@magicpixelart/cli 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -15
- package/dist/api.js +14 -7
- package/dist/commands/add.js +6 -4
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/init.js +60 -45
- package/dist/commands/login.js +98 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/remove.js +5 -3
- package/dist/commands/start.js +105 -0
- package/dist/commands/sync.js +279 -75
- package/dist/commands/whoami.js +18 -5
- package/dist/config.js +73 -13
- package/dist/index.js +23 -1
- package/dist/util/credentials.js +82 -0
- package/dist/util/emitIndex.js +157 -15
- package/dist/util/framework.js +56 -0
- package/dist/util/paths.js +20 -2
- package/dist/util/security.js +127 -0
- package/package.json +2 -2
|
@@ -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.
|
|
3
|
+
"version": "0.3.0",
|
|
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": {
|