Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add `--supabase-project-ref <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: <current> →
<latest>` and the install command to stderr. stdout stays exactly the version
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/deploy-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ Flags:
--reconnect <provider> 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 <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 <dir> Emit the bundle to <dir> and exit (no launch)
Expand Down Expand Up @@ -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<string, string> = {};
const reconnectProviders: string[] = [];

Expand All @@ -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') {
Expand Down Expand Up @@ -395,13 +409,24 @@ 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 } : {}),
...(Object.keys(inputs).length > 0 ? { inputs } : {})
};
}

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) {
Expand Down
5 changes: 5 additions & 0 deletions packages/deploy/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
230 changes: 230 additions & 0 deletions packages/deploy/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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<string, unknown> | 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({
Expand Down
Loading
Loading