Skip to content

Commit acd6d16

Browse files
feat(projects): warn on GitHub-only flags and offer a rename on a taken name
FileUpload projects have no repository, so --branch and --auto-deploy cannot apply to them. create refused --branch outright and sent --auto-deploy to the API without a word. Both now print "warn: --<flag> is not supported for FileUpload projects." and the project is created without them, whether the type came from --type or the prompt, in a terminal or not. A taken project name ended the command, after a FileUpload had already been zipped and uploaded. Report it the way V1 did - "New project creation failed!" and "Duplicate project name identified" - and in a terminal offer a new name and resend the same request, upload included, up to V1's three renames. Declining or running out of renames exits 1 as V1 did. Without a terminal V1 hung on its prompt; this never prompts there, saying to change the name and re-run instead. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8e3a754 commit acd6d16

11 files changed

Lines changed: 393 additions & 43 deletions

File tree

‎.talismanrc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ fileignoreconfig:
146146
- filename: src/core/layering.test.ts
147147
checksum: 7a62eca2d63801b98591267dcbaa9e2568b20e1732ab1c744263ddab7f0dbbe8
148148
- filename: src/commands/launch/projects/create.test.ts
149-
checksum: d271aa6c5a5067be9b27ab7c8d8f6dbde31a239783aeede7918ba49e3cefff72
149+
checksum: 65a8c28525aa8ade9af5f5356cf505d3ebe5fd0d6131ba9b758c709c2a34e242
150150
- filename: src/commands/launch/projects/delete.test.ts
151151
checksum: 029c034d3d38d8cb37901b0936a7d6b4525d5bedf8c7ce052984c9b6f9ceffec
152152
- filename: test/integration/projects-delete.test.ts

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,8 @@ USAGE
201201
FLAGS
202202
-c, --config=<value> Path to the local '.cs-launch.json' file
203203
-d, --data-dir=<value> Current working directory
204-
--auto-deploy=<value> Deploy on every push (enable | disable)
205-
--branch=<value> Git branch name
204+
--auto-deploy=<value> Deploy on every push, GitHub projects only (enable | disable)
205+
--branch=<value> Git branch name, GitHub projects only
206206
--build-cmd=<value> Build command
207207
--cs-auth=<value> Contentstack Authentication (enable | disable)
208208
--description=<value> Project description (255 characters or fewer)

‎src/commands/launch/projects/create.test.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
} from '../../../projects/project.create';
77

88
type WatchTiming = ReturnType<typeof defaultWatchTiming>;
9-
import { gitOnlyFlagRules } from '../../../projects/project.inputs';
109
import ProjectsCreate from './create';
1110

