ledge-server 0.1.0 → 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.
|
Binary file
|
|
Binary file
|
package/lib/serve.js
CHANGED
|
@@ -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
|
|
@@ -1515,7 +1515,7 @@ function stripLockedLine(text) {
|
|
|
1515
1515
|
}
|
|
1516
1516
|
|
|
1517
1517
|
// src/bun/assets.ts
|
|
1518
|
-
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";
|
|
1519
1519
|
import { mkdir as mkdir2, readdir as readdir2, readFile as readFile4, rename as rename3, unlink as unlink3, writeFile as writeFile3 } from "fs/promises";
|
|
1520
1520
|
function assetsDirOf(root) {
|
|
1521
1521
|
return join4(resolve2(root), ASSETS_DIRNAME);
|
|
@@ -1603,6 +1603,19 @@ async function savePastedImage(root, bytes, ext = ".png", seal = false, from) {
|
|
|
1603
1603
|
await writeAsset(assetsDir, join4(assetsDir, name), seal ? sealAssetBytes(bytes) : bytes);
|
|
1604
1604
|
return assetRefFor(root, join4(assetsDir, name), from);
|
|
1605
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
|
+
}
|
|
1606
1619
|
async function replaceAssetBytes(path, bytes) {
|
|
1607
1620
|
await writeAsset(dirname2(path), path, bytes);
|
|
1608
1621
|
}
|
|
@@ -1897,7 +1910,7 @@ async function writeNote(path, text, baseMtimeMs = null) {
|
|
|
1897
1910
|
}
|
|
1898
1911
|
}
|
|
1899
1912
|
tmpCounter4 += 1;
|
|
1900
|
-
const tmp = join5(dir, `.${
|
|
1913
|
+
const tmp = join5(dir, `.${basename3(path)}.tmp-${process.pid}-${tmpCounter4}`);
|
|
1901
1914
|
try {
|
|
1902
1915
|
await writeFile4(tmp, outgoing, "utf8");
|
|
1903
1916
|
const mtimeMs = (await stat2(tmp)).mtimeMs;
|
|
@@ -2180,7 +2193,7 @@ async function writeSealed(path, outgoing, baseMtimeMs) {
|
|
|
2180
2193
|
console.warn("[vault] concurrent edit preserved in trash during a lock state change:", moved);
|
|
2181
2194
|
}
|
|
2182
2195
|
tmpCounter4 += 1;
|
|
2183
|
-
const tmp = join5(dir, `.${
|
|
2196
|
+
const tmp = join5(dir, `.${basename3(path)}.tmp-${process.pid}-${tmpCounter4}`);
|
|
2184
2197
|
try {
|
|
2185
2198
|
await writeFile4(tmp, outgoing, "utf8");
|
|
2186
2199
|
await rename4(tmp, path);
|
|
@@ -2215,13 +2228,14 @@ async function createNote(root, text, folder) {
|
|
|
2215
2228
|
}
|
|
2216
2229
|
return metaFor(path, text);
|
|
2217
2230
|
}
|
|
2218
|
-
async function moveNote(path, folder) {
|
|
2231
|
+
async function moveNote(path, folder, toRoot) {
|
|
2219
2232
|
const root = assertWritableRoot(assertNote(path));
|
|
2233
|
+
const dest = toRoot == null ? root : assertWritableRoot(assertRegisteredRoot(toRoot));
|
|
2220
2234
|
const from = resolve3(path);
|
|
2221
2235
|
if (isInside(trashDirOf(root), from)) {
|
|
2222
2236
|
throw new Error("that note is in the trash \u2014 restore it first, then move it");
|
|
2223
2237
|
}
|
|
2224
|
-
const dir = await ensureFolder(
|
|
2238
|
+
const dir = await ensureFolder(dest, folder);
|
|
2225
2239
|
if (dirname3(from) === dir)
|
|
2226
2240
|
return metaAt(from);
|
|
2227
2241
|
const file = await readNote(from);
|
|
@@ -2239,15 +2253,58 @@ async function moveNote(path, folder) {
|
|
|
2239
2253
|
const target = join5(dir, name);
|
|
2240
2254
|
try {
|
|
2241
2255
|
assertNote(target);
|
|
2242
|
-
await
|
|
2243
|
-
|
|
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
|
+
}
|
|
2244
2266
|
if (rebased !== file.text)
|
|
2245
|
-
await writeNote(target, rebased,
|
|
2267
|
+
await writeNote(target, rebased, baseMtimeMs);
|
|
2246
2268
|
} finally {
|
|
2247
2269
|
reserved.delete(name);
|
|
2248
2270
|
}
|
|
2249
2271
|
return metaAt(target);
|
|
2250
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
|
+
}
|
|
2251
2308
|
async function sameEntry(a, b) {
|
|
2252
2309
|
const [x, y] = await Promise.all([stat2(a).catch(() => null), stat2(b).catch(() => null)]);
|
|
2253
2310
|
return x !== null && y !== null && x.dev === y.dev && x.ino === y.ino;
|
|
@@ -2296,7 +2353,7 @@ async function renameFolder(root, folder, name) {
|
|
|
2296
2353
|
async function retitleNote(path, text) {
|
|
2297
2354
|
assertWritableRoot(assertNote(path));
|
|
2298
2355
|
const dir = dirname3(path);
|
|
2299
|
-
const current =
|
|
2356
|
+
const current = basename3(path);
|
|
2300
2357
|
const reserved = reservedIn(dir);
|
|
2301
2358
|
const taken = new Set(await readdir3(dir));
|
|
2302
2359
|
taken.delete(current);
|
|
@@ -3042,7 +3099,7 @@ function endOfString(text, start) {
|
|
|
3042
3099
|
// src/bun/spawnParams.ts
|
|
3043
3100
|
import { accessSync, constants } from "fs";
|
|
3044
3101
|
import { homedir as homedir3 } from "os";
|
|
3045
|
-
import { basename as
|
|
3102
|
+
import { basename as basename4, isAbsolute as isAbsolute2, join as join6, resolve as resolve4 } from "path";
|
|
3046
3103
|
|
|
3047
3104
|
// src/shared/dotenv.ts
|
|
3048
3105
|
function parseDotenv(text) {
|
|
@@ -3115,7 +3172,7 @@ var SHELL_FALLBACKS = [
|
|
|
3115
3172
|
"/usr/local/bin/bash"
|
|
3116
3173
|
];
|
|
3117
3174
|
function isSupportedShell(path) {
|
|
3118
|
-
return SUPPORTED_SHELLS.includes(
|
|
3175
|
+
return SUPPORTED_SHELLS.includes(basename4(path));
|
|
3119
3176
|
}
|
|
3120
3177
|
function resolveShellPath(loginShell, isExecutable) {
|
|
3121
3178
|
if (loginShell && isAbsolute2(loginShell) && isSupportedShell(loginShell) && isExecutable(loginShell)) {
|
|
@@ -3149,7 +3206,7 @@ function defaultShellPath() {
|
|
|
3149
3206
|
return resolveShellPath(process.env["SHELL"], isExecutableFile);
|
|
3150
3207
|
}
|
|
3151
3208
|
function resolveShellArgs(path, args) {
|
|
3152
|
-
if (
|
|
3209
|
+
if (basename4(path) !== "zsh")
|
|
3153
3210
|
return args;
|
|
3154
3211
|
if (args.some((a) => a.toLowerCase().replace(/_/g, "") === "interactivecomments"))
|
|
3155
3212
|
return args;
|
|
@@ -3191,6 +3248,12 @@ function mergeDotenv(env, path, label, deps) {
|
|
|
3191
3248
|
deps.warn(`${label}: ${p}`);
|
|
3192
3249
|
Object.assign(env, vars);
|
|
3193
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
|
+
}
|
|
3194
3257
|
|
|
3195
3258
|
// src/bun/settings.ts
|
|
3196
3259
|
var SETTINGS_PATH = join7(APP_HOME, "settings.jsonc");
|
|
@@ -4232,8 +4295,8 @@ import { join as join16 } from "path";
|
|
|
4232
4295
|
|
|
4233
4296
|
// src/bun/server.ts
|
|
4234
4297
|
import { watch as watch2 } from "fs";
|
|
4235
|
-
import { homedir as
|
|
4236
|
-
import { basename as
|
|
4298
|
+
import { homedir as homedir7 } from "os";
|
|
4299
|
+
import { basename as basename8, resolve as resolve10 } from "path";
|
|
4237
4300
|
|
|
4238
4301
|
// src/bun/pty.ts
|
|
4239
4302
|
import { dlopen, ptr, CString, cc } from "bun:ffi";
|
|
@@ -4823,11 +4886,11 @@ class InlinePool {
|
|
|
4823
4886
|
}
|
|
4824
4887
|
let slot = session.primaries.get(host);
|
|
4825
4888
|
if (!slot) {
|
|
4826
|
-
slot = this.newSlot(sessionId, host);
|
|
4889
|
+
slot = this.newSlot(sessionId, host, true);
|
|
4827
4890
|
session.primaries.set(host, slot);
|
|
4828
4891
|
}
|
|
4829
4892
|
if (slot.activeRun !== null) {
|
|
4830
|
-
slot = this.newSlot(sessionId, host);
|
|
4893
|
+
slot = this.newSlot(sessionId, host, false);
|
|
4831
4894
|
session.overflow.set(id, slot);
|
|
4832
4895
|
}
|
|
4833
4896
|
slot.activeRun = id;
|
|
@@ -4957,6 +5020,16 @@ class InlinePool {
|
|
|
4957
5020
|
this.pendingResize.delete(id);
|
|
4958
5021
|
}
|
|
4959
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
|
+
}
|
|
4960
5033
|
closeSession(sessionId) {
|
|
4961
5034
|
const session = this.sessions.get(sessionId);
|
|
4962
5035
|
if (session) {
|
|
@@ -5015,8 +5088,8 @@ class InlinePool {
|
|
|
5015
5088
|
if (out.length > 0)
|
|
5016
5089
|
emit({ type: "output", blockId: slot.activeRun, data: out }, slot.client);
|
|
5017
5090
|
}
|
|
5018
|
-
newSlot(sessionId, host) {
|
|
5019
|
-
const shell = this.spawn(sessionId, host);
|
|
5091
|
+
newSlot(sessionId, host, persistent) {
|
|
5092
|
+
const shell = this.spawn(sessionId, host, persistent);
|
|
5020
5093
|
return {
|
|
5021
5094
|
shell,
|
|
5022
5095
|
parser: new MarkerParser(this.nonce),
|
|
@@ -5188,7 +5261,7 @@ var WELCOME_DOC = [
|
|
|
5188
5261
|
`);
|
|
5189
5262
|
|
|
5190
5263
|
// src/bun/docs.ts
|
|
5191
|
-
import { basename as
|
|
5264
|
+
import { basename as basename6, join as join13, resolve as resolve9 } from "path";
|
|
5192
5265
|
import { mkdir as mkdir5, readdir as readdir4, readFile as readFile9, rename as rename8, unlink as unlink7, writeFile as writeFile8 } from "fs/promises";
|
|
5193
5266
|
|
|
5194
5267
|
// docs/user/01-getting-started.md
|
|
@@ -5404,7 +5477,7 @@ The terminal drawer is a separate shell from the inline one. Both belong to this
|
|
|
5404
5477
|
|
|
5405
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.
|
|
5406
5479
|
|
|
5407
|
-
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.
|
|
5408
5481
|
|
|
5409
5482
|
## Change the shell
|
|
5410
5483
|
|
|
@@ -5419,6 +5492,8 @@ Ledge spawns your own login shell with \`-i\` for every inline shell and every t
|
|
|
5419
5492
|
|
|
5420
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.
|
|
5421
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
|
+
|
|
5422
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.
|
|
5423
5498
|
|
|
5424
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.
|
|
@@ -5581,6 +5656,16 @@ A locked note has to be unlocked before it can move, because those image referen
|
|
|
5581
5656
|
|
|
5582
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.
|
|
5583
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
|
+
|
|
5584
5669
|
## Favorites
|
|
5585
5670
|
|
|
5586
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.
|
|
@@ -5942,7 +6027,13 @@ Three keys feed the environment, layered in this order, with later layers overri
|
|
|
5942
6027
|
|
|
5943
6028
|
Frontmatter is read when a shell spawns, and a note's running shells keep the settings they started with.
|
|
5944
6029
|
|
|
5945
|
-
|
|
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.
|
|
5946
6037
|
|
|
5947
6038
|
## Every key
|
|
5948
6039
|
|
|
@@ -5961,7 +6052,7 @@ After editing the block, run "Restart Note Shell" from the command palette (\u21
|
|
|
5961
6052
|
`;
|
|
5962
6053
|
|
|
5963
6054
|
// docs/user/07-profiles-and-secrets.md
|
|
5964
|
-
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";
|
|
5965
6056
|
|
|
5966
6057
|
// docs/user/08-run-code-on-remote-hosts.md
|
|
5967
6058
|
var _08_run_code_on_remote_hosts_default = `# Run Code on Remote Hosts
|
|
@@ -9636,7 +9727,7 @@ var RETIRED_DIRNAME = ".retired";
|
|
|
9636
9727
|
var tmpCounter5 = 0;
|
|
9637
9728
|
async function writePage(path, text) {
|
|
9638
9729
|
tmpCounter5 += 1;
|
|
9639
|
-
const tmp = join13(resolve9(DOCS_ROOT), `.${
|
|
9730
|
+
const tmp = join13(resolve9(DOCS_ROOT), `.${basename6(path)}.tmp-${process.pid}-${tmpCounter5}`);
|
|
9640
9731
|
try {
|
|
9641
9732
|
await writeFile8(tmp, text, "utf8");
|
|
9642
9733
|
await rename8(tmp, path);
|
|
@@ -9687,7 +9778,7 @@ async function syncDocs(pages = DOC_PAGES) {
|
|
|
9687
9778
|
}
|
|
9688
9779
|
|
|
9689
9780
|
// src/bun/layout.ts
|
|
9690
|
-
import { basename as
|
|
9781
|
+
import { basename as basename7, join as join14 } from "path";
|
|
9691
9782
|
import { readFile as readFile10, rename as rename9, unlink as unlink8, writeFile as writeFile9 } from "fs/promises";
|
|
9692
9783
|
var LAYOUT_PATH = join14(APP_HOME, ".layout.json");
|
|
9693
9784
|
var ANONYMOUS = "_";
|
|
@@ -9717,7 +9808,7 @@ async function writeLayout(client, text) {
|
|
|
9717
9808
|
const next = { ...base, [key(client)]: value };
|
|
9718
9809
|
await ensureAppHome();
|
|
9719
9810
|
tmpCounter6 += 1;
|
|
9720
|
-
const tmp = join14(APP_HOME, `.${
|
|
9811
|
+
const tmp = join14(APP_HOME, `.${basename7(LAYOUT_PATH)}.tmp-${process.pid}-${tmpCounter6}`);
|
|
9721
9812
|
try {
|
|
9722
9813
|
await writeFile9(tmp, JSON.stringify(next), "utf8");
|
|
9723
9814
|
await rename9(tmp, LAYOUT_PATH);
|
|
@@ -9986,6 +10077,97 @@ function remoteCd(cwd) {
|
|
|
9986
10077
|
return `cd -- ${target} 2>/dev/null || printf 'ledge: cwd %s not found here; starting in %s\\n' ${shellQuote(cwd)} "$PWD"`;
|
|
9987
10078
|
}
|
|
9988
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
|
+
|
|
9989
10171
|
// src/bun/server.ts
|
|
9990
10172
|
import { readFileSync, statSync as statSync2 } from "fs";
|
|
9991
10173
|
function holdRunEvent(held, ev, cap) {
|
|
@@ -10059,6 +10241,7 @@ var fromB64 = (b64) => new Uint8Array(Buffer.from(b64, "base64"));
|
|
|
10059
10241
|
async function createServer(deps) {
|
|
10060
10242
|
const { push } = deps;
|
|
10061
10243
|
const settings = await loadSettings();
|
|
10244
|
+
const loginEnv = resolveLoginEnv(settings.shell.path, process.env);
|
|
10062
10245
|
await loadWorkspaces();
|
|
10063
10246
|
const first = await ensureDefault();
|
|
10064
10247
|
if (first) {
|
|
@@ -10066,8 +10249,10 @@ async function createServer(deps) {
|
|
|
10066
10249
|
}
|
|
10067
10250
|
await syncDocs();
|
|
10068
10251
|
await loadVault();
|
|
10069
|
-
const shellEnv = { ...
|
|
10252
|
+
const shellEnv = { ...await loginEnv, TERM: "xterm-256color" };
|
|
10070
10253
|
const sessionParams = new Map;
|
|
10254
|
+
const spawnKeys = new Map;
|
|
10255
|
+
const sentStale = new Map;
|
|
10071
10256
|
const sessionFacts = new Map;
|
|
10072
10257
|
const spawnDeps = {
|
|
10073
10258
|
readFile: (path) => {
|
|
@@ -10098,14 +10283,23 @@ async function createServer(deps) {
|
|
|
10098
10283
|
}
|
|
10099
10284
|
return requested;
|
|
10100
10285
|
}
|
|
10101
|
-
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
|
+
}
|
|
10102
10296
|
if (host !== LOCAL_HOST) {
|
|
10103
10297
|
const remote = buildRemoteSpawn(host, kind, sessionParams.get(sessionId), (msg) => console.warn("[session]", msg));
|
|
10104
10298
|
return new PtyProcess({
|
|
10105
10299
|
executable: remote.executable,
|
|
10106
10300
|
args: remote.args,
|
|
10107
10301
|
env: shellEnv,
|
|
10108
|
-
cwd:
|
|
10302
|
+
cwd: homedir7(),
|
|
10109
10303
|
interruptViaChar: true
|
|
10110
10304
|
});
|
|
10111
10305
|
}
|
|
@@ -10121,7 +10315,7 @@ async function createServer(deps) {
|
|
|
10121
10315
|
cwd
|
|
10122
10316
|
});
|
|
10123
10317
|
}
|
|
10124
|
-
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);
|
|
10125
10319
|
const terms = new Map;
|
|
10126
10320
|
let nextTermRunId = 1;
|
|
10127
10321
|
function termFor(sessionId, requestedHost) {
|
|
@@ -10146,6 +10340,28 @@ async function createServer(deps) {
|
|
|
10146
10340
|
}
|
|
10147
10341
|
return t;
|
|
10148
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
|
+
}
|
|
10149
10365
|
function flushPaste(t, now = Date.now()) {
|
|
10150
10366
|
const out = takePaste(t, now);
|
|
10151
10367
|
if (out !== null)
|
|
@@ -10182,6 +10398,8 @@ async function createServer(deps) {
|
|
|
10182
10398
|
terms.delete(sessionId);
|
|
10183
10399
|
sessionParams.delete(sessionId);
|
|
10184
10400
|
sessionFacts.delete(sessionId);
|
|
10401
|
+
spawnKeys.delete(sessionId);
|
|
10402
|
+
sentStale.delete(sessionId);
|
|
10185
10403
|
}
|
|
10186
10404
|
function refreshWatchers() {
|
|
10187
10405
|
syncWatchers(availableRoots(), (root) => push.all.notesChanged({ root }));
|
|
@@ -10192,7 +10410,7 @@ async function createServer(deps) {
|
|
|
10192
10410
|
return;
|
|
10193
10411
|
openRequestWatcherStarted = true;
|
|
10194
10412
|
try {
|
|
10195
|
-
const requestName =
|
|
10413
|
+
const requestName = basename8(OPEN_REQUEST_PATH);
|
|
10196
10414
|
watch2(APP_HOME, (_event, filename) => {
|
|
10197
10415
|
if (filename !== requestName)
|
|
10198
10416
|
return;
|
|
@@ -10256,6 +10474,7 @@ async function createServer(deps) {
|
|
|
10256
10474
|
"noteFromTemplate",
|
|
10257
10475
|
"noteLock",
|
|
10258
10476
|
"noteMove",
|
|
10477
|
+
"noteMoveToWorkspace",
|
|
10259
10478
|
"noteRemoveLock",
|
|
10260
10479
|
"noteRetitle",
|
|
10261
10480
|
"noteStash",
|
|
@@ -10337,6 +10556,12 @@ async function createServer(deps) {
|
|
|
10337
10556
|
await refuseLockedFrom(device, path);
|
|
10338
10557
|
return { note: await moveNote(path, folder) };
|
|
10339
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
|
+
},
|
|
10340
10565
|
folderRename: ({ root, folder, name }) => renameFolder(root, folder, name),
|
|
10341
10566
|
folderDelete: ({ root, folder }) => deleteFolder(root, folder),
|
|
10342
10567
|
noteRetitle: async ({ path, text }) => ({ note: await retitleNote(path, text) }),
|
|
@@ -10514,10 +10739,13 @@ async function createServer(deps) {
|
|
|
10514
10739
|
sessionFacts.set(sessionId, { note: resolve10(notePath), workspace: root });
|
|
10515
10740
|
else
|
|
10516
10741
|
sessionFacts.delete(sessionId);
|
|
10742
|
+
refreshStale(sessionId);
|
|
10517
10743
|
return { ok: true };
|
|
10518
10744
|
},
|
|
10519
10745
|
sessionRestart: ({ sessionId }) => {
|
|
10520
10746
|
inlinePool.restartSession(sessionId, sendRunEvent);
|
|
10747
|
+
spawnKeys.delete(sessionId);
|
|
10748
|
+
refreshStale(sessionId);
|
|
10521
10749
|
wake();
|
|
10522
10750
|
const t = terms.get(sessionId);
|
|
10523
10751
|
if (t) {
|
|
@@ -10627,6 +10855,9 @@ async function createServer(deps) {
|
|
|
10627
10855
|
terms.delete(sessionId);
|
|
10628
10856
|
}
|
|
10629
10857
|
}
|
|
10858
|
+
for (const [sessionId, stale] of sentStale)
|
|
10859
|
+
if (stale)
|
|
10860
|
+
refreshStale(sessionId);
|
|
10630
10861
|
if (awake)
|
|
10631
10862
|
lastBusyAt = now;
|
|
10632
10863
|
pace(now - lastBusyAt < DRAIN_SETTLE_MS ? DRAIN_FAST_MS : DRAIN_IDLE_MS);
|
|
@@ -10685,6 +10916,7 @@ var REQUEST_METHODS = [
|
|
|
10685
10916
|
"noteWrite",
|
|
10686
10917
|
"noteCreate",
|
|
10687
10918
|
"noteMove",
|
|
10919
|
+
"noteMoveToWorkspace",
|
|
10688
10920
|
"folderRename",
|
|
10689
10921
|
"folderDelete",
|
|
10690
10922
|
"noteRetitle",
|
|
@@ -10766,6 +10998,7 @@ var PUSH_MESSAGES = [
|
|
|
10766
10998
|
"terminalBusy",
|
|
10767
10999
|
"terminalExit",
|
|
10768
11000
|
"terminalDetached",
|
|
11001
|
+
"sessionStale",
|
|
10769
11002
|
"presence",
|
|
10770
11003
|
"notesChanged",
|
|
10771
11004
|
"openExternal",
|
|
@@ -11531,7 +11764,7 @@ function createOpLog(opts) {
|
|
|
11531
11764
|
}
|
|
11532
11765
|
|
|
11533
11766
|
// src/shared/version.ts
|
|
11534
|
-
var BUILD_VERSION = "0.1.
|
|
11767
|
+
var BUILD_VERSION = "0.1.1";
|
|
11535
11768
|
|
|
11536
11769
|
// src/bun/daemon.ts
|
|
11537
11770
|
var SOCKET_PATH = join16(APP_HOME, ".server.sock");
|
|
@@ -11794,7 +12027,7 @@ function daemonPid(pidPath = PID_PATH) {
|
|
|
11794
12027
|
|
|
11795
12028
|
// src/bun/backupCli.ts
|
|
11796
12029
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
11797
|
-
import { homedir as
|
|
12030
|
+
import { homedir as homedir8 } from "os";
|
|
11798
12031
|
import { join as join19 } from "path";
|
|
11799
12032
|
|
|
11800
12033
|
// src/bun/ask.ts
|
|
@@ -12699,7 +12932,7 @@ async function restore(args) {
|
|
|
12699
12932
|
const to = valueOf(args, "--to");
|
|
12700
12933
|
if (inPlace && to)
|
|
12701
12934
|
return usage("--in-place restores to the original paths; --to names another folder. One or the other.");
|
|
12702
|
-
const target = inPlace ? "/" : to ?? join19(
|
|
12935
|
+
const target = inPlace ? "/" : to ?? join19(homedir8(), `ledge-restore-${stamp(new Date)}`);
|
|
12703
12936
|
const include = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--snapshot" && args[i - 1] !== "--to");
|
|
12704
12937
|
if (inPlace && daemonRunning()) {
|
|
12705
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.`);
|