@magicpixelart/cli 0.3.3 → 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.
@@ -1,6 +1,7 @@
1
1
  import kleur from 'kleur';
2
2
  import { loadConfig } from '../config.js';
3
3
  import { fetchAllManifest } from '../api.js';
4
+ import { formatBytes } from '../util/format.js';
4
5
  export async function listCommand() {
5
6
  const config = await loadConfig();
6
7
  const entries = await fetchAllManifest(config);
@@ -9,15 +10,15 @@ export async function listCommand() {
9
10
  return;
10
11
  }
11
12
  const widthKey = Math.max(3, ...entries.map((e) => e.key.length));
12
- const widthSize = 8;
13
+ const sizes = entries.map((e) => (e.size_bytes != null ? formatBytes(e.size_bytes) : '-'));
14
+ const widthSize = Math.max(4, ...sizes.map((s) => s.length));
13
15
  console.log(kleur.bold('KEY'.padEnd(widthKey)) + ' ' +
14
16
  kleur.bold('SIZE'.padEnd(widthSize)) + ' ' +
15
17
  kleur.bold('SHA256'));
16
- for (const e of entries) {
17
- const size = e.size_bytes != null ? `${e.size_bytes}B` : '-';
18
+ entries.forEach((e, i) => {
18
19
  const sha = e.sha256 ? e.sha256.slice(0, 12) : '-';
19
- console.log(`${e.key.padEnd(widthKey)} ${size.padEnd(widthSize)} ${sha}`);
20
- }
20
+ console.log(`${e.key.padEnd(widthKey)} ${sizes[i].padEnd(widthSize)} ${sha}`);
21
+ });
21
22
  console.log();
22
23
  console.log(kleur.dim(`${entries.length} asset${entries.length === 1 ? '' : 's'}`));
23
24
  }
@@ -1,11 +1,9 @@
1
1
  import kleur from 'kleur';
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { resolveEndpoint, loadConfig, defaultConfig } from '../config.js';
5
- import { writeCredentials, credentialsPath } from '../util/credentials.js';
6
- import { safeFetch } from '../util/security.js';
7
- import { CLI_USER_AGENT } from '../version.js';
8
- import { existsSync } from 'node:fs';
4
+ import { loadConfig, defaultConfig } from '../config.js';
5
+ import { writeCredentials } from '../util/credentials.js';
6
+ import { assertKeyValid } from '../util/auth.js';
9
7
  import { relative } from 'node:path';
10
8
  const KEY_RE = /^mp_(live|test)_[a-f0-9]{64}$/;
11
9
  export async function loginCommand(opts = {}) {
@@ -67,32 +65,3 @@ async function promptForKey(maxAttempts, config) {
67
65
  rl.close();
68
66
  }
69
67
  }
