@meshxdata/fops 0.0.4 → 0.0.6

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.
Files changed (39) hide show
  1. package/package.json +2 -1
  2. package/src/commands/index.js +163 -1
  3. package/src/doctor.js +155 -17
  4. package/src/plugins/bundled/coda/auth.js +79 -0
  5. package/src/plugins/bundled/coda/client.js +187 -0
  6. package/src/plugins/bundled/coda/fops.plugin.json +7 -0
  7. package/src/plugins/bundled/coda/index.js +284 -0
  8. package/src/plugins/bundled/coda/package.json +3 -0
  9. package/src/plugins/bundled/coda/skills/coda/SKILL.md +82 -0
  10. package/src/plugins/bundled/cursor/fops.plugin.json +7 -0
  11. package/src/plugins/bundled/cursor/index.js +432 -0
  12. package/src/plugins/bundled/cursor/package.json +1 -0
  13. package/src/plugins/bundled/cursor/skills/cursor/SKILL.md +48 -0
  14. package/src/plugins/bundled/fops-plugin-1password/fops.plugin.json +7 -0
  15. package/src/plugins/bundled/fops-plugin-1password/index.js +239 -0
  16. package/src/plugins/bundled/fops-plugin-1password/lib/env.js +100 -0
  17. package/src/plugins/bundled/fops-plugin-1password/lib/op.js +111 -0
  18. package/src/plugins/bundled/fops-plugin-1password/lib/setup.js +235 -0
  19. package/src/plugins/bundled/fops-plugin-1password/lib/sync.js +61 -0
  20. package/src/plugins/bundled/fops-plugin-1password/package.json +1 -0
  21. package/src/plugins/bundled/fops-plugin-1password/skills/1password/SKILL.md +79 -0
  22. package/src/plugins/bundled/fops-plugin-ecr/fops.plugin.json +7 -0
  23. package/src/plugins/bundled/fops-plugin-ecr/index.js +302 -0
  24. package/src/plugins/bundled/fops-plugin-ecr/lib/aws.js +147 -0
  25. package/src/plugins/bundled/fops-plugin-ecr/lib/images.js +73 -0
  26. package/src/plugins/bundled/fops-plugin-ecr/lib/setup.js +180 -0
  27. package/src/plugins/bundled/fops-plugin-ecr/lib/sync.js +74 -0
  28. package/src/plugins/bundled/fops-plugin-ecr/package.json +1 -0
  29. package/src/plugins/bundled/fops-plugin-ecr/skills/ecr/SKILL.md +105 -0
  30. package/src/plugins/bundled/fops-plugin-memory/fops.plugin.json +7 -0
  31. package/src/plugins/bundled/fops-plugin-memory/index.js +148 -0
  32. package/src/plugins/bundled/fops-plugin-memory/lib/relevance.js +72 -0
  33. package/src/plugins/bundled/fops-plugin-memory/lib/store.js +75 -0
  34. package/src/plugins/bundled/fops-plugin-memory/package.json +1 -0
  35. package/src/plugins/bundled/fops-plugin-memory/skills/memory/SKILL.md +58 -0
  36. package/src/plugins/loader.js +40 -0
  37. package/src/setup/aws.js +51 -38
  38. package/src/setup/setup.js +2 -0
  39. package/src/setup/wizard.js +137 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meshxdata/fops",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "CLI to install and manage Foundation data mesh platforms",
