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
|
|
394
|
-
const kept =
|
|
395
|
-
if (kept.length ===
|
|
393
|
+
const content = b.lines.slice(1, b.close);
|
|
394
|
+
const kept = content.filter((l) => !FAVORITE_LINE.test(l));
|
|
395
|
+
if (kept.length === content.length)
|
|
396
396
|
return text;
|
|
397
397
|
if (kept.every((l) => l.trim() === ""))
|
|
398
398
|
return text.slice(b.end);
|
|
@@ -659,7 +659,7 @@ function tagRefsOf(text) {
|
|
|
659
659
|
}
|
|
660
660
|
|
|
661
661
|
// src/bun/notes.ts
|
|
662
|
-
import { basename as
|
|
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
|
-
|
|
893
|
+
return null;
|
|
894
|
+
const first = ![...entries.keys()].some((r) => kindOf(r) !== "docs");
|
|
895
|
+
const root = await createManaged("Scratch");
|
|
896
|
+
return first ? root : null;
|
|
895
897
|
}
|
|
896
898
|
async function createManaged(name) {
|
|
897
899
|
await ensureAppHome();
|
|
@@ -1247,10 +1249,10 @@ async function unlockVault(passphrase, device, probeHeader) {
|
|
|
1247
1249
|
} catch {
|
|
1248
1250
|
return false;
|
|
1249
1251
|
}
|
|
1250
|
-
const
|
|
1251
|
-
if (!checkAgainstFile(
|
|
1252
|
+
const key = deriveKey(passphrase, vaultSalt);
|
|
1253
|
+
if (!checkAgainstFile(key, checkB64))
|
|
1252
1254
|
return false;
|
|
1253
|
-
masterKey =
|
|
1255
|
+
masterKey = key;
|
|
1254
1256
|
authorize(device);
|
|
1255
1257
|
return true;
|
|
1256
1258
|
}
|
|
@@ -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
|
|
1754
|
-
const hits = await collectHits(query,
|
|
1755
|
-
return { hits, lockedSkipped: metas.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, `.${
|
|
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,
|
|
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 `${
|
|
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
|
|
2168
|
+
let entries;
|
|
2154
2169
|
try {
|
|
2155
|
-
|
|
2170
|
+
entries = await readdir3(dir, { withFileTypes: true });
|
|
2156
2171
|
} catch {
|
|
2157
2172
|
return;
|
|
2158
2173
|
}
|
|
2159
|
-
for (const entry of
|
|
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, `.${
|
|
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
|
|
2205
|
-
taken.add(
|
|
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(
|
|
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
|
|
2234
|
-
taken.add(
|
|
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
|
|
2241
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
2302
|
-
taken.add(
|
|
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
|
|
2441
|
+
let entries;
|
|
2383
2442
|
try {
|
|
2384
|
-
|
|
2443
|
+
entries = await readdir3(dir, { withFileTypes: true });
|
|
2385
2444
|
} catch {
|
|
2386
2445
|
return;
|
|
2387
2446
|
}
|
|
2388
|
-
for (const entry of
|
|
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
|
|
2431
|
-
taken.add(
|
|
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
|
|
2568
|
+
const sep = body.startsWith(`
|
|
2510
2569
|
`) || body === "" ? `
|
|
2511
2570
|
` : `
|
|
2512
2571
|
|
|
2513
2572
|
`;
|
|
2514
|
-
return `${text.slice(0, start)}# ${title}${
|
|
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
|
|
2567
|
-
if (
|
|
2568
|
-
return { path: local.path, text: templateText(
|
|
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
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
3389
|
-
if (
|
|
3390
|
-
return
|
|
3391
|
-
throw new Error(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3573
|
-
for (const root of
|
|
3637
|
+
let lockedSkipped = 0;
|
|
3638
|
+
for (const root of roots) {
|
|
3574
3639
|
try {
|
|
3575
3640
|
const res = await notesTagged(root, tag, folder);
|
|
3576
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
|
3655
|
-
const
|
|
3656
|
-
return { path:
|
|
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
|
|
4145
|
+
const res = await tool("tags", base);
|
|
4081
4146
|
if (flags.json) {
|
|
4082
|
-
io.out(JSON.stringify(
|
|
4147
|
+
io.out(JSON.stringify(res, null, 2));
|
|
4083
4148
|
return 0;
|
|
4084
4149
|
}
|
|
4085
|
-
if (
|
|
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 =
|
|
4090
|
-
for (const t of
|
|
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
|
|
4187
|
+
const n = await tool("create_note", args);
|
|
4123
4188
|
if (flags.json)
|
|
4124
|
-
io.out(JSON.stringify(
|
|
4189
|
+
io.out(JSON.stringify(n, null, 2));
|
|
4125
4190
|
else
|
|
4126
|
-
io.out(
|
|
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
|
|
4234
|
-
import { basename as
|
|
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 = (
|
|
4427
|
-
const enc = new TextEncoder().encode(
|
|
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
|
|
4746
|
-
if (
|
|
4810
|
+
const sep = payload.indexOf(":");
|
|
4811
|
+
if (sep === -1)
|
|
4747
4812
|
return null;
|
|
4748
|
-
if (payload.slice(0,
|
|
4813
|
+
if (payload.slice(0, sep) !== this.nonce)
|
|
4749
4814
|
return null;
|
|
4750
|
-
return payload.slice(
|
|
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
|
|
4921
|
-
if (
|
|
4985
|
+
const open = slot.parser.openBlockId ?? slot.activeRun;
|
|
4986
|
+
if (open && !slot.began)
|
|
4922
4987
|
this.flushPreamble(slot, emit);
|
|
4923
|
-
if (
|
|
4924
|
-
emit({ type: "ended", blockId:
|
|
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
|
|
4947
|
-
if (
|
|
4948
|
-
emit({ type: "ended", blockId:
|
|
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,
|
|
5061
|
-
if (
|
|
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,
|
|
5067
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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://
|
|
6259
|
+
curl -fsSL https://ledge.sh/server.sh | sudo -iu ledge sh
|
|
6110
6260
|
\`\`\`
|
|
6111
6261
|
|
|
6112
|
-
|
|
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
|
-
|
|
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
|
|
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 '
|
|
6303
|
+
ssh you@machine 'PATH=$HOME/.ledge/.server/bin:$PATH command -v ledge'
|
|
6147
6304
|
\`\`\`
|
|
6148
6305
|
|
|
6149
|
-
|
|
6306
|
+
A path printed means the machine is ready to add.
|
|
6150
6307
|
|
|
6151
|
-
|
|
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="/
|
|
6339
|
+
restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ssh-ed25519 AAAA... ledge@laptop
|
|
6181
6340
|
\`\`\`
|
|
6182
6341
|
|
|
6183
|
-
|
|
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.
|
|
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
|
|
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://
|
|
6438
|
-
|
|
6439
|
-
ledge pair
|
|
6440
|
-
\`\`\`
|
|
6441
|
-
|
|
6442
|
-
On a Mac:
|
|
6443
|
-
|
|
6444
|
-
\`\`\`sh norun
|
|
6445
|
-
curl -fsSL https://bun.sh/install | bash
|
|
6446
|
-
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.zshenv
|
|
6447
|
-
source ~/.zshenv
|
|
6448
|
-
bun add -g ledge-server
|
|
6449
|
-
ledge pair
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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://
|
|
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
|
|
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.
|
|
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 * * * * /
|
|
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), `.${
|
|
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
|
|
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, `.${
|
|
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(
|
|
9712
|
-
logPath = join15(LOG_DIR, `${
|
|
9713
|
-
prevPath = join15(LOG_DIR, `${
|
|
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(
|
|
9900
|
+
function startLogging(basename) {
|
|
9745
9901
|
if (patched)
|
|
9746
9902
|
return;
|
|
9747
9903
|
patched = true;
|
|
9748
|
-
if (
|
|
9749
|
-
logToFile(
|
|
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((
|
|
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(
|
|
9789
|
-
const want = new Set(
|
|
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
|
|
9850
|
-
const interpreter = interpreters[
|
|
10005
|
+
const key = (lang ?? "").toLowerCase();
|
|
10006
|
+
const interpreter = interpreters[key];
|
|
9851
10007
|
if (!interpreter) {
|
|
9852
|
-
const
|
|
9853
|
-
const command = remote ? remoteWrite(code,
|
|
9854
|
-
return { kind: "shell", path
|
|
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[
|
|
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(
|
|
9881
|
-
return
|
|
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 [
|
|
9903
|
-
if (isEnvName(
|
|
9904
|
-
parts.push(`export ${
|
|
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 "${
|
|
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 = { ...
|
|
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:
|
|
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 =
|
|
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((
|
|
10132
|
-
if (
|
|
10133
|
-
push.all.openExternal(
|
|
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
|
|
10792
|
+
const open = await takeOpenRequest();
|
|
10497
10793
|
startOpenRequestWatcher();
|
|
10498
|
-
return { open
|
|
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
|
|
11080
|
+
for (const key of path) {
|
|
10780
11081
|
if (typeof at !== "object" || at === null)
|
|
10781
11082
|
return null;
|
|
10782
|
-
at = at[
|
|
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(
|
|
10958
|
-
return typeof v === "string" ? { [
|
|
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(
|
|
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
|
|
11011
|
-
const
|
|
11012
|
-
|
|
11013
|
-
|
|
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(
|
|
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:
|
|
11327
|
+
this.held = { id: frame.id, bytes: frame.bytes };
|
|
11027
11328
|
}
|
|
11028
11329
|
claim(msg, kind, method) {
|
|
11029
|
-
const
|
|
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 (
|
|
11332
|
+
if (bin === undefined)
|
|
11032
11333
|
return body;
|
|
11033
11334
|
const held = this.held;
|
|
11034
11335
|
this.held = null;
|
|
11035
|
-
if (!held || held.id !==
|
|
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
|
|
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
|
|
11426
|
+
let open = true;
|
|
11126
11427
|
const waiting = [];
|
|
11127
11428
|
let settle;
|
|
11128
|
-
const closed = new Promise((
|
|
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 (!
|
|
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 (!
|
|
11449
|
+
if (!open)
|
|
11149
11450
|
return;
|
|
11150
11451
|
if (why !== undefined)
|
|
11151
11452
|
send({ t: "bye", why, ...back ? { back: true } : {} });
|
|
11152
|
-
|
|
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 (!
|
|
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 =
|
|
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
|
|
11565
|
+
for (const frame of frames) {
|
|
11265
11566
|
try {
|
|
11266
|
-
if (
|
|
11267
|
-
incoming.hold(
|
|
11567
|
+
if (frame.type === 1) {
|
|
11568
|
+
incoming.hold(frame);
|
|
11268
11569
|
continue;
|
|
11269
11570
|
}
|
|
11270
|
-
handle(parseControl(
|
|
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
|
-
|
|
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(
|
|
11452
|
-
const hit = seen.get(
|
|
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(
|
|
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.
|
|
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
|
|
11798
|
+
let idleTimer = null;
|
|
11498
11799
|
let heldUntil = 0;
|
|
11499
11800
|
let settleDone;
|
|
11500
|
-
const done = new Promise((
|
|
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 (
|
|
11546
|
-
clearTimeout(
|
|
11547
|
-
|
|
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 ||
|
|
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
|
-
|
|
11585
|
-
|
|
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 (
|
|
11630
|
-
clearTimeout(
|
|
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: (
|
|
11989
|
+
end: (s) => {
|
|
11689
11990
|
io?.finish();
|
|
11690
|
-
|
|
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
|
|
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
|
|
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 +=
|
|
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
|
|
12095
|
+
const { appHome, profilesDir, roots, secrets } = input;
|
|
11795
12096
|
const include = [appHome];
|
|
11796
|
-
for (const root of
|
|
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 [
|
|
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(`${
|
|
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((
|
|
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((
|
|
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((
|
|
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(
|
|
12317
|
+
function statusLines(s) {
|
|
12017
12318
|
const lines = [];
|
|
12018
|
-
lines.push(`repository ${
|
|
12019
|
-
lines.push(`restic ${"path" in
|
|
12020
|
-
const { state, now } =
|
|
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 (
|
|
12030
|
-
lines.push(
|
|
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(
|
|
12037
|
-
const ms = now.getTime() - Date.parse(
|
|
12337
|
+
function ago(iso, now) {
|
|
12338
|
+
const ms = now.getTime() - Date.parse(iso);
|
|
12038
12339
|
if (!Number.isFinite(ms))
|
|
12039
|
-
return
|
|
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
|
|
12090
|
-
if (
|
|
12091
|
-
return { path: onPath, version
|
|
12092
|
-
opts.log?.(`[backup] ${onPath} is restic ${
|
|
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
|
|
12097
|
-
if (
|
|
12098
|
-
return { path: fetched, version
|
|
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
|
|
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 && !
|
|
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
|
-
|
|
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
|
|
12611
|
-
out(`${
|
|
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(
|
|
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(
|
|
13116
|
-
|
|
13417
|
+
function toUtf8ByteArray(str) {
|
|
13418
|
+
str = encodeURI(str);
|
|
13117
13419
|
const result = [];
|
|
13118
|
-
for (let i = 0;i <
|
|
13119
|
-
if (
|
|
13120
|
-
result.push(
|
|
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(
|
|
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
|
|
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
|
-
|
|
13655
|
+
out[flag] = value;
|
|
13354
13656
|
}
|
|
13355
|
-
return
|
|
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
|
|
13707
|
+
const out = [];
|
|
13406
13708
|
const seen = new Set;
|
|
13407
13709
|
const add = (host, source, note) => {
|
|
13408
|
-
const
|
|
13409
|
-
if (host === "" || seen.has(
|
|
13710
|
+
const key = host.toLowerCase();
|
|
13711
|
+
if (host === "" || seen.has(key))
|
|
13410
13712
|
return;
|
|
13411
|
-
seen.add(
|
|
13412
|
-
|
|
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
|
|
13742
|
+
return out;
|
|
13441
13743
|
}
|
|
13442
13744
|
function tailscaleSelf(json) {
|
|
13443
|
-
let
|
|
13745
|
+
let status;
|
|
13444
13746
|
try {
|
|
13445
|
-
|
|
13747
|
+
status = JSON.parse(json);
|
|
13446
13748
|
} catch {
|
|
13447
13749
|
return null;
|
|
13448
13750
|
}
|
|
13449
|
-
if (typeof
|
|
13751
|
+
if (typeof status !== "object" || status === null)
|
|
13450
13752
|
return null;
|
|
13451
|
-
const { BackendState, Self } =
|
|
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
|
|
13903
|
+
const out = [];
|
|
13602
13904
|
if (columns !== undefined && columns < width) {
|
|
13603
|
-
|
|
13905
|
+
out.push(`This terminal is ${columns} columns wide, and the code needs ${width}. Widen it and run pair again.`);
|
|
13604
13906
|
} else {
|
|
13605
|
-
|
|
13907
|
+
out.push(...terminalQR(link));
|
|
13606
13908
|
}
|
|
13607
|
-
|
|
13608
|
-
return `${
|
|
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((
|
|
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((
|
|
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
|
|
13961
|
+
const fail = (message, status = 1) => {
|
|
13660
13962
|
console.error(message);
|
|
13661
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
13724
|
-
return
|
|
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
|
|
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
|
|
14048
|
+
const out = {};
|
|
13747
14049
|
for (const field of DMI_FIELDS) {
|
|
13748
14050
|
try {
|
|
13749
|
-
|
|
14051
|
+
out[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
|
|
13750
14052
|
} catch {}
|
|
13751
14053
|
}
|
|
13752
|
-
return
|
|
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
|
-
|
|
13824
|
-
pair,
|
|
14125
|
+
daemon,
|
|
13825
14126
|
main,
|
|
13826
|
-
|
|
14127
|
+
pair,
|
|
14128
|
+
serve2 as serve
|
|
13827
14129
|
};
|