@magicpixelart/cli 0.1.2 → 0.3.0
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/README.md +54 -13
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/init.js +60 -45
- package/dist/commands/login.js +98 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/start.js +105 -0
- package/dist/commands/sync.js +243 -72
- package/dist/config.js +22 -6
- package/dist/index.js +23 -1
- package/dist/util/credentials.js +82 -0
- package/dist/util/emitIndex.js +141 -15
- package/dist/util/framework.js +56 -0
- package/dist/util/paths.js +17 -3
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile, unlink, chmod } from 'node:fs/promises';
|
|
3
|
+
import { resolve, dirname } from 'node:path';
|
|
4
|
+
const CREDENTIALS_DIR = '.magicpixel';
|
|
5
|
+
const CREDENTIALS_FILE = 'credentials';
|
|
6
|
+
export function credentialsPath(cwd = process.cwd()) {
|
|
7
|
+
return resolve(cwd, CREDENTIALS_DIR, CREDENTIALS_FILE);
|
|
8
|
+
}
|
|
9
|
+
/** Synchronous read — called from `getApiKey()` which must stay sync. */
|
|
10
|
+
export function readCredentialsSync(cwd = process.cwd()) {
|
|
11
|
+
const path = credentialsPath(cwd);
|
|
12
|
+
if (!existsSync(path))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const raw = readFileSync(path, 'utf8');
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (typeof parsed.apiKey === 'string' && parsed.apiKey.trim()) {
|
|
18
|
+
return { apiKey: parsed.apiKey.trim(), savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : '' };
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function writeCredentials(apiKey, cwd = process.cwd()) {
|
|
27
|
+
const path = credentialsPath(cwd);
|
|
28
|
+
await mkdir(dirname(path), { recursive: true });
|
|
29
|
+
const body = { apiKey: apiKey.trim(), savedAt: new Date().toISOString() };
|
|
30
|
+
await writeFile(path, JSON.stringify(body, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
31
|
+
// Belt-and-suspenders: chmod again in case the file pre-existed with looser perms.
|
|
32
|
+
try {
|
|
33
|
+
await chmod(path, 0o600);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// ignore (Windows or read-only FS)
|
|
37
|
+
}
|
|
38
|
+
return path;
|
|
39
|
+
}
|
|
40
|
+
export async function deleteCredentials(cwd = process.cwd()) {
|
|
41
|
+
const path = credentialsPath(cwd);
|
|
42
|
+
if (!existsSync(path))
|
|
43
|
+
return false;
|
|
44
|
+
try {
|
|
45
|
+
await unlink(path);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Look for `MAGICPIXEL_API_KEY=…` in a `.env` / `.env.local` file. Returns the
|
|
54
|
+
* raw value (no validation) or `null`. Used by `init`/`start` to offer migration
|
|
55
|
+
* away from bundler-visible env files.
|
|
56
|
+
*/
|
|
57
|
+
export async function findKeyInDotenv(cwd = process.cwd()) {
|
|
58
|
+
for (const name of ['.env.local', '.env']) {
|
|
59
|
+
const path = resolve(cwd, name);
|
|
60
|
+
if (!existsSync(path))
|
|
61
|
+
continue;
|
|
62
|
+
try {
|
|
63
|
+
const raw = await readFile(path, 'utf8');
|
|
64
|
+
for (const line of raw.split('\n')) {
|
|
65
|
+
const m = line.match(/^\s*MAGICPIXEL_API_KEY\s*=\s*"?([^"\n#]+?)"?\s*$/);
|
|
66
|
+
if (m && m[1])
|
|
67
|
+
return { file: name, value: m[1].trim() };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
export function describeKeySource(cwd = process.cwd()) {
|
|
77
|
+
if (process.env.MAGICPIXEL_API_KEY)
|
|
78
|
+
return 'env';
|
|
79
|
+
if (readCredentialsSync(cwd))
|
|
80
|
+
return 'credentials-file';
|
|
81
|
+
return 'none';
|
|
82
|
+
}
|
package/dist/util/emitIndex.js
CHANGED
|
@@ -1,40 +1,95 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
-
import { assertSafeAssetSegments } from './security.js';
|
|
4
|
+
import { assertPathInsideRoot, assertSafeAssetSegments } from './security.js';
|
|
5
|
+
const AGENT_HEADER = [
|
|
6
|
+
'// 👋 AI agents: import sprites by key, e.g.',
|
|
7
|
+
'// import { MagicPixelAssets } from "<this-dir>";',
|
|
8
|
+
'// const tree = MagicPixelAssets["items/tree"];',
|
|
9
|
+
'// Keys survive folder reorganization when you use MagicPixelAssetsById instead:',
|
|
10
|
+
'// import { MagicPixelAssetsById } from "<this-dir>";',
|
|
11
|
+
'// const tree = MagicPixelAssetsById["<asset-uuid>"];',
|
|
12
|
+
].join('\n');
|
|
5
13
|
/**
|
|
6
|
-
*
|
|
14
|
+
* Walk `<outDir>` and return one entry per `*.png` on disk, ordered by key.
|
|
7
15
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
16
|
+
* The CLI used to emit the typed index straight from a manifest response. That
|
|
17
|
+
* coupled `index.ts` correctness to a successful network round-trip; a flaky
|
|
18
|
+
* manifest call left the barrel stale while the PNGs on disk were fresh. We
|
|
19
|
+
* now derive the index purely from the filesystem so it can never disagree
|
|
20
|
+
* with what bundlers will see.
|
|
21
|
+
*/
|
|
22
|
+
export async function scanDiskAssets(outDir, cwd = process.cwd()) {
|
|
23
|
+
const root = resolve(cwd, outDir);
|
|
24
|
+
if (!existsSync(root))
|
|
25
|
+
return [];
|
|
26
|
+
const out = [];
|
|
27
|
+
async function walk(dir, folderParts) {
|
|
28
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
29
|
+
for (const ent of entries) {
|
|
30
|
+
if (ent.isSymbolicLink())
|
|
31
|
+
continue;
|
|
32
|
+
const full = resolve(dir, ent.name);
|
|
33
|
+
try {
|
|
34
|
+
assertPathInsideRoot(full, root, 'outDir');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (ent.isDirectory()) {
|
|
40
|
+
// Skip the id sidecar dir or any dotted folders — never user assets.
|
|
41
|
+
if (ent.name.startsWith('.'))
|
|
42
|
+
continue;
|
|
43
|
+
await walk(full, [...folderParts, ent.name]);
|
|
44
|
+
}
|
|
45
|
+
else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
|
|
46
|
+
const slug = ent.name.slice(0, -4);
|
|
47
|
+
const folder = folderParts.length ? folderParts.join('/') : null;
|
|
48
|
+
const key = folder ? `${folder}/${slug}` : slug;
|
|
49
|
+
out.push({ key, folder, slug });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await walk(root, []);
|
|
54
|
+
out.sort((a, b) => a.key.localeCompare(b.key));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Emit `<outDir>/index.ts` with a typed map of every asset key → URL, plus an
|
|
59
|
+
* id-keyed map for rename-resilient imports.
|
|
60
|
+
*
|
|
61
|
+
* import { MagicPixelAssets, MagicPixelAssetsById } from '@/assets/magicpixel';
|
|
62
|
+
* const a = MagicPixelAssets['player/walk']; // breaks on rename
|
|
63
|
+
* const b = MagicPixelAssetsById['abc123…']; // survives rename
|
|
10
64
|
*
|
|
11
65
|
* Vite, webpack, esbuild, Parcel, Rollup, etc. all hash these URLs at build
|
|
12
66
|
* time. No runtime, no bundler config.
|
|
13
67
|
*/
|
|
14
|
-
export async function emitTypedIndex(outDir,
|
|
15
|
-
// Dedupe by key.
|
|
16
|
-
//
|
|
68
|
+
export async function emitTypedIndex(outDir, entries, cwd = process.cwd()) {
|
|
69
|
+
// Dedupe by key. The disk scan can't actually produce duplicates, but guard
|
|
70
|
+
// anyway so a future caller passing manifest entries can't break TS compile.
|
|
17
71
|
const seen = new Set();
|
|
18
|
-
const sorted = [...
|
|
72
|
+
const sorted = [...entries]
|
|
19
73
|
.sort((a, b) => a.key.localeCompare(b.key))
|
|
20
74
|
.filter((e) => {
|
|
21
|
-
if (seen.has(e.key))
|
|
22
|
-
console.warn(`magicpixel: duplicate manifest key "${e.key}" — keeping first.`);
|
|
75
|
+
if (seen.has(e.key))
|
|
23
76
|
return false;
|
|
24
|
-
}
|
|
25
77
|
seen.add(e.key);
|
|
26
78
|
return true;
|
|
27
79
|
});
|
|
28
80
|
const lines = [
|
|
29
81
|
'// AUTOGENERATED by @magicpixelart/cli. Do not edit.',
|
|
30
82
|
'// Re-runs of `magicpixel sync` overwrite this file.',
|
|
83
|
+
'//',
|
|
84
|
+
AGENT_HEADER,
|
|
31
85
|
'',
|
|
32
86
|
];
|
|
33
|
-
//
|
|
34
|
-
// by non-alphanumeric chars (e.g. `
|
|
35
|
-
|
|
87
|
+
// Positional identifiers (asset_0, asset_1, …) avoid collisions between
|
|
88
|
+
// keys that differ only by non-alphanumeric chars (e.g. `a/b` vs `a_b`).
|
|
89
|
+
const indexByKey = new Map();
|
|
36
90
|
sorted.forEach((e, i) => {
|
|
37
91
|
assertSafeAssetSegments(e.folder, e.slug, e.key);
|
|
92
|
+
indexByKey.set(e.key, i);
|
|
38
93
|
const rel = e.folder ? `./${e.folder}/${e.slug}.png` : `./${e.slug}.png`;
|
|
39
94
|
lines.push(`import asset_${i} from ${JSON.stringify(rel)};`);
|
|
40
95
|
});
|
|
@@ -47,6 +102,22 @@ export async function emitTypedIndex(outDir, manifest, cwd = process.cwd()) {
|
|
|
47
102
|
lines.push('');
|
|
48
103
|
lines.push('export type MagicPixelAssetKey = keyof typeof MagicPixelAssets;');
|
|
49
104
|
lines.push('');
|
|
105
|
+
// Rename-resilient id map. Only populated for entries whose id we know
|
|
106
|
+
// (from the sidecar written by `sync`). Entries without an id are omitted
|
|
107
|
+
// rather than getting a synthetic key that could later collide.
|
|
108
|
+
lines.push('export const MagicPixelAssetsById = {');
|
|
109
|
+
for (const e of sorted) {
|
|
110
|
+
if (typeof e.id !== 'string' || e.id.length === 0)
|
|
111
|
+
continue;
|
|
112
|
+
const i = indexByKey.get(e.key);
|
|
113
|
+
if (i === undefined)
|
|
114
|
+
continue;
|
|
115
|
+
lines.push(` ${JSON.stringify(e.id)}: asset_${i},`);
|
|
116
|
+
}
|
|
117
|
+
lines.push('} as const;');
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push('export type MagicPixelAssetId = keyof typeof MagicPixelAssetsById;');
|
|
120
|
+
lines.push('');
|
|
50
121
|
const path = resolve(cwd, outDir, 'index.ts');
|
|
51
122
|
const next = lines.join('\n');
|
|
52
123
|
// Skip the write when contents are identical. Avoids triggering Vite/webpack
|
|
@@ -64,3 +135,58 @@ export async function emitTypedIndex(outDir, manifest, cwd = process.cwd()) {
|
|
|
64
135
|
await writeFile(path, next, 'utf8');
|
|
65
136
|
return path;
|
|
66
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Ensure an `AGENTS.md` at the repo root nudges AI tools (Lovable, Cursor,
|
|
140
|
+
* Claude Code, …) toward the typed index. Idempotent:
|
|
141
|
+
*
|
|
142
|
+
* - If no `AGENTS.md` exists, write a short standalone one.
|
|
143
|
+
* - If one exists without our marker, append a single fenced section.
|
|
144
|
+
* - If our marker is already present, do nothing.
|
|
145
|
+
*/
|
|
146
|
+
export async function ensureAgentsDoc(outDir, cwd = process.cwd()) {
|
|
147
|
+
const path = resolve(cwd, 'AGENTS.md');
|
|
148
|
+
const importPath = guessImportPath(outDir);
|
|
149
|
+
const section = renderAgentsSection(importPath);
|
|
150
|
+
const marker = '<!-- magicpixel:start -->';
|
|
151
|
+
if (!existsSync(path)) {
|
|
152
|
+
await writeFile(path, `# AGENTS\n\n${section}\n`, 'utf8');
|
|
153
|
+
return 'created';
|
|
154
|
+
}
|
|
155
|
+
const current = await readFile(path, 'utf8');
|
|
156
|
+
if (current.includes(marker))
|
|
157
|
+
return 'unchanged';
|
|
158
|
+
const sep = current.endsWith('\n') ? '\n' : '\n\n';
|
|
159
|
+
await writeFile(path, current + sep + section + '\n', 'utf8');
|
|
160
|
+
return 'appended';
|
|
161
|
+
}
|
|
162
|
+
function renderAgentsSection(importPath) {
|
|
163
|
+
return [
|
|
164
|
+
'<!-- magicpixel:start -->',
|
|
165
|
+
'## MagicPixel sprites',
|
|
166
|
+
'',
|
|
167
|
+
'Pixel-art assets are synced to disk by `@magicpixelart/cli`. They are plain PNGs you can `import`.',
|
|
168
|
+
'',
|
|
169
|
+
'```ts',
|
|
170
|
+
`// 1) Import by key (matches the folder/slug shown in the MagicPixel editor):`,
|
|
171
|
+
`import { MagicPixelAssets } from "${importPath}";`,
|
|
172
|
+
`<img src={MagicPixelAssets["items/tree"]} />`,
|
|
173
|
+
'',
|
|
174
|
+
'// 2) Import by id (survives renames — pin imports that move around):',
|
|
175
|
+
`import { MagicPixelAssetsById } from "${importPath}";`,
|
|
176
|
+
`<img src={MagicPixelAssetsById["<asset-uuid>"]} />`,
|
|
177
|
+
'```',
|
|
178
|
+
'',
|
|
179
|
+
'When you rename or move an asset, `magicpixel sync` prints both the old → new key',
|
|
180
|
+
'and the matching `MagicPixelAssetsById[...]` hint so you can pick whichever you prefer.',
|
|
181
|
+
'',
|
|
182
|
+
'Keep the watcher running while you edit: `npm run magicpixel:watch`.',
|
|
183
|
+
'<!-- magicpixel:end -->',
|
|
184
|
+
].join('\n');
|
|
185
|
+
}
|
|
186
|
+
function guessImportPath(outDir) {
|
|
187
|
+
const norm = outDir.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
188
|
+
// `src/...` is conventionally aliased to `@/...` in Vite/Next/etc.
|
|
189
|
+
if (norm.startsWith('src/'))
|
|
190
|
+
return `@/${norm.slice(4)}`;
|
|
191
|
+
return `./${norm}`;
|
|
192
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
/** Read `package.json` and infer the framework from its dependencies. */
|
|
5
|
+
export async function detectFramework(cwd = process.cwd()) {
|
|
6
|
+
try {
|
|
7
|
+
const pkgPath = resolve(cwd, 'package.json');
|
|
8
|
+
if (!existsSync(pkgPath))
|
|
9
|
+
return null;
|
|
10
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
11
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
12
|
+
if (deps['next'])
|
|
13
|
+
return 'Next.js';
|
|
14
|
+
if (deps['@tanstack/start'])
|
|
15
|
+
return 'TanStack Start';
|
|
16
|
+
if (deps['@remix-run/react'])
|
|
17
|
+
return 'Remix';
|
|
18
|
+
if (deps['vite'])
|
|
19
|
+
return 'Vite';
|
|
20
|
+
if (deps['react-scripts'])
|
|
21
|
+
return 'Create React App';
|
|
22
|
+
if (deps['astro'])
|
|
23
|
+
return 'Astro';
|
|
24
|
+
if (deps['nuxt'])
|
|
25
|
+
return 'Nuxt';
|
|
26
|
+
if (deps['@sveltejs/kit'])
|
|
27
|
+
return 'SvelteKit';
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Default `outDir` for a detected framework. */
|
|
35
|
+
export function suggestOutDir(framework, cwd = process.cwd()) {
|
|
36
|
+
switch (framework) {
|
|
37
|
+
case 'Next.js':
|
|
38
|
+
case 'Astro':
|
|
39
|
+
case 'Nuxt':
|
|
40
|
+
case 'Create React App':
|
|
41
|
+
return 'public/magicpixel';
|
|
42
|
+
case 'SvelteKit':
|
|
43
|
+
return 'static/magicpixel';
|
|
44
|
+
case 'Vite':
|
|
45
|
+
case 'Remix':
|
|
46
|
+
case 'TanStack Start':
|
|
47
|
+
return 'src/assets/magicpixel';
|
|
48
|
+
default:
|
|
49
|
+
// No framework detected — prefer src/ if present so the typed index is
|
|
50
|
+
// importable; otherwise fall back to a top-level assets/ dir.
|
|
51
|
+
return existsSync(resolve(cwd, 'src')) ? 'src/assets/magicpixel' : 'assets/magicpixel';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function hasPackageJson(cwd = process.cwd()) {
|
|
55
|
+
return existsSync(resolve(cwd, 'package.json'));
|
|
56
|
+
}
|
package/dist/util/paths.js
CHANGED
|
@@ -6,10 +6,24 @@ import { assertPathInsideRoot, assertSafeAssetSegments } from './security.js';
|
|
|
6
6
|
* `outDir/<slug>.png`.
|
|
7
7
|
*/
|
|
8
8
|
export function assetDiskPath(outDir, entry, cwd = process.cwd()) {
|
|
9
|
-
|
|
9
|
+
return resolveAssetDiskPath(outDir, entry.folder, entry.slug, entry.key, cwd);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Same as `assetDiskPath` but takes a raw manifest key (`folder/slug` or
|
|
13
|
+
* `slug`) — useful when we only have the prior key from a snapshot and don't
|
|
14
|
+
* want to synthesize a fake `ManifestEntry` just to reuse the safety checks.
|
|
15
|
+
*/
|
|
16
|
+
export function assetDiskPathFromKey(outDir, key, cwd = process.cwd()) {
|
|
17
|
+
const parts = key.split('/');
|
|
18
|
+
const slug = parts.pop() ?? key;
|
|
19
|
+
const folder = parts.length ? parts.join('/') : null;
|
|
20
|
+
return resolveAssetDiskPath(outDir, folder, slug, key, cwd);
|
|
21
|
+
}
|
|
22
|
+
function resolveAssetDiskPath(outDir, folder, slug, key, cwd) {
|
|
23
|
+
assertSafeAssetSegments(folder, slug, key);
|
|
10
24
|
const base = resolve(cwd, outDir);
|
|
11
|
-
const file = `${
|
|
12
|
-
const diskPath =
|
|
25
|
+
const file = `${slug}.png`;
|
|
26
|
+
const diskPath = folder ? resolve(base, folder, file) : resolve(base, file);
|
|
13
27
|
assertPathInsideRoot(diskPath, base, 'outDir');
|
|
14
28
|
return diskPath;
|
|
15
29
|
}
|