@meshxdata/fops 0.1.49 → 0.1.50

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 +182 -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 +309 -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)
@@ -1536,34 +1673,73 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1536
1673
  RunLoop.main.add(pulse, forMode: .common)
1537
1674
  }
1538
1675
 
1676
+ var updateSpinnerTimer: Timer?
1677
+ var updateSpinnerFrame = 0
1678
+
1679
+ func startUpdateSpinner(item: NSMenuItem, label: String) {
1680
+ item.isEnabled = false
1681
+ updateSpinnerFrame = 0
1682
+ updateSpinnerTimer?.invalidate()
1683
+ let t = Timer(timeInterval: 0.1, repeats: true) { _ in
1684
+ let frame = self.spinnerFrames[self.updateSpinnerFrame % self.spinnerFrames.count]
1685
+ item.title = "\\(frame) \\(label)"
1686
+ self.updateSpinnerFrame += 1
1687
+ }
1688
+ RunLoop.main.add(t, forMode: .common)
1689
+ updateSpinnerTimer = t
1690
+ }
1691
+
1692
+ func stopUpdateSpinner() {
1693
+ updateSpinnerTimer?.invalidate()
1694
+ updateSpinnerTimer = nil
1695
+ }
1696
+
1539
1697
  @objc func checkForUpdateManual() {
1540
- checkUpdateItem.title = "Checking…"
1541
- checkUpdateItem.isEnabled = false
1698
+ startUpdateSpinner(item: checkUpdateItem, label: "Checking for updates…")
1542
1699
  checkForUpdate()
1543
1700
  DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
1701
+ self.stopUpdateSpinner()
1544
1702
  checkUpdateItem.title = "Check for updates"
1545
1703
  checkUpdateItem.isEnabled = true
1546
1704
  }
1547
1705
  }
1548
1706
 
1549
1707
  @objc func runUpdate() {
1550
- updateItem.title = " Updating…"
1551
- updateItem.isEnabled = false
1708
+ startUpdateSpinner(item: updateItem, label: "Updating fops…")
1552
1709
  let npm = "/opt/homebrew/bin/npm"
1553
1710
  let p = Process()
1554
1711
  p.executableURL = URL(fileURLWithPath: npm)
1555
1712
  p.arguments = ["install", "-g", "@meshxdata/fops"]
1556
- p.terminationHandler = { _ in
1713
+ p.terminationHandler = { proc in
1557
1714
  DispatchQueue.main.async {
1558
- updateItem.title = "✓ Updated — restart tray to apply"
1559
- updateItem.isEnabled = false
1715
+ self.stopUpdateSpinner()
1716
+ if proc.terminationStatus == 0 {
1717
+ updateItem.title = "✓ Updated — restarting tray…"
1718
+ updateItem.isEnabled = false
1719
+ // Relaunch tray after short delay
1720
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
1721
+ let fops = "/opt/homebrew/bin/fops"
1722
+ let relaunch = Process()
1723
+ relaunch.executableURL = URL(fileURLWithPath: fops)
1724
+ relaunch.arguments = ["tray"]
1725
+ try? relaunch.run()
1726
+ NSApp.terminate(nil)
1727
+ }
1728
+ } else {
1729
+ updateItem.title = "✗ Update failed"
1730
+ updateItem.isEnabled = true
1731
+ }
1560
1732
  }
1561
1733
  }
1562
1734
  try? p.run()
1563
1735
  }
1564
1736
 