1211
function commandUnderTest(resolved: Record<string, unknown>, dataDir = '/tmp/site') {
@@ -94,9 +93,8 @@ describe('launch:projects:create', () => {
9493
await expect(commandUnderTest({}).run()).rejects.toBe(boom);
9594
});
9695

97-
it('declares the framework gate and the GitHub-only flag gates as rules, so a bad pairing costs no API call', () => {
98-
expect(ProjectsCreate.rules).toEqual([serverCommandFrameworkGate, ...gitOnlyFlagRules]);
99-
expect(gitOnlyFlagRules).toHaveLength(1);
96+
it('declares only the framework gate as a rule, leaving GitHub-only flags on FileUpload to a confirmation', () => {
97+
expect(ProjectsCreate.rules).toEqual([serverCommandFrameworkGate]);
10098
});
10199

102100
it('declares every flag it reads and requires only the organization', () => {

‎src/commands/launch/projects/create.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { LaunchCommand } from '../../../core/launch-command';
22
import { flagsFor, inputs } from '../../../core/inputs';
33
import { ProjectCreator, defaultWatchTiming, serverCommandFrameworkGate } from '../../../projects/project.create';
4-
import { gitOnlyFlagRules } from '../../../projects/project.inputs';
54

65
const createInputs = inputs({
76
org: { required: true },
@@ -35,7 +34,7 @@ export default class ProjectsCreate extends LaunchCommand<typeof createInputs> {
3534

3635
static flags = flagsFor(createInputs);
3736

38-
static rules = [serverCommandFrameworkGate, ...gitOnlyFlagRules];
37+
static rules = [serverCommandFrameworkGate];
3938

4039
async run(): Promise<void> {
4140
const resolved = this.resolved;

‎src/environments/environment.inputs.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,15 @@ export const environmentFlags = {
6767
'Name of the environment created with the project ' +
6868
`(${ENVIRONMENT_NAME_MAX_LENGTH} characters or fewer)`,
6969
}),
70-
branch: Flags.string({ description: 'Git branch name' }),
70+
branch: Flags.string({ description: 'Git branch name, GitHub projects only' }),
7171
framework: Flags.string({ description: `Framework preset (${FRAMEWORK_CHOICES.join(' | ')})` }),
7272
'build-cmd': Flags.string({ description: 'Build command' }),
7373
'server-cmd': Flags.string({ description: 'Server command' }),
7474
'output-dir': Flags.string({ description: 'Output directory' }),
7575
'res-mode': Flags.string({ description: `Response mode (${RESPONSE_MODES.join(' | ')})` }),
76-
'auto-deploy': Flags.string({ description: `Deploy on every push (${TOGGLE_VALUES.join(' | ')})` }),
76+
'auto-deploy': Flags.string({
77+
description: `Deploy on every push, GitHub projects only (${TOGGLE_VALUES.join(' | ')})`,
78+
}),
7779
'cs-auth': Flags.string({ description: `Contentstack Authentication (${TOGGLE_VALUES.join(' | ')})` }),
7880
};
7981

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

Lines changed: 198 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { ApiErrorEntry, LaunchApiError } from '../transport/errors';
1313
import { GitConnectionMissingError } from '../git/git.errors';
1414
import { ApiSurface } from '../resources';
1515
import { CreateRequest, ProjectCreator, UPLOAD_PROGRESS_LABEL } from './project.create';
16-
import { UploadFailedError } from './project.errors';
16+
import { DuplicateProjectNameError, PROJECT_ERROR_MESSAGES, UploadFailedError } from './project.errors';
1717

1818
jest.mock('./project.upload', () => ({
1919
...jest.requireActual('./project.upload'),
@@ -88,6 +88,7 @@ interface Scenario {
8888
branches?: unknown[];
8989
detected?: unknown;
9090
createFails?: Error;
91+
createFailures?: Error[];
9192
repositoriesFails?: Error;
9293
launchAppUrl?: string;
9394
withoutOpenUrl?: boolean;
@@ -144,6 +145,12 @@ function harness(scenario: Scenario = {}) {
144145
throw scenario.createFails;
145146
}
146147

148+
const failure = scenario.createFailures?.shift();
149+
150+
if (failure) {
151+
throw failure;
152+
}
153+
147154
return scenario.createdProject ?? { uid: PROJECT_UID, name: 'My Site', projectType: 'GITPROVIDER' };
148155
},
149156
signedUploadUrl: async (params: unknown) => {
@@ -477,7 +484,7 @@ describe('ProjectCreator on the GitHub path', () => {
477484
expect(failure).toBeInstanceOf(UsageError);
478485
expect((failure as Error).message).toBe(
479486
`No GitHub repository was found in ${notAClone}. Run this command from a GitHub working copy, ` +
480-
'or pass --data-dir with the folder holding one.',
487+
'or pass --data-dir with the folder holding one.',
481488
);
482489
expect(asked).toEqual([]);
483490
});
@@ -492,8 +499,8 @@ describe('ProjectCreator on the GitHub path', () => {
492499
expect(failure).toBeInstanceOf(UsageError);
493500
expect((failure as Error).message).toBe(
494501
`The GitHub repository "my-org/my-repo" checked out in ${dataDir} is not available to this ` +
495-
'organization\'s connected GitHub account: Something went wrong. ' +
496-
'Connect it in the Launch app, or pass --data-dir with a folder whose repository is connected.',
502+
'organization\'s connected GitHub account: Something went wrong. ' +
503+
'Connect it in the Launch app, or pass --data-dir with a folder whose repository is connected.',
497504
);
498505
});
499506

@@ -605,8 +612,8 @@ describe('ProjectCreator on the GitHub path', () => {
605612
expect(failure).toBeInstanceOf(UsageError);
606613
expect((failure as Error).message).toBe(
607614
`The GitHub repository "other-org/missing-repo" checked out in ${dataDir} is not available to this ` +
608-
'organization\'s connected GitHub account: no repository with that name was found. ' +
609-
'Connect it in the Launch app, or pass --data-dir with a folder whose repository is connected.',
615+
'organization\'s connected GitHub account: no repository with that name was found. ' +
616+
'Connect it in the Launch app, or pass --data-dir with a folder whose repository is connected.',
610617
);
611618
});
612619

@@ -867,6 +874,62 @@ describe('ProjectCreator on the FileUpload path', () => {
867874
expect(printed.join('\n')).not.toContain('Uploading');
868875
});
869876

877+
it('warns that --auto-deploy is not supported and creates the project without it, asking nothing', async () => {
878+
const { creator, created, printed, asked } = harness({ isTTY: true });
879+
880+
await creator.create(uploadRequest({ autoDeploy: 'enable' }));
881+
882+
expect(printed).toContain('warn: --auto-deploy is not supported for FileUpload projects.');
883+
expect(asked.filter((question) => String(question).includes('proceed'))).toEqual([]);
884+
expect(bodyOf(created).environment).not.toHaveProperty('autoDeployOnPush');
885+
});
886+
887+
it('warns that --branch is not supported and creates the project without one', async () => {
888+
const { creator, created, printed } = harness();
889+
890+
await creator.create(uploadRequest({ branch: 'main' }));
891+
892+
expect(printed).toContain('warn: --branch is not supported for FileUpload projects.');
893+
expect(bodyOf(created).environment).toMatchObject({ gitBranch: undefined });
894+
});
895+
896+
it('warns once per unsupported flag, before zipping or uploading anything', async () => {
897+
const { creator, printed } = harness();
898+
899+
await creator.create(uploadRequest({ branch: 'main', autoDeploy: 'disable' }));
900+
901+
expect(printed.slice(0, 3)).toEqual([
902+
'warn: --branch is not supported for FileUpload projects.',
903+
'warn: --auto-deploy is not supported for FileUpload projects.',
904+
'Preparing zip file...',
905+
]);
906+
});
907+
908+
it('warns and carries on without an interactive terminal too', async () => {
909+
const { creator, created, printed } = harness({ isTTY: false });
910+
911+
await creator.create(uploadRequest({ autoDeploy: 'enable' }));
912+
913+
expect(printed).toContain('warn: --auto-deploy is not supported for FileUpload projects.');
914+
expect(created).toHaveLength(1);
915+
});
916+
917+
it('warns about --branch when FileUpload was chosen at the type prompt', async () => {
918+
const { creator, printed } = harness({ isTTY: true, answers: ['FileUpload'] });
919+
920+
await creator.create(uploadRequest({ type: undefined, branch: 'main' }));
921+
922+
expect(printed).toContain('warn: --branch is not supported for FileUpload projects.');
923+
});
924+
925+
it('colours the warning yellow only when stdout is a terminal', async () => {
926+
const { creator, printed } = harness({ outputIsTTY: true });
927+
928+
await creator.create(uploadRequest({ autoDeploy: 'enable' }));
929+
930+
expect(printed).toContain('\u001b[33mwarn: --auto-deploy is not supported for FileUpload projects.\u001b[39m');
931+
});
932+
870933
it.each([[true], [false], [undefined]])(
871934
'never spins for the zip, whose step says so in a line of its own (output terminal: %s)',
872935
async (outputIsTTY) => {
@@ -1165,10 +1228,10 @@ describe('ProjectCreator waiting on the first deployment', () => {
11651228
expect((failure as DeploymentUnsuccessfulError).exitCode).toBe(1);
11661229
expect((failure as Error).message).toBe(
11671230
'The deployment did not succeed; its last status was FAILED. ' +
1168-
'The project "My Site" (p1) and its environment "Default" were created and have not been rolled back. ' +
1169-
'Run csdx launch:deployments:create --org org1 --project p1 --env e1 to try the deployment again, ' +
1170-
'or csdx launch:logs:get --org org1 --project p1 --env e1 --deployment d1 ' +
1171-
'to see why it did not succeed.',
1231+
'The project "My Site" (p1) and its environment "Default" were created and have not been rolled back. ' +
1232+
'Run csdx launch:deployments:create --org org1 --project p1 --env e1 to try the deployment again, ' +
1233+
'or csdx launch:logs:get --org org1 --project p1 --env e1 --deployment d1 ' +
1234+
'to see why it did not succeed.',
11721235
);
11731236
});
11741237

@@ -1407,7 +1470,7 @@ describe('ProjectCreator writing the project config', () => {
14071470
expect(failure).toBeInstanceOf(UsageError);
14081471
expect((failure as UsageError).message).toBe(
14091472
`This folder is already linked to the project other-project in ${configPathIn(dataDir)}. ` +
1410-
'To create a new project, remove that file or pass --config with a different path.',
1473+
'To create a new project, remove that file or pass --config with a different path.',
14111474
);
14121475
expect(created).toEqual([]);
14131476
expect(configFileIn(dataDir)).toEqual(existing);
@@ -1423,8 +1486,8 @@ describe('ProjectCreator writing the project config', () => {
14231486
expect(created).toHaveLength(1);
14241487
expect(printed).toContain(
14251488
`Could not record this project in ${configPathIn(dataDir)}: ` +
1426-
`The config file at '${configPathIn(dataDir)}' is not valid JSON. It was left unchanged. ` +
1427-
`Pass --org ${ORG} --project ${PROJECT_UID} explicitly when you run Launch commands in this folder.`,
1489+
`The config file at '${configPathIn(dataDir)}' is not valid JSON. It was left unchanged. ` +
1490+
`Pass --org ${ORG} --project ${PROJECT_UID} explicitly when you run Launch commands in this folder.`,
14281491
);
14291492
expect(printed.some((line) => line.includes(PROJECT_UID))).toBe(true);
14301493
});
@@ -1448,3 +1511,125 @@ describe('ProjectCreator writing the project config', () => {
14481511
});
14491512

14501513
});
1514+
1515+
describe('ProjectCreator when the project name is already taken', () => {
1516+
const duplicate = (): LaunchApiError =>
1517+
new LaunchApiError(409, [{ code: 'launch.PROJECT.DUPLICATE_NAME' }], PROJECT_ERROR_MESSAGES);
1518+
1519+
beforeEach(() => {
1520+
dataDir = mkdtempSync(join(tmpdir(), 'launch-create-'));
1521+
writeFileSync(join(dataDir, 'index.html'), '<h1>site</h1>');
1522+
(uploadArchive as jest.Mock).mockClear();
1523+
(uploadArchive as jest.Mock).mockImplementation(async () => undefined);
1524+
});
1525+
1526+
afterEach(() => {
1527+
rmSync(dataDir, { recursive: true, force: true });
1528+
});
1529+
1530+
function terminalRequest(): CreateRequest {
1531+
return uploadRequest({ serverCmd: 'npm start' });
1532+
}
1533+
1534+
it('offers a new name in a terminal and retries with it, reusing the upload rather than zipping again', async () => {
1535+
const { creator, created, printed, asked } = harness({
1536+
isTTY: true,
1537+
createFailures: [duplicate()],
1538+
answers: [true, 'My Site 2'],
1539+
});
1540+
1541+
await creator.create(terminalRequest());
1542+
1543+
expect(printed).toEqual(expect.arrayContaining([
1544+
'error: New project creation failed!',
1545+
'error: Duplicate project name identified',
1546+
]));
1547+
expect(asked).toEqual(['Would you like to change the project\'s name and try again?', 'Project name']);
1548+
expect(created).toHaveLength(2);
1549+
expect(bodyOf(created.slice(1))).toMatchObject({ name: 'My Site 2', fileUpload: { uploadUid: 'upload-uid' } });
1550+
expect(uploadArchive).toHaveBeenCalledTimes(1);
1551+
});
1552+
1553+
it('prints the V1 lines and exits 1 with nothing more when the user does not want to rename', async () => {
1554+
const { creator, created, printed } = harness({ isTTY: true, createFailures: [duplicate()], answers: [false] });
1555+
1556+
const failure = await creator.create(terminalRequest()).catch((error: Error) => error);
1557+
1558+
expect(failure).toBeInstanceOf(DuplicateProjectNameError);
1559+
expect((failure as DuplicateProjectNameError).exitCode).toBe(1);
1560+
expect((failure as DuplicateProjectNameError).reported).toBe(true);
1561+
expect(printed.slice(-2)).toEqual([
1562+
'error: New project creation failed!',
1563+
'error: Duplicate project name identified',
1564+
]);
1565+
expect(created).toHaveLength(1);
1566+
});
1567+
1568+
it('prints the V1 lines and says to rename without a terminal, never prompting', async () => {
1569+
const { creator, created, printed, asked } = harness({ isTTY: false, createFailures: [duplicate()] });
1570+
1571+
const failure = await creator.create(uploadRequest()).catch((error: Error) => error);
1572+
1573+
expect(failure).toBeInstanceOf(DuplicateProjectNameError);
1574+
expect((failure as DuplicateProjectNameError).exitCode).toBe(1);
1575+
expect(printed.slice(-3)).toEqual([
1576+
'error: New project creation failed!',
1577+
'error: Duplicate project name identified',
1578+
'info: Change the project name and re-run the command.',
1579+
]);
1580+
expect(asked).toEqual([]);
1581+
expect(created).toHaveLength(1);
1582+
});
1583+
1584+
it('stops after three renames with the V1 retry-limit warning', async () => {
1585+
const { creator, created, printed } = harness({
1586+
isTTY: true,
1587+
createFailures: [duplicate(), duplicate(), duplicate(), duplicate()],
1588+
answers: [true, 'Two', true, 'Three', true, 'Four'],
1589+
});
1590+
1591+
const failure = await creator.create(terminalRequest()).catch((error: Error) => error);
1592+
1593+
expect(failure).toBeInstanceOf(DuplicateProjectNameError);
1594+
expect(printed.slice(-3)).toEqual([
1595+
'error: New project creation failed!',
1596+
'error: Duplicate project name identified',
1597+
'warn: Reached max project creation retry limit',
1598+
]);
1599+
expect(created).toHaveLength(4);
1600+
});
1601+
1602+
it('refuses a new name over the length limit', async () => {
1603+
const { creator, created } = harness({
1604+
isTTY: true,
1605+
createFailures: [duplicate()],
1606+
answers: [true, 'x'.repeat(201)],
1607+
});
1608+
1609+
const failure = await creator.create(terminalRequest()).catch((error: Error) => error);
1610+
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);
1614+
});
1615+
1616+
it('colours the error lines red and the rename hint green only when stdout is a terminal', async () => {
1617+
const { creator, printed } = harness({ outputIsTTY: true, createFailures: [duplicate()] });
1618+
1619+
await creator.create(uploadRequest()).catch(() => undefined);
1620+
1621+
expect(printed.slice(-3)).toEqual([
1622+
'\u001b[31merror: New project creation failed!\u001b[39m',
1623+
'\u001b[31merror: Duplicate project name identified\u001b[39m',
1624+
'\u001b[32minfo: Change the project name and re-run the command.\u001b[39m',
1625+
]);
1626+
});
1627+
1628+
it('lets any other create failure through untouched', async () => {
1629+
const boom = new LaunchApiError(422, [{ code: 'launch.PROJECT.LIMIT_REACHED' }], PROJECT_ERROR_MESSAGES);
1630+
const { creator, asked } = harness({ isTTY: true, createFailures: [boom] });
1631+
1632+
await expect(creator.create(terminalRequest())).rejects.toBe(boom);
1633+
expect(asked).toEqual([]);
1634+
});
1635+
});

0 commit comments

Comments
 (0)