@bivy/bivy 0.0.0 → 0.1.0-staging.2
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/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { SecretVault } from "./secrets.js";
|
|
7
|
+
import { privateKeyIdFor, upsertGitHubApp } from "./github-apps.js";
|
|
8
|
+
import { createAppJwt } from "./github-app-auth.js";
|
|
9
|
+
/**
|
|
10
|
+
* Fetch the app's slug + name from GitHub with an app JWT. The slug is the
|
|
11
|
+
* unique `@`-mention handle; failure is non-fatal (we just skip registration).
|
|
12
|
+
*/
|
|
13
|
+
async function fetchAppIdentity(appId, privateKeyPem) {
|
|
14
|
+
try {
|
|
15
|
+
const jwt = createAppJwt(appId, privateKeyPem, Math.floor(Date.now() / 1000));
|
|
16
|
+
const res = await fetch("https://api.github.com/app", {
|
|
17
|
+
headers: { authorization: `Bearer ${jwt}`, accept: "application/vnd.github+json", "user-agent": "bivy" },
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
return undefined;
|
|
21
|
+
const data = (await res.json().catch(() => ({})));
|
|
22
|
+
return { slug: String(data.slug ?? ""), name: String(data.name ?? data.slug ?? "") };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Point the app's OWN webhook at the given URL + secret via the app JWT
|
|
30
|
+
* (PATCH /app/hook/config), mirroring the web connect flow so the CLI no longer
|
|
31
|
+
* makes the user paste the webhook by hand. Best-effort: returns false and the
|
|
32
|
+
* caller falls back to printing manual instructions if GitHub rejects it.
|
|
33
|
+
*/
|
|
34
|
+
async function configureAppWebhook(appId, privateKeyPem, url, secret) {
|
|
35
|
+
try {
|
|
36
|
+
const jwt = createAppJwt(appId, privateKeyPem, Math.floor(Date.now() / 1000));
|
|
37
|
+
const res = await fetch("https://api.github.com/app/hook/config", {
|
|
38
|
+
method: "PATCH",
|
|
39
|
+
headers: { authorization: `Bearer ${jwt}`, accept: "application/vnd.github+json", "user-agent": "bivy", "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ url, secret, content_type: "json" }),
|
|
41
|
+
});
|
|
42
|
+
return res.ok;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a GitHub App's numeric App ID. A numeric value passes through; a slug
|
|
50
|
+
* (e.g. "bivy-app") is looked up via GET /apps/{slug} — which GitHub 404s when
|
|
51
|
+
* unauthenticated, so it needs a token from --github-token or GITHUB_TOKEN /
|
|
52
|
+
* GH_TOKEN. Without a token a slug can't be resolved, so we ask for the numeric
|
|
53
|
+
* id instead (there is no other way to get it programmatically for an existing
|
|
54
|
+
* app the node doesn't yet hold a key for).
|
|
55
|
+
*/
|
|
56
|
+
async function resolveAppId(value, token) {
|
|
57
|
+
if (/^\d+$/.test(value))
|
|
58
|
+
return value;
|
|
59
|
+
if (!token) {
|
|
60
|
+
throw new Error(`"${value}" is not a numeric App ID. To connect by slug pass --github-token <token> ` +
|
|
61
|
+
`(or set GITHUB_TOKEN), or supply the numeric --app-id from the app's settings page.`);
|
|
62
|
+
}
|
|
63
|
+
const res = await fetch(`https://api.github.com/apps/${encodeURIComponent(value)}`, {
|
|
64
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": "bivy" },
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok)
|
|
67
|
+
throw new Error(`Could not resolve GitHub App slug "${value}" (${res.status}). Check the slug and the token's scope.`);
|
|
68
|
+
const data = (await res.json().catch(() => ({})));
|
|
69
|
+
if (!data.id)
|
|
70
|
+
throw new Error(`GitHub returned no App ID for slug "${value}".`);
|
|
71
|
+
return String(data.id);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `bivy github:app-connect` — connect a user-owned GitHub App (M2, flavor A).
|
|
75
|
+
*
|
|
76
|
+
* Stores the app's private key in the node vault and the app id in cli.json, then
|
|
77
|
+
* asks the control plane for a `github_app` inbound hook and prints the webhook
|
|
78
|
+
* URL + secret to paste into the app (one webhook covers every repo that app is
|
|
79
|
+
* installed on). A node may hold several apps — a private app only installs on
|
|
80
|
+
* the account that owns it, so personal repos and each org need one app each.
|
|
81
|
+
*
|
|
82
|
+
* The private key stays on the node (the node mints its own installation tokens);
|
|
83
|
+
* the control plane only ever holds the webhook signing secret. So a single app
|
|
84
|
+
* install replaces per-repo webhooks without weakening the privacy model.
|
|
85
|
+
*
|
|
86
|
+
* The app's webhook is configured automatically (PATCH /app/hook/config via the
|
|
87
|
+
* app JWT) — parity with the web connect flow — so nothing has to be pasted.
|
|
88
|
+
*
|
|
89
|
+
* Usage:
|
|
90
|
+
* bivy github:app-connect (--app-id <id> | --slug <slug>) --key <path.pem>
|
|
91
|
+
* [--label bivy] [--node-label <name>]
|
|
92
|
+
* [--github-token <t>] # resolve --slug -> App ID
|
|
93
|
+
* [--rotate-webhook] # mint a fresh signing secret
|
|
94
|
+
*/
|
|
95
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
96
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
97
|
+
const appDir = process.env.BIVY_DATA_DIR ?? path.join(repoRoot, ".bivy");
|
|
98
|
+
const cliConfigPath = path.join(appDir, "cli.json");
|
|
99
|
+
const relayConfigPath = path.join(appDir, "relay.json");
|
|
100
|
+
function argValue(args, name) {
|
|
101
|
+
const i = args.indexOf(`--${name}`);
|
|
102
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
103
|
+
}
|
|
104
|
+
function hasFlag(args, name) {
|
|
105
|
+
return args.includes(`--${name}`);
|
|
106
|
+
}
|
|
107
|
+
function loadCliConfig() {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(fs.readFileSync(cliConfigPath, "utf8"));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function loadRelayConfig() {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(fs.readFileSync(relayConfigPath, "utf8"));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function main() {
|
|
124
|
+
const args = process.argv.slice(2);
|
|
125
|
+
const appIdOrSlug = (argValue(args, "app-id") || argValue(args, "slug") || process.env.BIVY_GITHUB_APP_ID || "").trim();
|
|
126
|
+
const keyPath = (argValue(args, "key") || "").trim();
|
|
127
|
+
const baseLabel = (argValue(args, "label") || "bivy").trim();
|
|
128
|
+
const nodeLabelArg = (argValue(args, "node-label") || "").trim();
|
|
129
|
+
const nodeLabel = nodeLabelArg ? (nodeLabelArg.includes("/") ? nodeLabelArg : `bivy/${nodeLabelArg}`) : "";
|
|
130
|
+
const githubToken = (argValue(args, "github-token") || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "").trim();
|
|
131
|
+
const rotateWebhook = hasFlag(args, "rotate-webhook");
|
|
132
|
+
if (!appIdOrSlug)
|
|
133
|
+
throw new Error("Missing --app-id (numeric App ID) or --slug (the app's slug).");
|
|
134
|
+
if (!keyPath)
|
|
135
|
+
throw new Error("Missing --key (path to the app's .pem private key).");
|
|
136
|
+
const privateKeyPem = fs.readFileSync(keyPath, "utf8");
|
|
137
|
+
if (!privateKeyPem.includes("PRIVATE KEY"))
|
|
138
|
+
throw new Error(`${keyPath} does not look like a PEM private key.`);
|
|
139
|
+
const relay = loadRelayConfig();
|
|
140
|
+
if (!relay?.controlPlaneUrl || !relay?.enrollmentToken) {
|
|
141
|
+
throw new Error("This node isn't enrolled with a control plane. Run 'bivy relay:setup' first.");
|
|
142
|
+
}
|
|
143
|
+
// A slug (e.g. "bivy-app") is resolved to the numeric App ID everything else
|
|
144
|
+
// keys on; a numeric value passes straight through.
|
|
145
|
+
const appId = await resolveAppId(appIdOrSlug, githubToken);
|
|
146
|
+
// 1) Private key → node vault (never leaves the machine). Keyed per app, so a
|
|
147
|
+
// node can hold several apps' keys side by side (personal account + orgs).
|
|
148
|
+
const keyId = privateKeyIdFor(appId);
|
|
149
|
+
new SecretVault(appDir).setLocal(keyId, privateKeyPem, `GitHub App private key (${appId})`);
|
|
150
|
+
// 2) Reuse this app's existing hook if it has one (so its already-configured
|
|
151
|
+
// webhook keeps working), otherwise ask for a new one. Each app needs its own:
|
|
152
|
+
// the hook's secret is what GitHub signs that app's deliveries with.
|
|
153
|
+
// --rotate-webhook skips the adopt step to force a fresh secret (e.g. after the
|
|
154
|
+
// old one leaked); we re-point the app's webhook at it below.
|
|
155
|
+
const existing = rotateWebhook
|
|
156
|
+
? undefined
|
|
157
|
+
: await fetch(`${relay.controlPlaneUrl.replace(/\/$/, "")}/node/hooks/github_app?appId=${encodeURIComponent(appId)}`, { headers: { authorization: `Bearer ${relay.enrollmentToken}` } })
|
|
158
|
+
.then((r) => (r.ok ? r.json() : undefined))
|
|
159
|
+
.catch(() => undefined);
|
|
160
|
+
const res = existing?.id && existing?.url && existing?.secret
|
|
161
|
+
? new Response(JSON.stringify(existing), { status: 200, headers: { "content-type": "application/json" } })
|
|
162
|
+
: await fetch(`${relay.controlPlaneUrl.replace(/\/$/, "")}/node/hooks`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { authorization: `Bearer ${relay.enrollmentToken}`, "content-type": "application/json" },
|
|
165
|
+
body: JSON.stringify({ kind: "github_app" }),
|
|
166
|
+
});
|
|
167
|
+
const hook = (await res.json().catch(() => ({})));
|
|
168
|
+
if (!res.ok || !hook.url || !hook.secret) {
|
|
169
|
+
throw new Error(hook.error || `Control plane did not return a hook (${res.status}).`);
|
|
170
|
+
}
|
|
171
|
+
// 2b) Resolve the app's slug (its unique `@`-mention handle) + name and
|
|
172
|
+
// register them so mentions route correctly and the UI shows what's connected.
|
|
173
|
+
const identity = await fetchAppIdentity(appId, privateKeyPem);
|
|
174
|
+
if (hook.id && identity?.slug) {
|
|
175
|
+
await fetch(`${relay.controlPlaneUrl.replace(/\/$/, "")}/node/hooks/${encodeURIComponent(hook.id)}/app-meta`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { authorization: `Bearer ${relay.enrollmentToken}`, "content-type": "application/json" },
|
|
178
|
+
body: JSON.stringify({ mention: identity.slug, name: identity.name, appId }),
|
|
179
|
+
}).catch(() => { });
|
|
180
|
+
}
|
|
181
|
+
// 2c) Point the app's own webhook at the hook automatically (parity with the
|
|
182
|
+
// web connect flow), so there is nothing to paste by hand. Best-effort.
|
|
183
|
+
const webhookConfigured = await configureAppWebhook(appId, privateKeyPem, hook.url, hook.secret);
|
|
184
|
+
// 3) Record the app in the node's registry (app id + a secret:// reference,
|
|
185
|
+
// never the raw key). The registry is what lets one node serve several apps;
|
|
186
|
+
// the env block below only ever describes one, and is kept for container and
|
|
187
|
+
// ephemeral setups configured purely through the environment.
|
|
188
|
+
upsertGitHubApp(appDir, {
|
|
189
|
+
appId,
|
|
190
|
+
slug: identity?.slug,
|
|
191
|
+
name: identity?.name,
|
|
192
|
+
privateKeyRef: `secret://${keyId}`,
|
|
193
|
+
hookId: hook.id,
|
|
194
|
+
});
|
|
195
|
+
const config = loadCliConfig();
|
|
196
|
+
const env = (config.env && typeof config.env === "object" ? config.env : {});
|
|
197
|
+
config.env = {
|
|
198
|
+
...env,
|
|
199
|
+
BIVY_GITHUB_HOSTED_TASKS: "1",
|
|
200
|
+
BIVY_GITHUB_LABEL: baseLabel,
|
|
201
|
+
...(identity?.slug ? { BIVY_GITHUB_APP_SLUG: identity.slug } : {}),
|
|
202
|
+
...(nodeLabel ? { BIVY_NODE_LABEL: nodeLabel } : {}),
|
|
203
|
+
};
|
|
204
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
205
|
+
fs.writeFileSync(cliConfigPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
206
|
+
try {
|
|
207
|
+
fs.chmodSync(cliConfigPath, 0o600);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// best effort
|
|
211
|
+
}
|
|
212
|
+
console.log("\n✓ GitHub App connected. Private key stored in this node's vault (cli.json holds only a secret:// ref).\n");
|
|
213
|
+
if (webhookConfigured) {
|
|
214
|
+
console.log("✓ Webhook configured on the app automatically — nothing to paste.");
|
|
215
|
+
console.log(` Payload URL: ${hook.url}`);
|
|
216
|
+
console.log(" Events: Issues, Issue comment");
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
console.log("Could not set the webhook automatically. Point the app's webhook at this URL by hand");
|
|
220
|
+
console.log("(one webhook covers every repo this app is installed on):");
|
|
221
|
+
console.log(` Payload URL: ${hook.url}`);
|
|
222
|
+
console.log(` Secret: ${hook.secret}`);
|
|
223
|
+
console.log(" Content type: application/json");
|
|
224
|
+
console.log(" Events: Issues, Issue comment");
|
|
225
|
+
}
|
|
226
|
+
console.log("\nApp permissions needed: Issues (RW), Contents (RW), Pull requests (RW).");
|
|
227
|
+
console.log(`Then install the app on your repos. Work routes to label ${baseLabel}${nodeLabel ? ` / ${nodeLabel}` : ""}.`);
|
|
228
|
+
if (identity?.slug)
|
|
229
|
+
console.log(`Trigger it from an issue comment with: @${identity.slug} <instruction>`);
|
|
230
|
+
console.log("Restart the node to apply: bivy restart (or bivy start).");
|
|
231
|
+
}
|
|
232
|
+
main().catch((error) => {
|
|
233
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
234
|
+
process.exit(1);
|
|
235
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/**
|
|
4
|
+
* GitHub App **manifest** flow (M2, one-click create).
|
|
5
|
+
*
|
|
6
|
+
* Instead of hand-filling the "New GitHub App" form, we POST a pre-built manifest
|
|
7
|
+
* to GitHub; the user clicks confirm; GitHub redirects back with a temporary
|
|
8
|
+
* `code`; we exchange it for the app's id, private key, and webhook secret.
|
|
9
|
+
*
|
|
10
|
+
* Custody stays flavor-A: this conversion runs **on the node**, so the private
|
|
11
|
+
* key never reaches the control plane. The node keeps the key and only hands the
|
|
12
|
+
* control plane the webhook signing secret (to verify inbound events).
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Build the manifest GitHub reads to pre-configure the app: the permissions and
|
|
16
|
+
* events the work queue needs, the webhook target, and the redirect back here.
|
|
17
|
+
*/
|
|
18
|
+
export function buildAppManifest(input) {
|
|
19
|
+
return {
|
|
20
|
+
name: input.name,
|
|
21
|
+
url: input.url,
|
|
22
|
+
hook_attributes: { url: input.hookUrl, active: true },
|
|
23
|
+
redirect_url: input.redirectUrl,
|
|
24
|
+
public: false,
|
|
25
|
+
default_permissions: {
|
|
26
|
+
issues: "write",
|
|
27
|
+
contents: "write",
|
|
28
|
+
pull_requests: "write",
|
|
29
|
+
metadata: "read",
|
|
30
|
+
},
|
|
31
|
+
default_events: ["issues", "issue_comment"],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Exchange a manifest `code` for the created app's credentials. GitHub returns
|
|
36
|
+
* these **once**; the caller must persist the key + secret immediately.
|
|
37
|
+
*/
|
|
38
|
+
export async function convertManifest(code, fetchImpl = fetch) {
|
|
39
|
+
const res = await fetchImpl(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", "user-agent": "bivy" },
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const detail = await res.text().catch(() => "");
|
|
45
|
+
throw new Error(`GitHub manifest conversion failed (${res.status}): ${detail.slice(0, 200)}`);
|
|
46
|
+
}
|
|
47
|
+
const data = (await res.json().catch(() => ({})));
|
|
48
|
+
if (!data.id || !data.pem || !data.webhook_secret) {
|
|
49
|
+
throw new Error("GitHub manifest conversion response was missing id/pem/webhook_secret");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
appId: String(data.id),
|
|
53
|
+
slug: String(data.slug ?? ""),
|
|
54
|
+
name: String(data.name ?? data.slug ?? ""),
|
|
55
|
+
pem: String(data.pem),
|
|
56
|
+
webhookSecret: String(data.webhook_secret),
|
|
57
|
+
htmlUrl: String(data.html_url ?? ""),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A tiny auto-submitting HTML page that POSTs the manifest to GitHub. The
|
|
62
|
+
* manifest must be sent as a form field (it's too big/nested for a query
|
|
63
|
+
* string), so we render a form and submit it on load.
|
|
64
|
+
*/
|
|
65
|
+
export function renderManifestForm(manifest, opts) {
|
|
66
|
+
const action = opts.org
|
|
67
|
+
? `https://github.com/organizations/${encodeURIComponent(opts.org)}/settings/apps/new?state=${encodeURIComponent(opts.state)}`
|
|
68
|
+
: `https://github.com/settings/apps/new?state=${encodeURIComponent(opts.state)}`;
|
|
69
|
+
const manifestJson = JSON.stringify(manifest)
|
|
70
|
+
.replace(/&/g, "&")
|
|
71
|
+
.replace(/"/g, """)
|
|
72
|
+
.replace(/</g, "<");
|
|
73
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Creating your Bivy GitHub App…</title></head>
|
|
74
|
+
<body style="font-family:system-ui;padding:2rem">
|
|
75
|
+
<p>Redirecting to GitHub to create your Bivy app…</p>
|
|
76
|
+
<form id="f" method="post" action="${action}">
|
|
77
|
+
<input type="hidden" name="manifest" value="${manifestJson}">
|
|
78
|
+
<noscript><button type="submit">Continue to GitHub</button></noscript>
|
|
79
|
+
</form>
|
|
80
|
+
<script${opts.nonce ? ` nonce="${opts.nonce}"` : ""}>document.getElementById("f").submit()</script>
|
|
81
|
+
</body></html>`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { listGitHubApps } from "./github-apps.js";
|
|
7
|
+
/**
|
|
8
|
+
* `bivy github:app-sync [on|off]` (issue #88) — opt this node in or out of
|
|
9
|
+
* cross-node GitHub App private-key sync.
|
|
10
|
+
*
|
|
11
|
+
* Off by default: a GitHub App key is a repo-write credential, so widening
|
|
12
|
+
* which nodes hold it is a deliberate, per-node decision (see the "Design
|
|
13
|
+
* decisions" section of issue #88), not automatic like model/provider auth
|
|
14
|
+
* sync. Turning sync ON on two or more nodes lets a `github:app-connect` done
|
|
15
|
+
* on ONE of them reach the others automatically, E2E-encrypted, without
|
|
16
|
+
* re-uploading the `.pem` anywhere — see docs/credential-sync.md.
|
|
17
|
+
*
|
|
18
|
+
* The flag itself lives in cli.json's env block (BIVY_GITHUB_APP_SYNC), the
|
|
19
|
+
* same place every other node-level feature flag lives (e.g.
|
|
20
|
+
* BIVY_GITHUB_HOSTED_TASKS) — `bin/bivy.mjs` spreads that block into the
|
|
21
|
+
* daemon's environment on start/service-install, so this only takes effect
|
|
22
|
+
* after `bivy restart`.
|
|
23
|
+
*/
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
26
|
+
const appDir = process.env.BIVY_DATA_DIR ?? path.join(repoRoot, ".bivy");
|
|
27
|
+
const cliConfigPath = path.join(appDir, "cli.json");
|
|
28
|
+
function loadCliConfig() {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(cliConfigPath, "utf8"));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function saveCliConfig(config) {
|
|
37
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
38
|
+
fs.writeFileSync(cliConfigPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
39
|
+
try {
|
|
40
|
+
fs.chmodSync(cliConfigPath, 0o600);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// best effort
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function isEnabled(config) {
|
|
47
|
+
return String(config.env?.BIVY_GITHUB_APP_SYNC ?? "").trim() === "1";
|
|
48
|
+
}
|
|
49
|
+
function printStatus(config) {
|
|
50
|
+
const enabled = isEnabled(config);
|
|
51
|
+
console.log(`GitHub App key sync: ${enabled ? "ON" : "OFF"} on this node.`);
|
|
52
|
+
const apps = listGitHubApps(appDir);
|
|
53
|
+
if (apps.length) {
|
|
54
|
+
console.log("\nApps this node holds a key for:");
|
|
55
|
+
for (const app of apps)
|
|
56
|
+
console.log(` - ${app.slug ? `@${app.slug}` : app.appId} (${app.appId})`);
|
|
57
|
+
}
|
|
58
|
+
console.log(enabled
|
|
59
|
+
? "\nThis node pushes the apps above (E2E-encrypted) for other opted-in nodes to pull, and pulls apps connected elsewhere on the account."
|
|
60
|
+
: "\nRun 'bivy github:app-sync on' to let this node send/receive GitHub App keys with the account's other opted-in nodes.");
|
|
61
|
+
}
|
|
62
|
+
async function main() {
|
|
63
|
+
const arg = (process.argv[2] || "").trim().toLowerCase();
|
|
64
|
+
const config = loadCliConfig();
|
|
65
|
+
if (!arg || arg === "status") {
|
|
66
|
+
printStatus(config);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (arg !== "on" && arg !== "off") {
|
|
70
|
+
console.error(`Usage: bivy github:app-sync [on|off]\n(no argument prints the current status)`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const env = (config.env && typeof config.env === "object" ? config.env : {});
|
|
75
|
+
if (arg === "on") {
|
|
76
|
+
config.env = { ...env, BIVY_GITHUB_APP_SYNC: "1" };
|
|
77
|
+
console.log("✓ GitHub App key sync enabled on this node.");
|
|
78
|
+
console.log(" Every GitHub App connected here (github:app-connect) will be pushed, E2E-encrypted, for the");
|
|
79
|
+
console.log(" account's other opted-in nodes to pull — and this node will pull apps connected elsewhere.");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const { BIVY_GITHUB_APP_SYNC: _drop, ...rest } = env;
|
|
83
|
+
config.env = rest;
|
|
84
|
+
console.log("✓ GitHub App key sync disabled on this node.");
|
|
85
|
+
console.log(" Keys already imported here are NOT deleted; this only stops future pushes/pulls.");
|
|
86
|
+
}
|
|
87
|
+
saveCliConfig(config);
|
|
88
|
+
console.log("\nRestart the node to apply: bivy restart (or bivy start).");
|
|
89
|
+
}
|
|
90
|
+
main().catch((error) => {
|
|
91
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { seal, open } from "./e2e.js";
|
|
7
|
+
/**
|
|
8
|
+
* GitHub App private-key vault sync (issue #88): opt-in, E2E-encrypted delivery
|
|
9
|
+
* of a connected GitHub App's private key from the node that holds it to the
|
|
10
|
+
* account's OTHER enrolled nodes, riding the same wrap-key mechanism already
|
|
11
|
+
* used for `model-auth-vault` (see src/pairing-crypto.ts, src/device-registry.ts).
|
|
12
|
+
*
|
|
13
|
+
* This module holds the parts that don't need a network round-trip: the
|
|
14
|
+
* envelope format sealed under a per-app symmetric vault key, and the local
|
|
15
|
+
* cache of resolved vault keys (one per app, since apps sync independently —
|
|
16
|
+
* a node opted into sync only has the vault keys for apps it actually holds or
|
|
17
|
+
* has pulled). The network glue (fetching/pushing against the control plane,
|
|
18
|
+
* answering key requests) lives in src/server.ts next to its `model-auth-vault`
|
|
19
|
+
* counterpart, which follows the same shape.
|
|
20
|
+
*
|
|
21
|
+
* The control plane only ever sees: ciphertext, and the vault key itself
|
|
22
|
+
* wrapped per-recipient-node under an ECDH key it cannot derive. It never sees
|
|
23
|
+
* a plaintext private key, matching the guarantee described in
|
|
24
|
+
* src/github-apps.ts and docs/credential-sync.md.
|
|
25
|
+
*/
|
|
26
|
+
const ENVELOPE_VERSION = 1;
|
|
27
|
+
/** Seal one app's private key (+ non-secret display metadata) for the vault. */
|
|
28
|
+
export function encryptGithubAppEnvelope(envelope, vaultKeyB64) {
|
|
29
|
+
const payload = { v: ENVELOPE_VERSION, ...envelope };
|
|
30
|
+
return seal(Buffer.from(vaultKeyB64, "base64"), JSON.stringify(payload));
|
|
31
|
+
}
|
|
32
|
+
/** Open a sealed envelope. Throws if the key is wrong/stale or the payload is malformed. */
|
|
33
|
+
export function decryptGithubAppEnvelope(ciphertext, vaultKeyB64) {
|
|
34
|
+
const parsed = JSON.parse(open(Buffer.from(vaultKeyB64, "base64"), ciphertext));
|
|
35
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.appId !== "string" || typeof parsed.privateKeyPem !== "string" || !parsed.appId || !parsed.privateKeyPem) {
|
|
36
|
+
throw new Error("Malformed GitHub App vault envelope");
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
v: typeof parsed.v === "number" ? parsed.v : ENVELOPE_VERSION,
|
|
40
|
+
appId: parsed.appId,
|
|
41
|
+
privateKeyPem: parsed.privateKeyPem,
|
|
42
|
+
slug: typeof parsed.slug === "string" ? parsed.slug : undefined,
|
|
43
|
+
name: typeof parsed.name === "string" ? parsed.name : undefined,
|
|
44
|
+
owner: typeof parsed.owner === "string" ? parsed.owner : undefined,
|
|
45
|
+
ownerType: parsed.ownerType === "Organization" || parsed.ownerType === "User" ? parsed.ownerType : undefined,
|
|
46
|
+
hookId: typeof parsed.hookId === "string" ? parsed.hookId : undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function vaultKeyFilePath(dataDir) {
|
|
50
|
+
return path.join(dataDir, "github-app-vault.json");
|
|
51
|
+
}
|
|
52
|
+
function readLocalVaultKeys(dataDir) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(fs.readFileSync(vaultKeyFilePath(dataDir), "utf8"));
|
|
55
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function writeLocalVaultKeys(dataDir, keys) {
|
|
62
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
63
|
+
const target = vaultKeyFilePath(dataDir);
|
|
64
|
+
const tmp = `${target}.tmp`;
|
|
65
|
+
fs.writeFileSync(tmp, `${JSON.stringify(keys, null, 2)}\n`, { mode: 0o600 });
|
|
66
|
+
try {
|
|
67
|
+
fs.chmodSync(tmp, 0o600);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// best effort on platforms without chmod
|
|
71
|
+
}
|
|
72
|
+
fs.renameSync(tmp, target);
|
|
73
|
+
}
|
|
74
|
+
/** The cached vault key for `appId`, if this node has resolved one. */
|
|
75
|
+
export function readLocalGithubAppVaultKey(dataDir, appId) {
|
|
76
|
+
const entry = readLocalVaultKeys(dataDir)[appId];
|
|
77
|
+
if (!entry?.vaultKeyB64)
|
|
78
|
+
return undefined;
|
|
79
|
+
return Buffer.from(entry.vaultKeyB64, "base64").length === 32 ? entry.vaultKeyB64 : undefined;
|
|
80
|
+
}
|
|
81
|
+
export function writeLocalGithubAppVaultKey(dataDir, appId, vaultKeyB64) {
|
|
82
|
+
const keys = readLocalVaultKeys(dataDir);
|
|
83
|
+
keys[appId] = { vaultKeyB64, createdAt: new Date().toISOString() };
|
|
84
|
+
writeLocalVaultKeys(dataDir, keys);
|
|
85
|
+
}
|
|
86
|
+
/** Drop a stale cached key (e.g. it no longer decrypts the current ciphertext
|
|
87
|
+
* because the app was rotated) so the node re-requests a fresh wrap. */
|
|
88
|
+
export function forgetLocalGithubAppVaultKey(dataDir, appId) {
|
|
89
|
+
const keys = readLocalVaultKeys(dataDir);
|
|
90
|
+
if (!(appId in keys))
|
|
91
|
+
return;
|
|
92
|
+
delete keys[appId];
|
|
93
|
+
writeLocalVaultKeys(dataDir, keys);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Mint a brand-new local vault key for `appId`, OVERWRITING any cached one.
|
|
97
|
+
* Used both the first time this node pushes the app, and to actually rotate:
|
|
98
|
+
* a node that was removed from the account keeps whatever vault key it last
|
|
99
|
+
* cached, so the only way to make that stale copy stop working is for a
|
|
100
|
+
* surviving node to start encrypting under a key the removed node never saw.
|
|
101
|
+
*/
|
|
102
|
+
export function mintLocalGithubAppVaultKey(dataDir, appId) {
|
|
103
|
+
const vaultKeyB64 = randomBytes(32).toString("base64");
|
|
104
|
+
writeLocalGithubAppVaultKey(dataDir, appId, vaultKeyB64);
|
|
105
|
+
return vaultKeyB64;
|
|
106
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { resolveSecret } from "./secrets.js";
|
|
6
|
+
const EMPTY = { version: 1, apps: [] };
|
|
7
|
+
function registryPath(dataDir) {
|
|
8
|
+
return path.join(dataDir, "github-apps.json");
|
|
9
|
+
}
|
|
10
|
+
/** The conventional vault id for an app's private key. */
|
|
11
|
+
export function privateKeyIdFor(appId) {
|
|
12
|
+
return `github.app.${appId}`;
|
|
13
|
+
}
|
|
14
|
+
function readRegistry(dataDir) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = JSON.parse(fs.readFileSync(registryPath(dataDir), "utf8"));
|
|
17
|
+
const apps = Array.isArray(raw.apps) ? raw.apps.filter((a) => a && typeof a.appId === "string" && a.appId) : [];
|
|
18
|
+
return { version: typeof raw.version === "number" ? raw.version : 1, apps };
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return { ...EMPTY, apps: [] };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeRegistry(dataDir, file) {
|
|
25
|
+
const target = registryPath(dataDir);
|
|
26
|
+
const tmp = `${target}.tmp`;
|
|
27
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
28
|
+
fs.writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
29
|
+
fs.renameSync(tmp, target);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Single-app installs configured the node through the environment
|
|
33
|
+
* (`BIVY_GITHUB_APP_ID` + `BIVY_GITHUB_APP_PRIVATE_KEY`). Surface that as a
|
|
34
|
+
* registry entry so the rest of the code only ever deals with a list.
|
|
35
|
+
*
|
|
36
|
+
* Not written to disk: the env vars remain the source of truth for containers
|
|
37
|
+
* and ephemeral runners that are configured purely through the environment.
|
|
38
|
+
*/
|
|
39
|
+
function recordFromEnv(env) {
|
|
40
|
+
const appId = env.BIVY_GITHUB_APP_ID?.trim();
|
|
41
|
+
if (!appId)
|
|
42
|
+
return undefined;
|
|
43
|
+
return {
|
|
44
|
+
appId,
|
|
45
|
+
slug: env.BIVY_GITHUB_APP_SLUG?.trim() || undefined,
|
|
46
|
+
privateKeyRef: env.BIVY_GITHUB_APP_PRIVATE_KEY?.trim() || "secret://github.app-private-key",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every app this node serves, the registry first and then any env-configured app
|
|
51
|
+
* that isn't already in it. Deduplicated by app id, registry wins.
|
|
52
|
+
*/
|
|
53
|
+
export function listGitHubApps(dataDir, env = process.env) {
|
|
54
|
+
const apps = readRegistry(dataDir).apps.slice();
|
|
55
|
+
const fromEnv = recordFromEnv(env);
|
|
56
|
+
if (fromEnv && !apps.some((a) => a.appId === fromEnv.appId))
|
|
57
|
+
apps.push(fromEnv);
|
|
58
|
+
return apps;
|
|
59
|
+
}
|
|
60
|
+
/** Add or update an app by id, preserving fields the caller didn't supply. */
|
|
61
|
+
export function upsertGitHubApp(dataDir, record) {
|
|
62
|
+
const file = readRegistry(dataDir);
|
|
63
|
+
const i = file.apps.findIndex((a) => a.appId === record.appId);
|
|
64
|
+
if (i >= 0) {
|
|
65
|
+
file.apps[i] = { ...file.apps[i], ...record };
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
file.apps.push({ addedAt: new Date().toISOString(), ...record });
|
|
69
|
+
}
|
|
70
|
+
writeRegistry(dataDir, file);
|
|
71
|
+
return file.apps;
|
|
72
|
+
}
|
|
73
|
+
/** Remove an app by id. Returns true when something was removed. */
|
|
74
|
+
export function removeGitHubApp(dataDir, appId) {
|
|
75
|
+
const file = readRegistry(dataDir);
|
|
76
|
+
const before = file.apps.length;
|
|
77
|
+
file.apps = file.apps.filter((a) => a.appId !== appId);
|
|
78
|
+
if (file.apps.length === before)
|
|
79
|
+
return false;
|
|
80
|
+
writeRegistry(dataDir, file);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve each app's private key. Apps whose key can't be resolved (revoked
|
|
85
|
+
* vault entry, missing 1Password item) are dropped rather than throwing — one
|
|
86
|
+
* broken app must not take down the GitHub integration for the others.
|
|
87
|
+
*/
|
|
88
|
+
export async function loadGitHubAppConfigs(dataDir, env = process.env) {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const record of listGitHubApps(dataDir, env)) {
|
|
91
|
+
const ref = record.privateKeyRef;
|
|
92
|
+
let privateKeyPem;
|
|
93
|
+
try {
|
|
94
|
+
privateKeyPem =
|
|
95
|
+
ref.startsWith("secret://") || ref.startsWith("op://") || ref.startsWith("env://")
|
|
96
|
+
? await resolveSecret(ref, dataDir)
|
|
97
|
+
: ref;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
privateKeyPem = undefined;
|
|
101
|
+
}
|
|
102
|
+
if (!privateKeyPem || !privateKeyPem.includes("PRIVATE KEY"))
|
|
103
|
+
continue;
|
|
104
|
+
out.push({ appId: record.appId, privateKeyPem, record });
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Pick the order to try apps in when resolving which one covers `owner/repo`.
|
|
110
|
+
* An app owned by the same account as the repo is overwhelmingly the right one,
|
|
111
|
+
* so try it first — that turns the common case into a single API call instead of
|
|
112
|
+
* one per configured app.
|
|
113
|
+
*/
|
|
114
|
+
export function orderAppsForOwner(apps, owner) {
|
|
115
|
+
const lower = owner.toLowerCase();
|
|
116
|
+
return apps.slice().sort((a, b) => {
|
|
117
|
+
const aMatch = a.record.owner?.toLowerCase() === lower ? 0 : 1;
|
|
118
|
+
const bMatch = b.record.owner?.toLowerCase() === lower ? 0 : 1;
|
|
119
|
+
return aMatch - bMatch;
|
|
120
|
+
});
|
|
121
|
+
}
|