@cosmicdrift/kumiko-cli 0.3.32 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-cli",
3
- "version": "0.3.32",
3
+ "version": "0.4.3",
4
4
  "description": "CLI for Kumiko apps. `bunx @cosmicdrift/kumiko-cli new app <name>` scaffolds a runnable workspace; `add feature <name>` mounts a feature; `agent lint`, `project` and `consumer` operate on an existing app.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,12 +28,17 @@
28
28
  "./commands": {
29
29
  "types": "./src/commands/index.ts",
30
30
  "default": "./src/commands/index.ts"
31
+ },
32
+ "./repo-manifest": {
33
+ "types": "./src/repo-manifest.ts",
34
+ "default": "./src/repo-manifest.ts"
31
35
  }
32
36
  },
33
37
  "dependencies": {
34
- "@cosmicdrift/kumiko-bundled-features": "0.270.0",
35
- "@cosmicdrift/kumiko-framework": "0.270.0",
36
- "@cosmicdrift/kumiko-dev-server": "0.270.0"
38
+ "@cosmicdrift/kumiko-bundled-features": "0.273.0",
39
+ "@cosmicdrift/kumiko-framework": "0.273.0",
40
+ "@cosmicdrift/kumiko-dev-server": "0.273.0",
41
+ "zod": "^4.4.3"
37
42
  },
