@magicpixelart/cli 0.5.28 → 0.5.29

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 CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.29]
11
+
12
+ ### Fixed
13
+ - Ship `magicpixel resync <folder>`: the npm 0.5.28 build was published before the command landed.
14
+
15
+ ## [0.5.28]
16
+
17
+ ### Added
18
+ - `magicpixel resync <folder>` (`--dry-run`, `--yes`): make one MagicPixel folder match the local game folder. Local files overwrite their MagicPixel copies (size follows the file); files missing locally move to Trash. Nothing is trashed if any upload fails or the game scan was capped.
19
+ - `sync --watch` runs "Resync from game…" requests queued from the library folder menu.
20
+
10
21
  ## [0.5.27] — 2026-09-21
11
22
 
12
23
  - Fixed Sync presses that changed only an artboard selection being skipped as
package/dist/api.js CHANGED
@@ -449,3 +449,29 @@ export async function pushSpritesAdaptive(sprites, retryOpts) {
449
449
  }
450
450
  }
451
451
  }
452
+ // ---------- folder resync (local folder is the source of truth) -------------
453
+ export function resolveResyncEndpoint() {
454
+ return resolveIngestEndpoint().replace(/\/ingest$/, '/resync');
455
+ }
456
+ async function postResync(body) {
457
+ const url = resolveResyncEndpoint();
458
+ return retryTransient('resync', async () => {
459
+ const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
460
+ const res = await safeFetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
461
+ const serverRequestId = res.headers.get('x-request-id') ?? requestId;
462
+ if (res.ok)
463
+ return (await res.json());
464
+ const text = await res.text();
465
+ throw new ApiError(res.status, withRequestId(friendlyApiError(res.status, text, 'resync', errorCodeFromResponse(res)), serverRequestId), serverRequestId);
466
+ });
467
+ }
468
+ export function pruneResyncFolder(folderPath, keepAssetIds, dryRun) {
469
+ return postResync({ action: 'prune', folderPath, keepAssetIds, dryRun });
470
+ }
471
+ export async function claimResyncRequest() {
472
+ const r = await postResync({ action: 'claim' });
473
+ return r.request ?? null;
474
+ }
475
+ export async function reportResyncRequest(id, status, message, counts) {
476
+ await postResync({ action: 'report', id, status, message, counts });
477
+ }
@@ -41,7 +41,11 @@ export async function runPush(opts = {}) {
41
41
  }
42
42
  export async function runPushWith(config, state, opts = {}) {
43
43
  const quiet = opts.quiet === true;
44
- const empty = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0 };
44
+ if (opts.replace)
45
+ opts = { ...opts, flatten: true };
46
+ const empty = {
47
+ created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0, keptAssetIds: [], scanned: 0,
48
+ };
45
49
  const spinner = quiet ? null : ora(countingSpritesText(0)).start();
46
50
  const kind = await detectProjectKind();
47
51
  const index = opts.gameIndex
