@magicpixelart/cli 0.5.4 → 0.5.6
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 +11 -1
- package/dist/api.js +48 -2
- package/dist/commands/status.js +10 -2
- package/dist/commands/sync.js +14 -2
- package/dist/commands/whoami.js +40 -4
- package/dist/util/telemetry.js +7 -0
- package/package.json +2 -7
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.6] — 2026-08-21
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **README positioning.** Lead with generation, reskins/variants, the full
|
|
15
|
+
browser editor, and MCP/CLI instead of sprites/tilesets/direction sheets.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **Transient Supabase 546 (edge worker recycle).** Friendly error copy for
|
|
20
|
+
status 546, five retry attempts with a longer backoff (~7.5s coverage), and
|
|
21
|
+
546s are no longer reported as telemetry noise. Persistent 5xx still reports.
|
|
22
|
+
Shape-guard tests that exhaust those retries now use a 15s budget so CI
|
|
23
|
+
matches the new backoff.
|
|
24
|
+
|
|
25
|
+
## [0.5.5] — 2026-07-12
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **Bound project name in `whoami` / `status` / `sync`.** Manifest responses
|
|
30
|
+
now expose the API key's project via `x-magicpixel-project-*` headers and an
|
|
31
|
+
optional `project` body field. The CLI surfaces `project: <name>` so a
|
|
32
|
+
key-bound-to-the-wrong-project mistake is obvious at a glance. On empty
|
|
33
|
+
fresh syncs the server may also send `totalInProject` + a ready-to-print
|
|
34
|
+
`hint`; `whoami`, `status`, and verbose `sync` print it instead of a silent
|
|
35
|
+
zero. Old servers without the headers keep working — the project line is
|
|
36
|
+
simply omitted.
|
|
37
|
+
|
|
10
38
|
## [0.5.4] — 2026-06-22
|
|
11
39
|
|
|
12
40
|
### Added
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @magicpixelart/cli
|
|
2
2
|
|
|
3
|
-
Sync MagicPixel pixel-art assets to your local project as flattened PNGs. Zero runtime — just files on disk that your bundler picks up.
|
|
3
|
+
Sync your [MagicPixel](https://magicpixel.art) pixel-art assets to your local project as flattened PNGs. Zero runtime — just files on disk that your bundler picks up.
|
|
4
|
+
|
|
5
|
+
MagicPixel is an [AI pixel art generator](https://magicpixel.art) for game developers: generate new pixel art, reskin and remix the assets you already have, and finish them in a full browser editor — then pull them straight into your repo with this CLI. It also exposes a [Model Context Protocol server](https://magicpixel.art/guides/connect) so Claude, ChatGPT, and Cursor can generate assets for you, and a [Vite plugin](https://www.npmjs.com/package/@magicpixelart/vite) that syncs on save with no second terminal.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -205,6 +207,14 @@ at a non-canonical host.
|
|
|
205
207
|
- A runtime JS SDK (your bundler already handles PNG imports)
|
|
206
208
|
- Cross-user asset sharing
|
|
207
209
|
|
|
210
|
+
## More about MagicPixel
|
|
211
|
+
|
|
212
|
+
- [AI pixel art generator](https://magicpixel.art) — generate new pixel art, and reskin or remix the assets you already have.
|
|
213
|
+
- [Pixel art editor](https://magicpixel.art/pixel-art-editor) — palette remapping, components, pixel-accurate cleanup, and animation frames.
|
|
214
|
+
- [Generating game sprites with AI](https://magicpixel.art/guides/generating-sprites) — the workflow for game-ready assets.
|
|
215
|
+
- [Editing AI pixel art](https://magicpixel.art/guides/editing-ai-art) — cleaning up and iterating on generated sprites.
|
|
216
|
+
- [Connect an AI agent over MCP](https://magicpixel.art/guides/connect) — Claude, ChatGPT, and Cursor.
|
|
217
|
+
|
|
208
218
|
## License
|
|
209
219
|
|
|
210
220
|
MIT
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { resolveEndpoint, getApiKey, CLI_VERSION } from './config.js';
|
|
2
2
|
import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, } from './util/security.js';
|
|
3
3
|
import { authHeaders } from './util/authHeaders.js';
|
|
4
|
+
/**
|
|
5
|
+
* Last-seen project metadata from any manifest response this process made.
|
|
6
|
+
* Populated as a side-effect of `fetchManifestPage`; read by `whoami`,
|
|
7
|
+
* `status`, and `sync` to surface `project: <name>` diagnostics.
|
|
8
|
+
*
|
|
9
|
+
* Never null after the first successful manifest call. Module-level so the
|
|
10
|
+
* three commands don't have to thread it through every helper.
|
|
11
|
+
*/
|
|
12
|
+
let lastProjectInfo = null;
|
|
13
|
+
export function getLastProjectInfo() {
|
|
14
|
+
return lastProjectInfo;
|
|
15
|
+
}
|
|
16
|
+
export function __resetLastProjectInfoForTesting() {
|
|
17
|
+
lastProjectInfo = null;
|
|
18
|
+
}
|
|
4
19
|
/**
|
|
5
20
|
* Error from the MagicPixel API. `requestId` is the `X-Request-Id` the server
|
|
6
21
|
* echoed (or the one we minted client-side if the server didn't respond) —
|
|
@@ -32,6 +47,11 @@ function friendly(status, body, context) {
|
|
|
32
47
|
if (status === 429) {
|
|
33
48
|
return `${context}: 429 — rate limited. Retry shortly.`;
|
|
34
49
|
}
|
|
50
|
+
if (status === 546) {
|
|
51
|
+
// Supabase Edge Runtime platform code — worker boot / CPU / wall-time.
|
|
52
|
+
// Not one of ours; typically clears in seconds.
|
|
53
|
+
return `${context}: 546 — MagicPixel edge worker restarting. Retried automatically; if this persists, retry in ~30s.`;
|
|
54
|
+
}
|
|
35
55
|
if (status >= 500) {
|
|
36
56
|
return `${context}: ${status} — MagicPixel server error. Retry shortly; status at https://magicpixel.art.`;
|
|
37
57
|
}
|
|
@@ -86,6 +106,29 @@ export async function fetchManifestPage(opts) {
|
|
|
86
106
|
if (!isObject || !Array.isArray(data.items) || !cursorOk) {
|
|
87
107
|
throw new ApiError(502, withRequestId('manifest: unexpected server response shape (items missing or non-string cursor).', serverRequestId), serverRequestId);
|
|
88
108
|
}
|
|
109
|
+
// Capture the bound project's name/id from response headers (always
|
|
110
|
+
// present on 2xx). The body may also carry a richer `project` payload
|
|
111
|
+
// (empty fresh-sync case, includes totalInProject + hint). Prefer the
|
|
112
|
+
// body when present so `whoami`/`sync` can print the "0 assets in this
|
|
113
|
+
// project" nudge without another round-trip.
|
|
114
|
+
const projectId = res.headers.get('x-magicpixel-project-id');
|
|
115
|
+
const projectName = res.headers.get('x-magicpixel-project-name');
|
|
116
|
+
const bodyProject = data.project;
|
|
117
|
+
if (bodyProject && typeof bodyProject === 'object' && typeof bodyProject.id === 'string') {
|
|
118
|
+
lastProjectInfo = {
|
|
119
|
+
id: bodyProject.id,
|
|
120
|
+
name: typeof bodyProject.name === 'string' ? bodyProject.name : (projectName ?? null),
|
|
121
|
+
totalInProject: typeof bodyProject.totalInProject === 'number'
|
|
122
|
+
? bodyProject.totalInProject
|
|
123
|
+
: undefined,
|
|
124
|
+
hint: typeof bodyProject.hint === 'string'
|
|
125
|
+
? bodyProject.hint
|
|
126
|
+
: undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
else if (projectId) {
|
|
130
|
+
lastProjectInfo = { id: projectId, name: projectName };
|
|
131
|
+
}
|
|
89
132
|
return data;
|
|
90
133
|
}
|
|
91
134
|
const bodyText = await res.text();
|
|
@@ -102,7 +145,10 @@ export async function fetchManifestPage(opts) {
|
|
|
102
145
|
* request-id of the most recent attempt.
|
|
103
146
|
*/
|
|
104
147
|
export async function retryTransient(context, fn) {
|
|
105
|
-
|
|
148
|
+
// 5 attempts × (500ms, 1s, 2s, 4s) = ~7.5s of coverage. Enough to ride out
|
|
149
|
+
// a Supabase edge worker recycle (546) or a brief 5xx without surfacing to
|
|
150
|
+
// the user; short enough that a real outage still fails within one sync tick.
|
|
151
|
+
const maxAttempts = 5;
|
|
106
152
|
let lastErr = null;
|
|
107
153
|
let nextDelayMs = 0;
|
|
108
154
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -126,7 +172,7 @@ export async function retryTransient(context, fn) {
|
|
|
126
172
|
nextDelayMs = 0;
|
|
127
173
|
}
|
|
128
174
|
if (attempt < maxAttempts) {
|
|
129
|
-
const backoffMs =
|
|
175
|
+
const backoffMs = 500 * 2 ** (attempt - 1);
|
|
130
176
|
await new Promise((r) => setTimeout(r, Math.max(nextDelayMs, backoffMs)));
|
|
131
177
|
}
|
|
132
178
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
2
|
import { loadConfig, loadState, resolveEndpoint } from '../config.js';
|
|
3
|
-
import { fetchAllManifest } from '../api.js';
|
|
3
|
+
import { fetchAllManifest, getLastProjectInfo } from '../api.js';
|
|
4
4
|
import { fileSha256 } from '../util/hash.js';
|
|
5
5
|
import { assetDiskPath } from '../util/paths.js';
|
|
6
6
|
import { readKeyForDisplay } from '../util/credentials.js';
|
|
@@ -66,10 +66,18 @@ export async function statusCommand() {
|
|
|
66
66
|
else
|
|
67
67
|
changed++;
|
|
68
68
|
})));
|
|
69
|
-
|
|
69
|
+
const project = getLastProjectInfo();
|
|
70
|
+
const projectSuffix = project ? kleur.dim(` (project: ${project.name ?? project.id.slice(0, 8)})`) : '';
|
|
71
|
+
console.log(` remote assets: ${manifest.length}${projectSuffix}`);
|
|
70
72
|
console.log(` ${kleur.dim('=')} unchanged: ${unchanged}`);
|
|
71
73
|
console.log(` ${kleur.yellow('~')} changed: ${changed}`);
|
|
72
74
|
console.log(` ${kleur.green('+')} missing: ${missing}`);
|
|
75
|
+
if (manifest.length === 0 && project?.hint) {
|
|
76
|
+
// Fresh sync into an empty-project key — surface the server's hint so
|
|
77
|
+
// a project mismatch is one glance away instead of a silent zero.
|
|
78
|
+
console.log();
|
|
79
|
+
console.log(kleur.yellow(` ${project.hint}`));
|
|
80
|
+
}
|
|
73
81
|
}
|
|
74
82
|
catch (e) {
|
|
75
83
|
console.log(kleur.red(` manifest fetch failed: ${e.message.split('\n')[0]}`));
|
package/dist/commands/sync.js
CHANGED
|
@@ -5,7 +5,7 @@ import { mkdir, unlink, readdir, rm, writeFile } from 'node:fs/promises';
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { dirname, relative, resolve } from 'node:path';
|
|
7
7
|
import { loadConfig, loadState, saveState } from '../config.js';
|
|
8
|
-
import { fetchAllManifest, fetchAssetBytes, ApiError } from '../api.js';
|
|
8
|
+
import { fetchAllManifest, fetchAssetBytes, ApiError, getLastProjectInfo } from '../api.js';
|
|
9
9
|
import { fileSha256 } from '../util/hash.js';
|
|
10
10
|
import { assetDiskPath, assetDiskPathFromKey, pruneEmptyDirs, walkOutDirPngs } from '../util/paths.js';
|
|
11
11
|
import { createLimit } from '../util/limit.js';
|
|
@@ -319,7 +319,19 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
319
319
|
let manifest;
|
|
320
320
|
try {
|
|
321
321
|
manifest = await fetchAllManifest(config, since);
|
|
322
|
-
|
|
322
|
+
const projectInfo = getLastProjectInfo();
|
|
323
|
+
const projectSuffix = projectInfo && verbose
|
|
324
|
+
? kleur.dim(` · project: ${projectInfo.name ?? projectInfo.id.slice(0, 8)}`)
|
|
325
|
+
: '';
|
|
326
|
+
spinner?.succeed(`Manifest: ${manifest.length} asset${manifest.length === 1 ? '' : 's'}${since ? kleur.dim(' (incremental)') : ''}${projectSuffix}`);
|
|
327
|
+
// Fresh full sync + zero assets = almost always a project-scope mismatch
|
|
328
|
+
// (the exact case the "0 assets" support flow hits). Server includes a
|
|
329
|
+
// ready-to-print hint on the empty-fresh-sync response; surface it once
|
|
330
|
+
// so the user isn't left wondering why sync "did nothing".
|
|
331
|
+
if (!since && manifest.length === 0 && projectInfo?.hint && verbose) {
|
|
332
|
+
console.log();
|
|
333
|
+
console.log(kleur.yellow(`! ${projectInfo.hint}`));
|
|
334
|
+
}
|
|
323
335
|
}
|
|
324
336
|
catch (e) {
|
|
325
337
|
spinner?.fail('Manifest fetch failed');
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
2
|
import { loadConfig, defaultConfig, resolveEndpoint, getApiKey } from '../config.js';
|
|
3
|
-
import { ApiError, retryTransient, retryAfterMsFromResponse } from '../api.js';
|
|
3
|
+
import { ApiError, retryTransient, retryAfterMsFromResponse, getLastProjectInfo } from '../api.js';
|
|
4
4
|
import { safeFetch } from '../util/security.js';
|
|
5
5
|
import { authHeaders } from '../util/authHeaders.js';
|
|
6
6
|
export async function whoamiCommand() {
|
|
@@ -49,7 +49,25 @@ export async function whoamiCommand() {
|
|
|
49
49
|
items: Array.isArray(raw.items) ? raw.items.filter((i) => !!i && typeof i.key === 'string') : [],
|
|
50
50
|
nextCursor: typeof raw.nextCursor === 'string' && raw.nextCursor ? raw.nextCursor : null,
|
|
51
51
|
};
|
|
52
|
-
|
|
52
|
+
// Prefer body.project (has totalInProject + hint on empty fresh syncs);
|
|
53
|
+
// fall back to response headers so paginated / non-empty responses still
|
|
54
|
+
// surface the bound project name. Kept in sync with fetchManifestPage.
|
|
55
|
+
const bodyProject = raw.project;
|
|
56
|
+
const projectId = res.headers.get('x-magicpixel-project-id');
|
|
57
|
+
const projectName = res.headers.get('x-magicpixel-project-name');
|
|
58
|
+
let project = null;
|
|
59
|
+
if (bodyProject && typeof bodyProject === 'object' && typeof bodyProject.id === 'string') {
|
|
60
|
+
project = {
|
|
61
|
+
id: bodyProject.id,
|
|
62
|
+
name: typeof bodyProject.name === 'string' ? bodyProject.name : (projectName ?? null),
|
|
63
|
+
totalInProject: typeof bodyProject.totalInProject === 'number' ? bodyProject.totalInProject : undefined,
|
|
64
|
+
hint: typeof bodyProject.hint === 'string' ? bodyProject.hint : undefined,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
else if (projectId) {
|
|
68
|
+
project = { id: projectId, name: projectName };
|
|
69
|
+
}
|
|
70
|
+
return { kind: 'ok', body, project, serverRequestId };
|
|
53
71
|
});
|
|
54
72
|
if (result.kind === 'rejected') {
|
|
55
73
|
console.log(kleur.red(`✗ API key rejected (${result.status}).`));
|
|
@@ -64,15 +82,33 @@ export async function whoamiCommand() {
|
|
|
64
82
|
process.exitCode = 1;
|
|
65
83
|
return;
|
|
66
84
|
}
|
|
67
|
-
const { body } = result;
|
|
85
|
+
const { body, project } = result;
|
|
68
86
|
const more = body.nextCursor ? '+' : '';
|
|
69
87
|
console.log(kleur.green('✓ key valid'));
|
|
70
88
|
console.log(` endpoint: ${resolveEndpoint(config)}`);
|
|
89
|
+
if (project) {
|
|
90
|
+
const label = project.name ?? '(unnamed)';
|
|
91
|
+
console.log(` project: ${label} ${kleur.dim(`(${project.id.slice(0, 8)}…)`)}`);
|
|
92
|
+
}
|
|
71
93
|
if (body.count === 0) {
|
|
72
|
-
|
|
94
|
+
if (project?.hint) {
|
|
95
|
+
// Server-provided hint already names the project; use it verbatim.
|
|
96
|
+
console.log(` visible: ${kleur.yellow('0 assets')}`);
|
|
97
|
+
console.log(kleur.dim(` ${project.hint}`));
|
|
98
|
+
}
|
|
99
|
+
else if (project) {
|
|
100
|
+
console.log(` visible: ${kleur.yellow(`0 assets in project "${project.name ?? project.id.slice(0, 8)}"`)}`);
|
|
101
|
+
console.log(kleur.dim(` If your art lives in another project, create a key from that project's row in Settings → Projects & API Keys.`));
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
console.log(` visible: ${kleur.yellow('0 assets — is the key bound to a project with content?')}`);
|
|
105
|
+
}
|
|
73
106
|
}
|
|
74
107
|
else {
|
|
75
108
|
console.log(` visible: ${body.count}${more} asset${body.count === 1 && !more ? '' : 's'}` +
|
|
76
109
|
` (first: ${body.items[0]?.key ?? '-'})`);
|
|
77
110
|
}
|
|
111
|
+
// Reference the module-singleton to avoid an unused-import lint on the
|
|
112
|
+
// getLastProjectInfo helper (whoami reads project data inline).
|
|
113
|
+
void getLastProjectInfo;
|
|
78
114
|
}
|
package/dist/util/telemetry.js
CHANGED
|
@@ -41,6 +41,13 @@ export function shouldReportCliError(err) {
|
|
|
41
41
|
if (!(err instanceof Error))
|
|
42
42
|
return false;
|
|
43
43
|
if (err instanceof ApiError) {
|
|
44
|
+
// 546 is a Supabase Edge Runtime platform code (worker boot / CPU /
|
|
45
|
+
// wall-time). It bypasses our function's try/catch and is almost always
|
|
46
|
+
// transient. Retries usually absorb it; a leaked-to-user 546 is still
|
|
47
|
+
// unactionable noise on /admin/errors. Persistent worker failures still
|
|
48
|
+
// surface as 500/502/503, which remain reported.
|
|
49
|
+
if (err.status === 546)
|
|
50
|
+
return false;
|
|
44
51
|
// User/config issues have actionable fix hints already; don't add noise.
|
|
45
52
|
return err.status >= 500;
|
|
46
53
|
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magicpixelart/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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": [
|
|
10
|
-
"dist",
|
|
11
|
-
"README.md",
|
|
12
|
-
"CHANGELOG.md",
|
|
13
|
-
"LICENSE"
|
|
14
|
-
],
|
|
9
|
+
"files": ["dist", "README.md", "CHANGELOG.md", "LICENSE"],
|
|
15
10
|
"scripts": {
|
|
16
11
|
"build": "tsc && chmod +x dist/index.js",
|
|
17
12
|
"dev": "tsx src/index.ts",
|