Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -109,6 +109,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 @@ -174,7 +175,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: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: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.
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