@marcusrbrown/infra 0.25.2 → 0.26.1

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 (2) hide show
  1. package/dist/cli.js +246 -12
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -6950,7 +6950,7 @@ import { goke } from "goke";
6950
6950
  // package.json
6951
6951
  var package_default = {
6952
6952
  name: "@marcusrbrown/infra",
6953
- version: "0.25.2",
6953
+ version: "0.26.1",
6954
6954
  description: "Infrastructure management CLI \u2014 deploy automation, health checks, and MCP bridge",
6955
6955
  keywords: [
6956
6956
  "infra",
@@ -6986,7 +6986,7 @@ var package_default = {
6986
6986
  },
6987
6987
  dependencies: {
6988
6988
  "@clack/prompts": "^1.2.0",
6989
- "@goke/mcp": "^0.0.13",
6989
+ "@goke/mcp": "^0.1.0",
6990
6990
  goke: "^6.3.2",
6991
6991
  "string-dedent": "^3.0.2",
6992
6992
  zod: "^4.3.6"
@@ -12129,6 +12129,7 @@ function getGatewayDeployEnv() {
12129
12129
  GATEWAY_OPERATOR_PUSH_VAPID_PRIVATE_KEY: process.env.GATEWAY_OPERATOR_PUSH_VAPID_PRIVATE_KEY ?? "",
12130
12130
  GATEWAY_OPERATOR_PUSH_VAPID_SUBJECT: process.env.GATEWAY_OPERATOR_PUSH_VAPID_SUBJECT ?? "",
12131
12131
  GATEWAY_OPERATOR_PUSH_VAPID_KEY_VERSION: process.env.GATEWAY_OPERATOR_PUSH_VAPID_KEY_VERSION ?? "",
12132
+ GATEWAY_BINDER_ALLOWLIST: process.env.GATEWAY_BINDER_ALLOWLIST ?? "",
12132
12133
  GATEWAY_VPC_IP: process.env.GATEWAY_VPC_IP ?? "",
12133
12134
  DASHBOARD_VPC_IP: process.env.DASHBOARD_VPC_IP ?? "",
12134
12135
  GATEWAY_IMAGE_DIGEST: process.env.GATEWAY_IMAGE_DIGEST ?? "",
@@ -12448,7 +12449,176 @@ function registerGatewayRestore(cli) {
12448
12449
 
12449
12450
  // src/commands/gateway/status.ts
12450
12451
  import { z as z26 } from "zod";
12452
+
12453
+ // src/commands/gateway/workspace-memory.ts
12451
12454
  var COMPOSE_PROJECT_DIR5 = "/opt/gateway/deploy";
12455
+ var MIB = 1024 * 1024;
12456
+ var READYZ_PROBE_HEADROOM_BYTES = 64 * MIB;
12457
+ var OPENCODE_STATES = ["starting", "ready", "down", "degraded"];
12458
+ var SECTION_MARKERS = {
12459
+ id: "@@ws-id@@",
12460
+ inspect: "@@ws-inspect@@",
12461
+ cgroup: "@@ws-cgroup@@",
12462
+ readyz: "@@ws-readyz@@"
12463
+ };
12464
+ var READYZ_SKIPPED_SENTINEL = "@@ws-readyz-skipped@@";
12465
+ var READYZ_URL = "http://127.0.0.1:9100/readyz";
12466
+ function buildReadyzProbeSnippet(url = READYZ_URL) {
12467
+ return `fetch("${url}",{signal:AbortSignal.timeout(3000)}).then(r=>r.text()).then(t=>process.stdout.write(t)).catch(()=>{})`;
12468
+ }
12469
+ function buildWorkspaceProbeScript() {
12470
+ return [
12471
+ `id=$(docker compose --project-directory ${COMPOSE_PROJECT_DIR5} ps -q workspace)`,
12472
+ `echo ${SECTION_MARKERS.id}`,
12473
+ 'echo "$id"',
12474
+ `echo ${SECTION_MARKERS.inspect}`,
12475
+ `docker inspect --format '{{.HostConfig.Memory}} {{.State.OOMKilled}} {{.RestartCount}} {{.State.StartedAt}}' "$id"`,
12476
+ `echo ${SECTION_MARKERS.cgroup}`,
12477
+ 'docker exec "$id" cat /sys/fs/cgroup/memory.current /sys/fs/cgroup/memory.events',
12478
+ `echo ${SECTION_MARKERS.readyz}`,
12479
+ `limit=$(docker inspect --format '{{.HostConfig.Memory}}' "$id")`,
12480
+ 'cur=$(docker exec "$id" cat /sys/fs/cgroup/memory.current)',
12481
+ "run=no",
12482
+ `case "$limit" in ''|*[!0-9]*) ;; *)`,
12483
+ ` case "$cur" in ''|*[!0-9]*) ;; *)`,
12484
+ ` if [ "$limit" -gt 0 ] && [ $(( limit - cur )) -gt ${READYZ_PROBE_HEADROOM_BYTES} ]; then run=yes; fi ;;`,
12485
+ " esac ;;",
12486
+ "esac",
12487
+ 'if [ "$run" = yes ]; then',
12488
+ ` docker exec "$id" node -e '${buildReadyzProbeSnippet()}'`,
12489
+ "else",
12490
+ ` echo ${READYZ_SKIPPED_SENTINEL}`,
12491
+ "fi",
12492
+ "exit 0"
12493
+ ].join(`
12494
+ `);
12495
+ }
12496
+ function splitSections(stdoutText) {
12497
+ const markers = new Set(Object.values(SECTION_MARKERS));
12498
+ const sections = new Map;
12499
+ let current;
12500
+ for (const line of stdoutText.split(`
12501
+ `)) {
12502
+ const trimmed = line.trim();
12503
+ if (markers.has(trimmed)) {
12504
+ if (sections.has(trimmed))
12505
+ return;
12506
+ current = [];
12507
+ sections.set(trimmed, current);
12508
+ } else if (current !== undefined) {
12509
+ current.push(trimmed);
12510
+ }
12511
+ }
12512
+ return sections;
12513
+ }
12514
+ function parseCount(raw) {
12515
+ if (raw === undefined || !/^\d+$/.test(raw))
12516
+ return;
12517
+ const value = Number(raw);
12518
+ return Number.isSafeInteger(value) ? value : undefined;
12519
+ }
12520
+ function parseOpencodeState(body) {
12521
+ if (body.length === 0)
12522
+ return "unknown";
12523
+ let parsed;
12524
+ try {
12525
+ parsed = JSON.parse(body);
12526
+ } catch {
12527
+ return;
12528
+ }
12529
+ if (typeof parsed !== "object" || parsed === null || !("opencode" in parsed))
12530
+ return "unknown";
12531
+ return OPENCODE_STATES.find((state) => state === parsed.opencode) ?? "unknown";
12532
+ }
12533
+ var unknownWorkspace = (reason) => ({ status: "unknown", reason });
12534
+ function parseWorkspaceProbeOutput(stdoutText) {
12535
+ const sections = splitSections(stdoutText);
12536
+ const idLines = sections?.get(SECTION_MARKERS.id);
12537
+ const inspectLines = sections?.get(SECTION_MARKERS.inspect);
12538
+ const cgroupLines = sections?.get(SECTION_MARKERS.cgroup);
12539
+ const readyzLines = sections?.get(SECTION_MARKERS.readyz);
12540
+ if (!idLines || !inspectLines || !cgroupLines || !readyzLines)
12541
+ return unknownWorkspace("incomplete probe output");
12542
+ const ids = idLines.filter((line) => line.length > 0);
12543
+ if (ids.length !== 1 || !/^[\da-f]{12,64}$/i.test(ids[0] ?? "")) {
12544
+ return unknownWorkspace("workspace container not found");
12545
+ }
12546
+ const inspectFields = inspectLines.filter((line) => line.length > 0).join(" ").split(/\s+/);
12547
+ const [memoryRaw, oomKilledRaw, restartRaw, startedAt] = inspectFields;
12548
+ const limitBytes = parseCount(memoryRaw);
12549
+ const restartCount = parseCount(restartRaw);
12550
+ if (inspectFields.length !== 4 || limitBytes === undefined || restartCount === undefined || oomKilledRaw !== "true" && oomKilledRaw !== "false" || startedAt === undefined || !/^\d{4}-\d\d-\d\dT/.test(startedAt)) {
12551
+ return unknownWorkspace("unparsable docker inspect output");
12552
+ }
12553
+ const cgroup = cgroupLines.filter((line) => line.length > 0);
12554
+ const currentBytes = parseCount(cgroup[0]);
12555
+ const events = new Map;
12556
+ for (const line of cgroup.slice(1)) {
12557
+ const [key, value] = line.split(/\s+/);
12558
+ if (key !== undefined && value !== undefined)
12559
+ events.set(key, value);
12560
+ }
12561
+ const maxEvents = parseCount(events.get("max"));
12562
+ const oomKills = parseCount(events.get("oom_kill"));
12563
+ if (currentBytes === undefined || maxEvents === undefined || oomKills === undefined) {
12564
+ return unknownWorkspace("unparsable cgroup memory output");
12565
+ }
12566
+ const probeAllowed = limitBytes > 0 && limitBytes - currentBytes > READYZ_PROBE_HEADROOM_BYTES;
12567
+ let opencode;
12568
+ if (readyzLines.includes(READYZ_SKIPPED_SENTINEL))
12569
+ opencode = "skipped";
12570
+ else if (probeAllowed)
12571
+ opencode = parseOpencodeState(readyzLines.join("").trim());
12572
+ else
12573
+ opencode = "unknown";
12574
+ if (opencode === undefined)
12575
+ return unknownWorkspace("unparsable readyz body");
12576
+ return {
12577
+ status: "ok",
12578
+ limitBytes,
12579
+ currentBytes,
12580
+ maxEvents,
12581
+ oomKills,
12582
+ oomKilled: oomKilledRaw === "true",
12583
+ restartCount,
12584
+ startedAt,
12585
+ opencode
12586
+ };
12587
+ }
12588
+ var formatMiB = (bytes) => `${Math.round(bytes / MIB)}`;
12589
+ function formatOpencode(status) {
12590
+ if (status.opencode !== "skipped")
12591
+ return status.opencode;
12592
+ if (status.limitBytes === 0)
12593
+ return "skipped (no limit)";
12594
+ if (status.limitBytes - status.currentBytes <= READYZ_PROBE_HEADROOM_BYTES) {
12595
+ return `skipped (within ${READYZ_PROBE_HEADROOM_BYTES / MIB} MiB of the limit)`;
12596
+ }
12597
+ return "skipped (memory read unavailable)";
12598
+ }
12599
+ function formatWorkspaceMemory(status) {
12600
+ if (status.status === "unknown")
12601
+ return [`Workspace memory: unknown (${status.reason})`];
12602
+ return [
12603
+ "Workspace memory",
12604
+ ` Limit: ${status.limitBytes === 0 ? "none" : `${formatMiB(status.limitBytes)} MiB`}`,
12605
+ ` Usage: ${formatMiB(status.currentBytes)} MiB`,
12606
+ ` Max events: ${status.maxEvents}`,
12607
+ ` OOM kills: ${status.oomKills} (counted since the container last started)`,
12608
+ ` Restarts: ${status.restartCount} (started ${status.startedAt})`,
12609
+ ` OOMKilled: ${status.oomKilled}`,
12610
+ ` OpenCode: ${formatOpencode(status)}`
12611
+ ];
12612
+ }
12613
+ function formatWorkspaceSummary(status) {
12614
+ if (status.status === "unknown")
12615
+ return "workspace=unknown";
12616
+ const mem = status.limitBytes === 0 ? `mem=${formatMiB(status.currentBytes)}MiB limit=none` : `mem=${formatMiB(status.currentBytes)}/${formatMiB(status.limitBytes)}MiB`;
12617
+ return `workspace ${mem} oom_kills=${status.oomKills} opencode=${status.opencode}`;
12618
+ }
12619
+
12620
+ // src/commands/gateway/status.ts
12621
+ var WORKSPACE_PROBE_TIMEOUT_MS = 1e4;
12452
12622
  function parseComposePsOutput2(stdoutText) {
12453
12623
  const trimmed = stdoutText.trim();
12454
12624
  if (trimmed.length === 0)
@@ -12534,9 +12704,55 @@ async function getGatewayComposeStatus(host, spawn = defaultSpawn5) {
12534
12704
  const ok = isAllRunning2(services);
12535
12705
  return { ok, services };
12536
12706
  }
12537
- async function getGatewayStatusSummary(host) {
12538
- const result = await getGatewayComposeStatus(host);
12539
- if (!result.ok || result.services.length === 0) {
12707
+ async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs = WORKSPACE_PROBE_TIMEOUT_MS) {
12708
+ validateGatewayHost(host);
12709
+ const knownHostsArgs = buildKnownHostsArgs();
12710
+ const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.GATEWAY_SSH_KEY);
12711
+ const sshCmd = [
12712
+ "ssh",
12713
+ "-o",
12714
+ "BatchMode=yes",
12715
+ "-o",
12716
+ "StrictHostKeyChecking=yes",
12717
+ ...knownHostsArgs,
12718
+ ...identityArgs,
12719
+ `root@${host}`,
12720
+ buildWorkspaceProbeScript()
12721
+ ];
12722
+ const env = {
12723
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
12724
+ HOME: process.env.HOME ?? "/tmp",
12725
+ ...process.env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK } : {}
12726
+ };
12727
+ let timer;
12728
+ const timedOut = new Promise((resolve) => {
12729
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
12730
+ });
12731
+ try {
12732
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
12733
+ const collected = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
12734
+ collected.catch(() => {
12735
+ return;
12736
+ });
12737
+ const outcome = await Promise.race([collected, timedOut]);
12738
+ if (outcome === "timeout") {
12739
+ child.kill?.();
12740
+ return unknownWorkspace(`timed out after ${Math.round(timeoutMs / 1000)}s`);
12741
+ }
12742
+ const [stdoutText, , exitCode] = outcome;
12743
+ if (exitCode !== 0)
12744
+ return unknownWorkspace(`SSH command failed (exit ${exitCode})`);
12745
+ return parseWorkspaceProbeOutput(stdoutText);
12746
+ } catch {
12747
+ return unknownWorkspace("SSH command could not run");
12748
+ } finally {
12749
+ clearTimeout(timer);
12750
+ cleanup();
12751
+ }
12752
+ }
12753
+ async function getGatewayStatusSummary(host, spawn) {
12754
+ const result = await getGatewayComposeStatus(host, spawn);
12755
+ if (result.services.length === 0) {
12540
12756
  const errorMsg = result.error ?? "No services reported";
12541
12757
  return {
12542
12758
  app: "gateway",
@@ -12548,16 +12764,19 @@ async function getGatewayStatusSummary(host) {
12548
12764
  };
12549
12765
  }
12550
12766
  const rows = result.services.map((s) => `${s.service}:${s.state}/${s.health}`).join(", ");
12767
+ const workspace = await getGatewayWorkspaceStatus(host, spawn);
12768
+ const prefix = result.ok ? `OK: ${rows}` : `DEGRADED: ${rows}`;
12551
12769
  return {
12552
12770
  app: "gateway",
12553
- http: `OK: ${rows}`,
12771
+ http: `${prefix}; ${formatWorkspaceSummary(workspace)}`,
12554
12772
  lastDeploy: "\u2014",
12555
12773
  version: "\u2014",
12556
12774
  contentHash: "\u2014",
12557
- usageStats: "\u2014"
12775
+ usageStats: "\u2014",
12776
+ workspace
12558
12777
  };
12559
12778
  }
12560
- async function gatewayStatusAction(options, ctx, spawn) {
12779
+ async function gatewayStatusAction(options, ctx, spawn, workspaceTimeoutMs) {
12561
12780
  const hostEnvKey = options.key ?? "GATEWAY_HOST";
12562
12781
  const host = process.env[hostEnvKey];
12563
12782
  if (!host) {
@@ -12565,14 +12784,24 @@ async function gatewayStatusAction(options, ctx, spawn) {
12565
12784
  ctx.process.exit(1);
12566
12785
  return;
12567
12786
  }
12568
- ctx.console.log("Gateway status");
12569
- ctx.console.log("");
12787
+ const json = options.json === true;
12788
+ if (!json) {
12789
+ ctx.console.log("Gateway status");
12790
+ ctx.console.log("");
12791
+ }
12570
12792
  const result = await getGatewayComposeStatus(host, spawn);
12571
12793
  if (!result.ok && result.services.length === 0) {
12572
12794
  ctx.console.error(`Error: ${result.error ?? "Unknown error"}`);
12573
12795
  ctx.process.exit(1);
12574
12796
  return;
12575
12797
  }
12798
+ if (json) {
12799
+ const workspace = await getGatewayWorkspaceStatus(host, spawn, workspaceTimeoutMs);
12800
+ ctx.console.log(JSON.stringify({ ok: result.ok, services: result.services, workspace }));
12801
+ if (!result.ok)
12802
+ ctx.process.exit(1);
12803
+ return;
12804
+ }
12576
12805
  ctx.console.log("Service State Health");
12577
12806
  ctx.console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
12578
12807
  for (const row of result.services) {
@@ -12581,6 +12810,11 @@ async function gatewayStatusAction(options, ctx, spawn) {
12581
12810
  ctx.console.log(`${svc} ${state} ${row.health}`);
12582
12811
  }
12583
12812
  ctx.console.log("");
12813
+ const workspace = await getGatewayWorkspaceStatus(host, spawn, workspaceTimeoutMs);
12814
+ for (const line of formatWorkspaceMemory(workspace)) {
12815
+ ctx.console.log(line);
12816
+ }
12817
+ ctx.console.log("");
12584
12818
  if (result.ok) {
12585
12819
  ctx.console.log("Status: OK");
12586
12820
  } else {
@@ -12589,7 +12823,7 @@ async function gatewayStatusAction(options, ctx, spawn) {
12589
12823
  }
12590
12824
  }
12591
12825
  function registerGatewayStatus(cli) {
12592
- cli.command("gateway status", "Show operational health of the gateway deployment via docker compose ps.").option("--key [key]", z26.string().describe("Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.")).action((options, ctx) => gatewayStatusAction(options, ctx));
12826
+ cli.command("gateway status", "Show operational health of the gateway deployment via docker compose ps.").option("--key [key]", z26.string().describe("Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.")).option("--json", z26.boolean().describe("Output a single JSON object with ok, services and the structured workspace memory status.")).action((options, ctx) => gatewayStatusAction(options, ctx));
12593
12827
  }
12594
12828
 
12595
12829
  // src/commands/gateway/index.ts
@@ -14123,7 +14357,7 @@ function registerStatus(cli, dependencies = {
14123
14357
  getVpnStatusSummary,
14124
14358
  getBrokerStatusSummary
14125
14359
  }) {
14126
- cli.command("status", "Show status of all deployments").option("--json", z37.boolean().describe("Output machine-readable JSON with keeweb, cliproxy, gateway, umami, dashboard, vpn, and broker summary objects.")).option("--verbose", z37.boolean().describe("Include verbose per-app health check details when building the summary rows.")).action((options, ctx) => unifiedStatusAction(options, ctx, dependencies));
14360
+ cli.command("status", "Show status of all deployments").option("--json", z37.boolean().describe("Output machine-readable JSON with keeweb, cliproxy, gateway, umami, dashboard, vpn, and broker summary objects. The gateway object also carries a structured workspace field.")).option("--verbose", z37.boolean().describe("Include verbose per-app health check details when building the summary rows.")).action((options, ctx) => unifiedStatusAction(options, ctx, dependencies));
14127
14361
  }
14128
14362
 
14129
14363
  // src/cli.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.25.2",
3
+ "version": "0.26.1",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@clack/prompts": "^1.2.0",
39
- "@goke/mcp": "^0.0.13",
39
+ "@goke/mcp": "^0.1.0",
40
40
  "goke": "^6.3.2",
41
41
  "string-dedent": "^3.0.2",
42
42
  "zod": "^4.3.6"