@suluk/platform 0.1.10 → 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 +4 -2
- package/src/index.ts +7 -1
- package/src/manifest.ts +106 -23
- package/src/plan.ts +54 -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 -254
- 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,7 +4,8 @@
|
|
|
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
|
|
7
|
+
import { type PlatformManifest, type Platform, isPlatform } from "./manifest";
|
|
8
|
+
import { liftSystemBrand } from "./resolve";
|
|
8
9
|
import { planPlatform, mergePackageJson, mergeWranglerToml, mergeGitignore, type PlatformPlan } from "./plan";
|
|
9
10
|
|
|
10
11
|
export interface GenerateOptions {
|
|
@@ -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
|
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
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
16
|
export { planPlatform, buildPackageJson, mergePackageJson, mergeWranglerToml, mergeGitignore, type PlatformPlan } from "./plan";
|
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),
|
|
@@ -193,9 +206,9 @@ process.exit(1);
|
|
|
193
206
|
|
|
194
207
|
/** The framework baseline package.json — name from the manifest, the union of BASE + each service's deps (versions
|
|
195
208
|
* resolved: @suluk/* → "latest", ecosystem → pinned), + the toolchain devDeps + the regenerate/typecheck scripts. */
|
|
196
|
-
export function buildPackageJson(name: string, services: string[]): string {
|
|
209
|
+
export function buildPackageJson(name: string, services: string[], catalog: Record<string, Service> = CORE_SERVICES): string {
|
|
197
210
|
const deps = new Set<string>(BASE_DEPS);
|
|
198
|
-
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);
|
|
199
212
|
const dependencies: Record<string, string> = {};
|
|
200
213
|
for (const d of [...deps].sort()) dependencies[d] = resolveVersion(d);
|
|
201
214
|
const pkg = {
|
|
@@ -281,33 +294,56 @@ function buildComponentsJson(): string {
|
|
|
281
294
|
);
|
|
282
295
|
}
|
|
283
296
|
|
|
284
|
-
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 {
|
|
285
298
|
const imports = ['import { createApp } from "./app";'];
|
|
286
299
|
const middleware: string[] = [];
|
|
287
300
|
const routes: string[] = [];
|
|
288
|
-
|
|
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).
|
|
289
307
|
const optOf = (s: string): string => {
|
|
290
|
-
const
|
|
291
|
-
|
|
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(", ")} }`;
|
|
292
317
|
};
|
|
293
318
|
// TWO passes: ALL middleware mounts (app.use / handler) emit BEFORE any route mount, so a cross-cutting concern
|
|
294
319
|
// (auth, rate-limit, i18n) applies to every route regardless of where it sits in the manifest.
|
|
295
320
|
for (const s of services) {
|
|
296
|
-
const m =
|
|
321
|
+
const m = catalog[s].mount;
|
|
297
322
|
if (m.kind === "middleware") {
|
|
298
323
|
imports.push(`import { ${m.symbol} } from "${m.from}";`);
|
|
324
|
+
bound.set(m.symbol, m.from);
|
|
299
325
|
middleware.push(`${m.symbol}(app${optOf(s)});`);
|
|
300
326
|
} else if (m.kind === "route") {
|
|
301
327
|
imports.push(`import { ${m.symbol} } from "${m.from}";`);
|
|
328
|
+
bound.set(m.symbol, m.from);
|
|
302
329
|
routes.push(`app.route("${m.path}", ${m.symbol}(${optOf(s).replace(/^, /, "")}));`);
|
|
303
330
|
}
|
|
304
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);
|
|
305
341
|
const body = ["const app = createApp();", ...middleware, ...routes];
|
|
306
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`;
|
|
307
343
|
}
|
|
308
344
|
|
|
309
|
-
function buildProvisionConfig(services: string[]): string {
|
|
310
|
-
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);
|
|
311
347
|
const imports = frags.map((f) => `import { ${f.symbol} } from "${f.from}";`);
|
|
312
348
|
return [
|
|
313
349
|
"// AUTO-GENERATED by @suluk/platform — the merged provision config. Run `suluk-provision apply`.",
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C053 Phase 2 — resolve the four opts quadrants of a `{ system, brand }` platform and LOWER it into the legacy
|
|
3
|
+
* `PlatformManifest` the C051 generator already consumes. This is the byte-identity strategy: the new authoring surface is
|
|
4
|
+
* sugar that normalizes DOWN to `{ services, opts, vars }`, then the UNCHANGED `planPlatform` renders it — so a system/brand
|
|
5
|
+
* manifest equivalent to the legacy one produces the SAME bytes (the Phase-0 golden lock proves it), and the legacy path is
|
|
6
|
+
* untouched.
|
|
7
|
+
*
|
|
8
|
+
* The 2×2 (NODE opts):
|
|
9
|
+
* - serviceOpts (per-service) + globalServiceOpts-keys-a-service `reads` → the ENTRY (a mount's opts object) → `opts`
|
|
10
|
+
* - brandOpts (per-service) + globalBrandOpts + env-shaped globalServiceOpts → wrangler `[vars]`/env → `vars`
|
|
11
|
+
* Composition (`wire`) is Phase 3 — ignored here.
|
|
12
|
+
*/
|
|
13
|
+
import type { PlatformManifest } from "./manifest";
|
|
14
|
+
import type { Platform, SystemManifest, BrandManifest } from "./manifest";
|
|
15
|
+
import { CORE_SERVICES } from "./service";
|
|
16
|
+
|
|
17
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
18
|
+
const isScalar = (v: unknown): v is string | number | boolean => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
19
|
+
|
|
20
|
+
/** Deep-merge b over a (b wins), preserving a's key insertion order then b's new keys — so the emitted JSON is stable. */
|
|
21
|
+
function deepMerge(a: Record<string, unknown>, b: Record<string, unknown>): Record<string, unknown> {
|
|
22
|
+
const out: Record<string, unknown> = { ...a };
|
|
23
|
+
for (const [k, v] of Object.entries(b)) {
|
|
24
|
+
const prev = out[k];
|
|
25
|
+
out[k] = isPlainObject(prev) && isPlainObject(v) ? deepMerge(prev, v) : v;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pick = (o: Record<string, unknown>, keys: string[]): Record<string, unknown> => {
|
|
31
|
+
const out: Record<string, unknown> = {};
|
|
32
|
+
for (const k of keys) if (k in o) out[k] = o[k];
|
|
33
|
+
return out;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** A ServiceRef → its runtime id (a string ref is the id; a Service object contributes `.id`). */
|
|
37
|
+
export function serviceId(ref: string | { id: string }): string {
|
|
38
|
+
return typeof ref === "string" ? ref : ref.id;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the node quadrants of `{ system, brand }` into the `{ services, opts, vars }` a legacy manifest carries:
|
|
43
|
+
* - `opts[id]` (→ entry): the globalServiceOpts keys the service `reads`, deep-merged UNDER its per-service serviceOpts.
|
|
44
|
+
* Empty results are omitted, so the map matches a hand-written legacy manifest (which only lists services that HAVE opts).
|
|
45
|
+
* - `vars` (→ [vars]): every scalar value across globalServiceOpts + globalBrandOpts + per-service brandOpts. `buildWrangler`
|
|
46
|
+
* only surfaces the ones that are declared service env vars, so extra keys are harmless.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveNodeOpts(system: SystemManifest, brand: BrandManifest): { services: string[]; opts: Record<string, Record<string, unknown>>; vars: Record<string, string> } {
|
|
49
|
+
const services = system.services.map(serviceId);
|
|
50
|
+
const gso = (system.globalServiceOpts ?? {}) as Record<string, unknown>;
|
|
51
|
+
|
|
52
|
+
const opts: Record<string, Record<string, unknown>> = {};
|
|
53
|
+
for (const id of services) {
|
|
54
|
+
const reads = CORE_SERVICES[id]?.reads?.globalService ?? [];
|
|
55
|
+
const fromGlobal = pick(gso, reads);
|
|
56
|
+
const perService = (system.serviceOpts as Record<string, Record<string, unknown>> | undefined)?.[id] ?? {};
|
|
57
|
+
const eff = deepMerge(fromGlobal, perService);
|
|
58
|
+
if (Object.keys(eff).length) opts[id] = eff;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const vars: Record<string, string> = {};
|
|
62
|
+
const foldScalars = (o: Record<string, unknown> | undefined) => {
|
|
63
|
+
for (const [k, v] of Object.entries(o ?? {})) if (isScalar(v)) vars[k] = String(v);
|
|
64
|
+
};
|
|
65
|
+
foldScalars(gso);
|
|
66
|
+
foldScalars(brand.globalBrandOpts as Record<string, unknown> | undefined);
|
|
67
|
+
for (const perBrand of Object.values(brand.brandOpts ?? {})) foldScalars(perBrand as Record<string, unknown>);
|
|
68
|
+
|
|
69
|
+
return { services, opts, vars };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** env-shaped globalServiceOpts (system behaviour delivered as a runtime env var) — the rest of `vars` is brand identity. */
|
|
73
|
+
const SYSTEM_VAR_NAMES = new Set(["TRUSTED_ORIGINS", "ENVIRONMENT"]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The MIGRATE direction — a legacy {@link PlatformManifest} → the C053 `{ system, brand }` split (the inverse of
|
|
77
|
+
* {@link liftSystemBrand}). `opts` → per-service serviceOpts; `vars` split into globalServiceOpts (system-shaped) vs
|
|
78
|
+
* globalBrandOpts (identity). Round-trips byte-for-byte: `liftSystemBrand(liftLegacy(m))` generates the same app as `m`.
|
|
79
|
+
*/
|
|
80
|
+
export function liftLegacy(m: PlatformManifest): Platform {
|
|
81
|
+
const globalServiceOpts: Record<string, string> = {};
|
|
82
|
+
const globalBrandOpts: Record<string, string> = {};
|
|
83
|
+
for (const [k, v] of Object.entries(m.vars ?? {})) (SYSTEM_VAR_NAMES.has(k) ? globalServiceOpts : globalBrandOpts)[k] = v;
|
|
84
|
+
return {
|
|
85
|
+
system: {
|
|
86
|
+
registry: m.registry,
|
|
87
|
+
services: m.services,
|
|
88
|
+
...(Object.keys(globalServiceOpts).length ? { globalServiceOpts } : {}),
|
|
89
|
+
...(m.opts && Object.keys(m.opts).length ? { serviceOpts: m.opts } : {}),
|
|
90
|
+
},
|
|
91
|
+
brand: {
|
|
92
|
+
name: m.name,
|
|
93
|
+
...(Object.keys(globalBrandOpts).length ? { globalBrandOpts } : {}),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Lower a `{ system, brand }` platform to the legacy {@link PlatformManifest} the C051 generator renders. */
|
|
99
|
+
export function liftSystemBrand(p: Platform): PlatformManifest {
|
|
100
|
+
const { services, opts, vars } = resolveNodeOpts(p.system, p.brand);
|
|
101
|
+
const registry = p.system.registry ?? p.system.registries?.core;
|
|
102
|
+
// backstop (definePlatform also guards): never lower to an empty registry → malformed "/service" adds.
|
|
103
|
+
if (!registry) throw new Error('platform: `system.registry` (or `system.registries.core`) is required (e.g. "MahmoodKhalil57/suluk")');
|
|
104
|
+
return {
|
|
105
|
+
name: p.brand.name,
|
|
106
|
+
registry,
|
|
107
|
+
services,
|
|
108
|
+
...(Object.keys(opts).length ? { opts } : {}),
|
|
109
|
+
...(Object.keys(vars).length ? { vars } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The common Service interface (C053) — "define what each core service is" so community shadcn registries can extend the
|
|
3
|
+
* platform with the SAME shape. A service = how it MOUNTS + its PROVISION fragment + npm DEPS + runtime ENV (all present
|
|
4
|
+
* since C051), PLUS (C053) the typed opts surfaces and the COMPOSITION surface (ports it EXPOSES / capabilities it OFFERS).
|
|
5
|
+
*
|
|
6
|
+
* PHASE 1 (this file) lands the interface + the 19 core services expressed through it (`CORE_SERVICES`); `catalog.ts`
|
|
7
|
+
* DERIVES the old `CATALOG` view from it (`toCatalogEntry`) so `planPlatform` is byte-for-byte unchanged. The typed opts
|
|
8
|
+
* schemas (`serviceOpts`/`brandOpts`) and the composition ENGINE are declared here but not yet consumed — Phase 2 wires the
|
|
9
|
+
* opts, Phase 3 the composition. Every phase runs against the Phase-0 golden lock.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** How a module contributes to the generated `src/index.ts`. (Unchanged from C051.) */
|
|
13
|
+
export type Mount =
|
|
14
|
+
| { kind: "base" } // the app skeleton — `createApp()`
|
|
15
|
+
| { kind: "middleware"; symbol: string; from: string } // e.g. `mountAuthRoutes(app)`
|
|
16
|
+
| { kind: "route"; path: string; symbol: string; from: string } // e.g. `app.route("/api/credits", creditsRoutes())`
|
|
17
|
+
| { kind: "dev" }; // dev/CI tooling (journeys, audit) — files only, no runtime mount, no provision fragment
|
|
18
|
+
|
|
19
|
+
/** An env var a module needs at runtime — drives the generated `.env.example` + the env-check preflight. (Unchanged.) */
|
|
20
|
+
export interface EnvVar {
|
|
21
|
+
name: string;
|
|
22
|
+
/** the app WON'T work without it (the "minimum keys") — the env-check requires a non-empty value before it's happy. */
|
|
23
|
+
required?: boolean;
|
|
24
|
+
/** a credential (never commit) — shown commented in `.env.example` + flagged in the temp file. */
|
|
25
|
+
secret?: boolean;
|
|
26
|
+
/** a one-line hint shown as a comment in `.env.example`. */
|
|
27
|
+
hint?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The old catalog record — now a DERIVED VIEW of a {@link Service} (see {@link toCatalogEntry}); kept so `planPlatform`
|
|
31
|
+
* and the C051 helpers read the same shape they always did. */
|
|
32
|
+
export interface CatalogEntry {
|
|
33
|
+
mount: Mount;
|
|
34
|
+
provision?: { symbol: string; from: string };
|
|
35
|
+
deps?: string[];
|
|
36
|
+
env?: EnvVar[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Standard-Schema v1 shape (zod v4 implements it). Declared LOCALLY so the Service interface can carry the typed-opts slots
|
|
41
|
+
* with NO runtime validator dependency in Phase 1; Phase 2 replaces this with `@standard-schema/spec` and populates
|
|
42
|
+
* `serviceOpts`/`brandOpts` with real zod schemas (zod as a peerDependency). `Out` carries the inferred value type.
|
|
43
|
+
*/
|
|
44
|
+
export interface Schema<Out = unknown> {
|
|
45
|
+
readonly "~standard": {
|
|
46
|
+
readonly version: 1;
|
|
47
|
+
readonly vendor: string;
|
|
48
|
+
readonly validate: (value: unknown) => { value: Out } | { issues: readonly unknown[] } | Promise<unknown>;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A typed PORT a service EXPOSES: a named hook others fill. `hookOptKey` is the mount-opt field a bound edge renders INTO
|
|
54
|
+
* (e.g. auth's `onUserCreated`), so an edge never emits a separate post-route statement — it composes into the producer's
|
|
55
|
+
* own mount call. `render` wraps the consumer expressions for this hook's real signature. (Consumed in Phase 3.)
|
|
56
|
+
*/
|
|
57
|
+
export interface Port<P = unknown> {
|
|
58
|
+
readonly kind: "port";
|
|
59
|
+
readonly param?: Schema<P>;
|
|
60
|
+
readonly hookOptKey: string;
|
|
61
|
+
readonly render: (consumerExprs: string[]) => string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A typed CAPABILITY a service OFFERS to fill a port. `build` produces the consumer EXPRESSION rendered into the producer's
|
|
66
|
+
* hook closure — it may reference the closure's fixed params `userId` and `env` (the seam threads env), plus the symbols it
|
|
67
|
+
* declares in `imports` (all TRUSTED — from the service definition, never manifest free text). `with` is the wire's
|
|
68
|
+
* schema-validated params (JSON data only). (Consumed in Phase 3.)
|
|
69
|
+
*/
|
|
70
|
+
export interface Capability<A = unknown> {
|
|
71
|
+
readonly kind: "capability";
|
|
72
|
+
readonly param?: Schema<A>;
|
|
73
|
+
readonly symbol: string; // exported name in the service's owned code (import-checked)
|
|
74
|
+
readonly from: string;
|
|
75
|
+
readonly imports?: { symbol: string; from: string }[]; // what the built expr references → unioned into the entry imports
|
|
76
|
+
readonly build: (ctx: { with: Record<string, unknown> }) => string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What a service brings to the composition graph: the ports it exposes + the capabilities it offers. */
|
|
80
|
+
export interface CompositionSurface {
|
|
81
|
+
exposes?: Record<string, Port>;
|
|
82
|
+
offers?: Record<string, Capability>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* THE COMMON INTERFACE. `SO` = the service-opts value type, `BO` = the brand-opts value type (both Phase 2). A core service
|
|
87
|
+
* and a community service instantiate the exact same shape via {@link defineService}.
|
|
88
|
+
*/
|
|
89
|
+
export interface Service<SO = {}, BO = {}> {
|
|
90
|
+
readonly id: string; // "auth" | "acme.analytics"
|
|
91
|
+
readonly registry?: string; // owning registry (multi-registry, Phase 4); default = the manifest's core alias
|
|
92
|
+
readonly mount: Mount;
|
|
93
|
+
readonly provision?: { symbol: string; from: string };
|
|
94
|
+
readonly deps?: string[];
|
|
95
|
+
readonly env?: EnvVar[];
|
|
96
|
+
readonly serviceOpts?: Schema<SO>; // how THIS service works → the ENTRY (mount 2nd arg) [Phase 2]
|
|
97
|
+
readonly brandOpts?: Schema<BO>; // THIS service's brand-facing → [vars]/env by default [Phase 2]
|
|
98
|
+
readonly reads?: { globalService?: string[]; globalBrand?: string[] }; // which globals it consumes [Phase 2]
|
|
99
|
+
readonly compose?: CompositionSurface; // ports it exposes + capabilities it offers [Phase 3]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Author a service. `const S` PRESERVES the literal `id` + the precise `serviceOpts`/`brandOpts` marker types, so the
|
|
104
|
+
* manifest (`defineSystem`) can key typed opts by service id off the imported service objects — no codegen. Validates the id.
|
|
105
|
+
*/
|
|
106
|
+
export function defineService<const S extends Service<any, any>>(s: S): S {
|
|
107
|
+
if (!s.id) throw new Error("defineService: `id` is required");
|
|
108
|
+
return s;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A TYPED opts marker for a service's `serviceOpts`/`brandOpts`. Phase 2 uses it purely for TYPES — the manifest author
|
|
113
|
+
* gets autocomplete + type-checking on that service's opts. It carries the value type `T` in the `Schema<T>` slot; Phase 3
|
|
114
|
+
* swaps it for a runtime-validating zod schema of the SAME type (a drop-in — the field type is `Schema<T>` either way).
|
|
115
|
+
*/
|
|
116
|
+
export function optsType<T>(): Schema<T> {
|
|
117
|
+
return { "~standard": { version: 1, vendor: "suluk", validate: (value) => ({ value: value as T }) } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The MCP OAuth authorization-server config (auth's `serviceOpts.mcp`) — the frontend OAuth pages + resource + scope set. */
|
|
121
|
+
export interface McpOAuthOpts {
|
|
122
|
+
loginPage: string;
|
|
123
|
+
consentPage: string;
|
|
124
|
+
resource: string;
|
|
125
|
+
scopes: string[];
|
|
126
|
+
}
|
|
127
|
+
/** auth's serviceOpts: optionally activate the MCP OAuth server (Better Auth `mcp()` plugin). */
|
|
128
|
+
export interface AuthServiceOpts {
|
|
129
|
+
mcp?: McpOAuthOpts;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Core service id → its serviceOpts value type. Lets a STRING-referenced core service (e.g. `services: ["auth", …]`) get the
|
|
134
|
+
* SAME typed serviceOpts as the imported-object form (`services: [authService, …]`), instead of collapsing to `unknown`.
|
|
135
|
+
* Extend as core services gain typed opts.
|
|
136
|
+
*/
|
|
137
|
+
export interface CoreServiceOptsMap {
|
|
138
|
+
auth: AuthServiceOpts;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Project a Service onto the legacy {@link CatalogEntry} shape the C051 generator reads. Field-for-field — so a derived
|
|
142
|
+
* CATALOG is behaviourally identical to the old hardcoded one (proven by the Phase-0 golden lock). */
|
|
143
|
+
export function toCatalogEntry(s: Service): CatalogEntry {
|
|
144
|
+
return { mount: s.mount, provision: s.provision, deps: s.deps, env: s.env };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The 19 CORE services, expressed through the common interface (the dogfood). Ported field-for-field from the C051 CATALOG;
|
|
149
|
+
* `auth` and `credits` additionally declare their composition surface (the `auth.onUserCreated` port + the
|
|
150
|
+
* `credits.grantOnSignup` capability) — inert until the Phase-3 engine consumes them, and the render/build templates are
|
|
151
|
+
* PROVISIONAL (Phase 3 pins them against the real auth seam signature, see ADR C053 open question #1).
|
|
152
|
+
*/
|
|
153
|
+
// Each core service is exported as a NAMED, precisely-typed const so a `defineSystem` author can import it and get typed
|
|
154
|
+
// serviceOpts keyed by id. Ported field-for-field from the C051 CATALOG (byte-identity via the Phase-0 golden lock).
|
|
155
|
+
|
|
156
|
+
export const appService = defineService({ id: "app", mount: { kind: "base" }, env: [{ name: "TRUSTED_ORIGINS", hint: "comma-separated browser origins allowed on /api/* (CORS)" }] });
|
|
157
|
+
|
|
158
|
+
export const authService = defineService({
|
|
159
|
+
id: "auth",
|
|
160
|
+
mount: { kind: "middleware", symbol: "mountAuthRoutes", from: "./auth" },
|
|
161
|
+
provision: { symbol: "authProvision", from: "./src/provision/auth" },
|
|
162
|
+
deps: ["better-auth", "@better-auth/api-key", "@better-auth/passkey", "@suluk/better-auth"],
|
|
163
|
+
env: [
|
|
164
|
+
{ name: "BETTER_AUTH_SECRET", required: true, secret: true, hint: "session-signing key — `openssl rand -base64 32`" },
|
|
165
|
+
{ name: "BETTER_AUTH_URL", hint: "your deployed origin, e.g. https://api.example.com" },
|
|
166
|
+
{ name: "GOOGLE_CLIENT_ID", secret: true, hint: "optional — enables Google sign-in" },
|
|
167
|
+
{ name: "GOOGLE_CLIENT_SECRET", secret: true, hint: "optional — pairs with GOOGLE_CLIENT_ID" },
|
|
168
|
+
],
|
|
169
|
+
serviceOpts: optsType<AuthServiceOpts>(), // typed: `serviceOpts.auth.mcp` autocompletes + type-checks
|
|
170
|
+
compose: {
|
|
171
|
+
exposes: {
|
|
172
|
+
// the signup hook. The seam is PINNED to (userId, env) — registry/auth/auth.ts widened to pass the Worker env into the
|
|
173
|
+
// databaseHook callback (env is already in buildAuth's closure), so a consumer expr can build its Effect layers.
|
|
174
|
+
onUserCreated: { kind: "port", hookOptKey: "onUserCreated", render: (exprs) => `async (userId, env) => { ${exprs.join("; ")}; }` },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
export const contractService = defineService({ id: "contract", mount: { kind: "middleware", symbol: "mountContract", from: "./routes/contract" }, deps: ["@suluk/hono", "zod"] });
|
|
180
|
+
export const mcpService = defineService({ id: "mcp", mount: { kind: "middleware", symbol: "mountMcp", from: "./routes/mcp" }, provision: { symbol: "mcpProvision", from: "./src/provision/mcp" }, deps: ["@suluk/mcp", "better-auth"] });
|
|
181
|
+
|
|
182
|
+
export const creditsService = defineService({
|
|
183
|
+
id: "credits",
|
|
184
|
+
mount: { kind: "route", path: "/api/credits", symbol: "creditsRoutes", from: "./routes/credits" },
|
|
185
|
+
provision: { symbol: "creditsProvision", from: "./src/provision/credits" },
|
|
186
|
+
deps: ["@suluk/credits"],
|
|
187
|
+
compose: {
|
|
188
|
+
offers: {
|
|
189
|
+
// grant N credits on signup, idempotent per user (the real Credits Effect service + grantOnce, over the request DB).
|
|
190
|
+
// References the closure's `userId` + `env`; provides CreditsLive + DbLive(env) and runs the program.
|
|
191
|
+
grantOnSignup: {
|
|
192
|
+
kind: "capability",
|
|
193
|
+
symbol: "Credits",
|
|
194
|
+
from: "./services/credits",
|
|
195
|
+
imports: [
|
|
196
|
+
{ symbol: "Effect", from: "effect" },
|
|
197
|
+
{ symbol: "Credits", from: "./services/credits" },
|
|
198
|
+
{ symbol: "CreditsLive", from: "./services/credits" },
|
|
199
|
+
{ symbol: "DbLive", from: "./app" },
|
|
200
|
+
],
|
|
201
|
+
build: ({ with: w }) => {
|
|
202
|
+
// fail LOUDLY at generate time on a wrong-typed money param — never render an invalid literal into the app.
|
|
203
|
+
if (w.amount !== undefined && typeof w.amount !== "number") throw new Error(`credits.grantOnSignup: 'amount' must be a number (got ${typeof w.amount})`);
|
|
204
|
+
const amount = (w.amount as number | undefined) ?? 100;
|
|
205
|
+
return `await Effect.runPromise(Effect.flatMap(Credits, (s) => s.grant(userId, ${JSON.stringify(amount)}, "signup:" + userId, "signup grant")).pipe(Effect.provide(CreditsLive), Effect.provide(DbLive(env))))`;
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
export const keysService = defineService({ id: "keys", mount: { kind: "route", path: "/api/keys", symbol: "keysRoutes", from: "./routes/keys" }, provision: { symbol: "keysProvision", from: "./src/provision/keys" }, deps: ["@suluk/keys"] });
|
|
213
|
+
|
|
214
|
+
export const billingService = defineService({
|
|
215
|
+
id: "billing",
|
|
216
|
+
mount: { kind: "route", path: "/api/billing", symbol: "billingRoutes", from: "./routes/billing" },
|
|
217
|
+
provision: { symbol: "billingProvision", from: "./src/provision/billing" },
|
|
218
|
+
deps: ["@suluk/billing", "@suluk/payments", "@suluk/credits"],
|
|
219
|
+
env: [
|
|
220
|
+
{ name: "STRIPE_SECRET_KEY", required: true, secret: true, hint: "your Stripe secret key" },
|
|
221
|
+
{ name: "STRIPE_PUBLISHABLE_KEY", hint: "returned by GET /api/billing/payment-config" },
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
export const costService = defineService({ id: "cost", mount: { kind: "route", path: "/api/cost", symbol: "costRoutes", from: "./routes/cost" }, provision: { symbol: "costProvision", from: "./src/provision/cost" }, deps: ["@suluk/cost"] });
|
|
226
|
+
export const erasureService = defineService({ id: "erasure", mount: { kind: "route", path: "/api/erasure", symbol: "erasureRoutes", from: "./routes/erasure" }, provision: { symbol: "erasureProvision", from: "./src/provision/erasure" }, deps: ["@suluk/better-auth"] });
|
|
227
|
+
|
|
228
|
+
export const emailService = defineService({
|
|
229
|
+
id: "email",
|
|
230
|
+
mount: { kind: "route", path: "/api/email", symbol: "emailRoutes", from: "./routes/email" }, // stateless binding — no provision fragment (C052)
|
|
231
|
+
deps: ["@suluk/email"],
|
|
232
|
+
env: [
|
|
233
|
+
{ name: "RESEND_API_KEY", secret: true, hint: "omit → the console provider (dev)" },
|
|
234
|
+
{ name: "EMAIL_FROM", hint: "the from-address" },
|
|
235
|
+
{ name: "BRAND_NAME", hint: "email template branding" },
|
|
236
|
+
{ name: "BASE_URL", hint: "email link base" },
|
|
237
|
+
{ name: "ENVIRONMENT", hint: '"production" → use Resend (else console)' },
|
|
238
|
+
],
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
export const webhooksService = defineService({
|
|
242
|
+
id: "webhooks",
|
|
243
|
+
mount: { kind: "route", path: "/api/webhooks", symbol: "webhooksRoutes", from: "./routes/webhooks" },
|
|
244
|
+
provision: { symbol: "webhooksProvision", from: "./src/provision/webhooks" },
|
|
245
|
+
deps: ["@suluk/payments"],
|
|
246
|
+
env: [{ name: "STRIPE_WEBHOOK_SECRET", required: true, secret: true, hint: "verifies inbound Stripe events (POST /api/webhooks/stripe)" }],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
export const rateLimitService = defineService({ id: "rate-limit", mount: { kind: "middleware", symbol: "mountRateLimit", from: "./services/rate-limit" }, deps: ["@suluk/hono"] });
|
|
250
|
+
export const rateCreditService = defineService({ id: "rate-credit", mount: { kind: "middleware", symbol: "mountRateCredit", from: "./services/rate-credit" } }); // credit-backed free-tier bucket (KV binding)
|
|
251
|
+
export const i18nService = defineService({ id: "i18n", mount: { kind: "middleware", symbol: "mountI18n", from: "./services/i18n" }, deps: ["@suluk/i18n"] });
|
|
252
|
+
export const referenceService = defineService({ id: "reference", mount: { kind: "route", path: "/api/reference", symbol: "referenceRoutes", from: "./routes/reference" }, deps: ["@suluk/reference"] }); // derived — no provision
|
|
253
|
+
export const adminService = defineService({ id: "admin", mount: { kind: "route", path: "/api/admin", symbol: "adminRoutes", from: "./routes/admin" }, deps: ["@suluk/credits"] }); // reads existing tables — no provision
|
|
254
|
+
export const logsService = defineService({ id: "logs", mount: { kind: "route", path: "/api/logs", symbol: "logsRoutes", from: "./routes/logs" }, provision: { symbol: "logsProvision", from: "./src/provision/logs" } });
|
|
255
|
+
export const journeysService = defineService({ id: "journeys", mount: { kind: "dev" }, deps: ["@suluk/journeys"] });
|
|
256
|
+
export const auditService = defineService({ id: "audit", mount: { kind: "dev" }, deps: ["@suluk/cockpit", "@suluk/harden"] });
|
|
257
|
+
|
|
258
|
+
/** The 19 core services, in the C051 catalog order. */
|
|
259
|
+
const CORE_SERVICE_LIST: Service[] = [
|
|
260
|
+
appService, authService, contractService, mcpService, creditsService, keysService, billingService, costService, erasureService, emailService,
|
|
261
|
+
webhooksService, rateLimitService, rateCreditService, i18nService, referenceService, adminService, logsService, journeysService, auditService,
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
/** The core services keyed by id (key === `service.id`, guaranteed by construction). */
|
|
265
|
+
export const CORE_SERVICES: Record<string, Service> = Object.fromEntries(CORE_SERVICE_LIST.map((s) => [s.id, s]));
|
package/src/wire.ts
ADDED
|
Binary file
|
package/test/plan.test.ts
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
import { test, expect, describe } from "bun:test";
|
|
2
|
-
import { definePlatform, planPlatform, mergeProvision, generatePlatform, buildPackageJson, mergePackageJson, mergeWranglerToml, mergeGitignore } 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("/api/credits", creditsRoutes());');
|
|
24
|
-
expect(plan.entry).toContain('app.route("/api/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
|
-
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
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe("cost (route + provision) + dev modules (journeys/audit — files only)", () => {
|
|
48
|
-
const plan = planPlatform(definePlatform({ name: "full", registry: "acme/reg", services: ["auth", "credits", "cost", "logs", "journeys", "audit"] }));
|
|
49
|
-
|
|
50
|
-
test("cost mounts a /cost route and contributes a provision fragment", () => {
|
|
51
|
-
expect(plan.entry).toContain('import { costRoutes } from "./routes/cost";');
|
|
52
|
-
expect(plan.entry).toContain('app.route("/api/cost", costRoutes());');
|
|
53
|
-
expect(plan.provisionConfig).toContain('import { costProvision } from "./src/provision/cost";');
|
|
54
|
-
expect(plan.provisionConfig).toContain("mergeProvision([authProvision, creditsProvision, costProvision, logsProvision])");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("erasure mounts an /erasure route and contributes a provision fragment", () => {
|
|
58
|
-
const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "erasure"] }));
|
|
59
|
-
expect(p.entry).toContain('import { erasureRoutes } from "./routes/erasure";');
|
|
60
|
-
expect(p.entry).toContain('app.route("/api/erasure", erasureRoutes());');
|
|
61
|
-
expect(p.provisionConfig).toContain('import { erasureProvision } from "./src/provision/erasure";');
|
|
62
|
-
expect(p.provisionConfig).toContain("mergeProvision([authProvision, erasureProvision])");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("email mounts an /email route but contributes NO provision fragment (stateless binding)", () => {
|
|
66
|
-
const p = planPlatform(definePlatform({ name: "m", registry: "acme/reg", services: ["auth", "email", "credits"] }));
|
|
67
|
-
expect(p.entry).toContain('import { emailRoutes } from "./routes/email";');
|
|
68
|
-
expect(p.entry).toContain('app.route("/api/email", emailRoutes());');
|
|
69
|
-
// email has no provision fragment, so the merge is just auth + credits.
|
|
70
|
-
expect(p.provisionConfig).not.toContain("emailProvision");
|
|
71
|
-
expect(p.provisionConfig).toContain("mergeProvision([authProvision, creditsProvision])");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("webhooks mounts a /webhooks route and contributes a provision fragment", () => {
|
|
75
|
-
const p = planPlatform(definePlatform({ name: "w", registry: "acme/reg", services: ["auth", "webhooks"] }));
|
|
76
|
-
expect(p.entry).toContain('import { webhooksRoutes } from "./routes/webhooks";');
|
|
77
|
-
expect(p.entry).toContain('app.route("/api/webhooks", webhooksRoutes());');
|
|
78
|
-
expect(p.provisionConfig).toContain("mergeProvision([authProvision, webhooksProvision])");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("rate-limit + i18n are MIDDLEWARE mounts (app.use, no route, no provision) emitted BEFORE routes", () => {
|
|
82
|
-
const p = planPlatform(definePlatform({ name: "mw", registry: "acme/reg", services: ["auth", "credits", "rate-limit", "i18n"] }));
|
|
83
|
-
expect(p.entry).toContain("mountRateLimit(app);");
|
|
84
|
-
expect(p.entry).toContain("mountI18n(app);");
|
|
85
|
-
// no route, no provision for either.
|
|
86
|
-
expect(p.entry).not.toContain('app.route("/rate-limit"');
|
|
87
|
-
expect(p.provisionConfig).toContain("mergeProvision([authProvision, creditsProvision])");
|
|
88
|
-
// two-pass ordering: every middleware mount precedes every route mount, so global middleware applies to all routes.
|
|
89
|
-
const lastMw = Math.max(p.entry.indexOf("mountRateLimit(app);"), p.entry.indexOf("mountI18n(app);"), p.entry.indexOf("mountAuthRoutes(app);"));
|
|
90
|
-
expect(lastMw).toBeLessThan(p.entry.indexOf('app.route("/api/credits"'));
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("contract is a MIDDLEWARE mount (scope gate + /api/openapi.json), emitted before routes; feature routes under /api/*", () => {
|
|
94
|
-
const p = planPlatform(definePlatform({ name: "c", registry: "acme/reg", services: ["auth", "contract", "credits"] }));
|
|
95
|
-
expect(p.entry).toContain('import { mountContract } from "./routes/contract";');
|
|
96
|
-
expect(p.entry).toContain("mountContract(app);");
|
|
97
|
-
expect(p.entry).toContain('app.route("/api/credits", creditsRoutes());'); // toolfactory-parity /api/* prefix
|
|
98
|
-
// the gate (middleware) is emitted before any route.
|
|
99
|
-
expect(p.entry.indexOf("mountContract(app);")).toBeLessThan(p.entry.indexOf('app.route("/api/credits"'));
|
|
100
|
-
expect(p.provisionConfig).not.toContain("contractProvision");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("reference + admin mount /api routes with NO provision (derived / reads existing tables)", () => {
|
|
104
|
-
const p = planPlatform(definePlatform({ name: "ra", registry: "acme/reg", services: ["auth", "contract", "reference", "admin", "credits"] }));
|
|
105
|
-
expect(p.entry).toContain('app.route("/api/reference", referenceRoutes());');
|
|
106
|
-
expect(p.entry).toContain('app.route("/api/admin", adminRoutes());');
|
|
107
|
-
expect(p.provisionConfig).not.toContain("referenceProvision");
|
|
108
|
-
expect(p.provisionConfig).not.toContain("adminProvision");
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("mcp is a middleware mount (server + discovery + connections) with a provision fragment", () => {
|
|
112
|
-
const p = planPlatform(definePlatform({ name: "m", registry: "acme/reg", services: ["auth", "contract", "mcp", "credits"] }));
|
|
113
|
-
expect(p.entry).toContain('import { mountMcp } from "./routes/mcp";');
|
|
114
|
-
expect(p.entry).toContain("mountMcp(app);");
|
|
115
|
-
expect(p.entry).not.toContain('app.route("/api/mcp"'); // it's a mount, not a route
|
|
116
|
-
expect(p.provisionConfig).toContain("mcpProvision");
|
|
117
|
-
});
|
|
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
|
-
|
|
125
|
-
test("dev modules add shadcn refs but NO entry mount and NO provision fragment", () => {
|
|
126
|
-
expect(plan.adds).toContain("acme/reg/journeys");
|
|
127
|
-
expect(plan.adds).toContain("acme/reg/audit");
|
|
128
|
-
expect(plan.entry).not.toContain("journeys");
|
|
129
|
-
expect(plan.entry).not.toContain("audit");
|
|
130
|
-
expect(plan.provisionConfig).not.toContain("journeys");
|
|
131
|
-
expect(plan.provisionConfig).not.toContain("audit");
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe("mergeProvision — combine same-ref instances, union migrations in order", () => {
|
|
136
|
-
test("two `db` fragments merge into one with both migrations (fragment order preserved)", () => {
|
|
137
|
-
const auth: InstanceSpec[] = [{ ref: "db", service: "cloudflare-d1", name: "app-db", params: { migrations: [{ name: "0000_auth", sql: "A" }] }, bind: { database_id: "ID" }, protected: true }];
|
|
138
|
-
const credits: InstanceSpec[] = [{ ref: "db", service: "cloudflare-d1", name: "app-db", params: { migrations: [{ name: "0001_credits", sql: "C" }] }, bind: { database_id: "ID" }, protected: true }];
|
|
139
|
-
const merged = mergeProvision([auth, credits]);
|
|
140
|
-
expect(merged.length).toBe(1);
|
|
141
|
-
expect(merged[0].ref).toBe("db");
|
|
142
|
-
expect((merged[0].params!.migrations as { name: string }[]).map((m) => m.name)).toEqual(["0000_auth", "0001_credits"]);
|
|
143
|
-
expect(merged[0].protected).toBe(true);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("distinct refs stay separate", () => {
|
|
147
|
-
const a: InstanceSpec[] = [{ ref: "db", service: "cloudflare-d1", name: "db", params: {} }];
|
|
148
|
-
const b: InstanceSpec[] = [{ ref: "kv", service: "cloudflare-kv", name: "cache", params: {} }];
|
|
149
|
-
expect(mergeProvision([a, b]).map((i) => i.ref).sort()).toEqual(["db", "kv"]);
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
describe("generatePlatform — the orchestration (with recorders)", () => {
|
|
154
|
-
test("writes the scaffold config FIRST, then a shadcn add per service, then the glue", async () => {
|
|
155
|
-
const ran: string[] = [];
|
|
156
|
-
const wrote: string[] = [];
|
|
157
|
-
const res = await generatePlatform(manifest, {
|
|
158
|
-
run: async (cmd, args) => void ran.push(`${cmd} ${args.join(" ")}`),
|
|
159
|
-
write: async (path) => void wrote.push(path),
|
|
160
|
-
read: async () => null, // a fresh app — no existing config
|
|
161
|
-
});
|
|
162
|
-
expect(ran).toEqual(plannedAdds()); // exactly the planned adds, in order
|
|
163
|
-
expect(ran.length).toBe(6); // app+auth+credits+keys+billing+logs
|
|
164
|
-
// config is written BEFORE the shadcn adds; the glue after. env-example + env-check + wrangler + gitignore included.
|
|
165
|
-
expect(wrote).toEqual(["package.json", "wrangler.toml", ".gitignore", "tsconfig.json", "components.json", ".env.example", "scripts/env-check.ts", "src/index.ts", "provision.config.ts"]);
|
|
166
|
-
expect(res.added.length).toBe(6);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("leaves an existing tsconfig/components.json untouched; always (re)writes package.json/.gitignore/.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(".gitignore"); // MERGED (never skip — must ensure .env is ignored)
|
|
178
|
-
expect(wrote).toContain(".env.example"); // template — always current
|
|
179
|
-
expect(wrote).toContain("scripts/env-check.ts");
|
|
180
|
-
expect(wrote).not.toContain("tsconfig.json"); // present → left as-is
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("mergeGitignore appends missing entries so .env is always ignored", () => {
|
|
184
|
-
const merged = mergeGitignore("node_modules/\n.env\n.env.temp\n", "node_modules\n");
|
|
185
|
-
expect(merged).toContain(".env");
|
|
186
|
-
expect(merged).toContain(".env.temp");
|
|
187
|
-
expect(merged.match(/node_modules/g)?.length).toBe(1); // deduped (node_modules vs node_modules/)
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
describe("env — secrets in .env (temp lifecycle), non-secrets in the manifest vars", () => {
|
|
192
|
-
test(".env.example lists ONLY required + optional secrets, never non-secret config", () => {
|
|
193
|
-
const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "billing", "webhooks", "email"] }));
|
|
194
|
-
expect(p.envExample).toContain("BETTER_AUTH_SECRET="); // required secret, uncommented
|
|
195
|
-
expect(p.envExample).toContain("STRIPE_SECRET_KEY=");
|
|
196
|
-
expect(p.envExample).toContain("STRIPE_WEBHOOK_SECRET=");
|
|
197
|
-
expect(p.envExample).toContain("# RESEND_API_KEY="); // optional secret, commented
|
|
198
|
-
expect(p.envExample).not.toContain("BASE_URL"); // non-secret → the manifest vars, NOT .env
|
|
199
|
-
expect(p.envExample).not.toContain("TRUSTED_ORIGINS");
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
test("wrangler.toml [vars] come from the manifest vars; unset non-secrets are commented; D1 binding present", () => {
|
|
203
|
-
const p = planPlatform(definePlatform({ name: "myapp", registry: "acme/reg", services: ["auth", "email", "rate-credit"], vars: { BASE_URL: "https://x.dev", ENVIRONMENT: "production" } }));
|
|
204
|
-
expect(p.wranglerToml).toContain('BASE_URL = "https://x.dev"'); // set in the manifest
|
|
205
|
-
expect(p.wranglerToml).toContain('ENVIRONMENT = "production"');
|
|
206
|
-
expect(p.wranglerToml).toContain("# EMAIL_FROM ="); // unset → commented
|
|
207
|
-
expect(p.wranglerToml).toContain('binding = "DB"');
|
|
208
|
-
expect(p.wranglerToml).toContain('binding = "RATE_CREDIT_KV"'); // rate-credit selected
|
|
209
|
-
expect(p.wranglerToml).not.toContain("BETTER_AUTH_SECRET"); // secrets never in [vars]
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("the env-check script bakes in the required secrets; merge preserves provisioned binding ids", () => {
|
|
213
|
-
const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "billing"] }));
|
|
214
|
-
expect(p.envCheck).toContain('["BETTER_AUTH_SECRET","STRIPE_SECRET_KEY"]');
|
|
215
|
-
expect(p.envCheck).toContain(".env.temp");
|
|
216
|
-
// wrangler merge keeps the operator's database_id across a regen
|
|
217
|
-
const merged = mergeWranglerToml(p.wranglerToml, 'name="e"\n[[d1_databases]]\nbinding = "DB"\ndatabase_id = "abc-123"');
|
|
218
|
-
expect(merged).toContain('database_id = "abc-123"');
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
describe("package.json generation — the manifest is the only surface", () => {
|
|
223
|
-
test("buildPackageJson unions base + service deps; @suluk/* → latest, ecosystem pinned", () => {
|
|
224
|
-
const plan = planPlatform(definePlatform({ name: "myapp", registry: "acme/reg", services: ["auth", "credits", "billing"] }));
|
|
225
|
-
const pkg = JSON.parse(plan.packageJson);
|
|
226
|
-
expect(pkg.name).toBe("myapp");
|
|
227
|
-
expect(pkg.dependencies["@suluk/credits"]).toBe("latest"); // fixes flow via bun update
|
|
228
|
-
expect(pkg.dependencies["@suluk/billing"]).toBe("latest");
|
|
229
|
-
expect(pkg.dependencies["hono"]).toBe("^4.0.0"); // ecosystem pinned
|
|
230
|
-
expect(pkg.dependencies["better-auth"]).toBe("^1.0.0"); // auth's dep
|
|
231
|
-
expect(pkg.devDependencies["typescript"]).toBeDefined();
|
|
232
|
-
expect(pkg.scripts.generate).toBe("suluk-platform");
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
test("mergePackageJson keeps app-added deps + scripts, baseline wins for framework deps", () => {
|
|
236
|
-
const baseline = buildPackageJson("myapp", ["auth", "credits"]);
|
|
237
|
-
const existing = JSON.stringify({ name: "myapp", dependencies: { "@suluk/credits": "^0.1.0", "my-product-lib": "^2.0.0" }, scripts: { deploy: "wrangler deploy" } });
|
|
238
|
-
const merged = JSON.parse(mergePackageJson(baseline, existing));
|
|
239
|
-
expect(merged.dependencies["my-product-lib"]).toBe("^2.0.0"); // app extra preserved
|
|
240
|
-
expect(merged.dependencies["@suluk/credits"]).toBe("latest"); // baseline wins → stays up to date
|
|
241
|
-
expect(merged.scripts.deploy).toBe("wrangler deploy"); // app script preserved
|
|
242
|
-
expect(merged.scripts.typecheck).toBe("tsc --noEmit -p ."); // framework script filled in
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("planPlatform emits tsconfig + components.json", () => {
|
|
246
|
-
const plan = planPlatform(manifest);
|
|
247
|
-
expect(JSON.parse(plan.tsconfig).exclude).toContain("src/**/*.test.ts");
|
|
248
|
-
expect(JSON.parse(plan.componentsJson).aliases.utils).toBe("src/lib/utils");
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
function plannedAdds() {
|
|
253
|
-
return planPlatform(manifest).adds.map((a) => `bunx shadcn@latest add ${a} --yes`);
|
|
254
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test", "bin"] }
|