@primitivedotdev/sdk 0.22.0 → 0.23.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.
@@ -0,0 +1,256 @@
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 major version bump of the SDK.
18
+ const SDK_VERSION_RANGE = "^0.22.0";
19
+ // esbuild version range. Pinned to the latest stable major used
20
+ // elsewhere in the Primitive codebase for bundling Workers-style
21
+ // handlers. Caret range so patch fixes flow in automatically.
22
+ const ESBUILD_VERSION_RANGE = "^0.27.0";
23
+ // Validate a directory name passed as the positional argument.
24
+ // Matches a conservative slug shape: lowercase letters, digits,
25
+ // hyphens, underscores. Rejecting weirder names up front prevents
26
+ // surprises when the same string lands in package.json's `name`
27
+ // field (which has its own validation rules) or in shell scripts.
28
+ const VALID_NAME = /^[a-z0-9][a-z0-9_-]{0,62}$/;
29
+ export function isValidFunctionName(name) {
30
+ return VALID_NAME.test(name);
31
+ }
32
+ // File contents for the scaffolded project. Each renderer takes the
33
+ // function name and returns the raw file body. Kept as named exports
34
+ // so the unit test can assert content without having to spin up the
35
+ // oclif command lifecycle.
36
+ export function renderHandler() {
37
+ return `// env.PRIMITIVE_API_KEY is auto-injected by the Primitive Functions runtime.
38
+ import { createPrimitiveClient } from "@primitivedotdev/sdk/api";
39
+
40
+ export default {
41
+ async fetch(
42
+ req: Request,
43
+ env: { PRIMITIVE_API_KEY: string },
44
+ ): Promise<Response> {
45
+ const event = (await req.json()) as {
46
+ email: { headers: { from?: string; subject?: string } };
47
+ };
48
+ const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });
49
+
50
+ const reply = await client.send({
51
+ from: "you@your-domain.primitive.email",
52
+ to: event.email.headers.from ?? "you@your-domain.primitive.email",
53
+ subject: \`Re: \${event.email.headers.subject ?? ""}\`,
54
+ bodyText: "Got your message.",
55
+ });
56
+
57
+ return Response.json({ ok: true, reply });
58
+ },
59
+ };
60
+ `;
61
+ }
62
+ export function renderPackageJson(name) {
63
+ const pkg = {
64
+ name,
65
+ version: "0.1.0",
66
+ private: true,
67
+ type: "module",
68
+ scripts: {
69
+ build: "node build.mjs",
70
+ deploy: `npm run build && primitive functions:deploy --name ${name} --file ./dist/handler.js`,
71
+ redeploy: "npm run build && primitive functions:redeploy --id $PRIMITIVE_FUNCTION_ID --file ./dist/handler.js",
72
+ },
73
+ dependencies: {
74
+ "@primitivedotdev/sdk": SDK_VERSION_RANGE,
75
+ },
76
+ devDependencies: {
77
+ esbuild: ESBUILD_VERSION_RANGE,
78
+ typescript: "^5.7.2",
79
+ },
80
+ };
81
+ return `${JSON.stringify(pkg, null, 2)}\n`;
82
+ }
83
+ export function renderBuildMjs() {
84
+ return `import { build } from "esbuild";
85
+
86
+ // Bundle handler.ts into a single ESM file suitable for the Primitive
87
+ // Functions runtime. The runtime is a Workers-style environment, so
88
+ // we pick the "worker" / "browser" export conditions on @primitivedotdev/sdk
89
+ // (which routes us to the /api subpath safely without dragging in
90
+ // node:crypto-dependent webhook helpers).
91
+
92
+ await build({
93
+ entryPoints: ["handler.ts"],
94
+ bundle: true,
95
+ format: "esm",
96
+ platform: "browser",
97
+ target: "es2022",
98
+ conditions: ["worker", "browser"],
99
+ outfile: "dist/handler.js",
100
+ });
101
+ `;
102
+ }
103
+ export function renderTsconfig() {
104
+ const tsconfig = {
105
+ compilerOptions: {
106
+ target: "ES2022",
107
+ module: "ESNext",
108
+ moduleResolution: "Bundler",
109
+ strict: true,
110
+ lib: ["ES2022", "WebWorker"],
111
+ types: [],
112
+ esModuleInterop: true,
113
+ skipLibCheck: true,
114
+ },
115
+ include: ["handler.ts"],
116
+ };
117
+ return `${JSON.stringify(tsconfig, null, 2)}\n`;
118
+ }
119
+ export function renderGitignore() {
120
+ return "node_modules\ndist\n";
121
+ }
122
+ export function renderReadme(name) {
123
+ return `# ${name}
124
+
125
+ ## What this is
126
+
127
+ A Primitive Function: a JavaScript handler that runs on inbound mail.
128
+ It receives the \`email.received\` event, demonstrates a basic reply
129
+ via the Primitive SDK, and returns a JSON envelope.
130
+
131
+ ## Develop
132
+
133
+ \`\`\`
134
+ npm install
135
+ npm run build
136
+ \`\`\`
137
+
138
+ ## Deploy
139
+
140
+ \`\`\`
141
+ npm run deploy
142
+ \`\`\`
143
+
144
+ The deploy step calls \`primitive functions:deploy\` and requires
145
+ \`PRIMITIVE_API_KEY\` to be set in your shell (or pass \`--api-key\`).
146
+ Run \`primitive login\` once to save a key in your CLI config if you
147
+ prefer that to an env var.
148
+ `;
149
+ }
150
+ // Files written by the scaffolder, in the order they're created.
151
+ // Exported as a pure function so the unit test can verify the
152
+ // exact content of every file without invoking the command and
153
+ // touching disk.
154
+ export function scaffoldFiles(name) {
155
+ return [
156
+ { contents: renderHandler(), relativePath: "handler.ts" },
157
+ { contents: renderPackageJson(name), relativePath: "package.json" },
158
+ { contents: renderBuildMjs(), relativePath: "build.mjs" },
159
+ { contents: renderTsconfig(), relativePath: "tsconfig.json" },
160
+ { contents: renderGitignore(), relativePath: ".gitignore" },
161
+ { contents: renderReadme(name), relativePath: "README.md" },
162
+ ];
163
+ }
164
+ // Write the scaffold to disk. Refuses to overwrite an existing
165
+ // directory: if `outDir` exists the function throws and leaves the
166
+ // filesystem untouched. On any write error after creating the
167
+ // directory, the partially-written tree is cleaned up so re-runs
168
+ // see a clean slate. Exported for unit testing.
169
+ export function writeScaffold(params) {
170
+ if (!isValidFunctionName(params.name)) {
171
+ 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 });
172
+ }
173
+ const files = scaffoldFiles(params.name);
174
+ const written = [];
175
+ // Create the target directory with recursive: false so the check
176
+ // and the create happen in one syscall. mkdirSync throws EEXIST
177
+ // atomically if the path already exists, which closes the TOCTOU
178
+ // window between a separate existsSync check and the mkdir call.
179
+ try {
180
+ mkdirSync(params.outDir, { recursive: false });
181
+ }
182
+ catch (error) {
183
+ const code = error.code;
184
+ if (code === "EEXIST") {
185
+ throw new Errors.CLIError(`Target directory already exists: ${params.outDir}. Refusing to overwrite. Remove it or pick a different --out-dir.`, { exit: 1 });
186
+ }
187
+ if (code === "ENOENT") {
188
+ throw new Errors.CLIError(`Parent directory does not exist for ${params.outDir}. Create it first or pick a different --out-dir.`, { exit: 1 });
189
+ }
190
+ const detail = error instanceof Error ? error.message : String(error);
191
+ throw new Errors.CLIError(`Failed to create ${params.outDir}: ${detail}`, {
192
+ exit: 1,
193
+ });
194
+ }
195
+ try {
196
+ for (const file of files) {
197
+ const fullPath = resolve(params.outDir, file.relativePath);
198
+ mkdirSync(dirname(fullPath), { recursive: true });
199
+ writeFileSync(fullPath, file.contents, "utf8");
200
+ written.push(fullPath);
201
+ }
202
+ }
203
+ catch (error) {
204
+ // Roll back the partial scaffold so the user can retry without
205
+ // tripping the "directory already exists" guard above.
206
+ try {
207
+ rmSync(params.outDir, { force: true, recursive: true });
208
+ }
209
+ catch {
210
+ // Best-effort cleanup; surface the original error regardless.
211
+ }
212
+ const detail = error instanceof Error ? error.message : String(error);
213
+ throw new Errors.CLIError(`Failed to write scaffold to ${params.outDir}: ${detail}`, { exit: 1 });
214
+ }
215
+ return { written };
216
+ }
217
+ class FunctionsInitCommand extends Command {
218
+ static description = `Scaffold a new Primitive Function project in ./<name>/ with handler.ts, package.json, build.mjs, tsconfig.json, .gitignore, and README.md.
219
+
220
+ The scaffolded handler imports \`createPrimitiveClient\` from
221
+ \`@primitivedotdev/sdk/api\` and demonstrates the canonical pattern:
222
+ parse the email.received event, send a reply via the SDK, return a
223
+ JSON envelope. The build script uses esbuild's JS API and emits
224
+ ./dist/handler.js, ready to hand to \`primitive functions:deploy --file\`.
225
+
226
+ Refuses to overwrite an existing directory. Use --out-dir to pick a
227
+ different target path than ./<name>/.`;
228
+ static summary = "Scaffold a new Primitive Function project ready for functions:deploy";
229
+ static examples = [
230
+ "<%= config.bin %> functions:init my-fn",
231
+ "<%= config.bin %> functions:init my-fn --out-dir ./functions/my-fn",
232
+ ];
233
+ static args = {
234
+ name: Args.string({
235
+ 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.",
236
+ required: true,
237
+ }),
238
+ };
239
+ static flags = {
240
+ "out-dir": Flags.string({
241
+ description: "Directory to scaffold into. Defaults to ./<name>/. Must not already exist.",
242
+ }),
243
+ };
244
+ async run() {
245
+ const { args, flags } = await this.parse(FunctionsInitCommand);
246
+ const outDir = resolve(flags["out-dir"] ?? `./${args.name}`);
247
+ writeScaffold({ name: args.name, outDir });
248
+ this.log(`Scaffolded ${outDir}.`);
249
+ this.log("Next:");
250
+ this.log(` cd ${outDir}`);
251
+ this.log(" npm install");
252
+ this.log(" npm run build");
253
+ this.log(` primitive functions:deploy --name ${args.name} --file ./dist/handler.js`);
254
+ }
255
+ }
256
+ export default FunctionsInitCommand;
@@ -5,6 +5,7 @@ import EmailsLatestCommand from "./commands/emails-latest.js";
5
5
  import EmailsWaitCommand from "./commands/emails-wait.js";
