@magicpixelart/cli 0.5.20 → 0.5.21
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 +11 -0
- package/dist/api.js +15 -9
- package/dist/commands/sync.js +33 -5
- package/dist/util/security.js +34 -2
- package/dist/util/watchCopy.js +20 -0
- package/package.json +1 -1
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.21] — 2026-09-14
|
|
11
|
+
|
|
12
|
+
- `sync --watch` can no longer hang forever: every network request now has a
|
|
13
|
+
deadline (45s for manifest/JSON calls, 120s for sprite downloads and pushes),
|
|
14
|
+
and a stalled request is retried like any other network blip.
|
|
15
|
+
- A slow tick prints a heartbeat with elapsed time instead of a frozen line, and
|
|
16
|
+
the first big pull reports its running sprite count page by page.
|
|
17
|
+
- Timeout errors now end with a "check your internet connection" hint, matching
|
|
18
|
+
every other CLI error.
|
|
19
|
+
- The heartbeat line no longer prints a stray ellipsis before the sprite count.
|
|
20
|
+
|
|
10
21
|
## 0.5.20
|
|
11
22
|
|
|
12
23
|
- `sync` no longer deletes a PNG that was edited in your project when the sprite
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveEndpoint, getApiKey, CLI_VERSION } from './config.js';
|
|
2
|
-
import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, validateEndpointUrl, } from './util/security.js';
|
|
2
|
+
import { ASSET_TIMEOUT_MS, etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, validateEndpointUrl, } from './util/security.js';
|
|
3
3
|
import { authHeaders } from './util/authHeaders.js';
|
|
4
4
|
import { cmd } from './util/invoke.js';
|
|
5
5
|
/**
|
|
@@ -275,8 +275,13 @@ function compareSemver(a, b) {
|
|
|
275
275
|
}
|
|
276
276
|
return 0;
|
|
277
277
|
}
|
|
278
|
-
/**
|
|
279
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Full manifest walk, including the cloud's removal list.
|
|
280
|
+
*
|
|
281
|
+
* `onProgress` fires after each page with the running entry count, so a big
|
|
282
|
+
* first pull shows movement instead of one frozen status line.
|
|
283
|
+
*/
|
|
284
|
+
export async function fetchManifestSnapshot(config, since, onProgress) {
|
|
280
285
|
const out = [];
|
|
281
286
|
const removed = new Set();
|
|
282
287
|
let removalsTruncated = false;
|
|
@@ -292,6 +297,8 @@ export async function fetchManifestSnapshot(config, since) {
|
|
|
292
297
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
293
298
|
const res = await fetchManifestPage({ config, since, cursor, limit: 500, conditional });
|
|
294
299
|
out.push(...res.items);
|
|
300
|
+
if (out.length > 0)
|
|
301
|
+
onProgress?.(out.length);
|
|
295
302
|
if (Array.isArray(res.removed_keys)) {
|
|
296
303
|
for (const k of res.removed_keys)
|
|
297
304
|
if (typeof k === 'string' && k)
|
|
@@ -328,7 +335,7 @@ export async function fetchAssetBytes(config, key, knownSha) {
|
|
|
328
335
|
const conditional = knownSha ? { 'If-None-Match': etagForSha256(knownSha) } : undefined;
|
|
329
336
|
return retryTransient(`download ${key}`, async () => {
|
|
330
337
|
const { headers, requestId } = buildHeaders(conditional);
|
|
331
|
-
const res = await safeFetch(url.href, { headers });
|
|
338
|
+
const res = await safeFetch(url.href, { headers }, { timeoutMs: ASSET_TIMEOUT_MS });
|
|
332
339
|
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
333
340
|
if (res.status === 304) {
|
|
334
341
|
// Drain so undici can return the socket to the keep-alive pool.
|
|
@@ -372,11 +379,10 @@ export async function pushSprites(sprites, retryOpts) {
|
|
|
372
379
|
const url = resolveIngestEndpoint();
|
|
373
380
|
return retryTransient('push', async () => {
|
|
374
381
|
const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
|
|
375
|
-
const res = await safeFetch(url, {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
});
|
|
382
|
+
const res = await safeFetch(url, { method: 'POST', headers, body: JSON.stringify({ sprites }) },
|
|
383
|
+
// A push batch carries PNG bytes; give it the asset deadline, not the
|
|
384
|
+
// shorter JSON one.
|
|
385
|
+
{ timeoutMs: ASSET_TIMEOUT_MS });
|
|
380
386
|
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
381
387
|
if (res.ok) {
|
|
382
388
|
const data = (await res.json());
|
package/dist/commands/sync.js
CHANGED
|
@@ -23,7 +23,7 @@ import { maxIsoTimestamp } from '../util/iso.js';
|
|
|
23
23
|
import { formatBytes } from '../util/format.js';
|
|
24
24
|
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
25
25
|
import { cmd } from '../util/invoke.js';
|
|
26
|
-
import { formatWatchSpriteLine } from '../util/watchCopy.js';
|
|
26
|
+
import { formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
|
|
27
27
|
import { decidePull } from '../util/pullDecision.js';
|
|
28
28
|
import { hasUnpushedLocalEdit } from '../util/localEdit.js';
|
|
29
29
|
import { shouldReconcile } from '../util/reconcile.js';
|
|
@@ -177,14 +177,31 @@ async function watchLoop(opts) {
|
|
|
177
177
|
return;
|
|
178
178
|
process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${msg}`)}`);
|
|
179
179
|
};
|
|
180
|
+
// Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
|
|
181
|
+
// link) must not look like the silent hang a timeout-less fetch used to
|
|
182
|
+
// cause. Requests now have deadlines, so a live counter here means "slow",
|
|
183
|
+
// not "stuck".
|
|
184
|
+
const tickStartedAt = Date.now();
|
|
185
|
+
let lastStatus = '';
|
|
186
|
+
const wrappedStatus = (msg) => {
|
|
187
|
+
lastStatus = msg;
|
|
188
|
+
onStatus(msg);
|
|
189
|
+
};
|
|
190
|
+
const heartbeat = opts.quiet
|
|
191
|
+
? null
|
|
192
|
+
: setInterval(() => {
|
|
193
|
+
const elapsedSec = Math.round((Date.now() - tickStartedAt) / 1000);
|
|
194
|
+
onStatus(formatSlowTickLine(elapsedSec, lastStatus));
|
|
195
|
+
}, SLOW_TICK_HEARTBEAT_MS);
|
|
196
|
+
heartbeat?.unref?.();
|
|
180
197
|
try {
|
|
181
|
-
|
|
198
|
+
wrappedStatus('Checking MagicPixel…');
|
|
182
199
|
const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
|
|
183
200
|
? gameIndexCache
|
|
184
201
|
: undefined;
|
|
185
202
|
const r = await runOnce({ ...opts, watch: false }, {
|
|
186
203
|
watchMode: true,
|
|
187
|
-
onStatus,
|
|
204
|
+
onStatus: wrappedStatus,
|
|
188
205
|
gameIndex: reuseIndex,
|
|
189
206
|
onGameIndex: (idx) => {
|
|
190
207
|
gameIndexCache = idx;
|
|
@@ -288,6 +305,8 @@ async function watchLoop(opts) {
|
|
|
288
305
|
}
|
|
289
306
|
}
|
|
290
307
|
finally {
|
|
308
|
+
if (heartbeat)
|
|
309
|
+
clearInterval(heartbeat);
|
|
291
310
|
inFlight = false;
|
|
292
311
|
}
|
|
293
312
|
};
|
|
@@ -440,8 +459,17 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
440
459
|
// Capture before the Unity filter drops rows — lastSync must not jump past
|
|
441
460
|
// an editor save whose `unity` flag was omitted on this tick.
|
|
442
461
|
let observedUpdatedAt = null;
|
|
462
|
+
// Page-by-page progress: a first pull of thousands of sprites walks several
|
|
463
|
+
// pages, and a single static status line reads as a hang.
|
|
464
|
+
const onManifestProgress = (n) => {
|
|
465
|
+
const count = n.toLocaleString('en-US');
|
|
466
|
+
if (spinner)
|
|
467
|
+
spinner.text = `Fetching manifest… (${count})`;
|
|
468
|
+
else
|
|
469
|
+
onStatus?.(`Fetching your sprites from MagicPixel… (${count})`);
|
|
470
|
+
};
|
|
443
471
|
try {
|
|
444
|
-
let snapshot = await fetchManifestSnapshot(config, since);
|
|
472
|
+
let snapshot = await fetchManifestSnapshot(config, since, onManifestProgress);
|
|
445
473
|
if (snapshot.removalsTruncated && since) {
|
|
446
474
|
// The server couldn't list every removal in this window. Trusting the
|
|
447
475
|
// partial list and advancing the cursor would strand the rest forever, so
|
|
@@ -450,7 +478,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
450
478
|
console.log(kleur.dim('Many sprites were removed at once — re-reading the full manifest to stay in sync.'));
|
|
451
479
|
}
|
|
452
480
|
since = undefined;
|
|
453
|
-
snapshot = await fetchManifestSnapshot(config, undefined);
|
|
481
|
+
snapshot = await fetchManifestSnapshot(config, undefined, onManifestProgress);
|
|
454
482
|
}
|
|
455
483
|
manifest = snapshot.entries;
|
|
456
484
|
removedRemoteKeys = snapshot.removedKeys;
|
package/dist/util/security.js
CHANGED
|
@@ -88,14 +88,46 @@ export function tmpPathFor(diskPath) {
|
|
|
88
88
|
const suffix = randomBytes(8).toString('hex');
|
|
89
89
|
return `${diskPath}.${process.pid}.${suffix}.tmp`;
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Deadline for a single request (JSON APIs: manifest, push, whoami, key
|
|
93
|
+
* validation). Without a deadline a stalled socket — sleeping laptop, dropped
|
|
94
|
+
* Wi-Fi, dead keep-alive connection — makes `fetch` hang forever, so
|
|
95
|
+
* `sync --watch` sits silently on one tick and never retries.
|
|
96
|
+
*/
|
|
97
|
+
export const REQUEST_TIMEOUT_MS = 45_000;
|
|
98
|
+
/** Deadline for a PNG body, which can legitimately take much longer. */
|
|
99
|
+
export const ASSET_TIMEOUT_MS = 120_000;
|
|
100
|
+
function isAbortError(err) {
|
|
101
|
+
const name = err?.name;
|
|
102
|
+
return name === 'AbortError' || name === 'TimeoutError';
|
|
103
|
+
}
|
|
91
104
|
/**
|
|
92
105
|
* Follow redirects only within the same origin as the initial request (prevents API key leak).
|
|
106
|
+
*
|
|
107
|
+
* Each hop gets a fresh deadline. The signal stays armed after the response
|
|
108
|
+
* headers arrive, so a stalled *body* read aborts too — that abort surfaces
|
|
109
|
+
* raw here and is relabelled as a transient network failure by `retryTransient`
|
|
110
|
+
* one layer up; only the connection attempt gets the friendly "didn't respond
|
|
111
|
+
* in time" wording. Callers that pass their own `init.signal` own their timing
|
|
112
|
+
* (e.g. `doctor`'s 5s probe).
|
|
93
113
|
*/
|
|
94
|
-
export async function safeFetch(url, init = {}) {
|
|
114
|
+
export async function safeFetch(url, init = {}, opts = {}) {
|
|
95
115
|
const origin = new URL(url).origin;
|
|
116
|
+
const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
96
117
|
let current = url;
|
|
97
118
|
for (let hop = 0; hop < 5; hop++) {
|
|
98
|
-
const
|
|
119
|
+
const signal = init.signal ?? AbortSignal.timeout(timeoutMs);
|
|
120
|
+
let res;
|
|
121
|
+
try {
|
|
122
|
+
res = await fetch(current, { ...init, signal, redirect: 'manual' });
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
if (!init.signal && isAbortError(e)) {
|
|
126
|
+
throw new Error(`timed out after ${Math.round(timeoutMs / 1000)}s — MagicPixel didn't respond in time.\n` +
|
|
127
|
+
` Fix: check your internet connection and that the MagicPixel API is reachable.`, { cause: e });
|
|
128
|
+
}
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
99
131
|
// 304 lives in the 3xx range but is a conditional-request answer, not a
|
|
100
132
|
// redirect: it carries no Location and callers handle it directly.
|
|
101
133
|
if (res.status < 300 || res.status >= 400 || res.status === 304)
|
package/dist/util/watchCopy.js
CHANGED
|
@@ -19,3 +19,23 @@ export function formatWatchSpriteLine(c) {
|
|
|
19
19
|
return ` Sprites: ${fmt(set)} in your game`;
|
|
20
20
|
return ` Sprites: ${fmt(pulled)}`;
|
|
21
21
|
}
|
|
22
|
+
/** How long a single watch tick may run before the heartbeat starts printing. */
|
|
23
|
+
export const SLOW_TICK_HEARTBEAT_MS = 60_000;
|
|
24
|
+
/**
|
|
25
|
+
* Heartbeat line for a tick that has outlived `SLOW_TICK_HEARTBEAT_MS`. Keeps
|
|
26
|
+
* the last status text so the user sees *what* is slow, not just that
|
|
27
|
+
* something is. Requests carry their own deadlines, so this always means slow.
|
|
28
|
+
*/
|
|
29
|
+
export function formatSlowTickLine(elapsedSec, lastStatus) {
|
|
30
|
+
const secs = Math.max(0, Math.round(elapsedSec));
|
|
31
|
+
const elapsed = secs >= 120 ? `${Math.round(secs / 60)}m` : `${secs}s`;
|
|
32
|
+
// "Fetching sprites… (1,200)" → "Fetching sprites (1,200)": the ellipsis and
|
|
33
|
+
// the heartbeat suffix would otherwise both claim the end of the line.
|
|
34
|
+
const what = lastStatus
|
|
35
|
+
?.trim()
|
|
36
|
+
.replace(/[.…]+(\s*\([^()]*\))?$/, '$1')
|
|
37
|
+
.trim();
|
|
38
|
+
return what
|
|
39
|
+
? `${what} — still working (${elapsed})`
|
|
40
|
+
: `Still working (${elapsed})`;
|
|
41
|
+
}
|