@suluk/platform 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/platform",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "The platform generator (C051): write one `definePlatform` manifest → it plans the shadcn-registry adds, generates the wired Hono entry, and merges each module's provision fragment into a single provision.config. The manifest compiles to a shadcn-add list + a C047 provision.config; the generator runs the adds + `@suluk/provision`. Turns the Suluk backend registry into a one-command platform. CANDIDATE tooling.",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/catalog.ts CHANGED
@@ -26,7 +26,7 @@ export const CATALOG: Record<string, CatalogEntry> = Object.fromEntries(Object.e
26
26
  * that imports mergeProvision from @suluk/platform + defineProvision from @suluk/provision). Union'd with each service's
27
27
  * `deps` to build package.json.
28
28
  */
29
- export const BASE_DEPS = ["@suluk/platform", "@suluk/provision", "@suluk/core", "effect", "hono", "drizzle-orm"];
29
+ export const BASE_DEPS = ["@suluk/platform", "@suluk/provision", "@suluk/core", "@suluk/env", "effect", "hono", "drizzle-orm"];
30
30
 
31
31
  /** Pinned ranges for the NON-@suluk ecosystem deps — the single place they're kept current for every generated app.
32
32
  * @suluk/* are NOT here: they resolve to "latest" so a package fix flows to the app via `bun update` (the C052 payoff). */
