@magicpixelart/cli 0.5.8 → 0.5.9

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,16 +1,16 @@
1
1
  /**
2
- * First-connect import: when `outDir` has no PNGs yet, copy nearby game
3
- * sprites into it so `push` can adopt them into the MagicPixel library.
4
- *
5
- * Only engine projects. Never walks Library/Temp/Packages. Never overwrites
6
- * a file already in outDir. Capped so a huge Assets/ tree can't stampede
7
- * the library.
2
+ * Game-tree index for opt-in connect. Walks the project/package (including
3
+ * hidden content dirs like `.SpineRaw_*`) but never copies files. `connect`
4
+ * globs select a working set; sync writes back to those original paths.
8
5
  */
9
6
  import { existsSync } from 'node:fs';
10
- import { copyFile, mkdir, readdir } from 'node:fs/promises';
11
- import { dirname, relative, resolve } from 'node:path';
12
- import { isEngineKind } from './framework.js';
13
- import { assertPathInsideRoot } from './security.js';
7
+ import { readdir } from 'node:fs/promises';
8
+ import { relative, resolve } from 'node:path';
9
+ import { isEngineKind, resolveChildDir } from './framework.js';
10
+ import { assertPathInsideRoot, sanitizeSourceRel } from './security.js';
11
+ import { gameImportAdoptPath } from './pushPlan.js';
12
+ import { matchGlob } from './globMatch.js';
13
+ import { createLimit } from './limit.js';
14
14
  /** Unity/engine folders that are never sprite sources. */
