@suluk/platform 0.1.9 → 0.2.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.
- package/README.md +77 -0
- package/bin/platform.ts +15 -3
- package/package.json +2 -1
- package/src/catalog.ts +18 -95
- package/src/generate.ts +10 -4
- package/src/index.ts +8 -2
- package/src/manifest.ts +106 -23
- package/src/plan.ts +66 -18
- package/src/resolve.ts +111 -0
- package/src/service.ts +265 -0
- package/src/wire.ts +0 -0
- package/test/plan.test.ts +0 -247
- package/tsconfig.json +0 -1
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @suluk/platform
|
|
2
|
+
|
|
3
|
+
Write **one manifest**; the generator plans the shadcn-registry adds, generates the wired Hono entry, merges each module's
|
|
4
|
+
provision fragment, and emits the whole scaffold (package.json / tsconfig / wrangler.toml / .env.example / …). The
|
|
5
|
+
higher-level surface over the [Suluk registry](https://github.com/MahmoodKhalil57/suluk) + `@suluk/provision`.
|
|
6
|
+
|
|
7
|
+
There are two authoring surfaces. The **legacy** one (C051) is a single object and is supported forever. The **C053** one
|
|
8
|
+
splits a platform into a reusable **system** and a swappable **brand**, adds typed opts, and lets services **compose**.
|
|
9
|
+
|
|
10
|
+
## The C053 model
|
|
11
|
+
|
|
12
|
+
### A service is a common interface
|
|
13
|
+
|
|
14
|
+
`defineService<SO, BO>({ id, mount, provision?, deps?, env?, serviceOpts?, brandOpts?, reads?, compose? })` — the shape a
|
|
15
|
+
community shadcn registry extends. The 19 core services are exported as typed consts (`authService`, `creditsService`, …).
|
|
16
|
+
|
|
17
|
+
### System vs brand
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { defineSystem, defineBrand, definePlatform, authService, creditsService, emailService } from "@suluk/platform";
|
|
21
|
+
import { analyticsService } from "@acme/suluk-analytics"; // a community service
|
|
22
|
+
|
|
23
|
+
export const system = defineSystem({
|
|
24
|
+
registry: "MahmoodKhalil57/suluk",
|
|
25
|
+
services: [authService, creditsService, emailService, analyticsService],
|
|
26
|
+
globalServiceOpts: { ENVIRONMENT: "production", TRUSTED_ORIGINS: "https://app.example" },
|
|
27
|
+
serviceOpts: { auth: { mcp: { loginPage: "…", consentPage: "…", resource: "…", scopes: ["credits:read"] } } }, // typed by id
|
|
28
|
+
wire: [{ id: "signup-grant", from: "auth.onUserCreated", to: "credits.grantOnSignup", with: { amount: 100 } }],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const brand = defineBrand({
|
|
32
|
+
name: "app",
|
|
33
|
+
globalBrandOpts: { BRAND_NAME: "App", BASE_URL: "https://app.example", EMAIL_FROM: "hi@app.example" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export default definePlatform({ system, brand });
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A **system** (services + serviceOpts + globalServiceOpts + wiring) is the reusable, publishable template. A **brand**
|
|
40
|
+
(brandOpts + globalBrandOpts) is thin and swappable — two businesses run the same system with different brands; the generated
|
|
41
|
+
entry *code* is identical, only `wrangler.toml [vars]` differ.
|
|
42
|
+
|
|
43
|
+
### The 2×2 opts matrix
|
|
44
|
+
|
|
45
|
+
| | per-service | global |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| **service axis** (how it works) | `serviceOpts` → the entry (mount opts) | `globalServiceOpts` (a service `reads` the keys it needs) |
|
|
48
|
+
| **brand axis** (identity) | `brandOpts` → `[vars]` | `globalBrandOpts` → `[vars]` |
|
|
49
|
+
|
|
50
|
+
`serviceOpts` is typed per service id off the imported service objects (or `CoreServiceOptsMap` for a string id) — a wrong
|
|
51
|
+
opt is a compile error.
|
|
52
|
+
|
|
53
|
+
### Composition (`wire`)
|
|
54
|
+
|
|
55
|
+
An edge binds a producer **port** to a consumer **capability**. It renders **into the producer's existing mount-opt field**
|
|
56
|
+
(e.g. `auth.onUserCreated`), not a separate statement — so it reuses a real seam and the hook closure gets a real `env`.
|
|
57
|
+
`resolveWiring` validates presence, port/capability existence, JSON-safe params, safe identifiers, and acyclicity; fan-out
|
|
58
|
+
(several wires on one port) composes in declaration order. A community service participates by offering a capability (fills a
|
|
59
|
+
core port) or exposing its own.
|
|
60
|
+
|
|
61
|
+
## CLI
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
suluk-platform # generate from ./platform.config.ts (legacy OR { system, brand })
|
|
65
|
+
suluk-platform --config <path>
|
|
66
|
+
suluk-platform migrate # print the { system, brand } split of a legacy config (a starting point)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`migrate` is byte-faithful: `liftLegacy` → the same generated app as the legacy manifest.
|
|
70
|
+
|
|
71
|
+
## Guarantees
|
|
72
|
+
|
|
73
|
+
- **Byte-identity.** A legacy manifest — and any `{ system, brand }` with no wire — regenerates the exact bytes the C051
|
|
74
|
+
generator produced (pinned by a golden test over the real 18-service reference app).
|
|
75
|
+
- **Fail closed.** A missing registry, a wrong-typed wire param, or a colliding wire import throws at generate time, not in
|
|
76
|
+
the shipped app.
|
|
77
|
+
- **Own the wiring, npm the logic** (C052). A community extends services *and* composition without forking `@suluk` logic.
|
package/bin/platform.ts
CHANGED
|
@@ -3,24 +3,36 @@
|
|
|
3
3
|
* The @suluk/platform CLI (C051) — load `platform.config.ts`, run `shadcn add` per service, write the wired entry +
|
|
4
4
|
* provision.config. Then `bun install && suluk-provision apply`.
|
|
5
5
|
*
|
|
6
|
-
* suluk-platform generate from ./platform.config.ts
|
|
6
|
+
* suluk-platform generate from ./platform.config.ts (legacy manifest OR a { system, brand } platform)
|
|
7
7
|
* --config <path> a different manifest
|
|
8
|
+
* suluk-platform migrate print the C053 { system, brand } split of a legacy platform.config.ts (a starting point)
|
|
8
9
|
*/
|
|
9
10
|
import { resolve, dirname } from "node:path";
|
|
10
11
|
import { writeFile, mkdir, readFile } from "node:fs/promises";
|
|
11
12
|
import { generatePlatform } from "../src/generate";
|
|
12
|
-
import type
|
|
13
|
+
import { isPlatform, type PlatformManifest, type Platform } from "../src/manifest";
|
|
14
|
+
import { liftLegacy } from "../src/resolve";
|
|
13
15
|
|
|
14
16
|
const argv = process.argv.slice(2);
|
|
15
17
|
const ci = argv.indexOf("--config");
|
|
16
18
|
const configPath = ci >= 0 ? argv[ci + 1] : "platform.config.ts";
|
|
17
19
|
|
|
18
|
-
const mod = (await import(resolve(process.cwd(), configPath))) as { default?: PlatformManifest };
|
|
20
|
+
const mod = (await import(resolve(process.cwd(), configPath))) as { default?: PlatformManifest | Platform };
|
|
19
21
|
if (!mod.default) {
|
|
20
22
|
console.error(`✗ ${configPath} has no default export (a definePlatform(...) result)`);
|
|
21
23
|
process.exit(2);
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
// `migrate` — show the { system, brand } split of a legacy manifest (does not rewrite the file; copy the output in).
|
|
27
|
+
if (argv[0] === "migrate") {
|
|
28
|
+
const platform = isPlatform(mod.default) ? mod.default : liftLegacy(mod.default);
|
|
29
|
+
console.log("// C053 { system, brand } split — a starting point; move brand-specific values into defineBrand:\n");
|
|
30
|
+
console.log("export const system = defineSystem(" + JSON.stringify(platform.system, null, 2) + ");");
|
|
31
|
+
console.log("export const brand = defineBrand(" + JSON.stringify(platform.brand, null, 2) + ");");
|
|
32
|
+
console.log("export default definePlatform({ system, brand });");
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
const run = (cmd: string, args: string[]): Promise<void> =>
|
|
25
37
|
new Promise((res, rej) => {
|
|
26
38
|
const p = Bun.spawn([cmd, ...args], { stdout: "inherit", stderr: "inherit", cwd: process.cwd() });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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"
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
15
|
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
16
|
"type": "module",
|
|
17
|
+
"files": ["src", "bin", "README.md"],
|
|
17
18
|
"main": "src/index.ts",
|
|
18
19
|
"bin": {
|
|
19
20
|
"suluk-platform": "bin/platform.ts"
|
package/src/catalog.ts
CHANGED
|
@@ -1,103 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The catalog (C051) —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The catalog (C051) — each service id → how to MOUNT its router + where its PROVISION fragment lives. Since C053 this is a
|
|
3
|
+
* DERIVED VIEW of {@link CORE_SERVICES} (the common {@link Service} interface): `CATALOG[id] = toCatalogEntry(service)`, so
|
|
4
|
+
* the C051 generator reads the exact same shape it always did (the Phase-0 golden lock proves byte-identity) while the
|
|
5
|
+
* authoring surface is now the open Service model. Types + the core service set live in `service.ts`.
|
|
5
6
|
*/
|
|
7
|
+
import { CORE_SERVICES, toCatalogEntry, type CatalogEntry, type EnvVar } from "./service";
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
|
|
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
|
-
| { kind: "dev" }; // dev/CI tooling (journeys, audit) — files only, no runtime mount, no provision fragment
|
|
9
|
+
// re-export the shape types from their new home so `@suluk/platform`'s public surface + `plan.ts` are unchanged.
|
|
10
|
+
export { CORE_SERVICES, toCatalogEntry, defineService } from "./service";
|
|
11
|
+
export type { Mount, EnvVar, CatalogEntry, Service, Port, Capability, CompositionSurface, Schema } from "./service";
|
|
13
12
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
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
|
-
}
|
|
13
|
+
/** The offerings, derived from the core service set. `app` is the base (no mount symbol, no fragment). */
|
|
14
|
+
export const CATALOG: Record<string, CatalogEntry> = Object.fromEntries(Object.entries(CORE_SERVICES).map(([id, s]) => [id, toCatalogEntry(s)]));
|
|
25
15
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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[];
|
|
16
|
+
// DRIFT GUARD: the derived view must expose exactly the core service ids (a dropped/renamed service would silently change
|
|
17
|
+
// the generated app). Trivially true while CATALOG is derived, but asserted so a future hand-edit can't diverge unnoticed.
|
|
18
|
+
{
|
|
19
|
+
const a = Object.keys(CORE_SERVICES).sort().join(",");
|
|
20
|
+
const b = Object.keys(CATALOG).sort().join(",");
|
|
21
|
+
if (a !== b) throw new Error(`platform: CATALOG drifted from CORE_SERVICES (${b} vs ${a})`);
|
|
38
22
|
}
|
|
39
23
|
|
|
40
|
-
export const CATALOG: Record<string, CatalogEntry> = {
|
|
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
|
-
},
|
|
53
|
-
// the contract is a MIDDLEWARE mount: it installs the scope gate (enforceApiKeyScope) + GET /api/openapi.json. Place it
|
|
54
|
-
// after `auth` in the manifest so the gate runs after identity/apiKeyAuth set keyId/scopes. Derived + stateless.
|
|
55
|
-
contract: { mount: { kind: "middleware", symbol: "mountContract", from: "./routes/contract" }, deps: ["@suluk/hono", "zod"] },
|
|
56
|
-
// the API-as-MCP server + OAuth discovery + connections — a middleware mount (registers /api/mcp + /.well-known/*).
|
|
57
|
-
mcp: { mount: { kind: "middleware", symbol: "mountMcp", from: "./routes/mcp" }, provision: { symbol: "mcpProvision", from: "./src/provision/mcp" }, deps: ["@suluk/mcp", "better-auth"] },
|
|
58
|
-
// feature routes mount under /api/* — where the caller-resolution + cors + rate-limit middleware live (toolfactory parity).
|
|
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
|
-
},
|
|
89
|
-
// cross-cutting MIDDLEWARE (apply globally via app.use, emitted before any route) — not routed resources.
|
|
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
|
|
95
|
-
logs: { mount: { kind: "route", path: "/api/logs", symbol: "logsRoutes", from: "./routes/logs" }, provision: { symbol: "logsProvision", from: "./src/provision/logs" } },
|
|
96
|
-
// dev/CI tooling — pulled in as files, no runtime mount, no provision fragment.
|
|
97
|
-
journeys: { mount: { kind: "dev" }, deps: ["@suluk/journeys"] },
|
|
98
|
-
audit: { mount: { kind: "dev" }, deps: ["@suluk/cockpit", "@suluk/harden"] },
|
|
99
|
-
};
|
|
100
|
-
|
|
101
24
|
/**
|
|
102
25
|
* The always-present framework deps (every generated app: the Effect services + Hono entry + the merged provision.config
|
|
103
26
|
* that imports mergeProvision from @suluk/platform + defineProvision from @suluk/provision). Union'd with each service's
|
|
@@ -134,10 +57,10 @@ export function resolveVersion(dep: string): string {
|
|
|
134
57
|
|
|
135
58
|
/** The env vars the selected services need, de-duped by name (first declaration wins). Split with `.secret` into the
|
|
136
59
|
* `.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[] {
|
|
60
|
+
export function collectEnv(services: string[], catalog: Record<string, { env?: EnvVar[] }> = CATALOG): EnvVar[] {
|
|
138
61
|
const seen = new Set<string>();
|
|
139
62
|
const out: EnvVar[] = [];
|
|
140
|
-
for (const s of services) for (const e of
|
|
63
|
+
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
64
|
return out;
|
|
142
65
|
}
|
|
143
66
|
|
package/src/generate.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* `run` + `write` are INJECTED (the CLI provides a real spawn + fs; a test provides recorders), so the orchestration is
|
|
5
5
|
* testable. Stops short of `provision apply` — that's a live infra op the operator triggers.
|
|
6
6
|
*/
|
|
7
|
-
import type
|
|
8
|
-
import {
|
|
7
|
+
import { type PlatformManifest, type Platform, isPlatform } from "./manifest";
|
|
8
|
+
import { liftSystemBrand } from "./resolve";
|
|
9
|
+
import { planPlatform, mergePackageJson, mergeWranglerToml, mergeGitignore, type PlatformPlan } from "./plan";
|
|
9
10
|
|
|
10
11
|
export interface GenerateOptions {
|
|
11
12
|
/** run a command — the CLI spawns `bunx shadcn add <ref>`; a test records. */
|
|
@@ -25,9 +26,10 @@ export interface GenerateResult {
|
|
|
25
26
|
written: string[];
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export async function generatePlatform(
|
|
29
|
+
export async function generatePlatform(input: PlatformManifest | Platform, opts: GenerateOptions): Promise<GenerateResult> {
|
|
29
30
|
const log = opts.log ?? (() => {});
|
|
30
31
|
const read = opts.read ?? (async () => null);
|
|
32
|
+
const manifest = isPlatform(input) ? liftSystemBrand(input) : input;
|
|
31
33
|
const plan = planPlatform(manifest);
|
|
32
34
|
const written: string[] = [];
|
|
33
35
|
|
|
@@ -43,10 +45,14 @@ export async function generatePlatform(manifest: PlatformManifest, opts: Generat
|
|
|
43
45
|
log("▸ writing wrangler.toml");
|
|
44
46
|
await opts.write("wrangler.toml", mergeWranglerToml(plan.wranglerToml, await read("wrangler.toml")));
|
|
45
47
|
written.push("wrangler.toml");
|
|
48
|
+
// .gitignore MERGES (append missing entries) — critically ensures .env/.env.temp are ignored even if the app already had
|
|
49
|
+
// a minimal .gitignore, so secrets are never committed. .env.example + the env-check are always (re)written (no values).
|
|
50
|
+
log("▸ writing .gitignore");
|
|
51
|
+
await opts.write(".gitignore", mergeGitignore(plan.gitignore, await read(".gitignore")));
|
|
52
|
+
written.push(".gitignore");
|
|
46
53
|
for (const [file, content, always] of [
|
|
47
54
|
["tsconfig.json", plan.tsconfig, false],
|
|
48
55
|
["components.json", plan.componentsJson, false],
|
|
49
|
-
[".gitignore", plan.gitignore, false],
|
|
50
56
|
[".env.example", plan.envExample, true], // a checked-in template (no values) — keep it current
|
|
51
57
|
["scripts/env-check.ts", plan.envCheck, true], // the .env.temp lifecycle preflight
|
|
52
58
|
] as const) {
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
* provision.config. The higher-level surface over C047's provision.config + the C050 registry: `services: ["auth",
|
|
5
5
|
* "credits", "billing"]` → a whole backend. The generated `provision.config.ts` imports `mergeProvision` from here.
|
|
6
6
|
*/
|
|
7
|
-
export { definePlatform, type PlatformManifest } from "./manifest";
|
|
7
|
+
export { definePlatform, defineSystem, defineBrand, isPlatform, type PlatformManifest, type SystemManifest, type BrandManifest, type Platform, type ServiceRef, type WireDecl } from "./manifest";
|
|
8
|
+
export { resolveNodeOpts, liftSystemBrand, liftLegacy, serviceId } from "./resolve";
|
|
9
|
+
export { resolveWiring, groupImports, assertJsonSafe, validateIdentifier, lit, type Wiring, type WireImport } from "./wire";
|
|
10
|
+
// C053 — the open Service interface (the common shape community registries extend): defineService + the core service set.
|
|
11
|
+
export { defineService, optsType, CORE_SERVICES, toCatalogEntry, type Service, type Port, type Capability, type CompositionSurface, type Schema, type McpOAuthOpts, type AuthServiceOpts } from "./service";
|
|
12
|
+
// the core services as named, typed consts — import these into `defineSystem({ services: [...] })` for typed opts by id.
|
|
13
|
+
export { appService, authService, contractService, mcpService, creditsService, keysService, billingService, costService, erasureService, emailService, webhooksService, rateLimitService, rateCreditService, i18nService, referenceService, adminService, logsService, journeysService, auditService } from "./service";
|
|
8
14
|
export { CATALOG, orderServices, collectEnv, resolveVersion, BASE_DEPS, ECOSYSTEM_VERSIONS, DEV_DEPS, type CatalogEntry, type Mount, type EnvVar } from "./catalog";
|
|
9
15
|
export { mergeProvision } from "./merge";
|
|
10
|
-
export { planPlatform, buildPackageJson, mergePackageJson, mergeWranglerToml, type PlatformPlan } from "./plan";
|
|
16
|
+
export { planPlatform, buildPackageJson, mergePackageJson, mergeWranglerToml, mergeGitignore, type PlatformPlan } from "./plan";
|
|
11
17
|
export { generatePlatform, type GenerateOptions, type GenerateResult } from "./generate";
|
package/src/manifest.ts
CHANGED
|
@@ -1,36 +1,119 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The platform manifest
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
2
|
+
* The platform manifest. TWO authoring surfaces:
|
|
3
|
+
* - LEGACY {@link PlatformManifest} (C051) — `definePlatform({ name, registry, services, opts?, vars? })`. Kept FOREVER; the
|
|
4
|
+
* C053 refactor keeps it a strict subset so an existing config regenerates byte-for-byte (the Phase-0 golden lock).
|
|
5
|
+
* - C053 {@link SystemManifest} + {@link BrandManifest} — `definePlatform({ system, brand })`. A SYSTEM (services + their
|
|
6
|
+
* serviceOpts + globalServiceOpts + composition) is the REUSABLE/PUBLISHABLE template; a BRAND (brandOpts + globalBrandOpts)
|
|
7
|
+
* is thin + SWAPPABLE. `defineSystem` is GENERIC over the services TUPLE, so `serviceOpts` is typed per service id off the
|
|
8
|
+
* imported service objects — no codegen. The new shape LOWERS into a legacy manifest (`liftSystemBrand`) and runs the same
|
|
9
|
+
* generator.
|
|
8
10
|
*/
|
|
11
|
+
import type { Service, Schema, CoreServiceOptsMap } from "./service";
|
|
12
|
+
|
|
13
|
+
/** The C051 legacy manifest — still valid, still the byte-identity anchor. */
|
|
9
14
|
export interface PlatformManifest {
|
|
10
15
|
/** the app/repo name (used in the generated scaffold). */
|
|
11
16
|
name: string;
|
|
12
17
|
/** the shadcn registry, e.g. "MahmoodKhalil57/suluk". */
|
|
13
18
|
registry: string;
|
|
14
|
-
/** the services to include, in mount order — resolved against the catalog. `app` + `auth` are implied if any is listed
|
|
15
|
-
* but list them for clarity; the base + foundation always come first. */
|
|
19
|
+
/** the services to include, in mount order — resolved against the catalog. `app` + `auth` are implied if any is listed. */
|
|
16
20
|
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
|
-
*/
|
|
21
|
+
/** per-service static OPTIONS passed to that service's mount in the generated entry (JSON-serializable). */
|
|
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
|
-
*/
|
|
23
|
+
/** NON-SECRET config values → generated into `wrangler.toml` `[vars]`. SECRETS never go here (they live in `.env`). */
|
|
28
24
|
vars?: Record<string, string>;
|
|
29
25
|
}
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
// ── C053: the open system/brand surface ──────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** A reference to a service: an imported {@link Service} object (fully typed) or a bare string id (resolved against the
|
|
30
|
+
* catalog; opts typed as `unknown`). */
|
|
31
|
+
export type ServiceRef = string | Service<any, any>;
|
|
32
|
+
|
|
33
|
+
/** the service id of a ref (a Service object's literal `id`, or the string itself). */
|
|
34
|
+
type IdOf<R> = R extends { id: infer Id extends string } ? Id : R extends string ? R : never;
|
|
35
|
+
/** the serviceOpts value type a ref carries: from its `serviceOpts` marker (imported object); else the {@link
|
|
36
|
+
* CoreServiceOptsMap} entry for a known core string id; else `unknown` for an unknown string; `{}` for a typed service
|
|
37
|
+
* without opts. */
|
|
38
|
+
type SoOf<R> = R extends { serviceOpts: Schema<infer SO> } ? SO : R extends keyof CoreServiceOptsMap ? CoreServiceOptsMap[R] : R extends string ? unknown : {};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An inter-service composition EDGE (Phase 3). Declared here so a Phase-2 manifest's shape is forward-compatible; the
|
|
42
|
+
* resolver ignores `wire` until the Phase-3 engine lands. `from`/`to` are `"<service>.<port|capability>"`.
|
|
43
|
+
*/
|
|
44
|
+
export interface WireDecl {
|
|
45
|
+
id?: string;
|
|
46
|
+
from: string;
|
|
47
|
+
to: string;
|
|
48
|
+
with?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A SYSTEM — the reusable, publishable template. Generic over the services tuple so `serviceOpts` is typed by service id. */
|
|
52
|
+
export interface SystemManifest<T extends readonly ServiceRef[] = readonly ServiceRef[]> {
|
|
53
|
+
/** the single core registry, e.g. "MahmoodKhalil57/suluk". (Multi-registry alias map: `registries`, Phase 4.) */
|
|
54
|
+
registry?: string;
|
|
55
|
+
/** alias → registry map for multi-registry systems (Phase 4). `registries.core` is the default when `registry` is unset. */
|
|
56
|
+
registries?: Record<string, string>;
|
|
57
|
+
/** the services, in mount order — imported {@link Service} objects (typed) and/or string ids. */
|
|
58
|
+
services: T;
|
|
59
|
+
/** system-wide behaviour shared by services; a service receives the keys it names in `reads.globalService` (else inert). */
|
|
60
|
+
globalServiceOpts?: Record<string, unknown>;
|
|
61
|
+
/** per-service serviceOpts — TYPED by service id off the imported service objects. */
|
|
62
|
+
serviceOpts?: Partial<{ [K in T[number] as IdOf<K>]: SoOf<K> }>;
|
|
63
|
+
/** inter-service composition edges (Phase 3). */
|
|
64
|
+
wire?: WireDecl[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A BRAND — thin, swappable per deployment. Carries the app identity + the brand-facing opts (→ `[vars]`). */
|
|
68
|
+
export interface BrandManifest {
|
|
69
|
+
/** the deployment/app name (the wrangler + package name). Differs per brand of the same system. */
|
|
70
|
+
name: string;
|
|
71
|
+
/** brand identity shared by every service (BRAND_NAME, baseUrl, emailFrom, …) → `[vars]`. */
|
|
72
|
+
globalBrandOpts?: Record<string, unknown>;
|
|
73
|
+
/** per-service brand-facing opts → `[vars]`. */
|
|
74
|
+
brandOpts?: Record<string, Record<string, unknown>>;
|
|
75
|
+
/** brand-tunable EDGE params keyed by `wire.id` (Phase 3). */
|
|
76
|
+
wireBrandOpts?: Record<string, Record<string, unknown>>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A bound platform = a system + a brand. */
|
|
80
|
+
export interface Platform {
|
|
81
|
+
system: SystemManifest<any>;
|
|
82
|
+
brand: BrandManifest;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Author a SYSTEM. `const T` captures the services tuple so `serviceOpts` types resolve per service id. */
|
|
86
|
+
export function defineSystem<const T extends readonly ServiceRef[]>(s: SystemManifest<T>): SystemManifest<T> {
|
|
87
|
+
if (!s.services?.length) throw new Error("defineSystem: `services` must list at least one service");
|
|
88
|
+
return s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Author a BRAND. */
|
|
92
|
+
export function defineBrand(b: BrandManifest): BrandManifest {
|
|
93
|
+
if (!b.name) throw new Error("defineBrand: `name` is required");
|
|
94
|
+
return b;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate + return a platform. Accepts BOTH the legacy {@link PlatformManifest} and the C053 `{ system, brand }` shape
|
|
99
|
+
* (discriminated on the `system` key). Overloaded so the return type matches the input surface.
|
|
100
|
+
*/
|
|
101
|
+
export function definePlatform(input: PlatformManifest): PlatformManifest;
|
|
102
|
+
export function definePlatform(input: Platform): Platform;
|
|
103
|
+
export function definePlatform(input: PlatformManifest | Platform): PlatformManifest | Platform {
|
|
104
|
+
if (isPlatform(input)) {
|
|
105
|
+
if (!input.system?.services?.length) throw new Error("platform: `system.services` must list at least one service");
|
|
106
|
+
if (!input.brand?.name) throw new Error("platform: `brand.name` is required");
|
|
107
|
+
// fail CLOSED on a missing registry (the legacy surface does), so every `adds` entry is `<registry>/<service>`.
|
|
108
|
+
if (!input.system.registry && !input.system.registries?.core) throw new Error('platform: `system.registry` (or `system.registries.core`) is required (e.g. "MahmoodKhalil57/suluk")');
|
|
109
|
+
return input;
|
|
110
|
+
}
|
|
111
|
+
if (!input.registry) throw new Error('platform: `registry` is required (e.g. "MahmoodKhalil57/suluk")');
|
|
112
|
+
if (!input.services?.length) throw new Error("platform: `services` must list at least one module");
|
|
113
|
+
return input;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Discriminate the C053 `{ system, brand }` shape from the legacy manifest. */
|
|
117
|
+
export function isPlatform(input: PlatformManifest | Platform): input is Platform {
|
|
118
|
+
return typeof input === "object" && input !== null && "system" in input;
|
|
36
119
|
}
|
package/src/plan.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* generated `provision.config.ts` (importing + merging the fragments). No I/O; `generate` executes this. Testable to the
|
|
4
4
|
* character.
|
|
5
5
|
*/
|
|
6
|
-
import type
|
|
7
|
-
import {
|
|
6
|
+
import { type PlatformManifest, type Platform, isPlatform } from "./manifest";
|
|
7
|
+
import { liftSystemBrand } from "./resolve";
|
|
8
|
+
import { resolveWiring, groupImports, type Wiring } from "./wire";
|
|
9
|
+
import { CATALOG, CORE_SERVICES, orderServices, collectEnv, BASE_DEPS, DEV_DEPS, resolveVersion, type EnvVar, type Service } from "./catalog";
|
|
8
10
|
|
|
9
11
|
export interface PlatformPlan {
|
|
10
12
|
services: string[];
|
|
@@ -31,17 +33,28 @@ export interface PlatformPlan {
|
|
|
31
33
|
envCheck: string;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
export function planPlatform(
|
|
36
|
+
export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
|
|
37
|
+
// C053: a `{ system, brand }` platform lowers to the legacy manifest first, then the UNCHANGED lowering runs — so the
|
|
38
|
+
// legacy path is byte-for-byte identical and the new surface is sugar over it.
|
|
39
|
+
const manifest = isPlatform(input) ? liftSystemBrand(input) : input;
|
|
40
|
+
// the EFFECTIVE catalog = core services + any inline (community) Service objects a `{system,brand}` platform carries. It
|
|
41
|
+
// threads through EVERY emitter (mounts, provision, deps, env, wiring), so a community service contributes end-to-end. For
|
|
42
|
+
// the legacy path and an all-core `{system,brand}` it === CORE_SERVICES → the Phase-0 golden lock still holds byte-for-byte.
|
|
43
|
+
const catalog: Record<string, Service> = { ...CORE_SERVICES };
|
|
44
|
+
if (isPlatform(input)) for (const ref of input.system.services) if (typeof ref !== "string") catalog[ref.id] = ref;
|
|
35
45
|
const services = orderServices(manifest.services);
|
|
36
|
-
const unknown = services.filter((s) => !
|
|
46
|
+
const unknown = services.filter((s) => !catalog[s]);
|
|
37
47
|
if (unknown.length) throw new Error(`platform: unknown service(s) [${unknown.join(", ")}] — not in the catalog`);
|
|
38
|
-
const env = collectEnv(services);
|
|
48
|
+
const env = collectEnv(services, catalog);
|
|
49
|
+
// resolve the wires (a `{system,brand}` platform may carry `wire`; a legacy manifest never does → no wiring → byte-identical).
|
|
50
|
+
const wiring = resolveWiring(services, isPlatform(input) ? input.system.wire ?? [] : [], catalog);
|
|
39
51
|
return {
|
|
40
52
|
services,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
// a service may override the registry it's pulled from (multi-registry); core services fall back to the system registry.
|
|
54
|
+
adds: services.map((s) => `${catalog[s].registry ?? manifest.registry}/${s}`),
|
|
55
|
+
entry: buildEntry(services, manifest.opts, wiring, catalog),
|
|
56
|
+
provisionConfig: buildProvisionConfig(services, catalog),
|
|
57
|
+
packageJson: buildPackageJson(manifest.name, services, catalog),
|
|
45
58
|
tsconfig: buildTsconfig(),
|
|
46
59
|
componentsJson: buildComponentsJson(),
|
|
47
60
|
envExample: buildEnvExample(env),
|
|
@@ -137,6 +150,18 @@ function buildGitignore(): string {
|
|
|
137
150
|
return ["node_modules/", ".env", ".env.temp", ".dev.vars", ".wrangler/", "dist/", ""].join("\n");
|
|
138
151
|
}
|
|
139
152
|
|
|
153
|
+
/** Merge the generated .gitignore into an existing one — APPEND any missing entries (never skip-if-present, so an app's
|
|
154
|
+
* minimal .gitignore can't leave `.env`/`.env.temp` UNIGNORED and risk committing secrets). Dedup, preserve app entries. */
|
|
155
|
+
export function mergeGitignore(generated: string, existing: string | null): string {
|
|
156
|
+
if (!existing) return generated;
|
|
157
|
+
const norm = (s: string) => s.trim().replace(/\/$/, "");
|
|
158
|
+
const have = new Set(existing.split("\n").map(norm).filter(Boolean));
|
|
159
|
+
const add = generated.split("\n").filter((l) => l.trim() && !have.has(norm(l)));
|
|
160
|
+
if (!add.length) return existing.endsWith("\n") ? existing : existing + "\n";
|
|
161
|
+
const base = existing.replace(/\n*$/, "");
|
|
162
|
+
return `${base}\n${add.join("\n")}\n`;
|
|
163
|
+
}
|
|
164
|
+
|
|
140
165
|
/** The `.env.temp` lifecycle preflight (run via `predev` / `bun run check`): if every REQUIRED secret is present (in `.env`
|
|
141
166
|
* or the process env), delete `.env.temp`; else write `.env.temp` from `.env.example` + report the missing keys + fail. */
|
|
142
167
|
function buildEnvCheckScript(env: EnvVar[]): string {
|
|
@@ -181,9 +206,9 @@ process.exit(1);
|
|
|
181
206
|
|
|
182
207
|
/** The framework baseline package.json — name from the manifest, the union of BASE + each service's deps (versions
|
|
183
208
|
* resolved: @suluk/* → "latest", ecosystem → pinned), + the toolchain devDeps + the regenerate/typecheck scripts. */
|
|
184
|
-
export function buildPackageJson(name: string, services: string[]): string {
|
|
209
|
+
export function buildPackageJson(name: string, services: string[], catalog: Record<string, Service> = CORE_SERVICES): string {
|
|
185
210
|
const deps = new Set<string>(BASE_DEPS);
|
|
186
|
-
for (const s of services) for (const d of
|
|
211
|
+
for (const s of services) for (const d of catalog[s]?.deps ?? []) deps.add(d);
|
|
187
212
|
const dependencies: Record<string, string> = {};
|
|
188
213
|
for (const d of [...deps].sort()) dependencies[d] = resolveVersion(d);
|
|
189
214
|
const pkg = {
|
|
@@ -269,33 +294,56 @@ function buildComponentsJson(): string {
|
|
|
269
294
|
);
|
|
270
295
|
}
|
|
271
296
|
|
|
272
|
-
function buildEntry(services: string[], opts?: Record<string, Record<string, unknown
|
|
297
|
+
function buildEntry(services: string[], opts?: Record<string, Record<string, unknown>>, wiring?: Wiring, catalog: Record<string, Service> = CORE_SERVICES): string {
|
|
273
298
|
const imports = ['import { createApp } from "./app";'];
|
|
274
299
|
const middleware: string[] = [];
|
|
275
300
|
const routes: string[] = [];
|
|
276
|
-
|
|
301
|
+
const hooksByService = wiring?.hooksByService ?? {};
|
|
302
|
+
// every identifier bound at the top of the entry (base + each mount) → its module. A wire import that reuses a symbol from
|
|
303
|
+
// a DIFFERENT module would shadow/duplicate-declare it, so reject it (fail closed); a same-module re-import is deduped.
|
|
304
|
+
const bound = new Map<string, string>([["createApp", "./app"]]);
|
|
305
|
+
// a service's mount opts = its serviceOpts (JSON) + any wire-injected hook closures (raw code). With NO hooks, render is
|
|
306
|
+
// the EXACT legacy JSON.stringify path (byte-identical); with hooks, a mixed object literal (JSON values + code fields).
|
|
277
307
|
const optOf = (s: string): string => {
|
|
278
|
-
const
|
|
279
|
-
|
|
308
|
+
const so = opts?.[s];
|
|
309
|
+
const hooks = hooksByService[s];
|
|
310
|
+
if (!hooks || !Object.keys(hooks).length) {
|
|
311
|
+
return so && Object.keys(so).length ? `, ${JSON.stringify(so)}` : "";
|
|
312
|
+
}
|
|
313
|
+
const parts: string[] = [];
|
|
314
|
+
for (const [k, v] of Object.entries(so ?? {})) parts.push(`${JSON.stringify(k)}: ${JSON.stringify(v)}`);
|
|
315
|
+
for (const [k, code] of Object.entries(hooks)) parts.push(`${JSON.stringify(k)}: ${code}`);
|
|
316
|
+
return `, { ${parts.join(", ")} }`;
|
|
280
317
|
};
|
|
281
318
|
// TWO passes: ALL middleware mounts (app.use / handler) emit BEFORE any route mount, so a cross-cutting concern
|
|
282
319
|
// (auth, rate-limit, i18n) applies to every route regardless of where it sits in the manifest.
|
|
283
320
|
for (const s of services) {
|
|
284
|
-
const m =
|
|
321
|
+
const m = catalog[s].mount;
|
|
285
322
|
if (m.kind === "middleware") {
|
|
286
323
|
imports.push(`import { ${m.symbol} } from "${m.from}";`);
|
|
324
|
+
bound.set(m.symbol, m.from);
|
|
287
325
|
middleware.push(`${m.symbol}(app${optOf(s)});`);
|
|
288
326
|
} else if (m.kind === "route") {
|
|
289
327
|
imports.push(`import { ${m.symbol} } from "${m.from}";`);
|
|
328
|
+
bound.set(m.symbol, m.from);
|
|
290
329
|
routes.push(`app.route("${m.path}", ${m.symbol}(${optOf(s).replace(/^, /, "")}));`);
|
|
291
330
|
}
|
|
292
331
|
}
|
|
332
|
+
// the wires' consumed capabilities need imports (e.g. Effect / Credits / CreditsLive / DbLive) — appended after the mounts,
|
|
333
|
+
// rejecting any that collides with a base/mount symbol from a different module (would break the generated entry).
|
|
334
|
+
const safeWireImports = (wiring?.imports ?? []).filter((imp) => {
|
|
335
|
+
const existing = bound.get(imp.symbol);
|
|
336
|
+
if (existing === undefined) return (bound.set(imp.symbol, imp.from), true);
|
|
337
|
+
if (existing !== imp.from) throw new Error(`wire: import symbol "${imp.symbol}" (from "${imp.from}") collides with an existing import from "${existing}" — rename the capability's export`);
|
|
338
|
+
return false; // same symbol + module already imported → dedup
|
|
339
|
+
});
|
|
340
|
+
for (const line of groupImports(safeWireImports)) imports.push(line);
|
|
293
341
|
const body = ["const app = createApp();", ...middleware, ...routes];
|
|
294
342
|
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`;
|
|
295
343
|
}
|
|
296
344
|
|
|
297
|
-
function buildProvisionConfig(services: string[]): string {
|
|
298
|
-
const frags = services.map((s) =>
|
|
345
|
+
function buildProvisionConfig(services: string[], catalog: Record<string, Service> = CORE_SERVICES): string {
|
|
346
|
+
const frags = services.map((s) => catalog[s].provision).filter((p): p is NonNullable<typeof p> => !!p);
|
|
299
347
|
const imports = frags.map((f) => `import { ${f.symbol} } from "${f.from}";`);
|
|
300
348
|
return [
|
|
301
349
|
"// AUTO-GENERATED by @suluk/platform — the merged provision config. Run `suluk-provision apply`.",
|