@magicpixelart/cli 0.1.2 → 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.
@@ -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) {
@@ -20,18 +20,33 @@ export async function syncCommand(opts) {
20
20
  }
21
21
  async function watchLoop(opts) {
22
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.`));
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();
24
39
  let stopping = false;
25
40
  let inFlight = false;
26
41
  let resolveIdle = null;
42
+ let backoffSec = intervalSec;
43
+ let pausedForAuth = false;
27
44
  const onSigint = () => {
28
45
  if (stopping)
29
46
  return;
30
47
  stopping = true;
31
48
  process.stdout.write('\x1b[2K\r');
32
49
  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
50
  console.log(kleur.dim('[watch] finishing current sync… (Ctrl+C again to force quit)'));
36
51
  process.once('SIGINT', () => process.exit(130));
37
52
  }
@@ -41,25 +56,53 @@ async function watchLoop(opts) {
41
56
  }
42
57
  };
43
58
  process.on('SIGINT', onSigint);
44
- // Run once eagerly so first sync isn't delayed by the interval.
45
59
  const tick = async () => {
46
60
  if (inFlight || stopping)
47
61
  return;
48
62
  inFlight = true;
49
63
  try {
50
- const r = await runOnce({ ...opts, quiet: true, watch: false });
51
- 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) {
52
72
  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`) : ''));
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 */ ' ');
56
77
  }
57
78
  else {
58
79
  process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} no changes (${r.unchanged} unchanged)`)}`);
59
80
  }
60
81
  }
61
82
  catch (e) {
62
- 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
+ }
63
106
  }
