@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.
- package/dist/index.d.ts +34 -0
- package/dist/index.js +791 -0
- package/package.json +22 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|