Skip to content

Commit cc985e9

Browse files
fix(projects): report create failures in V1's words and refuse what the API would reject
Only a taken name was reported the way V1 did. Every other create failure surfaced as a bare "Error:" line, while V1 opened each with "New project creation failed!" and named the cause beneath it - "Launch project limit reached!", its 1KB-100MB file-size message, or the error's own text. Report them all that way, keeping each failure's exit code, and reword the create codes the CLI did not map: CREATE_FAILED, NAME.TOO_LONG, an expired upload, and a failed signed-URL request. A zip under 1KB was uploaded only for the storage provider to refuse it with a bare HTTP status. Every provider enforces management-service's 1KB-100MB range, so check the floor before uploading, as the ceiling already was. Names typed at the create prompts skipped the 200-character limit the flags enforce. Check it inside the prompt, as projects:update does, so a long name is answered again rather than failing the command after the upload. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent acd6d16 commit cc985e9

9 files changed

Lines changed: 272 additions & 57 deletions

‎src/projects/project.create.prompt.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,22 @@ function chosen(value: unknown): string {
2222
return String(value);
2323
}
2424

25-
export async function askText(ux: UxLike, message: string, initial?: string): Promise<string> {
26-
return chosen(await ux.inquire<string | undefined>({ type: 'input', name: 'value', message, default: initial }));
25+
export function checkLength(label: string, value: string, max: number): true | string {
26+
const length = value.trim().length;
27+
28+
return length > max ? `${label} must be ${max} characters or fewer; that value is ${length} characters.` : true;
29+
}
30+
31+
/**
32+
* A `max` is checked inside the prompt, the way projects:update checks its fields, so a value that is
33+
* too long is answered again on the spot rather than failing the command after the upload.
34+
*/
35+
export async function askText(ux: UxLike, message: string, initial?: string, max?: number): Promise<string> {
36+
const validate = max === undefined ? undefined : (value: string) => checkLength(message, value, max);
37+
38+
return chosen(
39+
await ux.inquire<string | undefined>({ type: 'input', name: 'value', message, default: initial, validate }),
40+
);
2741
}
2842

2943
export async function askOptionalText(ux: UxLike, message: string, initial?: string): Promise<string | undefined> {

‎src/projects/project.create.test.ts‎

Lines changed: 119 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import AdmZip from 'adm-zip';
2+
import { randomBytes } from 'node:crypto';
23
import { cliux } from '@contentstack/cli-utilities';
34
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
45
import { tmpdir } from 'node:os';
@@ -13,7 +14,12 @@ import { ApiErrorEntry, LaunchApiError } from '../transport/errors';
1314
import { GitConnectionMissingError } from '../git/git.errors';
1415
import { ApiSurface } from '../resources';
1516
import { CreateRequest, ProjectCreator, UPLOAD_PROGRESS_LABEL } from './project.create';
16-
import { DuplicateProjectNameError, PROJECT_ERROR_MESSAGES, UploadFailedError } from './project.errors';
17+
import {
18+
DuplicateProjectNameError,
19+
PROJECT_ERROR_MESSAGES,
20+
ProjectCreateFailedError,
21+
UploadFailedError,
22+
} from './project.errors';
1723

1824
jest.mock('./project.upload', () => ({
1925
...jest.requireActual('./project.upload'),
@@ -23,6 +29,9 @@ jest.mock('./project.upload', () => ({
2329
import * as archiveModule from './project.archive';
2430
import { MAX_UPLOAD_BYTES, UploadOptions, uploadArchive } from './project.upload';
2531

32+
// Random, so it zips past the 1 KB Launch's storage providers accept as the smallest upload.
33+
const SITE_PAGE = `<h1>site</h1><!-- ${randomBytes(2048).toString('hex')} -->`;
34+
2635
function fakeProgressBars() {
2736
const built: Record<string, unknown>[] = [];
2837
const calls: string[] = [];
@@ -324,7 +333,7 @@ function bodyOf(created: unknown[]): Record<string, unknown> {
324333
describe('ProjectCreator on the GitHub path', () => {
325334
beforeEach(() => {
326335
dataDir = mkdtempSync(join(tmpdir(), 'launch-create-'));
327-
writeFileSync(join(dataDir, 'index.html'), '<h1>site</h1>');
336+
writeFileSync(join(dataDir, 'index.html'), SITE_PAGE);
328337
cloneOf('my-org/my-repo');
329338
});
330339

@@ -622,7 +631,7 @@ describe('ProjectCreator on the GitHub path', () => {
622631
describe('ProjectCreator prompting order and refusals', () => {
623632
beforeEach(() => {
624633
dataDir = mkdtempSync(join(tmpdir(), 'launch-create-'));
625-
writeFileSync(join(dataDir, 'index.html'), '<h1>site</h1>');
634+
writeFileSync(join(dataDir, 'index.html'), SITE_PAGE);
626635
cloneOf('my-org/my-repo');
627636
});
628637

@@ -837,7 +846,7 @@ describe('ProjectCreator prompting order and refusals', () => {
837846
describe('ProjectCreator on the FileUpload path', () => {
838847
beforeEach(() => {
839848
dataDir = mkdtempSync(join(tmpdir(), 'launch-create-'));
840-
writeFileSync(join(dataDir, 'index.html'), '<h1>site</h1>');
849+
writeFileSync(join(dataDir, 'index.html'), SITE_PAGE);
841850
(uploadArchive as jest.Mock).mockClear();
842851
(uploadArchive as jest.Mock).mockImplementation(async () => undefined);
843852
});
@@ -1024,6 +1033,19 @@ describe('ProjectCreator on the FileUpload path', () => {
10241033
expect(asked).toEqual(['Choose a project type to proceed']);
10251034
});
10261035

1036+
it('refuses a folder that zips under 1 KB before asking for an upload url, rather than being refused by storage', async () => {
1037+
writeFileSync(join(dataDir, 'index.html'), '<h1>site</h1>');
1038+
const { creator, calls, created } = harness();
1039+
1040+
const failure = await creator.create(uploadRequest()).catch((error: Error) => error);
1041+
1042+
expect(failure).toBeInstanceOf(UsageError);
1043+
expect((failure as Error).message).toContain('under the 1 KB Launch accepts for a file upload');
1044+
expect(calls.signedUploadUrl).toEqual([]);
1045+
expect(uploadArchive).not.toHaveBeenCalled();
1046+
expect(created).toEqual([]);
1047+
});
1048+
10271049
it('leaves a --config file that lives inside the data dir out of the upload', async () => {
10281050
const configPath = join(dataDir, 'launch.json');
10291051
writeFileSync(configPath, JSON.stringify({ project: { organizationUid: ORG } }));
@@ -1163,7 +1185,7 @@ describe('ProjectCreator on the FileUpload path', () => {
11631185
describe('ProjectCreator waiting on the first deployment', () => {
11641186
beforeEach(() => {
11651187
dataDir = mkdtempSync(join(tmpdir(), 'launch-create-'));
1166-
writeFileSync(join(dataDir, 'index.html'), '<h1>site</h1>');
1188+
writeFileSync(join(dataDir, 'index.html'), SITE_PAGE);
11671189
cloneOf('my-org/my-repo');
11681190
});
11691191

@@ -1393,20 +1415,27 @@ describe('ProjectCreator waiting on the first deployment', () => {
13931415
expect((failure as Error).message).not.toContain('undefined');
13941416
});
13951417

1396-
it('propagates a create failure without waiting on anything', async () => {
1397-
const boom = new UsageError('A project with that name already exists in this organization.');
1398-
const { creator, printed } = harness({ createFails: boom });
1418+
it('reports a create failure under V1\'s header without waiting on anything', async () => {
1419+
const boom = new UsageError('Project name contains characters that are not allowed.');
1420+
const { creator, printed, calls } = harness({ createFails: boom });
13991421

1400-
await expect(creator.create(gitRequest())).rejects.toBe(boom);
1401-
expect(printed).toEqual([]);
1422+
const failure = await creator.create(gitRequest()).catch((error: Error) => error);
1423+
1424+
expect(failure).toBeInstanceOf(ProjectCreateFailedError);
1425+
expect((failure as ProjectCreateFailedError).exitCode).toBe(2);
1426+
expect(printed).toEqual([
1427+
'error: New project creation failed!',
1428+
'error: Project name contains characters that are not allowed.',
1429+
]);
1430+
expect(calls.environments).toEqual([]);
14021431
});
14031432
});
14041433

14051434
describe('ProjectCreator writing the project config', () => {
14061435
beforeEach(() => {
14071436
(uploadArchive as jest.Mock).mockImplementation(async () => undefined);
14081437
dataDir = mkdtempSync(join(tmpdir(), 'launch-create-'));
1409-
writeFileSync(join(dataDir, 'index.html'), '<h1>site</h1>');
1438+
writeFileSync(join(dataDir, 'index.html'), SITE_PAGE);
14101439
cloneOf('my-org/my-repo');
14111440
});
14121441

@@ -1512,13 +1541,41 @@ describe('ProjectCreator writing the project config', () => {
15121541

15131542
});
15141543

1544+
describe('ProjectCreator name prompts', () => {
1545+
beforeEach(() => {
1546+
dataDir = mkdtempSync(join(tmpdir(), 'launch-create-'));
1547+
writeFileSync(join(dataDir, 'index.html'), SITE_PAGE);
1548+
(uploadArchive as jest.Mock).mockClear();
1549+
(uploadArchive as jest.Mock).mockImplementation(async () => undefined);
1550+
});
1551+
1552+
afterEach(() => {
1553+
rmSync(dataDir, { recursive: true, force: true });
1554+
});
1555+
1556+
it.each([
1557+
['Project name', 200],
1558+
['Environment name', 200],
1559+
])('checks a typed %s against its %i-character limit inside the prompt', async (message, max) => {
1560+
const { creator, asked, askedPayloads } = harness({ isTTY: true, answers: ['My Site', 'Default'] });
1561+
1562+
await creator.create(uploadRequest({ name: undefined, envName: undefined, serverCmd: 'npm start' }));
1563+
1564+
const validate = askedPayloads[asked.indexOf(message)].validate as (value: string) => true | string;
1565+
expect(validate('x'.repeat(max))).toBe(true);
1566+
expect(validate('x'.repeat(max + 1))).toBe(
1567+
`${message} must be ${max} characters or fewer; that value is ${max + 1} characters.`,
1568+
);
1569+
});
1570+
});
1571+
15151572
describe('ProjectCreator when the project name is already taken', () => {
15161573
const duplicate = (): LaunchApiError =>
15171574
new LaunchApiError(409, [{ code: 'launch.PROJECT.DUPLICATE_NAME' }], PROJECT_ERROR_MESSAGES);
15181575

15191576
beforeEach(() => {
15201577
dataDir = mkdtempSync(join(tmpdir(), 'launch-create-'));
1521-
writeFileSync(join(dataDir, 'index.html'), '<h1>site</h1>');
1578+
writeFileSync(join(dataDir, 'index.html'), SITE_PAGE);
15221579
(uploadArchive as jest.Mock).mockClear();
15231580
(uploadArchive as jest.Mock).mockImplementation(async () => undefined);
15241581
});
@@ -1599,18 +1656,18 @@ describe('ProjectCreator when the project name is already taken', () => {
15991656
expect(created).toHaveLength(4);
16001657
});
16011658

1602-
it('refuses a new name over the length limit', async () => {
1603-
const { creator, created } = harness({
1659+
it('checks a new name against the length limit inside the prompt, so the user can fix it there', async () => {
1660+
const { creator, askedPayloads } = harness({
16041661
isTTY: true,
16051662
createFailures: [duplicate()],
1606-
answers: [true, 'x'.repeat(201)],
1663+
answers: [true, 'My Site 2'],
16071664
});
16081665

1609-
const failure = await creator.create(terminalRequest()).catch((error: Error) => error);
1666+
await creator.create(terminalRequest());
16101667

1611-
expect(failure).toBeInstanceOf(UsageError);
1612-
expect((failure as Error).message).toBe('--name must be 200 characters or fewer; that value is 201 characters.');
1613-
expect(created).toHaveLength(1);
1668+
const validate = askedPayloads[1].validate as (value: string) => true | string;
1669+
expect(validate('x'.repeat(200))).toBe(true);
1670+
expect(validate('x'.repeat(201))).toBe('Project name must be 200 characters or fewer; that value is 201 characters.');
16141671
});
16151672

16161673
it('colours the error lines red and the rename hint green only when stdout is a terminal', async () => {
@@ -1625,11 +1682,50 @@ describe('ProjectCreator when the project name is already taken', () => {
16251682
]);
16261683
});
16271684

1628-
it('lets any other create failure through untouched', async () => {
1685+
it.each([
1686+
['launch.PROJECT.LIMIT_REACHED', 'error: Launch project limit reached!'],
1687+
['launch.DEPLOYMENT.INVALID_FILE_SIZE', 'error: Please use a file over the size of 1KB and under the size of 100MB.'],
1688+
['launch.DEPLOYMENT.FILE_UPLOAD_FAILED', 'error: Please use a file over the size of 1KB and under the size of 100MB.'],
1689+
['launch.PROJECT.CREATE_FAILED', 'error: The Launch API could not create that project.'],
1690+
])('reports %s under V1\'s header in V1\'s words, exiting 1 without prompting', async (code, cause) => {
1691+
const boom = new LaunchApiError(422, [{ code }], PROJECT_ERROR_MESSAGES);
1692+
const { creator, asked, printed } = harness({ isTTY: true, createFailures: [boom] });
1693+
1694+
const failure = await creator.create(terminalRequest()).catch((error: Error) => error);
1695+
1696+
expect(failure).toBeInstanceOf(ProjectCreateFailedError);
1697+
expect((failure as ProjectCreateFailedError).exitCode).toBe(1);
1698+
expect((failure as ProjectCreateFailedError).reported).toBe(true);
1699+
expect(printed.slice(-2)).toEqual(['error: New project creation failed!', cause]);
1700+
expect(asked.filter((question) => String(question).includes('change the project'))).toEqual([]);
1701+
});
1702+
1703+
it('reports a failure carrying only a message by that message', async () => {
1704+
const boom = new LaunchApiError(500, [{ message: 'Something went wrong.' }], PROJECT_ERROR_MESSAGES);
1705+
const { creator, printed } = harness({ createFailures: [boom] });
1706+
1707+
await creator.create(uploadRequest()).catch(() => undefined);
1708+
1709+
expect(printed.slice(-2)).toEqual(['error: New project creation failed!', 'error: Something went wrong.']);
1710+
});
1711+
1712+
it('colours a reported create failure red only when stdout is a terminal', async () => {
16291713
const boom = new LaunchApiError(422, [{ code: 'launch.PROJECT.LIMIT_REACHED' }], PROJECT_ERROR_MESSAGES);
1630-
const { creator, asked } = harness({ isTTY: true, createFailures: [boom] });
1714+
const { creator, printed } = harness({ outputIsTTY: true, createFailures: [boom] });
16311715

1632-
await expect(creator.create(terminalRequest())).rejects.toBe(boom);
1633-
expect(asked).toEqual([]);
1716+
await creator.create(uploadRequest()).catch(() => undefined);
1717+
1718+
expect(printed.slice(-2)).toEqual([
1719+
'\u001b[31merror: New project creation failed!\u001b[39m',
1720+
'\u001b[31merror: Launch project limit reached!\u001b[39m',
1721+
]);
1722+
});
1723+
1724+
it('lets a failure that is not a Launch error through untouched, unreported', async () => {
1725+
const boom = new Error('a bug, not a Launch failure');
1726+
const { creator, printed } = harness({ createFailures: [boom] });
1727+
1728+
await expect(creator.create(uploadRequest())).rejects.toBe(boom);
1729+
expect(printed).not.toContain('error: New project creation failed!');
16341730
});
16351731
});

‎src/projects/project.create.ts‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MissingInputError, UsageError } from '../core/errors';
1+
import { LaunchError, MissingInputError, UsageError } from '../core/errors';
22
import { ProjectConfig, ProjectConfigStore } from '../core/project-config';
33
import { requireValueOf } from '../core/rules';
44
import type { ServiceContext } from '../core/service-context';
@@ -9,6 +9,7 @@ import { deploymentUrlOf } from '../deployments/deployment.presenter';
99
import { WatchTiming, watchDeployment } from '../deployments/deployment.watcher';
1010
import type { Deployment } from '../deployments/types';
1111
import {
12+
ENVIRONMENT_NAME_MAX_LENGTH,
1213
FRAMEWORK_CHOICES,
1314
FRAMEWORK_PRESET_BY_LABEL,
1415
OUTPUT_DIRECTORY_BY_FRAMEWORK,
@@ -25,7 +26,6 @@ import { gitConnectionLines } from '../git/git.presenter';
2526
import { detectGitHubRepository, LocalGitHubRepository } from '../git/local-repository';
2627
import { GIT_PROVIDER_GITHUB, GitRepository } from '../git/types';
2728
import { connectedAccountsUrl } from '../core/region';
28-
import { withinLength } from '../core/values';
2929
import { LaunchApiError } from '../transport/errors';
3030
import { archiveDirectory } from './project.archive';
3131
import {
@@ -42,8 +42,10 @@ import {
4242
RENAME_PROJECT_QUESTION,
4343
deploymentFailureMessage,
4444
deploymentUrlLine,
45-
duplicateProjectNameLines,
45+
createFailureCauseLine,
46+
duplicateProjectNameLine,
4647
gitOnlyFlagLine,
48+
projectCreationFailedLine,
4749
renameAndRerunLine,
4850
renameRetryLimitLine,
4951
} from './project.presenter';
@@ -56,8 +58,8 @@ import {
5658
askProjectType,
5759
projectTypeChoiceOf,
5860
} from './project.inputs';
59-
import { DuplicateProjectNameError } from './project.errors';
60-
import { refuseOversizedArchive, uploadArchive } from './project.upload';
61+
import { DuplicateProjectNameError, ProjectCreateFailedError } from './project.errors';
62+
import { refuseArchiveOutsideLimits, uploadArchive } from './project.upload';
6163
import type { CreateProjectInput, DetectedFramework, IdentifiedProject, SignedUploadUrl } from './types';
6264

6365
export { DEPLOYMENT_WAIT_TIMEOUT_MS, defaultWatchTiming } from '../deployments/deployment.watcher';
@@ -146,9 +148,11 @@ export class ProjectCreator {
146148
this.warnGitFlagsOffGitHub(request, choice);
147149
const autoDeploy = choice === 'GitHub' ? request.autoDeploy : undefined;
148150
const upload = choice === 'GitHub' ? undefined : await this.selectUploadSource(request);
149-
const name = await this.need('name', request.name, () => askText(this.services.ux, 'Project name'));
151+
const name = await this.need('name', request.name, () =>
152+
askText(this.services.ux, 'Project name', undefined, PROJECT_NAME_MAX_LENGTH),
153+
);
150154
const envName = await this.need('env-name', request.envName, () =>
151-
askText(this.services.ux, 'Environment name', DEFAULT_ENVIRONMENT_NAME),
155+
askText(this.services.ux, 'Environment name', DEFAULT_ENVIRONMENT_NAME, ENVIRONMENT_NAME_MAX_LENGTH),
152156
);
153157
const source = upload ?? (await this.selectGitSource(request));
154158
const framework = await this.selectFramework(request, source.detected);
@@ -215,16 +219,21 @@ export class ProjectCreator {
215219
try {
216220
return await this.services.api.projects.create({ org, input });
217221
} catch (error) {
218-
if (!(error instanceof LaunchApiError) || error.code !== DUPLICATE_PROJECT_NAME_CODE) {
222+
if (!(error instanceof LaunchError)) {
219223
throw error;
220224
}
221225

222226
const colour = this.services.outputIsTTY === true;
223227

224-
for (const line of duplicateProjectNameLines(colour)) {
225-
this.services.ux.print(line);
228+
this.services.ux.print(projectCreationFailedLine(colour));
229+
230+
if (!(error instanceof LaunchApiError) || error.code !== DUPLICATE_PROJECT_NAME_CODE) {
231+
this.services.ux.print(createFailureCauseLine(error, colour));
232+
throw new ProjectCreateFailedError(error);
226233
}
227234

235+
this.services.ux.print(duplicateProjectNameLine(colour));
236+
228237
if (this.services.isTTY) {
229238
return this.askToRename(org, input, renames, colour);
230239
}
@@ -256,7 +265,7 @@ export class ProjectCreator {
256265
throw new DuplicateProjectNameError();
257266
}
258267

259-
const name = await withinLength('name', await askText(this.services.ux, 'Project name'), PROJECT_NAME_MAX_LENGTH);
268+
const name = await askText(this.services.ux, 'Project name', undefined, PROJECT_NAME_MAX_LENGTH);
260269

261270
return this.createProject(org, { ...input, name }, renames + 1);
262271
}
@@ -482,7 +491,7 @@ export class ProjectCreator {
482491
private async selectUploadSource(request: CreateRequest): Promise<SourceSelection> {
483492
this.services.ux.print(PREPARING_ARCHIVE);
484493
const archive = archiveDirectory(request.dataDir, [request.configPath]);
485-
refuseOversizedArchive(archive.buffer.length);
494+
refuseArchiveOutsideLimits(archive.buffer.length);
486495

487496
if (archive.skippedLinks.length > 0) {
488497
this.services.ux.print(

0 commit comments

Comments
 (0)