@magicpixelart/cli 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.8] — 2026-08-26
11
+
12
+ ### Added
13
+
14
+ - **Connect imports game sprites.** `sync` (and `sync --watch`) now runs `push`
15
+ automatically so connecting a game project imports existing sprites into the
16
+ MagicPixel library, flagged Sync to Unity so they appear under Connected.
17
+ `"push": false` in `magicpixel.json` opts out.
18
+ - **First-connect import.** On Unity/Godot/GameMaker, PNGs under `Assets/` /
19
+ `assets/` / `datafiles/` are copied into `outDir` if they aren't there yet
20
+ (skipping Library/Temp/Packages, never overwriting) and then adopted.
21
+ Root-level PNGs (`hero.png`) adopt as a single-artboard document instead of
22
+ being skipped.
23
+
24
+ ## [0.5.7] — 2026-08-26
25
+
26
+ ### Added
27
+
28
+ - **`magicpixel push`.** Two-way sync: walk `outDir`, hash local PNGs, and
29
+ upload changed or new sprites back to MagicPixel (`--dry-run`, `--flatten`).
30
+ Cloud conflicts refuse instead of overwriting; new on-disk files are adopted
31
+ into the library. Prune no longer deletes pending local sprites.
32
+ - **Unity `.meta` files.** Sync writes TextureImporter `.meta` next to PNGs
33
+ (point filter, no compression, deterministic GUIDs). `unityPpu` (default 32)
34
+ and `unitySyncAll` in `magicpixel.json`. By default only artboards flagged
35
+ "Sync to Unity" in the editor are pulled.
36
+
37
+ ### Fixed
38
+
39
+ - **`npx magicpixel …` dead end.** `magicpixel` is only the bin name — the
40
+ published package is `@magicpixelart/cli` — so hints like "run `npx
41
+ magicpixel init`" 404ed (`'magicpixel@*' is not in this registry`) for anyone
42
+ driving the CLI purely through `npx @magicpixelart/cli …`. Every user-facing
43
+ command hint now goes through a single `cmd()` helper that prints the short
44
+ `magicpixel <cmd>` form only when the package resolves from the project's
45
+ `node_modules`, and the always-correct `npx @magicpixelart/cli <cmd>` form
46
+ otherwise.
47
+ - **Missing-config hint points at `start`.** A config-less project is now told
48
+ to run `start` (init + login + first sync) rather than bare `init`, which left
49
+ users unauthenticated with nothing on disk. `status` uses the same hint
50
+ (it previously hardcoded `npx @magicpixelart/cli init`).
51
+
10
52
  ## [0.5.6] — 2026-08-21
11
53
 
12
54
  ### Changed
package/README.md CHANGED
@@ -19,7 +19,7 @@ Requires Node.js ≥ 18.
19
19
  One command, inside your project folder (the one with `package.json`):
20
20
 
21
21
  ```bash
22
- npx 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,9 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
135
136
  | `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
136
137
  | `exclude` | `string[]` | `[]` | Globs to exclude. |
137
138
  | `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
