@marcusrbrown/infra 0.13.21 → 0.14.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 +12 -10
  2. package/dist/cli.js +1959 -1548
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6949,7 +6949,7 @@ import { goke } from "goke";
6949
6949
  // package.json
6950
6950
  var package_default = {
6951
6951
  name: "@marcusrbrown/infra",
6952
- version: "0.13.21",
6952
+ version: "0.14.0",
6953
6953
  description: "Infrastructure management CLI \u2014 deploy automation, health checks, and MCP bridge",
6954
6954
  keywords: [
6955
6955
  "infra",
@@ -7126,7 +7126,7 @@ import { z as z2 } from "zod";
7126
7126
 
7127
7127
  // src/lib/known-hosts.ts
7128
7128
  import { existsSync as existsSync3 } from "fs";
7129
- import { join as join3, resolve } from "path";
7129
+ import { isAbsolute, join as join3, resolve } from "path";
7130
7130
 
7131
7131
  // src/resources/known_hosts
7132
7132
  var known_hosts_default = "./known_hosts-c3ns9jne.";
@@ -7139,8 +7139,9 @@ function resolveKnownHostsPath(libDir) {
7139
7139
  const repoCandidate = join3(repoRoot, ".github", "known_hosts");
7140
7140
  if (existsSync3(repoCandidate))
7141
7141
  return repoCandidate;
7142
- if (existsSync3(known_hosts_default))
7143
- return known_hosts_default;
7142
+ const assetPath = isAbsolute(known_hosts_default) ? known_hosts_default : resolve(import.meta.dir, known_hosts_default);
7143
+ if (existsSync3(assetPath))
7144
+ return assetPath;
7144
7145
  throw new Error(FAIL_CLOSED_ERROR);
7145
7146
  }
7146
7147
  function buildKnownHostsArgs(libDir) {
@@ -7964,1675 +7965,2084 @@ function registerCliproxyModels(cli) {
7964
7965
  cli.command("cliproxy models [provider]", "List the models CLIProxyAPI serves.").option("--url [url]", z8.string().describe("Base URL for CLIProxyAPI. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.")).option("--key [key]", z8.string().describe("API key (bearer). Falls back to CLIPROXY_API_KEY when omitted.")).option("--verbose", "Show owned_by and created date for each model.").example("# List all models served by CLIProxyAPI").example("infra cliproxy models").example("# Filter to Anthropic models only").example("infra cliproxy models anthropic").example("# Show verbose output with owned_by and created date").example("infra cliproxy models --verbose").example("# Filter to OpenAI models with verbose output").example("infra cliproxy models openai --verbose").action((provider, options, ctx) => cliproxyModelsAction({ ...options, provider }, ctx));
7965
7966
  }
7966
7967
 
7967
- // src/commands/cliproxy/open.ts
7968
+ // src/commands/cliproxy/monitor.ts
7969
+ import { z as z10 } from "zod";
7970
+
7971
+ // src/commands/cliproxy/status.ts
7968
7972
  import { z as z9 } from "zod";
7969
- var DEFAULT_HOST2 = "cliproxy.fro.bot";
7970
- var DEFAULT_REMOTE_USER2 = "root";
7971
- function resolveHost2(input) {
7972
- const host = input ?? process.env.CLIPROXY_DOMAIN ?? DEFAULT_HOST2;
7973
- if (!host) {
7974
- throw new Error("CLIPROXY_DOMAIN is required. Pass --host or set CLIPROXY_DOMAIN.");
7973
+ var DEFAULT_CLIPROXY_URL4 = "https://cliproxy.fro.bot";
7974
+ function levelLabel(level) {
7975
+ if (level === "ok") {
7976
+ return "OK";
7975
7977
  }
7976
- return host;
7977
- }
7978
- function registerCliproxyOpen(cli) {
7979
- cli.command("cliproxy open", "Launch CLIProxyAPI TUI dashboard via SSH.").option("--host [host]", z9.string().describe("SSH host for CLIProxyAPI server. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.")).example("# Open the TUI dashboard on the remote CLIProxyAPI instance").example("infra cliproxy open").example("# Open on a custom host").example("infra cliproxy open --host custom.example.com").action(async (options) => {
7980
- const host = validateCliproxyHost(resolveHost2(options.host));
7981
- if (!process.stdin.isTTY) {
7982
- throw new Error("cliproxy open requires an interactive terminal. Run from a shell with TTY attached.");
7983
- }
7984
- const path = process.env.PATH;
7985
- const home = process.env.HOME;
7986
- if (!path) {
7987
- throw new Error("PATH is required to invoke ssh");
7988
- }
7989
- if (!home) {
7990
- throw new Error("HOME is required to invoke ssh");
7991
- }
7992
- const remoteCommand = "cd /opt/cliproxy && docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --tui";
7993
- const child = Bun.spawn([
7994
- "ssh",
7995
- "-tt",
7996
- "-o",
7997
- "BatchMode=yes",
7998
- "-o",
7999
- "ConnectTimeout=10",
8000
- `${DEFAULT_REMOTE_USER2}@${host}`,
8001
- remoteCommand
8002
- ], {
8003
- env: {
8004
- PATH: path,
8005
- HOME: home
8006
- },
8007
- stdin: "inherit",
8008
- stdout: "inherit",
8009
- stderr: "inherit"
8010
- });
8011
- const exitCode = await child.exited;
8012
- if (exitCode !== 0) {
8013
- throw new Error(`Remote TUI command failed with exit code ${exitCode}`);
8014
- }
8015
- });
8016
- }
8017
-
8018
- // src/commands/cliproxy/setup.ts
8019
- import { cancel as cancel2, confirm as confirm2, intro, log as log2, note, outro, select as select2, text as text3 } from "@clack/prompts";
8020
- import { z as z14 } from "zod";
8021
-
8022
- // src/commands/cliproxy/setup/gh.ts
8023
- import { confirm, log, spinner } from "@clack/prompts";
8024
-
8025
- // src/commands/cliproxy/setup/prompts.ts
8026
- import * as clack from "@clack/prompts";
8027
- function ensureRepoFormat(value) {
8028
- const trimmed = value.trim();
8029
- if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
8030
- throw new Error("Repository must be in owner/repo format.");
7978
+ if (level === "warning") {
7979
+ return "WARN";
8031
7980
  }
8032
- return trimmed;
7981
+ return "ERROR";
8033
7982
  }
8034
- function ensureSecretName(value, label) {
8035
- const trimmed = value.trim();
8036
- if (!/^[A-Z][A-Z0-9_]*$/.test(trimmed)) {
8037
- throw new Error(`${label} must be SCREAMING_SNAKE_CASE.`);
8038
- }
8039
- return trimmed;
7983
+ function formatDurationMs(durationMs) {
7984
+ return `${Math.max(0, Math.round(durationMs))}ms`;
8040
7985
  }
8041
- function cancelAndExit(message = "Setup cancelled.") {
8042
- clack.cancel(message);
8043
- process.exit(0);
7986
+ function stripTrailingSlash4(value) {
7987
+ return value.endsWith("/") ? value.slice(0, -1) : value;
8044
7988
  }
8045
- async function promptValue(promise, cancelMessage) {
8046
- const value = await promise;
8047
- if (clack.isCancel(value)) {
8048
- cancelAndExit(cancelMessage);
7989
+ async function parseJsonResponse(response) {
7990
+ try {
7991
+ return await response.json();
7992
+ } catch {
7993
+ return null;
8049
7994
  }
8050
- return value;
8051
- }
8052
- function buildApiKeyValue(keyName) {
8053
- const slug = (keyName.trim().toLowerCase().match(/[a-z0-9]+/g) ?? []).join("-").slice(0, 24);
8054
- const random = crypto.randomUUID().split("-").join("");
8055
- return `sk-${slug || "cliproxy"}-${random}`;
8056
7995
  }
8057
- function extractErrorMessage(error) {
8058
- return error instanceof Error ? error.message : String(error);
7996
+ function toNumber(value) {
7997
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
8059
7998
  }
8060
- async function promptGenericSecretNames() {
8061
- const apiKeySecretName = ensureSecretName(await promptValue(clack.text({
8062
- message: "Name for the API key secret",
8063
- placeholder: "CLIPROXY_API_KEY",
8064
- validate: (value) => {
8065
- try {
8066
- ensureSecretName(value ?? "", "API key secret name");
8067
- return;
8068
- } catch (error) {
8069
- return extractErrorMessage(error);
8070
- }
8071
- }
8072
- }), "Setup cancelled before choosing the generic API key secret name."), "API key secret name");
8073
- const baseUrlSecretName = ensureSecretName(await promptValue(clack.text({
8074
- message: "Name for the proxy base URL secret",
8075
- placeholder: "CLIPROXY_BASE_URL",
8076
- validate: (value) => {
8077
- try {
8078
- ensureSecretName(value ?? "", "Base URL secret name");
8079
- return;
8080
- } catch (error) {
8081
- return extractErrorMessage(error);
7999
+ async function checkHttpReachability(url, verbose) {
8000
+ const startedAt = performance.now();
8001
+ try {
8002
+ const response = await fetch(url, {
8003
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
8004
+ });
8005
+ const elapsedMs = performance.now() - startedAt;
8006
+ const details = [];
8007
+ if (verbose) {
8008
+ details.push(`URL: ${url}`);
8009
+ details.push(`Status text: ${response.statusText || "(none)"}`);
8010
+ if (response.headers.get("content-type")) {
8011
+ details.push(`Content-Type: ${response.headers.get("content-type")}`);
8082
8012
  }
8083
8013
  }
8084
- }), "Setup cancelled before choosing the generic base URL secret name."), "Base URL secret name");
8085
- return { apiKeySecretName, baseUrlSecretName };
8086
- }
8087
-
8088
- // src/commands/cliproxy/setup/gh.ts
8089
- function extractErrorMessage2(error) {
8090
- return error instanceof Error ? error.message : String(error);
8091
- }
8092
- async function withSpinner(message, run) {
8093
- const spinnerInstance = spinner();
8094
- spinnerInstance.start(message);
8095
- try {
8096
- const result = await run(spinnerInstance);
8097
- spinnerInstance.stop(message);
8098
- return result;
8014
+ return {
8015
+ title: "HTTP reachability",
8016
+ level: response.ok ? "ok" : "error",
8017
+ summary: `GET ${url} \u2192 ${response.status} (${formatDurationMs(elapsedMs)})`,
8018
+ details
8019
+ };
8099
8020
  } catch (error) {
8100
- spinnerInstance.error(`${message} failed`);
8101
- throw error;
8021
+ const elapsedMs = performance.now() - startedAt;
8022
+ const message = error instanceof Error ? error.message : String(error);
8023
+ return {
8024
+ title: "HTTP reachability",
8025
+ level: "error",
8026
+ summary: `Request failed after ${formatDurationMs(elapsedMs)}: ${message}`,
8027
+ details: verbose ? [`URL: ${url}`, `Timeout: ${HTTP_TIMEOUT_MS2}ms`] : undefined
8028
+ };
8102
8029
  }
8103
8030
  }
8104
- async function runCommand(command, args) {
8105
- const child = Bun.spawn([command, ...args], {
8106
- stdout: "pipe",
8107
- stderr: "pipe",
8108
- env: process.env
8109
- });
8110
- const [stdout, stderr, exitCode] = await Promise.all([
8111
- new Response(child.stdout).text(),
8112
- new Response(child.stderr).text(),
8113
- child.exited
8114
- ]);
8115
- return { stdout, stderr, exitCode };
8116
- }
8117
- async function runGh(args) {
8118
- return runCommand("gh", args);
8119
- }
8120
- function isGhRateLimitError(text2) {
8121
- return /rate limit/i.test(text2);
8122
- }
8123
- async function queryRateLimitReset() {
8124
- try {
8125
- const result = await runGh(["api", "rate_limit"]);
8126
- if (result.exitCode === 0) {
8127
- const parsed = JSON.parse(result.stdout);
8128
- const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset;
8129
- if (reset) {
8130
- return new Date(reset * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
8131
- }
8031
+ function countQueueErrors(records) {
8032
+ let count = 0;
8033
+ for (const record of records) {
8034
+ if (record === null || typeof record !== "object") {
8035
+ continue;
8132
8036
  }
8133
- } catch {}
8134
- return "an unknown time";
8135
- }
8136
- async function withGhRetry(label, fn, interactive, queryReset = queryRateLimitReset) {
8137
- for (;; ) {
8138
- try {
8139
- return await withSpinner(label, fn);
8140
- } catch (error) {
8141
- const message = extractErrorMessage2(error);
8142
- if (!isGhRateLimitError(message)) {
8143
- throw error;
8144
- }
8145
- const reset = await queryReset();
8146
- if (!interactive) {
8147
- throw new Error(`${message} \u2014 GitHub API rate limit resets at ${reset}. Re-run when ready.`);
8148
- }
8149
- log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`);
8150
- const retry = await promptValue(confirm({
8151
- message: "Retry this step when ready?",
8152
- active: "retry",
8153
- inactive: "abort",
8154
- initialValue: true
8155
- }), "Setup aborted after rate limit.");
8156
- if (!retry) {
8157
- cancelAndExit("Setup aborted after GitHub API rate limit.");
8158
- }
8037
+ const rec = record;
8038
+ const status = toNumber(rec.status);
8039
+ if (status !== null && status >= 400) {
8040
+ count++;
8041
+ continue;
8042
+ }
8043
+ if (rec.error !== undefined || rec.failed === true || rec.failure === true) {
8044
+ count++;
8159
8045
  }
8160
8046
  }
8047
+ return count;
8161
8048
  }
8162
- async function assertGhInstalled() {
8163
- if (!Bun.which("gh")) {
8164
- throw new Error("GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/");
8165
- }
8166
- }
8167
- async function assertGhAuthenticated() {
8168
- const result = await runGh(["auth", "status"]);
8169
- if (result.exitCode !== 0) {
8170
- throw new Error(`GitHub CLI is not authenticated. Run "gh auth login" first. ${result.stderr.trim()}`.trim());
8171
- }
8172
- }
8173
- async function assertRepoAccess(repo) {
8174
- const { z: z10 } = await import("zod");
8175
- const ghRepoViewSchema = z10.object({
8176
- nameWithOwner: z10.string(),
8177
- viewerPermission: z10.string()
8178
- });
8179
- const result = await runGh(["repo", "view", repo, "--json", "nameWithOwner,viewerPermission"]);
8180
- if (result.exitCode !== 0) {
8181
- throw new Error(`Unable to access ${repo}. ${result.stderr.trim()}`.trim());
8182
- }
8183
- const parsed = ghRepoViewSchema.parse(JSON.parse(result.stdout));
8184
- const writePermissions = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
8185
- if (!writePermissions.has(parsed.viewerPermission)) {
8186
- throw new Error(`GitHub CLI does not have write access to ${parsed.nameWithOwner}. Current permission: ${parsed.viewerPermission}.`);
8187
- }
8188
- }
8189
- async function listExistingGhNames(repo, kind) {
8190
- const { z: z10 } = await import("zod");
8191
- const ghNameListSchema = z10.array(z10.object({ name: z10.string() }));
8192
- const result = await runGh([kind, "list", "--repo", repo, "--json", "name"]);
8193
- if (result.exitCode !== 0) {
8194
- throw new Error(`Unable to list existing GitHub ${kind}s for ${repo}. ${result.stderr.trim()}`.trim());
8195
- }
8196
- return ghNameListSchema.parse(JSON.parse(result.stdout)).map((entry) => entry.name);
8197
- }
8198
- async function createManagementApiKey(baseUrl, managementKey, keyValue) {
8199
- const endpoint = `${baseUrl}/v0/management/api-keys`;
8200
- const currentPayload = await requestJson(endpoint, {
8201
- method: "GET",
8202
- headers: managementHeaders(managementKey)
8203
- });
8204
- const currentKeys = parseManagementKeyList(currentPayload);
8205
- if (currentKeys.includes(keyValue)) {
8206
- return;
8207
- }
8208
- await requestJson(endpoint, {
8209
- method: "PUT",
8210
- headers: managementHeaders(managementKey),
8211
- body: JSON.stringify([...currentKeys, keyValue])
8212
- });
8213
- }
8214
- async function deleteManagementApiKey(baseUrl, managementKey, keyValue) {
8215
- const endpoint = `${baseUrl}/v0/management/api-keys`;
8216
- const currentPayload = await requestJson(endpoint, {
8217
- method: "GET",
8218
- headers: managementHeaders(managementKey)
8219
- });
8220
- const currentKeys = parseManagementKeyList(currentPayload);
8221
- const filtered = currentKeys.filter((k) => k !== keyValue);
8222
- if (filtered.length === currentKeys.length) {
8223
- return;
8224
- }
8225
- await requestJson(endpoint, {
8226
- method: "PUT",
8227
- headers: managementHeaders(managementKey),
8228
- body: JSON.stringify(filtered)
8229
- });
8230
- }
8231
- async function applyGhValue(kind, name, repo, value) {
8232
- if (kind === "secret") {
8233
- const child = Bun.spawn(["gh", "secret", "set", name, "--repo", repo], {
8234
- stdin: new Blob([value]).stream(),
8235
- stdout: "pipe",
8236
- stderr: "pipe",
8237
- env: process.env
8049
+ async function checkUsageStats(baseUrl, key) {
8050
+ const endpoint = `${baseUrl}/v0/management/usage-queue?count=50`;
8051
+ try {
8052
+ const response = await fetch(endpoint, {
8053
+ headers: managementHeaders(key),
8054
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
8238
8055
  });
8239
- const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
8240
- if (exitCode !== 0) {
8241
- throw new Error(`gh secret set ${name} failed: ${stderr.trim()}`.trim());
8056
+ if (response.status === 429) {
8057
+ return {
8058
+ title: "Usage stats",
8059
+ level: "warning",
8060
+ summary: "Rate limited by management API (HTTP 429). Retry in a few moments."
8061
+ };
8242
8062
  }
8243
- return;
8244
- }
8245
- const result = await runGh([kind, "set", name, "--repo", repo, "--body", value]);
8246
- if (result.exitCode !== 0) {
8247
- throw new Error(`gh ${kind} set ${name} failed: ${result.stderr.trim()}`.trim());
8248
- }
8249
- }
8250
-
8251
- // src/commands/cliproxy/setup/preview.ts
8252
- function formatDryRunPreview(opts) {
8253
- const { repo, harness, providers, model, template } = opts;
8254
- const lines = [
8255
- `Dry run: cliproxy setup --harness ${harness}`,
8256
- `Repository: ${repo}`,
8257
- `Providers: ${providers.join(", ")}`,
8258
- `Model: ${model}`,
8259
- "Planned secrets:"
8260
- ];
8261
- for (const secret of template.secrets) {
8262
- const size = new TextEncoder().encode(secret.value).byteLength;
8263
- lines.push(` - ${secret.name} (${size} bytes)`);
8264
- }
8265
- lines.push("Planned variables:");
8266
- for (const variable of template.variables) {
8267
- lines.push(` - ${variable.name} = ${variable.value}`);
8268
- }
8269
- lines.push("Proxy key (redacted): <proxy-key>");
8270
- lines.push("No mutations will be performed.");
8271
- return lines.join(`
8272
- `);
8273
- }
8274
-
8275
- // src/commands/cliproxy/setup/providers.ts
8276
- import { isCancel as isCancel2, multiselect, select, text as text2 } from "@clack/prompts";
8277
- import { z as z10 } from "zod";
8278
- var providerIdSchema = z10.enum(["anthropic", "openai"]);
8279
- var MODEL_ID_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/;
8280
- function parseProviders(input) {
8281
- const parts = input.split(",").map((p) => p.trim()).filter(Boolean);
8282
- if (parts.length === 0) {
8283
- throw new Error("--providers must not be empty. Supported values: anthropic, openai");
8284
- }
8285
- const parsed = parts.map((p) => {
8286
- const result = providerIdSchema.safeParse(p);
8287
- if (!result.success) {
8288
- throw new Error(`Unknown provider "${p}". Supported values: anthropic, openai`);
8063
+ if (!response.ok) {
8064
+ return {
8065
+ title: "Usage stats",
8066
+ level: "error",
8067
+ summary: `GET /v0/management/usage-queue failed with HTTP ${response.status}`
8068
+ };
8289
8069
  }
8290
- return result.data;
8291
- });
8292
- const deduped = new Set(parsed);
8293
- if (deduped.size < parsed.length) {
8294
- throw new Error(`--providers contains duplicate values: ${parsed.join(",")}`);
8295
- }
8296
- return parsed;
8297
- }
8298
- var PROVIDER_DEFAULTS = {
8299
- anthropic: "anthropic/claude-sonnet-4-6",
8300
- openai: "openai/gpt-5.4-mini"
8301
- };
8302
- var CUSTOM_MODEL_SENTINEL = "__custom__";
8303
- async function promptForProviders() {
8304
- let providers = [];
8305
- do {
8306
- const result = await multiselect({
8307
- message: "Select providers to configure",
8308
- options: [
8309
- { value: "anthropic", label: "Anthropic" },
8310
- { value: "openai", label: "OpenAI" }
8311
- ],
8312
- initialValues: ["anthropic"],
8313
- required: false
8314
- });
8315
- if (isCancel2(result)) {
8316
- cancelAndExit("Setup cancelled before selecting providers.");
8070
+ const payload = await parseJsonResponse(response);
8071
+ if (!Array.isArray(payload)) {
8072
+ return {
8073
+ title: "Usage stats",
8074
+ level: "warning",
8075
+ summary: "Usage-queue response was not an array \u2014 cannot parse recent activity."
8076
+ };
8317
8077
  }
8318
- providers = result;
8319
- } while (providers.length === 0);
8320
- return providers;
8321
- }
8322
- async function promptForModel(providers) {
8323
- if (providers.length === 1) {
8324
- return PROVIDER_DEFAULTS[providers[0]];
8325
- }
8326
- const chosen = await select({
8327
- message: "Choose a default model",
8328
- options: [
8329
- { value: "openai/gpt-5.4-mini", label: "openai/gpt-5.4-mini" },
8330
- { value: "anthropic/claude-sonnet-4-6", label: "anthropic/claude-sonnet-4-6" },
8331
- { value: CUSTOM_MODEL_SENTINEL, label: "Enter custom model ID..." }
8332
- ]
8333
- });
8334
- if (isCancel2(chosen)) {
8335
- cancelAndExit("Setup cancelled before selecting a model.");
8336
- }
8337
- if (chosen === CUSTOM_MODEL_SENTINEL) {
8338
- return promptForCustomModel();
8078
+ const total = payload.length;
8079
+ const errors2 = countQueueErrors(payload);
8080
+ if (total === 0) {
8081
+ return {
8082
+ title: "Usage stats",
8083
+ level: "ok",
8084
+ summary: "recent: 0 (idle)"
8085
+ };
8086
+ }
8087
+ return {
8088
+ title: "Usage stats",
8089
+ level: errors2 > 0 ? "warning" : "ok",
8090
+ summary: errors2 > 0 ? `recent: ${total}, errors: ${errors2}` : `recent: ${total}`
8091
+ };
8092
+ } catch (error) {
8093
+ const message = error instanceof Error ? error.message : String(error);
8094
+ return {
8095
+ title: "Usage stats",
8096
+ level: "error",
8097
+ summary: `Unable to read usage stats: ${message}`
8098
+ };
8339
8099
  }
8340
- return chosen;
8341
8100
  }
8342
- async function promptForCustomModel() {
8343
- let modelId;
8344
- do {
8345
- const result = await text2({
8346
- message: "Enter a custom model ID (e.g. openai/gpt-5.4-mini)",
8347
- placeholder: "provider/model-name",
8348
- validate: (value) => {
8349
- if (!MODEL_ID_RE.test(value ?? "")) {
8350
- return "Model ID must match provider/model-name (lowercase, digits, dots, hyphens only)";
8351
- }
8352
- return;
8353
- }
8101
+ async function checkVersion(baseUrl, key) {
8102
+ const endpoint = `${baseUrl}/v0/management/latest-version`;
8103
+ try {
8104
+ const response = await fetch(endpoint, {
8105
+ headers: managementHeaders(key),
8106
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
8354
8107
  });
8355
- if (isCancel2(result)) {
8356
- cancelAndExit("Setup cancelled before entering a custom model ID.");
8108
+ if (response.status === 429) {
8109
+ return {
8110
+ title: "Latest version",
8111
+ level: "warning",
8112
+ summary: "Rate limited by management API (HTTP 429). Retry in a few moments."
8113
+ };
8357
8114
  }
8358
- const candidate = result;
8359
- if (MODEL_ID_RE.test(candidate)) {
8360
- modelId = candidate;
8115
+ if (!response.ok) {
8116
+ return {
8117
+ title: "Latest version",
8118
+ level: "error",
8119
+ summary: `GET /v0/management/latest-version failed with HTTP ${response.status}`
8120
+ };
8361
8121
  }
8362
- } while (!modelId);
8363
- return modelId;
8364
- }
8365
-
8366
- // src/commands/cliproxy/setup/smoke-test.ts
8367
- import { z as z11 } from "zod";
8368
- var baselineRunSchema = z11.array(z11.object({ databaseId: z11.number() }));
8369
- var ghRunEntrySchema = z11.object({
8370
- databaseId: z11.number(),
8371
- status: z11.string(),
8372
- conclusion: z11.string().nullable(),
8373
- url: z11.string(),
8374
- createdAt: z11.string()
8375
- });
8376
- async function runSmokeTest(repo, _model, internals = {}) {
8377
- const BACKOFF_MS = [5000, 15000, 30000, 60000, 60000];
8378
- const delayFn = async (ms) => {
8379
- if (internals._testDelayMs !== undefined) {
8380
- if (internals._testDelayMs > 0) {
8381
- await new Promise((resolve2) => setTimeout(resolve2, internals._testDelayMs));
8122
+ const payload = await parseJsonResponse(response);
8123
+ if (payload && typeof payload === "object") {
8124
+ const version = payload["latest-version"];
8125
+ if (typeof version === "string" && version.length > 0) {
8126
+ return { title: "Latest version", level: "ok", summary: version };
8382
8127
  }
8383
- return;
8384
8128
  }
8385
- await new Promise((resolve2) => setTimeout(resolve2, ms));
8386
- };
8387
- const repoUrl = `https://github.com/${repo}`;
8388
- let baselineId = null;
8389
- try {
8390
- const baselineChild = Bun.spawn(["gh", "run", "list", "--workflow=fro-bot.yaml", "--repo", repo, "--limit", "1", "--json", "databaseId"], { stdout: "pipe", stderr: "pipe", env: process.env });
8391
- const [baselineStdout, , baselineExit] = await Promise.all([
8392
- new Response(baselineChild.stdout).text(),
8393
- new Response(baselineChild.stderr).text(),
8394
- baselineChild.exited
8395
- ]);
8396
- if (baselineExit === 0) {
8397
- const parseResult = baselineRunSchema.safeParse(JSON.parse(baselineStdout));
8398
- if (parseResult.success && parseResult.data.length > 0 && parseResult.data[0]) {
8399
- baselineId = parseResult.data[0].databaseId;
8400
- }
8401
- }
8402
- } catch {}
8403
- const triggerTime = internals._testTriggerTime ?? new Date;
8404
- const triggerChild = Bun.spawn(["gh", "workflow", "run", "fro-bot.yaml", "--repo", repo, "-f", "prompt=reply with exactly: ack"], { stdout: "pipe", stderr: "pipe", env: process.env });
8405
- const [, triggerStderr, triggerExit] = await Promise.all([
8406
- new Response(triggerChild.stdout).text(),
8407
- new Response(triggerChild.stderr).text(),
8408
- triggerChild.exited
8409
- ]);
8410
- if (triggerExit !== 0) {
8411
- const redacted = triggerStderr.slice(0, 200);
8412
- return { kind: "unverified", message: `gh workflow run failed: ${redacted}` };
8129
+ return {
8130
+ title: "Latest version",
8131
+ level: "warning",
8132
+ summary: "Response did not include a latest-version string."
8133
+ };
8134
+ } catch (error) {
8135
+ const message = error instanceof Error ? error.message : String(error);
8136
+ return { title: "Latest version", level: "error", summary: `Unable to check latest version: ${message}` };
8413
8137
  }
8414
- let latestMatchedRun;
8415
- for (const BACKOFF_M of BACKOFF_MS) {
8416
- await delayFn(BACKOFF_M ?? 60000);
8417
- let pollRuns = [];
8418
- try {
8419
- const pollChild = Bun.spawn([
8420
- "gh",
8421
- "run",
8422
- "list",
8423
- "--workflow=fro-bot.yaml",
8424
- "--repo",
8425
- repo,
8426
- "--limit",
8427
- "5",
8428
- "--json",
8429
- "databaseId,status,conclusion,url,createdAt"
8430
- ], { stdout: "pipe", stderr: "pipe", env: process.env });
8431
- const [pollStdout, , pollExit] = await Promise.all([
8432
- new Response(pollChild.stdout).text(),
8433
- new Response(pollChild.stderr).text(),
8434
- pollChild.exited
8435
- ]);
8436
- if (pollExit === 0) {
8437
- const rawParsed = JSON.parse(pollStdout);
8438
- if (Array.isArray(rawParsed)) {
8439
- pollRuns = rawParsed.flatMap((entry) => {
8440
- const entryResult = ghRunEntrySchema.safeParse(entry);
8441
- return entryResult.success ? [entryResult.data] : [];
8442
- });
8443
- }
8444
- }
8445
- } catch {
8446
- continue;
8447
- }
8448
- const candidates = pollRuns.filter((run) => {
8449
- if (baselineId !== null) {
8450
- return run.databaseId > baselineId;
8451
- }
8452
- return new Date(run.createdAt) > triggerTime;
8138
+ }
8139
+ var DEFAULT_PROVIDER_MODEL = "claude-sonnet-4-6";
8140
+ async function runProviderAuthProbe(baseUrl, apiKey, options) {
8141
+ const model = options?.model ?? DEFAULT_PROVIDER_MODEL;
8142
+ const verbose = options?.verbose === true;
8143
+ const endpoint = `${baseUrl}/v1/chat/completions`;
8144
+ try {
8145
+ const response = await fetch(endpoint, {
8146
+ method: "POST",
8147
+ headers: {
8148
+ authorization: `Bearer ${apiKey}`,
8149
+ "content-type": "application/json"
8150
+ },
8151
+ body: JSON.stringify({
8152
+ model,
8153
+ messages: [{ role: "user", content: "ping" }],
8154
+ max_tokens: 1
8155
+ }),
8156
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
8453
8157
  });
8454
- if (candidates.length === 0) {
8455
- continue;
8158
+ const details = [];
8159
+ if (verbose) {
8160
+ details.push(`URL: ${endpoint}`);
8161
+ details.push(`Model: ${model}`);
8162
+ details.push(`Status: ${response.status}`);
8456
8163
  }
8457
- const matched = candidates.reduce((best, run) => run.databaseId > best.databaseId ? run : best);
8458
- latestMatchedRun = matched;
8459
- const { status, conclusion, url: runUrl } = matched;
8460
- if (status === "waiting") {
8461
- return { kind: "unverified", message: `Workflow requires environment approval at ${runUrl}`, runUrl };
8164
+ if (response.ok) {
8165
+ return {
8166
+ classification: { state: "healthy", reason: "ok" },
8167
+ human: {
8168
+ title: "Upstream provider auth (anthropic)",
8169
+ level: "ok",
8170
+ summary: `anthropic route OK (${model})`,
8171
+ details: details.length > 0 ? details : undefined
8172
+ }
8173
+ };
8462
8174
  }
8463
- if (status === "completed") {
8464
- if (conclusion === "success") {
8465
- let logNote = "";
8466
- try {
8467
- const logChild = Bun.spawn(["gh", "run", "view", String(matched.databaseId), "--log", "--repo", repo], {
8468
- stdout: "pipe",
8469
- stderr: "pipe",
8470
- env: process.env
8471
- });
8472
- const [logStdout, , logExit] = await Promise.all([
8473
- new Response(logChild.stdout).text(),
8474
- new Response(logChild.stderr).text(),
8475
- logChild.exited
8476
- ]);
8477
- if (logExit !== 0) {
8478
- logNote = " (log fetch failed, but run conclusion is success)";
8479
- } else if (!/\back\b/i.test(logStdout)) {
8480
- logNote = ' (log fetch succeeded but "ack" not found in output)';
8481
- }
8482
- } catch {
8483
- logNote = " (log fetch failed, but run conclusion is success)";
8175
+ if (response.status === 401) {
8176
+ return {
8177
+ classification: { state: "dead", reason: "auth-401" },
8178
+ human: {
8179
+ title: "Upstream provider auth (anthropic)",
8180
+ level: "error",
8181
+ summary: `Anthropic upstream auth unavailable (${response.status}) \u2014 run: cliproxy login claude`,
8182
+ details: details.length > 0 ? details : undefined
8484
8183
  }
8485
- return { kind: "pass", message: `Smoke test passed${logNote}`, runUrl };
8184
+ };
8185
+ }
8186
+ if (response.status === 503) {
8187
+ let bodyText = "";
8188
+ try {
8189
+ bodyText = await response.text();
8190
+ } catch {}
8191
+ const isAuthUnavailable = /auth_unavailable|no auth available|providers=claude/i.test(bodyText);
8192
+ if (isAuthUnavailable) {
8193
+ return {
8194
+ classification: { state: "dead", reason: "auth-unavailable-503" },
8195
+ human: {
8196
+ title: "Upstream provider auth (anthropic)",
8197
+ level: "error",
8198
+ summary: `Anthropic upstream auth unavailable (${response.status}) \u2014 run: cliproxy login claude`,
8199
+ details: details.length > 0 ? details : undefined
8200
+ }
8201
+ };
8486
8202
  }
8487
- return { kind: "fail", message: `Run completed with conclusion=${conclusion ?? "unknown"}`, runUrl };
8488
8203
  }
8489
- }
8490
- if (latestMatchedRun) {
8491
8204
  return {
8492
- kind: "unverified",
8493
- message: `Smoke test did not complete in 5 minutes; check ${latestMatchedRun.url}`,
8494
- runUrl: latestMatchedRun.url
8205
+ classification: { state: "unknown", reason: "unrelated-http" },
8206
+ human: {
8207
+ title: "Upstream provider auth (anthropic)",
8208
+ level: "warning",
8209
+ summary: `Anthropic probe returned HTTP ${response.status}`,
8210
+ details: details.length > 0 ? details : undefined
8211
+ }
8212
+ };
8213
+ } catch (error) {
8214
+ const message = error instanceof Error ? error.message : String(error);
8215
+ const errorName = error instanceof Error ? error.name : "";
8216
+ const isTimeout = /timeout|timed out|aborted/i.test(`${errorName} ${message}`);
8217
+ return {
8218
+ classification: { state: "unknown", reason: isTimeout ? "timeout" : "network" },
8219
+ human: {
8220
+ title: "Upstream provider auth (anthropic)",
8221
+ level: "warning",
8222
+ summary: `Anthropic probe failed: ${message}`,
8223
+ details: verbose ? [`URL: ${endpoint}`, `Model: ${model}`] : undefined
8224
+ }
8495
8225
  };
8496
8226
  }
8497
- return {
8498
- kind: "unverified",
8499
- message: `Smoke test trigger not yet visible; check ${repoUrl}/actions`
8500
- };
8501
8227
  }
8502
-
8503
- // src/commands/cliproxy/setup/templates.ts
8504
- import { z as z12 } from "zod";
8505
- var harnessSchema = z12.enum(["opencode", "claude-code", "generic"]);
8506
- var DEFAULT_CLIPROXY_URL4 = "https://cliproxy.fro.bot";
8507
- function stripTrailingSlash4(value) {
8508
- return value.endsWith("/") ? value.slice(0, -1) : value;
8228
+ async function checkProviderAuthState(baseUrl, apiKey, options) {
8229
+ const probe = await runProviderAuthProbe(baseUrl, apiKey, options);
8230
+ return probe.classification;
8509
8231
  }
8510
- function getHarnessTemplate(harness, values = {}) {
8511
- const keyValue = values.keyValue ?? "sk-placeholder";
8512
- const baseUrl = stripTrailingSlash4(values.baseUrl ?? DEFAULT_CLIPROXY_URL4);
8513
- if (harness === "opencode") {
8514
- const rawProviders = values.providers ?? ["anthropic"];
8515
- const PROVIDER_ORDER = ["anthropic", "openai"];
8516
- const providers = PROVIDER_ORDER.filter((p) => rawProviders.includes(p));
8517
- let model;
8518
- if (values.model) {
8519
- model = values.model;
8520
- } else if (providers.length === 1) {
8521
- model = PROVIDER_DEFAULTS[providers[0]];
8522
- } else {
8523
- throw new Error("model required when multiple providers selected");
8232
+ async function checkProviderAuth(baseUrl, apiKey, options) {
8233
+ const probe = await runProviderAuthProbe(baseUrl, apiKey, options);
8234
+ return probe.human;
8235
+ }
8236
+ async function probeManagementAuth(baseUrl, key) {
8237
+ const endpoint = `${baseUrl}/v0/management/config`;
8238
+ try {
8239
+ const response = await fetch(endpoint, {
8240
+ headers: managementHeaders(key),
8241
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
8242
+ });
8243
+ if (response.ok) {
8244
+ return null;
8524
8245
  }
8525
- const authObj = {};
8526
- for (const p of providers) {
8527
- authObj[p] = { type: "api", key: keyValue };
8246
+ if (response.status === 403) {
8247
+ const payload = await parseJsonResponse(response);
8248
+ const isBanBody = payload !== null && typeof payload === "object" && Object.values(payload).some((v) => typeof v === "string" && /\b(?:ip[- ]?)?bann?ed\b/i.test(v));
8249
+ if (isBanBody) {
8250
+ return {
8251
+ title: "Management access",
8252
+ level: "error",
8253
+ summary: "IP banned \u2014 stop retrying for ~30 min. Management checks skipped."
8254
+ };
8255
+ }
8256
+ return {
8257
+ title: "Management access",
8258
+ level: "error",
8259
+ summary: "Management API returned HTTP 403. Management checks skipped."
8260
+ };
8528
8261
  }
8529
- const providerConfig = {};
8530
- for (const p of providers) {
8531
- providerConfig[p] = { options: { baseURL: `${baseUrl}/v1` } };
8262
+ if (response.status === 401) {
8263
+ return {
8264
+ title: "Management access",
8265
+ level: "error",
8266
+ summary: "Management API returned HTTP 401 (invalid key). Management checks skipped."
8267
+ };
8532
8268
  }
8533
8269
  return {
8534
- secrets: [
8535
- {
8536
- name: "OPENCODE_AUTH_JSON",
8537
- value: JSON.stringify(authObj)
8538
- },
8539
- {
8540
- name: "OPENCODE_CONFIG",
8541
- value: JSON.stringify({ provider: providerConfig })
8542
- }
8543
- ],
8544
- variables: [
8545
- {
8546
- name: "FRO_BOT_MODEL",
8547
- value: model
8548
- }
8549
- ]
8270
+ title: "Management access",
8271
+ level: "warning",
8272
+ summary: `Management auth probe returned HTTP ${response.status}. Management checks skipped.`
8550
8273
  };
8551
- }
8552
- if (harness === "claude-code") {
8274
+ } catch (error) {
8275
+ const message = error instanceof Error ? error.message : String(error);
8553
8276
  return {
8554
- secrets: [
8555
- {
8556
- name: "ANTHROPIC_API_KEY",
8557
- value: keyValue
8558
- }
8559
- ],
8560
- variables: []
8277
+ title: "Management access",
8278
+ level: "error",
8279
+ summary: `Management auth probe failed: ${message}`
8561
8280
  };
8562
8281
  }
8563
- if (!values.genericSecretNames) {
8564
- throw new Error("Generic harness requires custom secret names.");
8282
+ }
8283
+ async function runManagementChecks(baseUrl, key) {
8284
+ if (!key) {
8285
+ return { kind: "no-key" };
8286
+ }
8287
+ const authFailure = await probeManagementAuth(baseUrl, key);
8288
+ if (authFailure !== null) {
8289
+ return { kind: "auth-failure", result: authFailure };
8290
+ }
8291
+ const [usage, version] = await Promise.all([checkUsageStats(baseUrl, key), checkVersion(baseUrl, key)]);
8292
+ return { kind: "checks", usage, version };
8293
+ }
8294
+ function printCheckResult(result, ctx) {
8295
+ ctx.console.log(`[${levelLabel(result.level)}] ${result.title}`);
8296
+ ctx.console.log(` ${result.summary}`);
8297
+ if (result.details && result.details.length > 0) {
8298
+ for (const detail of result.details) {
8299
+ ctx.console.log(` - ${detail}`);
8300
+ }
8301
+ }
8302
+ }
8303
+ function formatCheckSummary(result) {
8304
+ return `${levelLabel(result.level)}: ${result.summary}`;
8305
+ }
8306
+ function formatUsageSummaryLine(result) {
8307
+ const recentMatch = /recent:\s*(\d+)/.exec(result.summary);
8308
+ if (!recentMatch) {
8309
+ return null;
8310
+ }
8311
+ const total = Number(recentMatch[1]);
8312
+ const errorMatch = /errors:\s*(\d+)/.exec(result.summary);
8313
+ const errors2 = errorMatch ? Number(errorMatch[1]) : 0;
8314
+ return `Recent requests: ${total}${errors2 > 0 ? `, ${errors2} errors` : ""}`;
8315
+ }
8316
+ async function getCliproxyStatusSummary(baseUrl, key, verbose) {
8317
+ const normalizedBaseUrl = stripTrailingSlash4(baseUrl);
8318
+ const [httpResult, mgmt] = await Promise.all([
8319
+ checkHttpReachability(`${normalizedBaseUrl}/healthz`, verbose),
8320
+ runManagementChecks(normalizedBaseUrl, key || undefined)
8321
+ ]);
8322
+ let version;
8323
+ let usageStats;
8324
+ if (mgmt.kind === "no-key") {
8325
+ version = "\u2014 (no key)";
8326
+ usageStats = "\u2014 (no key)";
8327
+ } else if (mgmt.kind === "auth-failure") {
8328
+ const authSummary = formatCheckSummary(mgmt.result);
8329
+ version = authSummary;
8330
+ usageStats = authSummary;
8331
+ } else {
8332
+ version = formatCheckSummary(mgmt.version);
8333
+ usageStats = formatCheckSummary(mgmt.usage);
8565
8334
  }
8566
8335
  return {
8567
- secrets: [
8568
- { name: values.genericSecretNames.apiKeySecretName, value: keyValue },
8569
- { name: values.genericSecretNames.baseUrlSecretName, value: `${baseUrl}/v1` }
8570
- ],
8571
- variables: []
8336
+ app: "cliproxy",
8337
+ http: formatCheckSummary(httpResult),
8338
+ lastDeploy: "\u2014",
8339
+ version,
8340
+ contentHash: "\u2014",
8341
+ usageStats
8572
8342
  };
8573
8343
  }
8574
- function formatTemplateSummary(template) {
8575
- const secretLines = template.secrets.map((secret) => `- secret ${secret.name}`);
8576
- const variableLines = template.variables.map((variable) => `- variable ${variable.name}`);
8577
- return [...secretLines, ...variableLines].join(`
8578
- `);
8579
- }
8580
- function collectCollisions(template, existingSecrets, existingVariables) {
8581
- const collisions = [];
8582
- for (const secret of template.secrets) {
8583
- if (existingSecrets.includes(secret.name)) {
8584
- collisions.push(`secret ${secret.name}`);
8344
+ async function cliproxyStatusAction(options, ctx) {
8345
+ let errorCount = 0;
8346
+ try {
8347
+ const verbose = options.verbose === true;
8348
+ const baseUrl = stripTrailingSlash4(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL4);
8349
+ const trustedUrl = stripTrailingSlash4(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL4);
8350
+ const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl;
8351
+ const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY);
8352
+ const providerKey = options.apiKey ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY);
8353
+ ctx.console.log("CLIProxyAPI status");
8354
+ ctx.console.log("");
8355
+ const results = [await checkHttpReachability(`${baseUrl}/healthz`, verbose)];
8356
+ let capturedUsageResult;
8357
+ const mgmt = await runManagementChecks(baseUrl, managementKey);
8358
+ if (mgmt.kind === "no-key") {
8359
+ results.push({
8360
+ title: "Management checks",
8361
+ level: "warning",
8362
+ summary: "CLIPROXY_MANAGEMENT_KEY is not set. Skipping usage stats and version checks. Provide --key or set env var."
8363
+ });
8364
+ } else if (mgmt.kind === "auth-failure") {
8365
+ results.push(mgmt.result);
8366
+ } else {
8367
+ capturedUsageResult = mgmt.usage;
8368
+ results.push(mgmt.usage, mgmt.version);
8585
8369
  }
8586
- }
8587
- for (const variable of template.variables) {
8588
- if (existingVariables.includes(variable.name)) {
8589
- collisions.push(`variable ${variable.name}`);
8370
+ if (providerKey) {
8371
+ results.push(await checkProviderAuth(baseUrl, providerKey, { verbose }));
8372
+ } else {
8373
+ results.push({
8374
+ title: "Upstream provider auth",
8375
+ level: "warning",
8376
+ summary: "CLIPROXY_API_KEY not set \u2014 skipping upstream provider probe."
8377
+ });
8378
+ }
8379
+ for (const result of results) {
8380
+ printCheckResult(result, ctx);
8381
+ ctx.console.log("");
8382
+ }
8383
+ errorCount = results.filter((result) => result.level === "error").length;
8384
+ const warningCount = results.filter((result) => result.level === "warning").length;
8385
+ ctx.console.log(`Summary: ${results.length} checks, ${errorCount} errors, ${warningCount} warnings`);
8386
+ if (capturedUsageResult) {
8387
+ const usageLine = formatUsageSummaryLine(capturedUsageResult);
8388
+ if (usageLine) {
8389
+ ctx.console.log(usageLine);
8390
+ }
8590
8391
  }
8392
+ } catch (error) {
8393
+ const message = error instanceof Error ? error.message : String(error);
8394
+ ctx.console.error(message);
8395
+ ctx.process.exit(1);
8396
+ return;
8591
8397
  }
8592
- return collisions;
8398
+ if (errorCount > 0) {
8399
+ ctx.process.exit(1);
8400
+ }
8401
+ }
8402
+ function registerCliproxyStatus(cli) {
8403
+ cli.command("cliproxy status", "Show operational health of CLIProxyAPI and its management endpoints.").option("--url [url]", z9.string().describe("Base URL for CLIProxyAPI health checks. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.")).option("--key [key]", z9.string().describe("Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.")).option("--api-key [key]", z9.string().describe("Downstream API key (bearer) for the upstream provider-auth probe. Falls back to CLIPROXY_API_KEY. Skipped when absent.")).option("--verbose", "Enable verbose output for all commands").action(cliproxyStatusAction);
8593
8404
  }
8594
8405
 
8595
- // src/commands/cliproxy/setup/validation.ts
8596
- import { z as z13 } from "zod";
8597
- var modelEntrySchema2 = z13.object({
8598
- id: z13.string(),
8599
- owned_by: z13.string().optional()
8600
- }).passthrough();
8601
- var modelsResponseSchema2 = z13.object({
8602
- data: z13.array(modelEntrySchema2)
8603
- }).passthrough();
8604
- var MODEL_ID_RE2 = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/;
8605
- var HTTP_TIMEOUT_MS3 = 1e4;
8606
- var PROVIDER_ID_PATTERNS2 = {
8607
- openai: /^(?:gpt-|o1-|o3-|codex)/i,
8608
- anthropic: /^claude-/i
8406
+ // src/commands/cliproxy/monitor.ts
8407
+ var DEFAULT_CLIPROXY_URL5 = "https://cliproxy.fro.bot";
8408
+ var PRODUCTION_LABEL = "cliproxy-auth-monitor";
8409
+ var TEST_LABEL = "cliproxy-auth-monitor-test";
8410
+ var PRODUCTION_IDENTITY_MARKER = "<!-- cliproxy-auth-monitor:v1 -->";
8411
+ var TEST_IDENTITY_MARKER = "<!-- cliproxy-auth-monitor-test:v1 -->";
8412
+ var MAX_DISCORD_ATTEMPTS = 3;
8413
+ var MAX_GITHUB_ATTEMPTS = 3;
8414
+ var GITHUB_TIMEOUT_MS = 1e4;
8415
+ var DISCORD_TIMEOUT_MS = 1e4;
8416
+ var monitorValidationModes = ["live", "synthetic-dead", "synthetic-healthy"];
8417
+ function formatMonitorRunSummary(summary) {
8418
+ const issue = summary.issue.length > 0 ? summary.issue.join("+") : "none";
8419
+ return `CLIProxy auth monitor summary: probe=${summary.probeState} reason=${summary.probeReason} transition=${summary.transition} issue=${issue} discord=${summary.discord} at ${summary.timestamp}`;
8420
+ }
8421
+ var githubIssueSchema = z10.object({
8422
+ number: z10.number().int().nonnegative(),
8423
+ state: z10.enum(["open", "closed"]),
8424
+ title: z10.string(),
8425
+ body: z10.string().nullable(),
8426
+ labels: z10.array(z10.object({ name: z10.string().optional() })),
8427
+ pull_request: z10.unknown().optional()
8428
+ });
8429
+ var githubIssuesSchema = z10.array(githubIssueSchema);
8430
+ var defaultMonitorRuntime = {
8431
+ fetch: globalThis.fetch,
8432
+ sleep: (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)),
8433
+ ghApi: (token, path, method, body) => runGhApiOnce(token, path, method, body),
8434
+ setTimeout,
8435
+ clearTimeout
8609
8436
  };
8610
- function entryMatchesProvider2(entry, provider) {
8611
- if (entry.owned_by !== undefined && entry.owned_by.trim() !== "") {
8612
- return entry.owned_by === provider;
8613
- }
8614
- const id = entry.id ?? "";
8615
- if (id.startsWith(`${provider}/`)) {
8616
- return true;
8437
+ function safeError(ctx, category) {
8438
+ ctx.console.error(`CLIProxy auth monitor failed: ${category}`);
8439
+ ctx.process.exit(1);
8440
+ }
8441
+ function validateProxyOrigin(value) {
8442
+ try {
8443
+ const url = new URL(value);
8444
+ return url.protocol === "https:" && url.hostname === "cliproxy.fro.bot" && url.username === "" && url.password === "" && url.port === "" && url.pathname === "/" && url.search === "" && url.hash === "";
8445
+ } catch {
8446
+ return false;
8617
8447
  }
8618
- const pattern = PROVIDER_ID_PATTERNS2[provider];
8619
- return pattern === undefined ? false : pattern.test(id);
8620
8448
  }
8621
- function extractErrorMessage3(error) {
8622
- return error instanceof Error ? error.message : String(error);
8449
+ function readInputs(validation) {
8450
+ const env = process.env;
8451
+ const values = {
8452
+ apiKey: env.CLIPROXY_API_KEY,
8453
+ discordWebhook: env.CLIPROXY_AUTH_MONITOR_DISCORD_WEBHOOK,
8454
+ githubToken: env.GITHUB_TOKEN,
8455
+ repository: env.GITHUB_REPOSITORY,
8456
+ actor: env.GITHUB_ACTOR,
8457
+ owner: env.GITHUB_REPOSITORY_OWNER
8458
+ };
8459
+ if (validation !== "live" && values.actor !== values.owner)
8460
+ return { ok: false, category: "synthetic-unauthorized" };
8461
+ if (validation === "live" && !values.apiKey)
8462
+ return { ok: false, category: "missing-CLIPROXY_API_KEY" };
8463
+ if (!values.discordWebhook)
8464
+ return { ok: false, category: "missing-CLIPROXY_AUTH_MONITOR_DISCORD_WEBHOOK" };
8465
+ if (!values.githubToken)
8466
+ return { ok: false, category: "missing-GITHUB_TOKEN" };
8467
+ if (!values.repository)
8468
+ return { ok: false, category: "missing-GITHUB_REPOSITORY" };
8469
+ if (!values.actor)
8470
+ return { ok: false, category: "missing-GITHUB_ACTOR" };
8471
+ if (!values.owner)
8472
+ return { ok: false, category: "missing-GITHUB_REPOSITORY_OWNER" };
8473
+ if (!/^[\w.-]+\/[\w.-]+$/.test(values.repository))
8474
+ return { ok: false, category: "malformed-GITHUB_REPOSITORY" };
8475
+ return {
8476
+ ok: true,
8477
+ inputs: {
8478
+ apiKey: values.apiKey ?? "",
8479
+ discordWebhook: values.discordWebhook,
8480
+ githubToken: values.githubToken,
8481
+ repository: values.repository,
8482
+ actor: values.actor,
8483
+ owner: values.owner
8484
+ }
8485
+ };
8623
8486
  }
8624
- function validateSetupOptions(options, isInteractive) {
8625
- if (isInteractive) {
8626
- return;
8487
+ var GH_ENV_ALLOWLIST = ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"];
8488
+ function createGhEnvironment(token) {
8489
+ const environment = {
8490
+ GH_TOKEN: token,
8491
+ GH_PROMPT_DISABLED: "1",
8492
+ GH_NO_UPDATE_NOTIFIER: "1"
8493
+ };
8494
+ for (const name of GH_ENV_ALLOWLIST) {
8495
+ const value = process.env[name];
8496
+ if (value !== undefined)
8497
+ environment[name] = value;
8627
8498
  }
8628
- if (options.providers) {
8629
- const providers = parseProviders(options.providers);
8630
- if (providers.length > 1 && !options.model) {
8631
- throw new Error("Pass --model <provider/model-id> when selecting multiple providers.");
8632
- }
8633
- if (options.model) {
8634
- const slashIndex = options.model.indexOf("/");
8635
- const prefix = slashIndex === -1 ? options.model : options.model.slice(0, slashIndex);
8636
- if (!providers.includes(prefix)) {
8637
- throw new Error(`Model prefix ${prefix} does not match selected providers (${providers.join(", ")}). Valid prefixes: ${providers.join(", ")}/`);
8499
+ return environment;
8500
+ }
8501
+ function tryParseGhResponse(output) {
8502
+ const separator = output.indexOf(`\r
8503
+ \r
8504
+ `);
8505
+ const splitLength = separator === -1 ? 2 : 4;
8506
+ const splitAt = separator === -1 ? output.indexOf(`
8507
+
8508
+ `) : separator;
8509
+ if (splitAt < 0)
8510
+ return null;
8511
+ const headers = output.slice(0, splitAt);
8512
+ const status = /^HTTP\/\S+\s+(\d{3})/m.exec(headers)?.[1];
8513
+ if (!status)
8514
+ return null;
8515
+ return { status: Number(status), body: output.slice(splitAt + splitLength) };
8516
+ }
8517
+ function interpretGhApiResult(exitCode, stdout) {
8518
+ const parsed = tryParseGhResponse(stdout);
8519
+ if (parsed)
8520
+ return parsed;
8521
+ if (exitCode !== 0)
8522
+ throw new Error("github-transient");
8523
+ throw new Error("github-invalid-response");
8524
+ }
8525
+ async function runGhApiOnce(token, path, method, body) {
8526
+ const child = Bun.spawn(["gh", "api", path, "--include", "--method", method, ...body ? ["--input", "-"] : []], {
8527
+ stdin: body ? "pipe" : "ignore",
8528
+ stdout: "pipe",
8529
+ stderr: "pipe",
8530
+ env: createGhEnvironment(token)
8531
+ });
8532
+ if (body && child.stdin) {
8533
+ child.stdin.write(body);
8534
+ child.stdin.end();
8535
+ }
8536
+ let timedOut = false;
8537
+ let timer;
8538
+ const operation = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
8539
+ const timeout = new Promise((_, reject) => {
8540
+ timer = setTimeout(() => {
8541
+ timedOut = true;
8542
+ child.kill();
8543
+ reject(new Error("github-timeout"));
8544
+ }, GITHUB_TIMEOUT_MS);
8545
+ });
8546
+ try {
8547
+ const [stdout, _stderr, exitCode] = await Promise.race([operation, timeout]);
8548
+ return interpretGhApiResult(exitCode, stdout);
8549
+ } catch (error) {
8550
+ if (timedOut || error instanceof Error && error.message.startsWith("github-"))
8551
+ throw error;
8552
+ throw new Error("github-transient");
8553
+ } finally {
8554
+ if (timer)
8555
+ clearTimeout(timer);
8556
+ }
8557
+ }
8558
+ function createGitHubClient(token, repository, sleep, ghApi) {
8559
+ const base = `/repos/${repository}`;
8560
+ async function requestApi(path, method, body, allowClientError = false, retry = true) {
8561
+ const maxAttempts = retry ? MAX_GITHUB_ATTEMPTS : 1;
8562
+ for (let attempt = 1;attempt <= maxAttempts; attempt++) {
8563
+ try {
8564
+ const response = await ghApi(token, `${base}${path}`, method, body);
8565
+ if (response.status >= 200 && response.status < 300)
8566
+ return response;
8567
+ if (allowClientError && response.status !== 429 && response.status < 500)
8568
+ return response;
8569
+ if (response.status !== 429 && response.status < 500)
8570
+ throw new Error("github-http");
8571
+ throw new Error("github-transient");
8572
+ } catch (error) {
8573
+ const retryable = error instanceof Error && /github-(?:transient|timeout)/.test(error.message);
8574
+ if (!retryable || attempt === maxAttempts)
8575
+ throw error;
8576
+ await sleep(attempt * 100);
8638
8577
  }
8639
8578
  }
8579
+ throw new Error("github-transient");
8640
8580
  }
8641
- if (!options.dryRun && !options.key) {
8642
- throw new Error("--key is required when stdin is not a TTY. Provide an existing CLIProxyAPI key value.");
8643
- }
8644
- if (!options.repo) {
8645
- throw new Error("--repo is required when stdin is not a TTY. Provide the target GitHub repository as owner/repo.");
8646
- }
8647
- if (!options.harness) {
8648
- throw new Error("--harness is required when stdin is not a TTY. Choose opencode or claude-code.");
8649
- }
8650
- if (options.harness === "generic") {
8651
- throw new Error("--harness generic is interactive-only because it requires custom secret names.");
8652
- }
8581
+ return {
8582
+ async request(path, schema, init) {
8583
+ const response = await requestApi(path, init?.method ?? "GET", init?.body, false, init?.retry ?? true);
8584
+ try {
8585
+ return schema.parse(JSON.parse(response.body));
8586
+ } catch {
8587
+ throw new Error("github-malformed-response");
8588
+ }
8589
+ },
8590
+ async requestStatus(path, init) {
8591
+ return (await requestApi(path, init?.method ?? "GET", init?.body, true)).status;
8592
+ }
8593
+ };
8653
8594
  }
8654
- async function verifyModelsAvailable(baseUrl, key, providers, model) {
8655
- if (providers.length === 1 && providers[0] === "anthropic") {
8595
+ function issueBody(identityMarker, state, timestamp, includeState) {
8596
+ const markerPrefix = identityMarker === TEST_IDENTITY_MARKER ? "cliproxy-auth-monitor-test" : "cliproxy-auth-monitor";
8597
+ const marker = includeState ? `
8598
+ <!-- ${markerPrefix}:notified=${state} -->` : "";
8599
+ const remediation = state === "dead" ? `
8600
+ Remediation: cliproxy login claude.` : "";
8601
+ return `${identityMarker}
8602
+ CLIProxyAPI Anthropic authentication monitor.${remediation}
8603
+ Last check: ${timestamp}.
8604
+ <!-- ${markerPrefix}:last-check=${timestamp} -->${marker}`;
8605
+ }
8606
+ function issueHasLabel(issue, label) {
8607
+ return issue.labels.some((candidate) => candidate.name === label);
8608
+ }
8609
+ function notifiedState(body, identityMarker) {
8610
+ const markerPrefix = identityMarker === TEST_IDENTITY_MARKER ? "cliproxy-auth-monitor-test" : "cliproxy-auth-monitor";
8611
+ const match = body?.match(new RegExp(`<!-- ${markerPrefix}:notified=(dead|healthy) -->`));
8612
+ return match?.[1] === "dead" || match?.[1] === "healthy" ? match[1] : null;
8613
+ }
8614
+ async function resolveIssue(client, identityMarker, label) {
8615
+ const issues = [];
8616
+ let page = 1;
8617
+ while (true) {
8618
+ const path = page === 1 ? "/issues?state=all&per_page=100" : `/issues?state=all&per_page=100&page=${page}`;
8619
+ const batch = await client.request(path, githubIssuesSchema);
8620
+ issues.push(...batch);
8621
+ if (batch.length < 100)
8622
+ break;
8623
+ page++;
8624
+ }
8625
+ const trusted = issues.filter((issue) => issue.pull_request === undefined && issueHasLabel(issue, label));
8626
+ if (trusted.some((issue) => issue.body?.includes(identityMarker) !== true))
8627
+ throw new Error("trusted-label-mismatch");
8628
+ const marked = trusted.filter((issue) => issue.body?.includes(identityMarker) === true).sort((left, right) => left.number - right.number);
8629
+ return marked[0] ?? null;
8630
+ }
8631
+ async function ensureLabel(client, label) {
8632
+ const encoded = encodeURIComponent(label);
8633
+ const status = await client.requestStatus(`/labels/${encoded}`);
8634
+ if (status === 404) {
8635
+ await client.request("/labels", z10.unknown(), {
8636
+ method: "POST",
8637
+ body: JSON.stringify({ name: label, color: "b60205", description: "CLIProxy auth monitor state" })
8638
+ });
8656
8639
  return;
8657
8640
  }
8658
- const endpoint = `${baseUrl}/v1/models`;
8659
- const response = await fetch(endpoint, {
8660
- headers: { Authorization: `Bearer ${key}` },
8661
- signal: AbortSignal.timeout(1e4)
8641
+ if (status < 200 || status >= 300)
8642
+ throw new Error(`github-${status}`);
8643
+ }
8644
+ async function updateIssue(client, issue, state, body) {
8645
+ await client.request(`/issues/${issue.number}`, z10.unknown(), {
8646
+ method: "PATCH",
8647
+ body: JSON.stringify({ state, body })
8662
8648
  });
8663
- if (response.status === 401 || response.status === 403) {
8664
- throw new Error("Proxy key rejected. Verify with `cliproxy keys list` or rerun setup to create a new one.");
8649
+ }
8650
+ async function createIssue(client, label, identityMarker, body, sleep) {
8651
+ await ensureLabel(client, label);
8652
+ for (let attempt = 1;attempt <= MAX_GITHUB_ATTEMPTS; attempt++) {
8653
+ try {
8654
+ return await client.request("/issues", githubIssueSchema, {
8655
+ method: "POST",
8656
+ body: JSON.stringify({ title: "CLIProxy Anthropic authentication outage", body, labels: [label] }),
8657
+ retry: false
8658
+ });
8659
+ } catch (error) {
8660
+ const retryable = error instanceof Error && /github-(?:transient|timeout)/.test(error.message);
8661
+ if (!retryable || attempt === MAX_GITHUB_ATTEMPTS)
8662
+ throw error;
8663
+ const appeared = await resolveIssue(client, identityMarker, label);
8664
+ if (appeared)
8665
+ return appeared;
8666
+ await sleep(attempt * 100);
8667
+ }
8665
8668
  }
8666
- if (!response.ok) {
8667
- const rawBody = await response.text();
8668
- const redacted = (key.length > 0 ? rawBody.replaceAll(key, "<redacted>") : rawBody).replaceAll(/Bearer\s+[^\s"]+/g, "Bearer <redacted>").replaceAll(/sk-[\w.-]{8,}/g, "sk-<redacted>");
8669
- const excerpt = redacted.slice(0, 200);
8670
- throw new Error(`/v1/models returned HTTP ${response.status}: ${excerpt}`);
8669
+ throw new Error("github-transient");
8670
+ }
8671
+ async function sendDiscord(webhook, state, timestamp, synthetic, runtime) {
8672
+ const content = state === "dead" ? `${synthetic ? "[synthetic test] " : ""}CLIProxy Anthropic authentication is unavailable as of ${timestamp}. Run cliproxy login claude.` : `${synthetic ? "[synthetic test] " : ""}CLIProxy Anthropic authentication recovered as of ${timestamp}.`;
8673
+ for (let attempt = 1;attempt <= MAX_DISCORD_ATTEMPTS; attempt++) {
8674
+ try {
8675
+ const controller = new AbortController;
8676
+ const timeout = runtime.setTimeout(() => controller.abort(), DISCORD_TIMEOUT_MS);
8677
+ let response;
8678
+ try {
8679
+ response = await runtime.fetch(webhook, {
8680
+ method: "POST",
8681
+ headers: { "content-type": "application/json" },
8682
+ body: JSON.stringify({ content, allowed_mentions: { parse: [] } }),
8683
+ signal: controller.signal
8684
+ });
8685
+ } finally {
8686
+ runtime.clearTimeout(timeout);
8687
+ }
8688
+ if (response.ok)
8689
+ return;
8690
+ const retryable = response.status === 429 || response.status >= 500;
8691
+ if (!retryable || attempt === MAX_DISCORD_ATTEMPTS) {
8692
+ throw new Error(`discord-${response.status}`);
8693
+ }
8694
+ const retryAfter = Number(response.headers.get("retry-after") ?? "0");
8695
+ await runtime.sleep(Math.min(1000, Number.isFinite(retryAfter) ? Math.max(0, retryAfter * 1000) : 0));
8696
+ } catch (error) {
8697
+ if (attempt === MAX_DISCORD_ATTEMPTS || error instanceof Error && /^discord-\d{3}$/.test(error.message))
8698
+ throw error;
8699
+ await runtime.sleep(0);
8700
+ }
8671
8701
  }
8672
- const json = await response.json();
8673
- let parsed;
8702
+ }
8703
+ async function persistNotifiedMarker(client, issue, identityMarker, state, timestamp) {
8704
+ await updateIssue(client, issue, issue.state, issueBody(identityMarker, state, timestamp, true));
8705
+ }
8706
+ function providerState(validation) {
8707
+ return validation === "synthetic-dead" ? { state: "dead", reason: "auth-401" } : { state: "healthy", reason: "ok" };
8708
+ }
8709
+ async function cliproxyMonitorAction(options, ctx, runtime = defaultMonitorRuntime) {
8710
+ const validation = options.validation ?? "live";
8711
+ const inputResult = readInputs(validation);
8712
+ if (!inputResult.ok)
8713
+ return safeError(ctx, inputResult.category);
8714
+ const inputs = inputResult.inputs;
8715
+ const proxyUrl = process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL5;
8716
+ if (!validateProxyOrigin(proxyUrl))
8717
+ return safeError(ctx, "untrusted-proxy-origin");
8718
+ const label = validation === "live" ? PRODUCTION_LABEL : TEST_LABEL;
8719
+ const client = createGitHubClient(inputs.githubToken, inputs.repository, runtime.sleep, runtime.ghApi);
8720
+ const timestamp = new Date().toISOString();
8674
8721
  try {
8675
- parsed = modelsResponseSchema2.parse(json);
8722
+ const classification = validation === "live" ? await checkProviderAuthState(proxyUrl, inputs.apiKey) : providerState(validation);
8723
+ const finish = (transition, issue2 = [], discord = "none") => {
8724
+ const summary = {
8725
+ probeState: classification.state,
8726
+ probeReason: classification.reason,
8727
+ transition,
8728
+ issue: issue2,
8729
+ discord,
8730
+ timestamp
8731
+ };
8732
+ ctx.console.log(formatMonitorRunSummary(summary));
8733
+ return summary;
8734
+ };
8735
+ const identityMarker = validation === "live" ? PRODUCTION_IDENTITY_MARKER : TEST_IDENTITY_MARKER;
8736
+ const issue = await resolveIssue(client, identityMarker, label);
8737
+ if (classification.state === "unknown")
8738
+ return finish("none");
8739
+ const target = classification.state === "dead" ? "dead" : "healthy";
8740
+ if (!issue && target === "healthy")
8741
+ return finish("none");
8742
+ const issueActions = [];
8743
+ if (!issue) {
8744
+ const created = await createIssue(client, label, identityMarker, issueBody(identityMarker, "dead", timestamp, false), runtime.sleep);
8745
+ await sendDiscord(inputs.discordWebhook, "dead", timestamp, validation !== "live", runtime);
8746
+ await persistNotifiedMarker(client, created, identityMarker, "dead", timestamp);
8747
+ return finish("outage", ["created"], "outage-sent");
8748
+ }
8749
+ if (target === "dead") {
8750
+ const currentMarker2 = notifiedState(issue.body, identityMarker);
8751
+ const wasOpen2 = issue.state === "open";
8752
+ const body = issueBody(identityMarker, "dead", timestamp, currentMarker2 === "dead");
8753
+ await updateIssue(client, issue, "open", body);
8754
+ issue.state = "open";
8755
+ issueActions.push(wasOpen2 ? "updated" : "reopened");
8756
+ if (currentMarker2 === "dead" && wasOpen2)
8757
+ return finish("none", issueActions);
8758
+ await sendDiscord(inputs.discordWebhook, "dead", timestamp, validation !== "live", runtime);
8759
+ await persistNotifiedMarker(client, issue, identityMarker, "dead", timestamp);
8760
+ return finish(wasOpen2 ? "notification-retry" : "outage", issueActions, "outage-sent");
8761
+ }
8762
+ const currentMarker = notifiedState(issue.body, identityMarker);
8763
+ const wasOpen = issue.state === "open";
8764
+ if (issue.state === "open") {
8765
+ await updateIssue(client, issue, "closed", issueBody(identityMarker, currentMarker === "dead" ? "dead" : "healthy", timestamp, currentMarker !== null));
8766
+ issue.state = "closed";
8767
+ issueActions.push("closed");
8768
+ } else if (currentMarker === "healthy" || currentMarker === null) {
8769
+ return finish("none", issueActions);
8770
+ }
8771
+ if (currentMarker === "dead") {
8772
+ await sendDiscord(inputs.discordWebhook, "healthy", timestamp, validation !== "live", runtime);
8773
+ await persistNotifiedMarker(client, issue, identityMarker, "healthy", timestamp);
8774
+ return finish(wasOpen ? "recovery" : "notification-retry", issueActions, "recovery-sent");
8775
+ }
8776
+ await persistNotifiedMarker(client, issue, identityMarker, "healthy", timestamp);
8777
+ return finish("repair", issueActions);
8676
8778
  } catch (error) {
8677
- const message = error instanceof z13.ZodError ? error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ") : extractErrorMessage3(error);
8678
- throw new Error(`Malformed /v1/models response: ${message}`);
8779
+ const category = error instanceof Error && /trusted-label-mismatch/.test(error.message) ? "trusted-label-mismatch" : error instanceof Error && /ambiguous-identity/.test(error.message) ? "ambiguous-state" : "reconciliation";
8780
+ safeError(ctx, category);
8679
8781
  }
8680
- const entries = parsed.data;
8681
- if (providers.includes("openai")) {
8682
- const hasOpenAi = entries.some((e) => entryMatchesProvider2(e, "openai"));
8683
- if (!hasOpenAi) {
8684
- throw new Error("No OpenAI models on proxy \u2014 is the Codex token loaded? Try `cliproxy login codex`.");
8685
- }
8686
- }
8687
- const slashIndex = model.indexOf("/");
8688
- const bareId = slashIndex === -1 ? model : model.slice(slashIndex + 1);
8689
- const providerPrefix = slashIndex >= 0 ? model.slice(0, slashIndex) : undefined;
8690
- const modelPresent = entries.some((e) => e.id === bareId || e.id === model);
8691
- if (!modelPresent) {
8692
- const matchingIds = providerPrefix ? entries.filter((e) => entryMatchesProvider2(e, providerPrefix)).map((e) => e.id) : entries.map((e) => e.id);
8693
- const available = matchingIds.length > 0 ? matchingIds.join(", ") : "(none)";
8694
- throw new Error(`Model "${bareId}" not found on proxy. Available ${providerPrefix ?? "models"}: ${available}`);
8782
+ }
8783
+ function registerCliproxyMonitor(cli) {
8784
+ cli.command("cliproxy monitor", "Reconcile Anthropic provider authentication with one canonical GitHub issue and transition-only Discord alerts. Requires CLIPROXY_API_KEY, CLIPROXY_AUTH_MONITOR_DISCORD_WEBHOOK, GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_ACTOR, and GITHUB_REPOSITORY_OWNER environment variables; prefer the cliproxy-auth-monitor.yaml GitHub Actions entrypoint for scheduled or synthetic validation.").option("--validation [mode]", z10.enum(monitorValidationModes).default("live").describe("Validation mode (live, synthetic-dead, or synthetic-healthy): live probes Anthropic; synthetic modes are owner-only isolated test transitions.")).example("# Run the live provider-auth monitor").example("infra cliproxy monitor").example("# Exercise the isolated outage transition as the repository owner").example("infra cliproxy monitor --validation synthetic-dead").action(cliproxyMonitorAction);
8785
+ }
8786
+
8787
+ // src/commands/cliproxy/open.ts
8788
+ import { z as z11 } from "zod";
8789
+ var DEFAULT_HOST2 = "cliproxy.fro.bot";
8790
+ var DEFAULT_REMOTE_USER2 = "root";
8791
+ function resolveHost2(input) {
8792
+ const host = input ?? process.env.CLIPROXY_DOMAIN ?? DEFAULT_HOST2;
8793
+ if (!host) {
8794
+ throw new Error("CLIPROXY_DOMAIN is required. Pass --host or set CLIPROXY_DOMAIN.");
8695
8795
  }
8796
+ return host;
8696
8797
  }
8697
- async function assertProxyReachable(baseUrl) {
8698
- try {
8699
- const response = await fetch(`${baseUrl}/healthz`, {
8700
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
8701
- });
8702
- if (!response.ok) {
8703
- throw new Error(`Proxy check failed for ${baseUrl}: HTTP ${response.status}. Is the proxy running and reachable?`);
8798
+ function registerCliproxyOpen(cli) {
8799
+ cli.command("cliproxy open", "Launch CLIProxyAPI TUI dashboard via SSH.").option("--host [host]", z11.string().describe("SSH host for CLIProxyAPI server. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.")).example("# Open the TUI dashboard on the remote CLIProxyAPI instance").example("infra cliproxy open").example("# Open on a custom host").example("infra cliproxy open --host custom.example.com").action(async (options) => {
8800
+ const host = validateCliproxyHost(resolveHost2(options.host));
8801
+ if (!process.stdin.isTTY) {
8802
+ throw new Error("cliproxy open requires an interactive terminal. Run from a shell with TTY attached.");
8704
8803
  }
8705
- } catch (error) {
8706
- if (error instanceof Error && error.message.startsWith("Proxy check failed")) {
8707
- throw error;
8804
+ const path = process.env.PATH;
8805
+ const home = process.env.HOME;
8806
+ if (!path) {
8807
+ throw new Error("PATH is required to invoke ssh");
8708
8808
  }
8709
- throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage3(error)}`);
8710
- }
8711
- }
8712
- async function assertProxyKeyWorks(baseUrl, keyValue) {
8713
- try {
8714
- const response = await fetch(`${baseUrl}/v1/models`, {
8715
- headers: {
8716
- authorization: `Bearer ${keyValue}`
8809
+ if (!home) {
8810
+ throw new Error("HOME is required to invoke ssh");
8811
+ }
8812
+ const remoteCommand = "cd /opt/cliproxy && docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --tui";
8813
+ const child = Bun.spawn([
8814
+ "ssh",
8815
+ "-tt",
8816
+ "-o",
8817
+ "BatchMode=yes",
8818
+ "-o",
8819
+ "ConnectTimeout=10",
8820
+ `${DEFAULT_REMOTE_USER2}@${host}`,
8821
+ remoteCommand
8822
+ ], {
8823
+ env: {
8824
+ PATH: path,
8825
+ HOME: home
8717
8826
  },
8718
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
8827
+ stdin: "inherit",
8828
+ stdout: "inherit",
8829
+ stderr: "inherit"
8719
8830
  });
8720
- if (!response.ok) {
8721
- throw new Error(`Proxy key verification failed with HTTP ${response.status}`);
8722
- }
8723
- } catch (error) {
8724
- if (error instanceof Error && error.message.startsWith("Proxy key verification")) {
8725
- throw error;
8831
+ const exitCode = await child.exited;
8832
+ if (exitCode !== 0) {
8833
+ throw new Error(`Remote TUI command failed with exit code ${exitCode}`);
8726
8834
  }
8727
- throw new Error(`Unable to verify proxy key at ${baseUrl}: ${extractErrorMessage3(error)}`);
8728
- }
8835
+ });
8729
8836
  }
8730
8837
 
8731
- // src/commands/cliproxy/setup/workflow-analyzer.ts
8732
- var REQUIRED_OPENCODE_INPUTS = ["auth-json", "opencode-config", "model"];
8733
- function findFroBotAgentStepBodies(content) {
8734
- const bodies = [];
8735
- const pattern = /^(\s*(?:-\s+)?)uses:\s*fro-bot\/agent@/gm;
8736
- for (const match of content.matchAll(pattern)) {
8737
- if (match.index === undefined || match[1] === undefined)
8738
- continue;
8739
- const stepBodyIndent = match[1].length;
8740
- const dashIndent = Math.max(0, stepBodyIndent - 2);
8741
- const lines = content.slice(match.index).split(`
8742
- `);
8743
- const stepLines = [lines[0] ?? ""];
8744
- for (let index = 1;index < lines.length; index += 1) {
8745
- const line = lines[index] ?? "";
8746
- if (!line.trim()) {
8747
- stepLines.push(line);
8748
- continue;
8749
- }
8750
- const firstNonSpace = line.search(/\S/);
8751
- if (firstNonSpace === dashIndent && line.trimStart().startsWith("-"))
8752
- break;
8753
- if (firstNonSpace < dashIndent)
8754
- break;
8755
- stepLines.push(line);
8756
- }
8757
- bodies.push({ stepOrdinal: bodies.length + 1, body: stepLines.join(`
8758
- `) });
8838
+ // src/commands/cliproxy/setup.ts
8839
+ import { cancel as cancel2, confirm as confirm2, intro, log as log2, note, outro, select as select2, text as text3 } from "@clack/prompts";
8840
+ import { z as z16 } from "zod";
8841
+
8842
+ // src/commands/cliproxy/setup/gh.ts
8843
+ import { confirm, log, spinner } from "@clack/prompts";
8844
+
8845
+ // src/commands/cliproxy/setup/prompts.ts
8846
+ import * as clack from "@clack/prompts";
8847
+ function ensureRepoFormat(value) {
8848
+ const trimmed = value.trim();
8849
+ if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
8850
+ throw new Error("Repository must be in owner/repo format.");
8759
8851
  }
8760
- return bodies;
8852
+ return trimmed;
8761
8853
  }
8762
- function analyzeFroBotWorkflow(workflowContent) {
8763
- const steps = findFroBotAgentStepBodies(workflowContent);
8764
- if (steps.length === 0) {
8765
- return { kind: "no-agent-step" };
8854
+ function ensureSecretName(value, label) {
8855
+ const trimmed = value.trim();
8856
+ if (!/^[A-Z][A-Z0-9_]*$/.test(trimmed)) {
8857
+ throw new Error(`${label} must be SCREAMING_SNAKE_CASE.`);
8766
8858
  }
8767
- const stepsWithGaps = steps.map((step) => ({
8768
- stepOrdinal: step.stepOrdinal,
8769
- missingInputs: REQUIRED_OPENCODE_INPUTS.filter((input) => {
8770
- const inputPattern = new RegExp(String.raw`^\s+${input}:`, "m");
8771
- return !inputPattern.test(step.body);
8772
- })
8773
- })).filter((step) => step.missingInputs.length > 0);
8774
- return { kind: "analyzed", stepsWithGaps };
8859
+ return trimmed;
8775
8860
  }
8776
- function interpretGhContentResult(result) {
8777
- if (result.exitCode === 0) {
8778
- return analyzeFroBotWorkflow(result.stdout);
8779
- }
8780
- if (/HTTP 404/.test(result.stderr)) {
8781
- return { kind: "missing" };
8861
+ function cancelAndExit(message = "Setup cancelled.") {
8862
+ clack.cancel(message);
8863
+ process.exit(0);
8864
+ }
8865
+ async function promptValue(promise, cancelMessage) {
8866
+ const value = await promise;
8867
+ if (clack.isCancel(value)) {
8868
+ cancelAndExit(cancelMessage);
8782
8869
  }
8783
- return {
8784
- kind: "unreachable",
8785
- reason: result.stderr.trim() || `gh api exited with code ${result.exitCode}`
8786
- };
8870
+ return value;
8787
8871
  }
8788
- async function checkFroBotWorkflow(repo) {
8789
- const result = await runGh([
8790
- "api",
8791
- "--header",
8792
- "Accept: application/vnd.github.raw",
8793
- `/repos/${repo}/contents/.github/workflows/fro-bot.yaml`
8794
- ]);
8795
- return interpretGhContentResult(result);
8872
+ function buildApiKeyValue(keyName) {
8873
+ const slug = (keyName.trim().toLowerCase().match(/[a-z0-9]+/g) ?? []).join("-").slice(0, 24);
8874
+ const random = crypto.randomUUID().split("-").join("");
8875
+ return `sk-${slug || "cliproxy"}-${random}`;
8796
8876
  }
8797
- function formatWorkflowSnippet(missingInputs) {
8798
- const inputMap = {
8799
- "auth-json": "auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}",
8800
- "opencode-config": "opencode-config: ${{ secrets.OPENCODE_CONFIG }}",
8801
- model: "model: ${{ vars.FRO_BOT_MODEL }}"
8802
- };
8803
- return missingInputs.map((input) => ` ${inputMap[input]}`).join(`
8804
- `);
8877
+ function extractErrorMessage(error) {
8878
+ return error instanceof Error ? error.message : String(error);
8879
+ }
8880
+ async function promptGenericSecretNames() {
8881
+ const apiKeySecretName = ensureSecretName(await promptValue(clack.text({
8882
+ message: "Name for the API key secret",
8883
+ placeholder: "CLIPROXY_API_KEY",
8884
+ validate: (value) => {
8885
+ try {
8886
+ ensureSecretName(value ?? "", "API key secret name");
8887
+ return;
8888
+ } catch (error) {
8889
+ return extractErrorMessage(error);
8890
+ }
8891
+ }
8892
+ }), "Setup cancelled before choosing the generic API key secret name."), "API key secret name");
8893
+ const baseUrlSecretName = ensureSecretName(await promptValue(clack.text({
8894
+ message: "Name for the proxy base URL secret",
8895
+ placeholder: "CLIPROXY_BASE_URL",
8896
+ validate: (value) => {
8897
+ try {
8898
+ ensureSecretName(value ?? "", "Base URL secret name");
8899
+ return;
8900
+ } catch (error) {
8901
+ return extractErrorMessage(error);
8902
+ }
8903
+ }
8904
+ }), "Setup cancelled before choosing the generic base URL secret name."), "Base URL secret name");
8905
+ return { apiKeySecretName, baseUrlSecretName };
8805
8906
  }
8806
8907
 
8807
- // src/commands/cliproxy/setup.ts
8808
- var DEFAULT_CLIPROXY_URL5 = "https://cliproxy.fro.bot";
8809
- function resolveBaseUrl3(input) {
8810
- return stripTrailingSlash4(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL5);
8908
+ // src/commands/cliproxy/setup/gh.ts
8909
+ function extractErrorMessage2(error) {
8910
+ return error instanceof Error ? error.message : String(error);
8811
8911
  }
8812
- function safeWarn(message) {
8912
+ async function withSpinner(message, run) {
8913
+ const spinnerInstance = spinner();
8914
+ spinnerInstance.start(message);
8813
8915
  try {
8814
- log2.warn(message);
8815
- } catch {}
8916
+ const result = await run(spinnerInstance);
8917
+ spinnerInstance.stop(message);
8918
+ return result;
8919
+ } catch (error) {
8920
+ spinnerInstance.error(`${message} failed`);
8921
+ throw error;
8922
+ }
8816
8923
  }
8817
- function buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames) {
8818
- const secretPart = writtenSecretNames.length > 0 ? `gh secret list --repo ${repo} (expect: ${writtenSecretNames.join(", ")})` : `gh secret list --repo ${repo}`;
8819
- const variablePart = writtenVariableNames.length > 0 ? `gh variable list --repo ${repo} (expect: ${writtenVariableNames.join(", ")})` : `gh variable list --repo ${repo}`;
8820
- return `Post-write readback: could not verify the written names are visible in ${repo} ` + `(the GitHub list call failed). Verify manually: ${secretPart}; ${variablePart}.`;
8924
+ async function runCommand(command, args) {
8925
+ const child = Bun.spawn([command, ...args], {
8926
+ stdout: "pipe",
8927
+ stderr: "pipe",
8928
+ env: process.env
8929
+ });
8930
+ const [stdout, stderr, exitCode] = await Promise.all([
8931
+ new Response(child.stdout).text(),
8932
+ new Response(child.stderr).text(),
8933
+ child.exited
8934
+ ]);
8935
+ return { stdout, stderr, exitCode };
8821
8936
  }
8822
- async function verifyWrittenNamesVisible(repo, writtenSecretNames, writtenVariableNames, listExistingGhNames2) {
8823
- try {
8824
- let secretReadback;
8825
- let variableReadback;
8826
- let readbackFailed = false;
8827
- try {
8828
- secretReadback = await listExistingGhNames2(repo, "secret");
8829
- } catch {
8830
- readbackFailed = true;
8831
- secretReadback = [];
8832
- }
8833
- if (readbackFailed) {
8834
- variableReadback = [];
8835
- } else {
8836
- try {
8837
- variableReadback = await listExistingGhNames2(repo, "variable");
8838
- } catch {
8839
- readbackFailed = true;
8840
- variableReadback = [];
8841
- }
8842
- }
8843
- if (readbackFailed) {
8844
- safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames));
8845
- return;
8846
- }
8847
- const absentSecrets = writtenSecretNames.filter((name) => !secretReadback.includes(name));
8848
- const absentVariables = writtenVariableNames.filter((name) => !variableReadback.includes(name));
8849
- if (absentSecrets.length === 0 && absentVariables.length === 0) {
8850
- return;
8851
- }
8852
- const lines = [
8853
- `Post-write readback: the following written names are not visible in ${repo} \u2014 ` + `the token's list view may be unreliable and the pre-write safety gates may have been bypassed.`
8854
- ];
8855
- if (absentSecrets.length > 0) {
8856
- lines.push(` Absent secrets: ${absentSecrets.join(", ")}`);
8857
- lines.push(` Verify manually: gh secret list --repo ${repo}`);
8858
- }
8859
- if (absentVariables.length > 0) {
8860
- lines.push(` Absent variables: ${absentVariables.join(", ")}`);
8861
- lines.push(` Verify manually: gh variable list --repo ${repo}`);
8862
- }
8863
- safeWarn(lines.join(`
8864
- `));
8865
- } catch {
8866
- safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames));
8867
- }
8937
+ async function runGh(args) {
8938
+ return runCommand("gh", args);
8868
8939
  }
8869
- function extractErrorMessage4(error) {
8870
- return error instanceof Error ? error.message : String(error);
8940
+ function isGhRateLimitError(text2) {
8941
+ return /rate limit/i.test(text2);
8871
8942
  }
8872
- function redactKey(key) {
8873
- if (key.length < 12)
8874
- return "sk-***";
8875
- return `${key.slice(0, 3)}***${key.slice(-4)}`;
8943
+ async function queryRateLimitReset() {
8944
+ try {
8945
+ const result = await runGh(["api", "rate_limit"]);
8946
+ if (result.exitCode === 0) {
8947
+ const parsed = JSON.parse(result.stdout);
8948
+ const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset;
8949
+ if (reset) {
8950
+ return new Date(reset * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
8951
+ }
8952
+ }
8953
+ } catch {}
8954
+ return "an unknown time";
8876
8955
  }
8877
- async function buildInteractivePlan(options, baseUrl, promptsImpl) {
8878
- const createKey = !options.key;
8879
- const keyName = createKey ? await promptsImpl.promptValue(text3({
8880
- message: "Name this new CLIProxyAPI key",
8881
- placeholder: "my-repo-ci",
8882
- validate: (value) => (value ?? "").trim().length > 0 ? undefined : "Key name is required."
8883
- }), "Setup cancelled before naming the key.") : undefined;
8884
- const harness = options.harness ?? await promptsImpl.promptValue(select2({
8885
- message: "Choose the harness to configure",
8886
- options: [
8887
- { value: "opencode", label: "OpenCode" },
8888
- { value: "claude-code", label: "Claude Code" },
8889
- { value: "generic", label: "Generic" }
8890
- ]
8891
- }), "Setup cancelled before selecting a harness.");
8892
- const repo = options.repo ? ensureRepoFormat(options.repo) : ensureRepoFormat(await promptsImpl.promptValue(text3({
8893
- message: "Target GitHub repository",
8894
- placeholder: "owner/repo",
8895
- validate: (value) => {
8896
- try {
8897
- ensureRepoFormat(value ?? "");
8898
- return;
8899
- } catch (error) {
8900
- return extractErrorMessage4(error);
8956
+ async function withGhRetry(label, fn, interactive, queryReset = queryRateLimitReset) {
8957
+ for (;; ) {
8958
+ try {
8959
+ return await withSpinner(label, fn);
8960
+ } catch (error) {
8961
+ const message = extractErrorMessage2(error);
8962
+ if (!isGhRateLimitError(message)) {
8963
+ throw error;
8964
+ }
8965
+ const reset = await queryReset();
8966
+ if (!interactive) {
8967
+ throw new Error(`${message} \u2014 GitHub API rate limit resets at ${reset}. Re-run when ready.`);
8968
+ }
8969
+ log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`);
8970
+ const retry = await promptValue(confirm({
8971
+ message: "Retry this step when ready?",
8972
+ active: "retry",
8973
+ inactive: "abort",
8974
+ initialValue: true
8975
+ }), "Setup aborted after rate limit.");
8976
+ if (!retry) {
8977
+ cancelAndExit("Setup aborted after GitHub API rate limit.");
8901
8978
  }
8902
8979
  }
8903
- }), "Setup cancelled before choosing a repository."));
8904
- let providers;
8905
- let model;
8906
- if (harness === "opencode") {
8907
- const doPromptForProviders = promptsImpl.promptForProviders ?? promptForProviders;
8908
- const doPromptForModel = promptsImpl.promptForModel ?? promptForModel;
8909
- providers = await doPromptForProviders();
8910
- model = await doPromptForModel(providers);
8911
8980
  }
8912
- const keyValue = options.key ?? buildApiKeyValue(keyName ?? "cliproxy");
8913
- const genericSecretNames = harness === "generic" ? await promptGenericSecretNames() : undefined;
8914
- return {
8915
- repo,
8916
- harness,
8917
- keyValue,
8918
- keyName,
8919
- createKey,
8920
- template: getHarnessTemplate(harness, { keyValue, baseUrl, genericSecretNames, providers, model })
8921
- };
8922
8981
  }
8923
- function requiresDestructiveProviderChangeConfirmation(providers) {
8924
- return !(providers.length === 1 && providers[0] === "anthropic");
8982
+ async function assertGhInstalled() {
8983
+ if (!Bun.which("gh")) {
8984
+ throw new Error("GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/");
8985
+ }
8925
8986
  }
8926
- async function buildNonInteractivePlan(options, baseUrl, deps) {
8927
- const harness = harnessSchema.parse(options.harness);
8928
- const repo = ensureRepoFormat(options.repo ?? "");
8929
- const keyValue = options.key ?? "";
8930
- const providers = options.providers ? parseProviders(options.providers) : ["anthropic"];
8931
- let model;
8932
- if (options.model) {
8933
- model = options.model;
8934
- } else if (providers.length === 1) {
8935
- model = PROVIDER_DEFAULTS[providers[0]];
8936
- } else {
8937
- throw new Error("Pass --model <provider/model-id> when selecting multiple providers.");
8987
+ async function assertGhAuthenticated() {
8988
+ const result = await runGh(["auth", "status"]);
8989
+ if (result.exitCode !== 0) {
8990
+ throw new Error(`GitHub CLI is not authenticated. Run "gh auth login" first. ${result.stderr.trim()}`.trim());
8938
8991
  }
8939
- if (options.dryRun) {
8940
- return {
8941
- repo,
8942
- harness,
8943
- keyValue,
8944
- createKey: false,
8945
- template: getHarnessTemplate(harness, { keyValue: keyValue || "sk-placeholder", baseUrl, providers, model })
8946
- };
8992
+ }
8993
+ async function assertRepoAccess(repo) {
8994
+ const { z: z12 } = await import("zod");
8995
+ const ghRepoViewSchema = z12.object({
8996
+ nameWithOwner: z12.string(),
8997
+ viewerPermission: z12.string()
8998
+ });
8999
+ const result = await runGh(["repo", "view", repo, "--json", "nameWithOwner,viewerPermission"]);
9000
+ if (result.exitCode !== 0) {
9001
+ throw new Error(`Unable to access ${repo}. ${result.stderr.trim()}`.trim());
8947
9002
  }
8948
- const verifyModels = deps?.validation?.verifyModelsAvailable ?? verifyModelsAvailable;
8949
- if (requiresDestructiveProviderChangeConfirmation(providers) && !options.force) {
8950
- throw new Error(`Refusing destructive provider change on ${options.repo ?? ""} without --force. Selected providers ${providers.join(", ")} would overwrite existing GitHub secret values (OPENCODE_AUTH_JSON, OPENCODE_CONFIG, FRO_BOT_MODEL). Note: --force authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`);
9003
+ const parsed = ghRepoViewSchema.parse(JSON.parse(result.stdout));
9004
+ const writePermissions = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
9005
+ if (!writePermissions.has(parsed.viewerPermission)) {
9006
+ throw new Error(`GitHub CLI does not have write access to ${parsed.nameWithOwner}. Current permission: ${parsed.viewerPermission}.`);
8951
9007
  }
8952
- await verifyModels(baseUrl, keyValue, providers, model);
8953
- return {
8954
- repo,
8955
- harness,
8956
- keyValue,
8957
- createKey: false,
8958
- template: getHarnessTemplate(harness, { keyValue, baseUrl, providers, model })
8959
- };
8960
9008
  }
8961
- var realGh = {
8962
- assertGhInstalled,
8963
- assertGhAuthenticated,
8964
- assertRepoAccess,
8965
- listExistingGhNames,
8966
- createManagementApiKey,
8967
- deleteManagementApiKey,
8968
- applyGhValue,
8969
- withGhRetry
8970
- };
8971
- var realPrompts = {
8972
- promptValue,
8973
- confirm: confirm2,
8974
- intro,
8975
- note,
8976
- outro,
8977
- promptForProviders,
8978
- promptForModel
8979
- };
8980
- var realSmoke = {
8981
- runSmokeTest
8982
- };
8983
- var realValidation = {
8984
- assertProxyReachable,
8985
- assertProxyKeyWorks,
8986
- verifyModelsAvailable
8987
- };
8988
- var realCtx = {
8989
- console: {
8990
- log: (...args) => {
8991
- console.log(...args);
8992
- },
8993
- error: (...args) => {
8994
- console.error(...args);
8995
- }
8996
- },
8997
- process: {
8998
- stdout: { write: (chunk) => process.stdout.write(chunk) },
8999
- stderr: { write: (chunk) => process.stderr.write(chunk) },
9000
- exit: (code) => process.exit(code)
9009
+ async function listExistingGhNames(repo, kind) {
9010
+ const { z: z12 } = await import("zod");
9011
+ const ghNameListSchema = z12.array(z12.object({ name: z12.string() }));
9012
+ const result = await runGh([kind, "list", "--repo", repo, "--json", "name"]);
9013
+ if (result.exitCode !== 0) {
9014
+ throw new Error(`Unable to list existing GitHub ${kind}s for ${repo}. ${result.stderr.trim()}`.trim());
9001
9015
  }
9002
- };
9003
- async function runSetupCommand(options, deps = {}) {
9004
- const interactive = deps.interactive ?? Boolean(process.stdin.isTTY);
9005
- const baseUrl = deps.baseUrl ?? resolveBaseUrl3();
9006
- const ctxInjected = deps.ctx !== undefined;
9007
- const ctx = deps.ctx ?? realCtx;
9008
- const gh = deps.gh ?? realGh;
9009
- const prompts = deps.prompts ?? realPrompts;
9010
- const smoke = deps.smoke ?? realSmoke;
9011
- const validation = deps.validation ?? realValidation;
9012
- const mgmtKeyResolver = deps.resolveManagementKey ?? resolveManagementKey;
9013
- if (options.dryRun) {
9014
- const providers = options.providers ? parseProviders(options.providers) : ["anthropic"];
9015
- const model = options.model ?? PROVIDER_DEFAULTS[providers[0]];
9016
- const harness = options.harness ?? "opencode";
9017
- const repo = options.repo ?? "<repo not specified>";
9018
- const keyValue = options.key ?? "sk-placeholder";
9019
- ctx.console.log(formatDryRunPreview({
9020
- repo,
9021
- harness,
9022
- providers,
9023
- model,
9024
- template: getHarnessTemplate(harness, { keyValue, baseUrl, providers, model })
9025
- }));
9016
+ return ghNameListSchema.parse(JSON.parse(result.stdout)).map((entry) => entry.name);
9017
+ }
9018
+ async function createManagementApiKey(baseUrl, managementKey, keyValue) {
9019
+ const endpoint = `${baseUrl}/v0/management/api-keys`;
9020
+ const currentPayload = await requestJson(endpoint, {
9021
+ method: "GET",
9022
+ headers: managementHeaders(managementKey)
9023
+ });
9024
+ const currentKeys = parseManagementKeyList(currentPayload);
9025
+ if (currentKeys.includes(keyValue)) {
9026
9026
  return;
9027
9027
  }
9028
- validateSetupOptions(options, interactive);
9029
- if (interactive) {
9030
- prompts.intro("CLIProxyAPI setup wizard");
9028
+ await requestJson(endpoint, {
9029
+ method: "PUT",
9030
+ headers: managementHeaders(managementKey),
9031
+ body: JSON.stringify([...currentKeys, keyValue])
9032
+ });
9033
+ }
9034
+ async function deleteManagementApiKey(baseUrl, managementKey, keyValue) {
9035
+ const endpoint = `${baseUrl}/v0/management/api-keys`;
9036
+ const currentPayload = await requestJson(endpoint, {
9037
+ method: "GET",
9038
+ headers: managementHeaders(managementKey)
9039
+ });
9040
+ const currentKeys = parseManagementKeyList(currentPayload);
9041
+ const filtered = currentKeys.filter((k) => k !== keyValue);
9042
+ if (filtered.length === currentKeys.length) {
9043
+ return;
9031
9044
  }
9032
- try {
9033
- await withSpinner("Checking GitHub CLI availability", async () => {
9034
- await gh.assertGhInstalled();
9035
- await gh.assertGhAuthenticated();
9036
- });
9037
- await withSpinner("Checking CLIProxyAPI reachability", async () => {
9038
- await validation.assertProxyReachable(baseUrl);
9045
+ await requestJson(endpoint, {
9046
+ method: "PUT",
9047
+ headers: managementHeaders(managementKey),
9048
+ body: JSON.stringify(filtered)
9049
+ });
9050
+ }
9051
+ async function applyGhValue(kind, name, repo, value) {
9052
+ if (kind === "secret") {
9053
+ const child = Bun.spawn(["gh", "secret", "set", name, "--repo", repo], {
9054
+ stdin: new Blob([value]).stream(),
9055
+ stdout: "pipe",
9056
+ stderr: "pipe",
9057
+ env: process.env
9039
9058
  });
9040
- const plan = interactive ? await buildInteractivePlan(options, baseUrl, prompts) : await buildNonInteractivePlan(options, baseUrl, { validation });
9041
- if (plan.createKey) {
9042
- mgmtKeyResolver();
9059
+ const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
9060
+ if (exitCode !== 0) {
9061
+ throw new Error(`gh secret set ${name} failed: ${stderr.trim()}`.trim());
9043
9062
  }
9044
- await gh.withGhRetry(`Checking GitHub access for ${plan.repo}`, async () => {
9045
- await gh.assertRepoAccess(plan.repo);
9046
- }, interactive);
9047
- if (options.key) {
9048
- log2.info("Using the provided API key value directly. No new CLIProxyAPI key will be created.");
9063
+ return;
9064
+ }
9065
+ const result = await runGh([kind, "set", name, "--repo", repo, "--body", value]);
9066
+ if (result.exitCode !== 0) {
9067
+ throw new Error(`gh ${kind} set ${name} failed: ${result.stderr.trim()}`.trim());
9068
+ }
9069
+ }
9070
+
9071
+ // src/commands/cliproxy/setup/preview.ts
9072
+ function formatDryRunPreview(opts) {
9073
+ const { repo, harness, providers, model, template } = opts;
9074
+ const lines = [
9075
+ `Dry run: cliproxy setup --harness ${harness}`,
9076
+ `Repository: ${repo}`,
9077
+ `Providers: ${providers.join(", ")}`,
9078
+ `Model: ${model}`,
9079
+ "Planned secrets:"
9080
+ ];
9081
+ for (const secret of template.secrets) {
9082
+ const size = new TextEncoder().encode(secret.value).byteLength;
9083
+ lines.push(` - ${secret.name} (${size} bytes)`);
9084
+ }
9085
+ lines.push("Planned variables:");
9086
+ for (const variable of template.variables) {
9087
+ lines.push(` - ${variable.name} = ${variable.value}`);
9088
+ }
9089
+ lines.push("Proxy key (redacted): <proxy-key>");
9090
+ lines.push("No mutations will be performed.");
9091
+ return lines.join(`
9092
+ `);
9093
+ }
9094
+
9095
+ // src/commands/cliproxy/setup/providers.ts
9096
+ import { isCancel as isCancel2, multiselect, select, text as text2 } from "@clack/prompts";
9097
+ import { z as z12 } from "zod";
9098
+ var providerIdSchema = z12.enum(["anthropic", "openai"]);
9099
+ var MODEL_ID_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/;
9100
+ function parseProviders(input) {
9101
+ const parts = input.split(",").map((p) => p.trim()).filter(Boolean);
9102
+ if (parts.length === 0) {
9103
+ throw new Error("--providers must not be empty. Supported values: anthropic, openai");
9104
+ }
9105
+ const parsed = parts.map((p) => {
9106
+ const result = providerIdSchema.safeParse(p);
9107
+ if (!result.success) {
9108
+ throw new Error(`Unknown provider "${p}". Supported values: anthropic, openai`);
9049
9109
  }
9050
- if (interactive) {
9051
- prompts.note([
9052
- `Proxy: ${baseUrl}`,
9053
- `Repository: ${plan.repo}`,
9054
- `Harness: ${plan.harness}`,
9055
- plan.createKey ? `New key name: ${plan.keyName}` : "Using existing key value",
9056
- "GitHub values to write:",
9057
- formatTemplateSummary(plan.template)
9058
- ].join(`
9059
- `), "Setup summary");
9060
- const shouldContinue = await prompts.promptValue(prompts.confirm({
9061
- message: "Proceed with GitHub secret and variable updates?",
9062
- active: "yes",
9063
- inactive: "no",
9064
- initialValue: true
9065
- }), "Setup cancelled before applying GitHub values.");
9066
- if (!shouldContinue) {
9067
- cancelAndExit("No changes applied.");
9068
- }
9110
+ return result.data;
9111
+ });
9112
+ const deduped = new Set(parsed);
9113
+ if (deduped.size < parsed.length) {
9114
+ throw new Error(`--providers contains duplicate values: ${parsed.join(",")}`);
9115
+ }
9116
+ return parsed;
9117
+ }
9118
+ var PROVIDER_DEFAULTS = {
9119
+ anthropic: "anthropic/claude-sonnet-4-6",
9120
+ openai: "openai/gpt-5.4-mini"
9121
+ };
9122
+ var CUSTOM_MODEL_SENTINEL = "__custom__";
9123
+ async function promptForProviders() {
9124
+ let providers = [];
9125
+ do {
9126
+ const result = await multiselect({
9127
+ message: "Select providers to configure",
9128
+ options: [
9129
+ { value: "anthropic", label: "Anthropic" },
9130
+ { value: "openai", label: "OpenAI" }
9131
+ ],
9132
+ initialValues: ["anthropic"],
9133
+ required: false
9134
+ });
9135
+ if (isCancel2(result)) {
9136
+ cancelAndExit("Setup cancelled before selecting providers.");
9069
9137
  }
9070
- const [existingSecrets, existingVariables] = await gh.withGhRetry("Checking existing GitHub secrets and variables", async () => Promise.all([gh.listExistingGhNames(plan.repo, "secret"), gh.listExistingGhNames(plan.repo, "variable")]), interactive);
9071
- if (options.key && existingSecrets.includes("OPENCODE_AUTH_JSON")) {
9072
- if (interactive) {
9073
- const proceed = await prompts.promptValue(prompts.confirm({
9074
- message: `You supplied --key ${redactKey(options.key)}. Verify it matches the bearer token inside the existing OPENCODE_AUTH_JSON on ${plan.repo}. Continue?`,
9075
- active: "yes",
9076
- inactive: "no",
9077
- initialValue: false
9078
- }), "Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.");
9079
- if (!proceed) {
9080
- cancelAndExit("Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.");
9138
+ providers = result;
9139
+ } while (providers.length === 0);
9140
+ return providers;
9141
+ }
9142
+ async function promptForModel(providers) {
9143
+ if (providers.length === 1) {
9144
+ return PROVIDER_DEFAULTS[providers[0]];
9145
+ }
9146
+ const chosen = await select({
9147
+ message: "Choose a default model",
9148
+ options: [
9149
+ { value: "openai/gpt-5.4-mini", label: "openai/gpt-5.4-mini" },
9150
+ { value: "anthropic/claude-sonnet-4-6", label: "anthropic/claude-sonnet-4-6" },
9151
+ { value: CUSTOM_MODEL_SENTINEL, label: "Enter custom model ID..." }
9152
+ ]
9153
+ });
9154
+ if (isCancel2(chosen)) {
9155
+ cancelAndExit("Setup cancelled before selecting a model.");
9156
+ }
9157
+ if (chosen === CUSTOM_MODEL_SENTINEL) {
9158
+ return promptForCustomModel();
9159
+ }
9160
+ return chosen;
9161
+ }
9162
+ async function promptForCustomModel() {
9163
+ let modelId;
9164
+ do {
9165
+ const result = await text2({
9166
+ message: "Enter a custom model ID (e.g. openai/gpt-5.4-mini)",
9167
+ placeholder: "provider/model-name",
9168
+ validate: (value) => {
9169
+ if (!MODEL_ID_RE.test(value ?? "")) {
9170
+ return "Model ID must match provider/model-name (lowercase, digits, dots, hyphens only)";
9081
9171
  }
9082
- } else if (!options.ackKeyReuse) {
9083
- throw new Error(`Refusing key-reuse without explicit acknowledgment. Pass --ack-key-reuse to confirm that --key matches the bearer token inside the existing OPENCODE_AUTH_JSON on ${plan.repo}. (The CLI cannot verify this because GitHub secrets are write-only.)`);
9172
+ return;
9084
9173
  }
9174
+ });
9175
+ if (isCancel2(result)) {
9176
+ cancelAndExit("Setup cancelled before entering a custom model ID.");
9085
9177
  }
9086
- const collisions = collectCollisions(plan.template, existingSecrets, existingVariables);
9087
- if (collisions.length > 0) {
9088
- if (!interactive && !options.force) {
9089
- throw new Error(`Refusing to overwrite existing GitHub values in ${plan.repo}: ${collisions.join(", ")}. Pass --force to confirm. Note: --force only authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`);
9090
- }
9091
- if (!interactive && options.force) {
9092
- log2.warn(`Overwriting existing GitHub values: ${collisions.join(", ")}. ` + `Concurrent setup runs against the same repo are not coordinated and resolve last-write-wins \u2014 don't run setup against this repo from two places at once.`);
9093
- }
9094
- if (interactive) {
9095
- log2.warn(`Existing GitHub values will be overwritten: ${collisions.join(", ")}`);
9096
- const overwrite = await prompts.promptValue(prompts.confirm({
9097
- message: "Overwrite the existing GitHub values?",
9098
- active: "overwrite",
9099
- inactive: "cancel",
9100
- initialValue: false
9101
- }), "Setup cancelled instead of overwriting existing values.");
9102
- if (!overwrite) {
9103
- cancelAndExit("Existing GitHub values left unchanged.");
9104
- }
9178
+ const candidate = result;
9179
+ if (MODEL_ID_RE.test(candidate)) {
9180
+ modelId = candidate;
9181
+ }
9182
+ } while (!modelId);
9183
+ return modelId;
9184
+ }
9185
+
9186
+ // src/commands/cliproxy/setup/smoke-test.ts
9187
+ import { z as z13 } from "zod";
9188
+ var baselineRunSchema = z13.array(z13.object({ databaseId: z13.number() }));
9189
+ var ghRunEntrySchema = z13.object({
9190
+ databaseId: z13.number(),
9191
+ status: z13.string(),
9192
+ conclusion: z13.string().nullable(),
9193
+ url: z13.string(),
9194
+ createdAt: z13.string()
9195
+ });
9196
+ async function runSmokeTest(repo, _model, internals = {}) {
9197
+ const BACKOFF_MS = [5000, 15000, 30000, 60000, 60000];
9198
+ const delayFn = async (ms) => {
9199
+ if (internals._testDelayMs !== undefined) {
9200
+ if (internals._testDelayMs > 0) {
9201
+ await new Promise((resolve2) => setTimeout(resolve2, internals._testDelayMs));
9105
9202
  }
9203
+ return;
9106
9204
  }
9107
- let keyCreatedByThisRun = false;
9108
- const managementKey = plan.createKey ? mgmtKeyResolver() : undefined;
9109
- if (plan.createKey && managementKey) {
9110
- await withSpinner("Creating a new CLIProxyAPI key", async () => {
9111
- await gh.createManagementApiKey(baseUrl, managementKey, plan.keyValue);
9112
- keyCreatedByThisRun = true;
9113
- });
9205
+ await new Promise((resolve2) => setTimeout(resolve2, ms));
9206
+ };
9207
+ const repoUrl = `https://github.com/${repo}`;
9208
+ let baselineId = null;
9209
+ try {
9210
+ const baselineChild = Bun.spawn(["gh", "run", "list", "--workflow=fro-bot.yaml", "--repo", repo, "--limit", "1", "--json", "databaseId"], { stdout: "pipe", stderr: "pipe", env: process.env });
9211
+ const [baselineStdout, , baselineExit] = await Promise.all([
9212
+ new Response(baselineChild.stdout).text(),
9213
+ new Response(baselineChild.stderr).text(),
9214
+ baselineChild.exited
9215
+ ]);
9216
+ if (baselineExit === 0) {
9217
+ const parseResult = baselineRunSchema.safeParse(JSON.parse(baselineStdout));
9218
+ if (parseResult.success && parseResult.data.length > 0 && parseResult.data[0]) {
9219
+ baselineId = parseResult.data[0].databaseId;
9220
+ }
9114
9221
  }
9115
- try {
9116
- await gh.withGhRetry("Writing GitHub secrets and variables", async (spinnerInstance) => {
9117
- for (const secret of plan.template.secrets) {
9118
- spinnerInstance.message(`Setting secret ${secret.name}`);
9119
- await gh.applyGhValue("secret", secret.name, plan.repo, secret.value);
9120
- }
9121
- for (const variable of plan.template.variables) {
9122
- spinnerInstance.message(`Setting variable ${variable.name}`);
9123
- await gh.applyGhValue("variable", variable.name, plan.repo, variable.value);
9124
- }
9125
- }, interactive);
9126
- await verifyWrittenNamesVisible(plan.repo, plan.template.secrets.map((s) => s.name), plan.template.variables.map((v) => v.name), gh.listExistingGhNames);
9127
- await withSpinner("Verifying the new key through the proxy", async () => {
9128
- await validation.assertProxyKeyWorks(baseUrl, plan.keyValue);
9129
- });
9130
- if (plan.harness === "opencode") {
9131
- const workflow = await gh.withGhRetry(`Checking ${plan.repo} fro-bot.yaml wiring`, async () => {
9132
- return checkFroBotWorkflow(plan.repo);
9133
- }, interactive);
9134
- switch (workflow.kind) {
9135
- case "missing": {
9136
- log2.warn(`No .github/workflows/fro-bot.yaml found in ${plan.repo}. The secrets and variables are set, but Fro Bot won't run until the workflow exists and passes them as inputs. See marcusrbrown/infra/.github/workflows/fro-bot.yaml for a reference.`);
9137
- break;
9138
- }
9139
- case "unreachable": {
9140
- log2.warn(`Could not check .github/workflows/fro-bot.yaml in ${plan.repo}: ${workflow.reason}. The secrets and variables are set, but the workflow wiring was not verified. Re-run 'infra cliproxy setup' later to confirm, or inspect the file directly.`);
9141
- break;
9142
- }
9143
- case "no-agent-step": {
9144
- log2.warn(`${plan.repo} .github/workflows/fro-bot.yaml exists but has no 'fro-bot/agent' step. Add one that passes the secrets and variables just written. See marcusrbrown/infra/.github/workflows/fro-bot.yaml for a reference.`);
9145
- break;
9146
- }
9147
- case "analyzed": {
9148
- for (const step of workflow.stepsWithGaps) {
9149
- const missing = [...step.missingInputs];
9150
- log2.warn([
9151
- `${plan.repo} .github/workflows/fro-bot.yaml fro-bot/agent step #${step.stepOrdinal} is missing ${missing.length} required input${missing.length > 1 ? "s" : ""} (${missing.join(", ")}).`,
9152
- `Without ${missing.includes("opencode-config") ? "opencode-config, the baseURL override is ignored and Fro Bot hits api.anthropic.com with the proxy key, which fails with 401" : "these, the secrets you just wrote will not reach OpenCode"}.`,
9153
- "",
9154
- `Add under the 'with:' block of the 'fro-bot/agent' step:`,
9155
- formatWorkflowSnippet(missing)
9156
- ].join(`
9157
- `));
9158
- }
9159
- break;
9160
- }
9161
- default: {
9162
- const _exhaustive = workflow;
9163
- throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`);
9164
- }
9222
+ } catch {}
9223
+ const triggerTime = internals._testTriggerTime ?? new Date;
9224
+ const triggerChild = Bun.spawn(["gh", "workflow", "run", "fro-bot.yaml", "--repo", repo, "-f", "prompt=reply with exactly: ack"], { stdout: "pipe", stderr: "pipe", env: process.env });
9225
+ const [, triggerStderr, triggerExit] = await Promise.all([
9226
+ new Response(triggerChild.stdout).text(),
9227
+ new Response(triggerChild.stderr).text(),
9228
+ triggerChild.exited
9229
+ ]);
9230
+ if (triggerExit !== 0) {
9231
+ const redacted = triggerStderr.slice(0, 200);
9232
+ return { kind: "unverified", message: `gh workflow run failed: ${redacted}` };
9233
+ }
9234
+ let latestMatchedRun;
9235
+ for (const BACKOFF_M of BACKOFF_MS) {
9236
+ await delayFn(BACKOFF_M ?? 60000);
9237
+ let pollRuns = [];
9238
+ try {
9239
+ const pollChild = Bun.spawn([
9240
+ "gh",
9241
+ "run",
9242
+ "list",
9243
+ "--workflow=fro-bot.yaml",
9244
+ "--repo",
9245
+ repo,
9246
+ "--limit",
9247
+ "5",
9248
+ "--json",
9249
+ "databaseId,status,conclusion,url,createdAt"
9250
+ ], { stdout: "pipe", stderr: "pipe", env: process.env });
9251
+ const [pollStdout, , pollExit] = await Promise.all([
9252
+ new Response(pollChild.stdout).text(),
9253
+ new Response(pollChild.stderr).text(),
9254
+ pollChild.exited
9255
+ ]);
9256
+ if (pollExit === 0) {
9257
+ const rawParsed = JSON.parse(pollStdout);
9258
+ if (Array.isArray(rawParsed)) {
9259
+ pollRuns = rawParsed.flatMap((entry) => {
9260
+ const entryResult = ghRunEntrySchema.safeParse(entry);
9261
+ return entryResult.success ? [entryResult.data] : [];
9262
+ });
9165
9263
  }
9166
9264
  }
9167
- } catch (mutationError) {
9168
- if (keyCreatedByThisRun && managementKey) {
9265
+ } catch {
9266
+ continue;
9267
+ }
9268
+ const candidates = pollRuns.filter((run) => {
9269
+ if (baselineId !== null) {
9270
+ return run.databaseId > baselineId;
9271
+ }
9272
+ return new Date(run.createdAt) > triggerTime;
9273
+ });
9274
+ if (candidates.length === 0) {
9275
+ continue;
9276
+ }
9277
+ const matched = candidates.reduce((best, run) => run.databaseId > best.databaseId ? run : best);
9278
+ latestMatchedRun = matched;
9279
+ const { status, conclusion, url: runUrl } = matched;
9280
+ if (status === "waiting") {
9281
+ return { kind: "unverified", message: `Workflow requires environment approval at ${runUrl}`, runUrl };
9282
+ }
9283
+ if (status === "completed") {
9284
+ if (conclusion === "success") {
9285
+ let logNote = "";
9169
9286
  try {
9170
- await gh.deleteManagementApiKey(baseUrl, managementKey, plan.keyValue);
9171
- log2.warn("Rolled back the newly created CLIProxyAPI key after failure.");
9287
+ const logChild = Bun.spawn(["gh", "run", "view", String(matched.databaseId), "--log", "--repo", repo], {
9288
+ stdout: "pipe",
9289
+ stderr: "pipe",
9290
+ env: process.env
9291
+ });
9292
+ const [logStdout, , logExit] = await Promise.all([
9293
+ new Response(logChild.stdout).text(),
9294
+ new Response(logChild.stderr).text(),
9295
+ logChild.exited
9296
+ ]);
9297
+ if (logExit !== 0) {
9298
+ logNote = " (log fetch failed, but run conclusion is success)";
9299
+ } else if (!/\back\b/i.test(logStdout)) {
9300
+ logNote = ' (log fetch succeeded but "ack" not found in output)';
9301
+ }
9172
9302
  } catch {
9173
- log2.warn("Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove");
9303
+ logNote = " (log fetch failed, but run conclusion is success)";
9174
9304
  }
9305
+ return { kind: "pass", message: `Smoke test passed${logNote}`, runUrl };
9175
9306
  }
9176
- throw mutationError;
9307
+ return { kind: "fail", message: `Run completed with conclusion=${conclusion ?? "unknown"}`, runUrl };
9177
9308
  }
9178
- if (interactive) {
9179
- prompts.outro(`Setup complete for ${plan.repo}. The ${plan.harness} harness can now use ${baseUrl}/v1.`);
9309
+ }
9310
+ if (latestMatchedRun) {
9311
+ return {
9312
+ kind: "unverified",
9313
+ message: `Smoke test did not complete in 5 minutes; check ${latestMatchedRun.url}`,
9314
+ runUrl: latestMatchedRun.url
9315
+ };
9316
+ }
9317
+ return {
9318
+ kind: "unverified",
9319
+ message: `Smoke test trigger not yet visible; check ${repoUrl}/actions`
9320
+ };
9321
+ }
9322
+
9323
+ // src/commands/cliproxy/setup/templates.ts
9324
+ import { z as z14 } from "zod";
9325
+ var harnessSchema = z14.enum(["opencode", "claude-code", "generic"]);
9326
+ var DEFAULT_CLIPROXY_URL6 = "https://cliproxy.fro.bot";
9327
+ function stripTrailingSlash5(value) {
9328
+ return value.endsWith("/") ? value.slice(0, -1) : value;
9329
+ }
9330
+ function getHarnessTemplate(harness, values = {}) {
9331
+ const keyValue = values.keyValue ?? "sk-placeholder";
9332
+ const baseUrl = stripTrailingSlash5(values.baseUrl ?? DEFAULT_CLIPROXY_URL6);
9333
+ if (harness === "opencode") {
9334
+ const rawProviders = values.providers ?? ["anthropic"];
9335
+ const PROVIDER_ORDER = ["anthropic", "openai"];
9336
+ const providers = PROVIDER_ORDER.filter((p) => rawProviders.includes(p));
9337
+ let model;
9338
+ if (values.model) {
9339
+ model = values.model;
9340
+ } else if (providers.length === 1) {
9341
+ model = PROVIDER_DEFAULTS[providers[0]];
9180
9342
  } else {
9181
- log2.success(`Setup complete for ${plan.repo}.`);
9343
+ throw new Error("model required when multiple providers selected");
9182
9344
  }
9183
- if (options.verifySmoke) {
9184
- const smokeResult = await withSpinner("Running smoke test", async () => smoke.runSmokeTest(plan.repo, plan.template.variables.find((v) => v.name === "FRO_BOT_MODEL")?.value ?? "")).catch(async (error) => {
9185
- return {
9186
- kind: "unverified",
9187
- message: `Smoke test error: ${extractErrorMessage4(error)}`,
9188
- runUrl: undefined
9189
- };
9190
- });
9191
- ctx.console.log(`[smoke-test] kind=${smokeResult.kind}`);
9192
- switch (smokeResult.kind) {
9193
- case "pass": {
9194
- log2.success(`\u2713 ${smokeResult.message}${smokeResult.runUrl ? ` \u2014 ${smokeResult.runUrl}` : ""}`);
9195
- break;
9196
- }
9197
- case "fail": {
9198
- log2.warn(`\u2717 ${smokeResult.message}${smokeResult.runUrl ? ` \u2014 ${smokeResult.runUrl}` : ""}`);
9199
- break;
9345
+ const authObj = {};
9346
+ for (const p of providers) {
9347
+ authObj[p] = { type: "api", key: keyValue };
9348
+ }
9349
+ const providerConfig = {};
9350
+ for (const p of providers) {
9351
+ providerConfig[p] = { options: { baseURL: `${baseUrl}/v1` } };
9352
+ }
9353
+ return {
9354
+ secrets: [
9355
+ {
9356
+ name: "OPENCODE_AUTH_JSON",
9357
+ value: JSON.stringify(authObj)
9358
+ },
9359
+ {
9360
+ name: "OPENCODE_CONFIG",
9361
+ value: JSON.stringify({ provider: providerConfig })
9200
9362
  }
9201
- case "unverified": {
9202
- log2.warn(`\u26A0 ${smokeResult.message}${smokeResult.runUrl ? ` \u2014 ${smokeResult.runUrl}` : ""}`);
9203
- break;
9363
+ ],
9364
+ variables: [
9365
+ {
9366
+ name: "FRO_BOT_MODEL",
9367
+ value: model
9204
9368
  }
9205
- default: {
9206
- const _exhaustive = smokeResult;
9207
- throw new Error(`Unhandled SmokeResult kind: ${JSON.stringify(_exhaustive)}`);
9369
+ ]
9370
+ };
9371
+ }
9372
+ if (harness === "claude-code") {
9373
+ return {
9374
+ secrets: [
9375
+ {
9376
+ name: "ANTHROPIC_API_KEY",
9377
+ value: keyValue
9208
9378
  }
9379
+ ],
9380
+ variables: []
9381
+ };
9382
+ }
9383
+ if (!values.genericSecretNames) {
9384
+ throw new Error("Generic harness requires custom secret names.");
9385
+ }
9386
+ return {
9387
+ secrets: [
9388
+ { name: values.genericSecretNames.apiKeySecretName, value: keyValue },
9389
+ { name: values.genericSecretNames.baseUrlSecretName, value: `${baseUrl}/v1` }
9390
+ ],
9391
+ variables: []
9392
+ };
9393
+ }
9394
+ function formatTemplateSummary(template) {
9395
+ const secretLines = template.secrets.map((secret) => `- secret ${secret.name}`);
9396
+ const variableLines = template.variables.map((variable) => `- variable ${variable.name}`);
9397
+ return [...secretLines, ...variableLines].join(`
9398
+ `);
9399
+ }
9400
+ function collectCollisions(template, existingSecrets, existingVariables) {
9401
+ const collisions = [];
9402
+ for (const secret of template.secrets) {
9403
+ if (existingSecrets.includes(secret.name)) {
9404
+ collisions.push(`secret ${secret.name}`);
9405
+ }
9406
+ }
9407
+ for (const variable of template.variables) {
9408
+ if (existingVariables.includes(variable.name)) {
9409
+ collisions.push(`variable ${variable.name}`);
9410
+ }
9411
+ }
9412
+ return collisions;
9413
+ }
9414
+
9415
+ // src/commands/cliproxy/setup/validation.ts
9416
+ import { z as z15 } from "zod";
9417
+ var modelEntrySchema2 = z15.object({
9418
+ id: z15.string(),
9419
+ owned_by: z15.string().optional()
9420
+ }).passthrough();
9421
+ var modelsResponseSchema2 = z15.object({
9422
+ data: z15.array(modelEntrySchema2)
9423
+ }).passthrough();
9424
+ var MODEL_ID_RE2 = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/;
9425
+ var HTTP_TIMEOUT_MS3 = 1e4;
9426
+ var PROVIDER_ID_PATTERNS2 = {
9427
+ openai: /^(?:gpt-|o1-|o3-|codex)/i,
9428
+ anthropic: /^claude-/i
9429
+ };
9430
+ function entryMatchesProvider2(entry, provider) {
9431
+ if (entry.owned_by !== undefined && entry.owned_by.trim() !== "") {
9432
+ return entry.owned_by === provider;
9433
+ }
9434
+ const id = entry.id ?? "";
9435
+ if (id.startsWith(`${provider}/`)) {
9436
+ return true;
9437
+ }
9438
+ const pattern = PROVIDER_ID_PATTERNS2[provider];
9439
+ return pattern === undefined ? false : pattern.test(id);
9440
+ }
9441
+ function extractErrorMessage3(error) {
9442
+ return error instanceof Error ? error.message : String(error);
9443
+ }
9444
+ function validateSetupOptions(options, isInteractive) {
9445
+ if (isInteractive) {
9446
+ return;
9447
+ }
9448
+ if (options.providers) {
9449
+ const providers = parseProviders(options.providers);
9450
+ if (providers.length > 1 && !options.model) {
9451
+ throw new Error("Pass --model <provider/model-id> when selecting multiple providers.");
9452
+ }
9453
+ if (options.model) {
9454
+ const slashIndex = options.model.indexOf("/");
9455
+ const prefix = slashIndex === -1 ? options.model : options.model.slice(0, slashIndex);
9456
+ if (!providers.includes(prefix)) {
9457
+ throw new Error(`Model prefix ${prefix} does not match selected providers (${providers.join(", ")}). Valid prefixes: ${providers.join(", ")}/`);
9458
+ }
9459
+ }
9460
+ }
9461
+ if (!options.dryRun && !options.key) {
9462
+ throw new Error("--key is required when stdin is not a TTY. Provide an existing CLIProxyAPI key value.");
9463
+ }
9464
+ if (!options.repo) {
9465
+ throw new Error("--repo is required when stdin is not a TTY. Provide the target GitHub repository as owner/repo.");
9466
+ }
9467
+ if (!options.harness) {
9468
+ throw new Error("--harness is required when stdin is not a TTY. Choose opencode or claude-code.");
9469
+ }
9470
+ if (options.harness === "generic") {
9471
+ throw new Error("--harness generic is interactive-only because it requires custom secret names.");
9472
+ }
9473
+ }
9474
+ async function verifyModelsAvailable(baseUrl, key, providers, model) {
9475
+ if (providers.length === 1 && providers[0] === "anthropic") {
9476
+ return;
9477
+ }
9478
+ const endpoint = `${baseUrl}/v1/models`;
9479
+ const response = await fetch(endpoint, {
9480
+ headers: { Authorization: `Bearer ${key}` },
9481
+ signal: AbortSignal.timeout(1e4)
9482
+ });
9483
+ if (response.status === 401 || response.status === 403) {
9484
+ throw new Error("Proxy key rejected. Verify with `cliproxy keys list` or rerun setup to create a new one.");
9485
+ }
9486
+ if (!response.ok) {
9487
+ const rawBody = await response.text();
9488
+ const redacted = (key.length > 0 ? rawBody.replaceAll(key, "<redacted>") : rawBody).replaceAll(/Bearer\s+[^\s"]+/g, "Bearer <redacted>").replaceAll(/sk-[\w.-]{8,}/g, "sk-<redacted>");
9489
+ const excerpt = redacted.slice(0, 200);
9490
+ throw new Error(`/v1/models returned HTTP ${response.status}: ${excerpt}`);
9491
+ }
9492
+ const json = await response.json();
9493
+ let parsed;
9494
+ try {
9495
+ parsed = modelsResponseSchema2.parse(json);
9496
+ } catch (error) {
9497
+ const message = error instanceof z15.ZodError ? error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ") : extractErrorMessage3(error);
9498
+ throw new Error(`Malformed /v1/models response: ${message}`);
9499
+ }
9500
+ const entries = parsed.data;
9501
+ if (providers.includes("openai")) {
9502
+ const hasOpenAi = entries.some((e) => entryMatchesProvider2(e, "openai"));
9503
+ if (!hasOpenAi) {
9504
+ throw new Error("No OpenAI models on proxy \u2014 is the Codex token loaded? Try `cliproxy login codex`.");
9505
+ }
9506
+ }
9507
+ const slashIndex = model.indexOf("/");
9508
+ const bareId = slashIndex === -1 ? model : model.slice(slashIndex + 1);
9509
+ const providerPrefix = slashIndex >= 0 ? model.slice(0, slashIndex) : undefined;
9510
+ const modelPresent = entries.some((e) => e.id === bareId || e.id === model);
9511
+ if (!modelPresent) {
9512
+ const matchingIds = providerPrefix ? entries.filter((e) => entryMatchesProvider2(e, providerPrefix)).map((e) => e.id) : entries.map((e) => e.id);
9513
+ const available = matchingIds.length > 0 ? matchingIds.join(", ") : "(none)";
9514
+ throw new Error(`Model "${bareId}" not found on proxy. Available ${providerPrefix ?? "models"}: ${available}`);
9515
+ }
9516
+ }
9517
+ async function assertProxyReachable(baseUrl) {
9518
+ try {
9519
+ const response = await fetch(`${baseUrl}/healthz`, {
9520
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
9521
+ });
9522
+ if (!response.ok) {
9523
+ throw new Error(`Proxy check failed for ${baseUrl}: HTTP ${response.status}. Is the proxy running and reachable?`);
9524
+ }
9525
+ } catch (error) {
9526
+ if (error instanceof Error && error.message.startsWith("Proxy check failed")) {
9527
+ throw error;
9528
+ }
9529
+ throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage3(error)}`);
9530
+ }
9531
+ }
9532
+ async function assertProxyKeyWorks(baseUrl, keyValue) {
9533
+ try {
9534
+ const response = await fetch(`${baseUrl}/v1/models`, {
9535
+ headers: {
9536
+ authorization: `Bearer ${keyValue}`
9537
+ },
9538
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS3)
9539
+ });
9540
+ if (!response.ok) {
9541
+ throw new Error(`Proxy key verification failed with HTTP ${response.status}`);
9542
+ }
9543
+ } catch (error) {
9544
+ if (error instanceof Error && error.message.startsWith("Proxy key verification")) {
9545
+ throw error;
9546
+ }
9547
+ throw new Error(`Unable to verify proxy key at ${baseUrl}: ${extractErrorMessage3(error)}`);
9548
+ }
9549
+ }
9550
+
9551
+ // src/commands/cliproxy/setup/workflow-analyzer.ts
9552
+ var REQUIRED_OPENCODE_INPUTS = ["auth-json", "opencode-config", "model"];
9553
+ function findFroBotAgentStepBodies(content) {
9554
+ const bodies = [];
9555
+ const pattern = /^(\s*(?:-\s+)?)uses:\s*fro-bot\/agent@/gm;
9556
+ for (const match of content.matchAll(pattern)) {
9557
+ if (match.index === undefined || match[1] === undefined)
9558
+ continue;
9559
+ const stepBodyIndent = match[1].length;
9560
+ const dashIndent = Math.max(0, stepBodyIndent - 2);
9561
+ const lines = content.slice(match.index).split(`
9562
+ `);
9563
+ const stepLines = [lines[0] ?? ""];
9564
+ for (let index = 1;index < lines.length; index += 1) {
9565
+ const line = lines[index] ?? "";
9566
+ if (!line.trim()) {
9567
+ stepLines.push(line);
9568
+ continue;
9569
+ }
9570
+ const firstNonSpace = line.search(/\S/);
9571
+ if (firstNonSpace === dashIndent && line.trimStart().startsWith("-"))
9572
+ break;
9573
+ if (firstNonSpace < dashIndent)
9574
+ break;
9575
+ stepLines.push(line);
9576
+ }
9577
+ bodies.push({ stepOrdinal: bodies.length + 1, body: stepLines.join(`
9578
+ `) });
9579
+ }
9580
+ return bodies;
9581
+ }
9582
+ function analyzeFroBotWorkflow(workflowContent) {
9583
+ const steps = findFroBotAgentStepBodies(workflowContent);
9584
+ if (steps.length === 0) {
9585
+ return { kind: "no-agent-step" };
9586
+ }
9587
+ const stepsWithGaps = steps.map((step) => ({
9588
+ stepOrdinal: step.stepOrdinal,
9589
+ missingInputs: REQUIRED_OPENCODE_INPUTS.filter((input) => {
9590
+ const inputPattern = new RegExp(String.raw`^\s+${input}:`, "m");
9591
+ return !inputPattern.test(step.body);
9592
+ })
9593
+ })).filter((step) => step.missingInputs.length > 0);
9594
+ return { kind: "analyzed", stepsWithGaps };
9595
+ }
9596
+ function interpretGhContentResult(result) {
9597
+ if (result.exitCode === 0) {
9598
+ return analyzeFroBotWorkflow(result.stdout);
9599
+ }
9600
+ if (/HTTP 404/.test(result.stderr)) {
9601
+ return { kind: "missing" };
9602
+ }
9603
+ return {
9604
+ kind: "unreachable",
9605
+ reason: result.stderr.trim() || `gh api exited with code ${result.exitCode}`
9606
+ };
9607
+ }
9608
+ async function checkFroBotWorkflow(repo) {
9609
+ const result = await runGh([
9610
+ "api",
9611
+ "--header",
9612
+ "Accept: application/vnd.github.raw",
9613
+ `/repos/${repo}/contents/.github/workflows/fro-bot.yaml`
9614
+ ]);
9615
+ return interpretGhContentResult(result);
9616
+ }
9617
+ function formatWorkflowSnippet(missingInputs) {
9618
+ const inputMap = {
9619
+ "auth-json": "auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}",
9620
+ "opencode-config": "opencode-config: ${{ secrets.OPENCODE_CONFIG }}",
9621
+ model: "model: ${{ vars.FRO_BOT_MODEL }}"
9622
+ };
9623
+ return missingInputs.map((input) => ` ${inputMap[input]}`).join(`
9624
+ `);
9625
+ }
9626
+
9627
+ // src/commands/cliproxy/setup.ts
9628
+ var DEFAULT_CLIPROXY_URL7 = "https://cliproxy.fro.bot";
9629
+ function resolveBaseUrl3(input) {
9630
+ return stripTrailingSlash5(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL7);
9631
+ }
9632
+ function safeWarn(message) {
9633
+ try {
9634
+ log2.warn(message);
9635
+ } catch {}
9636
+ }
9637
+ function buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames) {
9638
+ const secretPart = writtenSecretNames.length > 0 ? `gh secret list --repo ${repo} (expect: ${writtenSecretNames.join(", ")})` : `gh secret list --repo ${repo}`;
9639
+ const variablePart = writtenVariableNames.length > 0 ? `gh variable list --repo ${repo} (expect: ${writtenVariableNames.join(", ")})` : `gh variable list --repo ${repo}`;
9640
+ return `Post-write readback: could not verify the written names are visible in ${repo} ` + `(the GitHub list call failed). Verify manually: ${secretPart}; ${variablePart}.`;
9641
+ }
9642
+ async function verifyWrittenNamesVisible(repo, writtenSecretNames, writtenVariableNames, listExistingGhNames2) {
9643
+ try {
9644
+ let secretReadback;
9645
+ let variableReadback;
9646
+ let readbackFailed = false;
9647
+ try {
9648
+ secretReadback = await listExistingGhNames2(repo, "secret");
9649
+ } catch {
9650
+ readbackFailed = true;
9651
+ secretReadback = [];
9652
+ }
9653
+ if (readbackFailed) {
9654
+ variableReadback = [];
9655
+ } else {
9656
+ try {
9657
+ variableReadback = await listExistingGhNames2(repo, "variable");
9658
+ } catch {
9659
+ readbackFailed = true;
9660
+ variableReadback = [];
9209
9661
  }
9210
9662
  }
9211
- } catch (error) {
9212
- const message = extractErrorMessage4(error);
9213
- if (ctxInjected) {
9214
- ctx.console.error(message);
9663
+ if (readbackFailed) {
9664
+ safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames));
9665
+ return;
9215
9666
  }
9216
- if (interactive) {
9217
- cancel2(message);
9667
+ const absentSecrets = writtenSecretNames.filter((name) => !secretReadback.includes(name));
9668
+ const absentVariables = writtenVariableNames.filter((name) => !variableReadback.includes(name));
9669
+ if (absentSecrets.length === 0 && absentVariables.length === 0) {
9670
+ return;
9218
9671
  }
9219
- throw error;
9220
- }
9221
- }
9222
- function registerCliproxySetup(cli) {
9223
- cli.command("cliproxy setup", "Interactively onboard a GitHub repository to CLIProxyAPI by creating or reusing a key and wiring the required GitHub secrets and variables.").option("--key [key]", z14.string().describe("Existing CLIProxyAPI API key value. When provided, setup skips key creation and reuses this key for GitHub secrets.")).option("--repo [repo]", z14.string().describe("Target GitHub repository in owner/repo format. Skips the repository prompt when provided.")).option("--harness [harness]", harnessSchema.describe("Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.")).option("--providers [providers]", z14.string().describe("Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai")).option("--model [model]", z14.string().regex(MODEL_ID_RE2).describe("Override the default model. Must be provider-prefixed and lowercase. Required when multiple providers selected. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o")).option("--force", z14.boolean().optional().describe("Overwrite existing GitHub secrets and variables without prompting.")).option("--dry-run", z14.boolean().optional().describe("Print the plan without applying any changes.")).option("--verify-smoke", z14.boolean().optional().describe("Run a smoke test against the proxy after setup completes.")).option("--ack-key-reuse", z14.boolean().default(false).describe("Acknowledge that --key matches the bearer token inside the existing OPENCODE_AUTH_JSON. Required in non-interactive mode when --key is supplied for a repo with existing OPENCODE_AUTH_JSON.")).example("# Preview planned actions without applying any changes (no flags required)").example("infra cliproxy setup --dry-run").example("# Run the interactive onboarding wizard").example("infra cliproxy setup").example("# Run non-interactively with an existing key (anthropic-only)").example("infra cliproxy setup --repo owner/repo --harness opencode --key sk-existing --force").example("# Enable both providers non-interactively (requires --force and --model)").example("infra cliproxy setup --repo owner/repo --harness opencode --providers anthropic,openai --model openai/gpt-5.4-mini --key sk-existing --ack-key-reuse --force").action(async (options, ctx) => {
9224
- await runSetupCommand(options, { ctx });
9225
- });
9226
- }
9227
-
9228
- // src/commands/cliproxy/status.ts
9229
- import { z as z15 } from "zod";
9230
- var DEFAULT_CLIPROXY_URL6 = "https://cliproxy.fro.bot";
9231
- function levelLabel(level) {
9232
- if (level === "ok") {
9233
- return "OK";
9234
- }
9235
- if (level === "warning") {
9236
- return "WARN";
9672
+ const lines = [
9673
+ `Post-write readback: the following written names are not visible in ${repo} \u2014 ` + `the token's list view may be unreliable and the pre-write safety gates may have been bypassed.`
9674
+ ];
9675
+ if (absentSecrets.length > 0) {
9676
+ lines.push(` Absent secrets: ${absentSecrets.join(", ")}`);
9677
+ lines.push(` Verify manually: gh secret list --repo ${repo}`);
9678
+ }
9679
+ if (absentVariables.length > 0) {
9680
+ lines.push(` Absent variables: ${absentVariables.join(", ")}`);
9681
+ lines.push(` Verify manually: gh variable list --repo ${repo}`);
9682
+ }
9683
+ safeWarn(lines.join(`
9684
+ `));
9685
+ } catch {
9686
+ safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames));
9237
9687
  }
9238
- return "ERROR";
9239
9688
  }
9240
- function formatDurationMs(durationMs) {
9241
- return `${Math.max(0, Math.round(durationMs))}ms`;
9689
+ function extractErrorMessage4(error) {
9690
+ return error instanceof Error ? error.message : String(error);
9242
9691
  }
9243
- function stripTrailingSlash5(value) {
9244
- return value.endsWith("/") ? value.slice(0, -1) : value;
9692
+ function redactKey(key) {
9693
+ if (key.length < 12)
9694
+ return "sk-***";
9695
+ return `${key.slice(0, 3)}***${key.slice(-4)}`;
9245
9696
  }
9246
- async function parseJsonResponse(response) {
9247
- try {
9248
- return await response.json();
9249
- } catch {
9250
- return null;
9697
+ async function buildInteractivePlan(options, baseUrl, promptsImpl) {
9698
+ const createKey = !options.key;
9699
+ const keyName = createKey ? await promptsImpl.promptValue(text3({
9700
+ message: "Name this new CLIProxyAPI key",
9701
+ placeholder: "my-repo-ci",
9702
+ validate: (value) => (value ?? "").trim().length > 0 ? undefined : "Key name is required."
9703
+ }), "Setup cancelled before naming the key.") : undefined;
9704
+ const harness = options.harness ?? await promptsImpl.promptValue(select2({
9705
+ message: "Choose the harness to configure",
9706
+ options: [
9707
+ { value: "opencode", label: "OpenCode" },
9708
+ { value: "claude-code", label: "Claude Code" },
9709
+ { value: "generic", label: "Generic" }
9710
+ ]
9711
+ }), "Setup cancelled before selecting a harness.");
9712
+ const repo = options.repo ? ensureRepoFormat(options.repo) : ensureRepoFormat(await promptsImpl.promptValue(text3({
9713
+ message: "Target GitHub repository",
9714
+ placeholder: "owner/repo",
9715
+ validate: (value) => {
9716
+ try {
9717
+ ensureRepoFormat(value ?? "");
9718
+ return;
9719
+ } catch (error) {
9720
+ return extractErrorMessage4(error);
9721
+ }
9722
+ }
9723
+ }), "Setup cancelled before choosing a repository."));
9724
+ let providers;
9725
+ let model;
9726
+ if (harness === "opencode") {
9727
+ const doPromptForProviders = promptsImpl.promptForProviders ?? promptForProviders;
9728
+ const doPromptForModel = promptsImpl.promptForModel ?? promptForModel;
9729
+ providers = await doPromptForProviders();
9730
+ model = await doPromptForModel(providers);
9251
9731
  }
9732
+ const keyValue = options.key ?? buildApiKeyValue(keyName ?? "cliproxy");
9733
+ const genericSecretNames = harness === "generic" ? await promptGenericSecretNames() : undefined;
9734
+ return {
9735
+ repo,
9736
+ harness,
9737
+ keyValue,
9738
+ keyName,
9739
+ createKey,
9740
+ template: getHarnessTemplate(harness, { keyValue, baseUrl, genericSecretNames, providers, model })
9741
+ };
9252
9742
  }
9253
- function toNumber(value) {
9254
- return typeof value === "number" && Number.isFinite(value) ? value : null;
9743
+ function requiresDestructiveProviderChangeConfirmation(providers) {
9744
+ return !(providers.length === 1 && providers[0] === "anthropic");
9255
9745
  }
9256
- async function checkHttpReachability(url, verbose) {
9257
- const startedAt = performance.now();
9258
- try {
9259
- const response = await fetch(url, {
9260
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
9261
- });
9262
- const elapsedMs = performance.now() - startedAt;
9263
- const details = [];
9264
- if (verbose) {
9265
- details.push(`URL: ${url}`);
9266
- details.push(`Status text: ${response.statusText || "(none)"}`);
9267
- if (response.headers.get("content-type")) {
9268
- details.push(`Content-Type: ${response.headers.get("content-type")}`);
9269
- }
9270
- }
9271
- return {
9272
- title: "HTTP reachability",
9273
- level: response.ok ? "ok" : "error",
9274
- summary: `GET ${url} \u2192 ${response.status} (${formatDurationMs(elapsedMs)})`,
9275
- details
9276
- };
9277
- } catch (error) {
9278
- const elapsedMs = performance.now() - startedAt;
9279
- const message = error instanceof Error ? error.message : String(error);
9746
+ async function buildNonInteractivePlan(options, baseUrl, deps) {
9747
+ const harness = harnessSchema.parse(options.harness);
9748
+ const repo = ensureRepoFormat(options.repo ?? "");
9749
+ const keyValue = options.key ?? "";
9750
+ const providers = options.providers ? parseProviders(options.providers) : ["anthropic"];
9751
+ let model;
9752
+ if (options.model) {
9753
+ model = options.model;
9754
+ } else if (providers.length === 1) {
9755
+ model = PROVIDER_DEFAULTS[providers[0]];
9756
+ } else {
9757
+ throw new Error("Pass --model <provider/model-id> when selecting multiple providers.");
9758
+ }
9759
+ if (options.dryRun) {
9280
9760
  return {
9281
- title: "HTTP reachability",
9282
- level: "error",
9283
- summary: `Request failed after ${formatDurationMs(elapsedMs)}: ${message}`,
9284
- details: verbose ? [`URL: ${url}`, `Timeout: ${HTTP_TIMEOUT_MS2}ms`] : undefined
9761
+ repo,
9762
+ harness,
9763
+ keyValue,
9764
+ createKey: false,
9765
+ template: getHarnessTemplate(harness, { keyValue: keyValue || "sk-placeholder", baseUrl, providers, model })
9285
9766
  };
9286
9767
  }
9287
- }
9288
- function countQueueErrors(records) {
9289
- let count = 0;
9290
- for (const record of records) {
9291
- if (record === null || typeof record !== "object") {
9292
- continue;
9293
- }
9294
- const rec = record;
9295
- const status = toNumber(rec.status);
9296
- if (status !== null && status >= 400) {
9297
- count++;
9298
- continue;
9299
- }
9300
- if (rec.error !== undefined || rec.failed === true || rec.failure === true) {
9301
- count++;
9302
- }
9768
+ const verifyModels = deps?.validation?.verifyModelsAvailable ?? verifyModelsAvailable;
9769
+ if (requiresDestructiveProviderChangeConfirmation(providers) && !options.force) {
9770
+ throw new Error(`Refusing destructive provider change on ${options.repo ?? ""} without --force. Selected providers ${providers.join(", ")} would overwrite existing GitHub secret values (OPENCODE_AUTH_JSON, OPENCODE_CONFIG, FRO_BOT_MODEL). Note: --force authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`);
9303
9771
  }
9304
- return count;
9772
+ await verifyModels(baseUrl, keyValue, providers, model);
9773
+ return {
9774
+ repo,
9775
+ harness,
9776
+ keyValue,
9777
+ createKey: false,
9778
+ template: getHarnessTemplate(harness, { keyValue, baseUrl, providers, model })
9779
+ };
9305
9780
  }
9306
- async function checkUsageStats(baseUrl, key) {
9307
- const endpoint = `${baseUrl}/v0/management/usage-queue?count=50`;
9308
- try {
9309
- const response = await fetch(endpoint, {
9310
- headers: managementHeaders(key),
9311
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
9312
- });
9313
- if (response.status === 429) {
9314
- return {
9315
- title: "Usage stats",
9316
- level: "warning",
9317
- summary: "Rate limited by management API (HTTP 429). Retry in a few moments."
9318
- };
9319
- }
9320
- if (!response.ok) {
9321
- return {
9322
- title: "Usage stats",
9323
- level: "error",
9324
- summary: `GET /v0/management/usage-queue failed with HTTP ${response.status}`
9325
- };
9326
- }
9327
- const payload = await parseJsonResponse(response);
9328
- if (!Array.isArray(payload)) {
9329
- return {
9330
- title: "Usage stats",
9331
- level: "warning",
9332
- summary: "Usage-queue response was not an array \u2014 cannot parse recent activity."
9333
- };
9334
- }
9335
- const total = payload.length;
9336
- const errors2 = countQueueErrors(payload);
9337
- if (total === 0) {
9338
- return {
9339
- title: "Usage stats",
9340
- level: "ok",
9341
- summary: "recent: 0 (idle)"
9342
- };
9781
+ var realGh = {
9782
+ assertGhInstalled,
9783
+ assertGhAuthenticated,
9784
+ assertRepoAccess,
9785
+ listExistingGhNames,
9786
+ createManagementApiKey,
9787
+ deleteManagementApiKey,
9788
+ applyGhValue,
9789
+ withGhRetry
9790
+ };
9791
+ var realPrompts = {
9792
+ promptValue,
9793
+ confirm: confirm2,
9794
+ intro,
9795
+ note,
9796
+ outro,
9797
+ promptForProviders,
9798
+ promptForModel
9799
+ };
9800
+ var realSmoke = {
9801
+ runSmokeTest
9802
+ };
9803
+ var realValidation = {
9804
+ assertProxyReachable,
9805
+ assertProxyKeyWorks,
9806
+ verifyModelsAvailable
9807
+ };
9808
+ var realCtx = {
9809
+ console: {
9810
+ log: (...args) => {
9811
+ console.log(...args);
9812
+ },
9813
+ error: (...args) => {
9814
+ console.error(...args);
9343
9815
  }
9344
- return {
9345
- title: "Usage stats",
9346
- level: errors2 > 0 ? "warning" : "ok",
9347
- summary: errors2 > 0 ? `recent: ${total}, errors: ${errors2}` : `recent: ${total}`
9348
- };
9349
- } catch (error) {
9350
- const message = error instanceof Error ? error.message : String(error);
9351
- return {
9352
- title: "Usage stats",
9353
- level: "error",
9354
- summary: `Unable to read usage stats: ${message}`
9355
- };
9816
+ },
9817
+ process: {
9818
+ stdout: { write: (chunk) => process.stdout.write(chunk) },
9819
+ stderr: { write: (chunk) => process.stderr.write(chunk) },
9820
+ exit: (code) => process.exit(code)
9821
+ }
9822
+ };
9823
+ async function runSetupCommand(options, deps = {}) {
9824
+ const interactive = deps.interactive ?? Boolean(process.stdin.isTTY);
9825
+ const baseUrl = deps.baseUrl ?? resolveBaseUrl3();
9826
+ const ctxInjected = deps.ctx !== undefined;
9827
+ const ctx = deps.ctx ?? realCtx;
9828
+ const gh = deps.gh ?? realGh;
9829
+ const prompts = deps.prompts ?? realPrompts;
9830
+ const smoke = deps.smoke ?? realSmoke;
9831
+ const validation = deps.validation ?? realValidation;
9832
+ const mgmtKeyResolver = deps.resolveManagementKey ?? resolveManagementKey;
9833
+ if (options.dryRun) {
9834
+ const providers = options.providers ? parseProviders(options.providers) : ["anthropic"];
9835
+ const model = options.model ?? PROVIDER_DEFAULTS[providers[0]];
9836
+ const harness = options.harness ?? "opencode";
9837
+ const repo = options.repo ?? "<repo not specified>";
9838
+ const keyValue = options.key ?? "sk-placeholder";
9839
+ ctx.console.log(formatDryRunPreview({
9840
+ repo,
9841
+ harness,
9842
+ providers,
9843
+ model,
9844
+ template: getHarnessTemplate(harness, { keyValue, baseUrl, providers, model })
9845
+ }));
9846
+ return;
9847
+ }
9848
+ validateSetupOptions(options, interactive);
9849
+ if (interactive) {
9850
+ prompts.intro("CLIProxyAPI setup wizard");
9356
9851
  }
9357
- }
9358
- async function checkVersion(baseUrl, key) {
9359
- const endpoint = `${baseUrl}/v0/management/latest-version`;
9360
9852
  try {
9361
- const response = await fetch(endpoint, {
9362
- headers: managementHeaders(key),
9363
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
9853
+ await withSpinner("Checking GitHub CLI availability", async () => {
9854
+ await gh.assertGhInstalled();
9855
+ await gh.assertGhAuthenticated();
9364
9856
  });
9365
- if (response.status === 429) {
9366
- return {
9367
- title: "Latest version",
9368
- level: "warning",
9369
- summary: "Rate limited by management API (HTTP 429). Retry in a few moments."
9370
- };
9857
+ await withSpinner("Checking CLIProxyAPI reachability", async () => {
9858
+ await validation.assertProxyReachable(baseUrl);
9859
+ });
9860
+ const plan = interactive ? await buildInteractivePlan(options, baseUrl, prompts) : await buildNonInteractivePlan(options, baseUrl, { validation });
9861
+ if (plan.createKey) {
9862
+ mgmtKeyResolver();
9371
9863
  }
9372
- if (!response.ok) {
9373
- return {
9374
- title: "Latest version",
9375
- level: "error",
9376
- summary: `GET /v0/management/latest-version failed with HTTP ${response.status}`
9377
- };
9864
+ await gh.withGhRetry(`Checking GitHub access for ${plan.repo}`, async () => {
9865
+ await gh.assertRepoAccess(plan.repo);
9866
+ }, interactive);
9867
+ if (options.key) {
9868
+ log2.info("Using the provided API key value directly. No new CLIProxyAPI key will be created.");
9378
9869
  }
9379
- const payload = await parseJsonResponse(response);
9380
- if (payload && typeof payload === "object") {
9381
- const version = payload["latest-version"];
9382
- if (typeof version === "string" && version.length > 0) {
9383
- return { title: "Latest version", level: "ok", summary: version };
9870
+ if (interactive) {
9871
+ prompts.note([
9872
+ `Proxy: ${baseUrl}`,
9873
+ `Repository: ${plan.repo}`,
9874
+ `Harness: ${plan.harness}`,
9875
+ plan.createKey ? `New key name: ${plan.keyName}` : "Using existing key value",
9876
+ "GitHub values to write:",
9877
+ formatTemplateSummary(plan.template)
9878
+ ].join(`
9879
+ `), "Setup summary");
9880
+ const shouldContinue = await prompts.promptValue(prompts.confirm({
9881
+ message: "Proceed with GitHub secret and variable updates?",
9882
+ active: "yes",
9883
+ inactive: "no",
9884
+ initialValue: true
9885
+ }), "Setup cancelled before applying GitHub values.");
9886
+ if (!shouldContinue) {
9887
+ cancelAndExit("No changes applied.");
9384
9888
  }
9385
9889
  }
9386
- return {
9387
- title: "Latest version",
9388
- level: "warning",
9389
- summary: "Response did not include a latest-version string."
9390
- };
9391
- } catch (error) {
9392
- const message = error instanceof Error ? error.message : String(error);
9393
- return { title: "Latest version", level: "error", summary: `Unable to check latest version: ${message}` };
9394
- }
9395
- }
9396
- var DEFAULT_PROVIDER_MODEL = "claude-sonnet-4-6";
9397
- async function checkProviderAuth(baseUrl, apiKey, options) {
9398
- const model = options?.model ?? DEFAULT_PROVIDER_MODEL;
9399
- const verbose = options?.verbose === true;
9400
- const endpoint = `${baseUrl}/v1/chat/completions`;
9401
- try {
9402
- const response = await fetch(endpoint, {
9403
- method: "POST",
9404
- headers: {
9405
- authorization: `Bearer ${apiKey}`,
9406
- "content-type": "application/json"
9407
- },
9408
- body: JSON.stringify({
9409
- model,
9410
- messages: [{ role: "user", content: "ping" }],
9411
- max_tokens: 1
9412
- }),
9413
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
9414
- });
9415
- const details = [];
9416
- if (verbose) {
9417
- details.push(`URL: ${endpoint}`);
9418
- details.push(`Model: ${model}`);
9419
- details.push(`Status: ${response.status}`);
9890
+ const [existingSecrets, existingVariables] = await gh.withGhRetry("Checking existing GitHub secrets and variables", async () => Promise.all([gh.listExistingGhNames(plan.repo, "secret"), gh.listExistingGhNames(plan.repo, "variable")]), interactive);
9891
+ if (options.key && existingSecrets.includes("OPENCODE_AUTH_JSON")) {
9892
+ if (interactive) {
9893
+ const proceed = await prompts.promptValue(prompts.confirm({
9894
+ message: `You supplied --key ${redactKey(options.key)}. Verify it matches the bearer token inside the existing OPENCODE_AUTH_JSON on ${plan.repo}. Continue?`,
9895
+ active: "yes",
9896
+ inactive: "no",
9897
+ initialValue: false
9898
+ }), "Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.");
9899
+ if (!proceed) {
9900
+ cancelAndExit("Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.");
9901
+ }
9902
+ } else if (!options.ackKeyReuse) {
9903
+ throw new Error(`Refusing key-reuse without explicit acknowledgment. Pass --ack-key-reuse to confirm that --key matches the bearer token inside the existing OPENCODE_AUTH_JSON on ${plan.repo}. (The CLI cannot verify this because GitHub secrets are write-only.)`);
9904
+ }
9420
9905
  }
9421
- if (response.ok) {
9422
- return {
9423
- title: "Upstream provider auth (anthropic)",
9424
- level: "ok",
9425
- summary: `anthropic route OK (${model})`,
9426
- details: details.length > 0 ? details : undefined
9427
- };
9906
+ const collisions = collectCollisions(plan.template, existingSecrets, existingVariables);
9907
+ if (collisions.length > 0) {
9908
+ if (!interactive && !options.force) {
9909
+ throw new Error(`Refusing to overwrite existing GitHub values in ${plan.repo}: ${collisions.join(", ")}. Pass --force to confirm. Note: --force only authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`);
9910
+ }
9911
+ if (!interactive && options.force) {
9912
+ log2.warn(`Overwriting existing GitHub values: ${collisions.join(", ")}. ` + `Concurrent setup runs against the same repo are not coordinated and resolve last-write-wins \u2014 don't run setup against this repo from two places at once.`);
9913
+ }
9914
+ if (interactive) {
9915
+ log2.warn(`Existing GitHub values will be overwritten: ${collisions.join(", ")}`);
9916
+ const overwrite = await prompts.promptValue(prompts.confirm({
9917
+ message: "Overwrite the existing GitHub values?",
9918
+ active: "overwrite",
9919
+ inactive: "cancel",
9920
+ initialValue: false
9921
+ }), "Setup cancelled instead of overwriting existing values.");
9922
+ if (!overwrite) {
9923
+ cancelAndExit("Existing GitHub values left unchanged.");
9924
+ }
9925
+ }
9428
9926
  }
9429
- if (response.status === 401) {
9430
- return {
9431
- title: "Upstream provider auth (anthropic)",
9432
- level: "error",
9433
- summary: `Anthropic upstream auth unavailable (${response.status}) \u2014 run: cliproxy login claude`,
9434
- details: details.length > 0 ? details : undefined
9435
- };
9927
+ let keyCreatedByThisRun = false;
9928
+ const managementKey = plan.createKey ? mgmtKeyResolver() : undefined;
9929
+ if (plan.createKey && managementKey) {
9930
+ await withSpinner("Creating a new CLIProxyAPI key", async () => {
9931
+ await gh.createManagementApiKey(baseUrl, managementKey, plan.keyValue);
9932
+ keyCreatedByThisRun = true;
9933
+ });
9436
9934
  }
9437
- if (response.status === 503) {
9438
- let bodyText = "";
9439
- try {
9440
- bodyText = await response.text();
9441
- } catch {}
9442
- const isAuthUnavailable = /auth_unavailable|no auth available|providers=claude/i.test(bodyText);
9443
- if (isAuthUnavailable) {
9444
- return {
9445
- title: "Upstream provider auth (anthropic)",
9446
- level: "error",
9447
- summary: `Anthropic upstream auth unavailable (${response.status}) \u2014 run: cliproxy login claude`,
9448
- details: details.length > 0 ? details : undefined
9449
- };
9935
+ try {
9936
+ await gh.withGhRetry("Writing GitHub secrets and variables", async (spinnerInstance) => {
9937
+ for (const secret of plan.template.secrets) {
9938
+ spinnerInstance.message(`Setting secret ${secret.name}`);
9939
+ await gh.applyGhValue("secret", secret.name, plan.repo, secret.value);
9940
+ }
9941
+ for (const variable of plan.template.variables) {
9942
+ spinnerInstance.message(`Setting variable ${variable.name}`);
9943
+ await gh.applyGhValue("variable", variable.name, plan.repo, variable.value);
9944
+ }
9945
+ }, interactive);
9946
+ await verifyWrittenNamesVisible(plan.repo, plan.template.secrets.map((s) => s.name), plan.template.variables.map((v) => v.name), gh.listExistingGhNames);
9947
+ await withSpinner("Verifying the new key through the proxy", async () => {
9948
+ await validation.assertProxyKeyWorks(baseUrl, plan.keyValue);
9949
+ });
9950
+ if (plan.harness === "opencode") {
9951
+ const workflow = await gh.withGhRetry(`Checking ${plan.repo} fro-bot.yaml wiring`, async () => {
9952
+ return checkFroBotWorkflow(plan.repo);
9953
+ }, interactive);
9954
+ switch (workflow.kind) {
9955
+ case "missing": {
9956
+ log2.warn(`No .github/workflows/fro-bot.yaml found in ${plan.repo}. The secrets and variables are set, but Fro Bot won't run until the workflow exists and passes them as inputs. See marcusrbrown/infra/.github/workflows/fro-bot.yaml for a reference.`);
9957
+ break;
9958
+ }
9959
+ case "unreachable": {
9960
+ log2.warn(`Could not check .github/workflows/fro-bot.yaml in ${plan.repo}: ${workflow.reason}. The secrets and variables are set, but the workflow wiring was not verified. Re-run 'infra cliproxy setup' later to confirm, or inspect the file directly.`);
9961
+ break;
9962
+ }
9963
+ case "no-agent-step": {
9964
+ log2.warn(`${plan.repo} .github/workflows/fro-bot.yaml exists but has no 'fro-bot/agent' step. Add one that passes the secrets and variables just written. See marcusrbrown/infra/.github/workflows/fro-bot.yaml for a reference.`);
9965
+ break;
9966
+ }
9967
+ case "analyzed": {
9968
+ for (const step of workflow.stepsWithGaps) {
9969
+ const missing = [...step.missingInputs];
9970
+ log2.warn([
9971
+ `${plan.repo} .github/workflows/fro-bot.yaml fro-bot/agent step #${step.stepOrdinal} is missing ${missing.length} required input${missing.length > 1 ? "s" : ""} (${missing.join(", ")}).`,
9972
+ `Without ${missing.includes("opencode-config") ? "opencode-config, the baseURL override is ignored and Fro Bot hits api.anthropic.com with the proxy key, which fails with 401" : "these, the secrets you just wrote will not reach OpenCode"}.`,
9973
+ "",
9974
+ `Add under the 'with:' block of the 'fro-bot/agent' step:`,
9975
+ formatWorkflowSnippet(missing)
9976
+ ].join(`
9977
+ `));
9978
+ }
9979
+ break;
9980
+ }
9981
+ default: {
9982
+ const _exhaustive = workflow;
9983
+ throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`);
9984
+ }
9985
+ }
9986
+ }
9987
+ } catch (mutationError) {
9988
+ if (keyCreatedByThisRun && managementKey) {
9989
+ try {
9990
+ await gh.deleteManagementApiKey(baseUrl, managementKey, plan.keyValue);
9991
+ log2.warn("Rolled back the newly created CLIProxyAPI key after failure.");
9992
+ } catch {
9993
+ log2.warn("Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove");
9994
+ }
9450
9995
  }
9996
+ throw mutationError;
9451
9997
  }
9452
- return {
9453
- title: "Upstream provider auth (anthropic)",
9454
- level: "warning",
9455
- summary: `Anthropic probe returned HTTP ${response.status}`,
9456
- details: details.length > 0 ? details : undefined
9457
- };
9458
- } catch (error) {
9459
- const message = error instanceof Error ? error.message : String(error);
9460
- return {
9461
- title: "Upstream provider auth (anthropic)",
9462
- level: "warning",
9463
- summary: `Anthropic probe failed: ${message}`,
9464
- details: verbose ? [`URL: ${endpoint}`, `Model: ${model}`] : undefined
9465
- };
9466
- }
9467
- }
9468
- async function probeManagementAuth(baseUrl, key) {
9469
- const endpoint = `${baseUrl}/v0/management/config`;
9470
- try {
9471
- const response = await fetch(endpoint, {
9472
- headers: managementHeaders(key),
9473
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
9474
- });
9475
- if (response.ok) {
9476
- return null;
9998
+ if (interactive) {
9999
+ prompts.outro(`Setup complete for ${plan.repo}. The ${plan.harness} harness can now use ${baseUrl}/v1.`);
10000
+ } else {
10001
+ log2.success(`Setup complete for ${plan.repo}.`);
9477
10002
  }
9478
- if (response.status === 403) {
9479
- const payload = await parseJsonResponse(response);
9480
- const isBanBody = payload !== null && typeof payload === "object" && Object.values(payload).some((v) => typeof v === "string" && /\b(?:ip[- ]?)?bann?ed\b/i.test(v));
9481
- if (isBanBody) {
10003
+ if (options.verifySmoke) {
10004
+ const smokeResult = await withSpinner("Running smoke test", async () => smoke.runSmokeTest(plan.repo, plan.template.variables.find((v) => v.name === "FRO_BOT_MODEL")?.value ?? "")).catch(async (error) => {
9482
10005
  return {
9483
- title: "Management access",
9484
- level: "error",
9485
- summary: "IP banned \u2014 stop retrying for ~30 min. Management checks skipped."
10006
+ kind: "unverified",
10007
+ message: `Smoke test error: ${extractErrorMessage4(error)}`,
10008
+ runUrl: undefined
9486
10009
  };
10010
+ });
10011
+ ctx.console.log(`[smoke-test] kind=${smokeResult.kind}`);
10012
+ switch (smokeResult.kind) {
10013
+ case "pass": {
10014
+ log2.success(`\u2713 ${smokeResult.message}${smokeResult.runUrl ? ` \u2014 ${smokeResult.runUrl}` : ""}`);
10015
+ break;
10016
+ }
10017
+ case "fail": {
10018
+ log2.warn(`\u2717 ${smokeResult.message}${smokeResult.runUrl ? ` \u2014 ${smokeResult.runUrl}` : ""}`);
10019
+ break;
10020
+ }
10021
+ case "unverified": {
10022
+ log2.warn(`\u26A0 ${smokeResult.message}${smokeResult.runUrl ? ` \u2014 ${smokeResult.runUrl}` : ""}`);
10023
+ break;
10024
+ }
10025
+ default: {
10026
+ const _exhaustive = smokeResult;
10027
+ throw new Error(`Unhandled SmokeResult kind: ${JSON.stringify(_exhaustive)}`);
10028
+ }
9487
10029
  }
9488
- return {
9489
- title: "Management access",
9490
- level: "error",
9491
- summary: "Management API returned HTTP 403. Management checks skipped."
9492
- };
9493
- }
9494
- if (response.status === 401) {
9495
- return {
9496
- title: "Management access",
9497
- level: "error",
9498
- summary: "Management API returned HTTP 401 (invalid key). Management checks skipped."
9499
- };
9500
10030
  }
9501
- return {
9502
- title: "Management access",
9503
- level: "warning",
9504
- summary: `Management auth probe returned HTTP ${response.status}. Management checks skipped.`
9505
- };
9506
10031
  } catch (error) {
9507
- const message = error instanceof Error ? error.message : String(error);
9508
- return {
9509
- title: "Management access",
9510
- level: "error",
9511
- summary: `Management auth probe failed: ${message}`
9512
- };
9513
- }
9514
- }
9515
- async function runManagementChecks(baseUrl, key) {
9516
- if (!key) {
9517
- return { kind: "no-key" };
9518
- }
9519
- const authFailure = await probeManagementAuth(baseUrl, key);
9520
- if (authFailure !== null) {
9521
- return { kind: "auth-failure", result: authFailure };
9522
- }
9523
- const [usage, version] = await Promise.all([checkUsageStats(baseUrl, key), checkVersion(baseUrl, key)]);
9524
- return { kind: "checks", usage, version };
9525
- }
9526
- function printCheckResult(result, ctx) {
9527
- ctx.console.log(`[${levelLabel(result.level)}] ${result.title}`);
9528
- ctx.console.log(` ${result.summary}`);
9529
- if (result.details && result.details.length > 0) {
9530
- for (const detail of result.details) {
9531
- ctx.console.log(` - ${detail}`);
9532
- }
9533
- }
9534
- }
9535
- function formatCheckSummary(result) {
9536
- return `${levelLabel(result.level)}: ${result.summary}`;
9537
- }
9538
- function formatUsageSummaryLine(result) {
9539
- const recentMatch = /recent:\s*(\d+)/.exec(result.summary);
9540
- if (!recentMatch) {
9541
- return null;
9542
- }
9543
- const total = Number(recentMatch[1]);
9544
- const errorMatch = /errors:\s*(\d+)/.exec(result.summary);
9545
- const errors2 = errorMatch ? Number(errorMatch[1]) : 0;
9546
- return `Recent requests: ${total}${errors2 > 0 ? `, ${errors2} errors` : ""}`;
9547
- }
9548
- async function getCliproxyStatusSummary(baseUrl, key, verbose) {
9549
- const normalizedBaseUrl = stripTrailingSlash5(baseUrl);
9550
- const [httpResult, mgmt] = await Promise.all([
9551
- checkHttpReachability(`${normalizedBaseUrl}/healthz`, verbose),
9552
- runManagementChecks(normalizedBaseUrl, key || undefined)
9553
- ]);
9554
- let version;
9555
- let usageStats;
9556
- if (mgmt.kind === "no-key") {
9557
- version = "\u2014 (no key)";
9558
- usageStats = "\u2014 (no key)";
9559
- } else if (mgmt.kind === "auth-failure") {
9560
- const authSummary = formatCheckSummary(mgmt.result);
9561
- version = authSummary;
9562
- usageStats = authSummary;
9563
- } else {
9564
- version = formatCheckSummary(mgmt.version);
9565
- usageStats = formatCheckSummary(mgmt.usage);
9566
- }
9567
- return {
9568
- app: "cliproxy",
9569
- http: formatCheckSummary(httpResult),
9570
- lastDeploy: "\u2014",
9571
- version,
9572
- contentHash: "\u2014",
9573
- usageStats
9574
- };
9575
- }
9576
- async function cliproxyStatusAction(options, ctx) {
9577
- let errorCount = 0;
9578
- try {
9579
- const verbose = options.verbose === true;
9580
- const baseUrl = stripTrailingSlash5(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
9581
- const trustedUrl = stripTrailingSlash5(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
9582
- const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl;
9583
- const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY);
9584
- const providerKey = options.apiKey ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY);
9585
- ctx.console.log("CLIProxyAPI status");
9586
- ctx.console.log("");
9587
- const results = [await checkHttpReachability(`${baseUrl}/healthz`, verbose)];
9588
- let capturedUsageResult;
9589
- const mgmt = await runManagementChecks(baseUrl, managementKey);
9590
- if (mgmt.kind === "no-key") {
9591
- results.push({
9592
- title: "Management checks",
9593
- level: "warning",
9594
- summary: "CLIPROXY_MANAGEMENT_KEY is not set. Skipping usage stats and version checks. Provide --key or set env var."
9595
- });
9596
- } else if (mgmt.kind === "auth-failure") {
9597
- results.push(mgmt.result);
9598
- } else {
9599
- capturedUsageResult = mgmt.usage;
9600
- results.push(mgmt.usage, mgmt.version);
9601
- }
9602
- if (providerKey) {
9603
- results.push(await checkProviderAuth(baseUrl, providerKey, { verbose }));
9604
- } else {
9605
- results.push({
9606
- title: "Upstream provider auth",
9607
- level: "warning",
9608
- summary: "CLIPROXY_API_KEY not set \u2014 skipping upstream provider probe."
9609
- });
9610
- }
9611
- for (const result of results) {
9612
- printCheckResult(result, ctx);
9613
- ctx.console.log("");
10032
+ const message = extractErrorMessage4(error);
10033
+ if (ctxInjected) {
10034
+ ctx.console.error(message);
9614
10035
  }
9615
- errorCount = results.filter((result) => result.level === "error").length;
9616
- const warningCount = results.filter((result) => result.level === "warning").length;
9617
- ctx.console.log(`Summary: ${results.length} checks, ${errorCount} errors, ${warningCount} warnings`);
9618
- if (capturedUsageResult) {
9619
- const usageLine = formatUsageSummaryLine(capturedUsageResult);
9620
- if (usageLine) {
9621
- ctx.console.log(usageLine);
9622
- }
10036
+ if (interactive) {
10037
+ cancel2(message);
9623
10038
  }
9624
- } catch (error) {
9625
- const message = error instanceof Error ? error.message : String(error);
9626
- ctx.console.error(message);
9627
- ctx.process.exit(1);
9628
- return;
9629
- }
9630
- if (errorCount > 0) {
9631
- ctx.process.exit(1);
10039
+ throw error;
9632
10040
  }
9633
10041
  }
9634
- function registerCliproxyStatus(cli) {
9635
- cli.command("cliproxy status", "Show operational health of CLIProxyAPI and its management endpoints.").option("--url [url]", z15.string().describe("Base URL for CLIProxyAPI health checks. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.")).option("--key [key]", z15.string().describe("Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.")).option("--api-key [key]", z15.string().describe("Downstream API key (bearer) for the upstream provider-auth probe. Falls back to CLIPROXY_API_KEY. Skipped when absent.")).option("--verbose", "Enable verbose output for all commands").action(cliproxyStatusAction);
10042
+ function registerCliproxySetup(cli) {
10043
+ cli.command("cliproxy setup", "Interactively onboard a GitHub repository to CLIProxyAPI by creating or reusing a key and wiring the required GitHub secrets and variables.").option("--key [key]", z16.string().describe("Existing CLIProxyAPI API key value. When provided, setup skips key creation and reuses this key for GitHub secrets.")).option("--repo [repo]", z16.string().describe("Target GitHub repository in owner/repo format. Skips the repository prompt when provided.")).option("--harness [harness]", harnessSchema.describe("Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.")).option("--providers [providers]", z16.string().describe("Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai")).option("--model [model]", z16.string().regex(MODEL_ID_RE2).describe("Override the default model. Must be provider-prefixed and lowercase. Required when multiple providers selected. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o")).option("--force", z16.boolean().optional().describe("Overwrite existing GitHub secrets and variables without prompting.")).option("--dry-run", z16.boolean().optional().describe("Print the plan without applying any changes.")).option("--verify-smoke", z16.boolean().optional().describe("Run a smoke test against the proxy after setup completes.")).option("--ack-key-reuse", z16.boolean().default(false).describe("Acknowledge that --key matches the bearer token inside the existing OPENCODE_AUTH_JSON. Required in non-interactive mode when --key is supplied for a repo with existing OPENCODE_AUTH_JSON.")).example("# Preview planned actions without applying any changes (no flags required)").example("infra cliproxy setup --dry-run").example("# Run the interactive onboarding wizard").example("infra cliproxy setup").example("# Run non-interactively with an existing key (anthropic-only)").example("infra cliproxy setup --repo owner/repo --harness opencode --key sk-existing --force").example("# Enable both providers non-interactively (requires --force and --model)").example("infra cliproxy setup --repo owner/repo --harness opencode --providers anthropic,openai --model openai/gpt-5.4-mini --key sk-existing --ack-key-reuse --force").action(async (options, ctx) => {
10044
+ await runSetupCommand(options, { ctx });
10045
+ });
9636
10046
  }
9637
10047
 
9638
10048
  // src/commands/cliproxy/index.ts
@@ -9643,12 +10053,13 @@ function registerCliproxyCommands(cli) {
9643
10053
  registerCliproxyKeys(cli);
9644
10054
  registerCliproxyLogin(cli);
9645
10055
  registerCliproxyModels(cli);
10056
+ registerCliproxyMonitor(cli);
9646
10057
  registerCliproxyOpen(cli);
9647
10058
  registerCliproxySetup(cli);
9648
10059
  }
9649
10060
 
9650
10061
  // src/commands/dashboard/deploy.ts
9651
- import { z as z16 } from "zod";
10062
+ import { z as z17 } from "zod";
9652
10063
  var REPO3 = "marcusrbrown/infra";
9653
10064
  var WORKFLOW_NAME3 = "Deploy Dashboard";
9654
10065
  var WORKFLOW_URL3 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-dashboard.yaml";
@@ -9710,7 +10121,7 @@ function validateDashboardRemotePreconditions() {
9710
10121
  }
9711
10122
  }
9712
10123
  function registerDashboardDeploy(cli) {
9713
- cli.command("dashboard deploy", "Deploy the dashboard. Default mode triggers the GitHub Deploy Dashboard workflow, while --local runs apps/dashboard deploy directly with Bun.").option("--local", z16.boolean().default(false).describe("Run local deployment with Bun using apps/dashboard instead of triggering GitHub Actions.")).option("--dry-run", z16.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).option("--image-version <version>", z16.string().optional().describe("CalVer version to deploy (e.g. 2026.06.47). Remote-only. Triggers the versioned release path and requires dashboard environment approval. Must match YYYY.MM.N. Forwarded as -f version=<value> to gh workflow run.")).option("--digest <digest>", z16.string().optional().describe("Expected sha256 digest (e.g. sha256:abc...def). Remote-only. Requires --image-version. When set, the workflow fails if GHCR resolves the tag to a different digest. Must match sha256:<64 hex chars>.")).example("# Trigger remote GitHub Actions deploy (default mode)").example("infra dashboard deploy").example("# Deploy a specific CalVer release via GitHub Actions").example("infra dashboard deploy --image-version 2026.06.47").example("# Deploy a specific release with digest cross-check").example("infra dashboard deploy --image-version 2026.06.47 --digest sha256:<64 hex>").example("# Validate local deploy preconditions and planned command").example("infra dashboard deploy --local --dry-run").example("# Run local deploy with explicit SSH agent context").example("infra dashboard deploy --local").action(async (options) => {
10124
+ cli.command("dashboard deploy", "Deploy the dashboard. Default mode triggers the GitHub Deploy Dashboard workflow, while --local runs apps/dashboard deploy directly with Bun.").option("--local", z17.boolean().default(false).describe("Run local deployment with Bun using apps/dashboard instead of triggering GitHub Actions.")).option("--dry-run", z17.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).option("--image-version <version>", z17.string().optional().describe("CalVer version to deploy (e.g. 2026.06.47). Remote-only. Triggers the versioned release path and requires dashboard environment approval. Must match YYYY.MM.N. Forwarded as -f version=<value> to gh workflow run.")).option("--digest <digest>", z17.string().optional().describe("Expected sha256 digest (e.g. sha256:abc...def). Remote-only. Requires --image-version. When set, the workflow fails if GHCR resolves the tag to a different digest. Must match sha256:<64 hex chars>.")).example("# Trigger remote GitHub Actions deploy (default mode)").example("infra dashboard deploy").example("# Deploy a specific CalVer release via GitHub Actions").example("infra dashboard deploy --image-version 2026.06.47").example("# Deploy a specific release with digest cross-check").example("infra dashboard deploy --image-version 2026.06.47 --digest sha256:<64 hex>").example("# Validate local deploy preconditions and planned command").example("infra dashboard deploy --local --dry-run").example("# Run local deploy with explicit SSH agent context").example("infra dashboard deploy --local").action(async (options) => {
9714
10125
  validateReleaseFlags({ imageVersion: options.imageVersion, digest: options.digest, local: options.local });
9715
10126
  if (options.local) {
9716
10127
  const command = ["bun", "run", "--cwd", "apps/dashboard", "deploy"];
@@ -9753,7 +10164,7 @@ function registerDashboardDeploy(cli) {
9753
10164
  }
9754
10165
 
9755
10166
  // src/commands/dashboard/logs.ts
9756
- import { z as z17 } from "zod";
10167
+ import { z as z18 } from "zod";
9757
10168
 
9758
10169
  // ../shared/server/droplet-helpers.ts
9759
10170
  import { appendFileSync, chmodSync as chmodSync2, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
@@ -9865,7 +10276,7 @@ async function streamDashboardLogs(opts, spawn = defaultLogsSpawn2, printOut, pr
9865
10276
  return { refused: false, exitCode };
9866
10277
  }
9867
10278
  function registerDashboardLogs(cli) {
9868
- cli.command("dashboard logs [service]", "Stream logs from a dashboard service via SSH and docker compose.").option("--tail [n]", z17.number().default(100).describe("Number of log lines to tail from each service.")).option("--allow-ci", z17.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive credentials.")).option("--key [key]", z17.string().describe("Environment variable name holding the SSH host. Falls back to DASHBOARD_DOMAIN when omitted.")).action(async (service, options) => {
10279
+ cli.command("dashboard logs [service]", "Stream logs from a dashboard service via SSH and docker compose.").option("--tail [n]", z18.number().default(100).describe("Number of log lines to tail from each service.")).option("--allow-ci", z18.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive credentials.")).option("--key [key]", z18.string().describe("Environment variable name holding the SSH host. Falls back to DASHBOARD_DOMAIN when omitted.")).action(async (service, options) => {
9869
10280
  const targetService = service ?? "dashboard";
9870
10281
  try {
9871
10282
  validateService(targetService);
@@ -9895,7 +10306,7 @@ function registerDashboardLogs(cli) {
9895
10306
  }
9896
10307
 
9897
10308
  // src/commands/dashboard/status.ts
9898
- import { z as z18 } from "zod";
10309
+ import { z as z19 } from "zod";
9899
10310
 
9900
10311
  // src/lib/redact.ts
9901
10312
  function redactHost(text4, host) {
@@ -10079,7 +10490,7 @@ async function dashboardStatusAction(options, ctx, spawn) {
10079
10490
  }
10080
10491
  }
10081
10492
  function registerDashboardStatus(cli) {
10082
- cli.command("dashboard status", "Show operational health of the dashboard deployment via docker compose ps.").option("--key [key]", z18.string().optional().describe("Environment variable name holding the SSH host. Falls back to DASHBOARD_DOMAIN when omitted.")).action((options, ctx) => dashboardStatusAction(options, ctx));
10493
+ cli.command("dashboard status", "Show operational health of the dashboard deployment via docker compose ps.").option("--key [key]", z19.string().optional().describe("Environment variable name holding the SSH host. Falls back to DASHBOARD_DOMAIN when omitted.")).action((options, ctx) => dashboardStatusAction(options, ctx));
10083
10494
  }
10084
10495
 
10085
10496
  // src/commands/dashboard/index.ts
@@ -10091,7 +10502,7 @@ function registerDashboardCommands(cli) {
10091
10502
 
10092
10503
  // src/commands/gateway/backup.ts
10093
10504
  import { closeSync, constants as fsConstants, openSync, renameSync, unlinkSync, writeSync } from "fs";
10094
- import { z as z19 } from "zod";
10505
+ import { z as z20 } from "zod";
10095
10506
 
10096
10507
  // src/commands/gateway/host.ts
10097
10508
  var VALID_HOST_RE4 = /^[a-z\d][a-z\d.\-]*$/i;
@@ -10201,11 +10612,11 @@ async function gatewayBackupAction(options, ctx, spawn) {
10201
10612
  ctx.console.log(`CA backup written to: ${output}`);
10202
10613
  }
10203
10614
  function registerGatewayBackup(cli) {
10204
- cli.command("gateway backup", "Back up the mitmproxy CA certificate and private key from the gateway Docker volume to a local tarball. The output contains the CA private trust anchor \u2014 treat it as sensitive material.").option("--output [file]", z19.string().default(DEFAULT_OUTPUT).describe("Local path to write the CA tarball. File permissions are set to 0600 after writing.")).option("--include-ca", z19.boolean().default(true).describe("Include the mitmproxy CA certificate and private key in the backup. Currently the only supported backup target.")).example("# Back up the CA to the default path").example("infra gateway backup --include-ca").example("# Back up the CA to a custom path").example("infra gateway backup --output /secure/backup/mitmproxy-ca.tar").action(gatewayBackupAction);
10615
+ cli.command("gateway backup", "Back up the mitmproxy CA certificate and private key from the gateway Docker volume to a local tarball. The output contains the CA private trust anchor \u2014 treat it as sensitive material.").option("--output [file]", z20.string().default(DEFAULT_OUTPUT).describe("Local path to write the CA tarball. File permissions are set to 0600 after writing.")).option("--include-ca", z20.boolean().default(true).describe("Include the mitmproxy CA certificate and private key in the backup. Currently the only supported backup target.")).example("# Back up the CA to the default path").example("infra gateway backup --include-ca").example("# Back up the CA to a custom path").example("infra gateway backup --output /secure/backup/mitmproxy-ca.tar").action(gatewayBackupAction);
10205
10616
  }
10206
10617
 
10207
10618
  // src/commands/gateway/deploy.ts
10208
- import { z as z20 } from "zod";
10619
+ import { z as z21 } from "zod";
10209
10620
  var REPO4 = "marcusrbrown/infra";
10210
10621
  var WORKFLOW_NAME4 = "Deploy Gateway";
10211
10622
  var WORKFLOW_URL4 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-gateway.yaml";
@@ -10270,7 +10681,7 @@ function validateGatewayRemotePreconditions() {
10270
10681
  }
10271
10682
  }
10272
10683
  function registerGatewayDeploy(cli) {
10273
- cli.command("gateway deploy", "Deploy the gateway. Default mode triggers the GitHub Deploy Gateway workflow, while --local runs apps/gateway deploy directly with Bun.").option("--local", z20.boolean().default(false).describe("Run local deployment with Bun using apps/gateway instead of triggering GitHub Actions.")).option("--dry-run", z20.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).option("--force-recreate", z20.boolean().default(false).describe("Force recreate containers. Forwarded only in local mode.")).example("# Trigger remote GitHub Actions deploy (default mode)").example("infra gateway deploy").example("# Validate local deploy preconditions and planned command").example("infra gateway deploy --local --dry-run").example("# Run local deploy with explicit SSH agent context").example("infra gateway deploy --local").action(async (options) => {
10684
+ cli.command("gateway deploy", "Deploy the gateway. Default mode triggers the GitHub Deploy Gateway workflow, while --local runs apps/gateway deploy directly with Bun.").option("--local", z21.boolean().default(false).describe("Run local deployment with Bun using apps/gateway instead of triggering GitHub Actions.")).option("--dry-run", z21.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).option("--force-recreate", z21.boolean().default(false).describe("Force recreate containers. Forwarded only in local mode.")).example("# Trigger remote GitHub Actions deploy (default mode)").example("infra gateway deploy").example("# Validate local deploy preconditions and planned command").example("infra gateway deploy --local --dry-run").example("# Run local deploy with explicit SSH agent context").example("infra gateway deploy --local").action(async (options) => {
10274
10685
  if (options.local) {
10275
10686
  const command = [
10276
10687
  "bun",
@@ -10318,7 +10729,7 @@ function registerGatewayDeploy(cli) {
10318
10729
  }
10319
10730
 
10320
10731
  // src/commands/gateway/logs.ts
10321
- import { z as z21 } from "zod";
10732
+ import { z as z22 } from "zod";
10322
10733
  var COMPOSE_PROJECT_DIR3 = "/opt/gateway/deploy";
10323
10734
  var SENSITIVE_WARNING4 = "Warning: Logs may contain Discord tokens, S3 credentials, or user data. Treat output as sensitive; do not capture in shared logs or chat.";
10324
10735
  var VALID_SERVICES2 = ["gateway", "workspace", "mitmproxy"];
@@ -10367,7 +10778,7 @@ async function streamGatewayLogs(opts, spawn = defaultLogsSpawn3, printOut, prin
10367
10778
  return { refused: false, exitCode };
10368
10779
  }
10369
10780
  function registerGatewayLogs(cli) {
10370
- cli.command("gateway logs [service]", "Stream logs from a gateway service via SSH and docker compose.").option("--tail [n]", z21.number().default(100).describe("Number of log lines to tail from each service.")).option("--allow-ci", z21.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive credentials.")).option("--key [key]", z21.string().describe("Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.")).action(async (service, options) => {
10781
+ cli.command("gateway logs [service]", "Stream logs from a gateway service via SSH and docker compose.").option("--tail [n]", z22.number().default(100).describe("Number of log lines to tail from each service.")).option("--allow-ci", z22.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive credentials.")).option("--key [key]", z22.string().describe("Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.")).action(async (service, options) => {
10371
10782
  const targetService = service ?? "gateway";
10372
10783
  try {
10373
10784
  validateService2(targetService);
@@ -10397,7 +10808,7 @@ function registerGatewayLogs(cli) {
10397
10808
  }
10398
10809
 
10399
10810
  // src/commands/gateway/restore.ts
10400
- import { z as z22 } from "zod";
10811
+ import { z as z23 } from "zod";
10401
10812
  var MITMPROXY_CERTS_VOLUME2 = "mitmproxy-certs";
10402
10813
  var CA_CERT_FILE2 = "mitmproxy-ca-cert.pem";
10403
10814
  var CA_KEY_FILE2 = "mitmproxy-ca.pem";
@@ -10551,7 +10962,7 @@ async function restoreGatewayCa(opts, spawn = defaultSpawn3) {
10551
10962
  }
10552
10963
  }
10553
10964
  function registerGatewayRestore(cli) {
10554
- cli.command("gateway restore", "Restore the mitmproxy CA certificate and private key from a local tarball into the gateway Docker volume. Restarts mitmproxy and gateway services after restore and confirms byte-equal content before reporting success.").option("--input <file>", z22.string().describe("Path to the tarball produced by `gateway backup --include-ca`. Required.")).option("--include-ca", z22.boolean().default(true).describe("Restore the mitmproxy CA certificate and private key. Currently the only supported restore target.")).example("# Restore CA from a backup tarball").example("infra gateway restore --input apps/gateway/.local/mitmproxy-ca.tar").example("# Restore CA from a custom path").example("infra gateway restore --input /secure/backup/mitmproxy-ca.tar --include-ca").action(async (options) => {
10965
+ cli.command("gateway restore", "Restore the mitmproxy CA certificate and private key from a local tarball into the gateway Docker volume. Restarts mitmproxy and gateway services after restore and confirms byte-equal content before reporting success.").option("--input <file>", z23.string().describe("Path to the tarball produced by `gateway backup --include-ca`. Required.")).option("--include-ca", z23.boolean().default(true).describe("Restore the mitmproxy CA certificate and private key. Currently the only supported restore target.")).example("# Restore CA from a backup tarball").example("infra gateway restore --input apps/gateway/.local/mitmproxy-ca.tar").example("# Restore CA from a custom path").example("infra gateway restore --input /secure/backup/mitmproxy-ca.tar --include-ca").action(async (options) => {
10555
10966
  const hostEnvKey = "GATEWAY_HOST";
10556
10967
  const host = process.env[hostEnvKey];
10557
10968
  if (!host) {
@@ -10572,7 +10983,7 @@ function registerGatewayRestore(cli) {
10572
10983
  }
10573
10984
 
10574
10985
  // src/commands/gateway/status.ts
10575
- import { z as z23 } from "zod";
10986
+ import { z as z24 } from "zod";
10576
10987
  var COMPOSE_PROJECT_DIR5 = "/opt/gateway/deploy";
10577
10988
  function parseComposePsOutput2(stdoutText) {
10578
10989
  const trimmed = stdoutText.trim();
@@ -10714,7 +11125,7 @@ async function gatewayStatusAction(options, ctx, spawn) {
10714
11125
  }
10715
11126
  }
10716
11127
  function registerGatewayStatus(cli) {
10717
- cli.command("gateway status", "Show operational health of the gateway deployment via docker compose ps.").option("--key [key]", z23.string().describe("Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.")).action((options, ctx) => gatewayStatusAction(options, ctx));
11128
+ cli.command("gateway status", "Show operational health of the gateway deployment via docker compose ps.").option("--key [key]", z24.string().describe("Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.")).action((options, ctx) => gatewayStatusAction(options, ctx));
10718
11129
  }
10719
11130
 
10720
11131
  // src/commands/gateway/index.ts
@@ -10729,7 +11140,7 @@ function registerGatewayCommands(cli) {
10729
11140
  // src/commands/keeweb/deploy.ts
10730
11141
  import { existsSync as existsSync5 } from "fs";
10731
11142
  import { join as join6 } from "path";
10732
- import { z as z24 } from "zod";
11143
+ import { z as z25 } from "zod";
10733
11144
  var REPO5 = "marcusrbrown/infra";
10734
11145
  var WORKFLOW_NAME5 = "Deploy KeeWeb";
10735
11146
  var WORKFLOW_URL5 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-keeweb.yaml";
@@ -10783,7 +11194,7 @@ function validateRemotePreconditions3() {
10783
11194
  }
10784
11195
  }
10785
11196
  function registerKeewebDeploy(cli) {
10786
- cli.command("keeweb deploy", "Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy KeeWeb workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.").option("--local", z24.boolean().default(false).describe("Run local deployment using apps/keeweb/deploy.sh instead of triggering GitHub Actions.")).option("--nginx", z24.boolean().default(false).describe("Include nginx config deployment. Valid only with --local and passed through to deploy.sh as --nginx.")).option("--dry-run", z24.boolean().default(false).describe("Print planned actions without validating preconditions, executing deploy.sh, or triggering GitHub Actions.")).example("# Trigger GitHub Deploy KeeWeb workflow (default mode)").example("infra keeweb deploy").example("# Preview local deploy plan without side effects").example("infra keeweb deploy --local --dry-run").example("# Run local deploy including nginx config update").example("infra keeweb deploy --local --nginx").action(async (options) => {
11197
+ cli.command("keeweb deploy", "Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy KeeWeb workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.").option("--local", z25.boolean().default(false).describe("Run local deployment using apps/keeweb/deploy.sh instead of triggering GitHub Actions.")).option("--nginx", z25.boolean().default(false).describe("Include nginx config deployment. Valid only with --local and passed through to deploy.sh as --nginx.")).option("--dry-run", z25.boolean().default(false).describe("Print planned actions without validating preconditions, executing deploy.sh, or triggering GitHub Actions.")).example("# Trigger GitHub Deploy KeeWeb workflow (default mode)").example("infra keeweb deploy").example("# Preview local deploy plan without side effects").example("infra keeweb deploy --local --dry-run").example("# Run local deploy including nginx config update").example("infra keeweb deploy --local --nginx").action(async (options) => {
10787
11198
  if (options.nginx && !options.local) {
10788
11199
  throw new Error("--nginx is only valid with --local");
10789
11200
  }
@@ -10862,13 +11273,13 @@ function registerKeewebOpen(cli) {
10862
11273
 
10863
11274
  // src/commands/keeweb/status.ts
10864
11275
  import { join as join7 } from "path";
10865
- import { z as z25 } from "zod";
11276
+ import { z as z26 } from "zod";
10866
11277
  var SITE_URL = "https://kw.igg.ms/";
10867
11278
  var GH_REPO = "marcusrbrown/infra";
10868
11279
  var HTTP_TIMEOUT_MS4 = 1e4;
10869
- var ghRunSchema = z25.array(z25.object({
10870
- createdAt: z25.string().min(1),
10871
- url: z25.url()
11280
+ var ghRunSchema = z26.array(z26.object({
11281
+ createdAt: z26.string().min(1),
11282
+ url: z26.url()
10872
11283
  }));
10873
11284
  function levelLabel2(level) {
10874
11285
  if (level === "ok") {
@@ -11166,10 +11577,10 @@ function registerMcp(cli) {
11166
11577
  }
11167
11578
 
11168
11579
  // src/commands/status.ts
11169
- import { z as z34 } from "zod";
11580
+ import { z as z35 } from "zod";
11170
11581
 
11171
11582
  // src/commands/umami/deploy.ts
11172
- import { z as z26 } from "zod";
11583
+ import { z as z27 } from "zod";
11173
11584
  var REPO6 = "marcusrbrown/infra";
11174
11585
  var WORKFLOW_NAME6 = "Deploy Umami";
11175
11586
  var WORKFLOW_URL6 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-umami.yaml";
@@ -11204,7 +11615,7 @@ function validateUmamiRemotePreconditions() {
11204
11615
  }
11205
11616
  }
11206
11617
  function registerUmamiDeploy(cli) {
11207
- 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", z26.boolean().default(false).describe("Run local deployment with Bun using apps/umami instead of triggering GitHub Actions.")).option("--dry-run", z26.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) => {
11618
+ 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", z27.boolean().default(false).describe("Run local deployment with Bun using apps/umami instead of triggering GitHub Actions.")).option("--dry-run", z27.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) => {
11208
11619
  if (options.local) {
11209
11620
  const command = ["bun", "run", "--cwd", "apps/umami", "deploy"];
11210
11621
  if (options.dryRun) {
@@ -11245,7 +11656,7 @@ function registerUmamiDeploy(cli) {
11245
11656
  }
11246
11657
 
11247
11658
  // src/commands/umami/logs.ts
11248
- import { z as z27 } from "zod";
11659
+ import { z as z28 } from "zod";
11249
11660
 
11250
11661
  // src/commands/umami/host.ts
11251
11662
  var VALID_HOST_RE5 = /^[a-z\d][a-z\d.\-]*$/i;
@@ -11309,7 +11720,7 @@ async function streamUmamiLogs(opts, spawn = defaultLogsSpawn4, printOut, printE
11309
11720
  return { refused: false, exitCode };
11310
11721
  }
11311
11722
  function registerUmamiLogs(cli) {
11312
- cli.command("umami logs [service]", "Stream logs from an Umami service via SSH and docker compose.").option("--tail [n]", z27.number().default(100).describe("Number of log lines to tail from each service.")).option("--allow-ci", z27.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive credentials.")).option("--key [key]", z27.string().describe("Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.")).action(async (service, options) => {
11723
+ cli.command("umami logs [service]", "Stream logs from an Umami service via SSH and docker compose.").option("--tail [n]", z28.number().default(100).describe("Number of log lines to tail from each service.")).option("--allow-ci", z28.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive credentials.")).option("--key [key]", z28.string().describe("Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.")).action(async (service, options) => {
11313
11724
  const targetService = service ?? "umami";
11314
11725
  try {
11315
11726
  validateService3(targetService);
@@ -11339,7 +11750,7 @@ function registerUmamiLogs(cli) {
11339
11750
  }
11340
11751
 
11341
11752
  // src/commands/umami/status.ts
11342
- import { z as z28 } from "zod";
11753
+ import { z as z29 } from "zod";
11343
11754
  var COMPOSE_PROJECT_DIR7 = "/opt/umami";
11344
11755
  function parseComposePsOutput3(stdoutText) {
11345
11756
  const trimmed = stdoutText.trim();
@@ -11510,7 +11921,7 @@ async function umamiStatusAction(options, ctx, spawn) {
11510
11921
  }
11511
11922
  }
11512
11923
  function registerUmamiStatus(cli) {
11513
- cli.command("umami status", "Show operational health of the Umami analytics deployment via docker compose ps.").option("--key [key]", z28.string().optional().describe("Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.")).action((options, ctx) => umamiStatusAction(options, ctx));
11924
+ cli.command("umami status", "Show operational health of the Umami analytics deployment via docker compose ps.").option("--key [key]", z29.string().optional().describe("Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.")).action((options, ctx) => umamiStatusAction(options, ctx));
11514
11925
  }
11515
11926
 
11516
11927
  // src/commands/umami/index.ts
@@ -11523,24 +11934,24 @@ function registerUmamiCommands(cli) {
11523
11934
  // src/commands/vpn/client.ts
11524
11935
  import { mkdir, writeFile as writeFile2 } from "fs/promises";
11525
11936
  import { join as join8, resolve as resolve2 } from "path";
11526
- import { z as z30 } from "zod";
11937
+ import { z as z31 } from "zod";
11527
11938
 
11528
11939
  // src/commands/vpn/peers.ts
11529
11940
  import { readFile, writeFile } from "fs/promises";
11530
- import { z as z29 } from "zod";
11531
- var peerSchema = z29.object({
11532
- name: z29.string(),
11533
- publicKey: z29.string(),
11534
- tunnelIp: z29.string()
11941
+ import { z as z30 } from "zod";
11942
+ var peerSchema = z30.object({
11943
+ name: z30.string(),
11944
+ publicKey: z30.string(),
11945
+ tunnelIp: z30.string()
11535
11946
  });
11536
- var peersFileSchema = z29.object({
11537
- peers: z29.array(peerSchema)
11947
+ var peersFileSchema = z30.object({
11948
+ peers: z30.array(peerSchema)
11538
11949
  }).superRefine((data, ctx) => {
11539
11950
  const seenIps = new Set;
11540
11951
  for (const peer of data.peers) {
11541
11952
  if (seenIps.has(peer.tunnelIp)) {
11542
11953
  ctx.addIssue({
11543
- code: z29.ZodIssueCode.custom,
11954
+ code: z30.ZodIssueCode.custom,
11544
11955
  message: `Duplicate tunnel IP "${peer.tunnelIp}" \u2014 each peer must have a unique tunnel IP.`,
11545
11956
  path: ["peers"]
11546
11957
  });
@@ -11552,7 +11963,7 @@ var peersFileSchema = z29.object({
11552
11963
  for (const peer of data.peers) {
11553
11964
  if (seenNames.has(peer.name)) {
11554
11965
  ctx.addIssue({
11555
- code: z29.ZodIssueCode.custom,
11966
+ code: z30.ZodIssueCode.custom,
11556
11967
  message: `Duplicate name "${peer.name}" \u2014 each peer must have a unique name.`,
11557
11968
  path: ["peers"]
11558
11969
  });
@@ -11794,7 +12205,7 @@ function resolveDefaultClientsDir() {
11794
12205
  return join8(findRepoRoot(), "apps", "vpn", "clients");
11795
12206
  }
11796
12207
  function registerVpnClient(cli) {
11797
- 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]", z30.string().describe("Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).")).option("--dns [dns]", z30.string().describe("DNS server for the client config.")).option("--server-pubkey [key]", z30.string().describe("Server public key. Falls back to VPN_SERVER_PUBKEY env var.")).option("--endpoint [host]", z30.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) => {
12208
+ 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]", z31.string().describe("Comma-separated CIDRs for split-tunnel routing. Defaults to 0.0.0.0/0 (full tunnel).")).option("--dns [dns]", z31.string().describe("DNS server for the client config.")).option("--server-pubkey [key]", z31.string().describe("Server public key. Falls back to VPN_SERVER_PUBKEY env var.")).option("--endpoint [host]", z31.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) => {
11798
12209
  const serverPublicKey = options.serverPubkey ?? process.env.VPN_SERVER_PUBKEY;
11799
12210
  const endpoint = options.endpoint ?? process.env.VPN_HOST;
11800
12211
  if (!serverPublicKey) {
@@ -11858,7 +12269,7 @@ function registerVpnClient(cli) {
11858
12269
  }
11859
12270
 
11860
12271
  // src/commands/vpn/deploy.ts
11861
- import { z as z31 } from "zod";
12272
+ import { z as z32 } from "zod";
11862
12273
  var REPO7 = "marcusrbrown/infra";
11863
12274
  var WORKFLOW_NAME7 = "Deploy VPN";
11864
12275
  var WORKFLOW_URL7 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-vpn.yaml";
@@ -11888,7 +12299,7 @@ function validateVpnRemotePreconditions() {
11888
12299
  }
11889
12300
  }
11890
12301
  function registerVpnDeploy(cli) {
11891
- 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", z31.boolean().default(false).describe("Run local deployment with Bun using apps/vpn instead of triggering GitHub Actions.")).option("--dry-run", z31.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).option("--force-server-key", z31.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) => {
12302
+ 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", z32.boolean().default(false).describe("Run local deployment with Bun using apps/vpn instead of triggering GitHub Actions.")).option("--dry-run", z32.boolean().default(false).describe("Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.")).option("--force-server-key", z32.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) => {
11892
12303
  if (options.local) {
11893
12304
  const command = [
11894
12305
  "bun",
@@ -11936,7 +12347,7 @@ function registerVpnDeploy(cli) {
11936
12347
  }
11937
12348
 
11938
12349
  // src/commands/vpn/logs.ts
11939
- import { z as z32 } from "zod";
12350
+ import { z as z33 } from "zod";
11940
12351
 
11941
12352
  // src/commands/vpn/host.ts
11942
12353
  var VALID_HOST_RE6 = /^[a-z\d][a-z\d.\-]*$/i;
@@ -11992,7 +12403,7 @@ async function streamVpnLogs(opts, spawn = defaultLogsSpawn5, printOut, printErr
11992
12403
  return { refused: false, exitCode };
11993
12404
  }
11994
12405
  function registerVpnLogs(cli) {
11995
- cli.command("vpn logs", "Stream WireGuard VPN logs via SSH and journalctl.").option("--tail [n]", z32.number().default(100).describe("Number of log lines to tail.")).option("--allow-ci", z32.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive VPN data.")).option("--key [key]", z32.string().describe("Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.")).action(async (options) => {
12406
+ cli.command("vpn logs", "Stream WireGuard VPN logs via SSH and journalctl.").option("--tail [n]", z33.number().default(100).describe("Number of log lines to tail.")).option("--allow-ci", z33.boolean().default(false).describe("Allow log streaming in CI environments. Logs may contain sensitive VPN data.")).option("--key [key]", z33.string().describe("Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.")).action(async (options) => {
11996
12407
  const hostEnvKey = options.key ?? "VPN_HOST";
11997
12408
  const host = process.env[hostEnvKey];
11998
12409
  if (!host) {
@@ -12014,7 +12425,7 @@ function registerVpnLogs(cli) {
12014
12425
  }
12015
12426
 
12016
12427
  // src/commands/vpn/status.ts
12017
- import { z as z33 } from "zod";
12428
+ import { z as z34 } from "zod";
12018
12429
 
12019
12430
  // src/commands/vpn/shared.ts
12020
12431
  function parseWgShowOutput(output) {
@@ -12141,7 +12552,7 @@ async function vpnStatusAction(options, ctx, spawn) {
12141
12552
  }
12142
12553
  }
12143
12554
  function registerVpnStatus(cli) {
12144
- cli.command("vpn status", "Show operational health of the VPN box via SSH and wg show.").option("--key [key]", z33.string().describe("Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.")).action((options, ctx) => vpnStatusAction(options, ctx));
12555
+ cli.command("vpn status", "Show operational health of the VPN box via SSH and wg show.").option("--key [key]", z34.string().describe("Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.")).action((options, ctx) => vpnStatusAction(options, ctx));
12145
12556
  }
12146
12557
 
12147
12558
  // src/commands/vpn/index.ts
@@ -12153,7 +12564,7 @@ function registerVpnCommands(cli) {
12153
12564
  }
12154
12565
 
12155
12566
  // src/commands/status.ts
12156
- var DEFAULT_CLIPROXY_URL7 = "https://cliproxy.fro.bot";
12567
+ var DEFAULT_CLIPROXY_URL8 = "https://cliproxy.fro.bot";
12157
12568
  var ERROR_PREFIX = "\u274C";
12158
12569
  var TABLE_COLUMNS = [
12159
12570
  { key: "app", label: "App" },
@@ -12203,7 +12614,7 @@ async function unifiedStatusAction(options, ctx, dependencies = {
12203
12614
  getBrokerStatusSummary
12204
12615
  }) {
12205
12616
  const verbose = options.verbose === true;
12206
- const cliproxyBaseUrl = stripTrailingSlash6(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL7);
12617
+ const cliproxyBaseUrl = stripTrailingSlash6(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL8);
12207
12618
  const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? "";
12208
12619
  const gatewayHost = process.env.GATEWAY_HOST ?? "";
12209
12620
  const umamiHost = process.env.UMAMI_DOMAIN ?? "";
@@ -12248,7 +12659,7 @@ function registerStatus(cli, dependencies = {
12248
12659
  getVpnStatusSummary,
12249
12660
  getBrokerStatusSummary
12250
12661
  }) {
12251
- cli.command("status", "Show status of all deployments").option("--json", z34.boolean().describe("Output machine-readable JSON with keeweb, cliproxy, gateway, umami, dashboard, vpn, and broker summary objects.")).option("--verbose", z34.boolean().describe("Include verbose per-app health check details when building the summary rows.")).action((options, ctx) => unifiedStatusAction(options, ctx, dependencies));
12662
+ cli.command("status", "Show status of all deployments").option("--json", z35.boolean().describe("Output machine-readable JSON with keeweb, cliproxy, gateway, umami, dashboard, vpn, and broker summary objects.")).option("--verbose", z35.boolean().describe("Include verbose per-app health check details when building the summary rows.")).action((options, ctx) => unifiedStatusAction(options, ctx, dependencies));
12252
12663
  }
12253
12664
 
12254
12665
  // src/cli.ts