6
6
  import EmailsWatchCommand from "./commands/emails-watch.js";
7
7
  import FunctionsDeployCommand from "./commands/functions-deploy.js";
8
+ import FunctionsInitCommand from "./commands/functions-init.js";
8
9
  import FunctionsRedeployCommand from "./commands/functions-redeploy.js";
9
10
  import FunctionsSetSecretCommand from "./commands/functions-set-secret.js";
10
11
  import LoginCommand from "./commands/login.js";
@@ -140,6 +141,13 @@ export const COMMANDS = {
140
141
  // inbound mail. `watch` defaults to a human table; `wait` defaults to JSONL.
141
142
  "emails:watch": EmailsWatchCommand,
142
143
  "emails:wait": EmailsWaitCommand,
144
+ // `functions:init` scaffolds a deployable Function project so a
145
+ // new author can go zero-to-deployed without writing the handler,
146
+ // package.json, build script, and tsconfig from scratch. The
147
+ // scaffolded handler imports from @primitivedotdev/sdk/api (the
148
+ // runtime-client subpath) and demonstrates client.send() so the
149
+ // first thing the author sees is the SDK pattern, not raw fetch.
150
+ "functions:init": FunctionsInitCommand,
143
151
  // `functions:deploy` and `functions:redeploy` are file-input
144
152
  // shortcuts for create-function / update-function. The underlying
145
153
  // ops take `code` as a body string, which is awkward at the CLI
@@ -709,6 +709,39 @@
709
709
  "summary": "Wait for matching inbound emails",
710
710
  "enableJsonFlag": false
711
711
  },
