@xemahq/create-biome 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Build the `xema-biome.json` manifest object for a freshly-scaffolded biome.
3
+ *
4
+ * The shape MUST match the canonical `BiomeManifestSchema`
5
+ * (`@xemahq/kernel-contracts/biome`): the WRAPPED
6
+ * `{ name, version, xema: { id, displayName, scope, target, … } }` form. The
7
+ * legacy flat `{ schemaVersion, id, trustTier, contributes }` shape this
8
+ * builder used to emit was schema-INVALID and would have been rejected by the
9
+ * biome host at boot — it is gone, no compat shim.
10
+ *
11
+ * Manifest invariants enforced here so generated biomes never start life with
12
+ * an invalid file:
13
+ * - `xema.id` is kebab-case and MUST equal the biome folder name (the
14
+ * `check-biome-layout.mjs` boundary check enforces this).
15
+ * - `name` is the conventional first-party scoped package name
16
+ * `@xemahq/biomes-<id>` — first-party because the scaffold targets in-tree
17
+ * biomes the platform runs, not external npm packages.
18
+ * - `xema.scope` defaults to `platform` (a first-party domain biome — see
19
+ * {@link DEFAULT_BIOME_SCOPE}).
20
+ * - The default manifest is the SMALLEST viable server biome: every
21
+ * optional block the author has not asked for is ABSENT, never emitted
22
+ * as an empty array/object. Absent beats empty — the schema treats both
23
+ * the same, and an absent block never teaches a stale shape.
24
+ */
25
+
26
+ /** Manifest-shape scope tier — value-identical to the kernel `BiomeTier` set. */
27
+ export type ManifestScope = 'kernel' | 'system' | 'base' | 'platform';
28
+
29
+ /**
30
+ * Default `xema.scope` for a freshly-scaffolded biome. `platform` is the L3
31
+ * first-party domain/connector tier — where a new product biome belongs.
32
+ * Foundational tiers (`base`, `system`, `kernel`) are deliberate choices the
33
+ * author opts into via `--scope`.
34
+ */
35
+ export const DEFAULT_BIOME_SCOPE: ManifestScope = 'platform';
36
+
37
+ /**
38
+ * Default `xema.display.icon` for a freshly-scaffolded biome. A biome is a
39
+ * pluggable unit, so `puzzle` reads as a sensible generic glyph. Giving it a
40
+ * default means an author in a hurry to test never has to pick one — the biome
41
+ * still renders with a real icon in the installed-biomes view and picker, and
42
+ * the author can refine it later. (`icon`/`category` are free-string keys the
43
+ * host resolves; see `BiomeDisplaySchema`.)
44
+ */
45
+ export const DEFAULT_BIOME_ICON = 'puzzle';
46
+
47
+ /**
48
+ * Default `xema.display.category`. New biomes are L3 domain/capability biomes,
49
+ * so they group under `build` (the lowercase `NavCategory` value the host rail
50
+ * + biome picker use). Overridable, but a real default beats an unset field.
51
+ */
52
+ export const DEFAULT_BIOME_CATEGORY = 'build';
53
+
54
+ /** A biome's `display` block (icon/category/summary), emitted with real defaults. */
55
+ export interface ManifestDisplay {
56
+ readonly icon: string;
57
+ readonly category: string;
58
+ readonly summary: string;
59
+ }
60
+
61
+ export interface ServerManifestBuilderInput {
62
+ readonly biomeId: string;
63
+ readonly displayName: string;
64
+ readonly description: string;
65
+ readonly scope: ManifestScope;
66
+ /** Name of the single NestJS API shipped under `api/<apiName>/`. */
67
+ readonly apiName: string;
68
+ /**
69
+ * Optional display overrides. Any field omitted falls back to a real default
70
+ * (icon → {@link DEFAULT_BIOME_ICON}, category → {@link DEFAULT_BIOME_CATEGORY},
71
+ * summary → `description`) so a scaffolded biome always has a complete,
72
+ * renderable `display` block.
73
+ */
74
+ readonly display?: Partial<ManifestDisplay>;
75
+ /** Optional, non-mandatory keyword/hashtag tags for search + grouping. */
76
+ readonly tags?: readonly string[];
77
+ }
78
+
79
+ export interface WebManifestBuilderInput {
80
+ readonly biomeId: string;
81
+ readonly displayName: string;
82
+ readonly description: string;
83
+ readonly scope: ManifestScope;
84
+ /** Server biome ids this web surface requires (its backend counterpart). */
85
+ readonly requiresServerBiomes: readonly string[];
86
+ /** Optional display overrides (see {@link ServerManifestBuilderInput.display}). */
87
+ readonly display?: Partial<ManifestDisplay>;
88
+ /** Optional, non-mandatory keyword/hashtag tags. */
89
+ readonly tags?: readonly string[];
90
+ }
91
+
92
+ /**
93
+ * Resolve a complete `display` block from optional author overrides + defaults.
94
+ * `summary` defaults to the biome description so the tile is never blank.
95
+ */
96
+ function resolveDisplay(
97
+ description: string,
98
+ overrides: Partial<ManifestDisplay> | undefined,
99
+ ): ManifestDisplay {
100
+ return {
101
+ icon: overrides?.icon?.trim() || DEFAULT_BIOME_ICON,
102
+ category: overrides?.category?.trim() || DEFAULT_BIOME_CATEGORY,
103
+ summary: overrides?.summary?.trim() || description,
104
+ };
105
+ }
106
+
107
+ /** Only emit `tags` when the author actually supplied non-empty ones (absent beats empty). */
108
+ function normalizeTags(tags: readonly string[] | undefined): readonly string[] | undefined {
109
+ const cleaned = (tags ?? []).map((t) => t.trim()).filter((t) => t.length > 0);
110
+ return cleaned.length > 0 ? cleaned : undefined;
111
+ }
112
+
113
+ /**
114
+ * The one API declaration the default scaffold ships. `requiresServices`
115
+ * stays EXPLICIT (`['identity-api']` — the floor every biome-api needs)
116
+ * because it matches what `generate-service-bootstrap.mjs` emits into the
117
+ * seeded descriptor; every genuinely optional field (`exposesCapabilities`,
118
+ * `image`, `scopes`, …) is absent, not empty.
119
+ */
120
+ export interface ServerApiDeclaration {
121
+ readonly name: string;
122
+ readonly path: string;
123
+ readonly displayName: string;
124
+ readonly serviceKind: 'biome-api';
125
+ readonly requiresServices: readonly string[];
126
+ }
127
+
128
+ /**
129
+ * The smallest viable server-biome manifest: identity + capability domain +
130
+ * engines + ONE api. Optional blocks (`requiresCapabilities`,
131
+ * `exposesCapabilities`, `contributions`, `webhookFilters`, `mcpTools`,
132
+ * `provisioning`, …) are NOT part of the default shape — the host applies the
133
+ * same defaults whether they are absent or empty (e.g. the contributions
134
+ * directory defaults to `contributions/` with no manifest block), so the
135
+ * scaffold omits them.
136
+ *
137
+ * `capabilityDomain` is the ONE exception to "absent beats empty". The
138
+ * projector defaults it to `xema.id`, so emitting `capabilityDomain: <id>` is
139
+ * value-redundant — but it is the ONLY declaration that controls the `<domain>`
140
+ * segment of every capability the biome's routes project, and an author who
141
+ * never sees the field has no way to learn the knob exists. It is emitted so
142
+ * renaming the biome's capability vocabulary is a one-line edit rather than a
143
+ * documentation hunt.
144
+ */
145
+ export interface ServerManifestObject {
146
+ readonly name: string;
147
+ readonly version: string;
148
+ readonly xema: {
149
+ readonly id: string;
150
+ readonly displayName: string;
151
+ readonly description: string;
152
+ readonly display: ManifestDisplay;
153
+ readonly scope: ManifestScope;
154
+ readonly target: 'server';
155
+ readonly capabilityDomain: string;
156
+ readonly engines: { readonly xema: string };
157
+ readonly ships: { readonly apis: readonly ServerApiDeclaration[] };
158
+ readonly tags?: readonly string[];
159
+ };
160
+ }
161
+
162
+ /**
163
+ * A connector biome's manifest. `xema.kind: "connector"` is a MARKER with
164
+ * teeth: `tooling/boundaries/check-connector-biome.mjs` requires the biome to
165
+ * contribute ≥1 `connector-adapter`, to ship no product surface (no web target,
166
+ * no pages, no BFF), and to depend on/extend no other biome. The scaffolded
167
+ * connector satisfies all three, so a generated connector is CI-clean on
168
+ * creation.
169
+ *
170
+ * `contributes` is the declared whitelist the guard reads; the descriptors
171
+ * themselves live in the `ConnectorAdapterModule` the biome ships, NOT in a
172
+ * parallel `*.connector-adapter.contribution.json`. One source, not two — a
173
+ * hand-written descriptor file duplicating the module would be exactly the
174
+ * drift this scaffold exists to prevent.
175
+ */
176
+ export interface ConnectorManifestObject {
177
+ readonly name: string;
178
+ readonly version: string;
179
+ readonly xema: {
180
+ readonly id: string;
181
+ readonly displayName: string;
182
+ readonly description: string;
183
+ readonly display: ManifestDisplay;
184
+ readonly scope: ManifestScope;
185
+ readonly target: 'server';
186
+ readonly kind: 'connector';
187
+ readonly capabilityDomain: string;
188
+ readonly trustTier: 'first-party';
189
+ readonly engines: { readonly xema: string };
190
+ readonly contributes: readonly ['connector-adapter'];
191
+ readonly tags?: readonly string[];
192
+ };
193
+ }
194
+
195
+ export interface WebManifestObject {
196
+ readonly name: string;
197
+ readonly version: string;
198
+ readonly xema: {
199
+ readonly id: string;
200
+ readonly displayName: string;
201
+ readonly description: string;
202
+ readonly display: ManifestDisplay;
203
+ readonly scope: ManifestScope;
204
+ readonly target: 'web';
205
+ readonly requiresServerBiomes?: readonly string[];
206
+ readonly tags?: readonly string[];
207
+ };
208
+ }
209
+
210
+ const XEMA_ENGINE_RANGE = '^1.0.0';
211
+
212
+ /**
213
+ * Default services every biome-api needs to bootstrap. `identity-api` is the
214
+ * floor (the generated bootstrap descriptor falls back to it when none is
215
+ * declared); we make it explicit so the scaffolded manifest reads as a real
216
+ * one would.
217
+ */
218
+ const DEFAULT_API_REQUIRES_SERVICES: readonly string[] = ['identity-api'];
219
+
220
+ export function buildServerManifest(
221
+ input: ServerManifestBuilderInput,
222
+ ): ServerManifestObject {
223
+ const tags = normalizeTags(input.tags);
224
+ return {
225
+ name: firstPartyPackageName(input.biomeId),
226
+ version: '0.1.0',
227
+ xema: {
228
+ id: input.biomeId,
229
+ displayName: input.displayName,
230
+ description: input.description,
231
+ display: resolveDisplay(input.description, input.display),
232
+ scope: input.scope,
233
+ target: 'server',
234
+ capabilityDomain: input.biomeId,
235
+ engines: { xema: XEMA_ENGINE_RANGE },
236
+ ships: {
237
+ apis: [
238
+ {
239
+ name: input.apiName,
240
+ path: `./api/${input.apiName}`,
241
+ displayName: `${input.displayName} API`,
242
+ serviceKind: 'biome-api',
243
+ requiresServices: DEFAULT_API_REQUIRES_SERVICES,
244
+ },
245
+ ],
246
+ },
247
+ ...(tags ? { tags } : {}),
248
+ },
249
+ };
250
+ }
251
+
252
+ export interface ConnectorManifestBuilderInput {
253
+ readonly biomeId: string;
254
+ readonly displayName: string;
255
+ readonly description: string;
256
+ readonly scope: ManifestScope;
257
+ /** Optional display overrides (see {@link ServerManifestBuilderInput.display}). */
258
+ readonly display?: Partial<ManifestDisplay>;
259
+ /** Optional, non-mandatory keyword/hashtag tags. */
260
+ readonly tags?: readonly string[];
261
+ }
262
+
263
+ /**
264
+ * Default `xema.display.icon` for a connector biome. `plug` is the glyph the
265
+ * first-party connector biomes use, so a scaffolded connector renders
266
+ * alongside them in the store catalog rather than as a generic puzzle piece.
267
+ */
268
+ export const DEFAULT_CONNECTOR_ICON = 'plug';
269
+
270
+ /** Default `xema.display.category` for a connector biome — the connect rail. */
271
+ export const DEFAULT_CONNECTOR_CATEGORY = 'connect';
272
+
273
+ export function buildConnectorManifest(
274
+ input: ConnectorManifestBuilderInput,
275
+ ): ConnectorManifestObject {
276
+ const tags = normalizeTags(input.tags);
277
+ return {
278
+ name: firstPartyPackageName(input.biomeId),
279
+ version: '0.1.0',
280
+ xema: {
281
+ id: input.biomeId,
282
+ displayName: input.displayName,
283
+ description: input.description,
284
+ display: {
285
+ icon: input.display?.icon?.trim() || DEFAULT_CONNECTOR_ICON,
286
+ category: input.display?.category?.trim() || DEFAULT_CONNECTOR_CATEGORY,
287
+ summary: input.display?.summary?.trim() || input.description,
288
+ },
289
+ scope: input.scope,
290
+ target: 'server',
291
+ kind: 'connector',
292
+ capabilityDomain: input.biomeId,
293
+ // A scaffolded connector is generated INTO a first-party tree by an
294
+ // author who owns it. `trustTier` defaults to `third-party` when absent,
295
+ // which would mis-file it; declaring it is the honest default here and a
296
+ // one-word edit for an author vendoring someone else's adapter.
297
+ trustTier: 'first-party',
298
+ engines: { xema: XEMA_ENGINE_RANGE },
299
+ contributes: ['connector-adapter'],
300
+ ...(tags ? { tags } : {}),
301
+ },
302
+ };
303
+ }
304
+
305
+ export function buildWebManifest(
306
+ input: WebManifestBuilderInput,
307
+ ): WebManifestObject {
308
+ const tags = normalizeTags(input.tags);
309
+ const base: WebManifestObject = {
310
+ name: firstPartyPackageName(input.biomeId),
311
+ version: '0.1.0',
312
+ xema: {
313
+ id: input.biomeId,
314
+ displayName: input.displayName,
315
+ description: input.description,
316
+ display: resolveDisplay(input.description, input.display),
317
+ scope: input.scope,
318
+ target: 'web' as const,
319
+ ...(tags ? { tags } : {}),
320
+ },
321
+ };
322
+ if (input.requiresServerBiomes.length === 0) {
323
+ return base;
324
+ }
325
+ return {
326
+ ...base,
327
+ xema: { ...base.xema, requiresServerBiomes: input.requiresServerBiomes },
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Conventional first-party biome package name `@xemahq/biomes-<id>`. The
333
+ * scaffold emits in-tree first-party biomes (the platform runs them), so this
334
+ * is the right shape — NOT the external `@xemahq-biomes/<id>` form the old
335
+ * external-package scaffolder used.
336
+ */
337
+ export function firstPartyPackageName(biomeId: string): string {
338
+ return `@xemahq/biomes-${biomeId}`;
339
+ }
340
+
341
+ /**
342
+ * The single NestJS API a service/full biome ships, by convention
343
+ * `<id>-api` under `api/<id>-api/`.
344
+ */
345
+ export function apiNameForBiome(biomeId: string): string {
346
+ return `${biomeId}-api`;
347
+ }
348
+
349
+ /**
350
+ * Web biomes are conventionally suffixed `-web` (their id, package name, and
351
+ * directory all carry the suffix), so a server biome and its paired web
352
+ * surface never collide.
353
+ */
354
+ export function webBiomeId(biomeId: string): string {
355
+ return `${biomeId}-web`;
356
+ }
357
+
358
+ /**
359
+ * Validate the biome id rules the manifest enforces. Centralized so the CLI
360
+ * fails fast on bad input instead of generating a folder the biome host
361
+ * rejects at boot. Pattern matches `BiomeIdSchema` (kebab-case) plus a
362
+ * 3–63-char length floor.
363
+ */
364
+ const BIOME_ID_PATTERN = /^[a-z][a-z0-9-]{1,61}[a-z0-9]$/;
365
+
366
+ export function validateBiomeId(
367
+ biomeId: string,
368
+ ): { ok: true } | { ok: false; reason: string } {
369
+ if (!BIOME_ID_PATTERN.test(biomeId)) {
370
+ return {
371
+ ok: false,
372
+ reason: `biome id "${biomeId}" must be kebab-case (lowercase letters/digits + dashes), 3-63 chars, starting with a letter`,
373
+ };
374
+ }
375
+ return { ok: true };
376
+ }
@@ -0,0 +1,259 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import * as path from 'node:path';
4
+
5
+ import { BiomeKind } from './kind.js';
6
+ import {
7
+ apiNameForBiome,
8
+ buildConnectorManifest,
9
+ buildServerManifest,
10
+ buildWebManifest,
11
+ validateBiomeId,
12
+ webBiomeId,
13
+ type ManifestScope,
14
+ } from './manifest-builder.js';
15
+ import {
16
+ connectorBiomeFiles,
17
+ renderServerBiomeFiles,
18
+ webBiomeFiles,
19
+ type TemplateContext,
20
+ } from './template-files.js';
21
+
22
+ /**
23
+ * Pure-data description of a biome to be scaffolded. The CLI builds this from
24
+ * user prompts and hands it to `scaffoldBiome` — keeping the scaffolder itself
25
+ * agnostic of whether inputs came from prompts, CLI flags, or a programmatic
26
+ * caller (e.g. tests).
27
+ */
28
+ export interface ScaffoldBiomeInput {
29
+ readonly biomeId: string;
30
+ readonly displayName: string;
31
+ readonly description: string;
32
+ readonly kind: BiomeKind;
33
+ readonly scope: ManifestScope;
34
+ /**
35
+ * Monorepo root. The scaffolder writes server biomes under
36
+ * `<repoRoot>/<serverBiomesDir>/<id>/` and web biomes under
37
+ * `<repoRoot>/<webBiomesDir>/<tier>/<id>-web/`. Defaults are derived from
38
+ * the standard layout; tests pass a scratch root.
39
+ */
40
+ readonly repoRoot: string;
41
+ /**
42
+ * Directory (relative to repoRoot) where in-tree SERVER biomes live, e.g.
43
+ * `repos/xema-community/biomes`. Required for `service`/`full`.
44
+ */
45
+ readonly serverBiomesDir?: string;
46
+ /**
47
+ * Directory (relative to repoRoot) where host-web WEB biomes live, e.g.
48
+ * `submodules/xema-host-web/biomes`. Required for `web`/`full`. The biome is
49
+ * placed under `<webBiomesDir>/<scope-tier>/<id>-web/`.
50
+ */
51
+ readonly webBiomesDir?: string;
52
+ }
53
+
54
+ export interface ScaffoldedBiomeDir {
55
+ readonly target: 'server' | 'web';
56
+ readonly dir: string;
57
+ }
58
+
59
+ export interface ScaffoldBiomeResult {
60
+ readonly dirs: readonly ScaffoldedBiomeDir[];
61
+ readonly filesWritten: readonly string[];
62
+ }
63
+
64
+ /**
65
+ * Generate a new in-tree biome (server, web, or full-stack). Fails fast on:
66
+ * - malformed biome id (kebab-case 3–63 chars)
67
+ * - any target directory already exists (refuse to overwrite)
68
+ * - a required base directory not provided for the chosen kind
69
+ *
70
+ * Returns the directories created and the absolute paths of every file
71
+ * written so the CLI can render its own "next steps" output.
72
+ */
73
+ export async function scaffoldBiome(
74
+ input: ScaffoldBiomeInput,
75
+ ): Promise<ScaffoldBiomeResult> {
76
+ const idCheck = validateBiomeId(input.biomeId);
77
+ if (!idCheck.ok) {
78
+ throw new ScaffolderError(idCheck.reason);
79
+ }
80
+
81
+ const dirs: ScaffoldedBiomeDir[] = [];
82
+ const filesWritten: string[] = [];
83
+
84
+ const wantsServer =
85
+ input.kind === BiomeKind.Service || input.kind === BiomeKind.Full;
86
+ const wantsWeb = input.kind === BiomeKind.Web || input.kind === BiomeKind.Full;
87
+
88
+ if (input.kind === BiomeKind.Connector) {
89
+ const connectorDir = resolveServerDir(input);
90
+ refuseExisting(connectorDir);
91
+ await writeConnectorBiome(input, connectorDir, filesWritten);
92
+ // A connector is a server-target biome; it simply ships an adapter module
93
+ // instead of an API. It never has a paired web surface — the connector
94
+ // boundary check rejects one — so this returns here.
95
+ return { dirs: [{ target: 'server', dir: connectorDir }], filesWritten };
96
+ }
97
+
98
+ if (wantsServer) {
99
+ const serverDir = resolveServerDir(input);
100
+ refuseExisting(serverDir);
101
+ await writeServerBiome(input, serverDir, filesWritten);
102
+ dirs.push({ target: 'server', dir: serverDir });
103
+ }
104
+
105
+ if (wantsWeb) {
106
+ const webDir = resolveWebDir(input);
107
+ refuseExisting(webDir);
108
+ await writeWebBiome(input, webDir, filesWritten);
109
+ dirs.push({ target: 'web', dir: webDir });
110
+ }
111
+
112
+ return { dirs, filesWritten };
113
+ }
114
+
115
+ function resolveServerDir(input: ScaffoldBiomeInput): string {
116
+ if (!input.serverBiomesDir) {
117
+ throw new ScaffolderError(
118
+ 'serverBiomesDir is required for a service/full biome (e.g. repos/xema-community/biomes)',
119
+ );
120
+ }
121
+ return path.resolve(input.repoRoot, input.serverBiomesDir, input.biomeId);
122
+ }
123
+
124
+ function resolveWebDir(input: ScaffoldBiomeInput): string {
125
+ if (!input.webBiomesDir) {
126
+ throw new ScaffolderError(
127
+ 'webBiomesDir is required for a web/full biome (e.g. submodules/xema-host-web/biomes)',
128
+ );
129
+ }
130
+ // Web biomes are tiered by scope under the host tree (platform|system|…).
131
+ const tier = input.scope;
132
+ return path.resolve(
133
+ input.repoRoot,
134
+ input.webBiomesDir,
135
+ tier,
136
+ webBiomeId(input.biomeId),
137
+ );
138
+ }
139
+
140
+ function refuseExisting(dir: string): void {
141
+ if (existsSync(dir)) {
142
+ throw new ScaffolderError(
143
+ `target directory already exists: ${dir} — choose a fresh id or delete it first`,
144
+ );
145
+ }
146
+ }
147
+
148
+ async function writeServerBiome(
149
+ input: ScaffoldBiomeInput,
150
+ serverDir: string,
151
+ written: string[],
152
+ ): Promise<void> {
153
+ const ctx: TemplateContext = {
154
+ biomeId: input.biomeId,
155
+ displayName: input.displayName,
156
+ description: input.description,
157
+ };
158
+ const manifest = buildServerManifest({
159
+ biomeId: input.biomeId,
160
+ displayName: input.displayName,
161
+ description: input.description,
162
+ scope: input.scope,
163
+ apiName: apiNameForBiome(input.biomeId),
164
+ });
165
+ await mkdir(serverDir, { recursive: true });
166
+ await writeJson(serverDir, 'xema-biome.json', manifest, written);
167
+ for (const [rel, content] of Object.entries(renderServerBiomeFiles(ctx))) {
168
+ await writeText(serverDir, rel, content, written);
169
+ }
170
+ }
171
+
172
+ async function writeConnectorBiome(
173
+ input: ScaffoldBiomeInput,
174
+ connectorDir: string,
175
+ written: string[],
176
+ ): Promise<void> {
177
+ const ctx: TemplateContext = {
178
+ biomeId: input.biomeId,
179
+ displayName: input.displayName,
180
+ description: input.description,
181
+ };
182
+ const manifest = buildConnectorManifest({
183
+ biomeId: input.biomeId,
184
+ displayName: input.displayName,
185
+ description: input.description,
186
+ scope: input.scope,
187
+ });
188
+ await mkdir(connectorDir, { recursive: true });
189
+ await writeJson(connectorDir, 'xema-biome.json', manifest, written);
190
+ for (const [rel, content] of Object.entries(connectorBiomeFiles(ctx))) {
191
+ await writeText(connectorDir, rel, content, written);
192
+ }
193
+ }
194
+
195
+ async function writeWebBiome(
196
+ input: ScaffoldBiomeInput,
197
+ webDir: string,
198
+ written: string[],
199
+ ): Promise<void> {
200
+ const wid = webBiomeId(input.biomeId);
201
+ const navLabel = input.displayName;
202
+ const manifest = buildWebManifest({
203
+ biomeId: wid,
204
+ displayName: `${input.displayName} (Web)`,
205
+ description: input.description,
206
+ scope: input.scope,
207
+ // A full-stack biome's web surface requires its server counterpart; a
208
+ // standalone web biome requires none by default (the author wires it).
209
+ requiresServerBiomes:
210
+ input.kind === BiomeKind.Full ? [input.biomeId] : [],
211
+ });
212
+ await mkdir(webDir, { recursive: true });
213
+ await writeJson(webDir, 'xema-biome.json', manifest, written);
214
+ for (const [rel, content] of Object.entries(
215
+ webBiomeFiles({
216
+ biomeId: input.biomeId,
217
+ displayName: input.displayName,
218
+ description: input.description,
219
+ webBiomeId: wid,
220
+ navLabel,
221
+ }),
222
+ )) {
223
+ await writeText(webDir, rel, content, written);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Errors the scaffolder throws are always operator-readable (no stack-trace
229
+ * value-add). The CLI uses the dedicated type to render them with a clean
230
+ * prefix and exit code 1 — anything else surfaces with full trace.
231
+ */
232
+ export class ScaffolderError extends Error {
233
+ constructor(message: string) {
234
+ super(message);
235
+ this.name = 'ScaffolderError';
236
+ }
237
+ }
238
+
239
+ async function writeJson(
240
+ root: string,
241
+ relativePath: string,
242
+ data: unknown,
243
+ written: string[],
244
+ ): Promise<void> {
245
+ const json = `${JSON.stringify(data, null, 2)}\n`;
246
+ await writeText(root, relativePath, json, written);
247
+ }
248
+
249
+ async function writeText(
250
+ root: string,
251
+ relativePath: string,
252
+ content: string,
253
+ written: string[],
254
+ ): Promise<void> {
255
+ const absolute = path.join(root, relativePath);
256
+ await mkdir(path.dirname(absolute), { recursive: true });
257
+ await writeFile(absolute, content, 'utf8');
258
+ written.push(absolute);
259
+ }