@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 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 magicpixel start
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 magicpixel init # writes magicpixel.json
46
- npx magicpixel login # stores your key (or: export MAGICPIXEL_API_KEY=mp_live_...)
47
- npx magicpixel sync # downloads changed assets
48
- npx magicpixel sync --watch # keeps assets fresh while you work
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 magicpixel doctor
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 \`magicpixel login\`.`);
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
+ }
@@ -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('Run `magicpixel login` to store your API key.');
73
+ suggestions.push(`Run \`${cmd('login')}\` to store your API key.`);
73
74
  if (!config)
74
- suggestions.push('Run `magicpixel start` (or `magicpixel init`) to bootstrap.');
75
+ suggestions.push(`Run \`${cmd('start')}\` (or \`${cmd('init')}\`) to bootstrap.`);
75
76
  if (config && !watchScript && pkgFound) {
76
- suggestions.push('Add a `magicpixel:watch` npm script (or run `magicpixel sync -w`) for live sync.');
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('Last sync surfaced an error — run `magicpixel repair` to self-heal.');
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('API rejected the key — run `magicpixel login` with a fresh key from settings.');
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('none — run `magicpixel login`');
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
- : 'skipped (no API key — run `magicpixel login`)';
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) {
@@ -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('magicpixel login')} ${kleur.dim('# paste your key from https://magicpixel.art/settings')}`);
104
- console.log(` 2. ${kleur.dim('magicpixel sync')} ${kleur.dim('# downloads your assets')}`);
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('npx')} magicpixel sync --watch ${kleur.dim('# keeps assets fresh while you edit')}`);
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
+ }
@@ -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 magicpixel repair`":
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('would run `magicpixel sync --full` (skipped in --dry-run)')}`);
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('! repair completed with errors — re-run `magicpixel sync` to retry the failed downloads.'));
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.'));
@@ -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 magicpixel start` and end up with sprites on disk and a watch
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}. \`magicpixel start\` is JS-only; run ` +
28
- '`npx magicpixel init && npx magicpixel login && npx magicpixel sync --watch` instead.'));
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(' Re-run `npx magicpixel start --force` to overwrite it, or fix the file by hand.'));
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(' Re-run `npx magicpixel sync` to retry the failed downloads, or `npx magicpixel doctor` to diagnose.'));
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('npx magicpixel sync --watch')} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
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' : 'npx magicpixel sync --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();
@@ -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 \`npx magicpixel init\` to create one.`));
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(' no API key — run `magicpixel login` or set MAGICPIXEL_API_KEY'));
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)';
@@ -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(' Fix: run `magicpixel login` (this watcher will keep retrying every 30s).'));
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(' Fix: run `magicpixel login` with a fresh key, then restart the watcher.'));
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
- orphans = localPngs.map((a) => a.abs).filter((p) => !remoteDiskPaths.has(p));
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
- const nextState = { ...state, assets: nextAssets };
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 \`npx magicpixel init\` to create one.`);
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: 'magicpixel.json holds your sync config — without it `magicpixel sync` can\'t run.',
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
- ' 2. Run `magicpixel login` (or `export MAGICPIXEL_API_KEY=mp_live_...`).\n' +
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 \`magicpixel login\` and paste a fresh key from https://magicpixel.art/settings.`);
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')
@@ -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
- '// Re-runs of `magicpixel sync` overwrite this file.',
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
- 'When you rename or move an asset, `magicpixel sync` prints both the old → new key',
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.6",
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": ["dist", "README.md", "CHANGELOG.md", "LICENSE"],
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",