@meshxdata/fops 0.1.49 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +368 -0
  2. package/package.json +1 -1
  3. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +347 -6
  4. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-data-bootstrap.js +421 -0
  5. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +5 -179
  6. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +14 -4
  7. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +171 -4
  8. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +303 -8
  9. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +2 -0
  10. package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +1 -1
  11. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet-swarm.js +936 -0
  12. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet.js +10 -918
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +5 -0
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault-keys.js +413 -0
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +14 -399
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-config.js +754 -0
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-knock.js +527 -0
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-ssh.js +427 -0
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +99 -1686
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-health.js +279 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +186 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +66 -444
  23. package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +11 -0
  24. package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-lifecycle.js +5 -540
  25. package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-terraform.js +544 -0
  26. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +75 -3
  27. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +227 -11
  28. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +2 -1
  29. package/src/plugins/bundled/fops-plugin-azure/lib/pytest-parse.js +21 -0
  30. package/src/plugins/bundled/fops-plugin-foundation/index.js +371 -44
@@ -984,15 +984,50 @@ app.run()
984
984
  .command("tray")
985
985
  .description("Run a macOS menu bar tray for the Foundation stack")
986
986
  .option("--url <url>", "Override the backend API URL")
987
+ .option("--force", "Kill existing tray and start a new one")
987
988
  .action(async (opts) => {
988
- const { spawn } = await import("node:child_process");
989
- const { writeFileSync, existsSync, realpathSync, readFileSync } = await import("node:fs");
989
+ const { spawn, execSync } = await import("node:child_process");
990
+ const { writeFileSync, existsSync, realpathSync, readFileSync, mkdirSync, unlinkSync } = await import("node:fs");
990
991
  const { tmpdir, homedir } = await import("node:os");
991
992
  const { join, dirname } = await import("node:path");
992
993
  const { findComposeRoot } = await import("./lib/tools-write.js");
993
994
 
994
995
  const composeRoot = program._fopsRoot || findComposeRoot() || "";
995
996
 
997
+ // ── Singleton check ─────────────────────────────────────────────────────
998
+ const fopsDir = join(homedir(), ".fops");
999
+ const pidFile = join(fopsDir, ".tray.pid");
1000
+
1001
+ const isProcessRunning = (pid) => {
1002
+ try {
1003
+ process.kill(pid, 0);
1004
+ return true;
1005
+ } catch {
1006
+ return false;
1007
+ }
1008
+ };
1009
+
1010
+ if (existsSync(pidFile)) {
1011
+ try {
1012
+ const existingPid = parseInt(readFileSync(pidFile, "utf8").trim(), 10);
1013
+ if (existingPid && isProcessRunning(existingPid)) {
1014
+ if (opts.force) {
1015
+ console.log(ACCENT(" Killing existing tray process..."));
1016
+ try { process.kill(existingPid, "SIGTERM"); } catch { /* ignore */ }
1017
+ // Wait a bit for process to terminate
1018
+ await new Promise((r) => setTimeout(r, 500));
1019
+ } else {
1020
+ console.log(ACCENT(" Foundation tray is already running (PID: " + existingPid + ")"));
1021
+ console.log(ACCENT(" Use --force to restart it"));
1022
+ return;
1023
+ }
1024
+ }
1025
+ } catch { /* ignore invalid pid file */ }
1026
+ }
1027
+
1028
+ // Ensure .fops directory exists
1029
+ if (!existsSync(fopsDir)) mkdirSync(fopsDir, { recursive: true });
1030
+
996
1031
  let apiUrl = opts.url || process.env.FOPS_API_URL || "http://127.0.0.1:9001";
997
1032
 
998
1033
  // Current fops version — resolve from the running binary's location
@@ -1021,19 +1056,27 @@ app.run()
1021
1056
  if (existsSync(src)) iconPath = src;
1022
1057
  } catch { /* icon optional */ }
1023
1058
 
1024
- // Discover installed plugins for the Plugins submenu
1025
- let fopsPluginsJson = "[]";
1059
+ // Plugin discovery paths - tray will discover plugins dynamically
1060
+ const globalPluginsDir = join(homedir(), ".fops", "plugins");
1061
+ let npmPluginsDir = "";
1026
1062
  try {
1027
- const { discoverPlugins } = await import("../../discovery.js");
1028
- const { validateManifest } = await import("../../manifest.js");
1029
- const candidates = discoverPlugins();
1030
- const pluginList = candidates.map((c) => {
1031
- const manifest = validateManifest(c.path);
1032
- return { id: c.id, name: manifest?.name || c.id, source: c.source };
1033
- }).filter((p) => p.id);
1034
- fopsPluginsJson = JSON.stringify(pluginList);
1063
+ const pluginsNodeModules = join(homedir(), ".fops", "plugins", "node_modules");
1064
+ const fopsRoot = dirname(realpathSync(pluginsNodeModules));
1065
+ npmPluginsDir = join(fopsRoot, "node_modules");
1035
1066
  } catch { /* optional */ }
1036
1067
 
1068
+ // Common environment for tray processes
1069
+ const trayEnv = {
1070
+ ...process.env,
1071
+ FOUNDATION_API_URL: apiUrl,
1072
+ FOUNDATION_URL: "http://127.0.0.1:3002",
1073
+ FOUNDATION_ICON: iconPath,
1074
+ FOUNDATION_COMPOSE_ROOT: composeRoot,
1075
+ FOPS_VERSION: fopsVersion,
1076
+ FOPS_GLOBAL_PLUGINS_DIR: globalPluginsDir,
1077
+ FOPS_NPM_PLUGINS_DIR: npmPluginsDir,
1078
+ };
1079
+
1037
1080
  // ── Windows tray (PowerShell NotifyIcon) ─────────────────────────────
1038
1081
  if (process.platform === "win32") {
1039
1082
  const ps1Tray = join(tmpdir(), "fops-tray.ps1");
@@ -1044,8 +1087,49 @@ Add-Type -AssemblyName System.Drawing
1044
1087
  $script:apiUrl = $env:FOUNDATION_API_URL
1045
1088
  $script:composeRoot = $env:FOUNDATION_COMPOSE_ROOT
1046
1089
  $script:foundationUrl = if ($env:FOUNDATION_URL) { $env:FOUNDATION_URL } else { "http://127.0.0.1:3002" }
1047
- $script:allPlugins = @()
1048
- try { $script:allPlugins = $env:FOPS_PLUGINS | ConvertFrom-Json } catch {}
1090
+ $script:globalPluginsDir = $env:FOPS_GLOBAL_PLUGINS_DIR
1091
+ $script:npmPluginsDir = $env:FOPS_NPM_PLUGINS_DIR
1092
+
1093
+ function Discover-Plugins {
1094
+ $plugins = @()
1095
+ # Global plugins: ~/.fops/plugins/<name>/
1096
+ if ($script:globalPluginsDir -and (Test-Path $script:globalPluginsDir)) {
1097
+ Get-ChildItem -Path $script:globalPluginsDir -Directory -ErrorAction SilentlyContinue | ForEach-Object {
1098
+ $manifestPath = Join-Path $_.FullName "fops.plugin.json"
1099
+ if (Test-Path $manifestPath) {
1100
+ try {
1101
+ $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
1102
+ $plugins += [PSCustomObject]@{
1103
+ id = $_.Name
1104
+ name = if ($manifest.name) { $manifest.name } else { $_.Name }
1105
+ source = "global"
1106
+ }
1107
+ } catch {
1108
+ $plugins += [PSCustomObject]@{ id = $_.Name; name = $_.Name; source = "global" }
1109
+ }
1110
+ }
1111
+ }
1112
+ }
1113
+ # NPM plugins: node_modules/fops-plugin-*
1114
+ if ($script:npmPluginsDir -and (Test-Path $script:npmPluginsDir)) {
1115
+ Get-ChildItem -Path $script:npmPluginsDir -Directory -Filter "fops-plugin-*" -ErrorAction SilentlyContinue | ForEach-Object {
1116
+ $manifestPath = Join-Path $_.FullName "fops.plugin.json"
1117
+ if (Test-Path $manifestPath) {
1118
+ try {
1119
+ $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
1120
+ $plugins += [PSCustomObject]@{
1121
+ id = $_.Name
1122
+ name = if ($manifest.name) { $manifest.name } else { $_.Name }
1123
+ source = "npm"
1124
+ }
1125
+ } catch {
1126
+ $plugins += [PSCustomObject]@{ id = $_.Name; name = $_.Name; source = "npm" }
1127
+ }
1128
+ }
1129
+ }
1130
+ }
1131
+ return $plugins
1132
+ }
1049
1133
 
1050
1134
  function Get-Docker {
1051
1135
  foreach ($p in @("C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe")) {
@@ -1156,11 +1240,12 @@ $menu.Items.Add($composeItem) | Out-Null
1156
1240
  $pluginsItem = [System.Windows.Forms.ToolStripMenuItem]::new("Plugins")
1157
1241
  $pluginsItem.add_DropDownOpening({
1158
1242
  $pluginsItem.DropDownItems.Clear()
1159
- if ($script:allPlugins.Count -eq 0) {
1243
+ $currentPlugins = Discover-Plugins
1244
+ if ($currentPlugins.Count -eq 0) {
1160
1245
  $n = [System.Windows.Forms.ToolStripMenuItem]::new("No plugins installed")
1161
1246
  $n.Enabled = $false; $pluginsItem.DropDownItems.Add($n) | Out-Null; return
1162
1247
  }
1163
- foreach ($plugin in $script:allPlugins) {
1248
+ foreach ($plugin in $currentPlugins) {
1164
1249
  $enabled = Read-PluginEnabled $plugin.id
1165
1250
  $prefix = if ($enabled) { [char]0x2713 + " " } else { " " }
1166
1251
  $pItem = [System.Windows.Forms.ToolStripMenuItem]::new($prefix + $plugin.name)
@@ -1235,6 +1320,7 @@ $tray.Visible = $false
1235
1320
  env: trayEnv,
1236
1321
  windowsHide: true,
1237
1322
  });
1323
+ writeFileSync(pidFile, String(winChild.pid));
1238
1324
  winChild.unref();
1239
1325
  return;
1240
1326
  }
@@ -1266,6 +1352,8 @@ func git() -> String {
1266
1352
  let composeRoot = ProcessInfo.processInfo.environment["FOUNDATION_COMPOSE_ROOT"] ?? ""
1267
1353
  let iconPath = ProcessInfo.processInfo.environment["FOUNDATION_ICON"] ?? ""
1268
1354
  let fopsVersion = ProcessInfo.processInfo.environment["FOPS_VERSION"] ?? "0.0.0"
1355
+ let globalPluginsDir = ProcessInfo.processInfo.environment["FOPS_GLOBAL_PLUGINS_DIR"] ?? ""
1356
+ let npmPluginsDir = ProcessInfo.processInfo.environment["FOPS_NPM_PLUGINS_DIR"] ?? ""
1269
1357
 
1270
1358
  // ── Plugin registry ───────────────────────────────────────────────────────────
1271
1359
 
@@ -1275,15 +1363,52 @@ struct PluginEntry {
1275
1363
  let source: String
1276
1364
  }
1277
1365
 
1278
- let allPlugins: [PluginEntry] = {
1279
- let raw = ProcessInfo.processInfo.environment["FOPS_PLUGINS"] ?? "[]"
1280
- guard let data = raw.data(using: .utf8),
1281
- let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
1282
- return arr.compactMap { obj in
1283
- guard let id = obj["id"] as? String, let name = obj["name"] as? String else { return nil }
1284
- return PluginEntry(id: id, name: name, source: (obj["source"] as? String) ?? "")
1366
+ func discoverPlugins() -> [PluginEntry] {
1367
+ var plugins: [PluginEntry] = []
1368
+ let fm = FileManager.default
1369
+
1370
+ // Global plugins: ~/.fops/plugins/<name>/
1371
+ if !globalPluginsDir.isEmpty, fm.fileExists(atPath: globalPluginsDir) {
1372
+ if let dirs = try? fm.contentsOfDirectory(atPath: globalPluginsDir) {
1373
+ for dir in dirs {
1374
+ let pluginPath = (globalPluginsDir as NSString).appendingPathComponent(dir)
1375
+ let manifestPath = (pluginPath as NSString).appendingPathComponent("fops.plugin.json")
1376
+ var isDir: ObjCBool = false
1377
+ guard fm.fileExists(atPath: pluginPath, isDirectory: &isDir), isDir.boolValue,
1378
+ fm.fileExists(atPath: manifestPath) else { continue }
1379
+ var name = dir
1380
+ if let data = try? Data(contentsOf: URL(fileURLWithPath: manifestPath)),
1381
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1382
+ let manifestName = json["name"] as? String {
1383
+ name = manifestName
1384
+ }
1385
+ plugins.append(PluginEntry(id: dir, name: name, source: "global"))
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ // NPM plugins: node_modules/fops-plugin-*
1391
+ if !npmPluginsDir.isEmpty, fm.fileExists(atPath: npmPluginsDir) {
1392
+ if let dirs = try? fm.contentsOfDirectory(atPath: npmPluginsDir) {
1393
+ for dir in dirs where dir.hasPrefix("fops-plugin-") {
1394
+ let pluginPath = (npmPluginsDir as NSString).appendingPathComponent(dir)
1395
+ let manifestPath = (pluginPath as NSString).appendingPathComponent("fops.plugin.json")
1396
+ var isDir: ObjCBool = false
1397
+ guard fm.fileExists(atPath: pluginPath, isDirectory: &isDir), isDir.boolValue,
1398
+ fm.fileExists(atPath: manifestPath) else { continue }
1399
+ var name = dir
1400
+ if let data = try? Data(contentsOf: URL(fileURLWithPath: manifestPath)),
1401
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1402
+ let manifestName = json["name"] as? String {
1403
+ name = manifestName
1404
+ }
1405
+ plugins.append(PluginEntry(id: dir, name: name, source: "npm"))
1406
+ }
1407
+ }
1285
1408
  }
1286
- }()
1409
+
1410
+ return plugins
1411
+ }
1287
1412
 
1288
1413
  func readPluginEnabled(_ id: String) -> Bool {
1289
1414
  let home = ProcessInfo.processInfo.environment["HOME"] ?? ""
@@ -1316,6 +1441,11 @@ func compose(_ args: [String], capture: Bool = false, done: ((String, Bool) -> V
1316
1441
  run(docker(), ["compose"] + args, capture: capture, done: done)
1317
1442
  }
1318
1443
 
1444
+ func fops(_ args: [String], capture: Bool = false, done: ((String, Bool) -> Void)? = nil) {
1445
+ let fopsPath = "/opt/homebrew/bin/fops"
1446
+ run(fopsPath, args, cwd: nil, capture: capture, done: done)
1447
+ }
1448
+
1319
1449
  // ── Service discovery ─────────────────────────────────────────────────────────
1320
1450
 
1321
1451
  struct Service {
@@ -1439,6 +1569,12 @@ let pluginsMenu = NSMenu(title: "Plugins")
1439
1569
  pluginsItem.submenu = pluginsMenu
1440
1570
  menu.addItem(pluginsItem)
1441
1571
 
1572
+ // Azure > VMs / AKS (populated dynamically)
1573
+ let azureItem = NSMenuItem(title: "Azure", action: nil, keyEquivalent: "")
1574
+ let azureMenu = NSMenu(title: "Azure")
1575
+ azureItem.submenu = azureMenu
1576
+ menu.addItem(azureItem)
1577
+
1442
1578
  menu.addItem(NSMenuItem.separator())
1443
1579
 
1444
1580
  let openItem = NSMenuItem(title: "Open Foundation…", action: #selector(AppDelegate.openBrowser), keyEquivalent: "o")
@@ -1474,6 +1610,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1474
1610
  func applicationDidFinishLaunching(_ n: Notification) {
1475
1611
  composeMenu.delegate = self
1476
1612
  pluginsMenu.delegate = pluginsMenuDelegate
1613
+ azureMenu.delegate = self
1477
1614
  refresh()
1478
1615
  let pollTimer = Timer(timeInterval: 8, repeats: true) { _ in self.refresh() }
1479
1616
  RunLoop.main.add(pollTimer, forMode: .common)
@@ -1481,6 +1618,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1481
1618
  checkForUpdate()
1482
1619
  let updateTimer = Timer(timeInterval: 900, repeats: true) { _ in self.checkForUpdate() }
1483
1620
  RunLoop.main.add(updateTimer, forMode: .common)
1621
+ // Preload Azure resources
1622
+ preloadAzureResources()
1484
1623
  }
1485
1624
 
1486
1625
  func checkForUpdate() {
@@ -1536,34 +1675,73 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1536
1675
  RunLoop.main.add(pulse, forMode: .common)
1537
1676
  }
1538
1677
 
1678
+ var updateSpinnerTimer: Timer?
1679
+ var updateSpinnerFrame = 0
1680
+
1681
+ func startUpdateSpinner(item: NSMenuItem, label: String) {
1682
+ item.isEnabled = false
1683
+ updateSpinnerFrame = 0
1684
+ updateSpinnerTimer?.invalidate()
1685
+ let t = Timer(timeInterval: 0.1, repeats: true) { _ in
1686
+ let frame = self.spinnerFrames[self.updateSpinnerFrame % self.spinnerFrames.count]
1687
+ item.title = "\\(frame) \\(label)"
1688
+ self.updateSpinnerFrame += 1
1689
+ }
1690
+ RunLoop.main.add(t, forMode: .common)
1691
+ updateSpinnerTimer = t
1692
+ }
1693
+
1694
+ func stopUpdateSpinner() {
1695
+ updateSpinnerTimer?.invalidate()
1696
+ updateSpinnerTimer = nil
1697
+ }
1698
+
1539
1699
  @objc func checkForUpdateManual() {
1540
- checkUpdateItem.title = "Checking…"
1541
- checkUpdateItem.isEnabled = false
1700
+ startUpdateSpinner(item: checkUpdateItem, label: "Checking for updates…")
1542
1701
  checkForUpdate()
1543
1702
  DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
1703
+ self.stopUpdateSpinner()
1544
1704
  checkUpdateItem.title = "Check for updates"
1545
1705
  checkUpdateItem.isEnabled = true
1546
1706
  }
1547
1707
  }
1548
1708
 
1549
1709
  @objc func runUpdate() {
1550
- updateItem.title = " Updating…"
1551
- updateItem.isEnabled = false
1710
+ startUpdateSpinner(item: updateItem, label: "Updating fops…")
1552
1711
  let npm = "/opt/homebrew/bin/npm"
1553
1712
  let p = Process()
1554
1713
  p.executableURL = URL(fileURLWithPath: npm)
1555
1714
  p.arguments = ["install", "-g", "@meshxdata/fops"]
1556
- p.terminationHandler = { _ in
1715
+ p.terminationHandler = { proc in
1557
1716
  DispatchQueue.main.async {
1558
- updateItem.title = "✓ Updated — restart tray to apply"
1559
- updateItem.isEnabled = false
1717
+ self.stopUpdateSpinner()
1718
+ if proc.terminationStatus == 0 {
1719
+ updateItem.title = "✓ Updated — restarting tray…"
1720
+ updateItem.isEnabled = false
1721
+ // Relaunch tray after short delay
1722
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
1723
+ let fops = "/opt/homebrew/bin/fops"
1724
+ let relaunch = Process()
1725
+ relaunch.executableURL = URL(fileURLWithPath: fops)
1726
+ relaunch.arguments = ["tray"]
1727
+ try? relaunch.run()
1728
+ NSApp.terminate(nil)
1729
+ }
1730
+ } else {
1731
+ updateItem.title = "✗ Update failed"
1732
+ updateItem.isEnabled = true
1733
+ }
1560
1734
  }
1561
1735
  }
1562
1736
  try? p.run()
1563
1737
  }
1564
1738
 
1565
- // Rebuild Compose submenu each time it opens
1739
+ // Rebuild Compose/Azure submenus each time they open
1566
1740
  func menuWillOpen(_ m: NSMenu) {
1741
+ if m === azureMenu {
1742
+ populateAzureMenu(m)
1743
+ return
1744
+ }
1567
1745
  guard m === composeMenu else { return }
1568
1746
  m.removeAllItems()
1569
1747
  let loading = NSMenuItem(title: "Loading services…", action: nil, keyEquivalent: "")
@@ -1639,6 +1817,162 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1639
1817
 
1640
1818
  @objc func noop() {}
1641
1819
 
1820
+ // ── Azure Menu ───────────────────────────────────────────────────────────────
1821
+
1822
+ var azureCache: [String: Any]? = nil
1823
+ var azureCacheTime: Date? = nil
1824
+ let azureCacheTTL: TimeInterval = 120 // 2 minutes
1825
+
1826
+ func preloadAzureResources() {
1827
+ fops(["azure", "list", "--json"], capture: true) { out, success in
1828
+ guard success,
1829
+ let data = out.data(using: .utf8),
1830
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
1831
+ DispatchQueue.main.async {
1832
+ self.azureCache = json
1833
+ self.azureCacheTime = Date()
1834
+ }
1835
+ }
1836
+ }
1837
+
1838
+ func populateAzureMenu(_ m: NSMenu) {
1839
+ // Use cache if fresh
1840
+ if let cache = azureCache,
1841
+ let cacheTime = azureCacheTime,
1842
+ Date().timeIntervalSince(cacheTime) < azureCacheTTL {
1843
+ renderAzureMenu(m, data: cache)
1844
+ return
1845
+ }
1846
+
1847
+ m.removeAllItems()
1848
+ let loading = NSMenuItem(title: "Loading resources…", action: nil, keyEquivalent: "")
1849
+ loading.isEnabled = false
1850
+ m.addItem(loading)
1851
+
1852
+ // Fetch VMs and AKS clusters via fops azure list --json
1853
+ fops(["azure", "list", "--json"], capture: true) { out, success in
1854
+ DispatchQueue.main.async {
1855
+ guard success,
1856
+ let data = out.data(using: .utf8),
1857
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1858
+ m.removeAllItems()
1859
+ let err = NSMenuItem(title: "Not authenticated or plugin disabled", action: nil, keyEquivalent: "")
1860
+ err.isEnabled = false
1861
+ m.addItem(err)
1862
+ return
1863
+ }
1864
+
1865
+ // Update cache
1866
+ self.azureCache = json
1867
+ self.azureCacheTime = Date()
1868
+
1869
+ self.renderAzureMenu(m, data: json)
1870
+ }
1871
+ }
1872
+ }
1873
+
1874
+ func renderAzureMenu(_ m: NSMenu, data: [String: Any]) {
1875
+ m.removeAllItems()
1876
+
1877
+ let vms = data["vms"] as? [[String: Any]] ?? []
1878
+ let clusters = data["clusters"] as? [[String: Any]] ?? []
1879
+
1880
+ // VMs section
1881
+ if !vms.isEmpty {
1882
+ let vmHeader = NSMenuItem(title: "VMs", action: nil, keyEquivalent: "")
1883
+ vmHeader.isEnabled = false
1884
+ m.addItem(vmHeader)
1885
+ for vm in vms {
1886
+ let name = vm["name"] as? String ?? "unknown"
1887
+ let ip = vm["publicIp"] as? String ?? ""
1888
+ let ipSuffix = ip.isEmpty ? "" : " (\\(ip))"
1889
+
1890
+ let item = NSMenuItem(title: " " + name + ipSuffix, action: nil, keyEquivalent: "")
1891
+ let submenu = NSMenu()
1892
+
1893
+ let sshItem = NSMenuItem(title: "SSH", action: #selector(AppDelegate.azureVMSSH(_:)), keyEquivalent: "")
1894
+ sshItem.representedObject = name
1895
+ sshItem.target = self
1896
+ submenu.addItem(sshItem)
1897
+
1898
+ let testItem = NSMenuItem(title: "Run Tests", action: #selector(AppDelegate.azureVMTest(_:)), keyEquivalent: "")
1899
+ testItem.representedObject = name
1900
+ testItem.target = self
1901
+ submenu.addItem(testItem)
1902
+
1903
+ item.submenu = submenu
1904
+ m.addItem(item)
1905
+ }
1906
+ }
1907
+
1908
+ // AKS section
1909
+ if !clusters.isEmpty {
1910
+ if !vms.isEmpty { m.addItem(NSMenuItem.separator()) }
1911
+ let aksHeader = NSMenuItem(title: "AKS Clusters", action: nil, keyEquivalent: "")
1912
+ aksHeader.isEnabled = false
1913
+ m.addItem(aksHeader)
1914
+ for cluster in clusters {
1915
+ let name = cluster["name"] as? String ?? "unknown"
1916
+ let state = cluster["provisioningState"] as? String ?? ""
1917
+ let dot = state.lowercased() == "succeeded" ? "● " : "○ "
1918
+
1919
+ let item = NSMenuItem(title: " " + dot + name, action: nil, keyEquivalent: "")
1920
+ let submenu = NSMenu()
1921
+
1922
+ let kubeconfigItem = NSMenuItem(title: "Kubeconfig", action: #selector(AppDelegate.azureAKSKubeconfig(_:)), keyEquivalent: "")
1923
+ kubeconfigItem.representedObject = name
1924
+ kubeconfigItem.target = self
1925
+ submenu.addItem(kubeconfigItem)
1926
+
1927
+ let testItem = NSMenuItem(title: "Run Tests", action: #selector(AppDelegate.azureAKSTest(_:)), keyEquivalent: "")
1928
+ testItem.representedObject = name
1929
+ testItem.target = self
1930
+ submenu.addItem(testItem)
1931
+
1932
+ submenu.addItem(NSMenuItem.separator())
1933
+
1934
+ let statusItem = NSMenuItem(title: "Status", action: #selector(AppDelegate.azureAKSStatus(_:)), keyEquivalent: "")
1935
+ statusItem.representedObject = name
1936
+ statusItem.target = self
1937
+ submenu.addItem(statusItem)
1938
+
1939
+ item.submenu = submenu
1940
+ m.addItem(item)
1941
+ }
1942
+ }
1943
+
1944
+ if vms.isEmpty && clusters.isEmpty {
1945
+ let none = NSMenuItem(title: "No resources found", action: nil, keyEquivalent: "")
1946
+ none.isEnabled = false
1947
+ m.addItem(none)
1948
+ }
1949
+ }
1950
+
1951
+ @objc func azureVMSSH(_ sender: NSMenuItem) {
1952
+ guard let name = sender.representedObject as? String else { return }
1953
+ openTerminal(command: "fops azure ssh \\(name)")
1954
+ }
1955
+
1956
+ @objc func azureVMTest(_ sender: NSMenuItem) {
1957
+ guard let name = sender.representedObject as? String else { return }
1958
+ openTerminal(command: "fops azure test \\(name)")
1959
+ }
1960
+
1961
+ @objc func azureAKSKubeconfig(_ sender: NSMenuItem) {
1962
+ guard let name = sender.representedObject as? String else { return }
1963
+ openTerminal(command: "fops azure aks kubeconfig \\(name)")
1964
+ }
1965
+
1966
+ @objc func azureAKSTest(_ sender: NSMenuItem) {
1967
+ guard let name = sender.representedObject as? String else { return }
1968
+ openTerminal(command: "fops azure aks test \\(name)")
1969
+ }
1970
+
1971
+ @objc func azureAKSStatus(_ sender: NSMenuItem) {
1972
+ guard let name = sender.representedObject as? String else { return }
1973
+ openTerminal(command: "fops azure aks status \\(name)")
1974
+ }
1975
+
1642
1976
  // ── Stack-level actions ───────────────────────────────────────────────────
1643
1977
 
1644
1978
  func refresh() {
@@ -1946,17 +2280,19 @@ class BranchMenuDelegate: NSObject, NSMenuDelegate {
1946
2280
 
1947
2281
  // ── PluginMenuDelegate ────────────────────────────────────────────────────────
1948
2282
  // Populates the Plugins submenu lazily; clicking a plugin item toggles it.
2283
+ // Discovers plugins dynamically each time the menu opens.
1949
2284
 
1950
2285
  class PluginMenuDelegate: NSObject, NSMenuDelegate {
1951
2286
  func menuWillOpen(_ menu: NSMenu) {
1952
2287
  menu.removeAllItems()
1953
- if allPlugins.isEmpty {
2288
+ let currentPlugins = discoverPlugins()
2289
+ if currentPlugins.isEmpty {
1954
2290
  let none = NSMenuItem(title: "No plugins installed", action: nil, keyEquivalent: "")
1955
2291
  none.isEnabled = false
1956
2292
  menu.addItem(none)
1957
2293
  return
1958
2294
  }
1959
- for plugin in allPlugins {
2295
+ for plugin in currentPlugins {
1960
2296
  let enabled = readPluginEnabled(plugin.id)
1961
2297
  let prefix = enabled ? "✓ " : " "
1962
2298
  let item = NSMenuItem(
@@ -1978,16 +2314,6 @@ app.delegate = delegate
1978
2314
  app.run()
1979
2315
  `);
1980
2316
 
1981
- const trayEnv = {
1982
- ...process.env,
1983
- FOUNDATION_API_URL: apiUrl,
1984
- FOUNDATION_URL: "http://127.0.0.1:3002",
1985
- FOUNDATION_ICON: iconPath,
1986
- FOUNDATION_COMPOSE_ROOT: composeRoot,
1987
- FOPS_VERSION: fopsVersion,
1988
- FOPS_PLUGINS: fopsPluginsJson,
1989
- };
1990
-
1991
2317
  console.log(ACCENT(" Foundation tray started — look for the icon in your menu bar"));
1992
2318
 
1993
2319
  const child = spawn("swift", [swiftTray], {
@@ -1995,6 +2321,7 @@ app.run()
1995
2321
  detached: true,
1996
2322
  env: trayEnv,
1997
2323
  });
2324
+ writeFileSync(pidFile, String(child.pid));
1998
2325
  child.unref();
1999
2326
  });
2000
2327
  });