15
15
  export const GAME_SCAN_SKIP_DIRS = new Set([
16
16
  'library',
@@ -21,8 +21,36 @@ export const GAME_SCAN_SKIP_DIRS = new Set([
21
21
  'projectsettings',
22
22
  'usersettings',
23
23
  'streamingassets',
24
+ 'node_modules',
25
+ 'magicpixel',
24
26
  ]);
25
- export const MAX_GAME_IMPORT = 200;
27
+ /**
28
+ * Dot-directories we never walk. Other hidden folders (e.g. `.SpineRaw_*`)
29
+ * are scanned — Unity packages often stash sprites there.
30
+ */
31
+ export const GAME_SCAN_SKIP_HIDDEN = new Set([
32
+ '.git',
33
+ '.svn',
34
+ '.hg',
35
+ '.magicpixel',
36
+ '.vs',
37
+ '.vscode',
38
+ '.idea',
39
+ '.cursor',
40
+ '.config',
41
+ '.cache',
42
+ ]);
43
+ /** Parallel directory reads while walking the game tree. */
44
+ const SCAN_WALK_CONCURRENCY = 8;
45
+ /** Cap on the search index (whole tree). Connect uses the same ceiling. */
46
+ export const MAX_GAME_INDEX = 10_000;
47
+ /** Cap on PNGs ingested for one working-set connect / push. */
48
+ export const MAX_GAME_CONNECT = MAX_GAME_INDEX;
49
+ export const GAME_INDEX_CAP_HINT = `Game index hit ${MAX_GAME_INDEX} PNGs — later files aren't searchable or connectable.`;
50
+ export function connectCapMessage(total, ingested) {
51
+ return (`${total} PNGs match the working set — ingesting the first ${ingested} (cap ${MAX_GAME_CONNECT}). ` +
52
+ `Narrow the glob, or add another connect pattern for the rest.`);
53
+ }
26
54
  export function gameScanRoot(kind) {
27
55
  switch (kind) {
28
56
  case 'Unity':
@@ -35,51 +63,81 @@ export function gameScanRoot(kind) {
35
63
  return null;
36
64
  }
37
65
  }
38
- /**
39
- * Copy PNGs from the engine asset root into `outDir`, preserving relative
40
- * paths. No-op when `outDir` already has PNGs, when the project isn't an
41
- * engine, or when the scan root is missing.
42
- */
43
- export async function importNearbyGamePngs(outDir, kind, cwd = process.cwd()) {
44
- const result = { copied: 0, skipped: 0, capped: false };
66
+ function shouldSkipDir(name) {
67
+ const n = name.toLowerCase();
68
+ if (GAME_SCAN_SKIP_DIRS.has(n))
69
+ return true;
70
+ if (GAME_SCAN_SKIP_HIDDEN.has(n))
71
+ return true;
72
+ if (n.startsWith('.git'))
73
+ return true;
74
+ return false;
75
+ }
76
+ function importRel(abs, cwd, assetRoot) {
77
+ if (assetRoot) {
78
+ const rootNorm = assetRoot.replace(/\\/g, '/').toLowerCase();
79
+ const absNorm = abs.replace(/\\/g, '/').toLowerCase();
80
+ if (absNorm === rootNorm || absNorm.startsWith(`${rootNorm}/`)) {
81
+ return relative(assetRoot, abs).replace(/\\/g, '/');
82
+ }
83
+ }
84
+ return relative(cwd, abs).replace(/\\/g, '/');
85
+ }
86
+ function toEntry(abs, cwdAbs, assetRoot) {
87
+ const sourceRel = sanitizeSourceRel(relative(cwdAbs, abs).replace(/\\/g, '/'));
88
+ if (!sourceRel)
89
+ return null;
90
+ const adoptRel = importRel(abs, cwdAbs, assetRoot);
91
+ const relKey = adoptRel.replace(/\.png$/i, '');
92
+ const adopted = gameImportAdoptPath(relKey.split('/'));
93
+ if (adopted.path.length < 2 || adopted.path.some((s) => !s))
94
+ return null;
95
+ return { abs, sourceRel, adoptRel, key: adopted.path.join('/') };
96
+ }
97
+ /** Walk the engine tree and return index entries. Does not copy files. */
98
+ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '') {
99
+ const empty = { files: [], capped: false };
45
100
  if (!isEngineKind(kind))
46
- return result;
47
- const scanRel = gameScanRoot(kind);
48
- if (!scanRel)
49
- return result;
50
- const scanRoot = resolve(cwd, scanRel);
51
- const destRoot = resolve(cwd, outDir);
101
+ return empty;
102
+ const scanRoot = resolve(cwd);
52
103
  if (!existsSync(scanRoot))
53
- return result;
54
- const destNorm = destRoot.replace(/\\/g, '/').toLowerCase();
104
+ return empty;
105
+ const destNorm = outDir ? resolve(cwd, outDir).replace(/\\/g, '/').toLowerCase() : '';
106
+ const namedRoot = gameScanRoot(kind);
107
+ const assetRoot = namedRoot ? resolveChildDir(cwd, namedRoot) : null;
55
108
  const found = [];
56
- await walkPngs(scanRoot, scanRoot, destNorm, found);
57
- if (found.length >= MAX_GAME_IMPORT)
58
- result.capped = true;
59
- if (found.length > MAX_GAME_IMPORT)
60
- found.length = MAX_GAME_IMPORT;
109
+ const io = createLimit(SCAN_WALK_CONCURRENCY);
110
+ await walkPngs(scanRoot, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io);
111
+ found.sort((a, b) => a.localeCompare(b));
112
+ const capped = found.length > MAX_GAME_INDEX;
113
+ if (capped)
114
+ found.length = MAX_GAME_INDEX;
115
+ const files = [];
61
116
  for (const abs of found) {
62
- const rel = relative(scanRoot, abs).replace(/\\/g, '/');
63
- const dest = resolve(destRoot, rel);
64
- try {
65
- assertPathInsideRoot(dest, destRoot, 'outDir');
66
- }
67
- catch {
68
- result.skipped++;
69
- continue;
70
- }
71
- if (existsSync(dest)) {
72
- result.skipped++;
73
- continue;
74
- }
75
- await mkdir(dirname(dest), { recursive: true });
76
- await copyFile(abs, dest);
77
- result.copied++;
117
+ const entry = toEntry(abs, scanRoot, assetRoot);
118
+ if (entry)
119
+ files.push(entry);
78
120
  }
79
- return result;
121
+ return { files, capped };
122
+ }
123
+ /** Filter the index by connect globs (cwd-relative or asset-root-relative). */
124
+ export function matchConnectGlobs(index, globs, max = MAX_GAME_CONNECT) {
125
+ if (globs.length === 0)
126
+ return { entries: [], total: 0, capped: false };
127
+ const matched = index.files.filter((e) => globs.some((g) => matchGlob(e.sourceRel, g) || matchGlob(e.adoptRel, g)));
128
+ matched.sort((a, b) => a.sourceRel.localeCompare(b.sourceRel));
129
+ const total = matched.length;
130
+ const capped = total > max;
131
+ return { entries: capped ? matched.slice(0, max) : matched, total, capped };
132
+ }
133
+ export function searchGameIndex(index, query) {
134
+ const q = query.trim().toLowerCase();
135
+ if (!q)
136
+ return [];
137
+ return index.files.filter((e) => e.sourceRel.toLowerCase().includes(q) || e.key.includes(q) || e.adoptRel.toLowerCase().includes(q));
80
138
  }
81
- async function walkPngs(dir, root, destNorm, out) {
82
- if (out.length >= MAX_GAME_IMPORT)
139
+ async function walkPngs(dir, root, destNorm, out, limit, io) {
140
+ if (out.length >= limit)
83
141
  return;
84
142
  let entries;
85
143
  try {
@@ -88,8 +146,10 @@ async function walkPngs(dir, root, destNorm, out) {
88
146
  catch {
89
147
  return;
90
148
  }
149
+ entries.sort((a, b) => a.name.localeCompare(b.name));
150
+ const subdirs = [];
91
151
  for (const ent of entries) {
92
- if (out.length >= MAX_GAME_IMPORT)
152
+ if (out.length >= limit)
93
153
  return;
94
154
  if (ent.isSymbolicLink())
95
155
  continue;
@@ -101,19 +161,17 @@ async function walkPngs(dir, root, destNorm, out) {
101
161
  continue;
102
162
  }
103
163
  if (ent.isDirectory()) {
104
- const name = ent.name.toLowerCase();
105
- if (name.startsWith('.'))
164
+ if (shouldSkipDir(ent.name))
106
165
  continue;
107
- if (GAME_SCAN_SKIP_DIRS.has(name))
166
+ if (destNorm && full.replace(/\\/g, '/').toLowerCase() === destNorm)
108
167
  continue;
109
- // Don't re-import from outDir if it sits inside the scan root
110
- // (Assets/MagicPixel under Assets/).
111
- if (full.replace(/\\/g, '/').toLowerCase() === destNorm)
112
- continue;
113
- await walkPngs(full, root, destNorm, out);
168
+ subdirs.push(full);
114
169
  }
115
170
  else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
116
171
  out.push(full);
117
172
  }
118
173
  }
174
+ if (subdirs.length === 0 || out.length >= limit)
175
+ return;
176
+ await Promise.all(subdirs.map((d) => io(() => walkPngs(d, root, destNorm, out, limit, io))));
119
177
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Small picomatch subset (`*`, `**`, `?`) so `connect` globs work without
3
+ * adding a runtime dependency. Case-insensitive; backslashes treated as `/`.
4
+ */
5
+ const regexCache = new Map();
6
+ export function matchGlob(path, pattern) {
7
+ const p = path.replace(/\\/g, '/').replace(/^\.\//, '');
8
+ const g = pattern.replace(/\\/g, '/').replace(/^\.\//, '');
9
+ if (!g)
10
+ return false;
11
+ let re = regexCache.get(g);
12
+ if (!re) {
13
+ re = globToRegExp(g);
14
+ regexCache.set(g, re);
15
+ }
16
+ return re.test(p);
17
+ }
18
+ function globToRegExp(glob) {
19
+ let out = '^';
20
+ for (let i = 0; i < glob.length;) {
21
+ const c = glob[i];
22
+ if (c === '*' && glob[i + 1] === '*') {
23
+ if (glob[i + 2] === '/') {
24
+ out += '(?:.*/)?';
25
+ i += 3;
26
+ }
27
+ else {
28
+ out += '.*';
29
+ i += 2;
30
+ }
31
+ }
32
+ else if (c === '*') {
33
+ out += '[^/]*';
34
+ i += 1;
35
+ }
36
+ else if (c === '?') {
37
+ out += '[^/]';
38
+ i += 1;
39
+ }
40
+ else {
41
+ if (/[.+^${}()|[\]\\]/.test(c))
42
+ out += `\\${c}`;
43
+ else
44
+ out += c;
45
+ i += 1;
46
+ }
47
+ }
48
+ out += '$';
49
+ return new RegExp(out, 'i');
50
+ }
package/dist/util/hash.js CHANGED
@@ -1,9 +1,41 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFile } from 'node:fs/promises';
3
- import { existsSync } from 'node:fs';
4
- export async function fileSha256(path) {
5
- if (!existsSync(path))
2
+ import { lstat, readFile } from 'node:fs/promises';
3
+ /** Process-lifetime cache: skip re-reading a PNG when mtime+size are unchanged. */
4
+ const cache = new Map();
5
+ export function clearFileHashCache() {
6
+ cache.clear();
7
+ }
8
+ export async function hashFile(path, hint) {
9
+ let st;
10
+ try {
11
+ st = await lstat(path);
12
+ }
13
+ catch {
14
+ cache.delete(path);
15
+ return null;
16
+ }
17
+ if (!st.isFile() || st.isSymbolicLink()) {
18
+ cache.delete(path);
6
19
  return null;
20
+ }
21
+ if (hint && hint.mtimeMs === st.mtimeMs && hint.size === st.size) {
22
+ const rec = { sha256: hint.sha256, mtimeMs: st.mtimeMs, size: st.size };
23
+ cache.set(path, rec);
24
+ return rec;
25
+ }
26
+ const hit = cache.get(path);
27
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
28
+ return hit;
7
29
  const buf = await readFile(path);
8
- return createHash('sha256').update(buf).digest('hex');
30
+ const rec = {
31
+ sha256: createHash('sha256').update(buf).digest('hex'),
32
+ mtimeMs: st.mtimeMs,
33
+ size: st.size,
34
+ };
35
+ cache.set(path, rec);
36
+ return rec;
37
+ }
38
+ export async function fileSha256(path) {
39
+ const hashed = await hashFile(path);
40
+ return hashed?.sha256 ?? null;
9
41
  }
@@ -26,13 +26,107 @@ export function pushSlug(raw) {
26
26
  .replace(/-+$/g, '');
27
27
  }
28
28
  export const MAX_PUSH_PATH_SEGMENTS = 8;
29
+ /**
30
+ * Connected-section root used when a Unity PNG sits at the scan root
31
+ * (`Assets/hero.png`) with no parent folder to mirror.
32
+ */
33
+ export const GAME_IMPORT_ROOT_FOLDER = 'Game';
34
+ /**
35
+ * Unity/Godot/GameMaker layout: parent dirs become library folders (flagged
36
+ * Sync to Unity → Connected), and the PNG is a one-artboard document.
37
+ * `Sprites/hero.png` → folder Sprites / doc hero — not a root "Sprites"
38
+ * document, which would land in Files and leave Connected empty.
39
+ */
40
+ export function gameImportAdoptPath(segments) {
41
+ const parts = segments.filter(Boolean);
42
+ const file = parts[parts.length - 1] ?? '';
43
+ let folders = parts.length > 1 ? parts.slice(0, -1) : [GAME_IMPORT_ROOT_FOLDER];
44
+ const maxFolders = MAX_PUSH_PATH_SEGMENTS - 2;
45
+ if (folders.length > maxFolders)
46
+ folders = folders.slice(-maxFolders);
47
+ const pathNames = [...folders, file, file];
48
+ return { path: pathNames.map(pushSlug), pathNames, name: file };
49
+ }
50
+ /** Disk path relative to outDir for a Unity-imported PNG (`sprites/hero/hero.png`). */
51
+ export function gameImportDiskRel(segments) {
52
+ return `${gameImportAdoptPath(segments).path.join('/')}.png`;
53
+ }
54
+ /**
55
+ * Bytes last known on disk for skip decisions. Cloud `sha256` is the conflict
56
+ * baseline, not the original game PNG — connected sprites must not treat a
57
+ * composite mismatch as a local edit.
58
+ */
59
+ export function lastPushedDiskSha(known, opts = {}) {
60
+ if (known.diskSha256)
61
+ return known.diskSha256;
62
+ if (known.sourceRel || opts.connected)
63
+ return undefined;
64
+ return known.sha256;
65
+ }
66
+ export function indexSyncedBySourceRel(synced) {
67
+ const map = new Map();
68
+ for (const [key, sprite] of Object.entries(synced)) {
69
+ if (sprite.sourceRel)
70
+ map.set(sprite.sourceRel, key);
71
+ }
72
+ return map;
73
+ }
74
+ /** State key for a candidate: exact key, else the row that still points at this game path. */
75
+ export function resolveSyncedKey(candidate, synced, byRel = indexSyncedBySourceRel(synced)) {
76
+ if (synced[candidate.key])
77
+ return candidate.key;
78
+ if (candidate.sourceRel)
79
+ return byRel.get(candidate.sourceRel);
80
+ return undefined;
81
+ }
82
+ /**
83
+ * Record on-disk sha256 onto synced rows so the next push can skip without
84
+ * comparing against a cloud composite. Only updates keys already in state.
85
+ */
86
+ export function applyDiskFingerprints(synced, fingerprints) {
87
+ const byRel = indexSyncedBySourceRel(synced);
88
+ let next = null;
89
+ for (const fp of fingerprints) {
90
+ const src = next ?? synced;
91
+ const stateKey = resolveSyncedKey(fp, src, byRel);
92
+ if (!stateKey)
93
+ continue;
94
+ const known = src[stateKey];
95
+ if (!known)
96
+ continue;
97
+ if (known.diskSha256 === fp.diskSha256 &&
98
+ (fp.diskMtimeMs == null || known.diskMtimeMs === fp.diskMtimeMs) &&
99
+ (fp.diskSize == null || known.diskSize === fp.diskSize)) {
100
+ continue;
101
+ }
102
+ if (!next)
103
+ next = { ...synced };
104
+ next[stateKey] = {
105
+ ...known,
106
+ diskSha256: fp.diskSha256,
107
+ ...(fp.diskMtimeMs != null ? { diskMtimeMs: fp.diskMtimeMs } : {}),
108
+ ...(fp.diskSize != null ? { diskSize: fp.diskSize } : {}),
109
+ };
110
+ }
111
+ return next ? { next, changed: true } : { next: synced, changed: false };
112
+ }
29
113
  export function planPush(candidates, synced, opts = {}) {
30
114
  const state = synced ?? {};
115
+ const byRel = indexSyncedBySourceRel(state);
31
116
  const out = [];
32
117
  for (const c of candidates) {
33
- const known = state[c.key];
118
+ const stateKey = resolveSyncedKey(c, state, byRel);
119
+ const known = stateKey ? state[stateKey] : undefined;
34
120
  if (known) {
35
- if (known.sha256 === c.diskSha256) {
121
+ const connected = Boolean(c.sourceRel || opts.folderTreeKeys?.has(c.key) || (stateKey != null && opts.folderTreeKeys?.has(stateKey)));
122
+ const lastDisk = lastPushedDiskSha(known, { connected });
123
+ if (lastDisk === c.diskSha256) {
124
+ out.push({ kind: 'skip', key: c.key, reason: 'unchanged' });
125
+ continue;
126
+ }
127
+ if (lastDisk === undefined) {
128
+ // Connected original with no disk baseline yet. Ingest's composite
129
+ // sha is not the game file — seeding happens in `applyDiskFingerprints`.
36
130
  out.push({ kind: 'skip', key: c.key, reason: 'unchanged' });
37
131
  continue;
38
132
  }
@@ -55,8 +149,18 @@ export function planPush(candidates, synced, opts = {}) {
55
149
  });
56
150
  continue;
57
151
  }
58
- // New on disk → adopt. A single segment (outDir/hero.png) is a document
59
- // with one artboard of the same name — the server already accepts that.
152
+ // New on disk → adopt. Game-tree PNGs keep their folders so they show
153
+ // up under Connected. MagicPixel's own `doc/artboard.png` layout stays
154
+ // a two-segment path (document + artboard at library root / in-folder).
155
+ if (opts.folderTreeKeys?.has(c.key)) {
156
+ const adopted = gameImportAdoptPath(c.segments);
157
+ if (adopted.path.length < 2 || adopted.path.some((s) => !s)) {
158
+ out.push({ kind: 'skip', key: c.key, reason: 'unusable-path' });
159
+ continue;
160
+ }
161
+ out.push({ kind: 'adopt', key: c.key, ...adopted });
162
+ continue;
163
+ }
60
164
  const rawNames = c.segments.slice(-MAX_PUSH_PATH_SEGMENTS);
61
165
  let pathNames = rawNames;
62
166
  let path = pathNames.map(pushSlug);
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { lstat, realpath } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
4
  /** Max PNG payload per asset (64 MiB). */
4
5
  export const MAX_ASSET_BYTES = 64 * 1024 * 1024;
5
6
  const SHA256_RE = /^[a-f0-9]{64}$/i;
@@ -128,12 +129,101 @@ export async function readBodyWithLimit(res, maxBytes) {
128
129
  }
129
130
  /** Max length of a user-supplied picomatch glob; shared with config.ts. */
130
131
  export const MAX_GLOB_LEN = 256;
132
+ /** Max cwd-relative game PNG path persisted or written back. */
133
+ export const MAX_SOURCE_REL = 512;
134
+ /**
135
+ * Game write-back path must be a relative PNG with no `..` / empty / absolute
136
+ * segments. Rejects `Assets/../package.json`-style tricks that `resolve()`
137
+ * would otherwise normalize back inside the project.
138
+ */
139
+ export function sanitizeSourceRel(rel) {
140
+ if (!rel || typeof rel !== 'string')
141
+ return null;
142
+ const g = rel.trim().replace(/\\/g, '/').replace(/^\.\//, '');
143
+ if (!g || g.includes('\0') || g.length > MAX_SOURCE_REL)
144
+ return null;
145
+ if (isAbsolute(g) || /^[a-zA-Z]:/.test(g))
146
+ return null;
147
+ const parts = g.split('/');
148
+ if (parts.some((s) => s === '' || s === '.' || s === '..'))
149
+ return null;
150
+ if (!g.toLowerCase().endsWith('.png'))
151
+ return null;
152
+ return g;
153
+ }
154
+ /**
155
+ * Refuse to read or write through a symlink (or a parent symlink that
156
+ * realpath-escapes the project). Closes the window where a `.png` is swapped
157
+ * for a link after the index walk skipped `isSymbolicLink()`.
158
+ */
159
+ export async function assertSafeIoPath(targetPath, rootDir, opts = {}) {
160
+ const label = opts.label ?? 'project';
161
+ const verb = opts.forWrite ? 'write' : 'read';
162
+ const root = resolve(rootDir);
163
+ const target = resolve(targetPath);
164
+ assertPathInsideRoot(target, root, label);
165
+ // Compare symlink-resolved paths against the real project root. On macOS
166
+ // `tmpdir()` is `/var/folders` → `/private/var/folders`; lexical `resolve`
167
+ // alone would reject a realpath that is still the same directory.
168
+ let rootReal = root;
169
+ try {
170
+ rootReal = await realpath(root);
171
+ }
172
+ catch {
173
+ // Root missing — lexical check above already ran.
174
+ }
175
+ let st;
176
+ try {
177
+ st = await lstat(target);
178
+ }
179
+ catch (e) {
180
+ if (e.code !== 'ENOENT')
181
+ throw e;
182
+ st = null;
183
+ }
184
+ if (st) {
185
+ if (st.isSymbolicLink()) {
186
+ throw new Error(`refusing to ${verb} through a symlink (${relative(process.cwd(), target) || target}).\n` +
187
+ ` Fix: replace the symlink with a regular .png inside the project.`);
188
+ }
189
+ if (!st.isFile()) {
190
+ throw new Error(`refusing to ${verb} a non-file (${relative(process.cwd(), target) || target}).`);
191
+ }
192
+ const real = await realpath(target);
193
+ assertPathInsideRoot(real, rootReal, label);
194
+ return;
195
+ }
196
+ let dir = dirname(target);
197
+ while (true) {
198
+ try {
199
+ const realDir = await realpath(dir);
200
+ const rest = relative(dir, target);
201
+ if (rest === '' || rest.startsWith('..') || isAbsolute(rest)) {
202
+ throw new Error(`refusing to ${verb} outside ${label}.`);
203
+ }
204
+ assertPathInsideRoot(resolve(realDir, rest), rootReal, label);
205
+ return;
206
+ }
207
+ catch (e) {
208
+ if (e.code !== 'ENOENT')
209
+ throw e;
210
+ const parent = dirname(dir);
211
+ if (parent === dir) {
212
+ throw new Error(`refusing to ${verb}: could not resolve parent of ${target}.`);
213
+ }
214
+ dir = parent;
215
+ }
216
+ }
217
+ }
131
218
  /** Validate a user-supplied picomatch glob from the CLI. */
132
219
  export function assertSafeGlob(glob) {
133
220
  const g = glob.trim();
134
221
  if (!g || g.length > MAX_GLOB_LEN || g.includes('\0')) {
135
222
  throw new Error(`invalid glob pattern (empty, too long, or contains null bytes).`);
136
223
  }
224
+ if (g.split(/[/\\]/).some((seg) => seg === '..')) {
225
+ throw new Error(`invalid glob pattern (must not contain "..").`);
226
+ }
137
227
  return g;
138
228
  }
139
229
  /**
@@ -0,0 +1,76 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { assetDiskPathFromKey } from './paths.js';
3
+ import { assertPathInsideRoot, sanitizeSourceRel } from './security.js';
4
+ export { sanitizeSourceRel } from './security.js';
5
+ /** Resolve a cwd-relative game path; refuse anything that escapes the project. */
6
+ export function resolveProjectRel(rel, cwd = process.cwd()) {
7
+ const safe = sanitizeSourceRel(rel);
8
+ if (!safe) {
9
+ throw new Error(`refusing unsafe game path (${rel}).\n` +
10
+ ` Fix: sourceRel must be a relative .png inside the project, with no "..".`);
11
+ }
12
+ const abs = resolve(cwd, safe);
13
+ assertPathInsideRoot(abs, resolve(cwd), 'project');
14
+ return abs;
15
+ }
16
+ /**
17
+ * Write-back map from the live connect index. Persisted `sourceRel` is only
18
+ * used as a cloud-key alias when that path is still in the working set —
19
+ * a hand-edited state.json cannot invent a new target file.
20
+ */
21
+ export function collectSourceRelMap(connected, synced) {
22
+ const map = new Map();
23
+ const liveRels = new Set();
24
+ for (const e of connected) {
25
+ const rel = sanitizeSourceRel(e.sourceRel);
26
+ if (!rel)
27
+ continue;
28
+ map.set(e.key, rel);
29
+ liveRels.add(rel);
30
+ }
31
+ if (synced) {
32
+ for (const [key, sprite] of Object.entries(synced)) {
33
+ const rel = sanitizeSourceRel(sprite.sourceRel);
34
+ if (rel && liveRels.has(rel))
35
+ map.set(key, rel);
36
+ }
37
+ }
38
+ return map;
39
+ }
40
+ /**
41
+ * When the cloud assigned a different composite key than the index key,
42
+ * push under the cloud key so we update instead of adopting a duplicate.
43
+ */
44
+ export function remapAbsToCloudKeys(absByKey, connected, synced) {
45
+ if (!synced)
46
+ return;
47
+ const relToLiveKey = new Map();
48
+ for (const e of connected) {
49
+ const rel = sanitizeSourceRel(e.sourceRel);
50
+ if (rel)
51
+ relToLiveKey.set(rel, e.key);
52
+ }
53
+ for (const [cloudKey, sprite] of Object.entries(synced)) {
54
+ const rel = sanitizeSourceRel(sprite.sourceRel);
55
+ if (!rel)
56
+ continue;
57
+ const liveKey = relToLiveKey.get(rel);
58
+ if (!liveKey || liveKey === cloudKey)
59
+ continue;
60
+ const abs = absByKey.get(liveKey) ?? absByKey.get(cloudKey);
61
+ if (!abs)
62
+ continue;
63
+ absByKey.set(cloudKey, abs);
64
+ absByKey.delete(liveKey);
65
+ }
66
+ }
67
+ export function syncDiskPathFromKey(outDir, key, sourceByKey, cwd = process.cwd()) {
68
+ const rel = sourceByKey.get(key);
69
+ if (rel)
70
+ return resolveProjectRel(rel, cwd);
71
+ return assetDiskPathFromKey(outDir, key, cwd);
72
+ }
73
+ export function isPathInside(target, root) {
74
+ const rel = relative(resolve(root), resolve(target));
75
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {