diff --git a/cli/skills/release-management/SKILL.md b/cli/skills/release-management/SKILL.md index 33c9590e08..d60d729b5e 100644 --- a/cli/skills/release-management/SKILL.md +++ b/cli/skills/release-management/SKILL.md @@ -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. diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 6187e10ea2..38098d9c31 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -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' @@ -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 @@ -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) { @@ -1711,6 +1742,20 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio log.info(`[Verbose] Delta updates: ${options.delta ? 'enabled' : 'disabled'}`) } + if (shouldWarnDirectUpdateWithoutDelta({ + 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') @@ -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) { diff --git a/cli/src/updaterConfig.ts b/cli/src/updaterConfig.ts index d1fd99a638..72db50d4f7 100644 --- a/cli/src/updaterConfig.ts +++ b/cli/src/updaterConfig.ts @@ -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 @@ -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 +} diff --git a/cli/test/test-init-guardrails.mjs b/cli/test/test-init-guardrails.mjs index c7e63d8da6..e07a37fc1e 100644 --- a/cli/test/test-init-guardrails.mjs +++ b/cli/test/test-init-guardrails.mjs @@ -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 @@ -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', () => { diff --git a/supabase/functions/_backend/plugin_runtime/utils/org_email_notifications.ts b/supabase/functions/_backend/plugin_runtime/utils/org_email_notifications.ts index d4b404f99d..1639436fc1 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/org_email_notifications.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/org_email_notifications.ts @@ -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 @@ -95,6 +96,7 @@ export interface EmailPreferences { bundle_incompatible?: boolean bundle_incompatible_expected?: boolean app_too_large?: boolean + direct_update_without_delta?: boolean } /** diff --git a/supabase/functions/_backend/private/email_preferences.ts b/supabase/functions/_backend/private/email_preferences.ts index 911cc3211b..2bf7faaaee 100644 --- a/supabase/functions/_backend/private/email_preferences.ts +++ b/supabase/functions/_backend/private/email_preferences.ts @@ -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 } diff --git a/supabase/functions/_backend/private/events.ts b/supabase/functions/_backend/private/events.ts index 5460cd781e..de28b2bcbb 100644 --- a/supabase/functions/_backend/private/events.ts +++ b/supabase/functions/_backend/private/events.ts @@ -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' @@ -302,6 +303,35 @@ async function buildAppTooLargeTrackedBentoEvent( }) } +async function buildDirectUpdateWithoutDeltaTrackedBentoEvent( + c: Context, + supabase: ReturnType, + 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 } @@ -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, diff --git a/supabase/functions/_backend/utils/direct_update_without_delta_tracking.ts b/supabase/functions/_backend/utils/direct_update_without_delta_tracking.ts new file mode 100644 index 0000000000..ec13a2b8fa --- /dev/null +++ b/supabase/functions/_backend/utils/direct_update_without_delta_tracking.ts @@ -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 +} + +/** + * 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 } : {}), + }, + } +} diff --git a/supabase/functions/_backend/utils/org_email_notifications.ts b/supabase/functions/_backend/utils/org_email_notifications.ts index 1e82b3f7a4..b777b998b9 100644 --- a/supabase/functions/_backend/utils/org_email_notifications.ts +++ b/supabase/functions/_backend/utils/org_email_notifications.ts @@ -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 @@ -100,6 +101,7 @@ export interface EmailPreferences { bundle_incompatible?: boolean bundle_incompatible_expected?: boolean app_too_large?: boolean + direct_update_without_delta?: boolean } /** diff --git a/supabase/functions/_backend/utils/user_preferences.ts b/supabase/functions/_backend/utils/user_preferences.ts index f775c47e9e..a0c4dd190c 100644 --- a/supabase/functions/_backend/utils/user_preferences.ts +++ b/supabase/functions/_backend/utils/user_preferences.ts @@ -36,6 +36,7 @@ const EMAIL_PREF_DISABLED_TAGS: Record = { 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] diff --git a/tests/direct-update-without-delta-tracking.unit.test.ts b/tests/direct-update-without-delta-tracking.unit.test.ts new file mode 100644 index 0000000000..f689e54270 --- /dev/null +++ b/tests/direct-update-without-delta-tracking.unit.test.ts @@ -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('') + }) +}) diff --git a/tests/events.test.ts b/tests/events.test.ts index fc007d55ed..ee6eaad0ca 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -151,6 +151,29 @@ describe('[POST] /private/events operations', () => { expect(data.status).toBe('ok') }) + it.concurrent('tracks v2 Direct Update Without Delta events for Bento forwarding', async () => { + const response = await fetch(`${BASE_URL}/private/events`, { + method: 'POST', + headers: { + capgkey: headers.Authorization, + }, + body: JSON.stringify({ + channel: 'app-error', + event: 'Direct Update Without Delta', + org_id: ORG_ID, + tracking_version: 2, + tags: { + 'app-id': APPNAME_EVENT, + 'external': false, + }, + }), + }) + + const data = await response.json() as { status: string } + expect(response.status).toBe(200) + expect(data.status).toBe('ok') + }) + it.concurrent('tracks v2 onboarding-step-done events (resolves org from verified org, not user_id)', async () => { const response = await fetch(`${BASE_URL}/private/events`, { method: 'POST',