ledge-server 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/serve.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  // src/bun/cli.ts
3
3
  import { homedir as homedir5 } from "os";
4
- import { join as join10, relative as relative3, resolve as resolve8, sep as sep4 } from "path";
4
+ import { join as join12, relative as relative3, resolve as resolve8, sep as sep4 } from "path";
5
5
 
6
6
  // src/bun/mcp.ts
7
7
  var PROTOCOL_VERSIONS = new Set(["2024-11-05", "2025-03-26", "2025-06-18"]);
@@ -3848,6 +3848,7 @@ var ledgeTools = [
3848
3848
  // src/bun/cliShim.ts
3849
3849
  import { homedir as homedir4 } from "os";
3850
3850
  import { join as join8, resolve as resolve6 } from "path";
3851
+ var SERVE_ENTRY = join8(import.meta.dir, "serve.js");
3851
3852
  function tildify(p, home = homedir4()) {
3852
3853
  const h = resolve6(home);
3853
3854
  const r = resolve6(p);
@@ -3856,10 +3857,98 @@ function tildify(p, home = homedir4()) {
3856
3857
  return r.startsWith(h + "/") ? `~${r.slice(h.length)}` : p;
3857
3858
  }
3858
3859
 
3860
+ // src/bun/linuxApp.ts
3861
+ import { existsSync, readdirSync, readFileSync } from "fs";
3862
+ import { dirname as dirname4, join as join9 } from "path";
3863
+ function launcherBeside(execPath) {
3864
+ const launcher = join9(dirname4(execPath), "launcher");
3865
+ return existsSync(launcher) ? launcher : null;
3866
+ }
3867
+ function launcherRunning(launcher, procDir = "/proc") {
3868
+ let entries;
3869
+ try {
3870
+ entries = readdirSync(procDir);
3871
+ } catch {
3872
+ return false;
3873
+ }
3874
+ for (const entry of entries) {
3875
+ if (!/^\d+$/.test(entry))
3876
+ continue;
3877
+ let cmdline;
3878
+ try {
3879
+ cmdline = readFileSync(join9(procDir, entry, "cmdline"), "utf8");
3880
+ } catch {
3881
+ continue;
3882
+ }
3883
+ if (cmdline.split("\x00")[0] === launcher)
3884
+ return true;
3885
+ }
3886
+ return false;
3887
+ }
3888
+ function openLinuxApp(execPath = process.execPath) {
3889
+ const launcher = launcherBeside(execPath);
3890
+ if (!launcher)
3891
+ return false;
3892
+ if (launcherRunning(launcher))
3893
+ return true;
3894
+ try {
3895
+ Bun.spawn({ cmd: [launcher], detached: true, stdio: ["ignore", "ignore", "ignore"] }).unref();
3896
+ } catch {
3897
+ return false;
3898
+ }
3899
+ return true;
3900
+ }
3901
+
3902
+ // src/bun/wslApp.ts
3903
+ import { readFileSync as readFileSync2 } from "fs";
3904
+ import { dirname as dirname5, join as join10 } from "path";
3905
+ var WINDOWS_APP_FILE = ".windows-app.json";
3906
+ function inWsl(env = process.env) {
3907
+ return process.platform === "linux" && !!env["WSL_DISTRO_NAME"] && !!env["WSL_INTEROP"];
3908
+ }
3909
+ function readWindowsApp(appHome) {
3910
+ try {
3911
+ const v = JSON.parse(readFileSync2(join10(appHome, WINDOWS_APP_FILE), "utf8"));
3912
+ if (typeof v.launcher !== "string" || !/^[A-Za-z]:\\/.test(v.launcher))
3913
+ return null;
3914
+ if (typeof v.pid !== "number" || !Number.isInteger(v.pid) || v.pid <= 0)
3915
+ return null;
3916
+ return { launcher: v.launcher, pid: v.pid };
3917
+ } catch {
3918
+ return null;
3919
+ }
3920
+ }
3921
+ function tasklistShowsBun(out) {
3922
+ return /^"bun\.exe","\d+"/im.test(out);
3923
+ }
3924
+ async function output(cmd) {
3925
+ const proc = Bun.spawn({ cmd, stdin: "ignore", stdout: "pipe", stderr: "ignore" });
3926
+ const out = await new Response(proc.stdout).text();
3927
+ return { code: await proc.exited, out };
3928
+ }
3929
+ async function openWindowsApp(appHome) {
3930
+ const app = readWindowsApp(appHome);
3931
+ if (!app)
3932
+ return false;
3933
+ try {
3934
+ const running = await output(["tasklist.exe", "/FI", `PID eq ${app.pid}`, "/FI", "IMAGENAME eq bun.exe", "/FO", "CSV", "/NH"]);
3935
+ if (tasklistShowsBun(running.out))
3936
+ return true;
3937
+ const path = await output(["wslpath", "-u", app.launcher]);
3938
+ const launcher = path.out.trim();
3939
+ if (path.code !== 0 || !launcher)
3940
+ return false;
3941
+ Bun.spawn({ cmd: [launcher], cwd: dirname5(launcher), detached: true, stdio: ["ignore", "ignore", "ignore"] }).unref();
3942
+ return true;
3943
+ } catch {
3944
+ return false;
3945
+ }
3946
+ }
3947
+
3859
3948
  // src/bun/openRequest.ts
3860
- import { join as join9, resolve as resolve7 } from "path";
3949
+ import { join as join11, resolve as resolve7 } from "path";
3861
3950
  import { readFile as readFile7, rename as rename6, unlink as unlink5, writeFile as writeFile6 } from "fs/promises";
3862
- var OPEN_REQUEST_PATH = join9(APP_HOME, ".open-request.json");
3951
+ var OPEN_REQUEST_PATH = join11(APP_HOME, ".open-request.json");
3863
3952
  var OPEN_REQUEST_MAX_AGE_MS = 60000;
3864
3953
  async function writeOpenRequest(path) {
3865
3954
  await ensureAppHome();
@@ -4053,7 +4142,10 @@ function targetArgs(arg, cwd, scope, folder) {
4053
4142
  async function openApp(io) {
4054
4143
  if (await io.openApp())
4055
4144
  return 0;
4056
- io.err(`ledge: could not open the app (bundle ${BUNDLE_ID}) \u2014 is Ledge installed? (macOS only)`);
4145
+ if (inWsl())
4146
+ io.err("ledge: could not open the app. Start Ledge on Windows once, so WSL knows where it is installed.");
4147
+ else
4148
+ io.err("ledge: could not open the app. Is this the `ledge` the app installed? (Install Shell Command)");
4057
4149
  return 1;
4058
4150
  }
4059
4151
  function humanize(msg) {
@@ -4097,7 +4189,7 @@ async function runCli(argv, io) {
4097
4189
  return 0;
4098
4190
  }
4099
4191
  if (notes.length === 0) {
4100
- io.err(ws !== null ? `no notes in ${tildify(folder === "" ? ws : join10(ws, folder))}` : "no notes");
4192
+ io.err(ws !== null ? `no notes in ${tildify(folder === "" ? ws : join12(ws, folder))}` : "no notes");
4101
4193
  return 0;
4102
4194
  }
4103
4195
  for (const line of formatNoteList(notes))
@@ -4148,7 +4240,7 @@ async function runCli(argv, io) {
4148
4240
  return 0;
4149
4241
  }
4150
4242
  if (res.tags.length === 0) {
4151
- io.err(ws !== null ? `no tags in ${tildify(folder === "" ? ws : join10(ws, folder))}` : "no tags");
4243
+ io.err(ws !== null ? `no tags in ${tildify(folder === "" ? ws : join12(ws, folder))}` : "no tags");
4152
4244
  return 0;
4153
4245
  }
4154
4246
  const width = res.tags.reduce((w, t) => Math.max(w, t.tag.length + 1), 0);
@@ -4281,6 +4373,10 @@ function processIo() {
4281
4373
  stdin: async () => process.stdin.isTTY ? null : await Bun.stdin.text(),
4282
4374
  cwd: () => process.cwd(),
4283
4375
  openApp: async () => {
4376
+ if (inWsl())
4377
+ return await openWindowsApp(APP_HOME) || openLinuxApp();
4378
+ if (process.platform === "linux")
4379
+ return openLinuxApp();
4284
4380
  if (process.platform !== "darwin")
4285
4381
  return false;
4286
4382
  const proc = Bun.spawn({ cmd: ["open", "-b", BUNDLE_ID], stdout: "ignore", stderr: "ignore" });
@@ -4290,8 +4386,8 @@ function processIo() {
4290
4386
  }
4291
4387
 
4292
4388
  // src/bun/daemon.ts
4293
- import { chmodSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
4294
- import { join as join16 } from "path";
4389
+ import { chmodSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync4, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
4390
+ import { join as join19 } from "path";
4295
4391
 
4296
4392
  // src/bun/server.ts
4297
4393
  import { watch as watch2 } from "fs";
@@ -4300,9 +4396,9 @@ import { basename as basename8, resolve as resolve10 } from "path";
4300
4396
 
4301
4397
  // src/bun/pty.ts
4302
4398
  import { dlopen, ptr, CString, cc } from "bun:ffi";
4303
- import { existsSync, writeFileSync } from "fs";
4399
+ import { existsSync as existsSync2, writeFileSync } from "fs";
4304
4400
  import { tmpdir } from "os";
4305
- import { dirname as dirname4, join as join11 } from "path";
4401
+ import { dirname as dirname6, join as join13 } from "path";
4306
4402
 
4307
4403
  // src/bun/ptyNative.ts
4308
4404
  function nativeLibName(platform) {
@@ -4431,10 +4527,10 @@ function reap(pid) {
4431
4527
  }
4432
4528
  function libCandidates() {
4433
4529
  return [
4434
- join11(import.meta.dir, NATIVE_LIB),
4435
- join11(import.meta.dir, "native", NATIVE_DIR, NATIVE_LIB),
4436
- join11(import.meta.dir, "..", "..", "dist-native", NATIVE_LIB),
4437
- join11(dirname4(process.execPath), NATIVE_LIB)
4530
+ join13(import.meta.dir, NATIVE_LIB),
4531
+ join13(import.meta.dir, "native", NATIVE_DIR, NATIVE_LIB),
4532
+ join13(import.meta.dir, "..", "..", "dist-native", NATIVE_LIB),
4533
+ join13(dirname6(process.execPath), NATIVE_LIB)
4438
4534
  ];
4439
4535
  }
4440
4536
  var native;
@@ -4447,7 +4543,7 @@ function loadNative() {
4447
4543
  setNonblock: (fd) => symbols.ledge_set_nonblock(fd)
4448
4544
  });
4449
4545
  for (const lib of libCandidates()) {
4450
- if (!existsSync(lib))
4546
+ if (!existsSync2(lib))
4451
4547
  continue;
4452
4548
  try {
4453
4549
  native = wrap(dlopen(lib, NATIVE_SYMBOLS).symbols);
@@ -4457,7 +4553,7 @@ function loadNative() {
4457
4553
  }
4458
4554
  }
4459
4555
  try {
4460
- const src = join11(tmpdir(), "ledge-pty.c");
4556
+ const src = join13(tmpdir(), "ledge-pty.c");
4461
4557
  writeFileSync(src, NATIVE_C);
4462
4558
  native = wrap(cc({ source: src, symbols: NATIVE_SYMBOLS }).symbols);
4463
4559
  } catch (err) {
@@ -4966,9 +5062,12 @@ class InlinePool {
4966
5062
  slot.preambleLen = 0;
4967
5063
  slot.echo = "";
4968
5064
  }
4969
- emit(ev, slot.client);
4970
- if (ev.type === "ended")
5065
+ if (ev.type === "ended") {
5066
+ emit(this.endedEvent(slot, ev.blockId, ev.exitCode), slot.client);
4971
5067
  this.runEnded(session, slot, ev.blockId);
5068
+ } else {
5069
+ emit(ev, slot.client);
5070
+ }
4972
5071
  }
4973
5072
  }
4974
5073
  if (slot.activeRun !== null && !slot.began && !slot.spoke && this.now() - slot.startedAt >= SILENT_MS) {
@@ -4977,7 +5076,7 @@ class InlinePool {
4977
5076
  }
4978
5077
  if (slot.abandoned && slot.activeRun !== null) {
4979
5078
  this.flushPreamble(slot, emit);
4980
- emit({ type: "ended", blockId: slot.activeRun, exitCode: null }, slot.client);
5079
+ emit(this.endedEvent(slot, slot.activeRun, null), slot.client);
4981
5080
  this.dropSlot(session, slot);
4982
5081
  continue;
4983
5082
  }
@@ -4986,7 +5085,7 @@ class InlinePool {
4986
5085
  if (open && !slot.began)
4987
5086
  this.flushPreamble(slot, emit);
4988
5087
  if (open)
4989
- emit({ type: "ended", blockId: open, exitCode: null }, slot.client);
5088
+ emit(this.endedEvent(slot, open, null), slot.client);
4990
5089
  this.dropSlot(session, slot);
4991
5090
  }
4992
5091
  }
@@ -5010,7 +5109,7 @@ class InlinePool {
5010
5109
  for (const slot of this.slots(session)) {
5011
5110
  const open = slot.parser.openBlockId ?? slot.activeRun;
5012
5111
  if (open)
5013
- emit({ type: "ended", blockId: open, exitCode: null }, slot.client);
5112
+ emit(this.endedEvent(slot, open, null), slot.client);
5014
5113
  slot.shell.close();
5015
5114
  }
5016
5115
  this.sessions.delete(sessionId);
@@ -5122,6 +5221,9 @@ class InlinePool {
5122
5221
  return slot;
5123
5222
  return session.overflow.get(id);
5124
5223
  }
5224
+ endedEvent(slot, blockId, exitCode) {
5225
+ return { type: "ended", blockId, exitCode, durationMs: this.now() - slot.startedAt };
5226
+ }
5125
5227
  runEnded(session, slot, id) {
5126
5228
  if (slot.activeRun === id)
5127
5229
  slot.activeRun = null;
@@ -5168,7 +5270,7 @@ function takePaste(t, now) {
5168
5270
  }
5169
5271
 
5170
5272
  // src/bun/profiles.ts
5171
- import { join as join12 } from "path";
5273
+ import { join as join14 } from "path";
5172
5274
  import { mkdir as mkdir4, readFile as readFile8, rename as rename7, unlink as unlink6, writeFile as writeFile7 } from "fs/promises";
5173
5275
  function assertProfileName(name) {
5174
5276
  if (!isProfileName(name))
@@ -5188,7 +5290,7 @@ function seedText(name) {
5188
5290
  async function ensureProfileFile(name) {
5189
5291
  assertProfileName(name);
5190
5292
  await mkdir4(PROFILES_DIR, { recursive: true, mode: 448 });
5191
- const path = join12(PROFILES_DIR, `${name}.env`);
5293
+ const path = join14(PROFILES_DIR, `${name}.env`);
5192
5294
  await writeFile7(path, seedText(name), { encoding: "utf8", flag: "wx", mode: 384 }).catch(() => {});
5193
5295
  return path;
5194
5296
  }
@@ -5198,8 +5300,8 @@ async function readProfile(name) {
5198
5300
  async function writeProfile(name, text) {
5199
5301
  assertProfileName(name);
5200
5302
  await mkdir4(PROFILES_DIR, { recursive: true, mode: 448 });
5201
- const path = join12(PROFILES_DIR, `${name}.env`);
5202
- const tmp = join12(PROFILES_DIR, `.${name}.env.tmp-${process.pid}`);
5303
+ const path = join14(PROFILES_DIR, `${name}.env`);
5304
+ const tmp = join14(PROFILES_DIR, `.${name}.env.tmp-${process.pid}`);
5203
5305
  try {
5204
5306
  await writeFile7(tmp, text, { encoding: "utf8", mode: 384 });
5205
5307
  await rename7(tmp, path);
@@ -5218,7 +5320,7 @@ var WELCOME_DOC = [
5218
5320
  "",
5219
5321
  "## Run a block",
5220
5322
  "",
5221
- "\u2318\u21A9 inside the block below, or the Run button on it (a tap, on a phone), runs it.",
5323
+ "\u2318\u21A9 inside the block below (Ctrl+Enter on Linux), or the Run button on it (a tap, on a phone), runs it.",
5222
5324
  "",
5223
5325
  "```sh",
5224
5326
  "curl -s https://api.github.com/zen",
@@ -5261,7 +5363,7 @@ var WELCOME_DOC = [
5261
5363
  `);
5262
5364
 
5263
5365
  // src/bun/docs.ts
5264
- import { basename as basename6, join as join13, resolve as resolve9 } from "path";
5366
+ import { basename as basename6, join as join15, resolve as resolve9 } from "path";
5265
5367
  import { mkdir as mkdir5, readdir as readdir4, readFile as readFile9, rename as rename8, unlink as unlink7, writeFile as writeFile8 } from "fs/promises";
5266
5368
 
5267
5369
  // docs/user/01-getting-started.md
@@ -5269,7 +5371,7 @@ var _01_getting_started_default = `# Getting Started
5269
5371
 
5270
5372
  Ledge is the notebook for developers and DevOps. It runs code and commands straight from your Markdown.
5271
5373
 
5272
- 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.
5374
+ 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 own computer or a new server, and it stays in the Scratch workspace until you delete it.
5273
5375
 
5274
5376
  ## Your first note
5275
5377
 
@@ -5342,16 +5444,51 @@ Notes are ordinary \`.md\` files in ordinary folders, so git, agents, and shell
5342
5444
  - **Remote hosts.** A \`host:\` line sends a note's blocks over ssh to another machine while the note stays here. See [[Run Code on Remote Hosts]].
5343
5445
  - **Remote servers.** Keep your notes on a server and use this app as the window onto it: the server holds the notes and runs the shells, over ssh. See [[Keep Notes on a Remote Server]].
5344
5446
  - **Your phone.** The same app on an iPhone or iPad, reading and running the notes on that server. See [[Ledge on Your Phone]].
5345
- - **Appearance.** Ledge follows your Mac's light or dark setting. To pin one instead, set \`appearance.theme\` to \`"light"\` or \`"dark"\` under This app in Settings (\u2318,) and relaunch.
5447
+ - **Appearance.** Ledge follows the system's light or dark setting. To pin one instead, set \`appearance.theme\` to \`"light"\` or \`"dark"\` under This app in Settings (\u2318,) and relaunch.
5346
5448
  - **Fonts.** \`editor.fontSize\` sizes note text and \`terminal.fontSize\` sizes the terminal, both under This app in Settings (\u2318,). Relaunch to apply.
5347
5449
 
5450
+ ## Ledge on Linux
5451
+
5452
+ The Linux app is the same app. The keys in this manual are the keys of the desktop you are reading it on: a Mac reads Command chords, and Linux and Windows read the same chords with Ctrl and Alt.
5453
+
5454
+ | On Linux | What differs |
5455
+ | --- | --- |
5456
+ | Tabs | Alt+1 to Alt+9 jumps to a tab, because Ctrl+1 to Ctrl+9 is the workspace jump. |
5457
+ | The terminal drawer | Every plain Ctrl chord goes to the shell, so Ctrl+C still interrupts a program. Ctrl+Shift+C copies, Ctrl+Shift+V pastes, and Ctrl+Shift+P still opens the palette from the terminal. |
5458
+ | Menus | There is no menu bar. Every menu item this manual names is in the command palette (Ctrl+Shift+P), and Quit Ledge is Ctrl+Q. |
5459
+ | Two chords GNOME keeps | Ubuntu's desktop takes Ctrl+Alt+T and Ctrl+Alt+L before any app sees them, so Toggle Tags and Toggle Backlinks run from the palette there. |
5460
+ | Passwords | Kept in the desktop's keyring through \`secret-tool\`, which the \`libsecret-tools\` package provides ([[Keep Notes on a Remote Server]]). |
5461
+ | Spelling | Enchant's dictionaries, the ones WebKitGTK underlines with, through the \`enchant-2\` command ([[Notes and Workspaces]]). |
5462
+ | Files | The app lives under \`~/.local/share/sh.ledge.app\`. Notes, settings, and the log stay under \`~/.ledge\`, as on a Mac. |
5463
+
5464
+ ## Ledge on Windows
5465
+
5466
+ The Windows app is the same app, and its keys are Linux's: the chords in this manual with Ctrl and Alt, Alt+1 to Alt+9 for tabs, no menu bar, and Quit Ledge on Ctrl+Q.
5467
+
5468
+ Your notes and your code live in WSL, the Windows Subsystem for Linux. The app is a window onto a Ledge server in your default Linux distribution, and every block runs in that distribution's shell, as a Linux command.
5469
+
5470
+ Ledge needs WSL with a Linux distribution in it before it can finish installing. When either is missing, Ledge says so and quits. To install both, open PowerShell as administrator, run \`wsl --install\`, restart Windows, and create the Linux account Ubuntu asks for. Then open Ledge again.
5471
+
5472
+ Each time it starts, Ledge checks the server in WSL and installs its own version there when that one is missing or different, which is what happens on the first launch and on the first launch after an update. A notice says "Setting up Ledge's server in WSL", and the window opens when it is done.
5473
+
5474
+ | On Windows | What differs |
5475
+ | --- | --- |
5476
+ | Paths | Everything a note names is a Linux path. \`~\` is your Linux home, and \`cwd: /mnt/c/Users/you/project\` reaches a folder on the C: drive. |
5477
+ | Where to keep notes | In your Linux home, which File Explorer shows under Linux. WSL reports no file changes under \`/mnt\`, so a workspace there does not follow edits other programs make, a \`git pull\` included. |
5478
+ | Choose Folder\u2026 | Opens in your Linux home. A folder picked on a Windows drive attaches by its \`/mnt\` path, and one WSL does not mount is refused. |
5479
+ | The CLI | \`ledge\` is already on the PATH of a new WSL terminal, and Install Shell Command (ledge) is not offered. \`ledge <title>\` in WSL opens the Windows app ([[The ledge CLI]]). |
5480
+ | Passwords | Kept in Windows Credential Manager, under Windows Credentials ([[Keep Notes on a Remote Server]]). |
5481
+ | Spelling | Windows' own spell checker, in the language Windows is set to ([[Notes and Workspaces]]). |
5482
+ | Your phone | The server in WSL serves this computer only. To read notes on your phone, keep them on a separate server ([[Ledge on Your Phone]]). |
5483
+ | Files | The app lives under \`%LOCALAPPDATA%\\sh.ledge.app\`, and Settings > Apps lists it for uninstalling. Notes and the server's settings are under \`~/.ledge\` in WSL, and profiles under \`~/.config/ledge/profiles\` there. The app's settings and its log are in \`.ledge\` in your Windows user folder. |
5484
+
5348
5485
  ## Updating Ledge
5349
5486
 
5350
5487
  Ledge checks for a newer version when it starts and once a day after that, and downloads one in the background when it finds one.
5351
5488
 
5352
- When the download finishes, a notice says so and the Ledge menu shows Restart to Install Update. Choosing it quits Ledge and reopens the new version. Notes are already saved. A block that is still running keeps running on the old server, and the new version waits for it to finish before it swaps the server for its own, so the output arrives in the new window.
5489
+ When the download finishes, a notice says so and Restart to Install Update appears in the Ledge menu (in the command palette, on Linux and Windows). Choosing it quits Ledge and reopens the new version. Notes are already saved. A block that is still running keeps running on the old server, and the new version waits for it to finish before it swaps the server for its own, so the output arrives in the new window.
5353
5490
 
5354
- Ledge > Check for Updates\u2026 checks now and tells you the result.
5491
+ Ledge > Check for Updates\u2026 (Check for Updates\u2026 in the palette, on Linux and Windows) checks now and tells you the result.
5355
5492
 
5356
5493
  To check only when you ask, set \`updates.automatic\` to \`false\` under This app in Settings (\u2318,) and relaunch. Ledge then makes no request at launch or during the day, and Check for Updates\u2026 still checks and downloads.
5357
5494
 
@@ -5359,15 +5496,18 @@ The check is a request to \`ledge.sh\` for the newest version's details. It carr
5359
5496
 
5360
5497
  ## When something goes wrong
5361
5498
 
5362
- Ledge writes a log of each session, and Help > Reveal Log in Finder opens the folder it is in.
5499
+ Ledge writes a log of each session, and Help > Reveal Log in Finder (Reveal Log in File Manager, on Linux and Windows) opens the folder it is in.
5363
5500
 
5364
- Two files sit there.
5365
- \`ledge.log\` is the session running now.
5501
+ Four files sit there.
5502
+ \`ledge.log\` is the app's session running now.
5366
5503
  \`ledge.previous.log\` is the one before it, which is the file you want after a crash: relaunching Ledge starts a new log, and this is where the old one went.
5504
+ \`ledge-server.log\` and \`ledge-server.previous.log\` are the same pair for the server that holds your notes and runs your blocks.
5505
+
5506
+ On Windows the server's pair is in WSL, in \`~/.ledge/logs\`, and the folder Reveal Log opens holds the app's pair.
5367
5507
 
5368
- Both are plain text. Attach them to a bug report.
5508
+ All four are plain text. Attach them to a bug report.
5369
5509
 
5370
- The manual ends with six tutorials that combine these into working routines: [[Tutorial: Run a Project from a Note]], [[Tutorial: A Daily Workflow]], [[Tutorial: Pair with an Agent]], [[Tutorial: Keep Notes Synced]], [[Tutorial: Set Up a Ledge Server]], and [[Tutorial: Back Up Your Notes to S3]].
5510
+ The manual ends with seven tutorials that combine these into working routines: [[Tutorial: Run a Project from a Note]], [[Tutorial: A Daily Workflow]], [[Tutorial: Pair with an Agent]], [[Tutorial: Keep Notes Synced]], [[Tutorial: Set Up a Ledge Server]], [[Tutorial: Back Up Your Notes to S3]], and [[Tutorial: Share Notes with a Git Clone]].
5371
5511
  `;
5372
5512
 
5373
5513
  // docs/user/02-running-code.md
@@ -5604,7 +5744,7 @@ A removed attached folder is never in the trash, because Ledge did nothing to it
5604
5744
 
5605
5745
  The last workspace in the strip cannot be deleted or removed.
5606
5746
 
5607
- To move an attached workspace's folder somewhere else on disk, remove it from Ledge, move the folder in Finder, and attach it again at its new place. Everything travels with the folder: the notes, the images, and the trash. A managed folder can leave \`~/.ledge\` the same way: move it out in Finder (\u21E7\u2318G opens a hidden folder), attach it at its new place, then delete the empty workspace Ledge makes in the old one's place.
5747
+ To move an attached workspace's folder somewhere else on disk, remove it from Ledge, move the folder in Finder or your file manager, and attach it again at its new place. Everything travels with the folder: the notes, the images, and the trash. A managed folder can leave \`~/.ledge\` the same way: move it out (\`~/.ledge\` is hidden: Finder's Go to Folder\u2026 reaches it, and so does Ctrl+L in GNOME Files; on Windows it is in WSL, under Linux in File Explorer), attach it at its new place, then delete the empty workspace Ledge makes in the old one's place.
5608
5748
 
5609
5749
  ## Share a workspace with others
5610
5750
 
@@ -5724,13 +5864,13 @@ The sidebar answers a right-click too. On a row you get that row's menu, and on
5724
5864
 
5725
5865
  ## Spell checking
5726
5866
 
5727
- Ledge underlines misspelled words in a note with a red squiggle, using your Mac's spelling dictionary and its languages.
5867
+ Ledge underlines misspelled words in a note with a red squiggle, using the system's spelling dictionary and its languages. On Linux that is Enchant, the checker WebKitGTK underlines with, and the right-click menu's suggestions need its \`enchant-2\` command, which Ubuntu installs beside WebKitGTK. On Windows it is Windows' own spell checker.
5728
5868
 
5729
5869
  Only prose is checked. Code blocks, \`inline code\`, URLs, HTML, the frontmatter block, \`[[wikilinks]]\` and \`#tags\` are never underlined. The built-in documentation is not checked, and neither is a note on your phone.
5730
5870
 
5731
- Right-click a misspelled word to fix it. The dictionary's suggestions sit at the top of the menu: choose one to replace the word. "Learn Spelling" adds the word to your Mac's dictionary, which every app on the Mac shares, so it stops being underlined here and elsewhere.
5871
+ Right-click a misspelled word to fix it. The dictionary's suggestions sit at the top of the menu: choose one to replace the word. "Learn Spelling" adds the word to the system's dictionary, which every app on the machine shares, so it stops being underlined here and elsewhere.
5732
5872
 
5733
- Words are judged in the language of the line they are on, so a German paragraph is checked as German.
5873
+ On a Mac, words are judged in the language of the line they are on, so a German paragraph is checked as German. On Linux and Windows they are judged in the desktop's language.
5734
5874
 
5735
5875
  Set \`editor.spellCheck\` to \`false\` under This app in Settings (\u2318,) and relaunch to turn spell checking off. The right-click menu then offers no suggestions either.
5736
5876
 
@@ -5900,10 +6040,7 @@ New Window in the File menu opens another one. A window is on one server at a ti
5900
6040
 
5901
6041
  The help button in the top right opens this manual in a window of its own, so reading it costs you nothing you had open. Pressing it again brings that window forward rather than opening a second one. It is the one window Ledge does not reopen at the next launch, since it is a button away.
5902
6042
 
5903
- Two more keys for the window itself:
5904
-
5905
- - \u2325\u2318B hides the sidebar.
5906
- - \u2303\u2318F enters full screen, also in the View menu.
6043
+ One more key for the window itself: \u2325\u2318B hides the sidebar. Full screen is Enter Full Screen in the View menu on a Mac, and the window manager's on Linux.
5907
6044
  `;
5908
6045
 
5909
6046
  // docs/user/05-finding-things.md
@@ -6052,7 +6189,7 @@ The button is about the four keys a shell is spawned with: \`cwd\`, \`profile\`,
6052
6189
  `;
6053
6190
 
6054
6191
  // docs/user/07-profiles-and-secrets.md
6055
- var _07_profiles_and_secrets_default = "# Profiles and Secrets\n\nA profile is a named file of environment variables that lives outside your notes folder and is injected into the shells of any note that names it.\n\nUse one for secrets. Notes get synced, backed up, shared, and read by agents, so an API key written in an `env:` line travels everywhere the note does. With a profile, the note carries only a name.\n\n## Declare a profile\n\nAdd one line of frontmatter (see [[Frontmatter and Environments]] for the block itself):\n\n```\n---\nprofile: deploy\n---\n```\n\nProfile names may contain letters, digits, `-`, and `_`. The name resolves to a file under `~/.config/ledge/profiles/` on the machine holding the notes, here `deploy.env`, created for you the first time you open it for editing.\n\nA note names at most one profile, and any number of notes can share one. Every deploy-related note can say `profile: deploy` and pick up the same credentials.\n\nOne name is taken. `backup` is the profile `ledge backup setup` writes, holding the backup repository and its credentials ([[Keep Notes on a Remote Server]]). A note that says `profile: backup` runs with those variables, which is how a note runs restic by hand.\n\n## Edit a profile\n\nClick the profile name in the frontmatter block, or run \"Edit Note Profile\u2026\" from the command palette. The command appears whenever the current note names a profile.\n\nOn a touch device the palette command is the whole of it. The small key button beside the name is a pointer control and is not drawn there, and the command asks for nothing to be pointed at: it follows the note you are in.\n\nEither way you get Ledge's profile editor: KEY=value rows with the values masked.\n\nOn disk the profile is a plain dotenv file: `KEY=value` per line, `#` comments, and an optional `export ` prefix. Ledge creates it readable only by you. Hand edits and editor edits coexist, and saves from the editor preserve your comments.\n\n```\n# deploy.env\nAPI_TOKEN=abc123\nDEPLOY_REGION=eu-west-1\n```\n\n## How profiles layer\n\nProfile variables merge into the shell environment at spawn, above the note's `envFile` and below its inline `env:` lines. An `env:` line can therefore override a profile value for one note without editing the shared file.\n\nA `profile:` line naming a file that does not exist is skipped, and the shell spawns without it.\n\nA profile edit applies to newly spawned shells, like every frontmatter change. Changing which profile a note names raises the block's **Restart Note Shell** button; editing the values inside a profile file does not, so run the command yourself after that.\n\n## Profiles stay with the notes\n\nA profile lives on the machine that holds the notes and runs their blocks. With your notes on this Mac, that is this Mac. With your notes on a server, the file is on the server, \"Edit Note Profile\u2026\" edits it there, and the values never come to this app. [[Keep Notes on a Remote Server]] has the table of what lives where.\n\nWhen a note runs its blocks on a remote host over ssh, Ledge does not send the profile ([[Run Code on Remote Hosts]]). A secret passed on a remote command line would be visible in that machine's process table to anyone who can list processes. If a remote run needs credentials, put them on the remote machine.\n";
6192
+ var _07_profiles_and_secrets_default = "# Profiles and Secrets\n\nA profile is a named file of environment variables that lives outside your notes folder and is injected into the shells of any note that names it.\n\nUse one for secrets. Notes get synced, backed up, shared, and read by agents, so an API key written in an `env:` line travels everywhere the note does. With a profile, the note carries only a name.\n\n## Declare a profile\n\nAdd one line of frontmatter (see [[Frontmatter and Environments]] for the block itself):\n\n```\n---\nprofile: deploy\n---\n```\n\nProfile names may contain letters, digits, `-`, and `_`. The name resolves to a file under `~/.config/ledge/profiles/` on the machine holding the notes, here `deploy.env`, created for you the first time you open it for editing.\n\nA note names at most one profile, and any number of notes can share one. Every deploy-related note can say `profile: deploy` and pick up the same credentials.\n\nOne name is taken. `backup` is the profile `ledge backup setup` writes, holding the backup repository and its credentials ([[Keep Notes on a Remote Server]]). A note that says `profile: backup` runs with those variables, which is how a note runs restic by hand.\n\n## Edit a profile\n\nClick the profile name in the frontmatter block, or run \"Edit Note Profile\u2026\" from the command palette. The command appears whenever the current note names a profile.\n\nOn a touch device the palette command is the whole of it. The small key button beside the name is a pointer control and is not drawn there, and the command asks for nothing to be pointed at: it follows the note you are in.\n\nEither way you get Ledge's profile editor: KEY=value rows with the values masked.\n\nOn disk the profile is a plain dotenv file: `KEY=value` per line, `#` comments, and an optional `export ` prefix. Ledge creates it readable only by you. Hand edits and editor edits coexist, and saves from the editor preserve your comments.\n\n```\n# deploy.env\nAPI_TOKEN=abc123\nDEPLOY_REGION=eu-west-1\n```\n\n## How profiles layer\n\nProfile variables merge into the shell environment at spawn, above the note's `envFile` and below its inline `env:` lines. An `env:` line can therefore override a profile value for one note without editing the shared file.\n\nA `profile:` line naming a file that does not exist is skipped, and the shell spawns without it.\n\nA profile edit applies to newly spawned shells, like every frontmatter change. Changing which profile a note names raises the block's **Restart Note Shell** button; editing the values inside a profile file does not, so run the command yourself after that.\n\n## Profiles stay with the notes\n\nA profile lives on the machine that holds the notes and runs their blocks. With your notes on this computer, that is this computer. With your notes on a server, the file is on the server, \"Edit Note Profile\u2026\" edits it there, and the values never come to this app. [[Keep Notes on a Remote Server]] has the table of what lives where.\n\nWhen a note runs its blocks on a remote host over ssh, Ledge does not send the profile ([[Run Code on Remote Hosts]]). A secret passed on a remote command line would be visible in that machine's process table to anyone who can list processes. If a remote run needs credentials, put them on the remote machine.\n";
6056
6193
 
6057
6194
  // docs/user/08-run-code-on-remote-hosts.md
6058
6195
  var _08_run_code_on_remote_hosts_default = `# Run Code on Remote Hosts
@@ -6179,11 +6316,11 @@ Paste the link only if it came from your own server. A code holds no password or
6179
6316
 
6180
6317
  A server in your list can hand its code to your phone without a terminal. Open Notes On\u2026, and click the QR code icon on the server's row.
6181
6318
 
6182
- Ledge shows the code, then the account, host, port and host key it names, then the same code as a link. Scan the QR code with Ledge on your phone ("Pair with a code" on [[Ledge on Your Phone]]), or use Copy Link and paste it into another Mac's Add Server form.
6319
+ Ledge shows the code, then the account, host, port and host key it names, then the same code as a link. Scan the QR code with Ledge on your phone ("Pair with a code" on [[Ledge on Your Phone]]), or use Copy Link and paste it into another computer's Add Server form.
6183
6320
 
6184
- The code is made from what this Mac already has: the address it dials and the host key you pinned. Nothing is sent to the server to make it, and it holds no password or key. The phone still signs in with its own key, which has to be in the server's \`authorized_keys\`, or with a password.
6321
+ The code is made from what this computer already has: the address it dials and the host key you pinned. Nothing is sent to the server to make it, and it holds no password or key. The phone still signs in with its own key, which has to be in the server's \`authorized_keys\`, or with a password.
6185
6322
 
6186
- The phone has to reach the host by the same address this Mac does. A server on your home network or tailnet works from a phone on that network or tailnet, and not from elsewhere. A destination that is an alias from your \`~/.ssh/config\` means nothing to a phone: give the server its real address here first.
6323
+ The phone has to reach the host by the same address this computer does. A server on your home network or tailnet works from a phone on that network or tailnet, and not from elsewhere. A destination that is an alias from your \`~/.ssh/config\` means nothing to a phone: give the server its real address here first.
6187
6324
 
6188
6325
  A server with no pinned key has no code, since the code names the key. Edit the server and use "Check Key Again" to pin one. A phone checks only Ed25519 and ECDSA host keys, so a server pinned to an RSA key has no code either.
6189
6326
 
@@ -6199,7 +6336,7 @@ Changing the address to a different machine does not. The button reads "Continue
6199
6336
 
6200
6337
  Use "Check Key Again" when a server you already have has legitimately rotated its host key. It is the same fingerprint step, on a connection you keep.
6201
6338
 
6202
- This Mac cannot be removed or edited, and neither can the connection you are currently using: switch somewhere else first.
6339
+ The row for this computer cannot be removed or edited, and neither can the connection you are currently using: switch somewhere else first.
6203
6340
 
6204
6341
  ## Sign in with a password
6205
6342
 
@@ -6207,9 +6344,9 @@ Choose "A password" in the form and type the password for that account on that m
6207
6344
 
6208
6345
  Use it when the machine has no key on it yet. A fresh VPS with a password is a machine you can reach today, and setting up a key afterwards is a change you make once. Keys are the better long-term answer, and switching a connection over to one later is one edit.
6209
6346
 
6210
- Ledge keeps the password in your Mac's keychain and never in \`~/.ledge\`. When ssh asks for it, ssh reads it from the keychain itself, so the password does not pass through Ledge on its way out.
6347
+ Ledge keeps the password in the system keychain and never in \`~/.ledge\`: the macOS keychain on a Mac, Credential Manager on Windows, and on Linux the desktop's keyring (GNOME Keyring or KWallet) through \`secret-tool\`, which the \`libsecret-tools\` package provides. When ssh asks for it, ssh reads it from the keychain itself, so the password does not pass through Ledge on its way out.
6211
6348
 
6212
- Anything running as you on this Mac can read that keychain item. That is the same reach a private key file in \`~/.ssh\` gives, so a password here is neither safer nor less safe than the key it stands in for.
6349
+ Anything running as you on this computer can read that keychain item. That is the same reach a private key file in \`~/.ssh\` gives, so a password here is neither safer nor less safe than the key it stands in for.
6213
6350
 
6214
6351
  Removing the connection removes the password with it. So does switching that connection back to a key.
6215
6352
 
@@ -6225,17 +6362,17 @@ The picker opens on the connection in use, so Enter means stay and moving somewh
6225
6362
 
6226
6363
  Switching closes every tab and opens that machine's instead. Nothing is lost: the tabs are on the other machine and come back when you switch back.
6227
6364
 
6228
- A connection that will not open costs you nothing. Ledge reaches the new machine before it lets go of the old one, so a typo or a sleeping laptop leaves you exactly where you were with the reason on screen. If the failure happens at launch, Ledge opens on this Mac and the bar reads "not reachable".
6365
+ A connection that will not open costs you nothing. Ledge reaches the new machine before it lets go of the old one, so a typo or a sleeping laptop leaves you exactly where you were with the reason on screen. If the failure happens at launch, Ledge opens on this computer and the bar reads "not reachable".
6229
6366
 
6230
6367
  ## Two machines at once
6231
6368
 
6232
6369
  New Window in the File menu opens a second window, and each window is on its own machine. Switching moves one window; a second window is how you have a build box and a VPS open side by side.
6233
6370
 
6234
- A new window opens on this Mac. Switch it wherever you like from inside it.
6371
+ A new window opens on this computer. Switch it wherever you like from inside it.
6235
6372
 
6236
- Each window is titled after the machine it is on, so the title bar reads "This Mac" or the name you gave the connection. That is the name in the Window menu too, and on a window's tab when macOS merges your windows into tabs.
6373
+ Each window is titled after the machine it is on, so the title bar reads "This Mac" ("This Computer", on Linux and Windows) or the name you gave the connection. On a Mac that is the name in the Window menu too, and on a window's tab when macOS merges your windows into tabs.
6237
6374
 
6238
- The manual's window is the exception, titled "Documentation". It reads the copy of the manual that ships with this app, so it stays on this Mac whichever machine the window you opened it from is on.
6375
+ The manual's window is the exception, titled "Documentation". It reads the copy of the manual that ships with this app, so it stays on this computer whichever machine the window you opened it from is on.
6239
6376
 
6240
6377
  Each window keeps its own tabs and panes, and the server remembers them: switch a window back to a machine you used before and its arrangement comes back. Ledge reopens every window you left open at the next launch, each on the machine it was pointed at.
6241
6378
 
@@ -6261,9 +6398,9 @@ curl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh
6261
6398
 
6262
6399
  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.
6263
6400
 
6264
- 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").
6401
+ A computer 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. The machine also needs an ssh server: Remote Login on a Mac ("Expose ssh carefully"), or the \`openssh-server\` package on a Linux desktop.
6265
6402
 
6266
- 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.
6403
+ 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. Windows itself is not either: the Windows app runs its server in WSL, which is Linux.
6267
6404
 
6268
6405
  Nothing else has to be installed and no port is opened. Ledge speaks its protocol over ssh's stdin and stdout.
6269
6406
 
@@ -6373,19 +6510,19 @@ On a Mac, the server needs Remote Login turned on in System Settings, under Gene
6373
6510
  | Notes, images, and the trash | Window size and position |
6374
6511
  | Workspaces, and where each attached folder is | The clipboard |
6375
6512
  | Shells, running blocks, and scrollback | Which servers you have added and their pinned host keys |
6376
- | The vault and locked notes | Any stored passwords, in this Mac's keychain |
6377
- | Profiles and their secrets | The \`ledge\` command, which reads this Mac's notes whichever server the window is on ([[The ledge CLI]]) |
6513
+ | The vault and locked notes | Any stored passwords, in this computer's keychain |
6514
+ | Profiles and their secrets | The \`ledge\` command, which reads this computer's notes whichever server the window is on ([[The ledge CLI]]) |
6378
6515
  | The settings for the shell, interpreters, the trash, and daily notes | The settings for the theme, font sizes, live preview, and update checks |
6379
6516
 
6380
6517
  Settings (\u2318,) has a tab for each column, Server and This app, and each tab is its own file. The This app half follows you between machines. The Server half describes the machine it is on, because a VPS's shell is not your laptop's.
6381
6518
 
6382
- A Mac that runs the app is a server too. The app starts one of its own, so with no server added both columns are on this Mac, and the Server tab edits this Mac's file. Add a server and the left column moves there with your notes: the Server tab edits that machine's file, and so does "Edit Note Profile\u2026".
6519
+ A computer that runs the app is a server too. The app starts one of its own, so with no server added both columns are on this computer, and the Server tab edits this computer's file. Add a server and the left column moves there with your notes: the Server tab edits that machine's file, and so does "Edit Note Profile\u2026".
6383
6520
 
6384
6521
  Profile values never cross the connection. A note names a profile and the server reads the file at spawn, so the secrets exist only where the commands run ([[Profiles and Secrets]]).
6385
6522
 
6386
6523
  Unlocking a locked note sends the passphrase to the server, which is the only machine that can use it ([[Note Locking]]). The vault and its idle relock timer stay there.
6387
6524
 
6388
- Each device unlocks for itself. Typing the passphrase on your Mac does not open the locked notes on your phone, and \u2318L on one leaves the other reading. Every window on the same Mac shares one unlock.
6525
+ Each device unlocks for itself. Typing the passphrase on your computer does not open the locked notes on your phone, and \u2318L on one leaves the other reading. Every window on the same computer shares one unlock.
6389
6526
 
6390
6527
  ## Back up the server
6391
6528
 
@@ -6405,7 +6542,7 @@ Run them as the account the server runs as, on the machine the server runs on.
6405
6542
 
6406
6543
  What a backup covers is decided at every run, because only the server knows it: the app home, every workspace folder you attached from elsewhere on the machine, and the profiles directory. Inside the app home it skips the daemon's socket and pidfile, the logs, the copy of this manual, and the installed server in \`.server\`. An attached folder that is not on disk at the time, on an unmounted volume say, is skipped, said on stderr, and shown by \`status\` until it is back.
6407
6544
 
6408
- Backups run every hour while the server is up, and once more before it exits: after the app closes on a Mac, or after the last device disconnects from a VPS. The repository keeps 24 hourly, 30 daily, 12 weekly, and 24 monthly snapshots, and the rest are dropped.
6545
+ Backups run every hour while the server is up, and once more before it exits: after the app closes on a desktop, or after the last device disconnects from a VPS. The repository keeps 24 hourly, 30 daily, 12 weekly, and 24 monthly snapshots, and the rest are dropped.
6409
6546
 
6410
6547
  Three things to know before you rely on it:
6411
6548
 
@@ -6438,15 +6575,15 @@ A snapshot also lives in the account that pays for the server, so a lost login o
6438
6575
 
6439
6576
  ## Several devices on one server
6440
6577
 
6441
- A server serves every device that connects to it. Your Mac and your phone can both be on the same server at once, reading the same notes and running commands ([[Ledge on Your Phone]]).
6578
+ A server serves every device that connects to it. Your computer and your phone can both be on the same server at once, reading the same notes and running commands ([[Ledge on Your Phone]]).
6442
6579
 
6443
- Each device keeps its own tabs and panes. The server files them under the device that arranged them, so a phone does not open into a Mac's three-pane layout.
6580
+ Each device keeps its own tabs and panes. The server files them under the device that arranged them, so a phone does not open into a desktop's three-pane layout.
6444
6581
 
6445
- A second Ledge window counts as another device here. Point two windows at one server and each is listed in the other's connection bar, and a note's terminal has one owner between them, exactly as a Mac and a phone would.
6582
+ A second Ledge window counts as another device here. Point two windows at one server and each is listed in the other's connection bar, and a note's terminal has one owner between them, exactly as a desktop and a phone would.
6446
6583
 
6447
- The connection bar shows who else is connected: one other device by name, more than one as a count. Hover it for the full list. Names come from the devices themselves, so a Mac uses its computer name, and a device that gives no name reads as "another device".
6584
+ The connection bar shows who else is connected: one other device by name, more than one as a count. Hover it for the full list. Names come from the devices themselves, so a desktop uses its computer name, and a device that gives no name reads as "another device".
6448
6585
 
6449
- Nothing appears there when you are the only one connected. A Mac that runs the app is a server of its own, so a phone signed in to it, or a second window on it, appears there the same way.
6586
+ Nothing appears there when you are the only one connected. A computer that runs the app is a server of its own, so a phone signed in to it, or a second window on it, appears there the same way.
6450
6587
 
6451
6588
  A note saved on one device appears on the other without a refresh. Everything else a server owns is shared the same way: the same workspaces, the same trash, the same tags and backlinks, the same vault.
6452
6589
 
@@ -6514,7 +6651,7 @@ You cannot start a run at all once the bar reads "disconnected". Every block's R
6514
6651
 
6515
6652
  A Ledge that has relaunched has no panel, and no way to show that run or stop it. So blocks left running on a server are stopped the next time Ledge connects to it, which includes switching to another connection and back. A terminal is not affected, because reattaching finds its shell where you left it.
6516
6653
 
6517
- This reaches only the blocks that device started. A server can be carrying runs for more than one of your devices, and a phone connecting does not stop what your Mac left running.
6654
+ This reaches only the blocks that device started. A server can be carrying runs for more than one of your devices, and a phone connecting does not stop what your computer left running.
6518
6655
 
6519
6656
  A save that was in flight when the wire dropped is retried once the connection is back, and applied once, even if the first attempt had already landed.
6520
6657
 
@@ -6552,11 +6689,11 @@ Ledge also stops when the server hangs up on purpose rather than the wire failin
6552
6689
  // docs/user/10-ledge-on-your-phone.md
6553
6690
  var _10_ledge_on_your_phone_default = `# Ledge on Your Phone
6554
6691
 
6555
- 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.
6692
+ 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 the desktop app does in [[Keep Notes on a Remote Server]], and shows you what is there.
6556
6693
 
6557
6694
  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.
6558
6695
 
6559
- 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).
6696
+ A server that already serves your desktop 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).
6560
6697
 
6561
6698
  ## The first screen
6562
6699
 
@@ -6564,7 +6701,7 @@ The first launch opens on "Connect to your Ledge server", which offers three way
6564
6701
 
6565
6702
  | Control | Use it when |
6566
6703
  | --- | --- |
6567
- | Scan a pairing code | The server can show a code with \`ledge pair\`, or a Mac that already has the server can show one ("Pair with a code") |
6704
+ | Scan a pairing code | The server can show a code with \`ledge pair\`, or a desktop that already has the server can show one ("Pair with a code") |
6568
6705
  | I don't have a server yet | You have a Mac or Linux machine with ssh, and Ledge is not installed on it ("Set up a server") |
6569
6706
  | Add an existing server | Ledge is already installed on the server, and you would rather type its account and address ("Pair by address") |
6570
6707
 
@@ -6578,9 +6715,9 @@ ledge pair
6578
6715
 
6579
6716
  On a terminal, it first lists every address the machine has, with a note on which devices reach each one: its tailnet name and address, the address your ssh session reached, its public address when it runs in a cloud, its other network addresses, and its name. Type a number to pick one, or an address of your own as \`host\` or \`host:port\`, or press Return for the first. It then prints the code as a QR code, then the account, host, port, and host keys it holds, then the same code as a link. Without a terminal, it takes the first address and lists the rest under the code, and \`--host\` names one on the next run. \`ledge pair --help\` lists the other flags.
6580
6717
 
6581
- A Mac that already has the server in its list can show the same code without a terminal: the QR code icon on the server's row in Notes On\u2026 ("Show a pairing code for a server" on [[Keep Notes on a Remote Server]]). The same link pastes into the Mac app's Add Server form ("Add a server from a pairing code" on that page).
6718
+ A desktop that already has the server in its list can show the same code without a terminal: the QR code icon on the server's row in Notes On\u2026 ("Show a pairing code for a server" on [[Keep Notes on a Remote Server]]). The same link pastes into the desktop app's Add Server form ("Add a server from a pairing code" on that page).
6582
6719
 
6583
- The code names one address, and the reader connects to exactly that, so pick the one your other devices reach from where they will be. A tailnet name works from anywhere a device is on the tailnet. A home network address works from a device on that network. A cloud machine's public address works from anywhere, when its sshd is reachable from outside. A machine behind a router's port forward has an outside address no source knows: type it at the menu with its port, or give them with \`--host\` and \`--port\`. A Mac's code names the address the Mac dials, with the same reach.
6720
+ The code names one address, and the reader connects to exactly that, so pick the one your other devices reach from where they will be. A tailnet name works from anywhere a device is on the tailnet. A home network address works from a device on that network. A cloud machine's public address works from anywhere, when its sshd is reachable from outside. A machine behind a router's port forward has an outside address no source knows: type it at the menu with its port, or give them with \`--host\` and \`--port\`. A desktop's code names the address that desktop dials, with the same reach.
6584
6721
 
6585
6722
  On the phone, tap Scan a pairing code on the first screen, or in Add Server\u2026 inside the app ("More than one server" below), and point the camera at the QR code. Scan it from Ledge rather than the Camera app, which opens the code in Safari. Ledge shows what the code names and connects only when you tap Connect. Choose how to sign in first, the same way as in "Pair by address": with a key, whose line still has to be in the server's \`authorized_keys\`, or with a password. Ledge signs in only if the server offers one of the host keys in the code, so there is no fingerprint to check by eye.
6586
6723
 
@@ -6603,7 +6740,7 @@ Run them in a terminal on that machine, signed in as the account the phone shoul
6603
6740
 
6604
6741
  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.
6605
6742
 
6606
- 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.
6743
+ On a Mac, turn on Remote Login first, in System Settings under General, then Sharing; on a Linux desktop, install the \`openssh-server\` package. A computer 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.
6607
6744
 
6608
6745
  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.
6609
6746
 
@@ -6627,11 +6764,11 @@ The command adds that line to the file, creating \`~/.ssh\` first if the account
6627
6764
  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
6628
6765
  \`\`\`
6629
6766
 
6630
- 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.
6767
+ Run it on the server, signed in as the account Ledge uses: over ssh from your computer, 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.
6631
6768
 
6632
6769
  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).
6633
6770
 
6634
- The third is Connect. The phone dials the server, shows its host key fingerprint, and asks "Is this the server?" alongside the command that prints the same fingerprint on the server. Trust pins the key, and a server that later presents a different one is refused, the same as on a Mac.
6771
+ The third is Connect. The phone dials the server, shows its host key fingerprint, and asks "Is this the server?" alongside the command that prints the same fingerprint on the server. Trust pins the key, and a server that later presents a different one is refused, the same as on the desktop.
6635
6772
 
6636
6773
  Ledge adds the server only once \`ledge serve\` answers there. On a machine where ssh cannot find it, Connect says "Ledge's server is not installed" and adds nothing. Install it, then tap Connect again.
6637
6774
 
@@ -6653,11 +6790,11 @@ Removing the last server returns the phone to the first screen. Deleting the app
6653
6790
 
6654
6791
  ## More than one server
6655
6792
 
6656
- 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.
6793
+ Inside the app the connection bar works as on the desktop: 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 the desktop's shows a key path, with Share Command beside Copy Command.
6657
6794
 
6658
- 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.
6795
+ Add Server\u2026 starts with Scan a pairing code, where the desktop'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.
6659
6796
 
6660
- A phone and a Mac can be on one server at once. Each keeps its own tabs, and a note's terminal has one owner between them.
6797
+ A phone and a desktop can be on one server at once. Each keeps its own tabs, and a note's terminal has one owner between them.
6661
6798
 
6662
6799
  ## What a phone does
6663
6800
 
@@ -6676,9 +6813,9 @@ A phone and a Mac can be on one server at once. Each keeps its own tabs, and a n
6676
6813
 
6677
6814
  The pages for those features say how each works on a touch screen: Run on every block, the Code Block button, and the control keys above the keyboard in [[Running Code]], adding a picture in [[Images]], the mode chips under the search field in [[Finding Things]], and splits in [[Panes and Tabs]].
6678
6815
 
6679
- Tapping through the tree reuses one tab rather than filling the strip, since a note you tap opens as an italic preview ([[Panes and Tabs]]). A long press on the tab holds Keep Tab Open, which is what makes it stay, and so does typing in the note. It matters more here than on a Mac: there is no \u2318W, so a strip that filled up would take a long press and a menu item per tab to empty.
6816
+ Tapping through the tree reuses one tab rather than filling the strip, since a note you tap opens as an italic preview ([[Panes and Tabs]]). A long press on the tab holds Keep Tab Open, which is what makes it stay, and so does typing in the note. It matters more here than on a desktop: there is no \u2318W, so a strip that filled up would take a long press and a menu item per tab to empty.
6680
6817
 
6681
- A block keeps running on the server while the app is in the background, and what it printed is waiting when you come back. A program that needs a whole terminal belongs in a Mac's drawer on the same server.
6818
+ A block keeps running on the server while the app is in the background, and what it printed is waiting when you come back. A program that needs a whole terminal belongs in a desktop's drawer on the same server.
6682
6819
 
6683
6820
  Unlocking a locked note asks for the passphrase every time. The phone stores none of it, and Face ID does not stand in for it. The relock timer is the server's, so a phone put away for an hour finds its locked notes closed again ([[Note Locking]]).
6684
6821
 
@@ -6778,9 +6915,9 @@ A reference points at the file from the note that holds it, the way Markdown ref
6778
6915
 
6779
6916
  Run **Insert Image\u2026** from the palette to pick a picture instead of pasting one. Ledge saves it and inserts the reference exactly as a paste does.
6780
6917
 
6781
- On a Mac this opens a file dialog. On a phone it asks where the picture is: **Photo Library**, **Take Photo**, or **Choose File** for one in the Files app. The button for it sits on the bar above the keyboard. Take Photo appears only on a device with a camera, and the first time it asks permission to use it.
6918
+ On a desktop this opens a file dialog. On a phone it asks where the picture is: **Photo Library**, **Take Photo**, or **Choose File** for one in the Files app. The button for it sits on the bar above the keyboard. Take Photo appears only on a device with a camera, and the first time it asks permission to use it.
6782
6919
 
6783
- On a Mac, a picture chosen this way is saved as a JPEG when it already is one (a photograph stays a tenth of the size it would be as a PNG), and as a PNG otherwise. On a phone it is saved as a JPEG, except a PNG chosen from Files, which stays a PNG so a transparent background survives. A picture pasted on a phone is saved as a JPEG too. A phone does not carry location data over.
6920
+ On a desktop, a picture chosen this way is saved as a JPEG when it already is one (a photograph stays a tenth of the size it would be as a PNG), and as a PNG otherwise. On a phone it is saved as a JPEG, except a PNG chosen from Files, which stays a PNG so a transparent background survives. A picture pasted on a phone is saved as a JPEG too. A phone does not carry location data over.
6784
6921
 
6785
6922
  ## What renders
6786
6923
 
@@ -6841,7 +6978,7 @@ One passphrase covers every locked note, on the device you type it on.
6841
6978
  - The vault also relocks itself after 15 minutes in which no note changed. Reading a note does not hold it open, and neither does an agent working in your notes while you are away.
6842
6979
  - Quitting Ledge locks its notes, and so does a crash. The next launch asks for the passphrase again.
6843
6980
 
6844
- Unlocking covers every window on that Mac, so opening a second window does not ask again.
6981
+ Unlocking covers every window on that computer, so opening a second window does not ask again.
6845
6982
 
6846
6983
  Once unlocked, every locked note reads and edits like a normal note, and saves go back to disk encrypted. Locked notes show a lock glyph in the sidebar and in \u2318P, drawn open while the vault is unlocked, so you can see what is readable without opening anything.
6847
6984
 
@@ -6853,7 +6990,7 @@ A locked note is self-contained. Carried to another of your machines, it unlocks
6853
6990
 
6854
6991
  If you keep notes on a server and reach them from more than one device ([[Keep Notes on a Remote Server]]), each device unlocks for itself.
6855
6992
 
6856
- Unlocking on your Mac does not unlock your phone. Both use the same passphrase, and neither can see the other's locked notes until it is typed there. \u2318L works the same way: it locks the device you run it on and leaves the others reading.
6993
+ Unlocking on your computer does not unlock your phone. Both use the same passphrase, and neither can see the other's locked notes until it is typed there. \u2318L works the same way: it locks the device you run it on and leaves the others reading.
6857
6994
 
6858
6995
  This is the behavior a stolen phone needs. Somebody holding an unlocked, paired device still has to know the passphrase to open a locked note on it.
6859
6996
 
@@ -6907,86 +7044,10 @@ Locking protects notes at rest and from the software Ledge invites in. It does n
6907
7044
  `;
6908
7045
 
6909
7046
  // docs/user/14-agents-and-ledge.md
6910
- var _14_agents_and_ledge_default = "# Agents and Ledge\n\nLedge is built to be worked by AI agents as well as by you. An agent CLI such as Claude Code can read, search, create, and edit your notes through Ledge's MCP server. A terminal launched inside a note already knows which note it is in, and a `prompt` code fence turns a paragraph of instructions into a runnable block.\n\n## Connect an agent\n\nLedge ships an MCP server. `ledge mcp` serves it on stdio, so install the `ledge` command first (see [[The ledge CLI]]). Any MCP-speaking agent can use it. For Claude Code it is one line, in your own terminal:\n\n```sh norun\nclaude mcp add ledge -- ledge mcp\n```\n\nThe MCP server reads the notes on the machine it runs on. For an agent running on a server, the same line works there, since the server install puts `ledge` on that machine's PATH too.\n\nThe server exposes eleven tools:\n\n| Read | Write |\n| --- | --- |\n| `list_workspaces`, `list_notes`, `read_note`, `search_notes`, `backlinks`, `tags`, `settings` | `create_note`, `daily_note`, `append_note`, `edit_note` |\n\nNotes are addressed by title, which survives renames, so an agent's references do not go stale. Every tool goes through the same store and the same path guards as the app.\n\nTwo boundaries hold in every case: there is no delete tool, and locked notes refuse their bodies to every agent surface (see [[Note Locking]]).\n\n## Agents and folders\n\nTwo notes in different folders may share a title, so `list_notes` tells them apart: every row says which folder its note is in, and a note at the top level says nothing. Read a note by that title and the answer names its folder too.\n\nListing, searching, and the tag tools take a `folder` to narrow to one, and it covers the folders inside it as well. The tools that address a note by title take one too, which is how an agent says which of two notes sharing a title it means. `create_note` takes one to place a new note, creating the folder if it is new, and `daily_note` takes one for the day it creates today's note.\n\nWithout a folder a new note lands at the top level of the workspace, which is where your own New Note puts one. There is no tool for moving a note afterwards, and none for renaming or deleting a folder: filing is yours, in the sidebar ([[Notes and Workspaces]]).\n\n## Agents know which note they are in\n\nEvery shell a note spawns carries two environment variables: `LEDGE_NOTE`, the note's file, and `LEDGE_WORKSPACE`, its workspace folder. An agent launched in a note's terminal drawer picks these up through the MCP server:\n\n- `read_note` with no arguments reads the note the terminal belongs to.\n- `append_note` and `edit_note` default to that note.\n- `create_note` lands in its workspace, at the top level unless it names a folder.\n\nSo \"summarize this note\" or \"add a TODO section here\" needs no explanation of what \"this\" means. Open the note you are working in, press \u2303` for its terminal, start your agent, and talk about \"this note\" and \"this workspace\" in plain words.\n\n## Prompt fences\n\nA fenced block whose language is `prompt` is an agent run. Write instructions in it and press \u2318\u21A9. The block's text is piped to the agent CLI in one-shot mode, and the output streams into the panel below like any other run ([[Running Code]]).\n\nA prompt fence in a note might read:\n\n```prompt norun\nSummarize this note in one sentence.\n```\n\nThe answer streams into the panel beneath it. Instructions can also change things: \"append a Next steps section to this note\", or \"create a note titled Retro from what we discussed above\".\n\nThe block runs from the note's own shell, so the agent inherits the note's `cwd`, `env`, and the environment variables above.\n\nTwo things to expect. There is a pause before the answer appears, because one-shot mode thinks first and prints once. And since nobody is present to answer follow-up questions, the agent is instructed to act and report rather than ask.\n\nBy default the fence runs Claude Code (`claude -p`) with Ledge's own tools pre-authorized, because a non-interactive run has no one to click \"allow\". The command is an interpreter entry in Settings (\u2318,) under `blocks.interpreters`, key `prompt`. Point it at any CLI that reads its prompt on stdin to switch agents.\n\nA daily template carrying a prompt fence such as `Summarize [[{{yesterday}}]]` gives every day's note a one-keystroke briefing (see [[Daily Notes and Templates]]).\n\n## What agents cannot see\n\nAgents see the titles, bodies, tags, and links of ordinary notes. They can read this manual too, so \"check the Ledge docs\" is a fair instruction.\n\nThey never see the body of a locked note. Reads refuse with an explanation, searches skip locked notes and report how many they skipped, and listings flag them so an agent can plan around it.\n\nSettings are readable but not writable. The `settings` tool shows an agent your `settings.jsonc` with its comments, so it can answer \"which python is that block using\" from your actual configuration and name the line to change. Making the change is yours, in the app (\u2318,), and it applies at the next launch.\n\nDeletion is yours alone, in the app, where the trash and Undo live.\n";
7047
+ var _14_agents_and_ledge_default = "# Agents and Ledge\n\nLedge is built to be worked by AI agents as well as by you. An agent CLI such as Claude Code can read, search, create, and edit your notes through Ledge's MCP server. A terminal launched inside a note already knows which note it is in, and a `prompt` code fence turns a paragraph of instructions into a runnable block.\n\n## Connect an agent\n\nLedge ships an MCP server. `ledge mcp` serves it on stdio, so install the `ledge` command first (see [[The ledge CLI]]). Any MCP-speaking agent can use it. For Claude Code it is one line, in your own terminal:\n\n```sh norun\nclaude mcp add ledge -- ledge mcp\n```\n\nThe MCP server reads the notes on the machine it runs on. For an agent running on a server, the same line works there, since the server install puts `ledge` on that machine's PATH too. On Windows the notes are in WSL, so run the agent there, where `ledge` already is.\n\nThe server exposes eleven tools:\n\n| Read | Write |\n| --- | --- |\n| `list_workspaces`, `list_notes`, `read_note`, `search_notes`, `backlinks`, `tags`, `settings` | `create_note`, `daily_note`, `append_note`, `edit_note` |\n\nNotes are addressed by title, which survives renames, so an agent's references do not go stale. Every tool goes through the same store and the same path guards as the app.\n\nTwo boundaries hold in every case: there is no delete tool, and locked notes refuse their bodies to every agent surface (see [[Note Locking]]).\n\n## Agents and folders\n\nTwo notes in different folders may share a title, so `list_notes` tells them apart: every row says which folder its note is in, and a note at the top level says nothing. Read a note by that title and the answer names its folder too.\n\nListing, searching, and the tag tools take a `folder` to narrow to one, and it covers the folders inside it as well. The tools that address a note by title take one too, which is how an agent says which of two notes sharing a title it means. `create_note` takes one to place a new note, creating the folder if it is new, and `daily_note` takes one for the day it creates today's note.\n\nWithout a folder a new note lands at the top level of the workspace, which is where your own New Note puts one. There is no tool for moving a note afterwards, and none for renaming or deleting a folder: filing is yours, in the sidebar ([[Notes and Workspaces]]).\n\n## Agents know which note they are in\n\nEvery shell a note spawns carries two environment variables: `LEDGE_NOTE`, the note's file, and `LEDGE_WORKSPACE`, its workspace folder. An agent launched in a note's terminal drawer picks these up through the MCP server:\n\n- `read_note` with no arguments reads the note the terminal belongs to.\n- `append_note` and `edit_note` default to that note.\n- `create_note` lands in its workspace, at the top level unless it names a folder.\n\nSo \"summarize this note\" or \"add a TODO section here\" needs no explanation of what \"this\" means. Open the note you are working in, press \u2303` for its terminal, start your agent, and talk about \"this note\" and \"this workspace\" in plain words.\n\n## Prompt fences\n\nA fenced block whose language is `prompt` is an agent run. Write instructions in it and press \u2318\u21A9. The block's text is piped to the agent CLI in one-shot mode, and the output streams into the panel below like any other run ([[Running Code]]).\n\nA prompt fence in a note might read:\n\n```prompt norun\nSummarize this note in one sentence.\n```\n\nThe answer streams into the panel beneath it. Instructions can also change things: \"append a Next steps section to this note\", or \"create a note titled Retro from what we discussed above\".\n\nThe block runs from the note's own shell, so the agent inherits the note's `cwd`, `env`, and the environment variables above.\n\nTwo things to expect. There is a pause before the answer appears, because one-shot mode thinks first and prints once. And since nobody is present to answer follow-up questions, the agent is instructed to act and report rather than ask.\n\nBy default the fence runs Claude Code (`claude -p`) with Ledge's own tools pre-authorized, because a non-interactive run has no one to click \"allow\". The command is an interpreter entry in Settings (\u2318,) under `blocks.interpreters`, key `prompt`. Point it at any CLI that reads its prompt on stdin to switch agents.\n\nA daily template carrying a prompt fence such as `Summarize [[{{yesterday}}]]` gives every day's note a one-keystroke briefing (see [[Daily Notes and Templates]]).\n\n## What agents cannot see\n\nAgents see the titles, bodies, tags, and links of ordinary notes. They can read this manual too, so \"check the Ledge docs\" is a fair instruction.\n\nThey never see the body of a locked note. Reads refuse with an explanation, searches skip locked notes and report how many they skipped, and listings flag them so an agent can plan around it.\n\nSettings are readable but not writable. The `settings` tool shows an agent your `settings.jsonc` with its comments, so it can answer \"which python is that block using\" from your actual configuration and name the line to change. Making the change is yours, in the app (\u2318,), and it applies at the next launch.\n\nDeletion is yours alone, in the app, where the trash and Undo live.\n";
6911
7048
 
6912
7049
  // docs/user/15-the-ledge-cli.md
6913
- var _15_the_ledge_cli_default = `# The ledge CLI
6914
-
6915
- The \`ledge\` command lists, reads, searches, creates, and appends to notes from any terminal. The running app follows along live, because a CLI write is an ordinary file change.
6916
-
6917
- ## Install
6918
-
6919
- Run "Install Shell Command (ledge)" from the command palette.
6920
-
6921
- It writes one small launcher, \`ledge\`, into \`~/.ledge/.server/bin\`, pointing at this copy of Ledge, and adds that folder to your PATH in your shell's startup file if it is not there yet. Open a new terminal afterwards. If you move the app, run it again.
6922
-
6923
- The same launcher is what lets your phone reach this Mac's notes ([[Ledge on Your Phone]]): \`ledge serve\` over ssh is how a Ledge app reaches any machine, and \`ledge\` reads the notes on the machine it runs on. That is this Mac's from a terminal here, and a server's from a terminal there, where the server install already put \`ledge\` on the PATH ([[Keep Notes on a Remote Server]]). \`ledge help\` lists the server verbs beside the notes verbs.
6924
-
6925
- ## The verbs
6926
-
6927
- \`ledge help\` prints the full usage.
6928
-
6929
- | Verb | What it does |
6930
- | --- | --- |
6931
- | \`ledge ls\` | Lists notes. |
6932
- | \`ledge search <query>\` | Prints \`path:line: match\` rows like grep, and exits nonzero on no hits. |
6933
- | \`ledge cat <title>\` | Prints a note's Markdown. |
6934
- | \`ledge tags\` | Lists the workspace's tags with counts. \`ledge tags <name>\` lists the notes bearing one. |
6935
- | \`ledge workspaces\` | Lists the workspace roots. |
6936
- | \`ledge new <title>\` | Creates a note, with the body piped on stdin or stamped from \`--template\`, in the folder you are standing in or the one \`-f\` names. |
6937
- | \`ledge append <title>\` | Appends to a note, or to one heading's section with \`--heading\`. |
6938
- | \`ledge today\` | Opens today's daily note in the app. |
6939
- | \`ledge <title>\` | Opens the app at that note. \`ledge\` alone just opens the app. |
6940
- | \`ledge backup\` | Backs this machine up to a bucket: \`setup\`, \`now\`, \`status\`, \`snapshots\`, \`restore\`, \`paths\`, \`restic\` ([[Keep Notes on a Remote Server]]). |
6941
-
6942
- In a terminal, once the shim is on your PATH:
6943
-
6944
- \`\`\`sh norun
6945
- ledge ls
6946
- ledge search "spawn params"
6947
- ledge cat "Shipping Notes"
6948
- \`\`\`
6949
-
6950
- Notes are addressed by title. An argument ending in \`.md\` is treated as a path instead.
6951
-
6952
- \`\`\`
6953
- ledge new "Standup" --template "Meeting"
6954
- git log --oneline -5 | ledge append "Release Notes" --heading "Shipped"
6955
- \`\`\`
6956
-
6957
- \`--template\` stamps the usual \`{{tokens}}\` (see [[Daily Notes and Templates]]). Titles never clobber: a duplicate gets a numbered file, the same as in the app.
6958
-
6959
- ## Scope: workspace, folder, and note
6960
-
6961
- Run \`ledge\` from inside a workspace folder and it scopes itself there. \`ls\` and \`search\` cover that workspace, and \`new\` creates in it.
6962
-
6963
- Stand in a folder inside the workspace and it narrows one more step, the way any other shell command works on the directory you are in:
6964
-
6965
- \`\`\`sh norun
6966
- cd ~/Notes/projects
6967
- ledge ls # only the notes in projects, and below it
6968
- ledge search "rate limit" # only that folder
6969
- ledge new "API Rollout" # creates ~/Notes/projects/api-rollout.md
6970
- \`\`\`
6971
-
6972
- \`-f <folder>\` says it outright, from anywhere. \`ledge ls -f admin\` lists that folder, and \`ledge new "Expenses" -f admin/2026\` creates the folder if it is new. See [[Notes and Workspaces]] for folders in the app.
6973
-
6974
- \`-f\` is also how you say which of two notes sharing a title you mean: \`ledge cat "Plan" -f projects\`. The folder you are standing in never does that, only \`-f\`, so a title always reaches the whole workspace no matter where you run it from.
6975
-
6976
- \`ledge today\` is the one exception. It takes \`-f\`, which overrides the \`daily.folder\` setting for that call, but it ignores the folder you are standing in: today's note is found by its date, and where it lives should not depend on where you happened to be when you first ran it ([[Daily Notes and Templates]]).
6977
-
6978
- Inside a note's terminal drawer it also knows the note, so a bare \`ledge append -m "TODO: check the logs"\` appends to the note the terminal belongs to.
6979
-
6980
- Three flags override the scope: \`-w <workspace>\` targets a specific workspace, \`-f <folder>\` targets a folder, and \`--all\` widens \`ls\` and \`search\` to every workspace.
6981
-
6982
- ## Piping and JSON output
6983
-
6984
- Results go to stdout and everything conversational to stderr, so pipes stay clean. \`--json\` switches any verb to machine-readable output.
6985
-
6986
- The CLI dispatches through the same handlers as the MCP tools ([[Agents and Ledge]]), so it follows the same rules: titles resolve the same way, locked notes refuse their bodies, and there is no delete verb.
6987
-
6988
- That makes it an agent surface in its own right. An agent that can run shell commands can work your notes with \`ledge\` alone, with no MCP setup.
6989
- `;
7050
+ var _15_the_ledge_cli_default = "# The ledge CLI\n\nThe `ledge` command lists, reads, searches, creates, and appends to notes from any terminal. The running app follows along live, because a CLI write is an ordinary file change.\n\n## Install\n\nRun \"Install Shell Command (ledge)\" from the command palette.\n\nIt writes one small launcher, `ledge`, into `~/.ledge/.server/bin`, pointing at this copy of Ledge, and adds that folder to your PATH in your shell's startup file if it is not there yet. Open a new terminal afterwards. If you move the app, run it again.\n\nOn Windows there is nothing to install. The CLI runs in WSL, where the app installs its server, and a new WSL terminal already finds `ledge`. `ledge <title>` there opens the Windows app, once it has started at least once ([[Getting Started#Ledge on Windows]]).\n\nThe same launcher is what lets your phone reach this computer's notes ([[Ledge on Your Phone]]): `ledge serve` over ssh is how a Ledge app reaches any machine, and `ledge` reads the notes on the machine it runs on. That is this computer's from a terminal here, and a server's from a terminal there, where the server install already put `ledge` on the PATH ([[Keep Notes on a Remote Server]]). `ledge help` lists the server verbs beside the notes verbs.\n\n## The verbs\n\n`ledge help` prints the full usage.\n\n| Verb | What it does |\n| --- | --- |\n| `ledge ls` | Lists notes. |\n| `ledge search <query>` | Prints `path:line: match` rows like grep, and exits nonzero on no hits. |\n| `ledge cat <title>` | Prints a note's Markdown. |\n| `ledge tags` | Lists the workspace's tags with counts. `ledge tags <name>` lists the notes bearing one. |\n| `ledge workspaces` | Lists the workspace roots. |\n| `ledge new <title>` | Creates a note, with the body piped on stdin or stamped from `--template`, in the folder you are standing in or the one `-f` names. |\n| `ledge append <title>` | Appends to a note, or to one heading's section with `--heading`. |\n| `ledge today` | Opens today's daily note in the app. |\n| `ledge <title>` | Opens the app at that note. `ledge` alone just opens the app. |\n| `ledge backup` | Backs this machine up to a bucket: `setup`, `now`, `status`, `snapshots`, `restore`, `paths`, `restic` ([[Keep Notes on a Remote Server]]). |\n\nIn a terminal, once the shim is on your PATH:\n\n```sh norun\nledge ls\nledge search \"spawn params\"\nledge cat \"Shipping Notes\"\n```\n\nNotes are addressed by title. An argument ending in `.md` is treated as a path instead.\n\n```\nledge new \"Standup\" --template \"Meeting\"\ngit log --oneline -5 | ledge append \"Release Notes\" --heading \"Shipped\"\n```\n\n`--template` stamps the usual `{{tokens}}` (see [[Daily Notes and Templates]]). Titles never clobber: a duplicate gets a numbered file, the same as in the app.\n\n## Scope: workspace, folder, and note\n\nRun `ledge` from inside a workspace folder and it scopes itself there. `ls` and `search` cover that workspace, and `new` creates in it.\n\nStand in a folder inside the workspace and it narrows one more step, the way any other shell command works on the directory you are in:\n\n```sh norun\ncd ~/Notes/projects\nledge ls # only the notes in projects, and below it\nledge search \"rate limit\" # only that folder\nledge new \"API Rollout\" # creates ~/Notes/projects/api-rollout.md\n```\n\n`-f <folder>` says it outright, from anywhere. `ledge ls -f admin` lists that folder, and `ledge new \"Expenses\" -f admin/2026` creates the folder if it is new. See [[Notes and Workspaces]] for folders in the app.\n\n`-f` is also how you say which of two notes sharing a title you mean: `ledge cat \"Plan\" -f projects`. The folder you are standing in never does that, only `-f`, so a title always reaches the whole workspace no matter where you run it from.\n\n`ledge today` is the one exception. It takes `-f`, which overrides the `daily.folder` setting for that call, but it ignores the folder you are standing in: today's note is found by its date, and where it lives should not depend on where you happened to be when you first ran it ([[Daily Notes and Templates]]).\n\nInside a note's terminal drawer it also knows the note, so a bare `ledge append -m \"TODO: check the logs\"` appends to the note the terminal belongs to.\n\nThree flags override the scope: `-w <workspace>` targets a specific workspace, `-f <folder>` targets a folder, and `--all` widens `ls` and `search` to every workspace.\n\n## Piping and JSON output\n\nResults go to stdout and everything conversational to stderr, so pipes stay clean. `--json` switches any verb to machine-readable output.\n\nThe CLI dispatches through the same handlers as the MCP tools ([[Agents and Ledge]]), so it follows the same rules: titles resolve the same way, locked notes refuse their bodies, and there is no delete verb.\n\nThat makes it an agent surface in its own right. An agent that can run shell commands can work your notes with `ledge` alone, with no MCP setup.\n";
6990
7051
 
6991
7052
  // docs/user/16-tutorial-run-a-project.md
6992
7053
  var _16_tutorial_run_a_project_default = `# Tutorial: Run a Project from a Note
@@ -7196,7 +7257,7 @@ Sync workspace folders, not \`~/.ledge\` itself. The app home holds machine-loca
7196
7257
 
7197
7258
  1. Create a folder inside iCloud Drive, Dropbox, or any synced location.
7198
7259
  2. Run "Attach Folder as Workspace\u2026" and give its path, or press Choose Folder\u2026 to pick it.
7199
- 3. On a second Mac, attach the same folder there.
7260
+ 3. On a second computer, attach the same folder there.
7200
7261
 
7201
7262
  Notes you write are files in the synced folder, and the service carries them.
7202
7263
 
@@ -7251,17 +7312,17 @@ A repository is also how a workspace reaches other people, with a clone each ([[
7251
7312
  Both setups start with a folder in the right place. Notes in a managed workspace live inside \`~/.ledge\`, which a sync service will not carry, so the workspace has to move out first.
7252
7313
 
7253
7314
  1. Close the workspace (\u232B on its row). Closing only detaches the folder; no note is touched.
7254
- 2. In Finder, move the folder out of \`~/.ledge\` (it is hidden: press \u21E7\u2318. in Finder to show it, or use \u21E7\u2318G and type \`~/.ledge\`) into the synced location.
7315
+ 2. Move the folder out of \`~/.ledge\` into the synced location. The folder is hidden: Finder's Go to Folder\u2026 takes the path, and so does Ctrl+L in GNOME Files. On Windows it is in WSL, under Linux in File Explorer.
7255
7316
  3. Run "Attach Folder as Workspace\u2026" and give its new path.
7256
- 4. Attach the same folder on your other Mac.
7317
+ 4. Attach the same folder on your other computer.
7257
7318
 
7258
7319
  The whole folder travels: notes, images, and trash together, with references intact. The workspace continues at its new home as an ordinary attached folder.
7259
7320
 
7260
7321
  ## What syncing does not carry
7261
7322
 
7262
- Syncing workspace folders syncs all of your notes. It does not touch Ledge's own state in \`~/.ledge\`: your settings, the list of which folders are workspaces, and your window layout. Those are machine-local, since a list of folder paths means little on another Mac.
7323
+ Syncing workspace folders syncs all of your notes. It does not touch Ledge's own state in \`~/.ledge\`: your settings, the list of which folders are workspaces, and your window layout. Those are machine-local, since a list of folder paths means little on another computer.
7263
7324
 
7264
- So setting up a new Mac is a short manual step: install Ledge, attach your synced folders, and redo any settings you care about. If your \`settings.jsonc\` is heavily customized, keep a copy alongside your notes.
7325
+ So setting up a new computer is a short manual step: install Ledge, attach your synced folders, and redo any settings you care about. If your \`settings.jsonc\` is heavily customized, keep a copy alongside your notes.
7265
7326
 
7266
7327
  There is nothing else to migrate. Notes are files, so there is no export and no import. Locked notes carry what they need to be decrypted, so on the new machine the passphrase alone opens them, with no vault file to move ([[Note Locking]]).
7267
7328
 
@@ -7273,14 +7334,14 @@ They combine: a synced drive for the always-on workspaces, a git repo for the on
7273
7334
  `;
7274
7335
 
7275
7336
  // docs/user/20-tutorial-set-up-a-ledge-server.md
7276
- 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';
7337
+ 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 computer\n\nIn a terminal on your computer:\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 computer, 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 computer 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';
7277
7338
 
7278
7339
  // docs/user/21-tutorial-back-up-your-notes-to-s3.md
7279
7340
  var _21_tutorial_back_up_your_notes_to_s3_default = `# Tutorial: Back Up Your Notes to S3
7280
7341
 
7281
7342
  Put an encrypted copy of your notes, and everything Ledge keeps beside them, into an S3-compatible bucket, every hour, from the machine that holds them.
7282
7343
 
7283
- This works the same on a Mac that runs the app and on a server set up as in [[Tutorial: Set Up a Ledge Server]]. Every command below runs on that machine, as the account Ledge runs as, and needs the \`ledge\` command on its PATH: a server has it from the install, and a Mac gets it from "Install Shell Command (ledge)" in the command palette ([[The ledge CLI]]).
7344
+ This works the same on a computer that runs the app and on a server set up as in [[Tutorial: Set Up a Ledge Server]]. Every command below runs on that machine, as the account Ledge runs as, and needs the \`ledge\` command on its PATH: a server has it from the install, and the app's computer gets it from "Install Shell Command (ledge)" in the command palette ([[The ledge CLI]]).
7284
7345
 
7285
7346
  The backup tool is restic, and \`ledge backup\` does everything around it: it fetches restic, keeps the credentials, creates the repository, computes what to back up, and keeps the schedule. restic encrypts on the machine before anything leaves it, and keeps versions, so one note from last Tuesday is something you can ask for.
7286
7347
 
@@ -7322,7 +7383,7 @@ The password is what encrypts the backup, and it is the only key. Nothing can be
7322
7383
 
7323
7384
  Backups run every hour while this machine's Ledge server is up, and once more before it exits.
7324
7385
 
7325
- On a Mac, the server is up while the app is open and for a minute after it closes. On a VPS, it is up while a device is connected and for a minute after. Quitting the app or closing your laptop's connection is followed by a backup of whatever changed. A machine you open Ledge on after a week away backs up as soon as the server starts.
7386
+ On a computer that runs the app, the server is up while the app is open and for a minute after it closes. On a VPS, it is up while a device is connected and for a minute after. Quitting the app or closing your laptop's connection is followed by a backup of whatever changed. A machine you open Ledge on after a week away backs up as soon as the server starts.
7326
7387
 
7327
7388
  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:
7328
7389
 
@@ -7373,7 +7434,7 @@ Do this once now, with a note you have, before you need it.
7373
7434
 
7374
7435
  ## 6. Restore everything onto a new machine
7375
7436
 
7376
- On a fresh machine with Ledge installed, the app on a Mac or the server on a VPS, and the four values and the password at hand:
7437
+ On a fresh machine with Ledge installed, the app on a desktop or the server on a VPS, and the four values and the password at hand:
7377
7438
 
7378
7439
  \`\`\`sh norun
7379
7440
  ledge backup setup --existing
@@ -7417,7 +7478,7 @@ You publish the workspace in steps 1 to 4. Everyone else clones it in step 5, an
7417
7478
 
7418
7479
  An attached workspace is already a folder you chose, and a project workspace is usually a repository already. Either one is ready, so skip to step 2.
7419
7480
 
7420
- A managed workspace lives inside \`~/.ledge\`, the app's own home. Close the workspace (\u232B on its row), move its folder in Finder to somewhere like \`~/Projects\` (\`~/.ledge\` is hidden: \u21E7\u2318G in Finder reaches it), then run "Attach Folder as Workspace\u2026" and give the folder's new path.
7481
+ A managed workspace lives inside \`~/.ledge\`, the app's own home. Close the workspace (\u232B on its row), move its folder to somewhere like \`~/Projects\` (\`~/.ledge\` is hidden: Finder's Go to Folder\u2026 reaches it, and so does Ctrl+L in GNOME Files; on Windows it is in WSL, under Linux in File Explorer), then run "Attach Folder as Workspace\u2026" and give the folder's new path.
7421
7482
 
7422
7483
  ## 2. Look at what you are about to publish
7423
7484
 
@@ -9727,7 +9788,7 @@ var RETIRED_DIRNAME = ".retired";
9727
9788
  var tmpCounter5 = 0;
9728
9789
  async function writePage(path, text) {
9729
9790
  tmpCounter5 += 1;
9730
- const tmp = join13(resolve9(DOCS_ROOT), `.${basename6(path)}.tmp-${process.pid}-${tmpCounter5}`);
9791
+ const tmp = join15(resolve9(DOCS_ROOT), `.${basename6(path)}.tmp-${process.pid}-${tmpCounter5}`);
9731
9792
  try {
9732
9793
  await writeFile8(tmp, text, "utf8");
9733
9794
  await rename8(tmp, path);
@@ -9756,16 +9817,16 @@ async function syncDocs(pages = DOC_PAGES) {
9756
9817
  if (wanted.has(name))
9757
9818
  continue;
9758
9819
  try {
9759
- const retiredDir = join13(root, RETIRED_DIRNAME);
9820
+ const retiredDir = join15(root, RETIRED_DIRNAME);
9760
9821
  await mkdir5(retiredDir, { recursive: true });
9761
9822
  const taken = new Set(await readdir4(retiredDir));
9762
- await rename8(join13(root, name), join13(retiredDir, uniqueName(name.replace(/\.md$/i, ""), taken)));
9823
+ await rename8(join15(root, name), join15(retiredDir, uniqueName(name.replace(/\.md$/i, ""), taken)));
9763
9824
  } catch (err) {
9764
9825
  console.warn("[docs] could not retire a stale doc page", name, err);
9765
9826
  }
9766
9827
  }
9767
9828
  for (const [name, text] of wanted) {
9768
- const path = join13(root, name);
9829
+ const path = join15(root, name);
9769
9830
  try {
9770
9831
  const current = await readFile9(path, "utf8").catch(() => null);
9771
9832
  if (current === text)
@@ -9778,9 +9839,9 @@ async function syncDocs(pages = DOC_PAGES) {
9778
9839
  }
9779
9840
 
9780
9841
  // src/bun/layout.ts
9781
- import { basename as basename7, join as join14 } from "path";
9842
+ import { basename as basename7, join as join16 } from "path";
9782
9843
  import { readFile as readFile10, rename as rename9, unlink as unlink8, writeFile as writeFile9 } from "fs/promises";
9783
- var LAYOUT_PATH = join14(APP_HOME, ".layout.json");
9844
+ var LAYOUT_PATH = join16(APP_HOME, ".layout.json");
9784
9845
  var ANONYMOUS = "_";
9785
9846
  function adoptable(parsed) {
9786
9847
  return typeof parsed === "object" && parsed !== null && "version" in parsed;
@@ -9808,7 +9869,7 @@ async function writeLayout(client, text) {
9808
9869
  const next = { ...base, [key(client)]: value };
9809
9870
  await ensureAppHome();
9810
9871
  tmpCounter6 += 1;
9811
- const tmp = join14(APP_HOME, `.${basename7(LAYOUT_PATH)}.tmp-${process.pid}-${tmpCounter6}`);
9872
+ const tmp = join16(APP_HOME, `.${basename7(LAYOUT_PATH)}.tmp-${process.pid}-${tmpCounter6}`);
9812
9873
  try {
9813
9874
  await writeFile9(tmp, JSON.stringify(next), "utf8");
9814
9875
  await rename9(tmp, LAYOUT_PATH);
@@ -9831,10 +9892,10 @@ async function readFileJson() {
9831
9892
 
9832
9893
  // src/bun/log.ts
9833
9894
  import { appendFileSync, mkdirSync, renameSync, statSync } from "fs";
9834
- import { join as join15 } from "path";
9835
- var LOG_DIR = join15(APP_HOME, "logs");
9836
- var LOG_PATH = join15(LOG_DIR, "ledge.log");
9837
- var PREV_LOG_PATH = join15(LOG_DIR, "ledge.previous.log");
9895
+ import { join as join17 } from "path";
9896
+ var LOG_DIR = join17(APP_HOME, "logs");
9897
+ var LOG_PATH = join17(LOG_DIR, "ledge.log");
9898
+ var PREV_LOG_PATH = join17(LOG_DIR, "ledge.previous.log");
9838
9899
  var MAX_LOG_BYTES = 4 * 1024 * 1024;
9839
9900
  function formatArg(arg) {
9840
9901
  if (typeof arg === "string")
@@ -9865,8 +9926,8 @@ function sizeOf(path) {
9865
9926
  var logPath = LOG_PATH;
9866
9927
  var prevPath = PREV_LOG_PATH;
9867
9928
  function logToFile(basename) {
9868
- logPath = join15(LOG_DIR, `${basename}.log`);
9869
- prevPath = join15(LOG_DIR, `${basename}.previous.log`);
9929
+ logPath = join17(LOG_DIR, `${basename}.log`);
9930
+ prevPath = join17(LOG_DIR, `${basename}.previous.log`);
9870
9931
  }
9871
9932
  function rotate() {
9872
9933
  try {
@@ -9922,7 +9983,7 @@ function startLogging(basename) {
9922
9983
  function revealLog() {
9923
9984
  try {
9924
9985
  mkdirSync(LOG_DIR, { recursive: true });
9925
- Bun.spawn(["open", LOG_DIR]);
9986
+ Bun.spawn([process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open", LOG_DIR]);
9926
9987
  return true;
9927
9988
  } catch {
9928
9989
  return false;
@@ -10078,7 +10139,9 @@ function remoteCd(cwd) {
10078
10139
  }
10079
10140
 
10080
10141
  // src/bun/loginEnv.ts
10142
+ import { existsSync as existsSync3 } from "fs";
10081
10143
  import { homedir as homedir6 } from "os";
10144
+ import { join as join18 } from "path";
10082
10145
  var LOGIN_ENV_TIMEOUT_MS = 5000;
10083
10146
  var RESOLVING_VAR = "LEDGE_RESOLVING_ENVIRONMENT";
10084
10147
  var DROPPED = new Set(["PWD", "OLDPWD", "SHLVL", "_", RESOLVING_VAR]);
@@ -10159,6 +10222,28 @@ async function resolveLoginEnv(shellPath, base, opts = {}) {
10159
10222
  }
10160
10223
  return cleanLoginEnv(env);
10161
10224
  }
10225
+ var LOCALE_VARS = ["LC_ALL", "LC_CTYPE", "LANG"];
10226
+ var APPLE_LOCALE = /^[A-Za-z]{2,3}(_[A-Za-z0-9]{2,4})?$/;
10227
+ function withUtf8Locale(env, appleLocale, installed) {
10228
+ if (LOCALE_VARS.some((key) => env[key]))
10229
+ return env;
10230
+ const region = appleLocale?.split("@")[0] ?? "";
10231
+ const own = APPLE_LOCALE.test(region) ? `${region}.UTF-8` : null;
10232
+ return { ...env, LANG: own && installed(own) ? own : "en_US.UTF-8" };
10233
+ }
10234
+ async function resolveUtf8Locale(env) {
10235
+ if (process.platform !== "darwin")
10236
+ return env;
10237
+ if (LOCALE_VARS.some((key) => env[key]))
10238
+ return env;
10239
+ let appleLocale = null;
10240
+ try {
10241
+ const p = Bun.spawn(["defaults", "read", "-g", "AppleLocale"], { stdout: "pipe", stderr: "ignore" });
10242
+ appleLocale = (await new Response(p.stdout).text()).trim() || null;
10243
+ await p.exited;
10244
+ } catch {}
10245
+ return withUtf8Locale(env, appleLocale, (name) => existsSync3(join18("/usr/share/locale", name)));
10246
+ }
10162
10247
  function definedOnly(env) {
10163
10248
  const out = {};
10164
10249
  for (const [key, value] of Object.entries(env)) {
@@ -10169,7 +10254,7 @@ function definedOnly(env) {
10169
10254
  }
10170
10255
 
10171
10256
  // src/bun/server.ts
10172
- import { readFileSync, statSync as statSync2 } from "fs";
10257
+ import { readFileSync as readFileSync3, statSync as statSync2 } from "fs";
10173
10258
  function holdRunEvent(held, ev, cap) {
10174
10259
  held.events.push(ev);
10175
10260
  if (ev.type !== "output")
@@ -10213,6 +10298,7 @@ function clientSeamRefusals() {
10213
10298
  windowNew: refuse("windowNew"),
10214
10299
  windowDocs: refuse("windowDocs"),
10215
10300
  windowRole: refuse("windowRole"),
10301
+ appQuit: refuse("appQuit"),
10216
10302
  updateState: refuse("updateState"),
10217
10303
  updateCheck: refuse("updateCheck"),
10218
10304
  updateInstall: refuse("updateInstall"),
@@ -10249,7 +10335,10 @@ async function createServer(deps) {
10249
10335
  }
10250
10336
  await syncDocs();
10251
10337
  await loadVault();
10252
- const shellEnv = { ...await loginEnv, TERM: "xterm-256color" };
10338
+ const shellEnv = {
10339
+ ...await resolveUtf8Locale(await loginEnv),
10340
+ TERM: "xterm-256color"
10341
+ };
10253
10342
  const sessionParams = new Map;
10254
10343
  const spawnKeys = new Map;
10255
10344
  const sentStale = new Map;
@@ -10257,7 +10346,7 @@ async function createServer(deps) {
10257
10346
  const spawnDeps = {
10258
10347
  readFile: (path) => {
10259
10348
  try {
10260
- return readFileSync(path, "utf8");
10349
+ return readFileSync3(path, "utf8");
10261
10350
  } catch {
10262
10351
  return null;
10263
10352
  }
@@ -10451,7 +10540,7 @@ async function createServer(deps) {
10451
10540
  } else if (ev.type === "output") {
10452
10541
  to.runEvent({ id: ev.blockId, kind: "output", dataB64: toB64(ev.data) });
10453
10542
  } else {
10454
- to.runEvent({ id: ev.blockId, kind: "ended", exitCode: ev.exitCode });
10543
+ to.runEvent({ id: ev.blockId, kind: "ended", exitCode: ev.exitCode, durationMs: ev.durationMs });
10455
10544
  }
10456
10545
  }
10457
10546
  const deviceOf = new Map;
@@ -10959,6 +11048,7 @@ var REQUEST_METHODS = [
10959
11048
  "windowNew",
10960
11049
  "windowDocs",
10961
11050
  "windowRole",
11051
+ "appQuit",
10962
11052
  "settingsGet",
10963
11053
  "settingsRead",
10964
11054
  "settingsWrite",
@@ -11020,6 +11110,7 @@ var NATIVE_METHODS = [
11020
11110
  "windowNew",
11021
11111
  "windowDocs",
11022
11112
  "windowRole",
11113
+ "appQuit",
11023
11114
  "updateState",
11024
11115
  "updateCheck",
11025
11116
  "updateInstall"
@@ -11094,12 +11185,24 @@ function replace(payload, path, value) {
11094
11185
  copy[head] = rest.length === 0 ? value : replace(copy[head], rest, value);
11095
11186
  return copy;
11096
11187
  }
11097
- function toBase64(bytes) {
11098
- return bytes.toBase64();
11188
+ var nativeBase64 = typeof Uint8Array.prototype.toBase64 === "function";
11189
+ var CHUNK = 32768;
11190
+ function toBase64Slow(bytes) {
11191
+ let text = "";
11192
+ for (let at = 0;at < bytes.length; at += CHUNK) {
11193
+ text += String.fromCharCode(...bytes.subarray(at, at + CHUNK));
11194
+ }
11195
+ return btoa(text);
11099
11196
  }
11100
- function fromBase64(text) {
11101
- return Uint8Array.fromBase64(text);
11197
+ function fromBase64Slow(text) {
11198
+ const raw = atob(text);
11199
+ const bytes = new Uint8Array(raw.length);
11200
+ for (let i = 0;i < raw.length; i++)
11201
+ bytes[i] = raw.charCodeAt(i);
11202
+ return bytes;
11102
11203
  }
11204
+ var toBase64 = nativeBase64 ? (bytes) => bytes.toBase64() : toBase64Slow;
11205
+ var fromBase64 = nativeBase64 ? (text) => Uint8Array.fromBase64(text) : fromBase64Slow;
11103
11206
  function hello(role, build, client = "", instance = "", hold = 0, label = "", device = "") {
11104
11207
  const serves = role === "server";
11105
11208
  return {
@@ -11764,11 +11867,11 @@ function createOpLog(opts) {
11764
11867
  }
11765
11868
 
11766
11869
  // src/shared/version.ts
11767
- var BUILD_VERSION = "0.1.1";
11870
+ var BUILD_VERSION = "0.1.3";
11768
11871
 
11769
11872
  // src/bun/daemon.ts
11770
- var SOCKET_PATH = join16(APP_HOME, ".server.sock");
11771
- var PID_PATH = join16(APP_HOME, ".server.pid");
11873
+ var SOCKET_PATH = join19(APP_HOME, ".server.sock");
11874
+ var PID_PATH = join19(APP_HOME, ".server.pid");
11772
11875
  var DAEMON_LOG = "ledge-server";
11773
11876
  var IDLE_EXIT_MS = 60000;
11774
11877
  var IDLE_EXIT_NEVER = 0;
@@ -12012,13 +12115,13 @@ function spawnDaemon(head = ownCommand()) {
12012
12115
  let errFd = "ignore";
12013
12116
  try {
12014
12117
  mkdirSync2(LOG_DIR, { recursive: true });
12015
- errFd = openSync(join16(LOG_DIR, `${DAEMON_LOG}.log`), "a");
12118
+ errFd = openSync(join19(LOG_DIR, `${DAEMON_LOG}.log`), "a");
12016
12119
  } catch {}
12017
12120
  Bun.spawn({ cmd: argv, stdin: "ignore", stdout: "ignore", stderr: errFd }).unref();
12018
12121
  }
12019
12122
  function daemonPid(pidPath = PID_PATH) {
12020
12123
  try {
12021
- const pid = Number(readFileSync2(pidPath, "utf8").trim());
12124
+ const pid = Number(readFileSync4(pidPath, "utf8").trim());
12022
12125
  return Number.isInteger(pid) && pid > 1 ? pid : null;
12023
12126
  } catch {
12024
12127
  return null;
@@ -12028,7 +12131,7 @@ function daemonPid(pidPath = PID_PATH) {
12028
12131
  // src/bun/backupCli.ts
12029
12132
  import { randomBytes as randomBytes2 } from "crypto";
12030
12133
  import { homedir as homedir8 } from "os";
12031
- import { join as join19 } from "path";
12134
+ import { join as join22 } from "path";
12032
12135
 
12033
12136
  // src/bun/ask.ts
12034
12137
  var reader = null;
@@ -12090,7 +12193,7 @@ async function ask(question, o = {}) {
12090
12193
  }
12091
12194
 
12092
12195
  // src/bun/backup.ts
12093
- import { join as join17 } from "path";
12196
+ import { join as join20 } from "path";
12094
12197
  function backupSet(input) {
12095
12198
  const { appHome, profilesDir, roots, secrets } = input;
12096
12199
  const include = [appHome];
@@ -12102,7 +12205,7 @@ function backupSet(input) {
12102
12205
  return { include: unique(include), exclude: excludesFor(appHome) };
12103
12206
  }
12104
12207
  function excludesFor(appHome) {
12105
- return [".server.sock", ".server.pid", "logs", ".ledge-docs", ".server"].map((name) => join17(appHome, name));
12208
+ return [".server.sock", ".server.pid", "logs", ".ledge-docs", ".server"].map((name) => join20(appHome, name));
12106
12209
  }
12107
12210
  function unique(paths) {
12108
12211
  return [...new Set(paths)];
@@ -12353,18 +12456,18 @@ function duration(ms) {
12353
12456
  }
12354
12457
 
12355
12458
  // src/bun/backupRun.ts
12356
- import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, openSync as openSync2, closeSync, readFileSync as readFileSync3, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
12459
+ import { chmodSync as chmodSync2, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync as openSync2, closeSync, readFileSync as readFileSync5, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
12357
12460
  import { mkdtemp, rm as rm2 } from "fs/promises";
12358
- import { join as join18 } from "path";
12359
- var BACKUP_DIR = join18(APP_HOME, ".server", "backup");
12360
- var STATE_PATH = join18(BACKUP_DIR, "state.json");
12361
- var LOCK_PATH = join18(BACKUP_DIR, "lock");
12362
- var RESTIC_CACHE_DIR = join18(BACKUP_DIR, "cache");
12363
- var PROFILE_PATH = join18(PROFILES_DIR, `${BACKUP_PROFILE}.env`);
12461
+ import { join as join21 } from "path";
12462
+ var BACKUP_DIR = join21(APP_HOME, ".server", "backup");
12463
+ var STATE_PATH = join21(BACKUP_DIR, "state.json");
12464
+ var LOCK_PATH = join21(BACKUP_DIR, "lock");
12465
+ var RESTIC_CACHE_DIR = join21(BACKUP_DIR, "cache");
12466
+ var PROFILE_PATH = join21(PROFILES_DIR, `${BACKUP_PROFILE}.env`);
12364
12467
  function readConfig() {
12365
12468
  let text = null;
12366
12469
  try {
12367
- text = readFileSync3(PROFILE_PATH, "utf8");
12470
+ text = readFileSync5(PROFILE_PATH, "utf8");
12368
12471
  } catch {}
12369
12472
  return parseBackupConfig(text);
12370
12473
  }
@@ -12382,7 +12485,7 @@ function resticEnv(config) {
12382
12485
  return env;
12383
12486
  }
12384
12487
  function fetchedResticPath(version = RESTIC_VERSION) {
12385
- return join18(BACKUP_DIR, `restic-${version}`);
12488
+ return join21(BACKUP_DIR, `restic-${version}`);
12386
12489
  }
12387
12490
  async function findRestic(opts = {}) {
12388
12491
  const onPath = Bun.which("restic", { PATH: process.env["PATH"] ?? "" });
@@ -12393,7 +12496,7 @@ async function findRestic(opts = {}) {
12393
12496
  opts.log?.(`[backup] ${onPath} is restic ${version ?? "of an unknown version"}; ${RESTIC_MIN_VERSION} or newer is needed`);
12394
12497
  }
12395
12498
  const fetched = fetchedResticPath();
12396
- if (existsSync2(fetched)) {
12499
+ if (existsSync4(fetched)) {
12397
12500
  const version = await versionOf(fetched);
12398
12501
  if (version)
12399
12502
  return { path: fetched, version };
@@ -12473,7 +12576,7 @@ function resticSaid(r) {
12473
12576
  }
12474
12577
  function readState() {
12475
12578
  try {
12476
- return parseState(readFileSync3(STATE_PATH, "utf8"));
12579
+ return parseState(readFileSync5(STATE_PATH, "utf8"));
12477
12580
  } catch {
12478
12581
  return parseState(null);
12479
12582
  }
@@ -12496,7 +12599,7 @@ async function withBackupLock(fn) {
12496
12599
  } catch (err) {
12497
12600
  if (err.code !== "EEXIST")
12498
12601
  throw err;
12499
- const holder = Number(readFileSync3(LOCK_PATH, "utf8").trim());
12602
+ const holder = Number(readFileSync5(LOCK_PATH, "utf8").trim());
12500
12603
  if (Number.isInteger(holder) && holder > 0 && alive(holder))
12501
12604
  return { busy: holder };
12502
12605
  try {
@@ -12526,7 +12629,7 @@ function rootsOnDisk() {
12526
12629
  const present = [];
12527
12630
  const skipped = [];
12528
12631
  for (const r of roots())
12529
- (existsSync2(r) ? present : skipped).push(r);
12632
+ (existsSync4(r) ? present : skipped).push(r);
12530
12633
  return { present, skipped };
12531
12634
  }
12532
12635
  async function runBackup(o = { reason: "now" }) {
@@ -12544,9 +12647,9 @@ async function runBackup(o = { reason: "now" }) {
12544
12647
  for (const r of skipped)
12545
12648
  log(`[backup] skipping ${r}: not on disk (unmounted volume?)`);
12546
12649
  const set = backupSet({ appHome: APP_HOME, profilesDir: PROFILES_DIR, roots: present, secrets: o.secrets ?? true });
12547
- const dir = await mkdtemp(join18(BACKUP_DIR, "run-"));
12548
- const filesFrom = join18(dir, "include");
12549
- const excludeFile = join18(dir, "exclude");
12650
+ const dir = await mkdtemp(join21(BACKUP_DIR, "run-"));
12651
+ const filesFrom = join21(dir, "include");
12652
+ const excludeFile = join21(dir, "exclude");
12550
12653
  try {
12551
12654
  writeFileSync3(filesFrom, `${set.include.join(`
12552
12655
  `)}
@@ -12932,7 +13035,7 @@ async function restore(args) {
12932
13035
  const to = valueOf(args, "--to");
12933
13036
  if (inPlace && to)
12934
13037
  return usage("--in-place restores to the original paths; --to names another folder. One or the other.");
12935
- const target = inPlace ? "/" : to ?? join19(homedir8(), `ledge-restore-${stamp(new Date)}`);
13038
+ const target = inPlace ? "/" : to ?? join22(homedir8(), `ledge-restore-${stamp(new Date)}`);
12936
13039
  const include = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--snapshot" && args[i - 1] !== "--to");
12937
13040
  if (inPlace && daemonRunning()) {
12938
13041
  say(`This machine's Ledge server is running, and an in-place restore writes under it. Quit Ledge, or stop the daemon (kill $(cat ${PID_PATH})), and run this again.`);
@@ -13918,9 +14021,9 @@ function pairCode(user, address, keys) {
13918
14021
  }
13919
14022
 
13920
14023
  // src/bun/serve.ts
13921
- import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync4 } from "fs";
14024
+ import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
13922
14025
  import { hostname, networkInterfaces, userInfo } from "os";
13923
- import { join as join20 } from "path";
14026
+ import { join as join23 } from "path";
13924
14027
  async function serve2() {
13925
14028
  const upstream = await connectToDaemon();
13926
14029
  const mine = stdioDuplex();
@@ -13971,7 +14074,7 @@ async function pair(argv) {
13971
14074
  if ("error" in args)
13972
14075
  return fail(`${args.error}
13973
14076
  ${PAIR_USAGE}`, 2);
13974
- if (existsSync3("/.dockerenv") || existsSync3("/run/.containerenv")) {
14077
+ if (existsSync5("/.dockerenv") || existsSync5("/run/.containerenv")) {
13975
14078
  const refusal = containerRefusal(args);
13976
14079
  if (refusal)
13977
14080
  return fail(refusal);
@@ -13993,15 +14096,15 @@ ${PAIR_USAGE}`, 2);
13993
14096
  keyText = await Bun.stdin.text();
13994
14097
  else if (args.keys !== undefined) {
13995
14098
  try {
13996
- keyText = readFileSync4(args.keys, "utf8");
14099
+ keyText = readFileSync6(args.keys, "utf8");
13997
14100
  } catch {
13998
14101
  return fail(`Could not read ${args.keys}.`);
13999
14102
  }
14000
14103
  } else {
14001
- const files = existsSync3(HOST_KEY_DIR) ? readdirSync(HOST_KEY_DIR).filter((f) => /^ssh_host_\w+_key\.pub$/.test(f)) : [];
14104
+ const files = existsSync5(HOST_KEY_DIR) ? readdirSync2(HOST_KEY_DIR).filter((f) => /^ssh_host_\w+_key\.pub$/.test(f)) : [];
14002
14105
  for (const file of files) {
14003
14106
  try {
14004
- keyText += `${readFileSync4(join20(HOST_KEY_DIR, file), "utf8")}
14107
+ keyText += `${readFileSync6(join23(HOST_KEY_DIR, file), "utf8")}
14005
14108
  `;
14006
14109
  } catch {}
14007
14110
  }
@@ -14048,13 +14151,13 @@ function dmi() {
14048
14151
  const out = {};
14049
14152
  for (const field of DMI_FIELDS) {
14050
14153
  try {
14051
- out[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
14154
+ out[field] = readFileSync6(join23(DMI_DIR, field), "utf8");
14052
14155
  } catch {}
14053
14156
  }
14054
14157
  return out;
14055
14158
  }
14056
14159
  async function tailnetSelf() {
14057
- const path = TAILSCALE_PATHS.find((p) => existsSync3(p));
14160
+ const path = TAILSCALE_PATHS.find((p) => existsSync5(p));
14058
14161
  if (!path)
14059
14162
  return null;
14060
14163
  try {