@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/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('Download changed assets from MagicPixel')
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 ~3min idle, 10s after ~15min)', parseWatchInterval)
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('Add a game-sprite glob to the working set and ingest matching PNGs')
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
+ }
@@ -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
- return (pngs, folders, current) => {
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
+ }
@@ -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
- if (res.status < 300 || res.status >= 400)
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();
@@ -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.
@@ -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
- * `unknownFlags` so the caller can warn instead of silently syncing the whole
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
+ }
@@ -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 working set`;
19
+ return ` Sprites: ${fmt(set)} in your game`;
20
20
  return ` Sprites: ${fmt(pulled)}`;
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.13",
3
+ "version": "0.5.20",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {