Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
134 changes: 134 additions & 0 deletions cli/src/bundle/builder-cta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { confirm as pConfirm, isCancel as pIsCancel, log } from '@clack/prompts'
import { trackEvent } from '../analytics/track'

export type BuilderCtaSurface = 'skip' | 'ci-ad' | 'prompt-onboarding' | 'prompt-build'
export type BuilderCtaAction = 'continue' | 'launch-onboarding' | 'launch-build'

export interface BuilderCtaContext {
incompatible: boolean
interactive: boolean
hasCredentials: boolean
}

/**
* Pure decision: which Builder CTA surface (if any) to show for this upload.
* - `skip`: do nothing (compatible bundle).
* - `ci-ad`: non-interactive — print a one-off ad, never prompt.
* - `prompt-onboarding` / `prompt-build`: interactive prompt, branched on
* whether the app already has build credentials.
*/
export function decideBuilderCtaSurface(ctx: BuilderCtaContext): BuilderCtaSurface {
if (!ctx.incompatible)
return 'skip'
if (!ctx.interactive)
return 'ci-ad'
return ctx.hasCredentials ? 'prompt-build' : 'prompt-onboarding'
}

const DOCS_URL = 'https://capgo.app/docs/cli/cloud-build/'
const LEARN_URL = 'https://capgo.app/native-build/'

// Why a native build is needed — folded into the prompt and the CI ad.
const REASON = 'This update includes native changes. An app store update may be required for these changes to take effect. Capgo Builder can help you build and publish the required native update.'

/**
* Render a clickable terminal hyperlink (OSC 8). Clicking it opens the URL in the
* browser **without dismissing the active prompt**. Terminals that don't support
* OSC 8 just render `text`.
*/
function terminalLink(text: string, url: string): string {
const ESC = String.fromCharCode(27) // \x1B
const BEL = String.fromCharCode(7) // \x07
return `${ESC}]8;;${url}${BEL}${text}${ESC}]8;;${BEL}`
}

export function printBuilderCiAd(hasCredentials: boolean): void {
const action = hasCredentials
? 'run a native build (npx @capgo/cli build request --platform <ios|android>)'
: 'set up Capgo Builder (npx @capgo/cli build onboarding)'
log.warn(`${REASON} To ${action} — learn more: ${LEARN_URL} · docs: ${DOCS_URL}`)
}

/** Confirm-prompt seam (`@clack/prompts` `confirm` satisfies it); injectable for tests. */
export type BuilderConfirm = (opts: { message: string, initialValue?: boolean }) => Promise<boolean | symbol>

export interface MaybePromptBuilderCtaParams {
incompatible: boolean
interactive: boolean
/** Whether the app already has saved build credentials (resolved by the caller). */
hasCredentials: boolean
appId: string
orgId: string
apikey: string
incompatibleCount: number
/** Injectable for tests; defaults to the `@clack/prompts` confirm prompt. */
confirm?: BuilderConfirm
}

/**
* Surface the Capgo Builder CTA for an incompatible upload and return the action
* the caller should take. Never throws; telemetry and prompt failures degrade to
* `continue` so the upload is never blocked by the CTA.
*/
export async function maybePromptBuilderCta(params: MaybePromptBuilderCtaParams): Promise<BuilderCtaAction> {
try {
return await runBuilderCta(params)
}
catch {
// A CTA failure (prompt, telemetry) must never block the upload.
return 'continue'
}
}