712
+ "functions:init": {
713
+ "aliases": [],
714
+ "args": {
715
+ "name": {
716
+ "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.",
717
+ "name": "name",
718
+ "required": true
719
+ }
720
+ },
721
+ "description": "Scaffold a new Primitive Function project in ./<name>/ with handler.ts, package.json, build.mjs, tsconfig.json, .gitignore, and README.md.\n\n The scaffolded handler imports `createPrimitiveClient` from\n `@primitivedotdev/sdk/api` and demonstrates the canonical pattern:\n parse the email.received event, send a reply via the SDK, return a\n JSON envelope. The build script uses esbuild's JS API and emits\n ./dist/handler.js, ready to hand to `primitive functions:deploy --file`.\n\n Refuses to overwrite an existing directory. Use --out-dir to pick a\n different target path than ./<name>/.",
722
+ "examples": [
723
+ "<%= config.bin %> functions:init my-fn",
724
+ "<%= config.bin %> functions:init my-fn --out-dir ./functions/my-fn"
725
+ ],
726
+ "flags": {
727
+ "out-dir": {
728
+ "description": "Directory to scaffold into. Defaults to ./<name>/. Must not already exist.",
729
+ "name": "out-dir",
730
+ "hasDynamicHelp": false,
731
+ "multiple": false,
732
+ "type": "option"
733
+ }
734
+ },
735
+ "hasDynamicHelp": false,
736
+ "hiddenAliases": [],
737
+ "id": "functions:init",
738
+ "pluginAlias": "@primitivedotdev/sdk",
739
+ "pluginName": "@primitivedotdev/sdk",
740
+ "pluginType": "core",
741
+ "strict": true,
742
+ "summary": "Scaffold a new Primitive Function project ready for functions:deploy",
743
+ "enableJsonFlag": false
744
+ },
712
745
  "functions:deploy": {
713
746
  "aliases": [],
714
747
  "args": {},
@@ -4250,5 +4283,5 @@
4250
4283
  "enableJsonFlag": false
4251
4284
  }
