diff --git a/cli/README.md b/cli/README.md
index e67a892bea..2c20c79d16 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -98,6 +98,19 @@ The most complete [documentation is here](https://capgo.app/docs/).
Join the [discord](https://discord.gg/VnYRvBfgA6) to get help.
+
+## Dynamic monorepos
+
+When a root `capacitor.config.ts` selects an app-specific source through an environment variable, keep that selector active and pass the source to Capgo:
+
+```bash
+CAP_APP=qr-code-reader npx @capgo/cli@latest init \
+ --package-json ./package.json \
+ --main-file ./projects/qr-code-reader/src/main.ts \
+ --capacitor-config ./env-configs/capacitor.config.qr-code-reader.ts
+```
+
+Capgo continues to load the root config while writing only the selected source. Use `--capacitor-config` on every config-changing CLI command, or the matching `capacitorConfig` SDK/MCP option.
## ๐ Capgo CLI Commands
@@ -199,6 +212,9 @@ npx @capgo/cli@latest init YOUR_API_KEY com.example.app
| **-i** | string | App icon path for display in Capgo Cloud |
| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) |
| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) |
+| **--package-json** | string | Package JSON for the Capacitor app to onboard (useful in monorepos) |
+| **--main-file** | string | Application entry file to update (useful in monorepos) |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
| **--no-analytics** | boolean | Disable init analytics and terminal replay for this run |
@@ -405,7 +421,8 @@ npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel prod
| **--delta-only** | boolean | Upload only delta updates without full bundle for maximum speed (useful for large apps) |
| **--no-delta** | boolean | Disable delta updates even if instant updates are enabled |
| **--encrypted-checksum** | string | An encrypted checksum (signature). Used only when uploading an external bundle. |
-| **--auto-set-bundle** | boolean | Set the bundle in capacitor.config.json |
+| **--auto-set-bundle** | boolean | Set the bundle version in Capacitor config |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
| **--dry-upload** | boolean | Dry upload the bundle process: add the row in database without uploading files or updating channels (Used by Capgo for internal testing) |
| **--package-json** | string | Paths to package.json files for monorepos (comma-separated) |
| **--node-modules** | string | Paths to node_modules directories for monorepos (comma-separated) |
@@ -747,6 +764,7 @@ npx @capgo/cli@latest app setting plugins.CapacitorUpdater.defaultChannel --stri
| -------------- | ------------- | -------------------- |
| **--bool** | string | A value for the setting to modify as a boolean, ex: --bool true |
| **--string** | string | A value for the setting to modify as a string, ex: --string "Production" |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
### โ๏ธ **Set**
@@ -990,6 +1008,7 @@ npx @capgo/cli@latest key save --key ./path/to/key.pub
| **-f** | boolean | Force generate a new one |
| **--key** | string | Key path to save in Capacitor config |
| **--key-data** | string | Key data to save in Capacitor config |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
### ๐จ **Create**
@@ -1013,6 +1032,7 @@ npx @capgo/cli@latest key create
| Param | Type | Description |
| -------------- | ------------- | -------------------- |
| **-f** | boolean | Force generate a new one |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
### ๐๏ธ **Delete_old**
@@ -1028,6 +1048,12 @@ npx @capgo/cli@latest key delete_old
npx @capgo/cli@latest key delete_old
```
+**Options:**
+
+| Param | Type | Description |
+| -------------- | ------------- | -------------------- |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
+
## ๐ค **Account**
@@ -1789,6 +1815,7 @@ npx @capgo/cli@latest notifications setup com.example.app
| **--force** | boolean | Overwrite the helper file if it already exists |
| **--no-install** | boolean | Skip installing the notifications package |
| **--no-sync** | boolean | Skip Capacitor sync |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
## ๐น **Probe**
@@ -1823,7 +1850,7 @@ npx @capgo/cli@latest mcp
๐ค Start the Capgo MCP (Model Context Protocol) server for AI agent integration.
This command starts an MCP server that exposes Capgo functionality as tools for AI agents.
The server communicates via stdio and is designed for non-interactive, programmatic use.
-Available tools exposed via MCP:
+Selected tools exposed via MCP:
- capgo_list_apps, capgo_add_app, capgo_update_app, capgo_delete_app
- capgo_upload_bundle, capgo_list_bundles, capgo_delete_bundle, capgo_cleanup_bundles
- capgo_list_channels, capgo_add_channel, capgo_update_channel, capgo_delete_channel
@@ -1839,7 +1866,7 @@ Example usage with Claude Desktop:
"mcpServers": {
"capgo": {
"command": "npx",
- "args": ["@capgo/cli", "mcp"]
+ "args": ["@capgo/cli@latest", "mcp"]
}
}
}
@@ -1847,9 +1874,15 @@ Example usage with Claude Desktop:
**Example:**
```bash
-npx @capgo/cli mcp
+npx @capgo/cli@latest mcp
```
+## Options (Mcp)
+
+| Param | Type | Description |
+| -------------- | ------------- | -------------------- |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
+
diff --git a/cli/package.json b/cli/package.json
index b7080d0f72..f57cad81b5 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -112,6 +112,8 @@
"test:init-app-conflict": "bun test/test-init-app-conflict.mjs",
"test:init-guardrails": "bun test/test-init-guardrails.mjs",
"test:init-replay": "bun test/test-init-replay.mjs",
+ "test:capacitor-config-target": "bun test/test-capacitor-config-target.mjs",
+ "test:init-monorepo-targeting": "bun run test:capacitor-config-target && bun test/test-init-monorepo-targeting.mjs",
"test:prompt-preferences": "bun test/test-prompt-preferences.mjs",
"test:esm-sdk": "node test/test-sdk-esm.mjs",
"test:auth-session": "bun test/test-auth-session.mjs",
@@ -158,7 +160,7 @@
"test:ios-marketing-version": "bun test/test-ios-marketing-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:functional && bun run test:semver && 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:credentials && 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: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: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:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && 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-app-conflict && bun run test:init-guardrails && bun run test:init-replay && 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: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:organization-set-api-host",
+ "test": "bun run build && bun run test:helper-dce && 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:fail-on-incompatible && bun run test:native-dependencies && bun run test:credentials && 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: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: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:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && 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:init-guardrails && bun run test:init-replay && 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: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:organization-set-api-host",
"test:build-platform-selection": "bun test/test-build-platform-selection.mjs",
"test:ai-log-capture": "bun test/test-ai-log-capture.mjs",
"test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs",
diff --git a/cli/skills/release-management/SKILL.md b/cli/skills/release-management/SKILL.md
index ee645f73ee..ccd5a3fd4c 100644
--- a/cli/skills/release-management/SKILL.md
+++ b/cli/skills/release-management/SKILL.md
@@ -13,6 +13,7 @@ Use this skill for OTA update workflows in Capgo Cloud.
- `appId` can often be inferred from the current Capacitor project.
- Shared public flags often include `-a, --apikey`.
- Preview QR workflows require app preview to be enabled before the QR code can be printed.
+- `--capacitor-config ` is global. With a dynamic root config selector such as `CAP_APP`, it keeps loading the root config and writes config changes to the selected app-specific source.
## Preview QR workflows
diff --git a/cli/skills/usage/SKILL.md b/cli/skills/usage/SKILL.md
index a814816324..42f1d2ca08 100644
--- a/cli/skills/usage/SKILL.md
+++ b/cli/skills/usage/SKILL.md
@@ -19,6 +19,7 @@ TanStack Intent skills should stay focused and under the validator line limit, s
- Prefer `npx @capgo/cli@latest ...` in user-facing examples in this repo.
- Many commands can infer `appId` and related config from the current Capacitor project.
- Shared public flags commonly include `-a, --apikey ` and `--verbose` on commands that support verbose output.
+- `--capacitor-config ` is a global option for dynamic monorepos: Capacitor still loads the active root config, while config-writing commands update the selected app-specific source file. On `mcp`, the target remains active for the server lifetime so config-writing MCP tools use the same source.
## Use this skill for quick routing
@@ -42,7 +43,7 @@ TanStack Intent skills should stay focused and under the validator line limit, s
### Docs and agent integrations
-- `mcp`: start the Capgo MCP server for AI-agent integrations.
+- `mcp`: start the Capgo MCP server for AI-agent integrations; pass `--capacitor-config ` when its config-writing tools should target an app-specific source.
### GitHub support commands
diff --git a/cli/src/app/setting.ts b/cli/src/app/setting.ts
index 8c2ca05429..da23a95200 100644
--- a/cli/src/app/setting.ts
+++ b/cli/src/app/setting.ts
@@ -1,7 +1,7 @@
import type { AppSettingOptions } from '../schemas/app'
import { intro, log, outro } from '@clack/prompts'
import { writeConfigUpdater } from '../config'
-import { formatError, getConfig } from '../utils'
+import { formatError, getConfigForWrite } from '../utils'
export async function setSettingInternal(setting: string, options: AppSettingOptions, silent = false) {
if (!silent)
@@ -26,7 +26,7 @@ export async function setSettingInternal(setting: string, options: AppSettingOpt
}
try {
- const config = await getConfig()
+ const config = await getConfigForWrite()
let baseObj = config.config as any
const pathElements = setting.split('.')
diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts
index c6cc825171..42b0d85afd 100644
--- a/cli/src/bundle/upload.ts
+++ b/cli/src/bundle/upload.ts
@@ -1120,7 +1120,7 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
if (options.autoSetBundle) {
await updateConfigUpdater({ version: bundle })
if (options.verbose)
- log.info(`[Verbose] Auto-set bundle version in capacitor.config.json`)
+ log.info(`[Verbose] Auto-set bundle version in ${extConfig.path}`)
}
checkNotifyAppReady(options, path)
diff --git a/cli/src/capacitor-cli.ts b/cli/src/capacitor-cli.ts
index 8aefa584cf..bf3a537ef3 100644
--- a/cli/src/capacitor-cli.ts
+++ b/cli/src/capacitor-cli.ts
@@ -13,6 +13,8 @@ import type { CapacitorConfig } from './schemas/config'
import { loadConfig as loadConfigUntyped, writeConfig as writeConfigUntyped } from '@capacitor/cli/dist/config'
// @ts-expect-error `@capacitor/cli/dist/util/monorepotools` ships no type declarations
import { findMonorepoRoot as findMonorepoRootUntyped, findNXMonorepoRoot as findNXMonorepoRootUntyped, isMonorepo as isMonorepoUntyped, isNXMonorepo as isNXMonorepoUntyped } from '@capacitor/cli/dist/util/monorepotools'
+// @ts-expect-error `@capacitor/cli/dist/util/node` ships no type declarations
+import { requireTS as requireTSUntyped } from '@capacitor/cli/dist/util/node'
export interface CapacitorCliConfig {
app: {
@@ -21,6 +23,7 @@ export interface CapacitorCliConfig {
}
}
+export const requireTS: (typescript: unknown, filePath: string) => Record = requireTSUntyped
export const loadConfig: () => Promise = loadConfigUntyped
export const writeConfig: (extConfig: CapacitorConfig, extConfigFilePath: string) => Promise = writeConfigUntyped
export const findMonorepoRoot: (currentPath: string) => string = findMonorepoRootUntyped
diff --git a/cli/src/config/index.ts b/cli/src/config/index.ts
index e10e89d7ac..8fd2de1a96 100644
--- a/cli/src/config/index.ts
+++ b/cli/src/config/index.ts
@@ -1,20 +1,98 @@
-import type { ExtConfigPairs } from '../schemas/config'
-import { loadConfig as loadConfigCap, writeConfig as writeConfigCap } from '../capacitor-cli'
+import { AsyncLocalStorage } from 'node:async_hooks'
+import { existsSync, realpathSync, statSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path'
+import { cwd } from 'node:process'
+import type { CapacitorConfig, ExtConfigPairs } from '../schemas/config'
+import { loadConfig as loadConfigCap, requireTS, writeConfig as writeConfigCap } from '../capacitor-cli'
export type { CapacitorConfig, ExtConfigPairs } from '../schemas/config'
+let configWriteTarget: string | undefined
+const configWriteTargetStore = new AsyncLocalStorage<{ filePath: string | undefined }>()
+const capacitorConfigFilePattern = /^capacitor\.config(?:\.[^.]+)*\.(?:ts|json)$/
+
+/**
+ * Overrides the config file Capacitor writes after loading the active root config.
+ * This lets dynamic monorepos keep their root loader while Capgo updates the
+ * selected app-specific source config.
+ */
+export function setConfigWriteTarget(filePath?: string): void {
+ configWriteTarget = filePath
+}
+
+export function getConfigWriteTarget(): string | undefined {
+ const scopedTarget = configWriteTargetStore.getStore()
+ return scopedTarget === undefined ? configWriteTarget : scopedTarget.filePath
+}
+
+/**
+ * Uses a request-local config target so concurrent MCP tool calls cannot
+ * redirect one another's writes while awaiting async work.
+ */
+export function withConfigWriteTarget(filePath: string | undefined, action: () => T): T {
+ return configWriteTargetStore.run({ filePath }, action)
+}
+
+export function resolveCapacitorConfigTargetPath(value: string | undefined, initialCwd = cwd()): string | undefined {
+ if (value === undefined)
+ return undefined
+ if (!value.trim())
+ throw new Error('Capacitor config path must not be empty')
+
+ const resolved = resolve(initialCwd, value)
+ if (!existsSync(resolved) || !statSync(resolved).isFile())
+ throw new Error(`Capacitor config path does not exist: ${resolved}`)
+ if (!capacitorConfigFilePattern.test(basename(resolved)))
+ throw new Error(`Capacitor config path must point to a capacitor.config.*.ts or capacitor.config.*.json file: ${resolved}`)
+
+ const workspaceRoot = realpathSync(initialCwd)
+ const target = realpathSync(resolved)
+ const pathFromWorkspace = relative(workspaceRoot, target)
+ if (pathFromWorkspace === '..' || pathFromWorkspace.startsWith(`..${sep}`) || isAbsolute(pathFromWorkspace))
+ throw new Error(`Capacitor config path must stay within the current working directory: ${resolved}`)
+ return target
+}
+
+async function loadConfigTarget(filePath: string): Promise {
+ if (extname(filePath) === '.json')
+ return JSON.parse(await readFile(filePath, 'utf8')) as CapacitorConfig
+
+ const configModule = requireTS(createRequire(filePath)('typescript'), filePath)
+ const exportedConfig = configModule.default ?? configModule
+ return (typeof exportedConfig === 'function' ? await exportedConfig() : await exportedConfig) as CapacitorConfig
+}
+
export async function loadConfig(): Promise {
const config = await loadConfigCap()
return {
config: config.app.extConfig,
- path: config.app.extConfigFilePath,
+ path: getConfigWriteTarget() ?? config.app.extConfigFilePath,
+ }
+}
+
+/**
+ * Loads the source file that will receive a config update. Normal reads must
+ * continue through Capacitor's root loader so dynamic monorepos keep working.
+ */
+export async function loadConfigForWrite(): Promise {
+ const configTarget = getConfigWriteTarget()
+ if (configTarget) {
+ return {
+ config: await loadConfigTarget(configTarget),
+ path: configTarget,
+ }
}
+ return loadConfig()
}
export async function writeConfig(key: string, config: ExtConfigPairs, raw = false): Promise {
- const oldConfig = await loadConfigCap()
+ const oldConfig = await loadConfigForWrite()
+ if (!oldConfig)
+ return
- let { extConfig } = oldConfig.app
+ let { config: extConfig } = oldConfig
if (extConfig) {
if (!extConfig.plugins) {
extConfig.plugins = {
@@ -29,7 +107,7 @@ export async function writeConfig(key: string, config: ExtConfigPairs, raw = fal
extConfig.plugins[key] = config.config.plugins?.[key]
else
extConfig = config.config
- writeConfigCap(extConfig, oldConfig.app.extConfigFilePath)
+ await writeConfigCap(extConfig, oldConfig.path)
}
}
diff --git a/cli/src/index.ts b/cli/src/index.ts
index f69dcc3560..75e95fc330 100644
--- a/cli/src/index.ts
+++ b/cli/src/index.ts
@@ -38,6 +38,7 @@ import { currentBundle } from './channel/currentBundle'
import { deleteChannel } from './channel/delete'
import { listChannels } from './channel/list'
import { setChannel } from './channel/set'
+import { getConfigWriteTarget, resolveCapacitorConfigTargetPath, setConfigWriteTarget } from './config'
import { generateDocs } from './docs'
import { defaultStarRepo } from './github'
import { starAllRepositoriesCommand, starRepositoryCommand } from './github-command'
@@ -62,6 +63,7 @@ const optionDescriptions = {
supaAnon: `Custom Supabase anon key (for self-hosting)`,
packageJson: `Paths to package.json files for monorepos (comma-separated)`,
nodeModules: `Paths to node_modules directories for monorepos (comma-separated)`,
+ capacitorConfig: `Capacitor config source to update (useful with dynamic monorepo configs)`,
verbose: `Enable verbose output with detailed logging`,
}
@@ -74,6 +76,7 @@ program
.name(pack.name)
.description(`๐ฆ Manage packages and bundle versions in Capgo Cloud`)
.version(pack.version, '-v, --version', `output the current version`)
+ .option('--capacitor-config ', optionDescriptions.capacitorConfig)
// Turn on client-side Supabase perf tracking for the CLI. (Off by default so
// the SDK bundle, which transitively imports createSupabaseClient, stays clean.)
@@ -82,13 +85,20 @@ enableSupabaseInstrumentation()
let currentCommandPath = 'unknown'
program.hook('preAction', (_thisCommand, actionCommand) => {
+ setConfigWriteTarget(resolveCapacitorConfigTargetPath(actionCommand.optsWithGlobals().capacitorConfig))
currentCommandPath = getCommandPath(actionCommand)
applyCommandAnalyticsOptOut(currentCommandPath, actionCommand.opts())
trackCommandInvoked(currentCommandPath, extractCommandContext(actionCommand))
})
program.hook('postAction', (_thisCommand, actionCommand) => {
- trackCommandSucceeded(getCommandPath(actionCommand))
+ try {
+ trackCommandSucceeded(getCommandPath(actionCommand))
+ }
+ finally {
+ if (getCommandPath(actionCommand) !== 'mcp')
+ setConfigWriteTarget()
+ }
})
program
@@ -106,6 +116,9 @@ Example: npx @capgo/cli@latest init YOUR_API_KEY com.example.app`)
.option('-i, --icon ', `App icon path for display in Capgo Cloud`)
.option('--supa-host ', optionDescriptions.supaHost)
.option('--supa-anon ', optionDescriptions.supaAnon)
+ .option('--package-json ', 'Package JSON for the Capacitor app to onboard (useful in monorepos)')
+ .option('--main-file ', 'Application entry file to update (useful in monorepos)')
+ .option('--capacitor-config ', optionDescriptions.capacitorConfig)
.option('--no-analytics', 'Disable init analytics and terminal replay for this run')
const run = program
@@ -251,7 +264,8 @@ Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --cha
.option('--delta-only', `Upload only delta updates without full bundle for maximum speed (useful for large apps)`)
.option('--no-delta', `Disable delta updates even if instant updates are enabled`)
.option('--encrypted-checksum ', `An encrypted checksum (signature). Used only when uploading an external bundle.`)
- .option('--auto-set-bundle', `Set the bundle in capacitor.config.json`)
+ .option('--auto-set-bundle', `Set the bundle version in Capacitor config`)
+ .option('--capacitor-config ', optionDescriptions.capacitorConfig)
.option('--dry-upload', `Dry upload the bundle process: add the row in database without uploading files or updating channels (Used by Capgo for internal testing)`)
.option('--package-json ', optionDescriptions.packageJson)
.option('--node-modules ', optionDescriptions.nodeModules)
@@ -447,6 +461,7 @@ Specify setting path (e.g., plugins.CapacitorUpdater.defaultChannel) with --stri
Example: npx @capgo/cli@latest app setting plugins.CapacitorUpdater.defaultChannel --string "Production"`)
.option('--bool ', `A value for the setting to modify as a boolean, ex: --bool true`)
.option('--string ', `A value for the setting to modify as a string, ex: --string "Production"`)
+ .option('--capacitor-config ', optionDescriptions.capacitorConfig)
.action(setSetting)
app
@@ -613,6 +628,7 @@ Example: npx @capgo/cli@latest key save --key ./path/to/key.pub`)
.option('-f, --force', `Force generate a new one`)
.option('--key ', `Key path to save in Capacitor config`)
.option('--key-data ', `Key data to save in Capacitor config`)
+ .option('--capacitor-config ', optionDescriptions.capacitorConfig)
key
.command('create')
@@ -625,6 +641,7 @@ NEVER commit the private key - store it securely!
Example: npx @capgo/cli@latest key create`)
.action(createKey)
.option('-f, --force', `Force generate a new one`)
+ .option('--capacitor-config ', optionDescriptions.capacitorConfig)
key
.command('delete_old')
@@ -632,6 +649,7 @@ key
Example: npx @capgo/cli@latest key delete_old`)
.action(deleteOldKey)
+ .option('--capacitor-config ', optionDescriptions.capacitorConfig)
const account = program
.command('account')
@@ -1209,6 +1227,7 @@ Example: npx @capgo/cli@latest notifications setup com.example.app`)
.option('--force', 'Overwrite the helper file if it already exists')
.option('--no-install', 'Skip installing the notifications package')
.option('--no-sync', 'Skip Capacitor sync')
+ .option('--capacitor-config ', optionDescriptions.capacitorConfig)
program
.command('probe')
@@ -1236,7 +1255,7 @@ program
This command starts an MCP server that exposes Capgo functionality as tools for AI agents.
The server communicates via stdio and is designed for non-interactive, programmatic use.
-Available tools exposed via MCP:
+Selected tools exposed via MCP:
- capgo_list_apps, capgo_add_app, capgo_update_app, capgo_delete_app
- capgo_upload_bundle, capgo_list_bundles, capgo_delete_bundle, capgo_cleanup_bundles
- capgo_list_channels, capgo_add_channel, capgo_update_channel, capgo_delete_channel
@@ -1253,14 +1272,15 @@ Example usage with Claude Desktop:
"mcpServers": {
"capgo": {
"command": "npx",
- "args": ["@capgo/cli", "mcp"]
+ "args": ["@capgo/cli@latest", "mcp"]
}
}
}
-Example: npx @capgo/cli mcp`)
+Example: npx @capgo/cli@latest mcp`)
+ .option('--capacitor-config ', optionDescriptions.capacitorConfig)
.action(async () => {
- await startMcpServer()
+ await startMcpServer(getConfigWriteTarget())
})
program.exitOverride()
diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts
index 4ccf40fb93..7bd3e3527e 100644
--- a/cli/src/init/command.ts
+++ b/cli/src/init/command.ts
@@ -19,7 +19,7 @@ import { canUseFilePicker, openPackageJsonPicker } from '../build/onboarding/fil
import { getPlatformDirFromCapacitorConfig } from '../build/platform-paths'
import { uploadBundleInternal } from '../bundle/upload'
import { addChannelInternal } from '../channel/add'
-import { writeConfigUpdater } from '../config'
+import { getConfigWriteTarget, resolveCapacitorConfigTargetPath, setConfigWriteTarget, writeConfigUpdater } from '../config'
import { getRepoStarStatus, isRepoStarredInSession, starAllRepositories, starRepository } from '../github'
import { createKeyInternal } from '../key'
import { doLoginExists, loginInternal } from '../login'
@@ -30,7 +30,7 @@ import { copyToClipboard, revealInFinder } from '../support/clipboard'
import { appendInternalLog, getInternalLogPath, startInternalLog } from '../support/internal-log'
import { showReplicationProgress } from '../replicationProgress'
import { formatRunnerCommand, splitRunnerCommand } from '../runner-command'
-import { consoleWebUrl, createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils'
+import { consoleWebUrl, createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getConfigForWrite, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils'
import { buildAppIdConflictSuggestions, isAppAlreadyExistsError } from './app-conflict'
import { cancel as pCancel, confirm as pConfirm, intro as pIntro, isCancel as pIsCancel, log as pLog, outro as pOutro, select as pSelect, spinner as pSpinner, text as pText } from './prompts'
import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime'
@@ -40,7 +40,17 @@ import { CAPGO_UPDATER_PACKAGE, getUpdaterInstallState } from './updater'
interface SuperOptions extends Options {
analytics?: boolean
+ capacitorConfig?: string
local: boolean
+ mainFile?: string
+ packageJson?: string
+}
+
+interface InitTargetPaths {
+ pathToPackageJson?: string
+ capacitorConfigPath?: string
+ configLoadDir?: string
+ mainFilePath?: string
}
export type RunDeviceCancelHandler = () => Promise
@@ -102,6 +112,9 @@ interface InitAutoTestChange {
displayPath: string
kind: InitAutoTestChangeKind
}
+let globalCapacitorConfigPath: string | undefined
+let globalConfigLoadDir: string | undefined
+let globalMainFilePath: string | undefined
let tmpObject: tmp.FileResult['name'] | undefined
let globalPathToPackageJson: string | undefined
@@ -112,6 +125,102 @@ let globalDelta = false
let globalCurrentVersion: string | undefined
let globalAppId: string | undefined
let globalSupaHost: string | undefined
+
+export function resolveInitTargetPath(value: string | undefined, label: string, initialCwd = cwd()): string | undefined {
+ if (!value)
+ return undefined
+
+ const resolved = path.resolve(initialCwd, value)
+ if (!existsSync(resolved) || !statSync(resolved).isFile())
+ throw new Error(`${label} does not exist: ${resolved}`)
+
+ const workspaceRoot = realpathSync(initialCwd)
+ const target = realpathSync(resolved)
+ const pathFromWorkspace = path.relative(workspaceRoot, target)
+ if (pathFromWorkspace === '..' || pathFromWorkspace.startsWith(`..${path.sep}`) || path.isAbsolute(pathFromWorkspace))
+ throw new Error(`${label} must stay within the current working directory: ${resolved}`)
+ return resolved
+}
+
+function resolveInitDirectoryPath(value: string | undefined, initialCwd: string): string | undefined {
+ if (!value)
+ return undefined
+
+ const resolved = path.resolve(initialCwd, value)
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory())
+ return undefined
+
+ const workspaceRoot = realpathSync(initialCwd)
+ const target = realpathSync(resolved)
+ const pathFromWorkspace = path.relative(workspaceRoot, target)
+ if (pathFromWorkspace === '..' || pathFromWorkspace.startsWith(`..${path.sep}`) || path.isAbsolute(pathFromWorkspace))
+ return undefined
+ return resolved
+}
+
+export function resolveResumedInitTargets(currentTargets: InitTargetPaths, savedTargets: Partial, initialCwd = cwd()): InitTargetPaths | undefined {
+ // An explicit config source identifies the app being onboarded. Never merge
+ // it with a checkpoint created for another app in the same workspace.
+ if (currentTargets.capacitorConfigPath) {
+ try {
+ const currentConfigPath = resolveCapacitorConfigTargetPath(currentTargets.capacitorConfigPath, initialCwd)
+ const savedConfigPath = savedTargets.capacitorConfigPath
+ ? resolveCapacitorConfigTargetPath(savedTargets.capacitorConfigPath, initialCwd)
+ : undefined
+ if (!currentConfigPath || currentConfigPath !== savedConfigPath)
+ return undefined
+ }
+ catch {
+ return undefined
+ }
+ }
+
+ const resumedTargets = { ...currentTargets }
+
+ if (!resumedTargets.pathToPackageJson && savedTargets.pathToPackageJson) {
+ try {
+ const packageJsonPath = resolveInitTargetPath(savedTargets.pathToPackageJson, 'Package JSON path', initialCwd)
+ if (!packageJsonPath)
+ return undefined
+ resumedTargets.pathToPackageJson = packageJsonPath
+ }
+ catch {
+ return undefined
+ }
+ }
+
+ if (!resumedTargets.capacitorConfigPath && savedTargets.capacitorConfigPath) {
+ try {
+ const capacitorConfigPath = resolveCapacitorConfigTargetPath(savedTargets.capacitorConfigPath, initialCwd)
+ const configLoadDir = resolveInitDirectoryPath(savedTargets.configLoadDir, initialCwd)
+ if (!capacitorConfigPath || !configLoadDir)
+ return undefined
+ resumedTargets.capacitorConfigPath = capacitorConfigPath
+ resumedTargets.configLoadDir = configLoadDir
+ }
+ catch {
+ return undefined
+ }
+ }
+
+ if (!resumedTargets.mainFilePath && savedTargets.mainFilePath) {
+ try {
+ const mainFilePath = resolveInitTargetPath(savedTargets.mainFilePath, 'Main file path', initialCwd)
+ if (!mainFilePath || !/\.[cm]?[jt]sx?$/.test(mainFilePath))
+ return undefined
+ resumedTargets.mainFilePath = mainFilePath
+ }
+ catch {
+ return undefined
+ }
+ }
+
+ return resumedTargets
+}
+
+function getInitConfigLoadDir(projectDir: string): string {
+ return globalCapacitorConfigPath ? globalConfigLoadDir ?? projectDir : projectDir
+}
let globalCodeDiff: InitCodeDiff | undefined
let globalEncryptionSummary: InitEncryptionSummary | undefined
let globalCurrentStepNumber = 0
@@ -1044,6 +1153,9 @@ function markStepDone(step: number, pathToPackageJson?: string, channelName?: st
orgName: globalOrgName,
appId: globalAppId,
pathToPackageJson: pathToPackageJson ?? globalPathToPackageJson,
+ capacitorConfigPath: globalCapacitorConfigPath,
+ configLoadDir: globalConfigLoadDir,
+ mainFilePath: globalMainFilePath,
channelName: channelName ?? globalChannelName,
platform: globalPlatform,
delta: globalDelta,
@@ -1073,13 +1185,30 @@ interface ResumeResult {
appId?: string
}
-async function tryResumeOnboarding(apikey: string): Promise {
+async function tryResumeOnboarding(apikey: string, initialTargets: InitTargetPaths, initialCwd: string): Promise {
try {
const rawData = readFileSync(getTmpObjectPath(), 'utf-8')
if (!rawData || rawData.length === 0)
return undefined
- const { step_done, orgId, orgName, appId: savedAppId, pathToPackageJson, nodeModulesPath, channelName, platform, delta, currentVersion, codeDiff, encryptionSummary, autoTestChange } = JSON.parse(rawData)
+ const {
+ step_done,
+ orgId,
+ orgName,
+ appId: savedAppId,
+ pathToPackageJson,
+ capacitorConfigPath,
+ configLoadDir,
+ mainFilePath,
+ nodeModulesPath,
+ channelName,
+ platform,
+ delta,
+ currentVersion,
+ codeDiff,
+ encryptionSummary,
+ autoTestChange,
+ } = JSON.parse(rawData)
if (!orgId || !step_done) {
pLog.warn('โ ๏ธ Found previous onboarding progress, but it was saved in an older format.')
pLog.info(' Starting fresh. Your previous progress cannot be resumed.')
@@ -1099,9 +1228,27 @@ async function tryResumeOnboarding(apikey: string): Promise 0) {
globalNodeModulesPath = nodeModulesPath
}
@@ -1433,7 +1580,7 @@ async function saveAppIdToCapacitorConfig(appId: string) {
*/
async function syncPendingAppIdToCapacitorConfig(appId: string) {
try {
- const extConfig = await getConfig()
+ const extConfig = await getConfigForWrite()
extConfig.config.appId = appId
extConfig.config.plugins ||= {}
extConfig.config.plugins.CapacitorUpdater = {
@@ -2447,7 +2594,7 @@ async function addUpdaterStep(orgId: string, apikey: string, appId: string) {
s.start(`Updating config file`)
delta = !!doDirectInstall
const projectDir = dirname(path)
- await withTemporaryCwd(projectDir, async () => {
+ await withTemporaryCwd(getInitConfigLoadDir(projectDir), async () => {
if (doDirectInstall) {
await updateConfigbyKey('SplashScreen', { launchAutoHide: false })
}
@@ -2479,7 +2626,7 @@ async function addCodeStep(orgId: string, apikey: string, appId: string) {
const projectDir = dirname(packageJsonPath)
const resolveProjectFilePath = (filePath: string) => path.isAbsolute(filePath) ? filePath : join(projectDir, filePath)
const projectType = await findProjectType({ quiet: true, packageJsonPath })
- if (projectType === 'nuxtjs-js' || projectType === 'nuxtjs-ts') {
+ if (!globalMainFilePath && (projectType === 'nuxtjs-js' || projectType === 'nuxtjs-ts')) {
// Nuxt.js specific logic
const nuxtDir = join(projectDir, 'plugins')
if (!existsSync(nuxtDir)) {
@@ -2536,11 +2683,11 @@ async function addCodeStep(orgId: string, apikey: string, appId: string) {
}
else {
// Handle other project types
- let mainFilePath: string | null = null
- if (projectType === 'unknown') {
+ let mainFilePath: string | null = globalMainFilePath ?? null
+ if (!mainFilePath && projectType === 'unknown') {
mainFilePath = await findMainFile(true, projectDir)
}
- else {
+ else if (!mainFilePath) {
const isTypeScript = projectType.endsWith('-ts')
const projectTypeMainFile = findMainFileForProjectType(projectType, isTypeScript, projectDir)
mainFilePath = projectTypeMainFile ? resolveProjectFilePath(projectTypeMainFile) : projectTypeMainFile
@@ -2776,14 +2923,8 @@ async function addEncryptionStep(orgId: string, apikey: string, appId: string) {
// setupChannel=false avoids a rogue clack confirm when an old private
// key is present in the config.
try {
- const previousCwd = cwd()
- try {
- chdir(projectDir)
- await createKeyInternal({ force: true, setupChannel: false }, true)
- }
- finally {
- chdir(previousCwd)
- }
+ const encryptionConfig = await withTemporaryCwd(getInitConfigLoadDir(projectDir), () => getConfigForWrite())
+ await withTemporaryCwd(projectDir, () => createKeyInternal({ force: true, setupChannel: false }, true, encryptionConfig))
// Intentionally stop without a success message: the persistent
// encryption summary panel renders on the next step and already shows
// the outcome. Passing a message here would push it into the rolling
@@ -4537,6 +4678,26 @@ async function maybeStarCapgoRepo(includeSkillsRepository = false, repository?:
}
export async function initApp(apikeyCommand: string, appId: string, options: SuperOptions) {
+ const initialCwd = cwd()
+ const packageJsonPath = resolveInitTargetPath(options.packageJson, 'Package JSON path', initialCwd)
+ const capacitorConfigPath = getConfigWriteTarget() ?? resolveCapacitorConfigTargetPath(options.capacitorConfig, initialCwd)
+ const mainFilePath = resolveInitTargetPath(options.mainFile, 'Main file path', initialCwd)
+ if (packageJsonPath && path.basename(packageJsonPath) !== PACKNAME)
+ throw new Error(`Package JSON path must point to ${PACKNAME}: ${packageJsonPath}`)
+ if (mainFilePath && !/\.[cm]?[jt]sx?$/.test(mainFilePath))
+ throw new Error(`Main file path must point to a JavaScript or TypeScript file: ${mainFilePath}`)
+
+ const initialTargets: InitTargetPaths = {
+ pathToPackageJson: packageJsonPath,
+ capacitorConfigPath,
+ configLoadDir: capacitorConfigPath ? initialCwd : undefined,
+ mainFilePath,
+ }
+ globalPathToPackageJson = initialTargets.pathToPackageJson
+ globalCapacitorConfigPath = initialTargets.capacitorConfigPath
+ globalConfigLoadDir = initialTargets.configLoadDir
+ globalMainFilePath = initialTargets.mainFilePath
+ setConfigWriteTarget(initialTargets.capacitorConfigPath)
globalSupaHost = options.supaHost // honor --supa-host for the support-logs upload
const pm = getPMAndCommand()
options.apikey = apikeyCommand
@@ -4562,12 +4723,13 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
}
}
- let resumed = await tryResumeOnboarding(options.apikey)
+ let resumed = await tryResumeOnboarding(options.apikey, initialTargets, initialCwd)
let stepToSkip = resumed?.stepDone ?? 0
await ensureGitRepoCleanBeforeInit(stepToSkip > 0 ? globalAutoTestChange : undefined)
- appId = await ensureWorkspaceReadyForInit(appId) ?? appId
+ const initialAppId = await ensureWorkspaceReadyForInit(appId) ?? appId
+ appId = initialAppId
let selectedPackageJsonPath = path.resolve(globalPathToPackageJson ?? join(findRoot(cwd()), PACKNAME))
let selectedProjectDir = dirname(selectedPackageJsonPath)
const versionStatus = await checkVersionStatus()
@@ -4576,27 +4738,32 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
}
let extConfig: Awaited> | undefined
- if (!options.supaAnon || !options.supaHost) {
- try {
- extConfig = await withTemporaryCwd(selectedProjectDir, () => getConfig())
- }
- catch {
- extConfig = undefined
+ const reloadSelectedProjectConfig = async () => {
+ selectedPackageJsonPath = path.resolve(globalPathToPackageJson ?? join(findRoot(cwd()), PACKNAME))
+ selectedProjectDir = dirname(selectedPackageJsonPath)
+ if (!options.supaAnon || !options.supaHost) {
+ try {
+ extConfig = await withTemporaryCwd(getInitConfigLoadDir(selectedProjectDir), () => getConfig())
+ }
+ catch {
+ extConfig = undefined
+ }
}
- }
- else {
- extConfig = await withTemporaryCwd(selectedProjectDir, () => updateConfigUpdater({
- statsUrl: `${options.supaHost}/functions/v1/stats`,
- channelUrl: `${options.supaHost}/functions/v1/channel_self`,
- updateUrl: `${options.supaHost}/functions/v1/updates`,
- localApiFiles: `${options.supaHost}/functions/v1`,
- localS3: true,
- localSupa: options.supaHost,
- localSupaAnon: options.supaAnon,
- }))
- }
+ else {
+ extConfig = await withTemporaryCwd(getInitConfigLoadDir(selectedProjectDir), () => updateConfigUpdater({
+ statsUrl: `${options.supaHost}/functions/v1/stats`,
+ channelUrl: `${options.supaHost}/functions/v1/channel_self`,
+ updateUrl: `${options.supaHost}/functions/v1/updates`,
+ localApiFiles: `${options.supaHost}/functions/v1`,
+ localS3: true,
+ localSupa: options.supaHost,
+ localSupaAnon: options.supaAnon,
+ }))
+ }
+ }
+ await reloadSelectedProjectConfig()
// Warn if this doesn't look like a Capacitor project
- const hasCapacitorConfig = capacitorConfigFiles.some(file => existsSync(join(selectedProjectDir, file)))
+ const hasCapacitorConfig = Boolean(globalCapacitorConfigPath) || capacitorConfigFiles.some(file => existsSync(join(selectedProjectDir, file)))
if (!hasCapacitorConfig) {
pLog.warn('โ ๏ธ No capacitor.config.* found in the selected project directory.')
pLog.info(` Capgo requires a Capacitor project. Selected project: ${selectedProjectDir}`)
@@ -4673,8 +4840,8 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
}
}
- const localConfig = await withTemporaryCwd(selectedProjectDir, () => getLocalConfig())
- appId = getAppId(appId, extConfig?.config)
+ let localConfig = await withTemporaryCwd(selectedProjectDir, () => getLocalConfig())
+ appId = getAppId(initialAppId, extConfig?.config)
appId ??= await askForAppId('Enter your appId:')
@@ -4694,16 +4861,11 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
const supabase = await createSupabaseClient(options.apikey, options.supaHost, options.supaAnon)
await resolveUserIdFromApiKey(supabase, options.apikey)
- // Whenever a resume is aborted (org no longer available, role lost, 2FA
- // required, lookup failed) we restart from step 0. Drop any diff that
- // `tryResumeOnboarding` restored so the freshly walked step 4 doesn't see
- // stale content from an earlier run, and delete the on-disk resume file so
- // a subsequent `capgo init` run won't re-offer the now-invalid resume
- // before `markStepDone()` has had a chance to overwrite it.
- const discardResumedState = () => {
+ // A failed remote checkpoint (organization access, role, or 2FA) restarts
+ // onboarding at step 0 using only the caller's original project targets.
+ const discardResumedState = async () => {
stepToSkip = 0
resumed = undefined
- globalPathToPackageJson = undefined
globalNodeModulesPath = undefined
globalChannelName = defaultChannel
globalPlatform = 'ios'
@@ -4718,6 +4880,14 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
setInitEncryptionSummary(undefined)
globalAutoTestChange = undefined
cleanupStepsDone()
+ globalPathToPackageJson = initialTargets.pathToPackageJson
+ globalCapacitorConfigPath = initialTargets.capacitorConfigPath
+ globalConfigLoadDir = initialTargets.configLoadDir
+ globalMainFilePath = initialTargets.mainFilePath
+ setConfigWriteTarget(initialTargets.capacitorConfigPath)
+ await reloadSelectedProjectConfig()
+ localConfig = await withTemporaryCwd(selectedProjectDir, () => getLocalConfig())
+ appId = getAppId(initialAppId, extConfig?.config)
}
let organization: Organization
@@ -4729,7 +4899,7 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
pLog.error(`Cannot verify organization access: ${orgError ? JSON.stringify(orgError) : 'no data returned'}`)
pLog.warn('Falling back to organization selection.')
organization = await selectOrganizationForInit(supabase, options.apikey)
- discardResumedState()
+ await discardResumedState()
}
else {
const savedOrg = allOrganizations.find(org => org.gid === resumedSnapshot.orgId)
@@ -4741,18 +4911,18 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
if (!savedOrg) {
pLog.warn(`Previously used organization "${resumedSnapshot.orgName}" is no longer available. Please select a new one.`)
organization = await selectOrganizationForInit(supabase, options.apikey)
- discardResumedState()
+ await discardResumedState()
}
else if (!hasCreateAppPermission) {
pLog.warn(`You no longer have permission to create an app in "${savedOrg.name}". Please select a different organization.`)
organization = await selectOrganizationForInit(supabase, options.apikey)
- discardResumedState()
+ await discardResumedState()
}
else if (blocked2fa) {
pLog.warn(`Organization "${savedOrg.name}" now requires 2FA. Enable it at ${consoleWebUrl('/settings/account')}`)
pLog.warn('Please select a different organization or enable 2FA and try again.')
organization = await selectOrganizationForInit(supabase, options.apikey)
- discardResumedState()
+ await discardResumedState()
}
else {
organization = savedOrg
@@ -4828,7 +4998,7 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup
selectedPackageJsonPath = path.resolve(globalPathToPackageJson ?? join(findRoot(cwd()), PACKNAME))
selectedProjectDir = dirname(selectedPackageJsonPath)
try {
- extConfig = await withTemporaryCwd(selectedProjectDir, () => getConfig())
+ extConfig = await withTemporaryCwd(getInitConfigLoadDir(selectedProjectDir), () => getConfig())
}
catch {
extConfig = undefined
diff --git a/cli/src/init/mcp/engine.ts b/cli/src/init/mcp/engine.ts
index 2edb7e7ef3..bc169cafe4 100644
--- a/cli/src/init/mcp/engine.ts
+++ b/cli/src/init/mcp/engine.ts
@@ -34,7 +34,7 @@ export interface EngineDeps {
buildProject: (appId: string, platform: Platform) => Promise<{ ok: boolean, error?: string }>
applyTestChange: (appId: string, baseVersion?: string) => Promise<{ ok: boolean, version?: string, error?: string }>
uploadBundle: (appId: string, opts: { channelName: string, version: string, delta?: boolean, encrypt?: boolean }) => Promise<{ ok: boolean, error?: string }>
- getRunDeviceCommand: (platform: Platform) => { command: string }
+ getRunDeviceCommand: (platform: Platform) => { command: string, cwd?: string }
getGitStatus: (cwd?: string) => GitRepoStatus
}
@@ -216,12 +216,9 @@ export async function decideAdvance(facts: LiveUpdateFacts, deps: EngineDeps, in
if (input.resumeChoice === 'restart') {
deps.clearProgress()
clearSession(appId)
- mergeSession(appId, { resumeResolved: true })
facts = { ...facts, progress: null }
}
- else {
- mergeSession(appId, { resumeResolved: true })
- }
+ mergeSession(appId, { resumeResolved: true })
}
if (input?.encryptionChoice)
@@ -311,11 +308,11 @@ async function decideAtStep(facts: LiveUpdateFacts, deps: EngineDeps, input?: Li
if (stepNumber === 8 && !session.deviceRunConfirmed) {
const platform = session.platform ?? progress?.platform ?? facts.platformsDetected[0] ?? 'ios'
- const { command } = deps.getRunDeviceCommand(platform)
+ const { command, cwd } = deps.getRunDeviceCommand(platform)
return baseResult(stepNumber, 'run-on-device', 'human_gate', `${title} โ run the app on a device or simulator.`, {
platform,
human: {
- instruction: `Run this in your terminal:\n\n${command}\n\nConfirm the baseline app launches, then continue.`,
+ instruction: `Run this in your terminal${cwd ? ' from the selected project directory' : ''}:\n\n${command}\n\nConfirm the baseline app launches, then continue.`,
resourceUri: 'https://capgo.app/docs/getting-started/onboarding/',
},
collect: [{ field: 'deviceRunConfirmed', desc: 'set true once the app is running on device/simulator' }],
@@ -324,7 +321,7 @@ async function decideAtStep(facts: LiveUpdateFacts, deps: EngineDeps, input?: Li
{ deviceRunConfirmed: true },
`${NEXT_STEP_TOOL}({ deviceRunConfirmed: true })`,
),
- context: { runCommand: command, platform },
+ context: { runCommand: command, ...(cwd ? { projectDir: cwd } : {}), platform },
})
}
@@ -529,8 +526,12 @@ async function drive(deps: EngineDeps, input?: LiveUpdateNextStepInput): Promise
export async function runStart(deps: EngineDeps): Promise {
const appId = await deps.getAppId()
- if (appId)
- mergeSession(appId, { resumeResolved: undefined })
+ if (appId) {
+ const stepDone = progressForApp(appId, deps.loadProgress())?.step_done ?? 0
+ // Fresh starts may create progress during this call; only an existing
+ // checkpoint needs a resume decision.
+ mergeSession(appId, { resumeResolved: stepDone === 0 })
+ }
return drive(deps)
}
diff --git a/cli/src/init/mcp/live-update-tools.ts b/cli/src/init/mcp/live-update-tools.ts
index 82139b2fcd..8266e73ddd 100644
--- a/cli/src/init/mcp/live-update-tools.ts
+++ b/cli/src/init/mcp/live-update-tools.ts
@@ -3,25 +3,29 @@ import process from 'node:process'
// src/init/mcp/live-update-tools.ts
import { existsSync } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
-import { dirname, join } from 'node:path'
+import { basename, dirname, join, resolve } from 'node:path'
import type { CapgoSDK } from '../../sdk.js'
-import type { LiveUpdateNextStepInput } from '../../schemas/live-update-onboarding.js'
-import { liveUpdateNextStepSchema } from '../../schemas/live-update-onboarding.js'
+import type { LiveUpdateNextStepInput, LiveUpdateStartInput } from '../../schemas/live-update-onboarding.js'
+import { liveUpdateNextStepSchema, liveUpdateStartSchema } from '../../schemas/live-update-onboarding.js'
+import { capacitorConfigOptionSchema } from '../../schemas/sdk.js'
+import { getConfigWriteTarget, resolveCapacitorConfigTargetPath, withConfigWriteTarget } from '../../config'
import { getPlatformDirFromCapacitorConfig } from '../../build/platform-paths.js'
+import { createKeyInternal } from '../../key.js'
import { isAppAlreadyExistsError } from '../app-conflict.js'
import {
applyInitAutoTestChange,
getGitRepoStatus,
getInitSuggestedOtaVersion,
getInitUpdaterPluginConfig,
+ resolveInitTargetPath,
} from '../command.js'
import { getUpdaterInstallState } from '../updater.js'
-import { findSavedKeySilent, findMainFile, findRoot, getAppId, getBundleVersion, getConfig, getPMAndCommand, PACKNAME, updateConfigUpdater } from '../../utils.js'
+import { baseKeyV2, findSavedKeySilent, findMainFile, findRoot, getAppId, getBundleVersion, getConfig, getPMAndCommand, PACKNAME, updateConfigUpdater } from '../../utils.js'
import { formatRunnerCommand } from '../../runner-command.js'
import { addChannelInternal } from '../../channel/add.js'
import { uploadBundleInternal } from '../../bundle/upload.js'
import { execSync, spawnSync } from 'node:child_process'
-import type { Platform } from './contract.js'
+import type { NextStepResult, Platform } from './contract.js'
import { renderResult } from './contract.js'
import type { EngineDeps } from './engine.js'
import { explainLiveUpdateOnboarding, runAdvance, runStart } from './engine.js'
@@ -45,13 +49,48 @@ const DEFAULT_CHANNEL = 'production'
const importInject = 'import { CapacitorUpdater } from \'@capgo/capacitor-updater\''
const codeInject = 'CapacitorUpdater.notifyAppReady()'
-function getRunDeviceCommandForPlatform(platform: Platform): { command: string } {
+export interface LiveUpdateProjectTarget {
+ packageJsonPath?: string
+ mainFilePath?: string
+}
+
+type LiveUpdateProjectTargetInput = Pick
+
+function hasProjectTarget(target: LiveUpdateProjectTarget | undefined): target is LiveUpdateProjectTarget {
+ return Boolean(target?.packageJsonPath || target?.mainFilePath)
+}
+
+export function resolveLiveUpdateProjectTarget(input: LiveUpdateProjectTargetInput, initialCwd = process.cwd()): LiveUpdateProjectTarget {
+ const packageJsonPath = resolveInitTargetPath(input.packageJson, 'Package JSON path', initialCwd)
+ if (packageJsonPath && basename(packageJsonPath) !== PACKNAME)
+ throw new Error(`Package JSON path must point to ${PACKNAME}: ${packageJsonPath}`)
+
+ const mainFilePath = resolveInitTargetPath(input.mainFile, 'Main file path', initialCwd)
+ if (mainFilePath && !/\.[cm]?[jt]sx?$/.test(mainFilePath))
+ throw new Error(`Main file path must point to a JavaScript or TypeScript file: ${mainFilePath}`)
+
+ return { packageJsonPath, mainFilePath }
+}
+
+function getRunDeviceCommandForPlatform(platform: Platform, projectDir: string, initialCwd: string): { command: string, cwd?: string } {
const pm = getPMAndCommand()
const args = ['cap', 'run', platform]
- return { command: formatRunnerCommand(pm.runner, args) }
+ const command = formatRunnerCommand(pm.runner, args)
+ return { command, ...(projectDir === initialCwd ? {} : { cwd: projectDir }) }
}
-export function buildDeps(sdk: CapgoSDK, cwd = process.cwd()): EngineDeps {
+export function buildDeps(
+ sdk: CapgoSDK,
+ cwd = process.cwd(),
+ getProjectTarget: () => LiveUpdateProjectTarget | undefined = () => undefined,
+): EngineDeps {
+ const getPackageJsonPath = () => getProjectTarget()?.packageJsonPath ?? join(findRoot(cwd), PACKNAME)
+ const getProjectDir = () => dirname(getPackageJsonPath())
+ const getMainFile = async () => getProjectTarget()?.mainFilePath ?? findMainFile(true, getProjectDir())
+ const getNodeModulesPath = () => {
+ const nodeModulesPath = join(findRoot(getProjectDir()), 'node_modules')
+ return existsSync(nodeModulesPath) ? nodeModulesPath : undefined
+ }
const getAppIdClosure = async (): Promise => {
try {
const ext = await getConfig(true)
@@ -70,11 +109,12 @@ export function buildDeps(sdk: CapgoSDK, cwd = process.cwd()): EngineDeps {
const out: Platform[] = []
try {
const ext = await getConfig(true)
+ const projectDir = getProjectDir()
const iosDir = getPlatformDirFromCapacitorConfig(ext?.config, 'ios')
const androidDir = getPlatformDirFromCapacitorConfig(ext?.config, 'android')
- if (existsSync(join(cwd, iosDir)))
+ if (existsSync(join(projectDir, iosDir)))
out.push('ios')
- if (existsSync(join(cwd, androidDir)))
+ if (existsSync(join(projectDir, androidDir)))
out.push('android')
}
catch {
@@ -108,8 +148,8 @@ export function buildDeps(sdk: CapgoSDK, cwd = process.cwd()): EngineDeps {
},
installUpdater: async (appId: string) => {
try {
- const packageJsonPath = join(findRoot(cwd), PACKNAME)
- const projectDir = dirname(packageJsonPath)
+ const packageJsonPath = getPackageJsonPath()
+ const projectDir = getProjectDir()
const installState = getUpdaterInstallState(packageJsonPath)
if (!installState.ready) {
const pm = getPMAndCommand()
@@ -128,9 +168,7 @@ export function buildDeps(sdk: CapgoSDK, cwd = process.cwd()): EngineDeps {
},
addIntegrationCode: async (_appId: string) => {
try {
- const packageJsonPath = join(findRoot(cwd), PACKNAME)
- const projectDir = dirname(packageJsonPath)
- const mainFile = await findMainFile(true, projectDir)
+ const mainFile = await getMainFile()
if (!mainFile)
return { ok: false as const, error: 'Could not find main entry file' }
let content = await readFile(mainFile, 'utf8')
@@ -153,9 +191,7 @@ export function buildDeps(sdk: CapgoSDK, cwd = process.cwd()): EngineDeps {
if (!enable)
return { ok: true as const, enabled: false }
try {
- const res = await sdk.generateEncryptionKeys({ force: false })
- if (!res.success)
- return { ok: false as const, enabled: false, error: res.error ?? 'Encryption key generation failed' }
+ await createKeyInternal({ force: false, keyDir: getProjectDir(), setupChannel: false }, true)
return { ok: true as const, enabled: true }
}
catch (error) {
@@ -165,7 +201,7 @@ export function buildDeps(sdk: CapgoSDK, cwd = process.cwd()): EngineDeps {
buildProject: async (_appId: string, platform: Platform) => {
try {
const pm = getPMAndCommand()
- const projectDir = findRoot(cwd)
+ const projectDir = getProjectDir()
execSync(`${pm.pm} run build`, { cwd: projectDir, stdio: 'pipe' })
execSync(formatRunnerCommand(pm.runner, ['cap', 'sync', platform]), { cwd: projectDir, stdio: 'pipe' })
return { ok: true as const }
@@ -176,9 +212,8 @@ export function buildDeps(sdk: CapgoSDK, cwd = process.cwd()): EngineDeps {
},
applyTestChange: async (_appId: string, baseVersion?: string) => {
try {
- const packageJsonPath = join(findRoot(cwd), PACKNAME)
- const projectDir = dirname(packageJsonPath)
- const mainFile = await findMainFile(true, projectDir)
+ const packageJsonPath = getPackageJsonPath()
+ const mainFile = await getMainFile()
if (!mainFile)
return { ok: false as const, error: 'Could not find main entry file for test change' }
const content = await readFile(mainFile, 'utf8')
@@ -194,6 +229,14 @@ export function buildDeps(sdk: CapgoSDK, cwd = process.cwd()): EngineDeps {
},
uploadBundle: async (appId: string, opts) => {
try {
+ const projectDir = getProjectDir()
+ const packageJsonPath = getPackageJsonPath()
+ const privateKeyPath = join(projectDir, baseKeyV2)
+ const encrypt = opts.encrypt === true
+ if (encrypt && !existsSync(privateKeyPath))
+ return { ok: false as const, error: `Cannot find private key ${privateKeyPath}` }
+
+ const webDir = (await getConfig(true))?.config.webDir
const apikey = findSavedKeySilent() ?? ''
await uploadBundleInternal(appId, {
apikey,
@@ -201,6 +244,11 @@ export function buildDeps(sdk: CapgoSDK, cwd = process.cwd()): EngineDeps {
channel: opts.channelName || DEFAULT_CHANNEL,
deltaOnly: opts.delta,
ignoreChecksumCheck: true,
+ key: encrypt ? undefined : false,
+ keyV2: encrypt ? privateKeyPath : undefined,
+ nodeModules: getNodeModulesPath(),
+ packageJson: packageJsonPath,
+ path: webDir ? resolve(projectDir, webDir) : undefined,
showReplicationProgress: false,
}, true)
return { ok: true as const }
@@ -209,21 +257,146 @@ export function buildDeps(sdk: CapgoSDK, cwd = process.cwd()): EngineDeps {
return { ok: false as const, error: error instanceof Error ? error.message : String(error) }
}
},
- getRunDeviceCommand: platform => getRunDeviceCommandForPlatform(platform),
- getGitStatus: startDir => getGitRepoStatus(startDir ?? cwd),
+
+ getRunDeviceCommand: platform => getRunDeviceCommandForPlatform(platform, getProjectDir(), cwd),
+ getGitStatus: startDir => getGitRepoStatus(startDir ?? getProjectDir()),
+ }
+}
+
+function addConfigTargetToResult(
+ result: NextStepResult,
+ configTarget: string | undefined,
+ projectTarget: LiveUpdateProjectTarget | undefined,
+): NextStepResult {
+ const targetArgs = {
+ ...(configTarget ? { capacitorConfig: configTarget } : {}),
+ ...(projectTarget?.packageJsonPath ? { packageJson: projectTarget.packageJsonPath } : {}),
+ ...(projectTarget?.mainFilePath ? { mainFile: projectTarget.mainFilePath } : {}),
+ }
+ const targetNames = Object.keys(targetArgs)
+ if (targetNames.length === 0)
+ return result
+
+ const context = { ...result.context, ...targetArgs }
+ if (!result.next)
+ return { ...result, context }
+
+ const withArgs = { ...result.next.with, ...targetArgs }
+ return {
+ ...result,
+ context,
+ next: {
+ ...result.next,
+ with: withArgs,
+ call: `${result.next.tool}(${JSON.stringify(withArgs)})`,
+ instruction: `${result.next.instruction} Include the same ${targetNames.join(', ')} from context.`,
+ },
}
}
export function registerLiveUpdateTools(server: McpLike, sdk: CapgoSDK, depsOverride?: EngineDeps): void {
- const deps = depsOverride ?? buildDeps(sdk)
+ const configTargetsByApp = new Map>()
+ const projectTargetsByConfig = new Map()
+ const deps = depsOverride ?? buildDeps(sdk, process.cwd(), () => {
+ const configTarget = getConfigWriteTarget()
+ return configTarget ? projectTargetsByConfig.get(configTarget) : undefined
+ })
+ const addConfigTarget = (appId: string, configTarget: string): void => {
+ const targets = configTargetsByApp.get(appId) ?? new Set()
+ targets.add(configTarget)
+ configTargetsByApp.set(appId, targets)
+ }
+ const removeConfigTarget = (appId: string, configTarget: string): void => {
+ const targets = configTargetsByApp.get(appId)
+ if (!targets)
+ return
+ targets.delete(configTarget)
+ if (targets.size === 0)
+ configTargetsByApp.delete(appId)
+ }
+ const getSessionProjectTarget = (
+ configTarget: string | undefined,
+ incomingTarget: LiveUpdateProjectTarget,
+ ): LiveUpdateProjectTarget | undefined => {
+ if (!configTarget)
+ return hasProjectTarget(incomingTarget) ? incomingTarget : undefined
+
+ const currentTarget = projectTargetsByConfig.get(configTarget)
+ if (!hasProjectTarget(incomingTarget))
+ return currentTarget
+ if (
+ (currentTarget?.packageJsonPath && incomingTarget.packageJsonPath && currentTarget.packageJsonPath !== incomingTarget.packageJsonPath)
+ || (currentTarget?.mainFilePath && incomingTarget.mainFilePath && currentTarget.mainFilePath !== incomingTarget.mainFilePath)
+ ) {
+ throw new Error('This onboarding already has packageJson or mainFile targets for its Capacitor config. Pass the same paths from context.')
+ }
+
+ const projectTarget = { ...currentTarget, ...incomingTarget }
+ projectTargetsByConfig.set(configTarget, projectTarget)
+ return projectTarget
+ }
+ const updateActiveConfigTarget = (appId: string | undefined, configTarget: string | undefined, result: NextStepResult): void => {
+ if (configTarget && result.kind === 'done')
+ projectTargetsByConfig.delete(configTarget)
+ if (!appId || !configTarget)
+ return
+ if (result.kind === 'done')
+ removeConfigTarget(appId, configTarget)
+ else
+ addConfigTarget(appId, configTarget)
+ }
+ const migrateLegacyProgress = (appId: string | undefined, configTarget: string): void => {
+ const legacyProgress = withConfigWriteTarget(undefined, () => deps.loadProgress())
+ if (!legacyProgress || (legacyProgress.appId && legacyProgress.appId !== appId))
+ return
+
+ const targetProgress = withConfigWriteTarget(configTarget, () => deps.loadProgress())
+ if (targetProgress)
+ return
+
+ withConfigWriteTarget(configTarget, () => deps.saveProgress(legacyProgress))
+ withConfigWriteTarget(undefined, () => deps.clearProgress())
+ }
+ const getSessionConfigTarget = async (capacitorConfig?: string): Promise => {
+ if (capacitorConfig !== undefined)
+ return resolveCapacitorConfigTargetPath(capacitorConfig, deps.cwd)
+
+ const appId = await deps.getAppId()
+ const targets = appId ? configTargetsByApp.get(appId) : undefined
+ if (targets && targets.size > 1) {
+ throw new Error('Multiple Capacitor config sources are active for this onboarding. Pass the same capacitorConfig path used to start this flow.')
+ }
+ return targets?.values().next().value ?? getConfigWriteTarget()
+ }
server.tool(
'start_capgo_live_update_onboarding',
'Start (or resume) the guided Capgo live-update (OTA) setup for this Capacitor project โ register the app, install the updater plugin, build, upload a test bundle, and confirm OTA delivery. ALWAYS call this FIRST when the user wants to set up or troubleshoot Capgo OTA / live updates. Do NOT configure Capgo yourself โ this tool conducts the flow.',
- {},
- async () => {
- const result = await runStart(deps)
- return { content: [{ type: 'text' as const, text: renderResult(result) }] }
+ liveUpdateStartSchema.shape,
+ async (args: LiveUpdateStartInput) => {
+ const requestedProjectTarget = resolveLiveUpdateProjectTarget(args, deps.cwd)
+ const requestedConfigTarget = args.capacitorConfig === undefined
+ ? getConfigWriteTarget()
+ : resolveCapacitorConfigTargetPath(args.capacitorConfig, deps.cwd)
+ const { appId, result, configTarget, projectTarget } = await withConfigWriteTarget(requestedConfigTarget, async () => {
+ let configTarget = requestedConfigTarget
+ if (!configTarget) {
+ try {
+ configTarget = (await getConfig(true)).path
+ }
+ catch {
+ // runStart returns the normal no-Capacitor-project response
+ }
+ }
+ const projectTarget = getSessionProjectTarget(configTarget, requestedProjectTarget)
+ const appId = await deps.getAppId()
+ if (configTarget)
+ migrateLegacyProgress(appId, configTarget)
+ const result = await withConfigWriteTarget(configTarget, () => runStart(deps))
+ return { appId, result, configTarget, projectTarget }
+ })
+ updateActiveConfigTarget(appId, configTarget, result)
+ return { content: [{ type: 'text' as const, text: renderResult(addConfigTargetToResult(result, configTarget, projectTarget)) }] }
},
)
@@ -232,8 +405,15 @@ export function registerLiveUpdateTools(server: McpLike, sdk: CapgoSDK, depsOver
'Advance the guided Capgo live-update onboarding by one step. Call ONLY as directed by the previous result\'s `next`. Pass the user\'s choice when the previous step asked for one.',
liveUpdateNextStepSchema.shape,
async (args: LiveUpdateNextStepInput) => {
- const result = await runAdvance(deps, args)
- return { content: [{ type: 'text' as const, text: renderResult(result) }] }
+ const { capacitorConfig, packageJson, mainFile, ...input } = args
+ const configTarget = await getSessionConfigTarget(capacitorConfig)
+ const projectTarget = getSessionProjectTarget(configTarget, resolveLiveUpdateProjectTarget({ packageJson, mainFile }, deps.cwd))
+ const { appId, result } = await withConfigWriteTarget(configTarget, async () => ({
+ appId: await deps.getAppId(),
+ result: await runAdvance(deps, input),
+ }))
+ updateActiveConfigTarget(appId, configTarget, result)
+ return { content: [{ type: 'text' as const, text: renderResult(addConfigTargetToResult(result, configTarget, projectTarget)) }] }
},
)
@@ -242,9 +422,12 @@ export function registerLiveUpdateTools(server: McpLike, sdk: CapgoSDK, depsOver
'Explain a Capgo live-update onboarding step in plain language โ call when the user is confused. Defaults to the CURRENT step; pass { state } for a specific one. Read-only; never advances the flow.',
{
state: z.string().optional().describe('Optional state name to explain (from a prior result state field).'),
+ capacitorConfig: capacitorConfigOptionSchema.describe('The same app-specific capacitor.config.* source used to start onboarding when more than one source is active for this app.'),
},
- async (args: { state?: string }) => {
- const text = await explainLiveUpdateOnboarding(deps, args)
+ async (args: { state?: string, capacitorConfig?: string }) => {
+ const { capacitorConfig, ...input } = args
+ const configTarget = await getSessionConfigTarget(capacitorConfig)
+ const text = await withConfigWriteTarget(configTarget, () => explainLiveUpdateOnboarding(deps, input))
return { content: [{ type: 'text' as const, text }] }
},
)
diff --git a/cli/src/init/mcp/progress.ts b/cli/src/init/mcp/progress.ts
index 90cd70fcf9..9b8d960112 100644
--- a/cli/src/init/mcp/progress.ts
+++ b/cli/src/init/mcp/progress.ts
@@ -1,7 +1,9 @@
// src/init/mcp/progress.ts
+import { createHash } from 'node:crypto'
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import tmp from 'tmp'
+import { getConfigWriteTarget } from '../../config'
export interface LiveUpdateProgress {
step_done: number
@@ -13,18 +15,17 @@ export interface LiveUpdateProgress {
encryptionEnabled?: boolean
}
-let tmpPath: string | undefined
-
-function ensureTmpPath(): string {
- if (tmpPath)
- return tmpPath
- tmpPath = join(tmp.tmpdir, 'capgocli-live-update-progress.json')
- return tmpPath
+function progressPath(): string {
+ const configTarget = getConfigWriteTarget()
+ const suffix = configTarget
+ ? `-${createHash('sha256').update(configTarget).digest('hex')}`
+ : ''
+ return join(tmp.tmpdir, `capgocli-live-update-progress${suffix}.json`)
}
export function loadLiveUpdateProgress(): LiveUpdateProgress | null {
try {
- const raw = readFileSync(ensureTmpPath(), 'utf8')
+ const raw = readFileSync(progressPath(), 'utf8')
if (!raw)
return null
const parsed = JSON.parse(raw) as LiveUpdateProgress
@@ -38,17 +39,14 @@ export function loadLiveUpdateProgress(): LiveUpdateProgress | null {
}
export function saveLiveUpdateProgress(data: LiveUpdateProgress): void {
- writeFileSync(ensureTmpPath(), JSON.stringify(data))
+ writeFileSync(progressPath(), JSON.stringify(data))
}
export function clearLiveUpdateProgress(): void {
- if (!tmpPath)
- return
try {
- rmSync(tmpPath)
+ rmSync(progressPath())
}
catch {
// ignore
}
- tmpPath = undefined
}
diff --git a/cli/src/init/mcp/session-state.ts b/cli/src/init/mcp/session-state.ts
index 8cfe9eda71..8835bc7ee2 100644
--- a/cli/src/init/mcp/session-state.ts
+++ b/cli/src/init/mcp/session-state.ts
@@ -1,5 +1,6 @@
// src/init/mcp/session-state.ts
import type { Platform } from './contract.js'
+import { getConfigWriteTarget } from '../../config'
export interface LiveUpdateSessionState {
platform?: Platform
@@ -15,6 +16,10 @@ export interface LiveUpdateSessionState {
const registry = new Map()
+function sessionKey(appId: string): string {
+ return JSON.stringify([getConfigWriteTarget() ?? null, appId])
+}
+
function mergeDefined(base: T, partial: Partial): T {
const next: Record = { ...(base as Record) }
for (const [key, value] of Object.entries(partial)) {
@@ -25,23 +30,24 @@ function mergeDefined(base: T, partial: Partial): T {
}
export function getSession(appId: string): LiveUpdateSessionState {
- const existing = registry.get(appId)
+ const key = sessionKey(appId)
+ const existing = registry.get(key)
if (existing)
return existing
const created: LiveUpdateSessionState = {}
- registry.set(appId, created)
+ registry.set(key, created)
return created
}
export function mergeSession(appId: string, partial: Partial): LiveUpdateSessionState {
const session = getSession(appId)
const next = mergeDefined(session, partial)
- registry.set(appId, next)
+ registry.set(sessionKey(appId), next)
return next
}
export function clearSession(appId: string): void {
- registry.delete(appId)
+ registry.delete(sessionKey(appId))
}
export function clearAllSessions(): void {
diff --git a/cli/src/key.ts b/cli/src/key.ts
index dc1104573a..6e353c7d20 100644
--- a/cli/src/key.ts
+++ b/cli/src/key.ts
@@ -1,10 +1,12 @@
+import type { ExtConfigPairs } from './config'
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
import { intro, log, outro, confirm as pConfirm } from '@clack/prompts'
import { trackEvent } from './analytics/track'
import { createRSA } from './api/crypto'
import { checkAlerts } from './api/update'
-import { writeConfigUpdater } from './config'
-import { baseKey, baseKeyPub, baseKeyPubV2, baseKeyV2, getConfig, promptAndSyncCapacitor } from './utils'
+import { getConfigWriteTarget, writeConfigUpdater } from './config'
+import { baseKey, baseKeyPub, baseKeyPubV2, baseKeyV2, getConfigForWrite, promptAndSyncCapacitor } from './utils'
interface SaveOptions {
key?: string
@@ -15,6 +17,7 @@ interface SaveOptions {
interface Options {
force?: boolean
setupChannel?: boolean
+ keyDir?: string
}
function ensureCapacitorUpdaterConfig(config: any) {
@@ -28,7 +31,7 @@ export async function saveKeyInternal(options: SaveOptions, silent = false) {
if (!silent)
intro('Save keys ๐')
- const extConfig = await getConfig()
+ const extConfig = await getConfigForWrite()
const keyPath = options.key || baseKeyPubV2
let publicKey = options.keyData || ''
@@ -82,7 +85,7 @@ export async function deleteOldPrivateKeyInternal(options: Options, silent = fal
if (!silent)
intro('Deleting old private key ๐๏ธ')
- const extConfig = await getConfig()
+ const extConfig = await getConfigForWrite()
const updaterConfig = extConfig?.config?.plugins?.CapacitorUpdater
if (updaterConfig?.privateKey) {
@@ -132,27 +135,31 @@ export async function saveKeyCommand(options: SaveOptions) {
await saveKeyInternal(options, false)
}
-export async function createKeyInternal(options: Options, silent = false) {
+export async function createKeyInternal(options: Options, silent = false, existingConfig?: ExtConfigPairs) {
if (!silent)
intro('Create keys ๐')
const { publicKey, privateKey } = createRSA()
+ const publicKeyPath = options.keyDir ? join(options.keyDir, baseKeyPubV2) : baseKeyPubV2
+ const privateKeyPath = options.keyDir ? join(options.keyDir, baseKeyV2) : baseKeyV2
- if (existsSync(baseKeyPubV2) && !options.force) {
+ if (existsSync(publicKeyPath) && !options.force) {
if (!silent)
log.error('Public Key already exists, use --force to overwrite')
throw new Error('Public key already exists')
}
- writeFileSync(baseKeyPubV2, publicKey)
+ writeFileSync(publicKeyPath, publicKey)
- if (existsSync(baseKeyV2) && !options.force) {
+ if (existsSync(privateKeyPath) && !options.force) {
if (!silent)
log.error('Private Key already exists, use --force to overwrite')
throw new Error('Private key already exists')
}
- writeFileSync(baseKeyV2, privateKey)
+ writeFileSync(privateKeyPath, privateKey)
- const extConfig = await getConfig()
+ const extConfig = existingConfig && !getConfigWriteTarget()
+ ? existingConfig
+ : await getConfigForWrite()
if (extConfig) {
const updaterConfig = ensureCapacitorUpdaterConfig(extConfig.config)
@@ -180,7 +187,7 @@ export async function createKeyInternal(options: Options, silent = false) {
if (!silent) {
log.success('Your RSA key has been generated')
- log.success(`Private key saved in ${baseKeyV2}`)
+ log.success(`Private key saved in ${privateKeyPath}`)
log.success('This key will be used to encrypt your bundle before sending it to Capgo')
log.success('Keep it safe')
log.success('Then make it unreadable by Capgo and unmodifiable by anyone')
diff --git a/cli/src/mcp/server.ts b/cli/src/mcp/server.ts
index 30ce5a89a0..b5c18870d0 100644
--- a/cli/src/mcp/server.ts
+++ b/cli/src/mcp/server.ts
@@ -5,8 +5,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import pack from '../../package.json'
import { enableSupabaseInstrumentation, setInvocationSource, trackMcpServerStarted, withMcpToolTracking } from '../analytics/track'
-import { addAppOptionsSchema, cleanupOptionsSchema, getStatsOptionsSchema, requestBuildOptionsSchema, starAllRepositoriesOptionsSchema, starRepoOptionsSchema, updateAppOptionsSchema, updateChannelOptionsBaseSchema, updateChannelOptionsSchema, uploadOptionsSchema } from '../schemas/sdk'
+import { addAppOptionsSchema, cleanupOptionsSchema, generateKeyOptionsSchema, getStatsOptionsSchema, requestBuildOptionsSchema, starAllRepositoriesOptionsSchema, starRepoOptionsSchema, updateAppOptionsSchema, updateChannelOptionsBaseSchema, updateChannelOptionsSchema, uploadOptionsSchema } from '../schemas/sdk'
import { CapgoSDK } from '../sdk'
+import { getConfigWriteTarget, setConfigWriteTarget } from '../config'
import { clearSavedKey, getLoginState, loginSuccessMessage, logoutMessage, validateAndSaveKey, whoamiMessage } from '../auth/session'
import { mcpLoginInputSchema, mcpLogoutInputSchema } from '../schemas/auth'
import { findSavedKeySilent, formatError } from '../utils'
@@ -32,12 +33,31 @@ function formatMcpError(result: SDKResult): { content: Array<{ type: 'text
isError: true,
}
}
-
/**
* Start the Capgo MCP (Model Context Protocol) server.
* This allows AI agents to interact with Capgo Cloud programmatically.
*/
-export async function startMcpServer(): Promise {
+export async function startMcpServer(capacitorConfigTarget?: string): Promise {
+ const previousConfigWriteTarget = getConfigWriteTarget()
+ let restored = false
+ const restoreConfigWriteTarget = () => {
+ if (restored)
+ return
+ restored = true
+ setConfigWriteTarget(previousConfigWriteTarget)
+ }
+
+ setConfigWriteTarget(capacitorConfigTarget)
+ try {
+ await startMcpServerInternal(restoreConfigWriteTarget)
+ }
+ catch (error) {
+ restoreConfigWriteTarget()
+ throw error
+ }
+}
+
+async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Promise {
// Install the stdout guard FIRST, before any startup code (saved-key lookup, SDK init,
// lazily imported deps) can emit a stray clack/console line. It reroutes ambient stdout
// to stderr and returns the ONLY writer allowed to reach the real stdout for JSON-RPC
@@ -152,8 +172,36 @@ export async function startMcpServer(): Promise {
server.tool(
'capgo_upload_bundle',
'Upload a new app bundle to Capgo Cloud for distribution',
- uploadOptionsSchema.pick({ appId: true, path: true, bundle: true, channel: true, rollout: true, rolloutPercentageBps: true, rolloutCacheTtlSeconds: true, comment: true, minUpdateVersion: true, autoMinUpdateVersion: true, encrypt: true }).shape,
- async ({ appId, path, bundle, channel, rollout, rolloutPercentageBps, rolloutCacheTtlSeconds, comment, minUpdateVersion, autoMinUpdateVersion, encrypt }) => {
+ uploadOptionsSchema.pick({
+ appId: true,
+ path: true,
+ bundle: true,
+ channel: true,
+ rollout: true,
+ rolloutPercentageBps: true,
+ rolloutCacheTtlSeconds: true,
+ comment: true,
+ minUpdateVersion: true,
+ autoMinUpdateVersion: true,
+ autoSetBundle: true,
+ encrypt: true,
+ capacitorConfig: true,
+ }).shape,
+ async ({
+ appId,
+ path,
+ bundle,
+ channel,
+ rollout,
+ rolloutPercentageBps,
+ rolloutCacheTtlSeconds,
+ comment,
+ minUpdateVersion,
+ autoMinUpdateVersion,
+ autoSetBundle,
+ encrypt,
+ capacitorConfig,
+ }) => {
const result = await sdk.uploadBundle({
appId,
path,
@@ -165,7 +213,9 @@ export async function startMcpServer(): Promise {
comment,
minUpdateVersion,
autoMinUpdateVersion,
+ autoSetBundle,
encrypt,
+ capacitorConfig,
})
if (!result.success) {
return formatMcpError(result)
@@ -604,11 +654,9 @@ export async function startMcpServer(): Promise {
server.tool(
'capgo_generate_encryption_keys',
'Generate RSA key pair for end-to-end encryption of bundles',
- {
- force: z.boolean().optional().describe('Overwrite existing keys if they exist'),
- },
- async ({ force }) => {
- const result = await sdk.generateEncryptionKeys({ force })
+ generateKeyOptionsSchema.pick({ force: true, capacitorConfig: true }).shape,
+ async ({ force, capacitorConfig }) => {
+ const result = await sdk.generateEncryptionKeys({ force, capacitorConfig })
if (!result.success) {
return formatMcpError(result)
}
@@ -726,8 +774,10 @@ export async function startMcpServer(): Promise {
// Start the server with stdio transport. The stdout guard installed at the top of this
// function already routed ambient stdout (stray clack/console output from any tool or
// dependency) to stderr, so only JSON-RPC frames reach the real stdout a strict client
- // reads; otherwise the transport drops ("Transport closed").
+ // reads; otherwise the transport drops ("Transport closed"). Keep the config target
+ // for this server's lifetime because tool calls happen after the CLI action completes.
const transport = new StdioServerTransport(process.stdin, transportStdout)
+ transport.onclose = restoreConfigWriteTarget
await server.connect(transport)
trackMcpServerStarted(Boolean(savedApiKey))
}
diff --git a/cli/src/schemas/live-update-onboarding.ts b/cli/src/schemas/live-update-onboarding.ts
index 0eb54e7b13..865e71866c 100644
--- a/cli/src/schemas/live-update-onboarding.ts
+++ b/cli/src/schemas/live-update-onboarding.ts
@@ -1,7 +1,20 @@
// src/schemas/live-update-onboarding.ts
import { z } from 'zod'
+import { capacitorConfigOptionSchema } from './sdk'
+
+const packageJsonSchema = z.string().min(1).optional().describe('Package JSON for the Capacitor app to onboard. Use this with capacitorConfig when a monorepo config source lives outside the app directory.')
+const mainFileSchema = z.string().min(1).optional().describe('Application entry file to update. Use this with capacitorConfig when a monorepo app has a separate main file.')
+
+export const liveUpdateStartSchema = z.object({
+ capacitorConfig: capacitorConfigOptionSchema.describe('Existing app-specific capacitor.config.* file to update while Capacitor loads the active root config (useful with dynamic monorepos).'),
+ packageJson: packageJsonSchema,
+ mainFile: mainFileSchema,
+})
export const liveUpdateNextStepSchema = z.object({
+ capacitorConfig: capacitorConfigOptionSchema.describe('The same app-specific capacitor.config.* source used to start onboarding when more than one source is active for this app.'),
+ packageJson: packageJsonSchema,
+ mainFile: mainFileSchema,
resumeChoice: z.enum(['continue', 'restart']).optional().describe('Answer to the resume prompt: "continue" resumes saved progress, "restart" wipes it'),
encryptionChoice: z.enum(['enable', 'skip']).optional().describe('Answer at setup-encryption: "enable" turns on bundle encryption, "skip" leaves it off'),
platform: z.enum(['ios', 'android']).optional().describe('Answer at select-platform: target device platform'),
@@ -10,4 +23,5 @@ export const liveUpdateNextStepSchema = z.object({
otaReceivedConfirmed: z.boolean().optional().describe('Set true after the user confirms the OTA update appeared on device (test-update step)'),
})
+export type LiveUpdateStartInput = z.infer
export type LiveUpdateNextStepInput = z.infer
diff --git a/cli/src/schemas/sdk.ts b/cli/src/schemas/sdk.ts
index 00e9aff220..8d036bcafe 100644
--- a/cli/src/schemas/sdk.ts
+++ b/cli/src/schemas/sdk.ts
@@ -1,6 +1,8 @@
import { z } from 'zod'
import { buildCredentialsSchema } from './build'
+export const capacitorConfigOptionSchema = z.string().min(1).optional().describe('Capacitor config source to update')
+
function rejectConflictingBooleanGroup>(value: T, ctx: z.RefinementCtx, keys: Array) {
const selected = keys.filter(key => value[key] === true)
if (selected.length < 2)
@@ -104,11 +106,13 @@ export const uploadOptionsSchema = z.object({
comment: z.string().optional(),
minUpdateVersion: z.string().optional(),
autoMinUpdateVersion: z.boolean().optional(),
+ autoSetBundle: z.boolean().optional(),
selfAssign: z.boolean().optional(),
packageJsonPaths: z.string().optional(),
ignoreCompatibilityCheck: z.boolean().optional(),
disableCodeCheck: z.boolean().optional(),
useZip: z.boolean().optional(),
+ capacitorConfig: capacitorConfigOptionSchema,
})
export type UploadOptions = z.infer
@@ -159,8 +163,9 @@ export type CleanupOptions = z.infer
// ============================================================================
export const generateKeyOptionsSchema = z.object({
- force: z.boolean().optional(),
+ force: z.boolean().optional().describe('Overwrite existing keys if they exist'),
setupChannel: z.boolean().optional(),
+ capacitorConfig: capacitorConfigOptionSchema,
})
export type GenerateKeyOptions = z.infer
@@ -169,6 +174,7 @@ export const saveKeyOptionsSchema = z.object({
keyPath: z.string().optional(),
keyData: z.string().optional(),
setupChannel: z.boolean().optional(),
+ capacitorConfig: capacitorConfigOptionSchema,
})
export type SaveKeyOptions = z.infer
@@ -176,6 +182,7 @@ export type SaveKeyOptions = z.infer
export const deleteOldKeyOptionsSchema = z.object({
force: z.boolean().optional(),
setupChannel: z.boolean().optional(),
+ capacitorConfig: capacitorConfigOptionSchema,
})
export type DeleteOldKeyOptions = z.infer
@@ -405,6 +412,7 @@ export const setSettingOptionsSchema = z.object({
apikey: z.string().optional(),
bool: z.string().optional(),
string: z.string().optional(),
+ capacitorConfig: capacitorConfigOptionSchema,
})
export type SetSettingOptions = z.infer
diff --git a/cli/src/sdk.ts b/cli/src/sdk.ts
index 6efba6b7cc..9882ae9111 100644
--- a/cli/src/sdk.ts
+++ b/cli/src/sdk.ts
@@ -65,6 +65,7 @@ import { currentBundleInternal } from './channel/currentBundle'
import { deleteChannelInternal } from './channel/delete'
import { listChannelsInternal } from './channel/list'
import { setChannelInternal } from './channel/set'
+import { resolveCapacitorConfigTargetPath, withConfigWriteTarget } from './config'
import { starAllRepositories as starAllRepositoriesInternal, starRepository } from './github'
import { createKeyInternal, deleteOldPrivateKeyInternal, saveKeyInternal } from './key'
import { loginInternal } from './login'
@@ -103,6 +104,17 @@ function createErrorResult(error: unknown): SDKResult {
}
}
+/**
+ * Runs config-writing SDK methods against an app-specific source while Capacitor
+ * continues to load the active root config in dynamic monorepos.
+ */
+async function withCapacitorConfigTarget(capacitorConfig: string | undefined, action: () => Promise): Promise {
+ if (capacitorConfig === undefined)
+ return action()
+
+ return withConfigWriteTarget(resolveCapacitorConfigTargetPath(capacitorConfig), action)
+}
+
// ============================================================================
// SDK Class - Main Entry Point
// ============================================================================
@@ -110,6 +122,8 @@ function createErrorResult(error: unknown): SDKResult {
/**
* Capgo SDK for programmatic access to all CLI functionality.
* Use this class to integrate Capgo operations directly into your application.
+ * Config-writing methods accept `capacitorConfig` to target an app-specific
+ * source config in a dynamic monorepo.
*
* @example
* ```typescript
@@ -521,46 +535,49 @@ export class CapgoSDK {
*/
async uploadBundle(options: UploadOptions): Promise {
try {
- // Convert SDK options to internal format
- const internalOptions: OptionsUpload = {
- apikey: options.apikey || this.apikey || findSavedKey(true),
- supaHost: options.supaHost || this.supaHost,
- supaAnon: options.supaAnon || this.supaAnon,
- path: options.path,
- bundle: options.bundle,
- channel: options.channel,
- rollout: options.rollout,
- rolloutPercentageBps: options.rolloutPercentageBps,
- rolloutCacheTtlSeconds: options.rolloutCacheTtlSeconds,
- external: options.external,
- key: options.encrypt !== false, // default true unless explicitly false
- keyV2: options.encryptionKey,
- timeout: options.timeout,
- tus: options.useTus,
- comment: options.comment,
- minUpdateVersion: options.minUpdateVersion,
- autoMinUpdateVersion: options.autoMinUpdateVersion,
- selfAssign: options.selfAssign,
- packageJson: options.packageJsonPaths,
- ignoreMetadataCheck: options.ignoreCompatibilityCheck,
- codeCheck: !options.disableCodeCheck, // disable if requested, otherwise check
- zip: options.useZip, // use legacy zip upload if requested
- }
+ return await withCapacitorConfigTarget(options.capacitorConfig, async () => {
+ // Convert SDK options to internal format
+ const internalOptions: OptionsUpload = {
+ apikey: options.apikey || this.apikey || findSavedKey(true),
+ supaHost: options.supaHost || this.supaHost,
+ supaAnon: options.supaAnon || this.supaAnon,
+ path: options.path,
+ bundle: options.bundle,
+ channel: options.channel,
+ rollout: options.rollout,
+ rolloutPercentageBps: options.rolloutPercentageBps,
+ rolloutCacheTtlSeconds: options.rolloutCacheTtlSeconds,
+ external: options.external,
+ key: options.encrypt !== false, // default true unless explicitly false
+ keyV2: options.encryptionKey,
+ timeout: options.timeout,
+ tus: options.useTus,
+ comment: options.comment,
+ minUpdateVersion: options.minUpdateVersion,
+ autoMinUpdateVersion: options.autoMinUpdateVersion,
+ autoSetBundle: options.autoSetBundle,
+ selfAssign: options.selfAssign,
+ packageJson: options.packageJsonPaths,
+ ignoreMetadataCheck: options.ignoreCompatibilityCheck,
+ codeCheck: !options.disableCodeCheck, // disable if requested, otherwise check
+ zip: options.useZip, // use legacy zip upload if requested
+ }
- // Call internal upload function but suppress CLI behaviors
- const uploadResponse = await uploadBundleInternal(options.appId, internalOptions, true)
+ // Call internal upload function but suppress CLI behaviors
+ const uploadResponse = await uploadBundleInternal(options.appId, internalOptions, true)
- return {
- success: uploadResponse.success,
- bundleId: uploadResponse.bundle,
- checksum: uploadResponse.checksum ?? null,
- encryptionMethod: uploadResponse.encryptionMethod,
- sessionKey: uploadResponse.sessionKey,
- ivSessionKey: uploadResponse.ivSessionKey,
- storageProvider: uploadResponse.storageProvider,
- skipped: uploadResponse.skipped,
- reason: uploadResponse.reason,
- }
+ return {
+ success: uploadResponse.success,
+ bundleId: uploadResponse.bundle,
+ checksum: uploadResponse.checksum ?? null,
+ encryptionMethod: uploadResponse.encryptionMethod,
+ sessionKey: uploadResponse.sessionKey,
+ ivSessionKey: uploadResponse.ivSessionKey,
+ storageProvider: uploadResponse.storageProvider,
+ skipped: uploadResponse.skipped,
+ reason: uploadResponse.reason,
+ }
+ })
}
catch (error) {
return createErrorResult(error)
@@ -955,12 +972,14 @@ export class CapgoSDK {
*/
async generateEncryptionKeys(options?: GenerateKeyOptions): Promise {
try {
- await createKeyInternal({
- force: options?.force,
- setupChannel: options?.setupChannel,
- }, true)
+ return await withCapacitorConfigTarget(options?.capacitorConfig, async () => {
+ await createKeyInternal({
+ force: options?.force,
+ setupChannel: options?.setupChannel,
+ }, true)
- return { success: true }
+ return { success: true }
+ })
}
catch (error) {
return createErrorResult(error)
@@ -972,13 +991,15 @@ export class CapgoSDK {
*/
async saveEncryptionKey(options?: SaveKeyOptions): Promise {
try {
- await saveKeyInternal({
- key: options?.keyPath,
- keyData: options?.keyData,
- setupChannel: options?.setupChannel,
- }, true)
-
- return { success: true }
+ return await withCapacitorConfigTarget(options?.capacitorConfig, async () => {
+ await saveKeyInternal({
+ key: options?.keyPath,
+ keyData: options?.keyData,
+ setupChannel: options?.setupChannel,
+ }, true)
+
+ return { success: true }
+ })
}
catch (error) {
return createErrorResult(error)
@@ -990,15 +1011,17 @@ export class CapgoSDK {
*/
async deleteLegacyEncryptionKey(options?: DeleteOldKeyOptions): Promise> {
try {
- const deleted = await deleteOldPrivateKeyInternal({
- force: options?.force,
- setupChannel: options?.setupChannel,
- }, true)
+ return await withCapacitorConfigTarget(options?.capacitorConfig, async () => {
+ const deleted = await deleteOldPrivateKeyInternal({
+ force: options?.force,
+ setupChannel: options?.setupChannel,
+ }, true)
- return {
- success: true,
- data: { deleted },
- }
+ return {
+ success: true,
+ data: { deleted },
+ }
+ })
}
catch (error) {
return createErrorResult(error)
@@ -1223,13 +1246,15 @@ export class CapgoSDK {
async setAppSetting(path: string, options: SetSettingOptions): Promise {
try {
- await setSettingInternal(path, {
- apikey: options.apikey || this.apikey || findSavedKey(true),
- bool: options.bool,
- string: options.string,
- }, true)
-
- return { success: true }
+ return await withCapacitorConfigTarget(options.capacitorConfig, async () => {
+ await setSettingInternal(path, {
+ apikey: options.apikey || this.apikey || findSavedKey(true),
+ bool: options.bool,
+ string: options.string,
+ }, true)
+
+ return { success: true }
+ })
}
catch (error) {
return createErrorResult(error)
diff --git a/cli/src/utils.ts b/cli/src/utils.ts
index fd7b5a54c7..6ca740bf0e 100644
--- a/cli/src/utils.ts
+++ b/cli/src/utils.ts
@@ -26,7 +26,7 @@ import { createTimedFetch, isSupabaseInstrumentationEnabled } from './analytics/
import { markSnag } from './app/debug'
import { findMonorepoRoot, findNXMonorepoRoot, isMonorepo, isNXMonorepo } from './capacitor-cli'
import { getChecksum } from './checksum'
-import { loadConfig, writeConfig } from './config'
+import { loadConfig, loadConfigForWrite, writeConfig } from './config'
import { isTruthyEnvValue } from './posthog'
import { nativePackageSchema } from './schemas/common'
import { formatApiErrorForCli, parseSecurityPolicyError } from './utils/security_policy_errors'
@@ -540,9 +540,9 @@ export async function getDeclaredPackageVersionMap(f: string = findRoot(cwd()),
return dependencies
}
-export async function getConfig(silent = false) {
+async function getConfigFrom(loader: () => Promise, silent = false): Promise {
try {
- const extConfig = await loadConfig()
+ const extConfig = await loader()
if (!extConfig) {
const message = 'No capacitor config file found, run `cap init` first'
if (!silent)
@@ -559,8 +559,17 @@ export async function getConfig(silent = false) {
}
}
+export function getConfig(silent = false) {
+ return getConfigFrom(loadConfig, silent)
+}
+
+/** Loads the source config that a subsequent mutation will write. */
+export function getConfigForWrite(silent = false) {
+ return getConfigFrom(loadConfigForWrite, silent)
+}
+
export async function updateConfigbyKey(key: string, newConfig: any): Promise {
- const extConfig = await getConfig()
+ const extConfig = await getConfigForWrite()
if (extConfig?.config) {
extConfig.config.plugins ??= {}
diff --git a/cli/test/test-capacitor-config-target.mjs b/cli/test/test-capacitor-config-target.mjs
new file mode 100644
index 0000000000..ea0229fc5a
--- /dev/null
+++ b/cli/test/test-capacitor-config-target.mjs
@@ -0,0 +1,265 @@
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join, relative } from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
+import { getConfigWriteTarget, loadConfigForWrite, resolveCapacitorConfigTargetPath, setConfigWriteTarget } from '../src/config/index.ts'
+import { createKeyInternal } from '../src/key.ts'
+import { getConfig } from '../src/utils.ts'
+import { CapgoSDK } from '../src/sdk.ts'
+
+const cliRoot = dirname(dirname(fileURLToPath(import.meta.url)))
+const root = mkdtempSync(join(cliRoot, '.capgo-config-target-'))
+const outsideRoot = mkdtempSync(join(tmpdir(), 'capgo-config-outside-'))
+const withTimeout = (promise, ms, label) => new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
+ promise.then(
+ value => { clearTimeout(timer); resolve(value) },
+ error => { clearTimeout(timer); reject(error) },
+ )
+})
+
+let transport
+let mcpStderr = ''
+try {
+ const configDir = join(root, 'env-configs')
+ const directoryTarget = join(root, 'directory-target')
+ const rootConfig = join(root, 'capacitor.config.json')
+ const configTarget = join(configDir, 'capacitor.config.qr-code-reader.ts')
+ const alternateConfigTarget = join(configDir, 'capacitor.config.stripe-phone-app.ts')
+ const multiPartConfigTarget = join(configDir, 'capacitor.config.qr-code-reader.production.ts')
+ const jsonConfigTarget = join(configDir, 'capacitor.config.json-target.json')
+ const javascriptConfigTarget = join(configDir, 'capacitor.config.javascript.js')
+ const factoryConfigTarget = join(configDir, 'capacitor.config.factory.ts')
+ const outsideConfigTarget = join(outsideRoot, 'capacitor.config.escape.ts')
+ const rootConfigSource = JSON.stringify({
+ appId: 'com.example.root',
+ appName: 'Root app',
+ webDir: 'root-www',
+ server: {
+ url: 'https://root.example',
+ },
+ plugins: {
+ RootOnlyPlugin: {
+ enabled: true,
+ },
+ CapacitorUpdater: {
+ appId: 'com.example.root',
+ },
+ },
+ }, null, 2)
+ const configTargetSource = `export default {
+ appId: 'com.example.target',
+ appName: 'Target app',
+ webDir: 'target-www',
+ server: {
+ url: 'https://target.example',
+ },
+ plugins: {
+ TargetOnlyPlugin: {
+ enabled: true,
+ },
+ CapacitorUpdater: {
+ appId: 'com.example.target',
+ targetOnly: true,
+ },
+ },
+}
+`
+ const factoryConfigSource = `export default async () => ({
+ appId: 'com.example.factory',
+ appName: 'Factory app',
+ webDir: 'factory-www',
+})
+`
+ const appDir = join(root, 'apps', 'qr-code-reader')
+ mkdirSync(configDir, { recursive: true })
+ mkdirSync(directoryTarget)
+ mkdirSync(appDir, { recursive: true })
+ writeFileSync(join(root, 'package.json'), '{}')
+ writeFileSync(rootConfig, rootConfigSource)
+ writeFileSync(alternateConfigTarget, 'export default {}\n')
+ writeFileSync(multiPartConfigTarget, 'export default {}\n')
+ writeFileSync(configTarget, configTargetSource)
+ writeFileSync(jsonConfigTarget, JSON.stringify({ appId: 'com.example.json', appName: 'JSON app', webDir: 'json-www' }))
+ writeFileSync(javascriptConfigTarget, 'module.exports = {}\n')
+ writeFileSync(factoryConfigTarget, factoryConfigSource)
+ writeFileSync(join(configDir, 'not-a-capacitor-config.ts'), 'export default {}\n')
+ writeFileSync(outsideConfigTarget, 'export default {}\n')
+ assert.equal(resolveCapacitorConfigTargetPath('./env-configs/capacitor.config.qr-code-reader.production.ts', root), multiPartConfigTarget)
+ assert.equal(resolveCapacitorConfigTargetPath('./env-configs/capacitor.config.qr-code-reader.ts', root), configTarget)
+ assert.equal(resolveCapacitorConfigTargetPath('./env-configs/capacitor.config.json-target.json', root), jsonConfigTarget)
+ assert.throws(() => resolveCapacitorConfigTargetPath('./env-configs/capacitor.config.javascript.js', root), /\.ts or capacitor\.config\.\*\.json/)
+ assert.throws(() => resolveCapacitorConfigTargetPath(relative(root, outsideConfigTarget), root), /must stay within the current working directory/)
+ assert.throws(() => resolveCapacitorConfigTargetPath(outsideConfigTarget, root), /must stay within the current working directory/)
+ const outsideLink = join(root, 'outside-link')
+ symlinkSync(outsideRoot, outsideLink, process.platform === 'win32' ? 'junction' : 'dir')
+ assert.throws(() => resolveCapacitorConfigTargetPath(join('outside-link', 'capacitor.config.escape.ts'), root), /must stay within the current working directory/)
+ assert.throws(() => resolveCapacitorConfigTargetPath('./missing.ts', root), /Capacitor config path does not exist/)
+ assert.throws(() => resolveCapacitorConfigTargetPath('./directory-target', root), /Capacitor config path does not exist/)
+ assert.throws(() => resolveCapacitorConfigTargetPath('', root), /Capacitor config path must not be empty/)
+ const previousCwd = process.cwd()
+ const previousConfigWriteTarget = getConfigWriteTarget()
+ try {
+ process.chdir(root)
+ setConfigWriteTarget(factoryConfigTarget)
+ const factoryConfigWriteSnapshot = await loadConfigForWrite()
+ assert.equal(factoryConfigWriteSnapshot.config.appId, 'com.example.factory')
+ assert.equal(factoryConfigWriteSnapshot.config.webDir, 'factory-www')
+ setConfigWriteTarget(jsonConfigTarget)
+ const jsonConfigSnapshot = await getConfig()
+ assert.equal(jsonConfigSnapshot.config.appId, 'com.example.root')
+ assert.equal(jsonConfigSnapshot.path, jsonConfigTarget)
+ const jsonConfigWriteSnapshot = await loadConfigForWrite()
+ assert.equal(jsonConfigWriteSnapshot.config.appId, 'com.example.json')
+
+ setConfigWriteTarget(configTarget)
+ const configSnapshot = await getConfig()
+ assert.equal(configSnapshot.config.appId, 'com.example.root')
+ assert.equal(configSnapshot.path, configTarget)
+ assert.ok(configSnapshot.config.plugins.RootOnlyPlugin)
+ const configWriteSnapshot = await loadConfigForWrite()
+ assert.equal(configWriteSnapshot.config.appId, 'com.example.target')
+ const createKeyPromise = createKeyInternal({ force: true, keyDir: appDir, setupChannel: false }, true, configWriteSnapshot)
+ assert.equal(process.cwd(), root)
+ await createKeyPromise
+ assert.equal(process.cwd(), root)
+ }
+ finally {
+ process.chdir(previousCwd)
+ setConfigWriteTarget(previousConfigWriteTarget)
+ }
+ const createdTargetConfig = readFileSync(configTarget, 'utf8')
+ assert.match(createdTargetConfig, /publicKey/)
+ assert.match(createdTargetConfig, /appId:\s*'com\.example\.target'/)
+ assert.match(createdTargetConfig, /TargetOnlyPlugin/)
+ assert.match(createdTargetConfig, /targetOnly:\s*true/)
+ assert.doesNotMatch(createdTargetConfig, /RootOnlyPlugin/)
+ assert.ok(existsSync(join(appDir, '.capgo_key_v2')))
+ assert.ok(existsSync(join(appDir, '.capgo_key_v2.pub')))
+ assert.match(readFileSync(configTarget, 'utf8'), /publicKey/)
+ assert.equal(readFileSync(rootConfig, 'utf8'), rootConfigSource)
+ assert.ok(!existsSync(join(root, '.capgo_key_v2')))
+ assert.throws(() => resolveCapacitorConfigTargetPath('./env-configs/not-a-capacitor-config.ts', root), /must point to a capacitor.config/)
+
+ const sdkCwd = process.cwd()
+ const sdkConfigWriteTarget = getConfigWriteTarget()
+ try {
+ process.chdir(root)
+ const result = await new CapgoSDK().saveEncryptionKey({
+ capacitorConfig: configTarget,
+ keyData: '-----BEGIN RSA PUBLIC KEY-----\nsdk-public-key\n-----END RSA PUBLIC KEY-----',
+ })
+ assert.equal(result.success, true, result.error)
+ assert.equal(getConfigWriteTarget(), sdkConfigWriteTarget)
+ }
+ finally {
+ process.chdir(sdkCwd)
+ setConfigWriteTarget(sdkConfigWriteTarget)
+ }
+ assert.match(readFileSync(configTarget, 'utf8'), /sdk-public-key/)
+ assert.equal(readFileSync(rootConfig, 'utf8'), rootConfigSource)
+
+ const command = spawnSync('node', [
+ join(cliRoot, 'dist/index.js'),
+ 'app',
+ 'setting',
+ 'plugins.CapacitorUpdater.autoUpdate',
+ '--bool',
+ 'false',
+ '--capacitor-config',
+ configTarget,
+ ], {
+ cwd: root,
+ encoding: 'utf8',
+ env: { ...process.env, CAPGO_DISABLE_TELEMETRY: 'true' },
+ })
+
+ assert.equal(command.status, 0, `${command.stdout}\n${command.stderr}`)
+ const writtenTargetConfig = readFileSync(configTarget, 'utf8')
+ assert.match(writtenTargetConfig, /appId:\s*'com\.example\.target'/)
+ assert.match(writtenTargetConfig, /appName:\s*'Target app'/)
+ assert.match(writtenTargetConfig, /webDir:\s*'target-www'/)
+ assert.match(writtenTargetConfig, /https:\/\/target\.example/)
+ assert.match(writtenTargetConfig, /TargetOnlyPlugin/)
+ assert.match(writtenTargetConfig, /targetOnly:\s*true/)
+ assert.match(writtenTargetConfig, /autoUpdate:\s*false/)
+ assert.doesNotMatch(writtenTargetConfig, /RootOnlyPlugin/)
+ assert.equal(readFileSync(rootConfig, 'utf8'), rootConfigSource)
+
+ const notificationHelper = join(root, 'src', 'capgo-notifications.ts')
+ const notificationsCommand = spawnSync('node', [
+ join(cliRoot, 'dist/index.js'),
+ 'notifications',
+ 'setup',
+ 'com.example.app',
+ '--no-install',
+ '--no-sync',
+ '--file',
+ './src/capgo-notifications.ts',
+ '--capacitor-config',
+ configTarget,
+ ], {
+ cwd: root,
+ encoding: 'utf8',
+ env: { ...process.env, CAPGO_DISABLE_TELEMETRY: 'true' },
+ })
+
+ assert.equal(notificationsCommand.status, 0, `${notificationsCommand.stdout}\n${notificationsCommand.stderr}`)
+ assert.ok(existsSync(notificationHelper))
+ assert.match(readFileSync(configTarget, 'utf8'), /CapgoNotifications/)
+ assert.equal(readFileSync(rootConfig, 'utf8'), rootConfigSource)
+
+ const mcpHelp = spawnSync('node', [join(cliRoot, 'dist/index.js'), 'mcp', '--help'], {
+ cwd: root,
+ encoding: 'utf8',
+ env: { ...process.env, CAPGO_DISABLE_TELEMETRY: 'true' },
+ })
+ assert.equal(mcpHelp.status, 0, `${mcpHelp.stdout}\n${mcpHelp.stderr}`)
+ assert.match(mcpHelp.stdout, /--capacitor-config /)
+
+ transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [join(cliRoot, 'dist/index.js'), 'mcp', '--capacitor-config', configTarget],
+ cwd: root,
+ env: { ...process.env, CAPGO_DISABLE_TELEMETRY: 'true' },
+ stderr: 'pipe',
+ })
+ if (transport.stderr)
+ transport.stderr.on('data', chunk => { mcpStderr += chunk.toString() })
+
+ const client = new Client({ name: 'capgo-config-target-test', version: '0.0.0' })
+ await withTimeout(client.connect(transport), 10000, 'MCP connect')
+ const tools = await withTimeout(client.listTools(), 10000, 'MCP tool listing')
+ const generateEncryptionKeys = tools.tools.find(tool => tool.name === 'capgo_generate_encryption_keys')
+ assert.ok(generateEncryptionKeys?.inputSchema?.properties?.capacitorConfig)
+ const uploadBundle = tools.tools.find(tool => tool.name === 'capgo_upload_bundle')
+ assert.ok(uploadBundle?.inputSchema?.properties?.capacitorConfig)
+ assert.ok(uploadBundle?.inputSchema?.properties?.autoSetBundle)
+
+ const defaultResult = await withTimeout(client.callTool({ name: 'capgo_generate_encryption_keys', arguments: { force: true } }), 30000, 'MCP default encryption key generation')
+ assert.equal(defaultResult.isError, undefined, JSON.stringify(defaultResult))
+ assert.match(readFileSync(configTarget, 'utf8'), /publicKey/)
+
+ const overrideResult = await withTimeout(client.callTool({ name: 'capgo_generate_encryption_keys', arguments: { force: true, capacitorConfig: alternateConfigTarget } }), 30000, 'MCP override encryption key generation')
+ assert.equal(overrideResult.isError, undefined, JSON.stringify(overrideResult))
+ assert.match(readFileSync(alternateConfigTarget, 'utf8'), /publicKey/)
+ assert.equal(readFileSync(rootConfig, 'utf8'), rootConfigSource)
+ console.log('โ
capacitor config target tests passed')
+}
+finally {
+ try {
+ await transport?.close()
+ }
+ catch {
+ // The process may already be closed after an MCP failure.
+ }
+ if (mcpStderr)
+ console.error(mcpStderr.trim())
+ rmSync(root, { recursive: true, force: true })
+ rmSync(outsideRoot, { recursive: true, force: true })
+}
diff --git a/cli/test/test-init-monorepo-targeting.mjs b/cli/test/test-init-monorepo-targeting.mjs
new file mode 100644
index 0000000000..28bb103aba
--- /dev/null
+++ b/cli/test/test-init-monorepo-targeting.mjs
@@ -0,0 +1,95 @@
+import assert from 'node:assert/strict'
+import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, relative } from 'node:path'
+import { resolveInitTargetPath, resolveResumedInitTargets } from '../src/init/command.ts'
+
+const root = mkdtempSync(join(tmpdir(), 'capgo-init-monorepo-'))
+const outsideRoot = mkdtempSync(join(tmpdir(), 'capgo-init-monorepo-outside-'))
+try {
+ const appDir = join(root, 'projects', 'qr-code-reader', 'src')
+ const configDir = join(root, 'env-configs')
+ const directoryTarget = join(root, 'directory-target')
+ const savedPackageJson = join(root, 'package.json')
+ const savedMainFile = join(appDir, 'main.ts')
+ const savedConfigFile = join(configDir, 'capacitor.config.qr-code-reader.ts')
+ const explicitProjectDir = join(root, 'projects', 'stripe-phone-app')
+ const explicitPackageJson = join(explicitProjectDir, 'package.json')
+ const explicitMainFile = join(explicitProjectDir, 'src', 'main.ts')
+ const explicitConfigFile = join(configDir, 'capacitor.config.stripe-phone-app.ts')
+ const outsidePackageJson = join(outsideRoot, 'package.json')
+ mkdirSync(appDir, { recursive: true })
+ mkdirSync(configDir, { recursive: true })
+ mkdirSync(directoryTarget)
+ mkdirSync(join(explicitProjectDir, 'src'), { recursive: true })
+ writeFileSync(savedPackageJson, '{}')
+ writeFileSync(savedMainFile, 'export {}')
+ writeFileSync(savedConfigFile, 'export default {}')
+ writeFileSync(explicitPackageJson, '{}')
+ writeFileSync(explicitMainFile, 'export {}')
+ writeFileSync(explicitConfigFile, 'export default {}')
+ writeFileSync(outsidePackageJson, '{}')
+ const canonicalSavedConfigFile = realpathSync(savedConfigFile)
+ const canonicalExplicitConfigFile = realpathSync(explicitConfigFile)
+
+ assert.equal(resolveInitTargetPath('./package.json', 'Package JSON path', root), savedPackageJson)
+ assert.equal(resolveInitTargetPath('./projects/qr-code-reader/src/main.ts', 'Main file path', root), savedMainFile)
+ assert.equal(resolveInitTargetPath('./env-configs/capacitor.config.qr-code-reader.ts', 'Capacitor config path', root), savedConfigFile)
+ assert.throws(() => resolveInitTargetPath('./missing.ts', 'Main file path', root), /Main file path does not exist/)
+ assert.throws(() => resolveInitTargetPath('./directory-target', 'Main file path', root), /Main file path does not exist/)
+ assert.throws(() => resolveInitTargetPath(relative(root, outsidePackageJson), 'Package JSON path', root), /must stay within the current working directory/)
+ const outsideLink = join(root, 'outside-package.json')
+ symlinkSync(outsidePackageJson, outsideLink)
+ assert.throws(() => resolveInitTargetPath('./outside-package.json', 'Package JSON path', root), /must stay within the current working directory/)
+
+ const savedTargets = {
+ pathToPackageJson: savedPackageJson,
+ capacitorConfigPath: canonicalSavedConfigFile,
+ configLoadDir: root,
+ mainFilePath: savedMainFile,
+ }
+ assert.deepEqual(resolveResumedInitTargets({}, savedTargets, root), savedTargets)
+
+ const currentConfigTarget = {
+ capacitorConfigPath: canonicalExplicitConfigFile,
+ configLoadDir: root,
+ }
+ assert.equal(resolveResumedInitTargets(currentConfigTarget, savedTargets, root), undefined)
+ assert.deepEqual(resolveResumedInitTargets({ capacitorConfigPath: canonicalSavedConfigFile, configLoadDir: root }, savedTargets, root), savedTargets)
+ assert.equal(resolveResumedInitTargets(currentConfigTarget, { ...savedTargets, capacitorConfigPath: undefined, configLoadDir: undefined }, root), undefined)
+
+ const explicitTargets = {
+ pathToPackageJson: explicitPackageJson,
+ capacitorConfigPath: canonicalExplicitConfigFile,
+ configLoadDir: root,
+ mainFilePath: explicitMainFile,
+ }
+ assert.equal(resolveResumedInitTargets(explicitTargets, savedTargets, root), undefined)
+ assert.deepEqual(resolveResumedInitTargets({}, {}, root), {})
+
+ const invalidMainFile = join(root, 'projects', 'qr-code-reader', 'src', 'main.txt')
+ writeFileSync(invalidMainFile, 'export {}')
+ const staleTargets = {
+ pathToPackageJson: join(root, 'missing-package.json'),
+ capacitorConfigPath: join(configDir, 'capacitor.config.missing.ts'),
+ configLoadDir: join(root, 'missing-config-dir'),
+ mainFilePath: join(root, 'missing-main.ts'),
+ }
+ assert.deepEqual(resolveResumedInitTargets({ pathToPackageJson: explicitPackageJson, mainFilePath: explicitMainFile }, {
+ pathToPackageJson: staleTargets.pathToPackageJson,
+ mainFilePath: staleTargets.mainFilePath,
+ }, root), {
+ pathToPackageJson: explicitPackageJson,
+ mainFilePath: explicitMainFile,
+ })
+ assert.equal(resolveResumedInitTargets({}, { ...savedTargets, pathToPackageJson: staleTargets.pathToPackageJson }, root), undefined)
+ assert.equal(resolveResumedInitTargets({}, { ...savedTargets, capacitorConfigPath: staleTargets.capacitorConfigPath }, root), undefined)
+ assert.equal(resolveResumedInitTargets({}, { ...savedTargets, configLoadDir: staleTargets.configLoadDir }, root), undefined)
+ assert.equal(resolveResumedInitTargets({}, { ...savedTargets, mainFilePath: staleTargets.mainFilePath }, root), undefined)
+ assert.equal(resolveResumedInitTargets({}, { ...savedTargets, mainFilePath: invalidMainFile }, root), undefined)
+ console.log('โ
init monorepo target tests passed')
+}
+finally {
+ rmSync(root, { recursive: true, force: true })
+ rmSync(outsideRoot, { recursive: true, force: true })
+}
diff --git a/cli/test/test-mcp-live-update-onboarding.mjs b/cli/test/test-mcp-live-update-onboarding.mjs
index f7791c3371..17a8186617 100644
--- a/cli/test/test-mcp-live-update-onboarding.mjs
+++ b/cli/test/test-mcp-live-update-onboarding.mjs
@@ -1,12 +1,15 @@
#!/usr/bin/env node
/** Headless tests for the MCP-conducted Capgo live-update onboarding engine. */
+import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
import process from 'node:process'
console.log('๐งช Testing MCP live-update onboarding...\n')
const { renderResult, LIVE_UPDATE_RULES } = await import('../src/init/mcp/contract.ts')
-const { clearAllSessions } = await import('../src/init/mcp/session-state.ts')
-
+const { clearAllSessions, getSession, mergeSession } = await import('../src/init/mcp/session-state.ts')
+const { clearLiveUpdateProgress, loadLiveUpdateProgress, saveLiveUpdateProgress } = await import('../src/init/mcp/progress.ts')
let pass = 0
let fail = 0
async function test(name, fn) {
@@ -16,6 +19,7 @@ async function test(name, fn) {
}
function eq(a, b, msg) { if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`) }
function ok(c, msg) { if (!c) throw new Error(msg || 'expected truthy') }
+const realConfigPath = filePath => realpathSync(filePath)
await test('LIVE_UPDATE_RULES mentions explain tool and login', async () => {
const joined = LIVE_UPDATE_RULES.join('\n')
@@ -118,19 +122,28 @@ await test('gatherFacts ignores progress from another app', async () => {
eq(f.progress, null)
})
-await test('runStart enters prepare phase', async () => {
+await test('runStart does not re-prompt a new onboarding', async () => {
const r = await runStart(fakeDeps())
+ ok(r.state !== 'resume-prompt')
ok(r.onboarding === 'capgo-live-update')
- ok(r.phase === 'prepare' || r.phase === 'preflight' || r.kind === 'auto')
})
-const { registerLiveUpdateTools } = await import('../src/init/mcp/live-update-tools.ts')
+await test('runStart re-prompts after a previous resume decision', async () => {
+ const deps = fakeDeps()
+ deps.saveProgress({ step_done: 4, appId: 'com.acme.app' })
+ mergeSession('com.acme.app', { resumeResolved: true })
+
+ const r = await runStart(deps)
+ eq(r.state, 'resume-prompt')
+})
+
+const { buildDeps, registerLiveUpdateTools, resolveLiveUpdateProjectTarget } = await import('../src/init/mcp/live-update-tools.ts')
function fakeServer() {
const tools = {}
return {
tools,
- tool(name, _desc, _schema, handler) { tools[name] = { handler } },
+ tool(name, _desc, schema, handler) { tools[name] = { schema, handler } },
}
}
@@ -138,8 +151,14 @@ await test('registerLiveUpdateTools registers spine + explain', async () => {
const server = fakeServer()
registerLiveUpdateTools(server, null, fakeDeps())
ok(server.tools.start_capgo_live_update_onboarding)
+ ok(server.tools.start_capgo_live_update_onboarding.schema.capacitorConfig)
ok(server.tools.capgo_live_update_onboarding_next_step)
ok(server.tools.capgo_live_update_onboarding_explain)
+ ok(server.tools.capgo_live_update_onboarding_next_step.schema.capacitorConfig)
+ ok(server.tools.capgo_live_update_onboarding_explain.schema.capacitorConfig)
+ const explainConfigSchema = server.tools.capgo_live_update_onboarding_explain.schema.capacitorConfig
+ eq(explainConfigSchema.safeParse('').success, false)
+ eq(explainConfigSchema.safeParse('./env-configs/capacitor.config.qr-code-reader.ts').success, true)
})
await test('registerLiveUpdateTools: start returns rendered text', async () => {
@@ -149,6 +168,494 @@ await test('registerLiveUpdateTools: start returns rendered text', async () => {
ok(res.content[0].text.includes('Capgo live-update onboarding'))
})
+const { getConfigWriteTarget, setConfigWriteTarget, withConfigWriteTarget } = await import('../src/config/index.ts')
+const { liveUpdateNextStepSchema, liveUpdateStartSchema } = await import('../src/schemas/live-update-onboarding.ts')
+await test('live-update onboarding validates Capacitor config target input', async () => {
+ eq(liveUpdateStartSchema.safeParse({ capacitorConfig: '' }).success, false)
+ eq(liveUpdateNextStepSchema.safeParse({ capacitorConfig: '' }).success, false)
+ eq(liveUpdateNextStepSchema.safeParse({ capacitorConfig: './env-configs/capacitor.config.qr-code-reader.ts' }).success, true)
+ eq(liveUpdateStartSchema.safeParse({}).success, true)
+})
+
+await test('MCP monorepo targets keep project work scoped to the selected app', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'capgo-live-update-monorepo-'))
+ const configDir = join(root, 'env-configs')
+ const configTarget = join(configDir, 'capacitor.config.reader.ts')
+ const appDir = join(root, 'projects', 'reader')
+ const packageJsonPath = join(appDir, 'package.json')
+ const mainFilePath = join(appDir, 'src', 'main.ts')
+ const rootMainFilePath = join(root, 'main.ts')
+ const invalidMainFilePath = join(appDir, 'src', 'main.txt')
+ const previousCwd = process.cwd()
+ const previousTarget = getConfigWriteTarget()
+ try {
+ mkdirSync(join(appDir, 'src'), { recursive: true })
+ mkdirSync(join(appDir, 'ios'), { recursive: true })
+ mkdirSync(configDir, { recursive: true })
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'workspace-root', version: '1.0.0' }))
+ writeFileSync(join(root, 'capacitor.config.json'), JSON.stringify({ appId: 'com.acme.reader', appName: 'Reader', webDir: 'www' }))
+ writeFileSync(configTarget, 'export default {}\n')
+ writeFileSync(packageJsonPath, JSON.stringify({ name: 'reader', version: '2.0.0' }))
+ writeFileSync(mainFilePath, 'export {}\n')
+ writeFileSync(rootMainFilePath, 'export const rootOnly = true\n')
+ writeFileSync(invalidMainFilePath, 'export {}\n')
+
+ const projectTarget = resolveLiveUpdateProjectTarget({
+ packageJson: './projects/reader/package.json',
+ mainFile: './projects/reader/src/main.ts',
+ }, root)
+ eq(projectTarget.packageJsonPath, packageJsonPath)
+ eq(projectTarget.mainFilePath, mainFilePath)
+ let invalidPackageError
+ try {
+ resolveLiveUpdateProjectTarget({ packageJson: './projects/reader/src/main.ts' }, root)
+ }
+ catch (error) {
+ invalidPackageError = error
+ }
+ ok(String(invalidPackageError).includes('must point to package.json'))
+ let invalidMainError
+ try {
+ resolveLiveUpdateProjectTarget({ mainFile: './projects/reader/src/main.txt' }, root)
+ }
+ catch (error) {
+ invalidMainError = error
+ }
+ ok(String(invalidMainError).includes('JavaScript or TypeScript'))
+ eq(liveUpdateStartSchema.safeParse({ packageJson: './projects/reader/package.json', mainFile: './projects/reader/src/main.ts' }).success, true)
+ eq(liveUpdateNextStepSchema.safeParse({ packageJson: './projects/reader/package.json', mainFile: './projects/reader/src/main.ts' }).success, true)
+
+ process.chdir(root)
+ setConfigWriteTarget(undefined)
+ const deps = buildDeps({}, root, () => projectTarget)
+ eq(JSON.stringify(await deps.detectPlatforms()), JSON.stringify(['ios']))
+ const runCommand = deps.getRunDeviceCommand('ios')
+ eq(runCommand.cwd, appDir)
+ ok(!runCommand.command.includes(appDir))
+ const unsafeProjectDir = join(root, 'projects', 'reader-$(touch injected)')
+ const unsafeRunCommand = buildDeps({}, root, () => ({ packageJsonPath: join(unsafeProjectDir, 'package.json') })).getRunDeviceCommand('ios')
+ eq(unsafeRunCommand.cwd, unsafeProjectDir)
+ ok(!unsafeRunCommand.command.includes('$('))
+ const integration = await deps.addIntegrationCode('com.acme.reader')
+ eq(integration.ok, true)
+ ok(readFileSync(mainFilePath, 'utf8').includes('CapacitorUpdater.notifyAppReady()'))
+ ok(!readFileSync(rootMainFilePath, 'utf8').includes('CapacitorUpdater.notifyAppReady()'))
+
+ const server = fakeServer()
+ registerLiveUpdateTools(server, null, fakeDeps({ cwd: root }))
+ const started = await server.tools.start_capgo_live_update_onboarding.handler({
+ capacitorConfig: './env-configs/capacitor.config.reader.ts',
+ packageJson: './projects/reader/package.json',
+ mainFile: './projects/reader/src/main.ts',
+ })
+ ok(started.content[0].text.includes(realConfigPath(configTarget)))
+ ok(started.content[0].text.includes(packageJsonPath))
+ ok(started.content[0].text.includes(mainFilePath))
+ let conflictingTargetError
+ try {
+ await server.tools.start_capgo_live_update_onboarding.handler({
+ capacitorConfig: './env-configs/capacitor.config.reader.ts',
+ mainFile: './main.ts',
+ })
+ }
+ catch (error) {
+ conflictingTargetError = error
+ }
+ ok(String(conflictingTargetError).includes('already has packageJson or mainFile targets'))
+ }
+ finally {
+ process.chdir(previousCwd)
+ setConfigWriteTarget(previousTarget)
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+const { startMcpServer } = await import('../src/mcp/server.ts')
+
+await test('MCP startup restores the prior config target after failure', async () => {
+ const originalTarget = getConfigWriteTarget()
+ const originalStdinOn = process.stdin.on
+ const originalStdoutWrite = process.stdout.write
+ const priorTarget = '/tmp/capgo-prior-config.ts'
+ try {
+ setConfigWriteTarget(priorTarget)
+ process.stdin.on = () => { throw new Error('forced MCP startup failure') }
+
+ let failure
+ try {
+ await startMcpServer('/tmp/capgo-new-config.ts')
+ }
+ catch (error) {
+ failure = error
+ }
+
+ ok(String(failure).includes('forced MCP startup failure'))
+ eq(getConfigWriteTarget(), priorTarget)
+ }
+ finally {
+ process.stdin.on = originalStdinOn
+ process.stdout.write = originalStdoutWrite
+ setConfigWriteTarget(originalTarget)
+ }
+})
+
+await test('registerLiveUpdateTools keeps a request-local start config target for later steps', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'capgo-live-update-config-'))
+ const target = join(root, 'capacitor.config.qr-code-reader.ts')
+ const previousTarget = getConfigWriteTarget()
+ const serverTarget = '/tmp/capgo-server-config.ts'
+ const observedTargets = []
+ try {
+ writeFileSync(target, 'export default {}\n')
+ setConfigWriteTarget(serverTarget)
+ const server = fakeServer()
+ registerLiveUpdateTools(server, null, fakeDeps({
+ cwd: root,
+ setupEncryption: async (_appId, enable) => {
+ observedTargets.push(getConfigWriteTarget())
+ return { ok: true, enabled: enable }
+ },
+ }))
+ const started = await server.tools.start_capgo_live_update_onboarding.handler({ capacitorConfig: './capacitor.config.qr-code-reader.ts' })
+ ok(started.content[0].text.includes(realConfigPath(target)))
+ eq(getConfigWriteTarget(), serverTarget)
+ await server.tools.capgo_live_update_onboarding_next_step.handler({ resumeChoice: 'continue', encryptionChoice: 'enable' })
+ eq(observedTargets[0], realConfigPath(target))
+ eq(getConfigWriteTarget(), serverTarget)
+
+ let missingError
+ try {
+ await server.tools.start_capgo_live_update_onboarding.handler({ capacitorConfig: './missing.ts' })
+ }
+ catch (error) {
+ missingError = error
+ }
+ ok(String(missingError).includes('Capacitor config path does not exist'))
+ }
+ finally {
+ setConfigWriteTarget(previousTarget)
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
+await test('routing config does not bypass the saved-progress resume choice', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'capgo-live-update-resume-config-'))
+ const target = join(root, 'capacitor.config.qr-code-reader.ts')
+ const progress = { step_done: 4, appId: 'com.acme.app' }
+ try {
+ writeFileSync(target, 'export default {}\n')
+ const server = fakeServer()
+ registerLiveUpdateTools(server, null, fakeDeps({
+ cwd: root,
+ loadProgress: () => progress,
+ saveProgress: () => {},
+ clearProgress: () => {},
+ }))
+
+ const started = await server.tools.start_capgo_live_update_onboarding.handler({ capacitorConfig: './capacitor.config.qr-code-reader.ts' })
+ ok(started.content[0].text.includes('"state": "resume-prompt"'))
+
+ const continued = await server.tools.capgo_live_update_onboarding_next_step.handler({ capacitorConfig: './capacitor.config.qr-code-reader.ts' })
+ ok(continued.content[0].text.includes('"state": "resume-prompt"'))
+ }
+ finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+await test('scopes onboarding session and progress by Capacitor config source', async () => {
+ const firstTarget = '/tmp/capgo-live-update-first.ts'
+ const secondTarget = '/tmp/capgo-live-update-second.ts'
+ try {
+ withConfigWriteTarget(firstTarget, () => {
+ mergeSession('com.acme.shared', { encryptionChoice: 'enable' })
+ saveLiveUpdateProgress({ step_done: 4, appId: 'com.acme.shared' })
+ })
+ withConfigWriteTarget(secondTarget, () => {
+ mergeSession('com.acme.shared', { encryptionChoice: 'skip' })
+ saveLiveUpdateProgress({ step_done: 7, appId: 'com.acme.shared' })
+ })
+
+ withConfigWriteTarget(firstTarget, () => {
+ eq(getSession('com.acme.shared').encryptionChoice, 'enable')
+ eq(loadLiveUpdateProgress()?.step_done, 4)
+ })
+ withConfigWriteTarget(secondTarget, () => {
+ eq(getSession('com.acme.shared').encryptionChoice, 'skip')
+ eq(loadLiveUpdateProgress()?.step_done, 7)
+ })
+ }
+ finally {
+ withConfigWriteTarget(firstTarget, clearLiveUpdateProgress)
+ withConfigWriteTarget(secondTarget, clearLiveUpdateProgress)
+ }
+})
+
+await test('root onboarding migrates legacy progress to its resolved config source', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'capgo-live-update-legacy-progress-'))
+ const rootConfig = join(root, 'capacitor.config.json')
+ const previousCwd = process.cwd()
+ const previousTarget = getConfigWriteTarget()
+ const progressByTarget = new Map([[undefined, { step_done: 4, appId: 'com.acme.shared' }]])
+ try {
+ writeFileSync(join(root, 'package.json'), '{}')
+ writeFileSync(rootConfig, JSON.stringify({ appId: 'com.acme.shared', appName: 'Shared', webDir: 'www' }))
+ process.chdir(root)
+ setConfigWriteTarget(undefined)
+ const server = fakeServer()
+ registerLiveUpdateTools(server, null, fakeDeps({
+ cwd: root,
+ getAppId: async () => 'com.acme.shared',
+ loadProgress: () => progressByTarget.get(getConfigWriteTarget()) ?? null,
+ saveProgress: data => progressByTarget.set(getConfigWriteTarget(), data),
+ clearProgress: () => progressByTarget.delete(getConfigWriteTarget()),
+ }))
+
+ const started = await server.tools.start_capgo_live_update_onboarding.handler({})
+ ok(started.content[0].text.includes('"state": "resume-prompt"'))
+ const resolvedTarget = [...progressByTarget.keys()].find(target => target !== undefined)
+ eq(progressByTarget.get(resolvedTarget)?.step_done, 4)
+ eq(progressByTarget.has(undefined), false)
+ }
+ finally {
+ process.chdir(previousCwd)
+ setConfigWriteTarget(previousTarget)
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
+await test('explicit config target migrates legacy progress', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'capgo-live-update-explicit-legacy-progress-'))
+ const target = join(root, 'capacitor.config.qr-code-reader.ts')
+ const previousTarget = getConfigWriteTarget()
+ const progressByTarget = new Map([[undefined, { step_done: 4, appId: 'com.acme.shared' }]])
+ try {
+ writeFileSync(target, 'export default {}\n')
+ setConfigWriteTarget(undefined)
+ const server = fakeServer()
+ registerLiveUpdateTools(server, null, fakeDeps({
+ cwd: root,
+ getAppId: async () => 'com.acme.shared',
+ loadProgress: () => progressByTarget.get(getConfigWriteTarget()) ?? null,
+ saveProgress: data => progressByTarget.set(getConfigWriteTarget(), data),
+ clearProgress: () => progressByTarget.delete(getConfigWriteTarget()),
+ }))
+
+ const started = await server.tools.start_capgo_live_update_onboarding.handler({ capacitorConfig: './capacitor.config.qr-code-reader.ts' })
+ ok(started.content[0].text.includes('"state": "resume-prompt"'))
+ eq(progressByTarget.get(realConfigPath(target))?.step_done, 4)
+ eq(progressByTarget.has(undefined), false)
+ }
+ finally {
+ setConfigWriteTarget(previousTarget)
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
+await test('registerLiveUpdateTools returns a concrete root config source for ambiguous continuation', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'capgo-live-update-root-config-'))
+ const rootConfig = join(root, 'capacitor.config.json')
+ const customConfig = join(root, 'capacitor.config.qr-code-reader.ts')
+ const previousCwd = process.cwd()
+ const previousTarget = getConfigWriteTarget()
+ const observedTargets = []
+ const progressByTarget = new Map()
+ try {
+ writeFileSync(join(root, 'package.json'), '{}')
+ writeFileSync(rootConfig, JSON.stringify({ appId: 'com.acme.shared', appName: 'Shared', webDir: 'www' }))
+ writeFileSync(customConfig, 'export default {}\n')
+ process.chdir(root)
+ setConfigWriteTarget(undefined)
+ const server = fakeServer()
+ registerLiveUpdateTools(server, null, fakeDeps({
+ cwd: root,
+ getAppId: async () => 'com.acme.shared',
+ loadProgress: () => progressByTarget.get(getConfigWriteTarget()) ?? null,
+ saveProgress: data => progressByTarget.set(getConfigWriteTarget(), data),
+ clearProgress: () => progressByTarget.delete(getConfigWriteTarget()),
+ setupEncryption: async (_appId, enable) => {
+ observedTargets.push(getConfigWriteTarget())
+ return { ok: true, enabled: enable }
+ },
+ }))
+
+ const rootStarted = await server.tools.start_capgo_live_update_onboarding.handler({})
+ ok(rootStarted.content[0].text.includes(rootConfig))
+ await server.tools.start_capgo_live_update_onboarding.handler({ capacitorConfig: './capacitor.config.qr-code-reader.ts' })
+
+ let ambiguousError
+ try {
+ await server.tools.capgo_live_update_onboarding_next_step.handler({ encryptionChoice: 'enable' })
+ }
+ catch (error) {
+ ambiguousError = error
+ }
+ ok(String(ambiguousError).includes('Multiple Capacitor config sources'))
+
+ await server.tools.capgo_live_update_onboarding_next_step.handler({ capacitorConfig: rootConfig, resumeChoice: 'continue', encryptionChoice: 'enable' })
+ eq(observedTargets[0], realConfigPath(rootConfig))
+ }
+ finally {
+ process.chdir(previousCwd)
+ setConfigWriteTarget(previousTarget)
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
+await test('registerLiveUpdateTools drops completed config sources from pathless routing', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'capgo-live-update-complete-config-'))
+ const firstTarget = join(root, 'capacitor.config.first.ts')
+ const secondTarget = join(root, 'capacitor.config.second.ts')
+ const previousTarget = getConfigWriteTarget()
+ const progressByTarget = new Map()
+ const observedTargets = []
+ try {
+ writeFileSync(firstTarget, 'export default {}\n')
+ writeFileSync(secondTarget, 'export default {}\n')
+ progressByTarget.set(realConfigPath(firstTarget), { step_done: 4, appId: 'com.acme.shared' })
+ setConfigWriteTarget(undefined)
+ const server = fakeServer()
+ registerLiveUpdateTools(server, null, fakeDeps({
+ cwd: root,
+ getAppId: async () => 'com.acme.shared',
+ loadProgress: () => progressByTarget.get(getConfigWriteTarget()) ?? null,
+ saveProgress: data => progressByTarget.set(getConfigWriteTarget(), data),
+ clearProgress: () => progressByTarget.delete(getConfigWriteTarget()),
+ setupEncryption: async (_appId, enable) => {
+ observedTargets.push(getConfigWriteTarget())
+ return { ok: true, enabled: enable }
+ },
+ }))
+
+ const firstStarted = await server.tools.start_capgo_live_update_onboarding.handler({ capacitorConfig: './capacitor.config.first.ts' })
+ ok(firstStarted.content[0].text.includes('"state": "resume-prompt"'))
+
+ progressByTarget.set(realConfigPath(firstTarget), { step_done: 12, appId: 'com.acme.shared' })
+ withConfigWriteTarget(realConfigPath(firstTarget), () => mergeSession('com.acme.shared', { resumeResolved: true }))
+ const completed = await server.tools.capgo_live_update_onboarding_next_step.handler({ capacitorConfig: './capacitor.config.first.ts' })
+ ok(completed.content[0].text.includes('"state": "completion"'))
+ await server.tools.start_capgo_live_update_onboarding.handler({ capacitorConfig: './capacitor.config.second.ts' })
+ await server.tools.capgo_live_update_onboarding_next_step.handler({ resumeChoice: 'continue', encryptionChoice: 'enable' })
+ eq(observedTargets[0], realConfigPath(secondTarget))
+ }
+ finally {
+ setConfigWriteTarget(previousTarget)
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
+await test('registerLiveUpdateTools retains a failed source for a pathless retry', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'capgo-live-update-retry-config-'))
+ const target = join(root, 'capacitor.config.retry.ts')
+ const previousTarget = getConfigWriteTarget()
+ const progressByTarget = new Map()
+ const installTargets = []
+ let installAttempts = 0
+ try {
+ writeFileSync(target, 'export default {}\n')
+ withConfigWriteTarget(realConfigPath(target), () => mergeSession('com.acme.shared', { resumeResolved: true }))
+ setConfigWriteTarget(undefined)
+ const server = fakeServer()
+ registerLiveUpdateTools(server, null, fakeDeps({
+ cwd: root,
+ getAppId: async () => 'com.acme.shared',
+ loadProgress: () => progressByTarget.get(getConfigWriteTarget()) ?? null,
+ saveProgress: data => progressByTarget.set(getConfigWriteTarget(), data),
+ clearProgress: () => progressByTarget.delete(getConfigWriteTarget()),
+ installUpdater: async () => {
+ installTargets.push(getConfigWriteTarget())
+ installAttempts++
+ return installAttempts === 1
+ ? { ok: false, error: 'temporary install failure' }
+ : { ok: true, delta: false, currentVersion: '1.0.0' }
+ },
+ }))
+
+ const failed = await server.tools.start_capgo_live_update_onboarding.handler({ capacitorConfig: './capacitor.config.retry.ts' })
+ ok(failed.content[0].text.includes('temporary install failure'))
+
+ await server.tools.capgo_live_update_onboarding_next_step.handler({})
+ eq(installTargets[0], realConfigPath(target))
+ eq(installTargets[1], realConfigPath(target))
+ }
+ finally {
+ setConfigWriteTarget(previousTarget)
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
+await test('registerLiveUpdateTools isolates concurrent same-app config writes', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'capgo-live-update-concurrent-config-'))
+ const firstTarget = join(root, 'capacitor.config.first.ts')
+ const secondTarget = join(root, 'capacitor.config.second.ts')
+ const previousTarget = getConfigWriteTarget()
+ const progressByTarget = new Map()
+ const observedTargets = []
+ const installTargets = []
+ let writersReady = 0
+ let releaseWriters = () => {}
+ const writersStarted = new Promise((resolve) => {
+ releaseWriters = resolve
+ })
+ try {
+ writeFileSync(firstTarget, 'export default {}\n')
+ writeFileSync(secondTarget, 'export default {}\n')
+ withConfigWriteTarget(realConfigPath(firstTarget), () => mergeSession('com.acme.shared', { resumeResolved: true }))
+ withConfigWriteTarget(realConfigPath(secondTarget), () => mergeSession('com.acme.shared', { resumeResolved: true }))
+ setConfigWriteTarget(undefined)
+ const server = fakeServer()
+ registerLiveUpdateTools(server, null, fakeDeps({
+ cwd: root,
+ getAppId: async () => 'com.acme.shared',
+ loadProgress: () => progressByTarget.get(getConfigWriteTarget()) ?? null,
+ saveProgress: data => progressByTarget.set(getConfigWriteTarget(), data),
+ clearProgress: () => progressByTarget.delete(getConfigWriteTarget()),
+ installUpdater: async () => {
+ writersReady++
+ if (writersReady === 2)
+ releaseWriters()
+ await writersStarted
+ const target = getConfigWriteTarget()
+ installTargets.push(target)
+ writeFileSync(target, target === realConfigPath(firstTarget) ? 'first\n' : 'second\n')
+ return { ok: true, delta: false, currentVersion: '1.0.0' }
+ },
+ setupEncryption: async (_appId, enable) => {
+ observedTargets.push(getConfigWriteTarget())
+ return { ok: true, enabled: enable }
+ },
+ }))
+
+ const startResults = await Promise.all([
+ server.tools.start_capgo_live_update_onboarding.handler({ capacitorConfig: './capacitor.config.first.ts' }),
+ server.tools.start_capgo_live_update_onboarding.handler({ capacitorConfig: './capacitor.config.second.ts' }),
+ ])
+ const startStates = startResults.map(result => result.content[0].text.match(/"state": "([^"]+)"/)?.[1])
+ ok(installTargets.includes(realConfigPath(firstTarget)), `missing first config target: ${JSON.stringify(installTargets)}; states: ${JSON.stringify(startStates)}`)
+ ok(installTargets.includes(realConfigPath(secondTarget)), `missing second config target: ${JSON.stringify(installTargets)}; states: ${JSON.stringify(startStates)}`)
+
+ eq(readFileSync(firstTarget, 'utf8'), 'first\n')
+ eq(readFileSync(secondTarget, 'utf8'), 'second\n')
+
+ let ambiguousError
+ try {
+ await server.tools.capgo_live_update_onboarding_next_step.handler({ encryptionChoice: 'enable' })
+ }
+ catch (error) {
+ ambiguousError = error
+ }
+ ok(String(ambiguousError).includes('Multiple Capacitor config sources'))
+
+ await server.tools.capgo_live_update_onboarding_next_step.handler({ capacitorConfig: './capacitor.config.first.ts', resumeChoice: 'continue', encryptionChoice: 'enable' })
+ await server.tools.capgo_live_update_onboarding_next_step.handler({ capacitorConfig: './capacitor.config.second.ts', resumeChoice: 'continue', encryptionChoice: 'enable' })
+ eq(observedTargets[0], realConfigPath(firstTarget))
+ eq(observedTargets[1], realConfigPath(secondTarget))
+ eq(getConfigWriteTarget(), undefined)
+ }
+ finally {
+ setConfigWriteTarget(previousTarget)
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
console.log(`\n๐ Results: ${pass} passed, ${fail} failed`)
if (fail > 0)
process.exit(1)
diff --git a/cli/webdocs/app.mdx b/cli/webdocs/app.mdx
index 6b04b811ab..996766448f 100644
--- a/cli/webdocs/app.mdx
+++ b/cli/webdocs/app.mdx
@@ -121,12 +121,15 @@ Specify setting path (e.g., plugins.CapacitorUpdater.defaultChannel) with --stri
npx @capgo/cli@latest app setting plugins.CapacitorUpdater.defaultChannel --string "Production"
```
+For dynamic monorepos, keep the root config selector active and add `--capacitor-config ./env-configs/capacitor.config.qr-code-reader.ts` to write this setting to the selected app source.
+
**Options:**
| Param | Type | Description |
| -------------- | ------------- | -------------------- |
| **--bool** | string | A value for the setting to modify as a boolean, ex: --bool true |
| **--string** | string | A value for the setting to modify as a string, ex: --string "Production" |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
### โ๏ธ **Set**
diff --git a/cli/webdocs/bundle.mdx b/cli/webdocs/bundle.mdx
index 764112433d..4a84c667ec 100644
--- a/cli/webdocs/bundle.mdx
+++ b/cli/webdocs/bundle.mdx
@@ -28,6 +28,20 @@ Capgo never inspects external content. Add encryption for trustless security.
npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production,beta
```
+### Dynamic monorepos
+
+When a root `capacitor.config.ts` selects an app-specific source through an environment variable, keep that selector active and pass the source Capgo should write. This is especially useful with `--auto-set-bundle`:
+
+```bash
+CAP_APP=qr-code-reader npx @capgo/cli@latest bundle upload com.example.app \
+ --path ./dist/qr-code-reader/browser \
+ --channel production \
+ --auto-set-bundle \
+ --capacitor-config ./env-configs/capacitor.config.qr-code-reader.ts
+```
+
+Capgo loads the root config selected by `CAP_APP` and updates only the supplied source file.
+
**Options:**
| Param | Type | Description |
@@ -73,7 +87,8 @@ npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel prod
| **--delta-only** | boolean | Upload only delta updates without full bundle for maximum speed (useful for large apps) |
| **--no-delta** | boolean | Disable delta updates even if instant updates are enabled |
| **--encrypted-checksum** | string | An encrypted checksum (signature). Used only when uploading an external bundle. |
-| **--auto-set-bundle** | boolean | Set the bundle in capacitor.config.json |
+| **--auto-set-bundle** | boolean | Set the bundle version in Capacitor config |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
| **--dry-upload** | boolean | Dry upload the bundle process: add the row in database without uploading files or updating channels (Used by Capgo for internal testing) |
| **--package-json** | string | Paths to package.json files for monorepos (comma-separated) |
| **--node-modules** | string | Paths to node_modules directories for monorepos (comma-separated) |
diff --git a/cli/webdocs/init.mdx b/cli/webdocs/init.mdx
index 16684b73b4..4afd5c2683 100644
--- a/cli/webdocs/init.mdx
+++ b/cli/webdocs/init.mdx
@@ -24,13 +24,28 @@ During the iOS run-on-device step, choose a physical iPhone/iPad or simulator. I
npx @capgo/cli@latest init YOUR_API_KEY com.example.app
```
+### Monorepos
+
+When the Capacitor app is not the repository root, pass the exact project files that Capgo should use. This is useful when a root `capacitor.config.ts` selects an app-specific config through an environment variable:
+
+```bash
+CAP_APP=qr-code-reader npx @capgo/cli@latest init \
+ --package-json ./package.json \
+ --main-file ./projects/qr-code-reader/src/main.ts \
+ --capacitor-config ./env-configs/capacitor.config.qr-code-reader.ts
+```
+
+Capgo uses the selected package for dependency checks, the selected entry file for `notifyAppReady()`, and loads the active root config while writing only the supplied config source. The same global `--capacitor-config` option also targets config changes made by `bundle upload --auto-set-bundle`, `app setting`, key commands, and `notifications setup`.
+
## Options (Init)
-| Param | Type | Description |
-| -------------- | ------------- | -------------------- |
+| Param | Type | Description |
+| --- | --- | --- |
| **-n** | string | App name for display in Capgo Cloud |
| **-i** | string | App icon path for display in Capgo Cloud |
| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) |
| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) |
+| **--package-json** | string | Package JSON for the Capacitor app to onboard in a monorepo |
+| **--main-file** | string | Application entry file where Capgo adds `notifyAppReady()` |
+| **--capacitor-config** | string | Capacitor config source file to update in a monorepo |
| **--no-analytics** | boolean | Disable init analytics and terminal replay for this run |
-
diff --git a/cli/webdocs/key.mdx b/cli/webdocs/key.mdx
index 14529df92b..bdb714b96e 100644
--- a/cli/webdocs/key.mdx
+++ b/cli/webdocs/key.mdx
@@ -18,6 +18,8 @@ npx @capgo/cli@latest key save
๐พ Save the public key in the Capacitor config, useful for CI environments.
Recommended not to commit the key for security.
+For dynamic monorepos, add `--capacitor-config ./env-configs/capacitor.config.qr-code-reader.ts` to `key save`, `key create`, or `key delete_old` so the key change is written to the selected app source.
+
**Example:**
```bash
@@ -31,6 +33,7 @@ npx @capgo/cli@latest key save --key ./path/to/key.pub
| **-f** | boolean | Force generate a new one |
| **--key** | string | Key path to save in Capacitor config |
| **--key-data** | string | Key data to save in Capacitor config |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
### ๐จ **Create**
@@ -54,6 +57,7 @@ npx @capgo/cli@latest key create
| Param | Type | Description |
| -------------- | ------------- | -------------------- |
| **-f** | boolean | Force generate a new one |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
### ๐๏ธ **Delete_old**
@@ -69,3 +73,8 @@ npx @capgo/cli@latest key delete_old
npx @capgo/cli@latest key delete_old
```
+**Options:**
+
+| Param | Type | Description |
+| -------------- | ------------- | -------------------- |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |
diff --git a/cli/webdocs/mcp.mdx b/cli/webdocs/mcp.mdx
index e7f7c16c3f..2338e2038f 100644
--- a/cli/webdocs/mcp.mdx
+++ b/cli/webdocs/mcp.mdx
@@ -12,32 +12,47 @@ sidebar:
npx @capgo/cli@latest mcp
```
-This command starts an MCP server that exposes Capgo functionality as tools for AI agents.
-The server communicates via stdio and is designed for non-interactive, programmatic use.
-Available tools exposed via MCP:
- - capgo_list_apps, capgo_add_app, capgo_update_app, capgo_delete_app
- - capgo_upload_bundle, capgo_list_bundles, capgo_delete_bundle, capgo_cleanup_bundles
- - capgo_list_channels, capgo_add_channel, capgo_update_channel, capgo_delete_channel
- - capgo_get_current_bundle, capgo_check_compatibility
- - capgo_list_organizations, capgo_add_organization
- - capgo_star_repository
- - capgo_star_all_repositories
- - capgo_get_account_id, capgo_doctor, capgo_get_stats
- - capgo_request_build, capgo_generate_encryption_keys
-Example usage with Claude Desktop:
- Add to claude_desktop_config.json:
- {
- "mcpServers": {
- "capgo": {
- "command": "npx",
- "args": ["@capgo/cli", "mcp"]
- }
- }
- }
+This command starts an MCP server that exposes Capgo functionality as tools for AI agents. The server communicates via stdio and is designed for non-interactive, programmatic use.
+
+### Dynamic monorepos
-**Example:**
+When a root `capacitor.config.ts` selects an app-specific source through an environment variable, launch the MCP server with the same selector and target source:
```bash
-npx @capgo/cli mcp
+CAP_APP=qr-code-reader npx @capgo/cli@latest mcp \
+ --capacitor-config ./env-configs/capacitor.config.qr-code-reader.ts
+```
+
+Capacitor continues to load the active root config, while every MCP tool that writes Capacitor config updates only the supplied app source. The `capgo_upload_bundle` and `capgo_generate_encryption_keys` tools also accept a per-call `capacitorConfig` override.
+The live-update onboarding start tool accepts `capacitorConfig` when the server was launched without this option. If more than one source resolves to the same app ID, pass that same value from the returned context to the onboarding next-step and explain tools.
+
+Selected tools exposed via MCP:
+
+- capgo_list_apps, capgo_add_app, capgo_update_app, capgo_delete_app
+- capgo_upload_bundle, capgo_list_bundles, capgo_delete_bundle, capgo_cleanup_bundles
+- capgo_list_channels, capgo_add_channel, capgo_update_channel, capgo_delete_channel
+- capgo_get_current_bundle, capgo_check_compatibility
+- capgo_list_organizations, capgo_add_organization
+- capgo_star_repository
+- capgo_star_all_repositories
+- capgo_get_account_id, capgo_doctor, capgo_get_stats
+- capgo_request_build, capgo_generate_encryption_keys
+
+Example usage with Claude Desktop: add this to `claude_desktop_config.json`.
+
+```json
+{
+ "mcpServers": {
+ "capgo": {
+ "command": "npx",
+ "args": ["@capgo/cli@latest", "mcp"]
+ }
+ }
+}
```
+**Options:**
+
+| Param | Type | Description |
+| --- | --- | --- |
+| **--capacitor-config** | string | Capacitor config source to update for the lifetime of the MCP server |
diff --git a/cli/webdocs/notifications.mdx b/cli/webdocs/notifications.mdx
index d0d7e595f3..a246c4e62a 100644
--- a/cli/webdocs/notifications.mdx
+++ b/cli/webdocs/notifications.mdx
@@ -18,6 +18,8 @@ npx @capgo/cli@latest notifications setup
Install the Capgo notifications plugin, add Capacitor config, create a helper file, and run Capacitor sync.
Before sending production notifications, configure Android and iOS push credentials in the Capgo app Notifications tab.
+For dynamic monorepos, keep the root config selector active and add `--capacitor-config ./env-configs/capacitor.config.qr-code-reader.ts` so the notification configuration is written to the selected app source.
+
**Example:**
```bash
@@ -33,3 +35,4 @@ npx @capgo/cli@latest notifications setup com.example.app
| **--force** | boolean | Overwrite the helper file if it already exists |
| **--no-install** | boolean | Skip installing the notifications package |
| **--no-sync** | boolean | Skip Capacitor sync |
+| **--capacitor-config** | string | Capacitor config source to update (useful with dynamic monorepo configs) |