async function runBuilderCta(params: MaybePromptBuilderCtaParams): Promise<BuilderCtaAction> {
const { hasCredentials } = params

const surface = decideBuilderCtaSurface({
incompatible: params.incompatible,
interactive: params.interactive,
hasCredentials,
})
if (surface === 'skip')
return 'continue'

const mode: 'build' | 'onboarding' = hasCredentials ? 'build' : 'onboarding'
void trackEvent({
channel: 'bundle',
event: 'Builder CTA Shown',
icon: '📣',
apikey: params.apikey,
appId: params.appId,
orgId: params.orgId,
tags: { surface: surface === 'ci-ad' ? 'ci' : 'interactive', mode, incompatible_count: params.incompatibleCount },
})

if (surface === 'ci-ad') {
printBuilderCiAd(hasCredentials)
return 'continue'
}

// Message 1: the context, printed above the prompt so the question stays short.
log.info(REASON)

// Message 2: the short yes/no question, plus a clickable "learn more" hyperlink
// that opens in the browser without dismissing this prompt.
const confirm = params.confirm ?? pConfirm
const question = mode === 'build'
? 'Start a native build with Capgo Builder now?'
: 'Would you like to configure Capgo Builder now?'
const accepted = await confirm({
message: `${question}\n${terminalLink('Learn what Capgo Builder is', LEARN_URL)}`,
initialValue: true,
})
if (pIsCancel(accepted))
return 'continue'

if (accepted === true) {
void trackEvent({ channel: 'bundle', event: 'Builder CTA Accepted', icon: '✅', apikey: params.apikey, appId: params.appId, orgId: params.orgId, tags: { mode } })
return mode === 'build' ? 'launch-build' : 'launch-onboarding'
}

// Declined → just continue the OTA upload (no follow-up prompt).
void trackEvent({ channel: 'bundle', event: 'Builder CTA Declined', icon: '🚫', apikey: params.apikey, appId: params.appId, orgId: params.orgId, tags: { mode } })
return 'continue'
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
23 changes: 23 additions & 0 deletions cli/src/bundle/upload-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { OptionsUpload } from './upload_interface'
import { onboardingBuilderCommand } from '../build/onboarding/command'
import { requestBuildCommand } from '../build/request'
import { uploadBundle } from './upload'

/**
* `bundle upload` command handler. Uploads the bundle, then — when the bundle is
* incompatible and the user opted into Capgo Builder — launches the Ink-based
* build flow. Kept out of `upload.ts` so the programmatic SDK bundle (which
* imports `uploadBundleInternal`) never statically pulls in `ink`.
*/
export async function handleBundleUploadCommand(appId: string, options: OptionsUpload): Promise<void> {
const result = await uploadBundle(appId, options)
if (!result?.builderAction)
return

if (result.builderAction === 'launch-onboarding')
await onboardingBuilderCommand({ apikey: options.apikey })
else
// Don't forward options.path — for `bundle upload` it's the web asset dir,
// but `build request` treats `path` as the Capacitor project root.
await requestBuildCommand(appId ?? '', { apikey: options.apikey, supaHost: options.supaHost, supaAnon: options.supaAnon })
}
31 changes: 30 additions & 1 deletion cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import { trackEvent } from '../analytics/track'
import { check2FAComplianceForApp, checkAppExistsAndHasPermissionOrgErr } from '../api/app'
import { calcKeyId, encryptChecksum, encryptChecksumV3, encryptSource, generateSessionKey } from '../api/crypto'
import { checkAlerts } from '../api/update'
import { loadSavedCredentials } from '../build/credentials'
import { getChecksum } from '../checksum'
import { getRepoStarStatus, isRepoStarredInSession, starRepository } from '../github'
import { confirmWithRememberedChoice } from '../promptPreferences'
import { showReplicationProgress } from '../replicationProgress'
import { usesAlwaysDirectUpdate } from '../updaterConfig'
import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkChecksum, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteFileConfig, hasCliPermission, hasOrganizationPerm, isCompatible, isDeprecatedPluginVersion, OrganizationPerm, regexSemver, resolveUserIdFromApiKey, sendEvent, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils'
import { getVersionSuggestions, interactiveVersionBump } from '../versionHelpers'
import { maybePromptBuilderCta } from './builder-cta'
import { checkIndexPosition, searchInDirectory } from './check'
import { summarizeUploadCompatibility } from './compatibility'
import { prepareBundlePartialFiles, uploadPartial } from './partial'
Expand Down Expand Up @@ -259,6 +261,7 @@ async function verifyCompatibility(supabase: SupabaseType, pm: pmType, options:
return {
nativePackages,
minUpdateVersion,
incompatibleCount: compatibilitySummary.incompatibleCount,
compatibility: {
result: compatibilitySummary.result,
versionOldId: oldVersion?.id != null ? String(oldVersion.id) : undefined,
Expand Down Expand Up @@ -895,7 +898,33 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
if (options.verbose)
log.info(`[Verbose] Checking compatibility with channel ${channel}...`)

const { nativePackages, minUpdateVersion, compatibility } = await verifyCompatibility(supabase, pm, options, channel, appid, bundle, orgId)
const { nativePackages, minUpdateVersion, incompatibleCount, compatibility } = await verifyCompatibility(supabase, pm, options, channel, appid, bundle, orgId)
const incompatible = compatibility.result === 'incompatible'

// Incompatible bundle => a native build is required. Offer Capgo Builder:
// onboarding if the app has no build credentials, otherwise a native build.
// Accepting skips this OTA upload (a native build supersedes it). Skipped
// entirely for the programmatic SDK path (silent), which must not prompt,
// print, or emit CTA telemetry.
if (incompatible && !silent) {
const hasCredentials = (await loadSavedCredentials(appid)) !== null
const builderAction = await maybePromptBuilderCta({ incompatible, interactive, hasCredentials, appId: appid, orgId, apikey, incompatibleCount })
if (builderAction !== 'continue') {
// Skip the OTA upload and hand the launch back to the CLI entry point, which
// runs the Ink-based build commands. Doing it here would pull `ink` into the
// programmatic SDK bundle (which also imports this module).
return {
success: true,
skipped: true,
reason: 'NATIVE_BUILD',
builderAction,
bundle,
checksum: null,
encryptionMethod,
storageProvider: defaultStorageProvider,
}
}
}
if (options.verbose) {
log.info(`[Verbose] Compatibility check completed:`)
log.info(` - Native packages: ${nativePackages ? nativePackages.length : 0}`)
Expand Down
6 changes: 2 additions & 4 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { deleteBundle } from './bundle/delete'
import { encryptZip } from './bundle/encrypt'
import { listBundle } from './bundle/list'
import { printReleaseType } from './bundle/releaseType'
import { uploadBundle } from './bundle/upload'
import { handleBundleUploadCommand } from './bundle/upload-command'
import { zipBundle } from './bundle/zip'
import { addChannel } from './channel/add'
import { currentBundle } from './channel/currentBundle'
Expand Down Expand Up @@ -172,9 +172,7 @@ External option: Store only a URL link (useful for apps >200MB or privacy requir
Capgo never inspects external content. Add encryption for trustless security.

Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production`)
.action(async (...args: Parameters<typeof uploadBundle>): Promise<void> => {
await uploadBundle(...args)
})
.action(handleBundleUploadCommand)
.option('-a, --apikey <apikey>', optionDescriptions.apikey)
.option('-p, --path <path>', `Path of the folder to upload, if not provided it will use the webDir set in capacitor.config`)
.option('-c, --channel <channel>', `Channel to link to`)
Expand Down
1 change: 1 addition & 0 deletions cli/src/schemas/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const uploadBundleResultSchema = z.object({
storageProvider: z.string().optional(),
skipped: z.boolean().optional(),
reason: z.string().optional(),
builderAction: z.enum(['launch-onboarding', 'launch-build']).optional(),
})

export type UploadBundleResult = z.infer<typeof uploadBundleResultSchema>
Expand Down
Loading
Loading