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
101 changes: 99 additions & 2 deletions src/commands/cloud/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type Provider =
| 'convex'
| 'clickhouse'
| 'turso'
| 'triggerdev'
| 'kubernetes';

export const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: string }> = [
Expand All @@ -66,6 +67,7 @@ export const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: str
{ value: 'convex', label: 'Convex', hint: 'team access token' },
{ value: 'clickhouse', label: 'ClickHouse', hint: 'API key ID + secret' },
{ value: 'turso', label: 'Turso', hint: 'platform API token' },
{ value: 'triggerdev', label: 'Trigger.dev', hint: 'environment API key' },
{ value: 'kubernetes', label: 'Kubernetes', hint: 'in-cluster agent, installed with Helm (console)' },
];

Expand Down Expand Up @@ -325,6 +327,63 @@ export async function connectTurso(
}
}

const TRIGGERDEV_PROJECT_REF_HINT =
'The project ref is the `project` line in trigger.config.ts and starts with proj_.';

const TRIGGERDEV_HEADLESS_HINT =
'Create an environment API key in your Trigger.dev project (production environment > API Keys, No restrictions access preset), then re-run:\n' +
'polylane cloud connect --provider triggerdev --api-key <key>\n' +
`Add --project-ref <proj_...> when the API asks for it. ${TRIGGERDEV_PROJECT_REF_HINT}`;

// The generated client trails the deployed API spec; the triggerdev body
// shape is the contract from the API-side design record.
export type TriggerdevConnectBody = {
workspaceId: string;
provider: 'triggerdev';
apiKey: string;
projectRef?: string;
};

// The API resolves the project from the key alone when it can; it answers 400
// when it needs the project ref to disambiguate. Prompt for the ref only then,
// once, and retry. Any other 400 (for example a key that cannot read runs)
// already carries the API's guidance and ends the step.
export async function connectTriggerdev(
config: Config,
api: PolylaneAPI,
body: TriggerdevConnectBody
): Promise<typeof BACK | ConnectResult> {
const send = (b: TriggerdevConnectBody): Promise<ConnectResult> =>
api.cloudAccountsConnect(b as unknown as ConnectBody);
try {
return await send(body);
} catch (err) {
if (
!isApiError(err) ||
err.status !== 400 ||
body.projectRef !== undefined ||
!/project ref/i.test(err.message)
) {
throw err;
}
if (!isInteractive(config.nonInteractive)) {
throw new CLIError(
err.message,
ExitCode.USAGE,
`Pass --project-ref <proj_...>.\n${TRIGGERDEV_PROJECT_REF_HINT}`
);
}
note(`${err.message}\n${TRIGGERDEV_PROJECT_REF_HINT}`, 'Trigger.dev project ref');
const picked = await promptTextOrBack(
{ nonInteractive: config.nonInteractive },
'Trigger.dev project ref (proj_...)',
{ validate: (v: string) => (v.trim() ? undefined : 'Required') }
);
if (picked === BACK) return BACK;
return send({ ...body, projectRef: picked.trim() });
}
}