1565
- // Rebuild Compose submenu each time it opens
1737
+ // Rebuild Compose/Azure submenus each time they open
1566
1738
  func menuWillOpen(_ m: NSMenu) {
1739
+ if m === azureMenu {
1740
+ populateAzureMenu(m)
1741
+ return
1742
+ }
1567
1743
  guard m === composeMenu else { return }
1568
1744
  m.removeAllItems()
1569
1745
  let loading = NSMenuItem(title: "Loading services…", action: nil, keyEquivalent: "")
@@ -1639,6 +1815,102 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1639
1815
 
1640
1816
  @objc func noop() {}
1641
1817
 
1818
+ // ── Azure Menu ───────────────────────────────────────────────────────────────
1819
+
1820
+ var azureCache: [String: Any]? = nil
1821
+ var azureCacheTime: Date? = nil
1822
+ let azureCacheTTL: TimeInterval = 60 // 60 seconds
1823
+
1824
+ func populateAzureMenu(_ m: NSMenu) {
1825
+ // Use cache if fresh
1826
+ if let cache = azureCache,
1827
+ let cacheTime = azureCacheTime,
1828
+ Date().timeIntervalSince(cacheTime) < azureCacheTTL {
1829
+ renderAzureMenu(m, data: cache)
1830
+ return
1831
+ }
1832
+
1833
+ m.removeAllItems()
1834
+ let loading = NSMenuItem(title: "Loading resources…", action: nil, keyEquivalent: "")
1835
+ loading.isEnabled = false
1836
+ m.addItem(loading)
1837
+
1838
+ // Fetch VMs and AKS clusters via fops azure list --json
1839
+ fops(["azure", "list", "--json"], capture: true) { out, success in
1840
+ guard success,
1841
+ let data = out.data(using: .utf8),
1842
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1843
+ m.removeAllItems()
1844
+ let err = NSMenuItem(title: "Not authenticated or plugin disabled", action: nil, keyEquivalent: "")
1845
+ err.isEnabled = false
1846
+ m.addItem(err)
1847
+ return
1848
+ }
1849
+
1850
+ // Update cache
1851
+ self.azureCache = json
1852
+ self.azureCacheTime = Date()
1853
+
1854
+ self.renderAzureMenu(m, data: json)
1855
+ }
1856
+ }
1857
+
1858
+ func renderAzureMenu(_ m: NSMenu, data: [String: Any]) {
1859
+ m.removeAllItems()
1860
+
1861
+ let vms = data["vms"] as? [[String: Any]] ?? []
1862
+ let clusters = data["clusters"] as? [[String: Any]] ?? []
1863
+
1864
+ // VMs section
1865
+ if !vms.isEmpty {
1866
+ let vmHeader = NSMenuItem(title: "VMs", action: nil, keyEquivalent: "")
1867
+ vmHeader.isEnabled = false
1868
+ m.addItem(vmHeader)
1869
+ for vm in vms {
1870
+ let name = vm["name"] as? String ?? "unknown"
1871
+ let ip = vm["publicIp"] as? String ?? ""
1872
+ let ipSuffix = ip.isEmpty ? "" : " (\\(ip))"
1873
+ let item = NSMenuItem(title: " " + name + ipSuffix, action: #selector(AppDelegate.openAzureVM(_:)), keyEquivalent: "")
1874
+ item.representedObject = name
1875
+ item.target = self
1876
+ m.addItem(item)
1877
+ }
1878
+ }
1879
+
1880
+ // AKS section
1881
+ if !clusters.isEmpty {
1882
+ if !vms.isEmpty { m.addItem(NSMenuItem.separator()) }
1883
+ let aksHeader = NSMenuItem(title: "AKS Clusters", action: nil, keyEquivalent: "")
1884
+ aksHeader.isEnabled = false
1885
+ m.addItem(aksHeader)
1886
+ for cluster in clusters {
1887
+ let name = cluster["name"] as? String ?? "unknown"
1888
+ let state = cluster["provisioningState"] as? String ?? ""
1889
+ let dot = state.lowercased() == "succeeded" ? "● " : "○ "
1890
+ let item = NSMenuItem(title: " " + dot + name, action: #selector(AppDelegate.openAzureAKS(_:)), keyEquivalent: "")
1891
+ item.representedObject = name
1892
+ item.target = self
1893
+ m.addItem(item)
1894
+ }
1895
+ }
1896
+
1897
+ if vms.isEmpty && clusters.isEmpty {
1898
+ let none = NSMenuItem(title: "No resources found", action: nil, keyEquivalent: "")
1899
+ none.isEnabled = false
1900
+ m.addItem(none)
1901
+ }
1902
+ }
1903
+
1904
+ @objc func openAzureVM(_ sender: NSMenuItem) {
1905
+ guard let name = sender.representedObject as? String else { return }
1906
+ openTerminal(command: "fops azure ssh \\(name)")
1907
+ }
1908
+
1909
+ @objc func openAzureAKS(_ sender: NSMenuItem) {
1910
+ guard let name = sender.representedObject as? String else { return }
1911
+ openTerminal(command: "fops azure aks status \\(name)")
1912
+ }
1913
+
1642
1914
  // ── Stack-level actions ───────────────────────────────────────────────────
1643
1915
 
1644
1916
  func refresh() {
@@ -1946,17 +2218,19 @@ class BranchMenuDelegate: NSObject, NSMenuDelegate {
1946
2218
 
1947
2219
  // ── PluginMenuDelegate ────────────────────────────────────────────────────────
1948
2220
  // Populates the Plugins submenu lazily; clicking a plugin item toggles it.
2221
+ // Discovers plugins dynamically each time the menu opens.
1949
2222
 
1950
2223
  class PluginMenuDelegate: NSObject, NSMenuDelegate {
1951
2224
  func menuWillOpen(_ menu: NSMenu) {
1952
2225
  menu.removeAllItems()
1953
- if allPlugins.isEmpty {
2226
+ let currentPlugins = discoverPlugins()
2227
+ if currentPlugins.isEmpty {
1954
2228
  let none = NSMenuItem(title: "No plugins installed", action: nil, keyEquivalent: "")
1955
2229
  none.isEnabled = false
1956
2230
  menu.addItem(none)
1957
2231
  return
1958
2232
  }
1959
- for plugin in allPlugins {
2233
+ for plugin in currentPlugins {
1960
2234
  let enabled = readPluginEnabled(plugin.id)
1961
2235
  let prefix = enabled ? "✓ " : " "
1962
2236
  let item = NSMenuItem(
@@ -1978,16 +2252,6 @@ app.delegate = delegate
1978
2252
  app.run()
1979
2253
  `);
1980
2254
 
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
2255
  console.log(ACCENT(" Foundation tray started — look for the icon in your menu bar"));
1992
2256
 
1993
2257
  const child = spawn("swift", [swiftTray], {
@@ -1995,6 +2259,7 @@ app.run()
1995
2259
  detached: true,
1996
2260
  env: trayEnv,
1997
2261
  });
2262
+ writeFileSync(pidFile, String(child.pid));
1998
2263
  child.unref();
1999
2264
  });
2000
2265
  });