139
+ | `unityPpu` | `number?` | `32` | Unity only: pixels-per-unit in generated `.meta`. |
140
+ | `unitySyncAll` | `boolean?` | `false` | Unity only: sync every artboard, not just those flagged in the editor. |
141
+ | `push` | `boolean?` | `true` | Upload local PNG changes to MagicPixel on every `sync`. Set `false` for pull-only. |
138
142
  | `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
139
143
 
140
144
  State (`.magicpixel/state.json`) tracks `lastSync` (file mode `0600`). Add `.magicpixel/` to `.gitignore` (init offers to do this).
package/dist/api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { resolveEndpoint, getApiKey, CLI_VERSION } from './config.js';
2
- import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, } from './util/security.js';
2
+ import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, validateEndpointUrl, } from './util/security.js';
3
3
  import { authHeaders } from './util/authHeaders.js';
4
+ import { cmd } from './util/invoke.js';
4
5
  /**
5
6
  * Last-seen project metadata from any manifest response this process made.
6
7
  * Populated as a side-effect of `fetchManifestPage`; read by `whoami`,
@@ -38,7 +39,7 @@ export class ApiError extends Error {
38
39
  function friendly(status, body, context) {
39
40
  if (status === 401 || status === 403) {
40
41
  return (`${context}: ${status} — API key rejected.\n` +
41
- ` Fix: regenerate at https://magicpixel.art/settings and re-run \`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,207 @@
1
+ /**
2
+ * `magicpixel push` — disk → MagicPixel (the Unity → MagicPixel half of
3
+ * two-way sync).
4
+ *
5
+ * Walks `outDir`, hashes every PNG, and sends the ones that changed (or are
6
+ * brand new on disk) to `POST /api/public/integration/ingest`:
7
+ *
8
+ * - known key + changed sha → write back into that artboard, using the sha
9
+ * the CLI last pulled as the conflict baseline. If the cloud moved on the
10
+ * server refuses with `conflict` and nothing is lost.
11
+ * - unknown key → adopt: the server mirrors the disk folders into
12
+ * the library and appends/creates the document.
13
+ */
14
+ import kleur from 'kleur';
15
+ import ora from 'ora';
16
+ import { mkdir, readFile, rename } from 'node:fs/promises';
17
+ import { dirname } from 'node:path';
18
+ import { relative, resolve } from 'node:path';
19
+ import { loadConfig, loadState, saveState } from '../config.js';
20
+ import { PUSH_BATCH_SIZE, pushSprites } from '../api.js';
21
+ import { fileSha256 } from '../util/hash.js';
22
+ import { assetDiskPathFromKey, walkOutDirPngs } from '../util/paths.js';
23
+ import { planPush } from '../util/pushPlan.js';
24
+ import { cmd } from '../util/invoke.js';
25
+ import { detectProjectKind } from '../util/framework.js';
26
+ import { importNearbyGamePngs } from '../util/gameScan.js';
27
+ export async function pushCommand(opts = {}) {
28
+ await runPush(opts);
29
+ }
30
+ /**
31
+ * Scan local PNGs and upload new/changed ones into the MagicPixel library.
32
+ * Used by `magicpixel push` and by `sync` (so connect/watch imports game
33
+ * sprites without a second command).
34
+ */
35
+ export async function runPush(opts = {}) {
36
+ const config = await loadConfig();
37
+ const state = await loadState();
38
+ return runPushWith(config, state, opts);
39
+ }
40
+ export async function runPushWith(config, state, opts = {}) {
41
+ const quiet = opts.quiet === true;
42
+ const empty = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0 };
43
+ const spinner = quiet ? null : ora('Scanning local sprites…').start();
44
+ const kind = await detectProjectKind();
45
+ const scan = await importNearbyGamePngs(config.outDir, kind);
46
+ const imported = scan.copied;
47
+ const disk = await walkOutDirPngs(config.outDir);
48
+ const absByKey = new Map(disk.map((a) => [a.key, a.abs]));
49
+ const candidates = [];
50
+ for (const asset of disk) {
51
+ const sha = await fileSha256(asset.abs);
52
+ if (!sha)
53
+ continue;
54
+ candidates.push({
55
+ key: asset.key,
56
+ segments: asset.key.split('/'),
57
+ diskSha256: sha,
58
+ });
59
+ }
60
+ const actions = planPush(candidates, state.synced, { flatten: opts.flatten });
61
+ const skipped = actions.filter((a) => a.kind === 'skip' && a.reason !== 'legacy').length;
62
+ const legacy = actions.filter((a) => a.kind === 'skip' && a.reason === 'legacy');
63
+ const flattenBlocked = actions.filter((a) => a.kind === 'needs-flatten');
64
+ const sendable = actions.filter((a) => a.kind === 'update' || a.kind === 'adopt');
65
+ spinner?.succeed(`Local sprites: ${candidates.length} · to push ${sendable.length} · unchanged ${skipped}` +
66
+ (imported ? ` · imported ${imported}` : ''));
67
+ if (legacy.length > 0 && !quiet) {
68
+ console.log();
69
+ console.log(kleur.yellow(`! ${legacy.length} sprite${legacy.length === 1 ? '' : 's'} came from a legacy single-image file — skipped.`));
70
+ console.log(kleur.dim(` Fix: open the file in MagicPixel and save it once, then re-run \`${cmd('sync')}\`.`));
71
+ }
72
+ if (flattenBlocked.length > 0 && !quiet) {
73
+ console.log();
74
+ console.log(kleur.yellow(`! ${flattenBlocked.length} sprite${flattenBlocked.length === 1 ? '' : 's'} would flatten a multi-layer artboard — skipped.`));
75
+ for (const a of flattenBlocked.slice(0, 10)) {
76
+ console.log(kleur.dim(` ${a.key} (${'layers' in a ? a.layers : '?'} layers)`));
77
+ }
78
+ console.log(kleur.dim(' Fix: re-run with --flatten to replace them with the flat disk image.'));
79
+ }
80
+ if (sendable.length === 0) {
81
+ if (!quiet)
82
+ console.log(kleur.green('✓ nothing to push.'));
83
+ return { ...empty, imported, unchanged: skipped };
84
+ }
85
+ if (opts.dryRun) {
86
+ if (!quiet) {
87
+ console.log();
88
+ for (const a of sendable) {
89
+ const verb = a.kind === 'update' ? kleur.yellow('~') : kleur.green('+');
90
+ console.log(` ${verb} ${a.key}${a.kind === 'adopt' ? kleur.dim(' (new)') : ''}`);
91
+ }
92
+ console.log(kleur.dim('--dry-run: nothing sent.'));
93
+ }
94
+ return { ...empty, imported };
95
+ }
96
+ const outRoot = resolve(process.cwd(), config.outDir);
97
+ const shaByKey = new Map(candidates.map((c) => [c.key, c.diskSha256]));
98
+ const sprites = [];
99
+ for (const a of sendable) {
100
+ const abs = absByKey.get(a.key);
101
+ if (!abs)
102
+ continue;
103
+ const pngBase64 = (await readFile(abs)).toString('base64');
104
+ const diskSha256 = shaByKey.get(a.key);
105
+ if (a.kind === 'update') {
106
+ sprites.push({
107
+ key: a.key,
108
+ pngBase64,
109
+ diskSha256,
110
+ assetId: a.assetId,
111
+ layerIdx: a.layerIdx,
112
+ baseSha256: a.baseSha256,
113
+ flatten: opts.flatten === true,
114
+ });
115
+ }
116
+ else if (a.kind === 'adopt') {
117
+ sprites.push({ key: a.key, pngBase64, diskSha256, path: a.path, pathNames: a.pathNames, name: a.name });
118
+ }
119
+ }
120
+ const progress = quiet ? null : ora(`Pushing 0/${sprites.length}…`).start();
121
+ const results = [];
122
+ for (let i = 0; i < sprites.length; i += PUSH_BATCH_SIZE) {
123
+ const batch = sprites.slice(i, i + PUSH_BATCH_SIZE);
124
+ const batchResults = await pushSprites(batch);
125
+ results.push(...batchResults);
126
+ if (progress)
127
+ progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
128
+ }
129
+ const counts = { created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0 };
130
+ const nextSynced = { ...(state.synced ?? {}) };
131
+ for (const r of results) {
132
+ counts[r.status] = (counts[r.status] ?? 0) + 1;
133
+ const stateKey = r.cloudKey && r.cloudKey !== r.key ? r.cloudKey : r.key;
134
+ if (stateKey !== r.key && (r.status === 'created' || r.status === 'updated')) {
135
+ await renameLocalSprite(config.outDir, r.key, stateKey);
136
+ }
137
+ if ((r.status === 'created' || r.status === 'updated' || r.status === 'unchanged') && r.assetId && r.sha256) {
138
+ if (stateKey !== r.key)
139
+ delete nextSynced[r.key];
140
+ nextSynced[stateKey] = {
141
+ assetId: r.assetId,
142
+ layerIdx: typeof r.layerIdx === 'number' ? r.layerIdx : (nextSynced[stateKey]?.layerIdx ?? 0),
143
+ sha256: r.sha256,
144
+ layers: 1,
145
+ };
146
+ }
147
+ }
148
+ await saveState({ ...state, synced: nextSynced });
149
+ const failed = counts.conflict + counts.error;
150
+ const summary = `created ${counts.created}, updated ${counts.updated}, unchanged ${counts.unchanged}` +
151
+ (counts.conflict ? `, conflicts ${counts.conflict}` : '') +
152
+ (counts.error ? `, errors ${counts.error}` : '');
153
+ if (progress) {
154
+ if (failed)
155
+ progress.warn(`Push finished with issues. ${summary}`);
156
+ else
157
+ progress.succeed(`Pushed. ${summary}`);
158
+ }
159
+ if (!quiet) {
160
+ for (const r of results) {
161
+ if (r.status === 'conflict') {
162
+ console.log(` ${kleur.yellow('!')} ${r.key}: ${conflictHint(r.reason)}`);
163
+ }
164
+ else if (r.status === 'error') {
165
+ console.log(` ${kleur.red('!')} ${r.key}: ${r.message ?? 'push failed'}`);
166
+ }
167
+ }
168
+ if (counts.created > 0) {
169
+ console.log(kleur.dim(` ${counts.created} new sprite${counts.created === 1 ? '' : 's'} now live in your MagicPixel library ` +
170
+ `(folders mirrored from ${relative(process.cwd(), outRoot)}). Refresh the library to see them.`));
171
+ }
172
+ }
173
+ if (failed)
174
+ process.exitCode = 1;
175
+ return { ...counts, imported };
176
+ }
177
+ function conflictHint(reason) {
178
+ switch (reason) {
179
+ case 'cloud-changed':
180
+ return `changed in MagicPixel since the last sync — run \`${cmd('sync')}\` first, then re-push.`;
181
+ case 'would-flatten':
182
+ return 'artboard has multiple layers or frames — re-run with --flatten to replace it.';
183
+ case 'legacy-document':
184
+ return 'document predates layer storage — open and save it once in MagicPixel.';
185
+ case 'not-found':
186
+ return `artboard no longer exists in MagicPixel — run \`${cmd('sync')}\` to refresh.`;
187
+ default:
188
+ return `conflict — run \`${cmd('sync')}\` and try again.`;
189
+ }
190
+ }
191
+ /**
192
+ * Move a pushed sprite (and its `.meta` sidecar) to the disk path matching the
193
+ * key the cloud assigned. Best effort: a failed move only means the next sync
194
+ * re-downloads the sprite under its cloud name.
195
+ */
196
+ async function renameLocalSprite(outDir, fromKey, toKey) {
197
+ const from = assetDiskPathFromKey(outDir, fromKey);
198
+ const to = assetDiskPathFromKey(outDir, toKey);
199
+ try {
200
+ await mkdir(dirname(to), { recursive: true });
201
+ await rename(from, to);
202
+ await rename(`${from}.meta`, `${to}.meta`).catch(() => { });
203
+ }
204
+ catch {
205
+ // Leave the file where it is; sync reconciles.
206
+ }
207
+ }
@@ -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,24 +10,20 @@ import { findKeyInDotenv, readCredentialsSync, writeCredentials } from '../util/
10
10
  import { initCommand } from './init.js';
