@meshxdata/fops 0.0.6 → 0.0.8

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/src/doctor.js CHANGED
@@ -6,8 +6,9 @@ import path from "node:path";
6
6
  import chalk from "chalk";
7
7
  import { execa } from "execa";
8
8
  import { rootDir } from "./project.js";
9
- import inquirer from "inquirer";
10
9
  import { detectEcrRegistry, detectAwsSsoProfiles, fixAwsSso, fixEcr } from "./setup/aws.js";
10
+ import { wslExec, wslHomedir, wslFileExists, wslReadFile, wslCmdVersion } from "./wsl.js";
11
+ import { getInquirer } from "./lazy.js";
11
12
 
12
13
  const KEY_PORTS = {
13
14
  5432: "Postgres",
@@ -62,6 +63,41 @@ async function cmdVersion(cmd, args = ["--version"]) {
62
63
  }
63
64
  }
64
65
 
66
+ /**
67
+ * Find the Claude CLI binary — checks system PATH first, then node_modules/.bin
68
+ * Returns the path to the binary or null if not found.
69
+ */
70
+ async function findClaudeBinary() {
71
+ // Check system PATH via execa
72
+ try {
73
+ const cmd = process.platform === "win32" ? "where" : "which";
74
+ const { stdout, exitCode } = await execa(cmd, ["claude"], { reject: false, timeout: 3000 });
75
+ if (exitCode === 0 && stdout?.trim()) return stdout.trim().split("\n")[0];
76
+ } catch {}
77
+
78
+ // Check node_modules/.bin relative to this CLI's installation
79
+ const thisDir = path.dirname(new URL(import.meta.url).pathname);
80
+ const candidates = [
81
+ path.join(thisDir, "..", "node_modules", ".bin", "claude"),
82
+ path.join(thisDir, "..", "..", ".bin", "claude"), // when installed as dependency
83
+ ];
84
+ for (const candidate of candidates) {
85
+ if (fs.existsSync(candidate)) return candidate;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ async function getClaudeVersion() {
91
+ const claudePath = await findClaudeBinary();
92
+ if (!claudePath) return null;
93
+ try {
94
+ const { stdout } = await execa(claudePath, ["--version"], { reject: false, timeout: 5000 });
95
+ return stdout?.split("\n")[0]?.trim() || null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
65
101
  /**
66
102
  * Parse ~/.netrc and return the password/token for a given machine, or null.
67
103
  */
@@ -160,6 +196,63 @@ export async function runDoctor(opts = {}, registry = null) {
160
196
  }
161
197
  }
162
198
 
199
+ // WSL (Windows only — Docker Desktop uses WSL 2 backend)
200
+ let useWsl = false;
201
+ let wslHome = null;
202
+ if (process.platform === "win32") {
203
+ try {
204
+ const { stdout: wslList, exitCode: wslExit } = await execa("wsl", ["-l", "-v"], {
205
+ timeout: 10000, reject: false,
206
+ });
207
+ if (wslExit === 0 && wslList?.trim()) {
208
+ // Parse distro list — look for at least one WSL 2 distro
209
+ const hasWsl2 = /\s+2\s*$/m.test(wslList);
210
+ if (hasWsl2) {
211
+ ok("WSL 2 enabled", wslList.split("\n").find((l) => /\s+2\s*$/.test(l))?.trim().replace(/\s+/g, " ") || "active");
212
+ useWsl = true;
213
+ try { wslHome = await wslHomedir(); } catch {}
214
+ } else {
215
+ warn("WSL installed but no WSL 2 distro found", "Docker Desktop requires WSL 2", async () => {
216
+ console.log(chalk.cyan(" ▶ wsl --install"));
217
+ await execa("wsl", ["--install"], { stdio: "inherit", timeout: 300_000 });
218
+ console.log(chalk.yellow(" ⚠ A reboot may be required to complete WSL installation."));
219
+ });
220
+ }
221
+ } else {
222
+ fail("WSL not installed", "required for Docker Desktop", async () => {
223
+ console.log(chalk.cyan(" ▶ wsl --install"));
224
+ await execa("wsl", ["--install"], { stdio: "inherit", timeout: 300_000 });
225
+ console.log(chalk.yellow(" ⚠ A reboot may be required to complete WSL installation."));
226
+ });
227
+ }
228
+ } catch {
229
+ fail("WSL not available", "required for Docker Desktop", async () => {
230
+ console.log(chalk.cyan(" ▶ wsl --install"));
231
+ await execa("wsl", ["--install"], { stdio: "inherit", timeout: 300_000 });
232
+ console.log(chalk.yellow(" ⚠ A reboot may be required to complete WSL installation."));
233
+ });
234
+ }
235
+ }
236
+
237
+ // WSL-aware wrappers — when useWsl is true, delegate tool/config checks into WSL
238
+ const run = useWsl
239
+ ? (cmd, args, opts) => wslExec(cmd, args, opts)
240
+ : (cmd, args, opts) => execa(cmd, args, opts);
241
+
242
+ const ver = useWsl
243
+ ? (cmd, args) => wslCmdVersion(cmd, args)
244
+ : (cmd, args) => cmdVersion(cmd, args);
245
+
246
+ const home = useWsl ? wslHome : os.homedir();
247
+
248
+ const fileExists = useWsl
249
+ ? (p) => wslFileExists(p)
250
+ : (p) => fs.existsSync(p);
251
+
252
+ const readFile = useWsl
253
+ ? (p) => wslReadFile(p)
254
+ : (p) => fs.readFileSync(p, "utf8");
255
+
163
256
  // Docker
164
257
  const dockerVer = await cmdVersion("docker");
165
258
  if (dockerVer) {
@@ -216,6 +309,21 @@ export async function runDoctor(opts = {}, registry = null) {
216
309
  }
217
310
  hasBrew = true;
218
311
  }
312
+ // Clean up stale Docker cask artifacts that block reinstall
313
+ try {
314
+ await execa("brew", ["uninstall", "--cask", "--force", "docker-desktop"], { stdio: "ignore", timeout: 30_000 });
315
+ console.log(chalk.dim(" Removed leftover docker-desktop cask"));
316
+ } catch {}
317
+ try {
318
+ await execa("brew", ["uninstall", "--cask", "--force", "docker"], { stdio: "ignore", timeout: 30_000 });
319
+ console.log(chalk.dim(" Removed leftover docker cask"));
320
+ } catch {}
321
+ // Remove known conflicting binaries left behind by previous installs
322
+ for (const bin of ["/usr/local/bin/hub-tool"]) {
323
+ if (fs.existsSync(bin)) {
324
+ try { fs.unlinkSync(bin); console.log(chalk.dim(` Removed stale ${bin}`)); } catch {}
325
+ }
326
+ }
219
327
  console.log(chalk.cyan(" ▶ brew install --cask docker"));
220
328
  await execa("brew", ["install", "--cask", "docker"], { stdio: "inherit", timeout: 300_000 });
221
329
  console.log(chalk.cyan(" ▶ open -a Docker"));
@@ -254,7 +362,7 @@ export async function runDoctor(opts = {}, registry = null) {
254
362
  else fail("Docker Compose not found", "included with Docker Desktop — restart Docker or install the compose plugin");
255
363
 
256
364
  // Git
257
- const gitVer = await cmdVersion("git");
365
+ const gitVer = await ver("git");
258
366
  if (gitVer) ok("Git available", gitVer);
259
367
  else fail("Git not found", "install git", async () => {
260
368
  if (process.platform === "darwin") {
@@ -262,9 +370,14 @@ export async function runDoctor(opts = {}, registry = null) {
262
370
  console.log(chalk.cyan(" ▶ brew install git"));
263
371
  await execa("brew", ["install", "git"], { stdio: "inherit", timeout: 300_000 });
264
372
  } else if (process.platform === "win32") {
265
- if (!hasWinget) throw new Error("winget required");
266
- console.log(chalk.cyan(" ▶ winget install Git.Git"));
267
- await execa("winget", ["install", "Git.Git", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
373
+ if (useWsl) {
374
+ console.log(chalk.cyan(" ▶ [WSL] sudo apt-get install -y git"));
375
+ await run("sudo", ["apt-get", "install", "-y", "git"], { stdio: "inherit", timeout: 300_000 });
376
+ } else {
377
+ if (!hasWinget) throw new Error("winget required");
378
+ console.log(chalk.cyan(" ▶ winget install Git.Git"));
379
+ await execa("winget", ["install", "Git.Git", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
380
+ }
268
381
  } else {
269
382
  console.log(chalk.cyan(" ▶ sudo apt-get install -y git"));
270
383
  await execa("sudo", ["apt-get", "install", "-y", "git"], { stdio: "inherit", timeout: 300_000 });
@@ -272,21 +385,65 @@ export async function runDoctor(opts = {}, registry = null) {
272
385
  });
273
386
 
274
387
  // Node.js version
275
- const nodeVer = process.versions.node;
276
- const nodeMajor = parseInt(nodeVer.split(".")[0], 10);
277
- if (nodeMajor >= 18) ok(`Node.js v${nodeVer}`, ">=18 required");
278
- else fail(`Node.js v${nodeVer}`, "upgrade to >=18");
279
-
280
- // Claude CLI (bundled as a dependency)
281
- const claudeVer = await cmdVersion("claude");
282
- if (claudeVer) ok("Claude CLI", claudeVer);
283
- else fail("Claude CLI not found", "included as a dependency", async () => {
284
- console.log(chalk.cyan(" npm install"));
285
- await execa("npm", ["install"], { stdio: "inherit", timeout: 300_000 });
388
+ if (useWsl) {
389
+ const wslNodeVer = await ver("node", ["-v"]);
390
+ if (wslNodeVer) {
391
+ const wslNodeMajor = parseInt(wslNodeVer.replace(/^v/, "").split(".")[0], 10);
392
+ if (wslNodeMajor >= 18) ok(`Node.js ${wslNodeVer} (WSL)`, ">=18 required");
393
+ else fail(`Node.js ${wslNodeVer} (WSL)`, "upgrade to >=18");
394
+ } else {
395
+ fail("Node.js not found in WSL", "install Node.js >=18 inside WSL");
396
+ }
397
+ } else {
398
+ const nodeVer = process.versions.node;
399
+ const nodeMajor = parseInt(nodeVer.split(".")[0], 10);
400
+ if (nodeMajor >= 18) ok(`Node.js v${nodeVer}`, ">=18 required");
401
+ else fail(`Node.js v${nodeVer}`, "upgrade to >=18", async () => {
402
+ if (process.platform === "darwin") {
403
+ if (!(await ensureBrew())) throw new Error("Homebrew required");
404
+ console.log(chalk.cyan(" ▶ brew install node@22"));
405
+ await execa("brew", ["install", "node@22"], { stdio: "inherit", timeout: 300_000 });
406
+ } else if (process.platform === "win32") {
407
+ if (!hasWinget) throw new Error("winget required");
408
+ console.log(chalk.cyan(" ▶ winget install OpenJS.NodeJS.LTS"));
409
+ await execa("winget", ["install", "OpenJS.NodeJS.LTS", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
410
+ } else {
411
+ console.log(chalk.dim(" Install via: https://nodejs.org/ or use nvm"));
412
+ }
413
+ });
414
+ }
415
+
416
+ // npm (should come with Node.js but verify)
417
+ const npmVer = await cmdVersion("npm");
418
+ if (npmVer) ok("npm", npmVer);
419
+ else fail("npm not found", "usually bundled with Node.js", async () => {
420
+ if (process.platform === "darwin") {
421
+ if (!(await ensureBrew())) throw new Error("Homebrew required");
422
+ console.log(chalk.cyan(" ▶ brew install npm"));
423
+ await execa("brew", ["install", "npm"], { stdio: "inherit", timeout: 300_000 });
424
+ } else {
425
+ console.log(chalk.dim(" npm is bundled with Node.js — reinstall Node.js"));
426
+ }
286
427
  });
287
428
 
429
+ // Claude CLI (bundled as a dependency or installed globally)
430
+ const claudeVer = await getClaudeVersion();
431
+ if (claudeVer) {
432
+ ok("Claude CLI", claudeVer);
433
+ } else {
434
+ const claudePath = await findClaudeBinary();
435
+ if (claudePath) {
436
+ warn("Claude CLI found but not responding", claudePath);
437
+ } else {
438
+ fail("Claude CLI not found", "install globally: npm install -g @anthropic-ai/claude-code", async () => {
439
+ console.log(chalk.cyan(" ▶ npm install -g @anthropic-ai/claude-code"));
440
+ await execa("npm", ["install", "-g", "@anthropic-ai/claude-code"], { stdio: "inherit", timeout: 300_000 });
441
+ });
442
+ }
443
+ }
444
+
288
445
  // AWS CLI (optional)
289
- const awsVer = await cmdVersion("aws");
446
+ const awsVer = await ver("aws");
290
447
  if (awsVer) ok("AWS CLI", awsVer);
291
448
  else warn("AWS CLI not found", "needed for ECR login", async () => {
292
449
  if (process.platform === "darwin") {
@@ -294,9 +451,16 @@ export async function runDoctor(opts = {}, registry = null) {
294
451
  console.log(chalk.cyan(" ▶ brew install awscli"));
295
452
  await execa("brew", ["install", "awscli"], { stdio: "inherit", timeout: 300_000 });
296
453
  } else if (process.platform === "win32") {
297
- if (!hasWinget) throw new Error("winget required");
298
- console.log(chalk.cyan(" ▶ winget install Amazon.AWSCLI"));
299
- await execa("winget", ["install", "Amazon.AWSCLI", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
454
+ if (useWsl) {
455
+ console.log(chalk.cyan(" ▶ [WSL] curl + unzip install"));
456
+ await run("sh", ["-c", 'curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip && unzip -qo /tmp/awscliv2.zip -d /tmp && sudo /tmp/aws/install'], {
457
+ stdio: "inherit", timeout: 300_000,
458
+ });
459
+ } else {
460
+ if (!hasWinget) throw new Error("winget required");
461
+ console.log(chalk.cyan(" ▶ winget install Amazon.AWSCLI"));
462
+ await execa("winget", ["install", "Amazon.AWSCLI", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
463
+ }
300
464
  } else {
301
465
  console.log(chalk.cyan(" ▶ curl + unzip install"));
302
466
  await execa("sh", ["-c", 'curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip && unzip -qo /tmp/awscliv2.zip -d /tmp && sudo /tmp/aws/install'], {
@@ -306,7 +470,7 @@ export async function runDoctor(opts = {}, registry = null) {
306
470
  });
307
471
 
308
472
  // 1Password CLI (optional — needed for secret sync)
309
- const opVer = await cmdVersion("op");
473
+ const opVer = await ver("op");
310
474
  if (opVer) ok("1Password CLI (op)", opVer);
311
475
  else warn("1Password CLI (op) not installed", "needed for secret sync", async () => {
312
476
  if (process.platform === "darwin") {
@@ -314,16 +478,20 @@ export async function runDoctor(opts = {}, registry = null) {
314
478
  console.log(chalk.cyan(" ▶ brew install --cask 1password-cli"));
315
479
  await execa("brew", ["install", "--cask", "1password-cli"], { stdio: "inherit", timeout: 300_000 });
316
480
  } else if (process.platform === "win32") {
317
- if (!hasWinget) throw new Error("winget required");
318
- console.log(chalk.cyan(" winget install AgileBits.1Password.CLI"));
319
- await execa("winget", ["install", "AgileBits.1Password.CLI", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
481
+ if (useWsl) {
482
+ console.log(chalk.dim(" Install manually inside WSL: https://developer.1password.com/docs/cli/get-started/#install"));
483
+ } else {
484
+ if (!hasWinget) throw new Error("winget required");
485
+ console.log(chalk.cyan(" ▶ winget install AgileBits.1Password.CLI"));
486
+ await execa("winget", ["install", "AgileBits.1Password.CLI", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
487
+ }
320
488
  } else {
321
489
  console.log(chalk.dim(" Install manually: https://developer.1password.com/docs/cli/get-started/#install"));
322
490
  }
323
491
  });
324
492
 
325
493
  // GitHub CLI
326
- const ghVer = await cmdVersion("gh");
494
+ const ghVer = await ver("gh");
327
495
  if (ghVer) ok("GitHub CLI (gh)", ghVer);
328
496
  else warn("GitHub CLI (gh) not installed", "needed for auth", async () => {
329
497
  if (process.platform === "darwin") {
@@ -331,20 +499,81 @@ export async function runDoctor(opts = {}, registry = null) {
331
499
  console.log(chalk.cyan(" ▶ brew install gh"));
332
500
  await execa("brew", ["install", "gh"], { stdio: "inherit", timeout: 300_000 });
333
501
  } else if (process.platform === "win32") {
334
- if (!hasWinget) throw new Error("winget required");
335
- console.log(chalk.cyan(" ▶ winget install GitHub.cli"));
336
- await execa("winget", ["install", "GitHub.cli", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
502
+ if (useWsl) {
503
+ console.log(chalk.cyan(" ▶ [WSL] Install gh via apt"));
504
+ await run("sh", ["-c", "type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && echo 'deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main' | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null && sudo apt update && sudo apt install gh -y"], {
505
+ stdio: "inherit", timeout: 300_000,
506
+ });
507
+ } else {
508
+ if (!hasWinget) throw new Error("winget required");
509
+ console.log(chalk.cyan(" ▶ winget install GitHub.cli"));
510
+ await execa("winget", ["install", "GitHub.cli", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
511
+ }
337
512
  } else {
338
513
  console.log(chalk.dim(" Install: https://cli.github.com/"));
339
514
  }
340
515
  });
341
516
 
342
517
  // ~/.netrc GitHub credentials (required for private repo access)
343
- const netrcPath = path.join(os.homedir(), ".netrc");
518
+ const netrcPath = useWsl
519
+ ? path.posix.join(home, ".netrc")
520
+ : path.join(os.homedir(), ".netrc");
521
+
522
+ /**
523
+ * Write GitHub credentials to ~/.netrc from gh CLI auth.
524
+ * gh auth login stores tokens in its own keychain but doesn't create netrc.
525
+ */
526
+ const writeNetrcFromGh = async () => {
527
+ const { stdout: ghToken, exitCode: tokenExit } = await run("gh", ["auth", "token"], { timeout: 5000, reject: false });
528
+ if (tokenExit !== 0 || !ghToken?.trim()) {
529
+ throw new Error("gh auth token failed — run 'gh auth login' first");
530
+ }
531
+ const { stdout: ghUser, exitCode: userExit } = await run("gh", ["api", "/user", "--jq", ".login"], { timeout: 10000, reject: false });
532
+ if (userExit !== 0 || !ghUser?.trim()) {
533
+ throw new Error("gh api /user failed — token may lack read:user scope");
534
+ }
535
+ const entry = `machine github.com\nlogin ${ghUser.trim()}\npassword ${ghToken.trim()}\n`;
536
+ if (useWsl) {
537
+ const exists = await fileExists(netrcPath);
538
+ if (exists) {
539
+ const content = await readFile(netrcPath);
540
+ if (!content.includes("github.com")) {
541
+ await wslExec("sh", ["-c", `echo '\n${entry}' >> ${netrcPath}`]);
542
+ } else {
543
+ // Replace existing github.com entry via WSL
544
+ const newContent = content.replace(
545
+ /machine github\.com\s+login\s+\S+\s+password\s+\S+\n?/g,
546
+ entry
547
+ );
548
+ await wslExec("sh", ["-c", `cat > ${netrcPath} << 'NETRCEOF'\n${newContent}\nNETRCEOF`]);
549
+ await wslExec("chmod", ["600", netrcPath]);
550
+ }
551
+ } else {
552
+ await wslExec("sh", ["-c", `echo '${entry}' > ${netrcPath} && chmod 600 ${netrcPath}`]);
553
+ }
554
+ } else {
555
+ if (fs.existsSync(netrcPath)) {
556
+ const content = fs.readFileSync(netrcPath, "utf8");
557
+ if (!content.includes("github.com")) {
558
+ fs.appendFileSync(netrcPath, "\n" + entry);
559
+ } else {
560
+ // Replace existing github.com entry
561
+ const newContent = content.replace(
562
+ /machine github\.com\s+login\s+\S+\s+password\s+\S+\n?/g,
563
+ entry
564
+ );
565
+ fs.writeFileSync(netrcPath, newContent, { mode: 0o600 });
566
+ }
567
+ } else {
568
+ fs.writeFileSync(netrcPath, entry, { mode: 0o600 });
569
+ }
570
+ }
571
+ console.log(chalk.green(" ✓ ~/.netrc updated with GitHub credentials"));
572
+ };
344
573
  const netrcFixFn = async () => {
345
574
  // Install gh if missing
346
575
  let hasGh = false;
347
- try { await execa("gh", ["--version"]); hasGh = true; } catch {}
576
+ try { await run("gh", ["--version"]); hasGh = true; } catch {}
348
577
  if (!hasGh) {
349
578
  if (process.platform === "darwin") {
350
579
  if (!(await ensureBrew())) throw new Error("Homebrew required to install gh");
@@ -352,39 +581,45 @@ export async function runDoctor(opts = {}, registry = null) {
352
581
  await execa("brew", ["install", "gh"], { stdio: "inherit", timeout: 300_000 });
353
582
  hasGh = true;
354
583
  } else if (process.platform === "win32") {
355
- console.log(chalk.cyan(" ▶ winget install GitHub.cli"));
356
- await execa("winget", ["install", "GitHub.cli", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
357
- hasGh = true;
584
+ if (useWsl) {
585
+ console.log(chalk.cyan(" [WSL] Install gh via apt"));
586
+ await run("sh", ["-c", "type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && echo 'deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main' | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null && sudo apt update && sudo apt install gh -y"], {
587
+ stdio: "inherit", timeout: 300_000,
588
+ });
589
+ hasGh = true;
590
+ } else {
591
+ console.log(chalk.cyan(" ▶ winget install GitHub.cli"));
592
+ await execa("winget", ["install", "GitHub.cli", "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
593
+ hasGh = true;
594
+ }
358
595
  }
359
596
  }
597
+
598
+ // Check if already authenticated
599
+ const { exitCode: authStatus } = await execa("gh", ["auth", "status"], { timeout: 5000, reject: false });
600
+ if (authStatus === 0) {
601
+ // Already authenticated — just sync to netrc
602
+ console.log(chalk.dim(" gh already authenticated, syncing to ~/.netrc…"));
603
+ await writeNetrcFromGh();
604
+ return;
605
+ }
606
+
360
607
  // Authenticate via gh
361
608
  console.log(chalk.cyan("\n ▶ gh auth login -p https -h github.com -w"));
362
- await execa("gh", ["auth", "login", "-p", "https", "-h", "github.com", "-w"], {
609
+ await run("gh", ["auth", "login", "-p", "https", "-h", "github.com", "-w"], {
363
610
  stdio: "inherit", timeout: 120_000,
364
611
  });
365
612
  console.log(chalk.cyan(" ▶ gh auth setup-git"));
366
- await execa("gh", ["auth", "setup-git"], { stdio: "inherit", timeout: 10_000 }).catch(() => {});
367
- // Extract token and write to .netrc for tools that need it directly
368
- try {
369
- const { stdout: ghToken } = await execa("gh", ["auth", "token"], { timeout: 5000 });
370
- const { stdout: ghUser } = await execa("gh", ["api", "/user", "--jq", ".login"], { timeout: 10000 });
371
- if (ghToken?.trim() && ghUser?.trim()) {
372
- const entry = `machine github.com\nlogin ${ghUser.trim()}\npassword ${ghToken.trim()}\n`;
373
- if (fs.existsSync(netrcPath)) {
374
- const content = fs.readFileSync(netrcPath, "utf8");
375
- if (!content.includes("github.com")) {
376
- fs.appendFileSync(netrcPath, "\n" + entry);
377
- }
378
- } else {
379
- fs.writeFileSync(netrcPath, entry, { mode: 0o600 });
380
- }
381
- console.log(chalk.green(" ✓ ~/.netrc updated with GitHub credentials"));
382
- }
383
- } catch {}
613
+ await run("gh", ["auth", "setup-git"], { stdio: "inherit", timeout: 10_000 }).catch(() => {});
614
+
615
+ // Extract token and write to .netrc
616
+ await writeNetrcFromGh();
384
617
  };
385
- if (fs.existsSync(netrcPath)) {
618
+
619
+ const netrcExists = await fileExists(netrcPath);
620
+ if (netrcExists) {
386
621
  try {
387
- const content = fs.readFileSync(netrcPath, "utf8");
622
+ const content = await readFile(netrcPath);
388
623
  if (!content.includes("github.com")) {
389
624
  fail("~/.netrc exists but no github.com entry", "needed for private repos", netrcFixFn);
390
625
  } else {
@@ -417,41 +652,55 @@ export async function runDoctor(opts = {}, registry = null) {
417
652
  }
418
653
 
419
654
  // ~/.fops.json config (optional)
420
- const fopsConfig = path.join(os.homedir(), ".fops.json");
421
- if (fs.existsSync(fopsConfig)) ok("~/.fops.json config");
655
+ const fopsConfig = useWsl
656
+ ? path.posix.join(home, ".fops.json")
657
+ : path.join(os.homedir(), ".fops.json");
658
+ const fopsExists = await fileExists(fopsConfig);
659
+ if (fopsExists) ok("~/.fops.json config");
422
660
  else warn("~/.fops.json not found", "optional — run fops init to create");
423
661
 
424
662
  // ── AWS / ECR ──────────────────────────────────────
425
663
  header("AWS / ECR");
426
664
 
427
- const awsConfigPath = path.join(os.homedir(), ".aws", "config");
665
+ const awsConfigPath = useWsl
666
+ ? path.posix.join(home, ".aws", "config")
667
+ : path.join(os.homedir(), ".aws", "config");
428
668
  let awsSessionValid = false;
429
669
 
430
- if (fs.existsSync(awsConfigPath)) {
670
+ // Context object for WSL-delegated AWS commands
671
+ const awsCtx = useWsl ? { exec: run, home } : {};
672
+ const boundFixAwsSso = () => fixAwsSso(awsCtx);
673
+
674
+ const awsConfigExists = await fileExists(awsConfigPath);
675
+ if (awsConfigExists) {
431
676
  ok("~/.aws/config exists");
432
677
 
433
678
  // Check for SSO session — use detected profile
434
- const ssoProfiles = awsVer ? detectAwsSsoProfiles() : [];
679
+ let awsConfigContent = null;
680
+ if (useWsl) {
681
+ try { awsConfigContent = await readFile(awsConfigPath); } catch {}
682
+ }
683
+ const ssoProfiles = awsVer ? detectAwsSsoProfiles(awsConfigContent) : [];
435
684
  const defaultProfile = ssoProfiles[0];
436
685
 
437
686
  if (awsVer) {
438
687
  const profileArgs = defaultProfile ? ["--profile", defaultProfile.name] : [];
439
688
  try {
440
- const { stdout } = await execa("aws", ["sts", "get-caller-identity", "--output", "json", ...profileArgs], {
689
+ const { stdout } = await run("aws", ["sts", "get-caller-identity", "--output", "json", ...profileArgs], {
441
690
  timeout: 10000, reject: false,
442
691
  });
443
692
  if (stdout && stdout.includes("Account")) {
444
693
  ok("AWS SSO session valid");
445
694
  awsSessionValid = true;
446
695
  } else {
447
- fail("AWS SSO session expired or invalid", "run: aws sso login", fixAwsSso);
696
+ fail("AWS SSO session expired or invalid", "run: aws sso login", boundFixAwsSso);
448
697
  }
449
698
  } catch {
450
- fail("AWS SSO session check failed", "run: aws sso login", fixAwsSso);
699
+ fail("AWS SSO session check failed", "run: aws sso login", boundFixAwsSso);
451
700
  }
452
701
  }
453
702
  } else {
454
- warn("~/.aws/config not found", "needed for ECR", fixAwsSso);
703
+ warn("~/.aws/config not found", "needed for ECR", boundFixAwsSso);
455
704
  }
456
705
 
457
706
  // Validate ECR access if project references ECR images
@@ -462,20 +711,24 @@ export async function runDoctor(opts = {}, registry = null) {
462
711
  fail(`ECR registry ${ecrUrl}`, "fix AWS session first");
463
712
  } else {
464
713
  // Check we can get an ECR login password (same call the actual login uses)
465
- const ssoProfiles = detectAwsSsoProfiles();
714
+ let awsConfigContent = null;
715
+ if (useWsl) {
716
+ try { awsConfigContent = await readFile(awsConfigPath); } catch {}
717
+ }
718
+ const ssoProfiles = detectAwsSsoProfiles(awsConfigContent);
466
719
  const ecrProfile = ssoProfiles.find((p) => p.region === ecrInfo.region) || ssoProfiles[0];
467
720
  const ecrProfileArgs = ecrProfile ? ["--profile", ecrProfile.name] : [];
468
721
  try {
469
- const { stdout, exitCode } = await execa("aws", [
722
+ const { stdout, exitCode } = await run("aws", [
470
723
  "ecr", "get-login-password", "--region", ecrInfo.region, ...ecrProfileArgs,
471
724
  ], { timeout: 10000, reject: false });
472
725
  if (exitCode === 0 && stdout?.trim()) {
473
726
  ok(`ECR registry accessible`, ecrUrl);
474
727
  } else {
475
- fail(`ECR registry not accessible`, `${ecrUrl} — run: fops doctor --fix`, () => fixEcr(ecrInfo));
728
+ fail(`ECR registry not accessible`, `${ecrUrl} — run: fops doctor --fix`, () => fixEcr(ecrInfo, awsCtx));
476
729
  }
477
730
  } catch {
478
- fail(`ECR registry check failed`, ecrUrl, () => fixEcr(ecrInfo));
731
+ fail(`ECR registry check failed`, ecrUrl, () => fixEcr(ecrInfo, awsCtx));
479
732
  }
480
733
  }
481
734
  } else if (dir) {
@@ -582,9 +835,18 @@ export async function runDoctor(opts = {}, registry = null) {
582
835
  }
583
836
  }
584
837
  if (size && avail && pct != null) {
585
- if (pct >= 90) fail(`Host disk: ${avail} free of ${size}`, "critically low — Docker needs room");
586
- else if (pct >= 80) warn(`Host disk: ${avail} free of ${size}`, "getting low");
587
- else ok(`Host disk: ${avail} free of ${size}`);
838
+ const cleanupFix = async () => {
839
+ console.log(chalk.cyan(" ▶ docker system prune -af --volumes"));
840
+ await execa("docker", ["system", "prune", "-af", "--volumes"], { stdio: "inherit", timeout: 300_000 });
841
+ console.log(chalk.dim(" Tip: Also run 'docker builder prune -af' to clear build cache"));
842
+ };
843
+ if (pct >= 90) {
844
+ fail(`Host disk: ${avail} free of ${size}`, "critically low — Docker needs room", cleanupFix);
845
+ } else if (pct >= 80) {
846
+ warn(`Host disk: ${avail} free of ${size}`, "consider running: docker system prune", cleanupFix);
847
+ } else {
848
+ ok(`Host disk: ${avail} free of ${size}`);
849
+ }
588
850
  }
589
851
  } catch {}
590
852
 
@@ -781,7 +1043,7 @@ export async function runDoctor(opts = {}, registry = null) {
781
1043
  if (fixes.length > 0) {
782
1044
  let shouldFix = opts.fix;
783
1045
  if (!shouldFix) {
784
- const { ans } = await inquirer.prompt([{ type: "confirm", name: "ans", message: `Fix ${fixes.length} issue(s) automatically?`, default: true }]);
1046
+ const { ans } = await (await getInquirer()).prompt([{ type: "confirm", name: "ans", message: `Fix ${fixes.length} issue(s) automatically?`, default: true }]);
785
1047
  shouldFix = ans;
786
1048
  }
787
1049
  if (shouldFix) {
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import chalk from "chalk";
4
4
  import { execa } from "execa";
5
- import inquirer from "inquirer";
5
+ import { getInquirer } from "./lazy.js";
6
6
 
7
7
  /**
8
8
  * Canonical feature flags — the complete set known to the platform.
@@ -130,7 +130,7 @@ export async function runFeatureFlags(root) {
130
130
  checked: allFlags[name].value,
131
131
  }));
132
132
 
133
- const { enabled } = await inquirer.prompt([{
133
+ const { enabled } = await (await getInquirer()).prompt([{
134
134
  type: "checkbox",
135
135
  name: "enabled",
136
136
  message: "Toggle feature flags:",
@@ -175,7 +175,7 @@ export async function runFeatureFlags(root) {
175
175
  const serviceList = [...affectedServices];
176
176
  console.log(chalk.dim(` Affected: ${serviceList.join(", ")}`));
177
177
 
178
- const { restart } = await inquirer.prompt([{
178
+ const { restart } = await (await getInquirer()).prompt([{
179
179
  type: "confirm",
180
180
  name: "restart",
181
181
  message: `Restart ${serviceList.length} service(s)?`,
package/src/lazy.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Lazy-load inquirer to avoid ~20s import penalty on WSL /mnt/c/ filesystem.
3
+ * Only loads when a command actually needs interactive prompts.
4
+ */
5
+ let _inquirer;
6
+ export async function getInquirer() {
7
+ if (!_inquirer) {
8
+ const mod = await import("inquirer");
9
+ _inquirer = mod.default;
10
+ }
11
+ return _inquirer;
12
+ }
@@ -49,8 +49,14 @@ function saveCredentials(creds) {
49
49
  * Open a URL in the user's default browser.
50
50
  */
51
51
  function openBrowser(url) {
52
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
53
- try { execSync(`${cmd} '${url}'`, { stdio: "ignore" }); } catch {}
52
+ try {
53
+ if (process.platform === "win32") {
54
+ execSync(`cmd /c start "" "${url}"`, { stdio: "ignore" });
55
+ } else {
56
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
57
+ execSync(`${cmd} '${url}'`, { stdio: "ignore" });
58
+ }
59
+ } catch {}
54
60
  }
55
61
 
56
62
  /**
@@ -247,8 +247,9 @@ export function register(api) {
247
247
  fn: async (ok, warn) => {
248
248
  const bin = cursorBin();
249
249
  if (bin) {
250
- const version = run(bin, ["--version"]);
251
- ok("Cursor IDE", version || bin);
250
+ const raw = run(bin, ["--version"]);
251
+ const version = raw ? raw.split("\n")[0].trim() : bin;
252
+ ok("Cursor IDE", version);
252
253
  } else {
253
254
  warn("Cursor IDE", "not found — install from cursor.com, then: Cmd+Shift+P → Shell Command: Install 'cursor'");
254
255
  }