diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 235c396..6e9f450 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -261,7 +261,7 @@ export function startAwsStackWait( type ConnectResult = Awaited>; -function printConnectSuccess(config: Config, result: ConnectResult): void { +export function printConnectSuccess(config: Config, result: ConnectResult): void { if (config.output === 'json' || !('accounts' in result)) { formatOutput(config, result); return; @@ -270,7 +270,7 @@ function printConnectSuccess(config: Config, result: ConnectResult): void { process.stderr.write(`✓ Connected: ${accountLabel(account)}\n`); } for (const failure of result.failures) { - process.stderr.write(`Couldn't connect ${failure.account}: ${failure.message}\n`); + process.stderr.write(`Couldn't connect ${failure.account}: ${failure.response ?? failure.message}\n`); } } @@ -327,64 +327,11 @@ export async function connectTurso( } } -const TRIGGERDEV_PROJECT_REF_WHERE = - "Paste the project ref from the project's settings page (it starts with proj_). It is also the `project` line in trigger.config.ts."; - -const TRIGGERDEV_PROJECT_REF_HINT = `This key cannot name its project. ${TRIGGERDEV_PROJECT_REF_WHERE}`; +const TRIGGERDEV_INSTRUCTIONS = + 'In the Trigger.dev environment you want to monitor, open API Keys and create a key with the "No restrictions" access preset. The API refuses every other preset. A prod key connects production and a staging key connects staging, each as its own account.'; 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 answers that the key cannot name its project. ${TRIGGERDEV_PROJECT_REF_WHERE}`; - -// 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 }, - 'Project ref', - { placeholder: 'proj_…', validate: (v: string) => (v.trim() ? undefined : 'Required') } - ); - if (picked === BACK) return BACK; - return send({ ...body, projectRef: picked.trim() }); - } -} + `${TRIGGERDEV_INSTRUCTIONS}\n` + 'Then re-run:\npolylane cloud connect --provider triggerdev --api-key '; async function openOrPrintInstallUrl(config: Config, url: string, label: string, noBrowser: boolean): Promise { if (config.output === 'json') { @@ -589,8 +536,7 @@ async function connectProvider( '--api-key', { message: 'Trigger.dev environment API key', - instructions: - 'In your production environment open 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.', + instructions: TRIGGERDEV_INSTRUCTIONS, link: 'https://cloud.trigger.dev', linkLabel: 'Open Trigger.dev', }, @@ -600,14 +546,7 @@ async function connectProvider( ), ]); 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; + const result = await api.cloudAccountsConnect({ workspaceId, provider: 'triggerdev', apiKey }); printConnectSuccess(config, result); return 'connected'; } @@ -964,7 +903,6 @@ export const cloudConnectCommand: Command = { { flag: '--organization ', description: 'PlanetScale organization, or Turso organization slug', type: 'string' }, // Render { 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' }, @@ -988,7 +926,6 @@ export const cloudConnectCommand: Command = { '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 index 649f703..626f87a 100644 --- a/test/cloud-connect-triggerdev.test.ts +++ b/test/cloud-connect-triggerdev.test.ts @@ -1,93 +1,77 @@ 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 { printConnectSuccess, cloudConnectCommand } from '../src/commands/cloud/connect'; 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; -// The API's `detail` strings, verbatim from nominal -// apps/apis/api-cloud-accounts/src/routers/cloud-accounts/connects/triggerdev.ts. -const REF_REQUIRED = 'The Trigger.dev project ref is required for a restricted key'; -const RUNS_REQUIRED = 'This Trigger.dev API key cannot read runs'; +const config = { output: 'text' } as Config; -function mockApi(connect: (body: unknown) => Promise): PolylaneAPI { - return { cloudAccountsConnect: connect } as unknown as PolylaneAPI; -} +type ConnectResult = Parameters[1]; -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]); - }); +// The refusal copy comes from the API verbatim (nominal +// apps/apis/api-cloud-accounts/src/routers/cloud-accounts/connects/triggerdev.ts); +// the CLI never rewrites it. +const NO_RESTRICTIONS_REFUSED = + "Polylane needs a key created with the 'No restrictions' preset"; - 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('This key cannot name its project.') ?? false) && - (err.hint?.includes("project's settings page") ?? false) && - (err.hint?.includes('trigger.config.ts') ?? false) - ); - }); +async function captureStderr(fn: () => Promise | void): Promise { + const writes: string[] = []; + const original = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + await fn(); + } finally { + process.stderr.write = original; + } + return writes.join(''); +} - 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 - ); +describe('printConnectSuccess', () => { + it("surfaces the API's refusal from failures[].response verbatim", async () => { + const result = { + provider: 'triggerdev', + accounts: [], + failures: [ + { + message: 'There was an error when connecting an account.', + response: NO_RESTRICTIONS_REFUSED, + account: 'proj_abc123/prod', + name: 'my-project (prod)', + type: '400', + }, + ], + } as unknown as ConnectResult; + const output = await captureStderr(() => printConnectSuccess(config, result)); + assert.equal(output, `Couldn't connect proj_abc123/prod: ${NO_RESTRICTIONS_REFUSED}\n`); }); - it('rethrows other 400s untouched, including a key that cannot read runs', async () => { - const original = new ApiError(400, RUNS_REQUIRED, ExitCode.USAGE); - const api = mockApi(async () => { - throw original; - }); - await assert.rejects( - () => connectTriggerdev(config, api, body), - (err: unknown) => err === original + it('falls back to the failure message when the API sent no response text', async () => { + const result = { + provider: 'triggerdev', + accounts: [], + failures: [ + { + message: 'There was an error when connecting an account.', + account: 'proj_abc123/prod', + name: 'my-project (prod)', + type: 'unknown', + }, + ], + } as unknown as ConnectResult; + const output = await captureStderr(() => printConnectSuccess(config, result)); + assert.equal( + output, + "Couldn't connect proj_abc123/prod: There was an error when connecting an account.\n" ); }); +}); - 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 - ); +describe('cloud connect --provider triggerdev flags', () => { + it('takes only --api-key: the project ref comes from the key, so no --project-ref flag exists', () => { + const flags = (cloudConnectCommand.options ?? []).map((o) => o.flag); + assert.ok(flags.some((f) => f.startsWith('--api-key'))); + assert.ok(!flags.some((f) => f.startsWith('--project-ref'))); }); });