@suluk/platform 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/platform.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * --config <path> a different manifest
8
8
  */
9
9
  import { resolve, dirname } from "node:path";
10
- import { writeFile, mkdir } from "node:fs/promises";
10
+ import { writeFile, mkdir, readFile } from "node:fs/promises";
11
11
  import { generatePlatform } from "../src/generate";
12
12
  import type { PlatformManifest } from "../src/manifest";
13
13
 
@@ -33,4 +33,13 @@ const write = async (path: string, content: string): Promise<void> => {
33
33
  await writeFile(abs, content);
34
34
  };
35
35
 
36
- await generatePlatform(mod.default, { run, write, log: (m) => console.log(m) });
36
+ // read a file for the merge (null when absent), so a regenerate keeps package.json deps current without dropping app extras.
37
+ const read = async (path: string): Promise<string | null> => {
38
+ try {
39
+ return await readFile(resolve(process.cwd(), path), "utf8");
40
+ } catch {
41
+ return null;
42
+ }
43
+ };
44
+
45
+ await generatePlatform(mod.default, { run, write, read, log: (m) => console.log(m) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/platform",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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
@@ -16,35 +16,74 @@ export interface CatalogEntry {
16
16
  mount: Mount;
17
17
  /** the provision fragment export, if any (`InstanceSpec[]`). */
18
18
  provision?: { symbol: string; from: string };
19
+ /** the module's npm deps BEYOND the always-present base (see BASE_DEPS) — its @suluk/* logic packages + any extras
20
+ * (zod, better-auth). `shadcn add` also installs these; declaring them here lets the generator emit a complete,
21
+ * from-the-manifest package.json (so platform.config.ts is the only hand-authored surface). Kept in sync with the
22
+ * registry item's `dependencies`. */
23
+ deps?: string[];
19
24
  }
20
25
 
21
26
  export const CATALOG: Record<string, CatalogEntry> = {
22
27
  app: { mount: { kind: "base" } },
23
- auth: { mount: { kind: "middleware", symbol: "mountAuthRoutes", from: "./auth" }, provision: { symbol: "authProvision", from: "./src/provision/auth" } },
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"] },
24
29
  // the contract is a MIDDLEWARE mount: it installs the scope gate (enforceApiKeyScope) + GET /api/openapi.json. Place it
25
30
  // 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" } },
31
+ contract: { mount: { kind: "middleware", symbol: "mountContract", from: "./routes/contract" }, deps: ["@suluk/hono", "zod"] },
27
32
  // 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" } },
33
+ mcp: { mount: { kind: "middleware", symbol: "mountMcp", from: "./routes/mcp" }, provision: { symbol: "mcpProvision", from: "./src/provision/mcp" }, deps: ["@suluk/mcp", "better-auth"] },
29
34
  // feature routes mount under /api/* — where the caller-resolution + cors + rate-limit middleware live (toolfactory parity).
30
- credits: { mount: { kind: "route", path: "/api/credits", symbol: "creditsRoutes", from: "./routes/credits" }, provision: { symbol: "creditsProvision", from: "./src/provision/credits" } },
31
- keys: { mount: { kind: "route", path: "/api/keys", symbol: "keysRoutes", from: "./routes/keys" }, provision: { symbol: "keysProvision", from: "./src/provision/keys" } },
32
- billing: { mount: { kind: "route", path: "/api/billing", symbol: "billingRoutes", from: "./routes/billing" }, provision: { symbol: "billingProvision", from: "./src/provision/billing" } },
33
- cost: { mount: { kind: "route", path: "/api/cost", symbol: "costRoutes", from: "./routes/cost" }, provision: { symbol: "costProvision", from: "./src/provision/cost" } },
34
- erasure: { mount: { kind: "route", path: "/api/erasure", symbol: "erasureRoutes", from: "./routes/erasure" }, provision: { symbol: "erasureProvision", from: "./src/provision/erasure" } },
35
- email: { mount: { kind: "route", path: "/api/email", symbol: "emailRoutes", from: "./routes/email" } }, // stateless binding — no provision fragment (C052)
36
- webhooks: { mount: { kind: "route", path: "/api/webhooks", symbol: "webhooksRoutes", from: "./routes/webhooks" }, provision: { symbol: "webhooksProvision", from: "./src/provision/webhooks" } },
35
+ credits: { mount: { kind: "route", path: "/api/credits", symbol: "creditsRoutes", from: "./routes/credits" }, provision: { symbol: "creditsProvision", from: "./src/provision/credits" }, deps: ["@suluk/credits"] },
36
+ 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"] },
38
+ cost: { mount: { kind: "route", path: "/api/cost", symbol: "costRoutes", from: "./routes/cost" }, provision: { symbol: "costProvision", from: "./src/provision/cost" }, deps: ["@suluk/cost"] },
39
+ 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"] },
37
42
  // 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
- i18n: { mount: { kind: "middleware", symbol: "mountI18n", from: "./services/i18n" } },
40
- reference: { mount: { kind: "route", path: "/api/reference", symbol: "referenceRoutes", from: "./routes/reference" } }, // derived doc render — no provision
41
- admin: { mount: { kind: "route", path: "/api/admin", symbol: "adminRoutes", from: "./routes/admin" } }, // reads existing tables — no provision
43
+ "rate-limit": { mount: { kind: "middleware", symbol: "mountRateLimit", from: "./services/rate-limit" }, deps: ["@suluk/hono"] },
44
+ "rate-credit": { mount: { kind: "middleware", symbol: "mountRateCredit", from: "./services/rate-credit" } }, // credit-backed free-tier bucket (KV binding); base deps cover it
45
+ i18n: { mount: { kind: "middleware", symbol: "mountI18n", from: "./services/i18n" }, deps: ["@suluk/i18n"] },
46
+ reference: { mount: { kind: "route", path: "/api/reference", symbol: "referenceRoutes", from: "./routes/reference" }, deps: ["@suluk/reference"] }, // derived doc render — no provision
47
+ admin: { mount: { kind: "route", path: "/api/admin", symbol: "adminRoutes", from: "./routes/admin" }, deps: ["@suluk/credits"] }, // reads existing tables — no provision
42
48
  logs: { mount: { kind: "route", path: "/api/logs", symbol: "logsRoutes", from: "./routes/logs" }, provision: { symbol: "logsProvision", from: "./src/provision/logs" } },
43
49
  // dev/CI tooling — pulled in as files, no runtime mount, no provision fragment.
44
- journeys: { mount: { kind: "dev" } },
45
- audit: { mount: { kind: "dev" } },
50
+ journeys: { mount: { kind: "dev" }, deps: ["@suluk/journeys"] },
51
+ audit: { mount: { kind: "dev" }, deps: ["@suluk/cockpit", "@suluk/harden"] },
46
52
  };
47
53
 
54
+ /**
55
+ * The always-present framework deps (every generated app: the Effect services + Hono entry + the merged provision.config
56
+ * that imports mergeProvision from @suluk/platform + defineProvision from @suluk/provision). Union'd with each service's
57
+ * `deps` to build package.json.
58
+ */
59
+ export const BASE_DEPS = ["@suluk/platform", "@suluk/provision", "@suluk/core", "effect", "hono", "drizzle-orm"];
60
+
61
+ /** Pinned ranges for the NON-@suluk ecosystem deps — the single place they're kept current for every generated app.
62
+ * @suluk/* are NOT here: they resolve to "latest" so a package fix flows to the app via `bun update` (the C052 payoff). */
63
+ export const ECOSYSTEM_VERSIONS: Record<string, string> = {
64
+ "better-auth": "^1.0.0",
65
+ "@better-auth/api-key": "^1.0.0",
66
+ "@better-auth/passkey": "^1.0.0",
67
+ "drizzle-orm": "^0.45.2",
68
+ effect: "^3.0.0",
69
+ hono: "^4.0.0",
70
+ zod: "^4.0.0",
71
+ };
72
+
73
+ /** The generated app's devDeps (the Workers + TS toolchain). */
74
+ export const DEV_DEPS: Record<string, string> = {
75
+ "@cloudflare/workers-types": "^4.20260701.1",
76
+ "@types/node": "^26.0.1",
77
+ typescript: "^6.0.3",
78
+ };
79
+
80
+ /** Resolve a dep to its version: an @suluk/* package → "latest" (fixes flow via `bun update`); a known ecosystem dep →
81
+ * its pinned range; anything else → "latest" (a best-effort default). */
82
+ export function resolveVersion(dep: string): string {
83
+ if (dep.startsWith("@suluk/")) return "latest";
84
+ return ECOSYSTEM_VERSIONS[dep] ?? "latest";
85
+ }
86
+
48
87
  /** app + auth always come first (the base + the user/apikey tables others reference); the rest keep manifest order. */
49
88
  export function orderServices(services: string[]): string[] {
50
89
  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, 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,40 @@ 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
+ for (const [file, content] of [["tsconfig.json", plan.tsconfig], ["components.json", plan.componentsJson]] as const) {
42
+ if ((await read(file)) == null) {
43
+ log(`▸ writing ${file}`);
44
+ await opts.write(file, content);
45
+ written.push(file);
46
+ }
47
+ }
48
+
49
+ // 2) the module code — shadcn add pulls each module's files + resolves registryDependencies (deps already in package.json).
27
50
  const added: string[] = [];
28
51
  for (const add of plan.adds) {
29
52
  log(`▸ shadcn add ${add}`);
30
53
  await opts.run("bunx", ["shadcn@latest", "add", add, "--yes"]);
31
54
  added.push(add);
32
55
  }
56
+
57
+ // 3) the generated glue.
33
58
  log("▸ writing src/index.ts");
34
59
  await opts.write("src/index.ts", plan.entry);
35
60
  log("▸ writing provision.config.ts");
36
61
  await opts.write("provision.config.ts", plan.provisionConfig);
62
+ written.push("src/index.ts", "provision.config.ts");
63
+
37
64
  log(`✓ generated ${manifest.name}: ${plan.services.length} services. Next: bun install && suluk-provision apply`);
38
- return { plan, added, written: ["src/index.ts", "provision.config.ts"] };
65
+ return { plan, added, written };
39
66
  }
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, resolveVersion, BASE_DEPS, ECOSYSTEM_VERSIONS, DEV_DEPS, type CatalogEntry, type Mount } from "./catalog";
9
9
  export { mergeProvision } from "./merge";
10
- export { planPlatform, type PlatformPlan } from "./plan";
10
+ export { planPlatform, buildPackageJson, mergePackageJson, type PlatformPlan } from "./plan";
11
11
  export { generatePlatform, type GenerateOptions, type GenerateResult } from "./generate";
package/src/manifest.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  /**
2
- * The platform manifest (C051) — the one author-facing document. Name the registry + the services you want; the generator
3
- * compiles it to a shadcn-add list + a wired Hono entry + a merged provision.config. The higher-level surface over C047's
4
- * provision.config: you say "auth, credits, billing" and the catalog knows each one's component + provision fragment.
2
+ * The platform manifest (C051) — the ONLY author-facing document. Name the registry + the services you want; the generator
3
+ * compiles it to EVERYTHING: the shadcn-add list, the wired Hono entry, the merged provision.config, AND the scaffold config
4
+ * (package.json with each module's deps — @suluk/* on "latest" so fixes flow via `bun update`, ecosystem pinned; plus
5
+ * tsconfig.json + components.json). `platform.config.ts` is the single surface; regenerating keeps deps current + preserves
6
+ * any deps/scripts you added. The higher-level surface over C047's provision.config: you say "auth, credits, billing" and
7
+ * the catalog knows each one's component + provision fragment + npm deps.
5
8
  */
6
9
  export interface PlatformManifest {
7
10
  /** the app/repo name (used in the generated scaffold). */
@@ -11,6 +14,12 @@ export interface PlatformManifest {
11
14
  /** the services to include, in mount order — resolved against the catalog. `app` + `auth` are implied if any is listed
12
15
  * but list them for clarity; the base + foundation always come first. */
13
16
  services: string[];
17
+ /**
18
+ * Per-service static OPTIONS passed to that service's mount in the generated entry (a plain JSON-serializable object).
19
+ * E.g. enable MCP OAuth: `opts: { auth: { mcp: { loginPage, consentPage, resource, scopes } } }` → the entry emits
20
+ * `mountAuthRoutes(app, {...})`. Only JSON-safe values (no functions/env-refs — edit the generated entry for those).
21
+ */
22
+ opts?: Record<string, Record<string, unknown>>;
14
23
  }
15
24
 
16
25
  /** 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, BASE_DEPS, DEV_DEPS, resolveVersion } from "./catalog";
8
8
 
9
9
  export interface PlatformPlan {
10
10
  services: string[];
@@ -14,6 +14,13 @@ 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;
17
24
  }
18
25
 
19
26
  export function planPlatform(manifest: PlatformManifest): PlatformPlan {
@@ -23,25 +30,119 @@ export function planPlatform(manifest: PlatformManifest): PlatformPlan {
23
30
  return {
24
31
  services,
25
32
  adds: services.map((s) => `${manifest.registry}/${s}`),
26
- entry: buildEntry(services),
33
+ entry: buildEntry(services, manifest.opts),
27
34
  provisionConfig: buildProvisionConfig(services),
35
+ packageJson: buildPackageJson(manifest.name, services),
36
+ tsconfig: buildTsconfig(),
37
+ componentsJson: buildComponentsJson(),
28
38
  };
29
39
  }
30
40
 
31
- function buildEntry(services: string[]): string {
41
+ /** The framework baseline package.json — name from the manifest, the union of BASE + each service's deps (versions
42
+ * resolved: @suluk/* → "latest", ecosystem → pinned), + the toolchain devDeps + the regenerate/typecheck scripts. */
43
+ export function buildPackageJson(name: string, services: string[]): string {
44
+ const deps = new Set<string>(BASE_DEPS);
45
+ for (const s of services) for (const d of CATALOG[s]?.deps ?? []) deps.add(d);
46
+ const dependencies: Record<string, string> = {};
47
+ for (const d of [...deps].sort()) dependencies[d] = resolveVersion(d);
48
+ const pkg = {
49
+ name,
50
+ private: true,
51
+ type: "module",
52
+ scripts: {
53
+ generate: "suluk-platform", // re-pull modules + rewrite src/index.ts + provision.config.ts + this config
54
+ typecheck: "tsc --noEmit -p .",
55
+ test: "bun test",
56
+ },
57
+ dependencies,
58
+ devDependencies: { ...DEV_DEPS },
59
+ };
60
+ return JSON.stringify(pkg, null, 2) + "\n";
61
+ }
62
+
63
+ /**
64
+ * Merge the generated framework baseline package.json with the app's EXISTING one (if any). The baseline WINS for the
65
+ * framework + module deps (so `@suluk/*` stay `"latest"` and the ecosystem stays on its pinned range — deps stay current
66
+ * across a regenerate), while any deps / scripts / top-level fields the app added are PRESERVED. No existing ⇒ the baseline
67
+ * verbatim. Keys are sorted for stable output. Pure + testable.
68
+ */
69
+ export function mergePackageJson(baselineJson: string, existingJson: string | null): string {
70
+ if (!existingJson) return baselineJson;
71
+ const baseline = JSON.parse(baselineJson) as Record<string, unknown>;
72
+ let existing: Record<string, unknown>;
73
+ try {
74
+ existing = JSON.parse(existingJson) as Record<string, unknown>;
75
+ } catch {
76
+ return baselineJson; // an unparseable existing file → the baseline (don't silently keep broken JSON)
77
+ }
78
+ const obj = (v: unknown): Record<string, string> => (v && typeof v === "object" ? (v as Record<string, string>) : {});
79
+ const sortedMerge = (a: Record<string, string>, b: Record<string, string>): Record<string, string> => {
80
+ const out: Record<string, string> = {};
81
+ for (const k of Object.keys({ ...a, ...b }).sort()) out[k] = (b as Record<string, string>)[k] ?? a[k];
82
+ return out;
83
+ };
84
+ const merged = {
85
+ ...existing, // app-added top-level fields (engines, wrangler, …) survive
86
+ ...baseline, // baseline sets name/private/type
87
+ // app extras preserved; the baseline (framework + modules) WINS for overlaps → @suluk/* stay "latest".
88
+ dependencies: sortedMerge(obj(existing.dependencies), obj(baseline.dependencies)),
89
+ devDependencies: sortedMerge(obj(existing.devDependencies), obj(baseline.devDependencies)),
90
+ // app scripts win (custom commands survive); the framework's generate/typecheck/test fill any gaps.
91
+ scripts: { ...obj(baseline.scripts), ...obj(existing.scripts) },
92
+ };
93
+ return JSON.stringify(merged, null, 2) + "\n";
94
+ }
95
+
96
+ function buildTsconfig(): string {
97
+ return (
98
+ JSON.stringify(
99
+ {
100
+ compilerOptions: { module: "ESNext", target: "ESNext", moduleResolution: "bundler", types: ["node", "@cloudflare/workers-types"], skipLibCheck: true, strict: true, noEmit: true },
101
+ include: ["src", "provision.config.ts", "platform.config.ts"],
102
+ exclude: ["src/**/*.test.ts"], // the bun:test journeys harness runs under `bun test`, not the Worker build
103
+ },
104
+ null,
105
+ 2,
106
+ ) + "\n"
107
+ );
108
+ }
109
+
110
+ function buildComponentsJson(): string {
111
+ return (
112
+ JSON.stringify(
113
+ {
114
+ $schema: "https://ui.shadcn.com/schema.json",
115
+ style: "default",
116
+ rsc: false,
117
+ tsx: true,
118
+ tailwind: { config: "", css: "", baseColor: "neutral", cssVariables: false },
119
+ aliases: { components: "src/components", utils: "src/lib/utils" },
120
+ },
121
+ null,
122
+ 2,
123
+ ) + "\n"
124
+ );
125
+ }
126
+
127
+ function buildEntry(services: string[], opts?: Record<string, Record<string, unknown>>): string {
32
128
  const imports = ['import { createApp } from "./app";'];
33
129
  const middleware: string[] = [];
34
130
  const routes: string[] = [];
131
+ // per-service static options → a JSON literal passed to the mount (e.g. auth's mcp OAuth config). Empty ⇒ no 2nd arg.
132
+ const optOf = (s: string): string => {
133
+ const o = opts?.[s];
134
+ return o && Object.keys(o).length ? `, ${JSON.stringify(o)}` : "";
135
+ };
35
136
  // TWO passes: ALL middleware mounts (app.use / handler) emit BEFORE any route mount, so a cross-cutting concern
36
137
  // (auth, rate-limit, i18n) applies to every route regardless of where it sits in the manifest.
37
138
  for (const s of services) {
38
139
  const m = CATALOG[s].mount;
39
140
  if (m.kind === "middleware") {
40
141
  imports.push(`import { ${m.symbol} } from "${m.from}";`);
41
- middleware.push(`${m.symbol}(app);`);
142
+ middleware.push(`${m.symbol}(app${optOf(s)});`);
42
143
  } else if (m.kind === "route") {
43
144
  imports.push(`import { ${m.symbol} } from "${m.from}";`);
44
- routes.push(`app.route("${m.path}", ${m.symbol}());`);
145
+ routes.push(`app.route("${m.path}", ${m.symbol}(${optOf(s).replace(/^, /, "")}));`);
45
146
  }
46
147
  }
47
148
  const body = ["const app = createApp();", ...middleware, ...routes];
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 } 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
@@ -35,6 +35,13 @@ describe("planPlatform — manifest → shadcn adds + entry + provision.config",
35
35
  test("an unknown service throws", () => {
36
36
  expect(() => planPlatform({ name: "x", registry: "r", services: ["nope"] })).toThrow(/unknown service/);
37
37
  });
38
+
39
+ test("per-service opts are passed to the mount (e.g. auth mcp OAuth config)", () => {
40
+ const p = planPlatform(definePlatform({ name: "o", registry: "acme/reg", services: ["auth", "credits"], opts: { auth: { mcp: { resource: "https://api.x", scopes: ["credits:read"] } } } }));
41
+ expect(p.entry).toContain('mountAuthRoutes(app, {"mcp":{"resource":"https://api.x","scopes":["credits:read"]}});');
42
+ // a service with no opts still gets the bare call.
43
+ expect(p.entry).toContain('app.route("/api/credits", creditsRoutes());');
44
+ });
38
45
  });
39
46
 
40
47
  describe("cost (route + provision) + dev modules (journeys/audit — files only)", () => {
@@ -109,6 +116,12 @@ describe("cost (route + provision) + dev modules (journeys/audit — files only)
109
116
  expect(p.provisionConfig).toContain("mcpProvision");
110
117
  });
111
118
 
119
+ test("rate-credit is a middleware mount (KV binding) with NO provision fragment", () => {
120
+ const p = planPlatform(definePlatform({ name: "rc", registry: "acme/reg", services: ["auth", "rate-credit", "credits"] }));
121
+ expect(p.entry).toContain("mountRateCredit(app);");
122
+ expect(p.provisionConfig).not.toContain("rateCreditProvision");
123
+ });
124
+
112
125
  test("dev modules add shadcn refs but NO entry mount and NO provision fragment", () => {
113
126
  expect(plan.adds).toContain("acme/reg/journeys");
114
127
  expect(plan.adds).toContain("acme/reg/audit");
@@ -138,18 +151,62 @@ describe("mergeProvision — combine same-ref instances, union migrations in ord
138
151
  });
139
152
 
140
153
  describe("generatePlatform — the orchestration (with recorders)", () => {
141
- test("runs a shadcn add per service, then writes the entry + provision.config", async () => {
154
+ test("writes the scaffold config FIRST, then a shadcn add per service, then the glue", async () => {
142
155
  const ran: string[] = [];
143
156
  const wrote: string[] = [];
144
157
  const res = await generatePlatform(manifest, {
145
158
  run: async (cmd, args) => void ran.push(`${cmd} ${args.join(" ")}`),
146
159
  write: async (path) => void wrote.push(path),
160
+ read: async () => null, // a fresh app — no existing config
147
161
  });
148
162
  expect(ran).toEqual(plannedAdds()); // exactly the planned adds, in order
149
163
  expect(ran.length).toBe(6); // app+auth+credits+keys+billing+logs
150
- expect(wrote).toEqual(["src/index.ts", "provision.config.ts"]);
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"]);
151
166
  expect(res.added.length).toBe(6);
152
167
  });
168
+
169
+ test("leaves an existing tsconfig/components.json untouched but always (re)writes package.json", 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).not.toContain("tsconfig.json"); // present → left as-is
178
+ expect(wrote).not.toContain("components.json");
179
+ });
180
+ });
181
+
182
+ describe("package.json generation — the manifest is the only surface", () => {
183
+ test("buildPackageJson unions base + service deps; @suluk/* → latest, ecosystem pinned", () => {
184
+ const plan = planPlatform(definePlatform({ name: "myapp", registry: "acme/reg", services: ["auth", "credits", "billing"] }));
185
+ const pkg = JSON.parse(plan.packageJson);
186
+ expect(pkg.name).toBe("myapp");
187
+ expect(pkg.dependencies["@suluk/credits"]).toBe("latest"); // fixes flow via bun update
188
+ expect(pkg.dependencies["@suluk/billing"]).toBe("latest");
189
+ expect(pkg.dependencies["hono"]).toBe("^4.0.0"); // ecosystem pinned
190
+ expect(pkg.dependencies["better-auth"]).toBe("^1.0.0"); // auth's dep
191
+ expect(pkg.devDependencies["typescript"]).toBeDefined();
192
+ expect(pkg.scripts.generate).toBe("suluk-platform");
193
+ });
194
+
195
+ test("mergePackageJson keeps app-added deps + scripts, baseline wins for framework deps", () => {
196
+ const baseline = buildPackageJson("myapp", ["auth", "credits"]);
197
+ const existing = JSON.stringify({ name: "myapp", dependencies: { "@suluk/credits": "^0.1.0", "my-product-lib": "^2.0.0" }, scripts: { deploy: "wrangler deploy" } });
198
+ const merged = JSON.parse(mergePackageJson(baseline, existing));
199
+ expect(merged.dependencies["my-product-lib"]).toBe("^2.0.0"); // app extra preserved
200
+ expect(merged.dependencies["@suluk/credits"]).toBe("latest"); // baseline wins → stays up to date
201
+ expect(merged.scripts.deploy).toBe("wrangler deploy"); // app script preserved
202
+ expect(merged.scripts.typecheck).toBe("tsc --noEmit -p ."); // framework script filled in
203
+ });
204
+
205
+ test("planPlatform emits tsconfig + components.json", () => {
206
+ const plan = planPlatform(manifest);
207
+ expect(JSON.parse(plan.tsconfig).exclude).toContain("src/**/*.test.ts");
208
+ expect(JSON.parse(plan.componentsJson).aliases.utils).toBe("src/lib/utils");
209
+ });
153
210
  });
154
211
 
155
212
  function plannedAdds() {