@bivy/bivy 0.7.0-staging.107 → 0.7.0-staging.109

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/bin/bivy.mjs CHANGED
@@ -3343,9 +3343,11 @@ async function cmdSetup(args = []) {
3343
3343
  console.log(` ${agentReady ? c.green("✓") : c.yellow("!")} runtime ${agentReady ? `${setupAgent?.label || "Pi"} available` : "not installed — run 'bivy agents:install'"}`);
3344
3344
  console.log(` ${modelReady ? (setupAgent?.needsBivyModel ? c.green("✓") : c.dim("○")) : c.yellow("!")} model ${modelReady ? (setupAgent?.needsBivyModel ? "credential configured" : "agent-managed — verified by the first task") : "not configured — run 'bivy login'"}`);
3345
3345
  console.log(` ${c.dim("○")} repository chosen from the directory where you start Bivy`);
3346
+ const ghReady = githubConnected(finalConfig);
3347
+ console.log(` ${ghReady ? c.green("✓") : c.dim("○")} GitHub ${ghReady ? "connected — your repos will list in the app" : c.dim("not connected — 'bivy github:connect' to list repos (optional)")}`);
3346
3348
  console.log(` ${agentReady && modelReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady ? "ready to try" : "blocked by the stage above"}`);
3347
3349
  console.log(` ${fs.existsSync(relayConfigPath) ? c.green("✓") : c.yellow("!")} remote ${fs.existsSync(relayConfigPath) ? "configured" : "not configured — run 'bivy relay:setup'"}\n`);
3348
- printFirstRunSteps(modelReady);
3350
+ printFirstRunSteps(modelReady, finalConfig);
3349
3351
  await finishSetupRemote(finalConfig, setupSession);
3350
3352
  }
3351
3353
 
@@ -3433,10 +3435,29 @@ async function openRemoteApp(config, { setupSession = null, open = true } = {})
3433
3435
  return { relay, remoteBase, accountUrl, pairedUrl, openUrl };
3434
3436
  }
3435
3437
 
