@zaaxch/tailframe 3.0.0 → 4.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/bin/tailframe.mjs +18 -6
- package/package.json +1 -1
- package/src/architecture.mjs +3 -3
- package/src/config.mjs +57 -23
- package/src/conventions.mjs +3 -6
- package/src/exceptions.mjs +16 -6
- package/src/generate.mjs +13 -4
- package/src/new.mjs +151 -176
- package/src/owned-guidance.mjs +20 -8
- package/src/owned-sources.mjs +9 -266
- package/src/sync.mjs +48 -41
- package/src/ui-templates.mjs +0 -45
- package/src/validate.mjs +110 -20
- package/src/flutter.mjs +0 -111
package/bin/tailframe.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
+
import { APP_KINDS } from "../src/config.mjs";
|
|
4
5
|
import { runConfiguredValidate } from "../src/validate.mjs";
|
|
5
6
|
import { runGenerate, GenerateError } from "../src/generate.mjs";
|
|
6
7
|
import { createProject } from "../src/new.mjs";
|
|
@@ -9,10 +10,10 @@ import { runSync } from "../src/sync.mjs";
|
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
|
|
11
12
|
function usage() {
|
|
12
|
-
console.error("Usage: tailframe validate [root]");
|
|
13
|
+
console.error("Usage: tailframe validate [root] [--app service|ui]");
|
|
13
14
|
console.error(" tailframe sync --check|--write [root]");
|
|
14
15
|
console.error(" tailframe new <name> [--path <parent>] [--db mongo|postgres] [--auth none|firebase] [--ui] [--redis] [--worker]");
|
|
15
|
-
console.error(" tailframe generate <schematic> <args...> [--options]");
|
|
16
|
+
console.error(" tailframe generate --app service|ui <schematic> <args...> [--options]");
|
|
16
17
|
console.error(" service schematics: module <name> <VerbNoun> [--no-http], use-case <module> <VerbNoun>,");
|
|
17
18
|
console.error(" http <module> <VerbNoun>, port <module> <Name>, adapter <module> <PortName> --db <technology>,");
|
|
18
19
|
console.error(" identifiers <module> <NameId...>, integration <provider>");
|
|
@@ -73,7 +74,15 @@ if (command === "new") {
|
|
|
73
74
|
}
|
|
74
75
|
if (command === "generate") {
|
|
75
76
|
try {
|
|
76
|
-
|
|
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);
|
|
77
86
|
console.log("Created:");
|
|
78
87
|
for (const file of created) console.log(` ${file}`);
|
|
79
88
|
console.log("Next steps:");
|
|
@@ -90,13 +99,16 @@ if (command === "generate") {
|
|
|
90
99
|
if (command !== "validate") usage();
|
|
91
100
|
|
|
92
101
|
let root = ".";
|
|
102
|
+
let selectedKind;
|
|
93
103
|
while (args.length) {
|
|
94
104
|
const value = args.shift();
|
|
95
|
-
if (value
|
|
105
|
+
if (value === "--app") selectedKind = args.shift();
|
|
106
|
+
else if (value.startsWith("-")) usage();
|
|
96
107
|
else root = value;
|
|
97
108
|
}
|
|
109
|
+
if (selectedKind && !APP_KINDS.has(selectedKind)) usage();
|
|
98
110
|
|
|
99
|
-
const errors = runConfiguredValidate(root, contractVersion);
|
|
111
|
+
const errors = runConfiguredValidate(root, contractVersion, selectedKind);
|
|
100
112
|
for (const error of errors) console.error(error);
|
|
101
113
|
if (errors.length) process.exit(1);
|
|
102
|
-
console.log(`Validated Tailframe contract ${contractVersion}: ${path.resolve(root)}`);
|
|
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.1",
|
|
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
|
@@ -122,7 +122,7 @@ function validateStructure(root, kind, fail) {
|
|
|
122
122
|
checkEntries("src/core", "directory", []);
|
|
123
123
|
} else {
|
|
124
124
|
checkEntries("src", "directory", ["app", "core", "modules", "platform"]);
|
|
125
|
-
checkEntries("src", "file",
|
|
125
|
+
checkEntries("src", "file", ["main.ts"]);
|
|
126
126
|
checkEntries("src/app", "directory", ["__tests__", "components", "public", "stores", "views"]);
|
|
127
127
|
checkEntries("src/core", "directory", []);
|
|
128
128
|
checkOptionalEntries("src/app/public", "directory", []);
|
|
@@ -228,7 +228,7 @@ function validateServiceImport(source, target, external, fail, graph) {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
function validateUiImport(source, target, external, fail, graph, kind) {
|
|
231
|
-
const bootstraps =
|
|
231
|
+
const bootstraps = ["src/main.ts"];
|
|
232
232
|
if (bootstraps.includes(source)) {
|
|
233
233
|
if (external || !target.startsWith("src/app/")) fail(`${source} may import only app bootstrap code, not ${external ?? target}`);
|
|
234
234
|
return;
|
|
@@ -299,7 +299,7 @@ export function validateArchitecture(rootArgument, kind) {
|
|
|
299
299
|
const root = path.resolve(rootArgument);
|
|
300
300
|
const errors = [];
|
|
301
301
|
const fail = (message) => errors.push(message);
|
|
302
|
-
if (!["service", "ui"
|
|
302
|
+
if (!["service", "ui"].includes(kind)) return [`Architecture kind must be service or ui, received ${kind ?? "nothing"}`];
|
|
303
303
|
if (!fs.existsSync(root)) return [`Architecture root does not exist: ${root}`];
|
|
304
304
|
validateStructure(root, kind, fail);
|
|
305
305
|
const src = path.join(root, "src");
|
package/src/config.mjs
CHANGED
|
@@ -2,8 +2,13 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
export const CONFIG_FILE = "tailframe.json";
|
|
5
|
-
export const CONFIG_SCHEMA_VERSION =
|
|
6
|
-
export const
|
|
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
|
+
});
|
|
7
12
|
export const PROFILES = new Set([
|
|
8
13
|
"firebase",
|
|
9
14
|
"mongo",
|
|
@@ -15,6 +20,29 @@ export const PROFILES = new Set([
|
|
|
15
20
|
"notifications"
|
|
16
21
|
]);
|
|
17
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
|
+
|
|
18
46
|
export function loadConfig(rootArgument) {
|
|
19
47
|
const root = path.resolve(rootArgument);
|
|
20
48
|
const file = path.join(root, CONFIG_FILE);
|
|
@@ -26,34 +54,40 @@ export function loadConfig(rootArgument) {
|
|
|
26
54
|
return { root, errors: [`Invalid ${CONFIG_FILE}: ${error instanceof Error ? error.message : error}`] };
|
|
27
55
|
}
|
|
28
56
|
const errors = [];
|
|
29
|
-
if (config.schemaVersion !== CONFIG_SCHEMA_VERSION) {
|
|
30
|
-
|
|
31
|
-
}
|
|
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`);
|
|
32
59
|
if (typeof config.contractVersion !== "string" || !/^\d+\.\d+\.\d+$/.test(config.contractVersion)) {
|
|
33
60
|
errors.push(`${CONFIG_FILE} contractVersion must be an exact semantic version`);
|
|
34
61
|
}
|
|
35
|
-
if (!
|
|
36
|
-
|
|
37
|
-
errors.push(`${CONFIG_FILE} profiles contain an unsupported value`);
|
|
38
|
-
} else if (new Set(config.profiles).size !== config.profiles.length) {
|
|
39
|
-
errors.push(`${CONFIG_FILE} profiles must not contain duplicates`);
|
|
40
|
-
}
|
|
41
|
-
const profiles = new Set(config.profiles ?? []);
|
|
42
|
-
if (config.kind === "service") {
|
|
43
|
-
if (profiles.has("mongo") === profiles.has("postgres")) {
|
|
44
|
-
errors.push("Service profiles must select exactly one of mongo or postgres");
|
|
45
|
-
}
|
|
46
|
-
if (profiles.has("worker") && !profiles.has("redis")) errors.push("The worker profile requires redis");
|
|
47
|
-
if (profiles.has("rate-limit") && !profiles.has("redis")) errors.push("The rate-limit profile requires redis");
|
|
48
|
-
if (profiles.has("notifications")) errors.push("The notifications profile is client-only");
|
|
62
|
+
if (!Array.isArray(config.apps) || config.apps.length === 0) {
|
|
63
|
+
errors.push(`${CONFIG_FILE} apps must be a non-empty array`);
|
|
49
64
|
} else {
|
|
50
|
-
|
|
51
|
-
|
|
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);
|
|
52
77
|
}
|
|
78
|
+
if (!seen.has("service")) errors.push(`${CONFIG_FILE} must declare exactly one service app`);
|
|
53
79
|
}
|
|
54
80
|
return { root, config, errors };
|
|
55
81
|
}
|
|
56
82
|
|
|
57
|
-
export function
|
|
58
|
-
return
|
|
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`;
|
|
59
93
|
}
|
package/src/conventions.mjs
CHANGED
|
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { toPosix, walk } from "./architecture.mjs";
|
|
4
4
|
import { authAppStoreSource, themeAppStoreSource, themeToggleSource } from "./generate.mjs";
|
|
5
|
-
import { extensionAuthStoreSource } from "./ui-templates.mjs";
|
|
6
5
|
|
|
7
6
|
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
8
7
|
const CAMEL = /^[a-z][A-Za-z0-9]*$/;
|
|
@@ -115,9 +114,7 @@ export function validateConventions(rootArgument, kind) {
|
|
|
115
114
|
}
|
|
116
115
|
if (kind === "ui") {
|
|
117
116
|
validateRouteNames(root, src, flag);
|
|
118
|
-
validateCanonicalShell(root, flag
|
|
119
|
-
} else if (kind === "extension") {
|
|
120
|
-
validateCanonicalShell(root, flag, kind);
|
|
117
|
+
validateCanonicalShell(root, flag);
|
|
121
118
|
} else if (kind === "service") {
|
|
122
119
|
validateServiceComposition(root, flag);
|
|
123
120
|
}
|
|
@@ -213,10 +210,10 @@ function normalizeGeneratedSource(source) {
|
|
|
213
210
|
return source.replace(/\r\n/g, "\n");
|
|
214
211
|
}
|
|
215
212
|
|
|
216
|
-
function validateCanonicalShell(root, flag
|
|
213
|
+
function validateCanonicalShell(root, flag) {
|
|
217
214
|
const authRelative = "src/app/stores/auth.store.ts";
|
|
218
215
|
const authFile = path.join(root, authRelative);
|
|
219
|
-
const expectedAuth =
|
|
216
|
+
const expectedAuth = authAppStoreSource();
|
|
220
217
|
if (fs.existsSync(authFile) && normalizeGeneratedSource(fs.readFileSync(authFile, "utf8")) !== expectedAuth) {
|
|
221
218
|
flag("U7", authRelative, `${authRelative} differs from the canonical Tailframe auth store; regenerate it instead of maintaining a project-local variant`);
|
|
222
219
|
}
|
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,5 +1,6 @@
|
|
|
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
6
|
import { moduleApiSource, notificationStoreSource } from "./ui-templates.mjs";
|
|
@@ -528,7 +529,7 @@ const uiSchematics = {
|
|
|
528
529
|
store(root, [moduleName, name]) {
|
|
529
530
|
requireUiModuleName(root, moduleName);
|
|
530
531
|
if (!name || !KEBAB.test(name)) fail(`Store name must be lowercase kebab-case, received "${name ?? ""}"`);
|
|
531
|
-
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`]];
|
|
532
533
|
},
|
|
533
534
|
composable(root, [moduleName, name]) {
|
|
534
535
|
requireUiModuleName(root, moduleName);
|
|
@@ -571,8 +572,13 @@ const uiSchematics = {
|
|
|
571
572
|
}
|
|
572
573
|
};
|
|
573
574
|
|
|
574
|
-
export function runGenerate(rootArgument, argv) {
|
|
575
|
-
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);
|
|
576
582
|
const positionals = [];
|
|
577
583
|
const options = new Map();
|
|
578
584
|
const flags = new Set();
|
|
@@ -586,11 +592,14 @@ export function runGenerate(rootArgument, argv) {
|
|
|
586
592
|
options.has = (key) => flags.has(key) || Map.prototype.has.call(options, key);
|
|
587
593
|
const schematic = positionals.shift();
|
|
588
594
|
const kind = detectKind(root);
|
|
595
|
+
if (kind !== selectedKind) {
|
|
596
|
+
fail(`Configured ${selectedKind} app at ${app.path} has ${kind} entry-point shape`);
|
|
597
|
+
}
|
|
589
598
|
const registry = kind === "service" ? serviceSchematics : uiSchematics;
|
|
590
599
|
if (!schematic || !registry[schematic]) {
|
|
591
600
|
fail(`Unknown ${kind} schematic "${schematic ?? ""}". Available: ${Object.keys(registry).join(", ")}`);
|
|
592
601
|
}
|
|
593
602
|
const [filePlan, checklist] = registry[schematic](root, positionals, options);
|
|
594
603
|
const created = filePlan.write(root);
|
|
595
|
-
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"] };
|
|
596
605
|
}
|