@magicpixelart/cli 0.1.1 → 0.3.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.
@@ -0,0 +1,105 @@
1
+ import kleur from 'kleur';
2
+ import { existsSync } from 'node:fs';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { stdin, stdout } from 'node:process';
5
+ import { resolve } from 'node:path';
6
+ import { configPath } from '../config.js';
7
+ import { hasPackageJson } from '../util/framework.js';
8
+ import { findKeyInDotenv, readCredentialsSync, writeCredentials } from '../util/credentials.js';
9
+ import { initCommand } from './init.js';
10
+ import { loginCommand } from './login.js';
11
+ import { syncCommand } from './sync.js';
12
+ /**
13
+ * One-command bootstrap for new users. Runs init → key prompt → first sync →
14
+ * prints copy-pasteable watch instructions. Designed so a non-technical user
15
+ * can paste `npx magicpixel start` and end up with sprites on disk and a watch
16
+ * script ready to run.
17
+ */
18
+ export async function startCommand(opts = {}) {
19
+ console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
20
+ console.log(kleur.dim(' This walks you through getting your sprites onto disk.'));
21
+ console.log();
22
+ // 1. Need a package.json — refuse to scaffold into a non-project directory.
23
+ if (!hasPackageJson()) {
24
+ console.log(kleur.yellow(' MagicPixel needs a JavaScript project (package.json) to sync into.\n' +
25
+ ' Run this inside your project folder — the one with package.json.'));
26
+ return;
27
+ }
28
+ // 2. Run init non-interactively unless config already exists.
29
+ const cfgPath = configPath();
30
+ if (existsSync(cfgPath) && !opts.force) {
31
+ console.log(kleur.dim(` Found existing magicpixel.json — skipping init.`));
32
+ }
33
+ else {
34
+ await initCommand({ yes: true, force: opts.force });
35
+ }
36
+ // 3. Offer to migrate any key sitting in .env / .env.local.
37
+ await maybeMigrateDotenvKey();
38
+ // 4. Make sure we have a usable key. Env var or stored credentials are fine.
39
+ const haveEnv = !!process.env.MAGICPIXEL_API_KEY;
40
+ const stored = readCredentialsSync();
41
+ if (!haveEnv && !stored) {
42
+ console.log();
43
+ console.log(kleur.bold('Step: connect your account'));
44
+ await loginCommand();
45
+ }
46
+ else if (haveEnv) {
47
+ console.log(kleur.dim(' Using MAGICPIXEL_API_KEY from your environment.'));
48
+ }
49
+ else {
50
+ console.log(kleur.dim(' Using stored credentials from .magicpixel/credentials.'));
51
+ }
52
+ // 5. First sync.
53
+ console.log();
54
+ console.log(kleur.bold('Step: first sync'));
55
+ await syncCommand({ full: true });
56
+ // 6. Tell the user how to run the watch loop.
57
+ console.log();
58
+ console.log(kleur.bold('You\'re set up. ✨'));
59
+ console.log();
60
+ console.log(` ${kleur.green('▶')} ${kleur.bold('npm run magicpixel:watch')} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
61
+ console.log();
62
+ if (await hasDevScript()) {
63
+ console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));
64
+ console.log(kleur.dim(' `npx concurrently "npm run dev" "npm run magicpixel:watch"`'));
65
+ console.log();
66
+ }
67
+ }
68
+ async function hasDevScript() {
69
+ try {
70
+ const path = resolve(process.cwd(), 'package.json');
71
+ if (!existsSync(path))
72
+ return false;
73
+ const { readFile } = await import('node:fs/promises');
74
+ const pkg = JSON.parse(await readFile(path, 'utf8'));
75
+ return typeof pkg.scripts?.dev === 'string';
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ async function maybeMigrateDotenvKey() {
82
+ if (process.env.MAGICPIXEL_API_KEY)
83
+ return;
84
+ if (readCredentialsSync())
85
+ return;
86
+ if (!stdin.isTTY)
87
+ return;
88
+ const found = await findKeyInDotenv();
89
+ if (!found)
90
+ return;
91
+ if (!/^mp_(live|test)_[a-f0-9]{64}$/.test(found.value))
92
+ return;
93
+ const rl = createInterface({ input: stdin, output: stdout });
94
+ try {
95
+ const ans = (await rl.question(`${kleur.cyan('?')} Found MAGICPIXEL_API_KEY in ${found.file} — move it to .magicpixel/credentials so it stays out of your bundler? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
96
+ if (ans === 'n' || ans === 'no')
97
+ return;
98
+ await writeCredentials(found.value);
99
+ console.log(kleur.green(`✓ migrated key from ${found.file} → .magicpixel/credentials`));
100
+ console.log(kleur.dim(` You can now remove the MAGICPIXEL_API_KEY line from ${found.file}.`));
101
+ }
102
+ finally {
103
+ rl.close();
104
+ }
105
+ }
@@ -5,11 +5,12 @@ import { mkdir, writeFile, unlink, readdir, rm, rename } 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
- import { fetchAllManifest, fetchAssetBytes } from '../api.js';
8
+ import { fetchAllManifest, fetchAssetBytes, ApiError } from '../api.js';
9
9
  import { fileSha256 } from '../util/hash.js';