@@ -110,7 +114,7 @@ export async function runPushWith(config, state, opts = {}) {
110
114
  const segments = entry
111
115
  ? entry.adoptRel.replace(/\.png$/i, '').split('/')
112
116
  : key.split('/');
113
- if (opts.excludeKeys?.has(key)) {
117
+ if (opts.excludeKeys?.has(key) || (opts.keyFilter && !opts.keyFilter(key))) {
114
118
  reportHash();
115
119
  return;
116
120
  }
@@ -129,7 +133,7 @@ export async function runPushWith(config, state, opts = {}) {
129
133
  if (sourceByKey.has(k))
130
134
  folderTreeKeys.add(k);
131
135
  }
132
- const actions = planPush(candidates, state.synced, { flatten: opts.flatten, folderTreeKeys });
136
+ const actions = planPush(candidates, state.synced, { flatten: opts.flatten, folderTreeKeys, replace: opts.replace });
133
137
  const skipped = actions.filter((a) => a.kind === 'skip' && a.reason !== 'legacy').length;
134
138
  const legacy = actions.filter((a) => a.kind === 'skip' && a.reason === 'legacy');
135
139
  const flattenBlocked = actions.filter((a) => a.kind === 'needs-flatten');
@@ -162,7 +166,10 @@ export async function runPushWith(config, state, opts = {}) {
162
166
  await persistDiskFingerprints(state, candidates);
163
167
  if (!quiet)
164
168
  console.log(kleur.green('✓ nothing to push.'));
165
- return { ...empty, imported: matched.entries.length, unchanged: skipped };
169
+ return {
170
+ ...empty, imported: matched.entries.length, unchanged: skipped,
171
+ keptAssetIds: collectKeptAssetIds(candidates, state.synced ?? {}, []), scanned: candidates.length,
172
+ };
166
173
  }
167
174
  if (opts.dryRun) {
168
175
  if (!quiet) {
@@ -173,7 +180,10 @@ export async function runPushWith(config, state, opts = {}) {
173
180
  }
174
181
  console.log(kleur.dim('--dry-run: nothing sent.'));
175
182
  }
176
- return { ...empty, imported: matched.entries.length };
183
+ return {
184
+ ...empty, imported: matched.entries.length,
185
+ keptAssetIds: collectKeptAssetIds(candidates, state.synced ?? {}, []), scanned: candidates.length,
186
+ };
177
187
  }
178
188
  const outRoot = resolve(process.cwd(), config.outDir);
179
189
  const shaByKey = new Map(candidates.map((c) => [c.key, c.diskSha256]));
@@ -195,10 +205,14 @@ export async function runPushWith(config, state, opts = {}) {
195
205
  layerIdx: a.layerIdx,
196
206
  baseSha256: a.baseSha256,
197
207
  flatten: opts.flatten === true,
208
+ ...(opts.replace ? { replace: true } : {}),
198
209
  });
199
210
  }
200
211
  else if (a.kind === 'adopt') {
201
- sprites.push({ key: a.key, pngBase64, diskSha256, path: a.path, pathNames: a.pathNames, name: a.name });
212
+ sprites.push({
213
+ key: a.key, pngBase64, diskSha256, path: a.path, pathNames: a.pathNames, name: a.name,
214
+ ...(opts.replace ? { replace: true } : {}),
215
+ });
202
216
  }
203
217
  }
204
218
  const progress = quiet ? null : ora(`Pushing 0/${sprites.length}…`).start();
@@ -296,7 +310,27 @@ export async function runPushWith(config, state, opts = {}) {
296
310
  }
297
311
  if (failed)
298
312
  process.exitCode = 1;
299
- return { ...counts, imported: matched.entries.length };
313
+ return {
314
+ ...counts, imported: matched.entries.length,
315
+ keptAssetIds: collectKeptAssetIds(candidates, synced, results), scanned: candidates.length,
316
+ };
317
+ }
318
+ /**
319
+ * Library documents that now mirror an in-scope local file: every successful
320
+ * push result, plus unchanged candidates already recorded in sync state.
321
+ */
322
+ export function collectKeptAssetIds(candidates, synced, results) {
323
+ const out = new Set();
324
+ for (const r of results) {
325
+ if (r.assetId && (r.status === 'created' || r.status === 'updated' || r.status === 'unchanged'))
326
+ out.add(r.assetId);
327
+ }
328
+ for (const c of candidates) {
329
+ const id = synced[c.key]?.assetId;
330
+ if (id)
331
+ out.add(id);
332
+ }
333
+ return [...out];
300
334
  }