5
5
  "keywords": [
6
6
  "foundation",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@anthropic-ai/claude-code": "^1.0.0",
29
+ "@meshxdata/fops": "^0.0.4",
29
30
  "boxen": "^8.0.1",
30
31
  "chalk": "^5.3.0",
31
32
  "commander": "^12.0.0",
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import https from "node:https";
4
5
  import chalk from "chalk";
5
6
  import { Command } from "commander";
6
7
  import { PKG } from "../config.js";
@@ -273,6 +274,24 @@ export function registerCommands(program, registry) {
273
274
  .command("plugin")
274
275
  .description("Manage fops plugins");
275
276
 
277
+ // Helper: read/write plugin enabled state in ~/.fops.json
278
+ const fopsConfigPath = path.join(os.homedir(), ".fops.json");
279
+ const readFopsConfig = () => {
280
+ try { return fs.existsSync(fopsConfigPath) ? JSON.parse(fs.readFileSync(fopsConfigPath, "utf8")) : {}; } catch { return {}; }
281
+ };
282
+ const setPluginEnabled = (id, enabled) => {
283
+ const cfg = readFopsConfig();
284
+ if (!cfg.plugins) cfg.plugins = {};
285
+ if (!cfg.plugins.entries) cfg.plugins.entries = {};
286
+ if (!cfg.plugins.entries[id]) cfg.plugins.entries[id] = {};
287
+ cfg.plugins.entries[id].enabled = enabled;
288
+ fs.writeFileSync(fopsConfigPath, JSON.stringify(cfg, null, 2) + "\n");
289
+ };
290
+ const isPluginEnabled = (id) => {
291
+ const cfg = readFopsConfig();
292
+ return cfg?.plugins?.entries?.[id]?.enabled !== false;
293
+ };
294
+
276
295
  pluginCmd
277
296
  .command("list")
278
297
  .description("List installed plugins with status")
@@ -284,8 +303,11 @@ export function registerCommands(program, registry) {
284
303
  }
285
304
  console.log(chalk.bold.cyan("\n Installed Plugins\n"));
286
305
  for (const p of registry.plugins) {
306
+ const enabled = isPluginEnabled(p.id);
307
+ const dot = enabled ? chalk.green("●") : chalk.red("○");
308
+ const status = enabled ? "" : chalk.red(" (disabled)");
287
309
  const source = chalk.dim(`(${p.source})`);
288
- console.log(` ${chalk.green("●")} ${chalk.bold(p.name)} ${chalk.dim("v" + p.version)} ${source}`);
310
+ console.log(` ${dot} ${chalk.bold(p.name)} ${chalk.dim("v" + p.version)} ${source}${status}`);
289
311
  console.log(chalk.dim(` id: ${p.id} path: ${p.path}`));
290
312
  }
291
313
  console.log("");
@@ -338,4 +360,144 @@ export function registerCommands(program, registry) {
338
360
  fs.rmSync(pluginDir, { recursive: true, force: true });
339
361
  console.log(chalk.green(` ✓ Removed plugin "${id}"`));
340
362
  });
363
+
364
+ pluginCmd
365
+ .command("enable <id>")
366
+ .description("Enable a plugin")
367
+ .action(async (id) => {
368
+ const found = registry.plugins.find((p) => p.id === id);
369
+ if (!found) {
370
+ console.error(chalk.red(`Plugin "${id}" not found. Run: fops plugin list`));
371
+ process.exit(1);
372
+ }
373
+ setPluginEnabled(id, true);
374
+ console.log(chalk.green(` ✓ Enabled plugin "${id}". Restart fops to apply.`));
375
+ });
376
+
377
+ pluginCmd
378
+ .command("disable <id>")
379
+ .description("Disable a plugin without removing it")
380
+ .action(async (id) => {
381
+ const found = registry.plugins.find((p) => p.id === id);
382
+ if (!found) {
383
+ console.error(chalk.red(`Plugin "${id}" not found. Run: fops plugin list`));
384
+ process.exit(1);
385
+ }
386
+ setPluginEnabled(id, false);
387
+ console.log(chalk.yellow(` ○ Disabled plugin "${id}". Restart fops to apply.`));
388
+ });
389
+
390
+ // ── Plugin marketplace ──────────────────────────────
391
+ const marketplaceCmd = pluginCmd
392
+ .command("marketplace")
393
+ .description("Browse and install plugins from GitHub");
394
+
395
+ marketplaceCmd
396
+ .command("add <repo>")
397
+ .description("Install a plugin from GitHub (owner/repo)")
398
+ .action(async (repo) => {
399
+ const parts = repo.split("/");
400
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
401
+ console.error(chalk.red(' Usage: fops plugin marketplace add <owner/repo>'));
402
+ process.exit(1);
403
+ }
404
+ const [owner, repoName] = parts;
405
+
406
+ // Download tarball from GitHub
407
+ const tarballUrl = `https://api.github.com/repos/${owner}/${repoName}/tarball/main`;
408
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fops-marketplace-"));
409
+ const tarPath = path.join(tmpDir, "plugin.tar.gz");
410
+
411
+ console.log(chalk.blue(` Downloading ${owner}/${repoName}...`));
412
+
413
+ try {
414
+ await new Promise((resolve, reject) => {
415
+ const follow = (url) => {
416
+ https.get(url, { headers: { "User-Agent": "fops-cli", Accept: "application/vnd.github+json" } }, (res) => {
417
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
418
+ follow(res.headers.location);
419
+ return;
420
+ }
421
+ if (res.statusCode !== 200) {
422
+ reject(new Error(`GitHub returned ${res.statusCode}. Check that ${owner}/${repoName} exists and is public.`));
423
+ res.resume();
424
+ return;
425
+ }
426
+ const ws = fs.createWriteStream(tarPath);
427
+ res.pipe(ws);
428
+ ws.on("finish", resolve);
429
+ ws.on("error", reject);
430
+ }).on("error", reject);
431
+ };
432
+ follow(tarballUrl);
433
+ });
434
+ } catch (err) {
435
+ console.error(chalk.red(` Download failed: ${err.message}`));
436
+ fs.rmSync(tmpDir, { recursive: true, force: true });
437
+ process.exit(1);
438
+ }
439
+
440
+ // Extract tarball
441
+ const extractDir = path.join(tmpDir, "extracted");
442
+ fs.mkdirSync(extractDir, { recursive: true });
443
+ try {
444
+ await execa("tar", ["xzf", tarPath, "-C", extractDir]);
445
+ } catch (err) {
446
+ console.error(chalk.red(` Failed to extract archive: ${err.message}`));
447
+ fs.rmSync(tmpDir, { recursive: true, force: true });
448
+ process.exit(1);
449
+ }
450
+
451
+ // GitHub tarballs extract into a single directory like owner-repo-sha/
452
+ const extracted = fs.readdirSync(extractDir);
453
+ if (extracted.length === 0) {
454
+ console.error(chalk.red(" Archive was empty."));
455
+ fs.rmSync(tmpDir, { recursive: true, force: true });
456
+ process.exit(1);
457
+ }
458
+ const pluginSrc = path.join(extractDir, extracted[0]);
459
+
460
+ // Validate manifest
461
+ const manifestPath = path.join(pluginSrc, "fops.plugin.json");
462
+ if (!fs.existsSync(manifestPath)) {
463
+ console.error(chalk.red(` No fops.plugin.json found in ${owner}/${repoName}. Not a valid fops plugin.`));
464
+ fs.rmSync(tmpDir, { recursive: true, force: true });
465
+ process.exit(1);
466
+ }
467
+
468
+ let manifest;
469
+ try {
470
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
471
+ } catch {
472
+ console.error(chalk.red(" Invalid fops.plugin.json"));
473
+ fs.rmSync(tmpDir, { recursive: true, force: true });
474
+ process.exit(1);
475
+ }
476
+
477
+ if (!manifest.id) {
478
+ console.error(chalk.red(' fops.plugin.json missing required "id" field.'));
479
+ fs.rmSync(tmpDir, { recursive: true, force: true });
480
+ process.exit(1);
481
+ }
482
+
483
+ // Copy to ~/.fops/plugins/<id>/
484
+ const dest = path.join(os.homedir(), ".fops", "plugins", manifest.id);
485
+ fs.mkdirSync(dest, { recursive: true });
486
+
487
+ const entries = fs.readdirSync(pluginSrc, { withFileTypes: true });
488
+ for (const entry of entries) {
489
+ const srcFile = path.join(pluginSrc, entry.name);
490
+ const destFile = path.join(dest, entry.name);
491
+ if (entry.isFile()) {
492
+ fs.copyFileSync(srcFile, destFile);
493
+ } else if (entry.isDirectory()) {
494
+ fs.cpSync(srcFile, destFile, { recursive: true });
495
+ }
496
+ }
497
+
498
+ // Cleanup temp dir
499
+ fs.rmSync(tmpDir, { recursive: true, force: true });
500
+
501
+ console.log(chalk.green(` ✓ Installed plugin "${manifest.id}" from ${owner}/${repoName} to ${dest}`));
502
+ });
341
503
  }
