ledge-server 0.0.2 → 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 +2 -2
  2. package/lib/serve.js +79 -34
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -29,7 +29,7 @@ ledge pair
29
29
  ```
30
30
 
31
31
  It lists the machine's addresses, with what each one reaches, and asks which
32
- one the phone should dial. Scan the code with Ledge on a phone, or paste the
32
+ one your other devices should use. Scan the code with Ledge on a phone, or paste the
33
33
  link under it into the Mac app's Add Server form. The code carries the
34
34
  address, the account, and the host key.
35
35
 
@@ -86,7 +86,7 @@ sudo ln -s "$(command -v bun)" /usr/local/bin/bun
86
86
  | --- | --- |
87
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. |
88
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 a phone should dial. `--user`, `--host`, and `--port` override what it describes. |
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. |
90
90
  | `ledge backup` | Back this machine up to an S3-compatible bucket: `setup`, `now`, `status`, `snapshots`, `restore`, `paths`, `restic`. |
91
91
  | `ledge mcp` | The Ledge MCP server on stdin and stdout, for an agent running on this machine. |
92
92
  | `ledge ls`, `ledge cat`, `ledge search`, ... | Notes from this machine's own shell. `ledge help` lists them all. |
package/lib/serve.js CHANGED
@@ -6236,7 +6236,7 @@ Run them as the account the server runs as, on the machine the server runs on.
6236
6236
 
6237
6237
  | Verb | What it does |
6238
6238
  | --- | --- |
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. |
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. |
6240
6240
  | \`ledge backup now\` | Takes a backup and thins old snapshots. |
6241
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. |
6242
6242
  | \`ledge backup snapshots\` | The snapshots in the repository, newest first. |
@@ -6415,11 +6415,11 @@ A pairing code names a server and its host keys, so the phone can add it without
6415
6415
  ledge pair
6416
6416
  \`\`\`
6417
6417
 
6418
- On a terminal, it first lists every address the machine has, with a note on which phones 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.
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.
6419
6419
 
6420
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).
6421
6421
 
6422
- The code names one address, and the phone dials exactly that, so pick the one the phone reaches from where it will be. A tailnet name works from anywhere the phone is on the tailnet. A home network address works from a phone 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.
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.
6423
6423
 
6424
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.
6425
6425
 
@@ -7176,6 +7176,18 @@ Nothing else needs installing: no timer, no unit file, no line in a crontab. One
7176
7176
 
7177
7177
  \`ledge backup now\` takes a backup at any time and is safe to run beside the schedule.
7178
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
+
7179
7191
  ## 4. Check on it
7180
7192
 
7181
7193
  \`\`\`sh norun
@@ -7211,13 +7223,13 @@ On a fresh machine with Ledge installed, the app on a Mac or the server on a VPS
7211
7223
  ledge backup setup --existing
7212
7224
  \`\`\`
7213
7225
 
7214
- 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:
7215
7227
 
7216
7228
  \`\`\`sh norun
7217
7229
  ledge backup restore --in-place
7218
7230
  \`\`\`
7219
7231
 
7220
- 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.
7221
7233
 
7222
7234
  ## Run restic yourself
7223
7235
 
@@ -11451,7 +11463,7 @@ function createOpLog(opts) {
11451
11463
  }
11452
11464
 
11453
11465
  // src/shared/version.ts
11454
- var BUILD_VERSION = "0.0.2";
11466
+ var BUILD_VERSION = "0.0.3";
11455
11467
 
11456
11468
  // src/bun/daemon.ts
11457
11469
  var SOCKET_PATH = join16(APP_HOME, ".server.sock");
@@ -11860,7 +11872,7 @@ function versionAtLeast(version, min) {
11860
11872
  var BACKUP_EVERY_MS = 60 * 60 * 1000;
11861
11873
  var PRUNE_EVERY_MS = 24 * 60 * 60 * 1000;
11862
11874
  var IDLE_EXIT_MIN_GAP_MS = 10 * 60 * 1000;
11863
- var KEEP = { hourly: 24, daily: 30, weekly: 12, monthly: 24 };
11875
+ var KEEP = { last: 10, hourly: 24, daily: 30, weekly: 12, monthly: 24 };
11864
11876
  var SNAPSHOT_TAG = "ledge";
11865
11877
  var EMPTY_STATE = {
11866
11878
  version: 1,
@@ -11911,6 +11923,9 @@ function isOverdue(state, now, every = BACKUP_EVERY_MS) {
11911
11923
  function pruneDue(state, now, every = PRUNE_EVERY_MS) {
11912
11924
  return !state.lastPrune || Date.parse(state.lastPrune) + every <= now.getTime();
11913
11925
  }
11926
+ function forgetDue(state) {
11927
+ return state.lastSnapshot !== null;
11928
+ }
11914
11929
  function idleExitWorthIt(state, now, gap = IDLE_EXIT_MIN_GAP_MS) {
11915
11930
  return !state.lastOk || Date.parse(state.lastOk) + gap <= now.getTime();
11916
11931
  }
@@ -11932,6 +11947,8 @@ function forgetArgs(prune) {
11932
11947
  "forget",
11933
11948
  "--tag",
11934
11949
  SNAPSHOT_TAG,
11950
+ "--keep-last",
11951
+ String(KEEP.last),
11935
11952
  "--keep-hourly",
11936
11953
  String(KEEP.hourly),
11937
11954
  "--keep-daily",
@@ -12247,7 +12264,8 @@ async function runBackup(o = { reason: "now" }) {
12247
12264
  const out = parseBackupOutput(backed.stdout);
12248
12265
  for (const e of out.errors)
12249
12266
  log(`[backup] restic: ${e}`);
12250
- const previous = readState().lastSnapshot;
12267
+ const state = readState();
12268
+ const previous = state.lastSnapshot;
12251
12269
  if (out.snapshot && previous && previous !== out.snapshot) {
12252
12270
  const diff = await runRestic(restic.path, diffArgs(previous, out.snapshot), env);
12253
12271
  if (diff.code === 0 && parseDiffChanges(diff.stdout) === 0) {
@@ -12258,9 +12276,12 @@ async function runBackup(o = { reason: "now" }) {
12258
12276
  out.snapshot = null;
12259
12277
  }
12260
12278
  }
12261
- const prune = pruneDue(readState(), at);
12262
- const forgot = await runRestic(restic.path, forgetArgs(prune), env);
12263
- 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) {
12264
12285
  const error = `snapshot ${out.snapshot?.slice(0, 8) ?? "kept"}, but restic forget failed: ${resticSaid(forgot)}`;
12265
12286
  log(`[backup] ${error}`);
12266
12287
  writeState(recordRun(readState(), { at, ok: false, error, snapshot: out.snapshot, skipped }));
@@ -12483,33 +12504,54 @@ async function setup(args) {
12483
12504
  vars["RESTIC_PASSWORD"] = generated;
12484
12505
  }
12485
12506
  }
12486
- await writeProfile(BACKUP_PROFILE, backupProfileText(vars));
12487
- const read = readConfig();
12488
- if (!("config" in read)) {
12489
- 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(", ")}`);
12490
12510
  return 1;
