ledge-server 0.0.1 → 0.0.3

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.
Files changed (3) hide show
  1. package/README.md +87 -35
  2. package/lib/serve.js +382 -100
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,89 +1,141 @@
1
1
  # ledge-server
2
2
 
3
- The server half of [Ledge](https://github.com/ledgesh/ledge), the macOS notebook for developers and DevOps.
3
+ The server half of [Ledge](https://ledge.sh), the Markdown notebook that runs
4
+ code. Install it on a machine whose notes and shells you want to reach from
5
+ the Ledge app on a Mac or an iPhone. The machine keeps the notes, runs the
6
+ commands, and holds the vault. The app is the window onto it.
4
7
 
5
- Install this on a machine whose notes and shells you want to reach from the Ledge app on your Mac or iPhone. The machine keeps the notes, runs the commands, and holds the vault. Your client keeps nothing but the window.
6
-
7
- The package installs one command, `ledge`. Its server verbs are what the apps run over ssh, and its other verbs are your notes from that machine's own shell.
8
+ The package installs one command, `ledge`. Its server verbs are what the apps
9
+ run over ssh, and its other verbs read the notes from that machine's own
10
+ shell.
8
11
 
9
12
  ## Install
10
13
 
11
- The server runs on [Bun](https://bun.sh), and where Bun goes decides where the server goes: Bun puts global commands beside itself. Installing Bun into `/usr/local` puts both names in `/usr/local/bin`, which is where the short PATH of an ssh command looks.
14
+ Signed in as the account Ledge should use:
12
15
 
13
16
  ```sh
14
- curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
17
+ curl -fsSL https://ledge.sh/server.sh | sh
15
18
  ```
16
19
 
17
- Then the server itself, into the same place:
20
+ This puts the package and a private copy of Bun under `~/.ledge/.server`. It
21
+ needs no `sudo`, opens no port, and starts no service: Ledge starts the server
22
+ over ssh when a device connects, and it exits on its own a minute after the
23
+ last device leaves.
24
+
25
+ Then print a pairing code:
18
26
 
19
27
  ```sh
20
- sudo BUN_INSTALL=/usr/local bun add -g ledge-server
28
+ ledge pair
21
29
  ```
22
30
 
23
- The variable on the second command is not optional. Without it the package installs into the home directory of whoever ran it, where an incoming ssh will not find it.
24
-
25
- macOS and Linux, on arm64 or x64. On Linux the floor is glibc 2.29, which means Debian 11, Ubuntu 20.04, RHEL 9, or anything newer. Alpine and other musl systems are not supported.
31
+ It lists the machine's addresses, with what each one reaches, and asks which
32
+ one your other devices should use. Scan the code with Ledge on a phone, or paste the
33
+ link under it into the Mac app's Add Server form. The code carries the
34
+ address, the account, and the host key.
26
35
 
27
- ## Check that an incoming ssh can find it
36
+ If the machine already has Bun, the same package installs with it. On Linux,
37
+ Bun has to live in `/usr/local` so that an incoming ssh finds both commands:
28
38
 
29
- This is the one step worth not skipping, because Ledge reports what it catches as a server that is not installed: a remote shell that cannot find a command says only that. Ledge starts the server by running `ledge serve` over ssh, and a command run that way gets a short PATH and no shell profile. Both `ledge` and the `bun` its shebang names have to be on that PATH.
39
+ ```sh
40
+ curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
41
+ sudo BUN_INSTALL=/usr/local bun add -g ledge-server
42
+ ```
30
43
 
31
- From your Mac:
44
+ On a Mac, install Bun for the account and add its directory to `~/.zshenv`,
45
+ which zsh reads for commands run over ssh:
32
46
 
33
47
  ```sh
34
- ssh you@machine 'command -v ledge; command -v bun'
48
+ curl -fsSL https://bun.sh/install | bash
49
+ echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.zshenv
50
+ source ~/.zshenv
51
+ bun add -g ledge-server
35
52
  ```
36
53
 
37
- Two paths printed means you are done. Nothing printed means the install landed somewhere an incoming ssh does not look, which is what a machine with a per-user Bun on it already gives you. Bun puts global commands beside itself, so `bun pm bin -g` on that machine says where they went, and linking both into a system directory fixes it without reinstalling:
54
+ The server runs on macOS and Linux, arm64 or x64. Linux needs glibc 2.29 or
55
+ newer: Debian 11, Ubuntu 20.04, RHEL 9, or anything later. Alpine and other
56
+ musl systems are not supported.
57
+
58
+ ## Connect
59
+
60
+ In the Ledge app, run "Notes On…" from the command palette, choose Add, and
61
+ give the machine's ssh destination, or paste a pairing code. The app then
62
+ opens the connection with your own ssh credentials by running:
38
63
 
39
64
  ```sh
40
- sudo ln -s "$(bun pm bin -g)/ledge" /usr/local/bin/ledge
41
- sudo ln -s "$(command -v bun)" /usr/local/bin/bun
65
+ ssh you@machine 'PATH=$HOME/.ledge/.server/bin:$PATH ledge serve'
42
66
  ```
43
67
 
44
- ## Use
68
+ The machine's own sshd is the only thing listening, and the key or password
69
+ you already use is the credential. Ledge speaks its protocol over ssh's stdin
70
+ and stdout.
45
71
 
46
- You do not normally run this yourself. Add the machine in the Ledge app under Servers, and the app opens a connection with your own ssh credentials:
72
+ To check that an incoming ssh can find the command, run the same thing from
73
+ your Mac with `command -v ledge` in place of `ledge serve`. One path printed
74
+ means the machine is ready. Nothing printed means the install landed
75
+ somewhere ssh does not look, which happens with a `bun add -g` into a
76
+ per-user Bun. Link both commands into a system directory to fix it:
47
77
 
48
78
  ```sh
49
- ssh you@machine ledge serve
79
+ sudo ln -s "$(bun pm bin -g)/ledge" /usr/local/bin/ledge
80
+ sudo ln -s "$(command -v bun)" /usr/local/bin/bun
50
81
  ```
51
82
 
52
- No port is opened and no daemon is installed. Ledge speaks its protocol over ssh's stdin and stdout, so the machine's own sshd is the only thing listening, and the key you already use is the credential.
53
-
54
- The verbs, if you want them:
83
+ ## The verbs
55
84
 
56
85
  | Verb | What it does |
57
86
  | --- | --- |
58
- | `ledge serve` | Move the protocol between stdin, stdout, and this machine's daemon. Starts the daemon if nothing answers. |
87
+ | `ledge serve` | The protocol on stdin and stdout, attached to this machine's daemon. Starts the daemon if nothing answers. What a client runs. |
59
88
  | `ledge daemon` | Be this machine's server. Holds the notes, the shells, and the watchers, and runs until stopped. |
89
+ | `ledge pair` | Print a pairing code for this machine, after asking which of its addresses your other devices should use. `--user`, `--host`, and `--port` override what it describes. |
60
90
  | `ledge backup` | Back this machine up to an S3-compatible bucket: `setup`, `now`, `status`, `snapshots`, `restore`, `paths`, `restic`. |
61
- | `ledge pair` | Print a pairing code a phone scans to add this server. |
62
91
  | `ledge mcp` | The Ledge MCP server on stdin and stdout, for an agent running on this machine. |
63
- | `ledge ls`, `ledge cat`, ... | Notes from this machine's own shell. `ledge help` lists them. |
92
+ | `ledge ls`, `ledge cat`, `ledge search`, ... | Notes from this machine's own shell. `ledge help` lists them all. |
64
93
 
65
- The daemon outlives the connections to it, which is what lets a build keep running after your laptop closes and lets a reconnecting client pick the output back up.
94
+ The daemon outlives the connections to it. A build keeps running after your
95
+ laptop closes, and a reconnecting client picks the output back up.
66
96
 
67
97
  ## Restrict the key
68
98
 
69
- Optional, and worth doing on a server you care about. In that machine's `~/.ssh/authorized_keys`:
99
+ A key in that machine's `~/.ssh/authorized_keys` can be limited to Ledge's
100
+ protocol and nothing else:
70
101
 
71
102
  ```
72
- restrict,command="/usr/local/bin/ledge serve" ssh-ed25519 AAAA... ledge@laptop
103
+ restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ssh-ed25519 AAAA... ledge@laptop
73
104
  ```
74
105
 
75
- That key can then speak Ledge's protocol and nothing else. No shell, no port forwarding, no file transfer.
106
+ That key cannot forward a port, run `scp`, or open a shell. It can still run
107
+ every block in every note, because running them is what the protocol does.
108
+ A phone's key arrives with this prefix already on its line.
76
109
 
77
- An absolute path here settles half of the PATH question above, since sshd runs this string instead of whatever the client asked for. The other half stays: the file it names begins `#!/usr/bin/env bun`, so `bun` still has to be findable.
110
+ Keep your usual key on the machine as well if you also ssh to it from a
111
+ terminal. The restricted line is for Ledge alone.
78
112
 
79
113
  ## Where the data lives
80
114
 
81
- Most of what the server owns sits under one directory: the notes, the workspace registry, the vault, the layout, and the logs. It is `~/.ledge` by default, and `LEDGE_NOTES_ROOT` moves it. Two things live outside it: the profiles, at `~/.config/ledge/profiles`, so credentials stay out of the folder people sync; and any workspace folder attached from elsewhere on the machine.
115
+ The notes, the workspace registry, the vault, the layout, and the logs sit
116
+ under `~/.ledge`. `LEDGE_NOTES_ROOT` moves that directory. Two things live
117
+ outside it: profiles, at `~/.config/ledge/profiles`, so secrets stay out of
118
+ the folder people sync; and any workspace folder attached from elsewhere on
119
+ the machine.
82
120
 
83
121
  ## Back it up
84
122
 
85
- `ledge backup setup` asks for an S3-compatible bucket and its key, then keeps an encrypted copy of all of the above there: every hour while the server is up, and once more before it exits. `ledge backup status` says how it is going, and `ledge backup restore` brings files back. See [Back Up Your Notes to S3](https://github.com/ledgesh/ledge/blob/main/docs/user/21-tutorial-back-up-your-notes-to-s3.md) in the manual.
123
+ `ledge backup setup` asks for an S3-compatible bucket and its key, then keeps
124
+ an encrypted copy of all of the above there: every hour while the server is
125
+ up, and once more before it exits. `ledge backup status` reports on it, and
126
+ `ledge backup restore` brings files back. See
127
+ [Back Up Your Notes to S3](https://ledge.sh/docs/tutorial-back-up-your-notes-to-s3).
128
+
129
+ ## Documentation
130
+
131
+ - [Keep Notes on a Remote Server](https://ledge.sh/docs/keep-notes-on-a-remote-server):
132
+ the reference for connections, sharing a server, and what a dropped
133
+ connection does.
134
+ - [Set Up a Ledge Server](https://ledge.sh/docs/tutorial-set-up-a-ledge-server):
135
+ a fresh Linux VPS, from a new account to a hardened sshd.
136
+ - [Ledge on Your Phone](https://ledge.sh/docs/ledge-on-your-phone): pairing
137
+ and what a phone does with a server.
86
138
 
87
139
  ## License
88
140
 
89
- Apache-2.0
141
+ [Apache-2.0](https://github.com/ledgesh/ledge/blob/main/LICENSE)
package/lib/serve.js CHANGED
@@ -6047,6 +6047,8 @@ A server with no pinned key has no code, since the code names the key. Edit the
6047
6047
 
6048
6048
  Every row in the picker carries three controls: a QR code icon to show its pairing code, a pencil to change it and a bin to remove it. Press \u232B on a focused row to remove it without reaching for either.
6049
6049
 
6050
+ Removing asks first, and the dialog says what goes with the record: the address, the host key you pinned, and the password if that connection uses one. The notes on that machine stay where they are. Adding the server back means comparing its fingerprint again.
6051
+
6050
6052
  Editing opens the same form. A rename, or a change of account on the same machine, saves in one step, because neither changes which machine the pinned key belongs to.
6051
6053
 
6052
6054
  Changing the address to a different machine does not. The button reads "Continue" instead of "Save", Ledge asks that machine for its host key, and you compare the fingerprint again before anything is stored. A key pinned for one machine says nothing about another, and carrying it across would refuse every later connection with a warning about a changed host key.
@@ -6234,7 +6236,7 @@ Run them as the account the server runs as, on the machine the server runs on.
6234
6236
 
6235
6237
  | Verb | What it does |
6236
6238
  | --- | --- |
6237
- | \`ledge backup setup\` | Asks for the bucket and its key, fetches restic if none is installed, writes the credentials to the \`backup\` profile, creates the repository, and takes the first backup. \`--existing\` joins a repository that already has backups in it. |
6239
+ | \`ledge backup setup\` | Asks for the bucket and its key, fetches restic if none is installed, writes the credentials to the \`backup\` profile, creates the repository, and takes the first backup. \`--existing\` joins a repository that already has backups in it, and takes no first backup, since a machine that has just joined has nothing on it to back up. |
6238
6240
  | \`ledge backup now\` | Takes a backup and thins old snapshots. |
6239
6241
  | \`ledge backup status\` | When the last backup ran and how it went, when the next is due, and any attached folder the last run could not find. |
6240
6242
  | \`ledge backup snapshots\` | The snapshots in the repository, newest first. |
@@ -6413,13 +6415,13 @@ A pairing code names a server and its host keys, so the phone can add it without
6413
6415
  ledge pair
6414
6416
  \`\`\`
6415
6417
 
6416
- It prints the code as a QR code, then the account, host, port, and host keys it holds, then the same code as a link. It reads the address from your ssh session, or uses the machine's name when there is no session. When the phone cannot reach the server by that name, give the address yourself with \`--host\`. \`ledge pair --help\` lists the other flags.
6418
+ On a terminal, it first lists every address the machine has, with a note on which devices reach each one: its tailnet name and address, the address your ssh session reached, its public address when it runs in a cloud, its other network addresses, and its name. Type a number to pick one, or an address of your own as \`host\` or \`host:port\`, or press Return for the first. It then prints the code as a QR code, then the account, host, port, and host keys it holds, then the same code as a link. Without a terminal, it takes the first address and lists the rest under the code, and \`--host\` names one on the next run. \`ledge pair --help\` lists the other flags.
6417
6419
 
6418
6420
  A Mac that already has the server in its list can show the same code without a terminal: the QR code icon on the server's row in Notes On\u2026 ("Show a pairing code for a server" on [[Keep Notes on a Remote Server]]). The same link pastes into the Mac app's Add Server form ("Add a server from a pairing code" on that page).
6419
6421
 
6420
- The code names one address, and the phone dials exactly that. \`ledge pair\` over ssh names the address your session reached, so a machine you reached over your home network or tailnet gets that address, which works from a phone on the same network or tailnet. A machine behind a router's port forward needs \`--host\` and \`--port\` for the outside address, since the session only knows the inside one. A Mac's code names the address the Mac dials, with the same reach.
6422
+ The code names one address, and the reader connects to exactly that, so pick the one your other devices reach from where they will be. A tailnet name works from anywhere a device is on the tailnet. A home network address works from a device on that network. A cloud machine's public address works from anywhere, when its sshd is reachable from outside. A machine behind a router's port forward has an outside address no source knows: type it at the menu with its port, or give them with \`--host\` and \`--port\`. A Mac's code names the address the Mac dials, with the same reach.
6421
6423
 
6422
- On the phone, tap Scan a pairing code on the first screen and point the camera at the QR code. Scan it from Ledge rather than the Camera app, which opens the code in Safari. Ledge shows what the code names and connects only when you tap Connect. Choose how to sign in first, the same way as in "Pair by address": with a key, whose line still has to be in the server's \`authorized_keys\`, or with a password. Ledge signs in only if the server offers one of the host keys in the code, so there is no fingerprint to check by eye.
6424
+ On the phone, tap Scan a pairing code on the first screen, or in Add Server\u2026 inside the app ("More than one server" below), and point the camera at the QR code. Scan it from Ledge rather than the Camera app, which opens the code in Safari. Ledge shows what the code names and connects only when you tap Connect. Choose how to sign in first, the same way as in "Pair by address": with a key, whose line still has to be in the server's \`authorized_keys\`, or with a password. Ledge signs in only if the server offers one of the host keys in the code, so there is no fingerprint to check by eye.
6423
6425
 
6424
6426
  The code holds no password and no key. Someone who photographs it learns where the server is and which account to try, and nothing that signs them in.
6425
6427
 
@@ -6497,6 +6499,8 @@ Removing the last server returns the phone to the first screen. Deleting the app
6497
6499
 
6498
6500
  Inside the app the connection bar works as on a Mac: tap it to add, edit, remove, or switch servers, with the same fingerprint step ([[Keep Notes on a Remote Server]]). The form shows the phone's key line where a Mac's shows a key path, with Share Line beside Copy Line.
6499
6501
 
6502
+ Add Server\u2026 starts with Scan a pairing code, where a Mac's form has a field for the pasted link. It opens the camera, then the same "Pair with a server" screen as the first launch, and the app reopens on the new server once you tap Connect there. Cancel returns you to the form, where you can type the address instead. Editing a server has no scan: a code never replaces a host key the phone already has.
6503
+
6500
6504
  A phone and a Mac can be on one server at once. Each keeps its own tabs, and a note's terminal has one owner between them.
6501
6505
 
6502
6506
  ## What a phone does
@@ -7172,6 +7176,18 @@ Nothing else needs installing: no timer, no unit file, no line in a crontab. One
7172
7176
 
7173
7177
  \`ledge backup now\` takes a backup at any time and is safe to run beside the schedule.
7174
7178
 
7179
+ Old snapshots are thinned after each backup, and what survives is fixed:
7180
+
7181
+ | Kept | For |
7182
+ | --- | --- |
7183
+ | The ten newest snapshots | however close together they were taken |
7184
+ | One an hour | a day |
7185
+ | One a day | a month |
7186
+ | One a week | a quarter |
7187
+ | One a month | two years |
7188
+
7189
+ Only the snapshots Ledge took are thinned, so a bucket shared with another tool's backups keeps those whatever this policy says.
7190
+
7175
7191
  ## 4. Check on it
7176
7192
 
7177
7193
  \`\`\`sh norun
@@ -7207,13 +7223,13 @@ On a fresh machine with Ledge installed, the app on a Mac or the server on a VPS
7207
7223
  ledge backup setup --existing
7208
7224
  \`\`\`
7209
7225
 
7210
- It asks the same questions plus the password, opens the repository instead of creating one, and writes the profile. Then, with the app quit or the daemon stopped:
7226
+ It asks the same questions plus the password, opens the repository instead of creating one, writes the profile, and prints the newest snapshot in it. It takes no backup, since there is nothing on this machine to back up yet. Then, with the app quit or the daemon stopped:
7211
7227
 
7212
7228
  \`\`\`sh norun
7213
7229
  ledge backup restore --in-place
7214
7230
  \`\`\`
7215
7231
 
7216
- The paths inside the backup are absolute, so this puts the app home, the attached folders, and the profiles back where they were. Then open Ledge, or connect to the server. Your workspaces, images, trash, profiles, and vault are all there, and locked notes open with the passphrase they had ([[Note Locking]]). Backups continue on the new machine with the same repository.
7232
+ The paths inside the backup are absolute, so this puts the app home, the attached folders, and the profiles back where they were. \`--snapshot ID\` restores an older one than the newest. Then open Ledge, or connect to the server. Your workspaces, images, trash, profiles, and vault are all there, and locked notes open with the passphrase they had ([[Note Locking]]). Backups continue on the new machine with the same repository.
7217
7233
 
7218
7234
  ## Run restic yourself
7219
7235
 
@@ -11447,7 +11463,7 @@ function createOpLog(opts) {
11447
11463
  }
11448
11464
 
11449
11465
  // src/shared/version.ts
11450
- var BUILD_VERSION = "0.0.1";
11466
+ var BUILD_VERSION = "0.0.3";
11451
11467
 
11452
11468
  // src/bun/daemon.ts
11453
11469
  var SOCKET_PATH = join16(APP_HOME, ".server.sock");
@@ -11713,6 +11729,65 @@ import { randomBytes as randomBytes2 } from "crypto";
11713
11729
  import { homedir as homedir7 } from "os";
11714
11730
  import { join as join19 } from "path";
11715
11731
 
11732
+ // src/bun/ask.ts
11733
+ var reader = null;
11734
+ var pending = "";
11735
+ async function readLine() {
11736
+ reader ??= Bun.stdin.stream().getReader();
11737
+ const decoder2 = new TextDecoder;
11738
+ for (;; ) {
11739
+ const nl = pending.indexOf(`
11740
+ `);
11741
+ if (nl >= 0) {
11742
+ const line = pending.slice(0, nl);
11743
+ pending = pending.slice(nl + 1);
11744
+ return line.replace(/\r$/, "");
11745
+ }
11746
+ const { value, done } = await reader.read();
11747
+ if (done) {
11748
+ const line = pending;
11749
+ pending = "";
11750
+ return line;
11751
+ }
11752
+ pending += decoder2.decode(value, { stream: true });
11753
+ }
11754
+ }
11755
+ async function readHidden() {
11756
+ const stdin = process.stdin;
11757
+ stdin.setRawMode?.(true);
11758
+ reader ??= Bun.stdin.stream().getReader();
11759
+ let line = "";
11760
+ try {
11761
+ for (;; ) {
11762
+ const { value, done } = await reader.read();
11763
+ if (done)
11764
+ return line;
11765
+ for (const byte of value) {
11766
+ if (byte === 3)
11767
+ process.exit(130);
11768
+ if (byte === 13 || byte === 10)
11769
+ return line;
11770
+ if (byte === 127 || byte === 8)
11771
+ line = line.slice(0, -1);
11772
+ else if (byte >= 32)
11773
+ line += String.fromCharCode(byte);
11774
+ }
11775
+ }
11776
+ } finally {
11777
+ stdin.setRawMode?.(false);
11778
+ }
11779
+ }
11780
+ async function ask(question, o = {}) {
11781
+ process.stderr.write(`${question}: `);
11782
+ if (o.hidden && process.stdin.isTTY) {
11783
+ const line = await readHidden();
11784
+ process.stderr.write(`
11785
+ `);
11786
+ return line.trim();
11787
+ }
11788
+ return (await readLine()).trim();
11789
+ }
11790
+
11716
11791
  // src/bun/backup.ts
11717
11792
  import { join as join17 } from "path";
11718
11793
  function backupSet(input) {
@@ -11797,7 +11872,7 @@ function versionAtLeast(version, min) {
11797
11872
  var BACKUP_EVERY_MS = 60 * 60 * 1000;
11798
11873
  var PRUNE_EVERY_MS = 24 * 60 * 60 * 1000;
11799
11874
  var IDLE_EXIT_MIN_GAP_MS = 10 * 60 * 1000;
11800
- var KEEP = { hourly: 24, daily: 30, weekly: 12, monthly: 24 };
11875
+ var KEEP = { last: 10, hourly: 24, daily: 30, weekly: 12, monthly: 24 };
11801
11876
  var SNAPSHOT_TAG = "ledge";
11802
11877
  var EMPTY_STATE = {
11803
11878
  version: 1,
@@ -11848,6 +11923,9 @@ function isOverdue(state, now, every = BACKUP_EVERY_MS) {
11848
11923
  function pruneDue(state, now, every = PRUNE_EVERY_MS) {
11849
11924
  return !state.lastPrune || Date.parse(state.lastPrune) + every <= now.getTime();
11850
11925
  }
11926
+ function forgetDue(state) {
11927
+ return state.lastSnapshot !== null;
11928
+ }
11851
11929
  function idleExitWorthIt(state, now, gap = IDLE_EXIT_MIN_GAP_MS) {
11852
11930
  return !state.lastOk || Date.parse(state.lastOk) + gap <= now.getTime();
11853
11931
  }
@@ -11869,6 +11947,8 @@ function forgetArgs(prune) {
11869
11947
  "forget",
11870
11948
  "--tag",
11871
11949
  SNAPSHOT_TAG,
11950
+ "--keep-last",
11951
+ String(KEEP.last),
11872
11952
  "--keep-hourly",
11873
11953
  String(KEEP.hourly),
11874
11954
  "--keep-daily",
@@ -12184,7 +12264,8 @@ async function runBackup(o = { reason: "now" }) {
12184
12264
  const out = parseBackupOutput(backed.stdout);
12185
12265
  for (const e of out.errors)
12186
12266
  log(`[backup] restic: ${e}`);
12187
- const previous = readState().lastSnapshot;
12267
+ const state = readState();
12268
+ const previous = state.lastSnapshot;
12188
12269
  if (out.snapshot && previous && previous !== out.snapshot) {
12189
12270
  const diff = await runRestic(restic.path, diffArgs(previous, out.snapshot), env);
12190
12271
  if (diff.code === 0 && parseDiffChanges(diff.stdout) === 0) {
@@ -12195,9 +12276,12 @@ async function runBackup(o = { reason: "now" }) {
12195
12276
  out.snapshot = null;
12196
12277
  }
12197
12278
  }
12198
- const prune = pruneDue(readState(), at);
12199
- const forgot = await runRestic(restic.path, forgetArgs(prune), env);
12200
- if (forgot.code !== 0) {
12279
+ const thin = forgetDue(state);
12280
+ const prune = thin && pruneDue(state, at);
12281
+ if (!thin)
12282
+ log("[backup] the first backup from this machine: the snapshots already in the repository are left as they are");
12283
+ const forgot = thin ? await runRestic(restic.path, forgetArgs(prune), env) : null;
12284
+ if (forgot && forgot.code !== 0) {
12201
12285
  const error = `snapshot ${out.snapshot?.slice(0, 8) ?? "kept"}, but restic forget failed: ${resticSaid(forgot)}`;
12202
12286
  log(`[backup] ${error}`);
12203
12287
  writeState(recordRun(readState(), { at, ok: false, error, snapshot: out.snapshot, skipped }));
@@ -12420,33 +12504,54 @@ async function setup(args) {
12420
12504
  vars["RESTIC_PASSWORD"] = generated;
12421
12505
  }
12422
12506
  }
12423
- await writeProfile(BACKUP_PROFILE, backupProfileText(vars));
12424
- const read = readConfig();
12425
- if (!("config" in read)) {
12426
- say(`${PROFILE_PATH} is missing ${read.missing.join(", ")}`);
12507
+ const proposed = parseBackupConfig(backupProfileText(vars));
12508
+ if (!("config" in proposed)) {
12509
+ say(`a backup needs ${proposed.missing.join(", ")}`);
12427
12510
  return 1;
12428
12511
  }
12429
- const env = resticEnv(read.config);
12512
+ const env = resticEnv(proposed.config);
12513
+ const unchanged = already ? `
12514
+ Nothing here changed: this machine still backs up to ${already.repository}.` : `
12515
+ Nothing was written.`;
12430
12516
  if (existing) {
12431
- say(`Opening ${read.config.repository}...`);
12517
+ say(`Opening ${proposed.config.repository}...`);
12432
12518
  const r = await runRestic(restic.path, ["cat", "config"], env);
12433
12519
  if (r.code !== 0) {
12434
- say(`Could not open the repository: ${resticSaid(r)}
12435
- The profile is written at ${PROFILE_PATH}; fix it and run setup again with --replace.`);
12520
+ say(`Could not open the repository: ${resticSaid(r)}${unchanged}`);
12436
12521
  return 1;
12437
12522
  }
12438
12523
  } else {
12439
- say(`Creating the repository at ${read.config.repository}...`);
12524
+ say(`Creating the repository at ${proposed.config.repository}...`);
12440
12525
  const r = await runRestic(restic.path, ["init"], env);
12441
12526
  if (r.code !== 0) {
12442
12527
  const said = resticSaid(r);
12443
12528
  const hint = /already (exists|initialized)/i.test(said) ? `
12444
- That repository already has backups in it. Run setup again with --existing and its password.` : `
12445
- The profile is written at ${PROFILE_PATH}; fix it and run setup again with --replace.`;
12446
- say(`Could not create the repository: ${said}${hint}`);
12529
+ That repository already has backups in it. Run setup again with --existing and its password.` : "";
12530
+ say(`Could not create the repository: ${said}${hint}${unchanged}`);
12447
12531
  return 1;
12448
12532
  }
12449
12533
  }
12534
+ await writeProfile(BACKUP_PROFILE, backupProfileText(vars));
12535
+ if (existing) {
12536
+ const listed = await listSnapshots();
12537
+ say("");
12538
+ if ("error" in listed)
12539
+ say(`The repository opened, though its snapshots could not be listed: ${listed.error}`);
12540
+ else if (listed.snapshots.length === 0)
12541
+ say("The repository opened. It holds no snapshots yet.");
12542
+ else {
12543
+ const newest = listed.snapshots[0];
12544
+ say(`The repository opened. Its newest snapshot is ${newest.short_id}, from ${snapshotTime(newest.time)}; \`ledge backup snapshots\` lists the rest.`);
12545
+ }
12546
+ say("");
12547
+ say("Nothing has been backed up from this machine yet. To restore this machine, with the app quit or the daemon stopped:");
12548
+ say("");
12549
+ say(" ledge backup restore --in-place");
12550
+ say("");
12551
+ say("Backups run every hour while this machine's Ledge server is up, and once more before it exits.");
12552
+ say(`The repository and its credentials are in ${PROFILE_PATH}, the "${BACKUP_PROFILE}" profile.`);
12553
+ return 0;
12554
+ }
12450
12555
  say("Taking the first backup...");
12451
12556
  await loadWorkspaces();
12452
12557
  const result = await runBackup({ log: say, reason: "setup" });
@@ -12503,9 +12608,12 @@ async function snapshots() {
12503
12608
  return 1;
12504
12609
  }
12505
12610
  for (const s2 of r.snapshots)
12506
- out(`${s2.short_id} ${s2.time.replace(/\.\d+/, "").replace("T", " ")} ${s2.hostname} ${s2.paths.length} path${s2.paths.length === 1 ? "" : "s"}`);
12611
+ out(`${s2.short_id} ${snapshotTime(s2.time)} ${s2.hostname} ${s2.paths.length} path${s2.paths.length === 1 ? "" : "s"}`);
12507
12612
  return 0;
12508
12613
  }
12614
+ function snapshotTime(time) {
12615
+ return time.replace(/\.\d+/, "").replace("T", " ");
12616
+ }
12509
12617
  async function restore(args) {
12510
12618
  const config = configured();
12511
12619
  if (!config) {
@@ -12578,63 +12686,6 @@ function valueOf(args, flag) {
12578
12686
  const at = args.indexOf(flag);
12579
12687
  return at >= 0 ? args[at + 1] ?? null : null;
12580
12688
  }
12581
- var reader = null;
12582
- var pending = "";
12583
- async function readLine() {
12584
- reader ??= Bun.stdin.stream().getReader();
12585
- const decoder2 = new TextDecoder;
12586
- for (;; ) {
12587
- const nl = pending.indexOf(`
12588
- `);
12589
- if (nl >= 0) {
12590
- const line = pending.slice(0, nl);
12591
- pending = pending.slice(nl + 1);
12592
- return line.replace(/\r$/, "");
12593
- }
12594
- const { value, done } = await reader.read();
12595
- if (done) {
12596
- const line = pending;
12597
- pending = "";
12598
- return line;
12599
- }
12600
- pending += decoder2.decode(value, { stream: true });
12601
- }
12602
- }
12603
- async function readHidden() {
12604
- const stdin = process.stdin;
12605
- stdin.setRawMode?.(true);
12606
- reader ??= Bun.stdin.stream().getReader();
12607
- let line = "";
12608
- try {
12609
- for (;; ) {
12610
- const { value, done } = await reader.read();
12611
- if (done)
12612
- return line;
12613
- for (const byte of value) {
12614
- if (byte === 3)
12615
- process.exit(130);
12616
- if (byte === 13 || byte === 10)
12617
- return line;
12618
- if (byte === 127 || byte === 8)
12619
- line = line.slice(0, -1);
12620
- else if (byte >= 32)
12621
- line += String.fromCharCode(byte);
12622
- }
12623
- }
12624
- } finally {
12625
- stdin.setRawMode?.(false);
12626
- }
12627
- }
12628
- async function ask(question, o = {}) {
12629
- process.stderr.write(`${question}: `);
12630
- if (o.hidden && process.stdin.isTTY) {
12631
- const line = await readHidden();
12632
- process.stderr.write(`
12633
- `);
12634
- return line.trim();
12635
- }
12636
- return (await readLine()).trim();
12637
- }
12638
12689
 
12639
12690
  // node_modules/uqr/dist/index.mjs
12640
12691
  var QrCodeDataType = /* @__PURE__ */ ((QrCodeDataType2) => {
@@ -13315,20 +13366,179 @@ function sshServerAddress(sshConnection) {
13315
13366
  return null;
13316
13367
  return { host: ip, port: port === DEFAULT_PORT ? PORT_UNSET : port };
13317
13368
  }
13318
- function pairAddress(args, sshConnection, hostname) {
13369
+ function addressKind(ip) {
13370
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
13371
+ if (!match)
13372
+ return null;
13373
+ const [a, b, c, d] = match.slice(1).map(Number);
13374
+ if (a > 255 || b > 255 || c > 255 || d > 255)
13375
+ return null;
13376
+ if (a === 127)
13377
+ return "loopback";
13378
+ if (a === 169 && b === 254)
13379
+ return "linkLocal";
13380
+ if (a === 100 && b >= 64 && b <= 127)
13381
+ return "tailnet";
13382
+ if (a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168)
13383
+ return "private";
13384
+ return "public";
13385
+ }
13386
+ function sshClientAddress(sshConnection) {
13387
+ const parts = (sshConnection ?? "").trim().split(/\s+/);
13388
+ if (parts.length !== 4)
13389
+ return null;
13390
+ const ip = parts[0].replace(/^::ffff:/i, "");
13391
+ return addressKind(ip) === null ? null : ip;
13392
+ }
13393
+ var VIRTUAL_INTERFACE = /^(docker|br-|veth|virbr|lxc|lxd|cni|flannel|podman|vmnet|vboxnet)/;
13394
+ var NOTES = {
13395
+ tailnetName: "this machine's tailnet name, reachable from anywhere on the tailnet",
13396
+ tailnetAddress: "this machine's tailnet address, reachable from anywhere on the tailnet",
13397
+ ssh: "the address this ssh session reached",
13398
+ sshInside: "the address this ssh session reached, inside a NAT the session came through; a device outside needs the outside address",
13399
+ cloud: "this machine's public address, from the cloud's metadata service",
13400
+ public: (name) => `the public address on ${name}`,
13401
+ private: (name) => `the local network address on ${name}`,
13402
+ name: "this machine's name"
13403
+ };
13404
+ function addressCandidates(inputs) {
13405
+ const out2 = [];
13406
+ const seen = new Set;
13407
+ const add = (host, source, note) => {
13408
+ const key2 = host.toLowerCase();
13409
+ if (host === "" || seen.has(key2))
13410
+ return;
13411
+ seen.add(key2);
13412
+ out2.push({ host, source, note });
13413
+ };
13414
+ if (inputs.tailnet?.name)
13415
+ add(inputs.tailnet.name, "tailnet", NOTES.tailnetName);
13416
+ for (const ip of inputs.tailnet?.addresses ?? [])
13417
+ if (addressKind(ip) === "tailnet")
13418
+ add(ip, "tailnet", NOTES.tailnetAddress);
13419
+ for (const i of inputs.interfaces)
13420
+ if (addressKind(i.address) === "tailnet")
13421
+ add(i.address, "tailnet", NOTES.tailnetAddress);
13422
+ const session = sshServerAddress(inputs.sshConnection);
13423
+ const client = sshClientAddress(inputs.sshConnection);
13424
+ const crossedNat = session !== null && addressKind(session.host) === "private" && client !== null && addressKind(client) === "public";
13425
+ const cloud = inputs.cloudAddress && addressKind(inputs.cloudAddress) === "public" ? inputs.cloudAddress : null;
13426
+ if (session && !crossedNat)
13427
+ add(session.host, "ssh", NOTES.ssh);
13428
+ if (cloud)
13429
+ add(cloud, "cloud", NOTES.cloud);
13430
+ if (session && crossedNat)
13431
+ add(session.host, "ssh", NOTES.sshInside);
13432
+ const usable = inputs.interfaces.filter((i) => !VIRTUAL_INTERFACE.test(i.name));
13433
+ for (const i of usable)
13434
+ if (addressKind(i.address) === "public")
13435
+ add(i.address, "interface", NOTES.public(i.name));
13436
+ for (const i of usable)
13437
+ if (addressKind(i.address) === "private")
13438
+ add(i.address, "interface", NOTES.private(i.name));
13439
+ add(inputs.hostname, "name", NOTES.name);
13440
+ return out2;
13441
+ }
13442
+ function tailscaleSelf(json) {
13443
+ let status2;
13444
+ try {
13445
+ status2 = JSON.parse(json);
13446
+ } catch {
13447
+ return null;
13448
+ }
13449
+ if (typeof status2 !== "object" || status2 === null)
13450
+ return null;
13451
+ const { BackendState, Self } = status2;
13452
+ if (BackendState !== "Running" || typeof Self !== "object" || Self === null)
13453
+ return null;
13454
+ const name = typeof Self.DNSName === "string" ? Self.DNSName.replace(/\.$/, "") : "";
13455
+ const ips = Array.isArray(Self.TailscaleIPs) ? Self.TailscaleIPs : [];
13456
+ const addresses = ips.filter((ip) => typeof ip === "string" && addressKind(ip) === "tailnet");
13457
+ return { name: name === "" ? null : name, addresses };
13458
+ }
13459
+ var METADATA_ORIGIN = "http://169.254.169.254";
13460
+ var METADATA_PATHS = [
13461
+ { cloud: "Google Cloud", path: "/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip", headers: { "Metadata-Flavor": "Google" } },
13462
+ {
13463
+ cloud: "Azure",
13464
+ path: "/metadata/instance/network/interface/0/ipv4/ipAddress/0/publicIpAddress?api-version=2021-02-01&format=text",
13465
+ headers: { Metadata: "true" }
13466
+ },
13467
+ { cloud: "DigitalOcean", path: "/metadata/v1/interfaces/public/0/ipv4/address" },
13468
+ { cloud: "Hetzner", path: "/hetzner/v1/metadata/public-ipv4" }
13469
+ ];
13470
+ var AWS_TOKEN_PATH = "/latest/api/token";
13471
+ var DMI_DIR = "/sys/class/dmi/id";
13472
+ var DMI_FIELDS = ["sys_vendor", "bios_version", "chassis_asset_tag"];
13473
+ var AZURE_ASSET_TAG = "7783-7084-3265-9085-8269-3286-77";
13474
+ function inCloud(dmi) {
13475
+ const vendor = (dmi.sys_vendor ?? "").trim();
13476
+ if (/^(Amazon|Google|DigitalOcean|Hetzner)\b/i.test(vendor))
13477
+ return true;
13478
+ if (/amazon/i.test(dmi.bios_version ?? ""))
13479
+ return true;
13480
+ return (dmi.chassis_asset_tag ?? "").trim() === AZURE_ASSET_TAG;
13481
+ }
13482
+ var TAILSCALE_PATHS = [
13483
+ "/usr/bin/tailscale",
13484
+ "/usr/local/bin/tailscale",
13485
+ "/opt/homebrew/bin/tailscale",
13486
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
13487
+ ];
13488
+ var AWS_ADDRESS_PATH = "/latest/meta-data/public-ipv4";
13489
+ function publicAddressAnswer(body) {
13490
+ const answer = body.trim();
13491
+ return addressKind(answer) === "public" ? answer : null;
13492
+ }
13493
+ function candidateMenu(candidates) {
13494
+ const width = Math.max(...candidates.map((c) => c.host.length));
13495
+ const lines = ["Which address should Ledge on your other devices use to reach this server?"];
13496
+ candidates.forEach((c, i) => lines.push(` ${String(i + 1).padStart(2)} ${c.host.padEnd(width)} ${c.note}`));
13497
+ return `${lines.join(`
13498
+ `)}
13499
+ `;
13500
+ }
13501
+ var CHOICE_PROMPT = "A number, or an address as host or host:port [1]";
13502
+ function pairAddress(args, sshConnection, candidates, answer) {
13319
13503
  const session = sshServerAddress(sshConnection);
13320
13504
  let port = session?.port ?? PORT_UNSET;
13505
+ let host;
13506
+ let source;
13507
+ let note = "";
13508
+ if (args.host !== undefined) {
13509
+ host = args.host;
13510
+ source = "flag";
13511
+ } else if (answer !== undefined && answer.trim() !== "") {
13512
+ const typed = answer.trim();
13513
+ if (/^\d+$/.test(typed)) {
13514
+ const pick = candidates[Number(typed) - 1];
13515
+ if (!pick)
13516
+ return { error: `There is no address ${typed} in the list.` };
13517
+ ({ host, source, note } = pick);
13518
+ } else {
13519
+ const colon = typed.indexOf(":");
13520
+ host = colon < 0 ? typed : typed.slice(0, colon);
13521
+ source = "typed";
13522
+ if (colon >= 0) {
13523
+ const parsed = parsePort(typed.slice(colon + 1));
13524
+ if (parsed === null || parsed === PORT_UNSET)
13525
+ return { error: `"${typed.slice(colon + 1)}" is not a port from 1 to 65535.` };
13526
+ port = parsed === DEFAULT_PORT ? PORT_UNSET : parsed;
13527
+ }
13528
+ }
13529
+ } else {
13530
+ const pick = candidates[0];
13531
+ if (!pick)
13532
+ return { error: "This machine has no address to put in the code. Run again with --host." };
13533
+ ({ host, source, note } = pick);
13534
+ }
13321
13535
  if (args.port !== undefined) {
13322
13536
  const parsed = parsePort(args.port);
13323
13537
  if (parsed === null || parsed === PORT_UNSET)
13324
13538
  return { error: `"${args.port}" is not a port from 1 to 65535.` };
13325
13539
  port = parsed === DEFAULT_PORT ? PORT_UNSET : parsed;
13326
13540
  }
13327
- if (args.host !== undefined)
13328
- return { host: args.host, port, hostFrom: "flag" };
13329
- if (session)
13330
- return { host: session.host, port, hostFrom: "ssh" };
13331
- return { host: hostname, port, hostFrom: "name" };
13541
+ return { host, port, source, note };
13332
13542
  }
13333
13543
  function phoneHostKeys(keygenOutput) {
13334
13544
  const keys = [];
@@ -13374,12 +13584,18 @@ function terminalQR(text) {
13374
13584
  function terminalQRWidth(text) {
13375
13585
  return encode(text, QR_OPTIONS).size;
13376
13586
  }
13377
- var HOST_NOTES = {
13378
- flag: "",
13379
- ssh: " (the address this ssh session reached)",
13380
- name: " (this machine's name; if your phone cannot reach it by that name, run again with --host)"
13381
- };
13382
- function pairReport({ code, keys, hostFrom, columns }) {
13587
+ function othersNote(others) {
13588
+ if (others.length === 0)
13589
+ return "";
13590
+ const width = Math.max(...others.map((c) => c.host.length));
13591
+ const lines = ["Other addresses this machine has (run again with --host to use one):"];
13592
+ for (const c of others)
13593
+ lines.push(` ${c.host.padEnd(width)} ${c.note}`);
13594
+ return `${lines.join(`
13595
+ `)}
13596
+ `;
13597
+ }
13598
+ function pairReport({ code, keys, note, columns }) {
13383
13599
  const link = pairingLink(code);
13384
13600
  const width = terminalQRWidth(link);
13385
13601
  const out2 = [];
@@ -13388,7 +13604,7 @@ function pairReport({ code, keys, hostFrom, columns }) {
13388
13604
  } else {
13389
13605
  out2.push(...terminalQR(link));
13390
13606
  }
13391
- out2.push("", "Scan the code with Ledge on your phone. It names this server and its host keys, and holds no password or key.", "", ` Account ${code.user}`, ` Host ${code.host}${HOST_NOTES[hostFrom]}`, ` Port ${code.port === PORT_UNSET ? DEFAULT_PORT : code.port}`, ...keys.map((k, i) => ` ${i === 0 ? "Host keys" : " "} ${k.fingerprint} (${k.keyType})`), "", link);
13607
+ out2.push("", "Scan the code with Ledge on your phone, or paste the link below into the Mac app's Add Server form. It names this server and its host keys, and holds no password or key.", "", ` Account ${code.user}`, ` Host ${code.host}${note === "" ? "" : ` (${note})`}`, ` Port ${code.port === PORT_UNSET ? DEFAULT_PORT : code.port}`, ...keys.map((k, i) => ` ${i === 0 ? "Host keys" : " "} ${k.fingerprint} (${k.keyType})`), "", link);
13392
13608
  return `${out2.join(`
13393
13609
  `)}
13394
13610
  `;
@@ -13401,7 +13617,7 @@ function pairCode(user, address, keys) {
13401
13617
 
13402
13618
  // src/bun/serve.ts
13403
13619
  import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync4 } from "fs";
13404
- import { hostname, userInfo } from "os";
13620
+ import { hostname, networkInterfaces, userInfo } from "os";
13405
13621
  import { join as join20 } from "path";
13406
13622
  async function serve2() {
13407
13623
  const upstream = await connectToDaemon();
@@ -13458,9 +13674,18 @@ ${PAIR_USAGE}`, 2);
13458
13674
  if (refusal)
13459
13675
  return fail2(refusal);
13460
13676
  }
13461
- const address = pairAddress(args, process.env.SSH_CONNECTION, hostname());
13677
+ const candidates = args.host === undefined ? await gatherCandidates() : [];
13678
+ const interactive = args.host === undefined && args.keys !== "-" && process.stdin.isTTY && process.stdout.isTTY;
13679
+ let answer;
13680
+ if (interactive) {
13681
+ process.stderr.write(candidateMenu(candidates));
13682
+ answer = await ask(CHOICE_PROMPT);
13683
+ }
13684
+ const address = pairAddress(args, process.env.SSH_CONNECTION, candidates, answer);
13462
13685
  if ("error" in address)
13463
13686
  return fail2(address.error, 2);
13687
+ if (!interactive)
13688
+ process.stderr.write(othersNote(candidates.filter((c) => c.host !== address.host)));
13464
13689
  let keyText = "";
13465
13690
  if (args.keys === "-")
13466
13691
  keyText = await Bun.stdin.text();
@@ -13503,13 +13728,70 @@ ${PAIR_USAGE}`, 2);
13503
13728
  if ("error" in code)
13504
13729
  return fail2(code.error);
13505
13730
  const columns = process.stdout.isTTY ? process.stdout.columns : undefined;
13506
- process.stdout.write(pairReport({ code, keys, hostFrom: address.hostFrom, columns }));
13731
+ process.stdout.write(pairReport({ code, keys, note: address.note, columns }));
13507
13732
  return 0;
13508
13733
  }
13734
+ async function gatherCandidates() {
13735
+ const interfaces = [];
13736
+ for (const [name, list] of Object.entries(networkInterfaces())) {
13737
+ for (const i of list ?? [])
13738
+ if (i.family === "IPv4" && !i.internal)
13739
+ interfaces.push({ name, address: i.address });
13740
+ }
13741
+ const [tailnet, cloud] = await Promise.all([tailnetSelf(), process.platform === "linux" && inCloud(dmi()) ? cloudAddress() : null]);
13742
+ return addressCandidates({ sshConnection: process.env.SSH_CONNECTION, hostname: hostname(), tailnet, cloudAddress: cloud, interfaces });
13743
+ }
13744
+ var LOOKUP_MS = 1500;
13745
+ function dmi() {
13746
+ const out2 = {};
13747
+ for (const field of DMI_FIELDS) {
13748
+ try {
13749
+ out2[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
13750
+ } catch {}
13751
+ }
13752
+ return out2;
13753
+ }
13754
+ async function tailnetSelf() {
13755
+ const path = TAILSCALE_PATHS.find((p) => existsSync3(p));
13756
+ if (!path)
13757
+ return null;
13758
+ try {
13759
+ const p = Bun.spawn([path, "status", "--json"], { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
13760
+ const timer = setTimeout(() => p.kill(), LOOKUP_MS);
13761
+ const json = await new Response(p.stdout).text();
13762
+ clearTimeout(timer);
13763
+ return tailscaleSelf(json);
13764
+ } catch {
13765
+ return null;
13766
+ }
13767
+ }
13768
+ async function metadata(path, init = {}) {
13769
+ try {
13770
+ const res = await fetch(`${METADATA_ORIGIN}${path}`, { ...init, signal: AbortSignal.timeout(LOOKUP_MS) });
13771
+ return res.ok ? await res.text() : null;
13772
+ } catch {
13773
+ return null;
13774
+ }
13775
+ }
13776
+ async function cloudAddress() {
13777
+ const aws = (async () => {
13778
+ const token = await metadata(AWS_TOKEN_PATH, { method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "60" } });
13779
+ return metadata(AWS_ADDRESS_PATH, token ? { headers: { "X-aws-ec2-metadata-token": token } } : {});
13780
+ })();
13781
+ const others = METADATA_PATHS.map((m) => metadata(m.path, { headers: m.headers }));
13782
+ const answers = await Promise.all([aws, ...others]);
13783
+ for (const body of answers) {
13784
+ const address = body === null ? null : publicAddressAnswer(body);
13785
+ if (address)
13786
+ return address;
13787
+ }
13788
+ return null;
13789
+ }
13509
13790
  var PAIR_USAGE = [
13510
13791
  "usage: ledge pair [--user NAME] [--host ADDRESS] [--port N] [--keys FILE]",
13511
- " --user the account a phone signs in as (default: whoever runs pair)",
13512
- " --host the name or IPv4 address a phone dials (default: this ssh session's address, or the machine's name)",
13792
+ " --user the account Ledge signs in as (default: whoever runs pair)",
13793
+ " --host the name or IPv4 address Ledge connects to (default: a menu of this machine's addresses on a terminal,",
13794
+ " else the first of them: its tailnet name, this ssh session's address, its public address, its name)",
13513
13795
  " --port sshd's port (default: this ssh session's port, or 22)",
13514
13796
  " --keys public host keys to describe, - for stdin (default: /etc/ssh/ssh_host_*_key.pub)"
13515
13797
  ].join(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ledge-server",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "The Ledge server: your notes and shells on another machine, reached over ssh.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",