@magicpixelart/cli 0.5.3 → 0.5.5
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 +26 -0
- package/dist/api.js +38 -0
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/init.js +16 -11
- package/dist/commands/start.js +8 -2
- package/dist/commands/status.js +10 -2
- package/dist/commands/sync.js +34 -2
- package/dist/commands/whoami.js +40 -4
- package/dist/util/framework.js +46 -7
- package/dist/util/previousKeyOrphans.js +41 -0
- package/package.json +2 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.5] — 2026-07-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Bound project name in `whoami` / `status` / `sync`.** Manifest responses
|
|
15
|
+
now expose the API key's project via `x-magicpixel-project-*` headers and an
|
|
16
|
+
optional `project` body field. The CLI surfaces `project: <name>` so a
|
|
17
|
+
key-bound-to-the-wrong-project mistake is obvious at a glance. On empty
|
|
18
|
+
fresh syncs the server may also send `totalInProject` + a ready-to-print
|
|
19
|
+
`hint`; `whoami`, `status`, and verbose `sync` print it instead of a silent
|
|
20
|
+
zero. Old servers without the headers keep working — the project line is
|
|
21
|
+
simply omitted.
|
|
22
|
+
|
|
23
|
+
## [0.5.4] — 2026-06-22
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **Game engine project detection.** `init` and `doctor` now recognize Unity
|
|
28
|
+
(`ProjectSettings/ProjectVersion.txt`), Godot (`project.godot`), and
|
|
29
|
+
GameMaker (root `*.yyp`) and suggest engine-conventional `outDir` defaults:
|
|
30
|
+
`Assets/MagicPixel`, `assets/magicpixel`, and `datafiles/magicpixel`.
|
|
31
|
+
JS framework detection still runs first (monorepo-safe). Engine projects
|
|
32
|
+
skip typed `index.ts` and npm watch-script prompts; GameMaker prints a
|
|
33
|
+
one-liner to refresh Included Files after sync. `magicpixel start` remains
|
|
34
|
+
JS-only and redirects engine users to `init → login → sync --watch`.
|
|
35
|
+
|
|
10
36
|
## [0.5.3] — 2026-06-02
|
|
11
37
|
|
|
12
38
|
### Fixed
|
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) —
|
|
@@ -86,6 +101,29 @@ export async function fetchManifestPage(opts) {
|
|
|
86
101
|
if (!isObject || !Array.isArray(data.items) || !cursorOk) {
|
|
87
102
|
throw new ApiError(502, withRequestId('manifest: unexpected server response shape (items missing or non-string cursor).', serverRequestId), serverRequestId);
|
|
88
103
|
}
|
|
104
|
+
// Capture the bound project's name/id from response headers (always
|
|
105
|
+
// present on 2xx). The body may also carry a richer `project` payload
|
|
106
|
+
// (empty fresh-sync case, includes totalInProject + hint). Prefer the
|
|
107
|
+
// body when present so `whoami`/`sync` can print the "0 assets in this
|
|
108
|
+
// project" nudge without another round-trip.
|
|
109
|
+
const projectId = res.headers.get('x-magicpixel-project-id');
|
|
110
|
+
const projectName = res.headers.get('x-magicpixel-project-name');
|
|
111
|
+
const bodyProject = data.project;
|
|
112
|
+
if (bodyProject && typeof bodyProject === 'object' && typeof bodyProject.id === 'string') {
|
|
113
|
+
lastProjectInfo = {
|
|
114
|
+
id: bodyProject.id,
|
|
115
|
+
name: typeof bodyProject.name === 'string' ? bodyProject.name : (projectName ?? null),
|
|
116
|
+
totalInProject: typeof bodyProject.totalInProject === 'number'
|
|
117
|
+
? bodyProject.totalInProject
|
|
118
|
+
: undefined,
|
|
119
|
+
hint: typeof bodyProject.hint === 'string'
|
|
120
|
+
? bodyProject.hint
|
|
121
|
+
: undefined,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
else if (projectId) {
|
|
125
|
+
lastProjectInfo = { id: projectId, name: projectName };
|
|
126
|
+
}
|
|
89
127
|
return data;
|
|
90
128
|
}
|
|
91
129
|
const bodyText = await res.text();
|
package/dist/commands/doctor.js
CHANGED
|
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { resolve, relative } from 'node:path';
|
|
5
5
|
import { loadConfig, loadState, resolveEndpoint, defaultConfig } from '../config.js';
|
|
6
6
|
import { describeKeySource, readKeyForDisplay } from '../util/credentials.js';
|
|
7
|
-
import {
|
|
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';
|
|
@@ -24,7 +24,7 @@ export async function doctorCommand(opts = {}) {
|
|
|
24
24
|
* state on disk.
|
|
25
25
|
*/
|
|
26
26
|
export async function collectDoctorReport(opts = {}) {
|
|
27
|
-
const framework = await
|
|
27
|
+
const framework = await detectProjectKind();
|
|
28
28
|
const pkgFound = hasPackageJson();
|
|
29
29
|
let config = null;
|
|
30
30
|
let configErr = null;
|
package/dist/commands/init.js
CHANGED
|
@@ -5,7 +5,7 @@ import { resolve } from 'node:path';
|
|
|
5
5
|
import { createInterface } from 'node:readline/promises';
|
|
6
6
|
import { stdin, stdout } from 'node:process';
|
|
7
7
|
import { configPath, defaultConfig, saveConfig } from '../config.js';
|
|
8
|
-
import {
|
|
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
11
|
const WATCH_SCRIPT_NAME = 'magicpixel:watch';
|
|
@@ -19,18 +19,18 @@ export async function initCommand(opts) {
|
|
|
19
19
|
const interactive = !opts.yes && stdin.isTTY;
|
|
20
20
|
const config = { ...defaultConfig };
|
|
21
21
|
let addGitignore = true;
|
|
22
|
-
const
|
|
23
|
-
const suggestedOutDir = suggestOutDir(
|
|
22
|
+
const kind = await detectProjectKind();
|
|
23
|
+
const suggestedOutDir = suggestOutDir(kind);
|
|
24
24
|
config.outDir = suggestedOutDir;
|
|
25
25
|
const pkgExists = hasPackageJson();
|
|
26
26
|
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
27
|
-
let addWatchScript = pkgExists;
|
|
27
|
+
let addWatchScript = pkgExists && !isEngineKind(kind);
|
|
28
28
|
if (interactive) {
|
|
29
29
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
30
30
|
try {
|
|
31
31
|
console.log(kleur.bold('Set up @magicpixelart/cli'));
|
|
32
|
-
if (
|
|
33
|
-
console.log(kleur.dim(` Detected: ${
|
|
32
|
+
if (kind)
|
|
33
|
+
console.log(kleur.dim(` Detected: ${kind}`));
|
|
34
34
|
console.log();
|
|
35
35
|
// Re-prompt loop: catch unsafe outDir values (null bytes, `..` segments)
|
|
36
36
|
// at input time rather than letting `loadConfig` reject them on the next
|
|
@@ -47,9 +47,11 @@ export async function initCommand(opts) {
|
|
|
47
47
|
console.log(kleur.yellow(` ${e.message} Try again.`));
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
-
// Typed index only makes sense
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// Typed index only makes sense for JS projects with a bundler-importable outDir.
|
|
51
|
+
if (!supportsTypedIndex(kind)) {
|
|
52
|
+
config.emitIndex = false;
|
|
53
|
+
}
|
|
54
|
+
else if (!isStaticOutDir(config.outDir)) {
|
|
53
55
|
const emitAns = (await rl.question(`${kleur.cyan('?')} Emit a typed index.ts for autocomplete? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
|
|
54
56
|
config.emitIndex = emitAns !== 'n' && emitAns !== 'no';
|
|
55
57
|
}
|
|
@@ -59,7 +61,7 @@ export async function initCommand(opts) {
|
|
|
59
61
|
}
|
|
60
62
|
const giAns = (await rl.question(`${kleur.cyan('?')} Add .magicpixel/ to .gitignore? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
|
|
61
63
|
addGitignore = giAns !== 'n' && giAns !== 'no';
|
|
62
|
-
if (pkgExists) {
|
|
64
|
+
if (pkgExists && !isEngineKind(kind)) {
|
|
63
65
|
const wAns = (await rl.question(`${kleur.cyan('?')} Add a "${WATCH_SCRIPT_NAME}" script to package.json? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
|
|
64
66
|
addWatchScript = wAns !== 'n' && wAns !== 'no';
|
|
65
67
|
}
|
|
@@ -69,11 +71,14 @@ export async function initCommand(opts) {
|
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
else {
|
|
72
|
-
config.emitIndex = !isStaticOutDir(config.outDir);
|
|
74
|
+
config.emitIndex = supportsTypedIndex(kind) && !isStaticOutDir(config.outDir);
|
|
73
75
|
}
|
|
74
76
|
await saveConfig(config);
|
|
75
77
|
console.log();
|
|
76
78
|
console.log(kleur.green('✓ wrote magicpixel.json'));
|
|
79
|
+
if (kind === 'GameMaker') {
|
|
80
|
+
console.log(kleur.dim(" Note: GameMaker doesn't auto-import — refresh Included Files after each sync."));
|
|
81
|
+
}
|
|
77
82
|
if (addGitignore) {
|
|
78
83
|
const added = await ensureGitignore();
|
|
79
84
|
if (added)
|
package/dist/commands/start.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
5
5
|
import { stdin, stdout } from 'node:process';
|
|
6
6
|
import { resolve } from 'node:path';
|
|
7
7
|
import { configPath, loadConfig } from '../config.js';
|
|
8
|
-
import { hasPackageJson } from '../util/framework.js';
|
|
8
|
+
import { detectProjectKind, hasPackageJson, isEngineKind } from '../util/framework.js';
|
|
9
9
|
import { findKeyInDotenv, readCredentialsSync, writeCredentials } from '../util/credentials.js';
|
|
10
10
|
import { initCommand } from './init.js';
|
|
11
11
|
import { loginCommand } from './login.js';
|
|
@@ -20,8 +20,14 @@ export async function startCommand(opts = {}) {
|
|
|
20
20
|
console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
|
|
21
21
|
console.log(kleur.dim(' This walks you through getting your sprites onto disk.'));
|
|
22
22
|
console.log();
|
|
23
|
-
// 1.
|
|
23
|
+
// 1. `start` is JS-only — engine projects use init + login + sync --watch.
|
|
24
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
|
+
}
|
|
25
31
|
console.log(kleur.yellow(' MagicPixel needs a JavaScript project (package.json) to sync into.\n' +
|
|
26
32
|
' Run this inside your project folder — the one with package.json.'));
|
|
27
33
|
return;
|
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';
|
|
@@ -15,6 +15,7 @@ import { runTmpJanitor } from '../util/tmpJanitor.js';
|
|
|
15
15
|
import { friendlyFsError } from '../util/errors.js';
|
|
16
16
|
import { maxIsoTimestamp } from '../util/iso.js';
|
|
17
17
|
import { formatBytes } from '../util/format.js';
|
|
18
|
+
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
18
19
|
export async function syncCommand(opts) {
|
|
19
20
|
// Sweep any leaked `<file>.<pid>.<hex>.tmp` files left behind by a prior
|
|
20
21
|
// crashed/killed CLI run before they pile up. Runs once per CLI invocation
|
|
@@ -318,7 +319,19 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
318
319
|
let manifest;
|
|
319
320
|
try {
|
|
320
321
|
manifest = await fetchAllManifest(config, since);
|
|
321
|
-
|
|
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
|
+
}
|
|
322
335
|
}
|
|
323
336
|
catch (e) {
|
|
324
337
|
spinner?.fail('Manifest fetch failed');
|
|
@@ -427,6 +440,25 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
427
440
|
const orphanSet = new Set(orphans);
|
|
428
441
|
for (const p of renameStalePaths)
|
|
429
442
|
orphanSet.add(p);
|
|
443
|
+
// Server-side rename history. Each manifest entry carries the composite
|
|
444
|
+
// keys this row was previously emitted under (populated by the editor when
|
|
445
|
+
// a doc/artboard is renamed). For each prior key whose file is still on
|
|
446
|
+
// disk and is NOT shadowed by a live manifest entry, we add a rename hint
|
|
447
|
+
// + prune target — so cruft like `untitled-19/untitled.png` evaporates on
|
|
448
|
+
// the next sync without the user having to clean it up manually.
|
|
449
|
+
const prevKeyResult = computePreviousKeyOrphans({
|
|
450
|
+
manifest,
|
|
451
|
+
remoteDiskPaths,
|
|
452
|
+
resolveDiskPath: (key) => assetDiskPathFromKey(config.outDir, key),
|
|
453
|
+
fileExists: existsSync,
|
|
454
|
+
});
|
|
455
|
+
for (const p of prevKeyResult.orphanPaths)
|
|
456
|
+
orphanSet.add(p);
|
|
457
|
+
for (const r of prevKeyResult.renames) {
|
|
458
|
+
if (!renamed.some((x) => x.id === r.id && x.oldKey === r.oldKey)) {
|
|
459
|
+
renamed.push(r);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
430
462
|
orphans = [...orphanSet];
|
|
431
463
|
// Legacy-suffix folder sweep — always runs, even in incremental mode.
|
|
432
464
|
//
|
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/framework.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
-
|
|
5
|
-
export
|
|
4
|
+
const ENGINE_KINDS = new Set(['Unity', 'Godot', 'GameMaker']);
|
|
5
|
+
export function isEngineKind(kind) {
|
|
6
|
+
return kind !== null && ENGINE_KINDS.has(kind);
|
|
7
|
+
}
|
|
8
|
+
/** Typed `index.ts` is JS-only — game engines load PNGs directly. */
|
|
9
|
+
export function supportsTypedIndex(kind) {
|
|
10
|
+
return !isEngineKind(kind);
|
|
11
|
+
}
|
|
12
|
+
async function detectJsKind(cwd) {
|
|
6
13
|
try {
|
|
7
14
|
const pkgPath = resolve(cwd, 'package.json');
|
|
8
15
|
if (!existsSync(pkgPath))
|
|
@@ -31,9 +38,35 @@ export async function detectFramework(cwd = process.cwd()) {
|
|
|
31
38
|
return null;
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
function detectEngineKind(cwd) {
|
|
42
|
+
if (existsSync(resolve(cwd, 'project.godot')))
|
|
43
|
+
return 'Godot';
|
|
44
|
+
if (existsSync(resolve(cwd, 'ProjectSettings', 'ProjectVersion.txt')))
|
|
45
|
+
return 'Unity';
|
|
46
|
+
try {
|
|
47
|
+
const yyp = readdirSync(cwd).find((name) => name.endsWith('.yyp'));
|
|
48
|
+
if (yyp)
|
|
49
|
+
return 'GameMaker';
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// unreadable cwd — treat as no engine marker
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** Infer project kind from package.json deps, then engine marker files. */
|
|
57
|
+
export async function detectProjectKind(cwd = process.cwd()) {
|
|
58
|
+
const js = await detectJsKind(cwd);
|
|
59
|
+
if (js)
|
|
60
|
+
return js;
|
|
61
|
+
return detectEngineKind(cwd);
|
|
62
|
+
}
|
|
63
|
+
/** @deprecated use `detectProjectKind` */
|
|
64
|
+
export async function detectFramework(cwd = process.cwd()) {
|
|
65
|
+
return detectProjectKind(cwd);
|
|
66
|
+
}
|
|
67
|
+
/** Default `outDir` for a detected project kind. */
|
|
68
|
+
export function suggestOutDir(kind, cwd = process.cwd()) {
|
|
69
|
+
switch (kind) {
|
|
37
70
|
case 'Next.js':
|
|
38
71
|
case 'Astro':
|
|
39
72
|
case 'Nuxt':
|
|
@@ -45,8 +78,14 @@ export function suggestOutDir(framework, cwd = process.cwd()) {
|
|
|
45
78
|
case 'Remix':
|
|
46
79
|
case 'TanStack Start':
|
|
47
80
|
return 'src/assets/magicpixel';
|
|
81
|
+
case 'Unity':
|
|
82
|
+
return 'Assets/MagicPixel';
|
|
83
|
+
case 'Godot':
|
|
84
|
+
return 'assets/magicpixel';
|
|
85
|
+
case 'GameMaker':
|
|
86
|
+
return 'datafiles/magicpixel';
|
|
48
87
|
default:
|
|
49
|
-
// No
|
|
88
|
+
// No kind detected — prefer src/ if present so the typed index is
|
|
50
89
|
// importable; otherwise fall back to a top-level assets/ dir.
|
|
51
90
|
return existsSync(resolve(cwd, 'src')) ? 'src/assets/magicpixel' : 'assets/magicpixel';
|
|
52
91
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk each manifest entry's `previous_keys`, emit orphan paths + rename
|
|
3
|
+
* hints for prior composite keys whose PNGs still exist on disk and are
|
|
4
|
+
* NOT shadowed by a live manifest entry.
|
|
5
|
+
*
|
|
6
|
+
* Pure / side-effect-free — file existence + path resolution are injected
|
|
7
|
+
* so this is unit-testable without touching the real filesystem.
|
|
8
|
+
*
|
|
9
|
+
* The same `previous_keys` array is repeated on every entry from a given
|
|
10
|
+
* row; the `(id|prevKey)` signature set keeps the work idempotent.
|
|
11
|
+
*/
|
|
12
|
+
export function computePreviousKeyOrphans(input) {
|
|
13
|
+
const { manifest, remoteDiskPaths, resolveDiskPath, fileExists } = input;
|
|
14
|
+
const orphans = new Set();
|
|
15
|
+
const renames = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (const entry of manifest) {
|
|
18
|
+
if (!entry.previous_keys || entry.previous_keys.length === 0)
|
|
19
|
+
continue;
|
|
20
|
+
for (const prevKey of entry.previous_keys) {
|
|
21
|
+
if (prevKey === entry.key)
|
|
22
|
+
continue;
|
|
23
|
+
const sig = `${entry.id}|${prevKey}`;
|
|
24
|
+
if (seen.has(sig))
|
|
25
|
+
continue;
|
|
26
|
+
seen.add(sig);
|
|
27
|
+
const prevPath = resolveDiskPath(prevKey);
|
|
28
|
+
// Skip when another live entry owns the old path (e.g. the "old" name
|
|
29
|
+
// is still actively used by a different row in the same project).
|
|
30
|
+
if (remoteDiskPaths.has(prevPath))
|
|
31
|
+
continue;
|
|
32
|
+
if (!fileExists(prevPath))
|
|
33
|
+
continue;
|
|
34
|
+
orphans.add(prevPath);
|
|
35
|
+
if (!renames.some((r) => r.id === entry.id && r.oldKey === prevKey)) {
|
|
36
|
+
renames.push({ id: entry.id, oldKey: prevKey, newKey: entry.key });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { orphanPaths: [...orphans], renames };
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magicpixelart/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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",
|