@suluk/platform 0.4.1 → 0.5.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,35 +1,39 @@
1
1
  {
2
2
  "name": "@suluk/platform",
3
- "version": "0.4.1",
3
+ "version": "0.5.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
- "access": "public"
7
- },
8
- "license": "Apache-2.0",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
- "directory": "tooling/ts/packages/platform"
13
- },
14
- "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
- "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
- "type": "module",
17
- "files": ["src", "bin", "README.md"],
18
- "main": "src/index.ts",
19
- "bin": {
20
- "suluk-platform": "bin/platform.ts"
21
- },
22
- "exports": {
23
- ".": "./src/index.ts"
24
- },
25
- "dependencies": {
26
- "@suluk/provision": "^0.1.0"
27
- },
28
- "devDependencies": {
29
- "@types/bun": "latest"
30
- },
31
- "scripts": {
32
- "test": "bun test",
33
- "typecheck": "tsc --noEmit -p ."
34
- }
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/platform"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "files": [
18
+ "src",
19
+ "bin",
20
+ "README.md"
21
+ ],
22
+ "main": "src/index.ts",
23
+ "bin": {
24
+ "suluk-platform": "bin/platform.ts"
25
+ },
26
+ "exports": {
27
+ ".": "./src/index.ts"
28
+ },
29
+ "dependencies": {
30
+ "@suluk/provision": "^0.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "latest"
34
+ },
35
+ "scripts": {
36
+ "test": "bun test",
37
+ "typecheck": "tsc --noEmit -p ."
38
+ }
35
39
  }
package/src/generate.ts CHANGED
@@ -85,6 +85,12 @@ export async function generatePlatform(input: PlatformManifest | Platform, opts:
85
85
  log("▸ writing provision.config.ts");
86
86
  await opts.write("provision.config.ts", plan.provisionConfig);
87
87
  written.push("src/index.ts", "provision.config.ts");
88
+ // the bun MOCK-PROVIDER dev server — only when the manifest sets `local: true` (else undefined → not written).
89
+ if (plan.devEntry) {
90
+ log("▸ writing src/dev.ts");
91
+ await opts.write("src/dev.ts", plan.devEntry);
92
+ written.push("src/dev.ts");
93
+ }
88
94
 
89
95
  log(`✓ generated ${name}: ${plan.services.length} services. Next: bun install && suluk-provision apply`);
90
96
  return { plan, added, written };
package/src/manifest.ts CHANGED
@@ -22,6 +22,10 @@ export interface PlatformManifest {
22
22
  opts?: Record<string, Record<string, unknown>>;
23
23
  /** NON-SECRET config values → generated into `wrangler.toml` `[vars]`. SECRETS never go here (they live in `.env`). */
24
24
  vars?: Record<string, string>;
25
+ /** emit the MOCK-PROVIDER dev runtime: a `src/dev.ts` that runs the app under bun with a bun:sqlite DB + JSON-file KV +
26
+ * mocked providers when their keys are absent (mock-until-keyed), and the `dev` script pointed at it. Default false →
27
+ * the scaffold is byte-for-byte the C051 golden. */
28
+ local?: boolean;
25
29
  }
26
30
 
27
31
  // ── C053: the open system/brand surface ──────────────────────────────────────────────────────────────────────────────
@@ -62,6 +66,9 @@ export interface SystemManifest<T extends readonly ServiceRef[] = readonly Servi
62
66
  serviceOpts?: Partial<{ [K in T[number] as IdOf<K>]: SoOf<K> }>;
63
67
  /** inter-service composition edges (Phase 3). */
64
68
  wire?: WireDecl[];
69
+ /** emit the MOCK-PROVIDER dev runtime (a `src/dev.ts` bun server with a bun:sqlite DB + JSON KV + mocked providers when
70
+ * keys are absent). A SYSTEM-level property (the app structure), swappable per brand only if a brand overrides it. */
71
+ local?: boolean;
65
72
  }
66
73
 
67
74
  /** A BRAND — thin, swappable per deployment. Carries the app identity + the brand-facing opts (→ `[vars]`). */
package/src/plan.ts CHANGED
@@ -49,6 +49,9 @@ export interface PlatformPlan {
49
49
  /** the generated `.env` SCAFFOLD (committed) — a header + the setup steps, NO values. `generate` writes it only if absent
50
50
  * (never clobbering the operator's encrypted secrets). Secret VALUES are added encrypted via `suluk-env set`. */
51
51
  envScaffold: string;
52
+ /** the generated `src/dev.ts` — the bun MOCK-PROVIDER dev server (bun:sqlite DB + JSON KV + mocked providers when keys
53
+ * absent). Present ONLY when the manifest sets `local: true`; undefined otherwise (so the golden path is unchanged). */
54
+ devEntry?: string;
52
55
  }