10
- import { assetDiskPath } from '../util/paths.js';
10
+ import { assetDiskPath, assetDiskPathFromKey } from '../util/paths.js';
11
11
  import { createLimit } from '../util/limit.js';
12
- import { emitTypedIndex } from '../util/emitIndex.js';
12
+ import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitIndex.js';
13
+ import { assertPathInsideRoot, tmpPathFor } from '../util/security.js';
13
14
  export async function syncCommand(opts) {
14
15
  if (opts.watch) {
15
16
  await watchLoop(opts);
@@ -19,66 +20,134 @@ export async function syncCommand(opts) {
19
20
  }
20
21
  async function watchLoop(opts) {
21
22
  const intervalSec = typeof opts.watch === 'string' ? Math.max(2, parseInt(opts.watch, 10) || 10) : 10;
22
- console.log(kleur.dim(`[watch] polling every ${intervalSec}s. Ctrl+C to stop.`));
23
+ // Header — print once on start. Project/asset count is best-effort; if the
24
+ // first manifest fetch fails we still want the loop to come up and retry.
25
+ let headerCount = null;
26
+ try {
27
+ const state = await loadState();
28
+ headerCount = state.assets ? Object.keys(state.assets).length : null;
29
+ }
30
+ catch {
31
+ // ignore — header is cosmetic
32
+ }
33
+ console.log(kleur.bold('👀 MagicPixel watching for changes…'));
34
+ console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
35
+ if (headerCount !== null)
36
+ console.log(kleur.dim(` Sprites: ${headerCount}`));
37
+ console.log(kleur.dim(` Polling: every ${intervalSec}s · Stop: Ctrl+C`));
38
+ console.log();
23
39
  let stopping = false;
40
+ let inFlight = false;
41
+ let resolveIdle = null;
42
+ let backoffSec = intervalSec;
43
+ let pausedForAuth = false;
24
44
  const onSigint = () => {
25
45
  if (stopping)
26
46
  return;
27
47
  stopping = true;
28
- // \x1b[2K\r clears any in-flight status line before the goodbye message.
29
48
  process.stdout.write('\x1b[2K\r');
30
- console.log(kleur.dim('[watch] stopped.'));
31
- process.exit(0);
49
+ if (inFlight) {
50
+ console.log(kleur.dim('[watch] finishing current sync… (Ctrl+C again to force quit)'));
51
+ process.once('SIGINT', () => process.exit(130));
52
+ }
53
+ else {
54
+ console.log(kleur.dim('[watch] stopped.'));
55
+ process.exit(0);
56
+ }
32
57
  };
33
58
  process.on('SIGINT', onSigint);
34
- // Run once eagerly so first sync isn't delayed by the interval.
35
- let inFlight = false;
36
59
  const tick = async () => {
37
60
  if (inFlight || stopping)
38
61
  return;
39
62
  inFlight = true;
40
63
  try {
41
- const r = await runOnce({ ...opts, quiet: true, watch: false });
42
- if (r.downloaded > 0 || r.pruned > 0) {
64
+ const r = await runOnce({ ...opts, watch: false }, { watchMode: true });
65
+ // Reset backoff + auth-pause flags on any successful tick.
66
+ backoffSec = intervalSec;
67
+ pausedForAuth = false;
68
+ const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
69
+ if (opts.quiet)
70
+ return;
71
+ if (changedCount > 0) {
43
72
  process.stdout.write('\x1b[2K\r');
44
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} ${r.downloaded} downloaded` +
45
- (r.pruned ? `, ${r.pruned} pruned` : '') +
46
- (r.failed ? kleur.yellow(`, ${r.failed} failed`) : ''));
73
+ const verb = r.removed.length && !r.added.length && !r.modified.length ? 'Removed' : 'Pulled';
74
+ console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} ${verb} ${changedCount} ` +
75
+ `change${changedCount === 1 ? '' : 's'} from MagicPixel:`);
76
+ printChanges(r, /* indent */ ' ');
47
77
  }
48
78
  else {
49
79
  process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} no changes (${r.unchanged} unchanged)`)}`);
50
80
  }
51
81
  }
52
82
  catch (e) {
53
- console.log(`\n${kleur.dim(timestamp())} ${kleur.red('!')} ${e.message.split('\n')[0]}`);
83
+ const err = e;
84
+ const apiErr = err instanceof ApiError ? err : null;
85
+ const firstLine = err.message.split('\n')[0];
86
+ process.stdout.write('\x1b[2K\r');
87
+ if (apiErr && (apiErr.status === 401 || apiErr.status === 403)) {
88
+ if (!pausedForAuth) {
89
+ console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Your key looks invalid or rotated.`);
90
+ console.log(kleur.dim(' Fix: run `magicpixel login` (this watcher will keep retrying every 30s).'));
91
+ }
92
+ pausedForAuth = true;
93
+ backoffSec = 30;
94
+ }
95
+ else if (isNetworkError(err)) {
96
+ if (backoffSec < 30) {
97
+ console.log(`${kleur.dim(timestamp())} ${kleur.yellow('!')} MagicPixel is offline or your internet is. ` +
98
+ `Sprites you already have still work. Retrying in ${Math.min(backoffSec * 2, 60)}s.`);
99
+ }
100
+ backoffSec = Math.min(backoffSec * 2, 60);
101
+ }
102
+ else {
103
+ console.log(`${kleur.dim(timestamp())} ${kleur.red('!')} ${firstLine}`);
104
+ backoffSec = Math.min(backoffSec * 2, 60);
105
+ }
54
106
  }
55
107
  finally {
56
108
  inFlight = false;
109
+ resolveIdle?.();
110
+ resolveIdle = null;
57
111
  }
58
112
  };
59
113
  await tick();
60
114
  while (!stopping) {
61
- await new Promise((r) => setTimeout(r, intervalSec * 1000));
115
+ await new Promise((r) => setTimeout(r, backoffSec * 1000));
116
+ if (stopping)
117
+ break;
62
118
  await tick();
63
119
  }
120
+ if (inFlight) {
121
+ await new Promise((r) => {
122
+ resolveIdle = r;
123
+ });
124
+ }
125
+ console.log(kleur.dim('[watch] stopped.'));
126
+ process.exit(0);
64
127
  }
65
- async function runOnce(opts) {
128
+ function isNetworkError(err) {
129
+ // The api layer wraps fetch failures as `manifest: network error (...)`;
130
+ // bare ENOTFOUND/ETIMEDOUT/etc. also surface here from `fetchAssetBytes`.
131
+ const msg = err.message;
132
+ return (/network error/i.test(msg) ||
133
+ /ENOTFOUND|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN/i.test(msg) ||
134
+ /fetch failed/i.test(msg));
135
+ }
136
+ async function runOnce(opts, runOpts = {}) {
66
137
  const config = await loadConfig();
67
138
  const state = await loadState();
68
139
  const startedAt = new Date().toISOString();
69
140
  const concurrency = Math.max(1, Math.min(opts.concurrency ?? 6, 16));
70
- const quiet = !!opts.quiet;
71
- // Warn loudly when a custom endpoint is configured — easy to forget a test
72
- // override before committing magicpixel.json to a real repo / CI.
73
- if (!quiet && config.endpoint) {
141
+ const verbose = !opts.quiet && !runOpts.watchMode;
142
+ const shouldPrune = opts.prune !== false; // commander: --no-prune sets false
143
+ if (verbose && config.endpoint) {
74
144
  console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
75
145
  }
76
- // Incremental: only fetch what changed since last successful sync.
77
- // --full forces a from-scratch fetch.
78
146
  const since = opts.full ? undefined : state.lastSync;
79
- const spinner = quiet
80
- ? null
81
- : ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start();
147
+ const previousAssets = state.assets ?? {}; // id → key from prior sync
148
+ const spinner = verbose
149
+ ? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
150
+ : null;
82
151
  let manifest;
83
152
  try {
84
153
  manifest = await fetchAllManifest(config, since);
@@ -88,15 +157,23 @@ async function runOnce(opts) {
88
157
  spinner?.fail('Manifest fetch failed');
89
158
  throw e;
90
159
  }
91
- // Diff
160
+ // Detect renames: same id, different key vs prior snapshot.
161
+ const renamed = [];
162
+ for (const entry of manifest) {
163
+ const prevKey = previousAssets[entry.id];
164
+ if (prevKey && prevKey !== entry.key) {
165
+ renamed.push({ id: entry.id, oldKey: prevKey, newKey: entry.key });
166
+ }
167
+ }
168
+ // Diff against disk
92
169
  const toDownload = [];
93
- const skipped = [];
94
170
  let bytesSaved = 0;
171
+ let unchanged = 0;
95
172
  for (const entry of manifest) {
96
173
  const diskPath = assetDiskPath(config.outDir, entry);
97
174
  const localSha = await fileSha256(diskPath);
98
175
  if (entry.sha256 && localSha && entry.sha256 === localSha) {
99
- skipped.push(entry);
176
+ unchanged++;
100
177
  if (entry.size_bytes)
101
178
  bytesSaved += entry.size_bytes;
102
179
  }
@@ -105,51 +182,77 @@ async function runOnce(opts) {
105
182
  }
106
183
  }
107
184
  // Orphan detection only when we have the full picture.
185
+ // (Renames also produce a stale path on disk — collected separately below.)
108
186
  let orphans = [];
109
187
  if (!since) {
110
188
  const remoteDiskPaths = new Set(manifest.map((e) => assetDiskPath(config.outDir, e)));
111
189
  orphans = (await findLocalPngs(resolve(process.cwd(), config.outDir)))
112
190
  .filter((p) => !remoteDiskPaths.has(p));
113
191
  }
114
- if (!quiet) {
192
+ // Stale paths from detected renames (always pruned, even in incremental mode —
193
+ // otherwise the old PNG silently lingers next to the renamed copy).
194
+ const renameStalePaths = renamed
195
+ .map((r) => assetDiskPathFromKey(config.outDir, r.oldKey))
196
+ .filter((p) => existsSync(p));
197
+ // De-duplicate vs the full-sync orphan list.
198
+ const orphanSet = new Set(orphans);
199
+ for (const p of renameStalePaths)
200
+ orphanSet.add(p);
201
+ orphans = [...orphanSet];
202
+ if (verbose) {
115
203
  console.log();
116
204
  console.log(kleur.bold('Plan:'));
117
205
  console.log(` ${kleur.green('+')} download ${toDownload.length}`);
118
- console.log(` ${kleur.dim('=')} unchanged ${skipped.length}${bytesSaved ? kleur.dim(` (~${formatBytes(bytesSaved)} saved)`) : ''}`);
119
- if (since) {
206
+ console.log(` ${kleur.dim('=')} unchanged ${unchanged}${bytesSaved ? kleur.dim(` (~${formatBytes(bytesSaved)} saved)`) : ''}`);
207
+ if (renamed.length)
208
+ console.log(` ${kleur.cyan('↪')} renamed ${renamed.length}`);
209
+ if (since && !renamed.length) {
120
210
  console.log(` ${kleur.dim('orphan check skipped (incremental — use --full)')}`);
121
211
  }
122
212
  else if (orphans.length > 0) {
123
- const verb = opts.prune ? kleur.red('delete') : kleur.yellow('keep (orphan)');
213
+ const verb = shouldPrune ? kleur.red('delete') : kleur.yellow('keep (orphan)');
124
214
  console.log(` ${verb} ${orphans.length}`);
125
215
  }
126
216
  console.log();
127
217
  }
128
218
  if (opts.dryRun) {
129
- if (!quiet) {
219
+ if (verbose) {
130
220
  console.log(kleur.dim('--dry-run: no files written.'));
221
+ if (renamed.length > 0)
222
+ printRenames(renamed, { withHints: false });
131
223
  if (orphans.length > 0)
132
224
  printOrphans(orphans);
133
225
  }
134
- return { downloaded: 0, unchanged: skipped.length, pruned: 0, failed: 0, bytesIn: 0, bytesSaved };
226
+ return {
227
+ added: [],
228
+ modified: [],
229
+ unchanged,
230
+ removed: [],
231
+ renamed,
232
+ failed: 0,
233
+ bytesIn: 0,
234
+ bytesSaved,
235
+ };
135
236
  }
136
- // Download with progress bar
137
237
  const result = {
138
- downloaded: 0,
139
- unchanged: skipped.length,
140
- pruned: 0,
238
+ added: [],
239
+ modified: [],
240
+ unchanged,
241
+ removed: [],
242
+ renamed,
141
243
  failed: 0,
142
244
  bytesIn: 0,
143
245
  bytesSaved,
144
246
  };
145
247
  let progress = null;
146
- if (!quiet && toDownload.length > 0) {
248
+ if (verbose && toDownload.length > 0) {
147
249
  progress = ora({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
148
250
  }
149
251
  const run = createLimit(concurrency);
150
252
  let done = 0;
151
253
  await Promise.all(toDownload.map((entry) => run(async () => {
152
254
  const diskPath = assetDiskPath(config.outDir, entry);
255
+ const existedBefore = existsSync(diskPath);
153
256
  try {
154
257
  const localSha = await fileSha256(diskPath);
155
258
  const bytes = await fetchAssetBytes(config, entry.key, localSha);
@@ -157,8 +260,6 @@ async function runOnce(opts) {
157
260
  result.unchanged++;
158
261
  }
159
262
  else {
160
- // Verify the payload matches what the manifest advertised. Guards
161
- // against silently writing a corrupted body to disk.
162
263
  if (entry.sha256) {
163
264
  const actual = createHash('sha256').update(bytes).digest('hex');
164
265
  if (actual !== entry.sha256) {
@@ -166,10 +267,14 @@ async function runOnce(opts) {
166
267
  }
167
268
  }
168
269
  await mkdir(dirname(diskPath), { recursive: true });
169
- const tmp = `${diskPath}.${process.pid}.tmp`;
270
+ const tmp = tmpPathFor(diskPath);
271
+ assertPathInsideRoot(tmp, resolve(process.cwd(), config.outDir), 'outDir');
170
272
  await writeFile(tmp, bytes);
171
273
  await rename(tmp, diskPath);
172
- result.downloaded++;
274
+ if (existedBefore)
275
+ result.modified.push(entry.key);
276
+ else
277
+ result.added.push(entry.key);
173
278
  result.bytesIn += bytes.byteLength;
174
279
  }
175
280
  }
@@ -185,57 +290,146 @@ async function runOnce(opts) {
185
290
  progress.text = progressText(done, toDownload.length, result.bytesIn);
186
291
  }
187
292
  })));
293
+ const downloaded = result.added.length + result.modified.length;
188
294
  if (progress) {
189
295
  if (result.failed === 0)
190
- progress.succeed(`Downloaded ${result.downloaded} (${formatBytes(result.bytesIn)})`);
296
+ progress.succeed(`Downloaded ${downloaded} (${formatBytes(result.bytesIn)})`);
191
297
  else
192
- progress.warn(`Downloaded ${result.downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
298
+ progress.warn(`Downloaded ${downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
193
299
  }
194
- // Prune
195
- if (opts.prune && orphans.length > 0) {
300
+ // Prune (now default — pass --no-prune to opt out).
301
+ if (shouldPrune && orphans.length > 0) {
302
+ const outRoot = resolve(process.cwd(), config.outDir);
196
303
  for (const p of orphans) {
197
- await unlink(p);
198
- if (!quiet)
199
- console.log(` ${kleur.red('-')} ${relative(process.cwd(), p)}`);
304
+ assertPathInsideRoot(p, outRoot, 'outDir');
305
+ try {
306
+ await unlink(p);
307
+ const relPath = relative(outRoot, p).replace(/\\/g, '/');
308
+ const key = relPath.endsWith('.png') ? relPath.slice(0, -4) : relPath;
309
+ result.removed.push(key);
310
+ if (verbose)
311
+ console.log(` ${kleur.red('-')} ${relative(process.cwd(), p)}`);
312
+ }
313
+ catch (e) {
314
+ if (verbose)
315
+ console.log(` ${kleur.yellow('!')} failed to prune ${relative(process.cwd(), p)}: ${e.message}`);
316
+ }
200
317
  }
201
318
  await pruneEmptyDirs(resolve(process.cwd(), config.outDir));
202
- result.pruned = orphans.length;
203
319
  }
204
- else if (!quiet && orphans.length > 0) {
320
+ else if (verbose && orphans.length > 0) {
205
321
  printOrphans(orphans);
206
322
  }
207
- // Emit typed index if enabled AND any file change happened (or first sync).
208
- // In incremental mode `manifest` only contains changed entries, so we must
209
- // re-fetch the full manifest (cheap — JSON only) to write a complete index.
210
- if (config.emitIndex && (result.downloaded > 0 || result.pruned > 0 || !since)) {
211
- try {
212
- const fullManifest = since ? await fetchAllManifest(config) : manifest;
213
- const indexPath = await emitTypedIndex(config.outDir, fullManifest);
214
- if (!quiet)
215
- console.log(kleur.dim(` index → ${relative(process.cwd(), indexPath)}`));
216
- }
217
- catch (e) {
218
- if (!quiet)
219
- console.log(kleur.yellow(` index emit failed: ${e.message}`));
323
+ // Always persist the id → key snapshot so rename detection survives even
324
+ // when `emitIndex` is toggled off-then-on. `lastSync` only advances on a
325
+ // clean run, so partial-failure syncs are retried from the prior cursor.
326
+ const nextAssets = { ...previousAssets };
327
+ // Drop renamed ids (they'll be re-added with the new key below).
328
+ for (const r of renamed)
329
+ delete nextAssets[r.id];
330
+ for (const e of manifest)
331
+ nextAssets[e.id] = e.key;
332
+ // Drop ids whose key was pruned (orphans we just removed from disk).
333
+ if (result.removed.length > 0) {
334
+ const removedKeys = new Set(result.removed);
335
+ for (const [id, key] of Object.entries(nextAssets)) {
336
+ if (removedKeys.has(key))
337
+ delete nextAssets[id];
220
338
  }
221
339
  }
340
+ const nextState = { ...state, assets: nextAssets };
222
341
  if (result.failed === 0) {
223
- await saveState({ lastSync: startedAt });
342
+ nextState.lastSync = startedAt;
343
+ delete nextState.lastError;
344
+ }
345
+ else {
346
+ nextState.lastError = `${result.failed} download${result.failed === 1 ? '' : 's'} failed at ${startedAt}`;
347
+ }
348
+ // Emit typed index from the filesystem — never from the manifest. This
349
+ // guarantees the barrel can never disagree with what bundlers see on disk,
350
+ // and removes the silent-failure mode where a flaky manifest call left
351
+ // `index.ts` stale despite the PNGs being fresh.
352
+ if (config.emitIndex) {
353
+ const idByKey = {};
354
+ for (const [id, key] of Object.entries(nextAssets))
355
+ idByKey[key] = id;
356
+ const diskEntries = await scanDiskAssets(config.outDir);
357
+ for (const e of diskEntries) {
358
+ if (idByKey[e.key])
359
+ e.id = idByKey[e.key];
360
+ }
361
+ const indexPath = await emitTypedIndex(config.outDir, diskEntries);
362
+ if (verbose)
363
+ console.log(kleur.dim(` index → ${relative(process.cwd(), indexPath)}`));
364
+ // Nudge AI tools toward the typed index. Idempotent — `ensureAgentsDoc`
365
+ // no-ops once our marker section is present.
366
+ try {
367
+ const agentsResult = await ensureAgentsDoc(config.outDir);
368
+ if (verbose && agentsResult !== 'unchanged') {
369
+ console.log(kleur.dim(` AGENTS.md ${agentsResult}`));
370
+ }
371
+ }
372
+ catch {
373
+ // Never let an AGENTS.md write failure break sync.
374
+ }
224
375
  }
225
- else if (!quiet) {
376
+ await saveState(nextState);
377
+ if (result.failed > 0 && verbose) {
226
378
  console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
227
379
  }
228
- if (!quiet) {
380
+ if (verbose) {
229
381
  console.log();
230
- const summary = `downloaded ${result.downloaded}, unchanged ${result.unchanged}` +
231
- (opts.prune ? `, pruned ${result.pruned}` : '') +
382
+ const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
383
+ `unchanged ${result.unchanged}` +
384
+ (result.bytesSaved ? kleur.dim(` (~${formatBytes(result.bytesSaved)} saved)`) : '') +
385
+ (shouldPrune ? `, pruned ${result.removed.length}` : '') +
386
+ (renamed.length ? `, renamed ${renamed.length}` : '') +
232
387
  (result.failed ? `, failed ${result.failed}` : '');
233
388
  console.log(result.failed ? kleur.yellow(`done with errors. ${summary}`) : kleur.green(`✓ done. ${summary}`));
389
+ printChanges(result, ' ');
390
+ if (renamed.length > 0)
391
+ printRenames(renamed, { withHints: true });
234
392
  }
235
393
  if (result.failed)
236
394
  process.exitCode = 1;
237
395
  return result;
238
396
  }
397
+ const CHANGE_PRINT_CAP = 50;
398
+ function printChanges(r, indent) {
399
+ const lines = [];
400
+ for (const k of r.added)
401
+ lines.push(`${indent}${kleur.green('+')} ${k}`);
402
+ for (const k of r.modified)
403
+ lines.push(`${indent}${kleur.cyan('~')} ${k}`);
404
+ for (const r2 of r.renamed)
405
+ lines.push(`${indent}${kleur.cyan('↪')} ${r2.oldKey} → ${r2.newKey}`);
406
+ for (const k of r.removed)
407
+ lines.push(`${indent}${kleur.red('-')} ${k}`);
408
+ if (lines.length === 0)
409
+ return;
410
+ if (lines.length <= CHANGE_PRINT_CAP) {
411
+ for (const l of lines)
412
+ console.log(l);
413
+ }
414
+ else {
415
+ for (const l of lines.slice(0, CHANGE_PRINT_CAP))
416
+ console.log(l);
417
+ console.log(`${indent}${kleur.dim(`…and ${lines.length - CHANGE_PRINT_CAP} more`)}`);
418
+ }
419
+ }
420
+ function printRenames(renamed, opts) {
421
+ if (renamed.length === 0)
422
+ return;
423
+ console.log();
424
+ console.log(opts.withHints ? kleur.bold('Renamed assets — update imports:') : kleur.cyan('Renames detected:'));
425
+ for (const r of renamed) {
426
+ console.log(` ${kleur.cyan('↪')} ${r.oldKey} → ${r.newKey}`);
427
+ if (opts.withHints) {
428
+ console.log(` ${kleur.dim(`MagicPixelAssets['${r.oldKey}'] → MagicPixelAssets['${r.newKey}']`)}`);
429
+ console.log(` ${kleur.dim(`or pin to id: MagicPixelAssetsById['${r.id}'] (survives future renames)`)}`);
430
+ }
431
+ }
432
+ }
239
433
  function progressText(done, total, bytes) {
240
434
  const pct = total === 0 ? 100 : Math.round((done / total) * 100);
241
435
  const barWidth = 24;
@@ -256,8 +450,7 @@ function timestamp() {
256
450
  }
257
451
  function humanTime(iso) {
258
452
  try {
259
- const d = new Date(iso);
260
- return d.toLocaleString();
453
+ return new Date(iso).toLocaleString();
261
454
  }
262
455
  catch {
263
456
  return iso;
@@ -269,17 +462,28 @@ function printOrphans(orphans) {
269
462
  for (const p of orphans) {
270
463
  console.log(` ${kleur.dim('?')} ${relative(process.cwd(), p)}`);
271
464
  }
272
- console.log(kleur.dim(' Run with --prune to delete them.'));
465
+ console.log(kleur.dim(' Pruning is on by default. Re-run without --no-prune to delete them.'));
273
466
  }
274
467
  async function findLocalPngs(dir) {
275
468
  if (!existsSync(dir))
276
469
  return [];
470
+ const root = resolve(dir);
277
471
  const out = [];
278
472
  async function walk(d) {
279
473
  const entries = await readdir(d, { withFileTypes: true });
280
474
  for (const ent of entries) {
475
+ if (ent.isSymbolicLink())
476
+ continue;
281
477
  const full = resolve(d, ent.name);
478
+ try {
479
+ assertPathInsideRoot(full, root, 'outDir');
480
+ }
481
+ catch {
482
+ continue;
483
+ }
282
484
  if (ent.isDirectory()) {
485
+ if (ent.name.startsWith('.'))
486
+ continue;
283
487
  await walk(full);
284
488
  }
285
489
  else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
@@ -287,7 +491,7 @@ async function findLocalPngs(dir) {
287
491
  }
288
492
  }
289
493
  }
290
- await walk(dir);
494
+ await walk(root);
291
495
  return out;
292
496
  }
293
497
  async function pruneEmptyDirs(root) {