12491
12511
  }
12492
- 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.`;
12493
12516
  if (existing) {
12494
- say(`Opening ${read.config.repository}...`);
12517
+ say(`Opening ${proposed.config.repository}...`);
12495
12518
  const r = await runRestic(restic.path, ["cat", "config"], env);
12496
12519
  if (r.code !== 0) {
12497
- say(`Could not open the repository: ${resticSaid(r)}
12498
- 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}`);
12499
12521
  return 1;
12500
12522
  }
12501
12523
  } else {
12502
- say(`Creating the repository at ${read.config.repository}...`);
12524
+ say(`Creating the repository at ${proposed.config.repository}...`);
12503
12525
  const r = await runRestic(restic.path, ["init"], env);
12504
12526
  if (r.code !== 0) {
12505
12527
  const said = resticSaid(r);
12506
12528
  const hint = /already (exists|initialized)/i.test(said) ? `
12507
- That repository already has backups in it. Run setup again with --existing and its password.` : `
12508
- The profile is written at ${PROFILE_PATH}; fix it and run setup again with --replace.`;
12509
- 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}`);
12510
12531
  return 1;
12511
12532
  }
12512
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
+ }
12513
12555
  say("Taking the first backup...");
12514
12556
  await loadWorkspaces();
12515
12557
  const result = await runBackup({ log: say, reason: "setup" });