3436
- function printFirstRunSteps(modelReady = false) {
3438
+ // Whether GitHub is connected for repo listing/cloning. Bivy's own connect flow
3439
+ // (`bivy github:connect`, or the app's Connect button) writes BIVY_GITHUB_TOKEN
3440
+ // — usually a `secret://` vault reference — into cli.json's env; an explicit env
3441
+ // token counts too. A `gh auth login` session also works at runtime (the node
3442
+ // falls back to `gh auth token`), but that can't be known without shelling out,
3443
+ // so it's treated as "not connected here" — the hint is optional either way.
3444
+ function githubConnected(config = null) {
3445
+ const token = String(
3446
+ config?.env?.BIVY_GITHUB_TOKEN || process.env.BIVY_GITHUB_TOKEN || process.env.GITHUB_TOKEN || "",
3447
+ ).trim();
3448
+ return Boolean(token);
3449
+ }
3450
+
3451
+ function printFirstRunSteps(modelReady = false, config = null) {
3437
3452
  console.log(" Run your first task:");
3438
- if (!modelReady) console.log(` 1. Model access: ${c.cyan("bivy login")} ${c.dim("(for Pi; other agents use their own login)")}`);
3439
- console.log(` ${modelReady ? "1" : "2"}. Start chatting: ${c.cyan("bivy")}`);
3453
+ let n = 0;
3454
+ if (!modelReady) console.log(` ${++n}. Model access: ${c.cyan("bivy login")} ${c.dim("(for Pi; other agents use their own login)")}`);
3455
+ // GitHub is optional — "No repo" sessions work without it — so this only shows
3456
+ // when nothing is connected yet, and never blocks the flow.
3457
+ if (!githubConnected(config)) {
3458
+ console.log(` ${++n}. GitHub ${c.dim("(optional)")}: ${c.cyan("bivy github:connect")} ${c.dim("— lets the app list your repos; required for private ones")}`);
3459
+ }
3460
+ console.log(` ${++n}. Start chatting: ${c.cyan("bivy")}`);
3440
3461
  console.log(` Starter task: ${c.cyan('bivy exec "explain this repository and identify one low-risk improvement"')}\n`);
3441
3462
  }
3442
3463
 
@@ -3614,6 +3635,17 @@ async function cmdDoctor(args = []) {
3614
3635
  console.log(c.bold("\n Bivy doctor\n"));
3615
3636
  console.log(` ${mark(hasSupportedNode())} Node ${process.version}${hasSupportedNode() ? "" : c.dim(" (needs >= 22.19.0)")}`);
3616
3637
  console.log(` ${mark(commandExists("git"), true)} git${commandExists("git") ? "" : c.dim(" (recommended for repo-backed sessions)")}`);
3638
+ // GitHub is optional (a "No repo" session needs none), so this only ever warns.
3639
+ // `gh` is NOT required — it's a token fallback; the primary path is Bivy's own
3640
+ // 'bivy github:connect' (or the app's Connect button). We surface gh only as an
3641
+ // available shortcut when it's installed but nothing is connected yet.
3642
+ const ghConnected = githubConnected(config);
3643
+ const ghHint = ghConnected
3644
+ ? c.green("connected")
3645
+ : commandExists("gh")
3646
+ ? c.dim("not connected — 'bivy github:connect' (or 'gh auth login')")
3647
+ : c.dim("not connected — 'bivy github:connect' to list/clone private repos");
3648
+ console.log(` ${mark(ghConnected, true)} GitHub ${ghHint}`);
3617
3649
  console.log(` ${mark(reachable)} node ${reachable ? c.green("reachable") : c.dim("not reachable — 'bivy start'")} at ${url(config)}`);
3618
3650
  console.log(` ${mark(/running/.test(serviceStatusLine()), true)} ${serviceStatusLine()}`);
3619
3651
  const defaultAgent = String(config.env?.BIVY_RUNTIME || runtimes?.current?.id || "pi");
@@ -67,6 +67,22 @@ export function interpretTokenResponse(data) {
67
67
  }
68
68
  }
69
69
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
70
+ /**
71
+ * A SINGLE access-token poll (no internal waiting) — for a caller that drives
72
+ * its own cadence. The web-driven connect flow uses this: the node holds the
73
+ * device code and the browser polls it on GitHub's interval, so the node never
74
+ * blocks a request thread in a poll loop. `pollForAccessToken` is the CLI's
75
+ * self-driving loop built on the same interpretation.
76
+ */
77
+ export async function pollAccessTokenOnce(clientId, deviceCode) {
78
+ const res = await fetch(ACCESS_TOKEN_URL, {
79
+ method: "POST",
80
+ headers: { accept: "application/json", "content-type": "application/json" },
81
+ body: JSON.stringify({ client_id: clientId, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }),
82
+ });
83
+ const data = (await res.json().catch(() => ({})));
84
+ return interpretTokenResponse(data);
85
+ }
70
86
  /** Step 2: poll until the user authorizes (or the code expires). */
71
87
  export async function pollForAccessToken(clientId, device, signal) {
72
88
  let intervalMs = device.intervalSec * 1000;
@@ -100,6 +100,25 @@ export async function resolveGitHubToken(env = process.env) {
100
100
  return undefined;
101
101
  }
102
102
  }
103
+ /**
104
+ * Whether the GitHub CLI (`gh`) is installed on this machine — used only to
105
+ * shade the "no GitHub token" message: when `gh` is present but `gh auth token`
106
+ * gave us nothing, the user is one `gh auth login` away, so the picker can say
107
+ * so. It never means `gh` is REQUIRED — `bivy github:connect` is the primary
108
+ * path and needs no CLI (see resolveGitHubToken). Mirrors the `command -v`
109
+ * probe in secrets.ts.
110
+ */
111
+ export async function ghCliInstalled() {
112
+ const which = process.platform === "win32" ? "where" : "command";
113
+ const args = process.platform === "win32" ? ["gh"] : ["-v", "gh"];
114
+ try {
115
+ await exec(which, args, process.platform === "win32" ? {} : { shell: true });
116
+ return true;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
103
122
  /**
104
123
  * Refresh the remote-tracking refs so a session branches off the CURRENT state
105
124
  * of `origin`, not whatever the local checkout last saw. Best-effort: an offline
package/dist/secrets.js CHANGED
@@ -183,8 +183,12 @@ export class SecretVault {
183
183
  if (fs.existsSync(this.keyFile))
184
184
  checks.push({ name: "local key file permissions", ok: modeIsPrivate(this.keyFile), detail: this.keyFile });
185
185
  checks.push({ name: "1Password CLI", ok: await commandExists("op"), detail: "required for op:// references" });
186
- checks.push({ name: "GitHub CLI", ok: await commandExists("gh"), detail: "fallback for GitHub tokens when no Bivy secret is configured" });
187
- return { ok: checks.every((c) => c.ok || c.name === "1Password CLI" || c.name === "GitHub CLI"), checks };
186
+ checks.push({ name: "GitHub CLI (optional)", ok: await commandExists("gh"), detail: "optional token shortcut; Bivy connects GitHub itself via `bivy github:connect`" });
187
+ // 1Password and the GitHub CLI are optional shortcuts — a missing one must
188
+ // not fail the vault's health (Bivy connects GitHub itself; op is only for
189
+ // op:// refs). Match on a stable prefix so renaming the label can't silently
190
+ // turn either back into a hard failure.
191
+ return { ok: checks.every((c) => c.ok || c.name.startsWith("1Password CLI") || c.name.startsWith("GitHub CLI")), checks };
188
192
  }
189
193
  key() {
190
194
  try {
package/dist/server.js CHANGED
@@ -60,7 +60,7 @@ import { checkDiskAdmission } from "./harness/disk-admission.js";
60
60
  import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
61
61
  import { setConfiguredAutoAttachToolImages } from "./harness/tool-image-attachments.js";
62
62
  import { injectMcpProxyForSession, injectBivyToolsForSession } from "./harness/mcp-inject.js";
63
- import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, resolveAdoptBaseRef, fetchOrigin } from "./repo-workspace.js";
63
+ import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, ghCliInstalled, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, resolveAdoptBaseRef, fetchOrigin } from "./repo-workspace.js";
64
64
  import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
65
65
  import { GitHubTaskPoller, resolveGitHubTaskConfig, buildTaskPrompt, buildResumePrompt, buildInteractiveResumePrompt, DEFAULT_ISSUE_INSTRUCTIONS, parseBivyDirectives, commitAll, pushBranch, mergeBaseIntoBranch, completeMerge, abortMerge, findOpenPullRequestForBranch, findPullRequestsForBranch, findMergedPullRequestForBranch, issueBranchName, getPullRequest, commentIssue, listOpenLabelledIssues, selectActionableIssues, getIssue, getIssueCommentBody, addLabel, removeLabel, announcePickup, } from "./github-tasks.js";
66
66
  import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
@@ -84,6 +84,7 @@ import { ReplicationService } from "./session/replication-service.js";
84
84
  import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
85
85
  import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
86
86
  import { SecretVault, resolveSecret } from "./secrets.js";
87
+ import { deviceFlowClientId, requestDeviceCode, pollAccessTokenOnce, REPO_CONNECT_SCOPE } from "./github-device-auth.js";
87
88
  import { InstallationTokenCache, createAppJwt, resolveInstallationId } from "./github-app-auth.js";
88
89
  import { loadGitHubAppConfigs, orderAppsForOwner, listGitHubApps, removeGitHubApp, upsertGitHubApp, privateKeyIdFor, } from "./github-apps.js";
89
90
  import { buildAppManifest, convertManifest, renderManifestForm } from "./github-app-manifest.js";
@@ -2824,6 +2825,15 @@ const RELAY_COMMANDS = {
2824
2825
  async "repos.list"() {
2825
2826
  relay?.sendEvent({ type: "repos.list", ...(await listAccessibleRepos()) });
2826
2827
  },
2828
+ // Web-driven "Connect GitHub" for the repo picker: start the node's device
2829
+ // flow, then poll it on GitHub's interval. Both answer with the same
2830
+ // `github.connect.status` event so the client has one shape to handle.
2831
+ async "github.connect.start"() {
2832
+ relay?.sendEvent({ type: "github.connect.status", ...(await startGithubConnect()) });
2833
+ },
2834
+ async "github.connect.poll"() {
2835
+ relay?.sendEvent({ type: "github.connect.status", ...(await pollGithubConnect()) });
2836
+ },
2827
2837
  // Branches for the repo the composer's repo pill just picked, so the branch
2828
2838
  // pill next to it can offer a specific remote branch to clone/base a new
2829
2839
  // session from instead of always the repo's default. See listRepoBranches.
@@ -10134,7 +10144,7 @@ async function listAccessibleRepos() {
10134
10144
  try {
10135
10145
  const token = await resolveGitHubToken();
10136
10146
  if (!token)
10137
- return { authed: false, repos: [] };
10147
+ return { authed: false, repos: [], reason: (await ghCliInstalled()) ? "gh-unauthed" : "no-token" };
10138
10148
  const ghRes = await fetch("https://api.github.com/user/repos?sort=updated&per_page=100&affiliation=owner,collaborator,organization_member", {
10139
10149
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": "bivy" },
10140
10150
  });
@@ -10159,6 +10169,83 @@ async function listAccessibleRepos() {
10159
10169
  return { authed: false, repos: [], error: error instanceof Error ? error.message : String(error) };
10160
10170
  }
10161
10171
  }
10172
+ let pendingGithubConnect = null;
10173
+ async function startGithubConnect() {
10174
+ const clientId = deviceFlowClientId();
10175
+ if (!clientId)
10176
+ return { status: "unconfigured" };
10177
+ try {
10178
+ const device = await requestDeviceCode(clientId, REPO_CONNECT_SCOPE);
10179
+ pendingGithubConnect = { clientId, device, expiresAt: Date.now() + device.expiresInSec * 1000 };
10180
+ return {
10181
+ status: "waiting",
10182
+ userCode: device.userCode,
10183
+ verificationUri: device.verificationUri,
10184
+ intervalMs: device.intervalSec * 1000,
10185
+ expiresInMs: device.expiresInSec * 1000,
10186
+ };
10187
+ }
10188
+ catch (error) {
10189
+ return { status: "error", error: error instanceof Error ? error.message : String(error) };
10190
+ }
10191
+ }
10192
+ async function pollGithubConnect() {
10193
+ const pending = pendingGithubConnect;
10194
+ if (!pending)
10195
+ return { status: "idle" };
10196
+ if (Date.now() > pending.expiresAt) {
10197
+ pendingGithubConnect = null;
10198
+ return { status: "expired" };
10199
+ }
10200
+ let poll;
10201
+ try {
10202
+ poll = await pollAccessTokenOnce(pending.clientId, pending.device.deviceCode);
10203
+ }
10204
+ catch (error) {
10205
+ // A transient network blip mid-flow — keep the code alive and let the client
10206
+ // poll again rather than discarding a device code the user may have authorized.
10207
+ return { status: "error", error: error instanceof Error ? error.message : String(error) };
10208
+ }
10209
+ switch (poll.status) {
10210
+ case "ok":
10211
+ pendingGithubConnect = null;
10212
+ persistConnectedGithubToken(poll.token);
10213
+ return { status: "connected" };
10214
+ case "slow_down":
10215
+ // GitHub says we're polling too fast — widen the interval it hands the
10216
+ // browser so the next poll backs off (and doesn't burn the device code).
10217
+ pending.device.intervalSec = poll.intervalSec ?? pending.device.intervalSec + 5;
10218
+ // falls through — same "keep waiting" answer, just a larger interval.
10219
+ case "pending":
10220
+ return {
10221
+ status: "waiting",
10222
+ userCode: pending.device.userCode,
10223
+ verificationUri: pending.device.verificationUri,
10224
+ intervalMs: pending.device.intervalSec * 1000,
10225
+ expiresInMs: Math.max(0, pending.expiresAt - Date.now()),
10226
+ };
10227
+ case "denied":
10228
+ pendingGithubConnect = null;
10229
+ return { status: "denied" };
10230
+ case "expired":
10231
+ pendingGithubConnect = null;
10232
+ return { status: "expired" };
10233
+ default:
10234
+ pendingGithubConnect = null;
10235
+ return { status: "error", error: poll.error };
10236
+ }
10237
+ }
10238
+ // Store the repo-scoped token exactly like `bivy github:connect`: the raw token
10239
+ // in the node's secret vault, and only a `secret://` reference in cli.json. But
10240
+ // ALSO update the LIVE process env so resolveGitHubToken() picks it up without a
10241
+ // restart (the Tier-1 caveat), and drop the repo-list cache so the very next
10242
+ // list is authed.
10243
+ function persistConnectedGithubToken(token) {
10244
+ new SecretVault(appDir).setLocal("github.repo-token", token, "GitHub repo/work-queue token");
10245
+ saveCliEnv({ BIVY_GITHUB_TOKEN: "secret://github.repo-token" });
10246
+ process.env.BIVY_GITHUB_TOKEN = "secret://github.repo-token";
10247
+ invalidateGithubListingCaches();
10248
+ }
10162
10249
  // Fetch a repo's remote branch names with a given token (or none, for a public
10163
10250
  // repo). One GitHub call; returns null on a non-OK response so the caller can
10164
10251
  // decide whether to retry with a different token.
@@ -10582,6 +10669,13 @@ app.get("/github/app/manifest/callback", async (req, res, next) => {
10582
10669
  app.get("/api/repos", async (_req, res) => {
10583
10670
  res.json(await listAccessibleRepos());
10584
10671
  });
10672
+ // Direct-transport (local PWA) equivalents of the github.connect.* commands.
10673
+ app.post("/api/github/connect/start", async (_req, res) => {
10674
+ res.json(await startGithubConnect());
10675
+ });
10676
+ app.get("/api/github/connect/poll", async (_req, res) => {
10677
+ res.json(await pollGithubConnect());
10678
+ });
10585
10679
  app.get("/api/repos/branches", async (req, res) => {
10586
10680
  res.json(await listRepoBranches(String(req.query.repo || "").trim()));
10587
10681
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.7.0-staging.107",
3
+ "version": "0.7.0-staging.109",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",