@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meshxdata/fops",
3
- "version": "0.0.4",
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",
@@ -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(` ${chalk.green("●")} ${chalk.bold(p.name)} ${chalk.dim("v" + p.version)} ${source}`);
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", "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();
@@ -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 (failed > 0 && fixes.length > 0) {
648
- const shouldFix = opts.fix || await confirm(`\n Fix ${fixes.length} issue(s) automatically?`, true);
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 sessionName = await ask("SSO session name", "meshx");
170
- const startUrl = await ask("SSO start URL");
171
- const ssoRegion = await ask("SSO region", "us-east-1");
172
- const accountId = await ask("AWS account ID");
173
- const roleName = await ask("SSO role name", "AdministratorAccess");
174
- const region = await ask("Default region", ssoRegion);
175
- const profileName = await ask("Profile name", "dev");
176
-
177
- if (!startUrl || !accountId) {
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
- [sso-session ${sessionName}]
187
- sso_start_url = ${startUrl}
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.appendFileSync(configPath, block);
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
  /**
@@ -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
- let hasGit = false, hasDocker = false, hasAws = false, hasClaude = false;
34
- try { await execa("git", ["--version"]); hasGit = true; } catch {}
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
- try { await execa("aws", ["--version"]); hasAws = true; } catch {}
37
- try { await execa("claude", ["--version"]); hasClaude = true; } catch {}
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
- console.log(hasGit ? chalk.green(" ✓ Git") : chalk.red(" ✗ Git — install git first"));
40
- console.log(hasDocker ? chalk.green(" ✓ Docker") : chalk.red(" ✗ Docker — install and start Docker Desktop"));
41
- console.log(hasClaude ? chalk.green(" ✓ Claude CLI") : chalk.red(" ✗ Claude CLI — run: npm install (included as a dependency)"));
42
- console.log(hasAws ? chalk.green(" ✓ AWS CLI") : chalk.yellow(" ⚠ AWS CLI — install for ECR image pulls (brew install awscli)"));
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
- console.log(cursorVer
55
- ? chalk.green(" ✓ Cursor IDE") + chalk.dim(` — ${cursorVer}`)
56
- : chalk.yellow(" Cursor IDE install from cursor.com, then: Cmd+Shift+P → 'Install cursor command'"));
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("Fix the missing prerequisites above, then run fops init again.\n"));
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 = [