@primitivedotdev/cli 0.25.2 → 0.26.0

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,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
+ }
@@ -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
+ }
@@ -4366,5 +4366,5 @@
4366
4366
  "enableJsonFlag": false
4367
4367
  }
4368
4368
  },
4369
- "version": "0.25.2"
4369
+ "version": "0.26.0"
4370
4370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/cli",
3
- "version": "0.25.2",
3
+ "version": "0.26.0",
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,