@puddle-code/cli 0.0.1 → 0.0.3

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 +36 -0
  2. package/README.md +112 -0
  3. package/dist/index.js +825 -165
  4. package/dist/install.sh +8 -0
  5. package/dist/public/assets/Dashboard-zbeaP4WM.js +1 -0
  6. package/dist/public/assets/EditorTabClose-Bujs4cab.js +1 -0
  7. package/dist/public/assets/ModelRefcount-BN4jRD-x.js +1 -0
  8. package/dist/public/assets/PaneEditorBody-COdCF9BY.js +1 -0
  9. package/dist/public/assets/SettingsDialog-CKW2AM11.js +1 -0
  10. package/dist/public/assets/{Terminal-CuEf-GeX.js → Terminal-hw3UjYEu.js} +2 -2
  11. package/dist/public/assets/Workspace-DrDkPyrd.js +6 -0
  12. package/dist/public/assets/api-BJKPJeFZ.js +73 -0
  13. package/dist/public/assets/buffer-logic-BrpRsL1b.js +1 -0
  14. package/dist/public/assets/buffer-store-BGy4Fj9o.js +16 -0
  15. package/dist/public/assets/context-menu-CnHdXMkI.js +1 -0
  16. package/dist/public/assets/createLucideIcon-DyMAgLa3.js +1 -0
  17. package/dist/public/assets/cssMode-DuLDfCcC.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-Cw21CVB0.js +1 -0
  23. package/dist/public/assets/htmlMode-DaGDGb6z.js +1 -0
  24. package/dist/public/assets/index-wPaOVZ8U.js +10 -0
  25. package/dist/public/assets/index-y2Zzy0lJ.css +2 -0
  26. package/dist/public/assets/{jsonMode-BijgXT6I.js → jsonMode-Ekn8HPfg.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-CghPpMrk.js +1 -0
  30. package/dist/public/assets/switch-CQITZ7yg.js +1 -0
  31. package/dist/public/assets/{tsMode-B7q3gz7a.js → tsMode-CsjWL1Qz.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: 2 };
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,36 @@ 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; `restartTemplate` is sent
18733
+ * when a session is resumed after a daemon restart or machine reboot.
18734
+ */
18735
+ onboardingTemplate: external_exports.string().optional(),
18736
+ concurrentTemplate: external_exports.string().optional(),
18737
+ restartTemplate: external_exports.string().optional(),
18738
+ /**
18739
+ * How a session's tab/label is composed from its parts (SPEC §4). A template
18740
+ * string interpolating `${…}` variables (see `TAB_TITLE_VARIABLES`); ABSENT
18741
+ * falls back to `DEFAULT_TAB_TITLE_TEMPLATE` (`${name}`), reproducing the
18742
+ * historical display name. Empty renders (all chosen variables blank) fall
18743
+ * back to the session-id prefix, so a tab is never nameless.
18744
+ */
18745
+ tabTitleTemplate: external_exports.string().optional(),
18746
+ /**
18747
+ * The user's drag-order of projects on the homescreen (project ids). Projects
18748
+ * absent from this list — newly created ones — sort to the top. The right
18749
+ * sidebar's cross-project grouping inherits this order (SPEC §11, §12).
18750
+ */
18751
+ projectOrder: external_exports.array(external_exports.string()).optional()
18677
18752
  });
18678
18753
  var patchProfileSettingsRequestSchema = external_exports.record(external_exports.string(), external_exports.unknown());
18679
18754
 