package/src/generate.ts CHANGED
@@ -46,16 +46,19 @@ export async function generatePlatform(input: PlatformManifest | Platform, opts:
46
46
  log("▸ writing wrangler.toml");
47
47
  await opts.write("wrangler.toml", mergeWranglerToml(plan.wranglerToml, await read("wrangler.toml")));
48
48
  written.push("wrangler.toml");
49
- // .gitignore MERGES (append missing entries) — critically ensures .env/.env.temp are ignored even if the app already had
50
- // a minimal .gitignore, so secrets are never committed. .env.example + the env-check are always (re)written (no values).
49
+ // .gitignore MERGES (append missing entries) — ensures `.env.keys` (the PRIVATE key) + `.env.temp` are ignored. `.env`
50
+ // itself is COMMITTED (its secret values encrypted by @suluk/env). .env.example + env.ts + env-check are always (re)written.
51
51
  log("▸ writing .gitignore");
52
52
  await opts.write(".gitignore", mergeGitignore(plan.gitignore, await read(".gitignore")));
53
53
  written.push(".gitignore");
54
54
  for (const [file, content, always] of [
55
55
  ["tsconfig.json", plan.tsconfig, false],
56
56
  ["components.json", plan.componentsJson, false],
57
- [".env.example", plan.envExample, true], // a checked-in template (no values) — keep it current
58
- ["scripts/env-check.ts", plan.envCheck, true], // the .env.temp lifecycle preflight
57
+ [".env.example", plan.envExample, true], // a checked-in keys checklist (no values)
58
+ ["scripts/env-check.ts", plan.envCheck, true], // the encrypted-env preflight
59
+ ["src/env.ts", plan.envTs, true], // the @suluk/env declare-once (derived from the manifest's secrets)
60
+ ["scripts/sync-secrets.ts", plan.syncSecrets, true], // the deploy-time secret push (derived)
61
+ [".env", plan.envScaffold, false], // the COMMITTED encrypted-secrets file — SCAFFOLD IF ABSENT (never clobber secrets)
59
62
  ] as const) {
60
63
  if (always || (await read(file)) == null) {
61
64
  log(`▸ writing ${file}`);
package/src/plan.ts CHANGED
@@ -27,10 +27,18 @@ export interface PlatformPlan {
27
27
  envExample: string;
28
28
  /** the generated `wrangler.toml` — `[vars]` from the manifest's non-secret config + the D1/KV binding placeholders. */
29
29
  wranglerToml: string;
30
- /** the generated `.gitignore` (ignores `.env`, `.env.temp`, `.dev.vars`, ). */
30
+ /** the generated `.gitignore` ignores `.env.keys` (the private key) + `.env.temp`, but NOT `.env` (committed ENCRYPTED). */
31
31
  gitignore: string;
32
- /** the generated `scripts/env-check.ts` — the `.env.temp` lifecycle preflight (create when secrets missing, delete when ready). */
32
+ /** the generated `scripts/env-check.ts` — the encrypted-env preflight (keypair present? required secrets set + encrypted?). */
33
33
  envCheck: string;
34
+ /** the generated `src/env.ts` — the @suluk/env `defineEnv` declaration (declare-once: the app's secrets, surfaced). */
35
+ envTs: string;
36
+ /** the generated `scripts/sync-secrets.ts` — decrypt the cloudflare-surfaced secrets from the committed .env and push them
37
+ * as `wrangler secret`s (the toolfactory-exact deploy path; the alternative is the entry's runtime `loadEnv`). */
38
+ syncSecrets: string;
39
+ /** the generated `.env` SCAFFOLD (committed) — a header + the setup steps, NO values. `generate` writes it only if absent
40
+ * (never clobbering the operator's encrypted secrets). Secret VALUES are added encrypted via `suluk-env set`. */
41
+ envScaffold: string;
34
42
  }
35
43
 
36
44
  export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
@@ -61,9 +69,15 @@ export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
61
69
  wranglerToml: buildWranglerToml(manifest.name, services, env, manifest.vars ?? {}),
62
70
  gitignore: buildGitignore(),
63
71
  envCheck: buildEnvCheckScript(env),
72
+ envTs: buildEnvTs(env),
73
+ syncSecrets: buildSyncSecrets(),
74
+ envScaffold: buildEnvScaffold(env),
64
75
  };
65
76
  }
66
77
 
78
+ /** the app's SECRET env vars (the ones committed ENCRYPTED in `.env` + decrypted at runtime). */
79
+ const secretsOf = (env: EnvVar[]): EnvVar[] => env.filter((e) => e.secret);
80
+
67
81
  /** The SECRET env keys → `.env.example` (required uncommented `KEY=`, optional commented `# KEY=`), each with its hint.
68
82
  * Non-secret config is NOT here — it's in the manifest `vars` → wrangler `[vars]`. Safe to commit (no values). */
69
83
  function buildEnvExample(env: EnvVar[]): string {
@@ -72,7 +86,9 @@ function buildEnvExample(env: EnvVar[]): string {
72
86
  const required = secrets.filter((e) => e.required);
73
87
  const optional = secrets.filter((e) => !e.required);
74
88
  return [
75
- "# .env SECRET keys (generated from platform.config.ts). Copy the values in; never commit this file.",
89
+ "# .env keys checklist (generated). The `.env` is COMMITTED with these values ENCRYPTED (@suluk/env, ML-KEM-768):",
90
+ "# bunx suluk-env keygen # once — keypair (SULUK_PUBLIC_KEY → .env; private → .env.keys, gitignored)",
91
+ "# bunx suluk-env set KEY=value # per secret — encrypts it into .env",
76
92
  "# Non-secret config lives in platform.config.ts `vars` (→ wrangler.toml [vars]), NOT here.",
77
93
  "",
78
94
  "# Required — the app won't start without these:",
@@ -84,6 +100,76 @@ function buildEnvExample(env: EnvVar[]): string {
84
100
  ].join("\n");
85
101
  }
86
102
 
103
+ /**
104
+ * `src/env.ts` — the @suluk/env `defineEnv` DECLARE-ONCE for the app's SECRETS (from the catalog's secret env metadata). Each
105
+ * secret is `surfaces: ["cloudflare"]` (a Worker-runtime secret); `sync-secrets` reads `forSurface("cloudflare")` to know what
106
+ * to push, and the values live ENCRYPTED in the committed `.env`. Non-secret config stays in the manifest `vars` → [vars].
107
+ */
108
+ function buildEnvTs(env: EnvVar[]): string {
109
+ const secrets = secretsOf(env);
110
+ const spec = secrets
111
+ .map((e) => ` ${e.name}: { secret: true, ${e.required ? "required: true, " : ""}surfaces: ["cloudflare"]${e.hint ? `, description: ${JSON.stringify(e.hint)}` : ""} },`)
112
+ .join("\n");
113
+ return [
114
+ "// AUTO-GENERATED by @suluk/platform — the @suluk/env declare-once for this app's SECRETS. Values live ENCRYPTED in the",
115
+ "// committed .env (ML-KEM-768). Non-secret config is in platform.config.ts `vars` → wrangler.toml [vars].",
116
+ 'import { defineEnv } from "@suluk/env";',
117
+ "",
118
+ "export const env = defineEnv({",
119
+ spec,
120
+ "});",
121
+ "",
122
+ ].join("\n");
123
+ }
124
+
125
+ /**
126
+ * `scripts/sync-secrets.ts` — the deploy-time path (toolfactory-exact): decrypt the cloudflare-surfaced secrets from the
127
+ * committed .env (the private key comes from `.env.keys` / `~/.suluk` / `SULUK_PRIVATE_KEY`) and push each as a `wrangler
128
+ * secret`. Idempotent. The alternative is the runtime `loadEnv` in `src/index.ts` (set SULUK_PRIVATE_KEY as a wrangler secret).
129
+ */
130
+ function buildSyncSecrets(): string {
131
+ return `#!/usr/bin/env bun
132
+ // AUTO-GENERATED by @suluk/platform. Push the committed, @suluk/env-encrypted secrets to the Worker as \`wrangler secret\`s.
133
+ // Run at deploy: \`bun run sync-secrets\`. Needs the private key (.env.keys / ~/.suluk / SULUK_PRIVATE_KEY) + a CF-authed wrangler.
134
+ import { loadEnvFile } from "@suluk/env/node";
135
+ import { env } from "../src/env";
136
+
137
+ const values = await loadEnvFile(); // decrypt every value in .env into a { KEY: value } record
138
+ const names = env.forSurface("cloudflare").filter((k) => values[k] !== undefined && values[k] !== "");
139
+ if (!names.length) {
140
+ console.log("no cloudflare-surfaced secrets are set in .env yet — run \`bunx suluk-env set KEY=value\` first.");
141
+ process.exit(0);
142
+ }
143
+ for (const name of names) {
144
+ const proc = Bun.spawn(["wrangler", "secret", "put", name], { stdin: "pipe", stdout: "inherit", stderr: "inherit" });
145
+ proc.stdin.write(values[name]);
146
+ await proc.stdin.end();
147
+ if ((await proc.exited) !== 0) { console.error(\`✗ failed to put \${name}\`); process.exit(1); }
148
+ console.log(\`✓ \${name}\`);
149
+ }
150
+ console.log(\`✓ synced \${names.length} secret(s) to the Worker.\`);
151
+ `;
152
+ }
153
+
154
+ /**
155
+ * The `.env` SCAFFOLD — a header + setup steps, NO values (safe to commit). `generate` writes it ONLY IF ABSENT so it never
156
+ * clobbers the operator's encrypted secrets. Its presence also lets `src/index.ts`'s `import "../.env"` resolve on a fresh app.
157
+ */
158
+ function buildEnvScaffold(env: EnvVar[]): string {
159
+ const required = secretsOf(env).filter((e) => e.required);
160
+ return [
161
+ "# This file is COMMITTED. Secret VALUES are stored ENCRYPTED (@suluk/env, post-quantum ML-KEM-768) — safe to push to git.",
162
+ "# Setup:",
163
+ "# bunx suluk-env keygen # keypair: SULUK_PUBLIC_KEY here (commit); private key → .env.keys (gitignored)",
164
+ "# bunx suluk-env set BETTER_AUTH_SECRET=... # encrypts + adds each secret" + (required.length ? ` (required: ${required.map((e) => e.name).join(", ")})` : ""),
165
+ "# Get the secrets into the Worker EITHER way:",
166
+ "# • runtime: `wrangler secret put SULUK_PRIVATE_KEY` → src/index.ts decrypts this file on the first request",
167
+ "# • deploy: `bun run sync-secrets` → pushes each secret via `wrangler secret put`",
168
+ "# NEVER commit a plaintext secret — `suluk-env set` encrypts; `bunx suluk-env encrypt` seals any leftover plaintext.",
169
+ "",
170
+ ].join("\n");
171
+ }
172
+
87
173
  /** The `wrangler.toml` — `[vars]` from the manifest's non-secret config (unset ones commented with a hint) + the D1 binding
88
174
  * (always) + a KV binding when rate-credit is selected. `generate` merges it so provisioned binding ids survive a regen. */
89
175
  function buildWranglerToml(name: string, services: string[], env: EnvVar[], vars: Record<string, string>): string {
@@ -147,60 +233,66 @@ export function mergeWranglerToml(generated: string, existing: string | null): s
147
233
  }
148
234
 
149
235
  function buildGitignore(): string {
150
- return ["node_modules/", ".env", ".env.temp", ".dev.vars", ".wrangler/", "dist/", ""].join("\n");
236
+ // NOTE: `.env` is NOT ignored it is COMMITTED with its secret values ENCRYPTED (@suluk/env). The PRIVATE key
237
+ // (`.env.keys`) is what must never be committed; that + `.env.temp`/`.dev.vars` are ignored.
238
+ return ["node_modules/", ".env.keys", ".env.temp", ".dev.vars", ".wrangler/", "dist/", ""].join("\n");
151
239
  }
152
240
 
153
241
  /** Merge the generated .gitignore into an existing one — APPEND any missing entries (never skip-if-present, so an app's
154
- * minimal .gitignore can't leave `.env`/`.env.temp` UNIGNORED and risk committing secrets). Dedup, preserve app entries. */
242
+ * minimal .gitignore can't leave `.env.keys`/`.env.temp` UNIGNORED and risk committing the private key). Dedup, preserve app
243
+ * entries. ENCRYPTED-ENV TRANSITION: if the new baseline ignores `.env.keys` (the private key) but NOT `.env`, a plaintext-era
244
+ * `.env` ignore is REMOVED — the .env is now COMMITTED with its values encrypted, so ignoring it is wrong (and safe to undo). */
155
245
  export function mergeGitignore(generated: string, existing: string | null): string {
156
246
  if (!existing) return generated;
157
247
  const norm = (s: string) => s.trim().replace(/\/$/, "");
158
- const have = new Set(existing.split("\n").map(norm).filter(Boolean));
248
+ const genLines = generated.split("\n").map(norm);
249
+ const encryptedModel = genLines.includes(".env.keys") && !genLines.includes(".env");
250
+ const existingClean = encryptedModel ? existing.split("\n").filter((l) => norm(l) !== ".env").join("\n") : existing;
251
+ const have = new Set(existingClean.split("\n").map(norm).filter(Boolean));
159
252
  const add = generated.split("\n").filter((l) => l.trim() && !have.has(norm(l)));
160
- if (!add.length) return existing.endsWith("\n") ? existing : existing + "\n";
161
- const base = existing.replace(/\n*$/, "");
253
+ if (!add.length) return existingClean.endsWith("\n") ? existingClean : existingClean + "\n";
254
+ const base = existingClean.replace(/\n*$/, "");
162
255
  return `${base}\n${add.join("\n")}\n`;
163
256
  }
164
257
 
165
- /** The `.env.temp` lifecycle preflight (run via `predev` / `bun run check`): if every REQUIRED secret is present (in `.env`
166
- * or the process env), delete `.env.temp`; else write `.env.temp` from `.env.example` + report the missing keys + fail. */
258
+ /** The encrypted-env preflight (run via `predev` / `bun run check`): is there a keypair, and is every REQUIRED secret set
259
+ * (encrypted) in the committed `.env`? A plaintext secret sitting in `.env` is flagged (encrypt it before you commit). */
167
260
  function buildEnvCheckScript(env: EnvVar[]): string {
168
261
  const required = env.filter((e) => e.secret && e.required).map((e) => e.name);
169
262
  return `#!/usr/bin/env bun
170
263
  /**
171
- * AUTO-GENERATED by @suluk/platform — the .env lifecycle. Wired as \`predev\` (runs before \`dev\`) + \`bun run check\`.
172
- * The MINIMUM secret keys this app needs come from platform.config.ts (the selected modules). Non-secret config is in
173
- * the manifest \`vars\` → wrangler.toml [vars], not here.
264
+ * AUTO-GENERATED by @suluk/platform — the ENCRYPTED-env preflight (wired as \`predev\` + \`bun run check\`). Secrets live in the
265
+ * committed .env, ENCRYPTED with @suluk/env. This checks: a keypair exists, the REQUIRED secrets are set, and none is sitting
266
+ * in plaintext (which must never be committed). Non-secret config is in platform.config.ts \`vars\` → wrangler.toml [vars].
174
267
  */
175
- import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
268
+ import { existsSync, readFileSync } from "node:fs";
176
269
 
177
270
  const REQUIRED = ${JSON.stringify(required)};
178
- const ENV = ".env", TEMP = ".env.temp", EXAMPLE = ".env.example";
271
+ const ENV = ".env";
179
272
 
180
273
  const parse = (p: string): Record<string, string> => {
181
274
  const out: Record<string, string> = {};
182
275
  for (const line of readFileSync(p, "utf8").split("\\n")) {
183
276
  const m = line.match(/^\\s*([A-Z0-9_]+)\\s*=\\s*(.*)$/);
184
- if (m && m[2].trim()) out[m[1]] = m[2].trim();
277
+ if (m && m[2].trim()) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
185
278
  }
186
279
  return out;
187
280
  };
188
281
 
189
282
  const have = existsSync(ENV) ? parse(ENV) : {};
283
+ const isEncrypted = (v: string) => v.startsWith("encrypted:");
284
+ const fail = (msg: string) => { console.error("✗ " + msg); process.exit(1); };
285
+
286
+ if (!have.SULUK_PUBLIC_KEY) fail("no @suluk/env keypair — run \`bunx suluk-env keygen\` (creates SULUK_PUBLIC_KEY in .env + .env.keys).");
287
+
190
288
  const missing = REQUIRED.filter((k) => !have[k] && !process.env[k]);
289
+ if (missing.length) fail("missing required secret(s): " + missing.join(", ") + "\\n → set each: bunx suluk-env set KEY=value");
191
290
 
192
- if (missing.length === 0) {
193
- if (existsSync(TEMP)) { rmSync(TEMP); console.log(" .env ready removed .env.temp"); }
194
- else console.log("✓ .env ready (all required secrets present).");
195
- process.exit(0);
196
- }
291
+ const plaintext = Object.keys(have).filter((k) => k !== "SULUK_PUBLIC_KEY" && have[k] && !isEncrypted(have[k]));
292
+ if (plaintext.length) fail("PLAINTEXT secret(s) in .env (never commit these): " + plaintext.join(", ") + "\\n → encrypt: bunx suluk-env encrypt");
197
293
 
198
- // not ready drop a fill-me-in template so you can see exactly what's needed.
199
- if (existsSync(EXAMPLE)) writeFileSync(TEMP, readFileSync(EXAMPLE, "utf8"));
200
- console.error("✗ missing required secret(s): " + missing.join(", "));
201
- console.error(" → wrote .env.temp — fill the values in and save it as .env (or inject the secrets another way).");
202
- console.error(" .env.temp auto-deletes once .env has every required secret.");
203
- process.exit(1);
294
+ console.log("✓ .env ready keypair present, required secrets set + encrypted.");
295
+ process.exit(0);
204
296
  `;
205
297
  }
206
298
 
@@ -217,10 +309,13 @@ export function buildPackageJson(name: string, services: string[], catalog: Reco
217
309
  type: "module",
218
310
  scripts: {
219
311
  generate: "suluk-platform", // re-pull modules + rewrite the scaffold config + src/index.ts + provision.config.ts
220
- check: "bun run scripts/env-check.ts", // the .env.temp lifecycle (fails if a required secret is missing)
312
+ check: "bun run scripts/env-check.ts", // the encrypted-env preflight (keypair present? required secrets set + encrypted?)
221
313
  predev: "bun run scripts/env-check.ts", // runs automatically before `dev`
222
314
  dev: "wrangler dev",
223
315
  deploy: "wrangler deploy",
316
+ "env:keygen": "suluk-env keygen", // create the @suluk/env keypair (SULUK_PUBLIC_KEY → .env; private → .env.keys)
317
+ "env:set": "suluk-env set", // encrypt + add a secret: `bun run env:set BETTER_AUTH_SECRET=...`
318
+ "sync-secrets": "bun run scripts/sync-secrets.ts", // decrypt cloudflare-surfaced secrets → `wrangler secret put` each
224
319
  typecheck: "tsc --noEmit -p .",
225
320
  test: "bun test",
226
321
  },
@@ -295,7 +390,12 @@ function buildComponentsJson(): string {
295
390
  }
296
391
 
297
392
  function buildEntry(services: string[], opts?: Record<string, Record<string, unknown>>, wiring?: Wiring, catalog: Record<string, Service> = CORE_SERVICES): string {
298
- const imports = ['import { createApp } from "./app";'];
393
+ const imports = [
394
+ 'import { createApp } from "./app";',
395
+ 'import { loadEnv } from "@suluk/env";',
396
+ "// @ts-ignore — the committed, @suluk/env-encrypted .env, bundled as text (bun + wrangler text import).",
397
+ 'import envText from "../.env" with { type: "text" };',
398
+ ];
299
399
  const middleware: string[] = [];
300
400
  const routes: string[] = [];
301
401
  const hooksByService = wiring?.hooksByService ?? {};
@@ -339,7 +439,20 @@ function buildEntry(services: string[], opts?: Record<string, Record<string, unk
339
439
  });
340
440
  for (const line of groupImports(safeWireImports)) imports.push(line);
341
441
  const body = ["const app = createApp();", ...middleware, ...routes];
342
- return `// AUTO-GENERATED by @suluk/platform from platform.config.ts the wired Hono entry. Edit freely.\n${imports.join("\n")}\n\n${body.join("\n")}\n\nexport default app;\n`;
442
+ // the @suluk/env bootstrap: the committed .env holds the app's secrets ENCRYPTED. If SULUK_PRIVATE_KEY is set (a wrangler
443
+ // secret), decrypt them into the request env on first use (the runtime path); otherwise this is a no-op and the secrets come
444
+ // from `wrangler secret put` (the `bun run sync-secrets` deploy path). Decrypt once per isolate (env is stable across requests).
445
+ const bootstrap = [
446
+ "let secrets: Record<string, string> | null = null;",
447
+ "export default {",
448
+ " async fetch(request: Request, env: Record<string, unknown>, ctx: ExecutionContext): Promise<Response> {",
449
+ " if (!secrets && typeof env.SULUK_PRIVATE_KEY === \"string\") secrets = await loadEnv({ content: envText, privateKey: env.SULUK_PRIVATE_KEY });",
450
+ " const merged = secrets ? { ...secrets, ...env } : env;",
451
+ " return app.fetch(request, merged as Parameters<typeof app.fetch>[1], ctx);",
452
+ " },",
453
+ "};",
454
+ ];
455
+ return `// AUTO-GENERATED by @suluk/platform from platform.config.ts — the wired Hono entry. Edit freely.\n${imports.join("\n")}\n\n${body.join("\n")}\n\n${bootstrap.join("\n")}\n`;
343
456
  }
344
457
 
345
458
  function buildProvisionConfig(services: string[], catalog: Record<string, Service> = CORE_SERVICES): string {