@meshxdata/fops 0.0.4 → 0.0.5
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 +48 -1
- package/src/doctor.js +148 -17
- package/src/setup/aws.js +51 -38
- package/src/setup/wizard.js +125 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meshxdata/fops",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
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
|
@@ -273,6 +273,24 @@ export function registerCommands(program, registry) {
|
|
|
273
273
|
.command("plugin")
|
|
274
274
|
.description("Manage fops plugins");
|
|
275
275
|
|
|
276
|
+
// Helper: read/write plugin enabled state in ~/.fops.json
|
|
277
|
+
const fopsConfigPath = path.join(os.homedir(), ".fops.json");
|
|
278
|
+
const readFopsConfig = () => {
|
|
279
|
+
try { return fs.existsSync(fopsConfigPath) ? JSON.parse(fs.readFileSync(fopsConfigPath, "utf8")) : {}; } catch { return {}; }
|
|
280
|
+
};
|
|
281
|
+
const setPluginEnabled = (id, enabled) => {
|
|
282
|
+
const cfg = readFopsConfig();
|
|
283
|
+
if (!cfg.plugins) cfg.plugins = {};
|
|
284
|
+
if (!cfg.plugins.entries) cfg.plugins.entries = {};
|
|
285
|
+
if (!cfg.plugins.entries[id]) cfg.plugins.entries[id] = {};
|
|
286
|
+
cfg.plugins.entries[id].enabled = enabled;
|
|
287
|
+
fs.writeFileSync(fopsConfigPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
288
|
+
};
|
|
289
|
+
const isPluginEnabled = (id) => {
|
|
290
|
+
const cfg = readFopsConfig();
|
|
291
|
+
return cfg?.plugins?.entries?.[id]?.enabled !== false;
|
|
292
|
+
};
|
|
293
|
+
|
|
276
294
|
pluginCmd
|
|
277
295
|
.command("list")
|
|
278
296
|
.description("List installed plugins with status")
|
|
@@ -284,8 +302,11 @@ export function registerCommands(program, registry) {
|
|
|
284
302
|
}
|
|
285
303
|
console.log(chalk.bold.cyan("\n Installed Plugins\n"));
|
|
286
304
|
for (const p of registry.plugins) {
|
|
305
|
+
const enabled = isPluginEnabled(p.id);
|
|
306
|
+
const dot = enabled ? chalk.green("●") : chalk.red("○");
|
|
307
|
+
const status = enabled ? "" : chalk.red(" (disabled)");
|
|
287
308
|
const source = chalk.dim(`(${p.source})`);
|
|
288
|
-
console.log(` ${
|
|
309
|
+
console.log(` ${dot} ${chalk.bold(p.name)} ${chalk.dim("v" + p.version)} ${source}${status}`);
|
|
289
310
|
console.log(chalk.dim(` id: ${p.id} path: ${p.path}`));
|
|
290
311
|
}
|
|
291
312
|
console.log("");
|
|
@@ -338,4 +359,30 @@ export function registerCommands(program, registry) {
|
|
|
338
359
|
fs.rmSync(pluginDir, { recursive: true, force: true });
|
|
339
360
|
console.log(chalk.green(` ✓ Removed plugin "${id}"`));
|
|
340
361
|
});
|
|
362
|
+
|
|
363
|
+
pluginCmd
|
|
364
|
+
.command("enable <id>")
|
|
365
|
+
.description("Enable a plugin")
|
|
366
|
+
.action(async (id) => {
|
|
367
|
+
const found = registry.plugins.find((p) => p.id === id);
|
|
368
|
+
if (!found) {
|
|
369
|
+
console.error(chalk.red(`Plugin "${id}" not found. Run: fops plugin list`));
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
setPluginEnabled(id, true);
|
|
373
|
+
console.log(chalk.green(` ✓ Enabled plugin "${id}". Restart fops to apply.`));
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
pluginCmd
|
|
377
|
+
.command("disable <id>")
|
|
378
|
+
.description("Disable a plugin without removing it")
|
|
379
|
+
.action(async (id) => {
|
|
380
|
+
const found = registry.plugins.find((p) => p.id === id);
|
|
381
|
+
if (!found) {
|
|
382
|
+
console.error(chalk.red(`Plugin "${id}" not found. Run: fops plugin list`));
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
setPluginEnabled(id, false);
|
|
386
|
+
console.log(chalk.yellow(` ○ Disabled plugin "${id}". Restart fops to apply.`));
|
|
387
|
+
});
|
|
341
388
|
}
|
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();
|
|
@@ -644,8 +771,12 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
644
771
|
if (failed) parts.push(chalk.red(`${failed} failed`));
|
|
645
772
|
console.log(" " + parts.join(chalk.dim(" · ")));
|
|
646
773
|
|
|
647
|
-
if (
|
|
648
|
-
|
|
774
|
+
if (fixes.length > 0) {
|
|
775
|
+
let shouldFix = opts.fix;
|
|
776
|
+
if (!shouldFix) {
|
|
777
|
+
const { ans } = await inquirer.prompt([{ type: "confirm", name: "ans", message: `Fix ${fixes.length} issue(s) automatically?`, default: true }]);
|
|
778
|
+
shouldFix = ans;
|
|
779
|
+
}
|
|
649
780
|
if (shouldFix) {
|
|
650
781
|
console.log("");
|
|
651
782
|
for (const fix of fixes) {
|
|
@@ -658,7 +789,7 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
658
789
|
}
|
|
659
790
|
}
|
|
660
791
|
console.log(chalk.dim(" Run fops doctor again to verify.\n"));
|
|
661
|
-
} else {
|
|
792
|
+
} else if (failed > 0) {
|
|
662
793
|
console.log("");
|
|
663
794
|
process.exit(1);
|
|
664
795
|
}
|
package/src/setup/aws.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import readline from "node:readline";
|
|
5
4
|
import chalk from "chalk";
|
|
6
5
|
import { execa } from "execa";
|
|
7
6
|
import inquirer from "inquirer";
|
|
@@ -138,20 +137,6 @@ export function detectAwsSsoProfiles() {
|
|
|
138
137
|
return profiles;
|
|
139
138
|
}
|
|
140
139
|
|
|
141
|
-
/**
|
|
142
|
-
* Simple readline prompt helper.
|
|
143
|
-
*/
|
|
144
|
-
function ask(question, defaultVal) {
|
|
145
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
146
|
-
const suffix = defaultVal ? chalk.dim(` (${defaultVal})`) : "";
|
|
147
|
-
return new Promise((resolve) => {
|
|
148
|
-
rl.question(` ${question}${suffix}: `, (answer) => {
|
|
149
|
-
rl.close();
|
|
150
|
-
resolve(answer.trim() || defaultVal || "");
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
140
|
/**
|
|
156
141
|
* Check if ~/.aws/config has an sso-session block with sso_start_url.
|
|
157
142
|
* If not, prompt user for the values and write them.
|
|
@@ -166,38 +151,35 @@ export async function ensureSsoConfig() {
|
|
|
166
151
|
console.log(chalk.cyan("\n AWS SSO is not configured. Let's set it up.\n"));
|
|
167
152
|
console.log(chalk.dim(" You can find these values in your AWS SSO portal.\n"));
|
|
168
153
|
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
throw new Error("SSO start URL and account ID are required");
|
|
179
|
-
}
|
|
154
|
+
const answers = await inquirer.prompt([
|
|
155
|
+
{ type: "input", name: "sessionName", message: "SSO session name:", default: "me-central-1" },
|
|
156
|
+
{ type: "input", name: "startUrl", message: "SSO start URL:", validate: (v) => v?.trim() ? true : "Required." },
|
|
157
|
+
{ type: "input", name: "ssoRegion", message: "SSO region:", default: "us-east-1" },
|
|
158
|
+
{ type: "input", name: "accountId", message: "AWS account ID:", validate: (v) => /^\d{12}$/.test(v?.trim()) ? true : "Must be 12 digits." },
|
|
159
|
+
{ type: "input", name: "roleName", message: "SSO role name:", default: "AdministratorAccess" },
|
|
160
|
+
{ type: "input", name: "profileName", message: "Profile name:", default: "dev" },
|
|
161
|
+
{ type: "input", name: "region", message: "Default region:", default: (a) => a.ssoRegion },
|
|
162
|
+
]);
|
|
180
163
|
|
|
181
164
|
// Ensure ~/.aws directory exists
|
|
182
165
|
const awsDir = path.join(os.homedir(), ".aws");
|
|
183
166
|
if (!fs.existsSync(awsDir)) fs.mkdirSync(awsDir, { mode: 0o700 });
|
|
184
167
|
|
|
185
|
-
const block = `
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
sso_region = ${ssoRegion}
|
|
168
|
+
const block = `[sso-session ${answers.sessionName.trim()}]
|
|
169
|
+
sso_start_url = ${answers.startUrl.trim()}
|
|
170
|
+
sso_region = ${answers.ssoRegion.trim()}
|
|
189
171
|
sso_registration_scopes = sso:account:access
|
|
190
172
|
|
|
191
|
-
[profile ${profileName}]
|
|
192
|
-
sso_session = ${sessionName}
|
|
193
|
-
sso_account_id = ${accountId}
|
|
194
|
-
sso_role_name = ${roleName}
|
|
195
|
-
region = ${region}
|
|
173
|
+
[profile ${answers.profileName.trim()}]
|
|
174
|
+
sso_session = ${answers.sessionName.trim()}
|
|
175
|
+
sso_account_id = ${answers.accountId.trim()}
|
|
176
|
+
sso_role_name = ${answers.roleName.trim()}
|
|
177
|
+
region = ${answers.region.trim()}
|
|
196
178
|
output = json
|
|
197
179
|
`;
|
|
198
180
|
|
|
199
|
-
fs.
|
|
200
|
-
console.log(chalk.green(`\n ✓ Written to ~/.aws/config (profile: ${profileName})`));
|
|
181
|
+
fs.writeFileSync(configPath, block);
|
|
182
|
+
console.log(chalk.green(`\n ✓ Written to ~/.aws/config (profile: ${answers.profileName.trim()})`));
|
|
201
183
|
}
|
|
202
184
|
|
|
203
185
|
/**
|
|
@@ -220,13 +202,44 @@ export async function fixAwsSso() {
|
|
|
220
202
|
let ttyFd;
|
|
221
203
|
try { ttyFd = fs.openSync("/dev/tty", "r"); } catch { ttyFd = null; }
|
|
222
204
|
|
|
223
|
-
await execa("aws", ["sso", "login", "--profile", profile.name], {
|
|
205
|
+
const { exitCode } = await execa("aws", ["sso", "login", "--profile", profile.name], {
|
|
224
206
|
stdio: [ttyFd ?? "inherit", "inherit", "inherit"],
|
|
225
207
|
reject: false,
|
|
226
208
|
timeout: 120_000,
|
|
227
209
|
});
|
|
228
210
|
|
|
229
211
|
if (ttyFd !== null) fs.closeSync(ttyFd);
|
|
212
|
+
|
|
213
|
+
if (exitCode !== 0) {
|
|
214
|
+
// SSO login failed — likely bad config. Remove it and re-run setup.
|
|
215
|
+
console.log(chalk.yellow(" SSO login failed — re-running setup with new values...\n"));
|
|
216
|
+
const configPath = path.join(os.homedir(), ".aws", "config");
|
|
217
|
+
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
218
|
+
await ensureSsoConfig();
|
|
219
|
+
|
|
220
|
+
const retryProfiles = detectAwsSsoProfiles();
|
|
221
|
+
if (retryProfiles.length === 0) {
|
|
222
|
+
throw new Error("No SSO profiles found after config — check ~/.aws/config");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const retryProfile = retryProfiles[0];
|
|
226
|
+
console.log(chalk.cyan(` ▶ aws sso login --profile ${retryProfile.name}`));
|
|
227
|
+
|
|
228
|
+
let retryTtyFd;
|
|
229
|
+
try { retryTtyFd = fs.openSync("/dev/tty", "r"); } catch { retryTtyFd = null; }
|
|
230
|
+
|
|
231
|
+
const { exitCode: retryCode } = await execa("aws", ["sso", "login", "--profile", retryProfile.name], {
|
|
232
|
+
stdio: [retryTtyFd ?? "inherit", "inherit", "inherit"],
|
|
233
|
+
reject: false,
|
|
234
|
+
timeout: 120_000,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (retryTtyFd !== null) fs.closeSync(retryTtyFd);
|
|
238
|
+
|
|
239
|
+
if (retryCode !== 0) {
|
|
240
|
+
throw new Error("SSO login failed. Check your SSO start URL and region in ~/.aws/config");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
230
243
|
}
|
|
231
244
|
|
|
232
245
|
/**
|
package/src/setup/wizard.js
CHANGED
|
@@ -8,6 +8,60 @@ import { isFoundationRoot, findComposeRootUp } from "../project.js";
|
|
|
8
8
|
import { discoverPlugins } from "../plugins/discovery.js";
|
|
9
9
|
import { validateManifest } from "../plugins/manifest.js";
|
|
10
10
|
import { runSetup, CLONE_BRANCH } from "./setup.js";
|
|
11
|
+
import { confirm } from "../ui/index.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ensure Homebrew is available (macOS). Installs if missing.
|
|
15
|
+
*/
|
|
16
|
+
async function ensureBrew() {
|
|
17
|
+
try { await execa("brew", ["--version"]); return true; } catch {}
|
|
18
|
+
console.log(chalk.cyan(" ▶ Installing Homebrew…"));
|
|
19
|
+
try {
|
|
20
|
+
await execa("bash", ["-c", 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'], {
|
|
21
|
+
stdio: "inherit", timeout: 300_000,
|
|
22
|
+
});
|
|
23
|
+
const brewPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
|
|
24
|
+
for (const bp of brewPaths) {
|
|
25
|
+
if (fs.existsSync(bp)) { process.env.PATH = path.dirname(bp) + ":" + process.env.PATH; break; }
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
} catch { return false; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Try to install a missing tool. Returns true if installed successfully.
|
|
33
|
+
*/
|
|
34
|
+
async function installTool(name, { brew, brewCask, winget, apt, npm: npmPkg } = {}) {
|
|
35
|
+
const shouldInstall = await confirm(` Install ${name}?`, true);
|
|
36
|
+
if (!shouldInstall) return false;
|
|
37
|
+
try {
|
|
38
|
+
if (process.platform === "darwin" && (brew || brewCask)) {
|
|
39
|
+
if (!(await ensureBrew())) { console.log(chalk.red(" Homebrew required")); return false; }
|
|
40
|
+
const cmd = brewCask ? ["install", "--cask", brewCask] : ["install", brew];
|
|
41
|
+
console.log(chalk.cyan(` ▶ brew ${cmd.join(" ")}`));
|
|
42
|
+
await execa("brew", cmd, { stdio: "inherit", timeout: 300_000 });
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (process.platform === "win32" && winget) {
|
|
46
|
+
console.log(chalk.cyan(` ▶ winget install ${winget}`));
|
|
47
|
+
await execa("winget", ["install", winget, "--accept-source-agreements", "--accept-package-agreements"], { stdio: "inherit", timeout: 300_000 });
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
if (process.platform === "linux" && apt) {
|
|
51
|
+
console.log(chalk.cyan(` ▶ sudo apt-get install -y ${apt}`));
|
|
52
|
+
await execa("sudo", ["apt-get", "install", "-y", apt], { stdio: "inherit", timeout: 300_000 });
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (npmPkg) {
|
|
56
|
+
console.log(chalk.cyan(` ▶ npm install`));
|
|
57
|
+
await execa("npm", ["install"], { stdio: "inherit", timeout: 300_000 });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.log(chalk.red(` Failed to install ${name}: ${err.message}`));
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
11
65
|
|
|
12
66
|
export async function runInitWizard() {
|
|
13
67
|
const cwd = process.cwd();
|
|
@@ -30,19 +84,64 @@ export async function runInitWizard() {
|
|
|
30
84
|
projectRoot = foundUp;
|
|
31
85
|
}
|
|
32
86
|
if (!projectRoot) {
|
|
33
|
-
|
|
34
|
-
|
|
87
|
+
// ── Check prerequisites and offer to install missing ones ──
|
|
88
|
+
const getVer = async (cmd, args = ["--version"]) => {
|
|
89
|
+
try { const { stdout } = await execa(cmd, args, { reject: false, timeout: 5000 }); return stdout?.split("\n")[0]?.trim() || null; } catch { return null; }
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
let hasGit = !!(await getVer("git"));
|
|
93
|
+
let hasDocker = false;
|
|
35
94
|
try { await execa("docker", ["info"], { timeout: 5000 }); hasDocker = true; } catch {}
|
|
36
|
-
|
|
37
|
-
|
|
95
|
+
let hasAws = !!(await getVer("aws"));
|
|
96
|
+
let hasClaude = !!(await getVer("claude"));
|
|
97
|
+
let hasOp = !!(await getVer("op"));
|
|
98
|
+
|
|
38
99
|
console.log(chalk.cyan(" Prerequisites\n"));
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
console.log(
|
|
42
|
-
|
|
100
|
+
|
|
101
|
+
// Git
|
|
102
|
+
if (hasGit) console.log(chalk.green(" ✓ Git"));
|
|
103
|
+
else {
|
|
104
|
+
console.log(chalk.red(" ✗ Git"));
|
|
105
|
+
hasGit = await installTool("Git", { brew: "git", winget: "Git.Git", apt: "git" });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Docker
|
|
109
|
+
if (hasDocker) console.log(chalk.green(" ✓ Docker"));
|
|
110
|
+
else {
|
|
111
|
+
console.log(chalk.red(" ✗ Docker"));
|
|
112
|
+
hasDocker = await installTool("Docker Desktop", { brewCask: "docker", winget: "Docker.DockerDesktop" });
|
|
113
|
+
if (hasDocker && process.platform === "darwin") {
|
|
114
|
+
console.log(chalk.cyan(" ▶ open -a Docker"));
|
|
115
|
+
await execa("open", ["-a", "Docker"], { timeout: 10000 }).catch(() => {});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Claude CLI
|
|
120
|
+
if (hasClaude) console.log(chalk.green(" ✓ Claude CLI"));
|
|
121
|
+
else {
|
|
122
|
+
console.log(chalk.red(" ✗ Claude CLI"));
|
|
123
|
+
hasClaude = await installTool("Claude CLI", { npm: true });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// AWS CLI (optional)
|
|
127
|
+
if (hasAws) console.log(chalk.green(" ✓ AWS CLI"));
|
|
128
|
+
else {
|
|
129
|
+
console.log(chalk.yellow(" ⚠ AWS CLI") + chalk.dim(" — needed for ECR image pulls"));
|
|
130
|
+
hasAws = await installTool("AWS CLI", { brew: "awscli", winget: "Amazon.AWSCLI" });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 1Password CLI (optional)
|
|
134
|
+
if (hasOp) console.log(chalk.green(" ✓ 1Password CLI"));
|
|
135
|
+
else {
|
|
136
|
+
console.log(chalk.yellow(" ⚠ 1Password CLI") + chalk.dim(" — needed for secret sync"));
|
|
137
|
+
hasOp = await installTool("1Password CLI", { brewCask: "1password-cli", winget: "AgileBits.1Password.CLI" });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// GitHub credentials
|
|
43
141
|
const netrcPath = path.join(os.homedir(), ".netrc");
|
|
44
142
|
const hasNetrc = fs.existsSync(netrcPath) && fs.readFileSync(netrcPath, "utf8").includes("machine github.com");
|
|
45
143
|
console.log(hasNetrc ? chalk.green(" ✓ GitHub credentials (~/.netrc)") : chalk.yellow(" ⚠ GitHub credentials — add to ~/.netrc (needed for private submodules)"));
|
|
144
|
+
|
|
46
145
|
// Cursor IDE (only when cursor plugin is installed)
|
|
47
146
|
const cursorPluginDir = path.join(os.homedir(), ".fops", "plugins", "cursor");
|
|
48
147
|
if (fs.existsSync(cursorPluginDir)) {
|
|
@@ -51,13 +150,27 @@ export async function runInitWizard() {
|
|
|
51
150
|
const { stdout } = await execa("cursor", ["--version"]);
|
|
52
151
|
cursorVer = (stdout || "").split("\n")[0].trim();
|
|
53
152
|
} catch {}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
153
|
+
|
|
154
|
+
if (cursorVer) {
|
|
155
|
+
console.log(chalk.green(" ✓ Cursor IDE") + chalk.dim(` — ${cursorVer}`));
|
|
156
|
+
} else {
|
|
157
|
+
// Check if Cursor app is installed even without CLI command
|
|
158
|
+
const appInstalled = process.platform === "darwin"
|
|
159
|
+
? fs.existsSync("/Applications/Cursor.app")
|
|
160
|
+
: process.platform === "win32"
|
|
161
|
+
? fs.existsSync(path.join(os.homedir(), "AppData", "Local", "Programs", "Cursor", "Cursor.exe"))
|
|
162
|
+
: false;
|
|
163
|
+
if (appInstalled) {
|
|
164
|
+
console.log(chalk.green(" ✓ Cursor IDE") + chalk.dim(" — app installed (CLI not in PATH — Cmd+Shift+P → 'Install cursor command' to enable)"));
|
|
165
|
+
} else {
|
|
166
|
+
console.log(chalk.yellow(" ⚠ Cursor IDE — install from cursor.com, then: Cmd+Shift+P → 'Install cursor command'"));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
57
169
|
}
|
|
170
|
+
|
|
58
171
|
console.log("");
|
|
59
172
|
if (!hasGit || !hasDocker || !hasClaude) {
|
|
60
|
-
console.log(chalk.red("
|
|
173
|
+
console.log(chalk.red(" Required tools are still missing. Install them and run fops init again.\n"));
|
|
61
174
|
process.exit(1);
|
|
62
175
|
}
|
|
63
176
|
const choices = [
|