@primitivedotdev/cli 0.25.0 → 0.25.2

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.
@@ -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
  }
@@ -18,7 +18,18 @@ import { Args, Command, Errors, Flags } from "@oclif/core";
18
18
  // the CLI's own @primitivedotdev/sdk dep range in cli-node/package.json
19
19
  // so scaffolded projects use the same SDK version the CLI was built
20
20
  // and tested against.
21
- const SDK_VERSION_RANGE = "^0.23.0";
21
+ const SDK_VERSION_RANGE = "^0.25.0";
22
+ // The CLI version range that ships in the scaffolded devDependencies.
23
+ // Pinned separately from SDK_VERSION_RANGE because @primitivedotdev/cli
24
+ // and @primitivedotdev/sdk are independent packages on independent
25
+ // release cadences. Coupling them silently breaks `npm install` in
26
+ // every scaffolded project the day we bump one without publishing the
27
+ // other. Must include this CLI's own version: a `primitive
28
+ // functions:init` run from CLI v1.2.3 should scaffold a project that
29
+ // resolves at least v1.2.3, so the user does not silently downgrade
30
+ // the bin under themselves. The lockstep test in functions-init.test.ts
31
+ // enforces that invariant.
32
+ const CLI_VERSION_RANGE = "^0.25.0";
22
33
  // esbuild version range. Pinned to the latest stable major used
23
34
  // elsewhere in the Primitive codebase for bundling Workers-style
24
35
  // handlers. Caret range so patch fixes flow in automatically.
@@ -40,10 +51,16 @@ export function renderHandler() {
40
51
  return `// env.PRIMITIVE_API_KEY is auto-injected by the Primitive Functions runtime.
41
52
  import { createPrimitiveClient } from "@primitivedotdev/sdk/api";
42
53
 
54
+ // TODO: replace with your verified sender address. Must be a domain
55
+ // you own or your managed *.primitive.email subdomain. The
56
+ // self-reply guard below compares incoming mail against this value
57
+ // to avoid the handler replying to its own outbound traffic.
58
+ const REPLY_FROM = "you@your-domain.primitive.email";
59
+
43
60
  interface EmailReceivedEvent {
44
61
  event: string;
45
62
  email: {
46
- headers: { from?: string; subject?: string };
63
+ headers: { from?: string; to?: string; subject?: string };
47
64
  };
48
65
  }
49
66
 
@@ -63,6 +80,20 @@ export default {
63
80
  return Response.json({ ok: true, skipped: event.event });
64
81
  }
65
82
 
83
+ // Self-reply guard: do not act on mail that this function itself
84
+ // sent. Without this, an outbound reply that gets forwarded back
85
+ // into the inbox would trigger another reply, and so on.
86
+ //
87
+ // event.email.headers.from is the raw RFC 2822 header value, so
88
+ // it may be either a bare address ("alice@example.com") or a
89
+ // display-name form ("Alice <alice@example.com>"). A substring
90
+ // check matches both. Tighten this predicate (e.g. parse the
91
+ // bracketed address) if you legitimately want to act on mail
92
+ // from your own domain.
93
+ if (event.email.headers.from?.includes(REPLY_FROM)) {
94
+ return Response.json({ ok: true, skipped: "self-reply" });
95
+ }
96
+
66
97
  const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });
67
98
 
68
99
  // Recipient gate
@@ -73,9 +104,14 @@ export default {
73
104
  // external addresses return 403 recipient_not_allowed with a
74
105
  // structured gates[] array until the recipient has authenticated
75
106
  // to you or support has enabled the gate.
107
+
108
+ // Recipient routing: a single function can handle multiple inbound
109
+ // addresses by branching on event.email.headers.to. For example,
110
+ // route "support@" to a ticketing flow and "sales@" to a lead
111
+ // capture flow before calling client.send.
76
112
  const reply = await client.send({
77
- from: "you@your-domain.primitive.email",
78
- to: event.email.headers.from ?? "you@your-domain.primitive.email",
113
+ from: REPLY_FROM,
114
+ to: event.email.headers.from ?? REPLY_FROM,
79
115
  subject: \`Re: \${event.email.headers.subject ?? ""}\`,
80
116
  bodyText: "Got your message.",
81
117
  });
@@ -111,6 +147,15 @@ export function renderPackageJson(name) {
111
147
  "@primitivedotdev/sdk": SDK_VERSION_RANGE,
112
148
  },
113
149
  devDependencies: {
150
+ // @primitivedotdev/cli ships the primitive bin. Including it as
151
+ // a devDep here means `node_modules/.bin/primitive` resolves to
152
+ // the real CLI inside the scaffolded project; otherwise the
153
+ // bin falls through to @primitivedotdev/sdk's deprecated CLI
154
+ // alias and every `npm run deploy` invocation prints the
155
+ // "CLI moved" stderr banner. Pinned via CLI_VERSION_RANGE, a
156
+ // dedicated constant so the version is decoupled from the SDK
157
+ // range and bumps are explicit on both ends.
158
+ "@primitivedotdev/cli": CLI_VERSION_RANGE,
114
159
  esbuild: ESBUILD_VERSION_RANGE,
115
160
  typescript: "^5.7.2",
116
161
  },
@@ -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,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",
@@ -3611,7 +3627,7 @@
3611
3627
  "functions:test-function": {
3612
3628
  "aliases": [],
3613
3629
  "args": {},
3614
- "description": "Sends a real test email from a Primitive-controlled sender to a\nsynthetic local-part on one of the org's verified inbound\ndomains. 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.\n",
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",
3615
3631
  "flags": {
3616
3632
  "api-key": {
3617
3633
  "description": "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
@@ -3652,6 +3668,27 @@
3652
3668
  "hasDynamicHelp": false,
3653
3669
  "multiple": false,
3654
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"
3655
3692
  }
3656
3693
  },
3657
3694
  "hasDynamicHelp": false,
@@ -4329,5 +4366,5 @@
4329
4366
  "enableJsonFlag": false
4330
4367
  }
4331
4368
  },
4332
- "version": "0.25.0"
4369
+ "version": "0.25.2"
4333
4370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/cli",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
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,
@@ -92,7 +92,7 @@
92
92
  "@oclif/core": "^4.10.5",
93
93
  "@oclif/plugin-autocomplete": "^3.2.45",
94
94
  "@oclif/plugin-help": "^6.2.44",
95
- "@primitivedotdev/sdk": "^0.23.0"
95
+ "@primitivedotdev/sdk": "^0.25.0"
96
96
  },
97
97
  "devDependencies": {
98
98
  "@biomejs/biome": "^2.4.10",