ledge-server 0.0.2 → 0.1.0

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/lib/serve.js CHANGED
@@ -390,9 +390,9 @@ function setFavoriteLine(text, on) {
390
390
  if (!on) {
391
391
  if (b === null)
392
392
  return text;
393
- const content2 = b.lines.slice(1, b.close);
394
- const kept = content2.filter((l) => !FAVORITE_LINE.test(l));
395
- if (kept.length === content2.length)
393
+ const content = b.lines.slice(1, b.close);
394
+ const kept = content.filter((l) => !FAVORITE_LINE.test(l));
395
+ if (kept.length === content.length)
396
396
  return text;
397
397
  if (kept.every((l) => l.trim() === ""))
398
398
  return text.slice(b.end);
@@ -890,8 +890,10 @@ async function loadWorkspaces() {
890
890
  }
891
891
  async function ensureDefault() {
892
892
  if (availableRoots().some((r) => kindOf(r) !== "docs"))
893
- return;
894
- await createManaged("Scratch");
893
+ return null;
894
+ const first = ![...entries.keys()].some((r) => kindOf(r) !== "docs");
895
+ const root = await createManaged("Scratch");
896
+ return first ? root : null;
895
897
  }
896
898
  async function createManaged(name) {
897
899
  await ensureAppHome();
@@ -1247,10 +1249,10 @@ async function unlockVault(passphrase, device, probeHeader) {
1247
1249
  } catch {
1248
1250
  return false;
1249
1251
  }
1250
- const key2 = deriveKey(passphrase, vaultSalt);
1251
- if (!checkAgainstFile(key2, checkB64))
1252
+ const key = deriveKey(passphrase, vaultSalt);
1253
+ if (!checkAgainstFile(key, checkB64))
1252
1254
  return false;
1253
- masterKey = key2;
1255
+ masterKey = key;
1254
1256
  authorize(device);
1255
1257
  return true;
1256
1258
  }
@@ -1750,9 +1752,9 @@ async function listNotes(root) {
1750
1752
  }
1751
1753
  async function searchNotes(root, query, folder = "") {
1752
1754
  const metas = notesUnder(await listNotes(root), folder);
1753
- const open2 = metas.filter((m) => !m.locked);
1754
- const hits = await collectHits(query, open2, async (path) => (await readNote(path))?.text ?? null);
1755
- return { hits, lockedSkipped: metas.length - open2.length };
1755
+ const open = metas.filter((m) => !m.locked);
1756
+ const hits = await collectHits(query, open, async (path) => (await readNote(path))?.text ?? null);
1757
+ return { hits, lockedSkipped: metas.length - open.length };
1756
1758
  }
1757
1759
  var CONTEXT_MAX = 200;
1758
1760
  function contextOf(lines, line) {
@@ -1942,14 +1944,14 @@ function assetRefsOf(text, root, from) {
1942
1944
  function rebaseAssetRefs(text, root, from, to) {
1943
1945
  if (dirname3(resolve3(from)) === dirname3(resolve3(to)))
1944
1946
  return text;
1945
- return text.replace(IMAGE_REF, (whole, open2, ref, close) => {
1947
+ return text.replace(IMAGE_REF, (whole, open, ref, close) => {
1946
1948
  let asset;
1947
1949
  try {
1948
1950
  asset = assetPathOf(root, ref, from);
1949
1951
  } catch {
1950
1952
  return whole;
1951
1953
  }
1952
- return `${open2}${assetRefFor(root, asset, to)}${close}`;
1954
+ return `${open}${assetRefFor(root, asset, to)}${close}`;
1953
1955
  });
1954
1956
  }
1955
1957
  async function favoriteNote(path, on) {
@@ -2150,13 +2152,13 @@ async function rollBack(done, from, to, salt) {
2150
2152
  async function imageFilesUnder(root) {
2151
2153
  const out = [];
2152
2154
  const walk = async (dir) => {
2153
- let entries2;
2155
+ let entries;
2154
2156
  try {
2155
- entries2 = await readdir3(dir, { withFileTypes: true });
2157
+ entries = await readdir3(dir, { withFileTypes: true });
2156
2158
  } catch {
2157
2159
  return;
2158
2160
  }
2159
- for (const entry of entries2) {
2161
+ for (const entry of entries) {
2160
2162
  if (entry.name.startsWith(".") && entry.name !== ASSETS_DIRNAME)
2161
2163
  continue;
2162
2164
  const path = join5(dir, entry.name);
@@ -2201,8 +2203,8 @@ async function createNote(root, text, folder) {
2201
2203
  const dir = await ensureFolder(root, folder);
2202
2204
  const reserved = reservedIn(dir);
2203
2205
  const taken = new Set(await readdir3(dir));
2204
- for (const name2 of reserved)
2205
- taken.add(name2);
2206
+ for (const name of reserved)
2207
+ taken.add(name);
2206
2208
  const name = uniqueName(baseFor(text), taken);
2207
2209
  reserved.add(name);
2208
2210
  const path = join5(dir, name);
@@ -2230,8 +2232,8 @@ async function moveNote(path, folder) {
2230
2232
  }
2231
2233
  const reserved = reservedIn(dir);
2232
2234
  const taken = new Set(await readdir3(dir));
2233
- for (const name2 of reserved)
2234
- taken.add(name2);
2235
+ for (const name of reserved)
2236
+ taken.add(name);
2235
2237
  const name = uniqueName(titleOf(from), taken);
2236
2238
  reserved.add(name);
2237
2239
  const target = join5(dir, name);
@@ -2298,8 +2300,8 @@ async function retitleNote(path, text) {
2298
2300
  const reserved = reservedIn(dir);
2299
2301
  const taken = new Set(await readdir3(dir));
2300
2302
  taken.delete(current);
2301
- for (const name2 of reserved)
2302
- taken.add(name2);
2303
+ for (const name of reserved)
2304
+ taken.add(name);
2303
2305
  const name = uniqueName(baseFor(text), taken);
2304
2306
  if (name.toLowerCase() === current.toLowerCase()) {
2305
2307
  return metaFor(path, text);
@@ -2379,13 +2381,13 @@ async function stashNote(path, text) {
2379
2381
  async function trashFiles(root) {
2380
2382
  const out = [];
2381
2383
  const walk = async (dir) => {
2382
- let entries2;
2384
+ let entries;
2383
2385
  try {
2384
- entries2 = await readdir3(dir, { withFileTypes: true });
2386
+ entries = await readdir3(dir, { withFileTypes: true });
2385
2387
  } catch {
2386
2388
  return;
2387
2389
  }
2388
- for (const entry of entries2) {
2390
+ for (const entry of entries) {
2389
2391
  if (entry.name.startsWith("."))
2390
2392
  continue;
2391
2393
  const path = join5(dir, entry.name);
@@ -2427,8 +2429,8 @@ async function restoreNote(path) {
2427
2429
  await mkdir3(dir, { recursive: true });
2428
2430
  const reserved = reservedIn(dir);
2429
2431
  const taken = new Set(await readdir3(dir));
2430
- for (const name2 of reserved)
2431
- taken.add(name2);
2432
+ for (const name of reserved)
2433
+ taken.add(name);
2432
2434
  const name = uniqueName(titleOf(path), taken);
2433
2435
  reserved.add(name);
2434
2436
  const target = join5(dir, name);
@@ -2506,12 +2508,12 @@ function forceTitle(text, title) {
2506
2508
  const start = frontmatterEnd(text);
2507
2509
  if (heading === null) {
2508
2510
  const body = text.slice(start);
2509
- const sep4 = body.startsWith(`
2511
+ const sep = body.startsWith(`
2510
2512
  `) || body === "" ? `
2511
2513
  ` : `
2512
2514
 
2513
2515
  `;
2514
- return `${text.slice(0, start)}# ${title}${sep4}${body}`;
2516
+ return `${text.slice(0, start)}# ${title}${sep}${body}`;
2515
2517
  }
2516
2518
  const gap = start === 0 ? 0 : /^(?:[ \t]*\r?\n)+/.exec(text.slice(start))?.[0].length ?? 0;
2517
2519
  const lineStart = start + gap;
@@ -2563,9 +2565,9 @@ async function findTemplate(title, preferredRoot) {
2563
2565
  const pref = assertRegisteredRoot(preferredRoot);
2564
2566
  const local = resolveWikiTitle(title, await listNotes(pref));
2565
2567
  if (local) {
2566
- const file2 = await readNote(local.path);
2567
- if (file2)
2568
- return { path: local.path, text: templateText(file2, title) };
2568
+ const file = await readNote(local.path);
2569
+ if (file)
2570
+ return { path: local.path, text: templateText(file, title) };
2569
2571
  }
2570
2572
  const others = availableRoots().filter((r) => r !== pref);
2571
2573
  const metas = (await Promise.all(others.map((r) => listNotes(r)))).flat();
@@ -3297,10 +3299,10 @@ function folderOut(meta) {
3297
3299
  return meta.folder ? { folder: meta.folder } : {};
3298
3300
  }
3299
3301
  async function notesIn(workspace, folder = null) {
3300
- const roots2 = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3302
+ const roots = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3301
3303
  const scope = folderScopeOf(folder);
3302
3304
  const out = [];
3303
- for (const root of roots2) {
3305
+ for (const root of roots) {
3304
3306
  try {
3305
3307
  for (const n of notesUnder(await listNotes(root), scope))
3306
3308
  out.push({ ...n, workspace: root });
@@ -3385,10 +3387,10 @@ function targetWorkspace(args) {
3385
3387
  throw new Error(`LEDGE_WORKSPACE names ${env}, which is no longer a registered workspace root \u2014 name one explicitly (list_workspaces shows them)`);
3386
3388
  }
3387
3389
  }
3388
- const roots2 = writableRoots();
3389
- if (roots2.length === 1)
3390
- return roots2[0];
3391
- throw new Error(roots2.length === 0 ? "no workspace is available to create in (unmounted volume? list_workspaces shows what Ledge knows)" : "several workspaces exist \u2014 name one (list_workspaces shows them), or call from a shell in a Ledge note's terminal, where LEDGE_WORKSPACE names the current one");
3390
+ const roots = writableRoots();
3391
+ if (roots.length === 1)
3392
+ return roots[0];
3393
+ throw new Error(roots.length === 0 ? "no workspace is available to create in (unmounted volume? list_workspaces shows what Ledge knows)" : "several workspaces exist \u2014 name one (list_workspaces shows them), or call from a shell in a Ledge note's terminal, where LEDGE_WORKSPACE names the current one");
3392
3394
  }
3393
3395
  function dailyWorkspace(args, settings) {
3394
3396
  const asked = args["workspace"];
@@ -3502,11 +3504,11 @@ var ledgeTools = [
3502
3504
  throw new Error("give a non-empty query");
3503
3505
  await loadWorkspaces();
3504
3506
  const workspace = args["workspace"];
3505
- const roots2 = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3507
+ const roots = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3506
3508
  const folder = folderScopeOf(args["folder"]);
3507
3509
  const all = [];
3508
3510
  let lockedSkipped = 0;
3509
- for (const root of roots2) {
3511
+ for (const root of roots) {
3510
3512
  try {
3511
3513
  const res = await searchNotes(root, query, folder);
3512
3514
  lockedSkipped += res.lockedSkipped;
@@ -3564,16 +3566,16 @@ var ledgeTools = [
3564
3566
  handler: async (args) => {
3565
3567
  await loadWorkspaces();
3566
3568
  const workspace = args["workspace"];
3567
- const roots2 = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3569
+ const roots = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3568
3570
  const tag = args["tag"];
3569
3571
  const folder = folderScopeOf(args["folder"]);
3570
3572
  if (typeof tag === "string" && normalizeTag(tag) !== "") {
3571
3573
  const all = [];
3572
- let lockedSkipped2 = 0;
3573
- for (const root of roots2) {
3574
+ let lockedSkipped = 0;
3575
+ for (const root of roots) {
3574
3576
  try {
3575
3577
  const res = await notesTagged(root, tag, folder);
3576
- lockedSkipped2 += res.lockedSkipped;
3578
+ lockedSkipped += res.lockedSkipped;
3577
3579
  for (const h of res.hits) {
3578
3580
  all.push({ path: h.path, title: h.title, workspace: root, mtimeMs: h.mtimeMs, line: h.line, context: h.context });
3579
3581
  }
@@ -3588,12 +3590,12 @@ var ledgeTools = [
3588
3590
  return {
3589
3591
  hits: hits.map(({ mtimeMs, ...h }) => ({ ...h, modified: iso(mtimeMs) })),
3590
3592
  truncated: all.length > MAX_HITS,
3591
- ...lockedSkipped2 > 0 ? { lockedNoteBodiesSkipped: lockedSkipped2 } : {}
3593
+ ...lockedSkipped > 0 ? { lockedNoteBodiesSkipped: lockedSkipped } : {}
3592
3594
  };
3593
3595
  }
3594
3596
  const merged = new Map;
3595
3597
  let lockedSkipped = 0;
3596
- for (const root of roots2) {
3598
+ for (const root of roots) {
3597
3599
  try {
3598
3600
  const res = await tagsIn(root, folder);
3599
3601
  lockedSkipped += res.lockedSkipped;
@@ -3651,9 +3653,9 @@ var ledgeTools = [
3651
3653
  if (typeof title !== "string" || title.trim() === "") {
3652
3654
  throw new Error("creating from a template needs a `title` for the new note");
3653
3655
  }
3654
- const root2 = targetWorkspace(args);
3655
- const meta2 = await createFromTemplate(root2, template.trim(), title.trim(), folder);
3656
- return { path: meta2.path, title: meta2.title, workspace: root2, ...folderOut(meta2), modified: iso(meta2.mtimeMs) };
3656
+ const root = targetWorkspace(args);
3657
+ const meta = await createFromTemplate(root, template.trim(), title.trim(), folder);
3658
+ return { path: meta.path, title: meta.title, workspace: root, ...folderOut(meta), modified: iso(meta.mtimeMs) };
3657
3659
  }
3658
3660
  const text = args["text"];
3659
3661
  if (typeof text !== "string" || text.trim() === "") {
@@ -4077,17 +4079,17 @@ async function runCli(argv, io) {
4077
4079
  const ws = scope ?? (flags.all ? null : here);
4078
4080
  const base = inFolder(ws !== null ? { workspace: ws } : {});
4079
4081
  if (arg === "") {
4080
- const res2 = await tool("tags", base);
4082
+ const res = await tool("tags", base);
4081
4083
  if (flags.json) {
4082
- io.out(JSON.stringify(res2, null, 2));
4084
+ io.out(JSON.stringify(res, null, 2));
4083
4085
  return 0;
4084
4086
  }
4085
- if (res2.tags.length === 0) {
4087
+ if (res.tags.length === 0) {
4086
4088
  io.err(ws !== null ? `no tags in ${tildify(folder === "" ? ws : join10(ws, folder))}` : "no tags");
4087
4089
  return 0;
4088
4090
  }
4089
- const width = res2.tags.reduce((w, t) => Math.max(w, t.tag.length + 1), 0);
4090
- for (const t of res2.tags)
4091
+ const width = res.tags.reduce((w, t) => Math.max(w, t.tag.length + 1), 0);
4092
+ for (const t of res.tags)
4091
4093
  io.out(`${`#${t.tag}`.padEnd(width)} ${t.count}`);
4092
4094
  return 0;
4093
4095
  }
@@ -4119,11 +4121,11 @@ async function runCli(argv, io) {
4119
4121
  const args = inFolder({ template: flags.template, title });
4120
4122
  if (scope !== null)
4121
4123
  args["workspace"] = scope;
4122
- const n2 = await tool("create_note", args);
4124
+ const n = await tool("create_note", args);
4123
4125
  if (flags.json)
4124
- io.out(JSON.stringify(n2, null, 2));
4126
+ io.out(JSON.stringify(n, null, 2));
4125
4127
  else
4126
- io.out(n2.path);
4128
+ io.out(n.path);
4127
4129
  return 0;
4128
4130
  }
4129
4131
  if (title === "" && body === "") {
@@ -4423,8 +4425,8 @@ class PtyProcess {
4423
4425
  constructor(opts) {
4424
4426
  this.interruptViaChar = opts.interruptViaChar ?? false;
4425
4427
  const keep = [];
4426
- const cstr = (str2) => {
4427
- const enc = new TextEncoder().encode(str2);
4428
+ const cstr = (str) => {
4429
+ const enc = new TextEncoder().encode(str);
4428
4430
  const b = new Uint8Array(enc.length + 1);
4429
4431
  b.set(enc);
4430
4432
  keep.push(b);
@@ -4742,12 +4744,12 @@ class MarkerParser {
4742
4744
  if (!tag.startsWith("ledge="))
4743
4745
  return null;
4744
4746
  const payload = tag.slice("ledge=".length);
4745
- const sep5 = payload.indexOf(":");
4746
- if (sep5 === -1)
4747
+ const sep = payload.indexOf(":");
4748
+ if (sep === -1)
4747
4749
  return null;
4748
- if (payload.slice(0, sep5) !== this.nonce)
4750
+ if (payload.slice(0, sep) !== this.nonce)
4749
4751
  return null;
4750
- return payload.slice(sep5 + 1);
4752
+ return payload.slice(sep + 1);
4751
4753
  }
4752
4754
  }
4753
4755
  function concat(a, b) {
@@ -4917,11 +4919,11 @@ class InlinePool {
4917
4919
  continue;
4918
4920
  }
4919
4921
  if (slot.shell.exited) {
4920
- const open2 = slot.parser.openBlockId ?? slot.activeRun;
4921
- if (open2 && !slot.began)
4922
+ const open = slot.parser.openBlockId ?? slot.activeRun;
4923
+ if (open && !slot.began)
4922
4924
  this.flushPreamble(slot, emit);
4923
- if (open2)
4924
- emit({ type: "ended", blockId: open2, exitCode: null }, slot.client);
4925
+ if (open)
4926
+ emit({ type: "ended", blockId: open, exitCode: null }, slot.client);
4925
4927
  this.dropSlot(session, slot);
4926
4928
  }
4927
4929
  }
@@ -4943,9 +4945,9 @@ class InlinePool {
4943
4945
  const session = this.sessions.get(sessionId);
4944
4946
  if (session) {
4945
4947
  for (const slot of this.slots(session)) {
4946
- const open2 = slot.parser.openBlockId ?? slot.activeRun;
4947
- if (open2)
4948
- emit({ type: "ended", blockId: open2, exitCode: null }, slot.client);
4948
+ const open = slot.parser.openBlockId ?? slot.activeRun;
4949
+ if (open)
4950
+ emit({ type: "ended", blockId: open, exitCode: null }, slot.client);
4949
4951
  slot.shell.close();
4950
4952
  }
4951
4953
  this.sessions.delete(sessionId);
@@ -5057,14 +5059,14 @@ class InlinePool {
5057
5059
  }
5058
5060
  dropSlot(session, slot) {
5059
5061
  slot.shell.close();
5060
- for (const [host, s2] of session.primaries) {
5061
- if (s2 === slot) {
5062
+ for (const [host, s] of session.primaries) {
5063
+ if (s === slot) {
5062
5064
  session.primaries.delete(host);
5063
5065
  return;
5064
5066
  }
5065
5067
  }
5066
- for (const [id, s2] of session.overflow) {
5067
- if (s2 === slot)
5068
+ for (const [id, s] of session.overflow) {
5069
+ if (s === slot)
5068
5070
  session.overflow.delete(id);
5069
5071
  }
5070
5072
  }
@@ -5134,6 +5136,57 @@ async function writeProfile(name, text) {
5134
5136
  }
5135
5137
  }
5136
5138
 
5139
+ // src/shared/welcome.ts
5140
+ var WELCOME_TITLE = "Welcome to Ledge";
5141
+ var WELCOME_DOC = [
5142
+ `# ${WELCOME_TITLE}`,
5143
+ "",
5144
+ "Ledge runs code and commands straight from your Markdown. This note is yours: edit it, or start a new one with \u2318N.",
5145
+ "",
5146
+ "## Run a block",
5147
+ "",
5148
+ "\u2318\u21A9 inside the block below, or the Run button on it (a tap, on a phone), runs it.",
5149
+ "",
5150
+ "```sh",
5151
+ "curl -s https://api.github.com/zen",
5152
+ "```",
5153
+ "",
5154
+ "One line of output streams into a panel beneath the block, and Dismiss puts the panel away.",
5155
+ "",
5156
+ "## The shell persists between blocks",
5157
+ "",
5158
+ "Each note keeps one shell for inline runs, so a `cd` or an exported variable carries into the next block. Run these two in order:",
5159
+ "",
5160
+ "```sh",
5161
+ "cd /tmp",
5162
+ "export FLAVOR=nautical",
5163
+ "```",
5164
+ "",
5165
+ "```sh",
5166
+ "pwd",
5167
+ 'echo "this shell is feeling $FLAVOR"',
5168
+ "```",
5169
+ "",
5170
+ "\u21E7\u2318\u21A9 sends a block to the note's terminal drawer instead, a separate shell you can keep typing in. \u2303` opens the drawer.",
5171
+ "",
5172
+ "## Other languages",
5173
+ "",
5174
+ "`python`, `node`, `ts`, and others are runnable out of the box, each run a fresh process, and TypeScript runs on Bun, which Ledge already has:",
5175
+ "",
5176
+ "```ts",
5177
+ "const now = new Date();",
5178
+ "console.log(`hello from TypeScript, it is ${now.toLocaleTimeString()}`);",
5179
+ "```",
5180
+ "",
5181
+ "## Where to next",
5182
+ "",
5183
+ "- The first line of a note names its file, so this one is `welcome-to-ledge.md`. Delete it from the sidebar once you are done with it.",
5184
+ "- \u2318P opens a note by title, and \u2325\u2318P searches every note.",
5185
+ '- The manual is behind the help button in the header, or "Documentation" in the command palette (\u21E7\u2318P). Getting Started is its first page.',
5186
+ ""
5187
+ ].join(`
5188
+ `);
5189
+
5137
5190
  // src/bun/docs.ts
5138
5191
  import { basename as basename5, join as join13, resolve as resolve9 } from "path";
5139
5192
  import { mkdir as mkdir5, readdir as readdir4, readFile as readFile9, rename as rename8, unlink as unlink7, writeFile as writeFile8 } from "fs/promises";
@@ -5143,7 +5196,7 @@ var _01_getting_started_default = `# Getting Started
5143
5196
 
5144
5197
  Ledge is the notebook for developers and DevOps. It runs code and commands straight from your Markdown.
5145
5198
 
5146
- The manual is read-only and its code blocks do not run. The note called Welcome to Ledge, which opens on a first launch and on a server that has no notes yet, is where the same examples do run.
5199
+ The manual is read-only and its code blocks do not run. The note called Welcome to Ledge is where the same examples do run. Ledge creates it the first time it starts on a machine, whether that is your Mac or a new server, and it stays in the Scratch workspace until you delete it.
5147
5200
 
5148
5201
  ## Your first note
5149
5202
 
@@ -6101,17 +6154,36 @@ Closing the last window quits Ledge.
6101
6154
 
6102
6155
  ## Install the server
6103
6156
 
6104
- The other machine needs the \`ledge\` command on the PATH an incoming ssh gets. It comes from the \`ledge-server\` package, so a few commands install it. [[Tutorial: Set Up a Ledge Server]] walks through them on a fresh VPS, with an account for Ledge and the sshd hardening this page describes further down.
6157
+ The other machine needs the server, which is the \`ledge-server\` package. One command installs it, on Linux or a Mac. Run it in a terminal on that machine, signed in as the account Ledge will sign in to:
6105
6158
 
6106
- The server runs on Bun, and where Bun goes decides where the server goes, because Bun puts global commands beside itself. On a Linux machine, install Bun into \`/usr/local\` and both names land in \`/usr/local/bin\`, which is where the short PATH of an ssh command looks:
6159
+ \`\`\`sh norun
6160
+ curl -fsSL https://ledge.sh/server.sh | sh
6161
+ \`\`\`
6162
+
6163
+ It needs no \`sudo\` and nothing installed first. It downloads the package and a Bun of its own, checks both against the checksums written into the script, and puts them in \`~/.ledge/.server\` in that account's home. Ledge's ssh command looks in \`~/.ledge/.server/bin\` before anything else, so an incoming ssh finds the server with no change to the PATH. The script also adds a PATH line to the shell's startup file, so your own new terminals find \`ledge\` too.
6164
+
6165
+ It refuses to run as root, because the server belongs to the account Ledge signs in to. To install for another account, such as one named \`ledge\`, run it through \`sudo\`:
6107
6166
 
6108
6167
  \`\`\`sh norun
6109
- curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
6168
+ curl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh
6110
6169
  \`\`\`
6111
6170
 
6112
- Then the server, into the same place:
6171
+ Running the same command again updates the server. A server that is already running goes on serving until it exits on its own, a minute or more after the last device disconnects, and the next connection starts the new version. [[Tutorial: Set Up a Ledge Server]] walks through the install on a fresh VPS, with an account for Ledge and the sshd hardening this page describes further down.
6172
+
6173
+ A Mac that runs the Ledge app needs none of this. "Install Shell Command (ledge)" in the app's command palette puts \`ledge\` in \`~/.ledge/.server/bin\`, where an incoming ssh looks first, pointing at the app's own copy. Signing in as that account then reaches the notes the app shows, with the app's server answering both. On a Mac, the machine also needs Remote Login turned on ("Expose ssh carefully").
6174
+
6175
+ macOS and Linux are supported, 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.
6176
+
6177
+ Nothing else has to be installed and no port is opened. Ledge speaks its protocol over ssh's stdin and stdout.
6178
+
6179
+ Blocks need zsh or bash on that machine. Ledge spawns the account's login shell when it is one of those, and otherwise the first of the two it finds, so an ordinary Linux install needs nothing extra. Where neither exists, a run refuses and names the shell it looked for instead of appearing to do nothing.
6180
+
6181
+ ## Install the server with Bun
6182
+
6183
+ If the machine already has Bun and you would rather use it, the same package installs with \`bun add -g\`. Where Bun goes decides where the server goes, because Bun puts global commands beside itself, and both have to be on the short PATH an incoming ssh gets. On Linux, that means Bun in \`/usr/local\`:
6113
6184
 
6114
6185
  \`\`\`sh norun
6186
+ curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
6115
6187
  sudo BUN_INSTALL=/usr/local bun add -g ledge-server
6116
6188
  \`\`\`
6117
6189
 
@@ -6128,27 +6200,23 @@ bun add -g ledge-server
6128
6200
 
6129
6201
  Run them as the account Ledge signs in to. None of them needs \`sudo\`. The account's shell has to be zsh, which it is unless the account predates macOS Catalina.
6130
6202
 
6131
- A Mac that runs the Ledge app needs none of this. "Install Shell Command (ledge)" in the app's command palette puts \`ledge\` in \`~/.ledge/.server/bin\`, where an incoming ssh looks first, pointing at the app's own copy. Signing in as that account then reaches the notes the app shows, with the app's server answering both.
6132
-
6133
- macOS and Linux are supported, 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.
6134
-
6135
- Nothing else has to be installed and no port is opened. Ledge speaks its protocol over ssh's stdin and stdout.
6136
-
6137
- Blocks need zsh or bash on that machine. Ledge spawns the account's login shell when it is one of those, and otherwise the first of the two it finds, so an ordinary Linux install needs nothing extra. Where neither exists, a run refuses and names the shell it looked for instead of appearing to do nothing.
6203
+ Updating is \`bun add -g ledge-server@latest\`, with the same \`sudo BUN_INSTALL=/usr/local\` in front on Linux.
6138
6204
 
6139
6205
  ## Check that ssh can find the server
6140
6206
 
6141
6207
  Worth doing once, because Ledge reports the failure it catches as a server that is not installed. A remote shell that cannot find a command says only that, so that is all the app has to go on.
6142
6208
 
6143
- Ledge starts the server by running \`ledge serve\` over ssh. A command run that way gets a short PATH and skips the startup files a terminal reads, so both \`ledge\` and the \`bun\` its first line names have to be on that PATH already. From your Mac's own terminal:
6209
+ Ledge starts the server by running \`PATH=$HOME/.ledge/.server/bin:$PATH ledge serve\` over ssh. A command run that way skips the startup files a terminal reads, so \`ledge\` has to be in \`~/.ledge/.server/bin\` or on the short PATH an incoming ssh gets. From your Mac's own terminal, ask the machine the same question:
6144
6210
 
6145
6211
  \`\`\`sh norun
6146
- ssh you@machine 'command -v ledge; command -v bun'
6212
+ ssh you@machine 'PATH=$HOME/.ledge/.server/bin:$PATH command -v ledge'
6147
6213
  \`\`\`
6148
6214
 
6149
- Two paths printed means the machine is ready to add.
6215
+ A path printed means the machine is ready to add.
6150
6216
 
6151
- On Linux, nothing printed means Bun is installed for one user rather than system-wide, which is what a machine that already had Bun before you started usually has. Its global commands are then in \`~/.bun/bin\`, which an incoming ssh does not search, and \`bun pm bin -g\` on that machine confirms where they went. Linking both names into a system directory, on that machine, fixes it without reinstalling anything:
6217
+ Nothing printed after \`server.sh\` means it ran as a different account from the one you signed in as. Run it again as that account.
6218
+
6219
+ After an install with Bun, check \`bun\` as well, since the package starts with it: \`ssh you@machine 'command -v bun'\`. On Linux, nothing printed means Bun is installed for one user rather than in \`/usr/local\`, which is what a machine that already had Bun before you started usually has. Its global commands are then in \`~/.bun/bin\`, which an incoming ssh does not search, and \`bun pm bin -g\` on that machine confirms where they went. Linking both names into a system directory, on that machine, fixes it without reinstalling anything:
6152
6220
 
6153
6221
  \`\`\`sh norun
6154
6222
  sudo ln -s "$(bun pm bin -g)/ledge" /usr/local/bin/ledge
@@ -6177,10 +6245,10 @@ Optional, and worth doing on a server you care about. Ledge connects with an ord
6177
6245
  Restricting gives the server a key that can speak Ledge's protocol and nothing else. In that machine's \`~/.ssh/authorized_keys\`:
6178
6246
 
6179
6247
  \`\`\`
6180
- restrict,command="/usr/local/bin/ledge serve" ssh-ed25519 AAAA... ledge@laptop
6248
+ restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ssh-ed25519 AAAA... ledge@laptop
6181
6249
  \`\`\`
6182
6250
 
6183
- Use the absolute path that \`command -v ledge\` printed above. sshd runs this line instead of whatever the client asked for, so naming the file outright settles where it is. It does not settle where Bun is, which is the other half of the check.
6251
+ The command is the one Ledge itself runs, so it finds the server wherever the check above found it. sshd runs this line instead of whatever the client asked for.
6184
6252
 
6185
6253
  That key cannot forward a port, run \`scp\`, or open a shell over ssh. What it limits is what the key is good for if it is ever stolen: no route into the network behind that server, and no file copying.
6186
6254
 
@@ -6236,7 +6304,7 @@ Run them as the account the server runs as, on the machine the server runs on.
6236
6304
 
6237
6305
  | Verb | What it does |
6238
6306
  | --- | --- |
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. |
6307
+ | \`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
6308
  | \`ledge backup now\` | Takes a backup and thins old snapshots. |
6241
6309
  | \`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
6310
  | \`ledge backup snapshots\` | The snapshots in the repository, newest first. |
@@ -6250,7 +6318,7 @@ Backups run every hour while the server is up, and once more before it exits: af
6250
6318
 
6251
6319
  Three things to know before you rely on it:
6252
6320
 
6253
- - Keep the restic password somewhere other than this machine. \`setup\` prints it once. A restore starts on a machine that has nothing on it, and a password stored only inside the backup is a backup you cannot open.
6321
+ - Keep the restic password somewhere other than this machine, such as a password manager. \`setup\` prints it once. It is the only key to the backup, and nothing can be restored without it.
6254
6322
  - The backup holds secrets. Profile values are plain text on disk and so are unlocked notes, which is why restic encrypts before uploading. The bucket sees ciphertext only.
6255
6323
  - Locked notes and the vault travel together. \`.vault.json\` is inside the app home, so it is always in the backup, and a restore opens locked notes with the passphrase they had ([[Note Locking]]).
6256
6324
 
@@ -6395,6 +6463,8 @@ var _10_ledge_on_your_phone_default = `# Ledge on Your Phone
6395
6463
 
6396
6464
  Ledge runs on an iPhone or iPad as a window onto a server. The phone holds no notes: it reaches a server over ssh, the way a Mac does in [[Keep Notes on a Remote Server]], and shows you what is there.
6397
6465
 
6466
+ Get Ledge for iPhone from the App Store. It runs on iOS and iPadOS 17 or newer, and it needs a server to connect to before it shows anything.
6467
+
6398
6468
  A server that already serves your Mac needs nothing more. A machine without one needs the server installed first, as "Install the server" on that page describes, and the phone shows the same commands ("Set up a server" below).
6399
6469
 
6400
6470
  ## The first screen
@@ -6415,11 +6485,11 @@ A pairing code names a server and its host keys, so the phone can add it without
6415
6485
  ledge pair
6416
6486
  \`\`\`
6417
6487
 
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.
6488
+ 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
6489
 
6420
6490
  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
6491
 
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.
6492
+ 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
6493
 
6424
6494
  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
6495
 
@@ -6431,31 +6501,20 @@ A code never replaces a host key the phone already has. When Ledge has a differe
6431
6501
 
6432
6502
  ## Set up a server
6433
6503
 
6434
- I don't have a server yet opens "Set up a server", which shows the commands that make a machine a Ledge server. Choose Linux or Mac above them. On Linux:
6504
+ I don't have a server yet opens "Set up a server", which shows the two commands that make a machine a Ledge server, on Linux or a Mac:
6435
6505
 
6436
6506
  \`\`\`sh norun
6437
- curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
6438
- sudo BUN_INSTALL=/usr/local bun add -g ledge-server
6439
- ledge pair
6440
- \`\`\`
6441
-
6442
- On a Mac:
6443
-
6444
- \`\`\`sh norun
6445
- curl -fsSL https://bun.sh/install | bash
6446
- echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.zshenv
6447
- source ~/.zshenv
6448
- bun add -g ledge-server
6449
- ledge pair
6507
+ curl -fsSL https://ledge.sh/server.sh | sh
6508
+ ~/.ledge/.server/bin/ledge pair
6450
6509
  \`\`\`
6451
6510
 
6452
- Run them in a terminal on that machine, signed in as the account the phone should use. They install Bun and the server where a command run over ssh can find them, which on Linux needs \`sudo\` and on a Mac needs the \`~/.zshenv\` line instead. The last command prints a pairing code for that account, and Scan the pairing code on the same screen reads it.
6511
+ Run them in a terminal on that machine, signed in as the account the phone should use rather than root. The first installs the server in that account's home, where a command run over ssh finds it, and needs no \`sudo\`. The second prints a pairing code for that account, and Scan the pairing code on the same screen reads it. It names \`ledge\` by its full path because the PATH line the installer adds reaches only new terminals.
6453
6512
 
6454
6513
  Copy commands puts them on the phone's pasteboard. Share commands hands them to AirDrop, Messages, or any app that can carry them to a computer with a terminal open on that machine.
6455
6514
 
6456
- On a Mac, turn on Remote Login first, in System Settings under General, then Sharing. A Mac that runs the Ledge app needs only "Install Shell Command (ledge)" from the app's command palette in place of the first four commands: it puts \`ledge\` where the phone's ssh looks, pointing at the app's own copy, so the phone sees the same notes the app shows. \`ledge pair\` in a new terminal then prints the code.
6515
+ On a Mac, turn on Remote Login first, in System Settings under General, then Sharing. A Mac that runs the Ledge app needs only "Install Shell Command (ledge)" from the app's command palette in place of the first command: it puts \`ledge\` where the phone's ssh looks, pointing at the app's own copy, so the phone sees the same notes the app shows. The second command then prints the code.
6457
6516
 
6458
- The machine needs sshd running and an address the phone can reach. [[Keep Notes on a Remote Server]] has the details of the install, including a machine that already has Bun, and [[Tutorial: Set Up a Ledge Server]] walks through a fresh VPS.
6517
+ The machine needs sshd running and an address the phone can reach. [[Keep Notes on a Remote Server]] has the details of the install, including installing with Bun instead, and [[Tutorial: Set Up a Ledge Server]] walks through a fresh VPS.
6459
6518
 
6460
6519
  If you already have a server, Add an existing server at the bottom of the screen opens the form from "Pair by address", and Back from there returns to the first screen.
6461
6520
 
@@ -6465,13 +6524,19 @@ Add an existing server opens "Pair with a server", a form with three parts. The
6465
6524
 
6466
6525
  The first part is the machine: \`user@host\`, and a port when sshd is not on 22. A phone reads no \`~/.ssh/config\`, so write the address out.
6467
6526
 
6468
- The second is how to sign in. With A key, the default, the form shows a key line. On its first launch the phone makes a key of its own in the Secure Enclave, and that key never leaves the phone: there is no file to copy in or out. What leaves is the public half, as one line for the server's \`~/.ssh/authorized_keys\`:
6527
+ The second is how to sign in. With A key, the default, the form shows a command to run on the server. On its first launch the phone makes a key of its own in the Secure Enclave, and that key never leaves the phone: there is no file to copy in or out. What leaves is the public half, as one line for the server's \`~/.ssh/authorized_keys\`:
6469
6528
 
6470
6529
  \`\`\`
6471
6530
  restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ecdsa-sha2-nistp256 AAAA... ledge-iphone-3f2a91c0
6472
6531
  \`\`\`
6473
6532
 
6474
- Copy line puts it on the phone's pasteboard. Share line hands it to AirDrop, Messages, or any app that can carry it to a machine with a shell on the server, which is where the pasteboard on a phone falls short. Add it to \`~/.ssh/authorized_keys\` there. The comment at the end names the phone, so the line is easy to find again when you want to revoke it.
6533
+ The command adds that line to the file, creating \`~/.ssh\` first if the account has none:
6534
+
6535
+ \`\`\`sh norun
6536
+ mkdir -p ~/.ssh && chmod 700 ~/.ssh && printf '\\n%s\\n' 'restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ecdsa-sha2-nistp256 AAAA... ledge-iphone-3f2a91c0' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
6537
+ \`\`\`
6538
+
6539
+ Run it on the server, signed in as the account Ledge uses: over ssh from a Mac, or in the provider's web console for a new VPS. Copy command puts it on the phone's pasteboard. Share command hands it to AirDrop, Messages, or any app that can carry it to that terminal, which is where the pasteboard on a phone falls short. The comment at the end of the line names the phone, so the line is easy to find again when you want to revoke it.
6475
6540
 
6476
6541
  The line arrives already restricted, in the way "Restrict the key to Ledge" on [[Keep Notes on a Remote Server]] describes: the phone's key can speak Ledge's protocol and nothing else. It looks for \`ledge\` in \`~/.ledge/.server/bin\` first and then on the PATH an incoming ssh gets, so a server installed in either place starts ("Check that ssh can find the server" on the same page).
6477
6542
 
@@ -6481,7 +6546,7 @@ Ledge adds the server only once \`ledge serve\` answers there. On a machine wher
6481
6546
 
6482
6547
  ## Sign in with a password instead
6483
6548
 
6484
- Choose "A password" under Sign in with and type the password for that account. The phone keeps it in its own keychain, and no key line has to be installed.
6549
+ Choose "A password" under Sign in with and type the password for that account. The eye button at the end of the field shows what you typed, to check it before connecting. The phone keeps the password in its own keychain, and no key line has to be installed.
6485
6550
 
6486
6551
  The trade-off is the one described on [[Keep Notes on a Remote Server]]: a password reaches a fresh machine today, and a key is the better long-term answer. A server with \`PasswordAuthentication no\` refuses it.
6487
6552
 
@@ -6497,7 +6562,7 @@ Removing the last server returns the phone to the first screen. Deleting the app
6497
6562
 
6498
6563
  ## More than one server
6499
6564
 
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.
6565
+ 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 command that installs the phone's key where a Mac's shows a key path, with Share Command beside Copy Command.
6501
6566
 
6502
6567
  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
6568
 
@@ -7117,7 +7182,7 @@ They combine: a synced drive for the always-on workspaces, a git repo for the on
7117
7182
  `;
7118
7183
 
7119
7184
  // docs/user/20-tutorial-set-up-a-ledge-server.md
7120
- var _20_tutorial_set_up_a_ledge_server_default = '# Tutorial: Set Up a Ledge Server\n\nTurn a fresh Linux VPS into a Ledge server: an account for Ledge, the server package, a key that can do nothing but Ledge, and an sshd that ignores everyone else.\n\nThis builds on [[Keep Notes on a Remote Server]], which is the reference for every step here. The commands assume Debian or Ubuntu. Any Linux with glibc 2.29 or newer works, so substitute your package manager on anything else.\n\nTwo accounts appear throughout. `you@vps` is the account your provider gave you, which can `sudo`. `ledge@vps` is the account you create in step 1, which cannot.\n\n## 1. Create an account for Ledge\n\nOn the VPS, as your own account:\n\n```sh norun\nsudo adduser --disabled-password --gecos "" ledge\n```\n\nThe account has no password and no `sudo`. Everything Ledge does on this machine runs as this account: the server, the shells, and every block in every note. A key for it that is ever stolen cannot become root.\n\nIf your notes need `sudo`, that is a decision for later, made with `visudo` and as narrow as you can make it.\n\n`adduser` gives the account bash as its login shell, which is one of the two shells Ledge runs blocks in.\n\n## 2. Make a key on your Mac\n\nIn a terminal on your Mac:\n\n```sh norun\nssh-keygen -t ed25519 -f ~/.ssh/ledge -C ledge@laptop\ncat ~/.ssh/ledge.pub\n```\n\nLeave the key\'s passphrase empty, or use one your ssh agent already holds. Ledge\'s ssh runs with no terminal attached, so a passphrase it would have to type at a prompt never gets typed. This is about the key file only: signing in with the account\'s password is a choice on the form, and [[Keep Notes on a Remote Server]] covers it. This tutorial uses a key so that step 8 can turn passwords off.\n\nCopy the printed line, then put it on the VPS as the new account\'s only key. As your own account there, with the line pasted in place of the placeholder:\n\n```sh norun\nsudo install -d -m 700 -o ledge -g ledge /home/ledge/.ssh\necho \'ssh-ed25519 AAAA... ledge@laptop\' | sudo tee /home/ledge/.ssh/authorized_keys\nsudo chown ledge:ledge /home/ledge/.ssh/authorized_keys\nsudo chmod 600 /home/ledge/.ssh/authorized_keys\n```\n\nThe line goes in unrestricted for now. Step 7 restricts it, once you know the server works.\n\n## 3. Install the server\n\nStill on the VPS, as your own account:\n\n```sh norun\ncurl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash\nsudo BUN_INSTALL=/usr/local bun add -g ledge-server\n```\n\nBoth commands carry `BUN_INSTALL=/usr/local`. Bun puts global commands beside itself, and `/usr/local/bin` is on the short PATH an incoming ssh gets. Without the variable, the server lands in a home directory that ssh never searches.\n\nNothing else needs installing and no service needs starting. Ledge starts the server over ssh when it connects, and the server exits a minute after the last device leaves, unless a block is still running.\n\n## 4. Check that ssh can find it\n\nFrom your Mac, as the new account, with the new key:\n\n```sh norun\nssh -i ~/.ssh/ledge ledge@vps \'command -v ledge; command -v bun\'\n```\n\nTwo paths printed means the machine is ready. Keep the first one; step 7 needs it.\n\nNothing printed means Bun was already installed for one user before you started, and its commands are in a directory ssh does not search. [[Keep Notes on a Remote Server]] shows the two symlinks that fix it.\n\n## 5. Add the server in Ledge\n\nRun "Notes On\u2026" from the command palette, choose Add, and fill in the form:\n\n| Field | Value |\n| --- | --- |\n| Name | Whatever you want the connection bar to say |\n| SSH destination | `ledge@vps` |\n| Port | Blank |\n| Sign in with | A key |\n| Key | `~/.ssh/ledge` |\n\nLedge fetches the machine\'s host key and shows its fingerprint. Get the same fingerprint from the machine itself, in your terminal on the VPS:\n\n```sh norun\nssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub\n```\n\nChoose "It Matches, Add" when the two agree. Ledge pins the key and refuses any future connection from that address that presents a different one.\n\n## 6. Try it\n\nThe connection bar now names the server. Press \u2318N, and the note you create is a file on the VPS. Give it one block:\n\n````\n```sh\nhostname; whoami\n```\n````\n\n\u2318\u21A9 prints the VPS\'s hostname and `ledge`. The block ran on the server, as the account you made, and the note never left it.\n\n## 7. Restrict the key to Ledge\n\nEdit `/home/ledge/.ssh/authorized_keys` on the VPS and put a prefix in front of the key, using the path step 4 printed:\n\n```\nrestrict,command="/usr/local/bin/ledge serve" ssh-ed25519 AAAA... ledge@laptop\n```\n\nThat key can now speak Ledge\'s protocol and nothing else: no shell, no port forwarding, no file copying. sshd runs the named command whatever the client asks for, so the terminal check in step 4 stops working for this key. That is expected. Your own account is the one for terminals.\n\nThe connection you already have keeps working. Ledge\'s next connection, at the next launch or after a drop, uses the restricted line.\n\n## 8. Turn off passwords in sshd\n\nA Ledge server runs whatever its notes say, so sshd should answer keys and nothing else. Create `/etc/ssh/sshd_config.d/10-ledge.conf`:\n\n```\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin prohibit-password\n```\n\nThe `10-` matters. sshd keeps the first value it reads for a setting, and it reads this directory in name order. Cloud images ship a `50-cloud-init.conf` that turns passwords on, and a file named after it would lose.\n\nYour own account has to sign in with a key from now on. The provider usually installed one when it created the VPS, and this line from your Mac says whether it did:\n\n```sh norun\nssh -o PasswordAuthentication=no you@vps true\n```\n\nIf it asks for a password, put a key on that account first, with `ssh-copy-id`.\n\nThen check the configuration and reload, keeping your current terminal open until a second one has logged in:\n\n```sh norun\nsudo sshd -t && sudo systemctl reload ssh\n```\n\n## 9. Ban repeated guesses with fail2ban\n\nKeys-only sshd refuses every guess, but a box on the public internet still receives thousands of them a day, and each one costs a log line and a connection slot. fail2ban blocks an address after a few failures.\n\n```sh norun\nsudo apt-get install -y fail2ban\n```\n\nCreate `/etc/fail2ban/jail.local`:\n\n```ini\n[sshd]\nenabled = true\nbackend = systemd\nmaxretry = 5\nbantime = 1h\n```\n\n`backend = systemd` reads sshd\'s log from the journal. Debian 12, Ubuntu 24.04, and anything newer ship without a text `auth.log`, and fail2ban without this line fails to start on them.\n\n```sh norun\nsudo systemctl enable --now fail2ban\nsudo fail2ban-client status sshd\n```\n\nThe second command prints the jail\'s counts. Ledge never trips it: it connects with a key sshd accepts, and reconnects the same way.\n\n## 10. Close every other port\n\nOnly sshd needs to be reachable. Allow it, then turn the firewall on:\n\n```sh norun\nsudo apt-get install -y ufw\nsudo ufw allow 22/tcp\nsudo ufw enable\nsudo ufw status\n```\n\nUbuntu has `ufw` already, and the install line does nothing there.\n\nIf the VPS is on a tailnet or VPN, allow ssh from that interface alone and drop the public rule:\n\n```sh norun\nsudo ufw allow in on tailscale0 to any port 22\nsudo ufw delete allow 22/tcp\n```\n\nThen use the tailnet address as the SSH destination in Ledge. A server nobody else can reach has nothing for fail2ban to do, and the previous step does no harm.\n\n## 11. Keep it patched\n\nSecurity updates for the operating system should install themselves:\n\n```sh norun\nsudo apt-get install -y unattended-upgrades\nsudo dpkg-reconfigure -plow unattended-upgrades\n```\n\nAnswer Yes. Ubuntu ships with this on, and the two commands confirm it.\n\nThe server itself is a package, and updating it is the install line again:\n\n```sh norun\nsudo BUN_INSTALL=/usr/local bun add -g ledge-server@latest\n```\n\nA connection between an app and a server that cannot understand each other is refused with a sentence naming which end to update, so a version that falls behind is reported rather than guessed at.\n\n## Where to go next\n\n- **Back it up.** The notes now live on one disk that belongs to one provider. [[Tutorial: Back Up Your Notes to S3]] puts an encrypted copy in a bucket every hour, with one `ledge backup setup`.\n- **Add your phone.** Its pairing screen hands you a line for this same `authorized_keys`, already restricted ([[Ledge on Your Phone]]).\n- **Install what your notes run.** `git`, a language, a cloud CLI: whatever a block on this machine needs, installed as your own account with `apt-get`.\n- **Reach other machines from it.** A note on the VPS can carry `host: prod`, and the VPS makes that ssh connection with a key in `/home/ledge/.ssh` ([[Run Code on Remote Hosts]]).\n';
7185
+ var _20_tutorial_set_up_a_ledge_server_default = '# Tutorial: Set Up a Ledge Server\n\nTurn a fresh Linux VPS into a Ledge server: an account for Ledge, the server package, a key that can do nothing but Ledge, and an sshd that ignores everyone else.\n\nThis builds on [[Keep Notes on a Remote Server]], which is the reference for every step here. The commands assume Debian or Ubuntu. Any Linux with glibc 2.29 or newer works, so substitute your package manager on anything else.\n\nTwo accounts appear throughout. `you@vps` is the account your provider gave you, which can `sudo`. `ledge@vps` is the account you create in step 1, which cannot.\n\n## 1. Create an account for Ledge\n\nOn the VPS, as your own account:\n\n```sh norun\nsudo adduser --disabled-password --gecos "" ledge\n```\n\nThe account has no password and no `sudo`. Everything Ledge does on this machine runs as this account: the server, the shells, and every block in every note. A key for it that is ever stolen cannot become root.\n\nIf your notes need `sudo`, that is a decision for later, made with `visudo` and as narrow as you can make it.\n\n`adduser` gives the account bash as its login shell, which is one of the two shells Ledge runs blocks in.\n\n## 2. Make a key on your Mac\n\nIn a terminal on your Mac:\n\n```sh norun\nssh-keygen -t ed25519 -f ~/.ssh/ledge -C ledge@laptop\ncat ~/.ssh/ledge.pub\n```\n\nLeave the key\'s passphrase empty, or use one your ssh agent already holds. Ledge\'s ssh runs with no terminal attached, so a passphrase it would have to type at a prompt never gets typed. This is about the key file only: signing in with the account\'s password is a choice on the form, and [[Keep Notes on a Remote Server]] covers it. This tutorial uses a key so that step 8 can turn passwords off.\n\nCopy the printed line, then put it on the VPS as the new account\'s only key. As your own account there, with the line pasted in place of the placeholder:\n\n```sh norun\nsudo install -d -m 700 -o ledge -g ledge /home/ledge/.ssh\necho \'ssh-ed25519 AAAA... ledge@laptop\' | sudo tee /home/ledge/.ssh/authorized_keys\nsudo chown ledge:ledge /home/ledge/.ssh/authorized_keys\nsudo chmod 600 /home/ledge/.ssh/authorized_keys\n```\n\nThe line goes in unrestricted for now. Step 7 restricts it, once you know the server works.\n\n## 3. Install the server\n\nStill on the VPS, as your own account, install the server into the new account\'s home:\n\n```sh norun\ncurl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh\n```\n\nThe installer runs as `ledge` and puts the server, with a Bun of its own, in `/home/ledge/.ledge/.server`. It refuses to run as root, because the server belongs to the account Ledge signs in to, and `sudo -iu ledge` is how your own account runs it as that one.\n\nNothing else needs installing and no service needs starting. Ledge starts the server over ssh when it connects, and the server exits a minute after the last device leaves, unless a block is still running.\n\n## 4. Check that ssh can find it\n\nFrom your Mac, as the new account, with the new key:\n\n```sh norun\nssh -i ~/.ssh/ledge ledge@vps \'PATH=$HOME/.ledge/.server/bin:$PATH command -v ledge\'\n```\n\nA path printed means the machine is ready. This is the same lookup Ledge makes when it connects.\n\nNothing printed means the installer ran as a different account. Run step 3 again exactly as written.\n\n## 5. Add the server in Ledge\n\nRun "Notes On\u2026" from the command palette, choose Add, and fill in the form:\n\n| Field | Value |\n| --- | --- |\n| Name | Whatever you want the connection bar to say |\n| SSH destination | `ledge@vps` |\n| Port | Blank |\n| Sign in with | A key |\n| Key | `~/.ssh/ledge` |\n\nLedge fetches the machine\'s host key and shows its fingerprint. Get the same fingerprint from the machine itself, in your terminal on the VPS:\n\n```sh norun\nssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub\n```\n\nChoose "It Matches, Add" when the two agree. Ledge pins the key and refuses any future connection from that address that presents a different one.\n\n## 6. Try it\n\nThe connection bar now names the server. Press \u2318N, and the note you create is a file on the VPS. Give it one block:\n\n````\n```sh\nhostname; whoami\n```\n````\n\n\u2318\u21A9 prints the VPS\'s hostname and `ledge`. The block ran on the server, as the account you made, and the note never left it.\n\n## 7. Restrict the key to Ledge\n\nEdit `/home/ledge/.ssh/authorized_keys` on the VPS and put a prefix in front of the key:\n\n```\nrestrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ssh-ed25519 AAAA... ledge@laptop\n```\n\nThat key can now speak Ledge\'s protocol and nothing else: no shell, no port forwarding, no file copying. sshd runs the named command whatever the client asks for, so the terminal check in step 4 stops working for this key. That is expected. Your own account is the one for terminals.\n\nThe connection you already have keeps working. Ledge\'s next connection, at the next launch or after a drop, uses the restricted line.\n\n## 8. Turn off passwords in sshd\n\nA Ledge server runs whatever its notes say, so sshd should answer keys and nothing else. Create `/etc/ssh/sshd_config.d/10-ledge.conf`:\n\n```\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin prohibit-password\n```\n\nThe `10-` matters. sshd keeps the first value it reads for a setting, and it reads this directory in name order. Cloud images ship a `50-cloud-init.conf` that turns passwords on, and a file named after it would lose.\n\nYour own account has to sign in with a key from now on. The provider usually installed one when it created the VPS, and this line from your Mac says whether it did:\n\n```sh norun\nssh -o PasswordAuthentication=no you@vps true\n```\n\nIf it asks for a password, put a key on that account first, with `ssh-copy-id`.\n\nThen check the configuration and reload, keeping your current terminal open until a second one has logged in:\n\n```sh norun\nsudo sshd -t && sudo systemctl reload ssh\n```\n\n## 9. Ban repeated guesses with fail2ban\n\nKeys-only sshd refuses every guess, but a box on the public internet still receives thousands of them a day, and each one costs a log line and a connection slot. fail2ban blocks an address after a few failures.\n\n```sh norun\nsudo apt-get install -y fail2ban\n```\n\nCreate `/etc/fail2ban/jail.local`:\n\n```ini\n[sshd]\nenabled = true\nbackend = systemd\nmaxretry = 5\nbantime = 1h\n```\n\n`backend = systemd` reads sshd\'s log from the journal. Debian 12, Ubuntu 24.04, and anything newer ship without a text `auth.log`, and fail2ban without this line fails to start on them.\n\n```sh norun\nsudo systemctl enable --now fail2ban\nsudo fail2ban-client status sshd\n```\n\nThe second command prints the jail\'s counts. Ledge never trips it: it connects with a key sshd accepts, and reconnects the same way.\n\n## 10. Close every other port\n\nOnly sshd needs to be reachable. Allow it, then turn the firewall on:\n\n```sh norun\nsudo apt-get install -y ufw\nsudo ufw allow 22/tcp\nsudo ufw enable\nsudo ufw status\n```\n\nUbuntu has `ufw` already, and the install line does nothing there.\n\nIf the VPS is on a tailnet or VPN, allow ssh from that interface alone and drop the public rule:\n\n```sh norun\nsudo ufw allow in on tailscale0 to any port 22\nsudo ufw delete allow 22/tcp\n```\n\nThen use the tailnet address as the SSH destination in Ledge. A server nobody else can reach has nothing for fail2ban to do, and the previous step does no harm.\n\n## 11. Keep it patched\n\nSecurity updates for the operating system should install themselves:\n\n```sh norun\nsudo apt-get install -y unattended-upgrades\nsudo dpkg-reconfigure -plow unattended-upgrades\n```\n\nAnswer Yes. Ubuntu ships with this on, and the two commands confirm it.\n\nThe server updates with the install line from step 3, run again:\n\n```sh norun\ncurl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh\n```\n\nA connection between an app and a server that cannot understand each other is refused with a sentence naming which end to update, so a version that falls behind is reported rather than guessed at.\n\n## Where to go next\n\n- **Back it up.** The notes now live on one disk that belongs to one provider. [[Tutorial: Back Up Your Notes to S3]] puts an encrypted copy in a bucket every hour, with one `ledge backup setup`.\n- **Add your phone.** Its pairing screen hands you a line for this same `authorized_keys`, already restricted ([[Ledge on Your Phone]]).\n- **Install what your notes run.** `git`, a language, a cloud CLI: whatever a block on this machine needs, installed as your own account with `apt-get`.\n- **Reach other machines from it.** A note on the VPS can carry `host: prod`, and the VPS makes that ssh connection with a key in `/home/ledge/.ssh` ([[Run Code on Remote Hosts]]).\n';
7121
7186
 
7122
7187
  // docs/user/21-tutorial-back-up-your-notes-to-s3.md
7123
7188
  var _21_tutorial_back_up_your_notes_to_s3_default = `# Tutorial: Back Up Your Notes to S3
@@ -7158,9 +7223,9 @@ It asks for the endpoint, the bucket, the access key ID, and the secret. Then it
7158
7223
  | Credentials | Writes the four values and a generated restic password to the \`backup\` profile, \`~/.config/ledge/profiles/backup.env\`, readable by this account alone ([[Profiles and Secrets]]). |
7159
7224
  | Repository | Creates the restic repository in the bucket. |
7160
7225
  | First backup | Backs up everything \`ledge backup paths\` lists: the app home, every folder attached from elsewhere on the machine, and the profiles. |
7161
- | Password | Prints the password on its last line. |
7226
+ | Password | Prints the password, the one thing \`setup\` writes to stdout. |
7162
7227
 
7163
- The password is what encrypts the backup, and it is the only key. Copy it somewhere that is not this machine, such as a password manager. A restore starts on a machine with nothing on it, and a password stored only inside the backup is a backup you cannot open.
7228
+ The password is what encrypts the backup, and it is the only key. Nothing can be restored without it. Copy it somewhere that is not this machine, such as a password manager.
7164
7229
 
7165
7230
  ## 3. Leave it running
7166
7231
 
@@ -7171,11 +7236,23 @@ On a Mac, the server is up while the app is open and for a minute after it close
7171
7236
  Nothing else needs installing: no timer, no unit file, no line in a crontab. One line is worth adding on a server where notes are written while no device is connected, by the \`ledge\` command or by an agent, since those do not start the server:
7172
7237
 
7173
7238
  \`\`\`sh norun
7174
- 0 * * * * /usr/local/bin/ledge backup now
7239
+ 0 * * * * $HOME/.ledge/.server/bin/ledge backup now
7175
7240
  \`\`\`
7176
7241
 
7177
7242
  \`ledge backup now\` takes a backup at any time and is safe to run beside the schedule.
7178
7243
 
7244
+ Old snapshots are thinned after each backup, and what survives is fixed:
7245
+
7246
+ | Kept | For |
7247
+ | --- | --- |
7248
+ | The ten newest snapshots | however close together they were taken |
7249
+ | One an hour | a day |
7250
+ | One a day | a month |
7251
+ | One a week | a quarter |
7252
+ | One a month | two years |
7253
+
7254
+ Only the snapshots Ledge took are thinned, so a bucket shared with another tool's backups keeps those whatever this policy says.
7255
+
7179
7256
  ## 4. Check on it
7180
7257
 
7181
7258
  \`\`\`sh norun
@@ -7211,13 +7288,13 @@ On a fresh machine with Ledge installed, the app on a Mac or the server on a VPS
7211
7288
  ledge backup setup --existing
7212
7289
  \`\`\`
7213
7290
 
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:
7291
+ 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
7292
 
7216
7293
  \`\`\`sh norun
7217
7294
  ledge backup restore --in-place
7218
7295
  \`\`\`
7219
7296
 
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.
7297
+ 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
7298
 
7222
7299
  ## Run restic yourself
7223
7300
 
@@ -9696,9 +9773,9 @@ function sizeOf(path) {
9696
9773
  }
9697
9774
  var logPath = LOG_PATH;
9698
9775
  var prevPath = PREV_LOG_PATH;
9699
- function logToFile(basename7) {
9700
- logPath = join15(LOG_DIR, `${basename7}.log`);
9701
- prevPath = join15(LOG_DIR, `${basename7}.previous.log`);
9776
+ function logToFile(basename) {
9777
+ logPath = join15(LOG_DIR, `${basename}.log`);
9778
+ prevPath = join15(LOG_DIR, `${basename}.previous.log`);
9702
9779
  }
9703
9780
  function rotate() {
9704
9781
  try {
@@ -9729,12 +9806,12 @@ function write(source, level, args) {
9729
9806
  append(formatLine(new Date, source, level, args));
9730
9807
  }
9731
9808
  var patched = false;
9732
- function startLogging(basename7) {
9809
+ function startLogging(basename) {
9733
9810
  if (patched)
9734
9811
  return;
9735
9812
  patched = true;
9736
- if (basename7)
9737
- logToFile(basename7);
9813
+ if (basename)
9814
+ logToFile(basename);
9738
9815
  rotate();
9739
9816
  const levels = [
9740
9817
  ["log", "info"],
@@ -9768,13 +9845,13 @@ function relevantChange(filename) {
9768
9845
  if (filename === null)
9769
9846
  return true;
9770
9847
  const segments = filename.split("/");
9771
- if (segments.slice(0, -1).some((s2) => s2.startsWith(".")))
9848
+ if (segments.slice(0, -1).some((s) => s.startsWith(".")))
9772
9849
  return false;
9773
9850
  return /\.md(\.|$)/i.test(segments[segments.length - 1]);
9774
9851
  }
9775
9852
  var watchers = new Map;
9776
- function syncWatchers(roots2, onChange) {
9777
- const want = new Set(roots2);
9853
+ function syncWatchers(roots, onChange) {
9854
+ const want = new Set(roots);
9778
9855
  for (const [root, w] of watchers) {
9779
9856
  if (want.has(root))
9780
9857
  continue;
@@ -9834,14 +9911,14 @@ function bundledBun(execPath) {
9834
9911
  return /(^|\/)bun$/.test(execPath) ? execPath : "";
9835
9912
  }
9836
9913
  function runnerFor(id, lang, code, interpreters, bunPath, remote = false) {
9837
- const key2 = (lang ?? "").toLowerCase();
9838
- const interpreter = interpreters[key2];
9914
+ const key = (lang ?? "").toLowerCase();
9915
+ const interpreter = interpreters[key];
9839
9916
  if (!interpreter) {
9840
- const path2 = `/tmp/ledge-run-${id}.sh`;
9841
- const command = remote ? remoteWrite(code, path2, `source ${path2}`) : `source ${path2}`;
9842
- return { kind: "shell", path: path2, contents: code, command, remote };
9917
+ const path = `/tmp/ledge-run-${id}.sh`;
9918
+ const command = remote ? remoteWrite(code, path, `source ${path}`) : `source ${path}`;
9919
+ return { kind: "shell", path, contents: code, command, remote };
9843
9920
  }
9844
- const ext = EXT[key2] ?? (key2.replace(/[^a-z0-9]/g, "") || "txt");
9921
+ const ext = EXT[key] ?? (key.replace(/[^a-z0-9]/g, "") || "txt");
9845
9922
  const path = `/tmp/ledge-run-${id}.${ext}`;
9846
9923
  const contents = ext === "php" && !/^\s*<\?/.test(code) ? `<?php
9847
9924
  ${code}` : code;
@@ -9865,8 +9942,8 @@ function hostGlobMatches(pattern, host) {
9865
9942
  const rx = pattern.split("*").map(escapeRegex).join(".*");
9866
9943
  return new RegExp(`^${rx}$`).test(host);
9867
9944
  }
9868
- function escapeRegex(s2) {
9869
- return s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9945
+ function escapeRegex(s) {
9946
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9870
9947
  }
9871
9948
 
9872
9949
  // src/bun/remoteSpawn.ts
@@ -9887,11 +9964,11 @@ function buildRemoteSpawn(host, kind, params, warn) {
9887
9964
  const parts = [];
9888
9965
  if (params?.cwd)
9889
9966
  parts.push(remoteCd(params.cwd));
9890
- for (const [key2, value] of Object.entries(params?.env ?? {})) {
9891
- if (isEnvName(key2) && typeof value === "string") {
9892
- parts.push(`export ${key2}=${shellQuote(value)}`);
9967
+ for (const [key, value] of Object.entries(params?.env ?? {})) {
9968
+ if (isEnvName(key) && typeof value === "string") {
9969
+ parts.push(`export ${key}=${shellQuote(value)}`);
9893
9970
  } else {
9894
- warn(`ignoring unusable env entry "${key2}"`);
9971
+ warn(`ignoring unusable env entry "${key}"`);
9895
9972
  }
9896
9973
  }
9897
9974
  if (kind === "inline") {
@@ -9983,7 +10060,10 @@ async function createServer(deps) {
9983
10060
  const { push } = deps;
9984
10061
  const settings = await loadSettings();
9985
10062
  await loadWorkspaces();
9986
- await ensureDefault();
10063
+ const first = await ensureDefault();
10064
+ if (first) {
10065
+ await createNote(first, WELCOME_DOC).catch((err) => console.warn("[server] could not write the welcome note", err));
10066
+ }
9987
10067
  await syncDocs();
9988
10068
  await loadVault();
9989
10069
  const shellEnv = { ...process.env, TERM: "xterm-256color" };
@@ -10116,9 +10196,9 @@ async function createServer(deps) {
10116
10196
  watch2(APP_HOME, (_event, filename) => {
10117
10197
  if (filename !== requestName)
10118
10198
  return;
10119
- takeOpenRequest().then((open2) => {
10120
- if (open2 !== null)
10121
- push.all.openExternal(open2);
10199
+ takeOpenRequest().then((open) => {
10200
+ if (open !== null)
10201
+ push.all.openExternal(open);
10122
10202
  });
10123
10203
  });
10124
10204
  } catch (err) {
@@ -10481,9 +10561,9 @@ async function createServer(deps) {
10481
10561
  return { ok: true };
10482
10562
  },
10483
10563
  openRequestTake: async () => {
10484
- const open2 = await takeOpenRequest();
10564
+ const open = await takeOpenRequest();
10485
10565
  startOpenRequestWatcher();
10486
- return { open: open2 };
10566
+ return { open };
10487
10567
  },
10488
10568
  logAppend: async ({ level, text }) => {
10489
10569
  write("view", level, [text.slice(0, LOG_TEXT_CAP)]);
@@ -10764,10 +10844,10 @@ function restoreBinary(payload, path, bytes) {
10764
10844
  }
10765
10845
  function walk(payload, path) {
10766
10846
  let at = payload;
10767
- for (const key2 of path) {
10847
+ for (const key of path) {
10768
10848
  if (typeof at !== "object" || at === null)
10769
10849
  return null;
10770
- at = at[key2];
10850
+ at = at[key];
10771
10851
  }
10772
10852
  return { value: at };
10773
10853
  }
@@ -10942,8 +11022,8 @@ function names(v) {
10942
11022
  return v.filter((n) => typeof n === "string").slice(0, MAX_DECLARED_NAMES);
10943
11023
  }
10944
11024
  var MAX_DECLARED_NAMES = 512;
10945
- function opt(key2, v) {
10946
- return typeof v === "string" ? { [key2]: v } : {};
11025
+ function opt(key, v) {
11026
+ return typeof v === "string" ? { [key]: v } : {};
10947
11027
  }
10948
11028
  function bin(v) {
10949
11029
  return isId(v) ? { bin: v } : {};
@@ -10990,15 +11070,15 @@ function concat2(a, b) {
10990
11070
  out.set(b, a.length);
10991
11071
  return out;
10992
11072
  }
10993
- function writeMessage(write2, msg, kind, method) {
11073
+ function writeMessage(write, msg, kind, method) {
10994
11074
  const path = binaryPath(kind, method);
10995
11075
  const body = msg.t === "req" ? msg.p : msg.t === "res" ? msg.r : msg.t === "push" ? msg.p : null;
10996
11076
  const hoisted = path && body !== null ? hoistBinary(body, path) : null;
10997
11077
  if (!hoisted)
10998
- return write2(encodeControl(msg));
10999
- const bin2 = nextBinaryId();
11000
- write2(encodeBinary(bin2, hoisted.bytes));
11001
- write2(encodeControl(msg.t === "req" ? { ...msg, p: hoisted.payload, bin: bin2 } : msg.t === "res" ? { ...msg, r: hoisted.payload, bin: bin2 } : { ...msg, p: hoisted.payload, bin: bin2 }));
11078
+ return write(encodeControl(msg));
11079
+ const bin = nextBinaryId();
11080
+ write(encodeBinary(bin, hoisted.bytes));
11081
+ write(encodeControl(msg.t === "req" ? { ...msg, p: hoisted.payload, bin } : msg.t === "res" ? { ...msg, r: hoisted.payload, bin } : { ...msg, p: hoisted.payload, bin }));
11002
11082
  }
11003
11083
  var binaryId = 0;
11004
11084
  function nextBinaryId() {
@@ -11008,19 +11088,19 @@ function nextBinaryId() {
11008
11088
 
11009
11089
  class BinaryHolder {
11010
11090
  held = null;
11011
- hold(frame2) {
11091
+ hold(frame) {
11012
11092
  if (this.held)
11013
11093
  throw new WireError("the peer sent two binary frames with no control frame between them");
11014
- this.held = { id: frame2.id, bytes: frame2.bytes };
11094
+ this.held = { id: frame.id, bytes: frame.bytes };
11015
11095
  }
11016
11096
  claim(msg, kind, method) {
11017
- const bin2 = msg.t === "req" || msg.t === "res" || msg.t === "push" ? msg.bin : undefined;
11097
+ const bin = msg.t === "req" || msg.t === "res" || msg.t === "push" ? msg.bin : undefined;
11018
11098
  const body = msg.t === "req" ? msg.p : msg.t === "res" ? msg.r : msg.t === "push" ? msg.p : null;
11019
- if (bin2 === undefined)
11099
+ if (bin === undefined)
11020
11100
  return body;
11021
11101
  const held = this.held;
11022
11102
  this.held = null;
11023
- if (!held || held.id !== bin2)
11103
+ if (!held || held.id !== bin)
11024
11104
  throw new WireError("the peer claimed a binary frame that did not arrive");
11025
11105
  const path = binaryPath(kind, method);
11026
11106
  if (!path)
@@ -11102,7 +11182,7 @@ function serverConnection(duplex, opts) {
11102
11182
  const silentMs = opts.silentMs ?? SILENT_MS2;
11103
11183
  let heardFromClient = false;
11104
11184
  let stopWatching = null;
11105
- const decoder2 = new FrameDecoder;
11185
+ const decoder = new FrameDecoder;
11106
11186
  const incoming = new BinaryHolder;
11107
11187
  let handlers = null;
11108
11188
  let greeted = false;
@@ -11110,10 +11190,10 @@ function serverConnection(duplex, opts) {
11110
11190
  let peerDevice = "";
11111
11191
  let peerLabel = "";
11112
11192
  let peerHold = 0;
11113
- let open2 = true;
11193
+ let open = true;
11114
11194
  const waiting = [];
11115
11195
  let settle;
11116
- const closed = new Promise((resolve11) => settle = resolve11);
11196
+ const closed = new Promise((resolve) => settle = resolve);
11117
11197
  function raw(bytes) {
11118
11198
  try {
11119
11199
  duplex.write(bytes);
@@ -11123,7 +11203,7 @@ function serverConnection(duplex, opts) {
11123
11203
  }
11124
11204
  }
11125
11205
  function send(msg, method = "") {
11126
- if (!open2)
11206
+ if (!open)
11127
11207
  return;
11128
11208
  if (msg.t !== "push" || msg.m !== "terminalOutput")
11129
11209
  flushOutput();
@@ -11133,11 +11213,11 @@ function serverConnection(duplex, opts) {
11133
11213
  raw(encodeControl(msg));
11134
11214
  }
11135
11215
  function close(why, back = false) {
11136
- if (!open2)
11216
+ if (!open)
11137
11217
  return;
11138
11218
  if (why !== undefined)
11139
11219
  send({ t: "bye", why, ...back ? { back: true } : {} });
11140
- open2 = false;
11220
+ open = false;
11141
11221
  stopWatching?.();
11142
11222
  stopWatching = null;
11143
11223
  stopCoalescing();
@@ -11176,7 +11256,7 @@ function serverConnection(duplex, opts) {
11176
11256
  heldBytes = 0;
11177
11257
  }
11178
11258
  function pushOutput(p) {
11179
- if (!open2)
11259
+ if (!open)
11180
11260
  return;
11181
11261
  const bytes = fromBase64(p.dataB64);
11182
11262
  if (bytes.length === 0)
@@ -11244,18 +11324,18 @@ function serverConnection(duplex, opts) {
11244
11324
  heardFromClient = true;
11245
11325
  let frames;
11246
11326
  try {
11247
- frames = decoder2.push(chunk);
11327
+ frames = decoder.push(chunk);
11248
11328
  } catch (err) {
11249
11329
  console.error("[wire]", err instanceof Error ? err.message : err);
11250
11330
  return close(err instanceof WireError ? err.message : "unreadable frame");
11251
11331
  }
11252
- for (const frame2 of frames) {
11332
+ for (const frame of frames) {
11253
11333
  try {
11254
- if (frame2.type === 1) {
11255
- incoming.hold(frame2);
11334
+ if (frame.type === 1) {
11335
+ incoming.hold(frame);
11256
11336
  continue;
11257
11337
  }
11258
- handle(parseControl(frame2.text));
11338
+ handle(parseControl(frame.text));
11259
11339
  if (!incoming.idle())
11260
11340
  throw new WireError("the peer sent bytes that no control frame claimed");
11261
11341
  } catch (err) {
@@ -11265,7 +11345,7 @@ function serverConnection(duplex, opts) {
11265
11345
  }
11266
11346
  };
11267
11347
  duplex.onClose = () => {
11268
- open2 = false;
11348
+ open = false;
11269
11349
  stopWatching?.();
11270
11350
  stopWatching = null;
11271
11351
  stopCoalescing();
@@ -11436,13 +11516,13 @@ function createOpLog(opts) {
11436
11516
  }
11437
11517
  }
11438
11518
  return {
11439
- run(key2, exec) {
11440
- const hit = seen.get(key2);
11519
+ run(key, exec) {
11520
+ const hit = seen.get(key);
11441
11521
  if (hit)
11442
11522
  return hit.result;
11443
11523
  const result = exec();
11444
11524
  result.catch(() => {});
11445
- seen.set(key2, { at: now(), result });
11525
+ seen.set(key, { at: now(), result });
11446
11526
  evict();
11447
11527
  return result;
11448
11528
  },
@@ -11451,7 +11531,7 @@ function createOpLog(opts) {
11451
11531
  }
11452
11532
 
11453
11533
  // src/shared/version.ts
11454
- var BUILD_VERSION = "0.0.2";
11534
+ var BUILD_VERSION = "0.1.0";
11455
11535
 
11456
11536
  // src/bun/daemon.ts
11457
11537
  var SOCKET_PATH = join16(APP_HOME, ".server.sock");
@@ -11482,10 +11562,10 @@ async function startDaemon(opts = {}) {
11482
11562
  const ops = createOpLog();
11483
11563
  const instance = crypto.randomUUID();
11484
11564
  const server = await createServer({ push });
11485
- let idleTimer2 = null;
11565
+ let idleTimer = null;
11486
11566
  let heldUntil = 0;
11487
11567
  let settleDone;
11488
- const done = new Promise((resolve11) => settleDone = resolve11);
11568
+ const done = new Promise((resolve) => settleDone = resolve);
11489
11569
  let stopped = false;
11490
11570
  const listener = Bun.listen({
11491
11571
  unix: socketPath,
@@ -11530,9 +11610,9 @@ async function startDaemon(opts = {}) {
11530
11610
  console.error("[daemon] could not write the pid file:", err);
11531
11611
  }
11532
11612
  function accept(io) {
11533
- if (idleTimer2) {
11534
- clearTimeout(idleTimer2);
11535
- idleTimer2 = null;
11613
+ if (idleTimer) {
11614
+ clearTimeout(idleTimer);
11615
+ idleTimer = null;
11536
11616
  }
11537
11617
  const greet = () => {
11538
11618
  const id = conn.client();
@@ -11562,15 +11642,15 @@ async function startDaemon(opts = {}) {
11562
11642
  }
11563
11643
  let leaving = false;
11564
11644
  function armIdleExit() {
11565
- if (stopped || idleTimer2 || leaving || idleMs <= 0)
11645
+ if (stopped || idleTimer || leaving || idleMs <= 0)
11566
11646
  return;
11567
11647
  const held = server.sessionsOpen() ? heldUntil - Date.now() : 0;
11568
11648
  const wait = Math.max(idleMs, held);
11569
11649
  if (wait !== idleMs) {
11570
11650
  console.error(`[daemon] holding sessions for ${wait >= 1e4 ? `${Math.round(wait / 1000)}s` : `${wait}ms`}`);
11571
11651
  }
11572
- idleTimer2 = setTimeout(async () => {
11573
- idleTimer2 = null;
11652
+ idleTimer = setTimeout(async () => {
11653
+ idleTimer = null;
11574
11654
  if (clients.size > 0)
11575
11655
  return;
11576
11656
  if (server.running())
@@ -11614,8 +11694,8 @@ async function startDaemon(opts = {}) {
11614
11694
  if (stopped)
11615
11695
  return;
11616
11696
  stopped = true;
11617
- if (idleTimer2)
11618
- clearTimeout(idleTimer2);
11697
+ if (idleTimer)
11698
+ clearTimeout(idleTimer);
11619
11699
  for (const conn of accepted) {
11620
11700
  if (clients.get(conn.client()) === conn)
11621
11701
  conn.close("this server is shutting down", true);
@@ -11673,9 +11753,9 @@ async function tryConnect(socketPath) {
11673
11753
  socket: {
11674
11754
  data: (_s, chunk) => io?.feed(new Uint8Array(chunk)),
11675
11755
  drain: () => out?.drain(),
11676
- end: (s2) => {
11756
+ end: (s) => {
11677
11757
  io?.finish();
11678
- s2.end();
11758
+ s.end();
11679
11759
  },
11680
11760
  close: () => io?.finish(),
11681
11761
  error: () => io?.finish()
@@ -11722,7 +11802,7 @@ var reader = null;
11722
11802
  var pending = "";
11723
11803
  async function readLine() {
11724
11804
  reader ??= Bun.stdin.stream().getReader();
11725
- const decoder2 = new TextDecoder;
11805
+ const decoder = new TextDecoder;
11726
11806
  for (;; ) {
11727
11807
  const nl = pending.indexOf(`
11728
11808
  `);
@@ -11737,7 +11817,7 @@ async function readLine() {
11737
11817
  pending = "";
11738
11818
  return line;
11739
11819
  }
11740
- pending += decoder2.decode(value, { stream: true });
11820
+ pending += decoder.decode(value, { stream: true });
11741
11821
  }
11742
11822
  }
11743
11823
  async function readHidden() {
@@ -11779,9 +11859,9 @@ async function ask(question, o = {}) {
11779
11859
  // src/bun/backup.ts
11780
11860
  import { join as join17 } from "path";
11781
11861
  function backupSet(input) {
11782
- const { appHome, profilesDir, roots: roots2, secrets } = input;
11862
+ const { appHome, profilesDir, roots, secrets } = input;
11783
11863
  const include = [appHome];
11784
- for (const root of roots2)
11864
+ for (const root of roots)
11785
11865
  if (!isInside(appHome, root))
11786
11866
  include.push(root);
11787
11867
  if (secrets && !include.some((p) => isInside(p, profilesDir)))
@@ -11820,9 +11900,9 @@ function backupProfileText(vars) {
11820
11900
  `# RESTIC_PASSWORD is the only key to the backup. Keep a copy somewhere else.`,
11821
11901
  ``
11822
11902
  ];
11823
- for (const [key2, value] of Object.entries(vars)) {
11903
+ for (const [key, value] of Object.entries(vars)) {
11824
11904
  const quoted = value !== value.trim() || /^["']/.test(value) ? `"${value.replace(/["\\]/g, "\\$&")}"` : value;
11825
- lines.push(`${key2}=${quoted}`);
11905
+ lines.push(`${key}=${quoted}`);
11826
11906
  }
11827
11907
  lines.push(``);
11828
11908
  return lines.join(`
@@ -11860,7 +11940,7 @@ function versionAtLeast(version, min) {
11860
11940
  var BACKUP_EVERY_MS = 60 * 60 * 1000;
11861
11941
  var PRUNE_EVERY_MS = 24 * 60 * 60 * 1000;
11862
11942
  var IDLE_EXIT_MIN_GAP_MS = 10 * 60 * 1000;
11863
- var KEEP = { hourly: 24, daily: 30, weekly: 12, monthly: 24 };
11943
+ var KEEP = { last: 10, hourly: 24, daily: 30, weekly: 12, monthly: 24 };
11864
11944
  var SNAPSHOT_TAG = "ledge";
11865
11945
  var EMPTY_STATE = {
11866
11946
  version: 1,
@@ -11881,7 +11961,7 @@ function parseState(text) {
11881
11961
  return {
11882
11962
  ...EMPTY_STATE,
11883
11963
  ...raw,
11884
- skipped: Array.isArray(raw.skipped) ? raw.skipped.filter((s2) => typeof s2?.root === "string" && typeof s2?.since === "string") : []
11964
+ skipped: Array.isArray(raw.skipped) ? raw.skipped.filter((s) => typeof s?.root === "string" && typeof s?.since === "string") : []
11885
11965
  };
11886
11966
  } catch {
11887
11967
  return EMPTY_STATE;
@@ -11889,7 +11969,7 @@ function parseState(text) {
11889
11969
  }
11890
11970
  function recordRun(prev, o) {
11891
11971
  const at = o.at.toISOString();
11892
- const since = new Map(prev.skipped.map((s2) => [s2.root, s2.since]));
11972
+ const since = new Map(prev.skipped.map((s) => [s.root, s.since]));
11893
11973
  return {
11894
11974
  version: 1,
11895
11975
  lastRun: at,
@@ -11911,6 +11991,9 @@ function isOverdue(state, now, every = BACKUP_EVERY_MS) {
11911
11991
  function pruneDue(state, now, every = PRUNE_EVERY_MS) {
11912
11992
  return !state.lastPrune || Date.parse(state.lastPrune) + every <= now.getTime();
11913
11993
  }
11994
+ function forgetDue(state) {
11995
+ return state.lastSnapshot !== null;
11996
+ }
11914
11997
  function idleExitWorthIt(state, now, gap = IDLE_EXIT_MIN_GAP_MS) {
11915
11998
  return !state.lastOk || Date.parse(state.lastOk) + gap <= now.getTime();
11916
11999
  }
@@ -11932,6 +12015,8 @@ function forgetArgs(prune) {
11932
12015
  "forget",
11933
12016
  "--tag",
11934
12017
  SNAPSHOT_TAG,
12018
+ "--keep-last",
12019
+ String(KEEP.last),
11935
12020
  "--keep-hourly",
11936
12021
  String(KEEP.hourly),
11937
12022
  "--keep-daily",
@@ -11991,16 +12076,16 @@ function parseBackupOutput(stdout) {
11991
12076
  function parseSnapshots(stdout) {
11992
12077
  try {
11993
12078
  const raw = JSON.parse(stdout);
11994
- return raw.filter((s2) => typeof s2.short_id === "string" && typeof s2.time === "string").map((s2) => ({ short_id: s2.short_id, time: s2.time, hostname: s2.hostname ?? "", paths: s2.paths ?? [] }));
12079
+ return raw.filter((s) => typeof s.short_id === "string" && typeof s.time === "string").map((s) => ({ short_id: s.short_id, time: s.time, hostname: s.hostname ?? "", paths: s.paths ?? [] }));
11995
12080
  } catch {
11996
12081
  return [];
11997
12082
  }
11998
12083
  }
11999
- function statusLines(s2) {
12084
+ function statusLines(s) {
12000
12085
  const lines = [];
12001
- lines.push(`repository ${s2.repository ?? "none: run `ledge backup setup`"}`);
12002
- lines.push(`restic ${"path" in s2.restic ? `${s2.restic.version} at ${s2.restic.path}` : s2.restic.missing}`);
12003
- const { state, now } = s2;
12086
+ lines.push(`repository ${s.repository ?? "none: run `ledge backup setup`"}`);
12087
+ lines.push(`restic ${"path" in s.restic ? `${s.restic.version} at ${s.restic.path}` : s.restic.missing}`);
12088
+ const { state, now } = s;
12004
12089
  if (!state.lastRun)
12005
12090
  lines.push(`last backup never`);
12006
12091
  else if (state.lastOk === state.lastRun)
@@ -12009,17 +12094,17 @@ function statusLines(s2) {
12009
12094
  lines.push(`last backup ${ago(state.lastRun, now)}, FAILED: ${state.lastError ?? "unknown"}`);
12010
12095
  lines.push(`last good ${state.lastOk ? ago(state.lastOk, now) : "never"}`);
12011
12096
  }
12012
- if (s2.repository) {
12013
- lines.push(s2.daemonUp ? `next backup ${nextDue(state, now).getTime() <= now.getTime() ? "due now" : `in ${duration(nextDue(state, now).getTime() - now.getTime())}`} (the server is running)` : `next backup when the server next runs, and hourly while it does`);
12097
+ if (s.repository) {
12098
+ lines.push(s.daemonUp ? `next backup ${nextDue(state, now).getTime() <= now.getTime() ? "due now" : `in ${duration(nextDue(state, now).getTime() - now.getTime())}`} (the server is running)` : `next backup when the server next runs, and hourly while it does`);
12014
12099
  }
12015
12100
  for (const k of state.skipped)
12016
12101
  lines.push(`SKIPPED ${k.root} (not on disk since ${ago(k.since, now)})`);
12017
12102
  return lines;
12018
12103
  }
12019
- function ago(iso2, now) {
12020
- const ms = now.getTime() - Date.parse(iso2);
12104
+ function ago(iso, now) {
12105
+ const ms = now.getTime() - Date.parse(iso);
12021
12106
  if (!Number.isFinite(ms))
12022
- return iso2;
12107
+ return iso;
12023
12108
  if (ms < 60000)
12024
12109
  return "just now";
12025
12110
  return `${duration(ms)} ago`;
@@ -12069,16 +12154,16 @@ function fetchedResticPath(version = RESTIC_VERSION) {
12069
12154
  async function findRestic(opts = {}) {
12070
12155
  const onPath = Bun.which("restic", { PATH: process.env["PATH"] ?? "" });
12071
12156
  if (onPath) {
12072
- const version2 = await versionOf(onPath);
12073
- if (version2 && versionAtLeast(version2, RESTIC_MIN_VERSION))
12074
- return { path: onPath, version: version2 };
12075
- opts.log?.(`[backup] ${onPath} is restic ${version2 ?? "of an unknown version"}; ${RESTIC_MIN_VERSION} or newer is needed`);
12157
+ const version = await versionOf(onPath);
12158
+ if (version && versionAtLeast(version, RESTIC_MIN_VERSION))
12159
+ return { path: onPath, version };
12160
+ opts.log?.(`[backup] ${onPath} is restic ${version ?? "of an unknown version"}; ${RESTIC_MIN_VERSION} or newer is needed`);
12076
12161
  }
12077
12162
  const fetched = fetchedResticPath();
12078
12163
  if (existsSync2(fetched)) {
12079
- const version2 = await versionOf(fetched);
12080
- if (version2)
12081
- return { path: fetched, version: version2 };
12164
+ const version = await versionOf(fetched);
12165
+ if (version)
12166
+ return { path: fetched, version };
12082
12167
  }
12083
12168
  if (!opts.fetch) {
12084
12169
  return { missing: onPath ? `restic on the PATH is too old and none has been fetched: run \`ledge backup setup\`` : "restic is not installed: run `ledge backup setup`" };
@@ -12247,7 +12332,8 @@ async function runBackup(o = { reason: "now" }) {
12247
12332
  const out = parseBackupOutput(backed.stdout);
12248
12333
  for (const e of out.errors)
12249
12334
  log(`[backup] restic: ${e}`);
12250
- const previous = readState().lastSnapshot;
12335
+ const state = readState();
12336
+ const previous = state.lastSnapshot;
12251
12337
  if (out.snapshot && previous && previous !== out.snapshot) {
12252
12338
  const diff = await runRestic(restic.path, diffArgs(previous, out.snapshot), env);
12253
12339
  if (diff.code === 0 && parseDiffChanges(diff.stdout) === 0) {
@@ -12258,9 +12344,12 @@ async function runBackup(o = { reason: "now" }) {
12258
12344
  out.snapshot = null;
12259
12345
  }
12260
12346
  }
12261
- const prune = pruneDue(readState(), at);
12262
- const forgot = await runRestic(restic.path, forgetArgs(prune), env);
12263
- if (forgot.code !== 0) {
12347
+ const thin = forgetDue(state);
12348
+ const prune = thin && pruneDue(state, at);
12349
+ if (!thin)
12350
+ log("[backup] the first backup from this machine: the snapshots already in the repository are left as they are");
12351
+ const forgot = thin ? await runRestic(restic.path, forgetArgs(prune), env) : null;
12352
+ if (forgot && forgot.code !== 0) {
12264
12353
  const error = `snapshot ${out.snapshot?.slice(0, 8) ?? "kept"}, but restic forget failed: ${resticSaid(forgot)}`;
12265
12354
  log(`[backup] ${error}`);
12266
12355
  writeState(recordRun(readState(), { at, ok: false, error, snapshot: out.snapshot, skipped }));
@@ -12421,14 +12510,14 @@ async function paths(args) {
12421
12510
  async function setup(args) {
12422
12511
  const existing = args.includes("--existing");
12423
12512
  const fromEnv = args.includes("--from-env");
12424
- const replace2 = args.includes("--replace");
12513
+ const replace = args.includes("--replace");
12425
12514
  const repoFlag = valueOf(args, "--repository");
12426
12515
  if (!fromEnv && !process.stdin.isTTY) {
12427
12516
  say("ledge backup setup asks questions, and stdin is not a terminal. Pass --from-env with the variables set instead.");
12428
12517
  return 2;
12429
12518
  }
12430
12519
  const already = configured();
12431
- if (already && !replace2) {
12520
+ if (already && !replace) {
12432
12521
  say(`Backups are already set up here, to ${already.repository}. \`ledge backup status\` shows how they are going; \`ledge backup setup --replace\` starts over.`);
12433
12522
  return 1;
12434
12523
  }
@@ -12483,33 +12572,54 @@ async function setup(args) {
12483
12572
  vars["RESTIC_PASSWORD"] = generated;
12484
12573
  }
12485
12574
  }
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(", ")}`);
12575
+ const proposed = parseBackupConfig(backupProfileText(vars));
12576
+ if (!("config" in proposed)) {
12577
+ say(`a backup needs ${proposed.missing.join(", ")}`);
12490
12578
  return 1;
12491
12579
  }
12492
- const env = resticEnv(read.config);
12580
+ const env = resticEnv(proposed.config);
12581
+ const unchanged = already ? `
12582
+ Nothing here changed: this machine still backs up to ${already.repository}.` : `
12583
+ Nothing was written.`;
12493
12584
  if (existing) {
12494
- say(`Opening ${read.config.repository}...`);
12585
+ say(`Opening ${proposed.config.repository}...`);
12495
12586
  const r = await runRestic(restic.path, ["cat", "config"], env);
12496
12587
  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.`);
12588
+ say(`Could not open the repository: ${resticSaid(r)}${unchanged}`);
12499
12589
  return 1;
12500
12590
  }
12501
12591
  } else {
12502
- say(`Creating the repository at ${read.config.repository}...`);
12592
+ say(`Creating the repository at ${proposed.config.repository}...`);
12503
12593
  const r = await runRestic(restic.path, ["init"], env);
12504
12594
  if (r.code !== 0) {
12505
12595
  const said = resticSaid(r);
12506
12596
  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}`);
12597
+ That repository already has backups in it. Run setup again with --existing and its password.` : "";
12598
+ say(`Could not create the repository: ${said}${hint}${unchanged}`);
12510
12599
  return 1;
12511
12600
  }
12512
12601
  }
12602
+ await writeProfile(BACKUP_PROFILE, backupProfileText(vars));
12603
+ if (existing) {
12604
+ const listed = await listSnapshots();
12605
+ say("");
12606
+ if ("error" in listed)
12607
+ say(`The repository opened, though its snapshots could not be listed: ${listed.error}`);
12608
+ else if (listed.snapshots.length === 0)
12609
+ say("The repository opened. It holds no snapshots yet.");
12610
+ else {
12611
+ const newest = listed.snapshots[0];
12612
+ say(`The repository opened. Its newest snapshot is ${newest.short_id}, from ${snapshotTime(newest.time)}; \`ledge backup snapshots\` lists the rest.`);
12613
+ }
12614
+ say("");
12615
+ say("Nothing has been backed up from this machine yet. To restore this machine, with the app quit or the daemon stopped:");
12616
+ say("");
12617
+ say(" ledge backup restore --in-place");
12618
+ say("");
12619
+ say("Backups run every hour while this machine's Ledge server is up, and once more before it exits.");
12620
+ say(`The repository and its credentials are in ${PROFILE_PATH}, the "${BACKUP_PROFILE}" profile.`);
12621
+ return 0;
12622
+ }
12513
12623
  say("Taking the first backup...");
12514
12624
  await loadWorkspaces();
12515
12625
  const result = await runBackup({ log: say, reason: "setup" });
@@ -12522,10 +12632,11 @@ The profile is written at ${PROFILE_PATH}; fix it and run setup again with --rep
12522
12632
  say(`The repository and its credentials are in ${PROFILE_PATH}, the "${BACKUP_PROFILE}" profile.`);
12523
12633
  if (generated) {
12524
12634
  say("");
12525
- say("This is the password that encrypts the backup. Keep a copy somewhere that is not this machine:");
12526
- say("a restore starts on a machine with nothing on it, and a password stored only here is a backup you cannot open.");
12527
- say("");
12635
+ process.stderr.write("SAVE THIS PASSWORD: ");
12528
12636
  out(generated);
12637
+ say("");
12638
+ say("It is the only key to the backup, and nothing can be restored without it.");
12639
+ say("Keep a copy somewhere that is not this machine, such as a password manager.");
12529
12640
  }
12530
12641
  return 0;
12531
12642
  }
@@ -12565,10 +12676,13 @@ async function snapshots() {
12565
12676
  say("no snapshots yet");
12566
12677
  return 1;
12567
12678
  }
12568
- 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"}`);
12679
+ for (const s of r.snapshots)
12680
+ out(`${s.short_id} ${snapshotTime(s.time)} ${s.hostname} ${s.paths.length} path${s.paths.length === 1 ? "" : "s"}`);
12570
12681
  return 0;
12571
12682
  }
12683
+ function snapshotTime(time) {
12684
+ return time.replace(/\.\d+/, "").replace("T", " ");
12685
+ }
12572
12686
  async function restore(args) {
12573
12687
  const config = configured();
12574
12688
  if (!config) {
@@ -13067,14 +13181,14 @@ function getTotalBits(segs, version) {
13067
13181
  }
13068
13182
  return result;
13069
13183
  }
13070
- function toUtf8ByteArray(str2) {
13071
- str2 = encodeURI(str2);
13184
+ function toUtf8ByteArray(str) {
13185
+ str = encodeURI(str);
13072
13186
  const result = [];
13073
- for (let i = 0;i < str2.length; i++) {
13074
- if (str2.charAt(i) !== "%") {
13075
- result.push(str2.charCodeAt(i));
13187
+ for (let i = 0;i < str.length; i++) {
13188
+ if (str.charAt(i) !== "%") {
13189
+ result.push(str.charCodeAt(i));
13076
13190
  } else {
13077
- result.push(Number.parseInt(str2.substring(i + 1, i + 3), 16));
13191
+ result.push(Number.parseInt(str.substring(i + 1, i + 3), 16));
13078
13192
  i += 2;
13079
13193
  }
13080
13194
  }
@@ -13294,7 +13408,7 @@ var KEYGEN_PATH = "/usr/bin/ssh-keygen";
13294
13408
  var HOST_KEY_DIR = "/etc/ssh";
13295
13409
  var FLAGS = ["user", "host", "port", "keys"];
13296
13410
  function parsePairArgs(args) {
13297
- const out2 = {};
13411
+ const out = {};
13298
13412
  for (let i = 0;i < args.length; i++) {
13299
13413
  const arg = args[i];
13300
13414
  const eq = arg.indexOf("=");
@@ -13305,9 +13419,9 @@ function parsePairArgs(args) {
13305
13419
  const value = eq < 0 ? args[++i] : arg.slice(eq + 1);
13306
13420
  if (value === undefined || value === "")
13307
13421
  return { error: `--${flag} needs a value.` };
13308
- out2[flag] = value;
13422
+ out[flag] = value;
13309
13423
  }
13310
- return out2;
13424
+ return out;
13311
13425
  }
13312
13426
  function sshServerAddress(sshConnection) {
13313
13427
  const parts = (sshConnection ?? "").trim().split(/\s+/);
@@ -13347,24 +13461,24 @@ function sshClientAddress(sshConnection) {
13347
13461
  }
13348
13462
  var VIRTUAL_INTERFACE = /^(docker|br-|veth|virbr|lxc|lxd|cni|flannel|podman|vmnet|vboxnet)/;
13349
13463
  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",
13464
+ tailnetName: "this machine's tailnet name, reachable from anywhere on the tailnet",
13465
+ tailnetAddress: "this machine's tailnet address, reachable from anywhere on the tailnet",
13352
13466
  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",
13467
+ sshInside: "the address this ssh session reached, inside a NAT the session came through; a device outside needs the outside address",
13354
13468
  cloud: "this machine's public address, from the cloud's metadata service",
13355
13469
  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"
13470
+ private: (name) => `the local network address on ${name}`,
13471
+ name: "this machine's name"
13358
13472
  };
13359
13473
  function addressCandidates(inputs) {
13360
- const out2 = [];
13474
+ const out = [];
13361
13475
  const seen = new Set;
13362
13476
  const add = (host, source, note) => {
13363
- const key2 = host.toLowerCase();
13364
- if (host === "" || seen.has(key2))
13477
+ const key = host.toLowerCase();
13478
+ if (host === "" || seen.has(key))
13365
13479
  return;
13366
- seen.add(key2);
13367
- out2.push({ host, source, note });
13480
+ seen.add(key);
13481
+ out.push({ host, source, note });
13368
13482
  };
13369
13483
  if (inputs.tailnet?.name)
13370
13484
  add(inputs.tailnet.name, "tailnet", NOTES.tailnetName);
@@ -13392,18 +13506,18 @@ function addressCandidates(inputs) {
13392
13506
  if (addressKind(i.address) === "private")
13393
13507
  add(i.address, "interface", NOTES.private(i.name));
13394
13508
  add(inputs.hostname, "name", NOTES.name);
13395
- return out2;
13509
+ return out;
13396
13510
  }
13397
13511
  function tailscaleSelf(json) {
13398
- let status2;
13512
+ let status;
13399
13513
  try {
13400
- status2 = JSON.parse(json);
13514
+ status = JSON.parse(json);
13401
13515
  } catch {
13402
13516
  return null;
13403
13517
  }
13404
- if (typeof status2 !== "object" || status2 === null)
13518
+ if (typeof status !== "object" || status === null)
13405
13519
  return null;
13406
- const { BackendState, Self } = status2;
13520
+ const { BackendState, Self } = status;
13407
13521
  if (BackendState !== "Running" || typeof Self !== "object" || Self === null)
13408
13522
  return null;
13409
13523
  const name = typeof Self.DNSName === "string" ? Self.DNSName.replace(/\.$/, "") : "";
@@ -13447,7 +13561,7 @@ function publicAddressAnswer(body) {
13447
13561
  }
13448
13562
  function candidateMenu(candidates) {
13449
13563
  const width = Math.max(...candidates.map((c) => c.host.length));
13450
- const lines = ["Which address should a phone dial?"];
13564
+ const lines = ["Which address should Ledge on your other devices use to reach this server?"];
13451
13565
  candidates.forEach((c, i) => lines.push(` ${String(i + 1).padStart(2)} ${c.host.padEnd(width)} ${c.note}`));
13452
13566
  return `${lines.join(`
13453
13567
  `)}
@@ -13484,7 +13598,7 @@ function pairAddress(args, sshConnection, candidates, answer) {
13484
13598
  } else {
13485
13599
  const pick = candidates[0];
13486
13600
  if (!pick)
13487
- return { error: "This machine has no address a phone could dial. Run again with --host." };
13601
+ return { error: "This machine has no address to put in the code. Run again with --host." };
13488
13602
  ({ host, source, note } = pick);
13489
13603
  }
13490
13604
  if (args.port !== undefined) {
@@ -13553,14 +13667,14 @@ function othersNote(others) {
13553
13667
  function pairReport({ code, keys, note, columns }) {
13554
13668
  const link = pairingLink(code);
13555
13669
  const width = terminalQRWidth(link);
13556
- const out2 = [];
13670
+ const out = [];
13557
13671
  if (columns !== undefined && columns < width) {
13558
- out2.push(`This terminal is ${columns} columns wide, and the code needs ${width}. Widen it and run pair again.`);
13672
+ out.push(`This terminal is ${columns} columns wide, and the code needs ${width}. Widen it and run pair again.`);
13559
13673
  } else {
13560
- out2.push(...terminalQR(link));
13674
+ out.push(...terminalQR(link));
13561
13675
  }
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);
13563
- return `${out2.join(`
13676
+ out.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);
13677
+ return `${out.join(`
13564
13678
  `)}
13565
13679
  `;
13566
13680
  }
@@ -13578,7 +13692,7 @@ async function serve2() {
13578
13692
  const upstream = await connectToDaemon();
13579
13693
  const mine = stdioDuplex();
13580
13694
  let over;
13581
- const done = new Promise((resolve11) => over = resolve11);
13695
+ const done = new Promise((resolve) => over = resolve);
13582
13696
  let ended = false;
13583
13697
  const end = () => {
13584
13698
  if (ended)
@@ -13595,7 +13709,7 @@ async function serve2() {
13595
13709
  mine.onClose = end;
13596
13710
  console.error(`[serve] ledge ${BUILD_VERSION} attached to ${SOCKET_PATH}`);
13597
13711
  await done;
13598
- await new Promise((resolve11) => process.stdout.write("", () => resolve11()));
13712
+ await new Promise((resolve) => process.stdout.write("", () => resolve()));
13599
13713
  }
13600
13714
  async function daemon(autostart = false) {
13601
13715
  const idleMs = autostart ? IDLE_EXIT_MS : IDLE_EXIT_NEVER;
@@ -13611,9 +13725,9 @@ async function daemon(autostart = false) {
13611
13725
  backups.stop();
13612
13726
  }
13613
13727
  async function pair(argv) {
13614
- const fail2 = (message, status2 = 1) => {
13728
+ const fail = (message, status = 1) => {
13615
13729
  console.error(message);
13616
- return status2;
13730
+ return status;
13617
13731
  };
13618
13732
  if (argv.includes("--help") || argv.includes("-h")) {
13619
13733
  process.stdout.write(`${PAIR_USAGE}
@@ -13622,12 +13736,12 @@ async function pair(argv) {
13622
13736
  }
13623
13737
  const args = parsePairArgs(argv.slice(3));
13624
13738
  if ("error" in args)
13625
- return fail2(`${args.error}
13739
+ return fail(`${args.error}
13626
13740
  ${PAIR_USAGE}`, 2);
13627
13741
  if (existsSync3("/.dockerenv") || existsSync3("/run/.containerenv")) {
13628
13742
  const refusal = containerRefusal(args);
13629
13743
  if (refusal)
13630
- return fail2(refusal);
13744
+ return fail(refusal);
13631
13745
  }
13632
13746
  const candidates = args.host === undefined ? await gatherCandidates() : [];
13633
13747
  const interactive = args.host === undefined && args.keys !== "-" && process.stdin.isTTY && process.stdout.isTTY;
@@ -13638,7 +13752,7 @@ ${PAIR_USAGE}`, 2);
13638
13752
  }
13639
13753
  const address = pairAddress(args, process.env.SSH_CONNECTION, candidates, answer);
13640
13754
  if ("error" in address)
13641
- return fail2(address.error, 2);
13755
+ return fail(address.error, 2);
13642
13756
  if (!interactive)
13643
13757
  process.stderr.write(othersNote(candidates.filter((c) => c.host !== address.host)));
13644
13758
  let keyText = "";
@@ -13648,7 +13762,7 @@ ${PAIR_USAGE}`, 2);
13648
13762
  try {
13649
13763
  keyText = readFileSync4(args.keys, "utf8");
13650
13764
  } catch {
13651
- return fail2(`Could not read ${args.keys}.`);
13765
+ return fail(`Could not read ${args.keys}.`);
13652
13766
  }
13653
13767
  } else {
13654
13768
  const files = existsSync3(HOST_KEY_DIR) ? readdirSync(HOST_KEY_DIR).filter((f) => /^ssh_host_\w+_key\.pub$/.test(f)) : [];
@@ -13659,7 +13773,7 @@ ${PAIR_USAGE}`, 2);
13659
13773
  } catch {}
13660
13774
  }
13661
13775
  if (keyText.trim() === "") {
13662
- return fail2(`There are no sshd host keys in ${HOST_KEY_DIR}. If sshd keeps them elsewhere, pass the .pub file with --keys.`);
13776
+ return fail(`There are no sshd host keys in ${HOST_KEY_DIR}. If sshd keeps them elsewhere, pass the .pub file with --keys.`);
13663
13777
  }
13664
13778
  }
13665
13779
  let described;
@@ -13670,18 +13784,18 @@ ${PAIR_USAGE}`, 2);
13670
13784
  described = await new Response(p.stdout).text();
13671
13785
  await p.exited;
13672
13786
  } catch (err) {
13673
- return fail2(`Could not run ssh-keygen (${err instanceof Error ? err.message : String(err)}).`);
13787
+ return fail(`Could not run ssh-keygen (${err instanceof Error ? err.message : String(err)}).`);
13674
13788
  }
13675
13789
  const keys = phoneHostKeys(described);
13676
13790
  if (keys.length === 0) {
13677
13791
  if (!described.includes("SHA256:"))
13678
- return fail2(`${args.keys === "-" ? "stdin" : args.keys ?? HOST_KEY_DIR} holds no public host keys.`);
13679
- return fail2(`None of these host keys is Ed25519 or ECDSA, and those are the kinds Ledge on a phone can check.
13792
+ return fail(`${args.keys === "-" ? "stdin" : args.keys ?? HOST_KEY_DIR} holds no public host keys.`);
13793
+ return fail(`None of these host keys is Ed25519 or ECDSA, and those are the kinds Ledge on a phone can check.
13680
13794
  ` + "`sudo ssh-keygen -A` creates the missing default keys. Restart sshd after it.");
13681
13795
  }
13682
13796
  const code = pairCode(args.user ?? userInfo().username, address, keys);
13683
13797
  if ("error" in code)
13684
- return fail2(code.error);
13798
+ return fail(code.error);
13685
13799
  const columns = process.stdout.isTTY ? process.stdout.columns : undefined;
13686
13800
  process.stdout.write(pairReport({ code, keys, note: address.note, columns }));
13687
13801
  return 0;
@@ -13698,13 +13812,13 @@ async function gatherCandidates() {
13698
13812
  }
13699
13813
  var LOOKUP_MS = 1500;
13700
13814
  function dmi() {
13701
- const out2 = {};
13815
+ const out = {};
13702
13816
  for (const field of DMI_FIELDS) {
13703
13817
  try {
13704
- out2[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
13818
+ out[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
13705
13819
  } catch {}
13706
13820
  }
13707
- return out2;
13821
+ return out;
13708
13822
  }
13709
13823
  async function tailnetSelf() {
13710
13824
  const path = TAILSCALE_PATHS.find((p) => existsSync3(p));
@@ -13744,8 +13858,8 @@ async function cloudAddress() {
13744
13858
  }
13745
13859
  var PAIR_USAGE = [
13746
13860
  "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,",
13861
+ " --user the account Ledge signs in as (default: whoever runs pair)",
13862
+ " --host the name or IPv4 address Ledge connects to (default: a menu of this machine's addresses on a terminal,",
13749
13863
  " else the first of them: its tailnet name, this ssh session's address, its public address, its name)",
13750
13864
  " --port sshd's port (default: this ssh session's port, or 22)",
13751
13865
  " --keys public host keys to describe, - for stdin (default: /etc/ssh/ssh_host_*_key.pub)"
@@ -13775,8 +13889,8 @@ async function main(argv) {
13775
13889
  if (import.meta.main)
13776
13890
  await main(process.argv);
13777
13891
  export {
13778
- serve2 as serve,
13779
- pair,
13892
+ daemon,
13780
13893
  main,
13781
- daemon
13894
+ pair,
13895
+ serve2 as serve
13782
13896
  };