@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/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,247 +0,0 @@
|
|
|
1
|
-
import { test, expect, describe } from "bun:test";
|
|
2
|
-
import { definePlatform, planPlatform, mergeProvision, generatePlatform, buildPackageJson, mergePackageJson, mergeWranglerToml } 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", "tsconfig.json", "components.json", ".gitignore", ".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/.gitignore untouched; always rewrites package.json/.env.example", async () => {
|
|
170
|
-
const wrote: string[] = [];
|
|
171
|
-
await generatePlatform(manifest, {
|
|
172
|
-
run: async () => {},
|
|
173
|
-
write: async (path) => void wrote.push(path),
|
|
174
|
-
read: async (p) => (p === "package.json" ? '{"name":"x","dependencies":{"my-lib":"^1.0.0"}}' : "existing"),
|
|
175
|
-
});
|
|
176
|
-
expect(wrote).toContain("package.json"); // merged + rewritten
|
|
177
|
-
expect(wrote).toContain(".env.example"); // template — always current
|
|
178
|
-
expect(wrote).toContain("scripts/env-check.ts");
|
|
179
|
-
expect(wrote).not.toContain("tsconfig.json"); // present → left as-is
|
|
180
|
-
expect(wrote).not.toContain(".gitignore");
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
describe("env — secrets in .env (temp lifecycle), non-secrets in the manifest vars", () => {
|
|
185
|
-
test(".env.example lists ONLY required + optional secrets, never non-secret config", () => {
|
|
186
|
-
const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "billing", "webhooks", "email"] }));
|
|
187
|
-
expect(p.envExample).toContain("BETTER_AUTH_SECRET="); // required secret, uncommented
|
|
188
|
-
expect(p.envExample).toContain("STRIPE_SECRET_KEY=");
|
|
189
|
-
expect(p.envExample).toContain("STRIPE_WEBHOOK_SECRET=");
|
|
190
|
-
expect(p.envExample).toContain("# RESEND_API_KEY="); // optional secret, commented
|
|
191
|
-
expect(p.envExample).not.toContain("BASE_URL"); // non-secret → the manifest vars, NOT .env
|
|
192
|
-
expect(p.envExample).not.toContain("TRUSTED_ORIGINS");
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test("wrangler.toml [vars] come from the manifest vars; unset non-secrets are commented; D1 binding present", () => {
|
|
196
|
-
const p = planPlatform(definePlatform({ name: "myapp", registry: "acme/reg", services: ["auth", "email", "rate-credit"], vars: { BASE_URL: "https://x.dev", ENVIRONMENT: "production" } }));
|
|
197
|
-
expect(p.wranglerToml).toContain('BASE_URL = "https://x.dev"'); // set in the manifest
|
|
198
|
-
expect(p.wranglerToml).toContain('ENVIRONMENT = "production"');
|
|
199
|
-
expect(p.wranglerToml).toContain("# EMAIL_FROM ="); // unset → commented
|
|
200
|
-
expect(p.wranglerToml).toContain('binding = "DB"');
|
|
201
|
-
expect(p.wranglerToml).toContain('binding = "RATE_CREDIT_KV"'); // rate-credit selected
|
|
202
|
-
expect(p.wranglerToml).not.toContain("BETTER_AUTH_SECRET"); // secrets never in [vars]
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("the env-check script bakes in the required secrets; merge preserves provisioned binding ids", () => {
|
|
206
|
-
const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "billing"] }));
|
|
207
|
-
expect(p.envCheck).toContain('["BETTER_AUTH_SECRET","STRIPE_SECRET_KEY"]');
|
|
208
|
-
expect(p.envCheck).toContain(".env.temp");
|
|
209
|
-
// wrangler merge keeps the operator's database_id across a regen
|
|
210
|
-
const merged = mergeWranglerToml(p.wranglerToml, 'name="e"\n[[d1_databases]]\nbinding = "DB"\ndatabase_id = "abc-123"');
|
|
211
|
-
expect(merged).toContain('database_id = "abc-123"');
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe("package.json generation — the manifest is the only surface", () => {
|
|
216
|
-
test("buildPackageJson unions base + service deps; @suluk/* → latest, ecosystem pinned", () => {
|
|
217
|
-
const plan = planPlatform(definePlatform({ name: "myapp", registry: "acme/reg", services: ["auth", "credits", "billing"] }));
|
|
218
|
-
const pkg = JSON.parse(plan.packageJson);
|
|
219
|
-
expect(pkg.name).toBe("myapp");
|
|
220
|
-
expect(pkg.dependencies["@suluk/credits"]).toBe("latest"); // fixes flow via bun update
|
|
221
|
-
expect(pkg.dependencies["@suluk/billing"]).toBe("latest");
|
|
222
|
-
expect(pkg.dependencies["hono"]).toBe("^4.0.0"); // ecosystem pinned
|
|
223
|
-
expect(pkg.dependencies["better-auth"]).toBe("^1.0.0"); // auth's dep
|
|
224
|
-
expect(pkg.devDependencies["typescript"]).toBeDefined();
|
|
225
|
-
expect(pkg.scripts.generate).toBe("suluk-platform");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test("mergePackageJson keeps app-added deps + scripts, baseline wins for framework deps", () => {
|
|
229
|
-
const baseline = buildPackageJson("myapp", ["auth", "credits"]);
|
|
230
|
-
const existing = JSON.stringify({ name: "myapp", dependencies: { "@suluk/credits": "^0.1.0", "my-product-lib": "^2.0.0" }, scripts: { deploy: "wrangler deploy" } });
|
|
231
|
-
const merged = JSON.parse(mergePackageJson(baseline, existing));
|
|
232
|
-
expect(merged.dependencies["my-product-lib"]).toBe("^2.0.0"); // app extra preserved
|
|
233
|
-
expect(merged.dependencies["@suluk/credits"]).toBe("latest"); // baseline wins → stays up to date
|
|
234
|
-
expect(merged.scripts.deploy).toBe("wrangler deploy"); // app script preserved
|
|
235
|
-
expect(merged.scripts.typecheck).toBe("tsc --noEmit -p ."); // framework script filled in
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test("planPlatform emits tsconfig + components.json", () => {
|
|
239
|
-
const plan = planPlatform(manifest);
|
|
240
|
-
expect(JSON.parse(plan.tsconfig).exclude).toContain("src/**/*.test.ts");
|
|
241
|
-
expect(JSON.parse(plan.componentsJson).aliases.utils).toBe("src/lib/utils");
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
function plannedAdds() {
|
|
246
|
-
return planPlatform(manifest).adds.map((a) => `bunx shadcn@latest add ${a} --yes`);
|
|
247
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test", "bin"] }
|