@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 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
- /** Full manifest walk, including the cloud's removal list. */
279
- export async function fetchManifestSnapshot(config, since) {
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
- method: 'POST',
377
- headers,
378
- body: JSON.stringify({ sprites }),
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());
@@ -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
- onStatus('Checking MagicPixel…');
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;
@@ -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 res = await fetch(current, { ...init, redirect: 'manual' });
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)
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.5.20",
3
+ "version": "0.5.21",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {