@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.
|
|
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
|
-
//
|
|
57
|
-
// to
|
|
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
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
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
|
+
}
|
package/oclif.manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primitivedotdev/cli",
|
|
3
|
-
"version": "0.
|
|
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,
|