async function openOrPrintInstallUrl(config: Config, url: string, label: string, noBrowser: boolean): Promise<void> {
if (config.output === 'json') {
formatOutput(config, { url });
Expand Down Expand Up @@ -515,6 +574,41 @@ async function connectProvider(
printConnectSuccess(config, result);
return 'connected';
}
if (provider === 'triggerdev') {
if (!isInteractive(config.nonInteractive) && getArgString(args, 'apiKey') === undefined) {
throw new CLIError('Missing required flag: --api-key', ExitCode.USAGE, TRIGGERDEV_HEADLESS_HINT);
}
let apiKey = '';
const ok = await runSteps([
secretStep(
config,
args,
'apiKey',
'--api-key',
{
message: 'Trigger.dev environment API key',
instructions:
'In your Trigger.dev project, open the production environment, then API Keys, and create a key with the No restrictions access preset. That preset is the only kind on the Free and Hobby plans; on Pro you may instead use restricted keys such as Observer plus Deploy only, adding them one at a time. Re-running this command with another key adds it to the same account.',
link: 'https://cloud.trigger.dev',
linkLabel: 'Open Trigger.dev',
},
(v) => {
apiKey = v;
}
),
]);
if (!ok) return BACK;
const projectRef = getArgString(args, 'projectRef');
const result = await connectTriggerdev(config, api, {
workspaceId,
provider: 'triggerdev',
apiKey,
...(projectRef !== undefined ? { projectRef } : {}),
});
if (result === BACK) return BACK;
printConnectSuccess(config, result);
return 'connected';
}
if (provider === 'kubernetes') {
// The kubeconfig upload no longer exists in the API: Kubernetes connects
// through the in-cluster Polylane agent, which registers itself and opens
Expand Down Expand Up @@ -841,7 +935,7 @@ async function connectProvider(

export const cloudConnectCommand: Command = {
name: 'cloud connect',
description: 'Connect a cloud account (AWS, Cloudflare, Vercel, Fly.io, Render, Railway, PlanetScale, Supabase, Modal, Convex, ClickHouse, Turso, Kubernetes)',
description: 'Connect a cloud account (AWS, Cloudflare, Vercel, Fly.io, Render, Railway, PlanetScale, Supabase, Modal, Convex, ClickHouse, Turso, Trigger.dev, Kubernetes)',
operationId: 'cloud_accounts.connect',
options: [
{
Expand All @@ -867,7 +961,8 @@ export const cloudConnectCommand: Command = {
{ flag: '--token-secret <secret>', description: 'Modal token secret', type: 'string' },
{ flag: '--organization <org>', description: 'PlanetScale organization, or Turso organization slug', type: 'string' },
// Render
{ flag: '--api-key <key>', description: 'Render API key', type: 'string' },
{ flag: '--api-key <key>', description: 'Render API key, or Trigger.dev environment API key', type: 'string' },
{ flag: '--project-ref <ref>', description: 'Trigger.dev: project ref (proj_...), only needed when the API asks for it', type: 'string' },
// ClickHouse
{ flag: '--key-id <id>', description: 'ClickHouse Cloud API key ID', type: 'string' },
{ flag: '--key-secret <secret>', description: 'ClickHouse Cloud API key secret', type: 'string' },
Expand All @@ -890,6 +985,8 @@ export const cloudConnectCommand: Command = {
'polylane cloud connect --provider clickhouse --key-id <id> --key-secret <secret>',
'polylane cloud connect --provider turso --token <token>',
'polylane cloud connect --provider turso --token <token> --organization <slug>',
'polylane cloud connect --provider triggerdev --api-key <key>',
'polylane cloud connect --provider triggerdev --api-key <key> --project-ref proj_abc123',
'polylane cloud connect --provider kubernetes',
],
async execute(config: Config, _flags, args: Record<string, unknown>): Promise<void> {
Expand Down
93 changes: 93 additions & 0 deletions test/cloud-connect-triggerdev.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { connectTriggerdev } from '../src/commands/cloud/connect';
import { ApiError } from '../src/errors/api';
import { CLIError } from '../src/errors/base';
import { ExitCode } from '../src/errors/codes';
import type { Config } from '../src/config/schema';
import type { PolylaneAPI } from '../src/generated/client';

const config = { nonInteractive: true } as Config;
const body = { workspaceId: 'ws_1', provider: 'triggerdev', apiKey: 'tr_key' } as const;
const REF_REQUIRED =
'This API key is valid for multiple projects. Specify the project ref of the project to connect.';

function mockApi(connect: (body: unknown) => Promise<unknown>): PolylaneAPI {
return { cloudAccountsConnect: connect } as unknown as PolylaneAPI;
}

describe('connectTriggerdev', () => {
it('sends the body without projectRef and returns the result', async () => {
const seen: unknown[] = [];
const result = { provider: 'triggerdev', accounts: [], failures: [] };
const api = mockApi(async (b) => {
seen.push(b);
return result;
});
assert.equal(await connectTriggerdev(config, api, body), result);
assert.deepEqual(seen, [body]);
});

it('sends projectRef through when given', async () => {
const seen: unknown[] = [];
const withRef = { ...body, projectRef: 'proj_abc123' };
const api = mockApi(async (b) => {
seen.push(b);
return { provider: 'triggerdev', accounts: [], failures: [] };
});
await connectTriggerdev(config, api, withRef);
assert.deepEqual(seen, [withRef]);
});

it('turns the project-ref-required 400 into a usage error with a --project-ref hint when not interactive', async () => {
const api = mockApi(async () => {
throw new ApiError(400, REF_REQUIRED, ExitCode.USAGE);
});
await assert.rejects(
() => connectTriggerdev(config, api, body),
(err: unknown) =>
err instanceof CLIError &&
err.exitCode === ExitCode.USAGE &&
err.message.includes('project ref') &&
(err.hint?.includes('--project-ref') ?? false) &&
(err.hint?.includes('trigger.config.ts') ?? false)
);
});

it('rethrows the project-ref-required 400 when a projectRef was already sent', async () => {
const original = new ApiError(400, REF_REQUIRED, ExitCode.USAGE);
const api = mockApi(async () => {
throw original;
});
await assert.rejects(
() => connectTriggerdev(config, api, { ...body, projectRef: 'proj_abc123' }),
(err: unknown) => err === original
);
});

it('rethrows other 400s untouched, including a key that cannot read runs', async () => {
const original = new ApiError(
400,
'This API key cannot read runs. Create a key with the No restrictions access preset, or a restricted key that includes run read access.',
ExitCode.USAGE
);
const api = mockApi(async () => {
throw original;
});
await assert.rejects(
() => connectTriggerdev(config, api, body),
(err: unknown) => err === original
);
});

it('rethrows non-400 errors untouched', async () => {
const original = new ApiError(401, 'Not signed in.', ExitCode.AUTH);
const api = mockApi(async () => {
throw original;
});
await assert.rejects(
() => connectTriggerdev(config, api, body),
(err: unknown) => err === original
);
});
});
Loading