@magicpixelart/cli 0.1.2 → 0.3.3

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.
@@ -5,11 +5,11 @@ 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
13
  import { assertPathInsideRoot, tmpPathFor } from '../util/security.js';
14
14
  export async function syncCommand(opts) {
15
15
  if (opts.watch) {
@@ -19,19 +19,46 @@ export async function syncCommand(opts) {
19
19
  await runOnce(opts);
20
20
  }
21
21
  async function watchLoop(opts) {
22
- const intervalSec = typeof opts.watch === 'string' ? Math.max(2, parseInt(opts.watch, 10) || 10) : 10;
23
- console.log(kleur.dim(`[watch] polling every ${intervalSec}s. Ctrl+C to stop.`));
22
+ // Default 2s — matches the perceived-instant UX users expect when editing
23
+ // sprites in MagicPixel and watching them refresh in their game's dev
24
+ // server. Incremental polls send `?since=<lastSync>` so an empty manifest
25
+ // 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
+ // Header — print once on start. Project/asset count is best-effort; if the
30
+ // first manifest fetch fails we still want the loop to come up and retry.
31
+ let headerCount = null;
32
+ try {
33
+ const state = await loadState();
34
+ headerCount = state.assets ? Object.keys(state.assets).length : null;
35
+ }
36
+ catch {
37
+ // ignore — header is cosmetic
38
+ }
39
+ console.log(kleur.bold('👀 MagicPixel watching for changes…'));
40
+ console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
41
+ if (headerCount !== null)
42
+ console.log(kleur.dim(` Sprites: ${headerCount}`));
43
+ console.log(kleur.dim(` Polling: every ${intervalSec}s · Stop: Ctrl+C`));
44
+ console.log();
24
45
  let stopping = false;
25
46
  let inFlight = false;
26
47
  let resolveIdle = null;
48
+ let backoffSec = intervalSec;
49
+ let pausedForAuth = false;
50
+ // 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,
53
+ // so the "edit → see it" promise stays intact the moment the user comes
54
+ // back. Error backoff (2→60s) is separate and continues to win.
55
+ let consecutiveIdleTicks = 0;
27
56
  const onSigint = () => {
28
57
  if (stopping)
29
58
  return;
30
59
  stopping = true;
31
60
  process.stdout.write('\x1b[2K\r');
32
61
  if (inFlight) {
33
- // Let the current sync drain so we don't leave half-written *.tmp files
34
- // or an unflushed lastSync state. Second Ctrl+C hard-exits.
35
62
  console.log(kleur.dim('[watch] finishing current sync… (Ctrl+C again to force quit)'));
36
63
  process.once('SIGINT', () => process.exit(130));
37
64
  }
@@ -41,25 +68,68 @@ async function watchLoop(opts) {
41
68
  }
42
69
  };
43
70
  process.on('SIGINT', onSigint);
44
- // Run once eagerly so first sync isn't delayed by the interval.
45
71
  const tick = async () => {
46
72
  if (inFlight || stopping)
47
73
  return;
48
74
  inFlight = true;
49
75
  try {
50
- const r = await runOnce({ ...opts, quiet: true, watch: false });
51
- if (r.downloaded > 0 || r.pruned > 0) {
76
+ const r = await runOnce({ ...opts, watch: false }, { watchMode: true });
77
+ // Reset backoff + auth-pause flags on any successful tick.
78
+ backoffSec = intervalSec;
79
+ pausedForAuth = false;
80
+ const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
81
+ if (changedCount > 0) {
82
+ // Snap back to the fast interval the moment anything changes.
83
+ consecutiveIdleTicks = 0;
84
+ backoffSec = intervalSec;
85
+ }
86
+ else {
87
+ 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);
93
+ }
94
+ if (opts.quiet)
95
+ return;
96
+ if (changedCount > 0) {
52
97
  process.stdout.write('\x1b[2K\r');
53
- console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} ${r.downloaded} downloaded` +
54
- (r.pruned ? `, ${r.pruned} pruned` : '') +
55
- (r.failed ? kleur.yellow(`, ${r.failed} failed`) : ''));
98
+ const verb = r.removed.length && !r.added.length && !r.modified.length ? 'Removed' : 'Pulled';
99
+ console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} ${verb} ${changedCount} ` +
100
+ `change${changedCount === 1 ? '' : 's'} from MagicPixel:`);
101
+ printChanges(r, /* indent */ ' ');
56
102
  }
57
103
  else {
58
104
  process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} no changes (${r.unchanged} unchanged)`)}`);
59
105
  }
60
106
  }
