@magicpixelart/cli 0.5.14 → 0.5.21
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/CHANGELOG.md +74 -0
- package/dist/api.js +97 -11
- package/dist/commands/sync.js +257 -68
- package/dist/index.js +1 -1
- package/dist/util/localEdit.js +29 -0
- package/dist/util/pullDecision.js +22 -7
- package/dist/util/reconcile.js +23 -0
- package/dist/util/security.js +37 -3
- package/dist/util/watchCopy.js +20 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.21] — 2026-09-14
|
|
11
|
+
|
|
12
|
+
- `sync --watch` can no longer hang forever: every network request now has a
|
|
13
|
+
deadline (45s for manifest/JSON calls, 120s for sprite downloads and pushes),
|
|
14
|
+
and a stalled request is retried like any other network blip.
|
|
15
|
+
- A slow tick prints a heartbeat with elapsed time instead of a frozen line, and
|
|
16
|
+
the first big pull reports its running sprite count page by page.
|
|
17
|
+
- Timeout errors now end with a "check your internet connection" hint, matching
|
|
18
|
+
every other CLI error.
|
|
19
|
+
- The heartbeat line no longer prints a stray ellipsis before the sprite count.
|
|
20
|
+
|
|
21
|
+
## 0.5.20
|
|
22
|
+
|
|
23
|
+
- `sync` no longer deletes a PNG that was edited in your project when the sprite
|
|
24
|
+
leaves the manifest — un-checking "Sync to Unity", trashing the document, or a
|
|
25
|
+
rename/previous-key sweep now keep the local edit and report it instead.
|
|
26
|
+
|
|
27
|
+
## 0.5.19
|
|
28
|
+
|
|
29
|
+
- `sync` no longer overwrites a PNG that changed both on disk and in MagicPixel. Such sprites are reported as conflicts, kept on disk, never pruned, and re-reported until you `push` them or delete the local file.
|
|
30
|
+
- A full reconcile is promoted automatically every 15 minutes (and whenever the server reports it truncated its removal list), so sprites deleted permanently in MagicPixel stop lingering in the game project.
|
|
31
|
+
|
|
32
|
+
## [0.5.18] — 2026-08-27
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- Trashed documents (and documents converted into component masters) no longer
|
|
36
|
+
linger in the game project. The manifest now excludes them, and incremental
|
|
37
|
+
syncs receive an explicit `removed_keys` list so previously pulled sprites are
|
|
38
|
+
pruned on the next run. Sprites authored in Unity are still never touched.
|
|
39
|
+
- Renaming a folder or toggling its "Sync to Unity" flag now invalidates the
|
|
40
|
+
manifest cache, so the CLI stops receiving a stale `304 Not Modified`.
|
|
41
|
+
|
|
42
|
+
## [0.5.17] — 2026-08-27
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
- Watch-mode idle backoff now measures wall-clock seconds since the last change
|
|
46
|
+
instead of `ticks x interval`. Once the watcher had stepped down to 5s/10s
|
|
47
|
+
ticks the old estimate undercounted elapsed time, so the 5-minute step landed
|
|
48
|
+
well past 5 minutes.
|
|
49
|
+
- Pushing a locally edited sprite counts as activity again: a run that only
|
|
50
|
+
pushes (an artist working entirely inside their engine) resets the hot window
|
|
51
|
+
to the fast interval instead of sliding to the 10s idle poll.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
- Idle incremental ticks no longer walk the whole output directory. The listing
|
|
55
|
+
is only needed by the full-sync orphan sweep and the rename fallback, so it is
|
|
56
|
+
now computed lazily — an unchanged (304) tick does no filesystem scan.
|
|
57
|
+
|
|
58
|
+
## [0.5.16] — 2026-08-27
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
- Manifest validators are persisted in `.magicpixel/state.json`, so a one-shot
|
|
62
|
+
`sync` (CI, cron, a manual run) gets the same idle `304 Not Modified` a
|
|
63
|
+
watcher already got on its second tick. Validators are keyed by endpoint plus
|
|
64
|
+
exact query, so a staging/self-hosted swap or an edited `connect`/`exclude`
|
|
65
|
+
glob can never be answered from a stale validator.
|
|
66
|
+
|
|
67
|
+
## [0.5.15] — 2026-08-26
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
- `sync --watch` sends conditional manifest requests (`If-None-Match`), so an
|
|
71
|
+
idle tick costs a `304` with no body instead of a full manifest render.
|
|
72
|
+
Validators are only used on incremental fetches — a full fetch, which prune
|
|
73
|
+
compares against, always reads the body.
|
|
74
|
+
- Idle backoff is now a hot window: the configured interval applies for ~1 min
|
|
75
|
+
after the last change, then steps to 5s, then to 10s after ~5 min idle
|
|
76
|
+
(previously ~3 min / ~15 min).
|
|
77
|
+
|
|
78
|
+
### Fixed
|
|
79
|
+
- `safeFetch` no longer treated `304` as a malformed redirect. This made every
|
|
80
|
+
conditional asset download retry five times and then fail as a network error,
|
|
81
|
+
so unchanged PNGs were re-downloaded in full.
|
|
82
|
+
|
|
83
|
+
|
|
10
84
|
## [0.5.14] — 2026-08-26
|
|
11
85
|
|
|
12
86
|
### Fixed
|
package/dist/api.js
CHANGED
|
@@ -1,7 +1,53 @@
|
|
|
1
1
|
import { resolveEndpoint, getApiKey, CLI_VERSION } from './config.js';
|
|
2
|
-
import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, validateEndpointUrl, } from './util/security.js';
|
|
2
|
+
import { ASSET_TIMEOUT_MS, etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, validateEndpointUrl, } from './util/security.js';
|
|
3
3
|
import { authHeaders } from './util/authHeaders.js';
|
|
4
4
|
import { cmd } from './util/invoke.js';
|
|
5
|
+
/**
|
|
6
|
+
* Manifest validators, so an idle poll costs a conditional GET (304, no body)
|
|
7
|
+
* instead of a full manifest render. Keyed by endpoint + exact query string: a
|
|
8
|
+
* different `since`/glob set — or a different server — is a different
|
|
9
|
+
* projection and must not reuse a validator.
|
|
10
|
+
*
|
|
11
|
+
* Bounded so a long-lived watcher walking many `since` values can't grow it
|
|
12
|
+
* without limit; eviction is FIFO and only costs one non-conditional GET.
|
|
13
|
+
*
|
|
14
|
+
* `sync` persists this map into state.json (see `manifestEtagsSnapshot` /
|
|
15
|
+
* `primeManifestEtags`) so one-shot runs — CI, cron, a manual `sync` — get the
|
|
16
|
+
* same 304 as a watcher's second tick.
|
|
17
|
+
*/
|
|
18
|
+
const manifestEtags = new Map();
|
|
19
|
+
const MAX_MANIFEST_ETAGS = 32;
|
|
20
|
+
function rememberManifestEtag(key, etag) {
|
|
21
|
+
manifestEtags.delete(key);
|
|
22
|
+
manifestEtags.set(key, etag);
|
|
23
|
+
while (manifestEtags.size > MAX_MANIFEST_ETAGS) {
|
|
24
|
+
const oldest = manifestEtags.keys().next().value;
|
|
25
|
+
if (oldest === undefined)
|
|
26
|
+
break;
|
|
27
|
+
manifestEtags.delete(oldest);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Seed validators persisted by an earlier run. Ignores non-string junk so a
|
|
32
|
+
* hand-edited state.json can't inject a bogus header value; a wrong-but-typed
|
|
33
|
+
* validator is harmless (the server just answers 200 with a body).
|
|
34
|
+
*/
|
|
35
|
+
export function primeManifestEtags(entries) {
|
|
36
|
+
if (!entries || typeof entries !== 'object' || Array.isArray(entries))
|
|
37
|
+
return;
|
|
38
|
+
for (const [key, etag] of Object.entries(entries)) {
|
|
39
|
+
if (typeof etag === 'string' && etag)
|
|
40
|
+
rememberManifestEtag(key, etag);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Snapshot of the current validators, for persisting into state.json. */
|
|
44
|
+
export function manifestEtagsSnapshot() {
|
|
45
|
+
return Object.fromEntries(manifestEtags);
|
|
46
|
+
}
|
|
47
|
+
/** Test seam: drop cached manifest validators between cases. */
|
|
48
|
+
export function __resetManifestEtagsForTesting() {
|
|
49
|
+
manifestEtags.clear();
|
|
50
|
+
}
|
|
5
51
|
/**
|
|
6
52
|
* Last-seen project metadata from any manifest response this process made.
|
|
7
53
|
* Populated as a side-effect of `fetchManifestPage`; read by `whoami`,
|
|
@@ -84,17 +130,32 @@ export async function fetchManifestPage(opts) {
|
|
|
84
130
|
url.searchParams.set('cursor', opts.cursor);
|
|
85
131
|
if (opts.limit)
|
|
86
132
|
url.searchParams.set('limit', String(opts.limit));
|
|
133
|
+
// Conditional GET: an unchanged scope comes back as a 304 with no body.
|
|
134
|
+
// Only for un-paginated requests — the server doesn't validate cursor pages.
|
|
135
|
+
// Key includes the origin+path so a persisted validator can't be replayed
|
|
136
|
+
// against a different endpoint (self-hosted / staging swap).
|
|
137
|
+
const etagKey = opts.conditional && !opts.cursor ? `${url.origin}${url.pathname}${url.search}` : null;
|
|
138
|
+
const knownEtag = etagKey ? manifestEtags.get(etagKey) : undefined;
|
|
87
139
|
// Reuse the same transient-failure policy as asset downloads, with extra
|
|
88
140
|
// 546 attempts so first-run can ride out a worker recycle without stalling
|
|
89
141
|
// every PNG download for ~30s.
|
|
90
142
|
return retryTransient(`manifest`, async () => {
|
|
91
|
-
const { headers, requestId } = buildHeaders();
|
|
143
|
+
const { headers, requestId } = buildHeaders(knownEtag ? { 'If-None-Match': knownEtag } : undefined);
|
|
92
144
|
const res = await safeFetch(url.href, { headers });
|
|
93
145
|
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
146
|
+
if (res.status === 304) {
|
|
147
|
+
const minCli = res.headers.get('x-magicpixel-min-cli-version');
|
|
148
|
+
if (minCli)
|
|
149
|
+
maybeWarnStaleCli(minCli);
|
|
150
|
+
return { items: [], nextCursor: null, count: 0 };
|
|
151
|
+
}
|
|
94
152
|
if (res.status >= 200 && res.status < 300) {
|
|
95
153
|
const minCli = res.headers.get('x-magicpixel-min-cli-version');
|
|
96
154
|
if (minCli)
|
|
97
155
|
maybeWarnStaleCli(minCli);
|
|
156
|
+
const etag = res.headers.get('etag');
|
|
157
|
+
if (etagKey && etag)
|
|
158
|
+
rememberManifestEtag(etagKey, etag);
|
|
98
159
|
const data = (await res.json());
|
|
99
160
|
// Shape-guard the response. A malformed edge response (null,
|
|
100
161
|
// {items: null}, nextCursor: 42, etc.) would otherwise either crash
|
|
@@ -214,18 +275,40 @@ function compareSemver(a, b) {
|
|
|
214
275
|
}
|
|
215
276
|
return 0;
|
|
216
277
|
}
|
|
217
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Full manifest walk, including the cloud's removal list.
|
|
280
|
+
*
|
|
281
|
+
* `onProgress` fires after each page with the running entry count, so a big
|
|
282
|
+
* first pull shows movement instead of one frozen status line.
|
|
283
|
+
*/
|
|
284
|
+
export async function fetchManifestSnapshot(config, since, onProgress) {
|
|
218
285
|
const out = [];
|
|
286
|
+
const removed = new Set();
|
|
287
|
+
let removalsTruncated = false;
|
|
219
288
|
let cursor;
|
|
289
|
+
// Conditional GETs only on incremental fetches. An incremental 304 means
|
|
290
|
+
// "nothing changed since `since`", which is indistinguishable from the empty
|
|
291
|
+
// incremental page callers already handle. A full fetch (`since` unset) is
|
|
292
|
+
// the cloud truth that prune compares against, so it always reads the body.
|
|
293
|
+
const conditional = !!since;
|
|
220
294
|
// Cycle guard: caps total pages so a buggy server cursor can't hang the CLI.
|
|
221
295
|
// At 500 entries/page this is 100k assets — well past any realistic project.
|
|
222
296
|
const MAX_PAGES = 200;
|
|
223
297
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
224
|
-
const res = await fetchManifestPage({ config, since, cursor, limit: 500 });
|
|
298
|
+
const res = await fetchManifestPage({ config, since, cursor, limit: 500, conditional });
|
|
225
299
|
out.push(...res.items);
|
|
300
|
+
if (out.length > 0)
|
|
301
|
+
onProgress?.(out.length);
|
|
302
|
+
if (Array.isArray(res.removed_keys)) {
|
|
303
|
+
for (const k of res.removed_keys)
|
|
304
|
+
if (typeof k === 'string' && k)
|
|
305
|
+
removed.add(k);
|
|
306
|
+
}
|
|
307
|
+
if (res.removals_truncated === true)
|
|
308
|
+
removalsTruncated = true;
|
|
226
309
|
const nextCursor = res.nextCursor ?? undefined;
|
|
227
310
|
if (!nextCursor)
|
|
228
|
-
return out;
|
|
311
|
+
return { entries: out, removedKeys: [...removed], removalsTruncated };
|
|
229
312
|
// Detect a stuck cursor (server returns the same token it just received)
|
|
230
313
|
// in O(1) round-trips instead of burning the full page budget.
|
|
231
314
|
if (nextCursor === cursor) {
|
|
@@ -237,6 +320,10 @@ export async function fetchAllManifest(config, since) {
|
|
|
237
320
|
throw new Error(`manifest: pagination exceeded ${MAX_PAGES} pages — possible server cursor loop.\n` +
|
|
238
321
|
` Fix: re-run with --full, or report at https://github.com/magicpixel/cli/issues.`);
|
|
239
322
|
}
|
|
323
|
+
/** Entries only — for read-only commands (`list`, `status`). */
|
|
324
|
+
export async function fetchAllManifest(config, since) {
|
|
325
|
+
return (await fetchManifestSnapshot(config, since)).entries;
|
|
326
|
+
}
|
|
240
327
|
/**
|
|
241
328
|
* Download a single asset by key. Returns null on 304 (not modified).
|
|
242
329
|
* Shares the retryTransient policy with manifest fetches — including
|
|
@@ -248,7 +335,7 @@ export async function fetchAssetBytes(config, key, knownSha) {
|
|
|
248
335
|
const conditional = knownSha ? { 'If-None-Match': etagForSha256(knownSha) } : undefined;
|
|
249
336
|
return retryTransient(`download ${key}`, async () => {
|
|
250
337
|
const { headers, requestId } = buildHeaders(conditional);
|
|
251
|
-
const res = await safeFetch(url.href, { headers });
|
|
338
|
+
const res = await safeFetch(url.href, { headers }, { timeoutMs: ASSET_TIMEOUT_MS });
|
|
252
339
|
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
253
340
|
if (res.status === 304) {
|
|
254
341
|
// Drain so undici can return the socket to the keep-alive pool.
|
|
@@ -292,11 +379,10 @@ export async function pushSprites(sprites, retryOpts) {
|
|
|
292
379
|
const url = resolveIngestEndpoint();
|
|
293
380
|
return retryTransient('push', async () => {
|
|
294
381
|
const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
|
|
295
|
-
const res = await safeFetch(url, {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
});
|
|
382
|
+
const res = await safeFetch(url, { method: 'POST', headers, body: JSON.stringify({ sprites }) },
|
|
383
|
+
// A push batch carries PNG bytes; give it the asset deadline, not the
|
|
384
|
+
// shorter JSON one.
|
|
385
|
+
{ timeoutMs: ASSET_TIMEOUT_MS });
|
|
300
386
|
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
301
387
|
if (res.ok) {
|
|
302
388
|
const data = (await res.json());
|
package/dist/commands/sync.js
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync } from 'node:fs';
|
|
|
5
5
|
import { dirname, relative, resolve } from 'node:path';
|
|
6
6
|
import { loadConfig, loadState, saveState } from '../config.js';
|
|
7
7
|
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
8
|
-
import {
|
|
8
|
+
import { fetchManifestSnapshot, fetchAssetBytes, ApiError, getLastProjectInfo, primeManifestEtags, manifestEtagsSnapshot } from '../api.js';
|
|
9
9
|
import { fileSha256 } from '../util/hash.js';
|
|
10
10
|
import { pruneEmptyDirs, walkOutDirPngs } from '../util/paths.js';
|
|
11
11
|
import { createLimit } from '../util/limit.js';
|
|
@@ -23,9 +23,29 @@ import { maxIsoTimestamp } from '../util/iso.js';
|
|
|
23
23
|
import { formatBytes } from '../util/format.js';
|
|
24
24
|
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
25
25
|
import { cmd } from '../util/invoke.js';
|
|
26
|
-
import { formatWatchSpriteLine } from '../util/watchCopy.js';
|
|
27
|
-
import {
|
|
26
|
+
import { formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
|
|
27
|
+
import { decidePull } from '../util/pullDecision.js';
|
|
28
|
+
import { hasUnpushedLocalEdit } from '../util/localEdit.js';
|
|
29
|
+
import { shouldReconcile } from '../util/reconcile.js';
|
|
28
30
|
import { runPush } from './push.js';
|
|
31
|
+
/** Did the push half of a run actually move bytes into MagicPixel? */
|
|
32
|
+
export function pushWasActive(pushed) {
|
|
33
|
+
if (!pushed)
|
|
34
|
+
return false;
|
|
35
|
+
return pushed.created > 0 || pushed.updated > 0 || pushed.imported > 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Two consumers need the local PNG listing: the full-sync orphan sweep (no
|
|
39
|
+
* `since`) and the sha-based rename fallback (incremental, and only when
|
|
40
|
+
* something is download-bound). On an idle incremental tick — the common case
|
|
41
|
+
* now that unchanged manifests come back as a bodyless 304 — neither runs, so
|
|
42
|
+
* walking the whole output tree would be pure waste.
|
|
43
|
+
*
|
|
44
|
+
* Full syncs must ALWAYS walk: skipping there would silently disable pruning.
|
|
45
|
+
*/
|
|
46
|
+
export function needsLocalPngWalk(since, toDownloadCount) {
|
|
47
|
+
return !since || toDownloadCount > 0;
|
|
48
|
+
}
|
|
29
49
|
export async function syncCommand(opts) {
|
|
30
50
|
// Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
|
|
31
51
|
// crashed/killed CLI run before they pile up. Runs once per CLI invocation
|
|
@@ -101,17 +121,19 @@ async function watchLoop(opts) {
|
|
|
101
121
|
// genuinely broken (revoked key) rather than transiently blipped.
|
|
102
122
|
const MAX_AUTH_FAILURES = 5;
|
|
103
123
|
let consecutiveAuthFailures = 0;
|
|
104
|
-
// Adaptive idle backoff:
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
124
|
+
// Adaptive idle backoff: the fast interval is a "hot window" around actual
|
|
125
|
+
// work, not a steady state. After ~1 min of nothing-to-do we slow the poll
|
|
126
|
+
// from intervalSec → 5s, and after ~5 min → 10s, so a dev who walked away
|
|
127
|
+
// isn't polling the manifest hundreds of times an hour. ANY change (pulled
|
|
128
|
+
// OR pushed) or error resets this, so the "edit → see it" promise stays
|
|
129
|
+
// intact the moment the user comes back. Error backoff (2→60s) is separate
|
|
130
|
+
// and continues to win.
|
|
131
|
+
//
|
|
132
|
+
// Idle is measured in wall-clock seconds since the last change, not in
|
|
133
|
+
// ticks: once we've backed off, a "tick" is 5s or 10s long, so counting
|
|
134
|
+
// ticks × intervalSec would undercount elapsed time and push the 5-minute
|
|
135
|
+
// step far past 5 minutes.
|
|
136
|
+
let lastChangeAt = Date.now();
|
|
115
137
|
// Re-walk the game tree on this cadence so new PNGs are ingested without
|
|
116
138
|
// listing thousands of folders on every 2s tick.
|
|
117
139
|
const GAME_INDEX_TTL_MS = 30_000;
|
|
@@ -155,14 +177,31 @@ async function watchLoop(opts) {
|
|
|
155
177
|
return;
|
|
156
178
|
process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} ${msg}`)}`);
|
|
157
179
|
};
|
|
180
|
+
// Heartbeat: a tick that legitimately takes minutes (huge first pull, slow
|
|
181
|
+
// link) must not look like the silent hang a timeout-less fetch used to
|
|
182
|
+
// cause. Requests now have deadlines, so a live counter here means "slow",
|
|
183
|
+
// not "stuck".
|
|
184
|
+
const tickStartedAt = Date.now();
|
|
185
|
+
let lastStatus = '';
|
|
186
|
+
const wrappedStatus = (msg) => {
|
|
187
|
+
lastStatus = msg;
|
|
188
|
+
onStatus(msg);
|
|
189
|
+
};
|
|
190
|
+
const heartbeat = opts.quiet
|
|
191
|
+
? null
|
|
192
|
+
: setInterval(() => {
|
|
193
|
+
const elapsedSec = Math.round((Date.now() - tickStartedAt) / 1000);
|
|
194
|
+
onStatus(formatSlowTickLine(elapsedSec, lastStatus));
|
|
195
|
+
}, SLOW_TICK_HEARTBEAT_MS);
|
|
196
|
+
heartbeat?.unref?.();
|
|
158
197
|
try {
|
|
159
|
-
|
|
198
|
+
wrappedStatus('Checking MagicPixel…');
|
|
160
199
|
const reuseIndex = gameIndexCache && Date.now() - gameIndexAt < GAME_INDEX_TTL_MS
|
|
161
200
|
? gameIndexCache
|
|
162
201
|
: undefined;
|
|
163
202
|
const r = await runOnce({ ...opts, watch: false }, {
|
|
164
203
|
watchMode: true,
|
|
165
|
-
onStatus,
|
|
204
|
+
onStatus: wrappedStatus,
|
|
166
205
|
gameIndex: reuseIndex,
|
|
167
206
|
onGameIndex: (idx) => {
|
|
168
207
|
gameIndexCache = idx;
|
|
@@ -188,17 +227,16 @@ async function watchLoop(opts) {
|
|
|
188
227
|
console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Back online — resuming.`);
|
|
189
228
|
}
|
|
190
229
|
const changedCount = r.added.length + r.modified.length + r.removed.length + r.renamed.length;
|
|
191
|
-
|
|
230
|
+
// Pushing a locally edited sprite is activity too — it just isn't part of
|
|
231
|
+
// the printed pull list. Without it, an artist working only in Unity
|
|
232
|
+
// would slide into the 10s idle poll while actively saving files.
|
|
233
|
+
if (changedCount > 0 || pushWasActive(r.pushed)) {
|
|
192
234
|
// Snap back to the fast interval the moment anything changes.
|
|
193
|
-
|
|
235
|
+
lastChangeAt = Date.now();
|
|
194
236
|
backoffSec = intervalSec;
|
|
195
237
|
}
|
|
196
238
|
else {
|
|
197
|
-
|
|
198
|
-
backoffSec = nextBackoffForIdle(consecutiveIdleTicks, intervalSec, {
|
|
199
|
-
softSec: IDLE_SOFT_BACKOFF_SECONDS,
|
|
200
|
-
hardSec: IDLE_HARD_BACKOFF_SECONDS,
|
|
201
|
-
});
|
|
239
|
+
backoffSec = nextBackoffForIdle((Date.now() - lastChangeAt) / 1000, intervalSec);
|
|
202
240
|
}
|
|
203
241
|
if (opts.quiet)
|
|
204
242
|
return;
|
|
@@ -219,7 +257,7 @@ async function watchLoop(opts) {
|
|
|
219
257
|
const firstLine = err.message.split('\n')[0];
|
|
220
258
|
process.stdout.write('\x1b[2K\r');
|
|
221
259
|
// Any error breaks the idle streak so we come back fast once it clears.
|
|
222
|
-
|
|
260
|
+
lastChangeAt = Date.now();
|
|
223
261
|
const decision = classifyTickError(err, {
|
|
224
262
|
backoffSec,
|
|
225
263
|
pausedForAuth,
|
|
@@ -267,6 +305,8 @@ async function watchLoop(opts) {
|
|
|
267
305
|
}
|
|
268
306
|
}
|
|
269
307
|
finally {
|
|
308
|
+
if (heartbeat)
|
|
309
|
+
clearInterval(heartbeat);
|
|
270
310
|
inFlight = false;
|
|
271
311
|
}
|
|
272
312
|
};
|
|
@@ -311,13 +351,20 @@ function isNetworkError(err) {
|
|
|
311
351
|
/\b(502|504)\b.*\b(bad gateway|gateway timeout)\b/i.test(msg));
|
|
312
352
|
}
|
|
313
353
|
/**
|
|
314
|
-
*
|
|
315
|
-
* configured
|
|
316
|
-
* the watch
|
|
317
|
-
* in 0.4.0 without exercising the full watch loop.
|
|
354
|
+
* Idle thresholds (seconds of consecutive idleness) at which the watcher steps
|
|
355
|
+
* down from the configured interval to 5s, then to 10s. Single source of truth
|
|
356
|
+
* for both the watch loop and its regression tests.
|
|
318
357
|
*/
|
|
319
|
-
export
|
|
320
|
-
|
|
358
|
+
export const IDLE_BACKOFF_THRESHOLDS = { softSec: 60, hardSec: 300 };
|
|
359
|
+
/**
|
|
360
|
+
* Pure helper: given how long we've been idle (wall-clock seconds since the
|
|
361
|
+
* last pulled or pushed change) and the configured poll interval, return the
|
|
362
|
+
* next backoff in seconds. Exported so the watch-mode regression test can guard
|
|
363
|
+
* the "ticks vs seconds" bug fixed in 0.4.0 without exercising the full watch
|
|
364
|
+
* loop. Callers pass elapsed time, not tick counts: once the watcher has backed
|
|
365
|
+
* off, ticks are longer than `intervalSec`.
|
|
366
|
+
*/
|
|
367
|
+
export function nextBackoffForIdle(idleSeconds, intervalSec, thresholds = IDLE_BACKOFF_THRESHOLDS) {
|
|
321
368
|
if (idleSeconds >= thresholds.hardSec)
|
|
322
369
|
return Math.max(intervalSec, 10);
|
|
323
370
|
if (idleSeconds >= thresholds.softSec)
|
|
@@ -378,6 +425,11 @@ async function loadWatchHeader(onProgress) {
|
|
|
378
425
|
async function runOnce(opts, runOpts = {}) {
|
|
379
426
|
const config = await loadConfig();
|
|
380
427
|
const state = await loadState();
|
|
428
|
+
// Seed manifest validators persisted by the previous run so a one-shot sync
|
|
429
|
+
// can answer with a 304 on its very first poll (watchers already keep them
|
|
430
|
+
// in memory). A `--full` run intentionally sends no `since`, which is a
|
|
431
|
+
// different validator key, so it can't be short-circuited by a stale entry.
|
|
432
|
+
primeManifestEtags(state.manifestEtags);
|
|
381
433
|
const startedAt = new Date().toISOString();
|
|
382
434
|
const concurrency = Math.max(1, Math.min(opts.concurrency ?? 6, 16));
|
|
383
435
|
const verbose = !opts.quiet && !runOpts.watchMode;
|
|
@@ -387,7 +439,13 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
387
439
|
if (verbose && config.endpoint) {
|
|
388
440
|
console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
|
|
389
441
|
}
|
|
390
|
-
|
|
442
|
+
// Incremental unless asked for a full run — or unless a full pass is due:
|
|
443
|
+
// permanently-deleted rows can't be reported incrementally (see reconcile.ts).
|
|
444
|
+
const reconcileDue = !opts.full && !!state.lastSync && shouldReconcile(state.lastReconcile, Date.now());
|
|
445
|
+
let since = opts.full || reconcileDue ? undefined : state.lastSync;
|
|
446
|
+
if (reconcileDue && verbose) {
|
|
447
|
+
console.log(kleur.dim('Running a full reconcile (periodic) to clear anything deleted in MagicPixel.'));
|
|
448
|
+
}
|
|
391
449
|
const previousAssets = state.assets ?? {}; // id → key from prior sync
|
|
392
450
|
const spinner = verbose
|
|
393
451
|
? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
|
|
@@ -395,11 +453,35 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
395
453
|
if (!spinner)
|
|
396
454
|
onStatus?.(since ? 'Checking MagicPixel for edits…' : 'Fetching your sprites from MagicPixel…');
|
|
397
455
|
let manifest;
|
|
456
|
+
// Composite keys the cloud reports as gone (document trashed / componentized)
|
|
457
|
+
// since our cursor. Empty on --full, where the manifest itself is the truth.
|
|
458
|
+
let removedRemoteKeys = [];
|
|
398
459
|
// Capture before the Unity filter drops rows — lastSync must not jump past
|
|
399
460
|
// an editor save whose `unity` flag was omitted on this tick.
|
|
400
461
|
let observedUpdatedAt = null;
|
|
462
|
+
// Page-by-page progress: a first pull of thousands of sprites walks several
|
|
463
|
+
// pages, and a single static status line reads as a hang.
|
|
464
|
+
const onManifestProgress = (n) => {
|
|
465
|
+
const count = n.toLocaleString('en-US');
|
|
466
|
+
if (spinner)
|
|
467
|
+
spinner.text = `Fetching manifest… (${count})`;
|
|
468
|
+
else
|
|
469
|
+
onStatus?.(`Fetching your sprites from MagicPixel… (${count})`);
|
|
470
|
+
};
|
|
401
471
|
try {
|
|
402
|
-
|
|
472
|
+
let snapshot = await fetchManifestSnapshot(config, since, onManifestProgress);
|
|
473
|
+
if (snapshot.removalsTruncated && since) {
|
|
474
|
+
// The server couldn't list every removal in this window. Trusting the
|
|
475
|
+
// partial list and advancing the cursor would strand the rest forever, so
|
|
476
|
+
// re-read the whole manifest and let the orphan sweep decide.
|
|
477
|
+
if (verbose) {
|
|
478
|
+
console.log(kleur.dim('Many sprites were removed at once — re-reading the full manifest to stay in sync.'));
|
|
479
|
+
}
|
|
480
|
+
since = undefined;
|
|
481
|
+
snapshot = await fetchManifestSnapshot(config, undefined, onManifestProgress);
|
|
482
|
+
}
|
|
483
|
+
manifest = snapshot.entries;
|
|
484
|
+
removedRemoteKeys = snapshot.removedKeys;
|
|
403
485
|
observedUpdatedAt = maxIsoTimestamp(manifest.map((e) => e.updated_at));
|
|
404
486
|
const projectInfo = getLastProjectInfo();
|
|
405
487
|
const projectSuffix = projectInfo && verbose
|
|
@@ -524,11 +606,19 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
524
606
|
bytesIn: 0,
|
|
525
607
|
bytesSaved: 0,
|
|
526
608
|
renamed: [],
|
|
609
|
+
conflicts: [],
|
|
527
610
|
};
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
611
|
+
// Persist validators even when the cursor didn't move, so the next
|
|
612
|
+
// one-shot run starts from a conditional GET.
|
|
613
|
+
const lastSync = observedUpdatedAt && !(state.lastSync && state.lastSync > observedUpdatedAt)
|
|
614
|
+
? observedUpdatedAt
|
|
615
|
+
: state.lastSync;
|
|
616
|
+
await saveState({
|
|
617
|
+
...state,
|
|
618
|
+
...(lastSync ? { lastSync } : {}),
|
|
619
|
+
...(since ? {} : { lastReconcile: startedAt }),
|
|
620
|
+
manifestEtags: manifestEtagsSnapshot(),
|
|
621
|
+
});
|
|
532
622
|
await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
|
|
533
623
|
return empty;
|
|
534
624
|
}
|
|
@@ -571,6 +661,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
571
661
|
const diffLimit = createLimit(concurrency);
|
|
572
662
|
const shaByEntryId = new Map();
|
|
573
663
|
const toDownload = [];
|
|
664
|
+
const conflicts = [];
|
|
574
665
|
let bytesSaved = 0;
|
|
575
666
|
let unchanged = 0;
|
|
576
667
|
const synced = state.synced ?? {};
|
|
@@ -614,13 +705,17 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
614
705
|
}
|
|
615
706
|
const localSha = await fileSha256(pathFor(entry));
|
|
616
707
|
shaByEntryId.set(entry.id, localSha);
|
|
617
|
-
const
|
|
708
|
+
const decision = decidePull({
|
|
618
709
|
cloudSha256: entry.sha256,
|
|
619
710
|
localSha256: localSha,
|
|
620
711
|
previousCloudSha256,
|
|
712
|
+
lastPushedDiskSha256: synced[entry.key]?.diskSha256,
|
|
621
713
|
inWorkingSet,
|
|
622
714
|
});
|
|
623
|
-
if (
|
|
715
|
+
if (decision === 'conflict') {
|
|
716
|
+
conflicts.push(entry.key);
|
|
717
|
+
}
|
|
718
|
+
else if (decision === 'skip') {
|
|
624
719
|
unchanged++;
|
|
625
720
|
if (entry.size_bytes)
|
|
626
721
|
bytesSaved += entry.size_bytes;
|
|
@@ -633,30 +728,32 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
633
728
|
// (Renames also produce a stale path on disk — collected separately below.)
|
|
634
729
|
let orphans = [];
|
|
635
730
|
const remoteDiskPaths = new Set(manifest.map((e) => pathFor(e)));
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
731
|
+
const remoteKeySet = new Set(manifest.map((e) => e.key));
|
|
732
|
+
const previousKeySet = new Set(Object.values(previousAssets));
|
|
733
|
+
const localPngs = needsLocalPngWalk(since, toDownload.length)
|
|
734
|
+
? await walkOutDirPngs(config.outDir)
|
|
735
|
+
: [];
|
|
736
|
+
// Every key we have ever synced, indexed by the disk path it maps to. Used
|
|
737
|
+
// both for orphan tracking and to look up a prune candidate's edit baseline.
|
|
738
|
+
const keyByPath = new Map();
|
|
739
|
+
for (const key of Object.keys(state.synced ?? {}).concat(Object.values(previousAssets))) {
|
|
740
|
+
try {
|
|
741
|
+
const p = pathForKey(key);
|
|
742
|
+
if (!keyByPath.has(p))
|
|
743
|
+
keyByPath.set(p, key);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
/* unusable key — nothing to track */
|
|
747
|
+
}
|
|
748
|
+
}
|
|
641
749
|
// Sprites created in Unity (or any PNG we never pulled) are pending a
|
|
642
750
|
// push into MagicPixel, not orphans — see prunePolicy.ts.
|
|
643
751
|
if (!since) {
|
|
644
|
-
const trackedPaths = new Set(Object.keys(state.synced ?? {})
|
|
645
|
-
.concat(Object.values(previousAssets))
|
|
646
|
-
.map((key) => {
|
|
647
|
-
try {
|
|
648
|
-
return pathForKey(key);
|
|
649
|
-
}
|
|
650
|
-
catch {
|
|
651
|
-
return '';
|
|
652
|
-
}
|
|
653
|
-
})
|
|
654
|
-
.filter(Boolean));
|
|
655
752
|
const policy = selectFullSyncOrphans({
|
|
656
753
|
localPaths: localPngs.map((a) => a.abs),
|
|
657
754
|
remoteDiskPaths,
|
|
658
755
|
protectedPaths: unknownFlagPaths,
|
|
659
|
-
isTracked: (p) =>
|
|
756
|
+
isTracked: (p) => keyByPath.has(p),
|
|
660
757
|
});
|
|
661
758
|
orphans = policy.orphans;
|
|
662
759
|
}
|
|
@@ -716,15 +813,20 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
716
813
|
}
|
|
717
814
|
})
|
|
718
815
|
.filter((p) => p && existsSync(p) && isPathInside(p, outRoot));
|
|
719
|
-
//
|
|
816
|
+
// Every prune candidate goes through `addOrphan`, so no source can bypass the
|
|
817
|
+
// local-edit guard applied below. De-duplicates vs the full-sync orphan list.
|
|
720
818
|
const orphanSet = new Set(orphans);
|
|
819
|
+
const addOrphan = (p) => {
|
|
820
|
+
if (existsSync(p) && isPathInside(p, outRoot))
|
|
821
|
+
orphanSet.add(p);
|
|
822
|
+
};
|
|
721
823
|
for (const p of renameStalePaths)
|
|
722
|
-
|
|
824
|
+
addOrphan(p);
|
|
723
825
|
// Un-checked "Sync to Unity" artboards: their PNG (and .meta) leaves the
|
|
724
826
|
// Unity project on the next sync, incremental or not. Working-set originals
|
|
725
827
|
// are never in this list.
|
|
726
828
|
for (const p of deselectedPaths)
|
|
727
|
-
|
|
829
|
+
addOrphan(p);
|
|
728
830
|
// Server-side rename history. Each manifest entry carries the composite
|
|
729
831
|
// keys this row was previously emitted under (populated by the editor when
|
|
730
832
|
// a doc/artboard is renamed). For each prior key whose file is still on
|
|
@@ -737,16 +839,58 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
737
839
|
resolveDiskPath: (key) => pathForKey(key),
|
|
738
840
|
fileExists: existsSync,
|
|
739
841
|
});
|
|
740
|
-
for (const p of prevKeyResult.orphanPaths)
|
|
741
|
-
|
|
742
|
-
orphanSet.add(p);
|
|
743
|
-
}
|
|
842
|
+
for (const p of prevKeyResult.orphanPaths)
|
|
843
|
+
addOrphan(p);
|
|
744
844
|
for (const r of prevKeyResult.renames) {
|
|
745
845
|
if (!renamed.some((x) => x.id === r.id && x.oldKey === r.oldKey)) {
|
|
746
846
|
renamed.push(r);
|
|
747
847
|
}
|
|
748
848
|
}
|
|
749
|
-
|
|
849
|
+
// Cloud-side removals (document trashed, or converted into a component
|
|
850
|
+
// master). These rows leave the manifest entirely, so an incremental run has
|
|
851
|
+
// no other way to learn about them — without this the PNG would live on in
|
|
852
|
+
// the game project forever. Only keys we pulled ourselves are eligible:
|
|
853
|
+
// a same-named sprite authored in Unity is pending push, not an orphan.
|
|
854
|
+
for (const key of removedRemoteKeys) {
|
|
855
|
+
if (remoteKeySet.has(key))
|
|
856
|
+
continue; // still live under another row
|
|
857
|
+
if (!synced[key] && !previousKeySet.has(key))
|
|
858
|
+
continue; // never ours
|
|
859
|
+
try {
|
|
860
|
+
addOrphan(pathForKey(key));
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
// A locally-edited file is never deleted, whatever put it in the prune set
|
|
867
|
+
// (deselected "Sync to Unity", trashed document, rename fallback,
|
|
868
|
+
// previous-key sweep): that edit is the only copy of the work until the user
|
|
869
|
+
// pushes it or discards it. Manifest-resident conflicts were already detected
|
|
870
|
+
// during the diff, so they skip the re-hash.
|
|
871
|
+
const conflictPaths = new Set();
|
|
872
|
+
for (const key of conflicts) {
|
|
873
|
+
try {
|
|
874
|
+
conflictPaths.add(pathForKey(key));
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
/* unusable key — nothing to protect */
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
orphans = [];
|
|
881
|
+
const keptFromPrune = [];
|
|
882
|
+
for (const p of orphanSet) {
|
|
883
|
+
if (conflictPaths.has(p))
|
|
884
|
+
continue;
|
|
885
|
+
const key = keyByPath.get(p);
|
|
886
|
+
if (key &&
|
|
887
|
+
(await hasUnpushedLocalEdit({ absPath: p, baselineDiskSha256: synced[key]?.diskSha256 }))) {
|
|
888
|
+
keptFromPrune.push(key);
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
orphans.push(p);
|
|
892
|
+
}
|
|
893
|
+
conflicts.push(...keptFromPrune);
|
|
750
894
|
// Legacy-suffix folder sweep — always runs, even in incremental mode.
|
|
751
895
|
//
|
|
752
896
|
// Background: server-side slug uniqueness rules have changed over time
|
|
@@ -799,6 +943,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
799
943
|
}
|
|
800
944
|
console.log();
|
|
801
945
|
}
|
|
946
|
+
if (conflicts.length > 0) {
|
|
947
|
+
if (live) {
|
|
948
|
+
printConflicts(conflicts, new Set(keptFromPrune));
|
|
949
|
+
}
|
|
950
|
+
onStatus?.(`${conflicts.length} sprite${conflicts.length === 1 ? '' : 's'} changed in both places — kept your local copy.`);
|
|
951
|
+
}
|
|
802
952
|
if (opts.dryRun) {
|
|
803
953
|
if (verbose) {
|
|
804
954
|
console.log(kleur.dim('--dry-run: no files written.'));
|
|
@@ -818,6 +968,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
818
968
|
failed: 0,
|
|
819
969
|
bytesIn: 0,
|
|
820
970
|
bytesSaved,
|
|
971
|
+
conflicts,
|
|
821
972
|
};
|
|
822
973
|
}
|
|
823
974
|
const result = {
|
|
@@ -829,6 +980,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
829
980
|
failed: 0,
|
|
830
981
|
bytesIn: 0,
|
|
831
982
|
bytesSaved,
|
|
983
|
+
conflicts,
|
|
832
984
|
};
|
|
833
985
|
let progress = null;
|
|
834
986
|
if (live && toDownload.length > 0) {
|
|
@@ -1030,9 +1182,15 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1030
1182
|
for (const key of result.removed)
|
|
1031
1183
|
delete nextSynced[key];
|
|
1032
1184
|
const writtenKeys = new Set([...result.added, ...result.modified]);
|
|
1185
|
+
const conflictSet = new Set(conflicts);
|
|
1033
1186
|
for (const entry of manifest) {
|
|
1034
1187
|
if (!entry.asset_id)
|
|
1035
1188
|
continue;
|
|
1189
|
+
// Conflicted keys keep their old baselines: recording the new cloud sha
|
|
1190
|
+
// (with the locally-edited disk sha) would make both halves believe they
|
|
1191
|
+
// are in sync and freeze the divergence forever.
|
|
1192
|
+
if (conflictSet.has(entry.key))
|
|
1193
|
+
continue;
|
|
1036
1194
|
const sha = entry.sha256 ?? (await fileSha256(pathFor(entry)));
|
|
1037
1195
|
if (!sha)
|
|
1038
1196
|
continue;
|
|
@@ -1057,8 +1215,17 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1057
1215
|
...(sourceRel ? { sourceRel } : {}),
|
|
1058
1216
|
};
|
|
1059
1217
|
}
|
|
1060
|
-
const nextState = {
|
|
1061
|
-
|
|
1218
|
+
const nextState = {
|
|
1219
|
+
...state,
|
|
1220
|
+
assets: nextAssets,
|
|
1221
|
+
synced: nextSynced,
|
|
1222
|
+
manifestEtags: manifestEtagsSnapshot(),
|
|
1223
|
+
// A completed full pass is the only run that can prove nothing is orphaned.
|
|
1224
|
+
...(!since && result.failed === 0 ? { lastReconcile: startedAt } : {}),
|
|
1225
|
+
};
|
|
1226
|
+
// Unresolved conflicts hold the cursor, exactly like a failed download: the
|
|
1227
|
+
// next sync must see the same rows again and re-report them.
|
|
1228
|
+
if (result.failed === 0 && conflicts.length === 0) {
|
|
1062
1229
|
// Advance the cursor to the newest row we actually observed, NOT the
|
|
1063
1230
|
// wall-clock time the sync started. Using startedAt silently skipped
|
|
1064
1231
|
// rows whose `updated_at` lived in the gap between the manifest snapshot
|
|
@@ -1082,9 +1249,14 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1082
1249
|
}
|
|
1083
1250
|
delete nextState.lastError;
|
|
1084
1251
|
}
|
|
1085
|
-
else {
|
|
1252
|
+
else if (result.failed > 0) {
|
|
1086
1253
|
nextState.lastError = `${result.failed} download${result.failed === 1 ? '' : 's'} failed at ${startedAt}`;
|
|
1087
1254
|
}
|
|
1255
|
+
else {
|
|
1256
|
+
nextState.lastError =
|
|
1257
|
+
`${conflicts.length} sprite${conflicts.length === 1 ? '' : 's'} changed both locally and in MagicPixel at ${startedAt}` +
|
|
1258
|
+
` (${conflicts.slice(0, 3).join(', ')}${conflicts.length > 3 ? ', …' : ''})`;
|
|
1259
|
+
}
|
|
1088
1260
|
// Emit typed index from the filesystem — never from the manifest. This
|
|
1089
1261
|
// guarantees the barrel can never disagree with what bundlers see on disk,
|
|
1090
1262
|
// and removes the silent-failure mode where a flaky manifest call left
|
|
@@ -1136,10 +1308,10 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1136
1308
|
printRenames(renamed, { withHints: true });
|
|
1137
1309
|
}
|
|
1138
1310
|
onStatus?.('Checking your game files…');
|
|
1139
|
-
await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
|
|
1311
|
+
const pushed = await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
|
|
1140
1312
|
if (result.failed)
|
|
1141
1313
|
process.exitCode = 1;
|
|
1142
|
-
return result;
|
|
1314
|
+
return { ...result, pushed };
|
|
1143
1315
|
}
|
|
1144
1316
|
async function maybePushLocalSprites(pushEnabled, verbose, gameIndex, announceErrors = verbose, onStatus) {
|
|
1145
1317
|
if (pushEnabled === false)
|
|
@@ -1213,6 +1385,23 @@ function humanTime(iso) {
|
|
|
1213
1385
|
return iso;
|
|
1214
1386
|
}
|
|
1215
1387
|
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Both-sides-changed report. Printed on every run that has conflicts (also in
|
|
1390
|
+
* watch mode) because the alternative — staying silent — is what makes a lost
|
|
1391
|
+
* edit feel like a sync bug.
|
|
1392
|
+
*/
|
|
1393
|
+
function printConflicts(keys, removedRemotely = new Set()) {
|
|
1394
|
+
console.log();
|
|
1395
|
+
console.log(kleur.yellow(`${keys.length} sprite${keys.length === 1 ? '' : 's'} changed in both places — not overwritten:`));
|
|
1396
|
+
for (const key of keys) {
|
|
1397
|
+
const note = removedRemotely.has(key) ? kleur.dim(' (no longer synced from MagicPixel)') : '';
|
|
1398
|
+
console.log(` ${kleur.yellow('~')} ${key}${note}`);
|
|
1399
|
+
}
|
|
1400
|
+
console.log(kleur.dim(` Keep the local edit: run \`${cmd('push')}\` (it will report the cloud change), or delete the PNG to accept MagicPixel's copy.`));
|
|
1401
|
+
if (removedRemotely.size > 0) {
|
|
1402
|
+
console.log(kleur.dim(` Marked (no longer synced): re-check "Sync to Unity" (or restore the document) to push the edit up, or delete the PNG to accept the removal.`));
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1216
1405
|
function printOrphans(orphans) {
|
|
1217
1406
|
console.log();
|
|
1218
1407
|
console.log(kleur.yellow(`Orphaned local files (not in manifest):`));
|
package/dist/index.js
CHANGED
|
@@ -87,7 +87,7 @@ program
|
|
|
87
87
|
.option('--no-prune', 'Keep local files not in the manifest (pruning is now on by default)')
|
|
88
88
|
.option('--dry-run', 'Print the plan without writing files')
|
|
89
89
|
.option('--full', 'Ignore lastSync state; re-fetch the full manifest')
|
|
90
|
-
.option('-w, --watch [seconds]', 'Poll for changes (default 2s; auto-slows to 5s after ~
|
|
90
|
+
.option('-w, --watch [seconds]', 'Poll for changes (default 2s; auto-slows to 5s after ~1min idle, 10s after ~5min)', parseWatchInterval)
|
|
91
91
|
.option('-q, --quiet', 'Minimal output (for CI)')
|
|
92
92
|
.option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
|
|
93
93
|
.addHelpText('after', '\nExamples:\n $ magicpixel sync # incremental sync\n $ magicpixel sync --full # ignore lastSync, re-check everything\n $ magicpixel sync -w # watch mode (2s; adaptive idle backoff; exit 2 after 5 auth failures)\n')
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Was this PNG edited in the game project since we last wrote or pushed it?"
|
|
3
|
+
*
|
|
4
|
+
* The single definition of a local edit, used by two callers:
|
|
5
|
+
*
|
|
6
|
+
* - `pullDecision` — an entry still in the manifest whose disk bytes diverged
|
|
7
|
+
* is a conflict, not a download target.
|
|
8
|
+
* - the prune sweep in `sync` — a path that LEFT the manifest (deselected
|
|
9
|
+
* "Sync to Unity", trashed document, rename/previous-key cruft) must not be
|
|
10
|
+
* deleted while it holds work MagicPixel has never seen.
|
|
11
|
+
*
|
|
12
|
+
* Without a recorded baseline (`state.synced[key].diskSha256`) we can't tell an
|
|
13
|
+
* edit from a file we never wrote, so we report "no edit" and let the caller's
|
|
14
|
+
* existing rules apply unchanged.
|
|
15
|
+
*/
|
|
16
|
+
import { fileSha256 } from './hash.js';
|
|
17
|
+
/** Pure comparison — no filesystem access, safe for decision unit tests. */
|
|
18
|
+
export function isLocalEdit(baselineDiskSha256, localSha256) {
|
|
19
|
+
if (!baselineDiskSha256 || !localSha256)
|
|
20
|
+
return false;
|
|
21
|
+
return baselineDiskSha256 !== localSha256;
|
|
22
|
+
}
|
|
23
|
+
/** Hashes `absPath` only when a baseline exists (nothing else can be an edit). */
|
|
24
|
+
export async function hasUnpushedLocalEdit({ absPath, baselineDiskSha256, knownSha256, }) {
|
|
25
|
+
if (!baselineDiskSha256)
|
|
26
|
+
return false;
|
|
27
|
+
const sha = knownSha256 !== undefined ? knownSha256 : await fileSha256(absPath);
|
|
28
|
+
return isLocalEdit(baselineDiskSha256, sha);
|
|
29
|
+
}
|
|
@@ -6,25 +6,40 @@
|
|
|
6
6
|
* library on every catch-up and overwrite the originals. Pull only when the
|
|
7
7
|
* cloud composite actually changed since we last recorded it, or when there
|
|
8
8
|
* is no local file to protect.
|
|
9
|
+
*
|
|
10
|
+
* Third outcome: `conflict`. `sync` pulls before it pushes, so a PNG edited on
|
|
11
|
+
* disk *and* in MagicPixel since the last sync would be overwritten before
|
|
12
|
+
* `push` ever sees it — the local edit would vanish with no message. When the
|
|
13
|
+
* disk bytes diverge from the fingerprint we recorded when we last wrote/pushed
|
|
14
|
+
* that file, the entry is reported instead of downloaded.
|
|
9
15
|
*/
|
|
10
|
-
|
|
16
|
+
import { isLocalEdit } from './localEdit.js';
|
|
17
|
+
export function decidePull(opts) {
|
|
11
18
|
const cloud = opts.cloudSha256 ?? null;
|
|
12
19
|
const local = opts.localSha256;
|
|
13
20
|
const prev = opts.previousCloudSha256;
|
|
14
21
|
// Missing on disk → restore from cloud (MagicPixel-only art and deletions).
|
|
15
22
|
if (!local)
|
|
16
|
-
return
|
|
23
|
+
return 'pull';
|
|
17
24
|
if (cloud && cloud === local)
|
|
18
|
-
return
|
|
25
|
+
return 'skip';
|
|
19
26
|
// Cloud composite unchanged since last pull/push — keep the original PNG.
|
|
20
27
|
if (cloud && prev && cloud === prev)
|
|
21
|
-
return
|
|
28
|
+
return 'skip';
|
|
22
29
|
if (opts.inWorkingSet) {
|
|
23
30
|
// Never recorded a cloud hash: disk is the source of truth (connect/ingest).
|
|
24
31
|
if (!prev)
|
|
25
|
-
return
|
|
32
|
+
return 'skip';
|
|
26
33
|
// Editor save often nulls the cached hash; treat that as a cloud change.
|
|
27
|
-
return
|
|
34
|
+
return localEdit(opts) ? 'conflict' : 'pull';
|
|
28
35
|
}
|
|
29
|
-
return
|
|
36
|
+
return localEdit(opts) ? 'conflict' : 'pull';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Disk bytes changed since we recorded them → someone edited the PNG in the
|
|
40
|
+
* game project. Without a recorded baseline we can't tell an edit from a file
|
|
41
|
+
* we never wrote, so we stay out of the way and let the pull proceed.
|
|
42
|
+
*/
|
|
43
|
+
function localEdit(opts) {
|
|
44
|
+
return isLocalEdit(opts.lastPushedDiskSha256, opts.localSha256);
|
|
30
45
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When to promote an incremental sync to a full reconcile.
|
|
3
|
+
*
|
|
4
|
+
* `removed_keys` can only describe rows that still exist (trashed /
|
|
5
|
+
* componentized). A permanent delete ("Empty Trash") leaves nothing to report,
|
|
6
|
+
* so an incremental client keeps the PNG in the game project indefinitely. A
|
|
7
|
+
* periodic full pass — the manifest itself is the truth there — closes that
|
|
8
|
+
* without tombstone tables or per-tick cost.
|
|
9
|
+
*/
|
|
10
|
+
/** 15 minutes: bounded orphan lifetime, ~4 full manifests/hour worst case. */
|
|
11
|
+
export const RECONCILE_INTERVAL_MS = 15 * 60 * 1000;
|
|
12
|
+
export function shouldReconcile(lastReconcile, nowMs, intervalMs = RECONCILE_INTERVAL_MS) {
|
|
13
|
+
if (!lastReconcile)
|
|
14
|
+
return true;
|
|
15
|
+
const then = Date.parse(lastReconcile);
|
|
16
|
+
if (!Number.isFinite(then))
|
|
17
|
+
return true;
|
|
18
|
+
// Clock moved backwards (machine sleep / NTP step): reconcile rather than
|
|
19
|
+
// waiting out a bogus future timestamp.
|
|
20
|
+
if (then > nowMs)
|
|
21
|
+
return true;
|
|
22
|
+
return nowMs - then >= intervalMs;
|
|
23
|
+
}
|
package/dist/util/security.js
CHANGED
|
@@ -88,15 +88,49 @@ export function tmpPathFor(diskPath) {
|
|
|
88
88
|
const suffix = randomBytes(8).toString('hex');
|
|
89
89
|
return `${diskPath}.${process.pid}.${suffix}.tmp`;
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Deadline for a single request (JSON APIs: manifest, push, whoami, key
|
|
93
|
+
* validation). Without a deadline a stalled socket — sleeping laptop, dropped
|
|
94
|
+
* Wi-Fi, dead keep-alive connection — makes `fetch` hang forever, so
|
|
95
|
+
* `sync --watch` sits silently on one tick and never retries.
|
|
96
|
+
*/
|
|
97
|
+
export const REQUEST_TIMEOUT_MS = 45_000;
|
|
98
|
+
/** Deadline for a PNG body, which can legitimately take much longer. */
|
|
99
|
+
export const ASSET_TIMEOUT_MS = 120_000;
|
|
100
|
+
function isAbortError(err) {
|
|
101
|
+
const name = err?.name;
|
|
102
|
+
return name === 'AbortError' || name === 'TimeoutError';
|
|
103
|
+
}
|
|
91
104
|
/**
|
|
92
105
|
* Follow redirects only within the same origin as the initial request (prevents API key leak).
|
|
106
|
+
*
|
|
107
|
+
* Each hop gets a fresh deadline. The signal stays armed after the response
|
|
108
|
+
* headers arrive, so a stalled *body* read aborts too — that abort surfaces
|
|
109
|
+
* raw here and is relabelled as a transient network failure by `retryTransient`
|
|
110
|
+
* one layer up; only the connection attempt gets the friendly "didn't respond
|
|
111
|
+
* in time" wording. Callers that pass their own `init.signal` own their timing
|
|
112
|
+
* (e.g. `doctor`'s 5s probe).
|
|
93
113
|
*/
|
|
94
|
-
export async function safeFetch(url, init = {}) {
|
|
114
|
+
export async function safeFetch(url, init = {}, opts = {}) {
|
|
95
115
|
const origin = new URL(url).origin;
|
|
116
|
+
const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
96
117
|
let current = url;
|
|
97
118
|
for (let hop = 0; hop < 5; hop++) {
|
|
98
|
-
const
|
|
99
|
-
|
|
119
|
+
const signal = init.signal ?? AbortSignal.timeout(timeoutMs);
|
|
120
|
+
let res;
|
|
121
|
+
try {
|
|
122
|
+
res = await fetch(current, { ...init, signal, redirect: 'manual' });
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
if (!init.signal && isAbortError(e)) {
|
|
126
|
+
throw new Error(`timed out after ${Math.round(timeoutMs / 1000)}s — MagicPixel didn't respond in time.\n` +
|
|
127
|
+
` Fix: check your internet connection and that the MagicPixel API is reachable.`, { cause: e });
|
|
128
|
+
}
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
// 304 lives in the 3xx range but is a conditional-request answer, not a
|
|
132
|
+
// redirect: it carries no Location and callers handle it directly.
|
|
133
|
+
if (res.status < 300 || res.status >= 400 || res.status === 304)
|
|
100
134
|
return res;
|
|
101
135
|
const location = res.headers.get('location');
|
|
102
136
|
await res.body?.cancel();
|
package/dist/util/watchCopy.js
CHANGED
|
@@ -19,3 +19,23 @@ export function formatWatchSpriteLine(c) {
|
|
|
19
19
|
return ` Sprites: ${fmt(set)} in your game`;
|
|
20
20
|
return ` Sprites: ${fmt(pulled)}`;
|
|
21
21
|
}
|
|
22
|
+
/** How long a single watch tick may run before the heartbeat starts printing. */
|
|
23
|
+
export const SLOW_TICK_HEARTBEAT_MS = 60_000;
|
|
24
|
+
/**
|
|
25
|
+
* Heartbeat line for a tick that has outlived `SLOW_TICK_HEARTBEAT_MS`. Keeps
|
|
26
|
+
* the last status text so the user sees *what* is slow, not just that
|
|
27
|
+
* something is. Requests carry their own deadlines, so this always means slow.
|
|
28
|
+
*/
|
|
29
|
+
export function formatSlowTickLine(elapsedSec, lastStatus) {
|
|
30
|
+
const secs = Math.max(0, Math.round(elapsedSec));
|
|
31
|
+
const elapsed = secs >= 120 ? `${Math.round(secs / 60)}m` : `${secs}s`;
|
|
32
|
+
// "Fetching sprites… (1,200)" → "Fetching sprites (1,200)": the ellipsis and
|
|
33
|
+
// the heartbeat suffix would otherwise both claim the end of the line.
|
|
34
|
+
const what = lastStatus
|
|
35
|
+
?.trim()
|
|
36
|
+
.replace(/[.…]+(\s*\([^()]*\))?$/, '$1')
|
|
37
|
+
.trim();
|
|
38
|
+
return what
|
|
39
|
+
? `${what} — still working (${elapsed})`
|
|
40
|
+
: `Still working (${elapsed})`;
|
|
41
|
+
}
|