@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.
- package/CHANGELOG.md +54 -0
- package/README.md +16 -4
- package/dist/api.js +17 -17
- package/dist/commands/connect.js +49 -0
- package/dist/commands/doctor.js +10 -2
- package/dist/commands/push.js +111 -32
- package/dist/commands/search.js +45 -0
- package/dist/commands/start.js +24 -6
- package/dist/commands/sync.js +91 -26
- package/dist/config.js +30 -2
- package/dist/index.js +12 -0
- package/dist/util/framework.js +57 -5
- package/dist/util/gameScan.js +117 -59
- package/dist/util/globMatch.js +50 -0
- package/dist/util/hash.js +37 -5
- package/dist/util/pushPlan.js +108 -4
- package/dist/util/security.js +91 -1
- package/dist/util/syncPath.js +76 -0
- package/package.json +1 -1
package/dist/util/gameScan.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
47
|
-
const
|
|
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
|
|
54
|
-
const destNorm =
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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 >=
|
|
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 >=
|
|
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
|
-
|
|
105
|
-
if (name.startsWith('.'))
|
|
164
|
+
if (shouldSkipDir(ent.name))
|
|
106
165
|
continue;
|
|
107
|
-
if (
|
|
166
|
+
if (destNorm && full.replace(/\\/g, '/').toLowerCase() === destNorm)
|
|
108
167
|
continue;
|
|
109
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/util/pushPlan.js
CHANGED
|
@@ -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
|
|
118
|
+
const stateKey = resolveSyncedKey(c, state, byRel);
|
|
119
|
+
const known = stateKey ? state[stateKey] : undefined;
|
|
34
120
|
if (known) {
|
|
35
|
-
|
|
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.
|
|
59
|
-
//
|
|
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);
|
package/dist/util/security.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import {
|
|
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
|
+
}
|