From e92109b02688b0f99f8e11c4be305cccdd5f79f5 Mon Sep 17 00:00:00 2001 From: switchboard-coding Date: Thu, 17 Sep 2026 00:53:53 +0000 Subject: [PATCH] feat(cloud): add Trigger.dev to polylane cloud connect Co-Authored-By: Claude Fable 5.1 --- src/commands/cloud/connect.ts | 101 +++++++++++++++++++++++++- test/cloud-connect-triggerdev.test.ts | 93 ++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 test/cloud-connect-triggerdev.test.ts diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 9fd5383..7210884 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -51,6 +51,7 @@ type Provider = | 'convex' | 'clickhouse' | 'turso' + | 'triggerdev' | 'kubernetes'; export const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: string }> = [ @@ -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)' }, ]; @@ -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 \n' + + `Add --project-ref 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 { + const send = (b: TriggerdevConnectBody): Promise => + 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 .\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 { if (config.output === 'json') { formatOutput(config, { url }); @@ -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 @@ -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: [ { @@ -867,7 +961,8 @@ export const cloudConnectCommand: Command = { { flag: '--token-secret ', description: 'Modal token secret', type: 'string' }, { flag: '--organization ', description: 'PlanetScale organization, or Turso organization slug', type: 'string' }, // Render - { flag: '--api-key ', description: 'Render API key', type: 'string' }, + { flag: '--api-key ', description: 'Render API key, or Trigger.dev environment API key', type: 'string' }, + { flag: '--project-ref ', description: 'Trigger.dev: project ref (proj_...), only needed when the API asks for it', type: 'string' }, // ClickHouse { flag: '--key-id ', description: 'ClickHouse Cloud API key ID', type: 'string' }, { flag: '--key-secret ', description: 'ClickHouse Cloud API key secret', type: 'string' }, @@ -890,6 +985,8 @@ export const cloudConnectCommand: Command = { 'polylane cloud connect --provider clickhouse --key-id --key-secret ', 'polylane cloud connect --provider turso --token ', 'polylane cloud connect --provider turso --token --organization ', + 'polylane cloud connect --provider triggerdev --api-key ', + 'polylane cloud connect --provider triggerdev --api-key --project-ref proj_abc123', 'polylane cloud connect --provider kubernetes', ], async execute(config: Config, _flags, args: Record): Promise { diff --git a/test/cloud-connect-triggerdev.test.ts b/test/cloud-connect-triggerdev.test.ts new file mode 100644 index 0000000..a515be1 --- /dev/null +++ b/test/cloud-connect-triggerdev.test.ts @@ -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): 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 + ); + }); +});