@marcusrbrown/infra 0.26.2 → 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 +1541 -423
  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.2",
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;
9774
9787
  });
9775
- const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
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;
9821
+ });
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,28 +10937,56 @@ 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";
10964
+ async function readBodyText(response) {
10965
+ try {
10966
+ return await response.text();
10967
+ } catch {
10968
+ return "";
10969
+ }
10970
+ }
10971
+ var PROVIDER_MISSING_MESSAGE = /unknown provider for model/i;
10972
+ function isProviderMissingBody(bodyText) {
10973
+ let parsed;
10974
+ try {
10975
+ parsed = JSON.parse(bodyText);
10976
+ } catch {
10977
+ return PROVIDER_MISSING_MESSAGE.test(bodyText);
10978
+ }
10979
+ if (typeof parsed === "object" && parsed !== null && "error" in parsed) {
10980
+ const { error } = parsed;
10981
+ if (typeof error === "object" && error !== null) {
10982
+ if ("code" in error && error.code === "model_not_found")
10983
+ return true;
10984
+ if ("message" in error && typeof error.message === "string")
10985
+ return PROVIDER_MISSING_MESSAGE.test(error.message);
10986
+ }
10987
+ }
10988
+ return PROVIDER_MISSING_MESSAGE.test(bodyText);
10989
+ }
10673
10990
  async function runProviderAuthProbe(baseUrl, apiKey, options) {
10674
10991
  const model = options?.model ?? DEFAULT_PROVIDER_MODEL;
10675
10992
  const verbose = options?.verbose === true;
@@ -10716,11 +11033,22 @@ async function runProviderAuthProbe(baseUrl, apiKey, options) {
10716
11033
  }
10717
11034
  };
10718
11035
  }
11036
+ if (response.status === 400) {
11037
+ const bodyText = await readBodyText(response);
11038
+ if (isProviderMissingBody(bodyText)) {
11039
+ return {
11040
+ classification: { state: "dead", reason: "provider-missing" },
11041
+ human: {
11042
+ title: "Upstream provider auth (anthropic)",
11043
+ level: "error",
11044
+ summary: `Anthropic provider not registered for ${model} (HTTP ${response.status}) \u2014 check /v1/models; run: cliproxy login claude`,
11045
+ details: details.length > 0 ? details : undefined
11046
+ }
11047
+ };
11048
+ }
11049
+ }
10719
11050
  if (response.status === 503) {
10720
- let bodyText = "";
10721
- try {
10722
- bodyText = await response.text();
10723
- } catch {}
11051
+ const bodyText = await readBodyText(response);
10724
11052
  const isAuthUnavailable = /auth_unavailable|no auth available|providers=claude/i.test(bodyText);
10725
11053
  if (isAuthUnavailable) {
10726
11054
  return {
@@ -10781,35 +11109,50 @@ async function probeManagementAuth(baseUrl, key) {
10781
11109
  const isBanBody = payload !== null && typeof payload === "object" && Object.values(payload).some((v) => typeof v === "string" && /\b(?:ip[- ]?)?bann?ed\b/i.test(v));
10782
11110
  if (isBanBody) {
10783
11111
  return {
10784
- title: "Management access",
10785
- level: "error",
10786
- 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
+ }
10787
11118
  };
10788
11119
  }
10789
11120
  return {
10790
- title: "Management access",
10791
- level: "error",
10792
- 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
+ }
10793
11127
  };
10794
11128
  }
10795
11129
  if (response.status === 401) {
10796
11130
  return {
10797
- title: "Management access",
10798
- level: "error",
10799
- 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
+ }
10800
11137
  };
10801
11138
  }
10802
11139
  return {
10803
- title: "Management access",
10804
- level: "warning",
10805
- 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
+ }
10806
11146
  };
10807
11147
  } catch (error) {
10808
11148
  const message = error instanceof Error ? error.message : String(error);
10809
11149
  return {
10810
- title: "Management access",
10811
- level: "error",
10812
- 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
+ }
10813
11156
  };
10814
11157
  }
10815
11158
  }
@@ -10819,7 +11162,7 @@ async function runManagementChecks(baseUrl, key) {
10819
11162
  }
10820
11163
  const authFailure = await probeManagementAuth(baseUrl, key);
10821
11164
  if (authFailure !== null) {
10822
- return { kind: "auth-failure", result: authFailure };
11165
+ return { kind: "auth-failure", result: authFailure.result, access: authFailure.access };
10823
11166
  }
10824
11167
  const [usage, version] = await Promise.all([checkUsageStats(baseUrl, key), checkVersion(baseUrl, key)]);
10825
11168
  return { kind: "checks", usage, version };
@@ -10833,35 +11176,7 @@ function printCheckResult(result, ctx) {
10833
11176
  }
10834
11177
  }
10835
11178
  }
10836
- function formatCheckSummary(result) {
10837
- return `${levelLabel(result.level)}: ${result.summary}`;
10838
- }
10839
11179
  var RUNNING_VERSION_NOT_CHECKED_SUMMARY = "Not checked for explicit URL override.";
