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.
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 basename2, dirname as dirname3, join as join5, relative as relative2, resolve as resolve3, sep as sep3 } from "path";
662
+ import { basename as basename3, dirname as dirname3, join as join5, relative as relative2, resolve as resolve3, sep as sep3 } from "path";
663
663
  import { mkdir as mkdir3, open, readdir as readdir3, readFile as readFile5, rename as rename4, rmdir as rmdir2, stat as stat2, unlink as unlink4, writeFile as writeFile4 } from "fs/promises";
664
664
 
665
665
  // src/shared/rpc-schema.ts
@@ -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, `.${basename2(path)}.tmp-${process.pid}-${tmpCounter4}`);
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, `.${basename2(path)}.tmp-${process.pid}-${tmpCounter4}`);
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(root, folder);
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 rename4(from, target);
2243
- const rebased = rebaseAssetRefs(file.text, root, from, target);
2256
+ const rebased = dest === root ? rebaseAssetRefs(file.text, root, from, target) : await carryAssetRefs(file.text, root, from, dest, target);
2257
+ let baseMtimeMs = file.mtimeMs;
2258
+ try {
2259
+ await rename4(from, target);
2260
+ } catch (err) {
2261
+ if (err.code !== "EXDEV")
2262
+ throw err;
2263
+ await carryAcrossVolumes(from, target);
2264
+ baseMtimeMs = null;
2265
+ }
2244
2266
  if (rebased !== file.text)
