@magicpixelart/cli 0.5.29 → 0.5.31
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 +19 -0
- package/README.md +12 -1
- package/dist/api.js +1 -1
- package/dist/commands/push.js +13 -2
- package/dist/commands/start.js +67 -4
- package/dist/commands/sync.js +61 -4
- package/dist/config.js +24 -0
- package/dist/index.js +12 -2
- package/dist/util/syncScope.js +44 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable changes to `@magicpixelart/cli` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [0.5.31] - 2026-10-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `sync --only <folder>` (repeatable; or `"syncOnly": [...]` in magicpixel.json) limits push, pull and pruning to those game folders — e.g. `sync --watch --only Runtime/Sprites/Entities/decorations`. Everything else is left untouched. Changing the scope triggers one full pass.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `sync <folder>` now errors and suggests `--only` instead of silently syncing the whole project.
|
|
15
|
+
- With `--only`, the legacy-folder sweep never touches folders outside the scope, and `--watch` on a folder that matches nothing exits once with a clear message instead of erroring every tick.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Syncing an already-synced game folder to a new account/key/project no longer skips every sprite as "unchanged": the upload record now remembers its project and resets when the project changes (game files untouched).
|
|
19
|
+
|
|
20
|
+
## [0.5.30] - 2026-10-01
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- `start` now checks the saved or environment key before syncing; a rejected saved key re-opens the key prompt instead of failing every request with 401.
|
|
24
|
+
- `start` asks "all sprites, or one folder?" on game projects; `--folder <path>` answers it up front.
|
|
25
|
+
- Rejected-key errors point to `login` first.
|
|
26
|
+
|
|
8
27
|
## [Unreleased]
|
|
9
28
|
|
|
10
29
|
## [0.5.29]
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Then leave this running while you work:
|
|
|
32
32
|
npx @magicpixelart/cli sync --watch
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
After `npm i -D @magicpixelart/cli` you can type `magicpixel sync --watch`. Connected sprites write back to their original game path. New MagicPixel-only art still lands in `outDir`. To sync only some folders: `npx @magicpixelart/cli connect 'assets/Sprites/**'`.
|
|
35
|
+
After `npm i -D @magicpixelart/cli` you can type `magicpixel sync --watch`. Connected sprites write back to their original game path. New MagicPixel-only art still lands in `outDir`. To sync only some folders: `npx @magicpixelart/cli connect 'assets/Sprites/**'`. To watch just one folder of a huge project: `npx @magicpixelart/cli sync --watch --only Assets/Sprites/Enemies`.
|
|
36
36
|
|
|
37
37
|
When it finishes:
|
|
38
38
|
|
|
@@ -112,6 +112,7 @@ No bundler config, no runtime, no extra package.
|
|
|
112
112
|
| `repair [--dry-run] [-y]` | Self-heal a broken sync: validate key → quarantine `state.json` → prune empty dirs → full re-sync. |
|
|
113
113
|
| `sync [...flags]` | Two-way: pull MagicPixel edits and push changed game sprites. |
|
|
114
114
|
| `push [--dry-run] [--flatten]` | Upload local PNG edits (and connected game sprites) back to MagicPixel. |
|
|
115
|
+
| `resync <folder> [--dry-run] [-y]` | Local files win for one folder: overwrite MagicPixel copies, move cloud-only files to Trash. |
|
|
115
116
|
| `connect <glob>` | Limit which game folders sync (default is all sprites on Unity/Godot/GameMaker). |
|
|
116
117
|
| `search <query>` | Search indexed game PNGs (no network). |
|
|
117
118
|
| `add <glob>` / `remove <glob>` | Manage `include` patterns. |
|
|
@@ -129,6 +130,7 @@ No bundler config, no runtime, no extra package.
|
|
|
129
130
|
| `--full` | Ignore `lastSync`; re-fetch the full manifest. |
|
|
130
131
|
| `-c, --concurrency <n>` | Parallel downloads (1–16, default 6). |
|
|
131
132
|
| `-q, --quiet` | Minimal output (for CI). |
|
|
133
|
+
| `--only <folder...>` | Only push, pull and prune inside these game folders (repeatable). Everything else is left untouched. |
|
|
132
134
|
|
|
133
135
|
Each successful sync prints a per-file change list (`+` added, `~` modified, `↪` renamed, `-` pruned) so you (and any AI agent reading the logs) know exactly what to wire up.
|
|
134
136
|
|
|
@@ -140,6 +142,14 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
|
|
|
140
142
|
- Atomic writes (`*.tmp` → `rename`) survive crashes mid-write.
|
|
141
143
|
- Retry with backoff on 429/5xx; `lastSync` only advances on a clean run.
|
|
142
144
|
|
|
145
|
+
### Sync one folder of a big project
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npx @magicpixelart/cli sync --watch --only Runtime/Sprites/Entities/decorations
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Only that folder is pushed, pulled, or pruned. Every other file stays exactly as it is. Repeat `--only` for more folders, or set `"syncOnly"` in `magicpixel.json`. Changing the folder list triggers one full check. `sync <folder>` without `--only` stops with an error instead of syncing everything.
|
|
152
|
+
|
|
143
153
|
## Config (`magicpixel.json`)
|
|
144
154
|
|
|
145
155
|
| Field | Type | Default | Meaning |
|
|
@@ -151,6 +161,7 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
|
|
|
151
161
|
| `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
|
|
152
162
|
| `unityPpu` | `number?` | `32` | Unity only: pixels-per-unit in generated `.meta`. |
|
|
153
163
|
| `push` | `boolean?` | `true` | Upload local PNG changes (outDir + `connect` working set) to MagicPixel on every `sync`. Set `false` for pull-only. |
|
|
164
|
+
| `syncOnly` | `string[]?` | — | Default for `sync --only`: folders a plain `sync` is limited to. |
|
|
154
165
|
| `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
|
|
155
166
|
|
|
156
167
|
State (`.magicpixel/state.json`) tracks `lastSync` (file mode `0600`). Add `.magicpixel/` to `.gitignore` (init offers to do this).
|
package/dist/api.js
CHANGED
|
@@ -92,7 +92,7 @@ export function errorCodeFromResponse(res) {
|
|
|
92
92
|
export function friendlyApiError(status, body, context, code) {
|
|
93
93
|
if (status === 401 || status === 403) {
|
|
94
94
|
return (`${context}: ${status} — API key rejected.\n` +
|
|
95
|
-
` Fix:
|
|
95
|
+
` Fix: run \`${cmd('login')}\` and paste a fresh key from https://magicpixel.art/settings.`);
|
|
96
96
|
}
|
|
97
97
|
if (status === 404) {
|
|
98
98
|
return (`${context}: 404 — endpoint or asset not found.\n` +
|
package/dist/commands/push.js
CHANGED
|
@@ -10,9 +10,9 @@ import kleur from 'kleur';
|
|
|
10
10
|
import ora from 'ora';
|
|
11
11
|
import { mkdir, readFile, rename } from 'node:fs/promises';
|
|
12
12
|
import { dirname, relative, resolve } from 'node:path';
|
|
13
|
-
import { loadConfig, loadState, saveState } from '../config.js';
|
|
13
|
+
import { loadConfig, loadState, saveState, resetStateIfProjectChanged, PROJECT_CHANGED_MESSAGE } from '../config.js';
|
|
14
14
|
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
15
|
-
import { PUSH_BATCH_SIZE, pushSpritesAdaptive } from '../api.js';
|
|
15
|
+
import { PUSH_BATCH_SIZE, pushSpritesAdaptive, fetchManifestPage, getLastProjectInfo } from '../api.js';
|
|
16
16
|
import { hashFile } from '../util/hash.js';
|
|
17
17
|
import { createLimit } from '../util/limit.js';
|
|
18
18
|
import { assetDiskPathFromKey, walkOutDirPngs } from '../util/paths.js';
|
|
@@ -37,6 +37,17 @@ export async function pushCommand(opts = {}) {
|
|
|
37
37
|
export async function runPush(opts = {}) {
|
|
38
38
|
const config = await ensureEngineConnect(await loadConfig(), process.cwd(), !opts.dryRun);
|
|
39
39
|
const state = await loadState();
|
|
40
|
+
// Standalone push has no manifest yet — one tiny page tells us which project
|
|
41
|
+
// this key writes to, so a record from another account can't skip everything.
|
|
42
|
+
if (!opts.nestedSync && Object.keys(state.synced ?? {}).length > 0) {
|
|
43
|
+
const page = await fetchManifestPage({ config, limit: 1 });
|
|
44
|
+
if (resetStateIfProjectChanged(state, getLastProjectInfo()?.id, page.items.length === 0)) {
|
|
45
|
+
if (!opts.dryRun)
|
|
46
|
+
await saveState(state);
|
|
47
|
+
if (!opts.quiet)
|
|
48
|
+
console.log(kleur.yellow(`! ${PROJECT_CHANGED_MESSAGE}`));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
40
51
|
return runPushWith(config, state, opts);
|
|
41
52
|
}
|
|
42
53
|
export async function runPushWith(config, state, opts = {}) {
|
package/dist/commands/start.js
CHANGED
|
@@ -11,6 +11,31 @@ import { initCommand } from './init.js';
|
|
|
11
11
|
import { loginCommand } from './login.js';
|
|
12
12
|
import { syncCommand } from './sync.js';
|
|
13
13
|
import { cmd } from '../util/invoke.js';
|
|
14
|
+
import { assertKeyValid } from '../util/auth.js';
|
|
15
|
+
import { ApiError } from '../api.js';
|
|
16
|
+
import { connectCommand } from './connect.js';
|
|
17
|
+
import { isAllSpritesGlob } from '../util/engineConnect.js';
|
|
18
|
+
/** Validate a key; only auth rejections map to 'rejected' — other failures throw. */
|
|
19
|
+
export async function checkKey(key, config) {
|
|
20
|
+
try {
|
|
21
|
+
await assertKeyValid(key, config);
|
|
22
|
+
return 'ok';
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof ApiError && (e.status === 401 || e.status === 403))
|
|
26
|
+
return 'rejected';
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** `Sprites/Enemies` → `Sprites/Enemies/**`; already-globbed input is kept. */
|
|
31
|
+
export function folderToGlob(folder) {
|
|
32
|
+
const f = folder.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
33
|
+
return /[*?]/.test(f) ? f : `${f}/**`;
|
|
34
|
+
}
|
|
35
|
+
/** Only ask on an interactive engine project still set to "everything". */
|
|
36
|
+
export function shouldAskFolder(connect, isTTY, engine) {
|
|
37
|
+
return isTTY && engine && (connect.length === 0 || connect.every(isAllSpritesGlob));
|
|
38
|
+
}
|
|
14
39
|
/**
|
|
15
40
|
* One-command bootstrap for new users. Runs init → key prompt → first sync →
|
|
16
41
|
* prints copy-pasteable watch instructions. Designed so a non-technical user
|
|
@@ -55,20 +80,58 @@ export async function startCommand(opts = {}) {
|
|
|
55
80
|
}
|
|
56
81
|
// 3. Offer to migrate any key sitting in .env / .env.local.
|
|
57
82
|
await maybeMigrateDotenvKey();
|
|
58
|
-
// 4. Make sure we have a usable key
|
|
59
|
-
|
|
83
|
+
// 4. Make sure we have a usable key — and that it actually works. A stale
|
|
84
|
+
// stored key used to sail through and fail every request with 401.
|
|
85
|
+
const config = await loadConfig();
|
|
86
|
+
const envKey = process.env.MAGICPIXEL_API_KEY?.trim();
|
|
60
87
|
const stored = readCredentialsSync();
|
|
61
|
-
if (!
|
|
88
|
+
if (!envKey && !stored) {
|
|
62
89
|
console.log();
|
|
63
90
|
console.log(kleur.bold('Step: connect your account'));
|
|
64
91
|
await loginCommand();
|
|
65
92
|
}
|
|
66
|
-
else if (
|
|
93
|
+
else if (envKey) {
|
|
94
|
+
if ((await checkKey(envKey, config)) === 'rejected') {
|
|
95
|
+
console.log(kleur.red('✖ The MAGICPIXEL_API_KEY in your environment was rejected.'));
|
|
96
|
+
console.log(kleur.dim(' Fix: set it to a fresh key from https://magicpixel.art/settings (or unset it and re-run).'));
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
67
100
|
console.log(kleur.dim(' Using MAGICPIXEL_API_KEY from your environment.'));
|
|
68
101
|
}
|
|
102
|
+
else if (stored && (await checkKey(stored.apiKey, config)) === 'rejected') {
|
|
103
|
+
console.log(kleur.yellow('! Your saved key was rejected (revoked, or from another account).'));
|
|
104
|
+
if (!stdin.isTTY) {
|
|
105
|
+
console.log(kleur.dim(` Fix: run \`${cmd('login')} --key mp_live_…\` with a fresh key from https://magicpixel.art/settings.`));
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
console.log(kleur.bold('Step: connect your account'));
|
|
110
|
+
await loginCommand();
|
|
111
|
+
}
|
|
69
112
|
else {
|
|
70
113
|
console.log(kleur.dim(' Using stored credentials from .magicpixel/credentials.'));
|
|
71
114
|
}
|
|
115
|
+
// 4b. Don't push a whole game by surprise — offer one folder first.
|
|
116
|
+
let folder = opts.folder?.trim();
|
|
117
|
+
if (!folder && shouldAskFolder(config.connect, !!stdin.isTTY, isEngineKind(kind))) {
|
|
118
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
119
|
+
try {
|
|
120
|
+
folder = (await rl.question(`${kleur.cyan('?')} Sync all sprites, or one folder? ${kleur.dim('(type a folder like Sprites/Enemies, or press Enter for all)')} `)).trim();
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
rl.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (folder) {
|
|
127
|
+
console.log();
|
|
128
|
+
console.log(kleur.bold('Step: sync one folder'));
|
|
129
|
+
await connectCommand(folderToGlob(folder));
|
|
130
|
+
console.log();
|
|
131
|
+
console.log(kleur.dim(` Add another folder later with \`${cmd('connect')} "<folder>/**"\`.`));
|
|
132
|
+
console.log(` ${kleur.green('▶')} ${kleur.bold(`${cmd('sync')} --watch`)} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
72
135
|
// 5. First sync. syncCommand sets `process.exitCode = 1` on download
|
|
73
136
|
// failures without throwing — snapshot around the call so we don't
|
|
74
137
|
// print a misleading green "you're set up" over a half-failed run.
|
package/dist/commands/sync.js
CHANGED
|
@@ -3,7 +3,7 @@ import ora from 'ora';
|
|
|
3
3
|
import { mkdir, unlink, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import { dirname, relative, resolve } from 'node:path';
|
|
6
|
-
import { loadConfig, loadState, saveState } from '../config.js';
|
|
6
|
+
import { loadConfig, loadState, saveState, resetStateIfProjectChanged, PROJECT_CHANGED_MESSAGE } from '../config.js';
|
|
7
7
|
import { ensureEngineConnect } from '../util/engineConnect.js';
|
|
8
8
|
import { fetchManifestSnapshot, fetchAssetBytes, ApiError, DAILY_QUOTA_ERROR_CODE, getLastProjectInfo, primeManifestEtags, manifestEtagsSnapshot } from '../api.js';
|
|
9
9
|
import { fileSha256 } from '../util/hash.js';
|
|
@@ -30,6 +30,7 @@ import { shouldReconcile } from '../util/reconcile.js';
|
|
|
30
30
|
import { runPush } from './push.js';
|
|
31
31
|
import { claimResyncRequest, reportResyncRequest } from '../api.js';
|
|
32
32
|
import { runResync } from './resync.js';
|
|
33
|
+
import { keyInScope, pathInScope, resolveSyncScope, scopeFingerprint } from '../util/syncScope.js';
|
|
33
34
|
/** How often the watcher asks for a library "Resync from game" request. */
|
|
34
35
|
const RESYNC_POLL_MS = 15_000;
|
|
35
36
|
let lastResyncPollAt = 0;
|
|
@@ -122,6 +123,15 @@ async function watchLoop(opts) {
|
|
|
122
123
|
// manifest fetch fails we still want the loop to come up and retry.
|
|
123
124
|
console.log(kleur.bold('👀 MagicPixel watching for changes…'));
|
|
124
125
|
console.log(` Edit at: ${kleur.cyan('https://magicpixel.art')}`);
|
|
126
|
+
try {
|
|
127
|
+
const scope = resolveSyncScope(opts.only ?? (await loadConfig()).syncOnly);
|
|
128
|
+
if (scope) {
|
|
129
|
+
console.log(` Only: ${scope.folders.length} folder${scope.folders.length === 1 ? '' : 's'}: ${kleur.cyan(scope.folders.join(', '))}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* header is cosmetic */
|
|
134
|
+
}
|
|
125
135
|
let countSpinner = null;
|
|
126
136
|
if (!opts.quiet) {
|
|
127
137
|
try {
|
|
@@ -535,6 +545,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
535
545
|
if (reconcileDue && verbose) {
|
|
536
546
|
console.log(kleur.dim('Running a full reconcile (periodic) to clear anything deleted in MagicPixel.'));
|
|
537
547
|
}
|
|
548
|
+
// Folder scope: a changed scope needs one full pass so newly-included
|
|
549
|
+
// folders aren't skipped by a cursor that advanced while they were held.
|
|
550
|
+
const scope = resolveSyncScope(opts.only ?? config.syncOnly);
|
|
551
|
+
const scopeKey = scopeFingerprint(scope);
|
|
552
|
+
if (scopeKey !== state.syncScope)
|
|
553
|
+
since = undefined;
|
|
538
554
|
const previousAssets = state.assets ?? {}; // id → key from prior sync
|
|
539
555
|
const spinner = verbose
|
|
540
556
|
? ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start()
|
|
@@ -582,6 +598,12 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
582
598
|
// user presses Sync (the release bumps `updated_at`).
|
|
583
599
|
observedUpdatedAt = maxIsoTimestamp(snapshot.entries.map((e) => e.updated_at));
|
|
584
600
|
const projectInfo = getLastProjectInfo();
|
|
601
|
+
// Full read + empty project is the only safe "cloud is empty" signal.
|
|
602
|
+
if (resetStateIfProjectChanged(state, projectInfo?.id, !since && snapshot.entries.length === 0)) {
|
|
603
|
+
await saveState(state);
|
|
604
|
+
if (verbose)
|
|
605
|
+
console.log(kleur.yellow(`! ${PROJECT_CHANGED_MESSAGE}`));
|
|
606
|
+
}
|
|
585
607
|
const projectSuffix = projectInfo && verbose
|
|
586
608
|
? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
|
|
587
609
|
: '';
|
|
@@ -630,8 +652,39 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
630
652
|
: { files: [], capped: false });
|
|
631
653
|
if (!runOpts.gameIndex)
|
|
632
654
|
runOpts.onGameIndex?.(gameIndex);
|
|
633
|
-
const
|
|
655
|
+
const fullGameIndex = gameIndex;
|
|
656
|
+
const scopedGameIndex = scope
|
|
657
|
+
? { ...fullGameIndex, files: fullGameIndex.files.filter((f) => pathInScope(scope, f.sourceRel)) }
|
|
658
|
+
: fullGameIndex;
|
|
659
|
+
const connected = matchConnectGlobs(scopedGameIndex, config.connect ?? []);
|
|
634
660
|
const sourceByKey = collectSourceRelMap(connected.entries, state.synced);
|
|
661
|
+
if (scope) {
|
|
662
|
+
// Out-of-scope rows become hold-only: never pulled, and their folders are
|
|
663
|
+
// protected from every prune path exactly like unreleased documents.
|
|
664
|
+
const inScope = [];
|
|
665
|
+
for (const e of manifest) {
|
|
666
|
+
const rel = sourceByKey.get(e.key) ?? state.synced?.[e.key]?.sourceRel;
|
|
667
|
+
if (keyInScope(scope, e.key, rel))
|
|
668
|
+
inScope.push(e);
|
|
669
|
+
else
|
|
670
|
+
withheldEntries.push(e);
|
|
671
|
+
}
|
|
672
|
+
manifest = inScope;
|
|
673
|
+
removedRemoteKeys = removedRemoteKeys.filter((k) => keyInScope(scope, k, sourceByKey.get(k) ?? state.synced?.[k]?.sourceRel));
|
|
674
|
+
if (scopedGameIndex.files.length === 0 && manifest.length === 0) {
|
|
675
|
+
const msg = `Nothing found in ${scope.folders.join(', ')}.\n` +
|
|
676
|
+
` Fix: check the folder path (relative to ${process.cwd()}, e.g. Runtime/Sprites/Entities/decorations).`;
|
|
677
|
+
// Watch would hit this on every tick — say it once and stop.
|
|
678
|
+
if (runOpts.watchMode) {
|
|
679
|
+
console.error(kleur.red(msg));
|
|
680
|
+
process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
throw new Error(msg);
|
|
683
|
+
}
|
|
684
|
+
if (verbose) {
|
|
685
|
+
console.log(kleur.dim(` only ${scope.folders.join(', ')} → ${connected.entries.length} game file${connected.entries.length === 1 ? '' : 's'} · ${manifest.length} in MagicPixel`));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
635
688
|
aliasCollisionKeys(sourceByKey, manifest.map((e) => e.key));
|
|
636
689
|
if (verbose && gameIndex.capped) {
|
|
637
690
|
console.log(kleur.yellow(`! ${GAME_INDEX_CAP_HINT}`));
|
|
@@ -995,6 +1048,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
995
1048
|
for (const p of orphanSet) {
|
|
996
1049
|
if (conflictPaths.has(p))
|
|
997
1050
|
continue;
|
|
1051
|
+
if (scope && !pathInScope(scope, p))
|
|
1052
|
+
continue; // outside --only: hands off
|
|
998
1053
|
const key = keyByPath.get(p);
|
|
999
1054
|
if (key &&
|
|
1000
1055
|
(await hasUnpushedLocalEdit({ absPath: p, baselineDiskSha256: synced[key]?.diskSha256 }))) {
|
|
@@ -1041,7 +1096,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1041
1096
|
if (top)
|
|
1042
1097
|
knownFolderSlugs.add(top);
|
|
1043
1098
|
}
|
|
1044
|
-
const legacyFolders = await findLegacySuffixFolders(config.outDir, knownFolderSlugs)
|
|
1099
|
+
const legacyFolders = (await findLegacySuffixFolders(config.outDir, knownFolderSlugs))
|
|
1100
|
+
.filter((lf) => !scope || pathInScope(scope, lf.abs)); // --only: hands off elsewhere
|
|
1045
1101
|
if (verbose) {
|
|
1046
1102
|
console.log();
|
|
1047
1103
|
console.log(kleur.bold('Plan:'));
|
|
@@ -1365,6 +1421,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1365
1421
|
assets: nextAssets,
|
|
1366
1422
|
synced: nextSynced,
|
|
1367
1423
|
manifestEtags: manifestEtagsSnapshot(),
|
|
1424
|
+
syncScope: scopeKey,
|
|
1368
1425
|
// A completed full pass is the only run that can prove nothing is orphaned.
|
|
1369
1426
|
...(!since && result.failed === 0 ? { lastReconcile: startedAt } : {}),
|
|
1370
1427
|
};
|
|
@@ -1437,7 +1494,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1437
1494
|
console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
|
|
1438
1495
|
}
|
|
1439
1496
|
onStatus?.('Checking your game files…');
|
|
1440
|
-
const pushed = await maybePushLocalSprites(config.push, verbose,
|
|
1497
|
+
const pushed = await maybePushLocalSprites(config.push, verbose, scopedGameIndex, live && !!runOpts.watchMode, onStatus, conflictSet);
|
|
1441
1498
|
if (verbose) {
|
|
1442
1499
|
console.log();
|
|
1443
1500
|
const summary = `downloaded ${downloaded} (added ${result.added.length}, modified ${result.modified.length}), ` +
|
package/dist/config.js
CHANGED
|
@@ -10,6 +10,29 @@ const CONFIG_FILENAME = 'magicpixel.json';
|
|
|
10
10
|
const STATE_DIR = '.magicpixel';
|
|
11
11
|
const STATE_FILENAME = 'state.json';
|
|
12
12
|
const DEFAULT_ENDPOINT = 'https://sddsilidjhvtvejzvolx.supabase.co/functions/v1/integration-assets';
|
|
13
|
+
/**
|
|
14
|
+
* The upload record is only valid for the project it was built against. A new
|
|
15
|
+
* key / account / project must not inherit "already uploaded" entries, or every
|
|
16
|
+
* sprite is skipped as unchanged and nothing reaches the new project.
|
|
17
|
+
* Legacy records (no projectId) are treated as stale when the server reports
|
|
18
|
+
* an empty project. Mutates `state`; returns true when it was reset. Disk is
|
|
19
|
+
* never touched.
|
|
20
|
+
*/
|
|
21
|
+
export function resetStateIfProjectChanged(state, projectId, cloudIsEmpty) {
|
|
22
|
+
if (!projectId)
|
|
23
|
+
return false;
|
|
24
|
+
const hasRecord = Object.keys(state.synced ?? {}).length > 0 || Object.keys(state.assets ?? {}).length > 0;
|
|
25
|
+
const stale = state.projectId ? state.projectId !== projectId : hasRecord && cloudIsEmpty;
|
|
26
|
+
if (stale) {
|
|
27
|
+
delete state.synced;
|
|
28
|
+
delete state.assets;
|
|
29
|
+
delete state.manifestEtags;
|
|
30
|
+
delete state.lastReconcile;
|
|
31
|
+
}
|
|
32
|
+
state.projectId = projectId;
|
|
33
|
+
return stale;
|
|
34
|
+
}
|
|
35
|
+
export const PROJECT_CHANGED_MESSAGE = 'This key belongs to a different MagicPixel project — re-uploading your connected folders.';
|
|
13
36
|
export const defaultConfig = {
|
|
14
37
|
outDir: 'src/assets/magicpixel',
|
|
15
38
|
include: ['**/*'],
|
|
@@ -89,6 +112,7 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
89
112
|
unityPpu,
|
|
90
113
|
push: parsed.push === false ? false : undefined,
|
|
91
114
|
connect,
|
|
115
|
+
...(parsed.syncOnly !== undefined ? { syncOnly: normalizeGlobList(parsed.syncOnly, 'syncOnly') } : {}),
|
|
92
116
|
};
|
|
93
117
|
}
|
|
94
118
|
const MAX_GLOBS = 64;
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { resyncCommand } from './commands/resync.js';
|
|
|
19
19
|
import { searchCommand } from './commands/search.js';
|
|
20
20
|
import { parseWatchInterval, parseConcurrency } from './util/flagValidators.js';
|
|
21
21
|
import { CLI_VERSION } from './version.js';
|
|
22
|
+
import { cmd } from './util/invoke.js';
|
|
22
23
|
// Node version guard
|
|
23
24
|
const major = Number(process.versions.node.split('.')[0]);
|
|
24
25
|
if (major < 18) {
|
|
@@ -52,6 +53,7 @@ program
|
|
|
52
53
|
.command('start')
|
|
53
54
|
.description('One-command first-run setup: init + login + first sync')
|
|
54
55
|
.option('--force', 'Re-run init even if magicpixel.json exists')
|
|
56
|
+
.option('--folder <path>', 'Sync only this game folder first (skips the "all or one folder" question)')
|
|
55
57
|
.action(wrap("start", async (opts) => startCommand(opts)));
|
|
56
58
|
program
|
|
57
59
|
.command('init')
|
|
@@ -91,8 +93,16 @@ program
|
|
|
91
93
|
.option('-w, --watch [seconds]', 'Poll for changes (default 2s; auto-slows to 5s after ~1min idle, 10s after ~5min)', parseWatchInterval)
|
|
92
94
|
.option('-q, --quiet', 'Minimal output (for CI)')
|
|
93
95
|
.option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
|
|
94
|
-
.
|
|
95
|
-
.
|
|
96
|
+
.option('--only <folder...>', 'Only sync these game folders (repeatable), e.g. Runtime/Sprites/Entities/decorations')
|
|
97
|
+
.argument('[extra...]')
|
|
98
|
+
.addHelpText('after', '\nExamples:\n $ magicpixel sync --watch --only Runtime/Sprites/Enemies # one folder\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')
|
|
99
|
+
.action(wrap("sync", async (extra, opts) => {
|
|
100
|
+
if (extra.length > 0) {
|
|
101
|
+
throw new Error(`sync doesn't take a folder (got "${extra.join(' ')}").\n` +
|
|
102
|
+
` Fix: ${cmd(`sync --only ${extra[0].replace(/^\/+/, '')}`)}`);
|
|
103
|
+
}
|
|
104
|
+
return syncCommand(opts);
|
|
105
|
+
}));
|
|
96
106
|
program
|
|
97
107
|
.command('push')
|
|
98
108
|
.description('Upload local sprite edits back to MagicPixel (two-way sync)')
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sync --only <folder>`: limit both halves of a sync (push + pull + prune) to
|
|
3
|
+
* a few game folders. Everything outside the scope is treated as hold-only —
|
|
4
|
+
* never pulled, pushed, or deleted — so a huge project can be watched one
|
|
5
|
+
* folder at a time.
|
|
6
|
+
*/
|
|
7
|
+
import { relative } from 'node:path';
|
|
8
|
+
import { keyInFolder, parseFolderArg } from '../commands/resync.js';
|
|
9
|
+
/** Accepts `/Runtime/Foo/`, `Runtime\\Foo`, `Runtime/Foo/**`. */
|
|
10
|
+
export function normalizeScopeFolder(input) {
|
|
11
|
+
return input
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/\\/g, '/')
|
|
14
|
+
.replace(/\/\*\*?$/g, '')
|
|
15
|
+
.replace(/^\.?\/+/, '')
|
|
16
|
+
.replace(/\/+$/, '')
|
|
17
|
+
.replace(/\/{2,}/g, '/');
|
|
18
|
+
}
|
|
19
|
+
export function resolveSyncScope(inputs) {
|
|
20
|
+
const folders = Array.from(new Set((inputs ?? []).map(normalizeScopeFolder).filter(Boolean)));
|
|
21
|
+
if (folders.length === 0)
|
|
22
|
+
return null;
|
|
23
|
+
return {
|
|
24
|
+
folders: folders.map((f) => f.toLowerCase()),
|
|
25
|
+
keySegments: folders.map(parseFolderArg).filter((s) => s.length > 0),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Is a cwd-relative (or absolute) path inside the scope? */
|
|
29
|
+
export function pathInScope(scope, path) {
|
|
30
|
+
const rel = (path.startsWith('/') || /^[a-z]:/i.test(path) ? relative(process.cwd(), path) : path)
|
|
31
|
+
.replace(/\\/g, '/')
|
|
32
|
+
.toLowerCase();
|
|
33
|
+
return scope.folders.some((f) => rel === f || rel.startsWith(`${f}/`));
|
|
34
|
+
}
|
|
35
|
+
/** A manifest key is in scope when its game file or its library folder is. */
|
|
36
|
+
export function keyInScope(scope, key, sourceRel) {
|
|
37
|
+
if (sourceRel)
|
|
38
|
+
return pathInScope(scope, sourceRel);
|
|
39
|
+
return scope.keySegments.some((segs) => keyInFolder(key, segs));
|
|
40
|
+
}
|
|
41
|
+
/** Stable fingerprint persisted in state — a change forces one full pass. */
|
|
42
|
+
export function scopeFingerprint(scope) {
|
|
43
|
+
return scope ? [...scope.folders].sort().join('|') : undefined;
|
|
44
|
+
}
|