@zaaxch/tailframe 2.2.0 → 4.0.0
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/bin/tailframe.mjs +43 -13
- package/package.json +1 -1
- package/src/architecture.mjs +13 -8
- package/src/config.mjs +93 -0
- package/src/conventions.mjs +17 -3
- package/src/exceptions.mjs +16 -6
- package/src/generate.mjs +16 -6
- package/src/new.mjs +324 -201
- package/src/owned-guidance.mjs +27 -0
- package/src/owned-sources.mjs +222 -0
- package/src/service-templates.mjs +222 -19
- package/src/sync.mjs +64 -0
- package/src/ui-templates.mjs +135 -1
- package/src/validate.mjs +90 -0
package/bin/tailframe.mjs
CHANGED
|
@@ -1,30 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import {
|
|
4
|
+
import { APP_KINDS } from "../src/config.mjs";
|
|
5
|
+
import { runConfiguredValidate } from "../src/validate.mjs";
|
|
5
6
|
import { runGenerate, GenerateError } from "../src/generate.mjs";
|
|
6
7
|
import { createProject } from "../src/new.mjs";
|
|
8
|
+
import { runSync } from "../src/sync.mjs";
|
|
7
9
|
|
|
8
10
|
const require = createRequire(import.meta.url);
|
|
9
11
|
|
|
10
12
|
function usage() {
|
|
11
|
-
console.error("Usage: tailframe validate --
|
|
12
|
-
console.error(" tailframe
|
|
13
|
-
console.error(" tailframe
|
|
13
|
+
console.error("Usage: tailframe validate [root] [--app service|ui]");
|
|
14
|
+
console.error(" tailframe sync --check|--write [root]");
|
|
15
|
+
console.error(" tailframe new <name> [--path <parent>] [--db mongo|postgres] [--auth none|firebase] [--ui] [--redis] [--worker]");
|
|
16
|
+
console.error(" tailframe generate --app service|ui <schematic> <args...> [--options]");
|
|
14
17
|
console.error(" service schematics: module <name> <VerbNoun> [--no-http], use-case <module> <VerbNoun>,");
|
|
15
18
|
console.error(" http <module> <VerbNoun>, port <module> <Name>, adapter <module> <PortName> --db <technology>,");
|
|
16
19
|
console.error(" identifiers <module> <NameId...>, integration <provider>");
|
|
17
20
|
console.error(" ui schematics: module <name> --api|--view <Name>|--component <Name>|--store <name>|--composable <name>|--public <name>|--public-component <Name>,");
|
|
18
21
|
console.error(" api <module>, view <module> <Name>, component <module> <Name>, store <module> <name>, composable <module> <name>,");
|
|
19
|
-
console.error(" public <module> <name>, public-component <module> <Name>, app-store auth, app-store theme --storage-key <key>, app-component ThemeToggle");
|
|
22
|
+
console.error(" public <module> <name>, public-component <module> <Name>, app-store auth|notification, app-store theme --storage-key <key>, app-component ThemeToggle");
|
|
20
23
|
process.exit(2);
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
const args = process.argv.slice(2);
|
|
24
27
|
const command = args.shift();
|
|
28
|
+
const contractVersion = require("../package.json").version;
|
|
25
29
|
|
|
26
30
|
if (command === "--version" || command === "-v") {
|
|
27
|
-
console.log(
|
|
31
|
+
console.log(contractVersion);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
if (command === "sync") {
|
|
35
|
+
let mode;
|
|
36
|
+
let root = ".";
|
|
37
|
+
while (args.length) {
|
|
38
|
+
const value = args.shift();
|
|
39
|
+
if (value === "--check") mode = "check";
|
|
40
|
+
else if (value === "--write") mode = "write";
|
|
41
|
+
else if (value.startsWith("-")) usage();
|
|
42
|
+
else root = value;
|
|
43
|
+
}
|
|
44
|
+
if (!mode) usage();
|
|
45
|
+
const result = runSync(root, mode, contractVersion);
|
|
46
|
+
for (const error of result.errors) console.error(error);
|
|
47
|
+
for (const file of result.changed) console.log(`Updated ${file}`);
|
|
48
|
+
if (result.errors.length) process.exit(1);
|
|
49
|
+
if (mode === "check") console.log(`Canonical Tailframe sources match ${contractVersion}: ${path.resolve(root)}`);
|
|
28
50
|
process.exit(0);
|
|
29
51
|
}
|
|
30
52
|
if (command === "new") {
|
|
@@ -33,7 +55,7 @@ if (command === "new") {
|
|
|
33
55
|
const rest = [];
|
|
34
56
|
for (let index = 0; index < args.length; index += 1) {
|
|
35
57
|
const value = args[index];
|
|
36
|
-
if (value === "--path" || value === "--auth") rest.push(value, args[++index]);
|
|
58
|
+
if (value === "--path" || value === "--auth" || value === "--db") rest.push(value, args[++index]);
|
|
37
59
|
else if (value.startsWith("--")) rest.push(value);
|
|
38
60
|
else if (name === undefined) name = value;
|
|
39
61
|
else rest.push(value);
|
|
@@ -52,7 +74,15 @@ if (command === "new") {
|
|
|
52
74
|
}
|
|
53
75
|
if (command === "generate") {
|
|
54
76
|
try {
|
|
55
|
-
|
|
77
|
+
let selectedKind;
|
|
78
|
+
const rest = [];
|
|
79
|
+
while (args.length) {
|
|
80
|
+
const value = args.shift();
|
|
81
|
+
if (value === "--app") selectedKind = args.shift();
|
|
82
|
+
else rest.push(value);
|
|
83
|
+
}
|
|
84
|
+
if (!APP_KINDS.has(selectedKind)) usage();
|
|
85
|
+
const { created, checklist } = runGenerate(".", rest, selectedKind);
|
|
56
86
|
console.log("Created:");
|
|
57
87
|
for (const file of created) console.log(` ${file}`);
|
|
58
88
|
console.log("Next steps:");
|
|
@@ -68,17 +98,17 @@ if (command === "generate") {
|
|
|
68
98
|
}
|
|
69
99
|
if (command !== "validate") usage();
|
|
70
100
|
|
|
71
|
-
let kind;
|
|
72
101
|
let root = ".";
|
|
102
|
+
let selectedKind;
|
|
73
103
|
while (args.length) {
|
|
74
104
|
const value = args.shift();
|
|
75
|
-
if (value === "--
|
|
105
|
+
if (value === "--app") selectedKind = args.shift();
|
|
76
106
|
else if (value.startsWith("-")) usage();
|
|
77
107
|
else root = value;
|
|
78
108
|
}
|
|
79
|
-
if (!
|
|
109
|
+
if (selectedKind && !APP_KINDS.has(selectedKind)) usage();
|
|
80
110
|
|
|
81
|
-
const errors =
|
|
111
|
+
const errors = runConfiguredValidate(root, contractVersion, selectedKind);
|
|
82
112
|
for (const error of errors) console.error(error);
|
|
83
113
|
if (errors.length) process.exit(1);
|
|
84
|
-
console.log(`Validated ${
|
|
114
|
+
console.log(`Validated Tailframe contract ${contractVersion}${selectedKind ? ` for ${selectedKind}` : ""}: ${path.resolve(root)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaaxch/tailframe",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/architecture.mjs
CHANGED
|
@@ -4,9 +4,13 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".vue"]);
|
|
7
|
-
const APP_STORE_FILES = ["auth.store.ts", "theme.store.ts"];
|
|
7
|
+
const APP_STORE_FILES = ["auth.store.ts", "notification.store.ts", "theme.store.ts"];
|
|
8
8
|
const APP_PUBLIC_FILES = ["ThemeToggle.vue"];
|
|
9
|
-
const APP_STORE_TARGETS = new Set([
|
|
9
|
+
const APP_STORE_TARGETS = new Set([
|
|
10
|
+
"src/app/stores/auth.store",
|
|
11
|
+
"src/app/stores/notification.store",
|
|
12
|
+
"src/app/stores/theme.store"
|
|
13
|
+
]);
|
|
10
14
|
const APP_PUBLIC_TARGETS = new Set(["src/app/public/ThemeToggle"]);
|
|
11
15
|
|
|
12
16
|
function withoutSourceExtension(target) {
|
|
@@ -138,8 +142,8 @@ function validateStructure(root, kind, fail) {
|
|
|
138
142
|
? ["__tests__", "domain", "http", "persistence", "use-cases"]
|
|
139
143
|
: ["__tests__", "api", "components", "composables", "public", "routes", "stores", "types", "views"];
|
|
140
144
|
for (const moduleName of listEntries(modules, "directory")) {
|
|
141
|
-
if (kind
|
|
142
|
-
fail(`Application ${moduleName
|
|
145
|
+
if (kind !== "service" && ["auth", "notification", "theme"].includes(moduleName)) {
|
|
146
|
+
fail(`Application ${moduleName} capability must live under src/app, not src/modules/${moduleName}`);
|
|
143
147
|
}
|
|
144
148
|
checkEntries(`src/modules/${moduleName}`, "directory", allowedModuleDirectories);
|
|
145
149
|
checkEntries(`src/modules/${moduleName}`, "file", []);
|
|
@@ -223,9 +227,10 @@ function validateServiceImport(source, target, external, fail, graph) {
|
|
|
223
227
|
}
|
|
224
228
|
}
|
|
225
229
|
|
|
226
|
-
function validateUiImport(source, target, external, fail, graph) {
|
|
227
|
-
|
|
228
|
-
|
|
230
|
+
function validateUiImport(source, target, external, fail, graph, kind) {
|
|
231
|
+
const bootstraps = ["src/main.ts"];
|
|
232
|
+
if (bootstraps.includes(source)) {
|
|
233
|
+
if (external || !target.startsWith("src/app/")) fail(`${source} may import only app bootstrap code, not ${external ?? target}`);
|
|
229
234
|
return;
|
|
230
235
|
}
|
|
231
236
|
if (source.startsWith("src/core/")) {
|
|
@@ -313,7 +318,7 @@ export function validateArchitecture(rootArgument, kind) {
|
|
|
313
318
|
continue;
|
|
314
319
|
}
|
|
315
320
|
if (kind === "service") validateServiceImport(relative, target, target ? undefined : specifier, fail, graph);
|
|
316
|
-
else validateUiImport(relative, target, target ? undefined : specifier, fail, graph);
|
|
321
|
+
else validateUiImport(relative, target, target ? undefined : specifier, fail, graph, kind);
|
|
317
322
|
}
|
|
318
323
|
}
|
|
319
324
|
detectCycles(graph, fail, kind === "service" ? "Cross-module use-case dependency cycle" : "Cross-module UI public dependency cycle");
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const CONFIG_FILE = "tailframe.json";
|
|
5
|
+
export const CONFIG_SCHEMA_VERSION = 2;
|
|
6
|
+
export const PRODUCT_KIND = "product";
|
|
7
|
+
export const APP_KINDS = new Set(["service", "ui"]);
|
|
8
|
+
export const APP_PATHS = Object.freeze({
|
|
9
|
+
service: "apps/service",
|
|
10
|
+
ui: "apps/ui"
|
|
11
|
+
});
|
|
12
|
+
export const PROFILES = new Set([
|
|
13
|
+
"firebase",
|
|
14
|
+
"mongo",
|
|
15
|
+
"postgres",
|
|
16
|
+
"redis",
|
|
17
|
+
"worker",
|
|
18
|
+
"rate-limit",
|
|
19
|
+
"ui-host",
|
|
20
|
+
"notifications"
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function validateProfiles(app, errors) {
|
|
24
|
+
if (!Array.isArray(app.profiles) || app.profiles.some((profile) => !PROFILES.has(profile))) {
|
|
25
|
+
errors.push(`${CONFIG_FILE} app ${app.kind ?? "unknown"} profiles contain an unsupported value`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (new Set(app.profiles).size !== app.profiles.length) {
|
|
29
|
+
errors.push(`${CONFIG_FILE} app ${app.kind} profiles must not contain duplicates`);
|
|
30
|
+
}
|
|
31
|
+
const profiles = new Set(app.profiles);
|
|
32
|
+
if (app.kind === "service") {
|
|
33
|
+
if (profiles.has("mongo") === profiles.has("postgres")) {
|
|
34
|
+
errors.push("Service profiles must select exactly one of mongo or postgres");
|
|
35
|
+
}
|
|
36
|
+
if (profiles.has("worker") && !profiles.has("redis")) errors.push("The worker profile requires redis");
|
|
37
|
+
if (profiles.has("rate-limit") && !profiles.has("redis")) errors.push("The rate-limit profile requires redis");
|
|
38
|
+
if (profiles.has("notifications")) errors.push("The notifications profile is client-only");
|
|
39
|
+
} else {
|
|
40
|
+
for (const profile of ["mongo", "postgres", "redis", "worker", "rate-limit", "ui-host"]) {
|
|
41
|
+
if (profiles.has(profile)) errors.push(`${profile} is a service-only profile`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function loadConfig(rootArgument) {
|
|
47
|
+
const root = path.resolve(rootArgument);
|
|
48
|
+
const file = path.join(root, CONFIG_FILE);
|
|
49
|
+
if (!fs.existsSync(file)) return { root, errors: [`Missing ${CONFIG_FILE}`] };
|
|
50
|
+
let config;
|
|
51
|
+
try {
|
|
52
|
+
config = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return { root, errors: [`Invalid ${CONFIG_FILE}: ${error instanceof Error ? error.message : error}`] };
|
|
55
|
+
}
|
|
56
|
+
const errors = [];
|
|
57
|
+
if (config.schemaVersion !== CONFIG_SCHEMA_VERSION) errors.push(`${CONFIG_FILE} schemaVersion must be ${CONFIG_SCHEMA_VERSION}`);
|
|
58
|
+
if (config.kind !== PRODUCT_KIND) errors.push(`${CONFIG_FILE} kind must be product`);
|
|
59
|
+
if (typeof config.contractVersion !== "string" || !/^\d+\.\d+\.\d+$/.test(config.contractVersion)) {
|
|
60
|
+
errors.push(`${CONFIG_FILE} contractVersion must be an exact semantic version`);
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(config.apps) || config.apps.length === 0) {
|
|
63
|
+
errors.push(`${CONFIG_FILE} apps must be a non-empty array`);
|
|
64
|
+
} else {
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const app of config.apps) {
|
|
67
|
+
if (!app || !APP_KINDS.has(app.kind)) {
|
|
68
|
+
errors.push(`${CONFIG_FILE} app kind must be service or ui`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (seen.has(app.kind)) errors.push(`${CONFIG_FILE} contains duplicate ${app.kind} apps`);
|
|
72
|
+
seen.add(app.kind);
|
|
73
|
+
if (app.path !== APP_PATHS[app.kind]) {
|
|
74
|
+
errors.push(`${CONFIG_FILE} ${app.kind} path must be ${APP_PATHS[app.kind]}`);
|
|
75
|
+
}
|
|
76
|
+
validateProfiles(app, errors);
|
|
77
|
+
}
|
|
78
|
+
if (!seen.has("service")) errors.push(`${CONFIG_FILE} must declare exactly one service app`);
|
|
79
|
+
}
|
|
80
|
+
return { root, config, errors };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function appConfig(productConfig, kind) {
|
|
84
|
+
return productConfig.apps.find((app) => app.kind === kind);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function appRoot(productRoot, app) {
|
|
88
|
+
return path.join(productRoot, app.path);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function configSource({ apps, contractVersion }) {
|
|
92
|
+
return `${JSON.stringify({ schemaVersion: CONFIG_SCHEMA_VERSION, contractVersion, kind: PRODUCT_KIND, apps }, null, "\t")}\n`;
|
|
93
|
+
}
|
package/src/conventions.mjs
CHANGED
|
@@ -115,7 +115,7 @@ export function validateConventions(rootArgument, kind) {
|
|
|
115
115
|
if (kind === "ui") {
|
|
116
116
|
validateRouteNames(root, src, flag);
|
|
117
117
|
validateCanonicalShell(root, flag);
|
|
118
|
-
} else {
|
|
118
|
+
} else if (kind === "service") {
|
|
119
119
|
validateServiceComposition(root, flag);
|
|
120
120
|
}
|
|
121
121
|
return violations;
|
|
@@ -129,6 +129,12 @@ function validateServiceSource(relative, moduleName, layer, remainder, name, sou
|
|
|
129
129
|
} else if (exportedClasses.length === 1 && exportedClasses[0] !== name.stem) {
|
|
130
130
|
flag("G1", relative, `${relative} must be named ${exportedClasses[0]}.ts after its exported use-case class`);
|
|
131
131
|
}
|
|
132
|
+
if (/^\s*(?:async\s+)?execute\s*\(/m.test(source) && !/^\s*(?:async\s+)?execute\s*\(\s*[_a-zA-Z][A-Za-z0-9]*\s*:\s*RequestContext\s*,\s*[_a-zA-Z][A-Za-z0-9]*\s*:/m.test(source)) {
|
|
133
|
+
flag("S1", relative, `${relative} execute must receive RequestContext first and an explicit input second; internal policies use a descriptive method instead`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (["domain", "use-cases"].includes(layer) && /\b_id\b/.test(source)) {
|
|
137
|
+
flag("S5", relative, `${relative} exposes MongoDB _id outside persistence; domain and use-case code use id`);
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
if (layer !== "http") return;
|
|
@@ -143,6 +149,11 @@ function validateServiceSource(relative, moduleName, layer, remainder, name, sou
|
|
|
143
149
|
if (postCount > rpcHandlerCount) {
|
|
144
150
|
flag("S8", relative, `${relative} must translate every RPC POST through rpcHandler; found ${postCount} POST routes and ${rpcHandlerCount} rpcHandler calls`);
|
|
145
151
|
}
|
|
152
|
+
for (const match of source.matchAll(/\brouter\.post\s*\(\s*["']\/([^"']+)["']/g)) {
|
|
153
|
+
if (!match[1].startsWith(`${moduleName}.`)) {
|
|
154
|
+
flag("S8", relative, `${relative} exposes ${match[1]}; RPC operations use the owning module namespace ${moduleName}.*`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
146
157
|
}
|
|
147
158
|
if (name.role === "schemas" && name.subject === moduleName) {
|
|
148
159
|
const schemas = [...source.matchAll(/^export\s+const\s+([A-Za-z][A-Za-z0-9]*)\s*=/gm)].map((match) => match[1]);
|
|
@@ -202,7 +213,8 @@ function normalizeGeneratedSource(source) {
|
|
|
202
213
|
function validateCanonicalShell(root, flag) {
|
|
203
214
|
const authRelative = "src/app/stores/auth.store.ts";
|
|
204
215
|
const authFile = path.join(root, authRelative);
|
|
205
|
-
|
|
216
|
+
const expectedAuth = authAppStoreSource();
|
|
217
|
+
if (fs.existsSync(authFile) && normalizeGeneratedSource(fs.readFileSync(authFile, "utf8")) !== expectedAuth) {
|
|
206
218
|
flag("U7", authRelative, `${authRelative} differs from the canonical Tailframe auth store; regenerate it instead of maintaining a project-local variant`);
|
|
207
219
|
}
|
|
208
220
|
|
|
@@ -279,7 +291,9 @@ function validateShared(relative, name, flag) {
|
|
|
279
291
|
return;
|
|
280
292
|
}
|
|
281
293
|
if (relative.startsWith("src/app/stores/") && name.extension === ".ts") {
|
|
282
|
-
|
|
294
|
+
if (!new Set(["auth.store", "notification.store", "theme.store"]).has(name.stem)) {
|
|
295
|
+
flag("U3", relative, `${relative} is not a canonical app store; app/stores is closed to auth.store.ts, notification.store.ts, and theme.store.ts`);
|
|
296
|
+
}
|
|
283
297
|
return;
|
|
284
298
|
}
|
|
285
299
|
if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) {
|
package/src/exceptions.mjs
CHANGED
|
@@ -14,19 +14,29 @@ function parseExceptionSection(markdown) {
|
|
|
14
14
|
return covered;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function normalize(relative) {
|
|
18
|
+
return relative.split(path.sep).join("/");
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
export function loadExceptions(rootArgument) {
|
|
18
22
|
const root = path.resolve(rootArgument);
|
|
19
|
-
const repositoryName = path.basename(root);
|
|
20
23
|
const covered = [];
|
|
21
24
|
const own = path.join(root, "AGENTS.md");
|
|
22
25
|
if (fs.existsSync(own)) covered.push(...parseExceptionSection(fs.readFileSync(own, "utf8")));
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
|
|
27
|
+
let ancestor = path.dirname(root);
|
|
28
|
+
while (ancestor !== path.dirname(ancestor)) {
|
|
29
|
+
const guidance = path.join(ancestor, "AGENTS.md");
|
|
30
|
+
if (fs.existsSync(guidance)) {
|
|
31
|
+
const appPath = normalize(path.relative(ancestor, root));
|
|
32
|
+
for (const entry of parseExceptionSection(fs.readFileSync(guidance, "utf8"))) {
|
|
33
|
+
if (entry === appPath || entry.startsWith(`${appPath}/`)) {
|
|
34
|
+
covered.push(entry.slice(appPath.length + 1) || ".");
|
|
35
|
+
}
|
|
28
36
|
}
|
|
29
37
|
}
|
|
38
|
+
if (fs.existsSync(path.join(ancestor, "tailframe.json"))) break;
|
|
39
|
+
ancestor = path.dirname(ancestor);
|
|
30
40
|
}
|
|
31
41
|
return covered;
|
|
32
42
|
}
|
package/src/generate.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { appRoot, loadConfig } from "./config.mjs";
|
|
3
4
|
import { loadExceptions, isExcepted } from "./exceptions.mjs";
|
|
4
5
|
import { serviceHttpFiles, serviceOperationName } from "./service-templates.mjs";
|
|
5
|
-
import { moduleApiSource } from "./ui-templates.mjs";
|
|
6
|
+
import { moduleApiSource, notificationStoreSource } from "./ui-templates.mjs";
|
|
6
7
|
|
|
7
8
|
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
8
9
|
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
@@ -470,7 +471,8 @@ function appStoreFiles(name, options) {
|
|
|
470
471
|
return { "src/app/stores/theme.store.ts": themeAppStoreSource(storageKey) };
|
|
471
472
|
}
|
|
472
473
|
if (name === "auth") return { "src/app/stores/auth.store.ts": authAppStoreSource() };
|
|
473
|
-
|
|
474
|
+
if (name === "notification") return { "src/app/stores/notification.store.ts": notificationStoreSource };
|
|
475
|
+
fail(`App store must be one of auth, notification, or theme, received "${name ?? ""}"`);
|
|
474
476
|
}
|
|
475
477
|
|
|
476
478
|
function appPublicComponentFiles(name) {
|
|
@@ -527,7 +529,7 @@ const uiSchematics = {
|
|
|
527
529
|
store(root, [moduleName, name]) {
|
|
528
530
|
requireUiModuleName(root, moduleName);
|
|
529
531
|
if (!name || !KEBAB.test(name)) fail(`Store name must be lowercase kebab-case, received "${name ?? ""}"`);
|
|
530
|
-
return [plan(uiArtifacts(moduleName, { store: name })), [`Keep product state in this owning module;
|
|
532
|
+
return [plan(uiArtifacts(moduleName, { store: name })), [`Keep product state in this owning module; when another module needs the capability, design the smallest stable public entry instead of re-exporting the store by default`]];
|
|
531
533
|
},
|
|
532
534
|
composable(root, [moduleName, name]) {
|
|
533
535
|
requireUiModuleName(root, moduleName);
|
|
@@ -570,8 +572,13 @@ const uiSchematics = {
|
|
|
570
572
|
}
|
|
571
573
|
};
|
|
572
574
|
|
|
573
|
-
export function runGenerate(rootArgument, argv) {
|
|
574
|
-
const
|
|
575
|
+
export function runGenerate(rootArgument, argv, selectedKind) {
|
|
576
|
+
const loaded = loadConfig(rootArgument);
|
|
577
|
+
if (loaded.errors.length) fail(loaded.errors.join("\n"));
|
|
578
|
+
if (!selectedKind) fail("tailframe generate requires --app service or ui");
|
|
579
|
+
const app = loaded.config.apps.find((candidate) => candidate.kind === selectedKind);
|
|
580
|
+
if (!app) fail(`tailframe.json does not declare a ${selectedKind} app`);
|
|
581
|
+
const root = appRoot(loaded.root, app);
|
|
575
582
|
const positionals = [];
|
|
576
583
|
const options = new Map();
|
|
577
584
|
const flags = new Set();
|
|
@@ -585,11 +592,14 @@ export function runGenerate(rootArgument, argv) {
|
|
|
585
592
|
options.has = (key) => flags.has(key) || Map.prototype.has.call(options, key);
|
|
586
593
|
const schematic = positionals.shift();
|
|
587
594
|
const kind = detectKind(root);
|
|
595
|
+
if (kind !== selectedKind) {
|
|
596
|
+
fail(`Configured ${selectedKind} app at ${app.path} has ${kind} entry-point shape`);
|
|
597
|
+
}
|
|
588
598
|
const registry = kind === "service" ? serviceSchematics : uiSchematics;
|
|
589
599
|
if (!schematic || !registry[schematic]) {
|
|
590
600
|
fail(`Unknown ${kind} schematic "${schematic ?? ""}". Available: ${Object.keys(registry).join(", ")}`);
|
|
591
601
|
}
|
|
592
602
|
const [filePlan, checklist] = registry[schematic](root, positionals, options);
|
|
593
603
|
const created = filePlan.write(root);
|
|
594
|
-
return { kind, created, checklist: [...checklist, "Run
|
|
604
|
+
return { kind, created: created.map((relative) => path.join(app.path, relative)), checklist: [...checklist, "Run pnpm validate:architecture, then focused type checks and tests"] };
|
|
595
605
|
}
|