package/src/doctor.js CHANGED
@@ -6,8 +6,8 @@ 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";
9
10
  import { detectEcrRegistry, detectAwsSsoProfiles, fixAwsSso, fixEcr } from "./setup/aws.js";
10
- import { confirm } from "./ui/index.js";
11
11
 
12
12
  const KEY_PORTS = {
13
13
  5432: "Postgres",
@@ -34,6 +34,25 @@ async function checkPort(port) {
34
34
  });
35
35
  }
36
36
 
37
+ /**
38
+ * Ensure Homebrew is available (macOS). Installs if missing.
39
+ * Returns true if brew is usable after the call.
40
+ */
41
+ async function ensureBrew() {
42
+ try { await execa("brew", ["--version"]); return true; } catch {}
43
+ console.log(chalk.cyan(" ▶ Installing Homebrew…"));
44
+ try {
45
+ await execa("bash", ["-c", 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'], {
46
+ stdio: "inherit", timeout: 300_000,
47
+ });
48
+ const brewPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
49
+ for (const bp of brewPaths) {
50
+ if (fs.existsSync(bp)) { process.env.PATH = path.dirname(bp) + ":" + process.env.PATH; break; }
51
+ }
52
+ return true;
53
+ } catch { return false; }
54
+ }
55
+
37
56
  async function cmdVersion(cmd, args = ["--version"]) {
38
57
  try {
39
58
  const { stdout } = await execa(cmd, args, { reject: false, timeout: 5000 });
@@ -98,19 +117,21 @@ export async function runDoctor(opts = {}, registry = null) {
98
117
  let failed = 0;
99
118
 
100
119
  const fixes = []; // collect fix actions to run at the end
120
+ const fixFns = new Set(); // deduplicate by function reference
101
121
 
102
122
  const ok = (name, detail) => {
103
123
  console.log(chalk.green(" ✓ ") + name + (detail ? chalk.dim(` — ${detail}`) : ""));
104
124
  passed++;
105
125
  };
106
- const warn = (name, detail) => {
126
+ const warn = (name, detail, fixFn) => {
107
127
  console.log(chalk.yellow(" ⚠ ") + name + (detail ? chalk.dim(` — ${detail}`) : ""));
108
128
  warned++;
129
+ if (fixFn && !fixFns.has(fixFn)) { fixes.push({ name, fn: fixFn }); fixFns.add(fixFn); }
109
130
  };
110
131
  const fail = (name, detail, fixFn) => {
111
132
  console.log(chalk.red(" ✗ ") + name + (detail ? chalk.dim(` — ${detail}`) : ""));
112
133
  failed++;
113
- if (fixFn) fixes.push({ name, fn: fixFn });
134
+ if (fixFn && !fixFns.has(fixFn)) { fixes.push({ name, fn: fixFn }); fixFns.add(fixFn); }
114
135
  };
115
136
 
116
137
  // ── Prerequisites ──────────────────────────────────
@@ -235,7 +256,20 @@ export async function runDoctor(opts = {}, registry = null) {
235
256
  // Git
236
257
  const gitVer = await cmdVersion("git");
237
258
  if (gitVer) ok("Git available", gitVer);
238
- else fail("Git not found", "install git");
259
+ else fail("Git not found", "install git", async () => {
260
+ if (process.platform === "darwin") {
261
+ if (!(await ensureBrew())) throw new Error("Homebrew required");
262
+ console.log(chalk.cyan(" ▶ brew install git"));
263
+ await execa("brew", ["install", "git"], { stdio: "inherit", timeout: 300_000 });
264
+ } 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 });
268
+ } else {
269
+ console.log(chalk.cyan(" ▶ sudo apt-get install -y git"));
270
+ await execa("sudo", ["apt-get", "install", "-y", "git"], { stdio: "inherit", timeout: 300_000 });
271
+ }
272
+ });
239
273
 
240
274
  // Node.js version
241
275
  const nodeVer = process.versions.node;
@@ -246,28 +280,121 @@ export async function runDoctor(opts = {}, registry = null) {
246
280
  // Claude CLI (bundled as a dependency)
247
281
  const claudeVer = await cmdVersion("claude");
248
282
  if (claudeVer) ok("Claude CLI", claudeVer);
249
- else fail("Claude CLI not found", "run: npm install (included as a dependency)");
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 });
286
+ });
250
287
 
