ledge-server 0.0.3 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/serve.js CHANGED
@@ -390,9 +390,9 @@ function setFavoriteLine(text, on) {
390
390
  if (!on) {
391
391
  if (b === null)
392
392
  return text;
393
- const content2 = b.lines.slice(1, b.close);
394
- const kept = content2.filter((l) => !FAVORITE_LINE.test(l));
395
- if (kept.length === content2.length)
393
+ const content = b.lines.slice(1, b.close);
394
+ const kept = content.filter((l) => !FAVORITE_LINE.test(l));
395
+ if (kept.length === content.length)
396
396
  return text;
397
397
  if (kept.every((l) => l.trim() === ""))
398
398
  return text.slice(b.end);
@@ -659,7 +659,7 @@ function tagRefsOf(text) {
659
659
  }
660
660
 
661
661
  // src/bun/notes.ts
662
- import { basename as basename2, dirname as dirname3, join as join5, relative as relative2, resolve as resolve3, sep as sep3 } from "path";
662
+ import { basename as basename3, dirname as dirname3, join as join5, relative as relative2, resolve as resolve3, sep as sep3 } from "path";
663
663
  import { mkdir as mkdir3, open, readdir as readdir3, readFile as readFile5, rename as rename4, rmdir as rmdir2, stat as stat2, unlink as unlink4, writeFile as writeFile4 } from "fs/promises";
664
664
 
665
665
  // src/shared/rpc-schema.ts
@@ -890,8 +890,10 @@ async function loadWorkspaces() {
890
890
  }
891
891
  async function ensureDefault() {
892
892
  if (availableRoots().some((r) => kindOf(r) !== "docs"))
893
- return;
894
- await createManaged("Scratch");
893
+ return null;
894
+ const first = ![...entries.keys()].some((r) => kindOf(r) !== "docs");
895
+ const root = await createManaged("Scratch");
896
+ return first ? root : null;
895
897
  }
896
898
  async function createManaged(name) {
897
899
  await ensureAppHome();
@@ -1247,10 +1249,10 @@ async function unlockVault(passphrase, device, probeHeader) {
1247
1249
  } catch {
1248
1250
  return false;
1249
1251
  }
1250
- const key2 = deriveKey(passphrase, vaultSalt);
1251
- if (!checkAgainstFile(key2, checkB64))
1252
+ const key = deriveKey(passphrase, vaultSalt);
1253
+ if (!checkAgainstFile(key, checkB64))
1252
1254
  return false;
1253
- masterKey = key2;
1255
+ masterKey = key;
1254
1256
  authorize(device);
1255
1257
  return true;
1256
1258
  }
@@ -1513,7 +1515,7 @@ function stripLockedLine(text) {
1513
1515
  }
1514
1516
 
1515
1517
  // src/bun/assets.ts
1516
- import { dirname as dirname2, join as join4, relative, resolve as resolve2, extname, sep as sep2 } from "path";
1518
+ import { basename as basename2, dirname as dirname2, join as join4, relative, resolve as resolve2, extname, sep as sep2 } from "path";
1517
1519
  import { mkdir as mkdir2, readdir as readdir2, readFile as readFile4, rename as rename3, unlink as unlink3, writeFile as writeFile3 } from "fs/promises";
1518
1520
  function assetsDirOf(root) {
1519
1521
  return join4(resolve2(root), ASSETS_DIRNAME);
@@ -1601,6 +1603,19 @@ async function savePastedImage(root, bytes, ext = ".png", seal = false, from) {
1601
1603
  await writeAsset(assetsDir, join4(assetsDir, name), seal ? sealAssetBytes(bytes) : bytes);
1602
1604
  return assetRefFor(root, join4(assetsDir, name), from);
1603
1605
  }
1606
+ async function copyAssetInto(root, src) {
1607
+ const bytes = await readFile4(src).catch(() => null);
1608
+ if (bytes === null)
1609
+ return null;
1610
+ const assetsDir = assetsDirOf(assertWritableRoot(assertRegisteredRoot(root)));
1611
+ await mkdir2(assetsDir, { recursive: true });
1612
+ const taken = new Set(await readdir2(assetsDir));
1613
+ const ext = extname(src);
1614
+ const name = uniqueName(basename2(src, ext), taken, ext);
1615
+ const path = join4(assetsDir, name);
1616
+ await writeAsset(assetsDir, path, bytes);
1617
+ return path;
1618
+ }
1604
1619
  async function replaceAssetBytes(path, bytes) {
1605
1620
  await writeAsset(dirname2(path), path, bytes);
1606
1621
  }
@@ -1750,9 +1765,9 @@ async function listNotes(root) {
1750
1765
  }
1751
1766
  async function searchNotes(root, query, folder = "") {
1752
1767
  const metas = notesUnder(await listNotes(root), folder);
1753
- const open2 = metas.filter((m) => !m.locked);
1754
- const hits = await collectHits(query, open2, async (path) => (await readNote(path))?.text ?? null);
1755
- return { hits, lockedSkipped: metas.length - open2.length };
1768
+ const open = metas.filter((m) => !m.locked);
1769
+ const hits = await collectHits(query, open, async (path) => (await readNote(path))?.text ?? null);
1770
+ return { hits, lockedSkipped: metas.length - open.length };
1756
1771
  }
1757
1772
  var CONTEXT_MAX = 200;
1758
1773
  function contextOf(lines, line) {
@@ -1895,7 +1910,7 @@ async function writeNote(path, text, baseMtimeMs = null) {
1895
1910
  }
1896
1911
  }
1897
1912
  tmpCounter4 += 1;
1898
- const tmp = join5(dir, `.${basename2(path)}.tmp-${process.pid}-${tmpCounter4}`);
1913
+ const tmp = join5(dir, `.${basename3(path)}.tmp-${process.pid}-${tmpCounter4}`);
1899
1914
  try {
1900
1915
  await writeFile4(tmp, outgoing, "utf8");
1901
1916
  const mtimeMs = (await stat2(tmp)).mtimeMs;
@@ -1942,14 +1957,14 @@ function assetRefsOf(text, root, from) {
1942
1957
  function rebaseAssetRefs(text, root, from, to) {
1943
1958
  if (dirname3(resolve3(from)) === dirname3(resolve3(to)))
1944
1959
  return text;
1945
- return text.replace(IMAGE_REF, (whole, open2, ref, close) => {
1960
+ return text.replace(IMAGE_REF, (whole, open, ref, close) => {
1946
1961
  let asset;
1947
1962
  try {
1948
1963
  asset = assetPathOf(root, ref, from);
1949
1964
  } catch {
1950
1965
  return whole;
1951
1966
  }
1952
- return `${open2}${assetRefFor(root, asset, to)}${close}`;
1967
+ return `${open}${assetRefFor(root, asset, to)}${close}`;
1953
1968
  });
1954
1969
  }
1955
1970
  async function favoriteNote(path, on) {
@@ -2150,13 +2165,13 @@ async function rollBack(done, from, to, salt) {
2150
2165
  async function imageFilesUnder(root) {
2151
2166
  const out = [];
2152
2167
  const walk = async (dir) => {
2153
- let entries2;
2168
+ let entries;
2154
2169
  try {
2155
- entries2 = await readdir3(dir, { withFileTypes: true });
2170
+ entries = await readdir3(dir, { withFileTypes: true });
2156
2171
  } catch {
2157
2172
  return;
2158
2173
  }
2159
- for (const entry of entries2) {
2174
+ for (const entry of entries) {
2160
2175
  if (entry.name.startsWith(".") && entry.name !== ASSETS_DIRNAME)
2161
2176
  continue;
2162
2177
  const path = join5(dir, entry.name);
@@ -2178,7 +2193,7 @@ async function writeSealed(path, outgoing, baseMtimeMs) {
2178
2193
  console.warn("[vault] concurrent edit preserved in trash during a lock state change:", moved);
2179
2194
  }
2180
2195
  tmpCounter4 += 1;
2181
- const tmp = join5(dir, `.${basename2(path)}.tmp-${process.pid}-${tmpCounter4}`);
2196
+ const tmp = join5(dir, `.${basename3(path)}.tmp-${process.pid}-${tmpCounter4}`);
2182
2197
  try {
2183
2198
  await writeFile4(tmp, outgoing, "utf8");
2184
2199
  await rename4(tmp, path);
@@ -2201,8 +2216,8 @@ async function createNote(root, text, folder) {
2201
2216
  const dir = await ensureFolder(root, folder);
2202
2217
  const reserved = reservedIn(dir);
2203
2218
  const taken = new Set(await readdir3(dir));
2204
- for (const name2 of reserved)
2205
- taken.add(name2);
2219
+ for (const name of reserved)
2220
+ taken.add(name);
2206
2221
  const name = uniqueName(baseFor(text), taken);
2207
2222
  reserved.add(name);
2208
2223
  const path = join5(dir, name);
@@ -2213,13 +2228,14 @@ async function createNote(root, text, folder) {
2213
2228
  }
2214
2229
  return metaFor(path, text);
2215
2230
  }
2216
- async function moveNote(path, folder) {
2231
+ async function moveNote(path, folder, toRoot) {
2217
2232
  const root = assertWritableRoot(assertNote(path));
2233
+ const dest = toRoot == null ? root : assertWritableRoot(assertRegisteredRoot(toRoot));
2218
2234
  const from = resolve3(path);
2219
2235
  if (isInside(trashDirOf(root), from)) {
2220
2236
  throw new Error("that note is in the trash \u2014 restore it first, then move it");
2221
2237
  }
2222
- const dir = await ensureFolder(root, folder);
2238
+ const dir = await ensureFolder(dest, folder);
2223
2239
  if (dirname3(from) === dir)
2224
2240
  return metaAt(from);
2225
2241
  const file = await readNote(from);
@@ -2230,22 +2246,65 @@ async function moveNote(path, folder) {
2230
2246
  }
2231
2247
  const reserved = reservedIn(dir);
2232
2248
  const taken = new Set(await readdir3(dir));
2233
- for (const name2 of reserved)
2234
- taken.add(name2);
2249
+ for (const name of reserved)
2250
+ taken.add(name);
2235
2251
  const name = uniqueName(titleOf(from), taken);
2236
2252
  reserved.add(name);
2237
2253
  const target = join5(dir, name);
2238
2254
  try {
2239
2255
  assertNote(target);
2240
- await rename4(from, target);
2241
- const rebased = rebaseAssetRefs(file.text, root, from, target);
2256
+ const rebased = dest === root ? rebaseAssetRefs(file.text, root, from, target) : await carryAssetRefs(file.text, root, from, dest, target);
2257
+ let baseMtimeMs = file.mtimeMs;
2258
+ try {
2259
+ await rename4(from, target);
2260
+ } catch (err) {
2261
+ if (err.code !== "EXDEV")
2262
+ throw err;
2263
+ await carryAcrossVolumes(from, target);
2264
+ baseMtimeMs = null;
2265
+ }
2242
2266
  if (rebased !== file.text)
2243
- await writeNote(target, rebased, file.mtimeMs);
2267
+ await writeNote(target, rebased, baseMtimeMs);
2244
2268
  } finally {
2245
2269
  reserved.delete(name);
2246
2270
  }
2247
2271
  return metaAt(target);
2248
2272
  }
2273
+ async function carryAcrossVolumes(from, target) {
2274
+ const bytes = await readFile5(from);
2275
+ tmpCounter4 += 1;
2276
+ const tmp = join5(dirname3(target), `.${basename3(target)}.tmp-${process.pid}-${tmpCounter4}`);
2277
+ try {
2278
+ await writeFile4(tmp, bytes);
2279
+ await rename4(tmp, target);
2280
+ } catch (err) {
2281
+ await unlink4(tmp).catch(() => {});
2282
+ throw err;
2283
+ }
2284
+ try {
2285
+ await deleteNote(from);
2286
+ } catch (err) {
2287
+ throw new Error(`copied the note to ${target} but could not move the original to the trash: ${err instanceof Error ? err.message : String(err)}`);
2288
+ }
2289
+ }
2290
+ async function carryAssetRefs(text, root, from, dest, to) {
2291
+ const copies = new Map;
2292
+ for (const { path } of assetRefsOf(text, root, from)) {
2293
+ const copied = await copyAssetInto(dest, path);
2294
+ if (copied !== null)
2295
+ copies.set(path, copied);
2296
+ }
2297
+ return text.replace(IMAGE_REF, (whole, open, ref, close) => {
2298
+ let asset;
2299
+ try {
2300
+ asset = assetPathOf(root, ref, from);
2301
+ } catch {
2302
+ return whole;
2303
+ }
2304
+ const copied = copies.get(asset);
2305
+ return copied === undefined ? whole : `${open}${assetRefFor(dest, copied, to)}${close}`;
2306
+ });
2307
+ }
2249
2308
  async function sameEntry(a, b) {
2250
2309
  const [x, y] = await Promise.all([stat2(a).catch(() => null), stat2(b).catch(() => null)]);
2251
2310
  return x !== null && y !== null && x.dev === y.dev && x.ino === y.ino;
@@ -2294,12 +2353,12 @@ async function renameFolder(root, folder, name) {
2294
2353
  async function retitleNote(path, text) {
2295
2354
  assertWritableRoot(assertNote(path));
2296
2355
  const dir = dirname3(path);
2297
- const current = basename2(path);
2356
+ const current = basename3(path);
2298
2357
  const reserved = reservedIn(dir);
2299
2358
  const taken = new Set(await readdir3(dir));
2300
2359
  taken.delete(current);
2301
- for (const name2 of reserved)
2302
- taken.add(name2);
2360
+ for (const name of reserved)
2361
+ taken.add(name);
2303
2362
  const name = uniqueName(baseFor(text), taken);
2304
2363
  if (name.toLowerCase() === current.toLowerCase()) {
2305
2364
  return metaFor(path, text);
@@ -2379,13 +2438,13 @@ async function stashNote(path, text) {
2379
2438
  async function trashFiles(root) {
2380
2439
  const out = [];
2381
2440
  const walk = async (dir) => {
2382
- let entries2;
2441
+ let entries;
2383
2442
  try {
2384
- entries2 = await readdir3(dir, { withFileTypes: true });
2443
+ entries = await readdir3(dir, { withFileTypes: true });
2385
2444
  } catch {
2386
2445
  return;
2387
2446
  }
2388
- for (const entry of entries2) {
2447
+ for (const entry of entries) {
2389
2448
  if (entry.name.startsWith("."))
2390
2449
  continue;
2391
2450
  const path = join5(dir, entry.name);
@@ -2427,8 +2486,8 @@ async function restoreNote(path) {
2427
2486
  await mkdir3(dir, { recursive: true });
2428
2487
  const reserved = reservedIn(dir);
2429
2488
  const taken = new Set(await readdir3(dir));
2430
- for (const name2 of reserved)
2431
- taken.add(name2);
2489
+ for (const name of reserved)
2490
+ taken.add(name);
2432
2491
  const name = uniqueName(titleOf(path), taken);
2433
2492
  reserved.add(name);
2434
2493
  const target = join5(dir, name);
@@ -2506,12 +2565,12 @@ function forceTitle(text, title) {
2506
2565
  const start = frontmatterEnd(text);
2507
2566
  if (heading === null) {
2508
2567
  const body = text.slice(start);
2509
- const sep4 = body.startsWith(`
2568
+ const sep = body.startsWith(`
2510
2569
  `) || body === "" ? `
2511
2570
  ` : `
2512
2571
 
2513
2572
  `;
2514
- return `${text.slice(0, start)}# ${title}${sep4}${body}`;
2573
+ return `${text.slice(0, start)}# ${title}${sep}${body}`;
2515
2574
  }
2516
2575
  const gap = start === 0 ? 0 : /^(?:[ \t]*\r?\n)+/.exec(text.slice(start))?.[0].length ?? 0;
2517
2576
  const lineStart = start + gap;
@@ -2563,9 +2622,9 @@ async function findTemplate(title, preferredRoot) {
2563
2622
  const pref = assertRegisteredRoot(preferredRoot);
2564
2623
  const local = resolveWikiTitle(title, await listNotes(pref));
2565
2624
  if (local) {
2566
- const file2 = await readNote(local.path);
2567
- if (file2)
2568
- return { path: local.path, text: templateText(file2, title) };
2625
+ const file = await readNote(local.path);
2626
+ if (file)
2627
+ return { path: local.path, text: templateText(file, title) };
2569
2628
  }
2570
2629
  const others = availableRoots().filter((r) => r !== pref);
2571
2630
  const metas = (await Promise.all(others.map((r) => listNotes(r)))).flat();
@@ -3040,7 +3099,7 @@ function endOfString(text, start) {
3040
3099
  // src/bun/spawnParams.ts
3041
3100
  import { accessSync, constants } from "fs";
3042
3101
  import { homedir as homedir3 } from "os";
3043
- import { basename as basename3, isAbsolute as isAbsolute2, join as join6, resolve as resolve4 } from "path";
3102
+ import { basename as basename4, isAbsolute as isAbsolute2, join as join6, resolve as resolve4 } from "path";
3044
3103
 
3045
3104
  // src/shared/dotenv.ts
3046
3105
  function parseDotenv(text) {
@@ -3113,7 +3172,7 @@ var SHELL_FALLBACKS = [
3113
3172
  "/usr/local/bin/bash"
3114
3173
  ];
3115
3174
  function isSupportedShell(path) {
3116
- return SUPPORTED_SHELLS.includes(basename3(path));
3175
+ return SUPPORTED_SHELLS.includes(basename4(path));
3117
3176
  }
3118
3177
  function resolveShellPath(loginShell, isExecutable) {
3119
3178
  if (loginShell && isAbsolute2(loginShell) && isSupportedShell(loginShell) && isExecutable(loginShell)) {
@@ -3147,7 +3206,7 @@ function defaultShellPath() {
3147
3206
  return resolveShellPath(process.env["SHELL"], isExecutableFile);
3148
3207
  }
3149
3208
  function resolveShellArgs(path, args) {
3150
- if (basename3(path) !== "zsh")
3209
+ if (basename4(path) !== "zsh")
3151
3210
  return args;
3152
3211
  if (args.some((a) => a.toLowerCase().replace(/_/g, "") === "interactivecomments"))
3153
3212
  return args;
@@ -3189,6 +3248,12 @@ function mergeDotenv(env, path, label, deps) {
3189
3248
  deps.warn(`${label}: ${p}`);
3190
3249
  Object.assign(env, vars);
3191
3250
  }
3251
+ function spawnKeyOf(params, host = LOCAL_HOST) {
3252
+ const env = Object.keys(params?.env ?? {}).sort().map((key) => [key, params.env[key]]);
3253
+ if (host !== LOCAL_HOST)
3254
+ return JSON.stringify([env, params?.cwd ?? null]);
3255
+ return JSON.stringify([env, params?.cwd ?? null, params?.profile ?? null, params?.envFile ?? null]);
3256
+ }
3192
3257
 
3193
3258
  // src/bun/settings.ts
3194
3259
  var SETTINGS_PATH = join7(APP_HOME, "settings.jsonc");
@@ -3297,10 +3362,10 @@ function folderOut(meta) {
3297
3362
  return meta.folder ? { folder: meta.folder } : {};
3298
3363
  }
3299
3364
  async function notesIn(workspace, folder = null) {
3300
- const roots2 = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3365
+ const roots = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3301
3366
  const scope = folderScopeOf(folder);
3302
3367
  const out = [];
3303
- for (const root of roots2) {
3368
+ for (const root of roots) {
3304
3369
  try {
3305
3370
  for (const n of notesUnder(await listNotes(root), scope))
3306
3371
  out.push({ ...n, workspace: root });
@@ -3385,10 +3450,10 @@ function targetWorkspace(args) {
3385
3450
  throw new Error(`LEDGE_WORKSPACE names ${env}, which is no longer a registered workspace root \u2014 name one explicitly (list_workspaces shows them)`);
3386
3451
  }
3387
3452
  }
3388
- const roots2 = writableRoots();
3389
- if (roots2.length === 1)
3390
- return roots2[0];
3391
- throw new Error(roots2.length === 0 ? "no workspace is available to create in (unmounted volume? list_workspaces shows what Ledge knows)" : "several workspaces exist \u2014 name one (list_workspaces shows them), or call from a shell in a Ledge note's terminal, where LEDGE_WORKSPACE names the current one");
3453
+ const roots = writableRoots();
3454
+ if (roots.length === 1)
3455
+ return roots[0];
3456
+ 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
3457
  }
3393
3458
  function dailyWorkspace(args, settings) {
3394
3459
  const asked = args["workspace"];
@@ -3502,11 +3567,11 @@ var ledgeTools = [
3502
3567
  throw new Error("give a non-empty query");
3503
3568
  await loadWorkspaces();
3504
3569
  const workspace = args["workspace"];
3505
- const roots2 = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3570
+ const roots = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3506
3571
  const folder = folderScopeOf(args["folder"]);
3507
3572
  const all = [];
3508
3573
  let lockedSkipped = 0;
3509
- for (const root of roots2) {
3574
+ for (const root of roots) {
3510
3575
  try {
3511
3576
  const res = await searchNotes(root, query, folder);
3512
3577
  lockedSkipped += res.lockedSkipped;
@@ -3564,16 +3629,16 @@ var ledgeTools = [
3564
3629
  handler: async (args) => {
3565
3630
  await loadWorkspaces();
3566
3631
  const workspace = args["workspace"];
3567
- const roots2 = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3632
+ const roots = typeof workspace === "string" && workspace !== "" ? [assertRegisteredRoot(workspace)] : availableRoots();
3568
3633
  const tag = args["tag"];
3569
3634
  const folder = folderScopeOf(args["folder"]);
3570
3635
  if (typeof tag === "string" && normalizeTag(tag) !== "") {
3571
3636
  const all = [];
3572
- let lockedSkipped2 = 0;
3573
- for (const root of roots2) {
3637
+ let lockedSkipped = 0;
3638
+ for (const root of roots) {
3574
3639
  try {
3575
3640
  const res = await notesTagged(root, tag, folder);
3576
- lockedSkipped2 += res.lockedSkipped;
3641
+ lockedSkipped += res.lockedSkipped;
3577
3642
  for (const h of res.hits) {
3578
3643
  all.push({ path: h.path, title: h.title, workspace: root, mtimeMs: h.mtimeMs, line: h.line, context: h.context });
3579
3644
  }
@@ -3588,12 +3653,12 @@ var ledgeTools = [
3588
3653
  return {
3589
3654
  hits: hits.map(({ mtimeMs, ...h }) => ({ ...h, modified: iso(mtimeMs) })),
3590
3655
  truncated: all.length > MAX_HITS,
3591
- ...lockedSkipped2 > 0 ? { lockedNoteBodiesSkipped: lockedSkipped2 } : {}
3656
+ ...lockedSkipped > 0 ? { lockedNoteBodiesSkipped: lockedSkipped } : {}
3592
3657
  };
3593
3658
  }
3594
3659
  const merged = new Map;
3595
3660
  let lockedSkipped = 0;
3596
- for (const root of roots2) {
3661
+ for (const root of roots) {
3597
3662
  try {
3598
3663
  const res = await tagsIn(root, folder);
3599
3664
  lockedSkipped += res.lockedSkipped;
@@ -3651,9 +3716,9 @@ var ledgeTools = [
3651
3716
  if (typeof title !== "string" || title.trim() === "") {
3652
3717
  throw new Error("creating from a template needs a `title` for the new note");
3653
3718
  }
3654
- const root2 = targetWorkspace(args);
3655
- const meta2 = await createFromTemplate(root2, template.trim(), title.trim(), folder);
3656
- return { path: meta2.path, title: meta2.title, workspace: root2, ...folderOut(meta2), modified: iso(meta2.mtimeMs) };
3719
+ const root = targetWorkspace(args);
3720
+ const meta = await createFromTemplate(root, template.trim(), title.trim(), folder);
3721
+ return { path: meta.path, title: meta.title, workspace: root, ...folderOut(meta), modified: iso(meta.mtimeMs) };
3657
3722
  }
3658
3723
  const text = args["text"];
3659
3724
  if (typeof text !== "string" || text.trim() === "") {
@@ -4077,17 +4142,17 @@ async function runCli(argv, io) {
4077
4142
  const ws = scope ?? (flags.all ? null : here);
4078
4143
  const base = inFolder(ws !== null ? { workspace: ws } : {});
4079
4144
  if (arg === "") {
4080
- const res2 = await tool("tags", base);
4145
+ const res = await tool("tags", base);
4081
4146
  if (flags.json) {
4082
- io.out(JSON.stringify(res2, null, 2));
4147
+ io.out(JSON.stringify(res, null, 2));
4083
4148
  return 0;
4084
4149
  }
4085
- if (res2.tags.length === 0) {
4150
+ if (res.tags.length === 0) {
4086
4151
  io.err(ws !== null ? `no tags in ${tildify(folder === "" ? ws : join10(ws, folder))}` : "no tags");
4087
4152
  return 0;
4088
4153
  }
4089
- const width = res2.tags.reduce((w, t) => Math.max(w, t.tag.length + 1), 0);
4090
- for (const t of res2.tags)
4154
+ const width = res.tags.reduce((w, t) => Math.max(w, t.tag.length + 1), 0);
4155
+ for (const t of res.tags)
4091
4156
  io.out(`${`#${t.tag}`.padEnd(width)} ${t.count}`);
4092
4157
  return 0;
4093
4158
  }
@@ -4119,11 +4184,11 @@ async function runCli(argv, io) {
4119
4184
  const args = inFolder({ template: flags.template, title });
4120
4185
  if (scope !== null)
4121
4186
  args["workspace"] = scope;
4122
- const n2 = await tool("create_note", args);
4187
+ const n = await tool("create_note", args);
4123
4188
  if (flags.json)
4124
- io.out(JSON.stringify(n2, null, 2));
4189
+ io.out(JSON.stringify(n, null, 2));
4125
4190
  else
4126
- io.out(n2.path);
4191
+ io.out(n.path);
4127
4192
  return 0;
4128
4193
  }
4129
4194
  if (title === "" && body === "") {
@@ -4230,8 +4295,8 @@ import { join as join16 } from "path";
4230
4295
 
4231
4296
  // src/bun/server.ts
4232
4297
  import { watch as watch2 } from "fs";
4233
- import { homedir as homedir6 } from "os";
4234
- import { basename as basename7, resolve as resolve10 } from "path";
4298
+ import { homedir as homedir7 } from "os";
4299
+ import { basename as basename8, resolve as resolve10 } from "path";
4235
4300
 
4236
4301
  // src/bun/pty.ts
4237
4302
  import { dlopen, ptr, CString, cc } from "bun:ffi";
@@ -4423,8 +4488,8 @@ class PtyProcess {
4423
4488
  constructor(opts) {
4424
4489
  this.interruptViaChar = opts.interruptViaChar ?? false;
4425
4490
  const keep = [];
4426
- const cstr = (str2) => {
4427
- const enc = new TextEncoder().encode(str2);
4491
+ const cstr = (str) => {
4492
+ const enc = new TextEncoder().encode(str);
4428
4493
  const b = new Uint8Array(enc.length + 1);
4429
4494
  b.set(enc);
4430
4495
  keep.push(b);
@@ -4742,12 +4807,12 @@ class MarkerParser {
4742
4807
  if (!tag.startsWith("ledge="))
4743
4808
  return null;
4744
4809
  const payload = tag.slice("ledge=".length);
4745
- const sep5 = payload.indexOf(":");
4746
- if (sep5 === -1)
4810
+ const sep = payload.indexOf(":");
4811
+ if (sep === -1)
4747
4812
  return null;
4748
- if (payload.slice(0, sep5) !== this.nonce)
4813
+ if (payload.slice(0, sep) !== this.nonce)
4749
4814
  return null;
4750
- return payload.slice(sep5 + 1);
4815
+ return payload.slice(sep + 1);
4751
4816
  }
4752
4817
  }
4753
4818
  function concat(a, b) {
@@ -4821,11 +4886,11 @@ class InlinePool {
4821
4886
  }
4822
4887
  let slot = session.primaries.get(host);
4823
4888
  if (!slot) {
4824
- slot = this.newSlot(sessionId, host);
4889
+ slot = this.newSlot(sessionId, host, true);
4825
4890
  session.primaries.set(host, slot);
4826
4891
  }
4827
4892
  if (slot.activeRun !== null) {
4828
- slot = this.newSlot(sessionId, host);
4893
+ slot = this.newSlot(sessionId, host, false);
4829
4894
  session.overflow.set(id, slot);
4830
4895
  }
4831
4896
  slot.activeRun = id;
@@ -4917,11 +4982,11 @@ class InlinePool {
4917
4982
  continue;
4918
4983
  }
4919
4984
  if (slot.shell.exited) {
4920
- const open2 = slot.parser.openBlockId ?? slot.activeRun;
4921
- if (open2 && !slot.began)
4985
+ const open = slot.parser.openBlockId ?? slot.activeRun;
4986
+ if (open && !slot.began)
4922
4987
  this.flushPreamble(slot, emit);
4923
- if (open2)
4924
- emit({ type: "ended", blockId: open2, exitCode: null }, slot.client);
4988
+ if (open)
4989
+ emit({ type: "ended", blockId: open, exitCode: null }, slot.client);
4925
4990
  this.dropSlot(session, slot);
4926
4991
  }
4927
4992
  }
@@ -4943,9 +5008,9 @@ class InlinePool {
4943
5008
  const session = this.sessions.get(sessionId);
4944
5009
  if (session) {
4945
5010
  for (const slot of this.slots(session)) {
4946
- const open2 = slot.parser.openBlockId ?? slot.activeRun;
4947
- if (open2)
4948
- emit({ type: "ended", blockId: open2, exitCode: null }, slot.client);
5011
+ const open = slot.parser.openBlockId ?? slot.activeRun;
5012
+ if (open)
5013
+ emit({ type: "ended", blockId: open, exitCode: null }, slot.client);
4949
5014
  slot.shell.close();
4950
5015
  }
4951
5016
  this.sessions.delete(sessionId);
@@ -4955,6 +5020,16 @@ class InlinePool {
4955
5020
  this.pendingResize.delete(id);
4956
5021
  }
4957
5022
  }
5023
+ primaryHosts(sessionId) {
5024
+ const session = this.sessions.get(sessionId);
5025
+ if (!session)
5026
+ return [];
5027
+ const out = [];
5028
+ for (const [host, slot] of session.primaries)
5029
+ if (!slot.shell.exited)
5030
+ out.push(host);
5031
+ return out;
5032
+ }
4958
5033
  closeSession(sessionId) {
4959
5034
  const session = this.sessions.get(sessionId);
4960
5035
  if (session) {
@@ -5013,8 +5088,8 @@ class InlinePool {
5013
5088
  if (out.length > 0)
5014
5089
  emit({ type: "output", blockId: slot.activeRun, data: out }, slot.client);
5015
5090
  }
5016
- newSlot(sessionId, host) {
5017
- const shell = this.spawn(sessionId, host);
5091
+ newSlot(sessionId, host, persistent) {
5092
+ const shell = this.spawn(sessionId, host, persistent);
5018
5093
  return {
5019
5094
  shell,
5020
5095
  parser: new MarkerParser(this.nonce),
@@ -5057,14 +5132,14 @@ class InlinePool {
5057
5132
  }
5058
5133
  dropSlot(session, slot) {
5059
5134
  slot.shell.close();
5060
- for (const [host, s2] of session.primaries) {
5061
- if (s2 === slot) {
5135
+ for (const [host, s] of session.primaries) {
5136
+ if (s === slot) {
5062
5137
  session.primaries.delete(host);
5063
5138
  return;
5064
5139
  }
5065
5140
  }
5066
- for (const [id, s2] of session.overflow) {
5067
- if (s2 === slot)
5141
+ for (const [id, s] of session.overflow) {
5142
+ if (s === slot)
5068
5143
  session.overflow.delete(id);
5069
5144
  }
5070
5145
  }
@@ -5134,8 +5209,59 @@ async function writeProfile(name, text) {
5134
5209
  }
5135
5210
  }
5136
5211
 
5212
+ // src/shared/welcome.ts
5213
+ var WELCOME_TITLE = "Welcome to Ledge";
5214
+ var WELCOME_DOC = [
5215
+ `# ${WELCOME_TITLE}`,
5216
+ "",
5217
+ "Ledge runs code and commands straight from your Markdown. This note is yours: edit it, or start a new one with \u2318N.",
5218
+ "",
5219
+ "## Run a block",
5220
+ "",
5221
+ "\u2318\u21A9 inside the block below, or the Run button on it (a tap, on a phone), runs it.",
5222
+ "",
5223
+ "```sh",
5224
+ "curl -s https://api.github.com/zen",
5225
+ "```",
5226
+ "",
5227
+ "One line of output streams into a panel beneath the block, and Dismiss puts the panel away.",
5228
+ "",
5229
+ "## The shell persists between blocks",
5230
+ "",
5231
+ "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:",
5232
+ "",
5233
+ "```sh",
5234
+ "cd /tmp",
5235
+ "export FLAVOR=nautical",
5236
+ "```",
5237
+ "",
5238
+ "```sh",
5239
+ "pwd",
5240
+ 'echo "this shell is feeling $FLAVOR"',
5241
+ "```",
5242
+ "",
5243
+ "\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.",
5244
+ "",
5245
+ "## Other languages",
5246
+ "",
5247
+ "`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:",
5248
+ "",
5249
+ "```ts",
5250
+ "const now = new Date();",
5251
+ "console.log(`hello from TypeScript, it is ${now.toLocaleTimeString()}`);",
5252
+ "```",
5253
+ "",
5254
+ "## Where to next",
5255
+ "",
5256
+ "- 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.",
5257
+ "- \u2318P opens a note by title, and \u2325\u2318P searches every note.",
5258
+ '- The manual is behind the help button in the header, or "Documentation" in the command palette (\u21E7\u2318P). Getting Started is its first page.',
5259
+ ""
5260
+ ].join(`
5261
+ `);
5262
+
5137
5263
  // src/bun/docs.ts
5138
- import { basename as basename5, join as join13, resolve as resolve9 } from "path";
5264
+ import { basename as basename6, join as join13, resolve as resolve9 } from "path";
5139
5265
  import { mkdir as mkdir5, readdir as readdir4, readFile as readFile9, rename as rename8, unlink as unlink7, writeFile as writeFile8 } from "fs/promises";
5140
5266
 
5141
5267
  // docs/user/01-getting-started.md
@@ -5143,7 +5269,7 @@ var _01_getting_started_default = `# Getting Started
5143
5269
 
5144
5270
  Ledge is the notebook for developers and DevOps. It runs code and commands straight from your Markdown.
5145
5271
 
5146
- The manual is read-only and its code blocks do not run. The note called Welcome to Ledge, which opens on a first launch and on a server that has no notes yet, is where the same examples do run.
5272
+ The manual is read-only and its code blocks do not run. The note called Welcome to Ledge is where the same examples do run. Ledge creates it the first time it starts on a machine, whether that is your Mac or a new server, and it stays in the Scratch workspace until you delete it.
5147
5273
 
5148
5274
  ## Your first note
5149
5275
 
@@ -5351,7 +5477,7 @@ The terminal drawer is a separate shell from the inline one. Both belong to this
5351
5477
 
5352
5478
  Comments mean the same thing on both chords. A \`#\` line inside a shell block is a comment whether you run the block inline or send it to the drawer, so you can annotate a block without breaking it.
5353
5479
 
5354
- Frontmatter applies to newly spawned shells, so after editing it run "Restart Note Shell" from the palette. It kills the note's shells and lets them respawn. Use the same command when an experiment leaves a shell in a strange state.
5480
+ Frontmatter applies to newly spawned shells, so editing \`cwd:\` leaves a running shell where it was. The block grows a **Restart Note Shell** button when that happens, and pressing it lets the shells respawn with what the note now says. The same command is in the palette and the Note menu, for when an experiment leaves a shell in a strange state. [[Frontmatter and Environments]] has the detail.
5355
5481
 
5356
5482
  ## Change the shell
5357
5483
 
@@ -5366,6 +5492,8 @@ Ledge spawns your own login shell with \`-i\` for every inline shell and every t
5366
5492
 
5367
5493
  Relaunch to apply. Keep an interactive flag in \`args\`, usually \`-i\`, so your rc files run and blocks get the aliases and PATH you expect.
5368
5494
 
5495
+ Ledge also reads your login profile (\`~/.zprofile\`, \`~/.bash_profile\` or \`~/.profile\`) once at launch, so a PATH set there, such as Homebrew's, reaches every block even when you open Ledge from the Dock. Relaunch Ledge after editing your profile.
5496
+
5369
5497
  zsh and bash are the two shells Ledge can read block output from. It marks where a block's output starts and stops with a hook that only those two provide. Any other shell runs the terminal drawer normally, and its inline runs show no output and no exit code. Ledge warns about that in the launch log rather than overriding what you set.
5370
5498
 
5371
5499
  A shell that is not installed refuses the run and names the path it could not find. Nothing quietly falls back to a different shell, because a different shell is not the one you asked for.
@@ -5528,6 +5656,16 @@ A locked note has to be unlocked before it can move, because those image referen
5528
5656
 
5529
5657
  Two notes in different folders may share a title. Ledge shows the folder beside the title wherever the list is flat: quick-open, full text search, backlinks, and tag results.
5530
5658
 
5659
+ ## Moving a note to another workspace
5660
+
5661
+ Choose "Move to Workspace\u2026" from the note's right-click menu or the command palette, or drag its row onto a workspace in the strip above the list.
5662
+ The note lands at the top level of that workspace, under the name it had, and any tab you have open on it goes along.
5663
+ Its images go with it: Ledge copies them into the other workspace's own image folder and rewrites the note's references, so the pictures still show. The copies in the workspace you left stay there, in case another note there shows the same picture.
5664
+ Wikilinks are the one thing that does not travel. \`[[Title]]\` finds a note in its own workspace, so notes here that linked to the moved note will stop finding it, and the strip at the bottom of the sidebar says how many that is. "Undo" there moves the note straight back.
5665
+ A locked note moves with its vault open and is refused with it shut, as with moving between folders ([[Note Locking]]).
5666
+ Folders do not move between workspaces; move their notes one at a time.
5667
+ The two workspaces are on the same machine, since a window shows one machine's notes at a time ([[Keep Notes on a Remote Server]]). If they are on different disks, the original is put in the old workspace's Trash rather than deleted, and the copy in the new workspace is the note from then on.
5668
+
5531
5669
  ## Favorites
5532
5670
 
5533
5671
  A favorite note sits in a Favorites section at the top of the sidebar, above the tree, however deep in a folder it actually lives.
@@ -5889,7 +6027,13 @@ Three keys feed the environment, layered in this order, with later layers overri
5889
6027
 
5890
6028
  Frontmatter is read when a shell spawns, and a note's running shells keep the settings they started with.
5891
6029
 
5892
- After editing the block, run "Restart Note Shell" from the command palette (\u21E7\u2318P). It kills the note's shells, and the next run or drawer visit respawns them with the current frontmatter. Use the same command when an experiment leaves a shell in a strange state.
6030
+ So editing \`cwd:\` does not move a shell that is already running. A block that printed one directory keeps printing it until the note's shells are replaced.
6031
+
6032
+ The block says when that has happened. Edit a key that feeds a shell while the note has one running, and a **Restart Note Shell** button appears at the end of the block. Pressing it kills the note's shells; the next run or drawer visit spawns them with the frontmatter as it now reads.
6033
+
6034
+ The same command is in the palette (\u21E7\u2318P) and the Note menu. Use it when an experiment leaves a shell in a strange state, too.
6035
+
6036
+ The button is about the four keys a shell is spawned with: \`cwd\`, \`profile\`, \`envFile\`, and \`env\`. The rest of the block applies as you type it, so tagging or favoriting a note never raises it. Typing the old value back takes it down again, and so does the shell exiting on its own.
5893
6037
 
5894
6038
  ## Every key
5895
6039
 
@@ -5908,7 +6052,7 @@ After editing the block, run "Restart Note Shell" from the command palette (\u21
5908
6052
  `;
5909
6053
 
5910
6054
  // docs/user/07-profiles-and-secrets.md
5911
- 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. Run \"Restart Note Shell\" after changing one.\n\n## Profiles stay with the notes\n\nA profile lives on the machine that holds the notes and runs their blocks. With your notes on this Mac, that is this Mac. With your notes on a server, the file is on the server, \"Edit Note Profile\u2026\" edits it there, and the values never come to this app. [[Keep Notes on a Remote Server]] has the table of what lives where.\n\nWhen a note runs its blocks on a remote host over ssh, Ledge does not send the profile ([[Run Code on Remote Hosts]]). A secret passed on a remote command line would be visible in that machine's process table to anyone who can list processes. If a remote run needs credentials, put them on the remote machine.\n";
6055
+ var _07_profiles_and_secrets_default = "# Profiles and Secrets\n\nA profile is a named file of environment variables that lives outside your notes folder and is injected into the shells of any note that names it.\n\nUse one for secrets. Notes get synced, backed up, shared, and read by agents, so an API key written in an `env:` line travels everywhere the note does. With a profile, the note carries only a name.\n\n## Declare a profile\n\nAdd one line of frontmatter (see [[Frontmatter and Environments]] for the block itself):\n\n```\n---\nprofile: deploy\n---\n```\n\nProfile names may contain letters, digits, `-`, and `_`. The name resolves to a file under `~/.config/ledge/profiles/` on the machine holding the notes, here `deploy.env`, created for you the first time you open it for editing.\n\nA note names at most one profile, and any number of notes can share one. Every deploy-related note can say `profile: deploy` and pick up the same credentials.\n\nOne name is taken. `backup` is the profile `ledge backup setup` writes, holding the backup repository and its credentials ([[Keep Notes on a Remote Server]]). A note that says `profile: backup` runs with those variables, which is how a note runs restic by hand.\n\n## Edit a profile\n\nClick the profile name in the frontmatter block, or run \"Edit Note Profile\u2026\" from the command palette. The command appears whenever the current note names a profile.\n\nOn a touch device the palette command is the whole of it. The small key button beside the name is a pointer control and is not drawn there, and the command asks for nothing to be pointed at: it follows the note you are in.\n\nEither way you get Ledge's profile editor: KEY=value rows with the values masked.\n\nOn disk the profile is a plain dotenv file: `KEY=value` per line, `#` comments, and an optional `export ` prefix. Ledge creates it readable only by you. Hand edits and editor edits coexist, and saves from the editor preserve your comments.\n\n```\n# deploy.env\nAPI_TOKEN=abc123\nDEPLOY_REGION=eu-west-1\n```\n\n## How profiles layer\n\nProfile variables merge into the shell environment at spawn, above the note's `envFile` and below its inline `env:` lines. An `env:` line can therefore override a profile value for one note without editing the shared file.\n\nA `profile:` line naming a file that does not exist is skipped, and the shell spawns without it.\n\nA profile edit applies to newly spawned shells, like every frontmatter change. Changing which profile a note names raises the block's **Restart Note Shell** button; editing the values inside a profile file does not, so run the command yourself after that.\n\n## Profiles stay with the notes\n\nA profile lives on the machine that holds the notes and runs their blocks. With your notes on this Mac, that is this Mac. With your notes on a server, the file is on the server, \"Edit Note Profile\u2026\" edits it there, and the values never come to this app. [[Keep Notes on a Remote Server]] has the table of what lives where.\n\nWhen a note runs its blocks on a remote host over ssh, Ledge does not send the profile ([[Run Code on Remote Hosts]]). A secret passed on a remote command line would be visible in that machine's process table to anyone who can list processes. If a remote run needs credentials, put them on the remote machine.\n";
5912
6056
 
5913
6057
  // docs/user/08-run-code-on-remote-hosts.md
5914
6058
  var _08_run_code_on_remote_hosts_default = `# Run Code on Remote Hosts
@@ -6101,17 +6245,36 @@ Closing the last window quits Ledge.
6101
6245
 
6102
6246
  ## Install the server
6103
6247
 
6104
- The other machine needs the \`ledge\` command on the PATH an incoming ssh gets. It comes from the \`ledge-server\` package, so a few commands install it. [[Tutorial: Set Up a Ledge Server]] walks through them on a fresh VPS, with an account for Ledge and the sshd hardening this page describes further down.
6248
+ The other machine needs the server, which is the \`ledge-server\` package. One command installs it, on Linux or a Mac. Run it in a terminal on that machine, signed in as the account Ledge will sign in to:
6105
6249
 
6106
- The server runs on Bun, and where Bun goes decides where the server goes, because Bun puts global commands beside itself. On a Linux machine, install Bun into \`/usr/local\` and both names land in \`/usr/local/bin\`, which is where the short PATH of an ssh command looks:
6250
+ \`\`\`sh norun
6251
+ curl -fsSL https://ledge.sh/server.sh | sh
6252
+ \`\`\`
6253
+
6254
+ 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.
6255
+
6256
+ 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
6257
 
6108
6258
  \`\`\`sh norun
6109
- curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
6259
+ curl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh
6110
6260
  \`\`\`
6111
6261
 
6112
- Then the server, into the same place:
6262
+ 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
+
6264
+ A Mac that runs the Ledge app needs none of this. "Install Shell Command (ledge)" in the app's command palette puts \`ledge\` in \`~/.ledge/.server/bin\`, where an incoming ssh looks first, pointing at the app's own copy. Signing in as that account then reaches the notes the app shows, with the app's server answering both. On a Mac, the machine also needs Remote Login turned on ("Expose ssh carefully").
6265
+
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.
6267
+
6268
+ Nothing else has to be installed and no port is opened. Ledge speaks its protocol over ssh's stdin and stdout.
6269
+
6270
+ 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.
6271
+
6272
+ ## Install the server with Bun
6273
+
6274
+ 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
6275
 
6114
6276
  \`\`\`sh norun
6277
+ curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
6115
6278
  sudo BUN_INSTALL=/usr/local bun add -g ledge-server
6116
6279
  \`\`\`
6117
6280
 
@@ -6128,27 +6291,23 @@ bun add -g ledge-server
6128
6291
 
6129
6292
  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
6293
 
6131
- A Mac that runs the Ledge app needs none of this. "Install Shell Command (ledge)" in the app's command palette puts \`ledge\` in \`~/.ledge/.server/bin\`, where an incoming ssh looks first, pointing at the app's own copy. Signing in as that account then reaches the notes the app shows, with the app's server answering both.
6132
-
6133
- macOS and Linux are supported, on arm64 or x64. On Linux the floor is glibc 2.29, which means Debian 11, Ubuntu 20.04, RHEL 9, or anything newer. Alpine and other musl systems are not supported.
6134
-
6135
- Nothing else has to be installed and no port is opened. Ledge speaks its protocol over ssh's stdin and stdout.
6136
-
6137
- Blocks need zsh or bash on that machine. Ledge spawns the account's login shell when it is one of those, and otherwise the first of the two it finds, so an ordinary Linux install needs nothing extra. Where neither exists, a run refuses and names the shell it looked for instead of appearing to do nothing.
6294
+ Updating is \`bun add -g ledge-server@latest\`, with the same \`sudo BUN_INSTALL=/usr/local\` in front on Linux.
6138
6295
 
6139
6296
  ## Check that ssh can find the server
6140
6297
 
6141
6298
  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
6299
 
6143
- Ledge starts the server by running \`ledge serve\` over ssh. A command run that way gets a short PATH and skips the startup files a terminal reads, so both \`ledge\` and the \`bun\` its first line names have to be on that PATH already. From your Mac's own terminal:
6300
+ 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
6301
 
6145
6302
  \`\`\`sh norun
6146
- ssh you@machine 'command -v ledge; command -v bun'
6303
+ ssh you@machine 'PATH=$HOME/.ledge/.server/bin:$PATH command -v ledge'
6147
6304
  \`\`\`
6148
6305
 
6149
- Two paths printed means the machine is ready to add.
6306
+ A path printed means the machine is ready to add.
6150
6307
 
6151
- On Linux, nothing printed means Bun is installed for one user rather than system-wide, which is what a machine that already had Bun before you started usually has. Its global commands are then in \`~/.bun/bin\`, which an incoming ssh does not search, and \`bun pm bin -g\` on that machine confirms where they went. Linking both names into a system directory, on that machine, fixes it without reinstalling anything:
6308
+ 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.
6309
+
6310
+ 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
6311
 
6153
6312
  \`\`\`sh norun
6154
6313
  sudo ln -s "$(bun pm bin -g)/ledge" /usr/local/bin/ledge
@@ -6177,10 +6336,10 @@ Optional, and worth doing on a server you care about. Ledge connects with an ord
6177
6336
  Restricting gives the server a key that can speak Ledge's protocol and nothing else. In that machine's \`~/.ssh/authorized_keys\`:
6178
6337
 
6179
6338
  \`\`\`
6180
- restrict,command="/usr/local/bin/ledge serve" ssh-ed25519 AAAA... ledge@laptop
6339
+ restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ssh-ed25519 AAAA... ledge@laptop
6181
6340
  \`\`\`
6182
6341
 
6183
- Use the absolute path that \`command -v ledge\` printed above. sshd runs this line instead of whatever the client asked for, so naming the file outright settles where it is. It does not settle where Bun is, which is the other half of the check.
6342
+ 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
6343
 
6185
6344
  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
6345
 
@@ -6250,7 +6409,7 @@ Backups run every hour while the server is up, and once more before it exits: af
6250
6409
 
6251
6410
  Three things to know before you rely on it:
6252
6411
 
6253
- - Keep the restic password somewhere other than this machine. \`setup\` prints it once. A restore starts on a machine that has nothing on it, and a password stored only inside the backup is a backup you cannot open.
6412
+ - 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
6413
  - 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
6414
  - 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
6415
 
@@ -6395,6 +6554,8 @@ var _10_ledge_on_your_phone_default = `# Ledge on Your Phone
6395
6554
 
6396
6555
  Ledge runs on an iPhone or iPad as a window onto a server. The phone holds no notes: it reaches a server over ssh, the way a Mac does in [[Keep Notes on a Remote Server]], and shows you what is there.
6397
6556
 
6557
+ 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
+
6398
6559
  A server that already serves your Mac needs nothing more. A machine without one needs the server installed first, as "Install the server" on that page describes, and the phone shows the same commands ("Set up a server" below).
6399
6560
 
6400
6561
  ## The first screen
@@ -6431,31 +6592,20 @@ A code never replaces a host key the phone already has. When Ledge has a differe
6431
6592
 
6432
6593
  ## Set up a server
6433
6594
 
6434
- I don't have a server yet opens "Set up a server", which shows the commands that make a machine a Ledge server. Choose Linux or Mac above them. On Linux:
6595
+ 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
6596
 
6436
6597
  \`\`\`sh norun
6437
- curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
6438
- sudo BUN_INSTALL=/usr/local bun add -g ledge-server
6439
- ledge pair
6440
- \`\`\`
6441
-
6442
- On a Mac:
6443
-
6444
- \`\`\`sh norun
6445
- curl -fsSL https://bun.sh/install | bash
6446
- echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.zshenv
6447
- source ~/.zshenv
6448
- bun add -g ledge-server
6449
- ledge pair
6598
+ curl -fsSL https://ledge.sh/server.sh | sh
6599
+ ~/.ledge/.server/bin/ledge pair
6450
6600
  \`\`\`
6451
6601
 
6452
- Run them in a terminal on that machine, signed in as the account the phone should use. They install Bun and the server where a command run over ssh can find them, which on Linux needs \`sudo\` and on a Mac needs the \`~/.zshenv\` line instead. The last command prints a pairing code for that account, and Scan the pairing code on the same screen reads it.
6602
+ 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
6603
 
6454
6604
  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
6605
 
6456
- On a Mac, turn on Remote Login first, in System Settings under General, then Sharing. A Mac that runs the Ledge app needs only "Install Shell Command (ledge)" from the app's command palette in place of the first four commands: it puts \`ledge\` where the phone's ssh looks, pointing at the app's own copy, so the phone sees the same notes the app shows. \`ledge pair\` in a new terminal then prints the code.
6606
+ On a Mac, turn on Remote Login first, in System Settings under General, then Sharing. A Mac that runs the Ledge app needs only "Install Shell Command (ledge)" from the app's command palette in place of the first command: it puts \`ledge\` where the phone's ssh looks, pointing at the app's own copy, so the phone sees the same notes the app shows. The second command then prints the code.
6457
6607
 
6458
- The machine needs sshd running and an address the phone can reach. [[Keep Notes on a Remote Server]] has the details of the install, including a machine that already has Bun, and [[Tutorial: Set Up a Ledge Server]] walks through a fresh VPS.
6608
+ 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
6609
 
6460
6610
  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
6611
 
@@ -6465,13 +6615,19 @@ Add an existing server opens "Pair with a server", a form with three parts. The
6465
6615
 
6466
6616
  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
6617
 
6468
- The second is how to sign in. With A key, the default, the form shows a key line. On its first launch the phone makes a key of its own in the Secure Enclave, and that key never leaves the phone: there is no file to copy in or out. What leaves is the public half, as one line for the server's \`~/.ssh/authorized_keys\`:
6618
+ 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
6619
 
6470
6620
  \`\`\`
6471
6621
  restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ecdsa-sha2-nistp256 AAAA... ledge-iphone-3f2a91c0
6472
6622
  \`\`\`
6473
6623
 
6474
- Copy line puts it on the phone's pasteboard. Share line hands it to AirDrop, Messages, or any app that can carry it to a machine with a shell on the server, which is where the pasteboard on a phone falls short. Add it to \`~/.ssh/authorized_keys\` there. The comment at the end names the phone, so the line is easy to find again when you want to revoke it.
6624
+ The command adds that line to the file, creating \`~/.ssh\` first if the account has none:
6625
+
6626
+ \`\`\`sh norun
6627
+ 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
+ \`\`\`
6629
+
6630
+ Run it on the server, signed in as the account Ledge uses: over ssh from a Mac, or in the provider's web console for a new VPS. Copy command puts it on the phone's pasteboard. Share command hands it to AirDrop, Messages, or any app that can carry it to that terminal, which is where the pasteboard on a phone falls short. The comment at the end of the line names the phone, so the line is easy to find again when you want to revoke it.
6475
6631
 
6476
6632
  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
6633
 
@@ -6481,7 +6637,7 @@ Ledge adds the server only once \`ledge serve\` answers there. On a machine wher
6481
6637
 
6482
6638
  ## Sign in with a password instead
6483
6639
 
6484
- Choose "A password" under Sign in with and type the password for that account. The phone keeps it in its own keychain, and no key line has to be installed.
6640
+ 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
6641
 
6486
6642
  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
6643
 
@@ -6497,7 +6653,7 @@ Removing the last server returns the phone to the first screen. Deleting the app
6497
6653
 
6498
6654
  ## More than one server
6499
6655
 
6500
- Inside the app the connection bar works as on a Mac: tap it to add, edit, remove, or switch servers, with the same fingerprint step ([[Keep Notes on a Remote Server]]). The form shows the phone's key line where a Mac's shows a key path, with Share Line beside Copy Line.
6656
+ Inside the app the connection bar works as on a Mac: tap it to add, edit, remove, or switch servers, with the same fingerprint step ([[Keep Notes on a Remote Server]]). The form shows the command that installs the phone's key where a Mac's shows a key path, with Share Command beside Copy Command.
6501
6657
 
6502
6658
  Add Server\u2026 starts with Scan a pairing code, where a Mac's form has a field for the pasted link. It opens the camera, then the same "Pair with a server" screen as the first launch, and the app reopens on the new server once you tap Connect there. Cancel returns you to the form, where you can type the address instead. Editing a server has no scan: a code never replaces a host key the phone already has.
6503
6659
 
@@ -7117,7 +7273,7 @@ They combine: a synced drive for the always-on workspaces, a git repo for the on
7117
7273
  `;
7118
7274
 
7119
7275
  // docs/user/20-tutorial-set-up-a-ledge-server.md
7120
- var _20_tutorial_set_up_a_ledge_server_default = '# Tutorial: Set Up a Ledge Server\n\nTurn a fresh Linux VPS into a Ledge server: an account for Ledge, the server package, a key that can do nothing but Ledge, and an sshd that ignores everyone else.\n\nThis builds on [[Keep Notes on a Remote Server]], which is the reference for every step here. The commands assume Debian or Ubuntu. Any Linux with glibc 2.29 or newer works, so substitute your package manager on anything else.\n\nTwo accounts appear throughout. `you@vps` is the account your provider gave you, which can `sudo`. `ledge@vps` is the account you create in step 1, which cannot.\n\n## 1. Create an account for Ledge\n\nOn the VPS, as your own account:\n\n```sh norun\nsudo adduser --disabled-password --gecos "" ledge\n```\n\nThe account has no password and no `sudo`. Everything Ledge does on this machine runs as this account: the server, the shells, and every block in every note. A key for it that is ever stolen cannot become root.\n\nIf your notes need `sudo`, that is a decision for later, made with `visudo` and as narrow as you can make it.\n\n`adduser` gives the account bash as its login shell, which is one of the two shells Ledge runs blocks in.\n\n## 2. Make a key on your Mac\n\nIn a terminal on your Mac:\n\n```sh norun\nssh-keygen -t ed25519 -f ~/.ssh/ledge -C ledge@laptop\ncat ~/.ssh/ledge.pub\n```\n\nLeave the key\'s passphrase empty, or use one your ssh agent already holds. Ledge\'s ssh runs with no terminal attached, so a passphrase it would have to type at a prompt never gets typed. This is about the key file only: signing in with the account\'s password is a choice on the form, and [[Keep Notes on a Remote Server]] covers it. This tutorial uses a key so that step 8 can turn passwords off.\n\nCopy the printed line, then put it on the VPS as the new account\'s only key. As your own account there, with the line pasted in place of the placeholder:\n\n```sh norun\nsudo install -d -m 700 -o ledge -g ledge /home/ledge/.ssh\necho \'ssh-ed25519 AAAA... ledge@laptop\' | sudo tee /home/ledge/.ssh/authorized_keys\nsudo chown ledge:ledge /home/ledge/.ssh/authorized_keys\nsudo chmod 600 /home/ledge/.ssh/authorized_keys\n```\n\nThe line goes in unrestricted for now. Step 7 restricts it, once you know the server works.\n\n## 3. Install the server\n\nStill on the VPS, as your own account:\n\n```sh norun\ncurl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash\nsudo BUN_INSTALL=/usr/local bun add -g ledge-server\n```\n\nBoth commands carry `BUN_INSTALL=/usr/local`. Bun puts global commands beside itself, and `/usr/local/bin` is on the short PATH an incoming ssh gets. Without the variable, the server lands in a home directory that ssh never searches.\n\nNothing else needs installing and no service needs starting. Ledge starts the server over ssh when it connects, and the server exits a minute after the last device leaves, unless a block is still running.\n\n## 4. Check that ssh can find it\n\nFrom your Mac, as the new account, with the new key:\n\n```sh norun\nssh -i ~/.ssh/ledge ledge@vps \'command -v ledge; command -v bun\'\n```\n\nTwo paths printed means the machine is ready. Keep the first one; step 7 needs it.\n\nNothing printed means Bun was already installed for one user before you started, and its commands are in a directory ssh does not search. [[Keep Notes on a Remote Server]] shows the two symlinks that fix it.\n\n## 5. Add the server in Ledge\n\nRun "Notes On\u2026" from the command palette, choose Add, and fill in the form:\n\n| Field | Value |\n| --- | --- |\n| Name | Whatever you want the connection bar to say |\n| SSH destination | `ledge@vps` |\n| Port | Blank |\n| Sign in with | A key |\n| Key | `~/.ssh/ledge` |\n\nLedge fetches the machine\'s host key and shows its fingerprint. Get the same fingerprint from the machine itself, in your terminal on the VPS:\n\n```sh norun\nssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub\n```\n\nChoose "It Matches, Add" when the two agree. Ledge pins the key and refuses any future connection from that address that presents a different one.\n\n## 6. Try it\n\nThe connection bar now names the server. Press \u2318N, and the note you create is a file on the VPS. Give it one block:\n\n````\n```sh\nhostname; whoami\n```\n````\n\n\u2318\u21A9 prints the VPS\'s hostname and `ledge`. The block ran on the server, as the account you made, and the note never left it.\n\n## 7. Restrict the key to Ledge\n\nEdit `/home/ledge/.ssh/authorized_keys` on the VPS and put a prefix in front of the key, using the path step 4 printed:\n\n```\nrestrict,command="/usr/local/bin/ledge serve" ssh-ed25519 AAAA... ledge@laptop\n```\n\nThat key can now speak Ledge\'s protocol and nothing else: no shell, no port forwarding, no file copying. sshd runs the named command whatever the client asks for, so the terminal check in step 4 stops working for this key. That is expected. Your own account is the one for terminals.\n\nThe connection you already have keeps working. Ledge\'s next connection, at the next launch or after a drop, uses the restricted line.\n\n## 8. Turn off passwords in sshd\n\nA Ledge server runs whatever its notes say, so sshd should answer keys and nothing else. Create `/etc/ssh/sshd_config.d/10-ledge.conf`:\n\n```\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin prohibit-password\n```\n\nThe `10-` matters. sshd keeps the first value it reads for a setting, and it reads this directory in name order. Cloud images ship a `50-cloud-init.conf` that turns passwords on, and a file named after it would lose.\n\nYour own account has to sign in with a key from now on. The provider usually installed one when it created the VPS, and this line from your Mac says whether it did:\n\n```sh norun\nssh -o PasswordAuthentication=no you@vps true\n```\n\nIf it asks for a password, put a key on that account first, with `ssh-copy-id`.\n\nThen check the configuration and reload, keeping your current terminal open until a second one has logged in:\n\n```sh norun\nsudo sshd -t && sudo systemctl reload ssh\n```\n\n## 9. Ban repeated guesses with fail2ban\n\nKeys-only sshd refuses every guess, but a box on the public internet still receives thousands of them a day, and each one costs a log line and a connection slot. fail2ban blocks an address after a few failures.\n\n```sh norun\nsudo apt-get install -y fail2ban\n```\n\nCreate `/etc/fail2ban/jail.local`:\n\n```ini\n[sshd]\nenabled = true\nbackend = systemd\nmaxretry = 5\nbantime = 1h\n```\n\n`backend = systemd` reads sshd\'s log from the journal. Debian 12, Ubuntu 24.04, and anything newer ship without a text `auth.log`, and fail2ban without this line fails to start on them.\n\n```sh norun\nsudo systemctl enable --now fail2ban\nsudo fail2ban-client status sshd\n```\n\nThe second command prints the jail\'s counts. Ledge never trips it: it connects with a key sshd accepts, and reconnects the same way.\n\n## 10. Close every other port\n\nOnly sshd needs to be reachable. Allow it, then turn the firewall on:\n\n```sh norun\nsudo apt-get install -y ufw\nsudo ufw allow 22/tcp\nsudo ufw enable\nsudo ufw status\n```\n\nUbuntu has `ufw` already, and the install line does nothing there.\n\nIf the VPS is on a tailnet or VPN, allow ssh from that interface alone and drop the public rule:\n\n```sh norun\nsudo ufw allow in on tailscale0 to any port 22\nsudo ufw delete allow 22/tcp\n```\n\nThen use the tailnet address as the SSH destination in Ledge. A server nobody else can reach has nothing for fail2ban to do, and the previous step does no harm.\n\n## 11. Keep it patched\n\nSecurity updates for the operating system should install themselves:\n\n```sh norun\nsudo apt-get install -y unattended-upgrades\nsudo dpkg-reconfigure -plow unattended-upgrades\n```\n\nAnswer Yes. Ubuntu ships with this on, and the two commands confirm it.\n\nThe server itself is a package, and updating it is the install line again:\n\n```sh norun\nsudo BUN_INSTALL=/usr/local bun add -g ledge-server@latest\n```\n\nA connection between an app and a server that cannot understand each other is refused with a sentence naming which end to update, so a version that falls behind is reported rather than guessed at.\n\n## Where to go next\n\n- **Back it up.** The notes now live on one disk that belongs to one provider. [[Tutorial: Back Up Your Notes to S3]] puts an encrypted copy in a bucket every hour, with one `ledge backup setup`.\n- **Add your phone.** Its pairing screen hands you a line for this same `authorized_keys`, already restricted ([[Ledge on Your Phone]]).\n- **Install what your notes run.** `git`, a language, a cloud CLI: whatever a block on this machine needs, installed as your own account with `apt-get`.\n- **Reach other machines from it.** A note on the VPS can carry `host: prod`, and the VPS makes that ssh connection with a key in `/home/ledge/.ssh` ([[Run Code on Remote Hosts]]).\n';
7276
+ var _20_tutorial_set_up_a_ledge_server_default = '# Tutorial: Set Up a Ledge Server\n\nTurn a fresh Linux VPS into a Ledge server: an account for Ledge, the server package, a key that can do nothing but Ledge, and an sshd that ignores everyone else.\n\nThis builds on [[Keep Notes on a Remote Server]], which is the reference for every step here. The commands assume Debian or Ubuntu. Any Linux with glibc 2.29 or newer works, so substitute your package manager on anything else.\n\nTwo accounts appear throughout. `you@vps` is the account your provider gave you, which can `sudo`. `ledge@vps` is the account you create in step 1, which cannot.\n\n## 1. Create an account for Ledge\n\nOn the VPS, as your own account:\n\n```sh norun\nsudo adduser --disabled-password --gecos "" ledge\n```\n\nThe account has no password and no `sudo`. Everything Ledge does on this machine runs as this account: the server, the shells, and every block in every note. A key for it that is ever stolen cannot become root.\n\nIf your notes need `sudo`, that is a decision for later, made with `visudo` and as narrow as you can make it.\n\n`adduser` gives the account bash as its login shell, which is one of the two shells Ledge runs blocks in.\n\n## 2. Make a key on your Mac\n\nIn a terminal on your Mac:\n\n```sh norun\nssh-keygen -t ed25519 -f ~/.ssh/ledge -C ledge@laptop\ncat ~/.ssh/ledge.pub\n```\n\nLeave the key\'s passphrase empty, or use one your ssh agent already holds. Ledge\'s ssh runs with no terminal attached, so a passphrase it would have to type at a prompt never gets typed. This is about the key file only: signing in with the account\'s password is a choice on the form, and [[Keep Notes on a Remote Server]] covers it. This tutorial uses a key so that step 8 can turn passwords off.\n\nCopy the printed line, then put it on the VPS as the new account\'s only key. As your own account there, with the line pasted in place of the placeholder:\n\n```sh norun\nsudo install -d -m 700 -o ledge -g ledge /home/ledge/.ssh\necho \'ssh-ed25519 AAAA... ledge@laptop\' | sudo tee /home/ledge/.ssh/authorized_keys\nsudo chown ledge:ledge /home/ledge/.ssh/authorized_keys\nsudo chmod 600 /home/ledge/.ssh/authorized_keys\n```\n\nThe line goes in unrestricted for now. Step 7 restricts it, once you know the server works.\n\n## 3. Install the server\n\nStill on the VPS, as your own account, install the server into the new account\'s home:\n\n```sh norun\ncurl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh\n```\n\nThe installer runs as `ledge` and puts the server, with a Bun of its own, in `/home/ledge/.ledge/.server`. It refuses to run as root, because the server belongs to the account Ledge signs in to, and `sudo -iu ledge` is how your own account runs it as that one.\n\nNothing else needs installing and no service needs starting. Ledge starts the server over ssh when it connects, and the server exits a minute after the last device leaves, unless a block is still running.\n\n## 4. Check that ssh can find it\n\nFrom your Mac, as the new account, with the new key:\n\n```sh norun\nssh -i ~/.ssh/ledge ledge@vps \'PATH=$HOME/.ledge/.server/bin:$PATH command -v ledge\'\n```\n\nA path printed means the machine is ready. This is the same lookup Ledge makes when it connects.\n\nNothing printed means the installer ran as a different account. Run step 3 again exactly as written.\n\n## 5. Add the server in Ledge\n\nRun "Notes On\u2026" from the command palette, choose Add, and fill in the form:\n\n| Field | Value |\n| --- | --- |\n| Name | Whatever you want the connection bar to say |\n| SSH destination | `ledge@vps` |\n| Port | Blank |\n| Sign in with | A key |\n| Key | `~/.ssh/ledge` |\n\nLedge fetches the machine\'s host key and shows its fingerprint. Get the same fingerprint from the machine itself, in your terminal on the VPS:\n\n```sh norun\nssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub\n```\n\nChoose "It Matches, Add" when the two agree. Ledge pins the key and refuses any future connection from that address that presents a different one.\n\n## 6. Try it\n\nThe connection bar now names the server. Press \u2318N, and the note you create is a file on the VPS. Give it one block:\n\n````\n```sh\nhostname; whoami\n```\n````\n\n\u2318\u21A9 prints the VPS\'s hostname and `ledge`. The block ran on the server, as the account you made, and the note never left it.\n\n## 7. Restrict the key to Ledge\n\nEdit `/home/ledge/.ssh/authorized_keys` on the VPS and put a prefix in front of the key:\n\n```\nrestrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ssh-ed25519 AAAA... ledge@laptop\n```\n\nThat key can now speak Ledge\'s protocol and nothing else: no shell, no port forwarding, no file copying. sshd runs the named command whatever the client asks for, so the terminal check in step 4 stops working for this key. That is expected. Your own account is the one for terminals.\n\nThe connection you already have keeps working. Ledge\'s next connection, at the next launch or after a drop, uses the restricted line.\n\n## 8. Turn off passwords in sshd\n\nA Ledge server runs whatever its notes say, so sshd should answer keys and nothing else. Create `/etc/ssh/sshd_config.d/10-ledge.conf`:\n\n```\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin prohibit-password\n```\n\nThe `10-` matters. sshd keeps the first value it reads for a setting, and it reads this directory in name order. Cloud images ship a `50-cloud-init.conf` that turns passwords on, and a file named after it would lose.\n\nYour own account has to sign in with a key from now on. The provider usually installed one when it created the VPS, and this line from your Mac says whether it did:\n\n```sh norun\nssh -o PasswordAuthentication=no you@vps true\n```\n\nIf it asks for a password, put a key on that account first, with `ssh-copy-id`.\n\nThen check the configuration and reload, keeping your current terminal open until a second one has logged in:\n\n```sh norun\nsudo sshd -t && sudo systemctl reload ssh\n```\n\n## 9. Ban repeated guesses with fail2ban\n\nKeys-only sshd refuses every guess, but a box on the public internet still receives thousands of them a day, and each one costs a log line and a connection slot. fail2ban blocks an address after a few failures.\n\n```sh norun\nsudo apt-get install -y fail2ban\n```\n\nCreate `/etc/fail2ban/jail.local`:\n\n```ini\n[sshd]\nenabled = true\nbackend = systemd\nmaxretry = 5\nbantime = 1h\n```\n\n`backend = systemd` reads sshd\'s log from the journal. Debian 12, Ubuntu 24.04, and anything newer ship without a text `auth.log`, and fail2ban without this line fails to start on them.\n\n```sh norun\nsudo systemctl enable --now fail2ban\nsudo fail2ban-client status sshd\n```\n\nThe second command prints the jail\'s counts. Ledge never trips it: it connects with a key sshd accepts, and reconnects the same way.\n\n## 10. Close every other port\n\nOnly sshd needs to be reachable. Allow it, then turn the firewall on:\n\n```sh norun\nsudo apt-get install -y ufw\nsudo ufw allow 22/tcp\nsudo ufw enable\nsudo ufw status\n```\n\nUbuntu has `ufw` already, and the install line does nothing there.\n\nIf the VPS is on a tailnet or VPN, allow ssh from that interface alone and drop the public rule:\n\n```sh norun\nsudo ufw allow in on tailscale0 to any port 22\nsudo ufw delete allow 22/tcp\n```\n\nThen use the tailnet address as the SSH destination in Ledge. A server nobody else can reach has nothing for fail2ban to do, and the previous step does no harm.\n\n## 11. Keep it patched\n\nSecurity updates for the operating system should install themselves:\n\n```sh norun\nsudo apt-get install -y unattended-upgrades\nsudo dpkg-reconfigure -plow unattended-upgrades\n```\n\nAnswer Yes. Ubuntu ships with this on, and the two commands confirm it.\n\nThe server updates with the install line from step 3, run again:\n\n```sh norun\ncurl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh\n```\n\nA connection between an app and a server that cannot understand each other is refused with a sentence naming which end to update, so a version that falls behind is reported rather than guessed at.\n\n## Where to go next\n\n- **Back it up.** The notes now live on one disk that belongs to one provider. [[Tutorial: Back Up Your Notes to S3]] puts an encrypted copy in a bucket every hour, with one `ledge backup setup`.\n- **Add your phone.** Its pairing screen hands you a line for this same `authorized_keys`, already restricted ([[Ledge on Your Phone]]).\n- **Install what your notes run.** `git`, a language, a cloud CLI: whatever a block on this machine needs, installed as your own account with `apt-get`.\n- **Reach other machines from it.** A note on the VPS can carry `host: prod`, and the VPS makes that ssh connection with a key in `/home/ledge/.ssh` ([[Run Code on Remote Hosts]]).\n';
7121
7277
 
7122
7278
  // docs/user/21-tutorial-back-up-your-notes-to-s3.md
7123
7279
  var _21_tutorial_back_up_your_notes_to_s3_default = `# Tutorial: Back Up Your Notes to S3
@@ -7158,9 +7314,9 @@ It asks for the endpoint, the bucket, the access key ID, and the secret. Then it
7158
7314
  | 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
7315
  | Repository | Creates the restic repository in the bucket. |
7160
7316
  | First backup | Backs up everything \`ledge backup paths\` lists: the app home, every folder attached from elsewhere on the machine, and the profiles. |
7161
- | Password | Prints the password on its last line. |
7317
+ | Password | Prints the password, the one thing \`setup\` writes to stdout. |
7162
7318
 
7163
- The password is what encrypts the backup, and it is the only key. Copy it somewhere that is not this machine, such as a password manager. A restore starts on a machine with nothing on it, and a password stored only inside the backup is a backup you cannot open.
7319
+ 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
7320
 
7165
7321
  ## 3. Leave it running
7166
7322
 
@@ -7171,7 +7327,7 @@ On a Mac, the server is up while the app is open and for a minute after it close
7171
7327
  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
7328
 
7173
7329
  \`\`\`sh norun
7174
- 0 * * * * /usr/local/bin/ledge backup now
7330
+ 0 * * * * $HOME/.ledge/.server/bin/ledge backup now
7175
7331
  \`\`\`
7176
7332
 
7177
7333
  \`ledge backup now\` takes a backup at any time and is safe to run beside the schedule.
@@ -9571,7 +9727,7 @@ var RETIRED_DIRNAME = ".retired";
9571
9727
  var tmpCounter5 = 0;
9572
9728
  async function writePage(path, text) {
9573
9729
  tmpCounter5 += 1;
9574
- const tmp = join13(resolve9(DOCS_ROOT), `.${basename5(path)}.tmp-${process.pid}-${tmpCounter5}`);
9730
+ const tmp = join13(resolve9(DOCS_ROOT), `.${basename6(path)}.tmp-${process.pid}-${tmpCounter5}`);
9575
9731
  try {
9576
9732
  await writeFile8(tmp, text, "utf8");
9577
9733
  await rename8(tmp, path);
@@ -9622,7 +9778,7 @@ async function syncDocs(pages = DOC_PAGES) {
9622
9778
  }
9623
9779
 
9624
9780
  // src/bun/layout.ts
9625
- import { basename as basename6, join as join14 } from "path";
9781
+ import { basename as basename7, join as join14 } from "path";
9626
9782
  import { readFile as readFile10, rename as rename9, unlink as unlink8, writeFile as writeFile9 } from "fs/promises";
9627
9783
  var LAYOUT_PATH = join14(APP_HOME, ".layout.json");
9628
9784
  var ANONYMOUS = "_";
@@ -9652,7 +9808,7 @@ async function writeLayout(client, text) {
9652
9808
  const next = { ...base, [key(client)]: value };
9653
9809
  await ensureAppHome();
9654
9810
  tmpCounter6 += 1;
9655
- const tmp = join14(APP_HOME, `.${basename6(LAYOUT_PATH)}.tmp-${process.pid}-${tmpCounter6}`);
9811
+ const tmp = join14(APP_HOME, `.${basename7(LAYOUT_PATH)}.tmp-${process.pid}-${tmpCounter6}`);
9656
9812
  try {
9657
9813
  await writeFile9(tmp, JSON.stringify(next), "utf8");
9658
9814
  await rename9(tmp, LAYOUT_PATH);
@@ -9708,9 +9864,9 @@ function sizeOf(path) {
9708
9864
  }
9709
9865
  var logPath = LOG_PATH;
9710
9866
  var prevPath = PREV_LOG_PATH;
9711
- function logToFile(basename7) {
9712
- logPath = join15(LOG_DIR, `${basename7}.log`);
9713
- prevPath = join15(LOG_DIR, `${basename7}.previous.log`);
9867
+ function logToFile(basename) {
9868
+ logPath = join15(LOG_DIR, `${basename}.log`);
9869
+ prevPath = join15(LOG_DIR, `${basename}.previous.log`);
9714
9870
  }
9715
9871
  function rotate() {
9716
9872
  try {
@@ -9741,12 +9897,12 @@ function write(source, level, args) {
9741
9897
  append(formatLine(new Date, source, level, args));
9742
9898
  }
9743
9899
  var patched = false;
9744
- function startLogging(basename7) {
9900
+ function startLogging(basename) {
9745
9901
  if (patched)
9746
9902
  return;
9747
9903
  patched = true;
9748
- if (basename7)
9749
- logToFile(basename7);
9904
+ if (basename)
9905
+ logToFile(basename);
9750
9906
  rotate();
9751
9907
  const levels = [
9752
9908
  ["log", "info"],
@@ -9780,13 +9936,13 @@ function relevantChange(filename) {
9780
9936
  if (filename === null)
9781
9937
  return true;
9782
9938
  const segments = filename.split("/");
9783
- if (segments.slice(0, -1).some((s2) => s2.startsWith(".")))
9939
+ if (segments.slice(0, -1).some((s) => s.startsWith(".")))
9784
9940
  return false;
9785
9941
  return /\.md(\.|$)/i.test(segments[segments.length - 1]);
9786
9942
  }
9787
9943
  var watchers = new Map;
9788
- function syncWatchers(roots2, onChange) {
9789
- const want = new Set(roots2);
9944
+ function syncWatchers(roots, onChange) {
9945
+ const want = new Set(roots);
9790
9946
  for (const [root, w] of watchers) {
9791
9947
  if (want.has(root))
9792
9948
  continue;
@@ -9846,14 +10002,14 @@ function bundledBun(execPath) {
9846
10002
  return /(^|\/)bun$/.test(execPath) ? execPath : "";
9847
10003
  }
9848
10004
  function runnerFor(id, lang, code, interpreters, bunPath, remote = false) {
9849
- const key2 = (lang ?? "").toLowerCase();
9850
- const interpreter = interpreters[key2];
10005
+ const key = (lang ?? "").toLowerCase();
10006
+ const interpreter = interpreters[key];
9851
10007
  if (!interpreter) {
9852
- const path2 = `/tmp/ledge-run-${id}.sh`;
9853
- const command = remote ? remoteWrite(code, path2, `source ${path2}`) : `source ${path2}`;
9854
- return { kind: "shell", path: path2, contents: code, command, remote };
10008
+ const path = `/tmp/ledge-run-${id}.sh`;
10009
+ const command = remote ? remoteWrite(code, path, `source ${path}`) : `source ${path}`;
10010
+ return { kind: "shell", path, contents: code, command, remote };
9855
10011
  }
9856
- const ext = EXT[key2] ?? (key2.replace(/[^a-z0-9]/g, "") || "txt");
10012
+ const ext = EXT[key] ?? (key.replace(/[^a-z0-9]/g, "") || "txt");
9857
10013
  const path = `/tmp/ledge-run-${id}.${ext}`;
9858
10014
  const contents = ext === "php" && !/^\s*<\?/.test(code) ? `<?php
9859
10015
  ${code}` : code;
@@ -9877,8 +10033,8 @@ function hostGlobMatches(pattern, host) {
9877
10033
  const rx = pattern.split("*").map(escapeRegex).join(".*");
9878
10034
  return new RegExp(`^${rx}$`).test(host);
9879
10035
  }
9880
- function escapeRegex(s2) {
9881
- return s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10036
+ function escapeRegex(s) {
10037
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9882
10038
  }
9883
10039
 
9884
10040
  // src/bun/remoteSpawn.ts
@@ -9899,11 +10055,11 @@ function buildRemoteSpawn(host, kind, params, warn) {
9899
10055
  const parts = [];
9900
10056
  if (params?.cwd)
9901
10057
  parts.push(remoteCd(params.cwd));
9902
- for (const [key2, value] of Object.entries(params?.env ?? {})) {
9903
- if (isEnvName(key2) && typeof value === "string") {
9904
- parts.push(`export ${key2}=${shellQuote(value)}`);
10058
+ for (const [key, value] of Object.entries(params?.env ?? {})) {
10059
+ if (isEnvName(key) && typeof value === "string") {
10060
+ parts.push(`export ${key}=${shellQuote(value)}`);
9905
10061
  } else {
9906
- warn(`ignoring unusable env entry "${key2}"`);
10062
+ warn(`ignoring unusable env entry "${key}"`);
9907
10063
  }
9908
10064
  }
9909
10065
  if (kind === "inline") {
@@ -9921,6 +10077,97 @@ function remoteCd(cwd) {
9921
10077
  return `cd -- ${target} 2>/dev/null || printf 'ledge: cwd %s not found here; starting in %s\\n' ${shellQuote(cwd)} "$PWD"`;
9922
10078
  }
9923
10079
 
10080
+ // src/bun/loginEnv.ts
10081
+ import { homedir as homedir6 } from "os";
10082
+ var LOGIN_ENV_TIMEOUT_MS = 5000;
10083
+ var RESOLVING_VAR = "LEDGE_RESOLVING_ENVIRONMENT";
10084
+ var DROPPED = new Set(["PWD", "OLDPWD", "SHLVL", "_", RESOLVING_VAR]);
10085
+ function envCommand(nonce) {
10086
+ return `printf '%s' '${nonce}<'; /usr/bin/env -0; printf '%s' '>${nonce}'`;
10087
+ }
10088
+ function parseEnvOutput(output, nonce) {
10089
+ const start = output.indexOf(`${nonce}<`);
10090
+ if (start < 0)
10091
+ return null;
10092
+ const from = start + nonce.length + 1;
10093
+ const end = output.lastIndexOf(`>${nonce}`);
10094
+ if (end < from)
10095
+ return null;
10096
+ const env = {};
10097
+ for (const entry of output.slice(from, end).split("\x00")) {
10098
+ const eq = entry.indexOf("=");
10099
+ if (eq > 0)
10100
+ env[entry.slice(0, eq)] = entry.slice(eq + 1);
10101
+ }
10102
+ return env;
10103
+ }
10104
+ function cleanLoginEnv(env) {
10105
+ const out = {};
10106
+ for (const [key, value] of Object.entries(env)) {
10107
+ if (!DROPPED.has(key))
10108
+ out[key] = value;
10109
+ }
10110
+ if (out["PATH"] !== undefined)
10111
+ out["PATH"] = dedupePath(out["PATH"]);
10112
+ return out;
10113
+ }
10114
+ function dedupePath(path) {
10115
+ const seen = new Set;
10116
+ return path.split(":").filter((dir) => {
10117
+ if (dir === "" || seen.has(dir))
10118
+ return false;
10119
+ seen.add(dir);
10120
+ return true;
10121
+ }).join(":");
10122
+ }
10123
+ async function resolveLoginEnv(shellPath, base, opts = {}) {
10124
+ const fallback = definedOnly(base);
10125
+ if (opts.skip ?? Boolean(process.env["LEDGE_SKIP_LOGIN_ENV"]))
10126
+ return fallback;
10127
+ const warn = opts.warn ?? ((msg) => console.warn("[loginEnv]", msg));
10128
+ const timeoutMs = opts.timeoutMs ?? LOGIN_ENV_TIMEOUT_MS;
10129
+ const nonce = `ledge-env-${crypto.randomUUID()}`;
10130
+ let proc;
10131
+ try {
10132
+ proc = Bun.spawn({
10133
+ cmd: [shellPath, "-l", "-c", envCommand(nonce)],
10134
+ env: { ...fallback, [RESOLVING_VAR]: "1" },
10135
+ cwd: opts.cwd ?? homedir6(),
10136
+ stdin: "ignore",
10137
+ stdout: "pipe",
10138
+ stderr: "ignore"
10139
+ });
10140
+ } catch (err) {
10141
+ warn(`could not start ${shellPath} to read the login environment (${err}); using the app's own`);
10142
+ return fallback;
10143
+ }
10144
+ let timer;
10145
+ const timedOut = new Promise((resolve) => {
10146
+ timer = setTimeout(() => resolve(null), timeoutMs);
10147
+ });
10148
+ const output = await Promise.race([new Response(proc.stdout).text(), timedOut]);
10149
+ clearTimeout(timer);
10150
+ if (output === null) {
10151
+ proc.kill("SIGKILL");
10152
+ warn(`${shellPath} -l took over ${timeoutMs} ms to start; using the app's own environment`);
10153
+ return fallback;
10154
+ }
10155
+ const env = parseEnvOutput(output, nonce);
10156
+ if (!env) {
10157
+ warn(`${shellPath} -l printed no environment; using the app's own`);
10158
+ return fallback;
10159
+ }
10160
+ return cleanLoginEnv(env);
10161
+ }
10162
+ function definedOnly(env) {
10163
+ const out = {};
10164
+ for (const [key, value] of Object.entries(env)) {
10165
+ if (value !== undefined)
10166
+ out[key] = value;
10167
+ }
10168
+ return out;
10169
+ }
10170
+
9924
10171
  // src/bun/server.ts
9925
10172
  import { readFileSync, statSync as statSync2 } from "fs";
9926
10173
  function holdRunEvent(held, ev, cap) {
@@ -9994,12 +10241,18 @@ var fromB64 = (b64) => new Uint8Array(Buffer.from(b64, "base64"));
9994
10241
  async function createServer(deps) {
9995
10242
  const { push } = deps;
9996
10243
  const settings = await loadSettings();
10244
+ const loginEnv = resolveLoginEnv(settings.shell.path, process.env);
9997
10245
  await loadWorkspaces();
9998
- await ensureDefault();
10246
+ const first = await ensureDefault();
10247
+ if (first) {
10248
+ await createNote(first, WELCOME_DOC).catch((err) => console.warn("[server] could not write the welcome note", err));
10249
+ }
9999
10250
  await syncDocs();
10000
10251
  await loadVault();
10001
- const shellEnv = { ...process.env, TERM: "xterm-256color" };
10252
+ const shellEnv = { ...await loginEnv, TERM: "xterm-256color" };
10002
10253
  const sessionParams = new Map;
10254
+ const spawnKeys = new Map;
10255
+ const sentStale = new Map;
10003
10256
  const sessionFacts = new Map;
10004
10257
  const spawnDeps = {
10005
10258
  readFile: (path) => {
@@ -10030,14 +10283,23 @@ async function createServer(deps) {
10030
10283
  }
10031
10284
  return requested;
10032
10285
  }
10033
- function spawnShell(sessionId, host, kind) {
10286
+ function spawnShell(sessionId, host, kind, persistent = true) {
10287
+ if (persistent) {
10288
+ const born = spawnKeys.get(sessionId) ?? new Map;
10289
+ born.set(kind === "terminal" ? "terminal" : `inline:${host}`, {
10290
+ host,
10291
+ key: spawnKeyOf(sessionParams.get(sessionId), host)
10292
+ });
10293
+ spawnKeys.set(sessionId, born);
10294
+ queueMicrotask(() => refreshStale(sessionId));
10295
+ }
10034
10296
  if (host !== LOCAL_HOST) {
10035
10297
  const remote = buildRemoteSpawn(host, kind, sessionParams.get(sessionId), (msg) => console.warn("[session]", msg));
10036
10298
  return new PtyProcess({
10037
10299
  executable: remote.executable,
10038
10300
  args: remote.args,
10039
10301
  env: shellEnv,
10040
- cwd: homedir6(),
10302
+ cwd: homedir7(),
10041
10303
  interruptViaChar: true
10042
10304
  });
10043
10305
  }
@@ -10053,7 +10315,7 @@ async function createServer(deps) {
10053
10315
  cwd
10054
10316
  });
10055
10317
  }
10056
- const inlinePool = new InlinePool((sessionId, host) => spawnShell(sessionId, host, "inline"), NONCE);
10318
+ const inlinePool = new InlinePool((sessionId, host, persistent) => spawnShell(sessionId, host, "inline", persistent), NONCE);
10057
10319
  const terms = new Map;
10058
10320
  let nextTermRunId = 1;
10059
10321
  function termFor(sessionId, requestedHost) {
@@ -10078,6 +10340,28 @@ async function createServer(deps) {
10078
10340
  }
10079
10341
  return t;
10080
10342
  }
10343
+ function staleFor(sessionId) {
10344
+ const born = spawnKeys.get(sessionId);
10345
+ if (!born)
10346
+ return false;
10347
+ const params = sessionParams.get(sessionId);
10348
+ const live = inlinePool.primaryHosts(sessionId).map((host) => `inline:${host}`);
10349
+ if (terms.get(sessionId)?.term.exited === false)
10350
+ live.push("terminal");
10351
+ for (const at of live) {
10352
+ const was = born.get(at);
10353
+ if (was && was.key !== spawnKeyOf(params, was.host))
10354
+ return true;
10355
+ }
10356
+ return false;
10357
+ }
10358
+ function refreshStale(sessionId) {
10359
+ const stale = staleFor(sessionId);
10360
+ if (stale === (sentStale.get(sessionId) ?? false))
10361
+ return;
10362
+ sentStale.set(sessionId, stale);
10363
+ push.all.sessionStale({ sessionId, stale });
10364
+ }
10081
10365
  function flushPaste(t, now = Date.now()) {
10082
10366
  const out = takePaste(t, now);
10083
10367
  if (out !== null)
@@ -10114,6 +10398,8 @@ async function createServer(deps) {
10114
10398
  terms.delete(sessionId);
10115
10399
  sessionParams.delete(sessionId);
10116
10400
  sessionFacts.delete(sessionId);
10401
+ spawnKeys.delete(sessionId);
10402
+ sentStale.delete(sessionId);
10117
10403
  }
10118
10404
  function refreshWatchers() {
10119
10405
  syncWatchers(availableRoots(), (root) => push.all.notesChanged({ root }));
@@ -10124,13 +10410,13 @@ async function createServer(deps) {
10124
10410
  return;
10125
10411
  openRequestWatcherStarted = true;
10126
10412
  try {
10127
- const requestName = basename7(OPEN_REQUEST_PATH);
10413
+ const requestName = basename8(OPEN_REQUEST_PATH);
10128
10414
  watch2(APP_HOME, (_event, filename) => {
10129
10415
  if (filename !== requestName)
10130
10416
  return;
10131
- takeOpenRequest().then((open2) => {
10132
- if (open2 !== null)
10133
- push.all.openExternal(open2);
10417
+ takeOpenRequest().then((open) => {
10418
+ if (open !== null)
10419
+ push.all.openExternal(open);
10134
10420
  });
10135
10421
  });
10136
10422
  } catch (err) {
@@ -10188,6 +10474,7 @@ async function createServer(deps) {
10188
10474
  "noteFromTemplate",
10189
10475
  "noteLock",
10190
10476
  "noteMove",
10477
+ "noteMoveToWorkspace",
10191
10478
  "noteRemoveLock",
10192
10479
  "noteRetitle",
10193
10480
  "noteStash",
@@ -10269,6 +10556,12 @@ async function createServer(deps) {
10269
10556
  await refuseLockedFrom(device, path);
10270
10557
  return { note: await moveNote(path, folder) };
10271
10558
  },
10559
+ noteMoveToWorkspace: async ({ path, root }) => {
10560
+ await refuseLockedFrom(device, path);
10561
+ const crossing = root !== rootContaining(path);
10562
+ const backlinks = crossing ? new Set((await backlinksTo(path)).backlinks.map((b) => b.path)).size : 0;
10563
+ return { note: await moveNote(path, null, root), backlinks };
10564
+ },
10272
10565
  folderRename: ({ root, folder, name }) => renameFolder(root, folder, name),
10273
10566
  folderDelete: ({ root, folder }) => deleteFolder(root, folder),
10274
10567
  noteRetitle: async ({ path, text }) => ({ note: await retitleNote(path, text) }),
@@ -10446,10 +10739,13 @@ async function createServer(deps) {
10446
10739
  sessionFacts.set(sessionId, { note: resolve10(notePath), workspace: root });
10447
10740
  else
10448
10741
  sessionFacts.delete(sessionId);
10742
+ refreshStale(sessionId);
10449
10743
  return { ok: true };
10450
10744
  },
10451
10745
  sessionRestart: ({ sessionId }) => {
10452
10746
  inlinePool.restartSession(sessionId, sendRunEvent);
10747
+ spawnKeys.delete(sessionId);
10748
+ refreshStale(sessionId);
10453
10749
  wake();
10454
10750
  const t = terms.get(sessionId);
10455
10751
  if (t) {
@@ -10493,9 +10789,9 @@ async function createServer(deps) {
10493
10789
  return { ok: true };
10494
10790
  },
10495
10791
  openRequestTake: async () => {
10496
- const open2 = await takeOpenRequest();
10792
+ const open = await takeOpenRequest();
10497
10793
  startOpenRequestWatcher();
10498
- return { open: open2 };
10794
+ return { open };
10499
10795
  },
10500
10796
  logAppend: async ({ level, text }) => {
10501
10797
  write("view", level, [text.slice(0, LOG_TEXT_CAP)]);
@@ -10559,6 +10855,9 @@ async function createServer(deps) {
10559
10855
  terms.delete(sessionId);
10560
10856
  }
10561
10857
  }
10858
+ for (const [sessionId, stale] of sentStale)
10859
+ if (stale)
10860
+ refreshStale(sessionId);
10562
10861
  if (awake)
10563
10862
  lastBusyAt = now;
10564
10863
  pace(now - lastBusyAt < DRAIN_SETTLE_MS ? DRAIN_FAST_MS : DRAIN_IDLE_MS);
@@ -10617,6 +10916,7 @@ var REQUEST_METHODS = [
10617
10916
  "noteWrite",
10618
10917
  "noteCreate",
10619
10918
  "noteMove",
10919
+ "noteMoveToWorkspace",
10620
10920
  "folderRename",
10621
10921
  "folderDelete",
10622
10922
  "noteRetitle",
@@ -10698,6 +10998,7 @@ var PUSH_MESSAGES = [
10698
10998
  "terminalBusy",
10699
10999
  "terminalExit",
10700
11000
  "terminalDetached",
11001
+ "sessionStale",
10701
11002
  "presence",
10702
11003
  "notesChanged",
10703
11004
  "openExternal",
@@ -10776,10 +11077,10 @@ function restoreBinary(payload, path, bytes) {
10776
11077
  }
10777
11078
  function walk(payload, path) {
10778
11079
  let at = payload;
10779
- for (const key2 of path) {
11080
+ for (const key of path) {
10780
11081
  if (typeof at !== "object" || at === null)
10781
11082
  return null;
10782
- at = at[key2];
11083
+ at = at[key];
10783
11084
  }
10784
11085
  return { value: at };
10785
11086
  }
@@ -10954,8 +11255,8 @@ function names(v) {
10954
11255
  return v.filter((n) => typeof n === "string").slice(0, MAX_DECLARED_NAMES);
10955
11256
  }
10956
11257
  var MAX_DECLARED_NAMES = 512;
10957
- function opt(key2, v) {
10958
- return typeof v === "string" ? { [key2]: v } : {};
11258
+ function opt(key, v) {
11259
+ return typeof v === "string" ? { [key]: v } : {};
10959
11260
  }
10960
11261
  function bin(v) {
10961
11262
  return isId(v) ? { bin: v } : {};
@@ -11002,15 +11303,15 @@ function concat2(a, b) {
11002
11303
  out.set(b, a.length);
11003
11304
  return out;
11004
11305
  }
11005
- function writeMessage(write2, msg, kind, method) {
11306
+ function writeMessage(write, msg, kind, method) {
11006
11307
  const path = binaryPath(kind, method);
11007
11308
  const body = msg.t === "req" ? msg.p : msg.t === "res" ? msg.r : msg.t === "push" ? msg.p : null;
11008
11309
  const hoisted = path && body !== null ? hoistBinary(body, path) : null;
11009
11310
  if (!hoisted)
11010
- return write2(encodeControl(msg));
11011
- const bin2 = nextBinaryId();
11012
- write2(encodeBinary(bin2, hoisted.bytes));
11013
- write2(encodeControl(msg.t === "req" ? { ...msg, p: hoisted.payload, bin: bin2 } : msg.t === "res" ? { ...msg, r: hoisted.payload, bin: bin2 } : { ...msg, p: hoisted.payload, bin: bin2 }));
11311
+ return write(encodeControl(msg));
11312
+ const bin = nextBinaryId();
11313
+ write(encodeBinary(bin, hoisted.bytes));
11314
+ write(encodeControl(msg.t === "req" ? { ...msg, p: hoisted.payload, bin } : msg.t === "res" ? { ...msg, r: hoisted.payload, bin } : { ...msg, p: hoisted.payload, bin }));
11014
11315
  }
11015
11316
  var binaryId = 0;
11016
11317
  function nextBinaryId() {
@@ -11020,19 +11321,19 @@ function nextBinaryId() {
11020
11321
 
11021
11322
  class BinaryHolder {
11022
11323
  held = null;
11023
- hold(frame2) {
11324
+ hold(frame) {
11024
11325
  if (this.held)
11025
11326
  throw new WireError("the peer sent two binary frames with no control frame between them");
11026
- this.held = { id: frame2.id, bytes: frame2.bytes };
11327
+ this.held = { id: frame.id, bytes: frame.bytes };
11027
11328
  }
11028
11329
  claim(msg, kind, method) {
11029
- const bin2 = msg.t === "req" || msg.t === "res" || msg.t === "push" ? msg.bin : undefined;
11330
+ const bin = msg.t === "req" || msg.t === "res" || msg.t === "push" ? msg.bin : undefined;
11030
11331
  const body = msg.t === "req" ? msg.p : msg.t === "res" ? msg.r : msg.t === "push" ? msg.p : null;
11031
- if (bin2 === undefined)
11332
+ if (bin === undefined)
11032
11333
  return body;
11033
11334
  const held = this.held;
11034
11335
  this.held = null;
11035
- if (!held || held.id !== bin2)
11336
+ if (!held || held.id !== bin)
11036
11337
  throw new WireError("the peer claimed a binary frame that did not arrive");
11037
11338
  const path = binaryPath(kind, method);
11038
11339
  if (!path)
@@ -11114,7 +11415,7 @@ function serverConnection(duplex, opts) {
11114
11415
  const silentMs = opts.silentMs ?? SILENT_MS2;
11115
11416
  let heardFromClient = false;
11116
11417
  let stopWatching = null;
11117
- const decoder2 = new FrameDecoder;
11418
+ const decoder = new FrameDecoder;
11118
11419
  const incoming = new BinaryHolder;
11119
11420
  let handlers = null;
11120
11421
  let greeted = false;
@@ -11122,10 +11423,10 @@ function serverConnection(duplex, opts) {
11122
11423
  let peerDevice = "";
11123
11424
  let peerLabel = "";
11124
11425
  let peerHold = 0;
11125
- let open2 = true;
11426
+ let open = true;
11126
11427
  const waiting = [];
11127
11428
  let settle;
11128
- const closed = new Promise((resolve11) => settle = resolve11);
11429
+ const closed = new Promise((resolve) => settle = resolve);
11129
11430
  function raw(bytes) {
11130
11431
  try {
11131
11432
  duplex.write(bytes);
@@ -11135,7 +11436,7 @@ function serverConnection(duplex, opts) {
11135
11436
  }
11136
11437
  }
11137
11438
  function send(msg, method = "") {
11138
- if (!open2)
11439
+ if (!open)
11139
11440
  return;
11140
11441
  if (msg.t !== "push" || msg.m !== "terminalOutput")
11141
11442
  flushOutput();
@@ -11145,11 +11446,11 @@ function serverConnection(duplex, opts) {
11145
11446
  raw(encodeControl(msg));
11146
11447
  }
11147
11448
  function close(why, back = false) {
11148
- if (!open2)
11449
+ if (!open)
11149
11450
  return;
11150
11451
  if (why !== undefined)
11151
11452
  send({ t: "bye", why, ...back ? { back: true } : {} });
11152
- open2 = false;
11453
+ open = false;
11153
11454
  stopWatching?.();
11154
11455
  stopWatching = null;
11155
11456
  stopCoalescing();
@@ -11188,7 +11489,7 @@ function serverConnection(duplex, opts) {
11188
11489
  heldBytes = 0;
11189
11490
  }
11190
11491
  function pushOutput(p) {
11191
- if (!open2)
11492
+ if (!open)
11192
11493
  return;
11193
11494
  const bytes = fromBase64(p.dataB64);
11194
11495
  if (bytes.length === 0)
@@ -11256,18 +11557,18 @@ function serverConnection(duplex, opts) {
11256
11557
  heardFromClient = true;
11257
11558
  let frames;
11258
11559
  try {
11259
- frames = decoder2.push(chunk);
11560
+ frames = decoder.push(chunk);
11260
11561
  } catch (err) {
11261
11562
  console.error("[wire]", err instanceof Error ? err.message : err);
11262
11563
  return close(err instanceof WireError ? err.message : "unreadable frame");
11263
11564
  }
11264
- for (const frame2 of frames) {
11565
+ for (const frame of frames) {
11265
11566
  try {
11266
- if (frame2.type === 1) {
11267
- incoming.hold(frame2);
11567
+ if (frame.type === 1) {
11568
+ incoming.hold(frame);
11268
11569
  continue;
11269
11570
  }
11270
- handle(parseControl(frame2.text));
11571
+ handle(parseControl(frame.text));
11271
11572
  if (!incoming.idle())
11272
11573
  throw new WireError("the peer sent bytes that no control frame claimed");
11273
11574
  } catch (err) {
@@ -11277,7 +11578,7 @@ function serverConnection(duplex, opts) {
11277
11578
  }
11278
11579
  };
11279
11580
  duplex.onClose = () => {
11280
- open2 = false;
11581
+ open = false;
11281
11582
  stopWatching?.();
11282
11583
  stopWatching = null;
11283
11584
  stopCoalescing();
@@ -11448,13 +11749,13 @@ function createOpLog(opts) {
11448
11749
  }
11449
11750
  }
11450
11751
  return {
11451
- run(key2, exec) {
11452
- const hit = seen.get(key2);
11752
+ run(key, exec) {
11753
+ const hit = seen.get(key);
11453
11754
  if (hit)
11454
11755
  return hit.result;
11455
11756
  const result = exec();
11456
11757
  result.catch(() => {});
11457
- seen.set(key2, { at: now(), result });
11758
+ seen.set(key, { at: now(), result });
11458
11759
  evict();
11459
11760
  return result;
11460
11761
  },
@@ -11463,7 +11764,7 @@ function createOpLog(opts) {
11463
11764
  }
11464
11765
 
11465
11766
  // src/shared/version.ts
11466
- var BUILD_VERSION = "0.0.3";
11767
+ var BUILD_VERSION = "0.1.1";
11467
11768
 
11468
11769
  // src/bun/daemon.ts
11469
11770
  var SOCKET_PATH = join16(APP_HOME, ".server.sock");
@@ -11494,10 +11795,10 @@ async function startDaemon(opts = {}) {
11494
11795
  const ops = createOpLog();
11495
11796
  const instance = crypto.randomUUID();
11496
11797
  const server = await createServer({ push });
11497
- let idleTimer2 = null;
11798
+ let idleTimer = null;
11498
11799
  let heldUntil = 0;
11499
11800
  let settleDone;
11500
- const done = new Promise((resolve11) => settleDone = resolve11);
11801
+ const done = new Promise((resolve) => settleDone = resolve);
11501
11802
  let stopped = false;
11502
11803
  const listener = Bun.listen({
11503
11804
  unix: socketPath,
@@ -11542,9 +11843,9 @@ async function startDaemon(opts = {}) {
11542
11843
  console.error("[daemon] could not write the pid file:", err);
11543
11844
  }
11544
11845
  function accept(io) {
11545
- if (idleTimer2) {
11546
- clearTimeout(idleTimer2);
11547
- idleTimer2 = null;
11846
+ if (idleTimer) {
11847
+ clearTimeout(idleTimer);
11848
+ idleTimer = null;
11548
11849
  }
11549
11850
  const greet = () => {
11550
11851
  const id = conn.client();
@@ -11574,15 +11875,15 @@ async function startDaemon(opts = {}) {
11574
11875
  }
11575
11876
  let leaving = false;
11576
11877
  function armIdleExit() {
11577
- if (stopped || idleTimer2 || leaving || idleMs <= 0)
11878
+ if (stopped || idleTimer || leaving || idleMs <= 0)
11578
11879
  return;
11579
11880
  const held = server.sessionsOpen() ? heldUntil - Date.now() : 0;
11580
11881
  const wait = Math.max(idleMs, held);
11581
11882
  if (wait !== idleMs) {
11582
11883
  console.error(`[daemon] holding sessions for ${wait >= 1e4 ? `${Math.round(wait / 1000)}s` : `${wait}ms`}`);
11583
11884
  }
11584
- idleTimer2 = setTimeout(async () => {
11585
- idleTimer2 = null;
11885
+ idleTimer = setTimeout(async () => {
11886
+ idleTimer = null;
11586
11887
  if (clients.size > 0)
11587
11888
  return;
11588
11889
  if (server.running())
@@ -11626,8 +11927,8 @@ async function startDaemon(opts = {}) {
11626
11927
  if (stopped)
11627
11928
  return;
11628
11929
  stopped = true;
11629
- if (idleTimer2)
11630
- clearTimeout(idleTimer2);
11930
+ if (idleTimer)
11931
+ clearTimeout(idleTimer);
11631
11932
  for (const conn of accepted) {
11632
11933
  if (clients.get(conn.client()) === conn)
11633
11934
  conn.close("this server is shutting down", true);
@@ -11685,9 +11986,9 @@ async function tryConnect(socketPath) {
11685
11986
  socket: {
11686
11987
  data: (_s, chunk) => io?.feed(new Uint8Array(chunk)),
11687
11988
  drain: () => out?.drain(),
11688
- end: (s2) => {
11989
+ end: (s) => {
11689
11990
  io?.finish();
11690
- s2.end();
11991
+ s.end();
11691
11992
  },
11692
11993
  close: () => io?.finish(),
11693
11994
  error: () => io?.finish()
@@ -11726,7 +12027,7 @@ function daemonPid(pidPath = PID_PATH) {
11726
12027
 
11727
12028
  // src/bun/backupCli.ts
11728
12029
  import { randomBytes as randomBytes2 } from "crypto";
11729
- import { homedir as homedir7 } from "os";
12030
+ import { homedir as homedir8 } from "os";
11730
12031
  import { join as join19 } from "path";
11731
12032
 
11732
12033
  // src/bun/ask.ts
@@ -11734,7 +12035,7 @@ var reader = null;
11734
12035
  var pending = "";
11735
12036
  async function readLine() {
11736
12037
  reader ??= Bun.stdin.stream().getReader();
11737
- const decoder2 = new TextDecoder;
12038
+ const decoder = new TextDecoder;
11738
12039
  for (;; ) {
11739
12040
  const nl = pending.indexOf(`
11740
12041
  `);
@@ -11749,7 +12050,7 @@ async function readLine() {
11749
12050
  pending = "";
11750
12051
  return line;
11751
12052
  }
11752
- pending += decoder2.decode(value, { stream: true });
12053
+ pending += decoder.decode(value, { stream: true });
11753
12054
  }
11754
12055
  }
11755
12056
  async function readHidden() {
@@ -11791,9 +12092,9 @@ async function ask(question, o = {}) {
11791
12092
  // src/bun/backup.ts
11792
12093
  import { join as join17 } from "path";
11793
12094
  function backupSet(input) {
11794
- const { appHome, profilesDir, roots: roots2, secrets } = input;
12095
+ const { appHome, profilesDir, roots, secrets } = input;
11795
12096
  const include = [appHome];
11796
- for (const root of roots2)
12097
+ for (const root of roots)
11797
12098
  if (!isInside(appHome, root))
11798
12099
  include.push(root);
11799
12100
  if (secrets && !include.some((p) => isInside(p, profilesDir)))
@@ -11832,9 +12133,9 @@ function backupProfileText(vars) {
11832
12133
  `# RESTIC_PASSWORD is the only key to the backup. Keep a copy somewhere else.`,
11833
12134
  ``
11834
12135
  ];
11835
- for (const [key2, value] of Object.entries(vars)) {
12136
+ for (const [key, value] of Object.entries(vars)) {
11836
12137
  const quoted = value !== value.trim() || /^["']/.test(value) ? `"${value.replace(/["\\]/g, "\\$&")}"` : value;
11837
- lines.push(`${key2}=${quoted}`);
12138
+ lines.push(`${key}=${quoted}`);
11838
12139
  }
11839
12140
  lines.push(``);
11840
12141
  return lines.join(`
@@ -11893,7 +12194,7 @@ function parseState(text) {
11893
12194
  return {
11894
12195
  ...EMPTY_STATE,
11895
12196
  ...raw,
11896
- skipped: Array.isArray(raw.skipped) ? raw.skipped.filter((s2) => typeof s2?.root === "string" && typeof s2?.since === "string") : []
12197
+ skipped: Array.isArray(raw.skipped) ? raw.skipped.filter((s) => typeof s?.root === "string" && typeof s?.since === "string") : []
11897
12198
  };
11898
12199
  } catch {
11899
12200
  return EMPTY_STATE;
@@ -11901,7 +12202,7 @@ function parseState(text) {
11901
12202
  }
11902
12203
  function recordRun(prev, o) {
11903
12204
  const at = o.at.toISOString();
11904
- const since = new Map(prev.skipped.map((s2) => [s2.root, s2.since]));
12205
+ const since = new Map(prev.skipped.map((s) => [s.root, s.since]));
11905
12206
  return {
11906
12207
  version: 1,
11907
12208
  lastRun: at,
@@ -12008,16 +12309,16 @@ function parseBackupOutput(stdout) {
12008
12309
  function parseSnapshots(stdout) {
12009
12310
  try {
12010
12311
  const raw = JSON.parse(stdout);
12011
- return raw.filter((s2) => typeof s2.short_id === "string" && typeof s2.time === "string").map((s2) => ({ short_id: s2.short_id, time: s2.time, hostname: s2.hostname ?? "", paths: s2.paths ?? [] }));
12312
+ 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
12313
  } catch {
12013
12314
  return [];
12014
12315
  }
12015
12316
  }
12016
- function statusLines(s2) {
12317
+ function statusLines(s) {
12017
12318
  const lines = [];
12018
- lines.push(`repository ${s2.repository ?? "none: run `ledge backup setup`"}`);
12019
- lines.push(`restic ${"path" in s2.restic ? `${s2.restic.version} at ${s2.restic.path}` : s2.restic.missing}`);
12020
- const { state, now } = s2;
12319
+ lines.push(`repository ${s.repository ?? "none: run `ledge backup setup`"}`);
12320
+ lines.push(`restic ${"path" in s.restic ? `${s.restic.version} at ${s.restic.path}` : s.restic.missing}`);
12321
+ const { state, now } = s;
12021
12322
  if (!state.lastRun)
12022
12323
  lines.push(`last backup never`);
12023
12324
  else if (state.lastOk === state.lastRun)
@@ -12026,17 +12327,17 @@ function statusLines(s2) {
12026
12327
  lines.push(`last backup ${ago(state.lastRun, now)}, FAILED: ${state.lastError ?? "unknown"}`);
12027
12328
  lines.push(`last good ${state.lastOk ? ago(state.lastOk, now) : "never"}`);
12028
12329
  }
12029
- if (s2.repository) {
12030
- lines.push(s2.daemonUp ? `next backup ${nextDue(state, now).getTime() <= now.getTime() ? "due now" : `in ${duration(nextDue(state, now).getTime() - now.getTime())}`} (the server is running)` : `next backup when the server next runs, and hourly while it does`);
12330
+ if (s.repository) {
12331
+ 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
12332
  }
12032
12333
  for (const k of state.skipped)
12033
12334
  lines.push(`SKIPPED ${k.root} (not on disk since ${ago(k.since, now)})`);
12034
12335
  return lines;
12035
12336
  }
12036
- function ago(iso2, now) {
12037
- const ms = now.getTime() - Date.parse(iso2);
12337
+ function ago(iso, now) {
12338
+ const ms = now.getTime() - Date.parse(iso);
12038
12339
  if (!Number.isFinite(ms))
12039
- return iso2;
12340
+ return iso;
12040
12341
  if (ms < 60000)
12041
12342
  return "just now";
12042
12343
  return `${duration(ms)} ago`;
@@ -12086,16 +12387,16 @@ function fetchedResticPath(version = RESTIC_VERSION) {
12086
12387
  async function findRestic(opts = {}) {
12087
12388
  const onPath = Bun.which("restic", { PATH: process.env["PATH"] ?? "" });
12088
12389
  if (onPath) {
12089
- const version2 = await versionOf(onPath);
12090
- if (version2 && versionAtLeast(version2, RESTIC_MIN_VERSION))
12091
- return { path: onPath, version: version2 };
12092
- opts.log?.(`[backup] ${onPath} is restic ${version2 ?? "of an unknown version"}; ${RESTIC_MIN_VERSION} or newer is needed`);
12390
+ const version = await versionOf(onPath);
12391
+ if (version && versionAtLeast(version, RESTIC_MIN_VERSION))
12392
+ return { path: onPath, version };
12393
+ opts.log?.(`[backup] ${onPath} is restic ${version ?? "of an unknown version"}; ${RESTIC_MIN_VERSION} or newer is needed`);
12093
12394
  }
12094
12395
  const fetched = fetchedResticPath();
12095
12396
  if (existsSync2(fetched)) {
12096
- const version2 = await versionOf(fetched);
12097
- if (version2)
12098
- return { path: fetched, version: version2 };
12397
+ const version = await versionOf(fetched);
12398
+ if (version)
12399
+ return { path: fetched, version };
12099
12400
  }
12100
12401
  if (!opts.fetch) {
12101
12402
  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 +12743,14 @@ async function paths(args) {
12442
12743
  async function setup(args) {
12443
12744
  const existing = args.includes("--existing");
12444
12745
  const fromEnv = args.includes("--from-env");
12445
- const replace2 = args.includes("--replace");
12746
+ const replace = args.includes("--replace");
12446
12747
  const repoFlag = valueOf(args, "--repository");
12447
12748
  if (!fromEnv && !process.stdin.isTTY) {
12448
12749
  say("ledge backup setup asks questions, and stdin is not a terminal. Pass --from-env with the variables set instead.");
12449
12750
  return 2;
12450
12751
  }
12451
12752
  const already = configured();
12452
- if (already && !replace2) {
12753
+ if (already && !replace) {
12453
12754
  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
12755
  return 1;
12455
12756
  }
@@ -12564,10 +12865,11 @@ That repository already has backups in it. Run setup again with --existing and i
12564
12865
  say(`The repository and its credentials are in ${PROFILE_PATH}, the "${BACKUP_PROFILE}" profile.`);
12565
12866
  if (generated) {
12566
12867
  say("");
12567
- say("This is the password that encrypts the backup. Keep a copy somewhere that is not this machine:");
12568
- say("a restore starts on a machine with nothing on it, and a password stored only here is a backup you cannot open.");
12569
- say("");
12868
+ process.stderr.write("SAVE THIS PASSWORD: ");
12570
12869
  out(generated);
12870
+ say("");
12871
+ say("It is the only key to the backup, and nothing can be restored without it.");
12872
+ say("Keep a copy somewhere that is not this machine, such as a password manager.");
12571
12873
  }
12572
12874
  return 0;
12573
12875
  }
@@ -12607,8 +12909,8 @@ async function snapshots() {
12607
12909
  say("no snapshots yet");
12608
12910
  return 1;
12609
12911
  }
12610
- for (const s2 of r.snapshots)
12611
- out(`${s2.short_id} ${snapshotTime(s2.time)} ${s2.hostname} ${s2.paths.length} path${s2.paths.length === 1 ? "" : "s"}`);
12912
+ for (const s of r.snapshots)
12913
+ out(`${s.short_id} ${snapshotTime(s.time)} ${s.hostname} ${s.paths.length} path${s.paths.length === 1 ? "" : "s"}`);
12612
12914
  return 0;
12613
12915
  }
12614
12916
  function snapshotTime(time) {
@@ -12630,7 +12932,7 @@ async function restore(args) {
12630
12932
  const to = valueOf(args, "--to");
12631
12933
  if (inPlace && to)
12632
12934
  return usage("--in-place restores to the original paths; --to names another folder. One or the other.");
12633
- const target = inPlace ? "/" : to ?? join19(homedir7(), `ledge-restore-${stamp(new Date)}`);
12935
+ const target = inPlace ? "/" : to ?? join19(homedir8(), `ledge-restore-${stamp(new Date)}`);
12634
12936
  const include = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--snapshot" && args[i - 1] !== "--to");
12635
12937
  if (inPlace && daemonRunning()) {
12636
12938
  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.`);
@@ -13112,14 +13414,14 @@ function getTotalBits(segs, version) {
13112
13414
  }
13113
13415
  return result;
13114
13416
  }
13115
- function toUtf8ByteArray(str2) {
13116
- str2 = encodeURI(str2);
13417
+ function toUtf8ByteArray(str) {
13418
+ str = encodeURI(str);
13117
13419
  const result = [];
13118
- for (let i = 0;i < str2.length; i++) {
13119
- if (str2.charAt(i) !== "%") {
13120
- result.push(str2.charCodeAt(i));
13420
+ for (let i = 0;i < str.length; i++) {
13421
+ if (str.charAt(i) !== "%") {
13422
+ result.push(str.charCodeAt(i));
13121
13423
  } else {
13122
- result.push(Number.parseInt(str2.substring(i + 1, i + 3), 16));
13424
+ result.push(Number.parseInt(str.substring(i + 1, i + 3), 16));
13123
13425
  i += 2;
13124
13426
  }
13125
13427
  }
@@ -13339,7 +13641,7 @@ var KEYGEN_PATH = "/usr/bin/ssh-keygen";
13339
13641
  var HOST_KEY_DIR = "/etc/ssh";
13340
13642
  var FLAGS = ["user", "host", "port", "keys"];
13341
13643
  function parsePairArgs(args) {
13342
- const out2 = {};
13644
+ const out = {};
13343
13645
  for (let i = 0;i < args.length; i++) {
13344
13646
  const arg = args[i];
13345
13647
  const eq = arg.indexOf("=");
@@ -13350,9 +13652,9 @@ function parsePairArgs(args) {
13350
13652
  const value = eq < 0 ? args[++i] : arg.slice(eq + 1);
13351
13653
  if (value === undefined || value === "")
13352
13654
  return { error: `--${flag} needs a value.` };
13353
- out2[flag] = value;
13655
+ out[flag] = value;
13354
13656
  }
13355
- return out2;
13657
+ return out;
13356
13658
  }
13357
13659
  function sshServerAddress(sshConnection) {
13358
13660
  const parts = (sshConnection ?? "").trim().split(/\s+/);
@@ -13402,14 +13704,14 @@ var NOTES = {
13402
13704
  name: "this machine's name"
13403
13705
  };
13404
13706
  function addressCandidates(inputs) {
13405
- const out2 = [];
13707
+ const out = [];
13406
13708
  const seen = new Set;
13407
13709
  const add = (host, source, note) => {
13408
- const key2 = host.toLowerCase();
13409
- if (host === "" || seen.has(key2))
13710
+ const key = host.toLowerCase();
13711
+ if (host === "" || seen.has(key))
13410
13712
  return;
13411
- seen.add(key2);
13412
- out2.push({ host, source, note });
13713
+ seen.add(key);
13714
+ out.push({ host, source, note });
13413
13715
  };
13414
13716
  if (inputs.tailnet?.name)
13415
13717
  add(inputs.tailnet.name, "tailnet", NOTES.tailnetName);
@@ -13437,18 +13739,18 @@ function addressCandidates(inputs) {
13437
13739
  if (addressKind(i.address) === "private")
13438
13740
  add(i.address, "interface", NOTES.private(i.name));
13439
13741
  add(inputs.hostname, "name", NOTES.name);
13440
- return out2;
13742
+ return out;
13441
13743
  }
13442
13744
  function tailscaleSelf(json) {
13443
- let status2;
13745
+ let status;
13444
13746
  try {
13445
- status2 = JSON.parse(json);
13747
+ status = JSON.parse(json);
13446
13748
  } catch {
13447
13749
  return null;
13448
13750
  }
13449
- if (typeof status2 !== "object" || status2 === null)
13751
+ if (typeof status !== "object" || status === null)
13450
13752
  return null;
13451
- const { BackendState, Self } = status2;
13753
+ const { BackendState, Self } = status;
13452
13754
  if (BackendState !== "Running" || typeof Self !== "object" || Self === null)
13453
13755
  return null;
13454
13756
  const name = typeof Self.DNSName === "string" ? Self.DNSName.replace(/\.$/, "") : "";
@@ -13598,14 +13900,14 @@ function othersNote(others) {
13598
13900
  function pairReport({ code, keys, note, columns }) {
13599
13901
  const link = pairingLink(code);
13600
13902
  const width = terminalQRWidth(link);
13601
- const out2 = [];
13903
+ const out = [];
13602
13904
  if (columns !== undefined && columns < width) {
13603
- out2.push(`This terminal is ${columns} columns wide, and the code needs ${width}. Widen it and run pair again.`);
13905
+ out.push(`This terminal is ${columns} columns wide, and the code needs ${width}. Widen it and run pair again.`);
13604
13906
  } else {
13605
- out2.push(...terminalQR(link));
13907
+ out.push(...terminalQR(link));
13606
13908
  }
13607
- out2.push("", "Scan the code with Ledge on your phone, or paste the link below into the Mac app's Add Server form. It names this server and its host keys, and holds no password or key.", "", ` Account ${code.user}`, ` Host ${code.host}${note === "" ? "" : ` (${note})`}`, ` Port ${code.port === PORT_UNSET ? DEFAULT_PORT : code.port}`, ...keys.map((k, i) => ` ${i === 0 ? "Host keys" : " "} ${k.fingerprint} (${k.keyType})`), "", link);
13608
- return `${out2.join(`
13909
+ 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);
13910
+ return `${out.join(`
13609
13911
  `)}
13610
13912
  `;
13611
13913
  }
@@ -13623,7 +13925,7 @@ async function serve2() {
13623
13925
  const upstream = await connectToDaemon();
13624
13926
  const mine = stdioDuplex();
13625
13927
  let over;
13626
- const done = new Promise((resolve11) => over = resolve11);
13928
+ const done = new Promise((resolve) => over = resolve);
13627
13929
  let ended = false;
13628
13930
  const end = () => {
13629
13931
  if (ended)
@@ -13640,7 +13942,7 @@ async function serve2() {
13640
13942
  mine.onClose = end;
13641
13943
  console.error(`[serve] ledge ${BUILD_VERSION} attached to ${SOCKET_PATH}`);
13642
13944
  await done;
13643
- await new Promise((resolve11) => process.stdout.write("", () => resolve11()));
13945
+ await new Promise((resolve) => process.stdout.write("", () => resolve()));
13644
13946
  }
13645
13947
  async function daemon(autostart = false) {
13646
13948
  const idleMs = autostart ? IDLE_EXIT_MS : IDLE_EXIT_NEVER;
@@ -13656,9 +13958,9 @@ async function daemon(autostart = false) {
13656
13958
  backups.stop();
13657
13959
  }
13658
13960
  async function pair(argv) {
13659
- const fail2 = (message, status2 = 1) => {
13961
+ const fail = (message, status = 1) => {
13660
13962
  console.error(message);
13661
- return status2;
13963
+ return status;
13662
13964
  };
13663
13965
  if (argv.includes("--help") || argv.includes("-h")) {
13664
13966
  process.stdout.write(`${PAIR_USAGE}
@@ -13667,12 +13969,12 @@ async function pair(argv) {
13667
13969
  }
13668
13970
  const args = parsePairArgs(argv.slice(3));
13669
13971
  if ("error" in args)
13670
- return fail2(`${args.error}
13972
+ return fail(`${args.error}
13671
13973
  ${PAIR_USAGE}`, 2);
13672
13974
  if (existsSync3("/.dockerenv") || existsSync3("/run/.containerenv")) {
13673
13975
  const refusal = containerRefusal(args);
13674
13976
  if (refusal)
13675
- return fail2(refusal);
13977
+ return fail(refusal);
13676
13978
  }
13677
13979
  const candidates = args.host === undefined ? await gatherCandidates() : [];
13678
13980
  const interactive = args.host === undefined && args.keys !== "-" && process.stdin.isTTY && process.stdout.isTTY;
@@ -13683,7 +13985,7 @@ ${PAIR_USAGE}`, 2);
13683
13985
  }
13684
13986
  const address = pairAddress(args, process.env.SSH_CONNECTION, candidates, answer);
13685
13987
  if ("error" in address)
13686
- return fail2(address.error, 2);
13988
+ return fail(address.error, 2);
13687
13989
  if (!interactive)
13688
13990
  process.stderr.write(othersNote(candidates.filter((c) => c.host !== address.host)));
13689
13991
  let keyText = "";
@@ -13693,7 +13995,7 @@ ${PAIR_USAGE}`, 2);
13693
13995
  try {
13694
13996
  keyText = readFileSync4(args.keys, "utf8");
13695
13997
  } catch {
13696
- return fail2(`Could not read ${args.keys}.`);
13998
+ return fail(`Could not read ${args.keys}.`);
13697
13999
  }
13698
14000
  } else {
13699
14001
  const files = existsSync3(HOST_KEY_DIR) ? readdirSync(HOST_KEY_DIR).filter((f) => /^ssh_host_\w+_key\.pub$/.test(f)) : [];
@@ -13704,7 +14006,7 @@ ${PAIR_USAGE}`, 2);
13704
14006
  } catch {}
13705
14007
  }
13706
14008
  if (keyText.trim() === "") {
13707
- return fail2(`There are no sshd host keys in ${HOST_KEY_DIR}. If sshd keeps them elsewhere, pass the .pub file with --keys.`);
14009
+ return fail(`There are no sshd host keys in ${HOST_KEY_DIR}. If sshd keeps them elsewhere, pass the .pub file with --keys.`);
13708
14010
  }
13709
14011
  }
13710
14012
  let described;
@@ -13715,18 +14017,18 @@ ${PAIR_USAGE}`, 2);
13715
14017
  described = await new Response(p.stdout).text();
13716
14018
  await p.exited;
13717
14019
  } catch (err) {
13718
- return fail2(`Could not run ssh-keygen (${err instanceof Error ? err.message : String(err)}).`);
14020
+ return fail(`Could not run ssh-keygen (${err instanceof Error ? err.message : String(err)}).`);
13719
14021
  }
13720
14022
  const keys = phoneHostKeys(described);
13721
14023
  if (keys.length === 0) {
13722
14024
  if (!described.includes("SHA256:"))
13723
- return fail2(`${args.keys === "-" ? "stdin" : args.keys ?? HOST_KEY_DIR} holds no public host keys.`);
13724
- return fail2(`None of these host keys is Ed25519 or ECDSA, and those are the kinds Ledge on a phone can check.
14025
+ return fail(`${args.keys === "-" ? "stdin" : args.keys ?? HOST_KEY_DIR} holds no public host keys.`);
14026
+ return fail(`None of these host keys is Ed25519 or ECDSA, and those are the kinds Ledge on a phone can check.
13725
14027
  ` + "`sudo ssh-keygen -A` creates the missing default keys. Restart sshd after it.");
13726
14028
  }
13727
14029
  const code = pairCode(args.user ?? userInfo().username, address, keys);
13728
14030
  if ("error" in code)
13729
- return fail2(code.error);
14031
+ return fail(code.error);
13730
14032
  const columns = process.stdout.isTTY ? process.stdout.columns : undefined;
13731
14033
  process.stdout.write(pairReport({ code, keys, note: address.note, columns }));
13732
14034
  return 0;
@@ -13743,13 +14045,13 @@ async function gatherCandidates() {
13743
14045
  }
13744
14046
  var LOOKUP_MS = 1500;
13745
14047
  function dmi() {
13746
- const out2 = {};
14048
+ const out = {};
13747
14049
  for (const field of DMI_FIELDS) {
13748
14050
  try {
13749
- out2[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
14051
+ out[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
13750
14052
  } catch {}
13751
14053
  }
13752
- return out2;
14054
+ return out;
13753
14055
  }
13754
14056
  async function tailnetSelf() {
13755
14057
  const path = TAILSCALE_PATHS.find((p) => existsSync3(p));
@@ -13820,8 +14122,8 @@ async function main(argv) {
13820
14122
  if (import.meta.main)
13821
14123
  await main(process.argv);
13822
14124
  export {
13823
- serve2 as serve,
13824
- pair,
14125
+ daemon,
13825
14126
  main,
13826
- daemon
14127
+ pair,
14128
+ serve2 as serve
13827
14129
  };