@magicpixelart/cli 0.3.0 → 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/dist/api.js +58 -2
- package/dist/commands/sync.js +28 -1
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveEndpoint, getApiKey, CLI_USER_AGENT } from './config.js';
|
|
1
|
+
import { resolveEndpoint, getApiKey, CLI_USER_AGENT, CLI_VERSION } from './config.js';
|
|
2
2
|
import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, } from './util/security.js';
|
|
3
3
|
export class ApiError extends Error {
|
|
4
4
|
status;
|
|
@@ -53,8 +53,42 @@ export async function fetchManifestPage(opts) {
|
|
|
53
53
|
if (!res.ok) {
|
|
54
54
|
throw new ApiError(res.status, friendly(res.status, await res.text(), 'manifest'));
|
|
55
55
|
}
|
|
56
|
+
// Hint to nudge stale CLIs. Printed once per process by sync.ts.
|
|
57
|
+
const minCli = res.headers.get('x-magicpixel-min-cli-version');
|
|
58
|
+
if (minCli)
|
|
59
|
+
maybeWarnStaleCli(minCli);
|
|
56
60
|
return (await res.json());
|
|
57
61
|
}
|
|
62
|
+
// ----- Stale-CLI nudge ------------------------------------------------------
|
|
63
|
+
// Cheap version comparison: split on dots, compare numerically. Pre-release
|
|
64
|
+
// tags are ignored (treated as equal to their base) to avoid spamming devs
|
|
65
|
+
// running unpublished builds.
|
|
66
|
+
let staleWarned = false;
|
|
67
|
+
function maybeWarnStaleCli(minVersion) {
|
|
68
|
+
if (staleWarned)
|
|
69
|
+
return;
|
|
70
|
+
const current = (CLI_VERSION || '').split('-')[0];
|
|
71
|
+
const min = (minVersion || '').split('-')[0];
|
|
72
|
+
if (!current || !min)
|
|
73
|
+
return;
|
|
74
|
+
if (compareSemver(current, min) >= 0)
|
|
75
|
+
return;
|
|
76
|
+
staleWarned = true;
|
|
77
|
+
// Use stderr so the message survives piping but doesn't pollute structured
|
|
78
|
+
// stdout consumers.
|
|
79
|
+
console.warn(`\n[magicpixel] CLI ${current} is older than the recommended ${min}.\n` +
|
|
80
|
+
` Fix: npm i -D @magicpixelart/cli@latest (or @magicpixelart/vite)\n`);
|
|
81
|
+
}
|
|
82
|
+
function compareSemver(a, b) {
|
|
83
|
+
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
|
84
|
+
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
|
85
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
86
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
87
|
+
if (diff !== 0)
|
|
88
|
+
return diff;
|
|
89
|
+
}
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
58
92
|
export async function fetchAllManifest(config, since) {
|
|
59
93
|
const out = [];
|
|
60
94
|
let cursor;
|
|
@@ -83,6 +117,7 @@ export async function fetchAssetBytes(config, key, knownSha) {
|
|
|
83
117
|
headers['If-None-Match'] = etagForSha256(knownSha);
|
|
84
118
|
const maxAttempts = 3;
|
|
85
119
|
let lastErr = null;
|
|
120
|
+
let retryAfterMs = 0; // populated from Retry-After when present
|
|
86
121
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
87
122
|
try {
|
|
88
123
|
const res = await safeFetch(url.href, { headers });
|
|
@@ -97,6 +132,11 @@ export async function fetchAssetBytes(config, key, knownSha) {
|
|
|
97
132
|
// the stream so we don't leak the socket on retry.
|
|
98
133
|
const bodyText = await res.text();
|
|
99
134
|
if (res.status === 429 || res.status >= 500) {
|
|
135
|
+
// Honor Retry-After (seconds). Cap at 60s so a bogus header can't hang
|
|
136
|
+
// the CLI; fall back to exponential backoff when missing/invalid.
|
|
137
|
+
const ra = parseRetryAfterSeconds(res.headers.get('retry-after'));
|
|
138
|
+
if (ra > 0)
|
|
139
|
+
retryAfterMs = Math.min(ra * 1000, 60_000);
|
|
100
140
|
lastErr = new ApiError(res.status, friendly(res.status, bodyText, `download ${key}`));
|
|
101
141
|
}
|
|
102
142
|
else {
|
|
@@ -109,8 +149,24 @@ export async function fetchAssetBytes(config, key, knownSha) {
|
|
|
109
149
|
throw e;
|
|
110
150
|
}
|
|
111
151
|
if (attempt < maxAttempts) {
|
|
112
|
-
|
|
152
|
+
const backoffMs = 250 * 2 ** (attempt - 1);
|
|
153
|
+
await new Promise((r) => setTimeout(r, Math.max(retryAfterMs, backoffMs)));
|
|
154
|
+
retryAfterMs = 0;
|
|
113
155
|
}
|
|
114
156
|
}
|
|
115
157
|
throw lastErr ?? new Error(`download ${key}: unknown error`);
|
|
116
158
|
}
|
|
159
|
+
/** Retry-After may be seconds or an HTTP-date (RFC 7231). Accept both. */
|
|
160
|
+
function parseRetryAfterSeconds(raw) {
|
|
161
|
+
if (!raw)
|
|
162
|
+
return 0;
|
|
163
|
+
const trimmed = raw.trim();
|
|
164
|
+
const n = Number(trimmed);
|
|
165
|
+
if (Number.isFinite(n) && n > 0)
|
|
166
|
+
return n;
|
|
167
|
+
const asDate = Date.parse(trimmed);
|
|
168
|
+
if (!Number.isNaN(asDate)) {
|
|
169
|
+
return Math.max(0, Math.ceil((asDate - Date.now()) / 1000));
|
|
170
|
+
}
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -19,7 +19,13 @@ export async function syncCommand(opts) {
|
|
|
19
19
|
await runOnce(opts);
|
|
20
20
|
}
|
|
21
21
|
async function watchLoop(opts) {
|
|
22
|
-
|
|
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;
|
|
23
29
|
// Header — print once on start. Project/asset count is best-effort; if the
|
|
24
30
|
// first manifest fetch fails we still want the loop to come up and retry.
|
|
25
31
|
let headerCount = null;
|
|
@@ -41,6 +47,12 @@ async function watchLoop(opts) {
|
|
|
41
47
|
let resolveIdle = null;
|
|
42
48
|
let backoffSec = intervalSec;
|
|
43
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;
|
|
44
56
|
const onSigint = () => {
|
|
45
57
|
if (stopping)
|
|
46
58
|
return;
|
|
@@ -66,6 +78,19 @@ async function watchLoop(opts) {
|
|
|
66
78
|
backoffSec = intervalSec;
|
|
67
79
|
pausedForAuth = false;
|
|
68
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
|
+
}
|
|
69
94
|
if (opts.quiet)
|
|
70
95
|
return;
|
|
71
96
|
if (changedCount > 0) {
|
|
@@ -84,6 +109,8 @@ async function watchLoop(opts) {
|
|
|
84
109
|
const apiErr = err instanceof ApiError ? err : null;
|
|
85
110
|
const firstLine = err.message.split('\n')[0];
|
|
86
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;
|
|
87
114
|
if (apiErr && (apiErr.status === 401 || apiErr.status === 403)) {
|
|
88
115
|
if (!pausedForAuth) {
|
|
89
116
|
console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Your key looks invalid or rotated.`);
|