@magicpixelart/cli 0.3.0 → 0.4.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 +415 -0
- package/README.md +31 -2
- package/dist/api.js +173 -54
- 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 +297 -135
- 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/commands/whoami.js
CHANGED
|
@@ -1,30 +1,70 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
|
-
import { loadConfig, resolveEndpoint, getApiKey } from '../config.js';
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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 (
|
|
15
|
-
|
|
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 (
|
|
22
|
-
|
|
23
|
-
console.log(kleur.
|
|
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 =
|
|
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,
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
.
|
|
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)',
|
|
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)',
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
.
|
|
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
|
+
}
|
package/dist/util/credentials.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { mkdir, readFile,
|
|
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
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
+
}
|