@magicpixelart/cli 0.3.0 → 0.4.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.
@@ -1,7 +1,10 @@
1
- import { readFile, readdir, writeFile } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
- import { assertPathInsideRoot, assertSafeAssetSegments } from './security.js';
4
+ import { assertSafeAssetSegments } from './security.js';
5
+ import { atomicWrite } from './atomicWrite.js';
6
+ import { walkOutDirPngs } from './paths.js';
7
+ import { isStaticOutDir } from './framework.js';
5
8
  const AGENT_HEADER = [
6
9
  '// 👋 AI agents: import sprites by key, e.g.',
7
10
  '// import { MagicPixelAssets } from "<this-dir>";',
@@ -18,41 +21,15 @@ const AGENT_HEADER = [
18
21
  * manifest call left the barrel stale while the PNGs on disk were fresh. We
19
22
  * now derive the index purely from the filesystem so it can never disagree
20
23
  * with what bundlers will see.
24
+ *
25
+ * Delegates the actual walk to the shared `walkOutDirPngs` helper (also used
26
+ * by sync.ts's orphan scan) so both code paths share one symlink/security
27
+ * policy.
21
28
  */
22
29
  export async function scanDiskAssets(outDir, cwd = process.cwd()) {
23
- const root = resolve(cwd, outDir);
24
- if (!existsSync(root))
25
- return [];
26
- const out = [];
27
- async function walk(dir, folderParts) {
28
- const entries = await readdir(dir, { withFileTypes: true });
29
- for (const ent of entries) {
30
- if (ent.isSymbolicLink())
31
- continue;
32
- const full = resolve(dir, ent.name);
33
- try {
34
- assertPathInsideRoot(full, root, 'outDir');
35
- }
36
- catch {
37
- continue;
38
- }
39
- if (ent.isDirectory()) {
40
- // Skip the id sidecar dir or any dotted folders — never user assets.
41
- if (ent.name.startsWith('.'))
42
- continue;
43
- await walk(full, [...folderParts, ent.name]);
44
- }
45
- else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
46
- const slug = ent.name.slice(0, -4);
47
- const folder = folderParts.length ? folderParts.join('/') : null;
48
- const key = folder ? `${folder}/${slug}` : slug;
49
- out.push({ key, folder, slug });
50
- }
51
- }
52
- }
53
- await walk(root, []);
54
- out.sort((a, b) => a.key.localeCompare(b.key));
55
- return out;
30
+ const found = await walkOutDirPngs(outDir, cwd);
31
+ // No sort here — `emitTypedIndex` already owns the canonical key ordering.
32
+ return found.map(({ key, folder, slug }) => ({ key, folder, slug }));
56
33
  }
57
34
  /**
58
35
  * Emit `<outDir>/index.ts` with a typed map of every asset key → URL, plus an
@@ -66,17 +43,18 @@ export async function scanDiskAssets(outDir, cwd = process.cwd()) {
66
43
  * time. No runtime, no bundler config.
67
44
  */
68
45
  export async function emitTypedIndex(outDir, entries, cwd = process.cwd()) {
69
- // Dedupe by key. The disk scan can't actually produce duplicates, but guard
70
- // anyway so a future caller passing manifest entries can't break TS compile.
46
+ // Sort by key for stable output. The disk walker can't actually produce
47
+ // duplicate keys (one PNG per filesystem path), but a future caller passing
48
+ // manifest entries could — silently skip duplicates so we never emit an
49
+ // invalid TS object literal.
50
+ const sorted = [];
71
51
  const seen = new Set();
72
- const sorted = [...entries]
73
- .sort((a, b) => a.key.localeCompare(b.key))
74
- .filter((e) => {
52
+ for (const e of [...entries].sort((a, b) => a.key.localeCompare(b.key))) {
75
53
  if (seen.has(e.key))
76
- return false;
54
+ continue;
77
55
  seen.add(e.key);
78
- return true;
79
- });
56
+ sorted.push(e);
57
+ }
80
58
  const lines = [
81
59
  '// AUTOGENERATED by @magicpixelart/cli. Do not edit.',
82
60
  '// Re-runs of `magicpixel sync` overwrite this file.',
@@ -132,9 +110,10 @@ export async function emitTypedIndex(outDir, entries, cwd = process.cwd()) {
132
110
  // fall through and overwrite
133
111
  }
134
112
  }
135
- await writeFile(path, next, 'utf8');
113
+ await atomicWrite(path, next);
136
114
  return path;
137
115
  }
116
+ // (atomicWrite extracted to util/atomicWrite.ts and shared across writers.)
138
117
  /**
139
118
  * Ensure an `AGENTS.md` at the repo root nudges AI tools (Lovable, Cursor,
140
119
  * Claude Code, …) toward the typed index. Idempotent:
@@ -145,48 +124,77 @@ export async function emitTypedIndex(outDir, entries, cwd = process.cwd()) {
145
124
  */
146
125
  export async function ensureAgentsDoc(outDir, cwd = process.cwd()) {
147
126
  const path = resolve(cwd, 'AGENTS.md');
148
- const importPath = guessImportPath(outDir);
149
- const section = renderAgentsSection(importPath);
127
+ const section = renderAgentsSection(outDir);
150
128
  const marker = '<!-- magicpixel:start -->';
151
129
  if (!existsSync(path)) {
152
- await writeFile(path, `# AGENTS\n\n${section}\n`, 'utf8');
130
+ await atomicWrite(path, `# AGENTS\n\n${section}\n`);
153
131
  return 'created';
154
132
  }
155
133
  const current = await readFile(path, 'utf8');
156
134
  if (current.includes(marker))
157
135
  return 'unchanged';
158
136
  const sep = current.endsWith('\n') ? '\n' : '\n\n';
159
- await writeFile(path, current + sep + section + '\n', 'utf8');
137
+ await atomicWrite(path, current + sep + section + '\n');
160
138
  return 'appended';
161
139
  }
162
- function renderAgentsSection(importPath) {
140
+ /**
141
+ * Resolve how PNGs under `outDir` are referenced by application code. Two
142
+ * cases:
143
+ * - `public/...` or `static/...` (Next/Astro/Nuxt/CRA/SvelteKit) → these
144
+ * are served as static assets at the URL root, so the correct reference
145
+ * is an ABSOLUTE URL like `/magicpixel/items/tree.png`.
146
+ * - everything else → bundler-importable; emit an `import { … } from
147
+ * '@/...' | './...'` snippet that hashes the URL at build time.
148
+ */
149
+ function resolveAssetReference(outDir) {
150
+ const norm = outDir.replace(/\\/g, '/').replace(/^\.\//, '');
151
+ if (isStaticOutDir(outDir)) {
152
+ // Strip the leading `public/` or `static/` — these dirs are served at the URL root.
153
+ const baseUrl = '/' + norm.replace(/^(public|static)\//, '');
154
+ return { mode: 'url', baseUrl };
155
+ }
156
+ // `src/...` is conventionally aliased to `@/...` in Vite/Next/etc.
157
+ if (norm.startsWith('src/'))
158
+ return { mode: 'import', importPath: `@/${norm.slice(4)}` };
159
+ return { mode: 'import', importPath: `./${norm}` };
160
+ }
161
+ function renderAgentsSection(outDir) {
162
+ const ref = resolveAssetReference(outDir);
163
+ const body = ref.mode === 'url'
164
+ ? [
165
+ `Pixel-art assets are synced to \`${outDir}\` by \`@magicpixelart/cli\`. They are`,
166
+ `plain PNGs served at the URL root — reference them by absolute path:`,
167
+ '',
168
+ '```tsx',
169
+ `<img src="${ref.baseUrl}/items/tree.png" />`,
170
+ '```',
171
+ '',
172
+ `Files live on disk at \`${outDir}/<folder>/<slug>.png\` so any path you see in the`,
173
+ 'MagicPixel editor maps 1:1 to a URL.',
174
+ ]
175
+ : [
176
+ 'Pixel-art assets are synced to disk by `@magicpixelart/cli`. They are plain PNGs you can `import`.',
177
+ '',
178
+ '```ts',
179
+ `// 1) Import by key (matches the folder/slug shown in the MagicPixel editor):`,
180
+ `import { MagicPixelAssets } from "${ref.importPath}";`,
181
+ `<img src={MagicPixelAssets["items/tree"]} />`,
182
+ '',
183
+ '// 2) Import by id (survives renames — pin imports that move around):',
184
+ `import { MagicPixelAssetsById } from "${ref.importPath}";`,
185
+ `<img src={MagicPixelAssetsById["<asset-uuid>"]} />`,
186
+ '```',
187
+ '',
188
+ 'When you rename or move an asset, `magicpixel sync` prints both the old → new key',
189
+ 'and the matching `MagicPixelAssetsById[...]` hint so you can pick whichever you prefer.',
190
+ ];
163
191
  return [
164
192
  '<!-- magicpixel:start -->',
165
193
  '## MagicPixel sprites',
166
194
  '',
167
- 'Pixel-art assets are synced to disk by `@magicpixelart/cli`. They are plain PNGs you can `import`.',
168
- '',
169
- '```ts',
170
- `// 1) Import by key (matches the folder/slug shown in the MagicPixel editor):`,
171
- `import { MagicPixelAssets } from "${importPath}";`,
172
- `<img src={MagicPixelAssets["items/tree"]} />`,
173
- '',
174
- '// 2) Import by id (survives renames — pin imports that move around):',
175
- `import { MagicPixelAssetsById } from "${importPath}";`,
176
- `<img src={MagicPixelAssetsById["<asset-uuid>"]} />`,
177
- '```',
178
- '',
179
- 'When you rename or move an asset, `magicpixel sync` prints both the old → new key',
180
- 'and the matching `MagicPixelAssetsById[...]` hint so you can pick whichever you prefer.',
195
+ ...body,
181
196
  '',
182
197
  'Keep the watcher running while you edit: `npm run magicpixel:watch`.',
183
198
  '<!-- magicpixel:end -->',
184
199
  ].join('\n');
185
200
  }
186
- function guessImportPath(outDir) {
187
- const norm = outDir.replace(/\\/g, '/').replace(/^\.\//, '');
188
- // `src/...` is conventionally aliased to `@/...` in Vite/Next/etc.
189
- if (norm.startsWith('src/'))
190
- return `@/${norm.slice(4)}`;
191
- return `./${norm}`;
192
- }
@@ -0,0 +1,49 @@
1
+ import { relative } from 'node:path';
2
+ /**
3
+ * Filesystem error codes that indicate a permission/lock problem rather than a
4
+ * bug in our code. We rewrite these into multi-line, action-oriented messages
5
+ * so a non-technical user can fix them without a Stack Overflow detour.
6
+ */
7
+ const FS_PERMISSION_CODES = new Set(['EACCES', 'EPERM', 'EROFS', 'EBUSY', 'ETXTBSY']);
8
+ /**
9
+ * Returns `true` if the error is a filesystem permission error we know how to
10
+ * rewrite. Useful for `try { … } catch (e) { if (isFsPermissionError(e)) … }`.
11
+ */
12
+ export function isFsPermissionError(e) {
13
+ return !!e && typeof e === 'object' && 'code' in e && FS_PERMISSION_CODES.has(e.code ?? '');
14
+ }
15
+ /**
16
+ * Wrap a filesystem permission error in a friendly, paste-to-AI message.
17
+ * Returns the original error untouched if the code isn't one we recognize, so
18
+ * callers can blindly `throw friendlyFsError(e, …)` without losing detail.
19
+ */
20
+ export function friendlyFsError(e, ctx) {
21
+ if (!isFsPermissionError(e))
22
+ return e instanceof Error ? e : new Error(String(e));
23
+ const code = e.code ?? 'EACCES';
24
+ const relPath = (() => {
25
+ try {
26
+ return relative(process.cwd(), ctx.path) || ctx.path;
27
+ }
28
+ catch {
29
+ return ctx.path;
30
+ }
31
+ })();
32
+ const lines = [`${ctx.operation} failed (${code}): ${relPath}`];
33
+ if (code === 'EROFS') {
34
+ lines.push(' Fix: outDir is on a read-only filesystem. Move it under your project source tree.');
35
+ }
36
+ else if (code === 'EBUSY' || code === 'ETXTBSY') {
37
+ lines.push(' Fix: close any editor or program holding the file open and re-run.');
38
+ lines.push(' (On Windows, OneDrive/Dropbox can also lock files briefly.)');
39
+ }
40
+ else {
41
+ // EACCES / EPERM
42
+ lines.push(' Fix: check that you can write to this path.');
43
+ lines.push(' • Linux/macOS: chmod -R u+w <path> (or run from a different shell)');
44
+ lines.push(' • Windows: close OneDrive/your editor, or move outDir out of a synced folder');
45
+ }
46
+ if (ctx.hint)
47
+ lines.push(` ${ctx.hint}`);
48
+ return new Error(lines.join('\n'));
49
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Validators for `commander` `.option()` parsers, extracted so they can be
3
+ * unit-tested without spawning a subprocess. Keep these pure — they take
4
+ * the raw string commander hands us and either return the parsed value or
5
+ * throw with a user-facing message.
6
+ *
7
+ * We throw a plain `Error` (with `code = 'commander.invalidArgument'`)
8
+ * instead of importing `InvalidArgumentError` from commander. Commander's
9
+ * runtime checks the `code` field, not `instanceof`, so the formatting is
10
+ * identical — and this keeps the validators trivially unit-testable
11
+ * without dragging commander's CJS/ESM interop into the test loader.
12
+ */
13
+ class FlagValidationError extends Error {
14
+ // Matches commander's own `InvalidArgumentError` so it gets the same
15
+ // "error: invalid argument for option …" presentation.
16
+ code = 'commander.invalidArgument';
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = 'InvalidArgumentError';
20
+ }
21
+ }
22
+ /**
23
+ * `sync --watch [seconds]`. Bare `-w` (boolean) is short-circuited by
24
+ * commander before this is invoked; we only see string values here.
25
+ *
26
+ * Floor is 2 because the watch loop polls aggressively (every 2s by
27
+ * default) and faster polling thrashes the manifest endpoint without
28
+ * measurably improving perceived latency. Ceiling 3600 is a sanity bound
29
+ * (1h covers any reasonable "slow background sync" use case; users that
30
+ * want a daily sync should run `magicpixel sync` from cron instead).
31
+ */
32
+ export function parseWatchInterval(v) {
33
+ if (typeof v !== 'string')
34
+ return v;
35
+ const trimmed = v.trim();
36
+ const n = parseInt(trimmed, 10);
37
+ if (!Number.isFinite(n) || String(n) !== trimmed || n < 2 || n > 3600) {
38
+ throw new FlagValidationError(`expected an integer 2–3600 (seconds), got "${v}".`);
39
+ }
40
+ return String(n);
41
+ }
42
+ /** `sync -c <n>` — parallel downloads, 1–16 inclusive. */
43
+ export function parseConcurrency(v) {
44
+ const n = parseInt(v, 10);
45
+ if (!Number.isFinite(n) || String(n) !== v.trim() || n < 1 || n > 16) {
46
+ throw new FlagValidationError(`expected an integer 1–16, got "${v}".`);
47
+ }
48
+ return n;
49
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Human-readable byte formatter shared by `sync` (download summary, progress
3
+ * bar) and `list` (manifest table). Single source of truth — keep new
4
+ * call sites pointed here rather than re-implementing locally.
5
+ */
6
+ export function formatBytes(n) {
7
+ if (!Number.isFinite(n) || n < 0)
8
+ return '-';
9
+ if (n < 1024)
10
+ return `${n} B`;
11
+ if (n < 1024 * 1024)
12
+ return `${(n / 1024).toFixed(1)} KB`;
13
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
14
+ }
@@ -54,3 +54,13 @@ export function suggestOutDir(framework, cwd = process.cwd()) {
54
54
  export function hasPackageJson(cwd = process.cwd()) {
55
55
  return existsSync(resolve(cwd, 'package.json'));
56
56
  }
57
+ /**
58
+ * True when `outDir` lives under `public/` or `static/` — these dirs are
59
+ * served as-is by frameworks (Next/Astro/Nuxt/CRA/SvelteKit) and cannot be
60
+ * `import`ed by a bundler. Shared by `init` (skip typed index) and
61
+ * `emitIndex` (emit absolute-URL AGENTS.md snippet instead of an import).
62
+ */
63
+ export function isStaticOutDir(outDir) {
64
+ const norm = outDir.replace(/\\/g, '/').replace(/^\.\//, '');
65
+ return /^(public|static)(\/|$)/.test(norm);
66
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Strict ISO-8601 with required time and timezone (Z or ±HH:MM). Mirrors the
3
+ * edge function's `ISO_8601_RE` so cursor strings round-trip safely.
4
+ *
5
+ * Why strict: the prior `Date.parse`-based check accepted loose inputs like
6
+ * `"2024/06/01"` which sort lexicographically BEFORE any real ISO string and
7
+ * would silently rewind `lastSync`, re-downloading history on the next tick.
8
+ */
9
+ export const STRICT_ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
10
+ export function isStrictIso8601(value) {
11
+ return typeof value === 'string' && STRICT_ISO_8601_RE.test(value);
12
+ }
13
+ /**
14
+ * Return the lexicographically-greatest valid ISO-8601 timestamp from the
15
+ * input list, or null if none parse. ISO-8601 strings sort correctly as
16
+ * strings, so we don't convert to Date (and avoid timezone round-trip drift).
17
+ * Invalid entries are silently dropped.
18
+ */
19
+ export function maxIsoTimestamp(values) {
20
+ let best = null;
21
+ for (const v of values) {
22
+ if (!isStrictIso8601(v))
23
+ continue;
24
+ if (best === null || v > best)
25
+ best = v;
26
+ }
27
+ return best;
28
+ }
@@ -1,3 +1,5 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, rm } from 'node:fs/promises';
1
3
  import { resolve } from 'node:path';
2
4
  import { assertPathInsideRoot, assertSafeAssetSegments } from './security.js';
3
5
  /**
@@ -27,3 +29,100 @@ function resolveAssetDiskPath(outDir, folder, slug, key, cwd) {
27
29
  assertPathInsideRoot(diskPath, base, 'outDir');
28
30
  return diskPath;
29
31
  }
32
+ /**
33
+ * Recursively walk `outDir` and return every `*.png` on disk. Skips symlinks
34
+ * and dot-prefixed directories. Gates every path through `assertPathInsideRoot`
35
+ * so a symlink/junction outside the tree can't sneak into the result.
36
+ *
37
+ * Single canonical disk walker — replaces the two near-identical implementations
38
+ * that used to live in `sync.ts` (`findLocalPngs`) and `emitIndex.ts`
39
+ * (`scanDiskAssets`).
40
+ */
41
+ export async function walkOutDirPngs(outDir, cwd = process.cwd()) {
42
+ const root = resolve(cwd, outDir);
43
+ if (!existsSync(root))
44
+ return [];
45
+ const out = [];
46
+ async function walk(dir, folderParts) {
47
+ const entries = await readdir(dir, { withFileTypes: true });
48
+ for (const ent of entries) {
49
+ if (ent.isSymbolicLink())
50
+ continue;
51
+ const full = resolve(dir, ent.name);
52
+ try {
53
+ assertPathInsideRoot(full, root, 'outDir');
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ if (ent.isDirectory()) {
59
+ if (ent.name.startsWith('.'))
60
+ continue;
61
+ await walk(full, [...folderParts, ent.name]);
62
+ }
63
+ else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
64
+ const slug = ent.name.slice(0, -4);
65
+ const folder = folderParts.length ? folderParts.join('/') : null;
66
+ const key = folder ? `${folder}/${slug}` : slug;
67
+ out.push({ abs: full, folder, slug, key });
68
+ }
69
+ }
70
+ }
71
+ await walk(root, []);
72
+ return out;
73
+ }
74
+ /**
75
+ * DFS post-order walk that collects every subdirectory of `root` whose entire
76
+ * subtree contains no files. Never includes `root` itself. Shared by
77
+ * `pruneEmptyDirs` (deletes) and `repair --dry-run` (reports).
78
+ *
79
+ * Returns directories in post-order so callers that delete sequentially never
80
+ * try to remove a parent before its children. Soft-fails on per-directory
81
+ * readdir errors (returns "not empty" so we never delete a directory we
82
+ * can't actually enumerate).
83
+ */
84
+ export async function listEmptyDirs(root) {
85
+ if (!existsSync(root))
86
+ return [];
87
+ const out = [];
88
+ async function walk(d) {
89
+ let entries;
90
+ try {
91
+ entries = await readdir(d, { withFileTypes: true });
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ let hasFile = false;
97
+ let allChildrenEmpty = true;
98
+ for (const ent of entries) {
99
+ const full = resolve(d, ent.name);
100
+ if (ent.isDirectory()) {
101
+ const childEmpty = await walk(full);
102
+ if (childEmpty)
103
+ out.push(full);
104
+ else
105
+ allChildrenEmpty = false;
106
+ }
107
+ else {
108
+ hasFile = true;
109
+ }
110
+ }
111
+ return !hasFile && allChildrenEmpty;
112
+ }
113
+ await walk(root);
114
+ return out;
115
+ }
116
+ /**
117
+ * Recursively delete empty subdirectories under `root`. Never removes `root`
118
+ * itself. Idempotent and safe to call after every prune pass.
119
+ */
120
+ export async function pruneEmptyDirs(root) {
121
+ const empties = await listEmptyDirs(root);
122
+ // `listEmptyDirs` returns post-order, so deleting sequentially never tries
123
+ // to remove a parent before its children. `force: true` swallows the rare
124
+ // race where a sibling pass already removed the directory.
125
+ for (const p of empties) {
126
+ await rm(p, { recursive: true, force: true });
127
+ }
128
+ }
@@ -44,6 +44,13 @@ export function validateEndpointUrl(raw) {
44
44
  throw new Error(`endpoint is not a valid URL: ${raw}\n` +
45
45
  ` Fix: use https://… or remove "endpoint" from magicpixel.json.`);
46
46
  }
47
+ // Explicit allowlist of schemes. Anything outside { https, http } —
48
+ // file:, data:, javascript:, ws:, gopher:, etc. — is rejected up-front
49
+ // with a clearer message than the generic HTTPS error.
50
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
51
+ throw new Error(`endpoint scheme "${url.protocol}" is not allowed.\n` +
52
+ ` Fix: use an https:// URL (http://localhost is allowed with MAGICPIXEL_ALLOW_INSECURE_ENDPOINT=1).`);
53
+ }
47
54
  if (url.username || url.password) {
48
55
  throw new Error(`endpoint must not embed credentials in the URL.\n` +
49
56
  ` Fix: use MAGICPIXEL_API_KEY for auth, not user:pass@host.`);
@@ -52,7 +59,10 @@ export function validateEndpointUrl(raw) {
52
59
  return normalizeEndpointBase(url);
53
60
  }
54
61
  const allowInsecure = process.env.MAGICPIXEL_ALLOW_INSECURE_ENDPOINT === '1';
55
- if (allowInsecure && url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) {
62
+ if (allowInsecure && url.hostname === 'localhost') {
63
+ return normalizeEndpointBase(url);
64
+ }
65
+ if (allowInsecure && url.hostname === '127.0.0.1') {
56
66
  return normalizeEndpointBase(url);
57
67
  }
58
68
  throw new Error(`endpoint must use HTTPS (${url.protocol}//${url.host}).\n` +
@@ -116,7 +126,8 @@ export async function readBodyWithLimit(res, maxBytes) {
116
126
  }
117
127
  return buf;
118
128
  }
119
- const MAX_GLOB_LEN = 256;
129
+ /** Max length of a user-supplied picomatch glob; shared with config.ts. */
130
+ export const MAX_GLOB_LEN = 256;
120
131
  /** Validate a user-supplied picomatch glob from the CLI. */
121
132
  export function assertSafeGlob(glob) {
122
133
  const g = glob.trim();
@@ -125,3 +136,29 @@ export function assertSafeGlob(glob) {
125
136
  }
126
137
  return g;
127
138
  }
139
+ /**
140
+ * Trim + validate a candidate `outDir`. Single source of truth shared by
141
+ * `init` (prompt-time) and `loadConfig` (file-time) so a path that survives
142
+ * one is guaranteed to survive the other.
143
+ *
144
+ * Returns the trimmed value. Throws on:
145
+ * - empty / whitespace-only input
146
+ * - any `..` segment or null byte
147
+ * - absolute paths — otherwise an outDir like `/tmp/mp` would resolve to
148
+ * itself and bypass the cwd-relative containment check in
149
+ * `assertPathInsideRoot`, letting a manifest write outside the project
150
+ * tree. `outDir` must always be relative to the project root.
151
+ */
152
+ export function assertSafeOutDir(value) {
153
+ const v = value.trim();
154
+ if (!v) {
155
+ throw new Error('outDir must not be empty.');
156
+ }
157
+ if (v.includes('\0') || v.split(/[/\\]/).some((seg) => seg === '..')) {
158
+ throw new Error('outDir must not contain ".." segments or null bytes.');
159
+ }
160
+ if (isAbsolute(v)) {
161
+ throw new Error('outDir must be a relative path (not absolute).');
162
+ }
163
+ return v;
164
+ }