@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.
- package/README.md +25 -2
- package/dist/bin/create-biome.js +7 -1
- package/dist/bin/create-biome.js.map +1 -1
- package/dist/lib/kind.d.ts +2 -1
- package/dist/lib/kind.d.ts.map +1 -1
- package/dist/lib/kind.js +6 -0
- package/dist/lib/kind.js.map +1 -1
- package/dist/lib/manifest-builder.d.ts +47 -0
- package/dist/lib/manifest-builder.d.ts.map +1 -1
- package/dist/lib/manifest-builder.js +47 -0
- package/dist/lib/manifest-builder.js.map +1 -1
- package/dist/lib/scaffolder.d.ts.map +1 -1
- package/dist/lib/scaffolder.js +26 -2
- package/dist/lib/scaffolder.js.map +1 -1
- package/dist/lib/template-files.d.ts +1 -0
- package/dist/lib/template-files.d.ts.map +1 -1
- package/dist/lib/template-files.js +965 -63
- package/dist/lib/template-files.js.map +1 -1
- package/package.json +4 -3
- package/src/bin/create-biome.ts +331 -0
- package/src/index.ts +5 -0
- package/src/lib/host-wiring.ts +123 -0
- package/src/lib/kind.ts +110 -0
- package/src/lib/manifest-builder.ts +376 -0
- package/src/lib/scaffolder.ts +259 -0
- package/src/lib/template-files.ts +1701 -0
|
@@ -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
|
+
}
|