61
107
  catch (e) {
62
- console.log(`\n${kleur.dim(timestamp())} ${kleur.red('!')} ${e.message.split('\n')[0]}`);
108
+ const err = e;
109
+ const apiErr = err instanceof ApiError ? err : null;
110
+ const firstLine = err.message.split('\n')[0];
111
+ process.stdout.write('\x1b[2K\r');
112
+ // Any error breaks the idle streak so we come back fast once it clears.
113
+ consecutiveIdleTicks = 0;
114
+ if (apiErr && (apiErr.status === 401 || apiErr.status === 403)) {
115
+ if (!pausedForAuth) {
116
+ console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Your key looks invalid or rotated.`);
117
+ console.log(kleur.dim(' Fix: run `magicpixel login` (this watcher will keep retrying every 30s).'));
118
+ }
119
+ pausedForAuth = true;
120
+ backoffSec = 30;
121
+ }
122
+ else if (isNetworkError(err)) {
123
+ if (backoffSec < 30) {
124
+ 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.`);
126
+ }
127
+ backoffSec = Math.min(backoffSec * 2, 60);
128
+ }
129
+ else {
130
+ console.log(`${kleur.dim(timestamp())} ${kleur.red('!')} ${firstLine}`);
131
+ backoffSec = Math.min(backoffSec * 2, 60);
132
+ }
63
133
  }
