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