2245
- await writeNote(target, rebased, file.mtimeMs);
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 = basename2(path);
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 basename3, isAbsolute as isAbsolute2, join as join6, resolve as resolve4 } from "path";
3102
+ import { basename as basename4, isAbsolute as isAbsolute2, join as join6, resolve as resolve4 } from "path";
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(basename3(path));
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 (basename3(path) !== "zsh")
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 homedir6 } from "os";
4236
- import { basename as basename7, resolve as resolve10 } from "path";
4298
+ import { homedir as homedir7 } from "os";
4299
+ import { basename as basename8, resolve as resolve10 } from "path";
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 basename5, join as join13, resolve as resolve9 } from "path";
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 after editing it run "Restart Note Shell" from the palette. It kills the note's shells and lets them respawn. Use the same command when an experiment leaves a shell in a strange state.
5480
+ Frontmatter applies to newly spawned shells, so editing \`cwd:\` leaves a running shell where it was. The block grows a **Restart Note Shell** button when that happens, and pressing it lets the shells respawn with what the note now says. The same command is in the palette and the Note menu, for when an experiment leaves a shell in a strange state. [[Frontmatter and Environments]] has the detail.
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
- After editing the block, run "Restart Note Shell" from the command palette (\u21E7\u2318P). It kills the note's shells, and the next run or drawer visit respawns them with the current frontmatter. Use the same command when an experiment leaves a shell in a strange state.
6030
+ So editing \`cwd:\` does not move a shell that is already running. A block that printed one directory keeps printing it until the note's shells are replaced.
6031
+
6032
+ The block says when that has happened. Edit a key that feeds a shell while the note has one running, and a **Restart Note Shell** button appears at the end of the block. Pressing it kills the note's shells; the next run or drawer visit spawns them with the frontmatter as it now reads.
6033
+
6034
+ The same command is in the palette (\u21E7\u2318P) and the Note menu. Use it when an experiment leaves a shell in a strange state, too.
6035
+
6036
+ The button is about the four keys a shell is spawned with: \`cwd\`, \`profile\`, \`envFile\`, and \`env\`. The rest of the block applies as you type it, so tagging or favoriting a note never raises it. Typing the old value back takes it down again, and so does the shell exiting on its own.
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. Run \"Restart Note Shell\" after changing one.\n\n## Profiles stay with the notes\n\nA profile lives on the machine that holds the notes and runs their blocks. With your notes on this Mac, that is this Mac. With your notes on a server, the file is on the server, \"Edit Note Profile\u2026\" edits it there, and the values never come to this app. [[Keep Notes on a Remote Server]] has the table of what lives where.\n\nWhen a note runs its blocks on a remote host over ssh, Ledge does not send the profile ([[Run Code on Remote Hosts]]). A secret passed on a remote command line would be visible in that machine's process table to anyone who can list processes. If a remote run needs credentials, put them on the remote machine.\n";
6055
+ var _07_profiles_and_secrets_default = "# Profiles and Secrets\n\nA profile is a named file of environment variables that lives outside your notes folder and is injected into the shells of any note that names it.\n\nUse one for secrets. Notes get synced, backed up, shared, and read by agents, so an API key written in an `env:` line travels everywhere the note does. With a profile, the note carries only a name.\n\n## Declare a profile\n\nAdd one line of frontmatter (see [[Frontmatter and Environments]] for the block itself):\n\n```\n---\nprofile: deploy\n---\n```\n\nProfile names may contain letters, digits, `-`, and `_`. The name resolves to a file under `~/.config/ledge/profiles/` on the machine holding the notes, here `deploy.env`, created for you the first time you open it for editing.\n\nA note names at most one profile, and any number of notes can share one. Every deploy-related note can say `profile: deploy` and pick up the same credentials.\n\nOne name is taken. `backup` is the profile `ledge backup setup` writes, holding the backup repository and its credentials ([[Keep Notes on a Remote Server]]). A note that says `profile: backup` runs with those variables, which is how a note runs restic by hand.\n\n## Edit a profile\n\nClick the profile name in the frontmatter block, or run \"Edit Note Profile\u2026\" from the command palette. The command appears whenever the current note names a profile.\n\nOn a touch device the palette command is the whole of it. The small key button beside the name is a pointer control and is not drawn there, and the command asks for nothing to be pointed at: it follows the note you are in.\n\nEither way you get Ledge's profile editor: KEY=value rows with the values masked.\n\nOn disk the profile is a plain dotenv file: `KEY=value` per line, `#` comments, and an optional `export ` prefix. Ledge creates it readable only by you. Hand edits and editor edits coexist, and saves from the editor preserve your comments.\n\n```\n# deploy.env\nAPI_TOKEN=abc123\nDEPLOY_REGION=eu-west-1\n```\n\n## How profiles layer\n\nProfile variables merge into the shell environment at spawn, above the note's `envFile` and below its inline `env:` lines. An `env:` line can therefore override a profile value for one note without editing the shared file.\n\nA `profile:` line naming a file that does not exist is skipped, and the shell spawns without it.\n\nA profile edit applies to newly spawned shells, like every frontmatter change. Changing which profile a note names raises the block's **Restart Note Shell** button; editing the values inside a profile file does not, so run the command yourself after that.\n\n## Profiles stay with the notes\n\nA profile lives on the machine that holds the notes and runs their blocks. With your notes on this Mac, that is this Mac. With your notes on a server, the file is on the server, \"Edit Note Profile\u2026\" edits it there, and the values never come to this app. [[Keep Notes on a Remote Server]] has the table of what lives where.\n\nWhen a note runs its blocks on a remote host over ssh, Ledge does not send the profile ([[Run Code on Remote Hosts]]). A secret passed on a remote command line would be visible in that machine's process table to anyone who can list processes. If a remote run needs credentials, put them on the remote machine.\n";
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), `.${basename5(path)}.tmp-${process.pid}-${tmpCounter5}`);
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 basename6, join as join14 } from "path";
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, `.${basename6(LAYOUT_PATH)}.tmp-${process.pid}-${tmpCounter6}`);
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 = { ...process.env, TERM: "xterm-256color" };
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: homedir6(),
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 = basename7(OPEN_REQUEST_PATH);
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.0";
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 homedir7 } from "os";
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(homedir7(), `ledge-restore-${stamp(new Date)}`);
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.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ledge-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The Ledge server: your notes and shells on another machine, reached over ssh.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",