@primitivedotdev/cli 0.25.2 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/run.js CHANGED
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execute } from "@oclif/core";
4
+ import { applyProxyAutoDetect } from "../dist/oclif/proxy-auto-detect.js";
5
+
6
+ // Auto-set NODE_USE_ENV_PROXY=1 when HTTP(S)_PROXY is in the env.
7
+ // Must run before any network init (e.g. before oclif loads commands
8
+ // that touch fetch). See proxy-auto-detect.ts for the full rationale.
9
+ applyProxyAutoDetect();
4
10
 
5
11
  await execute({ dir: import.meta.url });
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { Command, Errors, Flags } from "@oclif/core";
3
3
  import { operations, PrimitiveApiClient } from "@primitivedotdev/sdk/api";
4
4
  import { deleteCliCredentials, resolveCliAuth, } from "./auth.js";
5
+ import { maybeWriteFunctionEndpointRedirect, } from "./endpoints-test-redirect.js";
5
6
  export const API_ERROR_CODES = {
6
7
  accessDenied: "access_denied",
7
8
  authorizationPending: "authorization_pending",
@@ -722,6 +723,29 @@ export function createOperationCommand(operation) {
722
723
  configDir: this.config.configDir,
723
724
  payload: errorPayload,
724
725
  });
726
+ // Function-endpoint redirect. POST /endpoints/{id}/test on a
727
+ // function-kind endpoint returns `not_found` even though the
728
+ // same id IS visible in `endpoints:list-endpoints`. The hook
729
+ // looks the id up via listEndpoints; if it matches a
730
+ // function-kind row, it prints a redirect to
731
+ // `functions:test-function` (with the function id) so the
732
+ // caller does not have to translate the id themselves.
733
+ // No-op for any other operation, any other error code, or
734
+ // when the lookup misses or fails.
735
+ const listClient = apiClient.client;
736
+ const listEndpointsFn = () => operations.listEndpoints({
737
+ client: listClient,
738
+ responseStyle: "fields",
739
+ });
740
+ await maybeWriteFunctionEndpointRedirect({
741
+ sdkName: operation.sdkName,
742
+ errorCode: extractErrorCode(errorPayload),
743
+ endpointId: typeof parsedFlags.id === "string" ? parsedFlags.id : undefined,
744
+ listEndpoints: listEndpointsFn,
745
+ writeStderr: (chunk) => {
746
+ process.stderr.write(chunk);
747
+ },
748
+ });
725
749
  process.exitCode = 1;
726
750
  return;
727
751
  }
@@ -29,7 +29,7 @@ const SDK_VERSION_RANGE = "^0.25.0";
29
29
  // resolves at least v1.2.3, so the user does not silently downgrade
30
30
  // the bin under themselves. The lockstep test in functions-init.test.ts
31
31
  // enforces that invariant.
32
- const CLI_VERSION_RANGE = "^0.25.0";
32
+ const CLI_VERSION_RANGE = "^0.26.0";
33
33
  // esbuild version range. Pinned to the latest stable major used
34
34
  // elsewhere in the Primitive codebase for bundling Workers-style
35
35
  // handlers. Caret range so patch fixes flow in automatically.
@@ -52,9 +52,11 @@ export function renderHandler() {
52
52
  import { createPrimitiveClient } from "@primitivedotdev/sdk/api";
53
53
 
54
54
  // TODO: replace with your verified sender address. Must be a domain
55
- // you own or your managed *.primitive.email subdomain. The
56
- // self-reply guard below compares incoming mail against this value
57
- // to avoid the handler replying to its own outbound traffic.
55
+ // you own or your managed *.primitive.email subdomain. The isLoop
56
+ // guard below compares incoming mail against this value (in addition
57
+ // to the *.primitive.email suffix) so the handler does not reply to
58
+ // its own outbound traffic when REPLY_FROM is on a non-managed
59
+ // domain.
58
60
  const REPLY_FROM = "you@your-domain.primitive.email";
59
61
 
60
62
  interface EmailReceivedEvent {
@@ -64,6 +66,45 @@ interface EmailReceivedEvent {
64
66
  };
65
67
  }
66
68
 
69
+ // Loop protection. A deployed Function receives catch-all inbound for
70
+ // the managed *.primitive.email subdomain, which includes bounces and
71
+ // auto-replies generated by its own outbound traffic. Without this
72
+ // guard the handler can respond to its own bounces and create a
73
+ // fan-out loop.
74
+ //
75
+ // The default check returns true when From is on any *.primitive.email
76
+ // address (covers the managed subdomain catch-all, the simple
77
+ // self-reply case, and bounces from mailer-daemon@*.primitive.email)
78
+ // or when From contains REPLY_FROM as a case-insensitive substring.
79
+ // Substring matching is deliberate so display-name forms like
80
+ // "Support <support@example.com>" match a bare-address REPLY_FROM,
81
+ // but it also accepts false positives where REPLY_FROM is a suffix
82
+ // of another address (e.g. REPLY_FROM="info@x.com" matches
83
+ // "mr.info@x.com"). For strict equality, parse the address out of the
84
+ // header and exact-match against REPLY_FROM.
85
+ //
86
+ // Extend this helper if you need stricter detection. Common additions:
87
+ // - Match the org's signup / account-owner email (not auto-injected
88
+ // into env today; either bake it into a SIGNUP_EMAIL const or read
89
+ // it from a secret you set via \`primitive functions:set-secret\`).
90
+ // - Honor RFC 3834 auto-response headers: skip when
91
+ // \`event.email.headers["auto-submitted"]\` is anything other than
92
+ // "no", or when a \`List-Unsubscribe\` / \`Precedence: bulk\` header
93
+ // is present.
94
+ // - Track Message-ID / In-Reply-To chains to break ping-pong loops
95
+ // between two cooperating handlers on different domains.
96
+ export function isLoop(event: EmailReceivedEvent): boolean {
97
+ // event.email.headers.from is the raw RFC 2822 header value, so it
98
+ // may be a bare address ("alice@example.com") or a display-name form
99
+ // ("Alice <alice@example.com>"). Lowercase substring checks match
100
+ // both shapes without needing to parse the bracketed address.
101
+ const from = event.email.headers.from?.toLowerCase() ?? "";
102
+ if (!from) return false;
103
+ if (from.includes(".primitive.email")) return true;
104
+ if (from.includes(REPLY_FROM.toLowerCase())) return true;
105
+ return false;
106
+ }
107
+
67
108
  export default {
68
109
  async fetch(
69
110
  req: Request,
@@ -80,18 +121,12 @@ export default {
80
121
  return Response.json({ ok: true, skipped: event.event });
81
122
  }
82
123
 
83
- // Self-reply guard: do not act on mail that this function itself
84
- // sent. Without this, an outbound reply that gets forwarded back
85
- // into the inbox would trigger another reply, and so on.
86
- //
87
- // event.email.headers.from is the raw RFC 2822 header value, so
88
- // it may be either a bare address ("alice@example.com") or a
89
- // display-name form ("Alice <alice@example.com>"). A substring
90
- // check matches both. Tighten this predicate (e.g. parse the
91
- // bracketed address) if you legitimately want to act on mail
92
- // from your own domain.
93
- if (event.email.headers.from?.includes(REPLY_FROM)) {
94
- return Response.json({ ok: true, skipped: "self-reply" });
124
+ // Loop protection runs immediately after the event-type check
125
+ // (the gateway has already HMAC-verified the request before it
126
+ // reaches this handler). See isLoop above for what's covered and
127
+ // how to extend it.
128
+ if (isLoop(event)) {
129
+ return Response.json({ ok: true, skipped: "loop" });
95
130
  }
96
131
 
97
132
  const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });
@@ -0,0 +1,238 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { getEmail, PrimitiveApiClient, testFunction, } from "@primitivedotdev/sdk/api";
3
+ 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";
4
+ import { resolveCliAuth } from "../auth.js";
5
+ import { DEFAULT_EMAIL_POLL_INTERVAL_SECONDS, fetchEmailSearchPage, sleep, } from "./emails-poll.js";
6
+ // `primitive functions:test-function` is the agent-grade shortcut for
7
+ // triggering a real round-trip and (optionally) waiting for the
8
+ // function to actually run before exiting. The underlying
9
+ // `POST /functions/{id}/test` operation only kicks off a synthetic
10
+ // inbound through MX and returns the queued send id; AGX walkthroughs
11
+ // flagged the missing wait-and-show-sends step as the single biggest
12
+ // time-sink in the verification loop.
13
+ //
14
+ // Shapes:
15
+ // primitive functions:test-function --id <fn-id>
16
+ // Fire-and-forget. Returns the TestInvocationResult JSON
17
+ // (recipient, poll_since, watch_url). Same behavior as the
18
+ // auto-generated functions:test-function it replaces.
19
+ //
20
+ // primitive functions:test-function --id <fn-id> --wait
21
+ // Blocks until the test inbound has arrived AND the function's
22
+ // webhook has fired (or --timeout elapses). Exits non-zero on
23
+ // timeout or on exhausted retries.
24
+ //
25
+ // primitive functions:test-function --id <fn-id> --wait --show-sends
26
+ // Same as --wait, plus prints the inbound's `replies` array
27
+ // (every outbound the function emitted while processing the
28
+ // test inbound), with each send's id, status, recipient,
29
+ // subject, and queue id.
30
+ //
31
+ // The auto-generated functions:test-function entry is filtered out
32
+ // of the generated-command set in oclif/index.ts so this hand-rolled
33
+ // version owns the id.
34
+ const DEFAULT_WAIT_TIMEOUT_SECONDS = 60;
35
+ // Terminal states from the EmailWebhookStatus enum. `fired` means the
36
+ // function returned 2xx; `exhausted` means all retries are spent and
37
+ // the delivery is permanently failed. `pending` / `in_flight` /
38
+ // `failed` are intermediate (`failed` is a temporary failure that may
39
+ // retry into `fired` or eventually `exhausted`), so we keep polling.
40
+ const TERMINAL_WEBHOOK_STATUSES = new Set(["fired", "exhausted"]);
41
+ class FunctionsTestFunctionCommand extends Command {
42
+ 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.";
43
+ static summary = "Trigger a test invocation; with --wait, watch it land";
44
+ static examples = [
45
+ "<%= config.bin %> functions:test-function --id <fn-id>",
46
+ "<%= config.bin %> functions:test-function --id <fn-id> --local-part summarize",
47
+ "<%= config.bin %> functions:test-function --id <fn-id> --wait --show-sends",
48
+ "<%= config.bin %> functions:test-function --id <fn-id> --local-part summarize --wait --timeout 120",
49
+ ];
50
+ static flags = {
51
+ "api-key": Flags.string({
52
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
53
+ env: "PRIMITIVE_API_KEY",
54
+ }),
55
+ "api-base-url-1": Flags.string({
56
+ description: API_BASE_URL_1_FLAG_DESCRIPTION,
57
+ env: "PRIMITIVE_API_BASE_URL_1",
58
+ hidden: true,
59
+ }),
60
+ "api-base-url-2": Flags.string({
61
+ description: API_BASE_URL_2_FLAG_DESCRIPTION,
62
+ env: "PRIMITIVE_API_BASE_URL_2",
63
+ hidden: true,
64
+ }),
65
+ id: Flags.string({
66
+ description: "Function id (UUID).",
67
+ required: true,
68
+ }),
69
+ "local-part": Flags.string({
70
+ description: "Override the synthetic local-part the test inbound is addressed to. Otherwise the runtime picks `__primitive_function_test+<random>`.",
71
+ }),
72
+ wait: Flags.boolean({
73
+ 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.",
74
+ }),
75
+ "show-sends": Flags.boolean({
76
+ description: "When the wait resolves, also print the outbound emails the function emitted while processing the test inbound (id, status, to, subject). Implies --wait.",
77
+ }),
78
+ timeout: Flags.integer({
79
+ default: DEFAULT_WAIT_TIMEOUT_SECONDS,
80
+ description: "Seconds to wait before exiting non-zero when --wait is set; 0 waits forever.",
81
+ min: 0,
82
+ }),
83
+ "poll-interval": Flags.integer({
84
+ default: DEFAULT_EMAIL_POLL_INTERVAL_SECONDS,
85
+ description: "Seconds between polls while waiting.",
86
+ min: 1,
87
+ }),
88
+ time: Flags.boolean({
89
+ description: TIME_FLAG_DESCRIPTION,
90
+ }),
91
+ };
92
+ async run() {
93
+ const { flags } = await this.parse(FunctionsTestFunctionCommand);
94
+ // --show-sends implies --wait. You can't print what was sent
95
+ // until the function has actually run.
96
+ const shouldWait = flags.wait || flags["show-sends"];
97
+ const shouldShowSends = flags["show-sends"];
98
+ const baseUrlOverridden = baseUrlOverriddenFromFlags(flags);
99
+ const auth = resolveCliAuth({
100
+ apiKey: flags["api-key"],
101
+ apiBaseUrl1: flags["api-base-url-1"],
102
+ apiBaseUrl2: flags["api-base-url-2"],
103
+ configDir: this.config.configDir,
104
+ });
105
+ const apiClient = new PrimitiveApiClient({
106
+ apiKey: auth.apiKey,
107
+ apiBaseUrl1: auth.apiBaseUrl1,
108
+ apiBaseUrl2: auth.apiBaseUrl2,
109
+ });
110
+ await runWithTiming(flags.time, async () => {
111
+ // 1. Trigger the test send.
112
+ const triggerResult = await testFunction({
113
+ client: apiClient.client,
114
+ path: { id: flags.id },
115
+ body: flags["local-part"]
116
+ ? { local_part: flags["local-part"] }
117
+ : undefined,
118
+ responseStyle: "fields",
119
+ });
120
+ if (triggerResult.error) {
121
+ const payload = extractErrorPayload(triggerResult.error);
122
+ writeErrorWithHints(payload);
123
+ removeStaleSavedCredentialOnUnauthorized({
124
+ auth,
125
+ baseUrlOverridden,
126
+ configDir: this.config.configDir,
127
+ payload,
128
+ });
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ const invocation = triggerResult.data
133
+ .data;
134
+ if (!shouldWait) {
135
+ // Fire-and-forget path: print the TestInvocationResult JSON
136
+ // unchanged. Same shape the auto-generated command emitted.
137
+ this.log(JSON.stringify(invocation, null, 2));
138
+ return;
139
+ }
140
+ const startedAt = Date.now();
141
+ const timeoutMs = flags.timeout * 1000;
142
+ const pollIntervalMs = flags["poll-interval"] * 1000;
143
+ const isExpired = () => flags.timeout > 0 && Date.now() - startedAt > timeoutMs;
144
+ // 2. Wait for the test inbound to arrive. The synthetic
145
+ // recipient is unique per call (random suffix in the local-part
146
+ // unless --local-part overrides), so `to` + `since` uniquely
147
+ // identifies the test inbound row.
148
+ this.log(`Waiting for test inbound to arrive at ${invocation.to}...`);
149
+ let inboundId;
150
+ while (!isExpired()) {
151
+ const page = await fetchEmailSearchPage({
152
+ apiClient,
153
+ filters: { to: invocation.to },
154
+ pageSize: 25,
155
+ since: invocation.poll_since,
156
+ });
157
+ if (!page.ok) {
158
+ const payload = extractErrorPayload(page.error);
159
+ writeErrorWithHints(payload);
160
+ removeStaleSavedCredentialOnUnauthorized({
161
+ auth,
162
+ baseUrlOverridden,
163
+ configDir: this.config.configDir,
164
+ payload,
165
+ });
166
+ process.exitCode = 1;
167
+ return;
168
+ }
169
+ const found = page.rows[0];
170
+ if (found) {
171
+ inboundId = found.id;
172
+ break;
173
+ }
174
+ await sleep(pollIntervalMs);
175
+ }
176
+ if (!inboundId) {
177
+ 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 });
178
+ }
179
+ // 3. Wait for the function (webhook) to actually run. We poll
180
+ // the email-detail endpoint because it already carries both the
181
+ // webhook_status terminal state and the `replies` array we'll
182
+ // print under --show-sends. No second endpoint needed.
183
+ this.log(`Inbound landed (${inboundId}). Waiting for function to run...`);
184
+ let detail;
185
+ while (!isExpired()) {
186
+ const result = await getEmail({
187
+ client: apiClient.client,
188
+ path: { id: inboundId },
189
+ responseStyle: "fields",
190
+ });
191
+ if (result.error) {
192
+ const payload = extractErrorPayload(result.error);
193
+ writeErrorWithHints(payload);
194
+ removeStaleSavedCredentialOnUnauthorized({
195
+ auth,
196
+ baseUrlOverridden,
197
+ configDir: this.config.configDir,
198
+ payload,
199
+ });
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+ const fetched = result.data.data;
204
+ if (fetched.webhook_status &&
205
+ TERMINAL_WEBHOOK_STATUSES.has(fetched.webhook_status)) {
206
+ detail = fetched;
207
+ break;
208
+ }
209
+ await sleep(pollIntervalMs);
210
+ }
211
+ if (!detail) {
212
+ 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 });
213
+ }
214
+ // 4. Emit the outcome.
215
+ const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
216
+ const outcome = {
217
+ function_id: flags.id,
218
+ inbound_id: inboundId,
219
+ inbound_to: invocation.to,
220
+ webhook_status: detail.webhook_status,
221
+ webhook_attempt_count: detail.webhook_attempt_count,
222
+ webhook_last_status_code: detail.webhook_last_status_code,
223
+ webhook_last_error: detail.webhook_last_error,
224
+ elapsed_seconds: elapsedSeconds,
225
+ };
226
+ if (shouldShowSends) {
227
+ outcome.sent_emails = detail.replies;
228
+ }
229
+ this.log(JSON.stringify(outcome, null, 2));
230
+ // Exit non-zero when the function failed permanently so CI
231
+ // scripts can gate on the exit code.
232
+ if (detail.webhook_status === "exhausted") {
233
+ process.exitCode = 1;
234
+ }
235
+ });
236
+ }
237
+ }
238
+ export default FunctionsTestFunctionCommand;
@@ -0,0 +1,94 @@
1
+ // `endpoints:test-endpoint` calls POST /endpoints/{id}/test. The server
2
+ // implementation only resolves http-kind endpoints by url and returns
3
+ // `not_found` for function-kind endpoints (where url is null). The same
4
+ // function-endpoint id IS returned by `endpoints:list-endpoints`, so a
5
+ // caller naturally tries it against test-endpoint and is greeted with
6
+ // "Endpoint not found." Confusing.
7
+ //
8
+ // This helper closes the loop on the CLI side. After test-endpoint
9
+ // returns `not_found`, the dispatcher in `api-command.ts` calls
10
+ // `detectFunctionEndpoint` to see whether the id actually belongs to a
11
+ // function-kind endpoint owned by the caller. If yes, we replace the
12
+ // generic envelope with a redirect to `functions:test-function`,
13
+ // surfacing both the endpoint id and the function id so the caller
14
+ // does not have to look the function id up themselves.
15
+ //
16
+ // `kind` and `function_id` are not currently declared on the OpenAPI
17
+ // `Endpoint` schema (so they are absent from the generated TS types),
18
+ // but they are present in the JSON the server returns. We read them
19
+ // off a loose Record<string, unknown> rather than relying on the
20
+ // generated type, then sanity-check both fields before treating an
21
+ // endpoint as a function endpoint.
22
+ // Returns a `FunctionEndpointMatch` if and only if `endpointId` matches
23
+ // an endpoint in the caller's `listEndpoints` response whose `kind` is
24
+ // `function` and whose `function_id` is a non-empty string. Any other
25
+ // outcome (no match, http-kind match, list call failure, missing
26
+ // fields) returns `null` so the dispatcher falls back to surfacing the
27
+ // original error envelope unchanged.
28
+ export async function detectFunctionEndpoint(endpointId, listEndpoints) {
29
+ let response;
30
+ try {
31
+ response = await listEndpoints();
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ if (response.error)
37
+ return null;
38
+ const rows = response.data?.data;
39
+ if (!Array.isArray(rows))
40
+ return null;
41
+ // Relies on `listEndpoints` returning every endpoint in a single response.
42
+ // True today: the operation has `query?: never` in the generated types and
43
+ // the server returns all rows. If pagination is ever added, an endpoint on
44
+ // a later page would silently miss the redirect and reduce this to the
45
+ // original "Endpoint not found" UX. Update this call (filter-by-id or
46
+ // exhaust-pages) at the same time pagination lands on listEndpoints.
47
+ for (const row of rows) {
48
+ if (!row || typeof row !== "object")
49
+ continue;
50
+ if (row.id !== endpointId)
51
+ continue;
52
+ if (row.kind !== "function")
53
+ return null;
54
+ const functionId = row.function_id;
55
+ if (typeof functionId !== "string" || functionId.length === 0)
56
+ return null;
57
+ return { endpointId, functionId };
58
+ }
59
+ return null;
60
+ }
61
+ // Stderr copy printed when the dispatcher detects the
62
+ // `endpoints:test-endpoint` `not_found` was really a function-kind
63
+ // endpoint. Surfaces both ids so the caller does not have to run
64
+ // `endpoints:list-endpoints` again to find the function_id. Returned
65
+ // as a string rather than written here so the call site controls the
66
+ // stream (stderr, in practice) and the tests can assert on the value.
67
+ export function formatFunctionEndpointRedirect(match) {
68
+ return [
69
+ "This is a function endpoint. Function endpoints are tested differently. Run:",
70
+ "",
71
+ ` primitive functions:test-function --id ${match.functionId}`,
72
+ "",
73
+ `(pass the function id, not the endpoint id. endpoint_id=${match.endpointId} function_id=${match.functionId})`,
74
+ ].join("\n");
75
+ }
76
+ // Post-error hook: if the operation that just failed is
77
+ // `endpoints:test-endpoint`, the failure is a `not_found`, and the
78
+ // caller's id matches a function-kind endpoint they own, print a
79
+ // redirect to `functions:test-function`. Returns the resolved match
80
+ // so the caller (and the test) can assert the branch taken without
81
+ // scraping stderr.
82
+ export async function maybeWriteFunctionEndpointRedirect(inputs) {
83
+ if (inputs.sdkName !== "testEndpoint")
84
+ return null;
85
+ if (inputs.errorCode !== "not_found")
86
+ return null;
87
+ if (!inputs.endpointId)
88
+ return null;
89
+ const match = await detectFunctionEndpoint(inputs.endpointId, inputs.listEndpoints);
90
+ if (!match)
91
+ return null;
92
+ inputs.writeStderr(`${formatFunctionEndpointRedirect(match)}\n`);
93
+ return match;
94
+ }
@@ -9,6 +9,7 @@ import FunctionsDeployCommand from "./commands/functions-deploy.js";
9
9
  import FunctionsInitCommand from "./commands/functions-init.js";
10
10
  import FunctionsRedeployCommand from "./commands/functions-redeploy.js";
11
11
  import FunctionsSetSecretCommand from "./commands/functions-set-secret.js";
12
+ import FunctionsTestFunctionCommand from "./commands/functions-test-function.js";
12
13
  import LoginCommand from "./commands/login.js";
13
14
  import LogoutCommand from "./commands/logout.js";
14
15
  import SendCommand from "./commands/send.js";
@@ -108,7 +109,17 @@ class CompletionCommand extends Command {
108
109
  function commandId(operation) {
109
110
  return `${operation.tagCommand}:${operation.command}`;
110
111
  }
111
- const generatedCommands = Object.fromEntries(operationManifest.map((operation) => [
112
+ // Operation ids whose surface is owned by a hand-rolled command in
113
+ // COMMANDS below. The auto-generated wrapper is filtered out so the
114
+ // hand-rolled command owns the id without a name collision.
115
+ const OVERRIDDEN_OPERATION_IDS = new Set([
116
+ // `functions:test-function` is hand-rolled to add --wait, --show-sends,
117
+ // and --timeout flags on top of the auto-generated POST /functions/{id}/test.
118
+ "functions:test-function",
119
+ ]);
120
+ const generatedCommands = Object.fromEntries(operationManifest
121
+ .filter((operation) => !OVERRIDDEN_OPERATION_IDS.has(commandId(operation)))
122
+ .map((operation) => [
112
123
  commandId(operation),
113
124
  createOperationCommand(operation),
114
125
  ]));
@@ -171,5 +182,12 @@ export const COMMANDS = {
171
182
  // visible to the running handler requires a separate redeploy,
172
183
  // which this shortcut folds in via --redeploy.
173
184
  "functions:set-secret": FunctionsSetSecretCommand,
185
+ // `functions:test-function` is hand-rolled to add --wait, --show-sends,
186
+ // and --timeout on top of POST /functions/{id}/test. Without those
187
+ // flags, agents had to manually thread queued-send + emails:wait +
188
+ // emails:get-email + sending:list-sent-emails to verify a function
189
+ // ran and see what it emitted; AGX walkthroughs flagged that loop as
190
+ // the single biggest verification time-sink.
191
+ "functions:test-function": FunctionsTestFunctionCommand,
174
192
  ...generatedCommands,
175
193
  };
@@ -0,0 +1,64 @@
1
+ // Auto-detect proxy environment variables at CLI startup so users
2
+ // behind a corporate proxy don't have to prefix every command with
3
+ // `NODE_USE_ENV_PROXY=1`.
4
+ //
5
+ // Node 22+ ignores `HTTP_PROXY` / `HTTPS_PROXY` for the built-in
6
+ // `fetch` / undici client unless `NODE_USE_ENV_PROXY=1` is set. That
7
+ // turns a one-line proxy export into per-command friction: every CLI
8
+ // invocation either inherits the prefix or fails with `ENETUNREACH`.
9
+ //
10
+ // This module is called once from `bin/run.js` before any network
11
+ // initialization. If any of the standard proxy env vars are set AND
12
+ // `NODE_USE_ENV_PROXY` is not already set explicitly, it sets it to
13
+ // `1` for the current process and prints a one-time stderr hint so
14
+ // the user knows what changed.
15
+ //
16
+ // An explicit `NODE_USE_ENV_PROXY` value (including `0`, `""`, etc.)
17
+ // is always respected: if the user has chosen to disable proxy use
18
+ // for this invocation, we don't override that choice.
19
+ const PROXY_ENV_VARS = [
20
+ "HTTP_PROXY",
21
+ "HTTPS_PROXY",
22
+ "http_proxy",
23
+ "https_proxy",
24
+ ];
25
+ // Module-level latch so the hint is printed at most once per process
26
+ // even if `applyProxyAutoDetect` is called more than once (e.g. from
27
+ // tests, or if a future entry point routes through it twice).
28
+ let hintPrinted = false;
29
+ // Test-only: reset the one-shot hint latch so each test case can
30
+ // observe the first-call behavior independently.
31
+ export function _resetHintLatchForTest() {
32
+ hintPrinted = false;
33
+ }
34
+ function detectProxyVars(env) {
35
+ return PROXY_ENV_VARS.filter((name) => {
36
+ const value = env[name];
37
+ return typeof value === "string" && value.length > 0;
38
+ });
39
+ }
40
+ export function applyProxyAutoDetect(options = {}) {
41
+ const env = options.env ?? process.env;
42
+ const stderr = options.stderr ?? process.stderr;
43
+ const detectedVars = detectProxyVars(env);
44
+ if (detectedVars.length === 0) {
45
+ return { applied: false, detectedVars: [], reason: "no_proxy_env" };
46
+ }
47
+ // Respect any explicit `NODE_USE_ENV_PROXY` value, including `0`
48
+ // or an empty string. The user has made a deliberate choice and
49
+ // auto-detection must not silently override it.
50
+ if (Object.hasOwn(env, "NODE_USE_ENV_PROXY")) {
51
+ return {
52
+ applied: false,
53
+ detectedVars,
54
+ reason: "node_use_env_proxy_already_set",
55
+ };
56
+ }
57
+ env.NODE_USE_ENV_PROXY = "1";
58
+ if (!hintPrinted) {
59
+ hintPrinted = true;
60
+ const names = detectedVars.join("/");
61
+ stderr.write(`primitive: proxy detected via ${names}, NODE_USE_ENV_PROXY=1 set automatically\n`);
62
+ }
63
+ return { applied: true, detectedVars, reason: "applied" };
64
+ }
@@ -1036,6 +1036,103 @@
1036
1036
  "summary": "Write a function secret (optionally redeploying to push it live)",
1037
1037
  "enableJsonFlag": false
1038
1038
  },
1039
+ "functions:test-function": {
1040
+ "aliases": [],
1041
+ "args": {},
1042
+ "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.",
1043
+ "examples": [
1044
+ "<%= config.bin %> functions:test-function --id <fn-id>",
1045
+ "<%= config.bin %> functions:test-function --id <fn-id> --local-part summarize",
1046
+ "<%= config.bin %> functions:test-function --id <fn-id> --wait --show-sends",
1047
+ "<%= config.bin %> functions:test-function --id <fn-id> --local-part summarize --wait --timeout 120"
1048
+ ],
1049
+ "flags": {
1050
+ "api-key": {
1051
+ "description": "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
1052
+ "env": "PRIMITIVE_API_KEY",
1053
+ "name": "api-key",
1054
+ "hasDynamicHelp": false,
1055
+ "multiple": false,
1056
+ "type": "option"
1057
+ },
1058
+ "api-base-url-1": {
1059
+ "description": "Override the primary API base URL. Internal testing only; not documented to customers.",
1060
+ "env": "PRIMITIVE_API_BASE_URL_1",
1061
+ "hidden": true,
1062
+ "name": "api-base-url-1",
1063
+ "hasDynamicHelp": false,
1064
+ "multiple": false,
1065
+ "type": "option"
1066
+ },
1067
+ "api-base-url-2": {
1068
+ "description": "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
1069
+ "env": "PRIMITIVE_API_BASE_URL_2",
1070
+ "hidden": true,
1071
+ "name": "api-base-url-2",
1072
+ "hasDynamicHelp": false,
1073
+ "multiple": false,
1074
+ "type": "option"
1075
+ },
1076
+ "id": {
1077
+ "description": "Function id (UUID).",
1078
+ "name": "id",
1079
+ "required": true,
1080
+ "hasDynamicHelp": false,
1081
+ "multiple": false,
1082
+ "type": "option"
1083
+ },
1084
+ "local-part": {
1085
+ "description": "Override the synthetic local-part the test inbound is addressed to. Otherwise the runtime picks `__primitive_function_test+<random>`.",
1086
+ "name": "local-part",
1087
+ "hasDynamicHelp": false,
1088
+ "multiple": false,
1089
+ "type": "option"
1090
+ },
1091
+ "wait": {
1092
+ "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.",
1093
+ "name": "wait",
1094
+ "allowNo": false,
1095
+ "type": "boolean"
1096
+ },
1097
+ "show-sends": {
1098
+ "description": "When the wait resolves, also print the outbound emails the function emitted while processing the test inbound (id, status, to, subject). Implies --wait.",
1099
+ "name": "show-sends",
1100
+ "allowNo": false,
1101
+ "type": "boolean"
1102
+ },
1103
+ "timeout": {
1104
+ "description": "Seconds to wait before exiting non-zero when --wait is set; 0 waits forever.",
1105
+ "name": "timeout",
1106
+ "default": 60,
1107
+ "hasDynamicHelp": false,
1108
+ "multiple": false,
1109
+ "type": "option"
1110
+ },
1111
+ "poll-interval": {
1112
+ "description": "Seconds between polls while waiting.",
1113
+ "name": "poll-interval",
1114
+ "default": 2,
1115
+ "hasDynamicHelp": false,
1116
+ "multiple": false,
1117
+ "type": "option"
1118
+ },
1119
+ "time": {
1120
+ "description": "Print the wall-clock duration of this command to stderr after it completes (e.g. `[time: 1.34s]`). Useful for measuring `--wait` send latency, comparing CLI overhead, or capturing timing in scripts.",
1121
+ "name": "time",
1122
+ "allowNo": false,
1123
+ "type": "boolean"
1124
+ }
1125
+ },
1126
+ "hasDynamicHelp": false,
1127
+ "hiddenAliases": [],
1128
+ "id": "functions:test-function",
1129
+ "pluginAlias": "@primitivedotdev/cli",
1130
+ "pluginName": "@primitivedotdev/cli",
1131
+ "pluginType": "core",
1132
+ "strict": true,
1133
+ "summary": "Trigger a test invocation; with --wait, watch it land",
1134
+ "enableJsonFlag": false
1135
+ },
1039
1136
  "account:get-account": {
1040
1137
  "aliases": [],
1041
1138
  "args": {},
@@ -3624,83 +3721,6 @@
3624
3721
  "summary": "Set a secret by key",
3625
3722
  "enableJsonFlag": false
3626
3723
  },
3627
- "functions:test-function": {
3628
- "aliases": [],
3629
- "args": {},
3630
- "description": "Sends a real test email from a Primitive-controlled sender to a\nlocal-part on one of the org's verified inbound domains. By\ndefault the recipient is a synthetic\n`__primitive_function_test+<random>@<domain>` address that\nevery handler's catch-all routing receives identically; pass\n`local_part` to override and exercise routing logic that\nbranches on a specific recipient (the common pattern when one\nfunction handles multiple inboxes like `summarize@` and\n`action@`). The function fires through the normal MX delivery\npath, so reply / send-mail calls from inside the handler\nagainst the inbound's `email.id` work the same as in\nproduction. Returns immediately after the send is queued; the\ninvocation appears on the function's invocations list within a\nfew seconds.\n\nRequires that the function is currently `deployed`. Returns 422\nif the function is in `pending` or `failed` state, or if the\norg has no verified inbound domain to receive the test mail.\nReturns 400 if `local_part` is set to a value that does not\nmatch the local-part character set.\n",
3631
- "flags": {
3632
- "api-key": {
3633
- "description": "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
3634
- "env": "PRIMITIVE_API_KEY",
3635
- "name": "api-key",
3636
- "hasDynamicHelp": false,
3637
- "multiple": false,
3638
- "type": "option"
3639
- },
3640
- "api-base-url-1": {
3641
- "description": "Override the primary API base URL. Internal testing only; not documented to customers.",
3642
- "env": "PRIMITIVE_API_BASE_URL_1",
3643
- "hidden": true,
3644
- "name": "api-base-url-1",
3645
- "hasDynamicHelp": false,
3646
- "multiple": false,
3647
- "type": "option"
3648
- },
3649
- "api-base-url-2": {
3650
- "description": "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
3651
- "env": "PRIMITIVE_API_BASE_URL_2",
3652
- "hidden": true,
3653
- "name": "api-base-url-2",
3654
- "hasDynamicHelp": false,
3655
- "multiple": false,
3656
- "type": "option"
3657
- },
3658
- "time": {
3659
- "description": "Print the wall-clock duration of this command to stderr after it completes (e.g. `[time: 1.34s]`). Useful for measuring `--wait` send latency, comparing CLI overhead, or capturing timing in scripts.",
3660
- "name": "time",
3661
- "allowNo": false,
3662
- "type": "boolean"
3663
- },
3664
- "id": {
3665
- "description": "Resource UUID",
3666
- "name": "id",
3667
- "required": true,
3668
- "hasDynamicHelp": false,
3669
- "multiple": false,
3670
- "type": "option"
3671
- },
3672
- "raw-body": {
3673
- "description": "Full request body as raw JSON. Escape hatch for nested or complex fields (e.g. arrays); prefer per-field flags (e.g. --to, --from, --body-text) when available.",
3674
- "name": "raw-body",
3675
- "hasDynamicHelp": false,
3676
- "multiple": false,
3677
- "type": "option"
3678
- },
3679
- "body-file": {
3680
- "description": "Path to a JSON file used as the request body. Same role as --raw-body for callers passing a saved payload.",
3681
- "name": "body-file",
3682
- "hasDynamicHelp": false,
3683
- "multiple": false,
3684
- "type": "option"
3685
- },
3686
- "local-part": {
3687
- "description": "Override the synthetic local-part. When set, the test email is sent to `<local_part>@<picked-domain>` instead of the default `__primitive_function_test+<random>@<picked-domain>`. Must start with an alphanumeric and contain only letters, digits, dots, plus signs, hyphens, or underscores; 1-64 characters total.",
3688
- "name": "local-part",
3689
- "hasDynamicHelp": false,
3690
- "multiple": false,
3691
- "type": "option"
3692
- }
3693
- },
3694
- "hasDynamicHelp": false,
3695
- "hiddenAliases": [],
3696
- "id": "functions:test-function",
3697
- "pluginAlias": "@primitivedotdev/cli",
3698
- "pluginName": "@primitivedotdev/cli",
3699
- "pluginType": "core",
3700
- "strict": true,
3701
- "summary": "Send a test invocation",
3702
- "enableJsonFlag": false
3703
- },
3704
3724
  "functions:update-function": {
3705
3725
  "aliases": [],
3706
3726
  "args": {},
@@ -4366,5 +4386,5 @@
4366
4386
  "enableJsonFlag": false
4367
4387
  }
4368
4388
  },
4369
- "version": "0.25.2"
4389
+ "version": "0.26.1"
4370
4390
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/cli",
3
- "version": "0.25.2",
3
+ "version": "0.26.1",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -35,7 +35,7 @@
35
35
  "description": "Claim, verify, and manage email domains"
36
36
  },
37
37
  "emails": {
38
- "description": "List, inspect, and manage received emails. Use `primitive emails:latest` for a one-line-per-email summary of recent inbound mail."
38
+ "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."
39
39
  },
40
40
  "sending": {
41
41
  "description": "Send outbound emails. For replies to inbound mail, use `sending:reply-to-email --id <inbound-id>` (threading and Re: subject derived server-side); for fresh sends, use `sending:send-email` or the `primitive send` shortcut."