@meshxdata/fops 0.1.51 → 0.1.53

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 (90) hide show
  1. package/CHANGELOG.md +207 -21
  2. package/package.json +2 -6
  3. package/src/agent/agent.js +6 -0
  4. package/src/commands/setup.js +34 -0
  5. package/src/doctor.js +11 -8
  6. package/src/fleet-registry.js +38 -2
  7. package/src/plugins/__test-fixtures__/fake-plugin.js +2 -0
  8. package/src/plugins/__test-fixtures__/no-register-plugin.js +2 -0
  9. package/src/plugins/__test-fixtures__/with-register/index.js +2 -0
  10. package/src/plugins/__test-fixtures__/without-register/index.js +2 -0
  11. package/src/plugins/api.js +4 -0
  12. package/src/plugins/builtins/docker-compose.js +59 -0
  13. package/src/plugins/bundled/fops-plugin-azure/index.js +4 -0
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +53 -53
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-secrets.js +151 -0
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +2 -2
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-cost.js +52 -22
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet.js +12 -4
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +6 -2
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +113 -7
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +13 -4
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +91 -14
  23. package/src/plugins/bundled/fops-plugin-azure/lib/azure-service.js +507 -0
  24. package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +146 -7
  25. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
  26. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +28 -0
  27. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +61 -0
  28. package/src/plugins/bundled/fops-plugin-cloud/api.js +712 -0
  29. package/src/plugins/bundled/fops-plugin-cloud/fops.plugin.json +6 -0
  30. package/src/plugins/bundled/fops-plugin-cloud/index.js +208 -0
  31. package/src/plugins/bundled/fops-plugin-cloud/lib/azure-provider.js +81 -0
  32. package/src/plugins/bundled/fops-plugin-cloud/lib/provider.js +50 -0
  33. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/favicon-C49brna2.svg +15 -0
  34. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-CVqQ_kKW.js +65 -0
  35. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-DZetahP3.css +1 -0
  36. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/index.html +28 -0
  37. package/src/plugins/bundled/fops-plugin-cloud/ui/index.html +27 -0
  38. package/src/plugins/bundled/fops-plugin-cloud/ui/package-lock.json +2634 -0
  39. package/src/plugins/bundled/fops-plugin-cloud/ui/package.json +29 -0
  40. package/src/plugins/bundled/fops-plugin-cloud/ui/postcss.config.cjs +5 -0
  41. package/src/plugins/bundled/fops-plugin-cloud/ui/src/App.jsx +32 -0
  42. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/client.js +114 -0
  43. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/queries.js +111 -0
  44. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/LogPanel.jsx +162 -0
  45. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/ThemeToggle.jsx +46 -0
  46. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/additional-styles/utility-patterns.css +147 -0
  47. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/style.css +138 -0
  48. package/src/plugins/bundled/fops-plugin-cloud/ui/src/favicon.svg +15 -0
  49. package/src/plugins/bundled/fops-plugin-cloud/ui/src/lib/utils.ts +19 -0
  50. package/src/plugins/bundled/fops-plugin-cloud/ui/src/main.jsx +25 -0
  51. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Audit.jsx +164 -0
  52. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Costs.jsx +305 -0
  53. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/CreateResource.jsx +285 -0
  54. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Fleet.jsx +307 -0
  55. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Resources.jsx +229 -0
  56. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Header.jsx +132 -0
  57. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Sidebar.jsx +174 -0
  58. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/SidebarLinkGroup.jsx +21 -0
  59. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/AuthContext.jsx +170 -0
  60. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Info.jsx +49 -0
  61. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/ThemeContext.jsx +37 -0
  62. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Transition.jsx +116 -0
  63. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Utils.js +63 -0
  64. package/src/plugins/bundled/fops-plugin-cloud/ui/vite.config.js +23 -0
  65. package/src/plugins/bundled/fops-plugin-foundation/test-helpers.js +65 -0
  66. package/src/plugins/loader.js +34 -1
  67. package/src/plugins/registry.js +15 -0
  68. package/src/plugins/schemas.js +17 -0
  69. package/src/project.js +1 -1
  70. package/src/serve.js +196 -2
  71. package/src/shell.js +21 -1
  72. package/src/web/admin.html.js +236 -0
  73. package/src/web/api.js +73 -0
  74. package/src/web/dist/assets/index-BphVaAUd.css +1 -0
  75. package/src/web/dist/assets/index-CSckLzuG.js +129 -0
  76. package/src/web/dist/index.html +2 -2
  77. package/src/web/frontend/index.html +16 -0
  78. package/src/web/frontend/src/App.jsx +445 -0
  79. package/src/web/frontend/src/components/ChatView.jsx +910 -0
  80. package/src/web/frontend/src/components/InputBox.jsx +523 -0
  81. package/src/web/frontend/src/components/Sidebar.jsx +410 -0
  82. package/src/web/frontend/src/components/StatusBar.jsx +37 -0
  83. package/src/web/frontend/src/components/TabBar.jsx +87 -0
  84. package/src/web/frontend/src/hooks/useWebSocket.js +412 -0
  85. package/src/web/frontend/src/index.css +78 -0
  86. package/src/web/frontend/src/main.jsx +6 -0
  87. package/src/web/frontend/vite.config.js +21 -0
  88. package/src/web/server.js +64 -1
  89. package/src/web/dist/assets/index-NXC8Hvnp.css +0 -1
  90. package/src/web/dist/assets/index-QH1N4ejK.js +0 -112
@@ -1,4 +1,7 @@
1
1
  import chalk from "chalk";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
2
5
  import {
3
6
  DEFAULTS, DIM, OK, WARN, ERR, LABEL,
4
7
  banner, hint, kvLine,
@@ -24,7 +27,7 @@ const SVC_MAP = {
24
27
  "foundation-storage-engine": "se",
25
28
  };
26
29
 
27
- // Parse "foundation-backend=compose|||a1b2c3d4e5f6" → { be: { tag: "compose", id: "a1b2c3d" } }
30
+ // Parse "foundation-backend=ghcr.io/…:tag|||sha256:abc…" → { be: { tag: "latest", sha: "a1b2c3d" } }
28
31
  export function parseServiceVersions(raw) {
29
32
  if (!raw?.trim()) return {};
30
33
  const versions = {};
@@ -39,8 +42,8 @@ export function parseServiceVersions(raw) {
39
42
  const [imagePart = "", idPart = ""] = rest.split("|||");
40
43
  const colon = imagePart.lastIndexOf(":");
41
44
  const tag = colon >= 0 ? imagePart.slice(colon + 1) : imagePart;
42
- const id = idPart.trim().slice(0, 7);
43
- versions[short] = id || tag;
45
+ const sha = idPart.trim().slice(0, 7);
46
+ versions[short] = { tag: tag || null, sha: sha || null };
44
47
  }
45
48
  return versions;
46
49
  }
@@ -196,9 +199,13 @@ async function syncVms(execa) {
196
199
  "echo '___SHA___'",
197
200
  "git rev-parse --short HEAD 2>/dev/null || echo unknown",
198
201
  "echo '___VER___'",
199
- "docker inspect --format '{{.Name}}={{.Config.Image}}|||{{slice .Image 7 19}}'" +
200
- " foundation-backend foundation-frontend foundation-processor" +
201
- " foundation-watcher foundation-scheduler foundation-storage-engine 2>/dev/null || true",
202
+ // Use docker compose ps to resolve actual container names (handles project-name prefixes)
203
+ "for svc in foundation-backend foundation-frontend foundation-processor foundation-watcher foundation-scheduler foundation-storage-engine; do" +
204
+ " cid=$(docker compose ps -q $svc 2>/dev/null | head -1);" +
205
+ " if [ -n \"$cid\" ]; then docker inspect --format \"$svc={{.Config.Image}}|||{{slice .Image 7 19}}\" $cid 2>/dev/null; fi;" +
206
+ " done",
207
+ "echo '___FLAGS___'",
208
+ "grep -h 'MX_FF_' /opt/foundation-compose/docker-compose.yaml 2>/dev/null | sed 's/.*- //' | sort -u || true",
202
209
  ].join(" && "),
203
210
  20000,
204
211
  );
