@puddle-code/cli 0.0.1 → 0.0.2

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 (54) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +78 -0
  3. package/dist/index.js +758 -155
  4. package/dist/install.sh +8 -0
  5. package/dist/public/assets/Dashboard-CcpYaLlK.js +1 -0
  6. package/dist/public/assets/EditorTabClose-Dal7HKgg.js +1 -0
  7. package/dist/public/assets/ModelRefcount-B12_Xdv2.js +1 -0
  8. package/dist/public/assets/PaneEditorBody-D1npLlye.js +1 -0
  9. package/dist/public/assets/SettingsDialog-Cp_1M1k7.js +1 -0
  10. package/dist/public/assets/{Terminal-CuEf-GeX.js → Terminal-D03wYX9q.js} +2 -2
  11. package/dist/public/assets/Workspace-d0mqka1w.js +6 -0
  12. package/dist/public/assets/api-DnhKCI2U.js +73 -0
  13. package/dist/public/assets/buffer-logic-BrpRsL1b.js +1 -0
  14. package/dist/public/assets/buffer-store-CJsO7gGE.js +16 -0
  15. package/dist/public/assets/context-menu-BWOPZx3M.js +1 -0
  16. package/dist/public/assets/createLucideIcon-DyMAgLa3.js +1 -0
  17. package/dist/public/assets/cssMode-D1iXtOo8.js +1 -0
  18. package/dist/public/assets/debounce-B9DrqirV.js +1 -0
  19. package/dist/public/assets/dialog-B43iKmfK.js +41 -0
  20. package/dist/public/assets/dist-fdPAlKke.js +1 -0
  21. package/dist/public/assets/editor-sync-F3Wyveaa.js +1 -0
  22. package/dist/public/assets/editor-tabs-DHG9ShX4.js +1 -0
  23. package/dist/public/assets/htmlMode-CBFRVNoG.js +1 -0
  24. package/dist/public/assets/index-BcSTVRMs.js +10 -0
  25. package/dist/public/assets/index-D9uQqhHq.css +2 -0
  26. package/dist/public/assets/{jsonMode-BijgXT6I.js → jsonMode-CpQhQE0R.js} +1 -1
  27. package/dist/public/assets/jsx-runtime-ffCh33zF.js +1 -0
  28. package/dist/public/assets/react-CZI7_Jkm.js +1 -0
  29. package/dist/public/assets/ssh-mode-C_dNI0ql.js +1 -0
  30. package/dist/public/assets/switch-RkFdxF0W.js +1 -0
  31. package/dist/public/assets/{tsMode-B7q3gz7a.js → tsMode-ET2kHbb0.js} +1 -1
  32. package/dist/public/assets/useMutation-Da9b4jwS.js +1 -0
  33. package/dist/public/assets/utils-B6KiDbIe.js +1 -0
  34. package/dist/public/index.html +9 -4
  35. package/package.json +14 -3
  36. package/dist/index.d.ts +0 -3
  37. package/dist/index.d.ts.map +0 -1
  38. package/dist/index.js.map +0 -1
  39. package/dist/public/assets/Dashboard-B1JE6YHV.js +0 -1
  40. package/dist/public/assets/EditorZone-BO-Ob9Td.js +0 -16
  41. package/dist/public/assets/SettingsDialog-CVcCiV13.js +0 -1
  42. package/dist/public/assets/Workspace-DctKubgC.js +0 -2
  43. package/dist/public/assets/api-DeKerbkb.js +0 -64
  44. package/dist/public/assets/cssMode-CSjHtANE.js +0 -1
  45. package/dist/public/assets/dialog-VTyOt2M_.js +0 -41
  46. package/dist/public/assets/folder-git-2-DX3SNYUW.js +0 -1
  47. package/dist/public/assets/history-logic-B2ZKE3AW.js +0 -1
  48. package/dist/public/assets/htmlMode-CIHMS93l.js +0 -1
  49. package/dist/public/assets/index-CXBMvmhw.css +0 -2
  50. package/dist/public/assets/index-ZcdMfr13.js +0 -10
  51. package/dist/public/assets/monaco.contribution-C0TYpF88.js +0 -2
  52. package/dist/public/assets/ssh-mode-BR2ZdOHI.js +0 -1
  53. package/dist/public/assets/switch-Ck_7k8fh.js +0 -1
  54. package/dist/public/assets/workers-Ck67TJT4.js +0 -1
