@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/CHANGELOG.md +431 -0
- package/README.md +31 -2
- package/dist/api.js +135 -72
- package/dist/commands/doctor.js +201 -56
- package/dist/commands/init.js +25 -15
- package/dist/commands/list.js +6 -5
- package/dist/commands/login.js +3 -34
- package/dist/commands/repair.js +157 -0
- package/dist/commands/start.js +56 -14
- package/dist/commands/status.js +41 -13
- package/dist/commands/sync.js +396 -136
- package/dist/commands/whoami.js +52 -12
- package/dist/config.js +88 -20
- package/dist/index.js +38 -23
- package/dist/util/atomicWrite.js +25 -0
- package/dist/util/auth.js +41 -0
- package/dist/util/authHeaders.js +24 -0
- package/dist/util/credentials.js +27 -10
- package/dist/util/emitIndex.js +78 -70
- package/dist/util/errors.js +49 -0
- package/dist/util/flagValidators.js +49 -0
- package/dist/util/format.js +14 -0
- package/dist/util/framework.js +10 -0
- package/dist/util/iso.js +28 -0
- package/dist/util/paths.js +99 -0
- package/dist/util/security.js +39 -2
- package/dist/util/telemetry.js +182 -0
- package/package.json +11 -4
package/dist/api.js
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
import { resolveEndpoint, getApiKey,
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
if (!
|
|
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
|
-
*
|
|
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
|
|
116
|
-
|
|
117
|
-
headers
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
/**
|
|
160
|
-
|
|
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,
|
|
232
|
+
return Math.min(Math.max(0, asDate - Date.now()), 60_000);
|
|
170
233
|
}
|
|
171
234
|
return 0;
|
|
172
235
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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(`
|
|
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(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
236
|
+
return lines.join('\n');
|
|
92
237
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { readFile,
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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/';
|