@suluk/platform 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/platform",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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,7 +233,9 @@ 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
@@ -162,45 +250,44 @@ export function mergeGitignore(generated: string, existing: string | null): stri
162
250
  return `${base}\n${add.join("\n")}\n`;
163
251
  }
164
252
 
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. */
253
+ /** The encrypted-env preflight (run via `predev` / `bun run check`): is there a keypair, and is every REQUIRED secret set
254
+ * (encrypted) in the committed `.env`? A plaintext secret sitting in `.env` is flagged (encrypt it before you commit). */
167
255
  function buildEnvCheckScript(env: EnvVar[]): string {
168
256
  const required = env.filter((e) => e.secret && e.required).map((e) => e.name);
169
257
  return `#!/usr/bin/env bun
170
258
  /**
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.
259
+ * AUTO-GENERATED by @suluk/platform — the ENCRYPTED-env preflight (wired as \`predev\` + \`bun run check\`). Secrets live in the
260
+ * committed .env, ENCRYPTED with @suluk/env. This checks: a keypair exists, the REQUIRED secrets are set, and none is sitting
261
+ * in plaintext (which must never be committed). Non-secret config is in platform.config.ts \`vars\` → wrangler.toml [vars].
174
262
  */
175
- import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
263
+ import { existsSync, readFileSync } from "node:fs";
176
264
 
177
265
  const REQUIRED = ${JSON.stringify(required)};
178
- const ENV = ".env", TEMP = ".env.temp", EXAMPLE = ".env.example";
266
+ const ENV = ".env";
179
267
 
180
268
  const parse = (p: string): Record<string, string> => {
181
269
  const out: Record<string, string> = {};
182
270
  for (const line of readFileSync(p, "utf8").split("\\n")) {
183
271
  const m = line.match(/^\\s*([A-Z0-9_]+)\\s*=\\s*(.*)$/);
184
- if (m && m[2].trim()) out[m[1]] = m[2].trim();
272
+ if (m && m[2].trim()) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
185
273
  }
186
274
  return out;
187
275
  };
188
276
 
189
277
  const have = existsSync(ENV) ? parse(ENV) : {};
278
+ const isEncrypted = (v: string) => v.startsWith("encrypted:");
279
+ const fail = (msg: string) => { console.error("✗ " + msg); process.exit(1); };
280
+
281
+ if (!have.SULUK_PUBLIC_KEY) fail("no @suluk/env keypair — run \`bunx suluk-env keygen\` (creates SULUK_PUBLIC_KEY in .env + .env.keys).");
282
+
190
283
  const missing = REQUIRED.filter((k) => !have[k] && !process.env[k]);
284
+ if (missing.length) fail("missing required secret(s): " + missing.join(", ") + "\\n → set each: bunx suluk-env set KEY=value");
191
285
 
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
- }
286
+ const plaintext = Object.keys(have).filter((k) => k !== "SULUK_PUBLIC_KEY" && have[k] && !isEncrypted(have[k]));
287
+ if (plaintext.length) fail("PLAINTEXT secret(s) in .env (never commit these): " + plaintext.join(", ") + "\\n → encrypt: bunx suluk-env encrypt");
197
288
 
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);
289
+ console.log("✓ .env ready keypair present, required secrets set + encrypted.");
290
+ process.exit(0);
204
291
  `;
205
292
  }
206
293
 
@@ -217,10 +304,13 @@ export function buildPackageJson(name: string, services: string[], catalog: Reco
217
304
  type: "module",
218
305
  scripts: {
219
306
  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)
307
+ check: "bun run scripts/env-check.ts", // the encrypted-env preflight (keypair present? required secrets set + encrypted?)
221
308
  predev: "bun run scripts/env-check.ts", // runs automatically before `dev`
222
309
  dev: "wrangler dev",
223
310
  deploy: "wrangler deploy",
311
+ "env:keygen": "suluk-env keygen", // create the @suluk/env keypair (SULUK_PUBLIC_KEY → .env; private → .env.keys)
312
+ "env:set": "suluk-env set", // encrypt + add a secret: `bun run env:set BETTER_AUTH_SECRET=...`
313
+ "sync-secrets": "bun run scripts/sync-secrets.ts", // decrypt cloudflare-surfaced secrets → `wrangler secret put` each
224
314
  typecheck: "tsc --noEmit -p .",
225
315
  test: "bun test",
226
316
  },
@@ -295,7 +385,12 @@ function buildComponentsJson(): string {
295
385
  }
296
386
 
297
387
  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";'];
388
+ const imports = [
389
+ 'import { createApp } from "./app";',
390
+ 'import { loadEnv } from "@suluk/env";',
391
+ "// @ts-ignore — the committed, @suluk/env-encrypted .env, bundled as text (bun + wrangler text import).",
392
+ 'import envText from "../.env" with { type: "text" };',
393
+ ];
299
394
  const middleware: string[] = [];
300
395
  const routes: string[] = [];
301
396
  const hooksByService = wiring?.hooksByService ?? {};
@@ -339,7 +434,20 @@ function buildEntry(services: string[], opts?: Record<string, Record<string, unk
339
434
  });
340
435
  for (const line of groupImports(safeWireImports)) imports.push(line);
341
436
  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`;
437
+ // the @suluk/env bootstrap: the committed .env holds the app's secrets ENCRYPTED. If SULUK_PRIVATE_KEY is set (a wrangler
438
+ // secret), decrypt them into the request env on first use (the runtime path); otherwise this is a no-op and the secrets come
439
+ // from `wrangler secret put` (the `bun run sync-secrets` deploy path). Decrypt once per isolate (env is stable across requests).
440
+ const bootstrap = [
441
+ "let secrets: Record<string, string> | null = null;",
442
+ "export default {",
443
+ " async fetch(request: Request, env: Record<string, unknown>, ctx: ExecutionContext): Promise<Response> {",
444
+ " if (!secrets && typeof env.SULUK_PRIVATE_KEY === \"string\") secrets = await loadEnv({ content: envText, privateKey: env.SULUK_PRIVATE_KEY });",
445
+ " const merged = secrets ? { ...secrets, ...env } : env;",
446
+ " return app.fetch(request, merged as Parameters<typeof app.fetch>[1], ctx);",
447
+ " },",
448
+ "};",
449
+ ];
450
+ 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
451
  }
344
452
 
345
453
  function buildProvisionConfig(services: string[], catalog: Record<string, Service> = CORE_SERVICES): string {