@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 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
- await new Promise((r) => setTimeout(r, 250 * 2 ** (attempt - 1)));
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
+ }
@@ -19,7 +19,13 @@ export async function syncCommand(opts) {
19
19
  await runOnce(opts);
20
20
  }
21
21
  async function watchLoop(opts) {
22
- const intervalSec = typeof opts.watch === 'string' ? Math.max(2, parseInt(opts.watch, 10) || 10) : 10;
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.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {