From f6f9145a289282188c7cf2164ed0fec9e28d22b8 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 18 May 2026 08:39:31 +0200 Subject: [PATCH 1/2] feat(cli): build init --renew for iOS cert and Capgo-managed profiles Adds `build init --renew`, a single-command renewal flow for iOS distribution certificates and Capgo-issued provisioning profiles. The flow inspects saved credentials, computes a plan (default threshold: anything expiring within 30 days), then re-issues only what's needed via the App Store Connect API. Renewal reuses the existing onboarding Ink UI by switching it into a new `mode: 'renew'` branch. When the cert is being renewed, all Capgo-named profiles in the saved provisioning map are re-issued to bind to the new cert; user-imported profiles (whose names don't match the `Capgo AppStore` convention) are flagged and skipped because we can't re-create them via Apple's API. If the saved `.p8` API key is rejected (401/403), the flow drops into the onboarding `.p8` input chain so the user can supply a fresh key without restarting. A second entry point is added to `build credentials manage`: a new "Renew expired credentials" action on the top-level menu that hands off to the same flow for the picked app. Flags: --renew, --force (renew everything), --days N (threshold, default 30), --dry-run (print the plan, no changes), --local (operate on the project-local credentials file), --appId (override capacitor.config detection). Design doc: docs/plans/2026-05-18-ios-credential-renewal-design.md --- cli/package.json | 4 +- cli/src/build/credentials-manage.ts | 23 + cli/src/build/mobileprovision-parser.ts | 17 +- cli/src/build/onboarding/command.ts | 42 +- cli/src/build/onboarding/csr.ts | 88 ++- cli/src/build/onboarding/progress.ts | 28 + cli/src/build/onboarding/renew-detection.ts | 226 ++++++++ cli/src/build/onboarding/renew-execution.ts | 111 ++++ cli/src/build/onboarding/types.ts | 76 +++ cli/src/build/onboarding/ui/app.tsx | 547 +++++++++++++++++- .../build/onboarding/ui/renew-complete.tsx | 118 ++++ cli/src/build/onboarding/ui/renew-plan.tsx | 165 ++++++ .../build/onboarding/ui/renew-progress.tsx | 48 ++ cli/src/index.ts | 8 +- cli/test/test-cert-expiry.mjs | 87 +++ cli/test/test-mobileprovision-parser.mjs | 57 ++ cli/test/test-renew-detection.mjs | 223 +++++++ ...026-05-18-ios-credential-renewal-design.md | 256 ++++++++ 18 files changed, 2091 insertions(+), 33 deletions(-) create mode 100644 cli/src/build/onboarding/renew-detection.ts create mode 100644 cli/src/build/onboarding/renew-execution.ts create mode 100644 cli/src/build/onboarding/ui/renew-complete.tsx create mode 100644 cli/src/build/onboarding/ui/renew-plan.tsx create mode 100644 cli/src/build/onboarding/ui/renew-progress.tsx create mode 100644 cli/test/test-cert-expiry.mjs create mode 100644 cli/test/test-renew-detection.mjs create mode 100644 docs/plans/2026-05-18-ios-credential-renewal-design.md diff --git a/cli/package.json b/cli/package.json index 68f4f1e6b2..59e57990c6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -71,6 +71,8 @@ "test:upload": "bun test/test-upload-validation.mjs", "test:credentials": "bun test/test-credentials.mjs", "test:credentials-validation": "bun test/test-credentials-validation.mjs", + "test:renew-detection": "bun test/test-renew-detection.mjs", + "test:cert-expiry": "bun test/test-cert-expiry.mjs", "test:build-zip-filter": "bun test/test-build-zip-filter.mjs", "test:checksum": "bun test/test-checksum-algorithm.mjs", "test:build-needed": "bun test/test-build-needed.mjs", @@ -89,7 +91,7 @@ "test:platform-paths": "bun test/test-platform-paths.mjs", "test:payload-split": "bun test/test-payload-split.mjs", "test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs", - "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding", + "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:renew-detection && bun run test:cert-expiry && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs" }, "dependencies": { diff --git a/cli/src/build/credentials-manage.ts b/cli/src/build/credentials-manage.ts index 34113632ec..e9f1c17399 100644 --- a/cli/src/build/credentials-manage.ts +++ b/cli/src/build/credentials-manage.ts @@ -459,6 +459,24 @@ export async function manageCredentialsCommand(options: ManageCredentialsOptions } } } + else if (action === 'renew') { + const proceed = await pConfirm({ + message: `This will close the credentials manager and launch the iOS renewal flow for ${currentEntry.appId}. You won't return here automatically — re-run \`capgo build credentials manage\` afterwards. Continue?`, + initialValue: false, + }) + if (pIsCancel(proceed) || !proceed) + continue + + stopInitInkSession({ text: 'Launching iOS renewal…', tone: 'green' }) + await onboardingBuilderCommand({ + renew: true, + platform: 'ios', + appId: currentEntry.appId, + local: currentEntry.local, + }) + handedOffToOnboarding = true + break + } else if (action === 'export') { const exported = await exportToEnvFile(currentEntry) if (!exported) @@ -632,14 +650,19 @@ async function pickAction(entry: AppEntry, canGoBack: boolean, extraIntro?: stri '', 'View — flat list of every credential across platforms (show, decode, copy, edit, explain, remove).', 'Add… — add a new platform via onboarding, or add a configuration option.', + 'Renew — re-issue an expiring iOS cert and Capgo-managed provisioning profiles.', 'Export — write a .env file ready for CI/CD secrets (asks which platform if both are configured).', 'Delete — wipe all credentials for one platform (asks which if both are configured).', ], statusLine: canGoBack ? 'Esc = back, Ctrl+C = quit.' : 'Ctrl+C or Esc to quit.', }) + const hasIos = entry.platforms.includes('ios') const options = [ { value: 'view', label: 'View credentials', hint: 'inspect, decode, copy, edit, explain, remove' }, { value: 'add', label: 'Add credential…', hint: 'add platform support or a configuration option' }, + ...(hasIos + ? [{ value: 'renew', label: 'Renew expired credentials', hint: 'iOS cert + Capgo-managed profiles' }] + : []), { value: 'export', label: 'Export to .env', hint: 'CI/CD-ready file' }, { value: 'delete', label: 'Delete', hint: 'remove a platform from storage' }, ...(canGoBack ? [{ value: 'back', label: 'Back', hint: 'previous picker' }] : []), diff --git a/cli/src/build/mobileprovision-parser.ts b/cli/src/build/mobileprovision-parser.ts index 0b1e72f122..6dd996ef1e 100644 --- a/cli/src/build/mobileprovision-parser.ts +++ b/cli/src/build/mobileprovision-parser.ts @@ -6,6 +6,7 @@ export interface MobileprovisionInfo { uuid: string applicationIdentifier: string bundleId: string + expirationDate: Date | null } export function parseMobileprovision(filePath: string): MobileprovisionInfo { @@ -41,7 +42,10 @@ function parseMobileprovisionBuffer(data: Buffer, source: string): Mobileprovisi const dotIndex = applicationIdentifier.indexOf('.') const bundleId = dotIndex !== -1 ? applicationIdentifier.slice(dotIndex + 1) : applicationIdentifier - return { name, uuid, applicationIdentifier, bundleId } + const expirationRaw = extractPlistDate(plistXml, 'ExpirationDate') + const expirationDate = expirationRaw ? parseIsoOrNull(expirationRaw) : null + + return { name, uuid, applicationIdentifier, bundleId, expirationDate } } function extractPlistValue(xml: string, key: string): string | null { @@ -58,6 +62,17 @@ function extractNestedPlistValue(xml: string, dictKey: string, valueKey: string) return extractPlistValue(dictMatch[1], valueKey) } +function extractPlistDate(xml: string, key: string): string | null { + const regex = new RegExp(`${escapeRegex(key)}\\s*([^<]*)`) + const match = xml.match(regex) + return match ? match[1] : null +} + +function parseIsoOrNull(value: string): Date | null { + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } diff --git a/cli/src/build/onboarding/command.ts b/cli/src/build/onboarding/command.ts index 5d44577b6e..f4401c6cc7 100644 --- a/cli/src/build/onboarding/command.ts +++ b/cli/src/build/onboarding/command.ts @@ -15,6 +15,18 @@ import OnboardingApp from './ui/app.js' export interface OnboardingBuilderOptions { apikey?: string platform?: string + /** Explicit app ID override; defaults to the one in capacitor.config. */ + appId?: string + /** Renew mode flag (build init --renew). */ + renew?: boolean + /** Renew --force: re-issue everything regardless of expiry. */ + force?: boolean + /** Renew --days N: threshold for "expiring soon" (default 30). */ + days?: number + /** Renew --dry-run: print the plan, take no action. */ + dryRun?: boolean + /** Renew --local: operate on local .capgo-credentials.json instead of global. */ + local?: boolean } type Platform = 'ios' | 'android' @@ -80,7 +92,7 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions let androidDir = 'android' try { const extConfig = await getConfig() - appId = getAppId(undefined, extConfig?.config) + appId = getAppId(options.appId, extConfig?.config) iosDir = getPlatformDirFromCapacitorConfig(extConfig?.config, 'ios') androidDir = getPlatformDirFromCapacitorConfig(extConfig?.config, 'android') } @@ -93,6 +105,34 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions process.exit(1) } + // Renew mode short-circuits platform resolution: iOS-only. + if (options.renew) { + const requested = (options.platform || '').toLowerCase() + if (requested && requested !== 'ios') { + log.info('Android keystores do not expire and do not need periodic renewal.') + log.info('If you need to refresh the Play OAuth token, re-run `build init --platform android`.') + return + } + const progress = await loadProgress(appId) + const { waitUntilExit } = render( + React.createElement(OnboardingApp, { + appId, + initialProgress: progress, + iosDir, + apikey: options.apikey, + mode: 'renew', + renewOptions: { + thresholdDays: options.days ?? 30, + force: !!options.force, + dryRun: !!options.dryRun, + local: !!options.local, + }, + }), + ) + await waitUntilExit() + return + } + const platform = await resolvePlatform(options, iosDir, androidDir) if (platform === 'android') { diff --git a/cli/src/build/onboarding/csr.ts b/cli/src/build/onboarding/csr.ts index f4f2e04d61..c2006cbd90 100644 --- a/cli/src/build/onboarding/csr.ts +++ b/cli/src/build/onboarding/csr.ts @@ -33,13 +33,12 @@ export function generateCsr(): CsrResult { } /** - * Create a PKCS#12 (.p12) file from Apple's certificate response and the private key. - * - * @param certificateContentBase64 - The `certificateContent` field from Apple's - * POST /v1/certificates response (base64-encoded DER certificate) - * @param privateKeyPem - The PEM-encoded private key from generateCsr() - * @param password - Optional password for the .p12 file (defaults to DEFAULT_P12_PASSWORD) + * Default P12 password. node-forge P12 with empty password is incompatible + * with macOS `security import` (MAC verification fails). Using a known + * non-empty password avoids this issue. */ +export const DEFAULT_P12_PASSWORD = 'capgo' + /** * Extract the Apple team ID from a certificate's subject OU field. * More reliable than parsing the certificate name string. @@ -58,12 +57,81 @@ export function extractTeamIdFromCert(certificateContentBase64: string): string } /** - * Default P12 password. node-forge P12 with empty password is incompatible - * with macOS `security import` (MAC verification fails). Using a known - * non-empty password avoids this issue. + * Parse a base64-encoded PKCS#12 (.p12) with password fallbacks and return its + * embedded X.509 certificate. Tries the provided password, then empty string, + * then DEFAULT_P12_PASSWORD. Throws if none work. */ -export const DEFAULT_P12_PASSWORD = 'capgo' +function parseP12Certificate(p12Base64: string, password?: string): forge.pki.Certificate { + let p12Asn1: forge.asn1.Asn1 + try { + const p12Der = forge.util.decode64(p12Base64) + p12Asn1 = forge.asn1.fromDer(p12Der) + } + catch (err) { + throw new Error( + `Could not parse saved P12 certificate: input is not valid base64-encoded DER (${ + err instanceof Error ? err.message : String(err) + })`, + ) + } + const candidates = [password ?? '', '', DEFAULT_P12_PASSWORD] + const tried = new Set() + let lastError: unknown + + for (const pw of candidates) { + if (tried.has(pw)) + continue + tried.add(pw) + try { + const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, pw) + const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }) + const bagList = certBags[forge.pki.oids.certBag] ?? [] + const certBag = bagList.find(bag => bag.cert) + if (certBag?.cert) + return certBag.cert + lastError = new Error('PKCS#12 parsed but no certificate bag was found') + } + catch (err) { + lastError = err + } + } + + throw new Error( + `Could not parse saved P12 certificate. Tried provided password, empty, and default. Last error: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ) +} + +/** + * Extract the X.509 certificate's notAfter date from a base64-encoded P12. + * Used by the renew flow to detect cert expiry against the configured threshold. + */ +export function extractCertExpiry(p12Base64: string, password?: string): Date { + const cert = parseP12Certificate(p12Base64, password) + return cert.validity.notAfter +} + +/** + * Extract the X.509 certificate's serial number (hex, upper-case, no leading + * zeros stripping beyond what forge does) from a base64-encoded P12. Used by + * the renew flow to match the saved cert against Apple's listDistributionCerts + * response so we know which cert to suggest for revocation. + */ +export function extractCertSerial(p12Base64: string, password?: string): string { + const cert = parseP12Certificate(p12Base64, password) + return (cert.serialNumber || '').toUpperCase() +} + +/** + * Create a PKCS#12 (.p12) file from Apple's certificate response and the private key. + * + * @param certificateContentBase64 - The `certificateContent` field from Apple's + * POST /v1/certificates response (base64-encoded DER certificate) + * @param privateKeyPem - The PEM-encoded private key from generateCsr() + * @param password - Optional password for the .p12 file (defaults to DEFAULT_P12_PASSWORD) + */ export function createP12( certificateContentBase64: string, privateKeyPem: string, diff --git a/cli/src/build/onboarding/progress.ts b/cli/src/build/onboarding/progress.ts index 8bfe6b9134..3f8696da02 100644 --- a/cli/src/build/onboarding/progress.ts +++ b/cli/src/build/onboarding/progress.ts @@ -81,6 +81,11 @@ export async function deleteProgress( /** * Determine the first incomplete step based on saved progress. * Returns the step to resume from. + * + * For init-mode progress (the default, including legacy files without a `mode` + * field), resumes the onboarding chain. For renew-mode progress, resumes the + * renewal chain — analysis re-runs unless we have a stored plan; otherwise we + * pick up at the cert or profile step depending on what's already completed. */ export function getResumeStep(progress: OnboardingProgress | null): OnboardingStep { if (!progress) @@ -88,6 +93,29 @@ export function getResumeStep(progress: OnboardingProgress | null): OnboardingSt const { completedSteps } = progress + if (progress.mode === 'renew') { + if (!completedSteps.renewPlan) + return 'renew-analyzing' + if (!completedSteps.apiKeyVerified) { + if (progress.issuerId && progress.keyId && progress.p8Path) + return 'verifying-key' + if (progress.keyId && progress.p8Path) + return 'input-issuer-id' + if (progress.p8Path) + return 'input-key-id' + return 'api-key-instructions' + } + if (!completedSteps.certificateCreated) { + // Plan tells us whether the cert needs renewing; the renew-revoking-cert + // and creating-certificate handlers will short-circuit when it doesn't. + return 'renew-revoking-cert' + } + // Cert is done (or wasn't needed). Profiles either in progress or about to save. + if ((completedSteps.renewedProfiles?.length ?? 0) === 0) + return 'renew-creating-profiles' + return 'renew-creating-profiles' + } + if (!completedSteps.apiKeyVerified) { // Resume at the furthest partial input step if (progress.issuerId && progress.keyId && progress.p8Path) diff --git a/cli/src/build/onboarding/renew-detection.ts b/cli/src/build/onboarding/renew-detection.ts new file mode 100644 index 0000000000..4313cea472 --- /dev/null +++ b/cli/src/build/onboarding/renew-detection.ts @@ -0,0 +1,226 @@ +import type { BuildCredentials } from '../../schemas/build' +import type { + CertRenewDecision, + CertRenewReason, + ProfileRenewDecision, + ProfileRenewReason, + RenewOptions, + RenewPlan, +} from './types' +import { parseMobileprovisionFromBase64 } from '../mobileprovision-parser' +import { getCapgoProfileName } from './apple-api' +import { extractCertExpiry } from './csr' + +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +interface ProvisioningMapEntry { + profile: string + name: string +} + +function parseProvisioningMap(raw: string | undefined): Record { + if (!raw) + return {} + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) + return parsed as Record + return {} + } + catch { + return {} + } +} + +function tryExtractCertExpiry(p12Base64: string | undefined, password: string | undefined): Date | null { + if (!p12Base64) + return null + try { + return extractCertExpiry(p12Base64, password) + } + catch { + return null + } +} + +function tryExtractProfileExpiry(base64: string): Date | null { + try { + return parseMobileprovisionFromBase64(base64).expirationDate + } + catch { + return null + } +} + +function decideCert(expiry: Date | null, now: Date, options: RenewOptions): CertRenewDecision { + if (options.force) { + return { needsRenewal: true, currentExpiry: expiry, reason: 'forced' } + } + if (expiry === null) { + // No cert at all, or cert is unparseable — needs renewal to recover. + return { needsRenewal: true, currentExpiry: null, reason: 'expired' } + } + + const reason = classifyExpiry(expiry, now, options.thresholdDays) + return { + needsRenewal: reason !== 'ok', + currentExpiry: expiry, + reason, + } +} + +function classifyExpiry(expiry: Date, now: Date, thresholdDays: number): CertRenewReason { + const diffMs = expiry.getTime() - now.getTime() + if (diffMs <= 0) + return 'expired' + if (diffMs <= thresholdDays * MS_PER_DAY) + return 'expiring' + return 'ok' +} + +function decideProfile( + bundleId: string, + name: string, + expiry: Date | null, + isCapgoCreated: boolean, + certNeedsRenewal: boolean, + now: Date, + options: RenewOptions, +): ProfileRenewDecision { + // User-imported profiles are never auto-renewed. + if (!isCapgoCreated) { + return { + bundleId, + name, + needsRenewal: false, + currentExpiry: expiry, + reason: 'skipped-non-capgo', + isCapgoCreated: false, + } + } + + // Cert is being renewed → all Capgo-created profiles must be re-issued too. + if (certNeedsRenewal) { + return { + bundleId, + name, + needsRenewal: true, + currentExpiry: expiry, + reason: 'cert-renewed', + isCapgoCreated: true, + } + } + + if (options.force) { + return { + bundleId, + name, + needsRenewal: true, + currentExpiry: expiry, + reason: 'forced', + isCapgoCreated: true, + } + } + + if (expiry === null) { + // Unparseable profile — renew to recover. + return { + bundleId, + name, + needsRenewal: true, + currentExpiry: null, + reason: 'expired', + isCapgoCreated: true, + } + } + + const reason = classifyExpiry(expiry, now, options.thresholdDays) + const profileReason: ProfileRenewReason = reason === 'ok' + ? 'ok' + : reason + return { + bundleId, + name, + needsRenewal: reason !== 'ok', + currentExpiry: expiry, + reason: profileReason, + isCapgoCreated: true, + } +} + +/** + * Compute what needs to be renewed for an app's saved iOS credentials. + * + * Pure function (no I/O beyond reading the credentials object passed in). + * The caller is responsible for loading saved credentials and supplying them. + * + * @param saved - The iOS section of saved credentials (Partial). + * @param appId - The Capacitor app ID. Used to detect which profiles were Capgo-created + * (name matches `Capgo ${appId} AppStore`). + * @param options - Threshold for "expiring soon" and a force flag. + * @param now - Override the current time. Defaults to new Date(). Exposed for testing. + */ +export function computeRenewPlan( + saved: Partial, + appId: string, + options: RenewOptions, + now: Date = new Date(), +): RenewPlan { + const certExpiry = tryExtractCertExpiry(saved.BUILD_CERTIFICATE_BASE64, saved.P12_PASSWORD) + const certDecision = decideCert(certExpiry, now, options) + + const map = parseProvisioningMap(saved.CAPGO_IOS_PROVISIONING_MAP) + const capgoName = getCapgoProfileName(appId) + + const profiles: ProfileRenewDecision[] = [] + for (const [bundleId, entry] of Object.entries(map)) { + const isCapgoCreated = entry.name === capgoName + const expiry = tryExtractProfileExpiry(entry.profile) + profiles.push( + decideProfile(bundleId, entry.name, expiry, isCapgoCreated, certDecision.needsRenewal, now, options), + ) + } + + // Stable order: main app first (matches appId), then alphabetical bundle ID. + profiles.sort((a, b) => { + if (a.bundleId === appId && b.bundleId !== appId) + return -1 + if (b.bundleId === appId && a.bundleId !== appId) + return 1 + return a.bundleId.localeCompare(b.bundleId) + }) + + const hasAnythingToRenew = certDecision.needsRenewal || profiles.some(p => p.needsRenewal) + + return { + appId, + cert: certDecision, + profiles, + hasAnythingToRenew, + } +} + +/** + * Has the saved credentials object got the legacy `BUILD_PROVISION_PROFILE_BASE64` + * field but no `CAPGO_IOS_PROVISIONING_MAP`? The renew flow refuses on this and + * points the user at `build credentials migrate`. + */ +export function isLegacyProfileFormat(saved: Partial): boolean { + return !!saved.BUILD_PROVISION_PROFILE_BASE64 && !saved.CAPGO_IOS_PROVISIONING_MAP +} + +/** + * Does the saved credentials object contain any iOS material at all? + * Used by the renew flow to decide whether to short-circuit to `renew-no-credentials`. + */ +export function hasAnyIosCredentials(saved: Partial | undefined | null): boolean { + if (!saved) + return false + return !!( + saved.BUILD_CERTIFICATE_BASE64 + || saved.CAPGO_IOS_PROVISIONING_MAP + || saved.BUILD_PROVISION_PROFILE_BASE64 + || saved.APPLE_KEY_CONTENT + || saved.APPLE_KEY_ID + ) +} diff --git a/cli/src/build/onboarding/renew-execution.ts b/cli/src/build/onboarding/renew-execution.ts new file mode 100644 index 0000000000..f2abac0ed2 --- /dev/null +++ b/cli/src/build/onboarding/renew-execution.ts @@ -0,0 +1,111 @@ +import type { BuildCredentials } from '../../schemas/build' +import type { RenewPlan } from './types' +import { listDistributionCerts } from './apple-api' +import { extractCertSerial } from './csr' + +interface ProvisioningMapEntry { + profile: string + name: string +} + +export interface RevokeCandidate { + certId: string + serialNumber: string + name: string + expirationDate: string +} + +/** + * Find the Apple-side cert that matches the saved P12's serial number, so the + * renew flow can revoke it before creating a new cert (frees a slot, avoiding + * the cert-limit-prompt in the common case). + * + * Returns null if no match is found — either the saved P12 was already revoked, + * the cert was created by a tool that doesn't show up in this list, or the + * saved P12 itself can't be parsed. Callers should treat null as "skip the + * proactive revoke, fall through to the regular create-cert flow." + */ +export async function findRevokeCandidate( + token: string, + savedP12Base64: string | undefined, + p12Password: string | undefined, +): Promise { + if (!savedP12Base64) + return null + + let savedSerial: string + try { + savedSerial = extractCertSerial(savedP12Base64, p12Password) + } + catch { + return null + } + if (!savedSerial) + return null + + const certs = await listDistributionCerts(token) + for (const cert of certs) { + if ((cert.serialNumber || '').toUpperCase() === savedSerial) { + return { + certId: cert.id, + serialNumber: cert.serialNumber, + name: cert.name, + expirationDate: cert.expirationDate, + } + } + } + return null +} + +/** + * Build the updated provisioning map for `updateSavedCredentials` by merging + * newly-issued profiles into the existing map. + * + * - `existingMap` is the JSON-parsed `CAPGO_IOS_PROVISIONING_MAP` from saved creds. + * - `renewedProfiles` is keyed by bundleId; each value is the new base64 profile + * content and the (server-assigned) profile name. + * - Entries in `existingMap` that aren't in `renewedProfiles` are carried forward + * unchanged (this preserves user-imported profiles for extension targets). + */ +export function assembleProvisioningMap( + existingMap: Record, + renewedProfiles: Record, +): Record { + const merged: Record = { ...existingMap } + for (const [bundleId, renewed] of Object.entries(renewedProfiles)) { + merged[bundleId] = { + profile: renewed.profileContent, + name: renewed.profileName, + } + } + return merged +} + +/** + * Assemble the `Partial` payload to hand to + * `updateSavedCredentials` after the renew flow has finished. + * + * - If a new cert was created, sets `BUILD_CERTIFICATE_BASE64` to its base64 + * P12 content. Otherwise leaves the existing value untouched. + * - Always sets `CAPGO_IOS_PROVISIONING_MAP` to the merged map JSON. (Even when + * no profiles were renewed, writing the same value is a no-op.) + */ +export function assembleRenewedCredentials(args: { + newP12Base64?: string + mergedProvisioningMap: Record +}): Partial { + const update: Partial = { + CAPGO_IOS_PROVISIONING_MAP: JSON.stringify(args.mergedProvisioningMap), + } + if (args.newP12Base64) + update.BUILD_CERTIFICATE_BASE64 = args.newP12Base64 + return update +} + +/** + * The bundleIds the renew flow needs to (re)create profiles for, in stable order. + * Excludes user-imported profiles (`reason: 'skipped-non-capgo'`). + */ +export function bundleIdsToRenew(plan: RenewPlan): string[] { + return plan.profiles.filter(p => p.needsRenewal).map(p => p.bundleId) +} diff --git a/cli/src/build/onboarding/types.ts b/cli/src/build/onboarding/types.ts index 6d0b3284d9..670cddaba4 100644 --- a/cli/src/build/onboarding/types.ts +++ b/cli/src/build/onboarding/types.ts @@ -2,6 +2,8 @@ export type Platform = 'ios' | 'android' +export type OnboardingMode = 'init' | 'renew' + export type OnboardingStep = | 'welcome' | 'platform-select' @@ -26,6 +28,15 @@ export type OnboardingStep | 'build-complete' | 'no-platform' | 'error' + // Renew-mode steps + | 'renew-analyzing' + | 'renew-no-credentials' + | 'renew-nothing-to-do' + | 'renew-plan' + | 'renew-revoking-cert' + | 'renew-creating-profiles' + | 'renew-saving' + | 'renew-complete' export interface ApiKeyData { keyId: string @@ -49,6 +60,8 @@ export interface OnboardingProgress { platform: Platform appId: string startedAt: string + /** 'init' (default, fresh onboarding) or 'renew' (renewing existing creds). Missing = 'init' for backward compat. */ + mode?: OnboardingMode /** Path to the .p8 file on disk (content is NOT stored, only the path) */ p8Path?: string /** Partial input — saved incrementally so resume works mid-flow */ @@ -58,6 +71,10 @@ export interface OnboardingProgress { apiKeyVerified?: ApiKeyData certificateCreated?: CertificateData profileCreated?: ProfileData + /** Set during renew: bundleIds whose new profile has been successfully created. Lets us resume the per-profile loop. */ + renewedProfiles?: string[] + /** Set during renew once the in-memory plan has been built. Holds the JSON-stringified RenewPlan so resume can skip re-analysis. */ + renewPlan?: string } /** Temporary — wiped after .p12 creation */ _privateKeyPem?: string @@ -88,6 +105,14 @@ export const STEP_PROGRESS: Record = { 'build-complete': 100, 'no-platform': 0, 'error': 0, + 'renew-analyzing': 10, + 'renew-no-credentials': 0, + 'renew-nothing-to-do': 100, + 'renew-plan': 20, + 'renew-revoking-cert': 40, + 'renew-creating-profiles': 70, + 'renew-saving': 90, + 'renew-complete': 100, } export function getPhaseLabel(step: OnboardingStep): string { @@ -122,5 +147,56 @@ export function getPhaseLabel(step: OnboardingStep): string { case 'no-platform': case 'error': return '' + case 'renew-analyzing': + case 'renew-plan': + case 'renew-no-credentials': + case 'renew-nothing-to-do': + return 'Renew · Analyze' + case 'renew-revoking-cert': + return 'Renew · Distribution Certificate' + case 'renew-creating-profiles': + return 'Renew · Provisioning Profiles' + case 'renew-saving': + return 'Renew · Save' + case 'renew-complete': + return 'Renew · Complete' } } + +// ─── Renew plan types ────────────────────────────────────────────── + +export type CertRenewReason = 'expired' | 'expiring' | 'forced' | 'ok' +export type ProfileRenewReason + = | 'expired' + | 'expiring' + | 'forced' + | 'cert-renewed' + | 'ok' + | 'skipped-non-capgo' + +export interface CertRenewDecision { + needsRenewal: boolean + currentExpiry: Date | null + reason: CertRenewReason +} + +export interface ProfileRenewDecision { + bundleId: string + name: string + needsRenewal: boolean + currentExpiry: Date | null + reason: ProfileRenewReason + isCapgoCreated: boolean +} + +export interface RenewPlan { + appId: string + cert: CertRenewDecision + profiles: ProfileRenewDecision[] + hasAnythingToRenew: boolean +} + +export interface RenewOptions { + thresholdDays: number + force: boolean +} diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index 358ec2c7c5..2d1de71ec7 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react' import type { BuildLogger } from '../../request.js' -import type { ApiKeyData, CertificateData, OnboardingProgress, OnboardingStep, ProfileData } from '../types.js' -import { handleCustomMsg } from '../../qr.js' -import { spawn } from 'node:child_process' +import type { ApiKeyData, CertificateData, OnboardingMode, OnboardingProgress, OnboardingStep, ProfileData, RenewPlan } from '../types.js' +import type { RenewCompleteSummary } from './renew-complete.js' import { Buffer } from 'node:buffer' +import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { copyFile, readFile } from 'node:fs/promises' import { homedir } from 'node:os' @@ -18,24 +18,41 @@ import { writeOnboardingSupportBundle } from '../../../onboarding-support.js' import { formatRunnerCommand, splitRunnerCommand } from '../../../runner-command.js' import { findSavedKeySilent, getPMAndCommand } from '../../../utils.js' import { loadSavedCredentials, updateSavedCredentials } from '../../credentials.js' +import { handleCustomMsg } from '../../qr.js' import { requestBuildInternal } from '../../request.js' -import { CertificateLimitError, createCertificate, createProfile, deleteProfile, DuplicateProfileError, ensureBundleId, generateJwt, revokeCertificate, verifyApiKey } from '../apple-api.js' +import { CertificateLimitError, createCertificate, createProfile, deleteProfile, DuplicateProfileError, ensureBundleId, findCapgoProfiles, generateJwt, revokeCertificate, verifyApiKey } from '../apple-api.js' import { createP12, DEFAULT_P12_PASSWORD, generateCsr } from '../csr.js' import { canUseFilePicker, openFilePicker } from '../file-picker.js' import { deleteProgress, getResumeStep, loadProgress, saveProgress } from '../progress.js' import { getBuildOnboardingRecoveryAdvice } from '../recovery.js' +import { computeRenewPlan, hasAnyIosCredentials, isLegacyProfileFormat } from '../renew-detection.js' +import { assembleProvisioningMap, assembleRenewedCredentials, bundleIdsToRenew, findRevokeCandidate } from '../renew-execution.js' import { getPhaseLabel, STEP_PROGRESS, } from '../types.js' import { Divider, ErrorLine, FilteredTextInput, Header, SpinnerLine, SuccessLine } from './components.js' +import { RenewCompleteScreen } from './renew-complete.js' +import { RenewPlanScreen } from './renew-plan.js' +import { RenewProgressScreen } from './renew-progress.js' const OUTPUT_LINE_SPLIT_RE = /\r?\n/ const CARRIAGE_RETURN_RE = /\r/g interface LogEntry { text: string, color?: string } +interface RenewModeOptions { + /** Days threshold for "expiring soon" (default 30 — applied by command.ts). */ + thresholdDays: number + /** --force: renew everything regardless of expiry. */ + force: boolean + /** --dry-run: render the plan, then exit without making changes. */ + dryRun: boolean + /** --local: operate on .capgo-credentials.json instead of the global file. */ + local: boolean +} + interface AppProps { appId: string initialProgress: OnboardingProgress | null @@ -43,6 +60,10 @@ interface AppProps { iosDir: string /** Optional Capgo API key passed via -a/--apikey flag; takes precedence over saved key */ apikey?: string + /** 'init' (default fresh onboarding) or 'renew' (renewing existing creds). */ + mode?: OnboardingMode + /** Only meaningful when mode === 'renew'. */ + renewOptions?: RenewModeOptions } async function runRunnerCommand(runner: string, args: string[]): Promise<{ success: boolean, output: string[] }> { @@ -82,11 +103,22 @@ async function runRunnerCommand(runner: string, args: string[]): Promise<{ succe }) } -const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) => { +const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey, mode = 'init', renewOptions }) => { const { exit } = useApp() const startStep = getResumeStep(initialProgress) + const modeRef = useRef(mode) - const [step, setStep] = useState(startStep === 'welcome' ? 'welcome' : startStep) + // In renew mode, bypass the welcome/platform-select chain and jump straight + // into analysis. Resume from saved progress only when its mode matches. + const renewResumeStep: OnboardingStep | null = mode === 'renew' && initialProgress?.mode === 'renew' + ? startStep + : null + + const [step, setStep] = useState( + mode === 'renew' + ? (renewResumeStep ?? 'renew-analyzing') + : (startStep === 'welcome' ? 'welcome' : startStep), + ) const [log, setLog] = useState([]) const [error, setError] = useState(null) const [retryCount, setRetryCount] = useState(0) @@ -138,6 +170,42 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) const [buildOutput, setBuildOutput] = useState([]) const [supportBundlePath, setSupportBundlePath] = useState(null) + // ── Renew-mode state ── + const [renewPlan, setRenewPlan] = useState(() => { + if (mode !== 'renew') + return null + const raw = initialProgress?.completedSteps.renewPlan + if (!raw) + return null + try { + const parsed = JSON.parse(raw) as RenewPlan + // Re-hydrate Date instances (lost via JSON serialization). + if (parsed.cert.currentExpiry) + parsed.cert.currentExpiry = new Date(parsed.cert.currentExpiry as unknown as string) + for (const p of parsed.profiles) { + if (p.currentExpiry) + p.currentExpiry = new Date(p.currentExpiry as unknown as string) + } + return parsed + } + catch { + return null + } + }) + const renewPlanRef = useRef(renewPlan) + const [renewCompletedProfiles, setRenewCompletedProfiles] = useState>([]) + const renewCompletedProfilesRef = useRef(renewCompletedProfiles) + const [renewCurrentBundleId, setRenewCurrentBundleId] = useState(null) + const [renewSummary, setRenewSummary] = useState(null) + const renewSavedKeyRejectedRef = useRef(false) + + useEffect(() => { + renewPlanRef.current = renewPlan + }, [renewPlan]) + useEffect(() => { + renewCompletedProfilesRef.current = renewCompletedProfiles + }, [renewCompletedProfiles]) + const addLog = useCallback((text: string, color = 'green') => { setLog(prev => [...prev, { text, color }]) }, []) @@ -156,7 +224,7 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) exitRequestedRef.current = true if (message) addLog(message, 'yellow') - setTimeout(() => exit(), 50) + setTimeout(exit, 50) }, [addLog, exit]) // Open browser on Ctrl+O (FilteredTextInput ignores ctrl keys, so no conflict) @@ -208,6 +276,22 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) } } + /** + * Detect an Apple authentication error (401/403). Used in renew mode to + * fall back to the onboarding p8 input chain when the saved key is rejected. + */ + function isLikelyAuthError(err: unknown): boolean { + if (err instanceof NeedP8Error) + return true + const message = err instanceof Error ? err.message : String(err) + if (!message) + return false + return /\b(?:401|403)\b/.test(message) + || /api key verification failed/i.test(message) + || /unauthorized/i.test(message) + || /forbidden/i.test(message) + } + async function getFreshToken(): Promise { let content = p8ContentRef.current if (!content && p8PathRef.current) { @@ -453,21 +537,48 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) if (verifyResult.teamId) setTeamId(verifyResult.teamId) const apiKeyData: ApiKeyData = { keyId: keyIdRef.current, issuerId: issuerIdRef.current } - const progress: OnboardingProgress = { - platform: 'ios', - appId, - p8Path: p8PathRef.current, - startedAt: new Date().toISOString(), - completedSteps: { apiKeyVerified: apiKeyData }, - } + const existing = await loadProgress(appId) + const progress: OnboardingProgress = existing + ? { ...existing, completedSteps: { ...existing.completedSteps, apiKeyVerified: apiKeyData } } + : { + platform: 'ios', + appId, + p8Path: p8PathRef.current, + startedAt: new Date().toISOString(), + mode: modeRef.current, + completedSteps: { apiKeyVerified: apiKeyData }, + } await saveProgress(appId, progress) addLog(`✔ API Key verified — Key: ${keyId}`) setRetryCount(0) - setStep('creating-certificate') + if (modeRef.current === 'renew') { + const plan = renewPlanRef.current + if (plan?.cert.needsRenewal) { + setStep('renew-revoking-cert') + } + else { + setStep('renew-creating-profiles') + } + } + else { + setStep('creating-certificate') + } } catch (err) { - if (!cancelled) + if (!cancelled) { + if (modeRef.current === 'renew' && !renewSavedKeyRejectedRef.current && isLikelyAuthError(err)) { + // Saved API key was rejected — drop into the onboarding p8 input chain. + renewSavedKeyRejectedRef.current = true + addLog('⚠ Saved App Store Connect API key was rejected. Please re-enter the key details.', 'yellow') + // Wipe the cached key material so the input chain starts clean. + setP8Content('') + setKeyId('') + setIssuerId('') + setStep('api-key-instructions') + return + } handleError(err, 'verifying-key') + } } })() } @@ -505,7 +616,10 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) } addLog(`✔ Distribution certificate created — Expires ${cert.expirationDate}`) setRetryCount(0) - setStep('creating-profile') + if (modeRef.current === 'renew') + setStep('renew-creating-profiles') + else + setStep('creating-profile') } catch (err) { if (cancelled) @@ -594,11 +708,14 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) addLog(`✔ Removed ${duplicateProfiles.length} old profile(s)`) setDuplicateProfiles([]) // Retry creating the profile - setStep('creating-profile') + if (modeRef.current === 'renew') + setStep('renew-creating-profiles') + else + setStep('creating-profile') } catch (err) { if (!cancelled) - handleError(err, 'creating-profile') + handleError(err, modeRef.current === 'renew' ? 'renew-creating-profiles' : 'creating-profile') } })() } @@ -699,6 +816,304 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) } } + // ── Renew-mode handlers ── + + if (step === 'renew-analyzing') { + ;(async () => { + try { + const saved = await loadSavedCredentials(appId, renewOptions?.local) + if (cancelled) + return + const ios = saved?.ios + if (!ios || !hasAnyIosCredentials(ios)) { + setStep('renew-no-credentials') + return + } + if (isLegacyProfileFormat(ios)) { + handleError( + new Error( + 'Saved iOS credentials use the legacy BUILD_PROVISION_PROFILE_BASE64 format. ' + + 'Run `build credentials migrate --platform ios` before renewing.', + ), + 'renew-analyzing', + ) + return + } + + const opts = { + thresholdDays: renewOptions?.thresholdDays ?? 30, + force: renewOptions?.force ?? false, + } + const plan = computeRenewPlan(ios, appId, opts) + if (cancelled) + return + setRenewPlan(plan) + renewPlanRef.current = plan + + // Re-hydrate APPLE_KEY_CONTENT into the key-input state so verifying-key works without prompting. + if (ios.APPLE_KEY_CONTENT) { + try { + const decoded = Buffer.from(ios.APPLE_KEY_CONTENT, 'base64').toString('utf-8') + setP8Content(decoded) + } + catch { + // ignore; we'll fall back to the input chain on key failure + } + } + if (ios.APPLE_KEY_ID) + setKeyId(ios.APPLE_KEY_ID) + if (ios.APPLE_ISSUER_ID) + setIssuerId(ios.APPLE_ISSUER_ID) + if (ios.APP_STORE_CONNECT_TEAM_ID) + setTeamId(ios.APP_STORE_CONNECT_TEAM_ID) + + // Persist plan into progress for resume — Dates serialize fine via toJSON. + const existing = await loadProgress(appId) + const progressPayload: OnboardingProgress = existing + ? { ...existing, mode: 'renew', completedSteps: { ...existing.completedSteps, renewPlan: JSON.stringify(plan) } } + : { + platform: 'ios', + appId, + startedAt: new Date().toISOString(), + mode: 'renew', + completedSteps: { renewPlan: JSON.stringify(plan) }, + } + await saveProgress(appId, progressPayload) + + if (!plan.hasAnythingToRenew) { + setStep('renew-nothing-to-do') + return + } + setStep('renew-plan') + } + catch (err) { + if (!cancelled) + handleError(err, 'renew-analyzing') + } + })() + } + + if (step === 'renew-revoking-cert') { + ;(async () => { + try { + const saved = await loadSavedCredentials(appId, renewOptions?.local) + if (cancelled) + return + const ios = saved?.ios + if (!ios?.BUILD_CERTIFICATE_BASE64) { + // Nothing to revoke; fall through to cert creation. + setStep('creating-certificate') + return + } + const token = await getFreshToken() + const candidate = await findRevokeCandidate(token, ios.BUILD_CERTIFICATE_BASE64, ios.P12_PASSWORD) + if (cancelled) + return + if (candidate) { + await revokeCertificate(token, candidate.certId) + if (cancelled) + return + addLog(`✔ Old certificate revoked (serial ${candidate.serialNumber})`) + } + else { + addLog('ℹ Saved certificate not found on Apple side — proceeding to fresh create.', 'cyan') + } + setStep('creating-certificate') + } + catch (err) { + if (!cancelled) + handleError(err, 'renew-revoking-cert') + } + })() + } + + if (step === 'renew-creating-profiles') { + ;(async () => { + try { + const plan = renewPlanRef.current + if (!plan) { + handleError(new Error('Renew plan was not loaded; cannot create profiles.'), 'renew-creating-profiles') + return + } + const targets = bundleIdsToRenew(plan) + if (targets.length === 0) { + // Nothing to do for profiles — go straight to save. + setStep('renew-saving') + return + } + + // Determine the cert ID to bind profiles to. + // If the cert was just renewed in this run, certData has the new ID. + // If the cert wasn't renewed, look up the existing one matching the saved P12 serial. + let certificateId = certData?.certificateId || '' + if (!certificateId) { + const saved = await loadSavedCredentials(appId, renewOptions?.local) + if (cancelled) + return + const ios = saved?.ios + if (ios?.BUILD_CERTIFICATE_BASE64) { + const token = await getFreshToken() + const candidate = await findRevokeCandidate(token, ios.BUILD_CERTIFICATE_BASE64, ios.P12_PASSWORD) + if (candidate) + certificateId = candidate.certId + } + } + if (!certificateId) { + handleError(new Error('Could not determine which Apple certificate to bind new profiles to.'), 'renew-creating-profiles') + return + } + + const completedSoFar = renewCompletedProfilesRef.current + const remaining = targets.filter(bid => !completedSoFar.some(c => c.bundleId === bid)) + const token = await getFreshToken() + + for (const bundleId of remaining) { + if (cancelled) + return + setRenewCurrentBundleId(bundleId) + const { bundleIdResourceId } = await ensureBundleId(token, bundleId) + if (cancelled) + return + // Clean up any existing Capgo-named profiles for this app before creating a new one. + const existingProfiles = await findCapgoProfiles(token, appId) + for (const existing of existingProfiles) { + if (cancelled) + return + try { + await deleteProfile(token, existing.id) + } + catch { + // Best effort — the create call below will report duplicate if it matters. + } + } + try { + const created = await createProfile(token, bundleIdResourceId, certificateId, appId) + if (cancelled) + return + const completed = { bundleId, profileBase64: created.profileContent, profileName: created.profileName } + setRenewCompletedProfiles((prev) => { + const next = [...prev, completed] + renewCompletedProfilesRef.current = next + return next + }) + // Persist progress per profile so we can resume. + const progress = await loadProgress(appId) + if (progress) { + const renewedList = progress.completedSteps.renewedProfiles ?? [] + if (!renewedList.includes(bundleId)) + renewedList.push(bundleId) + progress.completedSteps.renewedProfiles = renewedList + await saveProgress(appId, progress) + } + addLog(`✔ Provisioning profile renewed for ${bundleId}`) + } + catch (err) { + if (err instanceof DuplicateProfileError) { + setDuplicateProfiles(err.profiles) + setStep('duplicate-profile-prompt') + return + } + throw err + } + } + + if (cancelled) + return + setRenewCurrentBundleId(null) + setStep('renew-saving') + } + catch (err) { + if (!cancelled) + handleError(err, 'renew-creating-profiles') + } + })() + } + + if (step === 'renew-saving') { + ;(async () => { + try { + const plan = renewPlanRef.current + if (!plan) { + handleError(new Error('Renew plan was not loaded; cannot save.'), 'renew-saving') + return + } + const saved = await loadSavedCredentials(appId, renewOptions?.local) + if (cancelled) + return + const existingMap = (() => { + const raw = saved?.ios?.CAPGO_IOS_PROVISIONING_MAP + if (!raw) + return {} + try { + return JSON.parse(raw) as Record + } + catch { + return {} + } + })() + + const renewedRecord: Record = {} + for (const completed of renewCompletedProfilesRef.current) { + renewedRecord[completed.bundleId] = { + profileContent: completed.profileBase64, + profileName: completed.profileName, + } + } + const mergedMap = assembleProvisioningMap(existingMap, renewedRecord) + + const update = assembleRenewedCredentials({ + newP12Base64: certData?.p12Base64, + mergedProvisioningMap: mergedMap, + }) + + // Carry forward the (possibly refreshed) Apple API key material. + let keyContent = p8ContentRef.current + if (!keyContent && p8PathRef.current) { + try { + keyContent = await readFile(p8PathRef.current, 'utf-8') + } + catch { + // ignore; saved key content (if any) still works + } + } + if (keyContent) + update.APPLE_KEY_CONTENT = Buffer.from(keyContent).toString('base64') + if (keyIdRef.current) + update.APPLE_KEY_ID = keyIdRef.current + if (issuerIdRef.current) + update.APPLE_ISSUER_ID = issuerIdRef.current + if (teamId || certData?.teamId) + update.APP_STORE_CONNECT_TEAM_ID = teamId || certData!.teamId + if (certData?.p12Base64) + update.P12_PASSWORD = DEFAULT_P12_PASSWORD + + await updateSavedCredentials(appId, 'ios', update, renewOptions?.local) + if (cancelled) + return + await deleteProgress(appId) + + const certBefore = plan.cert.currentExpiry + const certAfterIso = certData?.expirationDate + const certAfter = certAfterIso ? new Date(certAfterIso) : plan.cert.currentExpiry + const summary: RenewCompleteSummary = { + appId, + certBefore, + certAfter, + certRenewed: !!certData?.p12Base64, + profilesRenewed: renewCompletedProfilesRef.current.map(c => c.bundleId), + profilesSkippedNonCapgo: plan.profiles.filter(p => p.reason === 'skipped-non-capgo').map(p => p.bundleId), + } + setRenewSummary(summary) + addLog('✔ Credentials renewed') + setStep('renew-complete') + } + catch (err) { + if (!cancelled) + handleError(err, 'renew-saving') + } + })() + } + return () => { cancelled = true } @@ -1376,6 +1791,100 @@ const OnboardingApp: FC = ({ appId, initialProgress, iosDir, apikey }) )} + + {/* ── Renew-mode screens ── */} + + {step === 'renew-analyzing' && ( + + + + )} + + {step === 'renew-no-credentials' && ( + + + + Run + {buildInitCommand} + first to onboard, then come back to + build init --renew + . + + exitOnboarding()} + /> + + )} + + {step === 'renew-plan' && renewPlan && ( + { + // After plan confirm: verify the saved API key first. + // If the saved key was already loaded into refs in renew-analyzing, this + // flows through directly. Otherwise the input chain kicks in. + setStep('verifying-key') + }} + onCancel={() => { + ;(async () => { + await deleteProgress(appId) + exitOnboarding('Renewal cancelled.') + })() + }} + /> + )} + + {step === 'renew-revoking-cert' && ( + + + + )} + + {step === 'renew-creating-profiles' && renewPlan && ( + c.bundleId)} + currentBundleId={renewCurrentBundleId} + /> + )} + + {step === 'renew-saving' && ( + + + + )} + + {step === 'renew-complete' && renewSummary && ( + setStep('requesting-build')} + onExit={() => exitOnboarding()} + /> + )} ) } diff --git a/cli/src/build/onboarding/ui/renew-complete.tsx b/cli/src/build/onboarding/ui/renew-complete.tsx new file mode 100644 index 0000000000..a56a00e08d --- /dev/null +++ b/cli/src/build/onboarding/ui/renew-complete.tsx @@ -0,0 +1,118 @@ +import type { FC } from 'react' +import { Select } from '@inkjs/ui' +import { Box, Newline, Text } from 'ink' +import React from 'react' +import { SuccessLine } from './components.js' + +function formatDate(date: Date | null): string { + if (!date) + return 'unknown' + return date.toISOString().slice(0, 10) +} + +export interface RenewCompleteSummary { + appId: string + certBefore: Date | null + certAfter: Date | null + certRenewed: boolean + profilesRenewed: string[] + profilesSkippedNonCapgo: string[] +} + +interface RenewCompleteScreenProps { + summary: RenewCompleteSummary + onRunBuild: () => void + onExit: () => void +} + +export const RenewCompleteScreen: FC = ({ summary, onRunBuild, onExit }) => { + return ( + + + + + {summary.certRenewed + ? ( + + {' '} + Certificate: valid until + {' '} + {formatDate(summary.certAfter)} + {summary.certBefore && ( + + {' '} + (was + {' '} + {formatDate(summary.certBefore)} + ) + + )} + + ) + : ( + + {' '} + Certificate: unchanged (still valid until + {' '} + {formatDate(summary.certAfter)} + ) + + )} + + + {' '} + Profiles renewed: + {' '} + {summary.profilesRenewed.length} + + {summary.profilesRenewed.map(bundleId => ( + + {' - '} + {bundleId} + + ))} + + {summary.profilesSkippedNonCapgo.length > 0 && ( + + + {' '} + Profiles skipped (user-imported, regenerate manually): + {' '} + {summary.profilesSkippedNonCapgo.length} + + {summary.profilesSkippedNonCapgo.map(bundleId => ( + + {' - '} + {bundleId} + + ))} + {summary.certRenewed && ( + + + {' '} + Re-generate skipped profiles with: + {' '} + build credentials update --ios-provisioning-profile <path> + + + )} + + )} + + + Run a test build now? + + + ) + : ( + + Continue? +