@suluk/platform 0.1.7 → 0.1.9

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/bin/platform.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * --config <path> a different manifest
8
8
  */
9
9
  import { resolve, dirname } from "node:path";
10
- import { writeFile, mkdir } from "node:fs/promises";
10
+ import { writeFile, mkdir, readFile } from "node:fs/promises";
11
11
  import { generatePlatform } from "../src/generate";
12
12
  import type { PlatformManifest } from "../src/manifest";
13
13
 
@@ -33,4 +33,13 @@ const write = async (path: string, content: string): Promise<void> => {
33
33
  await writeFile(abs, content);
34
34
  };
35
35
 
36
- await generatePlatform(mod.default, { run, write, log: (m) => console.log(m) });
36
+ // read a file for the merge (null when absent), so a regenerate keeps package.json deps current without dropping app extras.
37
+ const read = async (path: string): Promise<string | null> => {
38
+ try {
39
+ return await readFile(resolve(process.cwd(), path), "utf8");
40
+ } catch {
41
+ return null;
42
+ }
43
+ };
44
+
45
+ await generatePlatform(mod.default, { run, write, read, log: (m) => console.log(m) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/platform",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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
@@ -11,41 +11,136 @@ export type Mount =
11
11
  | { kind: "route"; path: string; symbol: string; from: string } // e.g. `app.route("/credits", creditsRoutes())`
12
12
  | { kind: "dev" }; // dev/CI tooling (journeys, audit) — files only, no runtime mount, no provision fragment
13
13
 
14
+ /** An env var a module needs at runtime — drives the generated `.env.example` + the env-check preflight. Bindings (D1/KV)
15
+ * are NOT here (they live in wrangler.toml); only string env vars. */
16
+ export interface EnvVar {
17
+ name: string;
18
+ /** the app WON'T work without it (the "minimum keys") — the env-check requires a non-empty value before it's happy. */
19
+ required?: boolean;
20
+ /** a credential (never commit) — shown commented in `.env.example` + flagged in the temp file. */
21
+ secret?: boolean;
22
+ /** a one-line hint shown as a comment in `.env.example`. */
23
+ hint?: string;
24
+ }
25
+
14
26
  export interface CatalogEntry {
15
27
  /** how it mounts into the entry. */
16
28
  mount: Mount;
17
29
  /** the provision fragment export, if any (`InstanceSpec[]`). */
18
30
  provision?: { symbol: string; from: string };
31
+ /** the module's npm deps BEYOND the always-present base (see BASE_DEPS) — its @suluk/* logic packages + any extras
32
+ * (zod, better-auth). `shadcn add` also installs these; declaring them here lets the generator emit a complete,
33
+ * from-the-manifest package.json (so platform.config.ts is the only hand-authored surface). Kept in sync with the
34
+ * registry item's `dependencies`. */
35
+ deps?: string[];
36
+ /** the runtime env vars this module reads — the generator unions them into `.env.example` + the env-check preflight. */
37
+ env?: EnvVar[];
19
38
  }
20
39
 
21
40
  export const CATALOG: Record<string, CatalogEntry> = {
22
- app: { mount: { kind: "base" } },
23
- auth: { mount: { kind: "middleware", symbol: "mountAuthRoutes", from: "./auth" }, provision: { symbol: "authProvision", from: "./src/provision/auth" } },
41
+ app: { mount: { kind: "base" }, env: [{ name: "TRUSTED_ORIGINS", hint: "comma-separated browser origins allowed on /api/* (CORS)" }] },
42
+ auth: {
43
+ mount: { kind: "middleware", symbol: "mountAuthRoutes", from: "./auth" },
44
+ provision: { symbol: "authProvision", from: "./src/provision/auth" },
45
+ deps: ["better-auth", "@better-auth/api-key", "@better-auth/passkey", "@suluk/better-auth"],
46
+ env: [
47
+ { name: "BETTER_AUTH_SECRET", required: true, secret: true, hint: "session-signing key — `openssl rand -base64 32`" },
48
+ { name: "BETTER_AUTH_URL", hint: "your deployed origin, e.g. https://api.example.com" },
49
+ { name: "GOOGLE_CLIENT_ID", secret: true, hint: "optional — enables Google sign-in" },
50
+ { name: "GOOGLE_CLIENT_SECRET", secret: true, hint: "optional — pairs with GOOGLE_CLIENT_ID" },
51
+ ],
52
+ },
24
53
  // the contract is a MIDDLEWARE mount: it installs the scope gate (enforceApiKeyScope) + GET /api/openapi.json. Place it
25
54
  // after `auth` in the manifest so the gate runs after identity/apiKeyAuth set keyId/scopes. Derived + stateless.
26
- contract: { mount: { kind: "middleware", symbol: "mountContract", from: "./routes/contract" } },
55
+ contract: { mount: { kind: "middleware", symbol: "mountContract", from: "./routes/contract" }, deps: ["@suluk/hono", "zod"] },
27
56
  // the API-as-MCP server + OAuth discovery + connections — a middleware mount (registers /api/mcp + /.well-known/*).
28
- mcp: { mount: { kind: "middleware", symbol: "mountMcp", from: "./routes/mcp" }, provision: { symbol: "mcpProvision", from: "./src/provision/mcp" } },
57
+ mcp: { mount: { kind: "middleware", symbol: "mountMcp", from: "./routes/mcp" }, provision: { symbol: "mcpProvision", from: "./src/provision/mcp" }, deps: ["@suluk/mcp", "better-auth"] },
29
58
  // feature routes mount under /api/* — where the caller-resolution + cors + rate-limit middleware live (toolfactory parity).
30
- credits: { mount: { kind: "route", path: "/api/credits", symbol: "creditsRoutes", from: "./routes/credits" }, provision: { symbol: "creditsProvision", from: "./src/provision/credits" } },
31
- keys: { mount: { kind: "route", path: "/api/keys", symbol: "keysRoutes", from: "./routes/keys" }, provision: { symbol: "keysProvision", from: "./src/provision/keys" } },
32
- billing: { mount: { kind: "route", path: "/api/billing", symbol: "billingRoutes", from: "./routes/billing" }, provision: { symbol: "billingProvision", from: "./src/provision/billing" } },
33
- cost: { mount: { kind: "route", path: "/api/cost", symbol: "costRoutes", from: "./routes/cost" }, provision: { symbol: "costProvision", from: "./src/provision/cost" } },
34
- erasure: { mount: { kind: "route", path: "/api/erasure", symbol: "erasureRoutes", from: "./routes/erasure" }, provision: { symbol: "erasureProvision", from: "./src/provision/erasure" } },
35
- email: { mount: { kind: "route", path: "/api/email", symbol: "emailRoutes", from: "./routes/email" } }, // stateless binding — no provision fragment (C052)
36
- webhooks: { mount: { kind: "route", path: "/api/webhooks", symbol: "webhooksRoutes", from: "./routes/webhooks" }, provision: { symbol: "webhooksProvision", from: "./src/provision/webhooks" } },
59
+ credits: { mount: { kind: "route", path: "/api/credits", symbol: "creditsRoutes", from: "./routes/credits" }, provision: { symbol: "creditsProvision", from: "./src/provision/credits" }, deps: ["@suluk/credits"] },
60
+ keys: { mount: { kind: "route", path: "/api/keys", symbol: "keysRoutes", from: "./routes/keys" }, provision: { symbol: "keysProvision", from: "./src/provision/keys" }, deps: ["@suluk/keys"] },
61
+ billing: {
62
+ mount: { kind: "route", path: "/api/billing", symbol: "billingRoutes", from: "./routes/billing" },
63
+ provision: { symbol: "billingProvision", from: "./src/provision/billing" },
64
+ deps: ["@suluk/billing", "@suluk/payments", "@suluk/credits"],
65
+ env: [
66
+ { name: "STRIPE_SECRET_KEY", required: true, secret: true, hint: "your Stripe secret key" },
67
+ { name: "STRIPE_PUBLISHABLE_KEY", hint: "returned by GET /api/billing/payment-config" },
68
+ ],
69
+ },
70
+ cost: { mount: { kind: "route", path: "/api/cost", symbol: "costRoutes", from: "./routes/cost" }, provision: { symbol: "costProvision", from: "./src/provision/cost" }, deps: ["@suluk/cost"] },
71
+ erasure: { mount: { kind: "route", path: "/api/erasure", symbol: "erasureRoutes", from: "./routes/erasure" }, provision: { symbol: "erasureProvision", from: "./src/provision/erasure" }, deps: ["@suluk/better-auth"] },
72
+ email: {
73
+ mount: { kind: "route", path: "/api/email", symbol: "emailRoutes", from: "./routes/email" }, // stateless binding — no provision fragment (C052)
74
+ deps: ["@suluk/email"],
75
+ env: [
76
+ { name: "RESEND_API_KEY", secret: true, hint: "omit → the console provider (dev)" },
77
+ { name: "EMAIL_FROM", hint: "the from-address" },
78
+ { name: "BRAND_NAME", hint: "email template branding" },
79
+ { name: "BASE_URL", hint: "email link base" },
80
+ { name: "ENVIRONMENT", hint: '"production" → use Resend (else console)' },
81
+ ],
82
+ },
83
+ webhooks: {
84
+ mount: { kind: "route", path: "/api/webhooks", symbol: "webhooksRoutes", from: "./routes/webhooks" },
85
+ provision: { symbol: "webhooksProvision", from: "./src/provision/webhooks" },
86
+ deps: ["@suluk/payments"],
87
+ env: [{ name: "STRIPE_WEBHOOK_SECRET", required: true, secret: true, hint: "verifies inbound Stripe events (POST /api/webhooks/stripe)" }],
88
+ },
37
89
  // cross-cutting MIDDLEWARE (apply globally via app.use, emitted before any route) — not routed resources.
38
- "rate-limit": { mount: { kind: "middleware", symbol: "mountRateLimit", from: "./services/rate-limit" } },
39
- "rate-credit": { mount: { kind: "middleware", symbol: "mountRateCredit", from: "./services/rate-credit" } }, // credit-backed free-tier bucket (KV binding)
40
- i18n: { mount: { kind: "middleware", symbol: "mountI18n", from: "./services/i18n" } },
41
- reference: { mount: { kind: "route", path: "/api/reference", symbol: "referenceRoutes", from: "./routes/reference" } }, // derived doc render — no provision
42
- admin: { mount: { kind: "route", path: "/api/admin", symbol: "adminRoutes", from: "./routes/admin" } }, // reads existing tables — no provision
90
+ "rate-limit": { mount: { kind: "middleware", symbol: "mountRateLimit", from: "./services/rate-limit" }, deps: ["@suluk/hono"] },
91
+ "rate-credit": { mount: { kind: "middleware", symbol: "mountRateCredit", from: "./services/rate-credit" } }, // credit-backed free-tier bucket (KV binding); base deps cover it
92
+ i18n: { mount: { kind: "middleware", symbol: "mountI18n", from: "./services/i18n" }, deps: ["@suluk/i18n"] },
93
+ reference: { mount: { kind: "route", path: "/api/reference", symbol: "referenceRoutes", from: "./routes/reference" }, deps: ["@suluk/reference"] }, // derived doc render — no provision
94
+ admin: { mount: { kind: "route", path: "/api/admin", symbol: "adminRoutes", from: "./routes/admin" }, deps: ["@suluk/credits"] }, // reads existing tables — no provision
43
95
  logs: { mount: { kind: "route", path: "/api/logs", symbol: "logsRoutes", from: "./routes/logs" }, provision: { symbol: "logsProvision", from: "./src/provision/logs" } },
44
96
  // dev/CI tooling — pulled in as files, no runtime mount, no provision fragment.
45
- journeys: { mount: { kind: "dev" } },
46
- audit: { mount: { kind: "dev" } },
97
+ journeys: { mount: { kind: "dev" }, deps: ["@suluk/journeys"] },
98
+ audit: { mount: { kind: "dev" }, deps: ["@suluk/cockpit", "@suluk/harden"] },
99
+ };
100
+
101
+ /**
102
+ * The always-present framework deps (every generated app: the Effect services + Hono entry + the merged provision.config
103
+ * that imports mergeProvision from @suluk/platform + defineProvision from @suluk/provision). Union'd with each service's
104
+ * `deps` to build package.json.
105
+ */
106
+ export const BASE_DEPS = ["@suluk/platform", "@suluk/provision", "@suluk/core", "effect", "hono", "drizzle-orm"];
107
+
108
+ /** Pinned ranges for the NON-@suluk ecosystem deps — the single place they're kept current for every generated app.
109
+ * @suluk/* are NOT here: they resolve to "latest" so a package fix flows to the app via `bun update` (the C052 payoff). */
110
+ export const ECOSYSTEM_VERSIONS: Record<string, string> = {
111
+ "better-auth": "^1.0.0",
112
+ "@better-auth/api-key": "^1.0.0",
113
+ "@better-auth/passkey": "^1.0.0",
114
+ "drizzle-orm": "^0.45.2",
115
+ effect: "^3.0.0",
116
+ hono: "^4.0.0",
117
+ zod: "^4.0.0",
47
118
  };
48
119
 
120
+ /** The generated app's devDeps (the Workers + TS toolchain). */
121
+ export const DEV_DEPS: Record<string, string> = {
122
+ "@cloudflare/workers-types": "^4.20260701.1",
123
+ "@types/node": "^26.0.1",
124
+ typescript: "^6.0.3",
125
+ wrangler: "^4.0.0",
126
+ };
127
+
128
+ /** Resolve a dep to its version: an @suluk/* package → "latest" (fixes flow via `bun update`); a known ecosystem dep →
129
+ * its pinned range; anything else → "latest" (a best-effort default). */
130
+ export function resolveVersion(dep: string): string {
131
+ if (dep.startsWith("@suluk/")) return "latest";
132
+ return ECOSYSTEM_VERSIONS[dep] ?? "latest";
133
+ }
134
+
135
+ /** The env vars the selected services need, de-duped by name (first declaration wins). Split with `.secret` into the
136
+ * `.env` secrets (the .env.temp lifecycle) vs the non-secret CONFIG (defined in platform.config.ts `vars` → wrangler `[vars]`). */
137
+ export function collectEnv(services: string[]): EnvVar[] {
138
+ const seen = new Set<string>();
139
+ const out: EnvVar[] = [];
140
+ for (const s of services) for (const e of CATALOG[s]?.env ?? []) if (!seen.has(e.name)) (seen.add(e.name), out.push(e));
141
+ return out;
142
+ }
143
+
49
144
  /** app + auth always come first (the base + the user/apikey tables others reference); the rest keep manifest order. */
50
145
  export function orderServices(services: string[]): string[] {
51
146
  const want = new Set(services);
package/src/generate.ts CHANGED
@@ -5,13 +5,17 @@
5
5
  * testable. Stops short of `provision apply` — that's a live infra op the operator triggers.
6
6
  */
7
7
  import type { PlatformManifest } from "./manifest";
8
- import { planPlatform, type PlatformPlan } from "./plan";
8
+ import { planPlatform, mergePackageJson, mergeWranglerToml, type PlatformPlan } from "./plan";
9
9
 
10
10
  export interface GenerateOptions {
11
11
  /** run a command — the CLI spawns `bunx shadcn add <ref>`; a test records. */
12
12
  run: (cmd: string, args: string[]) => Promise<void>;
13
13
  /** write a file (path relative to the target cwd). */
14
14
  write: (path: string, content: string) => Promise<void>;
15
+ /** read a file (null when absent) — used to MERGE the generated package.json with the app's existing one (so app-added
16
+ * deps/scripts survive a regenerate) and to leave an existing tsconfig/components.json untouched. Optional: without it,
17
+ * the config files are written as the fresh baseline. */
18
+ read?: (path: string) => Promise<string | null>;
15
19
  log?: (msg: string) => void;
16
20
  }
17
21
 
@@ -23,17 +27,51 @@ export interface GenerateResult {
23
27
 
24
28
  export async function generatePlatform(manifest: PlatformManifest, opts: GenerateOptions): Promise<GenerateResult> {
25
29
  const log = opts.log ?? (() => {});
30
+ const read = opts.read ?? (async () => null);
26
31
  const plan = planPlatform(manifest);
32
+ const written: string[] = [];
33
+
34
+ // 1) the scaffold CONFIG first — so `shadcn add` has a package.json to install into + a components.json to resolve
35
+ // targets against. package.json MERGES with any existing (app deps/scripts survive; @suluk/* stay "latest"). An
36
+ // existing tsconfig/components.json is left as-is (an app may have customized them).
37
+ const existingPkg = await read("package.json");
38
+ log("▸ writing package.json");
39
+ await opts.write("package.json", mergePackageJson(plan.packageJson, existingPkg));
40
+ written.push("package.json");
41
+ // wrangler.toml MERGES (preserve the operator's provisioned binding ids across a regen); .env.example + the env-check
42
+ // preflight always (re)written (a template + a script, no secrets); tsconfig/components.json/.gitignore left if present.
43
+ log("▸ writing wrangler.toml");
44
+ await opts.write("wrangler.toml", mergeWranglerToml(plan.wranglerToml, await read("wrangler.toml")));
45
+ written.push("wrangler.toml");
46
+ for (const [file, content, always] of [
47
+ ["tsconfig.json", plan.tsconfig, false],
48
+ ["components.json", plan.componentsJson, false],
49
+ [".gitignore", plan.gitignore, false],
50
+ [".env.example", plan.envExample, true], // a checked-in template (no values) — keep it current
51
+ ["scripts/env-check.ts", plan.envCheck, true], // the .env.temp lifecycle preflight
52
+ ] as const) {
53
+ if (always || (await read(file)) == null) {
54
+ log(`▸ writing ${file}`);
55
+ await opts.write(file, content);
56
+ written.push(file);
57
+ }
58
+ }
59
+
60
+ // 2) the module code — shadcn add pulls each module's files + resolves registryDependencies (deps already in package.json).
27
61
  const added: string[] = [];
28
62
  for (const add of plan.adds) {
29
63
  log(`▸ shadcn add ${add}`);
30
64
  await opts.run("bunx", ["shadcn@latest", "add", add, "--yes"]);
31
65
  added.push(add);
32
66
  }
67
+
68
+ // 3) the generated glue.
33
69
  log("▸ writing src/index.ts");
34
70
  await opts.write("src/index.ts", plan.entry);
35
71
  log("▸ writing provision.config.ts");
36
72
  await opts.write("provision.config.ts", plan.provisionConfig);
73
+ written.push("src/index.ts", "provision.config.ts");
74
+
37
75
  log(`✓ generated ${manifest.name}: ${plan.services.length} services. Next: bun install && suluk-provision apply`);
38
- return { plan, added, written: ["src/index.ts", "provision.config.ts"] };
76
+ return { plan, added, written };
39
77
  }
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * "credits", "billing"]` → a whole backend. The generated `provision.config.ts` imports `mergeProvision` from here.
6
6
  */
7
7
  export { definePlatform, type PlatformManifest } from "./manifest";
8
- export { CATALOG, orderServices, type CatalogEntry, type Mount } from "./catalog";
8
+ export { CATALOG, orderServices, collectEnv, resolveVersion, BASE_DEPS, ECOSYSTEM_VERSIONS, DEV_DEPS, type CatalogEntry, type Mount, type EnvVar } from "./catalog";
9
9
  export { mergeProvision } from "./merge";
10
- export { planPlatform, type PlatformPlan } from "./plan";
10
+ export { planPlatform, buildPackageJson, mergePackageJson, mergeWranglerToml, type PlatformPlan } from "./plan";
11
11
  export { generatePlatform, type GenerateOptions, type GenerateResult } from "./generate";
package/src/manifest.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  /**
2
- * The platform manifest (C051) — the one author-facing document. Name the registry + the services you want; the generator
3
- * compiles it to a shadcn-add list + a wired Hono entry + a merged provision.config. The higher-level surface over C047's
4
- * provision.config: you say "auth, credits, billing" and the catalog knows each one's component + provision fragment.
2
+ * The platform manifest (C051) — the ONLY author-facing document. Name the registry + the services you want; the generator
3
+ * compiles it to EVERYTHING: the shadcn-add list, the wired Hono entry, the merged provision.config, AND the scaffold config
4
+ * (package.json with each module's deps — @suluk/* on "latest" so fixes flow via `bun update`, ecosystem pinned; plus
5
+ * tsconfig.json + components.json). `platform.config.ts` is the single surface; regenerating keeps deps current + preserves
6
+ * any deps/scripts you added. The higher-level surface over C047's provision.config: you say "auth, credits, billing" and
7
+ * the catalog knows each one's component + provision fragment + npm deps.
5
8
  */
6
9
  export interface PlatformManifest {
7
10
  /** the app/repo name (used in the generated scaffold). */
@@ -17,6 +20,12 @@ export interface PlatformManifest {
17
20
  * `mountAuthRoutes(app, {...})`. Only JSON-safe values (no functions/env-refs — edit the generated entry for those).
18
21
  */
19
22
  opts?: Record<string, Record<string, unknown>>;
23
+ /**
24
+ * NON-SECRET config values (`BASE_URL`, `EMAIL_FROM`, `TRUSTED_ORIGINS`, `ENVIRONMENT`, `STRIPE_PUBLISHABLE_KEY`, …) —
25
+ * defined HERE, in the committed manifest, and generated into `wrangler.toml` `[vars]`. SECRETS never go here; they live
26
+ * in `.env` (see the generated `.env.example` + the env-check preflight). Keyed by the env var name.
27
+ */
28
+ vars?: Record<string, string>;
20
29
  }
21
30
 
22
31
  /** Validate + return the manifest (throws on an empty service list). */
package/src/plan.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * character.
5
5
  */
6
6
  import type { PlatformManifest } from "./manifest";
7
- import { CATALOG, orderServices } from "./catalog";
7
+ import { CATALOG, orderServices, collectEnv, BASE_DEPS, DEV_DEPS, resolveVersion, type EnvVar } from "./catalog";
8
8
 
9
9
  export interface PlatformPlan {
10
10
  services: string[];
@@ -14,20 +14,261 @@ export interface PlatformPlan {
14
14
  entry: string;
15
15
  /** the generated `provision.config.ts` content. */
16
16
  provisionConfig: string;
17
+ /** the generated `package.json` content (the FRAMEWORK baseline — `generate` merges it with any existing so app-added
18
+ * deps/scripts survive). @suluk/* on "latest" so fixes flow via `bun update`; ecosystem deps on pinned ranges. */
19
+ packageJson: string;
20
+ /** the generated `tsconfig.json` content (the Workers + TS config; test files excluded from the build). */
21
+ tsconfig: string;
22
+ /** the generated `components.json` content (so `shadcn add` resolves the file targets). */
23
+ componentsJson: string;
24
+ /** the generated `.env.example` — the SECRET keys the selected services need (non-secrets live in the manifest `vars`). */
25
+ envExample: string;
26
+ /** the generated `wrangler.toml` — `[vars]` from the manifest's non-secret config + the D1/KV binding placeholders. */
27
+ wranglerToml: string;
28
+ /** the generated `.gitignore` (ignores `.env`, `.env.temp`, `.dev.vars`, …). */
29
+ gitignore: string;
30
+ /** the generated `scripts/env-check.ts` — the `.env.temp` lifecycle preflight (create when secrets missing, delete when ready). */
31
+ envCheck: string;
17
32
  }
18
33
 
19
34
  export function planPlatform(manifest: PlatformManifest): PlatformPlan {
20
35
  const services = orderServices(manifest.services);
21
36
  const unknown = services.filter((s) => !CATALOG[s]);
22
37
  if (unknown.length) throw new Error(`platform: unknown service(s) [${unknown.join(", ")}] — not in the catalog`);
38
+ const env = collectEnv(services);
23
39
  return {
24
40
  services,
25
41
  adds: services.map((s) => `${manifest.registry}/${s}`),
26
42
  entry: buildEntry(services, manifest.opts),
27
43
  provisionConfig: buildProvisionConfig(services),
44
+ packageJson: buildPackageJson(manifest.name, services),
45
+ tsconfig: buildTsconfig(),
46
+ componentsJson: buildComponentsJson(),
47
+ envExample: buildEnvExample(env),
48
+ wranglerToml: buildWranglerToml(manifest.name, services, env, manifest.vars ?? {}),
49
+ gitignore: buildGitignore(),
50
+ envCheck: buildEnvCheckScript(env),
28
51
  };
29
52
  }
30
53
 
54
+ /** The SECRET env keys → `.env.example` (required uncommented `KEY=`, optional commented `# KEY=`), each with its hint.
55
+ * Non-secret config is NOT here — it's in the manifest `vars` → wrangler `[vars]`. Safe to commit (no values). */
56
+ function buildEnvExample(env: EnvVar[]): string {
57
+ const secrets = env.filter((e) => e.secret);
58
+ const line = (e: EnvVar, commented: boolean) => `${commented ? "# " : ""}${e.name}=${e.hint ? ` # ${e.hint}` : ""}`;
59
+ const required = secrets.filter((e) => e.required);
60
+ const optional = secrets.filter((e) => !e.required);
61
+ return [
62
+ "# .env — SECRET keys (generated from platform.config.ts). Copy the values in; never commit this file.",
63
+ "# Non-secret config lives in platform.config.ts `vars` (→ wrangler.toml [vars]), NOT here.",
64
+ "",
65
+ "# Required — the app won't start without these:",
66
+ ...required.map((e) => line(e, false)),
67
+ "",
68
+ "# Optional:",
69
+ ...optional.map((e) => line(e, true)),
70
+ "",
71
+ ].join("\n");
72
+ }
73
+
74
+ /** The `wrangler.toml` — `[vars]` from the manifest's non-secret config (unset ones commented with a hint) + the D1 binding
75
+ * (always) + a KV binding when rate-credit is selected. `generate` merges it so provisioned binding ids survive a regen. */
76
+ function buildWranglerToml(name: string, services: string[], env: EnvVar[], vars: Record<string, string>): string {
77
+ const nonSecret = env.filter((e) => !e.secret);
78
+ const varLines = nonSecret.map((e) =>
79
+ vars[e.name] !== undefined
80
+ ? `${e.name} = ${JSON.stringify(vars[e.name])}`
81
+ : `# ${e.name} = "" # ${e.hint ?? "set in platform.config.ts `vars`"}`,
82
+ );
83
+ const kv: string[] = [];
84
+ if (services.includes("rate-credit")) kv.push('\n[[kv_namespaces]]\nbinding = "RATE_CREDIT_KV"\nid = "" # ← `wrangler kv namespace create RATE_CREDIT_KV`');
85
+ if (services.includes("rate-limit")) kv.push('\n[[kv_namespaces]]\nbinding = "RATE_LIMIT_KV"\nid = "" # optional durable rate-limit store');
86
+ return [
87
+ `# AUTO-GENERATED by @suluk/platform — [vars] come from platform.config.ts \`vars\`; the binding ids are yours (from`,
88
+ `# \`suluk-provision apply\` / \`wrangler kv namespace create\`) and are PRESERVED across a regenerate.`,
89
+ `name = ${JSON.stringify(name)}`,
90
+ `main = "src/index.ts"`,
91
+ `compatibility_date = "2026-07-01"`,
92
+ "",
93
+ "[vars]",
94
+ ...varLines,
95
+ "",
96
+ "[[d1_databases]]",
97
+ `binding = "DB"`,
98
+ `database_name = ${JSON.stringify(name)}`,
99
+ `database_id = "" # ← the id \`suluk-provision apply\` lands`,
100
+ ...kv,
101
+ "",
102
+ ].join("\n");
103
+ }
104
+
105
+ /** Preserve the operator's provisioned binding ids (keyed by `binding = "NAME"`) across a wrangler.toml regenerate. */
106
+ export function mergeWranglerToml(generated: string, existing: string | null): string {
107
+ if (!existing) return generated;
108
+ const ids = new Map<string, string>();
109
+ let cur: string | null = null;
110
+ for (const l of existing.split("\n")) {
111
+ const b = l.match(/^\s*binding\s*=\s*"([^"]+)"/);
112
+ if (b) {
113
+ cur = b[1];
114
+ continue;
115
+ }
116
+ const id = l.match(/^\s*(?:database_id|id)\s*=\s*"([^"]+)"/);
117
+ if (id && cur && id[1]) ids.set(cur, id[1]);
118
+ }
119
+ if (!ids.size) return generated;
120
+ cur = null;
121
+ return generated
122
+ .split("\n")
123
+ .map((l) => {
124
+ const b = l.match(/^\s*binding\s*=\s*"([^"]+)"/);
125
+ if (b) {
126
+ cur = b[1];
127
+ return l;
128
+ }
129
+ const m = l.match(/^(\s*(?:database_id|id)\s*=\s*)""(.*)$/);
130
+ if (m && cur && ids.has(cur)) return `${m[1]}${JSON.stringify(ids.get(cur))}${m[2]}`;
131
+ return l;
132
+ })
133
+ .join("\n");
134
+ }
135
+
136
+ function buildGitignore(): string {
137
+ return ["node_modules/", ".env", ".env.temp", ".dev.vars", ".wrangler/", "dist/", ""].join("\n");
138
+ }
139
+
140
+ /** The `.env.temp` lifecycle preflight (run via `predev` / `bun run check`): if every REQUIRED secret is present (in `.env`
141
+ * or the process env), delete `.env.temp`; else write `.env.temp` from `.env.example` + report the missing keys + fail. */
142
+ function buildEnvCheckScript(env: EnvVar[]): string {
143
+ const required = env.filter((e) => e.secret && e.required).map((e) => e.name);
144
+ return `#!/usr/bin/env bun
145
+ /**
146
+ * AUTO-GENERATED by @suluk/platform — the .env lifecycle. Wired as \`predev\` (runs before \`dev\`) + \`bun run check\`.
147
+ * The MINIMUM secret keys this app needs come from platform.config.ts (the selected modules). Non-secret config is in
148
+ * the manifest \`vars\` → wrangler.toml [vars], not here.
149
+ */
150
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
151
+
152
+ const REQUIRED = ${JSON.stringify(required)};
153
+ const ENV = ".env", TEMP = ".env.temp", EXAMPLE = ".env.example";
154
+
155
+ const parse = (p: string): Record<string, string> => {
156
+ const out: Record<string, string> = {};
157
+ for (const line of readFileSync(p, "utf8").split("\\n")) {
158
+ const m = line.match(/^\\s*([A-Z0-9_]+)\\s*=\\s*(.*)$/);
159
+ if (m && m[2].trim()) out[m[1]] = m[2].trim();
160
+ }
161
+ return out;
162
+ };
163
+
164
+ const have = existsSync(ENV) ? parse(ENV) : {};
165
+ const missing = REQUIRED.filter((k) => !have[k] && !process.env[k]);
166
+
167
+ if (missing.length === 0) {
168
+ if (existsSync(TEMP)) { rmSync(TEMP); console.log("✓ .env ready — removed .env.temp"); }
169
+ else console.log("✓ .env ready (all required secrets present).");
170
+ process.exit(0);
171
+ }
172
+
173
+ // not ready → drop a fill-me-in template so you can see exactly what's needed.
174
+ if (existsSync(EXAMPLE)) writeFileSync(TEMP, readFileSync(EXAMPLE, "utf8"));
175
+ console.error("✗ missing required secret(s): " + missing.join(", "));
176
+ console.error(" → wrote .env.temp — fill the values in and save it as .env (or inject the secrets another way).");
177
+ console.error(" .env.temp auto-deletes once .env has every required secret.");
178
+ process.exit(1);
179
+ `;
180
+ }
181
+
182
+ /** The framework baseline package.json — name from the manifest, the union of BASE + each service's deps (versions
183
+ * resolved: @suluk/* → "latest", ecosystem → pinned), + the toolchain devDeps + the regenerate/typecheck scripts. */
184
+ export function buildPackageJson(name: string, services: string[]): string {
185
+ const deps = new Set<string>(BASE_DEPS);
186
+ for (const s of services) for (const d of CATALOG[s]?.deps ?? []) deps.add(d);
187
+ const dependencies: Record<string, string> = {};
188
+ for (const d of [...deps].sort()) dependencies[d] = resolveVersion(d);
189
+ const pkg = {
190
+ name,
191
+ private: true,
192
+ type: "module",
193
+ scripts: {
194
+ generate: "suluk-platform", // re-pull modules + rewrite the scaffold config + src/index.ts + provision.config.ts
195
+ check: "bun run scripts/env-check.ts", // the .env.temp lifecycle (fails if a required secret is missing)
196
+ predev: "bun run scripts/env-check.ts", // runs automatically before `dev`
197
+ dev: "wrangler dev",
198
+ deploy: "wrangler deploy",
199
+ typecheck: "tsc --noEmit -p .",
200
+ test: "bun test",
201
+ },
202
+ dependencies,
203
+ devDependencies: { ...DEV_DEPS },
204
+ };
205
+ return JSON.stringify(pkg, null, 2) + "\n";
206
+ }
207
+
208
+ /**
209
+ * Merge the generated framework baseline package.json with the app's EXISTING one (if any). The baseline WINS for the
210
+ * framework + module deps (so `@suluk/*` stay `"latest"` and the ecosystem stays on its pinned range — deps stay current
211
+ * across a regenerate), while any deps / scripts / top-level fields the app added are PRESERVED. No existing ⇒ the baseline
212
+ * verbatim. Keys are sorted for stable output. Pure + testable.
213
+ */
214
+ export function mergePackageJson(baselineJson: string, existingJson: string | null): string {
215
+ if (!existingJson) return baselineJson;
216
+ const baseline = JSON.parse(baselineJson) as Record<string, unknown>;
217
+ let existing: Record<string, unknown>;
218
+ try {
219
+ existing = JSON.parse(existingJson) as Record<string, unknown>;
220
+ } catch {
221
+ return baselineJson; // an unparseable existing file → the baseline (don't silently keep broken JSON)
222
+ }
223
+ const obj = (v: unknown): Record<string, string> => (v && typeof v === "object" ? (v as Record<string, string>) : {});
224
+ const sortedMerge = (a: Record<string, string>, b: Record<string, string>): Record<string, string> => {
225
+ const out: Record<string, string> = {};
226
+ for (const k of Object.keys({ ...a, ...b }).sort()) out[k] = (b as Record<string, string>)[k] ?? a[k];
227
+ return out;
228
+ };
229
+ const merged = {
230
+ ...existing, // app-added top-level fields (engines, wrangler, …) survive
231
+ ...baseline, // baseline sets name/private/type
232
+ // app extras preserved; the baseline (framework + modules) WINS for overlaps → @suluk/* stay "latest".
233
+ dependencies: sortedMerge(obj(existing.dependencies), obj(baseline.dependencies)),
234
+ devDependencies: sortedMerge(obj(existing.devDependencies), obj(baseline.devDependencies)),
235
+ // app scripts win (custom commands survive); the framework's generate/typecheck/test fill any gaps.
236
+ scripts: { ...obj(baseline.scripts), ...obj(existing.scripts) },
237
+ };
238
+ return JSON.stringify(merged, null, 2) + "\n";
239
+ }
240
+
241
+ function buildTsconfig(): string {
242
+ return (
243
+ JSON.stringify(
244
+ {
245
+ compilerOptions: { module: "ESNext", target: "ESNext", moduleResolution: "bundler", types: ["node", "@cloudflare/workers-types"], skipLibCheck: true, strict: true, noEmit: true },
246
+ include: ["src", "provision.config.ts", "platform.config.ts"],
247
+ exclude: ["src/**/*.test.ts"], // the bun:test journeys harness runs under `bun test`, not the Worker build
248
+ },
249
+ null,
250
+ 2,
251
+ ) + "\n"
252
+ );
253
+ }
254
+
255
+ function buildComponentsJson(): string {
256
+ return (
257
+ JSON.stringify(
258
+ {
259
+ $schema: "https://ui.shadcn.com/schema.json",
260
+ style: "default",
261
+ rsc: false,
262
+ tsx: true,
263
+ tailwind: { config: "", css: "", baseColor: "neutral", cssVariables: false },
264
+ aliases: { components: "src/components", utils: "src/lib/utils" },
265
+ },
266
+ null,
267
+ 2,
268
+ ) + "\n"
269
+ );
270
+ }
271
+
31
272
  function buildEntry(services: string[], opts?: Record<string, Record<string, unknown>>): string {
32
273
  const imports = ['import { createApp } from "./app";'];
33
274
  const middleware: string[] = [];
package/test/plan.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { test, expect, describe } from "bun:test";
2
- import { definePlatform, planPlatform, mergeProvision, generatePlatform } from "../src/index";
2
+ import { definePlatform, planPlatform, mergeProvision, generatePlatform, buildPackageJson, mergePackageJson, mergeWranglerToml } from "../src/index";
3
3
  import type { InstanceSpec } from "@suluk/provision";
4
4
 
5
5
  /** C051 — the platform generator: manifest → plan (adds + wired entry + merged provision), the provision merge, and the
@@ -151,18 +151,95 @@ describe("mergeProvision — combine same-ref instances, union migrations in ord
151
151
  });
152
152
 
153
153
  describe("generatePlatform — the orchestration (with recorders)", () => {
154
- test("runs a shadcn add per service, then writes the entry + provision.config", async () => {
154
+ test("writes the scaffold config FIRST, then a shadcn add per service, then the glue", async () => {
155
155
  const ran: string[] = [];
156
156
  const wrote: string[] = [];
157
157
  const res = await generatePlatform(manifest, {
158
158
  run: async (cmd, args) => void ran.push(`${cmd} ${args.join(" ")}`),
159
159
  write: async (path) => void wrote.push(path),
160
+ read: async () => null, // a fresh app — no existing config
160
161
  });
161
162
  expect(ran).toEqual(plannedAdds()); // exactly the planned adds, in order
162
163
  expect(ran.length).toBe(6); // app+auth+credits+keys+billing+logs
163
- expect(wrote).toEqual(["src/index.ts", "provision.config.ts"]);
164
+ // config is written BEFORE the shadcn adds; the glue after. env-example + env-check + wrangler + gitignore included.
165
+ expect(wrote).toEqual(["package.json", "wrangler.toml", "tsconfig.json", "components.json", ".gitignore", ".env.example", "scripts/env-check.ts", "src/index.ts", "provision.config.ts"]);
164
166
  expect(res.added.length).toBe(6);
165
167
  });
168
+
169
+ test("leaves an existing tsconfig/components.json/.gitignore untouched; always rewrites package.json/.env.example", async () => {
170
+ const wrote: string[] = [];
171
+ await generatePlatform(manifest, {
172
+ run: async () => {},
173
+ write: async (path) => void wrote.push(path),
174
+ read: async (p) => (p === "package.json" ? '{"name":"x","dependencies":{"my-lib":"^1.0.0"}}' : "existing"),
175
+ });
176
+ expect(wrote).toContain("package.json"); // merged + rewritten
177
+ expect(wrote).toContain(".env.example"); // template — always current
178
+ expect(wrote).toContain("scripts/env-check.ts");
179
+ expect(wrote).not.toContain("tsconfig.json"); // present → left as-is
180
+ expect(wrote).not.toContain(".gitignore");
181
+ });
182
+ });
183
+
184
+ describe("env — secrets in .env (temp lifecycle), non-secrets in the manifest vars", () => {
185
+ test(".env.example lists ONLY required + optional secrets, never non-secret config", () => {
186
+ const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "billing", "webhooks", "email"] }));
187
+ expect(p.envExample).toContain("BETTER_AUTH_SECRET="); // required secret, uncommented
188
+ expect(p.envExample).toContain("STRIPE_SECRET_KEY=");
189
+ expect(p.envExample).toContain("STRIPE_WEBHOOK_SECRET=");
190
+ expect(p.envExample).toContain("# RESEND_API_KEY="); // optional secret, commented
191
+ expect(p.envExample).not.toContain("BASE_URL"); // non-secret → the manifest vars, NOT .env
192
+ expect(p.envExample).not.toContain("TRUSTED_ORIGINS");
193
+ });
194
+
195
+ test("wrangler.toml [vars] come from the manifest vars; unset non-secrets are commented; D1 binding present", () => {
196
+ const p = planPlatform(definePlatform({ name: "myapp", registry: "acme/reg", services: ["auth", "email", "rate-credit"], vars: { BASE_URL: "https://x.dev", ENVIRONMENT: "production" } }));
197
+ expect(p.wranglerToml).toContain('BASE_URL = "https://x.dev"'); // set in the manifest
198
+ expect(p.wranglerToml).toContain('ENVIRONMENT = "production"');
199
+ expect(p.wranglerToml).toContain("# EMAIL_FROM ="); // unset → commented
200
+ expect(p.wranglerToml).toContain('binding = "DB"');
201
+ expect(p.wranglerToml).toContain('binding = "RATE_CREDIT_KV"'); // rate-credit selected
202
+ expect(p.wranglerToml).not.toContain("BETTER_AUTH_SECRET"); // secrets never in [vars]
203
+ });
204
+
205
+ test("the env-check script bakes in the required secrets; merge preserves provisioned binding ids", () => {
206
+ const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "billing"] }));
207
+ expect(p.envCheck).toContain('["BETTER_AUTH_SECRET","STRIPE_SECRET_KEY"]');
208
+ expect(p.envCheck).toContain(".env.temp");
209
+ // wrangler merge keeps the operator's database_id across a regen
210
+ const merged = mergeWranglerToml(p.wranglerToml, 'name="e"\n[[d1_databases]]\nbinding = "DB"\ndatabase_id = "abc-123"');
211
+ expect(merged).toContain('database_id = "abc-123"');
212
+ });
213
+ });
214
+
215
+ describe("package.json generation — the manifest is the only surface", () => {
216
+ test("buildPackageJson unions base + service deps; @suluk/* → latest, ecosystem pinned", () => {
217
+ const plan = planPlatform(definePlatform({ name: "myapp", registry: "acme/reg", services: ["auth", "credits", "billing"] }));
218
+ const pkg = JSON.parse(plan.packageJson);
219
+ expect(pkg.name).toBe("myapp");
220
+ expect(pkg.dependencies["@suluk/credits"]).toBe("latest"); // fixes flow via bun update
221
+ expect(pkg.dependencies["@suluk/billing"]).toBe("latest");
222
+ expect(pkg.dependencies["hono"]).toBe("^4.0.0"); // ecosystem pinned
223
+ expect(pkg.dependencies["better-auth"]).toBe("^1.0.0"); // auth's dep
224
+ expect(pkg.devDependencies["typescript"]).toBeDefined();
225
+ expect(pkg.scripts.generate).toBe("suluk-platform");
226
+ });
227
+
228
+ test("mergePackageJson keeps app-added deps + scripts, baseline wins for framework deps", () => {
229
+ const baseline = buildPackageJson("myapp", ["auth", "credits"]);
230
+ const existing = JSON.stringify({ name: "myapp", dependencies: { "@suluk/credits": "^0.1.0", "my-product-lib": "^2.0.0" }, scripts: { deploy: "wrangler deploy" } });
231
+ const merged = JSON.parse(mergePackageJson(baseline, existing));
232
+ expect(merged.dependencies["my-product-lib"]).toBe("^2.0.0"); // app extra preserved
233
+ expect(merged.dependencies["@suluk/credits"]).toBe("latest"); // baseline wins → stays up to date
234
+ expect(merged.scripts.deploy).toBe("wrangler deploy"); // app script preserved
235
+ expect(merged.scripts.typecheck).toBe("tsc --noEmit -p ."); // framework script filled in
236
+ });
237
+
238
+ test("planPlatform emits tsconfig + components.json", () => {
239
+ const plan = planPlatform(manifest);
240
+ expect(JSON.parse(plan.tsconfig).exclude).toContain("src/**/*.test.ts");
241
+ expect(JSON.parse(plan.componentsJson).aliases.utils).toBe("src/lib/utils");
242
+ });
166
243
  });
167
244
 
168
245
  function plannedAdds() {