@suluk/platform 0.1.0

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.
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The @suluk/platform CLI (C051) — load `platform.config.ts`, run `shadcn add` per service, write the wired entry +
4
+ * provision.config. Then `bun install && suluk-provision apply`.
5
+ *
6
+ * suluk-platform generate from ./platform.config.ts
7
+ * --config <path> a different manifest
8
+ */
9
+ import { resolve, dirname } from "node:path";
10
+ import { writeFile, mkdir } from "node:fs/promises";
11
+ import { generatePlatform } from "../src/generate";
12
+ import type { PlatformManifest } from "../src/manifest";
13
+
14
+ const argv = process.argv.slice(2);
15
+ const ci = argv.indexOf("--config");
16
+ const configPath = ci >= 0 ? argv[ci + 1] : "platform.config.ts";
17
+
18
+ const mod = (await import(resolve(process.cwd(), configPath))) as { default?: PlatformManifest };
19
+ if (!mod.default) {
20
+ console.error(`✗ ${configPath} has no default export (a definePlatform(...) result)`);
21
+ process.exit(2);
22
+ }
23
+
24
+ const run = (cmd: string, args: string[]): Promise<void> =>
25
+ new Promise((res, rej) => {
26
+ const p = Bun.spawn([cmd, ...args], { stdout: "inherit", stderr: "inherit", cwd: process.cwd() });
27
+ p.exited.then((code) => (code === 0 ? res() : rej(new Error(`${cmd} ${args.join(" ")} exited ${code}`))));
28
+ });
29
+
30
+ const write = async (path: string, content: string): Promise<void> => {
31
+ const abs = resolve(process.cwd(), path);
32
+ await mkdir(dirname(abs), { recursive: true });
33
+ await writeFile(abs, content);
34
+ };
35
+
36
+ await generatePlatform(mod.default, { run, write, log: (m) => console.log(m) });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@suluk/platform",
3
+ "version": "0.1.0",
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
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/platform"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "bin": {
19
+ "suluk-platform": "bin/platform.ts"
20
+ },
21
+ "exports": {
22
+ ".": "./src/index.ts"
23
+ },
24
+ "dependencies": {
25
+ "@suluk/provision": "^0.1.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/bun": "latest"
29
+ },
30
+ "scripts": {
31
+ "test": "bun test",
32
+ "typecheck": "tsc --noEmit -p ."
33
+ }
34
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The catalog (C051) — the OSB "offerings": each service id → how to MOUNT its router into the Hono entry + where its
3
+ * PROVISION fragment lives. This is the mapping the generator needs beyond `shadcn add` (which handles files/deps/order on
4
+ * its own). Kept in sync with the registry's module set (C050). `app` is the base (no mount, no fragment).
5
+ */
6
+
7
+ /** How a module contributes to the generated `src/index.ts`. */
8
+ export type Mount =
9
+ | { kind: "base" } // the app skeleton — `createApp()`
10
+ | { kind: "middleware"; symbol: string; from: string } // e.g. `mountAuthRoutes(app)`
11
+ | { kind: "route"; path: string; symbol: string; from: string }; // e.g. `app.route("/credits", creditsRoutes())`
12
+
13
+ export interface CatalogEntry {
14
+ /** how it mounts into the entry. */
15
+ mount: Mount;
16
+ /** the provision fragment export, if any (`InstanceSpec[]`). */
17
+ provision?: { symbol: string; from: string };
18
+ }
19
+
20
+ export const CATALOG: Record<string, CatalogEntry> = {
21
+ app: { mount: { kind: "base" } },
22
+ auth: { mount: { kind: "middleware", symbol: "mountAuthRoutes", from: "./auth" }, provision: { symbol: "authProvision", from: "./src/provision/auth" } },
23
+ credits: { mount: { kind: "route", path: "/credits", symbol: "creditsRoutes", from: "./routes/credits" }, provision: { symbol: "creditsProvision", from: "./src/provision/credits" } },
24
+ keys: { mount: { kind: "route", path: "/keys", symbol: "keysRoutes", from: "./routes/keys" }, provision: { symbol: "keysProvision", from: "./src/provision/keys" } },
25
+ billing: { mount: { kind: "route", path: "/billing", symbol: "billingRoutes", from: "./routes/billing" }, provision: { symbol: "billingProvision", from: "./src/provision/billing" } },
26
+ logs: { mount: { kind: "route", path: "/logs", symbol: "logsRoutes", from: "./routes/logs" }, provision: { symbol: "logsProvision", from: "./src/provision/logs" } },
27
+ };
28
+
29
+ /** app + auth always come first (the base + the user/apikey tables others reference); the rest keep manifest order. */
30
+ export function orderServices(services: string[]): string[] {
31
+ const want = new Set(services);
32
+ const head = ["app", "auth"].filter((s) => want.has(s) || s === "app"); // app is always present
33
+ const rest = services.filter((s) => !head.includes(s));
34
+ return [...new Set([...head, ...rest])];
35
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The generator (C051) — the impure shell over {@link planPlatform}: run the `shadcn add`s (which fetch each module's
3
+ * owned code + install its npm deps + resolve registryDependencies), then write the generated entry + provision.config.
4
+ * `run` + `write` are INJECTED (the CLI provides a real spawn + fs; a test provides recorders), so the orchestration is
5
+ * testable. Stops short of `provision apply` — that's a live infra op the operator triggers.
6
+ */
7
+ import type { PlatformManifest } from "./manifest";
8
+ import { planPlatform, type PlatformPlan } from "./plan";
9
+
10
+ export interface GenerateOptions {
11
+ /** run a command — the CLI spawns `bunx shadcn add <ref>`; a test records. */
12
+ run: (cmd: string, args: string[]) => Promise<void>;
13
+ /** write a file (path relative to the target cwd). */
14
+ write: (path: string, content: string) => Promise<void>;
15
+ log?: (msg: string) => void;
16
+ }
17
+
18
+ export interface GenerateResult {
19
+ plan: PlatformPlan;
20
+ added: string[];
21
+ written: string[];
22
+ }
23
+
24
+ export async function generatePlatform(manifest: PlatformManifest, opts: GenerateOptions): Promise<GenerateResult> {
25
+ const log = opts.log ?? (() => {});
26
+ const plan = planPlatform(manifest);
27
+ const added: string[] = [];
28
+ for (const add of plan.adds) {
29
+ log(`▸ shadcn add ${add}`);
30
+ await opts.run("bunx", ["shadcn@latest", "add", add, "--yes"]);
31
+ added.push(add);
32
+ }
33
+ log("▸ writing src/index.ts");
34
+ await opts.write("src/index.ts", plan.entry);
35
+ log("▸ writing provision.config.ts");
36
+ await opts.write("provision.config.ts", plan.provisionConfig);
37
+ 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"] };
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @suluk/platform — the platform generator (C051). Write one `definePlatform` manifest; the generator plans the
3
+ * shadcn-registry adds, generates the wired Hono entry, and merges each module's provision fragment into a single
4
+ * provision.config. The higher-level surface over C047's provision.config + the C050 registry: `services: ["auth",
5
+ * "credits", "billing"]` → a whole backend. The generated `provision.config.ts` imports `mergeProvision` from here.
6
+ */
7
+ export { definePlatform, type PlatformManifest } from "./manifest";
8
+ export { CATALOG, orderServices, type CatalogEntry, type Mount } from "./catalog";
9
+ export { mergeProvision } from "./merge";
10
+ export { planPlatform, type PlatformPlan } from "./plan";
11
+ export { generatePlatform, type GenerateOptions, type GenerateResult } from "./generate";
@@ -0,0 +1,21 @@
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.
5
+ */
6
+ export interface PlatformManifest {
7
+ /** the app/repo name (used in the generated scaffold). */
8
+ name: string;
9
+ /** the shadcn registry, e.g. "MahmoodKhalil57/suluk". */
10
+ registry: string;
11
+ /** the services to include, in mount order — resolved against the catalog. `app` + `auth` are implied if any is listed
12
+ * but list them for clarity; the base + foundation always come first. */
13
+ services: string[];
14
+ }
15
+
16
+ /** Validate + return the manifest (throws on an empty service list). */
17
+ export function definePlatform(manifest: PlatformManifest): PlatformManifest {
18
+ if (!manifest.registry) throw new Error("platform: `registry` is required (e.g. \"MahmoodKhalil57/suluk\")");
19
+ if (!manifest.services?.length) throw new Error("platform: `services` must list at least one module");
20
+ return manifest;
21
+ }
package/src/merge.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Merge each module's provision fragment into ONE instance set (C051). Same-ref instances — every module targets the
3
+ * shared `ref: "db"` — are combined into one, UNIONING their migrations in fragment order (so auth's tables land before
4
+ * the modules that reference them). Without this, two `db` instances would collide on the ref. The generated
5
+ * provision.config calls this over the imported fragments.
6
+ */
7
+ import type { InstanceSpec } from "@suluk/provision";
8
+
9
+ interface Migration { name: string; sql: string }
10
+
11
+ export function mergeProvision(fragments: InstanceSpec[][]): InstanceSpec[] {
12
+ const byRef = new Map<string, InstanceSpec>();
13
+ for (const frag of fragments) {
14
+ for (const inst of frag) {
15
+ const existing = byRef.get(inst.ref);
16
+ if (!existing) {
17
+ byRef.set(inst.ref, structuredClone(inst));
18
+ continue;
19
+ }
20
+ const em = (existing.params?.migrations as Migration[] | undefined) ?? [];
21
+ const nm = (inst.params?.migrations as Migration[] | undefined) ?? [];
22
+ const migrations = [...em, ...nm];
23
+ existing.params = { ...existing.params, ...inst.params, ...(migrations.length ? { migrations } : {}) };
24
+ }
25
+ }
26
+ return [...byRef.values()];
27
+ }
package/src/plan.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The plan (C051) — PURE: a manifest → the shadcn-add list + the generated `src/index.ts` (the wired Hono entry) + the
3
+ * generated `provision.config.ts` (importing + merging the fragments). No I/O; `generate` executes this. Testable to the
4
+ * character.
5
+ */
6
+ import type { PlatformManifest } from "./manifest";
7
+ import { CATALOG, orderServices } from "./catalog";
8
+
9
+ export interface PlatformPlan {
10
+ services: string[];
11
+ /** shadcn refs to add, in order (e.g. "MahmoodKhalil57/suluk/credits"). */
12
+ adds: string[];
13
+ /** the generated `src/index.ts` content. */
14
+ entry: string;
15
+ /** the generated `provision.config.ts` content. */
16
+ provisionConfig: string;
17
+ }
18
+
19
+ export function planPlatform(manifest: PlatformManifest): PlatformPlan {
20
+ const services = orderServices(manifest.services);
21
+ const unknown = services.filter((s) => !CATALOG[s]);
22
+ if (unknown.length) throw new Error(`platform: unknown service(s) [${unknown.join(", ")}] — not in the catalog`);
23
+ return {
24
+ services,
25
+ adds: services.map((s) => `${manifest.registry}/${s}`),
26
+ entry: buildEntry(services),
27
+ provisionConfig: buildProvisionConfig(services),
28
+ };
29
+ }
30
+
31
+ function buildEntry(services: string[]): string {
32
+ const imports = ['import { createApp } from "./app";'];
33
+ const body: string[] = ["const app = createApp();"];
34
+ for (const s of services) {
35
+ const m = CATALOG[s].mount;
36
+ if (m.kind === "middleware") {
37
+ imports.push(`import { ${m.symbol} } from "${m.from}";`);
38
+ body.push(`${m.symbol}(app);`);
39
+ } else if (m.kind === "route") {
40
+ imports.push(`import { ${m.symbol} } from "${m.from}";`);
41
+ body.push(`app.route("${m.path}", ${m.symbol}());`);
42
+ }
43
+ }
44
+ return `// AUTO-GENERATED by @suluk/platform from platform.config.ts — the wired Hono entry. Edit freely.\n${imports.join("\n")}\n\n${body.join("\n")}\n\nexport default app;\n`;
45
+ }
46
+
47
+ function buildProvisionConfig(services: string[]): string {
48
+ const frags = services.map((s) => CATALOG[s].provision).filter((p): p is NonNullable<typeof p> => !!p);
49
+ const imports = frags.map((f) => `import { ${f.symbol} } from "${f.from}";`);
50
+ return [
51
+ "// AUTO-GENERATED by @suluk/platform — the merged provision config. Run `suluk-provision apply`.",
52
+ 'import { defineProvision } from "@suluk/provision";',
53
+ 'import { mergeProvision } from "@suluk/platform";',
54
+ ...imports,
55
+ "",
56
+ `export default defineProvision({ instances: mergeProvision([${frags.map((f) => f.symbol).join(", ")}]) });`,
57
+ "",
58
+ ].join("\n");
59
+ }
@@ -0,0 +1,75 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { definePlatform, planPlatform, mergeProvision, generatePlatform } from "../src/index";
3
+ import type { InstanceSpec } from "@suluk/provision";
4
+
5
+ /** C051 — the platform generator: manifest → plan (adds + wired entry + merged provision), the provision merge, and the
6
+ * generate orchestration (with recorders). */
7
+ const manifest = definePlatform({ name: "autotoolfactory", registry: "acme/reg", services: ["auth", "credits", "keys", "billing", "logs"] });
8
+
9
+ describe("planPlatform — manifest → shadcn adds + entry + provision.config", () => {
10
+ const plan = planPlatform(manifest);
11
+
12
+ test("orders app + auth first, then the rest; adds are registry refs", () => {
13
+ expect(plan.services).toEqual(["app", "auth", "credits", "keys", "billing", "logs"]);
14
+ expect(plan.adds).toEqual(["acme/reg/app", "acme/reg/auth", "acme/reg/credits", "acme/reg/keys", "acme/reg/billing", "acme/reg/logs"]);
15
+ });
16
+
17
+ test("the generated entry wires the base + auth middleware + each route", () => {
18
+ expect(plan.entry).toContain('import { createApp } from "./app";');
19
+ expect(plan.entry).toContain("const app = createApp();");
20
+ expect(plan.entry).toContain('import { mountAuthRoutes } from "./auth";');
21
+ expect(plan.entry).toContain("mountAuthRoutes(app);");
22
+ expect(plan.entry).toContain('import { creditsRoutes } from "./routes/credits";');
23
+ expect(plan.entry).toContain('app.route("/credits", creditsRoutes());');
24
+ expect(plan.entry).toContain('app.route("/billing", billingRoutes());');
25
+ expect(plan.entry).toContain("export default app;");
26
+ });
27
+
28
+ test("the generated provision.config imports each fragment + merges them", () => {
29
+ expect(plan.provisionConfig).toContain('import { defineProvision } from "@suluk/provision";');
30
+ expect(plan.provisionConfig).toContain('import { mergeProvision } from "@suluk/platform";');
31
+ expect(plan.provisionConfig).toContain('import { authProvision } from "./src/provision/auth";');
32
+ expect(plan.provisionConfig).toContain("mergeProvision([authProvision, creditsProvision, keysProvision, billingProvision, logsProvision])");
33
+ });
34
+
35
+ test("an unknown service throws", () => {
36
+ expect(() => planPlatform({ name: "x", registry: "r", services: ["nope"] })).toThrow(/unknown service/);
37
+ });
38
+ });
39
+
40
+ describe("mergeProvision — combine same-ref instances, union migrations in order", () => {
41
+ test("two `db` fragments merge into one with both migrations (fragment order preserved)", () => {
42
+ const auth: InstanceSpec[] = [{ ref: "db", service: "cloudflare-d1", name: "app-db", params: { migrations: [{ name: "0000_auth", sql: "A" }] }, bind: { database_id: "ID" }, protected: true }];
43
+ const credits: InstanceSpec[] = [{ ref: "db", service: "cloudflare-d1", name: "app-db", params: { migrations: [{ name: "0001_credits", sql: "C" }] }, bind: { database_id: "ID" }, protected: true }];
44
+ const merged = mergeProvision([auth, credits]);
45
+ expect(merged.length).toBe(1);
46
+ expect(merged[0].ref).toBe("db");
47
+ expect((merged[0].params!.migrations as { name: string }[]).map((m) => m.name)).toEqual(["0000_auth", "0001_credits"]);
48
+ expect(merged[0].protected).toBe(true);
49
+ });
50
+
51
+ test("distinct refs stay separate", () => {
52
+ const a: InstanceSpec[] = [{ ref: "db", service: "cloudflare-d1", name: "db", params: {} }];
53
+ const b: InstanceSpec[] = [{ ref: "kv", service: "cloudflare-kv", name: "cache", params: {} }];
54
+ expect(mergeProvision([a, b]).map((i) => i.ref).sort()).toEqual(["db", "kv"]);
55
+ });
56
+ });
57
+
58
+ describe("generatePlatform — the orchestration (with recorders)", () => {
59
+ test("runs a shadcn add per service, then writes the entry + provision.config", async () => {
60
+ const ran: string[] = [];
61
+ const wrote: string[] = [];
62
+ const res = await generatePlatform(manifest, {
63
+ run: async (cmd, args) => void ran.push(`${cmd} ${args.join(" ")}`),
64
+ write: async (path) => void wrote.push(path),
65
+ });
66
+ expect(ran).toEqual(plannedAdds()); // exactly the planned adds, in order
67
+ expect(ran.length).toBe(6); // app+auth+credits+keys+billing+logs
68
+ expect(wrote).toEqual(["src/index.ts", "provision.config.ts"]);
69
+ expect(res.added.length).toBe(6);
70
+ });
71
+ });
72
+
73
+ function plannedAdds() {
74
+ return planPlatform(manifest).adds.map((a) => `bunx shadcn@latest add ${a} --yes`);
75
+ }
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test", "bin"] }