301
335
  /**
302
336
  * Sprites to re-send under `--force`: every sprite the cloud refused because it
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `magicpixel resync <folder>` — make one MagicPixel library folder match the
3
+ * local game folder exactly:
4
+ * - every local sprite in the folder overwrites its MagicPixel copy (size
5
+ * follows the file, MagicPixel-only edits are replaced);
6
+ * - new local sprites are imported;
7
+ * - MagicPixel files in that folder with no local file go to Trash.
8
+ */
9
+ import kleur from 'kleur';
10
+ import { createInterface } from 'node:readline/promises';
11
+ import { stdin, stdout } from 'node:process';
12
+ import { loadConfig, loadState } from '../config.js';
13
+ import { ensureEngineConnect } from '../util/engineConnect.js';
14
+ import { detectProjectKind, isEngineKind } from '../util/framework.js';
15
+ import { indexGamePngs, countingSpritesText } from '../util/gameScan.js';
16
+ import { pruneResyncFolder } from '../api.js';
17
+ import { runPushWith } from './push.js';
18
+ /** Same slug rules as library folders / manifest keys. */
19
+ export function slugifySegment(input) {
20
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
21
+ }
22
+ export function parseFolderArg(folder) {
23
+ return folder
24
+ .replace(/\\/g, '/')
25
+ .split('/')
26
+ .map((s) => slugifySegment(s.trim()))
27
+ .filter(Boolean);
28
+ }
29
+ /**
30
+ * True when `key` (`<folders…>/<doc>/<artboard>`) lives inside `segments`.
31
+ * Only the folder part of the key counts, so resyncing `enemies` never touches
32
+ * a root document that happens to be named "enemies".
33
+ */
34
+ export function keyInFolder(key, segments) {
35
+ const parts = key.split('/');
36
+ const folderParts = parts.slice(0, Math.max(0, parts.length - 2));
37
+ if (folderParts.length < segments.length)
38
+ return false;
39
+ return segments.every((s, i) => folderParts[i] === s);
40
+ }
41
+ async function confirm(prompt) {
42
+ if (!stdin.isTTY)
43
+ return false;
44
+ const rl = createInterface({ input: stdin, output: stdout });
45
+ try {
46
+ const a = (await rl.question(prompt)).trim().toLowerCase();
47
+ return a === 'y' || a === 'yes';
48
+ }
49
+ finally {
50
+ rl.close();
51
+ }
52
+ }
53
+ export async function resyncCommand(folder, opts = {}) {
54
+ await runResync(parseFolderArg(folder), opts);
55
+ }
56
+ export async function runResync(segments, opts = {}) {
57
+ if (segments.length === 0) {
58
+ throw new Error('Pass a folder, e.g. `magicpixel resync Sprites/Enemies`. Resyncing the whole project is `push --force`.');
59
+ }
60
+ const log = (m) => { if (!opts.quiet)
61
+ console.log(m); };
62
+ const config = await ensureEngineConnect(await loadConfig(), process.cwd(), !opts.dryRun);
63
+ const kind = await detectProjectKind();
64
+ if (!isEngineKind(kind))
65
+ throw new Error('This folder is not a Unity/Godot/GameMaker project.');
66
+ const gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, {
67
+ onProgress: opts.quiet ? undefined : (p) => { stdout.write(`\r${countingSpritesText(p)}`); },
68
+ });
69
+ if (!opts.quiet)
70
+ stdout.write('\n');
71
+ const keyFilter = (key) => keyInFolder(key, segments);
72
+ const label = segments.join('/');
73
+ // Plan first: what would change, and what would go to Trash.
74
+ const plan = await runPushWith(config, await loadState(), {
75
+ dryRun: true, replace: true, keyFilter, gameIndex, quiet: true,
76
+ });
77
+ if (plan.scanned === 0) {
78
+ throw new Error(`No local sprites found in "${label}". Nothing was changed. Check the folder name with \`magicpixel search\`.`);
79
+ }
80
+ const pruneAllowed = !gameIndex.capped;
81
+ const preview = pruneAllowed && plan.keptAssetIds.length > 0
82
+ ? await pruneResyncFolder(segments, plan.keptAssetIds, true)
83
+ : null;
84
+ log(kleur.bold(`Resync ${label} from your game`));
85
+ log(` local sprites: ${plan.scanned}`);
86
+ if (preview) {
87
+ log(` to trash in MagicPixel (no local file): ${preview.wouldTrash.length}`);
88
+ for (const n of preview.wouldTrash.slice(0, 10))
89
+ log(kleur.dim(` - ${n}`));
90
+ if (preview.wouldTrash.length > 10)
91
+ log(kleur.dim(` …and ${preview.wouldTrash.length - 10} more`));
92
+ }
93
+ if (!pruneAllowed)
94
+ log(kleur.yellow(' ! game scan hit its file cap — nothing will be trashed.'));
95
+ log(kleur.dim(' Local files win: MagicPixel copies are overwritten and resized to match.'));
96
+ if (opts.dryRun) {
97
+ log(kleur.dim('--dry-run: nothing sent.'));
98
+ return { push: plan, trashed: 0, pruneSkipped: 'dry-run' };
99
+ }
100
+ if (!opts.yes && !(await confirm('Continue? (y/N) '))) {
101
+ throw new Error('Cancelled. Re-run with --yes to skip this question.');
102
+ }
103
+ const push = await runPushWith(config, await loadState(), {
104
+ replace: true, keyFilter, gameIndex, quiet: opts.quiet,
105
+ });
106
+ let pruneSkipped = null;
107
+ let trashed = 0;
108
+ if (!pruneAllowed)
109
+ pruneSkipped = 'game scan capped';
110
+ else if (push.error > 0 || push.conflict > 0)
111
+ pruneSkipped = 'some sprites failed to upload';
112
+ else if (push.keptAssetIds.length === 0)
113
+ pruneSkipped = 'no files confirmed';
114
+ else {
115
+ const res = await pruneResyncFolder(segments, push.keptAssetIds, false);
116
+ trashed = res.trashed;
117
+ }
118
+ if (pruneSkipped)
119
+ log(kleur.yellow(`! Nothing moved to Trash (${pruneSkipped}). Re-run once fixed.`));
120
+ else
121
+ log(kleur.green(`✓ ${label} resynced. ${trashed} file${trashed === 1 ? '' : 's'} moved to Trash.`));
122
+ return { push, trashed, pruneSkipped };
123
+ }
@@ -28,6 +28,46 @@ import { canSkipWithoutHashing, decidePull, hasNewExplicitRelease } from '../uti
28
28
  import { hasUnpushedLocalEdit } from '../util/localEdit.js';
