@magicpixelart/cli 0.3.3 → 0.5.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.
@@ -1,30 +1,70 @@
1
1
  import kleur from 'kleur';
2
- import { loadConfig, resolveEndpoint, getApiKey } from '../config.js';
3
- import { CLI_USER_AGENT } from '../version.js';
2
+ import { loadConfig, defaultConfig, resolveEndpoint, getApiKey } from '../config.js';
3
+ import { ApiError, retryTransient, retryAfterMsFromResponse } from '../api.js';
4
4
  import { safeFetch } from '../util/security.js';
5
+ import { authHeaders } from '../util/authHeaders.js';
5
6
  export async function whoamiCommand() {
6
- const config = await loadConfig();
7
+ // Config is optional for `whoami` — a brand-new user runs it before `init`
8
+ // to verify their key works at all. Mirror the same fallback `login` uses.
9
+ let config;
10
+ try {
11
+ config = await loadConfig();
12
+ }
13
+ catch {
14
+ config = { ...defaultConfig };
15
+ }
7
16
  // Ask for a full page (server caps at 1000) so `count` is honest for any
8
17
  // project with ≤1000 assets — the previous `limit=1` always reported "1+".
9
18
  const url = new URL(`${resolveEndpoint(config)}/manifest`);
10
19
  url.searchParams.set('limit', '1000');
11
- const res = await safeFetch(url.href, {
12
- headers: { Authorization: `Bearer ${getApiKey()}`, 'User-Agent': CLI_USER_AGENT },
20
+ // Wrap in retryTransient so a transient 503 / network blip doesn't make
21
+ // `whoami` falsely report "key rejected" — the same recovery the rest of
22
+ // the API surface gets.
23
+ const result = await retryTransient('whoami', async () => {
24
+ const { headers, requestId } = authHeaders(getApiKey());
25
+ const res = await safeFetch(url.href, { headers });
26
+ const serverRequestId = res.headers.get('x-request-id') ?? requestId;
27
+ if (res.status === 401 || res.status === 403) {
28
+ await res.body?.cancel();
29
+ // Non-retryable: surface as a structured result rather than throwing,
30
+ // so retryTransient doesn't burn extra attempts on a hard rejection.
31
+ return { kind: 'rejected', status: res.status, serverRequestId };
32
+ }
33
+ if (res.status >= 500 || res.status === 429) {
34
+ const bodyText = await res.text();
35
+ // Pass Retry-After through so retryTransient honours server back-pressure
36
+ // (matches fetchManifestPage / fetchAssetBytes).
37
+ throw new ApiError(res.status, bodyText.slice(0, 200), serverRequestId, retryAfterMsFromResponse(res));
38
+ }
39
+ if (!res.ok) {
40
+ const bodyText = await res.text();
41
+ return { kind: 'error', status: res.status, bodyText, serverRequestId };
42
+ }
43
+ const raw = (await res.json());
44
+ // Shape-guard: mirror fetchManifestPage's contract so a malformed server
45
+ // body (e.g. items missing or not an array) reports cleanly instead of
46
+ // throwing TypeError on `body.items[0]?.key` below.
47
+ const body = {
48
+ count: typeof raw.count === 'number' && raw.count > 0 ? Math.floor(raw.count) : 0,
49
+ items: Array.isArray(raw.items) ? raw.items.filter((i) => !!i && typeof i.key === 'string') : [],
50
+ nextCursor: typeof raw.nextCursor === 'string' && raw.nextCursor ? raw.nextCursor : null,
51
+ };
52
+ return { kind: 'ok', body, serverRequestId };
13
53
  });
14
- if (res.status === 401 || res.status === 403) {
15
- await res.body?.cancel();
16
- console.log(kleur.red('✗ API key rejected (401/403).'));
54
+ if (result.kind === 'rejected') {
55
+ console.log(kleur.red(`✗ API key rejected (${result.status}).`));
17
56
  console.log(kleur.dim(' Generate a new key at MagicPixel → Settings → API Keys.'));
57
+ console.log(kleur.dim(` (request id: ${result.serverRequestId})`));
18
58
  process.exitCode = 1;
19
59
  return;
20
60
  }
21
- if (!res.ok) {
22
- const body = await res.text();
23
- console.log(kleur.red(`✗ ${res.status}: ${body.slice(0, 200)}`));
61
+ if (result.kind === 'error') {
62
+ console.log(kleur.red(`✗ ${result.status}: ${result.bodyText.slice(0, 200)}`));
63
+ console.log(kleur.dim(` (request id: ${result.serverRequestId})`));
24
64
  process.exitCode = 1;
25
65
  return;
26
66
  }
27
- const body = (await res.json());
67
+ const { body } = result;
28
68
  const more = body.nextCursor ? '+' : '';
29
69
  console.log(kleur.green('✓ key valid'));
30
70
  console.log(` endpoint: ${resolveEndpoint(config)}`);
package/dist/config.js CHANGED
@@ -1,8 +1,10 @@
1
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
1
+ import { readFile, mkdir, rename } 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
+ import { assertSafeOutDir, MAX_GLOB_LEN, validateEndpointUrl } from './util/security.js';
5
5
  import { readCredentialsSync } from './util/credentials.js';
6
+ import { friendlyFsError } from './util/errors.js';
7
+ import { atomicWrite } from './util/atomicWrite.js';
6
8
  const CONFIG_FILENAME = 'magicpixel.json';
7
9
  const STATE_DIR = '.magicpixel';
8
10
  const STATE_FILENAME = 'state.json';
@@ -34,11 +36,19 @@ export async function loadConfig(cwd = process.cwd()) {
34
36
  throw new Error(`${CONFIG_FILENAME} is not valid JSON: ${e.message}\n` +
35
37
  ` Fix: open the file and check for trailing commas or quotes.`);
36
38
  }
39
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
40
+ throw new Error(`${CONFIG_FILENAME} must be a JSON object (got ${Array.isArray(parsed) ? 'array' : typeof parsed}).\n` +
41
+ ` Fix: replace the file contents with { "outDir": "src/assets/magicpixel", "include": ["**/*"] }.`);
42
+ }
37
43
  const include = normalizeGlobList(parsed.include ?? defaultConfig.include, 'include');
38
44
  const exclude = normalizeGlobList(parsed.exclude ?? defaultConfig.exclude, 'exclude');
39
- const outDir = typeof parsed.outDir === 'string' && parsed.outDir.trim() ? parsed.outDir.trim() : defaultConfig.outDir;
40
- if (outDir.includes('\0') || outDir.split(/[/\\]/).some((seg) => seg === '..')) {
41
- throw new Error(`${CONFIG_FILENAME}: outDir must not contain ".." segments.\n` +
45
+ const rawOutDir = typeof parsed.outDir === 'string' && parsed.outDir.trim() ? parsed.outDir : defaultConfig.outDir;
46
+ let outDir;
47
+ try {
48
+ outDir = assertSafeOutDir(rawOutDir);
49
+ }
50
+ catch (e) {
51
+ throw new Error(`${CONFIG_FILENAME}: ${e.message}\n` +
42
52
  ` Fix: use a path like src/assets/magicpixel.`);
43
53
  }
44
54
  let endpoint;
@@ -48,54 +58,112 @@ export async function loadConfig(cwd = process.cwd()) {
48
58
  }
49
59
  endpoint = validateEndpointUrl(parsed.endpoint.trim());
50
60
  }
61
+ let emitIndex = defaultConfig.emitIndex ?? true;
62
+ if (parsed.emitIndex !== undefined) {
63
+ if (typeof parsed.emitIndex !== 'boolean') {
64
+ throw new Error(`${CONFIG_FILENAME}: "emitIndex" must be a boolean (got ${typeof parsed.emitIndex}).\n` +
65
+ ` Fix: set "emitIndex": true or "emitIndex": false (or remove the field).`);
66
+ }
67
+ emitIndex = parsed.emitIndex;
68
+ }
51
69
  return {
52
70
  outDir,
53
71
  include,
54
72
  exclude,
55
73
  endpoint,
56
- emitIndex: parsed.emitIndex ?? defaultConfig.emitIndex,
74
+ emitIndex,
57
75
  };
58
76
  }
59
77
  const MAX_GLOBS = 64;
60
- const MAX_GLOB_LEN = 256;
61
78
  function normalizeGlobList(value, field) {
62
79
  if (!Array.isArray(value)) {
63
- throw new Error(`${CONFIG_FILENAME}: "${field}" must be an array of glob strings.`);
80
+ throw new Error(`${CONFIG_FILENAME}: "${field}" must be an array of glob strings (got ${typeof value}).\n` +
81
+ ` Fix: change "${field}" to an array, e.g. "${field}": ["**/*"].`);
64
82
  }
65
83
  if (value.length > MAX_GLOBS) {
66
- throw new Error(`${CONFIG_FILENAME}: "${field}" has too many entries (max ${MAX_GLOBS}).`);
84
+ throw new Error(`${CONFIG_FILENAME}: "${field}" has too many entries (${value.length}, max ${MAX_GLOBS}).\n` +
85
+ ` Fix: combine patterns or split your sync into multiple projects.`);
67
86
  }
68
87
  const out = [];
69
88
  for (const item of value) {
70
89
  if (typeof item !== 'string' || !item.trim()) {
71
- throw new Error(`${CONFIG_FILENAME}: "${field}" entries must be non-empty strings.`);
90
+ throw new Error(`${CONFIG_FILENAME}: "${field}" entries must be non-empty strings.\n` +
91
+ ` Fix: remove empty/null entries from "${field}".`);
72
92
  }
73
93
  const g = item.trim();
74
94
  if (g.length > MAX_GLOB_LEN || g.includes('\0')) {
75
- throw new Error(`${CONFIG_FILENAME}: "${field}" entry is too long or invalid.`);
95
+ throw new Error(`${CONFIG_FILENAME}: "${field}" entry is too long (>${MAX_GLOB_LEN} chars) or contains a null byte.\n` +
96
+ ` Fix: shorten the pattern.`);
76
97
  }
77
98
  out.push(g);
78
99
  }
79
100
  return out;
80
101
  }
81
102
  export async function saveConfig(config, cwd = process.cwd()) {
82
- await writeFile(configPath(cwd), JSON.stringify(config, null, 2) + '\n', 'utf8');
103
+ const path = configPath(cwd);
104
+ try {
105
+ // Atomic write: a crash mid-write must never leave magicpixel.json
106
+ // truncated — a corrupt config wedges every subsequent `sync`/`status`
107
+ // run with a JSON parse error and is exactly the scenario `repair` was
108
+ // built to recover from. Cheap to avoid in the first place.
109
+ await atomicWrite(path, JSON.stringify(config, null, 2) + '\n');
110
+ }
111
+ catch (e) {
112
+ throw friendlyFsError(e, {
113
+ operation: 'Saving magicpixel.json',
114
+ path,
115
+ hint: 'magicpixel.json holds your sync config — without it `magicpixel sync` can\'t run.',
116
+ });
117
+ }
83
118
  }
84
119
  export async function loadState(cwd = process.cwd()) {
85
120
  const path = statePath(cwd);
86
121
  if (!existsSync(path))
87
122
  return {};
123
+ let raw;
88
124
  try {
89
- return JSON.parse(await readFile(path, 'utf8'));
125
+ raw = await readFile(path, 'utf8');
90
126
  }
91
127
  catch {
92
128
  return {};
93
129
  }
130
+ try {
131
+ return JSON.parse(raw);
132
+ }
133
+ catch (e) {
134
+ // Corrupt state file (truncated by a crash, hand-edited, disk full, etc.)
135
+ // Quarantine it so the user can recover/inspect, then fall back to a full
136
+ // re-sync rather than wedging every future run with a parse error.
137
+ const quarantine = `${path}.corrupt-${Date.now()}`;
138
+ try {
139
+ await rename(path, quarantine);
140
+ console.warn(`[magicpixel] state.json was corrupt (${e.message}). ` +
141
+ `Moved to ${quarantine} and falling back to a full sync.`);
142
+ }
143
+ catch {
144
+ console.warn(`[magicpixel] state.json was corrupt (${e.message}); ` +
145
+ `falling back to a full sync.`);
146
+ }
147
+ return {};
148
+ }
94
149
  }
95
150
  export async function saveState(state, cwd = process.cwd()) {
96
151
  const path = statePath(cwd);
97
- await mkdir(dirname(path), { recursive: true });
98
- await writeFile(path, JSON.stringify(state, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
152
+ try {
153
+ await mkdir(dirname(path), { recursive: true });
154
+ // Atomic write: stage to a sibling tmp file then rename. Prevents a half-
155
+ // written state.json on crash/power-loss, which would then poison the next
156
+ // run (and require the corrupt-state recovery path above). Mode 0600
157
+ // because state.json sits next to the API-key-bearing credentials file.
158
+ await atomicWrite(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
159
+ }
160
+ catch (e) {
161
+ throw friendlyFsError(e, {
162
+ operation: 'Saving sync state',
163
+ path,
164
+ hint: 'state.json tracks what was last synced — without it the next run re-downloads everything.',
165
+ });
166
+ }
99
167
  }
100
168
  export function resolveEndpoint(config) {
101
169
  if (config.endpoint)
@@ -111,13 +179,10 @@ export function resolveEndpoint(config) {
111
179
  export function getApiKey() {
112
180
  const fromEnv = process.env.MAGICPIXEL_API_KEY;
113
181
  let key = fromEnv;
114
- let source = 'env';
115
182
  if (!key) {
116
183
  const stored = readCredentialsSync();
117
- if (stored) {
184
+ if (stored)
118
185
  key = stored.apiKey;
119
- source = 'credentials-file';
120
- }
121
186
  }
122
187
  if (!key) {
123
188
  throw new Error('No MagicPixel API key found.\n' +
@@ -126,8 +191,11 @@ export function getApiKey() {
126
191
  ' 2. Run `magicpixel login` (or `export MAGICPIXEL_API_KEY=mp_live_...`).\n' +
127
192
  ' 3. Re-run the command.');
128
193
  }
194
+ // Env vars from shells routinely smuggle in stray quotes / whitespace; the
195
+ // credentials file is already trimmed at write time but we trim defensively
196
+ // here so both code paths share the same validation.
129
197
  const trimmed = key.trim();
130
- if (trimmed !== key && source === 'env') {
198
+ if (trimmed !== key && fromEnv !== undefined) {
131
199
  throw new Error(`MAGICPIXEL_API_KEY has leading/trailing whitespace.\n` +
132
200
  ` Fix: export the key without spaces or quotes.`);
133
201
  }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command, InvalidArgumentError } from 'commander';
2
+ import { Command } from 'commander';
3
3
  import kleur from 'kleur';
4
4
  import { initCommand } from './commands/init.js';
5
5
  import { syncCommand } from './commands/sync.js';
@@ -11,7 +11,9 @@ import { whoamiCommand } from './commands/whoami.js';
11
11
  import { loginCommand } from './commands/login.js';
12
12
  import { logoutCommand } from './commands/logout.js';
13
13
  import { doctorCommand } from './commands/doctor.js';
14
+ import { repairCommand } from './commands/repair.js';
14
15
  import { startCommand } from './commands/start.js';
16
+ import { parseWatchInterval, parseConcurrency } from './util/flagValidators.js';
15
17
  import { CLI_VERSION } from './version.js';
16
18
  // Node version guard
17
19
  const major = Number(process.versions.node.split('.')[0]);
@@ -24,75 +26,88 @@ program
24
26
  .name('magicpixel')
25
27
  .description('Sync MagicPixel pixel-art assets to your local project')
26
28
  .version(CLI_VERSION);
27
- const wrap = (fn) => async (...args) => {
29
+ const wrap = (commandName, fn) => async (...args) => {
28
30
  try {
29
31
  await fn(...args);
30
32
  }
31
33
  catch (e) {
32
- const msg = e.message ?? String(e);
34
+ const err = e;
35
+ const msg = err.message ?? String(e);
33
36
  // Multi-line messages are already formatted with "Fix:" hints — print as-is.
34
37
  console.error(kleur.red(msg));
35
- process.exit(1);
38
+ // Fire-and-forget telemetry → exit 1. `reportAndExit` decides whether
39
+ // the error is worth surfacing on /admin/errors (5xx ApiErrors +
40
+ // unexpected throws); user-fixable errors are filtered out inside
41
+ // `shouldReportCliError`. Lazy-imported so cold paths (e.g. `--help`)
42
+ // don't pay the cost.
43
+ const { reportAndExit } = await import('./util/telemetry.js');
44
+ await reportAndExit(err, commandName, 1);
36
45
  }
37
46
  };
38
47
  program
39
48
  .command('start')
40
49
  .description('One-command first-run setup: init + login + first sync')
41
50
  .option('--force', 'Re-run init even if magicpixel.json exists')
42
- .action(wrap(async (opts) => startCommand(opts)));
51
+ .action(wrap("start", async (opts) => startCommand(opts)));
43
52
  program
44
53
  .command('init')
45
54
  .description('Create magicpixel.json (interactive)')
46
55
  .option('--force', 'Overwrite existing config')
47
56
  .option('-y, --yes', 'Skip prompts, use defaults (CI-friendly)')
48
- .action(wrap(async (opts) => initCommand(opts)));
57
+ .action(wrap("init", async (opts) => initCommand(opts)));
49
58
  program
50
59
  .command('login')
51
60
  .description('Save your MagicPixel API key to .magicpixel/credentials')
52
61
  .option('--key <key>', 'Provide the key non-interactively')
53
- .action(wrap(async (opts) => loginCommand(opts)));
62
+ .action(wrap("login", async (opts) => loginCommand(opts)));
54
63
  program
55
64
  .command('logout')
56
65
  .description('Remove the stored MagicPixel API key')
57
- .action(wrap(async () => logoutCommand()));
66
+ .action(wrap("logout", async () => logoutCommand()));
58
67
  program
59
68
  .command('doctor')
60
69
  .description('Print a single-page diagnostic to paste to your AI agent')
61
- .action(wrap(async () => doctorCommand()));
70
+ .option('--json', 'Emit a stable JSON report (pipeable into jq or an LLM)')
71
+ .option('--offline', 'Skip the live manifest probe (useful behind a strict proxy)')
72
+ .addHelpText('after', '\nExamples:\n $ magicpixel doctor\n $ magicpixel doctor --json | jq .network\n')
73
+ .action(wrap("doctor", async (opts) => doctorCommand(opts)));
74
+ program
75
+ .command('repair')
76
+ .description('Self-heal a broken sync: validate key, reset state, re-sync')
77
+ .option('--dry-run', 'Print the plan without writing files')
78
+ .option('-y, --yes', 'Skip the confirmation prompt before resetting state')
79
+ .addHelpText('after', '\nExamples:\n $ magicpixel repair --dry-run # see what would change\n $ magicpixel repair --yes # non-interactive recovery\n')
80
+ .action(wrap("repair", async (opts) => repairCommand(opts)));
62
81
  program
63
82
  .command('sync')
64
83
  .description('Download changed assets from MagicPixel')
65
84
  .option('--no-prune', 'Keep local files not in the manifest (pruning is now on by default)')
66
85
  .option('--dry-run', 'Print the plan without writing files')
67
86
  .option('--full', 'Ignore lastSync state; re-fetch the full manifest')
68
- .option('-w, --watch [seconds]', 'Poll for changes (default 10s)', (v) => v ?? true)
87
+ .option('-w, --watch [seconds]', 'Poll for changes (default 2s; auto-slows to 5s after ~3min idle, 10s after ~15min)', parseWatchInterval)
69
88
  .option('-q, --quiet', 'Minimal output (for CI)')
70
- .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', (v) => {
71
- const n = parseInt(v, 10);
72
- if (!Number.isFinite(n) || String(n) !== v.trim() || n < 1 || n > 16) {
73
- throw new InvalidArgumentError(`expected an integer 1–16, got "${v}".`);
74
- }
75
- return n;
76
- })
77
- .action(wrap(async (opts) => syncCommand(opts)));
89
+ .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', parseConcurrency)
90
+ .addHelpText('after', '\nExamples:\n $ magicpixel sync # incremental sync\n $ magicpixel sync --full # ignore lastSync, re-check everything\n $ magicpixel sync -w # watch mode (2s; adaptive idle backoff; exit 2 after 5 auth failures)\n')
91
+ .action(wrap("sync", async (opts) => syncCommand(opts)));
78
92
  program
79
93
  .command('add <glob>')
80
94
  .description('Append a glob pattern to include')
81
- .action(wrap(async (glob) => addCommand(glob)));
95
+ .action(wrap("add", async (glob) => addCommand(glob)));
82
96
  program
83
97
  .command('remove <glob>')
84
98
  .description('Remove a glob pattern from include')
85
- .action(wrap(async (glob) => removeCommand(glob)));
99
+ .action(wrap("remove", async (glob) => removeCommand(glob)));
86
100
  program
87
101
  .command('list')
88
102
  .description('List the matching assets in the manifest')
89
- .action(wrap(async () => listCommand()));
103
+ .addHelpText('after', '\nExamples:\n $ magicpixel list # tabular preview of every matching asset\n $ magicpixel list | head # quick sanity-check after editing include globs\n')
104
+ .action(wrap("list", async () => listCommand()));
90
105
  program
91
106
  .command('status')
92
107
  .description('Show config, last sync, and diff vs remote')
93
- .action(wrap(async () => statusCommand()));
108
+ .action(wrap("status", async () => statusCommand()));
94
109
  program
95
110
  .command('whoami')
96
111
  .description('Verify the API key and show what it can see')
97
- .action(wrap(async () => whoamiCommand()));
112
+ .action(wrap("whoami", async () => whoamiCommand()));
98
113
  program.parseAsync(process.argv);
@@ -0,0 +1,25 @@
1
+ import { writeFile, rename, chmod } from 'node:fs/promises';
2
+ import { tmpPathFor } from './security.js';
3
+ /**
4
+ * Stage-and-rename file write. A crash mid-write can never leave a truncated
5
+ * file on disk — the rename is atomic on every POSIX FS and on NTFS. Used
6
+ * everywhere a corrupted target would break the project (state.json,
7
+ * package.json, magicpixel.json, credentials, AGENTS.md, generated index files).
8
+ */
9
+ export async function atomicWrite(path, contents, opts = {}) {
10
+ const tmp = tmpPathFor(path);
11
+ const writeOpts = opts.mode !== undefined ? { encoding: 'utf8', mode: opts.mode } : 'utf8';
12
+ await writeFile(tmp, contents, writeOpts);
13
+ if (opts.mode !== undefined) {
14
+ // Belt-and-suspenders: writeFile's `mode` only applies on create. If the
15
+ // tmp path was somehow pre-existing (collision is vanishingly unlikely
16
+ // given the pid+random suffix) the chmod ensures the perms land.
17
+ try {
18
+ await chmod(tmp, opts.mode);
19
+ }
20
+ catch {
21
+ // Windows / read-only FS — ignore.
22
+ }
23
+ }
24
+ await rename(tmp, path);
25
+ }
@@ -0,0 +1,41 @@
1
+ import { resolveEndpoint } from '../config.js';
2
+ import { ApiError, retryTransient } from '../api.js';
3
+ import { safeFetch, readBodyWithLimit } from './security.js';
4
+ import { authHeaders } from './authHeaders.js';
5
+ /**
6
+ * Validate an API key against `/manifest?limit=1`. Throws a friendly error on
7
+ * rejection / network failure; resolves silently on success. Used by `login`
8
+ * (post-prompt) and `repair` (step 1).
9
+ *
10
+ * Wrapped in `retryTransient` so a single 503 or network blip during
11
+ * onboarding doesn't kick the user back to "paste your key again". Carries
12
+ * `X-Request-Id` so a rejection is grep-able in the edge function logs
13
+ * (mem://integration/cli-request-id-contract).
14
+ */
15
+ export async function assertKeyValid(key, config) {
16
+ const url = new URL(`${resolveEndpoint(config)}/manifest`);
17
+ url.searchParams.set('limit', '1');
18
+ await retryTransient('validate key', async () => {
19
+ const { headers, requestId } = authHeaders(key);
20
+ const res = await safeFetch(url.href, { headers });
21
+ const serverRequestId = res.headers.get('x-request-id') ?? requestId;
22
+ if (res.status === 401 || res.status === 403) {
23
+ await res.body?.cancel();
24
+ // 4xx auth — non-retryable. Throw as ApiError so retryTransient bubbles
25
+ // immediately rather than burning two extra attempts.
26
+ throw new ApiError(res.status, `Key was rejected (${res.status}). Generate a fresh one at https://magicpixel.art/settings.\n` +
27
+ ` (request id: ${serverRequestId})`, serverRequestId);
28
+ }
29
+ if (!res.ok) {
30
+ // Cap body read so a misbehaving endpoint returning a multi-MB error
31
+ // page can't balloon CLI memory. 16 KB is plenty for the 120-char slice
32
+ // we ultimately surface.
33
+ const bodyBytes = await readBodyWithLimit(res, 16 * 1024).catch(() => new Uint8Array());
34
+ const body = new TextDecoder().decode(bodyBytes);
35
+ // 5xx + 429 retry through retryTransient; other 4xx bubble immediately.
36
+ throw new ApiError(res.status, `Server returned ${res.status}: ${body.slice(0, 120)}\n` +
37
+ ` (request id: ${serverRequestId})`, serverRequestId);
38
+ }
39
+ await res.body?.cancel();
40
+ });
41
+ }
@@ -0,0 +1,24 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { CLI_USER_AGENT } from '../version.js';
3
+ /**
4
+ * Build the standard request-header bundle used by every CLI → API call.
5
+ * Enforces the cross-cutting contract documented in
6
+ * mem://integration/cli-request-id-contract: every request carries a fresh
7
+ * `X-Request-Id` so the edge function can echo it back and support can
8
+ * correlate any failure against the edge function logs in one grep.
9
+ *
10
+ * Single shared helper instead of inlining the same three headers at every
11
+ * call site (`api.ts`, `auth.ts`, `whoami.ts`, `doctor.ts`).
12
+ */
13
+ export function authHeaders(apiKey, extra) {
14
+ const requestId = randomUUID();
15
+ return {
16
+ requestId,
17
+ headers: {
18
+ Authorization: `Bearer ${apiKey}`,
19
+ 'User-Agent': CLI_USER_AGENT,
20
+ 'X-Request-Id': requestId,
21
+ ...extra,
22
+ },
23
+ };
24
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { mkdir, readFile, writeFile, unlink, chmod } from 'node:fs/promises';
2
+ import { mkdir, readFile, unlink } from 'node:fs/promises';
3
3
  import { resolve, dirname } from 'node:path';
4
+ import { atomicWrite } from './atomicWrite.js';
4
5
  const CREDENTIALS_DIR = '.magicpixel';
5
6
  const CREDENTIALS_FILE = 'credentials';
6
7
  export function credentialsPath(cwd = process.cwd()) {
@@ -27,14 +28,10 @@ export async function writeCredentials(apiKey, cwd = process.cwd()) {
27
28
  const path = credentialsPath(cwd);
28
29
  await mkdir(dirname(path), { recursive: true });
29
30
  const body = { apiKey: apiKey.trim(), savedAt: new Date().toISOString() };
30
- await writeFile(path, JSON.stringify(body, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
31
- // Belt-and-suspenders: chmod again in case the file pre-existed with looser perms.
32
- try {
33
- await chmod(path, 0o600);
34
- }
35
- catch {
36
- // ignore (Windows or read-only FS)
37
- }
31
+ // Atomic stage-and-rename. The mode is applied to the tmp file *before* the
32
+ // rename so the final credentials file is never world-readable for even a
33
+ // single tick (crash-window safety + perm-window safety in one call).
34
+ await atomicWrite(path, JSON.stringify(body, null, 2) + '\n', { mode: 0o600 });
38
35
  return path;
39
36
  }
40
37
  export async function deleteCredentials(cwd = process.cwd()) {
@@ -62,7 +59,10 @@ export async function findKeyInDotenv(cwd = process.cwd()) {
62
59
  try {
63
60
  const raw = await readFile(path, 'utf8');
64
61
  for (const line of raw.split('\n')) {
65
- const m = line.match(/^\s*MAGICPIXEL_API_KEY\s*=\s*"?([^"\n#]+?)"?\s*$/);
62
+ // Anchored on the *exact* key name — otherwise `MAGICPIXEL_API_KEY_OLD`
63
+ // or `MAGICPIXEL_API_KEY_BACKUP` would silently match and return the
64
+ // wrong value. Allows optional `export` prefix and spaces around `=`.
65
+ const m = line.match(/^\s*(?:export\s+)?MAGICPIXEL_API_KEY\s*=\s*"?([^"\n#]+?)"?\s*(?:#.*)?$/);
66
66
  if (m && m[1])
67
67
  return { file: name, value: m[1].trim() };
68
68
  }
@@ -80,3 +80,20 @@ export function describeKeySource(cwd = process.cwd()) {
80
80
  return 'credentials-file';
81
81
  return 'none';
82
82
  }
83
+ /**
84
+ * Best-effort read of the API key for *display only* (e.g. `status`,
85
+ * `doctor`). Honors the same precedence as `getApiKey()` but never throws and
86
+ * never validates — callers that need a real key should still use `getApiKey`.
87
+ *
88
+ * Returns `null` when no key is configured. The caller is responsible for
89
+ * masking before printing.
90
+ */
91
+ export function readKeyForDisplay(cwd = process.cwd()) {
92
+ const fromEnv = process.env.MAGICPIXEL_API_KEY?.trim();
93
+ if (fromEnv)
94
+ return { value: fromEnv, source: 'env' };
95
+ const stored = readCredentialsSync(cwd);
96
+ if (stored)
97
+ return { value: stored.apiKey, source: 'credentials-file' };
98
+ return null;
99
+ }