251
288
  // AWS CLI (optional)
252
289
  const awsVer = await cmdVersion("aws");
253
290
  if (awsVer) ok("AWS CLI", awsVer);
254
- else warn("AWS CLI not found", "optional — needed for ECR login");
291
+ else warn("AWS CLI not found", "needed for ECR login", async () => {
292
+ if (process.platform === "darwin") {
293
+ if (!(await ensureBrew())) throw new Error("Homebrew required");
294
+ console.log(chalk.cyan(" ▶ brew install awscli"));
295
+ await execa("brew", ["install", "awscli"], { stdio: "inherit", timeout: 300_000 });
296
+ } 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 });
300
+ } else {
301
+ console.log(chalk.cyan(" ▶ curl + unzip install"));
302
+ 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'], {
303
+ stdio: "inherit", timeout: 300_000,
304
+ });
305
+ }
306
+ });
307
+
308
+ // 1Password CLI (optional — needed for secret sync)
309
+ const opVer = await cmdVersion("op");
310
+ if (opVer) ok("1Password CLI (op)", opVer);
311
+ else warn("1Password CLI (op) not installed", "needed for secret sync", async () => {
312
+ if (process.platform === "darwin") {
313
+ if (!(await ensureBrew())) throw new Error("Homebrew required");
314
+ console.log(chalk.cyan(" ▶ brew install --cask 1password-cli"));
315
+ await execa("brew", ["install", "--cask", "1password-cli"], { stdio: "inherit", timeout: 300_000 });
316
+ } 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 });
320
+ } else {
321
+ console.log(chalk.dim(" Install manually: https://developer.1password.com/docs/cli/get-started/#install"));
322
+ }
323
+ });
255
324
 
