Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
23 changes: 23 additions & 0 deletions cli/src/build/credentials-manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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' }] : []),
Expand Down
17 changes: 16 additions & 1 deletion cli/src/build/mobileprovision-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface MobileprovisionInfo {
uuid: string
applicationIdentifier: string
bundleId: string
expirationDate: Date | null
}

export function parseMobileprovision(filePath: string): MobileprovisionInfo {
Expand Down Expand Up @@ -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 {
Expand All @@ -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(`<key>${escapeRegex(key)}</key>\\s*<date>([^<]*)</date>`)
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, '\\$&')
}
42 changes: 41 additions & 1 deletion cli/src/build/onboarding/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
}
Comment on lines 94 to 98
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor --appId even when Capacitor config is missing

The new --appId override is assigned inside the getConfig() try-block, so if config loading throws (the exact case this flag is meant to support outside project root), appId stays undefined and the command exits with "Could not detect app ID". This makes the documented override path unusable unless Capacitor config can already be loaded.

Useful? React with 👍 / 👎.

Expand All @@ -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') {
Expand Down
88 changes: 78 additions & 10 deletions cli/src/build/onboarding/csr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string>()
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,
Expand Down
28 changes: 28 additions & 0 deletions cli/src/build/onboarding/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,41 @@ 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)
return 'welcome'

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'
Comment on lines +96 to +116
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The renew resume branch is skipping required state and can renew the cert unnecessarily.

Two cases break here:

  1. As soon as completedSteps.renewPlan exists, resume jumps past renew-plan, so an interrupted run can bypass the confirmation/warning screen entirely.
  2. After apiKeyVerified, this always resumes at renew-revoking-cert when certificateCreated is unset, even for plans where cert.needsRenewal === false. That turns a profile-only renew into an unintended cert revoke/recreate on resume.

This branch needs to read the stored plan and use it to decide between renew-plan, renew-revoking-cert, and renew-creating-profiles.

Suggested direction
   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) {
-      return 'renew-revoking-cert'
-    }
+    const plan = JSON.parse(completedSteps.renewPlan) as { cert: { needsRenewal: boolean } }
+    if (!completedSteps.apiKeyVerified)
+      return 'renew-plan'
+    if (!completedSteps.certificateCreated)
+      return plan.cert.needsRenewal ? 'renew-revoking-cert' : 'renew-creating-profiles'
     if ((completedSteps.renewedProfiles?.length ?? 0) === 0)
       return 'renew-creating-profiles'
     return 'renew-creating-profiles'
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/build/onboarding/progress.ts` around lines 96 - 116, When resuming
renew flows, don't short-circuit the user confirmation or mis-handle cert-less
renewals: if completedSteps.renewPlan is missing, return the 'renew-plan' step
(not skip to analyzing), and when certificateCreated is false consult the stored
plan (completedSteps.renewPlan or its plan.cert.needsRenewal flag) to choose
between 'renew-revoking-cert' (when cert.needsRenewal === true) and
'renew-creating-profiles' (when cert.needsRenewal === false); update the logic
in the progress.mode === 'renew' branch (referencing progress,
completedSteps.renewPlan, and certificateCreated) to implement these checks and
return the correct step.

}

if (!completedSteps.apiKeyVerified) {
// Resume at the furthest partial input step
if (progress.issuerId && progress.keyId && progress.p8Path)
Expand Down
Loading
Loading