@@ -211,7 +218,8 @@ async function syncVms(execa) {
211
218
 
212
219
  const [containerPart = "", rest = ""] = stdout.split("___BR___");
213
220
  const [branchRaw = "", afterBranch = ""] = rest.split("___SHA___");
214
- const [shaRaw = "", verRaw = ""] = afterBranch.split("___VER___");
221
+ const [shaRaw = "", afterSha = ""] = afterBranch.split("___VER___");
222
+ const [verRaw = "", flagsRaw = ""] = afterSha.split("___FLAGS___");
215
223
  entry.branch = branchRaw.trim() || "unknown";
216
224
  entry.sha = shaRaw.trim() || "unknown";
217
225
 
@@ -226,6 +234,16 @@ async function syncVms(execa) {
226
234
 
227
235
  // Parse service image tags: "foundation-backend=ghcr.io/…:tag" → { be: "tag", … }
228
236
  entry.services = parseServiceVersions(verRaw);
237
+
238
+ // Parse feature flags: "MX_FF_NAME=true" lines
239
+ if (flagsRaw.trim()) {
240
+ const flags = {};
241
+ for (const line of flagsRaw.trim().split("\n")) {
242
+ const match = line.match(/^(MX_FF_\w+)=(.+)/);
243
+ if (match) flags[match[1]] = match[2].trim().replace(/['"]/g, "") === "true";
244
+ }
245
+ if (Object.keys(flags).length > 0) entry.flags = flags;
246
+ }
229
247
  } catch {
230
248
  entry.status = "error";
231
249
  }
@@ -236,6 +254,73 @@ async function syncVms(execa) {
236
254
  return results;
237
255
  }
238
256
 
257
+ // ── Flux overlay version resolver ────────────────────────────────────────────
258
+
259
+ const AKS_SVC_DIRS = {
260
+ backend: "be",
261
+ frontend: "fe",
262
+ processor: "pr",
263
+ watcher: "wa",
264
+ scheduler: "sc",
265
+ "storage-engine": "se",
266
+ };
267
+
268
+ /**
269
+ * Resolve the flux repo directory from projectRoot or known locations.
270
+ */
271
+ function findFluxRepo() {
272
+ // Check projectRoot sibling (../flux relative to foundation-compose)
273
+ try {
274
+ const config = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".fops.json"), "utf8"));
275
+ if (config.projectRoot) {
276
+ const sibling = path.join(path.dirname(config.projectRoot), "flux");
277
+ if (fs.existsSync(path.join(sibling, "apps"))) return sibling;
278
+ }
279
+ } catch {}
280
+ return null;
281
+ }
282
+
283
+ /**
284
+ * Extract image tags from flux overlay kustomization files for a cluster.
285
+ * Looks for overlays matching <clusterName>-azure under each service.
286
+ */
287
+ function getFluxServiceVersions(clusterName) {
288
+ const fluxDir = findFluxRepo();
289
+ if (!fluxDir) return null;
290
+
291
+ const appsDir = path.join(fluxDir, "apps", "foundation");
292
+ if (!fs.existsSync(appsDir)) return null;
293
+
294
+ const versions = {};
295
+ const overlayName = `${clusterName}-azure`;
296
+
297
+ for (const [svcDir, shortKey] of Object.entries(AKS_SVC_DIRS)) {
298
+ // Search all overlay groups (meshx, customers, etc.)
299
+ const overlaysRoot = path.join(appsDir, svcDir, "overlays");
300
+ if (!fs.existsSync(overlaysRoot)) continue;
301
+
302
+ let found = false;
303
+ try {
304
+ for (const group of fs.readdirSync(overlaysRoot)) {
305
+ const kustomPath = path.join(overlaysRoot, group, overlayName, "kustomization.yaml");
306
+ if (!fs.existsSync(kustomPath)) continue;
307
+
308
+ const content = fs.readFileSync(kustomPath, "utf8");
309
+ // Extract image tag from "tag: v0.3.28" in the kustomization patch
310
+ const tagMatch = content.match(/\btag:\s*(\S+)/);
311
+ if (tagMatch) {
312
+ versions[shortKey] = { tag: tagMatch[1], sha: null };
313
+ found = true;
314
+ break;
315
+ }
316
+ }
317
+ } catch {}
318
+ if (!found) versions[shortKey] = null;
319
+ }
320
+
321
+ return Object.values(versions).some(v => v) ? versions : null;
322
+ }
323
+
239
324
  // ── Sync: probe AKS clusters ─────────────────────────────────────────────────
240
325
 
241
326
  async function syncClusters(execa) {
@@ -277,6 +362,60 @@ async function syncClusters(execa) {
277
362
  entry.status = "probe-failed";
278
363
  }
279
364
 
365
+ // HA pairing: infer from naming convention
366
+ const isStandby = name.endsWith("-standby");
367
+ if (isStandby) {
368
+ const primaryName = name.replace(/-standby$/, "");
369
+ if (names.includes(primaryName)) {
370
+ entry.isStandby = true;
371
+ entry.primaryCluster = primaryName;
372
+ }
373
+ } else {
374
+ const standbyName = `${name}-standby`;
375
+ if (names.includes(standbyName)) {
376
+ entry.ha = { standbyCluster: standbyName, standbyRegion: clusters[standbyName]?.location || null };
377
+ }
378
+ }
379
+
380
+ // Discover Key Vault and Storage Account in the resource group
381
+ try {
382
+ const rg = cl.resourceGroup;
383
+ const subArgs = cl.subscriptionId ? ["--subscription", cl.subscriptionId] : [];
384
+
385
+ const [kvResult, saResult, pgResult] = await Promise.all([
386
+ execa("az", ["keyvault", "list", "-g", rg, "--query", "[].{name:name}", "-o", "json", ...subArgs], { timeout: 15000, reject: false }),
387
+ execa("az", ["storage", "account", "list", "-g", rg, "--query", "[].{name:name,location:location}", "-o", "json", ...subArgs], { timeout: 15000, reject: false }),
388
+ execa("az", ["postgres", "flexible-server", "list", "-g", rg, "--query", "[].{name:name,fqdn:fullyQualifiedDomainName}", "-o", "json", ...subArgs], { timeout: 15000, reject: false }),
389
+ ]);
390
+
391
+ if (kvResult.exitCode === 0 && kvResult.stdout?.trim()) {
392
+ const vaults = JSON.parse(kvResult.stdout);
393
+ if (vaults.length > 0) entry.vault = { keyVaultName: vaults[0].name };
394
+ }
395
+ if (saResult.exitCode === 0 && saResult.stdout?.trim()) {
396
+ const accounts = JSON.parse(saResult.stdout);
397
+ if (accounts.length > 0) {
398
+ entry.storageAccount = accounts[0].name;
399
+ if (accounts.length > 1) {
400
+ entry.storageHA = { sourceAccount: accounts[0].name, destAccount: accounts[1].name, destRegion: accounts[1].location };
401
+ }
402
+ }
403
+ }
404
+ if (pgResult.exitCode === 0 && pgResult.stdout?.trim()) {
405
+ const servers = JSON.parse(pgResult.stdout);
406
+ if (servers.length > 0) entry.postgres = { serverName: servers[0].name, fqdn: servers[0].fqdn };
407
+ }
408
+ } catch { /* resource discovery is best-effort */ }
409
+
410
+ // Extract service image tags from flux overlays.
411
+ // Standbys share the primary's overlay.
412
+ let fluxVersions = getFluxServiceVersions(name);
413
+ if (!fluxVersions && isStandby) {
414
+ const primaryName = name.replace(/-standby$/, "");
415
+ fluxVersions = getFluxServiceVersions(primaryName);
416
+ }
417
+ if (fluxVersions) entry.services = fluxVersions;
418
+
280
419
  results[name] = entry;
281
420
  }));
