@magicpixelart/cli 0.5.6 → 0.5.7
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 +28 -0
- package/README.md +9 -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 +183 -0
- package/dist/commands/repair.js +4 -3
- package/dist/commands/start.js +8 -7
- package/dist/commands/status.js +3 -2
- package/dist/commands/sync.js +141 -4
- package/dist/config.js +18 -4
- package/dist/index.js +8 -0
- package/dist/util/emitIndex.js +3 -2
- package/dist/util/invoke.js +47 -0
- package/dist/util/prunePolicy.js +33 -0
- package/dist/util/pushPlan.js +74 -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,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.7] — 2026-08-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`magicpixel push`.** Two-way sync: walk `outDir`, hash local PNGs, and
|
|
15
|
+
upload changed or new sprites back to MagicPixel (`--dry-run`, `--flatten`).
|
|
16
|
+
Cloud conflicts refuse instead of overwriting; new on-disk files are adopted
|
|
17
|
+
into the library. Prune no longer deletes pending local sprites.
|
|
18
|
+
- **Unity `.meta` files.** Sync writes TextureImporter `.meta` next to PNGs
|
|
19
|
+
(point filter, no compression, deterministic GUIDs). `unityPpu` (default 32)
|
|
20
|
+
and `unitySyncAll` in `magicpixel.json`. By default only artboards flagged
|
|
21
|
+
"Sync to Unity" in the editor are pulled.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **`npx magicpixel …` dead end.** `magicpixel` is only the bin name — the
|
|
26
|
+
published package is `@magicpixelart/cli` — so hints like "run `npx
|
|
27
|
+
magicpixel init`" 404ed (`'magicpixel@*' is not in this registry`) for anyone
|
|
28
|
+
driving the CLI purely through `npx @magicpixelart/cli …`. Every user-facing
|
|
29
|
+
command hint now goes through a single `cmd()` helper that prints the short
|
|
30
|
+
`magicpixel <cmd>` form only when the package resolves from the project's
|
|
31
|
+
`node_modules`, and the always-correct `npx @magicpixelart/cli <cmd>` form
|
|
32
|
+
otherwise.
|
|
33
|
+
- **Missing-config hint points at `start`.** A config-less project is now told
|
|
34
|
+
to run `start` (init + login + first sync) rather than bare `init`, which left
|
|
35
|
+
users unauthenticated with nothing on disk. `status` uses the same hint
|
|
36
|
+
(it previously hardcoded `npx @magicpixelart/cli init`).
|
|
37
|
+
|
|
10
38
|
## [0.5.6] — 2026-08-21
|
|
11
39
|
|
|
12
40
|
### 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,8 @@ 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. |
|
|
138
141
|
| `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
|
|
139
142
|
|
|
140
143
|
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,183 @@
|
|
|
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
|
+
export async function pushCommand(opts) {
|
|
26
|
+
const config = await loadConfig();
|
|
27
|
+
const state = await loadState();
|
|
28
|
+
const spinner = ora('Scanning local sprites…').start();
|
|
29
|
+
const disk = await walkOutDirPngs(config.outDir);
|
|
30
|
+
const candidates = [];
|
|
31
|
+
for (const asset of disk) {
|
|
32
|
+
const sha = await fileSha256(asset.abs);
|
|
33
|
+
if (!sha)
|
|
34
|
+
continue;
|
|
35
|
+
candidates.push({
|
|
36
|
+
key: asset.key,
|
|
37
|
+
segments: asset.key.split('/'),
|
|
38
|
+
diskSha256: sha,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const actions = planPush(candidates, state.synced, { flatten: opts.flatten });
|
|
42
|
+
const skipped = actions.filter((a) => a.kind === 'skip' && a.reason !== 'legacy').length;
|
|
43
|
+
const legacy = actions.filter((a) => a.kind === 'skip' && a.reason === 'legacy');
|
|
44
|
+
const flattenBlocked = actions.filter((a) => a.kind === 'needs-flatten');
|
|
45
|
+
const sendable = actions.filter((a) => a.kind === 'update' || a.kind === 'adopt');
|
|
46
|
+
spinner.succeed(`Local sprites: ${candidates.length} · to push ${sendable.length} · unchanged ${skipped}`);
|
|
47
|
+
if (legacy.length > 0) {
|
|
48
|
+
console.log();
|
|
49
|
+
console.log(kleur.yellow(`! ${legacy.length} sprite${legacy.length === 1 ? '' : 's'} came from a legacy single-image file — skipped.`));
|
|
50
|
+
console.log(kleur.dim(` Fix: open the file in MagicPixel and save it once, then re-run \`${cmd('sync')}\`.`));
|
|
51
|
+
}
|
|
52
|
+
if (flattenBlocked.length > 0) {
|
|
53
|
+
console.log();
|
|
54
|
+
console.log(kleur.yellow(`! ${flattenBlocked.length} sprite${flattenBlocked.length === 1 ? '' : 's'} would flatten a multi-layer artboard — skipped.`));
|
|
55
|
+
for (const a of flattenBlocked.slice(0, 10)) {
|
|
56
|
+
console.log(kleur.dim(` ${a.key} (${'layers' in a ? a.layers : '?'} layers)`));
|
|
57
|
+
}
|
|
58
|
+
console.log(kleur.dim(' Fix: re-run with --flatten to replace them with the flat disk image.'));
|
|
59
|
+
}
|
|
60
|
+
if (sendable.length === 0) {
|
|
61
|
+
console.log(kleur.green('✓ nothing to push.'));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (opts.dryRun) {
|
|
65
|
+
console.log();
|
|
66
|
+
for (const a of sendable) {
|
|
67
|
+
const verb = a.kind === 'update' ? kleur.yellow('~') : kleur.green('+');
|
|
68
|
+
console.log(` ${verb} ${a.key}${a.kind === 'adopt' ? kleur.dim(' (new)') : ''}`);
|
|
69
|
+
}
|
|
70
|
+
console.log(kleur.dim('--dry-run: nothing sent.'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const outRoot = resolve(process.cwd(), config.outDir);
|
|
74
|
+
const shaByKey = new Map(candidates.map((c) => [c.key, c.diskSha256]));
|
|
75
|
+
const sprites = [];
|
|
76
|
+
for (const a of sendable) {
|
|
77
|
+
const abs = resolve(outRoot, `${a.key}.png`);
|
|
78
|
+
const pngBase64 = (await readFile(abs)).toString('base64');
|
|
79
|
+
const diskSha256 = shaByKey.get(a.key);
|
|
80
|
+
if (a.kind === 'update') {
|
|
81
|
+
sprites.push({
|
|
82
|
+
key: a.key,
|
|
83
|
+
pngBase64,
|
|
84
|
+
diskSha256,
|
|
85
|
+
assetId: a.assetId,
|
|
86
|
+
layerIdx: a.layerIdx,
|
|
87
|
+
baseSha256: a.baseSha256,
|
|
88
|
+
flatten: opts.flatten === true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
else if (a.kind === 'adopt') {
|
|
92
|
+
sprites.push({ key: a.key, pngBase64, diskSha256, path: a.path, pathNames: a.pathNames, name: a.name });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const progress = ora(`Pushing 0/${sprites.length}…`).start();
|
|
96
|
+
const results = [];
|
|
97
|
+
for (let i = 0; i < sprites.length; i += PUSH_BATCH_SIZE) {
|
|
98
|
+
const batch = sprites.slice(i, i + PUSH_BATCH_SIZE);
|
|
99
|
+
const batchResults = await pushSprites(batch);
|
|
100
|
+
results.push(...batchResults);
|
|
101
|
+
progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
|
|
102
|
+
}
|
|
103
|
+
const counts = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0 };
|
|
104
|
+
const nextSynced = { ...(state.synced ?? {}) };
|
|
105
|
+
for (const r of results) {
|
|
106
|
+
counts[r.status] = (counts[r.status] ?? 0) + 1;
|
|
107
|
+
// The server may have stored the document under a different slug than the
|
|
108
|
+
// disk folder (collision suffix, slugified name). Move the local files to
|
|
109
|
+
// the key the manifest will report, or the next `sync` downloads a second
|
|
110
|
+
// copy alongside this one.
|
|
111
|
+
const stateKey = r.cloudKey && r.cloudKey !== r.key ? r.cloudKey : r.key;
|
|
112
|
+
if (stateKey !== r.key && (r.status === 'created' || r.status === 'updated')) {
|
|
113
|
+
await renameLocalSprite(config.outDir, r.key, stateKey);
|
|
114
|
+
}
|
|
115
|
+
// Store the sha the cloud now reports so the next `push` is a no-op and
|
|
116
|
+
// the next `sync` doesn't see a phantom change.
|
|
117
|
+
if ((r.status === 'created' || r.status === 'updated' || r.status === 'unchanged') && r.assetId && r.sha256) {
|
|
118
|
+
if (stateKey !== r.key)
|
|
119
|
+
delete nextSynced[r.key];
|
|
120
|
+
nextSynced[stateKey] = {
|
|
121
|
+
assetId: r.assetId,
|
|
122
|
+
layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (nextSynced[stateKey]?.layerIdx ?? 0),
|
|
123
|
+
sha256: r.sha256,
|
|
124
|
+
// A push always leaves the artboard single-layer (the flat disk image).
|
|
125
|
+
layers: 1,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
await saveState({ ...state, synced: nextSynced });
|
|
130
|
+
const failed = counts.conflict + counts.error;
|
|
131
|
+
const summary = `created ${counts.created}, updated ${counts.updated}, unchanged ${counts.unchanged}` +
|
|
132
|
+
(counts.conflict ? `, conflicts ${counts.conflict}` : '') +
|
|
133
|
+
(counts.error ? `, errors ${counts.error}` : '');
|
|
134
|
+
if (failed)
|
|
135
|
+
progress.warn(`Push finished with issues. ${summary}`);
|
|
136
|
+
else
|
|
137
|
+
progress.succeed(`Pushed. ${summary}`);
|
|
138
|
+
for (const r of results) {
|
|
139
|
+
if (r.status === 'conflict') {
|
|
140
|
+
console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
|
|
141
|
+
}
|
|
142
|
+
else if (r.status === 'error') {
|
|
143
|
+
console.log(` ${kleur.red('!')} ${r.key}: ${r.message ?? 'push failed'}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (counts.created > 0) {
|
|
147
|
+
console.log(kleur.dim(` ${counts.created} new sprite${counts.created === 1 ? '' : 's'} now live in your MagicPixel library ` +
|
|
148
|
+
`(folders mirrored from ${relative(process.cwd(), outRoot)}).`));
|
|
149
|
+
}
|
|
150
|
+
if (failed)
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
}
|
|
153
|
+
function conflictHint(reason) {
|
|
154
|
+
switch (reason) {
|
|
155
|
+
case 'cloud-changed':
|
|
156
|
+
return `changed in MagicPixel since the last sync — run \`${cmd('sync')}\` first, then re-push.`;
|
|
157
|
+
case 'would-flatten':
|
|
158
|
+
return 'artboard has multiple layers or frames — re-run with --flatten to replace it.';
|
|
159
|
+
case 'legacy-document':
|
|
160
|
+
return 'document predates layer storage — open and save it once in MagicPixel.';
|
|
161
|
+
case 'not-found':
|
|
162
|
+
return `artboard no longer exists in MagicPixel — run \`${cmd('sync')}\` to refresh.`;
|
|
163
|
+
default:
|
|
164
|
+
return `conflict — run \`${cmd('sync')}\` and try again.`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Move a pushed sprite (and its `.meta` sidecar) to the disk path matching the
|
|
169
|
+
* key the cloud assigned. Best effort: a failed move only means the next sync
|
|
170
|
+
* re-downloads the sprite under its cloud name.
|
|
171
|
+
*/
|
|
172
|
+
async function renameLocalSprite(outDir, fromKey, toKey) {
|
|
173
|
+
const from = assetDiskPathFromKey(outDir, fromKey);
|
|
174
|
+
const to = assetDiskPathFromKey(outDir, toKey);
|
|
175
|
+
try {
|
|
176
|
+
await mkdir(dirname(to), { recursive: true });
|
|
177
|
+
await rename(from, to);
|
|
178
|
+
await rename(`${from}.meta`, `${to}.meta`).catch(() => { });
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Leave the file where it is; sync reconciles.
|
|
182
|
+
}
|
|
183
|
+
}
|
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,10 +10,11 @@ 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
|
+
* can paste `npx @magicpixelart/cli start` and end up with sprites on disk and a watch
|
|
17
18
|
* script ready to run.
|
|
18
19
|
*/
|
|
19
20
|
export async function startCommand(opts = {}) {
|
|
@@ -24,8 +25,8 @@ export async function startCommand(opts = {}) {
|
|
|
24
25
|
if (!hasPackageJson()) {
|
|
25
26
|
const kind = await detectProjectKind();
|
|
26
27
|
if (isEngineKind(kind)) {
|
|
27
|
-
console.log(kleur.yellow(` Detected ${kind}.
|
|
28
|
-
'
|
|
28
|
+
console.log(kleur.yellow(` Detected ${kind}. \`${cmd('start')}\` is JS-only; run ` +
|
|
29
|
+
`\`${cmd('init')} && ${cmd('login')} && ${cmd('sync')} --watch\` instead.`));
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
31
32
|
console.log(kleur.yellow(' MagicPixel needs a JavaScript project (package.json) to sync into.\n' +
|
|
@@ -46,7 +47,7 @@ export async function startCommand(opts = {}) {
|
|
|
46
47
|
console.log(kleur.yellow(` Existing magicpixel.json is invalid:`));
|
|
47
48
|
for (const line of msg.split('\n'))
|
|
48
49
|
console.log(kleur.yellow(` ${line}`));
|
|
49
|
-
console.log(kleur.dim(
|
|
50
|
+
console.log(kleur.dim(` Re-run \`${cmd('start')} --force\` to overwrite it, or fix the file by hand.`));
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
53
|
}
|
|
@@ -84,7 +85,7 @@ export async function startCommand(opts = {}) {
|
|
|
84
85
|
console.log();
|
|
85
86
|
if (firstSyncFailed) {
|
|
86
87
|
console.log(kleur.yellow('! first sync completed with errors.'));
|
|
87
|
-
console.log(kleur.dim(
|
|
88
|
+
console.log(kleur.dim(` Re-run \`${cmd('sync')}\` to retry the failed downloads, or \`${cmd('doctor')}\` to diagnose.`));
|
|
88
89
|
}
|
|
89
90
|
else {
|
|
90
91
|
console.log(kleur.bold('You\'re set up. ✨'));
|
|
@@ -95,11 +96,11 @@ export async function startCommand(opts = {}) {
|
|
|
95
96
|
console.log(` ${kleur.green('▶')} ${kleur.bold('npm run magicpixel:watch')} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
|
|
96
97
|
}
|
|
97
98
|
else {
|
|
98
|
-
console.log(` ${kleur.green('▶')} ${kleur.bold('
|
|
99
|
+
console.log(` ${kleur.green('▶')} ${kleur.bold(`${cmd('sync')} --watch`)} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
|
|
99
100
|
}
|
|
100
101
|
console.log();
|
|
101
102
|
if (await hasDevScript()) {
|
|
102
|
-
const watchCmd = hasWatch ? 'npm run magicpixel:watch' : '
|
|
103
|
+
const watchCmd = hasWatch ? 'npm run magicpixel:watch' : `${cmd('sync')} --watch`;
|
|
103
104
|
console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));
|
|
104
105
|
console.log(kleur.dim(` \`npx concurrently "npm run dev" "${watchCmd}"\``));
|
|
105
106
|
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)';
|
package/dist/commands/sync.js
CHANGED
|
@@ -11,11 +11,16 @@ 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';
|
|
19
24
|
export async function syncCommand(opts) {
|
|
20
25
|
// Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
|
|
21
26
|
// crashed/killed CLI run before they pile up. Runs once per CLI invocation
|
|
@@ -180,14 +185,14 @@ async function watchLoop(opts) {
|
|
|
180
185
|
// support thread and we can correlate against edge function logs.
|
|
181
186
|
const idSuffix = apiErr?.requestId ? kleur.dim(` (request id: ${apiErr.requestId})`) : '';
|
|
182
187
|
console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Your key looks invalid or rotated.${idSuffix}`);
|
|
183
|
-
console.log(kleur.dim(
|
|
188
|
+
console.log(kleur.dim(` Fix: run \`${cmd('login')}\` (this watcher will keep retrying every 30s).`));
|
|
184
189
|
}
|
|
185
190
|
pausedForAuth = true;
|
|
186
191
|
consecutiveAuthFailures = decision.consecutiveAuthFailures;
|
|
187
192
|
backoffSec = decision.nextBackoffSec;
|
|
188
193
|
if (decision.giveUp) {
|
|
189
194
|
console.log(`${kleur.dim(timestamp())} ${kleur.red('✗')} Giving up after ${MAX_AUTH_FAILURES} consecutive auth failures.`);
|
|
190
|
-
console.log(kleur.dim(
|
|
195
|
+
console.log(kleur.dim(` Fix: run \`${cmd('login')}\` with a fresh key, then restart the watcher.`));
|
|
191
196
|
// Surface this to /admin/errors — persistent watcher auth failure
|
|
192
197
|
// means a key is mass-rejected (revoked, project deleted, edge
|
|
193
198
|
// misconfig) and we want visibility without waiting for a support
|
|
@@ -337,6 +342,63 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
337
342
|
spinner?.fail('Manifest fetch failed');
|
|
338
343
|
throw e;
|
|
339
344
|
}
|
|
345
|
+
// Unity projects sync only the artboards flagged "Sync to Unity" in the
|
|
346
|
+
// editor (parity with the in-app sync button). `unitySyncAll: true` in
|
|
347
|
+
// magicpixel.json opts back into everything.
|
|
348
|
+
const projectKind = await detectProjectKind();
|
|
349
|
+
// Files whose artboard is no longer flagged for Unity. Deleted even in
|
|
350
|
+
// incremental mode: un-checking the box in the editor must actually remove
|
|
351
|
+
// the sprite from the game project, not leave a stale copy behind.
|
|
352
|
+
const deselectedPaths = [];
|
|
353
|
+
// Sprites whose "Sync to Unity" flag the server omitted. Not downloaded
|
|
354
|
+
// (strict opt-in) but explicitly shielded from pruning: a transient server
|
|
355
|
+
// response must never delete art from someone's game project.
|
|
356
|
+
const unknownFlagPaths = new Set();
|
|
357
|
+
if (projectKind === 'Unity') {
|
|
358
|
+
const filtered = filterUnityManifest(manifest, { syncAll: config.unitySyncAll });
|
|
359
|
+
if (filtered.unknown.length > 0) {
|
|
360
|
+
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).`));
|
|
361
|
+
console.log(kleur.dim(` Fix: re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
|
|
362
|
+
}
|
|
363
|
+
const unknownSet = new Set(filtered.unknown);
|
|
364
|
+
for (const entry of manifest) {
|
|
365
|
+
if (filtered.entries.includes(entry))
|
|
366
|
+
continue;
|
|
367
|
+
const p = assetDiskPath(config.outDir, entry);
|
|
368
|
+
if (unknownSet.has(entry)) {
|
|
369
|
+
unknownFlagPaths.add(p);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (existsSync(p))
|
|
373
|
+
deselectedPaths.push(p);
|
|
374
|
+
}
|
|
375
|
+
if (filtered.noneFlagged) {
|
|
376
|
+
// Every artboard is explicitly opted out. Pruning here would delete the
|
|
377
|
+
// user's whole sprite folder off the back of an unchecked box, so bail
|
|
378
|
+
// out with a hint instead.
|
|
379
|
+
if (verbose) {
|
|
380
|
+
console.log();
|
|
381
|
+
console.log(kleur.yellow('! No artboards are flagged "Sync to Unity" — nothing to sync.'));
|
|
382
|
+
console.log(kleur.dim(' Fix: right-click an artboard in MagicPixel → "Sync to Unity",'));
|
|
383
|
+
console.log(kleur.dim(' or set "unitySyncAll": true in magicpixel.json to sync everything.'));
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
added: [],
|
|
387
|
+
modified: [],
|
|
388
|
+
removed: [],
|
|
389
|
+
unchanged: 0,
|
|
390
|
+
failed: 0,
|
|
391
|
+
bytesIn: 0,
|
|
392
|
+
bytesSaved: 0,
|
|
393
|
+
renamed: [],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const skipped = manifest.length - filtered.entries.length;
|
|
397
|
+
manifest = filtered.entries;
|
|
398
|
+
if (verbose && skipped > 0) {
|
|
399
|
+
console.log(kleur.dim(` unity → ${skipped} artboard${skipped === 1 ? '' : 's'} not flagged for sync (skipped)`));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
340
402
|
// Detect renames: same id, different key vs prior snapshot.
|
|
341
403
|
const renamed = [];
|
|
342
404
|
for (const entry of manifest) {
|
|
@@ -385,8 +447,29 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
385
447
|
// still gated on `!since` to preserve incremental-mode safety (we never
|
|
386
448
|
// delete files outside what the current manifest delta touches).
|
|
387
449
|
const localPngs = await walkOutDirPngs(config.outDir);
|
|
450
|
+
// Sprites created in Unity (or any PNG we never pulled) are pending a
|
|
451
|
+
// `magicpixel push`, not orphans — see prunePolicy.ts.
|
|
452
|
+
let pendingPush = [];
|
|
388
453
|
if (!since) {
|
|
389
|
-
|
|
454
|
+
const trackedPaths = new Set(Object.keys(state.synced ?? {})
|
|
455
|
+
.concat(Object.values(previousAssets))
|
|
456
|
+
.map((key) => {
|
|
457
|
+
try {
|
|
458
|
+
return assetDiskPathFromKey(config.outDir, key);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return '';
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
.filter(Boolean));
|
|
465
|
+
const policy = selectFullSyncOrphans({
|
|
466
|
+
localPaths: localPngs.map((a) => a.abs),
|
|
467
|
+
remoteDiskPaths,
|
|
468
|
+
protectedPaths: unknownFlagPaths,
|
|
469
|
+
isTracked: (p) => trackedPaths.has(p),
|
|
470
|
+
});
|
|
471
|
+
orphans = policy.orphans;
|
|
472
|
+
pendingPush = policy.pendingPush;
|
|
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
|
|
@@ -785,6 +917,11 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
785
917
|
printChanges(result, ' ', { includeRenames: renamed.length === 0 });
|
|
786
918
|
if (renamed.length > 0)
|
|
787
919
|
printRenames(renamed, { withHints: true });
|
|
920
|
+
if (pendingPush.length > 0) {
|
|
921
|
+
console.log();
|
|
922
|
+
console.log(kleur.yellow(`! ${pendingPush.length} local sprite${pendingPush.length === 1 ? '' : 's'} ${pendingPush.length === 1 ? 'is' : 'are'} not in MagicPixel yet — kept on disk.`));
|
|
923
|
+
console.log(kleur.dim(` Fix: run \`${cmd('push')}\` to add them to your library.`));
|
|
924
|
+
}
|
|
788
925
|
}
|
|
789
926
|
if (result.failed)
|
|
790
927
|
process.exitCode = 1;
|
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,25 @@ 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,
|
|
75
89
|
};
|
|
76
90
|
}
|
|
77
91
|
const MAX_GLOBS = 64;
|
|
@@ -112,7 +126,7 @@ export async function saveConfig(config, cwd = process.cwd()) {
|
|
|
112
126
|
throw friendlyFsError(e, {
|
|
113
127
|
operation: 'Saving magicpixel.json',
|
|
114
128
|
path,
|
|
115
|
-
hint:
|
|
129
|
+
hint: `magicpixel.json holds your sync config — without it \`${cmd('sync')}\` can't run.`,
|
|
116
130
|
});
|
|
117
131
|
}
|
|
118
132
|
}
|
|
@@ -196,7 +210,7 @@ export function getApiKey() {
|
|
|
196
210
|
throw new Error('No MagicPixel API key found.\n' +
|
|
197
211
|
' Fix:\n' +
|
|
198
212
|
' 1. Get a key at https://magicpixel.art/settings (API Keys).\n' +
|
|
199
|
-
|
|
213
|
+
` 2. Run \`${cmd('login')}\` (or \`export MAGICPIXEL_API_KEY=mp_live_...\`).\n` +
|
|
200
214
|
' 3. Re-run the command.');
|
|
201
215
|
}
|
|
202
216
|
// Env vars from shells routinely smuggle in stray quotes / whitespace; the
|
|
@@ -209,7 +223,7 @@ export function getApiKey() {
|
|
|
209
223
|
}
|
|
210
224
|
if (!/^mp_(live|test)_[a-f0-9]{64}$/.test(trimmed)) {
|
|
211
225
|
throw new Error(`MagicPixel API key does not look right (expected mp_live_… or mp_test_…).\n` +
|
|
212
|
-
` Fix: run
|
|
226
|
+
` Fix: run \`${cmd('login')}\` and paste a fresh key from https://magicpixel.art/settings.`);
|
|
213
227
|
}
|
|
214
228
|
return trimmed;
|
|
215
229
|
}
|
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,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,74 @@
|
|
|
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. Needs at least a document + artboard segment.
|
|
59
|
+
const pathNames = c.segments.slice(-MAX_PUSH_PATH_SEGMENTS);
|
|
60
|
+
const path = pathNames.map(pushSlug);
|
|
61
|
+
if (path.length < 2 || path.some((s) => !s)) {
|
|
62
|
+
out.push({ kind: 'skip', key: c.key, reason: 'unusable-path' });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
out.push({
|
|
66
|
+
kind: 'adopt',
|
|
67
|
+
key: c.key,
|
|
68
|
+
path,
|
|
69
|
+
pathNames,
|
|
70
|
+
name: pathNames[pathNames.length - 1],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unity `.meta` generation for CLI-synced sprites.
|
|
3
|
+
*
|
|
4
|
+
* MIRROR: the TextureImporter body below is byte-identical to the in-app
|
|
5
|
+
* engine's (`src/utils/unitySync/unityMeta.ts`) so both sync paths produce the
|
|
6
|
+
* same pixel-art-correct importer settings. A sync test
|
|
7
|
+
* (`src/utils/unitySync/__tests__/unityMetaMirror.test.ts`) fails if they
|
|
8
|
+
* drift. The duplication is deliberate: the CLI is published as a standalone
|
|
9
|
+
* npm package and cannot import from the web app's source tree.
|
|
10
|
+
*
|
|
11
|
+
* GUIDs are derived deterministically from the asset identity, so re-running
|
|
12
|
+
* sync never changes a sprite's GUID and Unity scene/prefab references survive.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { writeFile } from 'node:fs/promises';
|
|
17
|
+
export const DEFAULT_UNITY_PPU = 32;
|
|
18
|
+
/** 32-char hex GUID, stable for a given seed. */
|
|
19
|
+
export function unityGuid(seed) {
|
|
20
|
+
return createHash('sha256').update(seed).digest('hex').slice(0, 32);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* TextureImporter meta tuned for pixel art:
|
|
24
|
+
* - textureType 8 (Sprite), single sprite mode
|
|
25
|
+
* - filterMode 0 (Point — no filtering)
|
|
26
|
+
* - textureCompression 0 (none)
|
|
27
|
+
* - mipmaps off, nPOTScale 0 (keep original size), wrap clamp
|
|
28
|
+
* - alphaIsTransparency 1
|
|
29
|
+
*/
|
|
30
|
+
export function buildTextureImporterMeta(guid, ppu) {
|
|
31
|
+
return `fileFormatVersion: 2
|
|
32
|
+
guid: ${guid}
|
|
33
|
+
TextureImporter:
|
|
34
|
+
internalIDToNameTable: []
|
|
35
|
+
externalObjects: {}
|
|
36
|
+
serializedVersion: 12
|
|
37
|
+
mipmaps:
|
|
38
|
+
mipMapMode: 0
|
|
39
|
+
enableMipMap: 0
|
|
40
|
+
sRGBTexture: 1
|
|
41
|
+
linearTexture: 0
|
|
42
|
+
fadeOut: 0
|
|
43
|
+
borderMipMap: 0
|
|
44
|
+
mipMapsPreserveCoverage: 0
|
|
45
|
+
alphaTestReferenceValue: 0.5
|
|
46
|
+
mipMapFadeDistanceStart: 1
|
|
47
|
+
mipMapFadeDistanceEnd: 3
|
|
48
|
+
bumpmap:
|
|
49
|
+
convertToNormalMap: 0
|
|
50
|
+
externalNormalMap: 0
|
|
51
|
+
heightScale: 0.25
|
|
52
|
+
normalMapFilter: 0
|
|
53
|
+
flipGreenChannel: 0
|
|
54
|
+
isReadable: 0
|
|
55
|
+
streamingMipmaps: 0
|
|
56
|
+
streamingMipmapsPriority: 0
|
|
57
|
+
vTOnly: 0
|
|
58
|
+
ignoreMipmapLimit: 0
|
|
59
|
+
grayScaleToAlpha: 0
|
|
60
|
+
generateCubemap: 6
|
|
61
|
+
cubemapConvolution: 0
|
|
62
|
+
seamlessCubemap: 0
|
|
63
|
+
textureFormat: 1
|
|
64
|
+
maxTextureSize: 2048
|
|
65
|
+
textureSettings:
|
|
66
|
+
serializedVersion: 2
|
|
67
|
+
filterMode: 0
|
|
68
|
+
aniso: 1
|
|
69
|
+
mipBias: 0
|
|
70
|
+
wrapU: 1
|
|
71
|
+
wrapV: 1
|
|
72
|
+
wrapW: 1
|
|
73
|
+
nPOTScale: 0
|
|
74
|
+
lightmap: 0
|
|
75
|
+
compressionQuality: 50
|
|
76
|
+
spriteMode: 1
|
|
77
|
+
spriteExtrude: 1
|
|
78
|
+
spriteMeshType: 1
|
|
79
|
+
alignment: 0
|
|
80
|
+
spritePivot: {x: 0.5, y: 0.5}
|
|
81
|
+
spritePixelsToUnits: ${ppu}
|
|
82
|
+
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
|
83
|
+
spriteGenerateFallbackPhysicsShape: 1
|
|
84
|
+
alphaUsage: 1
|
|
85
|
+
alphaIsTransparency: 1
|
|
86
|
+
spriteTessellationDetail: -1
|
|
87
|
+
textureType: 8
|
|
88
|
+
textureShape: 1
|
|
89
|
+
singleChannelComponent: 0
|
|
90
|
+
flipbookRows: 1
|
|
91
|
+
flipbookColumns: 1
|
|
92
|
+
maxTextureSizeSet: 0
|
|
93
|
+
compressionQualitySet: 0
|
|
94
|
+
textureFormatSet: 0
|
|
95
|
+
ignorePngGamma: 0
|
|
96
|
+
applyGammaDecoding: 0
|
|
97
|
+
swizzle: 50462976
|
|
98
|
+
cookieLightType: 0
|
|
99
|
+
platformSettings:
|
|
100
|
+
- serializedVersion: 4
|
|
101
|
+
buildTarget: DefaultTexturePlatform
|
|
102
|
+
maxTextureSize: 16384
|
|
103
|
+
resizeAlgorithm: 0
|
|
104
|
+
textureFormat: -1
|
|
105
|
+
textureCompression: 0
|
|
106
|
+
compressionQuality: 50
|
|
107
|
+
crunchedCompression: 0
|
|
108
|
+
allowsAlphaSplitting: 0
|
|
109
|
+
overridden: 0
|
|
110
|
+
ignorePlatformSupport: 0
|
|
111
|
+
androidETC2FallbackOverride: 0
|
|
112
|
+
forceMaximumCompressionQuality_BC6H_BC7: 0
|
|
113
|
+
spriteSheet:
|
|
114
|
+
serializedVersion: 2
|
|
115
|
+
sprites: []
|
|
116
|
+
outline: []
|
|
117
|
+
customData:
|
|
118
|
+
physicsShape: []
|
|
119
|
+
bones: []
|
|
120
|
+
spriteID:
|
|
121
|
+
internalID: 0
|
|
122
|
+
vertices: []
|
|
123
|
+
indices:
|
|
124
|
+
edges: []
|
|
125
|
+
weights: []
|
|
126
|
+
secondaryTextures: []
|
|
127
|
+
spriteCustomMetadata:
|
|
128
|
+
entries: []
|
|
129
|
+
nameFileIdTable: {}
|
|
130
|
+
mipmapLimitGroupName:
|
|
131
|
+
pSDRemoveMatte: 0
|
|
132
|
+
userData:
|
|
133
|
+
assetBundleName:
|
|
134
|
+
assetBundleVariant:
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Write `<file>.png.meta` beside each synced PNG that doesn't have one yet.
|
|
139
|
+
*
|
|
140
|
+
* Existing `.meta` files are never overwritten: once Unity has imported a
|
|
141
|
+
* sprite the user may have tweaked its importer settings (pivot, slicing,
|
|
142
|
+
* PPU) and clobbering that on every sync would be hostile. Delete the `.meta`
|
|
143
|
+
* to regenerate it.
|
|
144
|
+
*/
|
|
145
|
+
export async function writeMissingUnityMetas(sprites, ppu = DEFAULT_UNITY_PPU) {
|
|
146
|
+
const result = { written: 0, failed: 0 };
|
|
147
|
+
for (const sprite of sprites) {
|
|
148
|
+
const metaPath = `${sprite.pngPath}.meta`;
|
|
149
|
+
if (!existsSync(sprite.pngPath) || existsSync(metaPath))
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
await writeFile(metaPath, buildTextureImporterMeta(unityGuid(sprite.id), ppu));
|
|
153
|
+
result.written++;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Non-fatal: Unity generates its own default .meta on import. Counted
|
|
157
|
+
// so `sync` can surface a single warning line instead of failing.
|
|
158
|
+
result.failed++;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magicpixelart/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Sync MagicPixel pixel-art assets to your local project.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"magicpixel": "dist/index.js"
|
|
8
8
|
},
|
|
9
|
-
"files": [
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
10
15
|
"scripts": {
|
|
11
16
|
"build": "tsc && chmod +x dist/index.js",
|
|
12
17
|
"dev": "tsx src/index.ts",
|