@magicpixelart/cli 0.5.28 → 0.5.29
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 +11 -0
- package/dist/api.js +26 -0
- package/dist/commands/push.js +41 -7
- package/dist/commands/resync.js +123 -0
- package/dist/commands/sync.js +41 -0
- package/dist/index.js +8 -0
- package/dist/util/pushPlan.js +4 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.29]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Ship `magicpixel resync <folder>`: the npm 0.5.28 build was published before the command landed.
|
|
14
|
+
|
|
15
|
+
## [0.5.28]
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `magicpixel resync <folder>` (`--dry-run`, `--yes`): make one MagicPixel folder match the local game folder. Local files overwrite their MagicPixel copies (size follows the file); files missing locally move to Trash. Nothing is trashed if any upload fails or the game scan was capped.
|
|
19
|
+
- `sync --watch` runs "Resync from game…" requests queued from the library folder menu.
|
|
20
|
+
|
|
10
21
|
## [0.5.27] — 2026-09-21
|
|
11
22
|
|
|
12
23
|
- Fixed Sync presses that changed only an artboard selection being skipped as
|
package/dist/api.js
CHANGED
|
@@ -449,3 +449,29 @@ export async function pushSpritesAdaptive(sprites, retryOpts) {
|
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
|
+
// ---------- folder resync (local folder is the source of truth) -------------
|
|
453
|
+
export function resolveResyncEndpoint() {
|
|
454
|
+
return resolveIngestEndpoint().replace(/\/ingest$/, '/resync');
|
|
455
|
+
}
|
|
456
|
+
async function postResync(body) {
|
|
457
|
+
const url = resolveResyncEndpoint();
|
|
458
|
+
return retryTransient('resync', async () => {
|
|
459
|
+
const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
|
|
460
|
+
const res = await safeFetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
461
|
+
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
462
|
+
if (res.ok)
|
|
463
|
+
return (await res.json());
|
|
464
|
+
const text = await res.text();
|
|
465
|
+
throw new ApiError(res.status, withRequestId(friendlyApiError(res.status, text, 'resync', errorCodeFromResponse(res)), serverRequestId), serverRequestId);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
export function pruneResyncFolder(folderPath, keepAssetIds, dryRun) {
|
|
469
|
+
return postResync({ action: 'prune', folderPath, keepAssetIds, dryRun });
|
|
470
|
+
}
|
|
471
|
+
export async function claimResyncRequest() {
|
|
472
|
+
const r = await postResync({ action: 'claim' });
|
|
473
|
+
return r.request ?? null;
|
|
474
|
+
}
|
|
475
|
+
export async function reportResyncRequest(id, status, message, counts) {
|
|
476
|
+
await postResync({ action: 'report', id, status, message, counts });
|
|
477
|
+
}
|
package/dist/commands/push.js
CHANGED
|
@@ -41,7 +41,11 @@ export async function runPush(opts = {}) {
|
|
|
41
41
|
}
|
|
42
42
|
export async function runPushWith(config, state, opts = {}) {
|
|
43
43
|
const quiet = opts.quiet === true;
|
|
44
|
-
|
|
44
|
+
if (opts.replace)
|
|
45
|
+
opts = { ...opts, flatten: true };
|
|
46
|
+
const empty = {
|
|
47
|
+
created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0, keptAssetIds: [], scanned: 0,
|
|
48
|
+
};
|
|
45
49
|
const spinner = quiet ? null : ora(countingSpritesText(0)).start();
|
|
46
50
|
const kind = await detectProjectKind();
|
|
47
51
|
const index = opts.gameIndex
|
|
@@ -110,7 +114,7 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
110
114
|
const segments = entry
|
|
111
115
|
? entry.adoptRel.replace(/\.png$/i, '').split('/')
|
|
112
116
|
: key.split('/');
|
|
113
|
-
if (opts.excludeKeys?.has(key)) {
|
|
117
|
+
if (opts.excludeKeys?.has(key) || (opts.keyFilter && !opts.keyFilter(key))) {
|
|
114
118
|
reportHash();
|
|
115
119
|
return;
|
|
116
120
|
}
|
|
@@ -129,7 +133,7 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
129
133
|
if (sourceByKey.has(k))
|
|
130
134
|
folderTreeKeys.add(k);
|
|
131
135
|
}
|
|
132
|
-
const actions = planPush(candidates, state.synced, { flatten: opts.flatten, folderTreeKeys });
|
|
136
|
+
const actions = planPush(candidates, state.synced, { flatten: opts.flatten, folderTreeKeys, replace: opts.replace });
|
|
133
137
|
const skipped = actions.filter((a) => a.kind === 'skip' && a.reason !== 'legacy').length;
|
|
134
138
|
const legacy = actions.filter((a) => a.kind === 'skip' && a.reason === 'legacy');
|
|
135
139
|
const flattenBlocked = actions.filter((a) => a.kind === 'needs-flatten');
|
|
@@ -162,7 +166,10 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
162
166
|
await persistDiskFingerprints(state, candidates);
|
|
163
167
|
if (!quiet)
|
|
164
168
|
console.log(kleur.green('✓ nothing to push.'));
|
|
165
|
-
return {
|
|
169
|
+
return {
|
|
170
|
+
...empty, imported: matched.entries.length, unchanged: skipped,
|
|
171
|
+
keptAssetIds: collectKeptAssetIds(candidates, state.synced ?? {}, []), scanned: candidates.length,
|
|
172
|
+
};
|
|
166
173
|
}
|
|
167
174
|
if (opts.dryRun) {
|
|
168
175
|
if (!quiet) {
|
|
@@ -173,7 +180,10 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
173
180
|
}
|
|
174
181
|
console.log(kleur.dim('--dry-run: nothing sent.'));
|
|
175
182
|
}
|
|
176
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
...empty, imported: matched.entries.length,
|
|
185
|
+
keptAssetIds: collectKeptAssetIds(candidates, state.synced ?? {}, []), scanned: candidates.length,
|
|
186
|
+
};
|
|
177
187
|
}
|
|
178
188
|
const outRoot = resolve(process.cwd(), config.outDir);
|
|
179
189
|
const shaByKey = new Map(candidates.map((c) => [c.key, c.diskSha256]));
|
|
@@ -195,10 +205,14 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
195
205
|
layerIdx: a.layerIdx,
|
|
196
206
|
baseSha256: a.baseSha256,
|
|
197
207
|
flatten: opts.flatten === true,
|
|
208
|
+
...(opts.replace ? { replace: true } : {}),
|
|
198
209
|
});
|
|
199
210
|
}
|
|
200
211
|
else if (a.kind === 'adopt') {
|
|
201
|
-
sprites.push({
|
|
212
|
+
sprites.push({
|
|
213
|
+
key: a.key, pngBase64, diskSha256, path: a.path, pathNames: a.pathNames, name: a.name,
|
|
214
|
+
...(opts.replace ? { replace: true } : {}),
|
|
215
|
+
});
|
|
202
216
|
}
|
|
203
217
|
}
|
|
204
218
|
const progress = quiet ? null : ora(`Pushing 0/${sprites.length}…`).start();
|
|
@@ -296,7 +310,27 @@ export async function runPushWith(config, state, opts = {}) {
|
|
|
296
310
|
}
|
|
297
311
|
if (failed)
|
|
298
312
|
process.exitCode = 1;
|
|
299
|
-
return {
|
|
313
|
+
return {
|
|
314
|
+
...counts, imported: matched.entries.length,
|
|
315
|
+
keptAssetIds: collectKeptAssetIds(candidates, synced, results), scanned: candidates.length,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Library documents that now mirror an in-scope local file: every successful
|
|
320
|
+
* push result, plus unchanged candidates already recorded in sync state.
|
|
321
|
+
*/
|
|
322
|
+
export function collectKeptAssetIds(candidates, synced, results) {
|
|
323
|
+
const out = new Set();
|
|
324
|
+
for (const r of results) {
|
|
325
|
+
if (r.assetId && (r.status === 'created' || r.status === 'updated' || r.status === 'unchanged'))
|
|
326
|
+
out.add(r.assetId);
|
|
327
|
+
}
|
|
328
|
+
for (const c of candidates) {
|
|
329
|
+
const id = synced[c.key]?.assetId;
|
|
330
|
+
if (id)
|
|
331
|
+
out.add(id);
|
|
332
|
+
}
|
|
333
|
+
return [...out];
|
|
300
334
|
}
|
|
301
335
|
/**
|
|
302
336
|
* Sprites to re-send under `--force`: every sprite the cloud refused because it
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `magicpixel resync <folder>` — make one MagicPixel library folder match the
|
|
3
|
+
* local game folder exactly:
|
|
4
|
+
* - every local sprite in the folder overwrites its MagicPixel copy (size
|
|
5
|
+
* follows the file, MagicPixel-only edits are replaced);
|
|
6
|
+
* - new local sprites are imported;
|
|
7
|
+
* - MagicPixel files in that folder with no local file go to Trash.
|
|
8
|
+
*/
|
|
9
|
+
import kleur from 'kleur';
|
|
10
|
+
import { createInterface } from 'node:readline/promises';
|
|
11
|
+
import { stdin, stdout } from 'node:process';
|
|
12
|
+
import { loadConfig, loadState } from '../config.js';
|
|
13
|
+
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
14
|
+
import { detectProjectKind, isEngineKind } from '../util/framework.js';
|
|
15
|
+
import { indexGamePngs, countingSpritesText } from '../util/gameScan.js';
|
|
16
|
+
import { pruneResyncFolder } from '../api.js';
|
|
17
|
+
import { runPushWith } from './push.js';
|
|
18
|
+
/** Same slug rules as library folders / manifest keys. */
|
|
19
|
+
export function slugifySegment(input) {
|
|
20
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
21
|
+
}
|
|
22
|
+
export function parseFolderArg(folder) {
|
|
23
|
+
return folder
|
|
24
|
+
.replace(/\\/g, '/')
|
|
25
|
+
.split('/')
|
|
26
|
+
.map((s) => slugifySegment(s.trim()))
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* True when `key` (`<folders…>/<doc>/<artboard>`) lives inside `segments`.
|
|
31
|
+
* Only the folder part of the key counts, so resyncing `enemies` never touches
|
|
32
|
+
* a root document that happens to be named "enemies".
|
|
33
|
+
*/
|
|
34
|
+
export function keyInFolder(key, segments) {
|
|
35
|
+
const parts = key.split('/');
|
|
36
|
+
const folderParts = parts.slice(0, Math.max(0, parts.length - 2));
|
|
37
|
+
if (folderParts.length < segments.length)
|
|
38
|
+
return false;
|
|
39
|
+
return segments.every((s, i) => folderParts[i] === s);
|
|
40
|
+
}
|
|
41
|
+
async function confirm(prompt) {
|
|
42
|
+
if (!stdin.isTTY)
|
|
43
|
+
return false;
|
|
44
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
45
|
+
try {
|
|
46
|
+
const a = (await rl.question(prompt)).trim().toLowerCase();
|
|
47
|
+
return a === 'y' || a === 'yes';
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
rl.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function resyncCommand(folder, opts = {}) {
|
|
54
|
+
await runResync(parseFolderArg(folder), opts);
|
|
55
|
+
}
|
|
56
|
+
export async function runResync(segments, opts = {}) {
|
|
57
|
+
if (segments.length === 0) {
|
|
58
|
+
throw new Error('Pass a folder, e.g. `magicpixel resync Sprites/Enemies`. Resyncing the whole project is `push --force`.');
|
|
59
|
+
}
|
|
60
|
+
const log = (m) => { if (!opts.quiet)
|
|
61
|
+
console.log(m); };
|
|
62
|
+
const config = await ensureEngineConnect(await loadConfig(), process.cwd(), !opts.dryRun);
|
|
63
|
+
const kind = await detectProjectKind();
|
|
64
|
+
if (!isEngineKind(kind))
|
|
65
|
+
throw new Error('This folder is not a Unity/Godot/GameMaker project.');
|
|
66
|
+
const gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, {
|
|
67
|
+
onProgress: opts.quiet ? undefined : (p) => { stdout.write(`\r${countingSpritesText(p)}`); },
|
|
68
|
+
});
|
|
69
|
+
if (!opts.quiet)
|
|
70
|
+
stdout.write('\n');
|
|
71
|
+
const keyFilter = (key) => keyInFolder(key, segments);
|
|
72
|
+
const label = segments.join('/');
|
|
73
|
+
// Plan first: what would change, and what would go to Trash.
|
|
74
|
+
const plan = await runPushWith(config, await loadState(), {
|
|
75
|
+
dryRun: true, replace: true, keyFilter, gameIndex, quiet: true,
|
|
76
|
+
});
|
|
77
|
+
if (plan.scanned === 0) {
|
|
78
|
+
throw new Error(`No local sprites found in "${label}". Nothing was changed. Check the folder name with \`magicpixel search\`.`);
|
|
79
|
+
}
|
|
80
|
+
const pruneAllowed = !gameIndex.capped;
|
|
81
|
+
const preview = pruneAllowed && plan.keptAssetIds.length > 0
|
|
82
|
+
? await pruneResyncFolder(segments, plan.keptAssetIds, true)
|
|
83
|
+
: null;
|
|
84
|
+
log(kleur.bold(`Resync ${label} from your game`));
|
|
85
|
+
log(` local sprites: ${plan.scanned}`);
|
|
86
|
+
if (preview) {
|
|
87
|
+
log(` to trash in MagicPixel (no local file): ${preview.wouldTrash.length}`);
|
|
88
|
+
for (const n of preview.wouldTrash.slice(0, 10))
|
|
89
|
+
log(kleur.dim(` - ${n}`));
|
|
90
|
+
if (preview.wouldTrash.length > 10)
|
|
91
|
+
log(kleur.dim(` …and ${preview.wouldTrash.length - 10} more`));
|
|
92
|
+
}
|
|
93
|
+
if (!pruneAllowed)
|
|
94
|
+
log(kleur.yellow(' ! game scan hit its file cap — nothing will be trashed.'));
|
|
95
|
+
log(kleur.dim(' Local files win: MagicPixel copies are overwritten and resized to match.'));
|
|
96
|
+
if (opts.dryRun) {
|
|
97
|
+
log(kleur.dim('--dry-run: nothing sent.'));
|
|
98
|
+
return { push: plan, trashed: 0, pruneSkipped: 'dry-run' };
|
|
99
|
+
}
|
|
100
|
+
if (!opts.yes && !(await confirm('Continue? (y/N) '))) {
|
|
101
|
+
throw new Error('Cancelled. Re-run with --yes to skip this question.');
|
|
102
|
+
}
|
|
103
|
+
const push = await runPushWith(config, await loadState(), {
|
|
104
|
+
replace: true, keyFilter, gameIndex, quiet: opts.quiet,
|
|
105
|
+
});
|
|
106
|
+
let pruneSkipped = null;
|
|
107
|
+
let trashed = 0;
|
|
108
|
+
if (!pruneAllowed)
|
|
109
|
+
pruneSkipped = 'game scan capped';
|
|
110
|
+
else if (push.error > 0 || push.conflict > 0)
|
|
111
|
+
pruneSkipped = 'some sprites failed to upload';
|
|
112
|
+
else if (push.keptAssetIds.length === 0)
|
|
113
|
+
pruneSkipped = 'no files confirmed';
|
|
114
|
+
else {
|
|
115
|
+
const res = await pruneResyncFolder(segments, push.keptAssetIds, false);
|
|
116
|
+
trashed = res.trashed;
|
|
117
|
+
}
|
|
118
|
+
if (pruneSkipped)
|
|
119
|
+
log(kleur.yellow(`! Nothing moved to Trash (${pruneSkipped}). Re-run once fixed.`));
|
|
120
|
+
else
|
|
121
|
+
log(kleur.green(`✓ ${label} resynced. ${trashed} file${trashed === 1 ? '' : 's'} moved to Trash.`));
|
|
122
|
+
return { push, trashed, pruneSkipped };
|
|
123
|
+
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -28,6 +28,46 @@ import { canSkipWithoutHashing, decidePull, hasNewExplicitRelease } from '../uti
|
|
|
28
28
|
import { hasUnpushedLocalEdit } from '../util/localEdit.js';
|
|
29
29
|
import { shouldReconcile } from '../util/reconcile.js';
|
|
30
30
|
import { runPush } from './push.js';
|
|
31
|
+
import { claimResyncRequest, reportResyncRequest } from '../api.js';
|
|
32
|
+
import { runResync } from './resync.js';
|
|
33
|
+
/** How often the watcher asks for a library "Resync from game" request. */
|
|
34
|
+
const RESYNC_POLL_MS = 15_000;
|
|
35
|
+
let lastResyncPollAt = 0;
|
|
36
|
+
/**
|
|
37
|
+
* Run one queued library "Resync from game…" request, if any. Never throws:
|
|
38
|
+
* a failed resync is reported back to MagicPixel and the watcher keeps going.
|
|
39
|
+
*/
|
|
40
|
+
async function maybeRunQueuedResync(quiet) {
|
|
41
|
+
if (Date.now() - lastResyncPollAt < RESYNC_POLL_MS)
|
|
42
|
+
return;
|
|
43
|
+
lastResyncPollAt = Date.now();
|
|
44
|
+
let req = null;
|
|
45
|
+
try {
|
|
46
|
+
req = await claimResyncRequest();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!req)
|
|
52
|
+
return;
|
|
53
|
+
if (!quiet) {
|
|
54
|
+
process.stdout.write('\x1b[2K\r');
|
|
55
|
+
console.log(kleur.bold(`↻ Resyncing "${req.folderName}" from your game (requested in MagicPixel)…`));
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const out = await runResync(req.folderPath, { yes: true, quiet });
|
|
59
|
+
const p = out.push;
|
|
60
|
+
await reportResyncRequest(req.id, 'done', out.pruneSkipped ? `Nothing trashed: ${out.pruneSkipped}` : null, {
|
|
61
|
+
created: p.created, updated: p.updated, unchanged: p.unchanged,
|
|
62
|
+
conflict: p.conflict, error: p.error, trashed: out.trashed,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
await reportResyncRequest(req.id, 'failed', e.message).catch(() => { });
|
|
67
|
+
if (!quiet)
|
|
68
|
+
console.log(kleur.red(`! Resync failed: ${e.message}`));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
31
71
|
/** Did the push half of a run actually move bytes into MagicPixel? */
|
|
32
72
|
export function pushWasActive(pushed) {
|
|
33
73
|
if (!pushed)
|
|
@@ -222,6 +262,7 @@ async function watchLoop(opts) {
|
|
|
222
262
|
pausedForNetwork = false;
|
|
223
263
|
pausedForQuota = false;
|
|
224
264
|
consecutiveAuthFailures = 0;
|
|
265
|
+
await maybeRunQueuedResync(opts.quiet === true);
|
|
225
266
|
if (wasPausedForAuth && !opts.quiet) {
|
|
226
267
|
process.stdout.write('\x1b[2K\r');
|
|
227
268
|
console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} Key accepted again — resuming.`);
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { doctorCommand } from './commands/doctor.js';
|
|
|
15
15
|
import { repairCommand } from './commands/repair.js';
|
|
16
16
|
import { startCommand } from './commands/start.js';
|
|
17
17
|
import { connectCommand } from './commands/connect.js';
|
|
18
|
+
import { resyncCommand } from './commands/resync.js';
|
|
18
19
|
import { searchCommand } from './commands/search.js';
|
|
19
20
|
import { parseWatchInterval, parseConcurrency } from './util/flagValidators.js';
|
|
20
21
|
import { CLI_VERSION } from './version.js';
|
|
@@ -100,6 +101,13 @@ program
|
|
|
100
101
|
.option('--force', 'Keep your local copy when a sprite also changed in MagicPixel')
|
|
101
102
|
.addHelpText('after', '\nExamples:\n $ magicpixel push --dry-run # see what changed on disk\n $ magicpixel push # send local edits + new sprites\n $ magicpixel push --force # your local copy wins on both-sides-changed\n')
|
|
102
103
|
.action(wrap("push", async (opts) => pushCommand(opts)));
|
|
104
|
+
program
|
|
105
|
+
.command('resync <folder>')
|
|
106
|
+
.description('Make one MagicPixel folder match your local game folder (local wins, missing files go to Trash)')
|
|
107
|
+
.option('-y, --yes', 'Skip the confirmation question')
|
|
108
|
+
.option('--dry-run', 'Show what would change without sending anything')
|
|
109
|
+
.addHelpText('after', '\nExamples:\n $ magicpixel resync Sprites/Enemies --dry-run\n $ magicpixel resync Sprites/Enemies\n')
|
|
110
|
+
.action(wrap("resync", async (folder, opts) => resyncCommand(folder, opts)));
|
|
103
111
|
program
|
|
104
112
|
.command('connect <glob>')
|
|
105
113
|
.description('Limit which game folders sync (default is all sprites)')
|
package/dist/util/pushPlan.js
CHANGED
|
@@ -120,11 +120,13 @@ export function planPush(candidates, synced, opts = {}) {
|
|
|
120
120
|
if (known) {
|
|
121
121
|
const connected = Boolean(c.sourceRel || opts.folderTreeKeys?.has(c.key) || (stateKey != null && opts.folderTreeKeys?.has(stateKey)));
|
|
122
122
|
const lastDisk = lastPushedDiskSha(known, { connected });
|
|
123
|
-
|
|
123
|
+
// Resync (replace) sends every in-scope sprite: local wins even when
|
|
124
|
+
// only the MagicPixel copy changed. The server reports true no-ops.
|
|
125
|
+
if (lastDisk === c.diskSha256 && !opts.replace) {
|
|
124
126
|
out.push({ kind: 'skip', key: c.key, reason: 'unchanged' });
|
|
125
127
|
continue;
|
|
126
128
|
}
|
|
127
|
-
if (lastDisk === undefined) {
|
|
129
|
+
if (lastDisk === undefined && !opts.replace) {
|
|
128
130
|
// Connected original with no disk baseline yet. Ingest's composite
|
|
129
131
|
// sha is not the game file — seeding happens in `applyDiskFingerprints`.
|
|
130
132
|
out.push({ kind: 'skip', key: c.key, reason: 'unchanged' });
|