@meshxdata/fops 0.1.36 → 0.1.38

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 (59) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/fops.mjs +37 -14
  3. package/package.json +1 -1
  4. package/src/agent/llm.js +2 -0
  5. package/src/auth/azure.js +92 -0
  6. package/src/auth/cloudflare.js +125 -0
  7. package/src/auth/index.js +2 -0
  8. package/src/commands/index.js +8 -4
  9. package/src/commands/lifecycle.js +31 -10
  10. package/src/plugins/bundled/fops-plugin-azure/index.js +44 -2896
  11. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +130 -2
  12. package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +497 -0
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +51 -13
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +206 -52
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +128 -34
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-shared-cache.js +1 -1
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +4 -4
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +2 -2
  19. package/src/plugins/bundled/fops-plugin-azure/lib/commands/fleet-cmds.js +254 -0
  20. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +894 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +314 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +893 -0
  23. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/dai-backend.yaml +13 -0
  24. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/dai-frontend.yaml +13 -0
  25. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-backend.yaml +13 -0
  26. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-frontend.yaml +13 -0
  27. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-hive.yaml +13 -0
  28. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-kafka.yaml +13 -0
  29. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-meltano.yaml +13 -0
  30. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-mlflow.yaml +13 -0
  31. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-opa.yaml +13 -0
  32. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-processor.yaml +13 -0
  33. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-scheduler.yaml +13 -0
  34. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-storage-engine.yaml +13 -0
  35. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-trino.yaml +13 -0
  36. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-watcher.yaml +13 -0
  37. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/config/repository.yaml +66 -0
  38. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/kustomization.yaml +30 -0
  39. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/acr-webhook-controller.yaml +63 -0
  40. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/externalsecrets.yaml +15 -0
  41. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/istio.yaml +42 -0
  42. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kafka.yaml +15 -0
  43. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kube-reflector.yaml +33 -0
  44. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kubecost.yaml +12 -0
  45. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/nats-server.yaml +15 -0
  46. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/prometheus-agent.yaml +34 -0
  47. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/reloader.yaml +12 -0
  48. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/spark.yaml +112 -0
  49. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/tailscale.yaml +67 -0
  50. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/vertical-pod-autoscaler.yaml +15 -0
  51. package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.aligned.csv +1 -1
  52. package/src/plugins/bundled/fops-plugin-file/index.js +81 -12
  53. package/src/plugins/bundled/fops-plugin-file/lib/match.js +133 -15
  54. package/src/plugins/bundled/fops-plugin-file/lib/report.js +3 -0
  55. package/src/plugins/bundled/fops-plugin-foundation/index.js +26 -6
  56. package/src/plugins/bundled/fops-plugin-foundation/lib/client.js +9 -5
  57. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-product.js +32 -0
  58. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/schema.js +20 -1
  59. package/src/plugins/loader.js +23 -6
