@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 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
- npx magicpixel init # interactive setup, detects your framework
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
- Get an API key at [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys. Each key is bound to one project.
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, type MagicPixelAssetKey } from '@/assets/magicpixel';
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
- // Typo? TypeScript error. Vite/webpack hash the URL at build time.
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
- | `init [-y] [--force]` | Interactive config wizard. `-y` for CI. |
46
- | `sync [...flags]` | Fetch manifest, diff against disk, download changed assets. |
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` | Delete local files no longer in the manifest. |
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
- State (`.magicpixel/state.json`) tracks `lastSync`. Add `.magicpixel/` to `.gitignore` (init offers to do this).
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 --prune --quiet
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
- | `MAGICPIXEL_API_KEY is not set` | Export it from your shell. |
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 fetch(url, { headers: authHeaders() });
47
+ res = await safeFetch(url.href, { headers: authHeaders() });
47
48
  }
48
49
  catch (e) {
49
50
  throw new Error(`manifest: network error (${e.message}).\n` +
@@ -79,21 +80,27 @@ export async function fetchAssetBytes(config, key, knownSha) {
79
80
  url.searchParams.set('key', key);
80
81
  const headers = authHeaders();
81
82
  if (knownSha)
82
- headers['If-None-Match'] = `"${knownSha}"`;
83
+ headers['If-None-Match'] = etagForSha256(knownSha);
83
84
  const maxAttempts = 3;
84
85
  let lastErr = null;
85
86
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
86
87
  try {
87
- const res = await fetch(url, { headers });
88
- if (res.status === 304)
88
+ const res = await safeFetch(url.href, { headers });
89
+ if (res.status === 304) {
90
+ // Drain so undici can return the socket to the keep-alive pool.
91
+ await res.body?.cancel();
89
92
  return null;
93
+ }
90
94
  if (res.ok)
91
- return new Uint8Array(await res.arrayBuffer());
95
+ return await readBodyWithLimit(res, MAX_ASSET_BYTES);
96
+ // Consume the body via .text() for the error message; this also drains
97
+ // the stream so we don't leak the socket on retry.
98
+ const bodyText = await res.text();
92
99
  if (res.status === 429 || res.status >= 500) {
93
- lastErr = new ApiError(res.status, friendly(res.status, await res.text(), `download ${key}`));
100
+ lastErr = new ApiError(res.status, friendly(res.status, bodyText, `download ${key}`));
94
101
  }
95
102
  else {
96
- throw new ApiError(res.status, friendly(res.status, await res.text(), `download ${key}`));
103
+ throw new ApiError(res.status, friendly(res.status, bodyText, `download ${key}`));
97
104
  }
98
105
  }
99
106
  catch (e) {
@@ -1,12 +1,14 @@
1
1
  import kleur from 'kleur';
2
2
  import { loadConfig, saveConfig } from '../config.js';
3
+ import { assertSafeGlob } from '../util/security.js';
3
4
  export async function addCommand(glob) {
5
+ const pattern = assertSafeGlob(glob);
4
6
  const config = await loadConfig();
5
- if (config.include.includes(glob)) {
6
- console.log(kleur.yellow(`already included: ${glob}`));
7
+ if (config.include.includes(pattern)) {
8
+ console.log(kleur.yellow(`already included: ${pattern}`));
7
9
  return;
8
10
  }
9
- config.include.push(glob);
11
+ config.include.push(pattern);
10
12
  await saveConfig(config);
11
- console.log(kleur.green(`✓ added include pattern: ${glob}`));
13
+ console.log(kleur.green(`✓ added include pattern: ${pattern}`));
12
14
  }
@@ -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
+ }
@@ -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
- console.log();
57
- console.log(kleur.bold('Next:'));
58
- console.log(` 1. Get an API key → ${kleur.cyan('https://magicpixel.art/settings')}`);
59
- console.log(` 2. ${kleur.dim('export')} MAGICPIXEL_API_KEY=mp_live_...`);
60
- console.log(` 3. ${kleur.dim('npx')} magicpixel sync`);
61
- console.log();
62
- console.log(kleur.dim('Tip: use `magicpixel sync --watch` while editing.'));
63
- }
64
- async function detectFramework() {
65
- try {
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
- catch {
88
- return null;
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
- function suggestOutDir(framework) {
92
- switch (framework) {
93
- case 'Next.js':
94
- case 'Astro':
95
- case 'Nuxt':
96
- case 'Create React App':
97
- return 'public/magicpixel';
98
- case 'SvelteKit':
99
- return 'static/magicpixel';
100
- case 'Vite':
101
- case 'Remix':
102
- default:
103
- return 'src/assets/magicpixel';
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
+ }
@@ -1,13 +1,15 @@
1
1
  import kleur from 'kleur';
2
2
  import { loadConfig, saveConfig } from '../config.js';
3
+ import { assertSafeGlob } from '../util/security.js';
3
4
  export async function removeCommand(glob) {
5
+ const pattern = assertSafeGlob(glob);
4
6
  const config = await loadConfig();
5
7
  const before = config.include.length;
6
- config.include = config.include.filter((g) => g !== glob);
8
+ config.include = config.include.filter((g) => g !== pattern);
7
9
  if (config.include.length === before) {
8
- console.log(kleur.yellow(`not found in include: ${glob}`));
10
+ console.log(kleur.yellow(`not found in include: ${pattern}`));
9
11
  return;
10
12
  }
11
13
  await saveConfig(config);
12
- console.log(kleur.green(`✓ removed include pattern: ${glob}`));
14
+ console.log(kleur.green(`✓ removed include pattern: ${pattern}`));
13
15
  }