@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 +1 -1
- package/src/catalog.ts +1 -1
- package/src/generate.ts +7 -4
- package/src/plan.ts +134 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/platform",
|
|
3
|
-
"version": "0.
|
|
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) —
|
|
50
|
-
//
|
|
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
|
|
58
|
-
["scripts/env-check.ts", plan.envCheck, true], // the
|
|
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`
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
166
|
-
*
|
|
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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
|
263
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
176
264
|
|
|
177
265
|
const REQUIRED = ${JSON.stringify(required)};
|
|
178
|
-
const ENV = ".env"
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
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 = [
|
|
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
|
-
|
|
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 {
|