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.
- package/README.md +2 -2
- package/lib/serve.js +79 -34
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
12262
|
-
const
|
|
12263
|
-
if (
|
|
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
|
-
|
|
12487
|
-
|
|
12488
|
-
|
|
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(
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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
|
|
13351
|
-
tailnetAddress: "this machine's tailnet address
|
|
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
|
|
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}
|
|
13357
|
-
name: "this machine's name
|
|
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
|
|
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
|
|
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
|
|
13748
|
-
" --host the name or IPv4 address
|
|
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)"
|