@zaaxch/tailframe 3.0.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 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
- const { created, checklist } = runGenerate(".", args);
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.startsWith("-")) usage();
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.0.0",
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": {
@@ -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", kind === "extension" ? ["background.ts", "content.ts"] : ["main.ts"]);
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 = kind === "extension" ? ["src/background.ts", "src/content.ts"] : ["src/main.ts"];
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", "extension"].includes(kind)) return [`Architecture kind must be service, ui, or extension, received ${kind ?? "nothing"}`];
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 = 1;
6
- export const KINDS = new Set(["service", "ui", "extension", "flutter"]);
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
- errors.push(`${CONFIG_FILE} schemaVersion must be ${CONFIG_SCHEMA_VERSION}`);
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 (!KINDS.has(config.kind)) errors.push(`${CONFIG_FILE} kind must be service, ui, extension, or flutter`);
36
- if (!Array.isArray(config.profiles) || config.profiles.some((profile) => !PROFILES.has(profile))) {
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
- for (const profile of ["mongo", "postgres", "redis", "worker", "rate-limit", "ui-host"]) {
51
- if (profiles.has(profile)) errors.push(`${profile} is a service-only profile`);
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 configSource({ kind, profiles, contractVersion }) {
58
- return `${JSON.stringify({ schemaVersion: CONFIG_SCHEMA_VERSION, contractVersion, kind, profiles }, null, "\t")}\n`;
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
  }
@@ -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, kind);
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, kind = "ui") {
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 = kind === "extension" ? extensionAuthStoreSource : authAppStoreSource();
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
  }
@@ -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
- const project = path.join(path.dirname(root), "AGENTS.md");
24
- if (fs.existsSync(project)) {
25
- for (const entry of parseExceptionSection(fs.readFileSync(project, "utf8"))) {
26
- if (entry === repositoryName || entry.startsWith(`${repositoryName}/`)) {
27
- covered.push(entry.slice(repositoryName.length + 1) || ".");
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; expose a deliberate facade with tailframe generate public ${moduleName} use${pascal(name)}Store only when another module needs it`]];
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 root = path.resolve(rootArgument);
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 npm run validate:architecture, then focused type checks and tests"] };
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
  }