stackloom-cli 1.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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/cli.js +306 -0
  4. package/branding.json +8 -0
  5. package/package.json +72 -0
  6. package/src/__tests__/cli-smoke.test.js +46 -0
  7. package/src/blueprint/__tests__/blueprint.test.js +116 -0
  8. package/src/blueprint/blueprint.js +181 -0
  9. package/src/blueprint/default.blueprint.json +78 -0
  10. package/src/blueprint/index.js +10 -0
  11. package/src/blueprint/loader.js +101 -0
  12. package/src/blueprint/schema-kit.js +161 -0
  13. package/src/blueprint/schema.js +78 -0
  14. package/src/branding/__tests__/branding.test.js +49 -0
  15. package/src/branding/index.js +48 -0
  16. package/src/commands/__tests__/commands.test.js +83 -0
  17. package/src/commands/check.js +71 -0
  18. package/src/commands/cleanup.js +347 -0
  19. package/src/commands/customize.js +263 -0
  20. package/src/commands/doctor.js +84 -0
  21. package/src/commands/env.js +75 -0
  22. package/src/commands/finalize.js +68 -0
  23. package/src/commands/generate/ci-cd.js +378 -0
  24. package/src/commands/generate/deploy-advanced.js +253 -0
  25. package/src/commands/generate/deploy.js +99 -0
  26. package/src/commands/generate/env.template.js +221 -0
  27. package/src/commands/generate/index.js +7 -0
  28. package/src/commands/generate/module.js +836 -0
  29. package/src/commands/generate/page.js +1415 -0
  30. package/src/commands/generate/test-scaffold.js +279 -0
  31. package/src/commands/generate/theme.js +67 -0
  32. package/src/commands/generate-resource.js +133 -0
  33. package/src/commands/index.js +9 -0
  34. package/src/commands/init.js +350 -0
  35. package/src/commands/make/resource.js +298 -0
  36. package/src/commands/preset.js +57 -0
  37. package/src/commands/remove.js +170 -0
  38. package/src/commands/rename.js +54 -0
  39. package/src/commands/rollback.js +90 -0
  40. package/src/commands/wizard.js +303 -0
  41. package/src/core/__tests__/generator.test.js +67 -0
  42. package/src/core/__tests__/marker-strategy.test.js +57 -0
  43. package/src/core/__tests__/resource-definition.test.js +32 -0
  44. package/src/core/generator.js +542 -0
  45. package/src/core/marker-strategy.js +138 -0
  46. package/src/core/resource-definition.js +346 -0
  47. package/src/core/state-tracker.js +67 -0
  48. package/src/core/template-loader.js +163 -0
  49. package/src/engine/__tests__/engine.test.js +306 -0
  50. package/src/engine/index.js +21 -0
  51. package/src/engine/injector.js +198 -0
  52. package/src/engine/pipeline.js +138 -0
  53. package/src/engine/transaction.js +105 -0
  54. package/src/engine/validator.js +190 -0
  55. package/src/index.js +4 -0
  56. package/src/recipes/__tests__/recipe.test.js +128 -0
  57. package/src/recipes/builtin/module.json +22 -0
  58. package/src/recipes/builtin/page.json +21 -0
  59. package/src/recipes/builtin/resource.json +35 -0
  60. package/src/recipes/condition.js +147 -0
  61. package/src/recipes/index.js +11 -0
  62. package/src/recipes/loader.js +95 -0
  63. package/src/recipes/recipe.js +89 -0
  64. package/src/recipes/schema.js +47 -0
  65. package/src/schemas/__tests__/schemas.test.js +67 -0
  66. package/src/schemas/index.js +18 -0
  67. package/src/schemas/options.js +38 -0
  68. package/src/schemas/resource.js +112 -0
  69. package/src/services/__tests__/reporter.test.js +98 -0
  70. package/src/services/clock.js +31 -0
  71. package/src/services/index.js +43 -0
  72. package/src/services/reporter.js +136 -0
  73. package/src/templates/resource/api.js.ejs +39 -0
  74. package/src/templates/resource/components/form.jsx.ejs +81 -0
  75. package/src/templates/resource/components/table.jsx.ejs +68 -0
  76. package/src/templates/resource/controller.js.ejs +154 -0
  77. package/src/templates/resource/hooks.js.ejs +46 -0
  78. package/src/templates/resource/model.js.ejs +64 -0
  79. package/src/templates/resource/page-detail.jsx.ejs +55 -0
  80. package/src/templates/resource/page-form.jsx.ejs +30 -0
  81. package/src/templates/resource/page-inline.jsx.ejs +74 -0
  82. package/src/templates/resource/page-modal.jsx.ejs +98 -0
  83. package/src/templates/resource/page-page.jsx.ejs +99 -0
  84. package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
  85. package/src/templates/resource/routes.js.ejs +35 -0
  86. package/src/templates/resource/service.js.ejs +132 -0
  87. package/src/templates/resource/test.ejs +71 -0
  88. package/src/templates/resource/types.ts.ejs +17 -0
  89. package/src/templates/resource/validator.js.ejs +26 -0
  90. package/src/templates/snippets/lazy-import.ejs +1 -0
  91. package/src/templates/snippets/nav-entry.ejs +1 -0
  92. package/src/templates/snippets/route-entry.ejs +5 -0
  93. package/src/templates/snippets/route-mount.ejs +1 -0
  94. package/src/utils/fieldValidators.js +371 -0
  95. package/src/utils/logging/logger.js +47 -0
  96. package/src/utils/namingUtils.js +38 -0
  97. package/src/utils/sanitize.js +200 -0
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ mkdirSync,
4
+ writeFileSync,
5
+ readFileSync,
6
+ rmSync,
7
+ existsSync,
8
+ } from "node:fs";
9
+ import path from "node:path";
10
+ import os from "node:os";
11
+ import { Blueprint, BlueprintLoader, BlueprintLoadError, blueprintLoader } from "../index.js";
12
+
13
+ const tmp = (label) =>
14
+ path.join(os.tmpdir(), `${label}-${Math.random().toString(36).slice(2)}`);
15
+ const writeJSON = (file, obj) => {
16
+ mkdirSync(path.dirname(file), { recursive: true });
17
+ writeFileSync(file, JSON.stringify(obj, null, 2));
18
+ };
19
+ const readJSON = (file) => JSON.parse(readFileSync(file, "utf-8"));
20
+
21
+ describe("BlueprintLoader", () => {
22
+ it("falls back to the built-in MERN blueprint when a project has none", async () => {
23
+ const bp = await blueprintLoader.load(os.tmpdir());
24
+ expect(bp).toBeInstanceOf(Blueprint);
25
+ expect(bp.id).toBe("mern");
26
+ expect(bp.schemaVersion).toBe("1.0");
27
+ expect(bp.source).toBe(blueprintLoader.builtinPath);
28
+ });
29
+
30
+ it("rejects a blueprint that fails schema validation", async () => {
31
+ const file = `${tmp("bp-bad")}.json`;
32
+ writeJSON(file, { schemaVersion: "1.0", architecture: { id: "x" } });
33
+ await expect(blueprintLoader.loadFile(file)).rejects.toThrow(BlueprintLoadError);
34
+ rmSync(file, { force: true });
35
+ });
36
+
37
+ it("rejects an unsupported schemaVersion", async () => {
38
+ const file = `${tmp("bp-ver")}.json`;
39
+ writeJSON(file, { ...readJSON(blueprintLoader.builtinPath), schemaVersion: "99.0" });
40
+ await expect(blueprintLoader.loadFile(file)).rejects.toThrow(/schemaVersion/);
41
+ rmSync(file, { force: true });
42
+ });
43
+
44
+ it("prefers a project-local blueprint over the built-in", async () => {
45
+ const projectRoot = tmp("bp-proj");
46
+ const builtin = readJSON(blueprintLoader.builtinPath);
47
+ writeJSON(path.join(projectRoot, ".loom", "blueprint.json"), {
48
+ ...builtin,
49
+ architecture: { ...builtin.architecture, id: "custom" },
50
+ });
51
+
52
+ const bp = await new BlueprintLoader().load(projectRoot);
53
+ expect(bp.id).toBe("custom");
54
+ rmSync(projectRoot, { recursive: true, force: true });
55
+ });
56
+ });
57
+
58
+ describe("Blueprint", () => {
59
+ let projectRoot;
60
+ let bp;
61
+
62
+ beforeEach(async () => {
63
+ projectRoot = tmp("bp-test");
64
+ writeJSON(path.join(projectRoot, "backend", "package.json"), { name: "backend" });
65
+ mkdirSync(path.join(projectRoot, "frontend", "src"), { recursive: true });
66
+ writeFileSync(path.join(projectRoot, "frontend", "src", "main.jsx"), "");
67
+ bp = await blueprintLoader.load(projectRoot);
68
+ });
69
+
70
+ afterEach(() => {
71
+ rmSync(projectRoot, { recursive: true, force: true });
72
+ });
73
+
74
+ it("resolves named roots via detect + marker", () => {
75
+ expect(bp.resolveRoot("backend", projectRoot)).toBe("backend");
76
+ expect(bp.resolveRoot("frontend", projectRoot)).toBe("frontend");
77
+ });
78
+
79
+ it("falls back to a root's default when no candidate matches", () => {
80
+ expect(bp.resolveRoot("frontend", tmp("bp-bare"))).toBe("frontend");
81
+ });
82
+
83
+ it("throws for an unknown root", () => {
84
+ expect(() => bp.resolveRoot("database", projectRoot)).toThrow(/no root named "database"/);
85
+ });
86
+
87
+ it("expands root tokens in path templates", () => {
88
+ expect(bp.resolvePath("backend.modules", projectRoot)).toBe(
89
+ path.join(projectRoot, "backend", "src", "modules"),
90
+ );
91
+ });
92
+
93
+ it("substitutes extra vars into path templates", () => {
94
+ expect(bp.expand("{backend}/src/modules/{kebab}", projectRoot, { kebab: "order" })).toBe(
95
+ "backend/src/modules/order",
96
+ );
97
+ });
98
+
99
+ it("throws for an unknown token", () => {
100
+ expect(() => bp.expand("{frontend}/{mystery}", projectRoot)).toThrow(/Unknown token/);
101
+ });
102
+
103
+ it("looks up injection anchors", () => {
104
+ expect(bp.hasAnchor("backend.routes")).toBe(true);
105
+ expect(bp.getAnchor("backend.routes").strategy).toBe("before-line");
106
+ expect(bp.resolveAnchorFile("backend.routes", projectRoot)).toBe(
107
+ path.join(projectRoot, "backend", "src", "routes", "index.js"),
108
+ );
109
+ });
110
+
111
+ it("detects language per the blueprint convention", () => {
112
+ expect(bp.usesTypeScript(projectRoot)).toBe(false);
113
+ writeFileSync(path.join(projectRoot, "frontend", "tsconfig.json"), "{}");
114
+ expect(new Blueprint(bp.data, bp.source).usesTypeScript(projectRoot)).toBe(true);
115
+ });
116
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Blueprint — a validated architecture contract with behavior.
3
+ *
4
+ * Wraps the parsed `.loom/blueprint.json` data and exposes the operations the
5
+ * generation engine needs: resolving named roots/paths against a concrete
6
+ * project, looking up injection anchors, and detecting the project language.
7
+ *
8
+ * The engine talks only to this object — never to hardcoded "backend"/"frontend"
9
+ * strings — which is what lets a single engine serve many architectures.
10
+ */
11
+ import { existsSync } from "node:fs";
12
+ import path from "node:path";
13
+
14
+ export class Blueprint {
15
+ /**
16
+ * @param {import('./schema.js').BlueprintData} data - validated blueprint data
17
+ * @param {string} source - absolute path the blueprint was loaded from
18
+ */
19
+ constructor(data, source = "unknown") {
20
+ this.data = data;
21
+ this.source = source;
22
+ this._rootCache = new Map();
23
+ }
24
+
25
+ get schemaVersion() {
26
+ return this.data.schemaVersion;
27
+ }
28
+
29
+ get architecture() {
30
+ return this.data.architecture;
31
+ }
32
+
33
+ /** Short architecture id, e.g. "mern". */
34
+ get id() {
35
+ return this.data.architecture.id;
36
+ }
37
+
38
+ get conventions() {
39
+ return this.data.conventions;
40
+ }
41
+
42
+ /**
43
+ * Resolve a named root directory (e.g. "backend") to its concrete folder name
44
+ * within `projectRoot`. Walks `detect` candidates, confirms each with the
45
+ * root's `marker` file, and falls back to `default`.
46
+ * @returns {string} the resolved directory name (relative to projectRoot)
47
+ */
48
+ resolveRoot(name, projectRoot) {
49
+ const cacheKey = `${projectRoot}::${name}`;
50
+ if (this._rootCache.has(cacheKey)) return this._rootCache.get(cacheKey);
51
+
52
+ const root = this.data.roots[name];
53
+ if (!root) {
54
+ throw new Error(
55
+ `Blueprint "${this.id}" has no root named "${name}". ` +
56
+ `Known roots: ${Object.keys(this.data.roots).join(", ") || "(none)"}.`,
57
+ );
58
+ }
59
+
60
+ let resolved = root.default;
61
+ for (const candidate of root.detect) {
62
+ if (existsSync(path.join(projectRoot, candidate, root.marker))) {
63
+ resolved = candidate;
64
+ break;
65
+ }
66
+ }
67
+
68
+ this._rootCache.set(cacheKey, resolved);
69
+ return resolved;
70
+ }
71
+
72
+ /**
73
+ * Expand `{token}` placeholders in a template string. A token resolves from
74
+ * `vars` first, then from the blueprint's named roots.
75
+ */
76
+ expand(template, projectRoot, vars = {}) {
77
+ return template.replace(/\{([\w.]+)\}/g, (_match, token) => {
78
+ if (Object.prototype.hasOwnProperty.call(vars, token)) return vars[token];
79
+ if (this.data.roots[token]) return this.resolveRoot(token, projectRoot);
80
+ throw new Error(
81
+ `Unknown token "{${token}}" in blueprint template "${template}". ` +
82
+ `Provide it via vars or declare a root named "${token}".`,
83
+ );
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Render a free-form template that may reference named blueprint paths as
89
+ * `{@path.name}`, directory roots as `{root}`, and caller-supplied `{vars}`.
90
+ * Returns a project-relative string (recipes join it onto the project root).
91
+ *
92
+ * Lets recipes say `{@backend.modules}/{kebab}/{Name}.js` instead of
93
+ * re-spelling `src/modules` — the blueprint stays the single source of truth.
94
+ */
95
+ renderTemplate(template, projectRoot, vars = {}) {
96
+ const withPaths = template.replace(/\{@([\w.-]+)\}/g, (_match, name) => {
97
+ const pathTemplate = this.data.paths[name];
98
+ if (!pathTemplate) {
99
+ throw new Error(
100
+ `Unknown blueprint path "@${name}" in template "${template}". ` +
101
+ `Known paths: ${Object.keys(this.data.paths).join(", ") || "(none)"}.`,
102
+ );
103
+ }
104
+ return pathTemplate;
105
+ });
106
+ return this.expand(withPaths, projectRoot, vars);
107
+ }
108
+
109
+ /**
110
+ * Resolve a named path template (e.g. "backend.modules") to an absolute path
111
+ * within `projectRoot`. Extra `vars` are substituted into `{token}` slots.
112
+ */
113
+ resolvePath(name, projectRoot, vars = {}) {
114
+ const template = this.data.paths[name];
115
+ if (!template) {
116
+ throw new Error(
117
+ `Blueprint "${this.id}" has no path named "${name}". ` +
118
+ `Known paths: ${Object.keys(this.data.paths).join(", ") || "(none)"}.`,
119
+ );
120
+ }
121
+ return path.join(projectRoot, this.expand(template, projectRoot, vars));
122
+ }
123
+
124
+ hasPath(name) {
125
+ return Boolean(this.data.paths[name]);
126
+ }
127
+
128
+ /** Look up a named injection anchor; throws if it is not declared. */
129
+ getAnchor(name) {
130
+ const anchor = this.data.anchors[name];
131
+ if (!anchor) {
132
+ throw new Error(
133
+ `Blueprint "${this.id}" has no anchor named "${name}". ` +
134
+ `Known anchors: ${Object.keys(this.data.anchors).join(", ") || "(none)"}.`,
135
+ );
136
+ }
137
+ return anchor;
138
+ }
139
+
140
+ hasAnchor(name) {
141
+ return Boolean(this.data.anchors[name]);
142
+ }
143
+
144
+ /** Resolve a named anchor's target file to an absolute path. */
145
+ resolveAnchorFile(name, projectRoot, vars = {}) {
146
+ return path.join(projectRoot, this.expand(this.getAnchor(name).file, projectRoot, vars));
147
+ }
148
+
149
+ /** Path to a named recipe manifest (relative to project root), or null. */
150
+ getRecipe(name) {
151
+ return this.data.recipes[name] || null;
152
+ }
153
+
154
+ /**
155
+ * Detect the language a project is written in, per the blueprint's
156
+ * `conventions.language` policy. Looks for a `tsconfig.json` in any root.
157
+ * @returns {"javascript"|"typescript"}
158
+ */
159
+ detectLanguage(projectRoot) {
160
+ const { detect, default: fallback } = this.data.conventions.language;
161
+ if (detect === "typescript") {
162
+ for (const name of Object.keys(this.data.roots)) {
163
+ const rootDir = this.resolveRoot(name, projectRoot);
164
+ if (existsSync(path.join(projectRoot, rootDir, "tsconfig.json"))) {
165
+ return "typescript";
166
+ }
167
+ }
168
+ }
169
+ return fallback;
170
+ }
171
+
172
+ /** True when the project resolved to TypeScript. */
173
+ usesTypeScript(projectRoot) {
174
+ return this.detectLanguage(projectRoot) === "typescript";
175
+ }
176
+
177
+ /** A compact, log-friendly summary. */
178
+ describe() {
179
+ return `${this.architecture.name} (id: ${this.id}, schema: ${this.schemaVersion}) from ${this.source}`;
180
+ }
181
+ }
@@ -0,0 +1,78 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "architecture": {
4
+ "id": "mern",
5
+ "name": "MERN Fullstack Starter",
6
+ "description": "Express + MongoDB API with httpOnly refresh-token auth, React + Vite + Tailwind frontend."
7
+ },
8
+ "engine": {
9
+ "minCliVersion": "0.1.0"
10
+ },
11
+ "roots": {
12
+ "backend": {
13
+ "detect": ["backend", "api", "server", "be"],
14
+ "marker": "package.json",
15
+ "default": "backend"
16
+ },
17
+ "frontend": {
18
+ "detect": ["frontend", "client", "web", "app"],
19
+ "marker": "src/main.jsx",
20
+ "default": "frontend"
21
+ }
22
+ },
23
+ "conventions": {
24
+ "naming": {
25
+ "module": "kebab",
26
+ "component": "pascal",
27
+ "route": "kebab"
28
+ },
29
+ "language": {
30
+ "detect": "typescript",
31
+ "default": "javascript"
32
+ }
33
+ },
34
+ "paths": {
35
+ "backend.modules": "{backend}/src/modules",
36
+ "backend.routesIndex": "{backend}/src/routes/index.js",
37
+ "backend.validators": "{backend}/src/utils/validators",
38
+ "frontend.pages": "{frontend}/src/pages",
39
+ "frontend.adminPages": "{frontend}/src/pages/admin",
40
+ "frontend.components": "{frontend}/src/components",
41
+ "frontend.api": "{frontend}/src/api",
42
+ "frontend.hooks": "{frontend}/src/hooks",
43
+ "frontend.router": "{frontend}/src/routes/AppRouter.jsx",
44
+ "frontend.navConfig": "{frontend}/src/config/app-preset.js"
45
+ },
46
+ "anchors": {
47
+ "backend.routes": {
48
+ "file": "{backend}/src/routes/index.js",
49
+ "strategy": "before-line",
50
+ "pattern": "module.exports = router;",
51
+ "comment": "loom:anchor backend.routes"
52
+ },
53
+ "frontend.lazyImports": {
54
+ "file": "{frontend}/src/routes/AppRouter.jsx",
55
+ "strategy": "after-last-match",
56
+ "pattern": "^const \\w+ = lazy\\(.*\\);",
57
+ "comment": "loom:anchor frontend.lazyImports"
58
+ },
59
+ "frontend.routes": {
60
+ "file": "{frontend}/src/routes/AppRouter.jsx",
61
+ "strategy": "before-match",
62
+ "pattern": "<Route\\s+path=\"\\*\"",
63
+ "comment": "loom:anchor frontend.routes"
64
+ },
65
+ "frontend.nav": {
66
+ "file": "{frontend}/src/config/app-preset.js",
67
+ "strategy": "array-append",
68
+ "pattern": "navigation\\s*:\\s*\\[",
69
+ "comment": "loom:anchor frontend.nav"
70
+ }
71
+ },
72
+ "recipes": {},
73
+ "presets": {
74
+ "themes": "{frontend}/src/config/design-themes.js",
75
+ "layouts": "{frontend}/src/config/design-layouts.js",
76
+ "dataDisplay": "{frontend}/src/config/data-display-templates.js"
77
+ }
78
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Blueprint subsystem — the architecture contract layer.
3
+ *
4
+ * A blueprint describes *where things go* for a given stack so the generation
5
+ * engine stays architecture-agnostic. Adding support for a new architecture is
6
+ * a new `blueprint.json`, not an engine change.
7
+ */
8
+ export { Blueprint } from "./blueprint.js";
9
+ export { BlueprintLoader, BlueprintLoadError, blueprintLoader } from "./loader.js";
10
+ export { blueprintSchema, SUPPORTED_SCHEMA_VERSIONS } from "./schema.js";
@@ -0,0 +1,101 @@
1
+ /**
2
+ * BlueprintLoader — resolves, reads, and validates a blueprint for a project.
3
+ *
4
+ * Resolution is three-tier, highest priority first:
5
+ * 1. <project>/.loom/blueprint.json — the template's own contract
6
+ * 2. ~/.loom/blueprint.json — a user-global override
7
+ * 3. <cli>/default.blueprint.json — the shipped MERN fallback
8
+ *
9
+ * Every blueprint is schema-validated before use, so a malformed manifest fails
10
+ * fast with a path-pointed error instead of producing broken code downstream.
11
+ */
12
+ import { existsSync } from "node:fs";
13
+ import { readFile } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import os from "node:os";
16
+ import { fileURLToPath } from "node:url";
17
+ import { blueprintSchema, SUPPORTED_SCHEMA_VERSIONS } from "./schema.js";
18
+ import { Blueprint } from "./blueprint.js";
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const HOME = os.homedir();
22
+ const BUILTIN = path.join(__dirname, "default.blueprint.json");
23
+
24
+ /** Raised when a blueprint cannot be read, fails schema validation, or is incompatible. */
25
+ export class BlueprintLoadError extends Error {
26
+ constructor(message, { source, issues } = {}) {
27
+ super(message);
28
+ this.name = "BlueprintLoadError";
29
+ this.source = source;
30
+ this.issues = issues;
31
+ }
32
+ }
33
+
34
+ export class BlueprintLoader {
35
+ /** Candidate blueprint locations for a project, highest priority first. */
36
+ locations(projectRoot) {
37
+ return [
38
+ path.join(projectRoot, ".loom", "blueprint.json"),
39
+ path.join(HOME, ".loom", "blueprint.json"),
40
+ BUILTIN,
41
+ ];
42
+ }
43
+
44
+ /** Absolute path to the CLI's built-in fallback blueprint. */
45
+ get builtinPath() {
46
+ return BUILTIN;
47
+ }
48
+
49
+ /** First existing blueprint file for a project. Built-in is the guaranteed floor. */
50
+ resolve(projectRoot = process.cwd()) {
51
+ for (const loc of this.locations(projectRoot)) {
52
+ if (existsSync(loc)) return loc;
53
+ }
54
+ throw new BlueprintLoadError(
55
+ `No blueprint found and the built-in default is missing (expected at ${BUILTIN}).`,
56
+ );
57
+ }
58
+
59
+ /** Load + validate the effective blueprint for a project. */
60
+ async load(projectRoot = process.cwd()) {
61
+ return this.loadFile(this.resolve(projectRoot));
62
+ }
63
+
64
+ /** Load + validate a blueprint from an explicit file path. */
65
+ async loadFile(source) {
66
+ let raw;
67
+ try {
68
+ raw = JSON.parse(await readFile(source, "utf-8"));
69
+ } catch (err) {
70
+ throw new BlueprintLoadError(
71
+ `Could not read blueprint JSON at ${source}: ${err.message}`,
72
+ { source },
73
+ );
74
+ }
75
+
76
+ const parsed = blueprintSchema.safeParse(raw);
77
+ if (!parsed.success) {
78
+ const issues = parsed.error.issues.map(
79
+ (i) => ` • ${i.path.join(".") || "(root)"}: ${i.message}`,
80
+ );
81
+ throw new BlueprintLoadError(
82
+ `Invalid blueprint at ${source}:\n${issues.join("\n")}`,
83
+ { source, issues: parsed.error.issues },
84
+ );
85
+ }
86
+
87
+ if (!SUPPORTED_SCHEMA_VERSIONS.includes(parsed.data.schemaVersion)) {
88
+ throw new BlueprintLoadError(
89
+ `Blueprint at ${source} declares schemaVersion "${parsed.data.schemaVersion}", ` +
90
+ `but this CLI supports: ${SUPPORTED_SCHEMA_VERSIONS.join(", ")}. ` +
91
+ `Upgrade the CLI or the project's blueprint.`,
92
+ { source },
93
+ );
94
+ }
95
+
96
+ return new Blueprint(parsed.data, source);
97
+ }
98
+ }
99
+
100
+ /** Shared loader instance for convenience. */
101
+ export const blueprintLoader = new BlueprintLoader();
@@ -0,0 +1,161 @@
1
+ /**
2
+ * schema-kit — a tiny, dependency-free schema validator.
3
+ *
4
+ * Scoped deliberately to what the CLI needs (objects, strings, enums, arrays,
5
+ * string-keyed records, optionals, defaults, path-pointed errors). The public
6
+ * surface — `.optional()`, `.default()`, `.safeParse()` returning
7
+ * `{ success, data, error: { issues } }` — mirrors zod, so a future swap to a
8
+ * heavier validation library is mechanical rather than invasive.
9
+ */
10
+
11
+ const clone = (value) =>
12
+ value === undefined ? undefined : JSON.parse(JSON.stringify(value));
13
+
14
+ const typeOf = (value) =>
15
+ value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
16
+
17
+ class Schema {
18
+ /**
19
+ * @param {(value:any, path:Array<string|number>, ctx:{issues:Array}) => any} check
20
+ * Validates/coerces a *present* value, pushing `{ path, message }` issues.
21
+ */
22
+ constructor(check) {
23
+ this._check = check;
24
+ this._isOptional = false;
25
+ this._hasDefault = false;
26
+ this._default = undefined;
27
+ }
28
+
29
+ optional() {
30
+ const next = this._copy();
31
+ next._isOptional = true;
32
+ return next;
33
+ }
34
+
35
+ default(value) {
36
+ const next = this._copy();
37
+ next._hasDefault = true;
38
+ next._default = value;
39
+ return next;
40
+ }
41
+
42
+ _copy() {
43
+ const next = new Schema(this._check);
44
+ next._isOptional = this._isOptional;
45
+ next._hasDefault = this._hasDefault;
46
+ next._default = this._default;
47
+ return next;
48
+ }
49
+
50
+ /** Resolve a value, applying default/optional rules before delegating to `_check`. */
51
+ _resolve(value, path, ctx) {
52
+ if (value === undefined) {
53
+ if (this._hasDefault) return this._check(clone(this._default), path, ctx);
54
+ if (this._isOptional) return undefined;
55
+ ctx.issues.push({ path: [...path], message: "Required" });
56
+ return undefined;
57
+ }
58
+ return this._check(value, path, ctx);
59
+ }
60
+
61
+ /** zod-shaped entry point: never throws, returns a discriminated result. */
62
+ safeParse(value) {
63
+ const ctx = { issues: [] };
64
+ const data = this._resolve(value, [], ctx);
65
+ if (ctx.issues.length) return { success: false, error: { issues: ctx.issues } };
66
+ return { success: true, data };
67
+ }
68
+ }
69
+
70
+ export function string() {
71
+ return new Schema((value, path, ctx) => {
72
+ if (typeof value !== "string") {
73
+ ctx.issues.push({ path: [...path], message: `Expected string, got ${typeOf(value)}` });
74
+ }
75
+ return value;
76
+ });
77
+ }
78
+
79
+ export function boolean() {
80
+ return new Schema((value, path, ctx) => {
81
+ if (typeof value !== "boolean") {
82
+ ctx.issues.push({ path: [...path], message: `Expected boolean, got ${typeOf(value)}` });
83
+ }
84
+ return value;
85
+ });
86
+ }
87
+
88
+ export function number() {
89
+ return new Schema((value, path, ctx) => {
90
+ if (typeof value !== "number" || Number.isNaN(value)) {
91
+ ctx.issues.push({ path: [...path], message: `Expected number, got ${typeOf(value)}` });
92
+ }
93
+ return value;
94
+ });
95
+ }
96
+
97
+ /** Accepts any present value as-is. Use sparingly — only where the shape is genuinely open. */
98
+ export function any() {
99
+ return new Schema((value) => value);
100
+ }
101
+
102
+ export function enumOf(...values) {
103
+ return new Schema((value, path, ctx) => {
104
+ if (!values.includes(value)) {
105
+ ctx.issues.push({
106
+ path: [...path],
107
+ message: `Expected one of ${values.map((v) => `"${v}"`).join(", ")}, got ${JSON.stringify(value)}`,
108
+ });
109
+ }
110
+ return value;
111
+ });
112
+ }
113
+
114
+ export function arrayOf(item, { min = 0 } = {}) {
115
+ return new Schema((value, path, ctx) => {
116
+ if (!Array.isArray(value)) {
117
+ ctx.issues.push({ path: [...path], message: `Expected array, got ${typeOf(value)}` });
118
+ return value;
119
+ }
120
+ if (value.length < min) {
121
+ ctx.issues.push({
122
+ path: [...path],
123
+ message: `Expected at least ${min} item(s), got ${value.length}`,
124
+ });
125
+ }
126
+ return value.map((entry, i) => item._resolve(entry, [...path, i], ctx));
127
+ });
128
+ }
129
+
130
+ /** A string-keyed map where every value matches `valueSchema`. */
131
+ export function record(valueSchema) {
132
+ return new Schema((value, path, ctx) => {
133
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
134
+ ctx.issues.push({ path: [...path], message: `Expected object, got ${typeOf(value)}` });
135
+ return value;
136
+ }
137
+ const out = {};
138
+ for (const [key, entry] of Object.entries(value)) {
139
+ out[key] = valueSchema._resolve(entry, [...path, key], ctx);
140
+ }
141
+ return out;
142
+ });
143
+ }
144
+
145
+ /** A fixed-shape object. Unknown keys are dropped; missing keys defer to each field's rules. */
146
+ export function object(shape) {
147
+ return new Schema((value, path, ctx) => {
148
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
149
+ ctx.issues.push({ path: [...path], message: `Expected object, got ${typeOf(value)}` });
150
+ return value;
151
+ }
152
+ const out = {};
153
+ for (const [key, fieldSchema] of Object.entries(shape)) {
154
+ const resolved = fieldSchema._resolve(value[key], [...path, key], ctx);
155
+ if (resolved !== undefined) out[key] = resolved;
156
+ }
157
+ return out;
158
+ });
159
+ }
160
+
161
+ export { Schema };