@puddle-code/cli 0.0.33 → 0.0.35

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 (30) hide show
  1. package/CHANGELOG.md +9 -4
  2. package/README.md +18 -10
  3. package/dist/index.js +1033 -461
  4. package/dist/public/assets/{Dashboard-D0B3Kact.js → Dashboard-CjcXc41s.js} +1 -1
  5. package/dist/public/assets/{EditorTabClose-DH-LS9Zv.js → EditorTabClose-CR8qjstC.js} +1 -1
  6. package/dist/public/assets/{LazyTerminal-SW0c7j8t.js → LazyTerminal-CSezaFiz.js} +2 -2
  7. package/dist/public/assets/{ModelRefcount-BO6Mp87O.js → ModelRefcount-ruz_Q9xr.js} +1 -1
  8. package/dist/public/assets/PaneEditorBody-66So4Ff7.js +322 -0
  9. package/dist/public/assets/SettingsDialog-Bye-gWnk.js +1 -0
  10. package/dist/public/assets/{Terminal-MrMvzcuc.js → Terminal-Cd5V3epm.js} +2 -2
  11. package/dist/public/assets/Workspace-DWKoGE_e.js +7 -0
  12. package/dist/public/assets/{buffer-store-5mtYW1Xq.js → buffer-store-C7PmP6Kk.js} +2 -2
  13. package/dist/public/assets/{context-menu-7ErbVngu.js → context-menu-CNjTT3SL.js} +1 -1
  14. package/dist/public/assets/{cssMode-DgxyyatT.js → cssMode-BszXvhfs.js} +1 -1
  15. package/dist/public/assets/{editor-context-CJ-O2xj0.js → editor-context-CFhFe8Rl.js} +1 -1
  16. package/dist/public/assets/{editor-sync-CCVhpCCj.js → editor-sync-W2W78_MY.js} +1 -1
  17. package/dist/public/assets/{hotkeys-BJ9IivRE.js → hotkeys-DJt0-uY0.js} +2 -2
  18. package/dist/public/assets/{htmlMode-CsnCJ5rN.js → htmlMode-BdSq1oJt.js} +1 -1
  19. package/dist/public/assets/index-oaI2xX3B.js +10 -0
  20. package/dist/public/assets/{jsonMode-Bm5_kMyH.js → jsonMode-BGITbv0h.js} +1 -1
  21. package/dist/public/assets/{session-seed-CXqY3vWw.js → session-seed-DyZA5Bb-.js} +1 -1
  22. package/dist/public/assets/ssh-mode-D1ZueIgo.js +1 -0
  23. package/dist/public/assets/{tsMode-D76dymM5.js → tsMode-B9a_Su_k.js} +1 -1
  24. package/dist/public/index.html +3 -3
  25. package/package.json +1 -1
  26. package/dist/public/assets/PaneEditorBody-_zHVXavR.js +0 -322
  27. package/dist/public/assets/SettingsDialog-DvUCRTKI.js +0 -1
  28. package/dist/public/assets/Workspace-CNchP0lE.js +0 -7
  29. package/dist/public/assets/index-D9bGlq8g.js +0 -10
  30. package/dist/public/assets/ssh-mode-nM399PS5.js +0 -1
