From d3b519f20d297e2a61d2201c4da1f8092aa8a6eb Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:08:27 +0000 Subject: [PATCH 1/9] fix(backend): end request-scoped pg pools on workerd closeClient was a no-op on the workerd runtime, so every request-scoped pg.Pool built by getPgClient (auth-key and subkey resolution, RBAC checks, checkWriteAppAccess) was never ended. On Cloudflare Workers the unclosed pools leaked their Hyperdrive sockets until the connection slots were exhausted, and new connections failed with "Timed out while waiting for an open slot in the pool" on the bundle-upload write path. closeClient now always ends the pool, deferring end() to waitUntil via backgroundTask so it adds no request latency, and logging any end() failure instead of throwing. This mirrors the proven closeClient in plugin_runtime/utils/pg.ts. Module-scoped reused pools (file_read_cache) are never passed to closeClient, so they are unaffected. Generated-By: PostHog Desktop Task-Id: 3ea49ca0-9ec6-4083-a09f-4041420d20d1 --- supabase/functions/_backend/utils/pg.ts | 16 ++++-- tests/pg-close-client-lifecycle.unit.test.ts | 53 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 tests/pg-close-client-lifecycle.unit.test.ts diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 3444b7de08..1e13cd7f4f 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -5,7 +5,6 @@ import type { AdminOnboardingActivationCohort, AdminOnboardingWizardDropoff } fr import { and, eq, isNotNull, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/node-postgres' import { alias } from 'drizzle-orm/pg-core' -import { getRuntimeKey } from 'hono/adapter' // @ts-types="npm:@types/pg" import { Pool } from 'pg' import { serializePostgresError } from '../plugin_runtime/utils/postgres_error.ts' @@ -411,10 +410,17 @@ export function logPgError(c: Context, functionName: string, error: unknown) { } export function closeClient(c: Context, db: ReturnType) { - // cloudlog(c.get('requestId'), 'Closing client', client) - if (getRuntimeKey() !== 'workerd') - return backgroundTask(c, db.end()) - return undefined + // Always end the request-scoped pool. On workerd a Pool that is never ended + // leaks its Hyperdrive sockets until the pool slots are exhausted (the workerd + // sawtooth). backgroundTask defers end() to waitUntil, so it never adds + // request latency. + return backgroundTask(c, Promise.resolve(db.end()).catch((error: unknown) => { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'PG client end failed', + error: serializePostgresError(error), + }) + })) } export function getAlias() { diff --git a/tests/pg-close-client-lifecycle.unit.test.ts b/tests/pg-close-client-lifecycle.unit.test.ts new file mode 100644 index 0000000000..59f9a0920d --- /dev/null +++ b/tests/pg-close-client-lifecycle.unit.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { cloudlogErrMock } = vi.hoisted(() => ({ cloudlogErrMock: vi.fn() })) + +// backgroundTask defers pool.end() to waitUntil on workerd. Pass it through here +// so the test can await the end() promise directly. +vi.mock('../supabase/functions/_backend/utils/utils.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + backgroundTask: vi.fn((_c: any, p: any) => p), + } +}) + +vi.mock('../supabase/functions/_backend/utils/logging.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + cloudlogErr: cloudlogErrMock, + } +}) + +function createContext() { + return { + get: (key: string) => (key === 'requestId' ? 'req-1' : undefined), + } as any +} + +describe('main pg.ts closeClient lifecycle', () => { + beforeEach(() => { + vi.resetModules() + cloudlogErrMock.mockClear() + }) + + it('ends the request-scoped pool (no longer a workerd no-op)', async () => { + const { closeClient } = await import('../supabase/functions/_backend/utils/pg.ts') + const end = vi.fn(async () => undefined) + + await closeClient(createContext(), { end } as any) + + expect(end).toHaveBeenCalledTimes(1) + }) + + it('logs and swallows end() failures without throwing', async () => { + const { closeClient } = await import('../supabase/functions/_backend/utils/pg.ts') + const end = vi.fn(async () => { + throw new Error('end unsupported') + }) + + await expect(closeClient(createContext(), { end } as any)).resolves.toBeUndefined() + expect(cloudlogErrMock).toHaveBeenCalledWith(expect.objectContaining({ message: 'PG client end failed' })) + }) +}) From 92a11b6803b2a57a2b58a1bab076b6a5be74b13c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 16:48:20 +0000 Subject: [PATCH 2/9] fix(backend): use Hyperdrive Client + skipEnd in main pg utils Align getPgClient/closeClient with plugin_runtime: workerd Hyperdrive gets a per-request pg.Client with connect() and skipEndClients; non-Hyperdrive paths keep short-lived Pool with explicit end(). Pool max is 1 on workerd. Updates callers to await getPgClient and replaces lifecycle unit tests. Co-authored-by: Martin DONADIEU --- scripts/check_r2_big_files.ts | 8 +- .../_backend/files/file_read_cache.ts | 10 +- supabase/functions/_backend/files/files.ts | 2 +- .../_backend/private/accept_invitation.ts | 2 +- .../_backend/private/bundle_install_stats.ts | 2 +- .../_backend/private/create_device.ts | 6 +- supabase/functions/_backend/private/groups.ts | 14 +- .../private/invite_existing_user_to_org.ts | 6 +- .../functions/_backend/private/latency.ts | 2 +- supabase/functions/_backend/private/log_as.ts | 6 +- .../_backend/private/native_observe_stats.ts | 6 +- .../private/org_notification_stats.ts | 6 +- supabase/functions/_backend/private/replay.ts | 6 +- .../_backend/private/role_bindings.ts | 12 +- supabase/functions/_backend/private/roles.ts | 4 +- .../_backend/private/sso/prelink-shared.ts | 4 +- .../_backend/private/sso/providers.ts | 6 +- .../_backend/private/sso/provision-user.ts | 28 +-- .../_backend/private/sso/verify-dns.ts | 2 +- .../_backend/private/update_delivery_stats.ts | 2 +- .../functions/_backend/public/apikey/get.ts | 2 +- .../functions/_backend/public/apikey/post.ts | 6 +- .../functions/_backend/public/apikey/put.ts | 10 +- .../functions/_backend/public/apikey/scope.ts | 10 +- .../functions/_backend/public/app/demo.ts | 6 +- .../functions/_backend/public/app/post.ts | 2 +- supabase/functions/_backend/public/app/put.ts | 2 +- .../_backend/public/build/concurrency.ts | 10 +- .../_backend/public/build/support_logs.ts | 4 +- .../_backend/public/bundle/set_channel.ts | 6 +- .../_backend/public/channel/delete.ts | 2 +- .../functions/_backend/public/channel/post.ts | 6 +- .../_backend/public/notifications/index.ts | 38 ++-- .../public/organization/members/delete.ts | 6 +- .../public/organization/members/post.ts | 2 +- .../_backend/public/organization/post.ts | 10 +- .../_backend/public/organization/put.ts | 10 +- .../functions/_backend/public/queue_health.ts | 10 +- .../functions/_backend/public/replication.ts | 8 +- .../_backend/public/webhooks/index.ts | 2 +- .../_backend/triggers/credit_usage_alerts.ts | 2 +- .../_backend/triggers/cron_app_fame.ts | 6 +- .../_backend/triggers/cron_stat_app.ts | 4 +- .../_backend/triggers/cron_stat_org.ts | 2 +- .../_backend/triggers/cron_sync_sub.ts | 2 +- .../_backend/triggers/global_stats.ts | 74 ++++---- .../_backend/triggers/on_app_create.ts | 2 +- .../triggers/on_deploy_history_create.ts | 2 +- .../_backend/triggers/on_version_create.ts | 2 +- .../_backend/triggers/on_version_update.ts | 6 +- .../_backend/triggers/plugin_notifications.ts | 2 +- .../_backend/triggers/queue_consumer.ts | 24 +-- .../_backend/triggers/stripe_event.ts | 8 +- .../_backend/triggers/webhook_delivery.ts | 2 +- .../utils/ab_test_channel_creation.ts | 2 +- .../utils/ab_test_development_environment.ts | 2 +- .../_backend/utils/ab_test_distribution.ts | 2 +- .../utils/ab_test_publish_intent_outcome.ts | 2 +- supabase/functions/_backend/utils/ab_tests.ts | 6 +- .../_backend/utils/app_onboarding_login.ts | 2 +- .../_backend/utils/bento_first_org.ts | 14 +- .../_backend/utils/builder_analytics.ts | 2 +- .../_backend/utils/builder_capacity.ts | 6 +- .../_backend/utils/channel_surfing.ts | 2 +- .../functions/_backend/utils/cli_usage.ts | 6 +- .../_backend/utils/cloudflare_cache_purge.ts | 2 +- supabase/functions/_backend/utils/demo.ts | 2 +- .../frontend_onboarding_cli_checklist.ts | 2 +- .../_backend/utils/hono_middleware.ts | 18 +- .../_backend/utils/jwt_mfa_assurance.ts | 10 +- .../_backend/utils/manifest_persist.ts | 2 +- .../functions/_backend/utils/manifest_size.ts | 2 +- .../functions/_backend/utils/notifications.ts | 6 +- .../utils/onboarding_payment_cohorts_data.ts | 2 +- .../_backend/utils/org_email_notifications.ts | 4 +- .../_backend/utils/org_onboarding_intent.ts | 2 +- supabase/functions/_backend/utils/pg.ts | 131 ++++++++++---- .../_backend/utils/plans_billing_history.ts | 2 +- supabase/functions/_backend/utils/rbac.ts | 2 +- .../utils/registration_monthly_comparison.ts | 2 +- supabase/functions/_backend/utils/supabase.ts | 12 +- supabase/functions/_backend/utils/tracking.ts | 2 +- .../_backend/utils/user_bento_events.ts | 14 +- supabase/functions/_backend/utils/webhook.ts | 4 +- tests/api-pg-error-logging.unit.test.ts | 4 +- tests/pg-close-client-lifecycle.unit.test.ts | 166 +++++++++++++++--- .../plugin-supabase-write-guard.unit.test.ts | 2 +- 87 files changed, 518 insertions(+), 345 deletions(-) diff --git a/scripts/check_r2_big_files.ts b/scripts/check_r2_big_files.ts index f71e0b983e..534a63d971 100644 --- a/scripts/check_r2_big_files.ts +++ b/scripts/check_r2_big_files.ts @@ -631,7 +631,7 @@ export function getDatabaseURL(): string { return DEFAULT_DB_URL } -export function getPgClient(c: Context) { +export function await getPgClient(c: Context) { const dbUrl = getDatabaseURL() console.log({ message: 'getPgClient', dbUrl }) return new Pool({ @@ -646,7 +646,7 @@ async function get_app_versions() { // Create a mock context for getPgClient const mockContext = {} as Context - const pool = getPgClient(mockContext) + const pool = await getPgClient(mockContext) try { console.log('📊 Fetching all app versions...') @@ -745,7 +745,7 @@ async function get_big_orgs() { if (orgsOver100MB.length > 0) { // Create a mock context for getPgClient const mockContext = {} as Context - const pool = getPgClient(mockContext) + const pool = await getPgClient(mockContext) try { // Create all the plan queries @@ -1083,7 +1083,7 @@ async function prepare_cleanup_zip() { // Connect to database console.log('🔗 Connecting to database...') const mockContext = {} as Context - const pool = getPgClient(mockContext) + const pool = await getPgClient(mockContext) // Define types for cleanup data interface CleanupCandidate { diff --git a/supabase/functions/_backend/files/file_read_cache.ts b/supabase/functions/_backend/files/file_read_cache.ts index fd18edc709..aba01fa1eb 100644 --- a/supabase/functions/_backend/files/file_read_cache.ts +++ b/supabase/functions/_backend/files/file_read_cache.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono' import { getRuntimeKey } from 'hono/adapter' import { cloudlog } from '../utils/logging.ts' -import { getDatabaseURL, getPgClient } from '../utils/pg.ts' +import { getDatabaseURL, getPgClient, type PgClient } from '../utils/pg.ts' export const FILE_READ_TRACKING_QUERY_PARAMS = ['device_id'] as const export const DELETED_FILE_CACHE_HEADER = 'x-capgo-file-deleted' @@ -119,13 +119,13 @@ export async function markFileDeletedInCache(fileId: string): Promise { })) } -let sharedDeletedLookupPool: ReturnType | null = null +let sharedDeletedLookupPool: PgClient | null = null let sharedDeletedLookupPoolUrl: string | null = null -function getDeletedLookupPgClient(c: Context): ReturnType { +async function getDeletedLookupPgClient(c: Context): Promise { const dbUrl = getDatabaseURL(c, false) if (!sharedDeletedLookupPool || sharedDeletedLookupPoolUrl !== dbUrl) { - sharedDeletedLookupPool = getPgClient(c, false) + sharedDeletedLookupPool = await getPgClient(c, false) sharedDeletedLookupPoolUrl = dbUrl } return sharedDeletedLookupPool @@ -192,7 +192,7 @@ export async function isAttachmentVersionDeleted(c: Context, fileId: string): Pr return false try { - const pgClient = getDeletedLookupPgClient(c) + const pgClient = await getDeletedLookupPgClient(c) const result = await pgClient.query<{ deleted: boolean | null, deleted_at: string | null }>( ` SELECT deleted, deleted_at diff --git a/supabase/functions/_backend/files/files.ts b/supabase/functions/_backend/files/files.ts index 91dbdcc7ed..cb7ab36716 100644 --- a/supabase/functions/_backend/files/files.ts +++ b/supabase/functions/_backend/files/files.ts @@ -897,7 +897,7 @@ async function checkWriteAppAccess(c: Context, next: Next) { }) // Use Postgres instead of Supabase SDK - const pgClient = getPgClient(c, false) // authz + plan gating must read primary + const pgClient = await getPgClient(c, false) // authz + plan gating must read primary const drizzleClient = getDrizzleClient(pgClient) try { diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index e349b43b91..a28dbaf96d 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -195,7 +195,7 @@ async function ensureOrgMembership( userId: string, invitation: any, ) { - const pgPool = getPgClient(c, false) + const pgPool = await getPgClient(c, false) let pgClient: PoolClient | null = null let transactionStarted = false diff --git a/supabase/functions/_backend/private/bundle_install_stats.ts b/supabase/functions/_backend/private/bundle_install_stats.ts index d2e88c6fb0..118d6a818b 100644 --- a/supabase/functions/_backend/private/bundle_install_stats.ts +++ b/supabase/functions/_backend/private/bundle_install_stats.ts @@ -551,7 +551,7 @@ async function readBundleInstallStatsSB( endInclusive: dayjs.Dayjs, versionFilter?: Set, ) { - const db = getPgClient(c, true) + const db = await getPgClient(c, true) try { const versionNames = versionFilter ? [...versionFilter] : undefined const hasVersionFilter = Boolean(versionNames?.length) diff --git a/supabase/functions/_backend/private/create_device.ts b/supabase/functions/_backend/private/create_device.ts index 4a674ab906..7d02d65bbf 100644 --- a/supabase/functions/_backend/private/create_device.ts +++ b/supabase/functions/_backend/private/create_device.ts @@ -5,7 +5,7 @@ import { Hono } from 'hono/tiny' import { safeParseSchema } from '../utils/schema_validation.ts' import { BRES, parseBody, quickError, simpleError, useCors } from '../utils/hono.ts' import { middlewareAuth } from '../utils/hono_middleware.ts' -import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient} from '../utils/pg.ts' import { schema } from '../utils/postgres_schema.ts' import { checkPermission } from '../utils/rbac.ts' import { createStatsDevices } from '../utils/stats.ts' @@ -43,9 +43,9 @@ app.post('/', middlewareAuth(), async (c) => { const normalizedOrgId = safeBody.org_id.toLowerCase() let appOwnerOrg: string | null = null - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const appResult = await drizzleClient .select({ ownerOrg: schema.apps.owner_org }) diff --git a/supabase/functions/_backend/private/groups.ts b/supabase/functions/_backend/private/groups.ts index 16de175f13..acda0c6d60 100644 --- a/supabase/functions/_backend/private/groups.ts +++ b/supabase/functions/_backend/private/groups.ts @@ -165,7 +165,7 @@ app.get('/:org_id', sValidator('param', orgIdParamSchema, invalidOrgIdHook), asy let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) // Fetch groups @@ -224,7 +224,7 @@ app.post( let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) // Create the group @@ -273,7 +273,7 @@ app.put( let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) const result = await withLockedGroup(drizzle, groupId, async (txDrizzle, group) => { if (group.is_system) { @@ -344,7 +344,7 @@ app.delete('/:group_id', sValidator('param', groupIdParamSchema, invalidGroupIdH let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) const result = await withLockedGroup(drizzle, groupId, async (txDrizzle, group) => { if (group.is_system) { @@ -414,7 +414,7 @@ app.get('/:group_id/members', sValidator('param', groupIdParamSchema, invalidGro let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) // Fetch the group and verify access @@ -496,7 +496,7 @@ app.post( let pgClient let targetUserId: string | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) const result = await withLockedGroup(drizzle, groupId, async (txDrizzle, group) => { if (!(await canManageGroupRoles(c, txDrizzle, group.org_id))) { @@ -582,7 +582,7 @@ app.delete('/:group_id/members/:user_id', sValidator('param', groupMemberParamSc let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) const result = await withLockedGroup(drizzle, groupId, async (txDrizzle, group) => { if (!(await canManageGroupRoles(c, txDrizzle, group.org_id))) { diff --git a/supabase/functions/_backend/private/invite_existing_user_to_org.ts b/supabase/functions/_backend/private/invite_existing_user_to_org.ts index a8782f0509..e592087fc6 100644 --- a/supabase/functions/_backend/private/invite_existing_user_to_org.ts +++ b/supabase/functions/_backend/private/invite_existing_user_to_org.ts @@ -7,7 +7,7 @@ import { CacheHelper } from '../utils/cache.ts' import { BRES, createHono, parseBody, quickError, useCors } from '../utils/hono.ts' import { middlewareAuth } from '../utils/hono_jwt.ts' import { cloudlog } from '../utils/logging.ts' -import { closeClient, getPgClient } from '../utils/pg.ts' +import { closeClient, getPgClient} from '../utils/pg.ts' import { canCallerAssignOrgRole, checkPermission } from '../utils/rbac.ts' import { supabaseAdmin } from '../utils/supabase.ts' import { getEnv } from '../utils/utils.ts' @@ -57,7 +57,7 @@ export function getInviteResendRequiredPermission( } async function lockInviteNotification(c: AppContext, orgId: string, userId: string) { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const inviteNotificationLockKey = getInviteNotificationLockKey(orgId, userId) try { @@ -79,7 +79,7 @@ async function lockInviteNotification(c: AppContext, orgId: string, userId: stri async function unlockInviteNotification( c: AppContext, - pgClient: ReturnType, + pgClient: PgClient, orgId: string, userId: string, ) { diff --git a/supabase/functions/_backend/private/latency.ts b/supabase/functions/_backend/private/latency.ts index 7c23a81b30..d722824bb4 100644 --- a/supabase/functions/_backend/private/latency.ts +++ b/supabase/functions/_backend/private/latency.ts @@ -8,7 +8,7 @@ export const app = new Hono() app.get('/', async (c) => { cloudlog({ requestId: c.get('requestId'), message: 'Latency check' }) - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const res = await selectOne(pgClient) await closeClient(c, pgClient) diff --git a/supabase/functions/_backend/private/log_as.ts b/supabase/functions/_backend/private/log_as.ts index df0418b8cd..7329d0360f 100644 --- a/supabase/functions/_backend/private/log_as.ts +++ b/supabase/functions/_backend/private/log_as.ts @@ -98,7 +98,7 @@ async function getUserEmailById(supabaseAdmin: SupabaseAdmin, userId: string): P } async function getUserEmailByAuthEmail(c: Context, email: string): Promise { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) try { const result = await pgClient.query<{ email: string | null }>( @@ -124,7 +124,7 @@ async function getUserEmailByAuthEmail(c: Context, email } async function getOrgOwner(c: Context, orgId: string): Promise<{ userId: string, email: string } | null> { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) try { const result = await pgClient.query<{ user_id: string, email: string }>( @@ -300,7 +300,7 @@ app.post('/', middlewareAuth, async (c) => { // row long enough that support spoof does not lose MFA mid-session. const IMPERSONATION_SESSION_TTL_MS = 24 * 60 * 60 * 1000 const expiresAt = new Date(Math.max(jwtExpMs, Date.now() + IMPERSONATION_SESSION_TTL_MS)) - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) try { await pgClient.query( ` diff --git a/supabase/functions/_backend/private/native_observe_stats.ts b/supabase/functions/_backend/private/native_observe_stats.ts index ace6b73e63..4457af8c07 100644 --- a/supabase/functions/_backend/private/native_observe_stats.ts +++ b/supabase/functions/_backend/private/native_observe_stats.ts @@ -773,7 +773,7 @@ async function readReleaseMarkers( start: Dayjs, endExclusive: Dayjs, ) { - const db = getPgClient(c, true) + const db = await getPgClient(c, true) try { const result = await db.query( releaseMarkersQuery, @@ -884,7 +884,7 @@ async function readNativeObserveStatsSB( const { appId, days, labels, start, endExclusive, endInclusive, versionGroup } = input const params = [appId, start.toISOString(), endExclusive.toISOString(), nativeObserveActions] const paramsWithIssues = [...params, issueActions] - const db = getPgClient(c, true) + const db = await getPgClient(c, true) try { const dailyResult = await db.query(dailyStatsQuery, params) @@ -973,7 +973,7 @@ export async function readNativeObserveStats( } async function readNativeObservePluginStatsSB(c: Context, appId: string) { - const db = getPgClient(c, true) + const db = await getPgClient(c, true) try { const pluginVersionResult = await db.query(pluginVersionStatsQuery, [appId]) diff --git a/supabase/functions/_backend/private/org_notification_stats.ts b/supabase/functions/_backend/private/org_notification_stats.ts index 2d5ea0155f..1a4de7378e 100644 --- a/supabase/functions/_backend/private/org_notification_stats.ts +++ b/supabase/functions/_backend/private/org_notification_stats.ts @@ -6,7 +6,7 @@ import { MAX_ORG_NOTIFICATION_STATS_APPS, readNotificationStatsCF, } from '../utils/nativeNotifications.ts' -import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient} from '../utils/pg.ts' import { checkPermission } from '../utils/rbac.ts' import { version } from '../utils/version.ts' @@ -19,9 +19,9 @@ export const app = createHono('private/org_notification_stats', version) app.use('*', useCors) async function readOrgNotificationOverview(c: Parameters[0], orgId: string) { - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c, true) + pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute(sql` SELECT diff --git a/supabase/functions/_backend/private/replay.ts b/supabase/functions/_backend/private/replay.ts index 54e5411abd..eb185ae642 100644 --- a/supabase/functions/_backend/private/replay.ts +++ b/supabase/functions/_backend/private/replay.ts @@ -5,7 +5,7 @@ import { Hono } from 'hono/tiny' import { BRES, parseBody, quickError, useCors } from '../utils/hono.ts' import { middlewareAuth } from '../utils/hono_middleware.ts' import { cloudlogErr, serializeError } from '../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient} from '../utils/pg.ts' import { schema } from '../utils/postgres_schema.ts' import { capturePosthogReplaySnapshot } from '../utils/posthog.ts' @@ -77,9 +77,9 @@ function validateReplayBody(body: CliReplayBody): ValidatedReplayPayload { } async function getAuthenticatedUserEmail(c: Context, userId: string) { - let pgClient: ReturnType | null = null + let pgClient: PgClient | null = null try { - pgClient = getPgClient(c, true) + pgClient = await getPgClient(c, true) const drizzle = getDrizzleClient(pgClient) const rows = await drizzle .select({ email: schema.users.email }) diff --git a/supabase/functions/_backend/private/role_bindings.ts b/supabase/functions/_backend/private/role_bindings.ts index dfccf84a5a..0306ecbfb0 100644 --- a/supabase/functions/_backend/private/role_bindings.ts +++ b/supabase/functions/_backend/private/role_bindings.ts @@ -864,7 +864,7 @@ app.get('/app/:app_id/channel', requireAuthAndGuardLimitedKeys, sValidator('para let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) const appRow = await loadRoleBindingApp(drizzle, appId) @@ -950,7 +950,7 @@ app.get('/app/:app_id/principals', requireAuthAndGuardLimitedKeys, sValidator('p let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) const appRow = await loadRoleBindingApp(drizzle, appId) @@ -1030,7 +1030,7 @@ app.get('/:org_id', requireAuthAndGuardLimitedKeys, sValidator('param', orgIdPar let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) if (!(await checkPermission(c, 'org.read_members', { orgId }))) { @@ -1109,7 +1109,7 @@ app.post('/', requireAuthMfaAndGuardLimitedKeys, async (c) => { let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) const result = await drizzle.transaction(async (tx) => { const txDrizzle = tx as unknown as DrizzleClient @@ -1210,7 +1210,7 @@ app.patch( let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) const lockOrgId = await loadRoleBindingLockOrgId(drizzle, bindingId) if (!lockOrgId) { @@ -1295,7 +1295,7 @@ app.delete('/:binding_id', requireAuthMfaAndGuardLimitedKeys, sValidator('param' let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) const lockOrgId = await loadRoleBindingLockOrgId(drizzle, bindingId) if (!lockOrgId) { diff --git a/supabase/functions/_backend/private/roles.ts b/supabase/functions/_backend/private/roles.ts index e337bbfdda..e055db2be2 100644 --- a/supabase/functions/_backend/private/roles.ts +++ b/supabase/functions/_backend/private/roles.ts @@ -22,7 +22,7 @@ app.get('/', async (c) => { } try { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) // Récupérer tous les rôles assignables @@ -62,7 +62,7 @@ app.get('/:scope_type', sValidator('param', roleScopeParamSchema, invalidScopeTy } try { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) // Récupérer les rôles pour ce scope diff --git a/supabase/functions/_backend/private/sso/prelink-shared.ts b/supabase/functions/_backend/private/sso/prelink-shared.ts index f9412f2ba0..a07c1643ec 100644 --- a/supabase/functions/_backend/private/sso/prelink-shared.ts +++ b/supabase/functions/_backend/private/sso/prelink-shared.ts @@ -108,7 +108,7 @@ async function fallbackDeleteEmailIdentity( userId: string, identityId: string, ): Promise<{ error: string | null }> { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) try { // Local/self-hosted Supabase builds can lack the GoTrue admin identity-delete @@ -156,7 +156,7 @@ async function getOrgPrelinkCandidates( orgId: string, domain: string, ): Promise { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) try { const result = await pgClient.query( diff --git a/supabase/functions/_backend/private/sso/providers.ts b/supabase/functions/_backend/private/sso/providers.ts index fbb48f49df..f99ac4b3e0 100644 --- a/supabase/functions/_backend/private/sso/providers.ts +++ b/supabase/functions/_backend/private/sso/providers.ts @@ -6,7 +6,7 @@ import { safeParseSchema } from '../../utils/schema_validation.ts' import { BRES, createHono, parseBody, quickError, simpleError, useCors } from '../../utils/hono.ts' import { middlewareAuth } from '../../utils/hono_jwt.ts' import { cloudlogErr } from '../../utils/logging.ts' -import { closeClient, getPgClient } from '../../utils/pg.ts' +import { closeClient, getPgClient} from '../../utils/pg.ts' import { requireEnterprisePlan } from '../../utils/plan-gating.ts' import { checkPermission } from '../../utils/rbac.ts' import { createSSOProvider, deleteSSOProvider, ManagementAPIError } from '../../utils/supabase-management.ts' @@ -91,9 +91,9 @@ async function requireManageSsoPermission(c: Context, or } async function syncAuthUsersSsoOnlyByDomain(c: Context, domain: string, isSsoOnly: boolean): Promise { - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) await pgClient.query( ` update auth.users diff --git a/supabase/functions/_backend/private/sso/provision-user.ts b/supabase/functions/_backend/private/sso/provision-user.ts index 5203e7e58a..10e3d71c70 100644 --- a/supabase/functions/_backend/private/sso/provision-user.ts +++ b/supabase/functions/_backend/private/sso/provision-user.ts @@ -3,7 +3,7 @@ import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import { createHono, quickError, useCors } from '../../utils/hono.ts' import { middlewareAuth } from '../../utils/hono_jwt.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' -import { getPgClient } from '../../utils/pg.ts' +import { getPgClient} from '../../utils/pg.ts' import { supabaseAdmin } from '../../utils/supabase.ts' import { version } from '../../utils/version.ts' @@ -36,7 +36,7 @@ export const app = createHono('', version) app.use('*', useCors) app.use('*', middlewareAuth) -async function findCanonicalAuthUserIdByEmail(pgClient: ReturnType, email: string, excludedUserId: string, trustedProviders: string[]): Promise { +async function findCanonicalAuthUserIdByEmail(pgClient: PgClient, email: string, excludedUserId: string, trustedProviders: string[]): Promise { const result = await pgClient.query<{ id: string }>( ` select au.id @@ -127,7 +127,7 @@ function getAuthorizedSsoProviders(provider: SsoProviderRecord, authenticatedPro }) } -async function transferSsoIdentities(pgClient: ReturnType, originalUserId: string, duplicateUserId: string, trustedProviders: string[]): Promise { +async function transferSsoIdentities(pgClient: PgClient, originalUserId: string, duplicateUserId: string, trustedProviders: string[]): Promise { const result = await pgClient.query( ` update auth.identities @@ -142,7 +142,7 @@ async function transferSsoIdentities(pgClient: ReturnType, o return result.rowCount ?? 0 } -async function setAuthUserSsoOnly(pgClient: ReturnType, userId: string, authorizedSsoProviders: string[]): Promise { +async function setAuthUserSsoOnly(pgClient: PgClient, userId: string, authorizedSsoProviders: string[]): Promise { const primarySsoProvider = authorizedSsoProviders[0] if (!primarySsoProvider) { throw new Error('missing_sso_provider') @@ -192,7 +192,7 @@ function buildPublicUserSeed(userId: string, email: string, userMetadata: Record } async function ensureOrgMembership( - pgClient: ReturnType, + pgClient: PgClient, requestId: string, userId: string, orgId: string, @@ -266,7 +266,7 @@ async function ensurePublicUserRowExists( } async function ensurePublicUserRowExistsInTransaction( - pgClient: ReturnType, + pgClient: PgClient, requestId: string, user: PublicUserSeed, ): Promise { @@ -289,7 +289,7 @@ async function ensurePublicUserRowExistsInTransaction( } async function ensureOrgMembershipInTransaction( - pgClient: ReturnType, + pgClient: PgClient, requestId: string, userId: string, orgId: string, @@ -447,7 +447,7 @@ async function ensureOrgMembershipInTransaction( } async function mergeSsoIdentityWithExistingAccount( - pgClient: ReturnType, + pgClient: PgClient, requestId: string, params: { originalUserId: string @@ -511,9 +511,9 @@ app.post('/', async (c: Context) => { } const admin = supabaseAdmin(c) - let pgClient: ReturnType | undefined - const getSharedPgClient = () => { - pgClient ??= getPgClient(c) + let pgClient: PgClient | undefined + const getSharedPgClient = async () => { + pgClient ??= await getPgClient(c) return pgClient } @@ -563,7 +563,7 @@ app.post('/', async (c: Context) => { // so a pre-signup cannot become the merge target. let resolvedExistingUserId: string | null = null try { - resolvedExistingUserId = await findCanonicalAuthUserIdByEmail(getSharedPgClient(), userEmail, userId, trustedSsoProviders) + resolvedExistingUserId = await findCanonicalAuthUserIdByEmail(await getSharedPgClient(), userEmail, userId, trustedSsoProviders) if (resolvedExistingUserId) { cloudlog({ requestId, message: 'Canonical pre-existing auth account found — will merge SSO identity after provider authorization', userId, originalUserId: resolvedExistingUserId, email: userEmail }) } @@ -608,7 +608,7 @@ app.post('/', async (c: Context) => { // Step 2: Transfer the SSO identity and provision the merged account atomically. try { - await mergeSsoIdentityWithExistingAccount(getSharedPgClient(), requestId, { + await mergeSsoIdentityWithExistingAccount(await getSharedPgClient(), requestId, { originalUserId, duplicateUserId: userId, publicUser: { @@ -683,7 +683,7 @@ app.post('/', async (c: Context) => { let membershipResult: EnsureOrgMembershipResult try { - membershipResult = await ensureOrgMembership(getSharedPgClient(), requestId, userId, provider.org_id) + membershipResult = await ensureOrgMembership(await getSharedPgClient(), requestId, userId, provider.org_id) } catch { return quickError(500, 'provision_failed', 'Failed to provision user to organization') diff --git a/supabase/functions/_backend/private/sso/verify-dns.ts b/supabase/functions/_backend/private/sso/verify-dns.ts index 2c15cb0023..7393d6b121 100644 --- a/supabase/functions/_backend/private/sso/verify-dns.ts +++ b/supabase/functions/_backend/private/sso/verify-dns.ts @@ -62,7 +62,7 @@ app.post('/', middlewareAuth, async (c) => { } if (result.verified) { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) try { const updateResult = await pgClient.query( `UPDATE sso_providers diff --git a/supabase/functions/_backend/private/update_delivery_stats.ts b/supabase/functions/_backend/private/update_delivery_stats.ts index 8f1e6ef040..df22d0432f 100644 --- a/supabase/functions/_backend/private/update_delivery_stats.ts +++ b/supabase/functions/_backend/private/update_delivery_stats.ts @@ -540,7 +540,7 @@ async function readUpdateDeliveryStatsSB( endExclusive: Dayjs, endInclusive: Dayjs, ) { - const db = getPgClient(c, true) + const db = await getPgClient(c, true) try { const query = buildStatsQuery(scope) diff --git a/supabase/functions/_backend/public/apikey/get.ts b/supabase/functions/_backend/public/apikey/get.ts index cd861b87fa..0017deb12e 100644 --- a/supabase/functions/_backend/public/apikey/get.ts +++ b/supabase/functions/_backend/public/apikey/get.ts @@ -34,7 +34,7 @@ async function withGlobalPermissions( let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const { rows } = await pgClient.query<{ apikey_rbac_id: string, permission_key: string }>( `SELECT apikey_rbac_id::text, permission_key FROM public.apikey_global_permissions diff --git a/supabase/functions/_backend/public/apikey/post.ts b/supabase/functions/_backend/public/apikey/post.ts index e4930da007..fb6d1f644c 100644 --- a/supabase/functions/_backend/public/apikey/post.ts +++ b/supabase/functions/_backend/public/apikey/post.ts @@ -7,7 +7,7 @@ import { getErrorStatus } from '../../utils/errors.ts' import { honoFactory, parseBody, quickError, simpleError } from '../../utils/hono.ts' import { middlewareAuth } from '../../utils/hono_middleware.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient } from '../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient} from '../../utils/pg.ts' import { checkPermissionPg } from '../../utils/rbac.ts' import { assertExpirationMatchesOrgPolicies, validateExpirationDate } from '../../utils/supabase.ts' import { parseApiKeyGlobalPermissions, replaceApiKeyGlobalPermissions, validateApiKeyGlobalPermissionsForBindings } from './global_permissions.ts' @@ -140,9 +140,9 @@ app.post('/', middlewareAuth(), async (c) => { let apikeyData: ApiKeyRow | null = null - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) const createdBindings: unknown[] = [] const callerPrincipalId = auth.userId diff --git a/supabase/functions/_backend/public/apikey/put.ts b/supabase/functions/_backend/public/apikey/put.ts index 812e926ae1..8b00d24773 100644 --- a/supabase/functions/_backend/public/apikey/put.ts +++ b/supabase/functions/_backend/public/apikey/put.ts @@ -9,7 +9,7 @@ import { getErrorCode, getErrorStatus } from '../../utils/errors.ts' import { honoFactory, parseBody, quickError, simpleError } from '../../utils/hono.ts' import { middlewareAuth } from '../../utils/hono_middleware.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient } from '../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient} from '../../utils/pg.ts' import { schema } from '../../utils/postgres_schema.ts' import { checkPermission, checkPermissionPg } from '../../utils/rbac.ts' import { supabaseAdmin, supabaseWithAuth, validateExpirationAgainstOrgPolicies, validateExpirationDate } from '../../utils/supabase.ts' @@ -98,9 +98,9 @@ async function replaceApiKeyBindings( } } - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) if (globalPermissions !== undefined) { validateApiKeyGlobalPermissionsForBindings(globalPermissions, bindings, c.get('requestId')) @@ -219,9 +219,9 @@ async function replaceApiKeyGlobalPermissionsForExistingBindings( } } - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzle = getDrizzleClient(pgClient) await drizzle.transaction(async (tx) => { diff --git a/supabase/functions/_backend/public/apikey/scope.ts b/supabase/functions/_backend/public/apikey/scope.ts index 9db42f9a29..1c9c63fabb 100644 --- a/supabase/functions/_backend/public/apikey/scope.ts +++ b/supabase/functions/_backend/public/apikey/scope.ts @@ -4,7 +4,7 @@ import type { getDrizzleClient } from '../../utils/pg.ts' import type { Database } from '../../utils/supabase.types.ts' import { quickError } from '../../utils/hono.ts' import { assertJwtMfaAssurance } from '../../utils/jwt_mfa_assurance.ts' -import { closeClient, getPgClient } from '../../utils/pg.ts' +import { closeClient, getPgClient} from '../../utils/pg.ts' import { checkPermission, checkPermissionPg } from '../../utils/rbac.ts' import { supabaseAdmin, supabaseWithAuth } from '../../utils/supabase.ts' @@ -53,9 +53,9 @@ async function loadApiKeyBindingOrgIdsForRbacIds( return orgIdsByRbacId } - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const { rows } = await pgClient.query<{ principal_id: string, org_id: string }>( ` SELECT DISTINCT principal_id::text, org_id::text @@ -204,9 +204,9 @@ async function loadApiKeyOrgRoleBindings( c: Context, apikeyRbacId: string, ): Promise> { - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const { rows } = await pgClient.query<{ role_name: string, org_id: string }>( ` SELECT DISTINCT r.name AS role_name, rb.org_id::text AS org_id diff --git a/supabase/functions/_backend/public/app/demo.ts b/supabase/functions/_backend/public/app/demo.ts index 0f278dff55..c9983497c5 100644 --- a/supabase/functions/_backend/public/app/demo.ts +++ b/supabase/functions/_backend/public/app/demo.ts @@ -4,7 +4,7 @@ import type { Database } from '../../utils/supabase.types.ts' import { lockOnboardingApp, unlockOnboardingApp } from '../../utils/demo.ts' import { simpleError } from '../../utils/hono.ts' import { cloudlog } from '../../utils/logging.ts' -import { closeClient, getPgClient, logPgError } from '../../utils/pg.ts' +import { closeClient, getPgClient, logPgError, type PgClient } from '../../utils/pg.ts' import { checkPermission } from '../../utils/rbac.ts' import { supabaseAdmin } from '../../utils/supabase.ts' @@ -225,8 +225,6 @@ function generateDeviceId(): string { return crypto.randomUUID() } -type PgClient = ReturnType - interface SeedDemoAppDataOptions { appUuid: string appId: string @@ -277,7 +275,7 @@ async function seedOnboardingDemoDataInTransaction( c: Context, options: SeedDemoAppDataOptions, ): Promise { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) let shouldRollback = false try { diff --git a/supabase/functions/_backend/public/app/post.ts b/supabase/functions/_backend/public/app/post.ts index 6603f0c419..25fe938935 100644 --- a/supabase/functions/_backend/public/app/post.ts +++ b/supabase/functions/_backend/public/app/post.ts @@ -61,7 +61,7 @@ export async function post(c: Context, body: CreateApp): let pgClient let data: Database['public']['Tables']['apps']['Row'] | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const storedCreator = auth.claims?.email ? undefined : await pgClient.query<{ email: string }>('SELECT email FROM public.users WHERE id = $1 LIMIT 1', [auth.userId]) diff --git a/supabase/functions/_backend/public/app/put.ts b/supabase/functions/_backend/public/app/put.ts index 29f8238d42..ebc89dcfd8 100644 --- a/supabase/functions/_backend/public/app/put.ts +++ b/supabase/functions/_backend/public/app/put.ts @@ -40,7 +40,7 @@ async function persistAppOnboarding( transactionClient?: PoolClient, completePendingOnboarding = false, ) { - const pool = transactionClient ? null : getPgClient(c) + const pool = transactionClient ? null : await getPgClient(c) try { const drizzle = getDrizzleClient(transactionClient ?? pool!) return await drizzle.transaction(async (tx) => { diff --git a/supabase/functions/_backend/public/build/concurrency.ts b/supabase/functions/_backend/public/build/concurrency.ts index 7f2c213f45..26b375963a 100644 --- a/supabase/functions/_backend/public/build/concurrency.ts +++ b/supabase/functions/_backend/public/build/concurrency.ts @@ -2,7 +2,7 @@ import type { Context } from 'hono' import { HTTPException } from 'hono/http-exception' import { quickError, simpleError } from '../../utils/hono.ts' import { cloudlog, cloudlogErr, serializeError } from '../../utils/logging.ts' -import { closeClient, getPgClient, logPgError } from '../../utils/pg.ts' +import { closeClient, getPgClient, logPgError} from '../../utils/pg.ts' import { sendEventToTracking } from '../../utils/tracking.ts' import { getEnv, trimTrailingSlashes } from '../../utils/utils.ts' @@ -211,11 +211,11 @@ export async function assertNativeBuildConcurrencyAvailable( c: Context, input: { orgId: string, appId: string, userId?: string | null }, ): Promise { - let pgPool: ReturnType | null = null + let pgPool: PgClient | null = null let client: PgClient | null = null try { - pgPool = getPgClient(c, true) + pgPool = await getPgClient(c, true) client = await pgPool.connect() as PgClient const { planName, limit } = await readPlanConcurrencyLimit(client, input.orgId) const activeBuilds = await countActiveNativeBuilds(client, input.orgId) @@ -248,11 +248,11 @@ export async function reserveNativeBuildSlot( ): Promise { let planName: string let limit: number - let pgPool: ReturnType | null = null + let pgPool: PgClient | null = null let client: PgClient | null = null try { - pgPool = getPgClient(c) + pgPool = await getPgClient(c) client = await pgPool.connect() as PgClient await client.query('BEGIN') diff --git a/supabase/functions/_backend/public/build/support_logs.ts b/supabase/functions/_backend/public/build/support_logs.ts index 3695f74a49..8e5e51d2ca 100644 --- a/supabase/functions/_backend/public/build/support_logs.ts +++ b/supabase/functions/_backend/public/build/support_logs.ts @@ -75,7 +75,7 @@ async function bumpWindow(c: Context, path: string, userId: string, limit: numbe async function appExists(c: Context, appId: string): Promise { let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute( sql`SELECT EXISTS ( @@ -106,7 +106,7 @@ async function hasCurrentWriteCapableOrgBinding(c: Context, apikey: Database['pu let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute( sql`SELECT public.apikey_has_current_org_create_capability(${apikey.rbac_id}::uuid) AS allowed`, diff --git a/supabase/functions/_backend/public/bundle/set_channel.ts b/supabase/functions/_backend/public/bundle/set_channel.ts index cfa36f4e11..f54d15abbb 100644 --- a/supabase/functions/_backend/public/bundle/set_channel.ts +++ b/supabase/functions/_backend/public/bundle/set_channel.ts @@ -4,7 +4,7 @@ import type { Database } from '../../utils/supabase.types.ts' import { HTTPException } from 'hono/http-exception' import { throwIfChannelUpdatePackageMismatch } from '../../utils/channel_update_package.ts' import { simpleError } from '../../utils/hono.ts' -import { closeClient, getDrizzleClient, getPgClient, logPgError } from '../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, logPgError} from '../../utils/pg.ts' import { checkPermissionPg } from '../../utils/rbac.ts' import { isValidAppId } from '../../utils/utils.ts' @@ -98,7 +98,7 @@ export async function assertCanPromoteChannelInTransaction( dbClient: PgQueryClient, checkAppScope = false, ) { - const drizzle = getDrizzleClient(dbClient as unknown as ReturnType) as DrizzleClient + const drizzle = getDrizzleClient(dbClient as unknown as PgClient) as DrizzleClient const canPromote = await checkPermissionPg( c, 'channel.promote_bundle', @@ -145,7 +145,7 @@ export async function setChannelInTransaction( } export async function setChannel(c: Context, body: SetChannelBody, apikey: Database['public']['Tables']['apikeys']['Row']): Promise { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) let dbClient: PgQueryClient | null = null let transactionStarted = false let result: SetChannelResult | null = null diff --git a/supabase/functions/_backend/public/channel/delete.ts b/supabase/functions/_backend/public/channel/delete.ts index d77d1f21d8..32f94889ea 100644 --- a/supabase/functions/_backend/public/channel/delete.ts +++ b/supabase/functions/_backend/public/channel/delete.ts @@ -169,7 +169,7 @@ async function deletePreviewChannelAndBundle( apikey: Database['public']['Tables']['apikeys']['Row'], ) { const effectiveApikey = getEffectiveApikey(c, apikey) - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) let dbClient: PgQueryClient | null = null let transactionStarted = false diff --git a/supabase/functions/_backend/public/channel/post.ts b/supabase/functions/_backend/public/channel/post.ts index 8ad9f25d01..954498c8cc 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -5,7 +5,7 @@ import { HTTPException } from 'hono/http-exception' import { throwIfChannelUpdatePackageMismatch } from '../../utils/channel_update_package.ts' import { BRES, simpleError } from '../../utils/hono.ts' import { cloudlogErr } from '../../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient, logPgError } from '../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, logPgError} from '../../utils/pg.ts' import { checkPermission, checkPermissionPg } from '../../utils/rbac.ts' import { supabaseAdmin, updateOrCreateChannel } from '../../utils/supabase.ts' import { isInternalVersionName, isValidAppId } from '../../utils/utils.ts' @@ -273,7 +273,7 @@ async function createAndPromoteChannelInTransaction( throw simpleError('cannot_set_bundle_to_channel', 'Cannot set bundle to channel', { error: 'Missing API key context for audit logging' }) } - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) let dbClient: PgQueryClient | null = null let transactionStarted = false try { @@ -285,7 +285,7 @@ async function createAndPromoteChannelInTransaction( [JSON.stringify({ capgkey: effectiveApikey })], ) - const drizzle = getDrizzleClient(dbClient as unknown as ReturnType) as DrizzleClient + const drizzle = getDrizzleClient(dbClient as unknown as PgClient) as DrizzleClient const canCreateChannel = await checkPermissionPg( c, 'app.create_channel', diff --git a/supabase/functions/_backend/public/notifications/index.ts b/supabase/functions/_backend/public/notifications/index.ts index 8a966ee9ba..d9155d58ae 100644 --- a/supabase/functions/_backend/public/notifications/index.ts +++ b/supabase/functions/_backend/public/notifications/index.ts @@ -27,7 +27,7 @@ import { verifyNotificationEventProof, verifyNotificationIdentityProof, } from '../../utils/nativeNotifications.ts' -import { closeClient, getDrizzleClient, getPgClient } from '../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient} from '../../utils/pg.ts' import { checkPermission } from '../../utils/rbac.ts' import { isLimited, isValidAppId } from '../../utils/utils.ts' import { version } from '../../utils/version.ts' @@ -261,9 +261,9 @@ async function assertAppPermission(c: Context, permissio } async function getNotificationProviderConfigs(c: Context, appId: string): Promise { - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute(sql` SELECT provider, status, config, secret_ref, secret_ciphertext @@ -287,9 +287,9 @@ async function getNotificationProviderConfigs(c: Context } async function getAppOwnerOrg(c: Context, appId: string): Promise { - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute(sql`SELECT owner_org::text AS owner_org FROM public.apps WHERE app_id = ${appId} LIMIT 1`) const ownerOrg = (result.rows[0] as OwnerOrgRow | undefined)?.owner_org @@ -368,9 +368,9 @@ function resolveProviderConfigProvider(body: ProviderBody): NativeNotificationPr } async function getExistingProviderSecretCiphertext(c: Context, appId: string, provider: NativeNotificationProvider): Promise { - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute(sql` SELECT secret_ciphertext @@ -525,9 +525,9 @@ async function resolveTargetPlan(c: Context, body: SendB } async function getNotificationSettings(c: Context, appId: string) { - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute(sql` SELECT app_id, push_update_enabled, push_update_install_mode, push_update_channel @@ -551,9 +551,9 @@ async function upsertNotificationSettings(c: Context, bo const pushUpdateInstallMode = body.pushUpdateInstallMode === 'set' ? 'set' : 'next' const pushUpdateChannel = body.pushUpdateChannel ? assertString(body.pushUpdateChannel, 'pushUpdateChannel', 128) : null const auth = c.get('auth') - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute(sql` INSERT INTO public.notification_app_settings (owner_org, app_id, push_update_enabled, push_update_install_mode, push_update_channel, created_by) @@ -582,9 +582,9 @@ async function createCampaignRecord(c: Context, body: Ca const scheduledAt = assertOptionalDate(body.scheduledAt, 'scheduledAt') const auth = c.get('auth') - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute(sql` INSERT INTO public.notification_campaigns (owner_org, app_id, name, kind, status, audience, payload, scheduled_at, queued_at, created_by) @@ -887,9 +887,9 @@ app.post('/send', middlewareAuth(), async (c) => { app.get('/campaigns', middlewareAuth(), async (c) => { const appId = assertString(c.req.query('app_id'), 'app_id', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute(sql` SELECT id, created_at, updated_at, owner_org::text, app_id, name, kind, status, audience, payload, scheduled_at, queued_at, completed_at, counters @@ -923,9 +923,9 @@ app.get('/stats', middlewareAuth(), async (c) => { app.get('/providers', middlewareAuth(), async (c) => { const appId = assertString(c.req.query('app_id'), 'app_id', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute(sql` SELECT id, created_at, updated_at, owner_org::text, app_id, provider, status, config, secret_ref, secret_ciphertext @@ -961,9 +961,9 @@ app.put('/providers', middlewareAuth(), async (c) => { const secretRef = resolveProviderSecretRef(appId, provider, status, body.secretRef, hasUploadedSecret, hasStoredSecret) assertProviderConfigReady(provider, status, config, secretRef, hasUploadedSecret || hasStoredSecret) const auth = c.get('auth') - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute(sql` INSERT INTO public.notification_provider_configs (owner_org, app_id, provider, status, config, secret_ref, secret_ciphertext, created_by) diff --git a/supabase/functions/_backend/public/organization/members/delete.ts b/supabase/functions/_backend/public/organization/members/delete.ts index da31596cf4..b7796a45bb 100644 --- a/supabase/functions/_backend/public/organization/members/delete.ts +++ b/supabase/functions/_backend/public/organization/members/delete.ts @@ -6,7 +6,7 @@ import { HTTPException } from 'hono/http-exception' import { safeParseSchema } from '../../../utils/schema_validation.ts' import { BRES, quickError, simpleError } from '../../../utils/hono.ts' import { cloudlog } from '../../../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient } from '../../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient} from '../../../utils/pg.ts' import { checkPermission, checkPermissionPg } from '../../../utils/rbac.ts' import { supabaseAdmin } from '../../../utils/supabase.ts' @@ -159,7 +159,7 @@ async function assertMemberRemovalAuthorizedAfterLock( targetUserId: string, dbClient: PinnedPgClient, ): Promise { - const pinnedDrizzle = getDrizzleClient(dbClient as unknown as ReturnType) as DrizzleClient + const pinnedDrizzle = getDrizzleClient(dbClient as unknown as PgClient) as DrizzleClient const apikeyString = auth.apikey?.key ?? c.get('capgkey') ?? null const canManageRoles = await checkPermissionPg( c, @@ -219,7 +219,7 @@ export async function deleteMember(c: Context, bodyRaw: // Pin the transaction to one connection: rank read, cleanup, and membership deletion // must share the organization lock with every RBAC mutation trigger. - const pgPool = getPgClient(c) + const pgPool = await getPgClient(c) let dbClient: PinnedPgClient | undefined let transactionOpen = false cloudlog({ requestId: c.get('requestId'), message: 'targetUserId', data: targetUserId }) diff --git a/supabase/functions/_backend/public/organization/members/post.ts b/supabase/functions/_backend/public/organization/members/post.ts index 48abd82039..b37a321919 100644 --- a/supabase/functions/_backend/public/organization/members/post.ts +++ b/supabase/functions/_backend/public/organization/members/post.ts @@ -78,7 +78,7 @@ export async function post(c: Context, bodyRaw: unknown, // invite_user_to_org_rbac via Postgres (not service-role Supabase SDK) after // revoking anon execute. Mirrors organization/post.ts: BEGIN before // set_config(..., true) so capgkey survives until the RPC runs. - const pgPool = getPgClient(c) + const pgPool = await getPgClient(c) let dbClient: PgTransactionClient | null = null let transactionStarted = false try { diff --git a/supabase/functions/_backend/public/organization/post.ts b/supabase/functions/_backend/public/organization/post.ts index 7a9eaa3e9c..57caf7af8e 100644 --- a/supabase/functions/_backend/public/organization/post.ts +++ b/supabase/functions/_backend/public/organization/post.ts @@ -4,7 +4,7 @@ import type { Database } from '../../utils/supabase.types.ts' import { z } from 'zod' import { safeParseSchema } from '../../utils/schema_validation.ts' import { quickError, simpleError } from '../../utils/hono.ts' -import { closeClient, getPgClient } from '../../utils/pg.ts' +import { closeClient, getPgClient} from '../../utils/pg.ts' import { assertJwtMfaAssurance } from '../../utils/jwt_mfa_assurance.ts' import { supabaseAdmin, supabaseWithAuth } from '../../utils/supabase.ts' import { parseOrgOnboardingDevelopmentEnvironment, parseOrgOnboardingIntent } from '../../utils/org_onboarding_intent.ts' @@ -90,7 +90,7 @@ async function getOwnerEmail(c: Context, auth: AuthInfo) let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const result = await pgClient.query<{ email: string }>( 'SELECT email FROM public.users WHERE id = $1::uuid LIMIT 1', [auth.userId], @@ -121,7 +121,7 @@ async function ensureApiKeyCanCreateOrganization(c: Context( 'SELECT public.apikey_has_global_permission($1::text, public.rbac_perm_org_create()) AS allowed', [apikeyString], @@ -160,11 +160,11 @@ async function insertOrgForApiKey( } // API-key Supabase clients run as anon, so this checked endpoint owns the write path instead of reopening direct anon RLS inserts. - let pgPool: ReturnType | null = null + let pgPool: PgClient | null = null let dbClient: PgTransactionClient | null = null let transactionStarted = false try { - pgPool = getPgClient(c) + pgPool = await getPgClient(c) dbClient = await pgPool.connect() as PgTransactionClient const capabilityResult = await dbClient.query<{ allowed: boolean }>( 'SELECT public.apikey_has_current_org_create_capability($1::uuid) AS allowed', diff --git a/supabase/functions/_backend/public/organization/put.ts b/supabase/functions/_backend/public/organization/put.ts index 621d76ea56..0afafae110 100644 --- a/supabase/functions/_backend/public/organization/put.ts +++ b/supabase/functions/_backend/public/organization/put.ts @@ -5,7 +5,7 @@ import { z } from 'zod' import { HTTPException } from 'hono/http-exception' import { safeParseSchema } from '../../utils/schema_validation.ts' import { quickError, simpleError } from '../../utils/hono.ts' -import { closeClient, getPgClient } from '../../utils/pg.ts' +import { closeClient, getPgClient} from '../../utils/pg.ts' import { checkPermission } from '../../utils/rbac.ts' import { createSignedImageUrl, getStorageAllowedOrigins, resolveWritableImageValue } from '../../utils/storage.ts' import { getStripeCustomerName, isDeterministicStripeCustomerUpdateError, updateCustomerOrganizationName } from '../../utils/stripe.ts' @@ -271,7 +271,7 @@ async function sanitizeOrgNameForSync( name: string, ) { // Direct SQL avoids Kong/PostgREST upstream flakes under parallel test load. - const pgPool = getPgClient(c) + const pgPool = await getPgClient(c) let client: PgTransactionClient | null = null try { client = await pgPool.connect() as PgTransactionClient @@ -320,12 +320,12 @@ async function updateOrg( updateFields: OrgUpdateFields, options?: { expectedCurrentName?: string, expectedCurrentFields?: OrgUpdateFields }, ) { - let pgPool: ReturnType | null = null + let pgPool: PgClient | null = null let dbClient: PgTransactionClient | null = null let transactionStarted = false let data: OrgRow | undefined try { - pgPool = getPgClient(c) + pgPool = await getPgClient(c) dbClient = await pgPool.connect() as PgTransactionClient await dbClient.query('BEGIN') transactionStarted = true @@ -395,7 +395,7 @@ async function getOrgForNameSync( orgId: string, ): Promise { // Direct SQL avoids Kong/PostgREST upstream flakes under parallel test load. - const pgPool = getPgClient(c) + const pgPool = await getPgClient(c) let client: PgTransactionClient | null = null try { client = await pgPool.connect() as PgTransactionClient diff --git a/supabase/functions/_backend/public/queue_health.ts b/supabase/functions/_backend/public/queue_health.ts index 2fc1e1e45f..68d2d679c6 100644 --- a/supabase/functions/_backend/public/queue_health.ts +++ b/supabase/functions/_backend/public/queue_health.ts @@ -1,6 +1,6 @@ import { honoFactory, useCors } from '../utils/hono.ts' import { cloudlogErr } from '../utils/logging.ts' -import { closeClient, getPgClient, logPgError } from '../utils/pg.ts' +import { closeClient, getPgClient, logPgError} from '../utils/pg.ts' import { validatePlatformAdminOrApiSecret } from '../utils/platform_admin_access.ts' type QueueStatus = 'ok' | 'ko' @@ -292,7 +292,7 @@ function defaultThresholds(): QueueHealthThresholds { } } -async function listQueues(client: ReturnType): Promise { +async function listQueues(client: PgClient): Promise { const { rows } = await client.query<{ queue_name: string }>( 'SELECT queue_name FROM pgmq.list_queues() ORDER BY queue_name', ) @@ -301,7 +301,7 @@ async function listQueues(client: ReturnType): Promise typeof name === 'string' && isSafeQueueName(name)) } -async function loadQueueIntervals(client: ReturnType): Promise> { +async function loadQueueIntervals(client: PgClient): Promise> { const { rows } = await client.query<{ task_type: string target: unknown @@ -329,7 +329,7 @@ async function loadQueueIntervals(client: ReturnType): Promi } async function fetchQueueMetrics( - client: ReturnType, + client: PgClient, queueName: string, expectedIntervalSeconds: number | null, thresholds: QueueHealthThresholds, @@ -462,7 +462,7 @@ app.get('/', async (c) => { }) const thresholds = defaultThresholds() - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) try { const queueNames = await listQueues(pgClient) diff --git a/supabase/functions/_backend/public/replication.ts b/supabase/functions/_backend/public/replication.ts index 21f861c774..a22c4de3ce 100644 --- a/supabase/functions/_backend/public/replication.ts +++ b/supabase/functions/_backend/public/replication.ts @@ -3,7 +3,7 @@ import { sql } from 'drizzle-orm' import { CacheHelper } from '../utils/cache.ts' import { honoFactory, useCors } from '../utils/hono.ts' import { cloudlogErr } from '../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient, logPgError } from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, logPgError} from '../utils/pg.ts' import { validatePlatformAdminOrApiSecret } from '../utils/platform_admin_access.ts' const DEFAULT_THRESHOLD_SECONDS = 180 @@ -478,9 +478,9 @@ app.get('/', async (c) => { const thresholdSeconds = DEFAULT_THRESHOLD_SECONDS const thresholdBytes = DEFAULT_THRESHOLD_BYTES - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) - let replicaPgClient: ReturnType | null = null + let replicaPgClient: PgClient | null = null try { const { rows, mode } = await executeReplicationQuery({ requestId: c.get('requestId') }, drizzleClient) @@ -543,7 +543,7 @@ app.get('/', async (c) => { let dataCanary = skippedDataCanary('no_replica_connection') try { - replicaPgClient = getPgClient(c, true) + replicaPgClient = await getPgClient(c, true) const replicaSource = c.res.headers.get('X-Database-Source') ?? '' if (!isReplicaDatabaseSource(replicaSource)) { subscription = skippedSubscription('no_replica_connection') diff --git a/supabase/functions/_backend/public/webhooks/index.ts b/supabase/functions/_backend/public/webhooks/index.ts index bf13bdc562..0702302cc9 100644 --- a/supabase/functions/_backend/public/webhooks/index.ts +++ b/supabase/functions/_backend/public/webhooks/index.ts @@ -22,7 +22,7 @@ async function apiKeyHasAppScopedBinding( if (!apikey.rbac_id) return false - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) try { const result = await pgClient.query<{ has_app_scope: boolean }>( ` diff --git a/supabase/functions/_backend/triggers/credit_usage_alerts.ts b/supabase/functions/_backend/triggers/credit_usage_alerts.ts index 795a44ce51..687f468481 100644 --- a/supabase/functions/_backend/triggers/credit_usage_alerts.ts +++ b/supabase/functions/_backend/triggers/credit_usage_alerts.ts @@ -55,7 +55,7 @@ app.post('/', middlewareAPISecret, async (c) => { threshold, } - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) try { diff --git a/supabase/functions/_backend/triggers/cron_app_fame.ts b/supabase/functions/_backend/triggers/cron_app_fame.ts index dfa3b1a75b..5754a34163 100644 --- a/supabase/functions/_backend/triggers/cron_app_fame.ts +++ b/supabase/functions/_backend/triggers/cron_app_fame.ts @@ -11,7 +11,7 @@ import { } from '../utils/app_fame.ts' import { BRES, middlewareAPISecret, quickError } from '../utils/hono.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient, logPgError } from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, logPgError} from '../utils/pg.ts' export const app = new Hono() @@ -22,9 +22,9 @@ export async function processAppFameBatch(c: Context): P throw quickError(503, 'ai_unavailable', 'Workers AI binding is not configured') } - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const candidateResult = await drizzleClient.execute(sql` SELECT diff --git a/supabase/functions/_backend/triggers/cron_stat_app.ts b/supabase/functions/_backend/triggers/cron_stat_app.ts index c7d568bd5c..b5344b60bc 100644 --- a/supabase/functions/_backend/triggers/cron_stat_app.ts +++ b/supabase/functions/_backend/triggers/cron_stat_app.ts @@ -288,7 +288,7 @@ async function runSupabaseResultWithRetry( } async function readVersionMetaStorageRows(c: Parameters[0], appId: string, calculationEnd: string) { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) try { const { rows } = await pgClient.query( ` @@ -409,7 +409,7 @@ async function hasPendingAppStatsRefresh( c: Parameters[0], orgId: string, ): Promise { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const staleCutoff = new Date(Date.now() - APP_STATS_REFRESH_STALE_MS).toISOString() try { diff --git a/supabase/functions/_backend/triggers/cron_stat_org.ts b/supabase/functions/_backend/triggers/cron_stat_org.ts index ad5a9bd873..cac8cb9d6f 100644 --- a/supabase/functions/_backend/triggers/cron_stat_org.ts +++ b/supabase/functions/_backend/triggers/cron_stat_org.ts @@ -22,7 +22,7 @@ app.post('/', middlewareAPISecret, async (c) => { // `checkPlanStatusOnly()` may refresh the org metrics cache through // `get_plan_usage_and_fit_uncached()`, so this path must use a write-capable // transaction instead of a read-only pool. - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) try { let planStatusCalculated = false diff --git a/supabase/functions/_backend/triggers/cron_sync_sub.ts b/supabase/functions/_backend/triggers/cron_sync_sub.ts index 8474297c26..2da54db3fb 100644 --- a/supabase/functions/_backend/triggers/cron_sync_sub.ts +++ b/supabase/functions/_backend/triggers/cron_sync_sub.ts @@ -74,7 +74,7 @@ app.post('/', middlewareAPISecret, async (c) => { if (!body.orgId) throw simpleError('no_orgId', 'No orgId', { body }) - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) try { const { error, attempts } = await syncSubscriptionAndEventsWithRetry(c, body.orgId, drizzleClient) diff --git a/supabase/functions/_backend/triggers/global_stats.ts b/supabase/functions/_backend/triggers/global_stats.ts index ff4af056d5..1e15c363bf 100644 --- a/supabase/functions/_backend/triggers/global_stats.ts +++ b/supabase/functions/_backend/triggers/global_stats.ts @@ -10,7 +10,7 @@ import { GLOBAL_STATS_SHARDS, REQUIRED_GLOBAL_STATS_SHARDS, USAGE_GLOBAL_STATS_S import { BRES, middlewareAPISecret, quickError } from '../utils/hono.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' import { readGlobalNotificationStatsCF } from '../utils/nativeNotifications.ts' -import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient} from '../utils/pg.ts' import { countAllApps, countAllUpdates, countAllUpdatesExternal } from '../utils/stats.ts' import { supabaseAdmin } from '../utils/supabase.ts' import { sendEventToTracking } from '../utils/tracking.ts' @@ -617,7 +617,7 @@ async function reserveGlobalStatsRetry(c: Context, retryCount: number, dateId?: const nextRetryCount = retryCount + 1 const delaySeconds = GLOBAL_STATS_RETRY_DELAY_SECONDS * nextRetryCount const retryMessage = buildGlobalStatsRetryMessage(nextRetryCount, dateId) - const db = getPgClient(c) + const db = await getPgClient(c) try { const retryMsgId = await queueGlobalStatsMessage(db, retryMessage, delaySeconds) @@ -643,7 +643,7 @@ async function reserveGlobalStatsShardRetry(c: Context, shard: GlobalStatsShard, const nextRetryCount = retryCount + 1 const delaySeconds = GLOBAL_STATS_RETRY_DELAY_SECONDS * nextRetryCount const retryMessage = buildGlobalStatsShardMessage(shard, dateId, nextRetryCount) - const db = getPgClient(c) + const db = await getPgClient(c) try { const retryMsgId = await queueGlobalStatsMessage(db, retryMessage, delaySeconds) @@ -664,7 +664,7 @@ async function reserveGlobalStatsShardRetry(c: Context, shard: GlobalStatsShard, } async function cancelGlobalStatsRetry(c: Context, retryMsgId: number): Promise { - const db = getPgClient(c) + const db = await getPgClient(c) try { await db.query('SELECT pgmq.delete($1, $2::bigint[])', [ @@ -949,7 +949,7 @@ function isMissingAppsWithStoreUrlColumnError(error: unknown): boolean { } async function calculateRevenue(c: Context, referenceDate?: Date): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) try { @@ -1177,7 +1177,7 @@ async function getBuildStats(c: Context, window?: DailyWindow): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) const dayStart = window.dayStart const nextDayStart = window.nextDayStart @@ -1252,7 +1252,7 @@ async function getPaidProductActivityStats(c: Context, window: CurrentDayWindow) } async function getLtvStats(c: Context, window: CurrentDayWindow): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) const snapshotExclusiveEnd = window.nextDayStart.toISOString() const monthSeconds = (365.2425 / 12) * 24 * 60 * 60 @@ -1333,7 +1333,7 @@ async function getLtvStats(c: Context, window: CurrentDayWindow): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) const previousDateId = getPreviousDateId(dateId) @@ -1438,7 +1438,7 @@ async function aggregateDailyBuildStats( counts: Record<'ios' | 'android', number> } | null> { // Read from primary so the daily rollup is not permanently undercounted by replica lag. - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) const totalSecondsByPlatform: Record<'ios' | 'android', number> = { ios: 0, android: 0 } const avgSecondsByPlatform: Record<'ios' | 'android', number> = { ios: 0, android: 0 } @@ -1488,7 +1488,7 @@ function getCompletedAppBuildOnboardingWindow(window: DailyWindow): DailyWindow } async function getAppBuildOnboardingMetrics(c: Context, window: DailyWindow): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) const dayStartIso = window.prevDayStart.toISOString() const dayEndIso = window.prevDayEnd.toISOString() @@ -1531,7 +1531,7 @@ async function getAppBuildOnboardingMetrics(c: Context, window: DailyWindow): Pr } async function countDemoSeededApps(c: Context, createdAfterIso: string, createdBeforeIso: string): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) try { @@ -1563,7 +1563,7 @@ async function countDemoSeededApps(c: Context, createdAfterIso: string, createdB } async function countAppsWithPreview(c: Context, snapshotEnd: Date): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) try { @@ -1586,7 +1586,7 @@ async function countAppsWithPreview(c: Context, snapshotEnd: Date): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const snapshotEndIso = snapshotEnd.toISOString() try { @@ -1618,7 +1618,7 @@ async function countUsersWith2fa(c: Context, snapshotEnd: Date): Promise } async function countAppsWithStoreUrl(c: Context, snapshotEnd: Date): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) try { @@ -1644,7 +1644,7 @@ async function countAppsWithStoreUrl(c: Context, snapshotEnd: Date): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) const dayStartIso = window.dayStart.toISOString() const nextDayStartIso = window.nextDayStart.toISOString() @@ -1702,7 +1702,7 @@ async function ensureGlobalStatsSnapshotRows(c: Context, dateIds: readonly strin if (dateIds.length === 0) return - const db = getPgClient(c) + const db = await getPgClient(c) try { await db.query( @@ -1770,7 +1770,7 @@ async function updateGlobalStatsSnapshot(c: Context, dateId: string, patch: Glob } async function updateGlobalStatsSnapshotOrgCount(c: Context, dateId: string, orgs: number): Promise { - const db = getPgClient(c) + const db = await getPgClient(c) try { const result = await db.query( @@ -1936,7 +1936,7 @@ function getGlobalStatsNotificationStepAction( } async function readCompletedGlobalStatsShards(c: Context, dateId: string): Promise> { - const db = getPgClient(c) + const db = await getPgClient(c) try { const result = await db.query<{ completed_shards: unknown }>( @@ -1954,8 +1954,8 @@ async function readCompletedGlobalStatsShards(c: Context, dateId: string): Promi } } -async function claimGlobalStatsNotificationDelivery(c: Context, dateId: string): Promise | null> { - const db = getPgClient(c) +async function claimGlobalStatsNotificationDelivery(c: Context, dateId: string): Promise { + const db = await getPgClient(c) try { const result = await db.query<{ claimed: boolean }>( @@ -1974,7 +1974,7 @@ async function claimGlobalStatsNotificationDelivery(c: Context, dateId: string): } } -async function releaseGlobalStatsNotificationDeliveryClaim(c: Context, db: ReturnType, dateId: string): Promise { +async function releaseGlobalStatsNotificationDeliveryClaim(c: Context, db: PgClient, dateId: string): Promise { try { await db.query('SELECT pg_advisory_unlock(hashtext($1), hashtext($2))', [GLOBAL_STATS_NOTIFICATION_LOCK_NAMESPACE, dateId]) } @@ -2019,7 +2019,7 @@ async function shouldSkipCompletedGlobalStatsRetryDispatch(c: Context, dateId: s } async function markGlobalStatsShardComplete(c: Context, dateId: string, shard: GlobalStatsCompletionMarker): Promise { - const db = getPgClient(c) + const db = await getPgClient(c) try { const result = await db.query( @@ -2047,7 +2047,7 @@ async function markGlobalStatsShardComplete(c: Context, dateId: string, shard: G } async function removeGlobalStatsShardMarker(c: Context, dateId: string, shard: GlobalStatsCompletionMarker): Promise { - const db = getPgClient(c) + const db = await getPgClient(c) try { const result = await db.query( @@ -2118,7 +2118,7 @@ function getGlobalStatsShardDelaySeconds(shard: GlobalStatsShard): number { } async function queueGlobalStatsMessage( - db: ReturnType, + db: PgClient, message: ReturnType | ReturnType, delaySeconds: number, ): Promise { @@ -2134,7 +2134,7 @@ async function queueGlobalStatsMessage( } async function queueGlobalStatsShard(c: Context, shard: GlobalStatsShard, dateId: string): Promise<{ shard: GlobalStatsShard, msgId: number, delaySeconds: number }> { - const db = getPgClient(c) + const db = await getPgClient(c) try { const delaySeconds = getGlobalStatsShardDelaySeconds(shard) @@ -2154,7 +2154,7 @@ async function queueGlobalStatsShards( if (shards.length === 0) return [] - const db = getPgClient(c) + const db = await getPgClient(c) const queued: Array<{ shard: GlobalStatsShard, msgId: number, delaySeconds: number }> = [] try { @@ -2190,7 +2190,7 @@ async function readQueuedGlobalStatsShardKeys(c: Context, dateIds: readonly stri if (dateIds.length === 0) return new Set() - const db = getPgClient(c) + const db = await getPgClient(c) const functionNames = GLOBAL_STATS_SHARDS.map(shard => getGlobalStatsShardFunctionName(shard)) try { @@ -2219,7 +2219,7 @@ async function readGlobalStatsRepairRows(c: Context, dateIds: readonly string[]) if (dateIds.length === 0) return new Map() - const db = getPgClient(c) + const db = await getPgClient(c) try { const result = await db.query( @@ -2258,7 +2258,7 @@ async function readDailyBuildStatsByDate(c: Context, dateIds: readonly string[]) const end = new Date(`${uniqueDateIds.at(-1)}T00:00:00.000Z`) end.setUTCDate(end.getUTCDate() + 1) - const db = getPgClient(c, false) + const db = await getPgClient(c, false) try { const result = await db.query<{ date_id: string, platform: string, total_seconds: number | string | null, avg_seconds: number | string | null, total_builds: number | string | null }>( @@ -2440,7 +2440,7 @@ function remainingCreditsAtSnapshotSql(snapshotExclusiveEndIso: string) { } async function getBillingSnapshotCounts(c: Context, snapshotExclusiveEnd: Date): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) const snapshotExclusiveEndIso = snapshotExclusiveEnd.toISOString() @@ -2548,7 +2548,7 @@ async function getBillingSnapshotCounts(c: Context, snapshotExclusiveEnd: Date): } async function getSubscriptionAccessSnapshotCounts(c: Context, snapshotExclusiveEnd: Date): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) const snapshotExclusiveEndIso = snapshotExclusiveEnd.toISOString() @@ -2598,7 +2598,7 @@ async function getSubscriptionAccessSnapshotCounts(c: Context, snapshotExclusive } } async function getCoreSnapshotCounts(c: Context, snapshotExclusiveEnd: Date): Promise { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) const snapshotExclusiveEndIso = snapshotExclusiveEnd.toISOString() @@ -2720,7 +2720,7 @@ async function getCoreSnapshotCounts(c: Context, snapshotExclusiveEnd: Date): Pr } async function countRegisteredUsersForSnapshot(c: Context, snapshotExclusiveEnd: Date): Promise { - const db = getPgClient(c, false) + const db = await getPgClient(c, false) const snapshotExclusiveEndIso = snapshotExclusiveEnd.toISOString() try { @@ -2746,7 +2746,7 @@ async function countActiveUsersForSnapshot(c: Context, appIds: string[], window: if (appIds.length === 0) return 0 - const db = getPgClient(c, false) + const db = await getPgClient(c, false) const activeWindowStartIso = getLastMonthAnalyticsWindowStart(window.prevDayEnd).toISOString() try { @@ -3033,7 +3033,7 @@ async function getUpgradeRate12m( // chart after each daily run. Add today's freshly counted upgrades because // today's global_stats row is not written yet. const nextDayStart = getMetricWindowFromDailyWindow(window).nextDayStart - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) const snapshotEndIso = nextDayStart.toISOString() const trailingStartDateId = getTrailing12mStart(nextDayStart).toISOString().slice(0, 10) @@ -3399,9 +3399,9 @@ async function getNativeNotificationGlobalStats(c: Context, window: DailyWindow) const dayEndIso = window.prevDayEnd.toISOString() const lastMonthStart = new Date(window.prevDayEnd.getTime() - 30 * 24 * 60 * 60 * 1000) - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const result = await drizzleClient.execute<{ apps: number diff --git a/supabase/functions/_backend/triggers/on_app_create.ts b/supabase/functions/_backend/triggers/on_app_create.ts index a7889f30c1..300b4efdcb 100644 --- a/supabase/functions/_backend/triggers/on_app_create.ts +++ b/supabase/functions/_backend/triggers/on_app_create.ts @@ -31,7 +31,7 @@ app.post('/', middlewareAPISecret, triggerValidator('apps', 'INSERT'), async (c) // The app_versions table uses a DB trigger (auto_owner_org_by_app_id) that derives owner_org // from apps.app_id. If the app is deleted before this async trigger runs, inserting default // versions will fail with a NOT NULL violation. Always re-check that the app still exists. - const pg = getPgClient(c, true) + const pg = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pg) let appExists = false let ownerOrg: string | undefined diff --git a/supabase/functions/_backend/triggers/on_deploy_history_create.ts b/supabase/functions/_backend/triggers/on_deploy_history_create.ts index a077e628d6..ed76194ec8 100644 --- a/supabase/functions/_backend/triggers/on_deploy_history_create.ts +++ b/supabase/functions/_backend/triggers/on_deploy_history_create.ts @@ -66,7 +66,7 @@ app.post('/', middlewareAPISecret, triggerValidator('deploy_history', 'INSERT'), }) await backgroundTask(c, (async () => { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) try { await sendNotifToOrgMembersOnce(c, 'bundle:deployed', 'bundle_deployed', { diff --git a/supabase/functions/_backend/triggers/on_version_create.ts b/supabase/functions/_backend/triggers/on_version_create.ts index 8b8faacf60..3e1dbcd16f 100644 --- a/supabase/functions/_backend/triggers/on_version_create.ts +++ b/supabase/functions/_backend/triggers/on_version_create.ts @@ -52,7 +52,7 @@ app.post('/', middlewareAPISecret, triggerValidator('app_versions', 'INSERT'), a bundle_name: record.name, }, }) - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) try { await backgroundTask(c, sendEmailToOrgMembers(c, 'bundle:created', 'bundle_created', { diff --git a/supabase/functions/_backend/triggers/on_version_update.ts b/supabase/functions/_backend/triggers/on_version_update.ts index 7a1ba2fe1d..2dfa838e6c 100644 --- a/supabase/functions/_backend/triggers/on_version_update.ts +++ b/supabase/functions/_backend/triggers/on_version_update.ts @@ -339,7 +339,7 @@ type ManifestCleanupEntry = { * already-trashed paths are idempotent. */ async function deleteManifest(c: Context, record: Database['public']['Tables']['app_versions']['Row']) { - const readPgClient = getPgClient(c, true) + const readPgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(readPgClient) let manifestEntries: ManifestCleanupEntry[] = [] @@ -364,7 +364,7 @@ async function deleteManifest(c: Context, record: Database['public']['Tables'][' for (let i = 0; i < manifestEntries.length; i += MANIFEST_TRASH_CONCURRENCY) { const batch = manifestEntries.slice(i, i + MANIFEST_TRASH_CONCURRENCY) await Promise.all(batch.map(async (entry) => { - const entryPg = getPgClient(c, false) + const entryPg = await getPgClient(c, false) try { await entryPg.query('BEGIN') // Serialize shared-hash cleanup across concurrent deleted versions. @@ -419,7 +419,7 @@ async function deleteManifest(c: Context, record: Database['public']['Tables'][' } } - const writePgClient = getPgClient(c, false) + const writePgClient = await getPgClient(c, false) try { await writePgClient.query('BEGIN') try { diff --git a/supabase/functions/_backend/triggers/plugin_notifications.ts b/supabase/functions/_backend/triggers/plugin_notifications.ts index f5ad721c70..0aa39c9c46 100644 --- a/supabase/functions/_backend/triggers/plugin_notifications.ts +++ b/supabase/functions/_backend/triggers/plugin_notifications.ts @@ -47,7 +47,7 @@ async function sendQueuedPluginNotification(c: Context, item: PluginNotification } async function processPluginNotifications(c: Context, items: PluginNotificationQueueItem[]) { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) const results: PluginNotificationItemResult[] = [] let processed = 0 diff --git a/supabase/functions/_backend/triggers/queue_consumer.ts b/supabase/functions/_backend/triggers/queue_consumer.ts index 411b445c5c..fa27552e24 100644 --- a/supabase/functions/_backend/triggers/queue_consumer.ts +++ b/supabase/functions/_backend/triggers/queue_consumer.ts @@ -8,7 +8,7 @@ import { integerLikeSchema, safeParseSchema } from '../utils/schema_validation.t import { sendDiscordAlert } from '../utils/discord.ts' import { BRES, middlewareAPISecret, parseBody, simpleError } from '../utils/hono.ts' import { cloudlog, cloudlogErr, serializeError } from '../utils/logging.ts' -import { closeClient, getPgClient } from '../utils/pg.ts' +import { closeClient, getPgClient} from '../utils/pg.ts' import { backgroundTask, getEnv, WAIT_FOR_COMPLETION_HEADER } from '../utils/utils.ts' import { updateManifestSize } from './on_manifest_create.ts' @@ -591,7 +591,7 @@ function isSuccessfulQueueResult(result: ProcessedQueueMessage): boolean { async function deleteSuccessfulChunkMessages( c: Context, - db: ReturnType, + db: PgClient, queueName: string, chunkResults: ProcessedQueueMessage[], ): Promise { @@ -608,7 +608,7 @@ async function deleteSuccessfulChunkMessages( async function processQueueMessageChunks( c: Context, - db: ReturnType, + db: PgClient, queueName: string, messagesToProcess: Message[], processConcurrency: number, @@ -632,7 +632,7 @@ async function processQueueMessageChunks( async function persistQueueCfIds( c: Context, - db: ReturnType, + db: PgClient, queueName: string, results: ProcessedQueueMessage[], ): Promise { @@ -662,7 +662,7 @@ async function persistQueueCfIds( async function deleteUncheckpointedSuccessMessages( c: Context, - db: ReturnType, + db: PgClient, queueName: string, successMessages: ProcessedQueueMessage[], ): Promise { @@ -784,7 +784,7 @@ async function reportQueueFailures(c: Context, queueName: string, messagesFailed return actionableFailures.length } -async function processQueue(c: Context, db: ReturnType, queueName: string, batchSize: number = DEFAULT_BATCH_SIZE, waitForCompletion = false): Promise { +async function processQueue(c: Context, db: PgClient, queueName: string, batchSize: number = DEFAULT_BATCH_SIZE, waitForCompletion = false): Promise { const messages = await readQueue(c, db, queueName, batchSize) if (messages === null) { @@ -921,7 +921,7 @@ async function extractErrorDetails(response: Response): Promise<{ } // Reads messages from the queue and logs them -async function readQueue(c: Context, db: ReturnType, queueName: string, batchSize: number = DEFAULT_BATCH_SIZE): Promise { +async function readQueue(c: Context, db: PgClient, queueName: string, batchSize: number = DEFAULT_BATCH_SIZE): Promise { const queueKey = 'readQueue' const startTime = Date.now() let messages: Message[] = [] @@ -1021,7 +1021,7 @@ export async function http_post_helper( // Helper function to delete multiple messages from the queue in a single batch -async function delete_queue_message_batch(c: Context, db: ReturnType, queueName: string, msgIds: number[]) { +async function delete_queue_message_batch(c: Context, db: PgClient, queueName: string, msgIds: number[]) { try { if (msgIds.length === 0) return @@ -1037,7 +1037,7 @@ async function delete_queue_message_batch(c: Context, db: ReturnType, queueName: string, msgIds: number[]) { +async function archive_queue_messages(c: Context, db: PgClient, queueName: string, msgIds: number[]) { try { if (msgIds.length === 0) return @@ -1070,7 +1070,7 @@ async function archive_queue_messages(c: Context, db: ReturnType, + db: PgClient, updates: Array<{ msg_id: number, cf_id: string, queue: string }>, ) { try { @@ -1107,9 +1107,9 @@ async function runQueueSync( waitForCompletion = false, ): Promise { cloudlog({ requestId: c.get('requestId'), message: `[Queue Sync] Starting ${executionMode} execution for queue: ${queueName} with batch size: ${finalBatchSize}` }) - let db: ReturnType | null = null + let db: PgClient | null = null try { - db = getPgClient(c) + db = await getPgClient(c) const result = await processQueue(c, db, queueName, finalBatchSize, waitForCompletion) cloudlog({ requestId: c.get('requestId'), diff --git a/supabase/functions/_backend/triggers/stripe_event.ts b/supabase/functions/_backend/triggers/stripe_event.ts index 929479282d..b6a4471f1d 100644 --- a/supabase/functions/_backend/triggers/stripe_event.ts +++ b/supabase/functions/_backend/triggers/stripe_event.ts @@ -357,7 +357,7 @@ async function getBillingBentoEmails( audience: NotificationAudience = BENTO_TAG_AUDIENCE, ) { const emails: Array = [org.management_email] - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { const drizzleClient = getDrizzleClient(pgClient) @@ -651,7 +651,7 @@ async function persistStripeInfoAndRevenueMovement( if (Object.keys(transactionUpdateData).length === 0 && !shouldRecordMovement) return 'applied' - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) try { await pgClient.query('BEGIN') @@ -793,7 +793,7 @@ async function persistStripeInfoAndRevenueMovement( } async function writePaidAtAtomically(c: Context, customerId: string, eventOccurredAtIso: string) { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) const drizzleClient = getDrizzleClient(pgClient) try { @@ -812,7 +812,7 @@ async function writePaidAtAtomically(c: Context, customerId: string, eventOccurr } async function getCreditTopUpProductIdFromCustomer(c: Context, customerId: string): Promise { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) try { diff --git a/supabase/functions/_backend/triggers/webhook_delivery.ts b/supabase/functions/_backend/triggers/webhook_delivery.ts index 0aa6522bca..8c005a56de 100644 --- a/supabase/functions/_backend/triggers/webhook_delivery.ts +++ b/supabase/functions/_backend/triggers/webhook_delivery.ts @@ -206,7 +206,7 @@ app.post('/', middlewareAPISecret, async (c) => { // Send failure notification via Bento (webhook already fetched above) if (webhook) { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) try { await backgroundTask(c, sendNotifOrg( diff --git a/supabase/functions/_backend/utils/ab_test_channel_creation.ts b/supabase/functions/_backend/utils/ab_test_channel_creation.ts index af867f17a7..559e663043 100644 --- a/supabase/functions/_backend/utils/ab_test_channel_creation.ts +++ b/supabase/functions/_backend/utils/ab_test_channel_creation.ts @@ -482,7 +482,7 @@ export async function getAdminABTestChannelCreation(c: Context): Promise( diff --git a/supabase/functions/_backend/utils/ab_test_development_environment.ts b/supabase/functions/_backend/utils/ab_test_development_environment.ts index 8326bfa9be..6360139bc8 100644 --- a/supabase/functions/_backend/utils/ab_test_development_environment.ts +++ b/supabase/functions/_backend/utils/ab_test_development_environment.ts @@ -91,7 +91,7 @@ export async function getAdminABTestDevelopmentEnvironment(c: Context): Promise< hosted_builder_intents: AdminABTestDevelopmentEnvironmentIntent development_environment_intents: Record }> { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { const test = AB_TESTS_CONFIG[DEVELOPMENT_ENVIRONMENT_TEST] if (!test) diff --git a/supabase/functions/_backend/utils/ab_test_distribution.ts b/supabase/functions/_backend/utils/ab_test_distribution.ts index 70e815645e..f824c6eea4 100644 --- a/supabase/functions/_backend/utils/ab_test_distribution.ts +++ b/supabase/functions/_backend/utils/ab_test_distribution.ts @@ -72,7 +72,7 @@ export async function getAdminABTestDistribution(c: Context): Promise( `SELECT diff --git a/supabase/functions/_backend/utils/ab_test_publish_intent_outcome.ts b/supabase/functions/_backend/utils/ab_test_publish_intent_outcome.ts index 6f40f7d951..d163e5f4ca 100644 --- a/supabase/functions/_backend/utils/ab_test_publish_intent_outcome.ts +++ b/supabase/functions/_backend/utils/ab_test_publish_intent_outcome.ts @@ -79,7 +79,7 @@ export function buildAdminABTestPublishIntentOutcome( } export async function getAdminABTestPublishIntentOutcome(c: Context): Promise { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { const result = await pgClient.query( `WITH exposed_users AS ( diff --git a/supabase/functions/_backend/utils/ab_tests.ts b/supabase/functions/_backend/utils/ab_tests.ts index b94632965c..c768490c24 100644 --- a/supabase/functions/_backend/utils/ab_tests.ts +++ b/supabase/functions/_backend/utils/ab_tests.ts @@ -252,7 +252,7 @@ async function readAssignmentUser( c: Context, userId: string, ): Promise { - const pgPool = getPgClient(c, true) + const pgPool = await getPgClient(c, true) try { const pgClient = await pgPool.connect() try { @@ -283,7 +283,7 @@ async function persistABTestAssignments( userId: string, candidates: Record, ) { - const pgPool = getPgClient(c) + const pgPool = await getPgClient(c) let persisted: unknown try { const pgClient = await pgPool.connect() @@ -511,7 +511,7 @@ export async function getOrCreateUserABTests( return existing.assignments } - const pgPool = getPgClient(c, false) + const pgPool = await getPgClient(c, false) let closeInFinally = true let result: { assignments: Record diff --git a/supabase/functions/_backend/utils/app_onboarding_login.ts b/supabase/functions/_backend/utils/app_onboarding_login.ts index 16f17c4e78..2fe80dd176 100644 --- a/supabase/functions/_backend/utils/app_onboarding_login.ts +++ b/supabase/functions/_backend/utils/app_onboarding_login.ts @@ -29,7 +29,7 @@ export async function markAppOnboardingLoginFromTracking( return const apikey = auth.apikey?.key ?? c.get('capgkey') ?? null - const pool = getPgClient(c) + const pool = await getPgClient(c) try { const committed = await getDrizzleClient(pool).transaction(async (tx) => { const result = await tx.execute<{ app_id: string, onboarding: unknown, owner_org: string }>(sql` diff --git a/supabase/functions/_backend/utils/bento_first_org.ts b/supabase/functions/_backend/utils/bento_first_org.ts index 28ae290f4f..29f4d71faf 100644 --- a/supabase/functions/_backend/utils/bento_first_org.ts +++ b/supabase/functions/_backend/utils/bento_first_org.ts @@ -3,7 +3,7 @@ import type { MiddlewareKeyVariables } from './hono.ts' import type { Database } from './supabase.types.ts' import { syncBentoSubscriberTags, trackBentoEvent, unsubscribeBento } from './bento.ts' import { quickError } from './hono.ts' -import { closeClient, getPgClient } from './pg.ts' +import { closeClient, getPgClient} from './pg.ts' export const BENTO_AWAITING_FIRST_ORG_TAG = 'onboarding:awaiting_first_org' // Permanent safety opt-out: never remove this tag. The Bento recovery workflow @@ -154,7 +154,7 @@ export async function suppressAndUnsubscribeDeletedUserRecovery( async function reconcileFirstOrgStateAfterBentoMutation( c: Context, - pgPool: ReturnType, + pgPool: PgClient, userId: string, email: string, ) { @@ -166,7 +166,7 @@ async function reconcileFirstOrgStateAfterBentoMutation( async function runBentoMutationWithFirstOrgReconciliation( c: Context, - pgPool: ReturnType, + pgPool: PgClient, userId: string, email: string, mutate: () => Promise, @@ -201,7 +201,7 @@ async function runBentoMutationWithFirstOrgReconciliation( return state } -async function getFirstOrgDatabaseState(pgPool: ReturnType, userId: string) { +async function getFirstOrgDatabaseState(pgPool: PgClient, userId: string) { const pgClient = await pgPool.connect() try { const result = await pgClient.query( @@ -246,7 +246,7 @@ export async function prepareNewUserProvisioning( user: Database['public']['Tables']['users']['Row'], ) { const email = normalizeBentoEmail(user.email) - const pgPool = getPgClient(c) + const pgPool = await getPgClient(c) try { const state = await getFirstOrgDatabaseState(pgPool, user.id) if (!state.user_is_recovery_eligible) { @@ -265,7 +265,7 @@ export async function syncBentoFirstOrgOnUserCreate( user: Database['public']['Tables']['users']['Row'], ) { const email = normalizeBentoEmail(user.email) - const pgPool = getPgClient(c) + const pgPool = await getPgClient(c) try { // Provisioning can overlap account deletion. Read before lifecycle work // and reconcile after each terminal mutation so recovery stays fail closed. @@ -341,7 +341,7 @@ export async function syncBentoFirstOrgOnRoleBindingWrite( c: Context, roleBindingId: string, ) { - const pgPool = getPgClient(c) + const pgPool = await getPgClient(c) try { let binding: CurrentRoleBinding | undefined const pgClient = await pgPool.connect() diff --git a/supabase/functions/_backend/utils/builder_analytics.ts b/supabase/functions/_backend/utils/builder_analytics.ts index 4735c0972a..17b159ff8b 100644 --- a/supabase/functions/_backend/utils/builder_analytics.ts +++ b/supabase/functions/_backend/utils/builder_analytics.ts @@ -258,7 +258,7 @@ export async function getAdminBuilderAnalytics(c: Context, startDate: string, en const onboarding_error_categories = [...onbErrMap.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count) // --- Postgres builds (aggregated in-database; exact for any volume) --- - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { const [statusDayRes, orgBuildRes, failedRes] = await Promise.all([ pgClient.query( diff --git a/supabase/functions/_backend/utils/builder_capacity.ts b/supabase/functions/_backend/utils/builder_capacity.ts index 130a02a89a..cf7d2a27cf 100644 --- a/supabase/functions/_backend/utils/builder_capacity.ts +++ b/supabase/functions/_backend/utils/builder_capacity.ts @@ -297,7 +297,7 @@ export async function recordBuilderCapacityIfChanged( source = 'sync', ): Promise { const total = Math.max(0, Math.trunc(workersTotal)) - const client = getPgClient(c) + const client = await getPgClient(c) try { await client.query('BEGIN') await client.query('SELECT pg_advisory_xact_lock($1)', [CAPACITY_ADVISORY_LOCK_KEY]) @@ -369,7 +369,7 @@ async function loadCapacityEvents( startIso: string, endIso: string, ): Promise { - const client = getPgClient(c) + const client = await getPgClient(c) try { const { rows } = await client.query<{ created_at: string @@ -411,7 +411,7 @@ async function loadRunIntervals( startIso: string, endIso: string, ): Promise { - const client = getPgClient(c) + const client = await getPgClient(c) try { // Only builder-reported run intervals (started_at set when the runner // actually starts). Exclude waiting_runner / queue time from "used". diff --git a/supabase/functions/_backend/utils/channel_surfing.ts b/supabase/functions/_backend/utils/channel_surfing.ts index 379a231a4c..d93d2a290a 100644 --- a/supabase/functions/_backend/utils/channel_surfing.ts +++ b/supabase/functions/_backend/utils/channel_surfing.ts @@ -92,7 +92,7 @@ async function getAdminChannelSurfingFromPostgres( end_date: string, app_id?: string, ): Promise { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { const params: unknown[] = [start_date, end_date] let appFilter = '' diff --git a/supabase/functions/_backend/utils/cli_usage.ts b/supabase/functions/_backend/utils/cli_usage.ts index 079bfcbb2c..0508f3bd8c 100644 --- a/supabase/functions/_backend/utils/cli_usage.ts +++ b/supabase/functions/_backend/utils/cli_usage.ts @@ -76,7 +76,7 @@ async function resolveApikeyEmails(c: Context, apikeyIds: string[]): Promise( `SELECT a.rbac_id::text AS rbac_id, u.email @@ -137,7 +137,7 @@ export function trackCliUsage(c: Context, event: CliUsageEvent) { } backgroundTask(c, (async () => { - const pgClient = getPgClient(c, false) + const pgClient = await getPgClient(c, false) try { await pgClient.query( `INSERT INTO public.cli_usage @@ -237,7 +237,7 @@ async function getAdminCliUsageFromPostgres( start_date: string, end_date: string, ): Promise { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { const [totalRes, versionRes, commandRes, apiVersionRes, dayRes, userRes] = await Promise.all([ pgClient.query<{ total: string }>( diff --git a/supabase/functions/_backend/utils/cloudflare_cache_purge.ts b/supabase/functions/_backend/utils/cloudflare_cache_purge.ts index ce21a78049..f8cdaa43cf 100644 --- a/supabase/functions/_backend/utils/cloudflare_cache_purge.ts +++ b/supabase/functions/_backend/utils/cloudflare_cache_purge.ts @@ -108,7 +108,7 @@ export async function purgePlanCache(c: Context, appId: string) { * Plugin endpoints must keep using the read replica exclusively. */ async function listOrgAppIds(c: Context, orgId: string): Promise { - const pg = getPgClient(c) + const pg = await getPgClient(c) try { const result = await pg.query<{ app_id: string }>( 'SELECT app_id FROM public.apps WHERE owner_org = $1::uuid ORDER BY app_id', diff --git a/supabase/functions/_backend/utils/demo.ts b/supabase/functions/_backend/utils/demo.ts index 85ab10afba..2043bb7e1f 100644 --- a/supabase/functions/_backend/utils/demo.ts +++ b/supabase/functions/_backend/utils/demo.ts @@ -29,7 +29,7 @@ export async function isDemoApp(c: Context, appId: strin } export async function lockOnboardingApp(c: Context, appId: string) { - const pool = getPgClient(c) + const pool = await getPgClient(c) let client: PoolClient | undefined try { diff --git a/supabase/functions/_backend/utils/frontend_onboarding_cli_checklist.ts b/supabase/functions/_backend/utils/frontend_onboarding_cli_checklist.ts index 98388c0d99..d4db93ff04 100644 --- a/supabase/functions/_backend/utils/frontend_onboarding_cli_checklist.ts +++ b/supabase/functions/_backend/utils/frontend_onboarding_cli_checklist.ts @@ -71,7 +71,7 @@ export async function getFrontendOnboardingCliChecklistCoverage( if (appIds.length === 0) return buildCoverages([]) - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { const rows = await getDrizzleClient(pgClient) .select({ appId: schema.apps.app_id, onboarding: schema.apps.onboarding }) diff --git a/supabase/functions/_backend/utils/hono_middleware.ts b/supabase/functions/_backend/utils/hono_middleware.ts index 5cfd2f88c3..4d2e82403c 100644 --- a/supabase/functions/_backend/utils/hono_middleware.ts +++ b/supabase/functions/_backend/utils/hono_middleware.ts @@ -5,7 +5,7 @@ import { and, eq, isNull, or, sql } from 'drizzle-orm' import { honoFactory, quickError, simpleRateLimit } from './hono.ts' import { getClaimsFromJWT } from './hono_jwt.ts' import { cloudlog } from './logging.ts' -import { closeClient, getDrizzleClient, getPgClient, logPgError } from './pg.ts' +import { closeClient, getDrizzleClient, getPgClient, logPgError} from './pg.ts' import * as schema from './postgres_schema.ts' import { isAPIKeyRateLimited, isIPRateLimited, recordAPIKeyUsage, recordFailedAuth } from './rate_limit.ts' import { buildRateLimitInfo } from './rateLimitInfo.ts' @@ -236,9 +236,9 @@ async function hasLimitedRbacSubkeyScope( if (!subkey.rbac_id) return false - let pgClient: ReturnType | null = null + let pgClient: PgClient | null = null try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const result = await pgClient.query<{ is_limited: boolean }>( ` WITH user_orgs AS ( @@ -390,9 +390,9 @@ async function parentCanDelegateToSubkey( return true } - let pgClient: ReturnType | null = null + let pgClient: PgClient | null = null try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const result = await pgClient.query<{ can_delegate: boolean }>( ` WITH RECURSIVE child_direct_bindings AS ( @@ -647,9 +647,9 @@ async function resolveApiKey( return checkKey(c, key, supabaseAdmin(c)) } - let pgClient: ReturnType | null = null + let pgClient: PgClient | null = null try { - pgClient = getPgClient(c, readOnly) + pgClient = await getPgClient(c, readOnly) const drizzleClient = getDrizzleClient(pgClient) return await checkKeyPg(c, key, drizzleClient) } @@ -671,9 +671,9 @@ async function resolveSubkey( return checkKeyById(c, subkeyId, supabaseAdmin(c), expectedUserId) } - let subkeyPgClient: ReturnType | null = null + let subkeyPgClient: PgClient | null = null try { - subkeyPgClient = getPgClient(c, readOnly) + subkeyPgClient = await getPgClient(c, readOnly) const drizzleClient = getDrizzleClient(subkeyPgClient) return await checkKeyByIdPg(c, subkeyId, drizzleClient, expectedUserId) } diff --git a/supabase/functions/_backend/utils/jwt_mfa_assurance.ts b/supabase/functions/_backend/utils/jwt_mfa_assurance.ts index 200035d6b8..7e4fb552aa 100644 --- a/supabase/functions/_backend/utils/jwt_mfa_assurance.ts +++ b/supabase/functions/_backend/utils/jwt_mfa_assurance.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono' import type { AuthInfo, JWTClaims, MiddlewareKeyVariables } from './hono.ts' import { quickError } from './hono.ts' -import { closeClient, getPgClient } from './pg.ts' +import { closeClient, getPgClient} from './pg.ts' const SESSION_ID_UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @@ -22,9 +22,9 @@ async function userHasVerifiedMfaFactors( c: Context, userId: string, ): Promise { - let pgClient: ReturnType | null = null + let pgClient: PgClient | null = null try { - pgClient = getPgClient(c, true) + pgClient = await getPgClient(c, true) const result = await pgClient.query<{ has_verified_mfa: boolean }>( ` SELECT EXISTS ( @@ -54,9 +54,9 @@ async function isActivePlatformImpersonation( return false } - let pgClient: ReturnType | null = null + let pgClient: PgClient | null = null try { - pgClient = getPgClient(c, true) + pgClient = await getPgClient(c, true) const result = await pgClient.query<{ is_active: boolean }>( ` SELECT EXISTS ( diff --git a/supabase/functions/_backend/utils/manifest_persist.ts b/supabase/functions/_backend/utils/manifest_persist.ts index 5fe9e2681f..0a219bb000 100644 --- a/supabase/functions/_backend/utils/manifest_persist.ts +++ b/supabase/functions/_backend/utils/manifest_persist.ts @@ -85,7 +85,7 @@ export async function persistVersionManifestEntries( return { inserted: 0, alreadyPresent: false } } - const pgPool = getPgClient(c, false) + const pgPool = await getPgClient(c, false) const pgClient = await pgPool.connect() try { await pgClient.query('BEGIN') diff --git a/supabase/functions/_backend/utils/manifest_size.ts b/supabase/functions/_backend/utils/manifest_size.ts index 74d0144d01..ae0b7510ea 100644 --- a/supabase/functions/_backend/utils/manifest_size.ts +++ b/supabase/functions/_backend/utils/manifest_size.ts @@ -157,7 +157,7 @@ export async function getManifestDownloadSize( } } - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { const result = await pgClient.query<{ file_hash: string, version_id: number | null, file_size: number | string | null }>( ` diff --git a/supabase/functions/_backend/utils/notifications.ts b/supabase/functions/_backend/utils/notifications.ts index 70ebb6b420..c25505a247 100644 --- a/supabase/functions/_backend/utils/notifications.ts +++ b/supabase/functions/_backend/utils/notifications.ts @@ -232,7 +232,7 @@ export async function sendNotifOrg( } // Create write-capable drizzle client for mutations - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const writeClient = createDrizzleClient(pgClient) let shouldSend = false @@ -325,7 +325,7 @@ export async function claimNotifOrgOnce( return false } - const ownedPgClient = writeClient ? undefined : getPgClient(c) + const ownedPgClient = writeClient ? undefined : await getPgClient(c) const effectiveWriteClient = writeClient ?? createDrizzleClient(ownedPgClient!) try { @@ -360,7 +360,7 @@ export async function sendNotifOrgOnce( return { sent: false, cleanupFailed: false } } - const ownedPgClient = writeClient ? undefined : getPgClient(c) + const ownedPgClient = writeClient ? undefined : await getPgClient(c) const effectiveWriteClient = writeClient ?? createDrizzleClient(ownedPgClient!) try { diff --git a/supabase/functions/_backend/utils/onboarding_payment_cohorts_data.ts b/supabase/functions/_backend/utils/onboarding_payment_cohorts_data.ts index 6df901ff6a..c18bd58a1d 100644 --- a/supabase/functions/_backend/utils/onboarding_payment_cohorts_data.ts +++ b/supabase/functions/_backend/utils/onboarding_payment_cohorts_data.ts @@ -77,7 +77,7 @@ export async function queryOnboardingPaymentCohortData(executor: OnboardingPayme export async function loadOnboardingPaymentCohortData(c: Context, period: OnboardingPaymentCohortPeriod): Promise { // auth.users is not replicated: false deliberately selects the primary connection. - const pool = getPgClient(c, false) + const pool = await getPgClient(c, false) let client: PoolClient | undefined try { client = await pool.connect() diff --git a/supabase/functions/_backend/utils/org_email_notifications.ts b/supabase/functions/_backend/utils/org_email_notifications.ts index 1e82b3f7a4..cabca15117 100644 --- a/supabase/functions/_backend/utils/org_email_notifications.ts +++ b/supabase/functions/_backend/utils/org_email_notifications.ts @@ -494,7 +494,7 @@ export async function sendEmailToOrgMembers( if (!isBentoConfigured(c)) return 0 - const client = drizzleClient ?? getDrizzleClient(getPgClient(c, true)) + const client = drizzleClient ?? getDrizzleClient(await getPgClient(c, true)) const { recipients } = await getPreparedEligibleEmailTargets(c, orgId, preferenceKey, client) if (!recipients) { cloudlog({ requestId: c.get('requestId'), message: 'sendEmailToOrgMembers: org not found', orgId }) @@ -637,7 +637,7 @@ export async function sendNotifToOrgMembersOnce( if (!isBentoConfigured(c)) return false - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const writeClient = getDrizzleClient(pgClient) try { diff --git a/supabase/functions/_backend/utils/org_onboarding_intent.ts b/supabase/functions/_backend/utils/org_onboarding_intent.ts index 88fed0d856..b5b7e1de52 100644 --- a/supabase/functions/_backend/utils/org_onboarding_intent.ts +++ b/supabase/functions/_backend/utils/org_onboarding_intent.ts @@ -130,7 +130,7 @@ export async function syncOrgOnboardingIntentForOrg( org: { id: string, management_email?: string | null, created_by?: string | null, onboarding?: unknown }, ) { const intent = parseOrgOnboardingIntent(org.onboarding) - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) try { diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 1e13cd7f4f..42bd7b8857 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -5,8 +5,9 @@ import type { AdminOnboardingActivationCohort, AdminOnboardingWizardDropoff } fr import { and, eq, isNotNull, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/node-postgres' import { alias } from 'drizzle-orm/pg-core' +import { getRuntimeKey } from 'hono/adapter' // @ts-types="npm:@types/pg" -import { Pool } from 'pg' +import { Client, Pool } from 'pg' import { serializePostgresError } from '../plugin_runtime/utils/postgres_error.ts' import { backgroundTask, existInEnv, getEnv } from '../utils/utils.ts' import { CacheHelper } from './cache.ts' @@ -24,6 +25,15 @@ import { getRolloutDecision } from './rollout.ts' import { createPlatformAdminSignedImageUrl } from './storage.ts' import { shouldRequireReadReplica, shouldSkipDirectHyperdriveFallback } from './supabase_write_guard.ts' +/** + * PG client handle. On Hyperdrive (workerd) this is a per-request `Client`; + * elsewhere it is a short-lived `Pool`. + */ +export type PgClient = Client | Pool + +/** Hyperdrive owns Worker↔origin cleanup; do not call `.end()` on these clients. */ +const skipEndClients = new WeakSet() + const REPLICATION_LAG_THRESHOLD_SECONDS = 180 const REPLICATION_LAG_CACHE_TTL_SECONDS = 60 const REPLICATION_LAG_CACHE_TTL_MS = REPLICATION_LAG_CACHE_TTL_SECONDS * 1000 @@ -114,7 +124,7 @@ export function buildPlanValidationExpression( ) OR (${customerIdSubquery} IS NULL)` } -export function selectOne(pgClient: ReturnType) { +export function selectOne(pgClient: PgClient) { // Use pg Pool directly to avoid Drizzle's prepared statement handling // which doesn't work with Supabase pooler in transaction mode return pgClient.query('SELECT 1') @@ -360,7 +370,33 @@ export function getDatabaseURL(c: Context, readOnly = false): string { return fixSupabaseHost(getEnv(c, 'SUPABASE_DB_URL')) } -export function getPgClient(c: Context, readOnly = false) { +/** True when dbUrl is one of this Worker's Hyperdrive binding connection strings. */ +function isHyperdriveConnectionString(c: Context, dbUrl: string): boolean { + const env = c.env as Record | undefined + if (!env) + return false + for (const [key, value] of Object.entries(env)) { + if (!key.startsWith('HYPERDRIVE_') || !value?.connectionString) + continue + if (value.connectionString === dbUrl) + return true + } + return false +} + +/** + * Create a DB client for this request. + * + * Hyperdrive connection lifecycle (explicit Cloudflare contract): + * @see https://developers.cloudflare.com/hyperdrive/concepts/connection-lifecycle/ + * - New `pg.Client` inside each request. Never create/cache Client/Pool in global scope. + * - `await client.connect()`, then query. + * - Do **not** call `client.end()` / `pool.end()`: Workers-to-Hyperdrive connections + * are automatically cleaned up when the request ends. + * + * Non-Hyperdrive (local/direct/pooler): `Pool` + explicit `closeClient`/`end()`. + */ +export async function getPgClient(c: Context, readOnly = false): Promise { const dbUrl = getDatabaseURL(c, readOnly) const requestId = c.get('requestId') const appName = c.res.headers.get('X-Worker-Source') ?? 'unknown source' @@ -368,20 +404,39 @@ export function getPgClient(c: Context, readOnly = false) { cloudlog({ requestId, message: 'SUPABASE_DB_URL selected', dbName, appName, readOnly }) const isPooler = dbName.startsWith('sb_pooler') - const options = { + const readOnlyOptions = readOnly && !isPooler ? '-c default_transaction_read_only=on' : undefined + const isWorkerd = getRuntimeKey() === 'workerd' + // Match on the actual connection string so the Hyperdrive Client contract cannot + // silently become Pool+end() if c.set is missed. + const useHyperdriveClient = isWorkerd && isHyperdriveConnectionString(c, dbUrl) + + if (useHyperdriveClient) { + const client = new Client({ + connectionString: dbUrl, + application_name: `${appName}-${dbName}`, + connectionTimeoutMillis: 10000, + // PgBouncer/Supabase pooler doesn't support the 'options' startup parameter + options: readOnlyOptions, + }) + client.on('error', (err: Error) => { + cloudlogErr({ requestId, message: 'PG Client Error', databaseSource: dbName, error: serializePostgresError(err) }) + }) + await client.connect() + skipEndClients.add(client) + return client + } + + const pool = new Pool({ connectionString: dbUrl, - max: 4, + max: isWorkerd ? 1 : 4, application_name: `${appName}-${dbName}`, - idleTimeoutMillis: 20000, // Increase from 2 to 20 seconds - connectionTimeoutMillis: 10000, // Add explicit connect timeout - maxLifetimeMillis: 30 * 60 * 1000, // 30 minutes + idleTimeoutMillis: 20000, + connectionTimeoutMillis: 10000, + maxLifetimeSeconds: 30 * 60, // PgBouncer/Supabase pooler doesn't support the 'options' startup parameter - options: readOnly && !isPooler ? '-c default_transaction_read_only=on' : undefined, - } - - const pool = new Pool(options) + options: readOnlyOptions, + }) - // Hook to log when connections are removed from the pool pool.on('remove', () => { cloudlog({ requestId, message: 'PG Connection Removed from Pool' }) }) @@ -393,7 +448,7 @@ export function getPgClient(c: Context, readOnly = false) { return pool } -export function getDrizzleClient(db: ReturnType | PoolClient, options?: { logger?: boolean }) { +export function getDrizzleClient(db: PgClient | PoolClient, options?: { logger?: boolean }) { // Keep SQL logging on by default for API/trigger diagnostics. // Plugin hot paths pass `{ logger: false }` to avoid per-request log CPU/volume. return drizzle({ client: db, logger: options?.logger ?? true }) @@ -409,11 +464,13 @@ export function logPgError(c: Context, functionName: string, error: unknown) { }) } -export function closeClient(c: Context, db: ReturnType) { - // Always end the request-scoped pool. On workerd a Pool that is never ended - // leaks its Hyperdrive sockets until the pool slots are exhausted (the workerd - // sawtooth). backgroundTask defers end() to waitUntil, so it never adds - // request latency. +export function closeClient(c: Context, db: PgClient) { + // Hyperdrive: do not end() — connection-lifecycle docs say GC cleans the edge hop. + // https://developers.cloudflare.com/hyperdrive/concepts/connection-lifecycle/ + if (skipEndClients.has(db)) + return + + // Non-Hyperdrive Pool: must end() or we leak sockets (the old workerd sawtooth). return backgroundTask(c, Promise.resolve(db.end()).catch((error: unknown) => { cloudlogErr({ requestId: c.get('requestId'), @@ -1469,7 +1526,7 @@ export async function getAdminDeploymentsTrend( app_id?: string, ): Promise { try { - const pgClient = getPgClient(c, true) // Read-only query + const pgClient = await getPgClient(c, true) // Read-only query const drizzleClient = getDrizzleClient(pgClient) const appFilter = app_id ? sql`AND app_id = ${app_id}` : sql`` @@ -1624,7 +1681,7 @@ export async function getAdminGlobalStatsTrend( // Admin global stats are low traffic and depend on recently migrated // global_stats columns. Use primary DB so replica schema/data drift does not // silently blank the dashboard. - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) // Extract just the date portion (YYYY-MM-DD) from ISO timestamps @@ -1994,7 +2051,7 @@ export function normalizeAdminStatsDate(value: unknown): string { } async function getLiveRegisteredUsersCount(c: Context): Promise { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) try { @@ -2028,7 +2085,7 @@ export async function getAdminPayingOrgBreakdown(c: Context): Promise { - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const safeLimit = Math.max(1, Math.min(Math.floor(filters.limit ?? 50), 500)) const safeOffset = Math.max(0, Math.floor(filters.offset ?? 0)) @@ -2849,9 +2906,9 @@ export async function getAdminEnterpriseAdoption( start_date: string, end_date: string, ): Promise { - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const { startDay, seriesEndDay } = getAdminUtcDateRange(start_date, end_date) @@ -2986,9 +3043,9 @@ export async function getAdminFamousApps( famous_count: 0, notable_count: 0, } - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const safeLimit = Math.max(1, Math.min(Math.floor(filters.limit ?? 50), 500)) const safeOffset = Math.max(0, Math.floor(filters.offset ?? 0)) @@ -3133,7 +3190,7 @@ export async function getAdminCancelledOrganizations( offset: number = 0, ): Promise { try { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) const dateFilter = start_date && end_date @@ -3249,7 +3306,7 @@ export async function getAdminTrialOrganizations( try { // The admin dashboard needs plans.name, and plans is not replicated to // read replicas. - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) // Query to get trial organizations ordered by days remaining (ascending - expiring soon first) @@ -3356,10 +3413,10 @@ export async function getAdminTrialPlanBreakdown( trend: [], } - let pgClient: ReturnType | undefined + let pgClient: PgClient | undefined try { // The admin dashboard needs plans.name, and plans is not available on every read replica. - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const { startDay, seriesEndDay, endExclusive } = getAdminUtcDateRange(start_date, end_date) @@ -3539,7 +3596,7 @@ export async function getAdminOnboardingFunnel( ): Promise { try { // Read replicas don't include org/app/channel data, so use primary DB. - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) const now = new Date() @@ -4126,7 +4183,7 @@ export async function getAdminPluginBreakdown( end_date: string, ): Promise { try { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) const drizzleClient = getDrizzleClient(pgClient) const startDateOnly = start_date.split('T')[0] diff --git a/supabase/functions/_backend/utils/plans_billing_history.ts b/supabase/functions/_backend/utils/plans_billing_history.ts index d624c51a16..83838134fe 100644 --- a/supabase/functions/_backend/utils/plans_billing_history.ts +++ b/supabase/functions/_backend/utils/plans_billing_history.ts @@ -380,7 +380,7 @@ export async function loadPlansBillingHistories( if (orgIds.length === 0) return new Map() - const pool = getPgClient(c, true) + const pool = await getPgClient(c, true) let client: PoolClient | undefined try { diff --git a/supabase/functions/_backend/utils/rbac.ts b/supabase/functions/_backend/utils/rbac.ts index b2562edb07..b46b352c77 100644 --- a/supabase/functions/_backend/utils/rbac.ts +++ b/supabase/functions/_backend/utils/rbac.ts @@ -195,7 +195,7 @@ export async function checkPermission( let pgClient try { - pgClient = getPgClient(c) + pgClient = await getPgClient(c) const drizzleClient = getDrizzleClient(pgClient) if (auth.authType === 'apikey' && apikey?.rbac_id) { diff --git a/supabase/functions/_backend/utils/registration_monthly_comparison.ts b/supabase/functions/_backend/utils/registration_monthly_comparison.ts index a2189a1c92..7439bbbfff 100644 --- a/supabase/functions/_backend/utils/registration_monthly_comparison.ts +++ b/supabase/functions/_backend/utils/registration_monthly_comparison.ts @@ -88,7 +88,7 @@ export async function getAdminRegistrationMonthlyComparison(c: Context, now = ne const query = buildRegistrationMonthlyComparisonQuery(now) // Admin-only, aggregate-only reporting uses the primary Supabase database so newly // created accounts are not omitted by replica lag. Never use this on plugin hot paths. - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) try { const result = await pgClient.query(query.sql, query.params) const counts = new Map() diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index d0360ec5ae..ada9a220f6 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -153,7 +153,7 @@ async function readDevicesSBSql(c: Context, params: ReadDevicesParams, customIdM ? `updated_at ${devicesOrder.ascending ? 'ASC' : 'DESC'}, device_id ASC` : 'device_id ASC' values.push(limit + 1) - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { const result = await pgClient.query( `SELECT * FROM public.devices WHERE ${where} ORDER BY ${orderBy} LIMIT $${values.length}`, @@ -195,7 +195,7 @@ async function countDevicesSBSql( os_version_compare: options?.osVersionCompare, version_name_compare: options?.versionNameCompare, }, customIdMode) - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { const result = await pgClient.query<{ total: string }>( `SELECT COUNT(*)::text AS total FROM public.devices WHERE ${where}`, @@ -332,7 +332,7 @@ export async function getAppsFromSB(c: Context, referenceDate?: Date): Promise( ` @@ -951,7 +951,7 @@ export async function createApiKey(c: Context, userId: string) { return } - const pgPool = getPgClient(c) + const pgPool = await getPgClient(c) let pgClient: PoolClient | undefined let inTransaction = false try { @@ -1607,7 +1607,7 @@ export async function readStatsSB(c: Context, params: ReadStatsParams) { } export async function readStatsInsightsSB(c: Context, params: ReadStatsInsightsParams): Promise { - const pgClient = getPgClient(c) + const pgClient = await getPgClient(c) const actionValues = params.actions?.length ? params.actions : [] const versionName = params.version_name?.trim() const values: unknown[] = [params.app_id, params.start_date, params.end_date] diff --git a/supabase/functions/_backend/utils/tracking.ts b/supabase/functions/_backend/utils/tracking.ts index f47800ea9c..87656acdd1 100644 --- a/supabase/functions/_backend/utils/tracking.ts +++ b/supabase/functions/_backend/utils/tracking.ts @@ -159,7 +159,7 @@ async function executeBentoTracking(c: Context, payload: SendEventToTrackingPayl } await runTrackedCall(c, 'bento', async () => { - const pgClient = getPgClient(c, true) + const pgClient = await getPgClient(c, true) try { if (bento.once) { // Permanent per-(event, org, uniqId) claim: per-entity alerts (e.g. an diff --git a/supabase/functions/_backend/utils/user_bento_events.ts b/supabase/functions/_backend/utils/user_bento_events.ts index 3b4be1f2b4..55e6fa51b8 100644 --- a/supabase/functions/_backend/utils/user_bento_events.ts +++ b/supabase/functions/_backend/utils/user_bento_events.ts @@ -2,7 +2,7 @@ import type { Context } from 'hono' import { isBentoConfigured, trackBentoEvents } from './bento.ts' import { isFrontendOnboardingVersionLabel } from './frontend_onboarding_analytics_model.ts' import { cloudlogErr, serializeError } from './logging.ts' -import { closeClient, getPgClient } from './pg.ts' +import { closeClient, getPgClient} from './pg.ts' import { backgroundTask } from './utils.ts' export type TelemetryValue = string | number | boolean @@ -385,9 +385,9 @@ async function persistUserBentoObservation( userId: string, observation: MappedUserBentoEvent, ): Promise { - let pool: ReturnType | undefined + let pool: PgClient | undefined try { - pool = getPgClient(c) + pool = await getPgClient(c) const client = await pool.connect() let transactionOpen = false let rollbackError: Error | undefined @@ -465,9 +465,9 @@ export async function deliverPendingUserBentoEvents( if (!isBentoConfigured(c)) return false - let pool: ReturnType | undefined + let pool: PgClient | undefined try { - pool = getPgClient(c) + pool = await getPgClient(c) const client = await pool.connect() let transactionOpen = false let rollbackError: Error | undefined @@ -573,11 +573,11 @@ export async function recordUserBentoEvent( return try { - let fastPool: ReturnType | undefined + let fastPool: PgClient | undefined let fastEmail: string | undefined let fastState: StoredUserBentoEvents try { - fastPool = getPgClient(c) + fastPool = await getPgClient(c) const result = await fastPool.query<{ email: string, onboarding: unknown }>(FAST_STATE_SQL, [input.userId]) fastEmail = result.rows[0]?.email fastState = parseUserBentoEvents(result.rows[0]?.onboarding) diff --git a/supabase/functions/_backend/utils/webhook.ts b/supabase/functions/_backend/utils/webhook.ts index 1815be7e88..008c915f91 100644 --- a/supabase/functions/_backend/utils/webhook.ts +++ b/supabase/functions/_backend/utils/webhook.ts @@ -792,7 +792,7 @@ export async function queueWebhookDelivery( }, } - const db = getPgClient(c) + const db = await getPgClient(c) try { await db.query( 'SELECT pgmq.send($1, $2::jsonb)', @@ -835,7 +835,7 @@ export async function queueWebhookDeliveryWithDelay( }, } - const db = getPgClient(c) + const db = await getPgClient(c) try { // pgmq.send with delay parameter await db.query( diff --git a/tests/api-pg-error-logging.unit.test.ts b/tests/api-pg-error-logging.unit.test.ts index 895fafda9b..5b45cd492c 100644 --- a/tests/api-pg-error-logging.unit.test.ts +++ b/tests/api-pg-error-logging.unit.test.ts @@ -97,8 +97,8 @@ describe('API/trigger PostgreSQL error logging', () => { expect(JSON.stringify(payload)).not.toContain('test-secret') }) - it('serializes pool errors explicitly without copying the client or credentials', () => { - getPgClient(createContext()) + it('serializes pool errors explicitly without copying the client or credentials', async () => { + await getPgClient(createContext()) const listener = poolOnMock.mock.calls.find(([event]) => event === 'error')?.[1] expect(listener).toBeTypeOf('function') const error = Object.assign(new Error('failed to acquire a connection'), { diff --git a/tests/pg-close-client-lifecycle.unit.test.ts b/tests/pg-close-client-lifecycle.unit.test.ts index 59f9a0920d..518177e03f 100644 --- a/tests/pg-close-client-lifecycle.unit.test.ts +++ b/tests/pg-close-client-lifecycle.unit.test.ts @@ -1,53 +1,171 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { cloudlogErrMock } = vi.hoisted(() => ({ cloudlogErrMock: vi.fn() })) - -// backgroundTask defers pool.end() to waitUntil on workerd. Pass it through here -// so the test can await the end() promise directly. -vi.mock('../supabase/functions/_backend/utils/utils.ts', async (importOriginal) => { - const actual = await importOriginal() +const { getRuntimeKeyMock, PoolMock, ClientMock, poolOnMock, clientOnMock, clientConnectMock } = vi.hoisted(() => { + const poolOnMock = vi.fn() + const clientOnMock = vi.fn() + const clientConnectMock = vi.fn(async () => undefined) + const PoolMock = vi.fn(function PoolMock( + this: { on: typeof poolOnMock, end: ReturnType }, + _options?: { max?: number, connectionString?: string }, + ) { + this.on = poolOnMock + this.end = vi.fn(async () => undefined) + return this + }) + const ClientMock = vi.fn(function ClientMock( + this: { on: typeof clientOnMock, connect: typeof clientConnectMock, end: ReturnType }, + _options?: { connectionString?: string }, + ) { + this.on = clientOnMock + this.connect = clientConnectMock + this.end = vi.fn(async () => undefined) + return this + }) return { - ...actual, - backgroundTask: vi.fn((_c: any, p: any) => p), + getRuntimeKeyMock: vi.fn(() => 'workerd'), + PoolMock, + ClientMock, + poolOnMock, + clientOnMock, + clientConnectMock, } }) +vi.mock('hono/adapter', () => ({ + getRuntimeKey: getRuntimeKeyMock, +})) + +vi.mock('pg', () => ({ + Pool: PoolMock, + Client: ClientMock, +})) + vi.mock('../supabase/functions/_backend/utils/logging.ts', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - cloudlogErr: cloudlogErrMock, + cloudlog: vi.fn(), + cloudlogErr: vi.fn(), + } +}) + +vi.mock('../supabase/functions/_backend/utils/utils.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + backgroundTask: vi.fn((_c: any, p: any) => p), + getEnv: vi.fn((_c, key: string) => { + if (key === 'ENV_NAME') + return 'capgo_api-eu-prod-test' + if (key === 'SB_REGION') + return 'eu-west-3' + if (key === 'SUPABASE_DB_URL') + return 'postgres://supabase-direct' + if (key === 'MAIN_SUPABASE_DB_URL') + return 'postgres://main-pooler' + return '' + }), + existInEnv: vi.fn((_c, key: string) => key === 'ENV_NAME' || key === 'SB_REGION' || key === 'MAIN_SUPABASE_DB_URL'), } }) -function createContext() { +function createContext(env: Record = {}) { return { - get: (key: string) => (key === 'requestId' ? 'req-1' : undefined), + env: { + HYPERDRIVE_CAPGO_READ_EU: { connectionString: 'postgres://hyperdrive-eu' }, + ...env, + }, + get: (key: string) => { + if (key === 'requestId') + return 'request-id' + return undefined + }, + set: vi.fn(), + req: { + raw: { + cf: { continent: 'EU' }, + headers: new Headers(), + }, + url: 'http://localhost/private/latency', + header: () => undefined, + }, + res: { + headers: new Headers([['X-Worker-Source', 'api']]), + }, } as any } -describe('main pg.ts closeClient lifecycle', () => { +describe('main pg.ts Hyperdrive pg Client lifecycle', () => { beforeEach(() => { vi.resetModules() - cloudlogErrMock.mockClear() + PoolMock.mockClear() + ClientMock.mockClear() + poolOnMock.mockClear() + clientOnMock.mockClear() + clientConnectMock.mockReset() + clientConnectMock.mockImplementation(async () => undefined) + getRuntimeKeyMock.mockReturnValue('workerd') }) - it('ends the request-scoped pool (no longer a workerd no-op)', async () => { - const { closeClient } = await import('../supabase/functions/_backend/utils/pg.ts') - const end = vi.fn(async () => undefined) + it('uses a fresh connected Client per Hyperdrive request and does not end() it', async () => { + const { getPgClient, closeClient } = await import('../supabase/functions/_backend/utils/pg.ts') + const c = createContext() - await closeClient(createContext(), { end } as any) + const first = await getPgClient(c, true) + const second = await getPgClient(c, true) - expect(end).toHaveBeenCalledTimes(1) + expect(first).not.toBe(second) + expect(ClientMock).toHaveBeenCalledTimes(2) + expect(PoolMock).not.toHaveBeenCalled() + expect(clientConnectMock).toHaveBeenCalledTimes(2) + + await closeClient(c, first) + expect(first.end).not.toHaveBeenCalled() }) - it('logs and swallows end() failures without throwing', async () => { - const { closeClient } = await import('../supabase/functions/_backend/utils/pg.ts') - const end = vi.fn(async () => { - throw new Error('end unsupported') + it('uses Pool + end() outside workerd (non-Hyperdrive contract)', async () => { + getRuntimeKeyMock.mockReturnValue('node') + const { getPgClient, closeClient } = await import('../supabase/functions/_backend/utils/pg.ts') + const c = createContext() + + const first = await getPgClient(c, true) + const second = await getPgClient(c, true) + + expect(first).not.toBe(second) + expect(PoolMock).toHaveBeenCalledTimes(2) + expect(ClientMock).not.toHaveBeenCalled() + expect(PoolMock.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ max: 4 })) + + await closeClient(c, first) + expect(first.end).toHaveBeenCalledTimes(1) + }) + + it('ends non-Hyperdrive workerd Pools and logs end failures without throwing', async () => { + const { cloudlogErr } = await import('../supabase/functions/_backend/utils/logging.ts') + PoolMock.mockImplementation(function PoolMock( + this: { on: typeof poolOnMock, end: ReturnType }, + _options?: { max?: number }, + ) { + this.on = poolOnMock + this.end = vi.fn(async () => { + throw new Error('end unsupported') + }) + return this }) - await expect(closeClient(createContext(), { end } as any)).resolves.toBeUndefined() - expect(cloudlogErrMock).toHaveBeenCalledWith(expect.objectContaining({ message: 'PG client end failed' })) + const { getPgClient, closeClient } = await import('../supabase/functions/_backend/utils/pg.ts') + const c = createContext({ + HYPERDRIVE_CAPGO_READ_EU: undefined, + }) + + const client = await getPgClient(c, false) + expect(PoolMock).toHaveBeenCalled() + expect(PoolMock.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ max: 1 })) + expect(ClientMock).not.toHaveBeenCalled() + + await expect(closeClient(c, client)).resolves.toBeUndefined() + expect(cloudlogErr).toHaveBeenCalledWith(expect.objectContaining({ + message: 'PG client end failed', + })) }) }) diff --git a/tests/plugin-supabase-write-guard.unit.test.ts b/tests/plugin-supabase-write-guard.unit.test.ts index 4bccf45497..d8a9adfe84 100644 --- a/tests/plugin-supabase-write-guard.unit.test.ts +++ b/tests/plugin-supabase-write-guard.unit.test.ts @@ -179,7 +179,7 @@ describe('plugin Supabase write policy', () => { it.concurrent('fails closed instead of falling back from read replica to primary', async () => { const { getPgClient } = await import('../supabase/functions/_backend/utils/pg.ts') - expect(() => getPgClient(createPluginPolicyContext(), true)).toThrow('Read replica is required for this endpoint') + await expect(getPgClient(createPluginPolicyContext(), true)).rejects.toThrow('Read replica is required for this endpoint') }) it('skips direct Hyperdrive fallback in plugin policy contexts', async () => { From e7f3e3201e72b2e763235525123c730abe7a9a7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 17:19:58 +0000 Subject: [PATCH 3/9] fix(backend): add checkoutPgClient helpers and fix async getPgClient callers - Export checkoutPgClient/releasePgClient for Pool checkout vs Hyperdrive Client - Await getPgClient across backend call sites; align transaction types with PgQueryClient - Restore typecheck-clean org/channel transaction paths Co-authored-by: Martin DONADIEU --- .../_backend/private/accept_invitation.ts | 13 +++++---- .../_backend/private/create_device.ts | 2 +- .../private/invite_existing_user_to_org.ts | 2 +- .../private/org_notification_stats.ts | 2 +- supabase/functions/_backend/private/replay.ts | 2 +- .../_backend/private/sso/providers.ts | 2 +- .../_backend/private/sso/provision-user.ts | 2 +- .../functions/_backend/public/apikey/post.ts | 2 +- .../functions/_backend/public/apikey/put.ts | 2 +- .../functions/_backend/public/apikey/scope.ts | 2 +- supabase/functions/_backend/public/app/put.ts | 5 ++-- .../_backend/public/build/concurrency.ts | 26 +++++++----------- .../_backend/public/bundle/set_channel.ts | 23 ++++++++-------- .../_backend/public/channel/delete.ts | 21 +++++++-------- .../functions/_backend/public/channel/post.ts | 16 +++++------ .../_backend/public/notifications/index.ts | 2 +- .../public/organization/members/delete.ts | 22 +++++---------- .../public/organization/members/post.ts | 13 +++------ .../_backend/public/organization/post.ts | 13 +++------ .../_backend/public/organization/put.ts | 27 ++++++++----------- .../functions/_backend/public/queue_health.ts | 2 +- .../functions/_backend/public/replication.ts | 2 +- .../_backend/triggers/cron_app_fame.ts | 2 +- .../_backend/triggers/global_stats.ts | 2 +- .../_backend/triggers/queue_consumer.ts | 2 +- supabase/functions/_backend/utils/ab_tests.ts | 10 +++---- .../_backend/utils/bento_first_org.ts | 10 +++---- supabase/functions/_backend/utils/demo.ts | 12 ++++----- .../_backend/utils/hono_middleware.ts | 2 +- .../_backend/utils/jwt_mfa_assurance.ts | 2 +- .../_backend/utils/manifest_persist.ts | 6 ++--- .../utils/onboarding_payment_cohorts_data.ts | 9 +++---- supabase/functions/_backend/utils/pg.ts | 16 +++++++++++ .../_backend/utils/plans_billing_history.ts | 9 +++---- supabase/functions/_backend/utils/supabase.ts | 9 +++---- .../_backend/utils/user_bento_events.ts | 10 +++---- 36 files changed, 142 insertions(+), 162 deletions(-) diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index a28dbaf96d..0ea32720a0 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -1,4 +1,3 @@ -import type { PoolClient } from 'pg' import type { MiddlewareKeyVariables } from '../utils/hono.ts' import { HTTPException } from 'hono/http-exception' import { z } from 'zod' @@ -7,7 +6,7 @@ import { safeParseSchema } from '../utils/schema_validation.ts' import { parseBody, quickError, simpleError, useCors } from '../utils/hono.ts' import { cloudlog } from '../utils/logging.ts' import { getEffectivePasswordMinLength, getPasswordPolicyValidationErrors } from '../utils/password_policy.ts' -import { closeClient, getPgClient } from '../utils/pg.ts' +import { closeClient, getPgClient, type PgQueryClient, checkoutPgClient, releasePgClient } from '../utils/pg.ts' import { emptySupabase, supabaseAdmin as useSupabaseAdmin } from '../utils/supabase.ts' import { syncUserPreferenceTags } from '../utils/user_preferences.ts' import { getEnv } from '../utils/utils.ts' @@ -156,12 +155,12 @@ function isPgLockTimeoutError(error: unknown): boolean { && (error as { code: string }).code === '55P03' } -async function rollbackRbacOrgLockSavepoint(pgClient: PoolClient): Promise { +async function rollbackRbacOrgLockSavepoint(pgClient: PgQueryClient): Promise { await pgClient.query('ROLLBACK TO SAVEPOINT rbac_org_lock').catch(() => {}) await pgClient.query('RELEASE SAVEPOINT rbac_org_lock').catch(() => {}) } -async function acquireRbacOrgLockWithRetry(pgClient: PoolClient, orgId: string): Promise { +async function acquireRbacOrgLockWithRetry(pgClient: PgQueryClient, orgId: string): Promise { const lockAttempts = 6 const lockTimeoutMs = 5000 const savepointName = 'rbac_org_lock' @@ -196,11 +195,11 @@ async function ensureOrgMembership( invitation: any, ) { const pgPool = await getPgClient(c, false) - let pgClient: PoolClient | null = null + let pgClient: PgQueryClient | null = null let transactionStarted = false try { - pgClient = await pgPool.connect() + pgClient = await checkoutPgClient(pgPool) await pgClient.query('BEGIN') transactionStarted = true await pgClient.query('SET LOCAL statement_timeout = 10000') @@ -340,7 +339,7 @@ async function ensureOrgMembership( return quickError(500, 'failed_to_accept_invitation', 'Failed to finalize org membership', { error: errorMessage }) } finally { - pgClient?.release() + if (pgClient) releasePgClient(pgPool, pgClient) closeClient(c, pgPool) } } diff --git a/supabase/functions/_backend/private/create_device.ts b/supabase/functions/_backend/private/create_device.ts index 7d02d65bbf..6179fa609f 100644 --- a/supabase/functions/_backend/private/create_device.ts +++ b/supabase/functions/_backend/private/create_device.ts @@ -5,7 +5,7 @@ import { Hono } from 'hono/tiny' import { safeParseSchema } from '../utils/schema_validation.ts' import { BRES, parseBody, quickError, simpleError, useCors } from '../utils/hono.ts' import { middlewareAuth } from '../utils/hono_middleware.ts' -import { closeClient, getDrizzleClient, getPgClient} from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, type PgClient} from '../utils/pg.ts' import { schema } from '../utils/postgres_schema.ts' import { checkPermission } from '../utils/rbac.ts' import { createStatsDevices } from '../utils/stats.ts' diff --git a/supabase/functions/_backend/private/invite_existing_user_to_org.ts b/supabase/functions/_backend/private/invite_existing_user_to_org.ts index e592087fc6..5335bd92c5 100644 --- a/supabase/functions/_backend/private/invite_existing_user_to_org.ts +++ b/supabase/functions/_backend/private/invite_existing_user_to_org.ts @@ -7,7 +7,7 @@ import { CacheHelper } from '../utils/cache.ts' import { BRES, createHono, parseBody, quickError, useCors } from '../utils/hono.ts' import { middlewareAuth } from '../utils/hono_jwt.ts' import { cloudlog } from '../utils/logging.ts' -import { closeClient, getPgClient} from '../utils/pg.ts' +import { closeClient, getPgClient, type PgClient} from '../utils/pg.ts' import { canCallerAssignOrgRole, checkPermission } from '../utils/rbac.ts' import { supabaseAdmin } from '../utils/supabase.ts' import { getEnv } from '../utils/utils.ts' diff --git a/supabase/functions/_backend/private/org_notification_stats.ts b/supabase/functions/_backend/private/org_notification_stats.ts index 1a4de7378e..1aa27ce4ac 100644 --- a/supabase/functions/_backend/private/org_notification_stats.ts +++ b/supabase/functions/_backend/private/org_notification_stats.ts @@ -6,7 +6,7 @@ import { MAX_ORG_NOTIFICATION_STATS_APPS, readNotificationStatsCF, } from '../utils/nativeNotifications.ts' -import { closeClient, getDrizzleClient, getPgClient} from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, type PgClient} from '../utils/pg.ts' import { checkPermission } from '../utils/rbac.ts' import { version } from '../utils/version.ts' diff --git a/supabase/functions/_backend/private/replay.ts b/supabase/functions/_backend/private/replay.ts index eb185ae642..cec1401b73 100644 --- a/supabase/functions/_backend/private/replay.ts +++ b/supabase/functions/_backend/private/replay.ts @@ -5,7 +5,7 @@ import { Hono } from 'hono/tiny' import { BRES, parseBody, quickError, useCors } from '../utils/hono.ts' import { middlewareAuth } from '../utils/hono_middleware.ts' import { cloudlogErr, serializeError } from '../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient} from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, type PgClient} from '../utils/pg.ts' import { schema } from '../utils/postgres_schema.ts' import { capturePosthogReplaySnapshot } from '../utils/posthog.ts' diff --git a/supabase/functions/_backend/private/sso/providers.ts b/supabase/functions/_backend/private/sso/providers.ts index f99ac4b3e0..4f5f3d3cd7 100644 --- a/supabase/functions/_backend/private/sso/providers.ts +++ b/supabase/functions/_backend/private/sso/providers.ts @@ -6,7 +6,7 @@ import { safeParseSchema } from '../../utils/schema_validation.ts' import { BRES, createHono, parseBody, quickError, simpleError, useCors } from '../../utils/hono.ts' import { middlewareAuth } from '../../utils/hono_jwt.ts' import { cloudlogErr } from '../../utils/logging.ts' -import { closeClient, getPgClient} from '../../utils/pg.ts' +import { closeClient, getPgClient, type PgClient} from '../../utils/pg.ts' import { requireEnterprisePlan } from '../../utils/plan-gating.ts' import { checkPermission } from '../../utils/rbac.ts' import { createSSOProvider, deleteSSOProvider, ManagementAPIError } from '../../utils/supabase-management.ts' diff --git a/supabase/functions/_backend/private/sso/provision-user.ts b/supabase/functions/_backend/private/sso/provision-user.ts index 10e3d71c70..4f474a840a 100644 --- a/supabase/functions/_backend/private/sso/provision-user.ts +++ b/supabase/functions/_backend/private/sso/provision-user.ts @@ -3,7 +3,7 @@ import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import { createHono, quickError, useCors } from '../../utils/hono.ts' import { middlewareAuth } from '../../utils/hono_jwt.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' -import { getPgClient} from '../../utils/pg.ts' +import { getPgClient, type PgClient} from '../../utils/pg.ts' import { supabaseAdmin } from '../../utils/supabase.ts' import { version } from '../../utils/version.ts' diff --git a/supabase/functions/_backend/public/apikey/post.ts b/supabase/functions/_backend/public/apikey/post.ts index fb6d1f644c..e1d8231d6e 100644 --- a/supabase/functions/_backend/public/apikey/post.ts +++ b/supabase/functions/_backend/public/apikey/post.ts @@ -7,7 +7,7 @@ import { getErrorStatus } from '../../utils/errors.ts' import { honoFactory, parseBody, quickError, simpleError } from '../../utils/hono.ts' import { middlewareAuth } from '../../utils/hono_middleware.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient} from '../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, type PgClient} from '../../utils/pg.ts' import { checkPermissionPg } from '../../utils/rbac.ts' import { assertExpirationMatchesOrgPolicies, validateExpirationDate } from '../../utils/supabase.ts' import { parseApiKeyGlobalPermissions, replaceApiKeyGlobalPermissions, validateApiKeyGlobalPermissionsForBindings } from './global_permissions.ts' diff --git a/supabase/functions/_backend/public/apikey/put.ts b/supabase/functions/_backend/public/apikey/put.ts index 8b00d24773..ae89cd73d9 100644 --- a/supabase/functions/_backend/public/apikey/put.ts +++ b/supabase/functions/_backend/public/apikey/put.ts @@ -9,7 +9,7 @@ import { getErrorCode, getErrorStatus } from '../../utils/errors.ts' import { honoFactory, parseBody, quickError, simpleError } from '../../utils/hono.ts' import { middlewareAuth } from '../../utils/hono_middleware.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient} from '../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, type PgClient} from '../../utils/pg.ts' import { schema } from '../../utils/postgres_schema.ts' import { checkPermission, checkPermissionPg } from '../../utils/rbac.ts' import { supabaseAdmin, supabaseWithAuth, validateExpirationAgainstOrgPolicies, validateExpirationDate } from '../../utils/supabase.ts' diff --git a/supabase/functions/_backend/public/apikey/scope.ts b/supabase/functions/_backend/public/apikey/scope.ts index 1c9c63fabb..21556b4790 100644 --- a/supabase/functions/_backend/public/apikey/scope.ts +++ b/supabase/functions/_backend/public/apikey/scope.ts @@ -4,7 +4,7 @@ import type { getDrizzleClient } from '../../utils/pg.ts' import type { Database } from '../../utils/supabase.types.ts' import { quickError } from '../../utils/hono.ts' import { assertJwtMfaAssurance } from '../../utils/jwt_mfa_assurance.ts' -import { closeClient, getPgClient} from '../../utils/pg.ts' +import { closeClient, getPgClient, type PgClient} from '../../utils/pg.ts' import { checkPermission, checkPermissionPg } from '../../utils/rbac.ts' import { supabaseAdmin, supabaseWithAuth } from '../../utils/supabase.ts' diff --git a/supabase/functions/_backend/public/app/put.ts b/supabase/functions/_backend/public/app/put.ts index ebc89dcfd8..f190c37fbe 100644 --- a/supabase/functions/_backend/public/app/put.ts +++ b/supabase/functions/_backend/public/app/put.ts @@ -1,5 +1,4 @@ import type { Context } from 'hono' -import type { PoolClient } from 'pg' import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' import { sql } from 'drizzle-orm' @@ -12,7 +11,7 @@ import { createIfNotExistStoreInfo } from '../../utils/cloudflare.ts' import { lockOnboardingApp, unlockOnboardingApp } from '../../utils/demo.ts' import { quickError, simpleError } from '../../utils/hono.ts' import { cloudlog } from '../../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient } from '../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, type PgQueryClient } from '../../utils/pg.ts' import { trackPosthogEvent } from '../../utils/posthog.ts' import { checkPermission } from '../../utils/rbac.ts' import { createSignedImageUrl, getStorageAllowedOrigins, resolveWritableImageValue } from '../../utils/storage.ts' @@ -37,7 +36,7 @@ async function persistAppOnboarding( c: Context, appId: string, patch: NonNullable> | undefined, - transactionClient?: PoolClient, + transactionClient?: PgQueryClient, completePendingOnboarding = false, ) { const pool = transactionClient ? null : await getPgClient(c) diff --git a/supabase/functions/_backend/public/build/concurrency.ts b/supabase/functions/_backend/public/build/concurrency.ts index 26b375963a..3df59f96e2 100644 --- a/supabase/functions/_backend/public/build/concurrency.ts +++ b/supabase/functions/_backend/public/build/concurrency.ts @@ -2,7 +2,7 @@ import type { Context } from 'hono' import { HTTPException } from 'hono/http-exception' import { quickError, simpleError } from '../../utils/hono.ts' import { cloudlog, cloudlogErr, serializeError } from '../../utils/logging.ts' -import { closeClient, getPgClient, logPgError} from '../../utils/pg.ts' +import { checkoutPgClient, closeClient, getPgClient, logPgError, releasePgClient, type PgClient, type PgQueryClient } from '../../utils/pg.ts' import { sendEventToTracking } from '../../utils/tracking.ts' import { getEnv, trimTrailingSlashes } from '../../utils/utils.ts' @@ -10,13 +10,7 @@ export const NATIVE_BUILD_TERMINAL_STATUSES = ['succeeded', 'failed', 'expired', export const NATIVE_BUILD_CONCURRENCY_ERROR = 'native_build_concurrency_limit_exceeded' const NON_ACTIVE_NATIVE_BUILD_STATUSES = ['pending', ...NATIVE_BUILD_TERMINAL_STATUSES] as const -interface PgClient { - query: = Record>(query: string, params?: unknown[]) => Promise<{ - rowCount?: number | null - rows: T[] - }> - release: () => void -} +type NativeBuildQueryClient = PgQueryClient interface ReserveNativeBuildSlotInput { buildRequestId: string @@ -149,7 +143,7 @@ function throwNativeBuildConcurrencyLimit( }, undefined, { alert: false }) } -async function readPlanConcurrencyLimit(client: PgClient, orgId: string): Promise<{ planName: string, limit: number }> { +async function readPlanConcurrencyLimit(client: NativeBuildQueryClient, orgId: string): Promise<{ planName: string, limit: number }> { const planLimitResult = await client.query<{ plan_name: string | null native_build_concurrency: number | string | null @@ -177,7 +171,7 @@ async function readPlanConcurrencyLimit(client: PgClient, orgId: string): Promis return { planName, limit } } -async function countActiveNativeBuilds(client: PgClient, orgId: string, excludeBuildRequestId?: string): Promise { +async function countActiveNativeBuilds(client: NativeBuildQueryClient, orgId: string, excludeBuildRequestId?: string): Promise { // Bounded by idx_build_requests_org (owner_org); org-scoped active rows stay small. const activeBuildsResult = excludeBuildRequestId ? await client.query<{ active_count: string }>( @@ -212,11 +206,11 @@ export async function assertNativeBuildConcurrencyAvailable( input: { orgId: string, appId: string, userId?: string | null }, ): Promise { let pgPool: PgClient | null = null - let client: PgClient | null = null + let client: NativeBuildQueryClient | null = null try { pgPool = await getPgClient(c, true) - client = await pgPool.connect() as PgClient + client = await checkoutPgClient(pgPool) const { planName, limit } = await readPlanConcurrencyLimit(client, input.orgId) const activeBuilds = await countActiveNativeBuilds(client, input.orgId) const upgradeUrl = getPlansUpgradeUrl(c) @@ -236,7 +230,7 @@ export async function assertNativeBuildConcurrencyAvailable( throw simpleError('internal_error', 'Unable to validate native build concurrency', { error: (error as Error)?.message }) } finally { - client?.release() + if (client && pgPool) releasePgClient(pgPool, client) if (pgPool) await closeClient(c, pgPool) } @@ -249,11 +243,11 @@ export async function reserveNativeBuildSlot( let planName: string let limit: number let pgPool: PgClient | null = null - let client: PgClient | null = null + let client: NativeBuildQueryClient | null = null try { pgPool = await getPgClient(c) - client = await pgPool.connect() as PgClient + client = await checkoutPgClient(pgPool) await client.query('BEGIN') const orgLock = await client.query( @@ -345,7 +339,7 @@ export async function reserveNativeBuildSlot( throw simpleError('internal_error', 'Unable to reserve native build slot', { error: (error as Error)?.message }) } finally { - client?.release() + if (client && pgPool) releasePgClient(pgPool, client) if (pgPool) await closeClient(c, pgPool) } diff --git a/supabase/functions/_backend/public/bundle/set_channel.ts b/supabase/functions/_backend/public/bundle/set_channel.ts index f54d15abbb..4f3243c10d 100644 --- a/supabase/functions/_backend/public/bundle/set_channel.ts +++ b/supabase/functions/_backend/public/bundle/set_channel.ts @@ -4,7 +4,7 @@ import type { Database } from '../../utils/supabase.types.ts' import { HTTPException } from 'hono/http-exception' import { throwIfChannelUpdatePackageMismatch } from '../../utils/channel_update_package.ts' import { simpleError } from '../../utils/hono.ts' -import { closeClient, getDrizzleClient, getPgClient, logPgError} from '../../utils/pg.ts' +import { checkoutPgClient, closeClient, getDrizzleClient, getPgClient, logPgError, releasePgClient} from '../../utils/pg.ts' import { checkPermissionPg } from '../../utils/rbac.ts' import { isValidAppId } from '../../utils/utils.ts' @@ -14,9 +14,8 @@ export interface SetChannelBody { channel_id: number } -export interface PgQueryClient { +export interface SetChannelDbClient { query: >(text: string, params?: unknown[]) => Promise<{ rowCount?: number | null, rows: TRow[] }> - release: () => void } interface ChannelRow { name: string, owner_org: string } @@ -46,7 +45,7 @@ function getEffectiveApikey(c: Context, apikey: Database return effectiveApikey } -async function fetchTargetChannel(dbClient: PgQueryClient, body: SetChannelBody) { +async function fetchTargetChannel(dbClient: SetChannelDbClient, body: SetChannelBody) { const channelResult = await dbClient.query( `SELECT name, owner_org FROM public.channels @@ -59,7 +58,7 @@ async function fetchTargetChannel(dbClient: PgQueryClient, body: SetChannelBody) return (channelResult.rowCount ?? 0) === 1 ? channelResult.rows[0] : null } -async function fetchVersionName(dbClient: PgQueryClient, body: SetChannelBody) { +async function fetchVersionName(dbClient: SetChannelDbClient, body: SetChannelBody) { const versionResult = await dbClient.query<{ name: string }>( `SELECT name FROM public.app_versions @@ -75,7 +74,7 @@ async function fetchVersionName(dbClient: PgQueryClient, body: SetChannelBody) { return versionResult.rows[0].name } -async function updateChannelVersion(dbClient: PgQueryClient, body: SetChannelBody, channelOwnerOrg: string) { +async function updateChannelVersion(dbClient: SetChannelDbClient, body: SetChannelBody, channelOwnerOrg: string) { const updateResult = await dbClient.query( `UPDATE public.channels SET version = $1 @@ -95,10 +94,10 @@ export async function assertCanPromoteChannelInTransaction( c: Context, body: SetChannelBody, apikey: Database['public']['Tables']['apikeys']['Row'], - dbClient: PgQueryClient, + dbClient: SetChannelDbClient, checkAppScope = false, ) { - const drizzle = getDrizzleClient(dbClient as unknown as PgClient) as DrizzleClient + const drizzle = getDrizzleClient(dbClient as Parameters[0]) as DrizzleClient const canPromote = await checkPermissionPg( c, 'channel.promote_bundle', @@ -117,7 +116,7 @@ export async function setChannelInTransaction( c: Context, body: SetChannelBody, apikey: Database['public']['Tables']['apikeys']['Row'], - dbClient: PgQueryClient, + dbClient: SetChannelDbClient, ): Promise { validateSetChannelBody(body) @@ -146,11 +145,11 @@ export async function setChannelInTransaction( export async function setChannel(c: Context, body: SetChannelBody, apikey: Database['public']['Tables']['apikeys']['Row']): Promise { const pgClient = await getPgClient(c) - let dbClient: PgQueryClient | null = null + let dbClient: SetChannelDbClient | null = null let transactionStarted = false let result: SetChannelResult | null = null try { - dbClient = await pgClient.connect() + dbClient = await checkoutPgClient(pgClient) await dbClient.query('BEGIN') transactionStarted = true result = await setChannelInTransaction(c, body, apikey, dbClient) @@ -172,7 +171,7 @@ export async function setChannel(c: Context, body: SetCh throw simpleError('cannot_set_bundle_to_channel', 'Cannot set bundle to channel', { error: (error as Error)?.message }) } finally { - dbClient?.release() + if (dbClient) releasePgClient(pgClient, dbClient as import('../../utils/pg.ts').PgQueryClient) await closeClient(c, pgClient) } diff --git a/supabase/functions/_backend/public/channel/delete.ts b/supabase/functions/_backend/public/channel/delete.ts index 32f94889ea..ca91b75b4c 100644 --- a/supabase/functions/_backend/public/channel/delete.ts +++ b/supabase/functions/_backend/public/channel/delete.ts @@ -3,7 +3,7 @@ import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' import { HTTPException } from 'hono/http-exception' import { BRES, simpleError } from '../../utils/hono.ts' -import { closeClient, getPgClient, logPgError } from '../../utils/pg.ts' +import { checkoutPgClient, closeClient, getPgClient, logPgError, releasePgClient } from '../../utils/pg.ts' import { checkPermission } from '../../utils/rbac.ts' import { supabaseApikey } from '../../utils/supabase.ts' import { isValidAppId } from '../../utils/utils.ts' @@ -25,9 +25,8 @@ export interface ChannelSet { delete_bundle?: boolean } -interface PgQueryClient { +interface ChannelDeleteDbClient { query: >(text: string, params?: unknown[]) => Promise<{ rowCount?: number | null, rows: TRow[] }> - release: () => void } interface PreviewChannelRow { @@ -56,7 +55,7 @@ function getEffectiveApikey(c: Context, apikey: Database return effectiveApikey } -async function loadPreviewChannelForUpdate(dbClient: PgQueryClient, body: ChannelSet) { +async function loadPreviewChannelForUpdate(dbClient: ChannelDeleteDbClient, body: ChannelSet) { const result = await dbClient.query( `SELECT id, app_id, owner_org, rbac_id, version, rollout_version FROM public.channels @@ -69,7 +68,7 @@ async function loadPreviewChannelForUpdate(dbClient: PgQueryClient, body: Channe return (result.rowCount ?? 0) === 1 ? result.rows[0] : null } -async function loadPreviewChannelOwner(dbClient: PgQueryClient, body: ChannelSet) { +async function loadPreviewChannelOwner(dbClient: ChannelDeleteDbClient, body: ChannelSet) { const result = await dbClient.query( `SELECT owner_org FROM public.channels @@ -81,7 +80,7 @@ async function loadPreviewChannelOwner(dbClient: PgQueryClient, body: ChannelSet return (result.rowCount ?? 0) === 1 ? result.rows[0] : null } -async function loadPreviewAppForUpdate(dbClient: PgQueryClient, appId: string) { +async function loadPreviewAppForUpdate(dbClient: ChannelDeleteDbClient, appId: string) { const result = await dbClient.query( `SELECT owner_org FROM public.apps @@ -93,7 +92,7 @@ async function loadPreviewAppForUpdate(dbClient: PgQueryClient, appId: string) { return (result.rowCount ?? 0) === 1 ? result.rows[0] : null } -async function lockPreviewBundleLifecycle(dbClient: PgQueryClient, versionIds: number[]) { +async function lockPreviewBundleLifecycle(dbClient: ChannelDeleteDbClient, versionIds: number[]) { for (const versionId of [...versionIds].sort((left, right) => left - right)) { await dbClient.query( 'SELECT pg_catalog.pg_advisory_xact_lock($1::bigint)', @@ -104,7 +103,7 @@ async function lockPreviewBundleLifecycle(dbClient: PgQueryClient, versionIds: n async function assertPreviewChannelDeletePermission( c: Context, - dbClient: PgQueryClient, + dbClient: ChannelDeleteDbClient, body: ChannelSet, apikey: Database['public']['Tables']['apikeys']['Row'], effectiveApikey: string, @@ -170,11 +169,11 @@ async function deletePreviewChannelAndBundle( ) { const effectiveApikey = getEffectiveApikey(c, apikey) const pgClient = await getPgClient(c) - let dbClient: PgQueryClient | null = null + let dbClient: ChannelDeleteDbClient | null = null let transactionStarted = false try { - dbClient = await pgClient.connect() + dbClient = await checkoutPgClient(pgClient) await dbClient.query('BEGIN') transactionStarted = true @@ -297,7 +296,7 @@ async function deletePreviewChannelAndBundle( throw simpleError('cannot_delete_preview_bundle', 'Cannot delete this preview channel and bundle') } finally { - dbClient?.release() + if (dbClient) releasePgClient(pgClient, dbClient as import('../../utils/pg.ts').PgQueryClient) await closeClient(c, pgClient) } } diff --git a/supabase/functions/_backend/public/channel/post.ts b/supabase/functions/_backend/public/channel/post.ts index 954498c8cc..687bd76d38 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -5,11 +5,11 @@ import { HTTPException } from 'hono/http-exception' import { throwIfChannelUpdatePackageMismatch } from '../../utils/channel_update_package.ts' import { BRES, simpleError } from '../../utils/hono.ts' import { cloudlogErr } from '../../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient, logPgError} from '../../utils/pg.ts' +import { checkoutPgClient, closeClient, getDrizzleClient, getPgClient, logPgError, releasePgClient} from '../../utils/pg.ts' import { checkPermission, checkPermissionPg } from '../../utils/rbac.ts' import { supabaseAdmin, updateOrCreateChannel } from '../../utils/supabase.ts' import { isInternalVersionName, isValidAppId } from '../../utils/utils.ts' -import { assertCanPromoteChannelInTransaction, type PgQueryClient, setChannelInTransaction, type SetChannelBody } from '../bundle/set_channel.ts' +import { assertCanPromoteChannelInTransaction, type SetChannelDbClient, setChannelInTransaction, type SetChannelBody } from '../bundle/set_channel.ts' interface ChannelSet { app_id: string @@ -194,7 +194,7 @@ type CreatedChannel = { } async function insertChannelInTransaction( - dbClient: PgQueryClient, + dbClient: SetChannelDbClient, channel: Database['public']['Tables']['channels']['Insert'], ): Promise { const channelValues: Record = { @@ -246,7 +246,7 @@ async function insertChannelInTransaction( return { id: channelId, public: createdChannel.public } } -async function findVersionInTransaction(dbClient: PgQueryClient, appId: string, version: string, ownerOrg: string) { +async function findVersionInTransaction(dbClient: SetChannelDbClient, appId: string, version: string, ownerOrg: string) { const result = await dbClient.query<{ id: number }>( `SELECT id FROM public.app_versions @@ -274,10 +274,10 @@ async function createAndPromoteChannelInTransaction( } const pgClient = await getPgClient(c) - let dbClient: PgQueryClient | null = null + let dbClient: SetChannelDbClient | null = null let transactionStarted = false try { - dbClient = await pgClient.connect() + dbClient = await checkoutPgClient(pgClient) await dbClient.query('BEGIN') transactionStarted = true await dbClient.query( @@ -285,7 +285,7 @@ async function createAndPromoteChannelInTransaction( [JSON.stringify({ capgkey: effectiveApikey })], ) - const drizzle = getDrizzleClient(dbClient as unknown as PgClient) as DrizzleClient + const drizzle = getDrizzleClient(dbClient as Parameters[0]) as DrizzleClient const canCreateChannel = await checkPermissionPg( c, 'app.create_channel', @@ -326,7 +326,7 @@ async function createAndPromoteChannelInTransaction( throw simpleError('cannot_set_bundle_to_channel', 'Cannot set bundle to channel', { error: (error as Error)?.message }) } finally { - dbClient?.release() + if (dbClient) releasePgClient(pgClient, dbClient as import('../../utils/pg.ts').PgQueryClient) await closeClient(c, pgClient) } } diff --git a/supabase/functions/_backend/public/notifications/index.ts b/supabase/functions/_backend/public/notifications/index.ts index d9155d58ae..a959248bc8 100644 --- a/supabase/functions/_backend/public/notifications/index.ts +++ b/supabase/functions/_backend/public/notifications/index.ts @@ -27,7 +27,7 @@ import { verifyNotificationEventProof, verifyNotificationIdentityProof, } from '../../utils/nativeNotifications.ts' -import { closeClient, getDrizzleClient, getPgClient} from '../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, type PgClient} from '../../utils/pg.ts' import { checkPermission } from '../../utils/rbac.ts' import { isLimited, isValidAppId } from '../../utils/utils.ts' import { version } from '../../utils/version.ts' diff --git a/supabase/functions/_backend/public/organization/members/delete.ts b/supabase/functions/_backend/public/organization/members/delete.ts index b7796a45bb..bfdb8fa77a 100644 --- a/supabase/functions/_backend/public/organization/members/delete.ts +++ b/supabase/functions/_backend/public/organization/members/delete.ts @@ -6,7 +6,7 @@ import { HTTPException } from 'hono/http-exception' import { safeParseSchema } from '../../../utils/schema_validation.ts' import { BRES, quickError, simpleError } from '../../../utils/hono.ts' import { cloudlog } from '../../../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient} from '../../../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, type PgQueryClient, checkoutPgClient, releasePgClient} from '../../../utils/pg.ts' import { checkPermission, checkPermissionPg } from '../../../utils/rbac.ts' import { supabaseAdmin } from '../../../utils/supabase.ts' @@ -20,14 +20,6 @@ interface MemberRemovalRanks { target_max_rank: number | string | null } -interface PinnedPgClient { - query: (query: string, params?: unknown[]) => Promise<{ - rowCount?: number | null - rows: T[] - }> - release: () => void -} - interface MemberRemovalRequest { orgId: string email: string @@ -74,7 +66,7 @@ async function resolveMemberRemovalTargetUserId( } async function getMemberRemovalRanks( - pgClient: PinnedPgClient, + pgClient: PgQueryClient, authType: 'apikey' | 'jwt', callerPrincipalId: string, orgId: string, @@ -157,9 +149,9 @@ async function assertMemberRemovalAuthorizedAfterLock( auth: AuthInfo, body: MemberRemovalRequest, targetUserId: string, - dbClient: PinnedPgClient, + dbClient: PgQueryClient, ): Promise { - const pinnedDrizzle = getDrizzleClient(dbClient as unknown as PgClient) as DrizzleClient + const pinnedDrizzle = getDrizzleClient(dbClient as Parameters[0]) as DrizzleClient const apikeyString = auth.apikey?.key ?? c.get('capgkey') ?? null const canManageRoles = await checkPermissionPg( c, @@ -220,12 +212,12 @@ export async function deleteMember(c: Context, bodyRaw: // Pin the transaction to one connection: rank read, cleanup, and membership deletion // must share the organization lock with every RBAC mutation trigger. const pgPool = await getPgClient(c) - let dbClient: PinnedPgClient | undefined + let dbClient: PgQueryClient | undefined let transactionOpen = false cloudlog({ requestId: c.get('requestId'), message: 'targetUserId', data: targetUserId }) cloudlog({ requestId: c.get('requestId'), message: 'body.orgId', data: body.orgId }) try { - dbClient = await pgPool.connect() as unknown as PinnedPgClient + dbClient = await checkoutPgClient(pgPool) await dbClient.query('BEGIN') transactionOpen = true await dbClient.query('SELECT public.lock_rbac_orgs($1::uuid)', [body.orgId]) @@ -265,7 +257,7 @@ export async function deleteMember(c: Context, bodyRaw: throw simpleError('error_deleting_user_from_organization', 'Error deleting user from organization', { error }) } finally { - dbClient?.release() + if (dbClient) releasePgClient(pgPool, dbClient as import('../../../utils/pg.ts').PgQueryClient) closeClient(c, pgPool) } diff --git a/supabase/functions/_backend/public/organization/members/post.ts b/supabase/functions/_backend/public/organization/members/post.ts index b37a321919..f3fd352f01 100644 --- a/supabase/functions/_backend/public/organization/members/post.ts +++ b/supabase/functions/_backend/public/organization/members/post.ts @@ -6,7 +6,7 @@ import { z } from 'zod' import { safeParseSchema } from '../../../utils/schema_validation.ts' import { BRES, simpleError } from '../../../utils/hono.ts' import { cloudlog } from '../../../utils/logging.ts' -import { closeClient, getPgClient } from '../../../utils/pg.ts' +import { closeClient, getPgClient, type PgQueryClient, checkoutPgClient, releasePgClient } from '../../../utils/pg.ts' import { checkPermission } from '../../../utils/rbac.ts' const rbacInviteRoles = ['org_member', 'org_billing_admin', 'org_admin', 'org_super_admin'] as const @@ -37,11 +37,6 @@ const inviteBodySchema = z.object({ invite_type: inviteTypeSchema, }) -interface PgTransactionClient { - query: >(text: string, params?: unknown[]) => Promise<{ rowCount?: number | null, rows: TRow[] }> - release: () => void -} - export function normalizeInviteRole(inviteType: string): RbacInviteRole | null { if (!allowedInviteRoleSet.has(inviteType)) return null @@ -79,10 +74,10 @@ export async function post(c: Context, bodyRaw: unknown, // revoking anon execute. Mirrors organization/post.ts: BEGIN before // set_config(..., true) so capgkey survives until the RPC runs. const pgPool = await getPgClient(c) - let dbClient: PgTransactionClient | null = null + let dbClient: PgQueryClient | null = null let transactionStarted = false try { - dbClient = await pgPool.connect() as PgTransactionClient + dbClient = await checkoutPgClient(pgPool) await dbClient.query('BEGIN') transactionStarted = true await dbClient.query( @@ -109,7 +104,7 @@ export async function post(c: Context, bodyRaw: unknown, throw simpleError('error_inviting_user_to_organization', 'Error inviting user to organization', { error }) } finally { - dbClient?.release() + if (dbClient) releasePgClient(pgPool, dbClient) closeClient(c, pgPool) } diff --git a/supabase/functions/_backend/public/organization/post.ts b/supabase/functions/_backend/public/organization/post.ts index 57caf7af8e..e74df84eea 100644 --- a/supabase/functions/_backend/public/organization/post.ts +++ b/supabase/functions/_backend/public/organization/post.ts @@ -4,7 +4,7 @@ import type { Database } from '../../utils/supabase.types.ts' import { z } from 'zod' import { safeParseSchema } from '../../utils/schema_validation.ts' import { quickError, simpleError } from '../../utils/hono.ts' -import { closeClient, getPgClient} from '../../utils/pg.ts' +import { closeClient, getPgClient, type PgQueryClient, checkoutPgClient, releasePgClient, type PgClient} from '../../utils/pg.ts' import { assertJwtMfaAssurance } from '../../utils/jwt_mfa_assurance.ts' import { supabaseAdmin, supabaseWithAuth } from '../../utils/supabase.ts' import { parseOrgOnboardingDevelopmentEnvironment, parseOrgOnboardingIntent } from '../../utils/org_onboarding_intent.ts' @@ -28,11 +28,6 @@ const bodySchema = z.object({ }) -interface PgTransactionClient { - query: (text: string, params?: unknown[]) => Promise<{ rows: T[], rowCount?: number | null }> - release: () => void -} - async function getInitialPlanForMau(c: Context, estimatedMau: number) { const adminClient = supabaseAdmin(c) const { data: plan, error } = await adminClient @@ -161,11 +156,11 @@ async function insertOrgForApiKey( // API-key Supabase clients run as anon, so this checked endpoint owns the write path instead of reopening direct anon RLS inserts. let pgPool: PgClient | null = null - let dbClient: PgTransactionClient | null = null + let dbClient: PgQueryClient | null = null let transactionStarted = false try { pgPool = await getPgClient(c) - dbClient = await pgPool.connect() as PgTransactionClient + dbClient = await checkoutPgClient(pgPool) const capabilityResult = await dbClient.query<{ allowed: boolean }>( 'SELECT public.apikey_has_current_org_create_capability($1::uuid) AS allowed', [apikeyRbacId], @@ -243,7 +238,7 @@ async function insertOrgForApiKey( throw error } finally { - dbClient?.release() + if (dbClient && pgPool) releasePgClient(pgPool, dbClient) if (pgPool) { closeClient(c, pgPool) } diff --git a/supabase/functions/_backend/public/organization/put.ts b/supabase/functions/_backend/public/organization/put.ts index 0afafae110..f629485710 100644 --- a/supabase/functions/_backend/public/organization/put.ts +++ b/supabase/functions/_backend/public/organization/put.ts @@ -5,7 +5,7 @@ import { z } from 'zod' import { HTTPException } from 'hono/http-exception' import { safeParseSchema } from '../../utils/schema_validation.ts' import { quickError, simpleError } from '../../utils/hono.ts' -import { closeClient, getPgClient} from '../../utils/pg.ts' +import { closeClient, getPgClient, type PgQueryClient, checkoutPgClient, releasePgClient, type PgClient} from '../../utils/pg.ts' import { checkPermission } from '../../utils/rbac.ts' import { createSignedImageUrl, getStorageAllowedOrigins, resolveWritableImageValue } from '../../utils/storage.ts' import { getStripeCustomerName, isDeterministicStripeCustomerUpdateError, updateCustomerOrganizationName } from '../../utils/stripe.ts' @@ -59,11 +59,6 @@ interface OrganizationPutBody { password_policy_config?: PasswordPolicyConfig | null } -interface PgTransactionClient { - query: (text: string, params?: unknown[]) => Promise<{ rows: T[], rowCount?: number | null }> - release: () => void -} - const ORGANIZATION_UPDATE_COLUMNS = { logo: 'logo', name: 'name', @@ -128,7 +123,7 @@ function buildOrganizationUpdateQuery( async function setOrganizationUpdateAuditActor( c: Context, - dbClient: PgTransactionClient, + dbClient: PgQueryClient, auth: AuthInfo, ) { const isJwt = auth.authType === 'jwt' @@ -272,9 +267,9 @@ async function sanitizeOrgNameForSync( ) { // Direct SQL avoids Kong/PostgREST upstream flakes under parallel test load. const pgPool = await getPgClient(c) - let client: PgTransactionClient | null = null + let client: PgQueryClient | null = null try { - client = await pgPool.connect() as PgTransactionClient + client = await checkoutPgClient(pgPool) const result = await client.query<{ strip_html: string | null }>( 'SELECT public.strip_html($1) AS strip_html', [name], @@ -296,7 +291,7 @@ async function sanitizeOrgNameForSync( return sanitizedName } finally { - client?.release() + if (client) releasePgClient(pgPool, client) await closeClient(c, pgPool) } } @@ -321,12 +316,12 @@ async function updateOrg( options?: { expectedCurrentName?: string, expectedCurrentFields?: OrgUpdateFields }, ) { let pgPool: PgClient | null = null - let dbClient: PgTransactionClient | null = null + let dbClient: PgQueryClient | null = null let transactionStarted = false let data: OrgRow | undefined try { pgPool = await getPgClient(c) - dbClient = await pgPool.connect() as PgTransactionClient + dbClient = await checkoutPgClient(pgPool) await dbClient.query('BEGIN') transactionStarted = true // Use the primary connection with the request role and claims so RLS and audit triggers remain authoritative. @@ -349,7 +344,7 @@ async function updateOrg( }) } finally { - dbClient?.release() + if (dbClient && pgPool) releasePgClient(pgPool, dbClient) if (pgPool) await closeClient(c, pgPool) } @@ -396,9 +391,9 @@ async function getOrgForNameSync( ): Promise { // Direct SQL avoids Kong/PostgREST upstream flakes under parallel test load. const pgPool = await getPgClient(c) - let client: PgTransactionClient | null = null + let client: PgQueryClient | null = null try { - client = await pgPool.connect() as PgTransactionClient + client = await checkoutPgClient(pgPool) const result = await client.query( 'SELECT * FROM public.orgs WHERE id = $1::uuid LIMIT 1', [orgId], @@ -410,7 +405,7 @@ async function getOrgForNameSync( return data } finally { - client?.release() + if (client) releasePgClient(pgPool, client) await closeClient(c, pgPool) } } diff --git a/supabase/functions/_backend/public/queue_health.ts b/supabase/functions/_backend/public/queue_health.ts index 68d2d679c6..fac69e456e 100644 --- a/supabase/functions/_backend/public/queue_health.ts +++ b/supabase/functions/_backend/public/queue_health.ts @@ -1,6 +1,6 @@ import { honoFactory, useCors } from '../utils/hono.ts' import { cloudlogErr } from '../utils/logging.ts' -import { closeClient, getPgClient, logPgError} from '../utils/pg.ts' +import { closeClient, getPgClient, logPgError, type PgClient} from '../utils/pg.ts' import { validatePlatformAdminOrApiSecret } from '../utils/platform_admin_access.ts' type QueueStatus = 'ok' | 'ko' diff --git a/supabase/functions/_backend/public/replication.ts b/supabase/functions/_backend/public/replication.ts index a22c4de3ce..b6e514659f 100644 --- a/supabase/functions/_backend/public/replication.ts +++ b/supabase/functions/_backend/public/replication.ts @@ -3,7 +3,7 @@ import { sql } from 'drizzle-orm' import { CacheHelper } from '../utils/cache.ts' import { honoFactory, useCors } from '../utils/hono.ts' import { cloudlogErr } from '../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient, logPgError} from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, logPgError, type PgClient} from '../utils/pg.ts' import { validatePlatformAdminOrApiSecret } from '../utils/platform_admin_access.ts' const DEFAULT_THRESHOLD_SECONDS = 180 diff --git a/supabase/functions/_backend/triggers/cron_app_fame.ts b/supabase/functions/_backend/triggers/cron_app_fame.ts index 5754a34163..04b58cec23 100644 --- a/supabase/functions/_backend/triggers/cron_app_fame.ts +++ b/supabase/functions/_backend/triggers/cron_app_fame.ts @@ -11,7 +11,7 @@ import { } from '../utils/app_fame.ts' import { BRES, middlewareAPISecret, quickError } from '../utils/hono.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' -import { closeClient, getDrizzleClient, getPgClient, logPgError} from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, logPgError, type PgClient} from '../utils/pg.ts' export const app = new Hono() diff --git a/supabase/functions/_backend/triggers/global_stats.ts b/supabase/functions/_backend/triggers/global_stats.ts index 1e15c363bf..c4300650a9 100644 --- a/supabase/functions/_backend/triggers/global_stats.ts +++ b/supabase/functions/_backend/triggers/global_stats.ts @@ -10,7 +10,7 @@ import { GLOBAL_STATS_SHARDS, REQUIRED_GLOBAL_STATS_SHARDS, USAGE_GLOBAL_STATS_S import { BRES, middlewareAPISecret, quickError } from '../utils/hono.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' import { readGlobalNotificationStatsCF } from '../utils/nativeNotifications.ts' -import { closeClient, getDrizzleClient, getPgClient} from '../utils/pg.ts' +import { closeClient, getDrizzleClient, getPgClient, type PgClient} from '../utils/pg.ts' import { countAllApps, countAllUpdates, countAllUpdatesExternal } from '../utils/stats.ts' import { supabaseAdmin } from '../utils/supabase.ts' import { sendEventToTracking } from '../utils/tracking.ts' diff --git a/supabase/functions/_backend/triggers/queue_consumer.ts b/supabase/functions/_backend/triggers/queue_consumer.ts index fa27552e24..d10e8dba8a 100644 --- a/supabase/functions/_backend/triggers/queue_consumer.ts +++ b/supabase/functions/_backend/triggers/queue_consumer.ts @@ -8,7 +8,7 @@ import { integerLikeSchema, safeParseSchema } from '../utils/schema_validation.t import { sendDiscordAlert } from '../utils/discord.ts' import { BRES, middlewareAPISecret, parseBody, simpleError } from '../utils/hono.ts' import { cloudlog, cloudlogErr, serializeError } from '../utils/logging.ts' -import { closeClient, getPgClient} from '../utils/pg.ts' +import { closeClient, getPgClient, type PgClient} from '../utils/pg.ts' import { backgroundTask, getEnv, WAIT_FOR_COMPLETION_HEADER } from '../utils/utils.ts' import { updateManifestSize } from './on_manifest_create.ts' diff --git a/supabase/functions/_backend/utils/ab_tests.ts b/supabase/functions/_backend/utils/ab_tests.ts index c768490c24..6a00ff459e 100644 --- a/supabase/functions/_backend/utils/ab_tests.ts +++ b/supabase/functions/_backend/utils/ab_tests.ts @@ -6,7 +6,7 @@ import rawABTestsConfig from './ab_tests.json' with { type: 'json' } import { syncBentoSubscriberTags } from './bento.ts' import { quickError } from './hono.ts' import { cloudlogErr } from './logging.ts' -import { closeClient, getDrizzleClient, getPgClient } from './pg.ts' +import { closeClient, getDrizzleClient, getPgClient, checkoutPgClient, releasePgClient } from './pg.ts' import { backgroundTask } from './utils.ts' export type ABTestAudience = 'all' | 'self_signup' @@ -254,7 +254,7 @@ async function readAssignmentUser( ): Promise { const pgPool = await getPgClient(c, true) try { - const pgClient = await pgPool.connect() + const pgClient = await checkoutPgClient(pgPool) try { const result = await pgClient.query( `SELECT created_via_invite, @@ -270,7 +270,7 @@ async function readAssignmentUser( return result.rows[0] } finally { - pgClient.release(true) + releasePgClient(pgPool, pgClient, true) } } finally { @@ -286,7 +286,7 @@ async function persistABTestAssignments( const pgPool = await getPgClient(c) let persisted: unknown try { - const pgClient = await pgPool.connect() + const pgClient = await checkoutPgClient(pgPool) try { const result = await pgClient.query<{ abtests: unknown }>( `UPDATE public.users @@ -306,7 +306,7 @@ async function persistABTestAssignments( persisted = result.rows[0]?.abtests } finally { - pgClient.release(true) + releasePgClient(pgPool, pgClient, true) } } finally { diff --git a/supabase/functions/_backend/utils/bento_first_org.ts b/supabase/functions/_backend/utils/bento_first_org.ts index 29f4d71faf..47912be488 100644 --- a/supabase/functions/_backend/utils/bento_first_org.ts +++ b/supabase/functions/_backend/utils/bento_first_org.ts @@ -3,7 +3,7 @@ import type { MiddlewareKeyVariables } from './hono.ts' import type { Database } from './supabase.types.ts' import { syncBentoSubscriberTags, trackBentoEvent, unsubscribeBento } from './bento.ts' import { quickError } from './hono.ts' -import { closeClient, getPgClient} from './pg.ts' +import { closeClient, getPgClient, checkoutPgClient, releasePgClient, type PgClient} from './pg.ts' export const BENTO_AWAITING_FIRST_ORG_TAG = 'onboarding:awaiting_first_org' // Permanent safety opt-out: never remove this tag. The Bento recovery workflow @@ -202,7 +202,7 @@ async function runBentoMutationWithFirstOrgReconciliation( } async function getFirstOrgDatabaseState(pgPool: PgClient, userId: string) { - const pgClient = await pgPool.connect() + const pgClient = await checkoutPgClient(pgPool) try { const result = await pgClient.query( `SELECT @@ -237,7 +237,7 @@ async function getFirstOrgDatabaseState(pgPool: PgClient, userId: string) { // General-backend Pools are request-scoped and closeClient intentionally // does not end them in workerd. Destroy the checked-out socket at the query // boundary so it cannot survive across Bento I/O or request teardown. - pgClient.release(true) + releasePgClient(pgPool, pgClient, true) } } @@ -344,7 +344,7 @@ export async function syncBentoFirstOrgOnRoleBindingWrite( const pgPool = await getPgClient(c) try { let binding: CurrentRoleBinding | undefined - const pgClient = await pgPool.connect() + const pgClient = await checkoutPgClient(pgPool) try { const result = await pgClient.query( `SELECT @@ -369,7 +369,7 @@ export async function syncBentoFirstOrgOnRoleBindingWrite( finally { // See hasActiveDirectOrgAccess: destroy this request-scoped socket before // the handler crosses the network boundary into Bento. - pgClient.release(true) + releasePgClient(pgPool, pgClient, true) } if ( diff --git a/supabase/functions/_backend/utils/demo.ts b/supabase/functions/_backend/utils/demo.ts index 2043bb7e1f..dbdf858df2 100644 --- a/supabase/functions/_backend/utils/demo.ts +++ b/supabase/functions/_backend/utils/demo.ts @@ -1,8 +1,7 @@ import type { Context } from 'hono' -import type { PoolClient } from 'pg' import type { MiddlewareKeyVariables } from './hono.ts' import { cloudlog } from './logging.ts' -import { closeClient, getPgClient } from './pg.ts' +import { checkoutPgClient, closeClient, getPgClient, releasePgClient, type PgQueryClient } from './pg.ts' import { supabaseAdmin } from './supabase.ts' export function isDemoAppRow(app?: { need_onboarding?: boolean | null }): boolean { @@ -30,15 +29,16 @@ export async function isDemoApp(c: Context, appId: strin export async function lockOnboardingApp(c: Context, appId: string) { const pool = await getPgClient(c) - let client: PoolClient | undefined + let client: PgQueryClient | undefined try { - client = await pool.connect() + client = await checkoutPgClient(pool) await client.query('SELECT pg_advisory_lock(hashtext($1))', [`onboarding-demo:${appId}`]) return { client, pool } } catch (error) { - client?.release(error instanceof Error ? error : true) + if (client) + releasePgClient(pool, client, error instanceof Error ? error : true) await closeClient(c, pool) cloudlog({ requestId: c.get('requestId'), message: 'Cannot acquire onboarding app lock', error, app_id: appId }) throw error @@ -59,7 +59,7 @@ export async function unlockOnboardingApp( cloudlog({ requestId: c.get('requestId'), message: 'Cannot release onboarding app lock', error, app_id: appId }) } finally { - lock.client.release(releaseError) + releasePgClient(lock.pool, lock.client, releaseError) await closeClient(c, lock.pool) } } diff --git a/supabase/functions/_backend/utils/hono_middleware.ts b/supabase/functions/_backend/utils/hono_middleware.ts index 4d2e82403c..d45ab0faa8 100644 --- a/supabase/functions/_backend/utils/hono_middleware.ts +++ b/supabase/functions/_backend/utils/hono_middleware.ts @@ -5,7 +5,7 @@ import { and, eq, isNull, or, sql } from 'drizzle-orm' import { honoFactory, quickError, simpleRateLimit } from './hono.ts' import { getClaimsFromJWT } from './hono_jwt.ts' import { cloudlog } from './logging.ts' -import { closeClient, getDrizzleClient, getPgClient, logPgError} from './pg.ts' +import { closeClient, getDrizzleClient, getPgClient, logPgError, type PgClient} from './pg.ts' import * as schema from './postgres_schema.ts' import { isAPIKeyRateLimited, isIPRateLimited, recordAPIKeyUsage, recordFailedAuth } from './rate_limit.ts' import { buildRateLimitInfo } from './rateLimitInfo.ts' diff --git a/supabase/functions/_backend/utils/jwt_mfa_assurance.ts b/supabase/functions/_backend/utils/jwt_mfa_assurance.ts index 7e4fb552aa..6e5d9d22fb 100644 --- a/supabase/functions/_backend/utils/jwt_mfa_assurance.ts +++ b/supabase/functions/_backend/utils/jwt_mfa_assurance.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono' import type { AuthInfo, JWTClaims, MiddlewareKeyVariables } from './hono.ts' import { quickError } from './hono.ts' -import { closeClient, getPgClient} from './pg.ts' +import { closeClient, getPgClient, type PgClient} from './pg.ts' const SESSION_ID_UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/supabase/functions/_backend/utils/manifest_persist.ts b/supabase/functions/_backend/utils/manifest_persist.ts index 0a219bb000..5a0628ddef 100644 --- a/supabase/functions/_backend/utils/manifest_persist.ts +++ b/supabase/functions/_backend/utils/manifest_persist.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono' import { cloudlog } from './logging.ts' import { isPostgresSafeText, normalizeLegacyEncodedManifestFileName } from './manifest_encoding.ts' -import { closeClient, getPgClient } from './pg.ts' +import { closeClient, getPgClient, checkoutPgClient, releasePgClient } from './pg.ts' import { supabaseAdmin } from './supabase.ts' export interface ManifestPersistEntry { @@ -86,7 +86,7 @@ export async function persistVersionManifestEntries( } const pgPool = await getPgClient(c, false) - const pgClient = await pgPool.connect() + const pgClient = await checkoutPgClient(pgPool) try { await pgClient.query('BEGIN') // Serialize concurrent writers for this version (no unique constraint on manifest rows). @@ -149,7 +149,7 @@ export async function persistVersionManifestEntries( throw error } finally { - pgClient.release() + if (pgClient) releasePgClient(pgPool, pgClient) await closeClient(c, pgPool) } diff --git a/supabase/functions/_backend/utils/onboarding_payment_cohorts_data.ts b/supabase/functions/_backend/utils/onboarding_payment_cohorts_data.ts index c18bd58a1d..27862e876c 100644 --- a/supabase/functions/_backend/utils/onboarding_payment_cohorts_data.ts +++ b/supabase/functions/_backend/utils/onboarding_payment_cohorts_data.ts @@ -1,8 +1,7 @@ import type { Context } from 'hono' -import type { PoolClient } from 'pg' import type { OnboardingPaymentCohortData, OnboardingPaymentCohortPeriod } from './onboarding_payment_cohorts_model.ts' import { assertOnboardingPaymentSourceComplete, ONBOARDING_PAYMENT_SOURCE_LIMIT, onboardingPaymentSourceNumber, onboardingPaymentSourceString, onboardingPaymentSourceTimestamp } from './onboarding_payment_cohorts_model.ts' -import { closeClient, getPgClient } from './pg.ts' +import { closeClient, getPgClient, type PgQueryClient, checkoutPgClient, releasePgClient } from './pg.ts' export interface OnboardingPaymentQueryExecutor { query: (text: string, values: unknown[]) => Promise<{ rows: Record[] }> @@ -78,14 +77,14 @@ export async function queryOnboardingPaymentCohortData(executor: OnboardingPayme export async function loadOnboardingPaymentCohortData(c: Context, period: OnboardingPaymentCohortPeriod): Promise { // auth.users is not replicated: false deliberately selects the primary connection. const pool = await getPgClient(c, false) - let client: PoolClient | undefined + let client: PgQueryClient | undefined try { - client = await pool.connect() + client = await checkoutPgClient(pool) return await queryOnboardingPaymentCohortData(client, period) } finally { try { - client?.release() + if (client) releasePgClient(pool, client) } finally { await closeClient(c, pool) diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 42bd7b8857..b92a2fabdd 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -31,9 +31,25 @@ import { shouldRequireReadReplica, shouldSkipDirectHyperdriveFallback } from './ */ export type PgClient = Client | Pool +/** Checked-out query handle: Pool connection or a connected Hyperdrive Client. */ +export type PgQueryClient = PoolClient | Client + /** Hyperdrive owns Worker↔origin cleanup; do not call `.end()` on these clients. */ const skipEndClients = new WeakSet() +export async function checkoutPgClient(pg: PgClient): Promise { + if (skipEndClients.has(pg)) + return pg as Client + return (pg as Pool).connect() +} + +export function releasePgClient(pg: PgClient, client: PgQueryClient | null | undefined, error?: boolean | Error): void { + if (!client || skipEndClients.has(pg)) + return + if ('release' in client) + client.release(error) +} + const REPLICATION_LAG_THRESHOLD_SECONDS = 180 const REPLICATION_LAG_CACHE_TTL_SECONDS = 60 const REPLICATION_LAG_CACHE_TTL_MS = REPLICATION_LAG_CACHE_TTL_SECONDS * 1000 diff --git a/supabase/functions/_backend/utils/plans_billing_history.ts b/supabase/functions/_backend/utils/plans_billing_history.ts index 83838134fe..4366a4ed33 100644 --- a/supabase/functions/_backend/utils/plans_billing_history.ts +++ b/supabase/functions/_backend/utils/plans_billing_history.ts @@ -1,6 +1,5 @@ import type { Context } from 'hono' -import type { PoolClient } from 'pg' -import { closeClient, getPgClient } from './pg.ts' +import { closeClient, getPgClient, type PgQueryClient, checkoutPgClient, releasePgClient } from './pg.ts' export interface RevenueMovement { date: string @@ -381,10 +380,10 @@ export async function loadPlansBillingHistories( return new Map() const pool = await getPgClient(c, true) - let client: PoolClient | undefined + let client: PgQueryClient | undefined try { - client = await pool.connect() + client = await checkoutPgClient(pool) const organizations = await client.query(` SELECT o.id::text AS org_id, o.customer_id, si.trial_at, si.paid_at, si.canceled_at, si.past_due_at, si.churn_reason @@ -509,7 +508,7 @@ export async function loadPlansBillingHistories( return histories } finally { - client?.release() + if (client) releasePgClient(pool, client) await closeClient(c, pool) } } diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index ada9a220f6..8ec72a033e 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -1,7 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { Context } from 'hono' // @ts-types="npm:@types/pg" -import type { PoolClient } from 'pg' import type { BillingPlanBentoState } from './billing_bento_tags.ts' import type { AuthInfo } from './hono.ts' import type { Database } from './supabase.types.ts' @@ -12,7 +11,7 @@ import { buildBillingPlanBentoTags } from './billing_bento_tags.ts' import { buildNormalizedDeviceForWrite, hasComparableDeviceChanged, nullableString } from './deviceComparison.ts' import { quickError, simpleError } from './hono.ts' import { cloudlog, cloudlogErr } from './logging.ts' -import { closeClient, getPgClient } from './pg.ts' +import { closeClient, getPgClient, type PgQueryClient, checkoutPgClient, releasePgClient } from './pg.ts' import { emptyStatsInsights, normalizeStatsInsightsResult } from './statsInsights.ts' import { Constants } from './supabase.types.ts' import { getEnv, isStripeConfigured } from './utils.ts' @@ -952,10 +951,10 @@ export async function createApiKey(c: Context, userId: string) { } const pgPool = await getPgClient(c) - let pgClient: PoolClient | undefined + let pgClient: PgQueryClient | undefined let inTransaction = false try { - pgClient = await pgPool.connect() + pgClient = await checkoutPgClient(pgPool) await pgClient.query('BEGIN') inTransaction = true await pgClient.query(`SET LOCAL lock_timeout = '5s'`) @@ -1156,7 +1155,7 @@ export async function createApiKey(c: Context, userId: string) { // Workerd keeps request-scoped Pools open, so destroy the checked-out // socket explicitly after the transaction and then close the Pool where // the runtime supports it. - pgClient?.release(true) + if (pgClient) releasePgClient(pgPool, pgClient, true) closeClient(c, pgPool) } } diff --git a/supabase/functions/_backend/utils/user_bento_events.ts b/supabase/functions/_backend/utils/user_bento_events.ts index 55e6fa51b8..147f143c2a 100644 --- a/supabase/functions/_backend/utils/user_bento_events.ts +++ b/supabase/functions/_backend/utils/user_bento_events.ts @@ -2,7 +2,7 @@ import type { Context } from 'hono' import { isBentoConfigured, trackBentoEvents } from './bento.ts' import { isFrontendOnboardingVersionLabel } from './frontend_onboarding_analytics_model.ts' import { cloudlogErr, serializeError } from './logging.ts' -import { closeClient, getPgClient} from './pg.ts' +import { closeClient, getPgClient, checkoutPgClient, releasePgClient, type PgClient} from './pg.ts' import { backgroundTask } from './utils.ts' export type TelemetryValue = string | number | boolean @@ -388,7 +388,7 @@ async function persistUserBentoObservation( let pool: PgClient | undefined try { pool = await getPgClient(c) - const client = await pool.connect() + const client = await checkoutPgClient(pool) let transactionOpen = false let rollbackError: Error | undefined try { @@ -435,7 +435,7 @@ async function persistUserBentoObservation( } finally { try { - client.release(rollbackError) + releasePgClient(pool, client, rollbackError) } catch (error) { logUserBentoError(c, 'observe', userId, observation.bentoEvent, error) @@ -468,7 +468,7 @@ export async function deliverPendingUserBentoEvents( let pool: PgClient | undefined try { pool = await getPgClient(c) - const client = await pool.connect() + const client = await checkoutPgClient(pool) let transactionOpen = false let rollbackError: Error | undefined try { @@ -535,7 +535,7 @@ export async function deliverPendingUserBentoEvents( } finally { try { - client.release(rollbackError) + releasePgClient(pool, client, rollbackError) } catch (error) { logUserBentoError(c, 'deliver', userId, undefined, error) From 156e70895806a18686e949313f239f382ba0f1cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 17:31:40 +0000 Subject: [PATCH 4/9] test(backend): add checkoutPgClient mocks for async getPgClient Unit tests that mock pg.ts need checkoutPgClient and releasePgClient after the Hyperdrive Client + skipEnd alignment. Share a small helper for the Pool checkout path used across affected mocks. Co-authored-by: Martin DONADIEU --- tests/ab-tests.unit.test.ts | 15 ++++++++++----- tests/bento-first-org-lifecycle.unit.test.ts | 13 +++++++++---- tests/bundle-set-channel-rbac.unit.test.ts | 17 +++++++++++------ tests/channel-delete-rbac.unit.test.ts | 15 ++++++++++----- tests/channel-post.unit.test.ts | 19 ++++++++++++------- .../create-api-key-deletion-race.unit.test.ts | 13 +++++++++---- tests/helpers/pg-checkout-release-mocks.ts | 13 +++++++++++++ tests/native-build-concurrency.unit.test.ts | 15 ++++++++++----- ...boarding-payment-cohorts-data.unit.test.ts | 5 ++++- .../organization-put-stripe-sync.unit.test.ts | 13 +++++++++---- tests/plans-billing-history.unit.test.ts | 13 +++++++++---- tests/user-bento-event-delivery.unit.test.ts | 13 +++++++++---- 12 files changed, 115 insertions(+), 49 deletions(-) create mode 100644 tests/helpers/pg-checkout-release-mocks.ts diff --git a/tests/ab-tests.unit.test.ts b/tests/ab-tests.unit.test.ts index 4bfc38281a..74b855e4ab 100644 --- a/tests/ab-tests.unit.test.ts +++ b/tests/ab-tests.unit.test.ts @@ -45,11 +45,16 @@ vi.mock('../supabase/functions/_backend/utils/bento.ts', () => ({ syncBentoSubscriberTags: syncBentoSubscriberTagsMock, })) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - closeClient: closeClientMock, - getDrizzleClient: getDrizzleClientMock, - getPgClient: getPgClientMock, -})) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { + closeClient: closeClientMock, + getDrizzleClient: getDrizzleClientMock, + getPgClient: getPgClientMock, + checkoutPgClient, + releasePgClient, + } +}) vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ backgroundTask: backgroundTaskMock, diff --git a/tests/bento-first-org-lifecycle.unit.test.ts b/tests/bento-first-org-lifecycle.unit.test.ts index 6549b979ef..7e7ba095ac 100644 --- a/tests/bento-first-org-lifecycle.unit.test.ts +++ b/tests/bento-first-org-lifecycle.unit.test.ts @@ -71,10 +71,15 @@ vi.mock('../supabase/functions/_backend/utils/hono.ts', async () => { } }) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - closeClient: closeClientMock, - getPgClient: getPgClientMock, -})) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { + closeClient: closeClientMock, + getPgClient: getPgClientMock, + checkoutPgClient, + releasePgClient, + } +}) vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ createApiKey: createApiKeyMock, diff --git a/tests/bundle-set-channel-rbac.unit.test.ts b/tests/bundle-set-channel-rbac.unit.test.ts index 8e2c40c2dc..a42b1f8274 100644 --- a/tests/bundle-set-channel-rbac.unit.test.ts +++ b/tests/bundle-set-channel-rbac.unit.test.ts @@ -15,12 +15,17 @@ vi.mock('../supabase/functions/_backend/utils/rbac.ts', () => ({ checkPermissionPg: (...args: unknown[]) => checkPermissionPgMock(...args), })) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - closeClient: (...args: unknown[]) => closeClientMock(...args), - getDrizzleClient: (...args: unknown[]) => getDrizzleClientMock(...args), - getPgClient: () => pgClientMock, - logPgError: (...args: unknown[]) => logPgErrorMock(...args), -})) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { + closeClient: (...args: unknown[]) => closeClientMock(...args), + getDrizzleClient: (...args: unknown[]) => getDrizzleClientMock(...args), + getPgClient: async () => pgClientMock, + checkoutPgClient, + releasePgClient, + logPgError: (...args: unknown[]) => logPgErrorMock(...args), + } +}) const { setChannel } = await import('../supabase/functions/_backend/public/bundle/set_channel.ts') diff --git a/tests/channel-delete-rbac.unit.test.ts b/tests/channel-delete-rbac.unit.test.ts index f46371a2ec..5ca3960eea 100644 --- a/tests/channel-delete-rbac.unit.test.ts +++ b/tests/channel-delete-rbac.unit.test.ts @@ -16,11 +16,16 @@ vi.mock('../supabase/functions/_backend/utils/rbac.ts', () => ({ checkPermission: (...args: unknown[]) => checkPermissionMock(...args), })) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - closeClient: (...args: unknown[]) => closeClientMock(...args), - getPgClient: (...args: unknown[]) => getPgClientMock(...args), - logPgError: (...args: unknown[]) => logPgErrorMock(...args), -})) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { + closeClient: (...args: unknown[]) => closeClientMock(...args), + getPgClient: (...args: unknown[]) => getPgClientMock(...args), + checkoutPgClient, + releasePgClient, + logPgError: (...args: unknown[]) => logPgErrorMock(...args), + } +}) vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ supabaseApikey: (...args: unknown[]) => supabaseApikeyMock(...args), diff --git a/tests/channel-post.unit.test.ts b/tests/channel-post.unit.test.ts index 209620e934..15032a130f 100644 --- a/tests/channel-post.unit.test.ts +++ b/tests/channel-post.unit.test.ts @@ -40,12 +40,17 @@ vi.mock('../supabase/functions/_backend/utils/rbac.ts', () => ({ checkPermissionPg, })) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - closeClient, - getDrizzleClient, - getPgClient, - logPgError, -})) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { + closeClient, + getDrizzleClient, + getPgClient, + logPgError, + checkoutPgClient, + releasePgClient, + } +}) vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ supabaseAdmin, @@ -192,7 +197,7 @@ describe('public channel post', () => { isValidAppId.mockReturnValue(true) supabaseAdmin.mockImplementation(() => buildAdminChain()) updateOrCreateChannel.mockResolvedValue({ data: { id: 99 }, error: null }) - getPgClient.mockReturnValue(pgClient) + getPgClient.mockResolvedValue(pgClient) pgClient.connect.mockResolvedValue(dbClient) getDrizzleClient.mockReturnValue(drizzle) closeClient.mockResolvedValue(undefined) diff --git a/tests/create-api-key-deletion-race.unit.test.ts b/tests/create-api-key-deletion-race.unit.test.ts index 9303ae53e2..e7b0bdf33f 100644 --- a/tests/create-api-key-deletion-race.unit.test.ts +++ b/tests/create-api-key-deletion-race.unit.test.ts @@ -19,10 +19,15 @@ const { } }) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - closeClient: closeClientMock, - getPgClient: getPgClientMock, -})) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { + closeClient: closeClientMock, + getPgClient: getPgClientMock, + checkoutPgClient, + releasePgClient, + } +}) const { createApiKey } = await import('../supabase/functions/_backend/utils/supabase.ts') diff --git a/tests/helpers/pg-checkout-release-mocks.ts b/tests/helpers/pg-checkout-release-mocks.ts new file mode 100644 index 0000000000..82c0e931c5 --- /dev/null +++ b/tests/helpers/pg-checkout-release-mocks.ts @@ -0,0 +1,13 @@ +/** Test doubles for checkoutPgClient/releasePgClient (non-Hyperdrive Pool path). */ +export async function checkoutPgClient(pg: { connect: () => Promise }) { + return await pg.connect() +} + +export function releasePgClient( + _pg: unknown, + client: { release?: (error?: boolean | Error) => void } | null | undefined, + error?: boolean | Error, +): void { + if (client && 'release' in client) + client.release?.(error) +} diff --git a/tests/native-build-concurrency.unit.test.ts b/tests/native-build-concurrency.unit.test.ts index 455af3dae7..2e8523c609 100644 --- a/tests/native-build-concurrency.unit.test.ts +++ b/tests/native-build-concurrency.unit.test.ts @@ -14,11 +14,16 @@ const { mockCloseClient, mockGetPgClient, mockLogPgError, mockGetEnv, mockSendEv mockSendEventToTracking: vi.fn(), })) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - closeClient: mockCloseClient, - getPgClient: mockGetPgClient, - logPgError: mockLogPgError, -})) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { + closeClient: mockCloseClient, + getPgClient: mockGetPgClient, + checkoutPgClient, + releasePgClient, + logPgError: mockLogPgError, + } +}) vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ getEnv: mockGetEnv, diff --git a/tests/onboarding-payment-cohorts-data.unit.test.ts b/tests/onboarding-payment-cohorts-data.unit.test.ts index a3ff2d1bf5..b7c761fbcc 100644 --- a/tests/onboarding-payment-cohorts-data.unit.test.ts +++ b/tests/onboarding-payment-cohorts-data.unit.test.ts @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { loadOnboardingPaymentCohortData } from '../supabase/functions/_backend/utils/onboarding_payment_cohorts_data.ts' const { getPgMock, closeMock, queryMock, releaseMock, connectMock } = vi.hoisted(() => ({ getPgMock: vi.fn(), closeMock: vi.fn(), queryMock: vi.fn(), releaseMock: vi.fn(), connectMock: vi.fn() })) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ getPgClient: getPgMock, closeClient: closeMock })) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { getPgClient: getPgMock, closeClient: closeMock, checkoutPgClient, releasePgClient } +}) const c = {} as Context const period = { start: '2026-06-01T00:00:00.000Z', cutoff: '2026-09-16T00:00:00.000Z' } const user = { id: '00000000-0000-4000-a000-000000000001', signup_at: new Date('2026-09-01T00:00:00Z'), has_public_row: true, created_via_invite: false, total_rows: '1' } diff --git a/tests/organization-put-stripe-sync.unit.test.ts b/tests/organization-put-stripe-sync.unit.test.ts index e9310ec9ae..e009ff0b4b 100644 --- a/tests/organization-put-stripe-sync.unit.test.ts +++ b/tests/organization-put-stripe-sync.unit.test.ts @@ -44,10 +44,15 @@ vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ supabaseAdmin: (...args: unknown[]) => supabaseAdminMock(...args), })) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - getPgClient: (...args: unknown[]) => getPgClientMock(...args), - closeClient: (...args: unknown[]) => closeClientMock(...args), -})) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { + getPgClient: (...args: unknown[]) => getPgClientMock(...args), + closeClient: (...args: unknown[]) => closeClientMock(...args), + checkoutPgClient, + releasePgClient, + } +}) const { put } = await import('../supabase/functions/_backend/public/organization/put.ts') type OrgRow = Database['public']['Tables']['orgs']['Row'] diff --git a/tests/plans-billing-history.unit.test.ts b/tests/plans-billing-history.unit.test.ts index 3d54391d41..fd6d5d2b26 100644 --- a/tests/plans-billing-history.unit.test.ts +++ b/tests/plans-billing-history.unit.test.ts @@ -27,10 +27,15 @@ const { } }) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - closeClient: closeClientMock, - getPgClient: getPgClientMock, -})) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { + closeClient: closeClientMock, + getPgClient: getPgClientMock, + checkoutPgClient, + releasePgClient, + } +}) const at = Date.parse('2026-08-01T12:00:00Z') function base(): OrganizationBillingHistory { diff --git a/tests/user-bento-event-delivery.unit.test.ts b/tests/user-bento-event-delivery.unit.test.ts index e09dd1b461..76d77857d5 100644 --- a/tests/user-bento-event-delivery.unit.test.ts +++ b/tests/user-bento-event-delivery.unit.test.ts @@ -27,10 +27,15 @@ vi.mock('../supabase/functions/_backend/utils/logging.ts', () => ({ serializeError: mocks.serializeError, })) -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - closeClient: mocks.closeClient, - getPgClient: mocks.getPgClient, -})) +vi.mock('../supabase/functions/_backend/utils/pg.ts', async () => { + const { checkoutPgClient, releasePgClient } = await import('./helpers/pg-checkout-release-mocks.ts') + return { + closeClient: mocks.closeClient, + getPgClient: mocks.getPgClient, + checkoutPgClient, + releasePgClient, + } +}) vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ backgroundTask: mocks.backgroundTask, From 5442c762f4e3a6cd64dd8efc5c2734f9473fa0e4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 18:01:28 +0000 Subject: [PATCH 5/9] Revert "merge(main): sync branch for request_actor_email_adress RPC and onboarding" This reverts commit cc38f57c50a5fc2dddfd1683d58e9bf543390d70, reversing changes made to e7f3e3201e72b2e763235525123c730abe7a9a7a. --- .typos.toml | 1 - BOUNTY.md | 3 + SECURITY.md | 34 - bun.lock | 21 +- cli/AGENTS.md | 10 - cli/README.md | 48 +- cli/SECURITY.md | 38 - cli/build.mjs | 4 +- cli/package.json | 20 +- cli/skills/organization-management/SKILL.md | 7 +- cli/skills/release-management/SKILL.md | 2 - cli/skills/usage/SKILL.md | 10 +- cli/src/analytics/track.ts | 26 +- cli/src/api/versions.ts | 12 +- cli/src/app/add.ts | 41 +- cli/src/app/todo.ts | 282 - cli/src/build/app-id.ts | 34 - cli/src/build/credentials-command.ts | 13 +- cli/src/build/credentials-manage.ts | 21 +- cli/src/build/ios-provisioning-command.ts | 5 +- cli/src/build/needed.ts | 4 +- cli/src/build/onboarding/android/ui/app.tsx | 66 +- .../onboarding/android/ui/keystore-action.ts | 58 - cli/src/build/onboarding/app-selection.ts | 182 - cli/src/build/onboarding/appflow/flow.ts | 2 +- cli/src/build/onboarding/appflow/types.ts | 2 +- cli/src/build/onboarding/command.ts | 77 +- cli/src/build/onboarding/ios/flow.ts | 31 +- cli/src/build/onboarding/login.ts | 45 - cli/src/build/onboarding/mcp/engine.ts | 4 +- .../build/onboarding/mcp/onboarding-tools.ts | 20 +- cli/src/build/onboarding/project-discovery.ts | 11 +- cli/src/build/onboarding/telemetry.ts | 52 - .../onboarding/ui/app-selection-gate.tsx | 289 - cli/src/build/onboarding/ui/app.tsx | 100 +- cli/src/build/onboarding/ui/appflow-app.tsx | 5 +- cli/src/build/onboarding/ui/components.tsx | 17 +- .../ui/import-distribution-analytics.ts | 30 - .../onboarding/ui/ios-certificate-action.ts | 79 - .../onboarding/ui/ios-credential-action.ts | 57 - cli/src/build/onboarding/ui/login-gate.tsx | 308 - .../build/onboarding/ui/preparation-action.ts | 43 - .../build/onboarding/ui/setup-method-route.ts | 22 - cli/src/build/onboarding/ui/shell.tsx | 105 +- .../prescan/checks/ios-entitlements-checks.ts | 20 +- cli/src/build/prescan/command.ts | 7 +- cli/src/build/prescan/context.ts | 5 +- cli/src/build/prescan/types.ts | 2 - cli/src/build/request.ts | 3 +- cli/src/bundle/upload-config.ts | 54 - cli/src/bundle/upload.ts | 82 +- cli/src/cordova/project.ts | 97 - cli/src/framework/mode.ts | 5 - cli/src/index.ts | 48 +- cli/src/init/browser-login.ts | 44 +- cli/src/mcp/server.ts | 2 - cli/src/mcp/tool-schemas.ts | 2 - cli/src/notify-app-ready-worker.ts | 12 - cli/src/onboarding-worker.ts | 36 - cli/src/onboarding/background-api.ts | 39 - cli/src/onboarding/background-check.ts | 77 - cli/src/onboarding/background-preparation.ts | 57 - cli/src/onboarding/background-shutdown.ts | 73 - cli/src/onboarding/background-workers.ts | 23 - cli/src/onboarding/background.ts | 60 - .../onboarding/notify-app-ready-project.ts | 138 - cli/src/onboarding/notify-app-ready-source.ts | 189 - cli/src/onboarding/updater-installed.ts | 8 - cli/src/recovery/app-id.ts | 12 +- cli/src/schemas/bundle.ts | 2 - cli/src/schemas/sdk.ts | 2 - cli/src/sdk.ts | 1 - cli/src/types/supabase.types.ts | 1 - cli/src/updater-installed-worker.ts | 12 - cli/src/user/account.ts | 4 + cli/src/user/whoami.ts | 51 - cli/src/utils.ts | 18 +- cli/test/init/browser-login.test.ts | 31 +- .../checks-ios-entitlements-config.test.ts | 13 - cli/test/test-analytics.mjs | 12 +- cli/test/test-android-keystore-action.mjs | 200 - cli/test/test-app-add-getting-started.mjs | 103 - cli/test/test-app-todo.mjs | 274 - cli/test/test-auth-session.mjs | 28 - .../test-authenticated-command-invocation.mjs | 7 - cli/test/test-background-check-shutdown.mjs | 184 - cli/test/test-builder-app-id-config.mjs | 206 - cli/test/test-builder-app-selection.mjs | 331 - cli/test/test-builder-login-gate.mjs | 304 - cli/test/test-builder-project-discovery.mjs | 15 - cli/test/test-bundle-pages.test.ts | 72 - cli/test/test-cli-help.mjs | 4 - cli/test/test-cli-user-error-config.mjs | 4 +- cli/test/test-cordova-upload-mode.mjs | 158 - cli/test/test-create-supabase-client.mjs | 16 - cli/test/test-ios-certificate-action.mjs | 146 - cli/test/test-ios-credential-action.mjs | 120 - cli/test/test-ios-tui-routing.mjs | 16 - cli/test/test-notify-app-ready-background.mjs | 753 - cli/test/test-onboarding-telemetry.mjs | 201 +- cli/test/test-posthog-exception.mjs | 3 - cli/test/test-shell-size-gate.mjs | 23 +- cli/test/test-update-prompt.mjs | 23 +- cli/webdocs/account.mdx | 10 +- cli/webdocs/app.mdx | 25 - cli/webdocs/bundle.mdx | 4 +- .../delete_account_verification.html | 19 - .../delete_account_verification.txt | 11 - .../api/email_templates/email_change.html | 1 - .../api/email_templates/email_change.txt | 7 - .../email_changed_notification.html | 7 - .../email_changed_notification.txt | 7 - .../api/email_templates/index.ts | 89 - .../api/email_templates/invite.html | 1 - .../api/email_templates/invite.txt | 9 - .../api/email_templates/magiclink.html | 1 - .../api/email_templates/magiclink.txt | 13 - .../mfa_factor_enrolled_notification.html | 7 - .../mfa_factor_enrolled_notification.txt | 7 - .../mfa_factor_unenrolled_notification.html | 7 - .../mfa_factor_unenrolled_notification.txt | 7 - .../password_changed_notification.html | 7 - .../password_changed_notification.txt | 7 - .../api/email_templates/reauthentication.html | 5 - .../api/email_templates/reauthentication.txt | 5 - .../api/email_templates/recovery.html | 1 - .../api/email_templates/recovery.txt | 9 - .../api/email_templates/signup.html | 1 - .../api/email_templates/signup.txt | 7 - .../api/email_templates/template.d.ts | 9 - cloudflare_workers/api/index.ts | 7 +- cloudflare_workers/api/triggers/send_email.ts | 69 - cloudflare_workers/api/wrangler.jsonc | 3 - deno.lock | 200 +- design-qa.md | 15 - docs/onboarding-checklist-v3.md | 47 - docs/pr-assets/email-verification/before.png | Bin 319966 -> 0 bytes .../email-verification/code-mobile.png | Bin 82590 -> 0 bytes docs/pr-assets/email-verification/code.png | Bin 313672 -> 0 bytes docs/pr-assets/email-verification/send.png | Bin 302658 -> 0 bytes .../onboarding-v3/control-desktop.png | Bin 117693 -> 0 bytes .../onboarding-v3/treatment-dark.png | Bin 161440 -> 0 bytes .../onboarding-v3/treatment-desktop.png | Bin 161384 -> 0 bytes .../onboarding-v3/treatment-mobile.png | Bin 67172 -> 0 bytes .../pr-assets/onboarding-v3/visual-summary.md | 7 - .../3410/after-click-navigates-channel.png | Bin 236507 -> 0 bytes .../3410/after-clickable-popover-console.png | Bin 193002 -> 0 bytes .../3410/after-clickable-popover-panel.png | Bin 12939 -> 0 bytes .../3410/after-clickable-popover-table.png | Bin 126019 -> 0 bytes .../3410/before-title-only-console.png | Bin 161300 -> 0 bytes .../3410/before-title-only-table.png | Bin 50110 -> 0 bytes .../logs-table-action-hover-key-row.webp | Bin 5988 -> 0 bytes .../logs-table-action-name-vs-hover-key.webp | Bin 12062 -> 0 bytes .../logs-table-original-error-row.webp | Bin 5900 -> 0 bytes .../logs-table-original-error.webp | Bin 34134 -> 0 bytes .../pr-3340/01-download-format-section.png | Bin 201822 -> 0 bytes .../pr-3340/01-download-format-section.webp | Bin 314748 -> 0 bytes .../pr-3340/02-download-format-dropdown.png | Bin 105933 -> 0 bytes .../pr-3340/03-download-format-confirm.png | Bin 98817 -> 0 bytes .../pr-3340/04-rollout-percentage-confirm.png | Bin 243468 -> 0 bytes .../04-rollout-percentage-confirm.webp | Bin 72962 -> 0 bytes .../pr-3340/05-rollout-apply-controls.png | Bin 235291 -> 0 bytes .../pr-3340/05-rollout-apply-controls.webp | Bin 87886 -> 0 bytes .../06-rollout-action-buttons-bordered.png | Bin 233457 -> 0 bytes .../06-rollout-action-buttons-bordered.webp | Bin 87872 -> 0 bytes .../pr-3340/07-rollout-rollback-confirm.png | Bin 269145 -> 0 bytes .../pr-3340/07-rollout-rollback-confirm.webp | Bin 84878 -> 0 bytes .../pr-3340/08-rollout-settings-info-icon.png | Bin 233457 -> 0 bytes .../08-rollout-settings-info-icon.webp | Bin 87872 -> 0 bytes .../08-rollout-settings-info-modal.png | Bin 299068 -> 0 bytes .../08-rollout-settings-info-modal.webp | Bin 97372 -> 0 bytes .../pr-3340/09-download-format-info-modal.png | Bin 267887 -> 0 bytes .../09-download-format-info-modal.webp | Bin 308276 -> 0 bytes .../2026-09-21-builder-init-login-gate.md | 160 - .../2026-09-22-builder-init-app-selection.md | 102 - ...2026-09-22-onboarding-todo-batch-checks.md | 134 - ...26-09-21-builder-init-login-gate-design.md | 150 - ...09-22-builder-init-app-selection-design.md | 162 - graphify-out/GRAPH_REPORT.md | 2072 +- graphify-out/graph.json | 57617 ++++++---------- knip.json | 1 - messages/en.context.json | 566 +- messages/en.json | 190 +- package.json | 4 +- .../channel-rollout-ux-screenshots.spec.ts | 154 - playwright/e2e/email-verification.spec.ts | 92 - playwright/e2e/observe-tabs.spec.ts | 49 +- .../e2e/onboarding-builder-setup.spec.ts | 74 - playwright/e2e/onboarding-setup.spec.ts | 683 - playwright/fixtures/email-verification.html | 12 - playwright/fixtures/email-verification.ts | 106 - playwright/fixtures/onboarding-setup.html | 12 - playwright/fixtures/onboarding-setup.ts | 242 - playwright/visual-diff.config.ts | 45 +- private/cli-mcp-tests | 2 +- read_replicate/schema_replicate.catalog.json | 19 +- read_replicate/schema_replicate.sql | 19 +- .../bench/manifest_size_lookup_results.json | 209 - scripts/bench/manifest_size_lookup_summary.md | 384 - .../bench/zod_454_version_bump_comparison.md | 43 - scripts/bench_manifest_size_lookup.ts | 309 - scripts/bench_zod_stable_cpu.ts | 305 - .../manifest_version_hash_lookup_index.sql | 13 - scripts/playwright-frontend-preview.ts | 1 - scripts/visual-diff.ts | 3 - src/auto-imports.d.ts | 2 - src/components.d.ts | 5 - .../AppOnboardingBuilderChecklist.vue | 225 - .../dashboard/AppOnboardingCliSteps.vue | 77 +- .../dashboard/AppOnboardingFlow.vue | 80 +- .../dashboard/AppOnboardingSetupChecklist.vue | 330 - .../dashboard/ChannelCreateOnboarding.vue | 2 - .../ChannelSetupOnboardingDialog.vue | 185 - .../dashboard/GettingStartedNav.vue | 8 +- .../dashboard/OnboardingExploreBanner.vue | 5 +- .../dashboard/OnboardingExploreReminder.vue | 45 - .../dashboard/TechnicalTeammateInviteCard.vue | 45 +- .../tables/BundleChannelsPopover.vue | 112 - src/components/tables/BundleTable.vue | 28 +- src/components/tables/LogMetadataPopover.vue | 120 +- src/components/tables/LogTable.vue | 47 +- src/composables/useAnchorPopover.ts | 139 - .../useAppOnboardingCliProgress.ts | 105 - src/layouts/default.vue | 31 +- src/modules/onboarding-setup.ts | 38 - src/pages/app/[app].channel.[channel].vue | 779 +- src/pages/app/[app].getting-started.vue | 22 +- src/pages/login-cli.vue | 14 +- src/pages/resend_email.vue | 418 +- src/pages/settings/account/index.vue | 1 - src/services/appOnboarding.ts | 2 - src/services/builderOnboardingChecklist.ts | 71 - src/services/bundleLinkedChannels.ts | 32 - src/services/cliAiPrompt.ts | 61 +- src/services/emailOtp.ts | 6 - src/services/logTableDisplay.ts | 28 - src/stores/dialogv2.ts | 57 +- src/types/supabase.types.ts | 11 +- src/utils/appOnboardingChecklist.ts | 17 - src/utils/appOnboardingGuides.ts | 20 - src/utils/channelRolloutConfirmFlows.ts | 331 - src/utils/channelUpdatePackageCopy.ts | 48 - .../confirmConsequentialChannelChange.ts | 50 - src/utils/invites.ts | 3 - src/utils/onboardingChannelAnalytics.ts | 11 - src/utils/onboardingRedirect.ts | 41 - supabase/config.toml | 6 +- supabase/functions/_backend/files/files.ts | 151 +- .../plugin_runtime/utils/manifest_size.ts | 127 +- .../private/invite_new_user_to_org.ts | 4 - .../_backend/private/onboarding_progress.ts | 131 - .../functions/_backend/public/app/post.ts | 3 - supabase/functions/_backend/public/app/put.ts | 141 +- .../functions/_backend/public/bundle/get.ts | 4 +- .../_backend/triggers/cron_app_fame.ts | 83 +- .../triggers/cron_onboarding_refresh_apps.ts | 27 - .../_backend/triggers/queue_consumer.ts | 21 +- .../functions/_backend/triggers/send_email.ts | 51 +- .../functions/_backend/utils/ab_tests.json | 12 - supabase/functions/_backend/utils/ab_tests.ts | 7 +- .../functions/_backend/utils/appOnboarding.ts | 133 +- .../_backend/utils/appOnboardingCompletion.ts | 6 - .../_backend/utils/appOnboardingMutation.ts | 82 - .../_backend/utils/appOnboardingWriteLock.ts | 47 - supabase/functions/_backend/utils/app_fame.ts | 143 +- .../_backend/utils/app_onboarding_login.ts | 66 +- .../_backend/utils/app_onboarding_posthog.ts | 27 +- .../_backend/utils/app_onboarding_refresh.ts | 144 - .../utils/app_onboarding_todo_evidence.ts | 227 - .../utils/app_onboarding_todo_refresh.ts | 118 - .../functions/_backend/utils/auth_email.ts | 33 +- .../functions/_backend/utils/cloudflare.ts | 6 +- .../functions/_backend/utils/manifest_size.ts | 127 +- supabase/functions/_backend/utils/pg.ts | 11 +- supabase/functions/_backend/utils/posthog.ts | 73 +- supabase/functions/_backend/utils/stats.ts | 4 +- .../_backend/utils/supabase.types.ts | 11 +- supabase/functions/_backend/utils/version.ts | 2 +- supabase/functions/deno.json | 2 +- supabase/functions/deno.lock | 12 +- supabase/functions/private/index.ts | 2 - supabase/functions/shared/invite-name.ts | 24 - supabase/functions/triggers/index.ts | 2 - ...0916173249_app_onboarding_todo_list_v3.sql | 264 - ...60919100657_request_actor_email_adress.sql | 20 - ...0919162338_app_onboarding_todo_list_v4.sql | 319 - ...60920164800_backend_onboarding_refresh.sql | 177 - ...60920174512_get_public_builder_metrics.sql | 224 - ...20260922143054_builder_todo_v4_backend.sql | 116 - ...73211_onboarding_queue_request_timeout.sql | 62 - ...039_manifest_version_hash_lookup_index.sql | 74 - supabase/schemas/prod.sql | 707 +- supabase/seed.sql | 3 - tests/ab-tests.unit.test.ts | 92 +- tests/admin-famous-apps.test.ts | 43 +- tests/app-create-ab-assignment.unit.test.ts | 50 - tests/app-fame.unit.test.ts | 91 +- ...app-onboarding-apikey-runtime.unit.test.ts | 2 - tests/app-onboarding-builder-v4.db.test.ts | 138 - tests/app-onboarding-flow.unit.test.ts | 2 +- tests/app-onboarding-mutation.unit.test.ts | 250 - .../app-onboarding-posthog-batch.unit.test.ts | 32 - tests/app-onboarding-posthog.unit.test.ts | 14 - ...boarding-progress-integration.unit.test.ts | 28 +- tests/app-onboarding-progress.test.ts | 32 +- tests/app-onboarding-refresh.unit.test.ts | 24 - .../app-onboarding-todo-evidence.unit.test.ts | 180 - tests/app-onboarding-v3-postgres.test.ts | 160 - tests/app-onboarding-v3.db.test.ts | 146 - tests/app-onboarding.unit.test.ts | 125 +- tests/app-put-onboarding-bento.unit.test.ts | 104 - tests/auth-email.unit.test.ts | 24 +- .../builder-onboarding-checklist.unit.test.ts | 72 - tests/bundle-error-cases.test.ts | 37 - tests/bundle-list-channels.unit.test.ts | 54 - tests/channel-rollout-ui.unit.test.ts | 435 - tests/cli-ai-prompt.unit.test.ts | 57 +- tests/cli-login-page.unit.test.ts | 40 +- tests/cron-onboarding-refresh.test.ts | 181 - tests/cron-onboarding-todo-refresh.test.ts | 102 - tests/email-otp.unit.test.ts | 36 +- tests/files-head-read.unit.test.ts | 294 - tests/invite-name.unit.test.ts | 34 - tests/invites.unit.test.ts | 17 +- tests/log-doc-links.unit.test.ts | 18 - tests/log-table-display.unit.test.ts | 27 +- tests/manifest-size.unit.test.ts | 95 +- ...oarding-ab-tests-worker-route.unit.test.ts | 4 +- tests/onboarding-ab-tests.unit.test.ts | 14 - .../onboarding-channel-analytics.unit.test.ts | 54 +- .../onboarding-progress-endpoint.unit.test.ts | 371 - tests/onboarding-redirect.unit.test.ts | 61 - .../onboarding-setup-navigation.unit.test.ts | 119 - tests/private-invite-new-user-to-org.test.ts | 16 - .../rbac-apikey-request-identity-rpc.test.ts | 54 - tests/resend-email-page.unit.test.ts | 569 - ...security-definer-execute-hardening.test.ts | 15 - tests/send-email-trigger.unit.test.ts | 260 +- tests/tinbase-db-tests.txt | 2 - vite.config.mts | 20 +- vitest.config.ts | 10 - 341 files changed, 23659 insertions(+), 59873 deletions(-) delete mode 100644 SECURITY.md delete mode 100644 cli/SECURITY.md delete mode 100644 cli/src/app/todo.ts delete mode 100644 cli/src/build/app-id.ts delete mode 100644 cli/src/build/onboarding/android/ui/keystore-action.ts delete mode 100644 cli/src/build/onboarding/app-selection.ts delete mode 100644 cli/src/build/onboarding/login.ts delete mode 100644 cli/src/build/onboarding/ui/app-selection-gate.tsx delete mode 100644 cli/src/build/onboarding/ui/import-distribution-analytics.ts delete mode 100644 cli/src/build/onboarding/ui/ios-certificate-action.ts delete mode 100644 cli/src/build/onboarding/ui/ios-credential-action.ts delete mode 100644 cli/src/build/onboarding/ui/login-gate.tsx delete mode 100644 cli/src/build/onboarding/ui/preparation-action.ts delete mode 100644 cli/src/build/onboarding/ui/setup-method-route.ts delete mode 100644 cli/src/bundle/upload-config.ts delete mode 100644 cli/src/cordova/project.ts delete mode 100644 cli/src/framework/mode.ts delete mode 100644 cli/src/notify-app-ready-worker.ts delete mode 100644 cli/src/onboarding-worker.ts delete mode 100644 cli/src/onboarding/background-api.ts delete mode 100644 cli/src/onboarding/background-check.ts delete mode 100644 cli/src/onboarding/background-preparation.ts delete mode 100644 cli/src/onboarding/background-shutdown.ts delete mode 100644 cli/src/onboarding/background-workers.ts delete mode 100644 cli/src/onboarding/background.ts delete mode 100644 cli/src/onboarding/notify-app-ready-project.ts delete mode 100644 cli/src/onboarding/notify-app-ready-source.ts delete mode 100644 cli/src/onboarding/updater-installed.ts delete mode 100644 cli/src/updater-installed-worker.ts delete mode 100644 cli/src/user/whoami.ts delete mode 100644 cli/test/test-android-keystore-action.mjs delete mode 100644 cli/test/test-app-add-getting-started.mjs delete mode 100644 cli/test/test-app-todo.mjs delete mode 100644 cli/test/test-background-check-shutdown.mjs delete mode 100644 cli/test/test-builder-app-id-config.mjs delete mode 100644 cli/test/test-builder-app-selection.mjs delete mode 100644 cli/test/test-builder-login-gate.mjs delete mode 100644 cli/test/test-bundle-pages.test.ts delete mode 100644 cli/test/test-cordova-upload-mode.mjs delete mode 100644 cli/test/test-ios-certificate-action.mjs delete mode 100644 cli/test/test-ios-credential-action.mjs delete mode 100644 cli/test/test-notify-app-ready-background.mjs delete mode 100644 cloudflare_workers/api/email_templates/delete_account_verification.html delete mode 100644 cloudflare_workers/api/email_templates/delete_account_verification.txt delete mode 100644 cloudflare_workers/api/email_templates/email_change.html delete mode 100644 cloudflare_workers/api/email_templates/email_change.txt delete mode 100644 cloudflare_workers/api/email_templates/email_changed_notification.html delete mode 100644 cloudflare_workers/api/email_templates/email_changed_notification.txt delete mode 100644 cloudflare_workers/api/email_templates/index.ts delete mode 100644 cloudflare_workers/api/email_templates/invite.html delete mode 100644 cloudflare_workers/api/email_templates/invite.txt delete mode 100644 cloudflare_workers/api/email_templates/magiclink.html delete mode 100644 cloudflare_workers/api/email_templates/magiclink.txt delete mode 100644 cloudflare_workers/api/email_templates/mfa_factor_enrolled_notification.html delete mode 100644 cloudflare_workers/api/email_templates/mfa_factor_enrolled_notification.txt delete mode 100644 cloudflare_workers/api/email_templates/mfa_factor_unenrolled_notification.html delete mode 100644 cloudflare_workers/api/email_templates/mfa_factor_unenrolled_notification.txt delete mode 100644 cloudflare_workers/api/email_templates/password_changed_notification.html delete mode 100644 cloudflare_workers/api/email_templates/password_changed_notification.txt delete mode 100644 cloudflare_workers/api/email_templates/reauthentication.html delete mode 100644 cloudflare_workers/api/email_templates/reauthentication.txt delete mode 100644 cloudflare_workers/api/email_templates/recovery.html delete mode 100644 cloudflare_workers/api/email_templates/recovery.txt delete mode 100644 cloudflare_workers/api/email_templates/signup.html delete mode 100644 cloudflare_workers/api/email_templates/signup.txt delete mode 100644 cloudflare_workers/api/email_templates/template.d.ts delete mode 100644 cloudflare_workers/api/triggers/send_email.ts delete mode 100644 design-qa.md delete mode 100644 docs/onboarding-checklist-v3.md delete mode 100644 docs/pr-assets/email-verification/before.png delete mode 100644 docs/pr-assets/email-verification/code-mobile.png delete mode 100644 docs/pr-assets/email-verification/code.png delete mode 100644 docs/pr-assets/email-verification/send.png delete mode 100644 docs/pr-assets/onboarding-v3/control-desktop.png delete mode 100644 docs/pr-assets/onboarding-v3/treatment-dark.png delete mode 100644 docs/pr-assets/onboarding-v3/treatment-desktop.png delete mode 100644 docs/pr-assets/onboarding-v3/treatment-mobile.png delete mode 100644 docs/pr-assets/onboarding-v3/visual-summary.md delete mode 100644 docs/pr-screenshots/3410/after-click-navigates-channel.png delete mode 100644 docs/pr-screenshots/3410/after-clickable-popover-console.png delete mode 100644 docs/pr-screenshots/3410/after-clickable-popover-panel.png delete mode 100644 docs/pr-screenshots/3410/after-clickable-popover-table.png delete mode 100644 docs/pr-screenshots/3410/before-title-only-console.png delete mode 100644 docs/pr-screenshots/3410/before-title-only-table.png delete mode 100644 docs/pr-screenshots/logs-table-action-hover-key-row.webp delete mode 100644 docs/pr-screenshots/logs-table-action-name-vs-hover-key.webp delete mode 100644 docs/pr-screenshots/logs-table-original-error-row.webp delete mode 100644 docs/pr-screenshots/logs-table-original-error.webp delete mode 100644 docs/pr-screenshots/pr-3340/01-download-format-section.png delete mode 100644 docs/pr-screenshots/pr-3340/01-download-format-section.webp delete mode 100644 docs/pr-screenshots/pr-3340/02-download-format-dropdown.png delete mode 100644 docs/pr-screenshots/pr-3340/03-download-format-confirm.png delete mode 100644 docs/pr-screenshots/pr-3340/04-rollout-percentage-confirm.png delete mode 100644 docs/pr-screenshots/pr-3340/04-rollout-percentage-confirm.webp delete mode 100644 docs/pr-screenshots/pr-3340/05-rollout-apply-controls.png delete mode 100644 docs/pr-screenshots/pr-3340/05-rollout-apply-controls.webp delete mode 100644 docs/pr-screenshots/pr-3340/06-rollout-action-buttons-bordered.png delete mode 100644 docs/pr-screenshots/pr-3340/06-rollout-action-buttons-bordered.webp delete mode 100644 docs/pr-screenshots/pr-3340/07-rollout-rollback-confirm.png delete mode 100644 docs/pr-screenshots/pr-3340/07-rollout-rollback-confirm.webp delete mode 100644 docs/pr-screenshots/pr-3340/08-rollout-settings-info-icon.png delete mode 100644 docs/pr-screenshots/pr-3340/08-rollout-settings-info-icon.webp delete mode 100644 docs/pr-screenshots/pr-3340/08-rollout-settings-info-modal.png delete mode 100644 docs/pr-screenshots/pr-3340/08-rollout-settings-info-modal.webp delete mode 100644 docs/pr-screenshots/pr-3340/09-download-format-info-modal.png delete mode 100644 docs/pr-screenshots/pr-3340/09-download-format-info-modal.webp delete mode 100644 docs/superpowers/plans/2026-09-21-builder-init-login-gate.md delete mode 100644 docs/superpowers/plans/2026-09-22-builder-init-app-selection.md delete mode 100644 docs/superpowers/plans/2026-09-22-onboarding-todo-batch-checks.md delete mode 100644 docs/superpowers/specs/2026-09-21-builder-init-login-gate-design.md delete mode 100644 docs/superpowers/specs/2026-09-22-builder-init-app-selection-design.md delete mode 100644 playwright/e2e/channel-rollout-ux-screenshots.spec.ts delete mode 100644 playwright/e2e/email-verification.spec.ts delete mode 100644 playwright/e2e/onboarding-builder-setup.spec.ts delete mode 100644 playwright/e2e/onboarding-setup.spec.ts delete mode 100644 playwright/fixtures/email-verification.html delete mode 100644 playwright/fixtures/email-verification.ts delete mode 100644 playwright/fixtures/onboarding-setup.html delete mode 100644 playwright/fixtures/onboarding-setup.ts delete mode 100644 scripts/bench/manifest_size_lookup_results.json delete mode 100644 scripts/bench/manifest_size_lookup_summary.md delete mode 100644 scripts/bench/zod_454_version_bump_comparison.md delete mode 100644 scripts/bench_manifest_size_lookup.ts delete mode 100644 scripts/bench_zod_stable_cpu.ts delete mode 100644 scripts/ops/manifest_version_hash_lookup_index.sql delete mode 100644 src/components/dashboard/AppOnboardingBuilderChecklist.vue delete mode 100644 src/components/dashboard/AppOnboardingSetupChecklist.vue delete mode 100644 src/components/dashboard/ChannelSetupOnboardingDialog.vue delete mode 100644 src/components/dashboard/OnboardingExploreReminder.vue delete mode 100644 src/components/tables/BundleChannelsPopover.vue delete mode 100644 src/composables/useAnchorPopover.ts delete mode 100644 src/composables/useAppOnboardingCliProgress.ts delete mode 100644 src/modules/onboarding-setup.ts delete mode 100644 src/services/builderOnboardingChecklist.ts delete mode 100644 src/utils/appOnboardingChecklist.ts delete mode 100644 src/utils/appOnboardingGuides.ts delete mode 100644 src/utils/channelRolloutConfirmFlows.ts delete mode 100644 src/utils/channelUpdatePackageCopy.ts delete mode 100644 src/utils/confirmConsequentialChannelChange.ts delete mode 100644 supabase/functions/_backend/private/onboarding_progress.ts delete mode 100644 supabase/functions/_backend/triggers/cron_onboarding_refresh_apps.ts delete mode 100644 supabase/functions/_backend/utils/appOnboardingCompletion.ts delete mode 100644 supabase/functions/_backend/utils/appOnboardingMutation.ts delete mode 100644 supabase/functions/_backend/utils/appOnboardingWriteLock.ts delete mode 100644 supabase/functions/_backend/utils/app_onboarding_refresh.ts delete mode 100644 supabase/functions/_backend/utils/app_onboarding_todo_evidence.ts delete mode 100644 supabase/functions/_backend/utils/app_onboarding_todo_refresh.ts delete mode 100644 supabase/functions/shared/invite-name.ts delete mode 100644 supabase/migrations/20260916173249_app_onboarding_todo_list_v3.sql delete mode 100644 supabase/migrations/20260919100657_request_actor_email_adress.sql delete mode 100644 supabase/migrations/20260919162338_app_onboarding_todo_list_v4.sql delete mode 100644 supabase/migrations/20260920164800_backend_onboarding_refresh.sql delete mode 100644 supabase/migrations/20260920174512_get_public_builder_metrics.sql delete mode 100644 supabase/migrations/20260922143054_builder_todo_v4_backend.sql delete mode 100644 supabase/migrations/20260922173211_onboarding_queue_request_timeout.sql delete mode 100644 supabase/migrations/20260923143039_manifest_version_hash_lookup_index.sql delete mode 100644 tests/app-create-ab-assignment.unit.test.ts delete mode 100644 tests/app-onboarding-builder-v4.db.test.ts delete mode 100644 tests/app-onboarding-mutation.unit.test.ts delete mode 100644 tests/app-onboarding-posthog-batch.unit.test.ts delete mode 100644 tests/app-onboarding-refresh.unit.test.ts delete mode 100644 tests/app-onboarding-todo-evidence.unit.test.ts delete mode 100644 tests/app-onboarding-v3-postgres.test.ts delete mode 100644 tests/app-onboarding-v3.db.test.ts delete mode 100644 tests/app-put-onboarding-bento.unit.test.ts delete mode 100644 tests/builder-onboarding-checklist.unit.test.ts delete mode 100644 tests/bundle-list-channels.unit.test.ts delete mode 100644 tests/channel-rollout-ui.unit.test.ts delete mode 100644 tests/cron-onboarding-refresh.test.ts delete mode 100644 tests/cron-onboarding-todo-refresh.test.ts delete mode 100644 tests/files-head-read.unit.test.ts delete mode 100644 tests/invite-name.unit.test.ts delete mode 100644 tests/log-doc-links.unit.test.ts delete mode 100644 tests/onboarding-progress-endpoint.unit.test.ts delete mode 100644 tests/onboarding-setup-navigation.unit.test.ts delete mode 100644 tests/resend-email-page.unit.test.ts diff --git a/.typos.toml b/.typos.toml index 0d1a46ad28..bf2ec9759c 100644 --- a/.typos.toml +++ b/.typos.toml @@ -80,5 +80,4 @@ unparseable = "unparseable" # Valid English synonym of "unparsable". CIPS = "CIPS" # real App Store Connect session role value (ASC key helper) ITMS = "ITMS" # App Store Connect upload error-code prefix (ITMS-90704, ITMS-90474, ...) cited in iOS prescan findings loca = "loca" # localtunnel host suffix *.loca.lt detected by the iOS capacitor-server-url check -adress = "adress" # Preserve the requested request_actor_email_adress RPC name. # Add more project-specific terms as needed diff --git a/BOUNTY.md b/BOUNTY.md index f88f4a5c09..ab08e12408 100644 --- a/BOUNTY.md +++ b/BOUNTY.md @@ -25,6 +25,9 @@ Anyone from the community can review the pull request and leave comments. +Review are rewarded with a tip of $20 when requested on merged pull request. +AI review does not qualify. + ## What is a good review? Check code pattern repetition, and things that can be done better. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 01d6499035..0000000000 --- a/SECURITY.md +++ /dev/null @@ -1,34 +0,0 @@ -# Security - -Thanks for helping keep Capgo safe. - -## Report a vulnerability for this repository - -Do not use Discord, GitHub Issues, or any public forum. - -Open a private advisory here: -https://github.com/Cap-go/capgo.app/security/advisories/new - -Before you file, use the Capgo advisory checklist: -https://github.com/Cap-go/.github/blob/main/ADVISORY_TEMPLATE.md - -## Org policy (canonical) - -Reporting requirements, out-of-scope rules, what happens after you report, embargo, and bounty payout gates live in the Cap-go org security policy: -https://github.com/Cap-go/.github/blob/main/SECURITY.md - -Public researcher pages: -- https://capgo.app/security/ -- https://capgo.app/bug-bounty/ - -## Quick out-of-scope reminders - -Reports in these classes are closed (see org policy and https://capgo.app/security/ for the full list): - -- Unauthenticated `channel_self` set, and designed no-API-key behavior for `/updates` and `/stats` -- Uploader mislabeling encryption on `external_url` bundles -- Duplicates, already-fixed-on-`main` without a new exploit path, incomplete drafts - -## Bounty - -Capgo pays eligible bounties only after the fix is **released** and you have **verified** the fix. Linking or opening a PR alone is not enough. See https://capgo.app/bug-bounty/. diff --git a/bun.lock b/bun.lock index 8943914566..3f46dd4e45 100644 --- a/bun.lock +++ b/bun.lock @@ -206,13 +206,13 @@ "vue-tsc": "3.3.11", "wrangler": "^4.113.0", "yaml": "^2.9.0", - "zod": "4.5.4", + "zod": "^4.4.3", "zod-compiler": "^1.15.0", }, }, "cli": { "name": "@capgo/cli", - "version": "8.53.0", + "version": "8.47.1", "bin": { "capgo": "dist/index.js", }, @@ -235,7 +235,7 @@ "rrweb-snapshot": "^2.1.1", "string-width": "^8.2.2", "typescript": "6.0.3", - "zod": "^4.5.4", + "zod": "^4.4.3", }, "devDependencies": { "@antfu/eslint-config": "^9.2.0", @@ -260,7 +260,6 @@ "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vercel/ncc": "^0.44.1", - "@vue/compiler-dom": "3.5.40", "@xterm/headless": "^6.0.0", "adm-zip": "^0.6.0", "ci-info": "^4.4.0", @@ -286,7 +285,7 @@ }, "packages/capacitor-notifications": { "name": "@capgo/capacitor-notifications", - "version": "0.1.15", + "version": "0.1.13", "devDependencies": { "@capacitor/android": "^8.4.2", "@capacitor/ios": "^8.4.2", @@ -2805,7 +2804,7 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-compiler": ["zod-compiler@1.15.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.16.0", "get-tsconfig": "^4.14.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "picomatch": "^4.0.4", "unplugin": "^3.0.0" }, "peerDependencies": { "@swc/core": ">=1.3.0", "zod": "^4.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "zod-compiler": "dist/cli/index.js" } }, "sha512-wdPJxfow9p4SfCG0hLvnAZOjzseB+FGJViS/6Alc9nAg4dCdDQMcG5kCNdeLeB//wGE0E+r9AEYs1xi/1DZdZg=="], @@ -2839,8 +2838,6 @@ "@capacitor/cli/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], - "@capgo/cli/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], - "@codspeed/core/find-up": ["find-up@6.3.0", "", { "dependencies": { "locate-path": "^7.1.0", "path-exists": "^5.0.0" } }, "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], @@ -2899,12 +2896,6 @@ "@keyv/bigmap/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], - "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], "@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], @@ -3123,8 +3114,6 @@ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "knip/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 4d67934075..59fa874a8c 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -48,13 +48,3 @@ To reduce CI failures, run the relevant local checks after finishing a task. - Do not treat backend E2E as a blocker to add in this repo unless the task specifically requires coordinating with the Capgo repo. This is critical to prevent hardcoded build paths or MCP regressions from reaching customers. - -## Security (do not regress) - -Canonical researcher policy: https://github.com/Cap-go/.github/blob/main/SECURITY.md and https://capgo.app/security/. - -- Do **not** put GHSA ids or unpublished advisory/PoC text in public PRs, issues, or changelogs. -- Zip / bundle extract and write paths: canonicalize and keep writes inside the intended root. Do not follow symlinks out of the target directory. -- Treat project-controlled config (`localApi`, `localSupa`, app/id paths, custom endpoints) as untrusted for filesystem and network side effects. -- Prefer containment checks before delete or overwrite of paths derived from user/project input. -- Report and fix CLI security issues via private advisories: https://github.com/Cap-go/capgo.app/security/advisories/new diff --git a/cli/README.md b/cli/README.md index 3534164619..f0f812cc15 100644 --- a/cli/README.md +++ b/cli/README.md @@ -70,12 +70,6 @@ For an app that is already configured, upload a new bundle with: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production ``` -Cordova projects without `capacitor.config.*`: - -```bash -npx @capgo/cli@latest bundle upload com.example.app --mode cordova --path www --channel production -``` - ## CI Upload Example ```bash @@ -148,7 +142,6 @@ Capgo continues to load the root config while writing only the selected source. - [Add](#app-add) - [Delete](#app-delete) - [List](#app-list) - - [Todo](#app-todo) - [Debug](#app-debug) - [Setting](#app-setting) - [Set](#app-set) @@ -163,7 +156,7 @@ Capgo continues to load the root config while writing only the selected source. - [Create](#key-create) - [Delete_old](#key-delete_old) - 👤 [Account](#account) - - [Whoami](#account-whoami) + - [Id](#account-id) - 🔹 [Organization](#organization) - [List](#organization-list) - [Add](#organization-add) @@ -391,7 +384,6 @@ npx @capgo/cli@latest bundle upload Version must be > 0.0.0 and unique. Deleted versions cannot be reused for security. External option: Store only a URL link (useful for apps >200MB or privacy requirements). Capgo never inspects external content. Add encryption for trustless security. -Cordova example: npx @capgo/cli@latest bundle upload com.example.app --mode cordova --path www --channel production **Example:** @@ -404,8 +396,7 @@ npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel prod | Param | Type | Description | | -------------- | ------------- | -------------------- | | **-a** | string | API key to link to your account | -| **--mode** | string | Project framework mode. Use cordova for Cordova apps without capacitor.config (webDir defaults to www) | -| **-p** | string | Path of the folder to upload, if not provided it will use the webDir set in capacitor.config (or www with --mode cordova) | +| **-p** | string | Path of the folder to upload, if not provided it will use the webDir set in capacitor.config | | **-c** | string | Channel to link to. Use commas for multiple channels, for example production,beta | | **--rollout** | string | Set the uploaded bundle as this channel's rollout target at a percentage from 0 to 100 | | **--rollout-percentage-bps** | string | Set the uploaded bundle rollout percentage in basis points from 0 to 10000 | @@ -752,31 +743,6 @@ npx @capgo/cli@latest app list | **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) | | **--supa-anon** | string | Custom Supabase anon key (for self-hosting) | -### 🔹 **Todo** - -**Alias:** `todoList` - -```bash -npx @capgo/cli@latest app todo -``` - -📋 Show your app's onboarding todo list with done, skipped, and pending tasks. -Uses the same live progress checks as the Capgo dashboard. The app ID can be inferred from your Capacitor project. - -**Example:** - -```bash -npx @capgo/cli@latest app todo com.example.app -``` - -**Options:** - -| Param | Type | Description | -| -------------- | ------------- | -------------------- | -| **-a** | string | API key to link to your account | -| **--supa-host** | string | Custom Supabase host URL (for self-hosting or Capgo development) | -| **--supa-anon** | string | Custom Supabase anon key (for self-hosting) | - ### 🐞 **Debug** ```bash @@ -1119,20 +1085,18 @@ npx @capgo/cli@latest key delete_old 👤 Manage your Capgo account details and retrieve information for support or collaboration. -### 🔹 **Whoami** - -**Alias:** `id` +### 🔹 **Id** ```bash -npx @capgo/cli@latest account whoami +npx @capgo/cli@latest account id ``` -🪪 Retrieve your account ID and email address. +🪪 Retrieve your account ID, safe to share for collaboration or support purposes in Discord or other platforms. **Example:** ```bash -npx @capgo/cli@latest account whoami +npx @capgo/cli@latest account id ``` **Options:** diff --git a/cli/SECURITY.md b/cli/SECURITY.md deleted file mode 100644 index 4502a2d89f..0000000000 --- a/cli/SECURITY.md +++ /dev/null @@ -1,38 +0,0 @@ -# Security - -Thanks for helping keep Capgo and `@capgo/cli` safe. - -The live CLI source lives in this monorepo under `cli/` (`@capgo/cli` on npm). The standalone `Cap-go/CLI` repository is archived. - -## Report a vulnerability for this repository - -Do not use Discord, GitHub Issues, or any public forum. - -Open a private advisory on the live monorepo (preferred): -https://github.com/Cap-go/capgo.app/security/advisories/new - -Before you file, follow the Capgo reporting checklist in the org security policy: -https://github.com/Cap-go/.github/blob/main/SECURITY.md - -## Org policy (canonical) - -Reporting requirements, out-of-scope rules, what happens after you report, embargo, and bounty payout gates live in the Cap-go org security policy: -https://github.com/Cap-go/.github/blob/main/SECURITY.md - -Public researcher pages: -- https://capgo.app/security/ -- https://capgo.app/bug-bounty/ - -Paid open-source bounty eligibility is listed on https://capgo.app/bug-bounty/ (primarily the Capgo landing/website and `@capgo/capacitor-updater`). Always report CLI security issues privately here even when a cash bounty does not apply. - -## Quick out-of-scope reminders - -Reports in these classes are closed (see org policy and https://capgo.app/security/ for the full list): - -- Unauthenticated `channel_self` set, and designed no-API-key behavior for `/updates` and `/stats` -- Uploader mislabeling encryption on `external_url` bundles -- Duplicates, already-fixed-on-`main` without a new exploit path, incomplete drafts - -## Bounty - -When a bounty applies, Capgo pays only after the fix is **released** and you have **verified** the fix. Linking or opening a PR alone is not enough. See https://capgo.app/bug-bounty/. diff --git a/cli/build.mjs b/cli/build.mjs index 3f20f11b9f..a90cec6ce9 100644 --- a/cli/build.mjs +++ b/cli/build.mjs @@ -311,10 +311,10 @@ const fixCapacitorCliDirname = { // Build CLI const buildCLI = Bun.build({ - entrypoints: ['src/index.ts', 'src/onboarding-worker.ts', 'src/notify-app-ready-worker.ts', 'src/updater-installed-worker.ts'], + entrypoints: ['src/index.ts'], target: 'node', outdir: 'dist', - external: [...EXTERNAL_PACKAGES, 'typescript'], + external: EXTERNAL_PACKAGES, sourcemap: env.NODE_ENV === 'development' ? 'linked' : 'none', minify: true, // Keep env access runtime-only unless explicitly defined below. diff --git a/cli/package.json b/cli/package.json index 259c38ce20..ee0fb4d684 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@capgo/cli", "type": "module", - "version": "8.64.1", + "version": "8.51.2", "description": "A CLI to upload to capgo servers", "author": "Martin martin@capgo.app", "license": "Apache 2.0", @@ -64,7 +64,6 @@ "lint:fix": "oxlint --config ../.oxlintrc.json --fix src", "check-posix-paths": "node test/check-posix-paths.js", "generate-docs": "node dist/index.js generate-docs README.md", - "test:notify-app-ready-background": "bun test ./test/test-notify-app-ready-background.mjs ./test/test-background-check-shutdown.mjs", "test:bundle": "bun test/test-bundle.mjs", "test:bundle-validation": "bun test test/bundle/", "test:prescan": "bun test test/prescan/", @@ -90,7 +89,6 @@ "test:ci-prompts": "bun test/test-ci-prompts.mjs", "test:ci-secrets": "bun test/test-ci-secrets.mjs", "test:android-onboarding-progress": "bun test/test-android-onboarding-progress.mjs", - "test:android-keystore-action": "bun test/test-android-keystore-action.mjs", "test:onboarding-telemetry": "bun test/test-onboarding-telemetry.mjs", "test:v2-event-migration": "bun test/test-v2-event-migration.mjs", "test:analytics": "bun test/test-analytics.mjs", @@ -101,7 +99,6 @@ "test:analytics-org-resolver": "bun test/test-analytics-org-resolver.mjs", "test:supabase-perf": "bun test/test-supabase-perf.mjs", "test:preview-qr": "bun test/test-preview-qr.mjs", - "test:bundle-pages": "bun test test/test-bundle-pages.test.ts", "test:mcp-analytics": "bun test/test-mcp-analytics.mjs", "test:mcp-instructions": "bun test/test-mcp-instructions.mjs", "test:mcp-stdout-guard": "bun test/test-mcp-stdout-guard.mjs", @@ -116,9 +113,7 @@ "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-add-getting-started": "bun test/test-app-add-getting-started.mjs", "test:app-list-output-text": "bun test/test-app-list-output-text.mjs", - "test:app-todo": "bun test/test-app-todo.mjs", "test:doctor-analytics": "bun test/test-doctor-analytics.mjs", "test:posthog-exception": "bun test/test-posthog-exception.mjs", "test:cli-recovery": "bun test/test-cli-recovery.mjs", @@ -135,12 +130,10 @@ "test:init-replay": "bun test/test-init-replay.mjs", "test:init-telemetry": "bun test/test-init-telemetry.mjs", "test:capacitor-config-target": "bun test/test-capacitor-config-target.mjs", - "test:builder-app-id-config": "bun test/test-builder-app-id-config.mjs", "test:capacitor-config-typescript7": "bun test/test-capacitor-config-typescript7.mjs", "test:capacitor-config-native-import": "bun test/test-capacitor-config-native-import.mjs", "test:cli-user-error-config": "bun test/test-cli-user-error-config.mjs", - "test:cordova-upload-mode": "bun test/test-cordova-upload-mode.mjs", - "test:init-monorepo-targeting": "bun run test:capacitor-config-target && bun run test:builder-app-id-config && bun run test:capacitor-config-typescript7 && bun run test:capacitor-config-native-import && bun run test:cli-user-error-config && bun test/test-init-monorepo-targeting.mjs", + "test:init-monorepo-targeting": "bun run test:capacitor-config-target && bun run test:capacitor-config-typescript7 && bun run test:capacitor-config-native-import && bun run test:cli-user-error-config && bun test/test-init-monorepo-targeting.mjs", "test:prompt-preferences": "bun test/test-prompt-preferences.mjs", "test:esm-sdk": "node test/test-sdk-esm.mjs", "test:auth-session": "bun test/test-auth-session.mjs", @@ -170,8 +163,6 @@ "test:frame-fit-ios-shared": "bun test/test-frame-fit-ios-shared.mjs", "test:ios-confirm-app-id": "bun test/test-ios-confirm-app-id.mjs", "test:ios-create-new": "bun test/test-ios-create-new.mjs", - "test:ios-certificate-action": "bun test/test-ios-certificate-action.mjs", - "test:ios-credential-action": "bun test/test-ios-credential-action.mjs", "test:ios-e2e": "bun test/test-ios-e2e.mjs", "test:ios-flow-contract": "bun test/test-ios-flow-contract.mjs", "test:ios-import-discovery": "bun test/test-ios-import-discovery.mjs", @@ -190,11 +181,9 @@ "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:notify-app-ready-background && 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:cordova-upload-mode && 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:ios-provisioning-map && bun run test:ios-provisioning-command && 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:cli-help && bun run test:build-cache-payload && bun run test:build-cancellation && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:android-keystore-action && 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:bundle-pages && 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-add-getting-started && bun run test:app-list-output-text && bun run test:app-todo && 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:builder-app-selection && bun run test:builder-login-gate && 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-list && bun run test:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-upload-recovery && 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-certificate-action && bun run test:ios-credential-action && 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-gcp && 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:ios-provisioning-map && bun run test:ios-provisioning-command && 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:cli-help && bun run test:build-cache-payload && 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-list && bun run test:channel-add-exists && bun run test:wait-log && bun run test:init-guardrails && bun run test:init-upload-recovery && 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-gcp && 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:builder-app-selection": "bun test/test-builder-app-selection.mjs", - "test:builder-login-gate": "bun test/test-builder-login-gate.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", "test:cicd-failure-help": "bun test/test-cicd-failure-help.mjs", @@ -265,7 +254,7 @@ "rrweb-snapshot": "^2.1.1", "string-width": "^8.2.2", "typescript": "6.0.3", - "zod": "^4.5.4" + "zod": "^4.4.3" }, "optionalDependencies": { "@capgo/cli-helper-darwin-arm64": "^1.1.1", @@ -295,7 +284,6 @@ "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vercel/ncc": "^0.44.1", - "@vue/compiler-dom": "3.5.40", "@xterm/headless": "^6.0.0", "adm-zip": "^0.6.0", "ci-info": "^4.4.0", diff --git a/cli/skills/organization-management/SKILL.md b/cli/skills/organization-management/SKILL.md index ef4db20535..ebd0470895 100644 --- a/cli/skills/organization-management/SKILL.md +++ b/cli/skills/organization-management/SKILL.md @@ -9,11 +9,10 @@ Use this skill for account and organization administration commands. ## Account command -### `account whoami` +### `account id` -- Example: `npx @capgo/cli@latest account whoami` -- Alias: `account id`. -- Displays the account ID and email associated with the API key. +- Example: `npx @capgo/cli@latest account id` +- Use to retrieve an account ID that is safe to share for collaboration or support. - Key option: - `-a, --apikey ` diff --git a/cli/skills/release-management/SKILL.md b/cli/skills/release-management/SKILL.md index 9a4a0456b3..33c9590e08 100644 --- a/cli/skills/release-management/SKILL.md +++ b/cli/skills/release-management/SKILL.md @@ -38,7 +38,6 @@ Use this skill for OTA update workflows in Capgo Cloud. - Alias: `u` - Example: `npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production,beta` -- Cordova example: `npx @capgo/cli@latest bundle upload com.example.app --mode cordova --path www --channel production` - Progressive rollout example: `npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production --rollout 10` - Advance an existing rollout: `npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production --rollout-advance` - Key behavior: @@ -53,7 +52,6 @@ Use this skill for OTA update workflows in Capgo Cloud. - Use `--qr-preview` to print a terminal QR code for the uploaded bundle after a successful upload. App preview must be enabled first. - Use `--send-update-notification` to queue native update-check notifications for channels whose linked bundle changed. Native notifications and push update notifications must be enabled for the app. - Important options: - - `--mode ` (`cordova` for Cordova apps without `capacitor.config`; webDir defaults to `www`) - `-p, --path ` - `-c, --channel ` - `--rollout ` diff --git a/cli/skills/usage/SKILL.md b/cli/skills/usage/SKILL.md index c5cc8bf3c2..cc4a8d5f73 100644 --- a/cli/skills/usage/SKILL.md +++ b/cli/skills/usage/SKILL.md @@ -19,13 +19,6 @@ TanStack Intent skills should stay focused and under the validator line limit, s - Prefer `npx @capgo/cli@latest ...` in user-facing examples in this repo. - Many commands can infer `appId` and related config from the current Capacitor project. -- Commands inside an identifiable Capacitor project can automatically complete the Add Integration Code onboarding task when a source call to `CapacitorUpdater.notifyAppReady()` is detected. Detection is best effort and may be abandoned when the command exits. It does not confirm runtime readiness. -- A separate background check can complete Install Updater Plugin when `@capgo/capacitor-updater` is declared in the selected app's package.json and installed locally, including hoisted or symlinked workspace dependencies. Missing dependencies leave existing progress untouched; detection may be abandoned when the command exits. -- After supported interactive app, bundle, channel, organization, and key commands, plus `build request`, `login`, `doctor`, and `get-qr`, finish, pending background checks share a wait of up to five seconds. The CLI prints a waiting message and can exit sooner after the checks finish; pressing Ctrl-C during the wait exits immediately. JSON, output-text, quiet, CI, piped, `init`, `build init`, and MCP runs do not add this wait or message. `account whoami` (and its `account id` alias) and `bundle releaseType` also exit without waiting. -- With analytics enabled, source scans emit `scan_started` and `scan_ended` events in the `notify-app-ready` channel with a shared `attempt_id`. The ended event includes scan duration, result, and todo-report outcome. An abandoned scan may have no ended event. -- With analytics enabled, displaying the waiting message emits `background_checks_wait_started` in the `cli-usage` channel. Its event properties include the command path, pending check count, grace period, and pending `scan_attempt_ids`. Telemetry shares the five-second wait budget and respects `CAPGO_DISABLE_TELEMETRY` and `CAPGO_DISABLE_POSTHOG`. -- Updater installation checks use the same scan events and attempt pairing in the `updater-installed` channel, with a separate attempt ID from the source scan. Telemetry opt-out does not prevent either onboarding check. -- Background onboarding requests trust the default Capgo API origin. For a custom API, explicitly select the self-host with `--supa-host` and `--supa-anon` where supported, or list trusted URL origins (including scheme and port) in `CAPGO_TRUSTED_API_ORIGINS`, separated by commas. Remote hosts require HTTPS; HTTP is permitted only for trusted loopback origins. Untrusted destinations and redirects are skipped without sending credentials to another host. - Shared public flags commonly include `-a, --apikey ` and `--verbose` on commands that support verbose output. - `--capacitor-config ` is a global option for dynamic monorepos: Capacitor still loads the active root config, while config-writing commands update the selected app-specific source file. On `mcp`, the target remains active for the server lifetime so config-writing MCP tools use the same source. @@ -47,7 +40,6 @@ TanStack Intent skills should stay focused and under the validator line limit, s - `app add [appId]`: create an app in Capgo Cloud. - `app list`: list apps under the current account. Pass `--filter-by-org-id ` to list only apps from one organization, `--show-org` to include organization names, and `--show-org-id` to include organization IDs. The CLI warns that the filter can hide other accessible apps. Use `npx @capgo/cli@latest app list --output-text` for plain status text with an embedded CSV app table and no interactive terminal formatting. - `app delete [appId]`: remove an app. -- `app todo [appId]` (alias: `app todoList`): show the versioned onboarding checklist with done, skipped, and pending tasks, using the same live progress checks as the dashboard. Supports legacy v3 flat steps and v4 OTA steps under `setup.steps.ota` when `setup.ota_todo_list_version` is the string `"1"`. Skipped tasks count toward completed progress. Live checks can refresh saved OTA milestones; a warning means saved progress is shown for checks that failed. Requires `app.read`; additional checks depend on the key's read permissions. Omit the app ID to infer it from the current Capacitor project. Example: `npx @capgo/cli@latest app todo com.example.app`. Supports `-a, --apikey`, `--supa-host`, and `--supa-anon`. - `app set [appId]`: update app settings such as name, icon, retention, metadata exposure, and preview access with `--preview` or `--no-preview`. - `app setting [path]`: update Capacitor config values programmatically. - `app debug [appId]`: listen for live-update debug events, optionally for one device. @@ -96,7 +88,7 @@ Load `skills/native-builds/SKILL.md` when working with: Load `skills/organization-management/SKILL.md` when working with: -- `account whoami` (alias: `account id`) +- `account id` - `organization list`, `organization add`, `organization members`, `organization set`, `organization delete` - deprecated `organisation` aliases diff --git a/cli/src/analytics/track.ts b/cli/src/analytics/track.ts index 89ea572166..5e91c5d417 100644 --- a/cli/src/analytics/track.ts +++ b/cli/src/analytics/track.ts @@ -3,7 +3,6 @@ import { env } from 'node:process' import pack from '../../package.json' import { isTruthyEnvValue } from '../posthog' import { findSavedKeySilent, getAppId, getConfig, sendEvent } from '../utils' -import { getBuilderAppId } from '../build/app-id' import { resolveOwnerOrgId } from './org-resolver' import { categorizeCliError, categorizeHttpStatus } from './error-category' import { deriveSupabaseOperation, setSupabaseCallRecorder, SLOW_THRESHOLD_MS, withSupabaseSource } from './supabase-perf' @@ -52,20 +51,14 @@ export async function flushAnalytics(timeoutMs = 2000): Promise { // Keyed by apikey so events for different accounts never reuse another's org. const cachedContextByApiKey = new Map>() -export function isBuilderInvocation(commandPath: string): boolean { - return commandPath === 'build' || commandPath.startsWith('build ') - || /^mcp:(?:capgo_builder_|start_capgo_builder_|start_capgo_build$|capgo_build_|cancel_capgo_build$)/u.test(commandPath) -} - -export function resolveTrackingContext(apikey: string, signal?: AbortSignal, builder = false): Promise<{ appId?: string, orgId?: string }> { - const cacheKey = `${apikey}\0${builder ? 'builder' : 'default'}` - const cached = cachedContextByApiKey.get(cacheKey) +export function resolveTrackingContext(apikey: string, signal?: AbortSignal): Promise<{ appId?: string, orgId?: string }> { + const cached = cachedContextByApiKey.get(apikey) if (cached) return cached const promise = (async () => { try { const extConfig = await getConfig(true).catch(() => undefined) - const appId = (builder ? getBuilderAppId(undefined, extConfig?.config) : getAppId('', extConfig?.config)) || undefined + const appId = getAppId('', extConfig?.config) || undefined if (!appId) return {} const orgId = await resolveOwnerOrgId(apikey, appId, {}, signal) @@ -75,7 +68,7 @@ export function resolveTrackingContext(apikey: string, signal?: AbortSignal, bui return {} } })() - cachedContextByApiKey.set(cacheKey, promise) + cachedContextByApiKey.set(apikey, promise) return promise } @@ -88,9 +81,7 @@ export interface TrackEventInput { appId?: string /** Explicit key; falls back to the saved key. No key => no event. */ apikey?: string - timestamp?: Date tags?: Record - nonPersonTags?: Record } /** @@ -117,8 +108,7 @@ export function trackEvent(input: TrackEventInput): Promise { let appId = input.appId let orgId = input.orgId if (appId === undefined && orgId === undefined) { - const commandPath = mcpCommandPathStore.getStore() ?? (typeof input.tags?.command_path === 'string' ? input.tags.command_path : currentCommandPath) - const ctx = await resolveTrackingContext(apikey, controller.signal, isBuilderInvocation(commandPath)) + const ctx = await resolveTrackingContext(apikey, controller.signal) appId = ctx.appId orgId = ctx.orgId } @@ -132,10 +122,8 @@ export function trackEvent(input: TrackEventInput): Promise { channel: input.channel, event: input.event, tracking_version: 2, - ...(input.timestamp ? { timestamp: input.timestamp } : {}), ...(orgId ? { org_id: orgId } : {}), tags, - ...(input.nonPersonTags ? { nonPersonTags: input.nonPersonTags } : {}), }, false, controller.signal).catch(() => {}) } catch { @@ -209,10 +197,10 @@ function emitCommandInvoked(commandPath: string, ctx: CommandContext, apikey?: s }) } -export function trackCommandInvoked(commandPath: string, ctx: CommandContext, apikey?: string): void { +export function trackCommandInvoked(commandPath: string, ctx: CommandContext): void { commandStartedAt = Date.now() currentCommandPath = commandPath - emitCommandInvoked(commandPath, ctx, apikey) + emitCommandInvoked(commandPath, ctx) } export function deferCommandInvocation(commandPath: string, ctx: CommandContext): void { diff --git a/cli/src/api/versions.ts b/cli/src/api/versions.ts index 1c4c848cc2..965f22798c 100644 --- a/cli/src/api/versions.ts +++ b/cli/src/api/versions.ts @@ -10,8 +10,6 @@ interface VersionOptions { apikey?: string supaHost?: string supaAnon?: string - /** Injectable for unit tests; defaults to invokeCapgoCliApi. */ - invoke?: typeof invokeCapgoCliApi } interface DeleteSpecificVersionOptions extends VersionOptions { @@ -32,13 +30,12 @@ async function isEmptyBundleListError(error: unknown) { return payload?.error === 'cannot_get_bundle' && payload?.message === 'Cannot get bundle' } -async function fetchBundlePages(appid: string, options: CapgoHttpOptions & Pick) { - const invoke = options.invoke ?? invokeCapgoCliApi +async function fetchBundlePages(appid: string, options: CapgoHttpOptions) { const all: Database['public']['Tables']['app_versions']['Row'][] = [] let page = 0 while (true) { const params = new URLSearchParams({ app_id: appid, page: String(page) }) - const { data, error } = await invoke( + const { data, error } = await invokeCapgoCliApi( `bundle?${params.toString()}`, { apikey: options.apikey, @@ -49,8 +46,8 @@ async function fetchBundlePages(appid: string, options: CapgoHttpOptions & Pick< }, ) if (error) { - if (await isEmptyBundleListError(error)) - return all + if (page === 0 && await isEmptyBundleListError(error)) + return [] throw error } const batch = Array.isArray(data) ? data : [] @@ -169,7 +166,6 @@ export async function getActiveAppVersions( silent, supaHost: options.supaHost, supaAnon: options.supaAnon, - invoke: options.invoke, }) } catch (vError) { diff --git a/cli/src/app/add.ts b/cli/src/app/add.ts index 5ee5ac897d..b0bb9dabc1 100644 --- a/cli/src/app/add.ts +++ b/cli/src/app/add.ts @@ -18,10 +18,8 @@ import { formatError, getAppId, getCapgoCliHttpStatus, - defaultHostWeb, getConfig, getContentType, - getLocalConfig, getOrganizationWithPermission, invokeCapgoCliApi, resolveCapgoPublicApiHost, @@ -29,33 +27,6 @@ import { sendEvent, } from '../utils' -function normalizeConsoleHost(hostWeb: string): string { - return hostWeb.endsWith('/') ? hostWeb.slice(0, -1) : hostWeb -} - -export function appGettingStartedUrl(appId: string, hostWeb = defaultHostWeb): string { - return `${normalizeConsoleHost(hostWeb)}/app/${appId}/getting-started` -} - -export function formatAppGettingStartedMessage(appId: string, hostWeb = defaultHostWeb): string { - return `Continue setup at ${appGettingStartedUrl(appId, hostWeb)}` -} - -export function shouldPrintAppGettingStartedUrl(hostWeb: string, usesCustomSupabase: boolean): boolean { - return !usesCustomSupabase || normalizeConsoleHost(hostWeb) !== defaultHostWeb -} - -export async function resolveAppGettingStartedMessage( - appId: string, - options: { supaHost?: string, supaAnon?: string } = {}, -): Promise { - const localConfig = await getLocalConfig(true) - const usesCustomSupabase = Boolean(options.supaHost || localConfig.supaHost) - if (!shouldPrintAppGettingStartedUrl(localConfig.hostWeb, usesCustomSupabase)) - return null - return formatAppGettingStartedMessage(appId, localConfig.hostWeb) -} - export const reverseDomainRegex = /^[a-z0-9]+(\.[\w-]+)+$/i function ensureOptions(appId: string, options: AppOptions, silent: boolean) { @@ -442,7 +413,7 @@ export async function addAppInternal( const message = formatError(ownershipError) if (!silent) log.error(`Could not add app ${message}`) - throw new CliUserError(`Could not add app ${message}`) + throw new Error(`Could not add app ${message}`) } if (duplicateOutcome === 'duplicate_owned') { @@ -452,13 +423,13 @@ export async function addAppInternal( const takenMessage = `App ID ${appId} already exists` if (!silent) log.error(`Could not add app: ${takenMessage}`) - throw new CliUserError(`Could not add app: ${takenMessage}`) + throw new Error(`Could not add app: ${takenMessage}`) } else { const message = formatError(error) if (!silent) log.error(`Could not add app ${message}`) - throw new CliUserError(`Could not add app ${message}`) + throw new Error(`Could not add app ${message}`) } } @@ -487,12 +458,8 @@ export async function addAppInternal( if (!silent) { if (appAlreadyExists) log.success(`App ${appId} already exists in Capgo`) - else { + else log.success(`App ${appId} added to Capgo`) - const gettingStartedMessage = await resolveAppGettingStartedMessage(appId, options) - if (gettingStartedMessage) - log.info(gettingStartedMessage) - } 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 ✅') diff --git a/cli/src/app/todo.ts b/cli/src/app/todo.ts deleted file mode 100644 index d9d37cf242..0000000000 --- a/cli/src/app/todo.ts +++ /dev/null @@ -1,282 +0,0 @@ -import type { OptionsBase } from '../schemas/base' -import { env, stdin, stdout } from 'node:process' -import { intro, log, outro, spinner } from '@clack/prompts' -import { check2FAComplianceForApp } from '../api/app' -import { getPendingOnboardingChecks } from '../onboarding/background-workers' -import { CliUserError } from '../shared/cli-user-error' -import { createSupabaseClient, findSavedKey, formatCapgoCliInvokeError, getAppId, getCapgoCliHttpStatus, getConfig, invokeCapgoCliApi } from '../utils' - -const V2_STEP_IDS = [ - 'login_cli_mcp', 'add_channel', 'add_updater', 'add_code', 'add_encryption', - 'select_platform', 'build_project', 'run_device', 'add_code_change', - 'upload_bundle', 'test_update', 'completion', -] as const - -const V3_STEP_IDS = [ - 'login_cli_mcp', 'add_channel', 'add_updater', 'add_code', - 'run_device', 'upload_bundle', 'test_update', -] as const - -const STEP_TITLES = { - add_app: 'Add your app', - login_cli_mcp: 'Log in to the Capgo CLI/MCP', - add_channel: 'Create a channel', - add_updater: 'Install updater plugin', - add_code: 'Add integration code', - add_encryption: 'Setup encryption', - select_platform: 'Select platform', - build_project: 'Build your project', - run_device: 'Run on device', - add_code_change: 'Make a test change', - upload_bundle: 'Upload bundle', - test_update: 'Test update on device', - completion: 'Completion', -} - -const V3_STEP_TITLES: Record = { - login_cli_mcp: 'Start guided setup', - add_channel: 'Create a channel', - add_updater: 'Install Capgo Updater', - add_code: 'Add the app-ready code', - run_device: 'Run your app on a device', - upload_bundle: 'Publish your first update', - test_update: 'Deliver an update to a device', -} - -const V3_NEXT_STEP_HELP: Record = { - login_cli_mcp: { - action: 'Run a Capgo CLI command or start the MCP guided setup as the app creator.', - doneWhen: 'Capgo records that CLI or MCP activity for the app creator.', - }, - add_channel: { - action: 'Create a channel for this app to receive live updates.', - doneWhen: 'Capgo finds a channel for this app.', - }, - add_updater: { - action: 'Install @capgo/capacitor-updater in your app project.', - doneWhen: 'The CLI finds the dependency declared and installed, then reports it to Capgo.', - }, - add_code: { - action: 'Call CapacitorUpdater.notifyAppReady() once your app is ready after an update.', - doneWhen: 'The CLI finds that call in your app source and reports it to Capgo.', - }, - run_device: { - action: 'Build and open the app on a device or simulator with Capgo installed.', - doneWhen: 'Capgo sees a device connect to this app.', - }, - upload_bundle: { - action: 'Build and upload your first live update bundle.', - doneWhen: 'Capgo finds a published bundle for this app.', - }, - test_update: { - action: 'Assign the update to a channel, then reopen the app on a device.', - doneWhen: 'Capgo records a device applying an uploaded version.', - }, -} - -export interface AppTodoProgress { - onboarding: unknown - hasChannel?: boolean - checkErrors?: string[] -} - -export const TODO_BACKGROUND_WAIT_MS = 10_000 - -export async function waitForTodoBackgroundChecks( - checks: readonly Promise[], - onCountdown?: (remainingSeconds: number) => void, - timeoutMs = TODO_BACKGROUND_WAIT_MS, -): Promise { - if (!checks.length) - return true - - const deadline = Date.now() + timeoutMs - let timer: ReturnType | undefined - let countdown: ReturnType | undefined - let remaining = Math.ceil(timeoutMs / 1_000) - onCountdown?.(remaining) - if (onCountdown) { - countdown = setInterval(() => { - const next = Math.max(1, Math.ceil((deadline - Date.now()) / 1_000)) - if (next !== remaining) { - remaining = next - onCountdown(next) - } - }, 1_000) - } - - try { - return await Promise.race([ - Promise.allSettled(checks).then(() => true), - new Promise((resolve) => { - // Keep the process alive while the background workers remain unreferenced. - timer = setTimeout(() => resolve(false), timeoutMs) - }), - ]) - } - finally { - if (timer) - clearTimeout(timer) - if (countdown) - clearInterval(countdown) - } -} - -function asRecord(value: unknown): Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : {} -} - -export function getAppTodoSteps(progress: AppTodoProgress) { - const raw = asRecord(progress.onboarding) - const setup = raw.setup !== null && typeof raw.setup === 'object' && !Array.isArray(raw.setup) - ? asRecord(raw.setup) - : raw - const version = typeof setup.todo_list_version === 'number' && Number.isSafeInteger(setup.todo_list_version) && setup.todo_list_version > 0 - ? setup.todo_list_version - : 2 - const otaV1 = version === 4 && setup.ota_todo_list_version === '1' - // TODO(2027-03-19): Remove v3 flat-step compatibility after existing apps migrate. - const ids: readonly (keyof typeof STEP_TITLES)[] = version === 3 || otaV1 - ? V3_STEP_IDS - : version === 4 ? [] : version === 1 ? ['add_app', ...V2_STEP_IDS.slice(1)] : V2_STEP_IDS - const reportedSteps = otaV1 ? asRecord(asRecord(setup.steps).ota) : asRecord(setup.steps) - const steps = ids.map((id) => { - const reportedStatus = asRecord(reportedSteps[id]).status - let status: 'done' | 'skipped' | 'pending' = reportedStatus === 'done' || reportedStatus === 'skipped' ? reportedStatus : 'pending' - // Match the frontend's live channel override, including deleted channels. - if (id === 'add_channel' && typeof progress.hasChannel === 'boolean') - status = progress.hasChannel ? 'done' : 'pending' - const title = version === 3 || otaV1 ? V3_STEP_TITLES[id as keyof typeof V3_STEP_TITLES] : STEP_TITLES[id] - return { id, title, status } - }) - return { version, steps } -} - -export function formatAppTodoList(appId: string, progress: AppTodoProgress, options: { color?: boolean } = {}): string { - const { version, steps } = getAppTodoSteps(progress) - if (version === 4 && steps.length === 0) - return `App: ${appId} — Todo list v4\nThis CLI does not support this OTA checklist version.` - const done = steps.filter(step => step.status === 'done').length - const skipped = steps.filter(step => step.status === 'skipped').length - const markers = { done: '[x] Done', skipped: '[-] Skipped', pending: '[ ] Pending' } - const colors = { done: '32', skipped: '2', pending: '33' } - const colorize = (value: string, code: string) => options.color ? `\u001B[${code}m${value}\u001B[0m` : value - const next = version === 3 || version === 4 ? steps.find(step => step.status === 'pending') : undefined - const help = next ? V3_NEXT_STEP_HELP[next.id as typeof V3_STEP_IDS[number]] : undefined - return [ - colorize(`App: ${appId} — Todo list v${version}`, '1'), - `${done + skipped}/${steps.length} completed (${done} done, ${skipped} skipped, ${steps.length - done - skipped} pending)`, - '', - ...steps.map(step => `${colorize(markers[step.status], colors[step.status])}: ${step.title}`), - ...(next && help ? [ - '', - colorize(`Next step: ${next.title}`, '1;36'), - ` ${help.action}`, - ` Done when: ${help.doneWhen}`, - ' Run this command again to recheck progress.', - ] : []), - ].join('\n') -} - -export async function readAppTodoProgress(appId: string, options: OptionsBase): Promise { - const { data, error } = await invokeCapgoCliApi('private/onboarding_progress', { - ...options, - body: { appId, N: 0, initial: true }, - signal: AbortSignal.timeout(15_000), - }) - if (error) { - const status = getCapgoCliHttpStatus(error) - if (status === 401 || status === 403) { - throw new CliUserError('Cannot access app todo list. Check that your API key is valid and has app.read permission for this app.', { - appId, requiredPermissionKey: 'app.read', - }) - } - if (status === 404) - throw new CliUserError('App not found.', { appId }) - throw new Error(`Cannot read app todo list: ${await formatCapgoCliInvokeError(error)}`, { cause: error }) - } - if (!data || !Object.hasOwn(data, 'onboarding')) - throw new Error('Cannot read app todo list: invalid progress response') - return data -} - -export async function appTodo(appId: string | undefined, options: Partial) { - // Snapshot before the first API read so a check finishing during that read still triggers a refresh. - const backgroundChecks = [...getPendingOnboardingChecks().values()].map(check => check.completion) - intro('App todo list') - const apikey = options.apikey || findSavedKey() - if (!apikey) { - const message = 'Missing API key. Provide --apikey or log in.' - log.error(message) - throw new CliUserError(message) - } - if (!appId) - appId = getAppId(undefined, (await getConfig()).config) - if (!appId) { - const message = 'Missing appId. Provide an app ID or run this command in a Capacitor project.' - log.error(message) - throw new CliUserError(message) - } - - const supabase = await createSupabaseClient(apikey, options.supaHost, options.supaAnon) - const loading = stdin.isTTY && stdout.isTTY ? spinner() : null - if (loading) - loading.start('Loading the todo list') - else - log.info('Loading the todo list') - - let progress: AppTodoProgress - try { - await check2FAComplianceForApp(supabase, appId) - progress = await readAppTodoProgress(appId, { ...options, apikey }) - loading?.stop('Todo list loaded') - } - catch (error) { - loading?.stop('Could not load todo list') - if (error instanceof CliUserError) - log.error(error.message) - throw error - } - const { version, steps } = getAppTodoSteps(progress) - const checks = version === 3 || (version === 4 && steps.length > 0) ? backgroundChecks : [] - if (checks.length) { - const waiting = stdin.isTTY && stdout.isTTY ? spinner() : null - const waitMessage = (seconds: number) => `Waiting ${seconds} seconds for background TODO list checks to finish` - if (!waiting) - log.info(waitMessage(10)) - let started = false - const finished = await waitForTodoBackgroundChecks(checks, waiting - ? (seconds) => { - if (started) - waiting.message(waitMessage(seconds)) - else { - waiting.start(waitMessage(seconds)) - started = true - } - } - : undefined) - waiting?.stop(finished ? 'Background TODO list checks finished' : 'Finished waiting for background TODO list checks') - - const refreshing = stdin.isTTY && stdout.isTTY ? spinner() : null - if (refreshing) - refreshing.start('Refreshing the todo list') - else - log.info('Refreshing the todo list') - try { - progress = await readAppTodoProgress(appId, { ...options, apikey }) - refreshing?.stop('Todo list refreshed') - } - catch (error) { - refreshing?.stop('Could not refresh todo list') - if (error instanceof CliUserError) - log.error(error.message) - throw error - } - } - if (progress.checkErrors?.length) - log.warn('Some live progress checks failed. Showing saved progress for those tasks; try again to refresh them.') - log.info(formatAppTodoList(appId, progress, { color: !!stdout.isTTY && env.NO_COLOR === undefined })) - outro('Done ✅') -} diff --git a/cli/src/build/app-id.ts b/cli/src/build/app-id.ts deleted file mode 100644 index eaf6f3618b..0000000000 --- a/cli/src/build/app-id.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { CapacitorConfig } from '../config' -import { CliUserError } from '../shared/cli-user-error' -import { getAppId } from '../utils' - -export function hasBuilderAppIdField(config: CapacitorConfig | undefined): boolean { - const builderConfig: unknown = config?.plugins?.CapgoBuilder - return builderConfig !== null && typeof builderConfig === 'object' && Object.hasOwn(builderConfig, 'capgoBuilderAppId') -} - -export function getConfiguredBuilderAppId(config: CapacitorConfig | undefined): string | undefined { - const builderConfig: unknown = config?.plugins?.CapgoBuilder - if (hasBuilderAppIdField(config)) { - const value: unknown = (builderConfig as Record).capgoBuilderAppId - if (typeof value !== 'string' || !value.trim()) - throw new CliUserError('Invalid Capacitor config: plugins.CapgoBuilder.capgoBuilderAppId must be a non-empty string') - return value.trim() - } - return undefined -} - -/** Resolve the Capgo app key for Builder commands only. */ -export function getBuilderAppId( - explicitAppId: string | undefined, - config: CapacitorConfig | undefined, - legacyDefault: 'updater' | 'native' = 'updater', - explicitMode: 'truthy' | 'defined' = 'truthy', -): string | undefined { - if (explicitMode === 'defined' ? explicitAppId !== undefined : Boolean(explicitAppId)) - return explicitAppId - const configuredAppId = getConfiguredBuilderAppId(config) - if (configuredAppId) - return configuredAppId - return legacyDefault === 'native' ? config?.appId : getAppId(undefined, config) -} diff --git a/cli/src/build/credentials-command.ts b/cli/src/build/credentials-command.ts index 149af5541b..07aeadad59 100644 --- a/cli/src/build/credentials-command.ts +++ b/cli/src/build/credentials-command.ts @@ -4,8 +4,7 @@ import { resolve } from 'node:path' import { cwd, exit } from 'node:process' import { log } from '@clack/prompts' import { trackEvent } from '../analytics/track' -import { findSavedKey, getConfig, getOrganizationId, sendEvent } from '../utils' -import { getBuilderAppId } from './app-id' +import { findSavedKey, getAppId, getConfig, getOrganizationId, sendEvent } from '../utils' import { clearSavedCredentials, convertFilesToCredentials, @@ -187,7 +186,7 @@ export async function saveCredentialsCommand(options: SaveCredentialsOptions): P // Try to infer appId from capacitor.config if not provided const extConfig = await getConfig() - const appId = getBuilderAppId(options.appId, extConfig?.config) + const appId = getAppId(options.appId, extConfig?.config) if (!appId) { log.error('❌ App ID is required.') @@ -568,7 +567,7 @@ export async function listCredentialsCommand(options?: { appId?: string, local?: // Try to infer appId from capacitor.config if not provided const extConfig = await getConfig() - const inferredAppId = getBuilderAppId(options?.appId, extConfig?.config) + const inferredAppId = options?.appId || getAppId(undefined, extConfig?.config) // If specific appId is provided or inferred, only show that one const appsToShow = inferredAppId ? [inferredAppId] : allAppIds @@ -660,7 +659,7 @@ export async function clearCredentialsCommand(options: { appId?: string, platfor try { // Try to infer appId from capacitor.config if not explicitly provided const extConfig = await getConfig() - const appId = getBuilderAppId(options.appId, extConfig?.config) + const appId = options.appId || getAppId(undefined, extConfig?.config) const credentialsPath = options.local ? getLocalCredentialsPath() : getGlobalCredentialsPath() if (appId && options.platform) { @@ -750,7 +749,7 @@ export async function updateCredentialsCommand(options: SaveCredentialsOptions): // Try to infer appId from capacitor.config if not provided const extConfig = await getConfig() - const appId = getBuilderAppId(options.appId, extConfig?.config) + const appId = getAppId(options.appId, extConfig?.config) if (!appId) { log.error('❌ App ID is required.') @@ -995,7 +994,7 @@ export async function migrateCredentialsCommand(options: { appId?: string, platf // Try to infer appId from capacitor.config if not provided const extConfig = await getConfig() - const appId = getBuilderAppId(options.appId, extConfig?.config) + const appId = getAppId(options.appId, extConfig?.config) if (!appId) { log.error('❌ App ID is required.') diff --git a/cli/src/build/credentials-manage.ts b/cli/src/build/credentials-manage.ts index 69120cb69e..a500aae279 100644 --- a/cli/src/build/credentials-manage.ts +++ b/cli/src/build/credentials-manage.ts @@ -17,8 +17,7 @@ import { } from '../init/prompts' import { clearInitLogs, setInitScreen, stopInitInkSession } from '../init/runtime' import { trackEvent } from '../analytics/track' -import { getConfig } from '../utils' -import { getBuilderAppId, getConfiguredBuilderAppId } from './app-id' +import { getAppId, getConfig } from '../utils' import { clearSavedCredentials, getGlobalCredentialsPath, @@ -380,13 +379,12 @@ export async function manageCredentialsCommand(options: ManageCredentialsOptions return } - const detected = options.appId ? undefined : await detectAppIdFromCapacitor(entries) - const targetAppId = options.appId ?? detected?.appId + const targetAppId = options.appId ?? (await detectAppIdFromCapacitor(entries)) let detectedFromCapacitor = false if (targetAppId) { const filtered = entries.filter(entry => entry.appId === targetAppId) - if (filtered.length === 0 && (options.appId || detected?.configured)) { - pCancel(`No credentials found for app ${targetAppId}.`) + if (filtered.length === 0 && options.appId) { + pCancel(`No credentials found for app ${options.appId}.`) return } if (filtered.length > 0) { @@ -577,20 +575,17 @@ function describeFieldRow(row: FieldRow): string[] { return lines } -async function detectAppIdFromCapacitor(entries: AppEntry[]): Promise<{ appId: string | undefined, configured: boolean } | undefined> { +async function detectAppIdFromCapacitor(entries: AppEntry[]): Promise { if (entries.length === 0) return undefined - let extConfig: Awaited> | undefined try { - extConfig = await getConfig() + const extConfig = await getConfig() + const inferred = getAppId(undefined, extConfig?.config) + return inferred || undefined } catch { return undefined } - return { - appId: getBuilderAppId(undefined, extConfig?.config) || undefined, - configured: getConfiguredBuilderAppId(extConfig?.config) !== undefined, - } } async function loadEntries(localOnly?: boolean): Promise { diff --git a/cli/src/build/ios-provisioning-command.ts b/cli/src/build/ios-provisioning-command.ts index a216a0a73f..c5bf8099c3 100644 --- a/cli/src/build/ios-provisioning-command.ts +++ b/cli/src/build/ios-provisioning-command.ts @@ -6,8 +6,7 @@ import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { cwd, exit } from 'node:process' import { confirm, isCancel, log } from '@clack/prompts' -import { canPromptInteractively, formatError, getConfig } from '../utils' -import { getBuilderAppId } from './app-id' +import { canPromptInteractively, formatError, getAppId, getConfig } from '../utils' import { loadSavedCredentials, updateSavedCredentials } from './credentials' import { decodeCredentialBase64 } from './credentials-base64' import { resolveCredentialsStore } from './credentials-store-selection' @@ -253,7 +252,7 @@ export async function runIosProvisioningCommand(options: IosProvisioningOptions, async function loadDefaultProject(): Promise { const { config } = await getConfig(true) - const appId = getBuilderAppId(undefined, config) + const appId = getAppId(undefined, config) if (!appId) throw new Error('The Capacitor project does not define an app id') diff --git a/cli/src/build/needed.ts b/cli/src/build/needed.ts index dc52e94bd1..bcc7250a66 100644 --- a/cli/src/build/needed.ts +++ b/cli/src/build/needed.ts @@ -14,11 +14,11 @@ import { createSupabaseClient, findSavedKey, formatError, + getAppId, getCompatibilityDetails, getConfig, isCompatible, } from '../utils' -import { getBuilderAppId } from './app-id' type VersionChangeType = 'major' | 'minor' | 'patch' | 'prerelease' | 'changed' | 'same' | 'new' | 'removed' @@ -260,7 +260,7 @@ export async function getBuildNeeded( configError = error } - const resolvedAppId = getBuilderAppId(appId, extConfig?.config) + const resolvedAppId = getAppId(appId, extConfig?.config) if (!resolvedAppId) { if (configError instanceof Error) throw configError diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 50d0362cae..1cfd86d235 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -160,7 +160,6 @@ import { deleteAndroidProgress, getAndroidResumeStep, hasAnyOAuthProgress, loadA import { ANDROID_STEP_PROGRESS, getAndroidPhaseLabel } from '../types.js' import type { AndroidEffectDeps, AndroidInput } from '../flow.js' import { applyAndroidInput, runAndroidEffect } from '../flow.js' -import { trackAndroidKeystorePreparationFailure, trackPreparedAndroidKeystore } from './keystore-action.js' interface LogEntry { text: string, color?: string } @@ -472,7 +471,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir }, [appId, resolvedOrgId, step], ) - const reportedKeystoreSuccessesRef = useRef(new Set()) const [retryCount, setRetryCount] = useState(0) const [retryStep, setRetryStep] = useState(null) @@ -1528,7 +1526,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const keyPw = resolvedKeyPw setKeystoreKeyPassword(keyPw) addLog('✔ Key password set') - let keystorePrepared = false try { const bytes = await readFile(keystoreExistingPath) if (cancelled) @@ -1540,21 +1537,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir isGenerated: false, } // _keystoreBase64 / keystoreReady persisted below — no React mirrors (Plan 3.3). - const saved = await persist((p) => ({ + await persist((p) => ({ ...p, keystoreKeyPassword: keyPw, _keystoreBase64: base64, serviceAccountForkSeen: true, completedSteps: { ...p.completedSteps, keystoreReady: ready }, })) - keystorePrepared = trackPreparedAndroidKeystore( - saved, - 'imported', - resolution === 'probed-same' ? 'verified' : 'not_checked', - journeyId, - trackAction, - reportedKeystoreSuccessesRef.current, - ) addLog(`✔ Keystore loaded — ${keystoreExistingPath}`) // Smart-route: skip phases already complete (e.g. on resume into // this step after a legacy progress file already had OAuth steps @@ -1572,18 +1561,8 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setStep('service-account-method-select') } catch (err) { - if (!cancelled) { - if (!keystorePrepared) { - trackAndroidKeystorePreparationFailure( - 'imported', - resolution === 'probed-same' ? 'verified' : 'not_checked', - 'import_failed', - journeyId, - trackAction, - ) - } + if (!cancelled) handleError(err, 'keystore-existing-path') - } } })() } @@ -1951,7 +1930,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir signal: abort.signal, } - let keystorePrepared = false try { // Run the engine against the freshest persisted progress. Plan 3.1 made // disk progress the source of truth for in-session sequencing; the prior @@ -1968,17 +1946,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const t = result.transient const np = result.progress - if (step === 'keystore-generating') { - keystorePrepared = trackPreparedAndroidKeystore( - np, - 'generated', - 'generated_with_keystore', - journeyId, - trackAction, - reportedKeystoreSuccessesRef.current, - ) - } - // ── Apply transient runtime data to render state ────────────────────── if (t?.detectedPackageIds !== undefined) setDetectedPackageIds(t.detectedPackageIds) @@ -2051,15 +2018,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir catch (err) { if (cancelled) return - if (step === 'keystore-generating' && !keystorePrepared) { - trackAndroidKeystorePreparationFailure( - 'generated', - 'generated_with_keystore', - 'generate_failed', - journeyId, - trackAction, - ) - } // MissingScopesError on google-sign-in is handled INSIDE the engine // (returns next: 'google-sign-in'); any other throw routes through the // same retry/error UX the original effects used. @@ -2783,7 +2741,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setKeystoreKeyPassword(keyPw) addLog('✔ Key password set') ;(async () => { - let keystorePrepared = false try { const bytes = await readFile(keystoreExistingPath) const base64 = bytes.toString('base64') @@ -2793,21 +2750,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir isGenerated: false, } // _keystoreBase64 / keystoreReady persisted below — no React mirrors (Plan 3.3). - const saved = await persist((p) => ({ + await persist((p) => ({ ...p, keystoreKeyPassword: keyPw, _keystoreBase64: base64, serviceAccountForkSeen: true, completedSteps: { ...p.completedSteps, keystoreReady: ready }, })) - keystorePrepared = trackPreparedAndroidKeystore( - saved, - 'imported', - 'not_checked', - journeyId, - trackAction, - reportedKeystoreSuccessesRef.current, - ) addLog(`✔ Keystore loaded — ${keystoreExistingPath}`) // Smart-route: same pattern as the auto-probe branch above. // If the user has any OAuth-side progress (legacy resume or @@ -2821,15 +2770,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setStep('service-account-method-select') } catch (err) { - if (!keystorePrepared) { - trackAndroidKeystorePreparationFailure( - 'imported', - 'not_checked', - 'import_failed', - journeyId, - trackAction, - ) - } handleError(err, 'keystore-existing-path') } })() diff --git a/cli/src/build/onboarding/android/ui/keystore-action.ts b/cli/src/build/onboarding/android/ui/keystore-action.ts deleted file mode 100644 index 337ffd6688..0000000000 --- a/cli/src/build/onboarding/android/ui/keystore-action.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { AndroidOnboardingProgress, AndroidOnboardingStep } from '../types.js' -import type { PreparationTrackAction } from '../../ui/preparation-action.js' -import { emitPreparationAction, emitPreparationSuccessOnce } from '../../ui/preparation-action.js' - -export type AndroidKeystoreSource = 'generated' | 'imported' -export type AndroidKeyPasswordStatus = 'verified' | 'generated_with_keystore' | 'not_checked' -export type AndroidKeystoreFailureReason = 'generate_failed' | 'import_failed' - -type TrackAction = PreparationTrackAction - -function hasSavedKeystore(progress: AndroidOnboardingProgress, source: AndroidKeystoreSource): boolean { - const ready = progress.completedSteps.keystoreReady - return Boolean( - ready - && ready.isGenerated === (source === 'generated') - && progress._keystoreBase64 - && progress.keystoreAlias - && progress.keystoreStorePassword - && progress.keystoreKeyPassword, - ) -} - -export function trackPreparedAndroidKeystore( - progress: AndroidOnboardingProgress, - source: AndroidKeystoreSource, - keyPassword: AndroidKeyPasswordStatus, - journeyId: string, - trackAction: TrackAction, - reportedSuccesses: Set, -): boolean { - if (!hasSavedKeystore(progress, source)) - return false - - emitPreparationSuccessOnce(reportedSuccesses, source, trackAction, { - action: 'keystore_prepared', - attemptId: journeyId, - source, - step: source === 'generated' ? 'keystore-generating' : 'keystore-existing-key-password', - tags: { key_password: keyPassword }, - }) - return true -} - -export function trackAndroidKeystorePreparationFailure( - source: AndroidKeystoreSource, - keyPassword: AndroidKeyPasswordStatus, - reason: AndroidKeystoreFailureReason, - journeyId: string, - trackAction: TrackAction, -): void { - emitPreparationAction(trackAction, { - action: 'keystore_preparation_failed', - attemptId: journeyId, - source, - step: source === 'generated' ? 'keystore-generating' : 'keystore-existing-key-password', - tags: { reason, key_password: keyPassword }, - }) -} diff --git a/cli/src/build/onboarding/app-selection.ts b/cli/src/build/onboarding/app-selection.ts deleted file mode 100644 index 7f4bfb5263..0000000000 --- a/cli/src/build/onboarding/app-selection.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { CapacitorConfig } from '../../config' -import open from 'open' -import { getBuilderAppId, getConfiguredBuilderAppId } from '../app-id.js' -import { writeConfig } from '../../config/index.js' -import { consoleWebUrl, createSupabaseClient, formatCapgoCliInvokeError, getCapgoCliHttpStatus, getConfigForWrite, invokeCapgoCliApi } from '../../utils.js' - -export interface BuilderVisibleApp { - app_id: string - name: string | null - need_onboarding?: boolean | null -} - -export interface BuilderAppApiOptions { - supaHost?: string - supaAnon?: string -} - -export type AppSelectionErrorCode = 'list' | 'read' | 'missing' | 'build' | 'permission' | 'config' | 'api' - -export class AppSelectionError extends Error { - constructor(public readonly code: AppSelectionErrorCode, message: string, options?: ErrorOptions) { - super(message, options) - this.name = 'AppSelectionError' - } -} - -export function getAppSelectionSuggestion(config: CapacitorConfig): { appId: string, source: 'builder' | 'capacitor' } { - const builderAppId = getConfiguredBuilderAppId(config) - if (builderAppId) - return { appId: builderAppId, source: 'builder' } - if (typeof config.appId !== 'string' || !config.appId.trim()) - throw new AppSelectionError('config', 'Set appId in your Capacitor config before starting Builder onboarding.') - return { appId: config.appId.trim(), source: 'capacitor' } -} - -function commonDomainSegments(a: string, b: string): number { - const left = a.toLowerCase().split('.') - const right = b.toLowerCase().split('.') - let index = 0 - while (index < left.length && index < right.length && left[index] === right[index]) - index++ - return index -} - -function editDistance(a: string, b: string): number { - let previous = Array.from({ length: b.length + 1 }, (_, index) => index) - for (let i = 1; i <= a.length; i++) { - const current = [i] - for (let j = 1; j <= b.length; j++) - current[j] = Math.min(current[j - 1]! + 1, previous[j]! + 1, previous[j - 1]! + Number(a[i - 1] !== b[j - 1])) - previous = current - } - return previous[b.length]! -} - -export function rankVisibleApps(apps: T[], suggestedId: string): T[] { - const target = suggestedId.toLowerCase() - const scored = apps.map((app) => { - const id = app.app_id.toLowerCase() - return { - app, - prefix: commonDomainSegments(id, target), - similarity: 1 - editDistance(id, target) / Math.max(id.length, target.length, 1), - } - }) - return scored.sort((a, b) => b.prefix - a.prefix || b.similarity - a.similarity || a.app.app_id.localeCompare(b.app.app_id)).map(item => item.app) -} - -export async function listVisibleBuilderApps( - apikey: string, - options: BuilderAppApiOptions = {}, - request: typeof invokeCapgoCliApi = invokeCapgoCliApi, -): Promise { - const apps: BuilderVisibleApp[] = [] - for (let page = 0; ; page++) { - const { data, error } = await request(`app?page=${page}`, { - apikey, - method: 'GET', - body: undefined, - supaHost: options.supaHost, - supaAnon: options.supaAnon, - }) - if (error) - throw new AppSelectionError('list', `Could not load apps: ${await formatCapgoCliInvokeError(error)}`, { cause: error }) - if (!Array.isArray(data)) - throw new AppSelectionError('list', 'Capgo returned an invalid app list. Please retry.') - apps.push(...data) - if (data.length < 50) - return apps - } -} - -export async function verifyBuilderApp( - apikey: string, - appId: string, - options: BuilderAppApiOptions = {}, - dependencies: { request?: typeof invokeCapgoCliApi, createClient?: typeof createSupabaseClient } = {}, -): Promise { - const { data, error } = await (dependencies.request ?? invokeCapgoCliApi)(`app/${encodeURIComponent(appId)}`, { - apikey, - method: 'GET', - body: undefined, - supaHost: options.supaHost, - supaAnon: options.supaAnon, - }) - if (error) { - const status = getCapgoCliHttpStatus(error) - if (status === 401 || status === 403) - throw new AppSelectionError('read', `This API key needs app.read permission for ${appId}.`, { cause: error }) - if (status === 404) - throw new AppSelectionError('missing', `${appId} is no longer available. Check the app list again.`, { cause: error }) - throw new AppSelectionError('api', `Could not check app access: ${await formatCapgoCliInvokeError(error)}`, { cause: error }) - } - if (!data || data.app_id !== appId) - throw new AppSelectionError('missing', `${appId} is no longer available. Check the app list again.`) - - let supabase: Awaited> - try { - supabase = await (dependencies.createClient ?? createSupabaseClient)(apikey, options.supaHost, options.supaAnon, true) - } - catch (error) { - throw new AppSelectionError('api', 'Could not connect to Capgo to check build permission. Please retry.', { cause: error }) - } - const { data: canBuild, error: permissionError } = await supabase.rpc('cli_check_permission' as any, { - apikey, - permission_key: 'app.build_native', - org_id: null, - app_id: appId, - channel_id: null, - }) - if (permissionError) - throw new AppSelectionError('permission', 'Could not check app.build_native permission. Please retry.', { cause: permissionError }) - if (!canBuild) - throw new AppSelectionError('build', `This API key needs app.build_native permission for ${appId}.`) -} - -export async function persistBuilderAppSelection(appId: string): Promise { - try { - const extConfig = await getConfigForWrite(true) - if (getBuilderAppId(undefined, extConfig.config) === appId) - return false - extConfig.config.plugins ??= {} - extConfig.config.plugins.CapgoBuilder ??= {} - extConfig.config.plugins.CapgoBuilder.capgoBuilderAppId = appId - await writeConfig('CapgoBuilder', extConfig) - return true - } - catch (error) { - throw new AppSelectionError('config', 'Could not save the selected Builder app ID to your Capacitor config.', { cause: error }) - } -} - -export const builderAppCreationUrl = consoleWebUrl('/app/new') - -export async function openBuilderAppCreation(): Promise { - try { - await open(builderAppCreationUrl) - return true - } - catch { - return false - } -} - -export interface BuilderAppSelectionServices { - list: (apikey: string) => Promise - verify: (apikey: string, appId: string) => Promise - persist: (appId: string) => Promise - openDashboard: () => Promise - dashboardUrl: string -} - -export function createBuilderAppSelectionServices(options: BuilderAppApiOptions = {}): BuilderAppSelectionServices { - const dashboardAvailable = !options.supaHost && !options.supaAnon - return { - list: key => listVisibleBuilderApps(key, options), - verify: (key, appId) => verifyBuilderApp(key, appId, options), - persist: persistBuilderAppSelection, - openDashboard: dashboardAvailable ? openBuilderAppCreation : async () => false, - dashboardUrl: dashboardAvailable ? builderAppCreationUrl : '', - } -} diff --git a/cli/src/build/onboarding/appflow/flow.ts b/cli/src/build/onboarding/appflow/flow.ts index 744a3d1023..9ac4b74ff9 100644 --- a/cli/src/build/onboarding/appflow/flow.ts +++ b/cli/src/build/onboarding/appflow/flow.ts @@ -599,7 +599,7 @@ export function applyAppflowInput(step: AppflowStep, progress: AppflowProgress, return { ...base, p8IssuerId: (input.text ?? input.value ?? '').trim() } case 'handoff-build': // On 'build', the Appflow API work is done — switch progress.appId from the - // Appflow hex id to the resolved Capgo Builder app id so the + // Appflow hex id to the Capgo app id (the Capacitor config appId) so the // build/credential tail targets the real Capgo app, not the Appflow id. return { ...base, diff --git a/cli/src/build/onboarding/appflow/types.ts b/cli/src/build/onboarding/appflow/types.ts index 953357a0f6..db76a889e1 100644 --- a/cli/src/build/onboarding/appflow/types.ts +++ b/cli/src/build/onboarding/appflow/types.ts @@ -53,7 +53,7 @@ export interface AppflowProgress { appflowAccount?: string orgSlug?: string appId?: string // the SELECTED Appflow app id (hex), used for the Appflow API only - capgoAppId?: string // the resolved Capgo Builder app id, used for the build + credential store + capgoAppId?: string // the Capgo app id (Capacitor config appId), used for the build + credential store appSlug?: string ios?: Record // mapped Capgo iOS creds collected so far android?: Record // mapped Capgo Android creds collected so far diff --git a/cli/src/build/onboarding/command.ts b/cli/src/build/onboarding/command.ts index 05d9c9ee78..f70a41ef65 100644 --- a/cli/src/build/onboarding/command.ts +++ b/cli/src/build/onboarding/command.ts @@ -6,13 +6,11 @@ import { log } from '@clack/prompts' import { render } from 'ink' import React from 'react' import { resolveOwnerOrgId } from '../../analytics/org-resolver.js' -import { flushDeferredCommandInvocation, trackEvent } from '../../analytics/track.js' -import { getConfig } from '../../utils.js' -import { createBuilderAppSelectionServices, getAppSelectionSuggestion } from './app-selection.js' +import { trackEvent } from '../../analytics/track.js' +import { findSavedKeySilent, getAppId, getConfig } from '../../utils.js' import { appendInternalLog, startInternalLog } from '../../support/internal-log.js' import { newBuilderJourneyId } from './journey.js' -import { createBuilderLoginServices, resolveBuilderCandidateKey } from './login.js' -import { trackBuilderOnboardingAppSelection, trackBuilderOnboardingCancelled, trackBuilderOnboardingLogin } from './telemetry.js' +import { trackBuilderOnboardingCancelled } from './telemetry.js' import { isMacOS, probeGuidedHelper } from './asc-key/helper.js' import { ASC_KEY_CHANNEL } from './asc-key/protocol.js' import { getPlatformDirFromCapacitorConfig } from '../platform-paths.js' @@ -25,7 +23,6 @@ import { discoverCapacitorProjects, hasCapacitorConfig } from './project-discove import { selectCapacitorProject } from './project-selection.js' import type { BuilderProjectPrompts } from './project-selection.js' import type { OnboardingResult } from './types.js' -import type { AppSelectionEvent } from './ui/app-selection-gate.js' export interface OnboardingBuilderOptions { analytics?: boolean apikey?: string @@ -219,11 +216,11 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions // Detect app ID and platform directories from capacitor.config.ts let appId: string | undefined - let suggestedSource: 'builder' | 'capacitor' = 'capacitor' // `iosBundleIdInitial` is the iOS-side default — the top-level // `config.appId` (what `cap sync` writes into PRODUCT_BUNDLE_IDENTIFIER). - // This is distinct from `appId` above, which resolves the Capgo Builder key. - // The iOS onboarding flow uses these for different purposes — + // This is distinct from `appId` above, which `getAppId` resolves to the + // CapacitorUpdater plugin override when present (e.g. a Capgo dev-tunnel + // suffix). The iOS onboarding flow uses these for different purposes — // never collapse them — see the AppProps doc-block in ui/app.tsx. let iosBundleIdInitial: string | undefined let iosDir = 'ios' @@ -258,16 +255,7 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions process.exit(1) } - try { - const suggestion = getAppSelectionSuggestion(extConfig.config) - appId = suggestion.appId - suggestedSource = suggestion.source - } - catch (error) { - await stopInk(projectDiscoveryInk) - log.error(error instanceof Error ? error.message : String(error)) - process.exit(1) - } + appId = getAppId(undefined, extConfig.config) iosBundleIdInitial = extConfig.config.appId iosDir = getPlatformDirFromCapacitorConfig(extConfig.config, 'ios') androidDir = getPlatformDirFromCapacitorConfig(extConfig.config, 'android') @@ -289,7 +277,6 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions // resolved Capgo lookup key. Mismatch detection will still surface the // pbxproj/plist values; the user can pick the right one from there. const iosBundleIdForOnboarding = iosBundleIdInitial || appId - const appflowPackageName = iosBundleIdForOnboarding const initialPlatform = resolveInitialPlatform(options, iosDir, androidDir) @@ -342,11 +329,7 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions // handoff each get exactly one. const journeyId = newBuilderJourneyId() const analyticsEnabled = options.enableSelfUpdate === true && options.analytics !== false - const candidateApiKey = resolveBuilderCandidateKey(options.apikey) - const loginServices = createBuilderLoginServices({ supaHost: options.supaHost, supaAnon: options.supaAnon }) - const appSelectionServices = createBuilderAppSelectionServices({ supaHost: options.supaHost, supaAnon: options.supaAnon }) - let authenticatedApiKey: string | undefined - const replayApikey = candidateApiKey + const replayApikey = options.apikey?.trim() || findSavedKeySilent() const buildReplayUrl = resolveSupabaseReplayUrl(options.supaHost) const buildReplay = startInitReplay({ analyticsEnabled, @@ -371,16 +354,15 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions let lastStep: string | undefined const onboardingTree = React.createElement(OnboardingShell, { appId, - suggestedSource, - appSelectionServices, - // Keep the native iOS bundle ID separate from the Capgo app selected - // in the wizard. See the AppProps doc-block in ui/app.tsx for the split. + // Threaded through to the iOS OnboardingApp so it can use the iOS + // bundle id (config.appId) for Apple-side operations while keeping + // `appId` (the Capgo lookup key, which may include a dev-tunnel + // suffix via plugins.CapacitorUpdater.appId) for Capgo SaaS calls. + // See the AppProps doc-block in ui/app.tsx for the split. iosBundleIdInitial: iosBundleIdForOnboarding, - appflowPackageName, iosDir, androidDir, - apikey: candidateApiKey, - loginServices, + apikey: options.apikey, supaHost: options.supaHost, supaAnon: options.supaAnon, journeyId, @@ -399,35 +381,6 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions onResult: (r: OnboardingResult) => { result = r }, - onAuthenticated: (key, metadata) => { - authenticatedApiKey = key - flushDeferredCommandInvocation(key) - if (metadata.method) { - void trackBuilderOnboardingLogin({ - apikey: key, - appId: appId!, - journeyId, - method: metadata.method, - retryCount: metadata.retryCount, - durationMs: metadata.durationMs, - }) - } - }, - onAppSelected: (chosenId: string) => { - if (appId !== chosenId) - appendInternalLog(`build init: selected Capgo app ${chosenId} instead of ${appId}`) - appId = chosenId - }, - onAppSelectionEvent: (event: AppSelectionEvent) => { - if (!authenticatedApiKey || options.analytics === false) - return - void trackBuilderOnboardingAppSelection({ - apikey: authenticatedApiKey, - appId: appId!, - journeyId, - ...event, - }) - }, onBeforeExit: finishBuildReplay, }) const ink = projectDiscoveryInk ?? render(onboardingTree, { alternateScreen: true }) @@ -493,7 +446,7 @@ export async function onboardingBuilderCommand(options: OnboardingBuilderOptions // user has already quit. On timeout we abort the org lookup and skip the // event — losing one best-effort quit beacon is preferable to a hang. if (result.outcome === 'cancelled') { - const apikey = authenticatedApiKey + const apikey = options.apikey?.trim() || findSavedKeySilent() if (apikey) { const timeoutMs = 1500 const controller = new AbortController() diff --git a/cli/src/build/onboarding/ios/flow.ts b/cli/src/build/onboarding/ios/flow.ts index 4751d89e72..e6c35aef8b 100644 --- a/cli/src/build/onboarding/ios/flow.ts +++ b/cli/src/build/onboarding/ios/flow.ts @@ -175,8 +175,6 @@ export interface IosStepCtx { duplicateProfiles?: IosDuplicateProfile[] /** Existing Apple certs offered for revocation when the cert limit is hit. */ existingCerts?: AscDistributionCert[] - /** Preserves the limit outcome if the follow-up certificate lookup fails. */ - certificateLimitReached?: boolean /** The user's revoke selection (cert-limit-prompt → revoking-certificate). */ certToRevoke?: AscDistributionCert @@ -200,10 +198,6 @@ export interface IosStepCtx { teamId?: string /** Keychain export password (import-exporting). Transient only. */ importedP12Password?: string - /** Set only after a Keychain .p12 export succeeds; consumed after credentials are saved. */ - keychainP12Exported?: boolean - /** Safe outcome flag; export errors themselves never enter action telemetry. */ - keychainP12ExportFailed?: boolean // ── .p8 validation buffer (ephemeral during input-p8-path) ─────────────── /** Buffer of .p8 file content during validation (only the PATH is persisted). */ @@ -2448,17 +2442,9 @@ export async function runIosEffect( if (err instanceof CertificateLimitError) { // Offer the existing certs for revocation. Prefer the certs carried on // the error; fall back to a fresh list via listCertificates. - let existingCerts = err.certificates - if (!existingCerts?.length) { - try { - existingCerts = (await deps.listCertificates?.()) ?? [] - } - catch (lookupError) { - const msg = lookupError instanceof Error ? lookupError.message : String(lookupError) - deps.onLog?.(`✖ ${msg}`, 'red') - return iosError(progress, msg, step, { certificateLimitReached: true }) - } - } + const existingCerts = err.certificates?.length + ? err.certificates + : (await deps.listCertificates?.()) ?? [] return { progress, next: 'cert-limit-prompt', transient: { existingCerts } } } deps.onLog?.(`✖ ${err instanceof Error ? err.message : String(err)}`, 'red') @@ -3276,16 +3262,8 @@ export async function runIosEffect( // can't help; only Restart/Exit are offered (no retryStep). return iosError(progress, msg) } - let exported: ExportedP12 - try { - exported = await deps.exportP12FromKeychain!(chosenIdentity.sha1) - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err) - deps.onLog?.(`✖ ${msg}`, 'red') - return iosError(progress, msg, 'import-exporting', { keychainP12ExportFailed: true }) - } try { + const exported = await deps.exportP12FromKeychain!(chosenIdentity.sha1) // Synthesize a CertificateData record. Apple-API-only fields (certificateId) // stay empty for an imported cert (app.tsx:1639); expiry comes from the // chosen profile, team id from the identity. @@ -3318,7 +3296,6 @@ export async function runIosEffect( certData, profileData, importedP12Password: exported.passphrase, - keychainP12Exported: true, ...(chosenIdentity.teamId ? { teamId: chosenIdentity.teamId } : {}), }, } diff --git a/cli/src/build/onboarding/login.ts b/cli/src/build/onboarding/login.ts deleted file mode 100644 index dec81fbd26..0000000000 --- a/cli/src/build/onboarding/login.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { BrowserLoginSession } from '../../init/browser-login.js' -import { validateAndSaveKey } from '../../auth/session.js' -import { beginBrowserLogin, completeBrowserLogin } from '../../init/browser-login.js' -import { resolveAccountEmail } from '../../user/whoami.js' -import { createSupabaseClient, findSavedKeySilent, resolveUserIdFromApiKey } from '../../utils.js' - -export interface BuilderLoginOptions { - supaHost?: string - supaAnon?: string -} - -export interface BuilderLoginServices { - browserAvailable: boolean - validateExisting: (key: string) => Promise - getAccountEmail: (key: string) => Promise - savePasted: (key: string) => Promise - beginBrowser: (onUrl: (url: string) => void) => Promise - completeBrowser: (session: BrowserLoginSession, key: string) => Promise -} - -export function resolveBuilderCandidateKey(explicitKey?: string): string | undefined { - return explicitKey?.trim() || findSavedKeySilent() -} - -export function createBuilderLoginServices(options: BuilderLoginOptions = {}): BuilderLoginServices { - const saveOptions = { local: false, supaHost: options.supaHost, supaAnon: options.supaAnon } - return { - browserAvailable: !options.supaHost && !options.supaAnon, - validateExisting: async (key) => { - const client = await createSupabaseClient(key, options.supaHost, options.supaAnon, true) - await resolveUserIdFromApiKey(client, key, true) - }, - getAccountEmail: async (key) => { - const client = await createSupabaseClient(key, options.supaHost, options.supaAnon, true) - return resolveAccountEmail(client) - }, - savePasted: async (key) => { - await validateAndSaveKey(key, saveOptions) - }, - beginBrowser: onUrl => beginBrowserLogin(onUrl), - completeBrowser: async (session, key) => { - await completeBrowserLogin(session, key, saveOptions) - }, - } -} diff --git a/cli/src/build/onboarding/mcp/engine.ts b/cli/src/build/onboarding/mcp/engine.ts index 465f8c9d5f..27f2c4297b 100644 --- a/cli/src/build/onboarding/mcp/engine.ts +++ b/cli/src/build/onboarding/mcp/engine.ts @@ -2111,7 +2111,7 @@ export async function decideAppflow( if (!progress.capgoAppId) progress = { ...progress, capgoAppId: appId } - const flowDeps = buildAppflowEffectDeps({ appId, packageName: await deps.getNativeAppId?.() ?? facts.appId }) + const flowDeps = buildAppflowEffectDeps({ appId, packageName: facts.appId }) // Carries the most-recent AUTO effect's `transient` (e.g. the org/app/cert/dist // option lists, or validation results) so the interactive step it transitions @@ -3413,8 +3413,6 @@ export interface EngineDeps { cwd: string hasSavedKey: () => boolean getAppId: () => Promise - /** Native package/bundle id, separate from the Capgo Builder app key. */ - getNativeAppId?: () => Promise detectPlatforms: () => Promise isAppRegistered: (appId: string) => Promise loadProgress: (appId: string) => Promise diff --git a/cli/src/build/onboarding/mcp/onboarding-tools.ts b/cli/src/build/onboarding/mcp/onboarding-tools.ts index cba6fb0e94..1ae6e9b8ee 100644 --- a/cli/src/build/onboarding/mcp/onboarding-tools.ts +++ b/cli/src/build/onboarding/mcp/onboarding-tools.ts @@ -12,7 +12,6 @@ import type { McpRegistrar } from '../../../mcp/registrar.js' import { findBuildCommandForProjectType, findProjectType, findSavedKeySilent, getAppId, getConfig, getPackageScripts } from '../../../utils.js' import { findPackageManagerType } from '@capgo/find-package-manager' import { loadSavedCredentials, updateSavedCredentials } from '../../credentials.js' -import { getBuilderAppId, getConfiguredBuilderAppId } from '../../app-id.js' import { getPlatformDirFromCapacitorConfig } from '../../platform-paths.js' import type { AndroidEffectDeps } from '../android/flow.js' import type { IosEffectDeps } from '../ios/flow.js' @@ -219,9 +218,7 @@ function buildIosEffectDeps(cwd: string, getAppIdFn: () => Promise Promise CapgoSDK): EngineDeps { const cwd = process.cwd() const getAppIdClosure = async (): Promise => { - let ext: Awaited> | undefined try { - ext = await getConfig(true) + const ext = await getConfig(true) + return getAppId(undefined, ext?.config) } catch { return undefined } - return getBuilderAppId(undefined, ext?.config) } return { cwd, hasSavedKey: () => Boolean(findSavedKeySilent()), getAppId: getAppIdClosure, - getNativeAppId: async () => { - let ext: Awaited> | undefined - try { - ext = await getConfig(true) - } - catch { - return undefined - } - return getConfiguredBuilderAppId(ext?.config) ? ext?.config?.appId : undefined - }, detectPlatforms: async () => { const out: Platform[] = [] try { diff --git a/cli/src/build/onboarding/project-discovery.ts b/cli/src/build/onboarding/project-discovery.ts index 2ef0969f6d..6f35109ac2 100644 --- a/cli/src/build/onboarding/project-discovery.ts +++ b/cli/src/build/onboarding/project-discovery.ts @@ -107,7 +107,7 @@ function readStaticString(source: string, start: number): StaticStringToken | un return { end: source.length } } -function readStaticAppId(source: string, field = 'appId'): string | undefined { +function readStaticAppId(source: string): string | undefined { const values = new Set() let index = 0 while (index < source.length) { @@ -125,7 +125,7 @@ function readStaticAppId(source: string, field = 'appId'): string | undefined { keyEnd = index + (identifier?.length ?? 1) } - if (key === field) { + if (key === 'appId') { const colon = skipTrivia(source, keyEnd) if (source[colon] === ':') { const valueStart = skipTrivia(source, colon + 1) @@ -147,14 +147,11 @@ function readCapacitorAppId(directory: string): string | undefined { try { const source = readFileSync(configPath, 'utf8') if (configPath.endsWith('.json')) { - const config = JSON.parse(source) as { appId?: unknown, plugins?: { CapgoBuilder?: { capgoBuilderAppId?: unknown } } } - const builderAppId = config.plugins?.CapgoBuilder?.capgoBuilderAppId - if (typeof builderAppId === 'string' && builderAppId.trim()) - return builderAppId.trim() + const config = JSON.parse(source) as { appId?: unknown } const appId = typeof config.appId === 'string' ? config.appId.trim() : '' return appId || undefined } - return readStaticAppId(source, 'capgoBuilderAppId') ?? readStaticAppId(source) + return readStaticAppId(source) } catch { // Discovery remains best-effort. The selected project's normal config load diff --git a/cli/src/build/onboarding/telemetry.ts b/cli/src/build/onboarding/telemetry.ts index 1f8e36d0b1..cd628d131c 100644 --- a/cli/src/build/onboarding/telemetry.ts +++ b/cli/src/build/onboarding/telemetry.ts @@ -1,7 +1,5 @@ import type { AndroidOnboardingErrorCategory, AndroidOnboardingStep } from './android/types.js' import type { OnboardingErrorCategory, OnboardingStep, Platform } from './types.js' -import type { AppSelectionEvent } from './ui/app-selection-gate.js' -import { trackEvent } from '../../analytics/track.js' import { sendEvent } from '../../utils.js' import { getActiveCliReplaySessionId } from '../../init/replay.js' import { mapAndroidOnboardingError, mapIosOnboardingError } from './error-categories.js' @@ -12,47 +10,6 @@ function addReplaySessionTag(tags: Record, replaySessionId?: str tags.$session_id = sessionId } -export interface TrackBuilderOnboardingLoginInput { - apikey: string - appId: string - journeyId: string - method: 'browser' | 'paste' - retryCount: number - durationMs: number -} - -/** Login happens before platform and owner-org resolution. Send once a key is valid. */ -export function trackBuilderOnboardingLogin(input: TrackBuilderOnboardingLoginInput): Promise { - return trackEvent({ - apikey: input.apikey, - appId: input.appId, - channel: 'builder-onboarding', - event: 'Builder Onboarding Login', - tags: { - journey_id: input.journeyId, - method: input.method, - retry_count: input.retryCount, - duration_ms: input.durationMs, - }, - }) -} - -export function trackBuilderOnboardingAppSelection(input: AppSelectionEvent & { apikey: string, appId: string, journeyId: string }): Promise { - return trackEvent({ - apikey: input.apikey, - appId: input.appId, - channel: 'builder-onboarding', - event: 'Builder Onboarding App Selection', - tags: { - journey_id: input.journeyId, - phase: input.phase, - ...(input.result ? { result: input.result } : {}), - ...(input.source ? { source: input.source } : {}), - visible_app_count: input.visibleCount, - }, - }) -} - export interface TrackBuilderOnboardingStepInput { apikey: string appId: string @@ -76,18 +33,9 @@ export type BuilderOnboardingAction // fork — `continue` resumes saved progress, `restart` wipes it. Carries a // `choice` tag with that value. = | 'resume_prompt_decision' - | 'question_shown' - | 'question_answered' - | 'question_skipped' | 'android_sa_method_selected' | 'android_sa_validation_recovery_selected' | 'android_sa_validation_result' - | 'credential_verified' - | 'credential_verification_failed' - | 'certificate_prepared' - | 'certificate_preparation_failed' - | 'keystore_prepared' - | 'keystore_preparation_failed' export interface TrackBuilderOnboardingActionInput { apikey: string diff --git a/cli/src/build/onboarding/ui/app-selection-gate.tsx b/cli/src/build/onboarding/ui/app-selection-gate.tsx deleted file mode 100644 index 5e647444b1..0000000000 --- a/cli/src/build/onboarding/ui/app-selection-gate.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import type { FC, ReactNode } from 'react' -import type { BuilderAppSelectionServices, BuilderVisibleApp } from '../app-selection.js' -import { Box, Text, useInput } from 'ink' -import Spinner from 'ink-spinner' -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { AppSelectionError, rankVisibleApps } from '../app-selection.js' -import { PICKER_MIN_COLS, PICKER_MIN_ROWS, terminalFitsPicker } from '../min-terminal-size.js' -import { Header } from './components.js' -import { TerminalTooSmallPrompt } from './min-size-gate.js' - -type View = 'loading' | 'main' | 'all' | 'dashboard' | 'verifying' | 'error' -type Choice = { kind: 'app', app: BuilderVisibleApp, source: 'closest_list' | 'full_list' } | { kind: 'all' | 'login' | 'dashboard' | 'retry' | 'back' } - -export interface AppSelectionEvent { - phase: 'shown' | 'resolved' | 'error' - result?: 'exact_match' | 'selected' | 'list' | 'read' | 'missing' | 'build' | 'permission' | 'config' | 'api' - source?: 'closest_list' | 'full_list' - visibleCount: number -} - -export interface BuilderAppSelectionGateProps { - apikey: string - suggestedId: string - suggestedSource: 'builder' | 'capacitor' - services: BuilderAppSelectionServices - cols: number - rows: number - footer?: ReactNode - onSelected: (appId: string) => void - onSwitchKey: () => void - onCancel: () => void - onEvent?: (event: AppSelectionEvent) => void -} - -function visibleAppLabel(app: BuilderVisibleApp): string { - return app.name && app.name !== app.app_id ? `${app.name} · ${app.app_id}` : app.app_id -} - -function errorMessage(error: unknown): string { - if (error instanceof AppSelectionError) - return error.message - return error instanceof Error ? error.message : 'Could not check Capgo apps. Please retry.' -} - -const BuilderAppSelectionGate: FC = ({ apikey, suggestedId, suggestedSource, services, cols, rows, footer, onSelected, onSwitchKey, onCancel, onEvent }) => { - const [view, setView] = useState('loading') - const [apps, setApps] = useState([]) - const [index, setIndex] = useState(0) - const [query, setQuery] = useState('') - const [error, setError] = useState('') - const [retrySelection, setRetrySelection] = useState<{ app: BuilderVisibleApp, source?: 'closest_list' | 'full_list' } | undefined>() - const [dashboardOpened, setDashboardOpened] = useState(true) - const active = useRef(true) - const request = useRef(0) - const eventCallback = useRef(onEvent) - eventCallback.current = onEvent - const selectedCallback = useRef(onSelected) - selectedCallback.current = onSelected - - const emit = (event: AppSelectionEvent) => eventCallback.current?.(event) - - const verifyAndSelect = useCallback(async (app: BuilderVisibleApp, source?: 'closest_list' | 'full_list', count = 0) => { - const requestId = ++request.current - setRetrySelection({ app, source }) - setView('verifying') - try { - await services.verify(apikey, app.app_id) - await services.persist(app.app_id) - if (!active.current || requestId !== request.current) - return - selectedCallback.current(app.app_id) - emit({ phase: 'resolved', result: source ? 'selected' : 'exact_match', source, visibleCount: count }) - } - catch (cause) { - if (!active.current || requestId !== request.current) - return - const category = cause instanceof AppSelectionError ? cause.code : 'permission' - emit({ phase: 'error', result: category, visibleCount: count }) - setError(errorMessage(cause)) - setView('error') - } - }, [apikey, services]) - - const load = useCallback(async () => { - const requestId = ++request.current - setView('loading') - setError('') - setApps([]) - try { - const visible = await services.list(apikey) - if (!active.current || requestId !== request.current) - return - const ranked = rankVisibleApps(visible, suggestedId) - setApps(ranked) - setIndex(0) - const match = ranked.find(app => app.app_id === suggestedId) - if (match) { - await verifyAndSelect(match, undefined, ranked.length) - return - } - emit({ phase: 'shown', visibleCount: ranked.length }) - setRetrySelection(undefined) - setView('main') - } - catch (cause) { - if (!active.current || requestId !== request.current) - return - emit({ phase: 'error', result: 'list', visibleCount: 0 }) - setRetrySelection(undefined) - setError(errorMessage(cause)) - setView('error') - } - }, [apikey, services, suggestedId, verifyAndSelect]) - - useEffect(() => { - active.current = true - void load() - return () => { - active.current = false - request.current++ - } - }, [load]) - - const firstChoices: Choice[] = [ - ...apps.slice(0, 3).map(app => ({ kind: 'app' as const, app, source: 'closest_list' as const })), - ...(apps.length > 1 ? [{ kind: 'all' as const }] : []), - { kind: 'login' }, - ...(services.dashboardUrl ? [{ kind: 'dashboard' as const }] : []), - ] - const filteredApps = apps.filter(app => `${app.name ?? ''} ${app.app_id}`.toLowerCase().includes(query.toLowerCase())) - const allChoices: Choice[] = [ - ...filteredApps.map(app => ({ kind: 'app' as const, app, source: 'full_list' as const })), - { kind: 'back' }, - ] - const errorChoices: Choice[] = [ - { kind: 'retry' }, - ...(apps.length ? [{ kind: 'back' as const }] : []), - { kind: 'login' }, - ] - const choices = view === 'main' ? firstChoices : view === 'all' ? allChoices : view === 'error' ? errorChoices : [] - const boundedIndex = Math.min(index, Math.max(choices.length - 1, 0)) - - const choose = (choice: Choice) => { - if (choice.kind === 'app') { - void verifyAndSelect(choice.app, choice.source, apps.length) - } - else if (choice.kind === 'all') { - setQuery('') - setIndex(0) - setView('all') - } - else if (choice.kind === 'login') { - onSwitchKey() - } - else if (choice.kind === 'dashboard') { - const requestId = ++request.current - setDashboardOpened(true) - setView('dashboard') - void services.openDashboard().then((opened) => { - if (active.current && requestId === request.current) - setDashboardOpened(opened) - }).catch(() => { - if (active.current && requestId === request.current) - setDashboardOpened(false) - }) - } - else if (choice.kind === 'retry') { - if (retrySelection) - void verifyAndSelect(retrySelection.app, retrySelection.source, apps.length) - else - void load() - } - else { - setIndex(0) - setView('main') - } - } - - useInput((input, key) => { - if (key.escape) { - if (view === 'main') - onCancel() - else if (view === 'error' && apps.length === 0) - onCancel() - else if (view === 'all' || view === 'dashboard' || view === 'error') { - if (view === 'dashboard') - request.current++ - setIndex(0) - setView('main') - } - return - } - if (view === 'dashboard') { - if (key.return) - void load() - return - } - if (view !== 'main' && view !== 'all' && view !== 'error') - return - if (key.upArrow || (view !== 'all' && input === 'k')) { - setIndex(value => Math.max(0, value - 1)) - return - } - if (key.downArrow || (view !== 'all' && input === 'j')) { - setIndex(value => Math.min(choices.length - 1, value + 1)) - return - } - if (key.return) { - const choice = choices[boundedIndex] - if (choice) - choose(choice) - return - } - if (view === 'all') { - if (key.backspace || key.delete) { - setQuery(value => value.slice(0, -1)) - setIndex(0) - } - else if (!key.ctrl && !key.meta && !key.tab && input && !key.leftArrow && !key.rightArrow) { - setQuery(value => value + input) - setIndex(0) - } - } - }) - - if (!terminalFitsPicker(cols, rows)) - return - - const compact = cols < 64 || rows < 18 - const showDivider = !compact && rows >= 20 - const optionLimit = compact ? Math.max(1, rows - 9) : Math.max(3, rows - (showDivider ? 18 : 16)) - const start = Math.max(0, boundedIndex - optionLimit + 1) - const visibleChoices = choices.slice(start, start + optionLimit) - const suggestionLabel = suggestedSource === 'builder' ? 'Your Builder app ID:' : 'Your Capacitor app ID:' - - return ( - - {compact ? Capgo Cloud Build · Onboarding :
} - {(view === 'loading' || view === 'verifying') && ( - - - {view === 'loading' ? 'Checking Capgo apps…' : 'Checking app access…'} - - )} - {(view === 'main' || view === 'all') && ( - - Which Capgo app should Builder use? - {`${suggestionLabel} ${suggestedId}`} - No app with this ID is available to your API key. It may exist in Capgo, but you or your API key might lack access to it. - {view === 'all' - ? {`Search visible apps: ${query}█`} - : {apps.length === 0 ? 'No apps are visible to this API key.' : apps.length === 1 ? 'App visible to your API key:' : 'Apps visible to your API key (closest IDs first):'}} - - )} - {view === 'error' && {retrySelection ? 'Could not continue with this app' : 'Could not load Capgo apps'}{error}} - {view === 'dashboard' && ( - - Open Dashboard to create {suggestedId} - {dashboardOpened ? 'Create the app in your browser, then return here.' : 'Open this URL in your browser:'} - {!dashboardOpened && {services.dashboardUrl}} - ❯ I've created it — check again - Enter checks again · Esc goes back - - )} - {(view === 'main' || view === 'all' || view === 'error') && ( - - {showDivider && (view === 'main' || view === 'all') && {'─'.repeat(Math.min(76, cols - 2))}} - {view === 'all' && filteredApps.length === 0 && No matching apps} - {visibleChoices.map((choice, offset) => { - const selected = start + offset === boundedIndex - const label = choice.kind === 'app' ? visibleAppLabel(choice.app) - : choice.kind === 'all' ? 'Select a different app…' - : choice.kind === 'login' ? 'Log in with another API key' - : choice.kind === 'dashboard' ? `Open Dashboard to create ${suggestedId}` - : choice.kind === 'retry' ? 'Retry' - : 'Back to suggested apps' - return {`${selected ? '❯' : ' '} ${label}`} - })} - {compact && choices.length > optionLimit && ↑ ↓ more choices} - {!compact && ↑ ↓ choose · Enter select · Esc back} - - )} - {!compact && footer && } - {!compact && footer} - - ) -} - -export default BuilderAppSelectionGate diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index f78cb04890..0576979032 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -47,8 +47,6 @@ import { isAiAnalysisTooTall, resolveAiResultRoute } from '../ai-fit.js' import { getWorkflowDiffTelemetry, trackBuildOnboardingWorkflowEvent } from '../analytics.js' import { evaluateGate } from '../app-verification.js' import { exitAfterOnboardingBeforeExit } from './exit.js' -import { trackGuidedKeyValidationFailure, trackVerifiedIosKey, verifyIosKeyWithTelemetry } from './ios-credential-action.js' -import { trackCreatedIosCertificateResult, trackImportedIosCertificateSaveResult, trackIosCertificateCreationThrow, trackIosKeychainExportResult } from './ios-certificate-action.js' import { classifyCertAvailability, computeCertSha1, createCertificate, createProfile, deleteProfile, ensureBundleId, findCertIdBySha1, generateJwt, listApps, listBundleIds, listDistributionCerts, listProfilesForCert, revokeCertificate, verifyApiKey } from '../apple-api.js' import { runAscKeyHelper } from '../asc-key/helper.js' import { sanitizeBuildLogLines } from '../build-log.js' @@ -65,7 +63,6 @@ import { IOS_MIN_ROWS, terminalFitsOnboarding } from '../min-terminal-size.js' import { deleteProgress, extractKeyIdFromP8Path, getImportEntryStep, loadProgress, saveProgress } from '../progress.js' import { getBuildOnboardingRecoveryAdvice } from '../recovery.js' import { trackBuilderOnboardingAction, trackBuilderOnboardingStep } from '../telemetry.js' -import { saveImportDistributionAnswer, trackImportDistributionShown } from './import-distribution-analytics.js' import { getPhaseLabel, @@ -78,7 +75,6 @@ import { CompletedStepsLog } from './completed-steps-log.js' import { BOX_HEADER_ROWS, COMPACT_HEADER_ROWS, DiffSummary, Divider, FilteredTextInput, FullscreenAiViewer, FullscreenBuildOutput, FullscreenDiffViewer, Header, isBuildCompleteDismissKey, SecretsTable, SpinnerLine, SuccessLine, Table, WIZARD_PADDING_ROWS } from './components.js' import { logBudgetRows } from './frame-fit.js' import { TerminalTooSmallPrompt } from './min-size-gate.js' -import { routeFreshIosSetupMethod } from './setup-method-route.js' import { AskBuildStep, AskCiSecretsStep, @@ -249,9 +245,10 @@ interface LogEntry { text: string, color?: string } interface AppProps { /** * Capgo lookup key (progress files, saved credentials, Capgo SaaS build - * API). Resolved by the Builder app-id helper, which uses - * `plugins.CapgoBuilder.capgoBuilderAppId` when configured and otherwise - * keeps the prior updater/native fallback. Do NOT use for Apple-side operations — see + * API). Resolved by `getAppId()`, which prefers + * `config.plugins.CapacitorUpdater.appId` over `config.appId` so dev-tunnel + * sandboxes can override the Capgo-side identifier without renaming the + * iOS bundle. Do NOT use for Apple-side operations — see * `iosBundleIdInitial`. */ appId: string @@ -362,7 +359,9 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // ─── iOS bundle id ───────────────────────────────────────────────────── // - // `appId` (prop) is the Capgo Builder lookup key. It owns the progress-file key, credentials + // `appId` (prop) is the Capgo lookup key — what `getAppId()` resolves to, + // which prefers `config.plugins.CapacitorUpdater.appId` over `config.appId` + // for dev-tunnel sandboxes. It owns the progress-file key, credentials // store key, and `capgo build request` command path. // // `iosBundleId` is what we send to Apple — sourced from `config.appId` @@ -810,29 +809,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres }, [appId, resolvedOrgId, step, journeyId], ) - const reportedCertificateSuccessesRef = useRef(new Set()) - const setupMethodShownRef = useRef(false) - useEffect(() => { - if (step !== 'setup-method-select') { - setupMethodShownRef.current = false - return - } - if (setupMethodShownRef.current || !terminalFitsOnboarding(terminalCols, terminalRows, 'ios')) - return - setupMethodShownRef.current = true - trackAction('question_shown', { attempt_id: journeyId, question_id: 'ios_setup_method' }) - }, [step, terminalCols, terminalRows, trackAction, journeyId]) - const importDistributionShownRef = useRef(false) - useEffect(() => { - if (step !== 'import-distribution-mode') { - importDistributionShownRef.current = false - return - } - if (importDistributionShownRef.current || !terminalFitsOnboarding(terminalCols, terminalRows, 'ios')) - return - importDistributionShownRef.current = true - trackImportDistributionShown(journeyId, trackAction) - }, [step, terminalCols, terminalRows, trackAction, journeyId]) const [teamId, setTeamId] = useState(initialProgress?.completedSteps.certificateCreated?.teamId || '') const [certData, setCertData] = useState(initialProgress?.completedSteps.certificateCreated || null) const [profileData, setProfileData] = useState(initialProgress?.completedSteps.profileCreated || null) @@ -1762,8 +1738,13 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres return if (existing?.ios) setStep('credentials-exist') + else if (isMacOS()) + // Fresh iOS, no creds: offer the import-vs-create fork (create-new → + // the guided .p8 helper). Only macOS can drive the helper; other + // hosts go straight to the manual .p8 instructions. + setStep('setup-method-select') else - setStep(routeFreshIosSetupMethod(isMacOS(), journeyId, trackAction)) + setStep('api-key-instructions') })() }, 800) } @@ -1803,8 +1784,12 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres return if (existing?.ios) setStep('credentials-exist') + else if (isMacOS()) + // Fresh iOS, no creds: route through the import-vs-create fork + // (create-new → the guided .p8 helper) on macOS. + setStep('setup-method-select') else - setStep(routeFreshIosSetupMethod(isMacOS(), journeyId, trackAction)) + setStep('api-key-instructions') })() return } @@ -1834,11 +1819,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // helper window if the user quits the TUI, so the CLI doesn't hang. const abort = new AbortController() ascHelperAbortRef.current = abort - const outcome = await runAscKeyHelper({ - apikey, - signal: abort.signal, - onEvent: event => trackGuidedKeyValidationFailure(event.name, journeyId, trackAction, cancelled), - }) + const outcome = await runAscKeyHelper({ apikey, signal: abort.signal }) if (cancelled) return if (!outcome.ok) { @@ -2226,10 +2207,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // ── apple-api (token-adapted) ── verifyApiKey: async () => { - const token = await getFreshToken() - const r = await verifyIosKeyWithTelemetry( - () => verifyApiKey(token), journeyId, trackAction, () => cancelled, - ) + const r = await verifyApiKey(await getFreshToken()) return { teamId: r.teamId } }, createCertificate: async ({ csr }) => createCertificate(await getFreshToken(), csr), @@ -2310,7 +2288,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres }, } - let certificateEffectRunning = false try { // Run against the freshest persisted progress — the prior input steps // persisted p8Path / keyId / issuerId before these auto steps run, so the @@ -2327,18 +2304,13 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // verify-app: surface the step loader while the initial ASC fetch runs. if (step === 'verify-app') setVerifyAppLoading(true) - certificateEffectRunning = step === 'creating-certificate' const result = await runIosEffect(step, current, deps) - certificateEffectRunning = false if (cancelled) return const t: Partial | undefined = result.transient const np = result.progress - if (step === 'creating-certificate') - trackCreatedIosCertificateResult(result, journeyId, trackAction, reportedCertificateSuccessesRef.current) - // ── error route: surface through the TUI's handleError so the support // bundle + retryCount + telemetry UX is identical to the bespoke catch ── if (result.next === 'error') { @@ -2346,9 +2318,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres return } - if (step === 'verifying-key') - trackVerifiedIosKey(result, journeyId, trackAction) - // ── merge engine transient into the carried ref (threaded into the next // effect) AND mirror it into the React render state downstream code reads ── if (t) { @@ -2467,8 +2436,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres if (!next) return let advanceTo = next - if (step === 'backing-up' && (next === 'setup-method-select' || next === 'api-key-instructions')) - advanceTo = routeFreshIosSetupMethod(next === 'setup-method-select', journeyId, trackAction) if (step === 'verifying-key') { // The key is confirmed and the flow is moving on — NOW dismiss the // guided helper window (if it's still open on its success screen). @@ -2484,11 +2451,8 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres setStep(advanceTo) } catch (err) { - if (!cancelled) { - if (certificateEffectRunning) - trackIosCertificateCreationThrow(journeyId, trackAction) + if (!cancelled) handleErrorRef.current(err, step) - } } })() @@ -2642,9 +2606,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres const t: Partial | undefined = result.transient const np = result.progress - if (step === 'import-exporting') - trackIosKeychainExportResult(result, journeyId, trackAction) - // ── error route: surface through handleError so the support bundle + // retryCount + telemetry UX is identical to the bespoke catch ── if (result.next === 'error') { @@ -2948,9 +2909,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres const t = result.transient const np = result.progress - if (step === 'saving-credentials') - trackImportedIosCertificateSaveResult(result, deps.carried ?? {}, journeyId, trackAction, reportedCertificateSuccessesRef.current) - // ── Mirror engine transient → render state ───────────────────────────── if (t?.savedCredentials !== undefined) setSavedCredentials(t.savedCredentials) @@ -3451,8 +3409,13 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres if (existing?.ios) { setStep('credentials-exist') } + else if (isMacOS()) { + // macOS users see the fork: import existing or create new + setStep('setup-method-select') + } else { - setStep(routeFreshIosSetupMethod(isMacOS(), journeyId, trackAction)) + // Non-macOS hosts can only create new (importing requires Keychain) + setStep('api-key-instructions') } }} /> @@ -3477,7 +3440,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres if (existing?.ios) setStep('credentials-exist') else - setStep(routeFreshIosSetupMethod(isMacOS(), journeyId, trackAction)) + setStep('api-key-instructions') })() } else { @@ -3541,11 +3504,6 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres p8CreateMethod: value === 'import' ? existing.p8CreateMethod : undefined, } await saveProgress(appId, reduced) - trackAction('question_answered', { - attempt_id: journeyId, - question_id: 'ios_setup_method', - choice: value === 'import' ? 'import-existing' : 'create-new', - }) // Keep the React `importMode` mirror in sync (read by the // create-new effect driver's verifying-key guard + saving-credentials). @@ -3862,7 +3820,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres completedSteps: {}, } const reduced = applyIosInput('import-distribution-mode', base, { step: 'import-distribution-mode', value: value as 'app_store' | 'ad_hoc' | '__cancel__' }) - await saveImportDistributionAnswer(() => saveProgress(appId, reduced), value as 'app_store' | 'ad_hoc' | '__cancel__', journeyId, trackAction) + await saveProgress(appId, reduced) if (value === '__cancel__') { // The user bailed to the create-new path. Keep the React importMode diff --git a/cli/src/build/onboarding/ui/appflow-app.tsx b/cli/src/build/onboarding/ui/appflow-app.tsx index e2603b93a0..d26d1d0eac 100644 --- a/cli/src/build/onboarding/ui/appflow-app.tsx +++ b/cli/src/build/onboarding/ui/appflow-app.tsx @@ -44,7 +44,6 @@ const TOTAL_STAGES = 8 export interface AppflowAppProps { appId: string - packageName?: string /** Migration scope ('ios' | 'android') from the single-platform "migrating from Appflow?" gate. */ scope: MigrationScope apikey?: string @@ -55,7 +54,7 @@ export interface AppflowAppProps { onBeforeExit?: OnboardingBeforeExit } -const AppflowApp: FC = ({ appId, packageName, scope, apikey, supaHost, journeyId, onStep, onResult, onBeforeExit }) => { +const AppflowApp: FC = ({ appId, scope, apikey, supaHost, journeyId, onStep, onResult, onBeforeExit }) => { const { exit } = useApp() const { rows: terminalRows } = useTerminalSize() const [progress, setProgress] = useState(() => ({ scope, capgoAppId: appId, migratable: { ios: false, android: false }, completedSteps: [] })) @@ -68,7 +67,7 @@ const AppflowApp: FC = ({ appId, packageName, scope, apikey, su const [buildOutput, setBuildOutput] = useState([]) // Guards a single deps build (the appflow-side validators/token) + a single // in-flight auto effect per step. - const depsRef = useRef(buildAppflowEffectDeps({ appId, packageName: packageName ?? appId })) + const depsRef = useRef(buildAppflowEffectDeps({ appId, packageName: appId })) // Mirror buildOutput into a ref so finishMigration can lift the queued build // URL into the durable summary without taking buildOutput as a dependency // (which would rebuild the callback on every streamed line). diff --git a/cli/src/build/onboarding/ui/components.tsx b/cli/src/build/onboarding/ui/components.tsx index fbfec35da7..3b212afa53 100644 --- a/cli/src/build/onboarding/ui/components.tsx +++ b/cli/src/build/onboarding/ui/components.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import { Box, Text, useInput, useStdout } from 'ink' import Spinner from 'ink-spinner' // src/build/onboarding/ui/components.tsx -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useState } from 'react' import stringWidth from 'string-width' import { computeMaxScrollOffset, pickVisibleLines } from '../ai-fit.js' import type { DiffLine } from '../diff-utils.js' @@ -254,8 +254,6 @@ export const FilteredTextInput: FC<{ */ transform?: (value: string) => string mask?: boolean - /** Limit only the rendered mask; the full value is still submitted. */ - maxMaskWidth?: number /** * Pre-fills the input. Used when the user is editing an already-entered * value (e.g. fixing a typo in their ASC Key ID / Issuer ID after a @@ -264,18 +262,16 @@ export const FilteredTextInput: FC<{ */ initialValue?: string onSubmit: (value: string) => void -}> = ({ placeholder = '', filter = '=', allowedPattern, maxLength, transform, mask = false, maxMaskWidth, initialValue = '', onSubmit }) => { +}> = ({ placeholder = '', filter = '=', allowedPattern, maxLength, transform, mask = false, initialValue = '', onSubmit }) => { const [value, setValue] = useState(() => applyConstraints(initialValue, { filter, allowedPattern, maxLength, transform })) - const valueRef = useRef(value) useInput((input, key) => { if (key.return) { - onSubmit(valueRef.current) + onSubmit(value) return } if (key.backspace || key.delete) { - valueRef.current = valueRef.current.slice(0, -1) - setValue(valueRef.current) + setValue(prev => prev.slice(0, -1)) return } // Ignore control characters, arrows, etc. @@ -284,12 +280,11 @@ export const FilteredTextInput: FC<{ } // Append input then apply the full constraint pipeline (paste-safe). if (input) { - valueRef.current = applyConstraints(valueRef.current + input, { filter, allowedPattern, maxLength, transform }) - setValue(valueRef.current) + setValue(prev => applyConstraints(prev + input, { filter, allowedPattern, maxLength, transform })) } }) - const display = mask ? '•'.repeat(Math.min(value.length, maxMaskWidth ?? value.length)) : value + const display = mask ? '•'.repeat(value.length) : value const showCounter = maxLength !== undefined && !mask return ( diff --git a/cli/src/build/onboarding/ui/import-distribution-analytics.ts b/cli/src/build/onboarding/ui/import-distribution-analytics.ts deleted file mode 100644 index f77aa8310e..0000000000 --- a/cli/src/build/onboarding/ui/import-distribution-analytics.ts +++ /dev/null @@ -1,30 +0,0 @@ -type DistributionChoice = 'app_store' | 'ad_hoc' | '__cancel__' -type TrackAction = (action: 'question_shown' | 'question_answered', tags: Record) => void - -export function trackImportDistributionShown(journeyId: string, trackAction: TrackAction): void { - try { - trackAction('question_shown', { attempt_id: journeyId, question_id: 'ios_import_distribution' }) - } - catch { - // Telemetry must not interrupt onboarding. - } -} - -export async function saveImportDistributionAnswer( - save: () => Promise, - value: DistributionChoice, - journeyId: string, - trackAction: TrackAction, -): Promise { - await save() - try { - trackAction('question_answered', { - attempt_id: journeyId, - question_id: 'ios_import_distribution', - choice: value === '__cancel__' ? 'switch_to_create_new' : value, - }) - } - catch { - // Telemetry must not interrupt onboarding. - } -} diff --git a/cli/src/build/onboarding/ui/ios-certificate-action.ts b/cli/src/build/onboarding/ui/ios-certificate-action.ts deleted file mode 100644 index 359900f76a..0000000000 --- a/cli/src/build/onboarding/ui/ios-certificate-action.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { IosEffectResult, IosStepCtx } from '../ios/flow.js' -import type { OnboardingStep } from '../types.js' -import type { PreparationTrackAction } from './preparation-action.js' -import { emitPreparationAction, emitPreparationSuccessOnce } from './preparation-action.js' - -type TrackAction = PreparationTrackAction - -function emitCertificateAction( - trackAction: TrackAction, - action: 'certificate_prepared' | 'certificate_preparation_failed', - journeyId: string, - source: 'created' | 'keychain_import', - step: OnboardingStep, - reason?: 'create_failed' | 'certificate_limit' | 'export_failed', -): void { - emitPreparationAction(trackAction, { - action, - attemptId: journeyId, - source, - step, - tags: reason ? { reason } : undefined, - }) -} - -export function trackCreatedIosCertificateResult( - result: IosEffectResult, - journeyId: string, - trackAction: TrackAction, - reportedSuccesses: Set, -): void { - if (result.next === 'cert-limit-prompt' || result.transient?.certificateLimitReached) { - emitCertificateAction(trackAction, 'certificate_preparation_failed', journeyId, 'created', 'creating-certificate', 'certificate_limit') - return - } - if (result.next === 'error') { - emitCertificateAction(trackAction, 'certificate_preparation_failed', journeyId, 'created', 'creating-certificate', 'create_failed') - return - } - - const cert = result.progress.completedSteps.certificateCreated - if (result.next !== 'creating-profile' || !cert?.certificateId || !cert.p12Base64) - return - - const successKey = `created:${cert.certificateId}` - emitPreparationSuccessOnce(reportedSuccesses, successKey, trackAction, { - action: 'certificate_prepared', - attemptId: journeyId, - source: 'created', - step: 'creating-certificate', - }) -} - -export function trackIosCertificateCreationThrow(journeyId: string, trackAction: TrackAction): void { - emitCertificateAction(trackAction, 'certificate_preparation_failed', journeyId, 'created', 'creating-certificate', 'create_failed') -} - -export function trackIosKeychainExportResult(result: IosEffectResult, journeyId: string, trackAction: TrackAction): void { - if (result.next === 'error' && result.transient?.keychainP12ExportFailed) - emitCertificateAction(trackAction, 'certificate_preparation_failed', journeyId, 'keychain_import', 'import-exporting', 'export_failed') -} - -export function trackImportedIosCertificateSaveResult( - result: IosEffectResult, - carried: Partial, - journeyId: string, - trackAction: TrackAction, - reportedSuccesses: Set, -): void { - if (result.progress.setupMethod !== 'import-existing' || result.next !== 'ask-build' || !result.transient?.savedCredentials || !carried.keychainP12Exported || !carried.certData?.p12Base64) - return - - const successKey = `keychain_import:${carried.chosenIdentity?.sha1 ?? ''}` - emitPreparationSuccessOnce(reportedSuccesses, successKey, trackAction, { - action: 'certificate_prepared', - attemptId: journeyId, - source: 'keychain_import', - step: 'saving-credentials', - }) -} diff --git a/cli/src/build/onboarding/ui/ios-credential-action.ts b/cli/src/build/onboarding/ui/ios-credential-action.ts deleted file mode 100644 index 1b7699c8ca..0000000000 --- a/cli/src/build/onboarding/ui/ios-credential-action.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { IosEffectResult } from '../ios/flow.js' -import type { BuilderOnboardingAction } from '../telemetry.js' -import type { OnboardingStep } from '../types.js' -import { mapIosOnboardingError } from '../error-categories.js' - -type TrackAction = (action: BuilderOnboardingAction, tags: Record, step: OnboardingStep) => void - -function credentialTags(journeyId: string): Record { - return { credential: 'ios_app_store_connect_api_key', attempt_id: journeyId } -} - -export function trackGuidedKeyValidationFailure( - eventName: string, - journeyId: string, - trackAction: TrackAction, - cancelled = false, -): void { - if (cancelled || eventName !== 'validation_failed') - return - - trackAction('credential_verification_failed', { - ...credentialTags(journeyId), - source: 'guided_helper', - }, 'asc-key-generating') -} - -export async function verifyIosKeyWithTelemetry( - verify: () => Promise, - journeyId: string, - trackAction: TrackAction, - isCancelled: () => boolean, -): Promise { - try { - return await verify() - } - catch (error) { - if (!isCancelled()) { - trackAction('credential_verification_failed', { - ...credentialTags(journeyId), - source: 'cli_verifier', - error_category: mapIosOnboardingError(error, 'verifying-key'), - }, 'verifying-key') - } - throw error - } -} - -export function trackVerifiedIosKey( - result: IosEffectResult, - journeyId: string, - trackAction: TrackAction, -): void { - if (result.next === 'error' || !result.progress.completedSteps.apiKeyVerified) - return - - trackAction('credential_verified', credentialTags(journeyId), 'verifying-key') -} diff --git a/cli/src/build/onboarding/ui/login-gate.tsx b/cli/src/build/onboarding/ui/login-gate.tsx deleted file mode 100644 index 758811c165..0000000000 --- a/cli/src/build/onboarding/ui/login-gate.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import type { FC, ReactNode } from 'react' -import type { BrowserLoginSession } from '../../../init/browser-login.js' -import type { BuilderLoginServices } from '../login.js' -import { Select } from '@inkjs/ui' -import { Box, Text, useInput } from 'ink' -import Spinner from 'ink-spinner' -import React, { useEffect, useRef, useState } from 'react' -import { Header, FilteredTextInput } from './components.js' -import { pickPlatformLayout } from './frame-fit.js' -import { CardChooser } from './platform-picker.js' - -type LoginMethod = 'browser' | 'paste' -type LoginView = 'checking' | 'candidate-error' | 'choice' | 'opening' | 'entry' | 'verifying' | 'identifying' | 'welcome' - -export interface BuilderLoginMetadata { - method?: LoginMethod - retryCount: number - durationMs: number -} - -export interface BuilderLoginGateProps { - candidateKey?: string - services: BuilderLoginServices - cols: number - rows: number - footer?: ReactNode - onAuthenticated: (key: string, metadata: BuilderLoginMetadata) => void - onCancel: () => void -} - -const BuilderLoginGate: FC = ({ candidateKey, services, cols, rows, footer, onAuthenticated, onCancel }) => { - const [view, setView] = useState(candidateKey ? 'checking' : services.browserAvailable ? 'choice' : 'entry') - const [method, setMethod] = useState(services.browserAvailable ? undefined : 'paste') - const [session, setSession] = useState() - const [browserUrl, setBrowserUrl] = useState() - const [error, setError] = useState() - const [accountEmail, setAccountEmail] = useState() - const [inputRevision, setInputRevision] = useState(0) - const attempts = useRef(0) - const shownAt = useRef(Date.now()) - const cancelled = useRef(false) - const welcomeTimer = useRef | undefined>(undefined) - const authenticatedCallback = useRef(onAuthenticated) - authenticatedCallback.current = onAuthenticated - const compact = cols < 64 || rows < 18 - - const showWelcome = async (key: string, metadata: BuilderLoginMetadata) => { - setView('identifying') - let lookupTimer: ReturnType | undefined - const email = await Promise.race([ - services.getAccountEmail(key).catch(() => undefined), - new Promise((resolve) => { - lookupTimer = setTimeout(() => resolve(undefined), 2000) - }), - ]) - if (lookupTimer) - clearTimeout(lookupTimer) - if (cancelled.current) - return - setAccountEmail(email) - setView('welcome') - welcomeTimer.current = setTimeout(() => { - if (!cancelled.current) - authenticatedCallback.current(key, metadata) - }, 1500) - } - - useEffect(() => () => { - cancelled.current = true - if (welcomeTimer.current) - clearTimeout(welcomeTimer.current) - }, []) - - useEffect(() => { - if (!candidateKey) - return - void services.validateExisting(candidateKey) - .then(() => { - if (!cancelled.current) - void showWelcome(candidateKey, { retryCount: 0, durationMs: 0 }) - }) - .catch(() => { - if (!cancelled.current) { - setError("We couldn't verify this API key. Retry or use another key.") - setView('candidate-error') - } - }) - }, [candidateKey, services]) - - const cancel = () => { - cancelled.current = true - onCancel() - } - - const retryCandidate = () => { - if (!candidateKey) - return - setError(undefined) - setView('checking') - void services.validateExisting(candidateKey) - .then(() => { - if (!cancelled.current) - void showWelcome(candidateKey, { retryCount: 0, durationMs: 0 }) - }) - .catch(() => { - if (!cancelled.current) { - setError("We couldn't verify this API key. Retry or use another key.") - setView('candidate-error') - } - }) - } - - const chooseMethod = (choice: string) => { - if (choice !== 'browser' && choice !== 'paste') - return - setMethod(choice) - setError(undefined) - if (choice === 'paste') { - setView('entry') - return - } - if (session) { - setBrowserUrl(session.url) - setView('entry') - return - } - setView('opening') - void services.beginBrowser(setBrowserUrl) - .then((openedSession) => { - if (cancelled.current) - return - setSession(openedSession) - setView('entry') - }) - .catch(() => { - if (!cancelled.current) { - setError('Could not open the dashboard. Select a login method to try again.') - setView('choice') - } - }) - } - - const submit = (value: string) => { - const key = value.trim() - if (!key) { - setError('API key is required.') - return - } - attempts.current += 1 - setError(undefined) - setView('verifying') - const verify = method === 'browser' && session - ? services.completeBrowser(session, key) - : services.savePasted(key) - void verify - .then(() => { - if (!cancelled.current) { - void showWelcome(key, { - method: method ?? 'paste', - retryCount: attempts.current - 1, - durationMs: Date.now() - shownAt.current, - }) - } - }) - .catch(() => { - if (!cancelled.current) { - setInputRevision(revision => revision + 1) - setError("We couldn't verify that key. Paste another key and try again.") - setView('entry') - } - }) - } - - useInput((_input, key) => { - if (key.escape && view !== 'welcome') - cancel() - else if (key.tab && view === 'entry' && services.browserAvailable) { - setError(undefined) - setView('choice') - } - }) - - const choiceOptions = [ - { value: 'browser', emoji: '🌎', name: 'Open browser', hint: 'Create key in Dashboard' }, - { value: 'paste', emoji: '📋', name: 'Paste API key', hint: 'Use an existing key' }, - ] - - const statusText = view === 'checking' - ? 'Checking Capgo login…' - : view === 'opening' - ? 'Opening the Capgo Dashboard…' - : view === 'verifying' - ? 'Checking API key…' - : view === 'identifying' - ? 'Getting account details…' - : undefined - - return ( - - {compact - ? Capgo Cloud Build · Login - :
} - {statusText && ( - - - - {statusText} - - - )} - {view === 'welcome' && ( - - ✔ - - {accountEmail ? `Welcome ${accountEmail} 👋` : 'Welcome to Capgo 👋'} - - - )} - {view === 'candidate-error' && (compact - ? ( - - We couldn't verify your Capgo login. - - - ) - : - )} - {view === 'entry' && ( - - Paste the API key from the Capgo Dashboard - {method === 'browser' && browserUrl && ( - - {session?.browserOpened ? 'Dashboard URL:' : 'Open this URL in your browser:'} - {browserUrl} - - )} - - - - {error && {error}} - {services.browserAvailable ? 'Enter verify · Tab change method · Esc cancel' : 'Enter verify · Esc cancel'} - {!compact && footer} - - )} - - ) -} - -export default BuilderLoginGate diff --git a/cli/src/build/onboarding/ui/preparation-action.ts b/cli/src/build/onboarding/ui/preparation-action.ts deleted file mode 100644 index 859e35ab99..0000000000 --- a/cli/src/build/onboarding/ui/preparation-action.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { BuilderOnboardingAction } from '../telemetry.js' - -export type PreparationTrackAction = ( - action: BuilderOnboardingAction, - tags: Record, - step: TStep, -) => void - -interface PreparationActionInput { - action: BuilderOnboardingAction - attemptId: string - source: string - step: TStep - tags?: Record -} - -export function emitPreparationAction( - trackAction: PreparationTrackAction, - input: PreparationActionInput, -): void { - try { - trackAction(input.action, { - attempt_id: input.attemptId, - source: input.source, - ...input.tags, - }, input.step) - } - catch { - // Even a synchronous telemetry failure must not interrupt onboarding. - } -} - -export function emitPreparationSuccessOnce( - reportedSuccesses: Set, - successKey: string, - trackAction: PreparationTrackAction, - input: PreparationActionInput, -): void { - if (reportedSuccesses.has(successKey)) - return - reportedSuccesses.add(successKey) - emitPreparationAction(trackAction, input) -} diff --git a/cli/src/build/onboarding/ui/setup-method-route.ts b/cli/src/build/onboarding/ui/setup-method-route.ts deleted file mode 100644 index 213ea74ecc..0000000000 --- a/cli/src/build/onboarding/ui/setup-method-route.ts +++ /dev/null @@ -1,22 +0,0 @@ -type TrackSkip = ( - action: 'question_skipped', - tags: { attempt_id: string, question_id: string, choice: string, reason: string }, - step: 'setup-method-select', -) => void - -export function routeFreshIosSetupMethod( - onMac: boolean, - journeyId: string, - trackAction: TrackSkip, -): 'setup-method-select' | 'api-key-instructions' { - if (onMac) - return 'setup-method-select' - - trackAction('question_skipped', { - attempt_id: journeyId, - question_id: 'ios_setup_method', - choice: 'create-new', - reason: 'non_macos_auto_create_new', - }, 'setup-method-select') - return 'api-key-instructions' -} diff --git a/cli/src/build/onboarding/ui/shell.tsx b/cli/src/build/onboarding/ui/shell.tsx index 6afe4917ea..af495cbebe 100644 --- a/cli/src/build/onboarding/ui/shell.tsx +++ b/cli/src/build/onboarding/ui/shell.tsx @@ -1,9 +1,5 @@ import type { FC } from 'react' import type { OnboardingResult, Platform } from '../types.js' -import type { BuilderLoginServices } from '../login.js' -import type { BuilderLoginMetadata } from './login-gate.js' -import type { BuilderAppSelectionServices } from '../app-selection.js' -import type { AppSelectionEvent } from './app-selection-gate.js' // src/build/onboarding/ui/shell.tsx // // Top-level wizard shell, rendered ONCE inside the alt-screen buffer @@ -36,8 +32,6 @@ import { TerminalTooSmallPrompt } from './min-size-gate.js' import { CardChooser, PlatformPicker } from './platform-picker.js' import { exitAfterOnboardingBeforeExit } from './exit.js' import { UpdatePrompt } from './update-prompt.js' -import BuilderLoginGate from './login-gate.js' -import BuilderAppSelectionGate from './app-selection-gate.js' import type { OnboardingBeforeExit } from './exit.js' // Progress shapes derived from the loaders so we don't re-import the type names. @@ -86,17 +80,15 @@ export function useTerminalSize(): { cols: number, rows: number } { export interface OnboardingShellProps { appId: string - suggestedSource: 'builder' | 'capacitor' - appSelectionServices: BuilderAppSelectionServices /** * iOS-side bundle id default — sourced from `config.appId` (top-level), which * is what `cap sync` writes into `PRODUCT_BUNDLE_IDENTIFIER`. Distinct from - * the Capgo app ID selected by this shell, which may use the Builder override - * or another visible app. Threaded to the iOS OnboardingApp; Android ignores it. + * `appId` above, which `getAppId()` may resolve to + * `config.plugins.CapacitorUpdater.appId` (a Capgo lookup key — wrong for + * Apple signing). Threaded down to the iOS OnboardingApp; the Android app + * ignores it. */ iosBundleIdInitial: string - /** Android package name for Appflow validation; defaults to the legacy Capgo key. */ - appflowPackageName?: string iosDir: string androidDir: string /** @@ -106,7 +98,6 @@ export interface OnboardingShellProps { */ guidedHelperUsable: boolean apikey?: string - loginServices: BuilderLoginServices supaHost?: string /** Custom Supabase anon key for self-hosting (--supa-anon). */ supaAnon?: string @@ -134,10 +125,6 @@ export interface OnboardingShellProps { onResult?: (result: OnboardingResult) => void /** Awaited immediately before Ink exits so replay can capture the alt-screen frame. */ onBeforeExit?: OnboardingBeforeExit - /** Called after the login gate has verified a key. */ - onAuthenticated?: (key: string, metadata: BuilderLoginMetadata) => void - onAppSelected?: (appId: string) => void - onAppSelectionEvent?: (event: AppSelectionEvent) => void } const AnalyticsNotice: FC = () => ( @@ -146,13 +133,10 @@ const AnalyticsNotice: FC = () => ( ) -const OnboardingShell: FC = ({ appId, suggestedSource, appSelectionServices, iosBundleIdInitial, appflowPackageName, iosDir, androidDir, guidedHelperUsable, apikey, loginServices, supaHost, supaAnon, journeyId, initialPlatform, updateInfo, analyticsNotice, onResolvePlatform, onStep, onResult, onBeforeExit, onAuthenticated, onAppSelected, onAppSelectionEvent }) => { +const OnboardingShell: FC = ({ appId, iosBundleIdInitial, iosDir, androidDir, guidedHelperUsable, apikey, supaHost, supaAnon, journeyId, initialPlatform, updateInfo, analyticsNotice, onResolvePlatform, onStep, onResult, onBeforeExit }) => { const { exit } = useApp() const { cols, rows } = useTerminalSize() const [ready, setReady] = useState(null) - const [authenticatedKey, setAuthenticatedKey] = useState() - const [selectedAppId, setSelectedAppId] = useState() - const [switchingKey, setSwitchingKey] = useState(false) // Set when progress loading fails (e.g. corrupt saved-progress JSON). loadProgress // throws for non-ENOENT errors, so without a rejection handler `choose` would // leave an unhandled promise rejection and the picker stuck with no feedback. @@ -173,10 +157,8 @@ const OnboardingShell: FC = ({ appId, suggestedSource, app // The picker stays on screen during the (few-ms) load, so there's no loading // frame on the picker path. const choose = useCallback((platform: Platform) => { - if (!selectedAppId) - return onResolvePlatform?.(platform) - void loadReady(platform, selectedAppId) + void loadReady(platform, appId) .then(setReady) .catch((err: unknown) => { // Surface the failure instead of hanging: show an error frame, report a @@ -187,7 +169,7 @@ const OnboardingShell: FC = ({ appId, suggestedSource, app onResult?.({ outcome: 'cancelled' }) setTimeout(exitAfterBeforeExit, 50) }) - }, [selectedAppId, onResolvePlatform, onResult, exitAfterBeforeExit]) + }, [appId, onResolvePlatform, onResult, exitAfterBeforeExit]) // Picker answer. The picker only yields iOS / Android now; both pass through // the "migrating from Appflow?" gate before committing to native onboarding. @@ -216,21 +198,16 @@ const OnboardingShell: FC = ({ appId, suggestedSource, app useEffect(() => { // Hold the auto-load until the update prompt (if any) is answered, so the // update offer is the first screen even when --platform pre-resolves. - if (authenticatedKey && selectedAppId && initialPlatform && (!updateInfo || updateAnswered)) + if (initialPlatform && (!updateInfo || updateAnswered)) choose(initialPlatform) - }, [authenticatedKey, selectedAppId, initialPlatform, choose, updateInfo, updateAnswered]) - - useEffect(() => { - if (authenticatedKey && !selectedAppId && !switchingKey) - onStep?.('app-selection') - }, [authenticatedKey, selectedAppId, switchingKey, onStep]) + }, [initialPlatform, choose, updateInfo, updateAnswered]) // Progress load failed (corrupt/unreadable saved state) — show why and exit, // rather than hanging on a frozen picker. The exit is scheduled in the .catch. if (loadError) { return ( - {`✖ Could not load onboarding progress for ${selectedAppId ?? appId}.`} + {`✖ Could not load onboarding progress for ${appId}.`} {loadError} Your saved progress file may be corrupt. Remove it and re-run `capgo build init`. @@ -245,12 +222,12 @@ const OnboardingShell: FC = ({ appId, suggestedSource, app // here would unmount it on a mid-flow shrink, tearing down step state and // exiting the wizard. The app owns the size decision so a shrink→regrow keeps // the user exactly where they were. - if (ready?.kind === 'ios' && selectedAppId) - return - if (ready?.kind === 'android' && selectedAppId) - return - if (ready?.kind === 'appflow' && selectedAppId) - return + if (ready?.kind === 'ios') + return + if (ready?.kind === 'android') + return + if (ready?.kind === 'appflow') + return // Not ready yet: the platform picker (or a brief framed load). The picker is // NOT gated to the full 80×49 onboarding floor — it's small and adapts @@ -293,56 +270,6 @@ const OnboardingShell: FC = ({ appId, suggestedSource, app ) } - if (!authenticatedKey || switchingKey) { - return ( - : undefined} - onAuthenticated={(key, metadata) => { - setAuthenticatedKey(key) - setSwitchingKey(false) - onAuthenticated?.(key, metadata) - }} - onCancel={() => { - if (switchingKey) { - setSwitchingKey(false) - return - } - onResult?.({ outcome: 'cancelled' }) - setTimeout(exitAfterBeforeExit, 50) - }} - /> - ) - } - - if (!selectedAppId) { - return ( - : undefined} - onSelected={(chosenId) => { - setSelectedAppId(chosenId) - onAppSelected?.(chosenId) - }} - onSwitchKey={() => setSwitchingKey(true)} - onCancel={() => { - onResult?.({ outcome: 'cancelled' }) - setTimeout(exitAfterBeforeExit, 50) - }} - onEvent={onAppSelectionEvent} - /> - ) - } - // Migration gate: the user picked iOS / Android — ask whether they are // migrating from Ionic Appflow before committing to native onboarding. YES // routes into the Appflow migration scoped to that platform; NO continues to diff --git a/cli/src/build/prescan/checks/ios-entitlements-checks.ts b/cli/src/build/prescan/checks/ios-entitlements-checks.ts index 9c491a5c71..203304be0b 100644 --- a/cli/src/build/prescan/checks/ios-entitlements-checks.ts +++ b/cli/src/build/prescan/checks/ios-entitlements-checks.ts @@ -18,11 +18,11 @@ import { plistArrayStrings } from './ios-plist-read' import { parseProvisioningMap } from './ios-profiles' function primaryProvisioningProfile(ctx: ScanContext): ReturnType[number] | undefined { - return parseProvisioningMap(ctx).find(profile => profile.bundleId === (ctx.nativeAppId ?? ctx.appId)) + return parseProvisioningMap(ctx).find(profile => profile.bundleId === ctx.appId) } const hasPrimaryProfile = (ctx: ScanContext): boolean => primaryProvisioningProfile(ctx) !== undefined -const hasAppEntitlements = (ctx: ScanContext): boolean => readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) !== null +const hasAppEntitlements = (ctx: ScanContext): boolean => readAppEntitlements(ctx.projectDir, ctx.appId) !== null /** * Independent evidence the app actually uses push: the Info.plist declares the @@ -143,7 +143,7 @@ export const entitlementsVsProfileCapability: PrescanCheck = { platforms: ['ios'], appliesTo: ctx => hasPrimaryProfile(ctx) && hasAppEntitlements(ctx), async run(ctx): Promise { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) const profile = primaryProvisioningProfile(ctx) if (!app || !profile) return [] @@ -196,11 +196,11 @@ export const apsEnvironmentVsMode: PrescanCheck = { id: 'ios/entitlements-aps-environment-vs-mode', platforms: ['ios'], appliesTo: (ctx) => { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) return app !== null && entString(app.raw, 'aps-environment') !== null && Boolean(ctx.distributionMode) }, async run(ctx): Promise { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) if (!app) return [] const value = entString(app.raw, 'aps-environment') @@ -265,11 +265,11 @@ export const associatedDomainsFormat: PrescanCheck = { id: 'ios/entitlements-associated-domains-format', platforms: ['ios'], appliesTo: (ctx) => { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) return app !== null && entArray(app.raw, ASSOCIATED_DOMAIN_KEY).length > 0 }, async run(ctx): Promise { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) if (!app) return [] const bad: string[] = [] @@ -300,11 +300,11 @@ export const appGroupsFormat: PrescanCheck = { id: 'ios/entitlements-app-groups-format', platforms: ['ios'], appliesTo: (ctx) => { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) return app !== null && entArray(app.raw, APP_GROUP_KEY).length > 0 }, async run(ctx): Promise { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) if (!app) return [] const bad = entArray(app.raw, APP_GROUP_KEY).filter(g => !APP_GROUP_RE.test(g)) @@ -343,7 +343,7 @@ export const entitlementsDeclaredAgeRange: PrescanCheck = { platforms: ['ios'], appliesTo: ctx => packageHasDependency(ctx.projectDir, AGE_RANGE_PLUGIN) && willUploadToAppStore(ctx), async run(ctx): Promise { - const app = readAppEntitlements(ctx.projectDir, ctx.nativeAppId ?? ctx.appId) + const app = readAppEntitlements(ctx.projectDir, ctx.appId) if (app !== null && entBool(app.raw, DECLARED_AGE_RANGE_KEY) === true) return [] return [{ diff --git a/cli/src/build/prescan/command.ts b/cli/src/build/prescan/command.ts index 7ab794c9fc..6bb937a503 100644 --- a/cli/src/build/prescan/command.ts +++ b/cli/src/build/prescan/command.ts @@ -57,7 +57,6 @@ export function exitCodeFor(counts: Record, opts: OutcomeOptio export interface PrescanExecution { report: PrescanReport - appId: string /** apikey actually used for the scan (flag or saved key); undefined when remote checks were skipped */ apikey?: string } @@ -90,14 +89,14 @@ export async function executePrescan(appId: string | undefined, options: Prescan const overrides = parsePrescanOverrides({ skip: options.skip, warn: options.warn }) validateOverrideIds(overrides, ALL_CHECK_IDS) const report = await runPrescan(ctx, ALL_CHECKS, { overrides }) - return { report, appId: ctx.appId, apikey } + return { report, apikey } } export async function prescanCommand(appId: string | undefined, options: PrescanCommandOptions): Promise { validateFlags(options) if (!options.json) intro('Capgo build prescan') - const { report, appId: resolvedAppId, apikey: apikeyUsedForScan } = await executePrescan(appId, options) + const { report, apikey: apikeyUsedForScan } = await executePrescan(appId, options) if (options.json) { console.log(renderJsonReport(report)) } @@ -115,7 +114,7 @@ export async function prescanCommand(appId: string | undefined, options: Prescan tags: { 'source': 'standalone', 'result': enforced.error > 0 ? (options.ignoreFatal ? 'bypassed' : 'blocked') : enforced.warning > 0 ? (options.failOnWarnings ? 'blocked' : 'warned') : informationOnly > 0 ? 'information-only' : 'clean', - 'app-id': resolvedAppId, + 'app-id': appId ?? 'unknown', 'platform': options.platform ?? 'unknown', 'errors': String(report.counts.error), 'warnings': String(report.counts.warning), diff --git a/cli/src/build/prescan/context.ts b/cli/src/build/prescan/context.ts index 95cd2599a8..aade96c766 100644 --- a/cli/src/build/prescan/context.ts +++ b/cli/src/build/prescan/context.ts @@ -6,7 +6,6 @@ import { getConfig } from '../../utils' import { CliUserError } from '../../shared/cli-user-error' import { mergeCredentials } from '../credentials' import { withCwd } from '../cwd' -import { getBuilderAppId, hasBuilderAppIdField } from '../app-id' export interface BuildScanContextArgs { appId?: string @@ -29,14 +28,12 @@ export async function buildScanContext(args: BuildScanContextArgs): Promise getConfig(true))).config } catch { config = undefined } // no capacitor project — checks degrade individually - const appId = getBuilderAppId(args.appId, config, 'native', 'defined') + const appId = args.appId ?? config?.appId if (!appId) throw new CliUserError('Missing appId: pass it explicitly or run inside a Capacitor project') - const nativeAppId = hasBuilderAppIdField(config) ? config?.appId ?? appId : appId const credentials = args.credentials ?? (await mergeCredentials(appId, args.platform) as Record | undefined) return { appId, - nativeAppId, platform: args.platform, hostPlatform: process.platform, projectDir: args.projectDir, diff --git a/cli/src/build/prescan/types.ts b/cli/src/build/prescan/types.ts index 6abae8a820..3a040a7b42 100644 --- a/cli/src/build/prescan/types.ts +++ b/cli/src/build/prescan/types.ts @@ -24,8 +24,6 @@ export interface Finding { export interface ScanContext { appId: string - /** Native bundle/package id for local checks; defaults to appId for legacy scans. */ - nativeAppId?: string platform: Platform /** Operating system running the prescan, used for host-specific local checks. */ hostPlatform: NodeJS.Platform diff --git a/cli/src/build/request.ts b/cli/src/build/request.ts index c24d7362ff..9720b7bb92 100644 --- a/cli/src/build/request.ts +++ b/cli/src/build/request.ts @@ -67,7 +67,6 @@ import { uploadSupportLogs } from '../support/support-upload.js' import { offerSupportUploadBeforeAi } from '../support/support-upload-prompt.js' import { buildCliRequestHeaders } from '../analytics/cli-headers' import { assertCliPermission, canPromptInteractively, createSupabaseClient, findSavedKey, getConfig, getOrganizationId, getRemoteConfig, sendEvent, trimTrailingSlashes, TUS_UPLOAD_RETRY_DELAYS } from '../utils' -import { getBuilderAppId } from './app-id' import { syncAndroidVersion } from './android-version' import { createBuildCancellationSignalHandler, requestBuildCancellation } from './cancellation' import { mergeCredentials, MIN_OUTPUT_RETENTION_SECONDS, parseAndroidPlayStoreReleaseStatus, parseAndroidPlayStoreTrack, parseInAppUpdatePriority, parseOptionalBoolean, parseOutputRetentionSeconds } from './credentials' @@ -1467,7 +1466,7 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO // @capacitor/cli loadConfig() is cwd-based; honor --path for monorepos/workspaces. const config = await withCwd(projectDir, () => getConfig()) - appId = getBuilderAppId(appId, config?.config, 'native') || '' + appId = appId || config?.config?.appId if (!appId) { throw new Error('Missing argument, you need to provide a appId, or be in a capacitor project') diff --git a/cli/src/bundle/upload-config.ts b/cli/src/bundle/upload-config.ts deleted file mode 100644 index ef85f61c5f..0000000000 --- a/cli/src/bundle/upload-config.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ExtConfigPairs } from '../config' -import type { OptionsUpload } from './upload_interface' -import { buildCordovaUploadConfig } from '../cordova/project' -import { isCordovaMode } from '../framework/mode' -import { CliUserError } from '../shared/cli-user-error' -import { getConfig, NO_CAPACITOR_CONFIG_MESSAGE } from '../utils' - -export function buildCordovaModeUploadExample(options: Pick & { appId?: string }): string { - const appId = options.appId?.trim() || '' - const path = options.path?.trim() || 'www' - const channel = options.channel?.trim() || '' - return `npx @capgo/cli@latest bundle upload ${appId} --mode cordova --path ${path} --channel ${channel}` -} - -export function buildMissingCapacitorConfigUploadMessage(options: Pick & { appId?: string }): string { - return [ - NO_CAPACITOR_CONFIG_MESSAGE, - 'If this is a Cordova project (config.xml / plugin.xml and a www folder), retry with:', - buildCordovaModeUploadExample(options), - ].join('\n') -} - -export function enhanceMissingCapacitorConfigUploadError( - error: unknown, - options: Pick & { appId?: string }, -): never { - if (error instanceof CliUserError && error.message === NO_CAPACITOR_CONFIG_MESSAGE) { - throw new CliUserError(buildMissingCapacitorConfigUploadMessage({ - appId: options.appId, - path: options.path, - channel: options.channel, - })) - } - throw error -} - -export async function loadUploadProjectConfig( - options: OptionsUpload, - context: { appId?: string } = {}, -): Promise { - if (isCordovaMode(options.mode)) - return buildCordovaUploadConfig({ path: options.path, appId: context.appId }) - - try { - return await getConfig() - } - catch (error) { - enhanceMissingCapacitorConfigUploadError(error, { - appId: context.appId, - path: options.path, - channel: options.channel, - }) - } -} diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 85b5e2db4c..6187e10ea2 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -25,18 +25,15 @@ import { showReplicationProgress } from '../replicationProgress' import { CliUserError } from '../shared/cli-user-error' import { formatTable } from '../terminal-table' import { usesAlwaysDirectUpdate } 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, 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 { 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' import { resolveAutoBumpLevelFromAi } from './auto-bump-ai' import { maybePromptBuilderCta, shouldBlockIncompatibleUpload } from './builder-cta' import { checkIndexPosition, searchInDirectory } from './check' import { summarizeUploadCompatibility } from './compatibility' -import { CORDOVA_DEFAULT_WEB_DIR } from '../cordova/project' -import { isCordovaMode } from '../framework/mode' import { ensureNotifyAppReadyInBuildFolder } from '../recovery/notify-app-ready' import { parsePackageJsonOptionPaths, resolveAppIdWithRecovery } from '../recovery/app-id' -import { loadUploadProjectConfig } from './upload-config' import { prepareBundlePartialFiles, uploadPartial } from './partial' import { clackUploadReporter, getUploadReporter, runWithUploadReporter } from './reporter' import { formatUploadChannels, getChannelsToAssignByChecksum, parseUploadChannels } from './upload-channels' @@ -160,17 +157,13 @@ async function getAppIdAndPath(appId: string | undefined, options: OptionsUpload supaHost: options.supaHost, supaAnon: options.supaAnon, }) - const path = options.path || config?.webDir || (isCordovaMode(options.mode) ? CORDOVA_DEFAULT_WEB_DIR : undefined) + const path = options.path || config?.webDir if (!finalAppId) { - uploadFail(isCordovaMode(options.mode) - ? 'Missing appId. Pass it on the command line or set id in config.xml / plugin.xml' - : 'Missing argument, you need to provide a appid or be in a capacitor project') + uploadFail('Missing argument, you need to provide a appid or be in a capacitor project') } if (!path) { - uploadFail(isCordovaMode(options.mode) - ? `Missing upload path. Pass --path or use the default Cordova web dir (${CORDOVA_DEFAULT_WEB_DIR})` - : 'Missing argument, you need to provide a path (--path), or be in a capacitor project') + uploadFail('Missing argument, you need to provide a path (--path), or be in a capacitor project') } if (!existsSync(path)) { @@ -475,34 +468,6 @@ function shouldSendAppTooLargeEvent(options: OptionsUpload): boolean { return shouldUploadFullZip(options) || hasCompleteS3UploadConfig(options) } -const CORDOVA_UPDATER_PACKAGES = [ - '@capgo/cordova-updater', - 'cordova-plugin-capgo', -] as const - -type ResolvedUpdaterForUpload = { - packageName: string - version: string -} - -function isCordovaUpdaterPackage(packageName: string): boolean { - return (CORDOVA_UPDATER_PACKAGES as readonly string[]).includes(packageName) -} - -async function resolveUpdaterForUpload(options: OptionsUpload, root: string): Promise { - if (!isCordovaMode(options.mode)) { - const version = await getInstalledVersion('@capgo/capacitor-updater', root, options.packageJson) - return version ? { packageName: '@capgo/capacitor-updater', version } : null - } - - for (const packageName of CORDOVA_UPDATER_PACKAGES) { - const version = await getInstalledVersion(packageName, root, options.packageJson) - if (version) - return { packageName, version } - } - return null -} - async function prepareBundleFile(path: string, options: OptionsUpload, apikey: string, orgId: string, appid: string, maxUploadLength: number, alertUploadSize: number, publicKeyFromConfig?: string) { let ivSessionKey let sessionKey @@ -519,9 +484,7 @@ async function prepareBundleFile(path: string, options: OptionsUpload, apikey: s zipped = await zipFile(path) s.message(`Calculating checksum`) const root = findRoot(cwd()) - const resolvedUpdater = await resolveUpdaterForUpload(options, root) - const updaterVersion = resolvedUpdater?.version - const updaterPackageName = resolvedUpdater?.packageName + const updaterVersion = await getInstalledVersion('@capgo/capacitor-updater', root, options.packageJson) let useSha256 = false let coerced try { @@ -530,24 +493,15 @@ async function prepareBundleFile(path: string, options: OptionsUpload, apikey: s catch { coerced = undefined } - if (!resolvedUpdater) { - if (isCordovaMode(options.mode)) { - log.warn('Cannot find a Capgo updater plugin in node_modules. Using SHA256 checksum for this Cordova upload.') - useSha256 = true - } - else { - uploadFail('Cannot find @capgo/capacitor-updater in node_modules, please install it first with your package manager') - } - } - else if (updaterPackageName && isCordovaUpdaterPackage(updaterPackageName)) { - useSha256 = true + if (!updaterVersion) { + uploadFail('Cannot find @capgo/capacitor-updater in node_modules, please install it first with your package manager') } else if (coerced) { // Use SHA256 for v5.10.0+, v6.25.0+ and v7.0.30+ useSha256 = !isDeprecatedPluginVersion(coerced, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7) } else if (updaterVersion === 'link:@capgo/capacitor-updater' || updaterVersion === 'file:..' || updaterVersion === 'file:../') { - log.warn(`Using local ${updaterPackageName ?? '@capgo/capacitor-updater'}. Assuming latest version for checksum calculation.`) + log.warn('Using local @capgo/capacitor-updater. Assuming latest version for checksum calculation.') useSha256 = true } const forceCrc32 = options.forceCrc32Checksum === true @@ -1369,12 +1323,9 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio if (options.verbose) log.info(`[Verbose] API key retrieved successfully`) - const extConfig = await loadUploadProjectConfig(options, { appId: preAppid }) - if (options.verbose) { - log.info(isCordovaMode(options.mode) - ? `[Verbose] Cordova project config resolved (webDir: ${extConfig.config.webDir})` - : `[Verbose] Capacitor config loaded successfully`) - } + const extConfig = await getConfig() + if (options.verbose) + log.info(`[Verbose] Capacitor config loaded successfully`) // Record whether the user explicitly asked for a delta/partial upload BEFORE // any mutation of `options.delta`. The instant-update auto-enable below and @@ -1528,14 +1479,9 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio } if (options.autoSetBundle) { - if (isCordovaMode(options.mode)) { - log.warn('--auto-set-bundle is not supported in Cordova mode (no capacitor.config to update)') - } - else { - await updateConfigUpdater({ version: bundle }) - if (options.verbose) - log.info(`[Verbose] Auto-set bundle version in ${extConfig.path}`) - } + await updateConfigUpdater({ version: bundle }) + if (options.verbose) + log.info(`[Verbose] Auto-set bundle version in ${extConfig.path}`) } log.info(`Upload ${appid}@${bundle} started from path "${path}" to Capgo cloud`) diff --git a/cli/src/cordova/project.ts b/cli/src/cordova/project.ts deleted file mode 100644 index caefaf99b5..0000000000 --- a/cli/src/cordova/project.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { CapacitorConfig, ExtConfigPairs } from '../config' -import { existsSync, readFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { cwd } from 'node:process' -import { DOMParser } from '@xmldom/xmldom' -import { isValidAppId } from '../recovery/app-id' - -export const CORDOVA_DEFAULT_WEB_DIR = 'www' - -const CORDOVA_CONFIG_FILES = ['config.xml', 'plugin.xml'] as const - -export function findCordovaProjectRoot(startDir = cwd()): string { - let current = resolve(startDir) - const filesystemRoot = resolve(current, '/') - - while (true) { - for (const fileName of CORDOVA_CONFIG_FILES) { - if (existsSync(join(current, fileName))) - return current - } - if (current === filesystemRoot) - break - current = dirname(current) - } - - return resolve(startDir) -} - -function parseXmlRootId(content: string, rootTag: 'widget' | 'plugin'): string | undefined { - try { - const doc = new DOMParser().parseFromString(content, 'text/xml') - const root = doc.documentElement - if (!root || root.nodeName.toLowerCase() !== rootTag) - return undefined - const candidate = root.getAttribute('id')?.trim() - return candidate && isValidAppId(candidate) ? candidate : undefined - } - catch { - return undefined - } -} - -function readCordovaAppIdFromFile(filePath: string): string | undefined { - if (!existsSync(filePath)) - return undefined - - try { - const content = readFileSync(filePath, 'utf8') - if (filePath.endsWith('config.xml')) - return parseXmlRootId(content, 'widget') - if (filePath.endsWith('plugin.xml')) - return parseXmlRootId(content, 'plugin') - } - catch { - return undefined - } - - return undefined -} - -export function collectCordovaAppIdCandidates(projectRoot = findCordovaProjectRoot(cwd())): string[] { - const candidates = new Set() - - for (const fileName of CORDOVA_CONFIG_FILES) { - const appId = readCordovaAppIdFromFile(join(projectRoot, fileName)) - if (appId) - candidates.add(appId) - } - - return [...candidates] -} - -export function resolveCordovaWebDir(path?: string): string { - return path?.trim() || CORDOVA_DEFAULT_WEB_DIR -} - -export function buildCordovaUploadConfig(options: { path?: string, appId?: string }): ExtConfigPairs { - const projectRoot = findCordovaProjectRoot() - const detectedAppId = collectCordovaAppIdCandidates(projectRoot)[0] - const appId = options.appId?.trim() || detectedAppId || '' - const webDirRelative = resolveCordovaWebDir(options.path) - const webDir = options.path?.trim() - ? webDirRelative - : resolve(projectRoot, webDirRelative) - - const config: CapacitorConfig = { - appId, - appName: 'Cordova App', - webDir, - plugins: {}, - } - - return { - config, - path: '', - } -} diff --git a/cli/src/framework/mode.ts b/cli/src/framework/mode.ts deleted file mode 100644 index 5656dd68bd..0000000000 --- a/cli/src/framework/mode.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const CLI_PROJECT_MODES = ['cordova'] as const - -export function isCordovaMode(mode?: string): boolean { - return mode === 'cordova' -} diff --git a/cli/src/index.ts b/cli/src/index.ts index 0ef4b1ca08..49730ccc5d 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -14,7 +14,6 @@ import { getInfo } from './app/info' import { listApp } from './app/list' import { setApp } from './app/set' import { setSetting } from './app/setting' -import { appTodo } from './app/todo' import { clearCredentialsCommand, listCredentialsCommand, migrateCredentialsCommand, saveCredentialsCommand, updateCredentialsCommand } from './build/credentials-command' import { exportCredentialsCommand, isCredentialsExportInvocation } from './build/credentials-export-command' import { sanitizeCredentialsExportTerminalText, writeCredentialsExportStderr } from './build/credentials-export-terminal' @@ -54,8 +53,6 @@ import { createKey, deleteOldKey, saveKeyCommand } from './key' import { login } from './login' import { startMcpServer } from './mcp/server' import { setupNotifications } from './notifications/setup' -import { startOnboardingChecks } from './onboarding/background' -import { waitForOnboardingChecks } from './onboarding/background-shutdown' import { type ObserveCliOptions, observeCommand } from './observe/command' import { addOrganization, deleteOrganization, listMembers, listOrganizations, setOrganization } from './organization' import { capturePosthogException, getCommandPath, shouldCapturePosthogException } from './posthog' @@ -64,9 +61,8 @@ import { probe } from './probe' import { testRunDeviceCommand } from './run/device' import { CliUserError } from './shared/cli-user-error' import { TwoFactorComplianceNetworkError } from './shared/two-factor-compliance' -import { whoami } from './user/whoami' +import { getUserId } from './user/account' import { formatError } from './utils' -import { CLI_PROJECT_MODES } from './framework/mode' import { normalizeAutoBumpInput } from './versionHelpers' // Common option descriptions used across multiple commands @@ -79,7 +75,6 @@ const optionDescriptions = { capacitorConfig: `Capacitor config source to update (useful with dynamic monorepo configs)`, verbose: `Enable verbose output with detailed logging`, ignoreNotifyAppReady: `Skip notifyAppReady() check (not recommended — updates may roll back)`, - mode: `Project framework mode. Use cordova for Cordova apps without capacitor.config (webDir defaults to www)`, acceptIncompatible: `Accept native-package incompatibility as handled (still checks and warns, continues, skips the crash-warning email). Use this when your app already guards missing plugins at runtime.`, acceptIncompatibleChannel: `Accept native-package incompatibility as handled (still checks and warns, sets the channel instead of failing). Use this when your app already guards missing plugins at runtime.`, } @@ -100,22 +95,17 @@ program enableSupabaseInstrumentation() let currentCommandPath = 'unknown' -let currentActionCommand: Command | undefined program.hook('preAction', (_thisCommand, actionCommand) => { setConfigWriteTarget(resolveCapacitorConfigTargetPath(actionCommand.optsWithGlobals().capacitorConfig, cwd(), { logError: true })) currentCommandPath = getCommandPath(actionCommand) - currentActionCommand = actionCommand setCurrentCliCommand(currentCommandPath) applyCommandAnalyticsOptOut(currentCommandPath, actionCommand.opts()) - startOnboardingChecks(actionCommand, currentCommandPath) const commandContext = extractCommandContext(actionCommand) - if (currentCommandPath === 'login' || currentCommandPath === 'init' || currentCommandPath === 'build init' || currentCommandPath === 'build onboarding') + if (currentCommandPath === 'login' || currentCommandPath === 'init') deferCommandInvocation(currentCommandPath, commandContext) - else { - const optionKey = actionCommand.optsWithGlobals().apikey - trackCommandInvoked(currentCommandPath, commandContext, typeof optionKey === 'string' ? optionKey : undefined) - } + else + trackCommandInvoked(currentCommandPath, commandContext) }) program.hook('postAction', (_thisCommand, actionCommand) => { @@ -247,12 +237,10 @@ Version must be > 0.0.0 and unique. Deleted versions cannot be reused for securi External option: Store only a URL link (useful for apps >200MB or privacy requirements). Capgo never inspects external content. Add encryption for trustless security. -Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production,beta -Cordova example: npx @capgo/cli@latest bundle upload com.example.app --mode cordova --path www --channel production`) +Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production,beta`) .action(handleBundleUploadCommand) .option('-a, --apikey ', optionDescriptions.apikey) - .addOption(new Option('--mode ', optionDescriptions.mode).choices([...CLI_PROJECT_MODES])) - .option('-p, --path ', `Path of the folder to upload, if not provided it will use the webDir set in capacitor.config (or www with --mode cordova)`) + .option('-p, --path ', `Path of the folder to upload, if not provided it will use the webDir set in capacitor.config`) .option('-c, --channel ', `Channel to link to. Use commas for multiple channels, for example production,beta`) .option('--rollout ', `Set the uploaded bundle as this channel's rollout target at a percentage from 0 to 100`, value => Number.parseFloat(value)) .option('--rollout-percentage-bps ', `Set the uploaded bundle rollout percentage in basis points from 0 to 10000`, value => Number.parseInt(value, 10)) @@ -487,19 +475,6 @@ Example: npx @capgo/cli@latest app list`) .option('--supa-host ', optionDescriptions.supaHost) .option('--supa-anon ', optionDescriptions.supaAnon) -app - .command('todo [appId]') - .alias('todoList') - .description(`📋 Show your app's onboarding todo list with done, skipped, and pending tasks. - -Uses the same live progress checks as the Capgo dashboard. The app ID can be inferred from your Capacitor project. - -Example: npx @capgo/cli@latest app todo com.example.app`) - .action(appTodo) - .option('-a, --apikey ', optionDescriptions.apikey) - .option('--supa-host ', optionDescriptions.supaHost) - .option('--supa-anon ', optionDescriptions.supaAnon) - app .command('debug [appId]') .action(debugApp) @@ -718,12 +693,11 @@ const account = program .command('account') .description(`👤 Manage your Capgo account details and retrieve information for support or collaboration.`) -account.command('whoami') - .alias('id') - .description(`🪪 Retrieve your account ID and email address. +account.command('id') + .description(`🪪 Retrieve your account ID, safe to share for collaboration or support purposes in Discord or other platforms. -Example: npx @capgo/cli@latest account whoami`) - .action(whoami) +Example: npx @capgo/cli@latest account id`) + .action(getUserId) .option('-a, --apikey ', optionDescriptions.apikey) const organization = program @@ -1541,8 +1515,6 @@ void (async () => { try { await program.parseAsync() await flushAnalytics() - if (currentActionCommand) - await waitForOnboardingChecks(currentActionCommand, currentCommandPath) } catch (error: unknown) { if (typeof error === 'object' && error !== null && 'code' in error) { diff --git a/cli/src/init/browser-login.ts b/cli/src/init/browser-login.ts index c952359875..9ddbed2679 100644 --- a/cli/src/init/browser-login.ts +++ b/cli/src/init/browser-login.ts @@ -10,12 +10,6 @@ interface BrowserLoginOptions extends SaveKeyOptions { local: boolean } -export interface BrowserLoginSession { - session: string - url: string - browserOpened: boolean -} - interface BrowserLoginEvent { channel: 'user-login' event: 'User CLI login' @@ -66,30 +60,24 @@ export function shouldStartInitBrowserLogin(resolvedKey: string | undefined, int return !resolvedKey && interactive } -export async function beginBrowserLogin( - onUrl: (url: string) => void, +export async function loginInitInBrowser( + options: BrowserLoginOptions, overrides: Partial = {}, -): Promise { +): Promise { const dependencies = { ...defaults, ...overrides } const session = dependencies.createSession() const url = consoleWebUrl(`/login-cli?session=${encodeURIComponent(session)}`) - onUrl(url) + dependencies.writeUrl(`Open this URL to create your CLI key: ${url}`) try { await dependencies.openUrl(url) - return { session, url, browserOpened: true } } catch { - return { session, url, browserOpened: false } + // The printed URL is the fallback when a browser cannot be opened. } -} -export async function completeBrowserLogin( - browserSession: BrowserLoginSession, - key: string, - options: BrowserLoginOptions, - overrides: Partial = {}, -): Promise { - const dependencies = { ...defaults, ...overrides } + const key = await dependencies.promptForKey() + if (!key) + throw new CliUserError('CLI login cancelled') await dependencies.validateKey(key, { local: options.local, supaHost: options.supaHost, @@ -103,27 +91,13 @@ export async function completeBrowserLogin( event: 'User CLI login', tracking_version: 2, org_id: orgId, - description: `cli-login:${browserSession.session}`, + description: `cli-login:${session}`, notifyConsole: true, }))) } catch { // Saving a valid key is the success condition; browser confirmation is best effort. } -} -export async function loginInitInBrowser( - options: BrowserLoginOptions, - overrides: Partial = {}, -): Promise { - const dependencies = { ...defaults, ...overrides } - const session = await beginBrowserLogin( - url => dependencies.writeUrl(`Open this URL to create your CLI key: ${url}`), - dependencies, - ) - const key = await dependencies.promptForKey() - if (!key) - throw new CliUserError('CLI login cancelled') - await completeBrowserLogin(session, key, options, dependencies) return key } diff --git a/cli/src/mcp/server.ts b/cli/src/mcp/server.ts index 317e753f45..582b0a185b 100644 --- a/cli/src/mcp/server.ts +++ b/cli/src/mcp/server.ts @@ -202,7 +202,6 @@ async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Pro async ({ appId, path, - mode, bundle, channel, rollout, @@ -220,7 +219,6 @@ async function startMcpServerInternal(restoreConfigWriteTarget: () => void): Pro const result = await sdk.uploadBundle({ appId, path, - mode, bundle, channel, rollout, diff --git a/cli/src/mcp/tool-schemas.ts b/cli/src/mcp/tool-schemas.ts index 34dbd805a2..1b41375e12 100644 --- a/cli/src/mcp/tool-schemas.ts +++ b/cli/src/mcp/tool-schemas.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { CLI_PROJECT_MODES } from '../framework/mode' import { buildCacheKeyOptionSchema, buildCacheOptionSchema } from '../schemas/build' import { capacitorConfigOptionSchema, observeOptionsObjectSchema, refineObserveDeviceId } from '../schemas/sdk' @@ -23,7 +22,6 @@ export const mcpDeleteAppInputSchema = z.object({ export const mcpUploadBundleInputSchema = z.object({ appId: z.string(), path: z.string(), - mode: z.enum(CLI_PROJECT_MODES).optional().describe('Project framework mode. Use cordova for Cordova apps without capacitor.config'), bundle: z.string().optional(), channel: z.string().optional(), rollout: z.number().min(0).max(100).optional(), diff --git a/cli/src/notify-app-ready-worker.ts b/cli/src/notify-app-ready-worker.ts deleted file mode 100644 index 1875b63843..0000000000 --- a/cli/src/notify-app-ready-worker.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { exit } from 'node:process' -import { workerData } from 'node:worker_threads' -import { runOnboardingCheck, type PreparedOnboardingCheck } from './onboarding/background-check' -import { scanNotifyAppReadySource } from './onboarding/notify-app-ready-source' - -void runOnboardingCheck(workerData as PreparedOnboardingCheck, { - channel: 'notify-app-ready', - step: 'add_code', - scan: scanNotifyAppReadySource, -}).catch(() => { - // Missing projects, parser/config failures, and rejected reports are optional. -}).finally(() => exit(0)) diff --git a/cli/src/onboarding-worker.ts b/cli/src/onboarding-worker.ts deleted file mode 100644 index 44c86fb51a..0000000000 --- a/cli/src/onboarding-worker.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { OnboardingCheckOptions } from './onboarding/background' -import { randomUUID } from 'node:crypto' -import { exit } from 'node:process' -import { Worker, workerData } from 'node:worker_threads' -import { prepareOnboardingCheck } from './onboarding/background-preparation' - -function runScanWorker(workerUrl: URL, data: object): Promise { - return new Promise((resolve) => { - try { - const worker = new Worker(workerUrl, { workerData: data, stdout: true, stderr: true }) - worker.stdout?.destroy() - worker.stderr?.destroy() - worker.on('error', () => {}) - worker.once('exit', () => resolve()) - } - catch { - resolve() - } - }) -} - -async function runOnboardingChecks(options: OnboardingCheckOptions): Promise { - const prepared = await prepareOnboardingCheck(options) - if (!prepared) - return - - const attemptIds = options.attemptIds ?? [options.attemptId ?? randomUUID(), randomUUID()] - await Promise.allSettled([ - runScanWorker(new URL('./notify-app-ready-worker.js', import.meta.url), { ...prepared, attemptId: attemptIds[0] }), - runScanWorker(new URL('./updater-installed-worker.js', import.meta.url), { ...prepared, attemptId: attemptIds[1] }), - ]) -} - -void runOnboardingChecks(workerData as OnboardingCheckOptions).catch(() => { - // Optional onboarding checks must never affect the requested command. -}).finally(() => exit(0)) diff --git a/cli/src/onboarding/background-api.ts b/cli/src/onboarding/background-api.ts deleted file mode 100644 index 07584989ab..0000000000 --- a/cli/src/onboarding/background-api.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { OnboardingCheckOptions } from './background' -import { defaultApiHost } from '../utils' - -function parseApiUrl(value: string): URL | undefined { - try { - const url = new URL(value) - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) - return undefined - return url - } - catch { - return undefined - } -} - -export function isTrustedOnboardingApiHost( - apiHost: string, - options: Pick, - trustedOrigins: readonly string[], -): boolean { - const destination = parseApiUrl(apiHost) - if (!destination) - return false - const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(destination.hostname) - // Trust does not permit sending credentials over remote cleartext transport. - if (destination.protocol !== 'https:' && !loopback) - return false - - if (destination.origin === new URL(defaultApiHost).origin) - return true - // An explicit CLI self-host selection authorizes that origin, not project config alone. - const explicitHost = options.supaHost && options.supaAnon ? parseApiUrl(options.supaHost) : undefined - if (explicitHost?.origin === destination.origin) - return true - return trustedOrigins.some((origin) => { - const trusted = parseApiUrl(origin.trim()) - return trusted?.pathname === '/' && trusted.origin === destination.origin - }) -} diff --git a/cli/src/onboarding/background-check.ts b/cli/src/onboarding/background-check.ts deleted file mode 100644 index 2b6bc09d0c..0000000000 --- a/cli/src/onboarding/background-check.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { OnboardingScanProject } from './notify-app-ready-project' -import { buildCliRequestHeaders, setCurrentCliCommand } from '../analytics/cli-headers' -import { sendEvent, trimTrailingSlashes } from '../utils' - -interface BackgroundOnboardingCheck { - channel: 'notify-app-ready' | 'updater-installed' - step: 'add_code' | 'add_updater' - scan: (project: OnboardingScanProject) => 'found' | 'not_found' | 'unknown' -} - -export interface PreparedOnboardingCheck { - project: OnboardingScanProject - apiHost: string - anonKey?: string - apikey: string - command: string - attemptId: string -} - -export async function runOnboardingCheck(prepared: PreparedOnboardingCheck, check: BackgroundOnboardingCheck): Promise { - const { project, apiHost, anonKey, apikey, command, attemptId } = prepared - setCurrentCliCommand(command) - const trackScan = async (event: 'scan_started' | 'scan_ended', timestamp: number, tags: Record = {}) => { - try { - await sendEvent(apikey, { - channel: check.channel, - event, - tracking_version: 2, - timestamp: new Date(timestamp), - tags: { app_id: project.appId }, - nonPersonTags: { attempt_id: attemptId, command_path: command, ...tags }, - }, false, AbortSignal.timeout(500), apiHost, 'error') - } - catch { - // Scan telemetry must never prevent onboarding detection or todo reporting. - } - } - - await trackScan('scan_started', Date.now()) - const scanStartedAt = Date.now() - let scanEndedAt = scanStartedAt - let result: 'found' | 'not_found' | 'unknown' | 'error' = 'error' - let todoReportStatus = 'not_attempted' - let todoReportHttpStatus: number | undefined - try { - result = check.scan(project) - scanEndedAt = Date.now() - if (result !== 'found') - return - - todoReportStatus = 'failed' - const response = await fetch(`${trimTrailingSlashes(apiHost)}/app/${encodeURIComponent(project.appId)}`, { - method: 'PUT', - headers: buildCliRequestHeaders({ - 'Content-Type': 'application/json', - 'Authorization': apiHost.includes('/functions/v1') && anonKey ? `Bearer ${anonKey}` : apikey, - 'capgkey': apikey, - }), - // Preserve source, outcome, and all unrelated onboarding steps. - body: JSON.stringify({ onboarding: { steps: { [check.step]: { status: 'done' } } } }), - redirect: 'error', - }) - todoReportHttpStatus = response.status - todoReportStatus = response.ok ? 'success' : 'rejected' - await response.body?.cancel() - } - finally { - if (result === 'error') - scanEndedAt = Date.now() - await trackScan('scan_ended', scanEndedAt, { - result, - duration_ms: scanEndedAt - scanStartedAt, - todo_report_status: todoReportStatus, - ...(todoReportHttpStatus === undefined ? {} : { todo_report_http_status: todoReportHttpStatus }), - }) - } -} diff --git a/cli/src/onboarding/background-preparation.ts b/cli/src/onboarding/background-preparation.ts deleted file mode 100644 index 2d35a9b250..0000000000 --- a/cli/src/onboarding/background-preparation.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { OnboardingCheckOptions } from './background' -import type { PreparedOnboardingCheck } from './background-check' -import { env } from 'node:process' -import { defaultApiHost, findSavedKeySilent, isCapgoManagedSupabaseHost, normalizeSupabaseHost, resolveConfiguredCapgoPublicApiHost } from '../utils' -import { isTrustedOnboardingApiHost } from './background-api' -import { resolveNotifyAppReadyProject } from './notify-app-ready-project' - -function hasCustomUpdaterEndpoint(updater: Record | undefined): boolean { - return ['updateUrl', 'statsUrl'].some((field) => { - const value = updater?.[field] - if (value === undefined || value === null || value === '') - return false - if (typeof value !== 'string') - return true - try { - const hostname = new URL(value).hostname - return !['usecapgo.com', 'capgo.app'].some(domain => hostname === domain || hostname.endsWith(`.${domain}`)) - } - catch { - return true - } - }) -} - -export async function prepareOnboardingCheck(options: OnboardingCheckOptions): Promise | undefined> { - // Capture user-provided trust before evaluating executable project config. - const trustedOrigins = env.CAPGO_TRUSTED_API_ORIGINS?.split(',') ?? [] - const apikey = options.apikey ?? findSavedKeySilent() - if (!apikey) - return - const project = await resolveNotifyAppReadyProject(options) - if (!project) - return - - const updater = project.config.plugins?.CapacitorUpdater - if (hasCustomUpdaterEndpoint(updater)) - return - const config = { - hostApi: updater?.localApi || defaultApiHost, - supaHost: updater?.localSupa, - supaKey: updater?.localSupaAnon, - } - const explicitSelfHost = options.supaHost && options.supaAnon && !isCapgoManagedSupabaseHost(options.supaHost) - const apiHost = explicitSelfHost - ? `${normalizeSupabaseHost(options.supaHost!)}/functions/v1` - : resolveConfiguredCapgoPublicApiHost(config) - if (!isTrustedOnboardingApiHost(apiHost, options, trustedOrigins)) - return - const anonKey = options.supaAnon ?? config.supaKey - return { - project: { dir: project.dir, workspaceRoot: project.workspaceRoot, appId: project.appId, webDir: project.webDir }, - apiHost, - anonKey, - apikey, - command: options.command, - } -} diff --git a/cli/src/onboarding/background-shutdown.ts b/cli/src/onboarding/background-shutdown.ts deleted file mode 100644 index f6cd4701d0..0000000000 --- a/cli/src/onboarding/background-shutdown.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Command } from 'commander' -import process from 'node:process' -import { log } from '@clack/prompts' -import { isCI } from 'ci-info' -import { flushAnalytics, trackEvent } from '../analytics/track' -import { getPendingOnboardingChecks } from './background-workers' - -const gracePeriodMs = 5_000 - -// Only commands with human-facing output opt into the shutdown message and wait. -const interactiveCommands = new Set([ - 'doctor', 'login', 'get-qr', - 'app add', 'app delete', 'app list', 'app debug', 'app setting', 'app set', - 'bundle upload', 'bundle compatibility', 'bundle delete', 'bundle list', 'bundle cleanup', - 'bundle encrypt', 'bundle decrypt', 'bundle zip', - 'channel add', 'channel delete', 'channel list', 'channel currentBundle', 'channel set', - 'key save', 'key create', 'key delete_old', - 'organization list', 'organization add', 'organization members', 'organization set', 'organization delete', - 'organisation list', 'organisation add', 'organisation set', 'organisation delete', - 'build request', -]) - -function shouldWaitForOnboardingChecks(commandPath: string, options: Record): boolean { - return !!process.stdin.isTTY && !!process.stdout.isTTY && !isCI - && interactiveCommands.has(commandPath) - && !options.json && !options.outputText && !options.quiet -} - -export async function waitForOnboardingChecks(command: Pick, commandPath: string): Promise { - const pendingChecks = getPendingOnboardingChecks() - if (!shouldWaitForOnboardingChecks(commandPath, command.optsWithGlobals()) || pendingChecks.size === 0) - return - - let timer: ReturnType | undefined - const onInterrupt = () => process.exit(130) - // Take precedence over any command-specific cancellation handler still attached. - process.prependOnceListener('SIGINT', onInterrupt) - try { - log.info('Waiting for background checks to finish (up to 5 seconds). Press Ctrl-C to exit immediately.') - const checks = [...pendingChecks.values()] - const options = command.optsWithGlobals() - void trackEvent({ - channel: 'cli-usage', - event: 'background_checks_wait_started', - apikey: typeof options.apikey === 'string' ? options.apikey : undefined, - appId: typeof options.appId === 'string' ? options.appId : undefined, - timestamp: new Date(), - nonPersonTags: { - command_path: commandPath, - pending_checks: checks.reduce((total, check) => total + check.attemptIds.length, 0), - grace_period_ms: gracePeriodMs, - scan_attempt_ids: checks.flatMap(check => check.attemptIds), - }, - }) - await Promise.race([ - Promise.allSettled([...checks.map(check => check.completion), flushAnalytics(gracePeriodMs)]), - new Promise((resolve) => { - // This timer keeps the process alive while the workers remain unreferenced. - timer = setTimeout(resolve, gracePeriodMs) - }), - ]) - } - finally { - if (timer) - clearTimeout(timer) - process.removeListener('SIGINT', onInterrupt) - // A hanging request must not outlive the shared shutdown budget. - for (const worker of pendingChecks.keys()) - void worker.terminate().catch(() => {}) - // Delivery shares the worker budget; offline telemetry cannot extend shutdown. - await flushAnalytics(0) - } -} diff --git a/cli/src/onboarding/background-workers.ts b/cli/src/onboarding/background-workers.ts deleted file mode 100644 index 0397ecab7a..0000000000 --- a/cli/src/onboarding/background-workers.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Worker } from 'node:worker_threads' - -interface PendingOnboardingCheck { - completion: Promise - attemptId: string - attemptIds: string[] -} - -const pendingChecks = new Map() - -export function registerOnboardingCheck(worker: Worker, attemptIds: string[]): void { - const completion = new Promise((resolve) => { - worker.once('exit', () => { - pendingChecks.delete(worker) - resolve() - }) - }) - pendingChecks.set(worker, { completion, attemptId: attemptIds[0], attemptIds }) -} - -export function getPendingOnboardingChecks(): ReadonlyMap { - return pendingChecks -} diff --git a/cli/src/onboarding/background.ts b/cli/src/onboarding/background.ts deleted file mode 100644 index a3ca10aa05..0000000000 --- a/cli/src/onboarding/background.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Command } from 'commander' -import { randomUUID } from 'node:crypto' -import { cwd } from 'node:process' -import { Worker } from 'node:worker_threads' -import { registerOnboardingCheck } from './background-workers' - -export interface OnboardingCheckOptions { - cwd: string - command: string - attemptId?: string - attemptIds?: string[] - appId?: string - apikey?: string - capacitorConfig?: string - packageJson?: string - mainFile?: string - supaHost?: string - supaAnon?: string -} - -export function startOnboardingCheck(command: Command, commandPath: string, workerUrl: URL, attemptIds?: string[]): void { - try { - const options = command.optsWithGlobals() - const argument = (name: string) => { - const index = command.registeredArguments.findIndex(arg => arg.name() === name) - return index < 0 ? undefined : command.args[index] - } - const text = (value: unknown) => typeof value === 'string' ? value : undefined - const attemptId = attemptIds?.[0] ?? randomUUID() - const workerData: OnboardingCheckOptions = { - cwd: cwd(), - command: commandPath, - attemptId, - attemptIds, - appId: text(options.appId) ?? argument('appId'), - apikey: text(options.apikey) ?? argument('apikey'), - capacitorConfig: text(options.capacitorConfig), - packageJson: text(options.packageJson), - mainFile: text(options.mainFile), - supaHost: text(options.supaHost), - supaAnon: text(options.supaAnon), - } - const worker = new Worker(workerUrl, { workerData, stdout: true, stderr: true }) - // Discovery/config loading must not write into terminal UIs or MCP stdout. - // Discard captured streams so incoming output cannot reference the worker's IPC port. - worker.stdout?.destroy() - worker.stderr?.destroy() - worker.on('error', () => {}) - registerOnboardingCheck(worker, attemptIds ?? [attemptId]) - // Both detection and reporting can be abandoned when the command exits. - worker.unref() - } - catch { - // Optional onboarding detection must never affect the requested command. - } -} - -export function startOnboardingChecks(command: Command, commandPath: string): void { - startOnboardingCheck(command, commandPath, new URL('./onboarding-worker.js', import.meta.url), [randomUUID(), randomUUID()]) -} diff --git a/cli/src/onboarding/notify-app-ready-project.ts b/cli/src/onboarding/notify-app-ready-project.ts deleted file mode 100644 index 969c49f672..0000000000 --- a/cli/src/onboarding/notify-app-ready-project.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { CapacitorConfig } from '../config' -import type { OnboardingCheckOptions } from './background' -import { existsSync, readFileSync, realpathSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { discoverCapacitorProjects, hasCapacitorConfig } from '../build/onboarding/project-discovery' -import { loadConfigTarget } from '../config' -import { getAppId } from '../utils' - -export interface NotifyAppReadyProject { - dir: string - workspaceRoot: string - config: CapacitorConfig - appId: string - webDir?: string -} - -export type OnboardingScanProject = Pick - -function ancestors(dir: string): string[] { - const result: string[] = [] - for (let current = resolve(dir); ; current = dirname(current)) { - result.push(current) - if (current === dirname(current)) - return result - } -} - -function workspaceRoot(dir: string): string { - return ancestors(dir).find((candidate) => { - if (['pnpm-workspace.yaml', 'nx.json', 'lerna.json', 'rush.json'].some(name => existsSync(join(candidate, name)))) - return true - try { - return !!JSON.parse(readFileSync(join(candidate, 'package.json'), 'utf8')).workspaces - } - catch { - return false - } - }) ?? dir -} - -async function readConfig(dir: string): Promise { - const file = ['capacitor.config.ts', 'capacitor.config.js', 'capacitor.config.json'] - .map(name => join(dir, name)) - .find(existsSync) - if (!file) - throw new Error('No Capacitor config') - return loadConfigTarget(file) -} - -function projectDirectory(options: OnboardingCheckOptions, configDir: string, config: CapacitorConfig): string | undefined { - if (options.packageJson) { - const paths = options.packageJson.split(',').map(path => path.trim()).filter(Boolean) - // Multiple metadata files do not identify a unique source app. - if (paths.length !== 1) - return undefined - const path = realpathSync(resolve(options.cwd, paths[0])) - JSON.parse(readFileSync(path, 'utf8')) - return dirname(path) - } - if (options.mainFile) { - const mainFile = realpathSync(resolve(options.cwd, options.mainFile)) - return ancestors(dirname(mainFile)).find(dir => existsSync(join(dir, 'package.json'))) - } - // A dynamic root config can point at web assets in an app workspace. - if (typeof config.webDir === 'string') { - const webDir = resolve(configDir, config.webDir) - const owner = ancestors(webDir === configDir ? webDir : dirname(webDir)) - .find(dir => existsSync(join(dir, 'package.json'))) - if (owner) - return owner - } - return existsSync(join(configDir, 'package.json')) ? configDir : undefined -} - -export async function resolveNotifyAppReadyProject(options: OnboardingCheckOptions): Promise { - const initialDir = realpathSync(options.cwd) - const root = workspaceRoot(initialDir) - const activeDir = ancestors(initialDir).find(hasCapacitorConfig) - let configDir = activeDir - let config: CapacitorConfig | undefined - - if (configDir) { - config = await readConfig(configDir) - } - else if (options.capacitorConfig) { - const path = realpathSync(resolve(initialDir, options.capacitorConfig)) - configDir = dirname(path) - config = await loadConfigTarget(path) - } - else { - const discovery = await discoverCapacitorProjects(root) - const selectedSource = options.packageJson || options.mainFile - ? projectDirectory(options, root, {} as CapacitorConfig) - : undefined - if ((options.packageJson || options.mainFile) && !selectedSource) - return undefined - // Select statically before evaluating any executable workspace config. - const matches = discovery.candidates.filter(candidate => selectedSource - ? realpathSync(candidate.dir) === selectedSource - : !options.appId || candidate.appId === options.appId) - if (matches.length !== 1) - return undefined - configDir = matches[0].dir - try { - config = await readConfig(configDir) - } - catch { - return undefined - } - } - - const appId = getAppId(undefined, config) - if (typeof appId !== 'string' || !appId.trim() || (options.appId && options.appId !== appId) - || (config.webDir !== undefined && (typeof config.webDir !== 'string' || !config.webDir.trim()))) { - return undefined - } - - if (activeDir && options.capacitorConfig) { - const target = await loadConfigTarget(realpathSync(resolve(initialDir, options.capacitorConfig))) - // A write target must not silently select a different app than the root loader. - if (getAppId(undefined, target) !== appId) - return undefined - } - - const dir = projectDirectory(options, configDir, config) - if (!dir) - return undefined - // If source selection points at another Capacitor app, do not report for this one. - if (dir !== configDir && hasCapacitorConfig(dir) && getAppId(undefined, await readConfig(dir)) !== appId) - return undefined - return { - dir: realpathSync(dir), - workspaceRoot: workspaceRoot(realpathSync(dir)), - config, - appId, - webDir: resolve(configDir, config.webDir ?? 'www'), - } -} diff --git a/cli/src/onboarding/notify-app-ready-source.ts b/cli/src/onboarding/notify-app-ready-source.ts deleted file mode 100644 index 748ebfc511..0000000000 --- a/cli/src/onboarding/notify-app-ready-source.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { OnboardingScanProject } from './notify-app-ready-project' -import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' -import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path' -import { NodeTypes, parse as parseVue } from '@vue/compiler-dom' -import ts from 'typescript' - -const UPDATER_PACKAGE = '@capgo/capacitor-updater' -const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?|vue)$/ -const EXCLUDED_DIRECTORY = /^(?:\..*|node_modules|dist|build|www|coverage|android|ios|test|tests|__tests__|__mocks__|fixtures|__fixtures__|e2e|cypress|playwright|scripts)$/ -const EXCLUDED_FILE = /\.(?:test|spec|d)\.[cm]?[jt]sx?$|^capacitor\.config\./ - -function contained(root: string, path: string): boolean { - const fromRoot = relative(root, path) - return !isAbsolute(fromRoot) && fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`) -} - -function compilerOptions(dir: string): ts.CompilerOptions { - const path = ts.findConfigFile(dir, ts.sys.fileExists) - if (!path) - return {} - const config = ts.readConfigFile(path, ts.sys.readFile) - if (config.error) - return {} - return ts.parseJsonConfigFileContent({ ...config.config, files: [], include: [] }, ts.sys, dirname(path)).options -} - -function vueScripts(content: string): string { - // Only script blocks are JavaScript; markup/comments must not complete a todo. - const root = parseVue(content, { parseMode: 'sfc', onError: error => { throw error } }) - return root.children.flatMap((node) => { - if (node.type !== NodeTypes.ELEMENT || node.tag !== 'script') - return [] - return node.children.flatMap(child => child.type === NodeTypes.TEXT ? [child.content] : []) - }).join('\n') -} - -function importDeclaration(node: ts.Node): ts.ImportDeclaration | undefined { - for (let parent: ts.Node | undefined = node.parent; parent; parent = parent.parent) { - if (ts.isImportDeclaration(parent)) - return parent - } - return undefined -} - -function updaterImport(node: ts.Node): boolean { - const declaration = importDeclaration(node) - return !!declaration && ts.isStringLiteral(declaration.moduleSpecifier) - && declaration.moduleSpecifier.text === UPDATER_PACKAGE - && !declaration.importClause?.isTypeOnly -} - -function isUpdaterReference(node: ts.Expression, checker: ts.TypeChecker): boolean { - if (ts.isIdentifier(node)) { - return !!checker.getSymbolAtLocation(node)?.declarations?.some((declaration) => { - if (ts.isImportSpecifier(declaration)) { - return !declaration.isTypeOnly && (declaration.propertyName ?? declaration.name).text === 'CapacitorUpdater' - && updaterImport(declaration) - } - if (!ts.isBindingElement(declaration) || !ts.isObjectBindingPattern(declaration.parent)) - return false - const variable = declaration.parent.parent - const name = declaration.propertyName ?? declaration.name - if (!ts.isIdentifier(name) || name.text !== 'CapacitorUpdater' || !ts.isVariableDeclaration(variable)) - return false - const initializer = variable.initializer - return !!initializer && ts.isCallExpression(initializer) - && ts.isIdentifier(initializer.expression) && initializer.expression.text === 'require' - && !checker.getSymbolAtLocation(initializer.expression) - && initializer.arguments.length === 1 && ts.isStringLiteral(initializer.arguments[0]) - && initializer.arguments[0].text === UPDATER_PACKAGE - && ts.isVariableDeclarationList(variable.parent) && !!(variable.parent.flags & ts.NodeFlags.Const) - }) - } - if (ts.isPropertyAccessExpression(node) && node.name.text === 'CapacitorUpdater' && ts.isIdentifier(node.expression)) { - return !!checker.getSymbolAtLocation(node.expression)?.declarations?.some(declaration => - ts.isNamespaceImport(declaration) && updaterImport(declaration), - ) - } - return false -} - -function hasCall(source: ts.SourceFile, checker: ts.TypeChecker): boolean { - function visit(node: ts.Node): boolean { - if (ts.isCallExpression(node)) { - const callee = node.expression - if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'notifyAppReady' - && isUpdaterReference(callee.expression, checker)) { - return true - } - if (ts.isElementAccessExpression(callee) && ts.isStringLiteral(callee.argumentExpression) - && callee.argumentExpression.text === 'notifyAppReady' && isUpdaterReference(callee.expression, checker)) { - return true - } - } - return ts.forEachChild(node, visit) ?? false - } - return visit(source) -} - -export function scanNotifyAppReadySource(project: OnboardingScanProject): 'found' | 'not_found' | 'unknown' { - try { - const deadline = Date.now() + 5_000 - const contents = new Map() - const originalPaths = new Map() - const seen = new Set() - const options = compilerOptions(project.dir) - let bytes = 0 - const checkBudget = () => { - if (Date.now() > deadline || seen.size > 10_000 || bytes > 20 * 1024 * 1024) - throw new Error('Source scan budget exceeded') - } - function addFile(path: string): void { - checkBudget() - if (!SOURCE_EXTENSION.test(path) || EXCLUDED_FILE.test(basename(path))) - return - const canonical = realpathSync(path) - if (seen.has(canonical) || !contained(project.workspaceRoot, canonical) - || relative(project.workspaceRoot, canonical).split(sep).some(part => EXCLUDED_DIRECTORY.test(part)) - || (project.webDir && project.webDir !== project.dir && contained(project.webDir, canonical))) { - return - } - seen.add(canonical) - const size = statSync(canonical).size - if (size > 1024 * 1024) - throw new Error('Source file too large') - bytes += size - checkBudget() - const content = readFileSync(canonical, 'utf8') - const normalizedPath = canonical.split(sep).join('/') - const virtualPath = extname(canonical) === '.vue' ? `${normalizedPath}.tsx` : normalizedPath - const script = extname(canonical) === '.vue' ? vueScripts(content) : content - contents.set(virtualPath, script) - originalPaths.set(virtualPath, canonical) - } - function walk(dir: string): void { - checkBudget() - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name) - if (entry.isDirectory()) { - // Other workspace packages/apps are only followed through imports. - if (!EXCLUDED_DIRECTORY.test(entry.name) && !existsSync(join(path, 'package.json'))) - walk(path) - } - else if (entry.isFile()) { - addFile(path) - } - } - } - walk(project.dir) - - // Follow local shared modules, including tsconfig paths and workspace symlinks. - for (const [virtualPath, content] of contents) { - checkBudget() - const original = originalPaths.get(virtualPath)! - for (const imported of ts.preProcessFile(content, true, true).importedFiles) { - const name = imported.fileName - if (name === UPDATER_PACKAGE) - continue - const resolved = ts.resolveModuleName(name, original, options, ts.sys).resolvedModule?.resolvedFileName - ?? (name.startsWith('.') && SOURCE_EXTENSION.test(name) ? resolve(dirname(original), name) : undefined) - if (resolved && existsSync(resolved)) - addFile(resolved) - } - } - - const parseOptions: ts.CompilerOptions = { allowJs: true, noLib: true, noResolve: true, types: [], jsx: ts.JsxEmit.Preserve } - const host = ts.createCompilerHost(parseOptions) - host.getSourceFile = (path, languageVersion) => { - const content = contents.get(path) - return content === undefined ? undefined : ts.createSourceFile(path, content, languageVersion, true) - } - const program = ts.createProgram([...contents.keys()], parseOptions, host) - const checker = program.getTypeChecker() - let unknown = false - for (const source of program.getSourceFiles()) { - checkBudget() - if (program.getSyntacticDiagnostics(source).length) { - unknown = true - continue - } - if (hasCall(source, checker)) - return 'found' - } - return unknown ? 'unknown' : 'not_found' - } - catch { - return 'unknown' - } -} diff --git a/cli/src/onboarding/updater-installed.ts b/cli/src/onboarding/updater-installed.ts deleted file mode 100644 index 44148e4e1f..0000000000 --- a/cli/src/onboarding/updater-installed.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { OnboardingScanProject } from './notify-app-ready-project' -import { join } from 'node:path' -import { getUpdaterInstallState } from '../init/updater' - -export function scanUpdaterInstalled(project: OnboardingScanProject): 'found' | 'not_found' { - // A declaration alone, or another app's hoisted dependency, is insufficient. - return getUpdaterInstallState(join(project.dir, 'package.json')).ready ? 'found' : 'not_found' -} diff --git a/cli/src/recovery/app-id.ts b/cli/src/recovery/app-id.ts index adb41f3a85..8079547df3 100644 --- a/cli/src/recovery/app-id.ts +++ b/cli/src/recovery/app-id.ts @@ -5,10 +5,9 @@ import { join } from 'node:path' import { cwd } from 'node:process' import { confirm as pConfirm, isCancel as pIsCancel, log, select as pSelect, text as pText } from '@clack/prompts' import { trackEvent } from '../analytics/track' -import { addAppInternal, resolveAppGettingStartedMessage } from '../app/add' +import { addAppInternal } from '../app/add' import { getAppListPath } from '../app/list' import { extractApplicationIds } from '../build/onboarding/android/gradle-parser' -import { collectCordovaAppIdCandidates } from '../cordova/project' import { createSupabaseClient, findRoot, findSavedKeySilent, formatError, getAppId, getConfigForWrite, getOrganizationWithPermission, invokeCapgoCliApi, PACKNAME } from '../utils' import { writeConfigUpdater } from '../config' @@ -70,9 +69,6 @@ export function collectAppIdCandidates( } } - for (const cordovaAppId of collectCordovaAppIdCandidates(projectRoot)) - push(cordovaAppId) - return [...candidates] } @@ -279,12 +275,6 @@ export async function resolveAppIdWithRecovery(options: ResolveAppIdOptions): Pr await addAppInternal(appId, { apikey: resolvedApikey, supaHost: options.supaHost, supaAnon: options.supaAnon }, organization, true) await persistAppIdToConfig(appId) log.success(`Created app ${appId} in Capgo`) - const gettingStartedMessage = await resolveAppGettingStartedMessage(appId, { - supaHost: options.supaHost, - supaAnon: options.supaAnon, - }) - if (gettingStartedMessage) - log.info(gettingStartedMessage) trackAppIdRecovery(appId, 'create-app', resolvedApikey) return appId } diff --git a/cli/src/schemas/bundle.ts b/cli/src/schemas/bundle.ts index b65d701c7f..237f6bc17a 100644 --- a/cli/src/schemas/bundle.ts +++ b/cli/src/schemas/bundle.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { CLI_PROJECT_MODES } from '../framework/mode' import { optionsBaseSchema } from './base' // ============================================================================ @@ -7,7 +6,6 @@ import { optionsBaseSchema } from './base' // ============================================================================ export const optionsUploadSchema = optionsBaseSchema.extend({ - mode: z.enum(CLI_PROJECT_MODES).optional(), bundle: z.string().optional(), path: z.string().optional(), channel: z.string().optional(), diff --git a/cli/src/schemas/sdk.ts b/cli/src/schemas/sdk.ts index 0feb50f923..642fec4ba4 100644 --- a/cli/src/schemas/sdk.ts +++ b/cli/src/schemas/sdk.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { CLI_PROJECT_MODES } from '../framework/mode' import { buildCacheKeyOptionSchema, buildCacheOptionSchema, buildCredentialsSchema } from './build' import { localizedReleaseNotesSchema, rejectConflictingBooleanGroup } from './common' @@ -78,7 +77,6 @@ export type StarAllRepositoriesOptions = z.infer { - // Missing projects, config failures, and rejected reports are optional. -}).finally(() => exit(0)) diff --git a/cli/src/user/account.ts b/cli/src/user/account.ts index 9066f6d705..58362732cc 100644 --- a/cli/src/user/account.ts +++ b/cli/src/user/account.ts @@ -39,3 +39,7 @@ export async function getUserIdInternal(options: Options, silent = false) { throw error instanceof Error ? error : new Error(String(error)) } } + +export async function getUserId(options: Options) { + await getUserIdInternal(options, false) +} diff --git a/cli/src/user/whoami.ts b/cli/src/user/whoami.ts deleted file mode 100644 index e4bd2d6078..0000000000 --- a/cli/src/user/whoami.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js' -import type { Options } from '../api/app' -import type { Database } from '../types/supabase.types' -import { intro, log, outro } from '@clack/prompts' -import { trackEvent } from '../analytics/track' -import { formatTable } from '../terminal-table' -import { createSupabaseClient, findSavedKey, formatError, resolveUserIdFromApiKey } from '../utils' - -export async function resolveAccountEmail(supabase: SupabaseClient): Promise { - const emailResult = await supabase.rpc('request_actor_email_adress') - if (emailResult.error) - throw emailResult.error - if (!emailResult.data) - throw new Error('Account email not found for this API key') - - return emailResult.data -} - -export async function resolveAccountIdentity(supabase: SupabaseClient, apikey: string) { - const [userId, email] = await Promise.all([ - resolveUserIdFromApiKey(supabase, apikey, true), - resolveAccountEmail(supabase), - ]) - - return { userId, email } -} - -export async function whoami(options: Options) { - intro('Account details') - const apikey = options.apikey || findSavedKey() - if (!apikey) { - log.error('Missing API key, you need to provide an API key to fetch account details') - throw new Error('Missing API key') - } - - try { - const supabase = await createSupabaseClient(apikey, options.supaHost, options.supaAnon) - const { userId, email } = await resolveAccountIdentity(supabase, apikey) - - log.info(formatTable({ - headers: ['Account ID', 'Account email'], - rows: [[userId, email]], - })) - void trackEvent({ channel: 'account', event: 'Account Identity Viewed', apikey, tags: {} }) - outro('Done ✅') - } - catch (error) { - log.error(`Error getting account details ${formatError(error)}`) - throw error instanceof Error ? error : new Error(String(error)) - } -} diff --git a/cli/src/utils.ts b/cli/src/utils.ts index f943a66fb5..4d8124c426 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -653,10 +653,8 @@ function isPresentCapacitorConfig(extConfig: ExtConfigPairs | undefined): extCon return !!extConfig.path && existsSync(extConfig.path) } -export const NO_CAPACITOR_CONFIG_MESSAGE = 'No capacitor config file found, run `cap init` first' - async function getConfigFrom(loader: () => Promise, silent = false): Promise { - const message = NO_CAPACITOR_CONFIG_MESSAGE + const message = 'No capacitor config file found, run `cap init` first' try { const extConfig = await loader() if (!isPresentCapacitorConfig(extConfig)) { @@ -1091,8 +1089,7 @@ export async function createSupabaseClient(apikey: string, supaHost?: string, su config.supaKey = supaKey } if (!config.supaHost || !config.supaKey) { - if (!silent) - log.error(CAPGO_SERVER_CONFIG_MISSING_MESSAGE) + log.error(CAPGO_SERVER_CONFIG_MISSING_MESSAGE) throw new CliUserError(CAPGO_SERVER_CONFIG_MISSING_MESSAGE, { missingSupaHost: !config.supaHost, missingSupaKey: !config.supaKey, @@ -1995,7 +1992,7 @@ type SendEventPayload = TrackOptions & { nonPersonTags?: Record | { notifyConsole?: false, icon?: never } ) -export async function sendEvent(capgkey: string, payload: SendEventPayload, verbose?: boolean, signal?: AbortSignal, apiHost?: string, redirect?: RequestInit['redirect']): Promise { +export async function sendEvent(capgkey: string, payload: SendEventPayload, verbose?: boolean, signal?: AbortSignal): Promise { const telemetryDisabled = isTruthyEnvValue(env.CAPGO_DISABLE_TELEMETRY) || isTruthyEnvValue(env.CAPGO_DISABLE_POSTHOG) if (telemetryDisabled && !payload.notifyConsole) return @@ -2022,9 +2019,9 @@ export async function sendEvent(capgkey: string, payload: SendEventPayload, verb if (verbose) { log.info(`Get remove config: for ${payload.event}`) } - // A resolved destination avoids rediscovering config in background workers. - // Fetch config silently when needed so telemetry cannot interrupt terminal UIs. - const hostApi = apiHost ?? (await getRemoteConfig(true, signal)).hostApi + // Always fetch remote config silently — sendEvent is telemetry and must + // not bypass an Ink-controlled stdout (e.g. during `capgo init`). + const config = await getRemoteConfig(true, signal) if (verbose) { log.info(`Sending analytics event: ${JSON.stringify(enrichedPayload)}`) } @@ -2037,7 +2034,7 @@ export async function sendEvent(capgkey: string, payload: SendEventPayload, verb : controller.signal try { - const fetchResponse = await fetch(`${trimTrailingSlashes(hostApi)}/private/events`, { + const fetchResponse = await fetch(`${config.hostApi}/private/events`, { method: 'POST', body: JSON.stringify(enrichedPayload), headers: buildCliRequestHeaders({ @@ -2045,7 +2042,6 @@ export async function sendEvent(capgkey: string, payload: SendEventPayload, verb 'capgkey': capgkey, }), signal: eventSignal, - redirect, }) clearTimeout(timeoutId) diff --git a/cli/test/init/browser-login.test.ts b/cli/test/init/browser-login.test.ts index 5321d99351..2dd9ed8374 100644 --- a/cli/test/init/browser-login.test.ts +++ b/cli/test/init/browser-login.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it, mock } from 'bun:test' -import { beginBrowserLogin, completeBrowserLogin, loginInitInBrowser, shouldStartInitBrowserLogin } from '../../src/init/browser-login' +import { loginInitInBrowser, shouldStartInitBrowserLogin } from '../../src/init/browser-login' import * as loginModule from '../../src/login' const helperSource = readFileSync(new URL('../../src/init/browser-login.ts', import.meta.url), 'utf8') @@ -11,35 +11,6 @@ const initRuntimeSource = readFileSync(new URL('../../src/init/runtime.tsx', imp const initComponentsSource = readFileSync(new URL('../../src/init/ui/components.tsx', import.meta.url), 'utf8') describe('init browser login', () => { - it('keeps a browser session when opening the browser fails', async () => { - const urls: string[] = [] - const session = await beginBrowserLogin(url => urls.push(url), { - createSession: () => 'browser-session', - openUrl: async () => { throw new Error('browser unavailable') }, - }) - - expect(session).toEqual({ - session: 'browser-session', - url: 'https://console.capgo.app/login-cli?session=browser-session', - browserOpened: false, - }) - expect(urls).toEqual([session.url]) - }) - - it('completes the same session after validating the key', async () => { - const order: string[] = [] - await completeBrowserLogin({ - session: 'browser-session', - url: 'https://console.capgo.app/login-cli?session=browser-session', - browserOpened: true, - }, 'fake-key', { local: false }, { - validateKey: async () => { order.push('validate'); return { userId: 'user-1' } }, - listOrganizationIds: async () => { order.push('organizations'); return ['org-1'] }, - sendEvent: async (_key, payload) => { order.push(payload.description) }, - }) - expect(order).toEqual(['validate', 'organizations', 'cli-login:browser-session']) - }) - it('starts only for an interactive init with no resolved key', () => { expect(shouldStartInitBrowserLogin('', true)).toBe(true) expect(shouldStartInitBrowserLogin('argument-or-saved-key', true)).toBe(false) diff --git a/cli/test/prescan/checks-ios-entitlements-config.test.ts b/cli/test/prescan/checks-ios-entitlements-config.test.ts index ee1d67d2de..60b94de2ee 100644 --- a/cli/test/prescan/checks-ios-entitlements-config.test.ts +++ b/cli/test/prescan/checks-ios-entitlements-config.test.ts @@ -98,19 +98,6 @@ describe('ios/entitlements-vs-profile-capability', () => { expect(entitlementsVsProfileCapability.appliesTo?.(ctx)).toBe(false) }) - it('matches native iOS entitlements and profiles when the Builder app ID differs', async () => { - const ctx = ctxWithEntitlements( - 'com.apple.developer.healthkit', - { - appId: 'com.example.builder', - nativeAppId: 'com.demo.app', - credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('')) }, - }, - ) - expect(entitlementsVsProfileCapability.appliesTo?.(ctx)).toBe(true) - expect((await entitlementsVsProfileCapability.run(ctx))[0]?.severity).toBe('error') - }) - it('errors when the app declares a capability the profile does not grant', async () => { const ctx = ctxWithEntitlements( 'com.apple.developer.healthkit', diff --git a/cli/test/test-analytics.mjs b/cli/test/test-analytics.mjs index b951957f42..98830d0d11 100644 --- a/cli/test/test-analytics.mjs +++ b/cli/test/test-analytics.mjs @@ -46,8 +46,7 @@ try { delete process.env.CAPGO_DISABLE_TELEMETRY delete process.env.CAPGO_DISABLE_POSTHOG let requests = stubFetch() - const timestamp = new Date('2026-01-01T12:00:00Z') - await trackEvent({ apikey: 'capgo-key', channel: 'cli-usage', event: 'Test Event', orgId: 'org-1', appId: 'com.example.app', timestamp, tags: { foo: 'bar', count: 3, flag: true }, nonPersonTags: { scan_attempt_ids: ['example-attempt'] } }) + await trackEvent({ apikey: 'capgo-key', channel: 'cli-usage', event: 'Test Event', orgId: 'org-1', appId: 'com.example.app', tags: { foo: 'bar', count: 3, flag: true } }) await flushAnalytics() const req = findEvent(requests) assert.ok(req, 'expected a /private/events request') @@ -68,9 +67,6 @@ try { assert.equal(body.tags.flag, true) assert.equal(body.nonPersonTags.invocation_source, 'cli') assert.equal(typeof body.nonPersonTags.cli_version, 'string') - assert.deepEqual(body.nonPersonTags.scan_attempt_ids, ['example-attempt']) - assert.equal(body.tags.scan_attempt_ids, undefined, 'scan IDs are event properties only') - assert.equal(body.timestamp, timestamp.toISOString()) // 3. opt-out suppresses the send process.env.CAPGO_DISABLE_TELEMETRY = '1' @@ -136,12 +132,6 @@ try { assert.equal(body.tags.flags_count, 2) assert.equal(body.tags.positional_arg_count, 1) - requests = stubFetch() - trackCommandInvoked('app todo', ctx, 'explicit-todo-key') - await flushAnalytics() - assert.equal(findEvent(requests).init.headers.capgkey, 'explicit-todo-key', 'explicit --apikey takes precedence over a saved key') - assert.equal(JSON.stringify(JSON.parse(findEvent(requests).init.body).tags).includes('explicit-todo-key'), false, 'API keys must not appear in analytics tags') - // 6b. login/init defer invocation until an explicitly validated key is available process.env.CAPGO_TOKEN = 'stale-key' requests = stubFetch() diff --git a/cli/test/test-android-keystore-action.mjs b/cli/test/test-android-keystore-action.mjs deleted file mode 100644 index 3ed7595570..0000000000 --- a/cli/test/test-android-keystore-action.mjs +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict' -import { Buffer } from 'node:buffer' -import { runAndroidEffect } from '../src/build/onboarding/android/flow.ts' -import { trackAndroidKeystorePreparationFailure, trackPreparedAndroidKeystore } from '../src/build/onboarding/android/ui/keystore-action.ts' - -const journeyId = 'bj_keystore_test' -const actions = [] -const trackAction = (action, tags, step) => actions.push({ action, tags, step }) -const reportedSuccesses = new Set() - -const generatedProgress = () => ({ - appId: 'com.example.keystore', - platform: 'android', - startedAt: '2026-01-01T00:00:00.000Z', - keystoreMethod: 'generate', - keystoreAlias: 'release', - keystoreStorePassword: 'PRIVATE_STORE_PASSWORD', - keystoreKeyPassword: 'PRIVATE_KEY_PASSWORD', - keystoreCommonName: 'Private Customer Name', - completedSteps: {}, -}) - -const generatedOrder = [] -const generated = await runAndroidEffect('keystore-generating', generatedProgress(), { - generateKeystore: () => ({ - p12Base64: 'PRIVATE_GENERATED_KEYSTORE', - p12Bytes: Buffer.from('PRIVATE_GENERATED_KEYSTORE'), - alias: 'release', - notAfter: new Date('2050-01-01T00:00:00.000Z'), - }), - saveAndroidProgress: async (_appId, saved) => { - assert.equal(saved._keystoreBase64, 'PRIVATE_GENERATED_KEYSTORE') - assert.equal(saved.completedSteps.keystoreReady.isGenerated, true) - generatedOrder.push('saved-progress') - }, -}) -trackPreparedAndroidKeystore(generated.progress, 'generated', 'generated_with_keystore', journeyId, (...args) => { - generatedOrder.push('tracked-action') - trackAction(...args) -}, reportedSuccesses) -trackPreparedAndroidKeystore(generated.progress, 'generated', 'generated_with_keystore', journeyId, trackAction, reportedSuccesses) -assert.deepEqual(generatedOrder, ['saved-progress', 'tracked-action']) -assert.deepEqual(actions.splice(0), [{ - action: 'keystore_prepared', - tags: { - attempt_id: journeyId, - source: 'generated', - key_password: 'generated_with_keystore', - }, - step: 'keystore-generating', -}]) - -const importedProgress = keyPassword => ({ - appId: 'com.example.keystore', - platform: 'android', - startedAt: '2026-01-01T00:00:00.000Z', - keystoreMethod: 'existing', - keystoreExistingPath: '/private/customer-upload-key.p12', - keystoreAlias: 'private-customer-alias', - keystoreStorePassword: 'PRIVATE_STORE_PASSWORD', - ...(keyPassword ? { keystoreKeyPassword: keyPassword } : {}), - completedSteps: {}, -}) - -async function prepareImported(progress, probeResult) { - let persisted = null - const order = [] - const result = await runAndroidEffect('keystore-existing-key-password', progress, { - readFile: async () => Buffer.from('PRIVATE_IMPORTED_KEYSTORE'), - tryUnlockPrivateKey: () => probeResult, - saveAndroidProgress: async (_appId, saved) => { - persisted = saved - order.push('saved-progress') - }, - loadAndroidProgress: async () => persisted, - }) - return { result, order } -} - -const verifiedImport = await prepareImported(importedProgress(), { ok: true }) -trackPreparedAndroidKeystore(verifiedImport.result.progress, 'imported', 'verified', journeyId, (...args) => { - verifiedImport.order.push('tracked-action') - trackAction(...args) -}, reportedSuccesses) -assert.deepEqual(verifiedImport.order, ['saved-progress', 'tracked-action']) -assert.deepEqual(actions.splice(0), [{ - action: 'keystore_prepared', - tags: { - attempt_id: journeyId, - source: 'imported', - key_password: 'verified', - }, - step: 'keystore-existing-key-password', -}]) - -// A separately entered key password is valid preparation input even though a -// build has not verified it yet. -const separateSuccesses = new Set() -const separateImport = await prepareImported(importedProgress('PRIVATE_SEPARATE_KEY_PASSWORD'), { - ok: false, - reason: 'wrong-password', - message: 'PRIVATE_RAW_PROBE_ERROR', -}) -trackPreparedAndroidKeystore(separateImport.result.progress, 'imported', 'not_checked', journeyId, trackAction, separateSuccesses) -assert.deepEqual(actions.splice(0), [{ - action: 'keystore_prepared', - tags: { - attempt_id: journeyId, - source: 'imported', - key_password: 'not_checked', - }, - step: 'keystore-existing-key-password', -}]) - -// A failed same-password probe only opens the separate key-password question. -// It is not a preparation failure. -const promptResult = await runAndroidEffect('keystore-existing-key-password', importedProgress(), { - readFile: async () => Buffer.from('PRIVATE_IMPORTED_KEYSTORE'), - tryUnlockPrivateKey: () => ({ - ok: false, - reason: 'wrong-password', - message: 'PRIVATE_RAW_PROBE_ERROR', - }), -}) -assert.equal(promptResult.next, 'keystore-existing-key-password') -assert.equal(promptResult.transient.needsKeyPasswordPrompt, true) -assert.equal(actions.length, 0) - -// Genuine generation and import work failures emit safe categorical outcomes. -await assert.rejects(() => runAndroidEffect('keystore-generating', generatedProgress(), { - generateKeystore: () => { throw new Error('PRIVATE_GENERATION_ERROR') }, -}), /PRIVATE_GENERATION_ERROR/) -trackAndroidKeystorePreparationFailure('generated', 'generated_with_keystore', 'generate_failed', journeyId, trackAction) - -await assert.rejects(() => runAndroidEffect('keystore-existing-key-password', importedProgress('PRIVATE_SEPARATE_KEY_PASSWORD'), { - readFile: async () => { throw new Error('PRIVATE_IMPORT_ERROR') }, -}), /PRIVATE_IMPORT_ERROR/) -trackAndroidKeystorePreparationFailure('imported', 'not_checked', 'import_failed', journeyId, trackAction) -assert.deepEqual(actions.splice(0), [ - { - action: 'keystore_preparation_failed', - tags: { - attempt_id: journeyId, - source: 'generated', - reason: 'generate_failed', - key_password: 'generated_with_keystore', - }, - step: 'keystore-generating', - }, - { - action: 'keystore_preparation_failed', - tags: { - attempt_id: journeyId, - source: 'imported', - reason: 'import_failed', - key_password: 'not_checked', - }, - step: 'keystore-existing-key-password', - }, -]) - -// A failed attempt does not reserve the success key, so a retry can still -// report preparation. -trackAndroidKeystorePreparationFailure('generated', 'generated_with_keystore', 'generate_failed', journeyId, trackAction) -const retrySuccesses = new Set() -trackPreparedAndroidKeystore(generated.progress, 'generated', 'generated_with_keystore', journeyId, trackAction, retrySuccesses) -assert.deepEqual(actions.map(event => event.action), ['keystore_preparation_failed', 'keystore_prepared']) - -const safeEvents = JSON.stringify(actions) -for (const secret of [ - 'PRIVATE_STORE_PASSWORD', - 'PRIVATE_KEY_PASSWORD', - 'PRIVATE_SEPARATE_KEY_PASSWORD', - 'PRIVATE_GENERATED_KEYSTORE', - 'PRIVATE_RAW_PROBE_ERROR', - 'PRIVATE_GENERATION_ERROR', - 'PRIVATE_IMPORT_ERROR', - 'private-customer-alias', - '/private/customer-upload-key.p12', -]) { - assert.equal(safeEvents.includes(secret), false, `telemetry leaked ${secret}`) -} -actions.length = 0 - -// Incomplete data or an unsaved marker never counts as prepared. -trackPreparedAndroidKeystore({ - ...generated.progress, - completedSteps: {}, -}, 'generated', 'generated_with_keystore', journeyId, trackAction, new Set()) -assert.equal(actions.length, 0) -assert.doesNotThrow(() => trackAndroidKeystorePreparationFailure( - 'imported', - 'not_checked', - 'import_failed', - journeyId, - () => { throw new Error('telemetry unavailable') }, -)) - -console.log('✅ Android keystore actions follow persisted outcomes and use safe tags') diff --git a/cli/test/test-app-add-getting-started.mjs b/cli/test/test-app-add-getting-started.mjs deleted file mode 100644 index ce111b9481..0000000000 --- a/cli/test/test-app-add-getting-started.mjs +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict' -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import process from 'node:process' -import { - appGettingStartedUrl, - formatAppGettingStartedMessage, - resolveAppGettingStartedMessage, - shouldPrintAppGettingStartedUrl, -} from '../src/app/add.ts' -import { defaultHostWeb } from '../src/utils.ts' - -console.log('🧪 Testing app add getting-started URL...\n') - -const appId = 'com.example.app' -const expectedUrl = `${defaultHostWeb}/app/${appId}/getting-started` - -assert.equal(appGettingStartedUrl(appId), expectedUrl) -assert.equal(appGettingStartedUrl(appId, 'https://dashboard.example.com/'), `https://dashboard.example.com/app/${appId}/getting-started`) -assert.equal( - formatAppGettingStartedMessage(appId), - `Continue setup at ${expectedUrl}`, -) - -assert.equal(shouldPrintAppGettingStartedUrl(defaultHostWeb, false), true) -assert.equal(shouldPrintAppGettingStartedUrl(defaultHostWeb, true), false) -assert.equal(shouldPrintAppGettingStartedUrl(`${defaultHostWeb}/`, true), false) -assert.equal(shouldPrintAppGettingStartedUrl('https://dashboard.example.com', true), true) - -const tempDirs = [] -function makeTempDir(name) { - const dir = realpathSync(mkdtempSync(join(tmpdir(), `capgo-cli-getting-started-${name}-`))) - tempDirs.push(dir) - return dir -} - -function writeCapacitorConfig(root, updater = {}) { - writeFileSync(join(root, 'capacitor.config.json'), JSON.stringify({ - appId, - appName: 'demo', - webDir: 'www', - plugins: { CapacitorUpdater: updater }, - }, null, 2)) -} - -async function withTempProject(name, updater, fn) { - const root = makeTempDir(name) - const previousCwd = process.cwd() - writeCapacitorConfig(root, updater) - process.chdir(root) - try { - return await fn() - } - finally { - process.chdir(previousCwd) - } -} - -assert.equal( - await withTempProject('default-host', {}, () => resolveAppGettingStartedMessage(appId)), - `Continue setup at ${expectedUrl}`, -) - -assert.equal( - await withTempProject('custom-dashboard', { - localWebHost: 'https://dashboard.example.com', - localSupa: 'https://supabase.example.com', - localSupaAnon: 'anon-key', - }, () => resolveAppGettingStartedMessage(appId)), - `Continue setup at https://dashboard.example.com/app/${appId}/getting-started`, -) - -assert.equal( - await withTempProject('custom-supabase-only', { - localSupa: 'https://supabase.example.com', - localSupaAnon: 'anon-key', - }, () => resolveAppGettingStartedMessage(appId)), - null, -) - -assert.equal( - await withTempProject('cli-supa-host', {}, () => resolveAppGettingStartedMessage(appId, { - supaHost: 'https://supabase.example.com', - supaAnon: 'anon-key', - })), - null, -) - -assert.equal( - await withTempProject('trailing-slash-default', { - localWebHost: `${defaultHostWeb}/`, - localSupa: 'https://supabase.example.com', - localSupaAnon: 'anon-key', - }, () => resolveAppGettingStartedMessage(appId)), - null, -) - -for (const dir of tempDirs) - rmSync(dir, { recursive: true, force: true }) - -console.log('✅ app add getting-started URL tests passed') diff --git a/cli/test/test-app-todo.mjs b/cli/test/test-app-todo.mjs deleted file mode 100644 index 4d0bb2b2b2..0000000000 --- a/cli/test/test-app-todo.mjs +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict' -import { spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { formatAppTodoList, getAppTodoSteps, readAppTodoProgress, TODO_BACKGROUND_WAIT_MS, waitForTodoBackgroundChecks } from '../src/app/todo.ts' -import { getAppOnboardingStepIds, parseAppOnboarding } from '../../supabase/functions/_backend/utils/appOnboarding.ts' -import messages from '../../messages/en.json' - -const appId = 'com.example.todo' -const options = { apikey: 'test-todo-key', supaHost: 'http://localhost:54321', supaAnon: 'test-anon-key' } -const progress = { - onboarding: { - setup: { - todo_list_version: 3, - steps: { - login_cli_mcp: { status: 'done' }, - add_channel: { status: 'done' }, - add_updater: { status: 'skipped' }, - add_code: { status: 'invalid' }, - completion: { status: 'done' }, - }, - }, - }, - hasChannel: false, - checkErrors: [], -} - -assert.equal(TODO_BACKGROUND_WAIT_MS, 10_000) -const countdown = [] -const timedOutAt = Date.now() -assert.equal(await waitForTodoBackgroundChecks([new Promise(() => {})], seconds => countdown.push(seconds), 2_200), false) -assert.equal(countdown[0], 3) -assert.ok(countdown.length >= 2, 'interactive countdown updates while waiting') -assert.ok(countdown.slice(1).every((seconds, index) => seconds < countdown[index]), 'remaining seconds only decrease') -assert.ok(Date.now() - timedOutAt >= 2_100, 'wait honors its shared deadline') -let finishCheck -const completedCheck = new Promise(resolve => { finishCheck = resolve }) -const finishEarly = waitForTodoBackgroundChecks([completedCheck], undefined, 10_000) -finishCheck() -assert.equal(await finishEarly, true, 'completed checks end the wait before the deadline') - -for (const version of [1, 2, 3, 4, 0, -1, 1.5, '3', undefined]) { - const value = { setup: { todo_list_version: version, ...(version === 4 ? { ota_todo_list_version: '1' } : {}), steps: version === 4 ? { ota: progress.onboarding.setup.steps } : progress.onboarding.setup.steps } } - const parsed = parseAppOnboarding(value) - const actual = getAppTodoSteps({ onboarding: value }) - assert.equal(actual.version, parsed.todo_list_version) - assert.deepEqual(actual.steps.map(step => step.id), getAppOnboardingStepIds(parsed.todo_list_version, parsed.ota_todo_list_version), 'step order matches the frontend') - for (const step of actual.steps) { - assert.equal(step.status, parsed.steps[step.id]?.status ?? 'pending') - const prefix = actual.version === 3 || actual.version === 4 ? 'setup-checklist-step-' : 'app-onboarding-cli-step-' - assert.equal(step.title, messages[prefix + step.id], 'task titles match the frontend') - } -} - -for (const value of [null, [], {}, { setup: null }, { setup: [] }]) - assert.equal(getAppTodoSteps({ onboarding: value }).steps.length, 12) -assert.equal(getAppTodoSteps({ onboarding: { todo_list_version: 1, steps: { add_app: { status: 'done' } } } }).steps[0].status, 'done', 'supports legacy unwrapped setup') - -const output = formatAppTodoList(appId, progress) -assert.match(output, /Todo list v3/) -assert.match(output, /2\/7 completed \(1 done, 1 skipped, 5 pending\)/) -assert.match(output, /\[x\] Done: Start guided setup/) -assert.match(output, /\[-\] Skipped: Install Capgo Updater/) -assert.match(output, /\[ \] Pending: Create a channel/) -assert.match(output, /\[ \] Pending: Add the app-ready code/) -assert.match(output, /Next step: Create a channel/) -assert.match(output, /Done when: Capgo finds a channel for this app/) -assert.match(output, /Run this command again to recheck progress/) -assert.doesNotMatch(output, /Done when: The CLI finds that call/, 'only the next pending step is explained') -assert.doesNotMatch(output, /\u001B\[/, 'plain output has no color codes') -const coloredOutput = formatAppTodoList(appId, progress, { color: true }) -assert.match(coloredOutput, /\u001B\[32m\[x\] Done\u001B\[0m/) -assert.match(coloredOutput, /\u001B\[33m\[ \] Pending\u001B\[0m/) -assert.match(coloredOutput, /\u001B\[2m\[-\] Skipped\u001B\[0m/) -assert.match(coloredOutput, /\u001B\[1;36mNext step: Create a channel\u001B\[0m/) -assert.doesNotMatch(output, /Completion|encryption|undefined/) -assert.match(formatAppTodoList(appId, { ...progress, hasChannel: true }), /3\/7 completed/) -assert.match(formatAppTodoList(appId, { ...progress, hasChannel: true }), /Next step: Add the app-ready code/) -assert.match(formatAppTodoList(appId, { onboarding: progress.onboarding }), /3\/7 completed/, 'retains saved channel progress when the live check is unavailable') -assert.equal(progress.onboarding.setup.steps.add_channel.status, 'done', 'does not mutate saved progress') -const v4Progress = { onboarding: { setup: { todo_list_version: 4, ota_todo_list_version: '1', paths: ['ota'], selected_path: 'ota', steps: { ota: { ...progress.onboarding.setup.steps } } } }, hasChannel: false } -const v4Output = formatAppTodoList(appId, v4Progress) -assert.match(v4Output, /Todo list v4/) -assert.match(v4Output, /2\/7 completed/) -assert.match(v4Output, /Next step: Create a channel/) -assert.equal(getAppTodoSteps(v4Progress).steps.find(step => step.id === 'add_updater').status, 'skipped') -for (const otaVersion of ['2', 1, undefined]) { - const unsupported = { onboarding: { setup: { todo_list_version: 4, ota_todo_list_version: otaVersion, steps: { ota: progress.onboarding.setup.steps } } } } - assert.deepEqual(getAppTodoSteps(unsupported).steps, [], 'unsupported OTA versions do not show v1 steps') - assert.match(formatAppTodoList(appId, unsupported), /does not support this OTA checklist version/) -} -const allDone = formatAppTodoList(appId, { onboarding: { setup: { todo_list_version: 3, steps: Object.fromEntries(getAppOnboardingStepIds(3).map(id => [id, { status: 'done' }])) } } }) -assert.match(allDone, /7\/7 completed \(7 done, 0 skipped, 0 pending\)/) -assert.doesNotMatch(allDone, /Next step:/) -const v2Output = formatAppTodoList(appId, { onboarding: { setup: { todo_list_version: 2, steps: {} } } }) -assert.doesNotMatch(v2Output, /Next step:|Done when:/, 'v2 keeps the checklist without v3 guidance') - -for (const [id, action, completion] of [ - ['login_cli_mcp', 'Run a Capgo CLI command', 'Capgo records that CLI or MCP activity'], - ['add_channel', 'Create a channel', 'Capgo finds a channel'], - ['add_updater', 'Install @capgo/capacitor-updater', 'The CLI finds the dependency'], - ['add_code', 'Call CapacitorUpdater.notifyAppReady()', 'The CLI finds that call'], - ['run_device', 'Build and open the app', 'Capgo sees a device'], - ['upload_bundle', 'Build and upload your first', 'Capgo finds a published bundle'], - ['test_update', 'Assign the update', 'Capgo records a device applying'], -]) { - const steps = Object.fromEntries(getAppOnboardingStepIds(3).map(stepId => [stepId, { status: stepId === id ? 'pending' : 'done' }])) - const text = formatAppTodoList(appId, { onboarding: { setup: { todo_list_version: 3, steps } } }) - assert.match(text, new RegExp(`Next step: ${messages[`setup-checklist-step-${id}`]}`)) - assert.ok(text.includes(action), `${id} explains the action`) - assert.ok(text.includes(`Done when: ${completion}`), `${id} explains the completion signal`) - assert.equal((text.match(/Next step:/g) ?? []).length, 1) -} - -const originalFetch = globalThis.fetch -try { - globalThis.fetch = async (input, init) => { - assert.equal(String(input), options.supaHost + '/functions/v1/private/onboarding_progress') - assert.equal(init.method, 'POST') - assert.deepEqual(JSON.parse(init.body), { appId, N: 0, initial: true }) - assert.equal(init.headers.capgkey, options.apikey) - assert.equal(init.headers.Authorization, 'Bearer ' + options.supaAnon) - return Response.json(progress) - } - assert.deepEqual(await readAppTodoProgress(appId, options), progress) - for (const [status, expected] of [[401, /app.read permission/], [403, /app.read permission/], [404, /App not found/], [500, /database_unavailable/]]) { - globalThis.fetch = async () => Response.json({ error: 'database_unavailable' }, { status }) - await assert.rejects(() => readAppTodoProgress(appId, options), expected) - } - globalThis.fetch = async () => Response.json({ status: 'ok' }) - await assert.rejects(() => readAppTodoProgress(appId, options), /invalid progress response/) -} -finally { - globalThis.fetch = originalFetch -} - -const fixture = mkdtempSync(join(tmpdir(), 'capgo-app-todo-')) -try { - writeFileSync(join(fixture, 'capacitor.config.json'), JSON.stringify({ appId, appName: 'Todo test', webDir: 'dist' })) - writeFileSync(join(fixture, 'package.json'), JSON.stringify({ name: 'todo-test', version: '1.0.0' })) - const preload = join(fixture, 'fetch.mjs') - writeFileSync(preload, ` - import { appendFileSync, existsSync, writeFileSync } from 'node:fs' - import { isMainThread } from 'node:worker_threads' - const nativeFetch = globalThis.fetch - const codeMarker = ${JSON.stringify(join(fixture, 'background-code-updated'))} - const updaterMarker = ${JSON.stringify(join(fixture, 'background-updater-updated'))} - const progressReads = ${JSON.stringify(join(fixture, 'progress-reads'))} - globalThis.fetch = async (input, init) => { - const url = input?.url ?? String(input) - const scenario = process.env.CAPGO_TODO_SCENARIO - if (!url.startsWith('http') || url.includes('.wasm')) return nativeFetch(input, init) - if (url.includes('/private/events') && process.env.CAPGO_TODO_TRACKING_FILE) { - const event = JSON.parse(init.body) - if (event.event === 'CLI Command Invoked') - writeFileSync(process.env.CAPGO_TODO_TRACKING_FILE, JSON.stringify({ key: init.headers.capgkey, command: event.tags.command_path })) - } - if (url.includes('/private/config')) return Response.json({ supaHost: ${JSON.stringify(options.supaHost)}, supaKey: ${JSON.stringify(options.supaAnon)} }) - if (url.includes('/rpc/reject_access_due_to_2fa_for_app')) return Response.json(scenario === 'two-factor') - if (scenario?.startsWith('background-updated') && init?.method === 'PUT' && url.endsWith('/app/${appId}')) { - await new Promise(resolve => setTimeout(resolve, 700)) - const steps = JSON.parse(init.body).onboarding.steps - if (steps.add_code) writeFileSync(codeMarker, 'done') - if (steps.add_updater) writeFileSync(updaterMarker, 'done') - return Response.json({ status: 'ok' }) - } - if (url.includes('/private/onboarding_progress')) { - if (scenario?.startsWith('background-updated') && isMainThread) appendFileSync(progressReads, 'read\\n') - if (scenario === 'denied') return Response.json({ error: 'app_access_denied' }, { status: 403 }) - if (scenario === 'missing') return Response.json({ error: 'app_not_found' }, { status: 404 }) - if (scenario === 'failed') return Response.json({ error: 'database_unavailable' }, { status: 500 }) - if (scenario === 'invalid') return Response.json({ status: 'ok' }) - const progress = ${JSON.stringify(progress)} - if (scenario === 'partial') progress.checkErrors = ['run_device'] - if (scenario === 'v2') progress.onboarding.setup.todo_list_version = 2 - if (scenario === 'empty') progress.onboarding = null - if (scenario?.startsWith('background-updated')) { - if (scenario === 'background-updated-v4') { - progress.onboarding.setup.todo_list_version = 4 - progress.onboarding.setup.ota_todo_list_version = '1' - progress.onboarding.setup.steps = { ota: progress.onboarding.setup.steps } - } - const steps = scenario === 'background-updated-v4' ? progress.onboarding.setup.steps.ota : progress.onboarding.setup.steps - steps.add_code.status = existsSync(codeMarker) ? 'done' : 'pending' - steps.add_updater.status = existsSync(updaterMarker) ? 'done' : 'pending' - } - return Response.json(progress) - } - return Response.json({ status: 'ok' }) - } - `) - const builtCli = new URL('../dist/index.js', import.meta.url).pathname - for (const alias of ['todo', 'todoList']) { - const help = spawnSync('node', [builtCli, 'app', alias, '--help'], { encoding: 'utf8' }) - assert.equal(help.status, 0, help.stderr) - assert.match(help.stdout, /todo\|todoList \[options\] \[appId\]/) - for (const scenario of ['v3', 'v2', 'empty', 'partial', 'inferred', 'denied', 'missing', 'failed', 'invalid', 'two-factor']) { - const child = spawnSync('node', [ - '--import', preload, builtCli, 'app', alias, - ...(scenario === 'inferred' ? [] : [appId]), - '-a', options.apikey, '--supa-host', options.supaHost, '--supa-anon', options.supaAnon, - ], { - cwd: fixture, encoding: 'utf8', timeout: 15000, - env: { ...process.env, CAPGO_TODO_SCENARIO: scenario, CAPGO_DISABLE_TELEMETRY: '1', CAPGO_DISABLE_POSTHOG: '1', CI: '1' }, - }) - const text = child.stdout + child.stderr - const failure = ['denied', 'missing', 'failed', 'invalid', 'two-factor'].includes(scenario) - assert.equal(child.status, failure ? 1 : 0, text) - assert.match(text, /Loading the todo list/, 'non-interactive commands report the pending work') - assert.doesNotMatch(text, /Todo list loaded|Could not load todo list/, 'non-interactive commands do not render spinner completion') - assert.doesNotMatch(text, /\u001B\[/, 'non-interactive commands do not use ANSI colors') - if (!failure) { - assert.match(text, new RegExp('App: ' + appId.replaceAll('.', '\\.'))) - assert.match(text, /\[ \] Pending:/) - if (scenario === 'v2') { - assert.match(text, /Todo list v2/) - assert.doesNotMatch(text, /Next step:/) - assert.doesNotMatch(text, /Waiting 10 seconds for background TODO list checks/, 'v2 does not wait for background checks') - } - else if (scenario === 'empty') { - assert.match(text, /0\/12 completed/) - assert.doesNotMatch(text, /Waiting 10 seconds for background TODO list checks/) - } - else assert.match(text, /2\/7 completed/) - if (scenario === 'partial') assert.match(text, /Some live progress checks failed/) - } - else { - assert.doesNotMatch(text, /\[x\] Done:|\[ \] Pending:/, 'failures never print a misleading checklist') - if (scenario === 'denied') assert.match(text, /app.read permission/) - if (scenario === 'two-factor') assert.match(text, /2FA|two.factor/i) - } - } - } - const trackingFile = join(fixture, 'tracking.json') - const tracked = spawnSync('node', ['--import', preload, builtCli, 'app', 'todo', appId, '-a', options.apikey, '--supa-host', options.supaHost, '--supa-anon', options.supaAnon], { - cwd: fixture, encoding: 'utf8', timeout: 15000, - env: { ...process.env, CAPGO_TOKEN: 'stale-saved-key', CAPGO_TODO_TRACKING_FILE: trackingFile, CI: '1', CAPGO_DISABLE_TELEMETRY: '', CAPGO_DISABLE_POSTHOG: '' }, - }) - assert.equal(tracked.status, 0, tracked.stdout + tracked.stderr) - assert.deepEqual(JSON.parse(readFileSync(trackingFile, 'utf8')), { key: options.apikey, command: 'app todo' }, 'the command event uses --apikey instead of a saved key') - - writeFileSync(join(fixture, 'package.json'), JSON.stringify({ - name: 'todo-test', version: '1.0.0', dependencies: { '@capgo/capacitor-updater': '8.0.0' }, - })) - mkdirSync(join(fixture, 'node_modules/@capgo/capacitor-updater'), { recursive: true }) - writeFileSync(join(fixture, 'node_modules/@capgo/capacitor-updater/package.json'), JSON.stringify({ name: '@capgo/capacitor-updater', version: '8.0.0' })) - mkdirSync(join(fixture, 'src')) - writeFileSync(join(fixture, 'src/main.ts'), "import { CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater.notifyAppReady()") - for (const scenario of ['background-updated', 'background-updated-v4']) { - for (const name of ['background-code-updated', 'background-updater-updated', 'progress-reads']) - rmSync(join(fixture, name), { force: true }) - const backgroundUpdate = spawnSync('node', [ - '--import', preload, builtCli, 'app', 'todo', appId, - '-a', options.apikey, '--supa-host', options.supaHost, '--supa-anon', options.supaAnon, - ], { - cwd: fixture, encoding: 'utf8', timeout: 15_000, - env: { ...process.env, CAPGO_TODO_SCENARIO: scenario, CAPGO_DISABLE_TELEMETRY: '1', CAPGO_DISABLE_POSTHOG: '1', CI: '1' }, - }) - const backgroundText = backgroundUpdate.stdout + backgroundUpdate.stderr - assert.equal(backgroundUpdate.status, 0, backgroundText) - assert.match(backgroundText, new RegExp(`Todo list v${scenario === 'background-updated-v4' ? 4 : 3}`)) - assert.equal((backgroundText.match(/Waiting 10 seconds for background TODO list checks to finish/g) ?? []).length, 1, backgroundText) - assert.doesNotMatch(backgroundText, /Waiting [1-9] seconds for background TODO list checks to finish/, 'non-interactive output does not count down') - assert.match(backgroundText, /\[x\] Done: Add the app-ready code/, 'the printed list includes the background report') - assert.match(backgroundText, /\[x\] Done: Install Capgo Updater/, 'the list waits for the updater check too') - assert.equal(readFileSync(join(fixture, 'progress-reads'), 'utf8').trim().split('\n').length, 2, `${scenario} rereads progress after the worker completes`) - } -} -finally { - rmSync(fixture, { recursive: true, force: true }) -} -console.log('App todo frontend parity, live progress, errors, app ID inference, and both built CLI aliases passed') diff --git a/cli/test/test-auth-session.mjs b/cli/test/test-auth-session.mjs index 2ed8aa82f3..e234358ce7 100644 --- a/cli/test/test-auth-session.mjs +++ b/cli/test/test-auth-session.mjs @@ -37,7 +37,6 @@ const { whoamiMessage, logoutMessage, } = await import('../src/auth/session.ts') -const { resolveAccountIdentity } = await import('../src/user/whoami.ts') await test('validateAndSaveKey rejects an empty key (no write, no network)', async () => { let threw = false @@ -112,33 +111,6 @@ await test('loginSuccessMessage names the user and the scope path', () => { ok(loginSuccessMessage('u9', true).includes('./.capgo'), 'local path mentions ./.capgo') }) -await test('account identity starts ID and email RPCs in parallel', async () => { - const started = [] - const finish = {} - const client = { - rpc(name) { - started.push(name) - return new Promise((resolve) => { finish[name] = resolve }) - }, - } - - const identity = resolveAccountIdentity(client, 'test-key') - eq(started.join(','), 'request_actor_user_id,request_actor_email_adress') - finish.request_actor_email_adress({ data: 'account@example.com', error: null }) - finish.request_actor_user_id({ data: 'user-1', error: null }) - const result = await identity - eq(result.userId, 'user-1') - eq(result.email, 'account@example.com') -}) - -await test('account identity rejects missing email', async () => { - const client = { rpc: async name => ({ data: name === 'request_actor_user_id' ? 'user-1' : null, error: null }) } - let threw = false - try { await resolveAccountIdentity(client, 'test-key') } - catch (error) { threw = true; ok(/email not found/.test(error.message)) } - ok(threw, 'a missing email must not produce a partial identity') -}) - console.log(`📊 Results: ${pass} passed, ${fail} failed`) if (fail > 0) process.exit(1) diff --git a/cli/test/test-authenticated-command-invocation.mjs b/cli/test/test-authenticated-command-invocation.mjs index 2f66f10fe1..6412309dc5 100644 --- a/cli/test/test-authenticated-command-invocation.mjs +++ b/cli/test/test-authenticated-command-invocation.mjs @@ -36,7 +36,6 @@ const testDir = dirname(fileURLToPath(import.meta.url)) const indexSource = readFileSync(join(testDir, '../src/index.ts'), 'utf8') const loginSource = readFileSync(join(testDir, '../src/login.ts'), 'utf8') const initSource = readFileSync(join(testDir, '../src/init/command.ts'), 'utf8') -const builderSource = readFileSync(join(testDir, '../src/build/onboarding/command.ts'), 'utf8') function sourceBetween(source, start, end) { const startIndex = source.indexOf(start) @@ -49,8 +48,6 @@ function sourceBetween(source, start, end) { const preActionSource = sourceBetween(indexSource, "program.hook('preAction'", "program.hook('postAction'") assert.match(preActionSource, /currentCommandPath === 'login'/) assert.match(preActionSource, /currentCommandPath === 'init'/) -assert.match(preActionSource, /currentCommandPath === 'build init'/) -assert.match(preActionSource, /currentCommandPath === 'build onboarding'/) assert.match(preActionSource, /deferCommandInvocation\(currentCommandPath, commandContext\)/) const initCommandSource = sourceBetween(indexSource, ".command('init [apikey] [appId]')", "program\n .command('doctor')") @@ -80,8 +77,4 @@ assert.ok(initSavedKeyFallbackIndex < initValidationIndex, 'init restores its sa assert.ok(initValidationIndex >= 0, 'init validates the selected key with Capgo') assert.ok(initInvocationIndex > initValidationIndex, 'init invocation is emitted only after validation') -const builderAuthCallback = sourceBetween(builderSource, 'onAuthenticated: (key, metadata) => {', 'onBeforeExit: finishBuildReplay') -assert.match(builderAuthCallback, /flushDeferredCommandInvocation\(key\)/) -assert.match(builderAuthCallback, /if \(metadata\.method\)/) - console.log('✅ authenticated command invocation tests passed') diff --git a/cli/test/test-background-check-shutdown.mjs b/cli/test/test-background-check-shutdown.mjs deleted file mode 100644 index 4e1db3f885..0000000000 --- a/cli/test/test-background-check-shutdown.mjs +++ /dev/null @@ -1,184 +0,0 @@ -import assert from 'node:assert/strict' -import { spawn } from 'node:child_process' -import { once } from 'node:events' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { afterAll, beforeAll, test } from 'bun:test' - -const dir = mkdtempSync(join(tmpdir(), 'capgo-background-shutdown-')) -let harness -let runnerCount = 0 - -beforeAll(async () => { - writeFileSync(join(dir, 'entry.ts'), ` - export { startOnboardingCheck } from ${JSON.stringify(fileURLToPath(new URL('../src/onboarding/background.ts', import.meta.url)))} - export { waitForOnboardingChecks } from ${JSON.stringify(fileURLToPath(new URL('../src/onboarding/background-shutdown.ts', import.meta.url)))} - export { getPendingOnboardingChecks } from ${JSON.stringify(fileURLToPath(new URL('../src/onboarding/background-workers.ts', import.meta.url)))} - `) - const build = await Bun.build({ entrypoints: [join(dir, 'entry.ts')], outdir: dir, target: 'node', format: 'esm' }) - assert.equal(build.success, true) - harness = pathToFileURL(join(dir, 'entry.js')).href - writeFileSync(join(dir, 'busy.mjs'), 'setInterval(() => {}, 10_000)') - writeFileSync(join(dir, 'quick.mjs'), 'setTimeout(() => {}, 300)') - writeFileSync(join(dir, 'slow.mjs'), 'setTimeout(() => {}, 900)') -}) - -afterAll(() => rmSync(dir, { recursive: true, force: true })) - -function run({ commandPath = 'app list', options = {}, tty = true, stdinTty = tty, stdoutTty = tty, ci = false, workers = ['busy.mjs'], foregroundMs = 0, previousInterrupt = false, telemetry = 'ok', disabled = false } = {}) { - const source = ` - import { performance } from 'node:perf_hooks' - import { startOnboardingCheck, waitForOnboardingChecks, getPendingOnboardingChecks } from ${JSON.stringify(harness)} - Object.defineProperty(process.stdin, 'isTTY', { value: ${stdinTty} }) - Object.defineProperty(process.stdout, 'isTTY', { value: ${stdoutTty} }) - globalThis.fetch = async (url, init) => { - if (!String(url).endsWith('/private/events')) - return new Response('', { status: 500 }) - console.log('telemetry:' + init.body) - if (${JSON.stringify(telemetry)} === 'reject') - throw new Error('offline') - if (${JSON.stringify(telemetry)} === 'hang') { - return new Promise((resolve, reject) => { - const abort = () => { console.log('telemetry-aborted'); reject(new Error('aborted')) } - if (init.signal.aborted) abort() - else init.signal.addEventListener('abort', abort, { once: true }) - }) - } - return new Response('{}', { headers: { 'Content-Type': 'application/json' } }) - } - const command = { optsWithGlobals: () => (${JSON.stringify({ apikey: 'fake-api-key', appId: 'com.example.ready', ...options })}), registeredArguments: [], args: [] } - for (const filename of ${JSON.stringify(workers)}) - startOnboardingCheck(command, ${JSON.stringify(commandPath)}, new URL(filename, ${JSON.stringify(pathToFileURL(join(dir, 'run.mjs')).href)})) - console.log('pending-attempts:' + JSON.stringify([...getPendingOnboardingChecks().values()].map(check => check.attemptId))) - let foregroundInterrupts = 0 - if (${previousInterrupt}) { - process.on('SIGINT', () => { foregroundInterrupts++; console.log('foreground-interrupted') }) - process.emit('SIGINT') - } - await new Promise(resolve => setTimeout(resolve, ${foregroundMs})) - console.log('interrupt-count-before-wait:' + foregroundInterrupts) - const started = performance.now() - await waitForOnboardingChecks(command, ${JSON.stringify(commandPath)}) - console.log('foreground-finished:' + Math.round(performance.now() - started)) - ` - const runner = join(dir, `run-${++runnerCount}.mjs`) - writeFileSync(runner, source) - const child = spawn('node', [runner], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: ci ? 'true' : 'false', CAPGO_DISABLE_TELEMETRY: disabled ? 'true' : '', CAPGO_DISABLE_POSTHOG: '' } }) - let output = '' - let errors = '' - let waiting - const waitingMessage = new Promise(resolve => { waiting = resolve }) - for (const [stream, stderr] of [[child.stdout, false], [child.stderr, true]]) { - stream.on('data', chunk => { - if (stderr) errors += chunk - else output += chunk - if ((output + errors).includes('Waiting for background checks')) waiting() - }) - } - const timeout = setTimeout(() => child.kill('SIGKILL'), 10_000) - const completion = once(child, 'exit').then(([code, signal]) => { - clearTimeout(timeout) - return { code, signal, output, errors, text: output + errors } - }) - return { child, completion, waitingMessage } -} - -function waitedMs(result) { - assert.equal(result.code, 0, result.text) - assert.equal(result.signal, null) - return Number(result.output.match(/foreground-finished:(\d+)/)?.[1]) -} - -function waitEvents(result) { - return result.output.split('\n').filter(line => line.startsWith('telemetry:')).map(line => JSON.parse(line.slice('telemetry:'.length))) -} - -test.concurrent('both workers share one five-second shutdown budget', async () => { - const { completion } = run({ workers: ['busy.mjs', 'busy.mjs'] }) - const result = await completion - const duration = waitedMs(result) - assert.ok(duration >= 4_900 && duration < 6_000, `shared wait was ${duration}ms`) - assert.equal(result.text.match(/Waiting for background checks/g)?.length, 1) - const events = waitEvents(result) - assert.equal(events.length, 1) - const event = events[0] - assert.equal(event.event, 'background_checks_wait_started') - assert.equal(event.channel, 'cli-usage') - assert.equal(event.tracking_version, 2) - assert.ok(Number.isFinite(Date.parse(event.timestamp))) - assert.equal(event.nonPersonTags.command_path, 'app list') - assert.equal(event.nonPersonTags.pending_checks, 2) - assert.equal(event.nonPersonTags.grace_period_ms, 5_000) - const attempts = JSON.parse(result.output.match(/pending-attempts:(.+)/)[1]) - assert.deepEqual(event.nonPersonTags.scan_attempt_ids, attempts) - assert.equal(new Set(attempts).size, 2) - for (const id of attempts) assert.match(id, /^[0-9a-f-]{36}$/) - assert.equal(JSON.stringify(event).includes('fake-api-key'), false) - assert.equal(JSON.stringify(event).includes(dir), false) -}, 12_000) - -test.concurrent('exits early as soon as the last worker finishes', async () => { - const result = await run({ workers: ['quick.mjs', 'slow.mjs'] }).completion - const duration = waitedMs(result) - assert.ok(duration >= 800 && duration < 2_000, `early completion was ${duration}ms`) - assert.ok(result.text.includes('Waiting for background checks')) -}) - -test.concurrent('completed or absent checks produce no waiting message or delay', async () => { - for (const settings of [{ workers: [] }, { workers: ['quick.mjs'], foregroundMs: 1_000 }]) { - const result = await run(settings).completion - assert.ok(waitedMs(result) < 100) - assert.ok(!result.text.includes('Waiting for background checks')) - assert.deepEqual(waitEvents(result), []) - } -}) - -test.concurrent('SIGINT during the grace period exits immediately even after a previous interrupt', async () => { - const { child, completion, waitingMessage } = run({ previousInterrupt: true }) - await Promise.race([waitingMessage, completion.then(() => { throw new Error('exited before entering the grace period') })]) - child.kill('SIGINT') - const result = await completion - assert.equal(result.code, 130) - assert.equal(result.signal, null) - const previousInterrupts = Number(result.output.match(/interrupt-count-before-wait:(\d+)/)[1]) - assert.ok(previousInterrupts >= 1) - assert.equal(result.output.match(/foreground-interrupted/g)?.length, previousInterrupts, result.text) - assert.ok(!result.text.includes('foreground-finished')) -}) - -test.concurrent('machine output, init, MCP, CI and non-interactive commands do not wait or print', async () => { - const cases = [ - { options: { json: true } }, { options: { outputText: true } }, { options: { quiet: true } }, - { commandPath: 'channel currentBundle', options: { quiet: true } }, - { commandPath: 'bundle zip', options: { json: true } }, - { commandPath: 'init' }, { commandPath: 'build init' }, { commandPath: 'build onboarding' }, - { commandPath: 'mcp' }, { commandPath: 'account id' }, { commandPath: 'bundle releaseType' }, - { commandPath: 'build credentials export' }, { commandPath: 'generate-docs' }, - { commandPath: 'unknown-command' }, { ci: true }, { stdinTty: false }, { stdoutTty: false }, - ] - const results = await Promise.all(cases.map(settings => run(settings).completion)) - for (const result of results) { - assert.ok(waitedMs(result) < 100) - assert.ok(!result.text.includes('Waiting for background checks')) - assert.deepEqual(waitEvents(result), []) - } -}, 12_000) - -test.concurrent('telemetry opt-out and delivery errors preserve the wait and early exit', async () => { - for (const settings of [{ disabled: true }, { telemetry: 'reject' }]) { - const result = await run({ workers: ['quick.mjs'], ...settings }).completion - assert.ok(waitedMs(result) < 2_000) - assert.ok(result.text.includes('Waiting for background checks')) - assert.equal(waitEvents(result).length, settings.disabled ? 0 : 1) - } -}) - -test.concurrent('hanging telemetry is aborted within the same five-second budget', async () => { - const result = await run({ workers: ['quick.mjs'], telemetry: 'hang' }).completion - const duration = waitedMs(result) - assert.ok(duration >= 4_900 && duration < 6_000, `telemetry wait was ${duration}ms`) - assert.equal(waitEvents(result).length, 1) - assert.ok(result.output.includes('telemetry-aborted')) -}, 12_000) diff --git a/cli/test/test-builder-app-id-config.mjs b/cli/test/test-builder-app-id-config.mjs deleted file mode 100644 index e5dfd1f0b8..0000000000 --- a/cli/test/test-builder-app-id-config.mjs +++ /dev/null @@ -1,206 +0,0 @@ -import assert from 'node:assert/strict' -import { createRequire } from 'node:module' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import ts from 'typescript' -import { getBuilderAppId } from '../src/build/app-id.ts' -import { persistBuilderAppSelection } from '../src/build/onboarding/app-selection.ts' -import { loadConfig, writeConfigUpdater } from '../src/config/index.ts' -import { getAppId } from '../src/utils.ts' -import { flushAnalytics, isBuilderInvocation, resolveTrackingContext, trackCommandInvoked } from '../src/analytics/track.ts' -import { buildDeps } from '../src/build/onboarding/mcp/onboarding-tools.ts' -import { buildScanContext } from '../src/build/prescan/context.ts' -import { generateWorkflow } from '../src/build/onboarding/workflow-generator.ts' - -const require = createRequire(import.meta.url) -const { loadConfig: loadCapacitorConfig } = require('@capacitor/cli/dist/config') -const { syncCommand } = require('@capacitor/cli/dist/tasks/sync') -const cliRoot = dirname(dirname(fileURLToPath(import.meta.url))) -const root = mkdtempSync(join(cliRoot, '.builder-app-id-config-')) -const nativeId = 'com.example.native' -const updaterId = 'com.example.ota' -const builderId = 'com.example.builder' - -function configFor(builderValue = builderId) { - return { - appId: nativeId, - appName: 'Builder config proof', - webDir: 'www', - plugins: { - CapacitorUpdater: { appId: updaterId }, - CapgoBuilder: { capgoBuilderAppId: builderValue }, - }, - } -} - -function errorsFor(path) { - const program = ts.createProgram([path], { - noEmit: true, - strict: true, - skipLibCheck: true, - module: ts.ModuleKind.NodeNext, - moduleResolution: ts.ModuleResolutionKind.NodeNext, - }) - return ts.getPreEmitDiagnostics(program).filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) -} - -try { - assert.equal(getBuilderAppId(undefined, configFor()), builderId) - assert.equal(getBuilderAppId('com.example.explicit', configFor()), 'com.example.explicit') - assert.equal(getBuilderAppId(undefined, { ...configFor(), plugins: { CapacitorUpdater: { appId: updaterId } } }), updaterId) - assert.equal(getBuilderAppId(undefined, { ...configFor(), plugins: { CapacitorUpdater: { appId: updaterId } } }, 'native'), nativeId) - assert.equal(getBuilderAppId(undefined, configFor(), 'native'), builderId) - assert.equal(getBuilderAppId('', { ...configFor(), plugins: {} }, 'native', 'defined'), '', 'prescan keeps its nullish explicit-ID fallback') - assert.equal(getAppId(undefined, configFor()), updaterId, 'OTA resolution must ignore the Builder field') - const workflow = generateWorkflow({ - appId: getBuilderAppId(undefined, configFor()), - defaultPlatform: 'ios', - packageManager: 'npm', - buildScript: { type: 'skip' }, - secretKeys: [], - }) - assert.match(workflow.content, /@capgo\/cli@latest build request com\.example\.builder/) - assert.doesNotMatch(workflow.content, /build request com\.example\.native/) - for (const value of ['', ' ', 42, null, undefined]) { - const invalid = configFor() - invalid.plugins.CapgoBuilder.capgoBuilderAppId = value - assert.throws(() => getBuilderAppId(undefined, invalid), /plugins\.CapgoBuilder\.capgoBuilderAppId must be a non-empty string/) - assert.equal(getBuilderAppId('com.example.explicit', invalid), 'com.example.explicit', 'explicit app IDs bypass invalid Builder config') - } - assert.equal(isBuilderInvocation('build request'), true) - assert.equal(isBuilderInvocation('build credentials save'), true) - assert.equal(isBuilderInvocation('mcp:start_capgo_build'), true) - assert.equal(isBuilderInvocation('mcp:capgo_builder_onboarding_next_step'), true) - assert.equal(isBuilderInvocation('app list'), false) - assert.equal(isBuilderInvocation('bundle upload'), false) - assert.equal(isBuilderInvocation('mcp:capgo_init_next_step'), false) - - // An ordinary CapacitorConfig object literal rejects the proposed root field. - // Its documented plugins map accepts the Builder namespace without augmentation. - const rootAttempt = join(root, 'root-attempt.ts') - writeFileSync(rootAttempt, `import type { CapacitorConfig } from '@capacitor/cli' -const config: CapacitorConfig = { appId: '${nativeId}', appName: 'Proof', webDir: 'www', capgoBuilderAppId: '${builderId}' } -export default config -`) - assert.ok(errorsFor(rootAttempt).some(error => error.code === 2353 && ts.flattenDiagnosticMessageText(error.messageText, ' ').includes('capgoBuilderAppId'))) - - for (const extension of ['ts', 'json', 'js']) { - const project = join(root, extension) - mkdirSync(join(project, 'www'), { recursive: true }) - writeFileSync(join(project, 'package.json'), JSON.stringify({ name: `builder-proof-${extension}`, version: '1.0.0', private: true })) - writeFileSync(join(project, 'www', 'index.html'), 'proof') - const path = join(project, `capacitor.config.${extension}`) - const config = configFor() - if (extension === 'ts') { - writeFileSync(path, `import type { CapacitorConfig } from '@capacitor/cli' -const config: CapacitorConfig = ${JSON.stringify(config, null, 2)} -export default config -`) - assert.deepEqual(errorsFor(path), [], 'the plugins field must typecheck as an ordinary CapacitorConfig') - } - else if (extension === 'js') { - writeFileSync(path, `/** @type {import('@capacitor/cli').CapacitorConfig} */ -const config = ${JSON.stringify(config, null, 2)} -module.exports = config -`) - } - else { - writeFileSync(path, JSON.stringify(config, null, 2)) - } - - const previousCwd = process.cwd() - try { - process.chdir(project) - assert.equal(process.cwd(), project) - const capacitor = await loadCapacitorConfig() - assert.equal(capacitor.app.appId, nativeId) - assert.equal(capacitor.app.extConfig.plugins.CapgoBuilder.capgoBuilderAppId, builderId) - await syncCommand(capacitor, 'web') - assert.equal((await loadCapacitorConfig()).app.extConfig.plugins.CapgoBuilder.capgoBuilderAppId, builderId) - - const capgo = await loadConfig() - assert.equal(getBuilderAppId(undefined, capgo.config), builderId) - if (extension === 'ts') { - const aborted = new AbortController() - aborted.abort() - assert.equal((await resolveTrackingContext('builder-config-proof', aborted.signal, true)).appId, builderId) - assert.equal((await resolveTrackingContext('builder-config-proof', aborted.signal, false)).appId, updaterId, 'non-Builder telemetry stays on OTA resolution') - const originalFetch = globalThis.fetch - const events = [] - globalThis.fetch = async (url, init) => { - if (String(url).endsWith('/private/events')) - events.push(JSON.parse(init.body)) - return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }) - } - try { - const commandContext = { flags: [], positional_arg_count: 0 } - trackCommandInvoked('build request', commandContext, 'builder-config-proof') - await flushAnalytics() - assert.equal(events.at(-1)?.tags.app_id, builderId) - trackCommandInvoked('app list', commandContext, 'builder-config-proof') - await flushAnalytics() - assert.equal(events.at(-1)?.tags.app_id, updaterId, 'app list telemetry must ignore the Builder field') - } - finally { - globalThis.fetch = originalFetch - } - } - const scan = await buildScanContext({ platform: 'android', projectDir: project, credentials: {} }) - assert.equal(scan.appId, builderId, 'prescan Capgo checks use the Builder key') - assert.equal(scan.nativeAppId, nativeId, 'prescan local iOS checks use the native ID') - assert.equal(scan.config.appId, nativeId, 'prescan native checks keep the Capacitor ID') - const explicitScan = await buildScanContext({ appId: 'com.example.explicit', platform: 'android', projectDir: project, credentials: {} }) - assert.equal(explicitScan.appId, 'com.example.explicit') - assert.equal(explicitScan.nativeAppId, nativeId, 'an explicit Capgo ID does not replace the native ID') - if (extension === 'ts') { - const deps = buildDeps(() => ({})) - assert.equal(await deps.getAppId(), builderId, 'MCP Capgo operations use the Builder key') - assert.equal(await deps.getNativeAppId(), nativeId, 'MCP native operations keep the Capacitor ID') - for (let attempt = 0; attempt < 20 && deps.iosEffectDeps.detectBundleIds().capacitor.value !== nativeId; attempt++) - await new Promise(resolve => setTimeout(resolve, 5)) - assert.equal(deps.iosEffectDeps.detectBundleIds().capacitor.value, nativeId, 'iOS bundle detection stays native') - } - await writeConfigUpdater({ - path, - config: { ...capgo.config, plugins: { ...capgo.config.plugins, CapacitorUpdater: { appId: 'com.example.new-ota' } } }, - }) - const updated = await loadConfig() - assert.equal(updated.config.appId, nativeId) - assert.equal(updated.config.plugins.CapgoBuilder.capgoBuilderAppId, builderId) - assert.equal(getAppId(undefined, updated.config), 'com.example.new-ota') - updated.config.plugins.CapacitorUpdater.appId = 'com.example.raw-ota' - await writeConfigUpdater(updated, true) - const rawUpdated = await loadConfig() - assert.equal(rawUpdated.config.plugins.CapgoBuilder.capgoBuilderAppId, builderId, 'raw updater writes must retain the Builder field') - assert.equal(getAppId(undefined, rawUpdated.config), 'com.example.raw-ota') - assert.equal((await loadCapacitorConfig()).app.extConfig.plugins.CapgoBuilder.capgoBuilderAppId, builderId) - if (extension === 'ts') - assert.deepEqual(errorsFor(path), [], 'Capgo updater writes must keep the TypeScript config valid') - if (extension === 'js') - assert.match(readFileSync(path, 'utf8'), /capgoBuilderAppId/) - assert.equal(await persistBuilderAppSelection(builderId), false, 'matching Builder ID needs no config write') - assert.equal(await persistBuilderAppSelection('com.example.selected'), true) - const selectedConfig = await loadConfig() - assert.equal(selectedConfig.config.appId, nativeId) - assert.equal(selectedConfig.config.plugins.CapacitorUpdater.appId, 'com.example.raw-ota') - assert.equal(selectedConfig.config.plugins.CapgoBuilder.capgoBuilderAppId, 'com.example.selected') - assert.equal((await loadCapacitorConfig()).app.extConfig.plugins.CapgoBuilder.capgoBuilderAppId, 'com.example.selected') - } - finally { - process.chdir(previousCwd) - } - } - - const invalidProject = join(root, 'invalid-explicit') - mkdirSync(join(invalidProject, 'www'), { recursive: true }) - writeFileSync(join(invalidProject, 'capacitor.config.json'), JSON.stringify(configFor(''))) - const overriddenScan = await buildScanContext({ appId: 'com.example.explicit', platform: 'ios', projectDir: invalidProject, credentials: {} }) - assert.equal(overriddenScan.appId, 'com.example.explicit', 'explicit IDs bypass invalid Builder config during prescan') - assert.equal(overriddenScan.nativeAppId, nativeId, 'prescan still uses the native ID for local checks') -} -finally { - if (existsSync(root)) - rmSync(root, { recursive: true, force: true }) -} diff --git a/cli/test/test-builder-app-selection.mjs b/cli/test/test-builder-app-selection.mjs deleted file mode 100644 index 327eae939e..0000000000 --- a/cli/test/test-builder-app-selection.mjs +++ /dev/null @@ -1,331 +0,0 @@ -#!/usr/bin/env bun -import assert from 'node:assert/strict' -import { EventEmitter } from 'node:events' -import { render, Text } from 'ink' -import React from 'react' -import stringWidth from 'string-width' -import { AppSelectionError, createBuilderAppSelectionServices, getAppSelectionSuggestion, listVisibleBuilderApps, rankVisibleApps, verifyBuilderApp } from '../src/build/onboarding/app-selection.ts' -import BuilderAppSelectionGate from '../src/build/onboarding/ui/app-selection-gate.tsx' - -assert.deepEqual(getAppSelectionSuggestion({ - appId: 'com.example.native', - plugins: { CapgoBuilder: { capgoBuilderAppId: 'com.example.cloud' } }, -}), { appId: 'com.example.cloud', source: 'builder' }) - -assert.deepEqual(getAppSelectionSuggestion({ - appId: 'com.example.native', - plugins: { CapacitorUpdater: { appId: 'com.example.ota' } }, -}), { appId: 'com.example.native', source: 'capacitor' }) - -const apps = [ - { app_id: 'com.example.forecast', name: 'Forecast' }, - { app_id: 'com.other.weather', name: 'Other' }, - { app_id: 'com.example.weather.beta', name: 'Weather Beta' }, - { app_id: 'com.example.weather.dev', name: 'Weather Preview' }, -] -assert.deepEqual(rankVisibleApps(apps, 'com.example.weather').slice(0, 3).map(app => app.app_id), [ - 'com.example.weather.dev', - 'com.example.weather.beta', - 'com.example.forecast', -]) -assert.equal(createBuilderAppSelectionServices({ supaHost: 'https://example.invalid' }).dashboardUrl, '', 'custom API hosts cannot use the hosted Dashboard') - -console.log('Builder app suggestion and similarity passed') - -{ - const paths = [] - const page = Array.from({ length: 50 }, (_, index) => ({ app_id: `com.example.app${index}`, name: `App ${index}` })) - const result = await listVisibleBuilderApps('test-key', { supaHost: 'https://example.invalid', supaAnon: 'anon-test' }, async (path, options) => { - paths.push({ path, options }) - return { data: path.endsWith('page=0') ? page : [{ app_id: 'com.example.last', name: 'Last' }], error: null } - }) - assert.equal(result.length, 51) - assert.deepEqual(paths.map(item => item.path), ['app?page=0', 'app?page=1']) - assert.ok(paths.every(item => item.options.apikey === 'test-key' && item.options.supaHost === 'https://example.invalid' && item.options.supaAnon === 'anon-test')) - await assert.rejects(listVisibleBuilderApps('test-key', {}, async path => path.endsWith('page=0') - ? { data: page, error: null } - : { data: null, error: new Error('page failed') }), error => error instanceof AppSelectionError && error.code === 'list') -} - -{ - const calls = [] - const request = async (path) => { - calls.push(path) - return { data: { app_id: 'com.example.weather', name: 'Weather' }, error: null } - } - const createClient = async () => ({ rpc: async (name, args) => { - calls.push({ name, args }) - return { data: true, error: null } - } }) - await verifyBuilderApp('test-key', 'com.example.weather', {}, { request, createClient }) - assert.equal(calls[0], 'app/com.example.weather') - assert.equal(calls[1].name, 'cli_check_permission') - assert.equal(calls[1].args.permission_key, 'app.build_native') - assert.equal(calls[1].args.app_id, 'com.example.weather') - await assert.rejects(verifyBuilderApp('test-key', 'com.example.weather', {}, { - request, - createClient: async () => ({ rpc: async () => ({ data: false, error: null }) }), - }), error => error instanceof AppSelectionError && error.code === 'build') - await assert.rejects(verifyBuilderApp('test-key', 'com.example.weather', {}, { - request: async () => ({ data: null, error: Object.assign(new Error('denied'), { context: { status: 401 } }) }), - createClient, - }), error => error instanceof AppSelectionError && error.code === 'read') -} - -console.log('Builder app pagination and permissions passed') - -function makeStream(cols = 100, rows = 50) { - const stream = new EventEmitter() - stream.columns = cols - stream.rows = rows - stream.isTTY = true - stream.lastFrame = '' - stream.frames = [] - stream.write = (frame) => { - stream.lastFrame = String(frame) - stream.frames.push(stream.lastFrame) - return true - } - return stream -} - -function makeStdin() { - const stream = new EventEmitter() - const chunks = [] - stream.isTTY = true - stream.setEncoding = () => {} - stream.setRawMode = () => {} - stream.resume = () => {} - stream.pause = () => {} - stream.ref = () => {} - stream.unref = () => {} - stream.read = () => chunks.shift() ?? null - stream.send = (chunk) => { - chunks.push(chunk) - stream.emit('readable') - } - return stream -} - -async function waitFor(predicate, label) { - const deadline = Date.now() + 5000 - while (!predicate() && Date.now() < deadline) - await new Promise(resolve => setTimeout(resolve, 10)) - assert.ok(predicate(), `Timed out waiting for ${label}`) -} - -function renderGate({ visible = [], cols = 100, rows = 50, footer, verify = async () => {}, persist = async () => false, openDashboard = async () => true } = {}) { - const stdout = makeStream(cols, rows) - const stdin = makeStdin() - const selected = [] - const events = [] - const verified = [] - const saved = [] - let switched = 0 - const services = { - list: async () => visible, - verify: async (_key, id) => { verified.push(id); await verify(id) }, - persist: async (id) => { saved.push(id); return persist(id) }, - openDashboard, - dashboardUrl: 'https://console.capgo.app/app/new', - } - const instance = render(React.createElement(BuilderAppSelectionGate, { - apikey: 'test-key', - suggestedId: 'com.example.weather', - suggestedSource: 'capacitor', - services, - cols, - rows, - footer, - onSelected: id => selected.push(id), - onSwitchKey: () => { switched++ }, - onCancel: () => {}, - onEvent: event => events.push(event), - }), { stdout, stderr: makeStream(cols, rows), stdin, debug: true, exitOnCtrlC: false, patchConsole: false }) - return { stdout, stdin, instance, selected, events, verified, saved, switched: () => switched } -} - -async function stop(ui) { - ui.instance.unmount() - await ui.instance.waitUntilExit() -} - -{ - const ui = renderGate({ visible: [{ app_id: 'com.example.weather', name: 'Weather' }] }) - await waitFor(() => ui.selected.length === 1, 'exact match resolution') - assert.deepEqual(ui.verified, ['com.example.weather']) - assert.deepEqual(ui.saved, ['com.example.weather']) - assert.ok(ui.stdout.frames.every(frame => !frame.includes('Which Capgo app should Builder use?'))) - assert.equal(ui.events.find(event => event.phase === 'resolved')?.result, 'exact_match') - assert.equal(ui.events.some(event => event.phase === 'shown'), false, 'exact match does not show a choice screen') - await stop(ui) -} - -{ - const ui = renderGate({ visible: [{ app_id: 'com.example.weather.dev', name: 'Weather Preview' }] }) - await waitFor(() => ui.stdout.lastFrame.includes('Which Capgo app should Builder use?'), 'single app screen') - assert.match(ui.stdout.lastFrame, /Your Capacitor app ID: com\.example\.weather/) - assert.match(ui.stdout.lastFrame.replace(/\s+/g, ' '), /No app with this ID is available to your API key\. It may exist in Capgo, but you or your API key might lack access to it\./) - assert.match(ui.stdout.lastFrame, /App visible to your API key:/) - assert.match(ui.stdout.lastFrame, /─{20,}/, 'a divider separates the app choices from the explanation') - assert.match(ui.stdout.lastFrame, /Weather Preview.*com\.example\.weather\.dev/) - assert.match(ui.stdout.lastFrame, /\n\s*\n\s*↑ ↓ choose · Enter select · Esc back/u, 'normal terminals leave a blank row above navigation hints') - assert.doesNotMatch(ui.stdout.lastFrame, /Select a different app/) - assert.deepEqual(ui.selected, []) - await new Promise(resolve => setTimeout(resolve, 100)) - ui.stdin.send('\r') - await waitFor(() => ui.selected.length === 1, 'explicit single app selection') - assert.deepEqual(ui.selected, ['com.example.weather.dev']) - await stop(ui) -} - -{ - const rows = 30 - const ui = renderGate({ - visible: [{ app_id: 'com.example.weather.dev', name: 'Weather Preview' }], - rows, - footer: React.createElement(Text, null, 'Analytics notice'), - }) - await waitFor(() => ui.stdout.lastFrame.includes('Analytics notice'), 'app selection footer') - const lines = ui.stdout.lastFrame.split('\n') - assert.ok(lines.findIndex(line => line.includes('Analytics notice')) >= rows - 3, 'analytics notice stays near the terminal bottom') - await stop(ui) -} - -{ - const ui = renderGate({ visible: apps }) - await waitFor(() => ui.stdout.lastFrame.includes('Select a different app'), 'multiple app screen') - assert.match(ui.stdout.lastFrame, /Weather Preview.*com\.example\.weather\.dev/) - assert.match(ui.stdout.lastFrame, /Weather Beta.*com\.example\.weather\.beta/) - assert.doesNotMatch(ui.stdout.lastFrame, /com\.other\.weather/) - await new Promise(resolve => setTimeout(resolve, 100)) - for (let index = 0; index < 3; index++) { - ui.stdin.send('j') - await new Promise(resolve => setTimeout(resolve, 30)) - } - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('Search visible apps:'), 'full visible-app list') - ui.stdin.send('other') - await waitFor(() => ui.stdout.lastFrame.includes('com.other.weather'), 'full-list search') - ui.stdin.send('\r') - await waitFor(() => ui.selected.length === 1, 'full-list app selection') - assert.deepEqual(ui.selected, ['com.other.weather']) - await stop(ui) -} - -{ - const ui = renderGate() - await waitFor(() => ui.stdout.lastFrame.includes('No apps are visible to this API key'), 'zero apps screen') - assert.doesNotMatch(ui.stdout.lastFrame, /Select a different app/) - assert.match(ui.stdout.lastFrame, /Log in with another API key/) - await stop(ui) -} - -{ - const visible = [] - let opened = 0 - const ui = renderGate({ visible, openDashboard: async () => { opened++; return false } }) - await waitFor(() => ui.stdout.lastFrame.includes('No apps are visible to this API key'), 'empty list before Dashboard') - await new Promise(resolve => setTimeout(resolve, 100)) - ui.stdin.send('j') - await new Promise(resolve => setTimeout(resolve, 30)) - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('Open this URL in your browser'), 'Dashboard URL fallback') - assert.equal(opened, 1) - visible.push({ app_id: 'com.example.weather', name: 'Weather' }) - ui.stdin.send('\r') - await waitFor(() => ui.selected.length === 1, 'Dashboard-created app recheck') - assert.deepEqual(ui.selected, ['com.example.weather']) - await stop(ui) -} - -{ - let resolveFirst - let firstRequest - let opened = 0 - const ui = renderGate({ - openDashboard: () => { - if (++opened === 1) { - firstRequest = new Promise((resolve) => { resolveFirst = resolve }) - return firstRequest - } - return Promise.resolve(true) - }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('No apps are visible to this API key'), 'Dashboard race initial picker') - ui.stdin.send('j') - await waitFor(() => ui.stdout.lastFrame.includes('❯ Open Dashboard'), 'first Dashboard choice') - ui.stdin.send('\r') - await waitFor(() => opened === 1 && ui.stdout.lastFrame.includes('Create the app in your browser'), 'first Dashboard request') - ui.stdin.send('\x1B') - await waitFor(() => ui.stdout.lastFrame.includes('No apps are visible to this API key'), 'return from Dashboard') - ui.stdin.send('j') - await waitFor(() => ui.stdout.lastFrame.includes('❯ Open Dashboard'), 'second Dashboard choice') - ui.stdin.send('\r') - await waitFor(() => opened === 2 && ui.stdout.lastFrame.includes('Create the app in your browser'), 'second Dashboard request') - resolveFirst(false) - await firstRequest - await new Promise(resolve => setImmediate(resolve)) - assert.doesNotMatch(ui.stdout.lastFrame, /Open this URL in your browser/, 'an older Dashboard result cannot replace the current request') - await stop(ui) -} - -{ - const ui = renderGate({ - visible: [{ app_id: 'com.example.weather.dev', name: 'Weather Preview' }], - verify: async () => { throw new AppSelectionError('build', 'This API key needs app.build_native permission.') }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('Which Capgo app should Builder use?'), 'permission test picker') - await new Promise(resolve => setTimeout(resolve, 100)) - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('This API key needs app.build_native'), 'permission recovery') - assert.deepEqual(ui.selected, []) - assert.equal(ui.events.find(event => event.phase === 'error')?.result, 'build') - await stop(ui) -} - -{ - let attempts = 0 - const ui = renderGate({ - visible: [{ app_id: 'com.example.weather.dev', name: 'Weather Preview' }], - verify: async () => { - if (attempts++ === 0) - throw new AppSelectionError('build', 'Temporary permission check failure.') - }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('Which Capgo app should Builder use?'), 'explicit retry picker') - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('Temporary permission check failure.'), 'explicit retry error') - ui.stdin.send('\r') - await waitFor(() => ui.selected.length === 1, 'explicit retry success') - assert.deepEqual(ui.events.find(event => event.phase === 'resolved'), { phase: 'resolved', result: 'selected', source: 'closest_list', visibleCount: 1 }) - await stop(ui) -} - -{ - let attempts = 0 - const ui = renderGate({ - visible: [{ app_id: 'com.example.weather', name: 'Weather' }], - verify: async () => { - if (attempts++ === 0) - throw new AppSelectionError('build', 'Temporary permission check failure.') - }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('Temporary permission check failure.'), 'exact match retry error') - ui.stdin.send('\r') - await waitFor(() => ui.selected.length === 1, 'exact match retry success') - assert.deepEqual(ui.events.find(event => event.phase === 'resolved'), { phase: 'resolved', result: 'exact_match', source: undefined, visibleCount: 1 }) - await stop(ui) -} - -{ - const ui = renderGate({ visible: [{ app_id: 'com.example.weather.dev', name: 'Weather Preview' }], cols: 44, rows: 11 }) - await waitFor(() => ui.stdout.lastFrame.includes('App visible to your API key'), 'compact app screen') - assert.doesNotMatch(ui.stdout.lastFrame, /─{20,}/, 'compact terminals omit the divider') - assert.doesNotMatch(ui.stdout.lastFrame, /↑ ↓ choose · Enter select · Esc back/u, 'compact terminals omit the full navigation hint') - assert.ok(ui.stdout.lastFrame.split('\n').length <= 11, 'compact app screen fits the terminal height') - assert.ok(ui.stdout.lastFrame.split('\n').every(line => stringWidth(line) <= 44), 'compact app screen fits terminal width') - await stop(ui) -} - -console.log('Builder app selection gate passed') diff --git a/cli/test/test-builder-login-gate.mjs b/cli/test/test-builder-login-gate.mjs deleted file mode 100644 index bda4f975fb..0000000000 --- a/cli/test/test-builder-login-gate.mjs +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env bun -import assert from 'node:assert/strict' -import { EventEmitter } from 'node:events' -import { render } from 'ink' -import React from 'react' -import stringWidth from 'string-width' -import { resolveBuilderCandidateKey } from '../src/build/onboarding/login.ts' -import BuilderLoginGate from '../src/build/onboarding/ui/login-gate.tsx' -import OnboardingShell from '../src/build/onboarding/ui/shell.tsx' - -const previousToken = process.env.CAPGO_TOKEN -try { - process.env.CAPGO_TOKEN = 'env-test-key' - assert.equal(resolveBuilderCandidateKey(' explicit-test-key '), 'explicit-test-key') - assert.equal(resolveBuilderCandidateKey(' '), 'env-test-key') - assert.equal(resolveBuilderCandidateKey(), 'env-test-key') -} -finally { - if (previousToken === undefined) - delete process.env.CAPGO_TOKEN - else - process.env.CAPGO_TOKEN = previousToken -} - -console.log('Builder candidate key precedence passed') - -function makeStream(cols = 100, rows = 50) { - const stream = new EventEmitter() - stream.columns = cols - stream.rows = rows - stream.isTTY = true - stream.lastFrame = '' - stream.frames = [] - stream.write = (frame) => { - stream.lastFrame = String(frame) - stream.frames.push(stream.lastFrame) - return true - } - return stream -} - -function makeStdin() { - const stream = new EventEmitter() - const chunks = [] - stream.isTTY = true - stream.setEncoding = () => {} - stream.setRawMode = () => {} - stream.resume = () => {} - stream.pause = () => {} - stream.ref = () => {} - stream.unref = () => {} - stream.read = () => chunks.shift() ?? null - stream.send = (chunk) => { - chunks.push(chunk) - stream.emit('readable') - } - return stream -} - -async function waitFor(predicate, description = 'login UI') { - const deadline = Date.now() + 5000 - while (!predicate() && Date.now() < deadline) - await new Promise(resolve => setTimeout(resolve, 10)) - assert.ok(predicate(), `Timed out waiting for ${description}`) -} - -async function sendUntil(ui, input, predicate, description) { - const deadline = Date.now() + 5000 - while (!predicate() && Date.now() < deadline) { - ui.stdin.send(input) - await new Promise(resolve => setTimeout(resolve, 50)) - } - assert.ok(predicate(), `Timed out waiting for ${description}`) -} - -async function stop(ui) { - ui.instance.unmount() - await ui.instance.waitUntilExit() -} - -function renderGate({ cols = 100, rows = 50, candidateKey, browserAvailable = true, savePasted = async () => {}, validateExisting = async () => {}, getAccountEmail = async () => 'account@example.com', beginBrowser = async () => ({ session: 'test-session', url: 'https://console.capgo.app/login-cli?session=test-session', browserOpened: true }), completeBrowser = async () => {} } = {}) { - const stdout = makeStream(cols, rows) - const stdin = makeStdin() - const authenticated = [] - let cancelled = false - const services = { browserAvailable, savePasted, validateExisting, getAccountEmail, beginBrowser, completeBrowser } - const instance = render(React.createElement(BuilderLoginGate, { - candidateKey, - services, - cols, - rows, - onAuthenticated: (key, metadata) => authenticated.push({ key, metadata }), - onCancel: () => { cancelled = true }, - }), { stdout, stderr: makeStream(cols, rows), stdin, debug: true, exitOnCtrlC: false, patchConsole: false }) - return { stdout, stdin, authenticated, wasCancelled: () => cancelled, instance } -} - -{ - const ui = renderGate() - await waitFor(() => ui.stdout.lastFrame.includes('How would you like to log in?'), 'initial login choice') - assert.match(ui.stdout.lastFrame, /Create key in Dashboard/) - assert.match(ui.stdout.lastFrame, /Use an existing key/) - await stop(ui) -} - -{ - const submitted = [] - const ui = renderGate({ - cols: 44, - rows: 11, - browserAvailable: false, - savePasted: async key => submitted.push(key), - }) - await waitFor(() => ui.stdout.lastFrame.includes('Paste the API key'), 'compact manual entry') - assert.doesNotMatch(ui.stdout.lastFrame, /[╭╮╰╯]/u, 'small input must be unboxed') - // Ink can paint the new field before its useInput subscription is active. - await new Promise(resolve => setTimeout(resolve, 100)) - const key = '12345678-1234-1234-1234-123456789abc' - ui.stdin.send(key) - await waitFor(() => ui.stdout.lastFrame.includes('••••'), 'compact masked paste') - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('Welcome account@example.com 👋'), 'compact welcome') - assert.ok(ui.stdout.lastFrame.split('\n').length <= 11, 'compact welcome must fit the terminal') - assert.ok(ui.stdout.lastFrame.split('\n').every(line => stringWidth(line) <= 44), 'compact welcome must fit terminal width') - await waitFor(() => ui.authenticated.length === 1, 'compact key verification') - assert.deepEqual(submitted, [key]) - assert.equal(ui.authenticated[0].metadata.method, 'paste') - assert.ok(ui.stdout.lastFrame.length < 2000) - await stop(ui) -} - -console.log('Builder Ink login screen passed') - -{ - const ui = renderGate({ candidateKey: 'existing-key' }) - await waitFor(() => ui.stdout.lastFrame.includes('Welcome account@example.com 👋'), 'existing-key welcome') - assert.equal(ui.authenticated.length, 0, 'onboarding must wait for welcome screen') - const welcomeShownAt = Date.now() - await waitFor(() => ui.authenticated.length === 1, 'existing-key verification') - assert.ok(Date.now() - welcomeShownAt >= 1350, 'welcome should remain visible for about 1.5 seconds') - assert.equal(ui.authenticated[0].key, 'existing-key') - assert.equal(ui.authenticated[0].metadata.method, undefined) - assert.ok(ui.stdout.frames.every(frame => !frame.includes('How would you like to log in?'))) - await stop(ui) -} - -{ - const ui = renderGate({ candidateKey: 'existing-key', getAccountEmail: async () => { throw new Error('email unavailable') } }) - await waitFor(() => ui.stdout.lastFrame.includes('Welcome to Capgo 👋'), 'generic welcome when email lookup fails') - await waitFor(() => ui.authenticated.length === 1, 'login continues without account email') - await stop(ui) -} - -{ - let openings = 0 - const completed = [] - const ui = renderGate({ - beginBrowser: async (onUrl) => { - openings++ - const session = { session: 'test-session', url: 'https://console.capgo.app/login-cli?session=test-session', browserOpened: false } - onUrl(session.url) - return session - }, - completeBrowser: async (_session, key) => { - completed.push(key) - if (completed.length === 1) - throw new Error('invalid test key') - }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('How would you like to log in?'), 'browser login choice after existing-key check') - await sendUntil(ui, '\r', () => ui.stdout.lastFrame.includes('Open this URL in your browser:'), 'browser login entry') - assert.match(ui.stdout.lastFrame, /╭|╮/u, 'large input should be boxed') - await new Promise(resolve => setTimeout(resolve, 100)) - ui.stdin.send('invalid-key') - await waitFor(() => ui.stdout.lastFrame.includes('••••'), 'first browser masked paste') - ui.stdin.send('\r') - await waitFor(() => ui.stdout.lastFrame.includes('Paste another key'), 'invalid-key retry message') - await new Promise(resolve => setTimeout(resolve, 100)) - ui.stdin.send('valid-test-key') - await waitFor(() => ui.stdout.lastFrame.includes('••••'), 'second browser masked paste') - ui.stdin.send('\r') - await waitFor(() => ui.authenticated.length === 1, 'browser-key verification') - assert.deepEqual(completed, ['invalid-key', 'valid-test-key']) - assert.equal(openings, 1) - assert.equal(ui.authenticated[0].metadata.method, 'browser') - assert.equal(ui.authenticated[0].metadata.retryCount, 1) - assert.ok(ui.stdout.frames.every(frame => !frame.includes('invalid-key') && !frame.includes('valid-test-key'))) - await stop(ui) -} - -{ - const ui = renderGate() - await waitFor(() => ui.stdout.lastFrame.includes('How would you like to log in?'), 'login choice before Escape') - await sendUntil(ui, '\x1b', ui.wasCancelled, 'Escape cancellation') - await stop(ui) -} - -console.log('Builder browser retry and cancellation passed') - -{ - const ui = renderGate({ - cols: 44, - rows: 11, - beginBrowser: async (onUrl) => { - const session = { session: 'small-session', url: 'https://console.capgo.app/login-cli?session=small-session', browserOpened: false } - onUrl(session.url) - return session - }, - }) - await waitFor(() => ui.stdout.lastFrame.includes('How would you like to log in?'), 'small-terminal login choice') - assert.ok(ui.stdout.lastFrame.split('\n').length <= 11, 'small login choice must fit the terminal') - assert.ok(ui.stdout.lastFrame.split('\n').every(line => stringWidth(line) <= 44), 'small login choice must fit terminal width') - await sendUntil(ui, '\r', () => ui.stdout.lastFrame.includes('small-session'), 'small-terminal browser entry') - assert.match(ui.stdout.lastFrame, /Paste the API key/) - assert.doesNotMatch(ui.stdout.lastFrame, /[╭╮╰╯]/u) - assert.ok(ui.stdout.lastFrame.split('\n').length <= 11, 'small browser fallback must fit the terminal') - assert.ok(ui.stdout.lastFrame.split('\n').every(line => stringWidth(line) <= 44), 'small browser fallback must fit terminal width') - await stop(ui) -} - -console.log('Builder small-terminal browser fallback passed') - -function renderShellForLogin({ initialPlatform, list = async () => [{ app_id: 'com.example.builderlogin', name: 'Builder Login' }] } = {}) { - const stdout = makeStream(100, 50) - const stdin = makeStdin() - const resolved = [] - let finishValidation - const validation = new Promise(resolve => (finishValidation = resolve)) - const services = { - browserAvailable: true, - validateExisting: async () => validation, - getAccountEmail: async () => 'account@example.com', - savePasted: async () => {}, - beginBrowser: async () => { throw new Error('unused') }, - completeBrowser: async () => {}, - } - let instance - instance = render(React.createElement(OnboardingShell, { - appId: 'com.example.builderlogin', - iosBundleIdInitial: 'com.example.builderlogin', - iosDir: 'ios', - androidDir: 'android', - guidedHelperUsable: false, - apikey: 'existing-test-key', - loginServices: services, - suggestedSource: 'capacitor', - appSelectionServices: { - list, - verify: async () => {}, - persist: async () => false, - openDashboard: async () => true, - dashboardUrl: 'https://console.capgo.app/app/new', - }, - journeyId: 'bj_login-test', - initialPlatform, - onResolvePlatform: (platform) => { - resolved.push(platform) - instance?.unmount() - }, - }), { stdout, stderr: makeStream(100, 50), stdin, debug: true, exitOnCtrlC: false, patchConsole: false }) - return { stdout, stdin, instance, resolved, finishValidation } -} - -{ - const shell = renderShellForLogin() - await waitFor(() => shell.stdout.lastFrame.includes('Checking Capgo login'), 'shell login check') - const checkingLines = shell.stdout.lastFrame.split('\n') - const checkingLine = checkingLines.findIndex(line => line.includes('Checking Capgo login')) - assert.ok(checkingLine > 15, 'login progress should be vertically centered') - assert.match(checkingLines[checkingLine], /^\s{30,}Checking Capgo login/u, 'login progress text should be horizontally centered') - assert.ok(checkingLines[checkingLine - 2].trim(), 'spinner should be above the progress text') - assert.doesNotMatch(shell.stdout.lastFrame, /Which platform do you want to set up/) - shell.finishValidation() - await waitFor(() => shell.stdout.lastFrame.includes('Welcome account@example.com 👋'), 'shell welcome before platform choice') - assert.doesNotMatch(shell.stdout.lastFrame, /Which platform do you want to set up/) - await waitFor(() => shell.stdout.lastFrame.includes('Which platform do you want to set up'), 'platform picker after authentication') - await stop(shell) -} - -{ - const shell = renderShellForLogin({ initialPlatform: 'ios' }) - await waitFor(() => shell.stdout.lastFrame.includes('Checking Capgo login'), 'shell preselected-platform login check') - assert.deepEqual(shell.resolved, []) - shell.finishValidation() - await waitFor(() => shell.resolved.length === 1, 'preselected platform after authentication') - assert.deepEqual(shell.resolved, ['ios']) - await stop(shell) -} - -console.log('Builder shell authenticates before platform choice and auto-load') - -{ - let finishAppList - const appList = new Promise(resolve => (finishAppList = resolve)) - const shell = renderShellForLogin({ initialPlatform: 'ios', list: async () => appList }) - shell.finishValidation() - await waitFor(() => shell.stdout.lastFrame.includes('Checking Capgo apps'), 'app check after login') - assert.deepEqual(shell.resolved, [], 'preselected platform must wait for app resolution') - finishAppList([{ app_id: 'com.example.builderlogin', name: 'Builder Login' }]) - await waitFor(() => shell.resolved.length === 1, 'preselected platform after app verification') - await stop(shell) -} - -console.log('Builder shell verifies the app before --platform auto-load') diff --git a/cli/test/test-builder-project-discovery.mjs b/cli/test/test-builder-project-discovery.mjs index 0f47f0e65b..d8103080e3 100644 --- a/cli/test/test-builder-project-discovery.mjs +++ b/cli/test/test-builder-project-discovery.mjs @@ -268,21 +268,6 @@ try { assert.equal(result.candidates[0].appId, 'com.example.javascript') }) - await test('shows the configured Builder ID in workspace candidates', async () => { - const root = fixture('builder-app-id') - writeJson(join(root, 'package.json'), { private: true, workspaces: ['apps/*'] }) - const jsDir = addPackage(root, 'apps/javascript', '@example/javascript') - const jsonDir = addPackage(root, 'apps/json', '@example/json') - writeText(join(jsDir, 'capacitor.config.js'), "module.exports = { appId: 'com.example.native', plugins: { CapgoBuilder: { capgoBuilderAppId: 'com.example.builder' } } }\n") - writeJson(join(jsonDir, 'capacitor.config.json'), { - appId: 'com.example.native-json', - plugins: { CapgoBuilder: { capgoBuilderAppId: 'com.example.builder-json' } }, - }) - - const result = await discoverCapacitorProjects(root) - assert.deepEqual(result.candidates.map(candidate => candidate.appId), ['com.example.builder', 'com.example.builder-json']) - }) - await test('reads literal appId metadata without executing candidate configuration code', async () => { const root = fixture('non-executing-config-discovery') writeJson(join(root, 'package.json'), { private: true, workspaces: ['apps/*'] }) diff --git a/cli/test/test-bundle-pages.test.ts b/cli/test/test-bundle-pages.test.ts deleted file mode 100644 index f24542b961..0000000000 --- a/cli/test/test-bundle-pages.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { getActiveAppVersions } from '../src/api/versions.ts' - -function makeBundleRow(index: number) { - return { - id: index, - name: `1.0.${index}`, - app_id: 'com.test.app', - created_at: '2024-01-01T00:00:00.000Z', - deleted: false, - } -} - -function makeCannotGetBundleError(message = 'Cannot get bundle') { - const response = new Response(JSON.stringify({ error: 'cannot_get_bundle', message }), { status: 400 }) - return Object.assign(new Error('Edge Function returned a non-2xx status code'), { context: response }) -} - -function createInvokeStub(handlers: Record Promise<{ data: unknown, error: Error | null }>>) { - return async (path: string) => { - const url = new URL(path, 'https://example.test/') - const page = Number(url.searchParams.get('page') || '0') - const handler = handlers[page] - if (!handler) - throw new Error(`unexpected bundle page ${page}`) - return handler() - } -} - -describe('fetchBundlePages empty-list EOF', () => { - it('returns [] when page 0 responds with cannot_get_bundle', async () => { - const versions = await getActiveAppVersions('test-key', 'com.test.app', { - invoke: createInvokeStub({ - 0: async () => ({ data: null, error: makeCannotGetBundleError() }), - }), - }) - expect(versions).toEqual([]) - }) - - it('returns accumulated bundles when a later page responds with cannot_get_bundle', async () => { - const firstPage = Array.from({ length: 50 }, (_, index) => makeBundleRow(index)) - const versions = await getActiveAppVersions('test-key', 'com.test.app', { - invoke: createInvokeStub({ - 0: async () => ({ data: firstPage, error: null }), - 1: async () => ({ data: null, error: makeCannotGetBundleError() }), - }), - }) - expect(versions).toHaveLength(50) - expect(versions[0]?.name).toBe('1.0.0') - expect(versions[49]?.name).toBe('1.0.49') - }) - - it('still throws when cannot_get_bundle has a different message', async () => { - const firstPage = Array.from({ length: 50 }, (_, index) => makeBundleRow(index)) - await expect(getActiveAppVersions('test-key', 'com.test.app', { - invoke: createInvokeStub({ - 0: async () => ({ data: firstPage, error: null }), - 1: async () => ({ data: null, error: makeCannotGetBundleError('Access denied') }), - }), - })).rejects.toThrow(/not found in database/) - }) - - it('still throws for unrelated errors on later pages', async () => { - const firstPage = Array.from({ length: 50 }, (_, index) => makeBundleRow(index)) - await expect(getActiveAppVersions('test-key', 'com.test.app', { - invoke: createInvokeStub({ - 0: async () => ({ data: firstPage, error: null }), - 1: async () => ({ data: null, error: new Error('upstream failure') }), - }), - })).rejects.toThrow(/not found in database/) - }) -}) diff --git a/cli/test/test-cli-help.mjs b/cli/test/test-cli-help.mjs index a9cc1c1bff..7fc4ee6cfc 100644 --- a/cli/test/test-cli-help.mjs +++ b/cli/test/test-cli-help.mjs @@ -57,8 +57,4 @@ for (const help of [buildHelp, credentialsHelp, requestHelp, saveHelp, updateHel assert.doesNotMatch(help, /npx @capgo\/cli(?!@latest)/) } -const bundleUploadHelp = getHelp('bundle', 'upload') -assert.match(bundleUploadHelp, /--mode /) -assert.match(bundleUploadHelp, /cordova/) - console.log('✅ CLI help readability checks passed') diff --git a/cli/test/test-cli-user-error-config.mjs b/cli/test/test-cli-user-error-config.mjs index cb147b9691..27c72b9295 100644 --- a/cli/test/test-cli-user-error-config.mjs +++ b/cli/test/test-cli-user-error-config.mjs @@ -11,9 +11,9 @@ import { encryptZipInternal } from '../src/bundle/encrypt.ts' import { zipBundleInternal } from '../src/bundle/zip.ts' import { shouldCapturePosthogException } from '../src/posthog.ts' import { CliUserError } from '../src/shared/cli-user-error.ts' -import { getAppId, getConfigForWrite, getOrganizationId, NO_CAPACITOR_CONFIG_MESSAGE } from '../src/utils.ts' +import { getAppId, getConfigForWrite, getOrganizationId } from '../src/utils.ts' -const NO_CONFIG_MESSAGE = NO_CAPACITOR_CONFIG_MESSAGE +const NO_CONFIG_MESSAGE = 'No capacitor config file found, run `cap init` first' const ORG_ID_MESSAGE = 'Cannot get organization id for app' function assertCliUserError(error, message, contextKeys = []) { diff --git a/cli/test/test-cordova-upload-mode.mjs b/cli/test/test-cordova-upload-mode.mjs deleted file mode 100644 index a8e7c797fa..0000000000 --- a/cli/test/test-cordova-upload-mode.mjs +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { spawnSync } from 'node:child_process' -import { withCwd } from '../src/build/cwd.ts' -import { buildCordovaUploadConfig, collectCordovaAppIdCandidates, CORDOVA_DEFAULT_WEB_DIR } from '../src/cordova/project.ts' -import { buildMissingCapacitorConfigUploadMessage, enhanceMissingCapacitorConfigUploadError, loadUploadProjectConfig } from '../src/bundle/upload-config.ts' -import { CliUserError } from '../src/shared/cli-user-error.ts' -import { NO_CAPACITOR_CONFIG_MESSAGE } from '../src/utils.ts' - -const cliDir = new URL('..', import.meta.url) - -function t(name, fn) { - return (async () => { - try { - await fn() - process.stdout.write(`✓ ${name}\n`) - } - catch (error) { - process.stderr.write(`✗ ${name}\n`) - throw error - } - })() -} - -function writeCordovaProject(dir, { appId = 'com.example.cordova', webDir = 'www' } = {}) { - writeFileSync(join(dir, 'config.xml'), ` - - -`) - mkdirSync(join(dir, webDir), { recursive: true }) - writeFileSync(join(dir, webDir, 'index.html'), 'notifyAppReady()') -} - -await t('collectCordovaAppIdCandidates reads config.xml widget id', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-config-')) - try { - writeCordovaProject(dir, { appId: 'com.customer.cordova' }) - assert.deepEqual(collectCordovaAppIdCandidates(dir), ['com.customer.cordova']) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('collectCordovaAppIdCandidates ignores widget id in XML comments', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-comment-')) - try { - writeFileSync(join(dir, 'config.xml'), ` - - - -`) - assert.deepEqual(collectCordovaAppIdCandidates(dir), ['com.customer.real']) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('collectCordovaAppIdCandidates reads plugin.xml id', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-plugin-')) - try { - writeFileSync(join(dir, 'plugin.xml'), ` -`) - assert.deepEqual(collectCordovaAppIdCandidates(dir), ['com.customer.plugin']) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('buildCordovaUploadConfig defaults webDir to www', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-default-www-')) - try { - writeCordovaProject(dir) - await withCwd(dir, async () => { - const config = buildCordovaUploadConfig({}) - assert.equal(config.config.webDir, join(dir, CORDOVA_DEFAULT_WEB_DIR)) - assert.equal(config.config.appId, 'com.example.cordova') - assert.equal(config.path, '') - }) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('buildCordovaUploadConfig resolves webDir from nested working directory', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-nested-cwd-')) - const nestedDir = join(dir, 'scripts') - try { - writeCordovaProject(dir) - mkdirSync(nestedDir, { recursive: true }) - await withCwd(nestedDir, async () => { - const config = buildCordovaUploadConfig({}) - assert.equal(config.config.webDir, join(dir, CORDOVA_DEFAULT_WEB_DIR)) - assert.equal(config.config.appId, 'com.example.cordova') - }) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('loadUploadProjectConfig resolves cordova mode without capacitor.config', async () => { - const dir = mkdtempSync(join(tmpdir(), 'capgo-cordova-upload-config-')) - try { - writeCordovaProject(dir, { appId: 'com.upload.cordova', webDir: 'www' }) - await withCwd(dir, async () => { - const config = await loadUploadProjectConfig({ mode: 'cordova', path: 'www' }, { appId: 'com.upload.cordova' }) - assert.equal(config.config.webDir, 'www') - assert.equal(config.config.appId, 'com.upload.cordova') - }) - } - finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -await t('missing capacitor config suggests --mode cordova on upload', async () => { - const hint = buildMissingCapacitorConfigUploadMessage({ - appId: 'com.example.app', - path: 'www', - channel: 'production', - }) - assert.match(hint, new RegExp(NO_CAPACITOR_CONFIG_MESSAGE)) - assert.match(hint, /--mode cordova/) - assert.match(hint, /--channel production/) - assert.match(hint, /npx @capgo\/cli@latest bundle upload com\.example\.app --mode cordova --path www --channel production/) - - assert.throws( - () => enhanceMissingCapacitorConfigUploadError(new CliUserError(NO_CAPACITOR_CONFIG_MESSAGE), { - appId: 'com.example.app', - path: 'www', - channel: 'production', - }), - (error) => { - assert.equal(error instanceof CliUserError, true) - assert.equal(error.message, hint) - return true - }, - ) -}) - -await t('bundle upload rejects unknown --mode values', async () => { - const result = spawnSync(process.execPath, ['dist/index.js', 'bundle', 'upload', 'com.example.app', '--mode', 'react-native'], { - cwd: cliDir, - encoding: 'utf8', - }) - assert.notEqual(result.status, 0) - assert.match(`${result.stderr}\n${result.stdout}`, /invalid|error/i) -}) - -console.log('Cordova upload mode tests passed') diff --git a/cli/test/test-create-supabase-client.mjs b/cli/test/test-create-supabase-client.mjs index 3f5d6d3882..719a501fb1 100644 --- a/cli/test/test-create-supabase-client.mjs +++ b/cli/test/test-create-supabase-client.mjs @@ -17,17 +17,6 @@ const isolatedDir = mkdtempSync(join(tmpdir(), 'capgo-create-supabase-client-')) async function assertMissingConfig(fetchImpl, expectedContext, label) { globalThis.fetch = fetchImpl const controller = new AbortController() - const stdoutWrite = process.stdout.write - const stderrWrite = process.stderr.write - let output = '' - process.stdout.write = function (chunk, ...args) { - output += String(chunk) - return stdoutWrite.call(this, chunk, ...args) - } - process.stderr.write = function (chunk, ...args) { - output += String(chunk) - return stderrWrite.call(this, chunk, ...args) - } let thrown try { await createSupabaseClient('test-api-key', undefined, undefined, true, false, controller.signal) @@ -36,13 +25,8 @@ async function assertMissingConfig(fetchImpl, expectedContext, label) { catch (error) { thrown = error } - finally { - process.stdout.write = stdoutWrite - process.stderr.write = stderrWrite - } assert.equal(thrown instanceof CliUserError, true, label) - assert.doesNotMatch(output, /Cannot connect to server/, `silent validation must not write outside Ink (${label})`) assert.equal(thrown.message, CAPGO_SERVER_CONFIG_MISSING_MESSAGE, label) assert.equal(thrown.context?.missingSupaHost, expectedContext.missingSupaHost, label) assert.equal(thrown.context?.missingSupaKey, expectedContext.missingSupaKey, label) diff --git a/cli/test/test-ios-certificate-action.mjs b/cli/test/test-ios-certificate-action.mjs deleted file mode 100644 index eac69c0019..0000000000 --- a/cli/test/test-ios-certificate-action.mjs +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict' -import { Buffer } from 'node:buffer' -import { CertificateLimitError } from '../src/build/onboarding/apple-api.ts' -import { runIosEffect } from '../src/build/onboarding/ios/flow.ts' -import { trackCreatedIosCertificateResult, trackImportedIosCertificateSaveResult, trackIosCertificateCreationThrow, trackIosKeychainExportResult } from '../src/build/onboarding/ui/ios-certificate-action.ts' - -const journeyId = 'bj_certificate_test' -const actions = [] -const trackAction = (action, tags, step) => actions.push({ action, tags, step }) -const reportedSuccesses = new Set() -const progress = (setupMethod = 'create-new') => ({ - appId: 'com.example.certificate', - platform: 'ios', - startedAt: '2026-01-01T00:00:00.000Z', - setupMethod, - ...(setupMethod === 'import-existing' ? { importDistribution: 'ad_hoc' } : {}), - completedSteps: {}, -}) - -// The create action follows both .p12 creation and persistence of the marker. -const creationOrder = [] -const createDeps = { - generateCsr: () => ({ csr: 'PRIVATE_CSR', privateKeyPem: 'PRIVATE_KEY' }), - createCertificate: async () => ({ certificateId: 'FAKE_CERT_ID', certificateContent: 'PRIVATE_CERT', expirationDate: '2027-01-01', teamId: 'FAKE_TEAM' }), - createP12: () => { creationOrder.push('created-p12'); return 'PRIVATE_P12' }, - saveProgress: async (_appId, saved) => { - assert.equal(saved.completedSteps.certificateCreated.p12Base64, 'PRIVATE_P12') - creationOrder.push('saved-progress') - }, -} -const created = await runIosEffect('creating-certificate', progress(), createDeps) -trackCreatedIosCertificateResult(created, journeyId, (...args) => { - creationOrder.push('tracked-action') - trackAction(...args) -}, reportedSuccesses) -trackCreatedIosCertificateResult(created, journeyId, trackAction, reportedSuccesses) -assert.deepEqual(creationOrder, ['created-p12', 'saved-progress', 'tracked-action']) -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_prepared', - tags: { attempt_id: journeyId, source: 'created' }, - step: 'creating-certificate', -}]) - -const failedCreation = await runIosEffect('creating-certificate', progress(), { - ...createDeps, - createP12: () => { throw new Error('PRIVATE_CREATE_EXCEPTION') }, -}) -trackCreatedIosCertificateResult(failedCreation, journeyId, trackAction, reportedSuccesses) -assert.equal(failedCreation.next, 'error') -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_preparation_failed', - tags: { attempt_id: journeyId, source: 'created', reason: 'create_failed' }, - step: 'creating-certificate', -}]) - -const limited = await runIosEffect('creating-certificate', progress(), { - ...createDeps, - createCertificate: async () => { throw new CertificateLimitError([]) }, - listCertificates: async () => [], -}) -trackCreatedIosCertificateResult(limited, journeyId, trackAction, reportedSuccesses) -assert.equal(limited.next, 'cert-limit-prompt') -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_preparation_failed', - tags: { attempt_id: journeyId, source: 'created', reason: 'certificate_limit' }, - step: 'creating-certificate', -}]) - -const limitLookupFailed = await runIosEffect('creating-certificate', progress(), { - ...createDeps, - createCertificate: async () => { throw new CertificateLimitError([]) }, - listCertificates: async () => { throw new Error('PRIVATE_LOOKUP_EXCEPTION') }, -}) -trackCreatedIosCertificateResult(limitLookupFailed, journeyId, trackAction, reportedSuccesses) -assert.equal(limitLookupFailed.next, 'error', 'lookup failure keeps the existing onboarding error route') -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_preparation_failed', - tags: { attempt_id: journeyId, source: 'created', reason: 'certificate_limit' }, - step: 'creating-certificate', -}]) - -// A failed attempt does not reserve the success key; the retry can report success. -const retrySuccesses = new Set() -trackCreatedIosCertificateResult(failedCreation, journeyId, trackAction, retrySuccesses) -trackCreatedIosCertificateResult(created, journeyId, trackAction, retrySuccesses) -assert.deepEqual(actions.map(event => event.action), ['certificate_preparation_failed', 'certificate_prepared']) -actions.length = 0 - -const identity = { sha1: 'a'.repeat(40), name: 'Fake Distribution', type: 'distribution', teamId: 'FAKE_TEAM' } -const profile = { path: '/fake.mobileprovision', uuid: 'FAKE_PROFILE', name: 'Fake Profile', expirationDate: '2027-01-01', profileType: 'ad_hoc' } -const importDeps = { - carried: { chosenIdentity: identity, chosenProfile: profile }, - exportP12FromKeychain: async () => ({ base64: 'PRIVATE_EXPORTED_P12', passphrase: 'PRIVATE_PASSWORD' }), - readFile: async () => Buffer.from('PRIVATE_PROFILE'), -} -const exported = await runIosEffect('import-exporting', progress('import-existing'), importDeps) -trackIosKeychainExportResult(exported, journeyId, trackAction) -trackImportedIosCertificateSaveResult(exported, exported.transient, journeyId, trackAction, reportedSuccesses) -assert.equal(exported.next, 'saving-credentials') -assert.equal(exported.transient.keychainP12Exported, true) -assert.equal(actions.length, 0, 'export alone is transient and emits no success') - -const carried = { ...importDeps.carried, ...exported.transient } -const saveDeps = { - carried, - updateSavedCredentials: async (_appId, _platform, credentials) => { - assert.equal(credentials.BUILD_CERTIFICATE_BASE64, 'PRIVATE_EXPORTED_P12') - }, - deleteProgress: async () => {}, - loadProgress: async () => null, -} -await assert.rejects(() => runIosEffect('saving-credentials', progress('import-existing'), { - ...saveDeps, - updateSavedCredentials: async () => { throw new Error('PRIVATE_SAVE_EXCEPTION') }, -}), /PRIVATE_SAVE_EXCEPTION/) -assert.equal(actions.length, 0, 'failed credential save emits no success') - -const saved = await runIosEffect('saving-credentials', progress('import-existing'), saveDeps) -trackImportedIosCertificateSaveResult(saved, carried, journeyId, trackAction, reportedSuccesses) -trackImportedIosCertificateSaveResult(saved, carried, journeyId, trackAction, reportedSuccesses) -assert.equal(saved.next, 'ask-build') -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_prepared', - tags: { attempt_id: journeyId, source: 'keychain_import' }, - step: 'saving-credentials', -}]) - -const exportFailure = await runIosEffect('import-exporting', progress('import-existing'), { - ...importDeps, - exportP12FromKeychain: async () => { throw new Error('PRIVATE_EXPORT_EXCEPTION') }, -}) -trackIosKeychainExportResult(exportFailure, journeyId, trackAction) -assert.equal(exportFailure.next, 'error') -assert.deepEqual(actions.splice(0), [{ - action: 'certificate_preparation_failed', - tags: { attempt_id: journeyId, source: 'keychain_import', reason: 'export_failed' }, - step: 'import-exporting', -}]) - -const missingSelection = await runIosEffect('import-exporting', progress('import-existing'), { carried: {} }) -trackIosKeychainExportResult(missingSelection, journeyId, trackAction) -assert.equal(actions.length, 0, 'picker state alone is not an export failure') -assert.doesNotThrow(() => trackIosCertificateCreationThrow(journeyId, () => { throw new Error('telemetry unavailable') })) - -console.log('✅ iOS certificate actions follow prepared results and use safe tags') diff --git a/cli/test/test-ios-credential-action.mjs b/cli/test/test-ios-credential-action.mjs deleted file mode 100644 index 41c47c87b5..0000000000 --- a/cli/test/test-ios-credential-action.mjs +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict' -import { Buffer } from 'node:buffer' -import { runIosEffect } from '../src/build/onboarding/ios/flow.ts' -import { trackGuidedKeyValidationFailure, trackVerifiedIosKey, verifyIosKeyWithTelemetry } from '../src/build/onboarding/ui/ios-credential-action.ts' - -const journeyId = 'bj_test-journey' -const actions = [] -const emitted = [] -const trackAction = (action, tags, step) => { - const event = { action, tags, step } - actions.push(event) - emitted.push(event) -} -const progress = () => ({ - appId: 'com.example.app', - platform: 'ios', - startedAt: '2026-01-01T00:00:00.000Z', - completedSteps: {}, - keyId: 'SECRET_KEY_ID', - issuerId: 'SECRET_ISSUER_ID', - p8Path: '/secret/AuthKey_SECRET_KEY_ID.p8', -}) - -// The helper's own validation event is the only guided-path failure signal. -trackGuidedKeyValidationFailure('validation_failed', journeyId, trackAction) -assert.deepEqual(actions.shift(), { - action: 'credential_verification_failed', - tags: { - credential: 'ios_app_store_connect_api_key', - attempt_id: journeyId, - source: 'guided_helper', - }, - step: 'asc-key-generating', -}) -trackGuidedKeyValidationFailure('helper_cancelled', journeyId, trackAction) -trackGuidedKeyValidationFailure('validation_failed', journeyId, trackAction, true) -assert.equal(actions.length, 0, 'manual exit and cancellation emit no failure action') - -// The original Apple error still reaches the engine unchanged. Only the mapper's -// safe category reaches the action, even though the engine later drops status. -const appleError = Object.assign(new Error('PRIVATE_RAW_ERROR'), { status: 403 }) -const failure = await runIosEffect('verifying-key', progress(), { - carried: { p8Content: Buffer.from('PRIVATE_P8_CONTENT') }, - verifyApiKey: () => verifyIosKeyWithTelemetry( - async () => { throw appleError }, journeyId, trackAction, () => false, - ), -}) -assert.equal(failure.next, 'error') -assert.equal(failure.transient.retryStep, 'verifying-key', 'existing recovery route is preserved') -assert.deepEqual(actions.shift(), { - action: 'credential_verification_failed', - tags: { - credential: 'ios_app_store_connect_api_key', - attempt_id: journeyId, - source: 'cli_verifier', - error_category: 'apple_api_forbidden', - }, - step: 'verifying-key', -}) -assert.equal(actions.length, 0) - -const order = [] -const success = await runIosEffect('verifying-key', progress(), { - carried: { p8Content: Buffer.from('PRIVATE_P8_CONTENT') }, - verifyApiKey: () => verifyIosKeyWithTelemetry( - async () => { order.push('verified-with-apple'); return { teamId: 'SECRET_TEAM_ID' } }, - journeyId, trackAction, () => false, - ), - saveProgress: async (_appId, saved) => { - assert.deepEqual(saved.completedSteps.apiKeyVerified, { - keyId: 'SECRET_KEY_ID', - issuerId: 'SECRET_ISSUER_ID', - }) - order.push('saved-progress') - }, -}) -assert.equal(success.next, 'verify-app') -trackVerifiedIosKey(success, journeyId, (action, tags, step) => { - order.push('tracked-action') - trackAction(action, tags, step) -}) -assert.deepEqual(order, ['verified-with-apple', 'saved-progress', 'tracked-action']) -assert.deepEqual(actions.shift(), { - action: 'credential_verified', - tags: { credential: 'ios_app_store_connect_api_key', attempt_id: journeyId }, - step: 'verifying-key', -}) - -// A failed local save cannot turn a verified Apple response into a success event. -const saveFailure = await runIosEffect('verifying-key', progress(), { - carried: { p8Content: Buffer.from('PRIVATE_P8_CONTENT') }, - verifyApiKey: () => verifyIosKeyWithTelemetry( - async () => ({ teamId: 'SECRET_TEAM_ID' }), journeyId, trackAction, () => false, - ), - saveProgress: async () => { throw new Error('PRIVATE_SAVE_ERROR') }, -}) -trackVerifiedIosKey(saveFailure, journeyId, trackAction) -assert.equal(saveFailure.next, 'error') -assert.equal(actions.length, 0, 'local save failure emits neither action') - -// Missing key material fails before the shared verifier is invoked. -await assert.rejects(() => runIosEffect('verifying-key', progress(), { - readFile: async () => { throw new Error('ENOENT') }, - verifyApiKey: () => verifyIosKeyWithTelemetry( - async () => { throw new Error('should not run') }, journeyId, trackAction, () => false, - ), -}), /\.p8 content unavailable/) -assert.equal(actions.length, 0, 'missing file emits no action') - -await assert.rejects(() => verifyIosKeyWithTelemetry( - async () => { throw appleError }, journeyId, trackAction, () => true, -), error => error === appleError) -trackVerifiedIosKey({ next: 'verifying-key', progress: progress() }, journeyId, trackAction) -assert.equal(actions.length, 0, 'cancellation and merely opening verification emit no action') - -for (const forbidden of ['SECRET_KEY_ID', 'SECRET_ISSUER_ID', 'SECRET_TEAM_ID', 'PRIVATE_P8_CONTENT', 'PRIVATE_RAW_ERROR', 'PRIVATE_SAVE_ERROR', '/secret/']) - assert.equal(JSON.stringify(emitted).includes(forbidden), false, `action must omit ${forbidden}`) - -console.log('✅ iOS credential action boundaries and safe payloads') diff --git a/cli/test/test-ios-tui-routing.mjs b/cli/test/test-ios-tui-routing.mjs index 773aa7735e..5ca703ca1e 100644 --- a/cli/test/test-ios-tui-routing.mjs +++ b/cli/test/test-ios-tui-routing.mjs @@ -64,7 +64,6 @@ import process from 'node:process' const { applyIosInput, runIosEffect } = await import('../src/build/onboarding/ios/flow.ts') const { getIosResumeStep } = await import('../src/build/onboarding/ios/progress.ts') -const { routeFreshIosSetupMethod } = await import('../src/build/onboarding/ui/setup-method-route.ts') console.log('🧪 iOS choice/input ROUTING PARITY (applyIosInput → getIosResumeStep / resolver effect)\n') @@ -186,21 +185,6 @@ async function parityEffect({ step, progress, carried, deps: depsOverrides, besp // ════════════════════════════════════════════════════════════════════════════ // setup-method-select (app.tsx L3081-3103) — the macOS create-vs-import fork // ════════════════════════════════════════════════════════════════════════════ -test('fresh setup route · non-macOS skips the question; macOS shows it', () => { - const actions = [] - const trackAction = (...args) => actions.push(args) - assertEquals(routeFreshIosSetupMethod(false, 'bj_ios-setup', trackAction), 'api-key-instructions') - assertEquals(JSON.stringify(actions), JSON.stringify([['question_skipped', { - attempt_id: 'bj_ios-setup', - question_id: 'ios_setup_method', - choice: 'create-new', - reason: 'non_macos_auto_create_new', - }, 'setup-method-select']])) - actions.length = 0 - assertEquals(routeFreshIosSetupMethod(true, 'bj_ios-setup', trackAction), 'setup-method-select') - assertEquals(actions.length, 0) -}) - // Persists setupMethod. 'create' → getResumeStep (create-new, no .p8) lands on // api-key-instructions (MATCH the bespoke). 'import' → the bespoke jumps STRAIGHT // to import-scanning (the silent discovery); the engine resume, with no diff --git a/cli/test/test-notify-app-ready-background.mjs b/cli/test/test-notify-app-ready-background.mjs deleted file mode 100644 index c756a77d71..0000000000 --- a/cli/test/test-notify-app-ready-background.mjs +++ /dev/null @@ -1,753 +0,0 @@ -import assert from 'node:assert/strict' -import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { once } from 'node:events' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { createServer } from 'node:http' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterAll, test } from 'bun:test' -import { fileURLToPath } from 'node:url' -import { resolveNotifyAppReadyProject } from '../src/onboarding/notify-app-ready-project.ts' -import { scanNotifyAppReadySource } from '../src/onboarding/notify-app-ready-source.ts' -import { scanUpdaterInstalled } from '../src/onboarding/updater-installed.ts' -import { isTrustedOnboardingApiHost } from '../src/onboarding/background-api.ts' - -const fixtures = [] -const workerUrl = new URL('../dist/notify-app-ready-worker.js', import.meta.url) -const updaterWorkerUrl = new URL('../dist/updater-installed-worker.js', import.meta.url) -const combinedWorkerUrl = new URL('../dist/onboarding-worker.js', import.meta.url) -const call = "import { CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater.notifyAppReady()" - -async function workerHarness(worker = workerUrl) { - const requests = [] - const behavior = { events: 'ok', putStatus: 200, putError: false, putDelayMs: 0 } - const server = createServer(async (request, response) => { - let body = '' - for await (const chunk of request) - body += chunk - requests.push({ path: request.url, headers: request.headers, body: JSON.parse(body), method: request.method }) - if (request.method === 'POST') - behavior.onEvent?.(requests.at(-1).body) - if (request.method === 'PUT' && behavior.putError) { - request.socket.destroy() - return - } - if (behavior.redirectLocation && (request.method === 'POST' ? behavior.events === 'redirect' : behavior.putRedirect)) { - response.writeHead(307, { Location: behavior.redirectLocation }).end() - return - } - if (request.method === 'POST' && behavior.events === 'hang') - return - if (request.method === 'PUT' && behavior.putDelayMs) - await new Promise(resolve => setTimeout(resolve, behavior.putDelayMs)) - const status = request.method === 'PUT' ? behavior.putStatus : behavior.events === 'rejected' ? 503 : 200 - response.writeHead(status, { 'Content-Type': 'application/json' }).end('{"status":"ok"}') - }) - server.listen(0, '127.0.0.1') - await once(server, 'listening') - const api = `http://127.0.0.1:${server.address().port}` - const project = app(fixture(), '.', 'com.example.ready', { plugins: { CapacitorUpdater: { localApi: api } } }) - write(join(project.dir, 'src/main.ts'), call) - return { - api, project, requests, behavior, - async run(extra = {}, environment = {}) { - requests.length = 0 - const workerData = worker.href === combinedWorkerUrl.href - ? { cwd: project.dir, command: 'app list', apikey: 'fake-api-key', ...extra } - : { project: { dir: project.dir, workspaceRoot: project.workspaceRoot, appId: project.appId, webDir: project.webDir }, apiHost: api, command: 'app list', apikey: 'fake-api-key', attemptId: randomUUID(), ...extra } - const child = spawn('node', ['--input-type=module', '-e', ` - import { Worker } from 'node:worker_threads' - const worker = new Worker(new URL(${JSON.stringify(worker.href)}), { - workerData: ${JSON.stringify(workerData)}, stdout: true, stderr: true, execArgv: [] - }) - worker.on('error', () => process.exit(1)) - worker.on('exit', code => process.exit(code)) - `], { - stdio: 'ignore', - env: { ...process.env, CAPGO_DISABLE_TELEMETRY: '', CAPGO_DISABLE_POSTHOG: '', CAPGO_TRUSTED_API_ORIGINS: api, ...environment }, - }) - const timeout = setTimeout(() => child.kill(), 10_000) - try { - const [code, signal] = await once(child, 'exit') - assert.equal(signal, null, 'worker did not finish its bounded reporting') - assert.equal(code, 0) - } - finally { - clearTimeout(timeout) - } - }, - close() { - server.closeAllConnections() - server.close() - }, - } -} - -function scanEvents(requests, result, reportStatus, channel = 'notify-app-ready') { - const events = requests.filter(request => request.method === 'POST') - assert.deepEqual(events.map(request => request.body.event), ['scan_started', 'scan_ended']) - const [started, ended] = events.map(request => request.body) - assert.match(started.nonPersonTags.attempt_id, /^[0-9a-f-]{36}$/) - assert.equal(ended.nonPersonTags.attempt_id, started.nonPersonTags.attempt_id) - for (const request of events) { - assert.equal(request.path.endsWith('/private/events'), true) - assert.equal(request.headers.capgkey, 'fake-api-key') - assert.equal(request.headers['x-cli-command'], 'app list') - assert.equal(request.body.channel, channel) - assert.equal(request.body.tracking_version, 2) - assert.deepEqual(request.body.tags, { app_id: 'com.example.ready' }) - assert.equal(request.body.nonPersonTags.command_path, 'app list') - assert.equal(typeof request.body.nonPersonTags.cli_version, 'string') - assert.equal(Number.isFinite(Date.parse(request.body.timestamp)), true) - } - assert.equal(Date.parse(ended.timestamp) >= Date.parse(started.timestamp), true) - assert.equal(ended.nonPersonTags.result, result) - assert.equal(ended.nonPersonTags.todo_report_status, reportStatus) - assert.equal(typeof ended.nonPersonTags.duration_ms, 'number') - assert.equal(ended.nonPersonTags.duration_ms >= 0, true) - return started.nonPersonTags.attempt_id -} - -function write(path, content) { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, typeof content === 'string' ? content : JSON.stringify(content)) -} - -function fixture() { - const root = realpathSync(mkdtempSync(join(tmpdir(), 'capgo-ready-'))) - fixtures.push(root) - return root -} - -function app(root, path = '.', appId = 'com.example.ready', extra = {}) { - const dir = join(root, path) - const config = { appId, appName: 'Example', webDir: 'output', ...extra } - write(join(dir, 'package.json'), { name: `example-${appId}`, version: '1.0.0' }) - write(join(dir, 'capacitor.config.json'), config) - return { dir, workspaceRoot: root, appId, config, webDir: join(dir, config.webDir) } -} - -function scan(content, filename = 'main.ts') { - const project = app(fixture()) - write(join(project.dir, 'src', filename), content) - return scanNotifyAppReadySource(project) -} - -test('detects direct calls, import aliases, namespace imports, CommonJS, and optional/computed calls', () => { - for (const content of [ - call, - "import { CapacitorUpdater as Updater } from '@capgo/capacitor-updater'; Updater.notifyAppReady()", - "import * as updater from '@capgo/capacitor-updater'; updater.CapacitorUpdater.notifyAppReady()", - "const { CapacitorUpdater: Updater } = require('@capgo/capacitor-updater'); Updater.notifyAppReady()", - "import { CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater?.notifyAppReady?.()", - "import { CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater['notifyAppReady']()", - ]) { - assert.equal(scan(content), 'found', content) - } - assert.equal(scan(call, 'main.jsx'), 'found') - assert.equal(scan(call, 'main.mjs'), 'found') -}) - -test('ignores comments, strings, definitions, other packages, type imports, and shadowed bindings', () => { - for (const content of [ - `// ${call}`, - `const example = ${JSON.stringify(call)}`, - 'const CapacitorUpdater = { notifyAppReady() {} }; CapacitorUpdater.notifyAppReady()', - "import { CapacitorUpdater } from 'another-package'; CapacitorUpdater.notifyAppReady()", - "import type { CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater.notifyAppReady()", - "import { type CapacitorUpdater } from '@capgo/capacitor-updater'; CapacitorUpdater.notifyAppReady()", - "import { CapacitorUpdater } from '@capgo/capacitor-updater'; function example(CapacitorUpdater) { CapacitorUpdater.notifyAppReady() }", - "import * as updater from '@capgo/capacitor-updater'; function example(updater) { updater.CapacitorUpdater.notifyAppReady() }", - "function require() { return {} }; const { CapacitorUpdater } = require('@capgo/capacitor-updater'); CapacitorUpdater.notifyAppReady()", - ]) { - assert.equal(scan(content), 'not_found', content) - } - assert.equal(scan(`${call}; const broken = (`), 'unknown') -}) - -test('checks Vue script/setup blocks and ignores markup and HTML comments', () => { - assert.equal(scan(``, 'App.vue'), 'found') - assert.equal(scan(``, 'App.vue'), 'not_found') - assert.equal(scan(``, 'App.vue'), 'not_found') - assert.equal(scan(``, 'App.vue'), 'not_found') - assert.equal(scan(``, 'App.vue'), 'found') - assert.equal(scan(` - - diff --git a/playwright/fixtures/email-verification.ts b/playwright/fixtures/email-verification.ts deleted file mode 100644 index 3b4f642381..0000000000 --- a/playwright/fixtures/email-verification.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { Session } from '@supabase/supabase-js' -import { defaultConfig, plugin } from '@formkit/vue' -import { createPinia } from 'pinia' -import { createApp, defineComponent, h } from 'vue' -import { createRouter, createWebHistory, RouterView } from 'vue-router' -import { Toaster } from 'vue-sonner' -import { i18n } from '../../src/modules/i18n' -import { useSupabase } from '../../src/services/supabase' -import '../../src/styles/style.css' - -const state = { - sendError: null as { code: string, message: string, status: number } | null, - sendDelayMs: 0, - sends: [] as string[], - verificationError: false, - verifications: [] as string[], - captchaCallbacks: null as { error: () => void, unsupported: () => void } | null, -} -Object.assign(window, { emailVerificationPreview: state }) - -// Isolate the real page from external services; no emails or API writes occur. -window.fetch = async (input, init) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - const verifying = url.includes('/verify_email_otp') - if (verifying) - state.verifications.push(JSON.parse(String(init?.body)).token) - const failed = verifying && state.verificationError - return new Response(JSON.stringify(verifying - ? failed ? { message: 'Invalid verification code' } : { verified_at: new Date().toISOString() } - : { email_otp_verified_at: null }), { - status: failed ? 400 : 200, - headers: { 'Content-Type': 'application/json' }, - }) -} -const supabase = useSupabase() -supabase.auth.getSession = async () => ({ data: { session: { - access_token: 'fixture-token', - user: { id: '00000000-0000-4000-8000-000000000001', email: 'verification-preview@example.com' }, -} as Session }, error: null }) -supabase.auth.signInWithOtp = async () => { - state.sends.push('send') - if (state.sendDelayMs) - await new Promise(resolve => setTimeout(resolve, state.sendDelayMs)) - return { data: { user: null, session: null }, error: state.sendError as any } -} -supabase.functions.invoke = async (_, options) => { - state.verifications.push((options?.body as { token: string }).token) - return state.verificationError - ? { data: null, error: new Error('Invalid verification code') } - : { data: { verified_at: new Date().toISOString() }, error: null } -} - -// A deterministic widget exercises the page's CAPTCHA transitions in the browser. -const widgets = new Map() -Object.assign(window, { turnstile: { - render(element: HTMLElement | null, options: { 'callback': (token: string) => void, 'error-callback': (code: string) => void, 'unsupported-callback': () => void }) { - if (!element) - return 'unmounted-fixture-widget' - state.captchaCallbacks = { - error: () => options['error-callback']('fixture-error'), - unsupported: () => options['unsupported-callback'](), - } - const id = `fixture-widget-${widgets.size}` - const label = document.createElement('label') - label.className = 'flex items-center gap-3 rounded border border-slate-300 bg-slate-50 p-4 text-slate-900' - const input = document.createElement('input') - input.type = 'checkbox' - input.setAttribute('aria-label', 'Verify you are human') - input.addEventListener('change', () => { - if (input.checked) - options.callback('fixture-captcha-token') - }) - label.append(input, 'Verify you are human') - element.append(label) - widgets.set(id, { element, input }) - return id - }, - reset() { - widgets.forEach(widget => widget.input.checked = false) - }, - remove(id: string) { - widgets.get(id)?.element.replaceChildren() - widgets.delete(id) - }, -} }) - -async function mountPreview() { - const { default: ResendEmailPage } = await import('../../src/pages/resend_email.vue') - const router = createRouter({ - history: createWebHistory(), - routes: [ - { path: '/playwright/fixtures/email-verification.html', component: ResendEmailPage }, - { path: '/settings/account', component: { render: () => h('h1', 'Account settings') } }, - { path: '/login', component: { render: () => h('h1', 'Login') } }, - ], - }) - const app = createApp(defineComponent({ setup: () => () => [h(RouterView), h(Toaster)] })) - app.use(createPinia()) - app.use(plugin, defaultConfig()) - app.use(i18n) - app.use(router) - await router.isReady() - app.mount('#app') -} - -void mountPreview() diff --git a/playwright/fixtures/onboarding-setup.html b/playwright/fixtures/onboarding-setup.html deleted file mode 100644 index 988f56fd44..0000000000 --- a/playwright/fixtures/onboarding-setup.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Capgo setup preview - - -
- - - diff --git a/playwright/fixtures/onboarding-setup.ts b/playwright/fixtures/onboarding-setup.ts deleted file mode 100644 index b19659b3a1..0000000000 --- a/playwright/fixtures/onboarding-setup.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { OnboardingChannelEvent, OnboardingChannelEventProperties } from '../../src/utils/onboardingChannelAnalytics' -import { createPinia } from 'pinia' -import { createApp, defineComponent, h, onMounted, ref } from 'vue' -import { createRouter, createWebHistory, RouterView } from 'vue-router' -import AppOnboardingBuilderChecklist from '../../src/components/dashboard/AppOnboardingBuilderChecklist.vue' -import AppOnboardingCliSteps from '../../src/components/dashboard/AppOnboardingCliSteps.vue' -import AppOnboardingFlow from '../../src/components/dashboard/AppOnboardingFlow.vue' -import AppOnboardingSetupChecklist from '../../src/components/dashboard/AppOnboardingSetupChecklist.vue' -import GettingStartedNav from '../../src/components/dashboard/GettingStartedNav.vue' -import OnboardingExploreBanner from '../../src/components/dashboard/OnboardingExploreBanner.vue' -import OnboardingExploreReminder from '../../src/components/dashboard/OnboardingExploreReminder.vue' -import DialogV2 from '../../src/components/DialogV2.vue' -import { i18n } from '../../src/modules/i18n' -import { install as installOnboardingSetupNavigation } from '../../src/modules/onboarding-setup' -import GettingStartedPage from '../../src/pages/app/[app].getting-started.vue' -import { BUILDER_STEP_IDS } from '../../src/services/builderOnboardingChecklist' -import { useSupabase } from '../../src/services/supabase' -import { useMainStore } from '../../src/stores/main' -import { useOrganizationStore } from '../../src/stores/organization' -import '../../src/styles/style.css' - -const params = new URLSearchParams(location.search) -const navigationView = params.get('view') === 'navigation' || location.pathname.startsWith('/app/') || location.pathname === '/onboarding/app' -const builderComponentView = params.get('view') === 'builder' -const assignment = params.get('assignment') -const previewAppId = 'com.example.onboarding-preview' -const savedChannelStatus = params.get('channelStatus') -const state = { - version: Number(params.get('version') ?? 3), - steps: (savedChannelStatus === 'done' || savedChannelStatus === 'skipped' - ? { add_channel: { status: savedChannelStatus } } - : {}) as Record, - builderSteps: { - ios: Object.fromEntries(BUILDER_STEP_IDS.ios.map(id => [id, { status: 'pending' }])) as Record, - android: Object.fromEntries(BUILDER_STEP_IDS.android.map(id => [id, { status: 'pending' }])) as Record, - }, - selectedBuilderPlatform: params.get('platform') === 'ios' || params.get('platform') === 'android' ? params.get('platform') : null, - outcome: 'in_progress', - error: false, - requests: 0, - polls: [] as Array<{ appId: string, N: number, initial: boolean }>, - channels: (params.get('channel') === '1' ? [{ id: 1, app_id: previewAppId, name: 'staging', public: true, allow_device_self_set: true }] : []) as Array<{ id: number, app_id: string, name: string, public?: boolean, allow_device_self_set?: boolean }>, - channelError: false, - channelRequests: 0, - channelQueries: [] as string[], - channelDelayMs: Number(params.get('channelDelay') ?? 0), - channelPermissions: true, - channelInsertError: false, - channelInsertDelayMs: 0, - channelInserts: [] as Record[], - appWrites: [] as Array<{ method: string, body: unknown }>, -} -const events: string[] = [] -const channelEvents: Array<{ event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties }> = [] -const preview = { state, events, channelEvents, appId: ref(previewAppId), command: ref('npx @capgo/cli@latest i [API_KEY]'), hiding: ref(false), selectedOrgId: ref('') } -Object.assign(window, { onboardingSetupPreview: preview }) - -function previewOnboarding() { - const selectedPath = assignment === 'both-ota' || assignment === 'ota-only' ? 'ota' : 'builder' - const hasBuilder = builderComponentView || assignment === 'builder-only' || assignment === 'both-builder' || assignment === 'both-ota' - const hasOta = assignment === 'ota-only' || assignment === 'both-builder' || assignment === 'both-ota' - if (hasBuilder || hasOta) { - return { setup: { - todo_list_version: 4, - ...(hasBuilder ? { builder_todo_list_version: '1' } : {}), - ...(hasOta ? { ota_todo_list_version: '1' } : {}), - paths: [hasOta ? 'ota' : null, hasBuilder ? 'builder' : null].filter(Boolean), - ...(assignment === 'builder-only' ? {} : { selected_path: selectedPath }), - ...(state.selectedBuilderPlatform ? { selected_builder_platform: state.selectedBuilderPlatform } : {}), - steps: { - ...(hasOta ? { ota: {} } : {}), - ...(hasBuilder ? { builder: state.builderSteps } : {}), - }, - outcome: state.outcome, - } } - } - return { setup: { todo_list_version: state.version, steps: state.steps, outcome: state.outcome } } -} - -// This isolated component fixture never sends requests to production. -window.fetch = async (input, init) => { - const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, location.origin) - const method = init?.method?.toUpperCase() ?? 'GET' - if (url.pathname.endsWith('/apps') && method !== 'GET' && method !== 'HEAD') { - state.appWrites.push({ - method, - body: init?.body ? JSON.parse(String(init.body)) : undefined, - }) - } - if (url.pathname.endsWith('/onboarding_progress')) { - const body = JSON.parse(String(init?.body)) - state.polls.push(body) - state.requests += 1 - const checkChannel = body.initial || body.N % 5 === 0 - const hasChannel = state.channels.some(channel => channel.app_id === body.appId) - const channelError = state.channelError - if (checkChannel) { - state.channelRequests += 1 - state.channelQueries.push(`eq.${body.appId}`) - if (state.channelDelayMs) - await new Promise(resolve => setTimeout(resolve, state.channelDelayMs)) - } - return new Response(JSON.stringify(state.error - ? { message: 'Progress unavailable' } - : { - onboarding: { setup: { todo_list_version: state.version, steps: state.steps, outcome: state.outcome } }, - ...(checkChannel && !channelError ? { hasChannel } : {}), - checkErrors: checkChannel && channelError ? ['add_channel'] : [], - }), { status: state.error ? 503 : 200, headers: { 'Content-Type': 'application/json' } }) - } - if (url.pathname.includes('/rpc/')) { - return new Response(JSON.stringify(state.channelPermissions), { headers: { 'Content-Type': 'application/json' } }) - } - if (url.pathname.endsWith('/channels')) { - if (init?.method === 'POST') { - const insert = JSON.parse(String(init.body)) - state.channelInserts.push(insert) - if (state.channelInsertDelayMs) - await new Promise(resolve => setTimeout(resolve, state.channelInsertDelayMs)) - if (state.channelInsertError) - return new Response(JSON.stringify({ message: 'Insert unavailable' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) - state.channels.push({ id: state.channels.length + 1, ...insert }) - return new Response(null, { status: 201 }) - } - state.channelRequests += 1 - const filter = url.searchParams.get('app_id') ?? '' - state.channelQueries.push(filter) - const rows = state.channels.filter(channel => ( - (!filter || filter === `eq.${channel.app_id}`) - && (!url.searchParams.get('public') || url.searchParams.get('public') === `eq.${channel.public}`) - && (!url.searchParams.get('name') || url.searchParams.get('name') === `eq.${channel.name}`) - )).slice(0, Number(url.searchParams.get('limit') ?? state.channels.length)) - const error = state.channelError - if (state.channelDelayMs > 0) - await new Promise(resolve => setTimeout(resolve, state.channelDelayMs)) - return new Response(JSON.stringify(error ? { message: 'Channels unavailable' } : rows), { - status: error ? 503 : 200, - headers: { 'Content-Type': 'application/json' }, - }) - } - if (params.get('view') === 'flow' || navigationView) { - const app = { id: '00000000-0000-4000-8000-000000000003', app_id: previewAppId, name: 'My Capacitor app', icon_url: '', owner_org: '00000000-0000-4000-8000-000000000002', need_onboarding: true, onboarding: previewOnboarding() } - const user = { id: '00000000-0000-4000-8000-000000000001', email: 'preview@example.com', onboarding: { intent: assignment ? 'builder' : 'ota', status: 'in_progress', step: 'setup', flow: 'app', setup_stage: 'cli', app_id: previewAppId } } - const rows = url.pathname.endsWith('/apps') ? (!url.searchParams.get('owner_org') || url.searchParams.get('owner_org') === `eq.${app.owner_org}` ? [app] : []) : url.pathname.endsWith('/users') ? [user] : url.pathname.endsWith('/apikeys') ? [{ key: '00000000-0000-4000-8000-000000000004', rbac_id: '00000000-0000-4000-8000-000000000005', expires_at: null }] : url.pathname.endsWith('/role_bindings') ? [{ principal_id: '00000000-0000-4000-8000-000000000005', scope_type: 'org', roles: { name: 'org_super_admin' } }] : [] - const single = new Headers(init?.headers).get('Accept')?.includes('object') - return new Response(JSON.stringify(single ? rows[0] ?? {} : rows), { headers: { 'Content-Type': 'application/json' } }) - } - state.requests += 1 - return new Response(JSON.stringify(state.error - ? { message: 'Progress unavailable' } - : [{ - onboarding: previewOnboarding(), - }]), { - status: state.error ? 503 : 200, - headers: { 'Content-Type': 'application/json' }, - }) -} - -// Supply a fixture-only session. All fetch calls above are intercepted. -useSupabase().auth.getSession = async () => ({ data: { session: { access_token: 'fixture-token' } as any }, error: null }) - -const app = createApp(defineComponent({ - setup() { - return () => h('main', { - class: 'min-h-screen bg-slate-50 px-4 py-8 sm:px-6 lg:px-8 dark:bg-slate-950', - }, [ - h('div', { class: 'mx-auto max-w-6xl' }, [ - navigationView - ? h(RouterView) - : params.get('view') === 'flow' - ? h(AppOnboardingFlow, { onboarding: true }) - : builderComponentView - ? h(AppOnboardingBuilderChecklist, { - initialOnboarding: previewOnboarding(), - command: 'npx @capgo/cli@latest build init -a [API_KEY]', - hiding: preview.hiding.value, - leaving: false, - onCopyCommand: platform => events.push(`copy-builder-${platform}`), - onHide: () => events.push('hide'), - onExplore: () => events.push('explore'), - }) - : params.get('view') === 'compact' - ? h(AppOnboardingCliSteps, { appId: preview.appId.value }) - : h(AppOnboardingSetupChecklist, { - appId: preview.appId.value, - command: preview.command.value, - hiding: preview.hiding.value, - leaving: false, - onCopyCommand: async () => { - events.push('copy-command') - await navigator.clipboard.writeText(preview.command.value) - }, - onCopyAi: () => events.push('copy-ai'), - onHide: () => events.push('hide'), - onExplore: () => events.push('explore'), - onComplete: () => events.push('complete'), - onInviteOpened: () => events.push('invite-opened'), - onChannelAnalytics: (event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties) => channelEvents.push({ event, properties }), - }), - ]), - h(DialogV2), - ]) - }, -})) -const pinia = createPinia() -app.use(pinia) -// Supply identity to the real channel form without starting dashboard store watchers. -Object.defineProperty(useMainStore(pinia), 'user', { value: { id: '00000000-0000-4000-8000-000000000001', email: 'preview@example.com', onboarding: { intent: assignment ? 'builder' : 'ota', status: 'in_progress', step: 'setup', flow: 'app', setup_stage: 'cli', app_id: previewAppId } } }) -Object.defineProperty(useMainStore(pinia), 'auth', { value: { id: '00000000-0000-4000-8000-000000000001' } }) -useMainStore(pinia).awaitInitialLoad = async () => true -const organization = useOrganizationStore(pinia) -const previewOrganization = { gid: '00000000-0000-4000-8000-000000000002' } -const selectedOrganization = ref(params.get('wrongOrg') === '1' ? { gid: '00000000-0000-4000-8000-000000000099' } : previewOrganization) -preview.selectedOrgId.value = selectedOrganization.value.gid -Object.defineProperty(organization, 'currentOrganization', { get: () => selectedOrganization.value }) -organization.getOrgByAppId = appId => appId === previewAppId ? previewOrganization as any : undefined -organization.setCurrentOrganization = (orgId) => { - if (orgId === previewOrganization.gid) - selectedOrganization.value = previewOrganization - preview.selectedOrgId.value = selectedOrganization.value.gid -} -organization.awaitInitialLoad = async () => true -organization.getAppsByOrgId = orgId => orgId === previewOrganization.gid ? [{ app_id: previewAppId, owner_org: orgId, name: 'My Capacitor app', icon_url: '', need_onboarding: true, onboarding: previewOnboarding() }] : [] -app.use(i18n) -const router = createRouter({ - history: createWebHistory(), - routes: navigationView - ? [ - { path: '/app/:app', component: defineComponent({ setup: () => () => h('section', { 'data-test': 'preview-app-dashboard' }, [h('h1', 'App dashboard'), h(GettingStartedNav), h(OnboardingExploreBanner, { appId: previewAppId }), h(OnboardingExploreReminder, { appId: previewAppId })]) }) }, - { path: '/app/:app/getting-started', name: '/app/[app].getting-started', component: defineComponent({ - setup() { - onMounted(() => events.push('getting-started-mounted')) - return () => h(GettingStartedPage) - }, - }) }, - { path: '/:pathMatch(.*)*', component: defineComponent({ setup: () => () => h(AppOnboardingFlow, { onboarding: true }) }) }, - ] - : [{ path: '/:pathMatch(.*)*', component: { render: () => null } }], -}) -installOnboardingSetupNavigation({ app, router, routes: router.options.routes }) -app.use(router) -void router.isReady().then(() => app.mount('#app')) diff --git a/playwright/visual-diff.config.ts b/playwright/visual-diff.config.ts index 6035201cde..daf25a5757 100644 --- a/playwright/visual-diff.config.ts +++ b/playwright/visual-diff.config.ts @@ -1,4 +1,5 @@ import type { Page } from '@playwright/test' +import { dismissSupportPrompt } from './support/dismissSupportPrompt' export interface VisualDiffRoute { slug: string @@ -17,29 +18,6 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'login', path: '/login/', auth: false }, { slug: 'dashboard', path: '/dashboard', auth: true }, { slug: 'account-settings', path: '/settings/account', auth: true }, - { - slug: 'email-verification-send', - path: '/resend_email', - auth: true, - prepare: async (page) => { - await page.route('**/rest/v1/user_security?*', route => route.fulfill({ json: { email_otp_verified_at: null } })) - await page.goto('/resend_email?reason=email_not_verified&return_to=/settings/account') - await page.getByRole('button', { name: 'Send verification code', exact: true }).waitFor() - }, - }, - { - slug: 'email-verification-code', - path: '/resend_email', - auth: true, - prepare: async (page) => { - // Keep screenshots deterministic and never send a real verification email. - await page.route('**/rest/v1/user_security?*', route => route.fulfill({ json: { email_otp_verified_at: null } })) - await page.route('**/auth/v1/otp', route => route.fulfill({ json: { user: null, session: null } })) - await page.goto('/resend_email?reason=email_not_verified&return_to=/settings/account') - await page.getByRole('button', { name: 'Send verification code', exact: true }).click() - await page.getByLabel('Enter the verification code', { exact: true }).waitFor() - }, - }, { slug: 'organization-credits', path: '/settings/organization/credits', auth: true }, { slug: 'apps', path: '/apps', auth: true }, { @@ -47,6 +25,7 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ path: '/apps', auth: true, prepare: async (page) => { + await dismissSupportPrompt(page) const toggle = page.locator('[data-test="sidebar-collapse-toggle"]') if (!(await toggle.count())) return @@ -65,26 +44,6 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'app-dashboard-native', path: '/app/com.demo.app/native', auth: true }, { slug: 'app-dashboard-installs', path: '/app/com.demo.app/installs', auth: true }, { slug: 'app-dashboard-active-bundle', path: '/app/com.demo.app/active-bundle', auth: true }, - { - slug: 'onboarding-setup-v3', - path: '/apps', - auth: true, - prepare: async (page) => { - // Read-only response fixtures let both base and head render the same app. - // The base ignores version 3; the head shows the experiment treatment. - const onboarding = { setup: { todo_list_version: 3, source: 'manual', outcome: 'in_progress', steps: {} } } - await page.route('**/rest/v1/apps?*', async (route) => { - const response = await route.fetch() - const json = await response.json().catch(() => null) - const override = (row: any) => row?.app_id === 'com.demo.app' ? { ...row, need_onboarding: true, onboarding } : row - await route.fulfill({ response, json: Array.isArray(json) ? json.map(override) : override(json) }) - }) - await page.route('**/rpc/verify_getting_started', route => route.fulfill({ json: onboarding })) - await page.route('**/private/onboarding_progress', route => route.fulfill({ json: { onboarding, hasChannel: false, checkErrors: [] } })) - await page.goto('/app/new?resume=com.demo.app&step=setup') - await page.getByRole('heading', { name: /Start guided setup|Finish setup in your app/ }).waitFor() - }, - }, { slug: 'app-getting-started', path: '/app/com.demo.app/getting-started', auth: true }, { slug: 'app-settings', path: '/app/com.demo.app/settings', auth: true }, { slug: 'app-settings-access', path: '/app/com.demo.app/settings/access', auth: true }, diff --git a/private/cli-mcp-tests b/private/cli-mcp-tests index 1b5f76e690..a1a3e74785 160000 --- a/private/cli-mcp-tests +++ b/private/cli-mcp-tests @@ -1 +1 @@ -Subproject commit 1b5f76e6904b44e841cd096476885fba3ad800bd +Subproject commit a1a3e74785e7f9834ee197659e89d8cea98de7d2 diff --git a/read_replicate/schema_replicate.catalog.json b/read_replicate/schema_replicate.catalog.json index 365721a2d9..b7fcb969bb 100644 --- a/read_replicate/schema_replicate.catalog.json +++ b/read_replicate/schema_replicate.catalog.json @@ -2251,13 +2251,6 @@ "table": "apps", "valid": true }, - { - "constraintOwned": false, - "definition": "CREATE INDEX idx_apps_onboarding_login_creator ON public.apps USING btree (((onboarding ->> 'created_by_user_id'::text))) WHERE ((onboarding #>> '{setup,todo_list_version}'::text[]) = ANY (ARRAY['2'::text, '3'::text, '4'::text]))", - "name": "idx_apps_onboarding_login_creator", - "table": "apps", - "valid": true - }, { "constraintOwned": false, "definition": "CREATE INDEX idx_apps_onboarding_ota_stage ON public.apps USING btree (((((onboarding -> 'features'::text) -> 'ota'::text) ->> 'stage'::text)))", @@ -2267,15 +2260,15 @@ }, { "constraintOwned": false, - "definition": "CREATE INDEX idx_apps_onboarding_queued_refresh_at ON public.apps USING btree (COALESCE((onboarding ->> 'queued_refresh_at'::text), ''::text), COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id)", - "name": "idx_apps_onboarding_queued_refresh_at", + "definition": "CREATE INDEX idx_apps_onboarding_refreshed_at ON public.apps USING btree (COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id)", + "name": "idx_apps_onboarding_refreshed_at", "table": "apps", "valid": true }, { "constraintOwned": false, - "definition": "CREATE INDEX idx_apps_onboarding_refreshed_at ON public.apps USING btree (COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id)", - "name": "idx_apps_onboarding_refreshed_at", + "definition": "CREATE INDEX idx_apps_onboarding_v2_creator ON public.apps USING btree (((onboarding ->> 'created_by_user_id'::text))) WHERE ((onboarding #>> '{setup,todo_list_version}'::text[]) = '2'::text)", + "name": "idx_apps_onboarding_v2_creator", "table": "apps", "valid": true }, @@ -2421,8 +2414,8 @@ }, { "constraintOwned": false, - "definition": "CREATE INDEX idx_manifest_app_version_id_file_hash ON public.manifest USING btree (app_version_id, file_hash) INCLUDE (file_size)", - "name": "idx_manifest_app_version_id_file_hash", + "definition": "CREATE INDEX idx_manifest_app_version_id ON public.manifest USING btree (app_version_id)", + "name": "idx_manifest_app_version_id", "table": "manifest", "valid": true }, diff --git a/read_replicate/schema_replicate.sql b/read_replicate/schema_replicate.sql index 5606d743ac..0616329529 100644 --- a/read_replicate/schema_replicate.sql +++ b/read_replicate/schema_replicate.sql @@ -877,13 +877,6 @@ CREATE INDEX idx_apps_created_at ON public.apps USING btree (created_at); CREATE INDEX idx_apps_default_upload_channel ON public.apps USING btree (default_upload_channel); --- --- Name: idx_apps_onboarding_login_creator; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_apps_onboarding_login_creator ON public.apps USING btree (((onboarding ->> 'created_by_user_id'::text))) WHERE ((onboarding #>> '{setup,todo_list_version}'::text[]) = ANY (ARRAY['2'::text, '3'::text, '4'::text])); - - -- -- Name: idx_apps_onboarding_ota_stage; Type: INDEX; Schema: public; Owner: - -- @@ -892,17 +885,17 @@ CREATE INDEX idx_apps_onboarding_ota_stage ON public.apps USING btree (((((onboa -- --- Name: idx_apps_onboarding_queued_refresh_at; Type: INDEX; Schema: public; Owner: - +-- Name: idx_apps_onboarding_refreshed_at; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_apps_onboarding_queued_refresh_at ON public.apps USING btree (COALESCE((onboarding ->> 'queued_refresh_at'::text), ''::text), COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id); +CREATE INDEX idx_apps_onboarding_refreshed_at ON public.apps USING btree (COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id); -- --- Name: idx_apps_onboarding_refreshed_at; Type: INDEX; Schema: public; Owner: - +-- Name: idx_apps_onboarding_v2_creator; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_apps_onboarding_refreshed_at ON public.apps USING btree (COALESCE((onboarding ->> 'refreshed_at'::text), ''::text), app_id); +CREATE INDEX idx_apps_onboarding_v2_creator ON public.apps USING btree (((onboarding ->> 'created_by_user_id'::text))) WHERE ((onboarding #>> '{setup,todo_list_version}'::text[]) = '2'::text); -- @@ -955,10 +948,10 @@ CREATE INDEX idx_channels_rollout_version ON public.channels USING btree (rollou -- --- Name: idx_manifest_app_version_id_file_hash; Type: INDEX; Schema: public; Owner: - +-- Name: idx_manifest_app_version_id; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_manifest_app_version_id_file_hash ON public.manifest USING btree (app_version_id, file_hash) INCLUDE (file_size); +CREATE INDEX idx_manifest_app_version_id ON public.manifest USING btree (app_version_id); -- diff --git a/scripts/bench/manifest_size_lookup_results.json b/scripts/bench/manifest_size_lookup_results.json deleted file mode 100644 index a0d9606337..0000000000 --- a/scripts/bench/manifest_size_lookup_results.json +++ /dev/null @@ -1,209 +0,0 @@ -{ - "generatedAt": "2026-09-23T15:17:00.777Z", - "counts": { - "manifest_rows": 960000, - "versions": 480 - }, - "settings": [ - { - "name": "jit", - "setting": "off", - "unit": null - }, - { - "name": "server_version", - "setting": "17.11", - "unit": null - }, - { - "name": "shared_buffers", - "setting": "65536", - "unit": "8kB" - }, - { - "name": "work_mem", - "setting": "32768", - "unit": "kB" - } - ], - "reports": [ - { - "name": "fallback version id, 2000 hashes", - "side": "before", - "timing": { - "rows": 2000, - "timesMs": [ - 164.4, - 166.5, - 165.7, - 165.7, - 164.8 - ], - "medianMs": 165.7, - "minMs": 164.4, - "maxMs": 166.5 - }, - "skipped": null, - "explain": "GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=314.072..314.337 rows=2000 loops=1)\n Group Key: request_files.file_hash, app_versions.id\n Buffers: shared hit=46002\n -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=314.067..314.111 rows=2000 loops=1)\n Sort Key: request_files.file_hash, app_versions.id\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=46002\n -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.252..313.311 rows=2000 loops=1)\n Join Filter: (manifest.file_hash = request_files.file_hash)\n Rows Removed by Join Filter: 3998000\n Buffers: shared hit=46002\n -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.246..9.374 rows=2000 loops=1)\n Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.id = '1'::bigint)))\n Rows Removed by Join Filter: 158000\n Buffers: shared hit=2\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.237..0.410 rows=2000 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Materialize (cost=4.77..10.17 rows=80 width=8) (actual time=0.000..0.002 rows=80 loops=2000)\n Buffers: shared hit=2\n -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=8) (actual time=0.006..0.010 rows=80 loops=1)\n Recheck Cond: (app_id = 'com.bench.size'::text)\n Filter: (NOT deleted)\n Heap Blocks: exact=1\n Buffers: shared hit=2\n -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.003..0.003 rows=80 loops=1)\n Index Cond: (app_id = 'com.bench.size'::text)\n Buffers: shared hit=1\n -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.082 rows=2000 loops=2000)\n Index Cond: (app_version_id = app_versions.id)\n Buffers: shared hit=46000\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.192 ms\nExecution Time: 314.423 ms" - }, - { - "name": "fallback version id, 100 hashes", - "side": "before", - "timing": { - "rows": 100, - "timesMs": [ - 8.6, - 8.6, - 8.5, - 8.5, - 8.6 - ], - "medianMs": 8.6, - "minMs": 8.5, - "maxMs": 8.6 - }, - "skipped": null, - "explain": "GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=15.743..15.759 rows=100 loops=1)\n Group Key: request_files.file_hash, app_versions.id\n Buffers: shared hit=2302\n -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=15.742..15.745 rows=100 loops=1)\n Sort Key: request_files.file_hash, app_versions.id\n Sort Method: quicksort Memory: 28kB\n Buffers: shared hit=2302\n -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.028..15.711 rows=100 loops=1)\n Join Filter: (manifest.file_hash = request_files.file_hash)\n Rows Removed by Join Filter: 199900\n Buffers: shared hit=2302\n -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.022..0.486 rows=100 loops=1)\n Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.id = '1'::bigint)))\n Rows Removed by Join Filter: 7900\n Buffers: shared hit=2\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.015..0.023 rows=100 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Materialize (cost=4.77..10.17 rows=80 width=8) (actual time=0.000..0.002 rows=80 loops=100)\n Buffers: shared hit=2\n -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=8) (actual time=0.004..0.007 rows=80 loops=1)\n Recheck Cond: (app_id = 'com.bench.size'::text)\n Filter: (NOT deleted)\n Heap Blocks: exact=1\n Buffers: shared hit=2\n -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.001..0.002 rows=80 loops=1)\n Index Cond: (app_id = 'com.bench.size'::text)\n Buffers: shared hit=1\n -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.083 rows=2000 loops=100)\n Index Cond: (app_version_id = app_versions.id)\n Buffers: shared hit=2300\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.126 ms\nExecution Time: 15.790 ms" - }, - { - "name": "fallback version name, 2000 hashes", - "side": "before", - "timing": { - "rows": 2000, - "timesMs": [ - 165.1, - 165.2, - 164.6, - 166.4, - 168.6 - ], - "medianMs": 165.2, - "minMs": 164.6, - "maxMs": 168.6 - }, - "skipped": null, - "explain": "GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=313.639..313.914 rows=2000 loops=1)\n Group Key: request_files.file_hash, app_versions.id\n Buffers: shared hit=46002\n -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=313.635..313.680 rows=2000 loops=1)\n Sort Key: request_files.file_hash, app_versions.id\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=46002\n -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.260..312.901 rows=2000 loops=1)\n Join Filter: (manifest.file_hash = request_files.file_hash)\n Rows Removed by Join Filter: 3998000\n Buffers: shared hit=46002\n -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.254..9.946 rows=2000 loops=1)\n Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.name = '1'::text)))\n Rows Removed by Join Filter: 158000\n Buffers: shared hit=2\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.242..0.397 rows=2000 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Materialize (cost=4.77..10.17 rows=80 width=11) (actual time=0.000..0.002 rows=80 loops=2000)\n Buffers: shared hit=2\n -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=11) (actual time=0.008..0.012 rows=80 loops=1)\n Recheck Cond: (app_id = 'com.bench.size'::text)\n Filter: (NOT deleted)\n Heap Blocks: exact=1\n Buffers: shared hit=2\n -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.004..0.004 rows=80 loops=1)\n Index Cond: (app_id = 'com.bench.size'::text)\n Buffers: shared hit=1\n -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.081 rows=2000 loops=2000)\n Index Cond: (app_version_id = app_versions.id)\n Buffers: shared hit=46000\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.210 ms\nExecution Time: 314.009 ms" - }, - { - "name": "per-file version id, 2000 hashes", - "side": "before", - "timing": { - "rows": 2000, - "timesMs": [ - 165.2, - 167.9, - 168.5, - 164.7, - 163.9 - ], - "medianMs": 165.2, - "minMs": 163.9, - "maxMs": 168.5 - }, - "skipped": null, - "explain": "GroupAggregate (cost=8277.41..8278.55 rows=57 width=48) (actual time=313.753..314.020 rows=2000 loops=1)\n Group Key: request_files.file_hash, app_versions.id\n Buffers: shared hit=46007\n -> Sort (cost=8277.41..8277.55 rows=57 width=48) (actual time=313.748..313.790 rows=2000 loops=1)\n Sort Key: request_files.file_hash, app_versions.id\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=46007\n -> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.278..312.968 rows=2000 loops=1)\n Join Filter: (manifest.file_hash = request_files.file_hash)\n Rows Removed by Join Filter: 3998000\n Buffers: shared hit=46007\n -> Nested Loop (cost=0.28..211.68 rows=57 width=40) (actual time=0.272..10.262 rows=2000 loops=1)\n Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR (request_files.version_id IS NULL))\n Rows Removed by Join Filter: 158000\n Buffers: shared hit=7\n -> Index Scan using app_versions_pkey on app_versions (cost=0.27..31.67 rows=80 width=8) (actual time=0.005..0.035 rows=80 loops=1)\n Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text))\n Rows Removed by Filter: 400\n Buffers: shared hit=7\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.003..0.062 rows=2000 loops=80)\n Filter: (file_hash IS NOT NULL)\n -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.081 rows=2000 loops=2000)\n Index Cond: (app_version_id = app_versions.id)\n Buffers: shared hit=46000\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.174 ms\nExecution Time: 314.111 ms" - }, - { - "name": "no version, 2000 hashes", - "side": "before", - "timing": { - "rows": 160000, - "timesMs": [ - 12804, - 12741.6, - 12659.9, - 12751, - 12845.7 - ], - "medianMs": 12751, - "minMs": 12659.9, - "maxMs": 12845.7 - }, - "skipped": null, - "explain": "GroupAggregate (cost=8277.41..8278.55 rows=57 width=48) (actual time=24584.193..24604.666 rows=160000 loops=1)\n Group Key: request_files.file_hash, app_versions.id\n Buffers: shared hit=3898007\n -> Sort (cost=8277.41..8277.55 rows=57 width=48) (actual time=24584.186..24587.946 rows=160000 loops=1)\n Sort Key: request_files.file_hash, app_versions.id\n Sort Method: quicksort Memory: 13583kB\n Buffers: shared hit=3898007\n -> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.274..24501.270 rows=160000 loops=1)\n Join Filter: (manifest.file_hash = request_files.file_hash)\n Rows Removed by Join Filter: 319840000\n Buffers: shared hit=3898007\n -> Nested Loop (cost=0.28..211.68 rows=57 width=40) (actual time=0.266..29.543 rows=160000 loops=1)\n Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR (request_files.version_id IS NULL))\n Buffers: shared hit=7\n -> Index Scan using app_versions_pkey on app_versions (cost=0.27..31.67 rows=80 width=8) (actual time=0.007..0.175 rows=80 loops=1)\n Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text))\n Rows Removed by Filter: 400\n Buffers: shared hit=7\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.003..0.168 rows=2000 loops=80)\n Filter: (file_hash IS NOT NULL)\n -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.082 rows=2000 loops=160000)\n Index Cond: (app_version_id = app_versions.id)\n Buffers: shared hit=3898000\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.223 ms\nExecution Time: 24608.058 ms" - }, - { - "name": "fallback version id, 2000 hashes", - "side": "after", - "timing": { - "rows": 2000, - "timesMs": [ - 4.9, - 5, - 5, - 5, - 5.3 - ], - "medianMs": 5, - "minMs": 4.9, - "maxMs": 5.3 - }, - "skipped": null, - "explain": "GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=3.927..4.191 rows=2000 loops=1)\n Group Key: r.file_hash\n Buffers: shared hit=12001\n CTE requested\n -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.491..0.593 rows=2000 loops=1)\n Group Key: request_files.file_hash, request_files.version_id\n Batches: 1 Memory Usage: 257kB\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.218..0.286 rows=2000 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=3.926..3.969 rows=2000 loops=1)\n Sort Key: r.file_hash\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=12001\n -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.503..3.211 rows=2000 loops=1)\n Buffers: shared hit=12001\n -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.499..2.374 rows=2000 loops=1)\n Buffers: shared hit=6001\n -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.491..0.770 rows=2000 loops=1)\n Filter: (version_id IS NULL)\n -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000)\n Index Cond: ((app_version_id = '1'::bigint) AND (file_hash = r.file_hash))\n Heap Fetches: 0\n Buffers: shared hit=6001\n -> Index Scan using app_versions_pkey on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=2000)\n Index Cond: (id = '1'::bigint)\n Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text))\n Buffers: shared hit=6000\nPlanning Time: 0.081 ms\nExecution Time: 4.275 ms" - }, - { - "name": "fallback version id, 100 hashes", - "side": "after", - "timing": { - "rows": 100, - "timesMs": [ - 0.5, - 0.5, - 0.6, - 0.5, - 0.5 - ], - "medianMs": 0.5, - "minMs": 0.5, - "maxMs": 0.6 - }, - "skipped": null, - "explain": "GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=0.181..0.195 rows=100 loops=1)\n Group Key: r.file_hash\n Buffers: shared hit=601\n CTE requested\n -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.025..0.029 rows=100 loops=1)\n Group Key: request_files.file_hash, request_files.version_id\n Batches: 1 Memory Usage: 24kB\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.012..0.017 rows=100 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=0.181..0.183 rows=100 loops=1)\n Sort Key: r.file_hash\n Sort Method: quicksort Memory: 28kB\n Buffers: shared hit=601\n -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.033..0.159 rows=100 loops=1)\n Buffers: shared hit=601\n -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.030..0.115 rows=100 loops=1)\n Buffers: shared hit=301\n -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.025..0.039 rows=100 loops=1)\n Filter: (version_id IS NULL)\n -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=100)\n Index Cond: ((app_version_id = '1'::bigint) AND (file_hash = r.file_hash))\n Heap Fetches: 0\n Buffers: shared hit=301\n -> Index Scan using app_versions_pkey on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=100)\n Index Cond: (id = '1'::bigint)\n Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text))\n Buffers: shared hit=300\nPlanning Time: 0.043 ms\nExecution Time: 0.218 ms" - }, - { - "name": "fallback version name, 2000 hashes", - "side": "after", - "timing": { - "rows": 2000, - "timesMs": [ - 4.6, - 4.2, - 5.2, - 4.5, - 4.3 - ], - "medianMs": 4.5, - "minMs": 4.2, - "maxMs": 5.2 - }, - "skipped": null, - "explain": "GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=3.176..3.452 rows=2000 loops=1)\n Group Key: r.file_hash, av.id\n Buffers: shared hit=6004\n CTE requested\n -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.500..0.608 rows=2000 loops=1)\n Group Key: request_files.file_hash, request_files.version_id\n Batches: 1 Memory Usage: 257kB\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.210..0.282 rows=2000 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=3.174..3.218 rows=2000 loops=1)\n Sort Key: r.file_hash, av.id\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=6004\n -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.511..2.460 rows=2000 loops=1)\n Buffers: shared hit=6004\n -> Index Scan using idx_app_id_name_app_versions on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.005..0.006 rows=1 loops=1)\n Index Cond: ((app_id = 'com.bench.size'::text) AND (name = '1'::text))\n Filter: (NOT deleted)\n Buffers: shared hit=3\n -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.505..2.361 rows=2000 loops=1)\n Buffers: shared hit=6001\n -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.500..0.789 rows=2000 loops=1)\n Filter: (version_id IS NULL)\n -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000)\n Index Cond: ((app_version_id = av.id) AND (file_hash = r.file_hash))\n Heap Fetches: 0\n Buffers: shared hit=6001\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.161 ms\nExecution Time: 3.564 ms" - }, - { - "name": "per-file version id, 2000 hashes", - "side": "after", - "timing": { - "rows": 2000, - "timesMs": [ - 4.6, - 5.1, - 4.5, - 4.3, - 4.5 - ], - "medianMs": 4.5, - "minMs": 4.3, - "maxMs": 5.1 - }, - "skipped": null, - "explain": "GroupAggregate (cost=91.66..92.00 rows=17 width=48) (actual time=3.213..3.487 rows=2000 loops=1)\n Group Key: r.file_hash, av.id\n Buffers: shared hit=6003\n CTE requested\n -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.551..0.658 rows=2000 loops=1)\n Group Key: request_files.file_hash, request_files.version_id\n Batches: 1 Memory Usage: 385kB\n -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.265..0.336 rows=2000 loops=1)\n Filter: (file_hash IS NOT NULL)\n -> Sort (cost=89.15..89.20 rows=17 width=48) (actual time=3.211..3.259 rows=2000 loops=1)\n Sort Key: r.file_hash, av.id\n Sort Method: quicksort Memory: 141kB\n Buffers: shared hit=6003\n -> Nested Loop (cost=11.19..88.81 rows=17 width=48) (actual time=0.573..2.553 rows=2000 loops=1)\n Join Filter: (av.id = m.app_version_id)\n Buffers: shared hit=6003\n -> Hash Join (cost=10.77..13.03 rows=17 width=48) (actual time=0.567..0.996 rows=2000 loops=1)\n Hash Cond: (r.version_id = av.id)\n Buffers: shared hit=2\n -> CTE Scan on requested r (cost=0.00..2.00 rows=100 width=40) (actual time=0.552..0.851 rows=2000 loops=1)\n Filter: (version_id IS NOT NULL)\n -> Hash (cost=9.77..9.77 rows=80 width=8) (actual time=0.013..0.013 rows=80 loops=1)\n Buckets: 1024 Batches: 1 Memory Usage: 12kB\n Buffers: shared hit=2\n -> Bitmap Heap Scan on app_versions av (cost=4.77..9.77 rows=80 width=8) (actual time=0.005..0.008 rows=80 loops=1)\n Recheck Cond: (app_id = 'com.bench.size'::text)\n Filter: (NOT deleted)\n Heap Blocks: exact=1\n Buffers: shared hit=2\n -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.002..0.002 rows=80 loops=1)\n Index Cond: (app_id = 'com.bench.size'::text)\n Buffers: shared hit=1\n -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000)\n Index Cond: ((app_version_id = r.version_id) AND (file_hash = r.file_hash))\n Heap Fetches: 0\n Buffers: shared hit=6001\nPlanning:\n Buffers: shared hit=14\nPlanning Time: 0.166 ms\nExecution Time: 3.603 ms" - }, - { - "name": "no version, 2000 hashes", - "side": "after", - "timing": null, - "skipped": "handler returns size_unknown and does not query", - "explain": null - } - ] -} diff --git a/scripts/bench/manifest_size_lookup_summary.md b/scripts/bench/manifest_size_lookup_summary.md deleted file mode 100644 index c7d080c3ef..0000000000 --- a/scripts/bench/manifest_size_lookup_summary.md +++ /dev/null @@ -1,384 +0,0 @@ -# Manifest size lookup before / after - -Local Postgres 17.11. Not production. - -Seed: 80 versions of `com.bench.size` and 400 versions of `com.bench.noise`, 2000 identical hashes per version (960,000 manifest rows). Same hash is repeated on every version so `idx_manifest_file_hash` is not selective. - -Before uses the old `OR` join and `idx_manifest_app_version_id` plus `idx_manifest_file_hash`. After uses the split lookup and `idx_manifest_app_version_id_file_hash (app_version_id, file_hash) INCLUDE (file_size)`. `idx_manifest_file_hash` stays. - -Timings are the median of 5 hot-cache client round trips after one warmup. `jit` is off. `statement_timeout` is 120s. - -| Scenario | Before median | After median | Before rows | After rows | -| --- | ---: | ---: | ---: | ---: | -| fallback version id, 2000 hashes | 165.7 ms | 5 ms | 2000 | 2000 | -| fallback version id, 100 hashes | 8.6 ms | 0.5 ms | 100 | 100 | -| fallback version name, 2000 hashes | 165.2 ms | 4.5 ms | 2000 | 2000 | -| per-file version id, 2000 hashes | 165.2 ms | 4.5 ms | 2000 | 2000 | -| no version, 2000 hashes | 12751 ms | no query | 160000 | 0 | - -## Plans - -### before — fallback version id, 2000 hashes - --> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.252..313.311 rows=2000 loops=1) - -``` -GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=314.072..314.337 rows=2000 loops=1) - Group Key: request_files.file_hash, app_versions.id - Buffers: shared hit=46002 - -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=314.067..314.111 rows=2000 loops=1) - Sort Key: request_files.file_hash, app_versions.id - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=46002 - -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.252..313.311 rows=2000 loops=1) - Join Filter: (manifest.file_hash = request_files.file_hash) - Rows Removed by Join Filter: 3998000 - Buffers: shared hit=46002 - -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.246..9.374 rows=2000 loops=1) - Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.id = '1'::bigint))) - Rows Removed by Join Filter: 158000 - Buffers: shared hit=2 - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.237..0.410 rows=2000 loops=1) - Filter: (file_hash IS NOT NULL) - -> Materialize (cost=4.77..10.17 rows=80 width=8) (actual time=0.000..0.002 rows=80 loops=2000) - Buffers: shared hit=2 - -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=8) (actual time=0.006..0.010 rows=80 loops=1) - Recheck Cond: (app_id = 'com.bench.size'::text) - Filter: (NOT deleted) - Heap Blocks: exact=1 - Buffers: shared hit=2 - -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.003..0.003 rows=80 loops=1) - Index Cond: (app_id = 'com.bench.size'::text) - Buffers: shared hit=1 - -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.082 rows=2000 loops=2000) - Index Cond: (app_version_id = app_versions.id) - Buffers: shared hit=46000 -Planning: - Buffers: shared hit=14 -Planning Time: 0.192 ms -Execution Time: 314.423 ms -``` - -### before — fallback version id, 100 hashes - --> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.028..15.711 rows=100 loops=1) - -``` -GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=15.743..15.759 rows=100 loops=1) - Group Key: request_files.file_hash, app_versions.id - Buffers: shared hit=2302 - -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=15.742..15.745 rows=100 loops=1) - Sort Key: request_files.file_hash, app_versions.id - Sort Method: quicksort Memory: 28kB - Buffers: shared hit=2302 - -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.028..15.711 rows=100 loops=1) - Join Filter: (manifest.file_hash = request_files.file_hash) - Rows Removed by Join Filter: 199900 - Buffers: shared hit=2302 - -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.022..0.486 rows=100 loops=1) - Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.id = '1'::bigint))) - Rows Removed by Join Filter: 7900 - Buffers: shared hit=2 - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.015..0.023 rows=100 loops=1) - Filter: (file_hash IS NOT NULL) - -> Materialize (cost=4.77..10.17 rows=80 width=8) (actual time=0.000..0.002 rows=80 loops=100) - Buffers: shared hit=2 - -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=8) (actual time=0.004..0.007 rows=80 loops=1) - Recheck Cond: (app_id = 'com.bench.size'::text) - Filter: (NOT deleted) - Heap Blocks: exact=1 - Buffers: shared hit=2 - -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.001..0.002 rows=80 loops=1) - Index Cond: (app_id = 'com.bench.size'::text) - Buffers: shared hit=1 - -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.083 rows=2000 loops=100) - Index Cond: (app_version_id = app_versions.id) - Buffers: shared hit=2300 -Planning: - Buffers: shared hit=14 -Planning Time: 0.126 ms -Execution Time: 15.790 ms -``` - -### before — fallback version name, 2000 hashes - --> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.260..312.901 rows=2000 loops=1) - -``` -GroupAggregate (cost=2556.39..2556.73 rows=17 width=48) (actual time=313.639..313.914 rows=2000 loops=1) - Group Key: request_files.file_hash, app_versions.id - Buffers: shared hit=46002 - -> Sort (cost=2556.39..2556.43 rows=17 width=48) (actual time=313.635..313.680 rows=2000 loops=1) - Sort Key: request_files.file_hash, app_versions.id - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=46002 - -> Nested Loop (cost=5.19..2556.04 rows=17 width=48) (actual time=0.260..312.901 rows=2000 loops=1) - Join Filter: (manifest.file_hash = request_files.file_hash) - Rows Removed by Join Filter: 3998000 - Buffers: shared hit=46002 - -> Nested Loop (cost=4.77..150.97 rows=17 width=40) (actual time=0.254..9.946 rows=2000 loops=1) - Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR ((request_files.version_id IS NULL) AND (app_versions.name = '1'::text))) - Rows Removed by Join Filter: 158000 - Buffers: shared hit=2 - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.242..0.397 rows=2000 loops=1) - Filter: (file_hash IS NOT NULL) - -> Materialize (cost=4.77..10.17 rows=80 width=11) (actual time=0.000..0.002 rows=80 loops=2000) - Buffers: shared hit=2 - -> Bitmap Heap Scan on app_versions (cost=4.77..9.77 rows=80 width=11) (actual time=0.008..0.012 rows=80 loops=1) - Recheck Cond: (app_id = 'com.bench.size'::text) - Filter: (NOT deleted) - Heap Blocks: exact=1 - Buffers: shared hit=2 - -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.004..0.004 rows=80 loops=1) - Index Cond: (app_id = 'com.bench.size'::text) - Buffers: shared hit=1 - -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.081 rows=2000 loops=2000) - Index Cond: (app_version_id = app_versions.id) - Buffers: shared hit=46000 -Planning: - Buffers: shared hit=14 -Planning Time: 0.210 ms -Execution Time: 314.009 ms -``` - -### before — per-file version id, 2000 hashes - --> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.278..312.968 rows=2000 loops=1) - -``` -GroupAggregate (cost=8277.41..8278.55 rows=57 width=48) (actual time=313.753..314.020 rows=2000 loops=1) - Group Key: request_files.file_hash, app_versions.id - Buffers: shared hit=46007 - -> Sort (cost=8277.41..8277.55 rows=57 width=48) (actual time=313.748..313.790 rows=2000 loops=1) - Sort Key: request_files.file_hash, app_versions.id - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=46007 - -> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.278..312.968 rows=2000 loops=1) - Join Filter: (manifest.file_hash = request_files.file_hash) - Rows Removed by Join Filter: 3998000 - Buffers: shared hit=46007 - -> Nested Loop (cost=0.28..211.68 rows=57 width=40) (actual time=0.272..10.262 rows=2000 loops=1) - Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR (request_files.version_id IS NULL)) - Rows Removed by Join Filter: 158000 - Buffers: shared hit=7 - -> Index Scan using app_versions_pkey on app_versions (cost=0.27..31.67 rows=80 width=8) (actual time=0.005..0.035 rows=80 loops=1) - Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text)) - Rows Removed by Filter: 400 - Buffers: shared hit=7 - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.003..0.062 rows=2000 loops=80) - Filter: (file_hash IS NOT NULL) - -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.081 rows=2000 loops=2000) - Index Cond: (app_version_id = app_versions.id) - Buffers: shared hit=46000 -Planning: - Buffers: shared hit=14 -Planning Time: 0.174 ms -Execution Time: 314.111 ms -``` - -### before — no version, 2000 hashes - --> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.274..24501.270 rows=160000 loops=1) - -``` -GroupAggregate (cost=8277.41..8278.55 rows=57 width=48) (actual time=24584.193..24604.666 rows=160000 loops=1) - Group Key: request_files.file_hash, app_versions.id - Buffers: shared hit=3898007 - -> Sort (cost=8277.41..8277.55 rows=57 width=48) (actual time=24584.186..24587.946 rows=160000 loops=1) - Sort Key: request_files.file_hash, app_versions.id - Sort Method: quicksort Memory: 13583kB - Buffers: shared hit=3898007 - -> Nested Loop (cost=0.70..8275.75 rows=57 width=48) (actual time=0.274..24501.270 rows=160000 loops=1) - Join Filter: (manifest.file_hash = request_files.file_hash) - Rows Removed by Join Filter: 319840000 - Buffers: shared hit=3898007 - -> Nested Loop (cost=0.28..211.68 rows=57 width=40) (actual time=0.266..29.543 rows=160000 loops=1) - Join Filter: (((request_files.version_id IS NOT NULL) AND (app_versions.id = request_files.version_id)) OR (request_files.version_id IS NULL)) - Buffers: shared hit=7 - -> Index Scan using app_versions_pkey on app_versions (cost=0.27..31.67 rows=80 width=8) (actual time=0.007..0.175 rows=80 loops=1) - Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text)) - Rows Removed by Filter: 400 - Buffers: shared hit=7 - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.003..0.168 rows=2000 loops=80) - Filter: (file_hash IS NOT NULL) - -> Index Scan using idx_manifest_app_version_id on manifest (cost=0.42..116.47 rows=2000 width=25) (actual time=0.001..0.082 rows=2000 loops=160000) - Index Cond: (app_version_id = app_versions.id) - Buffers: shared hit=3898000 -Planning: - Buffers: shared hit=14 -Planning Time: 0.223 ms -Execution Time: 24608.058 ms -``` - -### after — fallback version id, 2000 hashes - --> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.218..0.286 rows=2000 loops=1) - -``` -GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=3.927..4.191 rows=2000 loops=1) - Group Key: r.file_hash - Buffers: shared hit=12001 - CTE requested - -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.491..0.593 rows=2000 loops=1) - Group Key: request_files.file_hash, request_files.version_id - Batches: 1 Memory Usage: 257kB - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.218..0.286 rows=2000 loops=1) - Filter: (file_hash IS NOT NULL) - -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=3.926..3.969 rows=2000 loops=1) - Sort Key: r.file_hash - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=12001 - -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.503..3.211 rows=2000 loops=1) - Buffers: shared hit=12001 - -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.499..2.374 rows=2000 loops=1) - Buffers: shared hit=6001 - -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.491..0.770 rows=2000 loops=1) - Filter: (version_id IS NULL) - -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000) - Index Cond: ((app_version_id = '1'::bigint) AND (file_hash = r.file_hash)) - Heap Fetches: 0 - Buffers: shared hit=6001 - -> Index Scan using app_versions_pkey on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=2000) - Index Cond: (id = '1'::bigint) - Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text)) - Buffers: shared hit=6000 -Planning Time: 0.081 ms -Execution Time: 4.275 ms -``` - -### after — fallback version id, 100 hashes - --> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.012..0.017 rows=100 loops=1) - -``` -GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=0.181..0.195 rows=100 loops=1) - Group Key: r.file_hash - Buffers: shared hit=601 - CTE requested - -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.025..0.029 rows=100 loops=1) - Group Key: request_files.file_hash, request_files.version_id - Batches: 1 Memory Usage: 24kB - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.012..0.017 rows=100 loops=1) - Filter: (file_hash IS NOT NULL) - -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=0.181..0.183 rows=100 loops=1) - Sort Key: r.file_hash - Sort Method: quicksort Memory: 28kB - Buffers: shared hit=601 - -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.033..0.159 rows=100 loops=1) - Buffers: shared hit=601 - -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.030..0.115 rows=100 loops=1) - Buffers: shared hit=301 - -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.025..0.039 rows=100 loops=1) - Filter: (version_id IS NULL) - -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=100) - Index Cond: ((app_version_id = '1'::bigint) AND (file_hash = r.file_hash)) - Heap Fetches: 0 - Buffers: shared hit=301 - -> Index Scan using app_versions_pkey on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=100) - Index Cond: (id = '1'::bigint) - Filter: ((NOT deleted) AND (app_id = 'com.bench.size'::text)) - Buffers: shared hit=300 -Planning Time: 0.043 ms -Execution Time: 0.218 ms -``` - -### after — fallback version name, 2000 hashes - --> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.210..0.282 rows=2000 loops=1) - -``` -GroupAggregate (cost=17.27..17.29 rows=1 width=48) (actual time=3.176..3.452 rows=2000 loops=1) - Group Key: r.file_hash, av.id - Buffers: shared hit=6004 - CTE requested - -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.500..0.608 rows=2000 loops=1) - Group Key: request_files.file_hash, request_files.version_id - Batches: 1 Memory Usage: 257kB - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.210..0.282 rows=2000 loops=1) - Filter: (file_hash IS NOT NULL) - -> Sort (cost=14.77..14.77 rows=1 width=48) (actual time=3.174..3.218 rows=2000 loops=1) - Sort Key: r.file_hash, av.id - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=6004 - -> Nested Loop (cost=0.70..14.76 rows=1 width=48) (actual time=0.511..2.460 rows=2000 loops=1) - Buffers: shared hit=6004 - -> Index Scan using idx_app_id_name_app_versions on app_versions av (cost=0.27..8.29 rows=1 width=8) (actual time=0.005..0.006 rows=1 loops=1) - Index Cond: ((app_id = 'com.bench.size'::text) AND (name = '1'::text)) - Filter: (NOT deleted) - Buffers: shared hit=3 - -> Nested Loop (cost=0.42..6.45 rows=1 width=48) (actual time=0.505..2.361 rows=2000 loops=1) - Buffers: shared hit=6001 - -> CTE Scan on requested r (cost=0.00..2.00 rows=1 width=32) (actual time=0.500..0.789 rows=2000 loops=1) - Filter: (version_id IS NULL) - -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000) - Index Cond: ((app_version_id = av.id) AND (file_hash = r.file_hash)) - Heap Fetches: 0 - Buffers: shared hit=6001 -Planning: - Buffers: shared hit=14 -Planning Time: 0.161 ms -Execution Time: 3.564 ms -``` - -### after — per-file version id, 2000 hashes - --> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.265..0.336 rows=2000 loops=1) - -``` -GroupAggregate (cost=91.66..92.00 rows=17 width=48) (actual time=3.213..3.487 rows=2000 loops=1) - Group Key: r.file_hash, av.id - Buffers: shared hit=6003 - CTE requested - -> HashAggregate (cost=1.50..2.50 rows=100 width=40) (actual time=0.551..0.658 rows=2000 loops=1) - Group Key: request_files.file_hash, request_files.version_id - Batches: 1 Memory Usage: 385kB - -> Function Scan on jsonb_to_recordset request_files (cost=0.00..1.00 rows=100 width=40) (actual time=0.265..0.336 rows=2000 loops=1) - Filter: (file_hash IS NOT NULL) - -> Sort (cost=89.15..89.20 rows=17 width=48) (actual time=3.211..3.259 rows=2000 loops=1) - Sort Key: r.file_hash, av.id - Sort Method: quicksort Memory: 141kB - Buffers: shared hit=6003 - -> Nested Loop (cost=11.19..88.81 rows=17 width=48) (actual time=0.573..2.553 rows=2000 loops=1) - Join Filter: (av.id = m.app_version_id) - Buffers: shared hit=6003 - -> Hash Join (cost=10.77..13.03 rows=17 width=48) (actual time=0.567..0.996 rows=2000 loops=1) - Hash Cond: (r.version_id = av.id) - Buffers: shared hit=2 - -> CTE Scan on requested r (cost=0.00..2.00 rows=100 width=40) (actual time=0.552..0.851 rows=2000 loops=1) - Filter: (version_id IS NOT NULL) - -> Hash (cost=9.77..9.77 rows=80 width=8) (actual time=0.013..0.013 rows=80 loops=1) - Buckets: 1024 Batches: 1 Memory Usage: 12kB - Buffers: shared hit=2 - -> Bitmap Heap Scan on app_versions av (cost=4.77..9.77 rows=80 width=8) (actual time=0.005..0.008 rows=80 loops=1) - Recheck Cond: (app_id = 'com.bench.size'::text) - Filter: (NOT deleted) - Heap Blocks: exact=1 - Buffers: shared hit=2 - -> Bitmap Index Scan on idx_app_id_app_versions (cost=0.00..4.75 rows=80 width=0) (actual time=0.002..0.002 rows=80 loops=1) - Index Cond: (app_id = 'com.bench.size'::text) - Buffers: shared hit=1 - -> Index Only Scan using idx_manifest_app_version_id_file_hash on manifest m (cost=0.42..4.44 rows=1 width=25) (actual time=0.001..0.001 rows=1 loops=2000) - Index Cond: ((app_version_id = r.version_id) AND (file_hash = r.file_hash)) - Heap Fetches: 0 - Buffers: shared hit=6001 -Planning: - Buffers: shared hit=14 -Planning Time: 0.166 ms -Execution Time: 3.603 ms -``` - -### after — no version, 2000 hashes - -no query - -handler returns size_unknown and does not query - -## Settings - -``` -jit=off -server_version=17.11 -shared_buffers=512 MiB -work_mem=32 MiB -``` - diff --git a/scripts/bench/zod_454_version_bump_comparison.md b/scripts/bench/zod_454_version_bump_comparison.md deleted file mode 100644 index 7010a3c858..0000000000 --- a/scripts/bench/zod_454_version_bump_comparison.md +++ /dev/null @@ -1,43 +0,0 @@ -# Zod 4.4.3 → 4.5.4 version-only benchmark (no z.compile) - -Measured on the same VM with existing validation paths only: production `.is` -predicate, `zod-compiler` AOT, and Zod runtime `safeParse`. Does **not** use -Zod 4.5 `z.compile()` — plugin hot paths keep the existing `zod-compiler` flow. - -## Summary - -| Area | 4.4.3 | 4.5.4 | Verdict | -| --- | ---: | ---: | --- | -| Plugin `.is` predicate (valid) | 91.4 ns/op | 91.9 ns/op | Flat | -| `zod-compiler` `.is` (valid) | 92.0 ns/op | 90.5 ns/op | ~1.6% faster | -| Zod runtime `safeParse` (valid) | 652.5 ns/op | 627.1 ns/op | **~3.9% faster** | -| Zod runtime `safeParse` (75% valid mixed) | 2359.6 ns/op | 1201.7 ns/op | **~49% faster** | -| Backend org schema `safeParse` (valid) | 20045 ns/op | 20846 ns/op | ~4% slower | -| 80k runtime parse heap Δ | 0.339 MB | 0.331 MB | ~2% lower | -| 80k runtime parse RSS Δ | ~20.2 MB | ~17.0 MB | ~16% lower | -| 100× `z.string()` heap Δ | 0.042 MB | 0.045 MB | Noisy / flat | - -## Method - -- CPU: 5 runs × 80k iterations, `process.cpuUsage()` (see `scripts/bench_zod_stable_cpu.ts`) -- Memory: 5 runs, heap/RSS delta with `Bun.gc()` between samples (no `z.compile`) -- Fixtures: plugin update-request body shape (mock data, same as `bench_plugin_validation_cpu.ts`) and backend org row shape from `organization/get.ts` -- `bun test:unit` — 2548 tests passed on 4.5.4 - -## Recommendation - -Upgrade is justified for backend Zod runtime `safeParse` validation (~40 Supabase -function files, including Deno edge functions on the same `zod` import): measurable -CPU win on valid and especially mixed valid/invalid paths on the representative -`updateRequestSchemaZod` fixture, plus lower RSS during parse bursts. - -Plugin `/updates` production path uses extracted `.is` predicates (flat in this -bench). `/stats` and `/channel_self` were not separately benchmarked here — keep -`zod-compiler` + extracted `.is` predicates on those hot paths. - -Re-run: - -```bash -bun scripts/bench_plugin_validation_cpu.ts -BENCH_RUNS=5 bun scripts/bench_zod_stable_cpu.ts -``` diff --git a/scripts/bench_manifest_size_lookup.ts b/scripts/bench_manifest_size_lookup.ts deleted file mode 100644 index 6ed3d48e53..0000000000 --- a/scripts/bench_manifest_size_lookup.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * Before/after timings for POST /updates/manifest_size. - * - * Local Postgres only. Setup drops the public schema. - * - * docker run -d --name capgo-manifest-size-bench-pg \ - * -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres \ - * -e POSTGRES_DB=manifest_size_bench -p 55433:5432 postgres:17-alpine \ - * -c shared_buffers=512MB -c work_mem=32MB -c maintenance_work_mem=512MB \ - * -c max_wal_size=2GB -c jit=off - * - * bun scripts/bench_manifest_size_lookup.ts - */ -import { writeFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { performance } from 'node:perf_hooks' -import { Client } from 'pg' -import { buildManifestSizeLookupQuery } from '../supabase/functions/_backend/utils/manifest_size.ts' - -const DATABASE_URL = 'postgres://postgres:postgres@127.0.0.1:55433/manifest_size_bench' -const APP_ID = 'com.bench.size' -const NOISE_APP_ID = 'com.bench.noise' -const TARGET_VERSIONS = 80 -const NOISE_VERSIONS = 400 -const FILES_PER_VERSION = 2000 -const RUNS = 5 -const ROOT = resolve(import.meta.dirname, '..') - -const OLD_SQL = ` -WITH requested AS ( - SELECT file_hash, version_id - FROM jsonb_to_recordset($1::jsonb) AS request_files(file_hash text, version_id bigint) - WHERE file_hash IS NOT NULL -) -SELECT - requested.file_hash, - app_versions.id AS version_id, - MAX(manifest.file_size) AS file_size -FROM requested -INNER JOIN public.app_versions - ON app_versions.app_id = $2 - AND app_versions.deleted = false - AND ( - ( - requested.version_id IS NOT NULL - AND app_versions.id = requested.version_id - ) OR ( - requested.version_id IS NULL - AND ( - ( - $3::bigint IS NOT NULL - AND app_versions.id = $3 - ) OR ( - $3::bigint IS NULL - AND ($4::text IS NULL OR app_versions.name = $4) - ) - ) - ) - ) -INNER JOIN public.manifest - ON manifest.app_version_id = app_versions.id - AND manifest.file_hash = requested.file_hash -GROUP BY requested.file_hash, app_versions.id -` - -interface Timing { - rows: number - timesMs: number[] - medianMs: number - minMs: number - maxMs: number -} - -interface ScenarioReport { - name: string - side: 'before' | 'after' - timing: Timing | null - skipped: string | null - explain: string | null -} - -function assertSafeUrl(databaseUrl: string) { - const parsed = new URL(databaseUrl) - const dbName = decodeURIComponent(parsed.pathname.replace(/^\/+/, '')) - if (parsed.hostname !== '127.0.0.1' || parsed.port !== '55433' || dbName !== 'manifest_size_bench') - throw new Error(`Refusing bench against ${parsed.hostname}:${parsed.port}/${dbName}`) -} - -function hashes(count: number, versionId: number | null) { - const files = [] - for (let i = 1; i <= count; i++) - files.push({ file_name: `f${i}`, file_hash: `hash-${i}`, download_url: null, version_id: versionId }) - return files -} - -function median(values: number[]): number { - const sorted = [...values].sort((a, b) => a - b) - return sorted[Math.floor(sorted.length / 2)] ?? 0 -} - -async function timeQuery(client: Client, sql: string, params: unknown[]): Promise { - await client.query(sql, params) - const timesMs: number[] = [] - let rows = 0 - for (let i = 0; i < RUNS; i++) { - const started = performance.now() - const result = await client.query(sql, params) - timesMs.push(performance.now() - started) - rows = result.rowCount ?? 0 - } - return { - rows, - timesMs: timesMs.map(value => Math.round(value * 10) / 10), - medianMs: Math.round(median(timesMs) * 10) / 10, - minMs: Math.round(Math.min(...timesMs) * 10) / 10, - maxMs: Math.round(Math.max(...timesMs) * 10) / 10, - } -} - -async function explain(client: Client, sql: string, params: unknown[]): Promise { - const result = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${sql}`, params) - return result.rows.map((row: { 'QUERY PLAN': string }) => row['QUERY PLAN']).join('\n') -} - -async function setup(client: Client) { - await client.query(` - DROP SCHEMA IF EXISTS public CASCADE; - CREATE SCHEMA public; - CREATE TABLE public.app_versions ( - id bigint PRIMARY KEY, - app_id text NOT NULL, - name text NOT NULL, - deleted boolean NOT NULL DEFAULT false - ); - CREATE TABLE public.manifest ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - app_version_id bigint NOT NULL REFERENCES public.app_versions(id), - file_name text NOT NULL, - file_hash text NOT NULL, - file_size bigint NOT NULL, - s3_path text NOT NULL - ); - CREATE INDEX idx_app_id_app_versions ON public.app_versions (app_id); - CREATE INDEX idx_app_id_name_app_versions ON public.app_versions (app_id, name); - `) - - await client.query(` - INSERT INTO public.app_versions (id, app_id, name, deleted) - SELECT v, $1, v::text, false - FROM generate_series(1, $2::int) AS v - `, [APP_ID, TARGET_VERSIONS]) - await client.query(` - INSERT INTO public.app_versions (id, app_id, name, deleted) - SELECT v, $1, v::text, false - FROM generate_series($2::int + 1, $2::int + $3::int) AS v - `, [NOISE_APP_ID, TARGET_VERSIONS, NOISE_VERSIONS]) - - await client.query(` - INSERT INTO public.manifest (app_version_id, file_name, file_hash, file_size, s3_path) - SELECT v, 'f' || f, 'hash-' || f, f * 10, v::text || '/' || f::text - FROM generate_series(1, $1::int + $2::int) AS v - CROSS JOIN generate_series(1, $3::int) AS f - `, [TARGET_VERSIONS, NOISE_VERSIONS, FILES_PER_VERSION]) - - await client.query(` - CREATE INDEX idx_manifest_app_version_id ON public.manifest (app_version_id); - CREATE INDEX idx_manifest_file_hash ON public.manifest (file_hash); - ANALYZE public.app_versions; - ANALYZE public.manifest; - `) -} - -async function swapToNewIndex(client: Client) { - await client.query(` - CREATE INDEX idx_manifest_app_version_id_file_hash - ON public.manifest (app_version_id, file_hash) INCLUDE (file_size); - DROP INDEX public.idx_manifest_app_version_id; - ANALYZE public.manifest; - `) -} - -function formatPgSetting(row: { name: string, setting: string, unit: string | null }): string { - const value = Number(row.setting) - if (row.unit === '8kB' && Number.isFinite(value)) - return `${row.name}=${value * 8 / 1024} MiB` - if (row.unit === 'kB' && Number.isFinite(value)) - return `${row.name}=${value / 1024} MiB` - return `${row.name}=${row.setting}${row.unit ? ` ${row.unit}` : ''}` -} - -function planHeadline(explainText: string | null): string { - if (!explainText) - return 'no query' - const scan = explainText.split('\n').find(line => /Scan|Nested Loop|Hash Join|Bitmap/.test(line) && !line.includes('Planning')) - return (scan ?? explainText.split('\n')[0] ?? '').trim() -} - -async function main() { - assertSafeUrl(DATABASE_URL) - const client = new Client({ connectionString: DATABASE_URL, statement_timeout: 120_000 }) - await client.connect() - await client.query('SET jit = off') - - console.log('Seeding…') - const seedStarted = performance.now() - await setup(client) - const counts = await client.query(` - SELECT - (SELECT count(*)::int FROM public.manifest) AS manifest_rows, - (SELECT count(*)::int FROM public.app_versions) AS versions - `) - console.log(`Seeded in ${Math.round((performance.now() - seedStarted) / 1000)}s`, counts.rows[0]) - - const fullFiles = hashes(FILES_PER_VERSION, null) - const partialFiles = hashes(100, null) - const perFileVersion = hashes(FILES_PER_VERSION, 1) - const scenarios: Array<{ name: string, files: ReturnType, versionName?: string, versionId?: number }> = [ - { name: 'fallback version id, 2000 hashes', files: fullFiles, versionId: 1 }, - { name: 'fallback version id, 100 hashes', files: partialFiles, versionId: 1 }, - { name: 'fallback version name, 2000 hashes', files: fullFiles, versionName: '1' }, - { name: 'per-file version id, 2000 hashes', files: perFileVersion }, - { name: 'no version, 2000 hashes', files: fullFiles }, - ] - - const reports: ScenarioReport[] = [] - for (const scenario of scenarios) { - console.log(`BEFORE ${scenario.name}`) - const params = [JSON.stringify(scenario.files), APP_ID, scenario.versionId ?? null, scenario.versionName ?? null] - const timing = await timeQuery(client, OLD_SQL, params) - const plan = await explain(client, OLD_SQL, params) - reports.push({ name: scenario.name, side: 'before', timing, skipped: null, explain: plan }) - console.log(` median ${timing.medianMs}ms rows ${timing.rows}`) - } - - console.log('Building composite index…') - const indexStarted = performance.now() - await swapToNewIndex(client) - console.log(`Index swap ${Math.round(performance.now() - indexStarted)}ms`) - - for (const scenario of scenarios) { - console.log(`AFTER ${scenario.name}`) - const lookup = buildManifestSizeLookupQuery(APP_ID, scenario.versionName, scenario.versionId, scenario.files) - if (!lookup) { - reports.push({ - name: scenario.name, - side: 'after', - timing: null, - skipped: 'handler returns size_unknown and does not query', - explain: null, - }) - console.log(' skipped') - continue - } - const timing = await timeQuery(client, lookup.text, lookup.values) - const plan = await explain(client, lookup.text, lookup.values) - reports.push({ name: scenario.name, side: 'after', timing, skipped: null, explain: plan }) - console.log(` median ${timing.medianMs}ms rows ${timing.rows}`) - } - - const settings = await client.query(` - SELECT name, setting, unit - FROM pg_settings - WHERE name IN ('shared_buffers', 'work_mem', 'jit', 'server_version') - `) - await client.end() - - const lines = [ - '# Manifest size lookup before / after', - '', - `Local Postgres ${settings.rows.find((row: { name: string }) => row.name === 'server_version')?.setting ?? '17'}. Not production.`, - '', - `Seed: ${TARGET_VERSIONS} versions of \`${APP_ID}\` and ${NOISE_VERSIONS} versions of \`${NOISE_APP_ID}\`, ${FILES_PER_VERSION} identical hashes per version (${((TARGET_VERSIONS + NOISE_VERSIONS) * FILES_PER_VERSION).toLocaleString()} manifest rows). Same hash is repeated on every version so \`idx_manifest_file_hash\` is not selective.`, - '', - 'Before uses the old `OR` join and `idx_manifest_app_version_id` plus `idx_manifest_file_hash`. After uses the split lookup and `idx_manifest_app_version_id_file_hash (app_version_id, file_hash) INCLUDE (file_size)`. `idx_manifest_file_hash` stays.', - '', - `Timings are the median of ${RUNS} hot-cache client round trips after one warmup. \`jit\` is off. \`statement_timeout\` is 120s.`, - '', - '| Scenario | Before median | After median | Before rows | After rows |', - '| --- | ---: | ---: | ---: | ---: |', - ] - - for (const scenario of scenarios) { - const before = reports.find(report => report.name === scenario.name && report.side === 'before') - const after = reports.find(report => report.name === scenario.name && report.side === 'after') - const afterCell = after?.skipped ? 'no query' : `${after?.timing?.medianMs} ms` - lines.push(`| ${scenario.name} | ${before?.timing?.medianMs} ms | ${afterCell} | ${before?.timing?.rows ?? ''} | ${after?.skipped ? '0' : after?.timing?.rows ?? ''} |`) - } - - lines.push('', '## Plans', '') - for (const report of reports) { - lines.push(`### ${report.side} — ${report.name}`, '', planHeadline(report.explain), '') - if (report.explain) - lines.push('```', report.explain, '```', '') - else - lines.push(report.skipped ?? '', '') - } - - lines.push('## Settings', '', '```', settings.rows.map(row => formatPgSetting(row)).join('\n'), '```', '') - - const summaryPath = resolve(ROOT, 'scripts/bench/manifest_size_lookup_summary.md') - writeFileSync(summaryPath, `${lines.join('\n')}\n`) - writeFileSync(resolve(ROOT, 'scripts/bench/manifest_size_lookup_results.json'), `${JSON.stringify({ generatedAt: new Date().toISOString(), counts: counts.rows[0], settings: settings.rows, reports }, null, 2)}\n`) - console.log(`Wrote ${summaryPath}`) -} - -main().catch((error) => { - console.error(error) - process.exit(1) -}) diff --git a/scripts/bench_zod_stable_cpu.ts b/scripts/bench_zod_stable_cpu.ts deleted file mode 100644 index 388d297f6f..0000000000 --- a/scripts/bench_zod_stable_cpu.ts +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env bun -/** - * Stable CPU + memory bench for Zod version bumps (no z.compile). - * - * Measures existing validation paths only: - * - production `.is` predicate - * - zod-compiler AOT `.is` - * - Zod runtime `safeParse` - * - representative backend org schema `safeParse` - * - * Usage: - * bun scripts/bench_zod_stable_cpu.ts - * BENCH_RUNS=5 bun scripts/bench_zod_stable_cpu.ts /workspace - */ - -import { spawnSync } from 'node:child_process' -import { resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { performance } from 'node:perf_hooks' - -const SCRIPT_PATH = fileURLToPath(import.meta.url) -const cliArgs = process.argv.slice(2) -const memWorkerCase = cliArgs[0] === '--mem-worker' ? cliArgs[1] : undefined -const ROOT = resolve(memWorkerCase ? cliArgs[2] : cliArgs[0] ?? resolve(import.meta.dirname, '..')) -const RUNS = Math.max(1, Number.parseInt(process.env.BENCH_RUNS ?? '1', 10) || 1) -const ITERATIONS = 80_000 - -interface CpuRow { - name: string - runs: number - iterations: number - nsCpuPerOp: number - nsCpuPerOpStd: number -} - -interface MemRow { - name: string - runs: number - heapUsedDeltaMB: number - rssDeltaMB: number -} - -function forceGc() { - if (typeof Bun !== 'undefined' && 'gc' in Bun && typeof Bun.gc === 'function') - Bun.gc(true) - else if (globalThis.gc) - globalThis.gc() -} - -async function readZodVersion(root: string) { - const mod = await import(pathToFileURL(resolve(root, 'node_modules/zod/package.json')).href) as { - default?: { version?: string } - version?: string - } - return mod.default?.version ?? mod.version ?? 'unknown' -} - -async function importZodFromRoot(root: string) { - return import(pathToFileURL(resolve(root, 'node_modules/zod/index.js')).href) -} - -function ensureCompiledZod() { - const out = resolve(ROOT, 'scripts/bench/validation/plugin_schemas.zod.compiled.ts') - const src = resolve(ROOT, 'scripts/bench/validation/plugin_schemas.zod.ts') - const result = spawnSync(process.execPath, ['x', 'zod-compiler', 'generate', src, '-o', out, '--emit', 'bag'], { - cwd: ROOT, - encoding: 'utf8', - }) - if (result.status === null) - throw new Error(`zod-compiler generate failed to start: ${result.error?.message ?? 'unknown error'}`) - if (result.status !== 0) - throw new Error(`zod-compiler generate failed:\n${result.stdout}\n${result.stderr}`) -} - -function validUpdatePayload() { - return { - app_id: 'com.demo.app', - device_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - version_name: '1.2.3', - version_build: '1.2.3', - is_emulator: false, - is_prod: true, - platform: 'ios' as const, - plugin_version: '6.8.1', - defaultChannel: 'production', - key_id: 'key_1', - } -} - -function invalidUpdatePayload() { - return { - ...validUpdatePayload(), - app_id: 'not a domain', - device_id: 'bad', - plugin_version: 'nope', - } -} - -function validOrgPayload() { - return { - id: '11111111-1111-4111-8111-111111111111', - created_by: '22222222-2222-4222-8222-222222222222', - created_at: '2026-01-01T00:00:00.000Z', - updated_at: '2026-01-01T00:00:00.000Z', - logo: null, - name: 'Example Org', - management_email: 'org-admin@example.com', - customer_id: null, - website: null, - } -} - -function mixedUpdateInputs(validRatio = 0.75) { - const valid = validUpdatePayload() - const invalid = invalidUpdatePayload() - const invalidEvery = Math.max(1, Math.round(1 / (1 - validRatio))) - return Array.from({ length: 16 }, (_, i) => (i % invalidEvery === 0 ? invalid : valid)) -} - -function benchCpuOnce(name: string, iterations: number, fn: (input: unknown) => boolean, inputs: unknown[]) { - for (let i = 0; i < Math.min(2000, iterations); i++) - fn(inputs[i % inputs.length]) - - const cpu0 = process.cpuUsage() - const t0 = performance.now() - for (let i = 0; i < iterations; i++) - fn(inputs[i % inputs.length]) - const wallMs = performance.now() - t0 - const cpu = process.cpuUsage(cpu0) - const cpuMs = (cpu.user + cpu.system) / 1000 - return { - name, - iterations, - wallMs, - cpuMs, - nsCpuPerOp: (cpuMs * 1e6) / iterations, - } -} - -function summarizeCpu(name: string, iterations: number, samples: number[]) { - const mean = samples.reduce((sum, value) => sum + value, 0) / samples.length - const variance = samples.reduce((sum, value) => sum + (value - mean) ** 2, 0) / samples.length - return { - name, - runs: samples.length, - iterations, - nsCpuPerOp: mean, - nsCpuPerOpStd: Math.sqrt(variance), - } -} - -function summarizeMem(name: string, samples: Array<{ heapUsedDeltaMB: number, rssDeltaMB: number }>) { - const heapUsedDeltaMB = samples.reduce((sum, row) => sum + row.heapUsedDeltaMB, 0) / samples.length - const rssDeltaMB = samples.reduce((sum, row) => sum + row.rssDeltaMB, 0) / samples.length - return { - name, - runs: samples.length, - heapUsedDeltaMB, - rssDeltaMB, - } -} - -function measureMemInChild(caseName: string) { - const result = spawnSync(process.execPath, [SCRIPT_PATH, '--mem-worker', caseName, ROOT], { - cwd: ROOT, - encoding: 'utf8', - env: process.env, - }) - if (result.status === null) - throw new Error(`memory worker failed to start for ${caseName}: ${result.error?.message ?? 'unknown error'}`) - if (result.status !== 0) - throw new Error(`memory worker ${caseName} failed:\n${result.stdout}\n${result.stderr}`) - return JSON.parse(result.stdout.trim()) as { heapUsedDeltaMB: number, rssDeltaMB: number } -} - -async function runMemWorker(caseName: string, root: string) { - const zodRuntime = await import(pathToFileURL(resolve(root, 'scripts/bench/validation/plugin_schemas.zod.ts')).href) - const { z } = await importZodFromRoot(root) - const valid = validUpdatePayload() - const retained: unknown[] = [] - - forceGc() - const before = process.memoryUsage() - - if (caseName === '80k_runtime_parse_heap_rss') { - for (let i = 0; i < ITERATIONS; i++) - retained.push(zodRuntime.updateRequestSchemaZod.safeParse(valid)) - } - else if (caseName === '100x_z_string_heap') { - for (let i = 0; i < 100; i++) - retained.push(z.string()) - } - else { - throw new Error(`unknown memory worker case: ${caseName}`) - } - - forceGc() - const after = process.memoryUsage() - void retained.length - - console.log(JSON.stringify({ - heapUsedDeltaMB: (after.heapUsed - before.heapUsed) / 1024 / 1024, - rssDeltaMB: (after.rss - before.rss) / 1024 / 1024, - })) -} - -async function main() { - ensureCompiledZod() - - const prodIs = await import(pathToFileURL(resolve(ROOT, 'supabase/functions/_backend/plugin_runtime/utils/plugin_schemas/update_request.is.ts')).href) - const zodRuntime = await import(pathToFileURL(resolve(ROOT, 'scripts/bench/validation/plugin_schemas.zod.ts')).href) - const zodCompiled = await import(pathToFileURL(resolve(ROOT, 'scripts/bench/validation/plugin_schemas.zod.compiled.ts')).href) - const { z } = await importZodFromRoot(ROOT) - - const orgSchema = z.object({ - id: z.uuid(), - created_by: z.uuid(), - created_at: z.union([z.string(), z.date()]), - updated_at: z.union([z.string(), z.date()]), - logo: z.string().nullable(), - name: z.string(), - management_email: z.email(), - customer_id: z.string().nullable(), - website: z.string().nullable(), - }) - - const valid = validUpdatePayload() - const mixed = mixedUpdateInputs(0.75) - const orgValid = validOrgPayload() - - const cpuCases: Array<{ name: string, fn: (input: unknown) => boolean, inputs: unknown[] }> = [ - { - name: 'plugin_is_predicate_valid', - fn: input => prodIs.isUpdateRequestBody(input), - inputs: [valid], - }, - { - name: 'zod_compiler_is_valid', - fn: input => zodCompiled.updateRequestSchemaZod.is(input), - inputs: [valid], - }, - { - name: 'zod_runtime_safeParse_valid', - fn: input => zodRuntime.updateRequestSchemaZod.safeParse(input).success, - inputs: [valid], - }, - { - name: 'zod_runtime_safeParse_mixed_75pct_valid', - fn: input => zodRuntime.updateRequestSchemaZod.safeParse(input).success, - inputs: mixed, - }, - { - name: 'backend_org_schema_safeParse_valid', - fn: input => orgSchema.safeParse(input).success, - inputs: [orgValid], - }, - ] - - const cpuRows: CpuRow[] = [] - for (const testCase of cpuCases) { - const samples: number[] = [] - for (let run = 0; run < RUNS; run++) - samples.push(benchCpuOnce(testCase.name, ITERATIONS, testCase.fn, testCase.inputs).nsCpuPerOp) - cpuRows.push(summarizeCpu(testCase.name, ITERATIONS, samples)) - } - - const memCaseNames = ['80k_runtime_parse_heap_rss', '100x_z_string_heap'] as const - const memRows: MemRow[] = [] - for (const caseName of memCaseNames) { - const samples = [] - for (let run = 0; run < RUNS; run++) - samples.push(measureMemInChild(caseName)) - memRows.push(summarizeMem(caseName, samples)) - } - - const zodVersion = await readZodVersion(ROOT) - - console.log(`\n=== Zod stable CPU + memory bench (zod@${zodVersion}, runs=${RUNS}) ===`) - console.log('\nCPU (lower ns/op is better):') - for (const row of cpuRows) { - console.log( - `${row.name.padEnd(40)} ${row.nsCpuPerOp.toFixed(1).padStart(10)} ns/op ± ${row.nsCpuPerOpStd.toFixed(1)}`, - ) - } - - console.log('\nMemory (lower delta is better; each case runs in a fresh child process):') - for (const row of memRows) { - console.log( - `${row.name.padEnd(40)} heap Δ ${row.heapUsedDeltaMB.toFixed(3).padStart(8)} MB | rss Δ ${row.rssDeltaMB.toFixed(3).padStart(8)} MB`, - ) - } -} - -if (memWorkerCase) { - runMemWorker(memWorkerCase, ROOT).catch((error) => { - console.error(error) - process.exit(1) - }) -} -else { - main().catch((error) => { - console.error(error) - process.exit(1) - }) -} diff --git a/scripts/ops/manifest_version_hash_lookup_index.sql b/scripts/ops/manifest_version_hash_lookup_index.sql deleted file mode 100644 index 73d594ed51..0000000000 --- a/scripts/ops/manifest_version_hash_lookup_index.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Required before migration 20260923143039 when public.manifest is large. --- That migration refuses a regular CREATE INDEX once the table estimate is --- above 100000 rows, because that build blocks manifest writes. --- Run the SINGLE statement below alone in SQL Editor (or psql). Do not mix --- with other statements in one Editor run if the Editor wraps a transaction — --- CREATE INDEX CONCURRENTLY cannot run in a transaction. --- --- Example (psql): --- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f scripts/ops/manifest_version_hash_lookup_index.sql - -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_manifest_app_version_id_file_hash - ON public.manifest USING btree (app_version_id, file_hash) - INCLUDE (file_size); diff --git a/scripts/playwright-frontend-preview.ts b/scripts/playwright-frontend-preview.ts index 830be891bf..c4d2bb1a59 100644 --- a/scripts/playwright-frontend-preview.ts +++ b/scripts/playwright-frontend-preview.ts @@ -13,7 +13,6 @@ const env = { ...process.env, API_DOMAIN: apiDomain, CAPTCHA_KEY: '', - CAPGO_PLAYWRIGHT_FIXTURES: 'true', ENV: 'local', SUPA_ANON: process.env.SUPABASE_ANON_KEY || '', SUPA_URL: supabaseUrl, diff --git a/scripts/visual-diff.ts b/scripts/visual-diff.ts index a4831a8043..de780d9a00 100644 --- a/scripts/visual-diff.ts +++ b/scripts/visual-diff.ts @@ -11,7 +11,6 @@ import { createClient } from '@supabase/supabase-js' import pixelmatch from 'pixelmatch' import { PNG } from 'pngjs' import type { VisualDiffRoute } from '../playwright/visual-diff.config' -import { dismissSupportPrompt } from '../playwright/support/dismissSupportPrompt' import { getSupabaseStatus } from './supabase-worktree-status' import { getPlaywrightStripeApiBaseUrl, getStripeEmulatorPort } from './playwright-stripe' import { getSupabaseWorktreeConfig } from './supabase-worktree-config' @@ -576,8 +575,6 @@ async function captureRouteScreenshot( await page.goto(route.path, { waitUntil: 'domcontentloaded', timeout: visualDiffActionTimeoutMs }) console.log(`[visual-diff] settling ${phase} ${route.slug}`) await settlePage(page) - if (route.auth) - await dismissSupportPrompt(page) if (route.prepare) { console.log(`[visual-diff] preparing ${phase} ${route.slug}`) await route.prepare(page) diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 33e40761db..3853527d67 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -133,7 +133,6 @@ declare global { const useAdminDashboardStore: typeof import('./stores/adminDashboard').useAdminDashboardStore const useAnimate: typeof import('@vueuse/core').useAnimate const useAppDetailStore: typeof import('./stores/appDetail').useAppDetailStore - const useAppOnboardingCliProgress: typeof import('./composables/useAppOnboardingCliProgress').useAppOnboardingCliProgress const useAppPage: typeof import('./composables/useAppPage').useAppPage const useArrayDifference: typeof import('@vueuse/core').useArrayDifference const useArrayEvery: typeof import('@vueuse/core').useArrayEvery @@ -506,7 +505,6 @@ declare module 'vue' { readonly useAdminDashboardStore: UnwrapRef readonly useAnimate: UnwrapRef readonly useAppDetailStore: UnwrapRef - readonly useAppOnboardingCliProgress: UnwrapRef readonly useAppPage: UnwrapRef readonly useArrayDifference: UnwrapRef readonly useArrayEvery: UnwrapRef diff --git a/src/components.d.ts b/src/components.d.ts index f871307edb..20d77ac7d5 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -35,11 +35,9 @@ declare module 'vue' { AppAccess: typeof import('./components/dashboard/AppAccess.vue')['default'] AppDashboardPage: typeof import('./components/dashboard/AppDashboardPage.vue')['default'] AppNotFoundModal: typeof import('./components/AppNotFoundModal.vue')['default'] - AppOnboardingBuilderChecklist: typeof import('./components/dashboard/AppOnboardingBuilderChecklist.vue')['default'] AppOnboardingCliSteps: typeof import('./components/dashboard/AppOnboardingCliSteps.vue')['default'] AppOnboardingFlow: typeof import('./components/dashboard/AppOnboardingFlow.vue')['default'] AppOnboardingIconInput: typeof import('./components/dashboard/AppOnboardingIconInput.vue')['default'] - AppOnboardingSetupChecklist: typeof import('./components/dashboard/AppOnboardingSetupChecklist.vue')['default'] AppOnboardingWelcome: typeof import('./components/dashboard/AppOnboardingWelcome.vue')['default'] AppPageFrame: typeof import('./components/dashboard/AppPageFrame.vue')['default'] AppPageNotFound: typeof import('./components/dashboard/AppPageNotFound.vue')['default'] @@ -58,7 +56,6 @@ declare module 'vue' { BuildTimeCard: typeof import('./components/dashboard/BuildTimeCard.vue')['default'] BuildTimeChart: typeof import('./components/dashboard/BuildTimeChart.vue')['default'] BundleAdoptionCard: typeof import('./components/bundle/BundleAdoptionCard.vue')['default'] - BundleChannelsPopover: typeof import('./components/tables/BundleChannelsPopover.vue')['default'] BundleCompareSelect: typeof import('./components/bundle/BundleCompareSelect.vue')['default'] BundleInstallStatsPanel: typeof import('./components/dashboard/BundleInstallStatsPanel.vue')['default'] BundleMultiFilter: typeof import('./components/tables/BundleMultiFilter.vue')['default'] @@ -77,7 +74,6 @@ declare module 'vue' { ChannelPermissionOverridesPanel: typeof import('./components/permissions/ChannelPermissionOverridesPanel.vue')['default'] ChannelSelfAssignMockup: typeof import('./components/dashboard/ChannelSelfAssignMockup.vue')['default'] ChannelSelfAssignOnboarding: typeof import('./components/dashboard/ChannelSelfAssignOnboarding.vue')['default'] - ChannelSetupOnboardingDialog: typeof import('./components/dashboard/ChannelSetupOnboardingDialog.vue')['default'] ChannelTable: typeof import('./components/tables/ChannelTable.vue')['default'] ChartCard: typeof import('./components/dashboard/ChartCard.vue')['default'] ChartLegend: typeof import('./components/dashboard/ChartLegend.vue')['default'] @@ -116,7 +112,6 @@ declare module 'vue' { NativePlatformTrendChart: typeof import('./components/dashboard/NativePlatformTrendChart.vue')['default'] Navbar: typeof import('./components/Navbar.vue')['default'] OnboardingExploreBanner: typeof import('./components/dashboard/OnboardingExploreBanner.vue')['default'] - OnboardingExploreReminder: typeof import('./components/dashboard/OnboardingExploreReminder.vue')['default'] OnboardingPublishIntentIcon: typeof import('./components/dashboard/OnboardingPublishIntentIcon.vue')['default'] OnboardingPublishIntentIconMobileApp: typeof import('./components/dashboard/OnboardingPublishIntentIconMobileApp.vue')['default'] OnboardingPublishIntentIconWebPage: typeof import('./components/dashboard/OnboardingPublishIntentIconWebPage.vue')['default'] diff --git a/src/components/dashboard/AppOnboardingBuilderChecklist.vue b/src/components/dashboard/AppOnboardingBuilderChecklist.vue deleted file mode 100644 index 0d4604428e..0000000000 --- a/src/components/dashboard/AppOnboardingBuilderChecklist.vue +++ /dev/null @@ -1,225 +0,0 @@ - - - diff --git a/src/components/dashboard/AppOnboardingCliSteps.vue b/src/components/dashboard/AppOnboardingCliSteps.vue index 6b394759f9..0c6c2b25da 100644 --- a/src/components/dashboard/AppOnboardingCliSteps.vue +++ b/src/components/dashboard/AppOnboardingCliSteps.vue @@ -1,12 +1,12 @@ - - - -
-import type { OnboardingChannelEvent, OnboardingChannelEventProperties } from '~/utils/onboardingChannelAnalytics' -import { computed, ref, useId, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import IconCopy from '~icons/ion/copy-outline' -import IconArrowRight from '~icons/lucide/arrow-right' -import IconCheck from '~icons/lucide/check' -import IconChevronDown from '~icons/lucide/chevron-down' -import IconCircle from '~icons/lucide/circle' -import IconFileText from '~icons/lucide/file-text' -import IconInfo from '~icons/lucide/info' -import IconLoader from '~icons/lucide/loader-2' -import IconMessageCircle from '~icons/lucide/message-circle' -import IconMinus from '~icons/lucide/minus' -import { useAppOnboardingCliProgress } from '~/composables/useAppOnboardingCliProgress' -import { getAppOnboardingStepIds } from '~/services/appOnboarding' -import { isAppOnboardingChecklistStep } from '~/utils/appOnboardingChecklist' -import { APP_ONBOARDING_STEP_GUIDES } from '~/utils/appOnboardingGuides' -import ChannelSetupOnboardingDialog from './ChannelSetupOnboardingDialog.vue' -import TechnicalTeammateInviteCard from './TechnicalTeammateInviteCard.vue' - -const props = defineProps<{ - appId: string - initialOnboarding?: unknown - command: string - hiding: boolean - leaving: boolean -}>() - -const emit = defineEmits<{ - copyCommand: [] - copyAi: [] - hide: [] - explore: [] - complete: [] - inviteOpened: [] - inviteSucceeded: [invite: { email: string, firstName: string, lastName: string }] - channelAnalytics: [event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties] -}>() - -const { t } = useI18n() -const panelId = useId() -const { onboarding, refreshError, refreshOnboarding } = useAppOnboardingCliProgress(() => props.appId, () => props.initialOnboarding) -const steps = computed(() => getAppOnboardingStepIds(onboarding.value.todo_list_version, onboarding.value.ota_todo_list_version).filter(isAppOnboardingChecklistStep).map((id, index) => ({ - id, - index, - status: onboarding.value.steps[id]?.status, - title: t(`setup-checklist-step-${id}`), - description: t(`setup-checklist-description-${id}`), -}))) -const doneCount = computed(() => steps.value.filter(step => step.status === 'done' || step.status === 'skipped').length) -const currentStep = computed(() => steps.value.find(step => step.status !== 'done' && step.status !== 'skipped')) -const selectedId = ref(null) -const selectedStep = computed(() => steps.value.find(step => step.id === selectedId.value) ?? currentStep.value ?? steps.value.at(-1)!) -const isSelectedCurrent = computed(() => selectedStep.value.id === currentStep.value?.id) -const isFirstStep = computed(() => selectedStep.value.index === 0) -const guideHref = computed(() => APP_ONBOARDING_STEP_GUIDES[selectedStep.value.id]) -const guideLabel = computed(() => isFirstStep.value ? t('setup-checklist-manual-guide') : t('setup-checklist-task-guide', { task: selectedStep.value.title })) -const waitingMessageKey = computed(() => { - if (isFirstStep.value) - return 'setup-checklist-waiting-start' - if (['add_channel', 'run_device', 'upload_bundle', 'test_update'].includes(selectedStep.value.id)) - return `setup-checklist-waiting-${selectedStep.value.id}` - return 'setup-checklist-waiting-step' -}) -const completed = computed(() => onboarding.value.outcome === 'completed') -const checklistOpen = ref(false) -const channelFlowOpen = ref(false) - -watch(() => props.appId, () => { - channelFlowOpen.value = false -}) - -function closeChannelFlow() { - channelFlowOpen.value = false - void refreshOnboarding() -} - -function trackChannelEvent(event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties) { - emit('channelAnalytics', event, properties) -} - -watch(() => currentStep.value?.id, (id, previous) => { - if (selectedId.value === null || selectedId.value === previous) - selectedId.value = id ?? null -}, { immediate: true }) - -function selectStep(id: string) { - selectedId.value = id - checklistOpen.value = false -} - -function statusLabel(status: string | undefined) { - if (status === 'done') - return t('app-onboarding-cli-step-done') - if (status === 'skipped') - return t('app-onboarding-cli-step-skipped') - return t('app-onboarding-cli-step-pending') -} - - - diff --git a/src/components/dashboard/ChannelCreateOnboarding.vue b/src/components/dashboard/ChannelCreateOnboarding.vue index 2491fb4ac0..5a536ab031 100644 --- a/src/components/dashboard/ChannelCreateOnboarding.vue +++ b/src/components/dashboard/ChannelCreateOnboarding.vue @@ -330,8 +330,6 @@ function continueOnboarding() { onMounted(() => { void initialize() }) - -defineExpose({ isSubmitting })