38
43
  "publishConfig": {
39
44
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.4.0",
4
+ "type": "improvement",
5
+ "title": "`@cosmicdrift/kumiko-cli/repo-manifest`: kumiko.json repo manifest schema and loader (fw#2856).",
6
+ "detail": "repoManifestSchema declares kind (a role — framework|library|app, not a layout description) plus repo-relative sourceRoots/testGlobs/uiRoots/excludes patterns; loadRepoManifest reads and validates a repo's kumiko.json, rejecting absolute paths, \"..\" traversal, and symlinks that resolve outside the repo root, and is strict against unknown fields (typos fail loudly instead of being silently ignored). Without a kumiko.json it falls back to deriving a layout from packages/*/src or src/ and warns once via the injected warn option."
7
+ }
8
+ ]
@@ -0,0 +1,226 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { z } from "zod";
4
+
5
+ export const REPO_MANIFEST_FILE = "kumiko.json";
6
+
7
+ export const repoKindSchema = z.enum(["framework", "library", "app"]);
8
+ export type RepoKind = z.infer<typeof repoKindSchema>;
9
+
10
+ const DRIVE_LETTER_RE = /^[A-Za-z]:/;
11
+
12
+ function isBlank(value: string): boolean {
13
+ return value.trim().length === 0;
14
+ }
15
+
16
+ function patternViolation(value: string): string | undefined {
17
+ if (isBlank(value)) return `must not be empty: "${value}"`;
18
+ if (value.includes("\0")) return `must not contain a NUL character: "${value}"`;
19
+ if (value.startsWith("/") || DRIVE_LETTER_RE.test(value)) {
20
+ return `must be relative to the repo root: "${value}"`;
21
+ }
22
+ if (value.includes("\\")) return `must use forward slashes, not backslashes: "${value}"`;
23
+ if (value.startsWith("!")) return `must not start with "!": "${value}"`;
24
+ if (value.split("/").some((segment) => segment === "..")) {
25
+ return `must not traverse outside the repo root: "${value}"`;
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ const pattern = z.string().superRefine((value, ctx) => {
31
+ const violation = patternViolation(value);
32
+ if (violation !== undefined) {
33
+ ctx.addIssue({ code: "custom", message: violation });
34
+ }
35
+ });
36
+
37
+ export const repoManifestSchema = z.strictObject({
38
+ kind: repoKindSchema,
39
+ sourceRoots: z.array(pattern).min(1),
40
+ testGlobs: z.array(pattern).min(1),
41
+ uiRoots: z.array(pattern).optional(),
42
+ excludes: z.array(pattern).optional(),
43
+ });
44
+ export type RepoManifest = z.infer<typeof repoManifestSchema>;
45
+
46
+ export type RepoManifestSource = "file" | "derived";
47
+
48
+ export type LoadedRepoManifest = {
49
+ readonly manifest: RepoManifest;
50
+ readonly source: RepoManifestSource;
51
+ readonly manifestPath: string;
52
+ };
53
+
54
+ export class RepoManifestError extends Error {
55
+ readonly manifestPath: string;
56
+
57
+ constructor(manifestPath: string, message: string) {
58
+ super(`${manifestPath}: ${message}`);
59
+ this.name = "RepoManifestError";
60
+ this.manifestPath = manifestPath;
61
+ }
62
+ }
63
+
64
+ export type LoadRepoManifestOptions = {
65
+ readonly warn?: (message: string) => void;
66
+ };
67
+
68
+ function isDirectory(path: string): boolean {
69
+ return existsSync(path) && statSync(path).isDirectory();
70
+ }
71
+
72
+ function isWithinRoot(candidateReal: string, rootReal: string): boolean {
73
+ return candidateReal === rootReal || candidateReal.startsWith(rootReal + sep);
74
+ }
75
+
76
+ // Static prefix (pre-glob segments) is what a symlink escape must hide behind — the glob tail never resolves to a real path.
77
+ const GLOB_METACHARACTER_RE = /[*?[\]{}]/;
78
+
79
+ function staticPatternBase(value: string): string {
80
+ const staticSegments: string[] = [];
81
+ for (const segment of value.split("/")) {
82
+ if (GLOB_METACHARACTER_RE.test(segment)) break;
83
+ staticSegments.push(segment);
84
+ }
85
+ return staticSegments.join("/");
86
+ }
87
+
88
+ function assertPatternsResolveWithinRoot(
89
+ manifestPath: string,
90
+ rootAbs: string,
91
+ rootReal: string,
92
+ manifest: RepoManifest,
93
+ ): void {
94
+ const patterns = [
95
+ ...manifest.sourceRoots,
96
+ ...manifest.testGlobs,
97
+ ...(manifest.uiRoots ?? []),
98
+ ...(manifest.excludes ?? []),
99
+ ];
100
+ for (const value of patterns) {
101
+ const base = staticPatternBase(value);
102
+ if (base.length === 0) continue;
103
+ // Walk segment by segment — a symlink can escape before the full base path exists on disk (e.g. "linked/src" whose target has no "src").
104
+ let relSoFar = "";
105
+ for (const segment of base.split("/")) {
106
+ relSoFar = relSoFar.length === 0 ? segment : `${relSoFar}/${segment}`;
107
+ const segmentAbs = join(rootAbs, relSoFar);
108
+ if (!existsSync(segmentAbs)) break;
109
+ const segmentReal = realpathSync(segmentAbs);
110
+ if (!isWithinRoot(segmentReal, rootReal)) {
111
+ throw new RepoManifestError(manifestPath, `"${value}" resolves outside the repo root`);
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ function loadManifestFile(
118
+ manifestPath: string,
119
+ rootAbs: string,
120
+ rootReal: string,
121
+ ): LoadedRepoManifest {
122
+ const manifestReal = realpathSync(manifestPath);
123
+ if (!isWithinRoot(manifestReal, rootReal)) {
124
+ throw new RepoManifestError(manifestPath, "resolves outside the repo root");
125
+ }
126
+
127
+ const raw = readFileSync(manifestPath, "utf8");
128
+ let json: unknown;
129
+ try {
130
+ json = JSON.parse(raw);
131
+ } catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ throw new RepoManifestError(manifestPath, `invalid JSON — ${message}`);
134
+ }
135
+
136
+ const result = repoManifestSchema.safeParse(json);
137
+ if (!result.success) {
138
+ const issues = result.error.issues
139
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
140
+ .join("; ");
141
+ throw new RepoManifestError(manifestPath, `invalid repo manifest — ${issues}`);
142
+ }
143
+
144
+ assertPatternsResolveWithinRoot(manifestPath, rootAbs, rootReal, result.data);
145
+
146
+ return { manifest: result.data, source: "file", manifestPath };
147
+ }
148
+
149
+ function hasPackagesSrcLayout(rootAbs: string): boolean {
150
+ const packagesDir = join(rootAbs, "packages");
151
+ if (!isDirectory(packagesDir)) return false;
152
+ return readdirSync(packagesDir, { withFileTypes: true }).some(
153
+ (entry) => entry.isDirectory() && isDirectory(join(packagesDir, entry.name, "src")),
154
+ );
155
+ }
156
+
157
+ type DerivedLayout = { readonly layout: string; readonly manifest: RepoManifest };
158
+
159
+ function derivedLayoutFor(rootAbs: string): DerivedLayout | undefined {
160
+ if (hasPackagesSrcLayout(rootAbs)) {
161
+ return {
162
+ layout: "packages/*/src",
163
+ manifest: {
164
+ kind: "library",
165
+ sourceRoots: ["packages/*/src"],
166
+ testGlobs: ["packages/*/src/**/*.{test,integration}.{ts,tsx}"],
167
+ },
168
+ };
169
+ }
170
+ if (isDirectory(join(rootAbs, "src"))) {
171
+ return {
172
+ layout: "src",
173
+ manifest: {
174
+ kind: "app",
175
+ sourceRoots: ["src"],
176
+ testGlobs: ["src/**/*.{test,integration}.{ts,tsx}"],
177
+ },
178
+ };
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ function deriveManifest(
184
+ manifestPath: string,
185
+ rootAbs: string,
186
+ warn: (message: string) => void,
187
+ ): LoadedRepoManifest {
188
+ const derived = derivedLayoutFor(rootAbs);
189
+
190
+ if (!derived) {
191
+ throw new RepoManifestError(
192
+ manifestPath,
193
+ "not found and no packages/*/src or src/ layout to derive one from",
194
+ );
195
+ }
196
+
197
+ warn(
198
+ `${manifestPath} not found — derived "${derived.layout}" layout (kind "${derived.manifest.kind}"); ` +
199
+ "add a kumiko.json to declare the repo layout explicitly",
200
+ );
201
+
202
+ return { manifest: derived.manifest, source: "derived", manifestPath };
203
+ }
204
+
205
+ function defaultWarn(message: string): void {
206
+ // biome-ignore lint/suspicious/noConsole: no logger is wired when the manifest loads
207
+ console.warn(message);
208
+ }
209
+
210
+ export function loadRepoManifest(
211
+ root: string,
212
+ options: LoadRepoManifestOptions = {},
213
+ ): LoadedRepoManifest {
214
+ const rootAbs = resolve(root);
215
+ const manifestPath = join(rootAbs, REPO_MANIFEST_FILE);
216
+
217
+ if (!isDirectory(rootAbs)) {
218
+ throw new RepoManifestError(manifestPath, "repo root does not exist or is not a directory");
219
+ }
220
+
221
+ if (existsSync(manifestPath)) {
222
+ return loadManifestFile(manifestPath, rootAbs, realpathSync(rootAbs));
223
+ }
224
+
225
+ return deriveManifest(manifestPath, rootAbs, options.warn ?? defaultWarn);
226
+ }