@suluk/platform 0.1.8 → 0.1.10
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 +62 -5
- package/src/generate.ts +18 -3
- package/src/index.ts +2 -2
- package/src/manifest.ts +6 -0
- package/src/plan.ts +159 -2
- package/test/plan.test.ts +45 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/platform",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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: {
|
|
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: {
|
|
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: {
|
|
41
|
-
|
|
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, mergeGitignore, 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,23 @@ 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
|
-
|
|
42
|
-
|
|
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
|
+
// .gitignore MERGES (append missing entries) — critically ensures .env/.env.temp are ignored even if the app already had
|
|
47
|
+
// a minimal .gitignore, so secrets are never committed. .env.example + the env-check are always (re)written (no values).
|
|
48
|
+
log("▸ writing .gitignore");
|
|
49
|
+
await opts.write(".gitignore", mergeGitignore(plan.gitignore, await read(".gitignore")));
|
|
50
|
+
written.push(".gitignore");
|
|
51
|
+
for (const [file, content, always] of [
|
|
52
|
+
["tsconfig.json", plan.tsconfig, false],
|
|
53
|
+
["components.json", plan.componentsJson, false],
|
|
54
|
+
[".env.example", plan.envExample, true], // a checked-in template (no values) — keep it current
|
|
55
|
+
["scripts/env-check.ts", plan.envCheck, true], // the .env.temp lifecycle preflight
|
|
56
|
+
] as const) {
|
|
57
|
+
if (always || (await read(file)) == null) {
|
|
43
58
|
log(`▸ writing ${file}`);
|
|
44
59
|
await opts.write(file, content);
|
|
45
60
|
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, mergeGitignore, 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,153 @@ 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
|
+
/** Merge the generated .gitignore into an existing one — APPEND any missing entries (never skip-if-present, so an app's
|
|
141
|
+
* minimal .gitignore can't leave `.env`/`.env.temp` UNIGNORED and risk committing secrets). Dedup, preserve app entries. */
|
|
142
|
+
export function mergeGitignore(generated: string, existing: string | null): string {
|
|
143
|
+
if (!existing) return generated;
|
|
144
|
+
const norm = (s: string) => s.trim().replace(/\/$/, "");
|
|
145
|
+
const have = new Set(existing.split("\n").map(norm).filter(Boolean));
|
|
146
|
+
const add = generated.split("\n").filter((l) => l.trim() && !have.has(norm(l)));
|
|
147
|
+
if (!add.length) return existing.endsWith("\n") ? existing : existing + "\n";
|
|
148
|
+
const base = existing.replace(/\n*$/, "");
|
|
149
|
+
return `${base}\n${add.join("\n")}\n`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The `.env.temp` lifecycle preflight (run via `predev` / `bun run check`): if every REQUIRED secret is present (in `.env`
|
|
153
|
+
* or the process env), delete `.env.temp`; else write `.env.temp` from `.env.example` + report the missing keys + fail. */
|
|
154
|
+
function buildEnvCheckScript(env: EnvVar[]): string {
|
|
155
|
+
const required = env.filter((e) => e.secret && e.required).map((e) => e.name);
|
|
156
|
+
return `#!/usr/bin/env bun
|
|
157
|
+
/**
|
|
158
|
+
* AUTO-GENERATED by @suluk/platform — the .env lifecycle. Wired as \`predev\` (runs before \`dev\`) + \`bun run check\`.
|
|
159
|
+
* The MINIMUM secret keys this app needs come from platform.config.ts (the selected modules). Non-secret config is in
|
|
160
|
+
* the manifest \`vars\` → wrangler.toml [vars], not here.
|
|
161
|
+
*/
|
|
162
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
163
|
+
|
|
164
|
+
const REQUIRED = ${JSON.stringify(required)};
|
|
165
|
+
const ENV = ".env", TEMP = ".env.temp", EXAMPLE = ".env.example";
|
|
166
|
+
|
|
167
|
+
const parse = (p: string): Record<string, string> => {
|
|
168
|
+
const out: Record<string, string> = {};
|
|
169
|
+
for (const line of readFileSync(p, "utf8").split("\\n")) {
|
|
170
|
+
const m = line.match(/^\\s*([A-Z0-9_]+)\\s*=\\s*(.*)$/);
|
|
171
|
+
if (m && m[2].trim()) out[m[1]] = m[2].trim();
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const have = existsSync(ENV) ? parse(ENV) : {};
|
|
177
|
+
const missing = REQUIRED.filter((k) => !have[k] && !process.env[k]);
|
|
178
|
+
|
|
179
|
+
if (missing.length === 0) {
|
|
180
|
+
if (existsSync(TEMP)) { rmSync(TEMP); console.log("✓ .env ready — removed .env.temp"); }
|
|
181
|
+
else console.log("✓ .env ready (all required secrets present).");
|
|
182
|
+
process.exit(0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// not ready → drop a fill-me-in template so you can see exactly what's needed.
|
|
186
|
+
if (existsSync(EXAMPLE)) writeFileSync(TEMP, readFileSync(EXAMPLE, "utf8"));
|
|
187
|
+
console.error("✗ missing required secret(s): " + missing.join(", "));
|
|
188
|
+
console.error(" → wrote .env.temp — fill the values in and save it as .env (or inject the secrets another way).");
|
|
189
|
+
console.error(" .env.temp auto-deletes once .env has every required secret.");
|
|
190
|
+
process.exit(1);
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
|
|
41
194
|
/** The framework baseline package.json — name from the manifest, the union of BASE + each service's deps (versions
|
|
42
195
|
* resolved: @suluk/* → "latest", ecosystem → pinned), + the toolchain devDeps + the regenerate/typecheck scripts. */
|
|
43
196
|
export function buildPackageJson(name: string, services: string[]): string {
|
|
@@ -50,7 +203,11 @@ export function buildPackageJson(name: string, services: string[]): string {
|
|
|
50
203
|
private: true,
|
|
51
204
|
type: "module",
|
|
52
205
|
scripts: {
|
|
53
|
-
generate: "suluk-platform", // re-pull modules + rewrite src/index.ts + provision.config.ts
|
|
206
|
+
generate: "suluk-platform", // re-pull modules + rewrite the scaffold config + src/index.ts + provision.config.ts
|
|
207
|
+
check: "bun run scripts/env-check.ts", // the .env.temp lifecycle (fails if a required secret is missing)
|
|
208
|
+
predev: "bun run scripts/env-check.ts", // runs automatically before `dev`
|
|
209
|
+
dev: "wrangler dev",
|
|
210
|
+
deploy: "wrangler deploy",
|
|
54
211
|
typecheck: "tsc --noEmit -p .",
|
|
55
212
|
test: "bun test",
|
|
56
213
|
},
|
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, mergeGitignore } 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
|
|
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", ".gitignore", "tsconfig.json", "components.json", ".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
|
|
169
|
+
test("leaves an existing tsconfig/components.json untouched; always (re)writes package.json/.gitignore/.env.example", async () => {
|
|
170
170
|
const wrote: string[] = [];
|
|
171
171
|
await generatePlatform(manifest, {
|
|
172
172
|
run: async () => {},
|
|
@@ -174,8 +174,48 @@ 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(".gitignore"); // MERGED (never skip — must ensure .env is ignored)
|
|
178
|
+
expect(wrote).toContain(".env.example"); // template — always current
|
|
179
|
+
expect(wrote).toContain("scripts/env-check.ts");
|
|
177
180
|
expect(wrote).not.toContain("tsconfig.json"); // present → left as-is
|
|
178
|
-
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("mergeGitignore appends missing entries so .env is always ignored", () => {
|
|
184
|
+
const merged = mergeGitignore("node_modules/\n.env\n.env.temp\n", "node_modules\n");
|
|
185
|
+
expect(merged).toContain(".env");
|
|
186
|
+
expect(merged).toContain(".env.temp");
|
|
187
|
+
expect(merged.match(/node_modules/g)?.length).toBe(1); // deduped (node_modules vs node_modules/)
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("env — secrets in .env (temp lifecycle), non-secrets in the manifest vars", () => {
|
|
192
|
+
test(".env.example lists ONLY required + optional secrets, never non-secret config", () => {
|
|
193
|
+
const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "billing", "webhooks", "email"] }));
|
|
194
|
+
expect(p.envExample).toContain("BETTER_AUTH_SECRET="); // required secret, uncommented
|
|
195
|
+
expect(p.envExample).toContain("STRIPE_SECRET_KEY=");
|
|
196
|
+
expect(p.envExample).toContain("STRIPE_WEBHOOK_SECRET=");
|
|
197
|
+
expect(p.envExample).toContain("# RESEND_API_KEY="); // optional secret, commented
|
|
198
|
+
expect(p.envExample).not.toContain("BASE_URL"); // non-secret → the manifest vars, NOT .env
|
|
199
|
+
expect(p.envExample).not.toContain("TRUSTED_ORIGINS");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("wrangler.toml [vars] come from the manifest vars; unset non-secrets are commented; D1 binding present", () => {
|
|
203
|
+
const p = planPlatform(definePlatform({ name: "myapp", registry: "acme/reg", services: ["auth", "email", "rate-credit"], vars: { BASE_URL: "https://x.dev", ENVIRONMENT: "production" } }));
|
|
204
|
+
expect(p.wranglerToml).toContain('BASE_URL = "https://x.dev"'); // set in the manifest
|
|
205
|
+
expect(p.wranglerToml).toContain('ENVIRONMENT = "production"');
|
|
206
|
+
expect(p.wranglerToml).toContain("# EMAIL_FROM ="); // unset → commented
|
|
207
|
+
expect(p.wranglerToml).toContain('binding = "DB"');
|
|
208
|
+
expect(p.wranglerToml).toContain('binding = "RATE_CREDIT_KV"'); // rate-credit selected
|
|
209
|
+
expect(p.wranglerToml).not.toContain("BETTER_AUTH_SECRET"); // secrets never in [vars]
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("the env-check script bakes in the required secrets; merge preserves provisioned binding ids", () => {
|
|
213
|
+
const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "billing"] }));
|
|
214
|
+
expect(p.envCheck).toContain('["BETTER_AUTH_SECRET","STRIPE_SECRET_KEY"]');
|
|
215
|
+
expect(p.envCheck).toContain(".env.temp");
|
|
216
|
+
// wrangler merge keeps the operator's database_id across a regen
|
|
217
|
+
const merged = mergeWranglerToml(p.wranglerToml, 'name="e"\n[[d1_databases]]\nbinding = "DB"\ndatabase_id = "abc-123"');
|
|
218
|
+
expect(merged).toContain('database_id = "abc-123"');
|
|
179
219
|
});
|
|
180
220
|
});
|
|
181
221
|
|