@suluk/platform 0.1.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/platform",
3
- "version": "0.1.8",
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,6 +11,18 @@ 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;
@@ -21,11 +33,23 @@ export interface CatalogEntry {
21
33
  * from-the-manifest package.json (so platform.config.ts is the only hand-authored surface). Kept in sync with the
22
34
  * registry item's `dependencies`. */
23
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[];
24
38
  }
25
39
 
26
40
  export const CATALOG: Record<string, CatalogEntry> = {
27
- app: { mount: { kind: "base" } },
28
- auth: { mount: { kind: "middleware", symbol: "mountAuthRoutes", from: "./auth" }, provision: { symbol: "authProvision", from: "./src/provision/auth" }, deps: ["better-auth", "@better-auth/api-key", "@better-auth/passkey", "@suluk/better-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
+ },
29
53
  // the contract is a MIDDLEWARE mount: it installs the scope gate (enforceApiKeyScope) + GET /api/openapi.json. Place it
30
54
  // after `auth` in the manifest so the gate runs after identity/apiKeyAuth set keyId/scopes. Derived + stateless.
31
55
  contract: { mount: { kind: "middleware", symbol: "mountContract", from: "./routes/contract" }, deps: ["@suluk/hono", "zod"] },
@@ -34,11 +58,34 @@ export const CATALOG: Record<string, CatalogEntry> = {
34
58
  // feature routes mount under /api/* — where the caller-resolution + cors + rate-limit middleware live (toolfactory parity).
35
59
  credits: { mount: { kind: "route", path: "/api/credits", symbol: "creditsRoutes", from: "./routes/credits" }, provision: { symbol: "creditsProvision", from: "./src/provision/credits" }, deps: ["@suluk/credits"] },
36
60
  keys: { mount: { kind: "route", path: "/api/keys", symbol: "keysRoutes", from: "./routes/keys" }, provision: { symbol: "keysProvision", from: "./src/provision/keys" }, deps: ["@suluk/keys"] },
37
- billing: { mount: { kind: "route", path: "/api/billing", symbol: "billingRoutes", from: "./routes/billing" }, provision: { symbol: "billingProvision", from: "./src/provision/billing" }, deps: ["@suluk/billing", "@suluk/payments", "@suluk/credits"] },
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
+ },
38
70
  cost: { mount: { kind: "route", path: "/api/cost", symbol: "costRoutes", from: "./routes/cost" }, provision: { symbol: "costProvision", from: "./src/provision/cost" }, deps: ["@suluk/cost"] },
39
71
  erasure: { mount: { kind: "route", path: "/api/erasure", symbol: "erasureRoutes", from: "./routes/erasure" }, provision: { symbol: "erasureProvision", from: "./src/provision/erasure" }, deps: ["@suluk/better-auth"] },
40
- email: { mount: { kind: "route", path: "/api/email", symbol: "emailRoutes", from: "./routes/email" }, deps: ["@suluk/email"] }, // stateless binding — no provision fragment (C052)
41
- webhooks: { mount: { kind: "route", path: "/api/webhooks", symbol: "webhooksRoutes", from: "./routes/webhooks" }, provision: { symbol: "webhooksProvision", from: "./src/provision/webhooks" }, deps: ["@suluk/payments"] },
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
+ },
42
89
  // cross-cutting MIDDLEWARE (apply globally via app.use, emitted before any route) — not routed resources.
43
90
  "rate-limit": { mount: { kind: "middleware", symbol: "mountRateLimit", from: "./services/rate-limit" }, deps: ["@suluk/hono"] },
44
91
  "rate-credit": { mount: { kind: "middleware", symbol: "mountRateCredit", from: "./services/rate-credit" } }, // credit-backed free-tier bucket (KV binding); base deps cover it
@@ -75,6 +122,7 @@ export const DEV_DEPS: Record<string, string> = {
75
122
  "@cloudflare/workers-types": "^4.20260701.1",
76
123
  "@types/node": "^26.0.1",
77
124
  typescript: "^6.0.3",
125
+ wrangler: "^4.0.0",
78
126
  };
79
127
 
80
128
  /** Resolve a dep to its version: an @suluk/* package → "latest" (fixes flow via `bun update`); a known ecosystem dep →
@@ -84,6 +132,15 @@ export function resolveVersion(dep: string): string {
84
132
  return ECOSYSTEM_VERSIONS[dep] ?? "latest";
85
133
  }
86
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
+
87
144
  /** app + auth always come first (the base + the user/apikey tables others reference); the rest keep manifest order. */
88
145
  export function orderServices(services: string[]): string[] {
89
146
  const want = new Set(services);
package/src/generate.ts CHANGED
@@ -5,7 +5,7 @@
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, mergePackageJson, 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. */
@@ -38,8 +38,19 @@ export async function generatePlatform(manifest: PlatformManifest, opts: Generat
38
38
  log("▸ writing package.json");
39
39
  await opts.write("package.json", mergePackageJson(plan.packageJson, existingPkg));
40
40
  written.push("package.json");
41
- for (const [file, content] of [["tsconfig.json", plan.tsconfig], ["components.json", plan.componentsJson]] as const) {
42
- if ((await read(file)) == null) {
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) {
43
54
  log(`▸ writing ${file}`);
44
55
  await opts.write(file, content);
45
56
  written.push(file);
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, resolveVersion, BASE_DEPS, ECOSYSTEM_VERSIONS, DEV_DEPS, 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, buildPackageJson, mergePackageJson, 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
@@ -20,6 +20,12 @@ export interface PlatformManifest {
20
20
  * `mountAuthRoutes(app, {...})`. Only JSON-safe values (no functions/env-refs — edit the generated entry for those).
21
21
  */
22
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>;
23
29
  }
24
30
 
25
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, BASE_DEPS, DEV_DEPS, resolveVersion } 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[];
@@ -21,12 +21,21 @@ export interface PlatformPlan {
21
21
  tsconfig: string;
22
22
  /** the generated `components.json` content (so `shadcn add` resolves the file targets). */
23
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;
24
32
  }
25
33
 
26
34
  export function planPlatform(manifest: PlatformManifest): PlatformPlan {
27
35
  const services = orderServices(manifest.services);
28
36
  const unknown = services.filter((s) => !CATALOG[s]);
29
37
  if (unknown.length) throw new Error(`platform: unknown service(s) [${unknown.join(", ")}] — not in the catalog`);
38
+ const env = collectEnv(services);
30
39
  return {
31
40
  services,
32
41
  adds: services.map((s) => `${manifest.registry}/${s}`),
@@ -35,9 +44,141 @@ export function planPlatform(manifest: PlatformManifest): PlatformPlan {
35
44
  packageJson: buildPackageJson(manifest.name, services),
36
45
  tsconfig: buildTsconfig(),
37
46
  componentsJson: buildComponentsJson(),
47
+ envExample: buildEnvExample(env),
48
+ wranglerToml: buildWranglerToml(manifest.name, services, env, manifest.vars ?? {}),
49
+ gitignore: buildGitignore(),
50
+ envCheck: buildEnvCheckScript(env),
38
51
  };
39
52
  }
40
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
+
41
182
  /** The framework baseline package.json — name from the manifest, the union of BASE + each service's deps (versions
42
183
  * resolved: @suluk/* → "latest", ecosystem → pinned), + the toolchain devDeps + the regenerate/typecheck scripts. */
43
184
  export function buildPackageJson(name: string, services: string[]): string {
@@ -50,7 +191,11 @@ export function buildPackageJson(name: string, services: string[]): string {
50
191
  private: true,
51
192
  type: "module",
52
193
  scripts: {
53
- generate: "suluk-platform", // re-pull modules + rewrite src/index.ts + provision.config.ts + this config
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",
54
199
  typecheck: "tsc --noEmit -p .",
55
200
  test: "bun test",
56
201
  },
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, buildPackageJson, mergePackageJson } 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
@@ -161,12 +161,12 @@ describe("generatePlatform — the orchestration (with recorders)", () => {
161
161
  });
162
162
  expect(ran).toEqual(plannedAdds()); // exactly the planned adds, in order
163
163
  expect(ran.length).toBe(6); // app+auth+credits+keys+billing+logs
164
- // config (package.json/tsconfig/components.json) is written BEFORE the shadcn adds; the glue after.
165
- expect(wrote).toEqual(["package.json", "tsconfig.json", "components.json", "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"]);
166
166
  expect(res.added.length).toBe(6);
167
167
  });
168
168
 
169
- test("leaves an existing tsconfig/components.json untouched but always (re)writes package.json", async () => {
169
+ test("leaves an existing tsconfig/components.json/.gitignore untouched; always rewrites package.json/.env.example", async () => {
170
170
  const wrote: string[] = [];
171
171
  await generatePlatform(manifest, {
172
172
  run: async () => {},
@@ -174,8 +174,41 @@ describe("generatePlatform — the orchestration (with recorders)", () => {
174
174
  read: async (p) => (p === "package.json" ? '{"name":"x","dependencies":{"my-lib":"^1.0.0"}}' : "existing"),
175
175
  });
176
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");
177
179
  expect(wrote).not.toContain("tsconfig.json"); // present → left as-is
178
- expect(wrote).not.toContain("components.json");
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"');
179
212
  });
180
213
  });
181
214