@magicpixelart/cli 0.1.1 → 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
@@ -21,7 +21,7 @@ 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
25
 
26
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.
27
27
 
@@ -76,9 +76,13 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
76
76
  | `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
77
77
  | `exclude` | `string[]` | `[]` | Globs to exclude. |
78
78
  | `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
79
- | `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
80
 
81
- State (`.magicpixel/state.json`) tracks `lastSync`. Add `.magicpixel/` to `.gitignore` (init offers to do this).
81
+ State (`.magicpixel/state.json`) tracks `lastSync` (file mode `0600`). Add `.magicpixel/` to `.gitignore` (init offers to do this).
82
+
83
+ ### Custom endpoint (advanced)
84
+
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.
82
86
 
83
87
  ## CI usage
84
88
 
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` +
@@ -79,21 +80,27 @@ export async function fetchAssetBytes(config, key, knownSha) {
79
80
  url.searchParams.set('key', key);
80
81
  const headers = authHeaders();
81
82
  if (knownSha)
82
- headers['If-None-Match'] = `"${knownSha}"`;
83
+ headers['If-None-Match'] = etagForSha256(knownSha);
83
84
  const maxAttempts = 3;
84
85
  let lastErr = null;
85
86
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
86
87
  try {
87
- const res = await fetch(url, { headers });
88
- 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();
89
92
  return null;
93
+ }
90
94
  if (res.ok)
91
- 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();
92
99
  if (res.status === 429 || res.status >= 500) {
93
- 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}`));
94
101
  }
95
102
  else {
96
- 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}`));
97
104
  }
98
105
  }
99
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
  }
@@ -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
  }
@@ -10,6 +10,7 @@ import { fileSha256 } from '../util/hash.js';
10
10
  import { assetDiskPath } from '../util/paths.js';
11
11
  import { createLimit } from '../util/limit.js';
12
12
  import { emitTypedIndex } from '../util/emitIndex.js';
13
+ import { assertPathInsideRoot, tmpPathFor } from '../util/security.js';
13
14
  export async function syncCommand(opts) {
14
15
  if (opts.watch) {
15
16
  await watchLoop(opts);
@@ -21,18 +22,26 @@ async function watchLoop(opts) {
21
22
  const intervalSec = typeof opts.watch === 'string' ? Math.max(2, parseInt(opts.watch, 10) || 10) : 10;
22
23
  console.log(kleur.dim(`[watch] polling every ${intervalSec}s. Ctrl+C to stop.`));
23
24
  let stopping = false;
25
+ let inFlight = false;
26
+ let resolveIdle = null;
24
27
  const onSigint = () => {
25
28
  if (stopping)
26
29
  return;
27
30
  stopping = true;
28
- // \x1b[2K\r clears any in-flight status line before the goodbye message.
29
31
  process.stdout.write('\x1b[2K\r');
30
- console.log(kleur.dim('[watch] stopped.'));
31
- process.exit(0);
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
+ }
32
42
  };
33
43
  process.on('SIGINT', onSigint);
34
44
  // Run once eagerly so first sync isn't delayed by the interval.
35
- let inFlight = false;
36
45
  const tick = async () => {
37
46
  if (inFlight || stopping)
38
47
  return;
@@ -54,13 +63,25 @@ async function watchLoop(opts) {
54
63
  }
55
64
  finally {
56
65
  inFlight = false;
66
+ resolveIdle?.();
67
+ resolveIdle = null;
57
68
  }
58
69
  };
59
70
  await tick();
60
71
  while (!stopping) {
61
72
  await new Promise((r) => setTimeout(r, intervalSec * 1000));
73
+ if (stopping)
74
+ break;
62
75
  await tick();
63
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);
64
85
  }
65
86
  async function runOnce(opts) {
66
87
  const config = await loadConfig();
@@ -166,7 +187,8 @@ async function runOnce(opts) {
166
187
  }
167
188
  }
168
189
  await mkdir(dirname(diskPath), { recursive: true });
169
- const tmp = `${diskPath}.${process.pid}.tmp`;
190
+ const tmp = tmpPathFor(diskPath);
191
+ assertPathInsideRoot(tmp, resolve(process.cwd(), config.outDir), 'outDir');
170
192
  await writeFile(tmp, bytes);
171
193
  await rename(tmp, diskPath);
172
194
  result.downloaded++;
@@ -193,7 +215,9 @@ async function runOnce(opts) {
193
215
  }
194
216
  // Prune
195
217
  if (opts.prune && orphans.length > 0) {
218
+ const outRoot = resolve(process.cwd(), config.outDir);
196
219
  for (const p of orphans) {
220
+ assertPathInsideRoot(p, outRoot, 'outDir');
197
221
  await unlink(p);
198
222
  if (!quiet)
199
223
  console.log(` ${kleur.red('-')} ${relative(process.cwd(), p)}`);
@@ -274,11 +298,20 @@ function printOrphans(orphans) {
274
298
  async function findLocalPngs(dir) {
275
299
  if (!existsSync(dir))
276
300
  return [];
301
+ const root = resolve(dir);
277
302
  const out = [];
278
303
  async function walk(d) {
279
304
  const entries = await readdir(d, { withFileTypes: true });
280
305
  for (const ent of entries) {
306
+ if (ent.isSymbolicLink())
307
+ continue;
281
308
  const full = resolve(d, ent.name);
309
+ try {
310
+ assertPathInsideRoot(full, root, 'outDir');
311
+ }
312
+ catch {
313
+ continue;
314
+ }
282
315
  if (ent.isDirectory()) {
283
316
  await walk(full);
284
317
  }
@@ -287,7 +320,7 @@ async function findLocalPngs(dir) {
287
320
  }
288
321
  }
289
322
  }
290
- await walk(dir);
323
+ await walk(root);
291
324
  return out;
292
325
  }
293
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
124
  export { CLI_USER_AGENT, CLI_VERSION } from './version.js';
@@ -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
  *
@@ -32,6 +34,7 @@ export async function emitTypedIndex(outDir, manifest, cwd = process.cwd()) {
32
34
  // by non-alphanumeric chars (e.g. `player/walk` vs `player_walk`) can't
33
35
  // collide on the JS identifier and break the consumer's TS build.
34
36
  sorted.forEach((e, i) => {
37
+ assertSafeAssetSegments(e.folder, e.slug, e.key);
35
38
  const rel = e.folder ? `./${e.folder}/${e.slug}.png` : `./${e.slug}.png`;
36
39
  lines.push(`import asset_${i} from ${JSON.stringify(rel)};`);
37
40
  });
@@ -45,6 +48,19 @@ export async function emitTypedIndex(outDir, manifest, cwd = process.cwd()) {
45
48
  lines.push('export type MagicPixelAssetKey = keyof typeof MagicPixelAssets;');
46
49
  lines.push('');
47
50
  const path = resolve(cwd, outDir, 'index.ts');
48
- 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');
49
65
  return path;
50
66
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.1.1",
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
- "magicpixel": "./dist/index.js"
7
+ "magicpixel": "dist/index.js"
8
8
  },
9
9
  "files": ["dist", "README.md", "LICENSE"],
10
10
  "scripts": {