@magicpixelart/cli 0.5.13 → 0.5.20
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 +101 -0
- package/README.md +9 -8
- package/dist/api.js +124 -8
- package/dist/commands/connect.js +10 -11
- package/dist/commands/init.js +3 -0
- package/dist/commands/push.js +89 -47
- package/dist/commands/start.js +2 -3
- package/dist/commands/sync.js +331 -84
- package/dist/index.js +3 -3
- package/dist/util/engineConnect.js +32 -0
- package/dist/util/gameScan.js +12 -1
- package/dist/util/localEdit.js +29 -0
- package/dist/util/pullDecision.js +45 -0
- package/dist/util/reconcile.js +23 -0
- package/dist/util/security.js +3 -1
- package/dist/util/syncPath.js +31 -0
- package/dist/util/unityFilter.js +54 -2
- package/dist/util/watchCopy.js +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -83,11 +83,11 @@ program
|
|
|
83
83
|
.action(wrap("repair", async (opts) => repairCommand(opts)));
|
|
84
84
|
program
|
|
85
85
|
.command('sync')
|
|
86
|
-
.description('
|
|
86
|
+
.description('Two-way sync: pull MagicPixel edits, push your game sprites')
|
|
87
87
|
.option('--no-prune', 'Keep local files not in the manifest (pruning is now on by default)')
|
|
88
88
|
.option('--dry-run', 'Print the plan without writing files')
|
|
89
89
|
.option('--full', 'Ignore lastSync state; re-fetch the full manifest')
|
|
90
|
-
.option('-w, --watch [seconds]', 'Poll for changes (default 2s; auto-slows to 5s after ~
|
|
90
|
+
.option('-w, --watch [seconds]', 'Poll for changes (default 2s; auto-slows to 5s after ~1min idle, 10s after ~5min)', parseWatchInterval)
|
|
91
91
|
.option('-q, --quiet', 'Minimal output (for CI)')
|
|
92
92
|
.option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
|
|
93
93
|
.addHelpText('after', '\nExamples:\n $ magicpixel sync # incremental sync\n $ magicpixel sync --full # ignore lastSync, re-check everything\n $ magicpixel sync -w # watch mode (2s; adaptive idle backoff; exit 2 after 5 auth failures)\n')
|
|
@@ -101,7 +101,7 @@ program
|
|
|
101
101
|
.action(wrap("push", async (opts) => pushCommand(opts)));
|
|
102
102
|
program
|
|
103
103
|
.command('connect <glob>')
|
|
104
|
-
.description('
|
|
104
|
+
.description('Limit which game folders sync (default is all sprites)')
|
|
105
105
|
.addHelpText('after', '\nExamples:\n $ magicpixel connect \'assets/Sprites/Hero/**\'\n $ magicpixel connect Runtime/UI/hud.png\n')
|
|
106
106
|
.action(wrap("connect", async (glob) => connectCommand(glob)));
|
|
107
107
|
program
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { saveConfig } from '../config.js';
|
|
2
|
+
import { detectProjectKind, isEngineKind } from './framework.js';
|
|
3
|
+
export const DEFAULT_ENGINE_CONNECT = ['**'];
|
|
4
|
+
const ALL_SPRITES_GLOBS = new Set(['**', '**/*', '**/**', '**/*.png', '**/**/*.png']);
|
|
5
|
+
export function isAllSpritesGlob(glob) {
|
|
6
|
+
return ALL_SPRITES_GLOBS.has(glob.trim());
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Next `connect` list after the user adds a glob.
|
|
10
|
+
* A specific folder replaces a default `**` (narrowing). `**` restores all
|
|
11
|
+
* sprites. Other globs accumulate. Stray `**` left over from an earlier
|
|
12
|
+
* default is dropped when narrowing so OR-matching cannot keep everything.
|
|
13
|
+
*/
|
|
14
|
+
export function nextConnectGlobs(current, pattern) {
|
|
15
|
+
if (isAllSpritesGlob(pattern))
|
|
16
|
+
return [...DEFAULT_ENGINE_CONNECT];
|
|
17
|
+
const withoutAll = current.filter((g) => !isAllSpritesGlob(g));
|
|
18
|
+
if (withoutAll.includes(pattern))
|
|
19
|
+
return withoutAll;
|
|
20
|
+
return [...withoutAll, pattern];
|
|
21
|
+
}
|
|
22
|
+
export async function ensureEngineConnect(config, cwd = process.cwd(), persist = true) {
|
|
23
|
+
if (config.connect.length > 0)
|
|
24
|
+
return config;
|
|
25
|
+
const kind = await detectProjectKind(cwd);
|
|
26
|
+
if (!isEngineKind(kind))
|
|
27
|
+
return config;
|
|
28
|
+
const next = { ...config, connect: [...DEFAULT_ENGINE_CONNECT] };
|
|
29
|
+
if (persist)
|
|
30
|
+
await saveConfig(next, cwd);
|
|
31
|
+
return next;
|
|
32
|
+
}
|
package/dist/util/gameScan.js
CHANGED
|
@@ -36,6 +36,7 @@ export const GAME_SCAN_SKIP_HIDDEN = new Set([
|
|
|
36
36
|
'.git',
|
|
37
37
|
'.svn',
|
|
38
38
|
'.hg',
|
|
39
|
+
'.godot',
|
|
39
40
|
'.magicpixel',
|
|
40
41
|
'.vs',
|
|
41
42
|
'.vscode',
|
|
@@ -138,7 +139,7 @@ function bindScanProgress(onProgress) {
|
|
|
138
139
|
lastAt = Date.now();
|
|
139
140
|
onProgress(p);
|
|
140
141
|
};
|
|
141
|
-
|
|
142
|
+
const visit = (pngs, folders, current) => {
|
|
142
143
|
const next = { pngs, folders, current };
|
|
143
144
|
const now = Date.now();
|
|
144
145
|
if (lastAt === 0 || now - lastAt >= 80) {
|
|
@@ -159,6 +160,15 @@ function bindScanProgress(onProgress) {
|
|
|
159
160
|
timer.unref();
|
|
160
161
|
}
|
|
161
162
|
};
|
|
163
|
+
visit.flush = () => {
|
|
164
|
+
if (timer) {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
timer = null;
|
|
167
|
+
}
|
|
168
|
+
if (pending)
|
|
169
|
+
flush(pending);
|
|
170
|
+
};
|
|
171
|
+
return visit;
|
|
162
172
|
}
|
|
163
173
|
function samePath(a, b) {
|
|
164
174
|
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase();
|
|
@@ -183,6 +193,7 @@ export async function indexGamePngs(kind, cwd = process.cwd(), outDir = '', opts
|
|
|
183
193
|
const startAt = isUnityGameProject(cwd, assetRoot) ? assetRoot : scanRoot;
|
|
184
194
|
const preferFirst = startAt === scanRoot ? assetRoot : null;
|
|
185
195
|
await walkPngs(startAt, scanRoot, destNorm, found, MAX_GAME_INDEX + 1, io, report, stats, preferFirst);
|
|
196
|
+
report.flush?.();
|
|
186
197
|
opts.onProgress?.({ pngs: found.length, folders: stats.folders });
|
|
187
198
|
found.sort((a, b) => a.localeCompare(b));
|
|
188
199
|
const capped = found.length > MAX_GAME_INDEX;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Was this PNG edited in the game project since we last wrote or pushed it?"
|
|
3
|
+
*
|
|
4
|
+
* The single definition of a local edit, used by two callers:
|
|
5
|
+
*
|
|
6
|
+
* - `pullDecision` — an entry still in the manifest whose disk bytes diverged
|
|
7
|
+
* is a conflict, not a download target.
|
|
8
|
+
* - the prune sweep in `sync` — a path that LEFT the manifest (deselected
|
|
9
|
+
* "Sync to Unity", trashed document, rename/previous-key cruft) must not be
|
|
10
|
+
* deleted while it holds work MagicPixel has never seen.
|
|
11
|
+
*
|
|
12
|
+
* Without a recorded baseline (`state.synced[key].diskSha256`) we can't tell an
|
|
13
|
+
* edit from a file we never wrote, so we report "no edit" and let the caller's
|
|
14
|
+
* existing rules apply unchanged.
|
|
15
|
+
*/
|
|
16
|
+
import { fileSha256 } from './hash.js';
|
|
17
|
+
/** Pure comparison — no filesystem access, safe for decision unit tests. */
|
|
18
|
+
export function isLocalEdit(baselineDiskSha256, localSha256) {
|
|
19
|
+
if (!baselineDiskSha256 || !localSha256)
|
|
20
|
+
return false;
|
|
21
|
+
return baselineDiskSha256 !== localSha256;
|
|
22
|
+
}
|
|
23
|
+
/** Hashes `absPath` only when a baseline exists (nothing else can be an edit). */
|
|
24
|
+
export async function hasUnpushedLocalEdit({ absPath, baselineDiskSha256, knownSha256, }) {
|
|
25
|
+
if (!baselineDiskSha256)
|
|
26
|
+
return false;
|
|
27
|
+
const sha = knownSha256 !== undefined ? knownSha256 : await fileSha256(absPath);
|
|
28
|
+
return isLocalEdit(baselineDiskSha256, sha);
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a manifest entry should be written to disk.
|
|
3
|
+
*
|
|
4
|
+
* Connected game PNGs are usually *not* the same bytes as MagicPixel's
|
|
5
|
+
* re-encoded composite. Comparing those hashes would re-download the whole
|
|
6
|
+
* library on every catch-up and overwrite the originals. Pull only when the
|
|
7
|
+
* cloud composite actually changed since we last recorded it, or when there
|
|
8
|
+
* is no local file to protect.
|
|
9
|
+
*
|
|
10
|
+
* Third outcome: `conflict`. `sync` pulls before it pushes, so a PNG edited on
|
|
11
|
+
* disk *and* in MagicPixel since the last sync would be overwritten before
|
|
12
|
+
* `push` ever sees it — the local edit would vanish with no message. When the
|
|
13
|
+
* disk bytes diverge from the fingerprint we recorded when we last wrote/pushed
|
|
14
|
+
* that file, the entry is reported instead of downloaded.
|
|
15
|
+
*/
|
|
16
|
+
import { isLocalEdit } from './localEdit.js';
|
|
17
|
+
export function decidePull(opts) {
|
|
18
|
+
const cloud = opts.cloudSha256 ?? null;
|
|
19
|
+
const local = opts.localSha256;
|
|
20
|
+
const prev = opts.previousCloudSha256;
|
|
21
|
+
// Missing on disk → restore from cloud (MagicPixel-only art and deletions).
|
|
22
|
+
if (!local)
|
|
23
|
+
return 'pull';
|
|
24
|
+
if (cloud && cloud === local)
|
|
25
|
+
return 'skip';
|
|
26
|
+
// Cloud composite unchanged since last pull/push — keep the original PNG.
|
|
27
|
+
if (cloud && prev && cloud === prev)
|
|
28
|
+
return 'skip';
|
|
29
|
+
if (opts.inWorkingSet) {
|
|
30
|
+
// Never recorded a cloud hash: disk is the source of truth (connect/ingest).
|
|
31
|
+
if (!prev)
|
|
32
|
+
return 'skip';
|
|
33
|
+
// Editor save often nulls the cached hash; treat that as a cloud change.
|
|
34
|
+
return localEdit(opts) ? 'conflict' : 'pull';
|
|
35
|
+
}
|
|
36
|
+
return localEdit(opts) ? 'conflict' : 'pull';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Disk bytes changed since we recorded them → someone edited the PNG in the
|
|
40
|
+
* game project. Without a recorded baseline we can't tell an edit from a file
|
|
41
|
+
* we never wrote, so we stay out of the way and let the pull proceed.
|
|
42
|
+
*/
|
|
43
|
+
function localEdit(opts) {
|
|
44
|
+
return isLocalEdit(opts.lastPushedDiskSha256, opts.localSha256);
|
|
45
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When to promote an incremental sync to a full reconcile.
|
|
3
|
+
*
|
|
4
|
+
* `removed_keys` can only describe rows that still exist (trashed /
|
|
5
|
+
* componentized). A permanent delete ("Empty Trash") leaves nothing to report,
|
|
6
|
+
* so an incremental client keeps the PNG in the game project indefinitely. A
|
|
7
|
+
* periodic full pass — the manifest itself is the truth there — closes that
|
|
8
|
+
* without tombstone tables or per-tick cost.
|
|
9
|
+
*/
|
|
10
|
+
/** 15 minutes: bounded orphan lifetime, ~4 full manifests/hour worst case. */
|
|
11
|
+
export const RECONCILE_INTERVAL_MS = 15 * 60 * 1000;
|
|
12
|
+
export function shouldReconcile(lastReconcile, nowMs, intervalMs = RECONCILE_INTERVAL_MS) {
|
|
13
|
+
if (!lastReconcile)
|
|
14
|
+
return true;
|
|
15
|
+
const then = Date.parse(lastReconcile);
|
|
16
|
+
if (!Number.isFinite(then))
|
|
17
|
+
return true;
|
|
18
|
+
// Clock moved backwards (machine sleep / NTP step): reconcile rather than
|
|
19
|
+
// waiting out a bogus future timestamp.
|
|
20
|
+
if (then > nowMs)
|
|
21
|
+
return true;
|
|
22
|
+
return nowMs - then >= intervalMs;
|
|
23
|
+
}
|
package/dist/util/security.js
CHANGED
|
@@ -96,7 +96,9 @@ export async function safeFetch(url, init = {}) {
|
|
|
96
96
|
let current = url;
|
|
97
97
|
for (let hop = 0; hop < 5; hop++) {
|
|
98
98
|
const res = await fetch(current, { ...init, redirect: 'manual' });
|
|
99
|
-
|
|
99
|
+
// 304 lives in the 3xx range but is a conditional-request answer, not a
|
|
100
|
+
// redirect: it carries no Location and callers handle it directly.
|
|
101
|
+
if (res.status < 300 || res.status >= 400 || res.status === 304)
|
|
100
102
|
return res;
|
|
101
103
|
const location = res.headers.get('location');
|
|
102
104
|
await res.body?.cancel();
|
package/dist/util/syncPath.js
CHANGED
|
@@ -37,6 +37,37 @@ export function collectSourceRelMap(connected, synced) {
|
|
|
37
37
|
}
|
|
38
38
|
return map;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Ingest appends `-2`, `-3`, … to the *document* slug when the library already
|
|
42
|
+
* has that name. The live connect index still uses the unsuffixed key, so a
|
|
43
|
+
* catch-up pull would treat `ground-ice-snow-4/…` as new MagicPixel art and
|
|
44
|
+
* dump thousands of copies into outDir. Alias those keys onto the original
|
|
45
|
+
* PNG when the unsuffixed key is already in the working set.
|
|
46
|
+
*/
|
|
47
|
+
export function stripDocCollisionSuffix(key) {
|
|
48
|
+
const parts = key.split('/');
|
|
49
|
+
if (parts.length < 2)
|
|
50
|
+
return key;
|
|
51
|
+
const docIdx = parts.length - 2;
|
|
52
|
+
const stripped = parts[docIdx].replace(/-(\d+)$/, '');
|
|
53
|
+
if (stripped === parts[docIdx])
|
|
54
|
+
return key;
|
|
55
|
+
const next = parts.slice();
|
|
56
|
+
next[docIdx] = stripped;
|
|
57
|
+
return next.join('/');
|
|
58
|
+
}
|
|
59
|
+
export function aliasCollisionKeys(sourceByKey, manifestKeys) {
|
|
60
|
+
for (const key of manifestKeys) {
|
|
61
|
+
if (sourceByKey.has(key))
|
|
62
|
+
continue;
|
|
63
|
+
const base = stripDocCollisionSuffix(key);
|
|
64
|
+
if (base === key)
|
|
65
|
+
continue;
|
|
66
|
+
const rel = sourceByKey.get(base);
|
|
67
|
+
if (rel)
|
|
68
|
+
sourceByKey.set(key, rel);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
40
71
|
/**
|
|
41
72
|
* When the cloud assigned a different composite key than the index key,
|
|
42
73
|
* push under the cloud key so we update instead of adopting a duplicate.
|
package/dist/util/unityFilter.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-artboard Unity sync opt-in (strict).
|
|
2
|
+
* Per-artboard Unity sync opt-in (strict), with a working-set override.
|
|
3
3
|
*
|
|
4
4
|
* In the editor, each artboard has a "Sync to Unity" checkbox (and library
|
|
5
5
|
* folders have a folder-level switch that cascades). A project may hold 100
|
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
* Strict opt-in: `unity === true` syncs, anything else does not. A missing
|
|
10
10
|
* flag means the server didn't say (older edge deploy, or a budget-starved
|
|
11
11
|
* manifest backfill) — those entries are excluded and reported via
|
|
12
|
-
* `
|
|
12
|
+
* `unknown` so the caller can warn instead of silently syncing the whole
|
|
13
13
|
* library into someone's game project.
|
|
14
|
+
*
|
|
15
|
+
* Exception: sprites already in the game (connect working set / previously
|
|
16
|
+
* synced keys) always pull. An editor save nulls `artboard_index` and the
|
|
17
|
+
* next manifest often omits `unity` (or rebuilds it as false because the
|
|
18
|
+
* layer JSON dropped `syncToUnity`). Those saves must still write back to
|
|
19
|
+
* the original PNG.
|
|
14
20
|
*/
|
|
15
21
|
export function filterUnityManifest(manifest, opts = {}) {
|
|
16
22
|
if (opts.syncAll)
|
|
@@ -23,3 +29,49 @@ export function filterUnityManifest(manifest, opts = {}) {
|
|
|
23
29
|
unknown,
|
|
24
30
|
};
|
|
25
31
|
}
|
|
32
|
+
/** Keys we already write to on disk — connect globs plus those sprites' cloud aliases. */
|
|
33
|
+
export function workingSetPullKeys(sourceByKey, synced) {
|
|
34
|
+
const keys = new Set(sourceByKey.keys());
|
|
35
|
+
const assetIds = new Set();
|
|
36
|
+
if (synced) {
|
|
37
|
+
for (const [key, sprite] of Object.entries(synced)) {
|
|
38
|
+
if (!keys.has(key))
|
|
39
|
+
continue;
|
|
40
|
+
if (sprite.assetId)
|
|
41
|
+
assetIds.add(sprite.assetId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { keys, assetIds };
|
|
45
|
+
}
|
|
46
|
+
export function isWorkingSetEntry(entry, pull) {
|
|
47
|
+
if (entry.key && pull.keys.has(entry.key))
|
|
48
|
+
return true;
|
|
49
|
+
if (entry.previous_keys?.some((k) => pull.keys.has(k)))
|
|
50
|
+
return true;
|
|
51
|
+
if (entry.asset_id && pull.assetIds.has(entry.asset_id)) {
|
|
52
|
+
// Same document as a connected sprite. Post-save fallback (flag omitted)
|
|
53
|
+
// must still pull; explicitly unflagged sibling artboards must not dump
|
|
54
|
+
// into outDir.
|
|
55
|
+
return entry.unity !== false;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Flagged artboards, plus any working-set sprite whose Unity flag is missing
|
|
61
|
+
* or false after an editor save / index rebuild.
|
|
62
|
+
*/
|
|
63
|
+
export function applyUnityPullPolicy(manifest, opts = {}) {
|
|
64
|
+
const filtered = filterUnityManifest(manifest, { syncAll: opts.syncAll });
|
|
65
|
+
if (opts.syncAll || !opts.alwaysPull)
|
|
66
|
+
return filtered;
|
|
67
|
+
const already = new Set(filtered.entries);
|
|
68
|
+
const extra = manifest.filter((e) => !already.has(e) && opts.alwaysPull(e));
|
|
69
|
+
if (extra.length === 0)
|
|
70
|
+
return filtered;
|
|
71
|
+
const extraSet = new Set(extra);
|
|
72
|
+
return {
|
|
73
|
+
entries: filtered.entries.concat(extra),
|
|
74
|
+
unknown: filtered.unknown.filter((e) => !extraSet.has(e)),
|
|
75
|
+
noneFlagged: false,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/dist/util/watchCopy.js
CHANGED
|
@@ -16,6 +16,6 @@ export function formatWatchSpriteLine(c) {
|
|
|
16
16
|
return ` Sprites: ${fmt(set)} in your game · ${fmt(pulled)} last pulled from MagicPixel`;
|
|
17
17
|
}
|
|
18
18
|
if (set > 0)
|
|
19
|
-
return ` Sprites: ${fmt(set)} in your
|
|
19
|
+
return ` Sprites: ${fmt(set)} in your game`;
|
|
20
20
|
return ` Sprites: ${fmt(pulled)}`;
|
|
21
21
|
}
|