package/dist/index.js CHANGED
@@ -3905,6 +3905,29 @@ var init_attach = __esm({
3905
3905
 
3906
3906
  // src/cli/args.ts
3907
3907
  init_types();
3908
+ var COMPONENTS = ["cli", "daemon", "desktop"];
3909
+ function parseComponentSpec(raw) {
3910
+ const at = raw.indexOf("@");
3911
+ const name = at === -1 ? raw : raw.slice(0, at);
3912
+ const versionRaw = at === -1 ? void 0 : raw.slice(at + 1);
3913
+ if (name !== "" && !COMPONENTS.includes(name)) return null;
3914
+ if (name === "" && versionRaw === void 0) return null;
3915
+ let version2;
3916
+ if (versionRaw !== void 0) {
3917
+ version2 = versionRaw.replace(/^v/, "");
3918
+ if (!/^\d+\.\d+\.\d+$/.test(version2)) {
3919
+ throw new CliError(
3920
+ "bad_arguments",
3921
+ `'${versionRaw}' is not a release version`,
3922
+ "versions look like @v0.0.32 (or @0.0.32)"
3923
+ );
3924
+ }
3925
+ }
3926
+ return {
3927
+ ...name !== "" ? { what: name } : {},
3928
+ ...version2 !== void 0 ? { version: version2 } : {}
3929
+ };
3930
+ }
3908
3931
  var USAGE = `Puddle \u2014 self-hosted orchestrator for CLI coding agents
3909
3932
 
3910
3933
  usage:
@@ -3917,7 +3940,9 @@ usage:
3917
3940
  puddle status [user@host]
3918
3941
  puddle attach [user@host] <session> [--term <id>]
3919
3942
  puddle logs [user@host] [session] [--term <id>] [-f|--follow]
3920
- puddle upgrade [daemon [user@host] | desktop]
3943
+ puddle install <daemon|desktop>[@version] [user@host] [--tarball <path>]
3944
+ puddle upgrade [cli|daemon|desktop][@version] [user@host] [--tarball <path>]
3945
+ puddle remove <cli|daemon|desktop> [user@host] [--yes] [--purge]
3921
3946
  puddle --version | --help
3922
3947
 
3923
3948
  launch serves the cockpit at http://localhost:7433 against the daemon on this
@@ -3928,9 +3953,19 @@ to stay attached; Ctrl-C then stops the cockpit). refresh stops a target's
3928
3953
  cockpit (even a wedged one) and runs the full launch flow again \u2014 tunnel,
3929
3954
  daemon restart if needed \u2014 keeping the old UI port so open tabs survive.
3930
3955
  list shows running cockpits; kill stops one \u2014 sessions keep running on the
3931
- host either way. upgrade with no subject updates the CLI itself through npm;
3932
- 'daemon' updates puddled (on this machine, or on a user@host) and 'desktop'
3933
- installs or updates the macOS app bundle (Linux AppImages update in-app).`;
3956
+ host either way.
3957
+
3958
+ install puts a component in place: 'daemon' under ~/.puddle on this machine
3959
+ or a user@host, 'desktop' as the macOS app bundle \u2014 or, on Linux, an
3960
+ AppImage placed where you say (default ~/puddle), then opened in the file
3961
+ manager. Already installed and no @version \u2192 nothing changes.
3962
+ upgrade moves components to the newest release (or the named @version),
3963
+ installing any that are missing; with no component it covers everything
3964
+ installed on the target \u2014 the CLI last, via npm. cli and desktop are
3965
+ client-machine artefacts: user@host targets the daemon only. remove
3966
+ uninstalls a component after confirmation; 'daemon' stops it (interrupting
3967
+ its sessions), unregisters the supervisor, and keeps ~/.puddle's data \u2014
3968
+ profiles, session history, worktrees \u2014 unless you also confirm --purge.`;
3934
3969
  function parseArgs(argv) {
3935
3970
  const [cmd, ...rest] = argv;
3936
3971
  if (cmd === void 0 || cmd === "help" || cmd === "--help" || cmd === "-h")
@@ -4101,30 +4136,76 @@ function parseArgs(argv) {
4101
4136
  return { cmd: "logs", follow, ...term !== void 0 ? { term } : {} };
4102
4137
  return first.includes("@") ? { cmd: "logs", host: first, follow, ...term !== void 0 ? { term } : {} } : { cmd: "logs", session: first, follow, ...term !== void 0 ? { term } : {} };
4103
4138
  }
4139
+ case "install": {
4140
+ const [specRaw, host, extra] = positionals;
4141
+ if (extra !== void 0)
4142
+ throw new CliError("bad_arguments", "install takes at most a component + host");
4143
+ const tarball = strFlag("--tarball");
4144
+ expect("--tarball");
4145
+ const spec = specRaw !== void 0 ? parseComponentSpec(specRaw) : null;
4146
+ if (spec?.what === void 0 || spec.what === "cli") {
4147
+ throw new CliError(
4148
+ "bad_arguments",
4149
+ "install takes one of: daemon | desktop (with an optional @version)",
4150
+ spec?.what === "cli" ? "the CLI installs itself via npm: npm install -g @puddle-code/cli" : "e.g. puddle install daemon@v0.0.32 user@host"
4151
+ );
4152
+ }
4153
+ return {
4154
+ cmd: "install",
4155
+ what: spec.what,
4156
+ ...spec.version !== void 0 ? { version: spec.version } : {},
4157
+ ...host !== void 0 ? { host } : {},
4158
+ ...tarball !== void 0 ? { tarball } : {}
4159
+ };
4160
+ }
4104
4161
  case "upgrade": {
4162
+ const tarball = strFlag("--tarball");
4163
+ expect("--tarball");
4164
+ if (positionals.length > 2)
4165
+ throw new CliError("bad_arguments", "upgrade takes at most a component + host");
4166
+ const [first, second] = positionals;
4167
+ const spec = first !== void 0 ? parseComponentSpec(first) : null;
4168
+ const host = spec === null ? first : second;
4169
+ if (spec === null && second !== void 0)
4170
+ throw new CliError(
4171
+ "bad_arguments",
4172
+ `'${first}' is not a component`,
4173
+ "upgrade takes [cli|daemon|desktop][@version], then an optional user@host"
4174
+ );
4175
+ return {
4176
+ cmd: "upgrade",
4177
+ ...spec?.what !== void 0 ? { what: spec.what } : {},
4178
+ ...spec?.version !== void 0 ? { version: spec.version } : {},
4179
+ ...host !== void 0 ? { host } : {},
4180
+ ...tarball !== void 0 ? { tarball } : {}
4181
+ };
4182
+ }
4183
+ case "remove": {
4105
4184
  const [what, host, extra] = positionals;
4106
- if (what === void 0) {
4107
- expect();
4108
- return { cmd: "upgrade", what: "cli" };
4109
- }
4110
- if (what === "cli") {
4185
+ if (extra !== void 0)
4186
+ throw new CliError("bad_arguments", "remove takes at most a component + host");
4187
+ expect("--yes", "--purge");
4188
+ if (what === void 0 || !["cli", "daemon", "desktop"].includes(what)) {
4111
4189
  throw new CliError(
4112
4190
  "bad_arguments",
4113
- "`puddle upgrade cli` is now just `puddle upgrade`",
4114
- "run: puddle upgrade"
4191
+ "remove needs a component: cli | daemon | desktop",
4192
+ what !== void 0 && /^(cli|daemon|desktop)@/.test(what) ? "remove takes no @version \u2014 a removal has no version to pick" : "e.g. puddle remove daemon user@host"
4115
4193
  );
4116
4194
  }
4117
- if (what !== "daemon" && what !== "desktop") {
4195
+ const purge = flags.has("--purge");
4196
+ if (purge && what !== "daemon") {
4118
4197
  throw new CliError(
4119
4198
  "bad_arguments",
4120
- "upgrade takes no subject (the CLI) or one of: daemon | desktop",
4121
- what.includes("@") ? `to upgrade the daemon on ${what}: puddle upgrade daemon ${what}` : "see: puddle --help"
4199
+ "--purge only applies to the daemon (it deletes ~/.puddle)"
4122
4200
  );
4123
4201
  }
4124
- if (extra !== void 0)
4125
- throw new CliError("bad_arguments", "upgrade takes at most a subject + host");
4126
- expect();
4127
- return { cmd: "upgrade", what, ...host !== void 0 ? { host } : {} };
4202
+ return {
4203
+ cmd: "remove",
4204
+ what,
4205
+ ...host !== void 0 ? { host } : {},
4206
+ yes: flags.has("--yes"),
4207
+ purge
4208
+ };
4128
4209
  }
4129
4210
  default:
4130
4211
  throw new CliError("bad_arguments", `unknown command '${cmd}'`, "see: puddle --help");
@@ -4145,331 +4226,30 @@ function argvFor(command) {
4145
4226
 
4146
4227
  // src/cli/run.ts
4147
4228
  init_attach();
4148
- import { spawn as spawn7 } from "node:child_process";
4149
- import { existsSync as existsSync3 } from "node:fs";
4150
- import { dirname as dirname7, join as join9 } from "node:path";
4229
+ import { existsSync as existsSync4 } from "node:fs";
4230
+ import { dirname as dirname7, join as join10 } from "node:path";
4151
4231
  import { fileURLToPath as fileURLToPath3 } from "node:url";
4152
4232
 
4153
- // src/lib/desktop-update.ts
4154
- import { createHash } from "node:crypto";
4233
+ // src/lib/browser.ts
4155
4234
  import { spawn } from "node:child_process";
4156
- import { constants, createReadStream, createWriteStream } from "node:fs";
4157
- import { access, chmod, mkdir, readdir, rm, writeFile } from "node:fs/promises";
4158
- import { dirname as dirname2, join as join3 } from "node:path";
4159
- import { Readable } from "node:stream";
4160
- import { pipeline } from "node:stream/promises";
4161
-
4162
- // src/lib/paths.ts
4163
- import { homedir } from "node:os";
4164
- import { join } from "node:path";
4165
- function clientHome(env = process.env) {
4166
- return env.PUDDLE_HOME ?? join(homedir(), ".puddle");
4167
- }
4168
- var HOST_HOME = '"${PUDDLE_HOME:-$HOME/.puddle}"';
4169
- var hostPaths = {
4170
- home: HOST_HOME,
4171
- token: `${HOST_HOME}/token`,
4172
- config: `${HOST_HOME}/config.json`,
4173
- runtime: `${HOST_HOME}/runtime.json`,
4174
- current: `${HOST_HOME}/bin/current`,
4175
- cache: `${HOST_HOME}/cache`,
4176
- logs: `${HOST_HOME}/logs`
4177
- };
4178
-
4179
- // src/lib/desktop-update.ts
4180
- init_types();
4181
-
4182
- // src/lib/version.ts
4183
- import { readFileSync } from "node:fs";
4184
- import { dirname, join as join2 } from "node:path";
4185
- import { fileURLToPath } from "node:url";
4186
- function cliVersion() {
4187
- if (true) return "0.0.33";
4188
- const here = dirname(fileURLToPath(import.meta.url));
4189
- for (const candidate of [
4190
- join2(here, "..", "..", "package.json"),
4191
- join2(here, "..", "package.json")
4192
- ]) {
4193
- try {
4194
- const pkg = JSON.parse(readFileSync(candidate, "utf8"));
4195
- if (pkg.version !== void 0) return pkg.version;
4196
- } catch {
4197
- }
4198
- }
4199
- return "0.0.0";
4200
- }
4201
- function pinnedDaemonVersion() {
4202
- return cliVersion();
4203
- }
4204
- function repoSlug(env = process.env) {
4205
- if (env.PUDDLE_REPO !== void 0 && env.PUDDLE_REPO !== "") return env.PUDDLE_REPO;
4206
- if (true) return "PerceptronV/puddle-code";
4207
- return void 0;
4208
- }
4209
-
4210
- // src/lib/desktop-update.ts
4211
- function isNewerVersion(candidate, current) {
4212
- const parse3 = (v) => {
4213
- const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
4214
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
4215
- };
4216
- const a = parse3(candidate);
4217
- const b = parse3(current);
4218
- if (a === null || b === null) return false;
4219
- for (let i = 0; i < 3; i++) {
4220
- if (a[i] !== b[i]) return a[i] > b[i];
4221
- }
4222
- return false;
4223
- }
4224
- function pickDesktopAsset(assets, platform, arch) {
4225
- const suffix = platform === "darwin" ? "-mac.zip" : platform === "linux" ? ".AppImage" : null;
4226
- if (suffix === null) return null;
4227
- return assets.find(
4228
- (a) => a.name.endsWith(suffix) && a.name.includes("-arm64") === (arch === "arm64")
4229
- ) ?? null;
4230
- }
4231
- function parseSums(text) {
4232
- const sums = /* @__PURE__ */ new Map();
4233
- for (const line of text.split("\n")) {
4234
- const match = /^([0-9a-f]{64})\s+\*?(.+?)\s*$/.exec(line.trim());
4235
- if (match) sums.set(match[2], match[1]);
4236
- }
4237
- return sums;
4238
- }
4239
- async function checkForDesktopUpdate(currentVersion, opts = {}) {
4240
- const slug2 = repoSlug();
4241
- if (slug2 === void 0) return null;
4242
- const fetchFn = opts.fetchFn ?? fetch;
4243
- const response = await fetchFn(`https://api.github.com/repos/${slug2}/releases/latest`, {
4244
- headers: { accept: "application/vnd.github+json", "user-agent": "puddle-desktop" }
4245
- });
4246
- if (!response.ok) {
4247
- throw new CliError("not_installed", `release lookup failed (HTTP ${response.status})`);
4248
- }
4249
- const release = await response.json();
4250
- const version2 = (release.tag_name ?? "").replace(/^v/, "");
4251
- if (!isNewerVersion(version2, currentVersion)) return null;
4252
- const assets = release.assets ?? [];
4253
- const asset = pickDesktopAsset(
4254
- assets,
4255
- opts.platform ?? process.platform,
4256
- opts.arch ?? process.arch
4257
- );
4258
- const sums = assets.find((a) => a.name === "SHA256SUMS");
4259
- if (asset === null || sums === void 0) return null;
4260
- return {
4261
- version: version2,
4262
- asset: { name: asset.name, url: asset.browser_download_url },
4263
- sumsUrl: sums.browser_download_url
4264
- };
4265
- }
4266
- async function stageDesktopUpdate(update, opts = {}) {
4267
- const logger = opts.logger ?? silentLogger;
4268
- const fetchFn = opts.fetchFn ?? fetch;
4269
- const dir = join3(opts.cacheDir ?? join3(clientHome(), "cache", "desktop"), update.version);
4270
- await rm(dir, { recursive: true, force: true });
4271
- await mkdir(dir, { recursive: true });
4235
+ function openBrowser(url2, platform = process.platform) {
4236
+ const command = platform === "darwin" ? { bin: "open", args: [url2] } : platform === "win32" ? { bin: "cmd", args: ["/c", "start", "", url2] } : { bin: "xdg-open", args: [url2] };
4272
4237
  try {
4273
- logger.info(`downloading ${update.asset.name}`);
4274
- const archive = join3(dir, update.asset.name);
4275
- const response = await fetchFn(update.asset.url, {
4276
- headers: { "user-agent": "puddle-desktop" }
4277
- });
4278
- if (!response.ok || response.body === null) {
4279
- throw new CliError("not_installed", `download failed (HTTP ${response.status})`);
4280
- }
4281
- await pipeline(
4282
- Readable.fromWeb(response.body),
4283
- createWriteStream(archive)
4284
- );
4285
- const sumsResponse = await fetchFn(update.sumsUrl, {
4286
- headers: { "user-agent": "puddle-desktop" }
4238
+ const child = spawn(command.bin, command.args, { stdio: "ignore", detached: true });
4239
+ child.on("error", () => {
4287
4240
  });
4288
- if (!sumsResponse.ok) {
4289
- throw new CliError("not_installed", `SHA256SUMS fetch failed (HTTP ${sumsResponse.status})`);
4290
- }
4291
- const expected = parseSums(await sumsResponse.text()).get(update.asset.name);
4292
- if (expected === void 0) {
4293
- throw new CliError("not_installed", `${update.asset.name} is not in SHA256SUMS`);
4294
- }
4295
- const actual = await fileSha256(archive);
4296
- if (actual !== expected) {
4297
- throw new CliError(
4298
- "not_installed",
4299
- `checksum mismatch for ${update.asset.name}`,
4300
- `expected ${expected}, got ${actual}`
4301
- );
4302
- }
4303
- if (update.asset.name.endsWith(".zip")) {
4304
- const extractDir = join3(dir, "extract");
4305
- await run("/usr/bin/ditto", ["-x", "-k", archive, extractDir]);
4306
- const bundle = (await readdir(extractDir)).find((name) => name.endsWith(".app"));
4307
- if (bundle === void 0) {
4308
- throw new CliError("not_installed", "the update zip contains no .app bundle");
4309
- }
4310
- logger.info(`staged ${update.version} at ${join3(extractDir, bundle)}`);
4311
- return {
4312
- version: update.version,
4313
- kind: "mac-app",
4314
- stagedPath: join3(extractDir, bundle),
4315
- dir
4316
- };
4317
- }
4318
- await chmod(archive, 493);
4319
- logger.info(`staged ${update.version} at ${archive}`);
4320
- return { version: update.version, kind: "appimage", stagedPath: archive, dir };
4321
- } catch (e) {
4322
- await rm(dir, { recursive: true, force: true });
4323
- throw e;
4324
- }
4325
- }
4326
- async function applyDesktopUpdate(staged, opts) {
4327
- const logger = opts.logger ?? silentLogger;
4328
- const script = swapScript(staged, opts);
4329
- const scriptPath = join3(staged.dir, "apply.sh");
4330
- await writeFile(scriptPath, script, { mode: 493 });
4331
- logger.info(`applying ${staged.version} via ${scriptPath}`);
4332
- if (opts.detach ?? true) {
4333
- const child = spawn("/bin/sh", [scriptPath], { detached: true, stdio: "ignore" });
4334
4241
  child.unref();
4335
- return;
4336
- }
4337
- await run("/bin/sh", [scriptPath]);
4338
- }
4339
- var q = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
4340
- function swapScript(staged, opts) {
4341
- const wait = opts.waitPid === void 0 ? [] : [
4342
- `i=0`,
4343
- `while kill -0 ${opts.waitPid} 2>/dev/null; do`,
4344
- ` i=$((i+1)); [ "$i" -gt 600 ] && exit 1`,
4345
- ` sleep 0.1`,
4346
- `done`
4347
- ];
4348
- if (staged.kind === "mac-app") {
4349
- return [
4350
- `#!/bin/sh`,
4351
- `# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
4352
- ...wait,
4353
- `target=${q(opts.targetPath)}`,
4354
- `target_dir=${q(dirname2(opts.targetPath))}`,
4355
- `staged=${q(staged.stagedPath)}`,
4356
- `mkdir -p "$target_dir" || exit 1`,
4357
- `rm -rf "$target.old"`,
4358
- `had_old=0`,
4359
- `if [ -e "$target" ]; then`,
4360
- ` mv "$target" "$target.old" || exit 1`,
4361
- ` had_old=1`,
4362
- `fi`,
4363
- `if mv "$staged" "$target" 2>/dev/null || /usr/bin/ditto "$staged" "$target"; then`,
4364
- // Defence in depth: the normal path never quarantines (node's fetch is
4365
- // not a quarantine-opted-in app, and ditto only propagates what the
4366
- // zip already carries), but if the bundle ever acquires the attribute
4367
- // the swap must not resurrect the Gatekeeper prompt.
4368
- ` /usr/bin/xattr -dr com.apple.quarantine "$target" 2>/dev/null`,
4369
- ` rm -rf "$target.old" ${q(staged.dir)}`,
4370
- ...opts.relaunch ? [` open "$target"`] : [],
4371
- `else`,
4372
- ` rm -rf "$target"`,
4373
- ` [ "$had_old" -eq 0 ] || mv "$target.old" "$target"`,
4374
- ` exit 1`,
4375
- `fi`,
4376
- ``
4377
- ].join("\n");
4378
- }
4379
- return [
4380
- `#!/bin/sh`,
4381
- `# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
4382
- ...wait,
4383
- `target=${q(opts.targetPath)}`,
4384
- `cp -f ${q(staged.stagedPath)} "$target" || exit 1`,
4385
- `chmod +x "$target"`,
4386
- `rm -rf ${q(staged.dir)}`,
4387
- ...opts.relaunch ? [`"$target" >/dev/null 2>&1 &`] : [],
4388
- ``
4389
- ].join("\n");
4390
- }
4391
- async function findInstalledDesktopApp(opts = {}) {
4392
- if ((opts.platform ?? process.platform) !== "darwin") return null;
4393
- const { readFile } = await import("node:fs/promises");
4394
- const { homedir: homedir2 } = await import("node:os");
4395
- const systemApplications = opts.systemApplicationsDir ?? "/Applications";
4396
- const home = opts.homeDir ?? homedir2();
4397
- for (const appPath of [
4398
- join3(systemApplications, "Puddle.app"),
4399
- join3(home, "Applications", "Puddle.app")
4400
- ]) {
4401
- try {
4402
- const plist = await readFile(join3(appPath, "Contents/Info.plist"), "utf8");
4403
- const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
4404
- plist
4405
- );
4406
- if (match) return { appPath, version: match[1] };
4407
- } catch {
4408
- }
4409
- }
4410
- return null;
4411
- }
4412
- async function desktopAppInstallPath(opts = {}) {
4413
- if ((opts.platform ?? process.platform) !== "darwin") return null;
4414
- const { homedir: homedir2 } = await import("node:os");
4415
- const systemApplications = opts.systemApplicationsDir ?? "/Applications";
4416
- const canWrite = opts.canWrite ?? (async (path) => {
4417
- try {
4418
- await access(path, constants.W_OK);
4419
- return true;
4420
- } catch {
4421
- return false;
4422
- }
4423
- });
4424
- if (await canWrite(systemApplications)) return join3(systemApplications, "Puddle.app");
4425
- const userApplications = join3(opts.homeDir ?? homedir2(), "Applications");
4426
- await mkdir(userApplications, { recursive: true });
4427
- return join3(userApplications, "Puddle.app");
4428
- }
4429
- function isDesktopAppRunning(appPath) {
4430
- return new Promise((resolve3) => {
4431
- const child = spawn("/usr/bin/pgrep", ["-f", `${appPath}/Contents/MacOS/`], {
4432
- stdio: "ignore"
4433
- });
4434
- child.on("error", () => resolve3(false));
4435
- child.on("exit", (code) => resolve3(code === 0));
4436
- });
4437
- }
4438
- function fileSha256(path) {
4439
- const hash2 = createHash("sha256");
4440
- return pipeline(createReadStream(path), hash2).then(() => hash2.digest("hex"));
4441
- }
4442
- function run(command, args) {
4443
- return new Promise((resolve3, reject) => {
4444
- const child = spawn(command, args, { stdio: "ignore" });
4445
- child.on("error", reject);
4446
- child.on(
4447
- "exit",
4448
- (code) => code === 0 ? resolve3() : reject(new CliError("not_installed", `${command} exited with ${code ?? "signal"}`))
4449
- );
4450
- });
4451
- }
4452
-
4453
- // src/lib/browser.ts
4454
- import { spawn as spawn2 } from "node:child_process";
4455
- function openBrowser(url2, platform = process.platform) {
4456
- const command = platform === "darwin" ? { bin: "open", args: [url2] } : platform === "win32" ? { bin: "cmd", args: ["/c", "start", "", url2] } : { bin: "xdg-open", args: [url2] };
4457
- try {
4458
- const child = spawn2(command.bin, command.args, { stdio: "ignore", detached: true });
4459
- child.on("error", () => {
4460
- });
4461
- child.unref();
4462
- return true;
4463
- } catch {
4464
- return false;
4242
+ return true;
4243
+ } catch {
4244
+ return false;
4465
4245
  }
4466
4246
  }
4467
4247
 
4468
4248
  // src/lib/connect.ts
4469
- import { join as join6 } from "node:path";
4249
+ import { join as join5 } from "node:path";
4470
4250
 
4471
4251
  // ../shared/src/protocol.ts
4472
- var PROTOCOL_VERSION = { major: 14, minor: 2 };
4252
+ var PROTOCOL_VERSION = { major: 15, minor: 1 };
4473
4253
 
4474
4254
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
4475
4255
  var external_exports = {};
@@ -19038,7 +18818,7 @@ var fields = {
19038
18818
  };
19039
18819
  var daemonConfigSchema = external_exports.object({
19040
18820
  port: fields.port.default(7434),
19041
- autoResume: fields.autoResume.default(false),
18821
+ autoResume: fields.autoResume.default(true),
19042
18822
  fetchIntervalMinutes: fields.fetchIntervalMinutes.default(15),
19043
18823
  logMaxBytes: fields.logMaxBytes.default(10 * 1024 * 1024),
19044
18824
  replayBytes: fields.replayBytes.default(256 * 1024),
@@ -19571,12 +19351,14 @@ var editorTabRefSchema = external_exports.object({
19571
19351
  */
19572
19352
  root: external_exports.string().optional(),
19573
19353
  /**
19574
- * How a `file` tab renders: Monaco (`source`, the default when absent) or a
19575
- * rendered `preview` — meaningful only for previewable types (markdown,
19576
- * HTML; SPEC §8). Not part of the tab's identity: toggling rewrites the tab
19577
- * ref in place.
19354
+ * How a `file` tab renders: Monaco (`source`, the default when absent), a
19355
+ * rendered `preview`, or a `linked` preview a rendered view whose
19356
+ * (session, path[, root]) is REWRITTEN to follow the most recently active
19357
+ * renderable tab in its layout tree (SPEC §8). Meaningful only for
19358
+ * previewable types (markdown, HTML). Not part of the tab's identity:
19359
+ * toggling rewrites the tab ref in place.
19578
19360
  */
19579
- view: external_exports.enum(["source", "preview"]).optional()
19361
+ view: external_exports.enum(["source", "preview", "linked"]).optional()
19580
19362
  });
19581
19363
  var tabRefSchema = external_exports.discriminatedUnion("type", [
19582
19364
  external_exports.object({ type: external_exports.literal("editor"), tab: editorTabRefSchema }),
@@ -19974,6 +19756,23 @@ function sleep(ms) {
19974
19756
  return new Promise((r) => setTimeout(r, ms));
19975
19757
  }
19976
19758
 
19759
+ // src/lib/paths.ts
19760
+ import { homedir } from "node:os";
19761
+ import { join } from "node:path";
19762
+ function clientHome(env = process.env) {
19763
+ return env.PUDDLE_HOME ?? join(homedir(), ".puddle");
19764
+ }
19765
+ var HOST_HOME = '"${PUDDLE_HOME:-$HOME/.puddle}"';
19766
+ var hostPaths = {
19767
+ home: HOST_HOME,
19768
+ token: `${HOST_HOME}/token`,
19769
+ config: `${HOST_HOME}/config.json`,
19770
+ runtime: `${HOST_HOME}/runtime.json`,
19771
+ current: `${HOST_HOME}/bin/current`,
19772
+ cache: `${HOST_HOME}/cache`,
19773
+ logs: `${HOST_HOME}/logs`
19774
+ };
19775
+
19977
19776
  // src/lib/daemon-client.ts
19978
19777
  init_types();
19979
19778
  var LIVE = /* @__PURE__ */ new Set(["starting", "running", "waiting_input"]);
@@ -20016,6 +19815,13 @@ var DaemonClient = class {
20016
19815
  sessions() {
20017
19816
  return this.get("/api/sessions").then((body) => sessionSchema.array().parse(body));
20018
19817
  }
19818
+ profiles() {
19819
+ return this.get("/api/profiles").then((body) => profileSchema.array().parse(body));
19820
+ }
19821
+ /** The live subset of sessions() — what an upgrade/removal interrupts. */
19822
+ async liveSessions() {
19823
+ return (await this.sessions()).filter((s) => LIVE.has(s.status));
19824
+ }
20019
19825
  async liveSessionCount() {
20020
19826
  return (await this.sessions()).filter((s) => LIVE.has(s.status)).length;
20021
19827
  }
@@ -20072,9 +19878,39 @@ async function waitForToken(transport, timeoutMs) {
20072
19878
 
20073
19879
  // src/lib/bootstrap.ts
20074
19880
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
20075
- import { dirname as dirname3, join as join4, resolve } from "node:path";
19881
+ import { dirname as dirname2, join as join3, resolve } from "node:path";
20076
19882
  import { fileURLToPath as fileURLToPath2 } from "node:url";
20077
19883
  init_types();
19884
+
19885
+ // src/lib/version.ts
19886
+ import { readFileSync } from "node:fs";
19887
+ import { dirname, join as join2 } from "node:path";
19888
+ import { fileURLToPath } from "node:url";
19889
+ function cliVersion() {
19890
+ if (true) return "0.0.35";
19891
+ const here = dirname(fileURLToPath(import.meta.url));
19892
+ for (const candidate of [
19893
+ join2(here, "..", "..", "package.json"),
19894
+ join2(here, "..", "package.json")
19895
+ ]) {
19896
+ try {
19897
+ const pkg = JSON.parse(readFileSync(candidate, "utf8"));
19898
+ if (pkg.version !== void 0) return pkg.version;
19899
+ } catch {
19900
+ }
19901
+ }
19902
+ return "0.0.0";
19903
+ }
19904
+ function pinnedDaemonVersion() {
19905
+ return cliVersion();
19906
+ }
19907
+ function repoSlug(env = process.env) {
19908
+ if (env.PUDDLE_REPO !== void 0 && env.PUDDLE_REPO !== "") return env.PUDDLE_REPO;
19909
+ if (true) return "PerceptronV/puddle-code";
19910
+ return void 0;
19911
+ }
19912
+
19913
+ // src/lib/bootstrap.ts
20078
19914
  async function installedVersion(transport) {
20079
19915
  const result = await transport.exec(`readlink ${hostPaths.current}`, { timeoutMs: 15e3 });
20080
19916
  if (result.code !== 0) return null;
@@ -20142,11 +19978,11 @@ function lastLines(text, count = 3) {
20142
19978
  return text.trim().split("\n").slice(-count).join("\n");
20143
19979
  }
20144
19980
  function readInstallScript() {
20145
- const here = dirname3(fileURLToPath2(import.meta.url));
19981
+ const here = dirname2(fileURLToPath2(import.meta.url));
20146
19982
  const candidates = [
20147
- join4(here, "install.sh"),
19983
+ join3(here, "install.sh"),
20148
19984
  // dist/index.js → dist/install.sh
20149
- join4(here, "..", "..", "..", "..", "scripts", "install.sh")
19985
+ join3(here, "..", "..", "..", "..", "scripts", "install.sh")
20150
19986
  // src/lib/ → repo root
20151
19987
  ];
20152
19988
  for (const candidate of candidates) {
@@ -20306,7 +20142,7 @@ function isLocalOrigin(origin) {
20306
20142
 
20307
20143
  // src/lib/serve/local-sync.ts
20308
20144
  import { mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
20309
- import { dirname as dirname4 } from "node:path";
20145
+ import { dirname as dirname3 } from "node:path";
20310
20146
  var MAX_BODY_BYTES = 5 * 1024 * 1024;
20311
20147
  function readStore(file2) {
20312
20148
  try {
@@ -20319,7 +20155,7 @@ function readStore(file2) {
20319
20155
  return { version: 1, profiles: {} };
20320
20156
  }
20321
20157
  function writeStore(file2, store) {
20322
- mkdirSync(dirname4(file2), { recursive: true });
20158
+ mkdirSync(dirname3(file2), { recursive: true });
20323
20159
  const tmp = `${file2}.tmp-${process.pid}`;
20324
20160
  writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n");
20325
20161
  renameSync(tmp, file2);
@@ -20394,8 +20230,8 @@ function recoverProxiedPath(referer, requestUrl) {
20394
20230
  }
20395
20231
 
20396
20232
  // src/lib/serve/static.ts
20397
- import { createReadStream as createReadStream2, existsSync as existsSync2, statSync } from "node:fs";
20398
- import { extname, join as join5, normalize, resolve as resolve2, sep } from "node:path";
20233
+ import { createReadStream, existsSync as existsSync2, statSync } from "node:fs";
20234
+ import { extname, join as join4, normalize, resolve as resolve2, sep } from "node:path";
20399
20235
  var MIME = {
20400
20236
  ".html": "text/html; charset=utf-8",
20401
20237
  ".js": "text/javascript; charset=utf-8",
@@ -20423,14 +20259,14 @@ function createStaticHandler(rootDir) {
20423
20259
  res.writeHead(404, { "content-type": "text/plain" }).end("not found");
20424
20260
  return;
20425
20261
  }
20426
- const requested = normalize(join5(root, decoded));
20262
+ const requested = normalize(join4(root, decoded));
20427
20263
  const candidate = requested.startsWith(root + sep) || requested === root ? requested : root;
20428
20264
  const isFile = existsSync2(candidate) && statSync(candidate).isFile();
20429
20265
  if (!isFile && extname(pathname) !== "") {
20430
20266
  res.writeHead(404, { "content-type": "text/plain" }).end("not found");
20431
20267
  return;
20432
20268
  }
20433
- const file2 = isFile ? candidate : join5(root, "index.html");
20269
+ const file2 = isFile ? candidate : join4(root, "index.html");
20434
20270
  if (!existsSync2(file2)) {
20435
20271
  res.writeHead(404, { "content-type": "text/plain" }).end("not found");
20436
20272
  return;
@@ -20443,7 +20279,7 @@ function createStaticHandler(rootDir) {
20443
20279
  res.end();
20444
20280
  return;
20445
20281
  }
20446
- createReadStream2(file2).pipe(res);
20282
+ createReadStream(file2).pipe(res);
20447
20283
  };
20448
20284
  }
20449
20285
 
@@ -20708,7 +20544,7 @@ async function listen(server, startPort, strict, avoidPort) {
20708
20544
  }
20709
20545
 
20710
20546
  // src/lib/tunnel.ts
20711
- import { spawn as spawn3 } from "node:child_process";
20547
+ import { spawn as spawn2 } from "node:child_process";
20712
20548
  init_types();
20713
20549
  var RECONNECT_INITIAL_MS = 500;
20714
20550
  var RECONNECT_MAX_MS = 1e4;
@@ -20724,7 +20560,7 @@ async function openCallbackForward(ssh, port, opts = {}) {
20724
20560
  return null;
20725
20561
  };
20726
20562
  if (await tcpListening(port)) return skip("local port busy");
20727
- const child = spawn3(
20563
+ const child = spawn2(
20728
20564
  opts.sshBinary ?? "ssh",
20729
20565
  ssh.args("-N", "-L", `${port}:127.0.0.1:${port}`, ssh.host),
20730
20566
  { stdio: ["ignore", "ignore", "inherit"] }
@@ -20756,7 +20592,7 @@ async function openTunnel(ssh, remotePort, opts = {}) {
20756
20592
  let healthTimer = null;
20757
20593
  const spawnForward = async (port) => {
20758
20594
  if (stopping) return false;
20759
- const child = spawn3(
20595
+ const child = spawn2(
20760
20596
  sshBinary,
20761
20597
  ssh.args("-N", "-L", `${port}:127.0.0.1:${remotePort}`, ssh.host),
20762
20598
  { stdio: ["ignore", "ignore", "inherit"] }
@@ -20879,15 +20715,15 @@ async function openTunnel(ssh, remotePort, opts = {}) {
20879
20715
  }
20880
20716
 
20881
20717
  // src/lib/transport/local.ts
20882
- import { spawn as spawn4 } from "node:child_process";
20718
+ import { spawn as spawn3 } from "node:child_process";
20883
20719
  import { copyFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync4 } from "node:fs";
20884
- import { dirname as dirname5 } from "node:path";
20720
+ import { dirname as dirname4 } from "node:path";
20885
20721
  var LocalTransport = class {
20886
20722
  kind = "local";
20887
20723
  label = "this machine";
20888
20724
  exec(command, opts = {}) {
20889
20725
  return new Promise((resolve3) => {
20890
- const child = spawn4("sh", ["-c", command], { stdio: ["pipe", "pipe", "pipe"] });
20726
+ const child = spawn3("sh", ["-c", command], { stdio: ["pipe", "pipe", "pipe"] });
20891
20727
  let stdout = "";
20892
20728
  let stderr = "";
20893
20729
  let timer;
@@ -20922,7 +20758,7 @@ var LocalTransport = class {
20922
20758
  }
20923
20759
  copyTo(localPath, destPath) {
20924
20760
  const dest = expandHome(destPath);
20925
- mkdirSync2(dirname5(dest), { recursive: true });
20761
+ mkdirSync2(dirname4(dest), { recursive: true });
20926
20762
  copyFileSync(localPath, dest);
20927
20763
  return Promise.resolve();
20928
20764
  }
@@ -20934,7 +20770,7 @@ function expandHome(path) {
20934
20770
  }
20935
20771
 
20936
20772
  // src/lib/transport/ssh.ts
20937
- import { spawn as spawn5 } from "node:child_process";
20773
+ import { spawn as spawn4 } from "node:child_process";
20938
20774
  import { mkdirSync as mkdirSync3 } from "node:fs";
20939
20775
  init_types();
20940
20776
  var SshTransport = class {
@@ -20981,7 +20817,7 @@ var SshTransport = class {
20981
20817
  */
20982
20818
  open() {
20983
20819
  return new Promise((resolve3, reject) => {
20984
- const child = spawn5(this.ssh, this.args(this.host, "true"), { stdio: "inherit" });
20820
+ const child = spawn4(this.ssh, this.args(this.host, "true"), { stdio: "inherit" });
20985
20821
  child.on(
20986
20822
  "error",
20987
20823
  (err) => reject(new CliError("ssh_unreachable", `could not run ${this.ssh}: ${err.message}`))
@@ -21004,7 +20840,7 @@ var SshTransport = class {
21004
20840
  isAlive() {
21005
20841
  if (!this.hasControlMaster) return Promise.resolve(true);
21006
20842
  return new Promise((resolve3) => {
21007
- const child = spawn5(this.ssh, this.args("-O", "check", this.host), { stdio: "ignore" });
20843
+ const child = spawn4(this.ssh, this.args("-O", "check", this.host), { stdio: "ignore" });
21008
20844
  child.on("error", () => resolve3(false));
21009
20845
  child.on("close", (code) => resolve3(code === 0));
21010
20846
  });
@@ -21021,7 +20857,7 @@ var SshTransport = class {
21021
20857
  if (!this.hasControlMaster) return Promise.resolve();
21022
20858
  return new Promise((resolve3) => {
21023
20859
  const spec = `${localPort}:127.0.0.1:${remotePort}`;
21024
- const child = spawn5(this.ssh, this.args("-O", "cancel", "-L", spec, this.host), {
20860
+ const child = spawn4(this.ssh, this.args("-O", "cancel", "-L", spec, this.host), {
21025
20861
  stdio: "ignore"
21026
20862
  });
21027
20863
  child.on("error", () => resolve3());
@@ -21030,7 +20866,7 @@ var SshTransport = class {
21030
20866
  }
21031
20867
  exec(command, opts = {}) {
21032
20868
  return new Promise((resolve3) => {
21033
- const child = spawn5(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
20869
+ const child = spawn4(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
21034
20870
  stdio: ["pipe", "pipe", "pipe"]
21035
20871
  });
21036
20872
  let stdout = "";
@@ -21065,7 +20901,7 @@ var SshTransport = class {
21065
20901
  async copyTo(localPath, destPath) {
21066
20902
  await this.exec(`mkdir -p $(dirname ${destPath})`, { timeoutMs: 15e3 });
21067
20903
  await new Promise((resolve3, reject) => {
21068
- const child = spawn5(this.scp, [...this.controlArgs, localPath, `${this.host}:${destPath}`], {
20904
+ const child = spawn4(this.scp, [...this.controlArgs, localPath, `${this.host}:${destPath}`], {
21069
20905
  stdio: ["ignore", "ignore", "inherit"]
21070
20906
  });
21071
20907
  child.on("error", reject);
@@ -21130,7 +20966,7 @@ async function connectRemote(opts) {
21130
20966
  ...opts.onRefreshRequest !== void 0 ? { control: { token: endpoint.token, onRefresh: opts.onRefreshRequest } } : {},
21131
20967
  // The store lives on the CLIENT machine — every cockpit here shares it,
21132
20968
  // whichever remote daemon each one drives.
21133
- localSync: { token: endpoint.token, file: join6(clientHome(), "local-sync.json") }
20969
+ localSync: { token: endpoint.token, file: join5(clientHome(), "local-sync.json") }
21134
20970
  });
21135
20971
  tunnel.onPortChange((port) => ui.setTarget({ host: "127.0.0.1", port }));
21136
20972
  const eventCbs = /* @__PURE__ */ new Set();
@@ -21182,18 +21018,18 @@ async function showLogs(transport, opts) {
21182
21018
 
21183
21019
  // src/lib/registry.ts
21184
21020
  import { mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
21185
- import { join as join7 } from "node:path";
21021
+ import { join as join6 } from "node:path";
21186
21022
  function cockpitsDir(env) {
21187
- return join7(clientHome(env), "cockpits");
21023
+ return join6(clientHome(env), "cockpits");
21188
21024
  }
21189
21025
  function slug(target) {
21190
21026
  return target.replace(/[^A-Za-z0-9@._-]/g, "_");
21191
21027
  }
21192
21028
  function cockpitRecordPath(target) {
21193
- return join7(cockpitsDir(), `${slug(target)}.json`);
21029
+ return join6(cockpitsDir(), `${slug(target)}.json`);
21194
21030
  }
21195
21031
  function cockpitLogPath(target) {
21196
- return join7(clientHome(), "logs", `cockpit-${slug(target)}.log`);
21032
+ return join6(clientHome(), "logs", `cockpit-${slug(target)}.log`);
21197
21033
  }
21198
21034
  function writeCockpitRecord(record2) {
21199
21035
  mkdirSync4(cockpitsDir(), { recursive: true, mode: 448 });
@@ -21222,7 +21058,7 @@ function listCockpitRecords() {
21222
21058
  for (const name of names) {
21223
21059
  if (!name.endsWith(".json")) continue;
21224
21060
  try {
21225
- records.push(JSON.parse(readFileSync5(join7(cockpitsDir(), name), "utf8")));
21061
+ records.push(JSON.parse(readFileSync5(join6(cockpitsDir(), name), "utf8")));
21226
21062
  } catch {
21227
21063
  }
21228
21064
  }
@@ -21254,7 +21090,7 @@ async function checkCockpit(record2) {
21254
21090
  }
21255
21091
 
21256
21092
  // src/lib/start.ts
21257
- import { join as join8 } from "node:path";
21093
+ import { join as join7 } from "node:path";
21258
21094
  init_types();
21259
21095
  async function startLocal(opts) {
21260
21096
  const logger = opts.logger ?? silentLogger;
@@ -21277,7 +21113,7 @@ async function startLocal(opts) {
21277
21113
  // never squat the daemon's own port
21278
21114
  target: { host: "127.0.0.1", port: endpoint.port },
21279
21115
  ...opts.onRefreshRequest !== void 0 ? { control: { token: endpoint.token, onRefresh: opts.onRefreshRequest } } : {},
21280
- localSync: { token: endpoint.token, file: join8(clientHome(), "local-sync.json") }
21116
+ localSync: { token: endpoint.token, file: join7(clientHome(), "local-sync.json") }
21281
21117
  });
21282
21118
  const eventCbs = /* @__PURE__ */ new Set();
21283
21119
  return {
@@ -21317,7 +21153,405 @@ async function statusReport(client) {
21317
21153
  return { daemon, host, sessions: sessions.filter((s) => s.status !== "archived") };
21318
21154
  }
21319
21155
 
21320
- // src/cli/run.ts
21156
+ // src/cli/run.ts
21157
+ init_types();
21158
+
21159
+ // src/cli/manage.ts
21160
+ import { spawn as spawn7 } from "node:child_process";
21161
+ import { existsSync as existsSync3 } from "node:fs";
21162
+ import { chmod as chmod2, copyFile, mkdir as mkdir2, rename, rm as rm2 } from "node:fs/promises";
21163
+ import { homedir as homedir2 } from "node:os";
21164
+ import { join as join9 } from "node:path";
21165
+
21166
+ // src/lib/desktop-update.ts
21167
+ import { createHash } from "node:crypto";
21168
+ import { spawn as spawn5 } from "node:child_process";
21169
+ import { constants, createReadStream as createReadStream2, createWriteStream } from "node:fs";
21170
+ import { access, chmod, mkdir, readdir, rm, writeFile } from "node:fs/promises";
21171
+ import { dirname as dirname5, join as join8 } from "node:path";
21172
+ import { Readable } from "node:stream";
21173
+ import { pipeline } from "node:stream/promises";
21174
+ init_types();
21175
+ function isNewerVersion(candidate, current) {
21176
+ const parse3 = (v) => {
21177
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
21178
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
21179
+ };
21180
+ const a = parse3(candidate);
21181
+ const b = parse3(current);
21182
+ if (a === null || b === null) return false;
21183
+ for (let i = 0; i < 3; i++) {
21184
+ if (a[i] !== b[i]) return a[i] > b[i];
21185
+ }
21186
+ return false;
21187
+ }
21188
+ function pickDesktopAsset(assets, platform, arch) {
21189
+ const suffix = platform === "darwin" ? "-mac.zip" : platform === "linux" ? ".AppImage" : null;
21190
+ if (suffix === null) return null;
21191
+ return assets.find(
21192
+ (a) => a.name.endsWith(suffix) && a.name.includes("-arm64") === (arch === "arm64")
21193
+ ) ?? null;
21194
+ }
21195
+ function parseSums(text) {
21196
+ const sums = /* @__PURE__ */ new Map();
21197
+ for (const line of text.split("\n")) {
21198
+ const match = /^([0-9a-f]{64})\s+\*?(.+?)\s*$/.exec(line.trim());
21199
+ if (match) sums.set(match[2], match[1]);
21200
+ }
21201
+ return sums;
21202
+ }
21203
+ async function checkForDesktopUpdate(currentVersion, opts = {}) {
21204
+ const slug2 = repoSlug();
21205
+ if (slug2 === void 0) return null;
21206
+ const fetchFn = opts.fetchFn ?? fetch;
21207
+ const response = await fetchFn(`https://api.github.com/repos/${slug2}/releases/latest`, {
21208
+ headers: { accept: "application/vnd.github+json", "user-agent": "puddle-desktop" }
21209
+ });
21210
+ if (!response.ok) {
21211
+ throw new CliError("not_installed", `release lookup failed (HTTP ${response.status})`);
21212
+ }
21213
+ const release = await response.json();
21214
+ const version2 = (release.tag_name ?? "").replace(/^v/, "");
21215
+ if (!isNewerVersion(version2, currentVersion)) return null;
21216
+ return updateFromRelease(version2, release.assets ?? [], opts);
21217
+ }
21218
+ async function desktopUpdateAt(version2, opts = {}) {
21219
+ const slug2 = repoSlug();
21220
+ if (slug2 === void 0) {
21221
+ throw new CliError(
21222
+ "not_installed",
21223
+ "no release source is configured for this build",
21224
+ "set PUDDLE_REPO=owner/repo"
21225
+ );
21226
+ }
21227
+ const fetchFn = opts.fetchFn ?? fetch;
21228
+ const response = await fetchFn(`https://api.github.com/repos/${slug2}/releases/tags/v${version2}`, {
21229
+ headers: { accept: "application/vnd.github+json", "user-agent": "puddle-desktop" }
21230
+ });
21231
+ if (response.status === 404) {
21232
+ throw new CliError("not_installed", `no release v${version2} exists in ${slug2}`);
21233
+ }
21234
+ if (!response.ok) {
21235
+ throw new CliError("not_installed", `release lookup failed (HTTP ${response.status})`);
21236
+ }
21237
+ const release = await response.json();
21238
+ return updateFromRelease(version2, release.assets ?? [], opts);
21239
+ }
21240
+ function updateFromRelease(version2, assets, opts) {
21241
+ const asset = pickDesktopAsset(
21242
+ assets,
21243
+ opts.platform ?? process.platform,
21244
+ opts.arch ?? process.arch
21245
+ );
21246
+ const sums = assets.find((a) => a.name === "SHA256SUMS");
21247
+ if (asset === null || sums === void 0) return null;
21248
+ return {
21249
+ version: version2,
21250
+ asset: { name: asset.name, url: asset.browser_download_url },
21251
+ sumsUrl: sums.browser_download_url
21252
+ };
21253
+ }
21254
+ async function stageDesktopUpdate(update, opts = {}) {
21255
+ const logger = opts.logger ?? silentLogger;
21256
+ const fetchFn = opts.fetchFn ?? fetch;
21257
+ const dir = join8(opts.cacheDir ?? join8(clientHome(), "cache", "desktop"), update.version);
21258
+ await rm(dir, { recursive: true, force: true });
21259
+ await mkdir(dir, { recursive: true });
21260
+ try {
21261
+ logger.info(`downloading ${update.asset.name}`);
21262
+ const archive = join8(dir, update.asset.name);
21263
+ const response = await fetchFn(update.asset.url, {
21264
+ headers: { "user-agent": "puddle-desktop" }
21265
+ });
21266
+ if (!response.ok || response.body === null) {
21267
+ throw new CliError("not_installed", `download failed (HTTP ${response.status})`);
21268
+ }
21269
+ await pipeline(
21270
+ Readable.fromWeb(response.body),
21271
+ createWriteStream(archive)
21272
+ );
21273
+ const sumsResponse = await fetchFn(update.sumsUrl, {
21274
+ headers: { "user-agent": "puddle-desktop" }
21275
+ });
21276
+ if (!sumsResponse.ok) {
21277
+ throw new CliError("not_installed", `SHA256SUMS fetch failed (HTTP ${sumsResponse.status})`);
21278
+ }
21279
+ const expected = parseSums(await sumsResponse.text()).get(update.asset.name);
21280
+ if (expected === void 0) {
21281
+ throw new CliError("not_installed", `${update.asset.name} is not in SHA256SUMS`);
21282
+ }
21283
+ const actual = await fileSha256(archive);
21284
+ if (actual !== expected) {
21285
+ throw new CliError(
21286
+ "not_installed",
21287
+ `checksum mismatch for ${update.asset.name}`,
21288
+ `expected ${expected}, got ${actual}`
21289
+ );
21290
+ }
21291
+ if (update.asset.name.endsWith(".zip")) {
21292
+ const extractDir = join8(dir, "extract");
21293
+ await run("/usr/bin/ditto", ["-x", "-k", archive, extractDir]);
21294
+ const bundle = (await readdir(extractDir)).find((name) => name.endsWith(".app"));
21295
+ if (bundle === void 0) {
21296
+ throw new CliError("not_installed", "the update zip contains no .app bundle");
21297
+ }
21298
+ logger.info(`staged ${update.version} at ${join8(extractDir, bundle)}`);
21299
+ return {
21300
+ version: update.version,
21301
+ kind: "mac-app",
21302
+ stagedPath: join8(extractDir, bundle),
21303
+ dir
21304
+ };
21305
+ }
21306
+ await chmod(archive, 493);
21307
+ logger.info(`staged ${update.version} at ${archive}`);
21308
+ return { version: update.version, kind: "appimage", stagedPath: archive, dir };
21309
+ } catch (e) {
21310
+ await rm(dir, { recursive: true, force: true });
21311
+ throw e;
21312
+ }
21313
+ }
21314
+ async function pruneDesktopUpdateCache(keep = [], opts = {}) {
21315
+ const dir = opts.cacheDir ?? join8(clientHome(), "cache", "desktop");
21316
+ let entries;
21317
+ try {
21318
+ entries = await readdir(dir);
21319
+ } catch {
21320
+ return;
21321
+ }
21322
+ await Promise.all(
21323
+ entries.filter((name) => !keep.includes(name)).map((name) => rm(join8(dir, name), { recursive: true, force: true }).catch(() => {
21324
+ }))
21325
+ );
21326
+ }
21327
+ async function applyDesktopUpdate(staged, opts) {
21328
+ const logger = opts.logger ?? silentLogger;
21329
+ const script = swapScript(staged, opts);
21330
+ const scriptPath = join8(staged.dir, "apply.sh");
21331
+ await writeFile(scriptPath, script, { mode: 493 });
21332
+ logger.info(`applying ${staged.version} via ${scriptPath}`);
21333
+ if (opts.detach ?? true) {
21334
+ const child = spawn5("/bin/sh", [scriptPath], { detached: true, stdio: "ignore" });
21335
+ child.unref();
21336
+ return;
21337
+ }
21338
+ await run("/bin/sh", [scriptPath]);
21339
+ }
21340
+ var q = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
21341
+ function swapScript(staged, opts) {
21342
+ const wait = opts.waitPid === void 0 ? [] : [
21343
+ `i=0`,
21344
+ `while kill -0 ${opts.waitPid} 2>/dev/null; do`,
21345
+ ` i=$((i+1)); [ "$i" -gt 600 ] && exit 1`,
21346
+ ` sleep 0.1`,
21347
+ `done`
21348
+ ];
21349
+ if (staged.kind === "mac-app") {
21350
+ return [
21351
+ `#!/bin/sh`,
21352
+ `# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
21353
+ ...wait,
21354
+ `target=${q(opts.targetPath)}`,
21355
+ `target_dir=${q(dirname5(opts.targetPath))}`,
21356
+ `staged=${q(staged.stagedPath)}`,
21357
+ `mkdir -p "$target_dir" || exit 1`,
21358
+ `rm -rf "$target.old"`,
21359
+ `had_old=0`,
21360
+ `if [ -e "$target" ]; then`,
21361
+ ` mv "$target" "$target.old" || exit 1`,
21362
+ ` had_old=1`,
21363
+ `fi`,
21364
+ `if mv "$staged" "$target" 2>/dev/null || /usr/bin/ditto "$staged" "$target"; then`,
21365
+ // Defence in depth: the normal path never quarantines (node's fetch is
21366
+ // not a quarantine-opted-in app, and ditto only propagates what the
21367
+ // zip already carries), but if the bundle ever acquires the attribute
21368
+ // the swap must not resurrect the Gatekeeper prompt.
21369
+ ` /usr/bin/xattr -dr com.apple.quarantine "$target" 2>/dev/null`,
21370
+ ` rm -rf "$target.old" ${q(staged.dir)}`,
21371
+ ...opts.relaunch ? [` open "$target"`] : [],
21372
+ `else`,
21373
+ ` rm -rf "$target"`,
21374
+ ` [ "$had_old" -eq 0 ] || mv "$target.old" "$target"`,
21375
+ ` exit 1`,
21376
+ `fi`,
21377
+ ``
21378
+ ].join("\n");
21379
+ }
21380
+ return [
21381
+ `#!/bin/sh`,
21382
+ `# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
21383
+ ...wait,
21384
+ `target=${q(opts.targetPath)}`,
21385
+ `cp -f ${q(staged.stagedPath)} "$target" || exit 1`,
21386
+ `chmod +x "$target"`,
21387
+ `rm -rf ${q(staged.dir)}`,
21388
+ ...opts.relaunch ? [`"$target" >/dev/null 2>&1 &`] : [],
21389
+ ``
21390
+ ].join("\n");
21391
+ }
21392
+ async function findInstalledDesktopApp(opts = {}) {
21393
+ if ((opts.platform ?? process.platform) !== "darwin") return null;
21394
+ const { readFile } = await import("node:fs/promises");
21395
+ const { homedir: homedir3 } = await import("node:os");
21396
+ const systemApplications = opts.systemApplicationsDir ?? "/Applications";
21397
+ const home = opts.homeDir ?? homedir3();
21398
+ for (const appPath of [
21399
+ join8(systemApplications, "Puddle.app"),
21400
+ join8(home, "Applications", "Puddle.app")
21401
+ ]) {
21402
+ try {
21403
+ const plist = await readFile(join8(appPath, "Contents/Info.plist"), "utf8");
21404
+ const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
21405
+ plist
21406
+ );
21407
+ if (match) return { appPath, version: match[1] };
21408
+ } catch {
21409
+ }
21410
+ }
21411
+ return null;
21412
+ }
21413
+ async function desktopAppInstallPath(opts = {}) {
21414
+ if ((opts.platform ?? process.platform) !== "darwin") return null;
21415
+ const { homedir: homedir3 } = await import("node:os");
21416
+ const systemApplications = opts.systemApplicationsDir ?? "/Applications";
21417
+ const canWrite = opts.canWrite ?? (async (path) => {
21418
+ try {
21419
+ await access(path, constants.W_OK);
21420
+ return true;
21421
+ } catch {
21422
+ return false;
21423
+ }
21424
+ });
21425
+ if (await canWrite(systemApplications)) return join8(systemApplications, "Puddle.app");
21426
+ const userApplications = join8(opts.homeDir ?? homedir3(), "Applications");
21427
+ await mkdir(userApplications, { recursive: true });
21428
+ return join8(userApplications, "Puddle.app");
21429
+ }
21430
+ function isDesktopAppRunning(appPath) {
21431
+ return new Promise((resolve3) => {
21432
+ const child = spawn5("/usr/bin/pgrep", ["-f", `${appPath}/Contents/MacOS/`], {
21433
+ stdio: "ignore"
21434
+ });
21435
+ child.on("error", () => resolve3(false));
21436
+ child.on("exit", (code) => resolve3(code === 0));
21437
+ });
21438
+ }
21439
+ function fileSha256(path) {
21440
+ const hash2 = createHash("sha256");
21441
+ return pipeline(createReadStream2(path), hash2).then(() => hash2.digest("hex"));
21442
+ }
21443
+ function run(command, args) {
21444
+ return new Promise((resolve3, reject) => {
21445
+ const child = spawn5(command, args, { stdio: "ignore" });
21446
+ child.on("error", reject);
21447
+ child.on(
21448
+ "exit",
21449
+ (code) => code === 0 ? resolve3() : reject(new CliError("not_installed", `${command} exited with ${code ?? "signal"}`))
21450
+ );
21451
+ });
21452
+ }
21453
+
21454
+ // src/lib/releases.ts
21455
+ init_types();
21456
+ async function latestReleaseVersion(opts = {}) {
21457
+ const slug2 = repoSlug();
21458
+ if (slug2 === void 0) {
21459
+ throw new CliError(
21460
+ "not_installed",
21461
+ "no release source is configured for this build",
21462
+ "name a version and pass --tarball, or set PUDDLE_REPO=owner/repo"
21463
+ );
21464
+ }
21465
+ const fetchFn = opts.fetchFn ?? fetch;
21466
+ const res = await fetchFn(`https://api.github.com/repos/${slug2}/releases/latest`, {
21467
+ headers: { accept: "application/vnd.github+json", "user-agent": "puddle-cli" }
21468
+ });
21469
+ if (!res.ok) {
21470
+ throw new CliError(
21471
+ "not_installed",
21472
+ `release lookup failed (HTTP ${res.status})`,
21473
+ "name a version explicitly, e.g. puddle install daemon@v0.0.32"
21474
+ );
21475
+ }
21476
+ const tag = (await res.json()).tag_name ?? "";
21477
+ const version2 = tag.replace(/^v/, "");
21478
+ if (version2 === "") throw new CliError("not_installed", `${slug2} has no published releases`);
21479
+ return version2;
21480
+ }
21481
+
21482
+ // src/lib/remove-daemon.ts
21483
+ init_types();
21484
+ async function sweepDirtyWorktrees(transport) {
21485
+ const script = `
21486
+ W="\${PUDDLE_HOME:-$HOME/.puddle}/worktrees"
21487
+ [ -d "$W" ] || exit 0
21488
+ for d in "$W"/*/*; do
21489
+ [ -e "$d/.git" ] || continue
21490
+ dirty=$(git -C "$d" status --porcelain 2>/dev/null | head -1)
21491
+ unpushed=$(git -C "$d" log --branches --not --remotes --oneline 2>/dev/null | head -1)
21492
+ if [ -n "$dirty" ] || [ -n "$unpushed" ]; then printf '%s\\n' "$d"; fi
21493
+ done
21494
+ exit 0`;
21495
+ const result = await transport.exec("sh -s", { stdin: script, timeoutMs: 6e4 });
21496
+ if (result.code !== 0) return [];
21497
+ return result.stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
21498
+ }
21499
+ async function removeDaemon(transport, opts = { purge: false }) {
21500
+ const logger = opts.logger ?? silentLogger;
21501
+ const script = `
21502
+ set -u
21503
+ HOME_DIR="\${PUDDLE_HOME:-$HOME/.puddle}"
21504
+ say() { printf 'puddled remove: %s\\n' "$1"; }
21505
+
21506
+ # systemd user unit
21507
+ if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
21508
+ systemctl --user disable --now puddled >/dev/null 2>&1 || true
21509
+ if [ -f "$HOME/.config/systemd/user/puddled.service" ]; then
21510
+ rm -f "$HOME/.config/systemd/user/puddled.service"
21511
+ systemctl --user daemon-reload >/dev/null 2>&1 || true
21512
+ say "removed the systemd user unit"
21513
+ fi
21514
+ fi
21515
+
21516
+ # launchd agent
21517
+ PLIST="$HOME/Library/LaunchAgents/dev.puddle.puddled.plist"
21518
+ if [ -f "$PLIST" ]; then
21519
+ launchctl bootout "gui/$(id -u)/dev.puddle.puddled" 2>/dev/null \\
21520
+ || launchctl unload "$PLIST" 2>/dev/null || true
21521
+ rm -f "$PLIST"
21522
+ say "removed the launchd agent"
21523
+ fi
21524
+
21525
+ # nohup fallback
21526
+ if [ -f "$HOME_DIR/puddled.pid" ]; then
21527
+ kill "$(cat "$HOME_DIR/puddled.pid")" 2>/dev/null || true
21528
+ rm -f "$HOME_DIR/puddled.pid"
21529
+ fi
21530
+
21531
+ if [ "\${PUDDLE_PURGE:-0}" = 1 ]; then
21532
+ rm -rf "$HOME_DIR"
21533
+ say "deleted $HOME_DIR"
21534
+ else
21535
+ rm -rf "$HOME_DIR/bin" "$HOME_DIR/cache" "$HOME_DIR/runtime.json"
21536
+ say "removed the install; data kept under $HOME_DIR"
21537
+ fi`;
21538
+ const result = await transport.exec(`PUDDLE_PURGE=${opts.purge ? 1 : 0} sh -s`, {
21539
+ stdin: script,
21540
+ timeoutMs: 2 * 6e4,
21541
+ onStdout: (chunk) => {
21542
+ for (const line of chunk.split("\n")) if (line.trim() !== "") logger.info(line.trimEnd());
21543
+ }
21544
+ });
21545
+ if (result.code !== 0) {
21546
+ throw new CliError(
21547
+ "not_installed",
21548
+ `the removal failed on ${transport.label} (exit ${result.code})`,
21549
+ result.stderr.trim().split("\n").slice(-3).join("\n")
21550
+ );
21551
+ }
21552
+ }
21553
+
21554
+ // src/cli/manage.ts
21321
21555
  init_types();
21322
21556
 
21323
21557
  // src/lib/upgrade.ts
@@ -21476,6 +21710,427 @@ async function terminateCockpit(record2) {
21476
21710
  if (current === null || current.pid === record2.pid) removeCockpitRecord(record2.target);
21477
21711
  }
21478
21712
 
21713
+ // src/cli/prompt.ts
21714
+ init_types();
21715
+ import { createInterface } from "node:readline";
21716
+ async function ask(question, def) {
21717
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return def;
21718
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
21719
+ try {
21720
+ const answer = await new Promise(
21721
+ (resolve3) => rl.question(`${question} [${def}] `, resolve3)
21722
+ );
21723
+ return answer.trim() === "" ? def : answer.trim();
21724
+ } finally {
21725
+ rl.close();
21726
+ }
21727
+ }
21728
+ async function confirm(question, opts = {}) {
21729
+ if (opts.skip === true) return true;
21730
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
21731
+ throw new CliError(
21732
+ "bad_arguments",
21733
+ "refusing to confirm without a terminal",
21734
+ "pass --yes to proceed non-interactively"
21735
+ );
21736
+ }
21737
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
21738
+ try {
21739
+ const answer = await new Promise(
21740
+ (resolve3) => rl.question(`${question} [y/N] `, resolve3)
21741
+ );
21742
+ return /^y(es)?$/i.test(answer.trim());
21743
+ } finally {
21744
+ rl.close();
21745
+ }
21746
+ }
21747
+
21748
+ // src/cli/manage.ts
21749
+ function assertLocal(what, host, verb) {
21750
+ if (host === void 0 || host === "local") return;
21751
+ throw new CliError(
21752
+ "bad_arguments",
21753
+ `puddle ${verb} ${what} runs on the machine it ${verb}s`,
21754
+ `ssh into ${host} and run it there \u2014 user@host targets the daemon only`
21755
+ );
21756
+ }
21757
+ async function openTransport(host) {
21758
+ const transport = host === void 0 || host === "local" ? new LocalTransport() : new SshTransport(host);
21759
+ if (transport instanceof SshTransport) await transport.open();
21760
+ return transport;
21761
+ }
21762
+ async function resolveDaemonVersion(logger) {
21763
+ try {
21764
+ return await latestReleaseVersion();
21765
+ } catch (e) {
21766
+ const pinned = pinnedDaemonVersion();
21767
+ logger.warn(
21768
+ `cannot resolve the newest release (${e instanceof Error ? e.message : String(e)}) \u2014 using this CLI's version train v${pinned}`
21769
+ );
21770
+ return pinned;
21771
+ }
21772
+ }
21773
+ function pinWarning(logger, version2) {
21774
+ if (version2 === cliVersion()) return;
21775
+ logger.warn(
21776
+ `pinning the daemon to v${version2} while this CLI is v${cliVersion()} \u2014 if the two speak different protocol majors, the next \`puddle launch\` will upgrade the daemon back (or refuse). Pin the CLI too (puddle upgrade cli@v${version2}) or launch with --no-upgrade.`
21777
+ );
21778
+ }
21779
+ async function runInstall(cmd, logger) {
21780
+ if (cmd.what === "desktop") {
21781
+ assertLocal("desktop", cmd.host, "install");
21782
+ if (cmd.tarball !== void 0) {
21783
+ throw new CliError("bad_arguments", "--tarball applies to the daemon only");
21784
+ }
21785
+ return setDesktop({ version: cmd.version, verb: "install" }, logger);
21786
+ }
21787
+ const transport = await openTransport(cmd.host);
21788
+ try {
21789
+ const installed = await installedVersion(transport);
21790
+ if (installed !== null && cmd.version === void 0) {
21791
+ logger.info(
21792
+ `puddled v${installed} is already installed on ${transport.label} \u2014 \`puddle upgrade daemon\` moves it to the newest release`
21793
+ );
21794
+ return 0;
21795
+ }
21796
+ if (installed !== null && installed === cmd.version) {
21797
+ logger.info(`puddled v${installed} is already installed on ${transport.label}`);
21798
+ return 0;
21799
+ }
21800
+ return installDaemonAt(transport, cmd.version, cmd.tarball, logger);
21801
+ } finally {
21802
+ transport.dispose();
21803
+ }
21804
+ }
21805
+ async function installDaemonAt(transport, explicit, tarball, logger) {
21806
+ const version2 = explicit ?? (tarball !== void 0 ? void 0 : await resolveDaemonVersion(logger));
21807
+ if (explicit !== void 0) pinWarning(logger, explicit);
21808
+ const result = await upgradeDaemon(transport, {
21809
+ ...version2 !== void 0 ? { version: version2 } : {},
21810
+ ...tarball !== void 0 ? { tarball } : {},
21811
+ logger
21812
+ });
21813
+ logger.info(`puddled ${result.from ?? "(fresh install)"} \u2192 ${result.to} on ${transport.label}`);
21814
+ return 0;
21815
+ }
21816
+ async function runUpgrade(cmd, logger) {
21817
+ if (cmd.what !== void 0) {
21818
+ if (cmd.what !== "daemon" && cmd.tarball !== void 0) {
21819
+ throw new CliError("bad_arguments", "--tarball applies to the daemon only");
21820
+ }
21821
+ switch (cmd.what) {
21822
+ case "cli":
21823
+ assertLocal("cli", cmd.host, "upgrade");
21824
+ return upgradeCli(cmd.version, logger);
21825
+ case "desktop":
21826
+ assertLocal("desktop", cmd.host, "upgrade");
21827
+ return setDesktop({ version: cmd.version, verb: "upgrade" }, logger);
21828
+ case "daemon": {
21829
+ const transport2 = await openTransport(cmd.host);
21830
+ try {
21831
+ return await installDaemonAt(transport2, cmd.version, cmd.tarball, logger);
21832
+ } finally {
21833
+ transport2.dispose();
21834
+ }
21835
+ }
21836
+ }
21837
+ }
21838
+ if (cmd.host !== void 0 && cmd.host !== "local") {
21839
+ logger.info(`${cmd.host} hosts the daemon only \u2014 upgrading it`);
21840
+ const transport2 = await openTransport(cmd.host);
21841
+ try {
21842
+ return await installDaemonAt(transport2, cmd.version, cmd.tarball, logger);
21843
+ } finally {
21844
+ transport2.dispose();
21845
+ }
21846
+ }
21847
+ const transport = new LocalTransport();
21848
+ const daemonInstalled = await installedVersion(transport) !== null;
21849
+ const desktopInstalled = process.platform === "darwin" && await findInstalledDesktopApp() !== null;
21850
+ if (daemonInstalled) await installDaemonAt(transport, cmd.version, cmd.tarball, logger);
21851
+ else logger.info("no daemon installed on this machine \u2014 skipping (puddle install daemon)");
21852
+ if (desktopInstalled) await setDesktop({ version: cmd.version, verb: "upgrade" }, logger);
21853
+ else if (process.platform === "darwin") logger.info("no desktop app installed \u2014 skipping");
21854
+ return upgradeCli(cmd.version, logger);
21855
+ }
21856
+ function upgradeCli(version2, logger) {
21857
+ const spec = `@puddle-code/cli@${version2 ?? "latest"}`;
21858
+ logger.info(`puddle CLI ${cliVersion()} \u2014 asking npm for ${version2 ?? "the latest release"}`);
21859
+ return new Promise((resolve3, reject) => {
21860
+ const child = spawn7("npm", ["install", "-g", spec], { stdio: "inherit" });
21861
+ child.on(
21862
+ "error",
21863
+ () => reject(
21864
+ new CliError(
21865
+ "not_installed",
21866
+ "npm is not on PATH",
21867
+ `the CLI is installed and upgraded via npm: npm install -g ${spec}`
21868
+ )
21869
+ )
21870
+ );
21871
+ child.on("exit", (code) => {
21872
+ if (code === 0) {
21873
+ logger.info("done \u2014 `puddle --version` shows the installed version");
21874
+ resolve3(0);
21875
+ } else {
21876
+ reject(new CliError("not_installed", `npm install exited with ${code ?? "a signal"}`));
21877
+ }
21878
+ });
21879
+ });
21880
+ }
21881
+ async function setDesktop(opts, logger) {
21882
+ if (process.platform === "linux") {
21883
+ if (opts.verb !== "install") {
21884
+ throw new CliError(
21885
+ "not_installed",
21886
+ "AppImages upgrade from inside the app (its toast knows $APPIMAGE)",
21887
+ "to place a fresh copy: puddle install desktop"
21888
+ );
21889
+ }
21890
+ return installDesktopAppImage(opts.version, logger);
21891
+ }
21892
+ const installed = await findInstalledDesktopApp();
21893
+ const targetPath = installed?.appPath ?? await desktopAppInstallPath();
21894
+ if (targetPath === null) {
21895
+ throw new CliError("not_installed", "the desktop app ships for macOS and Linux only");
21896
+ }
21897
+ if (installed !== null && await isDesktopAppRunning(installed.appPath)) {
21898
+ throw new CliError(
21899
+ "already_running",
21900
+ "Puddle is running \u2014 use its update toast, or quit it and rerun"
21901
+ );
21902
+ }
21903
+ if (opts.verb === "install" && installed !== null && opts.version === void 0) {
21904
+ logger.info(
21905
+ `Puddle ${installed.version} is already installed at ${installed.appPath} \u2014 \`puddle upgrade desktop\` moves it to the newest release`
21906
+ );
21907
+ return 0;
21908
+ }
21909
+ if (installed !== null && installed.version === opts.version) {
21910
+ logger.info(`Puddle ${installed.version} is already installed at ${installed.appPath}`);
21911
+ return 0;
21912
+ }
21913
+ const update = opts.version !== void 0 ? await desktopUpdateAt(opts.version) : await checkForDesktopUpdate(installed?.version ?? "0.0.0");
21914
+ if (update === null) {
21915
+ if (opts.version !== void 0) {
21916
+ throw new CliError(
21917
+ "not_installed",
21918
+ `release v${opts.version} has no desktop build for this platform/architecture`
21919
+ );
21920
+ }
21921
+ if (installed === null) {
21922
+ throw new CliError(
21923
+ "not_installed",
21924
+ "no Puddle desktop release is available for this macOS architecture"
21925
+ );
21926
+ }
21927
+ logger.info(`Puddle ${installed.version} is already the latest release`);
21928
+ return 0;
21929
+ }
21930
+ const move = installed === null ? `installing Puddle ${update.version}` : `Puddle ${installed.version} \u2192 ${update.version}` + (isNewerVersion(update.version, installed.version) ? "" : " (a downgrade)");
21931
+ logger.info(move);
21932
+ const staged = await stageDesktopUpdate(update, { logger });
21933
+ await applyDesktopUpdate(staged, { targetPath, detach: false, relaunch: false, logger });
21934
+ logger.info(`Puddle ${update.version} installed at ${targetPath}`);
21935
+ return 0;
21936
+ }
21937
+ async function installDesktopAppImage(version2, logger) {
21938
+ const update = version2 !== void 0 ? await desktopUpdateAt(version2) : await checkForDesktopUpdate("0.0.0");
21939
+ if (update === null) {
21940
+ throw new CliError(
21941
+ "not_installed",
21942
+ version2 !== void 0 ? `release v${version2} has no AppImage for this architecture` : "no Puddle desktop release is available for this Linux architecture"
21943
+ );
21944
+ }
21945
+ const dir = expandHome2(await ask("Where should the AppImage be stored?", "~/puddle"));
21946
+ const target = join9(dir, "Puddle.AppImage");
21947
+ if (existsSync3(target) && version2 === void 0) {
21948
+ logger.info(`${target} already exists \u2014 the app updates itself in-app (its update toast)`);
21949
+ return 0;
21950
+ }
21951
+ logger.info(`installing Puddle ${update.version} to ${target}`);
21952
+ const staged = await stageDesktopUpdate(update, { logger });
21953
+ await mkdir2(dir, { recursive: true });
21954
+ await rm2(target, { force: true });
21955
+ try {
21956
+ await rename(staged.stagedPath, target);
21957
+ } catch {
21958
+ await copyFile(staged.stagedPath, target);
21959
+ await chmod2(target, 493);
21960
+ }
21961
+ await rm2(staged.dir, { recursive: true, force: true });
21962
+ logger.info(`installed \u2014 run it directly (${target}) or add it to your launcher`);
21963
+ const opener = spawn7("xdg-open", [dir], { stdio: "ignore", detached: true });
21964
+ opener.on("error", () => void 0);
21965
+ opener.unref();
21966
+ return 0;
21967
+ }
21968
+ function expandHome2(path) {
21969
+ if (path === "~") return homedir2();
21970
+ return path.startsWith("~/") ? join9(homedir2(), path.slice(2)) : path;
21971
+ }
21972
+ async function runRemove(cmd, logger) {
21973
+ switch (cmd.what) {
21974
+ case "cli":
21975
+ assertLocal("cli", cmd.host, "remove");
21976
+ return removeCli(cmd.yes, logger);
21977
+ case "desktop":
21978
+ assertLocal("desktop", cmd.host, "remove");
21979
+ return removeDesktop(cmd.yes, logger);
21980
+ case "daemon":
21981
+ return removeDaemonCmd(cmd, logger);
21982
+ }
21983
+ }
21984
+ async function removeCli(yes, logger) {
21985
+ const managed = await new Promise((resolve3) => {
21986
+ const child = spawn7("npm", ["ls", "-g", "@puddle-code/cli", "--depth=0"], { stdio: "ignore" });
21987
+ child.on("error", () => resolve3(false));
21988
+ child.on("exit", (code) => resolve3(code === 0));
21989
+ });
21990
+ if (!managed) {
21991
+ throw new CliError(
21992
+ "not_installed",
21993
+ "the puddle CLI is not an npm global install on this machine",
21994
+ "a repo checkout or linked dev build is removed the way it was installed"
21995
+ );
21996
+ }
21997
+ const proceed = await confirm(
21998
+ `Remove the puddle CLI (npm uninstall -g @puddle-code/cli)? Running cockpits keep running until killed; daemons and their sessions are untouched.`,
21999
+ { skip: yes }
22000
+ );
22001
+ if (!proceed) {
22002
+ logger.info("nothing removed");
22003
+ return 0;
22004
+ }
22005
+ return new Promise((resolve3, reject) => {
22006
+ const child = spawn7("npm", ["uninstall", "-g", "@puddle-code/cli"], { stdio: "inherit" });
22007
+ child.on("error", () => reject(new CliError("not_installed", "npm is not on PATH")));
22008
+ child.on("exit", (code) => {
22009
+ if (code === 0) {
22010
+ logger.info("removed \u2014 daemons keep running (puddle remove daemon uninstalls one)");
22011
+ resolve3(0);
22012
+ } else {
22013
+ reject(new CliError("not_installed", `npm uninstall exited with ${code ?? "a signal"}`));
22014
+ }
22015
+ });
22016
+ });
22017
+ }
22018
+ async function removeDesktop(yes, logger) {
22019
+ if (process.platform !== "darwin") {
22020
+ throw new CliError(
22021
+ "not_installed",
22022
+ "desktop removal is macOS-only",
22023
+ "an AppImage carries no fixed path \u2014 delete the file where you put it (puddle install desktop defaults to ~/puddle/Puddle.AppImage)"
22024
+ );
22025
+ }
22026
+ const installed = await findInstalledDesktopApp();
22027
+ if (installed === null) {
22028
+ logger.info("no Puddle.app in /Applications or ~/Applications \u2014 nothing to remove");
22029
+ return 0;
22030
+ }
22031
+ if (await isDesktopAppRunning(installed.appPath)) {
22032
+ throw new CliError("already_running", "Puddle is running \u2014 quit it first");
22033
+ }
22034
+ const proceed = await confirm(
22035
+ `Delete ${installed.appPath} (Puddle ${installed.version})? Daemons and their sessions are untouched.`,
22036
+ { skip: yes }
22037
+ );
22038
+ if (!proceed) {
22039
+ logger.info("nothing removed");
22040
+ return 0;
22041
+ }
22042
+ await rm2(installed.appPath, { recursive: true, force: true });
22043
+ await pruneDesktopUpdateCache([]);
22044
+ logger.info(`deleted ${installed.appPath} (recent hosts kept in ~/.puddle/recent-hosts.json)`);
22045
+ return 0;
22046
+ }
22047
+ async function removeDaemonCmd(cmd, logger) {
22048
+ const target = cmd.host ?? "local";
22049
+ const transport = await openTransport(cmd.host);
22050
+ let tunnel = null;
22051
+ try {
22052
+ const installed = await installedVersion(transport);
22053
+ if (installed === null) {
22054
+ logger.info(
22055
+ `no bootstrap-managed daemon on ${transport.label} \u2014 nothing to remove (its data, if any, stays under ~/.puddle)`
22056
+ );
22057
+ return 0;
22058
+ }
22059
+ let live = null;
22060
+ let profileNames = null;
22061
+ try {
22062
+ const token = await readToken(transport);
22063
+ if (token !== null) {
22064
+ let port = await readDaemonPort(transport);
22065
+ if (transport instanceof SshTransport) {
22066
+ tunnel = await openTunnel(transport, port, {
22067
+ ready: (localPort) => waitForHttp(`http://127.0.0.1:${localPort}/api/version`, 8e3)
22068
+ });
22069
+ port = tunnel.localPort;
22070
+ }
22071
+ const client = new DaemonClient(port, token);
22072
+ live = await client.liveSessions();
22073
+ profileNames = (await client.profiles()).map((p) => p.name);
22074
+ }
22075
+ } catch {
22076
+ }
22077
+ logger.info(`puddled v${installed} on ${transport.label}`);
22078
+ if (live !== null && live.length > 0) {
22079
+ logger.warn(`${live.length} running session(s) will be interrupted:`);
22080
+ for (const s of live) {
22081
+ logger.warn(` ${s.id.slice(0, 8)} ${s.status} ${s.title ?? "(untitled)"}`);
22082
+ }
22083
+ }
22084
+ if (profileNames !== null && profileNames.length > 0) {
22085
+ logger.info(`profiles on this daemon: ${profileNames.join(", ")}`);
22086
+ }
22087
+ const proceed = await confirm(
22088
+ `Stop puddled v${installed} on ${transport.label}, unregister its supervisor, and uninstall it?`,
22089
+ { skip: cmd.yes }
22090
+ );
22091
+ if (!proceed) {
22092
+ logger.info("nothing removed");
22093
+ return 0;
22094
+ }
22095
+ let purge = cmd.purge;
22096
+ if (!purge && !cmd.yes) {
22097
+ purge = await confirm(
22098
+ `Also delete ~/.puddle on ${transport.label} \u2014 profiles, session history, agent credentials, and WORKTREES?`
22099
+ );
22100
+ }
22101
+ if (purge) {
22102
+ const dirty = await sweepDirtyWorktrees(transport);
22103
+ if (dirty.length > 0) {
22104
+ logger.warn("these worktrees carry uncommitted or unpushed work:");
22105
+ for (const path of dirty) logger.warn(` ${path}`);
22106
+ const anyway = cmd.yes ? true : await confirm("Delete them anyway?");
22107
+ if (!anyway) {
22108
+ purge = false;
22109
+ logger.info("keeping ~/.puddle \u2014 only the install is removed");
22110
+ }
22111
+ }
22112
+ }
22113
+ const record2 = readCockpitRecord(target);
22114
+ if (record2 !== null) {
22115
+ if (await checkCockpit(record2) === "dead") removeCockpitRecord(target);
22116
+ else {
22117
+ await terminateCockpit(record2);
22118
+ logger.info(`stopped the cockpit for ${target}`);
22119
+ }
22120
+ }
22121
+ await tunnel?.close();
22122
+ tunnel = null;
22123
+ await removeDaemon(transport, { purge, logger });
22124
+ logger.info(
22125
+ purge ? `puddled removed from ${transport.label}; ~/.puddle deleted` : `puddled removed from ${transport.label}; data kept \u2014 \`puddle launch\` reinstalls and resumes`
22126
+ );
22127
+ return 0;
22128
+ } finally {
22129
+ await tunnel?.close();
22130
+ transport.dispose();
22131
+ }
22132
+ }
22133
+
21479
22134
  // src/cli/output.ts
21480
22135
  function terminalLogger() {
21481
22136
  return {
@@ -21505,8 +22160,8 @@ function timestampedLogger() {
21505
22160
  // src/cli/run.ts
21506
22161
  function assetsDir() {
21507
22162
  const here = dirname7(fileURLToPath3(import.meta.url));
21508
- for (const candidate of [join9(here, "public"), join9(here, "..", "..", "dist", "public")]) {
21509
- if (existsSync3(join9(candidate, "index.html"))) return candidate;
22163
+ for (const candidate of [join10(here, "public"), join10(here, "..", "..", "dist", "public")]) {
22164
+ if (existsSync4(join10(candidate, "index.html"))) return candidate;
21510
22165
  }
21511
22166
  throw new CliError(
21512
22167
  "not_installed",
@@ -21788,96 +22443,13 @@ async function run2(command) {
21788
22443
  await target.close();
21789
22444
  }
21790
22445
  }
21791
- case "upgrade": {
21792
- const local = command.host === void 0 || command.host === "local";
21793
- if (command.what === "daemon") {
21794
- const transport = local ? new LocalTransport() : new SshTransport(command.host);
21795
- if (transport instanceof SshTransport) await transport.open();
21796
- try {
21797
- const result = await upgradeDaemon(transport, { logger });
21798
- logger.info(
21799
- `puddled ${result.from ?? "(unmanaged)"} \u2192 ${result.to} on ${transport.label}`
21800
- );
21801
- return 0;
21802
- } finally {
21803
- transport.dispose();
21804
- }
21805
- }
21806
- if (!local) {
21807
- throw new CliError(
21808
- "bad_arguments",
21809
- `puddle upgrade ${command.what} runs on the machine it upgrades`,
21810
- `ssh into ${command.host} and run it there \u2014 user@host targets the daemon only`
21811
- );
21812
- }
21813
- return command.what === "cli" ? upgradeCli(logger) : upgradeDesktop(logger);
21814
- }
21815
- }
21816
- }
21817
- function upgradeCli(logger) {
21818
- logger.info(`puddle CLI ${cliVersion()} \u2014 asking npm for the latest release`);
21819
- return new Promise((resolve3, reject) => {
21820
- const child = spawn7("npm", ["install", "-g", "@puddle-code/cli@latest"], {
21821
- stdio: "inherit"
21822
- });
21823
- child.on(
21824
- "error",
21825
- () => reject(
21826
- new CliError(
21827
- "not_installed",
21828
- "npm is not on PATH",
21829
- "the CLI is installed and upgraded via npm: npm install -g @puddle-code/cli@latest"
21830
- )
21831
- )
21832
- );
21833
- child.on("exit", (code) => {
21834
- if (code === 0) {
21835
- logger.info("done \u2014 `puddle --version` shows the installed version");
21836
- resolve3(0);
21837
- } else {
21838
- reject(new CliError("not_installed", `npm install exited with ${code ?? "a signal"}`));
21839
- }
21840
- });
21841
- });
21842
- }
21843
- async function upgradeDesktop(logger) {
21844
- const installed = await findInstalledDesktopApp();
21845
- const targetPath = installed?.appPath ?? await desktopAppInstallPath();
21846
- if (targetPath === null) {
21847
- throw new CliError(
21848
- "not_installed",
21849
- "no Puddle.app in /Applications or ~/Applications",
21850
- process.platform === "linux" ? "AppImages carry no fixed path \u2014 update from the app itself (its banner knows $APPIMAGE)" : void 0
21851
- );
21852
- }
21853
- if (installed !== null && await isDesktopAppRunning(installed.appPath)) {
21854
- throw new CliError(
21855
- "already_running",
21856
- "Puddle is running \u2014 use its update banner, or quit it and rerun"
21857
- );
21858
- }
21859
- const update = await checkForDesktopUpdate(installed?.version ?? "0.0.0");
21860
- if (update === null) {
21861
- if (installed === null) {
21862
- throw new CliError(
21863
- "not_installed",
21864
- "no Puddle desktop release is available for this macOS architecture"
21865
- );
21866
- }
21867
- logger.info(`Puddle ${installed.version} is already the latest release`);
21868
- return 0;
22446
+ case "install":
22447
+ return runInstall(command, logger);
22448
+ case "upgrade":
22449
+ return runUpgrade(command, logger);
22450
+ case "remove":
22451
+ return runRemove(command, logger);
21869
22452
  }
21870
- if (installed === null) logger.info(`installing Puddle ${update.version}`);
21871
- else logger.info(`Puddle ${installed.version} \u2192 ${update.version}`);
21872
- const staged = await stageDesktopUpdate(update, { logger });
21873
- await applyDesktopUpdate(staged, {
21874
- targetPath,
21875
- detach: false,
21876
- relaunch: false,
21877
- logger
21878
- });
21879
- logger.info(`Puddle ${update.version} installed at ${targetPath}`);
21880
- return 0;
21881
22453
  }
21882
22454
  async function runCockpit(command, target, terminal) {
21883
22455
  const detached = isCockpitChild();