@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.
@@ -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
+ }
@@ -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
- * Emit `<outDir>/index.ts` with a typed map of every asset key → URL.
14
+ * Walk `<outDir>` and return one entry per `*.png` on disk, ordered by key.
7
15
  *
8
- * import { MagicPixelAssets } from '@/assets/magicpixel';
9
- * const url = MagicPixelAssets['player/walk'];
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, manifest, cwd = process.cwd()) {
15
- // Dedupe by key. A well-behaved manifest never has duplicates, but a server
16
- // bug here would otherwise emit duplicate object keys and break TS compile.
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 = [...manifest]
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
- // Use positional identifiers (asset_0, asset_1, …) so keys that differ only
34
- // by non-alphanumeric chars (e.g. `player/walk` vs `player_walk`) can't
35
- // collide on the JS identifier and break the consumer's TS build.
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
+ }
@@ -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
- assertSafeAssetSegments(entry.folder, entry.slug, entry.key);
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 = `${entry.slug}.png`;
12
- const diskPath = entry.folder ? resolve(base, entry.folder, file) : resolve(base, file);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {