@puddle-code/cli 0.0.43 → 0.0.47

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +8 -8
  2. package/README.md +15 -3
  3. package/dist/index.js +1304 -865
  4. package/dist/install.sh +19 -3
  5. package/dist/public/assets/Dashboard-D5d81RrW.js +1 -0
  6. package/dist/public/assets/EditorTabClose-DQFig1yS.js +1 -0
  7. package/dist/public/assets/LazyTerminal-DLpi1FQW.js +2 -0
  8. package/dist/public/assets/{ModelRefcount-CCLXoHn3.js → ModelRefcount-QvnWLhBB.js} +1 -1
  9. package/dist/public/assets/PaneEditorBody-mRqGUQOX.js +515 -0
  10. package/dist/public/assets/SettingsDialog-D15adtF_.js +1 -0
  11. package/dist/public/assets/{Terminal-2XYN3IO5.js → Terminal-ZApPvZ0N.js} +51 -26
  12. package/dist/public/assets/Workspace-CsXrKmgW.js +7 -0
  13. package/dist/public/assets/{buffer-store-kyNS3Ffy.js → buffer-store-B2HjJa44.js} +3 -3
  14. package/dist/public/assets/chevron-down-59O6CgEo.js +1 -0
  15. package/dist/public/assets/context-menu-DOkkb33w.js +1 -0
  16. package/dist/public/assets/{cssMode-CfVFZjxa.js → cssMode-10rVHs6e.js} +1 -1
  17. package/dist/public/assets/dialog-DjL0qoAv.js +41 -0
  18. package/dist/public/assets/dist-DDFe8e3m.js +1 -0
  19. package/dist/public/assets/editor-context-D8zZKoyt.js +1 -0
  20. package/dist/public/assets/{editor-sync-1UzWDCzn.js → editor-sync-C_rbvqxK.js} +1 -1
  21. package/dist/public/assets/file-links-D3c5reil.js +1 -0
  22. package/dist/public/assets/hotkeys-C1l8zghv.js +73 -0
  23. package/dist/public/assets/{htmlMode-CwjicKNM.js → htmlMode-Dk6DjDk-.js} +1 -1
  24. package/dist/public/assets/index-CjwWpHJ7.js +10 -0
  25. package/dist/public/assets/index-Du89SpLI.css +2 -0
  26. package/dist/public/assets/{jsonMode-B9Rj1ARB.js → jsonMode-CwJtwmIn.js} +1 -1
  27. package/dist/public/assets/session-seed-BY79S7pl.js +1 -0
  28. package/dist/public/assets/{tsMode-gVybv0cY.js → tsMode-BZb-xILx.js} +1 -1
  29. package/dist/public/assets/use-find-controls-BxqgwO4Z.js +1 -0
  30. package/dist/public/assets/x-BJOrp_m5.js +1 -0
  31. package/dist/public/index.html +6 -8
  32. package/package.json +1 -1
  33. package/dist/public/assets/Dashboard-CVuyy1FW.js +0 -1
  34. package/dist/public/assets/EditorTabClose-Dltbo1Dg.js +0 -1
  35. package/dist/public/assets/LazyTerminal-dx89528B.js +0 -2
  36. package/dist/public/assets/PaneEditorBody-BEq9lIXk.js +0 -355
  37. package/dist/public/assets/SettingsDialog-cXSqHb2k.js +0 -1
  38. package/dist/public/assets/Workspace-DEEiDdYw.js +0 -7
  39. package/dist/public/assets/context-menu-DsXFpYcn.js +0 -1
  40. package/dist/public/assets/createLucideIcon-DyMAgLa3.js +0 -1
  41. package/dist/public/assets/dialog-B43iKmfK.js +0 -41
  42. package/dist/public/assets/dist-i-QevsUb.js +0 -1
  43. package/dist/public/assets/editor-context-A7YnYVrw.js +0 -1
  44. package/dist/public/assets/file-links-xHzwSI4k.js +0 -1
  45. package/dist/public/assets/hotkeys-CA5Pxll4.js +0 -73
  46. package/dist/public/assets/index-BYC57b5y.js +0 -10
  47. package/dist/public/assets/index-Cs421fXN.css +0 -2
  48. package/dist/public/assets/jsx-runtime-ffCh33zF.js +0 -1
  49. package/dist/public/assets/session-seed-2cbwtTKV.js +0 -1
  50. package/dist/public/assets/utils-B6KiDbIe.js +0 -1