@@ -12566,9 +12608,12 @@ async function snapshots() {
12566
12608
  return 1;
12567
12609
  }
12568
12610
  for (const s2 of r.snapshots)
12569
- 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"}`);
12570
12612
  return 0;
12571
12613
  }
12614
+ function snapshotTime(time) {
12615
+ return time.replace(/\.\d+/, "").replace("T", " ");
12616
+ }
12572
12617
  async function restore(args) {
12573
12618
  const config = configured();
12574
12619
  if (!config) {
@@ -13347,14 +13392,14 @@ function sshClientAddress(sshConnection) {
13347
13392
  }
13348
13393
  var VIRTUAL_INTERFACE = /^(docker|br-|veth|virbr|lxc|lxd|cni|flannel|podman|vmnet|vboxnet)/;
13349
13394
  var NOTES = {
13350
- tailnetName: "this machine's tailnet name; a phone on the tailnet reaches it from anywhere",
13351
- tailnetAddress: "this machine's tailnet address; a phone on the tailnet reaches it from anywhere",
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",
13352
13397
  ssh: "the address this ssh session reached",
13353
- sshInside: "the address this ssh session reached, inside a NAT the session came through; a phone outside needs the outside address",
13398
+ sshInside: "the address this ssh session reached, inside a NAT the session came through; a device outside needs the outside address",
13354
13399
  cloud: "this machine's public address, from the cloud's metadata service",
13355
13400
  public: (name) => `the public address on ${name}`,
13356
- private: (name) => `the local network address on ${name}; a phone on that network reaches it`,
13357
- name: "this machine's name; a phone on the same network may resolve it"
13401
+ private: (name) => `the local network address on ${name}`,
13402
+ name: "this machine's name"
13358
13403
  };
13359
13404
  function addressCandidates(inputs) {
13360
13405
  const out2 = [];
@@ -13447,7 +13492,7 @@ function publicAddressAnswer(body) {
13447
13492
  }
13448
13493
  function candidateMenu(candidates) {
13449
13494
  const width = Math.max(...candidates.map((c) => c.host.length));
13450
- const lines = ["Which address should a phone dial?"];
13495
+ const lines = ["Which address should Ledge on your other devices use to reach this server?"];
13451
13496
  candidates.forEach((c, i) => lines.push(` ${String(i + 1).padStart(2)} ${c.host.padEnd(width)} ${c.note}`));
13452
13497
  return `${lines.join(`
13453
13498
  `)}
@@ -13484,7 +13529,7 @@ function pairAddress(args, sshConnection, candidates, answer) {
13484
13529
  } else {
13485
13530
  const pick = candidates[0];
13486
13531
  if (!pick)
13487
- return { error: "This machine has no address a phone could dial. Run again with --host." };
13532
+ return { error: "This machine has no address to put in the code. Run again with --host." };
13488
13533
  ({ host, source, note } = pick);
13489
13534
  }
13490
13535
  if (args.port !== undefined) {
@@ -13559,7 +13604,7 @@ function pairReport({ code, keys, note, columns }) {
13559
13604
  } else {
13560
13605
  out2.push(...terminalQR(link));
13561
13606
  }
13562
- 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}${note === "" ? "" : ` (${note})`}`, ` 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);
13563
13608
  return `${out2.join(`
13564
13609
  `)}
13565
13610
  `;
@@ -13744,8 +13789,8 @@ async function cloudAddress() {
13744
13789
  }
13745
13790
  var PAIR_USAGE = [
13746
13791
  "usage: ledge pair [--user NAME] [--host ADDRESS] [--port N] [--keys FILE]",
13747
- " --user the account a phone signs in as (default: whoever runs pair)",
13748
- " --host the name or IPv4 address a phone dials (default: a menu of this machine's addresses on a terminal,",
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,",
13749
13794
  " else the first of them: its tailnet name, this ssh session's address, its public address, its name)",
13750
13795
  " --port sshd's port (default: this ssh session's port, or 22)",
13751
13796
  " --keys public host keys to describe, - for stdin (default: /etc/ssh/ssh_host_*_key.pub)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ledge-server",
3
- "version": "0.0.2",
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",