@primitivedotdev/cli 0.25.1 → 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
  }
@@ -1,35 +1,147 @@
1
1
  import { Command, Flags } from "@oclif/core";
2
- import { createFunction, PrimitiveApiClient } from "@primitivedotdev/sdk/api";
2
+ import { createFunction, PrimitiveApiClient, setFunctionSecret, updateFunction, } from "@primitivedotdev/sdk/api";
3
3
  import { extractErrorPayload, readTextFileFlag, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
4
4
  import { resolveCliAuth } from "../auth.js";
5
5
  import { emitRawSendMailFetchWarning } from "../lint/raw-send-mail-fetch.js";
6
- // `primitive functions:deploy` is the agent-grade shortcut for
7
- // `functions:create-function`. The underlying operation takes `code`
8
- // as a string in the JSON body, which is awkward at the CLI for
9
- // multi-line bundles: agents would otherwise have to shell-escape an
10
- // entire ESM file or write a temp body.json. This command reads the
11
- // bundle straight off disk via --file, so the natural workflow is:
12
- //
13
- // esbuild handler.ts --bundle --format=esm --outfile=bundle.js
14
- // primitive functions:deploy --name myfn --file bundle.js
15
- //
16
- // Source maps follow the same shape via --source-map-file. They are
17
- // stored only on the runtime side (not in our database) so dropping
18
- // them later in the pipeline is fine; the CLI just hands them through.
19
- //
20
- // For full control (raw body, --raw-body JSON, etc.) the underlying
21
- // `functions:create-function` operation stays available.
6
+ import { parseSecretFlags, SECRET_FLAG_SECURITY_NOTE, } from "../secret-flags.js";
7
+ // Pure-ish orchestration of create + (optional secrets + redeploy).
8
+ // Mirrors runSetSecret in functions-set-secret.ts so the failure
9
+ // stages map directly onto stderr hints in run() below. Pulled out
10
+ // as a named export so the unit test can drive every branch with a
11
+ // fake DeployApiSurface, without spinning up a real client or the
12
+ // oclif command lifecycle.
13
+ export async function runDeployWithSecrets(api, params) {
14
+ const createResult = await api.createFunction({
15
+ code: params.code,
16
+ name: params.name,
17
+ ...(params.sourceMap !== undefined ? { sourceMap: params.sourceMap } : {}),
18
+ });
19
+ if (createResult.error) {
20
+ return {
21
+ kind: "error",
22
+ payload: extractErrorPayload(createResult.error),
23
+ stage: "create",
24
+ };
25
+ }
26
+ const created = createResult.data?.data;
27
+ if (!created) {
28
+ return {
29
+ kind: "error",
30
+ payload: {
31
+ code: "client_error",
32
+ message: "Create function returned no data",
33
+ },
34
+ stage: "create",
35
+ };
36
+ }
37
+ // Fast path: no secrets means no extra round-trips. The naked
38
+ // create result is exactly what the pre-secrets-flag command
39
+ // returned, so this branch is byte-identical to the previous
40
+ // behavior.
41
+ if (params.secrets.length === 0) {
42
+ return { kind: "ok", result: { created } };
43
+ }
44
+ const writtenSecrets = [];
45
+ const succeededKeys = [];
46
+ for (let i = 0; i < params.secrets.length; i++) {
47
+ const pair = params.secrets[i];
48
+ // Pre-compute the keys that come AFTER the current pair so a
49
+ // set-secret failure can surface every key that was never
50
+ // attempted, not just the one that failed. Without this, a user
51
+ // following the recovery hint verbatim would re-run set-secret
52
+ // only for the failed key and silently leave the trailing keys
53
+ // un-written.
54
+ const pendingKeys = params.secrets.slice(i + 1).map((p) => p.key);
55
+ const setResult = await api.setSecret({
56
+ id: created.id,
57
+ key: pair.key,
58
+ value: pair.value,
59
+ });
60
+ if (setResult.error) {
61
+ return {
62
+ created,
63
+ failedKey: pair.key,
64
+ kind: "error",
65
+ payload: extractErrorPayload(setResult.error),
66
+ pendingKeys,
67
+ stage: "set-secret",
68
+ succeededKeys,
69
+ };
70
+ }
71
+ const secret = setResult.data?.data;
72
+ if (!secret) {
73
+ return {
74
+ created,
75
+ failedKey: pair.key,
76
+ kind: "error",
77
+ payload: {
78
+ code: "client_error",
79
+ message: "Secret write returned no data",
80
+ },
81
+ pendingKeys,
82
+ stage: "set-secret",
83
+ succeededKeys,
84
+ };
85
+ }
86
+ writtenSecrets.push(secret);
87
+ succeededKeys.push(pair.key);
88
+ }
89
+ const updateResult = await api.updateFunction({
90
+ code: params.code,
91
+ id: created.id,
92
+ ...(params.sourceMap !== undefined ? { sourceMap: params.sourceMap } : {}),
93
+ });
94
+ if (updateResult.error) {
95
+ return {
96
+ created,
97
+ kind: "error",
98
+ payload: extractErrorPayload(updateResult.error),
99
+ stage: "redeploy",
100
+ succeededKeys,
101
+ };
102
+ }
103
+ const redeployed = updateResult.data?.data;
104
+ if (!redeployed) {
105
+ return {
106
+ created,
107
+ kind: "error",
108
+ payload: {
109
+ code: "client_error",
110
+ message: "Redeploy returned no data",
111
+ },
112
+ stage: "redeploy",
113
+ succeededKeys,
114
+ };
115
+ }
116
+ return {
117
+ kind: "ok",
118
+ result: { created, redeploy: redeployed, secrets: writtenSecrets },
119
+ };
120
+ }
22
121
  class FunctionsDeployCommand extends Command {
23
122
  static description = `Deploy a new function from a bundled handler file. Agent-grade shortcut for functions:create-function.
24
123
 
25
124
  Reads the bundle off disk (--file) instead of forcing the caller to
26
125
  serialize the source into a JSON body. Use the underlying operation
27
126
  \`functions:create-function\` if you need the full flag surface
28
- (raw-body JSON, etc.).`;
127
+ (raw-body JSON, etc.).
128
+
129
+ Pass --secret KEY=VALUE (repeatable) to seed secret bindings in the
130
+ same command. Keys must match \`^[A-Z_][A-Z0-9_]*$\` (uppercase
131
+ letters, digits, underscores; first character is a letter or
132
+ underscore). With one or more --secret flags the deploy fans out to
133
+ multiple API calls: create-function, set-secret per pair, then a
134
+ final update-function with the same bundle so the running handler
135
+ picks up the bindings. If a secret write fails after the create
136
+ step the function exists with whatever secrets succeeded and the
137
+ redeploy has NOT fired; re-run \`primitive functions:set-secret\`
138
+ for the missing keys, then \`primitive functions:redeploy\` to
139
+ push them live.`;
29
140
  static summary = "Deploy a new function from a bundled handler file";
30
141
  static examples = [
31
142
  "<%= config.bin %> functions:deploy --name forwarder --file ./bundle.js",
32
143
  "<%= config.bin %> functions:deploy --name forwarder --file ./bundle.js --source-map-file ./bundle.js.map",
144
+ "<%= config.bin %> functions:deploy --name forwarder --file ./bundle.js --secret OPENAI_KEY=sk-... --secret OWNER_EMAIL=me@example.com",
33
145
  ];
34
146
  static flags = {
35
147
  "api-key": Flags.string({
@@ -57,6 +169,10 @@ class FunctionsDeployCommand extends Command {
57
169
  "source-map-file": Flags.string({
58
170
  description: "Optional path to a source map for the bundle. Stored only on the runtime side and used to symbolicate stack traces.",
59
171
  }),
172
+ secret: Flags.string({
173
+ description: `Secret KEY=VALUE to seed on the deployed function. Repeatable. KEY must match \`^[A-Z_][A-Z0-9_]*$\`; VALUE may contain \`=\` (only the first \`=\` is treated as a delimiter). Each KEY may only appear once per command. Passing one or more --secret flags fans out the deploy to create-function, set-secret per pair, then a final redeploy so the running handler picks up the bindings. ${SECRET_FLAG_SECURITY_NOTE}`,
174
+ multiple: true,
175
+ }),
60
176
  time: Flags.boolean({
61
177
  description: TIME_FLAG_DESCRIPTION,
62
178
  }),
@@ -64,6 +180,17 @@ class FunctionsDeployCommand extends Command {
64
180
  async run() {
65
181
  const { flags } = await this.parse(FunctionsDeployCommand);
66
182
  await runWithTiming(flags.time, async () => {
183
+ // Validate --secret pairs BEFORE any disk read or API call so
184
+ // a malformed input fails fast with a clear error and zero
185
+ // side effects. The fast path (no --secret flags) skips this
186
+ // entirely.
187
+ const rawSecrets = flags.secret ?? [];
188
+ const parsedSecrets = parseSecretFlags(rawSecrets);
189
+ if (parsedSecrets.kind === "error") {
190
+ process.stderr.write(`${parsedSecrets.message}\n`);
191
+ process.exitCode = 1;
192
+ return;
193
+ }
67
194
  // Reads are inside the timed block so --time captures disk I/O
68
195
  // alongside the API call. A pathological filesystem (NFS, slow
69
196
  // FUSE mount) showing up here is exactly the kind of latency
@@ -96,27 +223,79 @@ class FunctionsDeployCommand extends Command {
96
223
  baseUrlOverridden,
97
224
  configDir: this.config.configDir,
98
225
  };
99
- const result = await createFunction({
100
- body: {
101
- name: flags.name,
102
- code,
103
- ...(sourceMap !== undefined ? { sourceMap } : {}),
104
- },
105
- client: apiClient.client,
106
- responseStyle: "fields",
226
+ // Adapter: thin wrappers around the generated SDK calls,
227
+ // routed through host 1 (apiClient.client). The function
228
+ // CRUD and secrets endpoints are not on host 2.
229
+ const apiSurface = {
230
+ createFunction: (p) => createFunction({
231
+ body: {
232
+ code: p.code,
233
+ name: p.name,
234
+ ...(p.sourceMap !== undefined ? { sourceMap: p.sourceMap } : {}),
235
+ },
236
+ client: apiClient.client,
237
+ responseStyle: "fields",
238
+ }),
239
+ setSecret: (p) => setFunctionSecret({
240
+ body: { value: p.value },
241
+ client: apiClient.client,
242
+ path: { id: p.id, key: p.key },
243
+ responseStyle: "fields",
244
+ }),
245
+ updateFunction: (p) => updateFunction({
246
+ body: {
247
+ code: p.code,
248
+ ...(p.sourceMap !== undefined ? { sourceMap: p.sourceMap } : {}),
249
+ },
250
+ client: apiClient.client,
251
+ path: { id: p.id },
252
+ responseStyle: "fields",
253
+ }),
254
+ };
255
+ const outcome = await runDeployWithSecrets(apiSurface, {
256
+ code,
257
+ name: flags.name,
258
+ secrets: parsedSecrets.secrets,
259
+ ...(sourceMap !== undefined ? { sourceMap } : {}),
107
260
  });
108
- if (result.error) {
109
- const errorPayload = extractErrorPayload(result.error);
110
- writeErrorWithHints(errorPayload);
261
+ if (outcome.kind === "error") {
262
+ // Stage-specific framing on stderr so callers can tell
263
+ // whether the function was created before a downstream
264
+ // failure left it without secrets or without the
265
+ // redeploy. The JSON envelope still goes through
266
+ // writeErrorWithHints so any actionable hint (e.g.
267
+ // unauthorized) is surfaced.
268
+ if (outcome.stage === "set-secret") {
269
+ const succeeded = outcome.succeededKeys.length > 0
270
+ ? outcome.succeededKeys.join(", ")
271
+ : "(none)";
272
+ const pending = outcome.pendingKeys.length > 0
273
+ ? outcome.pendingKeys.join(", ")
274
+ : "(none)";
275
+ const allMissing = [outcome.failedKey, ...outcome.pendingKeys].join(", ");
276
+ process.stderr.write(`Function ${outcome.created.name} (${outcome.created.id}) was created, but writing secret ${outcome.failedKey} failed; succeeded keys so far: ${succeeded}; keys not yet attempted: ${pending}. The redeploy is NOT yet live. Re-run \`primitive functions:set-secret\` for each of [${allMissing}], then \`primitive functions:redeploy --id ${outcome.created.id} --file <bundle>\` to push them live.\n`);
277
+ }
278
+ else if (outcome.stage === "redeploy") {
279
+ const succeeded = outcome.succeededKeys.length > 0
280
+ ? outcome.succeededKeys.join(", ")
281
+ : "(none)";
282
+ process.stderr.write(`Function ${outcome.created.name} (${outcome.created.id}) was created and secrets [${succeeded}] were written, but the final redeploy failed; the new bindings are NOT yet live. Re-run \`primitive functions:redeploy --id ${outcome.created.id} --file <bundle>\` once the cause is fixed.\n`);
283
+ }
284
+ writeErrorWithHints(outcome.payload);
111
285
  removeStaleSavedCredentialOnUnauthorized({
112
286
  ...authFailureContext,
113
- payload: errorPayload,
287
+ payload: outcome.payload,
114
288
  });
115
289
  process.exitCode = 1;
116
290
  return;
117
291
  }
118
- const envelope = result.data;
119
- this.log(JSON.stringify(envelope?.data ?? null, null, 2));
292
+ // On the happy path, prefer the redeployed FunctionDetail
293
+ // (when secrets fired) over the bare CreateFunctionResult,
294
+ // since the redeploy is the state the user actually
295
+ // deployed. When no secrets were passed, fall back to the
296
+ // create payload for byte-identical pre-flag behavior.
297
+ const payload = outcome.result.redeploy ?? outcome.result.created;
298
+ this.log(JSON.stringify(payload, null, 2));
120
299
  });
121
300
  }
122
301
  }
@@ -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.
@@ -51,13 +51,60 @@ export function renderHandler() {
51
51
  return `// env.PRIMITIVE_API_KEY is auto-injected by the Primitive Functions runtime.
52
52
  import { createPrimitiveClient } from "@primitivedotdev/sdk/api";
53
53
 
54
+ // TODO: replace with your verified sender address. Must be a domain
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.
60
+ const REPLY_FROM = "you@your-domain.primitive.email";
61
+
54
62
  interface EmailReceivedEvent {
55
63
  event: string;
56
64
  email: {
57
- headers: { from?: string; subject?: string };
65
+ headers: { from?: string; to?: string; subject?: string };
58
66
  };
59
67
  }
60
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
+
61
108
  export default {
62
109
  async fetch(
63
110
  req: Request,
@@ -74,6 +121,14 @@ export default {
74
121
  return Response.json({ ok: true, skipped: event.event });
75
122
  }
76
123
 
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" });
130
+ }
131
+
77
132
  const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });
78
133
 
79
134
  // Recipient gate
@@ -84,9 +139,14 @@ export default {
84
139
  // external addresses return 403 recipient_not_allowed with a
85
140
  // structured gates[] array until the recipient has authenticated
86
141
  // to you or support has enabled the gate.
142
+
143
+ // Recipient routing: a single function can handle multiple inbound
144
+ // addresses by branching on event.email.headers.to. For example,
145
+ // route "support@" to a ticketing flow and "sales@" to a lead
146
+ // capture flow before calling client.send.
87
147
  const reply = await client.send({
88
- from: "you@your-domain.primitive.email",
89
- to: event.email.headers.from ?? "you@your-domain.primitive.email",
148
+ from: REPLY_FROM,
149
+ to: event.email.headers.from ?? REPLY_FROM,
90
150
  subject: \`Re: \${event.email.headers.subject ?? ""}\`,
91
151
  bodyText: "Got your message.",
92
152
  });
@@ -1,26 +1,108 @@
1
1
  import { Command, Flags } from "@oclif/core";
2
- import { PrimitiveApiClient, updateFunction } from "@primitivedotdev/sdk/api";
2
+ import { PrimitiveApiClient, setFunctionSecret, updateFunction, } from "@primitivedotdev/sdk/api";
3
3
  import { extractErrorPayload, readTextFileFlag, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
4
4
  import { resolveCliAuth } from "../auth.js";
5
5
  import { emitRawSendMailFetchWarning } from "../lint/raw-send-mail-fetch.js";
6
- // `primitive functions:redeploy` is the agent-grade shortcut for
7
- // `functions:update-function`. Same file-reading ergonomic as
8
- // functions:deploy but for an existing function. Use this to push a
9
- // new bundle, OR to refresh secret bindings: passing the
10
- // previously-deployed bundle (or any equivalent file) re-runs the
11
- // deploy and refreshes env from the secrets table, which is how
12
- // secret writes go live.
6
+ import { parseSecretFlags, SECRET_FLAG_SECURITY_NOTE, } from "../secret-flags.js";
7
+ // Pure-ish orchestration of (optional secrets +) update-function.
8
+ // Writes every secret first, then re-deploys with the new bundle so
9
+ // a single updateFunction call refreshes every binding the user
10
+ // wrote. Pulled out as a named export so the unit test can drive
11
+ // every branch with a fake RedeployApiSurface, without spinning up
12
+ // a real client or the oclif command lifecycle.
13
+ export async function runRedeployWithSecrets(api, params) {
14
+ const writtenSecrets = [];
15
+ const succeededKeys = [];
16
+ for (let i = 0; i < params.secrets.length; i++) {
17
+ const pair = params.secrets[i];
18
+ // Pre-compute the keys that come AFTER the current pair so a
19
+ // set-secret failure can surface every key that was never
20
+ // attempted, not just the one that failed.
21
+ const pendingKeys = params.secrets.slice(i + 1).map((p) => p.key);
22
+ const setResult = await api.setSecret({
23
+ id: params.id,
24
+ key: pair.key,
25
+ value: pair.value,
26
+ });
27
+ if (setResult.error) {
28
+ return {
29
+ failedKey: pair.key,
30
+ kind: "error",
31
+ payload: extractErrorPayload(setResult.error),
32
+ pendingKeys,
33
+ stage: "set-secret",
34
+ succeededKeys,
35
+ };
36
+ }
37
+ const secret = setResult.data?.data;
38
+ if (!secret) {
39
+ return {
40
+ failedKey: pair.key,
41
+ kind: "error",
42
+ payload: {
43
+ code: "client_error",
44
+ message: "Secret write returned no data",
45
+ },
46
+ pendingKeys,
47
+ stage: "set-secret",
48
+ succeededKeys,
49
+ };
50
+ }
51
+ writtenSecrets.push(secret);
52
+ succeededKeys.push(pair.key);
53
+ }
54
+ const updateResult = await api.updateFunction({
55
+ code: params.code,
56
+ id: params.id,
57
+ ...(params.sourceMap !== undefined ? { sourceMap: params.sourceMap } : {}),
58
+ });
59
+ if (updateResult.error) {
60
+ return {
61
+ kind: "error",
62
+ payload: extractErrorPayload(updateResult.error),
63
+ stage: "redeploy",
64
+ succeededKeys,
65
+ };
66
+ }
67
+ const redeployed = updateResult.data?.data;
68
+ if (!redeployed) {
69
+ return {
70
+ kind: "error",
71
+ payload: {
72
+ code: "client_error",
73
+ message: "Redeploy returned no data",
74
+ },
75
+ stage: "redeploy",
76
+ succeededKeys,
77
+ };
78
+ }
79
+ return {
80
+ kind: "ok",
81
+ result: {
82
+ redeploy: redeployed,
83
+ ...(writtenSecrets.length > 0 ? { secrets: writtenSecrets } : {}),
84
+ },
85
+ };
86
+ }
13
87
  class FunctionsRedeployCommand extends Command {
14
88
  static description = `Update or redeploy a function from a bundled handler file. Agent-grade shortcut for functions:update-function.
15
89
 
16
90
  Use to push a new bundle OR to refresh secret bindings into the
17
91
  running handler. The same file is fine for both: the deploy reads
18
92
  the bindings table fresh on every call, so passing the existing
19
- bundle picks up any secret writes since the last deploy.`;
93
+ bundle picks up any secret writes since the last deploy.
94
+
95
+ Pass --secret KEY=VALUE (repeatable) to write secrets BEFORE the
96
+ redeploy fires; one update-function call then refreshes every new
97
+ binding. Keys must match \`^[A-Z_][A-Z0-9_]*$\` (uppercase letters,
98
+ digits, underscores; first character is a letter or underscore).
99
+ With one or more --secret flags the redeploy fans out to multiple
100
+ API calls (set-secret per pair, then update-function).`;
20
101
  static summary = "Redeploy a function from a bundled handler file";
21
102
  static examples = [
22
103
  "<%= config.bin %> functions:redeploy --id <fn-id> --file ./bundle.js",
23
104
  "<%= config.bin %> functions:redeploy --id <fn-id> --file ./bundle.js --source-map-file ./bundle.js.map",
105
+ "<%= config.bin %> functions:redeploy --id <fn-id> --file ./bundle.js --secret OPENAI_KEY=sk-... --secret OWNER_EMAIL=me@example.com",
24
106
  ];
25
107
  static flags = {
26
108
  "api-key": Flags.string({
@@ -48,6 +130,10 @@ class FunctionsRedeployCommand extends Command {
48
130
  "source-map-file": Flags.string({
49
131
  description: "Optional path to a source map for the bundle. Used to symbolicate stack traces in the function's logs.",
50
132
  }),
133
+ secret: Flags.string({
134
+ description: `Secret KEY=VALUE to write on the function before the redeploy fires. Repeatable. KEY must match \`^[A-Z_][A-Z0-9_]*$\`; VALUE may contain \`=\` (only the first \`=\` is treated as a delimiter). Each KEY may only appear once per command. Passing one or more --secret flags fans out to set-secret per pair then a single update-function call so the new bindings land in the same redeploy. ${SECRET_FLAG_SECURITY_NOTE}`,
135
+ multiple: true,
136
+ }),
51
137
  time: Flags.boolean({
52
138
  description: TIME_FLAG_DESCRIPTION,
53
139
  }),
@@ -55,6 +141,17 @@ class FunctionsRedeployCommand extends Command {
55
141
  async run() {
56
142
  const { flags } = await this.parse(FunctionsRedeployCommand);
57
143
  await runWithTiming(flags.time, async () => {
144
+ // Validate --secret pairs BEFORE any disk read or API call so
145
+ // a malformed input fails fast with a clear error and zero
146
+ // side effects. The fast path (no --secret flags) skips the
147
+ // secret-write loop entirely.
148
+ const rawSecrets = flags.secret ?? [];
149
+ const parsedSecrets = parseSecretFlags(rawSecrets);
150
+ if (parsedSecrets.kind === "error") {
151
+ process.stderr.write(`${parsedSecrets.message}\n`);
152
+ process.exitCode = 1;
153
+ return;
154
+ }
58
155
  // Reads inside the timed block: --time captures disk I/O too,
59
156
  // which is the latency the flag is meant to surface.
60
157
  const code = readTextFileFlag(flags.file, "--file");
@@ -85,27 +182,58 @@ class FunctionsRedeployCommand extends Command {
85
182
  baseUrlOverridden,
86
183
  configDir: this.config.configDir,
87
184
  };
88
- const result = await updateFunction({
89
- path: { id: flags.id },
90
- body: {
91
- code,
92
- ...(sourceMap !== undefined ? { sourceMap } : {}),
93
- },
94
- client: apiClient.client,
95
- responseStyle: "fields",
185
+ // Adapter: thin wrappers around the generated SDK calls,
186
+ // routed through host 1 (apiClient.client). The function
187
+ // CRUD and secrets endpoints are not on host 2.
188
+ const apiSurface = {
189
+ setSecret: (p) => setFunctionSecret({
190
+ body: { value: p.value },
191
+ client: apiClient.client,
192
+ path: { id: p.id, key: p.key },
193
+ responseStyle: "fields",
194
+ }),
195
+ updateFunction: (p) => updateFunction({
196
+ body: {
197
+ code: p.code,
198
+ ...(p.sourceMap !== undefined ? { sourceMap: p.sourceMap } : {}),
199
+ },
200
+ client: apiClient.client,
201
+ path: { id: p.id },
202
+ responseStyle: "fields",
203
+ }),
204
+ };
205
+ const outcome = await runRedeployWithSecrets(apiSurface, {
206
+ code,
207
+ id: flags.id,
208
+ secrets: parsedSecrets.secrets,
209
+ ...(sourceMap !== undefined ? { sourceMap } : {}),
96
210
  });
97
- if (result.error) {
98
- const errorPayload = extractErrorPayload(result.error);
99
- writeErrorWithHints(errorPayload);
211
+ if (outcome.kind === "error") {
212
+ if (outcome.stage === "set-secret") {
213
+ const succeeded = outcome.succeededKeys.length > 0
214
+ ? outcome.succeededKeys.join(", ")
215
+ : "(none)";
216
+ const pending = outcome.pendingKeys.length > 0
217
+ ? outcome.pendingKeys.join(", ")
218
+ : "(none)";
219
+ const allMissing = [outcome.failedKey, ...outcome.pendingKeys].join(", ");
220
+ process.stderr.write(`Writing secret ${outcome.failedKey} failed before the redeploy; succeeded keys so far: ${succeeded}; keys not yet attempted: ${pending}. The new bundle has NOT been deployed. Re-run \`primitive functions:set-secret\` for each of [${allMissing}], then \`primitive functions:redeploy --id ${flags.id} --file <bundle>\` to push them live.\n`);
221
+ }
222
+ else if (outcome.stage === "redeploy") {
223
+ const succeeded = outcome.succeededKeys.length > 0
224
+ ? outcome.succeededKeys.join(", ")
225
+ : "(none)";
226
+ process.stderr.write(`Secrets [${succeeded}] were written, but the redeploy step failed; the new bindings are NOT yet live. Re-run \`primitive functions:redeploy --id ${flags.id} --file <bundle>\` once the cause is fixed.\n`);
227
+ }
228
+ writeErrorWithHints(outcome.payload);
100
229
  removeStaleSavedCredentialOnUnauthorized({
101
230
  ...authFailureContext,
102
- payload: errorPayload,
231
+ payload: outcome.payload,
103
232
  });
104
233
  process.exitCode = 1;
105
234
  return;
106
235
  }
107
- const envelope = result.data;
108
- this.log(JSON.stringify(envelope?.data ?? null, null, 2));
236
+ this.log(JSON.stringify(outcome.result.redeploy, null, 2));
109
237
  });
110
238
  }
111
239
  }
@@ -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
+ }
@@ -0,0 +1,59 @@
1
+ // Shared parsing for the `--secret KEY=VALUE` flag used by both
2
+ // functions:deploy and functions:redeploy. Lives in its own module so
3
+ // neither command implicitly depends on the other's file path.
4
+ // Server-side constraint on secret keys. Mirrored client-side so
5
+ // malformed input is rejected before any side-effecting API call.
6
+ export const SECRET_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
7
+ // Split each `--secret KEY=VALUE` on the FIRST `=`. KEY must match
8
+ // `^[A-Z_][A-Z0-9_]*$`; VALUE may contain `=` (only the first one
9
+ // is treated as a delimiter). Duplicate KEYs are rejected: silently
10
+ // accepting two pairs with the same key would fan out to two
11
+ // setFunctionSecret writes where only the second wins, which is
12
+ // almost always a typo and never the intent.
13
+ export function parseSecretFlags(raw) {
14
+ const secrets = [];
15
+ const seenKeys = new Set();
16
+ for (const entry of raw) {
17
+ const eq = entry.indexOf("=");
18
+ if (eq === -1) {
19
+ return {
20
+ kind: "error",
21
+ message: `--secret expects KEY=VALUE (got ${JSON.stringify(entry)}). Example: --secret API_TOKEN=abc123`,
22
+ };
23
+ }
24
+ const key = entry.slice(0, eq);
25
+ const value = entry.slice(eq + 1);
26
+ if (key.length === 0) {
27
+ return {
28
+ kind: "error",
29
+ message: `--secret is missing a KEY before '=' (got ${JSON.stringify(entry)}). Example: --secret API_TOKEN=abc123`,
30
+ };
31
+ }
32
+ if (!SECRET_KEY_RE.test(key)) {
33
+ return {
34
+ kind: "error",
35
+ message: `--secret KEY ${JSON.stringify(key)} does not match ${SECRET_KEY_RE.source} (uppercase letters, digits, underscores; first character is a letter or underscore).`,
36
+ };
37
+ }
38
+ if (seenKeys.has(key)) {
39
+ return {
40
+ kind: "error",
41
+ message: `--secret KEY ${JSON.stringify(key)} was passed more than once. Each key may only appear once per command.`,
42
+ };
43
+ }
44
+ seenKeys.add(key);
45
+ secrets.push({ key, value });
46
+ }
47
+ return { kind: "ok", secrets };
48
+ }
49
+ // Shared flag-description copy so both functions:deploy and
50
+ // functions:redeploy advertise the same security caveat and KEY
51
+ // constraints. The shell-history note is the load-bearing piece:
52
+ // CLI flag values land in ~/.bash_history, `ps aux`, and
53
+ // /proc/[pid]/cmdline, so callers handling sensitive values
54
+ // should set them via a shell variable (ideally read via `read -s`
55
+ // or piped from a secrets manager) and reference the variable on
56
+ // the command line. The variable still appears in `ps`-visible
57
+ // argv, but at least the literal value does not get archived in
58
+ // the user's shell history.
59
+ export const SECRET_FLAG_SECURITY_NOTE = 'Note: values passed on the command line are visible in shell history (e.g. ~/.bash_history) and to other users via `ps aux` / /proc/[pid]/cmdline. For sensitive values prefer `--secret KEY="$VAR"` where `$VAR` is set out-of-band (read -s, a secrets manager, etc.).';
@@ -791,10 +791,11 @@
791
791
  "functions:deploy": {
792
792
  "aliases": [],
793
793
  "args": {},
794
- "description": "Deploy a new function from a bundled handler file. Agent-grade shortcut for functions:create-function.\n\n Reads the bundle off disk (--file) instead of forcing the caller to\n serialize the source into a JSON body. Use the underlying operation\n `functions:create-function` if you need the full flag surface\n (raw-body JSON, etc.).",
794
+ "description": "Deploy a new function from a bundled handler file. Agent-grade shortcut for functions:create-function.\n\n Reads the bundle off disk (--file) instead of forcing the caller to\n serialize the source into a JSON body. Use the underlying operation\n `functions:create-function` if you need the full flag surface\n (raw-body JSON, etc.).\n\n Pass --secret KEY=VALUE (repeatable) to seed secret bindings in the\n same command. Keys must match `^[A-Z_][A-Z0-9_]*$` (uppercase\n letters, digits, underscores; first character is a letter or\n underscore). With one or more --secret flags the deploy fans out to\n multiple API calls: create-function, set-secret per pair, then a\n final update-function with the same bundle so the running handler\n picks up the bindings. If a secret write fails after the create\n step the function exists with whatever secrets succeeded and the\n redeploy has NOT fired; re-run `primitive functions:set-secret`\n for the missing keys, then `primitive functions:redeploy` to\n push them live.",
795
795
  "examples": [
796
796
  "<%= config.bin %> functions:deploy --name forwarder --file ./bundle.js",
797
- "<%= config.bin %> functions:deploy --name forwarder --file ./bundle.js --source-map-file ./bundle.js.map"
797
+ "<%= config.bin %> functions:deploy --name forwarder --file ./bundle.js --source-map-file ./bundle.js.map",
798
+ "<%= config.bin %> functions:deploy --name forwarder --file ./bundle.js --secret OPENAI_KEY=sk-... --secret OWNER_EMAIL=me@example.com"
798
799
  ],
799
800
  "flags": {
800
801
  "api-key": {
@@ -846,6 +847,13 @@
846
847
  "multiple": false,
847
848
  "type": "option"
848
849
  },
850
+ "secret": {
851
+ "description": "Secret KEY=VALUE to seed on the deployed function. Repeatable. KEY must match `^[A-Z_][A-Z0-9_]*$`; VALUE may contain `=` (only the first `=` is treated as a delimiter). Each KEY may only appear once per command. Passing one or more --secret flags fans out the deploy to create-function, set-secret per pair, then a final redeploy so the running handler picks up the bindings. Note: values passed on the command line are visible in shell history (e.g. ~/.bash_history) and to other users via `ps aux` / /proc/[pid]/cmdline. For sensitive values prefer `--secret KEY=\"$VAR\"` where `$VAR` is set out-of-band (read -s, a secrets manager, etc.).",
852
+ "name": "secret",
853
+ "hasDynamicHelp": false,
854
+ "multiple": true,
855
+ "type": "option"
856
+ },
849
857
  "time": {
850
858
  "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.",
851
859
  "name": "time",
@@ -866,10 +874,11 @@
866
874
  "functions:redeploy": {
867
875
  "aliases": [],
868
876
  "args": {},
869
- "description": "Update or redeploy a function from a bundled handler file. Agent-grade shortcut for functions:update-function.\n\n Use to push a new bundle OR to refresh secret bindings into the\n running handler. The same file is fine for both: the deploy reads\n the bindings table fresh on every call, so passing the existing\n bundle picks up any secret writes since the last deploy.",
877
+ "description": "Update or redeploy a function from a bundled handler file. Agent-grade shortcut for functions:update-function.\n\n Use to push a new bundle OR to refresh secret bindings into the\n running handler. The same file is fine for both: the deploy reads\n the bindings table fresh on every call, so passing the existing\n bundle picks up any secret writes since the last deploy.\n\n Pass --secret KEY=VALUE (repeatable) to write secrets BEFORE the\n redeploy fires; one update-function call then refreshes every new\n binding. Keys must match `^[A-Z_][A-Z0-9_]*$` (uppercase letters,\n digits, underscores; first character is a letter or underscore).\n With one or more --secret flags the redeploy fans out to multiple\n API calls (set-secret per pair, then update-function).",
870
878
  "examples": [
871
879
  "<%= config.bin %> functions:redeploy --id <fn-id> --file ./bundle.js",
872
- "<%= config.bin %> functions:redeploy --id <fn-id> --file ./bundle.js --source-map-file ./bundle.js.map"
880
+ "<%= config.bin %> functions:redeploy --id <fn-id> --file ./bundle.js --source-map-file ./bundle.js.map",
881
+ "<%= config.bin %> functions:redeploy --id <fn-id> --file ./bundle.js --secret OPENAI_KEY=sk-... --secret OWNER_EMAIL=me@example.com"
873
882
  ],
874
883
  "flags": {
875
884
  "api-key": {
@@ -921,6 +930,13 @@
921
930
  "multiple": false,
922
931
  "type": "option"
923
932
  },
933
+ "secret": {
934
+ "description": "Secret KEY=VALUE to write on the function before the redeploy fires. Repeatable. KEY must match `^[A-Z_][A-Z0-9_]*$`; VALUE may contain `=` (only the first `=` is treated as a delimiter). Each KEY may only appear once per command. Passing one or more --secret flags fans out to set-secret per pair then a single update-function call so the new bindings land in the same redeploy. Note: values passed on the command line are visible in shell history (e.g. ~/.bash_history) and to other users via `ps aux` / /proc/[pid]/cmdline. For sensitive values prefer `--secret KEY=\"$VAR\"` where `$VAR` is set out-of-band (read -s, a secrets manager, etc.).",
935
+ "name": "secret",
936
+ "hasDynamicHelp": false,
937
+ "multiple": true,
938
+ "type": "option"
939
+ },
924
940
  "time": {
925
941
  "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.",
926
942
  "name": "time",
@@ -4350,5 +4366,5 @@
4350
4366
  "enableJsonFlag": false
4351
4367
  }
4352
4368
  },
4353
- "version": "0.25.1"
4369
+ "version": "0.26.0"
4354
4370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/cli",
3
- "version": "0.25.1",
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,