64
134
  finally {
65
135
  inFlight = false;
@@ -69,12 +139,11 @@ async function watchLoop(opts) {
69
139
  };
70
140
  await tick();
71
141
  while (!stopping) {
72
- await new Promise((r) => setTimeout(r, intervalSec * 1000));
142
+ await new Promise((r) => setTimeout(r, backoffSec * 1000));
73
143
  if (stopping)
74
144
  break;
75
145
  await tick();
76
146
  }
77
- // SIGINT arrived mid-tick — wait for it to drain, then exit cleanly.
78
147
  if (inFlight) {
79
148
  await new Promise((r) => {
80
149
  resolveIdle = r;
@@ -83,23 +152,29 @@ async function watchLoop(opts) {
83
152
  console.log(kleur.dim('[watch] stopped.'));
84
153
  process.exit(0);
85
154
  }
86
- async function runOnce(opts) {
155
+ function isNetworkError(err) {
156
+ // The api layer wraps fetch failures as `manifest: network error (...)`;
157
+ // bare ENOTFOUND/ETIMEDOUT/etc. also surface here from `fetchAssetBytes`.
158
+ const msg = err.message;
159
+ return (/network error/i.test(msg) ||
160
+ /ENOTFOUND|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN/i.test(msg) ||
161
+ /fetch failed/i.test(msg));
162
+ }
163
+ async function runOnce(opts, runOpts = {}) {
87
164
  const config = await loadConfig();
88
165
  const state = await loadState();
89
166
  const startedAt = new Date().toISOString();
90
167
  const concurrency = Math.max(1, Math.min(opts.concurrency ?? 6, 16));
91
- const quiet = !!opts.quiet;
92
- // Warn loudly when a custom endpoint is configured — easy to forget a test
93
- // override before committing magicpixel.json to a real repo / CI.
94
- if (!quiet && config.endpoint) {
168
+ const verbose = !opts.quiet && !runOpts.watchMode;
169
+ const shouldPrune = opts.prune !== false; // commander: --no-prune sets false
170
+ if (verbose && config.endpoint) {
95
171
  console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
96
172
  }
97
- // Incremental: only fetch what changed since last successful sync.
98
- // --full forces a from-scratch fetch.
99
173
  const since = opts.full ? undefined : state.lastSync;
100
- const spinner = quiet
101
- ? null
102
- : ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start();
174
+ const previousAssets = state.assets ?? {}; // id → key from prior sync
175
+ const spinner = verbose
176
+ ? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
177
+ : null;
103
178
  let manifest;
104
179
  try {
105
180
  manifest = await fetchAllManifest(config, since);
@@ -109,15 +184,23 @@ async function runOnce(opts) {
109
184
  spinner?.fail('Manifest fetch failed');
110
185
  throw e;
111
186
  }
112
- // Diff
187
+ // Detect renames: same id, different key vs prior snapshot.
188
+ const renamed = [];
189
+ for (const entry of manifest) {
190
+ const prevKey = previousAssets[entry.id];
191
+ if (prevKey && prevKey !== entry.key) {
192
+ renamed.push({ id: entry.id, oldKey: prevKey, newKey: entry.key });
193
+ }
194
+ }
195
+ // Diff against disk
113
196
  const toDownload = [];
114
- const skipped = [];
115
197
  let bytesSaved = 0;
198
+ let unchanged = 0;
116
199
  for (const entry of manifest) {
117
200
  const diskPath = assetDiskPath(config.outDir, entry);
118
201
  const localSha = await fileSha256(diskPath);
119
202
  if (entry.sha256 && localSha && entry.sha256 === localSha) {
120
- skipped.push(entry);
203
+ unchanged++;
121
204
  if (entry.size_bytes)
122
205
  bytesSaved += entry.size_bytes;
123
206
  }
@@ -126,51 +209,77 @@ async function runOnce(opts) {
126
209
  }
127
210
  }
128
211
  // Orphan detection only when we have the full picture.
212
+ // (Renames also produce a stale path on disk — collected separately below.)
129
213
  let orphans = [];
130
214
  if (!since) {
131
215
  const remoteDiskPaths = new Set(manifest.map((e) => assetDiskPath(config.outDir, e)));
132
216
  orphans = (await findLocalPngs(resolve(process.cwd(), config.outDir)))
133
217
  .filter((p) => !remoteDiskPaths.has(p));
134
218
  }
135
- if (!quiet) {
219
+ // Stale paths from detected renames (always pruned, even in incremental mode —
220
+ // otherwise the old PNG silently lingers next to the renamed copy).
221
+ const renameStalePaths = renamed
222
+ .map((r) => assetDiskPathFromKey(config.outDir, r.oldKey))
223
+ .filter((p) => existsSync(p));
224
+ // De-duplicate vs the full-sync orphan list.
225
+ const orphanSet = new Set(orphans);
226
+ for (const p of renameStalePaths)
227
+ orphanSet.add(p);
228
+ orphans = [...orphanSet];
229
+ if (verbose) {
136
230
  console.log();
137
231
  console.log(kleur.bold('Plan:'));
138
232
  console.log(` ${kleur.green('+')} download ${toDownload.length}`);
139
- console.log(` ${kleur.dim('=')} unchanged ${skipped.length}${bytesSaved ? kleur.dim(` (~${formatBytes(bytesSaved)} saved)`) : ''}`);
140
- if (since) {
233
+ console.log(` ${kleur.dim('=')} unchanged ${unchanged}${bytesSaved ? kleur.dim(` (~${formatBytes(bytesSaved)} saved)`) : ''}`);
234
+ if (renamed.length)
235
+ console.log(` ${kleur.cyan('↪')} renamed ${renamed.length}`);
236
+ if (since && !renamed.length) {
141
237
  console.log(` ${kleur.dim('orphan check skipped (incremental — use --full)')}`);
142
238
  }
143
239
  else if (orphans.length > 0) {
144
- const verb = opts.prune ? kleur.red('delete') : kleur.yellow('keep (orphan)');
240
+ const verb = shouldPrune ? kleur.red('delete') : kleur.yellow('keep (orphan)');
145
241
  console.log(` ${verb} ${orphans.length}`);
146
242
  }
147
243
  console.log();
148
244
  }
149
245
  if (opts.dryRun) {
150
- if (!quiet) {
246
+ if (verbose) {
151
247
  console.log(kleur.dim('--dry-run: no files written.'));
248
+ if (renamed.length > 0)
249
+ printRenames(renamed, { withHints: false });
152
250
  if (orphans.length > 0)
153
251
  printOrphans(orphans);
154
252
  }
155
- return { downloaded: 0, unchanged: skipped.length, pruned: 0, failed: 0, bytesIn: 0, bytesSaved };
253
+ return {
254
+ added: [],
255
+ modified: [],
256
+ unchanged,
257
+ removed: [],
258
+ renamed,
259
+ failed: 0,
260
+ bytesIn: 0,
261
+ bytesSaved,
262
+ };
156
263
  }
157
- // Download with progress bar
158
264
  const result = {
159
- downloaded: 0,
160
- unchanged: skipped.length,
161
- pruned: 0,
265
+ added: [],
266
+ modified: [],
267
+ unchanged,
268
+ removed: [],
269
+ renamed,
162
270
  failed: 0,
163
271
  bytesIn: 0,
164
272
  bytesSaved,
165
273
  };
166
274
  let progress = null;
167
- if (!quiet && toDownload.length > 0) {
275
+ if (verbose && toDownload.length > 0) {
168
276
  progress = ora({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
169
277
  }
170
278
  const run = createLimit(concurrency);
171
279
  let done = 0;
172
280
  await Promise.all(toDownload.map((entry) => run(async () => {
173
281
  const diskPath = assetDiskPath(config.outDir, entry);
282
+ const existedBefore = existsSync(diskPath);
174
283
  try {
175
284
  const localSha = await fileSha256(diskPath);
176
285
  const bytes = await fetchAssetBytes(config, entry.key, localSha);
@@ -178,8 +287,6 @@ async function runOnce(opts) {
178
287
  result.unchanged++;
179
288
  }
180
289
  else {
181
- // Verify the payload matches what the manifest advertised. Guards
182
- // against silently writing a corrupted body to disk.
183
290
  if (entry.sha256) {
184
291
  const actual = createHash('sha256').update(bytes).digest('hex');
185
292
  if (actual !== entry.sha256) {
@@ -191,7 +298,10 @@ async function runOnce(opts) {
191
298
  assertPathInsideRoot(tmp, resolve(process.cwd(), config.outDir), 'outDir');
192
299
  await writeFile(tmp, bytes);
193
300
  await rename(tmp, diskPath);
194
- result.downloaded++;
301
+ if (existedBefore)
302
+ result.modified.push(entry.key);
303
+ else
304
+ result.added.push(entry.key);
195
305
  result.bytesIn += bytes.byteLength;
196
306
  }
197
307
  }
@@ -207,59 +317,146 @@ async function runOnce(opts) {
207
317
  progress.text = progressText(done, toDownload.length, result.bytesIn);
208
318
  }
209
319
  })));
320
+ const downloaded = result.added.length + result.modified.length;
210
321
  if (progress) {
211
322
  if (result.failed === 0)
212
- progress.succeed(`Downloaded ${result.downloaded} (${formatBytes(result.bytesIn)})`);
323
+ progress.succeed(`Downloaded ${downloaded} (${formatBytes(result.bytesIn)})`);
213
324
  else
214
- progress.warn(`Downloaded ${result.downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
325
+ progress.warn(`Downloaded ${downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
215
326
  }
216
- // Prune
217
- if (opts.prune && orphans.length > 0) {
327
+ // Prune (now default — pass --no-prune to opt out).
328
+ if (shouldPrune && orphans.length > 0) {
218
329
  const outRoot = resolve(process.cwd(), config.outDir);
219
330
  for (const p of orphans) {
220
331
  assertPathInsideRoot(p, outRoot, 'outDir');
221
- await unlink(p);
222
- if (!quiet)
223
- console.log(` ${kleur.red('-')} ${relative(process.cwd(), p)}`);
332
+ try {
333
+ await unlink(p);
334
+ const relPath = relative(outRoot, p).replace(/\\/g, '/');
335
+ const key = relPath.endsWith('.png') ? relPath.slice(0, -4) : relPath;
336
+ result.removed.push(key);
337
+ if (verbose)
338
+ console.log(` ${kleur.red('-')} ${relative(process.cwd(), p)}`);
339
+ }
340
+ catch (e) {
341
+ if (verbose)
342
+ console.log(` ${kleur.yellow('!')} failed to prune ${relative(process.cwd(), p)}: ${e.message}`);
343
+ }
224
344
  }
225
345
  await pruneEmptyDirs(resolve(process.cwd(), config.outDir));
226
- result.pruned = orphans.length;
227
346
  }
228
- else if (!quiet && orphans.length > 0) {
347
+ else if (verbose && orphans.length > 0) {
229
348
  printOrphans(orphans);
230
349
  }
231
- // Emit typed index if enabled AND any file change happened (or first sync).
232
- // In incremental mode `manifest` only contains changed entries, so we must
233
- // re-fetch the full manifest (cheap — JSON only) to write a complete index.
234
- if (config.emitIndex && (result.downloaded > 0 || result.pruned > 0 || !since)) {
235
- try {
236
- const fullManifest = since ? await fetchAllManifest(config) : manifest;
237
- const indexPath = await emitTypedIndex(config.outDir, fullManifest);
238
- if (!quiet)
239
- console.log(kleur.dim(` index → ${relative(process.cwd(), indexPath)}`));
240
- }
241
- catch (e) {
242
- if (!quiet)
243
- console.log(kleur.yellow(` index emit failed: ${e.message}`));
350
+ // Always persist the id → key snapshot so rename detection survives even
351
+ // when `emitIndex` is toggled off-then-on. `lastSync` only advances on a
352
+ // clean run, so partial-failure syncs are retried from the prior cursor.
353
+ const nextAssets = { ...previousAssets };
354
+ // Drop renamed ids (they'll be re-added with the new key below).
355
+ for (const r of renamed)
356
+ delete nextAssets[r.id];
357
+ for (const e of manifest)
358
+ nextAssets[e.id] = e.key;
359
+ // Drop ids whose key was pruned (orphans we just removed from disk).
360
+ if (result.removed.length > 0) {
361
+ const removedKeys = new Set(result.removed);
362
+ for (const [id, key] of Object.entries(nextAssets)) {
363
+ if (removedKeys.has(key))
364
+ delete nextAssets[id];
244
365
  }
245
366
  }
367
+ const nextState = { ...state, assets: nextAssets };
246
368
  if (result.failed === 0) {
247
- await saveState({ lastSync: startedAt });
369
+ nextState.lastSync = startedAt;
370
+ delete nextState.lastError;
371
+ }
372
+ else {
373
+ nextState.lastError = `${result.failed} download${result.failed === 1 ? '' : 's'} failed at ${startedAt}`;
374
+ }
375
+ // Emit typed index from the filesystem — never from the manifest. This
376
+ // guarantees the barrel can never disagree with what bundlers see on disk,
377
+ // and removes the silent-failure mode where a flaky manifest call left
378
+ // `index.ts` stale despite the PNGs being fresh.
379
+ if (config.emitIndex) {
380
+ const idByKey = {};
381
+ for (const [id, key] of Object.entries(nextAssets))
382
+ idByKey[key] = id;
383
+ const diskEntries = await scanDiskAssets(config.outDir);
384
+ for (const e of diskEntries) {
385
+ if (idByKey[e.key])
386
+ e.id = idByKey[e.key];
387
+ }
388
+ const indexPath = await emitTypedIndex(config.outDir, diskEntries);
389
+ if (verbose)
390
+ 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.
401
+ }
248
402
  }
249
- else if (!quiet) {
403
+ await saveState(nextState);
404
+ if (result.failed > 0 && verbose) {
250
405
  console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
251
406
  }
252
- if (!quiet) {
407
+ if (verbose) {
253
408
  console.log();
254
- const summary = `downloaded ${result.downloaded}, unchanged ${result.unchanged}` +
255
- (opts.prune ? `, pruned ${result.pruned}` : '') +
409
+ const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
410
+ `unchanged ${result.unchanged}` +
411
+ (result.bytesSaved ? kleur.dim(` (~${formatBytes(result.bytesSaved)} saved)`) : '') +
412
+ (shouldPrune ? `, pruned ${result.removed.length}` : '') +
413
+ (renamed.length ? `, renamed ${renamed.length}` : '') +
256
414
  (result.failed ? `, failed ${result.failed}` : '');
257
415
  console.log(result.failed ? kleur.yellow(`done with errors. ${summary}`) : kleur.green(`✓ done. ${summary}`));
416
+ printChanges(result, ' ');
417
+ if (renamed.length > 0)
418
+ printRenames(renamed, { withHints: true });
258
419
  }
259
420
  if (result.failed)
260
421
  process.exitCode = 1;
261
422
  return result;
262
423
  }
424
+ const CHANGE_PRINT_CAP = 50;
425
+ function printChanges(r, indent) {
426
+ const lines = [];
427
+ for (const k of r.added)
428
+ lines.push(`${indent}${kleur.green('+')} ${k}`);
429
+ for (const k of r.modified)
430
+ lines.push(`${indent}${kleur.cyan('~')} ${k}`);
431
+ for (const r2 of r.renamed)
432
+ lines.push(`${indent}${kleur.cyan('↪')} ${r2.oldKey} → ${r2.newKey}`);
433
+ for (const k of r.removed)
434
+ lines.push(`${indent}${kleur.red('-')} ${k}`);
435
+ if (lines.length === 0)
436
+ return;
437
+ if (lines.length <= CHANGE_PRINT_CAP) {
438
+ for (const l of lines)
439
+ console.log(l);
440
+ }
441
+ else {
442
+ for (const l of lines.slice(0, CHANGE_PRINT_CAP))
443
+ console.log(l);
444
+ console.log(`${indent}${kleur.dim(`…and ${lines.length - CHANGE_PRINT_CAP} more`)}`);
445
+ }
446
+ }
447
+ function printRenames(renamed, opts) {
448
+ if (renamed.length === 0)
449
+ return;
450
+ console.log();
451
+ console.log(opts.withHints ? kleur.bold('Renamed assets — update imports:') : kleur.cyan('Renames detected:'));
452
+ for (const r of renamed) {
453
+ console.log(` ${kleur.cyan('↪')} ${r.oldKey} → ${r.newKey}`);
454
+ if (opts.withHints) {
455
+ console.log(` ${kleur.dim(`MagicPixelAssets['${r.oldKey}'] → MagicPixelAssets['${r.newKey}']`)}`);
456
+ console.log(` ${kleur.dim(`or pin to id: MagicPixelAssetsById['${r.id}'] (survives future renames)`)}`);
457
+ }
458
+ }
459
+ }
263
460
  function progressText(done, total, bytes) {
264
461
  const pct = total === 0 ? 100 : Math.round((done / total) * 100);
265
462
  const barWidth = 24;
@@ -280,8 +477,7 @@ function timestamp() {
280
477
  }
281
478
  function humanTime(iso) {
282
479
  try {
283
- const d = new Date(iso);
284
- return d.toLocaleString();
480
+ return new Date(iso).toLocaleString();
285
481
  }
286
482
  catch {
287
483
  return iso;
@@ -293,7 +489,7 @@ function printOrphans(orphans) {
293
489
  for (const p of orphans) {
294
490
  console.log(` ${kleur.dim('?')} ${relative(process.cwd(), p)}`);
295
491
  }
296
- console.log(kleur.dim(' Run with --prune to delete them.'));
492
+ console.log(kleur.dim(' Pruning is on by default. Re-run without --no-prune to delete them.'));
297
493
  }
298
494
  async function findLocalPngs(dir) {
299
495
  if (!existsSync(dir))
@@ -313,6 +509,8 @@ async function findLocalPngs(dir) {
313
509
  continue;
314
510
  }
315
511
  if (ent.isDirectory()) {
512
+ if (ent.name.startsWith('.'))
513
+ continue;
316
514
  await walk(full);
317
515
  }
318
516
  else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { validateEndpointUrl } from './util/security.js';
5
+ import { readCredentialsSync } from './util/credentials.js';
5
6
  const CONFIG_FILENAME = 'magicpixel.json';
6
7
  const STATE_DIR = '.magicpixel';
7
8
  const STATE_FILENAME = 'state.json';
@@ -101,23 +102,38 @@ export function resolveEndpoint(config) {
101
102
  return config.endpoint;
102
103
  return DEFAULT_ENDPOINT;
103
104
  }
105
+ /**
106
+ * Read the API key. Precedence: `MAGICPIXEL_API_KEY` env var (highest, so CI
107
+ * can always override) > `.magicpixel/credentials` (written by `magicpixel
108
+ * login`). Throws a friendly error pointing at `magicpixel login` when neither
109
+ * source is configured.
110
+ */
104
111
  export function getApiKey() {
105
- const key = process.env.MAGICPIXEL_API_KEY;
112
+ const fromEnv = process.env.MAGICPIXEL_API_KEY;
113
+ let key = fromEnv;
114
+ let source = 'env';
106
115
  if (!key) {
107
- throw new Error('MAGICPIXEL_API_KEY is not set.\n' +
116
+ const stored = readCredentialsSync();
117
+ if (stored) {
118
+ key = stored.apiKey;
119
+ source = 'credentials-file';
120
+ }
121
+ }
122
+ if (!key) {
123
+ throw new Error('No MagicPixel API key found.\n' +
108
124
  ' Fix:\n' +
109
125
  ' 1. Get a key at https://magicpixel.art/settings (API Keys).\n' +
110
- ' 2. export MAGICPIXEL_API_KEY=mp_live_...\n' +
126
+ ' 2. Run `magicpixel login` (or `export MAGICPIXEL_API_KEY=mp_live_...`).\n' +
111
127
  ' 3. Re-run the command.');
112
128
  }
113
129
  const trimmed = key.trim();
114
- if (trimmed !== key) {
130
+ if (trimmed !== key && source === 'env') {
115
131
  throw new Error(`MAGICPIXEL_API_KEY has leading/trailing whitespace.\n` +
116
132
  ` Fix: export the key without spaces or quotes.`);
117
133
  }
118
134
  if (!/^mp_(live|test)_[a-f0-9]{64}$/.test(trimmed)) {
119
- throw new Error(`MAGICPIXEL_API_KEY does not look right (expected mp_live_… or mp_test_…).\n` +
120
- ` Fix: re-copy the key from https://magicpixel.art/settings.`);
135
+ throw new Error(`MagicPixel API key does not look right (expected mp_live_… or mp_test_…).\n` +
136
+ ` Fix: run \`magicpixel login\` and paste a fresh key from https://magicpixel.art/settings.`);
121
137
  }
122
138
  return trimmed;
123
139
  }
package/dist/index.js CHANGED
@@ -8,6 +8,10 @@ import { removeCommand } from './commands/remove.js';
8
8
  import { listCommand } from './commands/list.js';
9
9
  import { statusCommand } from './commands/status.js';
10
10
  import { whoamiCommand } from './commands/whoami.js';
11
+ import { loginCommand } from './commands/login.js';
12
+ import { logoutCommand } from './commands/logout.js';
13
+ import { doctorCommand } from './commands/doctor.js';
14
+ import { startCommand } from './commands/start.js';
11
15
  import { CLI_VERSION } from './version.js';
12
16
  // Node version guard
13
17
  const major = Number(process.versions.node.split('.')[0]);
@@ -31,16 +35,34 @@ const wrap = (fn) => async (...args) => {
31
35
  process.exit(1);
32
36
  }
33
37
  };
38
+ program
39
+ .command('start')
40
+ .description('One-command first-run setup: init + login + first sync')
41
+ .option('--force', 'Re-run init even if magicpixel.json exists')
42
+ .action(wrap(async (opts) => startCommand(opts)));
34
43
  program
35
44
  .command('init')
36
45
  .description('Create magicpixel.json (interactive)')
37
46
  .option('--force', 'Overwrite existing config')
38
47
  .option('-y, --yes', 'Skip prompts, use defaults (CI-friendly)')
39
48
  .action(wrap(async (opts) => initCommand(opts)));
49
+ program
50
+ .command('login')
51
+ .description('Save your MagicPixel API key to .magicpixel/credentials')
52
+ .option('--key <key>', 'Provide the key non-interactively')
53
+ .action(wrap(async (opts) => loginCommand(opts)));
54
+ program
55
+ .command('logout')
56
+ .description('Remove the stored MagicPixel API key')
57
+ .action(wrap(async () => logoutCommand()));
58
+ program
59
+ .command('doctor')
60
+ .description('Print a single-page diagnostic to paste to your AI agent')
61
+ .action(wrap(async () => doctorCommand()));
40
62
  program
41
63
  .command('sync')
42
64
  .description('Download changed assets from MagicPixel')
43
- .option('--prune', 'Delete local files no longer in the manifest')
65
+ .option('--no-prune', 'Keep local files not in the manifest (pruning is now on by default)')
44
66
  .option('--dry-run', 'Print the plan without writing files')
45
67
  .option('--full', 'Ignore lastSync state; re-fetch the full manifest')
46
68
  .option('-w, --watch [seconds]', 'Poll for changes (default 10s)', (v) => v ?? true)