diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index e47f623e..baf8dc1c 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `--supabase-project-ref ` for non-interactive, project-scoped + Supabase MCP deploys; interactive deploys prompt for the same value. + - `--version` now checks the npm registry for a newer published release and, when the installed build is behind, writes `Update available: ` and the install command to stderr. stdout stays exactly the version diff --git a/packages/cli/src/deploy-command.test.ts b/packages/cli/src/deploy-command.test.ts index 09105749..f81d9f30 100644 --- a/packages/cli/src/deploy-command.test.ts +++ b/packages/cli/src/deploy-command.test.ts @@ -404,6 +404,36 @@ test('parseDeployArgs: --reconnect is repeatable and comma-aware', () => { assert.deepEqual(parsed.reconnectProviders, ['slack', 'github', 'linear']); }); +test('parseDeployArgs: --supabase-project-ref normalizes and forwards the project', () => { + const parsed = parseDeployArgs([ + './persona.json', + '--supabase-project-ref', + 'BVZZCAFZOYSEZUMRDVIF', + ]); + + assert.equal(parsed.supabaseMcpProjectRef, 'bvzzcafzoysezumrdvif'); +}); + +test('parseDeployArgs: malformed --supabase-project-ref exits with a clean error', () => { + const trap = trapExit(); + try { + assert.throws( + () => parseDeployArgs([ + './persona.json', + '--supabase-project-ref=not-a-project', + ]), + /__exit_trap__:1/ + ); + assert.deepEqual(trap.exits, [1]); + assert.match( + trap.stderr, + /--supabase-project-ref: expected exactly 20 lowercase letters or digits/, + ); + } finally { + trap.restore(); + } +}); + test('parseDeployArgs: --harness-source managed is accepted', () => { const parsed = parseDeployArgs(['./persona.json', '--harness-source', 'managed']); diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index e05f0629..06bb60af 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -262,6 +262,8 @@ Flags: --reconnect Force a fresh connect flow even if already connected, for an integration or the harness LLM credential (e.g. openai/codex, anthropic/claude). Repeatable. + --supabase-project-ref Select the 20-character Supabase project used by + the project-scoped, read-only MCP connection --byo-sandbox Force BYO Daytona auth even when logged in --detach Background the runner instead of streaming logs --bundle-out Emit the bundle to and exit (no launch) @@ -317,6 +319,7 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { let harnessSource: DeployOptions['harnessSource']; let byokKey: string | undefined; let onExists: DeployOptions['onExists']; + let supabaseMcpProjectRef: string | undefined; const inputs: Record = {}; const reconnectProviders: string[] = []; @@ -339,6 +342,17 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { reconnectProviders.push(...parseProviderList(expectValue('--reconnect', args[++i]))); } else if (a.startsWith('--reconnect=')) { reconnectProviders.push(...parseProviderList(expectInlineValue('--reconnect', a.slice('--reconnect='.length)))); + } else if (a === '--supabase-project-ref') { + supabaseMcpProjectRef = expectSupabaseMcpProjectRef( + expectValue('--supabase-project-ref', args[++i]), + ); + } else if (a.startsWith('--supabase-project-ref=')) { + supabaseMcpProjectRef = expectSupabaseMcpProjectRef( + expectInlineValue( + '--supabase-project-ref', + a.slice('--supabase-project-ref='.length), + ), + ); } else if (a === '--byo-sandbox') { byoSandbox = true; } else if (a === '--detach') { @@ -395,6 +409,7 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { ...(cloudUrl ? { cloudUrl } : {}), ...(noPrompt ? { noPrompt: true } : {}), ...(reconnectProviders.length > 0 ? { reconnectProviders: [...new Set(reconnectProviders)] } : {}), + ...(supabaseMcpProjectRef ? { supabaseMcpProjectRef } : {}), ...(harnessSource ? { harnessSource } : {}), ...(byokKey ? { byokKey } : {}), ...(onExists ? { onExists } : {}), @@ -402,6 +417,16 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { }; } +function expectSupabaseMcpProjectRef(value: string): string { + const projectRef = value.trim().toLowerCase(); + if (!/^[a-z0-9]{20}$/u.test(projectRef)) { + die( + '--supabase-project-ref: expected exactly 20 lowercase letters or digits', + ); + } + return projectRef; +} + function parseProviderList(value: string): string[] { const providers = value.split(',').map((entry) => entry.trim()).filter(Boolean); if (providers.length === 0) { diff --git a/packages/deploy/CHANGELOG.md b/packages/deploy/CHANGELOG.md index c6a52a25..cf4c3bc7 100644 --- a/packages/deploy/CHANGELOG.md +++ b/packages/deploy/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Collect a Supabase project ref before generic MCP OAuth so the connection is + project-scoped and read-only instead of account-wide. + ### Changed - Migrated from the deprecated `@daytonaio/sdk` to `@daytona/sdk` (same API, no diff --git a/packages/deploy/src/connect.test.ts b/packages/deploy/src/connect.test.ts index ccc62f54..e561b7ae 100644 --- a/packages/deploy/src/connect.test.ts +++ b/packages/deploy/src/connect.test.ts @@ -45,6 +45,36 @@ test('relayfileIntegrationResolver isConnected reads workspace provider status b ]); }); +test('relayfileIntegrationResolver makes Supabase status checks project-aware', async () => { + const urls: string[] = []; + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + fetch: async (url) => { + urls.push(String(url)); + return okJson({ + provider: 'supabase-mcp', + configKey: 'supabase-mcp-relay', + ready: false, + connectionMatched: false + }); + } + }); + + assert.equal( + await resolver.isConnected({ + workspace: 'ws-runtime', + provider: 'supabase-mcp', + supabaseMcpProjectRef: 'BVZZCAFZOYSEZUMRDVIF' + }), + false + ); + assert.deepEqual(urls, [ + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/supabase-mcp/status?supabaseMcpProjectRef=bvzzcafzoysezumrdvif&scope=deployer_user' + ]); +}); + test('relayfileIntegrationResolver isConnected scopes workspace provider status checks', async () => { const urls: string[] = []; const resolver = relayfileIntegrationResolver({ @@ -372,6 +402,36 @@ test('relayfileIntegrationResolver isConnected falls back to deployer-user list ); }); +test('relayfileIntegrationResolver fails closed when project-aware Supabase status is unavailable', async () => { + const io = createBufferedIO(); + const urls: string[] = []; + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + io, + fetch: async (url) => { + urls.push(String(url)); + return new Response('not found', { status: 404 }); + } + }); + + assert.equal( + await resolver.isConnected({ + workspace: 'ws-runtime', + provider: 'supabase-mcp', + supabaseMcpProjectRef: 'bvzzcafzoysezumrdvif' + }), + false + ); + assert.deepEqual(urls, [ + 'https://cloud.example.test/api/v1/workspaces/ws-runtime/integrations/supabase-mcp/status?supabaseMcpProjectRef=bvzzcafzoysezumrdvif&scope=deployer_user' + ]); + assert.ok(io.messages.some((message) => + message.level === 'warn' && /project-aware Supabase MCP status/.test(message.message) + )); +}); + test('relayfileIntegrationResolver isConnected falls back to workspace list matching for workspace source when status 404s', async () => { const urls: string[] = []; const resolver = relayfileIntegrationResolver({ @@ -570,6 +630,176 @@ test('relayfileIntegrationResolver connect opens a session and polls until conne assert.ok(io.messages.some((message) => message.message.includes('notion connected'))); }); +test('relayfileIntegrationResolver sends a normalized Supabase project ref', async () => { + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + pollIntervalMs: 0, + timeoutMs: 100, + openUrl: () => undefined, + sleep: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/integrations/connect-session')) { + assert.deepEqual(JSON.parse(String(init?.body)), { + allowedIntegrations: ['supabase-mcp'], + scope: { kind: 'deployer_user' }, + supabaseMcpProjectRef: 'bvzzcafzoysezumrdvif', + }); + return okJson({ + connectLink: 'https://connect.example.test/supabase', + connectionId: 'conn-supabase', + }); + } + if (url.includes('/integrations/supabase-mcp/status')) { + return okJson({ + ready: true, + state: 'ready', + currentConnectionId: 'conn-supabase', + }); + } + throw new Error(`unexpected URL ${url}`); + }, + }); + + assert.deepEqual( + await resolver.connect({ + workspace: 'ws-runtime', + provider: 'supabase-mcp', + supabaseMcpProjectRef: 'BVZZCAFZOYSEZUMRDVIF', + }), + { connectionId: 'conn-supabase' }, + ); +}); + +test('relayfileIntegrationResolver refuses Supabase OAuth without a project ref', async () => { + let fetched = false; + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + fetch: async () => { + fetched = true; + return okJson({}); + }, + }); + + await assert.rejects( + resolver.connect({ workspace: 'ws-runtime', provider: 'supabase-mcp' }), + /requires a valid 20-character project ref/, + ); + assert.equal(fetched, false); +}); + +test('connectIntegrations prompts for the Supabase project ref before OAuth', async () => { + const io = createBufferedIO(); + io.scriptConfirmations([true]); + io.scriptAnswers(['BVZZCAFZOYSEZUMRDVIF']); + let connectArgs: Record | undefined; + + const result = await connectIntegrations({ + persona: { + id: 'supabase-watchdog', + intent: 'monitor', + description: 'test persona', + tags: ['implementation'], + integrations: { 'supabase-mcp': {} }, + } as never, + workspace: 'ws-1', + noConnect: false, + io, + integrations: { + async isConnected() { + return false; + }, + async connect(args) { + connectArgs = args; + return { connectionId: 'conn-supabase' }; + }, + }, + }); + + assert.equal(connectArgs?.supabaseMcpProjectRef, 'bvzzcafzoysezumrdvif'); + assert.deepEqual(result.outcomes, [ + { provider: 'supabase-mcp', status: 'connected-now' }, + ]); +}); + +test('connectIntegrations checks the requested Supabase project before reusing OAuth', async () => { + const io = createBufferedIO(); + io.scriptConfirmations([true]); + io.scriptAnswers(['BVZZCAFZOYSEZUMRDVIF']); + let statusProjectRef: string | undefined; + let connectProjectRef: string | undefined; + + const result = await connectIntegrations({ + persona: { + id: 'supabase-watchdog', + intent: 'monitor', + description: 'test persona', + tags: ['implementation'], + integrations: { 'supabase-mcp': {} }, + } as never, + workspace: 'ws-1', + noConnect: false, + io, + integrations: { + async isConnected(args) { + statusProjectRef = args.supabaseMcpProjectRef; + // Cloud reports project A as unmatched when project B is requested. + return false; + }, + async connect(args) { + connectProjectRef = args.supabaseMcpProjectRef; + return { connectionId: 'conn-project-b' }; + }, + }, + }); + + assert.equal(statusProjectRef, 'bvzzcafzoysezumrdvif'); + assert.equal(connectProjectRef, 'bvzzcafzoysezumrdvif'); + assert.deepEqual(result.outcomes, [ + { provider: 'supabase-mcp', status: 'connected-now' }, + ]); +}); + +test('connectIntegrations allows project-scoped Supabase OAuth under --no-prompt', async () => { + const io = createBufferedIO(); + let connectCalled = false; + + const result = await connectIntegrations({ + persona: { + id: 'supabase-watchdog', + intent: 'monitor', + description: 'test persona', + tags: ['implementation'], + integrations: { 'supabase-mcp': {} }, + } as never, + workspace: 'ws-1', + noConnect: false, + noPrompt: true, + supabaseMcpProjectRef: 'bvzzcafzoysezumrdvif', + io, + integrations: { + async isConnected(args) { + assert.equal(args.supabaseMcpProjectRef, 'bvzzcafzoysezumrdvif'); + return false; + }, + async connect(args) { + connectCalled = true; + assert.equal(args.supabaseMcpProjectRef, 'bvzzcafzoysezumrdvif'); + return { connectionId: 'conn-project-b' }; + }, + }, + }); + + assert.equal(connectCalled, true); + assert.deepEqual(result.outcomes, [ + { provider: 'supabase-mcp', status: 'connected-now' }, + ]); +}); + test('relayfileIntegrationResolver never retries a failed POST connect session', async () => { let calls = 0; const resolver = relayfileIntegrationResolver({ diff --git a/packages/deploy/src/connect.ts b/packages/deploy/src/connect.ts index d0d511d1..c2603a78 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -18,6 +18,18 @@ import type { DeployIO, IntegrationConnectOutcome } from './types.js'; * `DeployResolvers.integrations` once Relayfile's OAuth surface is wired. */ const PROVIDER_ENV_PREFIX = 'WORKFORCE_INTEGRATION_'; +const SUPABASE_MCP_PROVIDERS = new Set(['supabase-mcp', 'supabase-mcp-relay']); +const SUPABASE_MCP_PROJECT_REF_PATTERN = /^[a-z0-9]{20}$/u; + +export function normalizeSupabaseMcpProjectRef(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const projectRef = value.trim().toLowerCase(); + return SUPABASE_MCP_PROJECT_REF_PATTERN.test(projectRef) ? projectRef : undefined; +} + +function isSupabaseMcpProvider(provider: string): boolean { + return SUPABASE_MCP_PROVIDERS.has(provider.trim().toLowerCase()); +} /** * Resolver the orchestrator uses to check + connect a Relayfile-backed @@ -46,6 +58,9 @@ export interface IntegrationConnectResolver { * not match are ignored — protecting against false positives when the * workspace has multiple Slack providers (slack-relay / slack-ricky / * slack-nightcto / slack-my-senior-dev / slack-sage). + * + * `supabaseMcpProjectRef` makes the status lookup project-aware so a ready + * connection for a different Supabase project cannot satisfy deployment. */ isConnected(args: { workspace: string; @@ -53,6 +68,7 @@ export interface IntegrationConnectResolver { source?: IntegrationSource; expectedConfigKey?: string; allowWorkspaceFallback?: boolean; + supabaseMcpProjectRef?: string; }): Promise; /** * Run the browser-based OAuth flow and resolve when the user finishes. @@ -72,6 +88,7 @@ export interface IntegrationConnectResolver { provider: string; source?: IntegrationSource; allowWorkspaceFallback?: boolean; + supabaseMcpProjectRef?: string; }): Promise<{ connectionId: string }>; } @@ -139,11 +156,20 @@ export function relayfileIntegrationResolver(opts: { provider, source, expectedConfigKey, - allowWorkspaceFallback + allowWorkspaceFallback, + supabaseMcpProjectRef, }) { const workspaceId = workspace || opts.workspaceId; const token = await resolveWorkspaceToken(opts.workspaceToken); const effectiveSource: IntegrationSource = source ?? { kind: 'deployer_user' }; + const normalizedSupabaseMcpProjectRef = isSupabaseMcpProvider(provider) + ? normalizeSupabaseMcpProjectRef(supabaseMcpProjectRef) + : undefined; + if (supabaseMcpProjectRef !== undefined && !normalizedSupabaseMcpProjectRef) { + throw new Error( + 'Supabase MCP project ref must contain exactly 20 lowercase letters or digits.' + ); + } const status = await fetchIntegrationStatusForScope({ fetchImpl, @@ -153,6 +179,9 @@ export function relayfileIntegrationResolver(opts: { workspaceId, provider, source: effectiveSource, + ...(normalizedSupabaseMcpProjectRef + ? { supabaseMcpProjectRef: normalizedSupabaseMcpProjectRef } + : {}), io }); if (statusIsConnectedForSource(status, provider, effectiveSource, expectedConfigKey)) { @@ -173,6 +202,9 @@ const fallbackSource = workspaceFallbackSource( workspaceId, provider, source: fallbackSource, + ...(normalizedSupabaseMcpProjectRef + ? { supabaseMcpProjectRef: normalizedSupabaseMcpProjectRef } + : {}), io }); return statusIsConnectedForSource( @@ -182,10 +214,24 @@ const fallbackSource = workspaceFallbackSource( expectedConfigKey ); }, - async connect({ workspace, provider, source, allowWorkspaceFallback }) { + async connect({ + workspace, + provider, + source, + allowWorkspaceFallback, + supabaseMcpProjectRef, + }) { const workspaceId = workspace || opts.workspaceId; const token = await resolveWorkspaceToken(opts.workspaceToken); const effectiveSource: IntegrationSource = source ?? { kind: 'deployer_user' }; + const normalizedSupabaseMcpProjectRef = isSupabaseMcpProvider(provider) + ? normalizeSupabaseMcpProjectRef(supabaseMcpProjectRef) + : undefined; + if (isSupabaseMcpProvider(provider) && !normalizedSupabaseMcpProjectRef) { + throw new Error( + 'Supabase MCP requires a valid 20-character project ref. Pass --supabase-project-ref or enter it when prompted.' + ); + } // Tell the cloud which table to write the new row into. Per // AgentWorkforce/cloud#1001, when `scope` is omitted the cloud @@ -197,6 +243,9 @@ const fallbackSource = workspaceFallbackSource( const sessionBody = { allowedIntegrations: [provider], scope: scopeRequest(effectiveSource), + ...(normalizedSupabaseMcpProjectRef + ? { supabaseMcpProjectRef: normalizedSupabaseMcpProjectRef } + : {}), ...(provider === 'github' && effectiveSource.kind === 'deployer_user' ? { githubInstallationFlow: true } : {}) @@ -287,6 +336,9 @@ const fallbackSource = workspaceFallbackSource( workspaceId, provider, source: effectiveSource, + ...(normalizedSupabaseMcpProjectRef + ? { supabaseMcpProjectRef: normalizedSupabaseMcpProjectRef } + : {}), io }; const status = await fetchIntegrationStatusForScope({ @@ -553,6 +605,8 @@ export interface ConnectAllInput { noConnect: boolean; noPrompt?: boolean; reconnectProviders?: readonly string[]; + /** Optional non-interactive Supabase project selection. */ + supabaseMcpProjectRef?: string; io: DeployIO; integrations: IntegrationConnectResolver; /** Optional cloud-login recovery for interactive 401s. */ @@ -635,7 +689,8 @@ export async function resolveExpectedProviderConfigKey( * - already-connected provider → no prompt; emits `already-connected` * - 401 while checking status + authRecovery → prompts login and retries once * - other auth failure while checking status → fails without integration prompts - * - not connected + noPrompt=true → fails immediately without prompting + * - not connected + noPrompt=true → fails immediately, except project-scoped + * Supabase OAuth can proceed when `supabaseMcpProjectRef` is supplied * - not connected + noConnect=true → fails the deploy with a clear message * - not connected + noConnect=false → prompts; on yes runs `connect`, * on no marks `skipped`. The orchestrator decides what to do with @@ -659,6 +714,17 @@ export async function connectIntegrations(input: ConnectAllInput): Promise { statusCheckFailure = message; } @@ -702,6 +769,7 @@ export async function connectIntegrations(input: ConnectAllInput): Promise { statusCheckFailure = message; } @@ -726,7 +794,10 @@ export async function connectIntegrations(input: ConnectAllInput): Promise { + const configured = normalizeSupabaseMcpProjectRef( + input.supabaseMcpProjectRef, + ); + if (input.supabaseMcpProjectRef !== undefined && !configured) { + throw new Error( + 'Supabase MCP project ref must contain exactly 20 lowercase letters or digits.' + ); + } + if (configured) return configured; + if (input.noPrompt) { + throw new Error( + 'Supabase MCP project ref is required with --no-prompt. Pass --supabase-project-ref .' + ); + } + + const answer = await input.io.prompt( + 'Supabase project ref (20 characters)', + ); + const prompted = normalizeSupabaseMcpProjectRef(answer); + if (!prompted) { + throw new Error( + 'Supabase MCP project ref must contain exactly 20 lowercase letters or digits.' + ); + } + return prompted; +} + async function connectSubscriptionProvider( input: ConnectAllInput, subscription: ProviderSubscriptionResolver @@ -851,6 +953,7 @@ async function checkProviderConnected( provider: string, source: IntegrationSource, expectedConfigKey: string | undefined, + supabaseMcpProjectRef: string | undefined, onFailure: (message: string) => void ): Promise { return await input.integrations @@ -861,7 +964,8 @@ async function checkProviderConnected( allowWorkspaceFallback: integrationAllowsWorkspaceFallback( input.persona.integrations?.[provider] ), - ...(expectedConfigKey ? { expectedConfigKey } : {}) + ...(expectedConfigKey ? { expectedConfigKey } : {}), + ...(supabaseMcpProjectRef ? { supabaseMcpProjectRef } : {}) }) .catch((err) => { const message = err instanceof Error ? err.message : String(err); @@ -1152,12 +1256,16 @@ async function fetchIntegrationStatusForScope(args: { provider: string; source: IntegrationSource; connectionId?: string; + supabaseMcpProjectRef?: string; io?: Pick; }): Promise { const url = new URL( `${args.apiUrl}/api/v1/workspaces/${encodeURIComponent(args.workspaceId)}/integrations/${encodeURIComponent(args.provider)}/status` ); if (args.connectionId) url.searchParams.set('connectionId', args.connectionId); + if (args.supabaseMcpProjectRef) { + url.searchParams.set('supabaseMcpProjectRef', args.supabaseMcpProjectRef); + } url.searchParams.set('scope', args.source.kind); if (args.source.kind === 'workspace_service_account') { url.searchParams.set('serviceAccountName', args.source.name); @@ -1166,6 +1274,12 @@ async function fetchIntegrationStatusForScope(args: { return await requestJson(args.fetchImpl, url.toString(), args.token, {}, args.retrySleep); } catch (err) { if (isCloudRequestError(err) && (err.status === 404 || err.status === 405)) { + if (args.supabaseMcpProjectRef) { + args.io?.warn?.( + 'cloud does not expose project-aware Supabase MCP status yet; treating the existing connection as unmatched.' + ); + return { ready: false, state: 'pending', connectionMatched: false }; + } args.io?.warn?.( 'cloud does not expose /integrations//status yet; falling back to the integrations list with ready-only matching.' ); diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index d90399fc..6267eff1 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -744,6 +744,49 @@ test('deploy connects each missing persona integration before launch', async () } }); +test('deploy forwards the Supabase project ref into the OAuth connect flow', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ integrations: { 'supabase-mcp': {} } }) + ); + const io = createBufferedIO(); + let connectedProjectRef: string | undefined; + const workspaceAuth: WorkspaceAuth = { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'tok' }; + } + }; + const integrations: IntegrationConnectResolver = { + async isConnected() { + return false; + }, + async connect({ supabaseMcpProjectRef }) { + connectedProjectRef = supabaseMcpProjectRef; + return { connectionId: 'conn-supabase' }; + } + }; + + try { + await deploy( + { + personaPath, + mode: 'dev', + io, + supabaseMcpProjectRef: 'BVZZCAFZOYSEZUMRDVIF' + }, + { + workspaceAuth, + integrations, + bundle: successfulBundleStager(), + modes: { dev: successfulDevLauncher() } + } + ); + + assert.equal(connectedProjectRef, 'bvzzcafzoysezumrdvif'); + } finally { + await cleanup(); + } +}); + test('cloud deploy retries transient catalog and status fetch failures before launch', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: { github: {} } }), diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 518a64ac..3323f6d2 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -255,6 +255,9 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { noConnect: opts.noConnect === true, ...(opts.noPrompt ? { noPrompt: true } : {}), ...(opts.reconnectProviders ? { reconnectProviders: opts.reconnectProviders } : {}), + ...(opts.supabaseMcpProjectRef + ? { supabaseMcpProjectRef: opts.supabaseMcpProjectRef } + : {}), io, integrations: resolvers.integrations ?? defaultIntegrationResolver({ mode, diff --git a/packages/deploy/src/types.ts b/packages/deploy/src/types.ts index ffd9cf87..4e6834cb 100644 --- a/packages/deploy/src/types.ts +++ b/packages/deploy/src/types.ts @@ -14,6 +14,8 @@ export interface DeployOptions { noConnect?: boolean; /** Force a fresh OAuth/connect flow for specific providers, even if status is ready. */ reconnectProviders?: string[]; + /** Supabase project selected for a project-scoped, read-only MCP OAuth connection. */ + supabaseMcpProjectRef?: string; /** Force BYO Daytona even when workforce-managed sandbox issuance is available. */ byoSandbox?: boolean; /** Background the runner instead of streaming logs in the foreground. */