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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 :
|
|
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 :
|
|
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
|
|
4294
|
-
import { join as
|
|
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
|
|
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
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
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 (!
|
|
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 =
|
|
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
|
-
|
|
4970
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
5202
|
-
const tmp =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
5508
|
+
All four are plain text. Attach them to a bug report.
|
|
5369
5509
|
|
|
5370
|
-
The manual ends with
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
6377
|
-
| Profiles and their secrets | The \`ledge\` command, which reads this
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
9835
|
-
var LOG_DIR =
|
|
9836
|
-
var LOG_PATH =
|
|
9837
|
-
var PREV_LOG_PATH =
|
|
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 =
|
|
9869
|
-
prevPath =
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
11098
|
-
|
|
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
|
|
11101
|
-
|
|
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.
|
|
11870
|
+
var BUILD_VERSION = "0.1.3";
|
|
11768
11871
|
|
|
11769
11872
|
// src/bun/daemon.ts
|
|
11770
|
-
var SOCKET_PATH =
|
|
11771
|
-
var PID_PATH =
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
12359
|
-
var BACKUP_DIR =
|
|
12360
|
-
var STATE_PATH =
|
|
12361
|
-
var LOCK_PATH =
|
|
12362
|
-
var RESTIC_CACHE_DIR =
|
|
12363
|
-
var PROFILE_PATH =
|
|
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 =
|
|
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
|
|
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 (
|
|
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(
|
|
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(
|
|
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
|
-
(
|
|
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(
|
|
12548
|
-
const filesFrom =
|
|
12549
|
-
const excludeFile =
|
|
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 ??
|
|
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
|
|
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
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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 += `${
|
|
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] =
|
|
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) =>
|
|
14160
|
+
const path = TAILSCALE_PATHS.find((p) => existsSync5(p));
|
|
14058
14161
|
if (!path)
|
|
14059
14162
|
return null;
|
|
14060
14163
|
try {
|