@primitivedotdev/cli 0.26.2 → 0.26.3
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/dist/oclif/index.js +12554 -178
- package/dist/oclif/proxy-auto-detect.js +38 -57
- package/package.json +7 -9
- package/dist/oclif/api-command.js +0 -799
- package/dist/oclif/auth.js +0 -223
- package/dist/oclif/commands/doctor.js +0 -361
- package/dist/oclif/commands/emails-latest.js +0 -184
- package/dist/oclif/commands/emails-poll.js +0 -121
- package/dist/oclif/commands/emails-wait.js +0 -171
- package/dist/oclif/commands/emails-watch.js +0 -165
- package/dist/oclif/commands/functions-deploy.js +0 -302
- package/dist/oclif/commands/functions-init.js +0 -374
- package/dist/oclif/commands/functions-redeploy.js +0 -240
- package/dist/oclif/commands/functions-set-secret.js +0 -212
- package/dist/oclif/commands/functions-test-function.js +0 -238
- package/dist/oclif/commands/login.js +0 -236
- package/dist/oclif/commands/logout.js +0 -87
- package/dist/oclif/commands/send.js +0 -221
- package/dist/oclif/commands/whoami.js +0 -94
- package/dist/oclif/endpoints-test-redirect.js +0 -94
- package/dist/oclif/fish-completion.js +0 -87
- package/dist/oclif/lint/raw-send-mail-fetch.js +0 -98
- package/dist/oclif/secret-flags.js +0 -59
- package/oclif.manifest.json +0 -4462
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import { Command, Flags } from "@oclif/core";
|
|
2
|
-
import { createFunction, PrimitiveApiClient, setFunctionSecret, updateFunction, } from "@primitivedotdev/sdk/api";
|
|
3
|
-
import { extractErrorPayload, readTextFileFlag, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
|
|
4
|
-
import { resolveCliAuth } from "../auth.js";
|
|
5
|
-
import { emitRawSendMailFetchWarning } from "../lint/raw-send-mail-fetch.js";
|
|
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
|
-
}
|
|
121
|
-
class FunctionsDeployCommand extends Command {
|
|
122
|
-
static description = `Deploy a new function from a bundled handler file. Agent-grade shortcut for functions:create-function.
|
|
123
|
-
|
|
124
|
-
Reads the bundle off disk (--file) instead of forcing the caller to
|
|
125
|
-
serialize the source into a JSON body. Use the underlying operation
|
|
126
|
-
\`functions:create-function\` if you need the full flag surface
|
|
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.`;
|
|
140
|
-
static summary = "Deploy a new function from a bundled handler file";
|
|
141
|
-
static examples = [
|
|
142
|
-
"<%= config.bin %> functions:deploy --name forwarder --file ./bundle.js",
|
|
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",
|
|
145
|
-
];
|
|
146
|
-
static flags = {
|
|
147
|
-
"api-key": Flags.string({
|
|
148
|
-
description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
|
|
149
|
-
env: "PRIMITIVE_API_KEY",
|
|
150
|
-
}),
|
|
151
|
-
"api-base-url-1": Flags.string({
|
|
152
|
-
description: "Override the primary API base URL. Internal testing only; not documented to customers.",
|
|
153
|
-
env: "PRIMITIVE_API_BASE_URL_1",
|
|
154
|
-
hidden: true,
|
|
155
|
-
}),
|
|
156
|
-
"api-base-url-2": Flags.string({
|
|
157
|
-
description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
|
|
158
|
-
env: "PRIMITIVE_API_BASE_URL_2",
|
|
159
|
-
hidden: true,
|
|
160
|
-
}),
|
|
161
|
-
name: Flags.string({
|
|
162
|
-
description: "Slug-style name. Lowercase letters, digits, hyphens, underscores. 1-64 chars. Must be unique within the org.",
|
|
163
|
-
required: true,
|
|
164
|
-
}),
|
|
165
|
-
file: Flags.string({
|
|
166
|
-
description: "Path to the bundled ESM handler file (single self-contained module). Loaded as the `code` body field.",
|
|
167
|
-
required: true,
|
|
168
|
-
}),
|
|
169
|
-
"source-map-file": Flags.string({
|
|
170
|
-
description: "Optional path to a source map for the bundle. Stored only on the runtime side and used to symbolicate stack traces.",
|
|
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
|
-
}),
|
|
176
|
-
time: Flags.boolean({
|
|
177
|
-
description: TIME_FLAG_DESCRIPTION,
|
|
178
|
-
}),
|
|
179
|
-
};
|
|
180
|
-
async run() {
|
|
181
|
-
const { flags } = await this.parse(FunctionsDeployCommand);
|
|
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
|
-
}
|
|
194
|
-
// Reads are inside the timed block so --time captures disk I/O
|
|
195
|
-
// alongside the API call. A pathological filesystem (NFS, slow
|
|
196
|
-
// FUSE mount) showing up here is exactly the kind of latency
|
|
197
|
-
// surprise --time is meant to surface.
|
|
198
|
-
const code = readTextFileFlag(flags.file, "--file");
|
|
199
|
-
const sourceMap = flags["source-map-file"]
|
|
200
|
-
? readTextFileFlag(flags["source-map-file"], "--source-map-file")
|
|
201
|
-
: undefined;
|
|
202
|
-
// Non-blocking deploy-time lint: if the bundle has a raw
|
|
203
|
-
// fetch(...) call against /send-mail, nudge the author toward
|
|
204
|
-
// `createPrimitiveClient` from `@primitivedotdev/sdk/api`.
|
|
205
|
-
// The warning lands on stderr so it never contaminates the
|
|
206
|
-
// JSON stdout the caller may pipe into jq.
|
|
207
|
-
emitRawSendMailFetchWarning(code, (chunk) => process.stderr.write(chunk));
|
|
208
|
-
const baseUrlOverridden = flags["api-base-url-1"] !== undefined ||
|
|
209
|
-
flags["api-base-url-2"] !== undefined;
|
|
210
|
-
const auth = resolveCliAuth({
|
|
211
|
-
apiKey: flags["api-key"],
|
|
212
|
-
apiBaseUrl1: flags["api-base-url-1"],
|
|
213
|
-
apiBaseUrl2: flags["api-base-url-2"],
|
|
214
|
-
configDir: this.config.configDir,
|
|
215
|
-
});
|
|
216
|
-
const apiClient = new PrimitiveApiClient({
|
|
217
|
-
apiKey: auth.apiKey,
|
|
218
|
-
apiBaseUrl1: auth.apiBaseUrl1,
|
|
219
|
-
apiBaseUrl2: auth.apiBaseUrl2,
|
|
220
|
-
});
|
|
221
|
-
const authFailureContext = {
|
|
222
|
-
auth,
|
|
223
|
-
baseUrlOverridden,
|
|
224
|
-
configDir: this.config.configDir,
|
|
225
|
-
};
|
|
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 } : {}),
|
|
260
|
-
});
|
|
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);
|
|
285
|
-
removeStaleSavedCredentialOnUnauthorized({
|
|
286
|
-
...authFailureContext,
|
|
287
|
-
payload: outcome.payload,
|
|
288
|
-
});
|
|
289
|
-
process.exitCode = 1;
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
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));
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
export default FunctionsDeployCommand;
|
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, resolve } from "node:path";
|
|
3
|
-
import { Args, Command, Errors, Flags } from "@oclif/core";
|
|
4
|
-
// `primitive functions:init <name>` stamps a deployable Function project
|
|
5
|
-
// into ./<name>/ so a new author can go from zero to a deployed handler
|
|
6
|
-
// in two commands: `npm install && npm run build` then
|
|
7
|
-
// `primitive functions:deploy --name <name> --file ./dist/handler.js`.
|
|
8
|
-
//
|
|
9
|
-
// The scaffolded handler imports `createPrimitiveClient` from
|
|
10
|
-
// `@primitivedotdev/sdk/api`, NOT from the package root. The root export
|
|
11
|
-
// pulls in webhook helpers that depend on `node:crypto`, which breaks
|
|
12
|
-
// Workers-style bundles. The `/api` subpath is the runtime-client
|
|
13
|
-
// surface and is the documented import for in-handler use.
|
|
14
|
-
// The SDK version range that ships in the scaffolded package.json's
|
|
15
|
-
// dependencies. Pinned to the current shipped minor with a caret so
|
|
16
|
-
// patch releases of the SDK pick up automatically. Update alongside
|
|
17
|
-
// any minor or major version bump of the SDK; keep in lockstep with
|
|
18
|
-
// the CLI's own @primitivedotdev/sdk dep range in cli-node/package.json
|
|
19
|
-
// so scaffolded projects use the same SDK version the CLI was built
|
|
20
|
-
// and tested against.
|
|
21
|
-
const SDK_VERSION_RANGE = "^0.26.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.26.0";
|
|
33
|
-
// esbuild version range. Pinned to the latest stable major used
|
|
34
|
-
// elsewhere in the Primitive codebase for bundling Workers-style
|
|
35
|
-
// handlers. Caret range so patch fixes flow in automatically.
|
|
36
|
-
const ESBUILD_VERSION_RANGE = "^0.27.0";
|
|
37
|
-
// Validate a directory name passed as the positional argument.
|
|
38
|
-
// Matches a conservative slug shape: lowercase letters, digits,
|
|
39
|
-
// hyphens, underscores. Rejecting weirder names up front prevents
|
|
40
|
-
// surprises when the same string lands in package.json's `name`
|
|
41
|
-
// field (which has its own validation rules) or in shell scripts.
|
|
42
|
-
const VALID_NAME = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
43
|
-
export function isValidFunctionName(name) {
|
|
44
|
-
return VALID_NAME.test(name);
|
|
45
|
-
}
|
|
46
|
-
// File contents for the scaffolded project. Each renderer takes the
|
|
47
|
-
// function name and returns the raw file body. Kept as named exports
|
|
48
|
-
// so the unit test can assert content without having to spin up the
|
|
49
|
-
// oclif command lifecycle.
|
|
50
|
-
export function renderHandler() {
|
|
51
|
-
return `// env.PRIMITIVE_API_KEY is auto-injected by the Primitive Functions runtime.
|
|
52
|
-
import { createPrimitiveClient } from "@primitivedotdev/sdk/api";
|
|
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
|
-
|
|
62
|
-
interface EmailReceivedEvent {
|
|
63
|
-
event: string;
|
|
64
|
-
email: {
|
|
65
|
-
headers: { from?: string; to?: string; subject?: string };
|
|
66
|
-
};
|
|
67
|
-
}
|
|
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
|
-
|
|
108
|
-
export default {
|
|
109
|
-
async fetch(
|
|
110
|
-
req: Request,
|
|
111
|
-
env: { PRIMITIVE_API_KEY: string },
|
|
112
|
-
): Promise<Response> {
|
|
113
|
-
try {
|
|
114
|
-
const event = (await req.json()) as EmailReceivedEvent;
|
|
115
|
-
|
|
116
|
-
// Only "email.received" exists today. Future event types will
|
|
117
|
-
// arrive with a different discriminator; return 2xx so the
|
|
118
|
-
// delivery loop does not burn its retry budget on payloads you
|
|
119
|
-
// intentionally skipped.
|
|
120
|
-
if (event.event !== "email.received") {
|
|
121
|
-
return Response.json({ ok: true, skipped: event.event });
|
|
122
|
-
}
|
|
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
|
-
|
|
132
|
-
const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });
|
|
133
|
-
|
|
134
|
-
// Recipient gate
|
|
135
|
-
// https://www.primitive.dev/docs/sending#who-you-can-send-to
|
|
136
|
-
// New accounts can send to *.primitive.email addresses,
|
|
137
|
-
// verified domains, addresses that have authenticated to you,
|
|
138
|
-
// and other org-member signup emails. Sends to arbitrary
|
|
139
|
-
// external addresses return 403 recipient_not_allowed with a
|
|
140
|
-
// structured gates[] array until the recipient has authenticated
|
|
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.
|
|
147
|
-
const reply = await client.send({
|
|
148
|
-
from: REPLY_FROM,
|
|
149
|
-
to: event.email.headers.from ?? REPLY_FROM,
|
|
150
|
-
subject: \`Re: \${event.email.headers.subject ?? ""}\`,
|
|
151
|
-
bodyText: "Got your message.",
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
return Response.json({ ok: true, reply });
|
|
155
|
-
} catch (err) {
|
|
156
|
-
// Return 2xx so the webhook delivery loop does not retry a bug
|
|
157
|
-
// it cannot fix. The function-invocation row still records the
|
|
158
|
-
// error body for debugging. Flip to a 5xx status if you want
|
|
159
|
-
// transient failures retried (e.g. a flaky external API you call).
|
|
160
|
-
console.error("handler error:", err);
|
|
161
|
-
return Response.json(
|
|
162
|
-
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
163
|
-
{ status: 200 },
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
};
|
|
168
|
-
`;
|
|
169
|
-
}
|
|
170
|
-
export function renderPackageJson(name) {
|
|
171
|
-
const pkg = {
|
|
172
|
-
name,
|
|
173
|
-
version: "0.1.0",
|
|
174
|
-
private: true,
|
|
175
|
-
type: "module",
|
|
176
|
-
scripts: {
|
|
177
|
-
build: "node build.mjs",
|
|
178
|
-
deploy: `npm run build && primitive functions:deploy --name ${name} --file ./dist/handler.js`,
|
|
179
|
-
redeploy: "npm run build && primitive functions:redeploy --id $PRIMITIVE_FUNCTION_ID --file ./dist/handler.js",
|
|
180
|
-
},
|
|
181
|
-
dependencies: {
|
|
182
|
-
"@primitivedotdev/sdk": SDK_VERSION_RANGE,
|
|
183
|
-
},
|
|
184
|
-
devDependencies: {
|
|
185
|
-
// @primitivedotdev/cli ships the primitive bin. Including it as
|
|
186
|
-
// a devDep here means `node_modules/.bin/primitive` resolves to
|
|
187
|
-
// the real CLI inside the scaffolded project so `npm run deploy`
|
|
188
|
-
// works without a global install. Pinned via CLI_VERSION_RANGE,
|
|
189
|
-
// a dedicated constant so the version is decoupled from the SDK
|
|
190
|
-
// range and bumps are explicit on both ends.
|
|
191
|
-
"@primitivedotdev/cli": CLI_VERSION_RANGE,
|
|
192
|
-
esbuild: ESBUILD_VERSION_RANGE,
|
|
193
|
-
typescript: "^5.7.2",
|
|
194
|
-
},
|
|
195
|
-
};
|
|
196
|
-
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
197
|
-
}
|
|
198
|
-
export function renderBuildMjs() {
|
|
199
|
-
return `import { build } from "esbuild";
|
|
200
|
-
|
|
201
|
-
// Bundle handler.ts into a single ESM file suitable for the Primitive
|
|
202
|
-
// Functions runtime. The runtime is a Workers-style environment, so
|
|
203
|
-
// we pick the "worker" / "browser" export conditions on @primitivedotdev/sdk
|
|
204
|
-
// (which routes us to the /api subpath safely without dragging in
|
|
205
|
-
// node:crypto-dependent webhook helpers).
|
|
206
|
-
|
|
207
|
-
await build({
|
|
208
|
-
entryPoints: ["handler.ts"],
|
|
209
|
-
bundle: true,
|
|
210
|
-
format: "esm",
|
|
211
|
-
platform: "browser",
|
|
212
|
-
target: "es2022",
|
|
213
|
-
conditions: ["worker", "browser"],
|
|
214
|
-
outfile: "dist/handler.js",
|
|
215
|
-
});
|
|
216
|
-
`;
|
|
217
|
-
}
|
|
218
|
-
export function renderTsconfig() {
|
|
219
|
-
const tsconfig = {
|
|
220
|
-
compilerOptions: {
|
|
221
|
-
target: "ES2022",
|
|
222
|
-
module: "ESNext",
|
|
223
|
-
moduleResolution: "Bundler",
|
|
224
|
-
strict: true,
|
|
225
|
-
lib: ["ES2022", "WebWorker"],
|
|
226
|
-
types: [],
|
|
227
|
-
esModuleInterop: true,
|
|
228
|
-
skipLibCheck: true,
|
|
229
|
-
},
|
|
230
|
-
include: ["handler.ts"],
|
|
231
|
-
};
|
|
232
|
-
return `${JSON.stringify(tsconfig, null, 2)}\n`;
|
|
233
|
-
}
|
|
234
|
-
export function renderGitignore() {
|
|
235
|
-
return "node_modules\ndist\n";
|
|
236
|
-
}
|
|
237
|
-
export function renderReadme(name) {
|
|
238
|
-
return `# ${name}
|
|
239
|
-
|
|
240
|
-
## What this is
|
|
241
|
-
|
|
242
|
-
A Primitive Function: a JavaScript handler that runs on inbound mail.
|
|
243
|
-
It receives the \`email.received\` event, demonstrates a basic reply
|
|
244
|
-
via the Primitive SDK, and returns a JSON envelope.
|
|
245
|
-
|
|
246
|
-
## Develop
|
|
247
|
-
|
|
248
|
-
\`\`\`
|
|
249
|
-
npm install
|
|
250
|
-
npm run build
|
|
251
|
-
\`\`\`
|
|
252
|
-
|
|
253
|
-
## Deploy
|
|
254
|
-
|
|
255
|
-
\`\`\`
|
|
256
|
-
npm run deploy
|
|
257
|
-
\`\`\`
|
|
258
|
-
|
|
259
|
-
The deploy step calls \`primitive functions:deploy\` (provided by the
|
|
260
|
-
\`@primitivedotdev/cli\` package; install with
|
|
261
|
-
\`npm install -g @primitivedotdev/cli\` or run via
|
|
262
|
-
\`npx @primitivedotdev/cli@latest <command>\`). It requires
|
|
263
|
-
\`PRIMITIVE_API_KEY\` to be set in your shell (or pass \`--api-key\`).
|
|
264
|
-
Run \`primitive login\` once to save a key in your CLI config if you
|
|
265
|
-
prefer that to an env var.
|
|
266
|
-
`;
|
|
267
|
-
}
|
|
268
|
-
// Files written by the scaffolder, in the order they're created.
|
|
269
|
-
// Exported as a pure function so the unit test can verify the
|
|
270
|
-
// exact content of every file without invoking the command and
|
|
271
|
-
// touching disk.
|
|
272
|
-
export function scaffoldFiles(name) {
|
|
273
|
-
return [
|
|
274
|
-
{ contents: renderHandler(), relativePath: "handler.ts" },
|
|
275
|
-
{ contents: renderPackageJson(name), relativePath: "package.json" },
|
|
276
|
-
{ contents: renderBuildMjs(), relativePath: "build.mjs" },
|
|
277
|
-
{ contents: renderTsconfig(), relativePath: "tsconfig.json" },
|
|
278
|
-
{ contents: renderGitignore(), relativePath: ".gitignore" },
|
|
279
|
-
{ contents: renderReadme(name), relativePath: "README.md" },
|
|
280
|
-
];
|
|
281
|
-
}
|
|
282
|
-
// Write the scaffold to disk. Refuses to overwrite an existing
|
|
283
|
-
// directory: if `outDir` exists the function throws and leaves the
|
|
284
|
-
// filesystem untouched. On any write error after creating the
|
|
285
|
-
// directory, the partially-written tree is cleaned up so re-runs
|
|
286
|
-
// see a clean slate. Exported for unit testing.
|
|
287
|
-
export function writeScaffold(params) {
|
|
288
|
-
if (!isValidFunctionName(params.name)) {
|
|
289
|
-
throw new Errors.CLIError(`Invalid function name "${params.name}". Use lowercase letters, digits, hyphens, or underscores (1-63 chars, must start with a letter or digit).`, { exit: 1 });
|
|
290
|
-
}
|
|
291
|
-
const files = scaffoldFiles(params.name);
|
|
292
|
-
const written = [];
|
|
293
|
-
// Create the target directory with recursive: false so the check
|
|
294
|
-
// and the create happen in one syscall. mkdirSync throws EEXIST
|
|
295
|
-
// atomically if the path already exists, which closes the TOCTOU
|
|
296
|
-
// window between a separate existsSync check and the mkdir call.
|
|
297
|
-
try {
|
|
298
|
-
mkdirSync(params.outDir, { recursive: false });
|
|
299
|
-
}
|
|
300
|
-
catch (error) {
|
|
301
|
-
const code = error.code;
|
|
302
|
-
if (code === "EEXIST") {
|
|
303
|
-
throw new Errors.CLIError(`Target directory already exists: ${params.outDir}. Refusing to overwrite. Remove it or pick a different --out-dir.`, { exit: 1 });
|
|
304
|
-
}
|
|
305
|
-
if (code === "ENOENT") {
|
|
306
|
-
throw new Errors.CLIError(`Parent directory does not exist for ${params.outDir}. Create it first or pick a different --out-dir.`, { exit: 1 });
|
|
307
|
-
}
|
|
308
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
309
|
-
throw new Errors.CLIError(`Failed to create ${params.outDir}: ${detail}`, {
|
|
310
|
-
exit: 1,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
try {
|
|
314
|
-
for (const file of files) {
|
|
315
|
-
const fullPath = resolve(params.outDir, file.relativePath);
|
|
316
|
-
mkdirSync(dirname(fullPath), { recursive: true });
|
|
317
|
-
writeFileSync(fullPath, file.contents, "utf8");
|
|
318
|
-
written.push(fullPath);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
catch (error) {
|
|
322
|
-
// Roll back the partial scaffold so the user can retry without
|
|
323
|
-
// tripping the "directory already exists" guard above.
|
|
324
|
-
try {
|
|
325
|
-
rmSync(params.outDir, { force: true, recursive: true });
|
|
326
|
-
}
|
|
327
|
-
catch {
|
|
328
|
-
// Best-effort cleanup; surface the original error regardless.
|
|
329
|
-
}
|
|
330
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
331
|
-
throw new Errors.CLIError(`Failed to write scaffold to ${params.outDir}: ${detail}`, { exit: 1 });
|
|
332
|
-
}
|
|
333
|
-
return { written };
|
|
334
|
-
}
|
|
335
|
-
class FunctionsInitCommand extends Command {
|
|
336
|
-
static description = `Scaffold a new Primitive Function project in ./<name>/ with handler.ts, package.json, build.mjs, tsconfig.json, .gitignore, and README.md.
|
|
337
|
-
|
|
338
|
-
The scaffolded handler imports \`createPrimitiveClient\` from
|
|
339
|
-
\`@primitivedotdev/sdk/api\` and demonstrates the canonical pattern:
|
|
340
|
-
parse the email.received event, send a reply via the SDK, return a
|
|
341
|
-
JSON envelope. The build script uses esbuild's JS API and emits
|
|
342
|
-
./dist/handler.js, ready to hand to \`primitive functions:deploy --file\`.
|
|
343
|
-
|
|
344
|
-
Refuses to overwrite an existing directory. Use --out-dir to pick a
|
|
345
|
-
different target path than ./<name>/.`;
|
|
346
|
-
static summary = "Scaffold a new Primitive Function project ready for functions:deploy";
|
|
347
|
-
static examples = [
|
|
348
|
-
"<%= config.bin %> functions:init my-fn",
|
|
349
|
-
"<%= config.bin %> functions:init my-fn --out-dir ./functions/my-fn",
|
|
350
|
-
];
|
|
351
|
-
static args = {
|
|
352
|
-
name: Args.string({
|
|
353
|
-
description: "Function name. Lowercase letters, digits, hyphens, underscores. 1-63 chars. Used as the directory name (when --out-dir is not set) and as the package.json name.",
|
|
354
|
-
required: true,
|
|
355
|
-
}),
|
|
356
|
-
};
|
|
357
|
-
static flags = {
|
|
358
|
-
"out-dir": Flags.string({
|
|
359
|
-
description: "Directory to scaffold into. Defaults to ./<name>/. Must not already exist.",
|
|
360
|
-
}),
|
|
361
|
-
};
|
|
362
|
-
async run() {
|
|
363
|
-
const { args, flags } = await this.parse(FunctionsInitCommand);
|
|
364
|
-
const outDir = resolve(flags["out-dir"] ?? `./${args.name}`);
|
|
365
|
-
writeScaffold({ name: args.name, outDir });
|
|
366
|
-
this.log(`Scaffolded ${outDir}.`);
|
|
367
|
-
this.log("Next:");
|
|
368
|
-
this.log(` cd ${outDir}`);
|
|
369
|
-
this.log(" npm install");
|
|
370
|
-
this.log(" npm run build");
|
|
371
|
-
this.log(` primitive functions:deploy --name ${args.name} --file ./dist/handler.js`);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
export default FunctionsInitCommand;
|