29
29
  import { shouldReconcile } from '../util/reconcile.js';
30
30
  import { runPush } from './push.js';
31
+ import { claimResyncRequest, reportResyncRequest } from '../api.js';
32
+ import { runResync } from './resync.js';
33
+ /** How often the watcher asks for a library "Resync from game" request. */
34
+ const RESYNC_POLL_MS = 15_000;
35
+ let lastResyncPollAt = 0;
36
+ /**
37
+ * Run one queued library "Resync from game…" request, if any. Never throws:
38
+ * a failed resync is reported back to MagicPixel and the watcher keeps going.
39
+ */
40
+ async function maybeRunQueuedResync(quiet) {
41
+ if (Date.now() - lastResyncPollAt < RESYNC_POLL_MS)
42
+ return;
43
+ lastResyncPollAt = Date.now();
44
+ let req = null;
45
+ try {
46
+ req = await claimResyncRequest();
47
+ }
48
+ catch {
49
+ return;
50
+ }
51
+ if (!req)
52
+ return;
53
+ if (!quiet) {
54
+ process.stdout.write('\x1b[2K\r');
55
+ console.log(kleur.bold(`↻ Resyncing "${req.folderName}" from your game (requested in MagicPixel)…`));
56
+ }
57
+ try {
58
+ const out = await runResync(req.folderPath, { yes: true, quiet });
59
+ const p = out.push;
60
+ await reportResyncRequest(req.id, 'done', out.pruneSkipped ? `Nothing trashed: ${out.pruneSkipped}` : null, {
61
+ created: p.created, updated: p.updated, unchanged: p.unchanged,
62
+ conflict: p.conflict, error: p.error, trashed: out.trashed,
63
+ });
64
+ }
65
+ catch (e) {
66
+ await reportResyncRequest(req.id, 'failed', e.message).catch(() => { });
67
+ if (!quiet)
68
+ console.log(kleur.red(`! Resync failed: ${e.message}`));
69
+ }
70
+ }
31
71
  /** Did the push half of a run actually move bytes into MagicPixel? */
