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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 12 additions & 141 deletions bun.lock

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,9 @@
"qrcode": "^1.5.4",
"react": "^19.2.8",
"rrweb-snapshot": "^2.1.1",
"string-width": "^8.2.2"
"string-width": "^8.2.2",
"arktype": "^2.2.0",
"@standard-schema/spec": "^1.1.0"
},
"optionalDependencies": {
"@capgo/cli-helper-darwin-arm64": "^1.1.1",
Expand All @@ -230,7 +232,8 @@
"@capacitor/cli": "^8.4.2",
"@capgo/find-package-manager": "^0.0.18",
"@clack/prompts": "^1.7.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/client": "^2.0.0-beta.5",
"@modelcontextprotocol/server": "^2.0.0-beta.5",
"@sauber/table": "npm:@jsr/sauber__table",
"@std/semver": "npm:@jsr/std__semver@1.0.8",
"@supabase/supabase-js": "^2.110.8",
Expand Down Expand Up @@ -262,7 +265,6 @@
"tmp": "^0.2.7",
"tus-js-client": "^4.3.1",
"typescript": "6.0.3",
"ws": "^8.21.1",
"zod": "^4.4.3"
"ws": "^8.21.1"
}
}
23 changes: 23 additions & 0 deletions cli/src/build/onboarding/mcp/build-tool-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type } from '../../../schemas/arktype'

export const startCapgoBuildInputSchema = type({
'+': 'delete',
platform: type("'ios' | 'android'").describe('The platform to build: "ios" or "android".'),
})

export const capgoBuildWaitInputSchema = type({
'+': 'delete',
job_id: type('string').describe('The job_id returned by start_capgo_build.'),
'timeout_seconds?': type('1 <= number.integer <= 59').describe('How long to wait this call, in seconds. Default 40, maximum 59 (kept under the MCP tool-call timeout). Pass a larger value to wait longer in one call; the build keeps running regardless.'),
})

export const capgoBuildLogsInputSchema = type({
'+': 'delete',
job_id: type('string').describe('The job_id returned by start_capgo_build.'),
'cursor?': type('number.integer >= 0').describe('Where to read from. Pass 0 the first time, then the next_cursor from the previous call to get only new lines.'),
})

export const cancelCapgoBuildInputSchema = type({
'+': 'delete',
job_id: type('string').describe('The job_id returned by start_capgo_build.'),
})
45 changes: 20 additions & 25 deletions cli/src/build/onboarding/mcp/build-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,16 @@ import { closeSync, mkdirSync, openSync } from 'node:fs'
import { open, stat } from 'node:fs/promises'
import { Buffer } from 'node:buffer'
import { dirname } from 'node:path'
import { z } from 'zod'
import type { McpRegistrar } from '../../../mcp/registrar.js'
import { cancelCapgoBuildInputSchema, capgoBuildLogsInputSchema, capgoBuildWaitInputSchema, startCapgoBuildInputSchema } from './build-tool-schemas.js'
import { getLogCapturePath } from '../../../ai/log-capture.js'
import { defaultBuildRecordPath, readBuildOutputRecord, removeBuildOutputRecord } from '../../output-record.js'
import type { BuildChild, BuildJobDeps, BuildJobResult } from './build-job.js'
import { buildLogs, cancelBuild, startBuild, waitBuild } from './build-job.js'
import type { NextStepResult, Platform } from './contract.js'
import { ONBOARDING_RULES, renderResult } from './contract.js'

/** Minimal shape of the MCP server's tool registrar (matches McpServer.tool / McpLike). */
interface ToolRegistrar {
tool: (
name: string,
description: string,
schema: Record<string, unknown>,
handler: (args: any) => Promise<{ content: Array<{ type: 'text', text: string }> }>,
) => unknown
}


