ledung 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -4,8 +4,8 @@ import os16 from "node:os";
4
4
  import path40 from "node:path";
5
5
 
6
6
  // src/config.ts
7
- import fs9 from "node:fs";
8
- import path12 from "node:path";
7
+ import fs10 from "node:fs";
8
+ import path13 from "node:path";
9
9
  import { z as z6 } from "zod";
10
10
 
11
11
  // src/platform/paths.ts
@@ -2065,30 +2065,55 @@ function readPublishedUsage(raw) {
2065
2065
  var USAGE_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
2066
2066
 
2067
2067
  // src/accounts/store.ts
2068
- import fs8 from "node:fs";
2068
+ import fs9 from "node:fs";
2069
2069
  import os5 from "node:os";
2070
- import path11 from "node:path";
2070
+ import path12 from "node:path";
2071
2071
 
2072
2072
  // src/accounts/about-file.ts
2073
- import fs7 from "node:fs";
2074
- import path10 from "node:path";
2073
+ import fs8 from "node:fs";
2074
+ import path11 from "node:path";
2075
2075
 
2076
2076
  // src/accounts/signin-store.ts
2077
2077
  import { createHash } from "node:crypto";
2078
- import fs6 from "node:fs";
2078
+ import fs7 from "node:fs";
2079
2079
  import os4 from "node:os";
2080
- import path9 from "node:path";
2080
+ import path10 from "node:path";
2081
2081
 
2082
2082
  // src/accounts/signin.ts
2083
2083
  import { execFile as execFile2 } from "node:child_process";
2084
2084
 
2085
2085
  // src/runners/claude-program.ts
2086
2086
  import { execFile } from "node:child_process";
2087
- import fs5 from "node:fs";
2087
+ import fs6 from "node:fs";
2088
2088
  import { createRequire } from "node:module";
2089
2089
  import os3 from "node:os";
2090
- import path8 from "node:path";
2090
+ import path9 from "node:path";
2091
2091
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2092
+
2093
+ // src/platform/exercised.ts
2094
+ import fs5 from "node:fs";
2095
+ import path8 from "node:path";
2096
+ var EXERCISED_FILE = "exercised.log";
2097
+ var MAX_BYTES = 256 * 1024;
2098
+ var DEFAULT_TARGET = {
2099
+ console: !PACKAGED,
2100
+ file: () => path8.join(userDataDir(), EXERCISED_FILE)
2101
+ };
2102
+ function exercisedLine(line, target = DEFAULT_TARGET) {
2103
+ if (target.console) console.log(line);
2104
+ try {
2105
+ const file = target.file();
2106
+ if (!file) return;
2107
+ const size = fs5.statSync(file, { throwIfNoEntry: false })?.size ?? 0;
2108
+ const text2 = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
2109
+ `;
2110
+ if (size > MAX_BYTES) fs5.writeFileSync(file, text2);
2111
+ else fs5.appendFileSync(file, text2);
2112
+ } catch {
2113
+ }
2114
+ }
2115
+
2116
+ // src/runners/claude-program.ts
2092
2117
  var SDK = "@anthropic-ai/claude-agent-sdk";
2093
2118
  function sdkClaudeBinary(resolve = createRequire(import.meta.resolve(SDK)).resolve) {
2094
2119
  const exe = process.platform === "win32" ? ".exe" : "";
@@ -2109,8 +2134,8 @@ function sdkClaudeBinary(resolve = createRequire(import.meta.resolve(SDK)).resol
2109
2134
  }
2110
2135
  function sdkClaudeVersion() {
2111
2136
  try {
2112
- const sdk = path8.dirname(fileURLToPath2(import.meta.resolve(SDK)));
2113
- const v = JSON.parse(fs5.readFileSync(path8.join(sdk, "package.json"), "utf8")).claudeCodeVersion;
2137
+ const sdk = path9.dirname(fileURLToPath2(import.meta.resolve(SDK)));
2138
+ const v = JSON.parse(fs6.readFileSync(path9.join(sdk, "package.json"), "utf8")).claudeCodeVersion;
2114
2139
  return typeof v === "string" ? v : null;
2115
2140
  } catch {
2116
2141
  return null;
@@ -2134,6 +2159,26 @@ var runVersion = (program) => new Promise((resolve) => {
2134
2159
  const { command, args } = commandFor(program);
2135
2160
  execFile(command, [...args, "--version"], { windowsHide: true, timeout: 15e3 }, (err, stdout) => resolve(err ? null : String(stdout)));
2136
2161
  });
2162
+ function regValue(text2, name, env) {
2163
+ const row = text2.split(/\r?\n/).map((l) => l.trim().split(/\s{2,}|\t/)).find((cols) => cols[0]?.toLowerCase() === name.toLowerCase() && /^REG_(EXPAND_)?SZ$/i.test(cols[1] ?? ""));
2164
+ const value = row?.slice(2).join(" ");
2165
+ if (!value) return null;
2166
+ return value.replace(/%([^%]+)%/g, (whole, v) => {
2167
+ const key = Object.keys(env).find((k) => k.toLowerCase() === v.toLowerCase());
2168
+ return key ? env[key] ?? whole : whole;
2169
+ });
2170
+ }
2171
+ var regQuery = (key) => new Promise((resolve) => {
2172
+ execFile("reg", ["query", key, "/v", "Path"], { windowsHide: true, timeout: 5e3 }, (err, stdout) => resolve(err ? "" : String(stdout)));
2173
+ });
2174
+ async function windowsPath() {
2175
+ const [machine, user] = await Promise.all([
2176
+ regQuery("HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment"),
2177
+ regQuery("HKCU\\Environment")
2178
+ ]);
2179
+ const parts2 = [regValue(machine, "Path", process.env), regValue(user, "Path", process.env)].filter((v) => !!v);
2180
+ return parts2.length ? parts2.join(";") : null;
2181
+ }
2137
2182
  function realLookup() {
2138
2183
  return {
2139
2184
  platform: process.platform,
@@ -2141,7 +2186,7 @@ function realLookup() {
2141
2186
  home: os3.homedir(),
2142
2187
  stat: (file) => {
2143
2188
  try {
2144
- const s = fs5.statSync(file);
2189
+ const s = fs6.statSync(file);
2145
2190
  return s.isFile() ? { size: s.size, mtimeMs: s.mtimeMs } : null;
2146
2191
  } catch {
2147
2192
  return null;
@@ -2149,19 +2194,27 @@ function realLookup() {
2149
2194
  },
2150
2195
  readText: (file) => {
2151
2196
  try {
2152
- return fs5.readFileSync(file, "utf8");
2197
+ return fs6.readFileSync(file, "utf8");
2153
2198
  } catch {
2154
2199
  return null;
2155
2200
  }
2156
2201
  },
2157
2202
  realpath: (file) => {
2158
2203
  try {
2159
- return fs5.realpathSync(file);
2204
+ return fs6.realpathSync(file);
2160
2205
  } catch {
2161
2206
  return file;
2162
2207
  }
2163
2208
  },
2164
2209
  versionText: runVersion,
2210
+ listDir: (dir) => {
2211
+ try {
2212
+ return fs6.readdirSync(dir);
2213
+ } catch {
2214
+ return [];
2215
+ }
2216
+ },
2217
+ ...process.platform === "win32" ? { freshPath: windowsPath } : {},
2165
2218
  bundled: sdkClaudeBinary(),
2166
2219
  required: sdkClaudeVersion(),
2167
2220
  packaged: PACKAGED
@@ -2174,18 +2227,26 @@ function noBundledProblem(look) {
2174
2227
  function shimTarget(shim, look) {
2175
2228
  const text2 = look.readText(shim);
2176
2229
  if (!text2) return null;
2177
- const dir = path8.win32.dirname(shim);
2178
- const found = [...text2.matchAll(/"%~?dp0%?\\?([^"%]+?\.(?:exe|c?js|mjs))"/gi)].map((m) => path8.win32.join(dir, m[1]));
2230
+ const dir = path9.win32.dirname(shim);
2231
+ const found = [...text2.matchAll(/"%~?dp0%?\\?([^"%]+?\.(?:exe|c?js|mjs))"/gi)].map((m) => path9.win32.join(dir, m[1]));
2179
2232
  return found.reverse().find((f) => look.stat(f) !== null) ?? null;
2180
2233
  }
2181
2234
  function knownLocations(look) {
2182
2235
  const { home, env } = look;
2183
2236
  if (look.platform === "win32") {
2184
- const p2 = path8.win32;
2237
+ const p2 = path9.win32;
2185
2238
  const appData = env.APPDATA ?? p2.join(home, "AppData", "Roaming");
2186
- return [p2.join(home, ".local", "bin", "claude.exe"), p2.join(appData, "npm", "claude.cmd"), ...env.npm_config_prefix ? [p2.join(env.npm_config_prefix, "claude.cmd")] : []];
2239
+ const localAppData = env.LOCALAPPDATA ?? p2.join(home, "AppData", "Local");
2240
+ return [
2241
+ p2.join(home, ".local", "bin", "claude.exe"),
2242
+ p2.join(appData, "npm", "claude.cmd"),
2243
+ ...env.npm_config_prefix ? [p2.join(env.npm_config_prefix, "claude.cmd")] : [],
2244
+ // `winget install Anthropic.ClaudeCode` links it here, for the user or for the whole computer (#455).
2245
+ p2.join(localAppData, "Microsoft", "WinGet", "Links", "claude.exe"),
2246
+ p2.join(env.ProgramFiles ?? "C:\\Program Files", "WinGet", "Links", "claude.exe")
2247
+ ];
2187
2248
  }
2188
- const p = path8.posix;
2249
+ const p = path9.posix;
2189
2250
  return [
2190
2251
  p.join(home, ".local", "bin", "claude"),
2191
2252
  p.join(home, ".claude", "local", "claude"),
@@ -2204,24 +2265,65 @@ function programOf(file, look) {
2204
2265
  const real2 = look.realpath(file);
2205
2266
  return /\.(c|m)?js$/i.test(real2) ? real2 : file;
2206
2267
  }
2207
- function findInstalled(look) {
2268
+ function isDesktopApp(file) {
2269
+ return /[\\/]AnthropicClaude[\\/](app-[^\\/]+[\\/])?claude\.exe$/i.test(file) || /[\\/]Claude\.app[\\/]Contents[\\/]MacOS[\\/]/.test(file);
2270
+ }
2271
+ function searchInstalled(look, pathVar = look.env.PATH ?? look.env.Path ?? "") {
2208
2272
  const win = look.platform === "win32";
2209
2273
  const sep = win ? ";" : ":";
2210
2274
  const names = win ? ["claude.exe", "claude.cmd"] : ["claude"];
2211
- const pathVar = look.env.PATH ?? look.env.Path ?? "";
2212
- const onPath = pathVar.split(sep).filter(Boolean).flatMap((dir) => names.map((n) => (win ? path8.win32 : path8.posix).join(dir, n)));
2275
+ const onPath = pathVar.split(sep).filter(Boolean).flatMap((dir) => names.map((n) => (win ? path9.win32 : path9.posix).join(dir, n)));
2213
2276
  const bundled = look.bundled ? look.realpath(look.bundled) : null;
2214
2277
  const same = (a, b) => win ? a.toLowerCase() === b.toLowerCase() : a === b;
2278
+ const looked = [];
2215
2279
  for (const file of [...onPath, ...knownLocations(look)]) {
2280
+ if (looked.some((l) => same(l, file))) continue;
2281
+ looked.push(file);
2216
2282
  if (!look.stat(file)) continue;
2217
2283
  const program = programOf(file, look);
2218
2284
  if (!program || !look.stat(program)) continue;
2219
2285
  const real2 = look.realpath(program);
2220
2286
  if (bundled && same(real2, bundled)) continue;
2221
2287
  if (/[\\/]node_modules[\\/]@anthropic-ai[\\/]claude-agent-sdk/i.test(real2)) continue;
2222
- return program;
2288
+ if (isDesktopApp(real2)) continue;
2289
+ return { program, looked };
2223
2290
  }
2224
- return null;
2291
+ return { program: null, looked };
2292
+ }
2293
+ function desktopAppFolders(look) {
2294
+ const { home, env } = look;
2295
+ if (look.platform === "win32") {
2296
+ const p2 = path9.win32;
2297
+ const appData = env.APPDATA ?? p2.join(home, "AppData", "Roaming");
2298
+ const packages = p2.join(env.LOCALAPPDATA ?? p2.join(home, "AppData", "Local"), "Packages");
2299
+ const store = (look.listDir?.(packages) ?? []).filter((n) => /^Claude_/i.test(n)).map((n) => p2.join(packages, n, "LocalCache", "Roaming", "Claude", "claude-code"));
2300
+ return [p2.join(appData, "Claude", "claude-code"), ...store];
2301
+ }
2302
+ const p = path9.posix;
2303
+ if (look.platform === "darwin") return [p.join(home, "Library", "Application Support", "Claude", "claude-code")];
2304
+ return [p.join(env.XDG_CONFIG_HOME ?? p.join(home, ".config"), "Claude", "claude-code")];
2305
+ }
2306
+ function appProgramIn(look, versionDir, hash2) {
2307
+ if (look.platform === "win32") return path9.win32.join(versionDir, "claude.exe");
2308
+ if (look.platform !== "darwin") return path9.posix.join(versionDir, "claude");
2309
+ const inBundle = (h) => path9.posix.join(versionDir, h, "claude.app", "Contents", "MacOS", "claude");
2310
+ if (hash2) return inBundle(hash2);
2311
+ return (look.listDir?.(versionDir) ?? []).sort().map(inBundle).find((f) => look.stat(f)) ?? null;
2312
+ }
2313
+ function desktopAppCopy(look) {
2314
+ const p = look.platform === "win32" ? path9.win32 : path9.posix;
2315
+ const looked = [];
2316
+ let best = null;
2317
+ for (const folder of desktopAppFolders(look)) {
2318
+ looked.push(appProgramIn(look, p.join(folder, "<version>"), "<hash>"));
2319
+ for (const name of look.listDir?.(folder) ?? []) {
2320
+ const version = parseVersion(name);
2321
+ const program = version ? appProgramIn(look, p.join(folder, name)) : null;
2322
+ if (!version || !program || !look.stat(program)) continue;
2323
+ if (!best || !atLeast(best.version, version)) best = { program, version };
2324
+ }
2325
+ }
2326
+ return { program: best?.program ?? null, looked };
2225
2327
  }
2226
2328
  var versions = /* @__PURE__ */ new Map();
2227
2329
  async function programVersion(program, look) {
@@ -2234,6 +2336,10 @@ async function programVersion(program, look) {
2234
2336
  return version;
2235
2337
  }
2236
2338
  var INSTALL_LINK = "https://code.claude.com/docs/en/setup";
2339
+ function installCommand(platform) {
2340
+ return platform === "win32" ? "irm https://claude.ai/install.ps1 | iex" : "curl -fsSL https://claude.ai/install.sh | bash";
2341
+ }
2342
+ var appCopyLogged = false;
2237
2343
  async function chooseClaude(setting = "installed", look = realLookup()) {
2238
2344
  const required = look.required;
2239
2345
  const bundledVersion = look.bundled ? await programVersion(look.bundled, look) ?? required : null;
@@ -2243,20 +2349,49 @@ async function chooseClaude(setting = "installed", look = realLookup()) {
2243
2349
  return { ...base, installed: null, program: bundled, problem: bundled ? null : noBundledProblem(look) };
2244
2350
  }
2245
2351
  const given = setting !== "installed";
2246
- const found = given ? look.stat(setting) ? programOf(setting, look) : null : findInstalled(look);
2352
+ let found;
2353
+ let looked;
2354
+ let app = false;
2355
+ if (given) {
2356
+ found = look.stat(setting) ? programOf(setting, look) : null;
2357
+ looked = [setting];
2358
+ } else {
2359
+ ({ program: found, looked } = searchInstalled(look));
2360
+ const fresh = found || !look.freshPath ? null : await look.freshPath();
2361
+ if (fresh) {
2362
+ const again = searchInstalled(look, fresh);
2363
+ found = again.program;
2364
+ looked = [...looked, ...again.looked.filter((l) => !looked.includes(l))];
2365
+ }
2366
+ if (!found) {
2367
+ const copy = desktopAppCopy(look);
2368
+ found = copy.program;
2369
+ looked = [...looked, ...copy.looked];
2370
+ app = found !== null;
2371
+ if (app && !appCopyLogged) {
2372
+ appCopyLogged = true;
2373
+ exercisedLine("EXERCISED #455 Claude Code found as the desktop app's own copy");
2374
+ }
2375
+ }
2376
+ }
2247
2377
  const version = found ? await programVersion(found, look) : null;
2248
- const installed = found ? { kind: given ? "path" : "installed", path: found, version } : null;
2378
+ const installed = found ? { kind: given ? "path" : "installed", path: found, version, ...app ? { app: true } : {} } : null;
2379
+ const install = `install Claude Code (\`${installCommand(look.platform)}\`, or see ${INSTALL_LINK}), then press Check again`;
2249
2380
  let problem2 = null;
2250
2381
  if (!required) {
2251
2382
  problem2 = "Ledung could not read which Claude Code version its Agent SDK needs (the SDK's package.json). Install Ledung again (`npm install`).";
2252
2383
  } else if (!found) {
2253
2384
  problem2 = given ? `Ledung runs your Claude Code, and there is none at ${setting} (the config's "claudeExecutable"). Install it from ${INSTALL_LINK}, or correct the path.` : `Ledung runs your Claude Code, and it isn't installed on this computer. Install it from ${INSTALL_LINK}, then press Check again.`;
2385
+ } else if (app && !version) {
2386
+ problem2 = `Ledung runs your Claude Code, and the only one on this computer is the Claude desktop app's own copy at ${found}, whose version couldn't be read. The app keeps that copy for itself: ${install}.`;
2387
+ } else if (app && !atLeast(version, required)) {
2388
+ problem2 = `Ledung runs your Claude Code, and the only one on this computer is the Claude desktop app's own copy, ${version} at ${found}; Ledung needs ${required} or newer. The app keeps that copy for itself: ${install}.`;
2254
2389
  } else if (!version) {
2255
2390
  problem2 = `Ledung runs your Claude Code, and the version of the one at ${found} couldn't be read. Update it (\`claude update\`) or install it again from ${INSTALL_LINK}.`;
2256
2391
  } else if (!atLeast(version, required)) {
2257
2392
  problem2 = `Ledung runs your Claude Code, and yours is ${version}; Ledung needs ${required} or newer. Update it (\`claude update\`), then press Check again.`;
2258
2393
  }
2259
- return { ...base, installed, program: problem2 ? null : installed, problem: problem2 };
2394
+ return { ...base, installed, program: problem2 ? null : installed, problem: problem2, looked };
2260
2395
  }
2261
2396
 
2262
2397
  // src/accounts/signin.ts
@@ -2372,7 +2507,7 @@ async function removeSignIn(place, platform = process.platform, run8 = runWithIn
2372
2507
  const { service, account } = keychainEntry(place.folder);
2373
2508
  await run8("security", ["delete-generic-password", "-a", account, "-s", service], null, 2e4);
2374
2509
  } else {
2375
- fs6.rmSync(path9.join(place.folder, CREDENTIALS), { force: true });
2510
+ fs7.rmSync(path10.join(place.folder, CREDENTIALS), { force: true });
2376
2511
  }
2377
2512
  } catch {
2378
2513
  }
@@ -2406,12 +2541,12 @@ function aboutText(a, platform = process.platform) {
2406
2541
  return lines.join(platform === "win32" ? "\r\n" : "\n") + (platform === "win32" ? "\r\n" : "\n");
2407
2542
  }
2408
2543
  function writeAboutFile(a, platform = process.platform) {
2409
- const file = path10.join(a.configDir, ABOUT_FILE);
2544
+ const file = path11.join(a.configDir, ABOUT_FILE);
2410
2545
  const text2 = aboutText(a, platform);
2411
2546
  try {
2412
- if (!fs7.statSync(a.configDir).isDirectory()) return false;
2413
- if (fs7.existsSync(file) && fs7.readFileSync(file, "utf8") === text2) return false;
2414
- fs7.writeFileSync(file, text2);
2547
+ if (!fs8.statSync(a.configDir).isDirectory()) return false;
2548
+ if (fs8.existsSync(file) && fs8.readFileSync(file, "utf8") === text2) return false;
2549
+ fs8.writeFileSync(file, text2);
2415
2550
  return true;
2416
2551
  } catch {
2417
2552
  return false;
@@ -2428,7 +2563,7 @@ function defaultContainerDir(name) {
2428
2563
  return `~/.ledung-accounts/${name}`;
2429
2564
  }
2430
2565
  function canonicalFolder(folder) {
2431
- return path11.resolve(folder);
2566
+ return path12.resolve(folder);
2432
2567
  }
2433
2568
  function canonicalContainerFolder(folder) {
2434
2569
  const f = folder.trim().replace(/\/+$/, "");
@@ -2441,14 +2576,14 @@ function sameFolder(a, b, platform = process.platform) {
2441
2576
  return platform === "win32" || platform === "darwin" ? x.toLowerCase() === y.toLowerCase() : x === y;
2442
2577
  }
2443
2578
  function sharedLoginFolders() {
2444
- return [path11.join(os5.homedir(), ".claude"), ...process.env.CLAUDE_CONFIG_DIR ? [process.env.CLAUDE_CONFIG_DIR] : []];
2579
+ return [path12.join(os5.homedir(), ".claude"), ...process.env.CLAUDE_CONFIG_DIR ? [process.env.CLAUDE_CONFIG_DIR] : []];
2445
2580
  }
2446
2581
  var SHARED_CONTAINER_FOLDERS = ["~/.claude"];
2447
2582
  var AccountStore = class {
2448
2583
  db;
2449
2584
  /** Ledung's own accounts folder: the default login folders. */
2450
2585
  dir;
2451
- constructor(db, dir = path11.join(userDataDir(), "accounts")) {
2586
+ constructor(db, dir = path12.join(userDataDir(), "accounts")) {
2452
2587
  this.db = db;
2453
2588
  this.dir = dir;
2454
2589
  }
@@ -2461,7 +2596,7 @@ var AccountStore = class {
2461
2596
  }
2462
2597
  /** The default login folder on this computer for an account of this name. */
2463
2598
  defaultFolder(name) {
2464
- return canonicalFolder(path11.join(this.dir, name));
2599
+ return canonicalFolder(path12.join(this.dir, name));
2465
2600
  }
2466
2601
  /** Adds an account: its login folder is made, empty until someone signs in to it. */
2467
2602
  add(a, whatItIsFor) {
@@ -2584,7 +2719,7 @@ var AccountStore = class {
2584
2719
  };
2585
2720
  function makeFolder(folder) {
2586
2721
  try {
2587
- fs8.mkdirSync(folder, { recursive: true });
2722
+ fs9.mkdirSync(folder, { recursive: true });
2588
2723
  } catch (e) {
2589
2724
  throw new AccountError(`its sign-in folder ${folder} can't be made (${e.code ?? e.message}).`);
2590
2725
  }
@@ -2709,7 +2844,7 @@ function withoutNotes(raw) {
2709
2844
  }
2710
2845
  var ClaudeAccountSetting = z6.preprocess(withoutNotes, z6.object({
2711
2846
  whatItIsFor: z6.string().trim().max(200).optional(),
2712
- signInFolderOnThisComputer: z6.string().trim().min(1, "give a folder, or leave the field out for Ledung's own").refine((f) => path12.isAbsolute(f), "give the whole path, such as D:\\Claude sign-ins\\work or /Users/you/claude-signins/work").refine((f) => !sharedLoginFolders().some((s) => sameFolder(s, f)), "that is this computer's own Claude Code folder; an account needs a folder of its own").optional(),
2847
+ signInFolderOnThisComputer: z6.string().trim().min(1, "give a folder, or leave the field out for Ledung's own").refine((f) => path13.isAbsolute(f), "give the whole path, such as D:\\Claude sign-ins\\work or /Users/you/claude-signins/work").refine((f) => !sharedLoginFolders().some((s) => sameFolder(s, f)), "that is this computer's own Claude Code folder; an account needs a folder of its own").optional(),
2713
2848
  signInFolderInContainers: z6.string().trim().min(1, "give a folder, or leave the field out for ~/.ledung-accounts/<name>").refine((f) => canonicalContainerFolder(f) !== null, "starts with ~/ or /, of letters, digits and . _ / + - only").refine((f) => canonicalContainerFolder(f) !== "~/.claude", "that is the container user's own Claude Code folder; an account needs a folder of its own").optional()
2714
2849
  }).strict());
2715
2850
  var ClaudeAccountsSetting = z6.preprocess(withoutNotes, z6.record(z6.string(), ClaudeAccountSetting)).default({}).superRefine((accounts, ctx) => {
@@ -2758,7 +2893,7 @@ var LocalProject = z6.object({
2758
2893
  * Folders outside the project folder where its sessions may also work, such as where its git worktrees
2759
2894
  * go (start_session's cwd, decision of 2026-10-14). None: only the project folder and folders inside it.
2760
2895
  */
2761
- extraFolders: z6.array(z6.string().min(1).refine((f) => path12.isAbsolute(f), "Give each extra folder as a full path.")).max(20).optional(),
2896
+ extraFolders: z6.array(z6.string().min(1).refine((f) => path13.isAbsolute(f), "Give each extra folder as a full path.")).max(20).optional(),
2762
2897
  model: z6.string().optional(),
2763
2898
  /** Its manager's model; none: the same as its sessions (decision of 2026-10-13). */
2764
2899
  managerModel: z6.string().optional(),
@@ -2875,7 +3010,7 @@ var Config = z6.object({
2875
3010
  * One that is missing or older than the SDK needs is never run: sessions are refused and the page says how to fix it
2876
3011
  * (Martin, 2026-10-27). "bundled" only when set here. Containers run their own.
2877
3012
  */
2878
- claudeExecutable: z6.string().min(1).refine((v) => v === "installed" || v === "bundled" || path12.isAbsolute(v), 'claudeExecutable is "installed", "bundled" or a full path to Claude Code.').default("installed"),
3013
+ claudeExecutable: z6.string().min(1).refine((v) => v === "installed" || v === "bundled" || path13.isAbsolute(v), 'claudeExecutable is "installed", "bundled" or a full path to Claude Code.').default("installed"),
2879
3014
  /** The shell a "!" command from the message box runs in on Windows (issue #212): Git Bash, as Claude's Bash tool, or PowerShell. */
2880
3015
  windowsShell: z6.enum(["bash", "powershell"]).default("bash"),
2881
3016
  board: BoardSetting,
@@ -2908,9 +3043,9 @@ function boardConfig(config) {
2908
3043
  }
2909
3044
  function configPath(packaged = PACKAGED) {
2910
3045
  const set = envSetting("CONFIG");
2911
- if (set) return path12.resolve(set);
2912
- if (packaged) return path12.join(userDataDir(), "ledung.config.json");
2913
- return path12.join(appDir(), "ledung.config.local.json");
3046
+ if (set) return path13.resolve(set);
3047
+ if (packaged) return path13.join(userDataDir(), "ledung.config.json");
3048
+ return path13.join(appDir(), "ledung.config.local.json");
2914
3049
  }
2915
3050
  function parseConfig(raw) {
2916
3051
  const result = Config.safeParse(raw);
@@ -2949,9 +3084,9 @@ var FIRST_CONFIG = {
2949
3084
  port: DEFAULT_PORT2
2950
3085
  };
2951
3086
  function createDefaultConfig(file = configPath()) {
2952
- fs9.mkdirSync(path12.dirname(file), { recursive: true });
3087
+ fs10.mkdirSync(path13.dirname(file), { recursive: true });
2953
3088
  try {
2954
- fs9.writeFileSync(file, `${JSON.stringify(FIRST_CONFIG, null, 2)}
3089
+ fs10.writeFileSync(file, `${JSON.stringify(FIRST_CONFIG, null, 2)}
2955
3090
  `, { flag: "wx" });
2956
3091
  } catch (e) {
2957
3092
  if (e.code === "EEXIST") return false;
@@ -2960,12 +3095,12 @@ function createDefaultConfig(file = configPath()) {
2960
3095
  return true;
2961
3096
  }
2962
3097
  function loadConfig(file = configPath()) {
2963
- if (!fs9.existsSync(file)) {
3098
+ if (!fs10.existsSync(file)) {
2964
3099
  throw new Error(`No config file at ${file}. Copy ledung.config.example.json to ledung.config.local.json and fill it in.`);
2965
3100
  }
2966
3101
  let raw;
2967
3102
  try {
2968
- raw = JSON.parse(fs9.readFileSync(file, "utf8"));
3103
+ raw = JSON.parse(fs10.readFileSync(file, "utf8"));
2969
3104
  } catch (e) {
2970
3105
  throw new Error(`The config file ${file} is not valid JSON: ${e.message}`);
2971
3106
  }
@@ -3029,31 +3164,6 @@ function configProblem(text2) {
3029
3164
  // src/config-watch.ts
3030
3165
  import fs11 from "node:fs";
3031
3166
  import path14 from "node:path";
3032
-
3033
- // src/platform/exercised.ts
3034
- import fs10 from "node:fs";
3035
- import path13 from "node:path";
3036
- var EXERCISED_FILE = "exercised.log";
3037
- var MAX_BYTES = 256 * 1024;
3038
- var DEFAULT_TARGET = {
3039
- console: !PACKAGED,
3040
- file: () => path13.join(userDataDir(), EXERCISED_FILE)
3041
- };
3042
- function exercisedLine(line, target = DEFAULT_TARGET) {
3043
- if (target.console) console.log(line);
3044
- try {
3045
- const file = target.file();
3046
- if (!file) return;
3047
- const size = fs10.statSync(file, { throwIfNoEntry: false })?.size ?? 0;
3048
- const text2 = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
3049
- `;
3050
- if (size > MAX_BYTES) fs10.writeFileSync(file, text2);
3051
- else fs10.appendFileSync(file, text2);
3052
- } catch {
3053
- }
3054
- }
3055
-
3056
- // src/config-watch.ts
3057
3167
  var exercised358 = false;
3058
3168
  var ConfigWatcher = class {
3059
3169
  opts;
@@ -3224,344 +3334,81 @@ function lanNames(setting) {
3224
3334
 
3225
3335
  // src/platform/shortcuts.ts
3226
3336
  import { execFile as execFile4 } from "node:child_process";
3227
- import fs12 from "node:fs";
3337
+ import fs13 from "node:fs";
3228
3338
  import os7 from "node:os";
3339
+ import path16 from "node:path";
3340
+
3341
+ // src/server/security.ts
3342
+ import crypto3 from "node:crypto";
3343
+ import fs12 from "node:fs";
3229
3344
  import path15 from "node:path";
3230
3345
 
3231
- // src/platform/icons.ts
3232
- var PNG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
3233
- function pngSize(png) {
3234
- if (png.length < 24 || !png.subarray(0, 8).equals(PNG)) throw new Error("Not a PNG image.");
3235
- return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
3236
- }
3237
- function icoFromPng(png) {
3238
- const { width, height } = pngSize(png);
3239
- if (width > 256 || height > 256) throw new Error(`An .ico image is at most 256 pixels, not ${width}\xD7${height}.`);
3240
- const head = Buffer.alloc(6 + 16);
3241
- head.writeUInt16LE(0, 0);
3242
- head.writeUInt16LE(1, 2);
3243
- head.writeUInt16LE(1, 4);
3244
- head.writeUInt8(width % 256, 6);
3245
- head.writeUInt8(height % 256, 7);
3246
- head.writeUInt8(0, 8);
3247
- head.writeUInt8(0, 9);
3248
- head.writeUInt16LE(1, 10);
3249
- head.writeUInt16LE(32, 12);
3250
- head.writeUInt32LE(png.length, 14);
3251
- head.writeUInt32LE(head.length, 18);
3252
- return Buffer.concat([head, png]);
3253
- }
3254
- var ICNS_TYPES = { 256: "ic08", 512: "ic09", 1024: "ic10" };
3255
- function icnsFromPngs(pngs) {
3256
- const parts2 = pngs.map((png) => {
3257
- const { width, height } = pngSize(png);
3258
- const type = width === height ? ICNS_TYPES[width] : void 0;
3259
- if (!type) throw new Error(`An .icns here takes square images of 256, 512 or 1024 pixels, not ${width}\xD7${height}.`);
3260
- const head2 = Buffer.alloc(8);
3261
- head2.write(type, 0, "latin1");
3262
- head2.writeUInt32BE(8 + png.length, 4);
3263
- return Buffer.concat([head2, png]);
3264
- });
3265
- const head = Buffer.alloc(8);
3266
- head.write("icns", 0, "latin1");
3267
- head.writeUInt32BE(8 + parts2.reduce((n, p) => n + p.length, 0), 4);
3268
- return Buffer.concat([head, ...parts2]);
3346
+ // src/platform/running.ts
3347
+ import crypto from "node:crypto";
3348
+ var NONCE = /^[0-9a-f]{32}$/;
3349
+ function runningProof(token, nonce) {
3350
+ return crypto.createHmac("sha256", token).update(`ledung-running:${nonce}`).digest("hex");
3269
3351
  }
3270
3352
 
3271
- // src/platform/shortcuts.ts
3272
- var ASKED_KEY = "shortcuts-asked";
3273
- var BUNDLE_ID = "org.ledung.launcher";
3274
- var LABELS = { desktop: "Desktop", "start-menu": "Start menu", applications: "Applications" };
3275
- var ENV_FILE = "shortcut.env";
3276
- function launchTarget(dataDir = userDataDir(), app = appDir(), packaged = PACKAGED) {
3277
- const releaseLauncher = path15.join(dataDir, "launcher.mjs");
3278
- const fromRelease = !path15.relative(path15.join(dataDir, "app"), app).startsWith("..") && fs12.existsSync(releaseLauncher);
3279
- const script = fromRelease ? releaseLauncher : path15.join(app, packaged ? path15.join("dist", "cli.js") : path15.join("src", "cli.ts"));
3280
- const env = {};
3281
- for (const name of ["DATA_DIR", "CONFIG"]) {
3282
- const value = envSetting(name);
3283
- if (value) env[`LEDUNG_${name}`] = path15.resolve(value);
3284
- }
3285
- return Object.keys(env).length ? { node: process.execPath, script, env } : { node: process.execPath, script };
3286
- }
3287
- function envFileText(env) {
3288
- return Object.entries(env).map(([name, value]) => {
3289
- if (/[\r\n]/.test(value) || value.includes("'") && value.includes("`")) throw new Error(`${name} can't be passed to a shortcut: ${value}`);
3290
- return value.includes("'") ? `${name}=\`${value}\`
3291
- ` : `${name}='${value}'
3292
- `;
3293
- }).join("");
3294
- }
3295
- function iconFiles(app = appDir()) {
3296
- const built = path15.join(app, "web", "dist");
3297
- const dir = fs12.existsSync(path15.join(built, "icon-256.png")) ? built : path15.join(app, "web", "public");
3298
- return { png256: path15.join(dir, "icon-256.png"), png512: path15.join(dir, "icon-512.png") };
3299
- }
3300
- function powershell(script, env) {
3301
- return new Promise((resolve, reject) => {
3302
- execFile4("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `[Console]::OutputEncoding = [Text.Encoding]::UTF8; ${script}`], { env: { ...process.env, ...env }, windowsHide: true, timeout: 3e4 }, (error, stdout, stderr) => {
3303
- if (error) reject(new Error(stderr.trim() || error.message));
3304
- else resolve(stdout);
3305
- });
3306
- });
3307
- }
3308
- async function windowsFolders() {
3309
- const out = await powershell("@{ desktop = [Environment]::GetFolderPath('Desktop'); programs = [Environment]::GetFolderPath('Programs') } | ConvertTo-Json -Compress", {});
3310
- const { desktop, programs } = JSON.parse(out);
3311
- return { desktop, "start-menu": programs };
3312
- }
3313
- function macFolders() {
3314
- try {
3315
- fs12.accessSync("/Applications", fs12.constants.W_OK);
3316
- return { applications: "/Applications" };
3317
- } catch {
3318
- return { applications: path15.join(os7.homedir(), "Applications") };
3319
- }
3353
+ // src/server/devices.ts
3354
+ import crypto2 from "node:crypto";
3355
+ var DEVICE_COOKIE = "__Host-ledung_device";
3356
+ var PAIR_CODE_MS = 10 * 60 * 1e3;
3357
+ var PAIR_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
3358
+ var PAIR_CODE_LENGTH = 20;
3359
+ var PASSKEY_WINDOW_MS = 10 * 60 * 1e3;
3360
+ var TOUCH_MS = 60 * 1e3;
3361
+ var COMPUTER_IDLE_MS = 14 * 24 * 60 * 60 * 1e3;
3362
+ var COOKIE_RENEW_MS = 24 * 60 * 60 * 1e3;
3363
+ var OLD_COOKIE_GRACE_MS = 60 * 1e3;
3364
+ var hash = (secret) => crypto2.createHash("sha256").update(secret).digest("hex");
3365
+ function toDevice(r) {
3366
+ return {
3367
+ id: r.id,
3368
+ name: r.name,
3369
+ createdAt: Number(r.created_at),
3370
+ lastUsedAt: r.last_used_at == null ? null : Number(r.last_used_at),
3371
+ credentialId: r.credential_id ?? null,
3372
+ publicKey: r.public_key ? new Uint8Array(r.public_key) : null,
3373
+ counter: Number(r.counter ?? 0),
3374
+ passkeyAt: r.passkey_at == null ? null : Number(r.passkey_at),
3375
+ kind: r.kind === "computer" ? "computer" : "phone",
3376
+ partitioned: !!r.partitioned,
3377
+ loggedOnAt: r.logged_on_at == null ? null : Number(r.logged_on_at),
3378
+ cookieAt: r.cookie_at == null ? null : Number(r.cookie_at),
3379
+ pairedVia: r.paired_via ?? null
3380
+ };
3320
3381
  }
3321
- function ownDataFolders(platform) {
3322
- const root = path15.join(userDataDir(), "shortcuts");
3323
- return platform === "win32" ? { desktop: path15.join(root, "Desktop"), "start-menu": path15.join(root, "Start menu") } : { applications: path15.join(root, "Applications") };
3382
+ var deviceView = (d) => ({ id: d.id, name: d.name, createdAt: d.createdAt, lastUsedAt: d.lastUsedAt, passkey: !!d.credentialId, passkeyAt: d.passkeyAt, kind: d.kind, pairedVia: d.pairedVia });
3383
+ function deviceName(raw) {
3384
+ const name = typeof raw === "string" ? raw.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 60) : "";
3385
+ return name || "Phone";
3324
3386
  }
3325
- var shQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
3326
- var xmlEscape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3327
- var PLIST = (version) => `<?xml version="1.0" encoding="UTF-8"?>
3328
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3329
- <plist version="1.0">
3330
- <dict>
3331
- <key>CFBundleExecutable</key><string>Ledung</string>
3332
- <key>CFBundleIconFile</key><string>Ledung</string>
3333
- <key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
3334
- <key>CFBundleName</key><string>Ledung</string>
3335
- <key>CFBundleDisplayName</key><string>Ledung</string>
3336
- <key>CFBundlePackageType</key><string>APPL</string>
3337
- <key>CFBundleShortVersionString</key><string>${xmlEscape(version)}</string>
3338
- <key>LSMinimumSystemVersion</key><string>10.13</string>
3339
- </dict>
3340
- </plist>
3341
- `;
3342
- var Shortcuts = class {
3343
- opts;
3344
- platform;
3345
- folderCache = null;
3346
- constructor(opts) {
3347
- this.opts = opts;
3348
- this.platform = opts.platform ?? process.platform;
3387
+ var Devices = class {
3388
+ db;
3389
+ now;
3390
+ code = null;
3391
+ constructor(db, now = Date.now) {
3392
+ this.db = db;
3393
+ this.now = now;
3349
3394
  }
3350
- kind() {
3351
- return this.platform === "win32" ? "windows" : this.platform === "darwin" ? "mac" : "other";
3395
+ /**
3396
+ * A new pairing code; any earlier one stops working. Letters and digits only, 20 of them (103 bits, plenty for
3397
+ * one code that lives 10 minutes): the QR code carries the link in capitals, in its denser alphanumeric mode,
3398
+ * so the code is matched in either case (#194).
3399
+ */
3400
+ newPairCode(kind = "phone", via = null) {
3401
+ const code = Array.from({ length: PAIR_CODE_LENGTH }, () => PAIR_CODE_CHARS[crypto2.randomInt(PAIR_CODE_CHARS.length)]).join("");
3402
+ const expiresAt = this.now() + PAIR_CODE_MS;
3403
+ this.code = { value: Buffer.from(code), expiresAt, kind, via: via?.name ?? null, viaLink: via?.link ?? null };
3404
+ return { code, expiresAt, kind };
3352
3405
  }
3353
- folders() {
3354
- this.folderCache ??= this.opts.folders?.() ?? (envSetting("DATA_DIR") ? Promise.resolve(ownDataFolders(this.platform)) : this.platform === "win32" ? windowsFolders() : Promise.resolve(macFolders()));
3355
- this.folderCache.catch(() => this.folderCache = null);
3356
- return this.folderCache;
3357
- }
3358
- async places() {
3359
- const kind = this.kind();
3360
- if (kind === "other") return [];
3361
- const folders = await this.folders();
3362
- const ids = kind === "windows" ? ["desktop", "start-menu"] : ["applications"];
3363
- return ids.flatMap((id) => {
3364
- const folder = folders[id];
3365
- if (!folder) return [];
3366
- const file = path15.join(folder, kind === "windows" ? "Ledung.lnk" : "Ledung.app");
3367
- return [{ id, label: LABELS[id], file, present: kind === "windows" ? fs12.existsSync(file) : isOurApp(file) }];
3368
- });
3369
- }
3370
- async view() {
3371
- const kind = this.kind();
3372
- const asked = this.opts.meta.get(ASKED_KEY) !== null;
3373
- if (kind === "other") {
3374
- return { kind, available: false, reason: "Shortcuts are made on Windows and macOS. Here, start Ledung with ledung.sh or the ledung command.", asked, places: [] };
3375
- }
3376
- return { kind, available: true, reason: null, asked, places: await this.places() };
3377
- }
3378
- /** Add or Remove, or No to the first-run question. Add and No answer it for good; Remove leaves it answered. */
3379
- async act(action) {
3380
- if (action === "decline") {
3381
- this.opts.meta.set(ASKED_KEY, "declined");
3382
- return this.view();
3383
- }
3384
- if (this.kind() === "other") throw new Error("Shortcuts are made on Windows and macOS.");
3385
- const places = await this.places();
3386
- if (action === "add") {
3387
- this.writeEnvFile();
3388
- if (this.kind() === "windows") await this.addWindows(places.filter((p) => !p.present));
3389
- else {
3390
- for (const p of places.filter((p2) => !p2.present)) {
3391
- if (fs12.existsSync(p.file)) throw new Error(`There is already a Ledung.app in ${path15.dirname(p.file)} that Ledung didn't make. Rename or remove it, then add the shortcut again.`);
3392
- this.addMac(p.file);
3393
- }
3394
- }
3395
- this.opts.meta.set(ASKED_KEY, "added");
3396
- } else {
3397
- for (const p of places.filter((p2) => p2.present)) this.remove(p.file);
3398
- if (this.kind() === "windows") fs12.rmSync(this.icoFile(), { force: true });
3399
- fs12.rmSync(this.envFile(), { force: true });
3400
- }
3401
- return this.view();
3402
- }
3403
- icoFile() {
3404
- return path15.join(this.opts.iconDir, "Ledung.ico");
3405
- }
3406
- envFile() {
3407
- return path15.join(this.opts.iconDir, ENV_FILE);
3408
- }
3409
- /** The env file for a launch that carries settings, else none; the shortcuts then pass it with --env-file. */
3410
- writeEnvFile() {
3411
- const env = this.opts.launch.env ?? {};
3412
- if (!Object.keys(env).length) return void fs12.rmSync(this.envFile(), { force: true });
3413
- fs12.mkdirSync(this.opts.iconDir, { recursive: true });
3414
- fs12.writeFileSync(this.envFile(), envFileText(env));
3415
- }
3416
- /** Node's own options for a shortcut: --env-file when the launch carries settings. */
3417
- nodeOptions() {
3418
- return Object.keys(this.opts.launch.env ?? {}).length ? [`--env-file=${this.envFile()}`] : [];
3419
- }
3420
- async addWindows(places) {
3421
- if (!places.length) return;
3422
- fs12.mkdirSync(this.opts.iconDir, { recursive: true });
3423
- fs12.writeFileSync(this.icoFile(), icoFromPng(fs12.readFileSync(this.opts.icons.png256)));
3424
- for (const p of places) fs12.mkdirSync(path15.dirname(p.file), { recursive: true });
3425
- const { node, script } = this.opts.launch;
3426
- const args = [...this.nodeOptions(), script].map((a) => `"${a}"`).join(" ");
3427
- const spec = { files: places.map((p) => p.file), target: node, arguments: args, workdir: os7.homedir(), icon: this.icoFile(), description: "Open Ledung, and start it if it is not running" };
3428
- await powershell([
3429
- "$spec = $env:SHORTCUT_SPEC | ConvertFrom-Json",
3430
- "$shell = New-Object -ComObject WScript.Shell",
3431
- "foreach ($file in $spec.files) {",
3432
- " $s = $shell.CreateShortcut($file)",
3433
- " $s.TargetPath = $spec.target; $s.Arguments = $spec.arguments; $s.WorkingDirectory = $spec.workdir",
3434
- ' $s.IconLocation = "$($spec.icon),0"; $s.WindowStyle = 7; $s.Description = $spec.description',
3435
- " $s.Save()",
3436
- "}"
3437
- ].join("\n"), { SHORTCUT_SPEC: JSON.stringify(spec) });
3438
- }
3439
- /**
3440
- * Ledung.app: a script that opens a running Ledung's page (--open-only), or else opens Terminal on start.command,
3441
- * which runs the launcher there, so Ledung has a window to stop it from. Made by Ledung on this computer, so it
3442
- * carries no quarantine and Gatekeeper lets it open.
3443
- */
3444
- addMac(app) {
3445
- const { node, script } = this.opts.launch;
3446
- const contents = path15.join(app, "Contents");
3447
- fs12.mkdirSync(path15.join(contents, "MacOS"), { recursive: true });
3448
- fs12.mkdirSync(path15.join(contents, "Resources"), { recursive: true });
3449
- let version = "1";
3450
- try {
3451
- version = String(JSON.parse(fs12.readFileSync(path15.join(appDir(), "package.json"), "utf8")).version ?? "1");
3452
- } catch {
3453
- }
3454
- fs12.writeFileSync(path15.join(contents, "Info.plist"), PLIST(version));
3455
- fs12.writeFileSync(path15.join(contents, "Resources", "Ledung.icns"), icnsFromPngs([fs12.readFileSync(this.opts.icons.png256), fs12.readFileSync(this.opts.icons.png512)]));
3456
- const run8 = [...this.nodeOptions(), script].map(shQuote).join(" ");
3457
- fs12.writeFileSync(path15.join(contents, "Resources", "start.command"), [
3458
- "#!/bin/sh",
3459
- "# Starts Ledung in this Terminal window; closing it stops Ledung. Made by Ledung's Settings (issue #415).",
3460
- `NODE=${shQuote(node)}`,
3461
- '[ -x "$NODE" ] || NODE=node',
3462
- `exec "$NODE" ${run8}`,
3463
- ""
3464
- ].join("\n"), { mode: 493 });
3465
- fs12.writeFileSync(path15.join(contents, "MacOS", "Ledung"), [
3466
- "#!/bin/sh",
3467
- "# Opens a running Ledung's page, or starts Ledung in a Terminal window. Made by Ledung's Settings (issue #415).",
3468
- `NODE=${shQuote(node)}`,
3469
- `[ -x "$NODE" ] || NODE=$("\${SHELL:-/bin/zsh}" -lic 'command -v node' 2>/dev/null | tail -n 1)`,
3470
- `[ -x "$NODE" ] || { osascript -e 'display alert "Ledung cannot find Node.js" message "Install Node.js 24, or add the shortcut again in Settings in Ledung."'; exit 1; }`,
3471
- `"$NODE" ${run8} --open-only && exit 0`,
3472
- 'exec open -a Terminal "$(dirname "$0")/../Resources/start.command"',
3473
- ""
3474
- ].join("\n"), { mode: 493 });
3475
- }
3476
- /** Takes away Ledung.lnk, or a Ledung.app that is Ledung's own (its bundle id), never anything else. */
3477
- remove(file) {
3478
- if (file.endsWith(".lnk")) {
3479
- fs12.rmSync(file, { force: true });
3480
- return;
3481
- }
3482
- if (!isOurApp(file)) throw new Error(`${file} is not the Ledung.app that Ledung made; remove it yourself if you want it gone.`);
3483
- fs12.rmSync(file, { recursive: true, force: true });
3484
- }
3485
- };
3486
- function isOurApp(app) {
3487
- try {
3488
- return fs12.readFileSync(path15.join(app, "Contents", "Info.plist"), "utf8").includes(`<string>${BUNDLE_ID}</string>`);
3489
- } catch {
3490
- return false;
3491
- }
3492
- }
3493
-
3494
- // src/server/security.ts
3495
- import crypto3 from "node:crypto";
3496
- import fs13 from "node:fs";
3497
- import path16 from "node:path";
3498
-
3499
- // src/platform/running.ts
3500
- import crypto from "node:crypto";
3501
- var NONCE = /^[0-9a-f]{32}$/;
3502
- function runningProof(token, nonce) {
3503
- return crypto.createHmac("sha256", token).update(`ledung-running:${nonce}`).digest("hex");
3504
- }
3505
-
3506
- // src/server/devices.ts
3507
- import crypto2 from "node:crypto";
3508
- var DEVICE_COOKIE = "__Host-ledung_device";
3509
- var PAIR_CODE_MS = 10 * 60 * 1e3;
3510
- var PAIR_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
3511
- var PAIR_CODE_LENGTH = 20;
3512
- var PASSKEY_WINDOW_MS = 10 * 60 * 1e3;
3513
- var TOUCH_MS = 60 * 1e3;
3514
- var COMPUTER_IDLE_MS = 14 * 24 * 60 * 60 * 1e3;
3515
- var COOKIE_RENEW_MS = 24 * 60 * 60 * 1e3;
3516
- var OLD_COOKIE_GRACE_MS = 60 * 1e3;
3517
- var hash = (secret) => crypto2.createHash("sha256").update(secret).digest("hex");
3518
- function toDevice(r) {
3519
- return {
3520
- id: r.id,
3521
- name: r.name,
3522
- createdAt: Number(r.created_at),
3523
- lastUsedAt: r.last_used_at == null ? null : Number(r.last_used_at),
3524
- credentialId: r.credential_id ?? null,
3525
- publicKey: r.public_key ? new Uint8Array(r.public_key) : null,
3526
- counter: Number(r.counter ?? 0),
3527
- passkeyAt: r.passkey_at == null ? null : Number(r.passkey_at),
3528
- kind: r.kind === "computer" ? "computer" : "phone",
3529
- partitioned: !!r.partitioned,
3530
- loggedOnAt: r.logged_on_at == null ? null : Number(r.logged_on_at),
3531
- cookieAt: r.cookie_at == null ? null : Number(r.cookie_at),
3532
- pairedVia: r.paired_via ?? null
3533
- };
3534
- }
3535
- var deviceView = (d) => ({ id: d.id, name: d.name, createdAt: d.createdAt, lastUsedAt: d.lastUsedAt, passkey: !!d.credentialId, passkeyAt: d.passkeyAt, kind: d.kind, pairedVia: d.pairedVia });
3536
- function deviceName(raw) {
3537
- const name = typeof raw === "string" ? raw.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 60) : "";
3538
- return name || "Phone";
3539
- }
3540
- var Devices = class {
3541
- db;
3542
- now;
3543
- code = null;
3544
- constructor(db, now = Date.now) {
3545
- this.db = db;
3546
- this.now = now;
3547
- }
3548
- /**
3549
- * A new pairing code; any earlier one stops working. Letters and digits only, 20 of them (103 bits, plenty for
3550
- * one code that lives 10 minutes): the QR code carries the link in capitals, in its denser alphanumeric mode,
3551
- * so the code is matched in either case (#194).
3552
- */
3553
- newPairCode(kind = "phone", via = null) {
3554
- const code = Array.from({ length: PAIR_CODE_LENGTH }, () => PAIR_CODE_CHARS[crypto2.randomInt(PAIR_CODE_CHARS.length)]).join("");
3555
- const expiresAt = this.now() + PAIR_CODE_MS;
3556
- this.code = { value: Buffer.from(code), expiresAt, kind, via: via?.name ?? null, viaLink: via?.link ?? null };
3557
- return { code, expiresAt, kind };
3558
- }
3559
- /**
3560
- * Unlinking stops what the link gave (review 275, Important 1): an open code a linked computer asked for, and that
3561
- * computer is no longer linked, is closed.
3562
- */
3563
- closeCodeUnlessLinked(links) {
3564
- if (this.code?.viaLink && !links.has(this.code.viaLink)) this.code = null;
3406
+ /**
3407
+ * Unlinking stops what the link gave (review 275, Important 1): an open code a linked computer asked for, and that
3408
+ * computer is no longer linked, is closed.
3409
+ */
3410
+ closeCodeUnlessLinked(links) {
3411
+ if (this.code?.viaLink && !links.has(this.code.viaLink)) this.code = null;
3565
3412
  }
3566
3413
  /** The linked computer the open code was made for (issue #273), while `code` is that code; null otherwise. */
3567
3414
  codeVia(code) {
@@ -3693,143 +3540,563 @@ var Devices = class {
3693
3540
 
3694
3541
  // src/server/security.ts
3695
3542
  var COOKIE_NAME = "ledung_auth";
3696
- function loadOrCreateToken(file = path16.join(userDataDir(), "auth-token")) {
3697
- if (fs13.existsSync(file)) {
3698
- const token2 = fs13.readFileSync(file, "utf8").trim();
3543
+ function loadOrCreateToken(file = path15.join(userDataDir(), "auth-token")) {
3544
+ if (fs12.existsSync(file)) {
3545
+ const token2 = fs12.readFileSync(file, "utf8").trim();
3699
3546
  if (/^[0-9a-f]{64}$/.test(token2)) return token2;
3700
3547
  }
3701
- const token = crypto3.randomBytes(32).toString("hex");
3702
- fs13.writeFileSync(file, token, { mode: 384 });
3703
- return token;
3704
- }
3705
- var Guard = class {
3706
- token;
3707
- port;
3708
- lanNames;
3709
- hosts = /* @__PURE__ */ new Set();
3710
- origins = /* @__PURE__ */ new Set();
3711
- lanHosts = /* @__PURE__ */ new Set();
3712
- lanOpen;
3713
- remote = null;
3714
- /** Sockets that came through the relay tunnel; see isPublic. */
3715
- tunnel = /* @__PURE__ */ new WeakSet();
3716
- /**
3717
- * `lanNames`: extra names Ledung answers to on the local network (empty when it isn't opened to it).
3718
- * `lanOpen`: requests through those names need no sign-in.
3719
- * `remote`: the public name and how a device cookie is checked; null: no public name.
3720
- */
3721
- constructor(token, port, lanNames2 = [], lanOpen = false, remote = null) {
3722
- this.token = Buffer.from(token);
3723
- this.port = port;
3724
- this.lanNames = lanNames2.map((n) => n.toLowerCase());
3725
- this.lanOpen = lanOpen;
3726
- this.setRemote(remote);
3548
+ const token = crypto3.randomBytes(32).toString("hex");
3549
+ fs12.writeFileSync(file, token, { mode: 384 });
3550
+ return token;
3551
+ }
3552
+ var Guard = class {
3553
+ token;
3554
+ port;
3555
+ lanNames;
3556
+ hosts = /* @__PURE__ */ new Set();
3557
+ origins = /* @__PURE__ */ new Set();
3558
+ lanHosts = /* @__PURE__ */ new Set();
3559
+ lanOpen;
3560
+ remote = null;
3561
+ /** Sockets that came through the relay tunnel; see isPublic. */
3562
+ tunnel = /* @__PURE__ */ new WeakSet();
3563
+ /**
3564
+ * `lanNames`: extra names Ledung answers to on the local network (empty when it isn't opened to it).
3565
+ * `lanOpen`: requests through those names need no sign-in.
3566
+ * `remote`: the public name and how a device cookie is checked; null: no public name.
3567
+ */
3568
+ constructor(token, port, lanNames2 = [], lanOpen = false, remote = null) {
3569
+ this.token = Buffer.from(token);
3570
+ this.port = port;
3571
+ this.lanNames = lanNames2.map((n) => n.toLowerCase());
3572
+ this.lanOpen = lanOpen;
3573
+ this.setRemote(remote);
3574
+ }
3575
+ /** The public name changed: joining or leaving a relay from Settings (issue #237), with no restart. */
3576
+ setRemote(remote) {
3577
+ this.remote = remote;
3578
+ const lan = this.lanNames.filter((n) => n !== remote?.publicName);
3579
+ const names = ["127.0.0.1", "localhost", ...lan];
3580
+ this.hosts = new Set(names.map((n) => `${n}:${this.port}`));
3581
+ this.origins = new Set(names.map((n) => `http://${n}:${this.port}`));
3582
+ this.lanHosts = new Set(lan.map((n) => `${n}:${this.port}`));
3583
+ }
3584
+ /**
3585
+ * The request came in through the public name: exactly the name, as browsers send it for HTTPS on 443.
3586
+ * No port and no other spelling, so a lookalike or "<name>:4317" is an unknown host.
3587
+ */
3588
+ isPublic(req) {
3589
+ if (this.tunnelled(req)) return true;
3590
+ return !!this.remote && (req.headers.host ?? "").toLowerCase() === this.remote.publicName;
3591
+ }
3592
+ /** A connection the relay tunnel handed to the server (LedungServer.tunnelConnection), never a local one. */
3593
+ markTunnel(socket) {
3594
+ this.tunnel.add(socket);
3595
+ }
3596
+ tunnelled(req) {
3597
+ return this.tunnel.has(req.socket);
3598
+ }
3599
+ /** The live device signed in through the public name, or null (always null for local requests). */
3600
+ deviceOf(req) {
3601
+ if (!this.remote || !this.isPublic(req)) return null;
3602
+ for (const cookie of readCookies(req, DEVICE_COOKIE)) {
3603
+ const device = this.remote.device(cookie);
3604
+ if (device) return device;
3605
+ }
3606
+ return null;
3607
+ }
3608
+ /** Signed in, or come in through a trusted network address; through the public name, a live paired device only. */
3609
+ mayUse(req) {
3610
+ if (this.isPublic(req)) return this.deviceOf(req) !== null;
3611
+ return this.signedIn(req) || this.lanOpen && this.lanHosts.has((req.headers.host ?? "").toLowerCase());
3612
+ }
3613
+ hostAllowed(req) {
3614
+ if (this.isPublic(req)) return !!this.remote;
3615
+ return this.hosts.has((req.headers.host ?? "").toLowerCase());
3616
+ }
3617
+ /**
3618
+ * True only when the request says it comes from Ledung's own page, on the kind of name it was sent to:
3619
+ * through the public name only https://<name>; locally never that one.
3620
+ */
3621
+ originAllowed(req) {
3622
+ const origin = req.headers.origin;
3623
+ if (typeof origin !== "string") return false;
3624
+ if (this.isPublic(req)) return !!this.remote && origin.toLowerCase() === `https://${this.remote.publicName}`;
3625
+ return this.origins.has(origin.toLowerCase());
3626
+ }
3627
+ tokenMatches(candidate) {
3628
+ if (!candidate) return false;
3629
+ const given = Buffer.from(candidate);
3630
+ return given.length === this.token.length && crypto3.timingSafeEqual(given, this.token);
3631
+ }
3632
+ /** The launchers' proof that this Ledung holds the token, for their nonce (src/platform/running.ts). */
3633
+ runningProof(nonce) {
3634
+ return runningProof(this.token.toString(), nonce);
3635
+ }
3636
+ /** The install's token cookie; never counts through the public name. */
3637
+ signedIn(req) {
3638
+ return !this.isPublic(req) && this.tokenMatches(readCookie(req, COOKIE_NAME));
3639
+ }
3640
+ cookieHeader() {
3641
+ return `${COOKIE_NAME}=${this.token.toString()}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`;
3642
+ }
3643
+ };
3644
+ function deviceCookieHeader(value, framed = false) {
3645
+ return `${DEVICE_COOKIE}=${value}; Secure; HttpOnly; ${framed ? "SameSite=None; Partitioned" : "SameSite=Strict"}; Path=/; Max-Age=31536000`;
3646
+ }
3647
+ function readCookie(req, name) {
3648
+ return readCookies(req, name)[0] ?? null;
3649
+ }
3650
+ function readCookies(req, name) {
3651
+ const found = [];
3652
+ for (const part of (req.headers.cookie ?? "").split(";")) {
3653
+ const [k, ...v] = part.trim().split("=");
3654
+ if (k === name) found.push(v.join("="));
3655
+ }
3656
+ return found;
3657
+ }
3658
+ function securityHeaders(port, lanNames2 = [], imageHosts = [], publicName, frames = {}) {
3659
+ const ancestors = frames.ancestors ?? [];
3660
+ const sources = frames.sources ?? [];
3661
+ const live = [...["127.0.0.1", "localhost", ...lanNames2].map((n) => `ws://${n}:${port}`), ...publicName ? [`wss://${publicName}`] : []];
3662
+ return {
3663
+ "Content-Security-Policy": [
3664
+ "default-src 'self'",
3665
+ `connect-src 'self' ${live.join(" ")}`,
3666
+ // Extra hosts only for the board's people list (their pictures, decision 5 of the message board).
3667
+ `img-src ${["'self'", "data:", ...imageHosts].join(" ")}`,
3668
+ "style-src 'self' 'unsafe-inline'",
3669
+ `frame-ancestors ${ancestors.length ? ancestors.join(" ") : "'none'"}`,
3670
+ ...sources.length ? [`frame-src 'self' ${sources.join(" ")}`] : [],
3671
+ "base-uri 'none'",
3672
+ "form-action 'self'"
3673
+ ].join("; "),
3674
+ // Older browsers know only this header; it can't list pages, so it goes only when some may frame this one.
3675
+ ...ancestors.length ? {} : { "X-Frame-Options": "DENY" },
3676
+ "X-Content-Type-Options": "nosniff",
3677
+ "Referrer-Policy": "no-referrer",
3678
+ "Cache-Control": "no-store"
3679
+ };
3680
+ }
3681
+
3682
+ // src/platform/background.ts
3683
+ var LOG_FILE = "ledung.log";
3684
+ var MAX_LOG_BYTES = 10 * 1024 * 1024;
3685
+
3686
+ // src/platform/icons.ts
3687
+ var PNG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
3688
+ function pngSize(png) {
3689
+ if (png.length < 24 || !png.subarray(0, 8).equals(PNG)) throw new Error("Not a PNG image.");
3690
+ return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
3691
+ }
3692
+ function icoFromPng(png) {
3693
+ const { width, height } = pngSize(png);
3694
+ if (width > 256 || height > 256) throw new Error(`An .ico image is at most 256 pixels, not ${width}\xD7${height}.`);
3695
+ const head = Buffer.alloc(6 + 16);
3696
+ head.writeUInt16LE(0, 0);
3697
+ head.writeUInt16LE(1, 2);
3698
+ head.writeUInt16LE(1, 4);
3699
+ head.writeUInt8(width % 256, 6);
3700
+ head.writeUInt8(height % 256, 7);
3701
+ head.writeUInt8(0, 8);
3702
+ head.writeUInt8(0, 9);
3703
+ head.writeUInt16LE(1, 10);
3704
+ head.writeUInt16LE(32, 12);
3705
+ head.writeUInt32LE(png.length, 14);
3706
+ head.writeUInt32LE(head.length, 18);
3707
+ return Buffer.concat([head, png]);
3708
+ }
3709
+ var ICNS_TYPES = { 256: "ic08", 512: "ic09", 1024: "ic10" };
3710
+ function icnsFromPngs(pngs) {
3711
+ const parts2 = pngs.map((png) => {
3712
+ const { width, height } = pngSize(png);
3713
+ const type = width === height ? ICNS_TYPES[width] : void 0;
3714
+ if (!type) throw new Error(`An .icns here takes square images of 256, 512 or 1024 pixels, not ${width}\xD7${height}.`);
3715
+ const head2 = Buffer.alloc(8);
3716
+ head2.write(type, 0, "latin1");
3717
+ head2.writeUInt32BE(8 + png.length, 4);
3718
+ return Buffer.concat([head2, png]);
3719
+ });
3720
+ const head = Buffer.alloc(8);
3721
+ head.write("icns", 0, "latin1");
3722
+ head.writeUInt32BE(8 + parts2.reduce((n, p) => n + p.length, 0), 4);
3723
+ return Buffer.concat([head, ...parts2]);
3724
+ }
3725
+
3726
+ // src/platform/shortcuts.ts
3727
+ var ASKED_KEY = "shortcuts-asked";
3728
+ var BUNDLE_ID = "org.ledung.launcher";
3729
+ var AGENT_LABEL = "org.ledung.signin";
3730
+ var STARTER_FILE = "Ledung-start.js";
3731
+ var LABELS = { desktop: "Desktop", "start-menu": "Start menu", applications: "Applications" };
3732
+ var ENV_FILE = "shortcut.env";
3733
+ var LOG_FILE_SETTING = "LEDUNG_LOG_FILE";
3734
+ function launchTarget(dataDir = userDataDir(), app = appDir(), packaged = PACKAGED) {
3735
+ const releaseLauncher = path16.join(dataDir, "launcher.mjs");
3736
+ const fromRelease = !path16.relative(path16.join(dataDir, "app"), app).startsWith("..") && fs13.existsSync(releaseLauncher);
3737
+ const script = fromRelease ? releaseLauncher : path16.join(app, packaged ? path16.join("dist", "cli.js") : path16.join("src", "cli.ts"));
3738
+ const env = {};
3739
+ for (const name of ["DATA_DIR", "CONFIG"]) {
3740
+ const value = envSetting(name);
3741
+ if (value) env[`LEDUNG_${name}`] = path16.resolve(value);
3742
+ }
3743
+ return Object.keys(env).length ? { node: process.execPath, script, env } : { node: process.execPath, script };
3744
+ }
3745
+ function startsInBackground(launch) {
3746
+ return path16.basename(launch.script) !== "launcher.mjs";
3747
+ }
3748
+ function envFileText(env) {
3749
+ return Object.entries(env).map(([name, value]) => {
3750
+ if (/[\r\n]/.test(value) || value.includes("'") && value.includes("`")) throw new Error(`${name} can't be passed to a shortcut: ${value}`);
3751
+ return value.includes("'") ? `${name}=\`${value}\`
3752
+ ` : `${name}='${value}'
3753
+ `;
3754
+ }).join("");
3755
+ }
3756
+ function starterText(head, tail2) {
3757
+ const literal = (s) => JSON.stringify(s).replace(/[^\x20-\x7e]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
3758
+ return [
3759
+ "// Starts Ledung with no window. Made by Ledung's Settings (issue #456).",
3760
+ "var shell = new ActiveXObject('WScript.Shell');",
3761
+ "var extra = '';",
3762
+ `for (var i = 0; i < WScript.Arguments.length; i++) extra += ' "' + WScript.Arguments(i) + '"';`,
3763
+ `shell.Run(${literal(head)} + extra + ${literal(tail2)}, 0, false);`,
3764
+ ""
3765
+ ].join("\r\n");
3766
+ }
3767
+ function iconFiles(app = appDir()) {
3768
+ const built = path16.join(app, "web", "dist");
3769
+ const dir = fs13.existsSync(path16.join(built, "icon-256.png")) ? built : path16.join(app, "web", "public");
3770
+ return { png256: path16.join(dir, "icon-256.png"), png512: path16.join(dir, "icon-512.png") };
3771
+ }
3772
+ function powershell(script, env) {
3773
+ return new Promise((resolve, reject) => {
3774
+ execFile4("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `[Console]::OutputEncoding = [Text.Encoding]::UTF8; ${script}`], { env: { ...process.env, ...env }, windowsHide: true, timeout: 3e4 }, (error, stdout, stderr) => {
3775
+ if (error) reject(new Error(stderr.trim() || error.message));
3776
+ else resolve(stdout);
3777
+ });
3778
+ });
3779
+ }
3780
+ async function windowsFolders() {
3781
+ const out = await powershell("@{ desktop = [Environment]::GetFolderPath('Desktop'); programs = [Environment]::GetFolderPath('Programs'); startup = [Environment]::GetFolderPath('Startup') } | ConvertTo-Json -Compress", {});
3782
+ const { desktop, programs, startup } = JSON.parse(out);
3783
+ return { desktop, "start-menu": programs, startup };
3784
+ }
3785
+ function macFolders() {
3786
+ const startup = path16.join(os7.homedir(), "Library", "LaunchAgents");
3787
+ try {
3788
+ fs13.accessSync("/Applications", fs13.constants.W_OK);
3789
+ return { applications: "/Applications", startup };
3790
+ } catch {
3791
+ return { applications: path16.join(os7.homedir(), "Applications"), startup };
3792
+ }
3793
+ }
3794
+ function linuxFolders() {
3795
+ return { startup: path16.join(process.env.XDG_CONFIG_HOME ?? path16.join(os7.homedir(), ".config"), "autostart") };
3796
+ }
3797
+ function ownDataFolders(platform) {
3798
+ const root = path16.join(userDataDir(), "shortcuts");
3799
+ return platform === "win32" ? { desktop: path16.join(root, "Desktop"), "start-menu": path16.join(root, "Start menu"), startup: path16.join(root, "Startup") } : platform === "darwin" ? { applications: path16.join(root, "Applications"), startup: path16.join(root, "LaunchAgents") } : { startup: path16.join(root, "autostart") };
3800
+ }
3801
+ var shQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
3802
+ var winQuote = (s) => `"${s}"`;
3803
+ var desktopArg = (s) => `"${s.replace(/(["`$\\])/g, "\\$1")}"`.replace(/\\/g, "\\\\").replace(/%/g, "%%");
3804
+ var xmlEscape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3805
+ var PLIST = (version) => `<?xml version="1.0" encoding="UTF-8"?>
3806
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3807
+ <plist version="1.0">
3808
+ <dict>
3809
+ <key>CFBundleExecutable</key><string>Ledung</string>
3810
+ <key>CFBundleIconFile</key><string>Ledung</string>
3811
+ <key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
3812
+ <key>CFBundleName</key><string>Ledung</string>
3813
+ <key>CFBundleDisplayName</key><string>Ledung</string>
3814
+ <key>CFBundlePackageType</key><string>APPL</string>
3815
+ <key>CFBundleShortVersionString</key><string>${xmlEscape(version)}</string>
3816
+ <key>LSMinimumSystemVersion</key><string>10.13</string>
3817
+ </dict>
3818
+ </plist>
3819
+ `;
3820
+ var AGENT_PLIST = (args, log) => `<?xml version="1.0" encoding="UTF-8"?>
3821
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3822
+ <plist version="1.0">
3823
+ <dict>
3824
+ <key>Label</key><string>${AGENT_LABEL}</string>
3825
+ <key>ProgramArguments</key>
3826
+ <array>
3827
+ ${args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n")}
3828
+ </array>
3829
+ <key>RunAtLoad</key><true/>
3830
+ <key>AbandonProcessGroup</key><true/>
3831
+ <key>StandardOutPath</key><string>${xmlEscape(log)}</string>
3832
+ <key>StandardErrorPath</key><string>${xmlEscape(log)}</string>
3833
+ <key>EnvironmentVariables</key>
3834
+ <dict>
3835
+ <key>${LOG_FILE_SETTING}</key><string>${xmlEscape(log)}</string>
3836
+ </dict>
3837
+ </dict>
3838
+ </plist>
3839
+ `;
3840
+ var SIGN_IN_FILES = {
3841
+ windows: "Ledung at sign-in.lnk",
3842
+ mac: `${AGENT_LABEL}.plist`,
3843
+ other: `${AGENT_LABEL}.desktop`
3844
+ };
3845
+ var SIGN_IN_LABELS = {
3846
+ windows: "Start Ledung when I sign in to Windows",
3847
+ mac: "Start Ledung when I log in to this Mac",
3848
+ other: "Start Ledung when I log in"
3849
+ };
3850
+ var Shortcuts = class {
3851
+ opts;
3852
+ platform;
3853
+ folderCache = null;
3854
+ constructor(opts) {
3855
+ this.opts = opts;
3856
+ this.platform = opts.platform ?? process.platform;
3857
+ }
3858
+ kind() {
3859
+ return this.platform === "win32" ? "windows" : this.platform === "darwin" ? "mac" : "other";
3860
+ }
3861
+ folders() {
3862
+ this.folderCache ??= this.opts.folders?.() ?? (envSetting("DATA_DIR") ? Promise.resolve(ownDataFolders(this.platform)) : this.platform === "win32" ? windowsFolders() : Promise.resolve(this.platform === "darwin" ? macFolders() : linuxFolders()));
3863
+ this.folderCache.catch(() => this.folderCache = null);
3864
+ return this.folderCache;
3865
+ }
3866
+ async places() {
3867
+ const kind = this.kind();
3868
+ if (kind === "other") return [];
3869
+ const folders = await this.folders();
3870
+ const ids = kind === "windows" ? ["desktop", "start-menu"] : ["applications"];
3871
+ return ids.flatMap((id) => {
3872
+ const folder = folders[id];
3873
+ if (!folder) return [];
3874
+ const file = path16.join(folder, kind === "windows" ? "Ledung.lnk" : "Ledung.app");
3875
+ return [{ id, label: LABELS[id], file, present: kind === "windows" ? fs13.existsSync(file) : isOurApp(file) }];
3876
+ });
3877
+ }
3878
+ /** The sign-in start's file in Startup, LaunchAgents or autostart (SIGN_IN_FILES). */
3879
+ async signInFile() {
3880
+ const folder = (await this.folders()).startup;
3881
+ if (!folder) return null;
3882
+ const kind = this.kind();
3883
+ return path16.join(folder, SIGN_IN_FILES[kind]);
3884
+ }
3885
+ async signInView() {
3886
+ const label = SIGN_IN_LABELS[this.kind()];
3887
+ const file = await this.signInFile();
3888
+ if (!file) return { available: false, reason: "Ledung cannot start itself at login on this system.", label, on: false, file: null };
3889
+ return { available: true, reason: null, label, on: fs13.existsSync(file), file };
3727
3890
  }
3728
- /** The public name changed: joining or leaving a relay from Settings (issue #237), with no restart. */
3729
- setRemote(remote) {
3730
- this.remote = remote;
3731
- const lan = this.lanNames.filter((n) => n !== remote?.publicName);
3732
- const names = ["127.0.0.1", "localhost", ...lan];
3733
- this.hosts = new Set(names.map((n) => `${n}:${this.port}`));
3734
- this.origins = new Set(names.map((n) => `http://${n}:${this.port}`));
3735
- this.lanHosts = new Set(lan.map((n) => `${n}:${this.port}`));
3891
+ async view() {
3892
+ const kind = this.kind();
3893
+ const asked = this.opts.meta.get(ASKED_KEY) !== null;
3894
+ const log = { file: this.logFile(), active: envSetting("LOG_FILE") !== void 0 && path16.resolve(envSetting("LOG_FILE")) === path16.resolve(this.logFile()) };
3895
+ const signIn = await this.signInView();
3896
+ if (kind === "other") {
3897
+ return { kind, available: false, reason: "Shortcuts are made on Windows and macOS. Here, start Ledung with ledung.sh or the ledung command.", asked, places: [], signIn, log };
3898
+ }
3899
+ return { kind, available: true, reason: null, asked, places: await this.places(), signIn, log };
3736
3900
  }
3737
3901
  /**
3738
- * The request came in through the public name: exactly the name, as browsers send it for HTTPS on 443.
3739
- * No port and no other spelling, so a lookalike or "<name>:4317" is an unknown host.
3902
+ * Add or Remove, or No to the first-run question. Add and No answer it for good; Remove leaves it answered.
3903
+ * sign-in-on and sign-in-off switch the start at sign-in (#456).
3740
3904
  */
3741
- isPublic(req) {
3742
- if (this.tunnelled(req)) return true;
3743
- return !!this.remote && (req.headers.host ?? "").toLowerCase() === this.remote.publicName;
3905
+ async act(action) {
3906
+ if (action === "decline") {
3907
+ this.opts.meta.set(ASKED_KEY, "declined");
3908
+ return this.view();
3909
+ }
3910
+ if (action === "sign-in-on" || action === "sign-in-off") {
3911
+ await this.signIn(action === "sign-in-on");
3912
+ return this.view();
3913
+ }
3914
+ if (this.kind() === "other") throw new Error("Shortcuts are made on Windows and macOS.");
3915
+ const places = await this.places();
3916
+ if (action === "add") {
3917
+ this.writeEnvFile();
3918
+ if (this.kind() === "windows") await this.addWindows(places.filter((p) => !p.present));
3919
+ else {
3920
+ for (const p of places.filter((p2) => !p2.present)) {
3921
+ if (fs13.existsSync(p.file)) throw new Error(`There is already a Ledung.app in ${path16.dirname(p.file)} that Ledung didn't make. Rename or remove it, then add the shortcut again.`);
3922
+ this.addMac(p.file);
3923
+ }
3924
+ }
3925
+ this.opts.meta.set(ASKED_KEY, "added");
3926
+ } else {
3927
+ for (const p of places.filter((p2) => p2.present)) this.remove(p.file);
3928
+ if (this.kind() === "windows") fs13.rmSync(this.icoFile(), { force: true });
3929
+ if (!(await this.signInView()).on) this.removeShared();
3930
+ }
3931
+ return this.view();
3744
3932
  }
3745
- /** A connection the relay tunnel handed to the server (LedungServer.tunnelConnection), never a local one. */
3746
- markTunnel(socket) {
3747
- this.tunnel.add(socket);
3933
+ icoFile() {
3934
+ return path16.join(this.opts.iconDir, "Ledung.ico");
3748
3935
  }
3749
- tunnelled(req) {
3750
- return this.tunnel.has(req.socket);
3936
+ envFile() {
3937
+ return path16.join(this.opts.iconDir, ENV_FILE);
3751
3938
  }
3752
- /** The live device signed in through the public name, or null (always null for local requests). */
3753
- deviceOf(req) {
3754
- if (!this.remote || !this.isPublic(req)) return null;
3755
- for (const cookie of readCookies(req, DEVICE_COOKIE)) {
3756
- const device = this.remote.device(cookie);
3757
- if (device) return device;
3758
- }
3759
- return null;
3939
+ starterFile() {
3940
+ return path16.join(this.opts.iconDir, STARTER_FILE);
3760
3941
  }
3761
- /** Signed in, or come in through a trusted network address; through the public name, a live paired device only. */
3762
- mayUse(req) {
3763
- if (this.isPublic(req)) return this.deviceOf(req) !== null;
3764
- return this.signedIn(req) || this.lanOpen && this.lanHosts.has((req.headers.host ?? "").toLowerCase());
3942
+ logFile() {
3943
+ return this.opts.logFile ?? path16.join(this.opts.iconDir, LOG_FILE);
3765
3944
  }
3766
- hostAllowed(req) {
3767
- if (this.isPublic(req)) return !!this.remote;
3768
- return this.hosts.has((req.headers.host ?? "").toLowerCase());
3945
+ /** The env file and the start script, which the icons and the start at sign-in share. */
3946
+ removeShared() {
3947
+ fs13.rmSync(this.envFile(), { force: true });
3948
+ fs13.rmSync(this.starterFile(), { force: true });
3949
+ }
3950
+ /** The env file for a launch that carries settings, else none; the shortcuts then pass it with --env-file. */
3951
+ writeEnvFile() {
3952
+ const env = this.opts.launch.env ?? {};
3953
+ if (!Object.keys(env).length) return void fs13.rmSync(this.envFile(), { force: true });
3954
+ fs13.mkdirSync(this.opts.iconDir, { recursive: true });
3955
+ fs13.writeFileSync(this.envFile(), envFileText(env));
3956
+ }
3957
+ /** Node's own options for a shortcut: --env-file when the launch carries settings. */
3958
+ nodeOptions() {
3959
+ return Object.keys(this.opts.launch.env ?? {}).length ? [`--env-file=${this.envFile()}`] : [];
3769
3960
  }
3770
3961
  /**
3771
- * True only when the request says it comes from Ledung's own page, on the kind of name it was sent to:
3772
- * through the public name only https://<name>; locally never that one.
3962
+ * The start script for Windows Script Host (#456). The `ledung` command goes to the background by itself; the
3963
+ * release launcher runs through cmd.exe, hidden too, so its output goes to the log, and LEDUNG_LOG_FILE says so to
3964
+ * Settings.
3773
3965
  */
3774
- originAllowed(req) {
3775
- const origin = req.headers.origin;
3776
- if (typeof origin !== "string") return false;
3777
- if (this.isPublic(req)) return !!this.remote && origin.toLowerCase() === `https://${this.remote.publicName}`;
3778
- return this.origins.has(origin.toLowerCase());
3779
- }
3780
- tokenMatches(candidate) {
3781
- if (!candidate) return false;
3782
- const given = Buffer.from(candidate);
3783
- return given.length === this.token.length && crypto3.timingSafeEqual(given, this.token);
3966
+ writeStarter() {
3967
+ const { node, script } = this.opts.launch;
3968
+ const run8 = [node, ...this.nodeOptions(), script].map(winQuote).join(" ");
3969
+ const text2 = startsInBackground(this.opts.launch) ? starterText(run8, "") : starterText(`cmd.exe /d /s /c "set ${winQuote(`${LOG_FILE_SETTING}=${this.logFile()}`)}&& ${run8}`, ` >> ${winQuote(this.logFile())} 2>&1"`);
3970
+ fs13.mkdirSync(this.opts.iconDir, { recursive: true });
3971
+ fs13.writeFileSync(this.starterFile(), text2);
3972
+ }
3973
+ /** The Windows Script Host that runs the start script with no window, and its arguments. */
3974
+ windowsStart(extra) {
3975
+ const wscript = path16.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
3976
+ return { target: wscript, arguments: ["//Nologo", this.starterFile(), ...extra].map(winQuote).join(" ") };
3977
+ }
3978
+ async writeLinks(files, extra, icon, description) {
3979
+ for (const file of files) fs13.mkdirSync(path16.dirname(file), { recursive: true });
3980
+ const spec = { files, ...this.windowsStart(extra), workdir: os7.homedir(), icon: icon ? `${icon},0` : "", description };
3981
+ await powershell([
3982
+ "$spec = $env:SHORTCUT_SPEC | ConvertFrom-Json",
3983
+ "$shell = New-Object -ComObject WScript.Shell",
3984
+ "foreach ($file in $spec.files) {",
3985
+ " $s = $shell.CreateShortcut($file)",
3986
+ " $s.TargetPath = $spec.target; $s.Arguments = $spec.arguments; $s.WorkingDirectory = $spec.workdir",
3987
+ " if ($spec.icon) { $s.IconLocation = $spec.icon }",
3988
+ " $s.Description = $spec.description",
3989
+ " $s.Save()",
3990
+ "}"
3991
+ ].join("\n"), { SHORTCUT_SPEC: JSON.stringify(spec) });
3784
3992
  }
3785
- /** The launchers' proof that this Ledung holds the token, for their nonce (src/platform/running.ts). */
3786
- runningProof(nonce) {
3787
- return runningProof(this.token.toString(), nonce);
3993
+ async addWindows(places) {
3994
+ if (!places.length) return;
3995
+ fs13.mkdirSync(this.opts.iconDir, { recursive: true });
3996
+ fs13.writeFileSync(this.icoFile(), icoFromPng(fs13.readFileSync(this.opts.icons.png256)));
3997
+ this.writeStarter();
3998
+ await this.writeLinks(places.map((p) => p.file), [], this.icoFile(), "Open Ledung, and start it if it is not running");
3999
+ }
4000
+ /** Turns the start at sign-in on or off (#456). On starts Ledung without opening its page. */
4001
+ async signIn(on) {
4002
+ const file = await this.signInFile();
4003
+ if (!file) throw new Error("Ledung cannot start itself at login on this system.");
4004
+ if (!on) {
4005
+ fs13.rmSync(file, { force: true });
4006
+ if (!(await this.places()).some((p) => p.present)) this.removeShared();
4007
+ return;
4008
+ }
4009
+ this.writeEnvFile();
4010
+ fs13.mkdirSync(path16.dirname(file), { recursive: true });
4011
+ const { node, script } = this.opts.launch;
4012
+ const kind = this.kind();
4013
+ if (kind === "windows") {
4014
+ this.writeStarter();
4015
+ await this.writeLinks([file], ["--no-open"], null, "Start Ledung in the background");
4016
+ } else if (kind === "mac") {
4017
+ const run8 = [...this.nodeOptions(), script, ...startsInBackground(this.opts.launch) ? ["--foreground"] : [], "--no-open"].map(shQuote).join(" ");
4018
+ fs13.writeFileSync(file, AGENT_PLIST(["/bin/sh", "-c", signInScript(node, run8, "/bin/zsh").join("\n")], this.logFile()));
4019
+ } else {
4020
+ const run8 = [...this.nodeOptions(), script, "--no-open"].map(shQuote).join(" ");
4021
+ fs13.writeFileSync(file, [
4022
+ "[Desktop Entry]",
4023
+ "Type=Application",
4024
+ "Name=Ledung",
4025
+ "Comment=Start Ledung in the background. Made by Ledung's Settings (issue #456).",
4026
+ `Exec=${["/bin/sh", "-c", signInScript(node, run8, "/bin/sh").join("; ")].map(desktopArg).join(" ")}`,
4027
+ "Terminal=false",
4028
+ "X-GNOME-Autostart-enabled=true",
4029
+ ""
4030
+ ].join("\n"));
4031
+ }
3788
4032
  }
3789
- /** The install's token cookie; never counts through the public name. */
3790
- signedIn(req) {
3791
- return !this.isPublic(req) && this.tokenMatches(readCookie(req, COOKIE_NAME));
4033
+ /**
4034
+ * Ledung.app: a script that opens a running Ledung's page (--open-only), or else starts Ledung in the background
4035
+ * (#456): the `ledung` command goes there by itself; the release launcher is started with nohup, its output in the
4036
+ * log. Made by Ledung on this computer, so it carries no quarantine and Gatekeeper lets it open.
4037
+ */
4038
+ addMac(app) {
4039
+ const { node, script } = this.opts.launch;
4040
+ const contents = path16.join(app, "Contents");
4041
+ fs13.mkdirSync(path16.join(contents, "MacOS"), { recursive: true });
4042
+ fs13.mkdirSync(path16.join(contents, "Resources"), { recursive: true });
4043
+ let version = "1";
4044
+ try {
4045
+ version = String(JSON.parse(fs13.readFileSync(path16.join(appDir(), "package.json"), "utf8")).version ?? "1");
4046
+ } catch {
4047
+ }
4048
+ fs13.writeFileSync(path16.join(contents, "Info.plist"), PLIST(version));
4049
+ fs13.writeFileSync(path16.join(contents, "Resources", "Ledung.icns"), icnsFromPngs([fs13.readFileSync(this.opts.icons.png256), fs13.readFileSync(this.opts.icons.png512)]));
4050
+ const run8 = [...this.nodeOptions(), script].map(shQuote).join(" ");
4051
+ fs13.writeFileSync(path16.join(contents, "MacOS", "Ledung"), [
4052
+ "#!/bin/sh",
4053
+ "# Opens a running Ledung's page, or starts Ledung in the background. Made by Ledung's Settings (issues #415, #456).",
4054
+ ...findNode(node),
4055
+ `[ -x "$NODE" ] || { osascript -e 'display alert "Ledung cannot find Node.js" message "Install Node.js 24, or add the shortcut again in Settings in Ledung."'; exit 1; }`,
4056
+ `"$NODE" ${run8} --open-only && exit 0`,
4057
+ // Only for a start: opening a running Ledung's page doesn't wait for the login shell.
4058
+ ...loginPath("/bin/zsh"),
4059
+ ...nodeOnPath(),
4060
+ startsInBackground(this.opts.launch) ? `exec "$NODE" ${run8}` : `${LOG_FILE_SETTING}=${shQuote(this.logFile())} nohup "$NODE" ${run8} >> ${shQuote(this.logFile())} 2>&1 &`,
4061
+ ""
4062
+ ].join("\n"), { mode: 493 });
3792
4063
  }
3793
- cookieHeader() {
3794
- return `${COOKIE_NAME}=${this.token.toString()}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`;
4064
+ /** Takes away Ledung.lnk, or a Ledung.app that is Ledung's own (its bundle id), never anything else. */
4065
+ remove(file) {
4066
+ if (file.endsWith(".lnk")) {
4067
+ fs13.rmSync(file, { force: true });
4068
+ return;
4069
+ }
4070
+ if (!isOurApp(file)) throw new Error(`${file} is not the Ledung.app that Ledung made; remove it yourself if you want it gone.`);
4071
+ fs13.rmSync(file, { recursive: true, force: true });
3795
4072
  }
3796
4073
  };
3797
- function deviceCookieHeader(value, framed = false) {
3798
- return `${DEVICE_COOKIE}=${value}; Secure; HttpOnly; ${framed ? "SameSite=None; Partitioned" : "SameSite=Strict"}; Path=/; Max-Age=31536000`;
4074
+ function findNode(node) {
4075
+ return [`NODE=${shQuote(node)}`, `[ -x "$NODE" ] || NODE=$("\${SHELL:-/bin/zsh}" -lic 'command -v node' 2>/dev/null | tail -n 1)`];
3799
4076
  }
3800
- function readCookie(req, name) {
3801
- return readCookies(req, name)[0] ?? null;
4077
+ var LOGIN_PATH_TICKS = 40;
4078
+ var PATH_MARKER = "LEDUNG_PATH=";
4079
+ function loginPath(fallbackShell) {
4080
+ const ask = `"\${SHELL:-${fallbackShell}}" -lic 'printf "\\n${PATH_MARKER}%s\\n" "$PATH"' </dev/null >"$T" 2>/dev/null & P=$!`;
4081
+ return [
4082
+ 'L=; T=$(mktemp "${TMPDIR:-/tmp}/ledung-path.XXXXXX" 2>/dev/null) || T=',
4083
+ `[ -z "$T" ] || { ${ask}; I=0; while kill -0 "$P" 2>/dev/null && [ "$I" -lt ${LOGIN_PATH_TICKS} ]; do sleep 0.2; I=$((I + 1)); done; kill -KILL "$P" 2>/dev/null; L=$(sed -n 's/^${PATH_MARKER}//p' "$T" | tail -n 1); rm -f "$T"; } 2>/dev/null`,
4084
+ 'if [ -n "$L" ]; then PATH=$L; LEDUNG_START_PATH=login; else LEDUNG_START_PATH=fallback; fi'
4085
+ ];
3802
4086
  }
3803
- function readCookies(req, name) {
3804
- const found = [];
3805
- for (const part of (req.headers.cookie ?? "").split(";")) {
3806
- const [k, ...v] = part.trim().split("=");
3807
- if (k === name) found.push(v.join("="));
3808
- }
3809
- return found;
4087
+ function nodeOnPath() {
4088
+ return ['case ":$PATH:" in *":${NODE%/*}:"*) ;; *) PATH="$PATH:${NODE%/*}" ;; esac', "export PATH LEDUNG_START_PATH"];
3810
4089
  }
3811
- function securityHeaders(port, lanNames2 = [], imageHosts = [], publicName, frames = {}) {
3812
- const ancestors = frames.ancestors ?? [];
3813
- const sources = frames.sources ?? [];
3814
- const live = [...["127.0.0.1", "localhost", ...lanNames2].map((n) => `ws://${n}:${port}`), ...publicName ? [`wss://${publicName}`] : []];
3815
- return {
3816
- "Content-Security-Policy": [
3817
- "default-src 'self'",
3818
- `connect-src 'self' ${live.join(" ")}`,
3819
- // Extra hosts only for the board's people list (their pictures, decision 5 of the message board).
3820
- `img-src ${["'self'", "data:", ...imageHosts].join(" ")}`,
3821
- "style-src 'self' 'unsafe-inline'",
3822
- `frame-ancestors ${ancestors.length ? ancestors.join(" ") : "'none'"}`,
3823
- ...sources.length ? [`frame-src 'self' ${sources.join(" ")}`] : [],
3824
- "base-uri 'none'",
3825
- "form-action 'self'"
3826
- ].join("; "),
3827
- // Older browsers know only this header; it can't list pages, so it goes only when some may frame this one.
3828
- ...ancestors.length ? {} : { "X-Frame-Options": "DENY" },
3829
- "X-Content-Type-Options": "nosniff",
3830
- "Referrer-Policy": "no-referrer",
3831
- "Cache-Control": "no-store"
3832
- };
4090
+ function signInScript(node, run8, fallbackShell) {
4091
+ const missing = 'echo "Ledung cannot find Node.js. Install Node.js 24, or switch Start at sign-in off and on in Settings in Ledung." >&2; exit 1';
4092
+ return [...loginPath(fallbackShell), ...findNode(node), `[ -x "$NODE" ] || { ${missing}; }`, ...nodeOnPath(), `exec "$NODE" ${run8}`];
4093
+ }
4094
+ function isOurApp(app) {
4095
+ try {
4096
+ return fs13.readFileSync(path16.join(app, "Contents", "Info.plist"), "utf8").includes(`<string>${BUNDLE_ID}</string>`);
4097
+ } catch {
4098
+ return false;
4099
+ }
3833
4100
  }
3834
4101
 
3835
4102
  // src/server/server.ts
@@ -3852,6 +4119,22 @@ function displayVersion(version) {
3852
4119
  if (!m) return `v${version}`;
3853
4120
  return m[2] === "0" ? `v${m[1]}` : `v${m[1]}.${m[2]}`;
3854
4121
  }
4122
+ var CHANNELS = ["public", "dev"];
4123
+ var CHANNEL_TAG = { public: "latest", dev: "dev" };
4124
+ function parseVersion2(version) {
4125
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
4126
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
4127
+ }
4128
+ function compareVersions(a, b) {
4129
+ const x = parseVersion2(a);
4130
+ const y = parseVersion2(b);
4131
+ if (!x || !y) return (x ? 1 : 0) - (y ? 1 : 0);
4132
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
4133
+ }
4134
+ function offeredVersion(channel, tags) {
4135
+ const candidates = (channel === "dev" ? [tags.dev, tags.latest] : [tags.latest]).filter((v) => !!v && !!parseVersion2(v));
4136
+ return candidates.sort(compareVersions).at(-1) ?? null;
4137
+ }
3855
4138
 
3856
4139
  // src/server/source.ts
3857
4140
  var ANTHROPIC_NOTICE = "Ledung runs Anthropic's Claude Agent SDK and Claude Code, installed from npm under Anthropic's own terms (https://code.claude.com/docs/en/legal-and-compliance). They are not covered by Ledung's license (AGPL-3.0-only, see LICENSE).";
@@ -12509,6 +12792,7 @@ var SessionManager = class _SessionManager {
12509
12792
  choices: this.accountChoices(),
12510
12793
  signedOut: this.signedOut(),
12511
12794
  claudeProblem: this.claudeProblem,
12795
+ claudeLooked: this.claudeProblem ? this.claudeLooked : [],
12512
12796
  configFileProblems: this.accountFileProblems
12513
12797
  };
12514
12798
  }
@@ -12542,9 +12826,12 @@ var SessionManager = class _SessionManager {
12542
12826
  );
12543
12827
  /** Claude Code missing or too old on this computer (issue #341), for the page's banner; null: none. */
12544
12828
  claudeProblem = null;
12829
+ /** Where that last look went (#455), shown under the banner so a "not found" can be acted on. */
12830
+ claudeLooked = [];
12545
12831
  /** Looks for the Claude Code sessions run, and tells the page when its problem changed. Never throws. */
12546
12832
  async checkClaude() {
12547
12833
  const view = await this.claudeCodeView();
12834
+ this.claudeLooked = view.looked ?? [];
12548
12835
  if (view.problem !== this.claudeProblem) {
12549
12836
  this.claudeProblem = view.problem;
12550
12837
  this.publishAccounts();
@@ -12564,6 +12851,7 @@ var SessionManager = class _SessionManager {
12564
12851
  const all = names ?? [DEFAULT_ACCOUNT, ...this.accounts.list().map((a) => a.name)];
12565
12852
  const [claude] = await Promise.all([this.claudeCodeView(), ...all.map((n) => this.checkSignInOf(n))]);
12566
12853
  this.claudeProblem = claude.problem;
12854
+ this.claudeLooked = claude.looked ?? [];
12567
12855
  this.publishAccounts();
12568
12856
  }
12569
12857
  /**
@@ -14205,6 +14493,10 @@ var GIT_LIMIT_MS = 2 * 6e4;
14205
14493
  var INSTALL_LIMIT_MS = 10 * 6e4;
14206
14494
  var RELEASE_LIMIT_MS = 15 * 6e4;
14207
14495
  var NPM_REASON = "Ledung was installed from npm, so updates come from npm: run npm install -g ledung, then press Restart Ledung.";
14496
+ var NPM_DEV_REASON = "Ledung was installed from npm, so updates come from npm: run npm install -g ledung@dev, then press Restart Ledung.";
14497
+ var PACKAGE = "ledung";
14498
+ var CHANNEL_KEY = "update-channel";
14499
+ var devCheckTraced = false;
14208
14500
  function readRunningRelease(appDir2) {
14209
14501
  const own = readJson(path30.join(appDir2, "release.json"));
14210
14502
  if (own && typeof own.source === "string") {
@@ -14215,9 +14507,12 @@ function readRunningRelease(appDir2) {
14215
14507
  var npm = process.platform === "win32" ? "npm.cmd" : "npm";
14216
14508
  var NPM_INSTALL = { cmd: npm, args: ["install", "--no-audit", "--no-fund"], shell: process.platform === "win32", limitMs: INSTALL_LIMIT_MS };
14217
14509
  var NPM_RELEASE = { cmd: npm, args: ["run", "release"], shell: process.platform === "win32", limitMs: RELEASE_LIMIT_MS };
14510
+ var NPM_DIST_TAGS = { cmd: npm, args: ["view", PACKAGE, "dist-tags", "--json"], shell: process.platform === "win32" };
14511
+ var npmGlobalInstall = (spec) => ({ cmd: npm, args: ["install", "-g", spec, "--no-audit", "--no-fund"], shell: process.platform === "win32", limitMs: INSTALL_LIMIT_MS });
14218
14512
  var Updater = class {
14219
14513
  opts;
14220
14514
  check = null;
14515
+ npmCheck = null;
14221
14516
  checkError = null;
14222
14517
  checking = false;
14223
14518
  steps = [];
@@ -14231,16 +14526,30 @@ var Updater = class {
14231
14526
  running() {
14232
14527
  return this.opts.npmVersion ? null : readRunningRelease(this.opts.appDir);
14233
14528
  }
14529
+ /** Public or Dev (#461); Public until chosen otherwise. A clone install keeps updating from main either way. */
14530
+ channel() {
14531
+ return this.opts.meta?.get(CHANNEL_KEY) === "dev" ? "dev" : "public";
14532
+ }
14533
+ setChannel(channel) {
14534
+ if (!CHANNELS.includes(channel)) throw new Error(`The channel is ${CHANNELS.join(" or ")}.`);
14535
+ if (!this.opts.meta) throw new Error("The update channel cannot be kept here.");
14536
+ this.opts.meta.set(CHANNEL_KEY, channel);
14537
+ this.npmCheck = null;
14538
+ return this.view();
14539
+ }
14234
14540
  view() {
14235
14541
  const running = this.running();
14236
14542
  const npm2 = this.opts.npmVersion;
14543
+ const channel = this.channel();
14237
14544
  return {
14238
14545
  available: !!running,
14239
- reason: running ? void 0 : npm2 ? NPM_REASON : "This Ledung runs straight from a source folder, not from a release, so it has nothing to update. Start it from a release (npm run release).",
14546
+ reason: running ? void 0 : npm2 ? channel === "dev" ? NPM_DEV_REASON : NPM_REASON : "This Ledung runs straight from a source folder, not from a release, so it has nothing to update. Start it from a release (npm run release).",
14240
14547
  npm: npm2 ? { version: npm2 } : void 0,
14548
+ channel,
14241
14549
  canRestart: !!this.opts.onRestart,
14242
14550
  running: running ? { commit: running.commit, source: running.source, releasedAt: running.releasedAt } : null,
14243
14551
  check: this.check,
14552
+ npmCheck: this.npmCheck,
14244
14553
  checkError: this.checkError,
14245
14554
  checking: this.checking,
14246
14555
  steps: this.steps,
@@ -14249,6 +14558,102 @@ var Updater = class {
14249
14558
  restarting: this.restarting
14250
14559
  };
14251
14560
  }
14561
+ /** Check for updates: on npm the channel's version (#461), otherwise origin/main in the source folder. */
14562
+ checkForUpdates() {
14563
+ return this.opts.npmVersion ? this.runNpmCheck() : this.runCheck();
14564
+ }
14565
+ /** Update Ledung: on npm installs the channel's version and restarts (#461), otherwise pulls main and releases. */
14566
+ runUpdate() {
14567
+ return this.opts.npmVersion ? this.startNpmUpdate() : this.startUpdate();
14568
+ }
14569
+ /** Asks npm for the channel's version and says whether it is newer than the installed one. */
14570
+ async runNpmCheck() {
14571
+ const installed = this.opts.npmVersion;
14572
+ if (!installed || this.checking || this.updating) return this.view();
14573
+ this.checking = true;
14574
+ this.checkError = null;
14575
+ try {
14576
+ const channel = this.channel();
14577
+ const offered = offeredVersion(channel, await this.distTags());
14578
+ this.npmCheck = { checkedAt: (/* @__PURE__ */ new Date()).toISOString(), channel, offered, upToDate: !offered || compareVersions(offered, installed) <= 0 };
14579
+ if (channel === "dev" && !devCheckTraced) {
14580
+ devCheckTraced = true;
14581
+ exercisedLine(`EXERCISED #461 npm update check followed the dev tag (${offered ?? "none"})`);
14582
+ }
14583
+ } catch (e) {
14584
+ this.checkError = e.message;
14585
+ } finally {
14586
+ this.checking = false;
14587
+ }
14588
+ return this.view();
14589
+ }
14590
+ async distTags() {
14591
+ const r = await this.run(this.opts.distTags ?? NPM_DIST_TAGS, this.opts.dataDir);
14592
+ if (r.timedOut || r.code !== 0) throw new Error(`npm view ${PACKAGE} dist-tags failed:
14593
+ ${r.tail}`);
14594
+ try {
14595
+ const tags = JSON.parse(r.out);
14596
+ if (tags && typeof tags === "object") {
14597
+ return Object.fromEntries(Object.entries(tags).filter((e) => typeof e[1] === "string"));
14598
+ }
14599
+ } catch {
14600
+ }
14601
+ throw new Error(`npm view ${PACKAGE} dist-tags did not answer with its tags:
14602
+ ${r.tail}`);
14603
+ }
14604
+ /** Installs the channel's version globally with npm, then restarts into it; refusals as for a git update. */
14605
+ startNpmUpdate() {
14606
+ const installed = this.opts.npmVersion;
14607
+ if (!installed || this.updating) return this.view();
14608
+ if (this.checking) {
14609
+ this.refused = "A check for updates is still running. Update again when it has finished.";
14610
+ this.steps = [];
14611
+ return this.view();
14612
+ }
14613
+ this.refused = null;
14614
+ this.steps = [];
14615
+ if (!this.opts.onRestart) {
14616
+ this.refused = "This Ledung was not started by the ledung command, so it cannot restart itself into the new version. Start it with ledung first.";
14617
+ return this.view();
14618
+ }
14619
+ this.updating = true;
14620
+ void this.npmUpdate(installed).finally(() => {
14621
+ this.updating = false;
14622
+ });
14623
+ return this.view();
14624
+ }
14625
+ async npmUpdate(installed) {
14626
+ const channel = this.channel();
14627
+ this.steps = [`Ask npm for the ${channel === "dev" ? "Dev" : "Public"} version`, `npm install -g ${PACKAGE}@${CHANNEL_TAG[channel]}`, "Restart Ledung"].map((label) => ({ label, state: "waiting" }));
14628
+ const step = (i, state, output, label) => {
14629
+ this.steps = this.steps.map((s, j) => j === i ? { ...s, state, output, label: label ?? s.label } : s);
14630
+ };
14631
+ const refuse = (i, reason, output) => {
14632
+ step(i, "failed", output);
14633
+ this.refused = `${reason} The running Ledung was not changed.`;
14634
+ };
14635
+ step(0, "running");
14636
+ let offered;
14637
+ try {
14638
+ offered = offeredVersion(channel, await this.distTags());
14639
+ } catch (e) {
14640
+ return refuse(0, "npm could not be asked for the version.", e.message);
14641
+ }
14642
+ if (!offered) return refuse(0, `npm has no ${channel === "dev" ? "Dev" : "Public"} version of ${PACKAGE}.`);
14643
+ this.npmCheck = { checkedAt: (/* @__PURE__ */ new Date()).toISOString(), channel, offered, upToDate: compareVersions(offered, installed) <= 0 };
14644
+ if (this.npmCheck.upToDate) return refuse(0, `${displayVersion(installed)} is already the newest on this channel.`);
14645
+ step(0, "done");
14646
+ const spec = `${PACKAGE}@${offered}`;
14647
+ step(1, "running", void 0, `npm install -g ${spec}`);
14648
+ const command = this.opts.npmInstall?.(spec) ?? npmGlobalInstall(spec);
14649
+ const result = await this.run(command, this.opts.dataDir);
14650
+ if (result.timedOut) return refuse(1, `npm install -g ${spec} did not finish within ${Math.round((command.limitMs ?? GIT_LIMIT_MS) / 6e4)} minutes and was stopped.`, result.tail);
14651
+ if (result.code !== 0) return refuse(1, `npm install -g ${spec} failed (exit code ${result.code}).`, result.tail);
14652
+ step(1, "done", result.tail);
14653
+ step(2, "running");
14654
+ this.restarting = true;
14655
+ setTimeout(() => this.opts.onRestart?.(), 1500);
14656
+ }
14252
14657
  /** Fetches origin/main in the source folder and says what is new since the running release. */
14253
14658
  async runCheck() {
14254
14659
  const running = this.running();
@@ -16376,6 +16781,14 @@ var LedungServer = class {
16376
16781
  if (!this.opts.onRestart) throw new HttpError(404, "Restarting is not available.");
16377
16782
  setTimeout(() => this.opts.onRestart?.(), 100);
16378
16783
  return;
16784
+ // Stop Ledung (issue #456): only from this computer, whose icon or `ledung` starts it again; a phone or another
16785
+ // computer could stop it but never start it. The cookie and Origin as for every change; `ledung stop` sends both.
16786
+ case "POST stop":
16787
+ this.requireLocal(req, "Ledung is stopped");
16788
+ if (!fromThisComputer(req)) throw new HttpError(403, "Ledung is stopped on the computer it runs on.");
16789
+ if (!this.opts.onStop) throw new HttpError(404, "Stopping is not available.");
16790
+ setTimeout(() => this.opts.onStop?.(), 100);
16791
+ return;
16379
16792
  // Shortcuts on this computer's Desktop, Start menu or Applications (issue #415): only from a page on this computer
16380
16793
  // itself, not a phone or another computer on the network, and changes only from the open page.
16381
16794
  case "GET shortcuts":
@@ -16385,7 +16798,9 @@ var LedungServer = class {
16385
16798
  this.requireThisComputer(req);
16386
16799
  await this.requirePage(req, url);
16387
16800
  const action = (await readJson2(req)).action;
16388
- if (action !== "add" && action !== "remove" && action !== "decline") throw new HttpError(400, "The action is add, remove or decline.");
16801
+ if (action !== "add" && action !== "remove" && action !== "decline" && action !== "sign-in-on" && action !== "sign-in-off") {
16802
+ throw new HttpError(400, "The action is add, remove or decline, or sign-in-on or sign-in-off for the start at sign-in.");
16803
+ }
16389
16804
  try {
16390
16805
  return await this.shortcuts().act(action);
16391
16806
  } catch (e) {
@@ -16395,9 +16810,19 @@ var LedungServer = class {
16395
16810
  case "GET update":
16396
16811
  return this.updater().view();
16397
16812
  case "POST update-check":
16398
- return this.updater().runCheck();
16813
+ return this.updater().checkForUpdates();
16399
16814
  case "POST update":
16400
- return this.updater().startUpdate();
16815
+ return this.updater().runUpdate();
16816
+ // Settings → About's Public / Dev (#461): guarded as the update it steers (sign-in, origin, an unlocked device).
16817
+ case "POST update-channel": {
16818
+ const channel = (await readJson2(req)).channel;
16819
+ if (channel !== "public" && channel !== "dev") throw new HttpError(400, "The channel is public or dev.");
16820
+ try {
16821
+ return this.updater().setChannel(channel);
16822
+ } catch (e) {
16823
+ throw new HttpError(400, e.message);
16824
+ }
16825
+ }
16401
16826
  case "POST certificate-check":
16402
16827
  if (this.opts.onCertificateCheck) this.opts.onCertificateCheck();
16403
16828
  else if (!this.remoteLink?.checkNow()) throw new HttpError(404, "This Ledung gets no certificate by ACME.");
@@ -20737,6 +21162,8 @@ async function main() {
20737
21162
  }
20738
21163
  if (PACKAGED && createDefaultConfig()) console.log(`Wrote a default config to ${configPath()}.`);
20739
21164
  const config = loadConfig();
21165
+ const startPath = envSetting("START_PATH");
21166
+ if (startPath) exercisedLine(`EXERCISED #464 started with the ${startPath === "login" ? "login shell's" : "fallback"} PATH`);
20740
21167
  const remote = config.remote ?? savedRemote(remoteDir());
20741
21168
  if (!fs38.existsSync(databaseFile())) console.log(ANTHROPIC_NOTICE);
20742
21169
  const db = openDatabase();
@@ -20800,9 +21227,19 @@ async function main() {
20800
21227
  store,
20801
21228
  manager,
20802
21229
  onRestart,
21230
+ // Stop Ledung in Settings and `ledung stop` (issue #456): the same stop as Ctrl+C in its window.
21231
+ onStop: () => void shutdown(false),
20803
21232
  // Made only when asked, in the first-run question or Settings (issue #415).
20804
21233
  shortcuts: new Shortcuts({ launch: launchTarget(), iconDir: userDataDir(), icons: iconFiles(), meta: { get: (k) => store.getMeta(k), set: (k, v) => store.setMeta(k, v) } }),
20805
- updater: new Updater({ appDir: appDir(), dataDir: userDataDir(), configPath: configPath(), onRestart, npmVersion: PACKAGED ? appVersion() : void 0 }),
21234
+ updater: new Updater({
21235
+ appDir: appDir(),
21236
+ dataDir: userDataDir(),
21237
+ configPath: configPath(),
21238
+ onRestart,
21239
+ npmVersion: PACKAGED ? appVersion() : void 0,
21240
+ // The update channel (#461) is this computer's, like its shortcuts.
21241
+ meta: { get: (k) => store.getMeta(k), set: (k, v) => store.setMeta(k, v) }
21242
+ }),
20806
21243
  lanNames: lan,
20807
21244
  lanBind: config.lan === true ? "0.0.0.0" : config.lan || void 0,
20808
21245
  lanOpen: !config.lanSignIn,