@marcusrbrown/infra 0.13.22 → 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 +1955 -1545
- 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",
|
|
@@ -7965,1675 +7965,2084 @@ function registerCliproxyModels(cli) {
|
|
|
7965
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));
|
|
7966
7966
|
}
|
|
7967
7967
|
|
|
7968
|
-
// src/commands/cliproxy/
|
|
7968
|
+
// src/commands/cliproxy/monitor.ts
|
|
7969
|
+
import { z as z10 } from "zod";
|
|
7970
|
+
|
|
7971
|
+
// src/commands/cliproxy/status.ts
|
|
7969
7972
|
import { z as z9 } from "zod";
|
|
7970
|
-
var
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
if (!host) {
|
|
7975
|
-
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";
|
|
7976
7977
|
}
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
function registerCliproxyOpen(cli) {
|
|
7980
|
-
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) => {
|
|
7981
|
-
const host = validateCliproxyHost(resolveHost2(options.host));
|
|
7982
|
-
if (!process.stdin.isTTY) {
|
|
7983
|
-
throw new Error("cliproxy open requires an interactive terminal. Run from a shell with TTY attached.");
|
|
7984
|
-
}
|
|
7985
|
-
const path = process.env.PATH;
|
|
7986
|
-
const home = process.env.HOME;
|
|
7987
|
-
if (!path) {
|
|
7988
|
-
throw new Error("PATH is required to invoke ssh");
|
|
7989
|
-
}
|
|
7990
|
-
if (!home) {
|
|
7991
|
-
throw new Error("HOME is required to invoke ssh");
|
|
7992
|
-
}
|
|
7993
|
-
const remoteCommand = "cd /opt/cliproxy && docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --tui";
|
|
7994
|
-
const child = Bun.spawn([
|
|
7995
|
-
"ssh",
|
|
7996
|
-
"-tt",
|
|
7997
|
-
"-o",
|
|
7998
|
-
"BatchMode=yes",
|
|
7999
|
-
"-o",
|
|
8000
|
-
"ConnectTimeout=10",
|
|
8001
|
-
`${DEFAULT_REMOTE_USER2}@${host}`,
|
|
8002
|
-
remoteCommand
|
|
8003
|
-
], {
|
|
8004
|
-
env: {
|
|
8005
|
-
PATH: path,
|
|
8006
|
-
HOME: home
|
|
8007
|
-
},
|
|
8008
|
-
stdin: "inherit",
|
|
8009
|
-
stdout: "inherit",
|
|
8010
|
-
stderr: "inherit"
|
|
8011
|
-
});
|
|
8012
|
-
const exitCode = await child.exited;
|
|
8013
|
-
if (exitCode !== 0) {
|
|
8014
|
-
throw new Error(`Remote TUI command failed with exit code ${exitCode}`);
|
|
8015
|
-
}
|
|
8016
|
-
});
|
|
8017
|
-
}
|
|
8018
|
-
|
|
8019
|
-
// src/commands/cliproxy/setup.ts
|
|
8020
|
-
import { cancel as cancel2, confirm as confirm2, intro, log as log2, note, outro, select as select2, text as text3 } from "@clack/prompts";
|
|
8021
|
-
import { z as z14 } from "zod";
|
|
8022
|
-
|
|
8023
|
-
// src/commands/cliproxy/setup/gh.ts
|
|
8024
|
-
import { confirm, log, spinner } from "@clack/prompts";
|
|
8025
|
-
|
|
8026
|
-
// src/commands/cliproxy/setup/prompts.ts
|
|
8027
|
-
import * as clack from "@clack/prompts";
|
|
8028
|
-
function ensureRepoFormat(value) {
|
|
8029
|
-
const trimmed = value.trim();
|
|
8030
|
-
if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
|
|
8031
|
-
throw new Error("Repository must be in owner/repo format.");
|
|
7978
|
+
if (level === "warning") {
|
|
7979
|
+
return "WARN";
|
|
8032
7980
|
}
|
|
8033
|
-
return
|
|
7981
|
+
return "ERROR";
|
|
8034
7982
|
}
|
|
8035
|
-
function
|
|
8036
|
-
|
|
8037
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(trimmed)) {
|
|
8038
|
-
throw new Error(`${label} must be SCREAMING_SNAKE_CASE.`);
|
|
8039
|
-
}
|
|
8040
|
-
return trimmed;
|
|
7983
|
+
function formatDurationMs(durationMs) {
|
|
7984
|
+
return `${Math.max(0, Math.round(durationMs))}ms`;
|
|
8041
7985
|
}
|
|
8042
|
-
function
|
|
8043
|
-
|
|
8044
|
-
process.exit(0);
|
|
7986
|
+
function stripTrailingSlash4(value) {
|
|
7987
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
8045
7988
|
}
|
|
8046
|
-
async function
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
7989
|
+
async function parseJsonResponse(response) {
|
|
7990
|
+
try {
|
|
7991
|
+
return await response.json();
|
|
7992
|
+
} catch {
|
|
7993
|
+
return null;
|
|
8050
7994
|
}
|
|
8051
|
-
return value;
|
|
8052
|
-
}
|
|
8053
|
-
function buildApiKeyValue(keyName) {
|
|
8054
|
-
const slug = (keyName.trim().toLowerCase().match(/[a-z0-9]+/g) ?? []).join("-").slice(0, 24);
|
|
8055
|
-
const random = crypto.randomUUID().split("-").join("");
|
|
8056
|
-
return `sk-${slug || "cliproxy"}-${random}`;
|
|
8057
7995
|
}
|
|
8058
|
-
function
|
|
8059
|
-
return
|
|
7996
|
+
function toNumber(value) {
|
|
7997
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
8060
7998
|
}
|
|
8061
|
-
async function
|
|
8062
|
-
const
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
}
|
|
8072
|
-
|
|
8073
|
-
|
|
8074
|
-
const baseUrlSecretName = ensureSecretName(await promptValue(clack.text({
|
|
8075
|
-
message: "Name for the proxy base URL secret",
|
|
8076
|
-
placeholder: "CLIPROXY_BASE_URL",
|
|
8077
|
-
validate: (value) => {
|
|
8078
|
-
try {
|
|
8079
|
-
ensureSecretName(value ?? "", "Base URL secret name");
|
|
8080
|
-
return;
|
|
8081
|
-
} catch (error) {
|
|
8082
|
-
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")}`);
|
|
8083
8012
|
}
|
|
8084
8013
|
}
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
return error instanceof Error ? error.message : String(error);
|
|
8092
|
-
}
|
|
8093
|
-
async function withSpinner(message, run) {
|
|
8094
|
-
const spinnerInstance = spinner();
|
|
8095
|
-
spinnerInstance.start(message);
|
|
8096
|
-
try {
|
|
8097
|
-
const result = await run(spinnerInstance);
|
|
8098
|
-
spinnerInstance.stop(message);
|
|
8099
|
-
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
|
+
};
|
|
8100
8020
|
} catch (error) {
|
|
8101
|
-
|
|
8102
|
-
|
|
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
|
+
};
|
|
8103
8029
|
}
|
|
8104
8030
|
}
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
});
|
|
8111
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
8112
|
-
new Response(child.stdout).text(),
|
|
8113
|
-
new Response(child.stderr).text(),
|
|
8114
|
-
child.exited
|
|
8115
|
-
]);
|
|
8116
|
-
return { stdout, stderr, exitCode };
|
|
8117
|
-
}
|
|
8118
|
-
async function runGh(args) {
|
|
8119
|
-
return runCommand("gh", args);
|
|
8120
|
-
}
|
|
8121
|
-
function isGhRateLimitError(text2) {
|
|
8122
|
-
return /rate limit/i.test(text2);
|
|
8123
|
-
}
|
|
8124
|
-
async function queryRateLimitReset() {
|
|
8125
|
-
try {
|
|
8126
|
-
const result = await runGh(["api", "rate_limit"]);
|
|
8127
|
-
if (result.exitCode === 0) {
|
|
8128
|
-
const parsed = JSON.parse(result.stdout);
|
|
8129
|
-
const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset;
|
|
8130
|
-
if (reset) {
|
|
8131
|
-
return new Date(reset * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
8132
|
-
}
|
|
8031
|
+
function countQueueErrors(records) {
|
|
8032
|
+
let count = 0;
|
|
8033
|
+
for (const record of records) {
|
|
8034
|
+
if (record === null || typeof record !== "object") {
|
|
8035
|
+
continue;
|
|
8133
8036
|
}
|
|
8134
|
-
|
|
8135
|
-
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
const message = extractErrorMessage2(error);
|
|
8143
|
-
if (!isGhRateLimitError(message)) {
|
|
8144
|
-
throw error;
|
|
8145
|
-
}
|
|
8146
|
-
const reset = await queryReset();
|
|
8147
|
-
if (!interactive) {
|
|
8148
|
-
throw new Error(`${message} \u2014 GitHub API rate limit resets at ${reset}. Re-run when ready.`);
|
|
8149
|
-
}
|
|
8150
|
-
log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`);
|
|
8151
|
-
const retry = await promptValue(confirm({
|
|
8152
|
-
message: "Retry this step when ready?",
|
|
8153
|
-
active: "retry",
|
|
8154
|
-
inactive: "abort",
|
|
8155
|
-
initialValue: true
|
|
8156
|
-
}), "Setup aborted after rate limit.");
|
|
8157
|
-
if (!retry) {
|
|
8158
|
-
cancelAndExit("Setup aborted after GitHub API rate limit.");
|
|
8159
|
-
}
|
|
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++;
|
|
8160
8045
|
}
|
|
8161
8046
|
}
|
|
8047
|
+
return count;
|
|
8162
8048
|
}
|
|
8163
|
-
async function
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
const result = await runGh(["auth", "status"]);
|
|
8170
|
-
if (result.exitCode !== 0) {
|
|
8171
|
-
throw new Error(`GitHub CLI is not authenticated. Run "gh auth login" first. ${result.stderr.trim()}`.trim());
|
|
8172
|
-
}
|
|
8173
|
-
}
|
|
8174
|
-
async function assertRepoAccess(repo) {
|
|
8175
|
-
const { z: z10 } = await import("zod");
|
|
8176
|
-
const ghRepoViewSchema = z10.object({
|
|
8177
|
-
nameWithOwner: z10.string(),
|
|
8178
|
-
viewerPermission: z10.string()
|
|
8179
|
-
});
|
|
8180
|
-
const result = await runGh(["repo", "view", repo, "--json", "nameWithOwner,viewerPermission"]);
|
|
8181
|
-
if (result.exitCode !== 0) {
|
|
8182
|
-
throw new Error(`Unable to access ${repo}. ${result.stderr.trim()}`.trim());
|
|
8183
|
-
}
|
|
8184
|
-
const parsed = ghRepoViewSchema.parse(JSON.parse(result.stdout));
|
|
8185
|
-
const writePermissions = new Set(["ADMIN", "MAINTAIN", "WRITE"]);
|
|
8186
|
-
if (!writePermissions.has(parsed.viewerPermission)) {
|
|
8187
|
-
throw new Error(`GitHub CLI does not have write access to ${parsed.nameWithOwner}. Current permission: ${parsed.viewerPermission}.`);
|
|
8188
|
-
}
|
|
8189
|
-
}
|
|
8190
|
-
async function listExistingGhNames(repo, kind) {
|
|
8191
|
-
const { z: z10 } = await import("zod");
|
|
8192
|
-
const ghNameListSchema = z10.array(z10.object({ name: z10.string() }));
|
|
8193
|
-
const result = await runGh([kind, "list", "--repo", repo, "--json", "name"]);
|
|
8194
|
-
if (result.exitCode !== 0) {
|
|
8195
|
-
throw new Error(`Unable to list existing GitHub ${kind}s for ${repo}. ${result.stderr.trim()}`.trim());
|
|
8196
|
-
}
|
|
8197
|
-
return ghNameListSchema.parse(JSON.parse(result.stdout)).map((entry) => entry.name);
|
|
8198
|
-
}
|
|
8199
|
-
async function createManagementApiKey(baseUrl, managementKey, keyValue) {
|
|
8200
|
-
const endpoint = `${baseUrl}/v0/management/api-keys`;
|
|
8201
|
-
const currentPayload = await requestJson(endpoint, {
|
|
8202
|
-
method: "GET",
|
|
8203
|
-
headers: managementHeaders(managementKey)
|
|
8204
|
-
});
|
|
8205
|
-
const currentKeys = parseManagementKeyList(currentPayload);
|
|
8206
|
-
if (currentKeys.includes(keyValue)) {
|
|
8207
|
-
return;
|
|
8208
|
-
}
|
|
8209
|
-
await requestJson(endpoint, {
|
|
8210
|
-
method: "PUT",
|
|
8211
|
-
headers: managementHeaders(managementKey),
|
|
8212
|
-
body: JSON.stringify([...currentKeys, keyValue])
|
|
8213
|
-
});
|
|
8214
|
-
}
|
|
8215
|
-
async function deleteManagementApiKey(baseUrl, managementKey, keyValue) {
|
|
8216
|
-
const endpoint = `${baseUrl}/v0/management/api-keys`;
|
|
8217
|
-
const currentPayload = await requestJson(endpoint, {
|
|
8218
|
-
method: "GET",
|
|
8219
|
-
headers: managementHeaders(managementKey)
|
|
8220
|
-
});
|
|
8221
|
-
const currentKeys = parseManagementKeyList(currentPayload);
|
|
8222
|
-
const filtered = currentKeys.filter((k) => k !== keyValue);
|
|
8223
|
-
if (filtered.length === currentKeys.length) {
|
|
8224
|
-
return;
|
|
8225
|
-
}
|
|
8226
|
-
await requestJson(endpoint, {
|
|
8227
|
-
method: "PUT",
|
|
8228
|
-
headers: managementHeaders(managementKey),
|
|
8229
|
-
body: JSON.stringify(filtered)
|
|
8230
|
-
});
|
|
8231
|
-
}
|
|
8232
|
-
async function applyGhValue(kind, name, repo, value) {
|
|
8233
|
-
if (kind === "secret") {
|
|
8234
|
-
const child = Bun.spawn(["gh", "secret", "set", name, "--repo", repo], {
|
|
8235
|
-
stdin: new Blob([value]).stream(),
|
|
8236
|
-
stdout: "pipe",
|
|
8237
|
-
stderr: "pipe",
|
|
8238
|
-
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)
|
|
8239
8055
|
});
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
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
|
+
};
|
|
8243
8062
|
}
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
}
|
|
8251
|
-
|
|
8252
|
-
// src/commands/cliproxy/setup/preview.ts
|
|
8253
|
-
function formatDryRunPreview(opts) {
|
|
8254
|
-
const { repo, harness, providers, model, template } = opts;
|
|
8255
|
-
const lines = [
|
|
8256
|
-
`Dry run: cliproxy setup --harness ${harness}`,
|
|
8257
|
-
`Repository: ${repo}`,
|
|
8258
|
-
`Providers: ${providers.join(", ")}`,
|
|
8259
|
-
`Model: ${model}`,
|
|
8260
|
-
"Planned secrets:"
|
|
8261
|
-
];
|
|
8262
|
-
for (const secret of template.secrets) {
|
|
8263
|
-
const size = new TextEncoder().encode(secret.value).byteLength;
|
|
8264
|
-
lines.push(` - ${secret.name} (${size} bytes)`);
|
|
8265
|
-
}
|
|
8266
|
-
lines.push("Planned variables:");
|
|
8267
|
-
for (const variable of template.variables) {
|
|
8268
|
-
lines.push(` - ${variable.name} = ${variable.value}`);
|
|
8269
|
-
}
|
|
8270
|
-
lines.push("Proxy key (redacted): <proxy-key>");
|
|
8271
|
-
lines.push("No mutations will be performed.");
|
|
8272
|
-
return lines.join(`
|
|
8273
|
-
`);
|
|
8274
|
-
}
|
|
8275
|
-
|
|
8276
|
-
// src/commands/cliproxy/setup/providers.ts
|
|
8277
|
-
import { isCancel as isCancel2, multiselect, select, text as text2 } from "@clack/prompts";
|
|
8278
|
-
import { z as z10 } from "zod";
|
|
8279
|
-
var providerIdSchema = z10.enum(["anthropic", "openai"]);
|
|
8280
|
-
var MODEL_ID_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/;
|
|
8281
|
-
function parseProviders(input) {
|
|
8282
|
-
const parts = input.split(",").map((p) => p.trim()).filter(Boolean);
|
|
8283
|
-
if (parts.length === 0) {
|
|
8284
|
-
throw new Error("--providers must not be empty. Supported values: anthropic, openai");
|
|
8285
|
-
}
|
|
8286
|
-
const parsed = parts.map((p) => {
|
|
8287
|
-
const result = providerIdSchema.safeParse(p);
|
|
8288
|
-
if (!result.success) {
|
|
8289
|
-
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
|
+
};
|
|
8290
8069
|
}
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
}
|
|
8299
|
-
var PROVIDER_DEFAULTS = {
|
|
8300
|
-
anthropic: "anthropic/claude-sonnet-4-6",
|
|
8301
|
-
openai: "openai/gpt-5.4-mini"
|
|
8302
|
-
};
|
|
8303
|
-
var CUSTOM_MODEL_SENTINEL = "__custom__";
|
|
8304
|
-
async function promptForProviders() {
|
|
8305
|
-
let providers = [];
|
|
8306
|
-
do {
|
|
8307
|
-
const result = await multiselect({
|
|
8308
|
-
message: "Select providers to configure",
|
|
8309
|
-
options: [
|
|
8310
|
-
{ value: "anthropic", label: "Anthropic" },
|
|
8311
|
-
{ value: "openai", label: "OpenAI" }
|
|
8312
|
-
],
|
|
8313
|
-
initialValues: ["anthropic"],
|
|
8314
|
-
required: false
|
|
8315
|
-
});
|
|
8316
|
-
if (isCancel2(result)) {
|
|
8317
|
-
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
|
+
};
|
|
8318
8077
|
}
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8337
|
-
|
|
8338
|
-
|
|
8339
|
-
|
|
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
|
+
};
|
|
8340
8099
|
}
|
|
8341
|
-
return chosen;
|
|
8342
8100
|
}
|
|
8343
|
-
async function
|
|
8344
|
-
|
|
8345
|
-
|
|
8346
|
-
const
|
|
8347
|
-
|
|
8348
|
-
|
|
8349
|
-
validate: (value) => {
|
|
8350
|
-
if (!MODEL_ID_RE.test(value ?? "")) {
|
|
8351
|
-
return "Model ID must match provider/model-name (lowercase, digits, dots, hyphens only)";
|
|
8352
|
-
}
|
|
8353
|
-
return;
|
|
8354
|
-
}
|
|
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)
|
|
8355
8107
|
});
|
|
8356
|
-
if (
|
|
8357
|
-
|
|
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
|
+
};
|
|
8358
8114
|
}
|
|
8359
|
-
|
|
8360
|
-
|
|
8361
|
-
|
|
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
|
+
};
|
|
8362
8121
|
}
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
|
|
8367
|
-
|
|
8368
|
-
import { z as z11 } from "zod";
|
|
8369
|
-
var baselineRunSchema = z11.array(z11.object({ databaseId: z11.number() }));
|
|
8370
|
-
var ghRunEntrySchema = z11.object({
|
|
8371
|
-
databaseId: z11.number(),
|
|
8372
|
-
status: z11.string(),
|
|
8373
|
-
conclusion: z11.string().nullable(),
|
|
8374
|
-
url: z11.string(),
|
|
8375
|
-
createdAt: z11.string()
|
|
8376
|
-
});
|
|
8377
|
-
async function runSmokeTest(repo, _model, internals = {}) {
|
|
8378
|
-
const BACKOFF_MS = [5000, 15000, 30000, 60000, 60000];
|
|
8379
|
-
const delayFn = async (ms) => {
|
|
8380
|
-
if (internals._testDelayMs !== undefined) {
|
|
8381
|
-
if (internals._testDelayMs > 0) {
|
|
8382
|
-
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 };
|
|
8383
8127
|
}
|
|
8384
|
-
return;
|
|
8385
8128
|
}
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
8392
|
-
const
|
|
8393
|
-
|
|
8394
|
-
new Response(baselineChild.stderr).text(),
|
|
8395
|
-
baselineChild.exited
|
|
8396
|
-
]);
|
|
8397
|
-
if (baselineExit === 0) {
|
|
8398
|
-
const parseResult = baselineRunSchema.safeParse(JSON.parse(baselineStdout));
|
|
8399
|
-
if (parseResult.success && parseResult.data.length > 0 && parseResult.data[0]) {
|
|
8400
|
-
baselineId = parseResult.data[0].databaseId;
|
|
8401
|
-
}
|
|
8402
|
-
}
|
|
8403
|
-
} catch {}
|
|
8404
|
-
const triggerTime = internals._testTriggerTime ?? new Date;
|
|
8405
|
-
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 });
|
|
8406
|
-
const [, triggerStderr, triggerExit] = await Promise.all([
|
|
8407
|
-
new Response(triggerChild.stdout).text(),
|
|
8408
|
-
new Response(triggerChild.stderr).text(),
|
|
8409
|
-
triggerChild.exited
|
|
8410
|
-
]);
|
|
8411
|
-
if (triggerExit !== 0) {
|
|
8412
|
-
const redacted = triggerStderr.slice(0, 200);
|
|
8413
|
-
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}` };
|
|
8414
8137
|
}
|
|
8415
|
-
|
|
8416
|
-
|
|
8417
|
-
|
|
8418
|
-
|
|
8419
|
-
|
|
8420
|
-
|
|
8421
|
-
|
|
8422
|
-
|
|
8423
|
-
|
|
8424
|
-
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
|
|
8428
|
-
|
|
8429
|
-
|
|
8430
|
-
"
|
|
8431
|
-
|
|
8432
|
-
|
|
8433
|
-
|
|
8434
|
-
new Response(pollChild.stderr).text(),
|
|
8435
|
-
pollChild.exited
|
|
8436
|
-
]);
|
|
8437
|
-
if (pollExit === 0) {
|
|
8438
|
-
const rawParsed = JSON.parse(pollStdout);
|
|
8439
|
-
if (Array.isArray(rawParsed)) {
|
|
8440
|
-
pollRuns = rawParsed.flatMap((entry) => {
|
|
8441
|
-
const entryResult = ghRunEntrySchema.safeParse(entry);
|
|
8442
|
-
return entryResult.success ? [entryResult.data] : [];
|
|
8443
|
-
});
|
|
8444
|
-
}
|
|
8445
|
-
}
|
|
8446
|
-
} catch {
|
|
8447
|
-
continue;
|
|
8448
|
-
}
|
|
8449
|
-
const candidates = pollRuns.filter((run) => {
|
|
8450
|
-
if (baselineId !== null) {
|
|
8451
|
-
return run.databaseId > baselineId;
|
|
8452
|
-
}
|
|
8453
|
-
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)
|
|
8454
8157
|
});
|
|
8455
|
-
|
|
8456
|
-
|
|
8158
|
+
const details = [];
|
|
8159
|
+
if (verbose) {
|
|
8160
|
+
details.push(`URL: ${endpoint}`);
|
|
8161
|
+
details.push(`Model: ${model}`);
|
|
8162
|
+
details.push(`Status: ${response.status}`);
|
|
8457
8163
|
}
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
|
|
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
|
+
};
|
|
8463
8174
|
}
|
|
8464
|
-
if (status ===
|
|
8465
|
-
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
|
|
8471
|
-
|
|
8472
|
-
});
|
|
8473
|
-
const [logStdout, , logExit] = await Promise.all([
|
|
8474
|
-
new Response(logChild.stdout).text(),
|
|
8475
|
-
new Response(logChild.stderr).text(),
|
|
8476
|
-
logChild.exited
|
|
8477
|
-
]);
|
|
8478
|
-
if (logExit !== 0) {
|
|
8479
|
-
logNote = " (log fetch failed, but run conclusion is success)";
|
|
8480
|
-
} else if (!/\back\b/i.test(logStdout)) {
|
|
8481
|
-
logNote = ' (log fetch succeeded but "ack" not found in output)';
|
|
8482
|
-
}
|
|
8483
|
-
} catch {
|
|
8484
|
-
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
|
|
8485
8183
|
}
|
|
8486
|
-
|
|
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
|
+
};
|
|
8487
8202
|
}
|
|
8488
|
-
return { kind: "fail", message: `Run completed with conclusion=${conclusion ?? "unknown"}`, runUrl };
|
|
8489
8203
|
}
|
|
8490
|
-
}
|
|
8491
|
-
if (latestMatchedRun) {
|
|
8492
8204
|
return {
|
|
8493
|
-
|
|
8494
|
-
|
|
8495
|
-
|
|
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
|
+
}
|
|
8496
8225
|
};
|
|
8497
8226
|
}
|
|
8498
|
-
return {
|
|
8499
|
-
kind: "unverified",
|
|
8500
|
-
message: `Smoke test trigger not yet visible; check ${repoUrl}/actions`
|
|
8501
|
-
};
|
|
8502
8227
|
}
|
|
8503
|
-
|
|
8504
|
-
|
|
8505
|
-
|
|
8506
|
-
var harnessSchema = z12.enum(["opencode", "claude-code", "generic"]);
|
|
8507
|
-
var DEFAULT_CLIPROXY_URL4 = "https://cliproxy.fro.bot";
|
|
8508
|
-
function stripTrailingSlash4(value) {
|
|
8509
|
-
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;
|
|
8510
8231
|
}
|
|
8511
|
-
function
|
|
8512
|
-
const
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
}
|
|
8522
|
-
|
|
8523
|
-
|
|
8524
|
-
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;
|
|
8525
8245
|
}
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
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
|
+
};
|
|
8529
8261
|
}
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
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
|
+
};
|
|
8533
8268
|
}
|
|
8534
8269
|
return {
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
|
|
8538
|
-
value: JSON.stringify(authObj)
|
|
8539
|
-
},
|
|
8540
|
-
{
|
|
8541
|
-
name: "OPENCODE_CONFIG",
|
|
8542
|
-
value: JSON.stringify({ provider: providerConfig })
|
|
8543
|
-
}
|
|
8544
|
-
],
|
|
8545
|
-
variables: [
|
|
8546
|
-
{
|
|
8547
|
-
name: "FRO_BOT_MODEL",
|
|
8548
|
-
value: model
|
|
8549
|
-
}
|
|
8550
|
-
]
|
|
8270
|
+
title: "Management access",
|
|
8271
|
+
level: "warning",
|
|
8272
|
+
summary: `Management auth probe returned HTTP ${response.status}. Management checks skipped.`
|
|
8551
8273
|
};
|
|
8552
|
-
}
|
|
8553
|
-
|
|
8274
|
+
} catch (error) {
|
|
8275
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8554
8276
|
return {
|
|
8555
|
-
|
|
8556
|
-
|
|
8557
|
-
|
|
8558
|
-
value: keyValue
|
|
8559
|
-
}
|
|
8560
|
-
],
|
|
8561
|
-
variables: []
|
|
8277
|
+
title: "Management access",
|
|
8278
|
+
level: "error",
|
|
8279
|
+
summary: `Management auth probe failed: ${message}`
|
|
8562
8280
|
};
|
|
8563
8281
|
}
|
|
8564
|
-
|
|
8565
|
-
|
|
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);
|
|
8566
8334
|
}
|
|
8567
8335
|
return {
|
|
8568
|
-
|
|
8569
|
-
|
|
8570
|
-
|
|
8571
|
-
|
|
8572
|
-
|
|
8336
|
+
app: "cliproxy",
|
|
8337
|
+
http: formatCheckSummary(httpResult),
|
|
8338
|
+
lastDeploy: "\u2014",
|
|
8339
|
+
version,
|
|
8340
|
+
contentHash: "\u2014",
|
|
8341
|
+
usageStats
|
|
8573
8342
|
};
|
|
8574
8343
|
}
|
|
8575
|
-
function
|
|
8576
|
-
|
|
8577
|
-
|
|
8578
|
-
|
|
8579
|
-
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
|
|
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);
|
|
8586
8369
|
}
|
|
8587
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
8590
|
-
|
|
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
|
+
}
|
|
8591
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;
|
|
8592
8397
|
}
|
|
8593
|
-
|
|
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);
|
|
8594
8404
|
}
|
|
8595
8405
|
|
|
8596
|
-
// src/commands/cliproxy/
|
|
8597
|
-
|
|
8598
|
-
var
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
var
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
var
|
|
8606
|
-
var
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
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
|
|
8610
8436
|
};
|
|
8611
|
-
function
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
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;
|
|
8618
8447
|
}
|
|
8619
|
-
const pattern = PROVIDER_ID_PATTERNS2[provider];
|
|
8620
|
-
return pattern === undefined ? false : pattern.test(id);
|
|
8621
8448
|
}
|
|
8622
|
-
function
|
|
8623
|
-
|
|
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
|
+
};
|
|
8624
8486
|
}
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
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;
|
|
8628
8498
|
}
|
|
8629
|
-
|
|
8630
|
-
|
|
8631
|
-
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
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);
|
|
8639
8577
|
}
|
|
8640
8578
|
}
|
|
8579
|
+
throw new Error("github-transient");
|
|
8641
8580
|
}
|
|
8642
|
-
|
|
8643
|
-
|
|
8644
|
-
|
|
8645
|
-
|
|
8646
|
-
|
|
8647
|
-
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
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
|
+
};
|
|
8654
8594
|
}
|
|
8655
|
-
|
|
8656
|
-
|
|
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
|
+
});
|
|
8657
8639
|
return;
|
|
8658
8640
|
}
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
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 })
|
|
8663
8648
|
});
|
|
8664
|
-
|
|
8665
|
-
|
|
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
|
+
}
|
|
8666
8668
|
}
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
8671
|
-
|
|
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
|
+
}
|
|
8672
8701
|
}
|
|
8673
|
-
|
|
8674
|
-
|
|
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();
|
|
8675
8721
|
try {
|
|
8676
|
-
|
|
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);
|
|
8677
8778
|
} catch (error) {
|
|
8678
|
-
const
|
|
8679
|
-
|
|
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);
|
|
8680
8781
|
}
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
8685
|
-
|
|
8686
|
-
|
|
8687
|
-
|
|
8688
|
-
|
|
8689
|
-
|
|
8690
|
-
|
|
8691
|
-
const
|
|
8692
|
-
if (!
|
|
8693
|
-
|
|
8694
|
-
const available = matchingIds.length > 0 ? matchingIds.join(", ") : "(none)";
|
|
8695
|
-
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.");
|
|
8696
8795
|
}
|
|
8796
|
+
return host;
|
|
8697
8797
|
}
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
const
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
if (!response.ok) {
|
|
8704
|
-
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.");
|
|
8705
8803
|
}
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
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");
|
|
8709
8808
|
}
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
}
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
|
|
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
|
|
8718
8826
|
},
|
|
8719
|
-
|
|
8827
|
+
stdin: "inherit",
|
|
8828
|
+
stdout: "inherit",
|
|
8829
|
+
stderr: "inherit"
|
|
8720
8830
|
});
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
} catch (error) {
|
|
8725
|
-
if (error instanceof Error && error.message.startsWith("Proxy key verification")) {
|
|
8726
|
-
throw error;
|
|
8831
|
+
const exitCode = await child.exited;
|
|
8832
|
+
if (exitCode !== 0) {
|
|
8833
|
+
throw new Error(`Remote TUI command failed with exit code ${exitCode}`);
|
|
8727
8834
|
}
|
|
8728
|
-
|
|
8729
|
-
}
|
|
8835
|
+
});
|
|
8730
8836
|
}
|
|
8731
8837
|
|
|
8732
|
-
// src/commands/cliproxy/setup
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
for (let index = 1;index < lines.length; index += 1) {
|
|
8746
|
-
const line = lines[index] ?? "";
|
|
8747
|
-
if (!line.trim()) {
|
|
8748
|
-
stepLines.push(line);
|
|
8749
|
-
continue;
|
|
8750
|
-
}
|
|
8751
|
-
const firstNonSpace = line.search(/\S/);
|
|
8752
|
-
if (firstNonSpace === dashIndent && line.trimStart().startsWith("-"))
|
|
8753
|
-
break;
|
|
8754
|
-
if (firstNonSpace < dashIndent)
|
|
8755
|
-
break;
|
|
8756
|
-
stepLines.push(line);
|
|
8757
|
-
}
|
|
8758
|
-
bodies.push({ stepOrdinal: bodies.length + 1, body: stepLines.join(`
|
|
8759
|
-
`) });
|
|
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.");
|
|
8760
8851
|
}
|
|
8761
|
-
return
|
|
8852
|
+
return trimmed;
|
|
8762
8853
|
}
|
|
8763
|
-
function
|
|
8764
|
-
const
|
|
8765
|
-
if (
|
|
8766
|
-
|
|
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.`);
|
|
8767
8858
|
}
|
|
8768
|
-
|
|
8769
|
-
stepOrdinal: step.stepOrdinal,
|
|
8770
|
-
missingInputs: REQUIRED_OPENCODE_INPUTS.filter((input) => {
|
|
8771
|
-
const inputPattern = new RegExp(String.raw`^\s+${input}:`, "m");
|
|
8772
|
-
return !inputPattern.test(step.body);
|
|
8773
|
-
})
|
|
8774
|
-
})).filter((step) => step.missingInputs.length > 0);
|
|
8775
|
-
return { kind: "analyzed", stepsWithGaps };
|
|
8859
|
+
return trimmed;
|
|
8776
8860
|
}
|
|
8777
|
-
function
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
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);
|
|
8783
8869
|
}
|
|
8784
|
-
return
|
|
8785
|
-
kind: "unreachable",
|
|
8786
|
-
reason: result.stderr.trim() || `gh api exited with code ${result.exitCode}`
|
|
8787
|
-
};
|
|
8870
|
+
return value;
|
|
8788
8871
|
}
|
|
8789
|
-
|
|
8790
|
-
const
|
|
8791
|
-
|
|
8792
|
-
|
|
8793
|
-
"Accept: application/vnd.github.raw",
|
|
8794
|
-
`/repos/${repo}/contents/.github/workflows/fro-bot.yaml`
|
|
8795
|
-
]);
|
|
8796
|
-
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}`;
|
|
8797
8876
|
}
|
|
8798
|
-
function
|
|
8799
|
-
|
|
8800
|
-
|
|
8801
|
-
|
|
8802
|
-
|
|
8803
|
-
|
|
8804
|
-
|
|
8805
|
-
|
|
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 };
|
|
8806
8906
|
}
|
|
8807
8907
|
|
|
8808
|
-
// src/commands/cliproxy/setup.ts
|
|
8809
|
-
|
|
8810
|
-
|
|
8811
|
-
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);
|
|
8812
8911
|
}
|
|
8813
|
-
function
|
|
8912
|
+
async function withSpinner(message, run) {
|
|
8913
|
+
const spinnerInstance = spinner();
|
|
8914
|
+
spinnerInstance.start(message);
|
|
8814
8915
|
try {
|
|
8815
|
-
|
|
8816
|
-
|
|
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
|
+
}
|
|
8817
8923
|
}
|
|
8818
|
-
function
|
|
8819
|
-
const
|
|
8820
|
-
|
|
8821
|
-
|
|
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 };
|
|
8822
8936
|
}
|
|
8823
|
-
async function
|
|
8824
|
-
|
|
8825
|
-
let secretReadback;
|
|
8826
|
-
let variableReadback;
|
|
8827
|
-
let readbackFailed = false;
|
|
8828
|
-
try {
|
|
8829
|
-
secretReadback = await listExistingGhNames2(repo, "secret");
|
|
8830
|
-
} catch {
|
|
8831
|
-
readbackFailed = true;
|
|
8832
|
-
secretReadback = [];
|
|
8833
|
-
}
|
|
8834
|
-
if (readbackFailed) {
|
|
8835
|
-
variableReadback = [];
|
|
8836
|
-
} else {
|
|
8837
|
-
try {
|
|
8838
|
-
variableReadback = await listExistingGhNames2(repo, "variable");
|
|
8839
|
-
} catch {
|
|
8840
|
-
readbackFailed = true;
|
|
8841
|
-
variableReadback = [];
|
|
8842
|
-
}
|
|
8843
|
-
}
|
|
8844
|
-
if (readbackFailed) {
|
|
8845
|
-
safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames));
|
|
8846
|
-
return;
|
|
8847
|
-
}
|
|
8848
|
-
const absentSecrets = writtenSecretNames.filter((name) => !secretReadback.includes(name));
|
|
8849
|
-
const absentVariables = writtenVariableNames.filter((name) => !variableReadback.includes(name));
|
|
8850
|
-
if (absentSecrets.length === 0 && absentVariables.length === 0) {
|
|
8851
|
-
return;
|
|
8852
|
-
}
|
|
8853
|
-
const lines = [
|
|
8854
|
-
`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.`
|
|
8855
|
-
];
|
|
8856
|
-
if (absentSecrets.length > 0) {
|
|
8857
|
-
lines.push(` Absent secrets: ${absentSecrets.join(", ")}`);
|
|
8858
|
-
lines.push(` Verify manually: gh secret list --repo ${repo}`);
|
|
8859
|
-
}
|
|
8860
|
-
if (absentVariables.length > 0) {
|
|
8861
|
-
lines.push(` Absent variables: ${absentVariables.join(", ")}`);
|
|
8862
|
-
lines.push(` Verify manually: gh variable list --repo ${repo}`);
|
|
8863
|
-
}
|
|
8864
|
-
safeWarn(lines.join(`
|
|
8865
|
-
`));
|
|
8866
|
-
} catch {
|
|
8867
|
-
safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames));
|
|
8868
|
-
}
|
|
8937
|
+
async function runGh(args) {
|
|
8938
|
+
return runCommand("gh", args);
|
|
8869
8939
|
}
|
|
8870
|
-
function
|
|
8871
|
-
return
|
|
8940
|
+
function isGhRateLimitError(text2) {
|
|
8941
|
+
return /rate limit/i.test(text2);
|
|
8872
8942
|
}
|
|
8873
|
-
function
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
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";
|
|
8877
8955
|
}
|
|
8878
|
-
async function
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
8894
|
-
|
|
8895
|
-
|
|
8896
|
-
|
|
8897
|
-
|
|
8898
|
-
|
|
8899
|
-
|
|
8900
|
-
} catch (error) {
|
|
8901
|
-
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.");
|
|
8902
8978
|
}
|
|
8903
8979
|
}
|
|
8904
|
-
}), "Setup cancelled before choosing a repository."));
|
|
8905
|
-
let providers;
|
|
8906
|
-
let model;
|
|
8907
|
-
if (harness === "opencode") {
|
|
8908
|
-
const doPromptForProviders = promptsImpl.promptForProviders ?? promptForProviders;
|
|
8909
|
-
const doPromptForModel = promptsImpl.promptForModel ?? promptForModel;
|
|
8910
|
-
providers = await doPromptForProviders();
|
|
8911
|
-
model = await doPromptForModel(providers);
|
|
8912
8980
|
}
|
|
8913
|
-
const keyValue = options.key ?? buildApiKeyValue(keyName ?? "cliproxy");
|
|
8914
|
-
const genericSecretNames = harness === "generic" ? await promptGenericSecretNames() : undefined;
|
|
8915
|
-
return {
|
|
8916
|
-
repo,
|
|
8917
|
-
harness,
|
|
8918
|
-
keyValue,
|
|
8919
|
-
keyName,
|
|
8920
|
-
createKey,
|
|
8921
|
-
template: getHarnessTemplate(harness, { keyValue, baseUrl, genericSecretNames, providers, model })
|
|
8922
|
-
};
|
|
8923
8981
|
}
|
|
8924
|
-
function
|
|
8925
|
-
|
|
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
|
+
}
|
|
8926
8986
|
}
|
|
8927
|
-
async function
|
|
8928
|
-
const
|
|
8929
|
-
|
|
8930
|
-
|
|
8931
|
-
const providers = options.providers ? parseProviders(options.providers) : ["anthropic"];
|
|
8932
|
-
let model;
|
|
8933
|
-
if (options.model) {
|
|
8934
|
-
model = options.model;
|
|
8935
|
-
} else if (providers.length === 1) {
|
|
8936
|
-
model = PROVIDER_DEFAULTS[providers[0]];
|
|
8937
|
-
} else {
|
|
8938
|
-
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());
|
|
8939
8991
|
}
|
|
8940
|
-
|
|
8941
|
-
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
8947
|
-
|
|
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());
|
|
8948
9002
|
}
|
|
8949
|
-
const
|
|
8950
|
-
|
|
8951
|
-
|
|
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}.`);
|
|
8952
9007
|
}
|
|
8953
|
-
await verifyModels(baseUrl, keyValue, providers, model);
|
|
8954
|
-
return {
|
|
8955
|
-
repo,
|
|
8956
|
-
harness,
|
|
8957
|
-
keyValue,
|
|
8958
|
-
createKey: false,
|
|
8959
|
-
template: getHarnessTemplate(harness, { keyValue, baseUrl, providers, model })
|
|
8960
|
-
};
|
|
8961
9008
|
}
|
|
8962
|
-
|
|
8963
|
-
|
|
8964
|
-
|
|
8965
|
-
|
|
8966
|
-
|
|
8967
|
-
|
|
8968
|
-
deleteManagementApiKey,
|
|
8969
|
-
applyGhValue,
|
|
8970
|
-
withGhRetry
|
|
8971
|
-
};
|
|
8972
|
-
var realPrompts = {
|
|
8973
|
-
promptValue,
|
|
8974
|
-
confirm: confirm2,
|
|
8975
|
-
intro,
|
|
8976
|
-
note,
|
|
8977
|
-
outro,
|
|
8978
|
-
promptForProviders,
|
|
8979
|
-
promptForModel
|
|
8980
|
-
};
|
|
8981
|
-
var realSmoke = {
|
|
8982
|
-
runSmokeTest
|
|
8983
|
-
};
|
|
8984
|
-
var realValidation = {
|
|
8985
|
-
assertProxyReachable,
|
|
8986
|
-
assertProxyKeyWorks,
|
|
8987
|
-
verifyModelsAvailable
|
|
8988
|
-
};
|
|
8989
|
-
var realCtx = {
|
|
8990
|
-
console: {
|
|
8991
|
-
log: (...args) => {
|
|
8992
|
-
console.log(...args);
|
|
8993
|
-
},
|
|
8994
|
-
error: (...args) => {
|
|
8995
|
-
console.error(...args);
|
|
8996
|
-
}
|
|
8997
|
-
},
|
|
8998
|
-
process: {
|
|
8999
|
-
stdout: { write: (chunk) => process.stdout.write(chunk) },
|
|
9000
|
-
stderr: { write: (chunk) => process.stderr.write(chunk) },
|
|
9001
|
-
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());
|
|
9002
9015
|
}
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
|
|
9006
|
-
const
|
|
9007
|
-
const
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9011
|
-
const
|
|
9012
|
-
|
|
9013
|
-
const mgmtKeyResolver = deps.resolveManagementKey ?? resolveManagementKey;
|
|
9014
|
-
if (options.dryRun) {
|
|
9015
|
-
const providers = options.providers ? parseProviders(options.providers) : ["anthropic"];
|
|
9016
|
-
const model = options.model ?? PROVIDER_DEFAULTS[providers[0]];
|
|
9017
|
-
const harness = options.harness ?? "opencode";
|
|
9018
|
-
const repo = options.repo ?? "<repo not specified>";
|
|
9019
|
-
const keyValue = options.key ?? "sk-placeholder";
|
|
9020
|
-
ctx.console.log(formatDryRunPreview({
|
|
9021
|
-
repo,
|
|
9022
|
-
harness,
|
|
9023
|
-
providers,
|
|
9024
|
-
model,
|
|
9025
|
-
template: getHarnessTemplate(harness, { keyValue, baseUrl, providers, model })
|
|
9026
|
-
}));
|
|
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)) {
|
|
9027
9026
|
return;
|
|
9028
9027
|
}
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
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;
|
|
9032
9044
|
}
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
|
|
9036
|
-
|
|
9037
|
-
|
|
9038
|
-
|
|
9039
|
-
|
|
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
|
|
9040
9058
|
});
|
|
9041
|
-
const
|
|
9042
|
-
if (
|
|
9043
|
-
|
|
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());
|
|
9044
9062
|
}
|
|
9045
|
-
|
|
9046
|
-
|
|
9047
|
-
|
|
9048
|
-
|
|
9049
|
-
|
|
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`);
|
|
9050
9109
|
}
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
9055
|
-
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
|
|
9065
|
-
|
|
9066
|
-
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
|
|
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.");
|
|
9070
9137
|
}
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
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)";
|
|
9082
9171
|
}
|
|
9083
|
-
|
|
9084
|
-
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;
|
|
9085
9173
|
}
|
|
9174
|
+
});
|
|
9175
|
+
if (isCancel2(result)) {
|
|
9176
|
+
cancelAndExit("Setup cancelled before entering a custom model ID.");
|
|
9086
9177
|
}
|
|
9087
|
-
const
|
|
9088
|
-
if (
|
|
9089
|
-
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9093
|
-
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
|
|
9098
|
-
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
|
|
9103
|
-
|
|
9104
|
-
|
|
9105
|
-
|
|
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));
|
|
9106
9202
|
}
|
|
9203
|
+
return;
|
|
9107
9204
|
}
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
9113
|
-
|
|
9114
|
-
|
|
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
|
+
}
|
|
9115
9221
|
}
|
|
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
|
-
|
|
9157
|
-
].join(`
|
|
9158
|
-
`));
|
|
9159
|
-
}
|
|
9160
|
-
break;
|
|
9161
|
-
}
|
|
9162
|
-
default: {
|
|
9163
|
-
const _exhaustive = workflow;
|
|
9164
|
-
throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`);
|
|
9165
|
-
}
|
|
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
|
+
});
|
|
9166
9263
|
}
|
|
9167
9264
|
}
|
|
9168
|
-
} catch
|
|
9169
|
-
|
|
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 = "";
|
|
9170
9286
|
try {
|
|
9171
|
-
|
|
9172
|
-
|
|
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
|
+
}
|
|
9173
9302
|
} catch {
|
|
9174
|
-
|
|
9303
|
+
logNote = " (log fetch failed, but run conclusion is success)";
|
|
9175
9304
|
}
|
|
9305
|
+
return { kind: "pass", message: `Smoke test passed${logNote}`, runUrl };
|
|
9176
9306
|
}
|
|
9177
|
-
|
|
9307
|
+
return { kind: "fail", message: `Run completed with conclusion=${conclusion ?? "unknown"}`, runUrl };
|
|
9178
9308
|
}
|
|
9179
|
-
|
|
9180
|
-
|
|
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]];
|
|
9181
9342
|
} else {
|
|
9182
|
-
|
|
9343
|
+
throw new Error("model required when multiple providers selected");
|
|
9183
9344
|
}
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
}
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
|
|
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 })
|
|
9201
9362
|
}
|
|
9202
|
-
|
|
9203
|
-
|
|
9204
|
-
|
|
9363
|
+
],
|
|
9364
|
+
variables: [
|
|
9365
|
+
{
|
|
9366
|
+
name: "FRO_BOT_MODEL",
|
|
9367
|
+
value: model
|
|
9205
9368
|
}
|
|
9206
|
-
|
|
9207
|
-
|
|
9208
|
-
|
|
9369
|
+
]
|
|
9370
|
+
};
|
|
9371
|
+
}
|
|
9372
|
+
if (harness === "claude-code") {
|
|
9373
|
+
return {
|
|
9374
|
+
secrets: [
|
|
9375
|
+
{
|
|
9376
|
+
name: "ANTHROPIC_API_KEY",
|
|
9377
|
+
value: keyValue
|
|
9209
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 = [];
|
|
9210
9661
|
}
|
|
9211
9662
|
}
|
|
9212
|
-
|
|
9213
|
-
|
|
9214
|
-
|
|
9215
|
-
ctx.console.error(message);
|
|
9663
|
+
if (readbackFailed) {
|
|
9664
|
+
safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames));
|
|
9665
|
+
return;
|
|
9216
9666
|
}
|
|
9217
|
-
|
|
9218
|
-
|
|
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;
|
|
9219
9671
|
}
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
9235
|
-
}
|
|
9236
|
-
if (level === "warning") {
|
|
9237
|
-
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));
|
|
9238
9687
|
}
|
|
9239
|
-
return "ERROR";
|
|
9240
9688
|
}
|
|
9241
|
-
function
|
|
9242
|
-
return
|
|
9689
|
+
function extractErrorMessage4(error) {
|
|
9690
|
+
return error instanceof Error ? error.message : String(error);
|
|
9243
9691
|
}
|
|
9244
|
-
function
|
|
9245
|
-
|
|
9692
|
+
function redactKey(key) {
|
|
9693
|
+
if (key.length < 12)
|
|
9694
|
+
return "sk-***";
|
|
9695
|
+
return `${key.slice(0, 3)}***${key.slice(-4)}`;
|
|
9246
9696
|
}
|
|
9247
|
-
async function
|
|
9248
|
-
|
|
9249
|
-
|
|
9250
|
-
|
|
9251
|
-
|
|
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);
|
|
9252
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
|
+
};
|
|
9253
9742
|
}
|
|
9254
|
-
function
|
|
9255
|
-
return
|
|
9743
|
+
function requiresDestructiveProviderChangeConfirmation(providers) {
|
|
9744
|
+
return !(providers.length === 1 && providers[0] === "anthropic");
|
|
9256
9745
|
}
|
|
9257
|
-
async function
|
|
9258
|
-
const
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
|
|
9266
|
-
|
|
9267
|
-
|
|
9268
|
-
|
|
9269
|
-
|
|
9270
|
-
|
|
9271
|
-
}
|
|
9272
|
-
return {
|
|
9273
|
-
title: "HTTP reachability",
|
|
9274
|
-
level: response.ok ? "ok" : "error",
|
|
9275
|
-
summary: `GET ${url} \u2192 ${response.status} (${formatDurationMs(elapsedMs)})`,
|
|
9276
|
-
details
|
|
9277
|
-
};
|
|
9278
|
-
} catch (error) {
|
|
9279
|
-
const elapsedMs = performance.now() - startedAt;
|
|
9280
|
-
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) {
|
|
9281
9760
|
return {
|
|
9282
|
-
|
|
9283
|
-
|
|
9284
|
-
|
|
9285
|
-
|
|
9761
|
+
repo,
|
|
9762
|
+
harness,
|
|
9763
|
+
keyValue,
|
|
9764
|
+
createKey: false,
|
|
9765
|
+
template: getHarnessTemplate(harness, { keyValue: keyValue || "sk-placeholder", baseUrl, providers, model })
|
|
9286
9766
|
};
|
|
9287
9767
|
}
|
|
9288
|
-
|
|
9289
|
-
|
|
9290
|
-
|
|
9291
|
-
for (const record of records) {
|
|
9292
|
-
if (record === null || typeof record !== "object") {
|
|
9293
|
-
continue;
|
|
9294
|
-
}
|
|
9295
|
-
const rec = record;
|
|
9296
|
-
const status = toNumber(rec.status);
|
|
9297
|
-
if (status !== null && status >= 400) {
|
|
9298
|
-
count++;
|
|
9299
|
-
continue;
|
|
9300
|
-
}
|
|
9301
|
-
if (rec.error !== undefined || rec.failed === true || rec.failure === true) {
|
|
9302
|
-
count++;
|
|
9303
|
-
}
|
|
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).`);
|
|
9304
9771
|
}
|
|
9305
|
-
|
|
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
|
+
};
|
|
9306
9780
|
}
|
|
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
|
-
|
|
9341
|
-
level: "ok",
|
|
9342
|
-
summary: "recent: 0 (idle)"
|
|
9343
|
-
};
|
|
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);
|
|
9344
9815
|
}
|
|
9345
|
-
|
|
9346
|
-
|
|
9347
|
-
|
|
9348
|
-
|
|
9349
|
-
|
|
9350
|
-
}
|
|
9351
|
-
|
|
9352
|
-
|
|
9353
|
-
|
|
9354
|
-
|
|
9355
|
-
|
|
9356
|
-
|
|
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");
|
|
9357
9851
|
}
|
|
9358
|
-
}
|
|
9359
|
-
async function checkVersion(baseUrl, key) {
|
|
9360
|
-
const endpoint = `${baseUrl}/v0/management/latest-version`;
|
|
9361
9852
|
try {
|
|
9362
|
-
|
|
9363
|
-
|
|
9364
|
-
|
|
9853
|
+
await withSpinner("Checking GitHub CLI availability", async () => {
|
|
9854
|
+
await gh.assertGhInstalled();
|
|
9855
|
+
await gh.assertGhAuthenticated();
|
|
9365
9856
|
});
|
|
9366
|
-
|
|
9367
|
-
|
|
9368
|
-
|
|
9369
|
-
|
|
9370
|
-
|
|
9371
|
-
|
|
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();
|
|
9372
9863
|
}
|
|
9373
|
-
|
|
9374
|
-
|
|
9375
|
-
|
|
9376
|
-
|
|
9377
|
-
|
|
9378
|
-
};
|
|
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.");
|
|
9379
9869
|
}
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
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.");
|
|
9385
9888
|
}
|
|
9386
9889
|
}
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9399
|
-
|
|
9400
|
-
|
|
9401
|
-
|
|
9402
|
-
try {
|
|
9403
|
-
const response = await fetch(endpoint, {
|
|
9404
|
-
method: "POST",
|
|
9405
|
-
headers: {
|
|
9406
|
-
authorization: `Bearer ${apiKey}`,
|
|
9407
|
-
"content-type": "application/json"
|
|
9408
|
-
},
|
|
9409
|
-
body: JSON.stringify({
|
|
9410
|
-
model,
|
|
9411
|
-
messages: [{ role: "user", content: "ping" }],
|
|
9412
|
-
max_tokens: 1
|
|
9413
|
-
}),
|
|
9414
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
|
|
9415
|
-
});
|
|
9416
|
-
const details = [];
|
|
9417
|
-
if (verbose) {
|
|
9418
|
-
details.push(`URL: ${endpoint}`);
|
|
9419
|
-
details.push(`Model: ${model}`);
|
|
9420
|
-
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
|
+
}
|
|
9421
9905
|
}
|
|
9422
|
-
|
|
9423
|
-
|
|
9424
|
-
|
|
9425
|
-
|
|
9426
|
-
|
|
9427
|
-
|
|
9428
|
-
|
|
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
|
+
}
|
|
9429
9926
|
}
|
|
9430
|
-
|
|
9431
|
-
|
|
9432
|
-
|
|
9433
|
-
|
|
9434
|
-
|
|
9435
|
-
|
|
9436
|
-
};
|
|
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
|
+
});
|
|
9437
9934
|
}
|
|
9438
|
-
|
|
9439
|
-
|
|
9440
|
-
|
|
9441
|
-
|
|
9442
|
-
|
|
9443
|
-
|
|
9444
|
-
|
|
9445
|
-
|
|
9446
|
-
|
|
9447
|
-
|
|
9448
|
-
|
|
9449
|
-
|
|
9450
|
-
|
|
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
|
+
}
|
|
9451
9995
|
}
|
|
9996
|
+
throw mutationError;
|
|
9452
9997
|
}
|
|
9453
|
-
|
|
9454
|
-
|
|
9455
|
-
|
|
9456
|
-
|
|
9457
|
-
details: details.length > 0 ? details : undefined
|
|
9458
|
-
};
|
|
9459
|
-
} catch (error) {
|
|
9460
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
9461
|
-
return {
|
|
9462
|
-
title: "Upstream provider auth (anthropic)",
|
|
9463
|
-
level: "warning",
|
|
9464
|
-
summary: `Anthropic probe failed: ${message}`,
|
|
9465
|
-
details: verbose ? [`URL: ${endpoint}`, `Model: ${model}`] : undefined
|
|
9466
|
-
};
|
|
9467
|
-
}
|
|
9468
|
-
}
|
|
9469
|
-
async function probeManagementAuth(baseUrl, key) {
|
|
9470
|
-
const endpoint = `${baseUrl}/v0/management/config`;
|
|
9471
|
-
try {
|
|
9472
|
-
const response = await fetch(endpoint, {
|
|
9473
|
-
headers: managementHeaders(key),
|
|
9474
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2)
|
|
9475
|
-
});
|
|
9476
|
-
if (response.ok) {
|
|
9477
|
-
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}.`);
|
|
9478
10002
|
}
|
|
9479
|
-
if (
|
|
9480
|
-
const
|
|
9481
|
-
const isBanBody = payload !== null && typeof payload === "object" && Object.values(payload).some((v) => typeof v === "string" && /\b(?:ip[- ]?)?bann?ed\b/i.test(v));
|
|
9482
|
-
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) => {
|
|
9483
10005
|
return {
|
|
9484
|
-
|
|
9485
|
-
|
|
9486
|
-
|
|
10006
|
+
kind: "unverified",
|
|
10007
|
+
message: `Smoke test error: ${extractErrorMessage4(error)}`,
|
|
10008
|
+
runUrl: undefined
|
|
9487
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
|
+
}
|
|
9488
10029
|
}
|
|
9489
|
-
return {
|
|
9490
|
-
title: "Management access",
|
|
9491
|
-
level: "error",
|
|
9492
|
-
summary: "Management API returned HTTP 403. Management checks skipped."
|
|
9493
|
-
};
|
|
9494
|
-
}
|
|
9495
|
-
if (response.status === 401) {
|
|
9496
|
-
return {
|
|
9497
|
-
title: "Management access",
|
|
9498
|
-
level: "error",
|
|
9499
|
-
summary: "Management API returned HTTP 401 (invalid key). Management checks skipped."
|
|
9500
|
-
};
|
|
9501
10030
|
}
|
|
9502
|
-
return {
|
|
9503
|
-
title: "Management access",
|
|
9504
|
-
level: "warning",
|
|
9505
|
-
summary: `Management auth probe returned HTTP ${response.status}. Management checks skipped.`
|
|
9506
|
-
};
|
|
9507
10031
|
} catch (error) {
|
|
9508
|
-
const message =
|
|
9509
|
-
|
|
9510
|
-
|
|
9511
|
-
level: "error",
|
|
9512
|
-
summary: `Management auth probe failed: ${message}`
|
|
9513
|
-
};
|
|
9514
|
-
}
|
|
9515
|
-
}
|
|
9516
|
-
async function runManagementChecks(baseUrl, key) {
|
|
9517
|
-
if (!key) {
|
|
9518
|
-
return { kind: "no-key" };
|
|
9519
|
-
}
|
|
9520
|
-
const authFailure = await probeManagementAuth(baseUrl, key);
|
|
9521
|
-
if (authFailure !== null) {
|
|
9522
|
-
return { kind: "auth-failure", result: authFailure };
|
|
9523
|
-
}
|
|
9524
|
-
const [usage, version] = await Promise.all([checkUsageStats(baseUrl, key), checkVersion(baseUrl, key)]);
|
|
9525
|
-
return { kind: "checks", usage, version };
|
|
9526
|
-
}
|
|
9527
|
-
function printCheckResult(result, ctx) {
|
|
9528
|
-
ctx.console.log(`[${levelLabel(result.level)}] ${result.title}`);
|
|
9529
|
-
ctx.console.log(` ${result.summary}`);
|
|
9530
|
-
if (result.details && result.details.length > 0) {
|
|
9531
|
-
for (const detail of result.details) {
|
|
9532
|
-
ctx.console.log(` - ${detail}`);
|
|
9533
|
-
}
|
|
9534
|
-
}
|
|
9535
|
-
}
|
|
9536
|
-
function formatCheckSummary(result) {
|
|
9537
|
-
return `${levelLabel(result.level)}: ${result.summary}`;
|
|
9538
|
-
}
|
|
9539
|
-
function formatUsageSummaryLine(result) {
|
|
9540
|
-
const recentMatch = /recent:\s*(\d+)/.exec(result.summary);
|
|
9541
|
-
if (!recentMatch) {
|
|
9542
|
-
return null;
|
|
9543
|
-
}
|
|
9544
|
-
const total = Number(recentMatch[1]);
|
|
9545
|
-
const errorMatch = /errors:\s*(\d+)/.exec(result.summary);
|
|
9546
|
-
const errors2 = errorMatch ? Number(errorMatch[1]) : 0;
|
|
9547
|
-
return `Recent requests: ${total}${errors2 > 0 ? `, ${errors2} errors` : ""}`;
|
|
9548
|
-
}
|
|
9549
|
-
async function getCliproxyStatusSummary(baseUrl, key, verbose) {
|
|
9550
|
-
const normalizedBaseUrl = stripTrailingSlash5(baseUrl);
|
|
9551
|
-
const [httpResult, mgmt] = await Promise.all([
|
|
9552
|
-
checkHttpReachability(`${normalizedBaseUrl}/healthz`, verbose),
|
|
9553
|
-
runManagementChecks(normalizedBaseUrl, key || undefined)
|
|
9554
|
-
]);
|
|
9555
|
-
let version;
|
|
9556
|
-
let usageStats;
|
|
9557
|
-
if (mgmt.kind === "no-key") {
|
|
9558
|
-
version = "\u2014 (no key)";
|
|
9559
|
-
usageStats = "\u2014 (no key)";
|
|
9560
|
-
} else if (mgmt.kind === "auth-failure") {
|
|
9561
|
-
const authSummary = formatCheckSummary(mgmt.result);
|
|
9562
|
-
version = authSummary;
|
|
9563
|
-
usageStats = authSummary;
|
|
9564
|
-
} else {
|
|
9565
|
-
version = formatCheckSummary(mgmt.version);
|
|
9566
|
-
usageStats = formatCheckSummary(mgmt.usage);
|
|
9567
|
-
}
|
|
9568
|
-
return {
|
|
9569
|
-
app: "cliproxy",
|
|
9570
|
-
http: formatCheckSummary(httpResult),
|
|
9571
|
-
lastDeploy: "\u2014",
|
|
9572
|
-
version,
|
|
9573
|
-
contentHash: "\u2014",
|
|
9574
|
-
usageStats
|
|
9575
|
-
};
|
|
9576
|
-
}
|
|
9577
|
-
async function cliproxyStatusAction(options, ctx) {
|
|
9578
|
-
let errorCount = 0;
|
|
9579
|
-
try {
|
|
9580
|
-
const verbose = options.verbose === true;
|
|
9581
|
-
const baseUrl = stripTrailingSlash5(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
|
|
9582
|
-
const trustedUrl = stripTrailingSlash5(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL6);
|
|
9583
|
-
const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl;
|
|
9584
|
-
const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY);
|
|
9585
|
-
const providerKey = options.apiKey ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY);
|
|
9586
|
-
ctx.console.log("CLIProxyAPI status");
|
|
9587
|
-
ctx.console.log("");
|
|
9588
|
-
const results = [await checkHttpReachability(`${baseUrl}/healthz`, verbose)];
|
|
9589
|
-
let capturedUsageResult;
|
|
9590
|
-
const mgmt = await runManagementChecks(baseUrl, managementKey);
|
|
9591
|
-
if (mgmt.kind === "no-key") {
|
|
9592
|
-
results.push({
|
|
9593
|
-
title: "Management checks",
|
|
9594
|
-
level: "warning",
|
|
9595
|
-
summary: "CLIPROXY_MANAGEMENT_KEY is not set. Skipping usage stats and version checks. Provide --key or set env var."
|
|
9596
|
-
});
|
|
9597
|
-
} else if (mgmt.kind === "auth-failure") {
|
|
9598
|
-
results.push(mgmt.result);
|
|
9599
|
-
} else {
|
|
9600
|
-
capturedUsageResult = mgmt.usage;
|
|
9601
|
-
results.push(mgmt.usage, mgmt.version);
|
|
9602
|
-
}
|
|
9603
|
-
if (providerKey) {
|
|
9604
|
-
results.push(await checkProviderAuth(baseUrl, providerKey, { verbose }));
|
|
9605
|
-
} else {
|
|
9606
|
-
results.push({
|
|
9607
|
-
title: "Upstream provider auth",
|
|
9608
|
-
level: "warning",
|
|
9609
|
-
summary: "CLIPROXY_API_KEY not set \u2014 skipping upstream provider probe."
|
|
9610
|
-
});
|
|
9611
|
-
}
|
|
9612
|
-
for (const result of results) {
|
|
9613
|
-
printCheckResult(result, ctx);
|
|
9614
|
-
ctx.console.log("");
|
|
10032
|
+
const message = extractErrorMessage4(error);
|
|
10033
|
+
if (ctxInjected) {
|
|
10034
|
+
ctx.console.error(message);
|
|
9615
10035
|
}
|
|
9616
|
-
|
|
9617
|
-
|
|
9618
|
-
ctx.console.log(`Summary: ${results.length} checks, ${errorCount} errors, ${warningCount} warnings`);
|
|
9619
|
-
if (capturedUsageResult) {
|
|
9620
|
-
const usageLine = formatUsageSummaryLine(capturedUsageResult);
|
|
9621
|
-
if (usageLine) {
|
|
9622
|
-
ctx.console.log(usageLine);
|
|
9623
|
-
}
|
|
10036
|
+
if (interactive) {
|
|
10037
|
+
cancel2(message);
|
|
9624
10038
|
}
|
|
9625
|
-
|
|
9626
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
9627
|
-
ctx.console.error(message);
|
|
9628
|
-
ctx.process.exit(1);
|
|
9629
|
-
return;
|
|
9630
|
-
}
|
|
9631
|
-
if (errorCount > 0) {
|
|
9632
|
-
ctx.process.exit(1);
|
|
10039
|
+
throw error;
|
|
9633
10040
|
}
|
|
9634
10041
|
}
|
|
9635
|
-
function
|
|
9636
|
-
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
|
+
});
|
|
9637
10046
|
}
|
|
9638
10047
|
|
|
9639
10048
|
// src/commands/cliproxy/index.ts
|
|
@@ -9644,12 +10053,13 @@ function registerCliproxyCommands(cli) {
|
|
|
9644
10053
|
registerCliproxyKeys(cli);
|
|
9645
10054
|
registerCliproxyLogin(cli);
|
|
9646
10055
|
registerCliproxyModels(cli);
|
|
10056
|
+
registerCliproxyMonitor(cli);
|
|
9647
10057
|
registerCliproxyOpen(cli);
|
|
9648
10058
|
registerCliproxySetup(cli);
|
|
9649
10059
|
}
|
|
9650
10060
|
|
|
9651
10061
|
// src/commands/dashboard/deploy.ts
|
|
9652
|
-
import { z as
|
|
10062
|
+
import { z as z17 } from "zod";
|
|
9653
10063
|
var REPO3 = "marcusrbrown/infra";
|
|
9654
10064
|
var WORKFLOW_NAME3 = "Deploy Dashboard";
|
|
9655
10065
|
var WORKFLOW_URL3 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-dashboard.yaml";
|
|
@@ -9711,7 +10121,7 @@ function validateDashboardRemotePreconditions() {
|
|
|
9711
10121
|
}
|
|
9712
10122
|
}
|
|
9713
10123
|
function registerDashboardDeploy(cli) {
|
|
9714
|
-
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) => {
|
|
9715
10125
|
validateReleaseFlags({ imageVersion: options.imageVersion, digest: options.digest, local: options.local });
|
|
9716
10126
|
if (options.local) {
|
|
9717
10127
|
const command = ["bun", "run", "--cwd", "apps/dashboard", "deploy"];
|
|
@@ -9754,7 +10164,7 @@ function registerDashboardDeploy(cli) {
|
|
|
9754
10164
|
}
|
|
9755
10165
|
|
|
9756
10166
|
// src/commands/dashboard/logs.ts
|
|
9757
|
-
import { z as
|
|
10167
|
+
import { z as z18 } from "zod";
|
|
9758
10168
|
|
|
9759
10169
|
// ../shared/server/droplet-helpers.ts
|
|
9760
10170
|
import { appendFileSync, chmodSync as chmodSync2, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
|
|
@@ -9866,7 +10276,7 @@ async function streamDashboardLogs(opts, spawn = defaultLogsSpawn2, printOut, pr
|
|
|
9866
10276
|
return { refused: false, exitCode };
|
|
9867
10277
|
}
|
|
9868
10278
|
function registerDashboardLogs(cli) {
|
|
9869
|
-
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) => {
|
|
9870
10280
|
const targetService = service ?? "dashboard";
|
|
9871
10281
|
try {
|
|
9872
10282
|
validateService(targetService);
|
|
@@ -9896,7 +10306,7 @@ function registerDashboardLogs(cli) {
|
|
|
9896
10306
|
}
|
|
9897
10307
|
|
|
9898
10308
|
// src/commands/dashboard/status.ts
|
|
9899
|
-
import { z as
|
|
10309
|
+
import { z as z19 } from "zod";
|
|
9900
10310
|
|
|
9901
10311
|
// src/lib/redact.ts
|
|
9902
10312
|
function redactHost(text4, host) {
|
|
@@ -10080,7 +10490,7 @@ async function dashboardStatusAction(options, ctx, spawn) {
|
|
|
10080
10490
|
}
|
|
10081
10491
|
}
|
|
10082
10492
|
function registerDashboardStatus(cli) {
|
|
10083
|
-
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));
|
|
10084
10494
|
}
|
|
10085
10495
|
|
|
10086
10496
|
// src/commands/dashboard/index.ts
|
|
@@ -10092,7 +10502,7 @@ function registerDashboardCommands(cli) {
|
|
|
10092
10502
|
|
|
10093
10503
|
// src/commands/gateway/backup.ts
|
|
10094
10504
|
import { closeSync, constants as fsConstants, openSync, renameSync, unlinkSync, writeSync } from "fs";
|
|
10095
|
-
import { z as
|
|
10505
|
+
import { z as z20 } from "zod";
|
|
10096
10506
|
|
|
10097
10507
|
// src/commands/gateway/host.ts
|
|
10098
10508
|
var VALID_HOST_RE4 = /^[a-z\d][a-z\d.\-]*$/i;
|
|
@@ -10202,11 +10612,11 @@ async function gatewayBackupAction(options, ctx, spawn) {
|
|
|
10202
10612
|
ctx.console.log(`CA backup written to: ${output}`);
|
|
10203
10613
|
}
|
|
10204
10614
|
function registerGatewayBackup(cli) {
|
|
10205
|
-
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);
|
|
10206
10616
|
}
|
|
10207
10617
|
|
|
10208
10618
|
// src/commands/gateway/deploy.ts
|
|
10209
|
-
import { z as
|
|
10619
|
+
import { z as z21 } from "zod";
|
|
10210
10620
|
var REPO4 = "marcusrbrown/infra";
|
|
10211
10621
|
var WORKFLOW_NAME4 = "Deploy Gateway";
|
|
10212
10622
|
var WORKFLOW_URL4 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-gateway.yaml";
|
|
@@ -10271,7 +10681,7 @@ function validateGatewayRemotePreconditions() {
|
|
|
10271
10681
|
}
|
|
10272
10682
|
}
|
|
10273
10683
|
function registerGatewayDeploy(cli) {
|
|
10274
|
-
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) => {
|
|
10275
10685
|
if (options.local) {
|
|
10276
10686
|
const command = [
|
|
10277
10687
|
"bun",
|
|
@@ -10319,7 +10729,7 @@ function registerGatewayDeploy(cli) {
|
|
|
10319
10729
|
}
|
|
10320
10730
|
|
|
10321
10731
|
// src/commands/gateway/logs.ts
|
|
10322
|
-
import { z as
|
|
10732
|
+
import { z as z22 } from "zod";
|
|
10323
10733
|
var COMPOSE_PROJECT_DIR3 = "/opt/gateway/deploy";
|
|
10324
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.";
|
|
10325
10735
|
var VALID_SERVICES2 = ["gateway", "workspace", "mitmproxy"];
|
|
@@ -10368,7 +10778,7 @@ async function streamGatewayLogs(opts, spawn = defaultLogsSpawn3, printOut, prin
|
|
|
10368
10778
|
return { refused: false, exitCode };
|
|
10369
10779
|
}
|
|
10370
10780
|
function registerGatewayLogs(cli) {
|
|
10371
|
-
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) => {
|
|
10372
10782
|
const targetService = service ?? "gateway";
|
|
10373
10783
|
try {
|
|
10374
10784
|
validateService2(targetService);
|
|
@@ -10398,7 +10808,7 @@ function registerGatewayLogs(cli) {
|
|
|
10398
10808
|
}
|
|
10399
10809
|
|
|
10400
10810
|
// src/commands/gateway/restore.ts
|
|
10401
|
-
import { z as
|
|
10811
|
+
import { z as z23 } from "zod";
|
|
10402
10812
|
var MITMPROXY_CERTS_VOLUME2 = "mitmproxy-certs";
|
|
10403
10813
|
var CA_CERT_FILE2 = "mitmproxy-ca-cert.pem";
|
|
10404
10814
|
var CA_KEY_FILE2 = "mitmproxy-ca.pem";
|
|
@@ -10552,7 +10962,7 @@ async function restoreGatewayCa(opts, spawn = defaultSpawn3) {
|
|
|
10552
10962
|
}
|
|
10553
10963
|
}
|
|
10554
10964
|
function registerGatewayRestore(cli) {
|
|
10555
|
-
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) => {
|
|
10556
10966
|
const hostEnvKey = "GATEWAY_HOST";
|
|
10557
10967
|
const host = process.env[hostEnvKey];
|
|
10558
10968
|
if (!host) {
|
|
@@ -10573,7 +10983,7 @@ function registerGatewayRestore(cli) {
|
|
|
10573
10983
|
}
|
|
10574
10984
|
|
|
10575
10985
|
// src/commands/gateway/status.ts
|
|
10576
|
-
import { z as
|
|
10986
|
+
import { z as z24 } from "zod";
|
|
10577
10987
|
var COMPOSE_PROJECT_DIR5 = "/opt/gateway/deploy";
|
|
10578
10988
|
function parseComposePsOutput2(stdoutText) {
|
|
10579
10989
|
const trimmed = stdoutText.trim();
|
|
@@ -10715,7 +11125,7 @@ async function gatewayStatusAction(options, ctx, spawn) {
|
|
|
10715
11125
|
}
|
|
10716
11126
|
}
|
|
10717
11127
|
function registerGatewayStatus(cli) {
|
|
10718
|
-
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));
|
|
10719
11129
|
}
|
|
10720
11130
|
|
|
10721
11131
|
// src/commands/gateway/index.ts
|
|
@@ -10730,7 +11140,7 @@ function registerGatewayCommands(cli) {
|
|
|
10730
11140
|
// src/commands/keeweb/deploy.ts
|
|
10731
11141
|
import { existsSync as existsSync5 } from "fs";
|
|
10732
11142
|
import { join as join6 } from "path";
|
|
10733
|
-
import { z as
|
|
11143
|
+
import { z as z25 } from "zod";
|
|
10734
11144
|
var REPO5 = "marcusrbrown/infra";
|
|
10735
11145
|
var WORKFLOW_NAME5 = "Deploy KeeWeb";
|
|
10736
11146
|
var WORKFLOW_URL5 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-keeweb.yaml";
|
|
@@ -10784,7 +11194,7 @@ function validateRemotePreconditions3() {
|
|
|
10784
11194
|
}
|
|
10785
11195
|
}
|
|
10786
11196
|
function registerKeewebDeploy(cli) {
|
|
10787
|
-
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) => {
|
|
10788
11198
|
if (options.nginx && !options.local) {
|
|
10789
11199
|
throw new Error("--nginx is only valid with --local");
|
|
10790
11200
|
}
|
|
@@ -10863,13 +11273,13 @@ function registerKeewebOpen(cli) {
|
|
|
10863
11273
|
|
|
10864
11274
|
// src/commands/keeweb/status.ts
|
|
10865
11275
|
import { join as join7 } from "path";
|
|
10866
|
-
import { z as
|
|
11276
|
+
import { z as z26 } from "zod";
|
|
10867
11277
|
var SITE_URL = "https://kw.igg.ms/";
|
|
10868
11278
|
var GH_REPO = "marcusrbrown/infra";
|
|
10869
11279
|
var HTTP_TIMEOUT_MS4 = 1e4;
|
|
10870
|
-
var ghRunSchema =
|
|
10871
|
-
createdAt:
|
|
10872
|
-
url:
|
|
11280
|
+
var ghRunSchema = z26.array(z26.object({
|
|
11281
|
+
createdAt: z26.string().min(1),
|
|
11282
|
+
url: z26.url()
|
|
10873
11283
|
}));
|
|
10874
11284
|
function levelLabel2(level) {
|
|
10875
11285
|
if (level === "ok") {
|
|
@@ -11167,10 +11577,10 @@ function registerMcp(cli) {
|
|
|
11167
11577
|
}
|
|
11168
11578
|
|
|
11169
11579
|
// src/commands/status.ts
|
|
11170
|
-
import { z as
|
|
11580
|
+
import { z as z35 } from "zod";
|
|
11171
11581
|
|
|
11172
11582
|
// src/commands/umami/deploy.ts
|
|
11173
|
-
import { z as
|
|
11583
|
+
import { z as z27 } from "zod";
|
|
11174
11584
|
var REPO6 = "marcusrbrown/infra";
|
|
11175
11585
|
var WORKFLOW_NAME6 = "Deploy Umami";
|
|
11176
11586
|
var WORKFLOW_URL6 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-umami.yaml";
|
|
@@ -11205,7 +11615,7 @@ function validateUmamiRemotePreconditions() {
|
|
|
11205
11615
|
}
|
|
11206
11616
|
}
|
|
11207
11617
|
function registerUmamiDeploy(cli) {
|
|
11208
|
-
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) => {
|
|
11209
11619
|
if (options.local) {
|
|
11210
11620
|
const command = ["bun", "run", "--cwd", "apps/umami", "deploy"];
|
|
11211
11621
|
if (options.dryRun) {
|
|
@@ -11246,7 +11656,7 @@ function registerUmamiDeploy(cli) {
|
|
|
11246
11656
|
}
|
|
11247
11657
|
|
|
11248
11658
|
// src/commands/umami/logs.ts
|
|
11249
|
-
import { z as
|
|
11659
|
+
import { z as z28 } from "zod";
|
|
11250
11660
|
|
|
11251
11661
|
// src/commands/umami/host.ts
|
|
11252
11662
|
var VALID_HOST_RE5 = /^[a-z\d][a-z\d.\-]*$/i;
|
|
@@ -11310,7 +11720,7 @@ async function streamUmamiLogs(opts, spawn = defaultLogsSpawn4, printOut, printE
|
|
|
11310
11720
|
return { refused: false, exitCode };
|
|
11311
11721
|
}
|
|
11312
11722
|
function registerUmamiLogs(cli) {
|
|
11313
|
-
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) => {
|
|
11314
11724
|
const targetService = service ?? "umami";
|
|
11315
11725
|
try {
|
|
11316
11726
|
validateService3(targetService);
|
|
@@ -11340,7 +11750,7 @@ function registerUmamiLogs(cli) {
|
|
|
11340
11750
|
}
|
|
11341
11751
|
|
|
11342
11752
|
// src/commands/umami/status.ts
|
|
11343
|
-
import { z as
|
|
11753
|
+
import { z as z29 } from "zod";
|
|
11344
11754
|
var COMPOSE_PROJECT_DIR7 = "/opt/umami";
|
|
11345
11755
|
function parseComposePsOutput3(stdoutText) {
|
|
11346
11756
|
const trimmed = stdoutText.trim();
|
|
@@ -11511,7 +11921,7 @@ async function umamiStatusAction(options, ctx, spawn) {
|
|
|
11511
11921
|
}
|
|
11512
11922
|
}
|
|
11513
11923
|
function registerUmamiStatus(cli) {
|
|
11514
|
-
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));
|
|
11515
11925
|
}
|
|
11516
11926
|
|
|
11517
11927
|
// src/commands/umami/index.ts
|
|
@@ -11524,24 +11934,24 @@ function registerUmamiCommands(cli) {
|
|
|
11524
11934
|
// src/commands/vpn/client.ts
|
|
11525
11935
|
import { mkdir, writeFile as writeFile2 } from "fs/promises";
|
|
11526
11936
|
import { join as join8, resolve as resolve2 } from "path";
|
|
11527
|
-
import { z as
|
|
11937
|
+
import { z as z31 } from "zod";
|
|
11528
11938
|
|
|
11529
11939
|
// src/commands/vpn/peers.ts
|
|
11530
11940
|
import { readFile, writeFile } from "fs/promises";
|
|
11531
|
-
import { z as
|
|
11532
|
-
var peerSchema =
|
|
11533
|
-
name:
|
|
11534
|
-
publicKey:
|
|
11535
|
-
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()
|
|
11536
11946
|
});
|
|
11537
|
-
var peersFileSchema =
|
|
11538
|
-
peers:
|
|
11947
|
+
var peersFileSchema = z30.object({
|
|
11948
|
+
peers: z30.array(peerSchema)
|
|
11539
11949
|
}).superRefine((data, ctx) => {
|
|
11540
11950
|
const seenIps = new Set;
|
|
11541
11951
|
for (const peer of data.peers) {
|
|
11542
11952
|
if (seenIps.has(peer.tunnelIp)) {
|
|
11543
11953
|
ctx.addIssue({
|
|
11544
|
-
code:
|
|
11954
|
+
code: z30.ZodIssueCode.custom,
|
|
11545
11955
|
message: `Duplicate tunnel IP "${peer.tunnelIp}" \u2014 each peer must have a unique tunnel IP.`,
|
|
11546
11956
|
path: ["peers"]
|
|
11547
11957
|
});
|
|
@@ -11553,7 +11963,7 @@ var peersFileSchema = z29.object({
|
|
|
11553
11963
|
for (const peer of data.peers) {
|
|
11554
11964
|
if (seenNames.has(peer.name)) {
|
|
11555
11965
|
ctx.addIssue({
|
|
11556
|
-
code:
|
|
11966
|
+
code: z30.ZodIssueCode.custom,
|
|
11557
11967
|
message: `Duplicate name "${peer.name}" \u2014 each peer must have a unique name.`,
|
|
11558
11968
|
path: ["peers"]
|
|
11559
11969
|
});
|
|
@@ -11795,7 +12205,7 @@ function resolveDefaultClientsDir() {
|
|
|
11795
12205
|
return join8(findRepoRoot(), "apps", "vpn", "clients");
|
|
11796
12206
|
}
|
|
11797
12207
|
function registerVpnClient(cli) {
|
|
11798
|
-
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) => {
|
|
11799
12209
|
const serverPublicKey = options.serverPubkey ?? process.env.VPN_SERVER_PUBKEY;
|
|
11800
12210
|
const endpoint = options.endpoint ?? process.env.VPN_HOST;
|
|
11801
12211
|
if (!serverPublicKey) {
|
|
@@ -11859,7 +12269,7 @@ function registerVpnClient(cli) {
|
|
|
11859
12269
|
}
|
|
11860
12270
|
|
|
11861
12271
|
// src/commands/vpn/deploy.ts
|
|
11862
|
-
import { z as
|
|
12272
|
+
import { z as z32 } from "zod";
|
|
11863
12273
|
var REPO7 = "marcusrbrown/infra";
|
|
11864
12274
|
var WORKFLOW_NAME7 = "Deploy VPN";
|
|
11865
12275
|
var WORKFLOW_URL7 = "https://github.com/marcusrbrown/infra/actions/workflows/deploy-vpn.yaml";
|
|
@@ -11889,7 +12299,7 @@ function validateVpnRemotePreconditions() {
|
|
|
11889
12299
|
}
|
|
11890
12300
|
}
|
|
11891
12301
|
function registerVpnDeploy(cli) {
|
|
11892
|
-
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) => {
|
|
11893
12303
|
if (options.local) {
|
|
11894
12304
|
const command = [
|
|
11895
12305
|
"bun",
|
|
@@ -11937,7 +12347,7 @@ function registerVpnDeploy(cli) {
|
|
|
11937
12347
|
}
|
|
11938
12348
|
|
|
11939
12349
|
// src/commands/vpn/logs.ts
|
|
11940
|
-
import { z as
|
|
12350
|
+
import { z as z33 } from "zod";
|
|
11941
12351
|
|
|
11942
12352
|
// src/commands/vpn/host.ts
|
|
11943
12353
|
var VALID_HOST_RE6 = /^[a-z\d][a-z\d.\-]*$/i;
|
|
@@ -11993,7 +12403,7 @@ async function streamVpnLogs(opts, spawn = defaultLogsSpawn5, printOut, printErr
|
|
|
11993
12403
|
return { refused: false, exitCode };
|
|
11994
12404
|
}
|
|
11995
12405
|
function registerVpnLogs(cli) {
|
|
11996
|
-
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) => {
|
|
11997
12407
|
const hostEnvKey = options.key ?? "VPN_HOST";
|
|
11998
12408
|
const host = process.env[hostEnvKey];
|
|
11999
12409
|
if (!host) {
|
|
@@ -12015,7 +12425,7 @@ function registerVpnLogs(cli) {
|
|
|
12015
12425
|
}
|
|
12016
12426
|
|
|
12017
12427
|
// src/commands/vpn/status.ts
|
|
12018
|
-
import { z as
|
|
12428
|
+
import { z as z34 } from "zod";
|
|
12019
12429
|
|
|
12020
12430
|
// src/commands/vpn/shared.ts
|
|
12021
12431
|
function parseWgShowOutput(output) {
|
|
@@ -12142,7 +12552,7 @@ async function vpnStatusAction(options, ctx, spawn) {
|
|
|
12142
12552
|
}
|
|
12143
12553
|
}
|
|
12144
12554
|
function registerVpnStatus(cli) {
|
|
12145
|
-
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));
|
|
12146
12556
|
}
|
|
12147
12557
|
|
|
12148
12558
|
// src/commands/vpn/index.ts
|
|
@@ -12154,7 +12564,7 @@ function registerVpnCommands(cli) {
|
|
|
12154
12564
|
}
|
|
12155
12565
|
|
|
12156
12566
|
// src/commands/status.ts
|
|
12157
|
-
var
|
|
12567
|
+
var DEFAULT_CLIPROXY_URL8 = "https://cliproxy.fro.bot";
|
|
12158
12568
|
var ERROR_PREFIX = "\u274C";
|
|
12159
12569
|
var TABLE_COLUMNS = [
|
|
12160
12570
|
{ key: "app", label: "App" },
|
|
@@ -12204,7 +12614,7 @@ async function unifiedStatusAction(options, ctx, dependencies = {
|
|
|
12204
12614
|
getBrokerStatusSummary
|
|
12205
12615
|
}) {
|
|
12206
12616
|
const verbose = options.verbose === true;
|
|
12207
|
-
const cliproxyBaseUrl = stripTrailingSlash6(process.env.CLIPROXY_URL ??
|
|
12617
|
+
const cliproxyBaseUrl = stripTrailingSlash6(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL8);
|
|
12208
12618
|
const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? "";
|
|
12209
12619
|
const gatewayHost = process.env.GATEWAY_HOST ?? "";
|
|
12210
12620
|
const umamiHost = process.env.UMAMI_DOMAIN ?? "";
|
|
@@ -12249,7 +12659,7 @@ function registerStatus(cli, dependencies = {
|
|
|
12249
12659
|
getVpnStatusSummary,
|
|
12250
12660
|
getBrokerStatusSummary
|
|
12251
12661
|
}) {
|
|
12252
|
-
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));
|
|
12253
12663
|
}
|
|
12254
12664
|
|
|
12255
12665
|
// src/cli.ts
|