diff --git a/cli/package.json b/cli/package.json index 4ec2e038bb..fbd197378e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -175,7 +175,7 @@ "test:android-version": "bun test/test-android-version.mjs", "test:platform-flow-contract": "bun test/test-platform-flow-contract.mjs", "test:tail-engine-shared": "bun test/test-tail-engine-shared.mjs", - "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:bundle-validation && bun run test:functional && bun run test:semver && bun run test:auto-bump-version && bun run test:auto-bump-ai-diff && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:native-dependencies && bun run test:package-json-guard && bun run test:credentials && bun run test:credentials-export && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:build-cancellation && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:cli-headers && bun run test:min-cli-version && bun run test:authenticated-command-invocation && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:app-set-options && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:app-list-output-text && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:cli-recovery && bun run test:create-supabase-client && bun run test:build-platform-selection && bun run test:builder-project-discovery && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-monorepo-targeting && bun run test:init-app-conflict && bun run test:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-replay && bun run test:init-telemetry && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:ios-marketing-version && bun run test:android-version && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan && bun run test:android-reporting-api && bun run test:android-app-verification && bun run test:android-rename && bun run test:appflow-auth && bun run test:appflow-api-map && bun run test:appflow-validate && bun run test:appflow-flow && bun run test:appflow-gapfill && bun run test:appflow-engine && bun run test:appflow-tail && bun run test:appflow-fetch && bun run test:appflow-sa-decode && bun run test:app-permission-helper && bun run test:2fa-compliance-network && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation", + "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:bundle-validation && bun run test:functional && bun run test:semver && bun run test:auto-bump-version && bun run test:auto-bump-ai-diff && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:native-dependencies && bun run test:package-json-guard && bun run test:credentials && bun run test:credentials-export && bun run test:ios-provisioning-map && bun run test:ios-provisioning-command && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:build-cancellation && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:cli-headers && bun run test:min-cli-version && bun run test:authenticated-command-invocation && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:app-set-options && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:app-list-output-text && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:cli-recovery && bun run test:create-supabase-client && bun run test:build-platform-selection && bun run test:builder-project-discovery && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-monorepo-targeting && bun run test:init-app-conflict && bun run test:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-replay && bun run test:init-telemetry && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:ios-marketing-version && bun run test:android-version && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan && bun run test:android-reporting-api && bun run test:android-app-verification && bun run test:android-rename && bun run test:appflow-auth && bun run test:appflow-api-map && bun run test:appflow-validate && bun run test:appflow-flow && bun run test:appflow-gapfill && bun run test:appflow-engine && bun run test:appflow-tail && bun run test:appflow-fetch && bun run test:appflow-sa-decode && bun run test:app-permission-helper && bun run test:2fa-compliance-network && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:builder-project-discovery": "bun test/test-builder-project-discovery.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", @@ -223,7 +223,9 @@ "test:trial-warning": "bun test/test-trial-warning.mjs", "test:plan-validation": "bun test/test-plan-validation.mjs", "test:auto-bump-version": "bun test/test-auto-bump-version.mjs", - "test:auto-bump-ai-diff": "bun test/test-auto-bump-ai-diff.mjs" + "test:auto-bump-ai-diff": "bun test/test-auto-bump-ai-diff.mjs", + "test:ios-provisioning-map": "bun test/test-ios-provisioning-map.mjs", + "test:ios-provisioning-command": "bun test/test-ios-provisioning-command.mjs" }, "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/cli/src/build/credentials-export-command.ts b/cli/src/build/credentials-export-command.ts index 5c00344a4f..6ff0e5f50e 100644 --- a/cli/src/build/credentials-export-command.ts +++ b/cli/src/build/credentials-export-command.ts @@ -1,4 +1,4 @@ -import type { SavedCredentials } from '../schemas/build' +import type { CredentialsPlatform, CredentialsStoreName, CredentialsStores } from './credentials-store-selection' import type { FileHandle } from 'node:fs/promises' import { link, mkdtemp, open, rmdir, unlink } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' @@ -8,18 +8,15 @@ import { canPromptInteractively, formatError } from '../utils' import { getGlobalCredentialsPath, getLocalCredentialsPath, loadSavedCredentials } from './credentials' import { canDecodeCredentialBase64, decodeCredentialBase64 } from './credentials-base64' import { quoteCredentialsExportTerminalValue, writeCredentialsExportStderr } from './credentials-export-terminal' +import { credentialsPlatformFields, hasConfiguredCredentials, resolveCredentialsStore } from './credentials-store-selection' -type Platform = 'ios' | 'android' -type Store = 'local' | 'global' type CredentialsExportOptions = { appId?: string, platform?: string, local?: boolean, global?: boolean, file?: string, raw?: boolean, decodeBase64?: boolean } -type CredentialsExportStores = Record -type ResolvedCredentialsExport = { value: string, source: Store, platforms: Platform[] } +type ResolvedCredentialsExport = { value: string, source: CredentialsStoreName, platforms: CredentialsPlatform[] } type FileValue = { data: string | Buffer, decoded: boolean, warnLiteral: boolean } type FileHandleForExport = Pick type FileWriterDependencies = { mkdtemp?: typeof mkdtemp, open?: typeof open, link?: typeof link, unlink?: typeof unlink, rmdir?: typeof rmdir } -const platforms: Platform[] = ['ios', 'android'] -const stores: Store[] = ['local', 'global'] +const platforms: CredentialsPlatform[] = ['ios', 'android'] export function isCredentialsExportInvocation(argv: readonly string[]): boolean { for (let commandIndex = 2; commandIndex < argv.length - 2; commandIndex++) { @@ -47,30 +44,16 @@ export function isCredentialsExportInvocation(argv: readonly string[]): boolean return false } -function record(value: unknown): Record | undefined { - return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined -} - -function platformFields(saved: SavedCredentials | null, platform: Platform): Record | undefined { - const app = record(saved) - return app && Object.hasOwn(app, platform) ? record(app[platform]) : undefined -} - -function configured(saved: SavedCredentials | null, platform?: Platform): boolean { - const fields = platform === undefined ? platforms.map(item => platformFields(saved, item)) : [platformFields(saved, platform)] - return fields.some(field => Object.values(field ?? {}).some(value => typeof value === 'string')) -} - -function storedValue(saved: SavedCredentials, platform: Platform, variable: string): string | undefined { - const fields = platformFields(saved, platform) +function storedValue(saved: CredentialsStores[CredentialsStoreName], platform: CredentialsPlatform, variable: string): string | undefined { + const fields = credentialsPlatformFields(saved, platform) const value = fields && Object.hasOwn(fields, variable) ? fields[variable] : undefined return typeof value === 'string' ? value : undefined } const quoted = (value: string | undefined) => quoteCredentialsExportTerminalValue(value) -function resolvedFor(saved: SavedCredentials, source: Store, platform: Platform, variable: string): ResolvedCredentialsExport { - if (!configured(saved, platform)) +function resolvedFor(saved: NonNullable, source: CredentialsStoreName, platform: CredentialsPlatform, variable: string): ResolvedCredentialsExport { + if (!hasConfiguredCredentials(saved, platform)) throw new Error(`${platform} is not configured in the ${source} store`) const value = storedValue(saved, platform, variable) if (value === undefined) @@ -78,26 +61,16 @@ function resolvedFor(saved: SavedCredentials, source: Store, platform: Platform, return { value, source, platforms: [platform] } } -export function resolveCredentialsExport(variable: string, options: CredentialsExportOptions, savedStores: CredentialsExportStores): ResolvedCredentialsExport { - if (options.local && options.global) - throw new Error('Cannot use --local and --global together') - if (options.platform !== undefined && !platforms.includes(options.platform as Platform)) +export function resolveCredentialsExport(variable: string, options: CredentialsExportOptions, savedStores: CredentialsStores): ResolvedCredentialsExport { + if (options.platform !== undefined && !platforms.includes(options.platform as CredentialsPlatform)) throw new Error('--platform must be ios or android') - const available = stores.filter(source => configured(savedStores[source])) - const source = options.local || options.global ? options.local ? 'local' : 'global' : available[0] - if (source === undefined) - throw new Error(`No saved Builder credentials for ${quoted(options.appId)}`) - if (!configured(savedStores[source])) - throw new Error(`No saved Builder credentials for ${quoted(options.appId)} in the ${source} store`) - if (!options.local && !options.global && available.length > 1) - throw new Error('Saved Builder credentials exist in both stores; pass --local or --global') - const saved = savedStores[source]! - const selected = options.platform as Platform | undefined + const { source, saved } = resolveCredentialsStore(options, savedStores) + const selected = options.platform as CredentialsPlatform | undefined if (selected) return resolvedFor(saved, source, selected, variable) - const configuredPlatforms = platforms.filter(platform => configured(saved, platform)) + const configuredPlatforms = platforms.filter(platform => hasConfiguredCredentials(saved, platform)) if (configuredPlatforms.length === 1) return resolvedFor(saved, source, configuredPlatforms[0]!, variable) const [first, second] = configuredPlatforms diff --git a/cli/src/build/credentials-store-selection.ts b/cli/src/build/credentials-store-selection.ts new file mode 100644 index 0000000000..2007f0c205 --- /dev/null +++ b/cli/src/build/credentials-store-selection.ts @@ -0,0 +1,60 @@ +import type { SavedCredentials } from '../schemas/build' +import { quoteCredentialsExportTerminalValue } from './credentials-export-terminal' + +export type CredentialsStoreName = 'local' | 'global' +export type CredentialsPlatform = 'ios' | 'android' + +export interface CredentialsStoreOptions { + appId?: string + local?: boolean + global?: boolean +} + +export type CredentialsStores = Record + +export interface ResolvedCredentialsStore { + source: CredentialsStoreName + saved: SavedCredentials +} + +const platforms: CredentialsPlatform[] = ['ios', 'android'] +const stores: CredentialsStoreName[] = ['local', 'global'] + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined +} + +export function credentialsPlatformFields(saved: SavedCredentials | null, platform: CredentialsPlatform): Record | undefined { + const app = record(saved) + return app && Object.hasOwn(app, platform) ? record(app[platform]) : undefined +} + +export function hasConfiguredCredentials(saved: SavedCredentials | null, platform?: CredentialsPlatform): boolean { + const fields = platform === undefined + ? platforms.map(item => credentialsPlatformFields(saved, item)) + : [credentialsPlatformFields(saved, platform)] + return fields.some(field => Object.values(field ?? {}).some(value => typeof value === 'string')) +} + +export function resolveCredentialsStore(options: CredentialsStoreOptions, savedStores: CredentialsStores): ResolvedCredentialsStore { + if (options.local && options.global) + throw new Error('Cannot use --local and --global together') + + const available = stores.filter(source => hasConfiguredCredentials(savedStores[source])) + const source = options.local || options.global + ? options.local ? 'local' : 'global' + : available[0] + const appId = quoteCredentialsExportTerminalValue(options.appId) + + if (source === undefined) + throw new Error(`No saved Builder credentials for ${appId}`) + const saved = savedStores[source] + if (!hasConfiguredCredentials(saved)) + throw new Error(`No saved Builder credentials for ${appId} in the ${source} store`) + if (!options.local && !options.global && available.length > 1) + throw new Error('Saved Builder credentials exist in both stores; pass --local or --global') + + return { source, saved: saved! } +} diff --git a/cli/src/build/ios-provisioning-command.ts b/cli/src/build/ios-provisioning-command.ts new file mode 100644 index 0000000000..7d4c54bc84 --- /dev/null +++ b/cli/src/build/ios-provisioning-command.ts @@ -0,0 +1,315 @@ +import type { SavedCredentials } from '../schemas/build' +import type { CredentialsStoreName, CredentialsStores } from './credentials-store-selection' +import type { ProvisioningMap, ProvisioningTargetGroup } from './ios-provisioning-map' +import type { PbxTarget } from './pbxproj-parser' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { cwd, exit } from 'node:process' +import { confirm, isCancel, log } from '@clack/prompts' +import { canPromptInteractively, formatError, getAppId, getConfig } from '../utils' +import { loadSavedCredentials, updateSavedCredentials } from './credentials' +import { decodeCredentialBase64 } from './credentials-base64' +import { resolveCredentialsStore } from './credentials-store-selection' +import { analyzeProvisioningCoverage, createProvisioningMapEntry, isConcreteBundleId, parseProvisioningMap } from './ios-provisioning-map' +import { DuplicateProfileError, createProfile, deleteProfile, ensureBundleId, findCertBySha1, generateJwt, verifyApiKey } from './onboarding/apple-api' +import { findSignableTargets, findXcodeProject } from './pbxproj-parser' +import { getPlatformDirFromCapacitorConfig } from './platform-paths' +import { openP12 } from './prescan/checks/ios-certs' + +export interface IosProvisioningOptions { + local?: boolean + global?: boolean +} + +export interface IosProvisioningProject { + appId: string + targets: PbxTarget[] +} + +export interface IosProvisioningCommandDeps { + loadProject: () => Promise + loadStores: (appId: string, options: IosProvisioningOptions) => Promise + persistMap: (appId: string, source: CredentialsStoreName, map: ProvisioningMap) => Promise + canPrompt: () => boolean + confirm: (message: string) => Promise + logInfo: (message: string) => void + generateJwt: typeof generateJwt + verifyApiKey: typeof verifyApiKey + openP12: typeof openP12 + findCertBySha1: typeof findCertBySha1 + ensureBundleId: typeof ensureBundleId + createProfile: typeof createProfile + deleteProfile: typeof deleteProfile +} + +function formatTargets(targets: ProvisioningTargetGroup[]): string { + return targets.map(target => `${target.targetNames.join('/')} (${target.bundleId})`).join(', ') +} + +function formatTargetList(targets: ProvisioningTargetGroup[]): string { + return targets + .map(target => ` • ${target.targetNames.join('/')}\n Bundle ID: ${target.bundleId}`) + .join('\n') +} + +async function confirmRequired(deps: IosProvisioningCommandDeps, message: string): Promise { + if (!deps.canPrompt()) + throw new Error('This change requires confirmation in an interactive terminal. Run the command locally and retry.') + return deps.confirm(message) +} + +function requireP8Credentials(credentials: NonNullable): void { + const required = ['APPLE_KEY_ID', 'APPLE_ISSUER_ID', 'APPLE_KEY_CONTENT', 'BUILD_CERTIFICATE_BASE64'] as const + const missing = required.filter(key => !credentials[key]) + if (missing.length > 0) { + throw new Error(`App-specific password credentials are not supported for provisioning profile generation. Save a complete App Store Connect .p8 key and signing certificate first. Missing: ${missing.join(', ')}`) + } +} + +function safeCause(error: unknown, secrets: string[]): string { + let message = formatError(error) + for (const secret of secrets.filter(value => value.length >= 4)) + message = message.replaceAll(secret, '[REDACTED]') + return message +} + +async function prepareAppleCredentials(credentials: NonNullable, deps: IosProvisioningCommandDeps) { + requireP8Credentials(credentials) + const encodedKey = credentials.APPLE_KEY_CONTENT! + let p8Content: string + try { + p8Content = decodeCredentialBase64(encodedKey).toString('utf8') + } + catch { + throw new Error('The saved App Store Connect .p8 key is invalid') + } + const secrets = [encodedKey, p8Content, credentials.BUILD_CERTIFICATE_BASE64!, credentials.P12_PASSWORD ?? ''] + const freshToken = () => { + try { + return deps.generateJwt(credentials.APPLE_KEY_ID!, credentials.APPLE_ISSUER_ID!, p8Content) + } + catch { + throw new Error('The saved App Store Connect .p8 key is invalid') + } + } + + try { + await deps.verifyApiKey(freshToken()) + } + catch (error) { + throw new Error(`The saved App Store Connect .p8 key is invalid or does not have access: ${safeCause(error, secrets)}`) + } + + let certificateSha1: string + try { + certificateSha1 = deps.openP12(credentials.BUILD_CERTIFICATE_BASE64!, credentials.P12_PASSWORD ?? '').sha1 + } + catch { + throw new Error('The saved iOS signing certificate or P12 password is invalid') + } + + let certificate + try { + certificate = await deps.findCertBySha1(freshToken(), certificateSha1) + } + catch (error) { + throw new Error(`Cannot verify the saved signing certificate in App Store Connect: ${safeCause(error, secrets)}`) + } + if (!certificate) + throw new Error('The saved iOS signing certificate is not available to this App Store Connect .p8 key') + return { certificateId: certificate.id, freshToken, secrets } +} + +async function createTargetProfile( + target: ProvisioningTargetGroup, + certificateId: string, + freshToken: () => string, + secrets: string[], + deps: IosProvisioningCommandDeps, +) { + let bundleResource + try { + bundleResource = await deps.ensureBundleId(freshToken(), target.bundleId) + } + catch (error) { + throw new Error(`Could not prepare the Apple bundle id for ${formatTargets([target])}: ${safeCause(error, secrets)}`) + } + + const create = () => deps.createProfile(freshToken(), bundleResource.bundleIdResourceId, certificateId, target.bundleId) + try { + return await create() + } + catch (error) { + if (!(error instanceof DuplicateProfileError)) + throw new Error(`Could not create a provisioning profile for ${formatTargets([target])}: ${safeCause(error, secrets)}`) + + const replace = await confirmRequired( + deps, + `Replace these existing Capgo provisioning profiles for ${formatTargets([target])}? ${error.profiles.map(profile => profile.name).join(', ')}`, + ) + if (!replace) + throw new Error(`Provisioning profile replacement was declined for ${formatTargets([target])}`) + + for (const profile of error.profiles) { + try { + await deps.deleteProfile(freshToken(), profile.id) + } + catch { + throw new Error(`Could not delete all existing Capgo provisioning profiles for ${formatTargets([target])}. The saved map was not changed for this target.`) + } + } + try { + return await create() + } + catch { + throw new Error(`Existing Capgo provisioning profiles were deleted, but the replacement for ${formatTargets([target])} could not be created. Retry the command.`) + } + } +} + +export async function runIosProvisioningCommand(options: IosProvisioningOptions, deps: IosProvisioningCommandDeps): Promise { + if (options.local && options.global) + throw new Error('Cannot use --local and --global together') + + const project = await deps.loadProject() + if (!project.appId) + throw new Error('The Capacitor project does not define an app id') + if (project.targets.length === 0) + throw new Error('The iOS Xcode project has no signable targets') + + const unresolved = project.targets.filter(target => !isConcreteBundleId(target.bundleId)) + if (unresolved.length > 0) + throw new Error(`Cannot resolve the bundle id for: ${unresolved.map(target => target.name).join(', ')}`) + + const stores = await deps.loadStores(project.appId, options) + const { source, saved } = resolveCredentialsStore({ ...options, appId: project.appId }, stores) + const credentials = saved.ios + if (!credentials) + throw new Error(`No saved iOS Builder credentials for ${project.appId} in the ${source} store`) + if (credentials.CAPGO_IOS_DISTRIBUTION === 'ad_hoc') + throw new Error('Ad Hoc provisioning is not supported by this command') + + let map = parseProvisioningMap(credentials.CAPGO_IOS_PROVISIONING_MAP) + let coverage = analyzeProvisioningCoverage(project.targets, map) + if (coverage.missing.length === 0) { + deps.logInfo('All iOS targets have provisioning profiles saved in Capgo.') + return + } + if (coverage.wildcardConflict.length > 0) + throw new Error('Sorry, multiple matching wildcard provisioning profiles are not supported') + + let declinedWildcard = false + if (coverage.wildcardReuse) { + const accepted = await confirmRequired( + deps, + `Update the provisioning profile map so these targets reuse "${coverage.wildcardReuse.entry.name}"?\n\n${formatTargetList(coverage.wildcardReuse.targets)}`, + ) + if (accepted) { + const repaired = { ...map } + for (const target of coverage.wildcardReuse.targets) + repaired[target.bundleId] = coverage.wildcardReuse.entry + await deps.persistMap(project.appId, source, repaired) + map = repaired + coverage = analyzeProvisioningCoverage(project.targets, map) + } + else { + declinedWildcard = true + } + } + + const generationTargets = declinedWildcard ? coverage.missing : coverage.generation + if (generationTargets.length === 0) { + deps.logInfo('All iOS targets have provisioning profiles saved in Capgo.') + return + } + + const apple = await prepareAppleCredentials(credentials, deps) + const generate = await confirmRequired( + deps, + `Generate App Store provisioning profiles for these targets?\n\n${formatTargetList(generationTargets)}`, + ) + if (!generate) + throw new Error('Provisioning profile generation was declined; no Apple resources were changed') + + for (const target of generationTargets) { + const profile = await createTargetProfile(target, apple.certificateId, apple.freshToken, apple.secrets, deps) + const updated = { + ...map, + [target.bundleId]: createProvisioningMapEntry(profile.profileContent, profile.profileName, target.bundleId), + } + await deps.persistMap(project.appId, source, updated) + map = updated + deps.logInfo(`Saved a provisioning profile for ${formatTargets([target])}.`) + } + deps.logInfo('All iOS targets have provisioning profiles saved in Capgo.') +} + +async function loadDefaultProject(): Promise { + const { config } = await getConfig(true) + const appId = getAppId(undefined, config) + if (!appId) + throw new Error('The Capacitor project does not define an app id') + + const projectDir = cwd() + const iosDir = resolve(projectDir, getPlatformDirFromCapacitorConfig(config, 'ios')) + if (!existsSync(iosDir)) + throw new Error('iOS is not configured in this Capacitor project. Run `npx cap add ios` first.') + const pbxprojPath = findXcodeProject(iosDir) + if (!pbxprojPath) + throw new Error(`No Xcode project was found in ${iosDir}`) + let pbxproj: string + try { + pbxproj = readFileSync(pbxprojPath, 'utf8') + } + catch { + throw new Error(`Cannot read the Xcode project at ${pbxprojPath}`) + } + const targets = findSignableTargets(pbxproj) + if (targets.length === 0) + throw new Error('The iOS Xcode project has no signable targets') + return { appId, targets } +} + +async function loadDefaultStores(appId: string, options: IosProvisioningOptions): Promise { + if (options.local) + return { local: await loadSavedCredentials(appId, true, true), global: null } + if (options.global) + return { local: null, global: await loadSavedCredentials(appId, false, true) } + return { + local: await loadSavedCredentials(appId, true, true), + global: await loadSavedCredentials(appId, false, true), + } +} + +export function defaultIosProvisioningCommandDeps(): IosProvisioningCommandDeps { + return { + loadProject: loadDefaultProject, + loadStores: loadDefaultStores, + persistMap: (appId, source, map) => updateSavedCredentials(appId, 'ios', { CAPGO_IOS_PROVISIONING_MAP: JSON.stringify(map) }, source === 'local'), + canPrompt: canPromptInteractively, + confirm: async message => { + const answer = await confirm({ message }) + return !isCancel(answer) && answer + }, + logInfo: message => log.info(message), + generateJwt, + verifyApiKey, + openP12, + findCertBySha1, + ensureBundleId, + createProfile, + deleteProfile, + } +} + +export async function iosProvisioningCommand(options: IosProvisioningOptions): Promise { + try { + await runIosProvisioningCommand(options, defaultIosProvisioningCommandDeps()) + } + catch (error) { + log.error(formatError(error)) + exit(1) + } +} + +export { DuplicateProfileError } diff --git a/cli/src/build/ios-provisioning-map.ts b/cli/src/build/ios-provisioning-map.ts new file mode 100644 index 0000000000..de7393bf1c --- /dev/null +++ b/cli/src/build/ios-provisioning-map.ts @@ -0,0 +1,184 @@ +import type { PbxTarget } from './pbxproj-parser' +import { parseMobileprovisionDetailedFromBase64 } from './mobileprovision-parser' + +export type ProvisioningMapErrorCode = 'missing' | 'empty' | 'malformed' | 'invalid' + +export class ProvisioningMapError extends Error { + constructor(public readonly code: ProvisioningMapErrorCode, message: string) { + super(message) + this.name = 'ProvisioningMapError' + } +} + +export interface ProvisioningMapEntry { + profile: string + name: string + readonly bundleId: string +} + +export type ProvisioningMap = Record + +export interface ProvisioningTargetGroup { + bundleId: string + targetNames: string[] +} + +export interface WildcardReuse { + entry: ProvisioningMapEntry + sourceKeys: string[] + targets: ProvisioningTargetGroup[] +} + +export interface ProvisioningCoverage { + exact: ProvisioningTargetGroup[] + missing: ProvisioningTargetGroup[] + unresolved: PbxTarget[] + wildcardReuse: WildcardReuse | null + wildcardConflict: ProvisioningTargetGroup[] + generation: ProvisioningTargetGroup[] +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined +} + +function invalidMap(message: string): never { + throw new ProvisioningMapError('invalid', message) +} + +/** Cache the parsed bundle ID without changing the persisted { profile, name } contract. */ +export function createProvisioningMapEntry(profile: string, name: string, bundleId: string): ProvisioningMapEntry { + const entry = { profile, name } as ProvisioningMapEntry + Object.defineProperty(entry, 'bundleId', { value: bundleId }) + return entry +} + +export function parseProvisioningMap(raw: unknown): ProvisioningMap { + if (raw === undefined || raw === null) + throw new ProvisioningMapError('missing', 'No provisioning profile map is saved') + if (typeof raw !== 'string' || raw.trim() === '') + invalidMap('The saved provisioning profile map is empty or invalid') + + let value: unknown + try { + value = JSON.parse(raw) + } + catch { + throw new ProvisioningMapError('malformed', 'The saved provisioning profile map is not valid JSON') + } + + const entries = record(value) + if (!entries) + invalidMap('The saved provisioning profile map must be a JSON object') + if (Object.keys(entries).length === 0) + throw new ProvisioningMapError('empty', 'The saved provisioning profile map has no profiles') + + const map = Object.create(null) as ProvisioningMap + for (const [bundleId, stored] of Object.entries(entries)) { + if (!bundleId.trim()) + invalidMap('The saved provisioning profile map contains an empty bundle ID') + const storedRecord = record(stored) + const profile = typeof stored === 'string' + ? stored + : storedRecord && Object.hasOwn(storedRecord, 'profile') && typeof storedRecord.profile === 'string' + ? storedRecord.profile + : undefined + if (!profile) + invalidMap(`The saved provisioning profile for ${bundleId} is invalid`) + + try { + const detail = parseMobileprovisionDetailedFromBase64(profile) + map[bundleId] = createProvisioningMapEntry(profile, detail.name, detail.bundleId) + } + catch { + invalidMap(`The saved provisioning profile for ${bundleId} is invalid`) + } + } + return map +} + +export function isConcreteBundleId(bundleId: string): boolean { + const value = bundleId.trim() + return value.length > 0 + && !value.includes('*') + && !value.includes('$(') + && !value.includes('${') +} + +export function wildcardBundleMatches(wildcardBundleId: string, targetBundleId: string): boolean { + if (wildcardBundleId === '*') + return true + if (!wildcardBundleId.endsWith('.*')) + return false + const prefix = wildcardBundleId.slice(0, -2) + return prefix.length > 0 && targetBundleId.startsWith(`${prefix}.`) && targetBundleId.length > prefix.length + 1 +} + +function groupTargets(targets: PbxTarget[]): ProvisioningTargetGroup[] { + const groups = new Map() + for (const target of targets) { + const existing = groups.get(target.bundleId) + if (existing) + existing.targetNames.push(target.name) + else + groups.set(target.bundleId, { bundleId: target.bundleId, targetNames: [target.name] }) + } + return [...groups.values()] +} + +export function analyzeProvisioningCoverage(targets: PbxTarget[], map: ProvisioningMap): ProvisioningCoverage { + const unresolved = targets.filter(target => !isConcreteBundleId(target.bundleId)) + const groups = groupTargets(targets.filter(target => isConcreteBundleId(target.bundleId))) + const exact = groups.filter(target => Object.hasOwn(map, target.bundleId)) + const missing = groups.filter(target => !Object.hasOwn(map, target.bundleId)) + + const matchingWildcards = new Map + }>() + + for (const [sourceKey, entry] of Object.entries(map)) { + const matchingTargets = missing.filter(target => wildcardBundleMatches(entry.bundleId, target.bundleId)) + if (matchingTargets.length === 0) + continue + const existing = matchingWildcards.get(entry.profile) + if (existing) { + existing.sourceKeys.push(sourceKey) + for (const target of matchingTargets) + existing.targetBundleIds.add(target.bundleId) + } + else { + matchingWildcards.set(entry.profile, { + entry, + sourceKeys: [sourceKey], + targetBundleIds: new Set(matchingTargets.map(target => target.bundleId)), + }) + } + } + + const wildcardCandidates = [...matchingWildcards.values()] + const matchedBundleIds = new Set(wildcardCandidates.flatMap(candidate => [...candidate.targetBundleIds])) + const wildcardConflict = wildcardCandidates.length > 1 + ? missing.filter(target => matchedBundleIds.has(target.bundleId)) + : [] + const candidate = wildcardCandidates.length === 1 ? wildcardCandidates[0]! : undefined + const wildcardReuse = candidate + ? { + entry: candidate.entry, + sourceKeys: candidate.sourceKeys, + targets: missing.filter(target => candidate.targetBundleIds.has(target.bundleId)), + } + : null + + return { + exact, + missing, + unresolved, + wildcardReuse, + wildcardConflict, + generation: missing.filter(target => !matchedBundleIds.has(target.bundleId)), + } +} diff --git a/cli/src/build/prescan/checks/ios-profiles.ts b/cli/src/build/prescan/checks/ios-profiles.ts index a62fad458a..47477c0cb2 100644 --- a/cli/src/build/prescan/checks/ios-profiles.ts +++ b/cli/src/build/prescan/checks/ios-profiles.ts @@ -1,6 +1,7 @@ // src/build/prescan/checks/ios-profiles.ts import type { MobileprovisionDetail } from '../../mobileprovision-parser' import type { Finding, PrescanCheck, ScanContext } from '../types' +import { analyzeProvisioningCoverage, parseProvisioningMap as parseStoredProvisioningMap, wildcardBundleMatches } from '../../ios-provisioning-map' import { parseMobileprovisionDetailedFromBase64 } from '../../mobileprovision-parser' import { openP12 } from './ios-certs' @@ -29,10 +30,8 @@ export function parseProvisioningMap(ctx: ScanContext): MappedProfile[] { return [] const entries: MappedProfile[] = [] for (const [bundleId, value] of Object.entries(obj)) { - if (typeof value === 'string') { - // tolerated legacy/raw shape: { bundleId: base64 } + if (typeof value === 'string') entries.push({ bundleId, base64: value }) - } else if (value && typeof value === 'object' && typeof (value as { profile?: unknown }).profile === 'string') { const entry = value as { profile: string, name?: string } entries.push({ bundleId, base64: entry.profile, name: entry.name }) @@ -90,11 +89,7 @@ export const profileExpiry: PrescanCheck = { } function bundleMatches(profileBundleId: string, appBundleId: string): boolean { - if (profileBundleId === '*') - return true - if (profileBundleId.endsWith('.*')) - return appBundleId.startsWith(profileBundleId.slice(0, -1)) - return profileBundleId === appBundleId + return profileBundleId === appBundleId || wildcardBundleMatches(profileBundleId, appBundleId) } export const profileBundleMatch: PrescanCheck = { @@ -178,24 +173,82 @@ export const certProfilePairing: PrescanCheck = { export const targetsCovered: PrescanCheck = { id: 'ios/targets-covered', platforms: ['ios'], - appliesTo: hasMap, + appliesTo: ctx => ctx.credentials?.CAPGO_IOS_PROVISIONING_MAP !== undefined, async run(ctx): Promise { + let map + try { + map = parseStoredProvisioningMap(ctx.credentials?.CAPGO_IOS_PROVISIONING_MAP) + } + catch (error) { + return [{ + id: 'ios/targets-covered', + severity: 'error', + title: 'Saved iOS provisioning profile map cannot be used', + detail: error instanceof Error ? error.message : 'The saved provisioning profile map is invalid', + fix: 'Save or update the iOS provisioning profile map before building', + }] + } const { findSignableTargets, readPbxproj } = await import('../../pbxproj-parser') const pbx = readPbxproj(ctx.projectDir) if (!pbx) return [] const targets = findSignableTargets(pbx) - const mapped = parseProvisioningMap(ctx) - // targets without a resolvable bundle id cannot be matched — skip them rather than false-error - const missing = targets.filter(t => t.bundleId && !mapped.some(p => bundleMatches(p.bundleId, t.bundleId))) + const coverage = analyzeProvisioningCoverage(targets, map) + const wildcardOwned = new Set([ + ...(coverage.wildcardReuse?.targets ?? []), + ...coverage.wildcardConflict, + ].map(target => target.bundleId)) + const missing = coverage.missing.filter(target => !wildcardOwned.has(target.bundleId)) if (missing.length === 0) return [] + const missingCount = missing.reduce((count, target) => count + target.targetNames.length, 0) + const hasMultipleTargets = targets.filter(target => target.bundleId).length > 1 return [{ id: 'ios/targets-covered', severity: 'error', - title: `${missing.length} signable target(s) have no provisioning profile mapped`, - detail: `uncovered: ${missing.map(t => `${t.name} (${t.bundleId})`).join(', ')}`, - fix: 'Add --ios-provisioning-profile "bundleId=/path/to/profile.mobileprovision" for each and re-save credentials', + title: `${missingCount} signable target(s) have no provisioning profile mapped`, + detail: `uncovered: ${missing.flatMap(target => target.targetNames.map(name => `${name} (${target.bundleId})`)).join(', ')}`, + fix: hasMultipleTargets + ? 'Run npx @capgo/cli@latest build credentials ios-provisioning to set up every target' + : 'Add --ios-provisioning-profile "bundleId=/path/to/profile.mobileprovision" and re-save credentials', + }] + }, +} + +export const wildcardProfileTargets: PrescanCheck = { + id: 'ios/wildcard-profile-targets', + platforms: ['ios'], + appliesTo: ctx => ctx.credentials?.CAPGO_IOS_PROVISIONING_MAP !== undefined, + async run(ctx): Promise { + let map + try { + map = parseStoredProvisioningMap(ctx.credentials?.CAPGO_IOS_PROVISIONING_MAP) + } + catch { + return [] // ios/targets-covered owns malformed and invalid saved maps + } + const { findSignableTargets, readPbxproj } = await import('../../pbxproj-parser') + const pbx = readPbxproj(ctx.projectDir) + if (!pbx) + return [] + const coverage = analyzeProvisioningCoverage(findSignableTargets(pbx), map) + if (coverage.wildcardConflict.length > 0) { + return [{ + id: 'ios/wildcard-profile-targets', + severity: 'error', + title: 'Sorry, multiple matching wildcard provisioning profiles are not supported', + detail: `targets: ${coverage.wildcardConflict.map(target => `${target.targetNames.join('/')} (${target.bundleId})`).join(', ')}`, + fix: 'Remove or replace the conflicting wildcard profiles in the saved map, then retry', + }] + } + if (!coverage.wildcardReuse) + return [] + return [{ + id: 'ios/wildcard-profile-targets', + severity: 'error', + title: `${coverage.wildcardReuse.targets.length} target bundle id(s) can reuse a saved wildcard provisioning profile`, + detail: `targets: ${coverage.wildcardReuse.targets.map(target => `${target.targetNames.join('/')} (${target.bundleId})`).join(', ')}`, + fix: 'Run npx @capgo/cli@latest build credentials ios-provisioning to confirm and update the map', }] }, } diff --git a/cli/src/build/prescan/registry.ts b/cli/src/build/prescan/registry.ts index c3f22b73e2..20c687aa47 100644 --- a/cli/src/build/prescan/registry.ts +++ b/cli/src/build/prescan/registry.ts @@ -37,7 +37,7 @@ import { import { credentialsSaved } from './checks/credentials' import { ascKeyValid, p12Expiry, p12LegacyEncryption, p12Opens } from './checks/ios-certs' import { infoplistSanity } from './checks/ios-plist' -import { certProfilePairing, profileBundleMatch, profileExpiry, profileTypeVsMode, targetsCovered } from './checks/ios-profiles' +import { certProfilePairing, profileBundleMatch, profileExpiry, profileTypeVsMode, targetsCovered, wildcardProfileTargets } from './checks/ios-profiles' import { allowNavigationWildcard, serverCleartext, @@ -119,7 +119,7 @@ export const ALL_CHECKS: PrescanCheck[] = [ // ios certs / profiles / plist p12Opens, { ...p12LegacyEncryption, enforceAfter: IOS_P12_LEGACY_ENFORCE_AFTER }, p12Expiry, profileExpiry, profileBundleMatch, profileTypeVsMode, - certProfilePairing, targetsCovered, infoplistSanity, ascKeyValid, + certProfilePairing, targetsCovered, wildcardProfileTargets, infoplistSanity, ascKeyValid, // android keystore / project keystoreOpens, keystoreExpiry, cordovaVarsPresent, gradlePropsHeuristics, playSaJson, flavorExists, agp8PackageAttr, diff --git a/cli/src/index.ts b/cli/src/index.ts index 97dcc9e870..536e62805c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -18,6 +18,8 @@ import { clearCredentialsCommand, listCredentialsCommand, migrateCredentialsComm import { exportCredentialsCommand, isCredentialsExportInvocation } from './build/credentials-export-command' import { sanitizeCredentialsExportTerminalText, writeCredentialsExportStderr } from './build/credentials-export-terminal' import { manageCredentialsCommand } from './build/credentials-manage' +import type { IosProvisioningOptions } from './build/ios-provisioning-command' +import { iosProvisioningCommand } from './build/ios-provisioning-command' import { syncIosMarketingVersionCommand } from './build/ios-marketing-version' import { lastOutputCommand } from './build/last-output-command' import { checkBuildNeeded } from './build/needed' @@ -1086,6 +1088,19 @@ Example: .option('--local', 'Save into the per-project .capgo-credentials.json instead of the global file') .option('--json', 'Print the captured Key ID / Issuer ID / .p8 path as JSON') +buildCredentials + .command('ios-provisioning') + .description(`Set up provisioning profiles for every signable iOS target. + +Reuses an eligible saved wildcard profile after confirmation, or generates +missing App Store profiles with the saved App Store Connect .p8 key. + +Example: + npx @capgo/cli@latest build credentials ios-provisioning`) + .option('--local', 'Use credentials from the current project') + .option('--global', 'Use credentials from the global store') + .action((options: IosProvisioningOptions) => iosProvisioningCommand(options)) + buildCredentials .command('save') .description(`Save build credentials locally for iOS or Android. diff --git a/cli/test/prescan/checks-ios-profiles.test.ts b/cli/test/prescan/checks-ios-profiles.test.ts index 18ec56be61..9855cc5f2d 100644 --- a/cli/test/prescan/checks-ios-profiles.test.ts +++ b/cli/test/prescan/checks-ios-profiles.test.ts @@ -8,6 +8,7 @@ import { profileExpiry, profileTypeVsMode, targetsCovered, + wildcardProfileTargets, } from '../../src/build/prescan/checks/ios-profiles' import { makeCtx, makeP12, makeProfileXml, makeProfileXmlWithCert, makeProject } from './helpers' @@ -167,6 +168,7 @@ describe('ios/targets-covered', () => { expect(f[0]?.severity).toBe('error') expect(f[0]?.title).toContain('1 signable target') expect(f[0]?.detail).toContain('Widget') + expect(f[0]?.fix).toContain('npx @capgo/cli@latest build credentials ios-provisioning') }) it('passes when every signable target bundle id is covered', async () => { const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': TWO_TARGET_PBXPROJ }) @@ -177,4 +179,71 @@ describe('ios/targets-covered', () => { const ctx = makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: map } }) expect(await targetsCovered.run(ctx)).toEqual([]) }) + + it('reports present empty, malformed, and invalid maps without suggesting the command', async () => { + const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': TWO_TARGET_PBXPROJ }) + for (const raw of ['{}', 'not json', JSON.stringify({ bad: { profile: 'not-a-profile' } })]) { + const ctx = makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: raw } }) + const findings = await targetsCovered.run(ctx) + expect(findings[0]?.severity).toBe('error') + expect(findings[0]?.fix).toContain('Save or update') + expect(findings[0]?.fix).not.toContain('ios-provisioning') + } + }) + + it('keeps generic repair guidance for a single-target project', async () => { + const singleTarget = TWO_TARGET_PBXPROJ + .replace(/ AA11BB22CC33DD44[\s\S]*? };\n 13B07F931A680F5B00A75B9A/, ' 13B07F931A680F5B00A75B9A') + .replace(/ AA11BB22CC33DD55[\s\S]*? };\n };/, ' };') + const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': singleTarget }) + const map = JSON.stringify({ other: { profile: b64(makeProfileXml({ bundleId: 'com.other.app' })), name: 'Other' } }) + const findings = await targetsCovered.run(makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: map } })) + expect(findings[0]?.fix).toContain('--ios-provisioning-profile') + expect(findings[0]?.fix).not.toContain('build credentials ios-provisioning') + }) +}) + +describe('ios/wildcard-profile-targets', () => { + it('owns matching wildcard targets and recommends the repair command', async () => { + const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': TWO_TARGET_PBXPROJ }) + const wildcard = b64(makeProfileXml({ bundleId: 'com.demo.*' })) + const map = JSON.stringify({ + 'com.demo.app': { profile: b64(makeProfileXml()), name: 'App' }, + wildcard: { profile: wildcard, name: 'Wildcard' }, + }) + const ctx = makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: map } }) + + const findings = await wildcardProfileTargets.run(ctx) + expect(findings[0]?.severity).toBe('error') + expect(findings[0]?.detail).toContain('Widget') + expect(findings[0]?.fix).toContain('npx @capgo/cli@latest build credentials ios-provisioning') + expect(await targetsCovered.run(ctx)).toEqual([]) + }) + + it('fails unsupported when different wildcard profiles match', async () => { + const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': TWO_TARGET_PBXPROJ }) + const map = JSON.stringify({ + 'com.demo.app': { profile: b64(makeProfileXml()), name: 'App' }, + broad: { profile: b64(makeProfileXml({ bundleId: '*' })), name: 'Broad' }, + prefix: { profile: b64(makeProfileXml({ bundleId: 'com.demo.*' })), name: 'Prefix' }, + }) + const findings = await wildcardProfileTargets.run(makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: map } })) + expect(findings[0]?.title).toBe('Sorry, multiple matching wildcard provisioning profiles are not supported') + expect(findings[0]?.fix).toContain('Remove or replace') + }) + + it('deduplicates identical wildcard bytes and ignores exact-complete or nonmatching maps', async () => { + const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': TWO_TARGET_PBXPROJ }) + const wildcard = b64(makeProfileXml({ bundleId: 'com.demo.*' })) + const duplicateMap = JSON.stringify({ first: wildcard, second: { profile: wildcard, name: 'Duplicate' } }) + const duplicateFindings = await wildcardProfileTargets.run(makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: duplicateMap } })) + expect(duplicateFindings[0]?.title).not.toContain('multiple') + + const exactMap = JSON.stringify({ + 'com.demo.app': b64(makeProfileXml()), + 'com.demo.app.widget': b64(makeProfileXml({ bundleId: 'com.demo.app.widget' })), + wildcard: b64(makeProfileXml({ bundleId: 'org.other.*' })), + }) + expect(await wildcardProfileTargets.run(makeCtx({ projectDir: dir, platform: 'ios', credentials: { CAPGO_IOS_PROVISIONING_MAP: exactMap } }))).toEqual([]) + }) }) diff --git a/cli/test/prescan/engine.test.ts b/cli/test/prescan/engine.test.ts index a0795d4053..103b6bdf5e 100644 --- a/cli/test/prescan/engine.test.ts +++ b/cli/test/prescan/engine.test.ts @@ -150,15 +150,15 @@ describe('fixture helpers', () => { }) describe('registry', () => { - it('contains all 82 checks with unique ids', () => { + it('contains all 83 checks with unique ids', () => { const ids = ALL_CHECKS.map(c => c.id) expect(new Set(ids).size).toBe(ids.length) - expect(ids.length).toBe(82) + expect(ids.length).toBe(83) for (const expected of [ 'shared/apikey-permission', 'shared/app-exists', 'shared/credentials-saved', 'shared/cap-sync-stale', 'shared/node-linker-layout', 'shared/bundle-id-consistency', 'ios/p12-opens', 'ios/p12-legacy-encryption', 'ios/p12-expiry', 'ios/profile-expiry', 'ios/profile-bundle-match', - 'ios/profile-type-vs-mode', 'ios/cert-profile-pairing', 'ios/targets-covered', + 'ios/profile-type-vs-mode', 'ios/cert-profile-pairing', 'ios/targets-covered', 'ios/wildcard-profile-targets', 'ios/infoplist-sanity', 'ios/asc-key-valid', // 10 ios plist checks 'ios/plist-bundle-id-format', 'ios/plist-version-short-format', 'ios/plist-version-build-format', diff --git a/cli/test/test-app-list-output-text.mjs b/cli/test/test-app-list-output-text.mjs index 3c039cb8c2..1bc05da2af 100644 --- a/cli/test/test-app-list-output-text.mjs +++ b/cli/test/test-app-list-output-text.mjs @@ -47,36 +47,35 @@ finally { const keyFixture = mkdtempSync(join(tmpdir(), 'capgo-app-list-key-')) const projectFixture = join(keyFixture, 'project') -const originalHome = process.env.HOME -const originalCwd = process.cwd() -const originalFixtureToken = process.env.CAPGO_TOKEN try { mkdirSync(projectFixture) writeFileSync(join(keyFixture, '.capgo'), '') writeFileSync(join(projectFixture, '.capgo'), 'local-output-text-token') - process.env.HOME = keyFixture - delete process.env.CAPGO_TOKEN - process.chdir(projectFixture) - - const messages = [] - assert.equal(findSavedKey(false, message => messages.push(message)), 'local-output-text-token') - assert.deepEqual(messages, ['Use local API key .capgo']) - - writeFileSync(join(projectFixture, '.capgo'), '') - messages.length = 0 - assert.throws(() => findSavedKey(false, message => messages.push(message)), /No Capgo API key found/) - assert.deepEqual(messages, [], 'does not announce an empty API-key file') + const childEnv = { ...process.env, HOME: keyFixture, USERPROFILE: keyFixture } + delete childEnv.CAPGO_TOKEN + const utilsUrl = new URL('../src/utils.ts', import.meta.url).href + const child = spawnSync(process.execPath, ['--eval', ` + import { writeFileSync } from 'node:fs' + import { findSavedKey } from ${JSON.stringify(utilsUrl)} + const messages = [] + const key = findSavedKey(false, message => messages.push(message)) + const localMessages = [...messages] + writeFileSync('.capgo', '') + messages.length = 0 + let emptyError = '' + try { findSavedKey(false, message => messages.push(message)) } + catch (error) { emptyError = error.message } + process.stdout.write('CAPGO_TEST_RESULT=' + JSON.stringify({ key, localMessages, messages, emptyError })) + `], { cwd: projectFixture, encoding: 'utf8', env: childEnv }) + assert.equal(child.status, 0, child.stderr) + const marker = 'CAPGO_TEST_RESULT=' + const result = JSON.parse(child.stdout.slice(child.stdout.lastIndexOf(marker) + marker.length)) + assert.equal(result.key, 'local-output-text-token') + assert.deepEqual(result.localMessages, ['Use local API key .capgo']) + assert.deepEqual(result.messages, [], 'does not announce an empty API-key file') + assert.match(result.emptyError, /No Capgo API key found/) } finally { - process.chdir(originalCwd) - if (originalHome === undefined) - delete process.env.HOME - else - process.env.HOME = originalHome - if (originalFixtureToken === undefined) - delete process.env.CAPGO_TOKEN - else - process.env.CAPGO_TOKEN = originalFixtureToken rmSync(keyFixture, { recursive: true, force: true }) } diff --git a/cli/test/test-cli-recovery.mjs b/cli/test/test-cli-recovery.mjs index dcdb96a2a1..7dc3b62977 100644 --- a/cli/test/test-cli-recovery.mjs +++ b/cli/test/test-cli-recovery.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -251,7 +251,7 @@ await test('resolveUpdaterPackageJsonPath resolves root-relative package.json op try { process.chdir(root) const resolved = resolveUpdaterPackageJsonPath('missing/package.json,apps/mobile/package.json') - assert.equal(resolved, join(root, 'apps', 'mobile', 'package.json')) + assert.equal(resolved, realpathSync(join(root, 'apps', 'mobile', 'package.json'))) } finally { process.chdir(previousCwd) diff --git a/cli/test/test-credentials-export.mjs b/cli/test/test-credentials-export.mjs index 4d21130aae..d336a5db3c 100644 --- a/cli/test/test-credentials-export.mjs +++ b/cli/test/test-credentials-export.mjs @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { canDecodeCredentialBase64, decodeCredentialBase64 } from '../src/build/credentials-base64.ts' +import { resolveCredentialsStore } from '../src/build/credentials-store-selection.ts' const { isCredentialsExportInvocation, resolveCredentialsExport, @@ -126,6 +127,27 @@ const appId = 'com.example.app' const localIos = { ios: { BUILD_CERTIFICATE_BASE64: 'local-cert', P12_PASSWORD: '' } } const globalAndroid = { android: { ANDROID_KEYSTORE_FILE: 'global-store' } } +await test('shared store resolver selects the sole configured store', () => { + assert.deepEqual( + resolveCredentialsStore({ appId }, { local: localIos, global: null }), + { source: 'local', saved: localIos }, + ) +}) + +await test('shared store resolver rejects split local and global credentials', () => { + assert.throws( + () => resolveCredentialsStore({ appId }, { local: localIos, global: globalAndroid }), + /pass --local or --global/i, + ) +}) + +await test('shared store resolver never falls back from an explicit source', () => { + assert.throws( + () => resolveCredentialsStore({ appId, local: true }, { local: null, global: globalAndroid }), + /local store/i, + ) +}) + await test('automatically chooses a local-only configured source and platform', () => { assert.deepEqual( resolveCredentialsExport('BUILD_CERTIFICATE_BASE64', { appId }, { local: localIos, global: null }), diff --git a/cli/test/test-ios-provisioning-command.mjs b/cli/test/test-ios-provisioning-command.mjs new file mode 100644 index 0000000000..a5a9b1cf06 --- /dev/null +++ b/cli/test/test-ios-provisioning-command.mjs @@ -0,0 +1,461 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { makeProfileXml } from './prescan/helpers.ts' +import { DuplicateProfileError, runIosProvisioningCommand } from '../src/build/ios-provisioning-command.ts' + +let passed = 0 + +async function test(name, fn) { + try { + await fn() + passed++ + console.log(`✅ PASSED: ${name}`) + } + catch (error) { + console.error(`❌ FAILED: ${name}`) + throw error + } +} + +function profile(bundleId, name = 'Test Profile') { + return Buffer.from(makeProfileXml({ bundleId }).replace('Test Profile', `${name}`)).toString('base64') +} + +function map(entries) { + return JSON.stringify(entries) +} + +function appTarget(bundleId = 'com.example.app') { + return { name: 'App', bundleId, productType: 'com.apple.product-type.application' } +} + +function widgetTarget(bundleId = 'com.example.app.widget') { + return { name: 'Widget', bundleId, productType: 'com.apple.product-type.app-extension' } +} + +function iosCredentials(provisioningMap, extra = {}) { + return { ios: { CAPGO_IOS_PROVISIONING_MAP: provisioningMap, ...extra } } +} + +function generationCredentials(provisioningMap, extra = {}) { + return iosCredentials(provisioningMap, { + APPLE_KEY_ID: 'KEY1234567', + APPLE_ISSUER_ID: '11111111-2222-3333-4444-555555555555', + APPLE_KEY_CONTENT: Buffer.from('test-p8-pem').toString('base64'), + BUILD_CERTIFICATE_BASE64: 'test-p12-base64', + ...extra, + }) +} + +function baseDeps(overrides = {}) { + const state = { prompts: [], writes: [], logs: [], appleCalls: [] } + const deps = { + loadProject: async () => ({ appId: 'com.example.app', targets: [appTarget()] }), + loadStores: async () => ({ + local: iosCredentials(map({ 'com.example.app': profile('com.example.app') })), + global: null, + }), + persistMap: async (appId, source, value) => state.writes.push({ appId, source, value: structuredClone(value) }), + canPrompt: () => true, + confirm: async (message) => { + state.prompts.push(message) + return true + }, + logInfo: message => state.logs.push(message), + generateJwt: () => { + state.appleCalls.push('generateJwt') + return 'token' + }, + verifyApiKey: async () => state.appleCalls.push('verifyApiKey'), + openP12: () => { + state.appleCalls.push('openP12') + return { sha1: 'a'.repeat(40) } + }, + findCertBySha1: async () => { + state.appleCalls.push('findCertBySha1') + return { id: 'cert-id' } + }, + ensureBundleId: async () => { + state.appleCalls.push('ensureBundleId') + return { bundleIdResourceId: 'bundle-id' } + }, + createProfile: async () => { + state.appleCalls.push('createProfile') + return { profileId: 'profile-id', profileName: 'Created', profileContent: profile('com.example.app') } + }, + deleteProfile: async () => state.appleCalls.push('deleteProfile'), + ...overrides, + } + return { deps, state } +} + +await test('rejects an empty app id, no targets, and unresolved target bundle ids before store reads', async () => { + for (const [project, expected] of [ + [{ appId: '', targets: [appTarget()] }, /app id/i], + [{ appId: 'com.example.app', targets: [] }, /signable/i], + [{ appId: 'com.example.app', targets: [appTarget('$(PRODUCT_BUNDLE_IDENTIFIER)')] }, /resolve.*bundle id/i], + ]) { + let storeReads = 0 + const { deps } = baseDeps({ + loadProject: async () => project, + loadStores: async () => { + storeReads++ + return { local: null, global: null } + }, + }) + await assert.rejects(runIosProvisioningCommand({}, deps), expected) + assert.equal(storeReads, 0) + } +}) + +await test('uses the Capacitor app id for source selection and follows shared split-store rules', async () => { + let loadedAppId + const exact = iosCredentials(map({ 'com.example.app': profile('com.example.app') })) + const { deps } = baseDeps({ + loadStores: async (appId) => { + loadedAppId = appId + return { local: exact, global: exact } + }, + }) + await assert.rejects(runIosProvisioningCommand({}, deps), /pass --local or --global/i) + assert.equal(loadedAppId, 'com.example.app') + await runIosProvisioningCommand({ local: true }, deps) + + let storeReads = 0 + const invalid = baseDeps({ + loadStores: async () => { + storeReads++ + return { local: exact, global: exact } + }, + }) + await assert.rejects(runIosProvisioningCommand({ local: true, global: true }, invalid.deps), /cannot use --local and --global together/i) + assert.equal(storeReads, 0) +}) + +await test('requires existing iOS credentials and a valid nonempty provisioning map', async () => { + for (const [saved, expected] of [ + [{ android: { ANDROID_KEYSTORE_FILE: 'store' } }, /iOS Builder credentials/i], + [{ ios: {} }, /No saved Builder credentials/i], + [{ ios: { BUILD_CERTIFICATE_BASE64: 'cert' } }, /provisioning profile map.*saved/i], + [iosCredentials('{}'), /no profiles/i], + [iosCredentials('{broken'), /valid JSON/i], + [iosCredentials(map({ bad: 'not-a-profile' })), /bad.*invalid/i], + ]) { + const { deps } = baseDeps({ loadStores: async () => ({ local: saved, global: null }) }) + await assert.rejects(runIosProvisioningCommand({}, deps), expected) + } +}) + +await test('rejects ad hoc but ignores app-specific passwords when exact coverage is complete', async () => { + const exactMap = map({ 'com.example.app': profile('com.example.app') }) + const adHoc = baseDeps({ + loadStores: async () => ({ local: iosCredentials(exactMap, { CAPGO_IOS_DISTRIBUTION: 'ad_hoc' }), global: null }), + }) + await assert.rejects(runIosProvisioningCommand({}, adHoc.deps), /ad hoc.*not supported/i) + + const complete = baseDeps({ + loadStores: async () => ({ + local: iosCredentials(exactMap, { APPLE_APP_SPECIFIC_PASSWORD: 'ignored', APPLE_APP_ID: 'ignored' }), + global: null, + }), + }) + await runIosProvisioningCommand({}, complete.deps) + assert.equal(complete.state.prompts.length, 0) + assert.equal(complete.state.appleCalls.length, 0) + assert.match(complete.state.logs.at(-1), /all iOS targets/i) +}) + +await test('confirms one wildcard reuse and persists exact canonical entries in one write', async () => { + const wildcard = profile('com.example.*', 'Wildcard Profile') + const { deps, state } = baseDeps({ + loadProject: async () => ({ appId: 'com.example.app', targets: [appTarget(), widgetTarget()] }), + loadStores: async () => ({ local: iosCredentials(map({ wildcard })), global: null }), + }) + await runIosProvisioningCommand({}, deps) + + assert.equal(state.prompts.length, 1) + assert.equal(state.prompts[0], `Update the provisioning profile map so these targets reuse "Wildcard Profile"? + + • App + Bundle ID: com.example.app + • Widget + Bundle ID: com.example.app.widget`) + assert.equal(state.writes.length, 1) + assert.equal(state.writes[0].source, 'local') + assert.deepEqual(state.writes[0].value['com.example.app'], { profile: wildcard, name: 'Wildcard Profile' }) + assert.deepEqual(state.writes[0].value['com.example.app.widget'], { profile: wildcard, name: 'Wildcard Profile' }) + assert.equal(state.appleCalls.length, 0) +}) + +await test('requires an interactive terminal when wildcard confirmation is needed', async () => { + const { deps, state } = baseDeps({ + loadProject: async () => ({ appId: 'com.example.app', targets: [widgetTarget()] }), + loadStores: async () => ({ local: iosCredentials(map({ wildcard: profile('*') })), global: null }), + canPrompt: () => false, + }) + await assert.rejects(runIosProvisioningCommand({}, deps), /interactive terminal/i) + assert.equal(state.prompts.length, 0) + assert.equal(state.writes.length, 0) +}) + +await test('declining wildcard reuse falls through to dedicated generation requirements', async () => { + const { deps, state } = baseDeps({ + loadProject: async () => ({ appId: 'com.example.app', targets: [widgetTarget()] }), + loadStores: async () => ({ + local: iosCredentials(map({ wildcard: profile('*') }), { APPLE_APP_SPECIFIC_PASSWORD: 'not-supported' }), + global: null, + }), + confirm: async (message) => { + state.prompts.push(message) + return false + }, + }) + await assert.rejects(runIosProvisioningCommand({}, deps), /app-specific password.*not supported/i) + assert.equal(state.writes.length, 0) +}) + +await test('fails before prompting or writing when different wildcard profiles match', async () => { + const { deps, state } = baseDeps({ + loadProject: async () => ({ appId: 'com.example.app', targets: [widgetTarget()] }), + loadStores: async () => ({ + local: iosCredentials(map({ broad: profile('*', 'Broad'), prefix: profile('com.example.*', 'Prefix') })), + global: null, + }), + }) + await assert.rejects( + runIosProvisioningCommand({}, deps), + /Sorry, multiple matching wildcard provisioning profiles are not supported/, + ) + assert.equal(state.prompts.length, 0) + assert.equal(state.writes.length, 0) +}) + +await test('validates local p8, API access, P12, and Apple certificate before generation confirmation', async () => { + const events = [] + let p12Password + const { deps, state } = baseDeps({ + loadProject: async () => ({ appId: 'com.example.app', targets: [appTarget()] }), + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }), + generateJwt: (_keyId, _issuerId, pem) => { + events.push(`jwt:${pem}`) + return 'token' + }, + verifyApiKey: async () => events.push('verify'), + openP12: (_certificate, password) => { + p12Password = password + events.push('p12') + return { sha1: 'a'.repeat(40) } + }, + findCertBySha1: async () => { + events.push('cert') + return { id: 'cert-id' } + }, + confirm: async (message) => { + events.push('confirm') + state.prompts.push(message) + return false + }, + }) + await assert.rejects(runIosProvisioningCommand({}, deps), /generation was declined/i) + assert.equal(p12Password, '') + assert.deepEqual(events, ['jwt:test-p8-pem', 'verify', 'p12', 'jwt:test-p8-pem', 'cert', 'confirm']) + assert.equal(state.writes.length, 0) +}) + +await test('invalid p8 and inaccessible Apple keys fail before confirmation without leaking credential values', async () => { + const malformed = baseDeps({ + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') }), { APPLE_KEY_CONTENT: '***' }), global: null }), + }) + await assert.rejects(runIosProvisioningCommand({}, malformed.deps), /saved App Store Connect \.p8 key is invalid/i) + assert.equal(malformed.state.appleCalls.length, 0) + + const secret = Buffer.from('very-secret-p8').toString('base64') + const invalid = baseDeps({ + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') }), { APPLE_KEY_CONTENT: secret }), global: null }), + generateJwt: () => { throw new Error(`bad ${secret}`) }, + }) + let p8Error + try { + await runIosProvisioningCommand({}, invalid.deps) + } + catch (error) { + p8Error = error + } + assert.match(p8Error.message, /saved App Store Connect \.p8 key is invalid/i) + assert.doesNotMatch(p8Error.message, new RegExp(secret)) + assert.equal(invalid.state.prompts.length, 0) + + const inaccessible = baseDeps({ + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }), + verifyApiKey: async () => { throw new Error('access rejected') }, + }) + await assert.rejects(runIosProvisioningCommand({}, inaccessible.deps), /does not have access/i) + assert.equal(inaccessible.state.prompts.length, 0) + assert.deepEqual(inaccessible.state.appleCalls, ['generateJwt']) +}) + +await test('invalid P12 and an Apple certificate mismatch fail before generation confirmation', async () => { + const badP12 = baseDeps({ + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }), + openP12: () => { throw new Error('bad certificate') }, + }) + await assert.rejects(runIosProvisioningCommand({}, badP12.deps), /signing certificate or P12 password is invalid/i) + assert.equal(badP12.state.prompts.length, 0) + + const noMatch = baseDeps({ + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }), + findCertBySha1: async () => null, + }) + await assert.rejects(runIosProvisioningCommand({}, noMatch.deps), /certificate is not available.*\.p8 key/i) + assert.equal(noMatch.state.prompts.length, 0) +}) + +await test('generates targets sequentially with fresh JWTs and persists after each success', async () => { + const events = [] + let jwt = 0 + const { deps, state } = baseDeps({ + loadProject: async () => ({ appId: 'com.example.app', targets: [appTarget(), widgetTarget()] }), + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') }), { APPLE_APP_SPECIFIC_PASSWORD: 'ignored' }), global: null }), + generateJwt: () => `token-${++jwt}`, + verifyApiKey: async token => events.push(`verify:${token}`), + findCertBySha1: async token => { + events.push(`cert:${token}`) + return { id: 'cert-id' } + }, + ensureBundleId: async (token, bundleId) => { + events.push(`ensure:${token}:${bundleId}`) + return { bundleIdResourceId: `resource-${bundleId}` } + }, + createProfile: async (token, resourceId, certId, bundleId) => { + events.push(`create:${token}:${resourceId}:${certId}:${bundleId}`) + return { profileId: `profile-${bundleId}`, profileName: `Capgo ${bundleId}`, profileContent: profile(bundleId) } + }, + }) + await runIosProvisioningCommand({}, deps) + + assert.equal(state.prompts.length, 1) + assert.equal(state.prompts[0], `Generate App Store provisioning profiles for these targets? + + • App + Bundle ID: com.example.app + • Widget + Bundle ID: com.example.app.widget`) + assert.equal(state.writes.length, 2) + assert.ok(state.writes[0].value['com.example.app']) + assert.equal(state.writes[0].value['com.example.app.widget'], undefined) + assert.ok(state.writes[1].value['com.example.app.widget']) + assert.deepEqual(events, [ + 'verify:token-1', + 'cert:token-2', + 'ensure:token-3:com.example.app', + 'create:token-4:resource-com.example.app:cert-id:com.example.app', + 'ensure:token-5:com.example.app.widget', + 'create:token-6:resource-com.example.app.widget:cert-id:com.example.app.widget', + ]) +}) + +await test('keeps earlier persisted profiles when a later target fails', async () => { + let creates = 0 + const { deps, state } = baseDeps({ + loadProject: async () => ({ appId: 'com.example.app', targets: [appTarget(), widgetTarget()] }), + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }), + createProfile: async (_token, _resource, _cert, bundleId) => { + creates++ + if (creates === 2) + throw new Error('Apple create failed') + return { profileId: 'first', profileName: 'First', profileContent: profile(bundleId) } + }, + }) + await assert.rejects(runIosProvisioningCommand({}, deps), /could not create.*Widget/i) + assert.equal(state.writes.length, 1) + assert.ok(state.writes[0].value['com.example.app']) +}) + +await test('replaces only duplicate profiles after confirmation and retries creation once', async () => { + const duplicates = [ + { id: 'duplicate-1', name: 'Capgo one', profileType: 'IOS_APP_STORE' }, + { id: 'duplicate-2', name: 'Capgo two', profileType: 'IOS_APP_STORE' }, + ] + let creates = 0 + const deleted = [] + const { deps, state } = baseDeps({ + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }), + createProfile: async (_token, _resource, _cert, bundleId) => { + creates++ + if (creates === 1) + throw new DuplicateProfileError(duplicates) + return { profileId: 'replacement', profileName: 'Replacement', profileContent: profile(bundleId) } + }, + deleteProfile: async (_token, id) => deleted.push(id), + }) + await runIosProvisioningCommand({}, deps) + + assert.equal(state.prompts.length, 2) + assert.match(state.prompts[1], /Capgo one.*Capgo two/s) + assert.deepEqual(deleted, ['duplicate-1', 'duplicate-2']) + assert.equal(creates, 2) + assert.equal(state.writes.length, 1) +}) + +await test('duplicate decline and replacement retry failure stop without recursive deletion', async () => { + const duplicate = new DuplicateProfileError([{ id: 'duplicate-1', name: 'Existing', profileType: 'IOS_APP_STORE' }]) + let prompt = 0 + const declined = baseDeps({ + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }), + confirm: async (message) => { + declined.state.prompts.push(message) + return ++prompt === 1 + }, + createProfile: async () => { throw duplicate }, + }) + await assert.rejects(runIosProvisioningCommand({}, declined.deps), /replacement was declined/i) + assert.equal(declined.state.appleCalls.includes('deleteProfile'), false) + + let deletes = 0 + const retry = baseDeps({ + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }), + createProfile: async () => { throw duplicate }, + deleteProfile: async () => { deletes++ }, + }) + await assert.rejects(runIosProvisioningCommand({}, retry.deps), /were deleted.*could not be created/i) + assert.equal(deletes, 1) + assert.equal(retry.state.writes.length, 0) +}) + +await test('duplicate replacement requires a second interactive confirmation and reports deletion failure', async () => { + const duplicate = new DuplicateProfileError([{ id: 'duplicate-1', name: 'Existing', profileType: 'IOS_APP_STORE' }]) + let promptChecks = 0 + const noninteractive = baseDeps({ + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }), + canPrompt: () => ++promptChecks === 1, + createProfile: async () => { throw duplicate }, + }) + await assert.rejects(runIosProvisioningCommand({}, noninteractive.deps), /interactive terminal/i) + assert.equal(noninteractive.state.appleCalls.includes('deleteProfile'), false) + + const deleteFailure = baseDeps({ + loadStores: async () => ({ local: generationCredentials(map({ old: profile('org.other.app') })), global: null }), + createProfile: async () => { throw duplicate }, + deleteProfile: async () => { throw new Error('delete failed') }, + }) + await assert.rejects(runIosProvisioningCommand({}, deleteFailure.deps), /could not delete all existing/i) + assert.equal(deleteFailure.state.writes.length, 0) +}) + +await test('registers lowercase ios-provisioning help with only the supported command options', () => { + const cliDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') + const help = spawnSync(process.execPath, [resolve(cliDir, 'src/index.ts'), 'build', 'credentials', 'ios-provisioning', '--help'], { encoding: 'utf8' }) + assert.equal(help.status, 0, help.stderr) + assert.match(help.stdout, /Usage: @capgo\/cli build credentials ios-provisioning \[options\]/) + assert.match(help.stdout, /--local/) + assert.match(help.stdout, /--global/) + assert.match(help.stdout, /npx @capgo\/cli@latest build credentials ios-provisioning/) + assert.doesNotMatch(help.stdout, /--app-?[iI]d|--yes/) +}) + +console.log(`\n✅ iOS provisioning command tests passed (${passed})`) diff --git a/cli/test/test-ios-provisioning-map.mjs b/cli/test/test-ios-provisioning-map.mjs new file mode 100644 index 0000000000..df7e380837 --- /dev/null +++ b/cli/test/test-ios-provisioning-map.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict' +import { makeProfileXml } from './prescan/helpers.ts' +import { + analyzeProvisioningCoverage, + parseProvisioningMap, + ProvisioningMapError, +} from '../src/build/ios-provisioning-map.ts' + +let passed = 0 + +function test(name, fn) { + try { + fn() + passed++ + console.log(`✅ PASSED: ${name}`) + } + catch (error) { + console.error(`❌ FAILED: ${name}`) + throw error + } +} + +function profile(bundleId, name = 'Test Profile') { + const xml = makeProfileXml({ bundleId }).replace('Test Profile', `${name}`) + return Buffer.from(xml).toString('base64') +} + +function target(name, bundleId) { + return { name, bundleId, productType: 'com.apple.product-type.app-extension' } +} + +function mapJson(entries) { + return JSON.stringify(entries) +} + +test('distinguishes missing, empty, malformed, and invalid maps', () => { + for (const [raw, code] of [ + [undefined, 'missing'], + [mapJson({}), 'empty'], + ['{broken', 'malformed'], + [mapJson({ app: { profile: 'not-a-profile', name: 'Wrong' } }), 'invalid'], + ]) { + assert.throws( + () => parseProvisioningMap(raw), + error => error instanceof ProvisioningMapError && error.code === code, + ) + } +}) + +test('canonicalizes legacy and object entries with the embedded profile name', () => { + const appProfile = profile('com.example.app', 'Canonical App') + const widgetProfile = profile('com.example.widget', 'Canonical Widget') + const parsed = parseProvisioningMap(mapJson({ + 'com.example.app': appProfile, + 'com.example.widget': { profile: widgetProfile, name: 'Stale name' }, + })) + + assert.deepEqual({ ...parsed }, { + 'com.example.app': { profile: appProfile, name: 'Canonical App' }, + 'com.example.widget': { profile: widgetProfile, name: 'Canonical Widget' }, + }) + assert.equal(parsed['com.example.app'].bundleId, 'com.example.app') + assert.equal(parsed['com.example.widget'].bundleId, 'com.example.widget') + assert.equal(JSON.stringify(parsed), mapJson({ + 'com.example.app': { profile: appProfile, name: 'Canonical App' }, + 'com.example.widget': { profile: widgetProfile, name: 'Canonical Widget' }, + })) +}) + +test('uses exact map keys for coverage and groups duplicate target bundle IDs', () => { + const shared = profile('com.example.widget') + const map = parseProvisioningMap(mapJson({ 'some-other-key': shared })) + const coverage = analyzeProvisioningCoverage([ + target('Widget One', 'com.example.widget'), + target('Widget Two', 'com.example.widget'), + ], map) + + assert.equal(coverage.exact.length, 0) + assert.deepEqual(coverage.missing, [{ bundleId: 'com.example.widget', targetNames: ['Widget One', 'Widget Two'] }]) +}) + +test('returns unresolved build-setting bundle IDs separately', () => { + const coverage = analyzeProvisioningCoverage([ + target('Resolved', 'com.example.app'), + target('Missing setting', '$(WIDGET_BUNDLE_ID)'), + target('Blank', ''), + ], parseProvisioningMap(mapJson({ 'com.example.app': profile('com.example.app') }))) + + assert.deepEqual(coverage.exact.map(item => item.bundleId), ['com.example.app']) + assert.deepEqual(coverage.unresolved.map(item => item.name), ['Missing setting', 'Blank']) + assert.equal(coverage.missing.length, 0) +}) + +test('matches universal and prefix wildcards only against eligible missing targets', () => { + const prefix = profile('com.example.*', 'Prefix Wildcard') + const coverage = analyzeProvisioningCoverage([ + target('Exact App', 'com.example.app'), + target('Widget', 'com.example.widget'), + target('Other', 'org.other.extension'), + ], parseProvisioningMap(mapJson({ + 'com.example.app': { profile: profile('com.example.app'), name: 'Exact' }, + wildcard: { profile: prefix, name: 'Ignored' }, + }))) + + assert.deepEqual(coverage.exact.map(item => item.bundleId), ['com.example.app']) + assert.deepEqual(coverage.wildcardReuse?.targets.map(item => item.bundleId), ['com.example.widget']) + assert.deepEqual(coverage.generation.map(item => item.bundleId), ['org.other.extension']) + + const universal = analyzeProvisioningCoverage( + [target('Other', 'org.other.extension')], + parseProvisioningMap(mapJson({ wildcard: profile('*', 'Universal') })), + ) + assert.deepEqual(universal.wildcardReuse?.targets.map(item => item.bundleId), ['org.other.extension']) +}) + +test('deduplicates identical wildcard bytes stored under multiple keys', () => { + const wildcard = profile('com.example.*', 'Shared Wildcard') + const coverage = analyzeProvisioningCoverage( + [target('Widget', 'com.example.widget')], + parseProvisioningMap(mapJson({ first: wildcard, second: { profile: wildcard, name: 'Other' } })), + ) + + assert.equal(coverage.wildcardConflict.length, 0) + assert.deepEqual(coverage.wildcardReuse?.sourceKeys, ['first', 'second']) +}) + +test('reports different matching wildcard profiles as an unsupported conflict', () => { + const coverage = analyzeProvisioningCoverage( + [target('Widget', 'com.example.widget')], + parseProvisioningMap(mapJson({ + broad: profile('*', 'Broad'), + prefix: profile('com.example.*', 'Prefix'), + })), + ) + + assert.equal(coverage.wildcardReuse, null) + assert.deepEqual(coverage.wildcardConflict.map(item => item.bundleId), ['com.example.widget']) + assert.equal(coverage.generation.length, 0) +}) + +test('does not reuse a wildcard for a target with an exact key', () => { + const coverage = analyzeProvisioningCoverage( + [target('App', 'com.example.app')], + parseProvisioningMap(mapJson({ + 'com.example.app': profile('com.example.app', 'Exact'), + wildcard: profile('*', 'Wildcard'), + })), + ) + + assert.deepEqual(coverage.exact.map(item => item.bundleId), ['com.example.app']) + assert.equal(coverage.wildcardReuse, null) + assert.equal(coverage.missing.length, 0) +}) + +console.log(`\n✅ iOS provisioning map tests passed (${passed})`) diff --git a/cli/test/test-posthog-exception.mjs b/cli/test/test-posthog-exception.mjs index 30fefed6ff..d9c68184f8 100644 --- a/cli/test/test-posthog-exception.mjs +++ b/cli/test/test-posthog-exception.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' import { existsSync, mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { chdir, cwd } from 'node:process' +import { cwd } from 'node:process' import { Command } from 'commander' import { IncompatibleBundleError } from '../src/bundle/upload.ts' import { @@ -16,7 +17,7 @@ import { } from '../src/posthog.ts' import { CliUserError } from '../src/shared/cli-user-error.ts' import { TwoFactorComplianceNetworkError } from '../src/shared/two-factor-compliance.ts' -import { CAPGO_SERVER_CONFIG_MISSING_MESSAGE, findSavedKey } from '../src/utils.ts' +import { CAPGO_SERVER_CONFIG_MISSING_MESSAGE } from '../src/utils.ts' const originalFetch = globalThis.fetch const originalEnv = { @@ -211,38 +212,37 @@ try { // Exercise the real no-key path in an isolated home/project so the suggested // command and its error-tracking classification stay covered together. const noKeyDir = mkdtempSync(join(tmpdir(), 'capgo-no-key-')) - const previousCwd = cwd() - const previousHome = process.env.HOME - const previousToken = process.env.CAPGO_TOKEN - const previousUserAgent = process.env.npm_config_user_agent - let missingKeyError - try { - process.env.HOME = noKeyDir - process.env.npm_config_user_agent = 'npm/11.6.2 node/v24.8.0 darwin arm64' - delete process.env.CAPGO_TOKEN - chdir(noKeyDir) - assert.throws(() => findSavedKey(true), (error) => { - missingKeyError = error - return error instanceof CliUserError - }) - } - finally { - chdir(previousCwd) - if (previousHome === undefined) - delete process.env.HOME - else - process.env.HOME = previousHome - if (previousToken === undefined) - delete process.env.CAPGO_TOKEN - else - process.env.CAPGO_TOKEN = previousToken - if (previousUserAgent === undefined) - delete process.env.npm_config_user_agent - else - process.env.npm_config_user_agent = previousUserAgent + const childEnv = { + ...process.env, + HOME: noKeyDir, + USERPROFILE: noKeyDir, + npm_config_user_agent: 'npm/11.6.2 node/v24.8.0 darwin arm64', } - assert.equal(missingKeyError.message, 'No Capgo API key found. Run `npx -y @capgo/cli@latest login` first, then retry this command.') - assert.equal(shouldCapturePosthogException(missingKeyError), false) + delete childEnv.CAPGO_TOKEN + const utilsUrl = new URL('../src/utils.ts', import.meta.url).href + const posthogUrl = new URL('../src/posthog.ts', import.meta.url).href + const userErrorUrl = new URL('../src/shared/cli-user-error.ts', import.meta.url).href + const child = spawnSync(process.execPath, ['--eval', ` + import { shouldCapturePosthogException } from ${JSON.stringify(posthogUrl)} + import { CliUserError } from ${JSON.stringify(userErrorUrl)} + import { findSavedKey } from ${JSON.stringify(utilsUrl)} + let result + try { findSavedKey(true) } + catch (error) { + result = { + isCliUserError: error instanceof CliUserError, + message: error.message, + shouldCapture: shouldCapturePosthogException(error), + } + } + process.stdout.write('CAPGO_TEST_RESULT=' + JSON.stringify(result)) + `], { cwd: noKeyDir, encoding: 'utf8', env: childEnv }) + assert.equal(child.status, 0, child.stderr) + const marker = 'CAPGO_TEST_RESULT=' + const missingKeyResult = JSON.parse(child.stdout.slice(child.stdout.lastIndexOf(marker) + marker.length)) + assert.equal(missingKeyResult.isCliUserError, true) + assert.equal(missingKeyResult.message, 'No Capgo API key found. Run `npx -y @capgo/cli@latest login` first, then retry this command.') + assert.equal(missingKeyResult.shouldCapture, false) // `uploadFail` now throws CliUserError, so a duplicate-version upload — a normal // `bundle upload` outcome — is filtered out of error tracking by type. assert.equal(shouldCapturePosthogException(new CliUserError('Version 1.2.3 already exists')), false) diff --git a/docs/superpowers/plans/2026-08-30-ios-provisioning-target-repair.md b/docs/superpowers/plans/2026-08-30-ios-provisioning-target-repair.md new file mode 100644 index 0000000000..2f4d1d596c --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-ios-provisioning-target-repair.md @@ -0,0 +1,449 @@ +# iOS Provisioning Target Repair Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `build credentials ios-provisioning` and fatal prescans that safely repair missing per-target iOS provisioning map entries, including confirmed wildcard reuse and dedicated App Store profile generation. + +**Architecture:** Extract the existing local/global credential-store decision into a shared resolver, then add a pure provisioning-map analyzer used by both the command and prescan. Keep the command orchestration injectable so tests can cover wildcard reuse, Apple API validation, duplicate replacement, and resumable writes without network access. The existing onboarding Apple API and certificate/profile parsers remain the source of truth for Apple operations and profile metadata. + +**Tech Stack:** TypeScript, Commander, Capacitor config, `@clack/prompts`, Bun, Node test scripts, Vitest, existing Capgo credential storage and Apple API helpers. + +--- + +## File map + +New production files: + +- `cli/src/build/credentials-store-selection.ts` — shared local/global store selection used by export and provisioning setup. +- `cli/src/build/ios-provisioning-map.ts` — parsing, canonicalization, exact coverage, wildcard matching, and conflict analysis. +- `cli/src/build/ios-provisioning-command.ts` — project discovery, prompts, wildcard repair, Apple credential validation, generation, replacement, and persistence. + +Modified production files: + +- `cli/src/build/credentials-export-command.ts` — delegate store selection without changing export behavior. +- `cli/src/build/prescan/checks/ios-profiles.ts` — use exact coverage and add wildcard-specific fatal check. +- `cli/src/build/prescan/registry.ts` — register the new wildcard check. +- `cli/src/index.ts` — register `build credentials ios-provisioning` and public help text. + +Test files: + +- `cli/test/test-credentials-export.mjs` — regression tests for extracted store selection. +- `cli/test/test-ios-provisioning-map.mjs` — pure parser and coverage matrix. +- `cli/test/prescan/checks-ios-profiles.test.ts` — exact-target and wildcard prescan behavior. +- `cli/test/test-ios-provisioning-command.mjs` — command flow with injected project, storage, prompt, and Apple dependencies. +- `cli/package.json` — include new standalone tests in the CLI test suite if the existing glob/script does not already discover them. + +## Task 1: Share credential-store selection + +**Files:** + +- Create: `cli/src/build/credentials-store-selection.ts` +- Modify: `cli/src/build/credentials-export-command.ts` +- Test: `cli/test/test-credentials-export.mjs` + +- [ ] Add failing export tests proving that automatic mode selects the sole configured store, rejects split local/global credentials, and an explicit `--local` or `--global` never falls back to the other store. + +- [ ] Run the focused test and confirm the new cases fail for the intended reason: + +```bash +bun run --cwd cli test:credentials-export +``` + +- [ ] Add a shared resolver with this public contract: + +```ts +import type { SavedCredentials } from './credentials' + +export type CredentialsStoreName = 'local' | 'global' + +export interface CredentialsStoreOptions { + appId?: string + local?: boolean + global?: boolean +} + +export type CredentialsStores = Record + +export interface ResolvedCredentialsStore { + source: CredentialsStoreName + saved: SavedCredentials +} + +export function hasConfiguredCredentials(saved: SavedCredentials | null): boolean + +export function resolveCredentialsStore( + options: CredentialsStoreOptions, + stores: CredentialsStores, +): ResolvedCredentialsStore +``` + +The resolver must preserve the current export errors and semantics: reject both selectors, reject an explicitly selected empty store, auto-select one configured store, and require a selector when both are configured. + +- [ ] Refactor `resolveCredentialsExport` to call `resolveCredentialsStore`, retaining the existing platform and variable validation after source resolution. + +- [ ] Re-run the focused test and ensure all existing and new cases pass. + +- [ ] Commit the isolated refactor: + +```bash +git add cli/src/build/credentials-store-selection.ts cli/src/build/credentials-export-command.ts cli/test/test-credentials-export.mjs +git commit -m "refactor(cli): share credential store selection" +``` + +## Task 2: Build the pure provisioning-map analyzer + +**Files:** + +- Create: `cli/src/build/ios-provisioning-map.ts` +- Create: `cli/test/test-ios-provisioning-map.mjs` + +- [ ] Write failing tests for all analyzer invariants: + + - absent, empty, malformed, and invalid map values are distinguishable errors; + - legacy string values and `{ profile, name }` values canonicalize to `{ profile, name }`; + - target coverage is exact-key only, even if another profile payload happens to match; + - duplicate target bundle identifiers are grouped while retaining target names; + - unresolved build-setting bundle IDs are returned separately and are never generated; + - `*` and prefix wildcards cover only eligible missing bundle IDs; + - identical wildcard profile bytes under multiple keys count as one profile; + - different matching wildcard profile bytes create an unsupported conflict; + - a wildcard entry does not hide a target that already has an exact key. + +- [ ] Add the analyzer types and functions: + +```ts +import type { MobileprovisionDetail } from './mobileprovision-parser' +import type { PbxTarget } from './pbxproj-parser' + +export interface ProvisioningMapEntry { + profile: string + name: string +} + +export type ProvisioningMap = Record + +export interface ProvisioningTargetGroup { + bundleId: string + targetNames: string[] +} + +export interface WildcardReuse { + entry: ProvisioningMapEntry + sourceKeys: string[] + targets: ProvisioningTargetGroup[] +} + +export interface ProvisioningCoverage { + exact: ProvisioningTargetGroup[] + missing: ProvisioningTargetGroup[] + unresolved: PbxTarget[] + wildcardReuse: WildcardReuse | null + wildcardConflict: ProvisioningTargetGroup[] + generation: ProvisioningTargetGroup[] +} + +export function parseProvisioningMap(raw: string): ProvisioningMap + +export function analyzeProvisioningCoverage( + targets: PbxTarget[], + map: ProvisioningMap, +): ProvisioningCoverage +``` + +Parsing must call `parseMobileprovisionDetailedFromBase64` for authoritative profile names and application identifiers. Wildcard matching must use the embedded application identifier stripped of its team prefix, never the map key. Treat only `*` and identifiers ending in `.*` as wildcards. + +- [ ] Run the analyzer test directly until green: + +```bash +bun cli/test/test-ios-provisioning-map.mjs +``` + +- [ ] Run CLI typecheck to catch public type mismatches: + +```bash +bun run --cwd cli typecheck +``` + +- [ ] Commit the analyzer and tests: + +```bash +git add cli/src/build/ios-provisioning-map.ts cli/test/test-ios-provisioning-map.mjs +git commit -m "feat(cli): analyze iOS provisioning target coverage" +``` + +## Task 3: Tighten iOS provisioning prescans + +**Files:** + +- Modify: `cli/src/build/prescan/checks/ios-profiles.ts` +- Modify: `cli/src/build/prescan/registry.ts` +- Modify: `cli/test/prescan/checks-ios-profiles.test.ts` + +- [ ] Add failing prescan tests for: + + - a present empty, malformed, or invalid map producing a fatal generic save/update error; + - two targets with only one exact entry producing a fatal recommendation for `npx @capgo/cli@latest build credentials ios-provisioning`; + - a single missing target retaining the generic manual repair message; + - one matching wildcard producing only `ios/wildcard-profile-targets`, listing the missing targets and recommending the command; + - multiple different matching wildcard profiles producing the unsupported/manual-cleanup error; + - identical wildcard bytes under multiple keys not producing a conflict; + - exact-complete maps and nonmatching wildcard profiles producing no wildcard finding. + +- [ ] Run the focused prescan test and confirm the assertions fail: + +```bash +bun test cli/test/prescan/checks-ios-profiles.test.ts +``` + +- [ ] Replace the local loose map parser and wildcard-as-coverage logic with `parseProvisioningMap` and `analyzeProvisioningCoverage`. + +- [ ] Keep `ios/targets-covered` as the existing check ID, but exclude targets owned by the wildcard-specific check. Its multi-target failure must include: + +```text +npx @capgo/cli@latest build credentials ios-provisioning +``` + +- [ ] Export a new fatal check named `ios/wildcard-profile-targets`. It must report one reusable wildcard or the exact unsupported conflict message, without prompting or mutating state. + +- [ ] Register the new check beside `targetsCovered` in `ALL_CHECKS`. + +- [ ] Run the full prescan suite: + +```bash +bun run --cwd cli test:prescan +``` + +- [ ] Commit the prescan behavior: + +```bash +git add cli/src/build/prescan/checks/ios-profiles.ts cli/src/build/prescan/registry.ts cli/test/prescan/checks-ios-profiles.test.ts +git commit -m "feat(cli): detect missing iOS target profiles" +``` + +## Task 4: Implement project and credential validation for the command + +**Files:** + +- Create: `cli/src/build/ios-provisioning-command.ts` +- Create: `cli/test/test-ios-provisioning-command.mjs` + +- [ ] Start command tests with an injectable boundary: + +```ts +export interface IosProvisioningOptions { + local?: boolean + global?: boolean +} + +export interface IosProvisioningProject { + appId: string + targets: PbxTarget[] +} + +export interface IosProvisioningCommandDeps { + loadProject: () => Promise + loadStores: (appId: string, options: IosProvisioningOptions) => Promise + persistMap: ( + appId: string, + source: CredentialsStoreName, + map: ProvisioningMap, + ) => Promise + canPrompt: () => boolean + confirm: (message: string) => Promise + logInfo: (message: string) => void + generateJwt: typeof generateJwt + verifyApiKey: typeof verifyApiKey + findCertBySha1: typeof findCertBySha1 + ensureBundleId: typeof ensureBundleId + createProfile: typeof createProfile + deleteProfile: typeof deleteProfile +} + +export async function runIosProvisioningCommand( + options: IosProvisioningOptions, + deps: IosProvisioningCommandDeps, +): Promise +``` + +- [ ] Add failing tests for validation-only paths: + + - no Capacitor config, no configured iOS platform, no Xcode project, no signable targets, and unresolved target bundle IDs fail before credential reads or Apple calls; + - the saved credential lookup always uses the Capacitor app ID; + - source selection exactly follows the shared export resolver; + - no iOS credentials, absent map, empty map, malformed map, or invalid map fail without bootstrapping; + - an exact-complete map succeeds without prompts, `.p8`, or Apple API calls; + - `ad_hoc` credentials fail as unsupported; + - app-specific password fields are ignored when a complete `.p8` exists. + +- [ ] Implement default project loading with `getConfig(true)`, `getAppId(undefined, config)`, `getPlatformDirFromCapacitorConfig(config, 'ios')`, `findXcodeProject`, `readPbxproj`, and `findSignableTargets`. + +- [ ] Load only the explicitly requested store when a selector is supplied; otherwise load both and pass them through `resolveCredentialsStore`. Read errors must be fatal and there must be no cross-store fallback. + +- [ ] Parse `CAPGO_IOS_PROVISIONING_MAP` strictly, reject `BUILD_MODE === 'ad_hoc'`, and return success immediately when exact coverage is complete. + +- [ ] Run the focused command test until validation paths pass: + +```bash +bun cli/test/test-ios-provisioning-command.mjs +``` + +## Task 5: Implement confirmed wildcard repair + +**Files:** + +- Modify: `cli/src/build/ios-provisioning-command.ts` +- Modify: `cli/test/test-ios-provisioning-command.mjs` + +- [ ] Add failing tests for wildcard behavior: + + - one wildcard covering missing targets asks once with the target list; + - noninteractive mode fails with an actionable rerun message; + - acceptance writes exact target keys using the same profile bytes and canonical name in one persisted map update; + - wildcard-only completion succeeds without `.p8` or Apple calls; + - declining wildcard reuse leaves those targets in the dedicated-generation list; + - multiple different matching wildcard profiles fail with `Sorry, multiple matching wildcard provisioning profiles are not supported` before any prompt or mutation. + +- [ ] Implement a reusable confirmation guard that checks `canPrompt()` before invoking `confirm()`, treats cancellation as rejection, and throws an actionable error rather than silently defaulting. + +- [ ] On acceptance, copy the wildcard entry into every covered target’s exact bundle-ID key and call `persistMap` once. Re-run coverage analysis after the write so only genuinely missing targets continue. + +- [ ] Re-run the focused test until wildcard cases pass. + +- [ ] Commit the validation and wildcard command slice: + +```bash +git add cli/src/build/ios-provisioning-command.ts cli/test/test-ios-provisioning-command.mjs +git commit -m "feat(cli): repair iOS provisioning maps" +``` + +## Task 6: Implement App Store profile generation and replacement + +**Files:** + +- Modify: `cli/src/build/ios-provisioning-command.ts` +- Modify: `cli/test/test-ios-provisioning-command.mjs` + +- [ ] Add failing tests for generation and mutation ordering: + + - missing targets require `APPLE_KEY_ID`, `APPLE_ISSUER_ID`, base64 `APPLE_KEY_CONTENT`, and `BUILD_CERTIFICATE_BASE64`; + - absent/incomplete `.p8` fails with an explicit statement that app-specific passwords are unsupported; + - invalid base64/PEM/JWT or `verifyApiKey` access failure occurs before the generation confirmation; + - passwordless P12 opening uses `P12_PASSWORD ?? ''`; + - certificate lookup by SHA-1 and access verification occur before any bundle/profile mutation; + - the user confirms once for the remaining target list, and declining exits nonzero without Apple mutations; + - targets generate sequentially through fresh JWTs, `ensureBundleId`, and `createProfile`; + - every successful generated profile is persisted immediately under its exact bundle-ID key; + - a later failure leaves earlier persisted entries intact for a safe rerun; + - `DuplicateProfileError` prompts with the target/profile list, deletes only the returned duplicate IDs, and retries creation once; + - duplicate replacement decline, noninteractive mode, deletion failure, and retry failure all stop with precise errors; + - generic Apple failures never trigger duplicate deletion; + - logs and thrown errors never include raw private keys, certificates, profile bytes, or passwords. + +- [ ] Add a credential-preparation helper that decodes the base64 private key, calls `generateJwt`, verifies API access, opens the P12 with `openP12`, and resolves the distribution certificate with `findCertBySha1` before the generation prompt. + +- [ ] Mint a fresh JWT for every Apple request. Generate each target in order: + +```ts +const bundleResource = await deps.ensureBundleId(freshToken(), target.bundleId) +const profile = await deps.createProfile( + freshToken(), + bundleResource.id, + distributionCertificate.id, + appId, +) +``` + +- [ ] Catch only `DuplicateProfileError` for replacement. After confirmation, delete the error’s returned Capgo-managed profiles, retry once, and do not recursively replace a second duplicate response. + +- [ ] Canonicalize the returned profile as `{ profile: profile.profileContent, name: profile.name }`, persist immediately, and continue to the next target. + +- [ ] Re-run the focused command test and CLI typecheck: + +```bash +bun cli/test/test-ios-provisioning-command.mjs +bun run --cwd cli typecheck +``` + +- [ ] Commit generation support: + +```bash +git add cli/src/build/ios-provisioning-command.ts cli/test/test-ios-provisioning-command.mjs +git commit -m "feat(cli): generate missing iOS target profiles" +``` + +## Task 7: Register the public command + +**Files:** + +- Modify: `cli/src/index.ts` +- Modify: `cli/package.json` if needed +- Modify: `cli/test/test-ios-provisioning-command.mjs` + +- [ ] Add a failing source/help assertion that the subcommand is registered under `build credentials` as lowercase kebab-case `ios-provisioning`, exposes only `--local` and `--global`, and uses the public `npx @capgo/cli@latest` form in help/examples. + +- [ ] Export a thin `iosProvisioningCommand` wrapper that supplies real dependencies, reports a concise error through the existing logger, and exits nonzero without leaking secrets. + +- [ ] Register the command: + +```ts +buildCredentials + .command('ios-provisioning') + .description('Set up provisioning profiles for every iOS target') + .option('--local', 'Use credentials from the current project') + .option('--global', 'Use credentials from the global store') + .action(iosProvisioningCommand) +``` + +- [ ] Ensure the new standalone tests are included by `bun run cli:test`; update `cli/package.json` only if the current test command enumerates files explicitly. + +- [ ] Build the CLI and inspect the command help: + +```bash +bun run --cwd cli build +bun cli/dist/index.js build credentials ios-provisioning --help +``` + +- [ ] Commit registration: + +```bash +git add cli/src/index.ts cli/package.json cli/test/test-ios-provisioning-command.mjs +git commit -m "feat(cli): add iOS provisioning setup command" +``` + +## Task 8: Quality gate, line budget, and PR preparation + +**Files:** + +- Review all changed files +- Do not stage `codedb.snapshot` + +- [ ] Run formatting/lint first, as required by the repository: + +```bash +bun run --cwd cli lint +``` + +- [ ] Run focused tests, typecheck, build, then the full CLI check: + +```bash +bun run --cwd cli test:credentials-export +bun run --cwd cli test:prescan +bun cli/test/test-ios-provisioning-map.mjs +bun cli/test/test-ios-provisioning-command.mjs +bun run --cwd cli typecheck +bun run --cwd cli build +bun run cli:check +``` + +- [ ] Measure added production lines under `cli/src` and verify the net/new feature implementation is at most 1,100 lines, excluding tests and specification/plan files: + +```bash +git diff --numstat HEAD~5 -- cli/src +wc -l cli/src/build/credentials-store-selection.ts cli/src/build/ios-provisioning-map.ts cli/src/build/ios-provisioning-command.ts +``` + +- [ ] Review the final diff for exact design coverage, secret-safe errors, unsupported `ad_hoc`, no app-specific password fallback, no `--yes`, exact-key persistence, and no unrelated files. + +- [ ] Invoke the `pr-ready` skill and resolve every issue until it reports stable green. + +- [ ] Push the `wolny/ios-provisioning-target-repair` branch and create a PR with a conventional, non-`[CODEX]` title. Include behavior, test evidence, and the production line-count result in the PR body. diff --git a/docs/superpowers/specs/2026-08-30-ios-provisioning-target-repair-design.md b/docs/superpowers/specs/2026-08-30-ios-provisioning-target-repair-design.md new file mode 100644 index 0000000000..b8b717dc40 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-ios-provisioning-target-repair-design.md @@ -0,0 +1,357 @@ +# iOS provisioning target repair + +## Problem + +Capgo Builder stores iOS provisioning profiles in +`CAPGO_IOS_PROVISIONING_MAP`, keyed by the concrete bundle identifier that the +builder must sign. A Capacitor project can gain additional signable Xcode +targets after its initial Builder setup, such as a widget or notification +service extension. Each target normally has its own bundle identifier, but the +saved map commonly contains only the main app entry. The existing prescan blocks +the build, and the only repair path is to create profiles manually and pass +repeated `--ios-provisioning-profile` arguments. + +Some existing profiles are wildcard profiles. A wildcard profile already stored +in the map may cover a newly added target, but the builder still needs an exact +map key for that target. Today the CLI neither offers to copy the wildcard map +entry nor distinguishes that repair from creating a new Apple provisioning +profile. + +## Goal + +Add an interactive command: + +```text +npx @capgo/cli@latest build credentials ios-provisioning [--local|--global] +``` + +The command discovers every signable iOS target, reports whether every target +has an exact saved map entry, offers to assign one existing wildcard profile to +new targets it covers, and creates App Store provisioning profiles for targets +that remain uncovered. Prescan must block incomplete multi-target builds and +direct users to this command. + +The feature may add at most 1,100 production implementation lines under +`cli/src`, excluding tests and specification files. + +## Non-goals + +- Do not support ad-hoc, development, or enterprise profile generation. +- Do not create or modify the P12 signing certificate. +- Do not support choosing among multiple different wildcard profiles. +- Do not add non-interactive confirmation flags such as `--yes`. +- Do not change the Builder payload, backend API, database schema, or iOS + onboarding state machine. +- Do not create wildcard provisioning profiles through Apple. + +## Command interface + +Register `ios-provisioning` below `build credentials`. CLI command names remain +lowercase kebab-case. The command accepts only `--local` and `--global`; they are +mutually exclusive. It intentionally has no `--appId` option because it must run +inside the Capacitor project whose Xcode targets it inspects. + +Resolve the saved-credential app key with the same `getAppId(undefined, config)` +behavior used by existing Builder credential commands. This preserves the +`plugins.CapacitorUpdater.appId` override. Provisioning-map keys never use that +value blindly: they come from each Xcode target's concrete Release-preferred +`PRODUCT_BUNDLE_IDENTIFIER`. + +## Architecture + +### Command orchestrator + +Add `cli/src/build/ios-provisioning-command.ts`. It owns project validation, +credential-store selection, prompts, Apple API orchestration, incremental +persistence, and terminal summaries. Its dependencies should be injectable +where I/O or Apple calls would otherwise make tests non-hermetic. + +The command reuses existing code for: + +- Capacitor config loading and custom `ios.path` resolution; +- Xcode project discovery and `findSignableTargets`; +- saved credential reads and `updateSavedCredentials`; +- mobile provisioning parsing; +- P12 opening and leaf-certificate SHA-1 extraction; +- App Store Connect JWT generation and key verification; +- Apple certificate lookup by SHA-1; +- bundle-ID registration, profile creation, duplicate discovery/deletion; and +- Clack confirmation, cancellation, and safe terminal logging conventions. + +### Shared provisioning-map analyzer + +Add `cli/src/build/ios-provisioning-map.ts`. This module is pure apart from +calling the existing pure profile parser. Both the command and prescan use it so +exact coverage and wildcard decisions cannot drift. + +The analyzer accepts discovered targets and a parsed provisioning map. It +returns: + +- targets already covered by an exact own-property map key; +- targets missing an exact entry; +- targets whose bundle identifier is missing, unresolved, or non-concrete; +- the one reusable wildcard profile, when one exists; +- missing targets covered by that wildcard; +- missing targets that still require profile generation; and +- an unsupported wildcard conflict when more than one distinct matching + wildcard profile exists. + +Targets are grouped by concrete bundle identifier for profile work while +retaining every target name for messages. This prevents duplicate Apple calls +if a malformed project repeats a bundle identifier, without hiding which Xcode +targets share it. + +Map entries use the current canonical shape +`{ profile: string, name: string }`. The analyzer may read the tolerated legacy +string value shape already understood by prescan, but all newly generated +entries use the canonical object shape. Existing unrelated map entries are +preserved. + +Exact coverage means `Object.hasOwn(map, target.bundleId)`. A wildcard map key +alone is not an exact assignment. Wildcard capability is derived from the +profile's embedded application identifier: + +- `*` covers every concrete bundle identifier; +- `com.example.*` covers identifiers beginning with `com.example.`; and +- all other embedded identifiers require an exact match. + +Distinct wildcard profiles are compared by their stored profile bytes. The same +profile repeated under multiple keys counts once even if display-name metadata +differs. When missing targets are matchable by more than one distinct wildcard +profile, analysis reports an unsupported conflict; the CLI will not invent a +selection policy. + +Structurally invalid maps, invalid entries, or profile bytes that cannot be +parsed are fatal command validation errors naming only the affected map key. +Credential or profile contents are never included in errors. + +### Credential-store selection + +Extract the credential-store decision from +`credentials-export-command.ts` into a small shared helper and keep export's +observable behavior unchanged. The new command uses the same contract: + +- reject `--local` together with `--global`; +- when neither is passed, choose the only store configured for the app; +- when both stores configure the app, fail and require one selector; +- an explicit selector never falls back to the other store; and +- unreadable or malformed selected storage fails instead of silently falling + back. + +The command reads the selected store's iOS credentials and writes every map +update back to that same store. `updateSavedCredentials` receives +`local: source === 'local'` explicitly. + +## Command flow + +### 1. Validate the project and saved state + +1. Load Capacitor config from the current directory. Failure means this is not a + usable Capacitor project. +2. Resolve the configured iOS directory, locate its Xcode project, and read the + pbxproj. Fail if iOS is not configured, no signable targets exist, or any + signable target lacks a concrete bundle identifier. +3. Resolve the saved-credential source using the shared export rules. +4. Require iOS credentials and a valid, non-empty + `CAPGO_IOS_PROVISIONING_MAP` in that source. A completely absent map remains + an initial credential-setup problem; this repair command does not bootstrap + the first profile. +5. Reject `CAPGO_IOS_DISTRIBUTION=ad_hoc`. This command supports App Store + profiles only. + +If every concrete target already has an exact map entry, print that all targets +have provisioning profiles saved in Capgo and exit successfully without +requiring Apple API credentials. + +### 2. Reuse one existing wildcard profile + +Analyze missing exact entries against the embedded identifiers of profiles +already stored in the map. + +If more than one distinct wildcard profile matches the missing targets, stop +with an error equivalent to: "Sorry, multiple matching wildcard provisioning +profiles are not supported." Do not offer a selector or mutate the map. + +If one wildcard profile covers one or more missing targets, list their target +names and bundle identifiers and ask whether to update the map so those targets +use that wildcard profile. Confirmation is mandatory. On acceptance, copy the +same stored profile bytes into a canonical entry under every covered target's +exact bundle identifier and persist the whole wildcard batch in one local +write. On decline, leave those targets missing so the dedicated-profile flow +can offer to generate profiles for them. + +Recompute coverage after the wildcard decision. If no targets remain missing, +report success. This wildcard-only path requires no `.p8` because it performs no +Apple request. Saved app-specific-password fields are ignored rather than used. + +### 3. Validate Apple access for generation + +When targets remain missing, require: + +- `BUILD_CERTIFICATE_BASE64`; +- `APPLE_KEY_ID`; +- `APPLE_ISSUER_ID`; and +- base64-encoded `APPLE_KEY_CONTENT`. + +Open the P12 with `P12_PASSWORD ?? ''` so an intentionally passwordless P12 +continues to work exactly as it does in existing Builder flows. + +App-specific-password credentials are never used for provisioning. If the +`.p8` trio is absent or incomplete, fail with a message explaining that +app-specific passwords cannot create provisioning profiles. If a complete +`.p8` trio exists, ignore app-specific-password fields. + +Decode and locally validate the `.p8` before prompting for profile generation. +Then list every still-missing target and ask once whether to create its App +Store provisioning profile. Declining exits non-zero without Apple mutations. + +After confirmation: + +1. Generate a fresh JWT and call the existing App Store Connect verification + helper. Invalid key material, rejected credentials, missing agreements, or + insufficient access are fatal. +2. Open the saved P12 with the saved password and obtain its leaf-certificate + SHA-1. +3. Find the matching Apple-side distribution certificate with that JWT. If it + is not visible, fail before creating bundle IDs or profiles; this proves the + `.p8` cannot operate on the saved signing certificate's team. + +Neither the `.p8`, JWT, P12, passwords, certificate bytes, nor provisioning +profile bytes may appear in terminal output or logs. + +### 4. Generate and persist exact profiles + +Process the remaining unique bundle identifiers sequentially: + +1. Ensure the bundle identifier exists through the existing Apple helper. +2. Create an `IOS_APP_STORE` profile linked to the matched distribution + certificate, using the existing profile-generation helper. +3. On `DuplicateProfileError`, list the Capgo-managed duplicate profile names + and ask whether to replace them for this target. If accepted, delete only the + profiles returned by the existing Capgo-named duplicate lookup and retry + creation once. If declined, stop without deleting anything. +4. Store the returned profile bytes and name under the target's exact map key. +5. Persist the updated map immediately before starting the next target. + +Incremental persistence makes the command resumable. If a later Apple call +fails, earlier exact entries remain saved and a rerun skips them. If duplicate +deletion succeeds but recreation fails, report that target-specific state and +tell the user to rerun; do not conceal the remote mutation. + +Finish with a per-target summary distinguishing already configured, wildcard +reused, and newly generated entries. + +## Prescan design + +Prescan remains read-only and blocks before `/build/request`, so neither a +server-side job nor an upload occurs while exact target mappings are missing. + +### Edit `ios/targets-covered` + +Use the shared analyzer and exact own-key coverage. Continue emitting a fatal +error for targets without exact entries. Exclude targets owned by the wildcard +finding below so the same target is not reported twice. + +Change the check's applicability so a present but empty, malformed, or +structurally invalid map cannot evade coverage analysis. In that case emit one +fatal invalid-map finding with the existing general save/update guidance; do +not recommend `ios-provisioning`, because the repair command intentionally +requires a valid initial map. + +For a multi-target project whose uncovered targets cannot reuse the one +supported wildcard profile, list those targets and recommend: + +```text +npx @capgo/cli@latest build credentials ios-provisioning +``` + +For a single-target inconsistency, retain the existing general credential +repair guidance. A completely absent map remains owned by the existing fatal +`shared/credentials-saved` check and does not recommend a command that will +refuse to bootstrap the first profile. + +### Add `ios/wildcard-profile-targets` + +Register a fatal iOS check that runs only when a valid map exists and at least +one concrete target lacks an exact entry. + +- With one distinct wildcard profile covering missing targets, list those + targets and recommend `build credentials ios-provisioning`, which owns the + required confirmation and map mutation. +- With multiple distinct matching wildcard profiles, report that automatic + wildcard reuse is unsupported and require manual cleanup. Do not recommend an + operation the command will refuse. +- Emit nothing when every target has an exact entry or no wildcard covers a + missing target. + +Existing checks for profile expiry, profile type, embedded bundle matching, +certificate pairing, entitlements, P12 validity, and App Store Connect access +remain responsible for their current concerns. + +## Error and cancellation behavior + +- Project, target, store, map, distribution, and local credential validation + happens before mutation. +- Prompts are required for wildcard copying, profile generation, and duplicate + replacement. If a required prompt cannot run interactively, fail with an + actionable message. Do not add an automatic-confirmation flag. +- Cancellation and unsupported configurations exit non-zero. +- Apple failures stop the current run and identify the target without exposing + credentials. +- Wildcard copies are one local batch write; generated profiles are persisted + one target at a time. +- Existing unrelated map entries and credential fields are preserved. + +## Testing + +### Pure analyzer tests + +Cover exact entries, universal and prefix wildcard matching, a wildcard stored +under an unrelated exact key, same-profile deduplication, distinct-profile +conflicts, targets not covered by the wildcard, unrelated preserved map +entries, repeated target bundle identifiers, malformed entries, and unresolved +target identifiers. + +### Credential-source tests + +Extend the existing export source-selection tests while extracting the helper. +Lock local-only, global-only, both-store ambiguity, explicit selectors, +mutually exclusive selectors, no explicit fallback, malformed selected files, +and unchanged export behavior. + +### Command tests + +Use injected filesystem, prompt, persistence, and Apple dependencies. Cover: + +- missing Capacitor config, iOS project, Xcode project, targets, and concrete + target identifiers; +- no iOS credentials, absent/empty/malformed map, and ad-hoc distribution; +- all targets already exact, with no prompt or `.p8` requirement; +- wildcard acceptance, wildcard decline, wildcard-only success without `.p8`, + and distinct wildcard conflict; +- app-specific-password-only credentials, partial/invalid `.p8`, rejected Apple + access, missing agreements, invalid P12/password, and unmatched Apple cert; +- bulk generation confirmation and cancellation; +- successful sequential generation and per-target persistence; +- duplicate replacement acceptance, decline, delete failure, and recreate + failure; +- later-target failure followed by a rerun that skips saved entries; +- non-interactive prompt failure; and +- secret-free errors and summaries. + +### Prescan and CLI wiring tests + +Extend `cli/test/prescan/checks-ios-profiles.test.ts` for exact coverage, +wildcard-repair findings, distinct wildcard conflicts, and non-duplicated target +ownership. Cover present-but-empty and malformed maps so they cannot bypass the +fatal gate. Assert fatal severity and the exact public command text. Add CLI +help or registration coverage for `build credentials ios-provisioning` and its +two store flags. + +## Verification + +Run repository formatting/lint before validation, then run focused analyzer, +credential-export, command, prescan, and CLI-registration tests. Run +`bun run cli:check` as the full CLI gate when practical. Confirm the added +production implementation under `cli/src` does not exceed 1,100 lines; tests +and this specification are excluded from that count.