64
107
  finally {
65
108
  inFlight = false;
@@ -69,12 +112,11 @@ async function watchLoop(opts) {
69
112
  };
70
113
  await tick();
71
114
  while (!stopping) {
72
- await new Promise((r) => setTimeout(r, intervalSec * 1000));
115
+ await new Promise((r) => setTimeout(r, backoffSec * 1000));
73
116
  if (stopping)
74
117
  break;
75
118
  await tick();
76
119
  }
77
- // SIGINT arrived mid-tick — wait for it to drain, then exit cleanly.
78
120
  if (inFlight) {
79
121
  await new Promise((r) => {
80
122
  resolveIdle = r;
@@ -83,23 +125,29 @@ async function watchLoop(opts) {
83
125
  console.log(kleur.dim('[watch] stopped.'));
84
126
  process.exit(0);
85
127
  }
86
- 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 = {}) {
87
137
  const config = await loadConfig();
88
138
  const state = await loadState();
89
139
  const startedAt = new Date().toISOString();
90
140
  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) {
141
+ const verbose = !opts.quiet && !runOpts.watchMode;
142
+ const shouldPrune = opts.prune !== false; // commander: --no-prune sets false
143
+ if (verbose && config.endpoint) {
95
144
  console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
96
145
  }
97
- // Incremental: only fetch what changed since last successful sync.
98
- // --full forces a from-scratch fetch.
99
146
  const since = opts.full ? undefined : state.lastSync;
100
- const spinner = quiet
101
- ? null
102
- : 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;
103
151
  let manifest;
104
152
  try {
105
153
  manifest = await fetchAllManifest(config, since);
@@ -109,15 +157,23 @@ async function runOnce(opts) {
109
157
  spinner?.fail('Manifest fetch failed');
110
158
  throw e;
111
159
  }
112
- // 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
113
169
  const toDownload = [];
114
- const skipped = [];
115
170
  let bytesSaved = 0;
171
+ let unchanged = 0;
116
172
  for (const entry of manifest) {
117
173
  const diskPath = assetDiskPath(config.outDir, entry);
118
174
  const localSha = await fileSha256(diskPath);
119
175
  if (entry.sha256 && localSha && entry.sha256 === localSha) {
120
- skipped.push(entry);
176
+ unchanged++;
121
177
  if (entry.size_bytes)
122
178
  bytesSaved += entry.size_bytes;
123
179
  }
@@ -126,51 +182,77 @@ async function runOnce(opts) {
126
182
  }
127
183
  }
128
184
  // Orphan detection only when we have the full picture.
185
+ // (Renames also produce a stale path on disk — collected separately below.)
129
186
  let orphans = [];
130
187
  if (!since) {
131
188
  const remoteDiskPaths = new Set(manifest.map((e) => assetDiskPath(config.outDir, e)));
132
189
  orphans = (await findLocalPngs(resolve(process.cwd(), config.outDir)))
133
190
  .filter((p) => !remoteDiskPaths.has(p));
134
191
  }
135
- 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) {
136
203
  console.log();
137
204
  console.log(kleur.bold('Plan:'));
138
205
  console.log(` ${kleur.green('+')} download ${toDownload.length}`);
139
- console.log(` ${kleur.dim('=')} unchanged ${skipped.length}${bytesSaved ? kleur.dim(` (~${formatBytes(bytesSaved)} saved)`) : ''}`);
140
- 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) {
141
210
  console.log(` ${kleur.dim('orphan check skipped (incremental — use --full)')}`);
142
211
  }
143
212
  else if (orphans.length > 0) {
144
- const verb = opts.prune ? kleur.red('delete') : kleur.yellow('keep (orphan)');
213
+ const verb = shouldPrune ? kleur.red('delete') : kleur.yellow('keep (orphan)');
145
214
  console.log(` ${verb} ${orphans.length}`);
146
215
  }
147
216
  console.log();
148
217
  }
149
218
  if (opts.dryRun) {
150
- if (!quiet) {
219
+ if (verbose) {
151
220
  console.log(kleur.dim('--dry-run: no files written.'));
221
+ if (renamed.length > 0)
222
+ printRenames(renamed, { withHints: false });
152
223
  if (orphans.length > 0)
153
224
  printOrphans(orphans);
154
225
  }
155
- 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
+ };
156
236
  }
157
- // Download with progress bar
158
237
  const result = {
159
- downloaded: 0,
160
- unchanged: skipped.length,
161
- pruned: 0,
238
+ added: [],
239
+ modified: [],
240
+ unchanged,
241
+ removed: [],
242
+ renamed,
162
243
  failed: 0,
163
244
  bytesIn: 0,
164
245
  bytesSaved,
165
246
  };
166
247
  let progress = null;
167
- if (!quiet && toDownload.length > 0) {
248
+ if (verbose && toDownload.length > 0) {
168
249
  progress = ora({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
169
250
  }
170
251
  const run = createLimit(concurrency);
171
252
  let done = 0;
172
253
  await Promise.all(toDownload.map((entry) => run(async () => {
173
254
  const diskPath = assetDiskPath(config.outDir, entry);
255
+ const existedBefore = existsSync(diskPath);
174
256
  try {
175
257
  const localSha = await fileSha256(diskPath);
176
258
  const bytes = await fetchAssetBytes(config, entry.key, localSha);
@@ -178,8 +260,6 @@ async function runOnce(opts) {
178
260
  result.unchanged++;
179
261
  }
180
262
  else {
181
- // Verify the payload matches what the manifest advertised. Guards
182
- // against silently writing a corrupted body to disk.
183
263
  if (entry.sha256) {
184
264
  const actual = createHash('sha256').update(bytes).digest('hex');
185
265
  if (actual !== entry.sha256) {
@@ -191,7 +271,10 @@ async function runOnce(opts) {
191
271
  assertPathInsideRoot(tmp, resolve(process.cwd(), config.outDir), 'outDir');
192
272
  await writeFile(tmp, bytes);
193
273
  await rename(tmp, diskPath);
194
- result.downloaded++;
274
+ if (existedBefore)
275
+ result.modified.push(entry.key);
276
+ else
277
+ result.added.push(entry.key);
195
278
  result.bytesIn += bytes.byteLength;
196
279
  }
197
280
  }
@@ -207,59 +290,146 @@ async function runOnce(opts) {
207
290
  progress.text = progressText(done, toDownload.length, result.bytesIn);
208
291
  }
209
292
  })));
293
+ const downloaded = result.added.length + result.modified.length;
210
294
  if (progress) {
211
295
  if (result.failed === 0)
212
- progress.succeed(`Downloaded ${result.downloaded} (${formatBytes(result.bytesIn)})`);
296
+ progress.succeed(`Downloaded ${downloaded} (${formatBytes(result.bytesIn)})`);
213
297
  else
214
- progress.warn(`Downloaded ${result.downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
298
+ progress.warn(`Downloaded ${downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
215
299
  }
216
- // Prune
217
- if (opts.prune && orphans.length > 0) {
300
+ // Prune (now default — pass --no-prune to opt out).
301
+ if (shouldPrune && orphans.length > 0) {
218
302
  const outRoot = resolve(process.cwd(), config.outDir);
219
303
  for (const p of orphans) {
220
304
  assertPathInsideRoot(p, outRoot, 'outDir');
221
- await unlink(p);
222
- if (!quiet)
223
- console.log(` ${kleur.red('-')} ${relative(process.cwd(), p)}`);
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
+ }
224
317
  }
225
318
  await pruneEmptyDirs(resolve(process.cwd(), config.outDir));
226
- result.pruned = orphans.length;
227
319
  }
228
- else if (!quiet && orphans.length > 0) {
320
+ else if (verbose && orphans.length > 0) {
229
321
  printOrphans(orphans);
230
322
  }
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}`));
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];
244
338
  }
245
339
  }
340
+ const nextState = { ...state, assets: nextAssets };
246
341
  if (result.failed === 0) {
247
- 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
+ }
248
375
  }
249
- else if (!quiet) {
376
+ await saveState(nextState);
377
+ if (result.failed > 0 && verbose) {
250
378
  console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
251
379
  }
252
- if (!quiet) {
380
+ if (verbose) {
253
381
  console.log();
254
- const summary = `downloaded ${result.downloaded}, unchanged ${result.unchanged}` +
255
- (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}` : '') +
256
387
  (result.failed ? `, failed ${result.failed}` : '');
257
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 });
258
392
  }
259
393
  if (result.failed)
260
394
  process.exitCode = 1;
261
395
  return result;
262
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
+ }
263
433
  function progressText(done, total, bytes) {
264
434
  const pct = total === 0 ? 100 : Math.round((done / total) * 100);
265
435
  const barWidth = 24;
@@ -280,8 +450,7 @@ function timestamp() {
280
450
  }
281
451
  function humanTime(iso) {
282
452
  try {
283
- const d = new Date(iso);
284
- return d.toLocaleString();
453
+ return new Date(iso).toLocaleString();
285
454
  }
286
455
  catch {
287
456
  return iso;
@@ -293,7 +462,7 @@ function printOrphans(orphans) {
293
462
  for (const p of orphans) {
294
463
  console.log(` ${kleur.dim('?')} ${relative(process.cwd(), p)}`);
295
464
  }
296
- 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.'));
297
466
  }
298
467
  async function findLocalPngs(dir) {
299
468
  if (!existsSync(dir))
@@ -313,6 +482,8 @@ async function findLocalPngs(dir) {
313
482
  continue;
314
483
  }
315
484
  if (ent.isDirectory()) {
485
+ if (ent.name.startsWith('.'))
486
+ continue;
316
487
  await walk(full);
317
488
  }
318
489
  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)