@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/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.8] — 2026-08-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Connect imports game sprites.** `sync` (and `sync --watch`) now runs `push`
|
|
15
|
+
automatically so connecting a game project imports existing sprites into the
|
|
16
|
+
MagicPixel library, flagged Sync to Unity so they appear under Connected.
|
|
17
|
+
`"push": false` in `magicpixel.json` opts out.
|
|
18
|
+
- **First-connect import.** On Unity/Godot/GameMaker, PNGs under `Assets/` /
|
|
19
|
+
`assets/` / `datafiles/` are copied into `outDir` if they aren't there yet
|
|
20
|
+
(skipping Library/Temp/Packages, never overwriting) and then adopted.
|
|
21
|
+
Root-level PNGs (`hero.png`) adopt as a single-artboard document instead of
|
|
22
|
+
being skipped.
|
|
23
|
+
|
|
24
|
+
## [0.5.7] — 2026-08-26
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **`magicpixel push`.** Two-way sync: walk `outDir`, hash local PNGs, and
|
|
29
|
+
upload changed or new sprites back to MagicPixel (`--dry-run`, `--flatten`).
|
|
30
|
+
Cloud conflicts refuse instead of overwriting; new on-disk files are adopted
|
|
31
|
+
into the library. Prune no longer deletes pending local sprites.
|
|
32
|
+
- **Unity `.meta` files.** Sync writes TextureImporter `.meta` next to PNGs
|
|
33
|
+
(point filter, no compression, deterministic GUIDs). `unityPpu` (default 32)
|
|
34
|
+
and `unitySyncAll` in `magicpixel.json`. By default only artboards flagged
|
|
35
|
+
"Sync to Unity" in the editor are pulled.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **`npx magicpixel …` dead end.** `magicpixel` is only the bin name — the
|
|
40
|
+
published package is `@magicpixelart/cli` — so hints like "run `npx
|
|
41
|
+
magicpixel init`" 404ed (`'magicpixel@*' is not in this registry`) for anyone
|
|
42
|
+
driving the CLI purely through `npx @magicpixelart/cli …`. Every user-facing
|
|
43
|
+
command hint now goes through a single `cmd()` helper that prints the short
|
|
44
|
+
`magicpixel <cmd>` form only when the package resolves from the project's
|
|
45
|
+
`node_modules`, and the always-correct `npx @magicpixelart/cli <cmd>` form
|
|
46
|
+
otherwise.
|
|
47
|
+
- **Missing-config hint points at `start`.** A config-less project is now told
|
|
48
|
+
to run `start` (init + login + first sync) rather than bare `init`, which left
|
|
49
|
+
users unauthenticated with nothing on disk. `status` uses the same hint
|
|
50
|
+
(it previously hardcoded `npx @magicpixelart/cli init`).
|
|
51
|
+
|
|
10
52
|
## [0.5.6] — 2026-08-21
|
|
11
53
|
|
|
12
54
|
### Changed
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Requires Node.js ≥ 18.
|
|
|
19
19
|
One command, inside your project folder (the one with `package.json`):
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npx
|
|
22
|
+
npx @magicpixelart/cli start
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
That walks you through everything: detects your framework, writes `magicpixel.json`, prompts for your API key (paste it from [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys), pulls your sprites, and tells you how to keep them fresh.
|
|
@@ -42,16 +42,16 @@ If `start` finds a `MAGICPIXEL_API_KEY` in your `.env` / `.env.local`, it offers
|
|
|
42
42
|
### Manual setup (advanced)
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
npx
|
|
46
|
-
npx
|
|
47
|
-
npx
|
|
48
|
-
npx
|
|
45
|
+
npx @magicpixelart/cli init # writes magicpixel.json
|
|
46
|
+
npx @magicpixelart/cli login # stores your key (or: export MAGICPIXEL_API_KEY=mp_live_...)
|
|
47
|
+
npx @magicpixelart/cli sync # downloads changed assets
|
|
48
|
+
npx @magicpixelart/cli sync --watch # keeps assets fresh while you work
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
### Something not working?
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
npx
|
|
54
|
+
npx @magicpixelart/cli doctor
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Prints a one-screen diagnostic (CLI version, framework, outDir, key source, last sync, last error, live manifest probe). Paste it to your AI agent — it's designed to be the only context they need.
|
|
@@ -101,6 +101,7 @@ No bundler config, no runtime, no extra package.
|
|
|
101
101
|
| `doctor` | Print a one-screen diagnostic — paste it to your AI agent when something breaks. |
|
|
102
102
|
| `repair [--dry-run] [-y]` | Self-heal a broken sync: validate key → quarantine `state.json` → prune empty dirs → full re-sync. |
|
|
103
103
|
| `sync [...flags]` | Fetch manifest, diff against disk, download changed assets, prune orphans. |
|
|
104
|
+
| `push [--dry-run] [--flatten]` | Upload local PNG edits (and new sprites) back to MagicPixel. |
|
|
104
105
|
| `add <glob>` / `remove <glob>` | Manage `include` patterns. |
|
|
105
106
|
| `list` | Print matching manifest as a table. |
|
|
106
107
|
| `status` | Config, last sync, diff vs remote. |
|
|
@@ -135,6 +136,9 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
|
|
|
135
136
|
| `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
|
|
136
137
|
| `exclude` | `string[]` | `[]` | Globs to exclude. |
|
|
137
138
|
| `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
|
|
139
|
+
| `unityPpu` | `number?` | `32` | Unity only: pixels-per-unit in generated `.meta`. |
|
|
140
|
+
| `unitySyncAll` | `boolean?` | `false` | Unity only: sync every artboard, not just those flagged in the editor. |
|
|
141
|
+
| `push` | `boolean?` | `true` | Upload local PNG changes to MagicPixel on every `sync`. Set `false` for pull-only. |
|
|
138
142
|
| `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
|
|
139
143
|
|
|
140
144
|
State (`.magicpixel/state.json`) tracks `lastSync` (file mode `0600`). Add `.magicpixel/` to `.gitignore` (init offers to do this).
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveEndpoint, getApiKey, CLI_VERSION } from './config.js';
|
|
2
|
-
import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, } from './util/security.js';
|
|
2
|
+
import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, validateEndpointUrl, } from './util/security.js';
|
|
3
3
|
import { authHeaders } from './util/authHeaders.js';
|
|
4
|
+
import { cmd } from './util/invoke.js';
|
|
4
5
|
/**
|
|
5
6
|
* Last-seen project metadata from any manifest response this process made.
|
|
6
7
|
* Populated as a side-effect of `fetchManifestPage`; read by `whoami`,
|
|
@@ -38,7 +39,7 @@ export class ApiError extends Error {
|
|
|
38
39
|
function friendly(status, body, context) {
|
|
39
40
|
if (status === 401 || status === 403) {
|
|
40
41
|
return (`${context}: ${status} — API key rejected.\n` +
|
|
41
|
-
` Fix: regenerate at https://magicpixel.art/settings and re-run
|
|
42
|
+
` Fix: regenerate at https://magicpixel.art/settings and re-run \`${cmd('login')}\`.`);
|
|
42
43
|
}
|
|
43
44
|
if (status === 404) {
|
|
44
45
|
return (`${context}: 404 — endpoint or asset not found.\n` +
|
|
@@ -279,3 +280,33 @@ export function retryAfterMsFromResponse(res) {
|
|
|
279
280
|
}
|
|
280
281
|
return 0;
|
|
281
282
|
}
|
|
283
|
+
/** Max sprites the server accepts per request (mirrors MAX_SPRITES_PER_REQUEST). */
|
|
284
|
+
export const PUSH_BATCH_SIZE = 20;
|
|
285
|
+
const DEFAULT_INGEST_ENDPOINT = 'https://magicpixel.art/api/public/integration/ingest';
|
|
286
|
+
export function resolveIngestEndpoint() {
|
|
287
|
+
const override = process.env.MAGICPIXEL_INGEST_ENDPOINT?.trim();
|
|
288
|
+
return override ? validateEndpointUrl(override) : DEFAULT_INGEST_ENDPOINT;
|
|
289
|
+
}
|
|
290
|
+
/** Post one batch of sprites. Shares the transient-retry policy with pulls. */
|
|
291
|
+
export async function pushSprites(sprites) {
|
|
292
|
+
const url = resolveIngestEndpoint();
|
|
293
|
+
return retryTransient('push', async () => {
|
|
294
|
+
const { headers, requestId } = buildHeaders({ 'Content-Type': 'application/json' });
|
|
295
|
+
const res = await safeFetch(url, {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers,
|
|
298
|
+
body: JSON.stringify({ sprites }),
|
|
299
|
+
});
|
|
300
|
+
const serverRequestId = res.headers.get('x-request-id') ?? requestId;
|
|
301
|
+
if (res.ok) {
|
|
302
|
+
const data = (await res.json());
|
|
303
|
+
const results = data && typeof data === 'object' ? data.results : undefined;
|
|
304
|
+
if (!Array.isArray(results)) {
|
|
305
|
+
throw new ApiError(502, withRequestId('push: unexpected server response shape (results missing).', serverRequestId), serverRequestId);
|
|
306
|
+
}
|
|
307
|
+
return results;
|
|
308
|
+
}
|
|
309
|
+
const bodyText = await res.text();
|
|
310
|
+
throw new ApiError(res.status, withRequestId(friendly(res.status, bodyText, 'push'), serverRequestId), serverRequestId, retryAfterMsFromResponse(res));
|
|
311
|
+
});
|
|
312
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -8,6 +8,7 @@ import { detectProjectKind, hasPackageJson } from '../util/framework.js';
|
|
|
8
8
|
import { safeFetch } from '../util/security.js';
|
|
9
9
|
import { authHeaders } from '../util/authHeaders.js';
|
|
10
10
|
import { CLI_VERSION } from '../version.js';
|
|
11
|
+
import { cmd } from '../util/invoke.js';
|
|
11
12
|
export async function doctorCommand(opts = {}) {
|
|
12
13
|
const report = await collectDoctorReport(opts);
|
|
13
14
|
if (opts.json) {
|
|
@@ -69,18 +70,18 @@ export async function collectDoctorReport(opts = {}) {
|
|
|
69
70
|
: await probeManifest(endpoint);
|
|
70
71
|
const suggestions = [];
|
|
71
72
|
if (keySource === 'none')
|
|
72
|
-
suggestions.push(
|
|
73
|
+
suggestions.push(`Run \`${cmd('login')}\` to store your API key.`);
|
|
73
74
|
if (!config)
|
|
74
|
-
suggestions.push(
|
|
75
|
+
suggestions.push(`Run \`${cmd('start')}\` (or \`${cmd('init')}\`) to bootstrap.`);
|
|
75
76
|
if (config && !watchScript && pkgFound) {
|
|
76
|
-
suggestions.push(
|
|
77
|
+
suggestions.push(`Add a \`magicpixel:watch\` npm script (or run \`${cmd('sync')} -w\`) for live sync.`);
|
|
77
78
|
}
|
|
78
79
|
if (state.lastError)
|
|
79
|
-
suggestions.push(
|
|
80
|
+
suggestions.push(`Last sync surfaced an error — run \`${cmd('repair')}\` to self-heal.`);
|
|
80
81
|
// Only emit network-derived suggestions when we actually probed.
|
|
81
82
|
if ('ok' in network && !network.ok) {
|
|
82
83
|
if (network.status === 401 || network.status === 403) {
|
|
83
|
-
suggestions.push(
|
|
84
|
+
suggestions.push(`API rejected the key — run \`${cmd('login')}\` with a fresh key from settings.`);
|
|
84
85
|
}
|
|
85
86
|
else if (network.status === null) {
|
|
86
87
|
suggestions.push('Could not reach MagicPixel — check internet/proxy, or re-run with --offline.');
|
|
@@ -197,7 +198,7 @@ export function renderDoctorReport(r) {
|
|
|
197
198
|
? 'environment variable (MAGICPIXEL_API_KEY)'
|
|
198
199
|
: r.key.source === 'credentials-file'
|
|
199
200
|
? '.magicpixel/credentials'
|
|
200
|
-
: kleur.yellow(
|
|
201
|
+
: kleur.yellow(`none — run \`${cmd('login')}\``);
|
|
201
202
|
push(`API key source: ${sourceLabel}`);
|
|
202
203
|
push(`Watch script: ${r.watchScript ? `"${r.watchScript}"` : kleur.dim('not configured')}`);
|
|
203
204
|
push(`Last sync: ${r.state.lastSync ? new Date(r.state.lastSync).toLocaleString() : kleur.dim('never')}`);
|
|
@@ -211,7 +212,7 @@ export function renderDoctorReport(r) {
|
|
|
211
212
|
if ('skipped' in r.network) {
|
|
212
213
|
const reason = r.network.skipped === 'offline'
|
|
213
214
|
? 'skipped (--offline)'
|
|
214
|
-
:
|
|
215
|
+
: `skipped (no API key — run \`${cmd('login')}\`)`;
|
|
215
216
|
push(`Network probe: ${kleur.dim(reason)}`);
|
|
216
217
|
}
|
|
217
218
|
else if (r.network.ok) {
|
package/dist/commands/init.js
CHANGED
|
@@ -8,6 +8,7 @@ import { configPath, defaultConfig, saveConfig } from '../config.js';
|
|
|
8
8
|
import { detectProjectKind, suggestOutDir, hasPackageJson, isStaticOutDir, supportsTypedIndex, isEngineKind, } from '../util/framework.js';
|
|
9
9
|
import { assertSafeOutDir } from '../util/security.js';
|
|
10
10
|
import { atomicWrite } from '../util/atomicWrite.js';
|
|
11
|
+
import { cmd } from '../util/invoke.js';
|
|
11
12
|
const WATCH_SCRIPT_NAME = 'magicpixel:watch';
|
|
12
13
|
const WATCH_SCRIPT_CMD = 'magicpixel sync --watch';
|
|
13
14
|
export async function initCommand(opts) {
|
|
@@ -100,13 +101,13 @@ export async function initCommand(opts) {
|
|
|
100
101
|
if (interactive) {
|
|
101
102
|
console.log();
|
|
102
103
|
console.log(kleur.bold('Next:'));
|
|
103
|
-
console.log(` 1. ${kleur.dim('
|
|
104
|
-
console.log(` 2. ${kleur.dim('
|
|
104
|
+
console.log(` 1. ${kleur.dim(cmd('login'))} ${kleur.dim('# paste your key from https://magicpixel.art/settings')}`);
|
|
105
|
+
console.log(` 2. ${kleur.dim(cmd('sync'))} ${kleur.dim('# downloads your assets')}`);
|
|
105
106
|
if (watchScriptAdded) {
|
|
106
107
|
console.log(` 3. ${kleur.dim('npm run')} ${WATCH_SCRIPT_NAME} ${kleur.dim('# keeps assets fresh while you edit')}`);
|
|
107
108
|
}
|
|
108
109
|
else {
|
|
109
|
-
console.log(` 3. ${kleur.dim('
|
|
110
|
+
console.log(` 3. ${kleur.dim(`${cmd('sync')} --watch`)} ${kleur.dim('# keeps assets fresh while you edit')}`);
|
|
110
111
|
}
|
|
111
112
|
console.log();
|
|
112
113
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `magicpixel push` — disk → MagicPixel (the Unity → MagicPixel half of
|
|
3
|
+
* two-way sync).
|
|
4
|
+
*
|
|
5
|
+
* Walks `outDir`, hashes every PNG, and sends the ones that changed (or are
|
|
6
|
+
* brand new on disk) to `POST /api/public/integration/ingest`:
|
|
7
|
+
*
|
|
8
|
+
* - known key + changed sha → write back into that artboard, using the sha
|
|
9
|
+
* the CLI last pulled as the conflict baseline. If the cloud moved on the
|
|
10
|
+
* server refuses with `conflict` and nothing is lost.
|
|
11
|
+
* - unknown key → adopt: the server mirrors the disk folders into
|
|
12
|
+
* the library and appends/creates the document.
|
|
13
|
+
*/
|
|
14
|
+
import kleur from 'kleur';
|
|
15
|
+
import ora from 'ora';
|
|
16
|
+
import { mkdir, readFile, rename } from 'node:fs/promises';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
import { relative, resolve } from 'node:path';
|
|
19
|
+
import { loadConfig, loadState, saveState } from '../config.js';
|
|
20
|
+
import { PUSH_BATCH_SIZE, pushSprites } from '../api.js';
|
|
21
|
+
import { fileSha256 } from '../util/hash.js';
|
|
22
|
+
import { assetDiskPathFromKey, walkOutDirPngs } from '../util/paths.js';
|
|
23
|
+
import { planPush } from '../util/pushPlan.js';
|
|
24
|
+
import { cmd } from '../util/invoke.js';
|
|
25
|
+
import { detectProjectKind } from '../util/framework.js';
|
|
26
|
+
import { importNearbyGamePngs } from '../util/gameScan.js';
|
|
27
|
+
export async function pushCommand(opts = {}) {
|
|
28
|
+
await runPush(opts);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Scan local PNGs and upload new/changed ones into the MagicPixel library.
|
|
32
|
+
* Used by `magicpixel push` and by `sync` (so connect/watch imports game
|
|
33
|
+
* sprites without a second command).
|
|
34
|
+
*/
|
|
35
|
+
export async function runPush(opts = {}) {
|
|
36
|
+
const config = await loadConfig();
|
|
37
|
+
const state = await loadState();
|
|
38
|
+
return runPushWith(config, state, opts);
|
|
39
|
+
}
|
|
40
|
+
export async function runPushWith(config, state, opts = {}) {
|
|
41
|
+
const quiet = opts.quiet === true;
|
|
42
|
+
const empty = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0 };
|
|
43
|
+
const spinner = quiet ? null : ora('Scanning local sprites…').start();
|
|
44
|
+
const kind = await detectProjectKind();
|
|
45
|
+
const scan = await importNearbyGamePngs(config.outDir, kind);
|
|
46
|
+
const imported = scan.copied;
|
|
47
|
+
const disk = await walkOutDirPngs(config.outDir);
|
|
48
|
+
const absByKey = new Map(disk.map((a) => [a.key, a.abs]));
|
|
49
|
+
const candidates = [];
|
|
50
|
+
for (const asset of disk) {
|
|
51
|
+
const sha = await fileSha256(asset.abs);
|
|
52
|
+
if (!sha)
|
|
53
|
+
continue;
|
|
54
|
+
candidates.push({
|
|
55
|
+
key: asset.key,
|
|
56
|
+
segments: asset.key.split('/'),
|
|
57
|
+
diskSha256: sha,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const actions = planPush(candidates, state.synced, { flatten: opts.flatten });
|
|
61
|
+
const skipped = actions.filter((a) => a.kind === 'skip' && a.reason !== 'legacy').length;
|
|
62
|
+
const legacy = actions.filter((a) => a.kind === 'skip' && a.reason === 'legacy');
|
|
63
|
+
const flattenBlocked = actions.filter((a) => a.kind === 'needs-flatten');
|
|
64
|
+
const sendable = actions.filter((a) => a.kind === 'update' || a.kind === 'adopt');
|
|
65
|
+
spinner?.succeed(`Local sprites: ${candidates.length} · to push ${sendable.length} · unchanged ${skipped}` +
|
|
66
|
+
(imported ? ` · imported ${imported}` : ''));
|
|
67
|
+
if (legacy.length > 0 && !quiet) {
|
|
68
|
+
console.log();
|
|
69
|
+
console.log(kleur.yellow(`! ${legacy.length} sprite${legacy.length === 1 ? '' : 's'} came from a legacy single-image file — skipped.`));
|
|
70
|
+
console.log(kleur.dim(` Fix: open the file in MagicPixel and save it once, then re-run \`${cmd('sync')}\`.`));
|
|
71
|
+
}
|
|
72
|
+
if (flattenBlocked.length > 0 && !quiet) {
|
|
73
|
+
console.log();
|
|
74
|
+
console.log(kleur.yellow(`! ${flattenBlocked.length} sprite${flattenBlocked.length === 1 ? '' : 's'} would flatten a multi-layer artboard — skipped.`));
|
|
75
|
+
for (const a of flattenBlocked.slice(0, 10)) {
|
|
76
|
+
console.log(kleur.dim(` ${a.key} (${'layers' in a ? a.layers : '?'} layers)`));
|
|
77
|
+
}
|
|
78
|
+
console.log(kleur.dim(' Fix: re-run with --flatten to replace them with the flat disk image.'));
|
|
79
|
+
}
|
|
80
|
+
if (sendable.length === 0) {
|
|
81
|
+
if (!quiet)
|
|
82
|
+
console.log(kleur.green('✓ nothing to push.'));
|
|
83
|
+
return { ...empty, imported, unchanged: skipped };
|
|
84
|
+
}
|
|
85
|
+
if (opts.dryRun) {
|
|
86
|
+
if (!quiet) {
|
|
87
|
+
console.log();
|
|
88
|
+
for (const a of sendable) {
|
|
89
|
+
const verb = a.kind === 'update' ? kleur.yellow('~') : kleur.green('+');
|
|
90
|
+
console.log(` ${verb} ${a.key}${a.kind === 'adopt' ? kleur.dim(' (new)') : ''}`);
|
|
91
|
+
}
|
|
92
|
+
console.log(kleur.dim('--dry-run: nothing sent.'));
|
|
93
|
+
}
|
|
94
|
+
return { ...empty, imported };
|
|
95
|
+
}
|
|
96
|
+
const outRoot = resolve(process.cwd(), config.outDir);
|
|
97
|
+
const shaByKey = new Map(candidates.map((c) => [c.key, c.diskSha256]));
|
|
98
|
+
const sprites = [];
|
|
99
|
+
for (const a of sendable) {
|
|
100
|
+
const abs = absByKey.get(a.key);
|
|
101
|
+
if (!abs)
|
|
102
|
+
continue;
|
|
103
|
+
const pngBase64 = (await readFile(abs)).toString('base64');
|
|
104
|
+
const diskSha256 = shaByKey.get(a.key);
|
|
105
|
+
if (a.kind === 'update') {
|
|
106
|
+
sprites.push({
|
|
107
|
+
key: a.key,
|
|
108
|
+
pngBase64,
|
|
109
|
+
diskSha256,
|
|
110
|
+
assetId: a.assetId,
|
|
111
|
+
layerIdx: a.layerIdx,
|
|
112
|
+
baseSha256: a.baseSha256,
|
|
113
|
+
flatten: opts.flatten === true,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else if (a.kind === 'adopt') {
|
|
117
|
+
sprites.push({ key: a.key, pngBase64, diskSha256, path: a.path, pathNames: a.pathNames, name: a.name });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const progress = quiet ? null : ora(`Pushing 0/${sprites.length}…`).start();
|
|
121
|
+
const results = [];
|
|
122
|
+
for (let i = 0; i < sprites.length; i += PUSH_BATCH_SIZE) {
|
|
123
|
+
const batch = sprites.slice(i, i + PUSH_BATCH_SIZE);
|
|
124
|
+
const batchResults = await pushSprites(batch);
|
|
125
|
+
results.push(...batchResults);
|
|
126
|
+
if (progress)
|
|
127
|
+
progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
|
|
128
|
+
}
|
|
129
|
+
const counts = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0 };
|
|
130
|
+
const nextSynced = { ...(state.synced ?? {}) };
|
|
131
|
+
for (const r of results) {
|
|
132
|
+
counts[r.status] = (counts[r.status] ?? 0) + 1;
|
|
133
|
+
const stateKey = r.cloudKey && r.cloudKey !== r.key ? r.cloudKey : r.key;
|
|
134
|
+
if (stateKey !== r.key && (r.status === 'created' || r.status === 'updated')) {
|
|
135
|
+
await renameLocalSprite(config.outDir, r.key, stateKey);
|
|
136
|
+
}
|
|
137
|
+
if ((r.status === 'created' || r.status === 'updated' || r.status === 'unchanged') && r.assetId && r.sha256) {
|
|
138
|
+
if (stateKey !== r.key)
|
|
139
|
+
delete nextSynced[r.key];
|
|
140
|
+
nextSynced[stateKey] = {
|
|
141
|
+
assetId: r.assetId,
|
|
142
|
+
layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (nextSynced[stateKey]?.layerIdx ?? 0),
|
|
143
|
+
sha256: r.sha256,
|
|
144
|
+
layers: 1,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
await saveState({ ...state, synced: nextSynced });
|
|
149
|
+
const failed = counts.conflict + counts.error;
|
|
150
|
+
const summary = `created ${counts.created}, updated ${counts.updated}, unchanged ${counts.unchanged}` +
|
|
151
|
+
(counts.conflict ? `, conflicts ${counts.conflict}` : '') +
|
|
152
|
+
(counts.error ? `, errors ${counts.error}` : '');
|
|
153
|
+
if (progress) {
|
|
154
|
+
if (failed)
|
|
155
|
+
progress.warn(`Push finished with issues. ${summary}`);
|
|
156
|
+
else
|
|
157
|
+
progress.succeed(`Pushed. ${summary}`);
|
|
158
|
+
}
|
|
159
|
+
if (!quiet) {
|
|
160
|
+
for (const r of results) {
|
|
161
|
+
if (r.status === 'conflict') {
|
|
162
|
+
console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
|
|
163
|
+
}
|
|
164
|
+
else if (r.status === 'error') {
|
|
165
|
+
console.log(` ${kleur.red('!')} ${r.key}: ${r.message ?? 'push failed'}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (counts.created > 0) {
|
|
169
|
+
console.log(kleur.dim(` ${counts.created} new sprite${counts.created === 1 ? '' : 's'} now live in your MagicPixel library ` +
|
|
170
|
+
`(folders mirrored from ${relative(process.cwd(), outRoot)}). Refresh the library to see them.`));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (failed)
|
|
174
|
+
process.exitCode = 1;
|
|
175
|
+
return { ...counts, imported };
|
|
176
|
+
}
|
|
177
|
+
function conflictHint(reason) {
|
|
178
|
+
switch (reason) {
|
|
179
|
+
case 'cloud-changed':
|
|
180
|
+
return `changed in MagicPixel since the last sync — run \`${cmd('sync')}\` first, then re-push.`;
|
|
181
|
+
case 'would-flatten':
|
|
182
|
+
return 'artboard has multiple layers or frames — re-run with --flatten to replace it.';
|
|
183
|
+
case 'legacy-document':
|
|
184
|
+
return 'document predates layer storage — open and save it once in MagicPixel.';
|
|
185
|
+
case 'not-found':
|
|
186
|
+
return `artboard no longer exists in MagicPixel — run \`${cmd('sync')}\` to refresh.`;
|
|
187
|
+
default:
|
|
188
|
+
return `conflict — run \`${cmd('sync')}\` and try again.`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Move a pushed sprite (and its `.meta` sidecar) to the disk path matching the
|
|
193
|
+
* key the cloud assigned. Best effort: a failed move only means the next sync
|
|
194
|
+
* re-downloads the sprite under its cloud name.
|
|
195
|
+
*/
|
|
196
|
+
async function renameLocalSprite(outDir, fromKey, toKey) {
|
|
197
|
+
const from = assetDiskPathFromKey(outDir, fromKey);
|
|
198
|
+
const to = assetDiskPathFromKey(outDir, toKey);
|
|
199
|
+
try {
|
|
200
|
+
await mkdir(dirname(to), { recursive: true });
|
|
201
|
+
await rename(from, to);
|
|
202
|
+
await rename(`${from}.meta`, `${to}.meta`).catch(() => { });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Leave the file where it is; sync reconciles.
|
|
206
|
+
}
|
|
207
|
+
}
|
package/dist/commands/repair.js
CHANGED
|
@@ -8,9 +8,10 @@ import { loadConfig, statePath, getApiKey } from '../config.js';
|
|
|
8
8
|
import { assertKeyValid } from '../util/auth.js';
|
|
9
9
|
import { listEmptyDirs, pruneEmptyDirs } from '../util/paths.js';
|
|
10
10
|
import { syncCommand } from './sync.js';
|
|
11
|
+
import { cmd } from '../util/invoke.js';
|
|
11
12
|
/**
|
|
12
13
|
* One-shot self-healing command. Runs the standard "turn it off and on
|
|
13
|
-
* again" recovery sequence so support can say "run `npx
|
|
14
|
+
* again" recovery sequence so support can say "run `npx @magicpixelart/cli repair`":
|
|
14
15
|
*
|
|
15
16
|
* 1. Validate the stored API key.
|
|
16
17
|
* 2. Quarantine `state.json` so the next sync re-derives everything.
|
|
@@ -117,7 +118,7 @@ export async function repairCommand(opts = {}) {
|
|
|
117
118
|
console.log(kleur.dim(' (state was not reset — this will run a normal full sync, not a recovery)'));
|
|
118
119
|
}
|
|
119
120
|
if (opts.dryRun) {
|
|
120
|
-
console.log(` ${kleur.dim(
|
|
121
|
+
console.log(` ${kleur.dim(`would run \`${cmd('sync')} --full\` (skipped in --dry-run)`)}`);
|
|
121
122
|
console.log();
|
|
122
123
|
if (skippedStep2) {
|
|
123
124
|
console.log(kleur.yellow('! repair plan would skip the state reset. Re-run with --yes to apply it.'));
|
|
@@ -137,7 +138,7 @@ export async function repairCommand(opts = {}) {
|
|
|
137
138
|
await syncCommand({ full: true, prune: true });
|
|
138
139
|
console.log();
|
|
139
140
|
if ((process.exitCode ?? 0) > exitBefore) {
|
|
140
|
-
console.log(kleur.yellow(
|
|
141
|
+
console.log(kleur.yellow(`! repair completed with errors — re-run \`${cmd('sync')}\` to retry the failed downloads.`));
|
|
141
142
|
}
|
|
142
143
|
else {
|
|
143
144
|
console.log(kleur.green('✓ repair complete.'));
|
package/dist/commands/start.js
CHANGED
|
@@ -10,24 +10,20 @@ import { findKeyInDotenv, readCredentialsSync, writeCredentials } from '../util/
|
|
|
10
10
|
import { initCommand } from './init.js';
|
|
11
11
|
import { loginCommand } from './login.js';
|
|
12
12
|
import { syncCommand } from './sync.js';
|
|
13
|
+
import { cmd } from '../util/invoke.js';
|
|
13
14
|
/**
|
|
14
15
|
* One-command bootstrap for new users. Runs init → key prompt → first sync →
|
|
15
16
|
* prints copy-pasteable watch instructions. Designed so a non-technical user
|
|
16
|
-
* can paste `npx
|
|
17
|
-
*
|
|
17
|
+
* can paste `npx @magicpixelart/cli start` and end up with game sprites in
|
|
18
|
+
* MagicPixel (and flagged sprites back on disk) plus a watch script.
|
|
18
19
|
*/
|
|
19
20
|
export async function startCommand(opts = {}) {
|
|
20
21
|
console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
|
|
21
|
-
console.log(kleur.dim(' This
|
|
22
|
+
console.log(kleur.dim(' This connects your game project so existing sprites show up in MagicPixel.'));
|
|
22
23
|
console.log();
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (isEngineKind(kind)) {
|
|
27
|
-
console.log(kleur.yellow(` Detected ${kind}. \`magicpixel start\` is JS-only; run ` +
|
|
28
|
-
'`npx magicpixel init && npx magicpixel login && npx magicpixel sync --watch` instead.'));
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
24
|
+
const kind = await detectProjectKind();
|
|
25
|
+
// Engine projects have no package.json requirement. JS projects still need one.
|
|
26
|
+
if (!hasPackageJson() && !isEngineKind(kind)) {
|
|
31
27
|
console.log(kleur.yellow(' MagicPixel needs a JavaScript project (package.json) to sync into.\n' +
|
|
32
28
|
' Run this inside your project folder — the one with package.json.'));
|
|
33
29
|
return;
|
|
@@ -46,7 +42,7 @@ export async function startCommand(opts = {}) {
|
|
|
46
42
|
console.log(kleur.yellow(` Existing magicpixel.json is invalid:`));
|
|
47
43
|
for (const line of msg.split('\n'))
|
|
48
44
|
console.log(kleur.yellow(` ${line}`));
|
|
49
|
-
console.log(kleur.dim(
|
|
45
|
+
console.log(kleur.dim(` Re-run \`${cmd('start')} --force\` to overwrite it, or fix the file by hand.`));
|
|
50
46
|
return;
|
|
51
47
|
}
|
|
52
48
|
}
|
|
@@ -73,7 +69,7 @@ export async function startCommand(opts = {}) {
|
|
|
73
69
|
// failures without throwing — snapshot around the call so we don't
|
|
74
70
|
// print a misleading green "you're set up" over a half-failed run.
|
|
75
71
|
console.log();
|
|
76
|
-
console.log(kleur.bold('Step:
|
|
72
|
+
console.log(kleur.bold('Step: import + sync'));
|
|
77
73
|
const exitBefore = process.exitCode ?? 0;
|
|
78
74
|
await syncCommand({ full: true });
|
|
79
75
|
const firstSyncFailed = (process.exitCode ?? 0) > exitBefore;
|
|
@@ -84,7 +80,7 @@ export async function startCommand(opts = {}) {
|
|
|
84
80
|
console.log();
|
|
85
81
|
if (firstSyncFailed) {
|
|
86
82
|
console.log(kleur.yellow('! first sync completed with errors.'));
|
|
87
|
-
console.log(kleur.dim(
|
|
83
|
+
console.log(kleur.dim(` Re-run \`${cmd('sync')}\` to retry the failed downloads, or \`${cmd('doctor')}\` to diagnose.`));
|
|
88
84
|
}
|
|
89
85
|
else {
|
|
90
86
|
console.log(kleur.bold('You\'re set up. ✨'));
|
|
@@ -95,11 +91,11 @@ export async function startCommand(opts = {}) {
|
|
|
95
91
|
console.log(` ${kleur.green('▶')} ${kleur.bold('npm run magicpixel:watch')} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
|
|
96
92
|
}
|
|
97
93
|
else {
|
|
98
|
-
console.log(` ${kleur.green('▶')} ${kleur.bold('
|
|
94
|
+
console.log(` ${kleur.green('▶')} ${kleur.bold(`${cmd('sync')} --watch`)} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
|
|
99
95
|
}
|
|
100
96
|
console.log();
|
|
101
97
|
if (await hasDevScript()) {
|
|
102
|
-
const watchCmd = hasWatch ? 'npm run magicpixel:watch' : '
|
|
98
|
+
const watchCmd = hasWatch ? 'npm run magicpixel:watch' : `${cmd('sync')} --watch`;
|
|
103
99
|
console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));
|
|
104
100
|
console.log(kleur.dim(` \`npx concurrently "npm run dev" "${watchCmd}"\``));
|
|
105
101
|
console.log();
|
package/dist/commands/status.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileSha256 } from '../util/hash.js';
|
|
|
5
5
|
import { assetDiskPath } from '../util/paths.js';
|
|
6
6
|
import { readKeyForDisplay } from '../util/credentials.js';
|
|
7
7
|
import { createLimit } from '../util/limit.js';
|
|
8
|
+
import { cmd } from '../util/invoke.js';
|
|
8
9
|
export async function statusCommand() {
|
|
9
10
|
// Config is optional — a brand-new user can `magicpixel status` to inspect
|
|
10
11
|
// their key/env before bothering with init. Mirrors `whoami` / `login`.
|
|
@@ -25,7 +26,7 @@ export async function statusCommand() {
|
|
|
25
26
|
}
|
|
26
27
|
else {
|
|
27
28
|
console.log(kleur.yellow(` magicpixel.json: ${configErr ?? 'missing'}`));
|
|
28
|
-
console.log(kleur.dim(` Run \`
|
|
29
|
+
console.log(kleur.dim(` Run \`${cmd('start')}\` to create one.`));
|
|
29
30
|
}
|
|
30
31
|
console.log();
|
|
31
32
|
const state = await loadState();
|
|
@@ -40,7 +41,7 @@ export async function statusCommand() {
|
|
|
40
41
|
// env var and falsely reported "key not set" for logged-in users.
|
|
41
42
|
const stored = readKeyForDisplay();
|
|
42
43
|
if (!stored) {
|
|
43
|
-
console.log(kleur.red(
|
|
44
|
+
console.log(kleur.red(` no API key — run \`${cmd('login')}\` or set MAGICPIXEL_API_KEY`));
|
|
44
45
|
return;
|
|
45
46
|
}
|
|
46
47
|
const sourceLabel = stored.source === 'env' ? 'env (MAGICPIXEL_API_KEY)' : 'file (.magicpixel/credentials)';
|