package/dist/index.js CHANGED
@@ -3756,7 +3756,7 @@ var init_wrapper = __esm({
3756
3756
 
3757
3757
  // src/lib/ws-client.ts
3758
3758
  function connectGateway(port, token) {
3759
- return new Promise((resolve3, reject) => {
3759
+ return new Promise((resolve4, reject) => {
3760
3760
  const ws = new wrapper_default(`ws://127.0.0.1:${port}/ws`, {
3761
3761
  headers: { host: `localhost:${port}` }
3762
3762
  });
@@ -3778,7 +3778,7 @@ function connectGateway(port, token) {
3778
3778
  ws.on("close", () => closeCbs.forEach((cb) => cb()));
3779
3779
  ws.once("open", () => {
3780
3780
  ws.send(JSON.stringify({ t: "auth", token }));
3781
- resolve3({
3781
+ resolve4({
3782
3782
  send(message) {
3783
3783
  if (ws.readyState === wrapper_default.OPEN) ws.send(JSON.stringify(message));
3784
3784
  },
@@ -3841,7 +3841,7 @@ async function attachSession(opts) {
3841
3841
  rows: streams.stdout.rows ?? 24
3842
3842
  });
3843
3843
  gateway.send({ t: "attach", session: session.id, term, ...dims2() });
3844
- return new Promise((resolve3) => {
3844
+ return new Promise((resolve4) => {
3845
3845
  let settled = false;
3846
3846
  const finish = (outcome) => {
3847
3847
  if (settled) return;
@@ -3850,7 +3850,7 @@ async function attachSession(opts) {
3850
3850
  stdin.removeListener("data", onStdin);
3851
3851
  unResize?.();
3852
3852
  gateway.close();
3853
- resolve3(outcome);
3853
+ resolve4(outcome);
3854
3854
  };
3855
3855
  gateway.onMessage((message) => {
3856
3856
  switch (message.t) {
@@ -3945,6 +3945,9 @@ usage:
3945
3945
  puddle remove <cli|daemon|desktop> [user@host] [--yes] [--purge]
3946
3946
  puddle --version | --help
3947
3947
 
3948
+ --version lists the installed CLI, daemon, and desktop app with the protocol
3949
+ each speaks; absent local components are reported as not installed.
3950
+
3948
3951
  launch serves the cockpit at http://localhost:7433 against the daemon on this
3949
3952
  machine when no host (or 'local') is given \u2014 installing it under ~/.puddle if
3950
3953
  needed \u2014 and does the same for an SSH host through one tunnel. It keeps
@@ -3952,8 +3955,8 @@ running in the background once ready, so the terminal may close (--foreground
3952
3955
  to stay attached; Ctrl-C then stops the cockpit). refresh stops a target's
3953
3956
  cockpit (even a wedged one) and runs the full launch flow again \u2014 tunnel,
3954
3957
  daemon restart if needed \u2014 keeping the old UI port so open tabs survive.
3955
- list shows running cockpits; kill stops one \u2014 sessions keep running on the
3956
- host either way.
3958
+ list shows running cockpits; kill stops one. Sessions keep running under a
3959
+ persistent supervisor; an SSH-attached daemon keeps their state for the next launch.
3957
3960
 
3958
3961
  install puts a component in place: 'daemon' under ~/.puddle on this machine
3959
3962
  or a user@host, 'desktop' as the macOS app bundle \u2014 or, on Linux, an
@@ -4226,8 +4229,8 @@ function argvFor(command) {
4226
4229
 
4227
4230
  // src/cli/run.ts
4228
4231
  init_attach();
4229
- import { existsSync as existsSync4 } from "node:fs";
4230
- import { dirname as dirname7, join as join10 } from "node:path";
4232
+ import { existsSync as existsSync5 } from "node:fs";
4233
+ import { dirname as dirname8, join as join11 } from "node:path";
4231
4234
  import { fileURLToPath as fileURLToPath3 } from "node:url";
4232
4235
 
4233
4236
  // src/lib/browser.ts
@@ -4245,11 +4248,19 @@ function openBrowser(url2, platform = process.platform) {
4245
4248
  }
4246
4249
  }
4247
4250
 
4248
- // src/lib/connect.ts
4249
- import { join as join5 } from "node:path";
4251
+ // src/lib/component-versions.ts
4252
+ import {
4253
+ existsSync,
4254
+ mkdirSync,
4255
+ readFileSync as readFileSync2,
4256
+ readlinkSync,
4257
+ renameSync,
4258
+ writeFileSync
4259
+ } from "node:fs";
4260
+ import { dirname as dirname3, join as join4, resolve } from "node:path";
4250
4261
 
4251
4262
  // ../shared/src/protocol.ts
4252
- var PROTOCOL_VERSION = { major: 16, minor: 0 };
4263
+ var PROTOCOL_VERSION = { major: 16, minor: 1 };
4253
4264
 
4254
4265
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
4255
4266
  var external_exports = {};
@@ -19517,6 +19528,12 @@ var resolvePathResponseSchema = external_exports.object({
19517
19528
  line: external_exports.number().int().nullable(),
19518
19529
  /** What resolved (15.2). Absent means `file` — pre-15.2 daemons only answered files. */
19519
19530
  kind: external_exports.enum(["file", "dir"]).optional(),
19531
+ /**
19532
+ * For a directory contained by the resolution root, its root-relative path
19533
+ * (16.1). This lets Files reveal it in the existing tree instead of entering
19534
+ * an external pinned browse. Absent for directories outside that root.
19535
+ */
19536
+ relative_path: external_exports.string().optional(),
19520
19537
  /**
19521
19538
  * Absolute browse root when the file lies OUTSIDE the worktree (15.2):
19522
19539
  * `path` is then relative to it — the `external` tab convention (SPEC §8).
@@ -19789,54 +19806,14 @@ var wsClientMessageSchema = external_exports.discriminatedUnion("t", [
19789
19806
  })
19790
19807
  ]);
19791
19808
 
19792
- // src/lib/net.ts
19793
- import { connect, createServer } from "node:net";
19794
- function findFreePort() {
19795
- return new Promise((resolve3, reject) => {
19796
- const srv = createServer();
19797
- srv.once("error", reject);
19798
- srv.listen(0, "127.0.0.1", () => {
19799
- const address = srv.address();
19800
- const port = typeof address === "object" && address !== null ? address.port : 0;
19801
- srv.close(() => resolve3(port));
19802
- });
19803
- });
19804
- }
19805
- function tcpListening(port, timeoutMs = 500) {
19806
- return new Promise((resolve3) => {
19807
- const sock = connect({ host: "127.0.0.1", port });
19808
- const done = (ok) => {
19809
- sock.destroy();
19810
- resolve3(ok);
19811
- };
19812
- sock.setTimeout(timeoutMs, () => done(false));
19813
- sock.once("connect", () => done(true));
19814
- sock.once("error", () => done(false));
19815
- });
19816
- }
19817
- async function waitForTcp(port, timeoutMs) {
19818
- const deadline = Date.now() + timeoutMs;
19819
- while (Date.now() < deadline) {
19820
- if (await tcpListening(port)) return true;
19821
- await sleep(150);
19822
- }
19823
- return false;
19824
- }
19825
- async function waitForHttp(url2, timeoutMs) {
19826
- const deadline = Date.now() + timeoutMs;
19827
- while (Date.now() < deadline) {
19828
- try {
19829
- await fetch(url2, { signal: AbortSignal.timeout(2e3) });
19830
- return true;
19831
- } catch {
19832
- await sleep(250);
19833
- }
19834
- }
19835
- return false;
19836
- }
19837
- function sleep(ms) {
19838
- return new Promise((r) => setTimeout(r, ms));
19839
- }
19809
+ // src/lib/desktop-update.ts
19810
+ import { createHash } from "node:crypto";
19811
+ import { spawn as spawn2 } from "node:child_process";
19812
+ import { constants, createReadStream, createWriteStream } from "node:fs";
19813
+ import { access, chmod, mkdir, readdir, rm, writeFile } from "node:fs/promises";
19814
+ import { dirname as dirname2, join as join3 } from "node:path";
19815
+ import { Readable } from "node:stream";
19816
+ import { pipeline } from "node:stream/promises";
19840
19817
 
19841
19818
  // src/lib/paths.ts
19842
19819
  import { homedir } from "node:os";
@@ -19852,116 +19829,12 @@ var hostPaths = {
19852
19829
  runtime: `${HOST_HOME}/runtime.json`,
19853
19830
  current: `${HOST_HOME}/bin/current`,
19854
19831
  cache: `${HOST_HOME}/cache`,
19855
- logs: `${HOST_HOME}/logs`
19856
- };
19857
-
19858
- // src/lib/daemon-client.ts
19859
- init_types();
19860
- var LIVE = /* @__PURE__ */ new Set(["starting", "running", "waiting_input"]);
19861
- var DaemonClient = class {
19862
- constructor(port, token) {
19863
- this.port = port;
19864
- this.token = token;
19865
- }
19866
- setPort(port) {
19867
- this.port = port;
19868
- }
19869
- async get(path) {
19870
- let res;
19871
- try {
19872
- res = await fetch(`http://127.0.0.1:${this.port}${path}`, {
19873
- headers: { authorization: `Bearer ${this.token}` },
19874
- signal: AbortSignal.timeout(1e4)
19875
- });
19876
- } catch {
19877
- throw new CliError("daemon_unreachable", "the Puddle daemon is not reachable");
19878
- }
19879
- if (res.status === 401) {
19880
- throw new CliError(
19881
- "token_rejected",
19882
- "the stored token was rejected by the daemon",
19883
- "inspect ~/.puddle/token on the host \u2014 it may have been regenerated"
19884
- );
19885
- }
19886
- if (!res.ok) {
19887
- throw new CliError("daemon_unreachable", `the daemon answered ${res.status} for ${path}`);
19888
- }
19889
- return res.json();
19890
- }
19891
- version() {
19892
- return this.get("/api/version").then((body) => versionResponseSchema.parse(body));
19893
- }
19894
- host() {
19895
- return this.get("/api/host").then((body) => hostInfoSchema.parse(body));
19896
- }
19897
- sessions() {
19898
- return this.get("/api/sessions").then((body) => sessionSchema.array().parse(body));
19899
- }
19900
- profiles() {
19901
- return this.get("/api/profiles").then((body) => profileSchema.array().parse(body));
19902
- }
19903
- /** The live subset of sessions() — what an upgrade/removal interrupts. */
19904
- async liveSessions() {
19905
- return (await this.sessions()).filter((s) => LIVE.has(s.status));
19906
- }
19907
- async liveSessionCount() {
19908
- return (await this.sessions()).filter((s) => LIVE.has(s.status)).length;
19909
- }
19910
- /** Whether anything daemon-shaped answers /api/version (401 counts as alive). */
19911
- async responds() {
19912
- try {
19913
- const res = await fetch(`http://127.0.0.1:${this.port}/api/version`, {
19914
- headers: { authorization: `Bearer ${this.token}` },
19915
- signal: AbortSignal.timeout(2e3)
19916
- });
19917
- return res.status < 500;
19918
- } catch {
19919
- return false;
19920
- }
19921
- }
19832
+ logs: `${HOST_HOME}/logs`,
19833
+ supervisor: `${HOST_HOME}/supervisor`,
19834
+ pid: `${HOST_HOME}/puddled.pid`
19922
19835
  };
19923
- async function readDaemonPort(transport) {
19924
- const runtime = await transport.readFile(hostPaths.runtime);
19925
- if (runtime !== null) {
19926
- try {
19927
- const parsed = JSON.parse(runtime);
19928
- if (typeof parsed.port === "number") return parsed.port;
19929
- } catch {
19930
- }
19931
- }
19932
- const raw = await transport.readFile(hostPaths.config);
19933
- if (raw !== null) {
19934
- try {
19935
- const parsed = JSON.parse(raw);
19936
- if (typeof parsed.port === "number") return parsed.port;
19937
- } catch {
19938
- }
19939
- }
19940
- return 7434;
19941
- }
19942
- async function readToken(transport) {
19943
- const raw = await transport.readFile(hostPaths.token);
19944
- const token = raw?.trim() ?? "";
19945
- return /^[0-9a-f]{32,}$/.test(token) ? token : null;
19946
- }
19947
- async function waitForToken(transport, timeoutMs) {
19948
- const deadline = Date.now() + timeoutMs;
19949
- while (Date.now() < deadline) {
19950
- const token = await readToken(transport);
19951
- if (token !== null) return token;
19952
- await sleep(500);
19953
- }
19954
- throw new CliError(
19955
- "daemon_start_timeout",
19956
- `puddled did not come up on ${transport.label}`,
19957
- transport.kind === "ssh" ? `inspect it with: puddle logs ${transport.label}` : "inspect it with: puddle logs"
19958
- );
19959
- }
19960
19836
 
19961
- // src/lib/bootstrap.ts
19962
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
19963
- import { dirname as dirname2, join as join3, resolve } from "node:path";
19964
- import { fileURLToPath as fileURLToPath2 } from "node:url";
19837
+ // src/lib/desktop-update.ts
19965
19838
  init_types();
19966
19839
 
19967
19840
  // src/lib/version.ts
@@ -19969,7 +19842,7 @@ import { readFileSync } from "node:fs";
19969
19842
  import { dirname, join as join2 } from "node:path";
19970
19843
  import { fileURLToPath } from "node:url";
19971
19844
  function cliVersion() {
19972
- if (true) return "0.0.43";
19845
+ if (true) return "0.0.47";
19973
19846
  const here = dirname(fileURLToPath(import.meta.url));
19974
19847
  for (const candidate of [
19975
19848
  join2(here, "..", "..", "package.json"),
@@ -19992,103 +19865,697 @@ function repoSlug(env = process.env) {
19992
19865
  return void 0;
19993
19866
  }
19994
19867
 
19995
- // src/lib/bootstrap.ts
19996
- async function installedVersion(transport) {
19997
- const result = await transport.exec(`readlink ${hostPaths.current}`, { timeoutMs: 15e3 });
19998
- if (result.code !== 0) return null;
19999
- const match = /versions\/([^/\s]+)\s*$/.exec(result.stdout.trim());
20000
- return match?.[1] ?? null;
20001
- }
20002
- async function installDaemon(transport, opts = {}) {
20003
- const logger = opts.logger ?? silentLogger;
20004
- const version2 = opts.version ?? pinnedDaemonVersion();
20005
- const script = readInstallScript();
20006
- const args = ["--version", version2];
20007
- if (opts.tarball !== void 0 && !/^https?:\/\//.test(opts.tarball)) {
20008
- if (!existsSync(opts.tarball)) {
20009
- throw new CliError("not_installed", `tarball not found: ${opts.tarball}`);
20010
- }
20011
- if (transport.kind === "local") {
20012
- args.push("--tarball", resolve(opts.tarball));
20013
- const localSums = `${opts.tarball}.sha256`;
20014
- if (existsSync(localSums)) args.push("--sums", resolve(localSums));
20015
- } else {
20016
- const base = opts.tarball.split("/").pop() ?? "puddled.tar.gz";
20017
- const destTarball = `.puddle/cache/${base}`;
20018
- logger.info(`delivering ${base} to ${transport.label}`);
20019
- await transport.copyTo(opts.tarball, destTarball);
20020
- const sums = `${opts.tarball}.sha256`;
20021
- if (existsSync(sums)) {
20022
- await transport.copyTo(sums, `${destTarball}.sha256`);
20023
- args.push("--tarball", destTarball, "--sums", `${destTarball}.sha256`);
20024
- } else {
20025
- args.push("--tarball", destTarball);
20026
- }
20027
- }
20028
- } else {
20029
- const slug2 = repoSlug();
20030
- if (opts.tarball !== void 0) {
20031
- throw new CliError("bad_arguments", "--tarball must be a local file path");
20032
- }
20033
- if (slug2 === void 0) {
20034
- throw new CliError(
20035
- "not_installed",
20036
- "no release source is configured for this build",
20037
- "pass --tarball <path> (pnpm build:tarball produces one) or set PUDDLE_REPO=owner/repo"
20038
- );
20039
- }
20040
- args.push("--repo", slug2);
20041
- }
20042
- logger.info(`installing puddled ${version2} on ${transport.label}`);
20043
- const quoted = args.map((a) => `'${a.replaceAll("'", `'\\''`)}'`).join(" ");
20044
- const result = await transport.exec(`sh -s -- ${quoted}`, {
20045
- stdin: script,
20046
- timeoutMs: 10 * 6e4,
20047
- onStdout: (chunk) => {
20048
- for (const line of chunk.split("\n")) if (line.trim() !== "") logger.info(line.trimEnd());
20049
- }
20050
- });
20051
- if (result.code !== 0) {
20052
- throw new CliError(
20053
- "not_installed",
20054
- `the installer failed on ${transport.label} (exit ${result.code})`,
20055
- lastLines(result.stderr || result.stdout)
20056
- );
19868
+ // src/lib/desktop-update.ts
19869
+ function isNewerVersion(candidate, current) {
19870
+ const parse3 = (v) => {
19871
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
19872
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
19873
+ };
19874
+ const a = parse3(candidate);
19875
+ const b = parse3(current);
19876
+ if (a === null || b === null) return false;
19877
+ for (let i = 0; i < 3; i++) {
19878
+ if (a[i] !== b[i]) return a[i] > b[i];
20057
19879
  }
19880
+ return false;
20058
19881
  }
20059
- function lastLines(text, count = 3) {
20060
- return text.trim().split("\n").slice(-count).join("\n");
19882
+ function pickDesktopAsset(assets, platform, arch) {
19883
+ const suffix = platform === "darwin" ? "-mac.zip" : platform === "linux" ? ".AppImage" : null;
19884
+ if (suffix === null) return null;
19885
+ return assets.find(
19886
+ (a) => a.name.endsWith(suffix) && a.name.includes("-arm64") === (arch === "arm64")
19887
+ ) ?? null;
20061
19888
  }
20062
- function readInstallScript() {
20063
- const here = dirname2(fileURLToPath2(import.meta.url));
20064
- const candidates = [
20065
- join3(here, "install.sh"),
20066
- // dist/index.js → dist/install.sh
20067
- join3(here, "..", "..", "..", "..", "scripts", "install.sh")
20068
- // src/lib/ → repo root
20069
- ];
20070
- for (const candidate of candidates) {
20071
- if (existsSync(candidate)) return readFileSync2(candidate, "utf8");
19889
+ function parseSums(text) {
19890
+ const sums = /* @__PURE__ */ new Map();
19891
+ for (const line of text.split("\n")) {
19892
+ const match = /^([0-9a-f]{64})\s+\*?(.+?)\s*$/.exec(line.trim());
19893
+ if (match) sums.set(match[2], match[1]);
20072
19894
  }
20073
- throw new CliError("not_installed", "install.sh is missing from this build");
19895
+ return sums;
20074
19896
  }
20075
-
20076
- // src/lib/cockpit.ts
20077
- init_types();
20078
- async function ensureDaemon(transport, opts) {
19897
+ async function checkForDesktopUpdate(currentVersion, opts = {}) {
19898
+ const slug2 = repoSlug();
19899
+ if (slug2 === void 0) return null;
19900
+ const fetchFn = opts.fetchFn ?? fetch;
19901
+ const response = await fetchFn(`https://api.github.com/repos/${slug2}/releases/latest`, {
19902
+ headers: { accept: "application/vnd.github+json", "user-agent": "puddle-desktop" }
19903
+ });
19904
+ if (!response.ok) {
19905
+ throw new CliError("not_installed", `release lookup failed (HTTP ${response.status})`);
19906
+ }
19907
+ const release = await response.json();
19908
+ const version2 = (release.tag_name ?? "").replace(/^v/, "");
19909
+ if (!isNewerVersion(version2, currentVersion)) return null;
19910
+ return updateFromRelease(version2, release.assets ?? [], opts);
19911
+ }
19912
+ async function desktopUpdateAt(version2, opts = {}) {
19913
+ const slug2 = repoSlug();
19914
+ if (slug2 === void 0) {
19915
+ throw new CliError(
19916
+ "not_installed",
19917
+ "no release source is configured for this build",
19918
+ "set PUDDLE_REPO=owner/repo"
19919
+ );
19920
+ }
19921
+ const fetchFn = opts.fetchFn ?? fetch;
19922
+ const response = await fetchFn(`https://api.github.com/repos/${slug2}/releases/tags/v${version2}`, {
19923
+ headers: { accept: "application/vnd.github+json", "user-agent": "puddle-desktop" }
19924
+ });
19925
+ if (response.status === 404) {
19926
+ throw new CliError("not_installed", `no release v${version2} exists in ${slug2}`);
19927
+ }
19928
+ if (!response.ok) {
19929
+ throw new CliError("not_installed", `release lookup failed (HTTP ${response.status})`);
19930
+ }
19931
+ const release = await response.json();
19932
+ return updateFromRelease(version2, release.assets ?? [], opts);
19933
+ }
19934
+ function updateFromRelease(version2, assets, opts) {
19935
+ const asset = pickDesktopAsset(
19936
+ assets,
19937
+ opts.platform ?? process.platform,
19938
+ opts.arch ?? process.arch
19939
+ );
19940
+ const sums = assets.find((a) => a.name === "SHA256SUMS");
19941
+ if (asset === null || sums === void 0) return null;
19942
+ return {
19943
+ version: version2,
19944
+ asset: { name: asset.name, url: asset.browser_download_url },
19945
+ sumsUrl: sums.browser_download_url
19946
+ };
19947
+ }
19948
+ async function stageDesktopUpdate(update, opts = {}) {
19949
+ const logger = opts.logger ?? silentLogger;
19950
+ const fetchFn = opts.fetchFn ?? fetch;
19951
+ const dir = join3(opts.cacheDir ?? join3(clientHome(), "cache", "desktop"), update.version);
19952
+ await rm(dir, { recursive: true, force: true });
19953
+ await mkdir(dir, { recursive: true });
19954
+ try {
19955
+ logger.info(`downloading ${update.asset.name}`);
19956
+ const archive = join3(dir, update.asset.name);
19957
+ const response = await fetchFn(update.asset.url, {
19958
+ headers: { "user-agent": "puddle-desktop" }
19959
+ });
19960
+ if (!response.ok || response.body === null) {
19961
+ throw new CliError("not_installed", `download failed (HTTP ${response.status})`);
19962
+ }
19963
+ await pipeline(
19964
+ Readable.fromWeb(response.body),
19965
+ createWriteStream(archive)
19966
+ );
19967
+ const sumsResponse = await fetchFn(update.sumsUrl, {
19968
+ headers: { "user-agent": "puddle-desktop" }
19969
+ });
19970
+ if (!sumsResponse.ok) {
19971
+ throw new CliError("not_installed", `SHA256SUMS fetch failed (HTTP ${sumsResponse.status})`);
19972
+ }
19973
+ const expected = parseSums(await sumsResponse.text()).get(update.asset.name);
19974
+ if (expected === void 0) {
19975
+ throw new CliError("not_installed", `${update.asset.name} is not in SHA256SUMS`);
19976
+ }
19977
+ const actual = await fileSha256(archive);
19978
+ if (actual !== expected) {
19979
+ throw new CliError(
19980
+ "not_installed",
19981
+ `checksum mismatch for ${update.asset.name}`,
19982
+ `expected ${expected}, got ${actual}`
19983
+ );
19984
+ }
19985
+ if (update.asset.name.endsWith(".zip")) {
19986
+ const extractDir = join3(dir, "extract");
19987
+ await run("/usr/bin/ditto", ["-x", "-k", archive, extractDir]);
19988
+ const bundle = (await readdir(extractDir)).find((name) => name.endsWith(".app"));
19989
+ if (bundle === void 0) {
19990
+ throw new CliError("not_installed", "the update zip contains no .app bundle");
19991
+ }
19992
+ logger.info(`staged ${update.version} at ${join3(extractDir, bundle)}`);
19993
+ return {
19994
+ version: update.version,
19995
+ kind: "mac-app",
19996
+ stagedPath: join3(extractDir, bundle),
19997
+ dir
19998
+ };
19999
+ }
20000
+ await chmod(archive, 493);
20001
+ logger.info(`staged ${update.version} at ${archive}`);
20002
+ return { version: update.version, kind: "appimage", stagedPath: archive, dir };
20003
+ } catch (e) {
20004
+ await rm(dir, { recursive: true, force: true });
20005
+ throw e;
20006
+ }
20007
+ }
20008
+ async function pruneDesktopUpdateCache(keep = [], opts = {}) {
20009
+ const dir = opts.cacheDir ?? join3(clientHome(), "cache", "desktop");
20010
+ let entries;
20011
+ try {
20012
+ entries = await readdir(dir);
20013
+ } catch {
20014
+ return;
20015
+ }
20016
+ await Promise.all(
20017
+ entries.filter((name) => !keep.includes(name)).map((name) => rm(join3(dir, name), { recursive: true, force: true }).catch(() => {
20018
+ }))
20019
+ );
20020
+ }
20021
+ async function applyDesktopUpdate(staged, opts) {
20022
+ const logger = opts.logger ?? silentLogger;
20023
+ const script = swapScript(staged, opts);
20024
+ const scriptPath = join3(staged.dir, "apply.sh");
20025
+ await writeFile(scriptPath, script, { mode: 493 });
20026
+ logger.info(`applying ${staged.version} via ${scriptPath}`);
20027
+ if (opts.detach ?? true) {
20028
+ const child = spawn2("/bin/sh", [scriptPath], { detached: true, stdio: "ignore" });
20029
+ child.unref();
20030
+ return;
20031
+ }
20032
+ await run("/bin/sh", [scriptPath]);
20033
+ }
20034
+ var q = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
20035
+ function swapScript(staged, opts) {
20036
+ const wait = opts.waitPid === void 0 ? [] : [
20037
+ `i=0`,
20038
+ `while kill -0 ${opts.waitPid} 2>/dev/null; do`,
20039
+ ` i=$((i+1)); [ "$i" -gt 600 ] && exit 1`,
20040
+ ` sleep 0.1`,
20041
+ `done`
20042
+ ];
20043
+ if (staged.kind === "mac-app") {
20044
+ return [
20045
+ `#!/bin/sh`,
20046
+ `# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
20047
+ ...wait,
20048
+ `target=${q(opts.targetPath)}`,
20049
+ `target_dir=${q(dirname2(opts.targetPath))}`,
20050
+ `staged=${q(staged.stagedPath)}`,
20051
+ `mkdir -p "$target_dir" || exit 1`,
20052
+ `rm -rf "$target.old"`,
20053
+ `had_old=0`,
20054
+ `if [ -e "$target" ]; then`,
20055
+ ` mv "$target" "$target.old" || exit 1`,
20056
+ ` had_old=1`,
20057
+ `fi`,
20058
+ `if mv "$staged" "$target" 2>/dev/null || /usr/bin/ditto "$staged" "$target"; then`,
20059
+ // Defence in depth: the normal path never quarantines (node's fetch is
20060
+ // not a quarantine-opted-in app, and ditto only propagates what the
20061
+ // zip already carries), but if the bundle ever acquires the attribute
20062
+ // the swap must not resurrect the Gatekeeper prompt.
20063
+ ` /usr/bin/xattr -dr com.apple.quarantine "$target" 2>/dev/null`,
20064
+ ` rm -rf "$target.old" ${q(staged.dir)}`,
20065
+ ...opts.relaunch ? [` open "$target"`] : [],
20066
+ `else`,
20067
+ ` rm -rf "$target"`,
20068
+ ` [ "$had_old" -eq 0 ] || mv "$target.old" "$target"`,
20069
+ ` exit 1`,
20070
+ `fi`,
20071
+ ``
20072
+ ].join("\n");
20073
+ }
20074
+ return [
20075
+ `#!/bin/sh`,
20076
+ `# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
20077
+ ...wait,
20078
+ `target=${q(opts.targetPath)}`,
20079
+ `cp -f ${q(staged.stagedPath)} "$target" || exit 1`,
20080
+ `chmod +x "$target"`,
20081
+ `rm -rf ${q(staged.dir)}`,
20082
+ ...opts.relaunch ? [`"$target" >/dev/null 2>&1 &`] : [],
20083
+ ``
20084
+ ].join("\n");
20085
+ }
20086
+ async function findInstalledDesktopApp(opts = {}) {
20087
+ if ((opts.platform ?? process.platform) !== "darwin") return null;
20088
+ const { readFile } = await import("node:fs/promises");
20089
+ const { homedir: homedir3 } = await import("node:os");
20090
+ const systemApplications = opts.systemApplicationsDir ?? "/Applications";
20091
+ const home = opts.homeDir ?? homedir3();
20092
+ for (const appPath of [
20093
+ join3(systemApplications, "Puddle.app"),
20094
+ join3(home, "Applications", "Puddle.app")
20095
+ ]) {
20096
+ try {
20097
+ const plist = await readFile(join3(appPath, "Contents/Info.plist"), "utf8");
20098
+ const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
20099
+ plist
20100
+ );
20101
+ if (match) return { appPath, version: match[1] };
20102
+ } catch {
20103
+ }
20104
+ }
20105
+ return null;
20106
+ }
20107
+ async function desktopAppInstallPath(opts = {}) {
20108
+ if ((opts.platform ?? process.platform) !== "darwin") return null;
20109
+ const { homedir: homedir3 } = await import("node:os");
20110
+ const systemApplications = opts.systemApplicationsDir ?? "/Applications";
20111
+ const canWrite = opts.canWrite ?? (async (path) => {
20112
+ try {
20113
+ await access(path, constants.W_OK);
20114
+ return true;
20115
+ } catch {
20116
+ return false;
20117
+ }
20118
+ });
20119
+ if (await canWrite(systemApplications)) return join3(systemApplications, "Puddle.app");
20120
+ const userApplications = join3(opts.homeDir ?? homedir3(), "Applications");
20121
+ await mkdir(userApplications, { recursive: true });
20122
+ return join3(userApplications, "Puddle.app");
20123
+ }
20124
+ function isDesktopAppRunning(appPath) {
20125
+ return new Promise((resolve4) => {
20126
+ const child = spawn2("/usr/bin/pgrep", ["-f", `${appPath}/Contents/MacOS/`], {
20127
+ stdio: "ignore"
20128
+ });
20129
+ child.on("error", () => resolve4(false));
20130
+ child.on("exit", (code) => resolve4(code === 0));
20131
+ });
20132
+ }
20133
+ function fileSha256(path) {
20134
+ const hash2 = createHash("sha256");
20135
+ return pipeline(createReadStream(path), hash2).then(() => hash2.digest("hex"));
20136
+ }
20137
+ function run(command, args) {
20138
+ return new Promise((resolve4, reject) => {
20139
+ const child = spawn2(command, args, { stdio: "ignore" });
20140
+ child.on("error", reject);
20141
+ child.on(
20142
+ "exit",
20143
+ (code) => code === 0 ? resolve4() : reject(new CliError("not_installed", `${command} exited with ${code ?? "signal"}`))
20144
+ );
20145
+ });
20146
+ }
20147
+
20148
+ // src/lib/component-versions.ts
20149
+ var RELEASE_PROTOCOLS = {
20150
+ "0.0.1": { major: 5, minor: 1 },
20151
+ "0.0.2": { major: 6, minor: 0 },
20152
+ "0.0.3": { major: 6, minor: 2 },
20153
+ "0.0.4": { major: 6, minor: 2 },
20154
+ "0.0.5": { major: 7, minor: 0 },
20155
+ "0.0.6": { major: 7, minor: 1 },
20156
+ "0.0.7": { major: 7, minor: 1 },
20157
+ "0.0.8": { major: 7, minor: 4 },
20158
+ "0.0.9": { major: 8, minor: 0 },
20159
+ "0.0.10": { major: 9, minor: 1 },
20160
+ "0.0.11": { major: 9, minor: 2 },
20161
+ "0.0.12": { major: 10, minor: 1 },
20162
+ "0.0.13": { major: 10, minor: 1 },
20163
+ "0.0.14": { major: 10, minor: 1 },
20164
+ "0.0.15": { major: 10, minor: 1 },
20165
+ "0.0.16": { major: 10, minor: 4 },
20166
+ "0.0.17": { major: 10, minor: 7 },
20167
+ "0.0.18": { major: 11, minor: 0 },
20168
+ "0.0.19": { major: 11, minor: 1 },
20169
+ "0.0.20": { major: 11, minor: 1 },
20170
+ "0.0.21": { major: 11, minor: 1 },
20171
+ "0.0.22": { major: 12, minor: 3 },
20172
+ "0.0.23": { major: 12, minor: 3 },
20173
+ "0.0.24": { major: 12, minor: 3 },
20174
+ "0.0.25": { major: 13, minor: 0 },
20175
+ "0.0.26": { major: 13, minor: 0 },
20176
+ "0.0.27": { major: 13, minor: 0 },
20177
+ "0.0.28": { major: 13, minor: 0 },
20178
+ "0.0.29": { major: 13, minor: 1 },
20179
+ "0.0.30": { major: 13, minor: 1 },
20180
+ "0.0.31": { major: 13, minor: 1 },
20181
+ "0.0.32": { major: 14, minor: 0 },
20182
+ "0.0.33": { major: 14, minor: 2 },
20183
+ "0.0.34": { major: 14, minor: 2 },
20184
+ "0.0.35": { major: 15, minor: 1 },
20185
+ "0.0.36": { major: 15, minor: 2 },
20186
+ "0.0.37": { major: 15, minor: 2 },
20187
+ "0.0.38": { major: 15, minor: 2 },
20188
+ "0.0.39": { major: 15, minor: 3 },
20189
+ "0.0.40": { major: 15, minor: 3 },
20190
+ "0.0.41": { major: 15, minor: 3 },
20191
+ "0.0.42": { major: 16, minor: 0 },
20192
+ "0.0.43": { major: 16, minor: 0 },
20193
+ "0.0.44": { major: 16, minor: 0 },
20194
+ "0.0.47": { major: 16, minor: 1 }
20195
+ };
20196
+ function validProtocol(value) {
20197
+ if (typeof value !== "object" || value === null) return false;
20198
+ const protocol = value;
20199
+ return typeof protocol["major"] === "number" && Number.isInteger(protocol["major"]) && protocol["major"] >= 0 && typeof protocol["minor"] === "number" && Number.isInteger(protocol["minor"]) && protocol["minor"] >= 0;
20200
+ }
20201
+ function releasedProtocol(version2) {
20202
+ return RELEASE_PROTOCOLS[version2];
20203
+ }
20204
+ function componentProtocolForVersion(version2) {
20205
+ return releasedProtocol(version2) ?? (version2 === cliVersion() ? PROTOCOL_VERSION : void 0);
20206
+ }
20207
+ function desktopRecordFile(home) {
20208
+ return join4(home, "desktop-install.json");
20209
+ }
20210
+ function readDesktopInstallation(file2) {
20211
+ try {
20212
+ const value = JSON.parse(readFileSync2(file2, "utf8"));
20213
+ if (typeof value !== "object" || value === null) return null;
20214
+ const record2 = value;
20215
+ if (typeof record2["path"] !== "string" || typeof record2["version"] !== "string") return null;
20216
+ const protocol = validProtocol(record2["protocol"]) ? record2["protocol"] : void 0;
20217
+ return {
20218
+ path: record2["path"],
20219
+ version: record2["version"],
20220
+ ...protocol ? { protocol } : {}
20221
+ };
20222
+ } catch {
20223
+ return null;
20224
+ }
20225
+ }
20226
+ function recordDesktopInstallation(installation, file2 = desktopRecordFile(clientHome())) {
20227
+ try {
20228
+ mkdirSync(dirname3(file2), { recursive: true });
20229
+ const temp = `${file2}.${process.pid}.tmp`;
20230
+ writeFileSync(temp, `${JSON.stringify(installation, null, 2)}
20231
+ `);
20232
+ renameSync(temp, file2);
20233
+ } catch {
20234
+ }
20235
+ }
20236
+ function daemonVersion(home) {
20237
+ try {
20238
+ const target = readlinkSync(join4(home, "bin", "current"));
20239
+ return /(?:^|\/)versions\/([^/\s]+)$/.exec(target)?.[1];
20240
+ } catch {
20241
+ return void 0;
20242
+ }
20243
+ }
20244
+ function daemonProtocol(home, version2) {
20245
+ try {
20246
+ const raw = readFileSync2(join4(home, "bin", "current", "PROTOCOL"), "utf8").trim();
20247
+ const match = /^(\d+)\.(\d+)$/.exec(raw);
20248
+ if (match) return { major: Number(match[1]), minor: Number(match[2]) };
20249
+ } catch {
20250
+ }
20251
+ return releasedProtocol(version2);
20252
+ }
20253
+ async function installedComponentVersions(opts = {}) {
20254
+ const home = opts.home ?? clientHome();
20255
+ const cli = {
20256
+ component: "cli",
20257
+ installed: true,
20258
+ version: cliVersion(),
20259
+ protocol: PROTOCOL_VERSION
20260
+ };
20261
+ const installedDaemon = daemonVersion(home);
20262
+ const installedDaemonProtocol = installedDaemon === void 0 ? void 0 : daemonProtocol(home, installedDaemon);
20263
+ const daemon = installedDaemon === void 0 ? { component: "daemon", installed: false } : {
20264
+ component: "daemon",
20265
+ installed: true,
20266
+ version: installedDaemon,
20267
+ ...installedDaemonProtocol ? { protocol: installedDaemonProtocol } : {}
20268
+ };
20269
+ const recordedDesktop = readDesktopInstallation(desktopRecordFile(home));
20270
+ let desktopInstallation = recordedDesktop !== null && existsSync(recordedDesktop.path) ? recordedDesktop : null;
20271
+ if (desktopInstallation === null && (opts.platform ?? process.platform) === "darwin") {
20272
+ const found = await (opts.findDesktop ?? findInstalledDesktopApp)();
20273
+ if (found !== null) {
20274
+ const protocol = componentProtocolForVersion(found.version);
20275
+ desktopInstallation = {
20276
+ path: found.appPath,
20277
+ version: found.version,
20278
+ ...protocol ? { protocol } : {}
20279
+ };
20280
+ }
20281
+ }
20282
+ const legacyLinuxPath = resolve(home, "..", "puddle", "Puddle.AppImage");
20283
+ const legacyLinuxDesktop = desktopInstallation === null && (opts.platform ?? process.platform) === "linux" && existsSync(legacyLinuxPath);
20284
+ const desktopProtocol = desktopInstallation?.protocol ?? (desktopInstallation ? componentProtocolForVersion(desktopInstallation.version) : void 0);
20285
+ const desktop = desktopInstallation !== null ? {
20286
+ component: "desktop",
20287
+ installed: true,
20288
+ version: desktopInstallation.version,
20289
+ ...desktopProtocol ? { protocol: desktopProtocol } : {}
20290
+ } : legacyLinuxDesktop ? { component: "desktop", installed: true } : { component: "desktop", installed: false };
20291
+ return [cli, daemon, desktop];
20292
+ }
20293
+ function formatComponentVersions(components) {
20294
+ return components.map((component) => {
20295
+ const name = component.component.padEnd(7);
20296
+ if (!component.installed) return `${name} not installed`;
20297
+ const version2 = component.version ?? "unknown version";
20298
+ const protocol = component.protocol ? `protocol ${component.protocol.major}.${component.protocol.minor}` : "protocol unknown";
20299
+ return `${name} ${version2} (${protocol})`;
20300
+ }).join("\n");
20301
+ }
20302
+
20303
+ // src/lib/connect.ts
20304
+ import { join as join7 } from "node:path";
20305
+
20306
+ // src/lib/attached-daemon.ts
20307
+ import { randomUUID } from "node:crypto";
20308
+
20309
+ // src/lib/bootstrap.ts
20310
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
20311
+ import { dirname as dirname4, join as join5, resolve as resolve2 } from "node:path";
20312
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
20313
+ init_types();
20314
+ async function installedVersion(transport) {
20315
+ const result = await transport.exec(`readlink ${hostPaths.current}`, { timeoutMs: 15e3 });
20316
+ if (result.code !== 0) return null;
20317
+ const match = /versions\/([^/\s]+)\s*$/.exec(result.stdout.trim());
20318
+ return match?.[1] ?? null;
20319
+ }
20320
+ async function installDaemon(transport, opts = {}) {
20321
+ const logger = opts.logger ?? silentLogger;
20322
+ const version2 = opts.version ?? pinnedDaemonVersion();
20323
+ const script = readInstallScript();
20324
+ const args = ["--version", version2];
20325
+ if (opts.tarball !== void 0 && !/^https?:\/\//.test(opts.tarball)) {
20326
+ if (!existsSync2(opts.tarball)) {
20327
+ throw new CliError("not_installed", `tarball not found: ${opts.tarball}`);
20328
+ }
20329
+ if (transport.kind === "local") {
20330
+ args.push("--tarball", resolve2(opts.tarball));
20331
+ const localSums = `${opts.tarball}.sha256`;
20332
+ if (existsSync2(localSums)) args.push("--sums", resolve2(localSums));
20333
+ } else {
20334
+ const base = opts.tarball.split("/").pop() ?? "puddled.tar.gz";
20335
+ const destTarball = `.puddle/cache/${base}`;
20336
+ logger.info(`delivering ${base} to ${transport.label}`);
20337
+ await transport.copyTo(opts.tarball, destTarball);
20338
+ const sums = `${opts.tarball}.sha256`;
20339
+ if (existsSync2(sums)) {
20340
+ await transport.copyTo(sums, `${destTarball}.sha256`);
20341
+ args.push("--tarball", destTarball, "--sums", `${destTarball}.sha256`);
20342
+ } else {
20343
+ args.push("--tarball", destTarball);
20344
+ }
20345
+ }
20346
+ } else {
20347
+ const slug2 = repoSlug();
20348
+ if (opts.tarball !== void 0) {
20349
+ throw new CliError("bad_arguments", "--tarball must be a local file path");
20350
+ }
20351
+ if (slug2 === void 0) {
20352
+ throw new CliError(
20353
+ "not_installed",
20354
+ "no release source is configured for this build",
20355
+ "pass --tarball <path> (pnpm build:tarball produces one) or set PUDDLE_REPO=owner/repo"
20356
+ );
20357
+ }
20358
+ args.push("--repo", slug2);
20359
+ }
20360
+ logger.info(`installing puddled ${version2} on ${transport.label}`);
20361
+ const quoted = args.map((a) => `'${a.replaceAll("'", `'\\''`)}'`).join(" ");
20362
+ const result = await transport.exec(`sh -s -- ${quoted}`, {
20363
+ stdin: script,
20364
+ timeoutMs: 10 * 6e4,
20365
+ onStdout: (chunk) => {
20366
+ for (const line of chunk.split("\n")) if (line.trim() !== "") logger.info(line.trimEnd());
20367
+ }
20368
+ });
20369
+ if (result.code !== 0) {
20370
+ throw new CliError(
20371
+ "not_installed",
20372
+ `the installer failed on ${transport.label} (exit ${result.code})`,
20373
+ lastLines(result.stderr || result.stdout)
20374
+ );
20375
+ }
20376
+ }
20377
+ function lastLines(text, count = 3) {
20378
+ return text.trim().split("\n").slice(-count).join("\n");
20379
+ }
20380
+ function readInstallScript() {
20381
+ const here = dirname4(fileURLToPath2(import.meta.url));
20382
+ const candidates = [
20383
+ join5(here, "install.sh"),
20384
+ // dist/index.js → dist/install.sh
20385
+ join5(here, "..", "..", "..", "..", "scripts", "install.sh")
20386
+ // src/lib/ → repo root
20387
+ ];
20388
+ for (const candidate of candidates) {
20389
+ if (existsSync2(candidate)) return readFileSync3(candidate, "utf8");
20390
+ }
20391
+ throw new CliError("not_installed", "install.sh is missing from this build");
20392
+ }
20393
+
20394
+ // src/lib/net.ts
20395
+ import { connect, createServer } from "node:net";
20396
+ function findFreePort() {
20397
+ return new Promise((resolve4, reject) => {
20398
+ const srv = createServer();
20399
+ srv.once("error", reject);
20400
+ srv.listen(0, "127.0.0.1", () => {
20401
+ const address = srv.address();
20402
+ const port = typeof address === "object" && address !== null ? address.port : 0;
20403
+ srv.close(() => resolve4(port));
20404
+ });
20405
+ });
20406
+ }
20407
+ function tcpListening(port, timeoutMs = 500) {
20408
+ return new Promise((resolve4) => {
20409
+ const sock = connect({ host: "127.0.0.1", port });
20410
+ const done = (ok) => {
20411
+ sock.destroy();
20412
+ resolve4(ok);
20413
+ };
20414
+ sock.setTimeout(timeoutMs, () => done(false));
20415
+ sock.once("connect", () => done(true));
20416
+ sock.once("error", () => done(false));
20417
+ });
20418
+ }
20419
+ async function waitForTcp(port, timeoutMs) {
20420
+ const deadline = Date.now() + timeoutMs;
20421
+ while (Date.now() < deadline) {
20422
+ if (await tcpListening(port)) return true;
20423
+ await sleep(150);
20424
+ }
20425
+ return false;
20426
+ }
20427
+ async function waitForHttp(url2, timeoutMs) {
20428
+ const deadline = Date.now() + timeoutMs;
20429
+ while (Date.now() < deadline) {
20430
+ try {
20431
+ await fetch(url2, { signal: AbortSignal.timeout(2e3) });
20432
+ return true;
20433
+ } catch {
20434
+ await sleep(250);
20435
+ }
20436
+ }
20437
+ return false;
20438
+ }
20439
+ function sleep(ms) {
20440
+ return new Promise((r) => setTimeout(r, ms));
20441
+ }
20442
+
20443
+ // src/lib/daemon-client.ts
20444
+ init_types();
20445
+ var LIVE = /* @__PURE__ */ new Set(["starting", "running", "waiting_input"]);
20446
+ var DaemonClient = class {
20447
+ constructor(port, token) {
20448
+ this.port = port;
20449
+ this.token = token;
20450
+ }
20451
+ setPort(port) {
20452
+ this.port = port;
20453
+ }
20454
+ async get(path) {
20455
+ let res;
20456
+ try {
20457
+ res = await fetch(`http://127.0.0.1:${this.port}${path}`, {
20458
+ headers: { authorization: `Bearer ${this.token}` },
20459
+ signal: AbortSignal.timeout(1e4)
20460
+ });
20461
+ } catch {
20462
+ throw new CliError("daemon_unreachable", "the Puddle daemon is not reachable");
20463
+ }
20464
+ if (res.status === 401) {
20465
+ throw new CliError(
20466
+ "token_rejected",
20467
+ "the stored token was rejected by the daemon",
20468
+ "inspect ~/.puddle/token on the host \u2014 it may have been regenerated"
20469
+ );
20470
+ }
20471
+ if (!res.ok) {
20472
+ throw new CliError("daemon_unreachable", `the daemon answered ${res.status} for ${path}`);
20473
+ }
20474
+ return res.json();
20475
+ }
20476
+ version() {
20477
+ return this.get("/api/version").then((body) => versionResponseSchema.parse(body));
20478
+ }
20479
+ host() {
20480
+ return this.get("/api/host").then((body) => hostInfoSchema.parse(body));
20481
+ }
20482
+ sessions() {
20483
+ return this.get("/api/sessions").then((body) => sessionSchema.array().parse(body));
20484
+ }
20485
+ profiles() {
20486
+ return this.get("/api/profiles").then((body) => profileSchema.array().parse(body));
20487
+ }
20488
+ /** The live subset of sessions() — what an upgrade/removal interrupts. */
20489
+ async liveSessions() {
20490
+ return (await this.sessions()).filter((s) => LIVE.has(s.status));
20491
+ }
20492
+ async liveSessionCount() {
20493
+ return (await this.sessions()).filter((s) => LIVE.has(s.status)).length;
20494
+ }
20495
+ /** Whether anything daemon-shaped answers /api/version (401 counts as alive). */
20496
+ async responds() {
20497
+ try {
20498
+ const res = await fetch(`http://127.0.0.1:${this.port}/api/version`, {
20499
+ headers: { authorization: `Bearer ${this.token}` },
20500
+ signal: AbortSignal.timeout(2e3)
20501
+ });
20502
+ return res.status < 500;
20503
+ } catch {
20504
+ return false;
20505
+ }
20506
+ }
20507
+ };
20508
+ async function readDaemonPort(transport) {
20509
+ const runtime = await transport.readFile(hostPaths.runtime);
20510
+ if (runtime !== null) {
20511
+ try {
20512
+ const parsed = JSON.parse(runtime);
20513
+ if (typeof parsed.port === "number") return parsed.port;
20514
+ } catch {
20515
+ }
20516
+ }
20517
+ const raw = await transport.readFile(hostPaths.config);
20518
+ if (raw !== null) {
20519
+ try {
20520
+ const parsed = JSON.parse(raw);
20521
+ if (typeof parsed.port === "number") return parsed.port;
20522
+ } catch {
20523
+ }
20524
+ }
20525
+ return 7434;
20526
+ }
20527
+ async function readToken(transport) {
20528
+ const raw = await transport.readFile(hostPaths.token);
20529
+ const token = raw?.trim() ?? "";
20530
+ return /^[0-9a-f]{32,}$/.test(token) ? token : null;
20531
+ }
20532
+ async function waitForToken(transport, timeoutMs) {
20533
+ const deadline = Date.now() + timeoutMs;
20534
+ while (Date.now() < deadline) {
20535
+ const token = await readToken(transport);
20536
+ if (token !== null) return token;
20537
+ await sleep(500);
20538
+ }
20539
+ throw new CliError(
20540
+ "daemon_start_timeout",
20541
+ `puddled did not come up on ${transport.label}`,
20542
+ transport.kind === "ssh" ? `inspect it with: puddle logs ${transport.label}` : "inspect it with: puddle logs"
20543
+ );
20544
+ }
20545
+
20546
+ // src/lib/cockpit.ts
20547
+ init_types();
20548
+ async function ensureDaemon(transport, opts) {
20079
20549
  const logger = opts.logger ?? silentLogger;
20550
+ const startTimeoutMs = opts.startTimeoutMs ?? (opts.attachedFallback === void 0 ? 2e4 : 5e3);
20080
20551
  let bootstrapped = false;
20081
- let token = await readToken(transport);
20552
+ const token = await readToken(transport);
20082
20553
  if (token === null) {
20083
20554
  await installDaemon(transport, opts);
20084
20555
  bootstrapped = true;
20085
- token = await waitForToken(transport, 2e4);
20086
- const up = await waitForHostProbe(transport, token, 2e4);
20087
- if (up.result === "unauthorised") throw portConflict(transport, up.port);
20088
- if (up.result !== "ok") throw startTimeout(transport);
20089
- return { port: up.port, token, bootstrapped };
20556
+ return waitAfterInstall(transport, opts, logger, bootstrapped, startTimeoutMs);
20090
20557
  }
20091
- let port = await readDaemonPort(transport);
20558
+ const port = await readDaemonPort(transport);
20092
20559
  const probe = await hostProbe(transport, port, token);
20093
20560
  if (probe === "unauthorised") throw portConflict(transport, port);
20094
20561
  if (probe === "down") {
@@ -20099,13 +20566,39 @@ async function ensureDaemon(transport, opts) {
20099
20566
  }
20100
20567
  await installDaemon(transport, opts);
20101
20568
  bootstrapped = true;
20102
- token = await readToken(transport) ?? token;
20103
- const after = await waitForHostProbe(transport, token, 2e4);
20104
- if (after.result === "unauthorised") throw portConflict(transport, after.port);
20105
- if (after.result !== "ok") throw startTimeout(transport);
20106
- port = after.port;
20569
+ return waitAfterInstall(transport, opts, logger, bootstrapped, startTimeoutMs);
20570
+ }
20571
+ return { port, token, bootstrapped, daemonLifetime: "persistent" };
20572
+ }
20573
+ async function waitAfterInstall(transport, opts, logger, bootstrapped, timeoutMs) {
20574
+ try {
20575
+ const started = await waitForStartedDaemon(transport, timeoutMs);
20576
+ return { ...started, bootstrapped, daemonLifetime: "persistent" };
20577
+ } catch (err) {
20578
+ if (!(err instanceof CliError) || err.code !== "daemon_start_timeout" || opts.attachedFallback === void 0 || !await detachedNohupWasReaped(transport)) {
20579
+ throw err;
20580
+ }
20581
+ logger.warn(
20582
+ `puddled could not stay detached on ${transport.label} \u2014 keeping it attached to this cockpit; session state stays on disk and can resume on the next launch`
20583
+ );
20584
+ return opts.attachedFallback();
20107
20585
  }
20108
- return { port, token, bootstrapped };
20586
+ }
20587
+ async function detachedNohupWasReaped(transport) {
20588
+ if (transport.kind !== "ssh") return false;
20589
+ const supervisor = (await transport.readFile(hostPaths.supervisor))?.trim();
20590
+ if (supervisor !== "nohup") return false;
20591
+ const rawPid = (await transport.readFile(hostPaths.pid))?.trim() ?? "";
20592
+ if (!/^[1-9][0-9]*$/.test(rawPid)) return true;
20593
+ const probe = await transport.exec(`kill -0 ${rawPid}`, { timeoutMs: 5e3 });
20594
+ return probe.code !== 0;
20595
+ }
20596
+ async function waitForStartedDaemon(transport, timeoutMs) {
20597
+ const token = await waitForToken(transport, timeoutMs);
20598
+ const up = await waitForHostProbe(transport, token, timeoutMs);
20599
+ if (up.result === "unauthorised") throw portConflict(transport, up.port);
20600
+ if (up.result !== "ok") throw startTimeout(transport);
20601
+ return { port: up.port, token };
20109
20602
  }
20110
20603
  function startTimeout(transport) {
20111
20604
  return new CliError(
@@ -20141,9 +20634,10 @@ async function waitForHostProbe(transport, token, timeoutMs) {
20141
20634
  }
20142
20635
  return { result: last, port };
20143
20636
  }
20144
- function makeUpgrader(transport, client, opts) {
20637
+ function makeUpgrader(transport, client, opts, lease) {
20145
20638
  return async () => {
20146
20639
  await installDaemon(transport, opts);
20640
+ await lease?.ensureRunning();
20147
20641
  const deadline = Date.now() + 3e4;
20148
20642
  while (Date.now() < deadline) {
20149
20643
  if (await client.responds()) return;
@@ -20156,6 +20650,150 @@ function makeUpgrader(transport, client, opts) {
20156
20650
  };
20157
20651
  }
20158
20652
 
20653
+ // src/lib/attached-daemon.ts
20654
+ init_types();
20655
+ var AttachedDaemon = class {
20656
+ constructor(ssh, logger = silentLogger) {
20657
+ this.ssh = ssh;
20658
+ this.logger = logger;
20659
+ }
20660
+ command = null;
20661
+ commandExited = true;
20662
+ pid = null;
20663
+ port;
20664
+ leasePidPath = null;
20665
+ async start() {
20666
+ const started = await this.launchAndWait();
20667
+ return {
20668
+ ...started,
20669
+ bootstrapped: true,
20670
+ daemonLifetime: "cockpit",
20671
+ lease: this
20672
+ };
20673
+ }
20674
+ async ensureRunning() {
20675
+ if (!this.commandExited) return;
20676
+ this.logger.info(`restarting SSH-attached puddled on ${this.ssh.label}`);
20677
+ if (this.leasePidPath !== null) {
20678
+ await this.ssh.exec(`rm -f ${this.leasePidPath}`, { timeoutMs: 5e3 });
20679
+ }
20680
+ this.pid = null;
20681
+ this.leasePidPath = null;
20682
+ await this.launchAndWait(this.port);
20683
+ }
20684
+ async stop() {
20685
+ const command = this.command;
20686
+ if (command === null) return;
20687
+ const pid = this.pid;
20688
+ const leasePidPath = this.leasePidPath;
20689
+ if (pid !== null) {
20690
+ await this.ssh.exec(`kill ${pid}`, { timeoutMs: 5e3 });
20691
+ }
20692
+ const exited = await Promise.race([
20693
+ command.result.then(() => true),
20694
+ sleep(5e3).then(() => false)
20695
+ ]);
20696
+ if (!exited) command.terminate();
20697
+ if (pid !== null) {
20698
+ await this.ssh.exec(
20699
+ `if [ -f ${hostPaths.pid} ] && [ "$(cat ${hostPaths.pid})" = "${pid}" ]; then rm -f ${hostPaths.pid}; fi`,
20700
+ { timeoutMs: 5e3 }
20701
+ );
20702
+ }
20703
+ if (leasePidPath !== null) {
20704
+ await this.ssh.exec(`rm -f ${leasePidPath}`, { timeoutMs: 5e3 });
20705
+ }
20706
+ this.command = null;
20707
+ this.commandExited = true;
20708
+ this.pid = null;
20709
+ this.leasePidPath = null;
20710
+ }
20711
+ async launchAndWait(preferredPort) {
20712
+ const portFlag = preferredPort === void 0 ? "" : ` --port ${preferredPort}`;
20713
+ const leasePidPath = `${hostPaths.home}/attached-${randomUUID()}.pid`;
20714
+ this.leasePidPath = leasePidPath;
20715
+ const command = this.ssh.runAttached(
20716
+ `printf '%s\\n' "$$" > ${leasePidPath}; exec ${hostPaths.current}/puddled${portFlag} >> ${hostPaths.logs}/puddled.out.log 2>&1`
20717
+ );
20718
+ this.command = command;
20719
+ this.commandExited = false;
20720
+ void command.result.then(() => {
20721
+ if (this.command === command) {
20722
+ this.commandExited = true;
20723
+ this.pid = null;
20724
+ }
20725
+ });
20726
+ let outcome;
20727
+ try {
20728
+ outcome = await Promise.race([
20729
+ waitForStartedDaemon(this.ssh, 2e4).then((started) => ({
20730
+ kind: "started",
20731
+ started
20732
+ })),
20733
+ command.result.then((result) => ({ kind: "exited", result }))
20734
+ ]);
20735
+ } catch (err) {
20736
+ command.terminate();
20737
+ await this.clearFailedLaunch(leasePidPath);
20738
+ throw err;
20739
+ }
20740
+ if (outcome.kind === "exited") {
20741
+ await this.clearFailedLaunch(leasePidPath);
20742
+ throw await this.startFailure(outcome.result.stderr || outcome.result.stdout);
20743
+ }
20744
+ this.port = outcome.started.port;
20745
+ const [claimedPid, runtimePid] = await Promise.all([
20746
+ this.readPidFile(leasePidPath),
20747
+ this.readPidFile(hostPaths.runtime, true)
20748
+ ]);
20749
+ if (claimedPid === null || runtimePid !== claimedPid) {
20750
+ command.terminate();
20751
+ await this.clearFailedLaunch(leasePidPath);
20752
+ throw new CliError(
20753
+ "daemon_start_timeout",
20754
+ `another puddled process won the startup race on ${this.ssh.label}`,
20755
+ "close the other cockpit or wait for its daemon to stop, then launch again"
20756
+ );
20757
+ }
20758
+ this.pid = claimedPid;
20759
+ await this.ssh.exec(`printf '%s\\n' '${this.pid}' > ${hostPaths.pid}`, { timeoutMs: 5e3 });
20760
+ return outcome.started;
20761
+ }
20762
+ async readPidFile(path, json2 = false) {
20763
+ const raw = await this.ssh.readFile(path);
20764
+ if (raw === null) return null;
20765
+ try {
20766
+ const value = json2 ? JSON.parse(raw).pid : Number(raw.trim());
20767
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
20768
+ } catch {
20769
+ return null;
20770
+ }
20771
+ }
20772
+ async clearFailedLaunch(leasePidPath) {
20773
+ try {
20774
+ await this.ssh.exec(`rm -f ${leasePidPath}`, { timeoutMs: 5e3 });
20775
+ } catch {
20776
+ }
20777
+ this.command = null;
20778
+ this.commandExited = true;
20779
+ this.pid = null;
20780
+ if (this.leasePidPath === leasePidPath) this.leasePidPath = null;
20781
+ }
20782
+ async startFailure(channelOutput) {
20783
+ this.command = null;
20784
+ this.commandExited = true;
20785
+ const log = await this.ssh.exec(`tail -n 20 ${hostPaths.logs}/puddled.out.log`, {
20786
+ timeoutMs: 5e3
20787
+ });
20788
+ const detail = (log.stdout || log.stderr || channelOutput).trim().split("\n").slice(-8).join("\n");
20789
+ return new CliError(
20790
+ "daemon_start_timeout",
20791
+ `puddled exited while starting on ${this.ssh.label}`,
20792
+ detail || `inspect it with: puddle logs ${this.ssh.label}`
20793
+ );
20794
+ }
20795
+ };
20796
+
20159
20797
  // src/lib/handshake.ts
20160
20798
  init_types();
20161
20799
  var CLI_UPGRADE_COMMAND = "npm install -g @puddle-code/cli";
@@ -20201,7 +20839,7 @@ async function runHandshake(opts) {
20201
20839
 
20202
20840
  // src/lib/serve/ui-server.ts
20203
20841
  init_types();
20204
- import { randomUUID } from "node:crypto";
20842
+ import { randomUUID as randomUUID2 } from "node:crypto";
20205
20843
  import { createServer as createServer2 } from "node:http";
20206
20844
 
20207
20845
  // src/lib/serve/guard.ts
@@ -20223,12 +20861,12 @@ function isLocalOrigin(origin) {
20223
20861
  }
20224
20862
 
20225
20863
  // src/lib/serve/local-sync.ts
20226
- import { mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
20227
- import { dirname as dirname3 } from "node:path";
20864
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
20865
+ import { dirname as dirname5 } from "node:path";
20228
20866
  var MAX_BODY_BYTES = 5 * 1024 * 1024;
20229
20867
  function readStore(file2) {
20230
20868
  try {
20231
- const raw = JSON.parse(readFileSync3(file2, "utf8"));
20869
+ const raw = JSON.parse(readFileSync4(file2, "utf8"));
20232
20870
  if (raw && typeof raw === "object" && raw.profiles && typeof raw.profiles === "object") {
20233
20871
  return { version: 1, profiles: raw.profiles };
20234
20872
  }
@@ -20237,13 +20875,13 @@ function readStore(file2) {
20237
20875
  return { version: 1, profiles: {} };
20238
20876
  }
20239
20877
  function writeStore(file2, store) {
20240
- mkdirSync(dirname3(file2), { recursive: true });
20878
+ mkdirSync2(dirname5(file2), { recursive: true });
20241
20879
  const tmp = `${file2}.tmp-${process.pid}`;
20242
- writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n");
20243
- renameSync(tmp, file2);
20880
+ writeFileSync2(tmp, JSON.stringify(store, null, 2) + "\n");
20881
+ renameSync2(tmp, file2);
20244
20882
  }
20245
20883
  function readBody(req) {
20246
- return new Promise((resolve3, reject) => {
20884
+ return new Promise((resolve4, reject) => {
20247
20885
  let size = 0;
20248
20886
  const chunks = [];
20249
20887
  req.on("data", (chunk) => {
@@ -20255,7 +20893,7 @@ function readBody(req) {
20255
20893
  }
20256
20894
  chunks.push(chunk);
20257
20895
  });
20258
- req.on("end", () => resolve3(Buffer.concat(chunks).toString("utf8")));
20896
+ req.on("end", () => resolve4(Buffer.concat(chunks).toString("utf8")));
20259
20897
  req.on("error", reject);
20260
20898
  });
20261
20899
  }
@@ -20312,8 +20950,8 @@ function recoverProxiedPath(referer, requestUrl) {
20312
20950
  }
20313
20951
 
20314
20952
  // src/lib/serve/static.ts
20315
- import { createReadStream, existsSync as existsSync2, statSync } from "node:fs";
20316
- import { extname, join as join4, normalize, resolve as resolve2, sep } from "node:path";
20953
+ import { createReadStream as createReadStream2, existsSync as existsSync3, statSync } from "node:fs";
20954
+ import { extname, join as join6, normalize, resolve as resolve3, sep } from "node:path";
20317
20955
  var MIME = {
20318
20956
  ".html": "text/html; charset=utf-8",
20319
20957
  ".js": "text/javascript; charset=utf-8",
@@ -20327,7 +20965,7 @@ var MIME = {
20327
20965
  ".map": "application/json"
20328
20966
  };
20329
20967
  function createStaticHandler(rootDir) {
20330
- const root = resolve2(rootDir);
20968
+ const root = resolve3(rootDir);
20331
20969
  return (req, res) => {
20332
20970
  if (req.method !== "GET" && req.method !== "HEAD") {
20333
20971
  res.writeHead(405, { allow: "GET, HEAD" }).end();
@@ -20341,15 +20979,15 @@ function createStaticHandler(rootDir) {
20341
20979
  res.writeHead(404, { "content-type": "text/plain" }).end("not found");
20342
20980
  return;
20343
20981
  }
20344
- const requested = normalize(join4(root, decoded));
20982
+ const requested = normalize(join6(root, decoded));
20345
20983
  const candidate = requested.startsWith(root + sep) || requested === root ? requested : root;
20346
- const isFile = existsSync2(candidate) && statSync(candidate).isFile();
20984
+ const isFile = existsSync3(candidate) && statSync(candidate).isFile();
20347
20985
  if (!isFile && extname(pathname) !== "") {
20348
20986
  res.writeHead(404, { "content-type": "text/plain" }).end("not found");
20349
20987
  return;
20350
20988
  }
20351
- const file2 = isFile ? candidate : join4(root, "index.html");
20352
- if (!existsSync2(file2)) {
20989
+ const file2 = isFile ? candidate : join6(root, "index.html");
20990
+ if (!existsSync3(file2)) {
20353
20991
  res.writeHead(404, { "content-type": "text/plain" }).end("not found");
20354
20992
  return;
20355
20993
  }
@@ -20361,7 +20999,7 @@ function createStaticHandler(rootDir) {
20361
20999
  res.end();
20362
21000
  return;
20363
21001
  }
20364
- createReadStream(file2).pipe(res);
21002
+ createReadStream2(file2).pipe(res);
20365
21003
  };
20366
21004
  }
20367
21005
 
@@ -20510,7 +21148,7 @@ async function startUiServer(opts) {
20510
21148
  const target = { ...opts.target };
20511
21149
  const tracker = new ProxySocketTracker();
20512
21150
  const serveStatic = createStaticHandler(opts.assetsDir);
20513
- const nonce = randomUUID();
21151
+ const nonce = randomUUID2();
20514
21152
  const server = createServer2((req, res) => {
20515
21153
  res.setHeader("x-puddle-cockpit", nonce);
20516
21154
  const url2 = req.url ?? "/";
@@ -20566,8 +21204,8 @@ async function startUiServer(opts) {
20566
21204
  },
20567
21205
  close() {
20568
21206
  tracker.destroyAll();
20569
- return new Promise((resolve3) => {
20570
- server.close(() => resolve3());
21207
+ return new Promise((resolve4) => {
21208
+ server.close(() => resolve4());
20571
21209
  server.closeAllConnections();
20572
21210
  });
20573
21211
  }
@@ -20596,15 +21234,15 @@ async function listen(server, startPort, strict, avoidPort) {
20596
21234
  for (let probe = 0; probe < MAX_PORT_PROBES; probe += 1) {
20597
21235
  const port = startPort + probe;
20598
21236
  if (!strict && port === avoidPort) continue;
20599
- const ok = await new Promise((resolve3, rejectListen) => {
21237
+ const ok = await new Promise((resolve4, rejectListen) => {
20600
21238
  const onError = (err) => {
20601
21239
  server.off("listening", onListening);
20602
- if (err.code === "EADDRINUSE") resolve3(false);
21240
+ if (err.code === "EADDRINUSE") resolve4(false);
20603
21241
  else rejectListen(err);
20604
21242
  };
20605
21243
  const onListening = () => {
20606
21244
  server.off("error", onError);
20607
- resolve3(true);
21245
+ resolve4(true);
20608
21246
  };
20609
21247
  server.once("error", onError);
20610
21248
  server.once("listening", onListening);
@@ -20626,7 +21264,7 @@ async function listen(server, startPort, strict, avoidPort) {
20626
21264
  }
20627
21265
 
20628
21266
  // src/lib/tunnel.ts
20629
- import { spawn as spawn2 } from "node:child_process";
21267
+ import { spawn as spawn3 } from "node:child_process";
20630
21268
  init_types();
20631
21269
  var RECONNECT_INITIAL_MS = 500;
20632
21270
  var RECONNECT_MAX_MS = 1e4;
@@ -20642,7 +21280,7 @@ async function openCallbackForward(ssh, port, opts = {}) {
20642
21280
  return null;
20643
21281
  };
20644
21282
  if (await tcpListening(port)) return skip("local port busy");
20645
- const child = spawn2(
21283
+ const child = spawn3(
20646
21284
  opts.sshBinary ?? "ssh",
20647
21285
  ssh.args("-N", "-L", `${port}:127.0.0.1:${port}`, ssh.host),
20648
21286
  { stdio: ["ignore", "ignore", "inherit"], env: ssh.spawnEnv() }
@@ -20674,7 +21312,7 @@ async function openTunnel(ssh, remotePort, opts = {}) {
20674
21312
  let healthTimer = null;
20675
21313
  const spawnForward = async (port) => {
20676
21314
  if (stopping) return false;
20677
- const child = spawn2(
21315
+ const child = spawn3(
20678
21316
  sshBinary,
20679
21317
  ssh.args("-N", "-L", `${port}:127.0.0.1:${remotePort}`, ssh.host),
20680
21318
  { stdio: ["ignore", "ignore", "inherit"], env: ssh.spawnEnv() }
@@ -20797,15 +21435,15 @@ async function openTunnel(ssh, remotePort, opts = {}) {
20797
21435
  }
20798
21436
 
20799
21437
  // src/lib/transport/local.ts
20800
- import { spawn as spawn3 } from "node:child_process";
20801
- import { copyFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync4 } from "node:fs";
20802
- import { dirname as dirname4 } from "node:path";
21438
+ import { spawn as spawn4 } from "node:child_process";
21439
+ import { copyFileSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5 } from "node:fs";
21440
+ import { dirname as dirname6 } from "node:path";
20803
21441
  var LocalTransport = class {
20804
21442
  kind = "local";
20805
21443
  label = "this machine";
20806
21444
  exec(command, opts = {}) {
20807
- return new Promise((resolve3) => {
20808
- const child = spawn3("sh", ["-c", command], { stdio: ["pipe", "pipe", "pipe"] });
21445
+ return new Promise((resolve4) => {
21446
+ const child = spawn4("sh", ["-c", command], { stdio: ["pipe", "pipe", "pipe"] });
20809
21447
  let stdout = "";
20810
21448
  let stderr = "";
20811
21449
  let timer;
@@ -20821,11 +21459,11 @@ var LocalTransport = class {
20821
21459
  child.stderr.on("data", (chunk) => stderr += chunk.toString());
20822
21460
  child.on("error", (err) => {
20823
21461
  if (timer) clearTimeout(timer);
20824
- resolve3({ code: -1, stdout, stderr: stderr + String(err) });
21462
+ resolve4({ code: -1, stdout, stderr: stderr + String(err) });
20825
21463
  });
20826
21464
  child.on("close", (code) => {
20827
21465
  if (timer) clearTimeout(timer);
20828
- resolve3({ code: code ?? -1, stdout, stderr });
21466
+ resolve4({ code: code ?? -1, stdout, stderr });
20829
21467
  });
20830
21468
  if (opts.stdin !== void 0) child.stdin.end(opts.stdin);
20831
21469
  else child.stdin.end();
@@ -20833,14 +21471,14 @@ var LocalTransport = class {
20833
21471
  }
20834
21472
  readFile(path) {
20835
21473
  try {
20836
- return Promise.resolve(readFileSync4(expandHome(path), "utf8"));
21474
+ return Promise.resolve(readFileSync5(expandHome(path), "utf8"));
20837
21475
  } catch {
20838
21476
  return Promise.resolve(null);
20839
21477
  }
20840
21478
  }
20841
21479
  copyTo(localPath, destPath) {
20842
21480
  const dest = expandHome(destPath);
20843
- mkdirSync2(dirname4(dest), { recursive: true });
21481
+ mkdirSync3(dirname6(dest), { recursive: true });
20844
21482
  copyFileSync(localPath, dest);
20845
21483
  return Promise.resolve();
20846
21484
  }
@@ -20852,8 +21490,8 @@ function expandHome(path) {
20852
21490
  }
20853
21491
 
20854
21492
  // src/lib/transport/ssh.ts
20855
- import { spawn as spawn4 } from "node:child_process";
20856
- import { mkdirSync as mkdirSync3 } from "node:fs";
21493
+ import { spawn as spawn5 } from "node:child_process";
21494
+ import { mkdirSync as mkdirSync4 } from "node:fs";
20857
21495
  init_types();
20858
21496
  var SshTransport = class {
20859
21497
  constructor(host, opts = {}) {
@@ -20867,7 +21505,7 @@ var SshTransport = class {
20867
21505
  this.controlArgs = [];
20868
21506
  } else {
20869
21507
  const home = clientHome();
20870
- mkdirSync3(home, { recursive: true });
21508
+ mkdirSync4(home, { recursive: true });
20871
21509
  this.controlArgs = [
20872
21510
  "-o",
20873
21511
  "ControlMaster=auto",
@@ -20916,8 +21554,8 @@ var SshTransport = class {
20916
21554
  * live control socket behind.
20917
21555
  */
20918
21556
  open() {
20919
- return new Promise((resolve3, reject) => {
20920
- const child = spawn4(this.ssh, this.args(this.host, "true"), {
21557
+ return new Promise((resolve4, reject) => {
21558
+ const child = spawn5(this.ssh, this.args(this.host, "true"), {
20921
21559
  // A GUI has no useful inherited terminal; OpenSSH calls its askpass
20922
21560
  // helper instead. The terminal CLI keeps byte-for-byte inheritance.
20923
21561
  stdio: this.askpassProgram === void 0 ? "inherit" : "ignore",
@@ -20928,7 +21566,7 @@ var SshTransport = class {
20928
21566
  (err) => reject(new CliError("ssh_unreachable", `could not run ${this.ssh}: ${err.message}`))
20929
21567
  );
20930
21568
  child.on("close", (code) => {
20931
- if (code === 0) resolve3();
21569
+ if (code === 0) resolve4();
20932
21570
  else {
20933
21571
  reject(
20934
21572
  new CliError(
@@ -20944,13 +21582,13 @@ var SshTransport = class {
20944
21582
  /** Whether the master connection is still alive (-O check). */
20945
21583
  isAlive() {
20946
21584
  if (!this.hasControlMaster) return Promise.resolve(true);
20947
- return new Promise((resolve3) => {
20948
- const child = spawn4(this.ssh, this.args("-O", "check", this.host), {
21585
+ return new Promise((resolve4) => {
21586
+ const child = spawn5(this.ssh, this.args("-O", "check", this.host), {
20949
21587
  stdio: "ignore",
20950
21588
  env: this.spawnEnv()
20951
21589
  });
20952
- child.on("error", () => resolve3(false));
20953
- child.on("close", (code) => resolve3(code === 0));
21590
+ child.on("error", () => resolve4(false));
21591
+ child.on("close", (code) => resolve4(code === 0));
20954
21592
  });
20955
21593
  }
20956
21594
  /**
@@ -20963,19 +21601,19 @@ var SshTransport = class {
20963
21601
  */
20964
21602
  cancelForward(localPort, remotePort) {
20965
21603
  if (!this.hasControlMaster) return Promise.resolve();
20966
- return new Promise((resolve3) => {
21604
+ return new Promise((resolve4) => {
20967
21605
  const spec = `${localPort}:127.0.0.1:${remotePort}`;
20968
- const child = spawn4(this.ssh, this.args("-O", "cancel", "-L", spec, this.host), {
21606
+ const child = spawn5(this.ssh, this.args("-O", "cancel", "-L", spec, this.host), {
20969
21607
  stdio: "ignore",
20970
21608
  env: this.spawnEnv()
20971
21609
  });
20972
- child.on("error", () => resolve3());
20973
- child.on("close", () => resolve3());
21610
+ child.on("error", () => resolve4());
21611
+ child.on("close", () => resolve4());
20974
21612
  });
20975
21613
  }
20976
21614
  exec(command, opts = {}) {
20977
- return new Promise((resolve3) => {
20978
- const child = spawn4(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
21615
+ return new Promise((resolve4) => {
21616
+ const child = spawn5(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
20979
21617
  stdio: ["pipe", "pipe", "pipe"],
20980
21618
  env: this.spawnEnv()
20981
21619
  });
@@ -20994,575 +21632,358 @@ var SshTransport = class {
20994
21632
  child.stderr.on("data", (chunk) => stderr += chunk.toString());
20995
21633
  child.on("error", (err) => {
20996
21634
  if (timer) clearTimeout(timer);
20997
- resolve3({ code: -1, stdout, stderr: stderr + String(err) });
21635
+ resolve4({ code: -1, stdout, stderr: stderr + String(err) });
20998
21636
  });
20999
21637
  child.on("close", (code) => {
21000
21638
  if (timer) clearTimeout(timer);
21001
- resolve3({ code: code ?? -1, stdout, stderr });
21639
+ resolve4({ code: code ?? -1, stdout, stderr });
21002
21640
  });
21003
21641
  if (opts.stdin !== void 0) child.stdin.end(opts.stdin);
21004
21642
  else child.stdin.end();
21005
21643
  });
21006
21644
  }
21007
- async readFile(path) {
21008
- const result = await this.exec(`cat ${path}`, { timeoutMs: 15e3 });
21009
- return result.code === 0 ? result.stdout : null;
21010
- }
21011
- async copyTo(localPath, destPath) {
21012
- await this.exec(`mkdir -p $(dirname ${destPath})`, { timeoutMs: 15e3 });
21013
- await new Promise((resolve3, reject) => {
21014
- const child = spawn4(this.scp, [...this.controlArgs, localPath, `${this.host}:${destPath}`], {
21015
- stdio: ["ignore", "ignore", "inherit"],
21016
- env: this.spawnEnv()
21017
- });
21018
- child.on("error", reject);
21019
- child.on("close", (code) => {
21020
- if (code === 0) resolve3();
21021
- else reject(new CliError("ssh_unreachable", `scp to ${this.host} failed (exit ${code})`));
21022
- });
21645
+ /**
21646
+ * Run a command through a live SSH channel instead of waiting for a detached
21647
+ * supervisor to adopt it. Used by the daemon lease on hosts that reap nohup
21648
+ * children when an SSH exec channel closes.
21649
+ */
21650
+ runAttached(command) {
21651
+ const child = spawn5(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
21652
+ stdio: ["ignore", "pipe", "pipe"],
21653
+ env: this.spawnEnv()
21023
21654
  });
21024
- }
21025
- dispose() {
21026
- }
21027
- };
21028
- function shellQuote(text) {
21029
- return `'${text.replaceAll("'", `'\\''`)}'`;
21030
- }
21031
-
21032
- // src/lib/connect.ts
21033
- init_types();
21034
- var CODEX_OAUTH_PORT = 1455;
21035
- async function connectRemote(opts) {
21036
- const logger = opts.logger ?? silentLogger;
21037
- const platform = opts.platform ?? process.platform;
21038
- const ssh = new SshTransport(opts.host, {
21039
- sshBinary: opts.sshBinary,
21040
- scpBinary: opts.scpBinary,
21041
- platform,
21042
- askpassProgram: opts.sshAskpassProgram
21043
- });
21044
- if (platform === "win32") {
21045
- logger.warn(
21046
- "Windows OpenSSH cannot share a connection; you may be asked to authenticate more than once. An SSH key (ssh-copy-id) avoids the prompts."
21047
- );
21048
- }
21049
- await ssh.open();
21050
- const bootstrap = { tarball: opts.tarball, logger };
21051
- const endpoint = await ensureDaemon(ssh, bootstrap);
21052
- const remotePort = opts.remotePort ?? endpoint.port;
21053
- const tunnel = await openTunnel(ssh, remotePort, {
21054
- sshBinary: opts.sshBinary,
21055
- logger,
21056
- ready: (localPort) => waitForHttp(`http://127.0.0.1:${localPort}/api/version`, 8e3)
21057
- });
21058
- const client = new DaemonClient(tunnel.localPort, endpoint.token);
21059
- tunnel.onPortChange((port) => client.setPort(port));
21060
- const oauthForward = await openCallbackForward(ssh, CODEX_OAUTH_PORT, {
21061
- sshBinary: opts.sshBinary,
21062
- logger
21063
- });
21064
- const daemon = await runHandshake({
21065
- client,
21066
- noUpgrade: opts.noUpgrade,
21067
- upgradeDaemon: makeUpgrader(ssh, client, bootstrap),
21068
- logger
21069
- });
21070
- const avoidPort = await readDaemonPort(new LocalTransport());
21071
- const ui = await startUiServer({
21072
- assetsDir: opts.assetsDir,
21073
- port: opts.port ?? opts.preferPort,
21074
- strictPort: opts.port !== void 0,
21075
- // a preferred port stays non-strict
21076
- avoidPort,
21077
- target: { host: "127.0.0.1", port: tunnel.localPort },
21078
- ...opts.onRefreshRequest !== void 0 ? { control: { token: endpoint.token, onRefresh: opts.onRefreshRequest } } : {},
21079
- // The store lives on the CLIENT machine — every cockpit here shares it,
21080
- // whichever remote daemon each one drives.
21081
- localSync: { token: endpoint.token, file: join5(clientHome(), "local-sync.json") }
21082
- });
21083
- tunnel.onPortChange((port) => ui.setTarget({ host: "127.0.0.1", port }));
21084
- const eventCbs = /* @__PURE__ */ new Set();
21085
- tunnel.onEvent((e) => {
21086
- if (e.t === "tunnel-down") logger.warn(`tunnel to ${opts.host} lost \u2014 reconnecting\u2026`);
21087
- if (e.t === "tunnel-up") logger.info("tunnel restored");
21088
- eventCbs.forEach((cb) => cb(e));
21089
- });
21090
- return {
21091
- origin: ui.origin,
21092
- browserUrl: `${ui.origin}/?host=${encodeURIComponent(opts.host)}#token=${endpoint.token}`,
21093
- nonce: ui.nonce,
21094
- daemon,
21095
- onEvent(cb) {
21096
- eventCbs.add(cb);
21097
- return () => eventCbs.delete(cb);
21098
- },
21099
- async stop() {
21100
- await ui.close();
21101
- await oauthForward?.();
21102
- await tunnel.close();
21103
- ssh.dispose();
21104
- }
21105
- };
21106
- }
21107
-
21108
- // src/lib/logs.ts
21109
- init_types();
21110
- async function showLogs(transport, opts) {
21111
- if (opts.session !== void 0) {
21112
- const file2 = `${hostPaths.logs}/${opts.session}/${opts.term ?? "agent"}.log`;
21113
- const command2 = opts.follow ? `tail -f -c 262144 ${file2}` : `tail -c 262144 ${file2}`;
21114
- const result = await transport.exec(command2, {
21115
- onStdout: opts.write,
21116
- ...opts.follow ? {} : { timeoutMs: 3e4 }
21655
+ let stdout = "";
21656
+ let stderr = "";
21657
+ const result = new Promise((resolve4) => {
21658
+ child.stdout.on("data", (chunk) => stdout += chunk.toString());
21659
+ child.stderr.on("data", (chunk) => stderr += chunk.toString());
21660
+ child.on("error", (err) => resolve4({ code: -1, stdout, stderr: stderr + String(err) }));
21661
+ child.on("close", (code) => resolve4({ code: code ?? -1, stdout, stderr }));
21117
21662
  });
21118
- if (result.code !== 0 && !opts.follow) {
21119
- throw new CliError(
21120
- "unknown_session",
21121
- `no log at ${file2}`,
21122
- "list sessions with: puddle status \u2014 the id is the log directory name"
21123
- );
21124
- }
21125
- return;
21126
- }
21127
- const command = `if command -v journalctl >/dev/null 2>&1 && journalctl --user -u puddled -n 1 >/dev/null 2>&1; then journalctl --user -u puddled -n 200 --no-pager${opts.follow ? " -f" : ""}; elif [ -f ${hostPaths.logs}/puddled.out.log ]; then tail ${opts.follow ? "-f " : ""}-n 200 ${hostPaths.logs}/puddled.out.log ${hostPaths.logs}/puddled.err.log 2>/dev/null; else echo 'no daemon logs found \u2014 the daemon logs to its supervisor (journalctl --user -u puddled / ~/.puddle/logs)'; fi`;
21128
- await transport.exec(command, { onStdout: opts.write });
21129
- }
21130
-
21131
- // src/lib/registry.ts
21132
- import { mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
21133
- import { join as join6 } from "node:path";
21134
- function cockpitsDir(env) {
21135
- return join6(clientHome(env), "cockpits");
21136
- }
21137
- function slug(target) {
21138
- return target.replace(/[^A-Za-z0-9@._-]/g, "_");
21139
- }
21140
- function cockpitRecordPath(target) {
21141
- return join6(cockpitsDir(), `${slug(target)}.json`);
21142
- }
21143
- function cockpitLogPath(target) {
21144
- return join6(clientHome(), "logs", `cockpit-${slug(target)}.log`);
21145
- }
21146
- function writeCockpitRecord(record2) {
21147
- mkdirSync4(cockpitsDir(), { recursive: true, mode: 448 });
21148
- writeFileSync2(cockpitRecordPath(record2.target), JSON.stringify(record2, null, 2) + "\n", {
21149
- mode: 384
21150
- });
21151
- }
21152
- function readCockpitRecord(target) {
21153
- try {
21154
- return JSON.parse(readFileSync5(cockpitRecordPath(target), "utf8"));
21155
- } catch {
21156
- return null;
21157
- }
21158
- }
21159
- function removeCockpitRecord(target) {
21160
- rmSync(cockpitRecordPath(target), { force: true });
21161
- }
21162
- function listCockpitRecords() {
21163
- let names;
21164
- try {
21165
- names = readdirSync(cockpitsDir());
21166
- } catch {
21167
- return [];
21168
- }
21169
- const records = [];
21170
- for (const name of names) {
21171
- if (!name.endsWith(".json")) continue;
21172
- try {
21173
- records.push(JSON.parse(readFileSync5(join6(cockpitsDir(), name), "utf8")));
21174
- } catch {
21175
- }
21176
- }
21177
- return records.sort((a, b) => a.target.localeCompare(b.target));
21178
- }
21179
- function isPidAlive(pid) {
21180
- try {
21181
- process.kill(pid, 0);
21182
- return true;
21183
- } catch {
21184
- return false;
21663
+ return {
21664
+ result,
21665
+ terminate() {
21666
+ child.kill("SIGTERM");
21667
+ }
21668
+ };
21185
21669
  }
21186
- }
21187
- async function checkCockpit(record2) {
21188
- if (!isPidAlive(record2.pid)) return "dead";
21189
- if (record2.status === "starting") return "starting";
21190
- if (record2.status !== "ready" || record2.origin === void 0 || record2.nonce === void 0) {
21191
- return "unverified";
21670
+ async readFile(path) {
21671
+ const result = await this.exec(`cat ${path}`, { timeoutMs: 15e3 });
21672
+ return result.code === 0 ? result.stdout : null;
21192
21673
  }
21193
- try {
21194
- const res = await fetch(`${record2.origin}/`, {
21195
- method: "HEAD",
21196
- signal: AbortSignal.timeout(1500)
21674
+ async copyTo(localPath, destPath) {
21675
+ await this.exec(`mkdir -p $(dirname ${destPath})`, { timeoutMs: 15e3 });
21676
+ await new Promise((resolve4, reject) => {
21677
+ const child = spawn5(this.scp, [...this.controlArgs, localPath, `${this.host}:${destPath}`], {
21678
+ stdio: ["ignore", "ignore", "inherit"],
21679
+ env: this.spawnEnv()
21680
+ });
21681
+ child.on("error", reject);
21682
+ child.on("close", (code) => {
21683
+ if (code === 0) resolve4();
21684
+ else reject(new CliError("ssh_unreachable", `scp to ${this.host} failed (exit ${code})`));
21685
+ });
21197
21686
  });
21198
- return res.headers.get("x-puddle-cockpit") === record2.nonce ? "running" : "unverified";
21199
- } catch {
21200
- return "unverified";
21201
21687
  }
21202
- }
21203
-
21204
- // src/lib/start.ts
21205
- import { join as join7 } from "node:path";
21206
- init_types();
21207
- async function startLocal(opts) {
21208
- const logger = opts.logger ?? silentLogger;
21209
- const transport = new LocalTransport();
21210
- const bootstrap = { tarball: opts.tarball, logger };
21211
- const endpoint = await ensureDaemon(transport, bootstrap);
21212
- const client = new DaemonClient(endpoint.port, endpoint.token);
21213
- const daemon = await runHandshake({
21214
- client,
21215
- noUpgrade: opts.noUpgrade,
21216
- upgradeDaemon: makeUpgrader(transport, client, bootstrap),
21217
- logger
21218
- });
21219
- const ui = await startUiServer({
21220
- assetsDir: opts.assetsDir,
21221
- port: opts.port ?? opts.preferPort,
21222
- strictPort: opts.port !== void 0,
21223
- // a preferred port stays non-strict
21224
- avoidPort: endpoint.port,
21225
- // never squat the daemon's own port
21226
- target: { host: "127.0.0.1", port: endpoint.port },
21227
- ...opts.onRefreshRequest !== void 0 ? { control: { token: endpoint.token, onRefresh: opts.onRefreshRequest } } : {},
21228
- localSync: { token: endpoint.token, file: join7(clientHome(), "local-sync.json") }
21229
- });
21230
- const eventCbs = /* @__PURE__ */ new Set();
21231
- return {
21232
- origin: ui.origin,
21233
- browserUrl: `${ui.origin}/#token=${endpoint.token}`,
21234
- nonce: ui.nonce,
21235
- daemon,
21236
- onEvent(cb) {
21237
- eventCbs.add(cb);
21238
- return () => eventCbs.delete(cb);
21239
- },
21240
- async stop() {
21241
- await ui.close();
21242
- transport.dispose();
21243
- }
21244
- };
21245
- }
21246
-
21247
- // src/lib/status.ts
21248
- var LIVE_FIRST = {
21249
- running: 0,
21250
- waiting_input: 1,
21251
- starting: 2,
21252
- interrupted: 3,
21253
- exited: 4,
21254
- archived: 5
21688
+ dispose() {
21689
+ }
21255
21690
  };
21256
- async function statusReport(client) {
21257
- const [daemon, host, sessions] = await Promise.all([
21258
- client.version(),
21259
- client.host(),
21260
- client.sessions()
21261
- ]);
21262
- sessions.sort(
21263
- (a, b) => (LIVE_FIRST[a.status] ?? 9) - (LIVE_FIRST[b.status] ?? 9) || (b.last_activity_at ?? "").localeCompare(a.last_activity_at ?? "")
21264
- );
21265
- return { daemon, host, sessions: sessions.filter((s) => s.status !== "archived") };
21691
+ function shellQuote(text) {
21692
+ return `'${text.replaceAll("'", `'\\''`)}'`;
21266
21693
  }
21267
21694
 
21268
- // src/cli/run.ts
21269
- init_types();
21270
-
21271
- // src/cli/manage.ts
21272
- import { spawn as spawn7 } from "node:child_process";
21273
- import { existsSync as existsSync3 } from "node:fs";
21274
- import { chmod as chmod2, copyFile, mkdir as mkdir2, rename, rm as rm2 } from "node:fs/promises";
21275
- import { homedir as homedir2 } from "node:os";
21276
- import { join as join9 } from "node:path";
21277
-
21278
- // src/lib/desktop-update.ts
21279
- import { createHash } from "node:crypto";
21280
- import { spawn as spawn5 } from "node:child_process";
21281
- import { constants, createReadStream as createReadStream2, createWriteStream } from "node:fs";
21282
- import { access, chmod, mkdir, readdir, rm, writeFile } from "node:fs/promises";
21283
- import { dirname as dirname5, join as join8 } from "node:path";
21284
- import { Readable } from "node:stream";
21285
- import { pipeline } from "node:stream/promises";
21695
+ // src/lib/connect.ts
21286
21696
  init_types();
21287
- function isNewerVersion(candidate, current) {
21288
- const parse3 = (v) => {
21289
- const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
21290
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
21291
- };
21292
- const a = parse3(candidate);
21293
- const b = parse3(current);
21294
- if (a === null || b === null) return false;
21295
- for (let i = 0; i < 3; i++) {
21296
- if (a[i] !== b[i]) return a[i] > b[i];
21297
- }
21298
- return false;
21299
- }
21300
- function pickDesktopAsset(assets, platform, arch) {
21301
- const suffix = platform === "darwin" ? "-mac.zip" : platform === "linux" ? ".AppImage" : null;
21302
- if (suffix === null) return null;
21303
- return assets.find(
21304
- (a) => a.name.endsWith(suffix) && a.name.includes("-arm64") === (arch === "arm64")
21305
- ) ?? null;
21306
- }
21307
- function parseSums(text) {
21308
- const sums = /* @__PURE__ */ new Map();
21309
- for (const line of text.split("\n")) {
21310
- const match = /^([0-9a-f]{64})\s+\*?(.+?)\s*$/.exec(line.trim());
21311
- if (match) sums.set(match[2], match[1]);
21312
- }
21313
- return sums;
21314
- }
21315
- async function checkForDesktopUpdate(currentVersion, opts = {}) {
21316
- const slug2 = repoSlug();
21317
- if (slug2 === void 0) return null;
21318
- const fetchFn = opts.fetchFn ?? fetch;
21319
- const response = await fetchFn(`https://api.github.com/repos/${slug2}/releases/latest`, {
21320
- headers: { accept: "application/vnd.github+json", "user-agent": "puddle-desktop" }
21697
+ var CODEX_OAUTH_PORT = 1455;
21698
+ async function connectRemote(opts) {
21699
+ const logger = opts.logger ?? silentLogger;
21700
+ const platform = opts.platform ?? process.platform;
21701
+ const ssh = new SshTransport(opts.host, {
21702
+ sshBinary: opts.sshBinary,
21703
+ scpBinary: opts.scpBinary,
21704
+ platform,
21705
+ askpassProgram: opts.sshAskpassProgram
21321
21706
  });
21322
- if (!response.ok) {
21323
- throw new CliError("not_installed", `release lookup failed (HTTP ${response.status})`);
21324
- }
21325
- const release = await response.json();
21326
- const version2 = (release.tag_name ?? "").replace(/^v/, "");
21327
- if (!isNewerVersion(version2, currentVersion)) return null;
21328
- return updateFromRelease(version2, release.assets ?? [], opts);
21329
- }
21330
- async function desktopUpdateAt(version2, opts = {}) {
21331
- const slug2 = repoSlug();
21332
- if (slug2 === void 0) {
21333
- throw new CliError(
21334
- "not_installed",
21335
- "no release source is configured for this build",
21336
- "set PUDDLE_REPO=owner/repo"
21707
+ if (platform === "win32") {
21708
+ logger.warn(
21709
+ "Windows OpenSSH cannot share a connection; you may be asked to authenticate more than once. An SSH key (ssh-copy-id) avoids the prompts."
21337
21710
  );
21338
21711
  }
21339
- const fetchFn = opts.fetchFn ?? fetch;
21340
- const response = await fetchFn(`https://api.github.com/repos/${slug2}/releases/tags/v${version2}`, {
21341
- headers: { accept: "application/vnd.github+json", "user-agent": "puddle-desktop" }
21342
- });
21343
- if (response.status === 404) {
21344
- throw new CliError("not_installed", `no release v${version2} exists in ${slug2}`);
21345
- }
21346
- if (!response.ok) {
21347
- throw new CliError("not_installed", `release lookup failed (HTTP ${response.status})`);
21348
- }
21349
- const release = await response.json();
21350
- return updateFromRelease(version2, release.assets ?? [], opts);
21351
- }
21352
- function updateFromRelease(version2, assets, opts) {
21353
- const asset = pickDesktopAsset(
21354
- assets,
21355
- opts.platform ?? process.platform,
21356
- opts.arch ?? process.arch
21357
- );
21358
- const sums = assets.find((a) => a.name === "SHA256SUMS");
21359
- if (asset === null || sums === void 0) return null;
21360
- return {
21361
- version: version2,
21362
- asset: { name: asset.name, url: asset.browser_download_url },
21363
- sumsUrl: sums.browser_download_url
21364
- };
21365
- }
21366
- async function stageDesktopUpdate(update, opts = {}) {
21367
- const logger = opts.logger ?? silentLogger;
21368
- const fetchFn = opts.fetchFn ?? fetch;
21369
- const dir = join8(opts.cacheDir ?? join8(clientHome(), "cache", "desktop"), update.version);
21370
- await rm(dir, { recursive: true, force: true });
21371
- await mkdir(dir, { recursive: true });
21712
+ await ssh.open();
21713
+ let tunnelResource;
21714
+ let oauthForwardResource;
21715
+ let uiResource;
21716
+ let endpointResource;
21372
21717
  try {
21373
- logger.info(`downloading ${update.asset.name}`);
21374
- const archive = join8(dir, update.asset.name);
21375
- const response = await fetchFn(update.asset.url, {
21376
- headers: { "user-agent": "puddle-desktop" }
21718
+ const bootstrap = { tarball: opts.tarball, logger };
21719
+ const attachedDaemon = new AttachedDaemon(ssh, logger);
21720
+ const endpoint = await ensureDaemon(ssh, {
21721
+ ...bootstrap,
21722
+ attachedFallback: () => attachedDaemon.start()
21377
21723
  });
21378
- if (!response.ok || response.body === null) {
21379
- throw new CliError("not_installed", `download failed (HTTP ${response.status})`);
21380
- }
21381
- await pipeline(
21382
- Readable.fromWeb(response.body),
21383
- createWriteStream(archive)
21384
- );
21385
- const sumsResponse = await fetchFn(update.sumsUrl, {
21386
- headers: { "user-agent": "puddle-desktop" }
21724
+ endpointResource = endpoint;
21725
+ const remotePort = opts.remotePort ?? endpoint.port;
21726
+ const tunnel = await openTunnel(ssh, remotePort, {
21727
+ sshBinary: opts.sshBinary,
21728
+ logger,
21729
+ ready: async (localPort) => {
21730
+ await endpoint.lease?.ensureRunning();
21731
+ return waitForHttp(`http://127.0.0.1:${localPort}/api/version`, 8e3);
21732
+ }
21387
21733
  });
21388
- if (!sumsResponse.ok) {
21389
- throw new CliError("not_installed", `SHA256SUMS fetch failed (HTTP ${sumsResponse.status})`);
21734
+ tunnelResource = tunnel;
21735
+ const client = new DaemonClient(tunnel.localPort, endpoint.token);
21736
+ tunnel.onPortChange((port) => client.setPort(port));
21737
+ const oauthForward = await openCallbackForward(ssh, CODEX_OAUTH_PORT, {
21738
+ sshBinary: opts.sshBinary,
21739
+ logger
21740
+ });
21741
+ oauthForwardResource = oauthForward;
21742
+ const daemon = await runHandshake({
21743
+ client,
21744
+ noUpgrade: opts.noUpgrade,
21745
+ upgradeDaemon: makeUpgrader(ssh, client, bootstrap, endpoint.lease),
21746
+ logger
21747
+ });
21748
+ const avoidPort = await readDaemonPort(new LocalTransport());
21749
+ const ui = await startUiServer({
21750
+ assetsDir: opts.assetsDir,
21751
+ port: opts.port ?? opts.preferPort,
21752
+ strictPort: opts.port !== void 0,
21753
+ // a preferred port stays non-strict
21754
+ avoidPort,
21755
+ target: { host: "127.0.0.1", port: tunnel.localPort },
21756
+ ...opts.onRefreshRequest !== void 0 ? { control: { token: endpoint.token, onRefresh: opts.onRefreshRequest } } : {},
21757
+ // The store lives on the CLIENT machine — every cockpit here shares it,
21758
+ // whichever remote daemon each one drives.
21759
+ localSync: { token: endpoint.token, file: join7(clientHome(), "local-sync.json") }
21760
+ });
21761
+ uiResource = ui;
21762
+ tunnel.onPortChange((port) => ui.setTarget({ host: "127.0.0.1", port }));
21763
+ const eventCbs = /* @__PURE__ */ new Set();
21764
+ tunnel.onEvent((e) => {
21765
+ if (e.t === "tunnel-down") logger.warn(`tunnel to ${opts.host} lost \u2014 reconnecting\u2026`);
21766
+ if (e.t === "tunnel-up") logger.info("tunnel restored");
21767
+ eventCbs.forEach((cb) => cb(e));
21768
+ });
21769
+ return {
21770
+ origin: ui.origin,
21771
+ browserUrl: `${ui.origin}/?host=${encodeURIComponent(opts.host)}#token=${endpoint.token}`,
21772
+ nonce: ui.nonce,
21773
+ daemon,
21774
+ daemonLifetime: endpoint.daemonLifetime,
21775
+ onEvent(cb) {
21776
+ eventCbs.add(cb);
21777
+ return () => eventCbs.delete(cb);
21778
+ },
21779
+ async stop() {
21780
+ await closeRemoteResources(ui, oauthForward, endpoint, tunnel, ssh);
21781
+ }
21782
+ };
21783
+ } catch (err) {
21784
+ try {
21785
+ await closeRemoteResources(
21786
+ uiResource,
21787
+ oauthForwardResource,
21788
+ endpointResource,
21789
+ tunnelResource,
21790
+ ssh
21791
+ );
21792
+ } catch {
21390
21793
  }
21391
- const expected = parseSums(await sumsResponse.text()).get(update.asset.name);
21392
- if (expected === void 0) {
21393
- throw new CliError("not_installed", `${update.asset.name} is not in SHA256SUMS`);
21794
+ throw err;
21795
+ }
21796
+ }
21797
+ async function closeRemoteResources(ui, oauthForward, endpoint, tunnel, ssh) {
21798
+ const actions = [
21799
+ () => ui?.close(),
21800
+ () => oauthForward?.(),
21801
+ () => endpoint?.lease?.stop(),
21802
+ () => tunnel?.close(),
21803
+ () => ssh.dispose()
21804
+ ];
21805
+ let firstError;
21806
+ for (const action of actions) {
21807
+ try {
21808
+ await action();
21809
+ } catch (err) {
21810
+ if (firstError === void 0) firstError = err;
21394
21811
  }
21395
- const actual = await fileSha256(archive);
21396
- if (actual !== expected) {
21812
+ }
21813
+ if (firstError !== void 0) throw firstError;
21814
+ }
21815
+
21816
+ // src/lib/logs.ts
21817
+ init_types();
21818
+ async function showLogs(transport, opts) {
21819
+ if (opts.session !== void 0) {
21820
+ const file2 = `${hostPaths.logs}/${opts.session}/${opts.term ?? "agent"}.log`;
21821
+ const command2 = opts.follow ? `tail -f -c 262144 ${file2}` : `tail -c 262144 ${file2}`;
21822
+ const result = await transport.exec(command2, {
21823
+ onStdout: opts.write,
21824
+ ...opts.follow ? {} : { timeoutMs: 3e4 }
21825
+ });
21826
+ if (result.code !== 0 && !opts.follow) {
21397
21827
  throw new CliError(
21398
- "not_installed",
21399
- `checksum mismatch for ${update.asset.name}`,
21400
- `expected ${expected}, got ${actual}`
21828
+ "unknown_session",
21829
+ `no log at ${file2}`,
21830
+ "list sessions with: puddle status \u2014 the id is the log directory name"
21401
21831
  );
21402
21832
  }
21403
- if (update.asset.name.endsWith(".zip")) {
21404
- const extractDir = join8(dir, "extract");
21405
- await run("/usr/bin/ditto", ["-x", "-k", archive, extractDir]);
21406
- const bundle = (await readdir(extractDir)).find((name) => name.endsWith(".app"));
21407
- if (bundle === void 0) {
21408
- throw new CliError("not_installed", "the update zip contains no .app bundle");
21409
- }
21410
- logger.info(`staged ${update.version} at ${join8(extractDir, bundle)}`);
21411
- return {
21412
- version: update.version,
21413
- kind: "mac-app",
21414
- stagedPath: join8(extractDir, bundle),
21415
- dir
21416
- };
21417
- }
21418
- await chmod(archive, 493);
21419
- logger.info(`staged ${update.version} at ${archive}`);
21420
- return { version: update.version, kind: "appimage", stagedPath: archive, dir };
21421
- } catch (e) {
21422
- await rm(dir, { recursive: true, force: true });
21423
- throw e;
21833
+ return;
21424
21834
  }
21835
+ const command = `if command -v journalctl >/dev/null 2>&1 && journalctl --user -u puddled -n 1 >/dev/null 2>&1; then journalctl --user -u puddled -n 200 --no-pager${opts.follow ? " -f" : ""}; elif [ -f ${hostPaths.logs}/puddled.out.log ]; then tail ${opts.follow ? "-f " : ""}-n 200 ${hostPaths.logs}/puddled.out.log ${hostPaths.logs}/puddled.err.log 2>/dev/null; else echo 'no daemon logs found \u2014 the daemon logs to its supervisor (journalctl --user -u puddled / ~/.puddle/logs)'; fi`;
21836
+ await transport.exec(command, { onStdout: opts.write });
21425
21837
  }
21426
- async function pruneDesktopUpdateCache(keep = [], opts = {}) {
21427
- const dir = opts.cacheDir ?? join8(clientHome(), "cache", "desktop");
21428
- let entries;
21838
+
21839
+ // src/lib/registry.ts
21840
+ import { mkdirSync as mkdirSync5, readdirSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
21841
+ import { join as join8 } from "node:path";
21842
+ function cockpitsDir(env) {
21843
+ return join8(clientHome(env), "cockpits");
21844
+ }
21845
+ function slug(target) {
21846
+ return target.replace(/[^A-Za-z0-9@._-]/g, "_");
21847
+ }
21848
+ function cockpitRecordPath(target) {
21849
+ return join8(cockpitsDir(), `${slug(target)}.json`);
21850
+ }
21851
+ function cockpitLogPath(target) {
21852
+ return join8(clientHome(), "logs", `cockpit-${slug(target)}.log`);
21853
+ }
21854
+ function writeCockpitRecord(record2) {
21855
+ mkdirSync5(cockpitsDir(), { recursive: true, mode: 448 });
21856
+ writeFileSync3(cockpitRecordPath(record2.target), JSON.stringify(record2, null, 2) + "\n", {
21857
+ mode: 384
21858
+ });
21859
+ }
21860
+ function readCockpitRecord(target) {
21429
21861
  try {
21430
- entries = await readdir(dir);
21862
+ return JSON.parse(readFileSync6(cockpitRecordPath(target), "utf8"));
21431
21863
  } catch {
21432
- return;
21864
+ return null;
21433
21865
  }
21434
- await Promise.all(
21435
- entries.filter((name) => !keep.includes(name)).map((name) => rm(join8(dir, name), { recursive: true, force: true }).catch(() => {
21436
- }))
21437
- );
21438
21866
  }
21439
- async function applyDesktopUpdate(staged, opts) {
21440
- const logger = opts.logger ?? silentLogger;
21441
- const script = swapScript(staged, opts);
21442
- const scriptPath = join8(staged.dir, "apply.sh");
21443
- await writeFile(scriptPath, script, { mode: 493 });
21444
- logger.info(`applying ${staged.version} via ${scriptPath}`);
21445
- if (opts.detach ?? true) {
21446
- const child = spawn5("/bin/sh", [scriptPath], { detached: true, stdio: "ignore" });
21447
- child.unref();
21448
- return;
21449
- }
21450
- await run("/bin/sh", [scriptPath]);
21867
+ function removeCockpitRecord(target) {
21868
+ rmSync(cockpitRecordPath(target), { force: true });
21451
21869
  }
21452
- var q = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
21453
- function swapScript(staged, opts) {
21454
- const wait = opts.waitPid === void 0 ? [] : [
21455
- `i=0`,
21456
- `while kill -0 ${opts.waitPid} 2>/dev/null; do`,
21457
- ` i=$((i+1)); [ "$i" -gt 600 ] && exit 1`,
21458
- ` sleep 0.1`,
21459
- `done`
21460
- ];
21461
- if (staged.kind === "mac-app") {
21462
- return [
21463
- `#!/bin/sh`,
21464
- `# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
21465
- ...wait,
21466
- `target=${q(opts.targetPath)}`,
21467
- `target_dir=${q(dirname5(opts.targetPath))}`,
21468
- `staged=${q(staged.stagedPath)}`,
21469
- `mkdir -p "$target_dir" || exit 1`,
21470
- `rm -rf "$target.old"`,
21471
- `had_old=0`,
21472
- `if [ -e "$target" ]; then`,
21473
- ` mv "$target" "$target.old" || exit 1`,
21474
- ` had_old=1`,
21475
- `fi`,
21476
- `if mv "$staged" "$target" 2>/dev/null || /usr/bin/ditto "$staged" "$target"; then`,
21477
- // Defence in depth: the normal path never quarantines (node's fetch is
21478
- // not a quarantine-opted-in app, and ditto only propagates what the
21479
- // zip already carries), but if the bundle ever acquires the attribute
21480
- // the swap must not resurrect the Gatekeeper prompt.
21481
- ` /usr/bin/xattr -dr com.apple.quarantine "$target" 2>/dev/null`,
21482
- ` rm -rf "$target.old" ${q(staged.dir)}`,
21483
- ...opts.relaunch ? [` open "$target"`] : [],
21484
- `else`,
21485
- ` rm -rf "$target"`,
21486
- ` [ "$had_old" -eq 0 ] || mv "$target.old" "$target"`,
21487
- ` exit 1`,
21488
- `fi`,
21489
- ``
21490
- ].join("\n");
21870
+ function listCockpitRecords() {
21871
+ let names;
21872
+ try {
21873
+ names = readdirSync(cockpitsDir());
21874
+ } catch {
21875
+ return [];
21491
21876
  }
21492
- return [
21493
- `#!/bin/sh`,
21494
- `# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
21495
- ...wait,
21496
- `target=${q(opts.targetPath)}`,
21497
- `cp -f ${q(staged.stagedPath)} "$target" || exit 1`,
21498
- `chmod +x "$target"`,
21499
- `rm -rf ${q(staged.dir)}`,
21500
- ...opts.relaunch ? [`"$target" >/dev/null 2>&1 &`] : [],
21501
- ``
21502
- ].join("\n");
21503
- }
21504
- async function findInstalledDesktopApp(opts = {}) {
21505
- if ((opts.platform ?? process.platform) !== "darwin") return null;
21506
- const { readFile } = await import("node:fs/promises");
21507
- const { homedir: homedir3 } = await import("node:os");
21508
- const systemApplications = opts.systemApplicationsDir ?? "/Applications";
21509
- const home = opts.homeDir ?? homedir3();
21510
- for (const appPath of [
21511
- join8(systemApplications, "Puddle.app"),
21512
- join8(home, "Applications", "Puddle.app")
21513
- ]) {
21877
+ const records = [];
21878
+ for (const name of names) {
21879
+ if (!name.endsWith(".json")) continue;
21514
21880
  try {
21515
- const plist = await readFile(join8(appPath, "Contents/Info.plist"), "utf8");
21516
- const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
21517
- plist
21518
- );
21519
- if (match) return { appPath, version: match[1] };
21881
+ records.push(JSON.parse(readFileSync6(join8(cockpitsDir(), name), "utf8")));
21520
21882
  } catch {
21521
21883
  }
21522
21884
  }
21523
- return null;
21885
+ return records.sort((a, b) => a.target.localeCompare(b.target));
21524
21886
  }
21525
- async function desktopAppInstallPath(opts = {}) {
21526
- if ((opts.platform ?? process.platform) !== "darwin") return null;
21527
- const { homedir: homedir3 } = await import("node:os");
21528
- const systemApplications = opts.systemApplicationsDir ?? "/Applications";
21529
- const canWrite = opts.canWrite ?? (async (path) => {
21530
- try {
21531
- await access(path, constants.W_OK);
21532
- return true;
21533
- } catch {
21534
- return false;
21535
- }
21536
- });
21537
- if (await canWrite(systemApplications)) return join8(systemApplications, "Puddle.app");
21538
- const userApplications = join8(opts.homeDir ?? homedir3(), "Applications");
21539
- await mkdir(userApplications, { recursive: true });
21540
- return join8(userApplications, "Puddle.app");
21887
+ function isPidAlive(pid) {
21888
+ try {
21889
+ process.kill(pid, 0);
21890
+ return true;
21891
+ } catch {
21892
+ return false;
21893
+ }
21541
21894
  }
21542
- function isDesktopAppRunning(appPath) {
21543
- return new Promise((resolve3) => {
21544
- const child = spawn5("/usr/bin/pgrep", ["-f", `${appPath}/Contents/MacOS/`], {
21545
- stdio: "ignore"
21895
+ async function checkCockpit(record2) {
21896
+ if (!isPidAlive(record2.pid)) return "dead";
21897
+ if (record2.status === "starting") return "starting";
21898
+ if (record2.status !== "ready" || record2.origin === void 0 || record2.nonce === void 0) {
21899
+ return "unverified";
21900
+ }
21901
+ try {
21902
+ const res = await fetch(`${record2.origin}/`, {
21903
+ method: "HEAD",
21904
+ signal: AbortSignal.timeout(1500)
21546
21905
  });
21547
- child.on("error", () => resolve3(false));
21548
- child.on("exit", (code) => resolve3(code === 0));
21549
- });
21550
- }
21551
- function fileSha256(path) {
21552
- const hash2 = createHash("sha256");
21553
- return pipeline(createReadStream2(path), hash2).then(() => hash2.digest("hex"));
21906
+ return res.headers.get("x-puddle-cockpit") === record2.nonce ? "running" : "unverified";
21907
+ } catch {
21908
+ return "unverified";
21909
+ }
21554
21910
  }
21555
- function run(command, args) {
21556
- return new Promise((resolve3, reject) => {
21557
- const child = spawn5(command, args, { stdio: "ignore" });
21558
- child.on("error", reject);
21559
- child.on(
21560
- "exit",
21561
- (code) => code === 0 ? resolve3() : reject(new CliError("not_installed", `${command} exited with ${code ?? "signal"}`))
21562
- );
21911
+
21912
+ // src/lib/start.ts
21913
+ import { join as join9 } from "node:path";
21914
+ init_types();
21915
+ async function startLocal(opts) {
21916
+ const logger = opts.logger ?? silentLogger;
21917
+ const transport = new LocalTransport();
21918
+ const bootstrap = { tarball: opts.tarball, logger };
21919
+ const endpoint = await ensureDaemon(transport, bootstrap);
21920
+ const client = new DaemonClient(endpoint.port, endpoint.token);
21921
+ const daemon = await runHandshake({
21922
+ client,
21923
+ noUpgrade: opts.noUpgrade,
21924
+ upgradeDaemon: makeUpgrader(transport, client, bootstrap),
21925
+ logger
21926
+ });
21927
+ const ui = await startUiServer({
21928
+ assetsDir: opts.assetsDir,
21929
+ port: opts.port ?? opts.preferPort,
21930
+ strictPort: opts.port !== void 0,
21931
+ // a preferred port stays non-strict
21932
+ avoidPort: endpoint.port,
21933
+ // never squat the daemon's own port
21934
+ target: { host: "127.0.0.1", port: endpoint.port },
21935
+ ...opts.onRefreshRequest !== void 0 ? { control: { token: endpoint.token, onRefresh: opts.onRefreshRequest } } : {},
21936
+ localSync: { token: endpoint.token, file: join9(clientHome(), "local-sync.json") }
21563
21937
  });
21938
+ const eventCbs = /* @__PURE__ */ new Set();
21939
+ return {
21940
+ origin: ui.origin,
21941
+ browserUrl: `${ui.origin}/#token=${endpoint.token}`,
21942
+ nonce: ui.nonce,
21943
+ daemon,
21944
+ daemonLifetime: "persistent",
21945
+ onEvent(cb) {
21946
+ eventCbs.add(cb);
21947
+ return () => eventCbs.delete(cb);
21948
+ },
21949
+ async stop() {
21950
+ await ui.close();
21951
+ transport.dispose();
21952
+ }
21953
+ };
21954
+ }
21955
+
21956
+ // src/lib/status.ts
21957
+ var LIVE_FIRST = {
21958
+ running: 0,
21959
+ waiting_input: 1,
21960
+ starting: 2,
21961
+ interrupted: 3,
21962
+ exited: 4,
21963
+ archived: 5
21964
+ };
21965
+ async function statusReport(client) {
21966
+ const [daemon, host, sessions] = await Promise.all([
21967
+ client.version(),
21968
+ client.host(),
21969
+ client.sessions()
21970
+ ]);
21971
+ sessions.sort(
21972
+ (a, b) => (LIVE_FIRST[a.status] ?? 9) - (LIVE_FIRST[b.status] ?? 9) || (b.last_activity_at ?? "").localeCompare(a.last_activity_at ?? "")
21973
+ );
21974
+ return { daemon, host, sessions: sessions.filter((s) => s.status !== "archived") };
21564
21975
  }
21565
21976
 
21977
+ // src/cli/run.ts
21978
+ init_types();
21979
+
21980
+ // src/cli/manage.ts
21981
+ import { spawn as spawn7 } from "node:child_process";
21982
+ import { existsSync as existsSync4 } from "node:fs";
21983
+ import { chmod as chmod2, copyFile, mkdir as mkdir2, rename, rm as rm2 } from "node:fs/promises";
21984
+ import { homedir as homedir2 } from "node:os";
21985
+ import { join as join10 } from "node:path";
21986
+
21566
21987
  // src/lib/releases.ts
21567
21988
  init_types();
21568
21989
  async function latestReleaseVersion(opts = {}) {
@@ -21644,7 +22065,7 @@ if [ "\${PUDDLE_PURGE:-0}" = 1 ]; then
21644
22065
  rm -rf "$HOME_DIR"
21645
22066
  say "deleted $HOME_DIR"
21646
22067
  else
21647
- rm -rf "$HOME_DIR/bin" "$HOME_DIR/cache" "$HOME_DIR/runtime.json"
22068
+ rm -rf "$HOME_DIR/bin" "$HOME_DIR/cache" "$HOME_DIR/runtime.json" "$HOME_DIR/supervisor"
21648
22069
  say "removed the install; data kept under $HOME_DIR"
21649
22070
  fi`;
21650
22071
  const result = await transport.exec(`PUDDLE_PURGE=${opts.purge ? 1 : 0} sh -s`, {
@@ -21686,8 +22107,8 @@ async function upgradeDaemon(transport, opts = {}) {
21686
22107
 
21687
22108
  // src/cli/detach.ts
21688
22109
  import { spawn as spawn6 } from "node:child_process";
21689
- import { closeSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync6 } from "node:fs";
21690
- import { dirname as dirname6 } from "node:path";
22110
+ import { closeSync, mkdirSync as mkdirSync6, openSync, readFileSync as readFileSync7 } from "node:fs";
22111
+ import { dirname as dirname7 } from "node:path";
21691
22112
  init_types();
21692
22113
  var COCKPIT_CHILD_ENV = "PUDDLE_COCKPIT_CHILD";
21693
22114
  var READY_TIMEOUT_MS = 3e5;
@@ -21728,7 +22149,7 @@ async function launchDetached(opts) {
21728
22149
  }
21729
22150
  if (target !== "local") await new SshTransport(target).open();
21730
22151
  const logFile = cockpitLogPath(target);
21731
- mkdirSync5(dirname6(logFile), { recursive: true });
22152
+ mkdirSync6(dirname7(logFile), { recursive: true });
21732
22153
  const fd = openSync(logFile, "w");
21733
22154
  const argv = opts.argv ?? process.argv.slice(2);
21734
22155
  const child = spawn6(process.execPath, [process.argv[1] ?? "", ...argv], {
@@ -21768,7 +22189,7 @@ async function followStartup(target, childPid, logFile) {
21768
22189
  let offset = 0;
21769
22190
  const relay = () => {
21770
22191
  try {
21771
- const buf = readFileSync6(logFile);
22192
+ const buf = readFileSync7(logFile);
21772
22193
  if (buf.length > offset) {
21773
22194
  process.stderr.write(buf.subarray(offset));
21774
22195
  offset = buf.length;
@@ -21793,7 +22214,7 @@ async function followStartup(target, childPid, logFile) {
21793
22214
  }
21794
22215
  function spawnDetachedRefresh(target, argv) {
21795
22216
  const logFile = cockpitLogPath(target);
21796
- mkdirSync5(dirname6(logFile), { recursive: true });
22217
+ mkdirSync6(dirname7(logFile), { recursive: true });
21797
22218
  const fd = openSync(logFile, "a");
21798
22219
  const env = { ...process.env };
21799
22220
  delete env[COCKPIT_CHILD_ENV];
@@ -21810,7 +22231,7 @@ async function terminateCockpit(record2) {
21810
22231
  process.kill(record2.pid, "SIGTERM");
21811
22232
  } catch {
21812
22233
  }
21813
- const deadline = Date.now() + 5e3;
22234
+ const deadline = Date.now() + 1e4;
21814
22235
  while (Date.now() < deadline && isPidAlive(record2.pid)) await sleep(100);
21815
22236
  if (isPidAlive(record2.pid)) {
21816
22237
  try {
@@ -21830,7 +22251,7 @@ async function ask(question, def) {
21830
22251
  const rl = createInterface({ input: process.stdin, output: process.stdout });
21831
22252
  try {
21832
22253
  const answer = await new Promise(
21833
- (resolve3) => rl.question(`${question} [${def}] `, resolve3)
22254
+ (resolve4) => rl.question(`${question} [${def}] `, resolve4)
21834
22255
  );
21835
22256
  return answer.trim() === "" ? def : answer.trim();
21836
22257
  } finally {
@@ -21849,7 +22270,7 @@ async function confirm(question, opts = {}) {
21849
22270
  const rl = createInterface({ input: process.stdin, output: process.stdout });
21850
22271
  try {
21851
22272
  const answer = await new Promise(
21852
- (resolve3) => rl.question(`${question} [y/N] `, resolve3)
22273
+ (resolve4) => rl.question(`${question} [y/N] `, resolve4)
21853
22274
  );
21854
22275
  return /^y(es)?$/i.test(answer.trim());
21855
22276
  } finally {
@@ -21968,7 +22389,7 @@ async function runUpgrade(cmd, logger) {
21968
22389
  function upgradeCli(version2, logger) {
21969
22390
  const spec = `@puddle-code/cli@${version2 ?? "latest"}`;
21970
22391
  logger.info(`puddle CLI ${cliVersion()} \u2014 asking npm for ${version2 ?? "the latest release"}`);
21971
- return new Promise((resolve3, reject) => {
22392
+ return new Promise((resolve4, reject) => {
21972
22393
  const child = spawn7("npm", ["install", "-g", spec], { stdio: "inherit" });
21973
22394
  child.on(
21974
22395
  "error",
@@ -21983,7 +22404,7 @@ function upgradeCli(version2, logger) {
21983
22404
  child.on("exit", (code) => {
21984
22405
  if (code === 0) {
21985
22406
  logger.info("done \u2014 `puddle --version` shows the installed version");
21986
- resolve3(0);
22407
+ resolve4(0);
21987
22408
  } else {
21988
22409
  reject(new CliError("not_installed", `npm install exited with ${code ?? "a signal"}`));
21989
22410
  }
@@ -22043,6 +22464,12 @@ async function setDesktop(opts, logger) {
22043
22464
  logger.info(move);
22044
22465
  const staged = await stageDesktopUpdate(update, { logger });
22045
22466
  await applyDesktopUpdate(staged, { targetPath, detach: false, relaunch: false, logger });
22467
+ const protocol = componentProtocolForVersion(update.version);
22468
+ recordDesktopInstallation({
22469
+ path: targetPath,
22470
+ version: update.version,
22471
+ ...protocol ? { protocol } : {}
22472
+ });
22046
22473
  logger.info(`Puddle ${update.version} installed at ${targetPath}`);
22047
22474
  return 0;
22048
22475
  }
@@ -22055,8 +22482,8 @@ async function installDesktopAppImage(version2, logger) {
22055
22482
  );
22056
22483
  }
22057
22484
  const dir = expandHome2(await ask("Where should the AppImage be stored?", "~/puddle"));
22058
- const target = join9(dir, "Puddle.AppImage");
22059
- if (existsSync3(target) && version2 === void 0) {
22485
+ const target = join10(dir, "Puddle.AppImage");
22486
+ if (existsSync4(target) && version2 === void 0) {
22060
22487
  logger.info(`${target} already exists \u2014 the app updates itself in-app (its update toast)`);
22061
22488
  return 0;
22062
22489
  }
@@ -22071,6 +22498,12 @@ async function installDesktopAppImage(version2, logger) {
22071
22498
  await chmod2(target, 493);
22072
22499
  }
22073
22500
  await rm2(staged.dir, { recursive: true, force: true });
22501
+ const protocol = componentProtocolForVersion(update.version);
22502
+ recordDesktopInstallation({
22503
+ path: target,
22504
+ version: update.version,
22505
+ ...protocol ? { protocol } : {}
22506
+ });
22074
22507
  logger.info(`installed \u2014 run it directly (${target}) or add it to your launcher`);
22075
22508
  const opener = spawn7("xdg-open", [dir], { stdio: "ignore", detached: true });
22076
22509
  opener.on("error", () => void 0);
@@ -22079,7 +22512,7 @@ async function installDesktopAppImage(version2, logger) {
22079
22512
  }
22080
22513
  function expandHome2(path) {
22081
22514
  if (path === "~") return homedir2();
22082
- return path.startsWith("~/") ? join9(homedir2(), path.slice(2)) : path;
22515
+ return path.startsWith("~/") ? join10(homedir2(), path.slice(2)) : path;
22083
22516
  }
22084
22517
  async function runRemove(cmd, logger) {
22085
22518
  switch (cmd.what) {
@@ -22094,10 +22527,10 @@ async function runRemove(cmd, logger) {
22094
22527
  }
22095
22528
  }
22096
22529
  async function removeCli(yes, logger) {
22097
- const managed = await new Promise((resolve3) => {
22530
+ const managed = await new Promise((resolve4) => {
22098
22531
  const child = spawn7("npm", ["ls", "-g", "@puddle-code/cli", "--depth=0"], { stdio: "ignore" });
22099
- child.on("error", () => resolve3(false));
22100
- child.on("exit", (code) => resolve3(code === 0));
22532
+ child.on("error", () => resolve4(false));
22533
+ child.on("exit", (code) => resolve4(code === 0));
22101
22534
  });
22102
22535
  if (!managed) {
22103
22536
  throw new CliError(
@@ -22114,13 +22547,13 @@ async function removeCli(yes, logger) {
22114
22547
  logger.info("nothing removed");
22115
22548
  return 0;
22116
22549
  }
22117
- return new Promise((resolve3, reject) => {
22550
+ return new Promise((resolve4, reject) => {
22118
22551
  const child = spawn7("npm", ["uninstall", "-g", "@puddle-code/cli"], { stdio: "inherit" });
22119
22552
  child.on("error", () => reject(new CliError("not_installed", "npm is not on PATH")));
22120
22553
  child.on("exit", (code) => {
22121
22554
  if (code === 0) {
22122
22555
  logger.info("removed \u2014 daemons keep running (puddle remove daemon uninstalls one)");
22123
- resolve3(0);
22556
+ resolve4(0);
22124
22557
  } else {
22125
22558
  reject(new CliError("not_installed", `npm uninstall exited with ${code ?? "a signal"}`));
22126
22559
  }
@@ -22271,9 +22704,9 @@ function timestampedLogger() {
22271
22704
 
22272
22705
  // src/cli/run.ts
22273
22706
  function assetsDir() {
22274
- const here = dirname7(fileURLToPath3(import.meta.url));
22275
- for (const candidate of [join10(here, "public"), join10(here, "..", "..", "dist", "public")]) {
22276
- if (existsSync4(join10(candidate, "index.html"))) return candidate;
22707
+ const here = dirname8(fileURLToPath3(import.meta.url));
22708
+ for (const candidate of [join11(here, "public"), join11(here, "..", "..", "dist", "public")]) {
22709
+ if (existsSync5(join11(candidate, "index.html"))) return candidate;
22277
22710
  }
22278
22711
  throw new CliError(
22279
22712
  "not_installed",
@@ -22313,7 +22746,7 @@ async function openTarget(host) {
22313
22746
  };
22314
22747
  }
22315
22748
  function runUntilInterrupted(stop, farewell) {
22316
- return new Promise((resolve3) => {
22749
+ return new Promise((resolve4) => {
22317
22750
  let stopping = false;
22318
22751
  process.on("SIGINT", () => {
22319
22752
  if (stopping) process.exit(130);
@@ -22321,10 +22754,10 @@ function runUntilInterrupted(stop, farewell) {
22321
22754
  process.stderr.write(`
22322
22755
  ${farewell}
22323
22756
  `);
22324
- void stop().then(resolve3);
22757
+ void stop().then(resolve4);
22325
22758
  });
22326
22759
  process.on("SIGTERM", () => {
22327
- void stop().then(resolve3);
22760
+ void stop().then(resolve4);
22328
22761
  });
22329
22762
  });
22330
22763
  }
@@ -22335,7 +22768,7 @@ async function run2(command) {
22335
22768
  process.stdout.write(USAGE + "\n");
22336
22769
  return 0;
22337
22770
  case "version":
22338
- process.stdout.write(`Puddle ${cliVersion()}
22771
+ process.stdout.write(`${formatComponentVersions(await installedComponentVersions())}
22339
22772
  `);
22340
22773
  return 0;
22341
22774
  case "launch": {
@@ -22468,7 +22901,9 @@ async function run2(command) {
22468
22901
  for (const victim of victims) {
22469
22902
  await terminateCockpit(victim);
22470
22903
  const where = victim.target === "local" ? "this machine" : victim.target;
22471
- logger.info(`stopped the cockpit for ${victim.target} \u2014 sessions keep running on ${where}`);
22904
+ logger.info(
22905
+ victim.daemonLifetime === "cockpit" ? `stopped the cockpit for ${victim.target} \u2014 daemon data remains on ${where}; interrupted sessions can resume on the next launch` : `stopped the cockpit for ${victim.target} \u2014 sessions keep running on ${where}`
22906
+ );
22472
22907
  }
22473
22908
  return 0;
22474
22909
  }
@@ -22621,7 +23056,8 @@ async function runCockpit(command, target, terminal) {
22621
23056
  status: "ready",
22622
23057
  origin: cockpit.origin,
22623
23058
  browserUrl: cockpit.browserUrl,
22624
- nonce: cockpit.nonce
23059
+ nonce: cockpit.nonce,
23060
+ daemonLifetime: cockpit.daemonLifetime
22625
23061
  });
22626
23062
  const arrow = command.target === "local" ? "" : ` \u2192 ${command.target}`;
22627
23063
  logger.info(`Puddle cockpit at ${cockpit.origin}${arrow} (daemon ${cockpit.daemon.version})`);
@@ -22630,10 +23066,13 @@ async function runCockpit(command, target, terminal) {
22630
23066
  logger.info(`open: ${cockpit.browserUrl}`);
22631
23067
  }
22632
23068
  const where = command.target === "local" ? "this machine" : command.target;
22633
- await runUntilInterrupted(async () => {
22634
- await cockpit.stop();
22635
- removeCockpitRecord(target);
22636
- }, `cockpit closed \u2014 sessions keep running on ${where}`);
23069
+ await runUntilInterrupted(
23070
+ async () => {
23071
+ await cockpit.stop();
23072
+ removeCockpitRecord(target);
23073
+ },
23074
+ cockpit.daemonLifetime === "cockpit" ? `cockpit closed \u2014 daemon data remains on ${where}; interrupted sessions can resume on the next launch` : `cockpit closed \u2014 sessions keep running on ${where}`
23075
+ );
22637
23076
  return 0;
22638
23077
  }
22639
23078
  function pad(text, width) {