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
77 changes: 7 additions & 70 deletions src/commands/cloud/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ export function startAwsStackWait(

type ConnectResult = Awaited<ReturnType<PolylaneAPI['cloudAccountsConnect']>>;

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;
Expand All @@ -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`);
}
}

Expand Down Expand Up @@ -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 <key>\n' +
`Add --project-ref <proj_...> 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<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 },
'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 <key>';

async function openOrPrintInstallUrl(config: Config, url: string, label: string, noBrowser: boolean): Promise<void> {
if (config.output === 'json') {
Expand Down Expand Up @@ -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',
},
Expand All @@ -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';
}
Expand Down Expand Up @@ -964,7 +903,6 @@ export const cloudConnectCommand: Command = {
{ flag: '--organization <org>', description: 'PlanetScale organization, or Turso organization slug', type: 'string' },
// Render
{ 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 @@ -988,7 +926,6 @@ export const cloudConnectCommand: Command = {
'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
140 changes: 62 additions & 78 deletions test/cloud-connect-triggerdev.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): PolylaneAPI {
return { cloudAccountsConnect: connect } as unknown as PolylaneAPI;
}
type ConnectResult = Parameters<typeof printConnectSuccess>[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> | void): Promise<string> {
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')));
});
});
Loading