@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.
- package/README.md +54 -13
- package/dist/api.js +58 -2
- 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 +271 -73
- 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) {
|
|
@@ -19,19 +19,46 @@ export async function syncCommand(opts) {
|
|
|
19
19
|
await runOnce(opts);
|
|
20
20
|
}
|
|
21
21
|
async function watchLoop(opts) {
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
140
|
-
if (
|
|
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 =
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 (
|
|
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
|
-
|
|
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 ${
|
|
323
|
+
progress.succeed(`Downloaded ${downloaded} (${formatBytes(result.bytesIn)})`);
|
|
213
324
|
else
|
|
214
|
-
progress.warn(`Downloaded ${
|
|
325
|
+
progress.warn(`Downloaded ${downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
|
|
215
326
|
}
|
|
216
|
-
// Prune
|
|
217
|
-
if (
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 (
|
|
347
|
+
else if (verbose && orphans.length > 0) {
|
|
229
348
|
printOrphans(orphans);
|
|
230
349
|
}
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
407
|
+
if (verbose) {
|
|
253
408
|
console.log();
|
|
254
|
-
const summary = `downloaded ${result.
|
|
255
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
|
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)
|