From cd8a648bdaa156c30a9165fbc40de1bcaee5f1b4 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Wed, 8 Jul 2026 09:48:02 +0200 Subject: [PATCH 1/4] feat: add `apps:builds:share` command and `--share` flag on `apps:builds:create` --- src/commands/apps/builds/create.test.ts | 111 ++++++++++++++++++ src/commands/apps/builds/create.ts | 42 +++++++ src/commands/apps/builds/share.test.ts | 149 ++++++++++++++++++++++++ src/commands/apps/builds/share.ts | 96 +++++++++++++++ src/index.ts | 1 + src/services/app-builds.ts | 17 +++ src/types/app-build.ts | 16 +++ src/utils/app-build-shares.ts | 17 +++ 8 files changed, 449 insertions(+) create mode 100644 src/commands/apps/builds/create.test.ts create mode 100644 src/commands/apps/builds/share.test.ts create mode 100644 src/commands/apps/builds/share.ts create mode 100644 src/utils/app-build-shares.ts diff --git a/src/commands/apps/builds/create.test.ts b/src/commands/apps/builds/create.test.ts new file mode 100644 index 0000000..ed05184 --- /dev/null +++ b/src/commands/apps/builds/create.test.ts @@ -0,0 +1,111 @@ +import { DEFAULT_API_BASE_URL, DEFAULT_CONSOLE_BASE_URL } from '@/config/consts.js'; +import authorizationService from '@/services/authorization-service.js'; +import userConfig from '@/utils/user-config.js'; +import consola from 'consola'; +import nock from 'nock'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import createCommand from './create.js'; + +// Mock dependencies +vi.mock('@/utils/user-config.js'); +vi.mock('@/utils/prompt.js'); +vi.mock('@/services/authorization-service.js'); +vi.mock('@/utils/job.js'); +vi.mock('consola'); + +vi.mock('@/utils/environment.js', () => ({ + isInteractive: () => false, +})); + +describe('apps-builds-create', () => { + const mockUserConfig = vi.mocked(userConfig); + const mockAuthorizationService = vi.mocked(authorizationService); + const mockConsola = vi.mocked(consola); + + const testToken = 'test-token'; + const appId = '00000000-0000-0000-0000-000000000001'; + const buildId = '00000000-0000-0000-0000-000000000002'; + const shareId = 'share-abc'; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockUserConfig.read.mockReturnValue({ token: testToken }); + mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true); + mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken); + + // Mock waitForJobCompletion to resolve immediately as succeeded + const jobUtils = await import('@/utils/job.js'); + vi.mocked(jobUtils.waitForJobCompletion).mockResolvedValue({ + id: 'job-1', + status: 'succeeded', + createdAt: '2024-01-01T00:00:00Z', + }); + + vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`Process exited with code ${code}`); + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + nock.cleanAll(); + vi.restoreAllMocks(); + }); + + it('should create a share and merge it into the JSON output', async () => { + const options = { appId, platform: 'web' as const, gitRef: 'main', share: true, json: 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' }); + + const findScope = nock(DEFAULT_API_BASE_URL) + .get(`/v1/apps/${appId}/builds/${buildId}`) + .query({ relations: 'appBuildArtifacts' }) + .reply(200, { id: buildId, appBuildArtifacts: [] }); + + const shareScope = nock(DEFAULT_API_BASE_URL) + .post(`/v1/apps/${appId}/builds/${buildId}/shares`, {}) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(201, { + id: shareId, + appBuildId: buildId, + description: null, + expiresAt: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }); + + await createCommand.action(options, undefined); + + expect(buildScope.isDone()).toBe(true); + expect(findScope.isDone()).toBe(true); + expect(shareScope.isDone()).toBe(true); + + const url = `${DEFAULT_CONSOLE_BASE_URL}/app-build-shares/${shareId}`; + const qrCodeUrl = `${DEFAULT_API_BASE_URL}/v1/qrcodes?content=${encodeURIComponent(url)}`; + expect(console.log).toHaveBeenCalledWith( + JSON.stringify( + { + id: buildId, + numberAsString: '42', + share: { id: shareId, url, qrCodeUrl, expiresAt: null }, + }, + null, + 2, + ), + ); + }); + + it('should reject --share combined with --detached', async () => { + const options = { appId, platform: 'web' as const, gitRef: 'main', share: true, 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 --share. Sharing requires waiting for completion.', + ); + }); +}); diff --git a/src/commands/apps/builds/create.ts b/src/commands/apps/builds/create.ts index 6b5259d..37efb85 100644 --- a/src/commands/apps/builds/create.ts +++ b/src/commands/apps/builds/create.ts @@ -5,6 +5,7 @@ import appCertificatesService from '@/services/app-certificates.js'; import appEnvironmentsService from '@/services/app-environments.js'; import appsService from '@/services/apps.js'; import { AppBuildArtifactDto } from '@/types/app-build.js'; +import { getAppBuildShareUrls } from '@/utils/app-build-shares.js'; import { parseKeyValuePairs } from '@/utils/app-environments.js'; import { withAuth } from '@/utils/auth.js'; import { createBufferFromPath } from '@/utils/buffer.js'; @@ -66,6 +67,15 @@ export default defineCommand({ }) .optional() .describe('The platform for the build. Supported values are `ios`, `android`, and `web`.'), + share: z.boolean().optional().describe('Create a public share link for the build after it succeeds.'), + shareDescription: z + .string() + .optional() + .describe('Additional information for testers, e.g. what to test. Requires --share.'), + shareExpiresInDays: z.coerce + .number() + .optional() + .describe('Number of days until the share link expires. Requires --share.'), stack: z .enum(['macos-sequoia', 'macos-tahoe'], { message: 'Build stack must be either `macos-sequoia` or `macos-tahoe`.', @@ -116,6 +126,12 @@ export default defineCommand({ process.exit(1); } + // Validate that detached flag cannot be used with share + if (options.detached && options.share) { + consola.error('The --detached flag cannot be used with --share. Sharing requires waiting for completion.'); + process.exit(1); + } + // Validate that channel and destination cannot be used together if (options.channel && options.destination) { consola.error('The --channel and --destination flags cannot be used together.'); @@ -428,6 +444,31 @@ export default defineCommand({ filePath: typeof options.zip === 'string' ? options.zip : undefined, }); } + // Create a public share link if requested + let share: { id: string; url: string; qrCodeUrl: string; expiresAt: string | null } | undefined; + if (options.share) { + const expiresAt = options.shareExpiresInDays + ? new Date(Date.now() + options.shareExpiresInDays * 24 * 60 * 60 * 1000).toISOString() + : undefined; + const createdShare = await appBuildsService.createShare({ + appId, + appBuildId: response.id, + description: options.shareDescription, + expiresAt, + }); + const shareUrls = await getAppBuildShareUrls(createdShare.id); + share = { + id: createdShare.id, + url: shareUrls.url, + qrCodeUrl: shareUrls.qrCodeUrl, + expiresAt: createdShare.expiresAt, + }; + if (!json) { + consola.success('Build shared successfully.'); + consola.info(`Share URL: ${shareUrls.url}`); + } + } + // Output JSON if json flag is set if (json) { console.log( @@ -435,6 +476,7 @@ export default defineCommand({ { id: response.id, numberAsString: response.numberAsString, + ...(share ? { share } : {}), }, null, 2, diff --git a/src/commands/apps/builds/share.test.ts b/src/commands/apps/builds/share.test.ts new file mode 100644 index 0000000..58790f7 --- /dev/null +++ b/src/commands/apps/builds/share.test.ts @@ -0,0 +1,149 @@ +import { DEFAULT_API_BASE_URL, DEFAULT_CONSOLE_BASE_URL } from '@/config/consts.js'; +import authorizationService from '@/services/authorization-service.js'; +import userConfig from '@/utils/user-config.js'; +import consola from 'consola'; +import nock from 'nock'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import shareCommand from './share.js'; + +// Mock dependencies +vi.mock('@/utils/user-config.js'); +vi.mock('@/utils/prompt.js'); +vi.mock('@/services/authorization-service.js'); +vi.mock('consola'); + +vi.mock('@/utils/environment.js', () => ({ + isInteractive: () => false, +})); + +describe('apps-builds-share', () => { + const mockUserConfig = vi.mocked(userConfig); + const mockAuthorizationService = vi.mocked(authorizationService); + const mockConsola = vi.mocked(consola); + + const testToken = 'test-token'; + const appId = '00000000-0000-0000-0000-000000000001'; + const buildId = '00000000-0000-0000-0000-000000000002'; + const shareId = 'share-abc'; + + beforeEach(() => { + vi.clearAllMocks(); + + mockUserConfig.read.mockReturnValue({ token: testToken }); + mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true); + mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken); + + vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`Process exited with code ${code}`); + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + nock.cleanAll(); + vi.restoreAllMocks(); + }); + + it('should create a share and output the exact JSON shape', async () => { + const options = { appId, buildId, json: true }; + + const buildScope = nock(DEFAULT_API_BASE_URL) + .get(`/v1/apps/${appId}/builds/${buildId}`) + .query({ relations: 'job' }) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(200, { id: buildId, job: { id: 'job-1', status: 'succeeded' } }); + + const shareScope = nock(DEFAULT_API_BASE_URL) + .post(`/v1/apps/${appId}/builds/${buildId}/shares`, {}) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(201, { + id: shareId, + appBuildId: buildId, + description: null, + expiresAt: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }); + + await shareCommand.action(options, undefined); + + expect(buildScope.isDone()).toBe(true); + expect(shareScope.isDone()).toBe(true); + + const url = `${DEFAULT_CONSOLE_BASE_URL}/app-build-shares/${shareId}`; + const qrCodeUrl = `${DEFAULT_API_BASE_URL}/v1/qrcodes?content=${encodeURIComponent(url)}`; + expect(console.log).toHaveBeenCalledWith(JSON.stringify({ id: shareId, url, qrCodeUrl, expiresAt: null }, null, 2)); + expect(mockConsola.success).not.toHaveBeenCalled(); + }); + + it('should compute expiresAt from expiresInDays', async () => { + const expiresInDays = 7; + const options = { appId, buildId, expiresInDays, json: true }; + + nock(DEFAULT_API_BASE_URL) + .get(`/v1/apps/${appId}/builds/${buildId}`) + .query({ relations: 'job' }) + .reply(200, { id: buildId, job: { id: 'job-1', status: 'succeeded' } }); + + let capturedBody: { expiresAt?: string } = {}; + nock(DEFAULT_API_BASE_URL) + .post(`/v1/apps/${appId}/builds/${buildId}/shares`, (body) => { + capturedBody = body; + return true; + }) + .reply(201, { + id: shareId, + appBuildId: buildId, + description: null, + expiresAt: capturedBody.expiresAt ?? null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }); + + const before = Date.now(); + await shareCommand.action(options, undefined); + const after = Date.now(); + + expect(capturedBody.expiresAt).toBeDefined(); + const expiresAtMs = new Date(capturedBody.expiresAt as string).getTime(); + const millisPerDay = 24 * 60 * 60 * 1000; + expect(expiresAtMs).toBeGreaterThanOrEqual(before + expiresInDays * millisPerDay); + expect(expiresAtMs).toBeLessThanOrEqual(after + expiresInDays * millisPerDay); + }); + + it('should error when the build has not succeeded', async () => { + const options = { appId, buildId, json: true }; + + const buildScope = nock(DEFAULT_API_BASE_URL) + .get(`/v1/apps/${appId}/builds/${buildId}`) + .query({ relations: 'job' }) + .reply(200, { id: buildId, job: { id: 'job-1', status: 'in_progress' } }); + + await expect(shareCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1'); + + expect(buildScope.isDone()).toBe(true); + expect(mockConsola.error).toHaveBeenCalledWith( + 'The build has not succeeded yet. Only successful builds can be shared.', + ); + }); + + it('should require app ID in non-interactive mode', async () => { + const options = { buildId }; + + await expect(shareCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1'); + + expect(mockConsola.error).toHaveBeenCalledWith( + 'You must provide an app ID when running in non-interactive environment.', + ); + }); + + it('should require build ID in non-interactive mode', async () => { + const options = { appId }; + + await expect(shareCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1'); + + expect(mockConsola.error).toHaveBeenCalledWith( + 'You must provide a build ID when running in non-interactive environment.', + ); + }); +}); diff --git a/src/commands/apps/builds/share.ts b/src/commands/apps/builds/share.ts new file mode 100644 index 0000000..d268e94 --- /dev/null +++ b/src/commands/apps/builds/share.ts @@ -0,0 +1,96 @@ +import appBuildsService from '@/services/app-builds.js'; +import { getAppBuildShareUrls } from '@/utils/app-build-shares.js'; +import { withAuth } from '@/utils/auth.js'; +import { isInteractive } from '@/utils/environment.js'; +import { prompt, promptAppSelection, promptOrganizationSelection } from '@/utils/prompt.js'; +import { defineCommand, defineOptions } from '@robingenz/zli'; +import consola from 'consola'; +import { z } from 'zod'; + +export default defineCommand({ + description: 'Create a public share link for an app build.', + options: defineOptions( + z.object({ + appId: z + .uuid({ + message: 'App ID must be a UUID.', + }) + .optional() + .describe('App ID the build belongs to.'), + buildId: z + .uuid({ + message: 'Build ID must be a UUID.', + }) + .optional() + .describe('Build ID to share.'), + description: z.string().optional().describe('Additional information for testers, e.g. what to test.'), + expiresInDays: z.coerce.number().optional().describe('Number of days until the share link expires.'), + json: z.boolean().optional().describe('Output in JSON format.'), + }), + ), + action: withAuth(async (options) => { + let { appId, buildId } = options; + const { description, expiresInDays, json } = options; + + // Prompt for app ID if not provided + if (!appId) { + if (!isInteractive()) { + consola.error('You must provide an app ID when running in non-interactive environment.'); + process.exit(1); + } + const organizationId = await promptOrganizationSelection(); + appId = await promptAppSelection(organizationId); + } + + // Prompt for build ID if not provided + if (!buildId) { + if (!isInteractive()) { + consola.error('You must provide a build ID when running in non-interactive environment.'); + process.exit(1); + } + const builds = await appBuildsService.findAll({ appId }); + if (builds.length === 0) { + consola.error('No builds found for this app.'); + process.exit(1); + } + // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged + buildId = await prompt('Select the build you want to share:', { + type: 'select', + options: builds.map((build) => ({ + label: `Build #${build.numberAsString || build.id} (${build.platform} - ${build.type})`, + value: build.id, + })), + }); + if (!buildId) { + consola.error('You must select a build to share.'); + process.exit(1); + } + } + + // Ensure the build has succeeded before creating a share + const build = await appBuildsService.findOne({ appId, appBuildId: buildId, relations: 'job' }); + if (build.job?.status !== 'succeeded') { + consola.error('The build has not succeeded yet. Only successful builds can be shared.'); + process.exit(1); + } + + // Compute the expiration date if provided + const expiresAt = expiresInDays + ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString() + : undefined; + + const share = await appBuildsService.createShare({ appId, appBuildId: buildId, description, expiresAt }); + const { url, qrCodeUrl } = await getAppBuildShareUrls(share.id); + + if (json) { + console.log(JSON.stringify({ id: share.id, url, qrCodeUrl, expiresAt: share.expiresAt }, null, 2)); + } else { + consola.success('Build shared successfully.'); + consola.info(`Share URL: ${url}`); + consola.info(`QR Code URL: ${qrCodeUrl}`); + if (share.expiresAt) { + consola.info(`Expires At: ${share.expiresAt}`); + } + } + }), +}); diff --git a/src/index.ts b/src/index.ts index 6fa08d5..96b59bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ const config = defineConfig({ 'apps:builds:get': await import('@/commands/apps/builds/get.js').then((mod) => mod.default), 'apps:builds:list': await import('@/commands/apps/builds/list.js').then((mod) => mod.default), 'apps:builds:logs': await import('@/commands/apps/builds/logs.js').then((mod) => mod.default), + 'apps:builds:share': await import('@/commands/apps/builds/share.js').then((mod) => mod.default), 'apps:builds:download': await import('@/commands/apps/builds/download.js').then((mod) => mod.default), 'apps:bundles:create': await import('@/commands/apps/bundles/create.js').then((mod) => mod.default), 'apps:bundles:delete': await import('@/commands/apps/bundles/delete.js').then((mod) => mod.default), diff --git a/src/services/app-builds.ts b/src/services/app-builds.ts index 9a6ef4b..ed61157 100644 --- a/src/services/app-builds.ts +++ b/src/services/app-builds.ts @@ -1,7 +1,9 @@ import authorizationService from '@/services/authorization-service.js'; import { AppBuildDto, + AppBuildShareDto, CreateAppBuildDto, + CreateAppBuildShareDto, FindAllAppBuildsDto, FindOneAppBuildDto, UpdateAppBuildDto, @@ -16,6 +18,7 @@ export interface DownloadArtifactDto { export interface AppBuildsService { create(dto: CreateAppBuildDto): Promise; + createShare(dto: CreateAppBuildShareDto): Promise; findAll(dto: FindAllAppBuildsDto): Promise; findOne(dto: FindOneAppBuildDto): Promise; update(dto: UpdateAppBuildDto): Promise; @@ -39,6 +42,20 @@ class AppBuildsServiceImpl implements AppBuildsService { return response.data; } + async createShare(dto: CreateAppBuildShareDto): Promise { + const { appId, appBuildId, ...bodyData } = dto; + const response = await this.httpClient.post( + `/v1/apps/${appId}/builds/${appBuildId}/shares`, + bodyData, + { + headers: { + Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`, + }, + }, + ); + return response.data; + } + async findAll(dto: FindAllAppBuildsDto): Promise { const params: Record = {}; if (dto.limit !== undefined) { diff --git a/src/types/app-build.ts b/src/types/app-build.ts index ba8f22c..c15a270 100644 --- a/src/types/app-build.ts +++ b/src/types/app-build.ts @@ -37,6 +37,22 @@ export interface CreateAppBuildDto { type?: string; } +export interface AppBuildShareDto { + id: string; + appBuildId: string; + description: string | null; + expiresAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateAppBuildShareDto { + appId: string; + appBuildId: string; + description?: string; + expiresAt?: string; +} + export interface FindOneAppBuildDto { appId: string; appBuildId: string; diff --git a/src/utils/app-build-shares.ts b/src/utils/app-build-shares.ts new file mode 100644 index 0000000..20c2bb1 --- /dev/null +++ b/src/utils/app-build-shares.ts @@ -0,0 +1,17 @@ +import configService from '@/services/config.js'; + +export interface AppBuildShareUrls { + url: string; + qrCodeUrl: string; +} + +/** + * Build the public share page URL and the corresponding QR code image URL for an app build share. + */ +export const getAppBuildShareUrls = async (shareId: string): Promise => { + const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL'); + const apiBaseUrl = await configService.getValueForKey('API_BASE_URL'); + const url = `${consoleBaseUrl}/app-build-shares/${shareId}`; + const qrCodeUrl = `${apiBaseUrl}/v1/qrcodes?content=${encodeURIComponent(url)}`; + return { url, qrCodeUrl }; +}; From d331dfa00f2d9bb5fc07764c77878001a5815a99 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Fri, 10 Jul 2026 06:58:27 +0200 Subject: [PATCH 2/4] fix: validate share expiry days and reject share sub-flags without `--share` --- src/commands/apps/builds/create.test.ts | 51 +++++++++++++++++++++++++ src/commands/apps/builds/create.ts | 17 +++++++-- src/commands/apps/builds/share.test.ts | 20 ++++++++++ src/commands/apps/builds/share.ts | 16 ++++++-- 4 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/commands/apps/builds/create.test.ts b/src/commands/apps/builds/create.test.ts index ed05184..26d422f 100644 --- a/src/commands/apps/builds/create.test.ts +++ b/src/commands/apps/builds/create.test.ts @@ -25,6 +25,7 @@ describe('apps-builds-create', () => { const testToken = 'test-token'; const appId = '00000000-0000-0000-0000-000000000001'; const buildId = '00000000-0000-0000-0000-000000000002'; + const validAppId = '11111111-1111-4111-8111-111111111111'; const shareId = 'share-abc'; beforeEach(async () => { @@ -108,4 +109,54 @@ describe('apps-builds-create', () => { 'The --detached flag cannot be used with --share. Sharing requires waiting for completion.', ); }); + + it('should reject --share-description without --share', async () => { + const options = { appId, platform: 'web' as const, gitRef: 'main', shareDescription: 'What to test' }; + + await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1'); + + expect(mockConsola.error).toHaveBeenCalledWith( + 'The --share-description and --share-expires-in-days flags require --share.', + ); + }); + + it('should reject --share-expires-in-days without --share', async () => { + const options = { appId, platform: 'web' as const, gitRef: 'main', shareExpiresInDays: 7 }; + + await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1'); + + expect(mockConsola.error).toHaveBeenCalledWith( + 'The --share-description and --share-expires-in-days flags require --share.', + ); + }); + + it('should reject a non-positive shareExpiresInDays value', () => { + const schema = createCommand.options?.schema; + + for (const shareExpiresInDays of [0, -1]) { + const result = schema?.safeParse({ + appId: validAppId, + platform: 'web', + gitRef: 'main', + share: true, + shareExpiresInDays, + }); + expect(result?.success).toBe(false); + expect(result?.error?.issues[0]?.message).toBe('Expires in days must be a positive integer.'); + } + }); + + it('should accept a positive shareExpiresInDays value', () => { + const schema = createCommand.options?.schema; + + const result = schema?.safeParse({ + appId: validAppId, + platform: 'web', + gitRef: 'main', + share: true, + shareExpiresInDays: 7, + }); + expect(result?.success).toBe(true); + expect(result?.data?.shareExpiresInDays).toBe(7); + }); }); diff --git a/src/commands/apps/builds/create.ts b/src/commands/apps/builds/create.ts index 37efb85..c2e43ce 100644 --- a/src/commands/apps/builds/create.ts +++ b/src/commands/apps/builds/create.ts @@ -74,6 +74,10 @@ export default defineCommand({ .describe('Additional information for testers, e.g. what to test. Requires --share.'), shareExpiresInDays: z.coerce .number() + .int() + .min(1, { + message: 'Expires in days must be a positive integer.', + }) .optional() .describe('Number of days until the share link expires. Requires --share.'), stack: z @@ -132,6 +136,12 @@ export default defineCommand({ process.exit(1); } + // Validate that share sub-flags cannot be used without share + if (!options.share && (options.shareDescription !== undefined || options.shareExpiresInDays !== undefined)) { + consola.error('The --share-description and --share-expires-in-days flags require --share.'); + process.exit(1); + } + // Validate that channel and destination cannot be used together if (options.channel && options.destination) { consola.error('The --channel and --destination flags cannot be used together.'); @@ -447,9 +457,10 @@ export default defineCommand({ // Create a public share link if requested let share: { id: string; url: string; qrCodeUrl: string; expiresAt: string | null } | undefined; if (options.share) { - const expiresAt = options.shareExpiresInDays - ? new Date(Date.now() + options.shareExpiresInDays * 24 * 60 * 60 * 1000).toISOString() - : undefined; + const expiresAt = + options.shareExpiresInDays !== undefined + ? new Date(Date.now() + options.shareExpiresInDays * 24 * 60 * 60 * 1000).toISOString() + : undefined; const createdShare = await appBuildsService.createShare({ appId, appBuildId: response.id, diff --git a/src/commands/apps/builds/share.test.ts b/src/commands/apps/builds/share.test.ts index 58790f7..44a9332 100644 --- a/src/commands/apps/builds/share.test.ts +++ b/src/commands/apps/builds/share.test.ts @@ -24,6 +24,8 @@ describe('apps-builds-share', () => { const testToken = 'test-token'; const appId = '00000000-0000-0000-0000-000000000001'; const buildId = '00000000-0000-0000-0000-000000000002'; + const validAppId = '11111111-1111-4111-8111-111111111111'; + const validBuildId = '22222222-2222-4222-8222-222222222222'; const shareId = 'share-abc'; beforeEach(() => { @@ -146,4 +148,22 @@ describe('apps-builds-share', () => { 'You must provide a build ID when running in non-interactive environment.', ); }); + + it('should reject a non-positive expiresInDays value', () => { + const schema = shareCommand.options?.schema; + + for (const expiresInDays of [0, -1]) { + const result = schema?.safeParse({ appId: validAppId, buildId: validBuildId, expiresInDays }); + expect(result?.success).toBe(false); + expect(result?.error?.issues[0]?.message).toBe('Expires in days must be a positive integer.'); + } + }); + + it('should accept a positive expiresInDays value', () => { + const schema = shareCommand.options?.schema; + + const result = schema?.safeParse({ appId: validAppId, buildId: validBuildId, expiresInDays: 7 }); + expect(result?.success).toBe(true); + expect(result?.data?.expiresInDays).toBe(7); + }); }); diff --git a/src/commands/apps/builds/share.ts b/src/commands/apps/builds/share.ts index d268e94..270cbc5 100644 --- a/src/commands/apps/builds/share.ts +++ b/src/commands/apps/builds/share.ts @@ -24,7 +24,14 @@ export default defineCommand({ .optional() .describe('Build ID to share.'), description: z.string().optional().describe('Additional information for testers, e.g. what to test.'), - expiresInDays: z.coerce.number().optional().describe('Number of days until the share link expires.'), + expiresInDays: z.coerce + .number() + .int() + .min(1, { + message: 'Expires in days must be a positive integer.', + }) + .optional() + .describe('Number of days until the share link expires.'), json: z.boolean().optional().describe('Output in JSON format.'), }), ), @@ -75,9 +82,10 @@ export default defineCommand({ } // Compute the expiration date if provided - const expiresAt = expiresInDays - ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString() - : undefined; + const expiresAt = + expiresInDays !== undefined + ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString() + : undefined; const share = await appBuildsService.createShare({ appId, appBuildId: buildId, description, expiresAt }); const { url, qrCodeUrl } = await getAppBuildShareUrls(share.id); From 590fd58d7c3208d258392d1b7e74e7b21368c236 Mon Sep 17 00:00:00 2001 From: Robin Genz Date: Sun, 12 Jul 2026 10:10:35 +0200 Subject: [PATCH 3/4] feat: use PNG format for share QR code URLs --- src/commands/apps/builds/create.test.ts | 2 +- src/commands/apps/builds/share.test.ts | 2 +- src/utils/app-build-shares.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/apps/builds/create.test.ts b/src/commands/apps/builds/create.test.ts index 26d422f..b139f09 100644 --- a/src/commands/apps/builds/create.test.ts +++ b/src/commands/apps/builds/create.test.ts @@ -86,7 +86,7 @@ describe('apps-builds-create', () => { expect(shareScope.isDone()).toBe(true); const url = `${DEFAULT_CONSOLE_BASE_URL}/app-build-shares/${shareId}`; - const qrCodeUrl = `${DEFAULT_API_BASE_URL}/v1/qrcodes?content=${encodeURIComponent(url)}`; + const qrCodeUrl = `${DEFAULT_API_BASE_URL}/v1/qrcodes?content=${encodeURIComponent(url)}&format=png`; expect(console.log).toHaveBeenCalledWith( JSON.stringify( { diff --git a/src/commands/apps/builds/share.test.ts b/src/commands/apps/builds/share.test.ts index 44a9332..de0db48 100644 --- a/src/commands/apps/builds/share.test.ts +++ b/src/commands/apps/builds/share.test.ts @@ -73,7 +73,7 @@ describe('apps-builds-share', () => { expect(shareScope.isDone()).toBe(true); const url = `${DEFAULT_CONSOLE_BASE_URL}/app-build-shares/${shareId}`; - const qrCodeUrl = `${DEFAULT_API_BASE_URL}/v1/qrcodes?content=${encodeURIComponent(url)}`; + const qrCodeUrl = `${DEFAULT_API_BASE_URL}/v1/qrcodes?content=${encodeURIComponent(url)}&format=png`; expect(console.log).toHaveBeenCalledWith(JSON.stringify({ id: shareId, url, qrCodeUrl, expiresAt: null }, null, 2)); expect(mockConsola.success).not.toHaveBeenCalled(); }); diff --git a/src/utils/app-build-shares.ts b/src/utils/app-build-shares.ts index 20c2bb1..a2fd711 100644 --- a/src/utils/app-build-shares.ts +++ b/src/utils/app-build-shares.ts @@ -12,6 +12,6 @@ export const getAppBuildShareUrls = async (shareId: string): Promise Date: Sun, 12 Jul 2026 12:37:59 +0200 Subject: [PATCH 4/4] feat: add `--build-number` option to `apps:builds:share` --- src/commands/apps/builds/share.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/commands/apps/builds/share.ts b/src/commands/apps/builds/share.ts index 270cbc5..3f742b5 100644 --- a/src/commands/apps/builds/share.ts +++ b/src/commands/apps/builds/share.ts @@ -23,6 +23,7 @@ export default defineCommand({ }) .optional() .describe('Build ID to share.'), + buildNumber: z.string().optional().describe('Build number to share (e.g., "1", "42").'), description: z.string().optional().describe('Additional information for testers, e.g. what to test.'), expiresInDays: z.coerce .number() @@ -37,7 +38,7 @@ export default defineCommand({ ), action: withAuth(async (options) => { let { appId, buildId } = options; - const { description, expiresInDays, json } = options; + const { buildNumber, description, expiresInDays, json } = options; // Prompt for app ID if not provided if (!appId) { @@ -49,6 +50,16 @@ export default defineCommand({ appId = await promptAppSelection(organizationId); } + // Convert build number to build ID if provided + if (!buildId && buildNumber) { + const builds = await appBuildsService.findAll({ appId, numberAsString: buildNumber }); + if (builds.length === 0) { + consola.error(`Build #${buildNumber} not found.`); + process.exit(1); + } + buildId = builds[0]?.id; + } + // Prompt for build ID if not provided if (!buildId) { if (!isInteractive()) {