@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.
- package/CHANGELOG.md +36 -0
- package/README.md +112 -0
- package/dist/index.js +825 -165
- package/dist/install.sh +8 -0
- package/dist/public/assets/Dashboard-zbeaP4WM.js +1 -0
- package/dist/public/assets/EditorTabClose-Bujs4cab.js +1 -0
- package/dist/public/assets/ModelRefcount-BN4jRD-x.js +1 -0
- package/dist/public/assets/PaneEditorBody-COdCF9BY.js +1 -0
- package/dist/public/assets/SettingsDialog-CKW2AM11.js +1 -0
- package/dist/public/assets/{Terminal-CuEf-GeX.js → Terminal-hw3UjYEu.js} +2 -2
- package/dist/public/assets/Workspace-DrDkPyrd.js +6 -0
- package/dist/public/assets/api-BJKPJeFZ.js +73 -0
- package/dist/public/assets/buffer-logic-BrpRsL1b.js +1 -0
- package/dist/public/assets/buffer-store-BGy4Fj9o.js +16 -0
- package/dist/public/assets/context-menu-CnHdXMkI.js +1 -0
- package/dist/public/assets/createLucideIcon-DyMAgLa3.js +1 -0
- package/dist/public/assets/cssMode-DuLDfCcC.js +1 -0
- package/dist/public/assets/debounce-B9DrqirV.js +1 -0
- package/dist/public/assets/dialog-B43iKmfK.js +41 -0
- package/dist/public/assets/dist-fdPAlKke.js +1 -0
- package/dist/public/assets/editor-sync-F3Wyveaa.js +1 -0
- package/dist/public/assets/editor-tabs-Cw21CVB0.js +1 -0
- package/dist/public/assets/htmlMode-DaGDGb6z.js +1 -0
- package/dist/public/assets/index-wPaOVZ8U.js +10 -0
- package/dist/public/assets/index-y2Zzy0lJ.css +2 -0
- package/dist/public/assets/{jsonMode-BijgXT6I.js → jsonMode-Ekn8HPfg.js} +1 -1
- package/dist/public/assets/jsx-runtime-ffCh33zF.js +1 -0
- package/dist/public/assets/react-CZI7_Jkm.js +1 -0
- package/dist/public/assets/ssh-mode-CghPpMrk.js +1 -0
- package/dist/public/assets/switch-CQITZ7yg.js +1 -0
- package/dist/public/assets/{tsMode-B7q3gz7a.js → tsMode-CsjWL1Qz.js} +1 -1
- package/dist/public/assets/useMutation-Da9b4jwS.js +1 -0
- package/dist/public/assets/utils-B6KiDbIe.js +1 -0
- package/dist/public/index.html +9 -4
- package/package.json +14 -3
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/public/assets/Dashboard-B1JE6YHV.js +0 -1
- package/dist/public/assets/EditorZone-BO-Ob9Td.js +0 -16
- package/dist/public/assets/SettingsDialog-CVcCiV13.js +0 -1
- package/dist/public/assets/Workspace-DctKubgC.js +0 -2
- package/dist/public/assets/api-DeKerbkb.js +0 -64
- package/dist/public/assets/cssMode-CSjHtANE.js +0 -1
- package/dist/public/assets/dialog-VTyOt2M_.js +0 -41
- package/dist/public/assets/folder-git-2-DX3SNYUW.js +0 -1
- package/dist/public/assets/history-logic-B2ZKE3AW.js +0 -1
- package/dist/public/assets/htmlMode-CIHMS93l.js +0 -1
- package/dist/public/assets/index-CXBMvmhw.css +0 -2
- package/dist/public/assets/index-ZcdMfr13.js +0 -10
- package/dist/public/assets/monaco.contribution-C0TYpF88.js +0 -2
- package/dist/public/assets/ssh-mode-BR2ZdOHI.js +0 -1
- package/dist/public/assets/switch-Ck_7k8fh.js +0 -1
- 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]
|
|
3912
|
-
|
|
3913
|
-
|
|
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.
|
|
3923
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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
|
-
|
|
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().
|
|
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.
|
|
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
|
|
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 (
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
19395
|
-
|
|
19396
|
-
|
|
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
|
-
|
|
19404
|
-
|
|
19405
|
-
|
|
19406
|
-
|
|
19407
|
-
|
|
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
|
-
|
|
19415
|
-
|
|
19416
|
-
|
|
19417
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
20054
|
+
const ready = await waitForTcp(port, 5e3) && await isReady(port);
|
|
20055
|
+
if (!ready) discard(child, port);
|
|
20056
|
+
return ready;
|
|
19792
20057
|
};
|
|
19793
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
19801
|
-
|
|
19802
|
-
|
|
19803
|
-
|
|
19804
|
-
|
|
19805
|
-
|
|
19806
|
-
|
|
19807
|
-
|
|
19808
|
-
|
|
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
|
-
|
|
19812
|
-
if (
|
|
19813
|
-
|
|
19814
|
-
|
|
19815
|
-
|
|
19816
|
-
state.child?.kill();
|
|
19817
|
-
|
|
19818
|
-
|
|
19819
|
-
|
|
19820
|
-
|
|
19821
|
-
|
|
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
|
-
|
|
19830
|
-
|
|
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
|
-
|
|
19852
|
-
|
|
19853
|
-
|
|
19854
|
-
|
|
19855
|
-
|
|
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/
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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, {
|
|
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/
|
|
20070
|
-
import {
|
|
20071
|
-
import {
|
|
20072
|
-
|
|
20073
|
-
|
|
20074
|
-
|
|
20075
|
-
|
|
20076
|
-
|
|
20077
|
-
|
|
20078
|
-
|
|
20079
|
-
|
|
20080
|
-
|
|
20081
|
-
|
|
20082
|
-
|
|
20083
|
-
|
|
20084
|
-
|
|
20085
|
-
|
|
20086
|
-
|
|
20087
|
-
|
|
20088
|
-
|
|
20089
|
-
|
|
20090
|
-
|
|
20091
|
-
|
|
20092
|
-
|
|
20093
|
-
|
|
20094
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20503
|
+
records.push(JSON.parse(readFileSync4(join5(cockpitsDir(), name), "utf8")));
|
|
20107
20504
|
} catch {
|
|
20108
|
-
return Promise.resolve(null);
|
|
20109
20505
|
}
|
|
20110
20506
|
}
|
|
20111
|
-
|
|
20112
|
-
|
|
20113
|
-
|
|
20114
|
-
|
|
20115
|
-
|
|
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
|
-
|
|
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 =
|
|
20219
|
-
for (const candidate of [
|
|
20220
|
-
if (existsSync3(
|
|
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
|
-
|
|
20285
|
-
|
|
20286
|
-
if (!command.
|
|
20287
|
-
|
|
20288
|
-
|
|
20289
|
-
|
|
20290
|
-
|
|
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 "
|
|
20295
|
-
const
|
|
20296
|
-
|
|
20297
|
-
|
|
20298
|
-
|
|
20299
|
-
|
|
20300
|
-
|
|
20301
|
-
|
|
20302
|
-
|
|
20303
|
-
|
|
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
|
}
|