@magicpixelart/cli 0.1.0 → 0.1.2

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 CHANGED
@@ -1,13 +1,13 @@
1
- # @magicpixel/cli
1
+ # @magicpixelart/cli
2
2
 
3
3
  Sync MagicPixel pixel-art assets to your local project as flattened PNGs. Zero runtime — just files on disk that your bundler picks up.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install --save-dev @magicpixel/cli
9
- # or: pnpm add -D @magicpixel/cli
10
- # or: bun add -d @magicpixel/cli
8
+ npm install --save-dev @magicpixelart/cli
9
+ # or: pnpm add -D @magicpixelart/cli
10
+ # or: bun add -d @magicpixelart/cli
11
11
  ```
12
12
 
13
13
  Requires Node.js ≥ 18.
@@ -21,7 +21,9 @@ npx magicpixel sync # downloads changed assets
21
21
  npx magicpixel sync --watch # keeps assets fresh while you work
22
22
  ```
23
23
 
24
- Get an API key at [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys. Each key is bound to one project.
24
+ Get an API key at [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys. Each key is bound to one project. Production keys are prefixed `mp_live_`; sandbox keys (used internally for tests) are prefixed `mp_test_` and accepted the same way.
25
+
26
+ The key is read **only** from the `MAGICPIXEL_API_KEY` environment variable — never from `magicpixel.json` or any other file. This is intentional: it keeps secrets out of repos and CI logs.
25
27
 
26
28
  ## Typed asset index (default on)
27
29
 
@@ -74,9 +76,13 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
74
76
  | `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
75
77
  | `exclude` | `string[]` | `[]` | Globs to exclude. |
76
78
  | `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
77
- | `endpoint` | `string?` | production URL | Override the API base (testing only). |
79
+ | `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
80
+
81
+ State (`.magicpixel/state.json`) tracks `lastSync` (file mode `0600`). Add `.magicpixel/` to `.gitignore` (init offers to do this).
82
+
83
+ ### Custom endpoint (advanced)
78
84
 
79
- State (`.magicpixel/state.json`) tracks `lastSync`. Add `.magicpixel/` to `.gitignore` (init offers to do this).
85
+ For local integration testing against `http://localhost`, set `MAGICPIXEL_ALLOW_INSECURE_ENDPOINT=1` in the environment. Do not commit custom endpoints to shared repos — `sync` warns when `endpoint` is set.
80
86
 
81
87
  ## CI usage
82
88
 
@@ -93,7 +99,7 @@ jobs:
93
99
  - uses: actions/checkout@v4
94
100
  - uses: actions/setup-node@v4
95
101
  with: { node-version: 20 }
96
- - run: npx -y @magicpixel/cli sync --prune --quiet
102
+ - run: npx -y @magicpixelart/cli sync --prune --quiet
97
103
  env:
98
104
  MAGICPIXEL_API_KEY: ${{ secrets.MAGICPIXEL_API_KEY }}
99
105
  - uses: peter-evans/create-pull-request@v6
package/dist/api.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { resolveEndpoint, getApiKey, CLI_USER_AGENT } from './config.js';
2
+ import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, } from './util/security.js';
2
3
  export class ApiError extends Error {
3
4
  status;
4
5
  constructor(status, message) {
@@ -43,7 +44,7 @@ export async function fetchManifestPage(opts) {
43
44
  url.searchParams.set('limit', String(opts.limit));
44
45
  let res;
45
46
  try {
46
- res = await fetch(url, { headers: authHeaders() });
47
+ res = await safeFetch(url.href, { headers: authHeaders() });
47
48
  }
48
49
  catch (e) {
49
50
  throw new Error(`manifest: network error (${e.message}).\n` +
@@ -57,12 +58,18 @@ export async function fetchManifestPage(opts) {
57
58
  export async function fetchAllManifest(config, since) {
58
59
  const out = [];
59
60
  let cursor;
60
- do {
61
- const page = await fetchManifestPage({ config, since, cursor, limit: 500 });
62
- out.push(...page.items);
63
- cursor = page.nextCursor ?? undefined;
64
- } while (cursor);
65
- return out;
61
+ // Cycle guard: caps total pages so a buggy server cursor can't hang the CLI.
62
+ // At 500 entries/page this is 100k assets — well past any realistic project.
63
+ const MAX_PAGES = 200;
64
+ for (let page = 0; page < MAX_PAGES; page++) {
65
+ const res = await fetchManifestPage({ config, since, cursor, limit: 500 });
66
+ out.push(...res.items);
67
+ cursor = res.nextCursor ?? undefined;
68
+ if (!cursor)
69
+ return out;
70
+ }
71
+ throw new Error(`manifest: pagination exceeded ${MAX_PAGES} pages — possible server cursor loop.\n` +
72
+ ` Fix: re-run with --full, or report at https://github.com/magicpixel/cli/issues.`);
66
73
  }
67
74
  /**
68
75
  * Download a single asset by key. Returns null on 304 (not modified).
@@ -73,21 +80,27 @@ export async function fetchAssetBytes(config, key, knownSha) {
73
80
  url.searchParams.set('key', key);
74
81
  const headers = authHeaders();
75
82
  if (knownSha)
76
- headers['If-None-Match'] = `"${knownSha}"`;
83
+ headers['If-None-Match'] = etagForSha256(knownSha);
77
84
  const maxAttempts = 3;
78
85
  let lastErr = null;
79
86
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
80
87
  try {
81
- const res = await fetch(url, { headers });
82
- if (res.status === 304)
88
+ const res = await safeFetch(url.href, { headers });
89
+ if (res.status === 304) {
90
+ // Drain so undici can return the socket to the keep-alive pool.
91
+ await res.body?.cancel();
83
92
  return null;
93
+ }
84
94
  if (res.ok)
85
- return new Uint8Array(await res.arrayBuffer());
95
+ return await readBodyWithLimit(res, MAX_ASSET_BYTES);
96
+ // Consume the body via .text() for the error message; this also drains
97
+ // the stream so we don't leak the socket on retry.
98
+ const bodyText = await res.text();
86
99
  if (res.status === 429 || res.status >= 500) {
87
- lastErr = new ApiError(res.status, friendly(res.status, await res.text(), `download ${key}`));
100
+ lastErr = new ApiError(res.status, friendly(res.status, bodyText, `download ${key}`));
88
101
  }
89
102
  else {
90
- throw new ApiError(res.status, friendly(res.status, await res.text(), `download ${key}`));
103
+ throw new ApiError(res.status, friendly(res.status, bodyText, `download ${key}`));
91
104
  }
92
105
  }
93
106
  catch (e) {
@@ -1,12 +1,14 @@
1
1
  import kleur from 'kleur';
2
2
  import { loadConfig, saveConfig } from '../config.js';
3
+ import { assertSafeGlob } from '../util/security.js';
3
4
  export async function addCommand(glob) {
5
+ const pattern = assertSafeGlob(glob);
4
6
  const config = await loadConfig();
5
- if (config.include.includes(glob)) {
6
- console.log(kleur.yellow(`already included: ${glob}`));
7
+ if (config.include.includes(pattern)) {
8
+ console.log(kleur.yellow(`already included: ${pattern}`));
7
9
  return;
8
10
  }
9
- config.include.push(glob);
11
+ config.include.push(pattern);
10
12
  await saveConfig(config);
11
- console.log(kleur.green(`✓ added include pattern: ${glob}`));
13
+ console.log(kleur.green(`✓ added include pattern: ${pattern}`));
12
14
  }
@@ -20,7 +20,7 @@ export async function initCommand(opts) {
20
20
  if (interactive) {
21
21
  const rl = createInterface({ input: stdin, output: stdout });
22
22
  try {
23
- console.log(kleur.bold('Set up @magicpixel/cli'));
23
+ console.log(kleur.bold('Set up @magicpixelart/cli'));
24
24
  if (framework)
25
25
  console.log(kleur.dim(` Detected: ${framework}`));
26
26
  console.log();
@@ -115,9 +115,11 @@ function isImportableOutDir(outDir) {
115
115
  async function ensureGitignore() {
116
116
  const path = resolve(process.cwd(), '.gitignore');
117
117
  const marker = '.magicpixel/';
118
+ // Accept any of these as "already ignored" so we don't append a duplicate.
119
+ const equivalents = new Set(['.magicpixel', '.magicpixel/', '/.magicpixel', '/.magicpixel/']);
118
120
  if (existsSync(path)) {
119
121
  const current = await readFile(path, 'utf8');
120
- if (current.split('\n').some((l) => l.trim() === marker))
122
+ if (current.split('\n').some((l) => equivalents.has(l.trim())))
121
123
  return false;
122
124
  await appendFile(path, (current.endsWith('\n') ? '' : '\n') + `\n# MagicPixel CLI state\n${marker}\n`);
123
125
  return true;
@@ -1,13 +1,15 @@
1
1
  import kleur from 'kleur';
2
2
  import { loadConfig, saveConfig } from '../config.js';
3
+ import { assertSafeGlob } from '../util/security.js';
3
4
  export async function removeCommand(glob) {
5
+ const pattern = assertSafeGlob(glob);
4
6
  const config = await loadConfig();
5
7
  const before = config.include.length;
6
- config.include = config.include.filter((g) => g !== glob);
8
+ config.include = config.include.filter((g) => g !== pattern);
7
9
  if (config.include.length === before) {
8
- console.log(kleur.yellow(`not found in include: ${glob}`));
10
+ console.log(kleur.yellow(`not found in include: ${pattern}`));
9
11
  return;
10
12
  }
11
13
  await saveConfig(config);
12
- console.log(kleur.green(`✓ removed include pattern: ${glob}`));
14
+ console.log(kleur.green(`✓ removed include pattern: ${pattern}`));
13
15
  }
@@ -1,5 +1,6 @@
1
1
  import kleur from 'kleur';
2
2
  import ora from 'ora';
3
+ import { createHash } from 'node:crypto';
3
4
  import { mkdir, writeFile, unlink, readdir, rm, rename } from 'node:fs/promises';
4
5
  import { existsSync } from 'node:fs';
5
6
  import { dirname, relative, resolve } from 'node:path';
@@ -9,6 +10,7 @@ import { fileSha256 } from '../util/hash.js';
9
10
  import { assetDiskPath } from '../util/paths.js';
10
11
  import { createLimit } from '../util/limit.js';
11
12
  import { emitTypedIndex } from '../util/emitIndex.js';
13
+ import { assertPathInsideRoot, tmpPathFor } from '../util/security.js';
12
14
  export async function syncCommand(opts) {
13
15
  if (opts.watch) {
14
16
  await watchLoop(opts);
@@ -19,16 +21,34 @@ export async function syncCommand(opts) {
19
21
  async function watchLoop(opts) {
20
22
  const intervalSec = typeof opts.watch === 'string' ? Math.max(2, parseInt(opts.watch, 10) || 10) : 10;
21
23
  console.log(kleur.dim(`[watch] polling every ${intervalSec}s. Ctrl+C to stop.`));
22
- // Run once eagerly so first sync isn't delayed by the interval.
24
+ let stopping = false;
23
25
  let inFlight = false;
26
+ let resolveIdle = null;
27
+ const onSigint = () => {
28
+ if (stopping)
29
+ return;
30
+ stopping = true;
31
+ process.stdout.write('\x1b[2K\r');
32
+ if (inFlight) {
33
+ // Let the current sync drain so we don't leave half-written *.tmp files
34
+ // or an unflushed lastSync state. Second Ctrl+C hard-exits.
35
+ console.log(kleur.dim('[watch] finishing current sync… (Ctrl+C again to force quit)'));
36
+ process.once('SIGINT', () => process.exit(130));
37
+ }
38
+ else {
39
+ console.log(kleur.dim('[watch] stopped.'));
40
+ process.exit(0);
41
+ }
42
+ };
43
+ process.on('SIGINT', onSigint);
44
+ // Run once eagerly so first sync isn't delayed by the interval.
24
45
  const tick = async () => {
25
- if (inFlight)
46
+ if (inFlight || stopping)
26
47
  return;
27
48
  inFlight = true;
28
49
  try {
29
50
  const r = await runOnce({ ...opts, quiet: true, watch: false });
30
51
  if (r.downloaded > 0 || r.pruned > 0) {
31
- // \x1b[2K\r clears any residual "no changes" line from the previous tick.
32
52
  process.stdout.write('\x1b[2K\r');
33
53
  console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} ${r.downloaded} downloaded` +
34
54
  (r.pruned ? `, ${r.pruned} pruned` : '') +
@@ -43,14 +63,25 @@ async function watchLoop(opts) {
43
63
  }
44
64
  finally {
45
65
  inFlight = false;
66
+ resolveIdle?.();
67
+ resolveIdle = null;
46
68
  }
47
69
  };
48
70
  await tick();
49
- // eslint-disable-next-line no-constant-condition
50
- while (true) {
71
+ while (!stopping) {
51
72
  await new Promise((r) => setTimeout(r, intervalSec * 1000));
73
+ if (stopping)
74
+ break;
52
75
  await tick();
53
76
  }
77
+ // SIGINT arrived mid-tick — wait for it to drain, then exit cleanly.
78
+ if (inFlight) {
79
+ await new Promise((r) => {
80
+ resolveIdle = r;
81
+ });
82
+ }
83
+ console.log(kleur.dim('[watch] stopped.'));
84
+ process.exit(0);
54
85
  }
55
86
  async function runOnce(opts) {
56
87
  const config = await loadConfig();
@@ -58,6 +89,11 @@ async function runOnce(opts) {
58
89
  const startedAt = new Date().toISOString();
59
90
  const concurrency = Math.max(1, Math.min(opts.concurrency ?? 6, 16));
60
91
  const quiet = !!opts.quiet;
92
+ // Warn loudly when a custom endpoint is configured — easy to forget a test
93
+ // override before committing magicpixel.json to a real repo / CI.
94
+ if (!quiet && config.endpoint) {
95
+ console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
96
+ }
61
97
  // Incremental: only fetch what changed since last successful sync.
62
98
  // --full forces a from-scratch fetch.
63
99
  const since = opts.full ? undefined : state.lastSync;
@@ -142,8 +178,17 @@ async function runOnce(opts) {
142
178
  result.unchanged++;
143
179
  }
144
180
  else {
181
+ // Verify the payload matches what the manifest advertised. Guards
182
+ // against silently writing a corrupted body to disk.
183
+ if (entry.sha256) {
184
+ const actual = createHash('sha256').update(bytes).digest('hex');
185
+ if (actual !== entry.sha256) {
186
+ throw new Error(`sha256 mismatch (expected ${entry.sha256.slice(0, 12)}…, got ${actual.slice(0, 12)}…)`);
187
+ }
188
+ }
145
189
  await mkdir(dirname(diskPath), { recursive: true });
146
- const tmp = `${diskPath}.${process.pid}.tmp`;
190
+ const tmp = tmpPathFor(diskPath);
191
+ assertPathInsideRoot(tmp, resolve(process.cwd(), config.outDir), 'outDir');
147
192
  await writeFile(tmp, bytes);
148
193
  await rename(tmp, diskPath);
149
194
  result.downloaded++;
@@ -170,7 +215,9 @@ async function runOnce(opts) {
170
215
  }
171
216
  // Prune
172
217
  if (opts.prune && orphans.length > 0) {
218
+ const outRoot = resolve(process.cwd(), config.outDir);
173
219
  for (const p of orphans) {
220
+ assertPathInsideRoot(p, outRoot, 'outDir');
174
221
  await unlink(p);
175
222
  if (!quiet)
176
223
  console.log(` ${kleur.red('-')} ${relative(process.cwd(), p)}`);
@@ -251,11 +298,20 @@ function printOrphans(orphans) {
251
298
  async function findLocalPngs(dir) {
252
299
  if (!existsSync(dir))
253
300
  return [];
301
+ const root = resolve(dir);
254
302
  const out = [];
255
303
  async function walk(d) {
256
304
  const entries = await readdir(d, { withFileTypes: true });
257
305
  for (const ent of entries) {
306
+ if (ent.isSymbolicLink())
307
+ continue;
258
308
  const full = resolve(d, ent.name);
309
+ try {
310
+ assertPathInsideRoot(full, root, 'outDir');
311
+ }
312
+ catch {
313
+ continue;
314
+ }
259
315
  if (ent.isDirectory()) {
260
316
  await walk(full);
261
317
  }
@@ -264,7 +320,7 @@ async function findLocalPngs(dir) {
264
320
  }
265
321
  }
266
322
  }
267
- await walk(dir);
323
+ await walk(root);
268
324
  return out;
269
325
  }
270
326
  async function pruneEmptyDirs(root) {
@@ -1,25 +1,38 @@
1
1
  import kleur from 'kleur';
2
2
  import { loadConfig, resolveEndpoint, getApiKey } from '../config.js';
3
+ import { CLI_USER_AGENT } from '../version.js';
4
+ import { safeFetch } from '../util/security.js';
3
5
  export async function whoamiCommand() {
4
6
  const config = await loadConfig();
7
+ // Ask for a full page (server caps at 1000) so `count` is honest for any
8
+ // project with ≤1000 assets — the previous `limit=1` always reported "1+".
5
9
  const url = new URL(`${resolveEndpoint(config)}/manifest`);
6
- url.searchParams.set('limit', '1');
7
- const res = await fetch(url, {
8
- headers: { Authorization: `Bearer ${getApiKey()}` },
10
+ url.searchParams.set('limit', '1000');
11
+ const res = await safeFetch(url.href, {
12
+ headers: { Authorization: `Bearer ${getApiKey()}`, 'User-Agent': CLI_USER_AGENT },
9
13
  });
10
14
  if (res.status === 401 || res.status === 403) {
15
+ await res.body?.cancel();
11
16
  console.log(kleur.red('✗ API key rejected (401/403).'));
12
17
  console.log(kleur.dim(' Generate a new key at MagicPixel → Settings → API Keys.'));
13
18
  process.exitCode = 1;
14
19
  return;
15
20
  }
16
21
  if (!res.ok) {
17
- console.log(kleur.red(`✗ ${res.status}: ${await res.text()}`));
22
+ const body = await res.text();
23
+ console.log(kleur.red(`✗ ${res.status}: ${body.slice(0, 200)}`));
18
24
  process.exitCode = 1;
19
25
  return;
20
26
  }
21
27
  const body = (await res.json());
28
+ const more = body.nextCursor ? '+' : '';
22
29
  console.log(kleur.green('✓ key valid'));
23
30
  console.log(` endpoint: ${resolveEndpoint(config)}`);
24
- console.log(` visible: ${body.count > 0 ? `${body.count}+ asset${body.count === 1 ? '' : 's'} (first: ${body.items[0]?.key ?? '-'})` : kleur.yellow('0 assets — is the key bound to a project with content?')}`);
31
+ if (body.count === 0) {
32
+ console.log(` visible: ${kleur.yellow('0 assets — is the key bound to a project with content?')}`);
33
+ }
34
+ else {
35
+ console.log(` visible: ${body.count}${more} asset${body.count === 1 && !more ? '' : 's'}` +
36
+ ` (first: ${body.items[0]?.key ?? '-'})`);
37
+ }
25
38
  }
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
+ import { validateEndpointUrl } from './util/security.js';
4
5
  const CONFIG_FILENAME = 'magicpixel.json';
5
6
  const STATE_DIR = '.magicpixel';
6
7
  const STATE_FILENAME = 'state.json';
@@ -32,14 +33,50 @@ export async function loadConfig(cwd = process.cwd()) {
32
33
  throw new Error(`${CONFIG_FILENAME} is not valid JSON: ${e.message}\n` +
33
34
  ` Fix: open the file and check for trailing commas or quotes.`);
34
35
  }
36
+ const include = normalizeGlobList(parsed.include ?? defaultConfig.include, 'include');
37
+ const exclude = normalizeGlobList(parsed.exclude ?? defaultConfig.exclude, 'exclude');
38
+ const outDir = typeof parsed.outDir === 'string' && parsed.outDir.trim() ? parsed.outDir.trim() : defaultConfig.outDir;
39
+ if (outDir.includes('\0') || outDir.split(/[/\\]/).some((seg) => seg === '..')) {
40
+ throw new Error(`${CONFIG_FILENAME}: outDir must not contain ".." segments.\n` +
41
+ ` Fix: use a path like src/assets/magicpixel.`);
42
+ }
43
+ let endpoint;
44
+ if (parsed.endpoint !== undefined) {
45
+ if (typeof parsed.endpoint !== 'string' || !parsed.endpoint.trim()) {
46
+ throw new Error(`${CONFIG_FILENAME}: "endpoint" must be a non-empty string.\n Fix: remove the field to use the default API.`);
47
+ }
48
+ endpoint = validateEndpointUrl(parsed.endpoint.trim());
49
+ }
35
50
  return {
36
- outDir: parsed.outDir ?? defaultConfig.outDir,
37
- include: parsed.include ?? defaultConfig.include,
38
- exclude: parsed.exclude ?? defaultConfig.exclude,
39
- endpoint: parsed.endpoint,
51
+ outDir,
52
+ include,
53
+ exclude,
54
+ endpoint,
40
55
  emitIndex: parsed.emitIndex ?? defaultConfig.emitIndex,
41
56
  };
42
57
  }
58
+ const MAX_GLOBS = 64;
59
+ const MAX_GLOB_LEN = 256;
60
+ function normalizeGlobList(value, field) {
61
+ if (!Array.isArray(value)) {
62
+ throw new Error(`${CONFIG_FILENAME}: "${field}" must be an array of glob strings.`);
63
+ }
64
+ if (value.length > MAX_GLOBS) {
65
+ throw new Error(`${CONFIG_FILENAME}: "${field}" has too many entries (max ${MAX_GLOBS}).`);
66
+ }
67
+ const out = [];
68
+ for (const item of value) {
69
+ if (typeof item !== 'string' || !item.trim()) {
70
+ throw new Error(`${CONFIG_FILENAME}: "${field}" entries must be non-empty strings.`);
71
+ }
72
+ const g = item.trim();
73
+ if (g.length > MAX_GLOB_LEN || g.includes('\0')) {
74
+ throw new Error(`${CONFIG_FILENAME}: "${field}" entry is too long or invalid.`);
75
+ }
76
+ out.push(g);
77
+ }
78
+ return out;
79
+ }
43
80
  export async function saveConfig(config, cwd = process.cwd()) {
44
81
  await writeFile(configPath(cwd), JSON.stringify(config, null, 2) + '\n', 'utf8');
45
82
  }
@@ -57,10 +94,12 @@ export async function loadState(cwd = process.cwd()) {
57
94
  export async function saveState(state, cwd = process.cwd()) {
58
95
  const path = statePath(cwd);
59
96
  await mkdir(dirname(path), { recursive: true });
60
- await writeFile(path, JSON.stringify(state, null, 2) + '\n', 'utf8');
97
+ await writeFile(path, JSON.stringify(state, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
61
98
  }
62
99
  export function resolveEndpoint(config) {
63
- return config.endpoint ?? DEFAULT_ENDPOINT;
100
+ if (config.endpoint)
101
+ return config.endpoint;
102
+ return DEFAULT_ENDPOINT;
64
103
  }
65
104
  export function getApiKey() {
66
105
  const key = process.env.MAGICPIXEL_API_KEY;
@@ -71,10 +110,15 @@ export function getApiKey() {
71
110
  ' 2. export MAGICPIXEL_API_KEY=mp_live_...\n' +
72
111
  ' 3. Re-run the command.');
73
112
  }
74
- if (!/^mp_(live|test)_/.test(key)) {
75
- throw new Error(`MAGICPIXEL_API_KEY does not look right (expected to start with "mp_live_").\n` +
113
+ const trimmed = key.trim();
114
+ if (trimmed !== key) {
115
+ throw new Error(`MAGICPIXEL_API_KEY has leading/trailing whitespace.\n` +
116
+ ` Fix: export the key without spaces or quotes.`);
117
+ }
118
+ if (!/^mp_(live|test)_[a-f0-9]{64}$/.test(trimmed)) {
119
+ throw new Error(`MAGICPIXEL_API_KEY does not look right (expected mp_live_… or mp_test_…).\n` +
76
120
  ` Fix: re-copy the key from https://magicpixel.art/settings.`);
77
121
  }
78
- return key;
122
+ return trimmed;
79
123
  }
80
- export const CLI_USER_AGENT = `@magicpixel/cli/0.1.0 (node ${process.versions.node})`;
124
+ export { CLI_USER_AGENT, CLI_VERSION } from './version.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from 'commander';
2
+ import { Command, InvalidArgumentError } from 'commander';
3
3
  import kleur from 'kleur';
4
4
  import { initCommand } from './commands/init.js';
5
5
  import { syncCommand } from './commands/sync.js';
@@ -8,6 +8,7 @@ import { removeCommand } from './commands/remove.js';
8
8
  import { listCommand } from './commands/list.js';
9
9
  import { statusCommand } from './commands/status.js';
10
10
  import { whoamiCommand } from './commands/whoami.js';
11
+ import { CLI_VERSION } from './version.js';
11
12
  // Node version guard
12
13
  const major = Number(process.versions.node.split('.')[0]);
13
14
  if (major < 18) {
@@ -18,7 +19,7 @@ const program = new Command();
18
19
  program
19
20
  .name('magicpixel')
20
21
  .description('Sync MagicPixel pixel-art assets to your local project')
21
- .version('0.1.0');
22
+ .version(CLI_VERSION);
22
23
  const wrap = (fn) => async (...args) => {
23
24
  try {
24
25
  await fn(...args);
@@ -44,7 +45,13 @@ program
44
45
  .option('--full', 'Ignore lastSync state; re-fetch the full manifest')
45
46
  .option('-w, --watch [seconds]', 'Poll for changes (default 10s)', (v) => v ?? true)
46
47
  .option('-q, --quiet', 'Minimal output (for CI)')
47
- .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', (v) => parseInt(v, 10))
48
+ .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', (v) => {
49
+ const n = parseInt(v, 10);
50
+ if (!Number.isFinite(n) || String(n) !== v.trim() || n < 1 || n > 16) {
51
+ throw new InvalidArgumentError(`expected an integer 1–16, got "${v}".`);
52
+ }
53
+ return n;
54
+ })
48
55
  .action(wrap(async (opts) => syncCommand(opts)));
49
56
  program
50
57
  .command('add <glob>')
@@ -1,5 +1,7 @@
1
- import { writeFile } from 'node:fs/promises';
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
2
3
  import { resolve } from 'node:path';
4
+ import { assertSafeAssetSegments } from './security.js';
3
5
  /**
4
6
  * Emit `<outDir>/index.ts` with a typed map of every asset key → URL.
5
7
  *
@@ -10,30 +12,55 @@ import { resolve } from 'node:path';
10
12
  * time. No runtime, no bundler config.
11
13
  */
12
14
  export async function emitTypedIndex(outDir, manifest, cwd = process.cwd()) {
13
- const sorted = [...manifest].sort((a, b) => a.key.localeCompare(b.key));
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.
17
+ const seen = new Set();
18
+ const sorted = [...manifest]
19
+ .sort((a, b) => a.key.localeCompare(b.key))
20
+ .filter((e) => {
21
+ if (seen.has(e.key)) {
22
+ console.warn(`magicpixel: duplicate manifest key "${e.key}" — keeping first.`);
23
+ return false;
24
+ }
25
+ seen.add(e.key);
26
+ return true;
27
+ });
14
28
  const lines = [
15
- '// AUTOGENERATED by @magicpixel/cli. Do not edit.',
29
+ '// AUTOGENERATED by @magicpixelart/cli. Do not edit.',
16
30
  '// Re-runs of `magicpixel sync` overwrite this file.',
17
31
  '',
18
32
  ];
19
- for (const e of sorted) {
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.
36
+ sorted.forEach((e, i) => {
37
+ assertSafeAssetSegments(e.folder, e.slug, e.key);
20
38
  const rel = e.folder ? `./${e.folder}/${e.slug}.png` : `./${e.slug}.png`;
21
- const safe = JSON.stringify(rel);
22
- lines.push(`import asset_${slugToIdent(e.key)} from ${safe};`);
23
- }
39
+ lines.push(`import asset_${i} from ${JSON.stringify(rel)};`);
40
+ });
24
41
  lines.push('');
25
42
  lines.push('export const MagicPixelAssets = {');
26
- for (const e of sorted) {
27
- lines.push(` ${JSON.stringify(e.key)}: asset_${slugToIdent(e.key)},`);
28
- }
43
+ sorted.forEach((e, i) => {
44
+ lines.push(` ${JSON.stringify(e.key)}: asset_${i},`);
45
+ });
29
46
  lines.push('} as const;');
30
47
  lines.push('');
31
48
  lines.push('export type MagicPixelAssetKey = keyof typeof MagicPixelAssets;');
32
49
  lines.push('');
33
50
  const path = resolve(cwd, outDir, 'index.ts');
34
- await writeFile(path, lines.join('\n'), 'utf8');
51
+ const next = lines.join('\n');
52
+ // Skip the write when contents are identical. Avoids triggering Vite/webpack
53
+ // HMR reloads on every `--watch` poll when nothing actually changed.
54
+ if (existsSync(path)) {
55
+ try {
56
+ const current = await readFile(path, 'utf8');
57
+ if (current === next)
58
+ return path;
59
+ }
60
+ catch {
61
+ // fall through and overwrite
62
+ }
63
+ }
64
+ await writeFile(path, next, 'utf8');
35
65
  return path;
36
66
  }
37
- function slugToIdent(key) {
38
- return key.replace(/[^a-zA-Z0-9]/g, '_');
39
- }
@@ -1,11 +1,15 @@
1
1
  import { resolve } from 'node:path';
2
+ import { assertPathInsideRoot, assertSafeAssetSegments } from './security.js';
2
3
  /**
3
4
  * Disk path for a manifest entry under `outDir`. Mirrors the MagicPixel
4
5
  * folder structure: `outDir/<folder>/<slug>.png`. Unsorted assets land at
5
6
  * `outDir/<slug>.png`.
6
7
  */
7
8
  export function assetDiskPath(outDir, entry, cwd = process.cwd()) {
9
+ assertSafeAssetSegments(entry.folder, entry.slug, entry.key);
8
10
  const base = resolve(cwd, outDir);
9
11
  const file = `${entry.slug}.png`;
10
- return entry.folder ? resolve(base, entry.folder, file) : resolve(base, file);
12
+ const diskPath = entry.folder ? resolve(base, entry.folder, file) : resolve(base, file);
13
+ assertPathInsideRoot(diskPath, base, 'outDir');
14
+ return diskPath;
11
15
  }
@@ -0,0 +1,127 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
3
+ /** Max PNG payload per asset (64 MiB). */
4
+ export const MAX_ASSET_BYTES = 64 * 1024 * 1024;
5
+ const SHA256_RE = /^[a-f0-9]{64}$/i;
6
+ /**
7
+ * Resolved path must stay under `rootDir` (prevents manifest-driven path traversal).
8
+ */
9
+ export function assertPathInsideRoot(targetPath, rootDir, label) {
10
+ const root = resolve(rootDir);
11
+ const target = resolve(targetPath);
12
+ const rel = relative(root, target);
13
+ if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)))
14
+ return;
15
+ throw new Error(`refusing to write outside ${label} (${relative(process.cwd(), root) || '.'}).\n` +
16
+ ` Fix: report unexpected folder/slug values to MagicPixel support.`);
17
+ }
18
+ /**
19
+ * Reject manifest folder/slug values that could escape via `..` or separators.
20
+ */
21
+ export function assertSafeAssetSegments(folder, slug, key) {
22
+ const badSegment = (seg) => seg.length === 0 || seg === '.' || seg === '..' || /[\0\\]/.test(seg);
23
+ if (badSegment(slug)) {
24
+ throw new Error(`unsafe asset slug in manifest for key "${key}".`);
25
+ }
26
+ if (folder !== null) {
27
+ for (const seg of folder.split('/')) {
28
+ if (badSegment(seg)) {
29
+ throw new Error(`unsafe asset folder in manifest for key "${key}".`);
30
+ }
31
+ }
32
+ }
33
+ }
34
+ /**
35
+ * Custom endpoints must be HTTPS (unless MAGICPIXEL_ALLOW_INSECURE_ENDPOINT=1 for local http).
36
+ * Credentials embedded in the URL are rejected.
37
+ */
38
+ export function validateEndpointUrl(raw) {
39
+ let url;
40
+ try {
41
+ url = new URL(raw);
42
+ }
43
+ catch {
44
+ throw new Error(`endpoint is not a valid URL: ${raw}\n` +
45
+ ` Fix: use https://… or remove "endpoint" from magicpixel.json.`);
46
+ }
47
+ if (url.username || url.password) {
48
+ throw new Error(`endpoint must not embed credentials in the URL.\n` +
49
+ ` Fix: use MAGICPIXEL_API_KEY for auth, not user:pass@host.`);
50
+ }
51
+ if (url.protocol === 'https:') {
52
+ return normalizeEndpointBase(url);
53
+ }
54
+ const allowInsecure = process.env.MAGICPIXEL_ALLOW_INSECURE_ENDPOINT === '1';
55
+ if (allowInsecure && url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) {
56
+ return normalizeEndpointBase(url);
57
+ }
58
+ throw new Error(`endpoint must use HTTPS (${url.protocol}//${url.host}).\n` +
59
+ ` Fix: remove "endpoint" to use production, or for local http://localhost set ` +
60
+ `MAGICPIXEL_ALLOW_INSECURE_ENDPOINT=1.`);
61
+ }
62
+ function normalizeEndpointBase(url) {
63
+ // Drop query/hash; trim trailing slash on pathname (except root).
64
+ const path = url.pathname.replace(/\/+$/, '') || '';
65
+ return `${url.protocol}//${url.host}${path}`;
66
+ }
67
+ export function isSha256Hex(value) {
68
+ return SHA256_RE.test(value);
69
+ }
70
+ export function etagForSha256(sha256) {
71
+ if (!isSha256Hex(sha256)) {
72
+ throw new Error('internal: refusing to send If-None-Match for non-sha256 value');
73
+ }
74
+ return `"${sha256.toLowerCase()}"`;
75
+ }
76
+ export function tmpPathFor(diskPath) {
77
+ const suffix = randomBytes(8).toString('hex');
78
+ return `${diskPath}.${process.pid}.${suffix}.tmp`;
79
+ }
80
+ /**
81
+ * Follow redirects only within the same origin as the initial request (prevents API key leak).
82
+ */
83
+ export async function safeFetch(url, init = {}) {
84
+ const origin = new URL(url).origin;
85
+ let current = url;
86
+ for (let hop = 0; hop < 5; hop++) {
87
+ const res = await fetch(current, { ...init, redirect: 'manual' });
88
+ if (res.status < 300 || res.status >= 400)
89
+ return res;
90
+ const location = res.headers.get('location');
91
+ await res.body?.cancel();
92
+ if (!location) {
93
+ throw new Error(`HTTP ${res.status} redirect without Location header.`);
94
+ }
95
+ const next = new URL(location, current);
96
+ if (next.origin !== origin) {
97
+ throw new Error(`refusing cross-origin redirect to ${next.origin}.\n` +
98
+ ` Fix: point "endpoint" directly at the integration API; do not use redirecting URLs.`);
99
+ }
100
+ current = next.href;
101
+ }
102
+ throw new Error('too many HTTP redirects (max 5).');
103
+ }
104
+ export async function readBodyWithLimit(res, maxBytes) {
105
+ const declared = res.headers.get('content-length');
106
+ if (declared) {
107
+ const n = Number(declared);
108
+ if (Number.isFinite(n) && n > maxBytes) {
109
+ await res.body?.cancel();
110
+ throw new Error(`response too large (${n} bytes, max ${maxBytes}).`);
111
+ }
112
+ }
113
+ const buf = new Uint8Array(await res.arrayBuffer());
114
+ if (buf.byteLength > maxBytes) {
115
+ throw new Error(`response too large (${buf.byteLength} bytes, max ${maxBytes}).`);
116
+ }
117
+ return buf;
118
+ }
119
+ const MAX_GLOB_LEN = 256;
120
+ /** Validate a user-supplied picomatch glob from the CLI. */
121
+ export function assertSafeGlob(glob) {
122
+ const g = glob.trim();
123
+ if (!g || g.length > MAX_GLOB_LEN || g.includes('\0')) {
124
+ throw new Error(`invalid glob pattern (empty, too long, or contains null bytes).`);
125
+ }
126
+ return g;
127
+ }
@@ -0,0 +1,8 @@
1
+ import { createRequire } from 'node:module';
2
+ // Single source of truth: read version from package.json at runtime so
3
+ // `npm version <bump>` propagates everywhere without code edits.
4
+ // dist/version.js resolves `../package.json` → tools/cli/package.json.
5
+ const require = createRequire(import.meta.url);
6
+ const pkg = require('../package.json');
7
+ export const CLI_VERSION = pkg.version;
8
+ export const CLI_USER_AGENT = `@magicpixelart/cli/${CLI_VERSION} (node ${process.versions.node})`;
package/package.json CHANGED
@@ -1,16 +1,12 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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
- "LICENSE"
13
- ],
9
+ "files": ["dist", "README.md", "LICENSE"],
14
10
  "scripts": {
15
11
  "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
16
12
  "dev": "tsx src/index.ts",
@@ -30,7 +26,7 @@
30
26
  "url": "git+https://github.com/handpoke/magicpixel-cli.git"
31
27
  },
32
28
  "bugs": {
33
- "url": "https://github.com/magicpixel/cli/issues"
29
+ "url": "https://github.com/handpoke/magicpixel-cli/issues"
34
30
  },
35
31
  "license": "MIT",
36
32
  "author": "MagicPixel",