From d43649a390f0b1bf26e408ecb5c85b5cb62db995 Mon Sep 17 00:00:00 2001 From: Ethan Byrd Date: Tue, 12 May 2026 11:10:18 -0700 Subject: [PATCH 1/4] Mention emails:wait and emails:watch in emails topic description --- sdk-node/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk-node/package.json b/sdk-node/package.json index d32b095..93ad639 100644 --- a/sdk-node/package.json +++ b/sdk-node/package.json @@ -69,7 +69,7 @@ "description": "Claim, verify, and manage email domains" }, "emails": { - "description": "List, inspect, and manage received emails. Use `primitive emails:latest` for a one-line-per-email summary of recent inbound mail." + "description": "List, inspect, and wait for received emails. `primitive emails:latest` lists the most recent inbound, `primitive emails:wait` blocks until matching inbound arrives (filter with --to/--from/--subject/--q; bounded by --timeout and --number; ideal for agents and CI), and `primitive emails:watch` streams new matches indefinitely for long-running terminals." }, "sending": { "description": "Send outbound emails. For replies to inbound mail, use `sending:reply-to-email --id ` (threading and Re: subject derived server-side); for fresh sends, use `sending:send-email` or the `primitive send` shortcut." From 2ec982bc254ac78884e3899c99db0e86545689c0 Mon Sep 17 00:00:00 2001 From: Ethan Byrd Date: Tue, 12 May 2026 11:26:05 -0700 Subject: [PATCH 2/4] Add --wait / --show-sends / --timeout flags to functions:test-function --- .../oclif/commands/functions-test-function.ts | 294 ++++++++++++++++++ cli-node/src/oclif/index.ts | 27 +- .../oclif/functions-test-function.test.ts | 61 ++++ 3 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 cli-node/src/oclif/commands/functions-test-function.ts create mode 100644 cli-node/tests/oclif/functions-test-function.test.ts diff --git a/cli-node/src/oclif/commands/functions-test-function.ts b/cli-node/src/oclif/commands/functions-test-function.ts new file mode 100644 index 0000000..a1d1c37 --- /dev/null +++ b/cli-node/src/oclif/commands/functions-test-function.ts @@ -0,0 +1,294 @@ +import { Command, Flags } from "@oclif/core"; +import { + type EmailDetail, + getEmail, + PrimitiveApiClient, + type TestInvocationResult, + testFunction, +} from "@primitivedotdev/sdk/api"; +import { + API_BASE_URL_1_FLAG_DESCRIPTION, + API_BASE_URL_2_FLAG_DESCRIPTION, + baseUrlOverriddenFromFlags, + extractErrorPayload, + removeStaleSavedCredentialOnUnauthorized, + runWithTiming, + TIME_FLAG_DESCRIPTION, + writeErrorWithHints, +} from "../api-command.js"; +import { resolveCliAuth } from "../auth.js"; +import { + DEFAULT_EMAIL_POLL_INTERVAL_SECONDS, + fetchEmailSearchPage, + sleep, +} from "./emails-poll.js"; + +// `primitive functions:test-function` is the agent-grade shortcut for +// triggering a real round-trip and (optionally) waiting for the +// function to actually run before exiting. The underlying +// `POST /functions/{id}/test` operation only kicks off a synthetic +// inbound through MX and returns the queued send id; AGX walkthroughs +// flagged the missing wait-and-show-sends step as the single biggest +// time-sink in the verification loop. +// +// Shapes: +// primitive functions:test-function --id +// Fire-and-forget. Returns the TestInvocationResult JSON +// (recipient, poll_since, watch_url). Same behavior as the +// auto-generated functions:test-function it replaces. +// +// primitive functions:test-function --id --wait +// Blocks until the test inbound has arrived AND the function's +// webhook has fired (or --timeout elapses). Exits non-zero on +// timeout or on exhausted retries. +// +// primitive functions:test-function --id --wait --show-sends +// Same as --wait, plus prints the inbound's `replies` array +// (every outbound the function emitted while processing the +// test inbound), with each send's id, status, recipient, +// subject, and queue id. +// +// The auto-generated functions:test-function entry is filtered out +// of the generated-command set in oclif/index.ts so this hand-rolled +// version owns the id. + +const DEFAULT_WAIT_TIMEOUT_SECONDS = 60; + +// Terminal states from the EmailWebhookStatus enum. `fired` means the +// function returned 2xx; `exhausted` means all retries are spent and +// the delivery is permanently failed. `pending` / `in_flight` / +// `failed` are intermediate (`failed` is a temporary failure that may +// retry into `fired` or eventually `exhausted`), so we keep polling. +const TERMINAL_WEBHOOK_STATUSES = new Set(["fired", "exhausted"]); + +class FunctionsTestFunctionCommand extends Command { + static description = + "Send a real test email through MX to trigger this function. With --wait, blocks until the function has processed the inbound; with --show-sends, also prints any outbound sends the function emitted in response."; + + static summary = "Trigger a test invocation; with --wait, watch it land"; + + static examples = [ + "<%= config.bin %> functions:test-function --id ", + "<%= config.bin %> functions:test-function --id --local-part summarize", + "<%= config.bin %> functions:test-function --id --wait --show-sends", + "<%= config.bin %> functions:test-function --id --local-part summarize --wait --timeout 120", + ]; + + static flags = { + "api-key": Flags.string({ + description: + "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)", + env: "PRIMITIVE_API_KEY", + }), + "api-base-url-1": Flags.string({ + description: API_BASE_URL_1_FLAG_DESCRIPTION, + env: "PRIMITIVE_API_BASE_URL_1", + hidden: true, + }), + "api-base-url-2": Flags.string({ + description: API_BASE_URL_2_FLAG_DESCRIPTION, + env: "PRIMITIVE_API_BASE_URL_2", + hidden: true, + }), + id: Flags.string({ + description: "Function id (UUID).", + required: true, + }), + "local-part": Flags.string({ + description: + "Override the synthetic local-part the test inbound is addressed to. Otherwise the runtime picks `__primitive_function_test+`.", + }), + wait: Flags.boolean({ + description: + "Block until the function has processed the test inbound (webhook status is `fired` or `exhausted`) or --timeout elapses. Exits non-zero on timeout or on exhausted retries.", + }), + "show-sends": Flags.boolean({ + description: + "When the wait resolves, also print the outbound emails the function emitted while processing the test inbound (id, status, to, subject). Implies --wait.", + }), + timeout: Flags.integer({ + default: DEFAULT_WAIT_TIMEOUT_SECONDS, + description: + "Seconds to wait before exiting non-zero when --wait is set; 0 waits forever.", + min: 0, + }), + "poll-interval": Flags.integer({ + default: DEFAULT_EMAIL_POLL_INTERVAL_SECONDS, + description: "Seconds between polls while waiting.", + min: 1, + }), + time: Flags.boolean({ + description: TIME_FLAG_DESCRIPTION, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(FunctionsTestFunctionCommand); + + // --show-sends implies --wait. You can't print what was sent + // until the function has actually run. + const shouldWait = flags.wait || flags["show-sends"]; + const shouldShowSends = flags["show-sends"]; + + const baseUrlOverridden = baseUrlOverriddenFromFlags(flags); + const auth = resolveCliAuth({ + apiKey: flags["api-key"], + apiBaseUrl1: flags["api-base-url-1"], + apiBaseUrl2: flags["api-base-url-2"], + configDir: this.config.configDir, + }); + const apiClient = new PrimitiveApiClient({ + apiKey: auth.apiKey, + apiBaseUrl1: auth.apiBaseUrl1, + apiBaseUrl2: auth.apiBaseUrl2, + }); + + await runWithTiming(flags.time, async () => { + // 1. Trigger the test send. + const triggerResult = await testFunction({ + client: apiClient.client, + path: { id: flags.id }, + body: flags["local-part"] + ? { local_part: flags["local-part"] } + : undefined, + responseStyle: "fields", + }); + + if (triggerResult.error) { + const payload = extractErrorPayload(triggerResult.error); + writeErrorWithHints(payload); + removeStaleSavedCredentialOnUnauthorized({ + auth, + baseUrlOverridden, + configDir: this.config.configDir, + payload, + }); + process.exitCode = 1; + return; + } + + const invocation = (triggerResult.data as { data: TestInvocationResult }) + .data; + + if (!shouldWait) { + // Fire-and-forget path: print the TestInvocationResult JSON + // unchanged. Same shape the auto-generated command emitted. + this.log(JSON.stringify(invocation, null, 2)); + return; + } + + const startedAt = Date.now(); + const timeoutMs = flags.timeout * 1000; + const pollIntervalMs = flags["poll-interval"] * 1000; + const isExpired = () => + flags.timeout > 0 && Date.now() - startedAt > timeoutMs; + + // 2. Wait for the test inbound to arrive. The synthetic + // recipient is unique per call (random suffix in the local-part + // unless --local-part overrides), so `to` + `since` uniquely + // identifies the test inbound row. + this.log(`Waiting for test inbound to arrive at ${invocation.to}...`); + let inboundId: string | undefined; + while (!isExpired()) { + const page = await fetchEmailSearchPage({ + apiClient, + filters: { to: invocation.to }, + pageSize: 25, + since: invocation.poll_since, + }); + if (!page.ok) { + const payload = extractErrorPayload(page.error); + writeErrorWithHints(payload); + removeStaleSavedCredentialOnUnauthorized({ + auth, + baseUrlOverridden, + configDir: this.config.configDir, + payload, + }); + process.exitCode = 1; + return; + } + const found = page.rows[0]; + if (found) { + inboundId = found.id; + break; + } + await sleep(pollIntervalMs); + } + + if (!inboundId) { + this.error( + `Timed out after ${flags.timeout}s waiting for test inbound ${invocation.to} to land. Browse ${invocation.watch_url} for the live view.`, + { exit: 2 }, + ); + } + + // 3. Wait for the function (webhook) to actually run. We poll + // the email-detail endpoint because it already carries both the + // webhook_status terminal state and the `replies` array we'll + // print under --show-sends. No second endpoint needed. + this.log(`Inbound landed (${inboundId}). Waiting for function to run...`); + let detail: EmailDetail | undefined; + while (!isExpired()) { + const result = await getEmail({ + client: apiClient.client, + path: { id: inboundId }, + responseStyle: "fields", + }); + if (result.error) { + const payload = extractErrorPayload(result.error); + writeErrorWithHints(payload); + removeStaleSavedCredentialOnUnauthorized({ + auth, + baseUrlOverridden, + configDir: this.config.configDir, + payload, + }); + process.exitCode = 1; + return; + } + const fetched = (result.data as { data: EmailDetail }).data; + if ( + fetched.webhook_status && + TERMINAL_WEBHOOK_STATUSES.has(fetched.webhook_status) + ) { + detail = fetched; + break; + } + await sleep(pollIntervalMs); + } + + if (!detail) { + this.error( + `Timed out after ${flags.timeout}s waiting for function webhook to fire for inbound ${inboundId}. Browse ${invocation.watch_url} for the live view.`, + { exit: 2 }, + ); + } + + // 4. Emit the outcome. + const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); + const outcome: Record = { + function_id: flags.id, + inbound_id: inboundId, + inbound_to: invocation.to, + webhook_status: detail.webhook_status, + webhook_attempt_count: detail.webhook_attempt_count, + webhook_last_status_code: detail.webhook_last_status_code, + webhook_last_error: detail.webhook_last_error, + elapsed_seconds: elapsedSeconds, + }; + if (shouldShowSends) { + outcome.sent_emails = detail.replies; + } + this.log(JSON.stringify(outcome, null, 2)); + + // Exit non-zero when the function failed permanently so CI + // scripts can gate on the exit code. + if (detail.webhook_status === "exhausted") { + process.exitCode = 1; + } + }); + } +} + +export default FunctionsTestFunctionCommand; diff --git a/cli-node/src/oclif/index.ts b/cli-node/src/oclif/index.ts index a28b116..fc629f5 100644 --- a/cli-node/src/oclif/index.ts +++ b/cli-node/src/oclif/index.ts @@ -12,6 +12,7 @@ import FunctionsDeployCommand from "./commands/functions-deploy.js"; import FunctionsInitCommand from "./commands/functions-init.js"; import FunctionsRedeployCommand from "./commands/functions-redeploy.js"; import FunctionsSetSecretCommand from "./commands/functions-set-secret.js"; +import FunctionsTestFunctionCommand from "./commands/functions-test-function.js"; import LoginCommand from "./commands/login.js"; import LogoutCommand from "./commands/logout.js"; import SendCommand from "./commands/send.js"; @@ -150,11 +151,22 @@ function commandId(operation: PrimitiveOperationManifest): string { return `${operation.tagCommand}:${operation.command}`; } +// Operation ids whose surface is owned by a hand-rolled command in +// COMMANDS below. The auto-generated wrapper is filtered out so the +// hand-rolled command owns the id without a name collision. +const OVERRIDDEN_OPERATION_IDS = new Set([ + // `functions:test-function` is hand-rolled to add --wait, --show-sends, + // and --timeout flags on top of the auto-generated POST /functions/{id}/test. + "functions:test-function", +]); + const generatedCommands = Object.fromEntries( - operationManifest.map((operation) => [ - commandId(operation), - createOperationCommand(operation), - ]), + operationManifest + .filter((operation) => !OVERRIDDEN_OPERATION_IDS.has(commandId(operation))) + .map((operation) => [ + commandId(operation), + createOperationCommand(operation), + ]), ); export const COMMANDS: Record = { @@ -216,5 +228,12 @@ export const COMMANDS: Record = { // visible to the running handler requires a separate redeploy, // which this shortcut folds in via --redeploy. "functions:set-secret": FunctionsSetSecretCommand, + // `functions:test-function` is hand-rolled to add --wait, --show-sends, + // and --timeout on top of POST /functions/{id}/test. Without those + // flags, agents had to manually thread queued-send + emails:wait + + // emails:get-email + sending:list-sent-emails to verify a function + // ran and see what it emitted; AGX walkthroughs flagged that loop as + // the single biggest verification time-sink. + "functions:test-function": FunctionsTestFunctionCommand, ...generatedCommands, }; diff --git a/cli-node/tests/oclif/functions-test-function.test.ts b/cli-node/tests/oclif/functions-test-function.test.ts new file mode 100644 index 0000000..dd34fbd --- /dev/null +++ b/cli-node/tests/oclif/functions-test-function.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import FunctionsTestFunctionCommand from "../../src/oclif/commands/functions-test-function.js"; +import { COMMANDS } from "../../src/oclif/index.js"; + +// Smoke tests for the hand-rolled functions:test-function command. +// Verifies that the override is registered (so the auto-generated +// wrapper does not shadow it) and that the expected --wait / +// --show-sends / --timeout flags are present. The polling behavior +// itself is exercised via the existing emails-poll helpers, which +// have their own coverage in emails-poll.test.ts. + +describe("functions:test-function command registration", () => { + it("registers the hand-rolled command at the functions:test-function id", () => { + expect(COMMANDS["functions:test-function"]).toBe( + FunctionsTestFunctionCommand, + ); + }); + + it("exposes the --wait flag described as blocking", () => { + const flags = FunctionsTestFunctionCommand.flags as Record< + string, + { description?: string } + >; + expect(flags.wait).toBeDefined(); + expect(flags.wait.description).toMatch(/block/i); + }); + + it("exposes the --show-sends flag that implies --wait", () => { + const flags = FunctionsTestFunctionCommand.flags as Record< + string, + { description?: string } + >; + expect(flags["show-sends"]).toBeDefined(); + expect(flags["show-sends"].description).toMatch(/--wait|imply|implies/i); + }); + + it("exposes the --timeout flag with a sane default", () => { + const flags = FunctionsTestFunctionCommand.flags as Record< + string, + { default?: number } + >; + expect(flags.timeout).toBeDefined(); + expect(typeof flags.timeout.default).toBe("number"); + expect(flags.timeout.default).toBeGreaterThan(0); + }); + + it("requires --id", () => { + const flags = FunctionsTestFunctionCommand.flags as Record< + string, + { required?: boolean } + >; + expect(flags.id.required).toBe(true); + }); + + it("includes the wait + show-sends combo in static examples", () => { + const examples = FunctionsTestFunctionCommand.examples as string[]; + const joined = examples.join("\n"); + expect(joined).toMatch(/--wait/); + expect(joined).toMatch(/--show-sends/); + }); +}); From 42f10ba87bc9ab803f8a734bb7bc7dcd7f090d73 Mon Sep 17 00:00:00 2001 From: Ethan Byrd Date: Tue, 12 May 2026 11:31:05 -0700 Subject: [PATCH 3/4] Mirror emails topic description fix into cli-node (canonical CLI package) --- cli-node/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli-node/package.json b/cli-node/package.json index 025a27b..dddd8ed 100644 --- a/cli-node/package.json +++ b/cli-node/package.json @@ -35,7 +35,7 @@ "description": "Claim, verify, and manage email domains" }, "emails": { - "description": "List, inspect, and manage received emails. Use `primitive emails:latest` for a one-line-per-email summary of recent inbound mail." + "description": "List, inspect, and wait for received emails. `primitive emails:latest` lists the most recent inbound, `primitive emails:wait` blocks until matching inbound arrives (filter with --to/--from/--subject/--q; bounded by --timeout and --number; ideal for agents and CI), and `primitive emails:watch` streams new matches indefinitely for long-running terminals." }, "sending": { "description": "Send outbound emails. For replies to inbound mail, use `sending:reply-to-email --id ` (threading and Re: subject derived server-side); for fresh sends, use `sending:send-email` or the `primitive send` shortcut." From 54aa5c1ce39a0fc68585b8ca3daf153439b0aa04 Mon Sep 17 00:00:00 2001 From: Ethan Byrd Date: Tue, 12 May 2026 11:39:57 -0700 Subject: [PATCH 4/4] Bump cli-node and sdk-node to 0.26.1 for the help-text fix and --wait flags --- cli-node/package.json | 2 +- sdk-node/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli-node/package.json b/cli-node/package.json index dddd8ed..18ae55e 100644 --- a/cli-node/package.json +++ b/cli-node/package.json @@ -1,6 +1,6 @@ { "name": "@primitivedotdev/cli", - "version": "0.26.0", + "version": "0.26.1", "description": "Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.", "type": "module", "sideEffects": false, diff --git a/sdk-node/package.json b/sdk-node/package.json index 93ad639..ca5a3e8 100644 --- a/sdk-node/package.json +++ b/sdk-node/package.json @@ -1,6 +1,6 @@ { "name": "@primitivedotdev/sdk", - "version": "0.26.0", + "version": "0.26.1", "description": "Official Primitive Node.js SDK: webhook, api, openapi, contract, and parser runtime modules. The CLI moved to @primitivedotdev/cli; this package retains the CLI as a deprecated alias for a few minor releases.", "type": "module", "module": "./dist/index.js",