ledge-server 0.0.3 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/serve.js
CHANGED
|
@@ -390,9 +390,9 @@ function setFavoriteLine(text, on) {
|
|
|
390
390
|
if (!on) {
|
|
391
391
|
if (b === null)
|
|
392
392
|
return text;
|
|
393
|
-
const
|
|
394
|
-
const kept =
|
|
395
|
-
if (kept.length ===
|
|
393
|
+
const content = b.lines.slice(1, b.close);
|
|
394
|
+
const kept = content.filter((l) => !FAVORITE_LINE.test(l));
|
|
395
|
+
if (kept.length === content.length)
|
|
396
396
|
return text;
|
|
397
397
|
if (kept.every((l) => l.trim() === ""))
|
|
398
398
|
return text.slice(b.end);
|
|
@@ -890,8 +890,10 @@ async function loadWorkspaces() {
|
|
|
890
890
|
}
|
|
891
891
|
async function ensureDefault() {
|
|
892
892
|
if (availableRoots().some((r) => kindOf(r) !== "docs"))
|
|
893
|
-
return;
|
|
894
|
-
|
|
893
|
+
return null;
|
|
894
|
+
const first = ![...entries.keys()].some((r) => kindOf(r) !== "docs");
|
|
895
|
+
const root = await createManaged("Scratch");
|
|
896
|
+
return first ? root : null;
|
|
895
897
|
}
|
|
896
898
|
async function createManaged(name) {
|
|
897
899
|
await ensureAppHome();
|
|
@@ -1247,10 +1249,10 @@ async function unlockVault(passphrase, device, probeHeader) {
|
|
|
1247
1249
|
} catch {
|
|
1248
1250
|
return false;
|
|
1249
1251
|
}
|
|
1250
|
-
const
|
|
1251
|
-
if (!checkAgainstFile(
|
|
1252
|
+
const key = deriveKey(passphrase, vaultSalt);
|
|
1253
|
+
if (!checkAgainstFile(key, checkB64))
|
|
1252
1254
|
return false;
|
|
1253
|
-
masterKey =
|
|
1255
|
+
masterKey = key;
|
|
1254
1256
|
authorize(device);
|
|
1255
1257
|
return true;
|
|
1256
1258
|
}
|
|
@@ -1750,9 +1752,9 @@ async function listNotes(root) {
|
|
|
1750
1752
|
}
|
|
1751
1753
|
async function searchNotes(root, query, folder = "") {
|
|
1752
1754
|
const metas = notesUnder(await listNotes(root), folder);
|
|
1753
|
-
const
|
|
1754
|
-
const hits = await collectHits(query,
|
|
1755
|
-
return { hits, lockedSkipped: metas.length -
|
|
1755
|
+
const open = metas.filter((m) => !m.locked);
|
|
1756
|
+
const hits = await collectHits(query, open, async (path) => (await readNote(path))?.text ?? null);
|
|
1757
|
+
return { hits, lockedSkipped: metas.length - open.length };
|
|
1756
1758
|
}
|
|
1757
1759
|
var CONTEXT_MAX = 200;
|
|
1758
1760
|
function contextOf(lines, line) {
|
|
@@ -1942,14 +1944,14 @@ function assetRefsOf(text, root, from) {
|
|
|
1942
1944
|
function rebaseAssetRefs(text, root, from, to) {
|
|
1943
1945
|
if (dirname3(resolve3(from)) === dirname3(resolve3(to)))
|
|
1944
1946
|
return text;
|
|
1945
|
-
return text.replace(IMAGE_REF, (whole,
|
|
1947
|
+
return text.replace(IMAGE_REF, (whole, open, ref, close) => {
|
|
1946
1948
|
let asset;
|
|
1947
1949
|
try {
|
|
1948
1950
|
asset = assetPathOf(root, ref, from);
|
|
1949
1951
|
} catch {
|
|
1950
1952
|
return whole;
|
|
1951
1953
|
}
|
|
1952
|
-
return `${
|
|
1954
|
+
return `${open}${assetRefFor(root, asset, to)}${close}`;
|
|
1953
1955
|
});
|
|
1954
1956
|
}
|
|
1955
1957
|
async function favoriteNote(path, on) {
|
|
@@ -2150,13 +2152,13 @@ async function rollBack(done, from, to, salt) {
|
|
|
2150
2152
|
async function imageFilesUnder(root) {
|
|
2151
2153
|
const out = [];
|
|
2152
2154
|
const walk = async (dir) => {
|
|
2153
|
-
let
|
|
2155
|
+
let entries;
|
|
2154
2156
|
try {
|
|
2155
|
-
|
|
2157
|
+
entries = await readdir3(dir, { withFileTypes: true });
|
|
2156
2158
|
} catch {
|
|
2157
2159
|
return;
|
|
2158
2160
|
}
|
|
2159
|
-
for (const entry of
|
|
2161
|
+
for (const entry of entries) {
|
|
2160
2162
|
if (entry.name.startsWith(".") && entry.name !== ASSETS_DIRNAME)
|
|
2161
2163
|
continue;
|
|
2162
2164
|
const path = join5(dir, entry.name);
|
|
@@ -2201,8 +2203,8 @@ async function createNote(root, text, folder) {
|
|
|
2201
2203
|
const dir = await ensureFolder(root, folder);
|
|
2202
2204
|
const reserved = reservedIn(dir);
|
|
2203
2205
|
const taken = new Set(await readdir3(dir));
|
|
2204
|
-
for (const
|
|
2205
|
-
taken.add(
|
|
2206
|
+
for (const name of reserved)
|
|
2207
|
+
taken.add(name);
|
|
2206
2208
|
const name = uniqueName(baseFor(text), taken);
|
|
2207
2209
|
reserved.add(name);
|
|
2208
2210
|
const path = join5(dir, name);
|
|
@@ -2230,8 +2232,8 @@ async function moveNote(path, folder) {
|
|
|
2230
2232
|
}
|
|
2231
2233
|
const reserved = reservedIn(dir);
|
|
2232
2234
|
const taken = new Set(await readdir3(dir));
|
|
2233
|
-
for (const
|
|
2234
|
-
taken.add(
|
|
2235
|
+
for (const name of reserved)
|
|
2236
|
+
taken.add(name);
|
|
2235
2237
|
const name = uniqueName(titleOf(from), taken);
|
|
2236
2238
|
reserved.add(name);
|
|
2237
2239
|
const target = join5(dir, name);
|
|
@@ -2298,8 +2300,8 @@ async function retitleNote(path, text) {
|
|
|
2298
2300
|
const reserved = reservedIn(dir);
|
|
2299
2301
|
const taken = new Set(await readdir3(dir));
|
|
2300
2302
|
taken.delete(current);
|
|
2301
|
-
for (const
|
|
2302
|
-
taken.add(
|
|
2303
|
+
for (const name of reserved)
|
|
2304
|
+
taken.add(name);
|
|
2303
2305
|
const name = uniqueName(baseFor(text), taken);
|
|
2304
2306
|
if (name.toLowerCase() === current.toLowerCase()) {
|
|
2305
2307
|
return metaFor(path, text);
|
|
@@ -2379,13 +2381,13 @@ async function stashNote(path, text) {
|
|
|
2379
2381
|
async function trashFiles(root) {
|
|
2380
2382
|
const out = [];
|
|
2381
2383
|
const walk = async (dir) => {
|
|
2382
|
-
let
|
|
2384
|
+
let entries;
|
|
2383
2385
|
try {
|
|
2384
|
-
|
|
2386
|
+
entries = await readdir3(dir, { withFileTypes: true });
|
|
2385
2387
|
} catch {
|
|
2386
2388
|
return;
|
|
2387
2389
|
}
|
|
2388
|
-
for (const entry of
|
|
2390
|
+
for (const entry of entries) {
|
|
2389
2391
|
if (entry.name.startsWith("."))
|
|
2390
2392
|
continue;
|
|
2391
2393
|
const path = join5(dir, entry.name);
|
|
@@ -2427,8 +2429,8 @@ async function restoreNote(path) {
|
|
|
2427
2429
|
await mkdir3(dir, { recursive: true });
|
|
2428
2430
|
const reserved = reservedIn(dir);
|
|
2429
2431
|
const taken = new Set(await readdir3(dir));
|
|
2430
|
-
for (const
|
|
2431
|
-
taken.add(
|
|
2432
|
+
for (const name of reserved)
|
|
2433
|
+
taken.add(name);
|
|
2432
2434
|
const name = uniqueName(titleOf(path), taken);
|
|
2433
2435
|
reserved.add(name);
|
|
2434
2436
|
const target = join5(dir, name);
|
|
@@ -2506,12 +2508,12 @@ function forceTitle(text, title) {
|
|
|
2506
2508
|
const start = frontmatterEnd(text);
|
|
2507
2509
|
if (heading === null) {
|
|
2508
2510
|
const body = text.slice(start);
|
|
2509
|
-
const
|
|
2511
|
+
const sep = body.startsWith(`
|
|
2510
2512
|
`) || body === "" ? `
|
|
2511
2513
|
` : `
|
|
2512
2514
|
|
|
2513
2515
|
`;
|
|
2514
|
-
return `${text.slice(0, start)}# ${title}${
|
|
2516
|
+
return `${text.slice(0, start)}# ${title}${sep}${body}`;
|
|
2515
2517
|
}
|
|
2516
2518
|
const gap = start === 0 ? 0 : /^(?:[ \t]*\r?\n)+/.exec(text.slice(start))?.[0].length ?? 0;
|
|
2517
2519
|
const lineStart = start + gap;
|
|
@@ -2563,9 +2565,9 @@ async function findTemplate(title, preferredRoot) {
|
|
|
2563
2565
|
const pref = assertRegisteredRoot(preferredRoot);
|
|
2564
2566
|
const local = resolveWikiTitle(title, await listNotes(pref));
|
|
2565
2567
|
if (local) {
|
|
2566
|
-
const
|
|
2567
|
-
if (
|
|
2568
|
-
return { path: local.path, text: templateText(
|
|
2568
|
+
const file = await readNote(local.path);
|
|
2569
|
+
if (file)
|
|
2570
|
+
return { path: local.path, text: templateText(file, title) };
|
|
2569
2571
|
}
|
|
2570
2572
|
const others = availableRoots().filter((r) => r !== pref);
|
|
2571
2573
|
const metas = (await Promise.all(others.map((r) => listNotes(r)))).flat();
|
|
@@ -3297,10 +3299,10 @@ function folderOut(meta) {
|
|
|
3297
3299
|
return meta.folder ? { folder: meta.folder } : {};
|
|
3298
3300
|
}
|
|
3299
3301
|
async function notesIn(workspace, folder = null) {
|
|
3300
|
-
const
|
|
3302
|
+
const roots = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
|
|
3301
3303
|
const scope = folderScopeOf(folder);
|
|
3302
3304
|
const out = [];
|
|
3303
|
-
for (const root of
|
|
3305
|
+
for (const root of roots) {
|
|
3304
3306
|
try {
|
|
3305
3307
|
for (const n of notesUnder(await listNotes(root), scope))
|
|
3306
3308
|
out.push({ ...n, workspace: root });
|
|
@@ -3385,10 +3387,10 @@ function targetWorkspace(args) {
|
|
|
3385
3387
|
throw new Error(`LEDGE_WORKSPACE names ${env}, which is no longer a registered workspace root \u2014 name one explicitly (list_workspaces shows them)`);
|
|
3386
3388
|
}
|
|
3387
3389
|
}
|
|
3388
|
-
const
|
|
3389
|
-
if (
|
|
3390
|
-
return
|
|
3391
|
-
throw new Error(
|
|
3390
|
+
const roots = writableRoots();
|
|
3391
|
+
if (roots.length === 1)
|
|
3392
|
+
return roots[0];
|
|
3393
|
+
throw new Error(roots.length === 0 ? "no workspace is available to create in (unmounted volume? list_workspaces shows what Ledge knows)" : "several workspaces exist \u2014 name one (list_workspaces shows them), or call from a shell in a Ledge note's terminal, where LEDGE_WORKSPACE names the current one");
|
|
3392
3394
|
}
|
|
3393
3395
|
function dailyWorkspace(args, settings) {
|
|
3394
3396
|
const asked = args["workspace"];
|
|
@@ -3502,11 +3504,11 @@ var ledgeTools = [
|
|
|
3502
3504
|
throw new Error("give a non-empty query");
|
|
3503
3505
|
await loadWorkspaces();
|
|
3504
3506
|
const workspace = args["workspace"];
|
|
3505
|
-
const
|
|
3507
|
+
const roots = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
|
|
3506
3508
|
const folder = folderScopeOf(args["folder"]);
|
|
3507
3509
|
const all = [];
|
|
3508
3510
|
let lockedSkipped = 0;
|
|
3509
|
-
for (const root of
|
|
3511
|
+
for (const root of roots) {
|
|
3510
3512
|
try {
|
|
3511
3513
|
const res = await searchNotes(root, query, folder);
|
|
3512
3514
|
lockedSkipped += res.lockedSkipped;
|
|
@@ -3564,16 +3566,16 @@ var ledgeTools = [
|
|
|
3564
3566
|
handler: async (args) => {
|
|
3565
3567
|
await loadWorkspaces();
|
|
3566
3568
|
const workspace = args["workspace"];
|
|
3567
|
-
const
|
|
3569
|
+
const roots = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
|
|
3568
3570
|
const tag = args["tag"];
|
|
3569
3571
|
const folder = folderScopeOf(args["folder"]);
|
|
3570
3572
|
if (typeof tag === "string" && normalizeTag(tag) !== "") {
|
|
3571
3573
|
const all = [];
|
|
3572
|
-
let
|
|
3573
|
-
for (const root of
|
|
3574
|
+
let lockedSkipped = 0;
|
|
3575
|
+
for (const root of roots) {
|
|
3574
3576
|
try {
|
|
3575
3577
|
const res = await notesTagged(root, tag, folder);
|
|
3576
|
-
|
|
3578
|
+
lockedSkipped += res.lockedSkipped;
|
|
3577
3579
|
for (const h of res.hits) {
|
|
3578
3580
|
all.push({ path: h.path, title: h.title, workspace: root, mtimeMs: h.mtimeMs, line: h.line, context: h.context });
|
|
3579
3581
|
}
|
|
@@ -3588,12 +3590,12 @@ var ledgeTools = [
|
|
|
3588
3590
|
return {
|
|
3589
3591
|
hits: hits.map(({ mtimeMs, ...h }) => ({ ...h, modified: iso(mtimeMs) })),
|
|
3590
3592
|
truncated: all.length > MAX_HITS,
|
|
3591
|
-
...
|
|
3593
|
+
...lockedSkipped > 0 ? { lockedNoteBodiesSkipped: lockedSkipped } : {}
|
|
3592
3594
|
};
|
|
3593
3595
|
}
|
|
3594
3596
|
const merged = new Map;
|
|
3595
3597
|
let lockedSkipped = 0;
|
|
3596
|
-
for (const root of
|
|
3598
|
+
for (const root of roots) {
|
|
3597
3599
|
try {
|
|
3598
3600
|
const res = await tagsIn(root, folder);
|
|
3599
3601
|
lockedSkipped += res.lockedSkipped;
|
|
@@ -3651,9 +3653,9 @@ var ledgeTools = [
|
|
|
3651
3653
|
if (typeof title !== "string" || title.trim() === "") {
|
|
3652
3654
|
throw new Error("creating from a template needs a `title` for the new note");
|
|
3653
3655
|
}
|
|
3654
|
-
const
|
|
3655
|
-
const
|
|
3656
|
-
return { path:
|
|
3656
|
+
const root = targetWorkspace(args);
|
|
3657
|
+
const meta = await createFromTemplate(root, template.trim(), title.trim(), folder);
|
|
3658
|
+
return { path: meta.path, title: meta.title, workspace: root, ...folderOut(meta), modified: iso(meta.mtimeMs) };
|
|
3657
3659
|
}
|
|
3658
3660
|
const text = args["text"];
|
|
3659
3661
|
if (typeof text !== "string" || text.trim() === "") {
|
|
@@ -4077,17 +4079,17 @@ async function runCli(argv, io) {
|
|
|
4077
4079
|
const ws = scope ?? (flags.all ? null : here);
|
|
4078
4080
|
const base = inFolder(ws !== null ? { workspace: ws } : {});
|
|
4079
4081
|
if (arg === "") {
|
|
4080
|
-
const
|
|
4082
|
+
const res = await tool("tags", base);
|
|
4081
4083
|
if (flags.json) {
|
|
4082
|
-
io.out(JSON.stringify(
|
|
4084
|
+
io.out(JSON.stringify(res, null, 2));
|
|
4083
4085
|
return 0;
|
|
4084
4086
|
}
|
|
4085
|
-
if (
|
|
4087
|
+
if (res.tags.length === 0) {
|
|
4086
4088
|
io.err(ws !== null ? `no tags in ${tildify(folder === "" ? ws : join10(ws, folder))}` : "no tags");
|
|
4087
4089
|
return 0;
|
|
4088
4090
|
}
|
|
4089
|
-
const width =
|
|
4090
|
-
for (const t of
|
|
4091
|
+
const width = res.tags.reduce((w, t) => Math.max(w, t.tag.length + 1), 0);
|
|
4092
|
+
for (const t of res.tags)
|
|
4091
4093
|
io.out(`${`#${t.tag}`.padEnd(width)} ${t.count}`);
|
|
4092
4094
|
return 0;
|
|
4093
4095
|
}
|
|
@@ -4119,11 +4121,11 @@ async function runCli(argv, io) {
|
|
|
4119
4121
|
const args = inFolder({ template: flags.template, title });
|
|
4120
4122
|
if (scope !== null)
|
|
4121
4123
|
args["workspace"] = scope;
|
|
4122
|
-
const
|
|
4124
|
+
const n = await tool("create_note", args);
|
|
4123
4125
|
if (flags.json)
|
|
4124
|
-
io.out(JSON.stringify(
|
|
4126
|
+
io.out(JSON.stringify(n, null, 2));
|
|
4125
4127
|
else
|
|
4126
|
-
io.out(
|
|
4128
|
+
io.out(n.path);
|
|
4127
4129
|
return 0;
|
|
4128
4130
|
}
|
|
4129
4131
|
if (title === "" && body === "") {
|
|
@@ -4423,8 +4425,8 @@ class PtyProcess {
|
|
|
4423
4425
|
constructor(opts) {
|
|
4424
4426
|
this.interruptViaChar = opts.interruptViaChar ?? false;
|
|
4425
4427
|
const keep = [];
|
|
4426
|
-
const cstr = (
|
|
4427
|
-
const enc = new TextEncoder().encode(
|
|
4428
|
+
const cstr = (str) => {
|
|
4429
|
+
const enc = new TextEncoder().encode(str);
|
|
4428
4430
|
const b = new Uint8Array(enc.length + 1);
|
|
4429
4431
|
b.set(enc);
|
|
4430
4432
|
keep.push(b);
|
|
@@ -4742,12 +4744,12 @@ class MarkerParser {
|
|
|
4742
4744
|
if (!tag.startsWith("ledge="))
|
|
4743
4745
|
return null;
|
|
4744
4746
|
const payload = tag.slice("ledge=".length);
|
|
4745
|
-
const
|
|
4746
|
-
if (
|
|
4747
|
+
const sep = payload.indexOf(":");
|
|
4748
|
+
if (sep === -1)
|
|
4747
4749
|
return null;
|
|
4748
|
-
if (payload.slice(0,
|
|
4750
|
+
if (payload.slice(0, sep) !== this.nonce)
|
|
4749
4751
|
return null;
|
|
4750
|
-
return payload.slice(
|
|
4752
|
+
return payload.slice(sep + 1);
|
|
4751
4753
|
}
|
|
4752
4754
|
}
|
|
4753
4755
|
function concat(a, b) {
|
|
@@ -4917,11 +4919,11 @@ class InlinePool {
|
|
|
4917
4919
|
continue;
|
|
4918
4920
|
}
|
|
4919
4921
|
if (slot.shell.exited) {
|
|
4920
|
-
const
|
|
4921
|
-
if (
|
|
4922
|
+
const open = slot.parser.openBlockId ?? slot.activeRun;
|
|
4923
|
+
if (open && !slot.began)
|
|
4922
4924
|
this.flushPreamble(slot, emit);
|
|
4923
|
-
if (
|
|
4924
|
-
emit({ type: "ended", blockId:
|
|
4925
|
+
if (open)
|
|
4926
|
+
emit({ type: "ended", blockId: open, exitCode: null }, slot.client);
|
|
4925
4927
|
this.dropSlot(session, slot);
|
|
4926
4928
|
}
|
|
4927
4929
|
}
|
|
@@ -4943,9 +4945,9 @@ class InlinePool {
|
|
|
4943
4945
|
const session = this.sessions.get(sessionId);
|
|
4944
4946
|
if (session) {
|
|
4945
4947
|
for (const slot of this.slots(session)) {
|
|
4946
|
-
const
|
|
4947
|
-
if (
|
|
4948
|
-
emit({ type: "ended", blockId:
|
|
4948
|
+
const open = slot.parser.openBlockId ?? slot.activeRun;
|
|
4949
|
+
if (open)
|
|
4950
|
+
emit({ type: "ended", blockId: open, exitCode: null }, slot.client);
|
|
4949
4951
|
slot.shell.close();
|
|
4950
4952
|
}
|
|
4951
4953
|
this.sessions.delete(sessionId);
|
|
@@ -5057,14 +5059,14 @@ class InlinePool {
|
|
|
5057
5059
|
}
|
|
5058
5060
|
dropSlot(session, slot) {
|
|
5059
5061
|
slot.shell.close();
|
|
5060
|
-
for (const [host,
|
|
5061
|
-
if (
|
|
5062
|
+
for (const [host, s] of session.primaries) {
|
|
5063
|
+
if (s === slot) {
|
|
5062
5064
|
session.primaries.delete(host);
|
|
5063
5065
|
return;
|
|
5064
5066
|
}
|
|
5065
5067
|
}
|
|
5066
|
-
for (const [id,
|
|
5067
|
-
if (
|
|
5068
|
+
for (const [id, s] of session.overflow) {
|
|
5069
|
+
if (s === slot)
|
|
5068
5070
|
session.overflow.delete(id);
|
|
5069
5071
|
}
|
|
5070
5072
|
}
|
|
@@ -5134,6 +5136,57 @@ async function writeProfile(name, text) {
|
|
|
5134
5136
|
}
|
|
5135
5137
|
}
|
|
5136
5138
|
|
|
5139
|
+
// src/shared/welcome.ts
|
|
5140
|
+
var WELCOME_TITLE = "Welcome to Ledge";
|
|
5141
|
+
var WELCOME_DOC = [
|
|
5142
|
+
`# ${WELCOME_TITLE}`,
|
|
5143
|
+
"",
|
|
5144
|
+
"Ledge runs code and commands straight from your Markdown. This note is yours: edit it, or start a new one with \u2318N.",
|
|
5145
|
+
"",
|
|
5146
|
+
"## Run a block",
|
|
5147
|
+
"",
|
|
5148
|
+
"\u2318\u21A9 inside the block below, or the Run button on it (a tap, on a phone), runs it.",
|
|
5149
|
+
"",
|
|
5150
|
+
"```sh",
|
|
5151
|
+
"curl -s https://api.github.com/zen",
|
|
5152
|
+
"```",
|
|
5153
|
+
"",
|
|
5154
|
+
"One line of output streams into a panel beneath the block, and Dismiss puts the panel away.",
|
|
5155
|
+
"",
|
|
5156
|
+
"## The shell persists between blocks",
|
|
5157
|
+
"",
|
|
5158
|
+
"Each note keeps one shell for inline runs, so a `cd` or an exported variable carries into the next block. Run these two in order:",
|
|
5159
|
+
"",
|
|
5160
|
+
"```sh",
|
|
5161
|
+
"cd /tmp",
|
|
5162
|
+
"export FLAVOR=nautical",
|
|
5163
|
+
"```",
|
|
5164
|
+
"",
|
|
5165
|
+
"```sh",
|
|
5166
|
+
"pwd",
|
|
5167
|
+
'echo "this shell is feeling $FLAVOR"',
|
|
5168
|
+
"```",
|
|
5169
|
+
"",
|
|
5170
|
+
"\u21E7\u2318\u21A9 sends a block to the note's terminal drawer instead, a separate shell you can keep typing in. \u2303` opens the drawer.",
|
|
5171
|
+
"",
|
|
5172
|
+
"## Other languages",
|
|
5173
|
+
"",
|
|
5174
|
+
"`python`, `node`, `ts`, and others are runnable out of the box, each run a fresh process, and TypeScript runs on Bun, which Ledge already has:",
|
|
5175
|
+
"",
|
|
5176
|
+
"```ts",
|
|
5177
|
+
"const now = new Date();",
|
|
5178
|
+
"console.log(`hello from TypeScript, it is ${now.toLocaleTimeString()}`);",
|
|
5179
|
+
"```",
|
|
5180
|
+
"",
|
|
5181
|
+
"## Where to next",
|
|
5182
|
+
"",
|
|
5183
|
+
"- The first line of a note names its file, so this one is `welcome-to-ledge.md`. Delete it from the sidebar once you are done with it.",
|
|
5184
|
+
"- \u2318P opens a note by title, and \u2325\u2318P searches every note.",
|
|
5185
|
+
'- The manual is behind the help button in the header, or "Documentation" in the command palette (\u21E7\u2318P). Getting Started is its first page.',
|
|
5186
|
+
""
|
|
5187
|
+
].join(`
|
|
5188
|
+
`);
|
|
5189
|
+
|
|
5137
5190
|
// src/bun/docs.ts
|
|
5138
5191
|
import { basename as basename5, join as join13, resolve as resolve9 } from "path";
|
|
5139
5192
|
import { mkdir as mkdir5, readdir as readdir4, readFile as readFile9, rename as rename8, unlink as unlink7, writeFile as writeFile8 } from "fs/promises";
|
|
@@ -5143,7 +5196,7 @@ var _01_getting_started_default = `# Getting Started
|
|
|
5143
5196
|
|
|
5144
5197
|
Ledge is the notebook for developers and DevOps. It runs code and commands straight from your Markdown.
|
|
5145
5198
|
|
|
5146
|
-
The manual is read-only and its code blocks do not run. The note called Welcome to Ledge
|
|
5199
|
+
The manual is read-only and its code blocks do not run. The note called Welcome to Ledge is where the same examples do run. Ledge creates it the first time it starts on a machine, whether that is your Mac or a new server, and it stays in the Scratch workspace until you delete it.
|
|
5147
5200
|
|
|
5148
5201
|
## Your first note
|
|
5149
5202
|
|
|
@@ -6101,17 +6154,36 @@ Closing the last window quits Ledge.
|
|
|
6101
6154
|
|
|
6102
6155
|
## Install the server
|
|
6103
6156
|
|
|
6104
|
-
The other machine needs the
|
|
6157
|
+
The other machine needs the server, which is the \`ledge-server\` package. One command installs it, on Linux or a Mac. Run it in a terminal on that machine, signed in as the account Ledge will sign in to:
|
|
6158
|
+
|
|
6159
|
+
\`\`\`sh norun
|
|
6160
|
+
curl -fsSL https://ledge.sh/server.sh | sh
|
|
6161
|
+
\`\`\`
|
|
6162
|
+
|
|
6163
|
+
It needs no \`sudo\` and nothing installed first. It downloads the package and a Bun of its own, checks both against the checksums written into the script, and puts them in \`~/.ledge/.server\` in that account's home. Ledge's ssh command looks in \`~/.ledge/.server/bin\` before anything else, so an incoming ssh finds the server with no change to the PATH. The script also adds a PATH line to the shell's startup file, so your own new terminals find \`ledge\` too.
|
|
6105
6164
|
|
|
6106
|
-
|
|
6165
|
+
It refuses to run as root, because the server belongs to the account Ledge signs in to. To install for another account, such as one named \`ledge\`, run it through \`sudo\`:
|
|
6107
6166
|
|
|
6108
6167
|
\`\`\`sh norun
|
|
6109
|
-
curl -fsSL https://
|
|
6168
|
+
curl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh
|
|
6110
6169
|
\`\`\`
|
|
6111
6170
|
|
|
6112
|
-
|
|
6171
|
+
Running the same command again updates the server. A server that is already running goes on serving until it exits on its own, a minute or more after the last device disconnects, and the next connection starts the new version. [[Tutorial: Set Up a Ledge Server]] walks through the install on a fresh VPS, with an account for Ledge and the sshd hardening this page describes further down.
|
|
6172
|
+
|
|
6173
|
+
A Mac that runs the Ledge app needs none of this. "Install Shell Command (ledge)" in the app's command palette puts \`ledge\` in \`~/.ledge/.server/bin\`, where an incoming ssh looks first, pointing at the app's own copy. Signing in as that account then reaches the notes the app shows, with the app's server answering both. On a Mac, the machine also needs Remote Login turned on ("Expose ssh carefully").
|
|
6174
|
+
|
|
6175
|
+
macOS and Linux are supported, on arm64 or x64. On Linux the floor is glibc 2.29, which means Debian 11, Ubuntu 20.04, RHEL 9, or anything newer. Alpine and other musl systems are not supported.
|
|
6176
|
+
|
|
6177
|
+
Nothing else has to be installed and no port is opened. Ledge speaks its protocol over ssh's stdin and stdout.
|
|
6178
|
+
|
|
6179
|
+
Blocks need zsh or bash on that machine. Ledge spawns the account's login shell when it is one of those, and otherwise the first of the two it finds, so an ordinary Linux install needs nothing extra. Where neither exists, a run refuses and names the shell it looked for instead of appearing to do nothing.
|
|
6180
|
+
|
|
6181
|
+
## Install the server with Bun
|
|
6182
|
+
|
|
6183
|
+
If the machine already has Bun and you would rather use it, the same package installs with \`bun add -g\`. Where Bun goes decides where the server goes, because Bun puts global commands beside itself, and both have to be on the short PATH an incoming ssh gets. On Linux, that means Bun in \`/usr/local\`:
|
|
6113
6184
|
|
|
6114
6185
|
\`\`\`sh norun
|
|
6186
|
+
curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
|
|
6115
6187
|
sudo BUN_INSTALL=/usr/local bun add -g ledge-server
|
|
6116
6188
|
\`\`\`
|
|
6117
6189
|
|
|
@@ -6128,27 +6200,23 @@ bun add -g ledge-server
|
|
|
6128
6200
|
|
|
6129
6201
|
Run them as the account Ledge signs in to. None of them needs \`sudo\`. The account's shell has to be zsh, which it is unless the account predates macOS Catalina.
|
|
6130
6202
|
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
macOS and Linux are supported, on arm64 or x64. On Linux the floor is glibc 2.29, which means Debian 11, Ubuntu 20.04, RHEL 9, or anything newer. Alpine and other musl systems are not supported.
|
|
6134
|
-
|
|
6135
|
-
Nothing else has to be installed and no port is opened. Ledge speaks its protocol over ssh's stdin and stdout.
|
|
6136
|
-
|
|
6137
|
-
Blocks need zsh or bash on that machine. Ledge spawns the account's login shell when it is one of those, and otherwise the first of the two it finds, so an ordinary Linux install needs nothing extra. Where neither exists, a run refuses and names the shell it looked for instead of appearing to do nothing.
|
|
6203
|
+
Updating is \`bun add -g ledge-server@latest\`, with the same \`sudo BUN_INSTALL=/usr/local\` in front on Linux.
|
|
6138
6204
|
|
|
6139
6205
|
## Check that ssh can find the server
|
|
6140
6206
|
|
|
6141
6207
|
Worth doing once, because Ledge reports the failure it catches as a server that is not installed. A remote shell that cannot find a command says only that, so that is all the app has to go on.
|
|
6142
6208
|
|
|
6143
|
-
Ledge starts the server by running \`ledge serve\` over ssh. A command run that way
|
|
6209
|
+
Ledge starts the server by running \`PATH=$HOME/.ledge/.server/bin:$PATH ledge serve\` over ssh. A command run that way skips the startup files a terminal reads, so \`ledge\` has to be in \`~/.ledge/.server/bin\` or on the short PATH an incoming ssh gets. From your Mac's own terminal, ask the machine the same question:
|
|
6144
6210
|
|
|
6145
6211
|
\`\`\`sh norun
|
|
6146
|
-
ssh you@machine '
|
|
6212
|
+
ssh you@machine 'PATH=$HOME/.ledge/.server/bin:$PATH command -v ledge'
|
|
6147
6213
|
\`\`\`
|
|
6148
6214
|
|
|
6149
|
-
|
|
6215
|
+
A path printed means the machine is ready to add.
|
|
6216
|
+
|
|
6217
|
+
Nothing printed after \`server.sh\` means it ran as a different account from the one you signed in as. Run it again as that account.
|
|
6150
6218
|
|
|
6151
|
-
On Linux, nothing printed means Bun is installed for one user rather than
|
|
6219
|
+
After an install with Bun, check \`bun\` as well, since the package starts with it: \`ssh you@machine 'command -v bun'\`. On Linux, nothing printed means Bun is installed for one user rather than in \`/usr/local\`, which is what a machine that already had Bun before you started usually has. Its global commands are then in \`~/.bun/bin\`, which an incoming ssh does not search, and \`bun pm bin -g\` on that machine confirms where they went. Linking both names into a system directory, on that machine, fixes it without reinstalling anything:
|
|
6152
6220
|
|
|
6153
6221
|
\`\`\`sh norun
|
|
6154
6222
|
sudo ln -s "$(bun pm bin -g)/ledge" /usr/local/bin/ledge
|
|
@@ -6177,10 +6245,10 @@ Optional, and worth doing on a server you care about. Ledge connects with an ord
|
|
|
6177
6245
|
Restricting gives the server a key that can speak Ledge's protocol and nothing else. In that machine's \`~/.ssh/authorized_keys\`:
|
|
6178
6246
|
|
|
6179
6247
|
\`\`\`
|
|
6180
|
-
restrict,command="/
|
|
6248
|
+
restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ssh-ed25519 AAAA... ledge@laptop
|
|
6181
6249
|
\`\`\`
|
|
6182
6250
|
|
|
6183
|
-
|
|
6251
|
+
The command is the one Ledge itself runs, so it finds the server wherever the check above found it. sshd runs this line instead of whatever the client asked for.
|
|
6184
6252
|
|
|
6185
6253
|
That key cannot forward a port, run \`scp\`, or open a shell over ssh. What it limits is what the key is good for if it is ever stolen: no route into the network behind that server, and no file copying.
|
|
6186
6254
|
|
|
@@ -6250,7 +6318,7 @@ Backups run every hour while the server is up, and once more before it exits: af
|
|
|
6250
6318
|
|
|
6251
6319
|
Three things to know before you rely on it:
|
|
6252
6320
|
|
|
6253
|
-
- Keep the restic password somewhere other than this machine. \`setup\` prints it once.
|
|
6321
|
+
- Keep the restic password somewhere other than this machine, such as a password manager. \`setup\` prints it once. It is the only key to the backup, and nothing can be restored without it.
|
|
6254
6322
|
- The backup holds secrets. Profile values are plain text on disk and so are unlocked notes, which is why restic encrypts before uploading. The bucket sees ciphertext only.
|
|
6255
6323
|
- Locked notes and the vault travel together. \`.vault.json\` is inside the app home, so it is always in the backup, and a restore opens locked notes with the passphrase they had ([[Note Locking]]).
|
|
6256
6324
|
|
|
@@ -6395,6 +6463,8 @@ var _10_ledge_on_your_phone_default = `# Ledge on Your Phone
|
|
|
6395
6463
|
|
|
6396
6464
|
Ledge runs on an iPhone or iPad as a window onto a server. The phone holds no notes: it reaches a server over ssh, the way a Mac does in [[Keep Notes on a Remote Server]], and shows you what is there.
|
|
6397
6465
|
|
|
6466
|
+
Get Ledge for iPhone from the App Store. It runs on iOS and iPadOS 17 or newer, and it needs a server to connect to before it shows anything.
|
|
6467
|
+
|
|
6398
6468
|
A server that already serves your Mac needs nothing more. A machine without one needs the server installed first, as "Install the server" on that page describes, and the phone shows the same commands ("Set up a server" below).
|
|
6399
6469
|
|
|
6400
6470
|
## The first screen
|
|
@@ -6431,31 +6501,20 @@ A code never replaces a host key the phone already has. When Ledge has a differe
|
|
|
6431
6501
|
|
|
6432
6502
|
## Set up a server
|
|
6433
6503
|
|
|
6434
|
-
I don't have a server yet opens "Set up a server", which shows the commands that make a machine a Ledge server
|
|
6504
|
+
I don't have a server yet opens "Set up a server", which shows the two commands that make a machine a Ledge server, on Linux or a Mac:
|
|
6435
6505
|
|
|
6436
6506
|
\`\`\`sh norun
|
|
6437
|
-
curl -fsSL https://
|
|
6438
|
-
|
|
6439
|
-
ledge pair
|
|
6440
|
-
\`\`\`
|
|
6441
|
-
|
|
6442
|
-
On a Mac:
|
|
6443
|
-
|
|
6444
|
-
\`\`\`sh norun
|
|
6445
|
-
curl -fsSL https://bun.sh/install | bash
|
|
6446
|
-
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.zshenv
|
|
6447
|
-
source ~/.zshenv
|
|
6448
|
-
bun add -g ledge-server
|
|
6449
|
-
ledge pair
|
|
6507
|
+
curl -fsSL https://ledge.sh/server.sh | sh
|
|
6508
|
+
~/.ledge/.server/bin/ledge pair
|
|
6450
6509
|
\`\`\`
|
|
6451
6510
|
|
|
6452
|
-
Run them in a terminal on that machine, signed in as the account the phone should use.
|
|
6511
|
+
Run them in a terminal on that machine, signed in as the account the phone should use rather than root. The first installs the server in that account's home, where a command run over ssh finds it, and needs no \`sudo\`. The second prints a pairing code for that account, and Scan the pairing code on the same screen reads it. It names \`ledge\` by its full path because the PATH line the installer adds reaches only new terminals.
|
|
6453
6512
|
|
|
6454
6513
|
Copy commands puts them on the phone's pasteboard. Share commands hands them to AirDrop, Messages, or any app that can carry them to a computer with a terminal open on that machine.
|
|
6455
6514
|
|
|
6456
|
-
On a Mac, turn on Remote Login first, in System Settings under General, then Sharing. A Mac that runs the Ledge app needs only "Install Shell Command (ledge)" from the app's command palette in place of the first
|
|
6515
|
+
On a Mac, turn on Remote Login first, in System Settings under General, then Sharing. A Mac that runs the Ledge app needs only "Install Shell Command (ledge)" from the app's command palette in place of the first command: it puts \`ledge\` where the phone's ssh looks, pointing at the app's own copy, so the phone sees the same notes the app shows. The second command then prints the code.
|
|
6457
6516
|
|
|
6458
|
-
The machine needs sshd running and an address the phone can reach. [[Keep Notes on a Remote Server]] has the details of the install, including
|
|
6517
|
+
The machine needs sshd running and an address the phone can reach. [[Keep Notes on a Remote Server]] has the details of the install, including installing with Bun instead, and [[Tutorial: Set Up a Ledge Server]] walks through a fresh VPS.
|
|
6459
6518
|
|
|
6460
6519
|
If you already have a server, Add an existing server at the bottom of the screen opens the form from "Pair by address", and Back from there returns to the first screen.
|
|
6461
6520
|
|
|
@@ -6465,13 +6524,19 @@ Add an existing server opens "Pair with a server", a form with three parts. The
|
|
|
6465
6524
|
|
|
6466
6525
|
The first part is the machine: \`user@host\`, and a port when sshd is not on 22. A phone reads no \`~/.ssh/config\`, so write the address out.
|
|
6467
6526
|
|
|
6468
|
-
The second is how to sign in. With A key, the default, the form shows a
|
|
6527
|
+
The second is how to sign in. With A key, the default, the form shows a command to run on the server. On its first launch the phone makes a key of its own in the Secure Enclave, and that key never leaves the phone: there is no file to copy in or out. What leaves is the public half, as one line for the server's \`~/.ssh/authorized_keys\`:
|
|
6469
6528
|
|
|
6470
6529
|
\`\`\`
|
|
6471
6530
|
restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ecdsa-sha2-nistp256 AAAA... ledge-iphone-3f2a91c0
|
|
6472
6531
|
\`\`\`
|
|
6473
6532
|
|
|
6474
|
-
|
|
6533
|
+
The command adds that line to the file, creating \`~/.ssh\` first if the account has none:
|
|
6534
|
+
|
|
6535
|
+
\`\`\`sh norun
|
|
6536
|
+
mkdir -p ~/.ssh && chmod 700 ~/.ssh && printf '\\n%s\\n' 'restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ecdsa-sha2-nistp256 AAAA... ledge-iphone-3f2a91c0' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
|
|
6537
|
+
\`\`\`
|
|
6538
|
+
|
|
6539
|
+
Run it on the server, signed in as the account Ledge uses: over ssh from a Mac, or in the provider's web console for a new VPS. Copy command puts it on the phone's pasteboard. Share command hands it to AirDrop, Messages, or any app that can carry it to that terminal, which is where the pasteboard on a phone falls short. The comment at the end of the line names the phone, so the line is easy to find again when you want to revoke it.
|
|
6475
6540
|
|
|
6476
6541
|
The line arrives already restricted, in the way "Restrict the key to Ledge" on [[Keep Notes on a Remote Server]] describes: the phone's key can speak Ledge's protocol and nothing else. It looks for \`ledge\` in \`~/.ledge/.server/bin\` first and then on the PATH an incoming ssh gets, so a server installed in either place starts ("Check that ssh can find the server" on the same page).
|
|
6477
6542
|
|
|
@@ -6481,7 +6546,7 @@ Ledge adds the server only once \`ledge serve\` answers there. On a machine wher
|
|
|
6481
6546
|
|
|
6482
6547
|
## Sign in with a password instead
|
|
6483
6548
|
|
|
6484
|
-
Choose "A password" under Sign in with and type the password for that account. The phone keeps
|
|
6549
|
+
Choose "A password" under Sign in with and type the password for that account. The eye button at the end of the field shows what you typed, to check it before connecting. The phone keeps the password in its own keychain, and no key line has to be installed.
|
|
6485
6550
|
|
|
6486
6551
|
The trade-off is the one described on [[Keep Notes on a Remote Server]]: a password reaches a fresh machine today, and a key is the better long-term answer. A server with \`PasswordAuthentication no\` refuses it.
|
|
6487
6552
|
|
|
@@ -6497,7 +6562,7 @@ Removing the last server returns the phone to the first screen. Deleting the app
|
|
|
6497
6562
|
|
|
6498
6563
|
## More than one server
|
|
6499
6564
|
|
|
6500
|
-
Inside the app the connection bar works as on a Mac: tap it to add, edit, remove, or switch servers, with the same fingerprint step ([[Keep Notes on a Remote Server]]). The form shows the phone's key
|
|
6565
|
+
Inside the app the connection bar works as on a Mac: tap it to add, edit, remove, or switch servers, with the same fingerprint step ([[Keep Notes on a Remote Server]]). The form shows the command that installs the phone's key where a Mac's shows a key path, with Share Command beside Copy Command.
|
|
6501
6566
|
|
|
6502
6567
|
Add Server\u2026 starts with Scan a pairing code, where a Mac's form has a field for the pasted link. It opens the camera, then the same "Pair with a server" screen as the first launch, and the app reopens on the new server once you tap Connect there. Cancel returns you to the form, where you can type the address instead. Editing a server has no scan: a code never replaces a host key the phone already has.
|
|
6503
6568
|
|
|
@@ -7117,7 +7182,7 @@ They combine: a synced drive for the always-on workspaces, a git repo for the on
|
|
|
7117
7182
|
`;
|
|
7118
7183
|
|
|
7119
7184
|
// docs/user/20-tutorial-set-up-a-ledge-server.md
|
|
7120
|
-
var _20_tutorial_set_up_a_ledge_server_default = '# Tutorial: Set Up a Ledge Server\n\nTurn a fresh Linux VPS into a Ledge server: an account for Ledge, the server package, a key that can do nothing but Ledge, and an sshd that ignores everyone else.\n\nThis builds on [[Keep Notes on a Remote Server]], which is the reference for every step here. The commands assume Debian or Ubuntu. Any Linux with glibc 2.29 or newer works, so substitute your package manager on anything else.\n\nTwo accounts appear throughout. `you@vps` is the account your provider gave you, which can `sudo`. `ledge@vps` is the account you create in step 1, which cannot.\n\n## 1. Create an account for Ledge\n\nOn the VPS, as your own account:\n\n```sh norun\nsudo adduser --disabled-password --gecos "" ledge\n```\n\nThe account has no password and no `sudo`. Everything Ledge does on this machine runs as this account: the server, the shells, and every block in every note. A key for it that is ever stolen cannot become root.\n\nIf your notes need `sudo`, that is a decision for later, made with `visudo` and as narrow as you can make it.\n\n`adduser` gives the account bash as its login shell, which is one of the two shells Ledge runs blocks in.\n\n## 2. Make a key on your Mac\n\nIn a terminal on your Mac:\n\n```sh norun\nssh-keygen -t ed25519 -f ~/.ssh/ledge -C ledge@laptop\ncat ~/.ssh/ledge.pub\n```\n\nLeave the key\'s passphrase empty, or use one your ssh agent already holds. Ledge\'s ssh runs with no terminal attached, so a passphrase it would have to type at a prompt never gets typed. This is about the key file only: signing in with the account\'s password is a choice on the form, and [[Keep Notes on a Remote Server]] covers it. This tutorial uses a key so that step 8 can turn passwords off.\n\nCopy the printed line, then put it on the VPS as the new account\'s only key. As your own account there, with the line pasted in place of the placeholder:\n\n```sh norun\nsudo install -d -m 700 -o ledge -g ledge /home/ledge/.ssh\necho \'ssh-ed25519 AAAA... ledge@laptop\' | sudo tee /home/ledge/.ssh/authorized_keys\nsudo chown ledge:ledge /home/ledge/.ssh/authorized_keys\nsudo chmod 600 /home/ledge/.ssh/authorized_keys\n```\n\nThe line goes in unrestricted for now. Step 7 restricts it, once you know the server works.\n\n## 3. Install the server\n\nStill on the VPS, as your own account:\n\n```sh norun\ncurl -fsSL https://
|
|
7185
|
+
var _20_tutorial_set_up_a_ledge_server_default = '# Tutorial: Set Up a Ledge Server\n\nTurn a fresh Linux VPS into a Ledge server: an account for Ledge, the server package, a key that can do nothing but Ledge, and an sshd that ignores everyone else.\n\nThis builds on [[Keep Notes on a Remote Server]], which is the reference for every step here. The commands assume Debian or Ubuntu. Any Linux with glibc 2.29 or newer works, so substitute your package manager on anything else.\n\nTwo accounts appear throughout. `you@vps` is the account your provider gave you, which can `sudo`. `ledge@vps` is the account you create in step 1, which cannot.\n\n## 1. Create an account for Ledge\n\nOn the VPS, as your own account:\n\n```sh norun\nsudo adduser --disabled-password --gecos "" ledge\n```\n\nThe account has no password and no `sudo`. Everything Ledge does on this machine runs as this account: the server, the shells, and every block in every note. A key for it that is ever stolen cannot become root.\n\nIf your notes need `sudo`, that is a decision for later, made with `visudo` and as narrow as you can make it.\n\n`adduser` gives the account bash as its login shell, which is one of the two shells Ledge runs blocks in.\n\n## 2. Make a key on your Mac\n\nIn a terminal on your Mac:\n\n```sh norun\nssh-keygen -t ed25519 -f ~/.ssh/ledge -C ledge@laptop\ncat ~/.ssh/ledge.pub\n```\n\nLeave the key\'s passphrase empty, or use one your ssh agent already holds. Ledge\'s ssh runs with no terminal attached, so a passphrase it would have to type at a prompt never gets typed. This is about the key file only: signing in with the account\'s password is a choice on the form, and [[Keep Notes on a Remote Server]] covers it. This tutorial uses a key so that step 8 can turn passwords off.\n\nCopy the printed line, then put it on the VPS as the new account\'s only key. As your own account there, with the line pasted in place of the placeholder:\n\n```sh norun\nsudo install -d -m 700 -o ledge -g ledge /home/ledge/.ssh\necho \'ssh-ed25519 AAAA... ledge@laptop\' | sudo tee /home/ledge/.ssh/authorized_keys\nsudo chown ledge:ledge /home/ledge/.ssh/authorized_keys\nsudo chmod 600 /home/ledge/.ssh/authorized_keys\n```\n\nThe line goes in unrestricted for now. Step 7 restricts it, once you know the server works.\n\n## 3. Install the server\n\nStill on the VPS, as your own account, install the server into the new account\'s home:\n\n```sh norun\ncurl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh\n```\n\nThe installer runs as `ledge` and puts the server, with a Bun of its own, in `/home/ledge/.ledge/.server`. It refuses to run as root, because the server belongs to the account Ledge signs in to, and `sudo -iu ledge` is how your own account runs it as that one.\n\nNothing else needs installing and no service needs starting. Ledge starts the server over ssh when it connects, and the server exits a minute after the last device leaves, unless a block is still running.\n\n## 4. Check that ssh can find it\n\nFrom your Mac, as the new account, with the new key:\n\n```sh norun\nssh -i ~/.ssh/ledge ledge@vps \'PATH=$HOME/.ledge/.server/bin:$PATH command -v ledge\'\n```\n\nA path printed means the machine is ready. This is the same lookup Ledge makes when it connects.\n\nNothing printed means the installer ran as a different account. Run step 3 again exactly as written.\n\n## 5. Add the server in Ledge\n\nRun "Notes On\u2026" from the command palette, choose Add, and fill in the form:\n\n| Field | Value |\n| --- | --- |\n| Name | Whatever you want the connection bar to say |\n| SSH destination | `ledge@vps` |\n| Port | Blank |\n| Sign in with | A key |\n| Key | `~/.ssh/ledge` |\n\nLedge fetches the machine\'s host key and shows its fingerprint. Get the same fingerprint from the machine itself, in your terminal on the VPS:\n\n```sh norun\nssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub\n```\n\nChoose "It Matches, Add" when the two agree. Ledge pins the key and refuses any future connection from that address that presents a different one.\n\n## 6. Try it\n\nThe connection bar now names the server. Press \u2318N, and the note you create is a file on the VPS. Give it one block:\n\n````\n```sh\nhostname; whoami\n```\n````\n\n\u2318\u21A9 prints the VPS\'s hostname and `ledge`. The block ran on the server, as the account you made, and the note never left it.\n\n## 7. Restrict the key to Ledge\n\nEdit `/home/ledge/.ssh/authorized_keys` on the VPS and put a prefix in front of the key:\n\n```\nrestrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ssh-ed25519 AAAA... ledge@laptop\n```\n\nThat key can now speak Ledge\'s protocol and nothing else: no shell, no port forwarding, no file copying. sshd runs the named command whatever the client asks for, so the terminal check in step 4 stops working for this key. That is expected. Your own account is the one for terminals.\n\nThe connection you already have keeps working. Ledge\'s next connection, at the next launch or after a drop, uses the restricted line.\n\n## 8. Turn off passwords in sshd\n\nA Ledge server runs whatever its notes say, so sshd should answer keys and nothing else. Create `/etc/ssh/sshd_config.d/10-ledge.conf`:\n\n```\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin prohibit-password\n```\n\nThe `10-` matters. sshd keeps the first value it reads for a setting, and it reads this directory in name order. Cloud images ship a `50-cloud-init.conf` that turns passwords on, and a file named after it would lose.\n\nYour own account has to sign in with a key from now on. The provider usually installed one when it created the VPS, and this line from your Mac says whether it did:\n\n```sh norun\nssh -o PasswordAuthentication=no you@vps true\n```\n\nIf it asks for a password, put a key on that account first, with `ssh-copy-id`.\n\nThen check the configuration and reload, keeping your current terminal open until a second one has logged in:\n\n```sh norun\nsudo sshd -t && sudo systemctl reload ssh\n```\n\n## 9. Ban repeated guesses with fail2ban\n\nKeys-only sshd refuses every guess, but a box on the public internet still receives thousands of them a day, and each one costs a log line and a connection slot. fail2ban blocks an address after a few failures.\n\n```sh norun\nsudo apt-get install -y fail2ban\n```\n\nCreate `/etc/fail2ban/jail.local`:\n\n```ini\n[sshd]\nenabled = true\nbackend = systemd\nmaxretry = 5\nbantime = 1h\n```\n\n`backend = systemd` reads sshd\'s log from the journal. Debian 12, Ubuntu 24.04, and anything newer ship without a text `auth.log`, and fail2ban without this line fails to start on them.\n\n```sh norun\nsudo systemctl enable --now fail2ban\nsudo fail2ban-client status sshd\n```\n\nThe second command prints the jail\'s counts. Ledge never trips it: it connects with a key sshd accepts, and reconnects the same way.\n\n## 10. Close every other port\n\nOnly sshd needs to be reachable. Allow it, then turn the firewall on:\n\n```sh norun\nsudo apt-get install -y ufw\nsudo ufw allow 22/tcp\nsudo ufw enable\nsudo ufw status\n```\n\nUbuntu has `ufw` already, and the install line does nothing there.\n\nIf the VPS is on a tailnet or VPN, allow ssh from that interface alone and drop the public rule:\n\n```sh norun\nsudo ufw allow in on tailscale0 to any port 22\nsudo ufw delete allow 22/tcp\n```\n\nThen use the tailnet address as the SSH destination in Ledge. A server nobody else can reach has nothing for fail2ban to do, and the previous step does no harm.\n\n## 11. Keep it patched\n\nSecurity updates for the operating system should install themselves:\n\n```sh norun\nsudo apt-get install -y unattended-upgrades\nsudo dpkg-reconfigure -plow unattended-upgrades\n```\n\nAnswer Yes. Ubuntu ships with this on, and the two commands confirm it.\n\nThe server updates with the install line from step 3, run again:\n\n```sh norun\ncurl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh\n```\n\nA connection between an app and a server that cannot understand each other is refused with a sentence naming which end to update, so a version that falls behind is reported rather than guessed at.\n\n## Where to go next\n\n- **Back it up.** The notes now live on one disk that belongs to one provider. [[Tutorial: Back Up Your Notes to S3]] puts an encrypted copy in a bucket every hour, with one `ledge backup setup`.\n- **Add your phone.** Its pairing screen hands you a line for this same `authorized_keys`, already restricted ([[Ledge on Your Phone]]).\n- **Install what your notes run.** `git`, a language, a cloud CLI: whatever a block on this machine needs, installed as your own account with `apt-get`.\n- **Reach other machines from it.** A note on the VPS can carry `host: prod`, and the VPS makes that ssh connection with a key in `/home/ledge/.ssh` ([[Run Code on Remote Hosts]]).\n';
|
|
7121
7186
|
|
|
7122
7187
|
// docs/user/21-tutorial-back-up-your-notes-to-s3.md
|
|
7123
7188
|
var _21_tutorial_back_up_your_notes_to_s3_default = `# Tutorial: Back Up Your Notes to S3
|
|
@@ -7158,9 +7223,9 @@ It asks for the endpoint, the bucket, the access key ID, and the secret. Then it
|
|
|
7158
7223
|
| Credentials | Writes the four values and a generated restic password to the \`backup\` profile, \`~/.config/ledge/profiles/backup.env\`, readable by this account alone ([[Profiles and Secrets]]). |
|
|
7159
7224
|
| Repository | Creates the restic repository in the bucket. |
|
|
7160
7225
|
| First backup | Backs up everything \`ledge backup paths\` lists: the app home, every folder attached from elsewhere on the machine, and the profiles. |
|
|
7161
|
-
| Password | Prints the password
|
|
7226
|
+
| Password | Prints the password, the one thing \`setup\` writes to stdout. |
|
|
7162
7227
|
|
|
7163
|
-
The password is what encrypts the backup, and it is the only key. Copy it somewhere that is not this machine, such as a password manager.
|
|
7228
|
+
The password is what encrypts the backup, and it is the only key. Nothing can be restored without it. Copy it somewhere that is not this machine, such as a password manager.
|
|
7164
7229
|
|
|
7165
7230
|
## 3. Leave it running
|
|
7166
7231
|
|
|
@@ -7171,7 +7236,7 @@ On a Mac, the server is up while the app is open and for a minute after it close
|
|
|
7171
7236
|
Nothing else needs installing: no timer, no unit file, no line in a crontab. One line is worth adding on a server where notes are written while no device is connected, by the \`ledge\` command or by an agent, since those do not start the server:
|
|
7172
7237
|
|
|
7173
7238
|
\`\`\`sh norun
|
|
7174
|
-
0 * * * * /
|
|
7239
|
+
0 * * * * $HOME/.ledge/.server/bin/ledge backup now
|
|
7175
7240
|
\`\`\`
|
|
7176
7241
|
|
|
7177
7242
|
\`ledge backup now\` takes a backup at any time and is safe to run beside the schedule.
|
|
@@ -9708,9 +9773,9 @@ function sizeOf(path) {
|
|
|
9708
9773
|
}
|
|
9709
9774
|
var logPath = LOG_PATH;
|
|
9710
9775
|
var prevPath = PREV_LOG_PATH;
|
|
9711
|
-
function logToFile(
|
|
9712
|
-
logPath = join15(LOG_DIR, `${
|
|
9713
|
-
prevPath = join15(LOG_DIR, `${
|
|
9776
|
+
function logToFile(basename) {
|
|
9777
|
+
logPath = join15(LOG_DIR, `${basename}.log`);
|
|
9778
|
+
prevPath = join15(LOG_DIR, `${basename}.previous.log`);
|
|
9714
9779
|
}
|
|
9715
9780
|
function rotate() {
|
|
9716
9781
|
try {
|
|
@@ -9741,12 +9806,12 @@ function write(source, level, args) {
|
|
|
9741
9806
|
append(formatLine(new Date, source, level, args));
|
|
9742
9807
|
}
|
|
9743
9808
|
var patched = false;
|
|
9744
|
-
function startLogging(
|
|
9809
|
+
function startLogging(basename) {
|
|
9745
9810
|
if (patched)
|
|
9746
9811
|
return;
|
|
9747
9812
|
patched = true;
|
|
9748
|
-
if (
|
|
9749
|
-
logToFile(
|
|
9813
|
+
if (basename)
|
|
9814
|
+
logToFile(basename);
|
|
9750
9815
|
rotate();
|
|
9751
9816
|
const levels = [
|
|
9752
9817
|
["log", "info"],
|
|
@@ -9780,13 +9845,13 @@ function relevantChange(filename) {
|
|
|
9780
9845
|
if (filename === null)
|
|
9781
9846
|
return true;
|
|
9782
9847
|
const segments = filename.split("/");
|
|
9783
|
-
if (segments.slice(0, -1).some((
|
|
9848
|
+
if (segments.slice(0, -1).some((s) => s.startsWith(".")))
|
|
9784
9849
|
return false;
|
|
9785
9850
|
return /\.md(\.|$)/i.test(segments[segments.length - 1]);
|
|
9786
9851
|
}
|
|
9787
9852
|
var watchers = new Map;
|
|
9788
|
-
function syncWatchers(
|
|
9789
|
-
const want = new Set(
|
|
9853
|
+
function syncWatchers(roots, onChange) {
|
|
9854
|
+
const want = new Set(roots);
|
|
9790
9855
|
for (const [root, w] of watchers) {
|
|
9791
9856
|
if (want.has(root))
|
|
9792
9857
|
continue;
|
|
@@ -9846,14 +9911,14 @@ function bundledBun(execPath) {
|
|
|
9846
9911
|
return /(^|\/)bun$/.test(execPath) ? execPath : "";
|
|
9847
9912
|
}
|
|
9848
9913
|
function runnerFor(id, lang, code, interpreters, bunPath, remote = false) {
|
|
9849
|
-
const
|
|
9850
|
-
const interpreter = interpreters[
|
|
9914
|
+
const key = (lang ?? "").toLowerCase();
|
|
9915
|
+
const interpreter = interpreters[key];
|
|
9851
9916
|
if (!interpreter) {
|
|
9852
|
-
const
|
|
9853
|
-
const command = remote ? remoteWrite(code,
|
|
9854
|
-
return { kind: "shell", path
|
|
9917
|
+
const path = `/tmp/ledge-run-${id}.sh`;
|
|
9918
|
+
const command = remote ? remoteWrite(code, path, `source ${path}`) : `source ${path}`;
|
|
9919
|
+
return { kind: "shell", path, contents: code, command, remote };
|
|
9855
9920
|
}
|
|
9856
|
-
const ext = EXT[
|
|
9921
|
+
const ext = EXT[key] ?? (key.replace(/[^a-z0-9]/g, "") || "txt");
|
|
9857
9922
|
const path = `/tmp/ledge-run-${id}.${ext}`;
|
|
9858
9923
|
const contents = ext === "php" && !/^\s*<\?/.test(code) ? `<?php
|
|
9859
9924
|
${code}` : code;
|
|
@@ -9877,8 +9942,8 @@ function hostGlobMatches(pattern, host) {
|
|
|
9877
9942
|
const rx = pattern.split("*").map(escapeRegex).join(".*");
|
|
9878
9943
|
return new RegExp(`^${rx}$`).test(host);
|
|
9879
9944
|
}
|
|
9880
|
-
function escapeRegex(
|
|
9881
|
-
return
|
|
9945
|
+
function escapeRegex(s) {
|
|
9946
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9882
9947
|
}
|
|
9883
9948
|
|
|
9884
9949
|
// src/bun/remoteSpawn.ts
|
|
@@ -9899,11 +9964,11 @@ function buildRemoteSpawn(host, kind, params, warn) {
|
|
|
9899
9964
|
const parts = [];
|
|
9900
9965
|
if (params?.cwd)
|
|
9901
9966
|
parts.push(remoteCd(params.cwd));
|
|
9902
|
-
for (const [
|
|
9903
|
-
if (isEnvName(
|
|
9904
|
-
parts.push(`export ${
|
|
9967
|
+
for (const [key, value] of Object.entries(params?.env ?? {})) {
|
|
9968
|
+
if (isEnvName(key) && typeof value === "string") {
|
|
9969
|
+
parts.push(`export ${key}=${shellQuote(value)}`);
|
|
9905
9970
|
} else {
|
|
9906
|
-
warn(`ignoring unusable env entry "${
|
|
9971
|
+
warn(`ignoring unusable env entry "${key}"`);
|
|
9907
9972
|
}
|
|
9908
9973
|
}
|
|
9909
9974
|
if (kind === "inline") {
|
|
@@ -9995,7 +10060,10 @@ async function createServer(deps) {
|
|
|
9995
10060
|
const { push } = deps;
|
|
9996
10061
|
const settings = await loadSettings();
|
|
9997
10062
|
await loadWorkspaces();
|
|
9998
|
-
await ensureDefault();
|
|
10063
|
+
const first = await ensureDefault();
|
|
10064
|
+
if (first) {
|
|
10065
|
+
await createNote(first, WELCOME_DOC).catch((err) => console.warn("[server] could not write the welcome note", err));
|
|
10066
|
+
}
|
|
9999
10067
|
await syncDocs();
|
|
10000
10068
|
await loadVault();
|
|
10001
10069
|
const shellEnv = { ...process.env, TERM: "xterm-256color" };
|
|
@@ -10128,9 +10196,9 @@ async function createServer(deps) {
|
|
|
10128
10196
|
watch2(APP_HOME, (_event, filename) => {
|
|
10129
10197
|
if (filename !== requestName)
|
|
10130
10198
|
return;
|
|
10131
|
-
takeOpenRequest().then((
|
|
10132
|
-
if (
|
|
10133
|
-
push.all.openExternal(
|
|
10199
|
+
takeOpenRequest().then((open) => {
|
|
10200
|
+
if (open !== null)
|
|
10201
|
+
push.all.openExternal(open);
|
|
10134
10202
|
});
|
|
10135
10203
|
});
|
|
10136
10204
|
} catch (err) {
|
|
@@ -10493,9 +10561,9 @@ async function createServer(deps) {
|
|
|
10493
10561
|
return { ok: true };
|
|
10494
10562
|
},
|
|
10495
10563
|
openRequestTake: async () => {
|
|
10496
|
-
const
|
|
10564
|
+
const open = await takeOpenRequest();
|
|
10497
10565
|
startOpenRequestWatcher();
|
|
10498
|
-
return { open
|
|
10566
|
+
return { open };
|
|
10499
10567
|
},
|
|
10500
10568
|
logAppend: async ({ level, text }) => {
|
|
10501
10569
|
write("view", level, [text.slice(0, LOG_TEXT_CAP)]);
|
|
@@ -10776,10 +10844,10 @@ function restoreBinary(payload, path, bytes) {
|
|
|
10776
10844
|
}
|
|
10777
10845
|
function walk(payload, path) {
|
|
10778
10846
|
let at = payload;
|
|
10779
|
-
for (const
|
|
10847
|
+
for (const key of path) {
|
|
10780
10848
|
if (typeof at !== "object" || at === null)
|
|
10781
10849
|
return null;
|
|
10782
|
-
at = at[
|
|
10850
|
+
at = at[key];
|
|
10783
10851
|
}
|
|
10784
10852
|
return { value: at };
|
|
10785
10853
|
}
|
|
@@ -10954,8 +11022,8 @@ function names(v) {
|
|
|
10954
11022
|
return v.filter((n) => typeof n === "string").slice(0, MAX_DECLARED_NAMES);
|
|
10955
11023
|
}
|
|
10956
11024
|
var MAX_DECLARED_NAMES = 512;
|
|
10957
|
-
function opt(
|
|
10958
|
-
return typeof v === "string" ? { [
|
|
11025
|
+
function opt(key, v) {
|
|
11026
|
+
return typeof v === "string" ? { [key]: v } : {};
|
|
10959
11027
|
}
|
|
10960
11028
|
function bin(v) {
|
|
10961
11029
|
return isId(v) ? { bin: v } : {};
|
|
@@ -11002,15 +11070,15 @@ function concat2(a, b) {
|
|
|
11002
11070
|
out.set(b, a.length);
|
|
11003
11071
|
return out;
|
|
11004
11072
|
}
|
|
11005
|
-
function writeMessage(
|
|
11073
|
+
function writeMessage(write, msg, kind, method) {
|
|
11006
11074
|
const path = binaryPath(kind, method);
|
|
11007
11075
|
const body = msg.t === "req" ? msg.p : msg.t === "res" ? msg.r : msg.t === "push" ? msg.p : null;
|
|
11008
11076
|
const hoisted = path && body !== null ? hoistBinary(body, path) : null;
|
|
11009
11077
|
if (!hoisted)
|
|
11010
|
-
return
|
|
11011
|
-
const
|
|
11012
|
-
|
|
11013
|
-
|
|
11078
|
+
return write(encodeControl(msg));
|
|
11079
|
+
const bin = nextBinaryId();
|
|
11080
|
+
write(encodeBinary(bin, hoisted.bytes));
|
|
11081
|
+
write(encodeControl(msg.t === "req" ? { ...msg, p: hoisted.payload, bin } : msg.t === "res" ? { ...msg, r: hoisted.payload, bin } : { ...msg, p: hoisted.payload, bin }));
|
|
11014
11082
|
}
|
|
11015
11083
|
var binaryId = 0;
|
|
11016
11084
|
function nextBinaryId() {
|
|
@@ -11020,19 +11088,19 @@ function nextBinaryId() {
|
|
|
11020
11088
|
|
|
11021
11089
|
class BinaryHolder {
|
|
11022
11090
|
held = null;
|
|
11023
|
-
hold(
|
|
11091
|
+
hold(frame) {
|
|
11024
11092
|
if (this.held)
|
|
11025
11093
|
throw new WireError("the peer sent two binary frames with no control frame between them");
|
|
11026
|
-
this.held = { id:
|
|
11094
|
+
this.held = { id: frame.id, bytes: frame.bytes };
|
|
11027
11095
|
}
|
|
11028
11096
|
claim(msg, kind, method) {
|
|
11029
|
-
const
|
|
11097
|
+
const bin = msg.t === "req" || msg.t === "res" || msg.t === "push" ? msg.bin : undefined;
|
|
11030
11098
|
const body = msg.t === "req" ? msg.p : msg.t === "res" ? msg.r : msg.t === "push" ? msg.p : null;
|
|
11031
|
-
if (
|
|
11099
|
+
if (bin === undefined)
|
|
11032
11100
|
return body;
|
|
11033
11101
|
const held = this.held;
|
|
11034
11102
|
this.held = null;
|
|
11035
|
-
if (!held || held.id !==
|
|
11103
|
+
if (!held || held.id !== bin)
|
|
11036
11104
|
throw new WireError("the peer claimed a binary frame that did not arrive");
|
|
11037
11105
|
const path = binaryPath(kind, method);
|
|
11038
11106
|
if (!path)
|
|
@@ -11114,7 +11182,7 @@ function serverConnection(duplex, opts) {
|
|
|
11114
11182
|
const silentMs = opts.silentMs ?? SILENT_MS2;
|
|
11115
11183
|
let heardFromClient = false;
|
|
11116
11184
|
let stopWatching = null;
|
|
11117
|
-
const
|
|
11185
|
+
const decoder = new FrameDecoder;
|
|
11118
11186
|
const incoming = new BinaryHolder;
|
|
11119
11187
|
let handlers = null;
|
|
11120
11188
|
let greeted = false;
|
|
@@ -11122,10 +11190,10 @@ function serverConnection(duplex, opts) {
|
|
|
11122
11190
|
let peerDevice = "";
|
|
11123
11191
|
let peerLabel = "";
|
|
11124
11192
|
let peerHold = 0;
|
|
11125
|
-
let
|
|
11193
|
+
let open = true;
|
|
11126
11194
|
const waiting = [];
|
|
11127
11195
|
let settle;
|
|
11128
|
-
const closed = new Promise((
|
|
11196
|
+
const closed = new Promise((resolve) => settle = resolve);
|
|
11129
11197
|
function raw(bytes) {
|
|
11130
11198
|
try {
|
|
11131
11199
|
duplex.write(bytes);
|
|
@@ -11135,7 +11203,7 @@ function serverConnection(duplex, opts) {
|
|
|
11135
11203
|
}
|
|
11136
11204
|
}
|
|
11137
11205
|
function send(msg, method = "") {
|
|
11138
|
-
if (!
|
|
11206
|
+
if (!open)
|
|
11139
11207
|
return;
|
|
11140
11208
|
if (msg.t !== "push" || msg.m !== "terminalOutput")
|
|
11141
11209
|
flushOutput();
|
|
@@ -11145,11 +11213,11 @@ function serverConnection(duplex, opts) {
|
|
|
11145
11213
|
raw(encodeControl(msg));
|
|
11146
11214
|
}
|
|
11147
11215
|
function close(why, back = false) {
|
|
11148
|
-
if (!
|
|
11216
|
+
if (!open)
|
|
11149
11217
|
return;
|
|
11150
11218
|
if (why !== undefined)
|
|
11151
11219
|
send({ t: "bye", why, ...back ? { back: true } : {} });
|
|
11152
|
-
|
|
11220
|
+
open = false;
|
|
11153
11221
|
stopWatching?.();
|
|
11154
11222
|
stopWatching = null;
|
|
11155
11223
|
stopCoalescing();
|
|
@@ -11188,7 +11256,7 @@ function serverConnection(duplex, opts) {
|
|
|
11188
11256
|
heldBytes = 0;
|
|
11189
11257
|
}
|
|
11190
11258
|
function pushOutput(p) {
|
|
11191
|
-
if (!
|
|
11259
|
+
if (!open)
|
|
11192
11260
|
return;
|
|
11193
11261
|
const bytes = fromBase64(p.dataB64);
|
|
11194
11262
|
if (bytes.length === 0)
|
|
@@ -11256,18 +11324,18 @@ function serverConnection(duplex, opts) {
|
|
|
11256
11324
|
heardFromClient = true;
|
|
11257
11325
|
let frames;
|
|
11258
11326
|
try {
|
|
11259
|
-
frames =
|
|
11327
|
+
frames = decoder.push(chunk);
|
|
11260
11328
|
} catch (err) {
|
|
11261
11329
|
console.error("[wire]", err instanceof Error ? err.message : err);
|
|
11262
11330
|
return close(err instanceof WireError ? err.message : "unreadable frame");
|
|
11263
11331
|
}
|
|
11264
|
-
for (const
|
|
11332
|
+
for (const frame of frames) {
|
|
11265
11333
|
try {
|
|
11266
|
-
if (
|
|
11267
|
-
incoming.hold(
|
|
11334
|
+
if (frame.type === 1) {
|
|
11335
|
+
incoming.hold(frame);
|
|
11268
11336
|
continue;
|
|
11269
11337
|
}
|
|
11270
|
-
handle(parseControl(
|
|
11338
|
+
handle(parseControl(frame.text));
|
|
11271
11339
|
if (!incoming.idle())
|
|
11272
11340
|
throw new WireError("the peer sent bytes that no control frame claimed");
|
|
11273
11341
|
} catch (err) {
|
|
@@ -11277,7 +11345,7 @@ function serverConnection(duplex, opts) {
|
|
|
11277
11345
|
}
|
|
11278
11346
|
};
|
|
11279
11347
|
duplex.onClose = () => {
|
|
11280
|
-
|
|
11348
|
+
open = false;
|
|
11281
11349
|
stopWatching?.();
|
|
11282
11350
|
stopWatching = null;
|
|
11283
11351
|
stopCoalescing();
|
|
@@ -11448,13 +11516,13 @@ function createOpLog(opts) {
|
|
|
11448
11516
|
}
|
|
11449
11517
|
}
|
|
11450
11518
|
return {
|
|
11451
|
-
run(
|
|
11452
|
-
const hit = seen.get(
|
|
11519
|
+
run(key, exec) {
|
|
11520
|
+
const hit = seen.get(key);
|
|
11453
11521
|
if (hit)
|
|
11454
11522
|
return hit.result;
|
|
11455
11523
|
const result = exec();
|
|
11456
11524
|
result.catch(() => {});
|
|
11457
|
-
seen.set(
|
|
11525
|
+
seen.set(key, { at: now(), result });
|
|
11458
11526
|
evict();
|
|
11459
11527
|
return result;
|
|
11460
11528
|
},
|
|
@@ -11463,7 +11531,7 @@ function createOpLog(opts) {
|
|
|
11463
11531
|
}
|
|
11464
11532
|
|
|
11465
11533
|
// src/shared/version.ts
|
|
11466
|
-
var BUILD_VERSION = "0.0
|
|
11534
|
+
var BUILD_VERSION = "0.1.0";
|
|
11467
11535
|
|
|
11468
11536
|
// src/bun/daemon.ts
|
|
11469
11537
|
var SOCKET_PATH = join16(APP_HOME, ".server.sock");
|
|
@@ -11494,10 +11562,10 @@ async function startDaemon(opts = {}) {
|
|
|
11494
11562
|
const ops = createOpLog();
|
|
11495
11563
|
const instance = crypto.randomUUID();
|
|
11496
11564
|
const server = await createServer({ push });
|
|
11497
|
-
let
|
|
11565
|
+
let idleTimer = null;
|
|
11498
11566
|
let heldUntil = 0;
|
|
11499
11567
|
let settleDone;
|
|
11500
|
-
const done = new Promise((
|
|
11568
|
+
const done = new Promise((resolve) => settleDone = resolve);
|
|
11501
11569
|
let stopped = false;
|
|
11502
11570
|
const listener = Bun.listen({
|
|
11503
11571
|
unix: socketPath,
|
|
@@ -11542,9 +11610,9 @@ async function startDaemon(opts = {}) {
|
|
|
11542
11610
|
console.error("[daemon] could not write the pid file:", err);
|
|
11543
11611
|
}
|
|
11544
11612
|
function accept(io) {
|
|
11545
|
-
if (
|
|
11546
|
-
clearTimeout(
|
|
11547
|
-
|
|
11613
|
+
if (idleTimer) {
|
|
11614
|
+
clearTimeout(idleTimer);
|
|
11615
|
+
idleTimer = null;
|
|
11548
11616
|
}
|
|
11549
11617
|
const greet = () => {
|
|
11550
11618
|
const id = conn.client();
|
|
@@ -11574,15 +11642,15 @@ async function startDaemon(opts = {}) {
|
|
|
11574
11642
|
}
|
|
11575
11643
|
let leaving = false;
|
|
11576
11644
|
function armIdleExit() {
|
|
11577
|
-
if (stopped ||
|
|
11645
|
+
if (stopped || idleTimer || leaving || idleMs <= 0)
|
|
11578
11646
|
return;
|
|
11579
11647
|
const held = server.sessionsOpen() ? heldUntil - Date.now() : 0;
|
|
11580
11648
|
const wait = Math.max(idleMs, held);
|
|
11581
11649
|
if (wait !== idleMs) {
|
|
11582
11650
|
console.error(`[daemon] holding sessions for ${wait >= 1e4 ? `${Math.round(wait / 1000)}s` : `${wait}ms`}`);
|
|
11583
11651
|
}
|
|
11584
|
-
|
|
11585
|
-
|
|
11652
|
+
idleTimer = setTimeout(async () => {
|
|
11653
|
+
idleTimer = null;
|
|
11586
11654
|
if (clients.size > 0)
|
|
11587
11655
|
return;
|
|
11588
11656
|
if (server.running())
|
|
@@ -11626,8 +11694,8 @@ async function startDaemon(opts = {}) {
|
|
|
11626
11694
|
if (stopped)
|
|
11627
11695
|
return;
|
|
11628
11696
|
stopped = true;
|
|
11629
|
-
if (
|
|
11630
|
-
clearTimeout(
|
|
11697
|
+
if (idleTimer)
|
|
11698
|
+
clearTimeout(idleTimer);
|
|
11631
11699
|
for (const conn of accepted) {
|
|
11632
11700
|
if (clients.get(conn.client()) === conn)
|
|
11633
11701
|
conn.close("this server is shutting down", true);
|
|
@@ -11685,9 +11753,9 @@ async function tryConnect(socketPath) {
|
|
|
11685
11753
|
socket: {
|
|
11686
11754
|
data: (_s, chunk) => io?.feed(new Uint8Array(chunk)),
|
|
11687
11755
|
drain: () => out?.drain(),
|
|
11688
|
-
end: (
|
|
11756
|
+
end: (s) => {
|
|
11689
11757
|
io?.finish();
|
|
11690
|
-
|
|
11758
|
+
s.end();
|
|
11691
11759
|
},
|
|
11692
11760
|
close: () => io?.finish(),
|
|
11693
11761
|
error: () => io?.finish()
|
|
@@ -11734,7 +11802,7 @@ var reader = null;
|
|
|
11734
11802
|
var pending = "";
|
|
11735
11803
|
async function readLine() {
|
|
11736
11804
|
reader ??= Bun.stdin.stream().getReader();
|
|
11737
|
-
const
|
|
11805
|
+
const decoder = new TextDecoder;
|
|
11738
11806
|
for (;; ) {
|
|
11739
11807
|
const nl = pending.indexOf(`
|
|
11740
11808
|
`);
|
|
@@ -11749,7 +11817,7 @@ async function readLine() {
|
|
|
11749
11817
|
pending = "";
|
|
11750
11818
|
return line;
|
|
11751
11819
|
}
|
|
11752
|
-
pending +=
|
|
11820
|
+
pending += decoder.decode(value, { stream: true });
|
|
11753
11821
|
}
|
|
11754
11822
|
}
|
|
11755
11823
|
async function readHidden() {
|
|
@@ -11791,9 +11859,9 @@ async function ask(question, o = {}) {
|
|
|
11791
11859
|
// src/bun/backup.ts
|
|
11792
11860
|
import { join as join17 } from "path";
|
|
11793
11861
|
function backupSet(input) {
|
|
11794
|
-
const { appHome, profilesDir, roots
|
|
11862
|
+
const { appHome, profilesDir, roots, secrets } = input;
|
|
11795
11863
|
const include = [appHome];
|
|
11796
|
-
for (const root of
|
|
11864
|
+
for (const root of roots)
|
|
11797
11865
|
if (!isInside(appHome, root))
|
|
11798
11866
|
include.push(root);
|
|
11799
11867
|
if (secrets && !include.some((p) => isInside(p, profilesDir)))
|
|
@@ -11832,9 +11900,9 @@ function backupProfileText(vars) {
|
|
|
11832
11900
|
`# RESTIC_PASSWORD is the only key to the backup. Keep a copy somewhere else.`,
|
|
11833
11901
|
``
|
|
11834
11902
|
];
|
|
11835
|
-
for (const [
|
|
11903
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
11836
11904
|
const quoted = value !== value.trim() || /^["']/.test(value) ? `"${value.replace(/["\\]/g, "\\$&")}"` : value;
|
|
11837
|
-
lines.push(`${
|
|
11905
|
+
lines.push(`${key}=${quoted}`);
|
|
11838
11906
|
}
|
|
11839
11907
|
lines.push(``);
|
|
11840
11908
|
return lines.join(`
|
|
@@ -11893,7 +11961,7 @@ function parseState(text) {
|
|
|
11893
11961
|
return {
|
|
11894
11962
|
...EMPTY_STATE,
|
|
11895
11963
|
...raw,
|
|
11896
|
-
skipped: Array.isArray(raw.skipped) ? raw.skipped.filter((
|
|
11964
|
+
skipped: Array.isArray(raw.skipped) ? raw.skipped.filter((s) => typeof s?.root === "string" && typeof s?.since === "string") : []
|
|
11897
11965
|
};
|
|
11898
11966
|
} catch {
|
|
11899
11967
|
return EMPTY_STATE;
|
|
@@ -11901,7 +11969,7 @@ function parseState(text) {
|
|
|
11901
11969
|
}
|
|
11902
11970
|
function recordRun(prev, o) {
|
|
11903
11971
|
const at = o.at.toISOString();
|
|
11904
|
-
const since = new Map(prev.skipped.map((
|
|
11972
|
+
const since = new Map(prev.skipped.map((s) => [s.root, s.since]));
|
|
11905
11973
|
return {
|
|
11906
11974
|
version: 1,
|
|
11907
11975
|
lastRun: at,
|
|
@@ -12008,16 +12076,16 @@ function parseBackupOutput(stdout) {
|
|
|
12008
12076
|
function parseSnapshots(stdout) {
|
|
12009
12077
|
try {
|
|
12010
12078
|
const raw = JSON.parse(stdout);
|
|
12011
|
-
return raw.filter((
|
|
12079
|
+
return raw.filter((s) => typeof s.short_id === "string" && typeof s.time === "string").map((s) => ({ short_id: s.short_id, time: s.time, hostname: s.hostname ?? "", paths: s.paths ?? [] }));
|
|
12012
12080
|
} catch {
|
|
12013
12081
|
return [];
|
|
12014
12082
|
}
|
|
12015
12083
|
}
|
|
12016
|
-
function statusLines(
|
|
12084
|
+
function statusLines(s) {
|
|
12017
12085
|
const lines = [];
|
|
12018
|
-
lines.push(`repository ${
|
|
12019
|
-
lines.push(`restic ${"path" in
|
|
12020
|
-
const { state, now } =
|
|
12086
|
+
lines.push(`repository ${s.repository ?? "none: run `ledge backup setup`"}`);
|
|
12087
|
+
lines.push(`restic ${"path" in s.restic ? `${s.restic.version} at ${s.restic.path}` : s.restic.missing}`);
|
|
12088
|
+
const { state, now } = s;
|
|
12021
12089
|
if (!state.lastRun)
|
|
12022
12090
|
lines.push(`last backup never`);
|
|
12023
12091
|
else if (state.lastOk === state.lastRun)
|
|
@@ -12026,17 +12094,17 @@ function statusLines(s2) {
|
|
|
12026
12094
|
lines.push(`last backup ${ago(state.lastRun, now)}, FAILED: ${state.lastError ?? "unknown"}`);
|
|
12027
12095
|
lines.push(`last good ${state.lastOk ? ago(state.lastOk, now) : "never"}`);
|
|
12028
12096
|
}
|
|
12029
|
-
if (
|
|
12030
|
-
lines.push(
|
|
12097
|
+
if (s.repository) {
|
|
12098
|
+
lines.push(s.daemonUp ? `next backup ${nextDue(state, now).getTime() <= now.getTime() ? "due now" : `in ${duration(nextDue(state, now).getTime() - now.getTime())}`} (the server is running)` : `next backup when the server next runs, and hourly while it does`);
|
|
12031
12099
|
}
|
|
12032
12100
|
for (const k of state.skipped)
|
|
12033
12101
|
lines.push(`SKIPPED ${k.root} (not on disk since ${ago(k.since, now)})`);
|
|
12034
12102
|
return lines;
|
|
12035
12103
|
}
|
|
12036
|
-
function ago(
|
|
12037
|
-
const ms = now.getTime() - Date.parse(
|
|
12104
|
+
function ago(iso, now) {
|
|
12105
|
+
const ms = now.getTime() - Date.parse(iso);
|
|
12038
12106
|
if (!Number.isFinite(ms))
|
|
12039
|
-
return
|
|
12107
|
+
return iso;
|
|
12040
12108
|
if (ms < 60000)
|
|
12041
12109
|
return "just now";
|
|
12042
12110
|
return `${duration(ms)} ago`;
|
|
@@ -12086,16 +12154,16 @@ function fetchedResticPath(version = RESTIC_VERSION) {
|
|
|
12086
12154
|
async function findRestic(opts = {}) {
|
|
12087
12155
|
const onPath = Bun.which("restic", { PATH: process.env["PATH"] ?? "" });
|
|
12088
12156
|
if (onPath) {
|
|
12089
|
-
const
|
|
12090
|
-
if (
|
|
12091
|
-
return { path: onPath, version
|
|
12092
|
-
opts.log?.(`[backup] ${onPath} is restic ${
|
|
12157
|
+
const version = await versionOf(onPath);
|
|
12158
|
+
if (version && versionAtLeast(version, RESTIC_MIN_VERSION))
|
|
12159
|
+
return { path: onPath, version };
|
|
12160
|
+
opts.log?.(`[backup] ${onPath} is restic ${version ?? "of an unknown version"}; ${RESTIC_MIN_VERSION} or newer is needed`);
|
|
12093
12161
|
}
|
|
12094
12162
|
const fetched = fetchedResticPath();
|
|
12095
12163
|
if (existsSync2(fetched)) {
|
|
12096
|
-
const
|
|
12097
|
-
if (
|
|
12098
|
-
return { path: fetched, version
|
|
12164
|
+
const version = await versionOf(fetched);
|
|
12165
|
+
if (version)
|
|
12166
|
+
return { path: fetched, version };
|
|
12099
12167
|
}
|
|
12100
12168
|
if (!opts.fetch) {
|
|
12101
12169
|
return { missing: onPath ? `restic on the PATH is too old and none has been fetched: run \`ledge backup setup\`` : "restic is not installed: run `ledge backup setup`" };
|
|
@@ -12442,14 +12510,14 @@ async function paths(args) {
|
|
|
12442
12510
|
async function setup(args) {
|
|
12443
12511
|
const existing = args.includes("--existing");
|
|
12444
12512
|
const fromEnv = args.includes("--from-env");
|
|
12445
|
-
const
|
|
12513
|
+
const replace = args.includes("--replace");
|
|
12446
12514
|
const repoFlag = valueOf(args, "--repository");
|
|
12447
12515
|
if (!fromEnv && !process.stdin.isTTY) {
|
|
12448
12516
|
say("ledge backup setup asks questions, and stdin is not a terminal. Pass --from-env with the variables set instead.");
|
|
12449
12517
|
return 2;
|
|
12450
12518
|
}
|
|
12451
12519
|
const already = configured();
|
|
12452
|
-
if (already && !
|
|
12520
|
+
if (already && !replace) {
|
|
12453
12521
|
say(`Backups are already set up here, to ${already.repository}. \`ledge backup status\` shows how they are going; \`ledge backup setup --replace\` starts over.`);
|
|
12454
12522
|
return 1;
|
|
12455
12523
|
}
|
|
@@ -12564,10 +12632,11 @@ That repository already has backups in it. Run setup again with --existing and i
|
|
|
12564
12632
|
say(`The repository and its credentials are in ${PROFILE_PATH}, the "${BACKUP_PROFILE}" profile.`);
|
|
12565
12633
|
if (generated) {
|
|
12566
12634
|
say("");
|
|
12567
|
-
|
|
12568
|
-
say("a restore starts on a machine with nothing on it, and a password stored only here is a backup you cannot open.");
|
|
12569
|
-
say("");
|
|
12635
|
+
process.stderr.write("SAVE THIS PASSWORD: ");
|
|
12570
12636
|
out(generated);
|
|
12637
|
+
say("");
|
|
12638
|
+
say("It is the only key to the backup, and nothing can be restored without it.");
|
|
12639
|
+
say("Keep a copy somewhere that is not this machine, such as a password manager.");
|
|
12571
12640
|
}
|
|
12572
12641
|
return 0;
|
|
12573
12642
|
}
|
|
@@ -12607,8 +12676,8 @@ async function snapshots() {
|
|
|
12607
12676
|
say("no snapshots yet");
|
|
12608
12677
|
return 1;
|
|
12609
12678
|
}
|
|
12610
|
-
for (const
|
|
12611
|
-
out(`${
|
|
12679
|
+
for (const s of r.snapshots)
|
|
12680
|
+
out(`${s.short_id} ${snapshotTime(s.time)} ${s.hostname} ${s.paths.length} path${s.paths.length === 1 ? "" : "s"}`);
|
|
12612
12681
|
return 0;
|
|
12613
12682
|
}
|
|
12614
12683
|
function snapshotTime(time) {
|
|
@@ -13112,14 +13181,14 @@ function getTotalBits(segs, version) {
|
|
|
13112
13181
|
}
|
|
13113
13182
|
return result;
|
|
13114
13183
|
}
|
|
13115
|
-
function toUtf8ByteArray(
|
|
13116
|
-
|
|
13184
|
+
function toUtf8ByteArray(str) {
|
|
13185
|
+
str = encodeURI(str);
|
|
13117
13186
|
const result = [];
|
|
13118
|
-
for (let i = 0;i <
|
|
13119
|
-
if (
|
|
13120
|
-
result.push(
|
|
13187
|
+
for (let i = 0;i < str.length; i++) {
|
|
13188
|
+
if (str.charAt(i) !== "%") {
|
|
13189
|
+
result.push(str.charCodeAt(i));
|
|
13121
13190
|
} else {
|
|
13122
|
-
result.push(Number.parseInt(
|
|
13191
|
+
result.push(Number.parseInt(str.substring(i + 1, i + 3), 16));
|
|
13123
13192
|
i += 2;
|
|
13124
13193
|
}
|
|
13125
13194
|
}
|
|
@@ -13339,7 +13408,7 @@ var KEYGEN_PATH = "/usr/bin/ssh-keygen";
|
|
|
13339
13408
|
var HOST_KEY_DIR = "/etc/ssh";
|
|
13340
13409
|
var FLAGS = ["user", "host", "port", "keys"];
|
|
13341
13410
|
function parsePairArgs(args) {
|
|
13342
|
-
const
|
|
13411
|
+
const out = {};
|
|
13343
13412
|
for (let i = 0;i < args.length; i++) {
|
|
13344
13413
|
const arg = args[i];
|
|
13345
13414
|
const eq = arg.indexOf("=");
|
|
@@ -13350,9 +13419,9 @@ function parsePairArgs(args) {
|
|
|
13350
13419
|
const value = eq < 0 ? args[++i] : arg.slice(eq + 1);
|
|
13351
13420
|
if (value === undefined || value === "")
|
|
13352
13421
|
return { error: `--${flag} needs a value.` };
|
|
13353
|
-
|
|
13422
|
+
out[flag] = value;
|
|
13354
13423
|
}
|
|
13355
|
-
return
|
|
13424
|
+
return out;
|
|
13356
13425
|
}
|
|
13357
13426
|
function sshServerAddress(sshConnection) {
|
|
13358
13427
|
const parts = (sshConnection ?? "").trim().split(/\s+/);
|
|
@@ -13402,14 +13471,14 @@ var NOTES = {
|
|
|
13402
13471
|
name: "this machine's name"
|
|
13403
13472
|
};
|
|
13404
13473
|
function addressCandidates(inputs) {
|
|
13405
|
-
const
|
|
13474
|
+
const out = [];
|
|
13406
13475
|
const seen = new Set;
|
|
13407
13476
|
const add = (host, source, note) => {
|
|
13408
|
-
const
|
|
13409
|
-
if (host === "" || seen.has(
|
|
13477
|
+
const key = host.toLowerCase();
|
|
13478
|
+
if (host === "" || seen.has(key))
|
|
13410
13479
|
return;
|
|
13411
|
-
seen.add(
|
|
13412
|
-
|
|
13480
|
+
seen.add(key);
|
|
13481
|
+
out.push({ host, source, note });
|
|
13413
13482
|
};
|
|
13414
13483
|
if (inputs.tailnet?.name)
|
|
13415
13484
|
add(inputs.tailnet.name, "tailnet", NOTES.tailnetName);
|
|
@@ -13437,18 +13506,18 @@ function addressCandidates(inputs) {
|
|
|
13437
13506
|
if (addressKind(i.address) === "private")
|
|
13438
13507
|
add(i.address, "interface", NOTES.private(i.name));
|
|
13439
13508
|
add(inputs.hostname, "name", NOTES.name);
|
|
13440
|
-
return
|
|
13509
|
+
return out;
|
|
13441
13510
|
}
|
|
13442
13511
|
function tailscaleSelf(json) {
|
|
13443
|
-
let
|
|
13512
|
+
let status;
|
|
13444
13513
|
try {
|
|
13445
|
-
|
|
13514
|
+
status = JSON.parse(json);
|
|
13446
13515
|
} catch {
|
|
13447
13516
|
return null;
|
|
13448
13517
|
}
|
|
13449
|
-
if (typeof
|
|
13518
|
+
if (typeof status !== "object" || status === null)
|
|
13450
13519
|
return null;
|
|
13451
|
-
const { BackendState, Self } =
|
|
13520
|
+
const { BackendState, Self } = status;
|
|
13452
13521
|
if (BackendState !== "Running" || typeof Self !== "object" || Self === null)
|
|
13453
13522
|
return null;
|
|
13454
13523
|
const name = typeof Self.DNSName === "string" ? Self.DNSName.replace(/\.$/, "") : "";
|
|
@@ -13598,14 +13667,14 @@ function othersNote(others) {
|
|
|
13598
13667
|
function pairReport({ code, keys, note, columns }) {
|
|
13599
13668
|
const link = pairingLink(code);
|
|
13600
13669
|
const width = terminalQRWidth(link);
|
|
13601
|
-
const
|
|
13670
|
+
const out = [];
|
|
13602
13671
|
if (columns !== undefined && columns < width) {
|
|
13603
|
-
|
|
13672
|
+
out.push(`This terminal is ${columns} columns wide, and the code needs ${width}. Widen it and run pair again.`);
|
|
13604
13673
|
} else {
|
|
13605
|
-
|
|
13674
|
+
out.push(...terminalQR(link));
|
|
13606
13675
|
}
|
|
13607
|
-
|
|
13608
|
-
return `${
|
|
13676
|
+
out.push("", "Scan the code with Ledge on your phone, or paste the link below into the Mac app's Add Server form. It names this server and its host keys, and holds no password or key.", "", ` Account ${code.user}`, ` Host ${code.host}${note === "" ? "" : ` (${note})`}`, ` Port ${code.port === PORT_UNSET ? DEFAULT_PORT : code.port}`, ...keys.map((k, i) => ` ${i === 0 ? "Host keys" : " "} ${k.fingerprint} (${k.keyType})`), "", link);
|
|
13677
|
+
return `${out.join(`
|
|
13609
13678
|
`)}
|
|
13610
13679
|
`;
|
|
13611
13680
|
}
|
|
@@ -13623,7 +13692,7 @@ async function serve2() {
|
|
|
13623
13692
|
const upstream = await connectToDaemon();
|
|
13624
13693
|
const mine = stdioDuplex();
|
|
13625
13694
|
let over;
|
|
13626
|
-
const done = new Promise((
|
|
13695
|
+
const done = new Promise((resolve) => over = resolve);
|
|
13627
13696
|
let ended = false;
|
|
13628
13697
|
const end = () => {
|
|
13629
13698
|
if (ended)
|
|
@@ -13640,7 +13709,7 @@ async function serve2() {
|
|
|
13640
13709
|
mine.onClose = end;
|
|
13641
13710
|
console.error(`[serve] ledge ${BUILD_VERSION} attached to ${SOCKET_PATH}`);
|
|
13642
13711
|
await done;
|
|
13643
|
-
await new Promise((
|
|
13712
|
+
await new Promise((resolve) => process.stdout.write("", () => resolve()));
|
|
13644
13713
|
}
|
|
13645
13714
|
async function daemon(autostart = false) {
|
|
13646
13715
|
const idleMs = autostart ? IDLE_EXIT_MS : IDLE_EXIT_NEVER;
|
|
@@ -13656,9 +13725,9 @@ async function daemon(autostart = false) {
|
|
|
13656
13725
|
backups.stop();
|
|
13657
13726
|
}
|
|
13658
13727
|
async function pair(argv) {
|
|
13659
|
-
const
|
|
13728
|
+
const fail = (message, status = 1) => {
|
|
13660
13729
|
console.error(message);
|
|
13661
|
-
return
|
|
13730
|
+
return status;
|
|
13662
13731
|
};
|
|
13663
13732
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
13664
13733
|
process.stdout.write(`${PAIR_USAGE}
|
|
@@ -13667,12 +13736,12 @@ async function pair(argv) {
|
|
|
13667
13736
|
}
|
|
13668
13737
|
const args = parsePairArgs(argv.slice(3));
|
|
13669
13738
|
if ("error" in args)
|
|
13670
|
-
return
|
|
13739
|
+
return fail(`${args.error}
|
|
13671
13740
|
${PAIR_USAGE}`, 2);
|
|
13672
13741
|
if (existsSync3("/.dockerenv") || existsSync3("/run/.containerenv")) {
|
|
13673
13742
|
const refusal = containerRefusal(args);
|
|
13674
13743
|
if (refusal)
|
|
13675
|
-
return
|
|
13744
|
+
return fail(refusal);
|
|
13676
13745
|
}
|
|
13677
13746
|
const candidates = args.host === undefined ? await gatherCandidates() : [];
|
|
13678
13747
|
const interactive = args.host === undefined && args.keys !== "-" && process.stdin.isTTY && process.stdout.isTTY;
|
|
@@ -13683,7 +13752,7 @@ ${PAIR_USAGE}`, 2);
|
|
|
13683
13752
|
}
|
|
13684
13753
|
const address = pairAddress(args, process.env.SSH_CONNECTION, candidates, answer);
|
|
13685
13754
|
if ("error" in address)
|
|
13686
|
-
return
|
|
13755
|
+
return fail(address.error, 2);
|
|
13687
13756
|
if (!interactive)
|
|
13688
13757
|
process.stderr.write(othersNote(candidates.filter((c) => c.host !== address.host)));
|
|
13689
13758
|
let keyText = "";
|
|
@@ -13693,7 +13762,7 @@ ${PAIR_USAGE}`, 2);
|
|
|
13693
13762
|
try {
|
|
13694
13763
|
keyText = readFileSync4(args.keys, "utf8");
|
|
13695
13764
|
} catch {
|
|
13696
|
-
return
|
|
13765
|
+
return fail(`Could not read ${args.keys}.`);
|
|
13697
13766
|
}
|
|
13698
13767
|
} else {
|
|
13699
13768
|
const files = existsSync3(HOST_KEY_DIR) ? readdirSync(HOST_KEY_DIR).filter((f) => /^ssh_host_\w+_key\.pub$/.test(f)) : [];
|
|
@@ -13704,7 +13773,7 @@ ${PAIR_USAGE}`, 2);
|
|
|
13704
13773
|
} catch {}
|
|
13705
13774
|
}
|
|
13706
13775
|
if (keyText.trim() === "") {
|
|
13707
|
-
return
|
|
13776
|
+
return fail(`There are no sshd host keys in ${HOST_KEY_DIR}. If sshd keeps them elsewhere, pass the .pub file with --keys.`);
|
|
13708
13777
|
}
|
|
13709
13778
|
}
|
|
13710
13779
|
let described;
|
|
@@ -13715,18 +13784,18 @@ ${PAIR_USAGE}`, 2);
|
|
|
13715
13784
|
described = await new Response(p.stdout).text();
|
|
13716
13785
|
await p.exited;
|
|
13717
13786
|
} catch (err) {
|
|
13718
|
-
return
|
|
13787
|
+
return fail(`Could not run ssh-keygen (${err instanceof Error ? err.message : String(err)}).`);
|
|
13719
13788
|
}
|
|
13720
13789
|
const keys = phoneHostKeys(described);
|
|
13721
13790
|
if (keys.length === 0) {
|
|
13722
13791
|
if (!described.includes("SHA256:"))
|
|
13723
|
-
return
|
|
13724
|
-
return
|
|
13792
|
+
return fail(`${args.keys === "-" ? "stdin" : args.keys ?? HOST_KEY_DIR} holds no public host keys.`);
|
|
13793
|
+
return fail(`None of these host keys is Ed25519 or ECDSA, and those are the kinds Ledge on a phone can check.
|
|
13725
13794
|
` + "`sudo ssh-keygen -A` creates the missing default keys. Restart sshd after it.");
|
|
13726
13795
|
}
|
|
13727
13796
|
const code = pairCode(args.user ?? userInfo().username, address, keys);
|
|
13728
13797
|
if ("error" in code)
|
|
13729
|
-
return
|
|
13798
|
+
return fail(code.error);
|
|
13730
13799
|
const columns = process.stdout.isTTY ? process.stdout.columns : undefined;
|
|
13731
13800
|
process.stdout.write(pairReport({ code, keys, note: address.note, columns }));
|
|
13732
13801
|
return 0;
|
|
@@ -13743,13 +13812,13 @@ async function gatherCandidates() {
|
|
|
13743
13812
|
}
|
|
13744
13813
|
var LOOKUP_MS = 1500;
|
|
13745
13814
|
function dmi() {
|
|
13746
|
-
const
|
|
13815
|
+
const out = {};
|
|
13747
13816
|
for (const field of DMI_FIELDS) {
|
|
13748
13817
|
try {
|
|
13749
|
-
|
|
13818
|
+
out[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
|
|
13750
13819
|
} catch {}
|
|
13751
13820
|
}
|
|
13752
|
-
return
|
|
13821
|
+
return out;
|
|
13753
13822
|
}
|
|
13754
13823
|
async function tailnetSelf() {
|
|
13755
13824
|
const path = TAILSCALE_PATHS.find((p) => existsSync3(p));
|
|
@@ -13820,8 +13889,8 @@ async function main(argv) {
|
|
|
13820
13889
|
if (import.meta.main)
|
|
13821
13890
|
await main(process.argv);
|
|
13822
13891
|
export {
|
|
13823
|
-
|
|
13824
|
-
pair,
|
|
13892
|
+
daemon,
|
|
13825
13893
|
main,
|
|
13826
|
-
|
|
13894
|
+
pair,
|
|
13895
|
+
serve2 as serve
|
|
13827
13896
|
};
|