@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 +6 -0
- package/dist/oclif/api-command.js +24 -0
- package/dist/oclif/commands/functions-init.js +51 -16
- package/dist/oclif/commands/functions-test-function.js +238 -0
- package/dist/oclif/endpoints-test-redirect.js +94 -0
- package/dist/oclif/index.js +19 -1
- package/dist/oclif/proxy-auto-detect.js +64 -0
- package/oclif.manifest.json +98 -78
- package/package.json +2 -2
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,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
|
+
}
|
package/dist/oclif/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/oclif.manifest.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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."
|