@magicpixelart/cli 0.3.3 → 0.5.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/CHANGELOG.md +431 -0
- package/README.md +31 -2
- package/dist/api.js +135 -72
- package/dist/commands/doctor.js +201 -56
- package/dist/commands/init.js +25 -15
- package/dist/commands/list.js +6 -5
- package/dist/commands/login.js +3 -34
- package/dist/commands/repair.js +157 -0
- package/dist/commands/start.js +56 -14
- package/dist/commands/status.js +41 -13
- package/dist/commands/sync.js +396 -136
- package/dist/commands/whoami.js +52 -12
- package/dist/config.js +88 -20
- package/dist/index.js +38 -23
- package/dist/util/atomicWrite.js +25 -0
- package/dist/util/auth.js +41 -0
- package/dist/util/authHeaders.js +24 -0
- package/dist/util/credentials.js +27 -10
- package/dist/util/emitIndex.js +78 -70
- package/dist/util/errors.js +49 -0
- package/dist/util/flagValidators.js +49 -0
- package/dist/util/format.js +14 -0
- package/dist/util/framework.js +10 -0
- package/dist/util/iso.js +28 -0
- package/dist/util/paths.js +99 -0
- package/dist/util/security.js +39 -2
- package/dist/util/telemetry.js +182 -0
- package/package.json +11 -4
package/dist/util/emitIndex.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { readFile
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
-
import {
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
//
|
|
70
|
-
//
|
|
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
|
|
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
|
-
|
|
54
|
+
continue;
|
|
77
55
|
seen.add(e.key);
|
|
78
|
-
|
|
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
|
|
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
|
|
149
|
-
const section = renderAgentsSection(importPath);
|
|
127
|
+
const section = renderAgentsSection(outDir);
|
|
150
128
|
const marker = '<!-- magicpixel:start -->';
|
|
151
129
|
if (!existsSync(path)) {
|
|
152
|
-
await
|
|
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
|
|
137
|
+
await atomicWrite(path, current + sep + section + '\n');
|
|
160
138
|
return 'appended';
|
|
161
139
|
}
|
|
162
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/util/framework.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/util/iso.js
ADDED
|
@@ -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
|
+
}
|
package/dist/util/paths.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/util/security.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
}
|