70
- async function assertKeyValid(key, config) {
71
- const url = new URL(`${resolveEndpoint(config)}/manifest`);
72
- url.searchParams.set('limit', '1');
73
- let res;
74
- try {
75
- res = await safeFetch(url.href, {
76
- headers: { Authorization: `Bearer ${key}`, 'User-Agent': CLI_USER_AGENT },
77
- });
78
- }
79
- catch (e) {
80
- throw new Error(`Could not reach MagicPixel (${e.message}). Try again in a moment.`);
81
- }
82
- if (res.status === 401 || res.status === 403) {
83
- await res.body?.cancel();
84
- throw new Error('Key was rejected (401). Generate a fresh one at https://magicpixel.art/settings.');
85
- }
86
- if (!res.ok) {
87
- const body = await res.text();
88
- throw new Error(`Server returned ${res.status}: ${body.slice(0, 120)}`);
89
- }
90
- await res.body?.cancel();
91
- }
92
- // Kept here so `start` can reuse the same exact prompt-and-validate flow.
93
- export async function promptAndStoreKey(config) {
94
- if (existsSync(credentialsPath()) && !stdin.isTTY)
95
- return;
96
- const key = await promptForKey(3, config);
97
- await writeCredentials(key);
98
- }
@@ -0,0 +1,157 @@
1
+ import kleur from 'kleur';
2
+ import { existsSync } from 'node:fs';
3
+ import { rename } from 'node:fs/promises';
4
+ import { resolve, relative } from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { stdin, stdout } from 'node:process';
7
+ import { loadConfig, statePath, getApiKey } from '../config.js';
8
+ import { assertKeyValid } from '../util/auth.js';
9
+ import { listEmptyDirs, pruneEmptyDirs } from '../util/paths.js';
10
+ import { syncCommand } from './sync.js';
11
+ /**
12
+ * One-shot self-healing command. Runs the standard "turn it off and on
13
+ * again" recovery sequence so support can say "run `npx magicpixel repair`":
14
+ *
15
+ * 1. Validate the stored API key.
16
+ * 2. Quarantine `state.json` so the next sync re-derives everything.
17
+ * 3. Prune empty subdirectories under `outDir`.
18
+ * 4. Run a full sync (`--full --prune`).
19
+ *
20
+ * Idempotent. `--dry-run` reports the plan without writing. `--yes` skips the
21
+ * confirmation prompt for step 2.
22
+ */
23
+ export async function repairCommand(opts = {}) {
24
+ console.log(kleur.bold('MagicPixel repair'));
25
+ console.log(kleur.dim(' Validates your setup, clears local sync state, and re-fetches everything.'));
26
+ console.log();
27
+ const config = await loadConfig();
28
+ const sPath = statePath();
29
+ // --- Step 1: validate API key --------------------------------------------
30
+ console.log(kleur.bold('1/4 ') + 'Validating API key…');
31
+ try {
32
+ const key = getApiKey();
33
+ await assertKeyValid(key, config);
34
+ console.log(` ${kleur.green('✓')} key accepted`);
35
+ }
36
+ catch (e) {
37
+ // Preserve multi-line "Fix:" guidance from getApiKey()/assertKeyValid —
38
+ // truncating to the first line strips the exact instructions a
39
+ // first-time `repair` user needs.
40
+ const msg = e.message ?? String(e);
41
+ const [head, ...rest] = msg.split('\n');
42
+ console.log(` ${kleur.red('✗')} ${head}`);
43
+ for (const line of rest)
44
+ console.log(` ${line}`);
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ // --- Step 2: quarantine state.json ---------------------------------------
49
+ console.log(kleur.bold('2/4 ') + 'Resetting local sync state…');
50
+ const stateExists = existsSync(sPath);
51
+ // In dry-run we predict whether a real run *would* skip step 2: that
52
+ // happens only when there's no --yes AND no TTY for the prompt to come
53
+ // from (CI / piped input). Otherwise the prompt would run interactively
54
+ // and we can't know the answer from here — assume "would proceed" for
55
+ // the dry-run summary, matching the optimistic "plan looks good" line.
56
+ let skippedStep2 = false;
57
+ if (!stateExists) {
58
+ console.log(` ${kleur.dim('— no state.json on disk; nothing to reset')}`);
59
+ }
60
+ else if (opts.dryRun) {
61
+ const dest = `${sPath}.repair-<ts>`;
62
+ console.log(` ${kleur.dim(`would move: ${relative(process.cwd(), sPath)}`)}`);
63
+ console.log(` ${kleur.dim(` → ${relative(process.cwd(), dest)}`)}`);
64
+ if (!opts.yes && !stdin.isTTY) {
65
+ // Predict the skip a real run would hit, so step 4's note and the
66
+ // closing summary stay honest.
67
+ skippedStep2 = true;
68
+ console.log(` ${kleur.yellow('note')} ${kleur.dim('non-TTY without --yes — a real run would skip this step. Re-run with --yes.')}`);
69
+ }
70
+ }
71
+ else {
72
+ if (!opts.yes && !(await confirm(`Move ${relative(process.cwd(), sPath)} aside? (y/N) `))) {
73
+ console.log(` ${kleur.yellow('skipped')} (re-run with --yes to skip the prompt)`);
74
+ skippedStep2 = true;
75
+ }
76
+ else {
77
+ const dest = `${sPath}.repair-${Date.now()}`;
78
+ try {
79
+ await rename(sPath, dest);
80
+ console.log(` ${kleur.green('✓')} moved to ${relative(process.cwd(), dest)}`);
81
+ }
82
+ catch (e) {
83
+ console.log(` ${kleur.red('✗')} ${e.message}`);
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+ }
88
+ }
89
+ // --- Step 3: prune empty dirs --------------------------------------------
90
+ console.log(kleur.bold('3/4 ') + 'Pruning empty directories under outDir…');
91
+ const outRoot = resolve(process.cwd(), config.outDir);
92
+ if (!existsSync(outRoot)) {
93
+ console.log(` ${kleur.dim(`— outDir doesn't exist yet (${config.outDir})`)}`);
94
+ }
95
+ else if (opts.dryRun) {
96
+ const empties = await listEmptyDirs(outRoot);
97
+ if (empties.length === 0) {
98
+ console.log(` ${kleur.dim('— no empty subdirs under outDir')}`);
99
+ }
100
+ else {
101
+ console.log(` ${kleur.dim(`would remove ${empties.length} empty subdir${empties.length === 1 ? '' : 's'}:`)}`);
102
+ for (const p of empties.slice(0, 10)) {
103
+ console.log(` ${kleur.dim(` - ${relative(process.cwd(), p)}`)}`);
104
+ }
105
+ if (empties.length > 10) {
106
+ console.log(` ${kleur.dim(` …and ${empties.length - 10} more`)}`);
107
+ }
108
+ }
109
+ }
110
+ else {
111
+ await pruneEmptyDirs(outRoot);
112
+ console.log(` ${kleur.green('✓')} done`);
113
+ }
114
+ // --- Step 4: full sync ---------------------------------------------------
115
+ console.log(kleur.bold('4/4 ') + 'Running full sync…');
116
+ if (skippedStep2) {
117
+ console.log(kleur.dim(' (state was not reset — this will run a normal full sync, not a recovery)'));
118
+ }
119
+ if (opts.dryRun) {
120
+ console.log(` ${kleur.dim('would run `magicpixel sync --full` (skipped in --dry-run)')}`);
121
+ console.log();
122
+ if (skippedStep2) {
123
+ console.log(kleur.yellow('! repair plan would skip the state reset. Re-run with --yes to apply it.'));
124
+ }
125
+ else {
126
+ console.log(kleur.green('✓ repair plan looks good. Re-run without --dry-run to apply.'));
127
+ }
128
+ return;
129
+ }
130
+ console.log();
131
+ // syncCommand sets `process.exitCode = 1` on download failures but does not
132
+ // throw. Snapshot the value before/after so we can honour it in the final
133
+ // message — otherwise we'd print a misleading green "complete" line over a
134
+ // run that exited non-zero. Default to 0 so an already-failed prior step
135
+ // doesn't mask a fresh failure (`1 === 1` would look like success).
136
+ const exitBefore = process.exitCode ?? 0;
137
+ await syncCommand({ full: true, prune: true });
138
+ console.log();
139
+ if ((process.exitCode ?? 0) > exitBefore) {
140
+ console.log(kleur.yellow('! repair completed with errors — re-run `magicpixel sync` to retry the failed downloads.'));
141
+ }
142
+ else {
143
+ console.log(kleur.green('✓ repair complete.'));
144
+ }
145
+ }
146
+ async function confirm(prompt) {
147
+ if (!stdin.isTTY)
148
+ return false; // CI/non-interactive: default to NO; require --yes.
149
+ const rl = createInterface({ input: stdin, output: stdout });
150
+ try {
151
+ const answer = (await rl.question(` ${prompt}`)).trim().toLowerCase();
152
+ return answer === 'y' || answer === 'yes';
153
+ }
154
+ finally {
155
+ rl.close();
156
+ }
157
+ }
@@ -1,9 +1,10 @@
1
1
  import kleur from 'kleur';
2
2
  import { existsSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
3
4
  import { createInterface } from 'node:readline/promises';
4
5
  import { stdin, stdout } from 'node:process';
5
6
  import { resolve } from 'node:path';
6
- import { configPath } from '../config.js';
7
+ import { configPath, loadConfig } from '../config.js';
7
8
  import { hasPackageJson } from '../util/framework.js';
8
9
  import { findKeyInDotenv, readCredentialsSync, writeCredentials } from '../util/credentials.js';
9
10
  import { initCommand } from './init.js';
@@ -25,10 +26,23 @@ export async function startCommand(opts = {}) {
25
26
  ' Run this inside your project folder — the one with package.json.'));
26
27
  return;
27
28
  }
28
- // 2. Run init non-interactively unless config already exists.
29
+ // 2. Run init non-interactively unless config already exists AND is valid.
30
+ // A broken `magicpixel.json` (hand-edited / truncated) used to slip past
31
+ // the existsSync check here and then explode deep inside `syncCommand`.
29
32
  const cfgPath = configPath();
30
33
  if (existsSync(cfgPath) && !opts.force) {
31
- console.log(kleur.dim(` Found existing magicpixel.json — skipping init.`));
34
+ try {
35
+ await loadConfig();
36
+ console.log(kleur.dim(` Found existing magicpixel.json — skipping init.`));
37
+ }
38
+ catch (e) {
39
+ const msg = e.message ?? String(e);
40
+ console.log(kleur.yellow(` Existing magicpixel.json is invalid:`));
41
+ for (const line of msg.split('\n'))
42
+ console.log(kleur.yellow(` ${line}`));
43
+ console.log(kleur.dim(' Re-run `npx magicpixel start --force` to overwrite it, or fix the file by hand.'));
44
+ return;
45
+ }
32
46
  }
33
47
  else {
34
48
  await initCommand({ yes: true, force: opts.force });
@@ -49,35 +63,63 @@ export async function startCommand(opts = {}) {
49
63
  else {
50
64
  console.log(kleur.dim(' Using stored credentials from .magicpixel/credentials.'));
51
65
  }
52
- // 5. First sync.
66
+ // 5. First sync. syncCommand sets `process.exitCode = 1` on download
67
+ // failures without throwing — snapshot around the call so we don't
68
+ // print a misleading green "you're set up" over a half-failed run.
53
69
  console.log();
54
70
  console.log(kleur.bold('Step: first sync'));
71
+ const exitBefore = process.exitCode ?? 0;
55
72
  await syncCommand({ full: true });
56
- // 6. Tell the user how to run the watch loop.
73
+ const firstSyncFailed = (process.exitCode ?? 0) > exitBefore;
74
+ // 6. Tell the user how to run the watch loop. If `init` couldn't patch
75
+ // package.json (non-standard layout, write-protected, etc.) the
76
+ // `magicpixel:watch` npm script won't exist — fall back to the
77
+ // `npx` form so we never instruct users to run a script they don't have.
57
78
  console.log();
58
- console.log(kleur.bold('You\'re set up. ✨'));
79
+ if (firstSyncFailed) {
80
+ console.log(kleur.yellow('! first sync completed with errors.'));
81
+ console.log(kleur.dim(' Re-run `npx magicpixel sync` to retry the failed downloads, or `npx magicpixel doctor` to diagnose.'));
82
+ }
83
+ else {
84
+ console.log(kleur.bold('You\'re set up. ✨'));
85
+ }
59
86
  console.log();
60
- console.log(` ${kleur.green('▶')} ${kleur.bold('npm run magicpixel:watch')} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
87
+ const hasWatch = await hasWatchScript();
88
+ if (hasWatch) {
89
+ console.log(` ${kleur.green('▶')} ${kleur.bold('npm run magicpixel:watch')} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
90
+ }
91
+ else {
92
+ console.log(` ${kleur.green('▶')} ${kleur.bold('npx magicpixel sync --watch')} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
93
+ }
61
94
  console.log();
62
95
  if (await hasDevScript()) {
96
+ const watchCmd = hasWatch ? 'npm run magicpixel:watch' : 'npx magicpixel sync --watch';
63
97
  console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));
64
- console.log(kleur.dim(' `npx concurrently "npm run dev" "npm run magicpixel:watch"`'));
98
+ console.log(kleur.dim(` \`npx concurrently "npm run dev" "${watchCmd}"\``));
65
99
  console.log();
66
100
  }
67
101
  }
68
- async function hasDevScript() {
102
+ async function readPkgJson() {
69
103
  try {
70
104
  const path = resolve(process.cwd(), 'package.json');
71
105
  if (!existsSync(path))
72
- return false;
73
- const { readFile } = await import('node:fs/promises');
74
- const pkg = JSON.parse(await readFile(path, 'utf8'));
75
- return typeof pkg.scripts?.dev === 'string';
106
+ return null;
107
+ return JSON.parse(await readFile(path, 'utf8'));
76
108
  }
77
109
  catch {
78
- return false;
110
+ return null;
79
111
  }
80
112
  }
113
+ async function hasWatchScript() {
114
+ const pkg = await readPkgJson();
115
+ const scripts = pkg?.scripts;
116
+ return !!scripts && typeof scripts === 'object' && typeof scripts['magicpixel:watch'] === 'string';
117
+ }
118
+ async function hasDevScript() {
119
+ const pkg = await readPkgJson();
120
+ const scripts = pkg?.scripts;
121
+ return !!scripts && typeof scripts === 'object' && typeof scripts.dev === 'string';
122
+ }
81
123
  async function maybeMigrateDotenvKey() {
82
124
  if (process.env.MAGICPIXEL_API_KEY)
83
125
  return;
@@ -3,33 +3,61 @@ import { loadConfig, loadState, resolveEndpoint } from '../config.js';
3
3
  import { fetchAllManifest } from '../api.js';
4
4
  import { fileSha256 } from '../util/hash.js';
5
5
  import { assetDiskPath } from '../util/paths.js';
6
+ import { readKeyForDisplay } from '../util/credentials.js';
7
+ import { createLimit } from '../util/limit.js';
6
8
  export async function statusCommand() {
7
- const config = await loadConfig();
8
- const state = await loadState();
9
+ // Config is optional — a brand-new user can `magicpixel status` to inspect
10
+ // their key/env before bothering with init. Mirrors `whoami` / `login`.
11
+ let config = null;
12
+ let configErr = null;
13
+ try {
14
+ config = await loadConfig();
15
+ }
16
+ catch (e) {
17
+ configErr = e.message.split('\n')[0];
18
+ }
9
19
  console.log(kleur.bold('Config'));
10
- console.log(` outDir: ${config.outDir}`);
11
- console.log(` include: ${config.include.join(', ') || '(none)'}`);
12
- console.log(` exclude: ${config.exclude.join(', ') || '(none)'}`);
13
- console.log(` endpoint: ${resolveEndpoint(config)}`);
20
+ if (config) {
21
+ console.log(` outDir: ${config.outDir}`);
22
+ console.log(` include: ${config.include.join(', ') || '(none)'}`);
23
+ console.log(` exclude: ${config.exclude.join(', ') || '(none)'}`);
24
+ console.log(` endpoint: ${resolveEndpoint(config)}`);
25
+ }
26
+ else {
27
+ console.log(kleur.yellow(` magicpixel.json: ${configErr ?? 'missing'}`));
28
+ console.log(kleur.dim(` Run \`npx magicpixel init\` to create one.`));
29
+ }
14
30
  console.log();
31
+ const state = await loadState();
15
32
  console.log(kleur.bold('State'));
16
33
  console.log(` lastSync: ${state.lastSync ?? kleur.dim('never')}`);
34
+ if (state.lastError)
35
+ console.log(` lastError: ${kleur.yellow(state.lastError)}`);
17
36
  console.log();
18
37
  console.log(kleur.bold('Auth'));
19
- const key = process.env.MAGICPIXEL_API_KEY;
20
- if (!key) {
21
- console.log(kleur.red(' MAGICPIXEL_API_KEY not set'));
38
+ // Honors the same precedence as `getApiKey()` (env wins, then file written
39
+ // by `magicpixel login`). The pre-Batch-B implementation only looked at the
40
+ // env var and falsely reported "key not set" for logged-in users.
41
+ const stored = readKeyForDisplay();
42
+ if (!stored) {
43
+ console.log(kleur.red(' no API key — run `magicpixel login` or set MAGICPIXEL_API_KEY'));
22
44
  return;
23
45
  }
24
- console.log(` key: ${kleur.green('set')} (${maskKey(key)})`);
46
+ const sourceLabel = stored.source === 'env' ? 'env (MAGICPIXEL_API_KEY)' : 'file (.magicpixel/credentials)';
47
+ console.log(` key: ${kleur.green('set')} (${maskKey(stored.value)}) ${kleur.dim(`— ${sourceLabel}`)}`);
25
48
  console.log();
49
+ if (!config)
50
+ return; // No config, no diff to compute.
26
51
  console.log(kleur.bold('Diff vs remote'));
27
52
  try {
28
53
  const manifest = await fetchAllManifest(config);
54
+ // Parallelize the SHA pre-check through the same concurrency pool sync
55
+ // uses — on a 1k-asset project a serial loop dominates wall-clock time.
56
+ const limit = createLimit(6);
29
57
  let changed = 0;
30
58
  let unchanged = 0;
31
59
  let missing = 0;
32
- for (const entry of manifest) {
60
+ await Promise.all(manifest.map((entry) => limit(async () => {
33
61
  const local = await fileSha256(assetDiskPath(config.outDir, entry));
34
62
  if (!local)
35
63
  missing++;
@@ -37,14 +65,14 @@ export async function statusCommand() {
37
65
  unchanged++;
38
66
  else
39
67
  changed++;
40
- }
68
+ })));
41
69
  console.log(` remote assets: ${manifest.length}`);
42
70
  console.log(` ${kleur.dim('=')} unchanged: ${unchanged}`);
43
71
  console.log(` ${kleur.yellow('~')} changed: ${changed}`);
44
72
  console.log(` ${kleur.green('+')} missing: ${missing}`);
45
73
  }
46
74
  catch (e) {
47
- console.log(kleur.red(` manifest fetch failed: ${e.message}`));
75
+ console.log(kleur.red(` manifest fetch failed: ${e.message.split('\n')[0]}`));
48
76
  }
49
77
  }
50
78
  function maskKey(k) {