10840
- function formatVersionSummary(running, available) {
10841
- const latestFailure = /429|rate limit/i.test(available.summary) ? "rate-limited" : /auth|\b40[13]\b|ip banned/i.test(available.summary) ? "auth" : "unknown";
10842
- if (running.summary === RUNNING_VERSION_NOT_CHECKED_SUMMARY) {
10843
- if (available.level === "ok") {
10844
- return `not checked (latest ${available.summary})`;
10845
- }
10846
- if (available.summary === "Not checked (no management key).") {
10847
- return "not checked (latest: no key)";
10848
- }
10849
- return `not checked (latest ${latestFailure})`;
10850
- }
10851
- if (running.level === "ok" && available.level === "ok") {
10852
- return running.summary === available.summary ? `${running.summary} (latest)` : `${running.summary} (latest ${available.summary})`;
10853
- }
10854
- if (running.level === "ok") {
10855
- return available.summary === "Not checked (no management key)." ? `${running.summary} (latest: no key)` : `${running.summary} (latest ${latestFailure})`;
10856
- }
10857
- if (available.level === "ok") {
10858
- return `unknown (latest ${available.summary})`;
10859
- }
10860
- if (latestFailure === "auth") {
10861
- return "unknown (auth)";
10862
- }
10863
- return latestFailure === "rate-limited" ? "unknown (latest rate-limited)" : "unknown";
10864
- }
10865
11180
  function getRunningVersionNotChecked(host) {
10866
11181
  try {
10867
11182
  validateCliproxyHost(host);
@@ -10871,51 +11186,64 @@ function getRunningVersionNotChecked(host) {
10871
11186
  return { title: "Running version", level: "warning", summary: `Unable to validate running-version host: ${message}` };
10872
11187
  }
10873
11188
  }
10874
- function formatUsageSummaryLine(result) {
11189
+ function parseUsageCounts(result) {
10875
11190
  const recentMatch = /recent:\s*(\d+)/.exec(result.summary);
10876
11191
  if (!recentMatch) {
10877
11192
  return null;
10878
11193
  }
10879
- const total = Number(recentMatch[1]);
10880
11194
  const errorMatch = /errors:\s*(\d+)/.exec(result.summary);
10881
- const errors = errorMatch ? Number(errorMatch[1]) : 0;
10882
- return `Recent requests: ${total}${errors > 0 ? `, ${errors} errors` : ""}`;
11195
+ return { recent: Number(recentMatch[1]), errors: errorMatch ? Number(errorMatch[1]) : 0 };
11196
+ }
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` : ""}`;
10883
11203
  }
10884
- async function getCliproxyStatusSummary(baseUrl, key, verbose, spawn = defaultSpawn) {
10885
- const normalizedBaseUrl = stripTrailingSlash5(baseUrl);
11204
+ async function getCliproxyStatusSummary(baseUrl, key, spawn = defaultSpawn, signal) {
11205
+ const normalizedBaseUrl = stripTrailingSlash6(baseUrl);
10886
11206
  const host = new URL(normalizedBaseUrl).hostname;
10887
- const [httpResult, mgmt, running] = await Promise.all([
10888
- checkHttpReachability(`${normalizedBaseUrl}/healthz`, verbose),
11207
+ const [mgmt, running] = await Promise.all([
10889
11208
  runManagementChecks(normalizedBaseUrl, key || undefined),
10890
- checkRunningVersion(host, spawn)
11209
+ inspectRunningContainer(host, spawn, signal)
10891
11210
  ]);
10892
- let available;
10893
- 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;
10894
11223
  if (mgmt.kind === "no-key") {
10895
- available = { title: "Latest available version", level: "warning", summary: "Not checked (no management key)." };
10896
- usageStats = "\u2014 (no key)";
11224
+ managementAccess = "no-key";
10897
11225
  } else if (mgmt.kind === "auth-failure") {
10898
- available = mgmt.result;
10899
- usageStats = formatCheckSummary(mgmt.result);
11226
+ managementAccess = mgmt.access;
10900
11227
  } else {
10901
- available = mgmt.version;
10902
- usageStats = formatCheckSummary(mgmt.usage);
11228
+ latestVersion = mgmt.version.level === "ok" ? mgmt.version.summary : null;
11229
+ usage = parseUsageCounts(mgmt.usage);
10903
11230
  }
10904
11231
  return {
10905
- app: "cliproxy",
10906
- http: formatCheckSummary(httpResult),
10907
- lastDeploy: "\u2014",
10908
- version: formatVersionSummary(running, available),
10909
- contentHash: "\u2014",
10910
- usageStats
11232
+ observations: [container],
11233
+ facts: {
11234
+ runningVersion: running.kind === "running" ? running.version : null,
11235
+ latestVersion,
11236
+ usage,
11237
+ managementAccess
11238
+ }
10911
11239
  };
10912
11240
  }
10913
11241
  async function cliproxyStatusAction(options, ctx, spawn = defaultSpawn) {
10914
11242
  let errorCount = 0;
10915
11243
  try {
10916
11244
  const verbose = options.verbose === true;
10917
- const baseUrl = stripTrailingSlash5(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
10918
- 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);
10919
11247
  const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl;
10920
11248
  const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY);
10921
11249
  const providerKey = options.apiKey ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY);
@@ -10993,6 +11321,9 @@ function formatMonitorRunSummary(summary) {
10993
11321
  const issue = summary.issue.length > 0 ? summary.issue.join("+") : "none";
10994
11322
  return `CLIProxy auth monitor summary: probe=${summary.probeState} reason=${summary.probeReason} transition=${summary.transition} issue=${issue} discord=${summary.discord} at ${summary.timestamp}`;
10995
11323
  }
11324
+ function formatMonitorProbeLine(probe) {
11325
+ return `CLIProxy auth monitor probe: probe=${probe.probeState} reason=${probe.probeReason} at ${probe.timestamp}`;
11326
+ }
10996
11327
  var githubIssueSchema = z16.object({
10997
11328
  number: z16.number().int().nonnegative(),
10998
11329
  state: z16.enum(["open", "closed"]),
@@ -11295,6 +11626,7 @@ async function cliproxyMonitorAction(options, ctx, runtime = defaultMonitorRunti
11295
11626
  const timestamp = new Date().toISOString();
11296
11627
  try {
11297
11628
  const classification = validation === "live" ? await checkProviderAuthState(proxyUrl, inputs.apiKey) : providerState(validation);
11629
+ ctx.console.log(formatMonitorProbeLine({ probeState: classification.state, probeReason: classification.reason, timestamp }));
11298
11630
  const finish = (transition, issue = [], discord = "none") => {
11299
11631
  const summary = {
11300
11632
  probeState: classification.state,
@@ -11430,11 +11762,11 @@ var AuthFilesResponseSchema = z18.object({
11430
11762
  files: z18.array(AuthFileSchema)
11431
11763
  });
11432
11764
  var ManagementErrorSchema = z18.object({ error: z18.string() });
11433
- function stripTrailingSlash6(value) {
11765
+ function stripTrailingSlash7(value) {
11434
11766
  return value.endsWith("/") ? value.slice(0, -1) : value;
11435
11767
  }
11436
11768
  function resolveBaseUrl4(input) {
11437
- return stripTrailingSlash6(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL8);
11769
+ return stripTrailingSlash7(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL8);
11438
11770
  }
11439
11771
  function resolveManagementKey4(input) {
11440
11772
  const key = input ?? process.env.CLIPROXY_MANAGEMENT_KEY;
@@ -11768,7 +12100,7 @@ function isAllRunning(rows) {
11768
12100
  function defaultSpawn2(cmd, opts) {
11769
12101
  return Bun.spawn(cmd, opts);
11770
12102
  }
11771
- async function getDashboardComposeStatus(host, spawn = defaultSpawn2) {
12103
+ async function getDashboardComposeStatus(host, spawn = defaultSpawn2, signal) {
11772
12104
  validateDashboardHost(host);
11773
12105
  const knownHostsArgs = buildKnownHostsArgs();
11774
12106
  const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.DASHBOARD_SSH_KEY);
@@ -11794,7 +12126,7 @@ async function getDashboardComposeStatus(host, spawn = defaultSpawn2) {
11794
12126
  let stderrText;
11795
12127
  let exitCode;
11796
12128
  try {
11797
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
12129
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
11798
12130
  [stdoutText, stderrText, exitCode] = await Promise.all([
11799
12131
  new Response(child.stdout).text(),
11800
12132
  new Response(child.stderr).text(),
@@ -11824,50 +12156,11 @@ async function getDashboardComposeStatus(host, spawn = defaultSpawn2) {
11824
12156
  const ok = isAllRunning(services);
11825
12157
  return { ok, services };
11826
12158
  }
11827
- async function getDashboardStatusSummary(host, spawn) {
11828
- let result;
11829
- try {
11830
- result = await getDashboardComposeStatus(host, spawn);
11831
- } catch (error) {
11832
- const errorMsg = error instanceof Error ? error.message : String(error);
11833
- return {
11834
- app: "dashboard",
11835
- http: `ERROR: ${errorMsg}`,
11836
- lastDeploy: "\u2014",
11837
- version: "\u2014",
11838
- contentHash: "\u2014",
11839
- usageStats: "\u2014"
11840
- };
11841
- }
11842
- if (result.services.length === 0) {
11843
- const errorMsg = result.error ?? "No services reported";
11844
- return {
11845
- app: "dashboard",
11846
- http: `ERROR: ${errorMsg}`,
11847
- lastDeploy: "\u2014",
11848
- version: "\u2014",
11849
- contentHash: "\u2014",
11850
- usageStats: "\u2014"
11851
- };
11852
- }
11853
- const rows = result.services.map((s) => `${s.service}:${s.state}/${s.health}`).join(", ");
11854
- if (!result.ok) {
11855
- return {
11856
- app: "dashboard",
11857
- http: `DEGRADED: ${rows}`,
11858
- lastDeploy: "\u2014",
11859
- version: "\u2014",
11860
- contentHash: "\u2014",
11861
- usageStats: "\u2014"
11862
- };
11863
- }
12159
+ async function getDashboardStatusSummary(host, spawn, signal) {
12160
+ const result = await getDashboardComposeStatus(host, spawn, signal);
11864
12161
  return {
11865
- app: "dashboard",
11866
- http: `OK: ${rows}`,
11867
- lastDeploy: "\u2014",
11868
- version: "\u2014",
11869
- contentHash: "\u2014",
11870
- usageStats: "\u2014"
12162
+ observations: [composeObservation(declaredObservation("dashboard", "compose-services"), result)],
12163
+ facts: { services: servicesFact(result.services) }
11871
12164
  };
11872
12165
  }
11873
12166
  async function dashboardStatusAction(options, ctx, spawn) {
@@ -12610,12 +12903,6 @@ function formatWorkspaceMemory(status) {
12610
12903
  ` OpenCode: ${formatOpencode(status)}`
12611
12904
  ];
12612
12905
  }
12613
- function formatWorkspaceSummary(status) {
12614
- if (status.status === "unknown")
12615
- return "workspace=unknown";
12616
- const mem = status.limitBytes === 0 ? `mem=${formatMiB(status.currentBytes)}MiB limit=none` : `mem=${formatMiB(status.currentBytes)}/${formatMiB(status.limitBytes)}MiB`;
12617
- return `workspace ${mem} oom_kills=${status.oomKills} opencode=${status.opencode}`;
12618
- }
12619
12906
 
12620
12907
  // src/commands/gateway/status.ts
12621
12908
  var WORKSPACE_PROBE_TIMEOUT_MS = 1e4;
@@ -12649,7 +12936,7 @@ function isAllRunning2(rows) {
12649
12936
  function defaultSpawn5(cmd, opts) {
12650
12937
  return Bun.spawn(cmd, opts);
12651
12938
  }
12652
- async function getGatewayComposeStatus(host, spawn = defaultSpawn5) {
12939
+ async function getGatewayComposeStatus(host, spawn = defaultSpawn5, signal) {
12653
12940
  validateGatewayHost(host);
12654
12941
  const knownHostsArgs = buildKnownHostsArgs();
12655
12942
  const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.GATEWAY_SSH_KEY);
@@ -12673,7 +12960,7 @@ async function getGatewayComposeStatus(host, spawn = defaultSpawn5) {
12673
12960
  let stderrText;
12674
12961
  let exitCode;
12675
12962
  try {
12676
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
12963
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
12677
12964
  [stdoutText, stderrText, exitCode] = await Promise.all([
12678
12965
  new Response(child.stdout).text(),
12679
12966
  new Response(child.stderr).text(),
@@ -12704,7 +12991,7 @@ async function getGatewayComposeStatus(host, spawn = defaultSpawn5) {
12704
12991
  const ok = isAllRunning2(services);
12705
12992
  return { ok, services };
12706
12993
  }
12707
- async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs = WORKSPACE_PROBE_TIMEOUT_MS) {
12994
+ async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs = WORKSPACE_PROBE_TIMEOUT_MS, signal) {
12708
12995
  validateGatewayHost(host);
12709
12996
  const knownHostsArgs = buildKnownHostsArgs();
12710
12997
  const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.GATEWAY_SSH_KEY);
@@ -12729,7 +13016,7 @@ async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs
12729
13016
  timer = setTimeout(() => resolve("timeout"), timeoutMs);
12730
13017
  });
12731
13018
  try {
12732
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
13019
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
12733
13020
  const collected = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
12734
13021
  collected.catch(() => {
12735
13022
  return;
@@ -12750,30 +13037,43 @@ async function getGatewayWorkspaceStatus(host, spawn = defaultSpawn5, timeoutMs
12750
13037
  cleanup();
12751
13038
  }
12752
13039
  }
12753
- async function getGatewayStatusSummary(host, spawn) {
12754
- 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);
12755
13062
  if (result.services.length === 0) {
12756
- const errorMsg = result.error ?? "No services reported";
12757
13063
  return {
12758
- app: "gateway",
12759
- http: `ERROR: ${errorMsg}`,
12760
- lastDeploy: "\u2014",
12761
- version: "\u2014",
12762
- contentHash: "\u2014",
12763
- usageStats: "\u2014"
13064
+ observations: [
13065
+ compose,
13066
+ observe(declaredObservation("gateway", "workspace-memory"), "unknown", {
13067
+ detail: { reason: "compose-unavailable" }
13068
+ })
13069
+ ],
13070
+ facts: { services: [] }
12764
13071
  };
12765
13072
  }
12766
- const rows = result.services.map((s) => `${s.service}:${s.state}/${s.health}`).join(", ");
12767
- const workspace = await getGatewayWorkspaceStatus(host, spawn);
12768
- const prefix = result.ok ? `OK: ${rows}` : `DEGRADED: ${rows}`;
13073
+ const workspace = await getGatewayWorkspaceStatus(host, spawn, undefined, signal);
12769
13074
  return {
12770
- app: "gateway",
12771
- http: `${prefix}; ${formatWorkspaceSummary(workspace)}`,
12772
- lastDeploy: "\u2014",
12773
- version: "\u2014",
12774
- contentHash: "\u2014",
12775
- usageStats: "\u2014",
12776
- workspace
13075
+ observations: [compose, workspaceObservation(workspace)],
13076
+ facts: { services: servicesFact(result.services), workspace: { ...workspace } }
12777
13077
  };
12778
13078
  }
12779
13079
  async function gatewayStatusAction(options, ctx, spawn, workspaceTimeoutMs) {
@@ -12974,7 +13274,7 @@ import { join as join7 } from "path";
12974
13274
  import { z as z28 } from "zod";
12975
13275
  var SITE_URL = "https://kw.igg.ms/";
12976
13276
  var GH_REPO = "marcusrbrown/infra";
12977
- var HTTP_TIMEOUT_MS4 = 1e4;
13277
+ var HTTP_TIMEOUT_MS3 = 1e4;
12978
13278
  var ghRunSchema = z28.array(z28.object({
12979
13279
  createdAt: z28.string().min(1),
12980
13280
  url: z28.url()
@@ -13016,7 +13316,7 @@ async function checkHttpReachability2(verbose) {
13016
13316
  const startedAt = performance.now();
13017
13317
  try {
13018
13318
  const response = await fetch(SITE_URL, {
13019
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS4)
13319
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
13020
13320
  });
13021
13321
  const elapsedMs = performance.now() - startedAt;
13022
13322
  const details = [];
@@ -13043,11 +13343,11 @@ async function checkHttpReachability2(verbose) {
13043
13343
  title: "HTTP reachability",
13044
13344
  level: "error",
13045
13345
  summary: `Request failed after ${formatDurationMs2(elapsedMs)}: ${message}`,
13046
- details: verbose ? [`URL: ${SITE_URL}`, `Timeout: ${HTTP_TIMEOUT_MS4}ms`] : undefined
13346
+ details: verbose ? [`URL: ${SITE_URL}`, `Timeout: ${HTTP_TIMEOUT_MS3}ms`] : undefined
13047
13347
  };
13048
13348
  }
13049
13349
  }
13050
- async function checkLastDeploy(verbose) {
13350
+ async function checkLastDeploy(verbose, signal) {
13051
13351
  try {
13052
13352
  const proc = Bun.spawn([
13053
13353
  "gh",
@@ -13062,7 +13362,8 @@ async function checkLastDeploy(verbose) {
13062
13362
  GH_REPO
13063
13363
  ], {
13064
13364
  stderr: "pipe",
13065
- stdout: "pipe"
13365
+ stdout: "pipe",
13366
+ ...signal === undefined ? {} : { signal }
13066
13367
  });
13067
13368
  const [exitCode, stdout, stderr] = await Promise.all([
13068
13369
  proc.exited,
@@ -13104,7 +13405,8 @@ async function checkLastDeploy(verbose) {
13104
13405
  title: "Last successful deploy",
13105
13406
  level: "ok",
13106
13407
  summary: `${formatDate(latestRun.createdAt)}`,
13107
- details
13408
+ details,
13409
+ value: latestRun.createdAt
13108
13410
  };
13109
13411
  } catch (error) {
13110
13412
  const message = error instanceof Error ? error.message : String(error);
@@ -13158,7 +13460,7 @@ async function checkContentHash(verbose, repoRoot) {
13158
13460
  }
13159
13461
  try {
13160
13462
  const response = await fetch(SITE_URL, {
13161
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS4)
13463
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
13162
13464
  });
13163
13465
  if (!response.ok) {
13164
13466
  return {
@@ -13177,14 +13479,16 @@ async function checkContentHash(verbose, repoRoot) {
13177
13479
  title: "Content hash",
13178
13480
  level: "ok",
13179
13481
  summary: "Remote index.html matches local dist/index.html",
13180
- details
13482
+ details,
13483
+ value: "match"
13181
13484
  };
13182
13485
  }
13183
13486
  return {
13184
13487
  title: "Content hash",
13185
13488
  level: "warning",
13186
13489
  summary: "Remote index.html differs from local dist/index.html",
13187
- details
13490
+ details,
13491
+ value: "differs"
13188
13492
  };
13189
13493
  } catch (error) {
13190
13494
  const message = error instanceof Error ? error.message : String(error);
@@ -13205,22 +13509,17 @@ function printCheckResult2(result, ctx) {
13205
13509
  }
13206
13510
  }
13207
13511
  }
13208
- function formatCheckSummary2(result) {
13209
- return `${levelLabel2(result.level)}: ${result.summary}`;
13210
- }
13211
- async function getKeewebStatusSummary(verbose) {
13212
- const [httpResult, lastDeployResult, contentHashResult] = await Promise.all([
13213
- checkHttpReachability2(verbose),
13214
- checkLastDeploy(verbose),
13215
- checkContentHash(verbose)
13512
+ async function getKeewebStatusSummary(signal) {
13513
+ const [lastDeployResult, contentHashResult] = await Promise.all([
13514
+ checkLastDeploy(false, signal),
13515
+ checkContentHash(false)
13216
13516
  ]);
13217
13517
  return {
13218
- app: "keeweb",
13219
- http: formatCheckSummary2(httpResult),
13220
- lastDeploy: formatCheckSummary2(lastDeployResult),
13221
- version: "\u2014",
13222
- contentHash: formatCheckSummary2(contentHashResult),
13223
- usageStats: "\u2014"
13518
+ observations: [],
13519
+ facts: {
13520
+ lastDeploy: lastDeployResult.value ?? null,
13521
+ contentHash: contentHashResult.value ?? "unavailable"
13522
+ }
13224
13523
  };
13225
13524
  }
13226
13525
  async function keewebStatusAction(options, ctx) {
@@ -13275,10 +13574,811 @@ function registerMcp(cli) {
13275
13574
  }
13276
13575
 
13277
13576
  // src/commands/status.ts
13278
- import { z as z37 } from "zod";
13577
+ import { z as z43 } from "zod";
13279
13578
 
13280
- // 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
13281
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";
13282
14382
  var REPO6 = "marcusrbrown/infra";
13283
14383
  var WORKFLOW_NAME6 = "Deploy Umami";
13284
14384
  var WORKFLOW_URL6 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-umami.yaml";
@@ -13313,7 +14413,7 @@ function validateUmamiRemotePreconditions() {
13313
14413
  }
13314
14414
  }
13315
14415
  function registerUmamiDeploy(cli) {
13316
- 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) => {
13317
14417
  if (options.local) {
13318
14418
  const command = ["bun", "run", "--cwd", "apps/umami", "deploy"];
13319
14419
  if (options.dryRun) {
@@ -13354,7 +14454,7 @@ function registerUmamiDeploy(cli) {
13354
14454
  }
13355
14455
 
13356
14456
  // src/commands/umami/logs.ts
13357
- import { z as z30 } from "zod";
14457
+ import { z as z36 } from "zod";
13358
14458
 
13359
14459
  // src/commands/umami/host.ts
13360
14460
  var VALID_HOST_RE5 = /^[a-z\d][a-z\d.\-]*$/i;
@@ -13418,7 +14518,7 @@ async function streamUmamiLogs(opts, spawn = defaultLogsSpawn4, printOut, printE
13418
14518
  return { refused: false, exitCode };
13419
14519
  }
13420
14520
  function registerUmamiLogs(cli) {
13421
- 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) => {
13422
14522
  const targetService = service ?? "umami";
13423
14523
  try {
13424
14524
  validateService3(targetService);
@@ -13448,7 +14548,7 @@ function registerUmamiLogs(cli) {
13448
14548
  }
13449
14549
 
13450
14550
  // src/commands/umami/status.ts
13451
- import { z as z31 } from "zod";
14551
+ import { z as z37 } from "zod";
13452
14552
  var COMPOSE_PROJECT_DIR7 = "/opt/umami";
13453
14553
  function parseComposePsOutput3(stdoutText) {
13454
14554
  const trimmed = stdoutText.trim();
@@ -13480,7 +14580,7 @@ function isAllRunning3(rows) {
13480
14580
  function defaultSpawn6(cmd, opts) {
13481
14581
  return Bun.spawn(cmd, opts);
13482
14582
  }
13483
- async function getUmamiComposeStatus(host, spawn = defaultSpawn6) {
14583
+ async function getUmamiComposeStatus(host, spawn = defaultSpawn6, signal) {
13484
14584
  validateUmamiHost(host);
13485
14585
  const knownHostsArgs = buildKnownHostsArgs();
13486
14586
  const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.UMAMI_SSH_KEY);
@@ -13504,7 +14604,7 @@ async function getUmamiComposeStatus(host, spawn = defaultSpawn6) {
13504
14604
  let stderrText;
13505
14605
  let exitCode;
13506
14606
  try {
13507
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
14607
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
13508
14608
  [stdoutText, stderrText, exitCode] = await Promise.all([
13509
14609
  new Response(child.stdout).text(),
13510
14610
  new Response(child.stderr).text(),
@@ -13534,50 +14634,11 @@ async function getUmamiComposeStatus(host, spawn = defaultSpawn6) {
13534
14634
  const ok = isAllRunning3(services);
13535
14635
  return { ok, services };
13536
14636
  }
13537
- async function getUmamiStatusSummary(host, spawn) {
13538
- let result;
13539
- try {
13540
- result = await getUmamiComposeStatus(host, spawn);
13541
- } catch (error) {
13542
- const errorMsg = error instanceof Error ? error.message : String(error);
13543
- return {
13544
- app: "umami",
13545
- http: `ERROR: ${errorMsg}`,
13546
- lastDeploy: "\u2014",
13547
- version: "\u2014",
13548
- contentHash: "\u2014",
13549
- usageStats: "\u2014"
13550
- };
13551
- }
13552
- if (result.services.length === 0) {
13553
- const errorMsg = result.error ?? "No services reported";
13554
- return {
13555
- app: "umami",
13556
- http: `ERROR: ${errorMsg}`,
13557
- lastDeploy: "\u2014",
13558
- version: "\u2014",
13559
- contentHash: "\u2014",
13560
- usageStats: "\u2014"
13561
- };
13562
- }
13563
- const rows = result.services.map((s) => `${s.service}:${s.state}/${s.health}`).join(", ");
13564
- if (!result.ok) {
13565
- return {
13566
- app: "umami",
13567
- http: `DEGRADED: ${rows}`,
13568
- lastDeploy: "\u2014",
13569
- version: "\u2014",
13570
- contentHash: "\u2014",
13571
- usageStats: "\u2014"
13572
- };
13573
- }
14637
+ async function getUmamiStatusSummary(host, spawn, signal) {
14638
+ const result = await getUmamiComposeStatus(host, spawn, signal);
13574
14639
  return {
13575
- app: "umami",
13576
- http: `OK: ${rows}`,
13577
- lastDeploy: "\u2014",
13578
- version: "\u2014",
13579
- contentHash: "\u2014",
13580
- usageStats: "\u2014"
14640
+ observations: [composeObservation(declaredObservation("umami", "compose-services"), result)],
14641
+ facts: { services: servicesFact(result.services) }
13581
14642
  };
13582
14643
  }
13583
14644
  async function umamiStatusAction(options, ctx, spawn) {
@@ -13619,7 +14680,7 @@ async function umamiStatusAction(options, ctx, spawn) {
13619
14680
  }
13620
14681
  }
13621
14682
  function registerUmamiStatus(cli) {
13622
- 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));
13623
14684
  }
13624
14685
 
13625
14686
  // src/commands/umami/index.ts
@@ -13630,26 +14691,26 @@ function registerUmamiCommands(cli) {
13630
14691
  }
13631
14692
 
13632
14693
  // src/commands/vpn/client.ts
13633
- import { mkdir, writeFile as writeFile2 } from "fs/promises";
13634
- import { join as join8, resolve as resolve2 } from "path";
13635
- 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";
13636
14697
 
13637
14698
  // src/commands/vpn/peers.ts
13638
- import { readFile, writeFile } from "fs/promises";
13639
- import { z as z32 } from "zod";
13640
- var peerSchema = z32.object({
13641
- name: z32.string(),
13642
- publicKey: z32.string(),
13643
- 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()
13644
14705
  });
13645
- var peersFileSchema = z32.object({
13646
- peers: z32.array(peerSchema)
14706
+ var peersFileSchema = z38.object({
14707
+ peers: z38.array(peerSchema)
13647
14708
  }).superRefine((data, ctx) => {
13648
14709
  const seenIps = new Set;
13649
14710
  for (const peer of data.peers) {
13650
14711
  if (seenIps.has(peer.tunnelIp)) {
13651
14712
  ctx.addIssue({
13652
- code: z32.ZodIssueCode.custom,
14713
+ code: z38.ZodIssueCode.custom,
13653
14714
  message: `Duplicate tunnel IP "${peer.tunnelIp}" \u2014 each peer must have a unique tunnel IP.`,
13654
14715
  path: ["peers"]
13655
14716
  });
@@ -13661,7 +14722,7 @@ var peersFileSchema = z32.object({
13661
14722
  for (const peer of data.peers) {
13662
14723
  if (seenNames.has(peer.name)) {
13663
14724
  ctx.addIssue({
13664
- code: z32.ZodIssueCode.custom,
14725
+ code: z38.ZodIssueCode.custom,
13665
14726
  message: `Duplicate name "${peer.name}" \u2014 each peer must have a unique name.`,
13666
14727
  path: ["peers"]
13667
14728
  });
@@ -13674,7 +14735,7 @@ function parsePeersJson(raw) {
13674
14735
  return peersFileSchema.parse(raw);
13675
14736
  }
13676
14737
  async function readPeers(filePath) {
13677
- const content = await readFile(filePath, "utf-8");
14738
+ const content = await readFile2(filePath, "utf-8");
13678
14739
  const raw = JSON.parse(content);
13679
14740
  return parsePeersJson(raw);
13680
14741
  }
@@ -13691,7 +14752,7 @@ async function readPeersOrEmpty(filePath) {
13691
14752
  async function writePeers(filePath, peersFile) {
13692
14753
  const content = `${JSON.stringify(peersFile, null, 2)}
13693
14754
  `;
13694
- await writeFile(filePath, content, { mode: 384 });
14755
+ await writeFile2(filePath, content, { mode: 384 });
13695
14756
  }
13696
14757
  var TUNNEL_SUBNET_PREFIX = "10.8.0.";
13697
14758
  var SERVER_OCTET = 1;
@@ -13874,8 +14935,8 @@ async function vpnClientAdd(name, opts) {
13874
14935
  allowedIps,
13875
14936
  dns
13876
14937
  });
13877
- await mkdir(clientsDir, { recursive: true });
13878
- await writeFile2(confPath, confContent, { mode: 384 });
14938
+ await mkdir2(clientsDir, { recursive: true });
14939
+ await writeFile3(confPath, confContent, { mode: 384 });
13879
14940
  const peersJson = `${JSON.stringify(updatedPeersFile, null, 2)}
13880
14941
  `;
13881
14942
  await syncPeersSecret(peersJson, spawnFn, syncTimeoutMs);
@@ -13897,13 +14958,13 @@ async function vpnClientRemove(name, peersJsonPath, opts = {}) {
13897
14958
  await syncPeersSecret(peersJson, spawnFn, syncTimeoutMs);
13898
14959
  }
13899
14960
  function resolveDefaultPeersJsonPath() {
13900
- return join8(findRepoRoot(), "apps", "vpn", "config", "peers.json");
14961
+ return join9(findRepoRoot(), "apps", "vpn", "config", "peers.json");
13901
14962
  }
13902
14963
  function resolveDefaultClientsDir() {
13903
- return join8(findRepoRoot(), "apps", "vpn", "clients");
14964
+ return join9(findRepoRoot(), "apps", "vpn", "clients");
13904
14965
  }
13905
14966
  function registerVpnClient(cli) {
13906
- 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) => {
13907
14968
  const serverPublicKey = options.serverPubkey ?? process.env.VPN_SERVER_PUBKEY;
13908
14969
  const endpoint = options.endpoint ?? process.env.VPN_HOST;
13909
14970
  if (!serverPublicKey) {
@@ -13967,7 +15028,7 @@ function registerVpnClient(cli) {
13967
15028
  }
13968
15029
 
13969
15030
  // src/commands/vpn/deploy.ts
13970
- import { z as z34 } from "zod";
15031
+ import { z as z40 } from "zod";
13971
15032
  var REPO7 = "marcusrbrown/infra";
13972
15033
  var WORKFLOW_NAME7 = "Deploy VPN";
13973
15034
  var WORKFLOW_URL7 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-vpn.yaml";
@@ -13997,7 +15058,7 @@ function validateVpnRemotePreconditions() {
13997
15058
  }
13998
15059
  }
13999
15060
  function registerVpnDeploy(cli) {
14000
- 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) => {
14001
15062
  if (options.local) {
14002
15063
  const command = [
14003
15064
  "bun",
@@ -14045,7 +15106,7 @@ function registerVpnDeploy(cli) {
14045
15106
  }
14046
15107
 
14047
15108
  // src/commands/vpn/logs.ts
14048
- import { z as z35 } from "zod";
15109
+ import { z as z41 } from "zod";
14049
15110
 
14050
15111
  // src/commands/vpn/host.ts
14051
15112
  var VALID_HOST_RE6 = /^[a-z\d][a-z\d.\-]*$/i;
@@ -14101,7 +15162,7 @@ async function streamVpnLogs(opts, spawn = defaultLogsSpawn5, printOut, printErr
14101
15162
  return { refused: false, exitCode };
14102
15163
  }
14103
15164
  function registerVpnLogs(cli) {
14104
- 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) => {
14105
15166
  const hostEnvKey = options.key ?? "VPN_HOST";
14106
15167
  const host = process.env[hostEnvKey];
14107
15168
  if (!host) {
@@ -14123,7 +15184,7 @@ function registerVpnLogs(cli) {
14123
15184
  }
14124
15185
 
14125
15186
  // src/commands/vpn/status.ts
14126
- import { z as z36 } from "zod";
15187
+ import { z as z42 } from "zod";
14127
15188
 
14128
15189
  // src/commands/vpn/shared.ts
14129
15190
  function parseWgShowOutput(output) {
@@ -14143,7 +15204,7 @@ function parseWgShowOutput(output) {
14143
15204
  function defaultSpawn7(cmd, opts) {
14144
15205
  return Bun.spawn(cmd, opts);
14145
15206
  }
14146
- async function getVpnWgStatus(host, spawn = defaultSpawn7) {
15207
+ async function getVpnWgStatus(host, spawn = defaultSpawn7, signal) {
14147
15208
  validateVpnHost(host);
14148
15209
  const knownHostsArgs = buildKnownHostsArgs();
14149
15210
  const { args: identityArgs, cleanup } = buildIdentityArgs(process.env.VPN_SSH_KEY);
@@ -14167,7 +15228,7 @@ async function getVpnWgStatus(host, spawn = defaultSpawn7) {
14167
15228
  let stderrText;
14168
15229
  let exitCode;
14169
15230
  try {
14170
- const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe" });
15231
+ const child = spawn(sshCmd, { env, stdout: "pipe", stderr: "pipe", ...signal === undefined ? {} : { signal } });
14171
15232
  [stdoutText, stderrText, exitCode] = await Promise.all([
14172
15233
  new Response(child.stdout).text(),
14173
15234
  new Response(child.stderr).text(),
@@ -14180,35 +15241,26 @@ async function getVpnWgStatus(host, spawn = defaultSpawn7) {
14180
15241
  const redacted = redactHost(stderrText.trim(), host) || "unknown error";
14181
15242
  return {
14182
15243
  ok: false,
14183
- error: `SSH command failed (exit ${exitCode}): ${redacted}`
15244
+ error: `SSH command failed (exit ${exitCode}): ${redacted}`,
15245
+ exitCode
14184
15246
  };
14185
15247
  }
14186
15248
  const wgShow = parseWgShowOutput(stdoutText);
14187
15249
  return { ok: wgShow.interfaceUp, wgShow };
14188
15250
  }
14189
- async function getVpnStatusSummary(host, spawn) {
14190
- 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
+ }
14191
15258
  if (!result.ok || !result.wgShow) {
14192
- const errorMsg = result.error ?? "WireGuard interface not up";
14193
- return {
14194
- app: "vpn",
14195
- http: `ERROR: ${errorMsg}`,
14196
- lastDeploy: "\u2014",
14197
- version: "\u2014",
14198
- contentHash: "\u2014",
14199
- usageStats: "\u2014"
14200
- };
15259
+ return { observations: [observe(spec, "fail", { detail: { reason: "interface-down" } })], facts: {} };
14201
15260
  }
14202
- const { wgShow } = result;
14203
- const pubKeyInfo = wgShow.serverPublicKey ? ` pubkey:${wgShow.serverPublicKey}` : "";
14204
- const peerInfo = `${wgShow.peerCount} peer(s)`;
14205
15261
  return {
14206
- app: "vpn",
14207
- http: `OK: wg0 up,${pubKeyInfo} ${peerInfo}`,
14208
- lastDeploy: "\u2014",
14209
- version: "\u2014",
14210
- contentHash: "\u2014",
14211
- usageStats: "\u2014"
15262
+ observations: [observe(spec, "pass")],
15263
+ facts: { peerCount: result.wgShow.peerCount, serverPublicKey: result.wgShow.serverPublicKey ?? null }
14212
15264
  };
14213
15265
  }
14214
15266
 
@@ -14250,7 +15302,7 @@ async function vpnStatusAction(options, ctx, spawn) {
14250
15302
  }
14251
15303
  }
14252
15304
  function registerVpnStatus(cli) {
14253
- 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));
14254
15306
  }
14255
15307
 
14256
15308
  // src/commands/vpn/index.ts
@@ -14263,101 +15315,167 @@ function registerVpnCommands(cli) {
14263
15315
 
14264
15316
  // src/commands/status.ts
14265
15317
  var DEFAULT_CLIPROXY_URL9 = "https://cliproxy.fro.bot";
14266
- 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
+ };
14267
15332
  var TABLE_COLUMNS = [
14268
- { key: "app", label: "App" },
14269
- { key: "http", label: "HTTP" },
14270
- { key: "lastDeploy", label: "Last Deploy" },
14271
- { key: "version", label: "Version" },
14272
- { key: "contentHash", label: "Content Hash" },
14273
- { 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" }
14274
15338
  ];
14275
- 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) {
14276
15347
  return value.endsWith("/") ? value.slice(0, -1) : value;
14277
15348
  }
14278
- function errorSummary(app, reason) {
14279
- const message = reason instanceof Error ? reason.message : String(reason);
14280
- const errorCell = `${ERROR_PREFIX} ${message}`;
14281
- return {
14282
- app,
14283
- http: errorCell,
14284
- lastDeploy: errorCell,
14285
- version: errorCell,
14286
- contentHash: errorCell,
14287
- usageStats: errorCell
14288
- };
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}`;
14289
15354
  }
14290
- function toJsonPayload(rows) {
14291
- return {
14292
- keeweb: rows.find((row) => row.app === "keeweb") ?? errorSummary("keeweb", "missing result"),
14293
- cliproxy: rows.find((row) => row.app === "cliproxy") ?? errorSummary("cliproxy", "missing result"),
14294
- gateway: rows.find((row) => row.app === "gateway") ?? errorSummary("gateway", "missing result"),
14295
- umami: rows.find((row) => row.app === "umami") ?? errorSummary("umami", "missing result"),
14296
- dashboard: rows.find((row) => row.app === "dashboard") ?? errorSummary("dashboard", "missing result"),
14297
- vpn: rows.find((row) => row.app === "vpn") ?? errorSummary("vpn", "missing result"),
14298
- 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}`);
14299
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(" | ")} |`;
14300
15416
  }
14301
- function formatRow(row) {
14302
- const values = TABLE_COLUMNS.map(({ key }) => row[key]);
14303
- return `| ${values.join(" | ")} |`;
15417
+ function formatHeader() {
15418
+ return `| ${["App", ...TABLE_COLUMNS.map(({ label }) => label)].join(" | ")} |`;
14304
15419
  }
14305
- async function unifiedStatusAction(options, ctx, dependencies = {
14306
- getKeewebStatusSummary,
14307
- getCliproxyStatusSummary,
14308
- getGatewayStatusSummary,
14309
- getUmamiStatusSummary,
14310
- getDashboardStatusSummary,
14311
- getVpnStatusSummary,
14312
- getBrokerStatusSummary
14313
- }) {
14314
- const verbose = options.verbose === true;
14315
- 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);
14316
15422
  const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? "";
14317
15423
  const gatewayHost = process.env.GATEWAY_HOST ?? "";
14318
15424
  const umamiHost = process.env.UMAMI_DOMAIN ?? "";
14319
15425
  const dashboardHost = process.env.DASHBOARD_DOMAIN ?? "";
14320
15426
  const vpnHost = process.env.VPN_HOST ?? "";
14321
- const brokerHost = process.env.BROKER_DOMAIN ?? process.env.BROKER_HOST ?? "";
14322
- const results = await Promise.allSettled([
14323
- dependencies.getKeewebStatusSummary(verbose),
14324
- dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, verbose),
14325
- dependencies.getGatewayStatusSummary(gatewayHost),
14326
- dependencies.getUmamiStatusSummary(umamiHost),
14327
- dependencies.getDashboardStatusSummary(dashboardHost),
14328
- dependencies.getVpnStatusSummary(vpnHost),
14329
- dependencies.getBrokerStatusSummary(brokerHost)
14330
- ]);
14331
- const appNames = ["keeweb", "cliproxy", "gateway", "umami", "dashboard", "vpn", "broker"];
14332
- const rows = results.map((result, index) => {
14333
- const app = appNames[index] ?? "keeweb";
14334
- if (result.status === "fulfilled") {
14335
- return result.value;
14336
- }
14337
- const reason = result.reason;
14338
- const message = reason instanceof Error ? reason.message : String(reason);
14339
- ctx.console.error(`${app} status check failed: ${message}`);
14340
- 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 }
14341
15448
  });
14342
- if (options.json === true) {
14343
- 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));
14344
15468
  return;
14345
15469
  }
14346
- ctx.console.log("| App | HTTP | Last Deploy | Version | Content Hash | Usage Stats |");
14347
- for (const row of rows) {
14348
- ctx.console.log(formatRow(row));
15470
+ ctx.console.log(formatHeader());
15471
+ for (const app of apps) {
15472
+ ctx.console.log(formatRow(app));
14349
15473
  }
14350
15474
  }
14351
- function registerStatus(cli, dependencies = {
14352
- getKeewebStatusSummary,
14353
- getCliproxyStatusSummary,
14354
- getGatewayStatusSummary,
14355
- getUmamiStatusSummary,
14356
- getDashboardStatusSummary,
14357
- getVpnStatusSummary,
14358
- getBrokerStatusSummary
14359
- }) {
14360
- cli.command("status", "Show status of all deployments").option("--json", z37.boolean().describe("Output machine-readable JSON with keeweb, cliproxy, gateway, umami, dashboard, vpn, and broker summary objects. The gateway object also carries a structured workspace field.")).option("--verbose", z37.boolean().describe("Include verbose per-app health check details when building the summary rows.")).action((options, ctx) => unifiedStatusAction(options, ctx, dependencies));
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);
14361
15479
  }
14362
15480
 
14363
15481
  // src/cli.ts