53
56
 
54
57
  export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
@@ -66,18 +69,20 @@ export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
66
69
  const env = collectEnv(services, catalog);
67
70
  // resolve the wires (a `{system,brand}` platform may carry `wire`; a legacy manifest never does → no wiring → byte-identical).
68
71
  const wiring = resolveWiring(services, isPlatform(input) ? input.system.wire ?? [] : [], catalog);
72
+ // the MOCK-PROVIDER dev runtime is OPT-IN (`local: true`); off → every output below is byte-for-byte the C051 golden.
73
+ const local = manifest.local === true;
69
74
  return {
70
75
  services,
71
76
  // a service may override the registry it's pulled from (multi-registry); core services fall back to the system registry.
72
77
  adds: services.map((s) => `${catalog[s].registry ?? manifest.registry}/${s}`),
73
- entry: buildEntry(services, manifest.opts, wiring, catalog),
78
+ entry: buildEntry(services, manifest.opts, wiring, catalog, local),
74
79
  provisionConfig: buildProvisionConfig(services, catalog),
75
- packageJson: buildPackageJson(manifest.name, services, catalog),
80
+ packageJson: buildPackageJson(manifest.name, services, catalog, local),
76
81
  tsconfig: buildTsconfig(),
77
82
  componentsJson: buildComponentsJson(),
78
83
  envExample: buildEnvExample(env),
79
84
  wranglerToml: buildWranglerToml(manifest.name, services, env, manifest.vars ?? {}),
80
- gitignore: buildGitignore(),
85
+ gitignore: buildGitignore(local),
81
86
  envCheck: buildEnvCheckScript(env),
82
87
  envTs: buildEnvTs(env),
83
88
  syncSecrets: buildSyncSecrets(),
@@ -86,6 +91,7 @@ export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
86
91
  provisionScript: buildProvisionScript(env),
87
92
  mintTokens: buildMintTokens(env),
88
93
  envScaffold: buildEnvScaffold(env),
94
+ ...(local ? { devEntry: buildDevEntry(services) } : {}),
89
95
  };
90
96
  }
91
97
 
@@ -241,8 +247,9 @@ function buildEnvTemp(env: EnvVar[]): string {
241
247
  "# Provisioning creds (used to create infra + mint scoped tokens; the master is DELETED, never committed):",
242
248
  ...provisioningOf(env).filter((e) => !e.minted).map(line),
243
249
  "",
244
- "# Runtime secrets (encrypted into .env + committed; shipped to the Worker):",
245
- ...runtimeSecretsOf(env).map(line),
250
+ "# Runtime secrets (encrypted into .env + committed; shipped to the Worker). AUTO-GENERATED ones are NOT here — `provision`",
251
+ "# creates them: " + (runtimeSecretsOf(env).filter((e) => e.generated).map((e) => e.name).join(", ") || "(none)") + ".",
252
+ ...runtimeSecretsOf(env).filter((e) => !e.generated).map(line),
246
253
  "",
247
254
  ].join("\n");
248
255
  }
@@ -303,13 +310,16 @@ console.log("✓ scoped tokens ready (encrypted in .env).");
303
310
  */
304
311
  function buildProvisionScript(env: EnvVar[]): string {
305
312
  const ephemeral = ephemeralOf(env).map((e) => e.name);
313
+ const generated = env.filter((e) => e.generated).map((e) => e.name);
306
314
  return `#!/usr/bin/env bun
307
315
  // AUTO-GENERATED by @suluk/platform — stand up the infra + SEAL the secrets (@suluk/env encrypted-commit model). Run once
308
316
  // after filling .env.temp (or with an existing encrypted .env). Idempotent.
309
317
  import { existsSync, rmSync, readFileSync, writeFileSync } from "node:fs";
318
+ import { randomBytes } from "node:crypto";
310
319
  import { loadEnvFile, setVar } from "@suluk/env/node";
311
320
 
312
321
  const EPHEMERAL = ${JSON.stringify(ephemeral)}; // the CF master token(s): used to provision + mint, then DELETED (never committed)
322
+ const GENERATED = ${JSON.stringify(generated)}; // secrets the app creates itself (e.g. BETTER_AUTH_SECRET) — never operator-supplied
313
323
  const sh = async (cmd: string, args: string[]) => { const p = Bun.spawn([cmd, ...args], { stdout: "inherit", stderr: "inherit" }); if ((await p.exited) !== 0) { console.error(\`✗ \${cmd} \${args.join(" ")}\`); process.exit(1); } };
314
324
 
315
325
  // 1. keypair → the central ~/.suluk/settings.json (the private key never stays in the repo).
@@ -327,6 +337,12 @@ if (existsSync(".env.temp")) {
327
337
  await loadEnvFile({ override: true }); // decrypt everything into process.env
328
338
  if (!process.env.CLOUDFLARE_API_TOKEN || !process.env.CLOUDFLARE_ACCOUNT_ID) { console.error("✗ CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID missing — put them in .env.temp"); process.exit(1); }
329
339
 
340
+ // 2b. auto-generate any app-created secret (e.g. BETTER_AUTH_SECRET ← 32 random bytes) not already set — the operator never
341
+ // supplies these in .env.temp. Staged plaintext here, encrypted at step 5.
342
+ for (const name of GENERATED) {
343
+ if (!process.env[name]) { await setVar(name, randomBytes(32).toString("base64"), { plain: true }); console.log(\`✓ generated \${name}\`); }
344
+ }
345
+
330
346
  // 3. provision the infra (D1/KV — the C047 provision.config), 4. mint the scoped least-privilege tokens from the master.
331
347
  await sh("bunx", ["suluk-provision", "apply"]);
332
348
  await sh("bun", ["run", "scripts/mint-tokens.ts"]);
@@ -435,10 +451,11 @@ export function mergeWranglerToml(generated: string, existing: string | null): s
435
451
  .join("\n");
436
452
  }
437
453
 
438
- function buildGitignore(): string {
454
+ function buildGitignore(local = false): string {
439
455
  // NOTE: `.env` is NOT ignored — it is COMMITTED with its secret values ENCRYPTED (@suluk/env). The PRIVATE key
440
456
  // (`.env.keys`) is what must never be committed; that + `.env.temp`/`.dev.vars` are ignored.
441
- return ["node_modules/", ".env.keys", ".env.temp", ".dev.vars", ".wrangler/", "dist/", ""].join("\n");
457
+ // local mode also ignores `.suluk/` (the bun-dev sqlite DB + JSON KV — local dev data, never committed).
458
+ return ["node_modules/", ".env.keys", ".env.temp", ".dev.vars", ".wrangler/", "dist/", ...(local ? [".suluk/"] : []), ""].join("\n");
442
459
  }
443
460
 
444
461
  /** Merge the generated .gitignore into an existing one — APPEND any missing entries (never skip-if-present, so an app's
@@ -504,7 +521,7 @@ process.exit(0);
504
521
 
505
522
  /** The framework baseline package.json — name from the manifest, the union of BASE + each service's deps (versions
506
523
  * resolved: @suluk/* → "latest", ecosystem → pinned), + the toolchain devDeps + the regenerate/typecheck scripts. */
507
- export function buildPackageJson(name: string, services: string[], catalog: Record<string, Service> = CORE_SERVICES): string {
524
+ export function buildPackageJson(name: string, services: string[], catalog: Record<string, Service> = CORE_SERVICES, local = false): string {
508
525
  const deps = new Set<string>(BASE_DEPS);
509
526
  for (const s of services) for (const d of catalog[s]?.deps ?? []) deps.add(d);
510
527
  const dependencies: Record<string, string> = {};
@@ -516,8 +533,10 @@ export function buildPackageJson(name: string, services: string[], catalog: Reco
516
533
  scripts: {
517
534
  generate: "suluk-platform", // re-pull modules + rewrite the scaffold config + src/index.ts + provision.config.ts
518
535
  check: "bun run scripts/env-check.ts", // the encrypted-env preflight (keypair present? required secrets set + encrypted?)
519
- predev: "bun run scripts/env-check.ts", // runs automatically before `dev`
520
- dev: "wrangler dev",
536
+ // local mode: `dev` runs the bun mock-provider server (no keys needed → no env-check predev); `dev:cf` keeps wrangler.
537
+ ...(local ? {} : { predev: "bun run scripts/env-check.ts" }), // (non-local) runs automatically before `dev`
538
+ dev: local ? "bun run --hot src/dev.ts" : "wrangler dev",
539
+ ...(local ? { "dev:cf": "wrangler dev" } : {}),
521
540
  deploy: "wrangler deploy",
522
541
  "env:keygen": "suluk-env keygen", // create the @suluk/env keypair (SULUK_PUBLIC_KEY → .env; private → .env.keys)
523
542
  "link-key": "bun run scripts/link-key.ts", // register the private key in ~/.suluk/settings.json (the central store)
@@ -598,7 +617,7 @@ function buildComponentsJson(): string {
598
617
  );
599
618
  }
600
619
 
601
- function buildEntry(services: string[], opts?: Record<string, Record<string, unknown>>, wiring?: Wiring, catalog: Record<string, Service> = CORE_SERVICES): string {
620
+ function buildEntry(services: string[], opts?: Record<string, Record<string, unknown>>, wiring?: Wiring, catalog: Record<string, Service> = CORE_SERVICES, local = false): string {
602
621
  const imports = [
603
622
  'import { createApp } from "./app";',
604
623
  'import { loadEnv } from "@suluk/env";',
@@ -647,7 +666,10 @@ function buildEntry(services: string[], opts?: Record<string, Record<string, unk
647
666
  return false; // same symbol + module already imported → dedup
648
667
  });
649
668
  for (const line of groupImports(safeWireImports)) imports.push(line);
650
- const body = ["const app = createApp();", ...middleware, ...routes];
669
+ // local mode also EXPORTS the wired app so `src/dev.ts` can serve it under bun with mock bindings (the Worker `fetch`
670
+ // export is unchanged). Off → `const app` (byte-identical to the golden). The mock modules are imported ONLY by dev.ts,
671
+ // so `wrangler deploy` (bundling from `src/index.ts`) never pulls bun:sqlite into the Worker.
672
+ const body = [`${local ? "export const app" : "const app"} = createApp();`, ...middleware, ...routes];
651
673
  // the @suluk/env bootstrap: the committed .env holds the app's secrets ENCRYPTED. If SULUK_PRIVATE_KEY is set (a wrangler
652
674
  // secret), decrypt them into the request env on first use (the runtime path); otherwise this is a no-op and the secrets come
653
675
  // from `wrangler secret put` (the `bun run sync-secrets` deploy path). Decrypt once per isolate (env is stable across requests).
@@ -664,6 +686,54 @@ function buildEntry(services: string[], opts?: Record<string, Record<string, unk
664
686
  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`;
665
687
  }
666
688
 
689
+ /**
690
+ * `src/dev.ts` — the bun MOCK-PROVIDER dev server (emitted only when `local: true`). Runs the SAME wired app (imported from
691
+ * `src/index.ts`) under bun with a bun:sqlite `DB` facade + a JSON-file KV, so `bun run dev` needs no Cloudflare account and
692
+ * no wrangler. Mock-until-keyed: it decrypts the committed `.env` if the app has been provisioned (real HTTP providers), else
693
+ * every provider falls to its module's mock. The deployed Worker (`src/index.ts`) imports NONE of this — bun:sqlite stays out.
694
+ */
695
+ function buildDevEntry(services: string[]): string {
696
+ const usesKv = services.includes("rate-credit");
697
+ const kvImport = usesKv ? "jsonFileKvStore, " : "";
698
+ const kvBind = usesKv ? "\n RATE_CREDIT_KV: jsonFileKvStore(KV_PATH)," : "";
699
+ return `// AUTO-GENERATED by @suluk/platform — the bun MOCK-PROVIDER dev server. Runs the wired app under bun with a
700
+ // bun:sqlite DB + JSON-file KV, so \`bun run dev\` works with ZERO Cloudflare account and no wrangler. A provider goes REAL
701
+ // the moment its key is present (mock-until-keyed): add real keys to .env.temp + \`bun run provision\` and this file uses
702
+ // them. NOTE: src/index.ts (the deployed Worker) imports NONE of these mocks — bun:sqlite never enters the Worker bundle.
703
+ import { app } from "./index";
704
+ import { Database } from "bun:sqlite";
705
+ import { d1FromSqlite, ${kvImport}applyLocalSchema } from "@suluk/cloudflare/local";
706
+ import { loadEnvFile } from "@suluk/env/node";
707
+
708
+ const DB_PATH = process.env.SULUK_DB_PATH ?? ".suluk/dev.sqlite";${usesKv ? '\nconst KV_PATH = process.env.SULUK_KV_PATH ?? ".suluk/dev-kv.json";' : ""}
709
+ const PORT = Number(process.env.PORT ?? 8787);
710
+
711
+ const sqlite = new Database(DB_PATH, { create: true });
712
+ const tables = await applyLocalSchema(sqlite); // discover src/db/*.ts + create the tables from the drizzle schema
713
+ console.log(\`[suluk dev] sqlite \${DB_PATH} — \${tables.length} tables\`);
714
+
715
+ // Real secrets (if this app has been provisioned): decrypt the committed .env with the local private key. Fresh app / no
716
+ // key → {} → every provider mocks. Best-effort: a decryption failure never blocks the mock path.
717
+ let secrets: Record<string, string> = {};
718
+ try { secrets = await loadEnvFile(); } catch {}
719
+
720
+ // The request env: process.env < decrypted secrets < the mock bindings. DB/KV are always local (a bun process can't bind a
721
+ // remote D1/KV); the HTTP providers (Google/Stripe/Resend) use their real key when present, else their module's mock.
722
+ const env: Record<string, unknown> = {
723
+ ...process.env,
724
+ ...secrets,
725
+ DB: d1FromSqlite(sqlite),${kvBind}
726
+ };
727
+
728
+ const mocked = ["GOOGLE_CLIENT_ID", "STRIPE_SECRET_KEY", "RESEND_API_KEY"].filter((k) => !env[k]);
729
+ if (mocked.length) console.log(\`[suluk dev] mocked (no key): \${mocked.join(", ")}\`);
730
+
731
+ const ctx = { waitUntil() {}, passThroughOnException() {} } as unknown as ExecutionContext;
732
+ Bun.serve({ port: PORT, idleTimeout: 120, fetch: (req) => app.fetch(req, env as Parameters<typeof app.fetch>[1], ctx) });
733
+ console.log(\`[suluk dev] → http://localhost:\${PORT} (mock-until-keyed; provision to go live)\`);
734
+ `;
735
+ }
736
+
667
737
  function buildProvisionConfig(services: string[], catalog: Record<string, Service> = CORE_SERVICES): string {
668
738
  const frags = services.map((s) => catalog[s].provision).filter((p): p is NonNullable<typeof p> => !!p);
669
739
  const imports = frags.map((f) => `import { ${f.symbol} } from "${f.from}";`);
package/src/resolve.ts CHANGED
@@ -87,6 +87,7 @@ export function liftLegacy(m: PlatformManifest): Platform {
87
87
  services: m.services,
88
88
  ...(Object.keys(globalServiceOpts).length ? { globalServiceOpts } : {}),
89
89
  ...(m.opts && Object.keys(m.opts).length ? { serviceOpts: m.opts } : {}),
90
+ ...(m.local ? { local: true } : {}),
90
91
  },
91
92
  brand: {
92
93
  name: m.name,
@@ -107,5 +108,6 @@ export function liftSystemBrand(p: Platform): PlatformManifest {
107
108
  services,
108
109
  ...(Object.keys(opts).length ? { opts } : {}),
109
110
  ...(Object.keys(vars).length ? { vars } : {}),
111
+ ...(p.system.local ? { local: true } : {}),
110
112
  };
111
113
  }
package/src/service.ts CHANGED
@@ -40,6 +40,9 @@ export interface EnvVar {
40
40
  provisioning?: boolean;
41
41
  /** a scoped least-privilege token MINTED during provisioning (from the master), then kept ENCRYPTED in `.env`. `surface: "local"`. */
42
42
  minted?: boolean;
43
+ /** a random secret the provisioning flow AUTO-GENERATES (e.g. `BETTER_AUTH_SECRET` ← 32 random bytes) if not already set —
44
+ * so the operator never supplies it in `.env.temp`; it still lands ENCRYPTED in the committed `.env`. */
45
+ generated?: boolean;
43
46
  }
44
47
 
45
48
  /** The old catalog record — now a DERIVED VIEW of a {@link Service} (see {@link toCatalogEntry}); kept so `planPlatform`
@@ -191,7 +194,7 @@ export const authService = defineService({
191
194
  provision: { symbol: "authProvision", from: "./src/provision/auth" },
192
195
  deps: ["better-auth", "@better-auth/api-key", "@better-auth/passkey", "@suluk/better-auth"],
193
196
  env: [
194
- { name: "BETTER_AUTH_SECRET", required: true, secret: true, hint: "session-signing key — `openssl rand -base64 32`" },
197
+ { name: "BETTER_AUTH_SECRET", required: true, secret: true, generated: true, hint: "session-signing key — AUTO-GENERATED by `bun run provision` (32 random bytes); no need to supply it" },
195
198
  { name: "BETTER_AUTH_URL", hint: "your deployed origin, e.g. https://api.example.com" },
196
199
  { name: "GOOGLE_CLIENT_ID", secret: true, hint: "optional — enables Google sign-in" },
197
200
  { name: "GOOGLE_CLIENT_SECRET", secret: true, hint: "optional — pairs with GOOGLE_CLIENT_ID" },