@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.
- package/package.json +2 -1
- package/src/commands/index.js +163 -1
- package/src/doctor.js +155 -17
- package/src/plugins/bundled/coda/auth.js +79 -0
- package/src/plugins/bundled/coda/client.js +187 -0
- package/src/plugins/bundled/coda/fops.plugin.json +7 -0
- package/src/plugins/bundled/coda/index.js +284 -0
- package/src/plugins/bundled/coda/package.json +3 -0
- package/src/plugins/bundled/coda/skills/coda/SKILL.md +82 -0
- package/src/plugins/bundled/cursor/fops.plugin.json +7 -0
- package/src/plugins/bundled/cursor/index.js +432 -0
- package/src/plugins/bundled/cursor/package.json +1 -0
- package/src/plugins/bundled/cursor/skills/cursor/SKILL.md +48 -0
- package/src/plugins/bundled/fops-plugin-1password/fops.plugin.json +7 -0
- package/src/plugins/bundled/fops-plugin-1password/index.js +239 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/env.js +100 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/op.js +111 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/setup.js +235 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/sync.js +61 -0
- package/src/plugins/bundled/fops-plugin-1password/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-1password/skills/1password/SKILL.md +79 -0
- package/src/plugins/bundled/fops-plugin-ecr/fops.plugin.json +7 -0
- package/src/plugins/bundled/fops-plugin-ecr/index.js +302 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/aws.js +147 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/images.js +73 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/setup.js +180 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/sync.js +74 -0
- package/src/plugins/bundled/fops-plugin-ecr/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-ecr/skills/ecr/SKILL.md +105 -0
- package/src/plugins/bundled/fops-plugin-memory/fops.plugin.json +7 -0
- package/src/plugins/bundled/fops-plugin-memory/index.js +148 -0
- package/src/plugins/bundled/fops-plugin-memory/lib/relevance.js +72 -0
- package/src/plugins/bundled/fops-plugin-memory/lib/store.js +75 -0
- package/src/plugins/bundled/fops-plugin-memory/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-memory/skills/memory/SKILL.md +58 -0
- package/src/plugins/loader.js +40 -0
- package/src/setup/aws.js +51 -38
- package/src/setup/setup.js +2 -0
- 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.
|
|
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",
|
package/src/commands/index.js
CHANGED
|
@@ -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(` ${
|
|
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", "
|
|
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", "
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
+
fail("~/.netrc not readable", "check file permissions", netrcFixFn);
|
|
287
414
|
}
|
|
288
415
|
} else {
|
|
289
|
-
|
|
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", "
|
|
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"
|
|
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 (
|
|
648
|
-
|
|
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
|
+
}
|