@marcusrbrown/infra 0.26.3 → 0.27.0

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 (3) hide show
  1. package/README.md +5 -1
  2. package/dist/cli.js +1500 -419
  3. package/package.json +1 -1
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.26.3",
6953
+ version: "0.27.0",
6954
6954
  description: "Infrastructure management CLI \u2014 deploy automation, health checks, and MCP bridge",
6955
6955
  keywords: [
6956
6956
  "infra",
@@ -9763,49 +9763,207 @@ function registerBrokerLogs(cli) {
9763
9763
 
9764
9764
  // src/commands/broker/status.ts
9765
9765
  import { z as z9 } from "zod";
9766
- var DEFAULT_BROKER_URL = "https://broker.fro.bot";
9767
- var HTTP_TIMEOUT_MS3 = 1e4;
9768
- async function checkBrokerHealth(baseUrl) {
9769
- const url = `${baseUrl}/healthz`;
9770
- const startedAt = performance.now();
9766
+
9767
+ // src/status/public-probes.ts
9768
+ var PUBLIC_PROBE_TIMEOUT_MS = 1e4;
9769
+ var PUBLIC_PROBE_RETRIES = 1;
9770
+ var MAX_BODY_CHARS = 64 * 1024;
9771
+ var KEEWEB_ORIGIN = "https://kw.igg.ms";
9772
+ var CLIPROXY_ORIGIN = "https://cliproxy.fro.bot";
9773
+ var BROKER_ORIGIN = "https://broker.fro.bot";
9774
+ var UMAMI_ORIGIN = "https://metrics.fro.bot";
9775
+ var DASHBOARD_ORIGIN = "https://dashboard.fro.bot";
9776
+ var PUBLIC_REACHABILITY_ID = "public-reachability";
9777
+ function isTimeoutError(error) {
9778
+ if (typeof error !== "object" || error === null || !("name" in error)) {
9779
+ return false;
9780
+ }
9781
+ return error.name === "TimeoutError" || error.name === "AbortError";
9782
+ }
9783
+ function discardBody(response) {
9771
9784
  try {
9772
- const response = await fetch(url, {
9773
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
9785
+ response.body?.cancel().catch(() => {
9786
+ return;
9787
+ });
9788
+ } catch {}
9789
+ }
9790
+ async function readBoundedBody(response, signal) {
9791
+ const reader = response.body?.getReader();
9792
+ if (reader === undefined) {
9793
+ return "";
9794
+ }
9795
+ const aborted = new Promise((_resolve, reject) => {
9796
+ if (signal.aborted) {
9797
+ reject(signal.reason);
9798
+ }
9799
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
9800
+ });
9801
+ aborted.catch(() => {
9802
+ return;
9803
+ });
9804
+ const decoder = new TextDecoder;
9805
+ let text = "";
9806
+ try {
9807
+ while (text.length < MAX_BODY_CHARS) {
9808
+ const chunk = await Promise.race([reader.read(), aborted]);
9809
+ if (chunk.done) {
9810
+ text += decoder.decode();
9811
+ break;
9812
+ }
9813
+ text += decoder.decode(chunk.value, { stream: true });
9814
+ }
9815
+ return text.slice(0, MAX_BODY_CHARS);
9816
+ } catch {
9817
+ return;
9818
+ } finally {
9819
+ reader.cancel().catch(() => {
9820
+ return;
9774
9821
  });
9775
- const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
9822
+ }
9823
+ }
9824
+ async function httpGet(url, options) {
9825
+ const retries = options.retries ?? 0;
9826
+ let lastError;
9827
+ for (let attempt = 0;attempt <= retries; attempt++) {
9828
+ const startedAt = performance.now();
9829
+ const signal = AbortSignal.timeout(options.timeoutMs);
9830
+ let response;
9831
+ try {
9832
+ response = await fetch(url, { signal });
9833
+ } catch (error) {
9834
+ lastError = {
9835
+ kind: "network-error",
9836
+ error,
9837
+ timedOut: isTimeoutError(error),
9838
+ durationMs: Math.max(0, performance.now() - startedAt)
9839
+ };
9840
+ continue;
9841
+ }
9842
+ let body;
9843
+ if (!response.ok) {
9844
+ discardBody(response);
9845
+ } else if (options.readBody === true) {
9846
+ body = await readBoundedBody(response, signal);
9847
+ }
9776
9848
  return {
9849
+ kind: "response",
9850
+ response,
9851
+ status: response.status,
9777
9852
  ok: response.ok,
9778
- summary: `GET ${url} \u2192 ${response.status} (${elapsedMs}ms)`
9779
- };
9780
- } catch (error) {
9781
- const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
9782
- const message = error instanceof Error ? error.message : String(error);
9783
- return {
9784
- ok: false,
9785
- summary: `Request failed after ${elapsedMs}ms: ${message}`
9853
+ durationMs: Math.max(0, performance.now() - startedAt),
9854
+ body
9786
9855
  };
9787
9856
  }
9857
+ return lastError;
9788
9858
  }
9789
- async function getBrokerStatusSummary(host) {
9790
- if (!host) {
9859
+ function parseJsonObject(body) {
9860
+ let parsed;
9861
+ try {
9862
+ parsed = JSON.parse(body);
9863
+ } catch {
9864
+ return;
9865
+ }
9866
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
9867
+ return;
9868
+ }
9869
+ return parsed;
9870
+ }
9871
+ function checkUmamiHeartbeat(body) {
9872
+ return parseJsonObject(body)?.ok === true ? { ok: true } : { ok: false };
9873
+ }
9874
+ var CONTRACT_VERSION_PATTERN = /^[\w.+-]{1,64}$/;
9875
+ function checkOperatorHealth(body) {
9876
+ const version = parseJsonObject(body)?.contractVersion;
9877
+ if (typeof version !== "string" || !CONTRACT_VERSION_PATTERN.test(version)) {
9878
+ return { ok: false };
9879
+ }
9880
+ return { ok: true, detail: { contractVersion: version } };
9881
+ }
9882
+ function stripTrailingSlash2(value) {
9883
+ return value.endsWith("/") ? value.slice(0, -1) : value;
9884
+ }
9885
+ async function runProbe(spec, options = {}) {
9886
+ const url = `${stripTrailingSlash2(options.baseUrl ?? spec.origin)}${spec.path}`;
9887
+ const attempt = await httpGet(url, {
9888
+ timeoutMs: options.timeoutMs ?? PUBLIC_PROBE_TIMEOUT_MS,
9889
+ retries: PUBLIC_PROBE_RETRIES,
9890
+ readBody: spec.checkBody !== undefined
9891
+ });
9892
+ const observedAt = (options.now?.() ?? new Date).toISOString();
9893
+ const durationMs = Math.round(attempt.durationMs);
9894
+ const make = (result, detail) => ({
9895
+ id: PUBLIC_REACHABILITY_ID,
9896
+ kind: "reachability",
9897
+ result,
9898
+ observedAt,
9899
+ source: "http",
9900
+ detail
9901
+ });
9902
+ if (attempt.kind === "network-error") {
9903
+ let reason = attempt.timedOut ? "timeout" : "network-error";
9904
+ if (spec.unreachableReason === "dependency-unreachable") {
9905
+ reason = "dependency-unreachable";
9906
+ }
9907
+ return make("unknown", { durationMs, reason });
9908
+ }
9909
+ const detail = { statusCode: attempt.status, durationMs };
9910
+ if (!attempt.ok) {
9911
+ return make("fail", detail);
9912
+ }
9913
+ if (spec.checkBody === undefined) {
9914
+ return make("pass", detail);
9915
+ }
9916
+ const verdict = spec.checkBody(attempt.body ?? "");
9917
+ return verdict.ok ? make("pass", { ...detail, ...verdict.detail }) : make("fail", detail);
9918
+ }
9919
+ function probeKeeweb(options) {
9920
+ return runProbe({ origin: KEEWEB_ORIGIN, path: "/", unreachableReason: "network-error" }, options);
9921
+ }
9922
+ function probeCliproxy(options) {
9923
+ return runProbe({ origin: CLIPROXY_ORIGIN, path: "/healthz", unreachableReason: "network-error" }, options);
9924
+ }
9925
+ function probeBroker(options) {
9926
+ return runProbe({ origin: BROKER_ORIGIN, path: "/healthz", unreachableReason: "network-error" }, options);
9927
+ }
9928
+ function probeUmami(options) {
9929
+ return runProbe({ origin: UMAMI_ORIGIN, path: "/api/heartbeat", checkBody: checkUmamiHeartbeat, unreachableReason: "network-error" }, options);
9930
+ }
9931
+ function probeDashboard(options) {
9932
+ return runProbe({ origin: DASHBOARD_ORIGIN, path: "/api/healthz", unreachableReason: "network-error" }, options);
9933
+ }
9934
+ function probeGateway(options) {
9935
+ return runProbe({
9936
+ origin: DASHBOARD_ORIGIN,
9937
+ path: "/operator/health",
9938
+ checkBody: checkOperatorHealth,
9939
+ unreachableReason: "dependency-unreachable"
9940
+ }, options);
9941
+ }
9942
+ var PUBLIC_PROBES = {
9943
+ keeweb: probeKeeweb,
9944
+ cliproxy: probeCliproxy,
9945
+ gateway: probeGateway,
9946
+ umami: probeUmami,
9947
+ dashboard: probeDashboard,
9948
+ broker: probeBroker
9949
+ };
9950
+
9951
+ // src/commands/broker/status.ts
9952
+ var DEFAULT_BROKER_URL = "https://broker.fro.bot";
9953
+ async function checkBrokerHealth(baseUrl) {
9954
+ const url = `${baseUrl}/healthz`;
9955
+ const attempt = await httpGet(url, { timeoutMs: PUBLIC_PROBE_TIMEOUT_MS });
9956
+ const elapsedMs = Math.max(0, Math.round(attempt.durationMs));
9957
+ if (attempt.kind === "response") {
9791
9958
  return {
9792
- app: "broker",
9793
- http: "\u2014 (host not set)",
9794
- lastDeploy: "\u2014",
9795
- version: "\u2014",
9796
- contentHash: "\u2014",
9797
- usageStats: "\u2014"
9959
+ ok: attempt.ok,
9960
+ summary: `GET ${url} \u2192 ${attempt.status} (${elapsedMs}ms)`
9798
9961
  };
9799
9962
  }
9800
- const baseUrl = `https://${host}`;
9801
- const health = await checkBrokerHealth(baseUrl);
9963
+ const message = attempt.error instanceof Error ? attempt.error.message : String(attempt.error);
9802
9964
  return {
9803
- app: "broker",
9804
- http: health.ok ? `OK: ${health.summary}` : `ERROR: ${health.summary}`,
9805
- lastDeploy: "\u2014",
9806
- version: "\u2014",
9807
- contentHash: "\u2014",
9808
- usageStats: "\u2014"
9965
+ ok: false,
9966
+ summary: `Request failed after ${elapsedMs}ms: ${message}`
9809
9967
  };
9810
9968
  }
9811
9969
  async function brokerStatusAction(options, ctx) {
@@ -9867,11 +10025,11 @@ function registerBrokerCommands(cli) {
9867
10025
  import { chmodSync } from "fs";
9868
10026
  import { z as z10 } from "zod";
9869
10027
  var DEFAULT_CLIPROXY_URL3 = "https://cliproxy.fro.bot";
9870
- function stripTrailingSlash2(value) {
10028
+ function stripTrailingSlash3(value) {
9871
10029
  return value.endsWith("/") ? value.slice(0, -1) : value;
9872
10030
  }
9873
10031
  function resolveBaseUrl2(input) {
9874
- return stripTrailingSlash2(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL3);
10032
+ return stripTrailingSlash3(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL3);
9875
10033
  }
9876
10034
  var resolveManagementKey2 = resolveManagementKey;
9877
10035
  function parseBoolean(value) {
@@ -10089,11 +10247,11 @@ function registerCliproxyDeploy(cli) {
10089
10247
  // src/commands/cliproxy/keys.ts
10090
10248
  import { z as z12 } from "zod";
10091
10249
  var DEFAULT_CLIPROXY_URL4 = "https://cliproxy.fro.bot";
10092
- function stripTrailingSlash3(value) {
10250
+ function stripTrailingSlash4(value) {
10093
10251
  return value.endsWith("/") ? value.slice(0, -1) : value;
10094
10252
  }
10095
10253
  function resolveBaseUrl3(input) {
10096
- return stripTrailingSlash3(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL4);
10254
+ return stripTrailingSlash4(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL4);
10097
10255
  }
10098
10256
  function resolveManagementKey3(input) {
10099
10257
  const key = input ?? process.env.CLIPROXY_MANAGEMENT_KEY;
@@ -10272,7 +10430,7 @@ function registerCliproxyLogin(cli) {
10272
10430
  // src/commands/cliproxy/models.ts
10273
10431
  import { z as z14 } from "zod";
10274
10432
  var DEFAULT_CLIPROXY_URL5 = "https://cliproxy.fro.bot";
10275
- function stripTrailingSlash4(value) {
10433
+ function stripTrailingSlash5(value) {
10276
10434
  return value.endsWith("/") ? value.slice(0, -1) : value;
10277
10435
  }
10278
10436
  var modelEntrySchema2 = z14.object({
@@ -10344,8 +10502,8 @@ function formatVerboseEntry(entry) {
10344
10502
  }
10345
10503
  async function cliproxyModelsAction(options, ctx) {
10346
10504
  try {
10347
- const baseUrl = stripTrailingSlash4(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL5);
10348
- const trustedUrl = stripTrailingSlash4(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL5);
10505
+ const baseUrl = stripTrailingSlash5(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL5);
10506
+ const trustedUrl = stripTrailingSlash5(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL5);
10349
10507
  const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl;
10350
10508
  const key = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY);
10351
10509
  if (!key) {
@@ -10397,11 +10555,28 @@ import { z as z16 } from "zod";
10397
10555
  import { z as z15 } from "zod";
10398
10556
 
10399
10557
  // src/lib/redact.ts
10558
+ function lowerAscii(value) {
10559
+ let result = "";
10560
+ for (const char of value) {
10561
+ const code = char.codePointAt(0) ?? 0;
10562
+ result += code >= 65 && code <= 90 ? String.fromCodePoint(code + 32) : char;
10563
+ }
10564
+ return result;
10565
+ }
10400
10566
  function redactHost(text, host) {
10401
10567
  if (!host)
10402
10568
  return text;
10403
- const escaped = host.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
10404
- return text.replaceAll(new RegExp(escaped, "gi"), "<host>");
10569
+ const foldedText = lowerAscii(text);
10570
+ const foldedHost = lowerAscii(host);
10571
+ let result = "";
10572
+ let cursor = 0;
10573
+ let match = foldedText.indexOf(foldedHost, cursor);
10574
+ while (match !== -1) {
10575
+ result += `${text.slice(cursor, match)}<host>`;
10576
+ cursor = match + foldedHost.length;
10577
+ match = foldedText.indexOf(foldedHost, cursor);
10578
+ }
10579
+ return result + text.slice(cursor);
10405
10580
  }
10406
10581
 
10407
10582
  // ../shared/server/droplet-helpers.ts
@@ -10443,6 +10618,125 @@ function buildIdentityArgs(privateKey) {
10443
10618
  return { args: ["-i", path, "-o", "IdentitiesOnly=yes"], cleanup };
10444
10619
  }
10445
10620
 
10621
+ // src/status/collect.ts
10622
+ function observe(spec, result, options = {}) {
10623
+ return {
10624
+ id: spec.id,
10625
+ kind: spec.kind,
10626
+ source: spec.source,
10627
+ result,
10628
+ observedAt: (options.now ?? new Date).toISOString(),
10629
+ ...options.detail === undefined ? {} : { detail: options.detail }
10630
+ };
10631
+ }
10632
+ function notMeasured(spec, reason) {
10633
+ return { id: spec.id, kind: spec.kind, source: "config", result: "not_measured", observedAt: null, detail: { reason } };
10634
+ }
10635
+ function composeObservation(spec, result) {
10636
+ if (result.services.length === 0) {
10637
+ return result.error === undefined ? observe(spec, "fail", { detail: { reason: "no-services" } }) : observe(spec, "unknown", { detail: { reason: "compose-failed" } });
10638
+ }
10639
+ return result.ok ? observe(spec, "pass") : observe(spec, "fail", { detail: { reason: "degraded" } });
10640
+ }
10641
+ function servicesFact(services) {
10642
+ return services.map(({ service, state, health }) => ({ service, state, health }));
10643
+ }
10644
+
10645
+ // src/status/declared.ts
10646
+ var publicReachability = {
10647
+ id: PUBLIC_REACHABILITY_ID,
10648
+ kind: "reachability",
10649
+ source: "http",
10650
+ owner: "probe"
10651
+ };
10652
+ var deployState = {
10653
+ id: "deploy-state",
10654
+ kind: "deploy-state",
10655
+ source: "github-api",
10656
+ owner: "github"
10657
+ };
10658
+ var composeServices = {
10659
+ id: "compose-services",
10660
+ kind: "container-health",
10661
+ source: "ssh",
10662
+ owner: "collector"
10663
+ };
10664
+ var DECLARED_OBSERVATIONS = {
10665
+ keeweb: [publicReachability, deployState],
10666
+ cliproxy: [
10667
+ publicReachability,
10668
+ { id: "container-running", kind: "container-health", source: "ssh", owner: "collector" },
10669
+ deployState,
10670
+ { id: "provider-route", kind: "end-to-end", source: "monitor", owner: "github" }
10671
+ ],
10672
+ gateway: [
10673
+ publicReachability,
10674
+ composeServices,
10675
+ { id: "workspace-memory", kind: "resource", source: "ssh", owner: "collector" },
10676
+ deployState
10677
+ ],
10678
+ umami: [publicReachability, composeServices, deployState],
10679
+ dashboard: [publicReachability, composeServices, deployState],
10680
+ vpn: [
10681
+ { id: PUBLIC_REACHABILITY_ID, kind: "reachability", source: "config", owner: "not-applicable" },
10682
+ { id: "wireguard-interface", kind: "container-health", source: "ssh", owner: "collector" },
10683
+ deployState,
10684
+ { id: "egress-route", kind: "end-to-end", source: "config", owner: "not-applicable" }
10685
+ ],
10686
+ broker: [publicReachability, deployState]
10687
+ };
10688
+ function declaredObservation(app, id) {
10689
+ const declared = DECLARED_OBSERVATIONS[app].find((entry) => entry.id === id);
10690
+ if (declared === undefined) {
10691
+ throw new Error(`${app} does not declare observation "${id}"`);
10692
+ }
10693
+ return declared;
10694
+ }
10695
+ function unknown(declared, reason, now) {
10696
+ return observe(declared, "unknown", { detail: { reason }, now });
10697
+ }
10698
+ function resolveObservation(declared, probe, collector, github, now) {
10699
+ switch (declared.owner) {
10700
+ case "not-applicable":
10701
+ return notMeasured(declared, declared.owner);
10702
+ case "github":
10703
+ return github.find((observation) => observation.id === declared.id) ?? unknown(declared, "missing-result", now);
10704
+ case "probe":
10705
+ if (probe.kind === "ok")
10706
+ return probe.observation;
10707
+ return unknown(declared, probe.kind === "failed" ? "check-failed" : "missing-result", now);
10708
+ case "collector":
10709
+ if (collector.kind === "missing-config")
10710
+ return notMeasured(declared, "missing-config");
10711
+ if (collector.kind === "failed")
10712
+ return unknown(declared, "check-failed", now);
10713
+ if (collector.kind === "timeout")
10714
+ return unknown(declared, "timeout", now);
10715
+ if (collector.kind === "none")
10716
+ return unknown(declared, "missing-result", now);
10717
+ return collector.collection.observations.find((observation) => observation.id === declared.id) ?? unknown(declared, "missing-result", now);
10718
+ }
10719
+ }
10720
+ function assembleAppStatus(app, probe, collector, now, github = []) {
10721
+ return {
10722
+ app,
10723
+ observations: DECLARED_OBSERVATIONS[app].map((declared) => resolveObservation(declared, probe, collector, github, now)),
10724
+ facts: collector.kind === "ok" ? collector.collection.facts : {}
10725
+ };
10726
+ }
10727
+ var SEVERITY = { pass: 0, not_measured: 1, unknown: 2, fail: 3 };
10728
+ function worstResult(app, kind) {
10729
+ let worst;
10730
+ for (const observation of app.observations) {
10731
+ if (observation.kind !== kind)
10732
+ continue;
10733
+ if (worst === undefined || SEVERITY[observation.result] > SEVERITY[worst]) {
10734
+ worst = observation.result;
10735
+ }
10736
+ }
10737
+ return worst;
10738
+ }
10739
+
10446
10740
  // src/commands/cliproxy/status.ts
10447
10741
  var DEFAULT_CLIPROXY_URL6 = "https://cliproxy.fro.bot";
10448
10742
  function defaultSpawn(cmd, opts) {
@@ -10460,7 +10754,7 @@ function levelLabel(level) {
10460
10754
  function formatDurationMs(durationMs) {
10461
10755
  return `${Math.max(0, Math.round(durationMs))}ms`;
10462
10756
  }
10463
- function stripTrailingSlash5(value) {
10757
+ function stripTrailingSlash6(value) {
10464
10758
  return value.endsWith("/") ? value.slice(0, -1) : value;
10465
10759
  }
10466
10760
  async function parseJsonResponse(response) {
@@ -10474,12 +10768,9 @@ function toNumber(value) {
10474
10768
  return typeof value === "number" && Number.isFinite(value) ? value : null;
10475
10769
  }
10476
10770
  async function checkHttpReachability(url, verbose) {
10477
- const startedAt = performance.now();
10478
- try {
10479
- const response = await fetch(url, {
10480
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
10481
- });
10482
- const elapsedMs = performance.now() - startedAt;
10771
+ const attempt = await httpGet(url, { timeoutMs: HTTP_TIMEOUT_MS });
10772
+ if (attempt.kind === "response") {
10773
+ const { response } = attempt;
10483
10774
  const details = [];
10484
10775
  if (verbose) {
10485
10776
  details.push(`URL: ${url}`);
@@ -10490,20 +10781,18 @@ async function checkHttpReachability(url, verbose) {
10490
10781
  }
10491
10782
  return {
10492
10783
  title: "HTTP reachability",
10493
- level: response.ok ? "ok" : "error",
10494
- summary: `GET ${url} \u2192 ${response.status} (${formatDurationMs(elapsedMs)})`,
10784
+ level: attempt.ok ? "ok" : "error",
10785
+ summary: `GET ${url} \u2192 ${attempt.status} (${formatDurationMs(attempt.durationMs)})`,
10495
10786
  details
10496
10787
  };
10497
- } catch (error) {
10498
- const elapsedMs = performance.now() - startedAt;
10499
- const message = error instanceof Error ? error.message : String(error);
10500
- return {
10501
- title: "HTTP reachability",
10502
- level: "error",
10503
- summary: `Request failed after ${formatDurationMs(elapsedMs)}: ${message}`,
10504
- details: verbose ? [`URL: ${url}`, `Timeout: ${HTTP_TIMEOUT_MS}ms`] : undefined
10505
- };
10506
10788
  }
10789
+ const message = attempt.error instanceof Error ? attempt.error.message : String(attempt.error);
10790
+ return {
10791
+ title: "HTTP reachability",
10792
+ level: "error",
10793
+ summary: `Request failed after ${formatDurationMs(attempt.durationMs)}: ${message}`,
10794
+ details: verbose ? [`URL: ${url}`, `Timeout: ${HTTP_TIMEOUT_MS}ms`] : undefined
10795
+ };
10507
10796
  }
10508
10797
  function countQueueErrors(records) {
10509
10798
  let count = 0;
@@ -10613,7 +10902,7 @@ async function checkVersion(baseUrl, key) {
10613
10902
  return { title: "Latest available version", level: "error", summary: `Unable to check latest version: ${message}` };
10614
10903
  }
10615
10904
  }
10616
- async function checkRunningVersion(host, spawn = defaultSpawn) {
10905
+ async function inspectRunningContainer(host, spawn = defaultSpawn, signal) {
10617
10906
  let cleanup = () => {
10618
10907
  return;
10619
10908
  };
@@ -10640,7 +10929,7 @@ async function checkRunningVersion(host, spawn = defaultSpawn) {
10640
10929
  HOME: process.env.HOME ?? "/tmp",
10641
10930
  ...process.env.SSH_AUTH_SOCK ? { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK } : {}
10642
10931
  };
10643
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
10932
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
10644
10933
  const [stdoutText, stderrText, exitCode] = await Promise.all([
10645
10934
  new Response(child.stdout).text(),
10646
10935
  new Response(child.stderr).text(),
@@ -10648,27 +10937,29 @@ async function checkRunningVersion(host, spawn = defaultSpawn) {
10648
10937
  ]);
10649
10938
  if (exitCode !== 0) {
10650
10939
  return {
10651
- title: "Running version",
10652
- level: "warning",
10940
+ kind: "unavailable",
10653
10941
  summary: `SSH command failed (exit ${exitCode}): ${redactHost(stderrText.trim(), host) || "unknown error"}`
10654
10942
  };
10655
10943
  }
10656
10944
  const imageMatch = /(?:^|\s)eceasy\/cli-proxy-api:([^\s@]+)/m.exec(stdoutText);
10657
- if (!imageMatch) {
10658
- return {
10659
- title: "Running version",
10660
- level: "warning",
10661
- summary: "No running eceasy/cli-proxy-api container found."
10662
- };
10663
- }
10664
- return { title: "Running version", level: "ok", summary: imageMatch[1] ?? "unknown" };
10945
+ return imageMatch ? { kind: "running", version: imageMatch[1] ?? "unknown" } : { kind: "none" };
10665
10946
  } catch (error) {
10666
10947
  const message = error instanceof Error ? error.message : String(error);
10667
- return { title: "Running version", level: "warning", summary: `Unable to check running version via SSH: ${message}` };
10948
+ return { kind: "unavailable", summary: `Unable to check running version via SSH: ${message}` };
10668
10949
  } finally {
10669
10950
  cleanup();
10670
10951
  }
10671
10952
  }
10953
+ async function checkRunningVersion(host, spawn = defaultSpawn) {
10954
+ const container = await inspectRunningContainer(host, spawn);
10955
+ if (container.kind === "running") {
10956
+ return { title: "Running version", level: "ok", summary: container.version };
10957
+ }
10958
+ if (container.kind === "none") {
10959
+ return { title: "Running version", level: "warning", summary: "No running eceasy/cli-proxy-api container found." };
10960
+ }
10961
+ return { title: "Running version", level: "warning", summary: container.summary };
10962
+ }
10672
10963
  var DEFAULT_PROVIDER_MODEL = "claude-sonnet-4-6";
10673
10964
  async function readBodyText(response) {
10674
10965
  try {
@@ -10818,35 +11109,50 @@ async function probeManagementAuth(baseUrl, key) {
10818
11109
  const isBanBody = payload !== null && typeof payload === "object" && Object.values(payload).some((v) => typeof v === "string" && /\b(?:ip[- ]?)?bann?ed\b/i.test(v));
10819
11110
  if (isBanBody) {
10820
11111
  return {
10821
- title: "Management access",
10822
- level: "error",
10823
- summary: "IP banned \u2014 stop retrying for ~30 min. Management checks skipped."
11112
+ access: "denied",
11113
+ result: {
11114
+ title: "Management access",
11115
+ level: "error",
11116
+ summary: "IP banned \u2014 stop retrying for ~30 min. Management checks skipped."
11117
+ }
10824
11118
  };
10825
11119
  }
10826
11120
  return {
10827
- title: "Management access",
10828
- level: "error",
10829
- summary: "Management API returned HTTP 403. Management checks skipped."
11121
+ access: "denied",
11122
+ result: {
11123
+ title: "Management access",
11124
+ level: "error",
11125
+ summary: "Management API returned HTTP 403. Management checks skipped."
11126
+ }
10830
11127
  };
10831
11128
  }
10832
11129
  if (response.status === 401) {
10833
11130
  return {
10834
- title: "Management access",
10835
- level: "error",
10836
- summary: "Management API returned HTTP 401 (invalid key). Management checks skipped."
11131
+ access: "denied",
11132
+ result: {
11133
+ title: "Management access",
11134
+ level: "error",
11135
+ summary: "Management API returned HTTP 401 (invalid key). Management checks skipped."
11136
+ }
10837
11137
  };
10838
11138
  }
10839
11139
  return {
10840
- title: "Management access",
10841
- level: "warning",
10842
- summary: `Management auth probe returned HTTP ${response.status}. Management checks skipped.`
11140
+ access: response.status === 429 ? "rate-limited" : "unavailable",
11141
+ result: {
11142
+ title: "Management access",
11143
+ level: "warning",
11144
+ summary: `Management auth probe returned HTTP ${response.status}. Management checks skipped.`
11145
+ }
10843
11146
  };
10844
11147
  } catch (error) {
10845
11148
  const message = error instanceof Error ? error.message : String(error);
10846
11149
  return {
10847
- title: "Management access",
10848
- level: "error",
10849
- summary: `Management auth probe failed: ${message}`
11150
+ access: "unavailable",
11151
+ result: {
11152
+ title: "Management access",
11153
+ level: "error",
11154
+ summary: `Management auth probe failed: ${message}`
11155
+ }
10850
11156
  };
10851
11157
  }
10852
11158
  }
@@ -10856,7 +11162,7 @@ async function runManagementChecks(baseUrl, key) {
10856
11162
  }
10857
11163
  const authFailure = await probeManagementAuth(baseUrl, key);
10858
11164
  if (authFailure !== null) {
10859
- return { kind: "auth-failure", result: authFailure };
11165
+ return { kind: "auth-failure", result: authFailure.result, access: authFailure.access };
10860
11166
  }
10861
11167
  const [usage, version] = await Promise.all([checkUsageStats(baseUrl, key), checkVersion(baseUrl, key)]);
10862
11168
  return { kind: "checks", usage, version };
@@ -10870,35 +11176,7 @@ function printCheckResult(result, ctx) {
10870
11176
  }
10871
11177
  }
10872
11178
  }
10873
- function formatCheckSummary(result) {
10874
- return `${levelLabel(result.level)}: ${result.summary}`;
10875
- }
10876
11179
  var RUNNING_VERSION_NOT_CHECKED_SUMMARY = "Not checked for explicit URL override.";
10877
- function formatVersionSummary(running, available) {
10878
- const latestFailure = /429|rate limit/i.test(available.summary) ? "rate-limited" : /auth|\b40[13]\b|ip banned/i.test(available.summary) ? "auth" : "unknown";
10879
- if (running.summary === RUNNING_VERSION_NOT_CHECKED_SUMMARY) {
10880
- if (available.level === "ok") {
10881
- return `not checked (latest ${available.summary})`;
10882
- }
10883
- if (available.summary === "Not checked (no management key).") {
10884
- return "not checked (latest: no key)";
10885
- }
10886
- return `not checked (latest ${latestFailure})`;
10887
- }
10888
- if (running.level === "ok" && available.level === "ok") {
10889
- return running.summary === available.summary ? `${running.summary} (latest)` : `${running.summary} (latest ${available.summary})`;
10890
- }
10891
- if (running.level === "ok") {
10892
- return available.summary === "Not checked (no management key)." ? `${running.summary} (latest: no key)` : `${running.summary} (latest ${latestFailure})`;
10893
- }
10894
- if (available.level === "ok") {
10895
- return `unknown (latest ${available.summary})`;
10896
- }
10897
- if (latestFailure === "auth") {
10898
- return "unknown (auth)";
10899
- }
10900
- return latestFailure === "rate-limited" ? "unknown (latest rate-limited)" : "unknown";
10901
- }
10902
11180
  function getRunningVersionNotChecked(host) {
10903
11181
  try {
10904
11182
  validateCliproxyHost(host);
@@ -10908,51 +11186,64 @@ function getRunningVersionNotChecked(host) {
10908
11186
  return { title: "Running version", level: "warning", summary: `Unable to validate running-version host: ${message}` };
10909
11187
  }
10910
11188
  }
10911
- function formatUsageSummaryLine(result) {
11189
+ function parseUsageCounts(result) {
10912
11190
  const recentMatch = /recent:\s*(\d+)/.exec(result.summary);
10913
11191
  if (!recentMatch) {
10914
11192
  return null;
10915
11193
  }
10916
- const total = Number(recentMatch[1]);
10917
11194
  const errorMatch = /errors:\s*(\d+)/.exec(result.summary);
10918
- const errors = errorMatch ? Number(errorMatch[1]) : 0;
10919
- return `Recent requests: ${total}${errors > 0 ? `, ${errors} errors` : ""}`;
11195
+ return { recent: Number(recentMatch[1]), errors: errorMatch ? Number(errorMatch[1]) : 0 };
10920
11196
  }
10921
- async function getCliproxyStatusSummary(baseUrl, key, verbose, spawn = defaultSpawn) {
10922
- const normalizedBaseUrl = stripTrailingSlash5(baseUrl);
11197
+ function formatUsageSummaryLine(result) {
11198
+ const counts = parseUsageCounts(result);
11199
+ if (counts === null) {
11200
+ return null;
11201
+ }
11202
+ return `Recent requests: ${counts.recent}${counts.errors > 0 ? `, ${counts.errors} errors` : ""}`;
11203
+ }
11204
+ async function getCliproxyStatusSummary(baseUrl, key, spawn = defaultSpawn, signal) {
11205
+ const normalizedBaseUrl = stripTrailingSlash6(baseUrl);
10923
11206
  const host = new URL(normalizedBaseUrl).hostname;
10924
- const [httpResult, mgmt, running] = await Promise.all([
10925
- checkHttpReachability(`${normalizedBaseUrl}/healthz`, verbose),
11207
+ const [mgmt, running] = await Promise.all([
10926
11208
  runManagementChecks(normalizedBaseUrl, key || undefined),
10927
- checkRunningVersion(host, spawn)
11209
+ inspectRunningContainer(host, spawn, signal)
10928
11210
  ]);
10929
- let available;
10930
- let usageStats;
11211
+ const spec = declaredObservation("cliproxy", "container-running");
11212
+ let container;
11213
+ if (running.kind === "running") {
11214
+ container = observe(spec, "pass");
11215
+ } else if (running.kind === "none") {
11216
+ container = observe(spec, "fail", { detail: { reason: "not-running" } });
11217
+ } else {
11218
+ container = observe(spec, "unknown", { detail: { reason: "ssh-failed" } });
11219
+ }
11220
+ let managementAccess = "ok";
11221
+ let latestVersion = null;
11222
+ let usage = null;
10931
11223
  if (mgmt.kind === "no-key") {
10932
- available = { title: "Latest available version", level: "warning", summary: "Not checked (no management key)." };
10933
- usageStats = "\u2014 (no key)";
11224
+ managementAccess = "no-key";
10934
11225
  } else if (mgmt.kind === "auth-failure") {
10935
- available = mgmt.result;
10936
- usageStats = formatCheckSummary(mgmt.result);
11226
+ managementAccess = mgmt.access;
10937
11227
  } else {
10938
- available = mgmt.version;
10939
- usageStats = formatCheckSummary(mgmt.usage);
11228
+ latestVersion = mgmt.version.level === "ok" ? mgmt.version.summary : null;
11229
+ usage = parseUsageCounts(mgmt.usage);
10940
11230
  }
10941
11231
  return {
10942
- app: "cliproxy",
10943
- http: formatCheckSummary(httpResult),
10944
- lastDeploy: "\u2014",
10945
- version: formatVersionSummary(running, available),
10946
- contentHash: "\u2014",
10947
- usageStats
11232
+ observations: [container],
11233
+ facts: {
11234
+ runningVersion: running.kind === "running" ? running.version : null,
11235
+ latestVersion,
11236
+ usage,
11237
+ managementAccess
11238
+ }
10948
11239
  };
10949
11240
  }
10950
11241
  async function cliproxyStatusAction(options, ctx, spawn = defaultSpawn) {
10951
11242
  let errorCount = 0;
10952
11243
  try {
10953
11244
  const verbose = options.verbose === true;
10954
- const baseUrl = stripTrailingSlash5(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
10955
- const trustedUrl = stripTrailingSlash5(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
11245
+ const baseUrl = stripTrailingSlash6(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
11246
+ const trustedUrl = stripTrailingSlash6(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
10956
11247
  const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl;
10957
11248
  const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY);
10958
11249
  const providerKey = options.apiKey ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY);
@@ -11030,6 +11321,9 @@ function formatMonitorRunSummary(summary) {
11030
11321
  const issue = summary.issue.length > 0 ? summary.issue.join("+") : "none";
11031
11322
  return `CLIProxy auth monitor summary: probe=${summary.probeState} reason=${summary.probeReason} transition=${summary.transition} issue=${issue} discord=${summary.discord} at ${summary.timestamp}`;
11032
11323
  }
11324
+ function formatMonitorProbeLine(probe) {
11325
+ return `CLIProxy auth monitor probe: probe=${probe.probeState} reason=${probe.probeReason} at ${probe.timestamp}`;
11326
+ }
11033
11327
  var githubIssueSchema = z16.object({
11034
11328
  number: z16.number().int().nonnegative(),
11035
11329
  state: z16.enum(["open", "closed"]),
@@ -11332,6 +11626,7 @@ async function cliproxyMonitorAction(options, ctx, runtime = defaultMonitorRunti
11332
11626
  const timestamp = new Date().toISOString();
11333
11627
  try {
11334
11628
  const classification = validation === "live" ? await checkProviderAuthState(proxyUrl, inputs.apiKey) : providerState(validation);
11629
+ ctx.console.log(formatMonitorProbeLine({ probeState: classification.state, probeReason: classification.reason, timestamp }));
11335
11630
  const finish = (transition, issue = [], discord = "none") => {
11336
11631
  const summary = {
11337
11632
  probeState: classification.state,
@@ -11467,11 +11762,11 @@ var AuthFilesResponseSchema = z18.object({
11467
11762
  files: z18.array(AuthFileSchema)
11468
11763
  });
11469
11764
  var ManagementErrorSchema = z18.object({ error: z18.string() });
11470
- function stripTrailingSlash6(value) {
11765
+ function stripTrailingSlash7(value) {
11471
11766
  return value.endsWith("/") ? value.slice(0, -1) : value;
11472
11767
  }
11473
11768
  function resolveBaseUrl4(input) {
11474
- return stripTrailingSlash6(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL8);
11769
+ return stripTrailingSlash7(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL8);
11475
11770
  }
11476
11771
  function resolveManagementKey4(input) {
11477
11772
  const key = input ?? process.env.CLIPROXY_MANAGEMENT_KEY;
@@ -11805,7 +12100,7 @@ function isAllRunning(rows) {
11805
12100
  function defaultSpawn2(cmd, opts) {
11806
12101
  return Bun.spawn(cmd, opts);
11807
12102
  }
11808
- async function getDashboardComposeStatus(host, spawn = defaultSpawn2) {
12103
+ async function getDashboardComposeStatus(host, spawn = defaultSpawn2, signal) {
11809
12104
  validateDashboardHost(host);
11810
12105
  const knownHostsArgs = buildKnownHostsArgs();
11811
12106
  const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.DASHBOARD_SSH_KEY);
@@ -11831,7 +12126,7 @@ async function getDashboardComposeStatus(host, spawn = defaultSpawn2) {
11831
12126
  let stderrText;
11832
12127
  let exitCode;
11833
12128
  try {
11834
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
12129
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
11835
12130
  [stdoutText, stderrText, exitCode] = await Promise.all([
11836
12131
  new Response(child.stdout).text(),
11837
12132
  new Response(child.stderr).text(),
@@ -11861,50 +12156,11 @@ async function getDashboardComposeStatus(host, spawn = defaultSpawn2) {
11861
12156
  const ok = isAllRunning(services);
11862
12157
  return { ok, services };
11863
12158
  }
11864
- async function getDashboardStatusSummary(host, spawn) {
11865
- let result;
11866
- try {
11867
- result = await getDashboardComposeStatus(host, spawn);
11868
- } catch (error) {
11869
- const errorMsg = error instanceof Error ? error.message : String(error);
11870
- return {
11871
- app: "dashboard",
11872
- http: `ERROR: ${errorMsg}`,
11873
- lastDeploy: "\u2014",
11874
- version: "\u2014",
11875
- contentHash: "\u2014",
11876
- usageStats: "\u2014"
11877
- };
11878
- }
11879
- if (result.services.length === 0) {
11880
- const errorMsg = result.error ?? "No services reported";
11881
- return {
11882
- app: "dashboard",
11883
- http: `ERROR: ${errorMsg}`,
11884
- lastDeploy: "\u2014",
11885
- version: "\u2014",
11886
- contentHash: "\u2014",
11887
- usageStats: "\u2014"
11888
- };
11889
- }
11890
- const rows = result.services.map((s) => `${s.service}:${s.state}/${s.health}`).join(", ");
11891
- if (!result.ok) {
11892
- return {
11893
- app: "dashboard",
11894
- http: `DEGRADED: ${rows}`,
11895
- lastDeploy: "\u2014",
11896
- version: "\u2014",
11897
- contentHash: "\u2014",
11898
- usageStats: "\u2014"
11899
- };
11900
- }
12159
+ async function getDashboardStatusSummary(host, spawn, signal) {
12160
+ const result = await getDashboardComposeStatus(host, spawn, signal);
11901
12161
  return {
11902
- app: "dashboard",
11903
- http: `OK: ${rows}`,
11904
- lastDeploy: "\u2014",
11905
- version: "\u2014",
11906
- contentHash: "\u2014",
11907
- usageStats: "\u2014"
12162
+ observations: [composeObservation(declaredObservation("dashboard", "compose-services"), result)],
12163
+ facts: { services: servicesFact(result.services) }
11908
12164
  };
11909
12165
  }
11910
12166
  async function dashboardStatusAction(options, ctx, spawn) {
@@ -12647,12 +12903,6 @@ function formatWorkspaceMemory(status) {
12647
12903
  ` OpenCode: ${formatOpencode(status)}`
12648
12904
  ];
12649
12905
  }
12650
- function formatWorkspaceSummary(status) {
12651
- if (status.status === "unknown")
12652
- return "workspace=unknown";
12653
- const mem = status.limitBytes === 0 ? `mem=${formatMiB(status.currentBytes)}MiB limit=none` : `mem=${formatMiB(status.currentBytes)}/${formatMiB(status.limitBytes)}MiB`;
12654
- return `workspace ${mem} oom_kills=${status.oomKills} opencode=${status.opencode}`;
12655
- }
12656
12906
 
12657
12907
  // src/commands/gateway/status.ts
12658
12908
  var WORKSPACE_PROBE_TIMEOUT_MS = 1e4;
@@ -12686,7 +12936,7 @@ function isAllRunning2(rows) {
12686
12936
  function defaultSpawn5(cmd, opts) {
12687
12937
  return Bun.spawn(cmd, opts);
12688
12938
  }
12689
- async function getGatewayComposeStatus(host, spawn = defaultSpawn5) {
12939
+ async function getGatewayComposeStatus(host, spawn = defaultSpawn5, signal) {
12690
12940
  validateGatewayHost(host);
12691
12941
  const knownHostsArgs = buildKnownHostsArgs();
12692
12942
  const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.GATEWAY_SSH_KEY);
@@ -12710,7 +12960,7 @@ async function getGatewayComposeStatus(host, spawn = defaultSpawn5) {
12710
12960
  let stderrText;
12711
12961
  let exitCode;
12712
12962
  try {
12713
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
12963
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
12714
12964
  [stdoutText, stderrText, exitCode] = await Promise.all([
12715
12965
  new Response(child.stdout).text(),
12716
12966
  new Response(child.stderr).text(),
@@ -12741,7 +12991,7 @@ async function getGatewayComposeStatus(host, spawn = defaultSpawn5) {
12741
12991
  const ok = isAllRunning2(services);
12742
12992
  return { ok, services };
12743
12993
  }
12744
- async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs = WORKSPACE_PROBE_TIMEOUT_MS) {
12994
+ async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs = WORKSPACE_PROBE_TIMEOUT_MS, signal) {
12745
12995
  validateGatewayHost(host);
12746
12996
  const knownHostsArgs = buildKnownHostsArgs();
12747
12997
  const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.GATEWAY_SSH_KEY);
@@ -12766,7 +13016,7 @@ async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs
12766
13016
  timer = setTimeout(() => resolve("timeout"), timeoutMs);
12767
13017
  });
12768
13018
  try {
12769
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
13019
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
12770
13020
  const collected = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
12771
13021
  collected.catch(() => {
12772
13022
  return;
@@ -12787,30 +13037,43 @@ async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs
12787
13037
  cleanup();
12788
13038
  }
12789
13039
  }
12790
- async function getGatewayStatusSummary(host, spawn) {
12791
- const result = await getGatewayComposeStatus(host, spawn);
13040
+ function workspaceObservation(workspace) {
13041
+ const spec = declaredObservation("gateway", "workspace-memory");
13042
+ if (workspace.status === "unknown")
13043
+ return observe(spec, "unknown", { detail: { reason: "probe-failed" } });
13044
+ if (workspace.oomKilled || workspace.oomKills > 0)
13045
+ return observe(spec, "fail", { detail: { reason: "oom-kill" } });
13046
+ switch (workspace.opencode) {
13047
+ case "ready":
13048
+ return observe(spec, "pass");
13049
+ case "down":
13050
+ case "degraded":
13051
+ return observe(spec, "fail", { detail: { reason: `supervisor-${workspace.opencode}` } });
13052
+ case "skipped":
13053
+ return workspace.limitBytes > 0 ? observe(spec, "fail", { detail: { reason: "near-limit" } }) : observe(spec, "pass");
13054
+ case "starting":
13055
+ case "unknown":
13056
+ return observe(spec, "unknown", { detail: { reason: `supervisor-${workspace.opencode}` } });
13057
+ }
13058
+ }
13059
+ async function getGatewayStatusSummary(host, spawn, signal) {
13060
+ const result = await getGatewayComposeStatus(host, spawn, signal);
13061
+ const compose = composeObservation(declaredObservation("gateway", "compose-services"), result);
12792
13062
  if (result.services.length === 0) {
12793
- const errorMsg = result.error ?? "No services reported";
12794
13063
  return {
12795
- app: "gateway",
12796
- http: `ERROR: ${errorMsg}`,
12797
- lastDeploy: "\u2014",
12798
- version: "\u2014",
12799
- contentHash: "\u2014",
12800
- usageStats: "\u2014"
13064
+ observations: [
13065
+ compose,
13066
+ observe(declaredObservation("gateway", "workspace-memory"), "unknown", {
13067
+ detail: { reason: "compose-unavailable" }
13068
+ })
13069
+ ],
13070
+ facts: { services: [] }
12801
13071
  };
12802
13072
  }
12803
- const rows = result.services.map((s) => `${s.service}:${s.state}/${s.health}`).join(", ");
12804
- const workspace = await getGatewayWorkspaceStatus(host, spawn);
12805
- const prefix = result.ok ? `OK: ${rows}` : `DEGRADED: ${rows}`;
13073
+ const workspace = await getGatewayWorkspaceStatus(host, spawn, undefined, signal);
12806
13074
  return {
12807
- app: "gateway",
12808
- http: `${prefix}; ${formatWorkspaceSummary(workspace)}`,
12809
- lastDeploy: "\u2014",
12810
- version: "\u2014",
12811
- contentHash: "\u2014",
12812
- usageStats: "\u2014",
12813
- workspace
13075
+ observations: [compose, workspaceObservation(workspace)],
13076
+ facts: { services: servicesFact(result.services), workspace: { ...workspace } }
12814
13077
  };
12815
13078
  }
12816
13079
  async function gatewayStatusAction(options, ctx, spawn, workspaceTimeoutMs) {
@@ -13011,7 +13274,7 @@ import { join as join7 } from "path";
13011
13274
  import { z as z28 } from "zod";
13012
13275
  var SITE_URL = "https://kw.igg.ms/";
13013
13276
  var GH_REPO = "marcusrbrown/infra";
13014
- var HTTP_TIMEOUT_MS4 = 1e4;
13277
+ var HTTP_TIMEOUT_MS3 = 1e4;
13015
13278
  var ghRunSchema = z28.array(z28.object({
13016
13279
  createdAt: z28.string().min(1),
13017
13280
  url: z28.url()
@@ -13053,7 +13316,7 @@ async function checkHttpReachability2(verbose) {
13053
13316
  const startedAt = performance.now();
13054
13317
  try {
13055
13318
  const response = await fetch(SITE_URL, {
13056
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS4)
13319
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
13057
13320
  });
13058
13321
  const elapsedMs = performance.now() - startedAt;
13059
13322
  const details = [];
@@ -13080,11 +13343,11 @@ async function checkHttpReachability2(verbose) {
13080
13343
  title: "HTTP reachability",
13081
13344
  level: "error",
13082
13345
  summary: `Request failed after ${formatDurationMs2(elapsedMs)}: ${message}`,
13083
- details: verbose ? [`URL: ${SITE_URL}`, `Timeout: ${HTTP_TIMEOUT_MS4}ms`] : undefined
13346
+ details: verbose ? [`URL: ${SITE_URL}`, `Timeout: ${HTTP_TIMEOUT_MS3}ms`] : undefined
13084
13347
  };
13085
13348
  }
13086
13349
  }
13087
- async function checkLastDeploy(verbose) {
13350
+ async function checkLastDeploy(verbose, signal) {
13088
13351
  try {
13089
13352
  const proc = Bun.spawn([
13090
13353
  "gh",
@@ -13099,7 +13362,8 @@ async function checkLastDeploy(verbose) {
13099
13362
  GH_REPO
13100
13363
  ], {
13101
13364
  stderr: "pipe",
13102
- stdout: "pipe"
13365
+ stdout: "pipe",
13366
+ ...signal === undefined ? {} : { signal }
13103
13367
  });
13104
13368
  const [exitCode, stdout, stderr] = await Promise.all([
13105
13369
  proc.exited,
@@ -13141,7 +13405,8 @@ async function checkLastDeploy(verbose) {
13141
13405
  title: "Last successful deploy",
13142
13406
  level: "ok",
13143
13407
  summary: `${formatDate(latestRun.createdAt)}`,
13144
- details
13408
+ details,
13409
+ value: latestRun.createdAt
13145
13410
  };
13146
13411
  } catch (error) {
13147
13412
  const message = error instanceof Error ? error.message : String(error);
@@ -13195,7 +13460,7 @@ async function checkContentHash(verbose, repoRoot) {
13195
13460
  }
13196
13461
  try {
13197
13462
  const response = await fetch(SITE_URL, {
13198
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS4)
13463
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
13199
13464
  });
13200
13465
  if (!response.ok) {
13201
13466
  return {
@@ -13214,14 +13479,16 @@ async function checkContentHash(verbose, repoRoot) {
13214
13479
  title: "Content hash",
13215
13480
  level: "ok",
13216
13481
  summary: "Remote index.html matches local dist/index.html",
13217
- details
13482
+ details,
13483
+ value: "match"
13218
13484
  };
13219
13485
  }
13220
13486
  return {
13221
13487
  title: "Content hash",
13222
13488
  level: "warning",
13223
13489
  summary: "Remote index.html differs from local dist/index.html",
13224
- details
13490
+ details,
13491
+ value: "differs"
13225
13492
  };
13226
13493
  } catch (error) {
13227
13494
  const message = error instanceof Error ? error.message : String(error);
@@ -13242,22 +13509,17 @@ function printCheckResult2(result, ctx) {
13242
13509
  }
13243
13510
  }
13244
13511
  }
13245
- function formatCheckSummary2(result) {
13246
- return `${levelLabel2(result.level)}: ${result.summary}`;
13247
- }
13248
- async function getKeewebStatusSummary(verbose) {
13249
- const [httpResult, lastDeployResult, contentHashResult] = await Promise.all([
13250
- checkHttpReachability2(verbose),
13251
- checkLastDeploy(verbose),
13252
- checkContentHash(verbose)
13512
+ async function getKeewebStatusSummary(signal) {
13513
+ const [lastDeployResult, contentHashResult] = await Promise.all([
13514
+ checkLastDeploy(false, signal),
13515
+ checkContentHash(false)
13253
13516
  ]);
13254
13517
  return {
13255
- app: "keeweb",
13256
- http: formatCheckSummary2(httpResult),
13257
- lastDeploy: formatCheckSummary2(lastDeployResult),
13258
- version: "\u2014",
13259
- contentHash: formatCheckSummary2(contentHashResult),
13260
- usageStats: "\u2014"
13518
+ observations: [],
13519
+ facts: {
13520
+ lastDeploy: lastDeployResult.value ?? null,
13521
+ contentHash: contentHashResult.value ?? "unavailable"
13522
+ }
13261
13523
  };
13262
13524
  }
13263
13525
  async function keewebStatusAction(options, ctx) {
@@ -13312,10 +13574,811 @@ function registerMcp(cli) {
13312
13574
  }
13313
13575
 
13314
13576
  // src/commands/status.ts
13315
- import { z as z37 } from "zod";
13577
+ import { z as z43 } from "zod";
13316
13578
 
13317
- // src/commands/umami/deploy.ts
13579
+ // src/status/deploy-state.ts
13580
+ import { z as z30 } from "zod";
13581
+
13582
+ // src/status/github-api.ts
13583
+ var GITHUB_API_ORIGIN = "https://api.github.com";
13584
+ var GITHUB_REPOSITORY = "marcusrbrown/infra";
13585
+ var GITHUB_REQUEST_TIMEOUT_MS = 1e4;
13586
+ var MAX_LOG_BYTES = 4 * 1024 * 1024;
13587
+ function hasToken(token) {
13588
+ return token !== undefined && token.trim() !== "";
13589
+ }
13590
+ function resolveGithubToken(env) {
13591
+ return [env.GITHUB_TOKEN, env.GH_TOKEN].find(hasToken);
13592
+ }
13593
+ function apiHeaders(token) {
13594
+ return {
13595
+ accept: "application/vnd.github+json",
13596
+ authorization: `Bearer ${token}`,
13597
+ "x-github-api-version": "2022-11-28",
13598
+ "user-agent": "marcusrbrown-infra-status"
13599
+ };
13600
+ }
13601
+ async function githubGetJson(fetchFn, token, path, schema) {
13602
+ try {
13603
+ const response = await fetchFn(`${GITHUB_API_ORIGIN}${path}`, {
13604
+ headers: apiHeaders(token),
13605
+ signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS)
13606
+ });
13607
+ if (!response.ok) {
13608
+ await response.body?.cancel().catch(() => {
13609
+ return;
13610
+ });
13611
+ return { ok: false };
13612
+ }
13613
+ const parsed = schema.safeParse(await response.json());
13614
+ return parsed.success ? { ok: true, data: parsed.data } : { ok: false };
13615
+ } catch {
13616
+ return { ok: false };
13617
+ }
13618
+ }
13619
+ async function readBoundedText(response) {
13620
+ const declared = Number(response.headers.get("content-length"));
13621
+ if (Number.isFinite(declared) && declared > MAX_LOG_BYTES) {
13622
+ await response.body?.cancel().catch(() => {
13623
+ return;
13624
+ });
13625
+ return;
13626
+ }
13627
+ const text = await response.text();
13628
+ return text.length > MAX_LOG_BYTES ? undefined : text;
13629
+ }
13630
+ async function githubGetLogText(fetchFn, token, path) {
13631
+ try {
13632
+ const first = await fetchFn(`${GITHUB_API_ORIGIN}${path}`, {
13633
+ headers: apiHeaders(token),
13634
+ redirect: "manual",
13635
+ signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS)
13636
+ });
13637
+ if (first.status === 200) {
13638
+ const text = await readBoundedText(first);
13639
+ return text === undefined ? { ok: false } : { ok: true, data: text };
13640
+ }
13641
+ await first.body?.cancel().catch(() => {
13642
+ return;
13643
+ });
13644
+ const location = first.headers.get("location");
13645
+ if (first.status !== 302 || location === null || !location.startsWith("https://")) {
13646
+ return { ok: false };
13647
+ }
13648
+ const second = await fetchFn(location, { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) });
13649
+ if (!second.ok) {
13650
+ await second.body?.cancel().catch(() => {
13651
+ return;
13652
+ });
13653
+ return { ok: false };
13654
+ }
13655
+ const text = await readBoundedText(second);
13656
+ return text === undefined ? { ok: false } : { ok: true, data: text };
13657
+ } catch {
13658
+ return { ok: false };
13659
+ }
13660
+ }
13661
+
13662
+ // src/status/observation.ts
13318
13663
  import { z as z29 } from "zod";
13664
+ var STATUS_SCHEMA_VERSION = 1;
13665
+ var OBSERVATION_KINDS = ["reachability", "container-health", "deploy-state", "end-to-end", "resource"];
13666
+ var OBSERVATION_SOURCES = ["http", "ssh", "github-api", "monitor", "config"];
13667
+ var APP_IDS = ["keeweb", "cliproxy", "gateway", "umami", "dashboard", "vpn", "broker"];
13668
+ var isoTimestampSchema = z29.iso.datetime();
13669
+ var observationDetailSchema = z29.strictObject({
13670
+ statusCode: z29.number().int().min(100).max(599).optional(),
13671
+ durationMs: z29.number().int().min(0).max(600000).optional(),
13672
+ contractVersion: z29.string().min(1).max(64).optional(),
13673
+ since: isoTimestampSchema.optional(),
13674
+ probeAt: isoTimestampSchema.optional(),
13675
+ reason: z29.string().max(32).regex(/^[a-z][a-z0-9_-]*$/).optional()
13676
+ });
13677
+ var observationBaseShape = {
13678
+ id: z29.string().max(64).regex(/^[a-z][a-z0-9-]*$/),
13679
+ kind: z29.enum(OBSERVATION_KINDS),
13680
+ source: z29.enum(OBSERVATION_SOURCES),
13681
+ detail: observationDetailSchema.optional()
13682
+ };
13683
+ var observationSchema = z29.discriminatedUnion("result", [
13684
+ z29.strictObject({
13685
+ ...observationBaseShape,
13686
+ result: z29.enum(["pass", "fail", "unknown"]),
13687
+ observedAt: isoTimestampSchema
13688
+ }),
13689
+ z29.strictObject({
13690
+ ...observationBaseShape,
13691
+ result: z29.literal("not_measured"),
13692
+ observedAt: z29.null()
13693
+ })
13694
+ ]);
13695
+ var jsonValueSchema = z29.lazy(() => z29.union([
13696
+ z29.string(),
13697
+ z29.number(),
13698
+ z29.boolean(),
13699
+ z29.null(),
13700
+ z29.array(jsonValueSchema),
13701
+ z29.record(z29.string(), jsonValueSchema)
13702
+ ]));
13703
+ var appStatusSchema = z29.strictObject({
13704
+ app: z29.enum(APP_IDS),
13705
+ observations: z29.array(observationSchema),
13706
+ facts: z29.record(z29.string().max(64), jsonValueSchema)
13707
+ }).check((ctx) => {
13708
+ const seen = new Set;
13709
+ for (const [index, observation] of ctx.value.observations.entries()) {
13710
+ if (seen.has(observation.id)) {
13711
+ ctx.issues.push({
13712
+ code: "custom",
13713
+ message: `Duplicate observation id "${observation.id}" for app "${ctx.value.app}"`,
13714
+ input: ctx.value,
13715
+ path: ["observations", index, "id"]
13716
+ });
13717
+ }
13718
+ seen.add(observation.id);
13719
+ }
13720
+ });
13721
+ var statusEnvelopeSchema = z29.strictObject({
13722
+ schemaVersion: z29.literal(STATUS_SCHEMA_VERSION),
13723
+ generatedAt: isoTimestampSchema,
13724
+ apps: z29.array(appStatusSchema)
13725
+ }).check((ctx) => {
13726
+ const seen = new Set;
13727
+ for (const [index, entry] of ctx.value.apps.entries()) {
13728
+ if (seen.has(entry.app)) {
13729
+ ctx.issues.push({
13730
+ code: "custom",
13731
+ message: `Duplicate app entry "${entry.app}"`,
13732
+ input: ctx.value,
13733
+ path: ["apps", index, "app"]
13734
+ });
13735
+ }
13736
+ seen.add(entry.app);
13737
+ }
13738
+ });
13739
+
13740
+ // src/status/deploy-state.ts
13741
+ var DEPLOY_STATE_ID = "deploy-state";
13742
+ var DEPLOY_STATE_REF = "main";
13743
+ var DEPLOY_STATE_CALLS_PER_APP = 2;
13744
+ var DEPLOY_STATE_CALL_BUDGET = APP_IDS.length * DEPLOY_STATE_CALLS_PER_APP;
13745
+ var SPEC = { id: DEPLOY_STATE_ID, kind: "deploy-state", source: "github-api" };
13746
+ var deploymentListSchema = z30.array(z30.object({ id: z30.number().int().positive(), ref: z30.string() }));
13747
+ var statusListSchema = z30.array(z30.object({ state: z30.string().min(1).max(32), created_at: z30.iso.datetime() }));
13748
+ function mapDeploymentState(state) {
13749
+ switch (state) {
13750
+ case "success":
13751
+ return { result: "pass" };
13752
+ case "failure":
13753
+ case "error":
13754
+ return { result: "fail" };
13755
+ case "waiting":
13756
+ return { result: "unknown", reason: "at_gate" };
13757
+ case "queued":
13758
+ case "in_progress":
13759
+ case "pending":
13760
+ return { result: "unknown", reason: "running" };
13761
+ case "inactive":
13762
+ return { result: "unknown", reason: "inactive" };
13763
+ default:
13764
+ return { result: "unknown", reason: "unknown-state" };
13765
+ }
13766
+ }
13767
+ function apiError(now) {
13768
+ return observe(SPEC, "unknown", { detail: { reason: "api-error" }, now });
13769
+ }
13770
+ async function readAppDeployState(app, fetchFn, token, now) {
13771
+ const deployments = await githubGetJson(fetchFn, token, `/repos/${GITHUB_REPOSITORY}/deployments?environment=${encodeURIComponent(app)}&ref=${DEPLOY_STATE_REF}&per_page=1`, deploymentListSchema);
13772
+ if (!deployments.ok)
13773
+ return apiError(now);
13774
+ const newest = deployments.data[0];
13775
+ if (newest === undefined)
13776
+ return notMeasured(SPEC, "none");
13777
+ if (newest.ref !== DEPLOY_STATE_REF)
13778
+ return apiError(now);
13779
+ const statuses = await githubGetJson(fetchFn, token, `/repos/${GITHUB_REPOSITORY}/deployments/${newest.id}/statuses?per_page=1`, statusListSchema);
13780
+ if (!statuses.ok)
13781
+ return apiError(now);
13782
+ const status = statuses.data[0];
13783
+ if (status === undefined)
13784
+ return observe(SPEC, "unknown", { detail: { reason: "no-status" }, now });
13785
+ const mapping = mapDeploymentState(status.state);
13786
+ const detail = {
13787
+ ..."reason" in mapping ? { reason: mapping.reason } : {},
13788
+ since: status.created_at
13789
+ };
13790
+ return observe(SPEC, mapping.result, { detail, now });
13791
+ }
13792
+ async function readDeployStates(options) {
13793
+ const now = options.now ?? new Date;
13794
+ const { token } = options;
13795
+ if (!hasToken(token)) {
13796
+ return Object.fromEntries(APP_IDS.map((app) => [app, notMeasured(SPEC, "missing-config")]));
13797
+ }
13798
+ const fetchFn = options.fetch ?? fetch;
13799
+ const entries = await Promise.all(APP_IDS.map(async (app) => [app, await readAppDeployState(app, fetchFn, token, now)]));
13800
+ return Object.fromEntries(entries);
13801
+ }
13802
+
13803
+ // src/status/monitor-state.ts
13804
+ import { z as z31 } from "zod";
13805
+ var MONITOR_OBSERVATION_ID = "provider-route";
13806
+ var MONITOR_WORKFLOW_FILE = "cliproxy-auth-monitor.yaml";
13807
+ var PROBE_LINE_PREFIX = "CLIProxy auth monitor probe:";
13808
+ var MONITOR_LOG_RETRY_BUDGET = 1;
13809
+ var MONITOR_STALE_AFTER_MS = 45 * 60000;
13810
+ var MAX_FUTURE_SKEW_MS = 5 * 60000;
13811
+ var SPEC2 = { id: MONITOR_OBSERVATION_ID, kind: "end-to-end", source: "monitor" };
13812
+ var PROBE_REASON_STATE = {
13813
+ ok: "healthy",
13814
+ "auth-401": "dead",
13815
+ "auth-unavailable-503": "dead",
13816
+ "provider-missing": "dead",
13817
+ "unrelated-http": "unknown",
13818
+ timeout: "unknown",
13819
+ network: "unknown"
13820
+ };
13821
+ var runsSchema = z31.object({
13822
+ workflow_runs: z31.array(z31.object({
13823
+ id: z31.number().int().positive(),
13824
+ event: z31.string(),
13825
+ head_branch: z31.string().nullable(),
13826
+ status: z31.string()
13827
+ }))
13828
+ });
13829
+ var jobsSchema = z31.object({ jobs: z31.array(z31.object({ id: z31.number().int().positive() })) });
13830
+ var isoSchema = z31.iso.datetime();
13831
+ var RUNNER_PREFIX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z /;
13832
+ var PROBE_LINE = /^CLIProxy auth monitor probe: probe=([a-z]+) reason=([a-z0-9-]+) at (\S+)$/;
13833
+ function isReason(value) {
13834
+ return Object.keys(PROBE_REASON_STATE).includes(value);
13835
+ }
13836
+ function parseProbeLine(log) {
13837
+ const lines = log.replace(/^\uFEFF/, "").split(`
13838
+ `).map((line) => line.replace(/\r$/, "").replace(RUNNER_PREFIX, "")).filter((line) => line.startsWith(PROBE_LINE_PREFIX));
13839
+ if (lines.length === 0)
13840
+ return { kind: "none" };
13841
+ if (lines.length > 1)
13842
+ return { kind: "multiple" };
13843
+ const match = PROBE_LINE.exec(lines[0] ?? "");
13844
+ if (match === null)
13845
+ return { kind: "malformed" };
13846
+ const [, state, reason, at] = match;
13847
+ if (state === undefined || reason === undefined || at === undefined)
13848
+ return { kind: "malformed" };
13849
+ if (!isReason(reason) || PROBE_REASON_STATE[reason] !== state || !isoSchema.safeParse(at).success) {
13850
+ return { kind: "malformed" };
13851
+ }
13852
+ return { kind: "probe", state: PROBE_REASON_STATE[reason], reason, at };
13853
+ }
13854
+ function unknown2(reason, now) {
13855
+ return observe(SPEC2, "unknown", { detail: { reason }, now });
13856
+ }
13857
+ async function readLog(fetchFn, token, jobId) {
13858
+ for (let attempt = 0;attempt <= MONITOR_LOG_RETRY_BUDGET; attempt += 1) {
13859
+ const log = await githubGetLogText(fetchFn, token, `/repos/${GITHUB_REPOSITORY}/actions/jobs/${jobId}/logs`);
13860
+ if (!log.ok)
13861
+ return { kind: "error" };
13862
+ if (log.data.trim() !== "")
13863
+ return { kind: "log", text: log.data };
13864
+ }
13865
+ return { kind: "empty" };
13866
+ }
13867
+ async function readMonitorState(options) {
13868
+ const now = options.now ?? new Date;
13869
+ const { token } = options;
13870
+ if (!hasToken(token))
13871
+ return notMeasured(SPEC2, "missing-config");
13872
+ const fetchFn = options.fetch ?? fetch;
13873
+ const runs = await githubGetJson(fetchFn, token, `/repos/${GITHUB_REPOSITORY}/actions/workflows/${MONITOR_WORKFLOW_FILE}/runs?event=schedule&branch=main&per_page=1`, runsSchema);
13874
+ if (!runs.ok)
13875
+ return unknown2("api-error", now);
13876
+ const run = runs.data.workflow_runs[0];
13877
+ if (run === undefined || run.event !== "schedule" || run.head_branch !== "main")
13878
+ return unknown2("no-run", now);
13879
+ if (run.status !== "completed")
13880
+ return unknown2("running", now);
13881
+ const jobs = await githubGetJson(fetchFn, token, `/repos/${GITHUB_REPOSITORY}/actions/runs/${run.id}/jobs?per_page=2`, jobsSchema);
13882
+ if (!jobs.ok)
13883
+ return unknown2("api-error", now);
13884
+ const [job, ...extra] = jobs.data.jobs;
13885
+ if (job === undefined || extra.length > 0)
13886
+ return unknown2("job-missing", now);
13887
+ const log = await readLog(fetchFn, token, job.id);
13888
+ if (log.kind === "error")
13889
+ return unknown2("api-error", now);
13890
+ if (log.kind === "empty")
13891
+ return unknown2("log-empty", now);
13892
+ const parsed = parseProbeLine(log.text);
13893
+ switch (parsed.kind) {
13894
+ case "none":
13895
+ return unknown2("no-probe-line", now);
13896
+ case "multiple":
13897
+ return unknown2("multiple-probe-lines", now);
13898
+ case "malformed":
13899
+ return unknown2("malformed-probe-line", now);
13900
+ case "probe":
13901
+ break;
13902
+ }
13903
+ const age = now.getTime() - Date.parse(parsed.at);
13904
+ if (age < -MAX_FUTURE_SKEW_MS)
13905
+ return unknown2("malformed-probe-line", now);
13906
+ if (age > MONITOR_STALE_AFTER_MS) {
13907
+ return observe(SPEC2, "unknown", { detail: { reason: "stale", probeAt: parsed.at }, now });
13908
+ }
13909
+ const result = parsed.state === "healthy" ? "pass" : parsed.state === "dead" ? "fail" : "unknown";
13910
+ return observe(SPEC2, result, { detail: { reason: parsed.reason, probeAt: parsed.at }, now });
13911
+ }
13912
+
13913
+ // src/status/github-reads.ts
13914
+ async function runGithubReads(readers, options) {
13915
+ const [deploy, monitor] = await Promise.allSettled([
13916
+ readers.readDeployStates(options),
13917
+ readers.readMonitorState(options)
13918
+ ]);
13919
+ return { deploy, monitor };
13920
+ }
13921
+ function checkFailed(app, id, now) {
13922
+ return observe(declaredObservation(app, id), "unknown", { detail: { reason: "check-failed" }, now });
13923
+ }
13924
+ function githubObservationsFor(app, reads, now) {
13925
+ const observations = [];
13926
+ if (reads.deploy.status === "rejected") {
13927
+ observations.push(checkFailed(app, "deploy-state", now));
13928
+ } else {
13929
+ const deployState = reads.deploy.value[app];
13930
+ if (deployState !== undefined)
13931
+ observations.push(deployState);
13932
+ }
13933
+ if (app === "cliproxy") {
13934
+ observations.push(reads.monitor.status === "rejected" ? checkFailed(app, MONITOR_OBSERVATION_ID, now) : reads.monitor.value);
13935
+ }
13936
+ return observations;
13937
+ }
13938
+
13939
+ // src/commands/status-history-locate.ts
13940
+ import { appendFile } from "fs/promises";
13941
+ import { z as z32 } from "zod";
13942
+ var HISTORY_ARTIFACT_NAME = "status-history";
13943
+ var SNAPSHOT_WORKFLOW_PATH = ".github/workflows/status-snapshot.yaml";
13944
+ var HISTORY_LOCATE_RUN_CHECK_BUDGET = 5;
13945
+ var LIST_PAGE_SIZE = 100;
13946
+ var WRITER_EVENTS = new Set(["schedule", "workflow_dispatch"]);
13947
+ var REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/;
13948
+ var artifactsSchema = z32.object({
13949
+ total_count: z32.number().int().nonnegative(),
13950
+ artifacts: z32.array(z32.object({
13951
+ name: z32.string(),
13952
+ expired: z32.boolean(),
13953
+ created_at: z32.iso.datetime({ offset: true }),
13954
+ workflow_run: z32.object({ id: z32.number().int().positive(), head_branch: z32.string().nullable() }).nullable()
13955
+ }))
13956
+ });
13957
+ var runSchema = z32.object({
13958
+ id: z32.number().int().positive(),
13959
+ event: z32.string(),
13960
+ head_branch: z32.string().nullable(),
13961
+ path: z32.string()
13962
+ });
13963
+ async function locateHistory(fetchFn, token, repository) {
13964
+ const listed = await githubGetJson(fetchFn, token, `/repos/${repository}/actions/artifacts?name=${HISTORY_ARTIFACT_NAME}&per_page=${LIST_PAGE_SIZE}`, artifactsSchema);
13965
+ if (!listed.ok)
13966
+ return { state: "error", reason: "api-error" };
13967
+ const candidates = listed.data.artifacts.filter((artifact) => artifact.name === HISTORY_ARTIFACT_NAME && !artifact.expired).flatMap((artifact) => artifact.workflow_run?.head_branch === "main" ? [{ createdAt: Date.parse(artifact.created_at), runId: artifact.workflow_run.id }] : []).toSorted((left, right) => right.createdAt - left.createdAt);
13968
+ let checked = 0;
13969
+ for (const candidate of candidates) {
13970
+ if (checked >= HISTORY_LOCATE_RUN_CHECK_BUDGET)
13971
+ return { state: "error", reason: "run-check-budget-exceeded" };
13972
+ checked += 1;
13973
+ const run = await githubGetJson(fetchFn, token, `/repos/${repository}/actions/runs/${candidate.runId}`, runSchema);
13974
+ if (!run.ok)
13975
+ return { state: "error", reason: "api-error" };
13976
+ const { data } = run;
13977
+ if (data.id === candidate.runId && data.head_branch === "main" && WRITER_EVENTS.has(data.event) && data.path === SNAPSHOT_WORKFLOW_PATH) {
13978
+ return { state: "found", runId: candidate.runId };
13979
+ }
13980
+ }
13981
+ if (listed.data.total_count > listed.data.artifacts.length)
13982
+ return { state: "error", reason: "incomplete-listing" };
13983
+ return { state: "empty" };
13984
+ }
13985
+ function outputLines(result) {
13986
+ return result.state === "found" ? `state=found
13987
+ run-id=${result.runId}
13988
+ ` : `state=empty
13989
+ `;
13990
+ }
13991
+ async function statusHistoryLocateAction(ctx, deps = {}, env = process.env) {
13992
+ const fail = (reason) => {
13993
+ ctx.console.error(`status history-locate failed: ${reason}`);
13994
+ ctx.process.exit(1);
13995
+ };
13996
+ const token = resolveGithubToken(env);
13997
+ if (!hasToken(token))
13998
+ return fail("missing-token");
13999
+ const repository = env.GITHUB_REPOSITORY?.trim() || GITHUB_REPOSITORY;
14000
+ if (!REPOSITORY_PATTERN.test(repository))
14001
+ return fail("invalid-repository");
14002
+ const result = await locateHistory(deps.fetch ?? fetch, token, repository);
14003
+ if (result.state === "error")
14004
+ return fail(result.reason);
14005
+ const outputPath = env.GITHUB_OUTPUT?.trim() ?? "";
14006
+ if (outputPath !== "") {
14007
+ try {
14008
+ await appendFile(outputPath, outputLines(result));
14009
+ } catch {
14010
+ return fail("output-file-error");
14011
+ }
14012
+ }
14013
+ ctx.console.log(result.state === "found" ? `found run-id=${result.runId}` : "empty");
14014
+ }
14015
+ function registerStatusHistoryLocate(cli, deps) {
14016
+ cli.command("status history-locate", 'Find the newest usable status-history artifact for the snapshot workflow. Needs GITHUB_TOKEN or GH_TOKEN (read-only, actions: read) and reads GITHUB_REPOSITORY. Prints exactly "found run-id=<id>" or "empty" and exits 0; any API error, truncated listing or exhausted call budget exits non-zero, so an outage is never mistaken for an empty history. When GITHUB_OUTPUT is set it also appends state= and run-id= lines. CLI-only: not exposed over MCP.').example("# Locate the history artifact in CI").example("infra status history-locate").action((_options, ctx) => statusHistoryLocateAction(ctx, deps));
14017
+ }
14018
+
14019
+ // src/commands/status-snapshot.ts
14020
+ import { z as z34 } from "zod";
14021
+
14022
+ // src/status/history.ts
14023
+ import { randomUUID } from "crypto";
14024
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
14025
+ import { join as join8 } from "path";
14026
+ import { z as z33 } from "zod";
14027
+ var HISTORY_SCHEMA_VERSION = 1;
14028
+ var HISTORY_WINDOW_DAYS = 90;
14029
+ var HISTORY_WINDOW_MS = HISTORY_WINDOW_DAYS * 24 * 60 * 60000;
14030
+ var HISTORY_MAX_BYTES = 10 * 1024 * 1024;
14031
+ var MAX_FUTURE_SKEW_MS2 = 5 * 60000;
14032
+ var MAX_OBSERVATIONS_PER_SAMPLE = 32;
14033
+ var MESSAGES = {
14034
+ "missing-history": "history not found; pass --bootstrap to create it",
14035
+ "history-exists": "history already exists; refusing to bootstrap over it",
14036
+ "unsupported-schema": "unsupported history schemaVersion",
14037
+ malformed: "history is malformed",
14038
+ "unknown-app": "history holds an unknown app",
14039
+ "duplicate-sample": "history holds a duplicate run and attempt",
14040
+ "future-sample": "a sample is dated more than 5 minutes in the future",
14041
+ "sample-dropped": "merge would drop a sample inside the 90-day window",
14042
+ "newest-regressed": "newest sample time would move backwards",
14043
+ "too-large": "history exceeds the 10 MB cap",
14044
+ "invalid-sample": "snapshot sample is invalid",
14045
+ "invalid-run": "GITHUB_RUN_ID or GITHUB_RUN_ATTEMPT is invalid",
14046
+ "invalid-now": "--now must be a UTC ISO timestamp"
14047
+ };
14048
+
14049
+ class HistoryError extends Error {
14050
+ code;
14051
+ app;
14052
+ constructor(code, app) {
14053
+ super(app === undefined ? MESSAGES[code] : `${MESSAGES[code]} (${app})`);
14054
+ this.name = "HistoryError";
14055
+ this.code = code;
14056
+ this.app = app;
14057
+ }
14058
+ }
14059
+ var RESULT_CODES = { pass: "p", fail: "f", unknown: "u", not_measured: "n" };
14060
+ var resultCodeSchema = z33.enum(["p", "f", "u", "n"]);
14061
+ var observationIdSchema = z33.string().max(64).regex(/^[a-z][a-z0-9-]*$/);
14062
+ var SAMPLE_DETAIL_KEYS = ["statusCode", "reason", "contractVersion", "since", "probeAt"];
14063
+ var sampleDetailSchema = observationDetailSchema.pick({
14064
+ statusCode: true,
14065
+ reason: true,
14066
+ contractVersion: true,
14067
+ since: true,
14068
+ probeAt: true
14069
+ });
14070
+ var historySampleSchema = z33.strictObject({
14071
+ t: z33.iso.datetime(),
14072
+ run: z33.string().regex(/^(?:\d{1,20}|local(?:-\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z)?)$/),
14073
+ att: z33.number().int().min(1).max(1e4),
14074
+ r: z33.record(observationIdSchema, resultCodeSchema),
14075
+ d: z33.record(observationIdSchema, sampleDetailSchema).optional()
14076
+ }).refine((sample) => {
14077
+ const count = Object.keys(sample.r).length;
14078
+ return count >= 1 && count <= MAX_OBSERVATIONS_PER_SAMPLE;
14079
+ }, "r must hold 1 to 32 results").refine((sample) => Object.keys(sample.d ?? {}).every((id) => (id in sample.r)), "d may only describe ids present in r");
14080
+ var historyMetaSchema = z33.strictObject({
14081
+ schemaVersion: z33.literal(HISTORY_SCHEMA_VERSION),
14082
+ lastSampleAt: z33.iso.datetime()
14083
+ });
14084
+ var historyAppFileSchema = z33.array(historySampleSchema);
14085
+ function recordByApp(build) {
14086
+ return {
14087
+ keeweb: build("keeweb"),
14088
+ cliproxy: build("cliproxy"),
14089
+ gateway: build("gateway"),
14090
+ umami: build("umami"),
14091
+ dashboard: build("dashboard"),
14092
+ vpn: build("vpn"),
14093
+ broker: build("broker")
14094
+ };
14095
+ }
14096
+ function pickDetail(detail) {
14097
+ if (detail === undefined)
14098
+ return;
14099
+ const picked = Object.fromEntries(SAMPLE_DETAIL_KEYS.flatMap((key) => detail[key] === undefined ? [] : [[key, detail[key]]]));
14100
+ if (Object.keys(picked).length === 0)
14101
+ return;
14102
+ const parsed = sampleDetailSchema.safeParse(picked);
14103
+ if (!parsed.success)
14104
+ throw new HistoryError("invalid-sample");
14105
+ return parsed.data;
14106
+ }
14107
+ function compactSample(observations, identity, now) {
14108
+ const r = {};
14109
+ const d = {};
14110
+ for (const observation of observations) {
14111
+ r[observation.id] = RESULT_CODES[observation.result];
14112
+ const detail = pickDetail(observation.detail);
14113
+ if (detail !== undefined)
14114
+ d[observation.id] = detail;
14115
+ }
14116
+ const parsed = historySampleSchema.safeParse({
14117
+ t: now.toISOString(),
14118
+ run: identity.run,
14119
+ att: identity.att,
14120
+ r,
14121
+ ...Object.keys(d).length === 0 ? {} : { d }
14122
+ });
14123
+ if (!parsed.success)
14124
+ throw new HistoryError("invalid-sample");
14125
+ return parsed.data;
14126
+ }
14127
+ function serializeHistory(history) {
14128
+ const files = [
14129
+ ...APP_IDS.map((app) => ({ path: `apps/${app}.json`, content: JSON.stringify(history.apps[app]) })),
14130
+ { path: "meta.json", content: JSON.stringify(history.meta) }
14131
+ ];
14132
+ const encoder = new TextEncoder;
14133
+ return { files, bytes: files.reduce((total, file) => total + encoder.encode(file.content).length, 0) };
14134
+ }
14135
+ var sampleKey = (sample) => `${sample.run}:${sample.att}`;
14136
+ var sampleTime = (sample) => Date.parse(sample.t);
14137
+ var KNOWN_APPS = APP_IDS;
14138
+ function isAppId(value) {
14139
+ return KNOWN_APPS.includes(value);
14140
+ }
14141
+ function assertHistoryInvariants({ previous, next, now, maxBytes = HISTORY_MAX_BYTES }) {
14142
+ const nowMs = now.getTime();
14143
+ const cutoff = nowMs - HISTORY_WINDOW_MS;
14144
+ const futureLimit = nowMs + MAX_FUTURE_SKEW_MS2;
14145
+ for (const app of Object.keys(next.apps)) {
14146
+ if (!isAppId(app))
14147
+ throw new HistoryError("unknown-app");
14148
+ }
14149
+ if (Date.parse(next.meta.lastSampleAt) > futureLimit)
14150
+ throw new HistoryError("future-sample");
14151
+ let newest = Number.NEGATIVE_INFINITY;
14152
+ for (const app of APP_IDS) {
14153
+ const keys = new Set;
14154
+ for (const sample of next.apps[app]) {
14155
+ if (sampleTime(sample) > futureLimit)
14156
+ throw new HistoryError("future-sample", app);
14157
+ const key = sampleKey(sample);
14158
+ if (keys.has(key))
14159
+ throw new HistoryError("duplicate-sample", app);
14160
+ keys.add(key);
14161
+ newest = Math.max(newest, sampleTime(sample));
14162
+ }
14163
+ const before = previous?.apps[app] ?? [];
14164
+ const insideWindow = before.filter((sample) => sampleTime(sample) >= cutoff);
14165
+ const outsideWindow = before.length - insideWindow.length;
14166
+ if (next.apps[app].length < before.length - outsideWindow || insideWindow.some((sample) => !keys.has(sampleKey(sample)))) {
14167
+ throw new HistoryError("sample-dropped", app);
14168
+ }
14169
+ }
14170
+ if (previous !== undefined) {
14171
+ const floor = Date.parse(previous.meta.lastSampleAt);
14172
+ if (floor >= cutoff && (newest < floor || Date.parse(next.meta.lastSampleAt) < floor)) {
14173
+ throw new HistoryError("newest-regressed");
14174
+ }
14175
+ }
14176
+ if (serializeHistory(next).bytes > maxBytes)
14177
+ throw new HistoryError("too-large");
14178
+ }
14179
+ function mergeHistory(input) {
14180
+ const { previous, incoming, now } = input;
14181
+ const cutoff = now.getTime() - HISTORY_WINDOW_MS;
14182
+ let added = 0;
14183
+ let trimmed = 0;
14184
+ let newest;
14185
+ const apps = {};
14186
+ for (const app of APP_IDS) {
14187
+ const existing = previous?.apps[app] ?? [];
14188
+ const known = new Set(existing.map(sampleKey));
14189
+ const fresh = incoming[app];
14190
+ const appended = known.has(sampleKey(fresh)) ? [] : [fresh];
14191
+ const candidates = [...existing, ...appended];
14192
+ const kept = candidates.filter((sample) => sampleTime(sample) >= cutoff).sort((a, b) => sampleTime(a) - sampleTime(b));
14193
+ added += appended.length;
14194
+ trimmed += candidates.length - kept.length;
14195
+ apps[app] = kept;
14196
+ for (const sample of kept) {
14197
+ if (newest === undefined || sampleTime(sample) > sampleTime(newest))
14198
+ newest = sample;
14199
+ }
14200
+ }
14201
+ const history = {
14202
+ meta: { schemaVersion: HISTORY_SCHEMA_VERSION, lastSampleAt: newest?.t ?? now.toISOString() },
14203
+ apps
14204
+ };
14205
+ assertHistoryInvariants({
14206
+ previous,
14207
+ next: history,
14208
+ now,
14209
+ ...input.maxBytes === undefined ? {} : { maxBytes: input.maxBytes }
14210
+ });
14211
+ return { history, added, trimmed };
14212
+ }
14213
+ function isNotFound(error) {
14214
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
14215
+ }
14216
+ async function pathExists(path) {
14217
+ try {
14218
+ await stat(path);
14219
+ return true;
14220
+ } catch (error) {
14221
+ if (isNotFound(error))
14222
+ return false;
14223
+ throw error;
14224
+ }
14225
+ }
14226
+ async function readBounded(path, budget, app) {
14227
+ let size;
14228
+ try {
14229
+ size = (await stat(path)).size;
14230
+ } catch (error) {
14231
+ if (isNotFound(error))
14232
+ throw new HistoryError("malformed", app);
14233
+ throw error;
14234
+ }
14235
+ budget.remaining -= size;
14236
+ if (budget.remaining < 0)
14237
+ throw new HistoryError("too-large");
14238
+ return readFile(path, "utf8");
14239
+ }
14240
+ function parseJson(text, app) {
14241
+ try {
14242
+ return JSON.parse(text);
14243
+ } catch {
14244
+ throw new HistoryError("malformed", app);
14245
+ }
14246
+ }
14247
+ async function loadHistory(dir, mode, options = {}) {
14248
+ const metaPath = join8(dir, "meta.json");
14249
+ const appsDir = join8(dir, "apps");
14250
+ const hasMeta = await pathExists(metaPath);
14251
+ if (mode === "bootstrap") {
14252
+ const hasApps = await pathExists(appsDir) && (await readdir(appsDir)).length > 0;
14253
+ if (hasMeta || hasApps)
14254
+ throw new HistoryError("history-exists");
14255
+ return;
14256
+ }
14257
+ if (!hasMeta)
14258
+ throw new HistoryError("missing-history");
14259
+ const budget = { remaining: options.maxBytes ?? HISTORY_MAX_BYTES };
14260
+ const rawMeta = parseJson(await readBounded(metaPath, budget));
14261
+ if (typeof rawMeta === "object" && rawMeta !== null && "schemaVersion" in rawMeta && rawMeta.schemaVersion !== 1) {
14262
+ throw new HistoryError("unsupported-schema");
14263
+ }
14264
+ const meta = historyMetaSchema.safeParse(rawMeta);
14265
+ if (!meta.success)
14266
+ throw new HistoryError("malformed");
14267
+ let entries;
14268
+ try {
14269
+ entries = await readdir(appsDir);
14270
+ } catch (error) {
14271
+ if (isNotFound(error))
14272
+ throw new HistoryError("malformed");
14273
+ throw error;
14274
+ }
14275
+ for (const entry of entries) {
14276
+ if (!entry.endsWith(".json") || !isAppId(entry.slice(0, -".json".length)))
14277
+ throw new HistoryError("unknown-app");
14278
+ }
14279
+ const apps = {};
14280
+ for (const app of APP_IDS) {
14281
+ const parsed = historyAppFileSchema.safeParse(parseJson(await readBounded(join8(appsDir, `${app}.json`), budget, app), app));
14282
+ if (!parsed.success)
14283
+ throw new HistoryError("malformed", app);
14284
+ const keys = new Set(parsed.data.map(sampleKey));
14285
+ if (keys.size !== parsed.data.length)
14286
+ throw new HistoryError("duplicate-sample", app);
14287
+ apps[app] = parsed.data;
14288
+ }
14289
+ return { meta: meta.data, apps };
14290
+ }
14291
+ async function writeHistory(dir, history) {
14292
+ const { files } = serializeHistory(history);
14293
+ const staged = [];
14294
+ try {
14295
+ await mkdir(join8(dir, "apps"), { recursive: true });
14296
+ for (const file of files) {
14297
+ const final = join8(dir, file.path);
14298
+ const temp = `${final}.${randomUUID()}.tmp`;
14299
+ staged.push({ temp, final });
14300
+ await writeFile(temp, file.content, { flag: "wx" });
14301
+ }
14302
+ for (const { temp, final } of staged)
14303
+ await rename(temp, final);
14304
+ } catch (error) {
14305
+ await Promise.all(staged.map(({ temp }) => rm(temp, { force: true })));
14306
+ throw error;
14307
+ }
14308
+ }
14309
+
14310
+ // src/commands/status-snapshot.ts
14311
+ function resolveRunIdentity(env, now) {
14312
+ const run = env.GITHUB_RUN_ID?.trim() ?? "";
14313
+ if (run === "")
14314
+ return { run: `local-${now.toISOString()}`, att: 1 };
14315
+ const attempt = env.GITHUB_RUN_ATTEMPT?.trim() ?? "";
14316
+ if (!/^\d{1,20}$/.test(run) || attempt !== "" && !/^\d{1,5}$/.test(attempt)) {
14317
+ throw new HistoryError("invalid-run");
14318
+ }
14319
+ return { run, att: attempt === "" ? 1 : Number(attempt) };
14320
+ }
14321
+ function resolveNow(value, clock) {
14322
+ if (value === undefined)
14323
+ return clock();
14324
+ if (!z34.iso.datetime().safeParse(value).success)
14325
+ throw new HistoryError("invalid-now");
14326
+ return new Date(value);
14327
+ }
14328
+ var SNAPSHOT_IDS = recordByApp((app) => new Set(DECLARED_OBSERVATIONS[app].filter((declared) => declared.owner === "probe" || declared.owner === "github").map((declared) => declared.id)));
14329
+ async function runProbe2(probe) {
14330
+ if (probe === undefined)
14331
+ return { kind: "none" };
14332
+ try {
14333
+ return { kind: "ok", observation: await probe() };
14334
+ } catch {
14335
+ return { kind: "failed" };
14336
+ }
14337
+ }
14338
+ async function collectObservations(deps, token, now) {
14339
+ const reads = runGithubReads(deps, { token, now, ...deps.fetch === undefined ? {} : { fetch: deps.fetch } });
14340
+ const probes = await Promise.all(APP_IDS.map(async (app) => [app, await runProbe2(deps.publicProbes[app])]));
14341
+ const settled = await reads;
14342
+ return recordByApp((app) => {
14343
+ const probe = probes.find(([candidate]) => candidate === app)?.[1] ?? { kind: "none" };
14344
+ const status = assembleAppStatus(app, probe, { kind: "none" }, now, githubObservationsFor(app, settled, now));
14345
+ return status.observations.filter((observation) => SNAPSHOT_IDS[app].has(observation.id));
14346
+ });
14347
+ }
14348
+ function describeFailure(error) {
14349
+ if (error instanceof HistoryError)
14350
+ return error.message;
14351
+ if (error instanceof Error && "code" in error && typeof error.code === "string" && /^E[A-Z\d]+$/.test(error.code)) {
14352
+ return `filesystem error (${error.code})`;
14353
+ }
14354
+ return "unexpected error";
14355
+ }
14356
+ async function statusSnapshotAction(options, ctx, deps, env = process.env) {
14357
+ try {
14358
+ const now = resolveNow(options.now, deps.now);
14359
+ const identity = resolveRunIdentity(env, now);
14360
+ const previous = await loadHistory(options.history, options.bootstrap === true ? "bootstrap" : "existing");
14361
+ const observations = await collectObservations(deps, resolveGithubToken(env), now);
14362
+ const incoming = recordByApp((app) => compactSample(observations[app], identity, now));
14363
+ const merged = mergeHistory({ previous, incoming, now });
14364
+ if (previous === undefined || merged.added > 0 || merged.trimmed > 0) {
14365
+ await writeHistory(options.history, merged.history);
14366
+ }
14367
+ ctx.console.log(`samples: ${APP_IDS.map((app) => `${app}=${merged.history.apps[app].length}`).join(" ")}`);
14368
+ ctx.console.log(`added: ${merged.added}`);
14369
+ ctx.console.log(`trimmed: ${merged.trimmed}`);
14370
+ ctx.console.log(`bytes: ${serializeHistory(merged.history).bytes}`);
14371
+ } catch (error) {
14372
+ ctx.console.error(`status snapshot failed: ${describeFailure(error)}`);
14373
+ ctx.process.exit(1);
14374
+ }
14375
+ }
14376
+ function registerStatusSnapshot(cli, deps) {
14377
+ cli.command("status snapshot", "Take one public-safe status sample and merge it into the 90-day history directory. Runs only the public HTTPS probes, the GitHub Deployments reader and the auth monitor reader (GITHUB_TOKEN or GH_TOKEN, read-only); it uses no SSH and no deploy secrets. Stages every file as a temp file before renaming any into place; any invariant violation exits non-zero and writes nothing. Prints counts only. Exits 0 once the snapshot is written, even when observations are fail or unknown, because those are data. CLI-only: not exposed over MCP.").option("--history <dir>", z34.string().min(1).describe("History directory holding meta.json and apps/<app>.json. It must already contain a valid history unless --bootstrap is given.")).option("--bootstrap", z34.boolean().describe("Create the history and its first sample. Fails if meta.json or any app file already exists; without this flag a missing history fails.")).option("--now [iso]", z34.string().describe("UTC ISO timestamp (for example 2026-11-01T12:00:00Z) to use as the sample time and the 90-day trim reference instead of the clock. Intended for tests and replays.")).example("# Create the first history sample in an empty directory").example("infra status snapshot --history ./status-history --bootstrap").example("# Append a sample to an existing history").example("infra status snapshot --history ./status-history").action((options, ctx) => statusSnapshotAction(options, ctx, deps));
14378
+ }
14379
+
14380
+ // src/commands/umami/deploy.ts
14381
+ import { z as z35 } from "zod";
13319
14382
  var REPO6 = "marcusrbrown/infra";
13320
14383
  var WORKFLOW_NAME6 = "Deploy Umami";
13321
14384
  var WORKFLOW_URL6 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-umami.yaml";
@@ -13350,7 +14413,7 @@ function validateUmamiRemotePreconditions() {
13350
14413
  }
13351
14414
  }
13352
14415
  function registerUmamiDeploy(cli) {
13353
- cli.command("umami deploy", "Deploy Umami analytics. Default mode triggers the GitHub Deploy Umami workflow, while --local runs apps/umami deploy directly with Bun.").option("--local", z29.boolean().default(false).describe("Run local deployment with Bun using apps/umami instead of triggering GitHub Actions.")).option("--dry-run", z29.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).example("# Trigger remote GitHub Actions deploy (default mode)").example("infra umami deploy").example("# Validate local deploy preconditions and planned command").example("infra umami deploy --local --dry-run").example("# Run local deploy with explicit SSH agent context").example("infra umami deploy --local").action(async (options) => {
14416
+ cli.command("umami deploy", "Deploy Umami analytics. Default mode triggers the GitHub Deploy Umami workflow, while --local runs apps/umami deploy directly with Bun.").option("--local", z35.boolean().default(false).describe("Run local deployment with Bun using apps/umami instead of triggering GitHub Actions.")).option("--dry-run", z35.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).example("# Trigger remote GitHub Actions deploy (default mode)").example("infra umami deploy").example("# Validate local deploy preconditions and planned command").example("infra umami deploy --local --dry-run").example("# Run local deploy with explicit SSH agent context").example("infra umami deploy --local").action(async (options) => {
13354
14417
  if (options.local) {
13355
14418
  const command = ["bun", "run", "--cwd", "apps/umami", "deploy"];
13356
14419
  if (options.dryRun) {
@@ -13391,7 +14454,7 @@ function registerUmamiDeploy(cli) {
13391
14454
  }
13392
14455
 
13393
14456
  // src/commands/umami/logs.ts
13394
- import { z as z30 } from "zod";
14457
+ import { z as z36 } from "zod";
13395
14458
 
13396
14459
  // src/commands/umami/host.ts
13397
14460
  var VALID_HOST_RE5 = /^[a-z\d][a-z\d.\-]*$/i;
@@ -13455,7 +14518,7 @@ async function streamUmamiLogs(opts, spawn = defaultLogsSpawn4, printOut, printE
13455
14518
  return { refused: false, exitCode };
13456
14519
  }
13457
14520
  function registerUmamiLogs(cli) {
13458
- cli.command("umami logs [service]", "Stream logs from an Umami service via SSH and docker compose.").option("--tail [n]", z30.number().default(100).describe("Number of log lines to tail from each service.")).option("--allow-ci", z30.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive credentials.")).option("--key [key]", z30.string().describe("Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.")).action(async (service, options) => {
14521
+ cli.command("umami logs [service]", "Stream logs from an Umami service via SSH and docker compose.").option("--tail [n]", z36.number().default(100).describe("Number of log lines to tail from each service.")).option("--allow-ci", z36.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive credentials.")).option("--key [key]", z36.string().describe("Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.")).action(async (service, options) => {
13459
14522
  const targetService = service ?? "umami";
13460
14523
  try {
13461
14524
  validateService3(targetService);
@@ -13485,7 +14548,7 @@ function registerUmamiLogs(cli) {
13485
14548
  }
13486
14549
 
13487
14550
  // src/commands/umami/status.ts
13488
- import { z as z31 } from "zod";
14551
+ import { z as z37 } from "zod";
13489
14552
  var COMPOSE_PROJECT_DIR7 = "/opt/umami";
13490
14553
  function parseComposePsOutput3(stdoutText) {
13491
14554
  const trimmed = stdoutText.trim();
@@ -13517,7 +14580,7 @@ function isAllRunning3(rows) {
13517
14580
  function defaultSpawn6(cmd, opts) {
13518
14581
  return Bun.spawn(cmd, opts);
13519
14582
  }
13520
- async function getUmamiComposeStatus(host, spawn = defaultSpawn6) {
14583
+ async function getUmamiComposeStatus(host, spawn = defaultSpawn6, signal) {
13521
14584
  validateUmamiHost(host);
13522
14585
  const knownHostsArgs = buildKnownHostsArgs();
13523
14586
  const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.UMAMI_SSH_KEY);
@@ -13541,7 +14604,7 @@ async function getUmamiComposeStatus(host, spawn = defaultSpawn6) {
13541
14604
  let stderrText;
13542
14605
  let exitCode;
13543
14606
  try {
13544
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
14607
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
13545
14608
  [stdoutText, stderrText, exitCode] = await Promise.all([
13546
14609
  new Response(child.stdout).text(),
13547
14610
  new Response(child.stderr).text(),
@@ -13571,50 +14634,11 @@ async function getUmamiComposeStatus(host, spawn = defaultSpawn6) {
13571
14634
  const ok = isAllRunning3(services);
13572
14635
  return { ok, services };
13573
14636
  }
13574
- async function getUmamiStatusSummary(host, spawn) {
13575
- let result;
13576
- try {
13577
- result = await getUmamiComposeStatus(host, spawn);
13578
- } catch (error) {
13579
- const errorMsg = error instanceof Error ? error.message : String(error);
13580
- return {
13581
- app: "umami",
13582
- http: `ERROR: ${errorMsg}`,
13583
- lastDeploy: "\u2014",
13584
- version: "\u2014",
13585
- contentHash: "\u2014",
13586
- usageStats: "\u2014"
13587
- };
13588
- }
13589
- if (result.services.length === 0) {
13590
- const errorMsg = result.error ?? "No services reported";
13591
- return {
13592
- app: "umami",
13593
- http: `ERROR: ${errorMsg}`,
13594
- lastDeploy: "\u2014",
13595
- version: "\u2014",
13596
- contentHash: "\u2014",
13597
- usageStats: "\u2014"
13598
- };
13599
- }
13600
- const rows = result.services.map((s) => `${s.service}:${s.state}/${s.health}`).join(", ");
13601
- if (!result.ok) {
13602
- return {
13603
- app: "umami",
13604
- http: `DEGRADED: ${rows}`,
13605
- lastDeploy: "\u2014",
13606
- version: "\u2014",
13607
- contentHash: "\u2014",
13608
- usageStats: "\u2014"
13609
- };
13610
- }
14637
+ async function getUmamiStatusSummary(host, spawn, signal) {
14638
+ const result = await getUmamiComposeStatus(host, spawn, signal);
13611
14639
  return {
13612
- app: "umami",
13613
- http: `OK: ${rows}`,
13614
- lastDeploy: "\u2014",
13615
- version: "\u2014",
13616
- contentHash: "\u2014",
13617
- usageStats: "\u2014"
14640
+ observations: [composeObservation(declaredObservation("umami", "compose-services"), result)],
14641
+ facts: { services: servicesFact(result.services) }
13618
14642
  };
13619
14643
  }
13620
14644
  async function umamiStatusAction(options, ctx, spawn) {
@@ -13656,7 +14680,7 @@ async function umamiStatusAction(options, ctx, spawn) {
13656
14680
  }
13657
14681
  }
13658
14682
  function registerUmamiStatus(cli) {
13659
- cli.command("umami status", "Show operational health of the Umami analytics deployment via docker compose ps.").option("--key [key]", z31.string().optional().describe("Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.")).action((options, ctx) => umamiStatusAction(options, ctx));
14683
+ cli.command("umami status", "Show operational health of the Umami analytics deployment via docker compose ps.").option("--key [key]", z37.string().optional().describe("Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.")).action((options, ctx) => umamiStatusAction(options, ctx));
13660
14684
  }
13661
14685
 
13662
14686
  // src/commands/umami/index.ts
@@ -13667,26 +14691,26 @@ function registerUmamiCommands(cli) {
13667
14691
  }
13668
14692
 
13669
14693
  // src/commands/vpn/client.ts
13670
- import { mkdir, writeFile as writeFile2 } from "fs/promises";
13671
- import { join as join8, resolve as resolve2 } from "path";
13672
- import { z as z33 } from "zod";
14694
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
14695
+ import { join as join9, resolve as resolve2 } from "path";
14696
+ import { z as z39 } from "zod";
13673
14697
 
13674
14698
  // src/commands/vpn/peers.ts
13675
- import { readFile, writeFile } from "fs/promises";
13676
- import { z as z32 } from "zod";
13677
- var peerSchema = z32.object({
13678
- name: z32.string(),
13679
- publicKey: z32.string(),
13680
- tunnelIp: z32.string()
14699
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
14700
+ import { z as z38 } from "zod";
14701
+ var peerSchema = z38.object({
14702
+ name: z38.string(),
14703
+ publicKey: z38.string(),
14704
+ tunnelIp: z38.string()
13681
14705
  });
13682
- var peersFileSchema = z32.object({
13683
- peers: z32.array(peerSchema)
14706
+ var peersFileSchema = z38.object({
14707
+ peers: z38.array(peerSchema)
13684
14708
  }).superRefine((data, ctx) => {
13685
14709
  const seenIps = new Set;
13686
14710
  for (const peer of data.peers) {
13687
14711
  if (seenIps.has(peer.tunnelIp)) {
13688
14712
  ctx.addIssue({
13689
- code: z32.ZodIssueCode.custom,
14713
+ code: z38.ZodIssueCode.custom,
13690
14714
  message: `Duplicate tunnel IP "${peer.tunnelIp}" \u2014 each peer must have a unique tunnel IP.`,
13691
14715
  path: ["peers"]
13692
14716
  });
@@ -13698,7 +14722,7 @@ var peersFileSchema = z32.object({
13698
14722
  for (const peer of data.peers) {
13699
14723
  if (seenNames.has(peer.name)) {
13700
14724
  ctx.addIssue({
13701
- code: z32.ZodIssueCode.custom,
14725
+ code: z38.ZodIssueCode.custom,
13702
14726
  message: `Duplicate name "${peer.name}" \u2014 each peer must have a unique name.`,
13703
14727
  path: ["peers"]
13704
14728
  });
@@ -13711,7 +14735,7 @@ function parsePeersJson(raw) {
13711
14735
  return peersFileSchema.parse(raw);
13712
14736
  }
13713
14737
  async function readPeers(filePath) {
13714
- const content = await readFile(filePath, "utf-8");
14738
+ const content = await readFile2(filePath, "utf-8");
13715
14739
  const raw = JSON.parse(content);
13716
14740
  return parsePeersJson(raw);
13717
14741
  }
@@ -13728,7 +14752,7 @@ async function readPeersOrEmpty(filePath) {
13728
14752
  async function writePeers(filePath, peersFile) {
13729
14753
  const content = `${JSON.stringify(peersFile, null, 2)}
13730
14754
  `;
13731
- await writeFile(filePath, content, { mode: 384 });
14755
+ await writeFile2(filePath, content, { mode: 384 });
13732
14756
  }
13733
14757
  var TUNNEL_SUBNET_PREFIX = "10.8.0.";
13734
14758
  var SERVER_OCTET = 1;
@@ -13911,8 +14935,8 @@ async function vpnClientAdd(name, opts) {
13911
14935
  allowedIps,
13912
14936
  dns
13913
14937
  });
13914
- await mkdir(clientsDir, { recursive: true });
13915
- await writeFile2(confPath, confContent, { mode: 384 });
14938
+ await mkdir2(clientsDir, { recursive: true });
14939
+ await writeFile3(confPath, confContent, { mode: 384 });
13916
14940
  const peersJson = `${JSON.stringify(updatedPeersFile, null, 2)}
13917
14941
  `;
13918
14942
  await syncPeersSecret(peersJson, spawnFn, syncTimeoutMs);
@@ -13934,13 +14958,13 @@ async function vpnClientRemove(name, peersJsonPath, opts = {}) {
13934
14958
  await syncPeersSecret(peersJson, spawnFn, syncTimeoutMs);
13935
14959
  }
13936
14960
  function resolveDefaultPeersJsonPath() {
13937
- return join8(findRepoRoot(), "apps", "vpn", "config", "peers.json");
14961
+ return join9(findRepoRoot(), "apps", "vpn", "config", "peers.json");
13938
14962
  }
13939
14963
  function resolveDefaultClientsDir() {
13940
- return join8(findRepoRoot(), "apps", "vpn", "clients");
14964
+ return join9(findRepoRoot(), "apps", "vpn", "clients");
13941
14965
  }
13942
14966
  function registerVpnClient(cli) {
13943
- cli.command("vpn client add <name>", "Add a new VPN client peer. Generates a keypair, assigns a tunnel IP, writes the client .conf to apps/vpn/clients/, and syncs the roster to the VPN_PEERS GitHub secret (best-effort).").option("--split-tunnel [cidrs]", z33.string().describe("Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).")).option("--dns [dns]", z33.string().describe("DNS server for the client config.")).option("--server-pubkey [key]", z33.string().describe("Server public key. Falls back to VPN_SERVER_PUBKEY env var.")).option("--endpoint [host]", z33.string().describe("VPN server endpoint (IP or hostname). Falls back to VPN_HOST env var.")).example('# Add a new client named "laptop"').example("infra vpn client add laptop").example("# Add a split-tunnel client").example("infra vpn client add laptop --split-tunnel 10.0.0.0/8,192.168.0.0/16").action(async (name, options) => {
14967
+ cli.command("vpn client add <name>", "Add a new VPN client peer. Generates a keypair, assigns a tunnel IP, writes the client .conf to apps/vpn/clients/, and syncs the roster to the VPN_PEERS GitHub secret (best-effort).").option("--split-tunnel [cidrs]", z39.string().describe("Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).")).option("--dns [dns]", z39.string().describe("DNS server for the client config.")).option("--server-pubkey [key]", z39.string().describe("Server public key. Falls back to VPN_SERVER_PUBKEY env var.")).option("--endpoint [host]", z39.string().describe("VPN server endpoint (IP or hostname). Falls back to VPN_HOST env var.")).example('# Add a new client named "laptop"').example("infra vpn client add laptop").example("# Add a split-tunnel client").example("infra vpn client add laptop --split-tunnel 10.0.0.0/8,192.168.0.0/16").action(async (name, options) => {
13944
14968
  const serverPublicKey = options.serverPubkey ?? process.env.VPN_SERVER_PUBKEY;
13945
14969
  const endpoint = options.endpoint ?? process.env.VPN_HOST;
13946
14970
  if (!serverPublicKey) {
@@ -14004,7 +15028,7 @@ function registerVpnClient(cli) {
14004
15028
  }
14005
15029
 
14006
15030
  // src/commands/vpn/deploy.ts
14007
- import { z as z34 } from "zod";
15031
+ import { z as z40 } from "zod";
14008
15032
  var REPO7 = "marcusrbrown/infra";
14009
15033
  var WORKFLOW_NAME7 = "Deploy VPN";
14010
15034
  var WORKFLOW_URL7 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-vpn.yaml";
@@ -14034,7 +15058,7 @@ function validateVpnRemotePreconditions() {
14034
15058
  }
14035
15059
  }
14036
15060
  function registerVpnDeploy(cli) {
14037
- cli.command("vpn deploy", "Deploy the VPN. Default mode triggers the GitHub Deploy VPN workflow, while --local runs apps/vpn deploy directly with Bun.").option("--local", z34.boolean().default(false).describe("Run local deployment with Bun using apps/vpn instead of triggering GitHub Actions.")).option("--dry-run", z34.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).option("--force-server-key", z34.boolean().default(false).describe("Force regeneration of the server WireGuard keypair. WARNING: invalidates all existing client configs.")).example("# Trigger remote GitHub Actions deploy (default mode)").example("infra vpn deploy").example("# Validate local deploy preconditions and planned command").example("infra vpn deploy --local --dry-run").example("# Run local deploy with explicit SSH agent context").example("infra vpn deploy --local").action(async (options) => {
15061
+ cli.command("vpn deploy", "Deploy the VPN. Default mode triggers the GitHub Deploy VPN workflow, while --local runs apps/vpn deploy directly with Bun.").option("--local", z40.boolean().default(false).describe("Run local deployment with Bun using apps/vpn instead of triggering GitHub Actions.")).option("--dry-run", z40.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).option("--force-server-key", z40.boolean().default(false).describe("Force regeneration of the server WireGuard keypair. WARNING: invalidates all existing client configs.")).example("# Trigger remote GitHub Actions deploy (default mode)").example("infra vpn deploy").example("# Validate local deploy preconditions and planned command").example("infra vpn deploy --local --dry-run").example("# Run local deploy with explicit SSH agent context").example("infra vpn deploy --local").action(async (options) => {
14038
15062
  if (options.local) {
14039
15063
  const command = [
14040
15064
  "bun",
@@ -14082,7 +15106,7 @@ function registerVpnDeploy(cli) {
14082
15106
  }
14083
15107
 
14084
15108
  // src/commands/vpn/logs.ts
14085
- import { z as z35 } from "zod";
15109
+ import { z as z41 } from "zod";
14086
15110
 
14087
15111
  // src/commands/vpn/host.ts
14088
15112
  var VALID_HOST_RE6 = /^[a-z\d][a-z\d.\-]*$/i;
@@ -14138,7 +15162,7 @@ async function streamVpnLogs(opts, spawn = defaultLogsSpawn5, printOut, printErr
14138
15162
  return { refused: false, exitCode };
14139
15163
  }
14140
15164
  function registerVpnLogs(cli) {
14141
- cli.command("vpn logs", "Stream WireGuard VPN logs via SSH and journalctl.").option("--tail [n]", z35.number().default(100).describe("Number of log lines to tail.")).option("--allow-ci", z35.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive VPN data.")).option("--key [key]", z35.string().describe("Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.")).action(async (options) => {
15165
+ cli.command("vpn logs", "Stream WireGuard VPN logs via SSH and journalctl.").option("--tail [n]", z41.number().default(100).describe("Number of log lines to tail.")).option("--allow-ci", z41.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive VPN data.")).option("--key [key]", z41.string().describe("Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.")).action(async (options) => {
14142
15166
  const hostEnvKey = options.key ?? "VPN_HOST";
14143
15167
  const host = process.env[hostEnvKey];
14144
15168
  if (!host) {
@@ -14160,7 +15184,7 @@ function registerVpnLogs(cli) {
14160
15184
  }
14161
15185
 
14162
15186
  // src/commands/vpn/status.ts
14163
- import { z as z36 } from "zod";
15187
+ import { z as z42 } from "zod";
14164
15188
 
14165
15189
  // src/commands/vpn/shared.ts
14166
15190
  function parseWgShowOutput(output) {
@@ -14180,7 +15204,7 @@ function parseWgShowOutput(output) {
14180
15204
  function defaultSpawn7(cmd, opts) {
14181
15205
  return Bun.spawn(cmd, opts);
14182
15206
  }
14183
- async function getVpnWgStatus(host, spawn = defaultSpawn7) {
15207
+ async function getVpnWgStatus(host, spawn = defaultSpawn7, signal) {
14184
15208
  validateVpnHost(host);
14185
15209
  const knownHostsArgs = buildKnownHostsArgs();
14186
15210
  const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.VPN_SSH_KEY);
@@ -14204,7 +15228,7 @@ async function getVpnWgStatus(host, spawn = defaultSpawn7) {
14204
15228
  let stderrText;
14205
15229
  let exitCode;
14206
15230
  try {
14207
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
15231
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
14208
15232
  [stdoutText, stderrText, exitCode] = await Promise.all([
14209
15233
  new Response(child.stdout).text(),
14210
15234
  new Response(child.stderr).text(),
@@ -14217,35 +15241,26 @@ async function getVpnWgStatus(host, spawn = defaultSpawn7) {
14217
15241
  const redacted = redactHost(stderrText.trim(), host) || "unknown error";
14218
15242
  return {
14219
15243
  ok: false,
14220
- error: `SSH command failed (exit ${exitCode}): ${redacted}`
15244
+ error: `SSH command failed (exit ${exitCode}): ${redacted}`,
15245
+ exitCode
14221
15246
  };
14222
15247
  }
14223
15248
  const wgShow = parseWgShowOutput(stdoutText);
14224
15249
  return { ok: wgShow.interfaceUp, wgShow };
14225
15250
  }
14226
- async function getVpnStatusSummary(host, spawn) {
14227
- const result = await getVpnWgStatus(host, spawn);
15251
+ var SSH_FAILURE_EXIT_CODE = 255;
15252
+ async function getVpnStatusSummary(host, spawn, signal) {
15253
+ const result = await getVpnWgStatus(host, spawn, signal);
15254
+ const spec = declaredObservation("vpn", "wireguard-interface");
15255
+ if (result.exitCode === SSH_FAILURE_EXIT_CODE) {
15256
+ return { observations: [observe(spec, "unknown", { detail: { reason: "ssh-failed" } })], facts: {} };
15257
+ }
14228
15258
  if (!result.ok || !result.wgShow) {
14229
- const errorMsg = result.error ?? "WireGuard interface not up";
14230
- return {
14231
- app: "vpn",
14232
- http: `ERROR: ${errorMsg}`,
14233
- lastDeploy: "\u2014",
14234
- version: "\u2014",
14235
- contentHash: "\u2014",
14236
- usageStats: "\u2014"
14237
- };
15259
+ return { observations: [observe(spec, "fail", { detail: { reason: "interface-down" } })], facts: {} };
14238
15260
  }
14239
- const { wgShow } = result;
14240
- const pubKeyInfo = wgShow.serverPublicKey ? ` pubkey:${wgShow.serverPublicKey}` : "";
14241
- const peerInfo = `${wgShow.peerCount} peer(s)`;
14242
15261
  return {
14243
- app: "vpn",
14244
- http: `OK: wg0 up,${pubKeyInfo} ${peerInfo}`,
14245
- lastDeploy: "\u2014",
14246
- version: "\u2014",
14247
- contentHash: "\u2014",
14248
- usageStats: "\u2014"
15262
+ observations: [observe(spec, "pass")],
15263
+ facts: { peerCount: result.wgShow.peerCount, serverPublicKey: result.wgShow.serverPublicKey ?? null }
14249
15264
  };
14250
15265
  }
14251
15266
 
@@ -14287,7 +15302,7 @@ async function vpnStatusAction(options, ctx, spawn) {
14287
15302
  }
14288
15303
  }
14289
15304
  function registerVpnStatus(cli) {
14290
- cli.command("vpn status", "Show operational health of the VPN box via SSH and wg show.").option("--key [key]", z36.string().describe("Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.")).action((options, ctx) => vpnStatusAction(options, ctx));
15305
+ cli.command("vpn status", "Show operational health of the VPN box via SSH and wg show.").option("--key [key]", z42.string().describe("Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.")).action((options, ctx) => vpnStatusAction(options, ctx));
14291
15306
  }
14292
15307
 
14293
15308
  // src/commands/vpn/index.ts
@@ -14300,101 +15315,167 @@ function registerVpnCommands(cli) {
14300
15315
 
14301
15316
  // src/commands/status.ts
14302
15317
  var DEFAULT_CLIPROXY_URL9 = "https://cliproxy.fro.bot";
14303
- var ERROR_PREFIX = "\u274C";
15318
+ var COLLECTOR_DEADLINE_MS = 30000;
15319
+ var defaultDependencies = {
15320
+ getKeewebStatusSummary,
15321
+ getCliproxyStatusSummary: (baseUrl, key, signal) => getCliproxyStatusSummary(baseUrl, key, undefined, signal),
15322
+ getGatewayStatusSummary: (host, signal) => getGatewayStatusSummary(host, undefined, signal),
15323
+ getUmamiStatusSummary: (host, signal) => getUmamiStatusSummary(host, undefined, signal),
15324
+ getDashboardStatusSummary: (host, signal) => getDashboardStatusSummary(host, undefined, signal),
15325
+ getVpnStatusSummary: (host, signal) => getVpnStatusSummary(host, undefined, signal),
15326
+ publicProbes: PUBLIC_PROBES,
15327
+ readDeployStates,
15328
+ readMonitorState,
15329
+ now: () => new Date,
15330
+ collectorDeadlineMs: COLLECTOR_DEADLINE_MS
15331
+ };
14304
15332
  var TABLE_COLUMNS = [
14305
- { key: "app", label: "App" },
14306
- { key: "http", label: "HTTP" },
14307
- { key: "lastDeploy", label: "Last Deploy" },
14308
- { key: "version", label: "Version" },
14309
- { key: "contentHash", label: "Content Hash" },
14310
- { key: "usageStats", label: "Usage Stats" }
15333
+ { kind: "reachability", label: "Reachability" },
15334
+ { kind: "container-health", label: "Container" },
15335
+ { kind: "deploy-state", label: "Deploy" },
15336
+ { kind: "end-to-end", label: "End-to-end" },
15337
+ { kind: "resource", label: "Resource" }
14311
15338
  ];
14312
- function stripTrailingSlash7(value) {
15339
+ var RESULT_CELLS = {
15340
+ pass: "\u2705 pass",
15341
+ fail: "\u274C fail",
15342
+ unknown: "\u2753 unknown",
15343
+ not_measured: "\u26AA not measured"
15344
+ };
15345
+ var UNDECLARED_CELL = "\u2014";
15346
+ function stripTrailingSlash8(value) {
14313
15347
  return value.endsWith("/") ? value.slice(0, -1) : value;
14314
15348
  }
14315
- function errorSummary(app, reason) {
14316
- const message = reason instanceof Error ? reason.message : String(reason);
14317
- const errorCell = `${ERROR_PREFIX} ${message}`;
14318
- return {
14319
- app,
14320
- http: errorCell,
14321
- lastDeploy: errorCell,
14322
- version: errorCell,
14323
- contentHash: errorCell,
14324
- usageStats: errorCell
14325
- };
15349
+ function brokerBaseUrl(domain) {
15350
+ const trimmed = domain?.trim() ?? "";
15351
+ if (trimmed === "")
15352
+ return;
15353
+ return /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
14326
15354
  }
14327
- function toJsonPayload(rows) {
14328
- return {
14329
- keeweb: rows.find((row) => row.app === "keeweb") ?? errorSummary("keeweb", "missing result"),
14330
- cliproxy: rows.find((row) => row.app === "cliproxy") ?? errorSummary("cliproxy", "missing result"),
14331
- gateway: rows.find((row) => row.app === "gateway") ?? errorSummary("gateway", "missing result"),
14332
- umami: rows.find((row) => row.app === "umami") ?? errorSummary("umami", "missing result"),
14333
- dashboard: rows.find((row) => row.app === "dashboard") ?? errorSummary("dashboard", "missing result"),
14334
- vpn: rows.find((row) => row.app === "vpn") ?? errorSummary("vpn", "missing result"),
14335
- broker: rows.find((row) => row.app === "broker") ?? errorSummary("broker", "missing result")
15355
+ function describeReason(reason) {
15356
+ return reason instanceof Error ? reason.message : String(reason);
15357
+ }
15358
+ var TIMED_OUT = Symbol("collector-timed-out");
15359
+ function hostCollector(host, run) {
15360
+ return host === "" ? { kind: "missing-config" } : { kind: "run", run: (signal) => run(host, signal) };
15361
+ }
15362
+ async function withDeadline(run, deadlineMs) {
15363
+ const controller = new AbortController;
15364
+ let timer;
15365
+ const deadline = new Promise((resolve) => {
15366
+ timer = setTimeout(() => {
15367
+ controller.abort();
15368
+ resolve(TIMED_OUT);
15369
+ }, deadlineMs);
15370
+ });
15371
+ try {
15372
+ const work = run.run(controller.signal);
15373
+ work.catch(() => {
15374
+ return;
15375
+ });
15376
+ return await Promise.race([work, deadline]);
15377
+ } finally {
15378
+ clearTimeout(timer);
15379
+ }
15380
+ }
15381
+ async function collectApp(app, probe, collector, options) {
15382
+ const { ctx, now, deadlineMs, json, githubReads } = options;
15383
+ const [probeResult, collectorResult] = await Promise.allSettled([
15384
+ probe === undefined ? Promise.resolve(undefined) : probe(),
15385
+ collector.kind === "run" ? withDeadline(collector, deadlineMs) : Promise.resolve(undefined)
15386
+ ]);
15387
+ const logFailure = (message) => {
15388
+ if (!json)
15389
+ ctx.console.error(`${app} ${message}`);
14336
15390
  };
15391
+ let probeOutcome = { kind: "none" };
15392
+ if (probeResult.status === "rejected") {
15393
+ logFailure(`status check failed: ${describeReason(probeResult.reason)}`);
15394
+ probeOutcome = { kind: "failed" };
15395
+ } else if (probeResult.value !== undefined) {
15396
+ probeOutcome = { kind: "ok", observation: probeResult.value };
15397
+ }
15398
+ let collectorOutcome = { kind: collector.kind === "run" ? "none" : collector.kind };
15399
+ if (collectorResult.status === "rejected") {
15400
+ logFailure(`status check failed: ${describeReason(collectorResult.reason)}`);
15401
+ collectorOutcome = { kind: "failed" };
15402
+ } else if (collectorResult.value === TIMED_OUT) {
15403
+ logFailure(`status check timed out after ${deadlineMs}ms`);
15404
+ collectorOutcome = { kind: "timeout" };
15405
+ } else if (collectorResult.value !== undefined) {
15406
+ collectorOutcome = { kind: "ok", collection: collectorResult.value };
15407
+ }
15408
+ return assembleAppStatus(app, probeOutcome, collectorOutcome, now(), githubObservationsFor(app, await githubReads, now()));
15409
+ }
15410
+ function formatRow(app) {
15411
+ const cells = TABLE_COLUMNS.map(({ kind }) => {
15412
+ const worst = worstResult(app, kind);
15413
+ return worst === undefined ? UNDECLARED_CELL : RESULT_CELLS[worst];
15414
+ });
15415
+ return `| ${[app.app, ...cells].join(" | ")} |`;
14337
15416
  }
14338
- function formatRow(row) {
14339
- const values = TABLE_COLUMNS.map(({ key }) => row[key]);
14340
- return `| ${values.join(" | ")} |`;
15417
+ function formatHeader() {
15418
+ return `| ${["App", ...TABLE_COLUMNS.map(({ label }) => label)].join(" | ")} |`;
14341
15419
  }
14342
- async function unifiedStatusAction(options, ctx, dependencies = {
14343
- getKeewebStatusSummary,
14344
- getCliproxyStatusSummary,
14345
- getGatewayStatusSummary,
14346
- getUmamiStatusSummary,
14347
- getDashboardStatusSummary,
14348
- getVpnStatusSummary,
14349
- getBrokerStatusSummary
14350
- }) {
14351
- const verbose = options.verbose === true;
14352
- const cliproxyBaseUrl = stripTrailingSlash7(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL9);
15420
+ async function unifiedStatusAction(options, ctx, dependencies = defaultDependencies) {
15421
+ const cliproxyBaseUrl = stripTrailingSlash8(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL9);
14353
15422
  const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? "";
14354
15423
  const gatewayHost = process.env.GATEWAY_HOST ?? "";
14355
15424
  const umamiHost = process.env.UMAMI_DOMAIN ?? "";
14356
15425
  const dashboardHost = process.env.DASHBOARD_DOMAIN ?? "";
14357
15426
  const vpnHost = process.env.VPN_HOST ?? "";
14358
- const brokerHost = process.env.BROKER_DOMAIN ?? process.env.BROKER_HOST ?? "";
14359
- const results = await Promise.allSettled([
14360
- dependencies.getKeewebStatusSummary(verbose),
14361
- dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, verbose),
14362
- dependencies.getGatewayStatusSummary(gatewayHost),
14363
- dependencies.getUmamiStatusSummary(umamiHost),
14364
- dependencies.getDashboardStatusSummary(dashboardHost),
14365
- dependencies.getVpnStatusSummary(vpnHost),
14366
- dependencies.getBrokerStatusSummary(brokerHost)
14367
- ]);
14368
- const appNames = ["keeweb", "cliproxy", "gateway", "umami", "dashboard", "vpn", "broker"];
14369
- const rows = results.map((result, index) => {
14370
- const app = appNames[index] ?? "keeweb";
14371
- if (result.status === "fulfilled") {
14372
- return result.value;
14373
- }
14374
- const reason = result.reason;
14375
- const message = reason instanceof Error ? reason.message : String(reason);
14376
- ctx.console.error(`${app} status check failed: ${message}`);
14377
- return errorSummary(app, reason);
15427
+ const collectors = {
15428
+ keeweb: { kind: "run", run: (signal) => dependencies.getKeewebStatusSummary(signal) },
15429
+ cliproxy: {
15430
+ kind: "run",
15431
+ run: (signal) => dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, signal)
15432
+ },
15433
+ gateway: hostCollector(gatewayHost, dependencies.getGatewayStatusSummary),
15434
+ umami: hostCollector(umamiHost, dependencies.getUmamiStatusSummary),
15435
+ dashboard: hostCollector(dashboardHost, dependencies.getDashboardStatusSummary),
15436
+ vpn: hostCollector(vpnHost, dependencies.getVpnStatusSummary),
15437
+ broker: { kind: "none" }
15438
+ };
15439
+ const probeBaseUrls = {
15440
+ cliproxy: cliproxyBaseUrl,
15441
+ broker: brokerBaseUrl(process.env.BROKER_DOMAIN)
15442
+ };
15443
+ const json = options.json === true;
15444
+ const githubReads = runGithubReads(dependencies, {
15445
+ token: resolveGithubToken(process.env),
15446
+ now: dependencies.now(),
15447
+ ...dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }
14378
15448
  });
14379
- if (options.json === true) {
14380
- ctx.console.log(JSON.stringify(toJsonPayload(rows)));
15449
+ const apps = await Promise.all(APP_IDS.map((app) => {
15450
+ const probe = dependencies.publicProbes[app];
15451
+ const baseUrl = probeBaseUrls[app];
15452
+ const probeOptions = baseUrl === undefined ? undefined : { baseUrl };
15453
+ return collectApp(app, probe && (() => probe(probeOptions)), collectors[app], {
15454
+ ctx,
15455
+ now: dependencies.now,
15456
+ deadlineMs: dependencies.collectorDeadlineMs,
15457
+ json,
15458
+ githubReads
15459
+ });
15460
+ }));
15461
+ if (json) {
15462
+ const envelope = {
15463
+ schemaVersion: STATUS_SCHEMA_VERSION,
15464
+ generatedAt: dependencies.now().toISOString(),
15465
+ apps
15466
+ };
15467
+ ctx.console.log(JSON.stringify(envelope));
14381
15468
  return;
14382
15469
  }
14383
- ctx.console.log("| App | HTTP | Last Deploy | Version | Content Hash | Usage Stats |");
14384
- for (const row of rows) {
14385
- ctx.console.log(formatRow(row));
15470
+ ctx.console.log(formatHeader());
15471
+ for (const app of apps) {
15472
+ ctx.console.log(formatRow(app));
14386
15473
  }
14387
15474
  }
14388
- function registerStatus(cli, dependencies = {
14389
- getKeewebStatusSummary,
14390
- getCliproxyStatusSummary,
14391
- getGatewayStatusSummary,
14392
- getUmamiStatusSummary,
14393
- getDashboardStatusSummary,
14394
- getVpnStatusSummary,
14395
- getBrokerStatusSummary
14396
- }) {
14397
- 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));
15475
+ function registerStatus(cli, dependencies = defaultDependencies) {
15476
+ cli.command("status", "Show status of all deployments").option("--json", z43.boolean().describe("Output a schema-versioned JSON envelope: schemaVersion, generatedAt, and one entry per app with typed observations (id, kind, result, observedAt, source) and facts. The gateway workspace memory status is in facts.workspace.")).action((options, ctx) => unifiedStatusAction(options, ctx, dependencies));
15477
+ registerStatusSnapshot(cli, dependencies);
15478
+ registerStatusHistoryLocate(cli, dependencies);
14398
15479
  }
14399
15480
 
14400
15481
  // src/cli.ts