@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.
- package/README.md +54 -13
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/init.js +60 -45
- package/dist/commands/login.js +98 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/start.js +105 -0
- package/dist/commands/sync.js +243 -72
- package/dist/config.js +22 -6
- package/dist/index.js +23 -1
- package/dist/util/credentials.js +82 -0
- package/dist/util/emitIndex.js +141 -15
- package/dist/util/framework.js +56 -0
- package/dist/util/paths.js +17 -3
- package/package.json +1 -1
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
140
|
-
if (
|
|
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 =
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 (
|
|
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
|
-
|
|
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 ${
|
|
296
|
+
progress.succeed(`Downloaded ${downloaded} (${formatBytes(result.bytesIn)})`);
|
|
213
297
|
else
|
|
214
|
-
progress.warn(`Downloaded ${
|
|
298
|
+
progress.warn(`Downloaded ${downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
|
|
215
299
|
}
|
|
216
|
-
// Prune
|
|
217
|
-
if (
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 (
|
|
320
|
+
else if (verbose && orphans.length > 0) {
|
|
229
321
|
printOrphans(orphans);
|
|
230
322
|
}
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
380
|
+
if (verbose) {
|
|
253
381
|
console.log();
|
|
254
|
-
const summary = `downloaded ${result.
|
|
255
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
|
112
|
+
const fromEnv = process.env.MAGICPIXEL_API_KEY;
|
|
113
|
+
let key = fromEnv;
|
|
114
|
+
let source = 'env';
|
|
106
115
|
if (!key) {
|
|
107
|
-
|
|
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_
|
|
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(`
|
|
120
|
-
` Fix:
|
|
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', '
|
|
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)
|