@magicpixelart/cli 0.5.6 → 0.5.8
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 +42 -0
- package/README.md +10 -6
- package/dist/api.js +33 -2
- package/dist/commands/doctor.js +8 -7
- package/dist/commands/init.js +4 -3
- package/dist/commands/push.js +207 -0
- package/dist/commands/repair.js +4 -3
- package/dist/commands/start.js +12 -16
- package/dist/commands/status.js +3 -2
- package/dist/commands/sync.js +151 -4
- package/dist/config.js +19 -4
- package/dist/index.js +8 -0
- package/dist/util/emitIndex.js +3 -2
- package/dist/util/gameScan.js +119 -0
- package/dist/util/invoke.js +47 -0
- package/dist/util/prunePolicy.js +33 -0
- package/dist/util/pushPlan.js +80 -0
- package/dist/util/unityFilter.js +25 -0
- package/dist/util/unityMeta.js +162 -0
- package/package.json +7 -2
package/dist/commands/sync.js
CHANGED
|
@@ -11,11 +11,17 @@ import { assetDiskPath, assetDiskPathFromKey, pruneEmptyDirs, walkOutDirPngs } f
|
|
|
11
11
|
import { createLimit } from '../util/limit.js';
|
|
12
12
|
import { emitTypedIndex, ensureAgentsDoc, scanDiskAssets } from '../util/emitIndex.js';
|
|
13
13
|
import { assertPathInsideRoot } from '../util/security.js';
|
|
14
|
+
import { detectProjectKind } from '../util/framework.js';
|
|
15
|
+
import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
|
|
16
|
+
import { filterUnityManifest } from '../util/unityFilter.js';
|
|
17
|
+
import { selectFullSyncOrphans } from '../util/prunePolicy.js';
|
|
14
18
|
import { runTmpJanitor } from '../util/tmpJanitor.js';
|
|
15
19
|
import { friendlyFsError } from '../util/errors.js';
|
|
16
20
|
import { maxIsoTimestamp } from '../util/iso.js';
|
|
17
21
|
import { formatBytes } from '../util/format.js';
|
|
18
22
|
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
23
|
+
import { cmd } from '../util/invoke.js';
|
|
24
|
+
import { runPush } from './push.js';
|
|
19
25
|
export async function syncCommand(opts) {
|
|
20
26
|
// Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
|
|
21
27
|
// crashed/killed CLI run before they pile up. Runs once per CLI invocation
|
|
@@ -180,14 +186,14 @@ async function watchLoop(opts) {
|
|
|
180
186
|
// support thread and we can correlate against edge function logs.
|
|
181
187
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
182
188
|
console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Your key looks invalid or rotated.${idSuffix}`);
|
|
183
|
-
console.log(kleur.dim(
|
|
189
|
+
console.log(kleur.dim(` Fix: run \`${cmd('login')}\` (this watcher will keep retrying every 30s).`));
|
|
184
190
|
}
|
|
185
191
|
pausedForAuth = true;
|
|
186
192
|
consecutiveAuthFailures = decision.consecutiveAuthFailures;
|
|
187
193
|
backoffSec = decision.nextBackoffSec;
|
|
188
194
|
if (decision.giveUp) {
|
|
189
195
|
console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Giving up after ${MAX_AUTH_FAILURES} consecutive auth failures.`);
|
|
190
|
-
console.log(kleur.dim(
|
|
196
|
+
console.log(kleur.dim(` Fix: run \`${cmd('login')}\` with a fresh key, then restart the watcher.`));
|
|
191
197
|
// Surface this to /admin/errors — persistent watcher auth failure
|
|
192
198
|
// means a key is mass-rejected (revoked, project deleted, edge
|
|
193
199
|
// misconfig) and we want visibility without waiting for a support
|
|
@@ -337,6 +343,64 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
337
343
|
spinner?.fail('Manifest fetch failed');
|
|
338
344
|
throw e;
|
|
339
345
|
}
|
|
346
|
+
// Unity projects sync only the artboards flagged "Sync to Unity" in the
|
|
347
|
+
// editor (parity with the in-app sync button). `unitySyncAll: true` in
|
|
348
|
+
// magicpixel.json opts back into everything.
|
|
349
|
+
const projectKind = await detectProjectKind();
|
|
350
|
+
// Files whose artboard is no longer flagged for Unity. Deleted even in
|
|
351
|
+
// incremental mode: un-checking the box in the editor must actually remove
|
|
352
|
+
// the sprite from the game project, not leave a stale copy behind.
|
|
353
|
+
const deselectedPaths = [];
|
|
354
|
+
// Sprites whose "Sync to Unity" flag the server omitted. Not downloaded
|
|
355
|
+
// (strict opt-in) but explicitly shielded from pruning: a transient server
|
|
356
|
+
// response must never delete art from someone's game project.
|
|
357
|
+
const unknownFlagPaths = new Set();
|
|
358
|
+
if (projectKind === 'Unity') {
|
|
359
|
+
const filtered = filterUnityManifest(manifest, { syncAll: config.unitySyncAll });
|
|
360
|
+
if (filtered.unknown.length > 0) {
|
|
361
|
+
console.log(kleur.yellow(`! ${filtered.unknown.length} artboard${filtered.unknown.length === 1 ? '' : 's'} came back without a "Sync to Unity" flag — skipped (existing files kept).`));
|
|
362
|
+
console.log(kleur.dim(` Fix: re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
|
|
363
|
+
}
|
|
364
|
+
const unknownSet = new Set(filtered.unknown);
|
|
365
|
+
for (const entry of manifest) {
|
|
366
|
+
if (filtered.entries.includes(entry))
|
|
367
|
+
continue;
|
|
368
|
+
const p = assetDiskPath(config.outDir, entry);
|
|
369
|
+
if (unknownSet.has(entry)) {
|
|
370
|
+
unknownFlagPaths.add(p);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (existsSync(p))
|
|
374
|
+
deselectedPaths.push(p);
|
|
375
|
+
}
|
|
376
|
+
if (filtered.noneFlagged) {
|
|
377
|
+
// Nothing flagged for pull — don't dump the library, and don't prune.
|
|
378
|
+
// Local sprites still go up so they appear in MagicPixel on connect.
|
|
379
|
+
if (verbose) {
|
|
380
|
+
console.log();
|
|
381
|
+
console.log(kleur.yellow('! No artboards are flagged "Sync to Unity" — nothing to pull.'));
|
|
382
|
+
console.log(kleur.dim(' Existing game sprites will still be imported into MagicPixel.'));
|
|
383
|
+
console.log(kleur.dim(' Flag folders/artboards in the library to pull them back into Unity.'));
|
|
384
|
+
}
|
|
385
|
+
const empty = {
|
|
386
|
+
added: [],
|
|
387
|
+
modified: [],
|
|
388
|
+
removed: [],
|
|
389
|
+
unchanged: 0,
|
|
390
|
+
failed: 0,
|
|
391
|
+
bytesIn: 0,
|
|
392
|
+
bytesSaved: 0,
|
|
393
|
+
renamed: [],
|
|
394
|
+
};
|
|
395
|
+
await maybePushLocalSprites(config.push, verbose);
|
|
396
|
+
return empty;
|
|
397
|
+
}
|
|
398
|
+
const skipped = manifest.length - filtered.entries.length;
|
|
399
|
+
manifest = filtered.entries;
|
|
400
|
+
if (verbose && skipped > 0) {
|
|
401
|
+
console.log(kleur.dim(` unity → ${skipped} artboard${skipped === 1 ? '' : 's'} not flagged for sync (skipped)`));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
340
404
|
// Detect renames: same id, different key vs prior snapshot.
|
|
341
405
|
const renamed = [];
|
|
342
406
|
for (const entry of manifest) {
|
|
@@ -385,8 +449,27 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
385
449
|
// still gated on `!since` to preserve incremental-mode safety (we never
|
|
386
450
|
// delete files outside what the current manifest delta touches).
|
|
387
451
|
const localPngs = await walkOutDirPngs(config.outDir);
|
|
452
|
+
// Sprites created in Unity (or any PNG we never pulled) are pending a
|
|
453
|
+
// push into MagicPixel, not orphans — see prunePolicy.ts.
|
|
388
454
|
if (!since) {
|
|
389
|
-
|
|
455
|
+
const trackedPaths = new Set(Object.keys(state.synced ?? {})
|
|
456
|
+
.concat(Object.values(previousAssets))
|
|
457
|
+
.map((key) => {
|
|
458
|
+
try {
|
|
459
|
+
return assetDiskPathFromKey(config.outDir, key);
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return '';
|
|
463
|
+
}
|
|
464
|
+
})
|
|
465
|
+
.filter(Boolean));
|
|
466
|
+
const policy = selectFullSyncOrphans({
|
|
467
|
+
localPaths: localPngs.map((a) => a.abs),
|
|
468
|
+
remoteDiskPaths,
|
|
469
|
+
protectedPaths: unknownFlagPaths,
|
|
470
|
+
isTracked: (p) => trackedPaths.has(p),
|
|
471
|
+
});
|
|
472
|
+
orphans = policy.orphans;
|
|
390
473
|
}
|
|
391
474
|
// Heuristic-rename fallback for incremental mode.
|
|
392
475
|
//
|
|
@@ -440,6 +523,10 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
440
523
|
const orphanSet = new Set(orphans);
|
|
441
524
|
for (const p of renameStalePaths)
|
|
442
525
|
orphanSet.add(p);
|
|
526
|
+
// Un-checked "Sync to Unity" artboards: their PNG (and .meta) leaves the
|
|
527
|
+
// Unity project on the next sync, incremental or not.
|
|
528
|
+
for (const p of deselectedPaths)
|
|
529
|
+
orphanSet.add(p);
|
|
443
530
|
// Server-side rename history. Each manifest entry carries the composite
|
|
444
531
|
// keys this row was previously emitted under (populated by the editor when
|
|
445
532
|
// a doc/artboard is renamed). For each prior key whose file is still on
|
|
@@ -638,6 +725,19 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
638
725
|
else
|
|
639
726
|
progress.warn(`Downloaded ${downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
|
|
640
727
|
}
|
|
728
|
+
// Unity: write pixel-art-correct `.meta` sidecars so sprites import with
|
|
729
|
+
// point filtering / no compression instead of Unity's blurry defaults —
|
|
730
|
+
// parity with the in-app "Sync to Unity" button. Only missing sidecars are
|
|
731
|
+
// written, so importer settings the user tweaked in Unity are preserved.
|
|
732
|
+
if (projectKind === 'Unity') {
|
|
733
|
+
const meta = await writeMissingUnityMetas(manifest.map((entry) => ({ id: entry.id, pngPath: pathFor(entry) })), config.unityPpu ?? DEFAULT_UNITY_PPU);
|
|
734
|
+
if (verbose && meta.written > 0) {
|
|
735
|
+
console.log(kleur.dim(` unity → ${meta.written} .meta file${meta.written === 1 ? '' : 's'} written`));
|
|
736
|
+
}
|
|
737
|
+
if (verbose && meta.failed > 0) {
|
|
738
|
+
console.log(kleur.yellow(` ! ${meta.failed} .meta file${meta.failed === 1 ? '' : 's'} could not be written — Unity will use default import settings.`));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
641
741
|
// Prune (now default — pass --no-prune to opt out).
|
|
642
742
|
if (shouldPrune && orphans.length > 0) {
|
|
643
743
|
const outRoot = resolve(process.cwd(), config.outDir);
|
|
@@ -645,6 +745,9 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
645
745
|
assertPathInsideRoot(p, outRoot, 'outDir');
|
|
646
746
|
try {
|
|
647
747
|
await unlink(p);
|
|
748
|
+
// Unity sidecar follows its PNG — a stranded .meta shows up in Unity
|
|
749
|
+
// as a missing-asset warning.
|
|
750
|
+
await unlink(`${p}.meta`).catch(() => { });
|
|
648
751
|
const relPath = relative(outRoot, p).replace(/\\/g, '/');
|
|
649
752
|
const key = relPath.endsWith('.png') ? relPath.slice(0, -4) : relPath;
|
|
650
753
|
result.removed.push(key);
|
|
@@ -708,7 +811,36 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
708
811
|
delete nextAssets[id];
|
|
709
812
|
}
|
|
710
813
|
}
|
|
711
|
-
|
|
814
|
+
// Address book for `magicpixel push`: composite key → artboard + the sha we
|
|
815
|
+
// just put on disk. Keyed by key (not id) so a disk walk can look it up
|
|
816
|
+
// without re-fetching the manifest. Renamed/pruned keys are dropped so a
|
|
817
|
+
// stale entry can never send an edit to the wrong artboard.
|
|
818
|
+
const nextSynced = { ...(state.synced ?? {}) };
|
|
819
|
+
for (const r of renamed)
|
|
820
|
+
delete nextSynced[r.oldKey];
|
|
821
|
+
for (const key of result.removed)
|
|
822
|
+
delete nextSynced[key];
|
|
823
|
+
for (const entry of manifest) {
|
|
824
|
+
if (!entry.asset_id)
|
|
825
|
+
continue;
|
|
826
|
+
const sha = entry.sha256 ?? (await fileSha256(pathFor(entry)));
|
|
827
|
+
if (!sha)
|
|
828
|
+
continue;
|
|
829
|
+
// Legacy single-PNG rows have no artboard address (`layer_idx: -1`). Record
|
|
830
|
+
// them anyway, flagged, so `push` skips them with a hint instead of
|
|
831
|
+
// adopting the same art as a second document.
|
|
832
|
+
if (typeof entry.layer_idx !== 'number' || entry.layer_idx < 0) {
|
|
833
|
+
nextSynced[entry.key] = { assetId: entry.asset_id, layerIdx: 0, sha256: sha, legacy: true };
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
nextSynced[entry.key] = {
|
|
837
|
+
assetId: entry.asset_id,
|
|
838
|
+
layerIdx: entry.layer_idx,
|
|
839
|
+
sha256: sha,
|
|
840
|
+
...(typeof entry.layer_count === 'number' ? { layers: entry.layer_count } : {}),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
const nextState = { ...state, assets: nextAssets, synced: nextSynced };
|
|
712
844
|
if (result.failed === 0) {
|
|
713
845
|
// Advance the cursor to the newest row we actually observed, NOT the
|
|
714
846
|
// wall-clock time the sync started. Using startedAt silently skipped
|
|
@@ -786,10 +918,25 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
786
918
|
if (renamed.length > 0)
|
|
787
919
|
printRenames(renamed, { withHints: true });
|
|
788
920
|
}
|
|
921
|
+
await maybePushLocalSprites(config.push, verbose);
|
|
789
922
|
if (result.failed)
|
|
790
923
|
process.exitCode = 1;
|
|
791
924
|
return result;
|
|
792
925
|
}
|
|
926
|
+
async function maybePushLocalSprites(pushEnabled, verbose) {
|
|
927
|
+
if (pushEnabled === false)
|
|
928
|
+
return;
|
|
929
|
+
try {
|
|
930
|
+
await runPush({ quiet: !verbose });
|
|
931
|
+
}
|
|
932
|
+
catch (e) {
|
|
933
|
+
if (verbose) {
|
|
934
|
+
console.log();
|
|
935
|
+
console.log(kleur.yellow(`! could not import local sprites into MagicPixel: ${e.message}`));
|
|
936
|
+
console.log(kleur.dim(` Fix: run \`${cmd('push')}\` once the connection is healthy.`));
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
793
940
|
const CHANGE_PRINT_CAP = 50;
|
|
794
941
|
function printChanges(r, indent, opts = { includeRenames: true }) {
|
|
795
942
|
const lines = [];
|
package/dist/config.js
CHANGED
|
@@ -5,6 +5,7 @@ import { assertSafeOutDir, MAX_GLOB_LEN, validateEndpointUrl } from './util/secu
|
|
|
5
5
|
import { readCredentialsSync } from './util/credentials.js';
|
|
6
6
|
import { friendlyFsError } from './util/errors.js';
|
|
7
7
|
import { atomicWrite } from './util/atomicWrite.js';
|
|
8
|
+
import { cmd } from './util/invoke.js';
|
|
8
9
|
const CONFIG_FILENAME = 'magicpixel.json';
|
|
9
10
|
const STATE_DIR = '.magicpixel';
|
|
10
11
|
const STATE_FILENAME = 'state.json';
|
|
@@ -25,7 +26,7 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
25
26
|
const path = configPath(cwd);
|
|
26
27
|
if (!existsSync(path)) {
|
|
27
28
|
throw new Error(`No ${CONFIG_FILENAME} found in ${cwd}.\n` +
|
|
28
|
-
` Fix: run \`
|
|
29
|
+
` Fix: run \`${cmd('start')}\` — it creates the config, logs you in, and runs the first sync.`);
|
|
29
30
|
}
|
|
30
31
|
const raw = await readFile(path, 'utf8');
|
|
31
32
|
let parsed;
|
|
@@ -66,12 +67,26 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
66
67
|
}
|
|
67
68
|
emitIndex = parsed.emitIndex;
|
|
68
69
|
}
|
|
70
|
+
let unityPpu;
|
|
71
|
+
if (parsed.unityPpu !== undefined) {
|
|
72
|
+
if (typeof parsed.unityPpu !== 'number' ||
|
|
73
|
+
!Number.isFinite(parsed.unityPpu) ||
|
|
74
|
+
parsed.unityPpu <= 0 ||
|
|
75
|
+
parsed.unityPpu > 4096) {
|
|
76
|
+
throw new Error(`${CONFIG_FILENAME}: "unityPpu" must be a number between 1 and 4096.\n` +
|
|
77
|
+
` Fix: set "unityPpu": 32 (or remove the field to use the default).`);
|
|
78
|
+
}
|
|
79
|
+
unityPpu = Math.round(parsed.unityPpu);
|
|
80
|
+
}
|
|
69
81
|
return {
|
|
70
82
|
outDir,
|
|
71
83
|
include,
|
|
72
84
|
exclude,
|
|
73
85
|
endpoint,
|
|
74
86
|
emitIndex,
|
|
87
|
+
unityPpu,
|
|
88
|
+
unitySyncAll: parsed.unitySyncAll === true ? true : undefined,
|
|
89
|
+
push: parsed.push === false ? false : undefined,
|
|
75
90
|
};
|
|
76
91
|
}
|
|
77
92
|
const MAX_GLOBS = 64;
|
|
@@ -112,7 +127,7 @@ export async function saveConfig(config, cwd = process.cwd()) {
|
|
|
112
127
|
throw friendlyFsError(e, {
|
|
113
128
|
operation: 'Saving magicpixel.json',
|
|
114
129
|
path,
|
|
115
|
-
hint:
|
|
130
|
+
hint: `magicpixel.json holds your sync config — without it \`${cmd('sync')}\` can't run.`,
|
|
116
131
|
});
|
|
117
132
|
}
|
|
118
133
|
}
|
|
@@ -196,7 +211,7 @@ export function getApiKey() {
|
|
|
196
211
|
throw new Error('No MagicPixel API key found.\n' +
|
|
197
212
|
' Fix:\n' +
|
|
198
213
|
' 1. Get a key at https://magicpixel.art/settings (API Keys).\n' +
|
|
199
|
-
|
|
214
|
+
` 2. Run \`${cmd('login')}\` (or \`export MAGICPIXEL_API_KEY=mp_live_...\`).\n` +
|
|
200
215
|
' 3. Re-run the command.');
|
|
201
216
|
}
|
|
202
217
|
// Env vars from shells routinely smuggle in stray quotes / whitespace; the
|
|
@@ -209,7 +224,7 @@ export function getApiKey() {
|
|
|
209
224
|
}
|
|
210
225
|
if (!/^mp_(live|test)_[a-f0-9]{64}$/.test(trimmed)) {
|
|
211
226
|
throw new Error(`MagicPixel API key does not look right (expected mp_live_… or mp_test_…).\n` +
|
|
212
|
-
` Fix: run
|
|
227
|
+
` Fix: run \`${cmd('login')}\` and paste a fresh key from https://magicpixel.art/settings.`);
|
|
213
228
|
}
|
|
214
229
|
return trimmed;
|
|
215
230
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Command } from 'commander';
|
|
|
3
3
|
import kleur from 'kleur';
|
|
4
4
|
import { initCommand } from './commands/init.js';
|
|
5
5
|
import { syncCommand } from './commands/sync.js';
|
|
6
|
+
import { pushCommand } from './commands/push.js';
|
|
6
7
|
import { addCommand } from './commands/add.js';
|
|
7
8
|
import { removeCommand } from './commands/remove.js';
|
|
8
9
|
import { listCommand } from './commands/list.js';
|
|
@@ -89,6 +90,13 @@ program
|
|
|
89
90
|
.option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
|
|
90
91
|
.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')
|
|
91
92
|
.action(wrap("sync", async (opts) => syncCommand(opts)));
|
|
93
|
+
program
|
|
94
|
+
.command('push')
|
|
95
|
+
.description('Upload local sprite edits back to MagicPixel (two-way sync)')
|
|
96
|
+
.option('--dry-run', 'Print what would be pushed without sending anything')
|
|
97
|
+
.option('--flatten', 'Allow replacing multi-layer artboards with the flat local image')
|
|
98
|
+
.addHelpText('after', '\nExamples:\n $ magicpixel push --dry-run # see what changed on disk\n $ magicpixel push # send local edits + new sprites\n')
|
|
99
|
+
.action(wrap("push", async (opts) => pushCommand(opts)));
|
|
92
100
|
program
|
|
93
101
|
.command('add <glob>')
|
|
94
102
|
.description('Append a glob pattern to include')
|
package/dist/util/emitIndex.js
CHANGED
|
@@ -5,6 +5,7 @@ import { assertSafeAssetSegments } from './security.js';
|
|
|
5
5
|
import { atomicWrite } from './atomicWrite.js';
|
|
6
6
|
import { walkOutDirPngs } from './paths.js';
|
|
7
7
|
import { isStaticOutDir } from './framework.js';
|
|
8
|
+
import { cmd } from './invoke.js';
|
|
8
9
|
const AGENT_HEADER = [
|
|
9
10
|
'// 👋 AI agents: import sprites by key, e.g.',
|
|
10
11
|
'// import { MagicPixelAssets } from "<this-dir>";',
|
|
@@ -57,7 +58,7 @@ export async function emitTypedIndex(outDir, entries, cwd = process.cwd()) {
|
|
|
57
58
|
}
|
|
58
59
|
const lines = [
|
|
59
60
|
'// AUTOGENERATED by @magicpixelart/cli. Do not edit.',
|
|
60
|
-
|
|
61
|
+
`// Re-runs of \`${cmd('sync')}\` overwrite this file.`,
|
|
61
62
|
'//',
|
|
62
63
|
AGENT_HEADER,
|
|
63
64
|
'',
|
|
@@ -185,7 +186,7 @@ function renderAgentsSection(outDir) {
|
|
|
185
186
|
`<img src={MagicPixelAssetsById["<asset-uuid>"]} />`,
|
|
186
187
|
'```',
|
|
187
188
|
'',
|
|
188
|
-
|
|
189
|
+
`When you rename or move an asset, \`${cmd('sync')}\` prints both the old → new key`,
|
|
189
190
|
'and the matching `MagicPixelAssetsById[...]` hint so you can pick whichever you prefer.',
|
|
190
191
|
];
|
|
191
192
|
return [
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-connect import: when `outDir` has no PNGs yet, copy nearby game
|
|
3
|
+
* sprites into it so `push` can adopt them into the MagicPixel library.
|
|
4
|
+
*
|
|
5
|
+
* Only engine projects. Never walks Library/Temp/Packages. Never overwrites
|
|
6
|
+
* a file already in outDir. Capped so a huge Assets/ tree can't stampede
|
|
7
|
+
* the library.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { copyFile, mkdir, readdir } from 'node:fs/promises';
|
|
11
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
12
|
+
import { isEngineKind } from './framework.js';
|
|
13
|
+
import { assertPathInsideRoot } from './security.js';
|
|
14
|
+
/** Unity/engine folders that are never sprite sources. */
|
|
15
|
+
export const GAME_SCAN_SKIP_DIRS = new Set([
|
|
16
|
+
'library',
|
|
17
|
+
'temp',
|
|
18
|
+
'obj',
|
|
19
|
+
'logs',
|
|
20
|
+
'packages',
|
|
21
|
+
'projectsettings',
|
|
22
|
+
'usersettings',
|
|
23
|
+
'streamingassets',
|
|
24
|
+
]);
|
|
25
|
+
export const MAX_GAME_IMPORT = 200;
|
|
26
|
+
export function gameScanRoot(kind) {
|
|
27
|
+
switch (kind) {
|
|
28
|
+
case 'Unity':
|
|
29
|
+
return 'Assets';
|
|
30
|
+
case 'Godot':
|
|
31
|
+
return 'assets';
|
|
32
|
+
case 'GameMaker':
|
|
33
|
+
return 'datafiles';
|
|
34
|
+
default:
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Copy PNGs from the engine asset root into `outDir`, preserving relative
|
|
40
|
+
* paths. No-op when `outDir` already has PNGs, when the project isn't an
|
|
41
|
+
* engine, or when the scan root is missing.
|
|
42
|
+
*/
|
|
43
|
+
export async function importNearbyGamePngs(outDir, kind, cwd = process.cwd()) {
|
|
44
|
+
const result = { copied: 0, skipped: 0, capped: false };
|
|
45
|
+
if (!isEngineKind(kind))
|
|
46
|
+
return result;
|
|
47
|
+
const scanRel = gameScanRoot(kind);
|
|
48
|
+
if (!scanRel)
|
|
49
|
+
return result;
|
|
50
|
+
const scanRoot = resolve(cwd, scanRel);
|
|
51
|
+
const destRoot = resolve(cwd, outDir);
|
|
52
|
+
if (!existsSync(scanRoot))
|
|
53
|
+
return result;
|
|
54
|
+
const destNorm = destRoot.replace(/\\/g, '/').toLowerCase();
|
|
55
|
+
const found = [];
|
|
56
|
+
await walkPngs(scanRoot, scanRoot, destNorm, found);
|
|
57
|
+
if (found.length >= MAX_GAME_IMPORT)
|
|
58
|
+
result.capped = true;
|
|
59
|
+
if (found.length > MAX_GAME_IMPORT)
|
|
60
|
+
found.length = MAX_GAME_IMPORT;
|
|
61
|
+
for (const abs of found) {
|
|
62
|
+
const rel = relative(scanRoot, abs).replace(/\\/g, '/');
|
|
63
|
+
const dest = resolve(destRoot, rel);
|
|
64
|
+
try {
|
|
65
|
+
assertPathInsideRoot(dest, destRoot, 'outDir');
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
result.skipped++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (existsSync(dest)) {
|
|
72
|
+
result.skipped++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
76
|
+
await copyFile(abs, dest);
|
|
77
|
+
result.copied++;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
async function walkPngs(dir, root, destNorm, out) {
|
|
82
|
+
if (out.length >= MAX_GAME_IMPORT)
|
|
83
|
+
return;
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const ent of entries) {
|
|
92
|
+
if (out.length >= MAX_GAME_IMPORT)
|
|
93
|
+
return;
|
|
94
|
+
if (ent.isSymbolicLink())
|
|
95
|
+
continue;
|
|
96
|
+
const full = resolve(dir, ent.name);
|
|
97
|
+
try {
|
|
98
|
+
assertPathInsideRoot(full, root, 'scan');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (ent.isDirectory()) {
|
|
104
|
+
const name = ent.name.toLowerCase();
|
|
105
|
+
if (name.startsWith('.'))
|
|
106
|
+
continue;
|
|
107
|
+
if (GAME_SCAN_SKIP_DIRS.has(name))
|
|
108
|
+
continue;
|
|
109
|
+
// Don't re-import from outDir if it sits inside the scan root
|
|
110
|
+
// (Assets/MagicPixel under Assets/).
|
|
111
|
+
if (full.replace(/\\/g, '/').toLowerCase() === destNorm)
|
|
112
|
+
continue;
|
|
113
|
+
await walkPngs(full, root, destNorm, out);
|
|
114
|
+
}
|
|
115
|
+
else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
|
|
116
|
+
out.push(full);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How should we tell the user to run a subcommand?
|
|
3
|
+
*
|
|
4
|
+
* `magicpixel` is only the BIN name; the published package is
|
|
5
|
+
* `@magicpixelart/cli`. So npx-ing the bin name directly (e.g. `magicpixel init` via npx) 404s in a
|
|
6
|
+
* project with no local install ("'magicpixel@*' is not in this registry"), which is a dead end
|
|
7
|
+
* for anyone driving the CLI purely through `npx @magicpixelart/cli …`.
|
|
8
|
+
*
|
|
9
|
+
* Rule: if the package resolves from the project's node_modules, the bin is on
|
|
10
|
+
* the PATH for `npx`/scripts and the short `magicpixel <cmd>` form works.
|
|
11
|
+
* Otherwise we print the always-correct `npx @magicpixelart/cli <cmd>` form.
|
|
12
|
+
* When in doubt we prefer the long form — it works in both worlds.
|
|
13
|
+
*/
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
export const PKG_NAME = '@magicpixelart/cli';
|
|
17
|
+
let cachedLocal = null;
|
|
18
|
+
/** True when `@magicpixelart/cli` is installed in the current project. */
|
|
19
|
+
export function hasLocalInstall(cwd = process.cwd()) {
|
|
20
|
+
if (cachedLocal !== null)
|
|
21
|
+
return cachedLocal;
|
|
22
|
+
let local = false;
|
|
23
|
+
try {
|
|
24
|
+
// Resolve as if requiring from <cwd>/index.js so only the project's
|
|
25
|
+
// node_modules chain counts — not the npx cache this process runs from.
|
|
26
|
+
const req = createRequire(resolve(cwd, 'index.js'));
|
|
27
|
+
req.resolve(`${PKG_NAME}/package.json`);
|
|
28
|
+
local = true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
local = false;
|
|
32
|
+
}
|
|
33
|
+
cachedLocal = local;
|
|
34
|
+
return local;
|
|
35
|
+
}
|
|
36
|
+
/** Reset the memoized probe (tests only). */
|
|
37
|
+
export function resetInvokeCache() {
|
|
38
|
+
cachedLocal = null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Format a runnable command hint, e.g. `cmd('login')` →
|
|
42
|
+
* "npx @magicpixelart/cli login" or "magicpixel login".
|
|
43
|
+
*/
|
|
44
|
+
export function cmd(subcommand, cwd) {
|
|
45
|
+
const suffix = subcommand ? ` ${subcommand}` : '';
|
|
46
|
+
return hasLocalInstall(cwd) ? `magicpixel${suffix}` : `npx ${PKG_NAME}${suffix}`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which local PNGs a full sync is allowed to delete.
|
|
3
|
+
*
|
|
4
|
+
* Pruning used to be "anything on disk the manifest didn't mention". Two-way
|
|
5
|
+
* Unity sync breaks that assumption in two directions:
|
|
6
|
+
*
|
|
7
|
+
* - Strict Unity opt-in excludes entries whose `unity` flag the server
|
|
8
|
+
* omitted (older edge deploy, work-budget-starved manifest backfill). Those
|
|
9
|
+
* are "unknown", not "unwanted" — deleting their sprite because of a
|
|
10
|
+
* transient server response would be data loss inside someone's game
|
|
11
|
+
* project. They're protected instead.
|
|
12
|
+
* - Sprites authored in Unity have no manifest entry until `magicpixel push`
|
|
13
|
+
* adopts them. They're pending, not orphaned.
|
|
14
|
+
*
|
|
15
|
+
* Everything we HAVE synced before (it's in `state.synced`) and that the
|
|
16
|
+
* manifest no longer references is a genuine orphan and still prunes: renames,
|
|
17
|
+
* deletions, and un-checked "Sync to Unity" boxes all keep working.
|
|
18
|
+
*/
|
|
19
|
+
export function selectFullSyncOrphans({ localPaths, remoteDiskPaths, protectedPaths, isTracked, }) {
|
|
20
|
+
const orphans = [];
|
|
21
|
+
const pendingPush = [];
|
|
22
|
+
for (const p of localPaths) {
|
|
23
|
+
if (remoteDiskPaths.has(p))
|
|
24
|
+
continue;
|
|
25
|
+
if (protectedPaths?.has(p))
|
|
26
|
+
continue;
|
|
27
|
+
if (isTracked(p))
|
|
28
|
+
orphans.push(p);
|
|
29
|
+
else
|
|
30
|
+
pendingPush.push(p);
|
|
31
|
+
}
|
|
32
|
+
return { orphans, pendingPush };
|
|
33
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan a disk → MagicPixel push.
|
|
3
|
+
*
|
|
4
|
+
* Pure so the decision table (skip / update / adopt / needs-flatten) is unit
|
|
5
|
+
* testable without touching the network or the filesystem.
|
|
6
|
+
*
|
|
7
|
+
* Two shapes reach the server:
|
|
8
|
+
* - UPDATE: the key is in the sync state, so we know the exact artboard and
|
|
9
|
+
* the composite sha we last wrote. A changed disk sha means a local edit.
|
|
10
|
+
* - ADOPT: the key is new on disk (a sprite someone created in Unity), so
|
|
11
|
+
* the server mirrors the folders into the library and appends/creates the
|
|
12
|
+
* document.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Slug rule mirrored from the server (`slugify` + `SEGMENT_RE` in
|
|
16
|
+
* `src/lib/integration/artboardComposite.server.ts`). The server is the
|
|
17
|
+
* authority; this copy only exists so the CLI can pre-validate a path before
|
|
18
|
+
* spending a request, and `pushPlan.test.ts` pins the two together.
|
|
19
|
+
*/
|
|
20
|
+
export function pushSlug(raw) {
|
|
21
|
+
return raw
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
24
|
+
.replace(/^-+|-+$/g, '')
|
|
25
|
+
.slice(0, 64)
|
|
26
|
+
.replace(/-+$/g, '');
|
|
27
|
+
}
|
|
28
|
+
export const MAX_PUSH_PATH_SEGMENTS = 8;
|
|
29
|
+
export function planPush(candidates, synced, opts = {}) {
|
|
30
|
+
const state = synced ?? {};
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const c of candidates) {
|
|
33
|
+
const known = state[c.key];
|
|
34
|
+
if (known) {
|
|
35
|
+
if (known.sha256 === c.diskSha256) {
|
|
36
|
+
out.push({ kind: 'skip', key: c.key, reason: 'unchanged' });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Legacy row: the cloud has no addressable artboard, and adopting would
|
|
40
|
+
// duplicate art that already exists in the library.
|
|
41
|
+
if (known.legacy) {
|
|
42
|
+
out.push({ kind: 'skip', key: c.key, reason: 'legacy' });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!opts.flatten && typeof known.layers === 'number' && known.layers > 1) {
|
|
46
|
+
out.push({ kind: 'needs-flatten', key: c.key, layers: known.layers });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
out.push({
|
|
50
|
+
kind: 'update',
|
|
51
|
+
key: c.key,
|
|
52
|
+
assetId: known.assetId,
|
|
53
|
+
layerIdx: known.layerIdx,
|
|
54
|
+
baseSha256: known.sha256,
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// New on disk → adopt. A single segment (outDir/hero.png) is a document
|
|
59
|
+
// with one artboard of the same name — the server already accepts that.
|
|
60
|
+
const rawNames = c.segments.slice(-MAX_PUSH_PATH_SEGMENTS);
|
|
61
|
+
let pathNames = rawNames;
|
|
62
|
+
let path = pathNames.map(pushSlug);
|
|
63
|
+
if (path.length === 1 && path[0]) {
|
|
64
|
+
pathNames = [pathNames[0], pathNames[0]];
|
|
65
|
+
path = [path[0], path[0]];
|
|
66
|
+
}
|
|
67
|
+
if (path.length < 2 || path.some((s) => !s)) {
|
|
68
|
+
out.push({ kind: 'skip', key: c.key, reason: 'unusable-path' });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out.push({
|
|
72
|
+
kind: 'adopt',
|
|
73
|
+
key: c.key,
|
|
74
|
+
path,
|
|
75
|
+
pathNames,
|
|
76
|
+
name: pathNames[pathNames.length - 1],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-artboard Unity sync opt-in (strict).
|
|
3
|
+
*
|
|
4
|
+
* In the editor, each artboard has a "Sync to Unity" checkbox (and library
|
|
5
|
+
* folders have a folder-level switch that cascades). A project may hold 100
|
|
6
|
+
* drafts but only 3 artboards that belong in the Unity project, so the CLI
|
|
7
|
+
* mirrors the in-app sync button and pulls ONLY the flagged ones.
|
|
8
|
+
*
|
|
9
|
+
* Strict opt-in: `unity === true` syncs, anything else does not. A missing
|
|
10
|
+
* flag means the server didn't say (older edge deploy, or a budget-starved
|
|
11
|
+
* manifest backfill) — those entries are excluded and reported via
|
|
12
|
+
* `unknownFlags` so the caller can warn instead of silently syncing the whole
|
|
13
|
+
* library into someone's game project.
|
|
14
|
+
*/
|
|
15
|
+
export function filterUnityManifest(manifest, opts = {}) {
|
|
16
|
+
if (opts.syncAll)
|
|
17
|
+
return { entries: manifest, noneFlagged: false, unknown: [] };
|
|
18
|
+
const entries = manifest.filter((e) => e.unity === true);
|
|
19
|
+
const unknown = manifest.filter((e) => typeof e.unity !== 'boolean');
|
|
20
|
+
return {
|
|
21
|
+
entries,
|
|
22
|
+
noneFlagged: manifest.length > 0 && entries.length === 0,
|
|
23
|
+
unknown,
|
|
24
|
+
};
|
|
25
|
+
}
|