Skip to content
Open
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
1 change: 1 addition & 0 deletions cli/skills/release-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Use this skill for OTA update workflows in Capgo Cloud.
- External URL mode is useful for very large or privacy-sensitive bundles.
- Encryption is recommended for trustless distribution.
- Interactive prompts are disabled automatically in CI and other non-interactive sessions so uploads do not block automation.
- If CapacitorUpdater direct/instant updates are enabled (`directUpdate` or `autoUpdate` set to `always`, `atInstall`, or `onLaunch`) and the upload is not using `--delta`, the CLI warns and reports a Bento event. Use `--delta` (or omit `--no-delta`) so devices do not download a full zip while applying the update.
- Optional upload prompts can remember the user's answer on the current machine so future uploads can skip the same question.
- `--channel` accepts a single channel or a comma-separated list such as `production,beta`.
- When multiple channels are provided, channels that already have the uploaded checksum are skipped and the remaining channels are assigned.
Expand Down
66 changes: 62 additions & 4 deletions cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { confirmWithRememberedChoice } from '../promptPreferences'
import { showReplicationProgress } from '../replicationProgress'
import { CliUserError } from '../shared/cli-user-error'
import { formatTable } from '../terminal-table'
import { usesAlwaysDirectUpdate } from '../updaterConfig'
import { DIRECT_UPDATE_WITHOUT_DELTA_EVENT, shouldWarnDirectUpdateWithoutDelta, usesDirectUpdate } from '../updaterConfig'
import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, channelUpdatePackageCliError, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, deltaManifestTooLargeMessage, findRoot, findSavedKey, formatError, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, invokeCapgoCliApi, isCompatible, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, UPLOAD_TIMEOUT_ERROR_NAME, uploadTimeoutMessage, uploadTUS, uploadUrl, zipFile } from '../utils'
import type { AutoBumpLevel } from '../versionHelpers'
import { autoBumpVersionBy, getVersionSuggestions, interactiveVersionBump, normalizeAutoBumpInput } from '../versionHelpers'
Expand Down Expand Up @@ -468,6 +468,37 @@ function shouldSendAppTooLargeEvent(options: OptionsUpload): boolean {
return shouldUploadFullZip(options) || hasCompleteS3UploadConfig(options)
}

async function warnDirectUpdateWithoutDelta(input: {
apikey: string
appid: string
orgId: string
options: OptionsUpload
silent: boolean
}) {
if (!input.silent) {
log.warn('WARNING: Direct updates (directUpdate always/atInstall/onLaunch) are enabled, but this upload is not using delta updates.')
log.warn('Devices will download the full zip while applying the update, which can feel slow or stuck.')
log.warn('Upload with --delta so only changed files are sent: npx @capgo/cli@latest bundle upload --delta')
}

if (input.options.verbose)
log.info(`[Verbose] Sending '${DIRECT_UPDATE_WITHOUT_DELTA_EVENT}' event to analytics...`)

await sendEvent(input.apikey, {
channel: 'app-error',
event: DIRECT_UPDATE_WITHOUT_DELTA_EVENT,
org_id: input.orgId,
tracking_version: 2,
tags: {
'app-id': input.appid,
'external': !!input.options.external,
},
}, input.options.verbose)

if (input.options.verbose)
log.info(`[Verbose] Event sent successfully`)
}

async function prepareBundleFile(path: string, options: OptionsUpload, apikey: string, orgId: string, appid: string, maxUploadLength: number, alertUploadSize: number, publicKeyFromConfig?: string) {
let ivSessionKey
let sessionKey
Expand Down Expand Up @@ -1334,14 +1365,14 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio
options.userRequestedDelta = !!(options.partial || options.delta || options.partialOnly || options.deltaOnly)

// Check if instant updates are enabled and auto-enable delta updates.
const instantUpdateEnabled = usesAlwaysDirectUpdate(extConfig?.config?.plugins?.CapacitorUpdater)
const instantUpdateEnabled = usesDirectUpdate(extConfig?.config?.plugins?.CapacitorUpdater)
const interactive = canPromptInteractively({ silent })
if (instantUpdateEnabled && options.delta === undefined) {
if (interactive) {
log.info('💡 Instant updates are enabled in your config')
log.info('💡 Direct updates are enabled in your config (always, atInstall, or onLaunch)')
log.info(' Delta updates send only changed files instead of the full bundle')
const enableDelta = await pConfirm({
message: 'Enable delta updates for this upload? (Recommended with instant updates)',
message: 'Enable delta updates for this upload? (Recommended with direct updates)',
initialValue: true,
})
if (!pIsCancel(enableDelta) && enableDelta) {
Expand Down Expand Up @@ -1711,6 +1742,20 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio
log.info(`[Verbose] Delta updates: ${options.delta ? 'enabled' : 'disabled'}`)
}

if (shouldWarnDirectUpdateWithoutDelta({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options.external forces options.delta = false just above (line 1734–1735: "not available with external URLs"), then this still warns and tells the operator to bundle upload --delta.

That advice cannot work for --external / S3 uploads. The Bento/PostHog event (Direct Update Without Delta, channel: 'app-error') will also fire on every external upload of a direct-update app, including CI.

Skip the warn when delta is impossible (options.external or !fileConfig.partialUpload), e.g. pass that into shouldWarnDirectUpdateWithoutDelta.

warnDirectUpdateWithoutDelta also await sendEvent(...) with no try/catch. Other upload telemetry is best-effort; if this throw lands, a warning aborts the bundle. Swallow sendEvent errors here.

instantUpdateEnabled,
deltaEnabled: !!options.delta,
dryUpload: !!options.dryUpload,
})) {
await warnDirectUpdateWithoutDelta({
apikey,
appid,
orgId,
options,
silent,
})
}

if (options.encryptPartial && encryptionMethod === 'v1')
uploadFail('You cannot encrypt the partial update if you are not using the v2 encryption method')

Expand Down Expand Up @@ -1919,6 +1964,19 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio
log.info(`Failed to upload partial files to capgo cloud. Error: ${formatError(err)}. This is not a critical error, the bundle has been uploaded without the partial files`)
if (options.verbose)
log.info(`[Verbose] Delta upload error details: ${formatError(err)}`)
if (shouldWarnDirectUpdateWithoutDelta({
instantUpdateEnabled,
deltaEnabled: false,
dryUpload: !!options.dryUpload,
})) {
await warnDirectUpdateWithoutDelta({
apikey,
appid,
orgId,
options,
silent,
})
}
}

if (finalManifest?.length) {
Expand Down
24 changes: 24 additions & 0 deletions cli/src/updaterConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface CapacitorUpdaterPluginConfig {
directUpdate?: DirectUpdatePolicy
}

export const DIRECT_UPDATE_WITHOUT_DELTA_EVENT = 'Direct Update Without Delta'

export function usesAlwaysDirectUpdate(config: CapacitorUpdaterPluginConfig | undefined): boolean {
const autoUpdate = config?.autoUpdate

Expand All @@ -18,3 +20,25 @@ export function usesAlwaysDirectUpdate(config: CapacitorUpdaterPluginConfig | un
const directUpdate = config?.directUpdate
return directUpdate === true || directUpdate === 'always'
}

/** Splash-blocking direct/instant update modes that should upload with delta. */
export function usesDirectUpdate(config: CapacitorUpdaterPluginConfig | undefined): boolean {
const autoUpdate = config?.autoUpdate

if (autoUpdate === 'always' || autoUpdate === 'atInstall' || autoUpdate === 'onLaunch')
return true

if (typeof autoUpdate === 'string' || autoUpdate === false)
return false

const directUpdate = config?.directUpdate
return directUpdate === true || directUpdate === 'always' || directUpdate === 'atInstall' || directUpdate === 'onLaunch'
}

export function shouldWarnDirectUpdateWithoutDelta(input: {
instantUpdateEnabled: boolean
deltaEnabled: boolean
dryUpload?: boolean
}): boolean {
return input.instantUpdateEnabled && !input.deltaEnabled && !input.dryUpload
}
25 changes: 24 additions & 1 deletion cli/test/test-init-guardrails.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
waitForCommandResult,
} from '../src/init/command-execution.ts'
import { getCliLoginCommand } from '../src/runner-command.ts'
import { usesAlwaysDirectUpdate } from '../src/updaterConfig.ts'
import { shouldWarnDirectUpdateWithoutDelta, usesAlwaysDirectUpdate, usesDirectUpdate } from '../src/updaterConfig.ts'
import { getPMAndCommand, setPMAndCommand } from '../src/utils.ts'

let failures = 0
Expand Down Expand Up @@ -348,6 +348,29 @@ t('instant update detection supports new autoUpdate modes and legacy directUpdat
assert.equal(usesAlwaysDirectUpdate({ autoUpdate: true, directUpdate: true }), true)
assert.equal(usesAlwaysDirectUpdate({ directUpdate: 'always' }), true)
assert.equal(usesAlwaysDirectUpdate({ directUpdate: 'onLaunch' }), false)
assert.equal(usesAlwaysDirectUpdate({ directUpdate: 'atInstall' }), false)
})

t('direct update detection includes always, atInstall, and onLaunch', () => {
assert.equal(usesDirectUpdate({ autoUpdate: 'always' }), true)
assert.equal(usesDirectUpdate({ autoUpdate: 'atInstall' }), true)
assert.equal(usesDirectUpdate({ autoUpdate: 'onLaunch' }), true)
assert.equal(usesDirectUpdate({ autoUpdate: 'atBackground', directUpdate: 'always' }), false)
assert.equal(usesDirectUpdate({ autoUpdate: 'onlyDownload', directUpdate: 'atInstall' }), false)
assert.equal(usesDirectUpdate({ autoUpdate: false, directUpdate: 'onLaunch' }), false)
assert.equal(usesDirectUpdate({ autoUpdate: true, directUpdate: true }), true)
assert.equal(usesDirectUpdate({ directUpdate: 'always' }), true)
assert.equal(usesDirectUpdate({ directUpdate: 'atInstall' }), true)
assert.equal(usesDirectUpdate({ directUpdate: 'onLaunch' }), true)
assert.equal(usesDirectUpdate({ autoUpdate: true }), false)
assert.equal(usesDirectUpdate(undefined), false)
})

t('direct update without delta warns only when instant updates skip delta', () => {
assert.equal(shouldWarnDirectUpdateWithoutDelta({ instantUpdateEnabled: true, deltaEnabled: false }), true)
assert.equal(shouldWarnDirectUpdateWithoutDelta({ instantUpdateEnabled: true, deltaEnabled: true }), false)
assert.equal(shouldWarnDirectUpdateWithoutDelta({ instantUpdateEnabled: false, deltaEnabled: false }), false)
assert.equal(shouldWarnDirectUpdateWithoutDelta({ instantUpdateEnabled: true, deltaEnabled: false, dryUpload: true }), false)
})

t('guided ota version suggestions stay on major zero when native baseline is pinned', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export type EmailPreferenceKey
| 'bundle_incompatible'
| 'bundle_incompatible_expected'
| 'app_too_large'
| 'direct_update_without_delta'

export interface EmailPreferences {
usage_limit?: boolean
Expand All @@ -95,6 +96,7 @@ export interface EmailPreferences {
bundle_incompatible?: boolean
bundle_incompatible_expected?: boolean
app_too_large?: boolean
direct_update_without_delta?: boolean
}

/**
Expand Down
1 change: 1 addition & 0 deletions supabase/functions/_backend/private/email_preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ function allPreferencesDisabled(): EmailPreferences {
prefs.cli_realtime_feed = false
prefs.daily_fail_ratio = false
prefs.app_too_large = false
prefs.direct_update_without_delta = false
return prefs
}

Expand Down
38 changes: 37 additions & 1 deletion supabase/functions/_backend/private/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { APP_TOO_LARGE_EVENT, buildAppTooLargeBentoEvent } from '../utils/app_to
import { markAppOnboardingLoginFromTracking } from '../utils/app_onboarding_login.ts'
import { buildBuilderOnboardingBentoEvent, BUILDER_RECOVERY_MILESTONES } from '../utils/builder_onboarding_recovery.ts'
import { BUNDLE_INCOMPATIBLE_EVENT, buildBundleCompatibilityBentoEvent, bundleIncompatibleEmailOutcome, isBreakingChangeGatedByChannelStrategy, isCliTrueTag } from '../utils/bundle_compatibility_recovery.ts'
import { DIRECT_UPDATE_WITHOUT_DELTA_EVENT, buildDirectUpdateWithoutDeltaBentoEvent } from '../utils/direct_update_without_delta_tracking.ts'
import { BRES, parseBody, quickError, simpleError, useCors } from '../utils/hono.ts'
import { middlewareAuth } from '../utils/hono_middleware.ts'
import { cloudlog } from '../utils/logging.ts'
Expand Down Expand Up @@ -302,6 +303,35 @@ async function buildAppTooLargeTrackedBentoEvent(
})
}

async function buildDirectUpdateWithoutDeltaTrackedBentoEvent(
c: Context<MiddlewareKeyVariables>,
supabase: ReturnType<typeof supabaseWithAuth>,
onboardingOrgId: string | undefined,
appId: string | undefined,
trackedBody: TrackOptions,
) {
if (!onboardingOrgId || !appId || trackedBody.event !== DIRECT_UPDATE_WITHOUT_DELTA_EVENT)
return undefined

const [orgResult, appResult] = await Promise.all([
supabase.from('orgs').select('id, name').eq('id', onboardingOrgId).single(),
supabase.from('apps').select('name').eq('app_id', appId).single(),
])
if (orgResult.error || appResult.error) {
cloudlog({ requestId: c.get('requestId'), message: 'direct update without delta bento lookup failed; skipping signal', org: orgResult.error, app: appResult.error })
return undefined
}

return buildDirectUpdateWithoutDeltaBentoEvent({
event: trackedBody.event,
orgId: onboardingOrgId,
appId,
orgName: orgResult.data?.name ?? undefined,
appName: appResult.data?.name ?? undefined,
tags: trackedBody.tags,
})
}

function optionalTagString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
Expand Down Expand Up @@ -528,8 +558,14 @@ app.post('/', middlewareAuth(), async (c) => {
// automation email org admins (gated by the `app_too_large` preference).
const appTooLargeBentoEvent: BentoTrackingPayload | undefined = await buildAppTooLargeTrackedBentoEvent(c, supabase, onboardingOrgId, appId, trackedBody)

// CLI bundle upload warning when instant/direct updates are on but the
// upload is not using delta. PostHog records `Direct Update Without Delta`;
// this Bento signal lets a lifecycle automation email org admins (gated by
// the `direct_update_without_delta` preference).
const directUpdateWithoutDeltaBentoEvent: BentoTrackingPayload | undefined = await buildDirectUpdateWithoutDeltaTrackedBentoEvent(c, supabase, onboardingOrgId, appId, trackedBody)

// Exactly one of these is ever set (distinct event names); `??` picks the active one.
const bentoEvent = onboardingBentoEvent ?? builderBentoEvent ?? bundleIncompatibleBentoEvent ?? aiInstructionsCopiedBentoEvent ?? appTooLargeBentoEvent
const bentoEvent = onboardingBentoEvent ?? builderBentoEvent ?? bundleIncompatibleBentoEvent ?? aiInstructionsCopiedBentoEvent ?? appTooLargeBentoEvent ?? directUpdateWithoutDeltaBentoEvent
const apikeyId = c.get('apikey')?.id
await sendEventToTracking(c, addAuthenticatedApiKeyIdToTrackingPayload({
...trackedBody,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { BentoTrackingPayload } from './tracking.ts'

/**
* CLI `bundle upload` emits this when CapacitorUpdater instant/direct updates
* are enabled but the upload is not using delta (`--no-delta`, declined
* prompt, or delta unavailable). PostHog already records it; this helper
* builds the Bento payload so org admins can get a lifecycle automation / email.
*/
export const DIRECT_UPDATE_WITHOUT_DELTA_EVENT = 'Direct Update Without Delta'
export const DIRECT_UPDATE_WITHOUT_DELTA_BENTO_EVENT = 'direct_update_without_delta'

export interface DirectUpdateWithoutDeltaBentoInput {
event: string
orgId: string | undefined
appId: string | undefined
orgName?: string
appName?: string
tags?: Record<string, string | number | boolean>
}

/**
* Pure: emit a Bento signal when a direct-update app is uploaded without
* delta files. Returns undefined when the event name does not match or
* org/app context is missing.
*/
export function buildDirectUpdateWithoutDeltaBentoEvent(input: DirectUpdateWithoutDeltaBentoInput): BentoTrackingPayload | undefined {
if (input.event !== DIRECT_UPDATE_WITHOUT_DELTA_EVENT)
return undefined
if (!input.orgId || !input.appId)
return undefined

return {
cron: '* * * * *',
event: DIRECT_UPDATE_WITHOUT_DELTA_BENTO_EVENT,
preferenceKey: 'direct_update_without_delta',
uniqId: `${DIRECT_UPDATE_WITHOUT_DELTA_BENTO_EVENT}:${input.appId}`,
data: {
org_id: input.orgId,
org_name: input.orgName ?? '',
app_id: input.appId,
app_name: input.appName ?? '',
...(typeof input.tags?.external === 'boolean' ? { external: input.tags.external } : {}),
},
}
}
2 changes: 2 additions & 0 deletions supabase/functions/_backend/utils/org_email_notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export type EmailPreferenceKey
| 'bundle_incompatible'
| 'bundle_incompatible_expected'
| 'app_too_large'
| 'direct_update_without_delta'

export interface EmailPreferences {
usage_limit?: boolean
Expand All @@ -100,6 +101,7 @@ export interface EmailPreferences {
bundle_incompatible?: boolean
bundle_incompatible_expected?: boolean
app_too_large?: boolean
direct_update_without_delta?: boolean
}

/**
Expand Down
1 change: 1 addition & 0 deletions supabase/functions/_backend/utils/user_preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const EMAIL_PREF_DISABLED_TAGS: Record<EmailPreferenceKey, string> = {
bundle_incompatible: 'bundle_incompatible_disabled',
bundle_incompatible_expected: 'bundle_incompatible_expected_disabled',
app_too_large: 'app_too_large_disabled',
direct_update_without_delta: 'direct_update_without_delta_disabled',
}

const ALL_LEGACY_TAGS = [NOTIFICATION_TAG, NEWSLETTER_TAG]
Expand Down
51 changes: 51 additions & 0 deletions tests/direct-update-without-delta-tracking.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { DIRECT_UPDATE_WITHOUT_DELTA_BENTO_EVENT, DIRECT_UPDATE_WITHOUT_DELTA_EVENT, buildDirectUpdateWithoutDeltaBentoEvent } from '../supabase/functions/_backend/utils/direct_update_without_delta_tracking.ts'

const base = {
event: DIRECT_UPDATE_WITHOUT_DELTA_EVENT,
orgId: 'org-1',
appId: 'com.demo.app',
orgName: 'Demo Org',
appName: 'Demo',
}

describe('buildDirectUpdateWithoutDeltaBentoEvent', () => {
it.concurrent('builds a Bento payload for Direct Update Without Delta', () => {
expect(buildDirectUpdateWithoutDeltaBentoEvent(base)).toEqual({
cron: '* * * * *',
event: DIRECT_UPDATE_WITHOUT_DELTA_BENTO_EVENT,
preferenceKey: 'direct_update_without_delta',
uniqId: 'direct_update_without_delta:com.demo.app',
data: {
org_id: 'org-1',
org_name: 'Demo Org',
app_id: 'com.demo.app',
app_name: 'Demo',
},
})
})

it.concurrent('includes the external tag when present', () => {
const result = buildDirectUpdateWithoutDeltaBentoEvent({ ...base, tags: { external: true } })
expect(result?.data.external).toBe(true)
})

it.concurrent('returns undefined for other event names', () => {
expect(buildDirectUpdateWithoutDeltaBentoEvent({ ...base, event: 'App Too Large' })).toBeUndefined()
})

it.concurrent('returns undefined when org or app id is missing', () => {
expect(buildDirectUpdateWithoutDeltaBentoEvent({ ...base, orgId: undefined })).toBeUndefined()
expect(buildDirectUpdateWithoutDeltaBentoEvent({ ...base, appId: undefined })).toBeUndefined()
})

it.concurrent('defaults missing org and app names to empty strings', () => {
const result = buildDirectUpdateWithoutDeltaBentoEvent({
event: DIRECT_UPDATE_WITHOUT_DELTA_EVENT,
orgId: 'org-1',
appId: 'com.demo.app',
})
expect(result?.data.org_name).toBe('')
expect(result?.data.app_name).toBe('')
})
})
Loading
Loading