4252
4285
  },
4253
- "version": "0.22.0"
4286
+ "version": "0.23.0"
4254
4287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/sdk",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Official Primitive Node.js SDK: webhook, api, openapi, contract, and parser modules",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -84,7 +84,7 @@
84
84
  "description": "View and replay webhook delivery attempts"
85
85
  },
86
86
  "functions": {
87
- "description": "Deploy JavaScript handlers that run on inbound mail. Use `primitive functions:deploy --name <name> --file <bundle.js>` to create, `primitive functions:redeploy --id <id> --file <bundle.js>` to push a new bundle, and `primitive functions:set-secret --id <id> --key <KEY> --value <value> [--redeploy]` to write a secret (with optional one-call redeploy so the value lands in the running handler). The auto-generated functions:create-function / functions:update-function / functions:create-function-secret / functions:set-function-secret operations stay available for the full body-string surface."
87
+ "description": "Deploy JavaScript handlers that run on inbound mail. Use `primitive functions:init <name>` to scaffold a deployable project (handler, package.json, build script). Use `primitive functions:deploy --name <name> --file <bundle.js>` to create, `primitive functions:redeploy --id <id> --file <bundle.js>` to push a new bundle, and `primitive functions:set-secret --id <id> --key <KEY> --value <value> [--redeploy]` to write a secret (with optional one-call redeploy so the value lands in the running handler). The auto-generated functions:create-function / functions:update-function / functions:create-function-secret / functions:set-function-secret operations stay available for the full body-string surface."
88
88
  }
89
89
  },
90
90
  "topicSeparator": " "