32
72
  export function pushWasActive(pushed) {
33
73
  if (!pushed)
@@ -222,6 +262,7 @@ async function watchLoop(opts) {
222
262
  pausedForNetwork = false;
223
263
  pausedForQuota = false;
224
264
  consecutiveAuthFailures = 0;
265
+ await maybeRunQueuedResync(opts.quiet === true);
225
266
  if (wasPausedForAuth && !opts.quiet) {
226
267
  process.stdout.write('\x1b[2K\r');
227
268
  console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Key accepted again — resuming.`);
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { doctorCommand } from './commands/doctor.js';
15
15
  import { repairCommand } from './commands/repair.js';
16
16
  import { startCommand } from './commands/start.js';
17
17
  import { connectCommand } from './commands/connect.js';
18
+ import { resyncCommand } from './commands/resync.js';
18
19
  import { searchCommand } from './commands/search.js';
19
20
  import { parseWatchInterval, parseConcurrency } from './util/flagValidators.js';
20
21
  import { CLI_VERSION } from './version.js';
@@ -100,6 +101,13 @@ program
100
101
  .option('--force', 'Keep your local copy when a sprite also changed in MagicPixel')
101
102
  .addHelpText('after', '\nExamples:\n $ magicpixel push --dry-run # see what changed on disk\n $ magicpixel push # send local edits + new sprites\n $ magicpixel push --force # your local copy wins on both-sides-changed\n')
102
103
  .action(wrap("push", async (opts) => pushCommand(opts)));
104
+ program
105
+ .command('resync <folder>')
106
+ .description('Make one MagicPixel folder match your local game folder (local wins, missing files go to Trash)')
107
+ .option('-y, --yes', 'Skip the confirmation question')
108
+ .option('--dry-run', 'Show what would change without sending anything')
109
+ .addHelpText('after', '\nExamples:\n $ magicpixel resync Sprites/Enemies --dry-run\n $ magicpixel resync Sprites/Enemies\n')
110
+ .action(wrap("resync", async (folder, opts) => resyncCommand(folder, opts)));
103
111
  program
104
112
  .command('connect <glob>')
105
113
  .description('Limit which game folders sync (default is all sprites)')
@@ -120,11 +120,13 @@ export function planPush(candidates, synced, opts = {}) {
120
120
  if (known) {
121
121
  const connected = Boolean(c.sourceRel || opts.folderTreeKeys?.has(c.key) || (stateKey != null && opts.folderTreeKeys?.has(stateKey)));
122
122
  const lastDisk = lastPushedDiskSha(known, { connected });
123
- if (lastDisk === c.diskSha256) {
123
+ // Resync (replace) sends every in-scope sprite: local wins even when
124
+ // only the MagicPixel copy changed. The server reports true no-ops.
125
+ if (lastDisk === c.diskSha256 && !opts.replace) {
124
126
  out.push({ kind: 'skip', key: c.key, reason: 'unchanged' });
125
127
  continue;
126
128
  }
127
- if (lastDisk === undefined) {
129
+ if (lastDisk === undefined && !opts.replace) {
128
130
  // Connected original with no disk baseline yet. Ingest's composite
129
131
  // sha is not the game file — seeding happens in `applyDiskFingerprints`.
130
132
  out.push({ kind: 'skip', key: c.key, reason: 'unchanged' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.28",
3
+ "version": "0.5.29",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {