@bivy/bivy 0.20.1-staging.8 → 0.20.1
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 +111 -14
- package/bin/cli-commands.mjs +1 -0
- package/bin/tailscale.mjs +10 -3
- package/dist/access.js +72 -0
- package/dist/apps/listeners.js +31 -3
- package/dist/apps/service.js +8 -1
- package/dist/apps/styles.css +4 -0
- package/dist/auth.js +18 -4
- package/dist/controllers/app-commands.js +7 -2
- package/dist/protocol/client-command-routes.js +2 -0
- package/dist/protocol/client-command-schemas.js +3 -1
- 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/dist/session/run-terminal.js +6 -2
- package/package.json +1 -1
- package/web/assets/AutomationsView-fnzmthnq.js +51 -0
- package/web/assets/{ChangesCard-sGQlkM2d.js → ChangesCard-BWUgQ40Y.js} +1 -1
- package/web/assets/{LibraryView-DYUo2624.js → LibraryView-JlSOji08.js} +1 -1
- package/web/assets/{ProviderConnect-DB-gwOwe.js → ProviderConnect-BFFZWHsW.js} +1 -1
- package/web/assets/Settings-BAHWxYDH.js +10 -0
- package/web/assets/{Sheet-BmAB1Fu_.js → Sheet-cVfLQVwf.js} +1 -1
- package/web/assets/{Terminal-Dkr504wc.js → Terminal-C3QFXkbI.js} +1 -1
- package/web/assets/{VoiceSettings-DaR6ba3A.js → VoiceSettings-CWOtAlAV.js} +1 -1
- package/web/assets/{abnfDiagram-VCTEODGH-CZTMfHPQ.js → abnfDiagram-VCTEODGH-7cQ-bgry.js} +1 -1
- package/web/assets/architecture-7GRP2DOG-DVlvlW8H.js +1 -0
- package/web/assets/{architectureDiagram-5GKGNRK7-BBR7u-Nc.js → architectureDiagram-5GKGNRK7-BWOpNQNn.js} +1 -1
- package/web/assets/{blockDiagram-I7D4REHJ-DU1iTe41.js → blockDiagram-I7D4REHJ-8eE7JD9I.js} +1 -1
- package/web/assets/{c4Diagram-7LVT6UL2-DoY25VIT.js → c4Diagram-7LVT6UL2-DgiPr6K1.js} +1 -1
- package/web/assets/channel-Coq89lsO.js +1 -0
- package/web/assets/{chunk-4HAMMTFA-CwLS5ngk.js → chunk-4HAMMTFA-C8GTT_rj.js} +1 -1
- package/web/assets/{chunk-75Z2AOVW-FHeNbdLy.js → chunk-75Z2AOVW-BRMKvs-_.js} +1 -1
- package/web/assets/{chunk-DU6HZSFF-CUNfwm6b.js → chunk-DU6HZSFF-A-GuRJzV.js} +1 -1
- package/web/assets/{chunk-F27PBJKO-BT9LzzRz.js → chunk-F27PBJKO-D34kNJfE.js} +1 -1
- package/web/assets/{chunk-GMAD6QVW-BUr3PoNi.js → chunk-GMAD6QVW-CzLW6Gbl.js} +1 -1
- package/web/assets/{chunk-GVQU2GXP-Dwa-Ue8e.js → chunk-GVQU2GXP-CqTFATNd.js} +1 -1
- package/web/assets/{chunk-IMKFNOWR-CBemkwVh.js → chunk-IMKFNOWR-BLo_6uPl.js} +1 -1
- package/web/assets/{chunk-L3NEJ4N5-AGhZr4q5.js → chunk-L3NEJ4N5-CFSYYra2.js} +1 -1
- package/web/assets/{chunk-OSK3NFVY-CxTtDDij.js → chunk-OSK3NFVY-DJ3F-6TB.js} +1 -1
- package/web/assets/{chunk-P2QGCYS3-DGmss7hg.js → chunk-P2QGCYS3-B3qbBF6d.js} +1 -1
- package/web/assets/{chunk-POPQ4Y6H-nsxQTUJd.js → chunk-POPQ4Y6H-DbN8xQ-f.js} +1 -1
- package/web/assets/{chunk-PWAF6VOD-CFEkzIZ2.js → chunk-PWAF6VOD-C7BBYtAa.js} +1 -1
- package/web/assets/{chunk-SHT3W25Y-BArb8KoG.js → chunk-SHT3W25Y-B3wT37BE.js} +1 -1
- package/web/assets/{chunk-SVP7TREG-CEFJfpYl.js → chunk-SVP7TREG-OXvICZat.js} +1 -1
- package/web/assets/{chunk-TICWLB2K-ByqSjpyA.js → chunk-TICWLB2K-CrmJ4sWX.js} +1 -1
- package/web/assets/classDiagram-ZZMXUADV-rFDDoTN3.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-rFDDoTN3.js +1 -0
- package/web/assets/{cynefin-OW5HDTMX-BPd4eJmp.js → cynefin-OW5HDTMX-BFhdG87z.js} +1 -1
- package/web/assets/{cynefinDiagram-5FMLGOSQ-rm271vYO.js → cynefinDiagram-5FMLGOSQ-BlBRhJR5.js} +1 -1
- package/web/assets/{dagre-GXQ25YYZ-a4DVMh7o.js → dagre-GXQ25YYZ-COxHf3g-.js} +1 -1
- package/web/assets/{diagram-S7CK7UJ4-CR4kj8JQ.js → diagram-S7CK7UJ4-mWtNPBS9.js} +1 -1
- package/web/assets/{diagram-UQ7AKVKN-BVAidz_h.js → diagram-UQ7AKVKN-CAvoNcsc.js} +1 -1
- package/web/assets/{diagram-VSXAHHWV-P_KshIhr.js → diagram-VSXAHHWV-DA9LJqgE.js} +1 -1
- package/web/assets/{diagram-VX7I27RA-CfR4HucM.js → diagram-VX7I27RA-7RswVDku.js} +1 -1
- package/web/assets/{diagram-Z3DM3KII-CnxFHDS4.js → diagram-Z3DM3KII-DknRBMRd.js} +1 -1
- package/web/assets/{ebnfDiagram-PWID7BFC-0lvDIevQ.js → ebnfDiagram-PWID7BFC-hAmbemf1.js} +1 -1
- package/web/assets/{erDiagram-RLTQ6QDP-B5onTaVV.js → erDiagram-RLTQ6QDP-DxXSxQWj.js} +1 -1
- package/web/assets/eventmodeling-NTZA5JFV-CMDfKRrP.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-BQ27YGHa.js +1 -0
- package/web/assets/{ganttDiagram-EL5Y4UJY-DcP58ot2.js → ganttDiagram-EL5Y4UJY-BV-zpuyt.js} +1 -1
- package/web/assets/{gitGraph-4MIJSDKK-DMgkg1Fa.js → gitGraph-4MIJSDKK-BfZe7_3E.js} +1 -1
- package/web/assets/{gitGraphDiagram-WWUBYQGX-ChZQlU2S.js → gitGraphDiagram-WWUBYQGX-3_hHUbBU.js} +1 -1
- package/web/assets/index-D36pVYmX.css +1 -0
- package/web/assets/{index-emCm7_bF.js → index-x_iImoSf.js} +2 -2
- package/web/assets/{info-A6RAGUB7-Bwm7-KTz.js → info-A6RAGUB7-DUWO2H1L.js} +1 -1
- package/web/assets/{infoDiagram-27XIBGKW-PpNSZSrV.js → infoDiagram-27XIBGKW-BKxWRZYw.js} +1 -1
- package/web/assets/{ishikawaDiagram-5VMMS53U-DXh6yXna.js → ishikawaDiagram-5VMMS53U-CxQdy8l3.js} +1 -1
- package/web/assets/{journeyDiagram-3NMN7TZE-DFEj5jkB.js → journeyDiagram-3NMN7TZE-DplhU31X.js} +1 -1
- package/web/assets/{kanban-definition-UXKFOSKX-C5Mvo17h.js → kanban-definition-UXKFOSKX-Cmls5FZv.js} +1 -1
- package/web/assets/{line-CqUbcJrv.js → line-NoU3PhOc.js} +1 -1
- package/web/assets/{mermaid-parser.core-BzUSMP_7.js → mermaid-parser.core-CyOqjoOF.js} +3 -3
- package/web/assets/{mermaid.core-CooqV3Py.js → mermaid.core-BbpKlZWj.js} +4 -4
- package/web/assets/{mindmap-definition-YA3MSWOX-CvsfS7bG.js → mindmap-definition-YA3MSWOX-CfDDhyG9.js} +1 -1
- package/web/assets/mount-GHVLdZcl.js +27 -0
- package/web/assets/{packet-AYTQ26CC-BzXGhTo4.js → packet-AYTQ26CC-CEyZqXlC.js} +1 -1
- package/web/assets/{pegDiagram-XKGWAZYB-BvdN2RIR.js → pegDiagram-XKGWAZYB-BsQ551jM.js} +1 -1
- package/web/assets/{pie-WAS4IAKB-CNH8JQby.js → pie-WAS4IAKB-Cy_8_ris.js} +1 -1
- package/web/assets/{pieDiagram-E7YTZNPT-C6LABPqT.js → pieDiagram-E7YTZNPT-DQHtyFkg.js} +1 -1
- package/web/assets/{quadrantDiagram-AXDQQJYC-D2ebcGIP.js → quadrantDiagram-AXDQQJYC-iSVEwT1y.js} +1 -1
- package/web/assets/{radar-RG4KPBEZ-B7uFPzC9.js → radar-RG4KPBEZ-BO-qdlYd.js} +1 -1
- package/web/assets/{railroad-74A4TZTK-CE9pdZ9o.js → railroad-74A4TZTK-BEuDeDCt.js} +1 -1
- package/web/assets/railroad-abnf-HS5TGJTU-BbGdsa6v.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-qaRxJkxs.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-C6hTOfSB.js +1 -0
- package/web/assets/{railroadDiagram-O6MQD6OU-vJc9tRJP.js → railroadDiagram-O6MQD6OU-Bmp5czol.js} +1 -1
- package/web/assets/{requirementDiagram-BXWQKSXE-CVLUnEfL.js → requirementDiagram-BXWQKSXE-CflpYj7d.js} +1 -1
- package/web/assets/{sankeyDiagram-P5KCCOFB-DkHI3ZIh.js → sankeyDiagram-P5KCCOFB-D3xDfaLR.js} +1 -1
- package/web/assets/{sequenceDiagram-WJ2MYXX4-BgosmpaZ.js → sequenceDiagram-WJ2MYXX4-C6ZWIJwX.js} +1 -1
- package/web/assets/settingsRoute-COWp-9BH.js +1 -0
- package/web/assets/{stateDiagram-D77RDMKH-BZ5Zqme7.js → stateDiagram-D77RDMKH-BXjL8DPv.js} +1 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-BCGm337k.js +1 -0
- package/web/assets/{swimlanes-42K2YHIH-rkMg8KEw.js → swimlanes-42K2YHIH-CkyaLI3E.js} +1 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-RZPHF_4s.js +8 -0
- package/web/assets/{timeline-definition-24CTP7MA-_z_HJLWC.js → timeline-definition-24CTP7MA-BdMWCl3_.js} +1 -1
- package/web/assets/{treeView-Q6P3EWNA-jR5Z1Fmf.js → treeView-Q6P3EWNA-C4MMyFq1.js} +1 -1
- package/web/assets/{treemap-WGGIJYW6-OtJhfdUS.js → treemap-WGGIJYW6-BUER7WW0.js} +1 -1
- package/web/assets/{useStore-CaPvUEwc.js → useStore-B5xeFBSp.js} +3 -3
- package/web/assets/{vennDiagram-4TSXK5OY-BFTbjE-l.js → vennDiagram-4TSXK5OY-CmcfuybF.js} +1 -1
- package/web/assets/{wardley-WFR3VGLG-BV0ly0l7.js → wardley-WFR3VGLG-CbuHqcZs.js} +1 -1
- package/web/assets/{wardleyDiagram-VM6X3IG4-DYvCqNRg.js → wardleyDiagram-VM6X3IG4-B-dD7NPY.js} +1 -1
- package/web/assets/{xychartDiagram-S5SC5T6Z-CaYy8yTC.js → xychartDiagram-S5SC5T6Z-DkJf1OlB.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
|
|
@@ -4743,6 +4750,19 @@ async function cmdSetup(args = []) {
|
|
|
4743
4750
|
}
|
|
4744
4751
|
}
|
|
4745
4752
|
|
|
4753
|
+
// The terminal path to the same loop: the agent's own command starts a Bivy
|
|
4754
|
+
// session, so work begun in a terminal shows up in the app and reaches the
|
|
4755
|
+
// phone. Offered after sign-in, which runs the real agent.
|
|
4756
|
+
const shimAgent = setupAgent?.command;
|
|
4757
|
+
if (agentReady && shimAgent && AGENT_INTEGRATIONS.has(shimAgent) && !loadShims().shims[shimAgent]) {
|
|
4758
|
+
console.log(`\nWhen you type '${shimAgent}' in a terminal, Bivy can run it as a session: it shows up in the app, and your phone hears when it finishes or needs you.`);
|
|
4759
|
+
if (await askYesNo(`Start a Bivy session whenever you run '${shimAgent}'?`, true)) {
|
|
4760
|
+
rl.pause();
|
|
4761
|
+
await run(nodeBin, [selfScript, "shim", "install", shimAgent], { env: process.env });
|
|
4762
|
+
rl.resume();
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
|
|
4746
4766
|
// Materialize canonical typed config before starting/restarting the daemon,
|
|
4747
4767
|
// so this setup run's workspace/port/agent choices are effective immediately.
|
|
4748
4768
|
// Existing config keeps every advanced field.
|
|
@@ -4784,6 +4804,7 @@ async function cmdSetup(args = []) {
|
|
|
4784
4804
|
? Boolean(liveReadiness?.credential?.ok ?? hasModelConfig(finalConfig))
|
|
4785
4805
|
: agentAuthReady;
|
|
4786
4806
|
const repoReady = Boolean(liveReadiness?.repository?.ok);
|
|
4807
|
+
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
4808
|
console.log(c.bold(c.green("\n ✓ Node running. Check first-task readiness below.\n")));
|
|
4788
4809
|
console.log(` ${c.green("✓")} node reachable at ${url(finalConfig)}`);
|
|
4789
4810
|
console.log(` ${agentReady ? c.green("✓") : c.yellow("!")} runtime ${agentReady ? `${setupAgent?.label || "Pi"} available` : "not installed — run 'bivy agents:install'"}`);
|
|
@@ -4792,7 +4813,7 @@ async function cmdSetup(args = []) {
|
|
|
4792
4813
|
const ghReady = githubConnected(finalConfig);
|
|
4793
4814
|
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
4815
|
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.
|
|
4816
|
+
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
4817
|
// Get the user into the product immediately; terminal commands are the
|
|
4797
4818
|
// fallback/next-step checklist after the remote app has been opened or linked.
|
|
4798
4819
|
await finishSetupRemote(finalConfig, setupSession);
|
|
@@ -4979,8 +5000,10 @@ async function cmdStatus(args = []) {
|
|
|
4979
5000
|
const relay = loadRelayConfig();
|
|
4980
5001
|
const relaySt = status?.relay;
|
|
4981
5002
|
const relayConfigured = Boolean(relaySt?.configured || relay);
|
|
5003
|
+
const access = reachable ? await fetchAccess(config).catch(() => null) : null;
|
|
5004
|
+
if (access) console.log(` access: ${accessLine(access)}`);
|
|
4982
5005
|
if (!relayConfigured) {
|
|
4983
|
-
console.log(` remote: ${c.dim("
|
|
5006
|
+
if (!access) console.log(` remote: ${c.dim("this machine only")} ${c.dim("('bivy access' to see the options)")}`);
|
|
4984
5007
|
} else {
|
|
4985
5008
|
// The live link state is only knowable when the node is running; if it's
|
|
4986
5009
|
// down we can say it's configured but not whether it's currently connected.
|
|
@@ -5175,7 +5198,7 @@ async function cmdDoctor(args = []) {
|
|
|
5175
5198
|
const relayApp = status?.relay?.controlPlaneUrl;
|
|
5176
5199
|
const relayErr = status?.relay?.lastError;
|
|
5177
5200
|
const relayLine = !relayConfigured
|
|
5178
|
-
? c.dim("
|
|
5201
|
+
? c.dim("this machine only — 'bivy access' to see the options")
|
|
5179
5202
|
: relayConnected
|
|
5180
5203
|
? c.green("relay connected") + (relayApp ? c.dim(` ${relayApp}`) : "")
|
|
5181
5204
|
: c.yellow("configured, not connected") + (relayErr ? c.dim(` (${relayErr})`) : "");
|
|
@@ -5815,7 +5838,7 @@ async function cmdTailscale(args = []) {
|
|
|
5815
5838
|
process.exitCode = EXIT.unavailable;
|
|
5816
5839
|
return;
|
|
5817
5840
|
}
|
|
5818
|
-
const { running, hostname } = parseTailscaleStatus(status.stdout);
|
|
5841
|
+
const { running, hostname, owner } = parseTailscaleStatus(status.stdout);
|
|
5819
5842
|
if (!running || !hostname) {
|
|
5820
5843
|
console.log(`${c.yellow("Tailscale isn't connected.")} Sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
|
|
5821
5844
|
process.exitCode = EXIT.unavailable;
|
|
@@ -5828,7 +5851,7 @@ async function cmdTailscale(args = []) {
|
|
|
5828
5851
|
process.exitCode = EXIT.conflict;
|
|
5829
5852
|
return;
|
|
5830
5853
|
}
|
|
5831
|
-
fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname }, null, 2)}\n`, { mode: 0o600 });
|
|
5854
|
+
fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname, ...(owner ? { owner } : {}) }, null, 2)}\n`, { mode: 0o600 });
|
|
5832
5855
|
const reload = await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
|
|
5833
5856
|
const served = runTailscale(serveOnArgs(port));
|
|
5834
5857
|
if (!served?.ok) {
|
|
@@ -5841,12 +5864,83 @@ async function cmdTailscale(args = []) {
|
|
|
5841
5864
|
return;
|
|
5842
5865
|
}
|
|
5843
5866
|
console.log(c.bold(c.green(`\n Bivy is on https://${hostname}\n`)));
|
|
5844
|
-
console.log(c.dim(
|
|
5867
|
+
console.log(c.dim(owner
|
|
5868
|
+
? ` 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'.`
|
|
5869
|
+
: " 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
5870
|
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
5871
|
console.log("");
|
|
5872
|
+
if (owner) {
|
|
5873
|
+
console.log(`Open ${c.cyan(`https://${hostname}`)} on any of your devices.\n`);
|
|
5874
|
+
return;
|
|
5875
|
+
}
|
|
5847
5876
|
await printPairLink(config);
|
|
5848
5877
|
}
|
|
5849
5878
|
|
|
5879
|
+
async function fetchAccess(config) {
|
|
5880
|
+
return localApi(config, "/api/access/get", { method: "POST", body: "{}" });
|
|
5881
|
+
}
|
|
5882
|
+
|
|
5883
|
+
// `bivy access` — how this machine can be reached (this machine only,
|
|
5884
|
+
// Tailscale, Bivy hosted, your own server), what that gives you, and the next
|
|
5885
|
+
// step. The node owns the table (src/access.ts); this only renders and routes.
|
|
5886
|
+
async function cmdAccess(args = []) {
|
|
5887
|
+
const [verb, target] = args.filter((a) => !a.startsWith("-"));
|
|
5888
|
+
if (args.includes("-h") || args.includes("--help") || (verb && !["local", "tailscale", "hosted", "server"].includes(verb))) {
|
|
5889
|
+
console.log(`Usage: bivy access [--json]
|
|
5890
|
+
bivy access tailscale reach this machine from your devices over Tailscale
|
|
5891
|
+
bivy access hosted sign in to Bivy hosted: every machine, from anywhere
|
|
5892
|
+
bivy access server <url> [--relay <wss-url>] the same, through your own server
|
|
5893
|
+
bivy access local turn remote access off
|
|
5894
|
+
|
|
5895
|
+
Setups stack: Tailscale can stay on alongside hosted or your own server.`);
|
|
5896
|
+
if (verb && !["local", "tailscale", "hosted", "server"].includes(verb)) process.exitCode = EXIT.usage;
|
|
5897
|
+
return;
|
|
5898
|
+
}
|
|
5899
|
+
const config = loadConfig();
|
|
5900
|
+
if (!(await ensureNodeRunning(config))) {
|
|
5901
|
+
console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
|
|
5902
|
+
process.exitCode = EXIT.unavailable;
|
|
5903
|
+
return;
|
|
5904
|
+
}
|
|
5905
|
+
const report = await fetchAccess(config);
|
|
5906
|
+
|
|
5907
|
+
if (!verb) {
|
|
5908
|
+
if (wantsJson(args)) { console.log(JSON.stringify(report, null, 2)); return; }
|
|
5909
|
+
console.log(`\n${accessScreen(report, c).join("\n")}\n`);
|
|
5910
|
+
return;
|
|
5911
|
+
}
|
|
5912
|
+
if (verb === "tailscale") return cmdTailscale(["on"]);
|
|
5913
|
+
if (verb === "hosted" || verb === "server") {
|
|
5914
|
+
const linked = report.setups.find((s) => s.active && (s.id === "hosted" || s.id === "server"));
|
|
5915
|
+
if (linked && linked.id === verb && verb === "hosted") {
|
|
5916
|
+
console.log(`Already on ${linked.label}${report.controlPlaneUrl ? ` (${report.controlPlaneUrl})` : ""}.`);
|
|
5917
|
+
return;
|
|
5918
|
+
}
|
|
5919
|
+
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.`));
|
|
5920
|
+
if (verb === "hosted") return cmdRelaySetup([]);
|
|
5921
|
+
if (!target) {
|
|
5922
|
+
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.`);
|
|
5923
|
+
process.exitCode = EXIT.usage;
|
|
5924
|
+
return;
|
|
5925
|
+
}
|
|
5926
|
+
let appUrl;
|
|
5927
|
+
try { appUrl = new URL(/^https?:\/\//.test(target) ? target : `https://${target}`); } catch { appUrl = null; }
|
|
5928
|
+
if (!appUrl) { console.error(c.red(`Not a URL: ${target}`)); process.exitCode = EXIT.usage; return; }
|
|
5929
|
+
// Bivy's server installs serve the relay at /relay on the app's own domain.
|
|
5930
|
+
const relayUrl = argValue(args, "relay") || `${appUrl.protocol === "http:" ? "ws" : "wss"}://${appUrl.host}/relay`;
|
|
5931
|
+
return cmdRelaySetup(["--control-plane", appUrl.origin, "--relay", relayUrl]);
|
|
5932
|
+
}
|
|
5933
|
+
// local: turn off every remote setup that is on.
|
|
5934
|
+
const on = report.setups.filter((s) => s.active && s.id !== "local");
|
|
5935
|
+
if (!on.length) { console.log("Already this machine only."); return; }
|
|
5936
|
+
const rl = createPrompter();
|
|
5937
|
+
const yes = await rl.askYesNo(`Turn off ${on.map((s) => s.label).join(" and ")}? Sessions and credentials on this machine stay.`, false);
|
|
5938
|
+
rl.close();
|
|
5939
|
+
if (!yes) return;
|
|
5940
|
+
if (on.some((s) => s.id === "tailscale")) await cmdTailscale(["off"]);
|
|
5941
|
+
if (on.some((s) => s.id === "hosted" || s.id === "server")) await cmdAccountLogout([]);
|
|
5942
|
+
}
|
|
5943
|
+
|
|
5850
5944
|
function printUninstallHelp() {
|
|
5851
5945
|
console.log(`
|
|
5852
5946
|
${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
|
|
@@ -6453,6 +6547,9 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
|
|
|
6453
6547
|
case "tailscale":
|
|
6454
6548
|
await cmdTailscale(args);
|
|
6455
6549
|
break;
|
|
6550
|
+
case "access":
|
|
6551
|
+
await cmdAccess(args);
|
|
6552
|
+
break;
|
|
6456
6553
|
case "relay:setup":
|
|
6457
6554
|
await cmdRelaySetup(args);
|
|
6458
6555
|
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/listeners.js
CHANGED
|
@@ -19,11 +19,39 @@ export async function scanListeners(workspace, platform = process.platform) {
|
|
|
19
19
|
}
|
|
20
20
|
const found = platform === "linux" ? await scanProc(root) : platform === "darwin" ? await scanLsof(root) : [];
|
|
21
21
|
const byPort = new Map();
|
|
22
|
-
for (const offer of found)
|
|
22
|
+
for (const { cwd: _cwd, ...offer } of found)
|
|
23
23
|
if (offer.pid !== process.pid && offer.port >= 1024 && !byPort.has(offer.port))
|
|
24
24
|
byPort.set(offer.port, offer);
|
|
25
25
|
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
26
26
|
}
|
|
27
|
+
/** Servers running anywhere under `root` (the owner's home), each with the
|
|
28
|
+
* project it runs in: what a first session can open before it has a folder.
|
|
29
|
+
* Processes running in `root` itself or in Bivy's own data folders aren't apps. */
|
|
30
|
+
export async function scanMachineListeners(root, bivyDirs = [path.join(root, ".bivy")], platform = process.platform) {
|
|
31
|
+
let top;
|
|
32
|
+
try {
|
|
33
|
+
top = await fs.realpath(root);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const found = platform === "linux" ? await scanProc(top) : platform === "darwin" ? await scanLsof(top) : [];
|
|
39
|
+
const byPort = new Map();
|
|
40
|
+
for (const { cwd, ...offer } of found) {
|
|
41
|
+
if (offer.pid === process.pid || offer.port < 1024 || byPort.has(offer.port) || cwd === top || bivyDirs.includes(cwd))
|
|
42
|
+
continue;
|
|
43
|
+
byPort.set(offer.port, { ...offer, project: await projectOf(cwd, top) });
|
|
44
|
+
}
|
|
45
|
+
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
46
|
+
}
|
|
47
|
+
/** The nearest folder at or above `dir` (below `top`) with a .git, else `dir`. */
|
|
48
|
+
async function projectOf(dir, top) {
|
|
49
|
+
for (let at = dir; inside(top, at) && at !== top; at = path.dirname(at)) {
|
|
50
|
+
if (await fs.stat(path.join(at, ".git")).then(() => true, () => false))
|
|
51
|
+
return at;
|
|
52
|
+
}
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
27
55
|
const inside = (root, dir) => dir === root || dir.startsWith(root + path.sep);
|
|
28
56
|
/** `local_address` in /proc/net/tcp{,6}: hex address in little-endian 32-bit words. */
|
|
29
57
|
export function listenPort(line) {
|
|
@@ -73,7 +101,7 @@ async function scanProc(root) {
|
|
|
73
101
|
return;
|
|
74
102
|
const argv = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8").catch(() => "")).split("\0").filter(Boolean);
|
|
75
103
|
for (const port of ports)
|
|
76
|
-
offers.push({ port, pid: Number(pid), command: commandLabel(argv) });
|
|
104
|
+
offers.push({ port, pid: Number(pid), command: commandLabel(argv), cwd });
|
|
77
105
|
}));
|
|
78
106
|
return offers;
|
|
79
107
|
}
|
|
@@ -103,7 +131,7 @@ async function scanLsof(root) {
|
|
|
103
131
|
for (const name of values) {
|
|
104
132
|
const match = /^(\*|127\.[\d.]+|\[::1\]|\[::\]|localhost):(\d+)$/.exec(name);
|
|
105
133
|
if (match)
|
|
106
|
-
offers.push({ port: Number(match[2]), pid, command: commandLabel(args.split(/\s+/)) });
|
|
134
|
+
offers.push({ port: Number(match[2]), pid, command: commandLabel(args.split(/\s+/)), cwd });
|
|
107
135
|
}
|
|
108
136
|
}
|
|
109
137
|
return offers;
|
package/dist/apps/service.js
CHANGED
|
@@ -6,7 +6,7 @@ import { PIN_CHANGE, REVIEW_MODES, regionChange, shouldReview, visualChange } fr
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import { scanListeners } from "./listeners.js";
|
|
9
|
+
import { scanListeners, scanMachineListeners } from "./listeners.js";
|
|
10
10
|
import { takeShots } from "./screenshot.js";
|
|
11
11
|
import { approximate, composite, crop, noteAnchor, readNotes, readStrokes, readElementScrolls } from "./annotate.js";
|
|
12
12
|
import { captureFrame, encodePng, sendInput } from "./rfb.js";
|
|
@@ -44,6 +44,7 @@ export class AppService {
|
|
|
44
44
|
terminalStarts = new Map();
|
|
45
45
|
servers = new Map();
|
|
46
46
|
scan;
|
|
47
|
+
scanMachine;
|
|
47
48
|
serverWatchMs;
|
|
48
49
|
screenshots;
|
|
49
50
|
displays;
|
|
@@ -68,6 +69,7 @@ export class AppService {
|
|
|
68
69
|
this.gateway = gateway;
|
|
69
70
|
this.terminals = terminals;
|
|
70
71
|
this.scan = options.scan ?? scanListeners;
|
|
72
|
+
this.scanMachine = options.scanMachine ?? scanMachineListeners;
|
|
71
73
|
this.serverWatchMs = options.serverWatchMs ?? 5_000;
|
|
72
74
|
this.screenshots = { enabled: options.screenshots?.enabled ?? (() => false), take: options.screenshots?.take ?? takeShots };
|
|
73
75
|
this.displays = options.displays ?? NO_DISPLAYS;
|
|
@@ -330,6 +332,11 @@ export class AppService {
|
|
|
330
332
|
const claimed = this.registry.claimedPorts(sessionId);
|
|
331
333
|
return { offers: (await this.scan(workspace)).filter((offer) => !claimed.has(offer.port)) };
|
|
332
334
|
}
|
|
335
|
+
/** Servers running anywhere under `root`, with their projects: what a first
|
|
336
|
+
* session can start in and preview before it has a workspace of its own. */
|
|
337
|
+
async machineOffers(root) {
|
|
338
|
+
return { offers: await this.scanMachine(root) };
|
|
339
|
+
}
|
|
333
340
|
/** Publish a detected server. Re-scanned so a client can't adopt an arbitrary port. */
|
|
334
341
|
async adopt(sessionId, workspace, port) {
|
|
335
342
|
const offer = (await this.offers(sessionId, workspace)).offers.find((item) => item.port === port);
|
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); }
|
|
@@ -3288,6 +3289,9 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
|
|
|
3288
3289
|
.readiness .card-title { margin: 0 0 var(--space-1); }
|
|
3289
3290
|
.readiness .card-sub { margin: 0; }
|
|
3290
3291
|
.get-started-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-3); margin-top: var(--space-3); }
|
|
3292
|
+
.get-started-scan { display: flex; align-items: center; gap: var(--space-2); }
|
|
3293
|
+
.get-started-app { margin: var(--space-1) 0 0; overflow-wrap: anywhere; }
|
|
3294
|
+
.get-started-more { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-1) var(--space-2); margin-top: var(--space-2); }
|
|
3291
3295
|
.readiness-checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
3292
3296
|
/* Summarize completed checks so the next setup action stays visible. */
|
|
3293
3297
|
.readiness-check.state-passed { display: none; }
|
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) {
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
/** `machineRoot`: where servers are looked for before a session exists (the owner's home). */
|
|
5
|
+
export function createAppCommands(service, workspaceFor, published = () => { }, machineRoot = os.homedir) {
|
|
2
6
|
const workspaceOf = (sessionId) => {
|
|
3
7
|
const workspace = workspaceFor(sessionId);
|
|
4
8
|
if (!workspace)
|
|
@@ -13,7 +17,8 @@ export function createAppCommands(service, workspaceFor, published = () => { })
|
|
|
13
17
|
published(app);
|
|
14
18
|
return { app };
|
|
15
19
|
},
|
|
16
|
-
|
|
20
|
+
// Without a session: servers anywhere in the owner's home, for a first session to open.
|
|
21
|
+
"apps.offers": (msg) => typeof msg.sessionId === "string" ? service.offers(msg.sessionId, workspaceOf(msg.sessionId)) : service.machineOffers(machineRoot()),
|
|
17
22
|
"apps.adopt": async (msg) => {
|
|
18
23
|
const sessionId = String(msg.sessionId);
|
|
19
24
|
const app = await service.adopt(sessionId, workspaceOf(sessionId), Number(msg.port));
|
|
@@ -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,9 +11,11 @@ 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
|
-
"apps.offers": Type.Object({ ...request, ...
|
|
18
|
+
"apps.offers": Type.Object({ ...request, ...optionalSession }),
|
|
17
19
|
"apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
|
|
18
20
|
"apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
|
|
19
21
|
"apps.shot": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()), widths: Type.Optional(Type.Array(Type.Integer(), { maxItems: 4 })), themes: Type.Optional(Type.Array(Type.Union([Type.Literal("light"), Type.Literal("dark")]), { maxItems: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
|
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";
|