package/dist/index.js CHANGED
@@ -3908,9 +3908,12 @@ init_types();
3908
3908
  var USAGE = `puddle \u2014 self-hosted orchestrator for CLI coding agents
3909
3909
 
3910
3910
  usage:
3911
- puddle start [--port <p>] [--no-browser] [--no-upgrade] [--tarball <path>]
3912
- puddle connect user@host [--port <local>] [--remote-port <p>] [--no-browser]
3913
- [--no-upgrade] [--tarball <path>]
3911
+ puddle start [--port <p>] [--foreground] [--no-browser] [--no-upgrade]
3912
+ [--tarball <path>]
3913
+ puddle connect user@host [--port <local>] [--remote-port <p>] [--foreground]
3914
+ [--no-browser] [--no-upgrade] [--tarball <path>]
3915
+ puddle list
3916
+ puddle kill [local | user@host | --all]
3914
3917
  puddle status [user@host]
3915
3918
  puddle attach [user@host] <session> [--term <id>]
3916
3919
  puddle logs [user@host] [session] [--term <id>] [-f|--follow]
@@ -3919,8 +3922,10 @@ usage:
3919
3922
 
3920
3923
  start serves the cockpit at http://localhost:7433 against the daemon on this
3921
3924
  machine (installing it under ~/.puddle if needed); connect does the same for
3922
- an SSH host through one tunnel. Ctrl-C stops the cockpit only \u2014 sessions keep
3923
- running on the host.`;
3925
+ an SSH host through one tunnel. Both keep running in the background once
3926
+ ready, so the terminal may close (--foreground to stay attached; Ctrl-C then
3927
+ stops the cockpit). list shows running cockpits; kill stops one \u2014 sessions
3928
+ keep running on the host either way.`;
3924
3929
  function parseArgs(argv) {
3925
3930
  const [cmd, ...rest] = argv;
3926
3931
  if (cmd === void 0 || cmd === "help" || cmd === "--help" || cmd === "-h")
@@ -3977,13 +3982,14 @@ function parseArgs(argv) {
3977
3982
  }
3978
3983
  const port = intFlag("--port");
3979
3984
  const tarball = strFlag("--tarball");
3980
- expect("--port", "--no-browser", "--no-upgrade", "--tarball");
3985
+ expect("--port", "--no-browser", "--no-upgrade", "--tarball", "--foreground");
3981
3986
  return {
3982
3987
  cmd: "start",
3983
3988
  ...port !== void 0 ? { port } : {},
3984
3989
  ...tarball !== void 0 ? { tarball } : {},
3985
3990
  noBrowser: flags.has("--no-browser"),
3986
- noUpgrade: flags.has("--no-upgrade")
3991
+ noUpgrade: flags.has("--no-upgrade"),
3992
+ foreground: flags.has("--foreground")
3987
3993
  };
3988
3994
  }
3989
3995
  case "connect": {
@@ -3995,7 +4001,14 @@ function parseArgs(argv) {
3995
4001
  const port = intFlag("--port");
3996
4002
  const remotePort = intFlag("--remote-port");
3997
4003
  const tarball = strFlag("--tarball");
3998
- expect("--port", "--remote-port", "--no-browser", "--no-upgrade", "--tarball");
4004
+ expect(
4005
+ "--port",
4006
+ "--remote-port",
4007
+ "--no-browser",
4008
+ "--no-upgrade",
4009
+ "--tarball",
4010
+ "--foreground"
4011
+ );
3999
4012
  return {
4000
4013
  cmd: "connect",
4001
4014
  host,
@@ -4003,9 +4016,26 @@ function parseArgs(argv) {
4003
4016
  ...remotePort !== void 0 ? { remotePort } : {},
4004
4017
  ...tarball !== void 0 ? { tarball } : {},
4005
4018
  noBrowser: flags.has("--no-browser"),
4006
- noUpgrade: flags.has("--no-upgrade")
4019
+ noUpgrade: flags.has("--no-upgrade"),
4020
+ foreground: flags.has("--foreground")
4007
4021
  };
4008
4022
  }
4023
+ case "list": {
4024
+ if (positionals.length > 0)
4025
+ throw new CliError("bad_arguments", "list takes no positional arguments");
4026
+ expect();
4027
+ return { cmd: "list" };
4028
+ }
4029
+ case "kill": {
4030
+ if (positionals.length > 1)
4031
+ throw new CliError("bad_arguments", "kill takes at most one target");
4032
+ expect("--all");
4033
+ const all = flags.has("--all");
4034
+ const target = positionals[0];
4035
+ if (all && target !== void 0)
4036
+ throw new CliError("bad_arguments", "kill takes a target or --all, not both");
4037
+ return { cmd: "kill", all, ...target !== void 0 ? { target } : {} };
4038
+ }
4009
4039
  case "status": {
4010
4040
  if (positionals.length > 1)
4011
4041
  throw new CliError("bad_arguments", "status takes at most a host");
@@ -4062,7 +4092,7 @@ function parseArgs(argv) {
4062
4092
  // src/cli/run.ts
4063
4093
  init_attach();
4064
4094
  import { existsSync as existsSync3 } from "node:fs";
4065
- import { dirname as dirname4, join as join5 } from "node:path";
4095
+ import { dirname as dirname5, join as join6 } from "node:path";
4066
4096
  import { fileURLToPath as fileURLToPath3 } from "node:url";
4067
4097
 
4068
4098
  // src/lib/browser.ts
@@ -4081,7 +4111,7 @@ function openBrowser(url2, platform = process.platform) {
4081
4111
  }
4082
4112
 
4083
4113
  // ../shared/src/protocol.ts
4084
- var PROTOCOL_VERSION = { major: 5, minor: 1 };
4114
+ var PROTOCOL_VERSION = { major: 6, minor: 0 };
4085
4115
 
4086
4116
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
4087
4117
  var external_exports = {};
@@ -18627,7 +18657,14 @@ var fields = {
18627
18657
  logMaxBytes: external_exports.number().int().min(64 * 1024),
18628
18658
  replayBytes: external_exports.number().int().min(1024),
18629
18659
  /** Days a (project, profile) ui_state row survives without an update. */
18630
- uiStateRetentionDays: external_exports.number().int().min(1)
18660
+ uiStateRetentionDays: external_exports.number().int().min(1),
18661
+ /**
18662
+ * Colon-separated dirs prepended to the daemon's PATH so it can find agent
18663
+ * CLIs even under a supervisor with a bare PATH (launchd/systemd). Leading `~`
18664
+ * expands to the daemon user's home. Covers Claude Code's native-installer
18665
+ * location (`~/.local/bin`). Takes effect on the next daemon start.
18666
+ */
18667
+ agentPath: external_exports.string()
18631
18668
  };
18632
18669
  var daemonConfigSchema = external_exports.object({
18633
18670
  port: fields.port.default(7434),
@@ -18635,7 +18672,8 @@ var daemonConfigSchema = external_exports.object({
18635
18672
  fetchIntervalMinutes: fields.fetchIntervalMinutes.default(15),
18636
18673
  logMaxBytes: fields.logMaxBytes.default(10 * 1024 * 1024),
18637
18674
  replayBytes: fields.replayBytes.default(256 * 1024),
18638
- uiStateRetentionDays: fields.uiStateRetentionDays.default(90)
18675
+ uiStateRetentionDays: fields.uiStateRetentionDays.default(90),
18676
+ agentPath: fields.agentPath.default("~/.local/bin:~/bin:/opt/homebrew/bin:/usr/local/bin")
18639
18677
  });
18640
18678
  var daemonConfigPatchSchema = external_exports.object(fields).partial();
18641
18679
 
@@ -18659,6 +18697,17 @@ var hostInfoSchema = external_exports.object({
18659
18697
 
18660
18698
  // ../shared/src/api/profiles.ts
18661
18699
  var fsSafeName = external_exports.string().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "letters, digits, dot, underscore and hyphen only");
18700
+ var RULES_TOKEN = "{{rules}}";
18701
+ var DEFAULT_ONBOARDING_TEMPLATE = `[puddle onboarding] This is a freshly created git worktree for this task. Before starting, set up the environment:
18702
+
18703
+ 1. Standing setup rules for this repository (user-authored):
18704
+ ${RULES_TOKEN}
18705
+
18706
+ 2. Inspect the codebase for setup requirements (README, CONTRIBUTING, lockfiles, .tool-versions, pyproject.toml, package.json, \u2026).
18707
+ 3. Apply what the rules above settle without asking. Ask the user about anything they leave open, stating trade-offs where relevant (e.g. a symlinked .venv saves gigabytes per worktree, but parallel sessions then share mutable dependency state).
18708
+ 4. If the user states a standing rule for all future worktrees ("always\u2026", "never\u2026", "from now on\u2026"), write the complete updated rules to \`.puddle/onboarding-notes.md\` in this worktree \u2014 full replacement, user-owned prose; record their decision, don't invent policy.
18709
+
18710
+ Then proceed with the task below (or await instructions if none is given).`;
18662
18711
  var profileSchema = external_exports.object({
18663
18712
  id: profileId,
18664
18713
  name: fsSafeName,
@@ -18670,10 +18719,26 @@ var createProfileRequestSchema = external_exports.object({
18670
18719
  branch_prefix: external_exports.string().max(64).optional()
18671
18720
  });
18672
18721
  var patchProfileRequestSchema = external_exports.object({
18673
- branch_prefix: external_exports.string().max(64)
18722
+ name: fsSafeName.optional(),
18723
+ branch_prefix: external_exports.string().max(64).optional()
18674
18724
  });
18675
18725
  var profileSettingsSchema = external_exports.looseObject({
18676
- allowSkipPermissions: external_exports.boolean().default(false)
18726
+ allowSkipPermissions: external_exports.boolean().default(false),
18727
+ /**
18728
+ * Launch-text templates (SPEC §4). A key that is ABSENT falls back to the
18729
+ * daemon's built-in default; an EMPTY string is an intentional empty preamble.
18730
+ * `onboardingTemplate` (freshly created worktree) supports a `{{rules}}` token
18731
+ * where the repo's onboarding notes are injected; `concurrentTemplate` is used
18732
+ * when a session joins an existing/shared worktree.
18733
+ */
18734
+ onboardingTemplate: external_exports.string().optional(),
18735
+ concurrentTemplate: external_exports.string().optional(),
18736
+ /**
18737
+ * The user's drag-order of projects on the homescreen (project ids). Projects
18738
+ * absent from this list — newly created ones — sort to the top. The right
18739
+ * sidebar's cross-project grouping inherits this order (SPEC §11, §12).
18740
+ */
18741
+ projectOrder: external_exports.array(external_exports.string()).optional()
18677
18742
  });
18678
18743
  var patchProfileSettingsRequestSchema = external_exports.record(external_exports.string(), external_exports.unknown());
18679
18744
 
@@ -18836,7 +18901,15 @@ var sessionSchema = external_exports.object({
18836
18901
  /** Null for terminal sessions, which run a plain shell rather than an agent. */
18837
18902
  agent_type: external_exports.string().nullable(),
18838
18903
  agent_session_ref: external_exports.string().nullable(),
18904
+ /** User-set display override; null means "use the agent's own name" (agent_title) then the id. */
18839
18905
  title: external_exports.string().nullable(),
18906
+ /**
18907
+ * The agent's own session name (for Claude Code, the transcript's ai-title /
18908
+ * agent-name — the name shown in its resume picker), maintained by the daemon.
18909
+ * The default display name when the user has not set a `title`. Optional:
18910
+ * older daemons omit it; null when the agent has not named the session yet.
18911
+ */
18912
+ agent_title: external_exports.string().nullable().optional(),
18840
18913
  status: sessionStatusSchema,
18841
18914
  skip_permissions: external_exports.boolean(),
18842
18915
  created_at: isoTimestamp,
@@ -18898,7 +18971,7 @@ var createSessionRequestSchema = external_exports.object({
18898
18971
  prompt: external_exports.string().optional(),
18899
18972
  skip_permissions: external_exports.boolean().optional()
18900
18973
  });
18901
- var patchSessionRequestSchema = external_exports.object({ title: external_exports.string().min(1).max(200) });
18974
+ var patchSessionRequestSchema = external_exports.object({ title: external_exports.string().max(200) });
18902
18975
  var migrateSessionRequestSchema = external_exports.object({ account_id: rowId });
18903
18976
  var archiveRequestSchema = external_exports.object({
18904
18977
  force: external_exports.boolean().default(false),
@@ -18916,6 +18989,12 @@ var projectSchema = external_exports.object({
18916
18989
  profile_id: profileId,
18917
18990
  repo_id: rowId,
18918
18991
  name: external_exports.string(),
18992
+ /**
18993
+ * Hidden from the homescreen but never deleted — every session, worktree, and
18994
+ * bit of data is retained, and un-archiving restores it all (SPEC §11).
18995
+ * Defaults false so an older daemon that omits it reads as not-archived.
18996
+ */
18997
+ archived: external_exports.boolean().default(false),
18919
18998
  created_at: isoTimestamp,
18920
18999
  updated_at: isoTimestamp
18921
19000
  });
@@ -18924,6 +19003,10 @@ var createProjectRequestSchema = external_exports.object({
18924
19003
  repo_id: rowId,
18925
19004
  name: external_exports.string().min(1).max(100)
18926
19005
  });
19006
+ var patchProjectRequestSchema = external_exports.object({
19007
+ name: external_exports.string().min(1).max(100).optional(),
19008
+ archived: external_exports.boolean().optional()
19009
+ });
18927
19010
  var projectDetailSchema = external_exports.object({
18928
19011
  project: projectSchema,
18929
19012
  sessions: external_exports.array(sessionSchema)
@@ -18936,6 +19019,28 @@ var editorTabRefSchema = external_exports.object({
18936
19019
  kind: external_exports.enum(["file", "diff", "commit"]).optional(),
18937
19020
  sha: external_exports.string().optional()
18938
19021
  });
19022
+ var tabRefSchema = external_exports.discriminatedUnion("type", [
19023
+ external_exports.object({ type: external_exports.literal("editor"), tab: editorTabRefSchema }),
19024
+ external_exports.object({ type: external_exports.literal("terminal"), session: sessionId })
19025
+ ]);
19026
+ var layoutLeafSchema = external_exports.object({
19027
+ kind: external_exports.literal("leaf"),
19028
+ id: external_exports.string(),
19029
+ tabs: external_exports.array(tabRefSchema).default([]),
19030
+ activeKey: external_exports.string().nullable().default(null)
19031
+ });
19032
+ var layoutNodeSchema = external_exports.lazy(
19033
+ () => external_exports.discriminatedUnion("kind", [
19034
+ layoutLeafSchema,
19035
+ external_exports.object({
19036
+ kind: external_exports.literal("split"),
19037
+ id: external_exports.string(),
19038
+ direction: external_exports.enum(["row", "col"]),
19039
+ children: external_exports.array(layoutNodeSchema),
19040
+ sizes: external_exports.array(external_exports.number())
19041
+ })
19042
+ ])
19043
+ );
18939
19044
  var uiStateSnapshotSchema = external_exports.looseObject({
18940
19045
  session_tabs: external_exports.array(sessionId).default([]),
18941
19046
  active_session: sessionId.nullable().default(null),
@@ -18962,7 +19067,13 @@ var uiStateSnapshotSchema = external_exports.looseObject({
18962
19067
  * listed here (newly created ones) sort to the top; the list is otherwise
18963
19068
  * drag-reorderable and this persists it (SPEC §8).
18964
19069
  */
18965
- session_order: external_exports.array(sessionId).default([])
19070
+ session_order: external_exports.array(sessionId).default([]),
19071
+ /**
19072
+ * The tiling layout tree (SPEC §8): the source of truth for which tabs are
19073
+ * open and where. Null on legacy snapshots — the web rebuilds an equivalent
19074
+ * tree from `editor_tabs`/`session_tabs`/`layout` on first load.
19075
+ */
19076
+ layout_tree: layoutNodeSchema.nullable().default(null)
18966
19077
  });
18967
19078
  var putProjectStateRequestSchema = external_exports.object({
18968
19079
  ui_state: uiStateSnapshotSchema
@@ -19028,6 +19139,27 @@ var uploadResponseSchema = external_exports.object({
19028
19139
  )
19029
19140
  });
19030
19141
 
19142
+ // ../shared/src/api/worktree-fs.ts
19143
+ var createEntryRequestSchema = external_exports.object({
19144
+ path: external_exports.string().min(1),
19145
+ kind: external_exports.enum(["file", "dir"])
19146
+ });
19147
+ var renameEntryRequestSchema = external_exports.object({
19148
+ from: external_exports.string().min(1),
19149
+ to: external_exports.string().min(1)
19150
+ });
19151
+ var copyEntryRequestSchema = external_exports.object({
19152
+ from: external_exports.string().min(1),
19153
+ to: external_exports.string().min(1)
19154
+ });
19155
+ var deleteEntryRequestSchema = external_exports.object({
19156
+ path: external_exports.string().min(1)
19157
+ });
19158
+ var fsOpResponseSchema = external_exports.object({
19159
+ ok: external_exports.literal(true),
19160
+ path: external_exports.string()
19161
+ });
19162
+
19031
19163
  // ../shared/src/api/worktree-git.ts
19032
19164
  var diffStatusSchema = external_exports.enum(["added", "modified", "deleted", "renamed"]);
19033
19165
  var diffEntrySchema = external_exports.object({
@@ -19043,6 +19175,22 @@ var diffResponseSchema = external_exports.object({
19043
19175
  base_ref: external_exports.string().nullable(),
19044
19176
  entries: external_exports.array(diffEntrySchema)
19045
19177
  });
19178
+ var gitStatusSchema = external_exports.enum([
19179
+ "untracked",
19180
+ "modified",
19181
+ "added",
19182
+ "deleted",
19183
+ "renamed",
19184
+ "conflicted",
19185
+ "ignored"
19186
+ ]);
19187
+ var gitStatusEntrySchema = external_exports.object({
19188
+ path: external_exports.string(),
19189
+ status: gitStatusSchema
19190
+ });
19191
+ var gitStatusResponseSchema = external_exports.object({
19192
+ entries: external_exports.array(gitStatusEntrySchema)
19193
+ });
19046
19194
  var fileAtResponseSchema = external_exports.object({
19047
19195
  path: external_exports.string(),
19048
19196
  ref: external_exports.string(),
@@ -19169,6 +19317,7 @@ var hostPaths = {
19169
19317
  home: HOST_HOME,
19170
19318
  token: `${HOST_HOME}/token`,
19171
19319
  config: `${HOST_HOME}/config.json`,
19320
+ runtime: `${HOST_HOME}/runtime.json`,
19172
19321
  current: `${HOST_HOME}/bin/current`,
19173
19322
  cache: `${HOST_HOME}/cache`,
19174
19323
  logs: `${HOST_HOME}/logs`
@@ -19233,6 +19382,14 @@ var DaemonClient = class {
19233
19382
  }
19234
19383
  };
19235
19384
  async function readDaemonPort(transport) {
19385
+ const runtime = await transport.readFile(hostPaths.runtime);
19386
+ if (runtime !== null) {
19387
+ try {
19388
+ const parsed = JSON.parse(runtime);
19389
+ if (typeof parsed.port === "number") return parsed.port;
19390
+ } catch {
19391
+ }
19392
+ }
19236
19393
  const raw = await transport.readFile(hostPaths.config);
19237
19394
  if (raw !== null) {
19238
19395
  try {
@@ -19273,7 +19430,7 @@ import { readFileSync } from "node:fs";
19273
19430
  import { dirname, join as join2 } from "node:path";
19274
19431
  import { fileURLToPath } from "node:url";
19275
19432
  function cliVersion() {
19276
- if (true) return "0.0.1";
19433
+ if (true) return "0.0.2";
19277
19434
  const here = dirname(fileURLToPath(import.meta.url));
19278
19435
  for (const candidate of [
19279
19436
  join2(here, "..", "..", "package.json"),
@@ -19330,18 +19487,18 @@ async function installDaemon(transport, opts = {}) {
19330
19487
  }
19331
19488
  }
19332
19489
  } else {
19333
- const slug = repoSlug();
19490
+ const slug2 = repoSlug();
19334
19491
  if (opts.tarball !== void 0) {
19335
19492
  throw new CliError("bad_arguments", "--tarball must be a local file path");
19336
19493
  }
19337
- if (slug === void 0) {
19494
+ if (slug2 === void 0) {
19338
19495
  throw new CliError(
19339
19496
  "not_installed",
19340
19497
  "no release source is configured for this build",
19341
19498
  "pass --tarball <path> (pnpm build:tarball produces one) or set PUDDLE_REPO=owner/repo"
19342
19499
  );
19343
19500
  }
19344
- args.push("--repo", slug);
19501
+ args.push("--repo", slug2);
19345
19502
  }
19346
19503
  logger.info(`installing puddled ${version2} on ${transport.label}`);
19347
19504
  const quoted = args.map((a) => `'${a.replaceAll("'", `'\\''`)}'`).join(" ");
@@ -19387,42 +19544,63 @@ async function ensureDaemon(transport, opts) {
19387
19544
  await installDaemon(transport, opts);
19388
19545
  bootstrapped = true;
19389
19546
  token = await waitForToken(transport, 2e4);
19547
+ const up = await waitForHostProbe(transport, token, 2e4);
19548
+ if (up.result === "unauthorised") throw portConflict(transport, up.port);
19549
+ if (up.result !== "ok") throw startTimeout(transport);
19550
+ return { port: up.port, token, bootstrapped };
19390
19551
  }
19391
19552
  let port = await readDaemonPort(transport);
19392
- if (!await hostProbe(transport, port)) {
19553
+ const probe = await hostProbe(transport, port, token);
19554
+ if (probe === "unauthorised") throw portConflict(transport, port);
19555
+ if (probe === "down") {
19393
19556
  if (await installedVersion(transport) === null) {
19394
- throw new CliError(
19395
- "daemon_unreachable",
19396
- `a puddle state directory exists on ${transport.label} but no managed daemon answers on port ${port}`,
19397
- "a development daemon must be started by hand; otherwise remove ~/.puddle/bin and re-run to bootstrap"
19398
- );
19557
+ logger.info(`no managed daemon on ${transport.label} \u2014 bootstrapping one`);
19558
+ } else {
19559
+ logger.info(`puddled is installed on ${transport.label} but not running \u2014 restarting it`);
19399
19560
  }
19400
- logger.info(`puddled is installed on ${transport.label} but not running \u2014 restarting it`);
19401
19561
  await installDaemon(transport, opts);
19402
19562
  bootstrapped = true;
19403
- port = await readDaemonPort(transport);
19404
- if (!await waitForHostProbe(transport, port, 2e4)) {
19405
- throw new CliError(
19406
- "daemon_start_timeout",
19407
- `puddled did not come up on ${transport.label}`,
19408
- transport.kind === "ssh" ? `inspect it with: puddle logs ${transport.label}` : "inspect it with: puddle logs"
19409
- );
19410
- }
19563
+ token = await readToken(transport) ?? token;
19564
+ const after = await waitForHostProbe(transport, token, 2e4);
19565
+ if (after.result === "unauthorised") throw portConflict(transport, after.port);
19566
+ if (after.result !== "ok") throw startTimeout(transport);
19567
+ port = after.port;
19411
19568
  }
19412
19569
  return { port, token, bootstrapped };
19413
19570
  }
19414
- async function hostProbe(transport, port) {
19415
- const cmd = `(command -v curl >/dev/null 2>&1 && curl -s -o /dev/null --max-time 2 http://127.0.0.1:${port}/api/version && echo OK) || (command -v bash >/dev/null 2>&1 && bash -c 'exec 3<>/dev/tcp/127.0.0.1/${port}' 2>/dev/null && echo OK) || true`;
19416
- const result = await transport.exec(cmd, { timeoutMs: 1e4 });
19417
- return result.stdout.includes("OK");
19571
+ function startTimeout(transport) {
19572
+ return new CliError(
19573
+ "daemon_start_timeout",
19574
+ `puddled did not come up on ${transport.label}`,
19575
+ transport.kind === "ssh" ? `inspect it with: puddle logs ${transport.label}` : "inspect it with: puddle logs"
19576
+ );
19418
19577
  }
19419
- async function waitForHostProbe(transport, port, timeoutMs) {
19578
+ function portConflict(transport, port) {
19579
+ return new CliError(
19580
+ "port_in_use",
19581
+ `something on ${transport.label} answers on 127.0.0.1:${port} but rejects this host's token`,
19582
+ `it is probably not this host's daemon \u2014 another puddle cockpit's UI server that auto-picked ${port}, or a daemon started before the token changed. Close it (or restart it), or point the daemon at another port in ~/.puddle/config.json.`
19583
+ );
19584
+ }
19585
+ async function hostProbe(transport, port, token) {
19586
+ const cmd = `if command -v curl >/dev/null 2>&1; then curl -s -o /dev/null --max-time 2 -w 'HTTP:%{http_code}' -H 'Authorization: Bearer ${token}' http://127.0.0.1:${port}/api/version || echo DOWN; elif command -v bash >/dev/null 2>&1 && bash -c 'exec 3<>/dev/tcp/127.0.0.1/${port}' 2>/dev/null; then echo TCPOPEN; else echo DOWN; fi`;
19587
+ const out = (await transport.exec(cmd, { timeoutMs: 1e4 })).stdout;
19588
+ if (out.includes("HTTP:200")) return "ok";
19589
+ if (out.includes("HTTP:401") || out.includes("HTTP:403")) return "unauthorised";
19590
+ if (out.includes("TCPOPEN")) return "ok";
19591
+ return "down";
19592
+ }
19593
+ async function waitForHostProbe(transport, token, timeoutMs) {
19420
19594
  const deadline = Date.now() + timeoutMs;
19595
+ let last = "down";
19596
+ let port = await readDaemonPort(transport);
19421
19597
  while (Date.now() < deadline) {
19422
- if (await hostProbe(transport, port)) return true;
19598
+ port = await readDaemonPort(transport);
19599
+ last = await hostProbe(transport, port, token);
19600
+ if (last !== "down") return { result: last, port };
19423
19601
  await sleep(500);
19424
19602
  }
19425
- return false;
19603
+ return { result: last, port };
19426
19604
  }
19427
19605
  function makeUpgrader(transport, client, opts) {
19428
19606
  return async () => {
@@ -19484,6 +19662,7 @@ async function runHandshake(opts) {
19484
19662
 
19485
19663
  // src/lib/serve/ui-server.ts
19486
19664
  init_types();
19665
+ import { randomUUID } from "node:crypto";
19487
19666
  import { createServer as createServer2 } from "node:http";
19488
19667
 
19489
19668
  // src/lib/serve/guard.ts
@@ -19504,6 +19683,24 @@ function isLocalOrigin(origin) {
19504
19683
  }
19505
19684
  }
19506
19685
 
19686
+ // src/lib/serve/proxy-recovery.ts
19687
+ var PROXY_PAGE = /^\/proxy\/([^/]+)\/(\d+)(?:\/|$)/;
19688
+ function recoverProxiedPath(referer, requestUrl) {
19689
+ if (typeof referer !== "string" || !requestUrl.startsWith("/")) return null;
19690
+ const pathname = requestUrl.split("?")[0] ?? "";
19691
+ if (pathname === "/proxy" || pathname.startsWith("/proxy/")) return null;
19692
+ let ref;
19693
+ try {
19694
+ ref = new URL(referer);
19695
+ } catch {
19696
+ return null;
19697
+ }
19698
+ if (!isLocalHostHeader(ref.host)) return null;
19699
+ const match = PROXY_PAGE.exec(ref.pathname);
19700
+ if (match === null) return null;
19701
+ return `/proxy/${match[1]}/${match[2]}${requestUrl}`;
19702
+ }
19703
+
19507
19704
  // src/lib/serve/static.ts
19508
19705
  import { createReadStream, existsSync as existsSync2, statSync } from "node:fs";
19509
19706
  import { extname, join as join4, normalize, resolve as resolve2, sep } from "node:path";
@@ -19527,9 +19724,21 @@ function createStaticHandler(rootDir) {
19527
19724
  return;
19528
19725
  }
19529
19726
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
19530
- const requested = normalize(join4(root, decodeURIComponent(pathname)));
19727
+ let decoded;
19728
+ try {
19729
+ decoded = decodeURIComponent(pathname);
19730
+ } catch {
19731
+ res.writeHead(404, { "content-type": "text/plain" }).end("not found");
19732
+ return;
19733
+ }
19734
+ const requested = normalize(join4(root, decoded));
19531
19735
  const candidate = requested.startsWith(root + sep) || requested === root ? requested : root;
19532
- const file2 = existsSync2(candidate) && statSync(candidate).isFile() ? candidate : join4(root, "index.html");
19736
+ const isFile = existsSync2(candidate) && statSync(candidate).isFile();
19737
+ if (!isFile && extname(pathname) !== "") {
19738
+ res.writeHead(404, { "content-type": "text/plain" }).end("not found");
19739
+ return;
19740
+ }
19741
+ const file2 = isFile ? candidate : join4(root, "index.html");
19533
19742
  if (!existsSync2(file2)) {
19534
19743
  res.writeHead(404, { "content-type": "text/plain" }).end("not found");
19535
19744
  return;
@@ -19691,8 +19900,16 @@ async function startUiServer(opts) {
19691
19900
  const target = { ...opts.target };
19692
19901
  const tracker = new ProxySocketTracker();
19693
19902
  const serveStatic = createStaticHandler(opts.assetsDir);
19903
+ const nonce = randomUUID();
19694
19904
  const server = createServer2((req, res) => {
19905
+ res.setHeader("x-puddle-cockpit", nonce);
19695
19906
  const url2 = req.url ?? "/";
19907
+ const recovered = recoverProxiedPath(req.headers.referer, url2);
19908
+ if (recovered !== null) {
19909
+ res.writeHead(307, { location: recovered });
19910
+ res.end();
19911
+ return;
19912
+ }
19696
19913
  if (isProxiedPath(url2)) {
19697
19914
  if (!isLocalHostHeader(req.headers.host) || !isLocalOrigin(req.headers.origin)) {
19698
19915
  res.writeHead(403, { "content-type": "application/json" });
@@ -19715,10 +19932,16 @@ async function startUiServer(opts) {
19715
19932
  }
19716
19933
  proxyUpgrade(req, socket, head, target, tracker);
19717
19934
  });
19718
- const port = await listen(server, opts.port ?? DEFAULT_UI_PORT, opts.strictPort ?? false);
19935
+ const port = await listen(
19936
+ server,
19937
+ opts.port ?? DEFAULT_UI_PORT,
19938
+ opts.strictPort ?? false,
19939
+ opts.avoidPort
19940
+ );
19719
19941
  return {
19720
19942
  port,
19721
19943
  origin: `http://localhost:${port}`,
19944
+ nonce,
19722
19945
  setTarget(next) {
19723
19946
  target.host = next.host;
19724
19947
  target.port = next.port;
@@ -19732,9 +19955,10 @@ async function startUiServer(opts) {
19732
19955
  }
19733
19956
  };
19734
19957
  }
19735
- async function listen(server, startPort, strict) {
19958
+ async function listen(server, startPort, strict, avoidPort) {
19736
19959
  for (let probe = 0; probe < MAX_PORT_PROBES; probe += 1) {
19737
19960
  const port = startPort + probe;
19961
+ if (!strict && port === avoidPort) continue;
19738
19962
  const ok = await new Promise((resolve3, rejectListen) => {
19739
19963
  const onError = (err) => {
19740
19964
  server.off("listening", onListening);
@@ -19769,71 +19993,124 @@ import { spawn as spawn2 } from "node:child_process";
19769
19993
  init_types();
19770
19994
  var RECONNECT_INITIAL_MS = 500;
19771
19995
  var RECONNECT_MAX_MS = 1e4;
19996
+ var DOWN_GRACE_MS = 2e3;
19997
+ var STABLE_MS = 3e4;
19772
19998
  async function openTunnel(ssh, remotePort, opts = {}) {
19773
19999
  const logger = opts.logger ?? silentLogger;
19774
20000
  const sshBinary = opts.sshBinary ?? "ssh";
20001
+ const downGraceMs = opts.downGraceMs ?? DOWN_GRACE_MS;
19775
20002
  const eventCbs = /* @__PURE__ */ new Set();
19776
20003
  const portCbs = /* @__PURE__ */ new Set();
19777
20004
  const emit = (e) => eventCbs.forEach((cb) => cb(e));
19778
20005
  let localPort = await findFreePort();
19779
20006
  const state = { child: null };
19780
20007
  let stopping = false;
20008
+ let lastRestoredAt = 0;
20009
+ let reconnectActive = false;
19781
20010
  const spawnForward = async (port) => {
20011
+ if (stopping) return false;
19782
20012
  const child = spawn2(
19783
20013
  sshBinary,
19784
- ssh.args("-N", "-L", `${port}:127.0.0.1:${remotePort}`, ssh.host),
20014
+ // ExitOnForwardFailure: a forward that cannot bind must die (and be
20015
+ // seen to die) rather than linger as an ssh with no -L behind it.
20016
+ ssh.args(
20017
+ "-o",
20018
+ "ExitOnForwardFailure=yes",
20019
+ "-N",
20020
+ "-L",
20021
+ `${port}:127.0.0.1:${remotePort}`,
20022
+ ssh.host
20023
+ ),
19785
20024
  {
19786
20025
  stdio: ["ignore", "ignore", "inherit"]
19787
20026
  }
19788
20027
  );
19789
- child.on("close", onChildExit);
20028
+ child.on("close", () => onChildExit(child));
19790
20029
  state.child = child;
19791
- return waitForTcp(port, 5e3);
20030
+ const ready = await waitForTcp(port, 5e3) && child.exitCode === null;
20031
+ if (!ready) discard(child);
20032
+ return ready;
19792
20033
  };
19793
- const onChildExit = () => {
19794
- if (stopping) return;
19795
- emit({ t: "tunnel-down" });
20034
+ const discard = (child) => {
20035
+ if (state.child === child) state.child = null;
20036
+ child.kill("SIGTERM");
20037
+ };
20038
+ const onChildExit = (child) => {
20039
+ if (stopping || reconnectActive || child !== state.child) return;
20040
+ state.child = null;
19796
20041
  void reconnect();
19797
20042
  };
19798
20043
  const reconnect = async () => {
20044
+ if (reconnectActive || stopping) return;
20045
+ reconnectActive = true;
20046
+ const flapping = lastRestoredAt !== 0 && Date.now() - lastRestoredAt < STABLE_MS;
20047
+ let announced = false;
20048
+ const announce = () => {
20049
+ announced = true;
20050
+ emit({ t: "tunnel-down" });
20051
+ };
20052
+ const grace = flapping ? null : setTimeout(announce, downGraceMs);
20053
+ if (flapping) announce();
19799
20054
  let delay = RECONNECT_INITIAL_MS;
19800
- while (!stopping) {
19801
- await sleep(delay);
19802
- if (stopping) return;
19803
- if (!await ssh.isAlive()) {
19804
- try {
19805
- await ssh.open();
19806
- } catch {
19807
- delay = Math.min(delay * 2, RECONNECT_MAX_MS);
19808
- continue;
20055
+ let restored = false;
20056
+ try {
20057
+ while (!stopping) {
20058
+ await sleep(delay);
20059
+ if (stopping) break;
20060
+ if (!await ssh.isAlive()) {
20061
+ try {
20062
+ await ssh.open();
20063
+ } catch {
20064
+ delay = Math.min(delay * 2, RECONNECT_MAX_MS);
20065
+ continue;
20066
+ }
19809
20067
  }
20068
+ if (await spawnForward(localPort)) {
20069
+ restored = true;
20070
+ break;
20071
+ }
20072
+ const port = await findFreePort();
20073
+ if (await spawnForward(port)) {
20074
+ localPort = port;
20075
+ portCbs.forEach((cb) => cb(port));
20076
+ restored = true;
20077
+ break;
20078
+ }
20079
+ delay = Math.min(delay * 2, RECONNECT_MAX_MS);
20080
+ logger.warn(`tunnel to ${ssh.host} not back yet \u2014 retrying`);
19810
20081
  }
19811
- let port = localPort;
19812
- if (await spawnForward(port)) {
19813
- emit({ t: "tunnel-up" });
19814
- return;
19815
- }
19816
- state.child?.kill();
19817
- port = await findFreePort();
19818
- if (await spawnForward(port)) {
19819
- localPort = port;
19820
- portCbs.forEach((cb) => cb(port));
19821
- emit({ t: "tunnel-up" });
19822
- return;
20082
+ } finally {
20083
+ if (grace !== null) clearTimeout(grace);
20084
+ reconnectActive = false;
20085
+ }
20086
+ if (stopping) {
20087
+ state.child?.kill("SIGTERM");
20088
+ return;
20089
+ }
20090
+ if (restored) {
20091
+ lastRestoredAt = Date.now();
20092
+ if (announced) emit({ t: "tunnel-up" });
20093
+ if (state.child === null || state.child.exitCode !== null) {
20094
+ state.child = null;
20095
+ void reconnect();
19823
20096
  }
19824
- state.child?.kill();
19825
- delay = Math.min(delay * 2, RECONNECT_MAX_MS);
19826
- logger.warn(`tunnel to ${ssh.host} not back yet \u2014 retrying`);
19827
20097
  }
19828
20098
  };
19829
- if (!await spawnForward(localPort)) {
19830
- state.child?.kill();
20099
+ reconnectActive = true;
20100
+ const initialReady = await spawnForward(localPort);
20101
+ reconnectActive = false;
20102
+ if (!initialReady) {
20103
+ stopping = true;
19831
20104
  throw new CliError(
19832
20105
  "ssh_unreachable",
19833
20106
  `could not open a tunnel to ${ssh.host}:${remotePort}`,
19834
20107
  "is the daemon running on the host? try: puddle status " + ssh.host
19835
20108
  );
19836
20109
  }
20110
+ if (state.child === null || state.child.exitCode !== null) {
20111
+ state.child = null;
20112
+ void reconnect();
20113
+ }
19837
20114
  return {
19838
20115
  get localPort() {
19839
20116
  return localPort;
@@ -19858,9 +20135,64 @@ async function openTunnel(ssh, remotePort, opts = {}) {
19858
20135
  };
19859
20136
  }
19860
20137
 
19861
- // src/lib/transport/ssh.ts
20138
+ // src/lib/transport/local.ts
19862
20139
  import { spawn as spawn3 } from "node:child_process";
19863
- import { mkdirSync } from "node:fs";
20140
+ import { copyFileSync, mkdirSync, readFileSync as readFileSync3 } from "node:fs";
20141
+ import { dirname as dirname3 } from "node:path";
20142
+ var LocalTransport = class {
20143
+ kind = "local";
20144
+ label = "this machine";
20145
+ exec(command, opts = {}) {
20146
+ return new Promise((resolve3) => {
20147
+ const child = spawn3("sh", ["-c", command], { stdio: ["pipe", "pipe", "pipe"] });
20148
+ let stdout = "";
20149
+ let stderr = "";
20150
+ let timer;
20151
+ if (opts.timeoutMs !== void 0) {
20152
+ timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
20153
+ timer.unref();
20154
+ }
20155
+ child.stdout.on("data", (chunk) => {
20156
+ const text = chunk.toString();
20157
+ stdout += text;
20158
+ opts.onStdout?.(text);
20159
+ });
20160
+ child.stderr.on("data", (chunk) => stderr += chunk.toString());
20161
+ child.on("error", (err) => {
20162
+ if (timer) clearTimeout(timer);
20163
+ resolve3({ code: -1, stdout, stderr: stderr + String(err) });
20164
+ });
20165
+ child.on("close", (code) => {
20166
+ if (timer) clearTimeout(timer);
20167
+ resolve3({ code: code ?? -1, stdout, stderr });
20168
+ });
20169
+ if (opts.stdin !== void 0) child.stdin.end(opts.stdin);
20170
+ else child.stdin.end();
20171
+ });
20172
+ }
20173
+ readFile(path) {
20174
+ try {
20175
+ return Promise.resolve(readFileSync3(expandHome(path), "utf8"));
20176
+ } catch {
20177
+ return Promise.resolve(null);
20178
+ }
20179
+ }
20180
+ copyTo(localPath, destPath) {
20181
+ const dest = expandHome(destPath);
20182
+ mkdirSync(dirname3(dest), { recursive: true });
20183
+ copyFileSync(localPath, dest);
20184
+ return Promise.resolve();
20185
+ }
20186
+ dispose() {
20187
+ }
20188
+ };
20189
+ function expandHome(path) {
20190
+ return path.startsWith('"${PUDDLE_HOME:-$HOME/.puddle}"') ? clientHome() + path.slice('"${PUDDLE_HOME:-$HOME/.puddle}"'.length) : path;
20191
+ }
20192
+
20193
+ // src/lib/transport/ssh.ts
20194
+ import { spawn as spawn4 } from "node:child_process";
20195
+ import { mkdirSync as mkdirSync2 } from "node:fs";
19864
20196
  init_types();
19865
20197
  var SshTransport = class {
19866
20198
  constructor(host, opts = {}) {
@@ -19873,7 +20205,7 @@ var SshTransport = class {
19873
20205
  this.controlArgs = [];
19874
20206
  } else {
19875
20207
  const home = clientHome();
19876
- mkdirSync(home, { recursive: true });
20208
+ mkdirSync2(home, { recursive: true });
19877
20209
  this.controlArgs = [
19878
20210
  "-o",
19879
20211
  "ControlMaster=auto",
@@ -19885,18 +20217,20 @@ var SshTransport = class {
19885
20217
  "ControlPersist=10m"
19886
20218
  ];
19887
20219
  }
20220
+ this.keepaliveArgs = ["-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3"];
19888
20221
  }
19889
20222
  kind = "ssh";
19890
20223
  label;
19891
20224
  ssh;
19892
20225
  scp;
19893
20226
  controlArgs;
20227
+ keepaliveArgs;
19894
20228
  get hasControlMaster() {
19895
20229
  return this.controlArgs.length > 0;
19896
20230
  }
19897
20231
  /** Argv prefix every ssh/scp spawn shares. */
19898
20232
  args(...rest) {
19899
- return [...this.controlArgs, ...rest];
20233
+ return [...this.controlArgs, ...this.keepaliveArgs, ...rest];
19900
20234
  }
19901
20235
  /**
19902
20236
  * Open the master interactively — ssh's own prompts go straight to the
@@ -19904,7 +20238,7 @@ var SshTransport = class {
19904
20238
  */
19905
20239
  open() {
19906
20240
  return new Promise((resolve3, reject) => {
19907
- const child = spawn3(this.ssh, this.args(this.host, "true"), { stdio: "inherit" });
20241
+ const child = spawn4(this.ssh, this.args(this.host, "true"), { stdio: "inherit" });
19908
20242
  child.on(
19909
20243
  "error",
19910
20244
  (err) => reject(new CliError("ssh_unreachable", `could not run ${this.ssh}: ${err.message}`))
@@ -19927,14 +20261,14 @@ var SshTransport = class {
19927
20261
  isAlive() {
19928
20262
  if (!this.hasControlMaster) return Promise.resolve(true);
19929
20263
  return new Promise((resolve3) => {
19930
- const child = spawn3(this.ssh, this.args("-O", "check", this.host), { stdio: "ignore" });
20264
+ const child = spawn4(this.ssh, this.args("-O", "check", this.host), { stdio: "ignore" });
19931
20265
  child.on("error", () => resolve3(false));
19932
20266
  child.on("close", (code) => resolve3(code === 0));
19933
20267
  });
19934
20268
  }
19935
20269
  exec(command, opts = {}) {
19936
20270
  return new Promise((resolve3) => {
19937
- const child = spawn3(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
20271
+ const child = spawn4(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
19938
20272
  stdio: ["pipe", "pipe", "pipe"]
19939
20273
  });
19940
20274
  let stdout = "";
@@ -19969,7 +20303,7 @@ var SshTransport = class {
19969
20303
  async copyTo(localPath, destPath) {
19970
20304
  await this.exec(`mkdir -p $(dirname ${destPath})`, { timeoutMs: 15e3 });
19971
20305
  await new Promise((resolve3, reject) => {
19972
- const child = spawn3(this.scp, [...this.controlArgs, localPath, `${this.host}:${destPath}`], {
20306
+ const child = spawn4(this.scp, [...this.controlArgs, localPath, `${this.host}:${destPath}`], {
19973
20307
  stdio: ["ignore", "ignore", "inherit"]
19974
20308
  });
19975
20309
  child.on("error", reject);
@@ -20014,10 +20348,12 @@ async function connectRemote(opts) {
20014
20348
  upgradeDaemon: makeUpgrader(ssh, client, bootstrap),
20015
20349
  logger
20016
20350
  });
20351
+ const avoidPort = await readDaemonPort(new LocalTransport());
20017
20352
  const ui = await startUiServer({
20018
20353
  assetsDir: opts.assetsDir,
20019
20354
  port: opts.port,
20020
20355
  strictPort: opts.port !== void 0,
20356
+ avoidPort,
20021
20357
  target: { host: "127.0.0.1", port: tunnel.localPort }
20022
20358
  });
20023
20359
  tunnel.onPortChange((port) => ui.setTarget({ host: "127.0.0.1", port }));
@@ -20030,6 +20366,7 @@ async function connectRemote(opts) {
20030
20366
  return {
20031
20367
  origin: ui.origin,
20032
20368
  browserUrl: `${ui.origin}/?host=${encodeURIComponent(opts.host)}#token=${endpoint.token}`,
20369
+ nonce: ui.nonce,
20033
20370
  daemon,
20034
20371
  onEvent(cb) {
20035
20372
  eventCbs.add(cb);
@@ -20066,59 +20403,77 @@ async function showLogs(transport, opts) {
20066
20403
  await transport.exec(command, { onStdout: opts.write });
20067
20404
  }
20068
20405
 
20069
- // src/lib/transport/local.ts
20070
- import { spawn as spawn4 } from "node:child_process";
20071
- import { copyFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3 } from "node:fs";
20072
- import { dirname as dirname3 } from "node:path";
20073
- var LocalTransport = class {
20074
- kind = "local";
20075
- label = "this machine";
20076
- exec(command, opts = {}) {
20077
- return new Promise((resolve3) => {
20078
- const child = spawn4("sh", ["-c", command], { stdio: ["pipe", "pipe", "pipe"] });
20079
- let stdout = "";
20080
- let stderr = "";
20081
- let timer;
20082
- if (opts.timeoutMs !== void 0) {
20083
- timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
20084
- timer.unref();
20085
- }
20086
- child.stdout.on("data", (chunk) => {
20087
- const text = chunk.toString();
20088
- stdout += text;
20089
- opts.onStdout?.(text);
20090
- });
20091
- child.stderr.on("data", (chunk) => stderr += chunk.toString());
20092
- child.on("error", (err) => {
20093
- if (timer) clearTimeout(timer);
20094
- resolve3({ code: -1, stdout, stderr: stderr + String(err) });
20095
- });
20096
- child.on("close", (code) => {
20097
- if (timer) clearTimeout(timer);
20098
- resolve3({ code: code ?? -1, stdout, stderr });
20099
- });
20100
- if (opts.stdin !== void 0) child.stdin.end(opts.stdin);
20101
- else child.stdin.end();
20102
- });
20406
+ // src/lib/registry.ts
20407
+ import { mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync4, rmSync, writeFileSync } from "node:fs";
20408
+ import { join as join5 } from "node:path";
20409
+ function cockpitsDir(env) {
20410
+ return join5(clientHome(env), "cockpits");
20411
+ }
20412
+ function slug(target) {
20413
+ return target.replace(/[^A-Za-z0-9@._-]/g, "_");
20414
+ }
20415
+ function cockpitRecordPath(target) {
20416
+ return join5(cockpitsDir(), `${slug(target)}.json`);
20417
+ }
20418
+ function cockpitLogPath(target) {
20419
+ return join5(clientHome(), "logs", `cockpit-${slug(target)}.log`);
20420
+ }
20421
+ function writeCockpitRecord(record2) {
20422
+ mkdirSync3(cockpitsDir(), { recursive: true, mode: 448 });
20423
+ writeFileSync(cockpitRecordPath(record2.target), JSON.stringify(record2, null, 2) + "\n", {
20424
+ mode: 384
20425
+ });
20426
+ }
20427
+ function readCockpitRecord(target) {
20428
+ try {
20429
+ return JSON.parse(readFileSync4(cockpitRecordPath(target), "utf8"));
20430
+ } catch {
20431
+ return null;
20103
20432
  }
20104
- readFile(path) {
20433
+ }
20434
+ function removeCockpitRecord(target) {
20435
+ rmSync(cockpitRecordPath(target), { force: true });
20436
+ }
20437
+ function listCockpitRecords() {
20438
+ let names;
20439
+ try {
20440
+ names = readdirSync(cockpitsDir());
20441
+ } catch {
20442
+ return [];
20443
+ }
20444
+ const records = [];
20445
+ for (const name of names) {
20446
+ if (!name.endsWith(".json")) continue;
20105
20447
  try {
20106
- return Promise.resolve(readFileSync3(expandHome(path), "utf8"));
20448
+ records.push(JSON.parse(readFileSync4(join5(cockpitsDir(), name), "utf8")));
20107
20449
  } catch {
20108
- return Promise.resolve(null);
20109
20450
  }
20110
20451
  }
20111
- copyTo(localPath, destPath) {
20112
- const dest = expandHome(destPath);
20113
- mkdirSync2(dirname3(dest), { recursive: true });
20114
- copyFileSync(localPath, dest);
20115
- return Promise.resolve();
20452
+ return records.sort((a, b) => a.target.localeCompare(b.target));
20453
+ }
20454
+ function isPidAlive(pid) {
20455
+ try {
20456
+ process.kill(pid, 0);
20457
+ return true;
20458
+ } catch {
20459
+ return false;
20116
20460
  }
20117
- dispose() {
20461
+ }
20462
+ async function checkCockpit(record2) {
20463
+ if (!isPidAlive(record2.pid)) return "dead";
20464
+ if (record2.status === "starting") return "starting";
20465
+ if (record2.status !== "ready" || record2.origin === void 0 || record2.nonce === void 0) {
20466
+ return "unverified";
20467
+ }
20468
+ try {
20469
+ const res = await fetch(`${record2.origin}/`, {
20470
+ method: "HEAD",
20471
+ signal: AbortSignal.timeout(1500)
20472
+ });
20473
+ return res.headers.get("x-puddle-cockpit") === record2.nonce ? "running" : "unverified";
20474
+ } catch {
20475
+ return "unverified";
20118
20476
  }
20119
- };
20120
- function expandHome(path) {
20121
- return path.startsWith('"${PUDDLE_HOME:-$HOME/.puddle}"') ? clientHome() + path.slice('"${PUDDLE_HOME:-$HOME/.puddle}"'.length) : path;
20122
20477
  }
20123
20478
 
20124
20479
  // src/lib/start.ts
@@ -20139,12 +20494,15 @@ async function startLocal(opts) {
20139
20494
  assetsDir: opts.assetsDir,
20140
20495
  port: opts.port,
20141
20496
  strictPort: opts.port !== void 0,
20497
+ avoidPort: endpoint.port,
20498
+ // never squat the daemon's own port
20142
20499
  target: { host: "127.0.0.1", port: endpoint.port }
20143
20500
  });
20144
20501
  const eventCbs = /* @__PURE__ */ new Set();
20145
20502
  return {
20146
20503
  origin: ui.origin,
20147
20504
  browserUrl: `${ui.origin}/#token=${endpoint.token}`,
20505
+ nonce: ui.nonce,
20148
20506
  daemon,
20149
20507
  onEvent(cb) {
20150
20508
  eventCbs.add(cb);
@@ -20199,6 +20557,129 @@ async function upgradeDaemon(transport, opts = {}) {
20199
20557
  return { from, to: opts.version ?? pinnedDaemonVersion(), liveSessions };
20200
20558
  }
20201
20559
 
20560
+ // src/cli/detach.ts
20561
+ import { spawn as spawn5 } from "node:child_process";
20562
+ import { closeSync, mkdirSync as mkdirSync4, openSync, readFileSync as readFileSync5 } from "node:fs";
20563
+ import { dirname as dirname4 } from "node:path";
20564
+ init_types();
20565
+ var COCKPIT_CHILD_ENV = "PUDDLE_COCKPIT_CHILD";
20566
+ var READY_TIMEOUT_MS = 3e5;
20567
+ function isCockpitChild(env = process.env) {
20568
+ return env[COCKPIT_CHILD_ENV] === "1";
20569
+ }
20570
+ function killHint(target) {
20571
+ return target === "local" ? "stop it with: puddle kill" : `stop it with: puddle kill ${target}`;
20572
+ }
20573
+ async function launchDetached(opts) {
20574
+ const { target, logger } = opts;
20575
+ const existing = readCockpitRecord(target);
20576
+ if (existing !== null) {
20577
+ const liveness = await checkCockpit(existing);
20578
+ if (liveness === "running") {
20579
+ logger.info(`a cockpit for ${target} is already running at ${existing.origin ?? "?"}`);
20580
+ if (existing.browserUrl !== void 0) {
20581
+ if (!opts.noBrowser) openBrowser(existing.browserUrl);
20582
+ logger.info(`open: ${existing.browserUrl}`);
20583
+ }
20584
+ logger.info(killHint(target));
20585
+ return 0;
20586
+ }
20587
+ if (liveness === "starting") {
20588
+ logger.info(
20589
+ `a cockpit for ${target} is already starting (pid ${existing.pid}) \u2014 puddle list to watch it`
20590
+ );
20591
+ return 0;
20592
+ }
20593
+ if (liveness === "unverified") {
20594
+ throw new CliError(
20595
+ "already_running",
20596
+ `something holds the cockpit record for ${target} (pid ${existing.pid}) but did not answer at ${existing.origin ?? "its recorded origin"}`,
20597
+ `if it is defunct: puddle kill ${target === "local" ? "local" : target}`
20598
+ );
20599
+ }
20600
+ removeCockpitRecord(target);
20601
+ }
20602
+ if (target !== "local") await new SshTransport(target).open();
20603
+ const logFile = cockpitLogPath(target);
20604
+ mkdirSync4(dirname4(logFile), { recursive: true });
20605
+ const fd = openSync(logFile, "w");
20606
+ const child = spawn5(process.execPath, [process.argv[1] ?? "", ...process.argv.slice(2)], {
20607
+ detached: true,
20608
+ stdio: ["ignore", fd, fd],
20609
+ env: { ...process.env, [COCKPIT_CHILD_ENV]: "1" }
20610
+ });
20611
+ closeSync(fd);
20612
+ child.unref();
20613
+ if (child.pid === void 0) {
20614
+ throw new CliError("cockpit_launch_failed", "could not spawn the background cockpit process");
20615
+ }
20616
+ logger.info(`starting the cockpit for ${target} in the background\u2026`);
20617
+ const record2 = await followStartup(target, child.pid, logFile);
20618
+ if (record2 === null || record2.status !== "ready") {
20619
+ if (record2?.status === "error") {
20620
+ logger.warn(record2.message ?? "the cockpit failed to start");
20621
+ if (record2.hint !== void 0) logger.info(` hint: ${record2.hint}`);
20622
+ } else {
20623
+ logger.warn(`the cockpit did not report ready \u2014 its log: ${logFile}`);
20624
+ }
20625
+ logger.info(" (a run that stays on this terminal: add --foreground)");
20626
+ const current = readCockpitRecord(target);
20627
+ if (current !== null && current.pid === child.pid) removeCockpitRecord(target);
20628
+ return 1;
20629
+ }
20630
+ const arrow = target === "local" ? "" : ` \u2192 ${target}`;
20631
+ logger.info(`puddle cockpit at ${record2.origin ?? "?"}${arrow} \u2014 running in the background`);
20632
+ if (record2.browserUrl !== void 0) {
20633
+ if (!opts.noBrowser) openBrowser(record2.browserUrl);
20634
+ logger.info(`open: ${record2.browserUrl}`);
20635
+ }
20636
+ logger.info(killHint(target));
20637
+ return 0;
20638
+ }
20639
+ async function followStartup(target, childPid, logFile) {
20640
+ let offset = 0;
20641
+ const relay = () => {
20642
+ try {
20643
+ const buf = readFileSync5(logFile);
20644
+ if (buf.length > offset) {
20645
+ process.stderr.write(buf.subarray(offset));
20646
+ offset = buf.length;
20647
+ }
20648
+ } catch {
20649
+ }
20650
+ };
20651
+ const deadline = Date.now() + READY_TIMEOUT_MS;
20652
+ while (Date.now() < deadline) {
20653
+ relay();
20654
+ const record2 = readCockpitRecord(target);
20655
+ if (record2 !== null && record2.pid === childPid && record2.status !== "starting") return record2;
20656
+ if (!isPidAlive(childPid)) {
20657
+ relay();
20658
+ const last2 = readCockpitRecord(target);
20659
+ return last2 !== null && last2.pid === childPid ? last2 : null;
20660
+ }
20661
+ await sleep(200);
20662
+ }
20663
+ const last = readCockpitRecord(target);
20664
+ return last !== null && last.pid === childPid ? last : null;
20665
+ }
20666
+ async function terminateCockpit(record2) {
20667
+ try {
20668
+ process.kill(record2.pid, "SIGTERM");
20669
+ } catch {
20670
+ }
20671
+ const deadline = Date.now() + 5e3;
20672
+ while (Date.now() < deadline && isPidAlive(record2.pid)) await sleep(100);
20673
+ if (isPidAlive(record2.pid)) {
20674
+ try {
20675
+ process.kill(record2.pid, "SIGKILL");
20676
+ } catch {
20677
+ }
20678
+ }
20679
+ const current = readCockpitRecord(record2.target);
20680
+ if (current === null || current.pid === record2.pid) removeCockpitRecord(record2.target);
20681
+ }
20682
+
20202
20683
  // src/cli/output.ts
20203
20684
  function terminalLogger() {
20204
20685
  return {
@@ -20212,12 +20693,24 @@ function terminalLogger() {
20212
20693
  }
20213
20694
  };
20214
20695
  }
20696
+ function timestampedLogger() {
20697
+ const line = (message) => `${(/* @__PURE__ */ new Date()).toISOString()} ${message}
20698
+ `;
20699
+ return {
20700
+ info(message) {
20701
+ process.stderr.write(line(message));
20702
+ },
20703
+ warn(message) {
20704
+ process.stderr.write(line(`puddle: ${message}`));
20705
+ }
20706
+ };
20707
+ }
20215
20708
 
20216
20709
  // src/cli/run.ts
20217
20710
  function assetsDir() {
20218
- const here = dirname4(fileURLToPath3(import.meta.url));
20219
- for (const candidate of [join5(here, "public"), join5(here, "..", "..", "dist", "public")]) {
20220
- if (existsSync3(join5(candidate, "index.html"))) return candidate;
20711
+ const here = dirname5(fileURLToPath3(import.meta.url));
20712
+ for (const candidate of [join6(here, "public"), join6(here, "..", "..", "dist", "public")]) {
20713
+ if (existsSync3(join6(candidate, "index.html"))) return candidate;
20221
20714
  }
20222
20715
  throw new CliError(
20223
20716
  "not_installed",
@@ -20280,28 +20773,79 @@ async function run(command) {
20280
20773
  process.stdout.write(`puddle ${cliVersion()}
20281
20774
  `);
20282
20775
  return 0;
20283
- case "start": {
20284
- const cockpit = await startLocal({ ...command, assetsDir: assetsDir(), logger });
20285
- logger.info(`puddle cockpit at ${cockpit.origin} (daemon ${cockpit.daemon.version})`);
20286
- if (!command.noBrowser) openBrowser(cockpit.browserUrl);
20287
- logger.info(`open: ${cockpit.browserUrl}`);
20288
- await runUntilInterrupted(
20289
- cockpit.stop,
20290
- "cockpit closed \u2014 sessions keep running on this machine"
20776
+ case "start":
20777
+ case "connect": {
20778
+ const target = command.cmd === "connect" ? command.host : "local";
20779
+ if (!command.foreground && !isCockpitChild()) {
20780
+ return launchDetached({ target, noBrowser: command.noBrowser, logger });
20781
+ }
20782
+ return runCockpit(command, target, logger);
20783
+ }
20784
+ case "list": {
20785
+ const rows = [];
20786
+ for (const record2 of listCockpitRecords()) {
20787
+ const liveness = await checkCockpit(record2);
20788
+ if (liveness === "dead") {
20789
+ removeCockpitRecord(record2.target);
20790
+ continue;
20791
+ }
20792
+ rows.push({ record: record2, liveness });
20793
+ }
20794
+ if (rows.length === 0) {
20795
+ process.stdout.write("no cockpits are running\n");
20796
+ return 0;
20797
+ }
20798
+ process.stdout.write(
20799
+ `${pad("TARGET", 26)} ${pad("STATUS", 9)} ${pad("PID", 7)} ${pad("UI", 24)} STARTED
20800
+ `
20291
20801
  );
20802
+ for (const { record: record2, liveness } of rows) {
20803
+ process.stdout.write(
20804
+ `${pad(record2.target, 26)} ${pad(liveness, 9)} ${pad(String(record2.pid), 7)} ${pad(record2.origin ?? "\u2014", 24)} ${record2.startedAt}
20805
+ `
20806
+ );
20807
+ }
20292
20808
  return 0;
20293
20809
  }
20294
- case "connect": {
20295
- const cockpit = await connectRemote({ ...command, assetsDir: assetsDir(), logger });
20296
- logger.info(
20297
- `puddle cockpit at ${cockpit.origin} \u2192 ${command.host} (daemon ${cockpit.daemon.version})`
20298
- );
20299
- if (!command.noBrowser) openBrowser(cockpit.browserUrl);
20300
- logger.info(`open: ${cockpit.browserUrl}`);
20301
- await runUntilInterrupted(
20302
- cockpit.stop,
20303
- `cockpit closed \u2014 sessions keep running on ${command.host}`
20304
- );
20810
+ case "kill": {
20811
+ const live = [];
20812
+ for (const record2 of listCockpitRecords()) {
20813
+ if (await checkCockpit(record2) === "dead") removeCockpitRecord(record2.target);
20814
+ else live.push(record2);
20815
+ }
20816
+ let victims;
20817
+ if (command.all) {
20818
+ if (live.length === 0) {
20819
+ logger.info("no cockpits are running");
20820
+ return 0;
20821
+ }
20822
+ victims = live;
20823
+ } else if (command.target !== void 0) {
20824
+ const found = live.find((r) => r.target === command.target);
20825
+ if (found === void 0) {
20826
+ throw new CliError(
20827
+ "no_cockpit",
20828
+ `no cockpit is running for ${command.target}`,
20829
+ live.length > 0 ? `running: ${live.map((r) => r.target).join(", ")}` : "see: puddle list"
20830
+ );
20831
+ }
20832
+ victims = [found];
20833
+ } else {
20834
+ if (live.length === 0) throw new CliError("no_cockpit", "no cockpits are running");
20835
+ if (live.length > 1) {
20836
+ throw new CliError(
20837
+ "bad_arguments",
20838
+ "several cockpits are running \u2014 name the one to kill",
20839
+ `puddle kill <${live.map((r) => r.target).join(" | ")}> (or --all)`
20840
+ );
20841
+ }
20842
+ victims = live;
20843
+ }
20844
+ for (const victim of victims) {
20845
+ await terminateCockpit(victim);
20846
+ const where = victim.target === "local" ? "this machine" : victim.target;
20847
+ logger.info(`stopped the cockpit for ${victim.target} \u2014 sessions keep running on ${where}`);
20848
+ }
20305
20849
  return 0;
20306
20850
  }
20307
20851
  case "status": {
@@ -20400,6 +20944,65 @@ async function run(command) {
20400
20944
  }
20401
20945
  }
20402
20946
  }
20947
+ async function runCockpit(command, target, terminal) {
20948
+ const detached = isCockpitChild();
20949
+ const logger = detached ? timestampedLogger() : terminal;
20950
+ const existing = readCockpitRecord(target);
20951
+ if (existing !== null && existing.pid !== process.pid) {
20952
+ if (await checkCockpit(existing) !== "dead") {
20953
+ throw new CliError(
20954
+ "already_running",
20955
+ `a cockpit for ${target} is already running at ${existing.origin ?? `pid ${existing.pid}`}`,
20956
+ killHint(target).replace("stop it", "stop it first")
20957
+ );
20958
+ }
20959
+ removeCockpitRecord(target);
20960
+ }
20961
+ const base = {
20962
+ target,
20963
+ pid: process.pid,
20964
+ status: "starting",
20965
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
20966
+ cliVersion: cliVersion(),
20967
+ ...detached ? { logFile: cockpitLogPath(target) } : {}
20968
+ };
20969
+ writeCockpitRecord(base);
20970
+ let cockpit;
20971
+ try {
20972
+ cockpit = command.cmd === "start" ? await startLocal({ ...command, assetsDir: assetsDir(), logger }) : await connectRemote({ ...command, assetsDir: assetsDir(), logger });
20973
+ } catch (err) {
20974
+ if (err instanceof CliError) {
20975
+ writeCockpitRecord({
20976
+ ...base,
20977
+ status: "error",
20978
+ message: err.message,
20979
+ ...err.hint !== void 0 ? { hint: err.hint } : {}
20980
+ });
20981
+ } else {
20982
+ removeCockpitRecord(target);
20983
+ }
20984
+ throw err;
20985
+ }
20986
+ writeCockpitRecord({
20987
+ ...base,
20988
+ status: "ready",
20989
+ origin: cockpit.origin,
20990
+ browserUrl: cockpit.browserUrl,
20991
+ nonce: cockpit.nonce
20992
+ });
20993
+ const arrow = command.cmd === "connect" ? ` \u2192 ${command.host}` : "";
20994
+ logger.info(`puddle cockpit at ${cockpit.origin}${arrow} (daemon ${cockpit.daemon.version})`);
20995
+ if (!detached) {
20996
+ if (!command.noBrowser) openBrowser(cockpit.browserUrl);
20997
+ logger.info(`open: ${cockpit.browserUrl}`);
20998
+ }
20999
+ const where = command.cmd === "start" ? "this machine" : command.host;
21000
+ await runUntilInterrupted(async () => {
21001
+ await cockpit.stop();
21002
+ removeCockpitRecord(target);
21003
+ }, `cockpit closed \u2014 sessions keep running on ${where}`);
21004
+ return 0;
21005
+ }
20403
21006
  function pad(text, width) {
20404
21007
  return text.length >= width ? text.slice(0, width - 1) + "\u2026" : text.padEnd(width);
20405
21008
  }