@@ -0,0 +1,893 @@
1
+ /**
2
+ * VM lifecycle, SSH/knock, deploy/config, bootstrap, and misc commands.
3
+ * All operations target a single Azure VM.
4
+ */
5
+ import { pathToFileURL } from "node:url";
6
+ import chalk from "chalk";
7
+ import { resolveFoundationCreds, resolveAuth0Config } from "../azure-auth.js";
8
+
9
+ export function registerVmCommands(azure, api, registry) {
10
+ // ── doctor ──────────────────────────────────────────────────────────────
11
+ azure
12
+ .command("doctor [name]")
13
+ .description("Run doctor locally, or on VM if name given (e.g. fops azure doctor my-vm)")
14
+ .option("--fix", "Apply suggested fixes where possible", false)
15
+ .action(async (name, opts) => {
16
+ if (name) {
17
+ const {
18
+ lazyExeca, ensureAzCli, ensureAzAuth,
19
+ requireVmState, knockForVm, DEFAULTS, resolvePublicIp, sshCmd,
20
+ } = await import("../azure.js");
21
+ const { closeKnock } = await import("../port-knock.js");
22
+ const vmName = opts.vmName || name;
23
+ const execa = await lazyExeca();
24
+ await ensureAzCli(execa);
25
+ await ensureAzAuth(execa);
26
+ const state = requireVmState(vmName);
27
+ const ip = await resolvePublicIp(execa, state.resourceGroup, state.vmName, state.publicIp);
28
+ const user = DEFAULTS.adminUser;
29
+ if (!ip) {
30
+ console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n"));
31
+ process.exit(1);
32
+ }
33
+ await knockForVm(state);
34
+ const ssh = (cmd, timeout) => sshCmd(execa, ip, user, cmd, timeout);
35
+ const fixFlag = opts.fix ? " --fix" : "";
36
+ console.log(chalk.cyan(`\n Running fops doctor on "${state.vmName}" (${ip})…\n`));
37
+ const { MUX_OPTS } = await import("../azure.js");
38
+ const { exitCode } = await execa("ssh", [
39
+ ...MUX_OPTS(ip, user),
40
+ `${user}@${ip}`,
41
+ `cd /opt/foundation-compose && fops doctor${fixFlag}`,
42
+ ], { timeout: 120000, reject: false, stdio: "inherit" });
43
+ await closeKnock(ssh, { quiet: true });
44
+ if (exitCode !== 0) process.exitCode = 1;
45
+ return;
46
+ }
47
+ const doctorPath = api.getCliPath?.("src", "doctor.js");
48
+ const mod = doctorPath
49
+ ? await import(pathToFileURL(doctorPath).href)
50
+ : await import("../../../../../doctor.js");
51
+ await mod.runDoctor({ fix: opts.fix }, registry ?? null);
52
+ });
53
+
54
+ // ── packer ───────────────────────────────────────────────────────────────
55
+ azure
56
+ .command("packer")
57
+ .description("Build the Foundation platform image with Packer")
58
+ .option("--profile <subscription>", "Azure subscription name or ID")
59
+ .option("--github-token <token>", "GitHub PAT for cloning private repos (default: $GITHUB_TOKEN)")
60
+ .option("--branch <branch>", "Git branch to bake into the image (default: main)")
61
+ .option("--fops-version <version>", "fops npm version to install (default: latest)")
62
+ .option("--location <region>", "Azure region for the build VM (default: from pkrvars)")
63
+ .option("--vm-size <size>", "Build VM size (default: from pkrvars)")
64
+ .option("--image-name <name>", "Output image name (default: foundation-platform)")
65
+ .option("--force", "Replace existing image if it already exists")
66
+ .action(async (opts) => {
67
+ const { azureBuild } = await import("../azure.js");
68
+ await azureBuild({
69
+ githubToken: opts.githubToken, branch: opts.branch, fopsVersion: opts.fopsVersion,
70
+ location: opts.location, vmSize: opts.vmSize, imageName: opts.imageName, force: opts.force,
71
+ });
72
+ });
73
+
74
+ // ── marketplace ──────────────────────────────────────────────────────────
75
+ const marketplace = azure
76
+ .command("marketplace")
77
+ .description("Prepare Azure VM image artifacts for Marketplace publishing");
78
+
79
+ marketplace
80
+ .command("image <vmName>")
81
+ .description("Create a Marketplace-ready managed image from a safe clone of a source VM (source VM unchanged)")
82
+ .option("--profile <subscription>", "Azure subscription name or ID")
83
+ .option("--source-rg <name>", "Resource group containing the source VM")
84
+ .option("--resource-group <name>", "Working RG for snapshot/clone/image (default: source RG)")
85
+ .option("--clone-vm-name <name>", "Temporary clone VM name")
86
+ .option("--clone-vm-size <size>", "Temporary clone VM size (default: source VM size)")
87
+ .option("--admin-user <name>", "Admin username for temporary clone VM (default: azureuser)")
88
+ .option("--snapshot-name <name>", "Snapshot name override")
89
+ .option("--disk-name <name>", "Managed disk name override")
90
+ .option("--image-name <name>", "Output managed image artifact name")
91
+ .option("--keep-staging", "Keep temporary clone resources (VM/disk/snapshot)")
92
+ .option("--gallery-name <name>", "Optional: publish to Azure Compute Gallery")
93
+ .option("--image-definition <name>", "Gallery image definition name (default: <offer>-<sku>)")
94
+ .option("--publisher <name>", "Required with --gallery-name")
95
+ .option("--offer <name>", "Required with --gallery-name")
96
+ .option("--sku <name>", "Required with --gallery-name")
97
+ .option("--version <version>", "Required with --gallery-name (e.g. 1.0.0)")
98
+ .option("--target-region <region>", "Target region for gallery image version (default: source VM region)")
99
+ .action(async (vmName, opts) => {
100
+ const { marketplaceImageFromVm } = await import("../azure.js");
101
+ await marketplaceImageFromVm({
102
+ vmName, profile: opts.profile, sourceRg: opts.sourceRg, resourceGroup: opts.resourceGroup,
103
+ cloneVmName: opts.cloneVmName, cloneVmSize: opts.cloneVmSize, adminUser: opts.adminUser,
104
+ snapshotName: opts.snapshotName, diskName: opts.diskName, imageName: opts.imageName,
105
+ keepStaging: opts.keepStaging, galleryName: opts.galleryName, imageDefinition: opts.imageDefinition,
106
+ publisher: opts.publisher, offer: opts.offer, sku: opts.sku, version: opts.version,
107
+ targetRegion: opts.targetRegion,
108
+ });
109
+ });
110
+
111
+ // ── up ───────────────────────────────────────────────────────────────────
112
+ azure
113
+ .command("up [names...]")
114
+ .description("Provision / reconcile Azure VMs, or run 'fops up <component> <branch>' on remote (e.g. fops azure up backend FOU-2072)")
115
+ .option("--profile <subscription>", "Azure subscription name or ID")
116
+ .option("--vm-name <name>", "VM name (default: foundation-test-vm) or target VM for remote up")
117
+ .option("--vm-size <size>", "VM size (default: Standard_D8s_v3)")
118
+ .option("--location <region>", "Azure region (default: uaenorth)")
119
+ .option("--image <urn>", "Custom image URN or managed image ID (default: Ubuntu 24.04 LTS)")
120
+ .option("--branch <branch>", "Git branch to clone (default: main)")
121
+ .option("--fops-version <version>", "fops npm version to install (default: latest)")
122
+ .option("--url <url>", "Public URL override (default: https://<name>.meshx.app)")
123
+ .option("--github-token <token>", "GitHub PAT for .netrc + GHCR auth (default: $GITHUB_TOKEN)")
124
+ .option("--cf-token <token>", "Cloudflare API token for DNS (default: $CLOUDFLARE_API_TOKEN)")
125
+ .option("--wait-mode <mode>", "URL readiness mode: http-any (default) or http-2xx", "http-any")
126
+ .option("--no-knock", "Skip port-knock setup — SSH stays open to all")
127
+ .option("--k3s", "Include k3s Kubernetes services")
128
+ .option("--traefik", "Include traefik reverse proxy (auto-enabled for DNS URLs)")
129
+ .option("--dai", "Also start DAI services (implies --k3s)")
130
+ .option("--resource-group <name>", "Resource group for the VM (default: FOUNDATION-VM-RG or AZURE_RESOURCE_GROUP)")
131
+ .option("--update", "Run fops update (git pull, npm install, image pull) before starting services")
132
+ .action(async (names, opts) => {
133
+ if (opts.dai) opts.k3s = true;
134
+ const looksLikeBranch = (s) => /[/]/.test(s) || /^[A-Z]{2,}-\d+$/.test(s);
135
+ if (names.length === 2 && looksLikeBranch(names[1])) {
136
+ const { azureRunUp } = await import("../azure.js");
137
+ await azureRunUp({
138
+ vmName: opts.vmName, component: names[0], branch: names[1],
139
+ url: opts.url, k3s: opts.k3s, traefik: opts.traefik, dai: opts.dai, update: opts.update,
140
+ });
141
+ return;
142
+ }
143
+ const sharedOpts = {
144
+ vmSize: opts.vmSize, location: opts.location, image: opts.image, branch: opts.branch,
145
+ fopsVersion: opts.fopsVersion, url: opts.url, githubToken: opts.githubToken,
146
+ cfToken: opts.cfToken, profile: opts.profile, knock: opts.knock,
147
+ k3s: opts.k3s, traefik: opts.traefik, dai: opts.dai,
148
+ resourceGroup: opts.resourceGroup, waitMode: opts.waitMode,
149
+ };
150
+ const vmNames = names.length ? names : (opts.vmName ? [opts.vmName] : []);
151
+ if (vmNames.length > 0) {
152
+ const { azureUp } = await import("../azure.js");
153
+ for (const vmName of vmNames) await azureUp({ ...sharedOpts, vmName });
154
+ } else {
155
+ const { azureUpAll } = await import("../azure.js");
156
+ await azureUpAll(sharedOpts);
157
+ }
158
+ });
159
+
160
+ // ── list ─────────────────────────────────────────────────────────────────
161
+ azure
162
+ .command("list")
163
+ .description("List all tracked Azure VMs and AKS clusters (cached)")
164
+ .option("--live", "Force live probe (skip cache)")
165
+ .option("--verbose", "Show sync progress")
166
+ .option("--cost", "Show estimated cost per resource (queries Azure Cost Management)")
167
+ .option("--days <days>", "Days to look back for cost (default: 30)", "30")
168
+ .option("--versions", "Show service image version matrix")
169
+ .action(async (opts) => {
170
+ const { azureList } = await import("../azure.js");
171
+ await azureList({ live: opts.live, verbose: opts.verbose, cost: opts.cost, days: parseInt(opts.days), versions: opts.versions });
172
+ });
173
+
174
+ // ── select ───────────────────────────────────────────────────────────────
175
+ azure
176
+ .command("select [name]")
177
+ .description("Set the active VM (interactive picker when no name given)")
178
+ .action(async (name) => {
179
+ const { listVms, readState, saveState, migrateAzureState } = await import("../azure-state.js");
180
+ const { activeVm, vms } = listVms();
181
+ const vmNames = Object.keys(vms);
182
+ if (vmNames.length === 0) {
183
+ console.error(chalk.red("\n No tracked VMs. Provision one first: fops azure up --vm-name <name>\n"));
184
+ process.exit(1);
185
+ }
186
+ let selected = name;
187
+ if (!selected) {
188
+ if (vmNames.length === 1) {
189
+ selected = vmNames[0];
190
+ } else {
191
+ const { default: inquirer } = await import("inquirer");
192
+ const answer = await inquirer.prompt([{
193
+ type: "list", name: "vm", message: "Select active VM:",
194
+ choices: vmNames.map(n => ({ name: n === activeVm ? `${n} ${chalk.dim("(current)")}` : n, value: n })),
195
+ default: activeVm,
196
+ }]);
197
+ selected = answer.vm;
198
+ }
199
+ }
200
+ if (!vms[selected]) {
201
+ console.error(chalk.red(`\n VM "${selected}" not found. Tracked VMs: ${vmNames.join(", ")}\n`));
202
+ process.exit(1);
203
+ }
204
+ if (selected === activeVm) {
205
+ console.log(chalk.dim(`\n "${selected}" is already the active VM.\n`));
206
+ return;
207
+ }
208
+ const state = readState();
209
+ const az = migrateAzureState(state.azure);
210
+ az.activeVm = selected;
211
+ state.azure = az;
212
+ saveState(state);
213
+ const vm = vms[selected];
214
+ const url = vm.publicIp ? `https://${vm.domain || selected + ".meshx.app"}` : "";
215
+ console.log(chalk.green(`\n ✓ Active VM → ${chalk.bold(selected)}`));
216
+ if (url) console.log(chalk.dim(` ${url}`));
217
+ console.log();
218
+ });
219
+
220
+ // ── down ─────────────────────────────────────────────────────────────────
221
+ azure
222
+ .command("down [name]")
223
+ .description("Destroy the Azure VM and all associated resources")
224
+ .option("--profile <subscription>", "Azure subscription name or ID")
225
+ .option("--vm-name <name>", "Target VM (default: active VM)")
226
+ .option("--cf-token <token>", "Cloudflare API token for DNS cleanup (default: $CLOUDFLARE_API_TOKEN)")
227
+ .action(async (name, opts) => {
228
+ const { azureDown } = await import("../azure.js");
229
+ await azureDown({ vmName: opts.vmName || name, profile: opts.profile, cfToken: opts.cfToken });
230
+ });
231
+
232
+ // ── stop ─────────────────────────────────────────────────────────────────
233
+ azure
234
+ .command("stop [name]")
235
+ .description("Stop (deallocate) the VM — no compute charges, disk preserved")
236
+ .option("--profile <subscription>", "Azure subscription name or ID")
237
+ .option("--vm-name <name>", "Target VM (default: active VM)")
238
+ .action(async (name, opts) => {
239
+ const { azureStop } = await import("../azure.js");
240
+ await azureStop({ vmName: opts.vmName || name, profile: opts.profile });
241
+ });
242
+
243
+ // ── resize ───────────────────────────────────────────────────────────────
244
+ azure
245
+ .command("resize [name]")
246
+ .description("Scale the VM up (or down) to the next size in its family")
247
+ .option("--profile <subscription>", "Azure subscription name or ID")
248
+ .option("--vm-name <name>", "Target VM (default: active VM)")
249
+ .option("--size <size>", "Explicit target size (e.g. Standard_D8s_v3) — skips auto-detection")
250
+ .option("--down", "Scale down instead of up")
251
+ .option("--github-token <token>", "GitHub PAT for .netrc + GHCR auth (default: $GITHUB_TOKEN)")
252
+ .option("--k3s", "Include k3s Kubernetes services")
253
+ .option("--traefik", "Include traefik reverse proxy (auto-enabled for DNS URLs)")
254
+ .option("--dai", "Also start DAI services (implies --k3s)")
255
+ .action(async (name, opts) => {
256
+ if (opts.dai) opts.k3s = true;
257
+ const { azureResize } = await import("../azure.js");
258
+ await azureResize({ vmName: opts.vmName || name, size: opts.size, down: opts.down, githubToken: opts.githubToken, profile: opts.profile, k3s: opts.k3s, traefik: opts.traefik, dai: opts.dai });
259
+ });
260
+
261
+ // ── start ────────────────────────────────────────────────────────────────
262
+ azure
263
+ .command("start [name]")
264
+ .description("Start a stopped VM and reconfigure Foundation URL")
265
+ .option("--profile <subscription>", "Azure subscription name or ID")
266
+ .option("--vm-name <name>", "Target VM (default: active VM)")
267
+ .option("--url <url>", "Public URL override")
268
+ .option("--github-token <token>", "GitHub PAT for .netrc + GHCR auth (default: $GITHUB_TOKEN)")
269
+ .option("--cf-token <token>", "Cloudflare API token for DNS (default: $CLOUDFLARE_API_TOKEN)")
270
+ .option("--k3s", "Include k3s Kubernetes services")
271
+ .option("--traefik", "Include traefik reverse proxy (auto-enabled for DNS URLs)")
272
+ .option("--dai", "Also start DAI services (implies --k3s)")
273
+ .action(async (name, opts) => {
274
+ if (opts.dai) opts.k3s = true;
275
+ const { azureStart } = await import("../azure.js");
276
+ await azureStart({ vmName: opts.vmName || name, url: opts.url, githubToken: opts.githubToken, cfToken: opts.cfToken, profile: opts.profile, k3s: opts.k3s, traefik: opts.traefik, dai: opts.dai });
277
+ });
278
+
279
+ // ── status ───────────────────────────────────────────────────────────────
280
+ azure
281
+ .command("status [name]")
282
+ .description("Show VM state and Foundation service health")
283
+ .option("--profile <subscription>", "Azure subscription name or ID")
284
+ .option("--vm-name <name>", "Target VM (default: active VM)")
285
+ .action(async (name, opts) => {
286
+ const { azureStatus } = await import("../azure.js");
287
+ await azureStatus({ vmName: opts.vmName || name, profile: opts.profile });
288
+ });
289
+
290
+ // ── trino-status ─────────────────────────────────────────────────────────
291
+ azure
292
+ .command("trino-status [name]")
293
+ .description("Check Trino container and engine status on the VM (for bootstrap debugging)")
294
+ .option("--profile <subscription>", "Azure subscription name or ID")
295
+ .option("--vm-name <name>", "Target VM (default: active VM)")
296
+ .action(async (name, opts) => {
297
+ const { azureTrinoStatus } = await import("../azure.js");
298
+ await azureTrinoStatus({ vmName: opts.vmName || name, profile: opts.profile });
299
+ });
300
+
301
+ // ── terraform ────────────────────────────────────────────────────────────
302
+ azure
303
+ .command("terraform [name]")
304
+ .description("Generate Terraform HCL for the VM and its resources (VNet, NSG, IP, disk encryption)")
305
+ .option("--profile <subscription>", "Azure subscription name or ID")
306
+ .option("--vm-name <name>", "Target VM (default: active VM)")
307
+ .option("--output <file>", "Write HCL to a file instead of stdout")
308
+ .action(async (name, opts) => {
309
+ const { vmTerraform } = await import("../azure.js");
310
+ await vmTerraform({ vmName: opts.vmName || name, profile: opts.profile, output: opts.output });
311
+ });
312
+
313
+ // ── ssh ──────────────────────────────────────────────────────────────────
314
+ const ssh = azure
315
+ .command("ssh")
316
+ .description("SSH access and admin key management");
317
+
318
+ ssh
319
+ .command("connect [name]", { isDefault: true })
320
+ .description("Open an interactive SSH session to the VM")
321
+ .option("--vm-name <name>", "Target VM (default: active VM)")
322
+ .action(async (name, opts) => {
323
+ const { azureSsh } = await import("../azure.js");
324
+ await azureSsh({ vmName: opts.vmName || name });
325
+ });
326
+
327
+ const sshAdmin = ssh.command("admin").description("Manage admin SSH keys across all VMs");
328
+ sshAdmin
329
+ .command("add <pubKey>")
330
+ .description("Add a public SSH key to all tracked VMs")
331
+ .action(async (pubKey) => {
332
+ const { azureSshAdminAdd } = await import("../azure.js");
333
+ await azureSshAdminAdd({ pubKey });
334
+ });
335
+
336
+ ssh
337
+ .command("whitelist-me [names...]")
338
+ .description("Add your current public IP to the SSH (port 22) NSG rule (one or more VMs)")
339
+ .option("--vm-name <name>", "Target VM (default: active VM)")
340
+ .option("--profile <sub>", "Azure subscription profile")
341
+ .action(async (names, opts) => {
342
+ const { azureSshWhitelistMe } = await import("../azure.js");
343
+ const targets = names?.length ? names : [opts.vmName || undefined];
344
+ for (const vmName of targets) await azureSshWhitelistMe({ vmName, profile: opts.profile });
345
+ });
346
+
347
+ // ── port ─────────────────────────────────────────────────────────────────
348
+ azure
349
+ .command("port <remotePort>")
350
+ .description("Forward a remote VM port to localhost (SSH tunnel)")
351
+ .option("--vm-name <name>", "Target VM (default: active VM)")
352
+ .option("--local-port <port>", "Local port (default: same as remote port)")
353
+ .action(async (remotePort, opts) => {
354
+ const { azurePortForward } = await import("../azure.js");
355
+ await azurePortForward({ vmName: opts.vmName, remotePort: Number(remotePort), localPort: opts.localPort ? Number(opts.localPort) : undefined });
356
+ });
357
+
358
+ // ── foundation graphql ───────────────────────────────────────────────────
359
+ const azureFoundation = azure
360
+ .command("foundation")
361
+ .description("Foundation platform tools targeting a remote Azure VM");
362
+
363
+ azureFoundation
364
+ .command("graphql [name]")
365
+ .description("Start a local GraphiQL explorer tunnelled to a remote VM's Foundation API")
366
+ .option("--vm-name <name>", "Target VM (default: active VM)")
367
+ .option("--port <port>", "Local port for GraphiQL (default: random)", "0")
368
+ .option("--api-port <port>", "Foundation API port on the VM (default: 9001)", "9001")
369
+ .action(async (name, opts) => {
370
+ const { knockForVm, closeMux, ensureKnockSequence, DEFAULTS, lazyExeca } = await import("../azure.js");
371
+ const { readVmState } = await import("../azure-state.js");
372
+ const rawName = opts.vmName || name;
373
+ // Sync IP + knock sequence from Azure before using them
374
+ const freshState = await ensureKnockSequence(readVmState(rawName));
375
+ const vmName = rawName || freshState?.vmName;
376
+ const ip = freshState?.publicIp;
377
+ if (!ip) {
378
+ console.error(chalk.red("\n No IP. Is the VM running? Try: fops azure start\n"));
379
+ process.exit(1);
380
+ }
381
+
382
+ const execa = await lazyExeca();
383
+ await closeMux(execa, ip, DEFAULTS.adminUser);
384
+ await knockForVm(freshState);
385
+
386
+ const net = await import("node:net");
387
+ const tunnelPort = await new Promise((resolve) => {
388
+ const s = net.createServer();
389
+ s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => resolve(p)); });
390
+ });
391
+
392
+ const { exec } = await import("node:child_process");
393
+ console.log(chalk.cyan(`\n Tunnelling ${ip}:${opts.apiPort} → localhost:${tunnelPort}…`));
394
+
395
+ const tunnel = execa("ssh", [
396
+ "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
397
+ "-o", "ExitOnForwardFailure=yes", "-o", "ConnectTimeout=20",
398
+ "-o", "ServerAliveInterval=30", "-N",
399
+ "-L", `${tunnelPort}:localhost:${opts.apiPort}`,
400
+ `${DEFAULTS.adminUser}@${ip}`,
401
+ ], { reject: false, stderr: "inherit" });
402
+
403
+ const deadline = Date.now() + 30000;
404
+ await new Promise((resolve, reject) => {
405
+ tunnel.then((result) => {
406
+ if (result.exitCode !== 0) reject(new Error(`SSH exited with code ${result.exitCode}`));
407
+ }).catch(() => {});
408
+ const check = () => {
409
+ const sock = net.createConnection({ host: "127.0.0.1", port: tunnelPort });
410
+ sock.on("connect", () => { sock.destroy(); resolve(); });
411
+ sock.on("error", () => {
412
+ sock.destroy();
413
+ if (Date.now() > deadline) return reject(new Error("SSH tunnel did not establish within 30s"));
414
+ setTimeout(check, 500);
415
+ });
416
+ };
417
+ setTimeout(check, 800);
418
+ });
419
+
420
+ try {
421
+ const { FoundationClient } = await import("../../../fops-plugin-foundation/lib/client.js");
422
+ const client = new FoundationClient({ apiUrl: `http://127.0.0.1:${tunnelPort}/api` });
423
+ const { GraphQLFacade } = await import("../../../fops-plugin-foundation-graphql/lib/graphql/facade.js");
424
+ const facade = new GraphQLFacade(client);
425
+ const { createGraphQLRoute } = await import("../../../fops-plugin-foundation-graphql/lib/graphql/hono-route.js");
426
+ const { serve } = await import("@hono/node-server");
427
+
428
+ const route = createGraphQLRoute(facade);
429
+ const gqlPort = parseInt(opts.port, 10) || 0;
430
+ const server = serve({ fetch: route.fetch, port: gqlPort }, (info) => {
431
+ const url = `http://127.0.0.1:${info.port}`;
432
+ console.log(chalk.cyan(` ── Foundation GraphQL — ${vmName} (${ip}) ${"─".repeat(8)}`));
433
+ console.log(` ${chalk.green("✓")} GraphiQL at ${chalk.bold(url)}`);
434
+ console.log(chalk.dim(" Press Ctrl+C to stop\n"));
435
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
436
+ exec(`${opener} ${url}`);
437
+ });
438
+
439
+ await new Promise((resolve) => {
440
+ const stop = () => { server.close(); tunnel.kill(); resolve(); };
441
+ process.on("SIGINT", stop);
442
+ process.on("SIGTERM", stop);
443
+ });
444
+ } catch (err) {
445
+ tunnel.kill();
446
+ console.error(chalk.red(` ${err.message}`));
447
+ process.exit(1);
448
+ }
449
+ });
450
+
451
+ // ── agent ────────────────────────────────────────────────────────────────
452
+ azure
453
+ .command("agent [vmName]")
454
+ .description("Run fops agent TUI on the remote VM via SSH (vmName = VM name; use --agent for agent name)")
455
+ .option("--vm-name <name>", "Target VM (default: active VM)")
456
+ .option("--agent <name>", "Agent to run (default: foundation)", "foundation")
457
+ .option("-m, --message <text>", "Single-turn message instead of TUI")
458
+ .option("--classic", "Use classic terminal REPL instead of TUI")
459
+ .option("--model <id>", "Model override (e.g. claude-sonnet-4-20250514)")
460
+ .action(async (vmName, opts) => {
461
+ const { azureAgent } = await import("../azure.js");
462
+ await azureAgent({ vmName: opts.vmName || vmName, agent: opts.agent, message: opts.message, classic: opts.classic, model: opts.model });
463
+ });
464
+
465
+ // ── knock ─────────────────────────────────────────────────────────────────
466
+ const knock = azure
467
+ .command("knock")
468
+ .description("Perform port-knock sequence to temporarily open SSH");
469
+
470
+ knock
471
+ .command("open [names...]", { isDefault: true })
472
+ .description("Send the knock sequence — opens SSH for ~5 min")
473
+ .option("--vm-name <name>", "Target VM (default: active VM)")
474
+ .action(async (names, opts) => {
475
+ const { azureKnock } = await import("../azure.js");
476
+ const targets = opts.vmName ? [opts.vmName] : (names.length ? names : [undefined]);
477
+ for (const vmName of targets) await azureKnock({ vmName });
478
+ });
479
+
480
+ knock
481
+ .command("close [name]")
482
+ .description("Re-lock ports — revoke current knock, require a fresh one")
483
+ .option("--vm-name <name>", "Target VM (default: active VM)")
484
+ .action(async (name, opts) => {
485
+ const { azureKnockClose } = await import("../azure.js");
486
+ await azureKnockClose({ vmName: opts.vmName || name });
487
+ });
488
+
489
+ knock
490
+ .command("disable [name]")
491
+ .description("Remove port knocking — restore open SSH access")
492
+ .option("--vm-name <name>", "Target VM (default: active VM)")
493
+ .action(async (name, opts) => {
494
+ const { azureKnockDisable } = await import("../azure.js");
495
+ await azureKnockDisable({ vmName: opts.vmName || name });
496
+ });
497
+
498
+ knock
499
+ .command("verify [name]")
500
+ .description("Diagnose port-knock setup: check knockd, iptables rules, sequence match")
501
+ .option("--vm-name <name>", "Target VM (default: all VMs)")
502
+ .action(async (name, opts) => {
503
+ const { azureKnockVerify } = await import("../azure.js");
504
+ await azureKnockVerify({ vmName: opts.vmName || name });
505
+ });
506
+
507
+ knock
508
+ .command("fix [name]")
509
+ .description("Re-setup port knocking with correct iptables rules on all (or one) VM")
510
+ .option("--vm-name <name>", "Target VM (default: all VMs)")
511
+ .action(async (name, opts) => {
512
+ const { azureKnockFix } = await import("../azure.js");
513
+ await azureKnockFix({ vmName: opts.vmName || name });
514
+ });
515
+
516
+ // ── deploy ────────────────────────────────────────────────────────────────
517
+ const deploy = azure
518
+ .command("deploy")
519
+ .description("Deploy code, images, or specific component versions");
520
+
521
+ deploy
522
+ .command("stack [names...]", { isDefault: true })
523
+ .description("Pull latest code, images, and restart Foundation on the VM")
524
+ .option("--vm-name <name>", "Target VM (default: active VM)")
525
+ .option("--branch <branch>", "Git branch to deploy (default: main)")
526
+ .option("--url <url>", "Public URL override")
527
+ .option("--github-token <token>", "GitHub PAT for .netrc + GHCR auth (default: $GITHUB_TOKEN)")
528
+ .option("--k3s", "Include k3s Kubernetes services")
529
+ .option("--traefik", "Include traefik reverse proxy (auto-enabled for DNS URLs)")
530
+ .option("--dai", "Also start DAI services (implies --k3s)")
531
+ .action(async (names, opts) => {
532
+ if (opts.dai) opts.k3s = true;
533
+ const { azureDeploy } = await import("../azure.js");
534
+ const targets = names?.length ? names : [opts.vmName || undefined];
535
+ for (const vmName of targets)
536
+ await azureDeploy({ vmName, branch: opts.branch, url: opts.url, githubToken: opts.githubToken, k3s: opts.k3s, traefik: opts.traefik, dai: opts.dai });
537
+ });
538
+
539
+ deploy
540
+ .command("version <component> <tag>")
541
+ .description("Set a component to a specific image tag, pull, and restart")
542
+ .option("--vm-name <name>", "Target a specific VM")
543
+ .option("--all", "Deploy to all tracked VMs")
544
+ .option("--aks", "Also deploy to AKS clusters")
545
+ .option("--aks-cluster <name>", "Target a specific AKS cluster")
546
+ .option("--no-restart", "Pull image but don't restart the service")
547
+ .action(async (component, tag, opts) => {
548
+ const { azureDeployVersion } = await import("../azure.js");
549
+ await azureDeployVersion({ component, tag, vmName: opts.vmName, all: opts.all, aks: opts.aks, aksCluster: opts.aksCluster, noPull: !opts.restart });
550
+ });
551
+
552
+ // ── config ────────────────────────────────────────────────────────────────
553
+ const config = azure
554
+ .command("config")
555
+ .description("Manage feature flags and component versions on the remote VM");
556
+
557
+ config
558
+ .command("flags [name]", { isDefault: true })
559
+ .description("Toggle MX_FF_* feature flags")
560
+ .option("--vm-name <name>", "Target VM (default: active VM)")
561
+ .action(async (name, opts) => {
562
+ const { azureConfig } = await import("../azure.js");
563
+ await azureConfig({ vmName: opts.vmName || name });
564
+ });
565
+
566
+ config
567
+ .command("versions [name]")
568
+ .description("Set image tags per Foundation component (backend, frontend, watcher, scheduler, storage)")
569
+ .option("--vm-name <name>", "Target VM (default: active VM)")
570
+ .action(async (name, opts) => {
571
+ const { azureConfigVersions } = await import("../azure.js");
572
+ await azureConfigVersions({ vmName: opts.vmName || name });
573
+ });
574
+
575
+ // ── update ────────────────────────────────────────────────────────────────
576
+ azure
577
+ .command("update [names...]")
578
+ .description("Run fops update on tracked VMs in parallel (no args = all VMs, or list VM names)")
579
+ .option("--vm-name <name>", "Target a specific VM (alternative to positional names)")
580
+ .option("--github-token <token>", "GitHub PAT for git pull on VM when repo is private (default: $GITHUB_TOKEN)")
581
+ .option("--up", "After update, run fops up to restart services")
582
+ .action(async (names, opts) => {
583
+ const { azureUpdate } = await import("../azure.js");
584
+ const list = Array.isArray(names) ? names.filter(Boolean) : (names ? [names] : []);
585
+ await azureUpdate({ vmName: opts.vmName, vmNames: list.length ? list : undefined, githubToken: opts.githubToken, up: opts.up });
586
+ });
587
+
588
+ // ── apply ─────────────────────────────────────────────────────────────────
589
+ azure
590
+ .command("apply <file>")
591
+ .description("Apply a landscape file (FCL/HCL/YAML) to the remote VM's Foundation")
592
+ .option("--vm-name <name>", "Target VM (default: active VM)")
593
+ .option("--dry-run", "Preview changes without creating entities")
594
+ .action(async (file, opts) => {
595
+ const { azureApply } = await import("../azure.js");
596
+ await azureApply(file, { vmName: opts.vmName, dryRun: opts.dryRun });
597
+ });
598
+
599
+ // ── download ──────────────────────────────────────────────────────────────
600
+ azure
601
+ .command("download [name]")
602
+ .description("Pull all container images on a VM (docker compose pull)")
603
+ .option("--vm-name <name>", "Target VM (default: active VM)")
604
+ .action(async (name, opts) => {
605
+ const { lazyExeca, requireVmState, sshCmd, knockForVm } = await import("../azure.js");
606
+ const execa = await lazyExeca();
607
+ const state = requireVmState(opts.vmName || name);
608
+ const ip = state.ip || state.publicIp;
609
+ const user = state.adminUser || "azureuser";
610
+ if (!ip) {
611
+ console.log(chalk.red(` ✗ No IP found for VM "${state.vmName}". Run: fops azure audit ${state.vmName}`));
612
+ return;
613
+ }
614
+ await knockForVm(state);
615
+ console.log(chalk.cyan(` Pulling images on ${state.vmName || name} (${ip})…`));
616
+ const { stdout, stderr, exitCode } = await sshCmd(execa, ip, user, "cd /opt/foundation-compose && make download", 600000);
617
+ if (stdout) console.log(stdout);
618
+ if (exitCode !== 0) {
619
+ console.log(chalk.yellow(` ⚠ download exited with code ${exitCode}`));
620
+ if (stderr) console.log(chalk.dim(stderr.split("\n").slice(-5).join("\n")));
621
+ } else {
622
+ console.log(chalk.green(` ✓ Images pulled on ${state.vmName || name}`));
623
+ }
624
+ });
625
+
626
+ // ── pull ──────────────────────────────────────────────────────────────────
627
+ azure
628
+ .command("pull [name]")
629
+ .description("Pull latest git code on a VM (no restart)")
630
+ .option("--vm-name <name>", "Target VM (default: active VM)")
631
+ .option("--branch <branch>", "Git branch to pull (default: current branch)")
632
+ .option("--github-token <token>", "GitHub PAT for private repos (default: $GITHUB_TOKEN)")
633
+ .action(async (name, opts) => {
634
+ const { azurePull } = await import("../azure.js");
635
+ await azurePull({ vmName: opts.vmName || name, branch: opts.branch, githubToken: opts.githubToken });
636
+ });
637
+
638
+ // ── vm check ──────────────────────────────────────────────────────────────
639
+ azure
640
+ .command("vm check [name]")
641
+ .description("Diagnose VM: show config versions and run make download with full output (for image-pull failures)")
642
+ .option("--vm-name <name>", "Target VM (default: active VM)")
643
+ .action(async (name, opts) => {
644
+ const { azureVmCheck } = await import("../azure.js");
645
+ await azureVmCheck({ vmName: opts.vmName || name });
646
+ });
647
+
648
+ // ── bootstrap ─────────────────────────────────────────────────────────────
649
+ azure
650
+ .command("bootstrap [name]")
651
+ .description("Run `fops bootstrap` on a VM (create demo data mesh)")
652
+ .option("--vm-name <name>", "Target VM (default: active)")
653
+ .action(async (name, opts) => {
654
+ const vmName = opts.vmName || name;
655
+ const {
656
+ lazyExeca, ensureAzCli, ensureAzAuth,
657
+ requireVmState, sshCmd, knockForVm, DEFAULTS,
658
+ } = await import("../azure.js");
659
+ const { closeKnock } = await import("../port-knock.js");
660
+ const fs = await import("node:fs");
661
+ const os = await import("node:os");
662
+ const pathMod = await import("node:path");
663
+ const execa = await lazyExeca();
664
+ await ensureAzCli(execa);
665
+ await ensureAzAuth(execa);
666
+ const state = requireVmState(vmName);
667
+ const ip = state.publicIp;
668
+ const user = DEFAULTS.adminUser;
669
+
670
+ if (!ip) {
671
+ console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n"));
672
+ process.exit(1);
673
+ }
674
+
675
+ // Load project root .env so credentials are available
676
+ let projectRoot = null;
677
+ try {
678
+ const fopsPath = pathMod.join(os.homedir(), ".fops.json");
679
+ const raw = JSON.parse(fs.readFileSync(fopsPath, "utf8"));
680
+ if (raw?.projectRoot && fs.existsSync(pathMod.join(raw.projectRoot, ".env"))) projectRoot = raw.projectRoot;
681
+ } catch {}
682
+ if (!projectRoot) {
683
+ let dir = pathMod.resolve(process.cwd());
684
+ for (;;) {
685
+ if (fs.existsSync(pathMod.join(dir, "docker-compose.yaml")) && fs.existsSync(pathMod.join(dir, "Makefile"))) {
686
+ projectRoot = dir; break;
687
+ }
688
+ const parent = pathMod.dirname(dir);
689
+ if (parent === dir) break;
690
+ dir = parent;
691
+ }
692
+ }
693
+ if (projectRoot) {
694
+ const { loadEnvFromFile } = await import("../azure-helpers.js");
695
+ const loaded = loadEnvFromFile(pathMod.join(projectRoot, ".env"));
696
+ for (const [k, v] of Object.entries(loaded)) {
697
+ if (process.env[k] === undefined) process.env[k] = v;
698
+ }
699
+ }
700
+
701
+ // Resolve credentials using shared helper
702
+ const creds = resolveFoundationCreds();
703
+ let bearerToken = creds?.bearerToken || "";
704
+ let qaUser = creds?.user || "";
705
+ let qaPass = creds?.password || "";
706
+
707
+ if (!bearerToken && !qaUser) {
708
+ console.error(chalk.red("\n No Foundation credentials found locally."));
709
+ console.error(chalk.dim(" Set BEARER_TOKEN or QA_USERNAME/QA_PASSWORD in env, .env, or ~/.fops.json\n"));
710
+ process.exit(1);
711
+ }
712
+
713
+ // Pre-authenticate against the VM's API to get a bearer token
714
+ const vmUrl = state.publicUrl || `https://${ip}`;
715
+ let credsRejected = false;
716
+ if (!bearerToken && qaUser) {
717
+ console.log(chalk.dim(` Pre-authenticating against ${vmUrl}…`));
718
+ const hasDomain = state.publicUrl && !state.publicUrl.match(/^https?:\/\/\d+\.\d+\.\d+\.\d+/);
719
+ const apiUrls = hasDomain
720
+ ? [`${vmUrl}/api`]
721
+ : [`${vmUrl}/api`, `https://${ip}:3002/api`, `http://${ip}:9001/api`];
722
+
723
+ for (const apiBase of apiUrls) {
724
+ try {
725
+ const resp = await fetch(`${apiBase}/iam/login`, {
726
+ method: "POST",
727
+ headers: { "Content-Type": "application/json" },
728
+ body: JSON.stringify({ username: qaUser, password: qaPass }),
729
+ signal: AbortSignal.timeout(10000),
730
+ });
731
+ if (resp.ok) {
732
+ const data = await resp.json();
733
+ bearerToken = data.access_token || data.token || "";
734
+ if (bearerToken) {
735
+ console.log(chalk.green(` ✓ Authenticated as ${qaUser} via ${apiBase}`));
736
+ break;
737
+ }
738
+ } else {
739
+ console.log(chalk.dim(` ${apiBase}: HTTP ${resp.status}`));
740
+ if (resp.status === 401) credsRejected = true;
741
+ }
742
+ } catch (e) {
743
+ console.log(chalk.dim(` ${apiBase}: ${e.cause?.code || e.message || "unreachable"}`));
744
+ }
745
+ }
746
+
747
+ if (!bearerToken) {
748
+ const auth0Cfg = resolveAuth0Config();
749
+ if (auth0Cfg) {
750
+ try {
751
+ console.log(chalk.dim(` Trying Auth0 ROPC fallback (${auth0Cfg.domain})…`));
752
+ const body = {
753
+ grant_type: "password", client_id: auth0Cfg.clientId,
754
+ username: qaUser, password: qaPass, scope: "openid",
755
+ };
756
+ if (auth0Cfg.clientSecret) body.client_secret = auth0Cfg.clientSecret;
757
+ if (auth0Cfg.audience) body.audience = auth0Cfg.audience;
758
+ const resp = await fetch(`https://${auth0Cfg.domain}/oauth/token`, {
759
+ method: "POST",
760
+ headers: { "Content-Type": "application/json" },
761
+ body: JSON.stringify(body),
762
+ signal: AbortSignal.timeout(10000),
763
+ });
764
+ if (resp.ok) {
765
+ const data = await resp.json();
766
+ bearerToken = data.access_token || "";
767
+ if (bearerToken) { credsRejected = false; console.log(chalk.green(` ✓ Authenticated as ${qaUser} via Auth0`)); }
768
+ } else {
769
+ console.log(chalk.dim(` Auth0: HTTP ${resp.status}`));
770
+ }
771
+ } catch (e) {
772
+ console.log(chalk.dim(` Auth0 fallback: ${e.message || "failed"}`));
773
+ }
774
+ }
775
+ }
776
+ }
777
+
778
+ if (credsRejected && !bearerToken) {
779
+ console.error(chalk.red("\n Credentials rejected by the remote API (HTTP 401)."));
780
+ console.error(chalk.dim(" The password for " + qaUser + " does not match this environment's Keycloak."));
781
+ console.error(chalk.dim(" Fix: set the correct password in ~/.fops.json or pass BEARER_TOKEN directly.\n"));
782
+ console.error(chalk.dim(" Hint: ssh into the VM and check the Keycloak password:"));
783
+ console.error(chalk.dim(" fops azure ssh " + (state.vmName || "") + "\n"));
784
+ process.exit(1);
785
+ }
786
+
787
+ const cred = {};
788
+ if (bearerToken) cred.BEARER_TOKEN = bearerToken;
789
+ else if (qaUser) { cred.QA_USERNAME = qaUser; cred.QA_PASSWORD = qaPass; }
790
+
791
+ if (!cred.BEARER_TOKEN && !cred.QA_USERNAME) {
792
+ console.error(chalk.red("\n Could not authenticate against the VM's API."));
793
+ console.error(chalk.dim(" Check that the VM is running (fops azure status) and credentials are valid.\n"));
794
+ process.exit(1);
795
+ }
796
+
797
+ const vmEnv = { ...cred };
798
+ if (process.env.AUTH0_EMAIL?.trim()) vmEnv.AUTH0_EMAIL = process.env.AUTH0_EMAIL.trim();
799
+
800
+ await knockForVm(state);
801
+ const sshFn = (cmd, timeout, sshEnv) => sshCmd(execa, ip, user, cmd, timeout, { env: sshEnv });
802
+
803
+ const sedParts = ["sed -i '/^BEARER_TOKEN=/d;/^QA_USERNAME=/d;/^QA_PASSWORD=/d;/^AUTH0_EMAIL=/d' .env 2>/dev/null || true"];
804
+ for (const [k, v] of Object.entries(vmEnv)) {
805
+ const escaped = String(v).replace(/'/g, "'\\''");
806
+ sedParts.push(`echo '${k}=${escaped}' >> .env`);
807
+ }
808
+ await sshFn(`cd /opt/foundation-compose && ${sedParts.join(" && ")}`, 15000, vmEnv);
809
+
810
+ console.log(chalk.cyan(`\n Running fops bootstrap on "${state.vmName}"…\n`));
811
+ const { MUX_OPTS } = await import("../azure.js");
812
+ const bootstrapEnv = vmEnv ? { ...process.env, ...vmEnv } : undefined;
813
+ const remoteCmd = "cd /opt/foundation-compose && (fops bootstrap --yes || /usr/local/bin/fops bootstrap --yes || /usr/bin/fops bootstrap --yes)";
814
+ const { exitCode } = await execa("ssh", [
815
+ ...MUX_OPTS(ip, user), `${user}@${ip}`, remoteCmd,
816
+ ], { timeout: 600000, reject: false, stdout: ["inherit"], stderr: ["inherit"], env: bootstrapEnv });
817
+
818
+ if (state.knockSequence?.length) await closeKnock(sshFn, { quiet: true });
819
+
820
+ if (exitCode === 0) {
821
+ console.log(chalk.green("\n ✓ Bootstrap complete on VM\n"));
822
+ } else {
823
+ const normalized = exitCode === 255 || exitCode === -1 ? 1 : exitCode;
824
+ console.error(chalk.red(`\n Bootstrap failed (exit ${normalized}).\n`));
825
+ process.exitCode = 1;
826
+ }
827
+ });
828
+
829
+ // ── logs ──────────────────────────────────────────────────────────────────
830
+ azure
831
+ .command("logs [name] [service]")
832
+ .description("Tail Foundation logs from the VM (service 'up' = tail /tmp/fops-up.log)")
833
+ .option("--vm-name <name>", "Target VM (default: active VM)")
834
+ .action(async (name, service, opts) => {
835
+ const { azureLogs } = await import("../azure.js");
836
+ await azureLogs(service, { vmName: opts.vmName || name });
837
+ });
838
+
839
+ // ── context ───────────────────────────────────────────────────────────────
840
+ azure
841
+ .command("context [name]")
842
+ .description("Set local Docker CLI to talk to the remote VM via SSH")
843
+ .option("--vm-name <name>", "Target VM (default: active VM)")
844
+ .option("--reset", "Switch back to the default Docker context")
845
+ .action(async (name, opts) => {
846
+ const { azureContext } = await import("../azure.js");
847
+ await azureContext({ vmName: opts.vmName || name, reset: opts.reset });
848
+ });
849
+
850
+ // ── kubectl ───────────────────────────────────────────────────────────────
851
+ azure
852
+ .command("kubectl <name>")
853
+ .description("Generate / merge kubeconfig for an AKS cluster")
854
+ .option("--profile <subscription>", "Azure subscription name or ID")
855
+ .option("--admin", "Get admin credentials (cluster-admin role)")
856
+ .action(async (name, opts) => {
857
+ const { aksKubeconfig } = await import("../azure-aks.js");
858
+ await aksKubeconfig({ clusterName: name, profile: opts.profile, admin: opts.admin });
859
+ });
860
+
861
+ // ── grant ─────────────────────────────────────────────────────────────────
862
+ const grant = azure.command("grant").description("Grant roles to users on the VM");
863
+ grant
864
+ .command("admin [name]")
865
+ .description("Grant Foundation Admin role to users on the VM")
866
+ .option("--vm-name <name>", "Target VM (default: active VM)")
867
+ .option("--username <email>", "Grant to a specific user by email")
868
+ .option("--auth0-sub <sub>", "Grant to a specific Auth0 subject")
869
+ .action(async (name, opts) => {
870
+ const { azureGrantAdmin } = await import("../azure.js");
871
+ await azureGrantAdmin({ vmName: opts.vmName || name, username: opts.username, auth0Sub: opts.auth0Sub });
872
+ });
873
+
874
+ // ── cost ──────────────────────────────────────────────────────────────────
875
+ azure
876
+ .command("cost")
877
+ .description("Show current Azure costs by service")
878
+ .option("--profile <subscription>", "Azure subscription name or ID")
879
+ .option("--days <days>", "Days to look back (default: 30)", "30")
880
+ .action(async (opts) => {
881
+ const { azureCost } = await import("../azure.js");
882
+ await azureCost({ profile: opts.profile, days: opts.days });
883
+ });
884
+
885
+ // ── subscriptions ─────────────────────────────────────────────────────────
886
+ azure
887
+ .command("subscriptions")
888
+ .description("List available Azure subscriptions")
889
+ .action(async () => {
890
+ const { azureSubscriptions } = await import("../azure.js");
891
+ await azureSubscriptions();
892
+ });
893
+ }