@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.
- package/CHANGELOG.md +368 -0
- package/package.json +1 -1
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +347 -6
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-data-bootstrap.js +421 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +5 -179
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +14 -4
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +171 -4
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +303 -8
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +2 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +1 -1
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet-swarm.js +936 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet.js +10 -918
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +5 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault-keys.js +413 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +14 -399
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-config.js +754 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-knock.js +527 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-ssh.js +427 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +99 -1686
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-health.js +279 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +186 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +66 -444
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +11 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-lifecycle.js +5 -540
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-terraform.js +544 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +75 -3
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +227 -11
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +2 -1
- package/src/plugins/bundled/fops-plugin-azure/lib/pytest-parse.js +21 -0
- 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
|
-
//
|
|
1025
|
-
|
|
1059
|
+
// Plugin discovery paths - tray will discover plugins dynamically
|
|
1060
|
+
const globalPluginsDir = join(homedir(), ".fops", "plugins");
|
|
1061
|
+
let npmPluginsDir = "";
|
|
1026
1062
|
try {
|
|
1027
|
-
const
|
|
1028
|
-
const
|
|
1029
|
-
|
|
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:
|
|
1048
|
-
|
|
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
|
-
|
|
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 $
|
|
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
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
1715
|
+
p.terminationHandler = { proc in
|
|
1557
1716
|
DispatchQueue.main.async {
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
});
|