@bivy/bivy 0.20.1-staging.8 → 0.20.1-staging.9
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/access-view.mjs +43 -0
- package/bin/bivy.mjs +98 -14
- package/bin/cli-commands.mjs +1 -0
- package/bin/tailscale.mjs +10 -3
- package/dist/access.js +72 -0
- package/dist/apps/styles.css +1 -0
- package/dist/auth.js +18 -4
- package/dist/protocol/client-command-routes.js +2 -0
- package/dist/protocol/client-command-schemas.js +2 -0
- package/dist/remote/direct.js +2 -1
- package/dist/remote/index.js +1 -0
- package/dist/remote/tailnet.js +44 -0
- package/dist/server.js +40 -2
- package/package.json +1 -1
- package/web/assets/AutomationsView-DjOv4IMn.js +51 -0
- package/web/assets/{ChangesCard-sGQlkM2d.js → ChangesCard-Bcd6mp08.js} +1 -1
- package/web/assets/{LibraryView-DYUo2624.js → LibraryView-CiRErGG3.js} +1 -1
- package/web/assets/{ProviderConnect-DB-gwOwe.js → ProviderConnect-htTjSJzb.js} +1 -1
- package/web/assets/Settings-oBsZwFcG.js +10 -0
- package/web/assets/{Sheet-BmAB1Fu_.js → Sheet-lYov8PLo.js} +1 -1
- package/web/assets/{Terminal-Dkr504wc.js → Terminal-B9d4Ijmc.js} +1 -1
- package/web/assets/{VoiceSettings-DaR6ba3A.js → VoiceSettings-BIADw4Ll.js} +1 -1
- package/web/assets/{abnfDiagram-VCTEODGH-CZTMfHPQ.js → abnfDiagram-VCTEODGH-gRoFYqU5.js} +1 -1
- package/web/assets/architecture-7GRP2DOG-BEJ5aLoK.js +1 -0
- package/web/assets/{architectureDiagram-5GKGNRK7-BBR7u-Nc.js → architectureDiagram-5GKGNRK7-C4cNJboP.js} +1 -1
- package/web/assets/{blockDiagram-I7D4REHJ-DU1iTe41.js → blockDiagram-I7D4REHJ-DAICqtG4.js} +1 -1
- package/web/assets/{c4Diagram-7LVT6UL2-DoY25VIT.js → c4Diagram-7LVT6UL2-DRswRDNz.js} +1 -1
- package/web/assets/channel-BPXvQ5Jt.js +1 -0
- package/web/assets/{chunk-4HAMMTFA-CwLS5ngk.js → chunk-4HAMMTFA-2joFftNJ.js} +1 -1
- package/web/assets/{chunk-75Z2AOVW-FHeNbdLy.js → chunk-75Z2AOVW-XWOFp1H_.js} +1 -1
- package/web/assets/{chunk-DU6HZSFF-CUNfwm6b.js → chunk-DU6HZSFF-DS4Nz4nA.js} +1 -1
- package/web/assets/{chunk-F27PBJKO-BT9LzzRz.js → chunk-F27PBJKO-Dx_X9qdN.js} +1 -1
- package/web/assets/{chunk-GMAD6QVW-BUr3PoNi.js → chunk-GMAD6QVW-DxV6twrr.js} +1 -1
- package/web/assets/{chunk-GVQU2GXP-Dwa-Ue8e.js → chunk-GVQU2GXP-MSIDLTHB.js} +1 -1
- package/web/assets/{chunk-IMKFNOWR-CBemkwVh.js → chunk-IMKFNOWR-CELsloet.js} +1 -1
- package/web/assets/{chunk-L3NEJ4N5-AGhZr4q5.js → chunk-L3NEJ4N5-BZ3ozIZf.js} +1 -1
- package/web/assets/{chunk-OSK3NFVY-CxTtDDij.js → chunk-OSK3NFVY-BXjogSMs.js} +1 -1
- package/web/assets/{chunk-P2QGCYS3-DGmss7hg.js → chunk-P2QGCYS3-DFcISUhA.js} +1 -1
- package/web/assets/{chunk-POPQ4Y6H-nsxQTUJd.js → chunk-POPQ4Y6H-CMv8VcTR.js} +1 -1
- package/web/assets/{chunk-PWAF6VOD-CFEkzIZ2.js → chunk-PWAF6VOD-Ck43wPZJ.js} +1 -1
- package/web/assets/{chunk-SHT3W25Y-BArb8KoG.js → chunk-SHT3W25Y-Dd3YYPpo.js} +1 -1
- package/web/assets/{chunk-SVP7TREG-CEFJfpYl.js → chunk-SVP7TREG-1wHIX2u8.js} +1 -1
- package/web/assets/{chunk-TICWLB2K-ByqSjpyA.js → chunk-TICWLB2K-C2RL-jix.js} +1 -1
- package/web/assets/classDiagram-ZZMXUADV-DicIinQk.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-DicIinQk.js +1 -0
- package/web/assets/{cynefin-OW5HDTMX-BPd4eJmp.js → cynefin-OW5HDTMX-L3cCx0-a.js} +1 -1
- package/web/assets/{cynefinDiagram-5FMLGOSQ-rm271vYO.js → cynefinDiagram-5FMLGOSQ-B-p9_QH8.js} +1 -1
- package/web/assets/{dagre-GXQ25YYZ-a4DVMh7o.js → dagre-GXQ25YYZ-CNcD81ab.js} +1 -1
- package/web/assets/{diagram-S7CK7UJ4-CR4kj8JQ.js → diagram-S7CK7UJ4-CLXtZ4aE.js} +1 -1
- package/web/assets/{diagram-UQ7AKVKN-BVAidz_h.js → diagram-UQ7AKVKN-Bj1-misl.js} +1 -1
- package/web/assets/{diagram-VSXAHHWV-P_KshIhr.js → diagram-VSXAHHWV-BsncRf1X.js} +1 -1
- package/web/assets/{diagram-VX7I27RA-CfR4HucM.js → diagram-VX7I27RA-DgnUNiqR.js} +1 -1
- package/web/assets/{diagram-Z3DM3KII-CnxFHDS4.js → diagram-Z3DM3KII-CYA1FOfJ.js} +1 -1
- package/web/assets/{ebnfDiagram-PWID7BFC-0lvDIevQ.js → ebnfDiagram-PWID7BFC-EN37F-ZX.js} +1 -1
- package/web/assets/{erDiagram-RLTQ6QDP-B5onTaVV.js → erDiagram-RLTQ6QDP-7khCs6R0.js} +1 -1
- package/web/assets/eventmodeling-NTZA5JFV-DPlKwCgg.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-CuyexHJ1.js +1 -0
- package/web/assets/{ganttDiagram-EL5Y4UJY-DcP58ot2.js → ganttDiagram-EL5Y4UJY-X2fCrUiA.js} +1 -1
- package/web/assets/{gitGraph-4MIJSDKK-DMgkg1Fa.js → gitGraph-4MIJSDKK-C1jg4oXv.js} +1 -1
- package/web/assets/{gitGraphDiagram-WWUBYQGX-ChZQlU2S.js → gitGraphDiagram-WWUBYQGX-aRlKIG1q.js} +1 -1
- package/web/assets/{index-emCm7_bF.js → index-BYhnjcv5.js} +2 -2
- package/web/assets/index-Cik3irwJ.css +1 -0
- package/web/assets/{info-A6RAGUB7-Bwm7-KTz.js → info-A6RAGUB7-knTxGQzP.js} +1 -1
- package/web/assets/{infoDiagram-27XIBGKW-PpNSZSrV.js → infoDiagram-27XIBGKW-CZ5eWH_5.js} +1 -1
- package/web/assets/{ishikawaDiagram-5VMMS53U-DXh6yXna.js → ishikawaDiagram-5VMMS53U-BaN7oiXe.js} +1 -1
- package/web/assets/{journeyDiagram-3NMN7TZE-DFEj5jkB.js → journeyDiagram-3NMN7TZE-DSKzoitB.js} +1 -1
- package/web/assets/{kanban-definition-UXKFOSKX-C5Mvo17h.js → kanban-definition-UXKFOSKX-Do-Ybw2Z.js} +1 -1
- package/web/assets/{line-CqUbcJrv.js → line-CwAjwUaG.js} +1 -1
- package/web/assets/{mermaid-parser.core-BzUSMP_7.js → mermaid-parser.core-DgfSoRZF.js} +3 -3
- package/web/assets/{mermaid.core-CooqV3Py.js → mermaid.core-BmmLukUA.js} +4 -4
- package/web/assets/{mindmap-definition-YA3MSWOX-CvsfS7bG.js → mindmap-definition-YA3MSWOX-BvYA7HOJ.js} +1 -1
- package/web/assets/mount-YpsogRG1.js +26 -0
- package/web/assets/{packet-AYTQ26CC-BzXGhTo4.js → packet-AYTQ26CC-z5xJrrsj.js} +1 -1
- package/web/assets/{pegDiagram-XKGWAZYB-BvdN2RIR.js → pegDiagram-XKGWAZYB-Dlnf2qqP.js} +1 -1
- package/web/assets/{pie-WAS4IAKB-CNH8JQby.js → pie-WAS4IAKB-Bcc2ZLgF.js} +1 -1
- package/web/assets/{pieDiagram-E7YTZNPT-C6LABPqT.js → pieDiagram-E7YTZNPT-DK6Bm2ct.js} +1 -1
- package/web/assets/{quadrantDiagram-AXDQQJYC-D2ebcGIP.js → quadrantDiagram-AXDQQJYC-CGdNrgH7.js} +1 -1
- package/web/assets/{radar-RG4KPBEZ-B7uFPzC9.js → radar-RG4KPBEZ-DvDzdzlV.js} +1 -1
- package/web/assets/{railroad-74A4TZTK-CE9pdZ9o.js → railroad-74A4TZTK-DAk4jBzt.js} +1 -1
- package/web/assets/railroad-abnf-HS5TGJTU-BboXzbI7.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-2aWLf6tt.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-DkEFbMwn.js +1 -0
- package/web/assets/{railroadDiagram-O6MQD6OU-vJc9tRJP.js → railroadDiagram-O6MQD6OU-qghivQgJ.js} +1 -1
- package/web/assets/{requirementDiagram-BXWQKSXE-CVLUnEfL.js → requirementDiagram-BXWQKSXE-BB2VHwAe.js} +1 -1
- package/web/assets/{sankeyDiagram-P5KCCOFB-DkHI3ZIh.js → sankeyDiagram-P5KCCOFB-DYsK6FvP.js} +1 -1
- package/web/assets/{sequenceDiagram-WJ2MYXX4-BgosmpaZ.js → sequenceDiagram-WJ2MYXX4-DN7_ECVu.js} +1 -1
- package/web/assets/settingsRoute-BryKVhsu.js +1 -0
- package/web/assets/{stateDiagram-D77RDMKH-BZ5Zqme7.js → stateDiagram-D77RDMKH-CMoUI-nz.js} +1 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-DVVMJFff.js +1 -0
- package/web/assets/{swimlanes-42K2YHIH-rkMg8KEw.js → swimlanes-42K2YHIH-Dg9Ds-zo.js} +1 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-CMp2P5bx.js +8 -0
- package/web/assets/{timeline-definition-24CTP7MA-_z_HJLWC.js → timeline-definition-24CTP7MA-B71AxnxY.js} +1 -1
- package/web/assets/{treeView-Q6P3EWNA-jR5Z1Fmf.js → treeView-Q6P3EWNA-CcYDvc71.js} +1 -1
- package/web/assets/{treemap-WGGIJYW6-OtJhfdUS.js → treemap-WGGIJYW6-COuLLLjA.js} +1 -1
- package/web/assets/{useStore-CaPvUEwc.js → useStore-CVAcdLfm.js} +3 -3
- package/web/assets/{vennDiagram-4TSXK5OY-BFTbjE-l.js → vennDiagram-4TSXK5OY-Dyg63v_c.js} +1 -1
- package/web/assets/{wardley-WFR3VGLG-BV0ly0l7.js → wardley-WFR3VGLG-FAnOXgo0.js} +1 -1
- package/web/assets/{wardleyDiagram-VM6X3IG4-DYvCqNRg.js → wardleyDiagram-VM6X3IG4-fhlKAloZ.js} +1 -1
- package/web/assets/{xychartDiagram-S5SC5T6Z-CaYy8yTC.js → xychartDiagram-S5SC5T6Z-DbM80CJG.js} +1 -1
- package/web/index.html +2 -2
- package/web/sw.js +1 -1
- package/web/assets/AutomationsView-BnHdmp7P.js +0 -51
- package/web/assets/Settings-VYk0Et0A.js +0 -10
- package/web/assets/architecture-7GRP2DOG-MiVMMOHz.js +0 -1
- package/web/assets/channel-m_2Ky5Co.js +0 -1
- package/web/assets/classDiagram-ZZMXUADV-BM6g8SUg.js +0 -1
- package/web/assets/classDiagram-v2-VYDZK3BY-BM6g8SUg.js +0 -1
- package/web/assets/eventmodeling-NTZA5JFV-DviokIMu.js +0 -1
- package/web/assets/flowDiagram-HODETNUW-BtFCDXEP.js +0 -1
- package/web/assets/index-CsC63U16.css +0 -1
- package/web/assets/mount-BD-g3eYn.js +0 -26
- package/web/assets/railroad-abnf-HS5TGJTU-C_bKzcjL.js +0 -1
- package/web/assets/railroad-ebnf-LZEXJU2U-BzPEt-Wn.js +0 -1
- package/web/assets/railroad-peg-WCYAUIDC-CeL6qyBT.js +0 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-DtyGmcgM.js +0 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-CbnaKO-d.js +0 -8
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Terminal views of the node's access report (src/access.ts): the full
|
|
4
|
+
// `bivy access` screen and the one-line summary `bivy status` shows.
|
|
5
|
+
|
|
6
|
+
const REACH_WORD = ["", "on your tailnet", "from anywhere"];
|
|
7
|
+
|
|
8
|
+
/** "Tailscale + Bivy hosted", or "This machine only". */
|
|
9
|
+
export function activeLabel(report) {
|
|
10
|
+
const on = report.setups.filter((s) => s.active && s.id !== "local");
|
|
11
|
+
return on.length ? on.map((s) => s.label).join(" + ") : "This machine only";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function labelOf(report, id) {
|
|
15
|
+
return report.setups.find((s) => s.id === id)?.label ?? id;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `access: Tailscale — next: Bivy hosted` for `bivy status`. */
|
|
19
|
+
export function accessLine(report) {
|
|
20
|
+
const next = report.next.filter((n) => n.id !== "server").map((n) => labelOf(report, n.id));
|
|
21
|
+
return `${activeLabel(report)}${next.length ? ` — next: ${next.join(" or ")} ('bivy access')` : ""}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The `bivy access` screen as lines; `paint` supplies colors. */
|
|
25
|
+
export function accessScreen(report, paint) {
|
|
26
|
+
const lines = [`${paint.bold("Access:")} ${activeLabel(report)}`];
|
|
27
|
+
if (report.tailscaleUrl) lines.push(` ${paint.dim("tailnet")} ${report.tailscaleUrl}`);
|
|
28
|
+
if (report.controlPlaneUrl) lines.push(` ${paint.dim("app")} ${report.controlPlaneUrl}`);
|
|
29
|
+
lines.push("");
|
|
30
|
+
for (const feature of report.features) {
|
|
31
|
+
const reach = report.reach[feature.id];
|
|
32
|
+
lines.push(` ${reach ? paint.green("✓") : paint.dim("✗")} ${feature.label}${reach ? paint.dim(` ${REACH_WORD[reach]}`.trimEnd()) : ""}`);
|
|
33
|
+
}
|
|
34
|
+
if (report.next.length) {
|
|
35
|
+
lines.push("", paint.bold("Next:"));
|
|
36
|
+
for (const step of report.next) {
|
|
37
|
+
const setup = report.setups.find((s) => s.id === step.id);
|
|
38
|
+
lines.push(` ${paint.cyan((setup?.command ?? "").padEnd(26))} ${setup?.label}`, ` ${" ".repeat(26)} ${paint.dim(`adds ${step.addsText}`)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
lines.push("", paint.dim("Setups stack: adding one never takes away another. 'bivy access local' turns remote access off."));
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
package/bin/bivy.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import { nativeResumeRef, resolveSessionsLimit, truncateSavedSessions } from "./
|
|
|
40
40
|
import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
|
|
41
41
|
import { removeInstallAndState } from "./uninstall-paths.mjs";
|
|
42
42
|
import { findAvailablePort, reconcilePort } from "./port-picker.mjs";
|
|
43
|
+
import { accessLine, accessScreen } from "./access-view.mjs";
|
|
43
44
|
import { parseTailscaleStatus, serveConflict, serveFailureHint, serveOffArgs, serveOnArgs, serveState } from "./tailscale.mjs";
|
|
44
45
|
import { resolveAttachSessionId } from "./attach-session-id.mjs";
|
|
45
46
|
import { detectInstallKind as classifyInstallKind, managedInstall, npmGlobalPrefix } from "./install-kind.mjs";
|
|
@@ -4527,6 +4528,8 @@ async function cmdTokenSetup() {
|
|
|
4527
4528
|
}
|
|
4528
4529
|
|
|
4529
4530
|
async function cmdSetup(args = []) {
|
|
4531
|
+
// Set by the access question; `bivy tailscale` runs once the node is up.
|
|
4532
|
+
let wantTailscale = false;
|
|
4530
4533
|
if (args.includes("-h") || args.includes("--help")) {
|
|
4531
4534
|
console.log("Usage: bivy setup\n\nFirst-run wizard: agent choice, model login, remote access + sign-in, and background service. Workspace and port get safe defaults. Re-run later to change the default agent or remote access.");
|
|
4532
4535
|
return;
|
|
@@ -4634,18 +4637,22 @@ async function cmdSetup(args = []) {
|
|
|
4634
4637
|
// (BIVY_CONTROL_PLANE_URL / BIVY_RELAY_URL then pre-fill the URL prompts below).
|
|
4635
4638
|
const nodeClaimCode = process.env.BIVY_NODE_CLAIM_CODE?.trim();
|
|
4636
4639
|
const selfHostEnv = !nodeClaimCode && Boolean((process.env.BIVY_CONTROL_PLANE_URL || "").trim() || (process.env.BIVY_RELAY_URL || "").trim());
|
|
4640
|
+
// Tailscale is offered only where it's installed; it needs no account.
|
|
4641
|
+
const tailscaleInstalled = runTailscale(["version"]) !== null;
|
|
4637
4642
|
const syncChoice = nodeClaimCode ? "h" : await askChoice(
|
|
4638
|
-
"
|
|
4643
|
+
"How will you reach this machine?",
|
|
4639
4644
|
[
|
|
4640
|
-
{ key: "h", label: "hosted (recommended — sign in with GitHub or email;
|
|
4641
|
-
{ key: "
|
|
4642
|
-
{ key: "
|
|
4645
|
+
{ key: "h", label: "Bivy hosted (recommended — sign in with GitHub or email; every machine, from anywhere)" },
|
|
4646
|
+
...(tailscaleInstalled ? [{ key: "t", label: "Tailscale (your devices over your tailnet; no account, nothing in between)" }] : []),
|
|
4647
|
+
{ key: "s", label: "your own server (a Bivy server you run)" },
|
|
4648
|
+
{ key: "l", label: "this machine only for now (add more any time with 'bivy access')" },
|
|
4643
4649
|
],
|
|
4644
4650
|
selfHostEnv ? "s" : "h",
|
|
4645
4651
|
);
|
|
4652
|
+
wantTailscale = syncChoice === "t";
|
|
4646
4653
|
const relayArgs = [];
|
|
4647
|
-
if (syncChoice === "l") {
|
|
4648
|
-
console.log(c.dim("
|
|
4654
|
+
if (syncChoice === "l" || syncChoice === "t") {
|
|
4655
|
+
console.log(c.dim(`\n${syncChoice === "t" ? "Tailscale is turned on once the node is running." : "Skipping remote access."} 'bivy access' shows every option later.`));
|
|
4649
4656
|
}
|
|
4650
4657
|
if (syncChoice === "s") {
|
|
4651
4658
|
const endpoints = await getHostedEndpoints();
|
|
@@ -4655,7 +4662,7 @@ async function cmdSetup(args = []) {
|
|
|
4655
4662
|
if (relayWs.trim()) relayArgs.push("--relay", relayWs.trim());
|
|
4656
4663
|
}
|
|
4657
4664
|
|
|
4658
|
-
if (syncChoice !== "l") {
|
|
4665
|
+
if (syncChoice !== "l" && syncChoice !== "t") {
|
|
4659
4666
|
if (!nodeClaimCode) {
|
|
4660
4667
|
const loginChoice = await askChoice(
|
|
4661
4668
|
"Remote login",
|
|
@@ -4704,7 +4711,7 @@ async function cmdSetup(args = []) {
|
|
|
4704
4711
|
setupSession = consumeSetupSession();
|
|
4705
4712
|
}
|
|
4706
4713
|
} else {
|
|
4707
|
-
console.log(c.dim("\nRemote access already configured.
|
|
4714
|
+
console.log(c.dim("\nRemote access already configured. 'bivy access' shows it and the other options."));
|
|
4708
4715
|
}
|
|
4709
4716
|
|
|
4710
4717
|
// GitHub App — connect it from the web app (Settings → GitHub App) or with
|
|
@@ -4784,6 +4791,7 @@ async function cmdSetup(args = []) {
|
|
|
4784
4791
|
? Boolean(liveReadiness?.credential?.ok ?? hasModelConfig(finalConfig))
|
|
4785
4792
|
: agentAuthReady;
|
|
4786
4793
|
const repoReady = Boolean(liveReadiness?.repository?.ok);
|
|
4794
|
+
if (wantTailscale) await cmdTailscale(["on"]).catch((error) => console.log(c.yellow(`Tailscale setup didn't finish: ${error?.message || error}. Run 'bivy access tailscale' to retry.`)));
|
|
4787
4795
|
console.log(c.bold(c.green("\n ✓ Node running. Check first-task readiness below.\n")));
|
|
4788
4796
|
console.log(` ${c.green("✓")} node reachable at ${url(finalConfig)}`);
|
|
4789
4797
|
console.log(` ${agentReady ? c.green("✓") : c.yellow("!")} runtime ${agentReady ? `${setupAgent?.label || "Pi"} available` : "not installed — run 'bivy agents:install'"}`);
|
|
@@ -4792,7 +4800,7 @@ async function cmdSetup(args = []) {
|
|
|
4792
4800
|
const ghReady = githubConnected(finalConfig);
|
|
4793
4801
|
console.log(` ${ghReady ? c.green("✓") : c.dim("○")} GitHub ${ghReady ? "connected — your repos will list in the app" : c.dim("optional — connect later in the app under Settings → GitHub App")}`);
|
|
4794
4802
|
console.log(` ${agentReady && modelReady && repoReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady && repoReady ? "ready to try" : "blocked by the stage above"}`);
|
|
4795
|
-
console.log(` ${fs.existsSync(relayConfigPath) ? c.green("✓") : c.
|
|
4803
|
+
console.log(` ${fs.existsSync(relayConfigPath) || fs.existsSync(tailscaleConfigPath) ? c.green("✓") : c.dim("○")} access ${fs.existsSync(relayConfigPath) || fs.existsSync(tailscaleConfigPath) ? "set up — 'bivy access' shows what it gives you" : "this machine only — 'bivy access' shows the options"}\n`);
|
|
4796
4804
|
// Get the user into the product immediately; terminal commands are the
|
|
4797
4805
|
// fallback/next-step checklist after the remote app has been opened or linked.
|
|
4798
4806
|
await finishSetupRemote(finalConfig, setupSession);
|
|
@@ -4979,8 +4987,10 @@ async function cmdStatus(args = []) {
|
|
|
4979
4987
|
const relay = loadRelayConfig();
|
|
4980
4988
|
const relaySt = status?.relay;
|
|
4981
4989
|
const relayConfigured = Boolean(relaySt?.configured || relay);
|
|
4990
|
+
const access = reachable ? await fetchAccess(config).catch(() => null) : null;
|
|
4991
|
+
if (access) console.log(` access: ${accessLine(access)}`);
|
|
4982
4992
|
if (!relayConfigured) {
|
|
4983
|
-
console.log(` remote: ${c.dim("
|
|
4993
|
+
if (!access) console.log(` remote: ${c.dim("this machine only")} ${c.dim("('bivy access' to see the options)")}`);
|
|
4984
4994
|
} else {
|
|
4985
4995
|
// The live link state is only knowable when the node is running; if it's
|
|
4986
4996
|
// down we can say it's configured but not whether it's currently connected.
|
|
@@ -5175,7 +5185,7 @@ async function cmdDoctor(args = []) {
|
|
|
5175
5185
|
const relayApp = status?.relay?.controlPlaneUrl;
|
|
5176
5186
|
const relayErr = status?.relay?.lastError;
|
|
5177
5187
|
const relayLine = !relayConfigured
|
|
5178
|
-
? c.dim("
|
|
5188
|
+
? c.dim("this machine only — 'bivy access' to see the options")
|
|
5179
5189
|
: relayConnected
|
|
5180
5190
|
? c.green("relay connected") + (relayApp ? c.dim(` ${relayApp}`) : "")
|
|
5181
5191
|
: c.yellow("configured, not connected") + (relayErr ? c.dim(` (${relayErr})`) : "");
|
|
@@ -5815,7 +5825,7 @@ async function cmdTailscale(args = []) {
|
|
|
5815
5825
|
process.exitCode = EXIT.unavailable;
|
|
5816
5826
|
return;
|
|
5817
5827
|
}
|
|
5818
|
-
const { running, hostname } = parseTailscaleStatus(status.stdout);
|
|
5828
|
+
const { running, hostname, owner } = parseTailscaleStatus(status.stdout);
|
|
5819
5829
|
if (!running || !hostname) {
|
|
5820
5830
|
console.log(`${c.yellow("Tailscale isn't connected.")} Sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
|
|
5821
5831
|
process.exitCode = EXIT.unavailable;
|
|
@@ -5828,7 +5838,7 @@ async function cmdTailscale(args = []) {
|
|
|
5828
5838
|
process.exitCode = EXIT.conflict;
|
|
5829
5839
|
return;
|
|
5830
5840
|
}
|
|
5831
|
-
fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname }, null, 2)}\n`, { mode: 0o600 });
|
|
5841
|
+
fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname, ...(owner ? { owner } : {}) }, null, 2)}\n`, { mode: 0o600 });
|
|
5832
5842
|
const reload = await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
|
|
5833
5843
|
const served = runTailscale(serveOnArgs(port));
|
|
5834
5844
|
if (!served?.ok) {
|
|
@@ -5841,12 +5851,83 @@ async function cmdTailscale(args = []) {
|
|
|
5841
5851
|
return;
|
|
5842
5852
|
}
|
|
5843
5853
|
console.log(c.bold(c.green(`\n Bivy is on https://${hostname}\n`)));
|
|
5844
|
-
console.log(c.dim(
|
|
5854
|
+
console.log(c.dim(owner
|
|
5855
|
+
? ` Only devices on your tailnet can reach it. Yours (signed in as ${owner}) are let in without pairing;\n anyone else's pairs once with a link from 'bivy tailscale pair'.`
|
|
5856
|
+
: " Only devices on your tailnet can reach it. This machine is tagged, so each device pairs once with a link from 'bivy tailscale pair'."));
|
|
5845
5857
|
if (!reload?.webApp) console.log(c.yellow(" This install has no built web app, so the address serves the API only. Run 'pnpm run build:web' in a source checkout."));
|
|
5846
5858
|
console.log("");
|
|
5859
|
+
if (owner) {
|
|
5860
|
+
console.log(`Open ${c.cyan(`https://${hostname}`)} on any of your devices.\n`);
|
|
5861
|
+
return;
|
|
5862
|
+
}
|
|
5847
5863
|
await printPairLink(config);
|
|
5848
5864
|
}
|
|
5849
5865
|
|
|
5866
|
+
async function fetchAccess(config) {
|
|
5867
|
+
return localApi(config, "/api/access/get", { method: "POST", body: "{}" });
|
|
5868
|
+
}
|
|
5869
|
+
|
|
5870
|
+
// `bivy access` — how this machine can be reached (this machine only,
|
|
5871
|
+
// Tailscale, Bivy hosted, your own server), what that gives you, and the next
|
|
5872
|
+
// step. The node owns the table (src/access.ts); this only renders and routes.
|
|
5873
|
+
async function cmdAccess(args = []) {
|
|
5874
|
+
const [verb, target] = args.filter((a) => !a.startsWith("-"));
|
|
5875
|
+
if (args.includes("-h") || args.includes("--help") || (verb && !["local", "tailscale", "hosted", "server"].includes(verb))) {
|
|
5876
|
+
console.log(`Usage: bivy access [--json]
|
|
5877
|
+
bivy access tailscale reach this machine from your devices over Tailscale
|
|
5878
|
+
bivy access hosted sign in to Bivy hosted: every machine, from anywhere
|
|
5879
|
+
bivy access server <url> [--relay <wss-url>] the same, through your own server
|
|
5880
|
+
bivy access local turn remote access off
|
|
5881
|
+
|
|
5882
|
+
Setups stack: Tailscale can stay on alongside hosted or your own server.`);
|
|
5883
|
+
if (verb && !["local", "tailscale", "hosted", "server"].includes(verb)) process.exitCode = EXIT.usage;
|
|
5884
|
+
return;
|
|
5885
|
+
}
|
|
5886
|
+
const config = loadConfig();
|
|
5887
|
+
if (!(await ensureNodeRunning(config))) {
|
|
5888
|
+
console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
|
|
5889
|
+
process.exitCode = EXIT.unavailable;
|
|
5890
|
+
return;
|
|
5891
|
+
}
|
|
5892
|
+
const report = await fetchAccess(config);
|
|
5893
|
+
|
|
5894
|
+
if (!verb) {
|
|
5895
|
+
if (wantsJson(args)) { console.log(JSON.stringify(report, null, 2)); return; }
|
|
5896
|
+
console.log(`\n${accessScreen(report, c).join("\n")}\n`);
|
|
5897
|
+
return;
|
|
5898
|
+
}
|
|
5899
|
+
if (verb === "tailscale") return cmdTailscale(["on"]);
|
|
5900
|
+
if (verb === "hosted" || verb === "server") {
|
|
5901
|
+
const linked = report.setups.find((s) => s.active && (s.id === "hosted" || s.id === "server"));
|
|
5902
|
+
if (linked && linked.id === verb && verb === "hosted") {
|
|
5903
|
+
console.log(`Already on ${linked.label}${report.controlPlaneUrl ? ` (${report.controlPlaneUrl})` : ""}.`);
|
|
5904
|
+
return;
|
|
5905
|
+
}
|
|
5906
|
+
if (linked) console.log(c.yellow(`This machine is on ${linked.label}${report.controlPlaneUrl ? ` (${report.controlPlaneUrl})` : ""}. Switching keeps everything on this machine; your devices sign in again at the new address.`));
|
|
5907
|
+
if (verb === "hosted") return cmdRelaySetup([]);
|
|
5908
|
+
if (!target) {
|
|
5909
|
+
console.log(`Usage: bivy access server <url> [--relay <wss-url>]\n\nThe address of your Bivy server, e.g. https://bivy.example.com. Set one up with docs/self-host-quickstart.md.`);
|
|
5910
|
+
process.exitCode = EXIT.usage;
|
|
5911
|
+
return;
|
|
5912
|
+
}
|
|
5913
|
+
let appUrl;
|
|
5914
|
+
try { appUrl = new URL(/^https?:\/\//.test(target) ? target : `https://${target}`); } catch { appUrl = null; }
|
|
5915
|
+
if (!appUrl) { console.error(c.red(`Not a URL: ${target}`)); process.exitCode = EXIT.usage; return; }
|
|
5916
|
+
// Bivy's server installs serve the relay at /relay on the app's own domain.
|
|
5917
|
+
const relayUrl = argValue(args, "relay") || `${appUrl.protocol === "http:" ? "ws" : "wss"}://${appUrl.host}/relay`;
|
|
5918
|
+
return cmdRelaySetup(["--control-plane", appUrl.origin, "--relay", relayUrl]);
|
|
5919
|
+
}
|
|
5920
|
+
// local: turn off every remote setup that is on.
|
|
5921
|
+
const on = report.setups.filter((s) => s.active && s.id !== "local");
|
|
5922
|
+
if (!on.length) { console.log("Already this machine only."); return; }
|
|
5923
|
+
const rl = createPrompter();
|
|
5924
|
+
const yes = await rl.askYesNo(`Turn off ${on.map((s) => s.label).join(" and ")}? Sessions and credentials on this machine stay.`, false);
|
|
5925
|
+
rl.close();
|
|
5926
|
+
if (!yes) return;
|
|
5927
|
+
if (on.some((s) => s.id === "tailscale")) await cmdTailscale(["off"]);
|
|
5928
|
+
if (on.some((s) => s.id === "hosted" || s.id === "server")) await cmdAccountLogout([]);
|
|
5929
|
+
}
|
|
5930
|
+
|
|
5850
5931
|
function printUninstallHelp() {
|
|
5851
5932
|
console.log(`
|
|
5852
5933
|
${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
|
|
@@ -6453,6 +6534,9 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
|
|
|
6453
6534
|
case "tailscale":
|
|
6454
6535
|
await cmdTailscale(args);
|
|
6455
6536
|
break;
|
|
6537
|
+
case "access":
|
|
6538
|
+
await cmdAccess(args);
|
|
6539
|
+
break;
|
|
6456
6540
|
case "relay:setup":
|
|
6457
6541
|
await cmdRelaySetup(args);
|
|
6458
6542
|
break;
|
package/bin/cli-commands.mjs
CHANGED
|
@@ -106,6 +106,7 @@ export const COMMANDS = [
|
|
|
106
106
|
{ name: "token", group: "node", scope: "node", usage: "token", summary: "Print a device token for this node" },
|
|
107
107
|
{ name: "open", group: "node", scope: "node", usage: "open", summary: "Open the remote web app" },
|
|
108
108
|
{ name: "link", group: "node", scope: "node", usage: "link", summary: "Show a web app link as a QR code in the terminal" },
|
|
109
|
+
{ name: "access", group: "node", scope: "node", json: true, usage: "access [local|tailscale|hosted|server <url>]", summary: "How this machine can be reached, what that gives you, and the next step", subcommands: ["local", "tailscale", "hosted", "server"] },
|
|
109
110
|
{ name: "tailscale", group: "node", scope: "node", usage: "tailscale [on|pair|status|devices|revoke|off]", summary: "Reach this machine over Tailscale, with no server in between", subcommands: ["on", "pair", "status", "devices", "revoke", "off"] },
|
|
110
111
|
{ name: "relay:setup", group: "node", scope: "node", usage: "relay:setup", summary: "Enable secure remote web app access" },
|
|
111
112
|
{ name: "uninstall", group: "node", scope: "node", usage: "uninstall [--keep-sessions] [--dry-run]", summary: "Remove Bivy and all its data" },
|
package/bin/tailscale.mjs
CHANGED
|
@@ -7,12 +7,19 @@
|
|
|
7
7
|
/** The one HTTPS port Bivy claims on the machine's tailnet name. */
|
|
8
8
|
export const SERVE_HTTPS_PORT = 443;
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* `{ running, hostname, owner }` from `tailscale status --json` output. `owner`
|
|
12
|
+
* is the login of the user this machine belongs to, or null for a tagged
|
|
13
|
+
* machine (owned by tags, not a person).
|
|
14
|
+
*/
|
|
11
15
|
export function parseTailscaleStatus(text) {
|
|
12
16
|
let status;
|
|
13
|
-
try { status = JSON.parse(text); } catch { return { running: false, hostname: null }; }
|
|
17
|
+
try { status = JSON.parse(text); } catch { return { running: false, hostname: null, owner: null }; }
|
|
14
18
|
const hostname = String(status?.Self?.DNSName ?? "").replace(/\.$/, "") || null;
|
|
15
|
-
|
|
19
|
+
const tagged = Array.isArray(status?.Self?.Tags) && status.Self.Tags.length > 0;
|
|
20
|
+
const login = status?.User?.[String(status?.Self?.UserID)]?.LoginName;
|
|
21
|
+
const owner = !tagged && typeof login === "string" && login.includes("@") ? login : null;
|
|
22
|
+
return { running: status?.BackendState === "Running", hostname, owner };
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
/** Where `tailscale serve` should send the machine's HTTPS traffic. */
|
package/dist/access.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// How this machine can be reached, as one table: the setups (this machine only,
|
|
5
|
+
// Tailscale, Bivy hosted, your own server), what each gives you, and which are
|
|
6
|
+
// active here. `bivy access`, `bivy status`, setup and the app all render this
|
|
7
|
+
// report, so they always agree. Setups stack: each only adds to the ones before.
|
|
8
|
+
export const ACCESS_FEATURES = [
|
|
9
|
+
{ id: "devices", label: "Your phone and other devices" },
|
|
10
|
+
{ id: "machines", label: "All your machines in one app" },
|
|
11
|
+
{ id: "push", label: "Push notifications" },
|
|
12
|
+
{ id: "sharing", label: "Shareable app previews" },
|
|
13
|
+
];
|
|
14
|
+
export const ACCESS_SETUPS = [
|
|
15
|
+
{ id: "local", label: "This machine only", summary: "The terminal and a browser on this machine.", gives: { devices: 0, machines: 0, push: 0, sharing: 0 } },
|
|
16
|
+
{ id: "tailscale", label: "Tailscale", summary: "Your devices reach your machines over your tailnet. No account, nothing in between.", command: "bivy access tailscale", gives: { devices: 1, machines: 1, push: 0, sharing: 0 } },
|
|
17
|
+
{ id: "hosted", label: "Bivy hosted", summary: "Sign in once. Sessions stay end-to-end encrypted.", command: "bivy access hosted", gives: { devices: 2, machines: 2, push: 2, sharing: 2 } },
|
|
18
|
+
{ id: "server", label: "Your own server", summary: "Like hosted, on a server you control.", command: "bivy access server <url>", gives: { devices: 2, machines: 2, push: 2, sharing: 2 } },
|
|
19
|
+
];
|
|
20
|
+
/** "a, b and c" (or "a and b"). */
|
|
21
|
+
function and(items) {
|
|
22
|
+
return items.length < 2 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
23
|
+
}
|
|
24
|
+
const origin = (url) => {
|
|
25
|
+
try {
|
|
26
|
+
return url ? new URL(url).origin : "";
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** Which setups are active, from what the node has on disk. */
|
|
33
|
+
export function activeSetups(inputs) {
|
|
34
|
+
const active = ["local"];
|
|
35
|
+
if (inputs.tailscaleHostname)
|
|
36
|
+
active.push("tailscale");
|
|
37
|
+
const relay = inputs.relay;
|
|
38
|
+
if (relay?.room && relay.roomToken)
|
|
39
|
+
active.push("server");
|
|
40
|
+
else if (relay?.controlPlaneUrl)
|
|
41
|
+
active.push(origin(relay.controlPlaneUrl) === origin(inputs.hostedControlPlane) ? "hosted" : "server");
|
|
42
|
+
return active;
|
|
43
|
+
}
|
|
44
|
+
export function accessReport(inputs) {
|
|
45
|
+
const active = activeSetups(inputs);
|
|
46
|
+
const reach = {};
|
|
47
|
+
for (const { id } of ACCESS_FEATURES) {
|
|
48
|
+
reach[id] = Math.max(...ACCESS_SETUPS.filter((s) => active.includes(s.id)).map((s) => s.gives[id]));
|
|
49
|
+
}
|
|
50
|
+
// Hosted and your own server are alternatives: once one is on, neither is a next step.
|
|
51
|
+
const linked = active.includes("hosted") || active.includes("server");
|
|
52
|
+
const next = ACCESS_SETUPS
|
|
53
|
+
.filter((s) => !active.includes(s.id) && !(linked && (s.id === "hosted" || s.id === "server")))
|
|
54
|
+
.map((s) => {
|
|
55
|
+
const adds = ACCESS_FEATURES.filter((f) => s.gives[f.id] > reach[f.id]);
|
|
56
|
+
// What you already have on your tailnet only widens: say "from anywhere" once for those.
|
|
57
|
+
const widened = adds.filter((f) => reach[f.id] === 1).map((f) => f.label.toLowerCase());
|
|
58
|
+
const fresh = adds.filter((f) => reach[f.id] === 0).map((f) => f.label.toLowerCase());
|
|
59
|
+
const phrases = [...(widened.length ? [`${and(widened)} from anywhere`] : []), ...fresh];
|
|
60
|
+
return { id: s.id, adds: adds.map((f) => f.id), addsText: and(phrases) };
|
|
61
|
+
})
|
|
62
|
+
.filter((s) => s.adds.length > 0);
|
|
63
|
+
return {
|
|
64
|
+
features: ACCESS_FEATURES,
|
|
65
|
+
setups: ACCESS_SETUPS.map((s) => ({ ...s, active: active.includes(s.id) })),
|
|
66
|
+
active,
|
|
67
|
+
reach,
|
|
68
|
+
next,
|
|
69
|
+
...(inputs.tailscaleHostname ? { tailscaleUrl: `https://${inputs.tailscaleHostname}` } : {}),
|
|
70
|
+
...(inputs.relay?.controlPlaneUrl ? { controlPlaneUrl: inputs.relay.controlPlaneUrl } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
package/dist/apps/styles.css
CHANGED
|
@@ -116,6 +116,7 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
|
|
|
116
116
|
padding: 9px var(--space-3); border: 0; border-radius: var(--radius-sm);
|
|
117
117
|
background: transparent; color: var(--ink); font: inherit;
|
|
118
118
|
font-size: var(--text-sm); text-align: left; cursor: pointer;
|
|
119
|
+
text-decoration: none; /* a menu item can be a link to another machine */
|
|
119
120
|
}
|
|
120
121
|
.menu-item:hover, .menu-item[aria-checked="true"], .menu-item.active { background: var(--surface-2); }
|
|
121
122
|
.menu-item.danger { color: var(--danger); }
|
package/dist/auth.js
CHANGED
|
@@ -115,10 +115,24 @@ export function isLoopbackAddress(address) {
|
|
|
115
115
|
// Sockets accepted by a listener that fronts remote callers through a local
|
|
116
116
|
// proxy (`tailscale serve` forwards tailnet traffic from 127.0.0.1). Their peer
|
|
117
117
|
// address is loopback, but the caller is not, so they never get loopback trust.
|
|
118
|
-
const remoteSockets = new
|
|
118
|
+
const remoteSockets = new WeakMap();
|
|
119
119
|
/** Treat every connection `server` accepts as remote, whatever its peer address. */
|
|
120
|
-
export function markRemoteListener(server) {
|
|
121
|
-
server.on("connection", (socket) => remoteSockets.
|
|
120
|
+
export function markRemoteListener(server, options = {}) {
|
|
121
|
+
server.on("connection", (socket) => remoteSockets.set(socket, options));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* `tailnet:<login>` when a request reached a remote listener from a device
|
|
125
|
+
* signed in to the tailnet as the machine's owner. The listener binds loopback,
|
|
126
|
+
* so another local account could forge the header; honor it only where local
|
|
127
|
+
* callers are trusted anyway (the same rule as the loopback bypass).
|
|
128
|
+
*/
|
|
129
|
+
function tailnetDevice(req) {
|
|
130
|
+
const options = req.socket ? remoteSockets.get(req.socket) : undefined;
|
|
131
|
+
const owner = options?.trustedLogin?.()?.toLowerCase();
|
|
132
|
+
const login = req.headers["tailscale-user-login"];
|
|
133
|
+
if (!owner || typeof login !== "string" || login.toLowerCase() !== owner)
|
|
134
|
+
return null;
|
|
135
|
+
return loopbackAllowed() ? `tailnet:${owner}` : null;
|
|
122
136
|
}
|
|
123
137
|
/** True when a request physically arrived over loopback from a local caller. */
|
|
124
138
|
export function isLoopbackRequest(req) {
|
|
@@ -154,7 +168,7 @@ export function tokenFromRequest(req) {
|
|
|
154
168
|
* `isAuthorized` is off. */
|
|
155
169
|
export function resolveAuth(identity, req) {
|
|
156
170
|
const loopback = isLoopbackRequest(req);
|
|
157
|
-
const deviceId = identity.verifyToken(tokenFromRequest(req));
|
|
171
|
+
const deviceId = identity.verifyToken(tokenFromRequest(req)) ?? tailnetDevice(req);
|
|
158
172
|
return { deviceId, loopback };
|
|
159
173
|
}
|
|
160
174
|
export function isAuthorized(ctx) {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
export const CLIENT_COMMAND_ROUTES = [
|
|
6
6
|
...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "menu", "present", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "agentNotes", "notes", "share", "unshare", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
7
7
|
{ method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
|
|
8
|
+
{ method: "post", path: "/api/access/get", kind: "access.get" },
|
|
9
|
+
{ method: "post", path: "/api/tailnet/machines", kind: "tailnet.machines" },
|
|
8
10
|
{ method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
|
|
9
11
|
{ method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
|
|
10
12
|
{ method: "post", path: "/api/session/pause", kind: "session.pause" },
|
|
@@ -11,6 +11,8 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
11
11
|
ping: Type.Object(request),
|
|
12
12
|
"apps.list": Type.Object({ ...request, ...optionalSession }),
|
|
13
13
|
"artifacts.list": Type.Object(request),
|
|
14
|
+
"access.get": Type.Object(request),
|
|
15
|
+
"tailnet.machines": Type.Object(request),
|
|
14
16
|
"session.fork.local": Type.Object({ ...request, ...session, model: Type.Optional(Type.Object({ provider: Type.Optional(Type.String({ maxLength: 200 })), id: Type.String({ maxLength: 200 }) })) }),
|
|
15
17
|
"apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
|
|
16
18
|
"apps.offers": Type.Object({ ...request, ...session }),
|
package/dist/remote/direct.js
CHANGED
|
@@ -15,7 +15,8 @@ export function loadDirectListenerConfig(appDir) {
|
|
|
15
15
|
const port = Number(raw.port);
|
|
16
16
|
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
17
17
|
return null;
|
|
18
|
-
|
|
18
|
+
const text = (value) => (typeof value === "string" && value ? value : undefined);
|
|
19
|
+
return { port, hostname: text(raw.hostname), owner: text(raw.owner) };
|
|
19
20
|
}
|
|
20
21
|
catch {
|
|
21
22
|
return null;
|
package/dist/remote/index.js
CHANGED
|
@@ -13,3 +13,4 @@
|
|
|
13
13
|
export { RelayConnector, loadRelayConfig } from "./relay-client.js";
|
|
14
14
|
export { soloCredentials, buildDialUrl } from "./solo.js";
|
|
15
15
|
export { loadDirectListenerConfig, DIRECT_CONFIG_FILE } from "./direct.js";
|
|
16
|
+
export { discoverTailnetMachines, parseTailnetPeers } from "./tailnet.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// The other machines on your tailnet that run Bivy, so a machine reached over
|
|
5
|
+
// Tailscale can offer the rest. Tailscale lists the peers; each is asked for
|
|
6
|
+
// Bivy's hello at its tailnet address, which only answers where `bivy
|
|
7
|
+
// tailscale` is on.
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
/** Peers from `tailscale status --json`, without the machine itself. */
|
|
10
|
+
export function parseTailnetPeers(text) {
|
|
11
|
+
let status;
|
|
12
|
+
try {
|
|
13
|
+
status = JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
return Object.values(status?.Peer ?? {})
|
|
19
|
+
.map((p) => ({ dnsName: String(p.DNSName ?? "").replace(/\.$/, ""), hostName: String(p.HostName ?? ""), online: p.Online === true, os: p.OS }))
|
|
20
|
+
.filter((p) => p.dnsName.endsWith(".ts.net"));
|
|
21
|
+
}
|
|
22
|
+
function tailscaleStatus() {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
execFile("tailscale", ["status", "--json"], { timeout: 3000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => resolve(error ? "" : stdout));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async function hello(peer, fetchImpl) {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetchImpl(`https://${peer.dnsName}/api/direct/hello`, { signal: AbortSignal.timeout(2500) });
|
|
30
|
+
const body = await res.json();
|
|
31
|
+
if (!res.ok || body?.bivy !== true)
|
|
32
|
+
return null;
|
|
33
|
+
return { name: typeof body.name === "string" && body.name ? body.name : peer.hostName, url: `https://${peer.dnsName}`, nodeId: typeof body.nodeId === "string" ? body.nodeId : undefined, os: peer.os, online: true, self: false };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Online tailnet peers running Bivy over Tailscale. Offline peers aren't asked. */
|
|
40
|
+
export async function discoverTailnetMachines(deps = {}) {
|
|
41
|
+
const peers = parseTailnetPeers(await (deps.status ?? tailscaleStatus)()).filter((p) => p.online);
|
|
42
|
+
const found = await Promise.all(peers.map((p) => hello(p, deps.fetchImpl ?? fetch)));
|
|
43
|
+
return found.filter((m) => m !== null).sort((a, b) => a.name.localeCompare(b.name));
|
|
44
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -87,9 +87,11 @@ import { SessionEventCoalescer } from "./session-event-coalescer.js";
|
|
|
87
87
|
import { readMachineTheme, watchMachineTheme } from "./machine-theme.js";
|
|
88
88
|
import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed, markRemoteListener } from "./auth.js";
|
|
89
89
|
import { PairCodes } from "./pair-codes.js";
|
|
90
|
+
import { accessReport } from "./access.js";
|
|
91
|
+
import { hostedEndpoints } from "./hosted-endpoints.mjs";
|
|
90
92
|
import http from "node:http";
|
|
91
93
|
import { webAppDirs, webAppRouter } from "./web-app.js";
|
|
92
|
-
import { RelayConnector, loadRelayConfig, loadDirectListenerConfig, soloCredentials } from "./remote/index.js";
|
|
94
|
+
import { RelayConnector, loadRelayConfig, loadDirectListenerConfig, discoverTailnetMachines, soloCredentials } from "./remote/index.js";
|
|
93
95
|
import { readEphemeralTeardownConfig, shouldSelfTeardown, snapshotsDurableForTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
|
|
94
96
|
import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
|
|
95
97
|
import { clearTurnActivity } from "./session/turn-activity.js";
|
|
@@ -2297,6 +2299,25 @@ appRegistry.on("notes", (viewId) => {
|
|
|
2297
2299
|
});
|
|
2298
2300
|
}, NOTES_NOTIFY_MS).unref?.();
|
|
2299
2301
|
});
|
|
2302
|
+
// Asking every peer takes a moment; a short cache keeps switcher opens instant.
|
|
2303
|
+
let tailnetCache = null;
|
|
2304
|
+
async function tailnetMachines() {
|
|
2305
|
+
const direct = loadDirectListenerConfig(appDir);
|
|
2306
|
+
if (!direct?.hostname)
|
|
2307
|
+
return [];
|
|
2308
|
+
if (!tailnetCache || Date.now() - tailnetCache.at > 15_000)
|
|
2309
|
+
tailnetCache = { at: Date.now(), machines: discoverTailnetMachines() };
|
|
2310
|
+
const self = { name: identity.name, url: `https://${direct.hostname}`, nodeId: identity.nodeId, os: process.platform, online: true, self: true };
|
|
2311
|
+
return [self, ...(await tailnetCache.machines).filter((m) => m.url !== self.url)];
|
|
2312
|
+
}
|
|
2313
|
+
function currentAccess() {
|
|
2314
|
+
const relay = loadRelayConfig(appDir);
|
|
2315
|
+
return accessReport({
|
|
2316
|
+
tailscaleHostname: loadDirectListenerConfig(appDir)?.hostname,
|
|
2317
|
+
relay: relay ? { controlPlaneUrl: relay.controlPlaneUrl, room: relay.room, roomToken: relay.roomToken } : null,
|
|
2318
|
+
hostedControlPlane: hostedEndpoints(process.env).controlPlane,
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2300
2321
|
const RELAY_COMMANDS = {
|
|
2301
2322
|
...createAppCommands(appService, (id) => { const record = resolveSession(id); return record ? harnessDirFor(record) : undefined; }, (app) => {
|
|
2302
2323
|
const record = resolveSession(app.sessionId);
|
|
@@ -2314,6 +2335,14 @@ const RELAY_COMMANDS = {
|
|
|
2314
2335
|
// (see runBivyUpdate). The node restarts itself when the update lands, so the
|
|
2315
2336
|
// client just sees the socket reconnect on the new build; a failure to even
|
|
2316
2337
|
// start reports back so the banner can show the manual command.
|
|
2338
|
+
// The machines on this machine's tailnet that run Bivy, this one first.
|
|
2339
|
+
async "tailnet.machines"(msg, ctx) {
|
|
2340
|
+
ctx.reply({ type: "tailnet.machines.ok", requestId: msg.requestId, machines: await tailnetMachines() });
|
|
2341
|
+
},
|
|
2342
|
+
// How this machine can be reached and what the next setup would add (src/access.ts).
|
|
2343
|
+
"access.get"(msg, ctx) {
|
|
2344
|
+
ctx.reply({ type: "access.get.ok", requestId: msg.requestId, ...currentAccess() });
|
|
2345
|
+
},
|
|
2317
2346
|
"node.update"(_msg, ctx) {
|
|
2318
2347
|
const result = runBivyUpdate();
|
|
2319
2348
|
ctx.reply({ type: "node.update.result", ok: result.ok, error: result.error });
|
|
@@ -9478,6 +9507,12 @@ app.post("/api/auth/pair", sensitiveRateLimiter, (req, res) => {
|
|
|
9478
9507
|
const { device, token } = accessDevices.create(name);
|
|
9479
9508
|
res.json({ ok: true, device, token });
|
|
9480
9509
|
});
|
|
9510
|
+
// Identifies a Bivy node to the other machines on its tailnet, so each can list
|
|
9511
|
+
// the rest (tailnet.machines). Public, like /healthz: it says only that Bivy
|
|
9512
|
+
// runs here and under which name; the direct listener is tailnet-only.
|
|
9513
|
+
app.get("/api/direct/hello", (_req, res) => {
|
|
9514
|
+
res.json({ bivy: true, name: identity.name, nodeId: identity.nodeId });
|
|
9515
|
+
});
|
|
9481
9516
|
// All other /api routes require auth (loopback may bypass, per config and host
|
|
9482
9517
|
// — see loopbackAllowed()/isMultiUserHost() in src/auth.ts).
|
|
9483
9518
|
app.use("/api", authMiddleware(identity, {
|
|
@@ -12142,8 +12177,10 @@ const wss = new WebSocketServer({ server, path: "/ws" });
|
|
|
12142
12177
|
// getting loopback trust; every caller needs a device token.
|
|
12143
12178
|
const directWebApp = webAppRouter(webAppDirs(repoRoot));
|
|
12144
12179
|
let directListener = null;
|
|
12180
|
+
let directOwner;
|
|
12145
12181
|
async function reloadDirectListener() {
|
|
12146
12182
|
const config = loadDirectListenerConfig(appDir);
|
|
12183
|
+
directOwner = config?.owner;
|
|
12147
12184
|
if (directListener && directListener.port !== config?.port) {
|
|
12148
12185
|
const previous = directListener;
|
|
12149
12186
|
directListener = null;
|
|
@@ -12160,7 +12197,8 @@ async function reloadDirectListener() {
|
|
|
12160
12197
|
directApp.use(directWebApp);
|
|
12161
12198
|
directApp.use(app);
|
|
12162
12199
|
const listener = http.createServer(directApp);
|
|
12163
|
-
|
|
12200
|
+
// The owner's own tailnet devices come in on their Tailscale identity.
|
|
12201
|
+
markRemoteListener(listener, { trustedLogin: () => directOwner });
|
|
12164
12202
|
const directWss = new WebSocketServer({ server: listener, path: "/ws" });
|
|
12165
12203
|
directWss.on("connection", (socket, req) => wss.emit("connection", socket, req));
|
|
12166
12204
|
await new Promise((resolve, reject) => {
|
package/package.json
CHANGED