From 5d66fd4c476298664c41b844085a3b9c6ca35f1c Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:13:59 +0200 Subject: [PATCH 01/10] feat(apple): read the build attached to each App Store version --- src/apple/ascClient.test.ts | 25 ++++++++++++++++++ src/apple/ascClient.ts | 46 +++++++++++++++++++++++++--------- src/core/types/appleCatalog.ts | 4 +++ 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/apple/ascClient.test.ts b/src/apple/ascClient.test.ts index cffcebea..3483c0a2 100644 --- a/src/apple/ascClient.test.ts +++ b/src/apple/ascClient.test.ts @@ -3392,4 +3392,29 @@ describe('AppStoreConnectClient - app store versions', () => { expect(expectArrayElement(versions, 0, 'version').attachedBuildId).toBe('b-42'); expect(expectArrayElement(versions, 1, 'version').attachedBuildId).toBeUndefined(); }); + + it('reads the attached build number and processing state from the included build', async () => { + fetchMock.mockResolvedValueOnce( + fakeResponse( + 200, + JSON.stringify({ + data: [ + { + id: 'v-edit', + attributes: { versionString: '1.2.0', appStoreState: 'PREPARE_FOR_SUBMISSION' }, + relationships: { build: { data: { type: 'builds', id: 'b-42' } } }, + }, + ], + included: [ + { type: 'builds', id: 'b-42', attributes: { version: '42', processingState: 'VALID' } }, + ], + }), + ), + ); + const versions = await client.listAppStoreVersions('app1', 'IOS'); + expect(expectArrayElement(versions, 0, 'version')).toMatchObject({ + attachedBuildNumber: '42', + attachedBuildProcessingState: 'VALID', + }); + }); }); diff --git a/src/apple/ascClient.ts b/src/apple/ascClient.ts index c212c935..cdeeebb4 100644 --- a/src/apple/ascClient.ts +++ b/src/apple/ascClient.ts @@ -558,8 +558,17 @@ type BuildPageIncluded = { id: string; attributes?: { version?: string; + processingState?: string; }; }; +/** One page of an app's App Store versions, read with `include=build` so each attached build resolves from `included`. */ +type AppStoreVersionPage = PagedList<{ + versionString?: string; + appStoreState?: string; + releaseType?: string; +}> & { + included?: BuildPageIncluded[]; +}; type BuildPage = { data: { id: string; @@ -898,15 +907,22 @@ export class AppStoreConnectClient { // A is the caller-specified attributes shape of the returned rows; no argument infers it, so the param is required. // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters private async requestAll(path: string): Promise[]> { - const all: AppleResource[] = []; + const pages = await this.requestPages>(path); + return pages.flatMap((page) => page.data); + } + /** Walk every page of a collection, keeping each raw page so its `included` sidebar survives. */ + private async requestPages( + path: string, + ): Promise { + const pages: Page[] = []; let next: string | undefined = path; while (next) { // biome-ignore lint/performance/noAwaitInLoops: cursor pagination - each page’s `next` link comes from the prior response, so the reads are inherently sequential - const page: PagedList = await this.request>('GET', next); - all.push(...page.data); + const page = await this.request('GET', next); + pages.push(page); next = page.links?.next; } - return all; + return pages; } /** * Run a read that may legitimately find nothing, mapping App Store Connect's `404` to `null` while @@ -4034,16 +4050,14 @@ export class AppStoreConnectClient { relationships: { build: { data: { type: 'builds', id: buildId } } }, }); } - /** List an app's App Store versions for a platform (e.g. `IOS`), across all pages. */ + /** List an app's App Store versions for a platform (e.g. `IOS`), across all pages, with each attached build's number and state. */ async listAppStoreVersions(appId: string, platform: string): Promise { - const appleResources = await this.requestAll<{ - versionString?: string; - appStoreState?: string; - releaseType?: string; - }>( - `/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=200&fields[appStoreVersions]=versionString,appStoreState,releaseType,build`, + const versionPages = await this.requestPages( + `/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=200&fields[appStoreVersions]=versionString,appStoreState,releaseType,build&include=build&fields[builds]=version,processingState`, + ); + return versionPages.flatMap(({ data: entries, included = [] }) => + entries.map((entry) => toVersionResource(entry, '', included)), ); - return appleResources.map((entry) => toVersionResource(entry)); } /** Create a new App Store version for a marketing version string on a platform. */ async createAppStoreVersion( @@ -4978,6 +4992,7 @@ const toVersionResource = ( releaseType?: string; }>, fallbackVersion = '', + includedBuilds: readonly BuildPageIncluded[] = [], ): AppStoreVersionResource => { let versionString = entry.attributes.versionString; if (versionString === undefined) versionString = fallbackVersion; @@ -4986,12 +5001,19 @@ const toVersionResource = ( const releaseType = entry.attributes.releaseType; const attachedBuildId = entry.relationships?.build?.data?.id; const isBuildAttached = attachedBuildId !== undefined; + const attachedBuild = includedBuilds.find( + (includedBuild) => includedBuild.type === 'builds' && includedBuild.id === attachedBuildId, + ); + const attachedBuildNumber = attachedBuild?.attributes?.version; + const attachedBuildProcessingState = attachedBuild?.attributes?.processingState; const version: AppStoreVersionResource = { id: entry.id, versionString, appStoreState, ...(releaseType !== undefined && { releaseType }), ...(isBuildAttached && { attachedBuildId }), + ...(attachedBuildNumber !== undefined && { attachedBuildNumber }), + ...(attachedBuildProcessingState !== undefined && { attachedBuildProcessingState }), }; return version; }; diff --git a/src/core/types/appleCatalog.ts b/src/core/types/appleCatalog.ts index 32b9b8f6..9a1d083f 100644 --- a/src/core/types/appleCatalog.ts +++ b/src/core/types/appleCatalog.ts @@ -455,6 +455,10 @@ export type AppStoreVersionResource = Readonly<{ releaseType?: string; /** Id of the build attached to this version; absent when no build is attached. */ attachedBuildId?: string; + /** The attached build's number (its `version` attribute); absent when no build is attached. */ + attachedBuildNumber?: string; + /** The attached build's processing state (`PROCESSING`, `VALID`, ...); absent when no build is attached. */ + attachedBuildProcessingState?: string; }>; /** One locale's editable version copy. Launch only ever writes `whatsNew` (the release notes). */ export type AppStoreVersionLocalizationResource = Readonly<{ From 1ce152209c22cbc271fc50348bcbb72dd2725491 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:14:00 +0200 Subject: [PATCH 02/10] refactor(terminal): move the table formatter out of build history --- src/core/build/buildHistoryCommand.ts | 35 +-------------------------- src/core/terminal/table.ts | 33 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 34 deletions(-) create mode 100644 src/core/terminal/table.ts diff --git a/src/core/build/buildHistoryCommand.ts b/src/core/build/buildHistoryCommand.ts index e86f2de2..80bea41b 100644 --- a/src/core/build/buildHistoryCommand.ts +++ b/src/core/build/buildHistoryCommand.ts @@ -18,6 +18,7 @@ import { buildLogId, buildLogPath, readBuildLog } from './buildLog.js'; import { sizeSummary, worstDownloadBytes } from './pipelineArtifact.js'; import { mb } from './pipelineProviders.js'; import { hasMessage } from '../services/errorMessage.js'; +import { type Column, formatTable } from '../terminal/table.js'; /** Stable presentation shape emitted by `builds list --json`. */ export type BuildRow = Readonly<{ @@ -185,40 +186,6 @@ export const buildKindLabel = (flags: Readonly<{ clean: boolean; prunedAt?: stri return 'incremental'; }; -/** A table column and its domain-specific cell renderer. */ -type Column = Readonly<{ - header: string; - cell: (tableEntry: TableEntry) => string; -}>; - -/** Render a non-empty collection as a padded text table. */ -const formatTable = ( - columns: readonly Column[], - tableEntries: readonly TableEntry[], -): string => { - const widths = columns.map((column) => - Math.max( - column.header.length, - ...tableEntries.map((tableEntry) => column.cell(tableEntry).length), - ), - ); - const renderCells = (cells: readonly string[]): string => - cells - .map((cell, columnIndex) => { - const width = widths[columnIndex]; - if (width === undefined) return cell; - return cell.padEnd(width); - }) - .join(' ') - .trimEnd(); - return [ - renderCells(columns.map((column) => column.header)), - ...tableEntries.map((tableEntry) => - renderCells(columns.map((column) => column.cell(tableEntry))), - ), - ].join('\n'); -}; - const BUILD_COLUMNS: readonly Column[] = [ { header: 'BUILD', cell: (buildRow) => String(buildRow.buildNumber) }, { header: 'APP', cell: (buildRow) => buildRow.app }, diff --git a/src/core/terminal/table.ts b/src/core/terminal/table.ts new file mode 100644 index 00000000..715bb6d4 --- /dev/null +++ b/src/core/terminal/table.ts @@ -0,0 +1,33 @@ +/** A table column and its domain-specific cell renderer. */ +export type Column = Readonly<{ + header: string; + cell: (tableEntry: TableEntry) => string; +}>; + +/** Render a non-empty collection as a padded text table. */ +export const formatTable = ( + columns: readonly Column[], + tableEntries: readonly TableEntry[], +): string => { + const widths = columns.map((column) => + Math.max( + column.header.length, + ...tableEntries.map((tableEntry) => column.cell(tableEntry).length), + ), + ); + const renderCells = (cells: readonly string[]): string => + cells + .map((cell, columnIndex) => { + const width = widths[columnIndex]; + if (width === undefined) return cell; + return cell.padEnd(width); + }) + .join(' ') + .trimEnd(); + return [ + renderCells(columns.map((column) => column.header)), + ...tableEntries.map((tableEntry) => + renderCells(columns.map((column) => column.cell(tableEntry))), + ), + ].join('\n'); +}; From 9d4d1446a6cc23dd7e44eacd3d10171811aae882 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:14:00 +0200 Subject: [PATCH 03/10] feat(release): report the attached build and fail on rejected or missing builds --- src/core/release/appStoreRelease.test.ts | 43 ++++++++++++++++++++-- src/core/release/appStoreRelease.ts | 47 +++++++++++++++--------- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/src/core/release/appStoreRelease.test.ts b/src/core/release/appStoreRelease.test.ts index 395667ca..7ca0f38d 100644 --- a/src/core/release/appStoreRelease.test.ts +++ b/src/core/release/appStoreRelease.test.ts @@ -500,12 +500,19 @@ describe('pickCurrentVersion', () => { }); describe('readReleaseStatus', () => { - it('classifies the current version and latest build', async () => { + it('classifies the current version and the build attached to it', async () => { const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn(() => - Effect.succeed([{ id: 'v2', versionString: '1.1.0', appStoreState: 'IN_REVIEW' }]), + Effect.succeed([ + { + id: 'v2', + versionString: '1.1.0', + appStoreState: 'IN_REVIEW', + attachedBuildNumber: '42', + attachedBuildProcessingState: 'VALID', + }, + ]), ), - listBuilds: vi.fn(() => Effect.succeed([VALID_BUILD])), getPhasedRelease: vi.fn(() => Effect.succeed({ id: 'ph-1', phasedReleaseState: 'ACTIVE' })), }); const releaseStatus = await Effect.runPromise( @@ -522,6 +529,18 @@ describe('readReleaseStatus', () => { }); }); + it('reports no build when the current version has none attached', async () => { + const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => + Effect.succeed([{ id: 'v2', versionString: '1.1.0', appStoreState: 'PREPARE_FOR_SUBMISSION' }]), + ), + }); + const releaseStatus = await Effect.runPromise( + readReleaseStatus(api, 'com.acme.app', IOS_PLATFORM), + ); + expect(releaseStatus).toMatchObject({ versionString: '1.1.0', buildNumber: null }); + }); + it('returns an empty preparing verdict when the app has no versions yet', async () => { const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn(() => Effect.succeed([])) }); const releaseStatus = await Effect.runPromise( @@ -584,6 +603,24 @@ describe('waitForValidBuild', () => { ).rejects.toThrow(/INVALID/); }); + it('throws on FAILED processing', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => + Effect.succeed({ id: 'b', version: '42', processingState: 'FAILED', expired: false }), + ), + }); + await expect( + Effect.runPromise(waitForValidBuild(api, 'app1', 42, { sleep: noSleep })), + ).rejects.toThrow(/FAILED/); + }); + + it('throws a not-found message when the build never appears before the timeout', async () => { + const api = makeReleaseApiFake({ findBuildByVersion: vi.fn(() => Effect.succeed(null)) }); + await expect( + Effect.runPromise(waitForValidBuild(api, 'app1', 42, { sleep: noSleep, timeoutMs: 0 })), + ).rejects.toThrow(/was not found on App Store Connect/); + }); + it('throws on timeout', async () => { const api = makeReleaseApiFake({ findBuildByVersion: vi.fn(() => diff --git a/src/core/release/appStoreRelease.ts b/src/core/release/appStoreRelease.ts index 3fe6a342..b40fdd0f 100644 --- a/src/core/release/appStoreRelease.ts +++ b/src/core/release/appStoreRelease.ts @@ -826,6 +826,7 @@ export type ReleaseStatus = { bundleId: string; versionString: string | null; appStoreState: string | null; + /** Number of the build attached to the current version; null when none is attached. */ buildNumber: string | null; buildProcessingState: string | null; phasedReleaseState: string | null; @@ -869,17 +870,16 @@ export const readReleaseStatus = ( verdict: classifyVerdict(''), }; } - const [phasedRelease, storeBuilds] = yield* Effect.all( - [appleReleaseApi.getPhasedRelease(currentVersion.id), appleReleaseApi.listBuilds(appId, 1)], - { concurrency: 2 }, - ).pipe(Effect.mapError((cause) => releaseFailure('read App Store release status', cause))); - let latestBuild: BuildResource | null = null; - if (storeBuilds[0] !== undefined) latestBuild = storeBuilds[0]; + const phasedRelease = yield* appleReleaseApi + .getPhasedRelease(currentVersion.id) + .pipe(Effect.mapError((cause) => releaseFailure('read App Store release status', cause))); let buildNumber: string | null = null; + if (currentVersion.attachedBuildNumber !== undefined) { + buildNumber = currentVersion.attachedBuildNumber; + } let buildProcessingState: string | null = null; - if (latestBuild !== null) { - buildNumber = latestBuild.version; - buildProcessingState = latestBuild.processingState; + if (currentVersion.attachedBuildProcessingState !== undefined) { + buildProcessingState = currentVersion.attachedBuildProcessingState; } let phasedReleaseState: string | null = null; if (phasedRelease !== null) { @@ -914,11 +914,11 @@ export type WaitOptions = { intervalMs?: number; timeoutMs?: number; sleep?: (milliseconds: number) => Effect.Effect; - onTick?: (state: string) => void; + onTick?: (processingState: string) => Effect.Effect; }; /** - * Poll a freshly uploaded build until processing reaches `VALID`. Fails on `INVALID` or timeout. + * Poll a freshly uploaded build until processing reaches `VALID`. Fails on `INVALID`, `FAILED` or timeout. * Returns the chosen build so the caller can hand it to {@link releaseApp}. */ export const waitForValidBuild = ( @@ -947,25 +947,36 @@ export const waitForValidBuild = ( processingState = reportedState; } } - if (options.onTick !== undefined) { - options.onTick(processingState); - } + if (options.onTick !== undefined) yield* options.onTick(processingState); if (storeBuild !== null && processingState === 'VALID') return storeBuild; - if (processingState === 'INVALID') { + const isProcessingRejected = processingState === 'INVALID' || processingState === 'FAILED'; + if (isProcessingRejected) { return yield* Effect.fail( releaseFailure( 'wait for App Store build', storeBuild, - `Build ${buildNumber} failed App Store Connect processing (INVALID) - check the email Apple sent.`, + `Build ${buildNumber} failed App Store Connect processing (${processingState}) - check the email Apple sent.`, ), ); } - if (waited >= timeoutMs) { + const isTimedOut = waited >= timeoutMs; + if (isTimedOut) { + const timeoutMinutes = Math.round(timeoutMs / 60_000); + const isBuildMissing = storeBuild === null; + if (isBuildMissing) { + return yield* Effect.fail( + releaseFailure( + 'wait for App Store build', + storeBuild, + `Build ${buildNumber} was not found on App Store Connect after ${timeoutMinutes} min - check that the upload finished.`, + ), + ); + } return yield* Effect.fail( releaseFailure( 'wait for App Store build', storeBuild, - `Build ${buildNumber} is still ${processingState} after ${Math.round(timeoutMs / 60_000)} min. Re-run \`launch release ios --build ${buildNumber}\` once \`launch status\` shows it VALID.`, + `Build ${buildNumber} is still ${processingState} after ${timeoutMinutes} min. Re-run \`launch release ios --build ${buildNumber}\` once \`launch status\` shows it VALID.`, ), ); } From 7db1dfaf31f973dfc6a115426a4163a05e49fde4 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:14:00 +0200 Subject: [PATCH 04/10] feat(credentials): load the account a flag names --- src/core/credentials/accounts.test.ts | 65 +++++++++++++++++++++++---- src/core/credentials/accounts.ts | 7 +++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/core/credentials/accounts.test.ts b/src/core/credentials/accounts.test.ts index 8cdcaf53..c3b602e4 100644 --- a/src/core/credentials/accounts.test.ts +++ b/src/core/credentials/accounts.test.ts @@ -74,6 +74,7 @@ import { getActiveKeyId, listAccounts, loadActiveAscKey, + loadAscKeyForAccount, loadAscKeyById, matchAccount, migrateLegacyAccounts, @@ -282,15 +283,16 @@ describe('registry mutations', () => { expect(await runAccountEffect(getActiveKeyId())).toBe('AAAA1111'); }); }); +const addTwoAccounts = async () => { + await runAccountEffect( + addAccount({ keyId: 'AAAA1111', issuerId: 'issuer-a', label: 'Personal', p8: PEM }), + ); + await runAccountEffect( + addAccount({ keyId: 'BBBB2222', issuerId: 'issuer-b', label: 'Acme', p8: PEM }), + ); +}; + describe('loadActiveAscKey', () => { - const addTwoAccounts = async () => { - await runAccountEffect( - addAccount({ keyId: 'AAAA1111', issuerId: 'issuer-a', label: 'Personal', p8: PEM }), - ); - await runAccountEffect( - addAccount({ keyId: 'BBBB2222', issuerId: 'issuer-b', label: 'Acme', p8: PEM }), - ); - }; it('loads the active account without ASC_ACCOUNT', async () => { await addTwoAccounts(); expect((await loadActiveKeyWith({}))?.keyId).toBe('BBBB2222'); @@ -307,6 +309,53 @@ describe('loadActiveAscKey', () => { ); }); }); +describe('loadAscKeyForAccount', () => { + const loadKeyForAccountWith = ( + accountSelector: string | undefined, + environmentVariables: Readonly>, + ) => + Effect.runPromise( + loadAscKeyForAccount(accountSelector).pipe( + Effect.provide(makeLaunchEnvironmentTest(environmentVariables).pipe(Layer.orDie)), + Effect.provide(NodeContext.layer), + Effect.provide(makeLaunchPathsTest(home.dir, home.dir)), + Effect.provide(makeLaunchSecretStoreTest()), + ), + ); + + it('loads the account the flag names, even when another account is active', async () => { + await addTwoAccounts(); + + const ascKey = await loadKeyForAccountWith('personal', {}); + + expect(ascKey?.keyId).toBe('AAAA1111'); + }); + + it('falls back to the active account when no flag is given', async () => { + await addTwoAccounts(); + + const ascKey = await loadKeyForAccountWith(undefined, {}); + + expect(ascKey?.keyId).toBe('BBBB2222'); + }); + + it('lets the flag win over ASC_ACCOUNT', async () => { + await addTwoAccounts(); + + const ascKey = await loadKeyForAccountWith('AAAA1111', { ASC_ACCOUNT: 'BBBB2222' }); + + expect(ascKey?.keyId).toBe('AAAA1111'); + }); + + it('fails when the flag matches no account', async () => { + await addTwoAccounts(); + + await expect(loadKeyForAccountWith('nobody', {})).rejects.toThrow( + /No Apple account matching.*nobody/, + ); + }); +}); + describe('migrateLegacyAccounts', () => { it("moves a legacy single key into the registry as the active 'default' account", async () => { secrets.store.set('asc-key-id', 'KEYXYZ7890'); diff --git a/src/core/credentials/accounts.ts b/src/core/credentials/accounts.ts index 5fc113c7..d153c655 100644 --- a/src/core/credentials/accounts.ts +++ b/src/core/credentials/accounts.ts @@ -366,6 +366,13 @@ export const loadActiveAscKey = (): Effect.Effect< if (active) return yield* loadAscKeyById(active); return null; }); +/** Load the key of the account a `--account` flag names; without the flag, the same as {@link loadActiveAscKey}. */ +export const loadAscKeyForAccount = (accountSelector: string | undefined) => + Effect.gen(function* () { + const isAccountSelected = accountSelector !== undefined; + if (isAccountSelected) return yield* loadSelectedAscKey(accountSelector); + return yield* loadActiveAscKey(); + }); /** * The decision tree for which account a build uses, factored out as a pure function so it's unit- * testable without the filesystem: an explicit selector wins (error if it matches nothing), else the From f15f23dc44065c03bee8a77a80c936da0b50e85c Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:14:00 +0200 Subject: [PATCH 05/10] feat(release): show builds in status and wait for a build to process --- src/cli/commands/status.ts | 12 +- src/core/release/statusCommand.test.ts | 189 ++++++++++++- src/core/release/statusCommand.ts | 357 ++++++++++++++++++++----- 3 files changed, 491 insertions(+), 67 deletions(-) diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 837b540f..1c0e15e8 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -1,16 +1,22 @@ import type { Command } from 'commander'; -import { statusCommandProgram, type StatusCommandInput } from '@core/release/statusCommand.js'; +import { statusCommandProgram, type StatusCommandOptions } from '@core/release/statusCommand.js'; import { runCliProgram } from '../runCliProgram.js'; /** Attach the App Store status command. */ export const registerStatusCommand = (program: Command): void => { program .command('status') - .description("show each app's App Store version, review, and phased-rollout state") + .description("show each app's App Store version, build, review, and phased-rollout state") .option('-a, --app ', 'comma-separated app handles (default: all iOS apps)') + .option( + '--account ', + 'iOS only - Apple account label or Key ID (default: ASC_ACCOUNT, then the active account)', + ) .option('--watch', 'poll until the review reaches a terminal verdict', false) + .option('--build ', 'with --watch: wait until this build is VALID, then exit') + .option('--builds', 'list each app recent builds: version, build, processing state, upload time') .option('--json', 'machine-readable output for CI', false) - .action((commandOptions: StatusCommandInput) => + .action((commandOptions: StatusCommandOptions) => runCliProgram(statusCommandProgram(commandOptions)), ); }; diff --git a/src/core/release/statusCommand.test.ts b/src/core/release/statusCommand.test.ts index e7406b18..3fbe59a1 100644 --- a/src/core/release/statusCommand.test.ts +++ b/src/core/release/statusCommand.test.ts @@ -1,14 +1,21 @@ import { Effect, Schema } from 'effect'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { NodeHttpClient } from '@effect/platform-node'; +import { makeReleaseApiFake } from '@testkit/ascReleaseApi.testkit.js'; import { createLogger, makeLaunchLoggerTest } from '../services/logger.js'; import type { AppDescriptor } from '../types/app.js'; -import { classifyVerdict, type ReleaseStatus } from './appStoreRelease.js'; +import type { BuildResource } from '../types/appleCatalog.js'; +import { type AscReleaseApi, classifyVerdict, type ReleaseStatus } from './appStoreRelease.js'; import type { MutableDeep } from '../types/mutable.js'; import { + findStatusUsageProblem, + formatRecentBuilds, formatStatusLine, + readRecentBuilds, selectIosApps, + type StatusCommandInput, StatusCommandInputSchema, + watchBuildProcessing, watchReleaseStatuses, worstExitCode, } from './statusCommand.js'; @@ -42,12 +49,35 @@ const discoveredApp = (appName: string, bundleId?: string): AppDescriptor => { return appDescriptor; }; +const makeBuild = (overrides: Partial = {}): BuildResource => ({ + id: 'b-1', + version: '42', + processingState: 'VALID', + expired: false, + ...overrides, +}); + +const plainInput: StatusCommandInput = { watch: false, json: false, builds: false }; + describe('StatusCommandInputSchema', () => { - it('defaults to one read with human output', async () => { + it('defaults to one read with human output and no build list', async () => { const commandInput = await Effect.runPromise( Schema.decodeUnknown(StatusCommandInputSchema)({}), ); - expect(commandInput).toEqual({ watch: false, json: false }); + expect(commandInput).toEqual({ watch: false, json: false, builds: false }); + }); + + it('reads --build text as a whole build number', async () => { + const commandInput = await Effect.runPromise( + Schema.decodeUnknown(StatusCommandInputSchema)({ build: '42', watch: true }), + ); + expect(commandInput.build).toBe(42); + }); + + it('rejects a --build that is not a whole number', async () => { + await expect( + Effect.runPromise(Schema.decodeUnknown(StatusCommandInputSchema)({ build: '4.5' })), + ).rejects.toThrow(); }); }); @@ -70,6 +100,12 @@ describe('formatStatusLine', () => { expect(statusLine).toContain('phased: ACTIVE'); }); + it('shows no build when the version has none attached', () => { + expect( + formatStatusLine(releaseStatus({ buildNumber: null, buildProcessingState: null })), + ).toBe('v1.2.0 - In review - no build'); + }); + it('handles an app with no App Store version yet', () => { expect( formatStatusLine( @@ -79,6 +115,151 @@ describe('formatStatusLine', () => { }); }); +describe('formatRecentBuilds', () => { + it('says so when the app has no uploaded builds', () => { + expect(formatRecentBuilds([])).toBe(' no builds uploaded yet'); + }); + + it('shows one build with its version, processing state, and UTC upload time', () => { + const table = formatRecentBuilds([ + makeBuild({ + version: '42', + marketingVersion: '1.2.0', + uploadedDate: '2026-11-01T09:30:00Z', + }), + ]); + expect(table).toContain('UPLOADED (UTC)'); + expect(table).toContain('1.2.0'); + expect(table).toContain('VALID'); + expect(table).toContain('2026-11-01 09:30'); + }); + + it('lists every build it is given, one row each', () => { + const table = formatRecentBuilds([ + makeBuild({ id: 'b-43', version: '43' }), + makeBuild({ id: 'b-42', version: '42' }), + ]); + expect(table).toContain('43'); + expect(table).toContain('42'); + }); +}); + +describe('readRecentBuilds', () => { + const selectedApp = { name: 'alpha', bundleId: 'com.acme.alpha' }; + + it('returns the builds newest upload first', async () => { + const api = makeReleaseApiFake({ + listBuilds: vi.fn(() => + Effect.succeed([ + makeBuild({ version: '41', uploadedDate: '2026-10-01T10:00:00Z' }), + makeBuild({ version: '43', uploadedDate: '2026-11-03T10:00:00Z' }), + makeBuild({ version: '42', uploadedDate: '2026-10-20T10:00:00Z' }), + ]), + ), + }); + const recentBuilds = await Effect.runPromise(readRecentBuilds(api, selectedApp)); + expect(recentBuilds.map((build) => build.version)).toEqual(['43', '42', '41']); + }); + + it('fails with the app record message when App Store Connect has no app for the bundle id', async () => { + const api = makeReleaseApiFake({ getAppId: vi.fn(() => Effect.succeed(null)) }); + await expect(Effect.runPromise(readRecentBuilds(api, selectedApp))).rejects.toThrow( + /No App Store Connect app record for com\.acme\.alpha/, + ); + }); +}); + +describe('watchBuildProcessing', () => { + const selectedApp = { name: 'alpha', bundleId: 'com.acme.alpha' }; + + const watchBuild = ( + appleClient: AscReleaseApi, + terminalWrites: string[], + pollOptions: { timeoutMs?: number } = {}, + ) => + Effect.runPromise( + Effect.gen(function* () { + const logger = yield* createLogger(false); + return yield* watchBuildProcessing( + { appleClient, selectedApp, buildNumber: 42, logger, isJson: false }, + { sleep: () => Effect.void, intervalMs: 1, ...pollOptions }, + ); + }).pipe( + Effect.provide(makeLaunchLoggerTest(terminalWrites)), + Effect.provide(NodeHttpClient.layer), + ), + ); + + it('returns the build once App Store Connect reports it VALID, and prints the state', async () => { + const terminalWrites: string[] = []; + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'VALID' }))), + }); + const storeBuild = await watchBuild(api, terminalWrites); + expect(storeBuild.version).toBe('42'); + expect(terminalWrites.join('')).toContain('build 42 is VALID'); + }); + + it('waits through PROCESSING and prints each state it sees', async () => { + const terminalWrites: string[] = []; + const findBuildByVersion = vi + .fn() + .mockReturnValueOnce(Effect.succeed(makeBuild({ processingState: 'PROCESSING' }))) + .mockReturnValueOnce(Effect.succeed(makeBuild({ processingState: 'VALID' }))); + const api = makeReleaseApiFake({ findBuildByVersion }); + await watchBuild(api, terminalWrites); + expect(terminalWrites.join('')).toContain('build 42 is PROCESSING'); + expect(terminalWrites.join('')).toContain('build 42 is VALID'); + }); + + it('fails on FAILED processing', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'FAILED' }))), + }); + await expect(watchBuild(api, [])).rejects.toThrow(/FAILED/); + }); + + it('fails on INVALID processing', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'INVALID' }))), + }); + await expect(watchBuild(api, [])).rejects.toThrow(/INVALID/); + }); + + it('fails with the not-found message when the build never appears before the timeout', async () => { + const api = makeReleaseApiFake({ findBuildByVersion: vi.fn(() => Effect.succeed(null)) }); + await expect(watchBuild(api, [], { timeoutMs: 0 })).rejects.toThrow( + /was not found on App Store Connect/, + ); + }); +}); + +describe('findStatusUsageProblem', () => { + const oneApp = [{ name: 'alpha', bundleId: 'com.acme.alpha' }]; + const twoApps = [...oneApp, { name: 'beta', bundleId: 'com.acme.beta' }]; + + it('returns null when the flags fit together', () => { + expect(findStatusUsageProblem(plainInput, oneApp)).toBeNull(); + expect(findStatusUsageProblem({ ...plainInput, watch: true, build: 42 }, oneApp)).toBeNull(); + }); + + it('asks for --watch when --build is given without it', () => { + expect(findStatusUsageProblem({ ...plainInput, build: 42 }, oneApp)).toMatch(/needs --watch/); + }); + + it('rejects --builds combined with --watch', () => { + expect(findStatusUsageProblem({ ...plainInput, watch: true, builds: true }, oneApp)).toMatch( + /--builds lists/, + ); + }); + + it('asks for one --app when --build is given for several apps', () => { + expect(findStatusUsageProblem({ ...plainInput, watch: true, build: 42 }, twoApps)).toMatch( + /pass --app/, + ); + }); +}); + describe('worstExitCode - error > rejected > in-progress > ok', () => { it('picks the worst code in the batch', () => { expect(worstExitCode([0, 0])).toBe(0); diff --git a/src/core/release/statusCommand.ts b/src/core/release/statusCommand.ts index cb0d95ed..84726122 100644 --- a/src/core/release/statusCommand.ts +++ b/src/core/release/statusCommand.ts @@ -1,8 +1,9 @@ import { Data, Effect, Schema } from 'effect'; import { loadConfig } from '../config/config.js'; -import { loadActiveAscKey } from '../credentials/accounts.js'; +import { loadAscKeyForAccount } from '../credentials/accounts.js'; import { AppleStoreClientService, + appRecordMissingMessage, type AppleStoreClientService as AppleStoreClientRequirements, } from '../services/appleStoreClient.js'; import { errorMessage } from '../services/errorMessage.js'; @@ -10,26 +11,48 @@ import { createLogger, type Logger } from '../services/logger.js'; import { notify } from '../services/notify.js'; import { LaunchPaths, type LaunchPathsService } from '../services/paths.js'; import { CommandExitSchema, completeCommand, type CommandExit } from '../terminal/commandExit.js'; +import { type Column, formatTable } from '../terminal/table.js'; +import { endWithUsageFailure, makeUsageFailure } from '../terminal/usageFailure.js'; import type { AppDescriptor } from '../types/app.js'; +import type { BuildResource } from '../types/appleCatalog.js'; import type { LaunchConfig } from '../types/config.js'; -import { IOS_PLATFORM, readReleaseStatus, type ReleaseStatus } from './appStoreRelease.js'; +import { + type AscReleaseApi, + IOS_PLATFORM, + readReleaseStatus, + type ReleaseStatus, + waitForValidBuild, + type WaitOptions, +} from './appStoreRelease.js'; import { createTransitionTracker, planTransitionNotifications } from './releaseNotify.js'; const WATCH_INTERVAL_MS = 30_000; export const StatusCommandInputSchema = Schema.Struct({ app: Schema.optionalWith(Schema.String, { exact: true }), + account: Schema.optionalWith(Schema.String, { exact: true }), watch: Schema.optionalWith(Schema.Boolean, { default: () => false }), + build: Schema.optionalWith(Schema.NumberFromString.pipe(Schema.int(), Schema.positive()), { + exact: true, + }), + builds: Schema.optionalWith(Schema.Boolean, { default: () => false }), json: Schema.optionalWith(Schema.Boolean, { default: () => false }), }); export type StatusCommandInput = Schema.Schema.Type; +/** The options as the CLI passes them, before decoding: `--build` arrives as text. */ +export type StatusCommandOptions = Schema.Schema.Encoded; + /** One discovered iOS app reduced to status-read identity. */ export type StatusApp = Readonly<{ name: string; bundleId: string }>; -/** One app and its latest App Store status. */ -export type AppReleaseStatus = Readonly<{ appName: string; releaseStatus: ReleaseStatus }>; +/** One app and its latest App Store status, with its recent builds when `--builds` asked for them. */ +export type AppReleaseStatus = Readonly<{ + appName: string; + releaseStatus: ReleaseStatus; + recentBuilds?: readonly BuildResource[]; +}>; /** Reading or watching App Store status failed. */ export type StatusCommandFailure = Readonly<{ @@ -48,7 +71,7 @@ const StatusCommandFailureSchema: Schema.Schema = Schema.S cause: Schema.Unknown, }); -type AccountRequirements = Effect.Effect.Context>; +type AccountRequirements = Effect.Effect.Context>; type NotifyRequirements = Effect.Effect.Context>; type StatusCommandRequirements = @@ -105,24 +128,26 @@ export const selectIosApps = ( return Effect.succeed(selectedApps); }; +/** The build attached to the version: `build 42`, with its state while not VALID, or `no build`. */ +const formatAttachedBuild = (releaseStatus: ReleaseStatus): string => { + const isNoBuildAttached = releaseStatus.buildNumber === null; + if (isNoBuildAttached) return 'no build'; + const isBuildStillProcessing = + releaseStatus.buildProcessingState !== null && releaseStatus.buildProcessingState !== 'VALID'; + if (isBuildStillProcessing) { + return `build ${releaseStatus.buildNumber} (${releaseStatus.buildProcessingState})`; + } + return `build ${releaseStatus.buildNumber}`; +}; + /** Format one human-readable App Store status line. */ export const formatStatusLine = (releaseStatus: ReleaseStatus): string => { - const statusParts: string[] = []; - if (releaseStatus.versionString === null) statusParts.push('no App Store version'); - else statusParts.push(`v${releaseStatus.versionString}`); - statusParts.push(releaseStatus.verdict.label); - if (releaseStatus.buildNumber !== null) { - let processingSuffix = ''; - if ( - releaseStatus.buildProcessingState !== null && - releaseStatus.buildProcessingState !== 'VALID' - ) { - processingSuffix = ` (${releaseStatus.buildProcessingState})`; - } - statusParts.push(`build ${releaseStatus.buildNumber}${processingSuffix}`); - } - if (releaseStatus.phasedReleaseState !== null) - statusParts.push(`phased: ${releaseStatus.phasedReleaseState}`); + const isVersionMissing = releaseStatus.versionString === null; + const versionPart = isVersionMissing ? 'no App Store version' : `v${releaseStatus.versionString}`; + const statusParts = [versionPart, releaseStatus.verdict.label]; + if (!isVersionMissing) statusParts.push(formatAttachedBuild(releaseStatus)); + const isPhasedReleaseKnown = releaseStatus.phasedReleaseState !== null; + if (isPhasedReleaseKnown) statusParts.push(`phased: ${releaseStatus.phasedReleaseState}`); return statusParts.join(' - '); }; @@ -149,33 +174,132 @@ export const worstExitCode = (exitCodes: readonly number[]): number => { return worstCode; }; +/** Find an iOS app's App Store id; fails when App Store Connect has no record of the app. */ +const readAppId = (appleClient: AscReleaseApi, selectedApp: StatusApp) => + Effect.gen(function* () { + const appId = yield* appleClient + .getAppId(selectedApp.bundleId) + .pipe( + Effect.mapError((cause) => statusFailure(`find App Store app for ${selectedApp.name}`, cause)), + ); + const isThereNoAppRecord = appId === null; + if (isThereNoAppRecord) { + return yield* Effect.fail( + statusFailure( + `find App Store app for ${selectedApp.name}`, + selectedApp.bundleId, + appRecordMissingMessage(selectedApp.bundleId, 'launch status'), + ), + ); + } + return appId; + }); + +/** Upload time in milliseconds; a build with no upload time counts as the oldest. */ +const uploadTimeMilliseconds = (build: BuildResource): number => { + const isUploadTimeMissing = build.uploadedDate === undefined; + if (isUploadTimeMissing) return 0; + return Date.parse(build.uploadedDate); +}; + +/** Read an app's most recent builds from App Store Connect, newest upload first. */ +export const readRecentBuilds = ( + appleClient: AscReleaseApi, + selectedApp: StatusApp, +): Effect.Effect => + Effect.gen(function* () { + const appId = yield* readAppId(appleClient, selectedApp); + const recentBuildCount = 25; + const storeBuilds = yield* appleClient + .listBuilds(appId, recentBuildCount) + .pipe(Effect.mapError((cause) => statusFailure(`list builds for ${selectedApp.name}`, cause))); + return [...storeBuilds].sort( + (firstBuild, secondBuild) => + uploadTimeMilliseconds(secondBuild) - uploadTimeMilliseconds(firstBuild), + ); + }); + +/** Upload time in UTC, to the minute, so the column reads the same on every machine. */ +const formatUploadTime = (uploadedDate: string | undefined): string => { + const isUploadTimeMissing = uploadedDate === undefined; + if (isUploadTimeMissing) return '-'; + return new Date(uploadedDate).toISOString().slice(0, 16).replace('T', ' '); +}; + +const RECENT_BUILD_COLUMNS: readonly Column[] = [ + { header: 'VERSION', cell: (build) => build.marketingVersion ?? '-' }, + { header: 'BUILD', cell: (build) => build.version }, + { header: 'PROCESSING', cell: (build) => build.processingState }, + { header: 'UPLOADED (UTC)', cell: (build) => formatUploadTime(build.uploadedDate) }, +]; + +/** Render an app's recent builds as a table, in the order given; says so when there are none. */ +export const formatRecentBuilds = (recentBuilds: readonly BuildResource[]): string => { + const isThereNoBuild = recentBuilds.length === 0; + if (isThereNoBuild) return ' no builds uploaded yet'; + return formatTable(RECENT_BUILD_COLUMNS, recentBuilds); +}; + +/** Read one app's status, plus its recent builds when they were asked for. */ +const readOneAppStatus = ( + appleClient: AscReleaseApi, + selectedApp: StatusApp, + shouldListBuilds: boolean, +): Effect.Effect => + Effect.gen(function* () { + const releaseStatus = yield* readReleaseStatus( + appleClient, + selectedApp.bundleId, + IOS_PLATFORM, + ).pipe( + Effect.mapError((cause) => statusFailure(`read App Store status for ${selectedApp.name}`, cause)), + ); + const isBuildListSkipped = !shouldListBuilds; + if (isBuildListSkipped) return { appName: selectedApp.name, releaseStatus }; + const recentBuilds = yield* readRecentBuilds(appleClient, selectedApp); + return { appName: selectedApp.name, releaseStatus, recentBuilds }; + }); + /** Read every selected app concurrently through the shared Apple transport. */ const readSelectedStatuses = ( - appleClient: Parameters[0], + appleClient: AscReleaseApi, selectedApps: readonly StatusApp[], + shouldListBuilds: boolean, ): Effect.Effect => Effect.forEach( selectedApps, - (selectedApp) => - readReleaseStatus(appleClient, selectedApp.bundleId, IOS_PLATFORM).pipe( - Effect.mapError((cause) => - statusFailure(`read App Store status for ${selectedApp.name}`, cause), - ), - Effect.map((releaseStatus) => ({ appName: selectedApp.name, releaseStatus })), - ), + (selectedApp) => readOneAppStatus(appleClient, selectedApp, shouldListBuilds), { concurrency: 'unbounded' }, ); +/** Print one app's status line, then its build table when `--builds` asked for it. */ +const printOneAppStatus = ( + logger: Logger, + appStatus: AppReleaseStatus, +): Effect.Effect => + Effect.gen(function* () { + yield* logger.step(appStatus.appName, formatStatusLine(appStatus.releaseStatus)); + const isBuildListPresent = appStatus.recentBuilds !== undefined; + if (!isBuildListPresent) return; + yield* logger.line(formatRecentBuilds(appStatus.recentBuilds)); + }); + /** Print one human-readable status batch. */ const printStatusBatch = ( logger: Logger, appStatuses: readonly AppReleaseStatus[], ): Effect.Effect => - Effect.forEach( - appStatuses, - (appStatus) => logger.step(appStatus.appName, formatStatusLine(appStatus.releaseStatus)), - { concurrency: 1, discard: true }, - ); + Effect.forEach(appStatuses, (appStatus) => printOneAppStatus(logger, appStatus), { + concurrency: 1, + discard: true, + }); + +/** The JSON row for one app: its release status, plus its recent builds when they were asked for. */ +const toStatusJsonRow = (appStatus: AppReleaseStatus) => { + const isBuildListPresent = appStatus.recentBuilds !== undefined; + if (!isBuildListPresent) return appStatus.releaseStatus; + return { ...appStatus.releaseStatus, recentBuilds: appStatus.recentBuilds }; +}; /** Poll until every selected app reaches a terminal verdict. */ export const watchReleaseStatuses = ( @@ -217,6 +341,95 @@ export const watchReleaseStatuses = ( }), ); +/** What `--watch --build` waits on: the app, the build number, and where progress is written. */ +export type BuildWatchRequest = Readonly<{ + appleClient: AscReleaseApi; + selectedApp: StatusApp; + buildNumber: number; + logger: Logger; + isJson: boolean; +}>; + +/** + * Poll one build until App Store Connect reports it VALID. Fails on FAILED or INVALID, and when the + * build is still missing or processing once the wait runs out. `--json` runs print no progress lines. + */ +export const watchBuildProcessing = ( + buildWatchRequest: BuildWatchRequest, + pollOptions: Omit = {}, +): Effect.Effect => + Effect.gen(function* () { + const { appleClient, selectedApp, buildNumber, logger, isJson } = buildWatchRequest; + const appId = yield* readAppId(appleClient, selectedApp); + const isProgressHidden = isJson; + const reportProgress = (processingState: string) => { + if (isProgressHidden) return Effect.void; + return logger + .step(selectedApp.name, `build ${buildNumber} is ${processingState}`) + .pipe(Effect.orDie); + }; + const storeBuild = yield* waitForValidBuild(appleClient, appId, buildNumber, { + ...pollOptions, + onTick: reportProgress, + }).pipe( + Effect.mapError((cause) => + statusFailure(`wait for build ${buildNumber} of ${selectedApp.name}`, cause), + ), + ); + return storeBuild; + }); + +/** Print the watched build's outcome: a VALID result as a line or JSON object, or the failure as JSON. */ +const reportBuildWatchOutcome = ( + buildWatchRequest: BuildWatchRequest, + storeBuild: BuildResource, +): Effect.Effect => + Effect.gen(function* () { + const { logger, selectedApp, buildNumber, isJson } = buildWatchRequest; + if (isJson) { + const buildOutcome = { + appName: selectedApp.name, + buildNumber: storeBuild.version, + processingState: storeBuild.processingState, + }; + yield* logger.line(JSON.stringify(buildOutcome, null, 2)).pipe(Effect.orDie); + return; + } + yield* logger + .step(selectedApp.name, `build ${buildNumber} is ${storeBuild.processingState} - ready to release`) + .pipe(Effect.orDie); + }); + +/** Print a failed build wait as JSON when `--json` asked for machine output; the command still fails. */ +const reportBuildWatchFailure = ( + buildWatchRequest: BuildWatchRequest, + watchFailure: StatusCommandFailure, +): Effect.Effect => + Effect.gen(function* () { + const { logger, selectedApp, buildNumber, isJson } = buildWatchRequest; + if (!isJson) return; + const failureOutcome = { + appName: selectedApp.name, + buildNumber, + error: watchFailure.message, + }; + yield* logger.line(JSON.stringify(failureOutcome, null, 2)).pipe(Effect.orDie); + }); + +/** Explain a flag combination the status command cannot run, or return null when the flags fit together. */ +export const findStatusUsageProblem = ( + commandInput: StatusCommandInput, + selectedApps: readonly StatusApp[], +): string | null => { + const isBuildWithoutWatch = commandInput.build !== undefined && !commandInput.watch; + if (isBuildWithoutWatch) return '--build needs --watch: it waits for that build to finish processing.'; + const isBuildsWithWatch = commandInput.builds && commandInput.watch; + if (isBuildsWithWatch) return '--builds lists builds without waiting: drop --watch to list them.'; + const isBuildForSeveralApps = commandInput.build !== undefined && selectedApps.length !== 1; + if (isBuildForSeveralApps) return '--build waits on one build of one app: pass --app .'; + return null; +}; + /** Run the schema-decoded App Store status command. */ export const statusCommandProgram = ( rawCommandInput: unknown, @@ -227,46 +440,70 @@ export const statusCommandProgram = ( const logger = yield* createLogger(false); const loadedConfiguration = yield* loadConfig(launchPaths.workingDirectory); const selectedApps = yield* selectIosApps(loadedConfiguration.apps, commandInput.app); - if (selectedApps.length === 0) { + const isThereNoSelectedApp = selectedApps.length === 0; + if (isThereNoSelectedApp) { yield* logger.note( 'No iOS apps discovered. Add an app with an ios.bundleIdentifier in app.json.', ); return; } - const ascKey = yield* loadActiveAscKey(); - if (ascKey === null) { + const usageProblem = findStatusUsageProblem(commandInput, selectedApps); + const isUsageProblemFound = usageProblem !== null; + if (isUsageProblemFound) { + return yield* endWithUsageFailure( + makeUsageFailure({ message: usageProblem }), + commandInput.json, + ); + } + const ascKey = yield* loadAscKeyForAccount(commandInput.account); + const isThereNoAscKey = ascKey === null; + if (isThereNoAscKey) { return yield* Effect.fail( statusFailure( - 'load active Apple account', - 'missing-active-account', - 'No active Apple account. Run `launch creds set-key` first.', + 'load Apple account', + 'missing-account', + 'No Apple account key found. Run `launch creds set-key`, or pick one with --account.', ), ); } const appleStoreClients = yield* AppleStoreClientService; const appleClient = yield* appleStoreClients.createClient(ascKey); - const readStatuses = () => readSelectedStatuses(appleClient, selectedApps); - let exitCode: number; - if (commandInput.watch && !commandInput.json) { - exitCode = yield* watchReleaseStatuses(readStatuses, logger, loadedConfiguration.config); - } else { - const appStatuses = yield* readStatuses(); - if (commandInput.json) { - yield* logger.line( - JSON.stringify( - appStatuses.map((appStatus) => appStatus.releaseStatus), - null, - 2, - ), - ); - } else { - yield* printStatusBatch(logger, appStatuses); - } - exitCode = worstExitCode( - appStatuses.map((appStatus) => appStatus.releaseStatus.verdict.exitCode), + const watchedBuildNumber = commandInput.build; + const [onlySelectedApp] = selectedApps; + const isBuildWatch = watchedBuildNumber !== undefined && onlySelectedApp !== undefined; + if (isBuildWatch) { + const buildWatchRequest: BuildWatchRequest = { + appleClient, + selectedApp: onlySelectedApp, + buildNumber: watchedBuildNumber, + logger, + isJson: commandInput.json, + }; + const storeBuild = yield* watchBuildProcessing(buildWatchRequest).pipe( + Effect.tapError((watchFailure) => reportBuildWatchFailure(buildWatchRequest, watchFailure)), ); + return yield* reportBuildWatchOutcome(buildWatchRequest, storeBuild); } - yield* completeCommand(exitCode); + const isWatchingInTerminal = commandInput.watch && !commandInput.json; + if (isWatchingInTerminal) { + const readStatuses = () => readSelectedStatuses(appleClient, selectedApps, false); + const watchExitCode = yield* watchReleaseStatuses( + readStatuses, + logger, + loadedConfiguration.config, + ); + return yield* completeCommand(watchExitCode); + } + const appStatuses = yield* readSelectedStatuses(appleClient, selectedApps, commandInput.builds); + const isJsonOutput = commandInput.json; + if (isJsonOutput) { + yield* logger.line(JSON.stringify(appStatuses.map(toStatusJsonRow), null, 2)); + } + if (!isJsonOutput) yield* printStatusBatch(logger, appStatuses); + const statusExitCode = worstExitCode( + appStatuses.map((appStatus) => appStatus.releaseStatus.verdict.exitCode), + ); + yield* completeCommand(statusExitCode); }).pipe( Effect.mapError((cause) => { if (Schema.is(CommandExitSchema)(cause)) return cause; From f4787823824ddebad1c83cda404d7a0823ac8b31 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:14:00 +0200 Subject: [PATCH 06/10] docs: regenerate CLI reference --- README.de.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- llms-full.txt | 4 ++-- llms.txt | 4 ++-- 11 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.de.md b/README.de.md index 9f5e9133..7ae167a0 100644 --- a/README.de.md +++ b/README.de.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2942 tests passing

diff --git a/README.es.md b/README.es.md index 8f8e7c42..f5db84d8 100644 --- a/README.es.md +++ b/README.es.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2942 tests passing

diff --git a/README.fr.md b/README.fr.md index d3ee4d6c..03c15f0b 100644 --- a/README.fr.md +++ b/README.fr.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2942 tests passing

diff --git a/README.ja.md b/README.ja.md index 4f7b16b1..eef42fe6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2942 tests passing

diff --git a/README.ko.md b/README.ko.md index 852d54ae..3bfc7876 100644 --- a/README.ko.md +++ b/README.ko.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2942 tests passing

diff --git a/README.md b/README.md index 96424ef0..243e5f9c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2942 tests passing

diff --git a/README.pt-BR.md b/README.pt-BR.md index 5f4d23df..5a1e1dd4 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2942 tests passing

diff --git a/README.ru.md b/README.ru.md index 6b47bf49..c201fb0c 100644 --- a/README.ru.md +++ b/README.ru.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2942 tests passing

diff --git a/README.zh-CN.md b/README.zh-CN.md index c0daaf59..3f3a28df 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2942 tests passing

diff --git a/llms-full.txt b/llms-full.txt index e0c61b3b..ca3507f8 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -146,7 +146,7 @@ Each is declared in `launch.config.ts` (or a `*.config.json` sidecar) and reconc ## Commands -All 65 `launch` commands (205 store-API operations underneath, 2917 tests): +All 65 `launch` commands (205 store-API operations underneath, 2942 tests): - `launch init` - scaffold launch.config.ts (and .env.example) into the current repo - `launch adopt` - onboard an app that already ships: import its App Store Connect setup into config @@ -161,7 +161,7 @@ All 65 `launch` commands (205 store-API operations underneath, 2917 tests): - `launch config help [command]` - display help for command - `launch build ` - run the full pipeline and upload to the testing track (--no-submit to build only) - `launch release ` - submit the latest build to the store's PUBLIC production track (with confirmation) -- `launch status` - show each app's App Store version, review, and phased-rollout state +- `launch status` - show each app's App Store version, build, review, and phased-rollout state - `launch open [target]` - deep-link the app's App Store Connect / Play Console page in your browser - `launch dashboard` - serve a local, read-only web UI over your apps, builds, accounts, and secrets - `launch release-train [action] [id]` - coordinate an app's iOS + Android + OTA release as one resumable record diff --git a/llms.txt b/llms.txt index 9aa27f8a..0b8ef90b 100644 --- a/llms.txt +++ b/llms.txt @@ -146,7 +146,7 @@ Each is declared in `launch.config.ts` (or a `*.config.json` sidecar) and reconc ## Commands -All 65 `launch` commands (205 store-API operations underneath, 2917 tests): +All 65 `launch` commands (205 store-API operations underneath, 2942 tests): - `launch init` - scaffold launch.config.ts (and .env.example) into the current repo - `launch adopt` - onboard an app that already ships: import its App Store Connect setup into config @@ -161,7 +161,7 @@ All 65 `launch` commands (205 store-API operations underneath, 2917 tests): - `launch config help [command]` - display help for command - `launch build ` - run the full pipeline and upload to the testing track (--no-submit to build only) - `launch release ` - submit the latest build to the store's PUBLIC production track (with confirmation) -- `launch status` - show each app's App Store version, review, and phased-rollout state +- `launch status` - show each app's App Store version, build, review, and phased-rollout state - `launch open [target]` - deep-link the app's App Store Connect / Play Console page in your browser - `launch dashboard` - serve a local, read-only web UI over your apps, builds, accounts, and secrets - `launch release-train [action] [id]` - coordinate an app's iOS + Android + OTA release as one resumable record From 47427180192a3b0244a996da92a7479f49951abc Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:14:29 +0200 Subject: [PATCH 07/10] style(release): format the status command and its tests --- src/cli/commands/status.ts | 5 ++++- src/core/release/appStoreRelease.test.ts | 4 +++- src/core/release/statusCommand.test.ts | 6 +++--- src/core/release/statusCommand.ts | 20 +++++++++++++++----- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 1c0e15e8..49a45890 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -14,7 +14,10 @@ export const registerStatusCommand = (program: Command): void => { ) .option('--watch', 'poll until the review reaches a terminal verdict', false) .option('--build ', 'with --watch: wait until this build is VALID, then exit') - .option('--builds', 'list each app recent builds: version, build, processing state, upload time') + .option( + '--builds', + 'list each app recent builds: version, build, processing state, upload time', + ) .option('--json', 'machine-readable output for CI', false) .action((commandOptions: StatusCommandOptions) => runCliProgram(statusCommandProgram(commandOptions)), diff --git a/src/core/release/appStoreRelease.test.ts b/src/core/release/appStoreRelease.test.ts index 7ca0f38d..eeaeb90f 100644 --- a/src/core/release/appStoreRelease.test.ts +++ b/src/core/release/appStoreRelease.test.ts @@ -532,7 +532,9 @@ describe('readReleaseStatus', () => { it('reports no build when the current version has none attached', async () => { const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn(() => - Effect.succeed([{ id: 'v2', versionString: '1.1.0', appStoreState: 'PREPARE_FOR_SUBMISSION' }]), + Effect.succeed([ + { id: 'v2', versionString: '1.1.0', appStoreState: 'PREPARE_FOR_SUBMISSION' }, + ]), ), }); const releaseStatus = await Effect.runPromise( diff --git a/src/core/release/statusCommand.test.ts b/src/core/release/statusCommand.test.ts index 3fbe59a1..3cd0c968 100644 --- a/src/core/release/statusCommand.test.ts +++ b/src/core/release/statusCommand.test.ts @@ -101,9 +101,9 @@ describe('formatStatusLine', () => { }); it('shows no build when the version has none attached', () => { - expect( - formatStatusLine(releaseStatus({ buildNumber: null, buildProcessingState: null })), - ).toBe('v1.2.0 - In review - no build'); + expect(formatStatusLine(releaseStatus({ buildNumber: null, buildProcessingState: null }))).toBe( + 'v1.2.0 - In review - no build', + ); }); it('handles an app with no App Store version yet', () => { diff --git a/src/core/release/statusCommand.ts b/src/core/release/statusCommand.ts index 84726122..aad0a0c9 100644 --- a/src/core/release/statusCommand.ts +++ b/src/core/release/statusCommand.ts @@ -180,7 +180,9 @@ const readAppId = (appleClient: AscReleaseApi, selectedApp: StatusApp) => const appId = yield* appleClient .getAppId(selectedApp.bundleId) .pipe( - Effect.mapError((cause) => statusFailure(`find App Store app for ${selectedApp.name}`, cause)), + Effect.mapError((cause) => + statusFailure(`find App Store app for ${selectedApp.name}`, cause), + ), ); const isThereNoAppRecord = appId === null; if (isThereNoAppRecord) { @@ -212,7 +214,9 @@ export const readRecentBuilds = ( const recentBuildCount = 25; const storeBuilds = yield* appleClient .listBuilds(appId, recentBuildCount) - .pipe(Effect.mapError((cause) => statusFailure(`list builds for ${selectedApp.name}`, cause))); + .pipe( + Effect.mapError((cause) => statusFailure(`list builds for ${selectedApp.name}`, cause)), + ); return [...storeBuilds].sort( (firstBuild, secondBuild) => uploadTimeMilliseconds(secondBuild) - uploadTimeMilliseconds(firstBuild), @@ -252,7 +256,9 @@ const readOneAppStatus = ( selectedApp.bundleId, IOS_PLATFORM, ).pipe( - Effect.mapError((cause) => statusFailure(`read App Store status for ${selectedApp.name}`, cause)), + Effect.mapError((cause) => + statusFailure(`read App Store status for ${selectedApp.name}`, cause), + ), ); const isBuildListSkipped = !shouldListBuilds; if (isBuildListSkipped) return { appName: selectedApp.name, releaseStatus }; @@ -396,7 +402,10 @@ const reportBuildWatchOutcome = ( return; } yield* logger - .step(selectedApp.name, `build ${buildNumber} is ${storeBuild.processingState} - ready to release`) + .step( + selectedApp.name, + `build ${buildNumber} is ${storeBuild.processingState} - ready to release`, + ) .pipe(Effect.orDie); }); @@ -422,7 +431,8 @@ export const findStatusUsageProblem = ( selectedApps: readonly StatusApp[], ): string | null => { const isBuildWithoutWatch = commandInput.build !== undefined && !commandInput.watch; - if (isBuildWithoutWatch) return '--build needs --watch: it waits for that build to finish processing.'; + if (isBuildWithoutWatch) + return '--build needs --watch: it waits for that build to finish processing.'; const isBuildsWithWatch = commandInput.builds && commandInput.watch; if (isBuildsWithWatch) return '--builds lists builds without waiting: drop --watch to list them.'; const isBuildForSeveralApps = commandInput.build !== undefined && selectedApps.length !== 1; From e84bba4533d7664fa2df79a468486a35a064f2b5 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:15:05 +0200 Subject: [PATCH 08/10] fix(apple): type each App Store Connect page when walking links.next --- src/apple/ascClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apple/ascClient.ts b/src/apple/ascClient.ts index cdeeebb4..30e3edf4 100644 --- a/src/apple/ascClient.ts +++ b/src/apple/ascClient.ts @@ -918,7 +918,7 @@ export class AppStoreConnectClient { let next: string | undefined = path; while (next) { // biome-ignore lint/performance/noAwaitInLoops: cursor pagination - each page’s `next` link comes from the prior response, so the reads are inherently sequential - const page = await this.request('GET', next); + const page: Page = await this.request('GET', next); pages.push(page); next = page.links?.next; } From 74f1ed6da39587ecca5d9404afadad18f8e8309d Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:31:45 +0200 Subject: [PATCH 09/10] fix(release): address review on status builds and watch --- README.de.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- llms-full.txt | 2 +- llms.txt | 2 +- src/apple/ascClient.test.ts | 68 +++- src/apple/ascClient.ts | 19 +- src/cli/commands/status.ts | 6 +- src/core/build/buildHistoryCommand.ts | 6 +- src/core/credentials/accounts.ts | 9 +- src/core/release/appStoreRelease.test.ts | 31 ++ src/core/release/appStoreRelease.ts | 87 ++-- src/core/release/releaseCommand.ts | 4 +- src/core/release/statusCommand.test.ts | 489 ++++++++++------------- src/core/release/statusCommand.ts | 340 +++++++--------- src/core/terminal/table.ts | 17 +- src/core/types/appleCatalog.ts | 2 - 23 files changed, 568 insertions(+), 532 deletions(-) diff --git a/README.de.md b/README.de.md index 7ae167a0..cbc6e1fe 100644 --- a/README.de.md +++ b/README.de.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2942 tests passing + 2934 tests passing

diff --git a/README.es.md b/README.es.md index f5db84d8..656d66f3 100644 --- a/README.es.md +++ b/README.es.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2942 tests passing + 2934 tests passing

diff --git a/README.fr.md b/README.fr.md index 03c15f0b..cf68e00c 100644 --- a/README.fr.md +++ b/README.fr.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2942 tests passing + 2934 tests passing

diff --git a/README.ja.md b/README.ja.md index eef42fe6..83ab2c42 100644 --- a/README.ja.md +++ b/README.ja.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2942 tests passing + 2934 tests passing

diff --git a/README.ko.md b/README.ko.md index 3bfc7876..e19d308e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2942 tests passing + 2934 tests passing

diff --git a/README.md b/README.md index 243e5f9c..1489a335 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2942 tests passing + 2934 tests passing

diff --git a/README.pt-BR.md b/README.pt-BR.md index 5a1e1dd4..eab13835 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2942 tests passing + 2934 tests passing

diff --git a/README.ru.md b/README.ru.md index c201fb0c..5e9845ce 100644 --- a/README.ru.md +++ b/README.ru.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2942 tests passing + 2934 tests passing

diff --git a/README.zh-CN.md b/README.zh-CN.md index 3f3a28df..b943dd56 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2942 tests passing + 2934 tests passing

diff --git a/llms-full.txt b/llms-full.txt index ca3507f8..a7d65aeb 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -146,7 +146,7 @@ Each is declared in `launch.config.ts` (or a `*.config.json` sidecar) and reconc ## Commands -All 65 `launch` commands (205 store-API operations underneath, 2942 tests): +All 65 `launch` commands (205 store-API operations underneath, 2934 tests): - `launch init` - scaffold launch.config.ts (and .env.example) into the current repo - `launch adopt` - onboard an app that already ships: import its App Store Connect setup into config diff --git a/llms.txt b/llms.txt index 0b8ef90b..90fbf4d6 100644 --- a/llms.txt +++ b/llms.txt @@ -146,7 +146,7 @@ Each is declared in `launch.config.ts` (or a `*.config.json` sidecar) and reconc ## Commands -All 65 `launch` commands (205 store-API operations underneath, 2942 tests): +All 65 `launch` commands (205 store-API operations underneath, 2934 tests): - `launch init` - scaffold launch.config.ts (and .env.example) into the current repo - `launch adopt` - onboard an app that already ships: import its App Store Connect setup into config diff --git a/src/apple/ascClient.test.ts b/src/apple/ascClient.test.ts index 3483c0a2..e4945417 100644 --- a/src/apple/ascClient.test.ts +++ b/src/apple/ascClient.test.ts @@ -3084,7 +3084,7 @@ describe('AppStoreConnectClient - App Store release lifecycle', () => { marketingVersion: '6.11.77', }); expect(expectArrayElement(fetchMock.mock.calls, 0, 'mock call')[0]).toContain( - 'filter[version]=591&limit=1&fields[builds]=version,processingState,uploadedDate,expired,preReleaseVersion&include=preReleaseVersion', + 'filter[version]=591&sort=-uploadedDate&limit=1&fields[builds]=version,processingState,uploadedDate,expired,preReleaseVersion&include=preReleaseVersion', ); }); it('declares export compliance with a PATCH to the build', async () => { @@ -3417,4 +3417,70 @@ describe('AppStoreConnectClient - app store versions', () => { attachedBuildProcessingState: 'VALID', }); }); + + it('keeps each attached build number when the versions span two pages', async () => { + const page2Url = + 'https://api.appstoreconnect.apple.com/v1/apps/app1/appStoreVersions?cursor=page-2'; + fetchMock + .mockResolvedValueOnce( + fakeResponse( + 200, + JSON.stringify({ + data: [ + { + id: 'v-1', + attributes: { versionString: '1.2.0', appStoreState: 'IN_REVIEW' }, + relationships: { build: { data: { type: 'builds', id: 'b-1' } } }, + }, + ], + included: [ + { + type: 'builds', + id: 'b-1', + attributes: { version: '42', processingState: 'PROCESSING' }, + }, + ], + links: { next: page2Url }, + }), + ), + ) + .mockResolvedValueOnce( + fakeResponse( + 200, + JSON.stringify({ + data: [ + { + id: 'v-2', + attributes: { versionString: '1.1.0', appStoreState: 'READY_FOR_SALE' }, + relationships: { build: { data: { type: 'builds', id: 'b-2' } } }, + }, + ], + included: [ + { + type: 'builds', + id: 'b-2', + attributes: { version: '41', processingState: 'VALID' }, + }, + ], + }), + ), + ); + + const versions = await client.listAppStoreVersions('app1', 'IOS'); + + expect(versions.map((version) => version.attachedBuildNumber)).toEqual(['42', '41']); + expect(String(expectArrayElement(fetchMock.mock.calls, 1, 'mock call')[0])).toBe(page2Url); + }); + + it('asks Apple for the build of one marketing version, newest upload first', async () => { + fetchMock.mockResolvedValueOnce(fakeResponse(200, JSON.stringify({ data: [] }))); + + const storeBuild = await client.findBuildByVersion('app1', 9, '1.2.0'); + + const [requestUrl] = expectArrayElement(fetchMock.mock.calls, 0, 'mock call'); + expect(String(requestUrl)).toContain( + 'filter[version]=9&filter[preReleaseVersion.version]=1.2.0&sort=-uploadedDate&limit=1', + ); + expect(storeBuild).toBeNull(); + }); }); diff --git a/src/apple/ascClient.ts b/src/apple/ascClient.ts index 30e3edf4..1ca6dcd8 100644 --- a/src/apple/ascClient.ts +++ b/src/apple/ascClient.ts @@ -553,6 +553,11 @@ type BetaFeedbackPage = { }; const BUILD_PAGE_QUERY = 'fields[builds]=version,processingState,uploadedDate,expired,preReleaseVersion&include=preReleaseVersion&fields[preReleaseVersions]=version'; +const buildMarketingVersionFilter = (marketingVersion: string | undefined): string => { + const isMarketingVersionGiven = marketingVersion !== undefined; + if (!isMarketingVersionGiven) return ''; + return `&filter[preReleaseVersion.version]=${marketingVersion}`; +}; type BuildPageIncluded = { type: string; id: string; @@ -561,7 +566,6 @@ type BuildPageIncluded = { processingState?: string; }; }; -/** One page of an app's App Store versions, read with `include=build` so each attached build resolves from `included`. */ type AppStoreVersionPage = PagedList<{ versionString?: string; appStoreState?: string; @@ -910,7 +914,6 @@ export class AppStoreConnectClient { const pages = await this.requestPages>(path); return pages.flatMap((page) => page.data); } - /** Walk every page of a collection, keeping each raw page so its `included` sidebar survives. */ private async requestPages( path: string, ): Promise { @@ -3977,10 +3980,15 @@ export class AppStoreConnectClient { return toBuildResources(buildPage); } /** Find one build by its `CFBundleVersion` number (the resource id is needed to attach / PATCH it), or null. */ - async findBuildByVersion(appId: string, buildNumber: number): Promise { + async findBuildByVersion( + appId: string, + buildNumber: number, + marketingVersion?: string, + ): Promise { + const versionFilter = buildMarketingVersionFilter(marketingVersion); const buildPage = await this.request( 'GET', - `/builds?filter[app]=${appId}&filter[version]=${buildNumber}&limit=1&${BUILD_PAGE_QUERY}`, + `/builds?filter[app]=${appId}&filter[version]=${buildNumber}${versionFilter}&sort=-uploadedDate&limit=1&${BUILD_PAGE_QUERY}`, ); const [firstBuild] = toBuildResources(buildPage); if (firstBuild === undefined) return null; @@ -4050,13 +4058,12 @@ export class AppStoreConnectClient { relationships: { build: { data: { type: 'builds', id: buildId } } }, }); } - /** List an app's App Store versions for a platform (e.g. `IOS`), across all pages, with each attached build's number and state. */ async listAppStoreVersions(appId: string, platform: string): Promise { const versionPages = await this.requestPages( `/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=200&fields[appStoreVersions]=versionString,appStoreState,releaseType,build&include=build&fields[builds]=version,processingState`, ); return versionPages.flatMap(({ data: entries, included = [] }) => - entries.map((entry) => toVersionResource(entry, '', included)), + entries.map((versionEntry) => toVersionResource(versionEntry, '', included)), ); } /** Create a new App Store version for a marketing version string on a platform. */ diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 49a45890..2b7454b7 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import { statusCommandProgram, type StatusCommandOptions } from '@core/release/statusCommand.js'; +import { type StatusCommandFlags, statusCommandProgram } from '@core/release/statusCommand.js'; import { runCliProgram } from '../runCliProgram.js'; /** Attach the App Store status command. */ @@ -19,7 +19,7 @@ export const registerStatusCommand = (program: Command): void => { 'list each app recent builds: version, build, processing state, upload time', ) .option('--json', 'machine-readable output for CI', false) - .action((commandOptions: StatusCommandOptions) => - runCliProgram(statusCommandProgram(commandOptions)), + .action((commandFlags: StatusCommandFlags) => + runCliProgram(statusCommandProgram(commandFlags)), ); }; diff --git a/src/core/build/buildHistoryCommand.ts b/src/core/build/buildHistoryCommand.ts index 80bea41b..a3560628 100644 --- a/src/core/build/buildHistoryCommand.ts +++ b/src/core/build/buildHistoryCommand.ts @@ -18,7 +18,7 @@ import { buildLogId, buildLogPath, readBuildLog } from './buildLog.js'; import { sizeSummary, worstDownloadBytes } from './pipelineArtifact.js'; import { mb } from './pipelineProviders.js'; import { hasMessage } from '../services/errorMessage.js'; -import { type Column, formatTable } from '../terminal/table.js'; +import { alignTableColumns, type Column } from '../terminal/table.js'; /** Stable presentation shape emitted by `builds list --json`. */ export type BuildRow = Readonly<{ @@ -206,11 +206,11 @@ const PRUNE_COLUMNS: readonly Column[] = [ /** Render build history as a compact table. */ export const formatBuildsTable = (buildRows: readonly BuildRow[]): string => - formatTable(BUILD_COLUMNS, buildRows); + alignTableColumns(BUILD_COLUMNS, buildRows); /** Render the binaries selected by a prune preview. */ export const formatPrunePreview = (prunedArtifacts: readonly PrunedArtifact[]): string => - formatTable(PRUNE_COLUMNS, prunedArtifacts); + alignTableColumns(PRUNE_COLUMNS, prunedArtifacts); /** Render the detail block for one stored build. */ export const formatBuildDetail = (artifact: BuildArtifact): string => { diff --git a/src/core/credentials/accounts.ts b/src/core/credentials/accounts.ts index d153c655..676c193f 100644 --- a/src/core/credentials/accounts.ts +++ b/src/core/credentials/accounts.ts @@ -366,8 +366,13 @@ export const loadActiveAscKey = (): Effect.Effect< if (active) return yield* loadAscKeyById(active); return null; }); -/** Load the key of the account a `--account` flag names; without the flag, the same as {@link loadActiveAscKey}. */ -export const loadAscKeyForAccount = (accountSelector: string | undefined) => +export const loadAscKeyForAccount = ( + accountSelector: string | undefined, +): Effect.Effect< + AscKey | null, + unknown, + AccountStorageRequirements | LaunchEnvironmentService | LaunchSecretStoreService +> => Effect.gen(function* () { const isAccountSelected = accountSelector !== undefined; if (isAccountSelected) return yield* loadSelectedAscKey(accountSelector); diff --git a/src/core/release/appStoreRelease.test.ts b/src/core/release/appStoreRelease.test.ts index eeaeb90f..be067b3e 100644 --- a/src/core/release/appStoreRelease.test.ts +++ b/src/core/release/appStoreRelease.test.ts @@ -638,4 +638,35 @@ describe('waitForValidBuild', () => { Effect.runPromise(waitForValidBuild(api, 'app1', 42, { sleep: noSleep, timeoutMs: 0 })), ).rejects.toThrow(/still PROCESSING/); }); + + it('waits for the build of the given marketing version, not another version with the same number', async () => { + const findBuildByVersion = vi.fn( + (_appId: string, _buildNumber: number, marketingVersion?: string) => + Effect.succeed( + marketingVersion === '1.3.0' + ? { + id: 'b-130', + version: '42', + marketingVersion: '1.3.0', + processingState: 'VALID', + expired: false, + } + : { + id: 'b-120', + version: '42', + marketingVersion: '1.2.0', + processingState: 'FAILED', + expired: false, + }, + ), + ); + const api = makeReleaseApiFake({ findBuildByVersion }); + + const storeBuild = await Effect.runPromise( + waitForValidBuild(api, 'app1', 42, { sleep: noSleep, marketingVersion: '1.3.0' }), + ); + + expect(findBuildByVersion).toHaveBeenCalledWith('app1', 42, '1.3.0'); + expect(storeBuild).toMatchObject({ id: 'b-130', processingState: 'VALID' }); + }); }); diff --git a/src/core/release/appStoreRelease.ts b/src/core/release/appStoreRelease.ts index b40fdd0f..6aa2735e 100644 --- a/src/core/release/appStoreRelease.ts +++ b/src/core/release/appStoreRelease.ts @@ -27,6 +27,7 @@ export type AscReleaseApi = { findBuildByVersion( appId: string, buildNumber: number, + marketingVersion?: string, ): Effect.Effect; setBuildUsesNonExemptEncryption( buildId: string, @@ -826,7 +827,6 @@ export type ReleaseStatus = { bundleId: string; versionString: string | null; appStoreState: string | null; - /** Number of the build attached to the current version; null when none is attached. */ buildNumber: string | null; buildProcessingState: string | null; phasedReleaseState: string | null; @@ -842,11 +842,21 @@ export const readReleaseStatus = ( bundleId: string, platform: string, ): Effect.Effect => + Effect.gen(function* () { + const appId = yield* readAppStoreAppId(appleReleaseApi, bundleId); + return yield* readReleaseStatusForApp(appleReleaseApi, appId, bundleId, platform); + }); + +export const readAppStoreAppId = ( + appleReleaseApi: AscReleaseApi, + bundleId: string, +): Effect.Effect => Effect.gen(function* () { const appId = yield* appleReleaseApi .getAppId(bundleId) .pipe(Effect.mapError((cause) => releaseFailure('find App Store app', cause))); - if (appId === null) { + const isThereNoAppRecord = appId === null; + if (isThereNoAppRecord) { return yield* Effect.fail( releaseFailure( 'find App Store app', @@ -855,11 +865,22 @@ export const readReleaseStatus = ( ), ); } + return appId; + }); + +export const readReleaseStatusForApp = ( + appleReleaseApi: AscReleaseApi, + appId: string, + bundleId: string, + platform: string, +): Effect.Effect => + Effect.gen(function* () { const storeVersions = yield* appleReleaseApi .listAppStoreVersions(appId, platform) .pipe(Effect.mapError((cause) => releaseFailure('list App Store versions', cause))); const currentVersion = pickCurrentVersion(storeVersions); - if (currentVersion === null) { + const isThereNoCurrentVersion = currentVersion === null; + if (isThereNoCurrentVersion) { return { bundleId, versionString: null, @@ -873,18 +894,9 @@ export const readReleaseStatus = ( const phasedRelease = yield* appleReleaseApi .getPhasedRelease(currentVersion.id) .pipe(Effect.mapError((cause) => releaseFailure('read App Store release status', cause))); - let buildNumber: string | null = null; - if (currentVersion.attachedBuildNumber !== undefined) { - buildNumber = currentVersion.attachedBuildNumber; - } - let buildProcessingState: string | null = null; - if (currentVersion.attachedBuildProcessingState !== undefined) { - buildProcessingState = currentVersion.attachedBuildProcessingState; - } - let phasedReleaseState: string | null = null; - if (phasedRelease !== null) { - phasedReleaseState = phasedRelease.phasedReleaseState; - } + const buildNumber = currentVersion.attachedBuildNumber ?? null; + const buildProcessingState = currentVersion.attachedBuildProcessingState ?? null; + const phasedReleaseState = phasedRelease?.phasedReleaseState ?? null; return { bundleId, versionString: currentVersion.versionString, @@ -915,6 +927,8 @@ export type WaitOptions = { timeoutMs?: number; sleep?: (milliseconds: number) => Effect.Effect; onTick?: (processingState: string) => Effect.Effect; + // Picks this version's build when another version reused the same build number. + marketingVersion?: string; }; /** @@ -936,19 +950,19 @@ export const waitForValidBuild = ( Effect.sleep(milliseconds); if (options.sleep !== undefined) sleepBetweenPolls = options.sleep; let waited = 0; + const { onTick, marketingVersion } = options; + const timeoutMinutes = Math.round(timeoutMs / 60_000); for (;;) { const storeBuild = yield* appleReleaseApi - .findBuildByVersion(appId, buildNumber) + .findBuildByVersion(appId, buildNumber, marketingVersion) .pipe(Effect.mapError((cause) => releaseFailure('read App Store build', cause))); - let processingState = 'PROCESSING'; - if (storeBuild !== null) { - const reportedState = storeBuild.processingState; - if (reportedState !== undefined && reportedState !== null) { - processingState = reportedState; - } - } - if (options.onTick !== undefined) yield* options.onTick(processingState); - if (storeBuild !== null && processingState === 'VALID') return storeBuild; + const processingState = storeBuild?.processingState ?? 'PROCESSING'; + const isProgressReported = onTick !== undefined; + if (isProgressReported) yield* onTick(processingState); + + const isBuildValid = storeBuild !== null && processingState === 'VALID'; + if (isBuildValid) return storeBuild; + const isProcessingRejected = processingState === 'INVALID' || processingState === 'FAILED'; if (isProcessingRejected) { return yield* Effect.fail( @@ -959,19 +973,19 @@ export const waitForValidBuild = ( ), ); } + const isTimedOut = waited >= timeoutMs; + const isBuildMissingAtTimeout = isTimedOut && storeBuild === null; + if (isBuildMissingAtTimeout) { + return yield* Effect.fail( + releaseFailure( + 'wait for App Store build', + storeBuild, + `Build ${buildNumber} was not found on App Store Connect after ${timeoutMinutes} min - check that the upload finished.`, + ), + ); + } if (isTimedOut) { - const timeoutMinutes = Math.round(timeoutMs / 60_000); - const isBuildMissing = storeBuild === null; - if (isBuildMissing) { - return yield* Effect.fail( - releaseFailure( - 'wait for App Store build', - storeBuild, - `Build ${buildNumber} was not found on App Store Connect after ${timeoutMinutes} min - check that the upload finished.`, - ), - ); - } return yield* Effect.fail( releaseFailure( 'wait for App Store build', @@ -980,6 +994,7 @@ export const waitForValidBuild = ( ), ); } + yield* sleepBetweenPolls(intervalMs); waited += intervalMs; } diff --git a/src/core/release/releaseCommand.ts b/src/core/release/releaseCommand.ts index 19d0057e..55a667da 100644 --- a/src/core/release/releaseCommand.ts +++ b/src/core/release/releaseCommand.ts @@ -603,7 +603,9 @@ const resolveIosBuild = ( ); const storeBuild = yield* attemptRead( 'wait for App Store build processing', - waitForValidBuild(ascClient, appId, artifact.buildNumber), + waitForValidBuild(ascClient, appId, artifact.buildNumber, { + marketingVersion: artifact.version, + }), ); return { storeBuild, versionString: artifact.version }; }); diff --git a/src/core/release/statusCommand.test.ts b/src/core/release/statusCommand.test.ts index 3cd0c968..36c78735 100644 --- a/src/core/release/statusCommand.test.ts +++ b/src/core/release/statusCommand.test.ts @@ -1,53 +1,54 @@ -import { Effect, Schema } from 'effect'; -import { describe, expect, it, vi } from 'vitest'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { NodeHttpClient } from '@effect/platform-node'; +import { Effect, Layer, Schema } from 'effect'; +import { describe, expect, it, vi } from 'vitest'; import { makeReleaseApiFake } from '@testkit/ascReleaseApi.testkit.js'; -import { createLogger, makeLaunchLoggerTest } from '../services/logger.js'; -import type { AppDescriptor } from '../types/app.js'; -import type { BuildResource } from '../types/appleCatalog.js'; -import { type AscReleaseApi, classifyVerdict, type ReleaseStatus } from './appStoreRelease.js'; -import type { MutableDeep } from '../types/mutable.js'; +import { makeFakeAscClient } from '@testkit/storeClients.testkit.js'; import { - findStatusUsageProblem, - formatRecentBuilds, - formatStatusLine, - readRecentBuilds, - selectIosApps, - type StatusCommandInput, - StatusCommandInputSchema, - watchBuildProcessing, - watchReleaseStatuses, - worstExitCode, -} from './statusCommand.js'; - -const releaseStatus = (overrides: Partial = {}): ReleaseStatus => { - const baseStatus: ReleaseStatus = { - bundleId: 'com.acme.app', - versionString: '1.2.0', - appStoreState: 'IN_REVIEW', - buildNumber: '42', - buildProcessingState: 'VALID', - phasedReleaseState: null, - verdict: classifyVerdict('IN_REVIEW'), - }; - const mergedStatus: ReleaseStatus = { ...baseStatus, ...overrides }; - if (overrides.appStoreState !== undefined && overrides.verdict === undefined) { - let appStoreState = overrides.appStoreState; - if (appStoreState === null) appStoreState = ''; - mergedStatus.verdict = classifyVerdict(appStoreState); - } - return mergedStatus; -}; - -const discoveredApp = (appName: string, bundleId?: string): AppDescriptor => { - const appDescriptor: MutableDeep = { - name: appName, - dir: `/repo/${appName}`, - configPath: `/repo/${appName}/app.json`, - }; - if (bundleId !== undefined) appDescriptor.bundleId = bundleId; - return appDescriptor; -}; + createTemporaryHomeDirectory, + makeLaunchHomeTest, + removeTemporaryHomeDirectory, + writeLaunchHomeFile, +} from '@testkit/launchHome.testkit.js'; +import { AppleStoreClientService } from '../services/appleStoreClient.js'; +import { errorMessage } from '../services/errorMessage.js'; +import { makeLaunchEnvironmentTest } from '../services/environment.js'; +import { makeLaunchLoggerTest } from '../services/logger.js'; +import { makeLaunchSecretStoreTest } from '../services/secretStore.js'; +import { CommandExitSchema, type CommandExit } from '../terminal/commandExit.js'; +import type { AppStoreVersionResource, BuildResource } from '../types/appleCatalog.js'; +import type { AscReleaseApi } from './appStoreRelease.js'; +import { type StatusCommandFlags, statusCommandProgram } from './statusCommand.js'; + +const ASC_KEY_ID = 'KEY123'; +const P8_SECRET = Buffer.from('-----BEGIN PRIVATE KEY-----').toString('base64'); +const ACCOUNTS_FILE_TEXT = JSON.stringify({ + active: ASC_KEY_ID, + accounts: [ + { + keyId: ASC_KEY_ID, + issuerId: 'issuer-1', + label: 'Owner', + addedAt: '2026-11-01T00:00:00.000Z', + }, + ], +}); +const APP_JSON_TEXT = JSON.stringify({ + expo: { name: 'Oly', version: '1.2.0', ios: { bundleIdentifier: 'com.acme.app' } }, +}); +const APP_JSON_WITHOUT_IOS_TEXT = JSON.stringify({ expo: { name: 'Oly', version: '1.2.0' } }); + +const liveVersion = ( + overrides: Partial = {}, +): AppStoreVersionResource => ({ + id: 'v-1', + versionString: '1.2.0', + appStoreState: 'READY_FOR_SALE', + attachedBuildNumber: '42', + attachedBuildProcessingState: 'VALID', + ...overrides, +}); const makeBuild = (overrides: Partial = {}): BuildResource => ({ id: 'b-1', @@ -57,284 +58,228 @@ const makeBuild = (overrides: Partial = {}): BuildResource => ({ ...overrides, }); -const plainInput: StatusCommandInput = { watch: false, json: false, builds: false }; +type StatusRun = Readonly<{ + exitCode: number; + failureMessage: string | null; + logLines: string[]; +}>; -describe('StatusCommandInputSchema', () => { - it('defaults to one read with human output and no build list', async () => { - const commandInput = await Effect.runPromise( - Schema.decodeUnknown(StatusCommandInputSchema)({}), - ); - expect(commandInput).toEqual({ watch: false, json: false, builds: false }); - }); +const isCommandExitFailure = (failure: unknown): failure is CommandExit => + Schema.is(CommandExitSchema)(failure); - it('reads --build text as a whole build number', async () => { - const commandInput = await Effect.runPromise( - Schema.decodeUnknown(StatusCommandInputSchema)({ build: '42', watch: true }), - ); - expect(commandInput.build).toBe(42); - }); +const outcomeOfFailure = (failure: unknown): Omit => { + if (isCommandExitFailure(failure)) { + return { exitCode: failure.exitCode, failureMessage: failure.message ?? null }; + } + return { exitCode: 1, failureMessage: errorMessage(failure) }; +}; - it('rejects a --build that is not a whole number', async () => { - await expect( - Effect.runPromise(Schema.decodeUnknown(StatusCommandInputSchema)({ build: '4.5' })), - ).rejects.toThrow(); - }); -}); +const runStatusCommand = async ( + flags: StatusCommandFlags, + appleReleaseApi: AscReleaseApi, + appJsonText = APP_JSON_TEXT, +): Promise => { + const homeDirectory = createTemporaryHomeDirectory(); + const logLines: string[] = []; + + try { + const workDirectory = join(homeDirectory, 'work'); + mkdirSync(workDirectory, { recursive: true }); + writeFileSync(join(workDirectory, 'app.json'), appJsonText); + writeLaunchHomeFile(homeDirectory, 'accounts.json', ACCOUNTS_FILE_TEXT); + + const appleStoreClientTest = Layer.succeed(AppleStoreClientService, { + createClient: () => Effect.succeed(appleReleaseApi), + createEffectClient: () => Effect.succeed(makeFakeAscClient([])), + createReleaseAttributesClient: () => Effect.succeed(makeFakeAscClient([])), + }); + const statusProgram = statusCommandProgram(flags).pipe( + Effect.match({ + onSuccess: () => ({ exitCode: 0, failureMessage: null }), + onFailure: outcomeOfFailure, + }), + Effect.provide(makeLaunchHomeTest(homeDirectory)), + Effect.provide(NodeHttpClient.layer), + Effect.provide(makeLaunchEnvironmentTest({})), + Effect.provide(makeLaunchSecretStoreTest(new Map([[`asc-p8:${ASC_KEY_ID}`, P8_SECRET]]))), + Effect.provide(makeLaunchLoggerTest(logLines)), + Effect.provide(appleStoreClientTest), + ); -describe('formatStatusLine', () => { - it('renders version, verdict, build, and phased state', () => { - expect(formatStatusLine(releaseStatus())).toBe('v1.2.0 - In review - build 42'); - }); + const outcome = await Effect.runPromise(statusProgram); + return { ...outcome, logLines }; + } finally { + removeTemporaryHomeDirectory(homeDirectory); + } +}; - it('annotates a build still processing', () => { - expect(formatStatusLine(releaseStatus({ buildProcessingState: 'PROCESSING' }))).toContain( - 'build 42 (PROCESSING)', - ); - }); +describe('statusCommandProgram - plain status', () => { + it('prints the status line and exits 0 when the version is live', async () => { + const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => Effect.succeed([liveVersion()])), + }); - it('shows the phased-rollout state and a live verdict', () => { - const statusLine = formatStatusLine( - releaseStatus({ appStoreState: 'READY_FOR_SALE', phasedReleaseState: 'ACTIVE' }), - ); - expect(statusLine).toContain('Live on the App Store'); - expect(statusLine).toContain('phased: ACTIVE'); - }); + const run = await runStatusCommand({}, api); - it('shows no build when the version has none attached', () => { - expect(formatStatusLine(releaseStatus({ buildNumber: null, buildProcessingState: null }))).toBe( - 'v1.2.0 - In review - no build', - ); + expect(run.exitCode).toBe(0); + expect(run.logLines.join('\n')).toContain('Live on the App Store'); }); - it('handles an app with no App Store version yet', () => { - expect( - formatStatusLine( - releaseStatus({ versionString: null, appStoreState: '', buildNumber: null }), + it('annotates an attached build that is still processing', async () => { + const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => + Effect.succeed([ + liveVersion({ appStoreState: 'IN_REVIEW', attachedBuildProcessingState: 'PROCESSING' }), + ]), ), - ).toContain('no App Store version'); - }); -}); + }); -describe('formatRecentBuilds', () => { - it('says so when the app has no uploaded builds', () => { - expect(formatRecentBuilds([])).toBe(' no builds uploaded yet'); - }); + const run = await runStatusCommand({}, api); - it('shows one build with its version, processing state, and UTC upload time', () => { - const table = formatRecentBuilds([ - makeBuild({ - version: '42', - marketingVersion: '1.2.0', - uploadedDate: '2026-11-01T09:30:00Z', - }), - ]); - expect(table).toContain('UPLOADED (UTC)'); - expect(table).toContain('1.2.0'); - expect(table).toContain('VALID'); - expect(table).toContain('2026-11-01 09:30'); + expect(run.logLines.join('\n')).toContain('build 42 (PROCESSING)'); }); - it('lists every build it is given, one row each', () => { - const table = formatRecentBuilds([ - makeBuild({ id: 'b-43', version: '43' }), - makeBuild({ id: 'b-42', version: '42' }), - ]); - expect(table).toContain('43'); - expect(table).toContain('42'); - }); -}); + it('never lists builds without --builds', async () => { + const listBuilds = vi.fn(() => Effect.succeed([makeBuild()])); + const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => Effect.succeed([liveVersion()])), + listBuilds, + }); -describe('readRecentBuilds', () => { - const selectedApp = { name: 'alpha', bundleId: 'com.acme.alpha' }; + await runStatusCommand({}, api); - it('returns the builds newest upload first', async () => { + expect(listBuilds).not.toHaveBeenCalled(); + }); + + it('lists each recent build in its own row with --builds, looking up the app id once', async () => { const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => Effect.succeed([liveVersion()])), listBuilds: vi.fn(() => Effect.succeed([ - makeBuild({ version: '41', uploadedDate: '2026-10-01T10:00:00Z' }), - makeBuild({ version: '43', uploadedDate: '2026-11-03T10:00:00Z' }), - makeBuild({ version: '42', uploadedDate: '2026-10-20T10:00:00Z' }), + makeBuild({ id: 'b-43', version: '43', uploadedDate: '2026-11-03T10:00:00Z' }), + makeBuild({ id: 'b-42', version: '42', uploadedDate: '2026-10-20T10:00:00Z' }), ]), ), }); - const recentBuilds = await Effect.runPromise(readRecentBuilds(api, selectedApp)); - expect(recentBuilds.map((build) => build.version)).toEqual(['43', '42', '41']); + + const run = await runStatusCommand({ builds: true }, api); + + const printedText = run.logLines.join('\n'); + expect(printedText).toContain('2026-11-03 10:00'); + expect(printedText).toContain('2026-10-20 10:00'); + expect(api.getAppId).toHaveBeenCalledTimes(1); }); - it('fails with the app record message when App Store Connect has no app for the bundle id', async () => { - const api = makeReleaseApiFake({ getAppId: vi.fn(() => Effect.succeed(null)) }); - await expect(Effect.runPromise(readRecentBuilds(api, selectedApp))).rejects.toThrow( - /No App Store Connect app record for com\.acme\.alpha/, - ); + it('exits 0 with a note when the project has no iOS app', async () => { + const api = makeReleaseApiFake(); + + const run = await runStatusCommand({}, api, APP_JSON_WITHOUT_IOS_TEXT); + + expect(run.exitCode).toBe(0); + expect(run.logLines.join('\n')).toContain('No iOS apps discovered'); + }); + + it('fails with exit 1 on an --app name that is not an iOS app', async () => { + const api = makeReleaseApiFake(); + + const run = await runStatusCommand({ app: 'delta' }, api); + + expect(run.exitCode).toBe(1); + expect(run.failureMessage).toMatch(/Unknown iOS app "delta"/); }); }); -describe('watchBuildProcessing', () => { - const selectedApp = { name: 'alpha', bundleId: 'com.acme.alpha' }; - - const watchBuild = ( - appleClient: AscReleaseApi, - terminalWrites: string[], - pollOptions: { timeoutMs?: number } = {}, - ) => - Effect.runPromise( - Effect.gen(function* () { - const logger = yield* createLogger(false); - return yield* watchBuildProcessing( - { appleClient, selectedApp, buildNumber: 42, logger, isJson: false }, - { sleep: () => Effect.void, intervalMs: 1, ...pollOptions }, - ); - }).pipe( - Effect.provide(makeLaunchLoggerTest(terminalWrites)), - Effect.provide(NodeHttpClient.layer), - ), - ); +describe('statusCommandProgram - usage errors', () => { + it('exits 2 when --build comes without --watch', async () => { + const run = await runStatusCommand({ build: '42' }, makeReleaseApiFake()); - it('returns the build once App Store Connect reports it VALID, and prints the state', async () => { - const terminalWrites: string[] = []; - const api = makeReleaseApiFake({ - findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'VALID' }))), - }); - const storeBuild = await watchBuild(api, terminalWrites); - expect(storeBuild.version).toBe('42'); - expect(terminalWrites.join('')).toContain('build 42 is VALID'); + expect(run.exitCode).toBe(2); + expect(run.failureMessage).toMatch(/--build needs --watch/); }); - it('waits through PROCESSING and prints each state it sees', async () => { - const terminalWrites: string[] = []; - const findBuildByVersion = vi - .fn() - .mockReturnValueOnce(Effect.succeed(makeBuild({ processingState: 'PROCESSING' }))) - .mockReturnValueOnce(Effect.succeed(makeBuild({ processingState: 'VALID' }))); - const api = makeReleaseApiFake({ findBuildByVersion }); - await watchBuild(api, terminalWrites); - expect(terminalWrites.join('')).toContain('build 42 is PROCESSING'); - expect(terminalWrites.join('')).toContain('build 42 is VALID'); + it('exits 2 when --builds comes with --watch', async () => { + const run = await runStatusCommand({ builds: true, watch: true }, makeReleaseApiFake()); + + expect(run.exitCode).toBe(2); + expect(run.failureMessage).toMatch(/--builds lists builds/); }); - it('fails on FAILED processing', async () => { + it('exits 2 with the usage message for a --build that is not a whole number', async () => { + const run = await runStatusCommand({ build: '4.5', watch: true }, makeReleaseApiFake()); + + expect(run.exitCode).toBe(2); + expect(run.failureMessage).toMatch(/--build takes a whole number above 0/); + }); + + it('exits 2 for a --build of 0', async () => { + const run = await runStatusCommand({ build: '0', watch: true }, makeReleaseApiFake()); + + expect(run.exitCode).toBe(2); + }); +}); + +describe('statusCommandProgram - --watch and --build', () => { + it('exits with the verdict code when the first read is already terminal', async () => { const api = makeReleaseApiFake({ - findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'FAILED' }))), + listAppStoreVersions: vi.fn(() => Effect.succeed([liveVersion()])), }); - await expect(watchBuild(api, [])).rejects.toThrow(/FAILED/); + + const run = await runStatusCommand({ watch: true }, api); + + expect(run.exitCode).toBe(0); }); - it('fails on INVALID processing', async () => { + it('reports the build as ready once App Store Connect reports it VALID', async () => { const api = makeReleaseApiFake({ - findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'INVALID' }))), + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'VALID' }))), }); - await expect(watchBuild(api, [])).rejects.toThrow(/INVALID/); - }); - it('fails with the not-found message when the build never appears before the timeout', async () => { - const api = makeReleaseApiFake({ findBuildByVersion: vi.fn(() => Effect.succeed(null)) }); - await expect(watchBuild(api, [], { timeoutMs: 0 })).rejects.toThrow( - /was not found on App Store Connect/, - ); + const run = await runStatusCommand({ build: '42', watch: true }, api); + + expect(run.exitCode).toBe(0); + expect(run.logLines.join('\n')).toContain('build 42 is VALID - ready to release'); }); -}); -describe('findStatusUsageProblem', () => { - const oneApp = [{ name: 'alpha', bundleId: 'com.acme.alpha' }]; - const twoApps = [...oneApp, { name: 'beta', bundleId: 'com.acme.beta' }]; + it('fails with exit 1 when App Store Connect rejects the build', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'FAILED' }))), + }); - it('returns null when the flags fit together', () => { - expect(findStatusUsageProblem(plainInput, oneApp)).toBeNull(); - expect(findStatusUsageProblem({ ...plainInput, watch: true, build: 42 }, oneApp)).toBeNull(); - }); + const run = await runStatusCommand({ build: '42', watch: true }, api); - it('asks for --watch when --build is given without it', () => { - expect(findStatusUsageProblem({ ...plainInput, build: 42 }, oneApp)).toMatch(/needs --watch/); + expect(run.exitCode).toBe(1); + expect(run.failureMessage).toMatch(/failed App Store Connect processing \(FAILED\)/); }); - it('rejects --builds combined with --watch', () => { - expect(findStatusUsageProblem({ ...plainInput, watch: true, builds: true }, oneApp)).toMatch( - /--builds lists/, - ); - }); + it('prints ok true with the build under --json when the build is VALID', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'VALID' }))), + }); - it('asks for one --app when --build is given for several apps', () => { - expect(findStatusUsageProblem({ ...plainInput, watch: true, build: 42 }, twoApps)).toMatch( - /pass --app/, - ); - }); -}); + const run = await runStatusCommand({ build: '42', watch: true, json: true }, api); -describe('worstExitCode - error > rejected > in-progress > ok', () => { - it('picks the worst code in the batch', () => { - expect(worstExitCode([0, 0])).toBe(0); - expect(worstExitCode([0, 3])).toBe(3); - expect(worstExitCode([3, 2])).toBe(2); - expect(worstExitCode([2, 1])).toBe(1); - expect(worstExitCode([0, 1, 3])).toBe(1); - expect(worstExitCode([])).toBe(0); + expect(run.logLines.join('\n')).toContain('"ok": true'); }); -}); -describe('selectIosApps', () => { - const apps = [ - discoveredApp('alpha', 'com.acme.alpha'), - discoveredApp('beta'), - discoveredApp('gamma', 'com.acme.gamma'), - ]; - - it('returns every app with a bundle id when no selector is given', async () => { - expect(await Effect.runPromise(selectIosApps(apps, undefined))).toEqual([ - { name: 'alpha', bundleId: 'com.acme.alpha' }, - { name: 'gamma', bundleId: 'com.acme.gamma' }, - ]); - }); + it('prints ok false with the error under --json when the build is rejected', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'INVALID' }))), + }); - it('narrows to the named apps', async () => { - expect(await Effect.runPromise(selectIosApps(apps, 'gamma'))).toEqual([ - { name: 'gamma', bundleId: 'com.acme.gamma' }, - ]); - }); + const run = await runStatusCommand({ build: '42', watch: true, json: true }, api); - it('fails on an unknown app name', async () => { - await expect(Effect.runPromise(selectIosApps(apps, 'delta'))).rejects.toThrow( - /Unknown iOS app/, - ); + expect(run.exitCode).toBe(1); + expect(run.logLines.join('\n')).toContain('"ok": false'); }); -}); -describe('watchReleaseStatuses', () => { - it('polls until the verdict is terminal and returns its exit code', async () => { - let readCount = 0; - const terminalWrites: string[] = []; - const exitCode = await Effect.runPromise( - Effect.gen(function* () { - const logger = yield* createLogger(false); - return yield* watchReleaseStatuses( - () => { - readCount += 1; - let appStoreState = 'IN_REVIEW'; - if (readCount > 1) appStoreState = 'READY_FOR_SALE'; - return Effect.succeed([ - { - appName: 'alpha', - releaseStatus: releaseStatus({ appStoreState }), - }, - ]); - }, - logger, - { - credentials: 'local', - storage: 'local', - buildEngine: 'xcode', - submit: 'app-store-connect', - profiles: {}, - }, - () => Effect.void, - ); - }).pipe( - Effect.provide(makeLaunchLoggerTest(terminalWrites)), - Effect.provide(NodeHttpClient.layer), - ), + it('fails with exit 1 and a clear message when --build has no iOS app to watch', async () => { + const run = await runStatusCommand( + { build: '42', watch: true }, + makeReleaseApiFake(), + APP_JSON_WITHOUT_IOS_TEXT, ); - expect(readCount).toBe(2); - expect(exitCode).toBe(0); - expect(terminalWrites.join('')).toContain('In review'); - expect(terminalWrites.join('')).toContain('Live on the App Store'); + + expect(run.exitCode).toBe(1); + expect(run.failureMessage).toMatch(/No iOS apps discovered/); }); }); diff --git a/src/core/release/statusCommand.ts b/src/core/release/statusCommand.ts index aad0a0c9..a9581deb 100644 --- a/src/core/release/statusCommand.ts +++ b/src/core/release/statusCommand.ts @@ -3,7 +3,6 @@ import { loadConfig } from '../config/config.js'; import { loadAscKeyForAccount } from '../credentials/accounts.js'; import { AppleStoreClientService, - appRecordMissingMessage, type AppleStoreClientService as AppleStoreClientRequirements, } from '../services/appleStoreClient.js'; import { errorMessage } from '../services/errorMessage.js'; @@ -11,7 +10,7 @@ import { createLogger, type Logger } from '../services/logger.js'; import { notify } from '../services/notify.js'; import { LaunchPaths, type LaunchPathsService } from '../services/paths.js'; import { CommandExitSchema, completeCommand, type CommandExit } from '../terminal/commandExit.js'; -import { type Column, formatTable } from '../terminal/table.js'; +import { alignTableColumns, type Column } from '../terminal/table.js'; import { endWithUsageFailure, makeUsageFailure } from '../terminal/usageFailure.js'; import type { AppDescriptor } from '../types/app.js'; import type { BuildResource } from '../types/appleCatalog.js'; @@ -19,16 +18,18 @@ import type { LaunchConfig } from '../types/config.js'; import { type AscReleaseApi, IOS_PLATFORM, - readReleaseStatus, + readAppStoreAppId, + readReleaseStatusForApp, type ReleaseStatus, waitForValidBuild, - type WaitOptions, } from './appStoreRelease.js'; import { createTransitionTracker, planTransitionNotifications } from './releaseNotify.js'; const WATCH_INTERVAL_MS = 30_000; +const NO_IOS_APPS_MESSAGE = + 'No iOS apps discovered. Add an app with an ios.bundleIdentifier in app.json.'; -export const StatusCommandInputSchema = Schema.Struct({ +const StatusFlagsSchema = Schema.Struct({ app: Schema.optionalWith(Schema.String, { exact: true }), account: Schema.optionalWith(Schema.String, { exact: true }), watch: Schema.optionalWith(Schema.Boolean, { default: () => false }), @@ -39,23 +40,19 @@ export const StatusCommandInputSchema = Schema.Struct({ json: Schema.optionalWith(Schema.Boolean, { default: () => false }), }); -export type StatusCommandInput = Schema.Schema.Type; +type StatusFlags = Schema.Schema.Type; -/** The options as the CLI passes them, before decoding: `--build` arrives as text. */ -export type StatusCommandOptions = Schema.Schema.Encoded; +export type StatusCommandFlags = Schema.Schema.Encoded; -/** One discovered iOS app reduced to status-read identity. */ -export type StatusApp = Readonly<{ name: string; bundleId: string }>; +type StatusApp = Readonly<{ name: string; bundleId: string }>; -/** One app and its latest App Store status, with its recent builds when `--builds` asked for them. */ -export type AppReleaseStatus = Readonly<{ +type AppReleaseStatus = Readonly<{ appName: string; releaseStatus: ReleaseStatus; recentBuilds?: readonly BuildResource[]; }>; -/** Reading or watching App Store status failed. */ -export type StatusCommandFailure = Readonly<{ +type StatusCommandFailure = Readonly<{ readonly _tag: 'StatusCommandFailure'; readonly operation: string; readonly message: string; @@ -71,6 +68,11 @@ const StatusCommandFailureSchema: Schema.Schema = Schema.S cause: Schema.Unknown, }); +const isStatusCommandFailure = (cause: unknown): cause is StatusCommandFailure => + Schema.is(StatusCommandFailureSchema)(cause); + +const isCommandExit = (cause: unknown): cause is CommandExit => Schema.is(CommandExitSchema)(cause); + type AccountRequirements = Effect.Effect.Context>; type NotifyRequirements = Effect.Effect.Context>; @@ -81,28 +83,22 @@ type StatusCommandRequirements = | Logger | NotifyRequirements; -/** Normalize one status command failure. */ -const statusFailure = ( - operation: string, - cause: unknown, - explicitMessage?: string, -): StatusCommandFailure => { - let message = errorMessage(cause); - if (explicitMessage !== undefined) message = explicitMessage; - return makeStatusCommandFailure({ operation, message, cause }); -}; +const createStatusFailure = (operation: string, cause: unknown): StatusCommandFailure => + makeStatusCommandFailure({ operation, message: errorMessage(cause), cause }); -/** Select iOS apps from discovery and an optional comma-separated handle list. */ export const selectIosApps = ( discoveredApps: readonly AppDescriptor[], appSelector: string | undefined, ): Effect.Effect => { const iosApps: StatusApp[] = []; for (const discoveredApp of discoveredApps) { - if (discoveredApp.bundleId === undefined) continue; - iosApps.push({ name: discoveredApp.name, bundleId: discoveredApp.bundleId }); + const { bundleId } = discoveredApp; + const isIosApp = bundleId !== undefined; + if (isIosApp) iosApps.push({ name: discoveredApp.name, bundleId }); } - if (appSelector === undefined) return Effect.succeed(iosApps); + + const isAppSelectorMissing = appSelector === undefined; + if (isAppSelectorMissing) return Effect.succeed(iosApps); const requestedNames = appSelector .split(',') .map((appName) => appName.trim()) @@ -111,25 +107,24 @@ export const selectIosApps = ( const selectedApps: StatusApp[] = []; for (const requestedName of requestedNames) { const selectedApp = iosAppsByName.get(requestedName); - if (selectedApp !== undefined) { + const isAppFound = selectedApp !== undefined; + if (isAppFound) { selectedApps.push(selectedApp); continue; } - let availableApps = 'none'; - if (iosApps.length > 0) availableApps = iosApps.map((iosApp) => iosApp.name).join(', '); + const isThereIosApp = iosApps.length > 0; + const availableApps = isThereIosApp ? iosApps.map((iosApp) => iosApp.name).join(', ') : 'none'; return Effect.fail( - statusFailure( + createStatusFailure( 'select iOS apps', - requestedName, - `Unknown iOS app "${requestedName}". iOS apps: ${availableApps}.`, + new Error(`Unknown iOS app "${requestedName}". iOS apps: ${availableApps}.`), ), ); } return Effect.succeed(selectedApps); }; -/** The build attached to the version: `build 42`, with its state while not VALID, or `no build`. */ -const formatAttachedBuild = (releaseStatus: ReleaseStatus): string => { +const describeAttachedBuild = (releaseStatus: ReleaseStatus): string => { const isNoBuildAttached = releaseStatus.buildNumber === null; if (isNoBuildAttached) return 'no build'; const isBuildStillProcessing = @@ -140,18 +135,16 @@ const formatAttachedBuild = (releaseStatus: ReleaseStatus): string => { return `build ${releaseStatus.buildNumber}`; }; -/** Format one human-readable App Store status line. */ -export const formatStatusLine = (releaseStatus: ReleaseStatus): string => { - const isVersionMissing = releaseStatus.versionString === null; - const versionPart = isVersionMissing ? 'no App Store version' : `v${releaseStatus.versionString}`; +const describeReleaseStatus = (releaseStatus: ReleaseStatus): string => { + const isVersionPresent = releaseStatus.versionString !== null; + const versionPart = isVersionPresent ? `v${releaseStatus.versionString}` : 'no App Store version'; const statusParts = [versionPart, releaseStatus.verdict.label]; - if (!isVersionMissing) statusParts.push(formatAttachedBuild(releaseStatus)); + if (isVersionPresent) statusParts.push(describeAttachedBuild(releaseStatus)); const isPhasedReleaseKnown = releaseStatus.phasedReleaseState !== null; if (isPhasedReleaseKnown) statusParts.push(`phased: ${releaseStatus.phasedReleaseState}`); return statusParts.join(' - '); }; -/** Rank process exit codes from most severe to least severe. */ const exitCodeRank = (exitCode: number): number => { switch (exitCode) { case 1: @@ -165,8 +158,7 @@ const exitCodeRank = (exitCode: number): number => { } }; -/** Return the most severe exit code in a status batch. */ -export const worstExitCode = (exitCodes: readonly number[]): number => { +const worstExitCode = (exitCodes: readonly number[]): number => { let worstCode = 0; for (const exitCode of exitCodes) { if (exitCodeRank(exitCode) > exitCodeRank(worstCode)) worstCode = exitCode; @@ -174,123 +166,88 @@ export const worstExitCode = (exitCodes: readonly number[]): number => { return worstCode; }; -/** Find an iOS app's App Store id; fails when App Store Connect has no record of the app. */ const readAppId = (appleClient: AscReleaseApi, selectedApp: StatusApp) => - Effect.gen(function* () { - const appId = yield* appleClient - .getAppId(selectedApp.bundleId) - .pipe( - Effect.mapError((cause) => - statusFailure(`find App Store app for ${selectedApp.name}`, cause), - ), - ); - const isThereNoAppRecord = appId === null; - if (isThereNoAppRecord) { - return yield* Effect.fail( - statusFailure( - `find App Store app for ${selectedApp.name}`, - selectedApp.bundleId, - appRecordMissingMessage(selectedApp.bundleId, 'launch status'), - ), - ); - } - return appId; - }); - -/** Upload time in milliseconds; a build with no upload time counts as the oldest. */ -const uploadTimeMilliseconds = (build: BuildResource): number => { - const isUploadTimeMissing = build.uploadedDate === undefined; - if (isUploadTimeMissing) return 0; - return Date.parse(build.uploadedDate); -}; + readAppStoreAppId(appleClient, selectedApp.bundleId).pipe( + Effect.mapError((cause) => + createStatusFailure(`find App Store app for ${selectedApp.name}`, cause), + ), + ); -/** Read an app's most recent builds from App Store Connect, newest upload first. */ -export const readRecentBuilds = ( +const listRecentBuilds = ( appleClient: AscReleaseApi, - selectedApp: StatusApp, -): Effect.Effect => - Effect.gen(function* () { - const appId = yield* readAppId(appleClient, selectedApp); - const recentBuildCount = 25; - const storeBuilds = yield* appleClient - .listBuilds(appId, recentBuildCount) - .pipe( - Effect.mapError((cause) => statusFailure(`list builds for ${selectedApp.name}`, cause)), - ); - return [...storeBuilds].sort( - (firstBuild, secondBuild) => - uploadTimeMilliseconds(secondBuild) - uploadTimeMilliseconds(firstBuild), - ); - }); + appId: string, + appName: string, +): Effect.Effect => { + const recentBuildCount = 25; + return appleClient + .listBuilds(appId, recentBuildCount) + .pipe(Effect.mapError((cause) => createStatusFailure(`list builds for ${appName}`, cause))); +}; -/** Upload time in UTC, to the minute, so the column reads the same on every machine. */ -const formatUploadTime = (uploadedDate: string | undefined): string => { +const describeUploadTime = (uploadedDate: string | undefined): string => { const isUploadTimeMissing = uploadedDate === undefined; if (isUploadTimeMissing) return '-'; return new Date(uploadedDate).toISOString().slice(0, 16).replace('T', ' '); }; -const RECENT_BUILD_COLUMNS: readonly Column[] = [ - { header: 'VERSION', cell: (build) => build.marketingVersion ?? '-' }, - { header: 'BUILD', cell: (build) => build.version }, - { header: 'PROCESSING', cell: (build) => build.processingState }, - { header: 'UPLOADED (UTC)', cell: (build) => formatUploadTime(build.uploadedDate) }, -]; - -/** Render an app's recent builds as a table, in the order given; says so when there are none. */ -export const formatRecentBuilds = (recentBuilds: readonly BuildResource[]): string => { +const describeRecentBuilds = (recentBuilds: readonly BuildResource[]): string => { const isThereNoBuild = recentBuilds.length === 0; if (isThereNoBuild) return ' no builds uploaded yet'; - return formatTable(RECENT_BUILD_COLUMNS, recentBuilds); + const recentBuildColumns: readonly Column[] = [ + { header: 'VERSION', cell: (build) => build.marketingVersion ?? '-' }, + { header: 'BUILD', cell: (build) => build.version }, + { header: 'PROCESSING', cell: (build) => build.processingState }, + { header: 'UPLOADED (UTC)', cell: (build) => describeUploadTime(build.uploadedDate) }, + ]; + return alignTableColumns(recentBuildColumns, recentBuilds); }; -/** Read one app's status, plus its recent builds when they were asked for. */ const readOneAppStatus = ( appleClient: AscReleaseApi, selectedApp: StatusApp, - shouldListBuilds: boolean, + isBuildListWanted: boolean, ): Effect.Effect => Effect.gen(function* () { - const releaseStatus = yield* readReleaseStatus( + const appId = yield* readAppId(appleClient, selectedApp); + const releaseStatus = yield* readReleaseStatusForApp( appleClient, + appId, selectedApp.bundleId, IOS_PLATFORM, ).pipe( Effect.mapError((cause) => - statusFailure(`read App Store status for ${selectedApp.name}`, cause), + createStatusFailure(`read App Store status for ${selectedApp.name}`, cause), ), ); - const isBuildListSkipped = !shouldListBuilds; + + const isBuildListSkipped = !isBuildListWanted; if (isBuildListSkipped) return { appName: selectedApp.name, releaseStatus }; - const recentBuilds = yield* readRecentBuilds(appleClient, selectedApp); + const recentBuilds = yield* listRecentBuilds(appleClient, appId, selectedApp.name); return { appName: selectedApp.name, releaseStatus, recentBuilds }; }); -/** Read every selected app concurrently through the shared Apple transport. */ const readSelectedStatuses = ( appleClient: AscReleaseApi, selectedApps: readonly StatusApp[], - shouldListBuilds: boolean, + isBuildListWanted: boolean, ): Effect.Effect => Effect.forEach( selectedApps, - (selectedApp) => readOneAppStatus(appleClient, selectedApp, shouldListBuilds), + (selectedApp) => readOneAppStatus(appleClient, selectedApp, isBuildListWanted), { concurrency: 'unbounded' }, ); -/** Print one app's status line, then its build table when `--builds` asked for it. */ const printOneAppStatus = ( logger: Logger, appStatus: AppReleaseStatus, ): Effect.Effect => Effect.gen(function* () { - yield* logger.step(appStatus.appName, formatStatusLine(appStatus.releaseStatus)); + yield* logger.step(appStatus.appName, describeReleaseStatus(appStatus.releaseStatus)); const isBuildListPresent = appStatus.recentBuilds !== undefined; if (!isBuildListPresent) return; - yield* logger.line(formatRecentBuilds(appStatus.recentBuilds)); + yield* logger.line(describeRecentBuilds(appStatus.recentBuilds)); }); -/** Print one human-readable status batch. */ const printStatusBatch = ( logger: Logger, appStatuses: readonly AppReleaseStatus[], @@ -300,19 +257,16 @@ const printStatusBatch = ( discard: true, }); -/** The JSON row for one app: its release status, plus its recent builds when they were asked for. */ -const toStatusJsonRow = (appStatus: AppReleaseStatus) => { +const createStatusJsonRow = (appStatus: AppReleaseStatus) => { const isBuildListPresent = appStatus.recentBuilds !== undefined; if (!isBuildListPresent) return appStatus.releaseStatus; return { ...appStatus.releaseStatus, recentBuilds: appStatus.recentBuilds }; }; -/** Poll until every selected app reaches a terminal verdict. */ -export const watchReleaseStatuses = ( +const watchReleaseStatuses = ( readStatuses: () => Effect.Effect, logger: Logger, launchConfiguration: LaunchConfig, - sleepBetweenPolls: () => Effect.Effect = () => Effect.sleep(WATCH_INTERVAL_MS), ): Effect.Effect => Effect.gen(function* () { const transitionTracker = createTransitionTracker(); @@ -332,23 +286,23 @@ export const watchReleaseStatuses = ( { concurrency: 1, discard: true }, ); } - const allDone = appStatuses.every((appStatus) => appStatus.releaseStatus.verdict.done); - if (allDone) { + + const isEveryAppDone = appStatuses.every((appStatus) => appStatus.releaseStatus.verdict.done); + if (isEveryAppDone) { return worstExitCode( appStatuses.map((appStatus) => appStatus.releaseStatus.verdict.exitCode), ); } - yield* sleepBetweenPolls(); + yield* Effect.sleep(WATCH_INTERVAL_MS); } }).pipe( Effect.mapError((cause) => { - if (Schema.is(StatusCommandFailureSchema)(cause)) return cause; - return statusFailure('watch App Store status', cause); + if (isStatusCommandFailure(cause)) return cause; + return createStatusFailure('watch App Store status', cause); }), ); -/** What `--watch --build` waits on: the app, the build number, and where progress is written. */ -export type BuildWatchRequest = Readonly<{ +type BuildWatchRequest = Readonly<{ appleClient: AscReleaseApi; selectedApp: StatusApp; buildNumber: number; @@ -356,49 +310,44 @@ export type BuildWatchRequest = Readonly<{ isJson: boolean; }>; -/** - * Poll one build until App Store Connect reports it VALID. Fails on FAILED or INVALID, and when the - * build is still missing or processing once the wait runs out. `--json` runs print no progress lines. - */ -export const watchBuildProcessing = ( +const watchBuildProcessing = ( buildWatchRequest: BuildWatchRequest, - pollOptions: Omit = {}, ): Effect.Effect => Effect.gen(function* () { const { appleClient, selectedApp, buildNumber, logger, isJson } = buildWatchRequest; const appId = yield* readAppId(appleClient, selectedApp); - const isProgressHidden = isJson; + + const isProgressShown = !isJson; const reportProgress = (processingState: string) => { - if (isProgressHidden) return Effect.void; + if (!isProgressShown) return Effect.void; return logger .step(selectedApp.name, `build ${buildNumber} is ${processingState}`) .pipe(Effect.orDie); }; const storeBuild = yield* waitForValidBuild(appleClient, appId, buildNumber, { - ...pollOptions, onTick: reportProgress, }).pipe( Effect.mapError((cause) => - statusFailure(`wait for build ${buildNumber} of ${selectedApp.name}`, cause), + createStatusFailure(`wait for build ${buildNumber} of ${selectedApp.name}`, cause), ), ); return storeBuild; }); -/** Print the watched build's outcome: a VALID result as a line or JSON object, or the failure as JSON. */ -const reportBuildWatchOutcome = ( +const reportValidBuild = ( buildWatchRequest: BuildWatchRequest, storeBuild: BuildResource, ): Effect.Effect => Effect.gen(function* () { const { logger, selectedApp, buildNumber, isJson } = buildWatchRequest; if (isJson) { - const buildOutcome = { + const validBuildReport = { + ok: true, appName: selectedApp.name, - buildNumber: storeBuild.version, + buildNumber, processingState: storeBuild.processingState, }; - yield* logger.line(JSON.stringify(buildOutcome, null, 2)).pipe(Effect.orDie); + yield* logger.line(JSON.stringify(validBuildReport, null, 2)).pipe(Effect.orDie); return; } yield* logger @@ -409,77 +358,91 @@ const reportBuildWatchOutcome = ( .pipe(Effect.orDie); }); -/** Print a failed build wait as JSON when `--json` asked for machine output; the command still fails. */ -const reportBuildWatchFailure = ( +const reportFailedBuildWatch = ( buildWatchRequest: BuildWatchRequest, watchFailure: StatusCommandFailure, ): Effect.Effect => Effect.gen(function* () { const { logger, selectedApp, buildNumber, isJson } = buildWatchRequest; - if (!isJson) return; - const failureOutcome = { + const isHumanOutput = !isJson; + if (isHumanOutput) return; + const failedBuildReport = { + ok: false, appName: selectedApp.name, buildNumber, error: watchFailure.message, }; - yield* logger.line(JSON.stringify(failureOutcome, null, 2)).pipe(Effect.orDie); + yield* logger.line(JSON.stringify(failedBuildReport, null, 2)).pipe(Effect.orDie); }); -/** Explain a flag combination the status command cannot run, or return null when the flags fit together. */ -export const findStatusUsageProblem = ( - commandInput: StatusCommandInput, - selectedApps: readonly StatusApp[], -): string | null => { - const isBuildWithoutWatch = commandInput.build !== undefined && !commandInput.watch; - if (isBuildWithoutWatch) +const findStatusUsageProblem = (statusFlags: StatusFlags, iosAppCount: number): string | null => { + const isBuildWithoutWatch = statusFlags.build !== undefined && !statusFlags.watch; + if (isBuildWithoutWatch) { return '--build needs --watch: it waits for that build to finish processing.'; - const isBuildsWithWatch = commandInput.builds && commandInput.watch; + } + const isBuildsWithWatch = statusFlags.builds && statusFlags.watch; if (isBuildsWithWatch) return '--builds lists builds without waiting: drop --watch to list them.'; - const isBuildForSeveralApps = commandInput.build !== undefined && selectedApps.length !== 1; + const isBuildForSeveralApps = statusFlags.build !== undefined && iosAppCount > 1; if (isBuildForSeveralApps) return '--build waits on one build of one app: pass --app .'; return null; }; -/** Run the schema-decoded App Store status command. */ export const statusCommandProgram = ( - rawCommandInput: unknown, + flagsFromCli: StatusCommandFlags, ): Effect.Effect => Effect.gen(function* () { - const commandInput = yield* Schema.decodeUnknown(StatusCommandInputSchema)(rawCommandInput); + const isJsonRequested = flagsFromCli.json === true; + const statusFlags = yield* Schema.decodeUnknown(StatusFlagsSchema)(flagsFromCli).pipe( + Effect.catchTag('ParseError', () => + endWithUsageFailure( + makeUsageFailure({ message: '--build takes a whole number above 0, such as 42.' }), + isJsonRequested, + ), + ), + ); const launchPaths = yield* LaunchPaths; const logger = yield* createLogger(false); const loadedConfiguration = yield* loadConfig(launchPaths.workingDirectory); - const selectedApps = yield* selectIosApps(loadedConfiguration.apps, commandInput.app); - const isThereNoSelectedApp = selectedApps.length === 0; - if (isThereNoSelectedApp) { - yield* logger.note( - 'No iOS apps discovered. Add an app with an ios.bundleIdentifier in app.json.', - ); - return; - } - const usageProblem = findStatusUsageProblem(commandInput, selectedApps); + const selectedApps = yield* selectIosApps(loadedConfiguration.apps, statusFlags.app); + + const usageProblem = findStatusUsageProblem(statusFlags, selectedApps.length); const isUsageProblemFound = usageProblem !== null; if (isUsageProblemFound) { return yield* endWithUsageFailure( makeUsageFailure({ message: usageProblem }), - commandInput.json, + statusFlags.json, ); } - const ascKey = yield* loadAscKeyForAccount(commandInput.account); + + const isThereNoSelectedApp = selectedApps.length === 0; + const isBuildWatchWithNoApp = isThereNoSelectedApp && statusFlags.build !== undefined; + if (isBuildWatchWithNoApp) { + return yield* Effect.fail( + createStatusFailure('find iOS app', new Error(NO_IOS_APPS_MESSAGE)), + ); + } + if (isThereNoSelectedApp) { + yield* logger.note(NO_IOS_APPS_MESSAGE); + return; + } + + const ascKey = yield* loadAscKeyForAccount(statusFlags.account); const isThereNoAscKey = ascKey === null; if (isThereNoAscKey) { return yield* Effect.fail( - statusFailure( + createStatusFailure( 'load Apple account', - 'missing-account', - 'No Apple account key found. Run `launch creds set-key`, or pick one with --account.', + new Error( + 'No Apple account key found. Run `launch creds set-key`, or pick one with --account.', + ), ), ); } const appleStoreClients = yield* AppleStoreClientService; const appleClient = yield* appleStoreClients.createClient(ascKey); - const watchedBuildNumber = commandInput.build; + const [onlySelectedApp] = selectedApps; + const watchedBuildNumber = statusFlags.build; const isBuildWatch = watchedBuildNumber !== undefined && onlySelectedApp !== undefined; if (isBuildWatch) { const buildWatchRequest: BuildWatchRequest = { @@ -487,16 +450,18 @@ export const statusCommandProgram = ( selectedApp: onlySelectedApp, buildNumber: watchedBuildNumber, logger, - isJson: commandInput.json, + isJson: statusFlags.json, }; const storeBuild = yield* watchBuildProcessing(buildWatchRequest).pipe( - Effect.tapError((watchFailure) => reportBuildWatchFailure(buildWatchRequest, watchFailure)), + Effect.tapError((watchFailure) => reportFailedBuildWatch(buildWatchRequest, watchFailure)), ); - return yield* reportBuildWatchOutcome(buildWatchRequest, storeBuild); + return yield* reportValidBuild(buildWatchRequest, storeBuild); } - const isWatchingInTerminal = commandInput.watch && !commandInput.json; + + const isWatchingInTerminal = statusFlags.watch && !statusFlags.json; if (isWatchingInTerminal) { - const readStatuses = () => readSelectedStatuses(appleClient, selectedApps, false); + const isBuildListWanted = false; + const readStatuses = () => readSelectedStatuses(appleClient, selectedApps, isBuildListWanted); const watchExitCode = yield* watchReleaseStatuses( readStatuses, logger, @@ -504,20 +469,23 @@ export const statusCommandProgram = ( ); return yield* completeCommand(watchExitCode); } - const appStatuses = yield* readSelectedStatuses(appleClient, selectedApps, commandInput.builds); - const isJsonOutput = commandInput.json; + + const appStatuses = yield* readSelectedStatuses(appleClient, selectedApps, statusFlags.builds); + const isJsonOutput = statusFlags.json; if (isJsonOutput) { - yield* logger.line(JSON.stringify(appStatuses.map(toStatusJsonRow), null, 2)); + yield* logger.line(JSON.stringify(appStatuses.map(createStatusJsonRow), null, 2)); } - if (!isJsonOutput) yield* printStatusBatch(logger, appStatuses); + + const isHumanOutput = !isJsonOutput; + if (isHumanOutput) yield* printStatusBatch(logger, appStatuses); const statusExitCode = worstExitCode( appStatuses.map((appStatus) => appStatus.releaseStatus.verdict.exitCode), ); - yield* completeCommand(statusExitCode); + return yield* completeCommand(statusExitCode); }).pipe( Effect.mapError((cause) => { - if (Schema.is(CommandExitSchema)(cause)) return cause; - if (Schema.is(StatusCommandFailureSchema)(cause)) return cause; - return statusFailure('read App Store status', cause); + if (isCommandExit(cause)) return cause; + if (isStatusCommandFailure(cause)) return cause; + return createStatusFailure('read App Store status', cause); }), ); diff --git a/src/core/terminal/table.ts b/src/core/terminal/table.ts index 715bb6d4..a2e46e5d 100644 --- a/src/core/terminal/table.ts +++ b/src/core/terminal/table.ts @@ -1,11 +1,9 @@ -/** A table column and its domain-specific cell renderer. */ export type Column = Readonly<{ header: string; cell: (tableEntry: TableEntry) => string; }>; -/** Render a non-empty collection as a padded text table. */ -export const formatTable = ( +export const alignTableColumns = ( columns: readonly Column[], tableEntries: readonly TableEntry[], ): string => { @@ -15,19 +13,20 @@ export const formatTable = ( ...tableEntries.map((tableEntry) => column.cell(tableEntry).length), ), ); - const renderCells = (cells: readonly string[]): string => + const joinCellsIntoLine = (cells: readonly string[]): string => cells .map((cell, columnIndex) => { - const width = widths[columnIndex]; - if (width === undefined) return cell; - return cell.padEnd(width); + const columnWidth = widths[columnIndex]; + const isColumnWidthMissing = columnWidth === undefined; + if (isColumnWidthMissing) return cell; + return cell.padEnd(columnWidth); }) .join(' ') .trimEnd(); return [ - renderCells(columns.map((column) => column.header)), + joinCellsIntoLine(columns.map((column) => column.header)), ...tableEntries.map((tableEntry) => - renderCells(columns.map((column) => column.cell(tableEntry))), + joinCellsIntoLine(columns.map((column) => column.cell(tableEntry))), ), ].join('\n'); }; diff --git a/src/core/types/appleCatalog.ts b/src/core/types/appleCatalog.ts index 9a1d083f..868bdaf3 100644 --- a/src/core/types/appleCatalog.ts +++ b/src/core/types/appleCatalog.ts @@ -455,9 +455,7 @@ export type AppStoreVersionResource = Readonly<{ releaseType?: string; /** Id of the build attached to this version; absent when no build is attached. */ attachedBuildId?: string; - /** The attached build's number (its `version` attribute); absent when no build is attached. */ attachedBuildNumber?: string; - /** The attached build's processing state (`PROCESSING`, `VALID`, ...); absent when no build is attached. */ attachedBuildProcessingState?: string; }>; /** One locale's editable version copy. Launch only ever writes `whatsNew` (the release notes). */ From 0b5b003cfc65b7c04e0600a33744b8c663f15b61 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:34:17 +0200 Subject: [PATCH 10/10] refactor(apple): name the marketing version filter helper with a verb --- src/apple/ascClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apple/ascClient.ts b/src/apple/ascClient.ts index 1ca6dcd8..51f36682 100644 --- a/src/apple/ascClient.ts +++ b/src/apple/ascClient.ts @@ -553,7 +553,7 @@ type BetaFeedbackPage = { }; const BUILD_PAGE_QUERY = 'fields[builds]=version,processingState,uploadedDate,expired,preReleaseVersion&include=preReleaseVersion&fields[preReleaseVersions]=version'; -const buildMarketingVersionFilter = (marketingVersion: string | undefined): string => { +const createMarketingVersionFilter = (marketingVersion: string | undefined): string => { const isMarketingVersionGiven = marketingVersion !== undefined; if (!isMarketingVersionGiven) return ''; return `&filter[preReleaseVersion.version]=${marketingVersion}`; @@ -3985,7 +3985,7 @@ export class AppStoreConnectClient { buildNumber: number, marketingVersion?: string, ): Promise { - const versionFilter = buildMarketingVersionFilter(marketingVersion); + const versionFilter = createMarketingVersionFilter(marketingVersion); const buildPage = await this.request( 'GET', `/builds?filter[app]=${appId}&filter[version]=${buildNumber}${versionFilter}&sort=-uploadedDate&limit=1&${BUILD_PAGE_QUERY}`,