@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 +11 -2
- package/package.json +1 -1
- package/src/catalog.ts +113 -18
- package/src/generate.ts +40 -2
- package/src/index.ts +2 -2
- package/src/manifest.ts +12 -3
- package/src/plan.ts +242 -1
- package/test/plan.test.ts +80 -3
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
|
-
|
|
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.
|
|
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: {
|
|
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: {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
3
|
-
* compiles it to
|
|
4
|
-
*
|
|
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("
|
|
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
|
-
|
|
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() {
|