ledge-server 0.0.3 → 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:
6158
+
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.
6105
6164
 
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:
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.
6216
+
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.
6150
6218
 
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:
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
 
@@ -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
@@ -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,7 +7236,7 @@ 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.
@@ -9708,9 +9773,9 @@ function sizeOf(path) {
9708
9773
  }
9709
9774
  var logPath = LOG_PATH;
9710
9775
  var prevPath = PREV_LOG_PATH;
9711
- function logToFile(basename7) {
9712
- logPath = join15(LOG_DIR, `${basename7}.log`);
9713
- 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`);
9714
9779
  }
9715
9780
  function rotate() {
9716
9781
  try {
@@ -9741,12 +9806,12 @@ function write(source, level, args) {
9741
9806
  append(formatLine(new Date, source, level, args));
9742
9807
  }
9743
9808
  var patched = false;
9744
- function startLogging(basename7) {
9809
+ function startLogging(basename) {
9745
9810
  if (patched)
9746
9811
  return;
9747
9812
  patched = true;
9748
- if (basename7)
9749
- logToFile(basename7);
9813
+ if (basename)
9814
+ logToFile(basename);
9750
9815
  rotate();
9751
9816
  const levels = [
9752
9817
  ["log", "info"],
@@ -9780,13 +9845,13 @@ function relevantChange(filename) {
9780
9845
  if (filename === null)
9781
9846
  return true;
9782
9847
  const segments = filename.split("/");
9783
- if (segments.slice(0, -1).some((s2) => s2.startsWith(".")))
9848
+ if (segments.slice(0, -1).some((s) => s.startsWith(".")))
9784
9849
  return false;
9785
9850
  return /\.md(\.|$)/i.test(segments[segments.length - 1]);
9786
9851
  }
9787
9852
  var watchers = new Map;
9788
- function syncWatchers(roots2, onChange) {
9789
- const want = new Set(roots2);
9853
+ function syncWatchers(roots, onChange) {
9854
+ const want = new Set(roots);
9790
9855
  for (const [root, w] of watchers) {
9791
9856
  if (want.has(root))
9792
9857
  continue;
@@ -9846,14 +9911,14 @@ function bundledBun(execPath) {
9846
9911
  return /(^|\/)bun$/.test(execPath) ? execPath : "";
9847
9912
  }
9848
9913
  function runnerFor(id, lang, code, interpreters, bunPath, remote = false) {
9849
- const key2 = (lang ?? "").toLowerCase();
9850
- const interpreter = interpreters[key2];
9914
+ const key = (lang ?? "").toLowerCase();
9915
+ const interpreter = interpreters[key];
9851
9916
  if (!interpreter) {
9852
- const path2 = `/tmp/ledge-run-${id}.sh`;
9853
- const command = remote ? remoteWrite(code, path2, `source ${path2}`) : `source ${path2}`;
9854
- 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 };
9855
9920
  }
9856
- const ext = EXT[key2] ?? (key2.replace(/[^a-z0-9]/g, "") || "txt");
9921
+ const ext = EXT[key] ?? (key.replace(/[^a-z0-9]/g, "") || "txt");
9857
9922
  const path = `/tmp/ledge-run-${id}.${ext}`;
9858
9923
  const contents = ext === "php" && !/^\s*<\?/.test(code) ? `<?php
9859
9924
  ${code}` : code;
@@ -9877,8 +9942,8 @@ function hostGlobMatches(pattern, host) {
9877
9942
  const rx = pattern.split("*").map(escapeRegex).join(".*");
9878
9943
  return new RegExp(`^${rx}$`).test(host);
9879
9944
  }
9880
- function escapeRegex(s2) {
9881
- return s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9945
+ function escapeRegex(s) {
9946
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9882
9947
  }
9883
9948
 
9884
9949
  // src/bun/remoteSpawn.ts
@@ -9899,11 +9964,11 @@ function buildRemoteSpawn(host, kind, params, warn) {
9899
9964
  const parts = [];
9900
9965
  if (params?.cwd)
9901
9966
  parts.push(remoteCd(params.cwd));
9902
- for (const [key2, value] of Object.entries(params?.env ?? {})) {
9903
- if (isEnvName(key2) && typeof value === "string") {
9904
- 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)}`);
9905
9970
  } else {
9906
- warn(`ignoring unusable env entry "${key2}"`);
9971
+ warn(`ignoring unusable env entry "${key}"`);
9907
9972
  }
9908
9973
  }
9909
9974
  if (kind === "inline") {
@@ -9995,7 +10060,10 @@ async function createServer(deps) {
9995
10060
  const { push } = deps;
9996
10061
  const settings = await loadSettings();
9997
10062
  await loadWorkspaces();
9998
- 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
+ }
9999
10067
  await syncDocs();
10000
10068
  await loadVault();
10001
10069
  const shellEnv = { ...process.env, TERM: "xterm-256color" };
@@ -10128,9 +10196,9 @@ async function createServer(deps) {
10128
10196
  watch2(APP_HOME, (_event, filename) => {
10129
10197
  if (filename !== requestName)
10130
10198
  return;
10131
- takeOpenRequest().then((open2) => {
10132
- if (open2 !== null)
10133
- push.all.openExternal(open2);
10199
+ takeOpenRequest().then((open) => {
10200
+ if (open !== null)
10201
+ push.all.openExternal(open);
10134
10202
  });
10135
10203
  });
10136
10204
  } catch (err) {
@@ -10493,9 +10561,9 @@ async function createServer(deps) {
10493
10561
  return { ok: true };
10494
10562
  },
10495
10563
  openRequestTake: async () => {
10496
- const open2 = await takeOpenRequest();
10564
+ const open = await takeOpenRequest();
10497
10565
  startOpenRequestWatcher();
10498
- return { open: open2 };
10566
+ return { open };
10499
10567
  },
10500
10568
  logAppend: async ({ level, text }) => {
10501
10569
  write("view", level, [text.slice(0, LOG_TEXT_CAP)]);
@@ -10776,10 +10844,10 @@ function restoreBinary(payload, path, bytes) {
10776
10844
  }
10777
10845
  function walk(payload, path) {
10778
10846
  let at = payload;
10779
- for (const key2 of path) {
10847
+ for (const key of path) {
10780
10848
  if (typeof at !== "object" || at === null)
10781
10849
  return null;
10782
- at = at[key2];
10850
+ at = at[key];
10783
10851
  }
10784
10852
  return { value: at };
10785
10853
  }
@@ -10954,8 +11022,8 @@ function names(v) {
10954
11022
  return v.filter((n) => typeof n === "string").slice(0, MAX_DECLARED_NAMES);
10955
11023
  }
10956
11024
  var MAX_DECLARED_NAMES = 512;
10957
- function opt(key2, v) {
10958
- return typeof v === "string" ? { [key2]: v } : {};
11025
+ function opt(key, v) {
11026
+ return typeof v === "string" ? { [key]: v } : {};
10959
11027
  }
10960
11028
  function bin(v) {
10961
11029
  return isId(v) ? { bin: v } : {};
@@ -11002,15 +11070,15 @@ function concat2(a, b) {
11002
11070
  out.set(b, a.length);
11003
11071
  return out;
11004
11072
  }
11005
- function writeMessage(write2, msg, kind, method) {
11073
+ function writeMessage(write, msg, kind, method) {
11006
11074
  const path = binaryPath(kind, method);
11007
11075
  const body = msg.t === "req" ? msg.p : msg.t === "res" ? msg.r : msg.t === "push" ? msg.p : null;
11008
11076
  const hoisted = path && body !== null ? hoistBinary(body, path) : null;
11009
11077
  if (!hoisted)
11010
- return write2(encodeControl(msg));
11011
- const bin2 = nextBinaryId();
11012
- write2(encodeBinary(bin2, hoisted.bytes));
11013
- 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 }));
11014
11082
  }
11015
11083
  var binaryId = 0;
11016
11084
  function nextBinaryId() {
@@ -11020,19 +11088,19 @@ function nextBinaryId() {
11020
11088
 
11021
11089
  class BinaryHolder {
11022
11090
  held = null;
11023
- hold(frame2) {
11091
+ hold(frame) {
11024
11092
  if (this.held)
11025
11093
  throw new WireError("the peer sent two binary frames with no control frame between them");
11026
- this.held = { id: frame2.id, bytes: frame2.bytes };
11094
+ this.held = { id: frame.id, bytes: frame.bytes };
11027
11095
  }
11028
11096
  claim(msg, kind, method) {
11029
- 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;
11030
11098
  const body = msg.t === "req" ? msg.p : msg.t === "res" ? msg.r : msg.t === "push" ? msg.p : null;
11031
- if (bin2 === undefined)
11099
+ if (bin === undefined)
11032
11100
  return body;
11033
11101
  const held = this.held;
11034
11102
  this.held = null;
11035
- if (!held || held.id !== bin2)
11103
+ if (!held || held.id !== bin)
11036
11104
  throw new WireError("the peer claimed a binary frame that did not arrive");
11037
11105
  const path = binaryPath(kind, method);
11038
11106
  if (!path)
@@ -11114,7 +11182,7 @@ function serverConnection(duplex, opts) {
11114
11182
  const silentMs = opts.silentMs ?? SILENT_MS2;
11115
11183
  let heardFromClient = false;
11116
11184
  let stopWatching = null;
11117
- const decoder2 = new FrameDecoder;
11185
+ const decoder = new FrameDecoder;
11118
11186
  const incoming = new BinaryHolder;
11119
11187
  let handlers = null;
11120
11188
  let greeted = false;
@@ -11122,10 +11190,10 @@ function serverConnection(duplex, opts) {
11122
11190
  let peerDevice = "";
11123
11191
  let peerLabel = "";
11124
11192
  let peerHold = 0;
11125
- let open2 = true;
11193
+ let open = true;
11126
11194
  const waiting = [];
11127
11195
  let settle;
11128
- const closed = new Promise((resolve11) => settle = resolve11);
11196
+ const closed = new Promise((resolve) => settle = resolve);
11129
11197
  function raw(bytes) {
11130
11198
  try {
11131
11199
  duplex.write(bytes);
@@ -11135,7 +11203,7 @@ function serverConnection(duplex, opts) {
11135
11203
  }
11136
11204
  }
11137
11205
  function send(msg, method = "") {
11138
- if (!open2)
11206
+ if (!open)
11139
11207
  return;
11140
11208
  if (msg.t !== "push" || msg.m !== "terminalOutput")
11141
11209
  flushOutput();
@@ -11145,11 +11213,11 @@ function serverConnection(duplex, opts) {
11145
11213
  raw(encodeControl(msg));
11146
11214
  }
11147
11215
  function close(why, back = false) {
11148
- if (!open2)
11216
+ if (!open)
11149
11217
  return;
11150
11218
  if (why !== undefined)
11151
11219
  send({ t: "bye", why, ...back ? { back: true } : {} });
11152
- open2 = false;
11220
+ open = false;
11153
11221
  stopWatching?.();
11154
11222
  stopWatching = null;
11155
11223
  stopCoalescing();
@@ -11188,7 +11256,7 @@ function serverConnection(duplex, opts) {
11188
11256
  heldBytes = 0;
11189
11257
  }
11190
11258
  function pushOutput(p) {
11191
- if (!open2)
11259
+ if (!open)
11192
11260
  return;
11193
11261
  const bytes = fromBase64(p.dataB64);
11194
11262
  if (bytes.length === 0)
@@ -11256,18 +11324,18 @@ function serverConnection(duplex, opts) {
11256
11324
  heardFromClient = true;
11257
11325
  let frames;
11258
11326
  try {
11259
- frames = decoder2.push(chunk);
11327
+ frames = decoder.push(chunk);
11260
11328
  } catch (err) {
11261
11329
  console.error("[wire]", err instanceof Error ? err.message : err);
11262
11330
  return close(err instanceof WireError ? err.message : "unreadable frame");
11263
11331
  }
11264
- for (const frame2 of frames) {
11332
+ for (const frame of frames) {
11265
11333
  try {
11266
- if (frame2.type === 1) {
11267
- incoming.hold(frame2);
11334
+ if (frame.type === 1) {
11335
+ incoming.hold(frame);
11268
11336
  continue;
11269
11337
  }
11270
- handle(parseControl(frame2.text));
11338
+ handle(parseControl(frame.text));
11271
11339
  if (!incoming.idle())
11272
11340
  throw new WireError("the peer sent bytes that no control frame claimed");
11273
11341
  } catch (err) {
@@ -11277,7 +11345,7 @@ function serverConnection(duplex, opts) {
11277
11345
  }
11278
11346
  };
11279
11347
  duplex.onClose = () => {
11280
- open2 = false;
11348
+ open = false;
11281
11349
  stopWatching?.();
11282
11350
  stopWatching = null;
11283
11351
  stopCoalescing();
@@ -11448,13 +11516,13 @@ function createOpLog(opts) {
11448
11516
  }
11449
11517
  }
11450
11518
  return {
11451
- run(key2, exec) {
11452
- const hit = seen.get(key2);
11519
+ run(key, exec) {
11520
+ const hit = seen.get(key);
11453
11521
  if (hit)
11454
11522
  return hit.result;
11455
11523
  const result = exec();
11456
11524
  result.catch(() => {});
11457
- seen.set(key2, { at: now(), result });
11525
+ seen.set(key, { at: now(), result });
11458
11526
  evict();
11459
11527
  return result;
11460
11528
  },
@@ -11463,7 +11531,7 @@ function createOpLog(opts) {
11463
11531
  }
11464
11532
 
11465
11533
  // src/shared/version.ts
11466
- var BUILD_VERSION = "0.0.3";
11534
+ var BUILD_VERSION = "0.1.0";
11467
11535
 
11468
11536
  // src/bun/daemon.ts
11469
11537
  var SOCKET_PATH = join16(APP_HOME, ".server.sock");
@@ -11494,10 +11562,10 @@ async function startDaemon(opts = {}) {
11494
11562
  const ops = createOpLog();
11495
11563
  const instance = crypto.randomUUID();
11496
11564
  const server = await createServer({ push });
11497
- let idleTimer2 = null;
11565
+ let idleTimer = null;
11498
11566
  let heldUntil = 0;
11499
11567
  let settleDone;
11500
- const done = new Promise((resolve11) => settleDone = resolve11);
11568
+ const done = new Promise((resolve) => settleDone = resolve);
11501
11569
  let stopped = false;
11502
11570
  const listener = Bun.listen({
11503
11571
  unix: socketPath,
@@ -11542,9 +11610,9 @@ async function startDaemon(opts = {}) {
11542
11610
  console.error("[daemon] could not write the pid file:", err);
11543
11611
  }
11544
11612
  function accept(io) {
11545
- if (idleTimer2) {
11546
- clearTimeout(idleTimer2);
11547
- idleTimer2 = null;
11613
+ if (idleTimer) {
11614
+ clearTimeout(idleTimer);
11615
+ idleTimer = null;
11548
11616
  }
11549
11617
  const greet = () => {
11550
11618
  const id = conn.client();
@@ -11574,15 +11642,15 @@ async function startDaemon(opts = {}) {
11574
11642
  }
11575
11643
  let leaving = false;
11576
11644
  function armIdleExit() {
11577
- if (stopped || idleTimer2 || leaving || idleMs <= 0)
11645
+ if (stopped || idleTimer || leaving || idleMs <= 0)
11578
11646
  return;
11579
11647
  const held = server.sessionsOpen() ? heldUntil - Date.now() : 0;
11580
11648
  const wait = Math.max(idleMs, held);
11581
11649
  if (wait !== idleMs) {
11582
11650
  console.error(`[daemon] holding sessions for ${wait >= 1e4 ? `${Math.round(wait / 1000)}s` : `${wait}ms`}`);
11583
11651
  }
11584
- idleTimer2 = setTimeout(async () => {
11585
- idleTimer2 = null;
11652
+ idleTimer = setTimeout(async () => {
11653
+ idleTimer = null;
11586
11654
  if (clients.size > 0)
11587
11655
  return;
11588
11656
  if (server.running())
@@ -11626,8 +11694,8 @@ async function startDaemon(opts = {}) {
11626
11694
  if (stopped)
11627
11695
  return;
11628
11696
  stopped = true;
11629
- if (idleTimer2)
11630
- clearTimeout(idleTimer2);
11697
+ if (idleTimer)
11698
+ clearTimeout(idleTimer);
11631
11699
  for (const conn of accepted) {
11632
11700
  if (clients.get(conn.client()) === conn)
11633
11701
  conn.close("this server is shutting down", true);
@@ -11685,9 +11753,9 @@ async function tryConnect(socketPath) {
11685
11753
  socket: {
11686
11754
  data: (_s, chunk) => io?.feed(new Uint8Array(chunk)),
11687
11755
  drain: () => out?.drain(),
11688
- end: (s2) => {
11756
+ end: (s) => {
11689
11757
  io?.finish();
11690
- s2.end();
11758
+ s.end();
11691
11759
  },
11692
11760
  close: () => io?.finish(),
11693
11761
  error: () => io?.finish()
@@ -11734,7 +11802,7 @@ var reader = null;
11734
11802
  var pending = "";
11735
11803
  async function readLine() {
11736
11804
  reader ??= Bun.stdin.stream().getReader();
11737
- const decoder2 = new TextDecoder;
11805
+ const decoder = new TextDecoder;
11738
11806
  for (;; ) {
11739
11807
  const nl = pending.indexOf(`
11740
11808
  `);
@@ -11749,7 +11817,7 @@ async function readLine() {
11749
11817
  pending = "";
11750
11818
  return line;
11751
11819
  }
11752
- pending += decoder2.decode(value, { stream: true });
11820
+ pending += decoder.decode(value, { stream: true });
11753
11821
  }
11754
11822
  }
11755
11823
  async function readHidden() {
@@ -11791,9 +11859,9 @@ async function ask(question, o = {}) {
11791
11859
  // src/bun/backup.ts
11792
11860
  import { join as join17 } from "path";
11793
11861
  function backupSet(input) {
11794
- const { appHome, profilesDir, roots: roots2, secrets } = input;
11862
+ const { appHome, profilesDir, roots, secrets } = input;
11795
11863
  const include = [appHome];
11796
- for (const root of roots2)
11864
+ for (const root of roots)
11797
11865
  if (!isInside(appHome, root))
11798
11866
  include.push(root);
11799
11867
  if (secrets && !include.some((p) => isInside(p, profilesDir)))
@@ -11832,9 +11900,9 @@ function backupProfileText(vars) {
11832
11900
  `# RESTIC_PASSWORD is the only key to the backup. Keep a copy somewhere else.`,
11833
11901
  ``
11834
11902
  ];
11835
- for (const [key2, value] of Object.entries(vars)) {
11903
+ for (const [key, value] of Object.entries(vars)) {
11836
11904
  const quoted = value !== value.trim() || /^["']/.test(value) ? `"${value.replace(/["\\]/g, "\\$&")}"` : value;
11837
- lines.push(`${key2}=${quoted}`);
11905
+ lines.push(`${key}=${quoted}`);
11838
11906
  }
11839
11907
  lines.push(``);
11840
11908
  return lines.join(`
@@ -11893,7 +11961,7 @@ function parseState(text) {
11893
11961
  return {
11894
11962
  ...EMPTY_STATE,
11895
11963
  ...raw,
11896
- 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") : []
11897
11965
  };
11898
11966
  } catch {
11899
11967
  return EMPTY_STATE;
@@ -11901,7 +11969,7 @@ function parseState(text) {
11901
11969
  }
11902
11970
  function recordRun(prev, o) {
11903
11971
  const at = o.at.toISOString();
11904
- 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]));
11905
11973
  return {
11906
11974
  version: 1,
11907
11975
  lastRun: at,
@@ -12008,16 +12076,16 @@ function parseBackupOutput(stdout) {
12008
12076
  function parseSnapshots(stdout) {
12009
12077
  try {
12010
12078
  const raw = JSON.parse(stdout);
12011
- 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 ?? [] }));
12012
12080
  } catch {
12013
12081
  return [];
12014
12082
  }
12015
12083
  }
12016
- function statusLines(s2) {
12084
+ function statusLines(s) {
12017
12085
  const lines = [];
12018
- lines.push(`repository ${s2.repository ?? "none: run `ledge backup setup`"}`);
12019
- lines.push(`restic ${"path" in s2.restic ? `${s2.restic.version} at ${s2.restic.path}` : s2.restic.missing}`);
12020
- 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;
12021
12089
  if (!state.lastRun)
12022
12090
  lines.push(`last backup never`);
12023
12091
  else if (state.lastOk === state.lastRun)
@@ -12026,17 +12094,17 @@ function statusLines(s2) {
12026
12094
  lines.push(`last backup ${ago(state.lastRun, now)}, FAILED: ${state.lastError ?? "unknown"}`);
12027
12095
  lines.push(`last good ${state.lastOk ? ago(state.lastOk, now) : "never"}`);
12028
12096
  }
12029
- if (s2.repository) {
12030
- 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`);
12031
12099
  }
12032
12100
  for (const k of state.skipped)
12033
12101
  lines.push(`SKIPPED ${k.root} (not on disk since ${ago(k.since, now)})`);
12034
12102
  return lines;
12035
12103
  }
12036
- function ago(iso2, now) {
12037
- const ms = now.getTime() - Date.parse(iso2);
12104
+ function ago(iso, now) {
12105
+ const ms = now.getTime() - Date.parse(iso);
12038
12106
  if (!Number.isFinite(ms))
12039
- return iso2;
12107
+ return iso;
12040
12108
  if (ms < 60000)
12041
12109
  return "just now";
12042
12110
  return `${duration(ms)} ago`;
@@ -12086,16 +12154,16 @@ function fetchedResticPath(version = RESTIC_VERSION) {
12086
12154
  async function findRestic(opts = {}) {
12087
12155
  const onPath = Bun.which("restic", { PATH: process.env["PATH"] ?? "" });
12088
12156
  if (onPath) {
12089
- const version2 = await versionOf(onPath);
12090
- if (version2 && versionAtLeast(version2, RESTIC_MIN_VERSION))
12091
- return { path: onPath, version: version2 };
12092
- 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`);
12093
12161
  }
12094
12162
  const fetched = fetchedResticPath();
12095
12163
  if (existsSync2(fetched)) {
12096
- const version2 = await versionOf(fetched);
12097
- if (version2)
12098
- return { path: fetched, version: version2 };
12164
+ const version = await versionOf(fetched);
12165
+ if (version)
12166
+ return { path: fetched, version };
12099
12167
  }
12100
12168
  if (!opts.fetch) {
12101
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`" };
@@ -12442,14 +12510,14 @@ async function paths(args) {
12442
12510
  async function setup(args) {
12443
12511
  const existing = args.includes("--existing");
12444
12512
  const fromEnv = args.includes("--from-env");
12445
- const replace2 = args.includes("--replace");
12513
+ const replace = args.includes("--replace");
12446
12514
  const repoFlag = valueOf(args, "--repository");
12447
12515
  if (!fromEnv && !process.stdin.isTTY) {
12448
12516
  say("ledge backup setup asks questions, and stdin is not a terminal. Pass --from-env with the variables set instead.");
12449
12517
  return 2;
12450
12518
  }
12451
12519
  const already = configured();
12452
- if (already && !replace2) {
12520
+ if (already && !replace) {
12453
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.`);
12454
12522
  return 1;
12455
12523
  }
@@ -12564,10 +12632,11 @@ That repository already has backups in it. Run setup again with --existing and i
12564
12632
  say(`The repository and its credentials are in ${PROFILE_PATH}, the "${BACKUP_PROFILE}" profile.`);
12565
12633
  if (generated) {
12566
12634
  say("");
12567
- say("This is the password that encrypts the backup. Keep a copy somewhere that is not this machine:");
12568
- say("a restore starts on a machine with nothing on it, and a password stored only here is a backup you cannot open.");
12569
- say("");
12635
+ process.stderr.write("SAVE THIS PASSWORD: ");
12570
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.");
12571
12640
  }
12572
12641
  return 0;
12573
12642
  }
@@ -12607,8 +12676,8 @@ async function snapshots() {
12607
12676
  say("no snapshots yet");
12608
12677
  return 1;
12609
12678
  }
12610
- for (const s2 of r.snapshots)
12611
- out(`${s2.short_id} ${snapshotTime(s2.time)} ${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"}`);
12612
12681
  return 0;
12613
12682
  }
12614
12683
  function snapshotTime(time) {
@@ -13112,14 +13181,14 @@ function getTotalBits(segs, version) {
13112
13181
  }
13113
13182
  return result;
13114
13183
  }
13115
- function toUtf8ByteArray(str2) {
13116
- str2 = encodeURI(str2);
13184
+ function toUtf8ByteArray(str) {
13185
+ str = encodeURI(str);
13117
13186
  const result = [];
13118
- for (let i = 0;i < str2.length; i++) {
13119
- if (str2.charAt(i) !== "%") {
13120
- 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));
13121
13190
  } else {
13122
- result.push(Number.parseInt(str2.substring(i + 1, i + 3), 16));
13191
+ result.push(Number.parseInt(str.substring(i + 1, i + 3), 16));
13123
13192
  i += 2;
13124
13193
  }
13125
13194
  }
@@ -13339,7 +13408,7 @@ var KEYGEN_PATH = "/usr/bin/ssh-keygen";
13339
13408
  var HOST_KEY_DIR = "/etc/ssh";
13340
13409
  var FLAGS = ["user", "host", "port", "keys"];
13341
13410
  function parsePairArgs(args) {
13342
- const out2 = {};
13411
+ const out = {};
13343
13412
  for (let i = 0;i < args.length; i++) {
13344
13413
  const arg = args[i];
13345
13414
  const eq = arg.indexOf("=");
@@ -13350,9 +13419,9 @@ function parsePairArgs(args) {
13350
13419
  const value = eq < 0 ? args[++i] : arg.slice(eq + 1);
13351
13420
  if (value === undefined || value === "")
13352
13421
  return { error: `--${flag} needs a value.` };
13353
- out2[flag] = value;
13422
+ out[flag] = value;
13354
13423
  }
13355
- return out2;
13424
+ return out;
13356
13425
  }
13357
13426
  function sshServerAddress(sshConnection) {
13358
13427
  const parts = (sshConnection ?? "").trim().split(/\s+/);
@@ -13402,14 +13471,14 @@ var NOTES = {
13402
13471
  name: "this machine's name"
13403
13472
  };
13404
13473
  function addressCandidates(inputs) {
13405
- const out2 = [];
13474
+ const out = [];
13406
13475
  const seen = new Set;
13407
13476
  const add = (host, source, note) => {
13408
- const key2 = host.toLowerCase();
13409
- if (host === "" || seen.has(key2))
13477
+ const key = host.toLowerCase();
13478
+ if (host === "" || seen.has(key))
13410
13479
  return;
13411
- seen.add(key2);
13412
- out2.push({ host, source, note });
13480
+ seen.add(key);
13481
+ out.push({ host, source, note });
13413
13482
  };
13414
13483
  if (inputs.tailnet?.name)
13415
13484
  add(inputs.tailnet.name, "tailnet", NOTES.tailnetName);
@@ -13437,18 +13506,18 @@ function addressCandidates(inputs) {
13437
13506
  if (addressKind(i.address) === "private")
13438
13507
  add(i.address, "interface", NOTES.private(i.name));
13439
13508
  add(inputs.hostname, "name", NOTES.name);
13440
- return out2;
13509
+ return out;
13441
13510
  }
13442
13511
  function tailscaleSelf(json) {
13443
- let status2;
13512
+ let status;
13444
13513
  try {
13445
- status2 = JSON.parse(json);
13514
+ status = JSON.parse(json);
13446
13515
  } catch {
13447
13516
  return null;
13448
13517
  }
13449
- if (typeof status2 !== "object" || status2 === null)
13518
+ if (typeof status !== "object" || status === null)
13450
13519
  return null;
13451
- const { BackendState, Self } = status2;
13520
+ const { BackendState, Self } = status;
13452
13521
  if (BackendState !== "Running" || typeof Self !== "object" || Self === null)
13453
13522
  return null;
13454
13523
  const name = typeof Self.DNSName === "string" ? Self.DNSName.replace(/\.$/, "") : "";
@@ -13598,14 +13667,14 @@ function othersNote(others) {
13598
13667
  function pairReport({ code, keys, note, columns }) {
13599
13668
  const link = pairingLink(code);
13600
13669
  const width = terminalQRWidth(link);
13601
- const out2 = [];
13670
+ const out = [];
13602
13671
  if (columns !== undefined && columns < width) {
13603
- 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.`);
13604
13673
  } else {
13605
- out2.push(...terminalQR(link));
13674
+ out.push(...terminalQR(link));
13606
13675
  }
13607
- out2.push("", "Scan the code with Ledge on your phone, or paste the link below into the Mac app's Add Server form. It names this server and its host keys, and holds no password or key.", "", ` Account ${code.user}`, ` Host ${code.host}${note === "" ? "" : ` (${note})`}`, ` Port ${code.port === PORT_UNSET ? DEFAULT_PORT : code.port}`, ...keys.map((k, i) => ` ${i === 0 ? "Host keys" : " "} ${k.fingerprint} (${k.keyType})`), "", link);
13608
- 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(`
13609
13678
  `)}
13610
13679
  `;
13611
13680
  }
@@ -13623,7 +13692,7 @@ async function serve2() {
13623
13692
  const upstream = await connectToDaemon();
13624
13693
  const mine = stdioDuplex();
13625
13694
  let over;
13626
- const done = new Promise((resolve11) => over = resolve11);
13695
+ const done = new Promise((resolve) => over = resolve);
13627
13696
  let ended = false;
13628
13697
  const end = () => {
13629
13698
  if (ended)
@@ -13640,7 +13709,7 @@ async function serve2() {
13640
13709
  mine.onClose = end;
13641
13710
  console.error(`[serve] ledge ${BUILD_VERSION} attached to ${SOCKET_PATH}`);
13642
13711
  await done;
13643
- await new Promise((resolve11) => process.stdout.write("", () => resolve11()));
13712
+ await new Promise((resolve) => process.stdout.write("", () => resolve()));
13644
13713
  }
13645
13714
  async function daemon(autostart = false) {
13646
13715
  const idleMs = autostart ? IDLE_EXIT_MS : IDLE_EXIT_NEVER;
@@ -13656,9 +13725,9 @@ async function daemon(autostart = false) {
13656
13725
  backups.stop();
13657
13726
  }
13658
13727
  async function pair(argv) {
13659
- const fail2 = (message, status2 = 1) => {
13728
+ const fail = (message, status = 1) => {
13660
13729
  console.error(message);
13661
- return status2;
13730
+ return status;
13662
13731
  };
13663
13732
  if (argv.includes("--help") || argv.includes("-h")) {
13664
13733
  process.stdout.write(`${PAIR_USAGE}
@@ -13667,12 +13736,12 @@ async function pair(argv) {
13667
13736
  }
13668
13737
  const args = parsePairArgs(argv.slice(3));
13669
13738
  if ("error" in args)
13670
- return fail2(`${args.error}
13739
+ return fail(`${args.error}
13671
13740
  ${PAIR_USAGE}`, 2);
13672
13741
  if (existsSync3("/.dockerenv") || existsSync3("/run/.containerenv")) {
13673
13742
  const refusal = containerRefusal(args);
13674
13743
  if (refusal)
13675
- return fail2(refusal);
13744
+ return fail(refusal);
13676
13745
  }
13677
13746
  const candidates = args.host === undefined ? await gatherCandidates() : [];
13678
13747
  const interactive = args.host === undefined && args.keys !== "-" && process.stdin.isTTY && process.stdout.isTTY;
@@ -13683,7 +13752,7 @@ ${PAIR_USAGE}`, 2);
13683
13752
  }
13684
13753
  const address = pairAddress(args, process.env.SSH_CONNECTION, candidates, answer);
13685
13754
  if ("error" in address)
13686
- return fail2(address.error, 2);
13755
+ return fail(address.error, 2);
13687
13756
  if (!interactive)
13688
13757
  process.stderr.write(othersNote(candidates.filter((c) => c.host !== address.host)));
13689
13758
  let keyText = "";
@@ -13693,7 +13762,7 @@ ${PAIR_USAGE}`, 2);
13693
13762
  try {
13694
13763
  keyText = readFileSync4(args.keys, "utf8");
13695
13764
  } catch {
13696
- return fail2(`Could not read ${args.keys}.`);
13765
+ return fail(`Could not read ${args.keys}.`);
13697
13766
  }
13698
13767
  } else {
13699
13768
  const files = existsSync3(HOST_KEY_DIR) ? readdirSync(HOST_KEY_DIR).filter((f) => /^ssh_host_\w+_key\.pub$/.test(f)) : [];
@@ -13704,7 +13773,7 @@ ${PAIR_USAGE}`, 2);
13704
13773
  } catch {}
13705
13774
  }
13706
13775
  if (keyText.trim() === "") {
13707
- 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.`);
13708
13777
  }
13709
13778
  }
13710
13779
  let described;
@@ -13715,18 +13784,18 @@ ${PAIR_USAGE}`, 2);
13715
13784
  described = await new Response(p.stdout).text();
13716
13785
  await p.exited;
13717
13786
  } catch (err) {
13718
- 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)}).`);
13719
13788
  }
13720
13789
  const keys = phoneHostKeys(described);
13721
13790
  if (keys.length === 0) {
13722
13791
  if (!described.includes("SHA256:"))
13723
- return fail2(`${args.keys === "-" ? "stdin" : args.keys ?? HOST_KEY_DIR} holds no public host keys.`);
13724
- 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.
13725
13794
  ` + "`sudo ssh-keygen -A` creates the missing default keys. Restart sshd after it.");
13726
13795
  }
13727
13796
  const code = pairCode(args.user ?? userInfo().username, address, keys);
13728
13797
  if ("error" in code)
13729
- return fail2(code.error);
13798
+ return fail(code.error);
13730
13799
  const columns = process.stdout.isTTY ? process.stdout.columns : undefined;
13731
13800
  process.stdout.write(pairReport({ code, keys, note: address.note, columns }));
13732
13801
  return 0;
@@ -13743,13 +13812,13 @@ async function gatherCandidates() {
13743
13812
  }
13744
13813
  var LOOKUP_MS = 1500;
13745
13814
  function dmi() {
13746
- const out2 = {};
13815
+ const out = {};
13747
13816
  for (const field of DMI_FIELDS) {
13748
13817
  try {
13749
- out2[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
13818
+ out[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
13750
13819
  } catch {}
13751
13820
  }
13752
- return out2;
13821
+ return out;
13753
13822
  }
13754
13823
  async function tailnetSelf() {
13755
13824
  const path = TAILSCALE_PATHS.find((p) => existsSync3(p));
@@ -13820,8 +13889,8 @@ async function main(argv) {
13820
13889
  if (import.meta.main)
13821
13890
  await main(process.argv);
13822
13891
  export {
13823
- serve2 as serve,
13824
- pair,
13892
+ daemon,
13825
13893
  main,
13826
- daemon
13894
+ pair,
13895
+ serve2 as serve
13827
13896
  };