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