@@ -18836,7 +18911,24 @@ var sessionSchema = external_exports.object({
18836
18911
  /** Null for terminal sessions, which run a plain shell rather than an agent. */
18837
18912
  agent_type: external_exports.string().nullable(),
18838
18913
  agent_session_ref: external_exports.string().nullable(),
18914
+ /** User-set display override; null means "use the agent's own name" (agent_title) then the id. */
18839
18915
  title: external_exports.string().nullable(),
18916
+ /**
18917
+ * The agent's own session name (for Claude Code, the transcript's ai-title /
18918
+ * agent-name — the name shown in its resume picker), maintained by the daemon.
18919
+ * The default display name when the user has not set a `title`. Optional:
18920
+ * older daemons omit it; null when the agent has not named the session yet.
18921
+ */
18922
+ agent_title: external_exports.string().nullable().optional(),
18923
+ /**
18924
+ * The terminal-title "sequence" name: the last title the process set on its
18925
+ * PTY via an OSC 0/1/2 escape, normalised (leading spinner/status glyphs
18926
+ * stripped). This is the VSCode `${sequence}` variable — a name source for
18927
+ * agents and terminals that have no adapter-maintained `agent_title`. Optional:
18928
+ * older daemons omit it; null when the process has set no title. It never
18929
+ * overrides a user `title`; see `tabTitleTemplate` for how it is composed.
18930
+ */
18931
+ osc_title: external_exports.string().nullable().optional(),
18840
18932
  status: sessionStatusSchema,
18841
18933
  skip_permissions: external_exports.boolean(),
18842
18934
  created_at: isoTimestamp,
@@ -18898,7 +18990,7 @@ var createSessionRequestSchema = external_exports.object({
18898
18990
  prompt: external_exports.string().optional(),
18899
18991
  skip_permissions: external_exports.boolean().optional()
18900
18992
  });
18901
- var patchSessionRequestSchema = external_exports.object({ title: external_exports.string().min(1).max(200) });
18993
+ var patchSessionRequestSchema = external_exports.object({ title: external_exports.string().max(200) });
18902
18994
  var migrateSessionRequestSchema = external_exports.object({ account_id: rowId });
18903
18995
  var archiveRequestSchema = external_exports.object({
18904
18996
  force: external_exports.boolean().default(false),
@@ -18916,6 +19008,12 @@ var projectSchema = external_exports.object({
18916
19008
  profile_id: profileId,
18917
19009
  repo_id: rowId,
18918
19010
  name: external_exports.string(),
19011
+ /**
19012
+ * Hidden from the homescreen but never deleted — every session, worktree, and
19013
+ * bit of data is retained, and un-archiving restores it all (SPEC §11).
19014
+ * Defaults false so an older daemon that omits it reads as not-archived.
19015
+ */
19016
+ archived: external_exports.boolean().default(false),
18919
19017
  created_at: isoTimestamp,
18920
19018
  updated_at: isoTimestamp
18921
19019
  });
@@ -18924,6 +19022,10 @@ var createProjectRequestSchema = external_exports.object({
18924
19022
  repo_id: rowId,
18925
19023
  name: external_exports.string().min(1).max(100)
18926
19024
  });
19025
+ var patchProjectRequestSchema = external_exports.object({
19026
+ name: external_exports.string().min(1).max(100).optional(),
19027
+ archived: external_exports.boolean().optional()
19028
+ });
18927
19029
  var projectDetailSchema = external_exports.object({
18928
19030
  project: projectSchema,
18929
19031
  sessions: external_exports.array(sessionSchema)
@@ -18936,6 +19038,29 @@ var editorTabRefSchema = external_exports.object({
18936
19038
  kind: external_exports.enum(["file", "diff", "commit"]).optional(),
18937
19039
  sha: external_exports.string().optional()
18938
19040
  });
19041
+ var tabRefSchema = external_exports.discriminatedUnion("type", [
19042
+ external_exports.object({ type: external_exports.literal("editor"), tab: editorTabRefSchema }),
19043
+ external_exports.object({ type: external_exports.literal("terminal"), session: sessionId })
19044
+ ]);
19045
+ var layoutLeafSchema = external_exports.object({
19046
+ kind: external_exports.literal("leaf"),
19047
+ id: external_exports.string(),
19048
+ tabs: external_exports.array(tabRefSchema).default([]),
19049
+ activeKey: external_exports.string().nullable().default(null),
19050
+ previewKey: external_exports.string().nullable().default(null)
19051
+ });
19052
+ var layoutNodeSchema = external_exports.lazy(
19053
+ () => external_exports.discriminatedUnion("kind", [
19054
+ layoutLeafSchema,
19055
+ external_exports.object({
19056
+ kind: external_exports.literal("split"),
19057
+ id: external_exports.string(),
19058
+ direction: external_exports.enum(["row", "col"]),
19059
+ children: external_exports.array(layoutNodeSchema),
19060
+ sizes: external_exports.array(external_exports.number())
19061
+ })
19062
+ ])
19063
+ );
18939
19064
  var uiStateSnapshotSchema = external_exports.looseObject({
18940
19065
  session_tabs: external_exports.array(sessionId).default([]),
18941
19066
  active_session: sessionId.nullable().default(null),
@@ -18962,7 +19087,13 @@ var uiStateSnapshotSchema = external_exports.looseObject({
18962
19087
  * listed here (newly created ones) sort to the top; the list is otherwise
18963
19088
  * drag-reorderable and this persists it (SPEC §8).
18964
19089
  */
18965
- session_order: external_exports.array(sessionId).default([])
19090
+ session_order: external_exports.array(sessionId).default([]),
19091
+ /**
19092
+ * The tiling layout tree (SPEC §8): the source of truth for which tabs are
19093
+ * open and where. Null on legacy snapshots — the web rebuilds an equivalent
19094
+ * tree from `editor_tabs`/`session_tabs`/`layout` on first load.
19095
+ */
19096
+ layout_tree: layoutNodeSchema.nullable().default(null)
18966
19097
  });
18967
19098
  var putProjectStateRequestSchema = external_exports.object({
18968
19099
  ui_state: uiStateSnapshotSchema
@@ -19028,6 +19159,27 @@ var uploadResponseSchema = external_exports.object({
19028
19159
  )
19029
19160
  });
19030
19161
 
19162
+ // ../shared/src/api/worktree-fs.ts
19163
+ var createEntryRequestSchema = external_exports.object({
19164
+ path: external_exports.string().min(1),
19165
+ kind: external_exports.enum(["file", "dir"])
19166
+ });
19167
+ var renameEntryRequestSchema = external_exports.object({
19168
+ from: external_exports.string().min(1),
19169
+ to: external_exports.string().min(1)
19170
+ });
19171
+ var copyEntryRequestSchema = external_exports.object({
19172
+ from: external_exports.string().min(1),
19173
+ to: external_exports.string().min(1)
19174
+ });
19175
+ var deleteEntryRequestSchema = external_exports.object({
19176
+ path: external_exports.string().min(1)
19177
+ });
19178
+ var fsOpResponseSchema = external_exports.object({
19179
+ ok: external_exports.literal(true),
19180
+ path: external_exports.string()
19181
+ });
19182
+
19031
19183
  // ../shared/src/api/worktree-git.ts
19032
19184
  var diffStatusSchema = external_exports.enum(["added", "modified", "deleted", "renamed"]);
19033
19185
  var diffEntrySchema = external_exports.object({
@@ -19043,6 +19195,22 @@ var diffResponseSchema = external_exports.object({
19043
19195
  base_ref: external_exports.string().nullable(),
19044
19196
  entries: external_exports.array(diffEntrySchema)
19045
19197
  });
19198
+ var gitStatusSchema = external_exports.enum([
19199
+ "untracked",
19200
+ "modified",
19201
+ "added",
19202
+ "deleted",
19203
+ "renamed",
19204
+ "conflicted",
19205
+ "ignored"
19206
+ ]);
19207
+ var gitStatusEntrySchema = external_exports.object({
19208
+ path: external_exports.string(),
19209
+ status: gitStatusSchema
19210
+ });
19211
+ var gitStatusResponseSchema = external_exports.object({
19212
+ entries: external_exports.array(gitStatusEntrySchema)
19213
+ });
19046
19214
  var fileAtResponseSchema = external_exports.object({
19047
19215
  path: external_exports.string(),
19048
19216
  ref: external_exports.string(),
@@ -19154,6 +19322,18 @@ async function waitForTcp(port, timeoutMs) {
19154
19322
  }
19155
19323
  return false;
19156
19324
  }
19325
+ async function waitForHttp(url2, timeoutMs) {
19326
+ const deadline = Date.now() + timeoutMs;
19327
+ while (Date.now() < deadline) {
19328
+ try {
19329
+ await fetch(url2, { signal: AbortSignal.timeout(2e3) });
19330
+ return true;
19331
+ } catch {
19332
+ await sleep(250);
19333
+ }
19334
+ }
19335
+ return false;
19336
+ }
19157
19337
  function sleep(ms) {
19158
19338
  return new Promise((r) => setTimeout(r, ms));
19159
19339
  }
@@ -19169,6 +19349,7 @@ var hostPaths = {
19169
19349
  home: HOST_HOME,
19170
19350
  token: `${HOST_HOME}/token`,
19171
19351
  config: `${HOST_HOME}/config.json`,
19352
+ runtime: `${HOST_HOME}/runtime.json`,
19172
19353
  current: `${HOST_HOME}/bin/current`,
19173
19354
  cache: `${HOST_HOME}/cache`,
19174
19355
  logs: `${HOST_HOME}/logs`
@@ -19233,6 +19414,14 @@ var DaemonClient = class {
19233
19414
  }
19234
19415
  };
19235
19416
  async function readDaemonPort(transport) {
19417
+ const runtime = await transport.readFile(hostPaths.runtime);
19418
+ if (runtime !== null) {
19419
+ try {
19420
+ const parsed = JSON.parse(runtime);
19421
+ if (typeof parsed.port === "number") return parsed.port;
19422
+ } catch {
19423
+ }
19424
+ }
19236
19425
  const raw = await transport.readFile(hostPaths.config);
19237
19426
  if (raw !== null) {
19238
19427
  try {
@@ -19273,7 +19462,7 @@ import { readFileSync } from "node:fs";
19273
19462
  import { dirname, join as join2 } from "node:path";
19274
19463
  import { fileURLToPath } from "node:url";
19275
19464
  function cliVersion() {
19276
- if (true) return "0.0.1";
19465
+ if (true) return "0.0.3";
19277
19466
  const here = dirname(fileURLToPath(import.meta.url));
19278
19467
  for (const candidate of [
19279
19468
  join2(here, "..", "..", "package.json"),
@@ -19330,18 +19519,18 @@ async function installDaemon(transport, opts = {}) {
19330
19519
  }
19331
19520
  }
19332
19521
  } else {
19333
- const slug = repoSlug();
19522
+ const slug2 = repoSlug();
19334
19523
  if (opts.tarball !== void 0) {
19335
19524
  throw new CliError("bad_arguments", "--tarball must be a local file path");
19336
19525
  }
19337
- if (slug === void 0) {
19526
+ if (slug2 === void 0) {
19338
19527
  throw new CliError(
19339
19528
  "not_installed",
19340
19529
  "no release source is configured for this build",
19341
19530
  "pass --tarball <path> (pnpm build:tarball produces one) or set PUDDLE_REPO=owner/repo"
19342
19531
  );
19343
19532
  }
19344
- args.push("--repo", slug);
19533
+ args.push("--repo", slug2);
19345
19534
  }
19346
19535
  logger.info(`installing puddled ${version2} on ${transport.label}`);
19347
19536
  const quoted = args.map((a) => `'${a.replaceAll("'", `'\\''`)}'`).join(" ");
@@ -19387,42 +19576,63 @@ async function ensureDaemon(transport, opts) {
19387
19576
  await installDaemon(transport, opts);
19388
19577
  bootstrapped = true;
19389
19578
  token = await waitForToken(transport, 2e4);
19579
+ const up = await waitForHostProbe(transport, token, 2e4);
19580
+ if (up.result === "unauthorised") throw portConflict(transport, up.port);
19581
+ if (up.result !== "ok") throw startTimeout(transport);
19582
+ return { port: up.port, token, bootstrapped };
19390
19583
  }
19391
19584
  let port = await readDaemonPort(transport);
19392
- if (!await hostProbe(transport, port)) {
19585
+ const probe = await hostProbe(transport, port, token);
19586
+ if (probe === "unauthorised") throw portConflict(transport, port);
19587
+ if (probe === "down") {
19393
19588
  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
- );
19589
+ logger.info(`no managed daemon on ${transport.label} \u2014 bootstrapping one`);
19590
+ } else {
19591
+ logger.info(`puddled is installed on ${transport.label} but not running \u2014 restarting it`);
19399
19592
  }
19400
- logger.info(`puddled is installed on ${transport.label} but not running \u2014 restarting it`);
19401
19593
  await installDaemon(transport, opts);
19402
19594
  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
- }
19595
+ token = await readToken(transport) ?? token;
19596
+ const after = await waitForHostProbe(transport, token, 2e4);
19597
+ if (after.result === "unauthorised") throw portConflict(transport, after.port);
19598
+ if (after.result !== "ok") throw startTimeout(transport);
19599
+ port = after.port;
19411
19600
  }
19412
19601
  return { port, token, bootstrapped };
19413
19602
  }
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");
19603
+ function startTimeout(transport) {
19604
+ return new CliError(
19605
+ "daemon_start_timeout",
19606
+ `puddled did not come up on ${transport.label}`,
19607
+ transport.kind === "ssh" ? `inspect it with: puddle logs ${transport.label}` : "inspect it with: puddle logs"
19608
+ );
19609
+ }
19610
+ function portConflict(transport, port) {
19611
+ return new CliError(
19612
+ "port_in_use",
19613
+ `something on ${transport.label} answers on 127.0.0.1:${port} but rejects this host's token`,
19614
+ `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.`
19615
+ );
19418
19616
  }
19419
- async function waitForHostProbe(transport, port, timeoutMs) {
19617
+ async function hostProbe(transport, port, token) {
19618
+ 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`;
19619
+ const out = (await transport.exec(cmd, { timeoutMs: 1e4 })).stdout;
19620
+ if (out.includes("HTTP:200")) return "ok";
19621
+ if (out.includes("HTTP:401") || out.includes("HTTP:403")) return "unauthorised";
19622
+ if (out.includes("TCPOPEN")) return "ok";
19623
+ return "down";
19624
+ }
19625
+ async function waitForHostProbe(transport, token, timeoutMs) {
19420
19626
  const deadline = Date.now() + timeoutMs;
19627
+ let last = "down";
19628
+ let port = await readDaemonPort(transport);
19421
19629
  while (Date.now() < deadline) {
19422
- if (await hostProbe(transport, port)) return true;
19630
+ port = await readDaemonPort(transport);
19631
+ last = await hostProbe(transport, port, token);
19632
+ if (last !== "down") return { result: last, port };
19423
19633
  await sleep(500);
19424
19634
  }
19425
- return false;
19635
+ return { result: last, port };
19426
19636
  }
19427
19637
  function makeUpgrader(transport, client, opts) {
19428
19638
  return async () => {
@@ -19484,6 +19694,7 @@ async function runHandshake(opts) {
19484
19694
 
19485
19695
  // src/lib/serve/ui-server.ts
19486
19696
  init_types();
19697
+ import { randomUUID } from "node:crypto";
19487
19698
  import { createServer as createServer2 } from "node:http";
19488
19699
 
19489
19700
  // src/lib/serve/guard.ts
@@ -19504,6 +19715,24 @@ function isLocalOrigin(origin) {
19504
19715
  }
19505
19716
  }
19506
19717
 
19718
+ // src/lib/serve/proxy-recovery.ts
19719
+ var PROXY_PAGE = /^\/proxy\/([^/]+)\/(\d+)(?:\/|$)/;
19720
+ function recoverProxiedPath(referer, requestUrl) {
19721
+ if (typeof referer !== "string" || !requestUrl.startsWith("/")) return null;
19722
+ const pathname = requestUrl.split("?")[0] ?? "";
19723
+ if (pathname === "/proxy" || pathname.startsWith("/proxy/")) return null;
19724
+ let ref;
19725
+ try {
19726
+ ref = new URL(referer);
19727
+ } catch {
19728
+ return null;
19729
+ }
19730
+ if (!isLocalHostHeader(ref.host)) return null;
19731
+ const match = PROXY_PAGE.exec(ref.pathname);
19732
+ if (match === null) return null;
19733
+ return `/proxy/${match[1]}/${match[2]}${requestUrl}`;
19734
+ }
19735
+
19507
19736
  // src/lib/serve/static.ts
19508
19737
  import { createReadStream, existsSync as existsSync2, statSync } from "node:fs";
19509
19738
  import { extname, join as join4, normalize, resolve as resolve2, sep } from "node:path";
@@ -19527,9 +19756,21 @@ function createStaticHandler(rootDir) {
19527
19756
  return;
19528
19757
  }
19529
19758
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
19530
- const requested = normalize(join4(root, decodeURIComponent(pathname)));
19759
+ let decoded;
19760
+ try {
19761
+ decoded = decodeURIComponent(pathname);
19762
+ } catch {
19763
+ res.writeHead(404, { "content-type": "text/plain" }).end("not found");
19764
+ return;
19765
+ }
19766
+ const requested = normalize(join4(root, decoded));
19531
19767
  const candidate = requested.startsWith(root + sep) || requested === root ? requested : root;
19532
- const file2 = existsSync2(candidate) && statSync(candidate).isFile() ? candidate : join4(root, "index.html");
19768
+ const isFile = existsSync2(candidate) && statSync(candidate).isFile();
19769
+ if (!isFile && extname(pathname) !== "") {
19770
+ res.writeHead(404, { "content-type": "text/plain" }).end("not found");
19771
+ return;
19772
+ }
19773
+ const file2 = isFile ? candidate : join4(root, "index.html");
19533
19774
  if (!existsSync2(file2)) {
19534
19775
  res.writeHead(404, { "content-type": "text/plain" }).end("not found");
19535
19776
  return;
@@ -19691,8 +19932,16 @@ async function startUiServer(opts) {
19691
19932
  const target = { ...opts.target };
19692
19933
  const tracker = new ProxySocketTracker();
19693
19934
  const serveStatic = createStaticHandler(opts.assetsDir);
19935
+ const nonce = randomUUID();
19694
19936
  const server = createServer2((req, res) => {
19937
+ res.setHeader("x-puddle-cockpit", nonce);
19695
19938
  const url2 = req.url ?? "/";
19939
+ const recovered = recoverProxiedPath(req.headers.referer, url2);
19940
+ if (recovered !== null) {
19941
+ res.writeHead(307, { location: recovered });
19942
+ res.end();
19943
+ return;
19944
+ }
19696
19945
  if (isProxiedPath(url2)) {
19697
19946
  if (!isLocalHostHeader(req.headers.host) || !isLocalOrigin(req.headers.origin)) {
19698
19947
  res.writeHead(403, { "content-type": "application/json" });
@@ -19715,10 +19964,16 @@ async function startUiServer(opts) {
19715
19964
  }
19716
19965
  proxyUpgrade(req, socket, head, target, tracker);
19717
19966
  });
19718
- const port = await listen(server, opts.port ?? DEFAULT_UI_PORT, opts.strictPort ?? false);
19967
+ const port = await listen(
19968
+ server,
19969
+ opts.port ?? DEFAULT_UI_PORT,
19970
+ opts.strictPort ?? false,
19971
+ opts.avoidPort
19972
+ );
19719
19973
  return {
19720
19974
  port,
19721
19975
  origin: `http://localhost:${port}`,
19976
+ nonce,
19722
19977
  setTarget(next) {
19723
19978
  target.host = next.host;
19724
19979
  target.port = next.port;
@@ -19732,9 +19987,10 @@ async function startUiServer(opts) {
19732
19987
  }
19733
19988
  };
19734
19989
  }
19735
- async function listen(server, startPort, strict) {
19990
+ async function listen(server, startPort, strict, avoidPort) {
19736
19991
  for (let probe = 0; probe < MAX_PORT_PROBES; probe += 1) {
19737
19992
  const port = startPort + probe;
19993
+ if (!strict && port === avoidPort) continue;
19738
19994
  const ok = await new Promise((resolve3, rejectListen) => {
19739
19995
  const onError = (err) => {
19740
19996
  server.off("listening", onListening);
@@ -19769,71 +20025,122 @@ import { spawn as spawn2 } from "node:child_process";
19769
20025
  init_types();
19770
20026
  var RECONNECT_INITIAL_MS = 500;
19771
20027
  var RECONNECT_MAX_MS = 1e4;
20028
+ var DOWN_GRACE_MS = 2e3;
20029
+ var STABLE_MS = 3e4;
20030
+ var HEALTH_INTERVAL_MS = 1e3;
19772
20031
  async function openTunnel(ssh, remotePort, opts = {}) {
19773
20032
  const logger = opts.logger ?? silentLogger;
19774
20033
  const sshBinary = opts.sshBinary ?? "ssh";
20034
+ const downGraceMs = opts.downGraceMs ?? DOWN_GRACE_MS;
20035
+ const healthIntervalMs = opts.healthIntervalMs ?? HEALTH_INTERVAL_MS;
20036
+ const isReady = opts.ready ?? (() => Promise.resolve(true));
19775
20037
  const eventCbs = /* @__PURE__ */ new Set();
19776
20038
  const portCbs = /* @__PURE__ */ new Set();
19777
20039
  const emit = (e) => eventCbs.forEach((cb) => cb(e));
19778
20040
  let localPort = await findFreePort();
19779
20041
  const state = { child: null };
19780
20042
  let stopping = false;
20043
+ let lastRestoredAt = 0;
20044
+ let reconnectActive = false;
20045
+ let healthTimer = null;
19781
20046
  const spawnForward = async (port) => {
20047
+ if (stopping) return false;
19782
20048
  const child = spawn2(
19783
20049
  sshBinary,
19784
20050
  ssh.args("-N", "-L", `${port}:127.0.0.1:${remotePort}`, ssh.host),
19785
- {
19786
- stdio: ["ignore", "ignore", "inherit"]
19787
- }
20051
+ { stdio: ["ignore", "ignore", "inherit"] }
19788
20052
  );
19789
- child.on("close", onChildExit);
19790
20053
  state.child = child;
19791
- return waitForTcp(port, 5e3);
20054
+ const ready = await waitForTcp(port, 5e3) && await isReady(port);
20055
+ if (!ready) discard(child, port);
20056
+ return ready;
19792
20057
  };
19793
- const onChildExit = () => {
20058
+ const discard = (child, port) => {
20059
+ if (state.child === child) state.child = null;
20060
+ child.kill("SIGTERM");
20061
+ void ssh.cancelForward(port, remotePort);
20062
+ };
20063
+ const scheduleHealthCheck = () => {
20064
+ if (stopping || healthTimer !== null) return;
20065
+ healthTimer = setTimeout(() => void healthCheck(), healthIntervalMs);
20066
+ };
20067
+ const healthCheck = async () => {
20068
+ healthTimer = null;
19794
20069
  if (stopping) return;
19795
- emit({ t: "tunnel-down" });
20070
+ if (reconnectActive) {
20071
+ scheduleHealthCheck();
20072
+ return;
20073
+ }
20074
+ if (await tcpListening(localPort)) {
20075
+ scheduleHealthCheck();
20076
+ return;
20077
+ }
20078
+ if (stopping || reconnectActive) return;
19796
20079
  void reconnect();
19797
20080
  };
19798
20081
  const reconnect = async () => {
20082
+ if (reconnectActive || stopping) return;
20083
+ reconnectActive = true;
20084
+ const flapping = lastRestoredAt !== 0 && Date.now() - lastRestoredAt < STABLE_MS;
20085
+ let announced = false;
20086
+ const announce = () => {
20087
+ announced = true;
20088
+ emit({ t: "tunnel-down" });
20089
+ };
20090
+ const grace = flapping ? null : setTimeout(announce, downGraceMs);
20091
+ if (flapping) announce();
19799
20092
  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;
20093
+ let restored = false;
20094
+ try {
20095
+ while (!stopping) {
20096
+ await sleep(delay);
20097
+ if (stopping) break;
20098
+ if (!await ssh.isAlive()) {
20099
+ try {
20100
+ await ssh.open();
20101
+ } catch {
20102
+ delay = Math.min(delay * 2, RECONNECT_MAX_MS);
20103
+ continue;
20104
+ }
19809
20105
  }
20106
+ if (await spawnForward(localPort)) {
20107
+ restored = true;
20108
+ break;
20109
+ }
20110
+ const port = await findFreePort();
20111
+ if (await spawnForward(port)) {
20112
+ localPort = port;
20113
+ portCbs.forEach((cb) => cb(port));
20114
+ restored = true;
20115
+ break;
20116
+ }
20117
+ delay = Math.min(delay * 2, RECONNECT_MAX_MS);
20118
+ logger.warn(`tunnel to ${ssh.host} not back yet \u2014 retrying`);
19810
20119
  }
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;
19823
- }
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`);
20120
+ } finally {
20121
+ if (grace !== null) clearTimeout(grace);
20122
+ reconnectActive = false;
20123
+ }
20124
+ if (stopping) {
20125
+ state.child?.kill("SIGTERM");
20126
+ return;
20127
+ }
20128
+ if (restored) {
20129
+ lastRestoredAt = Date.now();
20130
+ if (announced) emit({ t: "tunnel-up" });
19827
20131
  }
20132
+ scheduleHealthCheck();
19828
20133
  };
19829
- if (!await spawnForward(localPort)) {
19830
- state.child?.kill();
20134
+ const initialReady = await spawnForward(localPort);
20135
+ if (!initialReady) {
20136
+ stopping = true;
19831
20137
  throw new CliError(
19832
20138
  "ssh_unreachable",
19833
20139
  `could not open a tunnel to ${ssh.host}:${remotePort}`,
19834
20140
  "is the daemon running on the host? try: puddle status " + ssh.host
19835
20141
  );
19836
20142
  }
20143
+ scheduleHealthCheck();
19837
20144
  return {
19838
20145
  get localPort() {
19839
20146
  return localPort;
@@ -19848,19 +20155,76 @@ async function openTunnel(ssh, remotePort, opts = {}) {
19848
20155
  },
19849
20156
  close() {
19850
20157
  stopping = true;
19851
- return new Promise((resolve3) => {
19852
- const child = state.child;
19853
- if (child === null || child.exitCode !== null) return resolve3();
19854
- child.once("close", () => resolve3());
19855
- child.kill("SIGTERM");
19856
- });
20158
+ if (healthTimer !== null) {
20159
+ clearTimeout(healthTimer);
20160
+ healthTimer = null;
20161
+ }
20162
+ const child = state.child;
20163
+ state.child = null;
20164
+ child?.kill("SIGTERM");
20165
+ return ssh.cancelForward(localPort, remotePort);
19857
20166
  }
19858
20167
  };
19859
20168
  }
19860
20169
 
19861
- // src/lib/transport/ssh.ts
20170
+ // src/lib/transport/local.ts
19862
20171
  import { spawn as spawn3 } from "node:child_process";
19863
- import { mkdirSync } from "node:fs";
20172
+ import { copyFileSync, mkdirSync, readFileSync as readFileSync3 } from "node:fs";
20173
+ import { dirname as dirname3 } from "node:path";
20174
+ var LocalTransport = class {
20175
+ kind = "local";
20176
+ label = "this machine";
20177
+ exec(command, opts = {}) {
20178
+ return new Promise((resolve3) => {
20179
+ const child = spawn3("sh", ["-c", command], { stdio: ["pipe", "pipe", "pipe"] });
20180
+ let stdout = "";
20181
+ let stderr = "";
20182
+ let timer;
20183
+ if (opts.timeoutMs !== void 0) {
20184
+ timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
20185
+ timer.unref();
20186
+ }
20187
+ child.stdout.on("data", (chunk) => {
20188
+ const text = chunk.toString();
20189
+ stdout += text;
20190
+ opts.onStdout?.(text);
20191
+ });
20192
+ child.stderr.on("data", (chunk) => stderr += chunk.toString());
20193
+ child.on("error", (err) => {
20194
+ if (timer) clearTimeout(timer);
20195
+ resolve3({ code: -1, stdout, stderr: stderr + String(err) });
20196
+ });
20197
+ child.on("close", (code) => {
20198
+ if (timer) clearTimeout(timer);
20199
+ resolve3({ code: code ?? -1, stdout, stderr });
20200
+ });
20201
+ if (opts.stdin !== void 0) child.stdin.end(opts.stdin);
20202
+ else child.stdin.end();
20203
+ });
20204
+ }
20205
+ readFile(path) {
20206
+ try {
20207
+ return Promise.resolve(readFileSync3(expandHome(path), "utf8"));
20208
+ } catch {
20209
+ return Promise.resolve(null);
20210
+ }
20211
+ }
20212
+ copyTo(localPath, destPath) {
20213
+ const dest = expandHome(destPath);
20214
+ mkdirSync(dirname3(dest), { recursive: true });
20215
+ copyFileSync(localPath, dest);
20216
+ return Promise.resolve();
20217
+ }
20218
+ dispose() {
20219
+ }
20220
+ };
20221
+ function expandHome(path) {
20222
+ return path.startsWith('"${PUDDLE_HOME:-$HOME/.puddle}"') ? clientHome() + path.slice('"${PUDDLE_HOME:-$HOME/.puddle}"'.length) : path;
20223
+ }
20224
+
20225
+ // src/lib/transport/ssh.ts
20226
+ import { spawn as spawn4 } from "node:child_process";
20227
+ import { mkdirSync as mkdirSync2 } from "node:fs";
19864
20228
  init_types();
19865
20229
  var SshTransport = class {
19866
20230
  constructor(host, opts = {}) {
@@ -19873,7 +20237,7 @@ var SshTransport = class {
19873
20237
  this.controlArgs = [];
19874
20238
  } else {
19875
20239
  const home = clientHome();
19876
- mkdirSync(home, { recursive: true });
20240
+ mkdirSync2(home, { recursive: true });
19877
20241
  this.controlArgs = [
19878
20242
  "-o",
19879
20243
  "ControlMaster=auto",
@@ -19885,18 +20249,20 @@ var SshTransport = class {
19885
20249
  "ControlPersist=10m"
19886
20250
  ];
19887
20251
  }
20252
+ this.keepaliveArgs = ["-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3"];
19888
20253
  }
19889
20254
  kind = "ssh";
19890
20255
  label;
19891
20256
  ssh;
19892
20257
  scp;
19893
20258
  controlArgs;
20259
+ keepaliveArgs;
19894
20260
  get hasControlMaster() {
19895
20261
  return this.controlArgs.length > 0;
19896
20262
  }
19897
20263
  /** Argv prefix every ssh/scp spawn shares. */
19898
20264
  args(...rest) {
19899
- return [...this.controlArgs, ...rest];
20265
+ return [...this.controlArgs, ...this.keepaliveArgs, ...rest];
19900
20266
  }
19901
20267
  /**
19902
20268
  * Open the master interactively — ssh's own prompts go straight to the
@@ -19904,7 +20270,7 @@ var SshTransport = class {
19904
20270
  */
19905
20271
  open() {
19906
20272
  return new Promise((resolve3, reject) => {
19907
- const child = spawn3(this.ssh, this.args(this.host, "true"), { stdio: "inherit" });
20273
+ const child = spawn4(this.ssh, this.args(this.host, "true"), { stdio: "inherit" });
19908
20274
  child.on(
19909
20275
  "error",
19910
20276
  (err) => reject(new CliError("ssh_unreachable", `could not run ${this.ssh}: ${err.message}`))
@@ -19927,14 +20293,33 @@ var SshTransport = class {
19927
20293
  isAlive() {
19928
20294
  if (!this.hasControlMaster) return Promise.resolve(true);
19929
20295
  return new Promise((resolve3) => {
19930
- const child = spawn3(this.ssh, this.args("-O", "check", this.host), { stdio: "ignore" });
20296
+ const child = spawn4(this.ssh, this.args("-O", "check", this.host), { stdio: "ignore" });
19931
20297
  child.on("error", () => resolve3(false));
19932
20298
  child.on("close", (code) => resolve3(code === 0));
19933
20299
  });
19934
20300
  }
20301
+ /**
20302
+ * Best-effort removal of a multiplexed local forward (`-O cancel`). A
20303
+ * non-OpenSSH server (Tailscale SSH) attaches the `-L` to the master and it
20304
+ * outlives the spawning client — killing that client does not remove it, so
20305
+ * the forward must be cancelled explicitly or the local port stays forwarded
20306
+ * until the master lapses. A no-op (and unnecessary) without a master, where
20307
+ * the forward dies with its own ssh process.
20308
+ */
20309
+ cancelForward(localPort, remotePort) {
20310
+ if (!this.hasControlMaster) return Promise.resolve();
20311
+ return new Promise((resolve3) => {
20312
+ const spec = `${localPort}:127.0.0.1:${remotePort}`;
20313
+ const child = spawn4(this.ssh, this.args("-O", "cancel", "-L", spec, this.host), {
20314
+ stdio: "ignore"
20315
+ });
20316
+ child.on("error", () => resolve3());
20317
+ child.on("close", () => resolve3());
20318
+ });
20319
+ }
19935
20320
  exec(command, opts = {}) {
19936
20321
  return new Promise((resolve3) => {
19937
- const child = spawn3(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
20322
+ const child = spawn4(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
19938
20323
  stdio: ["pipe", "pipe", "pipe"]
19939
20324
  });
19940
20325
  let stdout = "";
@@ -19969,7 +20354,7 @@ var SshTransport = class {
19969
20354
  async copyTo(localPath, destPath) {
19970
20355
  await this.exec(`mkdir -p $(dirname ${destPath})`, { timeoutMs: 15e3 });
19971
20356
  await new Promise((resolve3, reject) => {
19972
- const child = spawn3(this.scp, [...this.controlArgs, localPath, `${this.host}:${destPath}`], {
20357
+ const child = spawn4(this.scp, [...this.controlArgs, localPath, `${this.host}:${destPath}`], {
19973
20358
  stdio: ["ignore", "ignore", "inherit"]
19974
20359
  });
19975
20360
  child.on("error", reject);
@@ -20005,7 +20390,11 @@ async function connectRemote(opts) {
20005
20390
  const bootstrap = { tarball: opts.tarball, logger };
20006
20391
  const endpoint = await ensureDaemon(ssh, bootstrap);
20007
20392
  const remotePort = opts.remotePort ?? endpoint.port;
20008
- const tunnel = await openTunnel(ssh, remotePort, { sshBinary: opts.sshBinary, logger });
20393
+ const tunnel = await openTunnel(ssh, remotePort, {
20394
+ sshBinary: opts.sshBinary,
20395
+ logger,
20396
+ ready: (localPort) => waitForHttp(`http://127.0.0.1:${localPort}/api/version`, 8e3)
20397
+ });
20009
20398
  const client = new DaemonClient(tunnel.localPort, endpoint.token);
20010
20399
  tunnel.onPortChange((port) => client.setPort(port));
20011
20400
  const daemon = await runHandshake({
@@ -20014,10 +20403,12 @@ async function connectRemote(opts) {
20014
20403
  upgradeDaemon: makeUpgrader(ssh, client, bootstrap),
20015
20404
  logger
20016
20405
  });
20406
+ const avoidPort = await readDaemonPort(new LocalTransport());
20017
20407
  const ui = await startUiServer({
20018
20408
  assetsDir: opts.assetsDir,
20019
20409
  port: opts.port,
20020
20410
  strictPort: opts.port !== void 0,
20411
+ avoidPort,
20021
20412
  target: { host: "127.0.0.1", port: tunnel.localPort }
20022
20413
  });
20023
20414
  tunnel.onPortChange((port) => ui.setTarget({ host: "127.0.0.1", port }));
@@ -20030,6 +20421,7 @@ async function connectRemote(opts) {
20030
20421
  return {
20031
20422
  origin: ui.origin,
20032
20423
  browserUrl: `${ui.origin}/?host=${encodeURIComponent(opts.host)}#token=${endpoint.token}`,
20424
+ nonce: ui.nonce,
20033
20425
  daemon,
20034
20426
  onEvent(cb) {
20035
20427
  eventCbs.add(cb);
@@ -20066,59 +20458,77 @@ async function showLogs(transport, opts) {
20066
20458
  await transport.exec(command, { onStdout: opts.write });
20067
20459
  }
20068
20460
 
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
- });
20461
+ // src/lib/registry.ts
20462
+ import { mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync4, rmSync, writeFileSync } from "node:fs";
20463
+ import { join as join5 } from "node:path";
20464
+ function cockpitsDir(env) {
20465
+ return join5(clientHome(env), "cockpits");
20466
+ }
20467
+ function slug(target) {
20468
+ return target.replace(/[^A-Za-z0-9@._-]/g, "_");
20469
+ }
20470
+ function cockpitRecordPath(target) {
20471
+ return join5(cockpitsDir(), `${slug(target)}.json`);
20472
+ }
20473
+ function cockpitLogPath(target) {
20474
+ return join5(clientHome(), "logs", `cockpit-${slug(target)}.log`);
20475
+ }
20476
+ function writeCockpitRecord(record2) {
20477
+ mkdirSync3(cockpitsDir(), { recursive: true, mode: 448 });
20478
+ writeFileSync(cockpitRecordPath(record2.target), JSON.stringify(record2, null, 2) + "\n", {
20479
+ mode: 384
20480
+ });
20481
+ }
20482
+ function readCockpitRecord(target) {
20483
+ try {
20484
+ return JSON.parse(readFileSync4(cockpitRecordPath(target), "utf8"));
20485
+ } catch {
20486
+ return null;
20103
20487
  }
20104
- readFile(path) {
20488
+ }
20489
+ function removeCockpitRecord(target) {
20490
+ rmSync(cockpitRecordPath(target), { force: true });
20491
+ }
20492
+ function listCockpitRecords() {
20493
+ let names;
20494
+ try {
20495
+ names = readdirSync(cockpitsDir());
20496
+ } catch {
20497
+ return [];
20498
+ }
20499
+ const records = [];
20500
+ for (const name of names) {
20501
+ if (!name.endsWith(".json")) continue;
20105
20502
  try {
20106
- return Promise.resolve(readFileSync3(expandHome(path), "utf8"));
20503
+ records.push(JSON.parse(readFileSync4(join5(cockpitsDir(), name), "utf8")));
20107
20504
  } catch {
20108
- return Promise.resolve(null);
20109
20505
  }
20110
20506
  }
20111
- copyTo(localPath, destPath) {
20112
- const dest = expandHome(destPath);
20113
- mkdirSync2(dirname3(dest), { recursive: true });
20114
- copyFileSync(localPath, dest);
20115
- return Promise.resolve();
20507
+ return records.sort((a, b) => a.target.localeCompare(b.target));
20508
+ }
20509
+ function isPidAlive(pid) {
20510
+ try {
20511
+ process.kill(pid, 0);
20512
+ return true;
20513
+ } catch {
20514
+ return false;
20116
20515
  }
20117
- dispose() {
20516
+ }
20517
+ async function checkCockpit(record2) {
20518
+ if (!isPidAlive(record2.pid)) return "dead";
20519
+ if (record2.status === "starting") return "starting";
20520
+ if (record2.status !== "ready" || record2.origin === void 0 || record2.nonce === void 0) {
20521
+ return "unverified";
20522
+ }
20523
+ try {
20524
+ const res = await fetch(`${record2.origin}/`, {
20525
+ method: "HEAD",
20526
+ signal: AbortSignal.timeout(1500)
20527
+ });
20528
+ return res.headers.get("x-puddle-cockpit") === record2.nonce ? "running" : "unverified";
20529
+ } catch {
20530
+ return "unverified";
20118
20531
  }
20119
- };
20120
- function expandHome(path) {
20121
- return path.startsWith('"${PUDDLE_HOME:-$HOME/.puddle}"') ? clientHome() + path.slice('"${PUDDLE_HOME:-$HOME/.puddle}"'.length) : path;
20122
20532
  }
20123
20533
 
20124
20534
  // src/lib/start.ts
@@ -20139,12 +20549,15 @@ async function startLocal(opts) {
20139
20549
  assetsDir: opts.assetsDir,
20140
20550
  port: opts.port,
20141
20551
  strictPort: opts.port !== void 0,
20552
+ avoidPort: endpoint.port,
20553
+ // never squat the daemon's own port
20142
20554
  target: { host: "127.0.0.1", port: endpoint.port }
20143
20555
  });
20144
20556
  const eventCbs = /* @__PURE__ */ new Set();
20145
20557
  return {
20146
20558
  origin: ui.origin,
20147
20559
  browserUrl: `${ui.origin}/#token=${endpoint.token}`,
20560
+ nonce: ui.nonce,
20148
20561
  daemon,
20149
20562
  onEvent(cb) {
20150
20563
  eventCbs.add(cb);
@@ -20199,6 +20612,129 @@ async function upgradeDaemon(transport, opts = {}) {
20199
20612
  return { from, to: opts.version ?? pinnedDaemonVersion(), liveSessions };
20200
20613
  }
20201
20614
 
20615
+ // src/cli/detach.ts
20616
+ import { spawn as spawn5 } from "node:child_process";
20617
+ import { closeSync, mkdirSync as mkdirSync4, openSync, readFileSync as readFileSync5 } from "node:fs";
20618
+ import { dirname as dirname4 } from "node:path";
20619
+ init_types();
20620
+ var COCKPIT_CHILD_ENV = "PUDDLE_COCKPIT_CHILD";
20621
+ var READY_TIMEOUT_MS = 3e5;
20622
+ function isCockpitChild(env = process.env) {
20623
+ return env[COCKPIT_CHILD_ENV] === "1";
20624
+ }
20625
+ function killHint(target) {
20626
+ return target === "local" ? "stop it with: puddle kill" : `stop it with: puddle kill ${target}`;
20627
+ }
20628
+ async function launchDetached(opts) {
20629
+ const { target, logger } = opts;
20630
+ const existing = readCockpitRecord(target);
20631
+ if (existing !== null) {
20632
+ const liveness = await checkCockpit(existing);
20633
+ if (liveness === "running") {
20634
+ logger.info(`a cockpit for ${target} is already running at ${existing.origin ?? "?"}`);
20635
+ if (existing.browserUrl !== void 0) {
20636
+ if (!opts.noBrowser) openBrowser(existing.browserUrl);
20637
+ logger.info(`open: ${existing.browserUrl}`);
20638
+ }
20639
+ logger.info(killHint(target));
20640
+ return 0;
20641
+ }
20642
+ if (liveness === "starting") {
20643
+ logger.info(
20644
+ `a cockpit for ${target} is already starting (pid ${existing.pid}) \u2014 puddle list to watch it`
20645
+ );
20646
+ return 0;
20647
+ }
20648
+ if (liveness === "unverified") {
20649
+ throw new CliError(
20650
+ "already_running",
20651
+ `something holds the cockpit record for ${target} (pid ${existing.pid}) but did not answer at ${existing.origin ?? "its recorded origin"}`,
20652
+ `if it is defunct: puddle kill ${target === "local" ? "local" : target}`
20653
+ );
20654
+ }
20655
+ removeCockpitRecord(target);
20656
+ }
20657
+ if (target !== "local") await new SshTransport(target).open();
20658
+ const logFile = cockpitLogPath(target);
20659
+ mkdirSync4(dirname4(logFile), { recursive: true });
20660
+ const fd = openSync(logFile, "w");
20661
+ const child = spawn5(process.execPath, [process.argv[1] ?? "", ...process.argv.slice(2)], {
20662
+ detached: true,
20663
+ stdio: ["ignore", fd, fd],
20664
+ env: { ...process.env, [COCKPIT_CHILD_ENV]: "1" }
20665
+ });
20666
+ closeSync(fd);
20667
+ child.unref();
20668
+ if (child.pid === void 0) {
20669
+ throw new CliError("cockpit_launch_failed", "could not spawn the background cockpit process");
20670
+ }
20671
+ logger.info(`starting the cockpit for ${target} in the background\u2026`);
20672
+ const record2 = await followStartup(target, child.pid, logFile);
20673
+ if (record2 === null || record2.status !== "ready") {
20674
+ if (record2?.status === "error") {
20675
+ logger.warn(record2.message ?? "the cockpit failed to start");
20676
+ if (record2.hint !== void 0) logger.info(` hint: ${record2.hint}`);
20677
+ } else {
20678
+ logger.warn(`the cockpit did not report ready \u2014 its log: ${logFile}`);
20679
+ }
20680
+ logger.info(" (a run that stays on this terminal: add --foreground)");
20681
+ const current = readCockpitRecord(target);
20682
+ if (current !== null && current.pid === child.pid) removeCockpitRecord(target);
20683
+ return 1;
20684
+ }
20685
+ const arrow = target === "local" ? "" : ` \u2192 ${target}`;
20686
+ logger.info(`puddle cockpit at ${record2.origin ?? "?"}${arrow} \u2014 running in the background`);
20687
+ if (record2.browserUrl !== void 0) {
20688
+ if (!opts.noBrowser) openBrowser(record2.browserUrl);
20689
+ logger.info(`open: ${record2.browserUrl}`);
20690
+ }
20691
+ logger.info(killHint(target));
20692
+ return 0;
20693
+ }
20694
+ async function followStartup(target, childPid, logFile) {
20695
+ let offset = 0;
20696
+ const relay = () => {
20697
+ try {
20698
+ const buf = readFileSync5(logFile);
20699
+ if (buf.length > offset) {
20700
+ process.stderr.write(buf.subarray(offset));
20701
+ offset = buf.length;
20702
+ }
20703
+ } catch {
20704
+ }
20705
+ };
20706
+ const deadline = Date.now() + READY_TIMEOUT_MS;
20707
+ while (Date.now() < deadline) {
20708
+ relay();
20709
+ const record2 = readCockpitRecord(target);
20710
+ if (record2 !== null && record2.pid === childPid && record2.status !== "starting") return record2;
20711
+ if (!isPidAlive(childPid)) {
20712
+ relay();
20713
+ const last2 = readCockpitRecord(target);
20714
+ return last2 !== null && last2.pid === childPid ? last2 : null;
20715
+ }
20716
+ await sleep(200);
20717
+ }
20718
+ const last = readCockpitRecord(target);
20719
+ return last !== null && last.pid === childPid ? last : null;
20720
+ }
20721
+ async function terminateCockpit(record2) {
20722
+ try {
20723
+ process.kill(record2.pid, "SIGTERM");
20724
+ } catch {
20725
+ }
20726
+ const deadline = Date.now() + 5e3;
20727
+ while (Date.now() < deadline && isPidAlive(record2.pid)) await sleep(100);
20728
+ if (isPidAlive(record2.pid)) {
20729
+ try {
20730
+ process.kill(record2.pid, "SIGKILL");
20731
+ } catch {
20732
+ }
20733
+ }
20734
+ const current = readCockpitRecord(record2.target);
20735
+ if (current === null || current.pid === record2.pid) removeCockpitRecord(record2.target);
20736
+ }
20737
+
20202
20738
  // src/cli/output.ts
20203
20739
  function terminalLogger() {
20204
20740
  return {
@@ -20212,12 +20748,24 @@ function terminalLogger() {
20212
20748
  }
20213
20749
  };
20214
20750
  }
20751
+ function timestampedLogger() {
20752
+ const line = (message) => `${(/* @__PURE__ */ new Date()).toISOString()} ${message}
20753
+ `;
20754
+ return {
20755
+ info(message) {
20756
+ process.stderr.write(line(message));
20757
+ },
20758
+ warn(message) {
20759
+ process.stderr.write(line(`puddle: ${message}`));
20760
+ }
20761
+ };
20762
+ }
20215
20763
 
20216
20764
  // src/cli/run.ts
20217
20765
  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;
20766
+ const here = dirname5(fileURLToPath3(import.meta.url));
20767
+ for (const candidate of [join6(here, "public"), join6(here, "..", "..", "dist", "public")]) {
20768
+ if (existsSync3(join6(candidate, "index.html"))) return candidate;
20221
20769
  }
20222
20770
  throw new CliError(
20223
20771
  "not_installed",
@@ -20240,7 +20788,9 @@ async function openTarget(host) {
20240
20788
  let port = daemonPort;
20241
20789
  let tunnel = null;
20242
20790
  if (transport instanceof SshTransport) {
20243
- tunnel = await openTunnel(transport, daemonPort);
20791
+ tunnel = await openTunnel(transport, daemonPort, {
20792
+ ready: (localPort) => waitForHttp(`http://127.0.0.1:${localPort}/api/version`, 8e3)
20793
+ });
20244
20794
  port = tunnel.localPort;
20245
20795
  }
20246
20796
  return {
@@ -20280,28 +20830,79 @@ async function run(command) {
20280
20830
  process.stdout.write(`puddle ${cliVersion()}
20281
20831
  `);
20282
20832
  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"
20833
+ case "start":
20834
+ case "connect": {
20835
+ const target = command.cmd === "connect" ? command.host : "local";
20836
+ if (!command.foreground && !isCockpitChild()) {
20837
+ return launchDetached({ target, noBrowser: command.noBrowser, logger });
20838
+ }
20839
+ return runCockpit(command, target, logger);
20840
+ }
20841
+ case "list": {
20842
+ const rows = [];
20843
+ for (const record2 of listCockpitRecords()) {
20844
+ const liveness = await checkCockpit(record2);
20845
+ if (liveness === "dead") {
20846
+ removeCockpitRecord(record2.target);
20847
+ continue;
20848
+ }
20849
+ rows.push({ record: record2, liveness });
20850
+ }
20851
+ if (rows.length === 0) {
20852
+ process.stdout.write("no cockpits are running\n");
20853
+ return 0;
20854
+ }
20855
+ process.stdout.write(
20856
+ `${pad("TARGET", 26)} ${pad("STATUS", 9)} ${pad("PID", 7)} ${pad("UI", 24)} STARTED
20857
+ `
20291
20858
  );
20859
+ for (const { record: record2, liveness } of rows) {
20860
+ process.stdout.write(
20861
+ `${pad(record2.target, 26)} ${pad(liveness, 9)} ${pad(String(record2.pid), 7)} ${pad(record2.origin ?? "\u2014", 24)} ${record2.startedAt}
20862
+ `
20863
+ );
20864
+ }
20292
20865
  return 0;
20293
20866
  }
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
- );
20867
+ case "kill": {
20868
+ const live = [];
20869
+ for (const record2 of listCockpitRecords()) {
20870
+ if (await checkCockpit(record2) === "dead") removeCockpitRecord(record2.target);
20871
+ else live.push(record2);
20872
+ }
20873
+ let victims;
20874
+ if (command.all) {
20875
+ if (live.length === 0) {
20876
+ logger.info("no cockpits are running");
20877
+ return 0;
20878
+ }
20879
+ victims = live;
20880
+ } else if (command.target !== void 0) {
20881
+ const found = live.find((r) => r.target === command.target);
20882
+ if (found === void 0) {
20883
+ throw new CliError(
20884
+ "no_cockpit",
20885
+ `no cockpit is running for ${command.target}`,
20886
+ live.length > 0 ? `running: ${live.map((r) => r.target).join(", ")}` : "see: puddle list"
20887
+ );
20888
+ }
20889
+ victims = [found];
20890
+ } else {
20891
+ if (live.length === 0) throw new CliError("no_cockpit", "no cockpits are running");
20892
+ if (live.length > 1) {
20893
+ throw new CliError(
20894
+ "bad_arguments",
20895
+ "several cockpits are running \u2014 name the one to kill",
20896
+ `puddle kill <${live.map((r) => r.target).join(" | ")}> (or --all)`
20897
+ );
20898
+ }
20899
+ victims = live;
20900
+ }
20901
+ for (const victim of victims) {
20902
+ await terminateCockpit(victim);
20903
+ const where = victim.target === "local" ? "this machine" : victim.target;
20904
+ logger.info(`stopped the cockpit for ${victim.target} \u2014 sessions keep running on ${where}`);
20905
+ }
20305
20906
  return 0;
20306
20907
  }
20307
20908
  case "status": {
@@ -20400,6 +21001,65 @@ async function run(command) {
20400
21001
  }
20401
21002
  }
20402
21003
  }
21004
+ async function runCockpit(command, target, terminal) {
21005
+ const detached = isCockpitChild();
21006
+ const logger = detached ? timestampedLogger() : terminal;
21007
+ const existing = readCockpitRecord(target);
21008
+ if (existing !== null && existing.pid !== process.pid) {
21009
+ if (await checkCockpit(existing) !== "dead") {
21010
+ throw new CliError(
21011
+ "already_running",
21012
+ `a cockpit for ${target} is already running at ${existing.origin ?? `pid ${existing.pid}`}`,
21013
+ killHint(target).replace("stop it", "stop it first")
21014
+ );
21015
+ }
21016
+ removeCockpitRecord(target);
21017
+ }
21018
+ const base = {
21019
+ target,
21020
+ pid: process.pid,
21021
+ status: "starting",
21022
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
21023
+ cliVersion: cliVersion(),
21024
+ ...detached ? { logFile: cockpitLogPath(target) } : {}
21025
+ };
21026
+ writeCockpitRecord(base);
21027
+ let cockpit;
21028
+ try {
21029
+ cockpit = command.cmd === "start" ? await startLocal({ ...command, assetsDir: assetsDir(), logger }) : await connectRemote({ ...command, assetsDir: assetsDir(), logger });
21030
+ } catch (err) {
21031
+ if (err instanceof CliError) {
21032
+ writeCockpitRecord({
21033
+ ...base,
21034
+ status: "error",
21035
+ message: err.message,
21036
+ ...err.hint !== void 0 ? { hint: err.hint } : {}
21037
+ });
21038
+ } else {
21039
+ removeCockpitRecord(target);
21040
+ }
21041
+ throw err;
21042
+ }
21043
+ writeCockpitRecord({
21044
+ ...base,
21045
+ status: "ready",
21046
+ origin: cockpit.origin,
21047
+ browserUrl: cockpit.browserUrl,
21048
+ nonce: cockpit.nonce
21049
+ });
21050
+ const arrow = command.cmd === "connect" ? ` \u2192 ${command.host}` : "";
21051
+ logger.info(`puddle cockpit at ${cockpit.origin}${arrow} (daemon ${cockpit.daemon.version})`);
21052
+ if (!detached) {
21053
+ if (!command.noBrowser) openBrowser(cockpit.browserUrl);
21054
+ logger.info(`open: ${cockpit.browserUrl}`);
21055
+ }
21056
+ const where = command.cmd === "start" ? "this machine" : command.host;
21057
+ await runUntilInterrupted(async () => {
21058
+ await cockpit.stop();
21059
+ removeCockpitRecord(target);
21060
+ }, `cockpit closed \u2014 sessions keep running on ${where}`);
21061
+ return 0;
21062
+ }
20403
21063
  function pad(text, width) {
20404
21064
  return text.length >= width ? text.slice(0, width - 1) + "\u2026" : text.padEnd(width);
20405
21065
  }