-
-
Notifications
You must be signed in to change notification settings - Fork 129
feat(cli): in-repo macOS ASC key helper + iOS onboarding p8 fork + stdout stats protocol #2493
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
e28991b
7341f59
ef606c9
2748e28
c1e2899
a4d576f
9f88156
2e7713f
9593a08
0186a3b
e640c34
607c3e2
762f6fc
801e104
d12bc4a
2e985c5
14795cf
0b6742e
98c1829
0fe1f87
e97d7e1
e7aac52
35c451d
cdf760a
443bdde
bd74b78
90a9ae2
6ee0453
cb9e3af
588e467
fd3f35f
2ca4163
d32ec2f
9a5719f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| #!/usr/bin/env bash | ||
| # | ||
| # Build the precompiled App Store Connect API-key helper (a native macOS Swift | ||
| # app) as a universal (arm64 + x86_64) release binary, ready to ship as a | ||
| # downloadable artifact for `@capgo/cli build credentials apple-key`. | ||
| # | ||
| # The CLI itself does NOT bundle this macOS-only binary in its npm tarball | ||
| # (that would bloat every Linux/Windows install). Instead it locates the binary | ||
| # at runtime via: | ||
| # 1. CAPGO_ASC_KEY_HELPER_PATH (dev / CI / this script's output) | ||
| # 2. ~/.capgo/asc-key-helper/capgo-asc-key-helper (cached download) | ||
| # | ||
| # Usage: | ||
| # scripts/build-asc-key-helper.sh <path-to-helper-swift-package> [out-dir] | ||
| # | ||
| # Example: | ||
| # scripts/build-asc-key-helper.sh ~/Developer/test-p8-extract dist-helper | ||
| # export CAPGO_ASC_KEY_HELPER_PATH="$PWD/dist-helper/capgo-asc-key-helper" | ||
| # | ||
| set -euo pipefail | ||
|
|
||
| if [[ "$(uname)" != "Darwin" ]]; then | ||
| echo "error: the ASC key helper can only be built on macOS." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| SRC_DIR="${1:-}" | ||
| OUT_DIR="${2:-dist-helper}" | ||
| PRODUCT_NAME="P8Extract" # SwiftPM executable product name | ||
| OUT_BINARY="capgo-asc-key-helper" # canonical name the CLI looks for | ||
|
|
||
| if [[ -z "$SRC_DIR" || ! -f "$SRC_DIR/Package.swift" ]]; then | ||
| echo "error: pass the helper Swift package dir (the folder with Package.swift)." >&2 | ||
| echo "usage: $0 <path-to-helper-swift-package> [out-dir]" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "› Building universal release binary from $SRC_DIR ..." | ||
| swift build \ | ||
| --package-path "$SRC_DIR" \ | ||
| -c release \ | ||
| --arch arm64 \ | ||
| --arch x86_64 | ||
|
|
||
| BUILT="$SRC_DIR/.build/apple/Products/Release/$PRODUCT_NAME" | ||
| if [[ ! -f "$BUILT" ]]; then | ||
| # Fall back to the single-arch path if a universal build wasn't produced. | ||
| BUILT="$(find "$SRC_DIR/.build" -maxdepth 3 -name "$PRODUCT_NAME" -type f -perm -111 | head -1)" | ||
| fi | ||
| if [[ -z "${BUILT:-}" || ! -f "$BUILT" ]]; then | ||
| echo "error: could not find built product '$PRODUCT_NAME' under $SRC_DIR/.build" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| mkdir -p "$OUT_DIR" | ||
| cp "$BUILT" "$OUT_DIR/$OUT_BINARY" | ||
| chmod +x "$OUT_DIR/$OUT_BINARY" | ||
|
|
||
| echo "› Architectures:" | ||
| lipo -info "$OUT_DIR/$OUT_BINARY" || true | ||
| echo "› SHA-256:" | ||
| shasum -a 256 "$OUT_DIR/$OUT_BINARY" | ||
|
|
||
| echo "" | ||
| echo "✅ Built $OUT_DIR/$OUT_BINARY" | ||
| echo " Try it: CAPGO_ASC_KEY_HELPER_PATH=\"$PWD/$OUT_DIR/$OUT_BINARY\" npx @capgo/cli build credentials apple-key" | ||
| echo "" | ||
| echo "ℹ️ For distribution, codesign + notarize this binary before publishing:" | ||
| echo " codesign --force --options runtime --sign \"Developer ID Application: …\" \"$OUT_DIR/$OUT_BINARY\"" | ||
| echo " xcrun notarytool submit … && xcrun stapler staple …" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| # App Store Connect key helper — stdout stats protocol | ||
|
|
||
| The `build credentials apple-key` command launches a native macOS helper (a | ||
| precompiled Swift app) that walks the user through creating an App Store Connect | ||
| **team** API key in an embedded browser, then captures the resulting credentials. | ||
|
Comment on lines
+3
to
+5
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the canonical Please render this command reference as As per coding guidelines: “For Capgo CLI references in customer-facing command examples, always use 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| While it runs, the helper streams a **stats protocol** on **stdout** so the CLI | ||
| can forward usage statistics to PostHog and receive the final credentials. This | ||
| document is the contract between the Swift helper (`StatsProtocol.swift`) and the | ||
| CLI (`protocol.ts` / `helper.ts`). | ||
|
|
||
| ## Wire format | ||
|
|
||
| Newline-delimited JSON ("NDJSON"). One JSON object per line on **stdout**. Every | ||
| line is tagged with `capgoAscKey` (the protocol version) so the reader can | ||
| ignore incidental stdout chatter. Human-readable diagnostics go to **stderr** | ||
| and are NOT part of this protocol. | ||
|
|
||
| ```jsonc | ||
| {"capgoAscKey":1,"kind":"event","ts":12,"runId":"<uuid>","name":"step_changed","props":{ }} | ||
| {"capgoAscKey":1,"kind":"result","ts":900,"runId":"<uuid>","ok":true,"keyId":"…","issuerId":"…","privateKey":"…"} | ||
| {"capgoAscKey":1,"kind":"result","ts":900,"runId":"<uuid>","ok":false,"errorCode":"USER_CANCELLED","message":"…"} | ||
| ``` | ||
|
|
||
| | Field | Lines | Meaning | | ||
| | ------------- | ------------ | ---------------------------------------------------- | | ||
| | `capgoAscKey` | all | Protocol version (currently `1`). | | ||
| | `kind` | all | `"event"` or `"result"`. | | ||
| | `ts` | all | Milliseconds since the helper started. | | ||
| | `runId` | all | UUID correlating every line of one run. | | ||
| | `name` | event | snake_case event name. | | ||
| | `props` | event | Non-sensitive properties (never the private key). | | ||
| | `ok` | result | `true` = credentials present; `false` = error. | | ||
| | `keyId`/`issuerId`/`privateKey` | result (ok) | The captured credentials. | | ||
| | `errorCode`/`message` | result (!ok)| Failure reason. | | ||
|
|
||
| ### Rules | ||
|
|
||
| - **`event` lines are forwarded to PostHog** (channel `app-store-connect-key`). | ||
| - The **terminal `result` line** carries the credentials on success. The | ||
| `privateKey` appears **only** here and is **never** forwarded to analytics. | ||
| As defence-in-depth, the CLI also strips any prop key matching | ||
| `private_key|secret|p8|pem|password|token` before sending tags. | ||
| - The reader tolerates non-protocol stdout lines (it skips anything without a | ||
| matching `capgoAscKey`), partial lines split across chunks, and a final | ||
| newline-less line. | ||
|
|
||
| ## Events | ||
|
|
||
| | `name` | `props` | Emitted when | | ||
| | --------------------- | ---------------------------------------------------- | ----------------------------------------- | | ||
| | `helper_started` | `protocol_version`, `os_version` | The helper window appears. | | ||
| | `signed_in` | `team_count` | First authenticated session read. | | ||
| | `team_confirmed` | `is_switch`, `team_count` | User confirms a team in the dialog. | | ||
| | `api_access_checked` | `enabled`, `role_ok` | Team API-access capability is determined. | | ||
| | `api_access_denied` | `reason` (`not_enabled` \| `insufficient_role`) | Team can't create a key. | | ||
| | `step_changed` | `from`, `to`, `elapsed_ms_on_prev` | The guided step advances (the funnel). | | ||
| | `validation_started` | — | The new key is validated against Apple. | | ||
| | `validation_succeeded`| `duration_ms` | Validation passed. | | ||
| | `validation_failed` | `duration_ms` | Validation failed. | | ||
| | `helper_finished` | `ok`, `outcome` (`created`\|`cancelled`), `total_ms` | Just before the helper exits. | | ||
|
|
||
| New events can be added without bumping the protocol version — the CLI forwards | ||
| any `event` line generically (humanized name + `prop_*` tags). Bump | ||
| `ASC_PROTOCOL_VERSION` only for breaking envelope changes. | ||
|
|
||
| ## Distribution of the precompiled helper | ||
|
|
||
| The macOS-only binary is **not** bundled in the npm tarball. The CLI locates it | ||
| at runtime: | ||
|
|
||
| 1. `CAPGO_ASC_KEY_HELPER_PATH` — explicit override (dev / CI). | ||
| 2. `~/.capgo/asc-key-helper/capgo-asc-key-helper` — cached download. | ||
|
|
||
| Build a universal binary from the helper Swift package with | ||
| `scripts/build-asc-key-helper.sh <helper-src-dir>`. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import { Buffer } from 'node:buffer' | ||
| import { homedir } from 'node:os' | ||
| import { join } from 'node:path' | ||
| import { exit, platform, stdout } from 'node:process' | ||
| import { intro, log, outro } from '@clack/prompts' | ||
| import { flushAnalytics, trackEvent } from '../../../analytics/track' | ||
| import { checkAlerts } from '../../../api/update' | ||
| import { updateSavedCredentials } from '../../credentials' | ||
| import { isMacOS, NotMacOSError, resolveHelperBinary, runAscKeyHelper } from './helper' | ||
| import { ASC_KEY_CHANNEL } from './protocol' | ||
|
Comment on lines
+1
to
+11
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Use shared Line 121 currently formats errors manually; this bypasses the CLI’s shared user-visible error formatter. As per coding guidelines, “For user-visible error messages, format errors with ♻️ Proposed change import { appendInternalLog, getInternalLogPath, startInternalLog } from '../../../support/internal-log'
+import { formatError } from '../../../utils'
import { isMacOS, NotMacOSError, resolveHelperBinary, runAscKeyHelper } from './helper'
import { ASC_KEY_CHANNEL } from './protocol'
@@
catch (error) {
if (error instanceof NotMacOSError)
log.error(error.message)
else
- log.error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`)
+ log.error(`Unexpected error: ${formatError(error)}`)
await flushAnalytics()
exit(1)
}Also applies to: 117-122 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| export interface CreateAppleKeyOptions { | ||
| apikey?: string | ||
| /** When set, the captured key is saved into this app's iOS build credentials. */ | ||
| appId?: string | ||
| /** Save into the per-project .capgo-credentials.json instead of the global file. */ | ||
| local?: boolean | ||
| /** Print the captured Key ID / Issuer ID / .p8 path as JSON on stdout. */ | ||
| json?: boolean | ||
| } | ||
|
|
||
| /** | ||
| * Guided creation of an App Store Connect **team** API key. Launches the native | ||
| * macOS helper (a precompiled Swift app that walks the user through Apple's web | ||
| * UI in an embedded browser), streams its stats protocol to PostHog, and | ||
| * captures the resulting key — issuer id, key id and the one-time .p8 — without | ||
| * the user ever copy-pasting a credential. | ||
| */ | ||
| export async function createAppleKeyCommand(options: CreateAppleKeyOptions = {}): Promise<void> { | ||
| await checkAlerts() | ||
| intro('App Store Connect API Key 🔑') | ||
|
|
||
| if (!isMacOS()) { | ||
| log.error('This guided flow needs the macOS helper app and only runs on macOS.') | ||
| log.info('On other platforms, create the key manually at https://appstoreconnect.apple.com/access/integrations/api ' | ||
| + 'then save it with `npx @capgo/cli build credentials save --platform ios --apple-key <AuthKey.p8> --apple-key-id <id> --apple-issuer-id <id>`.') | ||
| void trackEvent({ channel: ASC_KEY_CHANNEL, event: 'ASC Key: Unsupported Platform', icon: '🔑', apikey: options.apikey, tags: { os_platform: platform } }) | ||
| await flushAnalytics() | ||
| exit(1) | ||
| } | ||
|
|
||
| if (!resolveHelperBinary()) { | ||
| log.error('Could not find the App Store Connect key helper binary.') | ||
| log.info('Set CAPGO_ASC_KEY_HELPER_PATH to a compiled helper, or upgrade to a CLI release that bundles it.') | ||
| void trackEvent({ channel: ASC_KEY_CHANNEL, event: 'ASC Key: Helper Missing', icon: '🔑', apikey: options.apikey }) | ||
|
Comment on lines
+44
to
+46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Align helper-missing remediation text with the actual discovery model. Line 45 tells users to “upgrade to a CLI release that bundles it,” but this feature’s rollout states the helper is discovered via env/cache/dev build and not bundled in npm tarballs. That guidance can send users to a dead end. 💡 Suggested wording update- log.info('Set CAPGO_ASC_KEY_HELPER_PATH to a compiled helper, or upgrade to a CLI release that bundles it.')
+ log.info('Set CAPGO_ASC_KEY_HELPER_PATH to a compiled helper, ensure the ~/.capgo/asc-key-helper cache is populated, or run from a local dev checkout with the helper built.')🤖 Prompt for AI Agents |
||
| await flushAnalytics() | ||
| exit(1) | ||
| } | ||
|
|
||
| log.step('Opening the guided helper… complete the steps in the window that appears.') | ||
|
|
||
| try { | ||
| const outcome = await runAscKeyHelper({ | ||
| apikey: options.apikey, | ||
| onEvent: (event) => { | ||
| // Surface a few high-signal milestones in the terminal as they happen. | ||
| if (event.name === 'signed_in') | ||
| log.info('Signed in to App Store Connect.') | ||
| else if (event.name === 'api_access_denied') | ||
| log.warn(`API access unavailable for this team (${String(event.props.reason ?? 'unknown')}).`) | ||
| else if (event.name === 'validation_started') | ||
| log.step('Validating the new key with Apple…') | ||
| }, | ||
| }) | ||
|
|
||
| if (!outcome.ok) { | ||
| if (outcome.errorCode === 'USER_CANCELLED') | ||
| log.warn('Cancelled — no key was created.') | ||
| else | ||
| log.error(`Key creation failed (${outcome.errorCode}): ${outcome.message}`) | ||
| await flushAnalytics() | ||
| exit(1) | ||
| } | ||
|
|
||
| const { credentials, eventCount } = outcome | ||
| const p8Path = join(homedir(), '.appstoreconnect', 'private_keys', `AuthKey_${credentials.keyId}.p8`) | ||
|
|
||
| log.success('App Store Connect API key created and validated.') | ||
| log.info(`Key ID: ${credentials.keyId}`) | ||
| log.info(`Issuer ID: ${credentials.issuerId}`) | ||
| log.info(`Saved .p8: ${p8Path}`) | ||
|
|
||
| let savedToAppId: string | undefined | ||
| if (options.appId) { | ||
| const appleKeyContent = Buffer.from(credentials.privateKey, 'utf-8').toString('base64') | ||
| await updateSavedCredentials(options.appId, 'ios', { | ||
| APPLE_KEY_ID: credentials.keyId, | ||
| APPLE_ISSUER_ID: credentials.issuerId, | ||
| APPLE_KEY_CONTENT: appleKeyContent, | ||
| }, options.local) | ||
| savedToAppId = options.appId | ||
| log.success(`Saved to ${options.local ? 'local' : 'global'} build credentials for ${options.appId}.`) | ||
| } | ||
| else { | ||
| log.info('Save it into your build credentials with:') | ||
| log.info(` npx @capgo/cli build credentials save --platform ios --apple-key "${p8Path}" --apple-key-id "${credentials.keyId}" --apple-issuer-id "${credentials.issuerId}" --appId <your-app-id>`) | ||
| } | ||
|
|
||
| if (options.json) { | ||
| // Deliberately excludes the private key — it's on disk at p8Path. | ||
| stdout.write(`${JSON.stringify({ keyId: credentials.keyId, issuerId: credentials.issuerId, p8Path, savedToAppId, eventCount })}\n`) | ||
|
Comment on lines
+109
to
+111
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| await flushAnalytics() | ||
| outro('Done 🎉') | ||
| } | ||
| catch (error) { | ||
| if (error instanceof NotMacOSError) | ||
| log.error(error.message) | ||
| else | ||
| log.error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`) | ||
| await flushAnalytics() | ||
| exit(1) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't copy an arbitrary
.buildexecutable.If the expected release binary is missing,
find ... | head -1can pick up a stale debug or single-arch artifact from a previous build. That makes the published helper nondeterministic.♻️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents