create-loomstack-app 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/README.md +25 -0
- package/dist/chunk-QTMEZJHE.js +1115 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# create-loomstack-app
|
|
2
|
+
|
|
3
|
+
Create a new loomstack full-stack application from npm.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm create loomstack-app@latest my-app
|
|
7
|
+
# or
|
|
8
|
+
npx create-loomstack-app@latest my-app
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The generated application uses TypeScript, React, Vite, Koa, PostgreSQL, Zod, pnpm, and Vitest.
|
|
12
|
+
|
|
13
|
+
## Options
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
create-loomstack-app <name> [--cwd <directory>] [--json]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
After creation:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd my-app
|
|
23
|
+
pnpm install
|
|
24
|
+
pnpm loomstack init
|
|
25
|
+
```
|
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { resolve as resolve4 } from "path";
|
|
3
|
+
|
|
4
|
+
// ../generator/src/files.ts
|
|
5
|
+
import { createHash } from "crypto";
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
7
|
+
import { dirname, join } from "path";
|
|
8
|
+
var GENERATED_SOURCE_MARKER = "// GENERATED BY loomstack. DO NOT EDIT.";
|
|
9
|
+
function stableJson(value) {
|
|
10
|
+
return `${JSON.stringify(value, null, 2)}
|
|
11
|
+
`;
|
|
12
|
+
}
|
|
13
|
+
function sha256(content) {
|
|
14
|
+
return createHash("sha256").update(content).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
function writeProjectFile(root, path, content, overwrite = true) {
|
|
17
|
+
const absolute = join(root, path);
|
|
18
|
+
if (!overwrite && existsSync(absolute)) throw new Error(`Target already exists: ${path}`);
|
|
19
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
20
|
+
writeFileSync(absolute, content, "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ../generator/src/scaffold.ts
|
|
24
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
|
|
25
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
26
|
+
|
|
27
|
+
// ../core/src/errors.ts
|
|
28
|
+
var ERROR_CATALOG = {
|
|
29
|
+
loomstack1001: {
|
|
30
|
+
title: "Feature ID does not match folder name",
|
|
31
|
+
defaultMessage: "The feature manifest ID must match its folder name.",
|
|
32
|
+
repair: "Rename the folder or update feature.yaml so the feature ID matches the folder name.",
|
|
33
|
+
docs: "implementation-plan/docs/10-verifier-and-error-system.md"
|
|
34
|
+
},
|
|
35
|
+
loomstack1002: {
|
|
36
|
+
title: "Missing or invalid feature manifest field",
|
|
37
|
+
defaultMessage: "A required feature manifest field is missing or invalid.",
|
|
38
|
+
repair: "Add the required field to feature.yaml using the canonical manifest shape.",
|
|
39
|
+
docs: "implementation-plan/docs/03-feature-contract.md"
|
|
40
|
+
},
|
|
41
|
+
loomstack1003: {
|
|
42
|
+
title: "Manifest action is not exported",
|
|
43
|
+
defaultMessage: "An action declared in the manifest has no implementation.",
|
|
44
|
+
repair: "Create the action file or update the manifest action name.",
|
|
45
|
+
docs: "implementation-plan/docs/05-action-query-runtime.md"
|
|
46
|
+
},
|
|
47
|
+
loomstack1004: {
|
|
48
|
+
title: "Exported action is missing from manifest",
|
|
49
|
+
defaultMessage: "An exported action is not declared in feature.yaml.",
|
|
50
|
+
repair: "Add the action name to feature.yaml or remove the action implementation.",
|
|
51
|
+
docs: "implementation-plan/docs/05-action-query-runtime.md"
|
|
52
|
+
},
|
|
53
|
+
loomstack1005: {
|
|
54
|
+
title: "Duplicate route path",
|
|
55
|
+
defaultMessage: "Route paths must be globally unique.",
|
|
56
|
+
repair: "Change one route path so every route path is globally unique.",
|
|
57
|
+
docs: "implementation-plan/docs/03-feature-contract.md"
|
|
58
|
+
},
|
|
59
|
+
loomstack1006: {
|
|
60
|
+
title: "Invalid feature manifest YAML",
|
|
61
|
+
defaultMessage: "feature.yaml could not be parsed.",
|
|
62
|
+
repair: "Fix the YAML syntax and run loomstack verify again.",
|
|
63
|
+
docs: "implementation-plan/docs/03-feature-contract.md"
|
|
64
|
+
},
|
|
65
|
+
loomstack1007: {
|
|
66
|
+
title: "Manifest query is not exported",
|
|
67
|
+
defaultMessage: "A query declared in the manifest has no implementation.",
|
|
68
|
+
repair: "Create the query file or update the manifest query name.",
|
|
69
|
+
docs: "implementation-plan/docs/05-action-query-runtime.md"
|
|
70
|
+
},
|
|
71
|
+
loomstack1008: {
|
|
72
|
+
title: "Exported query is missing from manifest",
|
|
73
|
+
defaultMessage: "An exported query is not declared in feature.yaml.",
|
|
74
|
+
repair: "Add the query name to feature.yaml or remove the query implementation.",
|
|
75
|
+
docs: "implementation-plan/docs/05-action-query-runtime.md"
|
|
76
|
+
},
|
|
77
|
+
loomstack1009: {
|
|
78
|
+
title: "Manifest view is not exported",
|
|
79
|
+
defaultMessage: "A route references a view that does not exist.",
|
|
80
|
+
repair: "Create the declared *.view.tsx file or update the route view in feature.yaml.",
|
|
81
|
+
docs: "implementation-plan/docs/06-react-frontend-adapter.md"
|
|
82
|
+
},
|
|
83
|
+
loomstack1010: {
|
|
84
|
+
title: "Manifest entity is not exported",
|
|
85
|
+
defaultMessage: "An entity declared in the manifest has no schema export.",
|
|
86
|
+
repair: "Export the entity from model.schema.ts or update the manifest entity name.",
|
|
87
|
+
docs: "implementation-plan/docs/04-schema-and-domain-layer.md"
|
|
88
|
+
},
|
|
89
|
+
loomstack1011: {
|
|
90
|
+
title: "Invalid feature name",
|
|
91
|
+
defaultMessage: "Feature IDs must be kebab-case.",
|
|
92
|
+
repair: "Use a lowercase kebab-case feature name such as project-notes.",
|
|
93
|
+
docs: "implementation-plan/docs/03-feature-contract.md"
|
|
94
|
+
},
|
|
95
|
+
loomstack2001: {
|
|
96
|
+
title: "Forbidden database import in UI file",
|
|
97
|
+
defaultMessage: "Database imports are forbidden in React UI files.",
|
|
98
|
+
repair: "Move database access into queries/*.query.ts or actions/*.action.ts.",
|
|
99
|
+
docs: "implementation-plan/docs/10-verifier-and-error-system.md"
|
|
100
|
+
},
|
|
101
|
+
loomstack2002: {
|
|
102
|
+
title: "Forbidden raw fetch in UI file",
|
|
103
|
+
defaultMessage: "Raw fetch is forbidden in React UI files.",
|
|
104
|
+
repair: "Use the generated loomstack action/query client.",
|
|
105
|
+
docs: "implementation-plan/docs/10-verifier-and-error-system.md"
|
|
106
|
+
},
|
|
107
|
+
loomstack2003: {
|
|
108
|
+
title: "Koa import in feature logic",
|
|
109
|
+
defaultMessage: "Feature logic must be transport-independent.",
|
|
110
|
+
repair: "Remove the Koa dependency and use LoomStackRequestContext.",
|
|
111
|
+
docs: "implementation-plan/docs/10-verifier-and-error-system.md"
|
|
112
|
+
},
|
|
113
|
+
loomstack3001: {
|
|
114
|
+
title: "Action input validation failed",
|
|
115
|
+
defaultMessage: "Action input did not match its schema.",
|
|
116
|
+
repair: "Send input matching the action input schema.",
|
|
117
|
+
docs: "implementation-plan/docs/05-action-query-runtime.md"
|
|
118
|
+
},
|
|
119
|
+
loomstack3002: {
|
|
120
|
+
title: "Runtime output validation failed",
|
|
121
|
+
defaultMessage: "Action or query output did not match its schema.",
|
|
122
|
+
repair: "Return a value matching the declared output schema.",
|
|
123
|
+
docs: "implementation-plan/docs/05-action-query-runtime.md"
|
|
124
|
+
},
|
|
125
|
+
loomstack3003: {
|
|
126
|
+
title: "Authentication required",
|
|
127
|
+
defaultMessage: "This operation requires an authenticated user.",
|
|
128
|
+
repair: "Authenticate the request before calling this operation.",
|
|
129
|
+
docs: "implementation-plan/docs/05-action-query-runtime.md"
|
|
130
|
+
},
|
|
131
|
+
loomstack4040: {
|
|
132
|
+
title: "Unknown action",
|
|
133
|
+
defaultMessage: "The requested action is not registered.",
|
|
134
|
+
repair: "Use an action declared in feature.yaml and run loomstack generate.",
|
|
135
|
+
docs: "implementation-plan/docs/07-koa-backend-adapter.md"
|
|
136
|
+
},
|
|
137
|
+
loomstack4041: {
|
|
138
|
+
title: "Unknown query",
|
|
139
|
+
defaultMessage: "The requested query is not registered.",
|
|
140
|
+
repair: "Use a query declared in feature.yaml and run loomstack generate.",
|
|
141
|
+
docs: "implementation-plan/docs/07-koa-backend-adapter.md"
|
|
142
|
+
},
|
|
143
|
+
loomstack4050: {
|
|
144
|
+
title: "Invalid RPC method",
|
|
145
|
+
defaultMessage: "loomstack RPC endpoints accept only HTTP POST.",
|
|
146
|
+
repair: "Send the action or query request with HTTP POST.",
|
|
147
|
+
docs: "implementation-plan/docs/07-koa-backend-adapter.md"
|
|
148
|
+
},
|
|
149
|
+
loomstack4001: {
|
|
150
|
+
title: "Generated file was manually modified",
|
|
151
|
+
defaultMessage: "A generated file does not match its recorded hash.",
|
|
152
|
+
repair: "Run loomstack generate or move custom logic out of the generated file.",
|
|
153
|
+
docs: "implementation-plan/docs/09-code-generation.md"
|
|
154
|
+
},
|
|
155
|
+
loomstack4002: {
|
|
156
|
+
title: "Generated file is stale",
|
|
157
|
+
defaultMessage: "Generated output does not match current feature contracts.",
|
|
158
|
+
repair: "Run loomstack generate.",
|
|
159
|
+
docs: "implementation-plan/docs/09-code-generation.md"
|
|
160
|
+
},
|
|
161
|
+
loomstack5001: {
|
|
162
|
+
title: "Missing loomstack config",
|
|
163
|
+
defaultMessage: "No loomstack.config.ts was found.",
|
|
164
|
+
repair: "Create loomstack.config.ts or run the command inside a loomstack project.",
|
|
165
|
+
docs: "implementation-plan/docs/02-repository-structure.md"
|
|
166
|
+
},
|
|
167
|
+
loomstack5002: {
|
|
168
|
+
title: "Target already exists",
|
|
169
|
+
defaultMessage: "The requested target already exists.",
|
|
170
|
+
repair: "Choose another name or remove the existing target first.",
|
|
171
|
+
docs: "implementation-plan/docs/08-cli-specification.md"
|
|
172
|
+
},
|
|
173
|
+
loomstack5003: {
|
|
174
|
+
title: "Invalid project configuration",
|
|
175
|
+
defaultMessage: "loomstack.config.ts does not contain the required golden-path values.",
|
|
176
|
+
repair: "Use React, Koa, PostgreSQL, pnpm, and explicit featuresDir/generatedDir values.",
|
|
177
|
+
docs: "implementation-plan/docs/02-repository-structure.md"
|
|
178
|
+
},
|
|
179
|
+
loomstack6001: {
|
|
180
|
+
title: "Unsupported PostgreSQL field",
|
|
181
|
+
defaultMessage: "An entity field cannot be represented by the v0.1 PostgreSQL adapter.",
|
|
182
|
+
repair: "Use a supported scalar field or provide explicit persistence logic.",
|
|
183
|
+
docs: "implementation-plan/docs/04-schema-and-domain-layer.md"
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
function frameworkError(code, options = {}) {
|
|
187
|
+
const definition = ERROR_CATALOG[code];
|
|
188
|
+
return {
|
|
189
|
+
code,
|
|
190
|
+
severity: options.severity ?? "error",
|
|
191
|
+
message: options.message ?? definition.defaultMessage,
|
|
192
|
+
repair: definition.repair,
|
|
193
|
+
docs: definition.docs,
|
|
194
|
+
...options.file ? { file: options.file } : {},
|
|
195
|
+
...options.relatedFiles ? { relatedFiles: options.relatedFiles } : {},
|
|
196
|
+
...options.location ? { location: options.location } : {}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ../core/src/scanner.ts
|
|
201
|
+
import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "fs";
|
|
202
|
+
import { dirname as dirname2, join as join2, relative, resolve, sep } from "path";
|
|
203
|
+
import { parse } from "yaml";
|
|
204
|
+
var FEATURE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
205
|
+
var DEFAULT_CONFIG = {
|
|
206
|
+
packageManager: "pnpm",
|
|
207
|
+
frontend: "react",
|
|
208
|
+
backend: "koa",
|
|
209
|
+
database: "postgres",
|
|
210
|
+
featuresDir: "features",
|
|
211
|
+
generatedDir: ".loomstack"
|
|
212
|
+
};
|
|
213
|
+
function toProjectPath(path) {
|
|
214
|
+
return path.split(sep).join("/");
|
|
215
|
+
}
|
|
216
|
+
function quotedValue(source2, key) {
|
|
217
|
+
const match = source2.match(new RegExp(`${key}\\s*:\\s*["']([^"']+)["']`));
|
|
218
|
+
return match?.[1];
|
|
219
|
+
}
|
|
220
|
+
function loadProjectConfig(root) {
|
|
221
|
+
const configPath = join2(root, "loomstack.config.ts");
|
|
222
|
+
if (!existsSync2(configPath)) {
|
|
223
|
+
return { errors: [frameworkError("loomstack5001", { file: "loomstack.config.ts" })] };
|
|
224
|
+
}
|
|
225
|
+
const source2 = readFileSync(configPath, "utf8");
|
|
226
|
+
const appName = quotedValue(source2, "appName");
|
|
227
|
+
const values = {
|
|
228
|
+
packageManager: quotedValue(source2, "packageManager"),
|
|
229
|
+
frontend: quotedValue(source2, "frontend"),
|
|
230
|
+
backend: quotedValue(source2, "backend"),
|
|
231
|
+
database: quotedValue(source2, "database"),
|
|
232
|
+
featuresDir: quotedValue(source2, "featuresDir"),
|
|
233
|
+
generatedDir: quotedValue(source2, "generatedDir")
|
|
234
|
+
};
|
|
235
|
+
if (!appName || values.packageManager !== DEFAULT_CONFIG.packageManager || values.frontend !== DEFAULT_CONFIG.frontend || values.backend !== DEFAULT_CONFIG.backend || values.database !== DEFAULT_CONFIG.database || values.featuresDir !== DEFAULT_CONFIG.featuresDir || values.generatedDir !== DEFAULT_CONFIG.generatedDir) {
|
|
236
|
+
return { errors: [frameworkError("loomstack5003", { file: "loomstack.config.ts" })] };
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
config: {
|
|
240
|
+
appName,
|
|
241
|
+
packageManager: "pnpm",
|
|
242
|
+
frontend: "react",
|
|
243
|
+
backend: "koa",
|
|
244
|
+
database: "postgres",
|
|
245
|
+
featuresDir: values.featuresDir,
|
|
246
|
+
generatedDir: values.generatedDir
|
|
247
|
+
},
|
|
248
|
+
errors: []
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function filesIn(directory, suffix) {
|
|
252
|
+
if (!existsSync2(directory)) return [];
|
|
253
|
+
return readdirSync(directory).filter((name) => name.endsWith(suffix) && statSync(join2(directory, name)).isFile()).sort().map((name) => join2(directory, name));
|
|
254
|
+
}
|
|
255
|
+
function discoverNamedFiles(root, directory, suffix, kind) {
|
|
256
|
+
const expression = new RegExp(`export\\s+const\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*${kind}\\b`);
|
|
257
|
+
return filesIn(directory, suffix).flatMap((file) => {
|
|
258
|
+
const match = readFileSync(file, "utf8").match(expression);
|
|
259
|
+
return match?.[1] ? [{ name: match[1], file: toProjectPath(relative(root, file)) }] : [];
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function discoverViews(root, directory) {
|
|
263
|
+
return filesIn(directory, ".view.tsx").flatMap((file) => {
|
|
264
|
+
const source2 = readFileSync(file, "utf8");
|
|
265
|
+
const loomStackName = source2.match(/\bname\s*:\s*["']([A-Za-z_$][\w$]*)["']/)?.[1];
|
|
266
|
+
const namedExport = source2.match(/export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*view\s*\(/)?.[1];
|
|
267
|
+
const name = loomStackName ?? namedExport;
|
|
268
|
+
return name ? [{ name, file: toProjectPath(relative(root, file)) }] : [];
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function discoverSchemas(root, featureDirectory) {
|
|
272
|
+
const file = join2(featureDirectory, "model.schema.ts");
|
|
273
|
+
if (!existsSync2(file)) return [];
|
|
274
|
+
const source2 = readFileSync(file, "utf8");
|
|
275
|
+
return [...source2.matchAll(/export\s+const\s+([A-Za-z_$][\w$]*)Schema\s*=\s*entity\s*\(\s*["']([^"']+)["']/g)].map((match) => ({ name: match[2] ?? match[1] ?? "", file: toProjectPath(relative(root, file)) })).filter((item) => item.name);
|
|
276
|
+
}
|
|
277
|
+
function validateManifest(value, manifestPath, folderName) {
|
|
278
|
+
const errors = [];
|
|
279
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
280
|
+
return { errors: [frameworkError("loomstack1002", { file: manifestPath })] };
|
|
281
|
+
}
|
|
282
|
+
const input = value;
|
|
283
|
+
const requiredStrings = ["id", "name"];
|
|
284
|
+
for (const key of requiredStrings) {
|
|
285
|
+
if (typeof input[key] !== "string" || input[key].length === 0) {
|
|
286
|
+
errors.push(frameworkError("loomstack1002", { message: `Missing or invalid required field: ${key}.`, file: manifestPath }));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const key of ["entities", "routes", "actions", "queries"]) {
|
|
290
|
+
if (!Array.isArray(input[key])) {
|
|
291
|
+
errors.push(frameworkError("loomstack1002", { message: `Missing or invalid required array: ${key}.`, file: manifestPath }));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (errors.length > 0) return { errors };
|
|
295
|
+
const id = input.id;
|
|
296
|
+
if (!FEATURE_ID.test(id)) errors.push(frameworkError("loomstack1011", { file: manifestPath }));
|
|
297
|
+
if (id !== folderName) {
|
|
298
|
+
errors.push(frameworkError("loomstack1001", { message: `Feature ID "${id}" does not match folder "${folderName}".`, file: manifestPath }));
|
|
299
|
+
}
|
|
300
|
+
if (input.description !== void 0 && typeof input.description !== "string") {
|
|
301
|
+
errors.push(frameworkError("loomstack1002", { message: "description must be a string when provided.", file: manifestPath }));
|
|
302
|
+
}
|
|
303
|
+
if (input.permissions !== void 0 && (!input.permissions || typeof input.permissions !== "object" || Array.isArray(input.permissions) || Object.values(input.permissions).some((value2) => typeof value2 !== "string"))) {
|
|
304
|
+
errors.push(frameworkError("loomstack1002", { message: "permissions must map string names to string requirements.", file: manifestPath }));
|
|
305
|
+
}
|
|
306
|
+
for (const key of ["entities", "actions", "queries"]) {
|
|
307
|
+
const values = input[key];
|
|
308
|
+
if (values.some((value2) => typeof value2 !== "string" || value2.length === 0) || new Set(values).size !== values.length) {
|
|
309
|
+
errors.push(frameworkError("loomstack1002", { message: `${key} must contain unique, non-empty strings.`, file: manifestPath }));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (input.entities.some((name) => !/^[A-Z][A-Za-z0-9]*$/.test(name))) {
|
|
313
|
+
errors.push(frameworkError("loomstack1002", { message: "Entity names must be PascalCase identifiers.", file: manifestPath }));
|
|
314
|
+
}
|
|
315
|
+
for (const key of ["actions", "queries"]) {
|
|
316
|
+
if (input[key].some((name) => !/^[A-Za-z_$][\w$]*$/.test(name))) {
|
|
317
|
+
errors.push(frameworkError("loomstack1002", { message: `${key} names must be JavaScript identifiers.`, file: manifestPath }));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const routes = input.routes;
|
|
321
|
+
const routeIds = /* @__PURE__ */ new Set();
|
|
322
|
+
for (const route of routes) {
|
|
323
|
+
if (!route || typeof route !== "object") {
|
|
324
|
+
errors.push(frameworkError("loomstack1002", { message: "Every route must be an object with id, path, and view.", file: manifestPath }));
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const candidate = route;
|
|
328
|
+
if (["id", "path", "view"].some((key) => typeof candidate[key] !== "string" || candidate[key].length === 0)) {
|
|
329
|
+
errors.push(frameworkError("loomstack1002", { message: "Every route requires string id, path, and view fields.", file: manifestPath }));
|
|
330
|
+
} else if (!candidate.path.startsWith("/") || !/^[A-Za-z_$][\w$]*$/.test(candidate.view)) {
|
|
331
|
+
errors.push(frameworkError("loomstack1002", { message: "Route paths must start with / and route views must be JavaScript identifiers.", file: manifestPath }));
|
|
332
|
+
}
|
|
333
|
+
if (typeof candidate.id === "string") {
|
|
334
|
+
if (routeIds.has(candidate.id)) errors.push(frameworkError("loomstack1002", { message: `Duplicate route ID: ${candidate.id}.`, file: manifestPath }));
|
|
335
|
+
routeIds.add(candidate.id);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (errors.length > 0) return { errors };
|
|
339
|
+
return { manifest: input, errors };
|
|
340
|
+
}
|
|
341
|
+
function scanProject(rootInput) {
|
|
342
|
+
const root = resolve(rootInput);
|
|
343
|
+
const loaded = loadProjectConfig(root);
|
|
344
|
+
if (!loaded.config) return { errors: loaded.errors };
|
|
345
|
+
const config = loaded.config;
|
|
346
|
+
const featuresDirectory = join2(root, config.featuresDir);
|
|
347
|
+
const errors = [...loaded.errors];
|
|
348
|
+
const features = [];
|
|
349
|
+
if (existsSync2(featuresDirectory)) {
|
|
350
|
+
const featureFolders = readdirSync(featuresDirectory).filter((name) => statSync(join2(featuresDirectory, name)).isDirectory()).sort();
|
|
351
|
+
for (const folderName of featureFolders) {
|
|
352
|
+
const directory = join2(featuresDirectory, folderName);
|
|
353
|
+
const absoluteManifest = join2(directory, "feature.yaml");
|
|
354
|
+
if (!existsSync2(absoluteManifest)) continue;
|
|
355
|
+
const manifestPath = toProjectPath(relative(root, absoluteManifest));
|
|
356
|
+
let raw;
|
|
357
|
+
try {
|
|
358
|
+
raw = parse(readFileSync(absoluteManifest, "utf8"));
|
|
359
|
+
} catch (error) {
|
|
360
|
+
errors.push(frameworkError("loomstack1006", { message: `Invalid YAML: ${error instanceof Error ? error.message : String(error)}`, file: manifestPath }));
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const validated = validateManifest(raw, manifestPath, folderName);
|
|
364
|
+
errors.push(...validated.errors);
|
|
365
|
+
if (!validated.manifest) continue;
|
|
366
|
+
const feature = {
|
|
367
|
+
id: folderName,
|
|
368
|
+
directory: toProjectPath(relative(root, directory)),
|
|
369
|
+
manifestPath,
|
|
370
|
+
manifest: validated.manifest,
|
|
371
|
+
schemas: discoverSchemas(root, directory),
|
|
372
|
+
actions: discoverNamedFiles(root, join2(directory, "actions"), ".action.ts", "action"),
|
|
373
|
+
queries: discoverNamedFiles(root, join2(directory, "queries"), ".query.ts", "query"),
|
|
374
|
+
views: discoverViews(root, join2(directory, "ui")),
|
|
375
|
+
tests: filesIn(join2(directory, "tests"), ".test.ts").map((file) => toProjectPath(relative(root, file)))
|
|
376
|
+
};
|
|
377
|
+
feature.views = feature.views.map((view) => {
|
|
378
|
+
const route = feature.manifest.routes.find((candidate) => candidate.view === view.name)?.path;
|
|
379
|
+
return route ? { ...view, route } : view;
|
|
380
|
+
});
|
|
381
|
+
features.push(feature);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const paths = /* @__PURE__ */ new Map();
|
|
385
|
+
for (const feature of features) {
|
|
386
|
+
for (const route of feature.manifest.routes) {
|
|
387
|
+
const owner = paths.get(route.path);
|
|
388
|
+
if (owner) {
|
|
389
|
+
errors.push(frameworkError("loomstack1005", {
|
|
390
|
+
message: `Route path "${route.path}" is declared by both ${owner} and ${feature.id}.`,
|
|
391
|
+
file: feature.manifestPath,
|
|
392
|
+
relatedFiles: [features.find((item) => item.id === owner)?.manifestPath ?? owner]
|
|
393
|
+
}));
|
|
394
|
+
} else paths.set(route.path, feature.id);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const graph = {
|
|
398
|
+
features: features.map((feature) => ({
|
|
399
|
+
id: feature.id,
|
|
400
|
+
manifest: feature.manifestPath,
|
|
401
|
+
entities: [...feature.manifest.entities].sort(),
|
|
402
|
+
actions: [...feature.actions].sort((a, b) => a.name.localeCompare(b.name)),
|
|
403
|
+
queries: [...feature.queries].sort((a, b) => a.name.localeCompare(b.name)),
|
|
404
|
+
views: [...feature.views].sort((a, b) => a.name.localeCompare(b.name)),
|
|
405
|
+
tests: [...feature.tests].sort(),
|
|
406
|
+
routes: feature.manifest.routes.map((route) => route.path).sort()
|
|
407
|
+
})),
|
|
408
|
+
dependencies: []
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
project: { root, configPath: "loomstack.config.ts", config, features, graph },
|
|
412
|
+
errors
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ../generator/src/scaffold.ts
|
|
417
|
+
var GeneratorFailure = class extends Error {
|
|
418
|
+
constructor(errors) {
|
|
419
|
+
super(errors.map((error) => `${error.code}: ${error.message}`).join("\n"));
|
|
420
|
+
this.errors = errors;
|
|
421
|
+
this.name = "GeneratorFailure";
|
|
422
|
+
}
|
|
423
|
+
errors;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// ../generator/src/generate.ts
|
|
427
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
428
|
+
import { join as join4 } from "path";
|
|
429
|
+
function source(lines) {
|
|
430
|
+
return `${GENERATED_SOURCE_MARKER}
|
|
431
|
+
|
|
432
|
+
${lines.join("\n").trimEnd()}
|
|
433
|
+
`;
|
|
434
|
+
}
|
|
435
|
+
function importPath(path) {
|
|
436
|
+
return `../../${path.replace(/\.(tsx?|jsx?)$/, ".js")}`;
|
|
437
|
+
}
|
|
438
|
+
function featureRegistry(project) {
|
|
439
|
+
const entries = project.features.map(
|
|
440
|
+
(feature) => ` ${JSON.stringify(feature.id)}: { id: ${JSON.stringify(feature.id)}, name: ${JSON.stringify(feature.manifest.name)}, manifestPath: ${JSON.stringify(feature.manifestPath)} }`
|
|
441
|
+
);
|
|
442
|
+
return source([
|
|
443
|
+
"export const featureRegistry = {",
|
|
444
|
+
entries.join(",\n"),
|
|
445
|
+
"} as const"
|
|
446
|
+
]);
|
|
447
|
+
}
|
|
448
|
+
function actionRegistry(project) {
|
|
449
|
+
const actions = project.features.flatMap((feature) => feature.actions).sort((a, b) => a.name.localeCompare(b.name));
|
|
450
|
+
return source([
|
|
451
|
+
...actions.map((item) => `import { ${item.name} } from ${JSON.stringify(importPath(item.file))}`),
|
|
452
|
+
actions.length ? "" : "",
|
|
453
|
+
"export const actionRegistry = {",
|
|
454
|
+
...actions.map((item) => ` ${item.name},`),
|
|
455
|
+
"} as const"
|
|
456
|
+
]);
|
|
457
|
+
}
|
|
458
|
+
function queryRegistry(project) {
|
|
459
|
+
const queries = project.features.flatMap((feature) => feature.queries).sort((a, b) => a.name.localeCompare(b.name));
|
|
460
|
+
return source([
|
|
461
|
+
...queries.map((item) => `import { ${item.name} } from ${JSON.stringify(importPath(item.file))}`),
|
|
462
|
+
queries.length ? "" : "",
|
|
463
|
+
"export const queryRegistry = {",
|
|
464
|
+
...queries.map((item) => ` ${item.name},`),
|
|
465
|
+
"} as const"
|
|
466
|
+
]);
|
|
467
|
+
}
|
|
468
|
+
function schemaRegistry(project) {
|
|
469
|
+
const schemas = project.features.flatMap((feature) => feature.manifest.entities.map((name) => ({
|
|
470
|
+
name,
|
|
471
|
+
file: `${feature.directory}/model.schema.ts`
|
|
472
|
+
}))).sort((a, b) => a.name.localeCompare(b.name));
|
|
473
|
+
return source([
|
|
474
|
+
...schemas.map((item) => `import { ${item.name}Schema } from ${JSON.stringify(importPath(item.file))}`),
|
|
475
|
+
schemas.length ? "" : "",
|
|
476
|
+
"export const schemaRegistry = {",
|
|
477
|
+
...schemas.map((item) => ` ${item.name}: ${item.name}Schema,`),
|
|
478
|
+
"} as const"
|
|
479
|
+
]);
|
|
480
|
+
}
|
|
481
|
+
function resolveView(feature, viewName) {
|
|
482
|
+
return feature.views.find((item) => item.name === viewName)?.file;
|
|
483
|
+
}
|
|
484
|
+
function reactRoutes(project) {
|
|
485
|
+
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));
|
|
486
|
+
const missing = routes.filter((route) => !resolveView(route.feature, route.view));
|
|
487
|
+
if (missing.length) {
|
|
488
|
+
throw new GeneratorFailure(missing.map((route) => frameworkError("loomstack1009", {
|
|
489
|
+
message: `Route ${route.id} references missing view ${route.view}.`,
|
|
490
|
+
file: route.feature.manifestPath
|
|
491
|
+
})));
|
|
492
|
+
}
|
|
493
|
+
return source([
|
|
494
|
+
'import { lazy } from "react"',
|
|
495
|
+
'import type { ComponentType, LazyExoticComponent } from "react"',
|
|
496
|
+
"",
|
|
497
|
+
"export interface LoomStackRoute {",
|
|
498
|
+
" id: string",
|
|
499
|
+
" feature: string",
|
|
500
|
+
" path: string",
|
|
501
|
+
" component: LazyExoticComponent<ComponentType>",
|
|
502
|
+
"}",
|
|
503
|
+
"",
|
|
504
|
+
"export const routes: LoomStackRoute[] = [",
|
|
505
|
+
...routes.map((route) => {
|
|
506
|
+
const file = resolveView(route.feature, route.view) ?? "";
|
|
507
|
+
const relative2 = `../../../${file.replace(/\.(tsx?|jsx?)$/, "")}`;
|
|
508
|
+
return ` { id: ${JSON.stringify(route.id)}, feature: ${JSON.stringify(route.feature.id)}, path: ${JSON.stringify(route.path)}, component: lazy(() => import(${JSON.stringify(relative2)})) },`;
|
|
509
|
+
}),
|
|
510
|
+
"]"
|
|
511
|
+
]);
|
|
512
|
+
}
|
|
513
|
+
function apiClient(project) {
|
|
514
|
+
const actions = project.features.flatMap((feature) => feature.actions).sort((a, b) => a.name.localeCompare(b.name));
|
|
515
|
+
const queries = project.features.flatMap((feature) => feature.queries).sort((a, b) => a.name.localeCompare(b.name));
|
|
516
|
+
return source([
|
|
517
|
+
'import { createActionClient, createQueryClient } from "@loomstack/react"',
|
|
518
|
+
...actions.map((item) => `import type { ${item.name} as _${item.name}Definition } from ${JSON.stringify(`../../../${item.file.replace(/\.(tsx?|jsx?)$/, ".js")}`)}`),
|
|
519
|
+
...queries.map((item) => `import type { ${item.name} as _${item.name}Definition } from ${JSON.stringify(`../../../${item.file.replace(/\.(tsx?|jsx?)$/, ".js")}`)}`),
|
|
520
|
+
"",
|
|
521
|
+
"export const api = {",
|
|
522
|
+
" actions: {",
|
|
523
|
+
...actions.map((item) => ` ${item.name}: createActionClient<Parameters<typeof _${item.name}Definition.run>[1], Awaited<ReturnType<typeof _${item.name}Definition.run>>>(${JSON.stringify(item.name)}),`),
|
|
524
|
+
" },",
|
|
525
|
+
" queries: {",
|
|
526
|
+
...queries.map((item) => ` ${item.name}: createQueryClient<Parameters<typeof _${item.name}Definition.run>[1], Awaited<ReturnType<typeof _${item.name}Definition.run>>>(${JSON.stringify(item.name)}),`),
|
|
527
|
+
" }",
|
|
528
|
+
"} as const"
|
|
529
|
+
]);
|
|
530
|
+
}
|
|
531
|
+
function koaRoutes() {
|
|
532
|
+
return source([
|
|
533
|
+
'import type Koa from "koa"',
|
|
534
|
+
'import { registerLoomStackRoutes } from "@loomstack/koa"',
|
|
535
|
+
'import { actionRegistry } from "../../../shared/generated/action-registry.generated.js"',
|
|
536
|
+
'import { queryRegistry } from "../../../shared/generated/query-registry.generated.js"',
|
|
537
|
+
'import { createRequestContext } from "./context.js"',
|
|
538
|
+
"",
|
|
539
|
+
"export function registerGeneratedRoutes(app: Koa) {",
|
|
540
|
+
" registerLoomStackRoutes(app, { actionRegistry, queryRegistry, createRequestContext })",
|
|
541
|
+
"}"
|
|
542
|
+
]);
|
|
543
|
+
}
|
|
544
|
+
function contextDocument(project) {
|
|
545
|
+
return {
|
|
546
|
+
generatedBy: "loomstack",
|
|
547
|
+
doNotEdit: true,
|
|
548
|
+
project: {
|
|
549
|
+
name: project.config.appName,
|
|
550
|
+
frontend: project.config.frontend,
|
|
551
|
+
backend: project.config.backend,
|
|
552
|
+
database: project.config.database,
|
|
553
|
+
packageManager: project.config.packageManager
|
|
554
|
+
},
|
|
555
|
+
paths: { features: project.config.featuresDir, web: "apps/web", api: "apps/api" },
|
|
556
|
+
commands: {
|
|
557
|
+
dev: "pnpm dev",
|
|
558
|
+
verify: "pnpm loomstack verify",
|
|
559
|
+
test: "pnpm test",
|
|
560
|
+
typecheck: "pnpm typecheck"
|
|
561
|
+
},
|
|
562
|
+
features: project.features.map((feature) => feature.id),
|
|
563
|
+
rules: [
|
|
564
|
+
{ id: "feature-source-of-truth", description: "Product behavior lives in features/*.", errorCode: null },
|
|
565
|
+
{ id: "no-db-in-ui", description: "React UI files may not import database code.", errorCode: "loomstack2001" },
|
|
566
|
+
{ id: "no-fetch-in-ui", description: "React UI files may not call raw fetch.", errorCode: "loomstack2002" },
|
|
567
|
+
{ id: "transport-independent-features", description: "Feature logic may not import Koa.", errorCode: "loomstack2003" }
|
|
568
|
+
]
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function graphDocument(project) {
|
|
572
|
+
return { generatedBy: "loomstack", doNotEdit: true, ...project.graph };
|
|
573
|
+
}
|
|
574
|
+
function commandsDocument() {
|
|
575
|
+
return {
|
|
576
|
+
generatedBy: "loomstack",
|
|
577
|
+
doNotEdit: true,
|
|
578
|
+
commands: {
|
|
579
|
+
install: "pnpm install",
|
|
580
|
+
dev: "pnpm dev",
|
|
581
|
+
test: "pnpm test",
|
|
582
|
+
typecheck: "pnpm typecheck",
|
|
583
|
+
verify: "pnpm loomstack verify",
|
|
584
|
+
verifyFeature: "pnpm loomstack verify feature <feature>"
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function errorsDocument() {
|
|
589
|
+
return {
|
|
590
|
+
generatedBy: "loomstack",
|
|
591
|
+
doNotEdit: true,
|
|
592
|
+
errors: Object.fromEntries(Object.entries(ERROR_CATALOG).sort(([a], [b]) => a.localeCompare(b)).map(([code, definition]) => [code, {
|
|
593
|
+
title: definition.title,
|
|
594
|
+
repair: definition.repair,
|
|
595
|
+
docs: definition.docs
|
|
596
|
+
}]))
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
function renderGeneratedFiles(project) {
|
|
600
|
+
const generatedDir = project.config.generatedDir;
|
|
601
|
+
const entries = [
|
|
602
|
+
["apps/web/src/routes.generated.tsx", reactRoutes(project)],
|
|
603
|
+
["apps/web/src/api-client.generated.ts", apiClient(project)],
|
|
604
|
+
["apps/api/src/routes.generated.ts", koaRoutes()],
|
|
605
|
+
["shared/generated/feature-registry.generated.ts", featureRegistry(project)],
|
|
606
|
+
["shared/generated/schema-registry.generated.ts", schemaRegistry(project)],
|
|
607
|
+
["shared/generated/action-registry.generated.ts", actionRegistry(project)],
|
|
608
|
+
["shared/generated/query-registry.generated.ts", queryRegistry(project)],
|
|
609
|
+
[`${generatedDir}/context.json`, stableJson(contextDocument(project))],
|
|
610
|
+
[`${generatedDir}/graph.json`, stableJson(graphDocument(project))],
|
|
611
|
+
[`${generatedDir}/commands.json`, stableJson(commandsDocument())],
|
|
612
|
+
[`${generatedDir}/errors.json`, stableJson(errorsDocument())]
|
|
613
|
+
];
|
|
614
|
+
return Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)));
|
|
615
|
+
}
|
|
616
|
+
function generatedManifest(files) {
|
|
617
|
+
return {
|
|
618
|
+
generatedBy: "loomstack",
|
|
619
|
+
doNotEdit: true,
|
|
620
|
+
files: Object.entries(files).map(([path, content]) => ({ path, sha256: sha256(content) })).sort((a, b) => a.path.localeCompare(b.path))
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function generationContractErrors(project) {
|
|
624
|
+
const errors = [];
|
|
625
|
+
const operationOwners = /* @__PURE__ */ new Map();
|
|
626
|
+
for (const feature of project.features) {
|
|
627
|
+
const actionNames = new Set(feature.actions.map((item) => item.name));
|
|
628
|
+
const queryNames = new Set(feature.queries.map((item) => item.name));
|
|
629
|
+
const viewNames = new Set(feature.views.map((item) => item.name));
|
|
630
|
+
const schemaNames = new Set(feature.schemas.map((item) => item.name));
|
|
631
|
+
for (const name of feature.manifest.actions) {
|
|
632
|
+
if (!actionNames.has(name)) errors.push(frameworkError("loomstack1003", { message: `Manifest action "${name}" is not exported.`, file: feature.manifestPath }));
|
|
633
|
+
}
|
|
634
|
+
for (const operation of feature.actions) {
|
|
635
|
+
if (!feature.manifest.actions.includes(operation.name)) errors.push(frameworkError("loomstack1004", { message: `Exported action "${operation.name}" is missing from feature.yaml.`, file: operation.file }));
|
|
636
|
+
}
|
|
637
|
+
for (const name of feature.manifest.queries) {
|
|
638
|
+
if (!queryNames.has(name)) errors.push(frameworkError("loomstack1007", { message: `Manifest query "${name}" is not exported.`, file: feature.manifestPath }));
|
|
639
|
+
}
|
|
640
|
+
for (const operation of feature.queries) {
|
|
641
|
+
if (!feature.manifest.queries.includes(operation.name)) errors.push(frameworkError("loomstack1008", { message: `Exported query "${operation.name}" is missing from feature.yaml.`, file: operation.file }));
|
|
642
|
+
}
|
|
643
|
+
for (const route of feature.manifest.routes) {
|
|
644
|
+
if (!viewNames.has(route.view)) errors.push(frameworkError("loomstack1009", { message: `Route "${route.id}" references missing view "${route.view}".`, file: feature.manifestPath }));
|
|
645
|
+
}
|
|
646
|
+
for (const name of feature.manifest.entities) {
|
|
647
|
+
if (!schemaNames.has(name)) errors.push(frameworkError("loomstack1010", { message: `Manifest entity "${name}" is not exported by model.schema.ts.`, file: feature.manifestPath }));
|
|
648
|
+
}
|
|
649
|
+
for (const [kind, operations] of [["action", feature.actions], ["query", feature.queries]]) {
|
|
650
|
+
for (const operation of operations) {
|
|
651
|
+
const owner = operationOwners.get(operation.name);
|
|
652
|
+
if (owner) errors.push(frameworkError(kind === "action" ? "loomstack1004" : "loomstack1008", { message: `Operation "${operation.name}" is also exported by ${owner}.`, file: operation.file }));
|
|
653
|
+
else operationOwners.set(operation.name, feature.id);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return errors;
|
|
658
|
+
}
|
|
659
|
+
function generateProject(root) {
|
|
660
|
+
const result = scanProject(root);
|
|
661
|
+
if (!result.project || result.errors.length > 0) throw new GeneratorFailure(result.errors);
|
|
662
|
+
const contractErrors = generationContractErrors(result.project);
|
|
663
|
+
if (contractErrors.length > 0) throw new GeneratorFailure(contractErrors);
|
|
664
|
+
const files = renderGeneratedFiles(result.project);
|
|
665
|
+
for (const [path, content] of Object.entries(files)) writeProjectFile(result.project.root, path, content);
|
|
666
|
+
const manifestPath = `${result.project.config.generatedDir}/generated-files.json`;
|
|
667
|
+
writeProjectFile(result.project.root, manifestPath, stableJson(generatedManifest(files)));
|
|
668
|
+
return { generatedFiles: [...Object.keys(files), manifestPath].sort() };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ../generator/src/app.ts
|
|
672
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync as readdirSync2 } from "fs";
|
|
673
|
+
import { basename, join as join5, resolve as resolve3 } from "path";
|
|
674
|
+
|
|
675
|
+
// ../generator/src/app-template.ts
|
|
676
|
+
function appTemplateFiles(appName) {
|
|
677
|
+
return {
|
|
678
|
+
"package.json": `${JSON.stringify({
|
|
679
|
+
name: appName,
|
|
680
|
+
version: "0.0.1",
|
|
681
|
+
private: true,
|
|
682
|
+
type: "module",
|
|
683
|
+
packageManager: "pnpm@11.11.0",
|
|
684
|
+
scripts: {
|
|
685
|
+
loomstack: "loomstack",
|
|
686
|
+
dev: "loomstack dev start",
|
|
687
|
+
"dev:refresh": "loomstack dev refresh",
|
|
688
|
+
"dev:stop": "loomstack dev stop",
|
|
689
|
+
"dev:status": "loomstack dev status",
|
|
690
|
+
generate: "loomstack generate",
|
|
691
|
+
verify: "loomstack verify",
|
|
692
|
+
test: "vitest run --passWithNoTests",
|
|
693
|
+
typecheck: "tsc --noEmit",
|
|
694
|
+
build: "pnpm generate && pnpm -r --filter './apps/*' build"
|
|
695
|
+
},
|
|
696
|
+
devDependencies: {
|
|
697
|
+
"@loomstack/cli": "^0.0.1",
|
|
698
|
+
"@loomstack/react": "^0.0.1",
|
|
699
|
+
"@loomstack/runtime": "^0.0.1",
|
|
700
|
+
"@loomstack/postgres": "^0.0.1",
|
|
701
|
+
"@types/node": "^24.0.0",
|
|
702
|
+
"@types/react": "^19.0.0",
|
|
703
|
+
"@types/react-dom": "^19.0.0",
|
|
704
|
+
react: "^19.2.7",
|
|
705
|
+
"react-dom": "^19.2.7",
|
|
706
|
+
typescript: "^5.9.3",
|
|
707
|
+
vitest: "^4.1.10"
|
|
708
|
+
}
|
|
709
|
+
}, null, 2)}
|
|
710
|
+
`,
|
|
711
|
+
"pnpm-workspace.yaml": "packages:\n - apps/*\n\nallowBuilds:\n esbuild: true\n",
|
|
712
|
+
"tsconfig.json": `${JSON.stringify({
|
|
713
|
+
compilerOptions: {
|
|
714
|
+
target: "ES2023",
|
|
715
|
+
module: "ESNext",
|
|
716
|
+
moduleResolution: "Bundler",
|
|
717
|
+
lib: ["ES2023", "DOM", "DOM.Iterable"],
|
|
718
|
+
strict: true,
|
|
719
|
+
noUncheckedIndexedAccess: true,
|
|
720
|
+
exactOptionalPropertyTypes: true,
|
|
721
|
+
esModuleInterop: true,
|
|
722
|
+
skipLibCheck: true,
|
|
723
|
+
resolveJsonModule: true,
|
|
724
|
+
jsx: "react-jsx"
|
|
725
|
+
},
|
|
726
|
+
include: ["apps/**/*.ts", "apps/**/*.tsx", "features/**/*.ts", "features/**/*.tsx", "shared/**/*.ts", "loomstack.config.ts"]
|
|
727
|
+
}, null, 2)}
|
|
728
|
+
`,
|
|
729
|
+
"vitest.config.ts": `import { defineConfig } from "vitest/config"
|
|
730
|
+
|
|
731
|
+
export default defineConfig({ test: { include: ["features/**/*.test.ts", "features/**/*.test.tsx"] } })
|
|
732
|
+
`,
|
|
733
|
+
"loomstack.config.ts": `import { defineLoomStackConfig } from "@loomstack/runtime"
|
|
734
|
+
|
|
735
|
+
export default defineLoomStackConfig({
|
|
736
|
+
appName: "${appName}",
|
|
737
|
+
packageManager: "pnpm",
|
|
738
|
+
frontend: "react",
|
|
739
|
+
backend: "koa",
|
|
740
|
+
database: "postgres",
|
|
741
|
+
featuresDir: "features",
|
|
742
|
+
generatedDir: ".loomstack"
|
|
743
|
+
})
|
|
744
|
+
`,
|
|
745
|
+
"AGENTS.md": `# ${appName} agent instructions
|
|
746
|
+
|
|
747
|
+
This is a LoomStack app. Humans define product intent; agents implement it through explicit feature contracts.
|
|
748
|
+
|
|
749
|
+
## Architecture
|
|
750
|
+
|
|
751
|
+
\`\`\`text
|
|
752
|
+
React = presentation
|
|
753
|
+
Koa = transport
|
|
754
|
+
features/* = product behavior and source of truth
|
|
755
|
+
generated files = derived wiring, never product behavior
|
|
756
|
+
\`\`\`
|
|
757
|
+
|
|
758
|
+
## Command convention
|
|
759
|
+
|
|
760
|
+
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.
|
|
761
|
+
|
|
762
|
+
## Start every task efficiently
|
|
763
|
+
|
|
764
|
+
1. Read this file and any in-scope \`features/<feature>/AGENTS.md\`.
|
|
765
|
+
2. Use \`pnpm loomstack doctor --json\` only when environment or setup may be relevant.
|
|
766
|
+
3. Discover scope before broad file exploration:
|
|
767
|
+
- Existing feature: \`pnpm loomstack context feature <name> --json\`
|
|
768
|
+
- New or cross-feature work: \`pnpm loomstack context --json\`
|
|
769
|
+
- Dependencies or routes: \`pnpm loomstack graph --json\`
|
|
770
|
+
4. Before editing an existing authored file, run \`pnpm loomstack affected <path> --json\`.
|
|
771
|
+
5. Create missing features with \`pnpm loomstack create feature <kebab-case-name> --json\`; do not hand-build feature folders.
|
|
772
|
+
|
|
773
|
+
## Tool selection
|
|
774
|
+
|
|
775
|
+
| Need | Command |
|
|
776
|
+
|---|---|
|
|
777
|
+
| Validate setup | \`pnpm loomstack doctor --json\` |
|
|
778
|
+
| Understand the app | \`pnpm loomstack context --json\` |
|
|
779
|
+
| Understand one feature | \`pnpm loomstack context feature <name> --json\` |
|
|
780
|
+
| Inspect relationships/routes | \`pnpm loomstack graph --json\` |
|
|
781
|
+
| Find companion files | \`pnpm loomstack affected <authored-file> --json\` |
|
|
782
|
+
| Scaffold a feature | \`pnpm loomstack create feature <name> --json\` |
|
|
783
|
+
| Rebuild derived wiring | \`pnpm loomstack generate --json\` |
|
|
784
|
+
| Check one feature | \`pnpm loomstack verify feature <name> --json\` |
|
|
785
|
+
| Check the whole app | \`pnpm loomstack verify --json\` |
|
|
786
|
+
| Understand an error | \`pnpm loomstack explain <code> --json\` |
|
|
787
|
+
|
|
788
|
+
When a command returns an error code, run \`pnpm loomstack explain <code> --json\` and follow its repair guidance before inventing a workaround.
|
|
789
|
+
|
|
790
|
+
## Development containers
|
|
791
|
+
|
|
792
|
+
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.
|
|
793
|
+
|
|
794
|
+
- Start before browser/API validation: \`pnpm loomstack dev start --json\`.
|
|
795
|
+
- Check state when uncertain: \`pnpm loomstack dev status --json\`.
|
|
796
|
+
- Use \`pnpm loomstack dev refresh --json\` after dependency, Dockerfile, Compose, or environment changes. Do not refresh for ordinary source edits; hot reload handles them.
|
|
797
|
+
- 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.
|
|
798
|
+
- If lifecycle commands fail, run \`pnpm loomstack doctor --json\` and follow the returned repair guidance.
|
|
799
|
+
|
|
800
|
+
## Canonical feature layout
|
|
801
|
+
|
|
802
|
+
- \`feature.yaml\`: declared entities, routes, actions, and queries
|
|
803
|
+
- \`model.schema.ts\`: domain entities and schemas
|
|
804
|
+
- \`permissions.policy.ts\`: authorization policy
|
|
805
|
+
- \`actions/*.action.ts\`: mutations
|
|
806
|
+
- \`queries/*.query.ts\`: reads
|
|
807
|
+
- \`ui/*.view.tsx\`: routes and presentation
|
|
808
|
+
- \`tests/*.test.ts\`: behavior proof
|
|
809
|
+
|
|
810
|
+
Keep names synchronized across the manifest and authored exports.
|
|
811
|
+
|
|
812
|
+
## Hard rules
|
|
813
|
+
|
|
814
|
+
- Never edit files containing \`GENERATED BY loomstack\` or JSON with \`"generatedBy": "loomstack"\`.
|
|
815
|
+
- Never put database access or raw fetch in feature UI.
|
|
816
|
+
- Never import Koa in feature logic.
|
|
817
|
+
- Put product behavior only in canonical feature files.
|
|
818
|
+
- Do not bypass verifier errors with alternate architecture.
|
|
819
|
+
- Update tests for every behavior change.
|
|
820
|
+
|
|
821
|
+
## Minimal implementation loop
|
|
822
|
+
|
|
823
|
+
\`\`\`bash
|
|
824
|
+
pnpm loomstack context feature <name> --json
|
|
825
|
+
pnpm loomstack affected <authored-file> --json
|
|
826
|
+
# Edit only relevant canonical authored files.
|
|
827
|
+
pnpm loomstack generate --json
|
|
828
|
+
pnpm loomstack verify feature <name> --json
|
|
829
|
+
pnpm test
|
|
830
|
+
\`\`\`
|
|
831
|
+
|
|
832
|
+
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.
|
|
833
|
+
|
|
834
|
+
## Completion report
|
|
835
|
+
|
|
836
|
+
State the behavior implemented, authored files changed, generated files refreshed, validation run, and any remaining error codes or environment requirements.
|
|
837
|
+
`,
|
|
838
|
+
"CLAUDE.md": `# ${appName} LoomStack memory
|
|
839
|
+
|
|
840
|
+
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\`.
|
|
841
|
+
`,
|
|
842
|
+
".gitignore": "node_modules/\ndist/\ncoverage/\n.env\n.env.local\n.DS_Store\n",
|
|
843
|
+
".dockerignore": "node_modules\n**/node_modules\ndist\n**/dist\ncoverage\n.git\n.env\n.env.local\n",
|
|
844
|
+
".env.example": "DATABASE_URL=postgresql://loomstack:loomstack@db:5432/loomstack\nPORT=3001\n",
|
|
845
|
+
"Dockerfile.dev": `FROM node:22-alpine
|
|
846
|
+
|
|
847
|
+
RUN corepack enable
|
|
848
|
+
WORKDIR /app
|
|
849
|
+
|
|
850
|
+
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
851
|
+
COPY apps/web/package.json apps/web/package.json
|
|
852
|
+
COPY apps/api/package.json apps/api/package.json
|
|
853
|
+
COPY .loomstack/local-packages .loomstack/local-packages
|
|
854
|
+
RUN pnpm install --frozen-lockfile
|
|
855
|
+
|
|
856
|
+
COPY . .
|
|
857
|
+
`,
|
|
858
|
+
"compose.yaml": `services:
|
|
859
|
+
db:
|
|
860
|
+
image: postgres:17-alpine
|
|
861
|
+
environment:
|
|
862
|
+
POSTGRES_DB: loomstack
|
|
863
|
+
POSTGRES_USER: loomstack
|
|
864
|
+
POSTGRES_PASSWORD: loomstack
|
|
865
|
+
ports:
|
|
866
|
+
- "5432:5432"
|
|
867
|
+
volumes:
|
|
868
|
+
- postgres-data:/var/lib/postgresql/data
|
|
869
|
+
healthcheck:
|
|
870
|
+
test: ["CMD-SHELL", "pg_isready -U loomstack -d loomstack"]
|
|
871
|
+
interval: 2s
|
|
872
|
+
timeout: 3s
|
|
873
|
+
retries: 15
|
|
874
|
+
|
|
875
|
+
api:
|
|
876
|
+
build:
|
|
877
|
+
context: .
|
|
878
|
+
dockerfile: Dockerfile.dev
|
|
879
|
+
command: pnpm --filter './apps/api' dev
|
|
880
|
+
environment:
|
|
881
|
+
DATABASE_URL: postgresql://loomstack:loomstack@db:5432/loomstack
|
|
882
|
+
PORT: 3001
|
|
883
|
+
CI: "true"
|
|
884
|
+
ports:
|
|
885
|
+
- "3001:3001"
|
|
886
|
+
volumes:
|
|
887
|
+
- .:/app
|
|
888
|
+
- api-root-node-modules:/app/node_modules
|
|
889
|
+
- api-node-modules:/app/apps/api/node_modules
|
|
890
|
+
depends_on:
|
|
891
|
+
db:
|
|
892
|
+
condition: service_healthy
|
|
893
|
+
healthcheck:
|
|
894
|
+
test: ["CMD", "node", "-e", "require('net').connect(3001, '127.0.0.1').on('connect', () => process.exit(0)).on('error', () => process.exit(1))"]
|
|
895
|
+
interval: 2s
|
|
896
|
+
timeout: 3s
|
|
897
|
+
retries: 30
|
|
898
|
+
|
|
899
|
+
web:
|
|
900
|
+
build:
|
|
901
|
+
context: .
|
|
902
|
+
dockerfile: Dockerfile.dev
|
|
903
|
+
command: pnpm --filter './apps/web' dev --host 0.0.0.0
|
|
904
|
+
environment:
|
|
905
|
+
LOOMSTACK_API_URL: http://api:3001
|
|
906
|
+
CHOKIDAR_USEPOLLING: "true"
|
|
907
|
+
CI: "true"
|
|
908
|
+
ports:
|
|
909
|
+
- "3000:3000"
|
|
910
|
+
volumes:
|
|
911
|
+
- .:/app
|
|
912
|
+
- web-root-node-modules:/app/node_modules
|
|
913
|
+
- web-node-modules:/app/apps/web/node_modules
|
|
914
|
+
depends_on:
|
|
915
|
+
api:
|
|
916
|
+
condition: service_healthy
|
|
917
|
+
healthcheck:
|
|
918
|
+
test: ["CMD", "node", "-e", "require('net').connect(3000, '127.0.0.1').on('connect', () => process.exit(0)).on('error', () => process.exit(1))"]
|
|
919
|
+
interval: 2s
|
|
920
|
+
timeout: 3s
|
|
921
|
+
retries: 30
|
|
922
|
+
|
|
923
|
+
volumes:
|
|
924
|
+
postgres-data:
|
|
925
|
+
api-root-node-modules:
|
|
926
|
+
api-node-modules:
|
|
927
|
+
web-root-node-modules:
|
|
928
|
+
web-node-modules:
|
|
929
|
+
`,
|
|
930
|
+
".loomstack/local-packages/.gitkeep": "",
|
|
931
|
+
"features/.gitkeep": "",
|
|
932
|
+
"apps/web/package.json": `${JSON.stringify({
|
|
933
|
+
name: `@${appName}/web`,
|
|
934
|
+
version: "0.0.1",
|
|
935
|
+
private: true,
|
|
936
|
+
type: "module",
|
|
937
|
+
scripts: { dev: "vite", build: "vite build" },
|
|
938
|
+
dependencies: {
|
|
939
|
+
"@loomstack/react": "^0.0.1",
|
|
940
|
+
"@loomstack/runtime": "^0.0.1",
|
|
941
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
942
|
+
react: "^19.2.7",
|
|
943
|
+
"react-dom": "^19.2.7",
|
|
944
|
+
vite: "^8.1.4"
|
|
945
|
+
}
|
|
946
|
+
}, null, 2)}
|
|
947
|
+
`,
|
|
948
|
+
"apps/web/index.html": `<!doctype html>
|
|
949
|
+
<html lang="en">
|
|
950
|
+
<head>
|
|
951
|
+
<meta charset="UTF-8" />
|
|
952
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
953
|
+
<title>${appName}</title>
|
|
954
|
+
</head>
|
|
955
|
+
<body>
|
|
956
|
+
<div id="root"></div>
|
|
957
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
958
|
+
</body>
|
|
959
|
+
</html>
|
|
960
|
+
`,
|
|
961
|
+
"apps/web/src/main.tsx": `import { StrictMode } from "react"
|
|
962
|
+
import { createRoot } from "react-dom/client"
|
|
963
|
+
import { App } from "./app.js"
|
|
964
|
+
|
|
965
|
+
const root = document.getElementById("root")
|
|
966
|
+
if (!root) throw new Error("Missing #root element")
|
|
967
|
+
createRoot(root).render(<StrictMode><App /></StrictMode>)
|
|
968
|
+
`,
|
|
969
|
+
"apps/web/src/app.tsx": `import { Suspense } from "react"
|
|
970
|
+
import { routes } from "./routes.generated.js"
|
|
971
|
+
|
|
972
|
+
function matches(pattern: string, path: string) {
|
|
973
|
+
const expected = pattern.split("/").filter(Boolean)
|
|
974
|
+
const actual = path.split("/").filter(Boolean)
|
|
975
|
+
return expected.length === actual.length && expected.every((part, index) => part.startsWith(":") || part === actual[index])
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export function App() {
|
|
979
|
+
const route = routes.find((candidate) => matches(candidate.path, window.location.pathname))
|
|
980
|
+
if (!route) return <main><h1>Not found</h1></main>
|
|
981
|
+
const Component = route.component
|
|
982
|
+
return <Suspense fallback={<main>Loading\u2026</main>}><Component /></Suspense>
|
|
983
|
+
}
|
|
984
|
+
`,
|
|
985
|
+
"apps/web/vite.config.ts": `import { defineConfig } from "vite"
|
|
986
|
+
import react from "@vitejs/plugin-react"
|
|
987
|
+
|
|
988
|
+
export default defineConfig({
|
|
989
|
+
plugins: [react()],
|
|
990
|
+
server: {
|
|
991
|
+
host: "0.0.0.0",
|
|
992
|
+
port: 3000,
|
|
993
|
+
proxy: { "/_loomstack": process.env.LOOMSTACK_API_URL ?? "http://localhost:3001" }
|
|
994
|
+
}
|
|
995
|
+
})
|
|
996
|
+
`,
|
|
997
|
+
"apps/api/package.json": `${JSON.stringify({
|
|
998
|
+
name: `@${appName}/api`,
|
|
999
|
+
version: "0.0.1",
|
|
1000
|
+
private: true,
|
|
1001
|
+
type: "module",
|
|
1002
|
+
scripts: { dev: "tsx watch src/server.ts", build: "tsup src/server.ts --format esm --clean" },
|
|
1003
|
+
dependencies: {
|
|
1004
|
+
"@loomstack/koa": "^0.0.1",
|
|
1005
|
+
"@loomstack/runtime": "^0.0.1",
|
|
1006
|
+
"@loomstack/postgres": "^0.0.1",
|
|
1007
|
+
koa: "^3.2.1",
|
|
1008
|
+
"koa-bodyparser": "^4.4.1"
|
|
1009
|
+
},
|
|
1010
|
+
devDependencies: {
|
|
1011
|
+
"@types/koa": "^3.0.3",
|
|
1012
|
+
"@types/koa-bodyparser": "^4.3.13",
|
|
1013
|
+
tsup: "^8.5.0",
|
|
1014
|
+
tsx: "^4.20.0"
|
|
1015
|
+
}
|
|
1016
|
+
}, null, 2)}
|
|
1017
|
+
`,
|
|
1018
|
+
"apps/api/src/context.ts": `import type Koa from "koa"
|
|
1019
|
+
import { PostgresDatabase } from "@loomstack/postgres"
|
|
1020
|
+
|
|
1021
|
+
export const db = new PostgresDatabase()
|
|
1022
|
+
|
|
1023
|
+
export async function createRequestContext(ctx: Koa.Context) {
|
|
1024
|
+
const userId = ctx.get("x-user-id")
|
|
1025
|
+
return {
|
|
1026
|
+
requestId: ctx.get("x-request-id") || crypto.randomUUID(),
|
|
1027
|
+
...(userId ? { user: { id: userId } } : {}),
|
|
1028
|
+
db,
|
|
1029
|
+
logger: console
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
`,
|
|
1033
|
+
"apps/api/src/server.ts": `import Koa from "koa"
|
|
1034
|
+
import bodyParser from "koa-bodyparser"
|
|
1035
|
+
import { registerGeneratedRoutes } from "./routes.generated.js"
|
|
1036
|
+
|
|
1037
|
+
const app = new Koa()
|
|
1038
|
+
app.use(bodyParser())
|
|
1039
|
+
registerGeneratedRoutes(app)
|
|
1040
|
+
|
|
1041
|
+
const port = Number(process.env.PORT ?? 3001)
|
|
1042
|
+
app.listen(port, () => console.log(\`loomstack API listening on http://localhost:\${port}\`))
|
|
1043
|
+
`
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// ../generator/src/app.ts
|
|
1048
|
+
var APP_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
1049
|
+
function renderApp(root, appName, nextCommands) {
|
|
1050
|
+
const files = appTemplateFiles(appName);
|
|
1051
|
+
for (const path of Object.keys(files).sort()) writeProjectFile(root, path, files[path] ?? "", false);
|
|
1052
|
+
const generation = generateProject(root);
|
|
1053
|
+
const createdFiles = [...Object.keys(files), ...generation.generatedFiles].filter((value, index, all) => all.indexOf(value) === index).sort();
|
|
1054
|
+
return { appName, root, createdFiles, nextCommands };
|
|
1055
|
+
}
|
|
1056
|
+
function createApp(parentInput, appName) {
|
|
1057
|
+
if (!APP_NAME.test(appName)) {
|
|
1058
|
+
throw new GeneratorFailure([frameworkError("loomstack5003", {
|
|
1059
|
+
message: `App names must be lowercase kebab-case: ${appName}.`
|
|
1060
|
+
})]);
|
|
1061
|
+
}
|
|
1062
|
+
const parent = resolve3(parentInput);
|
|
1063
|
+
const root = join5(parent, appName);
|
|
1064
|
+
if (existsSync5(root)) {
|
|
1065
|
+
throw new GeneratorFailure([frameworkError("loomstack5002", { message: `Target directory already exists: ${appName}.`, file: appName })]);
|
|
1066
|
+
}
|
|
1067
|
+
mkdirSync3(root, { recursive: false });
|
|
1068
|
+
return renderApp(root, appName, [`cd ${appName}`, "pnpm install", "loomstack init"]);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// src/index.ts
|
|
1072
|
+
function usage() {
|
|
1073
|
+
return "Usage: create-loomstack-app <name> [--cwd <directory>] [--json]";
|
|
1074
|
+
}
|
|
1075
|
+
function runCreateLoomStackApp(argv, io = { stdout: console.log, stderr: console.error }) {
|
|
1076
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
1077
|
+
io.stdout(`${usage()}
|
|
1078
|
+
|
|
1079
|
+
Create a production-ready loomstack application.`);
|
|
1080
|
+
return 0;
|
|
1081
|
+
}
|
|
1082
|
+
const json = argv.includes("--json");
|
|
1083
|
+
const cwdIndex = argv.indexOf("--cwd");
|
|
1084
|
+
const cwdValue = cwdIndex >= 0 ? argv[cwdIndex + 1] : void 0;
|
|
1085
|
+
const positional = argv.filter(
|
|
1086
|
+
(value, index) => value !== "--json" && value !== "--cwd" && index !== cwdIndex + 1 && !value.startsWith("-")
|
|
1087
|
+
);
|
|
1088
|
+
const name = positional[0];
|
|
1089
|
+
if (!name || cwdIndex >= 0 && !cwdValue) {
|
|
1090
|
+
const error = { code: "loomstack5003", message: usage(), repair: "Provide a lowercase kebab-case application name." };
|
|
1091
|
+
if (json) io.stdout(JSON.stringify({ ok: false, errors: [error] }));
|
|
1092
|
+
else io.stderr(`${error.message}
|
|
1093
|
+
Repair: ${error.repair}`);
|
|
1094
|
+
return 1;
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
const result = createApp(resolve4(cwdValue ?? process.cwd()), name);
|
|
1098
|
+
if (json) io.stdout(JSON.stringify({ ok: true, data: result }));
|
|
1099
|
+
else {
|
|
1100
|
+
io.stdout(`Created loomstack app: ${result.appName}`);
|
|
1101
|
+
io.stdout(`Next: ${result.nextCommands.join(" && ")}`);
|
|
1102
|
+
}
|
|
1103
|
+
return 0;
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
const errors = error instanceof GeneratorFailure ? error.errors : [{ code: "loomstack5003", message: error instanceof Error ? error.message : String(error), repair: "Retry with a writable target directory." }];
|
|
1106
|
+
if (json) io.stdout(JSON.stringify({ ok: false, errors }));
|
|
1107
|
+
else for (const item of errors) io.stderr(`${item.code}: ${item.message}
|
|
1108
|
+
Repair: ${item.repair}`);
|
|
1109
|
+
return 1;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
export {
|
|
1114
|
+
runCreateLoomStackApp
|
|
1115
|
+
};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-loomstack-app",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Create a full-stack loomstack application",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-loomstack-app": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"yaml": "^2.9.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@loomstack/generator": "0.0.1"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.0.0"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"loomstack",
|
|
30
|
+
"create-app",
|
|
31
|
+
"fullstack",
|
|
32
|
+
"typescript",
|
|
33
|
+
"react",
|
|
34
|
+
"koa",
|
|
35
|
+
"postgresql"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/DenizOkcu/loomstack.git",
|
|
40
|
+
"directory": "packages/create-loomstack-app"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/DenizOkcu/loomstack#readme",
|
|
43
|
+
"bugs": "https://github.com/DenizOkcu/loomstack/issues",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsup --config tsup.config.ts",
|
|
49
|
+
"test": "vitest run --root ../.. packages/create-loomstack-app/tests"
|
|
50
|
+
}
|
|
51
|
+
}
|