282
421
 
@@ -46,7 +46,7 @@ export {
46
46
 
47
47
  // ── VM operations ────────────────────────────────────────────────────────────
48
48
  export {
49
- azureStatus, azureTrinoStatus, azureSsh, azureSshWhitelistMe, azurePortForward, azureSshAdminAdd, azureVmCheck, azureAgent, azureOpenAiDebugVm,
49
+ azureStatus, azureTrinoStatus, azurePing, azureSsh, azureSshWhitelistMe, azurePortForward, azureSshAdminAdd, azureVmCheck, azureAgent, azureOpenAiDebugVm,
50
50
  azureDeploy, azurePull, azureDeployVersion, azureRunUp, azureConfig, azureConfigVersions, azureUpdate,
51
51
  azureLogs, azureRestart, azureGrantAdmin, azureContext,
52
52
  azureList, azureApply,
@@ -14,6 +14,8 @@ export function registerTestCommands(azure) {
14
14
  .command("run [name]", { isDefault: true })
15
15
  .description("Run QA automation tests locally against a remote VM")
16
16
  .option("--vm-name <name>", "Target VM (default: active)")
17
+ .option("--landscape <file>", "Apply landscape file (FCL/HCL/YAML) before running tests")
18
+ .option("--landscape-template <name>", "Use built-in landscape template (demo, pipeline_demo)")
17
19
  .action(async (name, opts) => {
18
20
  const { resolveCliSrc, lazyExeca, ensureAzCli, ensureAzAuth, resolvePublicIp } = await import("../azure-helpers.js");
19
21
  const { requireVmState, knockForVm, sshCmd, MUX_OPTS } = await import("../azure.js");
@@ -46,6 +48,32 @@ export function registerTestCommands(azure) {
46
48
  process.exit(1);
47
49
  }
48
50
 
51
+ // Apply landscape if specified
52
+ let landscapeFile = opts.landscape;
53
+ if (!landscapeFile && opts.landscapeTemplate) {
54
+ const templateDir = path.join(root, "operator-cli/src/plugins/bundled/fops-plugin-foundation/templates/landscapes");
55
+ const templateName = opts.landscapeTemplate.endsWith(".fcl") ? opts.landscapeTemplate : `${opts.landscapeTemplate}.fcl`;
56
+ landscapeFile = path.join(templateDir, templateName);
57
+ try {
58
+ await fsp.access(landscapeFile);
59
+ } catch {
60
+ console.error(chalk.red(`\n Landscape template not found: ${templateName}`));
61
+ console.error(chalk.dim(` Available: demo.fcl, pipeline_demo.fcl\n`));
62
+ process.exit(1);
63
+ }
64
+ }
65
+ if (landscapeFile) {
66
+ console.log(chalk.cyan(`\n Applying landscape: ${path.basename(landscapeFile)}…\n`));
67
+ const { azureApply } = await import("../azure.js");
68
+ try {
69
+ await azureApply(landscapeFile, { vmName: state.vmName });
70
+ console.log(chalk.green(" ✓ Landscape applied\n"));
71
+ } catch (err) {
72
+ console.error(chalk.red(`\n Failed to apply landscape: ${err.message}\n`));
73
+ process.exit(1);
74
+ }
75
+ }
76
+
49
77
  const vmUrl = state.publicUrl || `https://${ip}`;
50
78
  const apiUrl = `${vmUrl}/api`;
51
79
 
@@ -248,6 +248,55 @@ export function registerVmCommands(azure, api, registry) {
248
248
  console.log();
249
249
  });
250
250
 
251
+ // ── reconcile ───────────────────────────────────────────────────────────
252
+ azure
253
+ .command("reconcile [name]")
254
+ .description("Reconcile an existing VM — fix drift without full provisioning (DNS, env, repo, services)")
255
+ .option("--profile <subscription>", "Azure subscription name or ID")
256
+ .option("--url <url>", "Public URL override (default: from tracked state)")
257
+ .option("--cf-token <token>", "Cloudflare API token for DNS (default: $CLOUDFLARE_API_TOKEN)")
258
+ .option("--k3s", "Include k3s Kubernetes services")
259
+ .option("--traefik", "Include traefik reverse proxy")
260
+ .option("--dai", "Also start DAI services (implies --k3s)")
261
+ .option("--no-knock", "Skip port-knock setup")
262
+ .action(async (name, opts) => {
263
+ if (opts.dai) opts.k3s = true;
264
+ const {
265
+ lazyExeca, ensureAzCli, ensureAzAuth, resolveGithubToken, verifyGithubToken,
266
+ reconcileVm, DEFAULTS,
267
+ } = await import("../azure.js");
268
+ const { resolveCfToken } = await import("../cloudflare.js");
269
+ const { readVmState, writeVmState } = await import("../azure-state.js");
270
+ const execa = await lazyExeca();
271
+ await ensureAzCli(execa);
272
+ const account = await ensureAzAuth(execa, { subscription: opts.profile });
273
+ const { token: githubToken, login: githubLogin } = await verifyGithubToken(resolveGithubToken(opts));
274
+ const sub = opts.profile;
275
+ const subId = account.id || DEFAULTS.subscriptionId;
276
+ const tracked = readVmState(name);
277
+ if (!tracked) {
278
+ console.error(chalk.red(`\n VM "${name}" not tracked. Use 'fops azure up ${name}' first.\n`));
279
+ process.exit(1);
280
+ }
281
+ const rg = tracked.resourceGroup;
282
+ const desiredUrl = opts.url || tracked.publicUrl;
283
+ const cfToken = resolveCfToken(opts.cfToken);
284
+ const { publicIp, publicUrl, rg: actualRg } = await reconcileVm(execa, {
285
+ vmName: name, rg, sub, subId, location: tracked.location || DEFAULTS.location,
286
+ port: DEFAULTS.port, adminUser: DEFAULTS.adminUser,
287
+ githubToken, githubLogin, desiredUrl,
288
+ knockSequence: tracked.knockSequence,
289
+ k3s: opts.k3s, traefik: opts.traefik, dai: opts.dai,
290
+ knock: opts.knock, cfToken, waitMode: "http-any",
291
+ });
292
+ const resolvedRg = actualRg || rg;
293
+ writeVmState(name, { resourceGroup: resolvedRg, location: tracked.location, publicIp, publicUrl, subscriptionId: subId });
294
+ console.log(chalk.green(`\n ✓ Reconciled ${chalk.bold(name)}`));
295
+ if (publicUrl) console.log(chalk.dim(` ${publicUrl}`));
296
+ if (publicIp) console.log(chalk.dim(` IP: ${publicIp}`));
297
+ console.log();
298
+ });
299
+
251
300
  // ── down ─────────────────────────────────────────────────────────────────
252
301
  azure
253
302
  .command("down [name]")
@@ -329,6 +378,18 @@ export function registerVmCommands(azure, api, registry) {
329
378
  await azureTrinoStatus({ vmName: opts.vmName || name, profile: opts.profile });
330
379
  });
331
380
 
381
+ // ── ping ────────────────────────────────────────────────────────────────
382
+ azure
383
+ .command("ping [name]")
384
+ .description("Check Foundation backend health via /api/ping/json")
385
+ .option("--profile <subscription>", "Azure subscription name or ID")
386
+ .option("--vm-name <name>", "Target VM (default: active VM)")
387
+ .option("--token <token>", "Ping auth token (or set FOPS_PING_TOKEN)")
388
+ .action(async (name, opts) => {
389
+ const { azurePing } = await import("../azure.js");
390
+ await azurePing({ vmName: opts.vmName || name, profile: opts.profile, token: opts.token });
391
+ });
392
+
332
393
  // ── terraform ────────────────────────────────────────────────────────────
333
394
  azure
334
395
  .command("terraform [name]")