@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.
- package/README.md +12 -10
- package/dist/cli.js +1959 -1548
- 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.
|
|
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
|
-
|
|
7143
|
-
|
|
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/
|
|
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
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
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
|
-
|
|
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
|
|
7981
|
+
return "ERROR";
|
|
8033
7982
|
}
|
|
8034
|
-
function
|
|
8035
|
-
|
|
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
|
|
8042
|
-
|
|
8043
|
-
process.exit(0);
|
|
7986
|
+
function stripTrailingSlash4(value) {
|
|
7987
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
8044
7988
|
}
|
|
8045
|
-
async function
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
|
|
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
|
|
8058
|
-
return
|
|
7996
|
+
function toNumber(value) {
|
|
7997
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
8059
7998
|
}
|
|
8060
|
-
async function
|
|
8061
|
-
const
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
}
|
|
8071
|
-
|
|
8072
|
-
|
|
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
|
-
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
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
|
-
|
|
8101
|
-
|
|
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
|
-
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
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
|
-
|
|
8134
|
-
|
|
8135
|
-
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
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
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
|
|
8167
|
-
|
|
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
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
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
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
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
|
-
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
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
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8337
|
-
|
|
8338
|
-
|
|
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
|
|
8343
|
-
|
|
8344
|
-
|
|
8345
|
-
const
|
|
8346
|
-
|
|
8347
|
-
|
|
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 (
|
|
8356
|
-
|
|
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
|
-
|
|
8359
|
-
|
|
8360
|
-
|
|
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
|
-
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
|
|
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
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
const
|
|
8392
|
-
|
|
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
|
-
|
|
8415
|
-
|
|
8416
|
-
|
|
8417
|
-
|
|
8418
|
-
|
|
8419
|
-
|
|
8420
|
-
|
|
8421
|
-
|
|
8422
|
-
|
|
8423
|
-
|
|
8424
|
-
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
|
|
8428
|
-
|
|
8429
|
-
"
|
|
8430
|
-
|
|
8431
|
-
|
|
8432
|
-
|
|
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
|
-
|
|
8455
|
-
|
|
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
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
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 ===
|
|
8464
|
-
|
|
8465
|
-
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8493
|
-
|
|
8494
|
-
|
|
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
|
-
|
|
8504
|
-
|
|
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
|
|
8511
|
-
const
|
|
8512
|
-
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
|
|
8519
|
-
|
|
8520
|
-
}
|
|
8521
|
-
|
|
8522
|
-
|
|
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
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
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
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
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
|
-
|
|
8535
|
-
|
|
8536
|
-
|
|
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
|
-
|
|
8274
|
+
} catch (error) {
|
|
8275
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8553
8276
|
return {
|
|
8554
|
-
|
|
8555
|
-
|
|
8556
|
-
|
|
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
|
-
|
|
8564
|
-
|
|
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
|
-
|
|
8568
|
-
|
|
8569
|
-
|
|
8570
|
-
|
|
8571
|
-
|
|
8336
|
+
app: "cliproxy",
|
|
8337
|
+
http: formatCheckSummary(httpResult),
|
|
8338
|
+
lastDeploy: "\u2014",
|
|
8339
|
+
version,
|
|
8340
|
+
contentHash: "\u2014",
|
|
8341
|
+
usageStats
|
|
8572
8342
|
};
|
|
8573
8343
|
}
|
|
8574
|
-
function
|
|
8575
|
-
|
|
8576
|
-
|
|
8577
|
-
|
|
8578
|
-
|
|
8579
|
-
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
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
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
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
|
-
|
|
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/
|
|
8596
|
-
|
|
8597
|
-
var
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
var
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
var
|
|
8605
|
-
var
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
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
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
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
|
|
8622
|
-
|
|
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
|
-
|
|
8625
|
-
|
|
8626
|
-
|
|
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
|
-
|
|
8629
|
-
|
|
8630
|
-
|
|
8631
|
-
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
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
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
8644
|
-
|
|
8645
|
-
|
|
8646
|
-
|
|
8647
|
-
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
|
|
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
|
-
|
|
8655
|
-
|
|
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
|
-
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
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
|
-
|
|
8664
|
-
|
|
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
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
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
|
-
|
|
8673
|
-
|
|
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
|
-
|
|
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
|
|
8678
|
-
|
|
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
|
-
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
8685
|
-
|
|
8686
|
-
|
|
8687
|
-
|
|
8688
|
-
|
|
8689
|
-
|
|
8690
|
-
const
|
|
8691
|
-
if (!
|
|
8692
|
-
|
|
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
|
-
|
|
8698
|
-
|
|
8699
|
-
const
|
|
8700
|
-
|
|
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
|
-
|
|
8706
|
-
|
|
8707
|
-
|
|
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
|
-
|
|
8710
|
-
|
|
8711
|
-
}
|
|
8712
|
-
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
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
|
-
|
|
8827
|
+
stdin: "inherit",
|
|
8828
|
+
stdout: "inherit",
|
|
8829
|
+
stderr: "inherit"
|
|
8719
8830
|
});
|
|
8720
|
-
|
|
8721
|
-
|
|
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
|
-
|
|
8728
|
-
}
|
|
8835
|
+
});
|
|
8729
8836
|
}
|
|
8730
8837
|
|
|
8731
|
-
// src/commands/cliproxy/setup
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
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
|
|
8852
|
+
return trimmed;
|
|
8761
8853
|
}
|
|
8762
|
-
function
|
|
8763
|
-
const
|
|
8764
|
-
if (
|
|
8765
|
-
|
|
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
|
-
|
|
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
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
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
|
-
|
|
8789
|
-
const
|
|
8790
|
-
|
|
8791
|
-
|
|
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
|
|
8798
|
-
|
|
8799
|
-
|
|
8800
|
-
|
|
8801
|
-
|
|
8802
|
-
|
|
8803
|
-
|
|
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
|
-
|
|
8809
|
-
|
|
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
|
|
8912
|
+
async function withSpinner(message, run) {
|
|
8913
|
+
const spinnerInstance = spinner();
|
|
8914
|
+
spinnerInstance.start(message);
|
|
8813
8915
|
try {
|
|
8814
|
-
|
|
8815
|
-
|
|
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
|
|
8818
|
-
const
|
|
8819
|
-
|
|
8820
|
-
|
|
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
|
|
8823
|
-
|
|
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
|
|
8870
|
-
return
|
|
8940
|
+
function isGhRateLimitError(text2) {
|
|
8941
|
+
return /rate limit/i.test(text2);
|
|
8871
8942
|
}
|
|
8872
|
-
function
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
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
|
|
8878
|
-
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
8894
|
-
|
|
8895
|
-
|
|
8896
|
-
|
|
8897
|
-
|
|
8898
|
-
|
|
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
|
|
8924
|
-
|
|
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
|
|
8927
|
-
const
|
|
8928
|
-
|
|
8929
|
-
|
|
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
|
-
|
|
8940
|
-
|
|
8941
|
-
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
8945
|
-
|
|
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
|
|
8949
|
-
|
|
8950
|
-
|
|
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
|
-
|
|
8962
|
-
|
|
8963
|
-
|
|
8964
|
-
|
|
8965
|
-
|
|
8966
|
-
|
|
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
|
-
|
|
9004
|
-
|
|
9005
|
-
const
|
|
9006
|
-
const
|
|
9007
|
-
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
const
|
|
9011
|
-
|
|
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
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
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
|
-
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
|
|
9036
|
-
|
|
9037
|
-
|
|
9038
|
-
|
|
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
|
|
9041
|
-
if (
|
|
9042
|
-
|
|
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
|
-
|
|
9045
|
-
|
|
9046
|
-
|
|
9047
|
-
|
|
9048
|
-
|
|
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
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
9055
|
-
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
|
|
9065
|
-
|
|
9066
|
-
|
|
9067
|
-
|
|
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
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
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
|
-
|
|
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
|
|
9087
|
-
if (
|
|
9088
|
-
|
|
9089
|
-
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9093
|
-
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
|
|
9098
|
-
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
|
|
9103
|
-
|
|
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
|
-
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
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
|
-
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
|
|
9127
|
-
|
|
9128
|
-
|
|
9129
|
-
|
|
9130
|
-
|
|
9131
|
-
|
|
9132
|
-
|
|
9133
|
-
|
|
9134
|
-
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
|
|
9138
|
-
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
9143
|
-
|
|
9144
|
-
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
|
|
9154
|
-
|
|
9155
|
-
|
|
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
|
|
9168
|
-
|
|
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
|
-
|
|
9171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9307
|
+
return { kind: "fail", message: `Run completed with conclusion=${conclusion ?? "unknown"}`, runUrl };
|
|
9177
9308
|
}
|
|
9178
|
-
|
|
9179
|
-
|
|
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
|
-
|
|
9343
|
+
throw new Error("model required when multiple providers selected");
|
|
9182
9344
|
}
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
}
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
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
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
9363
|
+
],
|
|
9364
|
+
variables: [
|
|
9365
|
+
{
|
|
9366
|
+
name: "FRO_BOT_MODEL",
|
|
9367
|
+
value: model
|
|
9204
9368
|
}
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
|
|
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
|
-
|
|
9212
|
-
|
|
9213
|
-
|
|
9214
|
-
ctx.console.error(message);
|
|
9663
|
+
if (readbackFailed) {
|
|
9664
|
+
safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames));
|
|
9665
|
+
return;
|
|
9215
9666
|
}
|
|
9216
|
-
|
|
9217
|
-
|
|
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
|
-
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
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
|
|
9241
|
-
return
|
|
9689
|
+
function extractErrorMessage4(error) {
|
|
9690
|
+
return error instanceof Error ? error.message : String(error);
|
|
9242
9691
|
}
|
|
9243
|
-
function
|
|
9244
|
-
|
|
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
|
|
9247
|
-
|
|
9248
|
-
|
|
9249
|
-
|
|
9250
|
-
|
|
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
|
|
9254
|
-
return
|
|
9743
|
+
function requiresDestructiveProviderChangeConfirmation(providers) {
|
|
9744
|
+
return !(providers.length === 1 && providers[0] === "anthropic");
|
|
9255
9745
|
}
|
|
9256
|
-
async function
|
|
9257
|
-
const
|
|
9258
|
-
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
|
|
9266
|
-
|
|
9267
|
-
|
|
9268
|
-
|
|
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
|
-
|
|
9282
|
-
|
|
9283
|
-
|
|
9284
|
-
|
|
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
|
-
|
|
9289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
|
|
9325
|
-
|
|
9326
|
-
|
|
9327
|
-
|
|
9328
|
-
|
|
9329
|
-
|
|
9330
|
-
|
|
9331
|
-
|
|
9332
|
-
|
|
9333
|
-
|
|
9334
|
-
|
|
9335
|
-
|
|
9336
|
-
|
|
9337
|
-
|
|
9338
|
-
|
|
9339
|
-
|
|
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
|
-
|
|
9345
|
-
|
|
9346
|
-
|
|
9347
|
-
|
|
9348
|
-
|
|
9349
|
-
}
|
|
9350
|
-
|
|
9351
|
-
|
|
9352
|
-
|
|
9353
|
-
|
|
9354
|
-
|
|
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
|
-
|
|
9362
|
-
|
|
9363
|
-
|
|
9853
|
+
await withSpinner("Checking GitHub CLI availability", async () => {
|
|
9854
|
+
await gh.assertGhInstalled();
|
|
9855
|
+
await gh.assertGhAuthenticated();
|
|
9364
9856
|
});
|
|
9365
|
-
|
|
9366
|
-
|
|
9367
|
-
|
|
9368
|
-
|
|
9369
|
-
|
|
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
|
-
|
|
9373
|
-
|
|
9374
|
-
|
|
9375
|
-
|
|
9376
|
-
|
|
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
|
-
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
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
|
-
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9399
|
-
|
|
9400
|
-
|
|
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
|
-
|
|
9422
|
-
|
|
9423
|
-
|
|
9424
|
-
|
|
9425
|
-
|
|
9426
|
-
|
|
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
|
-
|
|
9430
|
-
|
|
9431
|
-
|
|
9432
|
-
|
|
9433
|
-
|
|
9434
|
-
|
|
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
|
-
|
|
9438
|
-
|
|
9439
|
-
|
|
9440
|
-
|
|
9441
|
-
|
|
9442
|
-
|
|
9443
|
-
|
|
9444
|
-
|
|
9445
|
-
|
|
9446
|
-
|
|
9447
|
-
|
|
9448
|
-
|
|
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
|
-
|
|
9453
|
-
|
|
9454
|
-
|
|
9455
|
-
|
|
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 (
|
|
9479
|
-
const
|
|
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
|
-
|
|
9484
|
-
|
|
9485
|
-
|
|
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 =
|
|
9508
|
-
|
|
9509
|
-
|
|
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
|
-
|
|
9616
|
-
|
|
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
|
-
|
|
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
|
|
9635
|
-
cli.command("cliproxy
|
|
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
|
|
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",
|
|
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
|
|
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]",
|
|
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
|
|
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]",
|
|
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
|
|
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]",
|
|
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
|
|
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",
|
|
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
|
|
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]",
|
|
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
|
|
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>",
|
|
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
|
|
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]",
|
|
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
|
|
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",
|
|
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
|
|
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 =
|
|
10870
|
-
createdAt:
|
|
10871
|
-
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
|
|
11580
|
+
import { z as z35 } from "zod";
|
|
11170
11581
|
|
|
11171
11582
|
// src/commands/umami/deploy.ts
|
|
11172
|
-
import { z as
|
|
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",
|
|
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
|
|
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]",
|
|
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
|
|
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]",
|
|
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
|
|
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
|
|
11531
|
-
var peerSchema =
|
|
11532
|
-
name:
|
|
11533
|
-
publicKey:
|
|
11534
|
-
tunnelIp:
|
|
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 =
|
|
11537
|
-
peers:
|
|
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:
|
|
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:
|
|
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]",
|
|
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
|
|
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",
|
|
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
|
|
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]",
|
|
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
|
|
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]",
|
|
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
|
|
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 ??
|
|
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",
|
|
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
|