11
11
  import { loginCommand } from './login.js';
12
12
  import { syncCommand } from './sync.js';
13
+ import { cmd } from '../util/invoke.js';
13
14
  /**
14
15
  * One-command bootstrap for new users. Runs init → key prompt → first sync →
15
16
  * prints copy-pasteable watch instructions. Designed so a non-technical user
16
- * can paste `npx magicpixel start` and end up with sprites on disk and a watch
17
- * script ready to run.
17
+ * can paste `npx @magicpixelart/cli start` and end up with game sprites in
18
+ * MagicPixel (and flagged sprites back on disk) plus a watch script.
18
19
  */
19
20
  export async function startCommand(opts = {}) {
20
21
  console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
21
- console.log(kleur.dim(' This walks you through getting your sprites onto disk.'));
22
+ console.log(kleur.dim(' This connects your game project so existing sprites show up in MagicPixel.'));
22
23
  console.log();
23
- // 1. `start` is JS-only — engine projects use init + login + sync --watch.
24
- if (!hasPackageJson()) {
25
- const kind = await detectProjectKind();
26
- if (isEngineKind(kind)) {
27
- console.log(kleur.yellow(` Detected ${kind}. \`magicpixel start\` is JS-only; run ` +
28
- '`npx magicpixel init && npx magicpixel login && npx magicpixel sync --watch` instead.'));
29
- return;
30
- }
24
+ const kind = await detectProjectKind();
25
+ // Engine projects have no package.json requirement. JS projects still need one.
26
+ if (!hasPackageJson() && !isEngineKind(kind)) {
31
27
  console.log(kleur.yellow(' MagicPixel needs a JavaScript project (package.json) to sync into.\n' +
32
28
  ' Run this inside your project folder — the one with package.json.'));
33
29
  return;
@@ -46,7 +42,7 @@ export async function startCommand(opts = {}) {
46
42
  console.log(kleur.yellow(` Existing magicpixel.json is invalid:`));
47
43
  for (const line of msg.split('\n'))
48
44
  console.log(kleur.yellow(` ${line}`));
49
- console.log(kleur.dim(' Re-run `npx magicpixel start --force` to overwrite it, or fix the file by hand.'));
45
+ console.log(kleur.dim(` Re-run \`${cmd('start')} --force\` to overwrite it, or fix the file by hand.`));
50
46
  return;
51
47
  }
52
48
  }
@@ -73,7 +69,7 @@ export async function startCommand(opts = {}) {
73
69
  // failures without throwing — snapshot around the call so we don't
74
70
  // print a misleading green "you're set up" over a half-failed run.
75
71
  console.log();
76
- console.log(kleur.bold('Step: first sync'));
72
+ console.log(kleur.bold('Step: import + sync'));
77
73
  const exitBefore = process.exitCode ?? 0;
78
74
  await syncCommand({ full: true });
79
75
  const firstSyncFailed = (process.exitCode ?? 0) > exitBefore;
@@ -84,7 +80,7 @@ export async function startCommand(opts = {}) {
84
80
  console.log();
85
81
  if (firstSyncFailed) {
86
82
  console.log(kleur.yellow('! first sync completed with errors.'));
87
- console.log(kleur.dim(' Re-run `npx magicpixel sync` to retry the failed downloads, or `npx magicpixel doctor` to diagnose.'));
83
+ console.log(kleur.dim(` Re-run \`${cmd('sync')}\` to retry the failed downloads, or \`${cmd('doctor')}\` to diagnose.`));
88
84
  }
89
85
  else {
90
86
  console.log(kleur.bold('You\'re set up. ✨'));
@@ -95,11 +91,11 @@ export async function startCommand(opts = {}) {
95
91
  console.log(` ${kleur.green('▶')} ${kleur.bold('npm run magicpixel:watch')} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
96
92
  }
97
93
  else {
98
- console.log(` ${kleur.green('▶')} ${kleur.bold('npx magicpixel sync --watch')} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
94
+ console.log(` ${kleur.green('▶')} ${kleur.bold(`${cmd('sync')} --watch`)} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
99
95
  }
100
96
  console.log();
101
97
  if (await hasDevScript()) {
102
- const watchCmd = hasWatch ? 'npm run magicpixel:watch' : 'npx magicpixel sync --watch';
98
+ const watchCmd = hasWatch ? 'npm run magicpixel:watch' : `${cmd('sync')} --watch`;
103
99
  console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));
104
100
  console.log(kleur.dim(` \`npx concurrently "npm run dev" "${watchCmd}"\``));
105
101
  console.log();
@@ -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)';