@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 +14 -8
- package/dist/api.js +26 -13
- package/dist/commands/add.js +6 -4
- package/dist/commands/init.js +4 -2
- package/dist/commands/remove.js +5 -3
- package/dist/commands/sync.js +63 -7
- package/dist/commands/whoami.js +18 -5
- package/dist/config.js +54 -10
- package/dist/index.js +10 -3
- package/dist/util/emitIndex.js +41 -14
- package/dist/util/paths.js +5 -1
- package/dist/util/security.js +127 -0
- package/dist/version.js +8 -0
- package/package.json +3 -7
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
# @
|
|
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 @
|
|
9
|
-
# or: pnpm add -D @
|
|
10
|
-
# or: bun add -d @
|
|
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
|
-
|
|
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 @
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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'] =
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
103
|
+
throw new ApiError(res.status, friendly(res.status, bodyText, `download ${key}`));
|
|
91
104
|
}
|
|
92
105
|
}
|
|
93
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/init.js
CHANGED
|
@@ -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 @
|
|
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()
|
|
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;
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
323
|
+
await walk(root);
|
|
268
324
|
return out;
|
|
269
325
|
}
|
|
270
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
|
-
export
|
|
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(
|
|
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) =>
|
|
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>')
|
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
|
*
|
|
@@ -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
|
-
|
|
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 @
|
|
29
|
+
'// AUTOGENERATED by @magicpixelart/cli. Do not edit.',
|
|
16
30
|
'// Re-runs of `magicpixel sync` overwrite this file.',
|
|
17
31
|
'',
|
|
18
32
|
];
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
39
|
+
lines.push(`import asset_${i} from ${JSON.stringify(rel)};`);
|
|
40
|
+
});
|
|
24
41
|
lines.push('');
|
|
25
42
|
lines.push('export const MagicPixelAssets = {');
|
|
26
|
-
|
|
27
|
-
lines.push(` ${JSON.stringify(e.key)}: asset_${
|
|
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
|
-
|
|
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
|
-
}
|
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/dist/version.js
ADDED
|
@@ -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.
|
|
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
|
|
29
|
+
"url": "https://github.com/handpoke/magicpixel-cli/issues"
|
|
34
30
|
},
|
|
35
31
|
"license": "MIT",
|
|
36
32
|
"author": "MagicPixel",
|