@loomstack/generator 0.0.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,34 @@
1
+ import { FrameworkError, GeneratedFilesManifest, LoomStackProject } from '@loomstack/core';
2
+
3
+ declare const GENERATED_SOURCE_MARKER = "// GENERATED BY loomstack. DO NOT EDIT.";
4
+ declare function stableJson(value: unknown): string;
5
+ declare function sha256(content: string): string;
6
+ declare function writeProjectFile(root: string, path: string, content: string, overwrite?: boolean): void;
7
+
8
+ declare class GeneratorFailure extends Error {
9
+ readonly errors: FrameworkError[];
10
+ constructor(errors: FrameworkError[]);
11
+ }
12
+ interface GenerateResult {
13
+ createdFiles: string[];
14
+ }
15
+ declare function createFeature(rootInput: string, name: string): GenerateResult;
16
+
17
+ declare function renderGeneratedFiles(project: LoomStackProject): Record<string, string>;
18
+ declare function generatedManifest(files: Record<string, string>): GeneratedFilesManifest;
19
+ interface ProjectGenerationResult {
20
+ generatedFiles: string[];
21
+ }
22
+ declare function generateProject(root: string): ProjectGenerationResult;
23
+ declare function readGeneratedManifest(project: LoomStackProject): GeneratedFilesManifest | undefined;
24
+
25
+ interface CreateAppResult {
26
+ appName: string;
27
+ root: string;
28
+ createdFiles: string[];
29
+ nextCommands: string[];
30
+ }
31
+ declare function createApp(parentInput: string, appName: string): CreateAppResult;
32
+ declare function createAppInPlace(rootInput: string): CreateAppResult;
33
+
34
+ export { type CreateAppResult, GENERATED_SOURCE_MARKER, type GenerateResult, GeneratorFailure, type ProjectGenerationResult, createApp, createAppInPlace, createFeature, generateProject, generatedManifest, readGeneratedManifest, renderGeneratedFiles, sha256, stableJson, writeProjectFile };
package/dist/index.js ADDED
@@ -0,0 +1,791 @@
1
+ // src/files.ts
2
+ import { createHash } from "crypto";
3
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
4
+ import { dirname, join } from "path";
5
+ var GENERATED_SOURCE_MARKER = "// GENERATED BY loomstack. DO NOT EDIT.";
6
+ function stableJson(value) {
7
+ return `${JSON.stringify(value, null, 2)}
8
+ `;
9
+ }
10
+ function sha256(content) {
11
+ return createHash("sha256").update(content).digest("hex");
12
+ }
13
+ function writeProjectFile(root, path, content, overwrite = true) {
14
+ const absolute = join(root, path);
15
+ if (!overwrite && existsSync(absolute)) throw new Error(`Target already exists: ${path}`);
16
+ mkdirSync(dirname(absolute), { recursive: true });
17
+ writeFileSync(absolute, content, "utf8");
18
+ }
19
+
20
+ // src/scaffold.ts
21
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
22
+ import { join as join2, resolve } from "path";
23
+ import { frameworkError, loadProjectConfig } from "@loomstack/core";
24
+ var FEATURE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
25
+ var GeneratorFailure = class extends Error {
26
+ constructor(errors) {
27
+ super(errors.map((error) => `${error.code}: ${error.message}`).join("\n"));
28
+ this.errors = errors;
29
+ this.name = "GeneratorFailure";
30
+ }
31
+ errors;
32
+ };
33
+ function titleCase(id) {
34
+ return id.split("-").map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join(" ");
35
+ }
36
+ function camelCase(id) {
37
+ return id.split("-").map((part, index) => index === 0 ? part : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join("");
38
+ }
39
+ function createFeature(rootInput, name) {
40
+ const root = resolve(rootInput);
41
+ if (!FEATURE_ID.test(name)) throw new GeneratorFailure([frameworkError("loomstack1011", { message: `Invalid feature name: ${name}.` })]);
42
+ const loaded = loadProjectConfig(root);
43
+ if (!loaded.config) throw new GeneratorFailure(loaded.errors);
44
+ const base = `${loaded.config.featuresDir}/${name}`;
45
+ if (existsSync2(join2(root, base))) {
46
+ throw new GeneratorFailure([frameworkError("loomstack5002", { message: `Feature already exists: ${name}.`, file: base })]);
47
+ }
48
+ const title = titleCase(name);
49
+ const files = {
50
+ [`${base}/feature.yaml`]: `id: ${name}
51
+ name: ${title}
52
+ description: ${title} product capability.
53
+
54
+ entities: []
55
+ routes: []
56
+ actions: []
57
+ queries: []
58
+ `,
59
+ [`${base}/AGENTS.md`]: `# ${title} Feature Agent Instructions
60
+
61
+ ## Purpose
62
+
63
+ ${title} product capability.
64
+
65
+ ## Source of truth
66
+
67
+ - Manifest: \`feature.yaml\`
68
+ - Schema: \`model.schema.ts\`
69
+ - Permissions: \`permissions.policy.ts\`
70
+
71
+ ## Rules
72
+
73
+ - Mutations belong in \`actions/*.action.ts\`.
74
+ - Reads belong in \`queries/*.query.ts\`.
75
+ - Route UI belongs in \`ui/*.view.tsx\`.
76
+ - Never import database or Koa modules from UI.
77
+ - Never call raw fetch from UI.
78
+ - Update tests with every contract change.
79
+
80
+ ## Verify
81
+
82
+ \`pnpm loomstack verify feature ${name} --json\`
83
+ `,
84
+ [`${base}/model.schema.ts`]: `import { entity } from "@loomstack/runtime"
85
+
86
+ // Add and export manifest-declared entities here with entity().
87
+ void entity
88
+ `,
89
+ [`${base}/permissions.policy.ts`]: `import { policy } from "@loomstack/runtime"
90
+
91
+ export const ${camelCase(name)}Policy = policy({})
92
+ `
93
+ };
94
+ const createdFiles = Object.keys(files).sort();
95
+ for (const path of createdFiles) writeProjectFile(root, path, files[path] ?? "", false);
96
+ for (const directory of ["actions", "queries", "ui", "tests"]) {
97
+ const path = `${base}/${directory}/.gitkeep`;
98
+ mkdirSync2(join2(root, base, directory), { recursive: true });
99
+ writeProjectFile(root, path, "", false);
100
+ createdFiles.push(path);
101
+ }
102
+ return { createdFiles: createdFiles.sort() };
103
+ }
104
+
105
+ // src/generate.ts
106
+ import { existsSync as existsSync3, readFileSync } from "fs";
107
+ import { join as join3 } from "path";
108
+ import { ERROR_CATALOG, frameworkError as frameworkError2, scanProject } from "@loomstack/core";
109
+ function source(lines) {
110
+ return `${GENERATED_SOURCE_MARKER}
111
+
112
+ ${lines.join("\n").trimEnd()}
113
+ `;
114
+ }
115
+ function importPath(path) {
116
+ return `../../${path.replace(/\.(tsx?|jsx?)$/, ".js")}`;
117
+ }
118
+ function featureRegistry(project) {
119
+ const entries = project.features.map(
120
+ (feature) => ` ${JSON.stringify(feature.id)}: { id: ${JSON.stringify(feature.id)}, name: ${JSON.stringify(feature.manifest.name)}, manifestPath: ${JSON.stringify(feature.manifestPath)} }`
121
+ );
122
+ return source([
123
+ "export const featureRegistry = {",
124
+ entries.join(",\n"),
125
+ "} as const"
126
+ ]);
127
+ }
128
+ function actionRegistry(project) {
129
+ const actions = project.features.flatMap((feature) => feature.actions).sort((a, b) => a.name.localeCompare(b.name));
130
+ return source([
131
+ ...actions.map((item) => `import { ${item.name} } from ${JSON.stringify(importPath(item.file))}`),
132
+ actions.length ? "" : "",
133
+ "export const actionRegistry = {",
134
+ ...actions.map((item) => ` ${item.name},`),
135
+ "} as const"
136
+ ]);
137
+ }
138
+ function queryRegistry(project) {
139
+ const queries = project.features.flatMap((feature) => feature.queries).sort((a, b) => a.name.localeCompare(b.name));
140
+ return source([
141
+ ...queries.map((item) => `import { ${item.name} } from ${JSON.stringify(importPath(item.file))}`),
142
+ queries.length ? "" : "",
143
+ "export const queryRegistry = {",
144
+ ...queries.map((item) => ` ${item.name},`),
145
+ "} as const"
146
+ ]);
147
+ }
148
+ function schemaRegistry(project) {
149
+ const schemas = project.features.flatMap((feature) => feature.manifest.entities.map((name) => ({
150
+ name,
151
+ file: `${feature.directory}/model.schema.ts`
152
+ }))).sort((a, b) => a.name.localeCompare(b.name));
153
+ return source([
154
+ ...schemas.map((item) => `import { ${item.name}Schema } from ${JSON.stringify(importPath(item.file))}`),
155
+ schemas.length ? "" : "",
156
+ "export const schemaRegistry = {",
157
+ ...schemas.map((item) => ` ${item.name}: ${item.name}Schema,`),
158
+ "} as const"
159
+ ]);
160
+ }
161
+ function resolveView(feature, viewName) {
162
+ return feature.views.find((item) => item.name === viewName)?.file;
163
+ }
164
+ function reactRoutes(project) {
165
+ const routes = project.features.flatMap((feature) => feature.manifest.routes.map((route) => ({ ...route, feature }))).sort((a, b) => a.path.localeCompare(b.path) || a.id.localeCompare(b.id));
166
+ const missing = routes.filter((route) => !resolveView(route.feature, route.view));
167
+ if (missing.length) {
168
+ throw new GeneratorFailure(missing.map((route) => frameworkError2("loomstack1009", {
169
+ message: `Route ${route.id} references missing view ${route.view}.`,
170
+ file: route.feature.manifestPath
171
+ })));
172
+ }
173
+ return source([
174
+ 'import { lazy } from "react"',
175
+ 'import type { ComponentType, LazyExoticComponent } from "react"',
176
+ "",
177
+ "export interface LoomStackRoute {",
178
+ " id: string",
179
+ " feature: string",
180
+ " path: string",
181
+ " component: LazyExoticComponent<ComponentType>",
182
+ "}",
183
+ "",
184
+ "export const routes: LoomStackRoute[] = [",
185
+ ...routes.map((route) => {
186
+ const file = resolveView(route.feature, route.view) ?? "";
187
+ const relative = `../../../${file.replace(/\.(tsx?|jsx?)$/, "")}`;
188
+ return ` { id: ${JSON.stringify(route.id)}, feature: ${JSON.stringify(route.feature.id)}, path: ${JSON.stringify(route.path)}, component: lazy(() => import(${JSON.stringify(relative)})) },`;
189
+ }),
190
+ "]"
191
+ ]);
192
+ }
193
+ function apiClient(project) {
194
+ const actions = project.features.flatMap((feature) => feature.actions).sort((a, b) => a.name.localeCompare(b.name));
195
+ const queries = project.features.flatMap((feature) => feature.queries).sort((a, b) => a.name.localeCompare(b.name));
196
+ return source([
197
+ 'import { createActionClient, createQueryClient } from "@loomstack/react"',
198
+ ...actions.map((item) => `import type { ${item.name} as _${item.name}Definition } from ${JSON.stringify(`../../../${item.file.replace(/\.(tsx?|jsx?)$/, ".js")}`)}`),
199
+ ...queries.map((item) => `import type { ${item.name} as _${item.name}Definition } from ${JSON.stringify(`../../../${item.file.replace(/\.(tsx?|jsx?)$/, ".js")}`)}`),
200
+ "",
201
+ "export const api = {",
202
+ " actions: {",
203
+ ...actions.map((item) => ` ${item.name}: createActionClient<Parameters<typeof _${item.name}Definition.run>[1], Awaited<ReturnType<typeof _${item.name}Definition.run>>>(${JSON.stringify(item.name)}),`),
204
+ " },",
205
+ " queries: {",
206
+ ...queries.map((item) => ` ${item.name}: createQueryClient<Parameters<typeof _${item.name}Definition.run>[1], Awaited<ReturnType<typeof _${item.name}Definition.run>>>(${JSON.stringify(item.name)}),`),
207
+ " }",
208
+ "} as const"
209
+ ]);
210
+ }
211
+ function koaRoutes() {
212
+ return source([
213
+ 'import type Koa from "koa"',
214
+ 'import { registerLoomStackRoutes } from "@loomstack/koa"',
215
+ 'import { actionRegistry } from "../../../shared/generated/action-registry.generated.js"',
216
+ 'import { queryRegistry } from "../../../shared/generated/query-registry.generated.js"',
217
+ 'import { createRequestContext } from "./context.js"',
218
+ "",
219
+ "export function registerGeneratedRoutes(app: Koa) {",
220
+ " registerLoomStackRoutes(app, { actionRegistry, queryRegistry, createRequestContext })",
221
+ "}"
222
+ ]);
223
+ }
224
+ function contextDocument(project) {
225
+ return {
226
+ generatedBy: "loomstack",
227
+ doNotEdit: true,
228
+ project: {
229
+ name: project.config.appName,
230
+ frontend: project.config.frontend,
231
+ backend: project.config.backend,
232
+ database: project.config.database,
233
+ packageManager: project.config.packageManager
234
+ },
235
+ paths: { features: project.config.featuresDir, web: "apps/web", api: "apps/api" },
236
+ commands: {
237
+ dev: "pnpm dev",
238
+ verify: "pnpm loomstack verify",
239
+ test: "pnpm test",
240
+ typecheck: "pnpm typecheck"
241
+ },
242
+ features: project.features.map((feature) => feature.id),
243
+ rules: [
244
+ { id: "feature-source-of-truth", description: "Product behavior lives in features/*.", errorCode: null },
245
+ { id: "no-db-in-ui", description: "React UI files may not import database code.", errorCode: "loomstack2001" },
246
+ { id: "no-fetch-in-ui", description: "React UI files may not call raw fetch.", errorCode: "loomstack2002" },
247
+ { id: "transport-independent-features", description: "Feature logic may not import Koa.", errorCode: "loomstack2003" }
248
+ ]
249
+ };
250
+ }
251
+ function graphDocument(project) {
252
+ return { generatedBy: "loomstack", doNotEdit: true, ...project.graph };
253
+ }
254
+ function commandsDocument() {
255
+ return {
256
+ generatedBy: "loomstack",
257
+ doNotEdit: true,
258
+ commands: {
259
+ install: "pnpm install",
260
+ dev: "pnpm dev",
261
+ test: "pnpm test",
262
+ typecheck: "pnpm typecheck",
263
+ verify: "pnpm loomstack verify",
264
+ verifyFeature: "pnpm loomstack verify feature <feature>"
265
+ }
266
+ };
267
+ }
268
+ function errorsDocument() {
269
+ return {
270
+ generatedBy: "loomstack",
271
+ doNotEdit: true,
272
+ errors: Object.fromEntries(Object.entries(ERROR_CATALOG).sort(([a], [b]) => a.localeCompare(b)).map(([code, definition]) => [code, {
273
+ title: definition.title,
274
+ repair: definition.repair,
275
+ docs: definition.docs
276
+ }]))
277
+ };
278
+ }
279
+ function renderGeneratedFiles(project) {
280
+ const generatedDir = project.config.generatedDir;
281
+ const entries = [
282
+ ["apps/web/src/routes.generated.tsx", reactRoutes(project)],
283
+ ["apps/web/src/api-client.generated.ts", apiClient(project)],
284
+ ["apps/api/src/routes.generated.ts", koaRoutes()],
285
+ ["shared/generated/feature-registry.generated.ts", featureRegistry(project)],
286
+ ["shared/generated/schema-registry.generated.ts", schemaRegistry(project)],
287
+ ["shared/generated/action-registry.generated.ts", actionRegistry(project)],
288
+ ["shared/generated/query-registry.generated.ts", queryRegistry(project)],
289
+ [`${generatedDir}/context.json`, stableJson(contextDocument(project))],
290
+ [`${generatedDir}/graph.json`, stableJson(graphDocument(project))],
291
+ [`${generatedDir}/commands.json`, stableJson(commandsDocument())],
292
+ [`${generatedDir}/errors.json`, stableJson(errorsDocument())]
293
+ ];
294
+ return Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)));
295
+ }
296
+ function generatedManifest(files) {
297
+ return {
298
+ generatedBy: "loomstack",
299
+ doNotEdit: true,
300
+ files: Object.entries(files).map(([path, content]) => ({ path, sha256: sha256(content) })).sort((a, b) => a.path.localeCompare(b.path))
301
+ };
302
+ }
303
+ function generationContractErrors(project) {
304
+ const errors = [];
305
+ const operationOwners = /* @__PURE__ */ new Map();
306
+ for (const feature of project.features) {
307
+ const actionNames = new Set(feature.actions.map((item) => item.name));
308
+ const queryNames = new Set(feature.queries.map((item) => item.name));
309
+ const viewNames = new Set(feature.views.map((item) => item.name));
310
+ const schemaNames = new Set(feature.schemas.map((item) => item.name));
311
+ for (const name of feature.manifest.actions) {
312
+ if (!actionNames.has(name)) errors.push(frameworkError2("loomstack1003", { message: `Manifest action "${name}" is not exported.`, file: feature.manifestPath }));
313
+ }
314
+ for (const operation of feature.actions) {
315
+ if (!feature.manifest.actions.includes(operation.name)) errors.push(frameworkError2("loomstack1004", { message: `Exported action "${operation.name}" is missing from feature.yaml.`, file: operation.file }));
316
+ }
317
+ for (const name of feature.manifest.queries) {
318
+ if (!queryNames.has(name)) errors.push(frameworkError2("loomstack1007", { message: `Manifest query "${name}" is not exported.`, file: feature.manifestPath }));
319
+ }
320
+ for (const operation of feature.queries) {
321
+ if (!feature.manifest.queries.includes(operation.name)) errors.push(frameworkError2("loomstack1008", { message: `Exported query "${operation.name}" is missing from feature.yaml.`, file: operation.file }));
322
+ }
323
+ for (const route of feature.manifest.routes) {
324
+ if (!viewNames.has(route.view)) errors.push(frameworkError2("loomstack1009", { message: `Route "${route.id}" references missing view "${route.view}".`, file: feature.manifestPath }));
325
+ }
326
+ for (const name of feature.manifest.entities) {
327
+ if (!schemaNames.has(name)) errors.push(frameworkError2("loomstack1010", { message: `Manifest entity "${name}" is not exported by model.schema.ts.`, file: feature.manifestPath }));
328
+ }
329
+ for (const [kind, operations] of [["action", feature.actions], ["query", feature.queries]]) {
330
+ for (const operation of operations) {
331
+ const owner = operationOwners.get(operation.name);
332
+ if (owner) errors.push(frameworkError2(kind === "action" ? "loomstack1004" : "loomstack1008", { message: `Operation "${operation.name}" is also exported by ${owner}.`, file: operation.file }));
333
+ else operationOwners.set(operation.name, feature.id);
334
+ }
335
+ }
336
+ }
337
+ return errors;
338
+ }
339
+ function generateProject(root) {
340
+ const result = scanProject(root);
341
+ if (!result.project || result.errors.length > 0) throw new GeneratorFailure(result.errors);
342
+ const contractErrors = generationContractErrors(result.project);
343
+ if (contractErrors.length > 0) throw new GeneratorFailure(contractErrors);
344
+ const files = renderGeneratedFiles(result.project);
345
+ for (const [path, content] of Object.entries(files)) writeProjectFile(result.project.root, path, content);
346
+ const manifestPath = `${result.project.config.generatedDir}/generated-files.json`;
347
+ writeProjectFile(result.project.root, manifestPath, stableJson(generatedManifest(files)));
348
+ return { generatedFiles: [...Object.keys(files), manifestPath].sort() };
349
+ }
350
+ function readGeneratedManifest(project) {
351
+ const path = join3(project.root, project.config.generatedDir, "generated-files.json");
352
+ if (!existsSync3(path)) return void 0;
353
+ try {
354
+ return JSON.parse(readFileSync(path, "utf8"));
355
+ } catch {
356
+ return void 0;
357
+ }
358
+ }
359
+
360
+ // src/app.ts
361
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync } from "fs";
362
+ import { basename, join as join4, resolve as resolve2 } from "path";
363
+ import { frameworkError as frameworkError3 } from "@loomstack/core";
364
+
365
+ // src/app-template.ts
366
+ function appTemplateFiles(appName) {
367
+ return {
368
+ "package.json": `${JSON.stringify({
369
+ name: appName,
370
+ version: "0.0.1",
371
+ private: true,
372
+ type: "module",
373
+ packageManager: "pnpm@11.11.0",
374
+ scripts: {
375
+ loomstack: "loomstack",
376
+ dev: "loomstack dev start",
377
+ "dev:refresh": "loomstack dev refresh",
378
+ "dev:stop": "loomstack dev stop",
379
+ "dev:status": "loomstack dev status",
380
+ generate: "loomstack generate",
381
+ verify: "loomstack verify",
382
+ test: "vitest run --passWithNoTests",
383
+ typecheck: "tsc --noEmit",
384
+ build: "pnpm generate && pnpm -r --filter './apps/*' build"
385
+ },
386
+ devDependencies: {
387
+ "@loomstack/cli": "^0.0.1",
388
+ "@loomstack/react": "^0.0.1",
389
+ "@loomstack/runtime": "^0.0.1",
390
+ "@loomstack/postgres": "^0.0.1",
391
+ "@types/node": "^24.0.0",
392
+ "@types/react": "^19.0.0",
393
+ "@types/react-dom": "^19.0.0",
394
+ react: "^19.2.7",
395
+ "react-dom": "^19.2.7",
396
+ typescript: "^5.9.3",
397
+ vitest: "^4.1.10"
398
+ }
399
+ }, null, 2)}
400
+ `,
401
+ "pnpm-workspace.yaml": "packages:\n - apps/*\n\nallowBuilds:\n esbuild: true\n",
402
+ "tsconfig.json": `${JSON.stringify({
403
+ compilerOptions: {
404
+ target: "ES2023",
405
+ module: "ESNext",
406
+ moduleResolution: "Bundler",
407
+ lib: ["ES2023", "DOM", "DOM.Iterable"],
408
+ strict: true,
409
+ noUncheckedIndexedAccess: true,
410
+ exactOptionalPropertyTypes: true,
411
+ esModuleInterop: true,
412
+ skipLibCheck: true,
413
+ resolveJsonModule: true,
414
+ jsx: "react-jsx"
415
+ },
416
+ include: ["apps/**/*.ts", "apps/**/*.tsx", "features/**/*.ts", "features/**/*.tsx", "shared/**/*.ts", "loomstack.config.ts"]
417
+ }, null, 2)}
418
+ `,
419
+ "vitest.config.ts": `import { defineConfig } from "vitest/config"
420
+
421
+ export default defineConfig({ test: { include: ["features/**/*.test.ts", "features/**/*.test.tsx"] } })
422
+ `,
423
+ "loomstack.config.ts": `import { defineLoomStackConfig } from "@loomstack/runtime"
424
+
425
+ export default defineLoomStackConfig({
426
+ appName: "${appName}",
427
+ packageManager: "pnpm",
428
+ frontend: "react",
429
+ backend: "koa",
430
+ database: "postgres",
431
+ featuresDir: "features",
432
+ generatedDir: ".loomstack"
433
+ })
434
+ `,
435
+ "AGENTS.md": `# ${appName} agent instructions
436
+
437
+ This is a LoomStack app. Humans define product intent; agents implement it through explicit feature contracts.
438
+
439
+ ## Architecture
440
+
441
+ \`\`\`text
442
+ React = presentation
443
+ Koa = transport
444
+ features/* = product behavior and source of truth
445
+ generated files = derived wiring, never product behavior
446
+ \`\`\`
447
+
448
+ ## Command convention
449
+
450
+ Invoke the project CLI as \`pnpm loomstack\`. Always use \`--json\` when available so output and repair guidance remain structured. \`loomstack init\` is the human onboarding entrypoint; it starts containers and prints agent guidance but never launches an agent. Do not invoke it from an existing agent session.
451
+
452
+ ## Start every task efficiently
453
+
454
+ 1. Read this file and any in-scope \`features/<feature>/AGENTS.md\`.
455
+ 2. Use \`pnpm loomstack doctor --json\` only when environment or setup may be relevant.
456
+ 3. Discover scope before broad file exploration:
457
+ - Existing feature: \`pnpm loomstack context feature <name> --json\`
458
+ - New or cross-feature work: \`pnpm loomstack context --json\`
459
+ - Dependencies or routes: \`pnpm loomstack graph --json\`
460
+ 4. Before editing an existing authored file, run \`pnpm loomstack affected <path> --json\`.
461
+ 5. Create missing features with \`pnpm loomstack create feature <kebab-case-name> --json\`; do not hand-build feature folders.
462
+
463
+ ## Tool selection
464
+
465
+ | Need | Command |
466
+ |---|---|
467
+ | Validate setup | \`pnpm loomstack doctor --json\` |
468
+ | Understand the app | \`pnpm loomstack context --json\` |
469
+ | Understand one feature | \`pnpm loomstack context feature <name> --json\` |
470
+ | Inspect relationships/routes | \`pnpm loomstack graph --json\` |
471
+ | Find companion files | \`pnpm loomstack affected <authored-file> --json\` |
472
+ | Scaffold a feature | \`pnpm loomstack create feature <name> --json\` |
473
+ | Rebuild derived wiring | \`pnpm loomstack generate --json\` |
474
+ | Check one feature | \`pnpm loomstack verify feature <name> --json\` |
475
+ | Check the whole app | \`pnpm loomstack verify --json\` |
476
+ | Understand an error | \`pnpm loomstack explain <code> --json\` |
477
+
478
+ When a command returns an error code, run \`pnpm loomstack explain <code> --json\` and follow its repair guidance before inventing a workaround.
479
+
480
+ ## Development containers
481
+
482
+ The complete development app runs in Docker Compose: Vite web server, Koa API, and PostgreSQL. The source tree is bind-mounted, so normal TypeScript and React edits reload automatically.
483
+
484
+ - Start before browser/API validation: \`pnpm loomstack dev start --json\`.
485
+ - Check state when uncertain: \`pnpm loomstack dev status --json\`.
486
+ - Use \`pnpm loomstack dev refresh --json\` after dependency, Dockerfile, Compose, or environment changes. Do not refresh for ordinary source edits; hot reload handles them.
487
+ - Stop with \`pnpm loomstack dev stop --json\` when the user requests it or when the task explicitly requires cleanup. Do not stop containers if the user expects the app to remain available.
488
+ - If lifecycle commands fail, run \`pnpm loomstack doctor --json\` and follow the returned repair guidance.
489
+
490
+ ## Canonical feature layout
491
+
492
+ - \`feature.yaml\`: declared entities, routes, actions, and queries
493
+ - \`model.schema.ts\`: domain entities and schemas
494
+ - \`permissions.policy.ts\`: authorization policy
495
+ - \`actions/*.action.ts\`: mutations
496
+ - \`queries/*.query.ts\`: reads
497
+ - \`ui/*.view.tsx\`: routes and presentation
498
+ - \`tests/*.test.ts\`: behavior proof
499
+
500
+ Keep names synchronized across the manifest and authored exports.
501
+
502
+ ## Hard rules
503
+
504
+ - Never edit files containing \`GENERATED BY loomstack\` or JSON with \`"generatedBy": "loomstack"\`.
505
+ - Never put database access or raw fetch in feature UI.
506
+ - Never import Koa in feature logic.
507
+ - Put product behavior only in canonical feature files.
508
+ - Do not bypass verifier errors with alternate architecture.
509
+ - Update tests for every behavior change.
510
+
511
+ ## Minimal implementation loop
512
+
513
+ \`\`\`bash
514
+ pnpm loomstack context feature <name> --json
515
+ pnpm loomstack affected <authored-file> --json
516
+ # Edit only relevant canonical authored files.
517
+ pnpm loomstack generate --json
518
+ pnpm loomstack verify feature <name> --json
519
+ pnpm test
520
+ \`\`\`
521
+
522
+ Use scoped verification while iterating. Finish cross-feature, routing, configuration, or generated-wiring work with \`pnpm loomstack verify --json\`, \`pnpm typecheck\`, and \`pnpm build\`. Do not repeatedly run full generation/build after every small edit.
523
+
524
+ ## Completion report
525
+
526
+ State the behavior implemented, authored files changed, generated files refreshed, validation run, and any remaining error codes or environment requirements.
527
+ `,
528
+ "CLAUDE.md": `# ${appName} LoomStack memory
529
+
530
+ Read the root \`AGENTS.md\` before acting, then read every feature-local \`AGENTS.md\` in scope. Product behavior lives in \`features/*\`; React renders and Koa transports. Never edit generated files, use generated RPC clients, and finish with \`pnpm loomstack generate --json && pnpm loomstack verify --json\`.
531
+ `,
532
+ ".gitignore": "node_modules/\ndist/\ncoverage/\n.env\n.env.local\n.DS_Store\n",
533
+ ".dockerignore": "node_modules\n**/node_modules\ndist\n**/dist\ncoverage\n.git\n.env\n.env.local\n",
534
+ ".env.example": "DATABASE_URL=postgresql://loomstack:loomstack@db:5432/loomstack\nPORT=3001\n",
535
+ "Dockerfile.dev": `FROM node:22-alpine
536
+
537
+ RUN corepack enable
538
+ WORKDIR /app
539
+
540
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
541
+ COPY apps/web/package.json apps/web/package.json
542
+ COPY apps/api/package.json apps/api/package.json
543
+ COPY .loomstack/local-packages .loomstack/local-packages
544
+ RUN pnpm install --frozen-lockfile
545
+
546
+ COPY . .
547
+ `,
548
+ "compose.yaml": `services:
549
+ db:
550
+ image: postgres:17-alpine
551
+ environment:
552
+ POSTGRES_DB: loomstack
553
+ POSTGRES_USER: loomstack
554
+ POSTGRES_PASSWORD: loomstack
555
+ ports:
556
+ - "5432:5432"
557
+ volumes:
558
+ - postgres-data:/var/lib/postgresql/data
559
+ healthcheck:
560
+ test: ["CMD-SHELL", "pg_isready -U loomstack -d loomstack"]
561
+ interval: 2s
562
+ timeout: 3s
563
+ retries: 15
564
+
565
+ api:
566
+ build:
567
+ context: .
568
+ dockerfile: Dockerfile.dev
569
+ command: pnpm --filter './apps/api' dev
570
+ environment:
571
+ DATABASE_URL: postgresql://loomstack:loomstack@db:5432/loomstack
572
+ PORT: 3001
573
+ CI: "true"
574
+ ports:
575
+ - "3001:3001"
576
+ volumes:
577
+ - .:/app
578
+ - api-root-node-modules:/app/node_modules
579
+ - api-node-modules:/app/apps/api/node_modules
580
+ depends_on:
581
+ db:
582
+ condition: service_healthy
583
+ healthcheck:
584
+ test: ["CMD", "node", "-e", "require('net').connect(3001, '127.0.0.1').on('connect', () => process.exit(0)).on('error', () => process.exit(1))"]
585
+ interval: 2s
586
+ timeout: 3s
587
+ retries: 30
588
+
589
+ web:
590
+ build:
591
+ context: .
592
+ dockerfile: Dockerfile.dev
593
+ command: pnpm --filter './apps/web' dev --host 0.0.0.0
594
+ environment:
595
+ LOOMSTACK_API_URL: http://api:3001
596
+ CHOKIDAR_USEPOLLING: "true"
597
+ CI: "true"
598
+ ports:
599
+ - "3000:3000"
600
+ volumes:
601
+ - .:/app
602
+ - web-root-node-modules:/app/node_modules
603
+ - web-node-modules:/app/apps/web/node_modules
604
+ depends_on:
605
+ api:
606
+ condition: service_healthy
607
+ healthcheck:
608
+ test: ["CMD", "node", "-e", "require('net').connect(3000, '127.0.0.1').on('connect', () => process.exit(0)).on('error', () => process.exit(1))"]
609
+ interval: 2s
610
+ timeout: 3s
611
+ retries: 30
612
+
613
+ volumes:
614
+ postgres-data:
615
+ api-root-node-modules:
616
+ api-node-modules:
617
+ web-root-node-modules:
618
+ web-node-modules:
619
+ `,
620
+ ".loomstack/local-packages/.gitkeep": "",
621
+ "features/.gitkeep": "",
622
+ "apps/web/package.json": `${JSON.stringify({
623
+ name: `@${appName}/web`,
624
+ version: "0.0.1",
625
+ private: true,
626
+ type: "module",
627
+ scripts: { dev: "vite", build: "vite build" },
628
+ dependencies: {
629
+ "@loomstack/react": "^0.0.1",
630
+ "@loomstack/runtime": "^0.0.1",
631
+ "@vitejs/plugin-react": "^6.0.3",
632
+ react: "^19.2.7",
633
+ "react-dom": "^19.2.7",
634
+ vite: "^8.1.4"
635
+ }
636
+ }, null, 2)}
637
+ `,
638
+ "apps/web/index.html": `<!doctype html>
639
+ <html lang="en">
640
+ <head>
641
+ <meta charset="UTF-8" />
642
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
643
+ <title>${appName}</title>
644
+ </head>
645
+ <body>
646
+ <div id="root"></div>
647
+ <script type="module" src="/src/main.tsx"></script>
648
+ </body>
649
+ </html>
650
+ `,
651
+ "apps/web/src/main.tsx": `import { StrictMode } from "react"
652
+ import { createRoot } from "react-dom/client"
653
+ import { App } from "./app.js"
654
+
655
+ const root = document.getElementById("root")
656
+ if (!root) throw new Error("Missing #root element")
657
+ createRoot(root).render(<StrictMode><App /></StrictMode>)
658
+ `,
659
+ "apps/web/src/app.tsx": `import { Suspense } from "react"
660
+ import { routes } from "./routes.generated.js"
661
+
662
+ function matches(pattern: string, path: string) {
663
+ const expected = pattern.split("/").filter(Boolean)
664
+ const actual = path.split("/").filter(Boolean)
665
+ return expected.length === actual.length && expected.every((part, index) => part.startsWith(":") || part === actual[index])
666
+ }
667
+
668
+ export function App() {
669
+ const route = routes.find((candidate) => matches(candidate.path, window.location.pathname))
670
+ if (!route) return <main><h1>Not found</h1></main>
671
+ const Component = route.component
672
+ return <Suspense fallback={<main>Loading\u2026</main>}><Component /></Suspense>
673
+ }
674
+ `,
675
+ "apps/web/vite.config.ts": `import { defineConfig } from "vite"
676
+ import react from "@vitejs/plugin-react"
677
+
678
+ export default defineConfig({
679
+ plugins: [react()],
680
+ server: {
681
+ host: "0.0.0.0",
682
+ port: 3000,
683
+ proxy: { "/_loomstack": process.env.LOOMSTACK_API_URL ?? "http://localhost:3001" }
684
+ }
685
+ })
686
+ `,
687
+ "apps/api/package.json": `${JSON.stringify({
688
+ name: `@${appName}/api`,
689
+ version: "0.0.1",
690
+ private: true,
691
+ type: "module",
692
+ scripts: { dev: "tsx watch src/server.ts", build: "tsup src/server.ts --format esm --clean" },
693
+ dependencies: {
694
+ "@loomstack/koa": "^0.0.1",
695
+ "@loomstack/runtime": "^0.0.1",
696
+ "@loomstack/postgres": "^0.0.1",
697
+ koa: "^3.2.1",
698
+ "koa-bodyparser": "^4.4.1"
699
+ },
700
+ devDependencies: {
701
+ "@types/koa": "^3.0.3",
702
+ "@types/koa-bodyparser": "^4.3.13",
703
+ tsup: "^8.5.0",
704
+ tsx: "^4.20.0"
705
+ }
706
+ }, null, 2)}
707
+ `,
708
+ "apps/api/src/context.ts": `import type Koa from "koa"
709
+ import { PostgresDatabase } from "@loomstack/postgres"
710
+
711
+ export const db = new PostgresDatabase()
712
+
713
+ export async function createRequestContext(ctx: Koa.Context) {
714
+ const userId = ctx.get("x-user-id")
715
+ return {
716
+ requestId: ctx.get("x-request-id") || crypto.randomUUID(),
717
+ ...(userId ? { user: { id: userId } } : {}),
718
+ db,
719
+ logger: console
720
+ }
721
+ }
722
+ `,
723
+ "apps/api/src/server.ts": `import Koa from "koa"
724
+ import bodyParser from "koa-bodyparser"
725
+ import { registerGeneratedRoutes } from "./routes.generated.js"
726
+
727
+ const app = new Koa()
728
+ app.use(bodyParser())
729
+ registerGeneratedRoutes(app)
730
+
731
+ const port = Number(process.env.PORT ?? 3001)
732
+ app.listen(port, () => console.log(\`loomstack API listening on http://localhost:\${port}\`))
733
+ `
734
+ };
735
+ }
736
+
737
+ // src/app.ts
738
+ var APP_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
739
+ function renderApp(root, appName, nextCommands) {
740
+ const files = appTemplateFiles(appName);
741
+ for (const path of Object.keys(files).sort()) writeProjectFile(root, path, files[path] ?? "", false);
742
+ const generation = generateProject(root);
743
+ const createdFiles = [...Object.keys(files), ...generation.generatedFiles].filter((value, index, all) => all.indexOf(value) === index).sort();
744
+ return { appName, root, createdFiles, nextCommands };
745
+ }
746
+ function createApp(parentInput, appName) {
747
+ if (!APP_NAME.test(appName)) {
748
+ throw new GeneratorFailure([frameworkError3("loomstack5003", {
749
+ message: `App names must be lowercase kebab-case: ${appName}.`
750
+ })]);
751
+ }
752
+ const parent = resolve2(parentInput);
753
+ const root = join4(parent, appName);
754
+ if (existsSync4(root)) {
755
+ throw new GeneratorFailure([frameworkError3("loomstack5002", { message: `Target directory already exists: ${appName}.`, file: appName })]);
756
+ }
757
+ mkdirSync3(root, { recursive: false });
758
+ return renderApp(root, appName, [`cd ${appName}`, "pnpm install", "loomstack init"]);
759
+ }
760
+ function createAppInPlace(rootInput) {
761
+ const root = resolve2(rootInput);
762
+ const appName = basename(root);
763
+ if (!APP_NAME.test(appName)) {
764
+ throw new GeneratorFailure([frameworkError3("loomstack5003", {
765
+ message: `The directory name must be lowercase kebab-case: ${appName}.`,
766
+ file: appName
767
+ })]);
768
+ }
769
+ if (!existsSync4(root)) mkdirSync3(root, { recursive: true });
770
+ if (readdirSync(root).length > 0) {
771
+ throw new GeneratorFailure([frameworkError3("loomstack5002", {
772
+ message: "The current directory is not empty and is not a LoomStack project.",
773
+ file: "."
774
+ })]);
775
+ }
776
+ return renderApp(root, appName, ["pnpm install", "loomstack init"]);
777
+ }
778
+ export {
779
+ GENERATED_SOURCE_MARKER,
780
+ GeneratorFailure,
781
+ createApp,
782
+ createAppInPlace,
783
+ createFeature,
784
+ generateProject,
785
+ generatedManifest,
786
+ readGeneratedManifest,
787
+ renderGeneratedFiles,
788
+ sha256,
789
+ stableJson,
790
+ writeProjectFile
791
+ };
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@loomstack/generator",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "dependencies": {
16
+ "@loomstack/core": "0.0.1"
17
+ },
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format esm --dts --clean",
20
+ "test": "vitest run"
21
+ }
22
+ }