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
3 changes: 2 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
"test:mcp-build-job": "bun test/test-mcp-build-job.mjs",
"test:mcp-build-tools": "bun test/test-mcp-build-tools.mjs",
"test:app-created-source": "bun test/test-app-created-source.mjs",
"test:app-add-exists": "bun test/test-app-add-exists.mjs",
"test:app-list-output-text": "bun test/test-app-list-output-text.mjs",
"test:doctor-analytics": "bun test/test-doctor-analytics.mjs",
"test:posthog-exception": "bun test/test-posthog-exception.mjs",
Expand Down Expand Up @@ -175,7 +176,7 @@
"test:android-version": "bun test/test-android-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:bundle-validation && bun run test:functional && bun run test:semver && bun run test:auto-bump-version && bun run test:auto-bump-ai-diff && 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:package-json-guard && bun run test:credentials && bun run test:credentials-export && 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:build-cancellation && 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:cli-headers && bun run test:min-cli-version && bun run test:authenticated-command-invocation && 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:app-list-output-text && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:cli-recovery && bun run test:create-supabase-client && bun run test:build-platform-selection && bun run test:builder-project-discovery && 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:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-replay && bun run test:init-telemetry && 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:android-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:2fa-compliance-network && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation",
"test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:bundle-validation && bun run test:functional && bun run test:semver && bun run test:auto-bump-version && bun run test:auto-bump-ai-diff && 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:package-json-guard && bun run test:credentials && bun run test:credentials-export && 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:build-cancellation && 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:cli-headers && bun run test:min-cli-version && bun run test:authenticated-command-invocation && 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:app-add-exists && bun run test:app-list-output-text && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:cli-recovery && bun run test:create-supabase-client && bun run test:build-platform-selection && bun run test:builder-project-discovery && 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:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-replay && bun run test:init-telemetry && 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:android-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:2fa-compliance-network && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation",
"test:build-platform-selection": "bun test/test-build-platform-selection.mjs",
"test:builder-project-discovery": "bun test/test-builder-project-discovery.mjs",
"test:ai-log-capture": "bun test/test-ai-log-capture.mjs",
Expand Down
226 changes: 210 additions & 16 deletions cli/src/app/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import type { Organization } from '../utils'
import { existsSync, readFileSync } from 'node:fs'
import { intro, log, outro } from '@clack/prompts'
import { buildCliRequestHeaders } from '../analytics/cli-headers'
import { getInvocationSource } from '../analytics/track'
import { getInvocationSource, trackEvent } from '../analytics/track'
import { getAppIconStoragePath, newIconPath } from '../api/app'
import { getAppListPath } from './list'
import { checkAlerts } from '../api/update'
import { isAiAgentEnvironment } from '../init/onboarding-source'
import { CliUserError } from '../shared/cli-user-error'
Expand All @@ -16,9 +17,11 @@ import {
formatCapgoApiErrorBody,
formatError,
getAppId,
getCapgoCliHttpStatus,
getConfig,
getContentType,
getOrganizationWithPermission,
invokeCapgoCliApi,
resolveCapgoPublicApiHost,
resolveUserIdFromApiKey,
sendEvent,
Expand Down Expand Up @@ -78,6 +81,149 @@ export function isStorageObjectConflict(error: unknown) {
return status === 409 || statusCode === '409'
}

export function isDuplicateAppCreateError(error: unknown, httpStatus?: number) {
if (httpStatus === 409)
return true
const message = formatError(error).toLowerCase()
return message.includes('app_id_already_exists') || message.includes('app id already exists')
}

export type AppAddDuplicateOutcome = 'duplicate_owned' | 'duplicate_taken' | 'not_duplicate'

type AppListRow = { app_id?: string }

async function isAppListedInOrganization(
apikey: string,
appId: string,
ownerOrg: string,
options?: { supaHost?: string, supaAnon?: string },
): Promise<boolean | null> {
let page = 0
while (true) {
const { data, error } = await invokeCapgoCliApi<AppListRow[]>(
getAppListPath(page, ownerOrg),
{
apikey,
method: 'GET',
body: undefined,
supaHost: options?.supaHost,
supaAnon: options?.supaAnon,
},
)

if (error) {
const status = getCapgoCliHttpStatus(error)
if (status === 400 || status === 401 || status === 403)
return null
throw error
}

const batch = Array.isArray(data) ? data : []
if (batch.some(row => row.app_id === appId))
return true
if (batch.length < 50)
return false
page += 1
}
}

async function isAppInTargetOrganization(
apikey: string,
appId: string,
ownerOrg: string,
options?: { supaHost?: string, supaAnon?: string },
): Promise<boolean | null> {
const { data, error } = await invokeCapgoCliApi<{ owner_org?: string }>(
`app/${encodeURIComponent(appId)}`,
{
apikey,
method: 'GET',
body: undefined,
supaHost: options?.supaHost,
supaAnon: options?.supaAnon,
},
)

if (error) {
const status = getCapgoCliHttpStatus(error)
if (status === 404)
return false
if (status === 401 || status === 403)
return null
throw error
}

return data?.owner_org === ownerOrg
}

async function isDuplicateAppOwnedByCaller(
params: {
apikey: string
appId: string
ownerOrg: string
supaHost?: string
supaAnon?: string
},
deps: {
isAppInTargetOrganization?: typeof isAppInTargetOrganization
isAppListedInOrganization?: typeof isAppListedInOrganization
} = {},
): Promise<boolean> {
const lookup = deps.isAppInTargetOrganization ?? isAppInTargetOrganization
const appInTargetOrg = await lookup(
params.apikey,
params.appId,
params.ownerOrg,
{ supaHost: params.supaHost, supaAnon: params.supaAnon },
)

if (appInTargetOrg === true)
return true
if (appInTargetOrg === false)
return false

const listAppsInOrg = deps.isAppListedInOrganization ?? isAppListedInOrganization
const listedInOrg = await listAppsInOrg(
params.apikey,
params.appId,
params.ownerOrg,
{ supaHost: params.supaHost, supaAnon: params.supaAnon },
)
if (listedInOrg === true)
return true
if (listedInOrg === false)
return false

throw new Error('Cannot verify app ownership for this API key. Grant app.read or org.read, then retry.')
}

export async function resolveAppAddDuplicateOutcome(
params: {
apikey: string
appId: string
ownerOrg: string
createError: unknown
httpStatus?: number
supaHost?: string
supaAnon?: string
},
deps: {
isAppInTargetOrganization?: typeof isAppInTargetOrganization
isAppListedInOrganization?: typeof isAppListedInOrganization
} = {},
): Promise<AppAddDuplicateOutcome> {
if (!isDuplicateAppCreateError(params.createError, params.httpStatus))
return 'not_duplicate'

try {
const owned = await isDuplicateAppOwnedByCaller(params, deps)
return owned ? 'duplicate_owned' : 'duplicate_taken'
}
catch (error) {
throw new Error(formatError(error))
}
}

async function createAppViaApi(
apikey: string,
params: {
Expand Down Expand Up @@ -124,7 +270,9 @@ async function createAppViaApi(
const data = await response.json().catch(() => null)
if (!response.ok) {
const details = formatCapgoApiErrorBody(data) || `HTTP ${response.status}`
throw new Error(details)
const error = new Error(details) as Error & { httpStatus?: number }
error.httpStatus = response.status
throw error
}

const createdAppId = (data as { app_id?: string } | null)?.app_id
Expand Down Expand Up @@ -233,6 +381,7 @@ export async function addAppInternal(
? 'mcp'
: isAiAgentEnvironment() ? 'ai' : 'cli'

let appAlreadyExists = false
try {
// Use the same authorized API path as the web console. Direct PostgREST inserts
// hit apps/storage RLS and fail for common API-key + pending-onboarding setups.
Expand All @@ -248,24 +397,69 @@ export async function addAppInternal(
})
}
catch (error) {
const message = formatError(error)
if (!silent)
log.error(`Could not add app ${message}`)
throw new Error(`Could not add app ${message}`)
let duplicateOutcome: AppAddDuplicateOutcome
try {
duplicateOutcome = await resolveAppAddDuplicateOutcome({
apikey: options.apikey!,
appId,
ownerOrg: organizationUid,
createError: error,
httpStatus: (error as { httpStatus?: number }).httpStatus,
supaHost: options.supaHost,
supaAnon: options.supaAnon,
})
}
catch (ownershipError) {
const message = formatError(ownershipError)
if (!silent)
log.error(`Could not add app ${message}`)
throw new Error(`Could not add app ${message}`)
}

if (duplicateOutcome === 'duplicate_owned') {
appAlreadyExists = true
}
else if (duplicateOutcome === 'duplicate_taken') {
const takenMessage = `App ID ${appId} already exists`
if (!silent)
log.error(`Could not add app: ${takenMessage}`)
throw new Error(`Could not add app: ${takenMessage}`)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else {
const message = formatError(error)
if (!silent)
log.error(`Could not add app ${message}`)
throw new Error(`Could not add app ${message}`)
}
}

await sendEvent(options.apikey!, {
channel: 'app',
event: 'App Created',
icon: '🆕',
org_id: organizationUid,
tracking_version: 2,
tags: { 'app-id': appId, 'source': appCreateSource },
notifyConsole: true,
}).catch(() => {})
if (appAlreadyExists) {
void trackEvent({
channel: 'app',
event: 'CLI Recovered App Already Exists',
appId,
apikey: options.apikey!,
orgId: organizationUid,
tags: { source: appCreateSource },
})
}
else {
await sendEvent(options.apikey!, {
channel: 'app',
event: 'App Created',
icon: '🆕',
org_id: organizationUid,
tracking_version: 2,
tags: { 'app-id': appId, 'source': appCreateSource },
notifyConsole: true,
}).catch(() => {})
}

if (!silent) {
log.success(`App ${appId} added to Capgo`)
if (appAlreadyExists)
log.success(`App ${appId} already exists in Capgo`)
else
log.success(`App ${appId} added to Capgo`)
log.info(`This app is accessible to all members of your organization based on their permissions`)
log.info(`Next step: upload a bundle with "npx @capgo/cli bundle upload ${appId}"`)
outro('Done ✅')
Expand Down
Loading
Loading