@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.
package/dist/api.js CHANGED
@@ -1,16 +1,29 @@
1
- import { resolveEndpoint, getApiKey, CLI_USER_AGENT, CLI_VERSION } from './config.js';
1
+ import { resolveEndpoint, getApiKey, CLI_VERSION } from './config.js';
2
2
  import { etagForSha256, MAX_ASSET_BYTES, readBodyWithLimit, safeFetch, } from './util/security.js';
3
+ import { authHeaders } from './util/authHeaders.js';
4
+ /**
5
+ * Error from the MagicPixel API. `requestId` is the `X-Request-Id` the server
6
+ * echoed (or the one we minted client-side if the server didn't respond) —
7
+ * surface it in user messages so support can correlate against edge logs.
8
+ *
9
+ * `retryAfterMs` is populated when the server sent a `Retry-After` header on
10
+ * a 429/5xx; the retry helper honors it instead of the default backoff.
11
+ */
3
12
  export class ApiError extends Error {
4
13
  status;
5
- constructor(status, message) {
14
+ requestId;
15
+ retryAfterMs;
16
+ constructor(status, message, requestId, retryAfterMs) {
6
17
  super(message);
7
18
  this.status = status;
19
+ this.requestId = requestId;
20
+ this.retryAfterMs = retryAfterMs;
8
21
  }
9
22
  }
10
23
  function friendly(status, body, context) {
11
24
  if (status === 401 || status === 403) {
12
25
  return (`${context}: ${status} — API key rejected.\n` +
13
- ` Fix: regenerate at https://magicpixel.art/settings and re-export MAGICPIXEL_API_KEY.`);
26
+ ` Fix: regenerate at https://magicpixel.art/settings and re-run \`magicpixel login\`.`);
14
27
  }
15
28
  if (status === 404) {
16
29
  return (`${context}: 404 — endpoint or asset not found.\n` +
@@ -24,11 +37,19 @@ function friendly(status, body, context) {
24
37
  }
25
38
  return `${context}: ${status} — ${body.slice(0, 200)}`;
26
39
  }
27
- function authHeaders() {
28
- return {
29
- Authorization: `Bearer ${getApiKey()}`,
30
- 'User-Agent': CLI_USER_AGENT,
31
- };
40
+ /** Append "(request id: …)" to a friendly message — only when we have one. */
41
+ function withRequestId(message, requestId) {
42
+ if (!requestId)
43
+ return message;
44
+ return `${message}\n (request id: ${requestId})`;
45
+ }
46
+ /**
47
+ * Authenticated header bundle for API requests. Re-reads `getApiKey()` on
48
+ * every call so a `magicpixel login` in another terminal is picked up by the
49
+ * next retry tick of a long-running `sync --watch`.
50
+ */
51
+ function buildHeaders(extra) {
52
+ return authHeaders(getApiKey(), extra);
32
53
  }
33
54
  export async function fetchManifestPage(opts) {
34
55
  const url = new URL(`${resolveEndpoint(opts.config)}/manifest`);
@@ -42,22 +63,75 @@ export async function fetchManifestPage(opts) {
42
63
  url.searchParams.set('cursor', opts.cursor);
43
64
  if (opts.limit)
44
65
  url.searchParams.set('limit', String(opts.limit));
45
- let res;
46
- try {
47
- res = await safeFetch(url.href, { headers: authHeaders() });
48
- }
49
- catch (e) {
50
- throw new Error(`manifest: network error (${e.message}).\n` +
51
- ` Fix: check your internet connection and that ${url.host} is reachable.`);
52
- }
53
- if (!res.ok) {
54
- throw new ApiError(res.status, friendly(res.status, await res.text(), 'manifest'));
66
+ // Reuse the same transient-failure policy as asset downloads: 3 attempts,
67
+ // exponential backoff, honor Retry-After on 429/5xx. Network blips and
68
+ // brief edge restarts otherwise blow up the whole sync mid-pagination.
69
+ return retryTransient(`manifest`, async () => {
70
+ const { headers, requestId } = buildHeaders();
71
+ const res = await safeFetch(url.href, { headers });
72
+ const serverRequestId = res.headers.get('x-request-id') ?? requestId;
73
+ if (res.status >= 200 && res.status < 300) {
74
+ const minCli = res.headers.get('x-magicpixel-min-cli-version');
75
+ if (minCli)
76
+ maybeWarnStaleCli(minCli);
77
+ const data = (await res.json());
78
+ // Shape-guard the response. A malformed edge response (null,
79
+ // {items: null}, nextCursor: 42, etc.) would otherwise either crash
80
+ // inside the pagination loop ("null is not iterable") or get fed back
81
+ // as a bogus ?cursor=42 on the next request. Surface a friendly
82
+ // ApiError carrying the request id so support can grep the edge logs.
83
+ const isObject = !!data && typeof data === 'object' && !Array.isArray(data);
84
+ const cursor = isObject ? data.nextCursor : undefined;
85
+ const cursorOk = cursor === null || cursor === undefined || typeof cursor === 'string';
86
+ if (!isObject || !Array.isArray(data.items) || !cursorOk) {
87
+ throw new ApiError(502, withRequestId('manifest: unexpected server response shape (items missing or non-string cursor).', serverRequestId), serverRequestId);
88
+ }
89
+ return data;
90
+ }
91
+ const bodyText = await res.text();
92
+ throw new ApiError(res.status, withRequestId(friendly(res.status, bodyText, 'manifest'), serverRequestId), serverRequestId, retryAfterMsFromResponse(res));
93
+ });
94
+ }
95
+ /**
96
+ * Run `fn` up to 3 times, backing off on network errors, 429, and 5xx.
97
+ * Non-retryable ApiErrors (4xx other than 429) bubble immediately.
98
+ *
99
+ * - `ApiError.retryAfterMs` (populated from a `Retry-After` header on 429/5xx)
100
+ * wins over the default exponential backoff for the *next* attempt.
101
+ * - Network/transport failures are wrapped once with a friendly hint and the
102
+ * request-id of the most recent attempt.
103
+ */
104
+ export async function retryTransient(context, fn) {
105
+ const maxAttempts = 3;
106
+ let lastErr = null;
107
+ let nextDelayMs = 0;
108
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
109
+ try {
110
+ return await fn();
111
+ }
112
+ catch (e) {
113
+ const err = e;
114
+ if (err instanceof ApiError) {
115
+ if (err.status < 500 && err.status !== 429)
116
+ throw err;
117
+ lastErr = err;
118
+ nextDelayMs = err.retryAfterMs ?? 0;
119
+ }
120
+ else {
121
+ // Network/transport failure — wrap once with the existing hint.
122
+ // Preserve the original error as `cause` so `--verbose` consumers
123
+ // and Node's default printer can still surface the underlying stack.
124
+ lastErr = new Error(`${context}: network error (${err.message}).\n` +
125
+ ` Fix: check your internet connection and that the MagicPixel API is reachable.`, { cause: err });
126
+ nextDelayMs = 0;
127
+ }
128
+ if (attempt < maxAttempts) {
129
+ const backoffMs = 250 * 2 ** (attempt - 1);
130
+ await new Promise((r) => setTimeout(r, Math.max(nextDelayMs, backoffMs)));
131
+ }
132
+ }
55
133
  }
56
- // Hint to nudge stale CLIs. Printed once per process by sync.ts.
57
- const minCli = res.headers.get('x-magicpixel-min-cli-version');
58
- if (minCli)
59
- maybeWarnStaleCli(minCli);
60
- return (await res.json());
134
+ throw lastErr ?? new Error(`${context}: unknown error`);
61
135
  }
62
136
  // ----- Stale-CLI nudge ------------------------------------------------------
63
137
  // Cheap version comparison: split on dots, compare numerically. Pre-release
@@ -79,6 +153,10 @@ function maybeWarnStaleCli(minVersion) {
79
153
  console.warn(`\n[magicpixel] CLI ${current} is older than the recommended ${min}.\n` +
80
154
  ` Fix: npm i -D @magicpixelart/cli@latest (or @magicpixelart/vite)\n`);
81
155
  }
156
+ /** Test-only: reset the module-singleton warn-once flag between vitest runs. */
157
+ export function __resetStaleWarnedForTesting() {
158
+ staleWarned = false;
159
+ }
82
160
  function compareSemver(a, b) {
83
161
  const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
84
162
  const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
@@ -98,75 +176,60 @@ export async function fetchAllManifest(config, since) {
98
176
  for (let page = 0; page < MAX_PAGES; page++) {
99
177
  const res = await fetchManifestPage({ config, since, cursor, limit: 500 });
100
178
  out.push(...res.items);
101
- cursor = res.nextCursor ?? undefined;
102
- if (!cursor)
179
+ const nextCursor = res.nextCursor ?? undefined;
180
+ if (!nextCursor)
103
181
  return out;
182
+ // Detect a stuck cursor (server returns the same token it just received)
183
+ // in O(1) round-trips instead of burning the full page budget.
184
+ if (nextCursor === cursor) {
185
+ throw new Error(`manifest: server returned a repeating cursor — possible server cursor loop.\n` +
186
+ ` Fix: re-run with --full, or report at https://github.com/magicpixel/cli/issues.`);
187
+ }
188
+ cursor = nextCursor;
104
189
  }
105
190
  throw new Error(`manifest: pagination exceeded ${MAX_PAGES} pages — possible server cursor loop.\n` +
106
191
  ` Fix: re-run with --full, or report at https://github.com/magicpixel/cli/issues.`);
107
192
  }
108
193
  /**
109
194
  * Download a single asset by key. Returns null on 304 (not modified).
110
- * Retries transient failures (network errors, 429, 5xx) with backoff.
195
+ * Shares the retryTransient policy with manifest fetches — including
196
+ * Retry-After honoring and request-id propagation on failures.
111
197
  */
112
198
  export async function fetchAssetBytes(config, key, knownSha) {
113
199
  const url = new URL(resolveEndpoint(config));
114
200
  url.searchParams.set('key', key);
115
- const headers = authHeaders();
116
- if (knownSha)
117
- headers['If-None-Match'] = etagForSha256(knownSha);
118
- const maxAttempts = 3;
119
- let lastErr = null;
120
- let retryAfterMs = 0; // populated from Retry-After when present
121
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
122
- try {
123
- const res = await safeFetch(url.href, { headers });
124
- if (res.status === 304) {
125
- // Drain so undici can return the socket to the keep-alive pool.
126
- await res.body?.cancel();
127
- return null;
128
- }
129
- if (res.ok)
130
- return await readBodyWithLimit(res, MAX_ASSET_BYTES);
131
- // Consume the body via .text() for the error message; this also drains
132
- // the stream so we don't leak the socket on retry.
133
- const bodyText = await res.text();
134
- if (res.status === 429 || res.status >= 500) {
135
- // Honor Retry-After (seconds). Cap at 60s so a bogus header can't hang
136
- // the CLI; fall back to exponential backoff when missing/invalid.
137
- const ra = parseRetryAfterSeconds(res.headers.get('retry-after'));
138
- if (ra > 0)
139
- retryAfterMs = Math.min(ra * 1000, 60_000);
140
- lastErr = new ApiError(res.status, friendly(res.status, bodyText, `download ${key}`));
141
- }
142
- else {
143
- throw new ApiError(res.status, friendly(res.status, bodyText, `download ${key}`));
144
- }
145
- }
146
- catch (e) {
147
- lastErr = e;
148
- if (e instanceof ApiError && e.status < 500 && e.status !== 429)
149
- throw e;
201
+ const conditional = knownSha ? { 'If-None-Match': etagForSha256(knownSha) } : undefined;
202
+ return retryTransient(`download ${key}`, async () => {
203
+ const { headers, requestId } = buildHeaders(conditional);
204
+ const res = await safeFetch(url.href, { headers });
205
+ const serverRequestId = res.headers.get('x-request-id') ?? requestId;
206
+ if (res.status === 304) {
207
+ // Drain so undici can return the socket to the keep-alive pool.
208
+ await res.body?.cancel();
209
+ return null;
150
210
  }
151
- if (attempt < maxAttempts) {
152
- const backoffMs = 250 * 2 ** (attempt - 1);
153
- await new Promise((r) => setTimeout(r, Math.max(retryAfterMs, backoffMs)));
154
- retryAfterMs = 0;
155
- }
156
- }
157
- throw lastErr ?? new Error(`download ${key}: unknown error`);
211
+ if (res.ok)
212
+ return await readBodyWithLimit(res, MAX_ASSET_BYTES);
213
+ const bodyText = await res.text();
214
+ throw new ApiError(res.status, withRequestId(friendly(res.status, bodyText, `download ${key}`), serverRequestId), serverRequestId, retryAfterMsFromResponse(res));
215
+ });
158
216
  }
159
- /** Retry-After may be seconds or an HTTP-date (RFC 7231). Accept both. */
160
- function parseRetryAfterSeconds(raw) {
217
+ /**
218
+ * Parse a `Retry-After` header (seconds or HTTP-date per RFC 7231) into ms.
219
+ * Capped at 60s so a bogus header can't hang the CLI. Returns 0 when absent
220
+ * or invalid (caller falls back to its default backoff).
221
+ */
222
+ export function retryAfterMsFromResponse(res) {
223
+ const raw = res.headers.get('retry-after');
161
224
  if (!raw)
162
225
  return 0;
163
226
  const trimmed = raw.trim();
164
227
  const n = Number(trimmed);
165
228
  if (Number.isFinite(n) && n > 0)
166
- return n;
229
+ return Math.min(n * 1000, 60_000);
167
230
  const asDate = Date.parse(trimmed);
168
231
  if (!Number.isNaN(asDate)) {
169
- return Math.max(0, Math.ceil((asDate - Date.now()) / 1000));
232
+ return Math.min(Math.max(0, asDate - Date.now()), 60_000);
170
233
  }
171
234
  return 0;
172
235
  }
@@ -3,54 +3,42 @@ import { existsSync } from 'node:fs';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { resolve, relative } from 'node:path';
5
5
  import { loadConfig, loadState, resolveEndpoint, defaultConfig } from '../config.js';
6
- import { describeKeySource } from '../util/credentials.js';
6
+ import { describeKeySource, readKeyForDisplay } from '../util/credentials.js';
7
7
  import { detectFramework, hasPackageJson } from '../util/framework.js';
8
+ import { safeFetch } from '../util/security.js';
9
+ import { authHeaders } from '../util/authHeaders.js';
8
10
  import { CLI_VERSION } from '../version.js';
11
+ export async function doctorCommand(opts = {}) {
12
+ const report = await collectDoctorReport(opts);
13
+ if (opts.json) {
14
+ // Pure JSON to stdout — no ANSI, no header. Pipe-able into `jq` or pasted
15
+ // into an LLM verbatim.
16
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
17
+ return;
18
+ }
19
+ console.log(renderDoctorReport(report));
20
+ }
9
21
  /**
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.
22
+ * Read-only data collection. Hits the network exactly once (a HEAD-equivalent
23
+ * GET against `/manifest?limit=1`) unless `--offline` is set. Never mutates
24
+ * state on disk.
12
25
  */
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
26
+ export async function collectDoctorReport(opts = {}) {
25
27
  const framework = await detectFramework();
26
- push(`package.json: ${hasPackageJson() ? 'found' : kleur.yellow('missing')}`);
27
- push(`Framework detected: ${framework ?? kleur.dim('none')}`);
28
- // Config
28
+ const pkgFound = hasPackageJson();
29
29
  let config = null;
30
+ let configErr = null;
30
31
  try {
31
32
  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
33
  }
41
34
  catch (e) {
42
- push(`magicpixel.json: ${kleur.yellow('missing')} (${e.message.split('\n')[0]})`);
35
+ // Preserve the full multi-line "Fix:" hint for `--json` consumers (LLM
36
+ // paste-and-debug). The human renderer below trims to the first line so
37
+ // the TTY layout stays compact.
38
+ configErr = e.message;
43
39
  }
44
- push(`API endpoint: ${resolveEndpoint(config ?? { ...defaultConfig })}`);
45
- // Key source
40
+ const endpoint = resolveEndpoint(config ?? { ...defaultConfig });
46
41
  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
42
  let watchScript = null;
55
43
  try {
56
44
  const pkgPath = resolve(process.cwd(), 'package.json');
@@ -60,33 +48,190 @@ export async function doctorCommand() {
60
48
  }
61
49
  }
62
50
  catch {
63
- // ignore
51
+ // package.json absent or unreadable — already reflected in `hasPackageJson`.
64
52
  }
65
- push(`Watch script: ${watchScript ? `"${watchScript}"` : kleur.dim('not configured')}`);
66
- // State
67
53
  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)
54
+ let outDirPath = null;
55
+ let outDirExists = false;
73
56
  if (config) {
74
- const out = resolve(process.cwd(), config.outDir);
75
- if (existsSync(out)) {
76
- push(`outDir on disk: ${relative(process.cwd(), out)} (exists)`);
57
+ const abs = resolve(process.cwd(), config.outDir);
58
+ outDirPath = relative(process.cwd(), abs) || config.outDir;
59
+ outDirExists = existsSync(abs);
60
+ }
61
+ // Skip the network probe when there's no key — it would unauthenticate to
62
+ // a guaranteed 401 and surface a misleading "API rejected the key"
63
+ // suggestion on top of the (correct) "run login" one. The network section
64
+ // still gets a structured value so `--json` consumers can branch on it.
65
+ const network = opts.offline
66
+ ? { skipped: 'offline' }
67
+ : keySource === 'none'
68
+ ? { skipped: 'no-api-key' }
69
+ : await probeManifest(endpoint);
70
+ const suggestions = [];
71
+ if (keySource === 'none')
72
+ suggestions.push('Run `magicpixel login` to store your API key.');
73
+ if (!config)
74
+ suggestions.push('Run `magicpixel start` (or `magicpixel init`) to bootstrap.');
75
+ if (config && !watchScript && pkgFound) {
76
+ suggestions.push('Add a `magicpixel:watch` npm script (or run `magicpixel sync -w`) for live sync.');
77
+ }
78
+ if (state.lastError)
79
+ suggestions.push('Last sync surfaced an error — run `magicpixel repair` to self-heal.');
80
+ // Only emit network-derived suggestions when we actually probed.
81
+ if ('ok' in network && !network.ok) {
82
+ if (network.status === 401 || network.status === 403) {
83
+ suggestions.push('API rejected the key — run `magicpixel login` with a fresh key from settings.');
84
+ }
85
+ else if (network.status === null) {
86
+ suggestions.push('Could not reach MagicPixel — check internet/proxy, or re-run with --offline.');
77
87
  }
78
88
  else {
79
- push(`outDir on disk: ${kleur.dim('not created yet')}`);
89
+ suggestions.push(`Manifest probe returned ${network.status} — paste the request id when filing a report.`);
80
90
  }
81
91
  }
92
+ return {
93
+ cli: CLI_VERSION,
94
+ node: process.versions.node,
95
+ platform: process.platform,
96
+ cwd: process.cwd(),
97
+ framework: framework ?? null,
98
+ hasPackageJson: pkgFound,
99
+ config: config
100
+ ? {
101
+ found: true,
102
+ outDir: config.outDir,
103
+ emitIndex: config.emitIndex !== false,
104
+ include: config.include,
105
+ exclude: config.exclude,
106
+ endpoint: config.endpoint ?? null,
107
+ }
108
+ : { found: false, error: configErr ?? 'unknown' },
109
+ endpoint,
110
+ key: { source: keySource },
111
+ watchScript,
112
+ state: {
113
+ lastSync: state.lastSync ?? null,
114
+ assetCount: state.assets ? Object.keys(state.assets).length : 0,
115
+ lastError: state.lastError ?? null,
116
+ },
117
+ outDir: { path: outDirPath, exists: outDirExists },
118
+ network,
119
+ suggestions,
120
+ };
121
+ }
122
+ async function probeManifest(endpoint) {
123
+ const url = new URL(`${endpoint}/manifest`);
124
+ url.searchParams.set('limit', '1');
125
+ const display = readKeyForDisplay();
126
+ if (!display) {
127
+ // Defensive: the caller in `collectDoctorReport` already returns the
128
+ // structured "no API key" record without invoking us. If we somehow get
129
+ // here, surface a clear internal error rather than a confusing 401.
130
+ return {
131
+ ok: false,
132
+ status: null,
133
+ roundtripMs: null,
134
+ requestId: null,
135
+ error: 'internal: probeManifest invoked without a stored API key',
136
+ };
137
+ }
138
+ const { headers, requestId } = authHeaders(display.value);
139
+ const controller = new AbortController();
140
+ const timer = setTimeout(() => controller.abort(), 5000);
141
+ const start = Date.now();
142
+ try {
143
+ const res = await safeFetch(url.href, { headers, signal: controller.signal });
144
+ await res.body?.cancel();
145
+ const elapsed = Date.now() - start;
146
+ const serverRequestId = res.headers.get('x-request-id') ?? requestId;
147
+ return {
148
+ ok: res.ok,
149
+ status: res.status,
150
+ roundtripMs: elapsed,
151
+ requestId: serverRequestId,
152
+ error: res.ok ? null : `HTTP ${res.status}`,
153
+ };
154
+ }
155
+ catch (e) {
156
+ return {
157
+ ok: false,
158
+ status: null,
159
+ roundtripMs: Date.now() - start,
160
+ requestId,
161
+ error: e.message,
162
+ };
163
+ }
164
+ finally {
165
+ clearTimeout(timer);
166
+ }
167
+ }
168
+ /** Human-readable rendering of `DoctorReport`. */
169
+ export function renderDoctorReport(r) {
170
+ const lines = [];
171
+ const push = (s = '') => lines.push(s);
172
+ push(kleur.bold('MagicPixel CLI — doctor'));
173
+ push(kleur.dim(` Paste this to your AI agent if something isn't working.`));
82
174
  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.'));
175
+ push(`CLI version: ${r.cli}`);
176
+ push(`Node version: ${r.node}`);
177
+ push(`Platform: ${r.platform}`);
178
+ push(`cwd: ${r.cwd}`);
179
+ push();
180
+ push(`package.json: ${r.hasPackageJson ? 'found' : kleur.yellow('missing')}`);
181
+ push(`Framework detected: ${r.framework ?? kleur.dim('none')}`);
182
+ if (r.config.found) {
183
+ push(`magicpixel.json: found`);
184
+ push(` outDir: ${r.config.outDir}`);
185
+ push(` emitIndex: ${r.config.emitIndex ? 'true' : 'false'}`);
186
+ push(` include: ${r.config.include.join(', ')}`);
187
+ if (r.config.exclude.length)
188
+ push(` exclude: ${r.config.exclude.join(', ')}`);
189
+ if (r.config.endpoint)
190
+ push(` endpoint: ${kleur.yellow(r.config.endpoint)} (custom)`);
191
+ }
192
+ else {
193
+ push(`magicpixel.json: ${kleur.yellow('missing')} (${r.config.error.split('\n')[0]})`);
194
+ }
195
+ push(`API endpoint: ${r.endpoint}`);
196
+ const sourceLabel = r.key.source === 'env'
197
+ ? 'environment variable (MAGICPIXEL_API_KEY)'
198
+ : r.key.source === 'credentials-file'
199
+ ? '.magicpixel/credentials'
200
+ : kleur.yellow('none — run `magicpixel login`');
201
+ push(`API key source: ${sourceLabel}`);
202
+ push(`Watch script: ${r.watchScript ? `"${r.watchScript}"` : kleur.dim('not configured')}`);
203
+ push(`Last sync: ${r.state.lastSync ? new Date(r.state.lastSync).toLocaleString() : kleur.dim('never')}`);
204
+ push(`Assets in state: ${r.state.assetCount}`);
205
+ if (r.state.lastError)
206
+ push(`Last error: ${kleur.yellow(r.state.lastError)}`);
207
+ if (r.outDir.path !== null) {
208
+ push(`outDir on disk: ${r.outDir.path} (${r.outDir.exists ? 'exists' : kleur.dim('not created yet')})`);
209
+ }
210
+ push();
211
+ if ('skipped' in r.network) {
212
+ const reason = r.network.skipped === 'offline'
213
+ ? 'skipped (--offline)'
214
+ : 'skipped (no API key — run `magicpixel login`)';
215
+ push(`Network probe: ${kleur.dim(reason)}`);
216
+ }
217
+ else if (r.network.ok) {
218
+ push(`Network probe: ${kleur.green('✓')} ${r.network.status} in ${r.network.roundtripMs}ms`);
219
+ if (r.network.requestId)
220
+ push(` request id: ${kleur.dim(r.network.requestId)}`);
221
+ }
222
+ else {
223
+ const statusLabel = r.network.status !== null ? `HTTP ${r.network.status}` : 'no response';
224
+ push(`Network probe: ${kleur.red('✗')} ${statusLabel} (${r.network.roundtripMs}ms)`);
225
+ if (r.network.requestId)
226
+ push(` request id: ${kleur.dim(r.network.requestId)}`);
227
+ if (r.network.error)
228
+ push(` error: ${kleur.dim(r.network.error)}`);
229
+ }
230
+ if (r.suggestions.length > 0) {
231
+ push();
232
+ push(kleur.dim('Next steps:'));
233
+ for (const s of r.suggestions)
234
+ push(kleur.dim(` • ${s}`));
90
235
  }
91
- console.log(lines.join('\n'));
236
+ return lines.join('\n');
92
237
  }
@@ -1,11 +1,13 @@
1
1
  import kleur from 'kleur';
2
2
  import { existsSync } from 'node:fs';
3
- import { readFile, writeFile, appendFile } from 'node:fs/promises';
3
+ import { readFile, appendFile, writeFile } from 'node:fs/promises';
4
4
  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';
8
+ import { detectFramework, suggestOutDir, hasPackageJson, isStaticOutDir } from '../util/framework.js';
9
+ import { assertSafeOutDir } from '../util/security.js';
10
+ import { atomicWrite } from '../util/atomicWrite.js';
9
11
  const WATCH_SCRIPT_NAME = 'magicpixel:watch';
10
12
  const WATCH_SCRIPT_CMD = 'magicpixel sync --watch';
11
13
  export async function initCommand(opts) {
@@ -30,10 +32,24 @@ export async function initCommand(opts) {
30
32
  if (framework)
31
33
  console.log(kleur.dim(` Detected: ${framework}`));
32
34
  console.log();
33
- config.outDir = (await rl.question(`${kleur.cyan('?')} Where should assets be written? ${kleur.dim(`(${suggestedOutDir})`)} `)).trim() || suggestedOutDir;
35
+ // Re-prompt loop: catch unsafe outDir values (null bytes, `..` segments)
36
+ // at input time rather than letting `loadConfig` reject them on the next
37
+ // `sync`/`status` run. UX is faster and the error sits next to the cause.
38
+ // Delegates to `assertSafeOutDir` so the predicate matches `loadConfig` exactly.
39
+ for (;;) {
40
+ const ans = (await rl.question(`${kleur.cyan('?')} Where should assets be written? ${kleur.dim(`(${suggestedOutDir})`)} `)).trim();
41
+ const candidate = ans || suggestedOutDir;
42
+ try {
43
+ config.outDir = assertSafeOutDir(candidate);
44
+ break;
45
+ }
46
+ catch (e) {
47
+ console.log(kleur.yellow(` ${e.message} Try again.`));
48
+ }
49
+ }
34
50
  // Typed index only makes sense when the outDir is importable by a bundler.
35
51
  // public/ and static/ are served as-is and cannot be imported.
36
- if (isImportableOutDir(config.outDir)) {
52
+ if (!isStaticOutDir(config.outDir)) {
37
53
  const emitAns = (await rl.question(`${kleur.cyan('?')} Emit a typed index.ts for autocomplete? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
38
54
  config.emitIndex = emitAns !== 'n' && emitAns !== 'no';
39
55
  }
@@ -53,7 +69,7 @@ export async function initCommand(opts) {
53
69
  }
54
70
  }
55
71
  else {
56
- config.emitIndex = isImportableOutDir(config.outDir);
72
+ config.emitIndex = !isStaticOutDir(config.outDir);
57
73
  }
58
74
  await saveConfig(config);
59
75
  console.log();
@@ -111,22 +127,16 @@ async function ensureWatchScript(pkgPath) {
111
127
  scripts[WATCH_SCRIPT_NAME] = WATCH_SCRIPT_CMD;
112
128
  pkg.scripts = scripts;
113
129
  const trailingNewline = raw.endsWith('\n') ? '\n' : '';
114
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + trailingNewline, 'utf8');
130
+ // Atomic write — a crash mid-write must never corrupt the user's package.json.
131
+ await atomicWrite(pkgPath, JSON.stringify(pkg, null, 2) + trailingNewline);
115
132
  return { added: true, alreadyPresent: false };
116
133
  }
117
134
  catch (e) {
118
135
  return { added: false, alreadyPresent: false, error: e.message };
119
136
  }
120
137
  }
121
- /**
122
- * `public/` and `static/` are served as-is by frameworks and can't be
123
- * `import`ed by a bundler — emitting a typed index there is dead code that
124
- * also gets shipped as a static asset.
125
- */
126
- function isImportableOutDir(outDir) {
127
- const norm = outDir.replace(/\\/g, '/').replace(/^\.\//, '');
128
- return !/^(public|static)(\/|$)/.test(norm);
129
- }
138
+ // `isImportableOutDir` lives in util/framework.ts as `isStaticOutDir`
139
+ // (negated) — shared with `emitIndex.ts`'s AGENTS.md snippet resolver.
130
140
  async function ensureGitignore() {
131
141
  const path = resolve(process.cwd(), '.gitignore');
132
142
  const marker = '.magicpixel/';