@magicpixelart/cli 0.3.3 → 0.5.0

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.
@@ -1,16 +1,19 @@
1
1
  import kleur from 'kleur';
2
2
  import ora from 'ora';
3
3
  import { createHash } from 'node:crypto';
4
- import { mkdir, writeFile, unlink, readdir, rm, rename } from 'node:fs/promises';
4
+ import { mkdir, writeFile, unlink, rename, readdir, rm } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
6
6
  import { dirname, relative, resolve } from 'node:path';
7
7
  import { loadConfig, loadState, saveState } from '../config.js';
8
8
  import { fetchAllManifest, fetchAssetBytes, ApiError } from '../api.js';
9
9
  import { fileSha256 } from '../util/hash.js';
10
- import { assetDiskPath, assetDiskPathFromKey } from '../util/paths.js';
10
+ import { assetDiskPath, assetDiskPathFromKey, pruneEmptyDirs, walkOutDirPngs } from '../util/paths.js';
11
11
  import { createLimit } from '../util/limit.js';
12
12
  import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitIndex.js';
13
13
  import { assertPathInsideRoot, tmpPathFor } from '../util/security.js';
14
+ import { friendlyFsError } from '../util/errors.js';
15
+ import { maxIsoTimestamp } from '../util/iso.js';
16
+ import { formatBytes } from '../util/format.js';
14
17
  export async function syncCommand(opts) {
15
18
  if (opts.watch) {
16
19
  await watchLoop(opts);
@@ -23,9 +26,9 @@ async function watchLoop(opts) {
23
26
  // sprites in MagicPixel and watching them refresh in their game's dev
24
27
  // server. Incremental polls send `?since=<lastSync>` so an empty manifest
25
28
  // round-trip is cheap (a single HTTP HEAD-sized response with `count: 0`).
26
- // Floor stays at 2s to keep accidental thrashing in check; users who need
27
- // less can pass `--watch 5`.
28
- const intervalSec = typeof opts.watch === 'string' ? Math.max(2, parseInt(opts.watch, 10) || 2) : 2;
29
+ // Commander already validated the numeric value (2–3600); the `?? 2`
30
+ // covers the bare boolean `-w` form.
31
+ const intervalSec = typeof opts.watch === 'string' ? parseInt(opts.watch, 10) : 2;
29
32
  // Header — print once on start. Project/asset count is best-effort; if the
30
33
  // first manifest fetch fails we still want the loop to come up and retry.
31
34
  let headerCount = null;
@@ -40,43 +43,84 @@ async function watchLoop(opts) {
40
43
  console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
41
44
  if (headerCount !== null)
42
45
  console.log(kleur.dim(` Sprites: ${headerCount}`));
43
- console.log(kleur.dim(` Polling: every ${intervalSec}s · Stop: Ctrl+C`));
46
+ console.log(kleur.dim(` Polling: every ${intervalSec}s (slows when idle) · Stop: Ctrl+C`));
44
47
  console.log();
45
48
  let stopping = false;
46
49
  let inFlight = false;
47
- let resolveIdle = null;
48
50
  let backoffSec = intervalSec;
49
51
  let pausedForAuth = false;
52
+ // Mirrors pausedForAuth for the network-offline path: once we've told the
53
+ // user "MagicPixel is offline", the next successful tick prints a single
54
+ // "back online" recovery line. Without this flag a user who walked away
55
+ // during an outage has no signal that things are healthy again.
56
+ let pausedForNetwork = false;
57
+ // After this many consecutive 401/403s we give up and exit non-zero so a
58
+ // parent process (Vite plugin, systemd, pm2) can tell the watcher is
59
+ // genuinely broken (revoked key) rather than transiently blipped.
60
+ const MAX_AUTH_FAILURES = 5;
61
+ let consecutiveAuthFailures = 0;
50
62
  // Adaptive idle backoff: after a few minutes of nothing-to-do we slow the
51
- // poll from 2s → 5s → 10s so a dev who walked away isn't hammering the
52
- // manifest endpoint. ANY change OR error resets this back to intervalSec,
63
+ // poll from intervalSec → 5s → 10s so a dev who walked away isn't hammering
64
+ // the manifest endpoint. ANY change OR error resets this back to intervalSec,
53
65
  // so the "edit → see it" promise stays intact the moment the user comes
54
66
  // back. Error backoff (2→60s) is separate and continues to win.
55
67
  let consecutiveIdleTicks = 0;
56
- const onSigint = () => {
68
+ // Thresholds in seconds (NOT ticks) so a `--watch 10` user gets the same
69
+ // ~3 min / ~15 min UX as a default `--watch 2` user. Previously these were
70
+ // tick counts (90 / 300) which made the slowdown timing scale with intervalSec.
71
+ const IDLE_SOFT_BACKOFF_SECONDS = 180;
72
+ const IDLE_HARD_BACKOFF_SECONDS = 900;
73
+ // Cancellable idle sleep — `onStopSignal` calls `wakeStop()` so the next
74
+ // tick exits immediately instead of waiting out the full backoff (which can
75
+ // be 60s at the error ceiling). Initialised to a no-op so the first
76
+ // pre-loop `await tick()` is safe even before the first sleep.
77
+ let wakeStop = () => { };
78
+ // Handle both SIGINT (Ctrl+C) and SIGTERM (`kill`, `docker stop`, systemd,
79
+ // pm2). Without the SIGTERM listener a supervisor-managed watcher would die
80
+ // mid-sync without draining in-flight work or preserving the exit code.
81
+ const onStopSignal = (signal) => {
57
82
  if (stopping)
58
83
  return;
59
84
  stopping = true;
85
+ wakeStop();
60
86
  process.stdout.write('\x1b[2K\r');
61
87
  if (inFlight) {
62
- console.log(kleur.dim('[watch] finishing current sync… (Ctrl+C again to force quit)'));
63
- process.once('SIGINT', () => process.exit(130));
88
+ console.log(kleur.dim(`[watch] finishing current sync… (${signal} again to force quit)`));
89
+ process.once(signal, () => process.exit(signal === 'SIGINT' ? 130 : 143));
64
90
  }
65
91
  else {
66
- console.log(kleur.dim('[watch] stopped.'));
67
- process.exit(0);
92
+ if (!opts.quiet)
93
+ console.log(kleur.dim('[watch] stopped.'));
94
+ // Preserve any exit code already set by a prior failed tick.
95
+ process.exit(process.exitCode ?? 0);
68
96
  }
69
97
  };
70
- process.on('SIGINT', onSigint);
98
+ process.on('SIGINT', onStopSignal);
99
+ process.on('SIGTERM', onStopSignal);
71
100
  const tick = async () => {
72
101
  if (inFlight || stopping)
73
102
  return;
74
103
  inFlight = true;
75
104
  try {
76
105
  const r = await runOnce({ ...opts, watch: false }, { watchMode: true });
77
- // Reset backoff + auth-pause flags on any successful tick.
106
+ // Reset backoff on any successful tick. Resume message fires once when
107
+ // we recover from an auth pause — `getApiKey()` re-reads
108
+ // .magicpixel/credentials on every call, so a `magicpixel login` in
109
+ // another terminal is picked up automatically by the next tick.
110
+ const wasPausedForAuth = pausedForAuth;
111
+ const wasPausedForNetwork = pausedForNetwork;
78
112
  backoffSec = intervalSec;
79
113
  pausedForAuth = false;
114
+ pausedForNetwork = false;
115
+ consecutiveAuthFailures = 0;
116
+ if (wasPausedForAuth && !opts.quiet) {
117
+ process.stdout.write('\x1b[2K\r');
118
+ console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Key accepted again — resuming.`);
119
+ }
120
+ if (wasPausedForNetwork && !wasPausedForAuth && !opts.quiet) {
121
+ process.stdout.write('\x1b[2K\r');
122
+ console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Back online — resuming.`);
123
+ }
80
124
  const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
81
125
  if (changedCount > 0) {
82
126
  // Snap back to the fast interval the moment anything changes.
@@ -85,11 +129,10 @@ async function watchLoop(opts) {
85
129
  }
86
130
  else {
87
131
  consecutiveIdleTicks++;
88
- // Thresholds in *ticks*; at intervalSec=2 these are ~3 min and ~15 min.
89
- if (consecutiveIdleTicks >= 300)
90
- backoffSec = Math.max(intervalSec, 10);
91
- else if (consecutiveIdleTicks >= 90)
92
- backoffSec = Math.max(intervalSec, 5);
132
+ backoffSec = nextBackoffForIdle(consecutiveIdleTicks, intervalSec, {
133
+ softSec: IDLE_SOFT_BACKOFF_SECONDS,
134
+ hardSec: IDLE_HARD_BACKOFF_SECONDS,
135
+ });
93
136
  }
94
137
  if (opts.quiet)
95
138
  return;
@@ -111,54 +154,139 @@ async function watchLoop(opts) {
111
154
  process.stdout.write('\x1b[2K\r');
112
155
  // Any error breaks the idle streak so we come back fast once it clears.
113
156
  consecutiveIdleTicks = 0;
114
- if (apiErr && (apiErr.status === 401 || apiErr.status === 403)) {
157
+ const decision = classifyTickError(err, {
158
+ backoffSec,
159
+ pausedForAuth,
160
+ pausedForNetwork,
161
+ consecutiveAuthFailures,
162
+ maxAuthFailures: MAX_AUTH_FAILURES,
163
+ });
164
+ if (decision.kind === 'auth') {
115
165
  if (!pausedForAuth) {
116
- console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Your key looks invalid or rotated.`);
166
+ // Surface the request id (from B1) so the user can paste it into a
167
+ // support thread and we can correlate against edge function logs.
168
+ const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
169
+ console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Your key looks invalid or rotated.${idSuffix}`);
117
170
  console.log(kleur.dim(' Fix: run `magicpixel login` (this watcher will keep retrying every 30s).'));
118
171
  }
119
172
  pausedForAuth = true;
120
- backoffSec = 30;
173
+ consecutiveAuthFailures = decision.consecutiveAuthFailures;
174
+ backoffSec = decision.nextBackoffSec;
175
+ if (decision.giveUp) {
176
+ console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Giving up after ${MAX_AUTH_FAILURES} consecutive auth failures.`);
177
+ console.log(kleur.dim(' Fix: run `magicpixel login` with a fresh key, then restart the watcher.'));
178
+ // Surface this to /admin/errors — persistent watcher auth failure
179
+ // means a key is mass-rejected (revoked, project deleted, edge
180
+ // misconfig) and we want visibility without waiting for a support
181
+ // ping. Awaited so the report flushes before exit.
182
+ const { reportAndExit } = await import('../util/telemetry.js');
183
+ await reportAndExit(err, 'sync (watch)', 2);
184
+ }
121
185
  }
122
- else if (isNetworkError(err)) {
123
- if (backoffSec < 30) {
186
+ else if (decision.kind === 'network') {
187
+ consecutiveAuthFailures = 0;
188
+ if (decision.printMessage) {
189
+ const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
124
190
  console.log(`${kleur.dim(timestamp())} ${kleur.yellow('!')} MagicPixel is offline or your internet is. ` +
125
- `Sprites you already have still work. Retrying in ${Math.min(backoffSec * 2, 60)}s.`);
191
+ `Sprites you already have still work. Retrying in ${decision.nextBackoffSec}s.${idSuffix}`);
126
192
  }
127
- backoffSec = Math.min(backoffSec * 2, 60);
193
+ pausedForNetwork = true;
194
+ backoffSec = decision.nextBackoffSec;
128
195
  }
129
196
  else {
130
- console.log(`${kleur.dim(timestamp())} ${kleur.red('!')} ${firstLine}`);
131
- backoffSec = Math.min(backoffSec * 2, 60);
197
+ consecutiveAuthFailures = 0;
198
+ const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
199
+ console.log(`${kleur.dim(timestamp())} ${kleur.red('!')} ${firstLine}${idSuffix}`);
200
+ backoffSec = decision.nextBackoffSec;
132
201
  }
133
202
  }
134
203
  finally {
135
204
  inFlight = false;
136
- resolveIdle?.();
137
- resolveIdle = null;
138
205
  }
139
206
  };
140
207
  await tick();
141
208
  while (!stopping) {
142
- await new Promise((r) => setTimeout(r, backoffSec * 1000));
209
+ // Race the sleep against a stop signal so Ctrl+C during a long error-
210
+ // backoff (up to 60s) exits within a tick instead of after the full wait.
211
+ await new Promise((resolve) => {
212
+ const timer = setTimeout(() => {
213
+ wakeStop = () => { };
214
+ resolve();
215
+ }, backoffSec * 1000);
216
+ wakeStop = () => {
217
+ clearTimeout(timer);
218
+ wakeStop = () => { };
219
+ resolve();
220
+ };
221
+ });
143
222
  if (stopping)
144
223
  break;
224
+ // `await tick()` only returns once its `finally` runs (clearing
225
+ // `inFlight`), so the loop naturally drains the in-flight sync on Ctrl+C
226
+ // before we exit — no separate idle-promise dance required.
145
227
  await tick();
146
228
  }
147
- if (inFlight) {
148
- await new Promise((r) => {
149
- resolveIdle = r;
150
- });
151
- }
152
- console.log(kleur.dim('[watch] stopped.'));
153
- process.exit(0);
229
+ if (!opts.quiet)
230
+ console.log(kleur.dim('[watch] stopped.'));
231
+ // Preserve any non-zero exit code set by a failed tick — don't mask a
232
+ // download failure with a clean exit just because the user pressed Ctrl+C.
233
+ process.exit(process.exitCode ?? 0);
154
234
  }
155
235
  function isNetworkError(err) {
156
236
  // The api layer wraps fetch failures as `manifest: network error (...)`;
157
237
  // bare ENOTFOUND/ETIMEDOUT/etc. also surface here from `fetchAssetBytes`.
238
+ // Include 502/504 strings — corporate proxies often surface upstream gateway
239
+ // failures as terse text rather than as ApiError (e.g. 502 from a TLS
240
+ // terminator before our edge function ever sees the request).
158
241
  const msg = err.message;
159
242
  return (/network error/i.test(msg) ||
160
- /ENOTFOUND|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN/i.test(msg) ||
161
- /fetch failed/i.test(msg));
243
+ /ENOTFOUND|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH/i.test(msg) ||
244
+ /fetch failed/i.test(msg) ||
245
+ /\b(502|504)\b.*\b(bad gateway|gateway timeout)\b/i.test(msg));
246
+ }
247
+ /**
248
+ * Pure helper: given how many consecutive idle ticks we've seen and the
249
+ * configured poll interval, return the next backoff in seconds. Exported so
250
+ * the watch-mode regression test can guard the "ticks vs seconds" bug fixed
251
+ * in 0.4.0 without exercising the full watch loop.
252
+ */
253
+ export function nextBackoffForIdle(consecutiveIdleTicks, intervalSec, thresholds = { softSec: 180, hardSec: 900 }) {
254
+ const idleSeconds = consecutiveIdleTicks * intervalSec;
255
+ if (idleSeconds >= thresholds.hardSec)
256
+ return Math.max(intervalSec, 10);
257
+ if (idleSeconds >= thresholds.softSec)
258
+ return Math.max(intervalSec, 5);
259
+ return intervalSec;
260
+ }
261
+ /**
262
+ * Pure helper: classify a per-tick error and compute the next backoff +
263
+ * counter updates. Extracted so the auth-failure watchdog and the
264
+ * network-recovery message can be unit-tested without spinning up signals or
265
+ * mocking timers.
266
+ *
267
+ * Caller is responsible for performing side effects (logging, `process.exit`,
268
+ * assigning the returned values back onto loop state).
269
+ */
270
+ export function classifyTickError(err, state) {
271
+ const apiErr = err instanceof ApiError ? err : null;
272
+ if (apiErr && (apiErr.status === 401 || apiErr.status === 403)) {
273
+ const next = state.consecutiveAuthFailures + 1;
274
+ return {
275
+ kind: 'auth',
276
+ nextBackoffSec: 30,
277
+ consecutiveAuthFailures: next,
278
+ giveUp: next >= state.maxAuthFailures,
279
+ };
280
+ }
281
+ if (isNetworkError(err)) {
282
+ const nextBackoffSec = Math.min(state.backoffSec * 2, 60);
283
+ // Print on the first offline tick, then again whenever backoff changes —
284
+ // capped at 60s so users who walked away still see periodic confirmation
285
+ // the watcher is alive and retrying.
286
+ const printMessage = !state.pausedForNetwork || nextBackoffSec !== state.backoffSec;
287
+ return { kind: 'network', nextBackoffSec, printMessage };
288
+ }
289
+ return { kind: 'other', nextBackoffSec: Math.min(state.backoffSec * 2, 60) };
162
290
  }
163
291
  async function runOnce(opts, runOpts = {}) {
164
292
  const config = await loadConfig();
@@ -192,13 +320,28 @@ async function runOnce(opts, runOpts = {}) {
192
320
  renamed.push({ id: entry.id, oldKey: prevKey, newKey: entry.key });
193
321
  }
194
322
  }
195
- // Diff against disk
323
+ // Compute the disk path once per entry and reuse it across the diff /
324
+ // orphan / download loops — saves three resolves + two security asserts
325
+ // per asset on large projects. Every entry in `manifest` is pre-seeded, so
326
+ // the lookup never misses; non-null assertion is safe.
327
+ const diskPathById = new Map();
328
+ for (const entry of manifest) {
329
+ diskPathById.set(entry.id, assetDiskPath(config.outDir, entry));
330
+ }
331
+ const pathFor = (entry) => diskPathById.get(entry.id);
332
+ // Diff against disk. SHA pre-check runs through the same concurrency pool
333
+ // used for downloads — on a 1k-asset project that's all-unchanged this is
334
+ // the dominant wall-clock cost of an incremental sync. We cache each result
335
+ // so the download loop can reuse it for `If-None-Match` ETags instead of
336
+ // re-hashing the same file moments later.
337
+ const diffLimit = createLimit(concurrency);
338
+ const shaByEntryId = new Map();
196
339
  const toDownload = [];
197
340
  let bytesSaved = 0;
198
341
  let unchanged = 0;
199
- for (const entry of manifest) {
200
- const diskPath = assetDiskPath(config.outDir, entry);
201
- const localSha = await fileSha256(diskPath);
342
+ await Promise.all(manifest.map((entry) => diffLimit(async () => {
343
+ const localSha = await fileSha256(pathFor(entry));
344
+ shaByEntryId.set(entry.id, localSha);
202
345
  if (entry.sha256 && localSha && entry.sha256 === localSha) {
203
346
  unchanged++;
204
347
  if (entry.size_bytes)
@@ -207,14 +350,14 @@ async function runOnce(opts, runOpts = {}) {
207
350
  else {
208
351
  toDownload.push(entry);
209
352
  }
210
- }
353
+ })));
211
354
  // Orphan detection only when we have the full picture.
212
355
  // (Renames also produce a stale path on disk — collected separately below.)
213
356
  let orphans = [];
214
357
  if (!since) {
215
- const remoteDiskPaths = new Set(manifest.map((e) => assetDiskPath(config.outDir, e)));
216
- orphans = (await findLocalPngs(resolve(process.cwd(), config.outDir)))
217
- .filter((p) => !remoteDiskPaths.has(p));
358
+ const remoteDiskPaths = new Set(manifest.map((e) => pathFor(e)));
359
+ const localPngs = await walkOutDirPngs(config.outDir);
360
+ orphans = localPngs.map((a) => a.abs).filter((p) => !remoteDiskPaths.has(p));
218
361
  }
219
362
  // Stale paths from detected renames (always pruned, even in incremental mode —
220
363
  // otherwise the old PNG silently lingers next to the renamed copy).
@@ -226,6 +369,38 @@ async function runOnce(opts, runOpts = {}) {
226
369
  for (const p of renameStalePaths)
227
370
  orphanSet.add(p);
228
371
  orphans = [...orphanSet];
372
+ // Legacy-suffix folder sweep — always runs, even in incremental mode.
373
+ //
374
+ // Background: server-side slug uniqueness rules have changed over time
375
+ // (e.g. per-user → per-project). A doc that used to live under
376
+ // `outDir/cards-2/` may now report slug `cards` in the manifest. If the
377
+ // CLI's prior snapshot doesn't have the old id→key mapping (fresh clone,
378
+ // CI runner, snapshot wipe), rename detection finds nothing and the stale
379
+ // folder lingers next to the current one, breaking the user's imports of
380
+ // `@/assets/.../cards-2/...`.
381
+ //
382
+ // We detect this by looking for top-level disk folders whose name matches
383
+ // `<currentSlug>-<n>` for any slug currently in the manifest. Those are
384
+ // unambiguously legacy suffix collisions. We prune the whole folder
385
+ // (when --prune is on) and surface a clear "update your imports" notice
386
+ // so the user knows their source code references must be migrated.
387
+ // Build the "known top-level slugs" set from BOTH the current manifest AND
388
+ // the prior id→key snapshot. In incremental (`--watch`) mode the manifest
389
+ // only contains rows changed since `lastSync`, so a user with two
390
+ // legitimate sibling slugs (`tiles/` + `tiles-2/`) would lose `tiles-2/`
391
+ // the moment only `tiles` appeared in a delta. Pulling from
392
+ // `previousAssets` (the persisted full snapshot) closes that hole.
393
+ const knownFolderSlugs = new Set();
394
+ for (const e of manifest) {
395
+ if (e.folder)
396
+ knownFolderSlugs.add(e.folder.split('/')[0]);
397
+ }
398
+ for (const key of Object.values(previousAssets)) {
399
+ const top = key.split('/')[0];
400
+ if (top)
401
+ knownFolderSlugs.add(top);
402
+ }
403
+ const legacyFolders = await findLegacySuffixFolders(config.outDir, knownFolderSlugs);
229
404
  if (verbose) {
230
405
  console.log();
231
406
  console.log(kleur.bold('Plan:'));
@@ -233,6 +408,10 @@ async function runOnce(opts, runOpts = {}) {
233
408
  console.log(` ${kleur.dim('=')} unchanged ${unchanged}${bytesSaved ? kleur.dim(` (~${formatBytes(bytesSaved)} saved)`) : ''}`);
234
409
  if (renamed.length)
235
410
  console.log(` ${kleur.cyan('↪')} renamed ${renamed.length}`);
411
+ if (legacyFolders.length) {
412
+ const verb = shouldPrune ? kleur.red('delete') : kleur.yellow('keep');
413
+ console.log(` ${verb} ${legacyFolders.length} legacy slug folder${legacyFolders.length === 1 ? '' : 's'}`);
414
+ }
236
415
  if (since && !renamed.length) {
237
416
  console.log(` ${kleur.dim('orphan check skipped (incremental — use --full)')}`);
238
417
  }
@@ -249,6 +428,8 @@ async function runOnce(opts, runOpts = {}) {
249
428
  printRenames(renamed, { withHints: false });
250
429
  if (orphans.length > 0)
251
430
  printOrphans(orphans);
431
+ if (legacyFolders.length > 0)
432
+ printLegacyFolders(legacyFolders);
252
433
  }
253
434
  return {
254
435
  added: [],
@@ -278,13 +459,25 @@ async function runOnce(opts, runOpts = {}) {
278
459
  const run = createLimit(concurrency);
279
460
  let done = 0;
280
461
  await Promise.all(toDownload.map((entry) => run(async () => {
281
- const diskPath = assetDiskPath(config.outDir, entry);
462
+ const diskPath = pathFor(entry);
282
463
  const existedBefore = existsSync(diskPath);
283
464
  try {
284
- const localSha = await fileSha256(diskPath);
465
+ // Reuse the SHA computed during the diff pre-check — re-hashing the
466
+ // same file moments later was a measurable cost on large projects.
467
+ const localSha = shaByEntryId.get(entry.id) ?? null;
285
468
  const bytes = await fetchAssetBytes(config, entry.key, localSha);
286
469
  if (bytes === null) {
470
+ // Server returned 304 (ETag matched). Credit the asset's manifest
471
+ // size to bytesSaved so the end-of-sync summary reflects the
472
+ // bandwidth the conditional GET avoided.
473
+ //
474
+ // Invariant: we only reach here for entries that made it into
475
+ // `toDownload`, i.e. the disk-SHA pre-check above did NOT credit
476
+ // them (typically because `entry.sha256` was null in the manifest
477
+ // but the ETag still matched server-side). So no double-counting.
287
478
  result.unchanged++;
479
+ if (entry.size_bytes)
480
+ result.bytesSaved += entry.size_bytes;
288
481
  }
289
482
  else {
290
483
  if (entry.sha256) {
@@ -293,11 +486,22 @@ async function runOnce(opts, runOpts = {}) {
293
486
  throw new Error(`sha256 mismatch (expected ${entry.sha256.slice(0, 12)}…, got ${actual.slice(0, 12)}…)`);
294
487
  }
295
488
  }
296
- await mkdir(dirname(diskPath), { recursive: true });
297
- const tmp = tmpPathFor(diskPath);
298
- assertPathInsideRoot(tmp, resolve(process.cwd(), config.outDir), 'outDir');
299
- await writeFile(tmp, bytes);
300
- await rename(tmp, diskPath);
489
+ // Wrap any of mkdir/writeFile/rename so the user sees a friendly
490
+ // multi-line diagnostic instead of a raw `EACCES: permission denied`.
491
+ try {
492
+ await mkdir(dirname(diskPath), { recursive: true });
493
+ const tmp = tmpPathFor(diskPath);
494
+ assertPathInsideRoot(tmp, resolve(process.cwd(), config.outDir), 'outDir');
495
+ await writeFile(tmp, bytes);
496
+ await rename(tmp, diskPath);
497
+ }
498
+ catch (fsErr) {
499
+ throw friendlyFsError(fsErr, {
500
+ operation: `Writing asset`,
501
+ path: diskPath,
502
+ hint: `Sync can't continue until outDir (${config.outDir}) is writable.`,
503
+ });
504
+ }
301
505
  if (existedBefore)
302
506
  result.modified.push(entry.key);
303
507
  else
@@ -308,7 +512,17 @@ async function runOnce(opts, runOpts = {}) {
308
512
  catch (e) {
309
513
  result.failed++;
310
514
  progress?.stop();
311
- console.log(` ${kleur.red('!')} ${entry.key}: ${e.message.split('\n')[0]}`);
515
+ // Multi-line messages come from friendlyFsError — print all lines
516
+ // so the user sees the fix hint. Single-line errors stay terse.
517
+ const msg = e.message ?? String(e);
518
+ if (msg.includes('\n')) {
519
+ console.log(` ${kleur.red('!')} ${entry.key}:`);
520
+ for (const line of msg.split('\n'))
521
+ console.log(` ${line}`);
522
+ }
523
+ else {
524
+ console.log(` ${kleur.red('!')} ${entry.key}: ${msg}`);
525
+ }
312
526
  progress?.start();
313
527
  }
314
528
  finally {
@@ -347,6 +561,36 @@ async function runOnce(opts, runOpts = {}) {
347
561
  else if (verbose && orphans.length > 0) {
348
562
  printOrphans(orphans);
349
563
  }
564
+ // Legacy-suffix folder sweep. Runs in BOTH full and incremental mode so a
565
+ // user who is hot off a server-side slug rule change recovers without
566
+ // having to remember `--full`. Folders are deleted whole (children
567
+ // included) because every PNG inside is, by definition, stale; their
568
+ // canonical copies live under the de-suffixed folder we just downloaded.
569
+ if (shouldPrune && legacyFolders.length > 0) {
570
+ const outRoot = resolve(process.cwd(), config.outDir);
571
+ for (const lf of legacyFolders) {
572
+ assertPathInsideRoot(lf.abs, outRoot, 'outDir');
573
+ try {
574
+ await rm(lf.abs, { recursive: true, force: true });
575
+ // Push a synthetic key so the watch loop's changedCount picks this up
576
+ // (otherwise a tick that ONLY swept legacy folders reports "no changes").
577
+ result.removed.push(`${lf.legacyName}/ (legacy)`);
578
+ if (verbose) {
579
+ console.log(` ${kleur.red('-')} ${relative(process.cwd(), lf.abs)} ${kleur.dim(`(legacy slug — now ${lf.currentSlug}/)`)}`);
580
+ }
581
+ }
582
+ catch (e) {
583
+ if (verbose)
584
+ console.log(` ${kleur.yellow('!')} failed to remove legacy folder ${relative(process.cwd(), lf.abs)}: ${e.message}`);
585
+ }
586
+ }
587
+ // Always surface the import-update notice — even in --quiet/watch mode
588
+ // — because user source code now references paths that no longer exist.
589
+ printLegacyFolders(legacyFolders);
590
+ }
591
+ else if (verbose && legacyFolders.length > 0) {
592
+ printLegacyFolders(legacyFolders);
593
+ }
350
594
  // Always persist the id → key snapshot so rename detection survives even
351
595
  // when `emitIndex` is toggled off-then-on. `lastSync` only advances on a
352
596
  // clean run, so partial-failure syncs are retried from the prior cursor.
@@ -366,7 +610,27 @@ async function runOnce(opts, runOpts = {}) {
366
610
  }
367
611
  const nextState = { ...state, assets: nextAssets };
368
612
  if (result.failed === 0) {
369
- nextState.lastSync = startedAt;
613
+ // Advance the cursor to the newest row we actually observed, NOT the
614
+ // wall-clock time the sync started. Using startedAt silently skipped
615
+ // rows whose `updated_at` lived in the gap between the manifest snapshot
616
+ // and the next poll (notably when only metadata changed — e.g. artboard
617
+ // renames — and the row updated between fetch start and save).
618
+ const maxUpdatedAt = maxIsoTimestamp(manifest.map((e) => e.updated_at));
619
+ if (maxUpdatedAt) {
620
+ // Take the later of: newest row we saw, or the prior cursor. Never
621
+ // rewind — an incremental sync that returned 0 rows must keep the
622
+ // existing lastSync (otherwise we'd re-download history next tick).
623
+ nextState.lastSync =
624
+ state.lastSync && state.lastSync > maxUpdatedAt ? state.lastSync : maxUpdatedAt;
625
+ }
626
+ else if (state.lastSync) {
627
+ nextState.lastSync = state.lastSync;
628
+ }
629
+ else {
630
+ // No prior cursor and an empty manifest (fresh project): fall back to
631
+ // startedAt so future incremental polls have a baseline.
632
+ nextState.lastSync = startedAt;
633
+ }
370
634
  delete nextState.lastError;
371
635
  }
372
636
  else {
@@ -388,18 +652,20 @@ async function runOnce(opts, runOpts = {}) {
388
652
  const indexPath = await emitTypedIndex(config.outDir, diskEntries);
389
653
  if (verbose)
390
654
  console.log(kleur.dim(` index → ${relative(process.cwd(), indexPath)}`));
391
- // Nudge AI tools toward the typed index. Idempotent — `ensureAgentsDoc`
392
- // no-ops once our marker section is present.
393
- try {
394
- const agentsResult = await ensureAgentsDoc(config.outDir);
395
- if (verbose && agentsResult !== 'unchanged') {
396
- console.log(kleur.dim(` AGENTS.md ${agentsResult}`));
397
- }
398
- }
399
- catch {
400
- // Never let an AGENTS.md write failure break sync.
655
+ }
656
+ // AGENTS.md hint is always written — `public/` and `static/` users
657
+ // (emitIndex: false) need the absolute-URL snippet just as much as
658
+ // bundler-importable outDir users need the ES-import one.
659
+ // `ensureAgentsDoc` no-ops once our marker section is present.
660
+ try {
661
+ const agentsResult = await ensureAgentsDoc(config.outDir);
662
+ if (verbose && agentsResult !== 'unchanged') {
663
+ console.log(kleur.dim(` AGENTS.md ${agentsResult}`));
401
664
  }
402
665
  }
666
+ catch {
667
+ // Never let an AGENTS.md write failure break sync.
668
+ }
403
669
  await saveState(nextState);
404
670
  if (result.failed > 0 && verbose) {
405
671
  console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
@@ -413,7 +679,10 @@ async function runOnce(opts, runOpts = {}) {
413
679
  (renamed.length ? `, renamed ${renamed.length}` : '') +
414
680
  (result.failed ? `, failed ${result.failed}` : '');
415
681
  console.log(result.failed ? kleur.yellow(`done with errors. ${summary}`) : kleur.green(`✓ done. ${summary}`));
416
- printChanges(result, ' ');
682
+ // Per-file change list. Suppress renames here — printRenames below owns
683
+ // the rename block (it adds the import-update hints). Letting both fire
684
+ // would print each rename twice.
685
+ printChanges(result, ' ', { includeRenames: renamed.length === 0 });
417
686
  if (renamed.length > 0)
418
687
  printRenames(renamed, { withHints: true });
419
688
  }
@@ -422,14 +691,16 @@ async function runOnce(opts, runOpts = {}) {
422
691
  return result;
423
692
  }
424
693
  const CHANGE_PRINT_CAP = 50;
425
- function printChanges(r, indent) {
694
+ function printChanges(r, indent, opts = { includeRenames: true }) {
426
695
  const lines = [];
427
696
  for (const k of r.added)
428
697
  lines.push(`${indent}${kleur.green('+')} ${k}`);
429
698
  for (const k of r.modified)
430
699
  lines.push(`${indent}${kleur.cyan('~')} ${k}`);
431
- for (const r2 of r.renamed)
432
- lines.push(`${indent}${kleur.cyan('↪')} ${r2.oldKey} → ${r2.newKey}`);
700
+ if (opts.includeRenames) {
701
+ for (const r2 of r.renamed)
702
+ lines.push(`${indent}${kleur.cyan('↪')} ${r2.oldKey} → ${r2.newKey}`);
703
+ }
433
704
  for (const k of r.removed)
434
705
  lines.push(`${indent}${kleur.red('-')} ${k}`);
435
706
  if (lines.length === 0)
@@ -464,13 +735,6 @@ function progressText(done, total, bytes) {
464
735
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
465
736
  return `${bar} ${done}/${total} ${kleur.dim(formatBytes(bytes))}`;
466
737
  }
467
- function formatBytes(n) {
468
- if (n < 1024)
469
- return `${n} B`;
470
- if (n < 1024 * 1024)
471
- return `${(n / 1024).toFixed(1)} KB`;
472
- return `${(n / (1024 * 1024)).toFixed(1)} MB`;
473
- }
474
738
  function timestamp() {
475
739
  const d = new Date();
476
740
  return `[${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}]`;
@@ -489,68 +753,64 @@ function printOrphans(orphans) {
489
753
  for (const p of orphans) {
490
754
  console.log(` ${kleur.dim('?')} ${relative(process.cwd(), p)}`);
491
755
  }
492
- console.log(kleur.dim(' Pruning is on by default. Re-run without --no-prune to delete them.'));
756
+ console.log(kleur.dim(' You passed --no-prune, so these were kept. Remove the flag to delete them.'));
493
757
  }
494
- async function findLocalPngs(dir) {
495
- if (!existsSync(dir))
758
+ /**
759
+ * Find top-level disk folders whose name matches `<currentSlug>-<n>` for some
760
+ * slug present in the manifest. These are unambiguously legacy artifacts
761
+ * from a server-side slug rule change (e.g. per-user → per-project
762
+ * uniqueness) — the canonical copy now lives under `<currentSlug>/`, and the
763
+ * suffixed folder's PNGs are stale.
764
+ *
765
+ * Only scans the FIRST level under outDir; nested folders are user content.
766
+ * Returns `[]` when outDir doesn't exist or when no remote slugs are known
767
+ * (defensive — never wipe folders when we have no manifest to compare to).
768
+ */
769
+ export async function findLegacySuffixFolders(outDir, knownFolderSlugs, cwd = process.cwd()) {
770
+ if (knownFolderSlugs.size === 0)
771
+ return [];
772
+ const root = resolve(cwd, outDir);
773
+ if (!existsSync(root))
496
774
  return [];
497
- const root = resolve(dir);
775
+ let entries;
776
+ try {
777
+ entries = await readdir(root, { withFileTypes: true });
778
+ }
779
+ catch {
780
+ return [];
781
+ }
498
782
  const out = [];
499
- async function walk(d) {
500
- const entries = await readdir(d, { withFileTypes: true });
501
- for (const ent of entries) {
502
- if (ent.isSymbolicLink())
503
- continue;
504
- const full = resolve(d, ent.name);
505
- try {
506
- assertPathInsideRoot(full, root, 'outDir');
507
- }
508
- catch {
509
- continue;
510
- }
511
- if (ent.isDirectory()) {
512
- if (ent.name.startsWith('.'))
513
- continue;
514
- await walk(full);
515
- }
516
- else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
517
- out.push(full);
518
- }
519
- }
783
+ for (const ent of entries) {
784
+ if (!ent.isDirectory())
785
+ continue;
786
+ if (ent.name.startsWith('.'))
787
+ continue;
788
+ // Skip folders that ARE known slugs — only suffixed siblings are suspect.
789
+ if (knownFolderSlugs.has(ent.name))
790
+ continue;
791
+ const m = /^(.+)-(\d+)$/.exec(ent.name);
792
+ if (!m)
793
+ continue;
794
+ const base = m[1];
795
+ if (!knownFolderSlugs.has(base))
796
+ continue;
797
+ out.push({
798
+ abs: resolve(root, ent.name),
799
+ legacyName: ent.name,
800
+ currentSlug: base,
801
+ });
520
802
  }
521
- await walk(root);
522
803
  return out;
523
804
  }
524
- async function pruneEmptyDirs(root) {
525
- if (!existsSync(root))
805
+ function printLegacyFolders(legacy) {
806
+ if (legacy.length === 0)
526
807
  return;
527
- async function walk(d) {
528
- const entries = await readdir(d, { withFileTypes: true });
529
- let isEmpty = true;
530
- for (const ent of entries) {
531
- const full = resolve(d, ent.name);
532
- if (ent.isDirectory()) {
533
- const childEmpty = await walk(full);
534
- if (childEmpty) {
535
- await rm(full, { recursive: true, force: true });
536
- }
537
- else {
538
- isEmpty = false;
539
- }
540
- }
541
- else {
542
- isEmpty = false;
543
- }
544
- }
545
- return isEmpty;
546
- }
547
- const entries = await readdir(root, { withFileTypes: true });
548
- for (const ent of entries) {
549
- if (ent.isDirectory()) {
550
- const full = resolve(root, ent.name);
551
- const empty = await walk(full);
552
- if (empty)
553
- await rm(full, { recursive: true, force: true });
554
- }
808
+ console.log();
809
+ console.log(kleur.bold('Legacy slug folders removed — update your imports:'));
810
+ for (const lf of legacy) {
811
+ console.log(` ${kleur.red('-')} ${lf.legacyName}/ ${kleur.dim(`→ now ${lf.currentSlug}/`)}`);
812
+ console.log(` ${kleur.dim(`Find/replace in your project: ${lf.legacyName}/ → ${lf.currentSlug}/`)}`);
555
813
  }
556
814
  }
815
+ // Note: orphan scan delegates to `walkOutDirPngs` in util/paths.ts (shared
816
+ // with the typed-index emitter). `maxIsoTimestamp` lives in util/iso.ts.