256
- // ~/.netrc GitHub credentials (optional — validate against API + repo access)
325
+ // GitHub CLI
326
+ const ghVer = await cmdVersion("gh");
327
+ if (ghVer) ok("GitHub CLI (gh)", ghVer);
328
+ else warn("GitHub CLI (gh) not installed", "needed for auth", async () => {
329
+ if (process.platform === "darwin") {
330
+ if (!(await ensureBrew())) throw new Error("Homebrew required");
331
+ console.log(chalk.cyan(" ▶ brew install gh"));
332
+ await execa("brew", ["install", "gh"], { stdio: "inherit", timeout: 300_000 });
333
+ } 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 });
337
+ } else {
338
+ console.log(chalk.dim(" Install: https://cli.github.com/"));
339
+ }
340
+ });
341
+
342
+ // ~/.netrc GitHub credentials (required for private repo access)
257
343
  const netrcPath = path.join(os.homedir(), ".netrc");
344
+ const netrcFixFn = async () => {
345
+ // Install gh if missing
346
+ let hasGh = false;
347
+ try { await execa("gh", ["--version"]); hasGh = true; } catch {}
348
+ if (!hasGh) {
349
+ if (process.platform === "darwin") {
350
+ if (!(await ensureBrew())) throw new Error("Homebrew required to install gh");
351
+ console.log(chalk.cyan(" ▶ brew install gh"));
352
+ await execa("brew", ["install", "gh"], { stdio: "inherit", timeout: 300_000 });
353
+ hasGh = true;
354
+ } 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;
358
+ }
359
+ }
360
+ // Authenticate via gh
361
+ 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"], {
363
+ stdio: "inherit", timeout: 120_000,
364
+ });
365
+ 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 {}
384
+ };
258
385
  if (fs.existsSync(netrcPath)) {
259
386
  try {
260
387
  const content = fs.readFileSync(netrcPath, "utf8");
261
388
  if (!content.includes("github.com")) {
262
- warn("~/.netrc exists but no github.com entry");
389
+ fail("~/.netrc exists but no github.com entry", "needed for private repos", netrcFixFn);
263
390
  } else {
264
391
  const token = readNetrcToken(content, "github.com");
265
392
  if (!token) {
266
- warn("~/.netrc has github.com but no password/token");
393
+ fail("~/.netrc has github.com but no password/token", "add token", netrcFixFn);
267
394
  } else {
268
395
  const userRes = await ghApiGet("/user", token);
269
396
  if (userRes.status !== 200) {
270
- fail("~/.netrc GitHub token invalid or expired", "regenerate at github.com/settings/tokens");
397
+ fail("~/.netrc GitHub token invalid or expired", "regenerate at github.com/settings/tokens", netrcFixFn);
271
398
  } else {
272
399
  const login = userRes.body.login || "authenticated";
273
400
  ok("~/.netrc GitHub credentials", `authenticated as ${login}`);
@@ -283,10 +410,10 @@ export async function runDoctor(opts = {}, registry = null) {
283
410
  }
284
411
  }
285
412
  } catch {
286
- warn("~/.netrc not readable");
413
+ fail("~/.netrc not readable", "check file permissions", netrcFixFn);
287
414
  }
288
415
  } else {
289
- warn("~/.netrc not found", "optional — used for private repo access");
416
+ fail("~/.netrc not found", "needed for private repo access", netrcFixFn);
290
417
  }
291
418
 
292
419
  // ~/.fops.json config (optional)
@@ -324,7 +451,7 @@ export async function runDoctor(opts = {}, registry = null) {
324
451
  }
325
452
  }
326
453
  } else {
327
- warn("~/.aws/config not found", "optional — needed for ECR");
454
+ warn("~/.aws/config not found", "needed for ECR", fixAwsSso);
328
455
  }
329
456
 
330
457
  // Validate ECR access if project references ECR images
@@ -332,7 +459,7 @@ export async function runDoctor(opts = {}, registry = null) {
332
459
  if (ecrInfo) {
333
460
  const ecrUrl = `${ecrInfo.accountId}.dkr.ecr.${ecrInfo.region}.amazonaws.com`;
334
461
  if (!awsSessionValid) {
335
- fail(`ECR registry ${ecrUrl}`, "fix AWS session first", () => fixEcr(ecrInfo));
462
+ fail(`ECR registry ${ecrUrl}`, "fix AWS session first");
336
463
  } else {
337
464
  // Check we can get an ECR login password (same call the actual login uses)
338
465
  const ssoProfiles = detectAwsSsoProfiles();
@@ -528,6 +655,10 @@ export async function runDoctor(opts = {}, registry = null) {
528
655
  const ERROR_RE = /\b(ERROR|FATAL|PANIC|CRITICAL)\b/;
529
656
  const CRASH_RE = /\b(OOM|OutOfMemory|out of memory|segmentation fault|segfault)\b/i;
530
657
  const CONN_RE = /\b(ECONNREFUSED|ETIMEDOUT|connection refused)\b/i;
658
+ // Telemetry/analytics hosts whose failures are harmless noise
659
+ const TELEMETRY_RE = /heapanalytics\.com|segment\.io|sentry\.io|amplitude\.com|mixpanel\.com|telemetry/i;
660
+ // Harmless startup noise — idempotent init scripts & transient Kafka topic races
661
+ const BENIGN_RE = /already exists|UNKNOWN_TOPIC_OR_PART/;
531
662
 
532
663
  for (const line of logOut.split("\n")) {
533
664
  const sep = line.indexOf(" | ");
@@ -535,6 +666,9 @@ export async function runDoctor(opts = {}, registry = null) {
535
666
  const service = line.slice(0, sep).trim();
536
667
  const msg = line.slice(sep + 3);
537
668
 
669
+ if (TELEMETRY_RE.test(msg)) continue;
670
+ if (BENIGN_RE.test(msg)) continue;
671
+
538
672
  let level = null;
539
673
  if (CRASH_RE.test(msg)) level = "crash";
540
674
  else if (ERROR_RE.test(msg)) level = "error";
@@ -644,8 +778,12 @@ export async function runDoctor(opts = {}, registry = null) {
644
778
  if (failed) parts.push(chalk.red(`${failed} failed`));
645
779
  console.log(" " + parts.join(chalk.dim(" · ")));
646
780
 
647
- if (failed > 0 && fixes.length > 0) {
648
- const shouldFix = opts.fix || await confirm(`\n Fix ${fixes.length} issue(s) automatically?`, true);
781
+ if (fixes.length > 0) {
782
+ let shouldFix = opts.fix;
783
+ if (!shouldFix) {
784
+ const { ans } = await inquirer.prompt([{ type: "confirm", name: "ans", message: `Fix ${fixes.length} issue(s) automatically?`, default: true }]);
785
+ shouldFix = ans;
786
+ }
649
787
  if (shouldFix) {
650
788
  console.log("");
651
789
  for (const fix of fixes) {
@@ -658,7 +796,7 @@ export async function runDoctor(opts = {}, registry = null) {
658
796
  }
659
797
  }
660
798
  console.log(chalk.dim(" Run fops doctor again to verify.\n"));
661
- } else {
799
+ } else if (failed > 0) {
662
800
  console.log("");
663
801
  process.exit(1);
664
802
  }
@@ -0,0 +1,79 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import readline from "node:readline";
5
+ import { execSync } from "node:child_process";
6
+
7
+ const CREDENTIALS_PATH = path.join(os.homedir(), ".fops", "plugins", "coda", ".credentials.json");
8
+ const CODA_ACCOUNT_URL = "https://coda.io/account";
9
+
10
+ /**
11
+ * Prompt for a single line of input from the terminal.
12
+ */
13
+ function ask(question) {
14
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
15
+ return new Promise((resolve) => {
16
+ rl.question(question, (answer) => {
17
+ rl.close();
18
+ resolve(answer.trim());
19
+ });
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Load saved credentials from disk.
25
+ */
26
+ export function loadCredentials() {
27
+ try {
28
+ if (!fs.existsSync(CREDENTIALS_PATH)) return null;
29
+ const raw = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf8"));
30
+ if (!raw.access_token) return null;
31
+ return raw;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Save credentials to disk (mode 0o600).
39
+ */
40
+ function saveCredentials(creds) {
41
+ const dir = path.dirname(CREDENTIALS_PATH);
42
+ if (!fs.existsSync(dir)) {
43
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
44
+ }
45
+ fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
46
+ }
47
+
48
+ /**
49
+ * Open a URL in the user's default browser.
50
+ */
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 {}
54
+ }
55
+
56
+ /**
57
+ * Run the Coda login flow — prompt user for an API token.
58
+ * Returns true on success, false on failure.
59
+ */
60
+ export async function runCodaLogin() {
61
+ console.log("\n Coda API Token Setup");
62
+ console.log(" ────────────────────");
63
+ console.log(` 1. Go to ${CODA_ACCOUNT_URL}`);
64
+ console.log(" 2. Scroll to \"API Settings\"");
65
+ console.log(" 3. Click \"Generate API token\" and copy it\n");
66
+
67
+ openBrowser(CODA_ACCOUNT_URL);
68
+
69
+ const token = await ask(" API token: ");
70
+ if (!token) {
71
+ console.log("\n Aborted.\n");
72
+ return false;
73
+ }
74
+
75
+ saveCredentials({ access_token: token });
76
+
77
+ console.log("\n Saved to ~/.fops/plugins/coda/.credentials.json\n");
78
+ return true;
79
+ }