/** Per-(appId, platform) local log file path, kept filesystem-safe. */
function buildLogPath(appId: string, platform: Platform): string {
Expand Down Expand Up @@ -235,14 +228,16 @@ const MAX_LOG_CHARS = 8000
* buildJobDeps; injectable fakes in tests).
*/
export function registerBuildTools(
server: ToolRegistrar,
server: McpRegistrar,
getAppId: () => Promise<string | undefined>,
deps: BuildJobDeps,
): void {
server.tool(
server.registerTool(
'start_capgo_build',
'Start the first cloud build for this app on the given platform. The build runs in Capgo\'s cloud and takes a few minutes; this returns immediately with a job_id you use to track it. Idempotent — if a build for this app and platform is already running, it returns that same job_id instead of starting another.',
{ platform: z.enum(['ios', 'android']).describe('The platform to build: "ios" or "android".') },
{
description: 'Start the first cloud build for this app on the given platform. The build runs in Capgo\'s cloud and takes a few minutes; this returns immediately with a job_id you use to track it. Idempotent — if a build for this app and platform is already running, it returns that same job_id instead of starting another.',
inputSchema: startCapgoBuildInputSchema,
},
async (args: { platform: Platform }) => {
const appId = await getAppId()
if (!appId) {
Expand All @@ -253,25 +248,23 @@ export function registerBuildTools(
},
)

server.tool(
server.registerTool(
'capgo_build_wait',
'Wait for a running cloud build to finish. Blocks for up to timeout_seconds and returns the moment the build completes, fails, or is cancelled; if it\'s still building when the time is up, it returns status "running" — call this again to keep waiting. This is the main way to make progress on a build.',
{
job_id: z.string().describe('The job_id returned by start_capgo_build.'),
timeout_seconds: z.number().int().min(1).max(59).optional().describe('How long to wait this call, in seconds. Default 40, maximum 59 (kept under the MCP tool-call timeout). Pass a larger value to wait longer in one call; the build keeps running regardless.'),
description: 'Wait for a running cloud build to finish. Blocks for up to timeout_seconds and returns the moment the build completes, fails, or is cancelled; if it\'s still building when the time is up, it returns status "running" — call this again to keep waiting. This is the main way to make progress on a build.',
inputSchema: capgoBuildWaitInputSchema,
},
async (args: { job_id: string, timeout_seconds?: number }) => {
const r = await waitBuild(deps, { jobId: args.job_id, timeoutSeconds: args.timeout_seconds })
return text(renderWait(r))
},
)

server.tool(
server.registerTool(
'capgo_build_logs',
'Fetch new build log output since cursor, to summarize progress or explain a failure. Returns the new text, the next cursor, and whether the log is complete. The user can already watch the full live logs locally — use this only when you need to read the logs yourself. Logs may contain sensitive build output: summarize, don\'t paste them verbatim.',
{
job_id: z.string().describe('The job_id returned by start_capgo_build.'),
cursor: z.number().int().min(0).optional().describe('Where to read from. Pass 0 the first time, then the next_cursor from the previous call to get only new lines.'),
description: 'Fetch new build log output since cursor, to summarize progress or explain a failure. Returns the new text, the next cursor, and whether the log is complete. The user can already watch the full live logs locally — use this only when you need to read the logs yourself. Logs may contain sensitive build output: summarize, don\'t paste them verbatim.',
inputSchema: capgoBuildLogsInputSchema,
},
async (args: { job_id: string, cursor?: number }) => {
const r = await buildLogs(deps, { jobId: args.job_id, cursor: args.cursor })
Expand All @@ -282,10 +275,12 @@ export function registerBuildTools(
},
)

server.tool(
server.registerTool(
'cancel_capgo_build',
'Cancel a running cloud build. Stops watching the build locally and returns. Only use this if the user explicitly asks to stop the build.',
{ job_id: z.string().describe('The job_id returned by start_capgo_build.') },
{
description: 'Cancel a running cloud build. Stops watching the build locally and returns. Only use this if the user explicitly asks to stop the build.',
inputSchema: cancelCapgoBuildInputSchema,
},
async (args: { job_id: string }) => {
const r = await cancelBuild(deps, { jobId: args.job_id })
return text(renderCancel(r))
Expand Down
43 changes: 18 additions & 25 deletions cli/src/build/onboarding/mcp/credentials-manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import type { BuildCredentials } from '../../../schemas/build.js'
import { homedir } from 'node:os'
import { extname, join, resolve, sep } from 'node:path'
import { cwd } from 'node:process'
import { z } from 'zod'
import { type } from 'arktype'
import type { McpRegistrar } from '../../../mcp/registrar.js'
import { readSafeFileBytes } from '../../../utils/safeWrites.js'
import {
loadSavedCredentials,
Expand Down Expand Up @@ -272,17 +273,16 @@ export async function runCredentialsManage(input: CredentialsManageInput, deps:
}
}

/** zod shape for the tool's arguments. */
export const credentialsManageSchema = {
action: z.enum(['list', 'export', 'set', 'remove']).describe('list = show which fields are saved (names only); export = write a platform\'s credentials to a local .env; set = add or edit one field; remove = delete one field.'),
platform: z.enum(['ios', 'android']).optional().describe('Required for export/set/remove. The platform whose credentials to act on (must already be set up).'),
key: z.string().optional().describe('For set/remove: the credential field name (e.g. KEYSTORE_STORE_PASSWORD, ANDROID_KEYSTORE_FILE, PLAY_CONFIG_JSON, P12_PASSWORD).'),
value: z.string().optional().describe('For set: the new value to save (never echoed back).'),
valueFile: z.string().optional().describe('For set: a path to a credential FILE (.keystore/.jks/.p12/.json/.p8/.cer/.mobileprovision) whose contents are base64-encoded into the value — use for FILE fields like ANDROID_KEYSTORE_FILE, PLAY_CONFIG_JSON, BUILD_CERTIFICATE_BASE64, or APPLE_KEY_CONTENT (the .p8 key). Non-credential files, and files in sensitive dirs (~/.ssh, ~/.aws, ~/.capgo-credentials), are refused.'),
path: z.string().optional().describe('For export: the target .env path (must stay inside the project dir). Defaults to .env.capgo.<appId>.<platform> in the project dir.'),
overwrite: z.boolean().optional().describe('For export: overwrite the target file if it already exists.'),
appId: z.string().optional().describe('App id to target. Defaults to the current Capacitor project.'),
}
export const credentialsManageSchema = type({
action: type("'list' | 'export' | 'set' | 'remove'").describe("list = show which fields are saved (names only); export = write a platform's credentials to a local .env; set = add or edit one field; remove = delete one field."),
'platform?': type("'ios' | 'android'").describe('Required for export/set/remove. The platform whose credentials to act on (must already be set up).'),
'key?': type('string').describe('For set/remove: the credential field name (e.g. KEYSTORE_STORE_PASSWORD, ANDROID_KEYSTORE_FILE, PLAY_CONFIG_JSON, P12_PASSWORD).'),
'value?': type('string').describe('For set: the new value to save (never echoed back).'),
'valueFile?': type('string').describe('For set: a path to a credential FILE (.keystore/.jks/.p12/.json/.p8/.cer/.mobileprovision) whose contents are base64-encoded into the value — use for FILE fields like ANDROID_KEYSTORE_FILE, PLAY_CONFIG_JSON, BUILD_CERTIFICATE_BASE64, or APPLE_KEY_CONTENT (the .p8 key). Non-credential files, and files in sensitive dirs (~/.ssh, ~/.aws, ~/.capgo-credentials), are refused.'),
'path?': type('string').describe('For export: the target .env path (must stay inside the project dir). Defaults to .env.capgo.<appId>.<platform> in the project dir.'),
'overwrite?': type('boolean').describe('For export: overwrite the target file if it already exists.'),
'appId?': type('string').describe('App id to target. Defaults to the current Capacitor project.'),
})

const CREDENTIALS_MANAGE_DESCRIPTION
= 'Manage Capgo Builder credentials that ALREADY exist for the app: export them to a local .env file, or add / edit / remove a single credential field (list / export / set / remove). '
Expand All @@ -292,27 +292,20 @@ const CREDENTIALS_MANAGE_DESCRIPTION
+ 'To replace a keystore / .p12 / service-account FILE, pass valueFile (a path to that credential file); the tool base64-encodes it. '
+ 'Secret values never leave through tool output: export writes them to a 0600 .env file, list shows field NAMES only, and set takes a value (or file) you provide without echoing it back.'

/** Minimal MCP server surface this registers against (mirrors onboarding-tools' McpLike.tool). */
interface ToolRegistrar {
tool: (
name: string,
description: string,
schema: Record<string, unknown>,
handler: (args: CredentialsManageInput) => Promise<{ content: Array<{ type: 'text', text: string }> }>,
) => unknown
}

/** Register `capgo_builder_credentials_manage` on the given MCP server, bound to a getAppId resolver. */
export function registerCredentialsManageTool(
server: ToolRegistrar,
server: McpRegistrar,
getAppId: () => Promise<string | undefined>,
depsOverride?: CredentialsManageDeps,
): void {
const deps = depsOverride ?? buildCredentialsManageDeps(getAppId)
server.tool(
server.registerTool(
'capgo_builder_credentials_manage',
CREDENTIALS_MANAGE_DESCRIPTION,
credentialsManageSchema,
{
description: CREDENTIALS_MANAGE_DESCRIPTION,
inputSchema: credentialsManageSchema,
},
async (args: CredentialsManageInput) => {
const text = await runCredentialsManage(args, deps)
return { content: [{ type: 'text' as const, text }] }
Expand Down
Loading
Loading