diff --git a/src/commands/apps/builds/create.test.ts b/src/commands/apps/builds/create.test.ts index ec708b6..76cfaac 100644 --- a/src/commands/apps/builds/create.test.ts +++ b/src/commands/apps/builds/create.test.ts @@ -154,6 +154,75 @@ describe('apps-builds-create', () => { expect(mockConsola.error).not.toHaveBeenCalled(); }); + it('should send the channels as appChannelNames', async () => { + const options = { appId, platform: 'web' as const, gitRef: 'main', channel: ['beta', 'alpha'], detached: true }; + + const buildScope = nock(DEFAULT_API_BASE_URL) + .post( + `/v1/apps/${appId}/builds`, + (body) => JSON.stringify(body.appChannelNames) === JSON.stringify(['beta', 'alpha']), + ) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' }); + + await createCommand.action(options, undefined); + + expect(buildScope.isDone()).toBe(true); + expect(mockConsola.error).not.toHaveBeenCalled(); + }); + + it('should split comma-separated channels into separate appChannelNames', async () => { + const options = { appId, platform: 'web' as const, gitRef: 'main', channel: ['beta, alpha'], detached: true }; + + const buildScope = nock(DEFAULT_API_BASE_URL) + .post( + `/v1/apps/${appId}/builds`, + (body) => JSON.stringify(body.appChannelNames) === JSON.stringify(['beta', 'alpha']), + ) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' }); + + await createCommand.action(options, undefined); + + expect(buildScope.isDone()).toBe(true); + expect(mockConsola.error).not.toHaveBeenCalled(); + }); + + it('should accept --channel combined with --detached', async () => { + const options = { appId, platform: 'web' as const, gitRef: 'main', channel: ['beta'], detached: true }; + + const buildScope = nock(DEFAULT_API_BASE_URL) + .post(`/v1/apps/${appId}/builds`) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' }); + + await createCommand.action(options, undefined); + + expect(buildScope.isDone()).toBe(true); + expect(mockConsola.error).not.toHaveBeenCalled(); + }); + + it('should reject --detached combined with --destination', async () => { + const options = { appId, platform: 'ios' as const, gitRef: 'main', destination: 'testflight', detached: true }; + + await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1'); + + expect(mockConsola.error).toHaveBeenCalledWith('The --detached flag cannot be used with the --destination flag.'); + }); + + it('should parse a single channel into an array', () => { + const schema = createCommand.options?.schema; + + const result = schema?.safeParse({ + appId: validAppId, + platform: 'web', + gitRef: 'main', + channel: ['beta'], + }); + expect(result?.success).toBe(true); + expect(result?.data?.channel).toEqual(['beta']); + }); + it('should reject a non-positive shareExpiresInDays value', () => { const schema = createCommand.options?.schema; diff --git a/src/commands/apps/builds/create.ts b/src/commands/apps/builds/create.ts index a79e88f..53e653c 100644 --- a/src/commands/apps/builds/create.ts +++ b/src/commands/apps/builds/create.ts @@ -44,7 +44,10 @@ export default defineCommand({ .optional() .describe('App ID to create the build for.'), certificate: z.string().optional().describe('The name of the certificate to use for the build.'), - channel: z.string().optional().describe('The name of the channel to deploy to (Web only).'), + channel: z + .array(z.string()) + .optional() + .describe('The name of a channel to deploy to (Web only). Can be specified multiple times or comma-separated.'), configuration: z.string().optional().describe('The name of the native configuration (Android/iOS only).'), destination: z.string().optional().describe('The name of the destination to deploy to (Android/iOS only).'), detached: z @@ -126,15 +129,20 @@ export default defineCommand({ url, } = options; + const channels = options.channel + ?.flatMap((value) => value.split(',')) + .map((value) => value.trim()) + .filter((value) => value.length > 0); + // Validate that detached flag cannot be used with artifact flags if (options.detached && (options.apk || options.aab || options.ipa || options.zip)) { consola.error('The --detached flag cannot be used with --apk, --aab, --ipa, or --zip flags.'); process.exit(1); } - // Validate that detached flag cannot be used with channel or destination - if (options.detached && (options.channel || options.destination)) { - consola.error('The --detached flag cannot be used with --channel or --destination flags.'); + // Validate that detached flag cannot be used with destination + if (options.detached && options.destination) { + consola.error('The --detached flag cannot be used with the --destination flag.'); process.exit(1); } @@ -157,7 +165,7 @@ export default defineCommand({ } // Validate that channel and destination cannot be used together - if (options.channel && options.destination) { + if (channels?.length && options.destination) { consola.error('The --channel and --destination flags cannot be used together.'); process.exit(1); } @@ -282,7 +290,7 @@ export default defineCommand({ } // Validate that channel is only used with web platform - if (options.channel && platform !== 'web') { + if (channels?.length && platform !== 'web') { consola.error('The --channel flag can only be used with the web platform.'); process.exit(1); } @@ -423,6 +431,7 @@ export default defineCommand({ adHocEnvironmentVariables, appBuildSourceId, appCertificateName: certificate, + appChannelNames: channels, appConfigurationName: configuration, appEnvironmentName: environment, appId, @@ -435,6 +444,11 @@ export default defineCommand({ consola.info(`Build Number: ${response.numberAsString}`); consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`); consola.success('Build created successfully.'); + if (channels?.length) { + consola.info( + `The build will be deployed to the following ${channels.length === 1 ? 'channel' : 'channels'} once it succeeds: ${channels.join(', ')}.`, + ); + } // Wait for build job to complete by default, unless --detached flag is set const shouldWait = !options.detached; @@ -523,14 +537,11 @@ export default defineCommand({ } } - // Create deployment if channel or destination is set - if (options.channel || options.destination) { + // Create deployment if destination is set + if (options.destination) { await ( await import('@/commands/apps/deployments/create.js').then((mod) => mod.default) - ).action( - { appId, buildId: response.id, channel: options.channel, destination: options.destination }, - undefined, - ); + ).action({ appId, buildId: response.id, destination: options.destination }, undefined); } // Output JSON if json flag is set diff --git a/src/types/app-build.ts b/src/types/app-build.ts index b5c9e88..e94ef71 100644 --- a/src/types/app-build.ts +++ b/src/types/app-build.ts @@ -31,6 +31,7 @@ export interface CreateAppBuildDto { adHocEnvironmentVariables?: Record; appBuildSourceId?: string; appCertificateName?: string; + appChannelNames?: string[]; appConfigurationName?: string; appEnvironmentName?: string; appId: string;