@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 +7 -3
- package/dist/api.js +14 -7
- package/dist/commands/add.js +6 -4
- package/dist/commands/remove.js +5 -3
- package/dist/commands/sync.js +39 -6
- package/dist/commands/whoami.js +18 -5
- package/dist/config.js +53 -9
- package/dist/util/emitIndex.js +18 -2
- package/dist/util/paths.js +5 -1
- package/dist/util/security.js +127 -0
- package/package.json +2 -2
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
|
|
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
|
|
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'] =
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
103
|
+
throw new ApiError(res.status, friendly(res.status, bodyText, `download ${key}`));
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
106
|
catch (e) {
|
package/dist/commands/add.js
CHANGED
|
@@ -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(
|
|
6
|
-
console.log(kleur.yellow(`already included: ${
|
|
7
|
+
if (config.include.includes(pattern)) {
|
|
8
|
+
console.log(kleur.yellow(`already included: ${pattern}`));
|
|
7
9
|
return;
|
|
8
10
|
}
|
|
9
|
-
config.include.push(
|
|
11
|
+
config.include.push(pattern);
|
|
10
12
|
await saveConfig(config);
|
|
11
|
-
console.log(kleur.green(`✓ added include pattern: ${
|
|
13
|
+
console.log(kleur.green(`✓ added include pattern: ${pattern}`));
|
|
12
14
|
}
|
package/dist/commands/remove.js
CHANGED
|
@@ -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 !==
|
|
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: ${
|
|
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: ${
|
|
14
|
+
console.log(kleur.green(`✓ removed include pattern: ${pattern}`));
|
|
13
15
|
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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 =
|
|
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(
|
|
323
|
+
await walk(root);
|
|
291
324
|
return out;
|
|
292
325
|
}
|
|
293
326
|
async function pruneEmptyDirs(root) {
|
package/dist/commands/whoami.js
CHANGED
|
@@ -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', '
|
|
7
|
-
const res = await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
include
|
|
38
|
-
exclude
|
|
39
|
-
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
122
|
+
return trimmed;
|
|
79
123
|
}
|
|
80
124
|
export { CLI_USER_AGENT, CLI_VERSION } from './version.js';
|
package/dist/util/emitIndex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/util/paths.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": "
|
|
7
|
+
"magicpixel": "dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"files": ["dist", "README.md", "LICENSE"],
|
|
10
10
|
"scripts": {
|