@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
package/README.md
CHANGED
|
@@ -14,36 +14,75 @@ Requires Node.js ≥ 18.
|
|
|
14
14
|
|
|
15
15
|
## Quickstart
|
|
16
16
|
|
|
17
|
+
One command, inside your project folder (the one with `package.json`):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx magicpixel start
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That walks you through everything: detects your framework, writes `magicpixel.json`, prompts for your API key (paste it from [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys), pulls your sprites, and tells you how to keep them fresh.
|
|
24
|
+
|
|
25
|
+
When it finishes:
|
|
26
|
+
|
|
17
27
|
```bash
|
|
18
|
-
|
|
19
|
-
export MAGICPIXEL_API_KEY=mp_live_...
|
|
20
|
-
npx magicpixel sync # downloads changed assets
|
|
21
|
-
npx magicpixel sync --watch # keeps assets fresh while you work
|
|
28
|
+
npm run magicpixel:watch # keeps sprites fresh while you edit them in MagicPixel
|
|
22
29
|
```
|
|
23
30
|
|
|
24
|
-
|
|
31
|
+
Production keys are prefixed `mp_live_`; sandbox keys (used internally for tests) are `mp_test_` and accepted the same way.
|
|
32
|
+
|
|
33
|
+
### Where your key lives
|
|
34
|
+
|
|
35
|
+
- **`MAGICPIXEL_API_KEY` env var** — highest priority, so CI keeps working with no code changes.
|
|
36
|
+
- **`.magicpixel/credentials`** — written by `magicpixel login`, mode `0600`, automatically gitignored. This is what `start` uses for local dev.
|
|
37
|
+
|
|
38
|
+
If `start` finds a `MAGICPIXEL_API_KEY` in your `.env` / `.env.local`, it offers to move it to the credentials file so it stops shipping in your bundler.
|
|
39
|
+
|
|
40
|
+
### Manual setup (advanced)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx magicpixel init # writes magicpixel.json
|
|
44
|
+
npx magicpixel login # stores your key (or: export MAGICPIXEL_API_KEY=mp_live_...)
|
|
45
|
+
npx magicpixel sync # downloads changed assets
|
|
46
|
+
npx magicpixel sync --watch # keeps assets fresh while you work
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Something not working?
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx magicpixel doctor
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Prints a one-screen diagnostic (CLI version, framework, outDir, key source, last sync, last error). Paste it to your AI agent — it's designed to be the only context they need.
|
|
25
56
|
|
|
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
57
|
|
|
28
58
|
## Typed asset index (default on)
|
|
29
59
|
|
|
30
60
|
If `emitIndex` is true in `magicpixel.json`, every sync writes `<outDir>/index.ts`:
|
|
31
61
|
|
|
32
62
|
```ts
|
|
33
|
-
import { MagicPixelAssets,
|
|
63
|
+
import { MagicPixelAssets, MagicPixelAssetsById } from '@/assets/magicpixel';
|
|
34
64
|
|
|
65
|
+
// Key-based: ergonomic, but breaks if you rename or move the asset in the editor.
|
|
35
66
|
<img src={MagicPixelAssets['player/walk']} />
|
|
36
|
-
|
|
67
|
+
|
|
68
|
+
// Id-based: survives every rename. Use for assets you don't want to chase imports for.
|
|
69
|
+
<img src={MagicPixelAssetsById['abc123…']} />
|
|
37
70
|
```
|
|
38
71
|
|
|
72
|
+
The index is built from what's actually on disk after each sync, so it can never disagree with the PNGs your bundler picks up. Renames are detected against the prior sync's snapshot — `sync` prints the old → new key plus the matching `MagicPixelAssetsById[...]` import hint so you can pick whichever you prefer.
|
|
73
|
+
|
|
39
74
|
No bundler config, no runtime, no extra package.
|
|
40
75
|
|
|
41
76
|
## Commands
|
|
42
77
|
|
|
43
78
|
| Command | What it does |
|
|
44
79
|
| --- | --- |
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
80
|
+
| `start [--force]` | One-command bootstrap. Init + login + first sync. The only command to tell new users to run. |
|
|
81
|
+
| `init [-y] [--force]` | Interactive config wizard. Offers a `magicpixel:watch` npm script. `-y` for CI. |
|
|
82
|
+
| `login [--key <key>]` | Save your API key to `.magicpixel/credentials` (mode `0600`). Validates against the server first. |
|
|
83
|
+
| `logout` | Remove the stored API key. |
|
|
84
|
+
| `doctor` | Print a one-screen diagnostic — paste it to your AI agent when something breaks. |
|
|
85
|
+
| `sync [...flags]` | Fetch manifest, diff against disk, download changed assets, prune orphans. |
|
|
47
86
|
| `add <glob>` / `remove <glob>` | Manage `include` patterns. |
|
|
48
87
|
| `list` | Print matching manifest as a table. |
|
|
49
88
|
| `status` | Config, last sync, diff vs remote. |
|
|
@@ -54,12 +93,14 @@ No bundler config, no runtime, no extra package.
|
|
|
54
93
|
| Flag | Meaning |
|
|
55
94
|
| --- | --- |
|
|
56
95
|
| `-w, --watch [seconds]` | Poll for changes (default 10s). Ideal during development. |
|
|
57
|
-
| `--prune` |
|
|
96
|
+
| `--no-prune` | Keep local files not in the manifest (default: prune them on full syncs). |
|
|
58
97
|
| `--dry-run` | Print plan, write nothing. |
|
|
59
98
|
| `--full` | Ignore `lastSync`; re-fetch the full manifest. |
|
|
60
99
|
| `-c, --concurrency <n>` | Parallel downloads (1–16, default 6). |
|
|
61
100
|
| `-q, --quiet` | Minimal output (for CI). |
|
|
62
101
|
|
|
102
|
+
Each successful sync prints a per-file change list (`+` added, `~` modified, `↪` renamed, `-` pruned) so you (and any AI agent reading the logs) know exactly what to wire up.
|
|
103
|
+
|
|
63
104
|
Sync is built to be cheap: a no-op run is one small manifest request, zero PNG bytes.
|
|
64
105
|
|
|
65
106
|
- sha256 diff against disk — matching files are never re-downloaded.
|
|
@@ -76,9 +117,13 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
|
|
|
76
117
|
| `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
|
|
77
118
|
| `exclude` | `string[]` | `[]` | Globs to exclude. |
|
|
78
119
|
| `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
|
|
79
|
-
| `endpoint` | `string?` | production URL | Override the API base (testing only).
|
|
120
|
+
| `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
|
|
121
|
+
|
|
122
|
+
State (`.magicpixel/state.json`) tracks `lastSync` (file mode `0600`). Add `.magicpixel/` to `.gitignore` (init offers to do this).
|
|
123
|
+
|
|
124
|
+
### Custom endpoint (advanced)
|
|
80
125
|
|
|
81
|
-
|
|
126
|
+
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
127
|
|
|
83
128
|
## CI usage
|
|
84
129
|
|
|
@@ -95,7 +140,7 @@ jobs:
|
|
|
95
140
|
- uses: actions/checkout@v4
|
|
96
141
|
- uses: actions/setup-node@v4
|
|
97
142
|
with: { node-version: 20 }
|
|
98
|
-
- run: npx -y @magicpixelart/cli sync --
|
|
143
|
+
- run: npx -y @magicpixelart/cli sync --quiet --full
|
|
99
144
|
env:
|
|
100
145
|
MAGICPIXEL_API_KEY: ${{ secrets.MAGICPIXEL_API_KEY }}
|
|
101
146
|
- uses: peter-evans/create-pull-request@v6
|
|
@@ -119,7 +164,7 @@ Every error message includes a `Fix:` block. Common ones:
|
|
|
119
164
|
|
|
120
165
|
| Symptom | Fix |
|
|
121
166
|
| --- | --- |
|
|
122
|
-
| `
|
|
167
|
+
| `No MagicPixel API key found` | Run `magicpixel login`, or `export MAGICPIXEL_API_KEY=mp_live_...`. |
|
|
123
168
|
| `401` / `403` | Regenerate the key at magicpixel.art/settings. |
|
|
124
169
|
| `whoami` shows 0 assets | The key is bound to an empty project. Mint a key for the project that has art. |
|
|
125
170
|
| `index.ts` doesn't update | Run `sync --full` once; renames may take a full pass to propagate. |
|
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
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve, relative } from 'node:path';
|
|
5
|
+
import { loadConfig, loadState, resolveEndpoint, defaultConfig } from '../config.js';
|
|
6
|
+
import { describeKeySource } from '../util/credentials.js';
|
|
7
|
+
import { detectFramework, hasPackageJson } from '../util/framework.js';
|
|
8
|
+
import { CLI_VERSION } from '../version.js';
|
|
9
|
+
/**
|
|
10
|
+
* Single-page diagnostic that a non-technical user can paste to their AI agent
|
|
11
|
+
* when something breaks. Read-only — never mutates state or hits the network.
|
|
12
|
+
*/
|
|
13
|
+
export async function doctorCommand() {
|
|
14
|
+
const lines = [];
|
|
15
|
+
const push = (s = '') => lines.push(s);
|
|
16
|
+
push(kleur.bold('MagicPixel CLI — doctor'));
|
|
17
|
+
push(kleur.dim(` Paste this to your AI agent if something isn't working.`));
|
|
18
|
+
push();
|
|
19
|
+
push(`CLI version: ${CLI_VERSION}`);
|
|
20
|
+
push(`Node version: ${process.versions.node}`);
|
|
21
|
+
push(`Platform: ${process.platform}`);
|
|
22
|
+
push(`cwd: ${process.cwd()}`);
|
|
23
|
+
push();
|
|
24
|
+
// Framework + package.json
|
|
25
|
+
const framework = await detectFramework();
|
|
26
|
+
push(`package.json: ${hasPackageJson() ? 'found' : kleur.yellow('missing')}`);
|
|
27
|
+
push(`Framework detected: ${framework ?? kleur.dim('none')}`);
|
|
28
|
+
// Config
|
|
29
|
+
let config = null;
|
|
30
|
+
try {
|
|
31
|
+
config = await loadConfig();
|
|
32
|
+
push(`magicpixel.json: found`);
|
|
33
|
+
push(` outDir: ${config.outDir}`);
|
|
34
|
+
push(` emitIndex: ${config.emitIndex === false ? 'false' : 'true'}`);
|
|
35
|
+
push(` include: ${config.include.join(', ')}`);
|
|
36
|
+
if (config.exclude.length)
|
|
37
|
+
push(` exclude: ${config.exclude.join(', ')}`);
|
|
38
|
+
if (config.endpoint)
|
|
39
|
+
push(` endpoint: ${kleur.yellow(config.endpoint)} (custom)`);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
push(`magicpixel.json: ${kleur.yellow('missing')} (${e.message.split('\n')[0]})`);
|
|
43
|
+
}
|
|
44
|
+
push(`API endpoint: ${resolveEndpoint(config ?? { ...defaultConfig })}`);
|
|
45
|
+
// Key source
|
|
46
|
+
const keySource = describeKeySource();
|
|
47
|
+
const sourceLabel = keySource === 'env'
|
|
48
|
+
? 'environment variable (MAGICPIXEL_API_KEY)'
|
|
49
|
+
: keySource === 'credentials-file'
|
|
50
|
+
? '.magicpixel/credentials'
|
|
51
|
+
: kleur.yellow('none — run `magicpixel login`');
|
|
52
|
+
push(`API key source: ${sourceLabel}`);
|
|
53
|
+
// Watch script
|
|
54
|
+
let watchScript = null;
|
|
55
|
+
try {
|
|
56
|
+
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
57
|
+
if (existsSync(pkgPath)) {
|
|
58
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
59
|
+
watchScript = pkg.scripts?.['magicpixel:watch'] ?? null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// ignore
|
|
64
|
+
}
|
|
65
|
+
push(`Watch script: ${watchScript ? `"${watchScript}"` : kleur.dim('not configured')}`);
|
|
66
|
+
// State
|
|
67
|
+
const state = await loadState();
|
|
68
|
+
push(`Last sync: ${state.lastSync ? new Date(state.lastSync).toLocaleString() : kleur.dim('never')}`);
|
|
69
|
+
push(`Assets in state: ${state.assets ? Object.keys(state.assets).length : 0}`);
|
|
70
|
+
if (state.lastError)
|
|
71
|
+
push(`Last error: ${kleur.yellow(state.lastError)}`);
|
|
72
|
+
// outDir contents (approximate)
|
|
73
|
+
if (config) {
|
|
74
|
+
const out = resolve(process.cwd(), config.outDir);
|
|
75
|
+
if (existsSync(out)) {
|
|
76
|
+
push(`outDir on disk: ${relative(process.cwd(), out)} (exists)`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
push(`outDir on disk: ${kleur.dim('not created yet')}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
push();
|
|
83
|
+
push(kleur.dim('Next steps:'));
|
|
84
|
+
if (keySource === 'none')
|
|
85
|
+
push(kleur.dim(' • Run `magicpixel login` to store your API key.'));
|
|
86
|
+
if (!config)
|
|
87
|
+
push(kleur.dim(' • Run `magicpixel start` (or `magicpixel init`) to bootstrap.'));
|
|
88
|
+
if (!watchScript && hasPackageJson()) {
|
|
89
|
+
push(kleur.dim(' • Run `npm run magicpixel:watch` after init for live sync.'));
|
|
90
|
+
}
|
|
91
|
+
console.log(lines.join('\n'));
|
|
92
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -5,6 +5,9 @@ import { resolve } from 'node:path';
|
|
|
5
5
|
import { createInterface } from 'node:readline/promises';
|
|
6
6
|
import { stdin, stdout } from 'node:process';
|
|
7
7
|
import { configPath, defaultConfig, saveConfig } from '../config.js';
|
|
8
|
+
import { detectFramework, suggestOutDir, hasPackageJson } from '../util/framework.js';
|
|
9
|
+
const WATCH_SCRIPT_NAME = 'magicpixel:watch';
|
|
10
|
+
const WATCH_SCRIPT_CMD = 'magicpixel sync --watch';
|
|
8
11
|
export async function initCommand(opts) {
|
|
9
12
|
const path = configPath();
|
|
10
13
|
if (existsSync(path) && !opts.force) {
|
|
@@ -17,6 +20,9 @@ export async function initCommand(opts) {
|
|
|
17
20
|
const framework = await detectFramework();
|
|
18
21
|
const suggestedOutDir = suggestOutDir(framework);
|
|
19
22
|
config.outDir = suggestedOutDir;
|
|
23
|
+
const pkgExists = hasPackageJson();
|
|
24
|
+
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
25
|
+
let addWatchScript = pkgExists;
|
|
20
26
|
if (interactive) {
|
|
21
27
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
22
28
|
try {
|
|
@@ -37,6 +43,10 @@ export async function initCommand(opts) {
|
|
|
37
43
|
}
|
|
38
44
|
const giAns = (await rl.question(`${kleur.cyan('?')} Add .magicpixel/ to .gitignore? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
|
|
39
45
|
addGitignore = giAns !== 'n' && giAns !== 'no';
|
|
46
|
+
if (pkgExists) {
|
|
47
|
+
const wAns = (await rl.question(`${kleur.cyan('?')} Add a "${WATCH_SCRIPT_NAME}" script to package.json? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
|
|
48
|
+
addWatchScript = wAns !== 'n' && wAns !== 'no';
|
|
49
|
+
}
|
|
40
50
|
}
|
|
41
51
|
finally {
|
|
42
52
|
rl.close();
|
|
@@ -53,54 +63,59 @@ export async function initCommand(opts) {
|
|
|
53
63
|
if (added)
|
|
54
64
|
console.log(kleur.green('✓ added .magicpixel/ to .gitignore'));
|
|
55
65
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
67
|
-
if (!existsSync(pkgPath))
|
|
68
|
-
return null;
|
|
69
|
-
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
70
|
-
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
71
|
-
if (deps['next'])
|
|
72
|
-
return 'Next.js';
|
|
73
|
-
if (deps['vite'])
|
|
74
|
-
return 'Vite';
|
|
75
|
-
if (deps['@remix-run/react'])
|
|
76
|
-
return 'Remix';
|
|
77
|
-
if (deps['react-scripts'])
|
|
78
|
-
return 'Create React App';
|
|
79
|
-
if (deps['astro'])
|
|
80
|
-
return 'Astro';
|
|
81
|
-
if (deps['nuxt'])
|
|
82
|
-
return 'Nuxt';
|
|
83
|
-
if (deps['@sveltejs/kit'])
|
|
84
|
-
return 'SvelteKit';
|
|
85
|
-
return null;
|
|
66
|
+
let watchScriptAdded = false;
|
|
67
|
+
if (addWatchScript && pkgExists) {
|
|
68
|
+
const r = await ensureWatchScript(pkgPath);
|
|
69
|
+
watchScriptAdded = r.added;
|
|
70
|
+
if (r.added)
|
|
71
|
+
console.log(kleur.green(`✓ added "${WATCH_SCRIPT_NAME}" script to package.json`));
|
|
72
|
+
else if (r.alreadyPresent)
|
|
73
|
+
console.log(kleur.dim(` "${WATCH_SCRIPT_NAME}" script already present in package.json`));
|
|
74
|
+
else if (r.error)
|
|
75
|
+
console.log(kleur.yellow(` could not patch package.json: ${r.error}`));
|
|
86
76
|
}
|
|
87
|
-
|
|
88
|
-
|
|
77
|
+
// Only print the "Next" block in fully-interactive init runs. `start`
|
|
78
|
+
// orchestrates its own onboarding summary, so doubling them up is noisy.
|
|
79
|
+
if (interactive) {
|
|
80
|
+
console.log();
|
|
81
|
+
console.log(kleur.bold('Next:'));
|
|
82
|
+
console.log(` 1. ${kleur.dim('magicpixel login')} ${kleur.dim('# paste your key from https://magicpixel.art/settings')}`);
|
|
83
|
+
console.log(` 2. ${kleur.dim('magicpixel sync')} ${kleur.dim('# downloads your assets')}`);
|
|
84
|
+
if (watchScriptAdded) {
|
|
85
|
+
console.log(` 3. ${kleur.dim('npm run')} ${WATCH_SCRIPT_NAME} ${kleur.dim('# keeps assets fresh while you edit')}`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
console.log(` 3. ${kleur.dim('npx')} magicpixel sync --watch ${kleur.dim('# keeps assets fresh while you edit')}`);
|
|
89
|
+
}
|
|
90
|
+
console.log();
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return
|
|
93
|
+
/**
|
|
94
|
+
* Add a `magicpixel:watch` npm script. Preserves existing scripts and 2-space
|
|
95
|
+
* indentation; never overwrites a user-defined script of the same name.
|
|
96
|
+
*/
|
|
97
|
+
async function ensureWatchScript(pkgPath) {
|
|
98
|
+
try {
|
|
99
|
+
const raw = await readFile(pkgPath, 'utf8');
|
|
100
|
+
let pkg;
|
|
101
|
+
try {
|
|
102
|
+
pkg = JSON.parse(raw);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return { added: false, alreadyPresent: false, error: `package.json is not valid JSON (${e.message})` };
|
|
106
|
+
}
|
|
107
|
+
const scripts = (pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {});
|
|
108
|
+
if (typeof scripts[WATCH_SCRIPT_NAME] === 'string') {
|
|
109
|
+
return { added: false, alreadyPresent: true };
|
|
110
|
+
}
|
|
111
|
+
scripts[WATCH_SCRIPT_NAME] = WATCH_SCRIPT_CMD;
|
|
112
|
+
pkg.scripts = scripts;
|
|
113
|
+
const trailingNewline = raw.endsWith('\n') ? '\n' : '';
|
|
114
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + trailingNewline, 'utf8');
|
|
115
|
+
return { added: true, alreadyPresent: false };
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return { added: false, alreadyPresent: false, error: e.message };
|
|
104
119
|
}
|
|
105
120
|
}
|
|
106
121
|
/**
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { stdin, stdout } from 'node:process';
|
|
4
|
+
import { resolveEndpoint, loadConfig, defaultConfig } from '../config.js';
|
|
5
|
+
import { writeCredentials, credentialsPath } from '../util/credentials.js';
|
|
6
|
+
import { safeFetch } from '../util/security.js';
|
|
7
|
+
import { CLI_USER_AGENT } from '../version.js';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { relative } from 'node:path';
|
|
10
|
+
const KEY_RE = /^mp_(live|test)_[a-f0-9]{64}$/;
|
|
11
|
+
export async function loginCommand(opts = {}) {
|
|
12
|
+
// Config is optional for `login` — a brand-new user runs it before `init`.
|
|
13
|
+
let config;
|
|
14
|
+
try {
|
|
15
|
+
config = await loadConfig();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
config = { ...defaultConfig };
|
|
19
|
+
}
|
|
20
|
+
let key = opts.key?.trim();
|
|
21
|
+
if (!key) {
|
|
22
|
+
if (!stdin.isTTY) {
|
|
23
|
+
throw new Error('magicpixel login: no key provided and stdin is not a TTY.\n' +
|
|
24
|
+
' Fix: pass --key mp_live_… or run interactively.');
|
|
25
|
+
}
|
|
26
|
+
key = await promptForKey(3, config);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
if (!KEY_RE.test(key)) {
|
|
30
|
+
throw new Error(`That key doesn't look right (expected mp_live_… or mp_test_…).\n` +
|
|
31
|
+
` Fix: copy a fresh key from https://magicpixel.art/settings.`);
|
|
32
|
+
}
|
|
33
|
+
await assertKeyValid(key, config);
|
|
34
|
+
}
|
|
35
|
+
const path = await writeCredentials(key);
|
|
36
|
+
console.log();
|
|
37
|
+
console.log(kleur.green('✓ logged in'));
|
|
38
|
+
console.log(kleur.dim(` Key stored at ${relative(process.cwd(), path)} (mode 0600).`));
|
|
39
|
+
if (process.env.MAGICPIXEL_API_KEY) {
|
|
40
|
+
console.log(kleur.yellow(' Note: MAGICPIXEL_API_KEY is still set in your environment and takes precedence.'));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function promptForKey(maxAttempts, config) {
|
|
44
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
45
|
+
try {
|
|
46
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
47
|
+
const answer = (await rl.question(`${kleur.cyan('?')} Paste your key from ${kleur.cyan('https://magicpixel.art/settings')} ${kleur.dim("(we'll store it in .magicpixel/credentials, gitignored)")}: `)).trim();
|
|
48
|
+
if (!answer) {
|
|
49
|
+
console.log(kleur.yellow(' No key entered.'));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!KEY_RE.test(answer)) {
|
|
53
|
+
console.log(kleur.yellow(` That doesn't look like a MagicPixel key (expected mp_live_… or mp_test_…).`));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await assertKeyValid(answer, config);
|
|
58
|
+
return answer;
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
console.log(kleur.yellow(` ${e.message.split('\n')[0]}`));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Gave up after ${maxAttempts} attempts.\n Fix: generate a fresh key at https://magicpixel.art/settings and re-run.`);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
rl.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function assertKeyValid(key, config) {
|
|
71
|
+
const url = new URL(`${resolveEndpoint(config)}/manifest`);
|
|
72
|
+
url.searchParams.set('limit', '1');
|
|
73
|
+
let res;
|
|
74
|
+
try {
|
|
75
|
+
res = await safeFetch(url.href, {
|
|
76
|
+
headers: { Authorization: `Bearer ${key}`, 'User-Agent': CLI_USER_AGENT },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
throw new Error(`Could not reach MagicPixel (${e.message}). Try again in a moment.`);
|
|
81
|
+
}
|
|
82
|
+
if (res.status === 401 || res.status === 403) {
|
|
83
|
+
await res.body?.cancel();
|
|
84
|
+
throw new Error('Key was rejected (401). Generate a fresh one at https://magicpixel.art/settings.');
|
|
85
|
+
}
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const body = await res.text();
|
|
88
|
+
throw new Error(`Server returned ${res.status}: ${body.slice(0, 120)}`);
|
|
89
|
+
}
|
|
90
|
+
await res.body?.cancel();
|
|
91
|
+
}
|
|
92
|
+
// Kept here so `start` can reuse the same exact prompt-and-validate flow.
|
|
93
|
+
export async function promptAndStoreKey(config) {
|
|
94
|
+
if (existsSync(credentialsPath()) && !stdin.isTTY)
|
|
95
|
+
return;
|
|
96
|
+
const key = await promptForKey(3, config);
|
|
97
|
+
await writeCredentials(key);
|
|
98
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { deleteCredentials, credentialsPath } from '../util/credentials.js';
|
|
3
|
+
import { relative } from 'node:path';
|
|
4
|
+
export async function logoutCommand() {
|
|
5
|
+
const path = credentialsPath();
|
|
6
|
+
const removed = await deleteCredentials();
|
|
7
|
+
if (removed) {
|
|
8
|
+
console.log(kleur.green(`✓ logged out`));
|
|
9
|
+
console.log(kleur.dim(` Removed ${relative(process.cwd(), path)}.`));
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
console.log(kleur.dim('No stored credentials to remove.'));
|
|
13
|
+
}
|
|
14
|
+
if (process.env.MAGICPIXEL_API_KEY) {
|
|
15
|
+
console.log(kleur.yellow(' Note: MAGICPIXEL_API_KEY is still set in your environment — unset it to fully log out.'));
|
|
16
|
+
}
|
|
17
|
+
}
|
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
|
}
|