@magicpixelart/cli 0.5.7 → 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.
@@ -0,0 +1,177 @@
1
+ /**
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.
5
+ */
6
+ import { existsSync } from 'node:fs';
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
+ /** Unity/engine folders that are never sprite sources. */
15
+ export const GAME_SCAN_SKIP_DIRS = new Set([
16
+ 'library',
17
+ 'temp',
18
+ 'obj',
19
+ 'logs',
20
+ 'packages',
21
+ 'projectsettings',
22
+ 'usersettings',
23
+ 'streamingassets',
24
+ 'node_modules',
25
+ 'magicpixel',
26
+ ]);
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
+ }
54
+ export function gameScanRoot(kind) {
55
+ switch (kind) {
56
+ case 'Unity':
57
+ return 'Assets';
58
+ case 'Godot':
59
+ return 'assets';
60
+ case 'GameMaker':
61
+ return 'datafiles';
62
+ default:
63
+ return null;
64
+ }
65
+ }
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 };
100
+ if (!isEngineKind(kind))
101
+ return empty;
102
+ const scanRoot = resolve(cwd);
103
+ if (!existsSync(scanRoot))
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;
108
+ const found = [];
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 = [];
116
+ for (const abs of found) {
117
+ const entry = toEntry(abs, scanRoot, assetRoot);
118
+ if (entry)
119
+ files.push(entry);
120
+ }
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));
138
+ }
139
+ async function walkPngs(dir, root, destNorm, out, limit, io) {
140
+ if (out.length >= limit)
141
+ return;
142
+ let entries;
143
+ try {
144
+ entries = await readdir(dir, { withFileTypes: true });
145
+ }
146
+ catch {
147
+ return;
148
+ }
149
+ entries.sort((a, b) => a.name.localeCompare(b.name));
150
+ const subdirs = [];
151
+ for (const ent of entries) {
152
+ if (out.length >= limit)
153
+ return;
154
+ if (ent.isSymbolicLink())
155
+ continue;
156
+ const full = resolve(dir, ent.name);
157
+ try {
158
+ assertPathInsideRoot(full, root, 'scan');
159
+ }
160
+ catch {
161
+ continue;
162
+ }
163
+ if (ent.isDirectory()) {
164
+ if (shouldSkipDir(ent.name))
165
+ continue;
166
+ if (destNorm && full.replace(/\\/g, '/').toLowerCase() === destNorm)
167
+ continue;
168
+ subdirs.push(full);
169
+ }
170
+ else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
171
+ out.push(full);
172
+ }
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))));
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,9 +149,25 @@ export function planPush(candidates, synced, opts = {}) {
55
149
  });
56
150
  continue;
57
151
  }
58
- // New on disk → adopt. Needs at least a document + artboard segment.
59
- const pathNames = c.segments.slice(-MAX_PUSH_PATH_SEGMENTS);
60
- const path = pathNames.map(pushSlug);
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
+ }
164
+ const rawNames = c.segments.slice(-MAX_PUSH_PATH_SEGMENTS);
165
+ let pathNames = rawNames;
166
+ let path = pathNames.map(pushSlug);
167
+ if (path.length === 1 && path[0]) {
168
+ pathNames = [pathNames[0], pathNames[0]];
169
+ path = [path[0], path[0]];
170
+ }
61
171
  if (path.length < 2 || path.some((s) => !s)) {
62
172
  out.push({ kind: 'skip', key: c.key, reason: 'unusable-path' });
63
173
  continue;
@@ -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.7",
3
+ "version": "0.5.9",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {