@puddle-code/cli 0.0.42 → 0.0.46
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 +5 -7
- package/README.md +15 -3
- package/dist/index.js +1338 -870
- package/dist/install.sh +19 -3
- package/dist/public/assets/Dashboard-CGX5SwjL.js +1 -0
- package/dist/public/assets/EditorTabClose-GbJw01R2.js +1 -0
- package/dist/public/assets/LazyTerminal-BrGyjHpN.js +2 -0
- package/dist/public/assets/{ModelRefcount-DOfCr-GB.js → ModelRefcount-BB4HvEjT.js} +1 -1
- package/dist/public/assets/PaneEditorBody-CScvnDsN.js +515 -0
- package/dist/public/assets/SettingsDialog-CW-QjT_1.js +1 -0
- package/dist/public/assets/Terminal-Bx7CnodU.js +142 -0
- package/dist/public/assets/Workspace-DT1HAB03.js +7 -0
- package/dist/public/assets/{buffer-store-lRf0Css5.js → buffer-store-Du5yBe6d.js} +3 -3
- package/dist/public/assets/chevron-down-59O6CgEo.js +1 -0
- package/dist/public/assets/context-menu-Ddj7ho3n.js +1 -0
- package/dist/public/assets/{cssMode-Dc1ahC7j.js → cssMode-Bl5pBA0Y.js} +1 -1
- package/dist/public/assets/dialog-DjL0qoAv.js +41 -0
- package/dist/public/assets/dist-DDFe8e3m.js +1 -0
- package/dist/public/assets/editor-context-D8zZKoyt.js +1 -0
- package/dist/public/assets/{editor-sync-D8bMPmSR.js → editor-sync-Cm-mKgd9.js} +1 -1
- package/dist/public/assets/file-links-Co-4c1Cz.js +1 -0
- package/dist/public/assets/hotkeys-C1l8zghv.js +73 -0
- package/dist/public/assets/{htmlMode-DAayVS36.js → htmlMode-C09I1s01.js} +1 -1
- package/dist/public/assets/index-DDdcyDxs.css +2 -0
- package/dist/public/assets/index-ozoDGVJ_.js +10 -0
- package/dist/public/assets/{jsonMode-B7Y1ZN6D.js → jsonMode-DJi0jtkR.js} +1 -1
- package/dist/public/assets/session-seed-Dd9snd73.js +1 -0
- package/dist/public/assets/{tsMode-CBG_jsQ8.js → tsMode-DyI21oAt.js} +1 -1
- package/dist/public/assets/use-find-controls-aC7Avd1r.js +1 -0
- package/dist/public/assets/x-BJOrp_m5.js +1 -0
- package/dist/public/index.html +6 -8
- package/package.json +1 -1
- package/dist/public/assets/Dashboard-BTrJV1ZL.js +0 -1
- package/dist/public/assets/EditorTabClose-DQ6zIRnK.js +0 -1
- package/dist/public/assets/LazyTerminal-BaxoOzC-.js +0 -2
- package/dist/public/assets/PaneEditorBody-S9haVBER.js +0 -355
- package/dist/public/assets/SettingsDialog-C4zj9xpa.js +0 -1
- package/dist/public/assets/Terminal-7PIU9bCC.js +0 -117
- package/dist/public/assets/Workspace-lnshKyvd.js +0 -7
- package/dist/public/assets/context-menu-BvognaUc.js +0 -1
- package/dist/public/assets/createLucideIcon-DyMAgLa3.js +0 -1
- package/dist/public/assets/dialog-B43iKmfK.js +0 -41
- package/dist/public/assets/dist-i-QevsUb.js +0 -1
- package/dist/public/assets/editor-context-A7YnYVrw.js +0 -1
- package/dist/public/assets/hotkeys-CA5Pxll4.js +0 -73
- package/dist/public/assets/index-DpDy4sdB.js +0 -10
- package/dist/public/assets/index-fOB6mfj_.css +0 -2
- package/dist/public/assets/jsx-runtime-ffCh33zF.js +0 -1
- package/dist/public/assets/session-seed-DSCaVN9j.js +0 -1
- package/dist/public/assets/ssh-mode-Cy629wWo.js +0 -1
- package/dist/public/assets/utils-B6KiDbIe.js +0 -1
package/dist/index.js
CHANGED
|
@@ -3756,7 +3756,7 @@ var init_wrapper = __esm({
|
|
|
3756
3756
|
|
|
3757
3757
|
// src/lib/ws-client.ts
|
|
3758
3758
|
function connectGateway(port, token) {
|
|
3759
|
-
return new Promise((
|
|
3759
|
+
return new Promise((resolve4, reject) => {
|
|
3760
3760
|
const ws = new wrapper_default(`ws://127.0.0.1:${port}/ws`, {
|
|
3761
3761
|
headers: { host: `localhost:${port}` }
|
|
3762
3762
|
});
|
|
@@ -3778,7 +3778,7 @@ function connectGateway(port, token) {
|
|
|
3778
3778
|
ws.on("close", () => closeCbs.forEach((cb) => cb()));
|
|
3779
3779
|
ws.once("open", () => {
|
|
3780
3780
|
ws.send(JSON.stringify({ t: "auth", token }));
|
|
3781
|
-
|
|
3781
|
+
resolve4({
|
|
3782
3782
|
send(message) {
|
|
3783
3783
|
if (ws.readyState === wrapper_default.OPEN) ws.send(JSON.stringify(message));
|
|
3784
3784
|
},
|
|
@@ -3841,7 +3841,7 @@ async function attachSession(opts) {
|
|
|
3841
3841
|
rows: streams.stdout.rows ?? 24
|
|
3842
3842
|
});
|
|
3843
3843
|
gateway.send({ t: "attach", session: session.id, term, ...dims2() });
|
|
3844
|
-
return new Promise((
|
|
3844
|
+
return new Promise((resolve4) => {
|
|
3845
3845
|
let settled = false;
|
|
3846
3846
|
const finish = (outcome) => {
|
|
3847
3847
|
if (settled) return;
|
|
@@ -3850,7 +3850,7 @@ async function attachSession(opts) {
|
|
|
3850
3850
|
stdin.removeListener("data", onStdin);
|
|
3851
3851
|
unResize?.();
|
|
3852
3852
|
gateway.close();
|
|
3853
|
-
|
|
3853
|
+
resolve4(outcome);
|
|
3854
3854
|
};
|
|
3855
3855
|
gateway.onMessage((message) => {
|
|
3856
3856
|
switch (message.t) {
|
|
@@ -3945,6 +3945,9 @@ usage:
|
|
|
3945
3945
|
puddle remove <cli|daemon|desktop> [user@host] [--yes] [--purge]
|
|
3946
3946
|
puddle --version | --help
|
|
3947
3947
|
|
|
3948
|
+
--version lists the installed CLI, daemon, and desktop app with the protocol
|
|
3949
|
+
each speaks; absent local components are reported as not installed.
|
|
3950
|
+
|
|
3948
3951
|
launch serves the cockpit at http://localhost:7433 against the daemon on this
|
|
3949
3952
|
machine when no host (or 'local') is given \u2014 installing it under ~/.puddle if
|
|
3950
3953
|
needed \u2014 and does the same for an SSH host through one tunnel. It keeps
|
|
@@ -3952,8 +3955,8 @@ running in the background once ready, so the terminal may close (--foreground
|
|
|
3952
3955
|
to stay attached; Ctrl-C then stops the cockpit). refresh stops a target's
|
|
3953
3956
|
cockpit (even a wedged one) and runs the full launch flow again \u2014 tunnel,
|
|
3954
3957
|
daemon restart if needed \u2014 keeping the old UI port so open tabs survive.
|
|
3955
|
-
list shows running cockpits; kill stops one
|
|
3956
|
-
|
|
3958
|
+
list shows running cockpits; kill stops one. Sessions keep running under a
|
|
3959
|
+
persistent supervisor; an SSH-attached daemon keeps their state for the next launch.
|
|
3957
3960
|
|
|
3958
3961
|
install puts a component in place: 'daemon' under ~/.puddle on this machine
|
|
3959
3962
|
or a user@host, 'desktop' as the macOS app bundle \u2014 or, on Linux, an
|
|
@@ -4226,8 +4229,8 @@ function argvFor(command) {
|
|
|
4226
4229
|
|
|
4227
4230
|
// src/cli/run.ts
|
|
4228
4231
|
init_attach();
|
|
4229
|
-
import { existsSync as
|
|
4230
|
-
import { dirname as
|
|
4232
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
4233
|
+
import { dirname as dirname8, join as join11 } from "node:path";
|
|
4231
4234
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
4232
4235
|
|
|
4233
4236
|
// src/lib/browser.ts
|
|
@@ -4245,11 +4248,19 @@ function openBrowser(url2, platform = process.platform) {
|
|
|
4245
4248
|
}
|
|
4246
4249
|
}
|
|
4247
4250
|
|
|
4248
|
-
// src/lib/
|
|
4249
|
-
import {
|
|
4251
|
+
// src/lib/component-versions.ts
|
|
4252
|
+
import {
|
|
4253
|
+
existsSync,
|
|
4254
|
+
mkdirSync,
|
|
4255
|
+
readFileSync as readFileSync2,
|
|
4256
|
+
readlinkSync,
|
|
4257
|
+
renameSync,
|
|
4258
|
+
writeFileSync
|
|
4259
|
+
} from "node:fs";
|
|
4260
|
+
import { dirname as dirname3, join as join4, resolve } from "node:path";
|
|
4250
4261
|
|
|
4251
4262
|
// ../shared/src/protocol.ts
|
|
4252
|
-
var PROTOCOL_VERSION = { major: 16, minor:
|
|
4263
|
+
var PROTOCOL_VERSION = { major: 16, minor: 1 };
|
|
4253
4264
|
|
|
4254
4265
|
// ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
|
|
4255
4266
|
var external_exports = {};
|
|
@@ -19517,6 +19528,12 @@ var resolvePathResponseSchema = external_exports.object({
|
|
|
19517
19528
|
line: external_exports.number().int().nullable(),
|
|
19518
19529
|
/** What resolved (15.2). Absent means `file` — pre-15.2 daemons only answered files. */
|
|
19519
19530
|
kind: external_exports.enum(["file", "dir"]).optional(),
|
|
19531
|
+
/**
|
|
19532
|
+
* For a directory contained by the resolution root, its root-relative path
|
|
19533
|
+
* (16.1). This lets Files reveal it in the existing tree instead of entering
|
|
19534
|
+
* an external pinned browse. Absent for directories outside that root.
|
|
19535
|
+
*/
|
|
19536
|
+
relative_path: external_exports.string().optional(),
|
|
19520
19537
|
/**
|
|
19521
19538
|
* Absolute browse root when the file lies OUTSIDE the worktree (15.2):
|
|
19522
19539
|
* `path` is then relative to it — the `external` tab convention (SPEC §8).
|
|
@@ -19789,54 +19806,14 @@ var wsClientMessageSchema = external_exports.discriminatedUnion("t", [
|
|
|
19789
19806
|
})
|
|
19790
19807
|
]);
|
|
19791
19808
|
|
|
19792
|
-
// src/lib/
|
|
19793
|
-
import {
|
|
19794
|
-
|
|
19795
|
-
|
|
19796
|
-
|
|
19797
|
-
|
|
19798
|
-
|
|
19799
|
-
|
|
19800
|
-
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
19801
|
-
srv.close(() => resolve3(port));
|
|
19802
|
-
});
|
|
19803
|
-
});
|
|
19804
|
-
}
|
|
19805
|
-
function tcpListening(port, timeoutMs = 500) {
|
|
19806
|
-
return new Promise((resolve3) => {
|
|
19807
|
-
const sock = connect({ host: "127.0.0.1", port });
|
|
19808
|
-
const done = (ok) => {
|
|
19809
|
-
sock.destroy();
|
|
19810
|
-
resolve3(ok);
|
|
19811
|
-
};
|
|
19812
|
-
sock.setTimeout(timeoutMs, () => done(false));
|
|
19813
|
-
sock.once("connect", () => done(true));
|
|
19814
|
-
sock.once("error", () => done(false));
|
|
19815
|
-
});
|
|
19816
|
-
}
|
|
19817
|
-
async function waitForTcp(port, timeoutMs) {
|
|
19818
|
-
const deadline = Date.now() + timeoutMs;
|
|
19819
|
-
while (Date.now() < deadline) {
|
|
19820
|
-
if (await tcpListening(port)) return true;
|
|
19821
|
-
await sleep(150);
|
|
19822
|
-
}
|
|
19823
|
-
return false;
|
|
19824
|
-
}
|
|
19825
|
-
async function waitForHttp(url2, timeoutMs) {
|
|
19826
|
-
const deadline = Date.now() + timeoutMs;
|
|
19827
|
-
while (Date.now() < deadline) {
|
|
19828
|
-
try {
|
|
19829
|
-
await fetch(url2, { signal: AbortSignal.timeout(2e3) });
|
|
19830
|
-
return true;
|
|
19831
|
-
} catch {
|
|
19832
|
-
await sleep(250);
|
|
19833
|
-
}
|
|
19834
|
-
}
|
|
19835
|
-
return false;
|
|
19836
|
-
}
|
|
19837
|
-
function sleep(ms) {
|
|
19838
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
19839
|
-
}
|
|
19809
|
+
// src/lib/desktop-update.ts
|
|
19810
|
+
import { createHash } from "node:crypto";
|
|
19811
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
19812
|
+
import { constants, createReadStream, createWriteStream } from "node:fs";
|
|
19813
|
+
import { access, chmod, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
19814
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
19815
|
+
import { Readable } from "node:stream";
|
|
19816
|
+
import { pipeline } from "node:stream/promises";
|
|
19840
19817
|
|
|
19841
19818
|
// src/lib/paths.ts
|
|
19842
19819
|
import { homedir } from "node:os";
|
|
@@ -19852,116 +19829,12 @@ var hostPaths = {
|
|
|
19852
19829
|
runtime: `${HOST_HOME}/runtime.json`,
|
|
19853
19830
|
current: `${HOST_HOME}/bin/current`,
|
|
19854
19831
|
cache: `${HOST_HOME}/cache`,
|
|
19855
|
-
logs: `${HOST_HOME}/logs
|
|
19856
|
-
}
|
|
19857
|
-
|
|
19858
|
-
// src/lib/daemon-client.ts
|
|
19859
|
-
init_types();
|
|
19860
|
-
var LIVE = /* @__PURE__ */ new Set(["starting", "running", "waiting_input"]);
|
|
19861
|
-
var DaemonClient = class {
|
|
19862
|
-
constructor(port, token) {
|
|
19863
|
-
this.port = port;
|
|
19864
|
-
this.token = token;
|
|
19865
|
-
}
|
|
19866
|
-
setPort(port) {
|
|
19867
|
-
this.port = port;
|
|
19868
|
-
}
|
|
19869
|
-
async get(path) {
|
|
19870
|
-
let res;
|
|
19871
|
-
try {
|
|
19872
|
-
res = await fetch(`http://127.0.0.1:${this.port}${path}`, {
|
|
19873
|
-
headers: { authorization: `Bearer ${this.token}` },
|
|
19874
|
-
signal: AbortSignal.timeout(1e4)
|
|
19875
|
-
});
|
|
19876
|
-
} catch {
|
|
19877
|
-
throw new CliError("daemon_unreachable", "the Puddle daemon is not reachable");
|
|
19878
|
-
}
|
|
19879
|
-
if (res.status === 401) {
|
|
19880
|
-
throw new CliError(
|
|
19881
|
-
"token_rejected",
|
|
19882
|
-
"the stored token was rejected by the daemon",
|
|
19883
|
-
"inspect ~/.puddle/token on the host \u2014 it may have been regenerated"
|
|
19884
|
-
);
|
|
19885
|
-
}
|
|
19886
|
-
if (!res.ok) {
|
|
19887
|
-
throw new CliError("daemon_unreachable", `the daemon answered ${res.status} for ${path}`);
|
|
19888
|
-
}
|
|
19889
|
-
return res.json();
|
|
19890
|
-
}
|
|
19891
|
-
version() {
|
|
19892
|
-
return this.get("/api/version").then((body) => versionResponseSchema.parse(body));
|
|
19893
|
-
}
|
|
19894
|
-
host() {
|
|
19895
|
-
return this.get("/api/host").then((body) => hostInfoSchema.parse(body));
|
|
19896
|
-
}
|
|
19897
|
-
sessions() {
|
|
19898
|
-
return this.get("/api/sessions").then((body) => sessionSchema.array().parse(body));
|
|
19899
|
-
}
|
|
19900
|
-
profiles() {
|
|
19901
|
-
return this.get("/api/profiles").then((body) => profileSchema.array().parse(body));
|
|
19902
|
-
}
|
|
19903
|
-
/** The live subset of sessions() — what an upgrade/removal interrupts. */
|
|
19904
|
-
async liveSessions() {
|
|
19905
|
-
return (await this.sessions()).filter((s) => LIVE.has(s.status));
|
|
19906
|
-
}
|
|
19907
|
-
async liveSessionCount() {
|
|
19908
|
-
return (await this.sessions()).filter((s) => LIVE.has(s.status)).length;
|
|
19909
|
-
}
|
|
19910
|
-
/** Whether anything daemon-shaped answers /api/version (401 counts as alive). */
|
|
19911
|
-
async responds() {
|
|
19912
|
-
try {
|
|
19913
|
-
const res = await fetch(`http://127.0.0.1:${this.port}/api/version`, {
|
|
19914
|
-
headers: { authorization: `Bearer ${this.token}` },
|
|
19915
|
-
signal: AbortSignal.timeout(2e3)
|
|
19916
|
-
});
|
|
19917
|
-
return res.status < 500;
|
|
19918
|
-
} catch {
|
|
19919
|
-
return false;
|
|
19920
|
-
}
|
|
19921
|
-
}
|
|
19832
|
+
logs: `${HOST_HOME}/logs`,
|
|
19833
|
+
supervisor: `${HOST_HOME}/supervisor`,
|
|
19834
|
+
pid: `${HOST_HOME}/puddled.pid`
|
|
19922
19835
|
};
|
|
19923
|
-
async function readDaemonPort(transport) {
|
|
19924
|
-
const runtime = await transport.readFile(hostPaths.runtime);
|
|
19925
|
-
if (runtime !== null) {
|
|
19926
|
-
try {
|
|
19927
|
-
const parsed = JSON.parse(runtime);
|
|
19928
|
-
if (typeof parsed.port === "number") return parsed.port;
|
|
19929
|
-
} catch {
|
|
19930
|
-
}
|
|
19931
|
-
}
|
|
19932
|
-
const raw = await transport.readFile(hostPaths.config);
|
|
19933
|
-
if (raw !== null) {
|
|
19934
|
-
try {
|
|
19935
|
-
const parsed = JSON.parse(raw);
|
|
19936
|
-
if (typeof parsed.port === "number") return parsed.port;
|
|
19937
|
-
} catch {
|
|
19938
|
-
}
|
|
19939
|
-
}
|
|
19940
|
-
return 7434;
|
|
19941
|
-
}
|
|
19942
|
-
async function readToken(transport) {
|
|
19943
|
-
const raw = await transport.readFile(hostPaths.token);
|
|
19944
|
-
const token = raw?.trim() ?? "";
|
|
19945
|
-
return /^[0-9a-f]{32,}$/.test(token) ? token : null;
|
|
19946
|
-
}
|
|
19947
|
-
async function waitForToken(transport, timeoutMs) {
|
|
19948
|
-
const deadline = Date.now() + timeoutMs;
|
|
19949
|
-
while (Date.now() < deadline) {
|
|
19950
|
-
const token = await readToken(transport);
|
|
19951
|
-
if (token !== null) return token;
|
|
19952
|
-
await sleep(500);
|
|
19953
|
-
}
|
|
19954
|
-
throw new CliError(
|
|
19955
|
-
"daemon_start_timeout",
|
|
19956
|
-
`puddled did not come up on ${transport.label}`,
|
|
19957
|
-
transport.kind === "ssh" ? `inspect it with: puddle logs ${transport.label}` : "inspect it with: puddle logs"
|
|
19958
|
-
);
|
|
19959
|
-
}
|
|
19960
19836
|
|
|
19961
|
-
// src/lib/
|
|
19962
|
-
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
19963
|
-
import { dirname as dirname2, join as join3, resolve } from "node:path";
|
|
19964
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
19837
|
+
// src/lib/desktop-update.ts
|
|
19965
19838
|
init_types();
|
|
19966
19839
|
|
|
19967
19840
|
// src/lib/version.ts
|
|
@@ -19969,7 +19842,7 @@ import { readFileSync } from "node:fs";
|
|
|
19969
19842
|
import { dirname, join as join2 } from "node:path";
|
|
19970
19843
|
import { fileURLToPath } from "node:url";
|
|
19971
19844
|
function cliVersion() {
|
|
19972
|
-
if (true) return "0.0.
|
|
19845
|
+
if (true) return "0.0.46";
|
|
19973
19846
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19974
19847
|
for (const candidate of [
|
|
19975
19848
|
join2(here, "..", "..", "package.json"),
|
|
@@ -19992,103 +19865,696 @@ function repoSlug(env = process.env) {
|
|
|
19992
19865
|
return void 0;
|
|
19993
19866
|
}
|
|
19994
19867
|
|
|
19995
|
-
// src/lib/
|
|
19996
|
-
|
|
19997
|
-
const
|
|
19998
|
-
|
|
19999
|
-
|
|
20000
|
-
|
|
20001
|
-
|
|
20002
|
-
|
|
20003
|
-
|
|
20004
|
-
|
|
20005
|
-
|
|
20006
|
-
const args = ["--version", version2];
|
|
20007
|
-
if (opts.tarball !== void 0 && !/^https?:\/\//.test(opts.tarball)) {
|
|
20008
|
-
if (!existsSync(opts.tarball)) {
|
|
20009
|
-
throw new CliError("not_installed", `tarball not found: ${opts.tarball}`);
|
|
20010
|
-
}
|
|
20011
|
-
if (transport.kind === "local") {
|
|
20012
|
-
args.push("--tarball", resolve(opts.tarball));
|
|
20013
|
-
const localSums = `${opts.tarball}.sha256`;
|
|
20014
|
-
if (existsSync(localSums)) args.push("--sums", resolve(localSums));
|
|
20015
|
-
} else {
|
|
20016
|
-
const base = opts.tarball.split("/").pop() ?? "puddled.tar.gz";
|
|
20017
|
-
const destTarball = `.puddle/cache/${base}`;
|
|
20018
|
-
logger.info(`delivering ${base} to ${transport.label}`);
|
|
20019
|
-
await transport.copyTo(opts.tarball, destTarball);
|
|
20020
|
-
const sums = `${opts.tarball}.sha256`;
|
|
20021
|
-
if (existsSync(sums)) {
|
|
20022
|
-
await transport.copyTo(sums, `${destTarball}.sha256`);
|
|
20023
|
-
args.push("--tarball", destTarball, "--sums", `${destTarball}.sha256`);
|
|
20024
|
-
} else {
|
|
20025
|
-
args.push("--tarball", destTarball);
|
|
20026
|
-
}
|
|
20027
|
-
}
|
|
20028
|
-
} else {
|
|
20029
|
-
const slug2 = repoSlug();
|
|
20030
|
-
if (opts.tarball !== void 0) {
|
|
20031
|
-
throw new CliError("bad_arguments", "--tarball must be a local file path");
|
|
20032
|
-
}
|
|
20033
|
-
if (slug2 === void 0) {
|
|
20034
|
-
throw new CliError(
|
|
20035
|
-
"not_installed",
|
|
20036
|
-
"no release source is configured for this build",
|
|
20037
|
-
"pass --tarball <path> (pnpm build:tarball produces one) or set PUDDLE_REPO=owner/repo"
|
|
20038
|
-
);
|
|
20039
|
-
}
|
|
20040
|
-
args.push("--repo", slug2);
|
|
20041
|
-
}
|
|
20042
|
-
logger.info(`installing puddled ${version2} on ${transport.label}`);
|
|
20043
|
-
const quoted = args.map((a) => `'${a.replaceAll("'", `'\\''`)}'`).join(" ");
|
|
20044
|
-
const result = await transport.exec(`sh -s -- ${quoted}`, {
|
|
20045
|
-
stdin: script,
|
|
20046
|
-
timeoutMs: 10 * 6e4,
|
|
20047
|
-
onStdout: (chunk) => {
|
|
20048
|
-
for (const line of chunk.split("\n")) if (line.trim() !== "") logger.info(line.trimEnd());
|
|
20049
|
-
}
|
|
20050
|
-
});
|
|
20051
|
-
if (result.code !== 0) {
|
|
20052
|
-
throw new CliError(
|
|
20053
|
-
"not_installed",
|
|
20054
|
-
`the installer failed on ${transport.label} (exit ${result.code})`,
|
|
20055
|
-
lastLines(result.stderr || result.stdout)
|
|
20056
|
-
);
|
|
19868
|
+
// src/lib/desktop-update.ts
|
|
19869
|
+
function isNewerVersion(candidate, current) {
|
|
19870
|
+
const parse3 = (v) => {
|
|
19871
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
|
|
19872
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
19873
|
+
};
|
|
19874
|
+
const a = parse3(candidate);
|
|
19875
|
+
const b = parse3(current);
|
|
19876
|
+
if (a === null || b === null) return false;
|
|
19877
|
+
for (let i = 0; i < 3; i++) {
|
|
19878
|
+
if (a[i] !== b[i]) return a[i] > b[i];
|
|
20057
19879
|
}
|
|
19880
|
+
return false;
|
|
20058
19881
|
}
|
|
20059
|
-
function
|
|
20060
|
-
|
|
19882
|
+
function pickDesktopAsset(assets, platform, arch) {
|
|
19883
|
+
const suffix = platform === "darwin" ? "-mac.zip" : platform === "linux" ? ".AppImage" : null;
|
|
19884
|
+
if (suffix === null) return null;
|
|
19885
|
+
return assets.find(
|
|
19886
|
+
(a) => a.name.endsWith(suffix) && a.name.includes("-arm64") === (arch === "arm64")
|
|
19887
|
+
) ?? null;
|
|
20061
19888
|
}
|
|
20062
|
-
function
|
|
20063
|
-
const
|
|
20064
|
-
const
|
|
20065
|
-
|
|
20066
|
-
|
|
20067
|
-
join3(here, "..", "..", "..", "..", "scripts", "install.sh")
|
|
20068
|
-
// src/lib/ → repo root
|
|
20069
|
-
];
|
|
20070
|
-
for (const candidate of candidates) {
|
|
20071
|
-
if (existsSync(candidate)) return readFileSync2(candidate, "utf8");
|
|
19889
|
+
function parseSums(text) {
|
|
19890
|
+
const sums = /* @__PURE__ */ new Map();
|
|
19891
|
+
for (const line of text.split("\n")) {
|
|
19892
|
+
const match = /^([0-9a-f]{64})\s+\*?(.+?)\s*$/.exec(line.trim());
|
|
19893
|
+
if (match) sums.set(match[2], match[1]);
|
|
20072
19894
|
}
|
|
20073
|
-
|
|
19895
|
+
return sums;
|
|
20074
19896
|
}
|
|
20075
|
-
|
|
20076
|
-
|
|
20077
|
-
|
|
20078
|
-
|
|
19897
|
+
async function checkForDesktopUpdate(currentVersion, opts = {}) {
|
|
19898
|
+
const slug2 = repoSlug();
|
|
19899
|
+
if (slug2 === void 0) return null;
|
|
19900
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
19901
|
+
const response = await fetchFn(`https://api.github.com/repos/${slug2}/releases/latest`, {
|
|
19902
|
+
headers: { accept: "application/vnd.github+json", "user-agent": "puddle-desktop" }
|
|
19903
|
+
});
|
|
19904
|
+
if (!response.ok) {
|
|
19905
|
+
throw new CliError("not_installed", `release lookup failed (HTTP ${response.status})`);
|
|
19906
|
+
}
|
|
19907
|
+
const release = await response.json();
|
|
19908
|
+
const version2 = (release.tag_name ?? "").replace(/^v/, "");
|
|
19909
|
+
if (!isNewerVersion(version2, currentVersion)) return null;
|
|
19910
|
+
return updateFromRelease(version2, release.assets ?? [], opts);
|
|
19911
|
+
}
|
|
19912
|
+
async function desktopUpdateAt(version2, opts = {}) {
|
|
19913
|
+
const slug2 = repoSlug();
|
|
19914
|
+
if (slug2 === void 0) {
|
|
19915
|
+
throw new CliError(
|
|
19916
|
+
"not_installed",
|
|
19917
|
+
"no release source is configured for this build",
|
|
19918
|
+
"set PUDDLE_REPO=owner/repo"
|
|
19919
|
+
);
|
|
19920
|
+
}
|
|
19921
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
19922
|
+
const response = await fetchFn(`https://api.github.com/repos/${slug2}/releases/tags/v${version2}`, {
|
|
19923
|
+
headers: { accept: "application/vnd.github+json", "user-agent": "puddle-desktop" }
|
|
19924
|
+
});
|
|
19925
|
+
if (response.status === 404) {
|
|
19926
|
+
throw new CliError("not_installed", `no release v${version2} exists in ${slug2}`);
|
|
19927
|
+
}
|
|
19928
|
+
if (!response.ok) {
|
|
19929
|
+
throw new CliError("not_installed", `release lookup failed (HTTP ${response.status})`);
|
|
19930
|
+
}
|
|
19931
|
+
const release = await response.json();
|
|
19932
|
+
return updateFromRelease(version2, release.assets ?? [], opts);
|
|
19933
|
+
}
|
|
19934
|
+
function updateFromRelease(version2, assets, opts) {
|
|
19935
|
+
const asset = pickDesktopAsset(
|
|
19936
|
+
assets,
|
|
19937
|
+
opts.platform ?? process.platform,
|
|
19938
|
+
opts.arch ?? process.arch
|
|
19939
|
+
);
|
|
19940
|
+
const sums = assets.find((a) => a.name === "SHA256SUMS");
|
|
19941
|
+
if (asset === null || sums === void 0) return null;
|
|
19942
|
+
return {
|
|
19943
|
+
version: version2,
|
|
19944
|
+
asset: { name: asset.name, url: asset.browser_download_url },
|
|
19945
|
+
sumsUrl: sums.browser_download_url
|
|
19946
|
+
};
|
|
19947
|
+
}
|
|
19948
|
+
async function stageDesktopUpdate(update, opts = {}) {
|
|
19949
|
+
const logger = opts.logger ?? silentLogger;
|
|
19950
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
19951
|
+
const dir = join3(opts.cacheDir ?? join3(clientHome(), "cache", "desktop"), update.version);
|
|
19952
|
+
await rm(dir, { recursive: true, force: true });
|
|
19953
|
+
await mkdir(dir, { recursive: true });
|
|
19954
|
+
try {
|
|
19955
|
+
logger.info(`downloading ${update.asset.name}`);
|
|
19956
|
+
const archive = join3(dir, update.asset.name);
|
|
19957
|
+
const response = await fetchFn(update.asset.url, {
|
|
19958
|
+
headers: { "user-agent": "puddle-desktop" }
|
|
19959
|
+
});
|
|
19960
|
+
if (!response.ok || response.body === null) {
|
|
19961
|
+
throw new CliError("not_installed", `download failed (HTTP ${response.status})`);
|
|
19962
|
+
}
|
|
19963
|
+
await pipeline(
|
|
19964
|
+
Readable.fromWeb(response.body),
|
|
19965
|
+
createWriteStream(archive)
|
|
19966
|
+
);
|
|
19967
|
+
const sumsResponse = await fetchFn(update.sumsUrl, {
|
|
19968
|
+
headers: { "user-agent": "puddle-desktop" }
|
|
19969
|
+
});
|
|
19970
|
+
if (!sumsResponse.ok) {
|
|
19971
|
+
throw new CliError("not_installed", `SHA256SUMS fetch failed (HTTP ${sumsResponse.status})`);
|
|
19972
|
+
}
|
|
19973
|
+
const expected = parseSums(await sumsResponse.text()).get(update.asset.name);
|
|
19974
|
+
if (expected === void 0) {
|
|
19975
|
+
throw new CliError("not_installed", `${update.asset.name} is not in SHA256SUMS`);
|
|
19976
|
+
}
|
|
19977
|
+
const actual = await fileSha256(archive);
|
|
19978
|
+
if (actual !== expected) {
|
|
19979
|
+
throw new CliError(
|
|
19980
|
+
"not_installed",
|
|
19981
|
+
`checksum mismatch for ${update.asset.name}`,
|
|
19982
|
+
`expected ${expected}, got ${actual}`
|
|
19983
|
+
);
|
|
19984
|
+
}
|
|
19985
|
+
if (update.asset.name.endsWith(".zip")) {
|
|
19986
|
+
const extractDir = join3(dir, "extract");
|
|
19987
|
+
await run("/usr/bin/ditto", ["-x", "-k", archive, extractDir]);
|
|
19988
|
+
const bundle = (await readdir(extractDir)).find((name) => name.endsWith(".app"));
|
|
19989
|
+
if (bundle === void 0) {
|
|
19990
|
+
throw new CliError("not_installed", "the update zip contains no .app bundle");
|
|
19991
|
+
}
|
|
19992
|
+
logger.info(`staged ${update.version} at ${join3(extractDir, bundle)}`);
|
|
19993
|
+
return {
|
|
19994
|
+
version: update.version,
|
|
19995
|
+
kind: "mac-app",
|
|
19996
|
+
stagedPath: join3(extractDir, bundle),
|
|
19997
|
+
dir
|
|
19998
|
+
};
|
|
19999
|
+
}
|
|
20000
|
+
await chmod(archive, 493);
|
|
20001
|
+
logger.info(`staged ${update.version} at ${archive}`);
|
|
20002
|
+
return { version: update.version, kind: "appimage", stagedPath: archive, dir };
|
|
20003
|
+
} catch (e) {
|
|
20004
|
+
await rm(dir, { recursive: true, force: true });
|
|
20005
|
+
throw e;
|
|
20006
|
+
}
|
|
20007
|
+
}
|
|
20008
|
+
async function pruneDesktopUpdateCache(keep = [], opts = {}) {
|
|
20009
|
+
const dir = opts.cacheDir ?? join3(clientHome(), "cache", "desktop");
|
|
20010
|
+
let entries;
|
|
20011
|
+
try {
|
|
20012
|
+
entries = await readdir(dir);
|
|
20013
|
+
} catch {
|
|
20014
|
+
return;
|
|
20015
|
+
}
|
|
20016
|
+
await Promise.all(
|
|
20017
|
+
entries.filter((name) => !keep.includes(name)).map((name) => rm(join3(dir, name), { recursive: true, force: true }).catch(() => {
|
|
20018
|
+
}))
|
|
20019
|
+
);
|
|
20020
|
+
}
|
|
20021
|
+
async function applyDesktopUpdate(staged, opts) {
|
|
20022
|
+
const logger = opts.logger ?? silentLogger;
|
|
20023
|
+
const script = swapScript(staged, opts);
|
|
20024
|
+
const scriptPath = join3(staged.dir, "apply.sh");
|
|
20025
|
+
await writeFile(scriptPath, script, { mode: 493 });
|
|
20026
|
+
logger.info(`applying ${staged.version} via ${scriptPath}`);
|
|
20027
|
+
if (opts.detach ?? true) {
|
|
20028
|
+
const child = spawn2("/bin/sh", [scriptPath], { detached: true, stdio: "ignore" });
|
|
20029
|
+
child.unref();
|
|
20030
|
+
return;
|
|
20031
|
+
}
|
|
20032
|
+
await run("/bin/sh", [scriptPath]);
|
|
20033
|
+
}
|
|
20034
|
+
var q = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
|
|
20035
|
+
function swapScript(staged, opts) {
|
|
20036
|
+
const wait = opts.waitPid === void 0 ? [] : [
|
|
20037
|
+
`i=0`,
|
|
20038
|
+
`while kill -0 ${opts.waitPid} 2>/dev/null; do`,
|
|
20039
|
+
` i=$((i+1)); [ "$i" -gt 600 ] && exit 1`,
|
|
20040
|
+
` sleep 0.1`,
|
|
20041
|
+
`done`
|
|
20042
|
+
];
|
|
20043
|
+
if (staged.kind === "mac-app") {
|
|
20044
|
+
return [
|
|
20045
|
+
`#!/bin/sh`,
|
|
20046
|
+
`# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
|
|
20047
|
+
...wait,
|
|
20048
|
+
`target=${q(opts.targetPath)}`,
|
|
20049
|
+
`target_dir=${q(dirname2(opts.targetPath))}`,
|
|
20050
|
+
`staged=${q(staged.stagedPath)}`,
|
|
20051
|
+
`mkdir -p "$target_dir" || exit 1`,
|
|
20052
|
+
`rm -rf "$target.old"`,
|
|
20053
|
+
`had_old=0`,
|
|
20054
|
+
`if [ -e "$target" ]; then`,
|
|
20055
|
+
` mv "$target" "$target.old" || exit 1`,
|
|
20056
|
+
` had_old=1`,
|
|
20057
|
+
`fi`,
|
|
20058
|
+
`if mv "$staged" "$target" 2>/dev/null || /usr/bin/ditto "$staged" "$target"; then`,
|
|
20059
|
+
// Defence in depth: the normal path never quarantines (node's fetch is
|
|
20060
|
+
// not a quarantine-opted-in app, and ditto only propagates what the
|
|
20061
|
+
// zip already carries), but if the bundle ever acquires the attribute
|
|
20062
|
+
// the swap must not resurrect the Gatekeeper prompt.
|
|
20063
|
+
` /usr/bin/xattr -dr com.apple.quarantine "$target" 2>/dev/null`,
|
|
20064
|
+
` rm -rf "$target.old" ${q(staged.dir)}`,
|
|
20065
|
+
...opts.relaunch ? [` open "$target"`] : [],
|
|
20066
|
+
`else`,
|
|
20067
|
+
` rm -rf "$target"`,
|
|
20068
|
+
` [ "$had_old" -eq 0 ] || mv "$target.old" "$target"`,
|
|
20069
|
+
` exit 1`,
|
|
20070
|
+
`fi`,
|
|
20071
|
+
``
|
|
20072
|
+
].join("\n");
|
|
20073
|
+
}
|
|
20074
|
+
return [
|
|
20075
|
+
`#!/bin/sh`,
|
|
20076
|
+
`# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
|
|
20077
|
+
...wait,
|
|
20078
|
+
`target=${q(opts.targetPath)}`,
|
|
20079
|
+
`cp -f ${q(staged.stagedPath)} "$target" || exit 1`,
|
|
20080
|
+
`chmod +x "$target"`,
|
|
20081
|
+
`rm -rf ${q(staged.dir)}`,
|
|
20082
|
+
...opts.relaunch ? [`"$target" >/dev/null 2>&1 &`] : [],
|
|
20083
|
+
``
|
|
20084
|
+
].join("\n");
|
|
20085
|
+
}
|
|
20086
|
+
async function findInstalledDesktopApp(opts = {}) {
|
|
20087
|
+
if ((opts.platform ?? process.platform) !== "darwin") return null;
|
|
20088
|
+
const { readFile } = await import("node:fs/promises");
|
|
20089
|
+
const { homedir: homedir3 } = await import("node:os");
|
|
20090
|
+
const systemApplications = opts.systemApplicationsDir ?? "/Applications";
|
|
20091
|
+
const home = opts.homeDir ?? homedir3();
|
|
20092
|
+
for (const appPath of [
|
|
20093
|
+
join3(systemApplications, "Puddle.app"),
|
|
20094
|
+
join3(home, "Applications", "Puddle.app")
|
|
20095
|
+
]) {
|
|
20096
|
+
try {
|
|
20097
|
+
const plist = await readFile(join3(appPath, "Contents/Info.plist"), "utf8");
|
|
20098
|
+
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
|
|
20099
|
+
plist
|
|
20100
|
+
);
|
|
20101
|
+
if (match) return { appPath, version: match[1] };
|
|
20102
|
+
} catch {
|
|
20103
|
+
}
|
|
20104
|
+
}
|
|
20105
|
+
return null;
|
|
20106
|
+
}
|
|
20107
|
+
async function desktopAppInstallPath(opts = {}) {
|
|
20108
|
+
if ((opts.platform ?? process.platform) !== "darwin") return null;
|
|
20109
|
+
const { homedir: homedir3 } = await import("node:os");
|
|
20110
|
+
const systemApplications = opts.systemApplicationsDir ?? "/Applications";
|
|
20111
|
+
const canWrite = opts.canWrite ?? (async (path) => {
|
|
20112
|
+
try {
|
|
20113
|
+
await access(path, constants.W_OK);
|
|
20114
|
+
return true;
|
|
20115
|
+
} catch {
|
|
20116
|
+
return false;
|
|
20117
|
+
}
|
|
20118
|
+
});
|
|
20119
|
+
if (await canWrite(systemApplications)) return join3(systemApplications, "Puddle.app");
|
|
20120
|
+
const userApplications = join3(opts.homeDir ?? homedir3(), "Applications");
|
|
20121
|
+
await mkdir(userApplications, { recursive: true });
|
|
20122
|
+
return join3(userApplications, "Puddle.app");
|
|
20123
|
+
}
|
|
20124
|
+
function isDesktopAppRunning(appPath) {
|
|
20125
|
+
return new Promise((resolve4) => {
|
|
20126
|
+
const child = spawn2("/usr/bin/pgrep", ["-f", `${appPath}/Contents/MacOS/`], {
|
|
20127
|
+
stdio: "ignore"
|
|
20128
|
+
});
|
|
20129
|
+
child.on("error", () => resolve4(false));
|
|
20130
|
+
child.on("exit", (code) => resolve4(code === 0));
|
|
20131
|
+
});
|
|
20132
|
+
}
|
|
20133
|
+
function fileSha256(path) {
|
|
20134
|
+
const hash2 = createHash("sha256");
|
|
20135
|
+
return pipeline(createReadStream(path), hash2).then(() => hash2.digest("hex"));
|
|
20136
|
+
}
|
|
20137
|
+
function run(command, args) {
|
|
20138
|
+
return new Promise((resolve4, reject) => {
|
|
20139
|
+
const child = spawn2(command, args, { stdio: "ignore" });
|
|
20140
|
+
child.on("error", reject);
|
|
20141
|
+
child.on(
|
|
20142
|
+
"exit",
|
|
20143
|
+
(code) => code === 0 ? resolve4() : reject(new CliError("not_installed", `${command} exited with ${code ?? "signal"}`))
|
|
20144
|
+
);
|
|
20145
|
+
});
|
|
20146
|
+
}
|
|
20147
|
+
|
|
20148
|
+
// src/lib/component-versions.ts
|
|
20149
|
+
var RELEASE_PROTOCOLS = {
|
|
20150
|
+
"0.0.1": { major: 5, minor: 1 },
|
|
20151
|
+
"0.0.2": { major: 6, minor: 0 },
|
|
20152
|
+
"0.0.3": { major: 6, minor: 2 },
|
|
20153
|
+
"0.0.4": { major: 6, minor: 2 },
|
|
20154
|
+
"0.0.5": { major: 7, minor: 0 },
|
|
20155
|
+
"0.0.6": { major: 7, minor: 1 },
|
|
20156
|
+
"0.0.7": { major: 7, minor: 1 },
|
|
20157
|
+
"0.0.8": { major: 7, minor: 4 },
|
|
20158
|
+
"0.0.9": { major: 8, minor: 0 },
|
|
20159
|
+
"0.0.10": { major: 9, minor: 1 },
|
|
20160
|
+
"0.0.11": { major: 9, minor: 2 },
|
|
20161
|
+
"0.0.12": { major: 10, minor: 1 },
|
|
20162
|
+
"0.0.13": { major: 10, minor: 1 },
|
|
20163
|
+
"0.0.14": { major: 10, minor: 1 },
|
|
20164
|
+
"0.0.15": { major: 10, minor: 1 },
|
|
20165
|
+
"0.0.16": { major: 10, minor: 4 },
|
|
20166
|
+
"0.0.17": { major: 10, minor: 7 },
|
|
20167
|
+
"0.0.18": { major: 11, minor: 0 },
|
|
20168
|
+
"0.0.19": { major: 11, minor: 1 },
|
|
20169
|
+
"0.0.20": { major: 11, minor: 1 },
|
|
20170
|
+
"0.0.21": { major: 11, minor: 1 },
|
|
20171
|
+
"0.0.22": { major: 12, minor: 3 },
|
|
20172
|
+
"0.0.23": { major: 12, minor: 3 },
|
|
20173
|
+
"0.0.24": { major: 12, minor: 3 },
|
|
20174
|
+
"0.0.25": { major: 13, minor: 0 },
|
|
20175
|
+
"0.0.26": { major: 13, minor: 0 },
|
|
20176
|
+
"0.0.27": { major: 13, minor: 0 },
|
|
20177
|
+
"0.0.28": { major: 13, minor: 0 },
|
|
20178
|
+
"0.0.29": { major: 13, minor: 1 },
|
|
20179
|
+
"0.0.30": { major: 13, minor: 1 },
|
|
20180
|
+
"0.0.31": { major: 13, minor: 1 },
|
|
20181
|
+
"0.0.32": { major: 14, minor: 0 },
|
|
20182
|
+
"0.0.33": { major: 14, minor: 2 },
|
|
20183
|
+
"0.0.34": { major: 14, minor: 2 },
|
|
20184
|
+
"0.0.35": { major: 15, minor: 1 },
|
|
20185
|
+
"0.0.36": { major: 15, minor: 2 },
|
|
20186
|
+
"0.0.37": { major: 15, minor: 2 },
|
|
20187
|
+
"0.0.38": { major: 15, minor: 2 },
|
|
20188
|
+
"0.0.39": { major: 15, minor: 3 },
|
|
20189
|
+
"0.0.40": { major: 15, minor: 3 },
|
|
20190
|
+
"0.0.41": { major: 15, minor: 3 },
|
|
20191
|
+
"0.0.42": { major: 16, minor: 0 },
|
|
20192
|
+
"0.0.43": { major: 16, minor: 0 },
|
|
20193
|
+
"0.0.44": { major: 16, minor: 0 }
|
|
20194
|
+
};
|
|
20195
|
+
function validProtocol(value) {
|
|
20196
|
+
if (typeof value !== "object" || value === null) return false;
|
|
20197
|
+
const protocol = value;
|
|
20198
|
+
return typeof protocol["major"] === "number" && Number.isInteger(protocol["major"]) && protocol["major"] >= 0 && typeof protocol["minor"] === "number" && Number.isInteger(protocol["minor"]) && protocol["minor"] >= 0;
|
|
20199
|
+
}
|
|
20200
|
+
function releasedProtocol(version2) {
|
|
20201
|
+
return RELEASE_PROTOCOLS[version2];
|
|
20202
|
+
}
|
|
20203
|
+
function componentProtocolForVersion(version2) {
|
|
20204
|
+
return releasedProtocol(version2) ?? (version2 === cliVersion() ? PROTOCOL_VERSION : void 0);
|
|
20205
|
+
}
|
|
20206
|
+
function desktopRecordFile(home) {
|
|
20207
|
+
return join4(home, "desktop-install.json");
|
|
20208
|
+
}
|
|
20209
|
+
function readDesktopInstallation(file2) {
|
|
20210
|
+
try {
|
|
20211
|
+
const value = JSON.parse(readFileSync2(file2, "utf8"));
|
|
20212
|
+
if (typeof value !== "object" || value === null) return null;
|
|
20213
|
+
const record2 = value;
|
|
20214
|
+
if (typeof record2["path"] !== "string" || typeof record2["version"] !== "string") return null;
|
|
20215
|
+
const protocol = validProtocol(record2["protocol"]) ? record2["protocol"] : void 0;
|
|
20216
|
+
return {
|
|
20217
|
+
path: record2["path"],
|
|
20218
|
+
version: record2["version"],
|
|
20219
|
+
...protocol ? { protocol } : {}
|
|
20220
|
+
};
|
|
20221
|
+
} catch {
|
|
20222
|
+
return null;
|
|
20223
|
+
}
|
|
20224
|
+
}
|
|
20225
|
+
function recordDesktopInstallation(installation, file2 = desktopRecordFile(clientHome())) {
|
|
20226
|
+
try {
|
|
20227
|
+
mkdirSync(dirname3(file2), { recursive: true });
|
|
20228
|
+
const temp = `${file2}.${process.pid}.tmp`;
|
|
20229
|
+
writeFileSync(temp, `${JSON.stringify(installation, null, 2)}
|
|
20230
|
+
`);
|
|
20231
|
+
renameSync(temp, file2);
|
|
20232
|
+
} catch {
|
|
20233
|
+
}
|
|
20234
|
+
}
|
|
20235
|
+
function daemonVersion(home) {
|
|
20236
|
+
try {
|
|
20237
|
+
const target = readlinkSync(join4(home, "bin", "current"));
|
|
20238
|
+
return /(?:^|\/)versions\/([^/\s]+)$/.exec(target)?.[1];
|
|
20239
|
+
} catch {
|
|
20240
|
+
return void 0;
|
|
20241
|
+
}
|
|
20242
|
+
}
|
|
20243
|
+
function daemonProtocol(home, version2) {
|
|
20244
|
+
try {
|
|
20245
|
+
const raw = readFileSync2(join4(home, "bin", "current", "PROTOCOL"), "utf8").trim();
|
|
20246
|
+
const match = /^(\d+)\.(\d+)$/.exec(raw);
|
|
20247
|
+
if (match) return { major: Number(match[1]), minor: Number(match[2]) };
|
|
20248
|
+
} catch {
|
|
20249
|
+
}
|
|
20250
|
+
return releasedProtocol(version2);
|
|
20251
|
+
}
|
|
20252
|
+
async function installedComponentVersions(opts = {}) {
|
|
20253
|
+
const home = opts.home ?? clientHome();
|
|
20254
|
+
const cli = {
|
|
20255
|
+
component: "cli",
|
|
20256
|
+
installed: true,
|
|
20257
|
+
version: cliVersion(),
|
|
20258
|
+
protocol: PROTOCOL_VERSION
|
|
20259
|
+
};
|
|
20260
|
+
const installedDaemon = daemonVersion(home);
|
|
20261
|
+
const installedDaemonProtocol = installedDaemon === void 0 ? void 0 : daemonProtocol(home, installedDaemon);
|
|
20262
|
+
const daemon = installedDaemon === void 0 ? { component: "daemon", installed: false } : {
|
|
20263
|
+
component: "daemon",
|
|
20264
|
+
installed: true,
|
|
20265
|
+
version: installedDaemon,
|
|
20266
|
+
...installedDaemonProtocol ? { protocol: installedDaemonProtocol } : {}
|
|
20267
|
+
};
|
|
20268
|
+
const recordedDesktop = readDesktopInstallation(desktopRecordFile(home));
|
|
20269
|
+
let desktopInstallation = recordedDesktop !== null && existsSync(recordedDesktop.path) ? recordedDesktop : null;
|
|
20270
|
+
if (desktopInstallation === null && (opts.platform ?? process.platform) === "darwin") {
|
|
20271
|
+
const found = await (opts.findDesktop ?? findInstalledDesktopApp)();
|
|
20272
|
+
if (found !== null) {
|
|
20273
|
+
const protocol = componentProtocolForVersion(found.version);
|
|
20274
|
+
desktopInstallation = {
|
|
20275
|
+
path: found.appPath,
|
|
20276
|
+
version: found.version,
|
|
20277
|
+
...protocol ? { protocol } : {}
|
|
20278
|
+
};
|
|
20279
|
+
}
|
|
20280
|
+
}
|
|
20281
|
+
const legacyLinuxPath = resolve(home, "..", "puddle", "Puddle.AppImage");
|
|
20282
|
+
const legacyLinuxDesktop = desktopInstallation === null && (opts.platform ?? process.platform) === "linux" && existsSync(legacyLinuxPath);
|
|
20283
|
+
const desktopProtocol = desktopInstallation?.protocol ?? (desktopInstallation ? componentProtocolForVersion(desktopInstallation.version) : void 0);
|
|
20284
|
+
const desktop = desktopInstallation !== null ? {
|
|
20285
|
+
component: "desktop",
|
|
20286
|
+
installed: true,
|
|
20287
|
+
version: desktopInstallation.version,
|
|
20288
|
+
...desktopProtocol ? { protocol: desktopProtocol } : {}
|
|
20289
|
+
} : legacyLinuxDesktop ? { component: "desktop", installed: true } : { component: "desktop", installed: false };
|
|
20290
|
+
return [cli, daemon, desktop];
|
|
20291
|
+
}
|
|
20292
|
+
function formatComponentVersions(components) {
|
|
20293
|
+
return components.map((component) => {
|
|
20294
|
+
const name = component.component.padEnd(7);
|
|
20295
|
+
if (!component.installed) return `${name} not installed`;
|
|
20296
|
+
const version2 = component.version ?? "unknown version";
|
|
20297
|
+
const protocol = component.protocol ? `protocol ${component.protocol.major}.${component.protocol.minor}` : "protocol unknown";
|
|
20298
|
+
return `${name} ${version2} (${protocol})`;
|
|
20299
|
+
}).join("\n");
|
|
20300
|
+
}
|
|
20301
|
+
|
|
20302
|
+
// src/lib/connect.ts
|
|
20303
|
+
import { join as join7 } from "node:path";
|
|
20304
|
+
|
|
20305
|
+
// src/lib/attached-daemon.ts
|
|
20306
|
+
import { randomUUID } from "node:crypto";
|
|
20307
|
+
|
|
20308
|
+
// src/lib/bootstrap.ts
|
|
20309
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
|
|
20310
|
+
import { dirname as dirname4, join as join5, resolve as resolve2 } from "node:path";
|
|
20311
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
20312
|
+
init_types();
|
|
20313
|
+
async function installedVersion(transport) {
|
|
20314
|
+
const result = await transport.exec(`readlink ${hostPaths.current}`, { timeoutMs: 15e3 });
|
|
20315
|
+
if (result.code !== 0) return null;
|
|
20316
|
+
const match = /versions\/([^/\s]+)\s*$/.exec(result.stdout.trim());
|
|
20317
|
+
return match?.[1] ?? null;
|
|
20318
|
+
}
|
|
20319
|
+
async function installDaemon(transport, opts = {}) {
|
|
20320
|
+
const logger = opts.logger ?? silentLogger;
|
|
20321
|
+
const version2 = opts.version ?? pinnedDaemonVersion();
|
|
20322
|
+
const script = readInstallScript();
|
|
20323
|
+
const args = ["--version", version2];
|
|
20324
|
+
if (opts.tarball !== void 0 && !/^https?:\/\//.test(opts.tarball)) {
|
|
20325
|
+
if (!existsSync2(opts.tarball)) {
|
|
20326
|
+
throw new CliError("not_installed", `tarball not found: ${opts.tarball}`);
|
|
20327
|
+
}
|
|
20328
|
+
if (transport.kind === "local") {
|
|
20329
|
+
args.push("--tarball", resolve2(opts.tarball));
|
|
20330
|
+
const localSums = `${opts.tarball}.sha256`;
|
|
20331
|
+
if (existsSync2(localSums)) args.push("--sums", resolve2(localSums));
|
|
20332
|
+
} else {
|
|
20333
|
+
const base = opts.tarball.split("/").pop() ?? "puddled.tar.gz";
|
|
20334
|
+
const destTarball = `.puddle/cache/${base}`;
|
|
20335
|
+
logger.info(`delivering ${base} to ${transport.label}`);
|
|
20336
|
+
await transport.copyTo(opts.tarball, destTarball);
|
|
20337
|
+
const sums = `${opts.tarball}.sha256`;
|
|
20338
|
+
if (existsSync2(sums)) {
|
|
20339
|
+
await transport.copyTo(sums, `${destTarball}.sha256`);
|
|
20340
|
+
args.push("--tarball", destTarball, "--sums", `${destTarball}.sha256`);
|
|
20341
|
+
} else {
|
|
20342
|
+
args.push("--tarball", destTarball);
|
|
20343
|
+
}
|
|
20344
|
+
}
|
|
20345
|
+
} else {
|
|
20346
|
+
const slug2 = repoSlug();
|
|
20347
|
+
if (opts.tarball !== void 0) {
|
|
20348
|
+
throw new CliError("bad_arguments", "--tarball must be a local file path");
|
|
20349
|
+
}
|
|
20350
|
+
if (slug2 === void 0) {
|
|
20351
|
+
throw new CliError(
|
|
20352
|
+
"not_installed",
|
|
20353
|
+
"no release source is configured for this build",
|
|
20354
|
+
"pass --tarball <path> (pnpm build:tarball produces one) or set PUDDLE_REPO=owner/repo"
|
|
20355
|
+
);
|
|
20356
|
+
}
|
|
20357
|
+
args.push("--repo", slug2);
|
|
20358
|
+
}
|
|
20359
|
+
logger.info(`installing puddled ${version2} on ${transport.label}`);
|
|
20360
|
+
const quoted = args.map((a) => `'${a.replaceAll("'", `'\\''`)}'`).join(" ");
|
|
20361
|
+
const result = await transport.exec(`sh -s -- ${quoted}`, {
|
|
20362
|
+
stdin: script,
|
|
20363
|
+
timeoutMs: 10 * 6e4,
|
|
20364
|
+
onStdout: (chunk) => {
|
|
20365
|
+
for (const line of chunk.split("\n")) if (line.trim() !== "") logger.info(line.trimEnd());
|
|
20366
|
+
}
|
|
20367
|
+
});
|
|
20368
|
+
if (result.code !== 0) {
|
|
20369
|
+
throw new CliError(
|
|
20370
|
+
"not_installed",
|
|
20371
|
+
`the installer failed on ${transport.label} (exit ${result.code})`,
|
|
20372
|
+
lastLines(result.stderr || result.stdout)
|
|
20373
|
+
);
|
|
20374
|
+
}
|
|
20375
|
+
}
|
|
20376
|
+
function lastLines(text, count = 3) {
|
|
20377
|
+
return text.trim().split("\n").slice(-count).join("\n");
|
|
20378
|
+
}
|
|
20379
|
+
function readInstallScript() {
|
|
20380
|
+
const here = dirname4(fileURLToPath2(import.meta.url));
|
|
20381
|
+
const candidates = [
|
|
20382
|
+
join5(here, "install.sh"),
|
|
20383
|
+
// dist/index.js → dist/install.sh
|
|
20384
|
+
join5(here, "..", "..", "..", "..", "scripts", "install.sh")
|
|
20385
|
+
// src/lib/ → repo root
|
|
20386
|
+
];
|
|
20387
|
+
for (const candidate of candidates) {
|
|
20388
|
+
if (existsSync2(candidate)) return readFileSync3(candidate, "utf8");
|
|
20389
|
+
}
|
|
20390
|
+
throw new CliError("not_installed", "install.sh is missing from this build");
|
|
20391
|
+
}
|
|
20392
|
+
|
|
20393
|
+
// src/lib/net.ts
|
|
20394
|
+
import { connect, createServer } from "node:net";
|
|
20395
|
+
function findFreePort() {
|
|
20396
|
+
return new Promise((resolve4, reject) => {
|
|
20397
|
+
const srv = createServer();
|
|
20398
|
+
srv.once("error", reject);
|
|
20399
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
20400
|
+
const address = srv.address();
|
|
20401
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
20402
|
+
srv.close(() => resolve4(port));
|
|
20403
|
+
});
|
|
20404
|
+
});
|
|
20405
|
+
}
|
|
20406
|
+
function tcpListening(port, timeoutMs = 500) {
|
|
20407
|
+
return new Promise((resolve4) => {
|
|
20408
|
+
const sock = connect({ host: "127.0.0.1", port });
|
|
20409
|
+
const done = (ok) => {
|
|
20410
|
+
sock.destroy();
|
|
20411
|
+
resolve4(ok);
|
|
20412
|
+
};
|
|
20413
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
20414
|
+
sock.once("connect", () => done(true));
|
|
20415
|
+
sock.once("error", () => done(false));
|
|
20416
|
+
});
|
|
20417
|
+
}
|
|
20418
|
+
async function waitForTcp(port, timeoutMs) {
|
|
20419
|
+
const deadline = Date.now() + timeoutMs;
|
|
20420
|
+
while (Date.now() < deadline) {
|
|
20421
|
+
if (await tcpListening(port)) return true;
|
|
20422
|
+
await sleep(150);
|
|
20423
|
+
}
|
|
20424
|
+
return false;
|
|
20425
|
+
}
|
|
20426
|
+
async function waitForHttp(url2, timeoutMs) {
|
|
20427
|
+
const deadline = Date.now() + timeoutMs;
|
|
20428
|
+
while (Date.now() < deadline) {
|
|
20429
|
+
try {
|
|
20430
|
+
await fetch(url2, { signal: AbortSignal.timeout(2e3) });
|
|
20431
|
+
return true;
|
|
20432
|
+
} catch {
|
|
20433
|
+
await sleep(250);
|
|
20434
|
+
}
|
|
20435
|
+
}
|
|
20436
|
+
return false;
|
|
20437
|
+
}
|
|
20438
|
+
function sleep(ms) {
|
|
20439
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
20440
|
+
}
|
|
20441
|
+
|
|
20442
|
+
// src/lib/daemon-client.ts
|
|
20443
|
+
init_types();
|
|
20444
|
+
var LIVE = /* @__PURE__ */ new Set(["starting", "running", "waiting_input"]);
|
|
20445
|
+
var DaemonClient = class {
|
|
20446
|
+
constructor(port, token) {
|
|
20447
|
+
this.port = port;
|
|
20448
|
+
this.token = token;
|
|
20449
|
+
}
|
|
20450
|
+
setPort(port) {
|
|
20451
|
+
this.port = port;
|
|
20452
|
+
}
|
|
20453
|
+
async get(path) {
|
|
20454
|
+
let res;
|
|
20455
|
+
try {
|
|
20456
|
+
res = await fetch(`http://127.0.0.1:${this.port}${path}`, {
|
|
20457
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
20458
|
+
signal: AbortSignal.timeout(1e4)
|
|
20459
|
+
});
|
|
20460
|
+
} catch {
|
|
20461
|
+
throw new CliError("daemon_unreachable", "the Puddle daemon is not reachable");
|
|
20462
|
+
}
|
|
20463
|
+
if (res.status === 401) {
|
|
20464
|
+
throw new CliError(
|
|
20465
|
+
"token_rejected",
|
|
20466
|
+
"the stored token was rejected by the daemon",
|
|
20467
|
+
"inspect ~/.puddle/token on the host \u2014 it may have been regenerated"
|
|
20468
|
+
);
|
|
20469
|
+
}
|
|
20470
|
+
if (!res.ok) {
|
|
20471
|
+
throw new CliError("daemon_unreachable", `the daemon answered ${res.status} for ${path}`);
|
|
20472
|
+
}
|
|
20473
|
+
return res.json();
|
|
20474
|
+
}
|
|
20475
|
+
version() {
|
|
20476
|
+
return this.get("/api/version").then((body) => versionResponseSchema.parse(body));
|
|
20477
|
+
}
|
|
20478
|
+
host() {
|
|
20479
|
+
return this.get("/api/host").then((body) => hostInfoSchema.parse(body));
|
|
20480
|
+
}
|
|
20481
|
+
sessions() {
|
|
20482
|
+
return this.get("/api/sessions").then((body) => sessionSchema.array().parse(body));
|
|
20483
|
+
}
|
|
20484
|
+
profiles() {
|
|
20485
|
+
return this.get("/api/profiles").then((body) => profileSchema.array().parse(body));
|
|
20486
|
+
}
|
|
20487
|
+
/** The live subset of sessions() — what an upgrade/removal interrupts. */
|
|
20488
|
+
async liveSessions() {
|
|
20489
|
+
return (await this.sessions()).filter((s) => LIVE.has(s.status));
|
|
20490
|
+
}
|
|
20491
|
+
async liveSessionCount() {
|
|
20492
|
+
return (await this.sessions()).filter((s) => LIVE.has(s.status)).length;
|
|
20493
|
+
}
|
|
20494
|
+
/** Whether anything daemon-shaped answers /api/version (401 counts as alive). */
|
|
20495
|
+
async responds() {
|
|
20496
|
+
try {
|
|
20497
|
+
const res = await fetch(`http://127.0.0.1:${this.port}/api/version`, {
|
|
20498
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
20499
|
+
signal: AbortSignal.timeout(2e3)
|
|
20500
|
+
});
|
|
20501
|
+
return res.status < 500;
|
|
20502
|
+
} catch {
|
|
20503
|
+
return false;
|
|
20504
|
+
}
|
|
20505
|
+
}
|
|
20506
|
+
};
|
|
20507
|
+
async function readDaemonPort(transport) {
|
|
20508
|
+
const runtime = await transport.readFile(hostPaths.runtime);
|
|
20509
|
+
if (runtime !== null) {
|
|
20510
|
+
try {
|
|
20511
|
+
const parsed = JSON.parse(runtime);
|
|
20512
|
+
if (typeof parsed.port === "number") return parsed.port;
|
|
20513
|
+
} catch {
|
|
20514
|
+
}
|
|
20515
|
+
}
|
|
20516
|
+
const raw = await transport.readFile(hostPaths.config);
|
|
20517
|
+
if (raw !== null) {
|
|
20518
|
+
try {
|
|
20519
|
+
const parsed = JSON.parse(raw);
|
|
20520
|
+
if (typeof parsed.port === "number") return parsed.port;
|
|
20521
|
+
} catch {
|
|
20522
|
+
}
|
|
20523
|
+
}
|
|
20524
|
+
return 7434;
|
|
20525
|
+
}
|
|
20526
|
+
async function readToken(transport) {
|
|
20527
|
+
const raw = await transport.readFile(hostPaths.token);
|
|
20528
|
+
const token = raw?.trim() ?? "";
|
|
20529
|
+
return /^[0-9a-f]{32,}$/.test(token) ? token : null;
|
|
20530
|
+
}
|
|
20531
|
+
async function waitForToken(transport, timeoutMs) {
|
|
20532
|
+
const deadline = Date.now() + timeoutMs;
|
|
20533
|
+
while (Date.now() < deadline) {
|
|
20534
|
+
const token = await readToken(transport);
|
|
20535
|
+
if (token !== null) return token;
|
|
20536
|
+
await sleep(500);
|
|
20537
|
+
}
|
|
20538
|
+
throw new CliError(
|
|
20539
|
+
"daemon_start_timeout",
|
|
20540
|
+
`puddled did not come up on ${transport.label}`,
|
|
20541
|
+
transport.kind === "ssh" ? `inspect it with: puddle logs ${transport.label}` : "inspect it with: puddle logs"
|
|
20542
|
+
);
|
|
20543
|
+
}
|
|
20544
|
+
|
|
20545
|
+
// src/lib/cockpit.ts
|
|
20546
|
+
init_types();
|
|
20547
|
+
async function ensureDaemon(transport, opts) {
|
|
20079
20548
|
const logger = opts.logger ?? silentLogger;
|
|
20549
|
+
const startTimeoutMs = opts.startTimeoutMs ?? (opts.attachedFallback === void 0 ? 2e4 : 5e3);
|
|
20080
20550
|
let bootstrapped = false;
|
|
20081
|
-
|
|
20551
|
+
const token = await readToken(transport);
|
|
20082
20552
|
if (token === null) {
|
|
20083
20553
|
await installDaemon(transport, opts);
|
|
20084
20554
|
bootstrapped = true;
|
|
20085
|
-
|
|
20086
|
-
const up = await waitForHostProbe(transport, token, 2e4);
|
|
20087
|
-
if (up.result === "unauthorised") throw portConflict(transport, up.port);
|
|
20088
|
-
if (up.result !== "ok") throw startTimeout(transport);
|
|
20089
|
-
return { port: up.port, token, bootstrapped };
|
|
20555
|
+
return waitAfterInstall(transport, opts, logger, bootstrapped, startTimeoutMs);
|
|
20090
20556
|
}
|
|
20091
|
-
|
|
20557
|
+
const port = await readDaemonPort(transport);
|
|
20092
20558
|
const probe = await hostProbe(transport, port, token);
|
|
20093
20559
|
if (probe === "unauthorised") throw portConflict(transport, port);
|
|
20094
20560
|
if (probe === "down") {
|
|
@@ -20099,13 +20565,39 @@ async function ensureDaemon(transport, opts) {
|
|
|
20099
20565
|
}
|
|
20100
20566
|
await installDaemon(transport, opts);
|
|
20101
20567
|
bootstrapped = true;
|
|
20102
|
-
|
|
20103
|
-
|
|
20104
|
-
|
|
20105
|
-
|
|
20106
|
-
|
|
20568
|
+
return waitAfterInstall(transport, opts, logger, bootstrapped, startTimeoutMs);
|
|
20569
|
+
}
|
|
20570
|
+
return { port, token, bootstrapped, daemonLifetime: "persistent" };
|
|
20571
|
+
}
|
|
20572
|
+
async function waitAfterInstall(transport, opts, logger, bootstrapped, timeoutMs) {
|
|
20573
|
+
try {
|
|
20574
|
+
const started = await waitForStartedDaemon(transport, timeoutMs);
|
|
20575
|
+
return { ...started, bootstrapped, daemonLifetime: "persistent" };
|
|
20576
|
+
} catch (err) {
|
|
20577
|
+
if (!(err instanceof CliError) || err.code !== "daemon_start_timeout" || opts.attachedFallback === void 0 || !await detachedNohupWasReaped(transport)) {
|
|
20578
|
+
throw err;
|
|
20579
|
+
}
|
|
20580
|
+
logger.warn(
|
|
20581
|
+
`puddled could not stay detached on ${transport.label} \u2014 keeping it attached to this cockpit; session state stays on disk and can resume on the next launch`
|
|
20582
|
+
);
|
|
20583
|
+
return opts.attachedFallback();
|
|
20107
20584
|
}
|
|
20108
|
-
|
|
20585
|
+
}
|
|
20586
|
+
async function detachedNohupWasReaped(transport) {
|
|
20587
|
+
if (transport.kind !== "ssh") return false;
|
|
20588
|
+
const supervisor = (await transport.readFile(hostPaths.supervisor))?.trim();
|
|
20589
|
+
if (supervisor !== "nohup") return false;
|
|
20590
|
+
const rawPid = (await transport.readFile(hostPaths.pid))?.trim() ?? "";
|
|
20591
|
+
if (!/^[1-9][0-9]*$/.test(rawPid)) return true;
|
|
20592
|
+
const probe = await transport.exec(`kill -0 ${rawPid}`, { timeoutMs: 5e3 });
|
|
20593
|
+
return probe.code !== 0;
|
|
20594
|
+
}
|
|
20595
|
+
async function waitForStartedDaemon(transport, timeoutMs) {
|
|
20596
|
+
const token = await waitForToken(transport, timeoutMs);
|
|
20597
|
+
const up = await waitForHostProbe(transport, token, timeoutMs);
|
|
20598
|
+
if (up.result === "unauthorised") throw portConflict(transport, up.port);
|
|
20599
|
+
if (up.result !== "ok") throw startTimeout(transport);
|
|
20600
|
+
return { port: up.port, token };
|
|
20109
20601
|
}
|
|
20110
20602
|
function startTimeout(transport) {
|
|
20111
20603
|
return new CliError(
|
|
@@ -20141,9 +20633,10 @@ async function waitForHostProbe(transport, token, timeoutMs) {
|
|
|
20141
20633
|
}
|
|
20142
20634
|
return { result: last, port };
|
|
20143
20635
|
}
|
|
20144
|
-
function makeUpgrader(transport, client, opts) {
|
|
20636
|
+
function makeUpgrader(transport, client, opts, lease) {
|
|
20145
20637
|
return async () => {
|
|
20146
20638
|
await installDaemon(transport, opts);
|
|
20639
|
+
await lease?.ensureRunning();
|
|
20147
20640
|
const deadline = Date.now() + 3e4;
|
|
20148
20641
|
while (Date.now() < deadline) {
|
|
20149
20642
|
if (await client.responds()) return;
|
|
@@ -20156,6 +20649,150 @@ function makeUpgrader(transport, client, opts) {
|
|
|
20156
20649
|
};
|
|
20157
20650
|
}
|
|
20158
20651
|
|
|
20652
|
+
// src/lib/attached-daemon.ts
|
|
20653
|
+
init_types();
|
|
20654
|
+
var AttachedDaemon = class {
|
|
20655
|
+
constructor(ssh, logger = silentLogger) {
|
|
20656
|
+
this.ssh = ssh;
|
|
20657
|
+
this.logger = logger;
|
|
20658
|
+
}
|
|
20659
|
+
command = null;
|
|
20660
|
+
commandExited = true;
|
|
20661
|
+
pid = null;
|
|
20662
|
+
port;
|
|
20663
|
+
leasePidPath = null;
|
|
20664
|
+
async start() {
|
|
20665
|
+
const started = await this.launchAndWait();
|
|
20666
|
+
return {
|
|
20667
|
+
...started,
|
|
20668
|
+
bootstrapped: true,
|
|
20669
|
+
daemonLifetime: "cockpit",
|
|
20670
|
+
lease: this
|
|
20671
|
+
};
|
|
20672
|
+
}
|
|
20673
|
+
async ensureRunning() {
|
|
20674
|
+
if (!this.commandExited) return;
|
|
20675
|
+
this.logger.info(`restarting SSH-attached puddled on ${this.ssh.label}`);
|
|
20676
|
+
if (this.leasePidPath !== null) {
|
|
20677
|
+
await this.ssh.exec(`rm -f ${this.leasePidPath}`, { timeoutMs: 5e3 });
|
|
20678
|
+
}
|
|
20679
|
+
this.pid = null;
|
|
20680
|
+
this.leasePidPath = null;
|
|
20681
|
+
await this.launchAndWait(this.port);
|
|
20682
|
+
}
|
|
20683
|
+
async stop() {
|
|
20684
|
+
const command = this.command;
|
|
20685
|
+
if (command === null) return;
|
|
20686
|
+
const pid = this.pid;
|
|
20687
|
+
const leasePidPath = this.leasePidPath;
|
|
20688
|
+
if (pid !== null) {
|
|
20689
|
+
await this.ssh.exec(`kill ${pid}`, { timeoutMs: 5e3 });
|
|
20690
|
+
}
|
|
20691
|
+
const exited = await Promise.race([
|
|
20692
|
+
command.result.then(() => true),
|
|
20693
|
+
sleep(5e3).then(() => false)
|
|
20694
|
+
]);
|
|
20695
|
+
if (!exited) command.terminate();
|
|
20696
|
+
if (pid !== null) {
|
|
20697
|
+
await this.ssh.exec(
|
|
20698
|
+
`if [ -f ${hostPaths.pid} ] && [ "$(cat ${hostPaths.pid})" = "${pid}" ]; then rm -f ${hostPaths.pid}; fi`,
|
|
20699
|
+
{ timeoutMs: 5e3 }
|
|
20700
|
+
);
|
|
20701
|
+
}
|
|
20702
|
+
if (leasePidPath !== null) {
|
|
20703
|
+
await this.ssh.exec(`rm -f ${leasePidPath}`, { timeoutMs: 5e3 });
|
|
20704
|
+
}
|
|
20705
|
+
this.command = null;
|
|
20706
|
+
this.commandExited = true;
|
|
20707
|
+
this.pid = null;
|
|
20708
|
+
this.leasePidPath = null;
|
|
20709
|
+
}
|
|
20710
|
+
async launchAndWait(preferredPort) {
|
|
20711
|
+
const portFlag = preferredPort === void 0 ? "" : ` --port ${preferredPort}`;
|
|
20712
|
+
const leasePidPath = `${hostPaths.home}/attached-${randomUUID()}.pid`;
|
|
20713
|
+
this.leasePidPath = leasePidPath;
|
|
20714
|
+
const command = this.ssh.runAttached(
|
|
20715
|
+
`printf '%s\\n' "$$" > ${leasePidPath}; exec ${hostPaths.current}/puddled${portFlag} >> ${hostPaths.logs}/puddled.out.log 2>&1`
|
|
20716
|
+
);
|
|
20717
|
+
this.command = command;
|
|
20718
|
+
this.commandExited = false;
|
|
20719
|
+
void command.result.then(() => {
|
|
20720
|
+
if (this.command === command) {
|
|
20721
|
+
this.commandExited = true;
|
|
20722
|
+
this.pid = null;
|
|
20723
|
+
}
|
|
20724
|
+
});
|
|
20725
|
+
let outcome;
|
|
20726
|
+
try {
|
|
20727
|
+
outcome = await Promise.race([
|
|
20728
|
+
waitForStartedDaemon(this.ssh, 2e4).then((started) => ({
|
|
20729
|
+
kind: "started",
|
|
20730
|
+
started
|
|
20731
|
+
})),
|
|
20732
|
+
command.result.then((result) => ({ kind: "exited", result }))
|
|
20733
|
+
]);
|
|
20734
|
+
} catch (err) {
|
|
20735
|
+
command.terminate();
|
|
20736
|
+
await this.clearFailedLaunch(leasePidPath);
|
|
20737
|
+
throw err;
|
|
20738
|
+
}
|
|
20739
|
+
if (outcome.kind === "exited") {
|
|
20740
|
+
await this.clearFailedLaunch(leasePidPath);
|
|
20741
|
+
throw await this.startFailure(outcome.result.stderr || outcome.result.stdout);
|
|
20742
|
+
}
|
|
20743
|
+
this.port = outcome.started.port;
|
|
20744
|
+
const [claimedPid, runtimePid] = await Promise.all([
|
|
20745
|
+
this.readPidFile(leasePidPath),
|
|
20746
|
+
this.readPidFile(hostPaths.runtime, true)
|
|
20747
|
+
]);
|
|
20748
|
+
if (claimedPid === null || runtimePid !== claimedPid) {
|
|
20749
|
+
command.terminate();
|
|
20750
|
+
await this.clearFailedLaunch(leasePidPath);
|
|
20751
|
+
throw new CliError(
|
|
20752
|
+
"daemon_start_timeout",
|
|
20753
|
+
`another puddled process won the startup race on ${this.ssh.label}`,
|
|
20754
|
+
"close the other cockpit or wait for its daemon to stop, then launch again"
|
|
20755
|
+
);
|
|
20756
|
+
}
|
|
20757
|
+
this.pid = claimedPid;
|
|
20758
|
+
await this.ssh.exec(`printf '%s\\n' '${this.pid}' > ${hostPaths.pid}`, { timeoutMs: 5e3 });
|
|
20759
|
+
return outcome.started;
|
|
20760
|
+
}
|
|
20761
|
+
async readPidFile(path, json2 = false) {
|
|
20762
|
+
const raw = await this.ssh.readFile(path);
|
|
20763
|
+
if (raw === null) return null;
|
|
20764
|
+
try {
|
|
20765
|
+
const value = json2 ? JSON.parse(raw).pid : Number(raw.trim());
|
|
20766
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
|
|
20767
|
+
} catch {
|
|
20768
|
+
return null;
|
|
20769
|
+
}
|
|
20770
|
+
}
|
|
20771
|
+
async clearFailedLaunch(leasePidPath) {
|
|
20772
|
+
try {
|
|
20773
|
+
await this.ssh.exec(`rm -f ${leasePidPath}`, { timeoutMs: 5e3 });
|
|
20774
|
+
} catch {
|
|
20775
|
+
}
|
|
20776
|
+
this.command = null;
|
|
20777
|
+
this.commandExited = true;
|
|
20778
|
+
this.pid = null;
|
|
20779
|
+
if (this.leasePidPath === leasePidPath) this.leasePidPath = null;
|
|
20780
|
+
}
|
|
20781
|
+
async startFailure(channelOutput) {
|
|
20782
|
+
this.command = null;
|
|
20783
|
+
this.commandExited = true;
|
|
20784
|
+
const log = await this.ssh.exec(`tail -n 20 ${hostPaths.logs}/puddled.out.log`, {
|
|
20785
|
+
timeoutMs: 5e3
|
|
20786
|
+
});
|
|
20787
|
+
const detail = (log.stdout || log.stderr || channelOutput).trim().split("\n").slice(-8).join("\n");
|
|
20788
|
+
return new CliError(
|
|
20789
|
+
"daemon_start_timeout",
|
|
20790
|
+
`puddled exited while starting on ${this.ssh.label}`,
|
|
20791
|
+
detail || `inspect it with: puddle logs ${this.ssh.label}`
|
|
20792
|
+
);
|
|
20793
|
+
}
|
|
20794
|
+
};
|
|
20795
|
+
|
|
20159
20796
|
// src/lib/handshake.ts
|
|
20160
20797
|
init_types();
|
|
20161
20798
|
var CLI_UPGRADE_COMMAND = "npm install -g @puddle-code/cli";
|
|
@@ -20201,7 +20838,7 @@ async function runHandshake(opts) {
|
|
|
20201
20838
|
|
|
20202
20839
|
// src/lib/serve/ui-server.ts
|
|
20203
20840
|
init_types();
|
|
20204
|
-
import { randomUUID } from "node:crypto";
|
|
20841
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
20205
20842
|
import { createServer as createServer2 } from "node:http";
|
|
20206
20843
|
|
|
20207
20844
|
// src/lib/serve/guard.ts
|
|
@@ -20223,12 +20860,12 @@ function isLocalOrigin(origin) {
|
|
|
20223
20860
|
}
|
|
20224
20861
|
|
|
20225
20862
|
// src/lib/serve/local-sync.ts
|
|
20226
|
-
import { mkdirSync, readFileSync as
|
|
20227
|
-
import { dirname as
|
|
20863
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
20864
|
+
import { dirname as dirname5 } from "node:path";
|
|
20228
20865
|
var MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
20229
20866
|
function readStore(file2) {
|
|
20230
20867
|
try {
|
|
20231
|
-
const raw = JSON.parse(
|
|
20868
|
+
const raw = JSON.parse(readFileSync4(file2, "utf8"));
|
|
20232
20869
|
if (raw && typeof raw === "object" && raw.profiles && typeof raw.profiles === "object") {
|
|
20233
20870
|
return { version: 1, profiles: raw.profiles };
|
|
20234
20871
|
}
|
|
@@ -20237,13 +20874,13 @@ function readStore(file2) {
|
|
|
20237
20874
|
return { version: 1, profiles: {} };
|
|
20238
20875
|
}
|
|
20239
20876
|
function writeStore(file2, store) {
|
|
20240
|
-
|
|
20877
|
+
mkdirSync2(dirname5(file2), { recursive: true });
|
|
20241
20878
|
const tmp = `${file2}.tmp-${process.pid}`;
|
|
20242
|
-
|
|
20243
|
-
|
|
20879
|
+
writeFileSync2(tmp, JSON.stringify(store, null, 2) + "\n");
|
|
20880
|
+
renameSync2(tmp, file2);
|
|
20244
20881
|
}
|
|
20245
20882
|
function readBody(req) {
|
|
20246
|
-
return new Promise((
|
|
20883
|
+
return new Promise((resolve4, reject) => {
|
|
20247
20884
|
let size = 0;
|
|
20248
20885
|
const chunks = [];
|
|
20249
20886
|
req.on("data", (chunk) => {
|
|
@@ -20255,7 +20892,7 @@ function readBody(req) {
|
|
|
20255
20892
|
}
|
|
20256
20893
|
chunks.push(chunk);
|
|
20257
20894
|
});
|
|
20258
|
-
req.on("end", () =>
|
|
20895
|
+
req.on("end", () => resolve4(Buffer.concat(chunks).toString("utf8")));
|
|
20259
20896
|
req.on("error", reject);
|
|
20260
20897
|
});
|
|
20261
20898
|
}
|
|
@@ -20312,8 +20949,8 @@ function recoverProxiedPath(referer, requestUrl) {
|
|
|
20312
20949
|
}
|
|
20313
20950
|
|
|
20314
20951
|
// src/lib/serve/static.ts
|
|
20315
|
-
import { createReadStream, existsSync as
|
|
20316
|
-
import { extname, join as
|
|
20952
|
+
import { createReadStream as createReadStream2, existsSync as existsSync3, statSync } from "node:fs";
|
|
20953
|
+
import { extname, join as join6, normalize, resolve as resolve3, sep } from "node:path";
|
|
20317
20954
|
var MIME = {
|
|
20318
20955
|
".html": "text/html; charset=utf-8",
|
|
20319
20956
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -20327,7 +20964,7 @@ var MIME = {
|
|
|
20327
20964
|
".map": "application/json"
|
|
20328
20965
|
};
|
|
20329
20966
|
function createStaticHandler(rootDir) {
|
|
20330
|
-
const root =
|
|
20967
|
+
const root = resolve3(rootDir);
|
|
20331
20968
|
return (req, res) => {
|
|
20332
20969
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
20333
20970
|
res.writeHead(405, { allow: "GET, HEAD" }).end();
|
|
@@ -20341,15 +20978,15 @@ function createStaticHandler(rootDir) {
|
|
|
20341
20978
|
res.writeHead(404, { "content-type": "text/plain" }).end("not found");
|
|
20342
20979
|
return;
|
|
20343
20980
|
}
|
|
20344
|
-
const requested = normalize(
|
|
20981
|
+
const requested = normalize(join6(root, decoded));
|
|
20345
20982
|
const candidate = requested.startsWith(root + sep) || requested === root ? requested : root;
|
|
20346
|
-
const isFile =
|
|
20983
|
+
const isFile = existsSync3(candidate) && statSync(candidate).isFile();
|
|
20347
20984
|
if (!isFile && extname(pathname) !== "") {
|
|
20348
20985
|
res.writeHead(404, { "content-type": "text/plain" }).end("not found");
|
|
20349
20986
|
return;
|
|
20350
20987
|
}
|
|
20351
|
-
const file2 = isFile ? candidate :
|
|
20352
|
-
if (!
|
|
20988
|
+
const file2 = isFile ? candidate : join6(root, "index.html");
|
|
20989
|
+
if (!existsSync3(file2)) {
|
|
20353
20990
|
res.writeHead(404, { "content-type": "text/plain" }).end("not found");
|
|
20354
20991
|
return;
|
|
20355
20992
|
}
|
|
@@ -20361,7 +20998,7 @@ function createStaticHandler(rootDir) {
|
|
|
20361
20998
|
res.end();
|
|
20362
20999
|
return;
|
|
20363
21000
|
}
|
|
20364
|
-
|
|
21001
|
+
createReadStream2(file2).pipe(res);
|
|
20365
21002
|
};
|
|
20366
21003
|
}
|
|
20367
21004
|
|
|
@@ -20510,7 +21147,7 @@ async function startUiServer(opts) {
|
|
|
20510
21147
|
const target = { ...opts.target };
|
|
20511
21148
|
const tracker = new ProxySocketTracker();
|
|
20512
21149
|
const serveStatic = createStaticHandler(opts.assetsDir);
|
|
20513
|
-
const nonce =
|
|
21150
|
+
const nonce = randomUUID2();
|
|
20514
21151
|
const server = createServer2((req, res) => {
|
|
20515
21152
|
res.setHeader("x-puddle-cockpit", nonce);
|
|
20516
21153
|
const url2 = req.url ?? "/";
|
|
@@ -20566,8 +21203,8 @@ async function startUiServer(opts) {
|
|
|
20566
21203
|
},
|
|
20567
21204
|
close() {
|
|
20568
21205
|
tracker.destroyAll();
|
|
20569
|
-
return new Promise((
|
|
20570
|
-
server.close(() =>
|
|
21206
|
+
return new Promise((resolve4) => {
|
|
21207
|
+
server.close(() => resolve4());
|
|
20571
21208
|
server.closeAllConnections();
|
|
20572
21209
|
});
|
|
20573
21210
|
}
|
|
@@ -20596,15 +21233,15 @@ async function listen(server, startPort, strict, avoidPort) {
|
|
|
20596
21233
|
for (let probe = 0; probe < MAX_PORT_PROBES; probe += 1) {
|
|
20597
21234
|
const port = startPort + probe;
|
|
20598
21235
|
if (!strict && port === avoidPort) continue;
|
|
20599
|
-
const ok = await new Promise((
|
|
21236
|
+
const ok = await new Promise((resolve4, rejectListen) => {
|
|
20600
21237
|
const onError = (err) => {
|
|
20601
21238
|
server.off("listening", onListening);
|
|
20602
|
-
if (err.code === "EADDRINUSE")
|
|
21239
|
+
if (err.code === "EADDRINUSE") resolve4(false);
|
|
20603
21240
|
else rejectListen(err);
|
|
20604
21241
|
};
|
|
20605
21242
|
const onListening = () => {
|
|
20606
21243
|
server.off("error", onError);
|
|
20607
|
-
|
|
21244
|
+
resolve4(true);
|
|
20608
21245
|
};
|
|
20609
21246
|
server.once("error", onError);
|
|
20610
21247
|
server.once("listening", onListening);
|
|
@@ -20626,7 +21263,7 @@ async function listen(server, startPort, strict, avoidPort) {
|
|
|
20626
21263
|
}
|
|
20627
21264
|
|
|
20628
21265
|
// src/lib/tunnel.ts
|
|
20629
|
-
import { spawn as
|
|
21266
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
20630
21267
|
init_types();
|
|
20631
21268
|
var RECONNECT_INITIAL_MS = 500;
|
|
20632
21269
|
var RECONNECT_MAX_MS = 1e4;
|
|
@@ -20642,10 +21279,10 @@ async function openCallbackForward(ssh, port, opts = {}) {
|
|
|
20642
21279
|
return null;
|
|
20643
21280
|
};
|
|
20644
21281
|
if (await tcpListening(port)) return skip("local port busy");
|
|
20645
|
-
const child =
|
|
21282
|
+
const child = spawn3(
|
|
20646
21283
|
opts.sshBinary ?? "ssh",
|
|
20647
21284
|
ssh.args("-N", "-L", `${port}:127.0.0.1:${port}`, ssh.host),
|
|
20648
|
-
{ stdio: ["ignore", "ignore", "inherit"] }
|
|
21285
|
+
{ stdio: ["ignore", "ignore", "inherit"], env: ssh.spawnEnv() }
|
|
20649
21286
|
);
|
|
20650
21287
|
if (!await waitForTcp(port, 5e3)) {
|
|
20651
21288
|
child.kill("SIGTERM");
|
|
@@ -20674,10 +21311,10 @@ async function openTunnel(ssh, remotePort, opts = {}) {
|
|
|
20674
21311
|
let healthTimer = null;
|
|
20675
21312
|
const spawnForward = async (port) => {
|
|
20676
21313
|
if (stopping) return false;
|
|
20677
|
-
const child =
|
|
21314
|
+
const child = spawn3(
|
|
20678
21315
|
sshBinary,
|
|
20679
21316
|
ssh.args("-N", "-L", `${port}:127.0.0.1:${remotePort}`, ssh.host),
|
|
20680
|
-
{ stdio: ["ignore", "ignore", "inherit"] }
|
|
21317
|
+
{ stdio: ["ignore", "ignore", "inherit"], env: ssh.spawnEnv() }
|
|
20681
21318
|
);
|
|
20682
21319
|
state.child = child;
|
|
20683
21320
|
const ready = await waitForTcp(port, 5e3) && await isReady(port);
|
|
@@ -20797,15 +21434,15 @@ async function openTunnel(ssh, remotePort, opts = {}) {
|
|
|
20797
21434
|
}
|
|
20798
21435
|
|
|
20799
21436
|
// src/lib/transport/local.ts
|
|
20800
|
-
import { spawn as
|
|
20801
|
-
import { copyFileSync, mkdirSync as
|
|
20802
|
-
import { dirname as
|
|
21437
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
21438
|
+
import { copyFileSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
21439
|
+
import { dirname as dirname6 } from "node:path";
|
|
20803
21440
|
var LocalTransport = class {
|
|
20804
21441
|
kind = "local";
|
|
20805
21442
|
label = "this machine";
|
|
20806
21443
|
exec(command, opts = {}) {
|
|
20807
|
-
return new Promise((
|
|
20808
|
-
const child =
|
|
21444
|
+
return new Promise((resolve4) => {
|
|
21445
|
+
const child = spawn4("sh", ["-c", command], { stdio: ["pipe", "pipe", "pipe"] });
|
|
20809
21446
|
let stdout = "";
|
|
20810
21447
|
let stderr = "";
|
|
20811
21448
|
let timer;
|
|
@@ -20821,11 +21458,11 @@ var LocalTransport = class {
|
|
|
20821
21458
|
child.stderr.on("data", (chunk) => stderr += chunk.toString());
|
|
20822
21459
|
child.on("error", (err) => {
|
|
20823
21460
|
if (timer) clearTimeout(timer);
|
|
20824
|
-
|
|
21461
|
+
resolve4({ code: -1, stdout, stderr: stderr + String(err) });
|
|
20825
21462
|
});
|
|
20826
21463
|
child.on("close", (code) => {
|
|
20827
21464
|
if (timer) clearTimeout(timer);
|
|
20828
|
-
|
|
21465
|
+
resolve4({ code: code ?? -1, stdout, stderr });
|
|
20829
21466
|
});
|
|
20830
21467
|
if (opts.stdin !== void 0) child.stdin.end(opts.stdin);
|
|
20831
21468
|
else child.stdin.end();
|
|
@@ -20833,14 +21470,14 @@ var LocalTransport = class {
|
|
|
20833
21470
|
}
|
|
20834
21471
|
readFile(path) {
|
|
20835
21472
|
try {
|
|
20836
|
-
return Promise.resolve(
|
|
21473
|
+
return Promise.resolve(readFileSync5(expandHome(path), "utf8"));
|
|
20837
21474
|
} catch {
|
|
20838
21475
|
return Promise.resolve(null);
|
|
20839
21476
|
}
|
|
20840
21477
|
}
|
|
20841
21478
|
copyTo(localPath, destPath) {
|
|
20842
21479
|
const dest = expandHome(destPath);
|
|
20843
|
-
|
|
21480
|
+
mkdirSync3(dirname6(dest), { recursive: true });
|
|
20844
21481
|
copyFileSync(localPath, dest);
|
|
20845
21482
|
return Promise.resolve();
|
|
20846
21483
|
}
|
|
@@ -20852,8 +21489,8 @@ function expandHome(path) {
|
|
|
20852
21489
|
}
|
|
20853
21490
|
|
|
20854
21491
|
// src/lib/transport/ssh.ts
|
|
20855
|
-
import { spawn as
|
|
20856
|
-
import { mkdirSync as
|
|
21492
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
21493
|
+
import { mkdirSync as mkdirSync4 } from "node:fs";
|
|
20857
21494
|
init_types();
|
|
20858
21495
|
var SshTransport = class {
|
|
20859
21496
|
constructor(host, opts = {}) {
|
|
@@ -20861,12 +21498,13 @@ var SshTransport = class {
|
|
|
20861
21498
|
this.label = host;
|
|
20862
21499
|
this.ssh = opts.sshBinary ?? "ssh";
|
|
20863
21500
|
this.scp = opts.scpBinary ?? "scp";
|
|
21501
|
+
this.askpassProgram = opts.askpassProgram;
|
|
20864
21502
|
const platform = opts.platform ?? process.platform;
|
|
20865
21503
|
if (platform === "win32") {
|
|
20866
21504
|
this.controlArgs = [];
|
|
20867
21505
|
} else {
|
|
20868
21506
|
const home = clientHome();
|
|
20869
|
-
|
|
21507
|
+
mkdirSync4(home, { recursive: true });
|
|
20870
21508
|
this.controlArgs = [
|
|
20871
21509
|
"-o",
|
|
20872
21510
|
"ControlMaster=auto",
|
|
@@ -20886,6 +21524,7 @@ var SshTransport = class {
|
|
|
20886
21524
|
scp;
|
|
20887
21525
|
controlArgs;
|
|
20888
21526
|
keepaliveArgs;
|
|
21527
|
+
askpassProgram;
|
|
20889
21528
|
get hasControlMaster() {
|
|
20890
21529
|
return this.controlArgs.length > 0;
|
|
20891
21530
|
}
|
|
@@ -20894,18 +21533,39 @@ var SshTransport = class {
|
|
|
20894
21533
|
return [...this.controlArgs, ...this.keepaliveArgs, ...rest];
|
|
20895
21534
|
}
|
|
20896
21535
|
/**
|
|
20897
|
-
*
|
|
20898
|
-
*
|
|
21536
|
+
* Environment for every ssh/scp child. `force` is scoped to graphical
|
|
21537
|
+
* embedders that supplied a helper; normal CLI launches still talk to the
|
|
21538
|
+
* inherited TTY exactly as before. OpenSSH also requires DISPLAY to be set
|
|
21539
|
+
* before it considers askpass, even when the helper is not an X11 program.
|
|
21540
|
+
*/
|
|
21541
|
+
spawnEnv() {
|
|
21542
|
+
if (this.askpassProgram === void 0) return void 0;
|
|
21543
|
+
return {
|
|
21544
|
+
...process.env,
|
|
21545
|
+
SSH_ASKPASS: this.askpassProgram,
|
|
21546
|
+
SSH_ASKPASS_REQUIRE: "force",
|
|
21547
|
+
DISPLAY: process.env.DISPLAY ?? "puddle"
|
|
21548
|
+
};
|
|
21549
|
+
}
|
|
21550
|
+
/**
|
|
21551
|
+
* Open the master through the caller's authentication surface: the CLI's
|
|
21552
|
+
* inherited TTY or a graphical embedder's askpass helper. Exit 0 leaves a
|
|
21553
|
+
* live control socket behind.
|
|
20899
21554
|
*/
|
|
20900
21555
|
open() {
|
|
20901
|
-
return new Promise((
|
|
20902
|
-
const child =
|
|
21556
|
+
return new Promise((resolve4, reject) => {
|
|
21557
|
+
const child = spawn5(this.ssh, this.args(this.host, "true"), {
|
|
21558
|
+
// A GUI has no useful inherited terminal; OpenSSH calls its askpass
|
|
21559
|
+
// helper instead. The terminal CLI keeps byte-for-byte inheritance.
|
|
21560
|
+
stdio: this.askpassProgram === void 0 ? "inherit" : "ignore",
|
|
21561
|
+
env: this.spawnEnv()
|
|
21562
|
+
});
|
|
20903
21563
|
child.on(
|
|
20904
21564
|
"error",
|
|
20905
21565
|
(err) => reject(new CliError("ssh_unreachable", `could not run ${this.ssh}: ${err.message}`))
|
|
20906
21566
|
);
|
|
20907
21567
|
child.on("close", (code) => {
|
|
20908
|
-
if (code === 0)
|
|
21568
|
+
if (code === 0) resolve4();
|
|
20909
21569
|
else {
|
|
20910
21570
|
reject(
|
|
20911
21571
|
new CliError(
|
|
@@ -20921,10 +21581,13 @@ var SshTransport = class {
|
|
|
20921
21581
|
/** Whether the master connection is still alive (-O check). */
|
|
20922
21582
|
isAlive() {
|
|
20923
21583
|
if (!this.hasControlMaster) return Promise.resolve(true);
|
|
20924
|
-
return new Promise((
|
|
20925
|
-
const child =
|
|
20926
|
-
|
|
20927
|
-
|
|
21584
|
+
return new Promise((resolve4) => {
|
|
21585
|
+
const child = spawn5(this.ssh, this.args("-O", "check", this.host), {
|
|
21586
|
+
stdio: "ignore",
|
|
21587
|
+
env: this.spawnEnv()
|
|
21588
|
+
});
|
|
21589
|
+
child.on("error", () => resolve4(false));
|
|
21590
|
+
child.on("close", (code) => resolve4(code === 0));
|
|
20928
21591
|
});
|
|
20929
21592
|
}
|
|
20930
21593
|
/**
|
|
@@ -20937,19 +21600,21 @@ var SshTransport = class {
|
|
|
20937
21600
|
*/
|
|
20938
21601
|
cancelForward(localPort, remotePort) {
|
|
20939
21602
|
if (!this.hasControlMaster) return Promise.resolve();
|
|
20940
|
-
return new Promise((
|
|
21603
|
+
return new Promise((resolve4) => {
|
|
20941
21604
|
const spec = `${localPort}:127.0.0.1:${remotePort}`;
|
|
20942
|
-
const child =
|
|
20943
|
-
stdio: "ignore"
|
|
21605
|
+
const child = spawn5(this.ssh, this.args("-O", "cancel", "-L", spec, this.host), {
|
|
21606
|
+
stdio: "ignore",
|
|
21607
|
+
env: this.spawnEnv()
|
|
20944
21608
|
});
|
|
20945
|
-
child.on("error", () =>
|
|
20946
|
-
child.on("close", () =>
|
|
21609
|
+
child.on("error", () => resolve4());
|
|
21610
|
+
child.on("close", () => resolve4());
|
|
20947
21611
|
});
|
|
20948
21612
|
}
|
|
20949
21613
|
exec(command, opts = {}) {
|
|
20950
|
-
return new Promise((
|
|
20951
|
-
const child =
|
|
20952
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
21614
|
+
return new Promise((resolve4) => {
|
|
21615
|
+
const child = spawn5(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
|
|
21616
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
21617
|
+
env: this.spawnEnv()
|
|
20953
21618
|
});
|
|
20954
21619
|
let stdout = "";
|
|
20955
21620
|
let stderr = "";
|
|
@@ -20966,573 +21631,358 @@ var SshTransport = class {
|
|
|
20966
21631
|
child.stderr.on("data", (chunk) => stderr += chunk.toString());
|
|
20967
21632
|
child.on("error", (err) => {
|
|
20968
21633
|
if (timer) clearTimeout(timer);
|
|
20969
|
-
|
|
21634
|
+
resolve4({ code: -1, stdout, stderr: stderr + String(err) });
|
|
20970
21635
|
});
|
|
20971
21636
|
child.on("close", (code) => {
|
|
20972
21637
|
if (timer) clearTimeout(timer);
|
|
20973
|
-
|
|
21638
|
+
resolve4({ code: code ?? -1, stdout, stderr });
|
|
20974
21639
|
});
|
|
20975
21640
|
if (opts.stdin !== void 0) child.stdin.end(opts.stdin);
|
|
20976
21641
|
else child.stdin.end();
|
|
20977
21642
|
});
|
|
20978
21643
|
}
|
|
20979
|
-
|
|
20980
|
-
|
|
20981
|
-
|
|
20982
|
-
|
|
20983
|
-
|
|
20984
|
-
|
|
20985
|
-
|
|
20986
|
-
|
|
20987
|
-
|
|
20988
|
-
});
|
|
20989
|
-
child.on("error", reject);
|
|
20990
|
-
child.on("close", (code) => {
|
|
20991
|
-
if (code === 0) resolve3();
|
|
20992
|
-
else reject(new CliError("ssh_unreachable", `scp to ${this.host} failed (exit ${code})`));
|
|
20993
|
-
});
|
|
20994
|
-
});
|
|
20995
|
-
}
|
|
20996
|
-
dispose() {
|
|
20997
|
-
}
|
|
20998
|
-
};
|
|
20999
|
-
function shellQuote(text) {
|
|
21000
|
-
return `'${text.replaceAll("'", `'\\''`)}'`;
|
|
21001
|
-
}
|
|
21002
|
-
|
|
21003
|
-
// src/lib/connect.ts
|
|
21004
|
-
init_types();
|
|
21005
|
-
var CODEX_OAUTH_PORT = 1455;
|
|
21006
|
-
async function connectRemote(opts) {
|
|
21007
|
-
const logger = opts.logger ?? silentLogger;
|
|
21008
|
-
const platform = opts.platform ?? process.platform;
|
|
21009
|
-
const ssh = new SshTransport(opts.host, {
|
|
21010
|
-
sshBinary: opts.sshBinary,
|
|
21011
|
-
scpBinary: opts.scpBinary,
|
|
21012
|
-
platform
|
|
21013
|
-
});
|
|
21014
|
-
if (platform === "win32") {
|
|
21015
|
-
logger.warn(
|
|
21016
|
-
"Windows OpenSSH cannot share a connection; you may be asked to authenticate more than once. An SSH key (ssh-copy-id) avoids the prompts."
|
|
21017
|
-
);
|
|
21018
|
-
}
|
|
21019
|
-
await ssh.open();
|
|
21020
|
-
const bootstrap = { tarball: opts.tarball, logger };
|
|
21021
|
-
const endpoint = await ensureDaemon(ssh, bootstrap);
|
|
21022
|
-
const remotePort = opts.remotePort ?? endpoint.port;
|
|
21023
|
-
const tunnel = await openTunnel(ssh, remotePort, {
|
|
21024
|
-
sshBinary: opts.sshBinary,
|
|
21025
|
-
logger,
|
|
21026
|
-
ready: (localPort) => waitForHttp(`http://127.0.0.1:${localPort}/api/version`, 8e3)
|
|
21027
|
-
});
|
|
21028
|
-
const client = new DaemonClient(tunnel.localPort, endpoint.token);
|
|
21029
|
-
tunnel.onPortChange((port) => client.setPort(port));
|
|
21030
|
-
const oauthForward = await openCallbackForward(ssh, CODEX_OAUTH_PORT, {
|
|
21031
|
-
sshBinary: opts.sshBinary,
|
|
21032
|
-
logger
|
|
21033
|
-
});
|
|
21034
|
-
const daemon = await runHandshake({
|
|
21035
|
-
client,
|
|
21036
|
-
noUpgrade: opts.noUpgrade,
|
|
21037
|
-
upgradeDaemon: makeUpgrader(ssh, client, bootstrap),
|
|
21038
|
-
logger
|
|
21039
|
-
});
|
|
21040
|
-
const avoidPort = await readDaemonPort(new LocalTransport());
|
|
21041
|
-
const ui = await startUiServer({
|
|
21042
|
-
assetsDir: opts.assetsDir,
|
|
21043
|
-
port: opts.port ?? opts.preferPort,
|
|
21044
|
-
strictPort: opts.port !== void 0,
|
|
21045
|
-
// a preferred port stays non-strict
|
|
21046
|
-
avoidPort,
|
|
21047
|
-
target: { host: "127.0.0.1", port: tunnel.localPort },
|
|
21048
|
-
...opts.onRefreshRequest !== void 0 ? { control: { token: endpoint.token, onRefresh: opts.onRefreshRequest } } : {},
|
|
21049
|
-
// The store lives on the CLIENT machine — every cockpit here shares it,
|
|
21050
|
-
// whichever remote daemon each one drives.
|
|
21051
|
-
localSync: { token: endpoint.token, file: join5(clientHome(), "local-sync.json") }
|
|
21052
|
-
});
|
|
21053
|
-
tunnel.onPortChange((port) => ui.setTarget({ host: "127.0.0.1", port }));
|
|
21054
|
-
const eventCbs = /* @__PURE__ */ new Set();
|
|
21055
|
-
tunnel.onEvent((e) => {
|
|
21056
|
-
if (e.t === "tunnel-down") logger.warn(`tunnel to ${opts.host} lost \u2014 reconnecting\u2026`);
|
|
21057
|
-
if (e.t === "tunnel-up") logger.info("tunnel restored");
|
|
21058
|
-
eventCbs.forEach((cb) => cb(e));
|
|
21059
|
-
});
|
|
21060
|
-
return {
|
|
21061
|
-
origin: ui.origin,
|
|
21062
|
-
browserUrl: `${ui.origin}/?host=${encodeURIComponent(opts.host)}#token=${endpoint.token}`,
|
|
21063
|
-
nonce: ui.nonce,
|
|
21064
|
-
daemon,
|
|
21065
|
-
onEvent(cb) {
|
|
21066
|
-
eventCbs.add(cb);
|
|
21067
|
-
return () => eventCbs.delete(cb);
|
|
21068
|
-
},
|
|
21069
|
-
async stop() {
|
|
21070
|
-
await ui.close();
|
|
21071
|
-
await oauthForward?.();
|
|
21072
|
-
await tunnel.close();
|
|
21073
|
-
ssh.dispose();
|
|
21074
|
-
}
|
|
21075
|
-
};
|
|
21076
|
-
}
|
|
21077
|
-
|
|
21078
|
-
// src/lib/logs.ts
|
|
21079
|
-
init_types();
|
|
21080
|
-
async function showLogs(transport, opts) {
|
|
21081
|
-
if (opts.session !== void 0) {
|
|
21082
|
-
const file2 = `${hostPaths.logs}/${opts.session}/${opts.term ?? "agent"}.log`;
|
|
21083
|
-
const command2 = opts.follow ? `tail -f -c 262144 ${file2}` : `tail -c 262144 ${file2}`;
|
|
21084
|
-
const result = await transport.exec(command2, {
|
|
21085
|
-
onStdout: opts.write,
|
|
21086
|
-
...opts.follow ? {} : { timeoutMs: 3e4 }
|
|
21644
|
+
/**
|
|
21645
|
+
* Run a command through a live SSH channel instead of waiting for a detached
|
|
21646
|
+
* supervisor to adopt it. Used by the daemon lease on hosts that reap nohup
|
|
21647
|
+
* children when an SSH exec channel closes.
|
|
21648
|
+
*/
|
|
21649
|
+
runAttached(command) {
|
|
21650
|
+
const child = spawn5(this.ssh, this.args(this.host, "--", `sh -c ${shellQuote(command)}`), {
|
|
21651
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
21652
|
+
env: this.spawnEnv()
|
|
21087
21653
|
});
|
|
21088
|
-
|
|
21089
|
-
|
|
21090
|
-
|
|
21091
|
-
|
|
21092
|
-
|
|
21093
|
-
);
|
|
21094
|
-
|
|
21095
|
-
|
|
21096
|
-
|
|
21097
|
-
|
|
21098
|
-
|
|
21099
|
-
|
|
21100
|
-
|
|
21101
|
-
|
|
21102
|
-
import { mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
21103
|
-
import { join as join6 } from "node:path";
|
|
21104
|
-
function cockpitsDir(env) {
|
|
21105
|
-
return join6(clientHome(env), "cockpits");
|
|
21106
|
-
}
|
|
21107
|
-
function slug(target) {
|
|
21108
|
-
return target.replace(/[^A-Za-z0-9@._-]/g, "_");
|
|
21109
|
-
}
|
|
21110
|
-
function cockpitRecordPath(target) {
|
|
21111
|
-
return join6(cockpitsDir(), `${slug(target)}.json`);
|
|
21112
|
-
}
|
|
21113
|
-
function cockpitLogPath(target) {
|
|
21114
|
-
return join6(clientHome(), "logs", `cockpit-${slug(target)}.log`);
|
|
21115
|
-
}
|
|
21116
|
-
function writeCockpitRecord(record2) {
|
|
21117
|
-
mkdirSync4(cockpitsDir(), { recursive: true, mode: 448 });
|
|
21118
|
-
writeFileSync2(cockpitRecordPath(record2.target), JSON.stringify(record2, null, 2) + "\n", {
|
|
21119
|
-
mode: 384
|
|
21120
|
-
});
|
|
21121
|
-
}
|
|
21122
|
-
function readCockpitRecord(target) {
|
|
21123
|
-
try {
|
|
21124
|
-
return JSON.parse(readFileSync5(cockpitRecordPath(target), "utf8"));
|
|
21125
|
-
} catch {
|
|
21126
|
-
return null;
|
|
21127
|
-
}
|
|
21128
|
-
}
|
|
21129
|
-
function removeCockpitRecord(target) {
|
|
21130
|
-
rmSync(cockpitRecordPath(target), { force: true });
|
|
21131
|
-
}
|
|
21132
|
-
function listCockpitRecords() {
|
|
21133
|
-
let names;
|
|
21134
|
-
try {
|
|
21135
|
-
names = readdirSync(cockpitsDir());
|
|
21136
|
-
} catch {
|
|
21137
|
-
return [];
|
|
21138
|
-
}
|
|
21139
|
-
const records = [];
|
|
21140
|
-
for (const name of names) {
|
|
21141
|
-
if (!name.endsWith(".json")) continue;
|
|
21142
|
-
try {
|
|
21143
|
-
records.push(JSON.parse(readFileSync5(join6(cockpitsDir(), name), "utf8")));
|
|
21144
|
-
} catch {
|
|
21145
|
-
}
|
|
21146
|
-
}
|
|
21147
|
-
return records.sort((a, b) => a.target.localeCompare(b.target));
|
|
21148
|
-
}
|
|
21149
|
-
function isPidAlive(pid) {
|
|
21150
|
-
try {
|
|
21151
|
-
process.kill(pid, 0);
|
|
21152
|
-
return true;
|
|
21153
|
-
} catch {
|
|
21154
|
-
return false;
|
|
21654
|
+
let stdout = "";
|
|
21655
|
+
let stderr = "";
|
|
21656
|
+
const result = new Promise((resolve4) => {
|
|
21657
|
+
child.stdout.on("data", (chunk) => stdout += chunk.toString());
|
|
21658
|
+
child.stderr.on("data", (chunk) => stderr += chunk.toString());
|
|
21659
|
+
child.on("error", (err) => resolve4({ code: -1, stdout, stderr: stderr + String(err) }));
|
|
21660
|
+
child.on("close", (code) => resolve4({ code: code ?? -1, stdout, stderr }));
|
|
21661
|
+
});
|
|
21662
|
+
return {
|
|
21663
|
+
result,
|
|
21664
|
+
terminate() {
|
|
21665
|
+
child.kill("SIGTERM");
|
|
21666
|
+
}
|
|
21667
|
+
};
|
|
21155
21668
|
}
|
|
21156
|
-
|
|
21157
|
-
|
|
21158
|
-
|
|
21159
|
-
if (record2.status === "starting") return "starting";
|
|
21160
|
-
if (record2.status !== "ready" || record2.origin === void 0 || record2.nonce === void 0) {
|
|
21161
|
-
return "unverified";
|
|
21669
|
+
async readFile(path) {
|
|
21670
|
+
const result = await this.exec(`cat ${path}`, { timeoutMs: 15e3 });
|
|
21671
|
+
return result.code === 0 ? result.stdout : null;
|
|
21162
21672
|
}
|
|
21163
|
-
|
|
21164
|
-
|
|
21165
|
-
|
|
21166
|
-
|
|
21673
|
+
async copyTo(localPath, destPath) {
|
|
21674
|
+
await this.exec(`mkdir -p $(dirname ${destPath})`, { timeoutMs: 15e3 });
|
|
21675
|
+
await new Promise((resolve4, reject) => {
|
|
21676
|
+
const child = spawn5(this.scp, [...this.controlArgs, localPath, `${this.host}:${destPath}`], {
|
|
21677
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
21678
|
+
env: this.spawnEnv()
|
|
21679
|
+
});
|
|
21680
|
+
child.on("error", reject);
|
|
21681
|
+
child.on("close", (code) => {
|
|
21682
|
+
if (code === 0) resolve4();
|
|
21683
|
+
else reject(new CliError("ssh_unreachable", `scp to ${this.host} failed (exit ${code})`));
|
|
21684
|
+
});
|
|
21167
21685
|
});
|
|
21168
|
-
return res.headers.get("x-puddle-cockpit") === record2.nonce ? "running" : "unverified";
|
|
21169
|
-
} catch {
|
|
21170
|
-
return "unverified";
|
|
21171
21686
|
}
|
|
21172
|
-
|
|
21173
|
-
|
|
21174
|
-
// src/lib/start.ts
|
|
21175
|
-
import { join as join7 } from "node:path";
|
|
21176
|
-
init_types();
|
|
21177
|
-
async function startLocal(opts) {
|
|
21178
|
-
const logger = opts.logger ?? silentLogger;
|
|
21179
|
-
const transport = new LocalTransport();
|
|
21180
|
-
const bootstrap = { tarball: opts.tarball, logger };
|
|
21181
|
-
const endpoint = await ensureDaemon(transport, bootstrap);
|
|
21182
|
-
const client = new DaemonClient(endpoint.port, endpoint.token);
|
|
21183
|
-
const daemon = await runHandshake({
|
|
21184
|
-
client,
|
|
21185
|
-
noUpgrade: opts.noUpgrade,
|
|
21186
|
-
upgradeDaemon: makeUpgrader(transport, client, bootstrap),
|
|
21187
|
-
logger
|
|
21188
|
-
});
|
|
21189
|
-
const ui = await startUiServer({
|
|
21190
|
-
assetsDir: opts.assetsDir,
|
|
21191
|
-
port: opts.port ?? opts.preferPort,
|
|
21192
|
-
strictPort: opts.port !== void 0,
|
|
21193
|
-
// a preferred port stays non-strict
|
|
21194
|
-
avoidPort: endpoint.port,
|
|
21195
|
-
// never squat the daemon's own port
|
|
21196
|
-
target: { host: "127.0.0.1", port: endpoint.port },
|
|
21197
|
-
...opts.onRefreshRequest !== void 0 ? { control: { token: endpoint.token, onRefresh: opts.onRefreshRequest } } : {},
|
|
21198
|
-
localSync: { token: endpoint.token, file: join7(clientHome(), "local-sync.json") }
|
|
21199
|
-
});
|
|
21200
|
-
const eventCbs = /* @__PURE__ */ new Set();
|
|
21201
|
-
return {
|
|
21202
|
-
origin: ui.origin,
|
|
21203
|
-
browserUrl: `${ui.origin}/#token=${endpoint.token}`,
|
|
21204
|
-
nonce: ui.nonce,
|
|
21205
|
-
daemon,
|
|
21206
|
-
onEvent(cb) {
|
|
21207
|
-
eventCbs.add(cb);
|
|
21208
|
-
return () => eventCbs.delete(cb);
|
|
21209
|
-
},
|
|
21210
|
-
async stop() {
|
|
21211
|
-
await ui.close();
|
|
21212
|
-
transport.dispose();
|
|
21213
|
-
}
|
|
21214
|
-
};
|
|
21215
|
-
}
|
|
21216
|
-
|
|
21217
|
-
// src/lib/status.ts
|
|
21218
|
-
var LIVE_FIRST = {
|
|
21219
|
-
running: 0,
|
|
21220
|
-
waiting_input: 1,
|
|
21221
|
-
starting: 2,
|
|
21222
|
-
interrupted: 3,
|
|
21223
|
-
exited: 4,
|
|
21224
|
-
archived: 5
|
|
21687
|
+
dispose() {
|
|
21688
|
+
}
|
|
21225
21689
|
};
|
|
21226
|
-
|
|
21227
|
-
|
|
21228
|
-
client.version(),
|
|
21229
|
-
client.host(),
|
|
21230
|
-
client.sessions()
|
|
21231
|
-
]);
|
|
21232
|
-
sessions.sort(
|
|
21233
|
-
(a, b) => (LIVE_FIRST[a.status] ?? 9) - (LIVE_FIRST[b.status] ?? 9) || (b.last_activity_at ?? "").localeCompare(a.last_activity_at ?? "")
|
|
21234
|
-
);
|
|
21235
|
-
return { daemon, host, sessions: sessions.filter((s) => s.status !== "archived") };
|
|
21690
|
+
function shellQuote(text) {
|
|
21691
|
+
return `'${text.replaceAll("'", `'\\''`)}'`;
|
|
21236
21692
|
}
|
|
21237
21693
|
|
|
21238
|
-
// src/
|
|
21239
|
-
init_types();
|
|
21240
|
-
|
|
21241
|
-
// src/cli/manage.ts
|
|
21242
|
-
import { spawn as spawn7 } from "node:child_process";
|
|
21243
|
-
import { existsSync as existsSync3 } from "node:fs";
|
|
21244
|
-
import { chmod as chmod2, copyFile, mkdir as mkdir2, rename, rm as rm2 } from "node:fs/promises";
|
|
21245
|
-
import { homedir as homedir2 } from "node:os";
|
|
21246
|
-
import { join as join9 } from "node:path";
|
|
21247
|
-
|
|
21248
|
-
// src/lib/desktop-update.ts
|
|
21249
|
-
import { createHash } from "node:crypto";
|
|
21250
|
-
import { spawn as spawn5 } from "node:child_process";
|
|
21251
|
-
import { constants, createReadStream as createReadStream2, createWriteStream } from "node:fs";
|
|
21252
|
-
import { access, chmod, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
21253
|
-
import { dirname as dirname5, join as join8 } from "node:path";
|
|
21254
|
-
import { Readable } from "node:stream";
|
|
21255
|
-
import { pipeline } from "node:stream/promises";
|
|
21694
|
+
// src/lib/connect.ts
|
|
21256
21695
|
init_types();
|
|
21257
|
-
|
|
21258
|
-
|
|
21259
|
-
|
|
21260
|
-
|
|
21261
|
-
|
|
21262
|
-
|
|
21263
|
-
|
|
21264
|
-
|
|
21265
|
-
|
|
21266
|
-
if (a[i] !== b[i]) return a[i] > b[i];
|
|
21267
|
-
}
|
|
21268
|
-
return false;
|
|
21269
|
-
}
|
|
21270
|
-
function pickDesktopAsset(assets, platform, arch) {
|
|
21271
|
-
const suffix = platform === "darwin" ? "-mac.zip" : platform === "linux" ? ".AppImage" : null;
|
|
21272
|
-
if (suffix === null) return null;
|
|
21273
|
-
return assets.find(
|
|
21274
|
-
(a) => a.name.endsWith(suffix) && a.name.includes("-arm64") === (arch === "arm64")
|
|
21275
|
-
) ?? null;
|
|
21276
|
-
}
|
|
21277
|
-
function parseSums(text) {
|
|
21278
|
-
const sums = /* @__PURE__ */ new Map();
|
|
21279
|
-
for (const line of text.split("\n")) {
|
|
21280
|
-
const match = /^([0-9a-f]{64})\s+\*?(.+?)\s*$/.exec(line.trim());
|
|
21281
|
-
if (match) sums.set(match[2], match[1]);
|
|
21282
|
-
}
|
|
21283
|
-
return sums;
|
|
21284
|
-
}
|
|
21285
|
-
async function checkForDesktopUpdate(currentVersion, opts = {}) {
|
|
21286
|
-
const slug2 = repoSlug();
|
|
21287
|
-
if (slug2 === void 0) return null;
|
|
21288
|
-
const fetchFn = opts.fetchFn ?? fetch;
|
|
21289
|
-
const response = await fetchFn(`https://api.github.com/repos/${slug2}/releases/latest`, {
|
|
21290
|
-
headers: { accept: "application/vnd.github+json", "user-agent": "puddle-desktop" }
|
|
21696
|
+
var CODEX_OAUTH_PORT = 1455;
|
|
21697
|
+
async function connectRemote(opts) {
|
|
21698
|
+
const logger = opts.logger ?? silentLogger;
|
|
21699
|
+
const platform = opts.platform ?? process.platform;
|
|
21700
|
+
const ssh = new SshTransport(opts.host, {
|
|
21701
|
+
sshBinary: opts.sshBinary,
|
|
21702
|
+
scpBinary: opts.scpBinary,
|
|
21703
|
+
platform,
|
|
21704
|
+
askpassProgram: opts.sshAskpassProgram
|
|
21291
21705
|
});
|
|
21292
|
-
if (
|
|
21293
|
-
|
|
21294
|
-
|
|
21295
|
-
const release = await response.json();
|
|
21296
|
-
const version2 = (release.tag_name ?? "").replace(/^v/, "");
|
|
21297
|
-
if (!isNewerVersion(version2, currentVersion)) return null;
|
|
21298
|
-
return updateFromRelease(version2, release.assets ?? [], opts);
|
|
21299
|
-
}
|
|
21300
|
-
async function desktopUpdateAt(version2, opts = {}) {
|
|
21301
|
-
const slug2 = repoSlug();
|
|
21302
|
-
if (slug2 === void 0) {
|
|
21303
|
-
throw new CliError(
|
|
21304
|
-
"not_installed",
|
|
21305
|
-
"no release source is configured for this build",
|
|
21306
|
-
"set PUDDLE_REPO=owner/repo"
|
|
21706
|
+
if (platform === "win32") {
|
|
21707
|
+
logger.warn(
|
|
21708
|
+
"Windows OpenSSH cannot share a connection; you may be asked to authenticate more than once. An SSH key (ssh-copy-id) avoids the prompts."
|
|
21307
21709
|
);
|
|
21308
21710
|
}
|
|
21309
|
-
|
|
21310
|
-
|
|
21311
|
-
|
|
21312
|
-
|
|
21313
|
-
|
|
21314
|
-
throw new CliError("not_installed", `no release v${version2} exists in ${slug2}`);
|
|
21315
|
-
}
|
|
21316
|
-
if (!response.ok) {
|
|
21317
|
-
throw new CliError("not_installed", `release lookup failed (HTTP ${response.status})`);
|
|
21318
|
-
}
|
|
21319
|
-
const release = await response.json();
|
|
21320
|
-
return updateFromRelease(version2, release.assets ?? [], opts);
|
|
21321
|
-
}
|
|
21322
|
-
function updateFromRelease(version2, assets, opts) {
|
|
21323
|
-
const asset = pickDesktopAsset(
|
|
21324
|
-
assets,
|
|
21325
|
-
opts.platform ?? process.platform,
|
|
21326
|
-
opts.arch ?? process.arch
|
|
21327
|
-
);
|
|
21328
|
-
const sums = assets.find((a) => a.name === "SHA256SUMS");
|
|
21329
|
-
if (asset === null || sums === void 0) return null;
|
|
21330
|
-
return {
|
|
21331
|
-
version: version2,
|
|
21332
|
-
asset: { name: asset.name, url: asset.browser_download_url },
|
|
21333
|
-
sumsUrl: sums.browser_download_url
|
|
21334
|
-
};
|
|
21335
|
-
}
|
|
21336
|
-
async function stageDesktopUpdate(update, opts = {}) {
|
|
21337
|
-
const logger = opts.logger ?? silentLogger;
|
|
21338
|
-
const fetchFn = opts.fetchFn ?? fetch;
|
|
21339
|
-
const dir = join8(opts.cacheDir ?? join8(clientHome(), "cache", "desktop"), update.version);
|
|
21340
|
-
await rm(dir, { recursive: true, force: true });
|
|
21341
|
-
await mkdir(dir, { recursive: true });
|
|
21711
|
+
await ssh.open();
|
|
21712
|
+
let tunnelResource;
|
|
21713
|
+
let oauthForwardResource;
|
|
21714
|
+
let uiResource;
|
|
21715
|
+
let endpointResource;
|
|
21342
21716
|
try {
|
|
21343
|
-
|
|
21344
|
-
const
|
|
21345
|
-
const
|
|
21346
|
-
|
|
21717
|
+
const bootstrap = { tarball: opts.tarball, logger };
|
|
21718
|
+
const attachedDaemon = new AttachedDaemon(ssh, logger);
|
|
21719
|
+
const endpoint = await ensureDaemon(ssh, {
|
|
21720
|
+
...bootstrap,
|
|
21721
|
+
attachedFallback: () => attachedDaemon.start()
|
|
21347
21722
|
});
|
|
21348
|
-
|
|
21349
|
-
|
|
21350
|
-
|
|
21351
|
-
|
|
21352
|
-
|
|
21353
|
-
|
|
21354
|
-
|
|
21355
|
-
|
|
21356
|
-
|
|
21723
|
+
endpointResource = endpoint;
|
|
21724
|
+
const remotePort = opts.remotePort ?? endpoint.port;
|
|
21725
|
+
const tunnel = await openTunnel(ssh, remotePort, {
|
|
21726
|
+
sshBinary: opts.sshBinary,
|
|
21727
|
+
logger,
|
|
21728
|
+
ready: async (localPort) => {
|
|
21729
|
+
await endpoint.lease?.ensureRunning();
|
|
21730
|
+
return waitForHttp(`http://127.0.0.1:${localPort}/api/version`, 8e3);
|
|
21731
|
+
}
|
|
21357
21732
|
});
|
|
21358
|
-
|
|
21359
|
-
|
|
21733
|
+
tunnelResource = tunnel;
|
|
21734
|
+
const client = new DaemonClient(tunnel.localPort, endpoint.token);
|
|
21735
|
+
tunnel.onPortChange((port) => client.setPort(port));
|
|
21736
|
+
const oauthForward = await openCallbackForward(ssh, CODEX_OAUTH_PORT, {
|
|
21737
|
+
sshBinary: opts.sshBinary,
|
|
21738
|
+
logger
|
|
21739
|
+
});
|
|
21740
|
+
oauthForwardResource = oauthForward;
|
|
21741
|
+
const daemon = await runHandshake({
|
|
21742
|
+
client,
|
|
21743
|
+
noUpgrade: opts.noUpgrade,
|
|
21744
|
+
upgradeDaemon: makeUpgrader(ssh, client, bootstrap, endpoint.lease),
|
|
21745
|
+
logger
|
|
21746
|
+
});
|
|
21747
|
+
const avoidPort = await readDaemonPort(new LocalTransport());
|
|
21748
|
+
const ui = await startUiServer({
|
|
21749
|
+
assetsDir: opts.assetsDir,
|
|
21750
|
+
port: opts.port ?? opts.preferPort,
|
|
21751
|
+
strictPort: opts.port !== void 0,
|
|
21752
|
+
// a preferred port stays non-strict
|
|
21753
|
+
avoidPort,
|
|
21754
|
+
target: { host: "127.0.0.1", port: tunnel.localPort },
|
|
21755
|
+
...opts.onRefreshRequest !== void 0 ? { control: { token: endpoint.token, onRefresh: opts.onRefreshRequest } } : {},
|
|
21756
|
+
// The store lives on the CLIENT machine — every cockpit here shares it,
|
|
21757
|
+
// whichever remote daemon each one drives.
|
|
21758
|
+
localSync: { token: endpoint.token, file: join7(clientHome(), "local-sync.json") }
|
|
21759
|
+
});
|
|
21760
|
+
uiResource = ui;
|
|
21761
|
+
tunnel.onPortChange((port) => ui.setTarget({ host: "127.0.0.1", port }));
|
|
21762
|
+
const eventCbs = /* @__PURE__ */ new Set();
|
|
21763
|
+
tunnel.onEvent((e) => {
|
|
21764
|
+
if (e.t === "tunnel-down") logger.warn(`tunnel to ${opts.host} lost \u2014 reconnecting\u2026`);
|
|
21765
|
+
if (e.t === "tunnel-up") logger.info("tunnel restored");
|
|
21766
|
+
eventCbs.forEach((cb) => cb(e));
|
|
21767
|
+
});
|
|
21768
|
+
return {
|
|
21769
|
+
origin: ui.origin,
|
|
21770
|
+
browserUrl: `${ui.origin}/?host=${encodeURIComponent(opts.host)}#token=${endpoint.token}`,
|
|
21771
|
+
nonce: ui.nonce,
|
|
21772
|
+
daemon,
|
|
21773
|
+
daemonLifetime: endpoint.daemonLifetime,
|
|
21774
|
+
onEvent(cb) {
|
|
21775
|
+
eventCbs.add(cb);
|
|
21776
|
+
return () => eventCbs.delete(cb);
|
|
21777
|
+
},
|
|
21778
|
+
async stop() {
|
|
21779
|
+
await closeRemoteResources(ui, oauthForward, endpoint, tunnel, ssh);
|
|
21780
|
+
}
|
|
21781
|
+
};
|
|
21782
|
+
} catch (err) {
|
|
21783
|
+
try {
|
|
21784
|
+
await closeRemoteResources(
|
|
21785
|
+
uiResource,
|
|
21786
|
+
oauthForwardResource,
|
|
21787
|
+
endpointResource,
|
|
21788
|
+
tunnelResource,
|
|
21789
|
+
ssh
|
|
21790
|
+
);
|
|
21791
|
+
} catch {
|
|
21360
21792
|
}
|
|
21361
|
-
|
|
21362
|
-
|
|
21363
|
-
|
|
21793
|
+
throw err;
|
|
21794
|
+
}
|
|
21795
|
+
}
|
|
21796
|
+
async function closeRemoteResources(ui, oauthForward, endpoint, tunnel, ssh) {
|
|
21797
|
+
const actions = [
|
|
21798
|
+
() => ui?.close(),
|
|
21799
|
+
() => oauthForward?.(),
|
|
21800
|
+
() => endpoint?.lease?.stop(),
|
|
21801
|
+
() => tunnel?.close(),
|
|
21802
|
+
() => ssh.dispose()
|
|
21803
|
+
];
|
|
21804
|
+
let firstError;
|
|
21805
|
+
for (const action of actions) {
|
|
21806
|
+
try {
|
|
21807
|
+
await action();
|
|
21808
|
+
} catch (err) {
|
|
21809
|
+
if (firstError === void 0) firstError = err;
|
|
21364
21810
|
}
|
|
21365
|
-
|
|
21366
|
-
|
|
21811
|
+
}
|
|
21812
|
+
if (firstError !== void 0) throw firstError;
|
|
21813
|
+
}
|
|
21814
|
+
|
|
21815
|
+
// src/lib/logs.ts
|
|
21816
|
+
init_types();
|
|
21817
|
+
async function showLogs(transport, opts) {
|
|
21818
|
+
if (opts.session !== void 0) {
|
|
21819
|
+
const file2 = `${hostPaths.logs}/${opts.session}/${opts.term ?? "agent"}.log`;
|
|
21820
|
+
const command2 = opts.follow ? `tail -f -c 262144 ${file2}` : `tail -c 262144 ${file2}`;
|
|
21821
|
+
const result = await transport.exec(command2, {
|
|
21822
|
+
onStdout: opts.write,
|
|
21823
|
+
...opts.follow ? {} : { timeoutMs: 3e4 }
|
|
21824
|
+
});
|
|
21825
|
+
if (result.code !== 0 && !opts.follow) {
|
|
21367
21826
|
throw new CliError(
|
|
21368
|
-
"
|
|
21369
|
-
`
|
|
21370
|
-
|
|
21827
|
+
"unknown_session",
|
|
21828
|
+
`no log at ${file2}`,
|
|
21829
|
+
"list sessions with: puddle status \u2014 the id is the log directory name"
|
|
21371
21830
|
);
|
|
21372
21831
|
}
|
|
21373
|
-
|
|
21374
|
-
const extractDir = join8(dir, "extract");
|
|
21375
|
-
await run("/usr/bin/ditto", ["-x", "-k", archive, extractDir]);
|
|
21376
|
-
const bundle = (await readdir(extractDir)).find((name) => name.endsWith(".app"));
|
|
21377
|
-
if (bundle === void 0) {
|
|
21378
|
-
throw new CliError("not_installed", "the update zip contains no .app bundle");
|
|
21379
|
-
}
|
|
21380
|
-
logger.info(`staged ${update.version} at ${join8(extractDir, bundle)}`);
|
|
21381
|
-
return {
|
|
21382
|
-
version: update.version,
|
|
21383
|
-
kind: "mac-app",
|
|
21384
|
-
stagedPath: join8(extractDir, bundle),
|
|
21385
|
-
dir
|
|
21386
|
-
};
|
|
21387
|
-
}
|
|
21388
|
-
await chmod(archive, 493);
|
|
21389
|
-
logger.info(`staged ${update.version} at ${archive}`);
|
|
21390
|
-
return { version: update.version, kind: "appimage", stagedPath: archive, dir };
|
|
21391
|
-
} catch (e) {
|
|
21392
|
-
await rm(dir, { recursive: true, force: true });
|
|
21393
|
-
throw e;
|
|
21832
|
+
return;
|
|
21394
21833
|
}
|
|
21834
|
+
const command = `if command -v journalctl >/dev/null 2>&1 && journalctl --user -u puddled -n 1 >/dev/null 2>&1; then journalctl --user -u puddled -n 200 --no-pager${opts.follow ? " -f" : ""}; elif [ -f ${hostPaths.logs}/puddled.out.log ]; then tail ${opts.follow ? "-f " : ""}-n 200 ${hostPaths.logs}/puddled.out.log ${hostPaths.logs}/puddled.err.log 2>/dev/null; else echo 'no daemon logs found \u2014 the daemon logs to its supervisor (journalctl --user -u puddled / ~/.puddle/logs)'; fi`;
|
|
21835
|
+
await transport.exec(command, { onStdout: opts.write });
|
|
21395
21836
|
}
|
|
21396
|
-
|
|
21397
|
-
|
|
21398
|
-
|
|
21837
|
+
|
|
21838
|
+
// src/lib/registry.ts
|
|
21839
|
+
import { mkdirSync as mkdirSync5, readdirSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
21840
|
+
import { join as join8 } from "node:path";
|
|
21841
|
+
function cockpitsDir(env) {
|
|
21842
|
+
return join8(clientHome(env), "cockpits");
|
|
21843
|
+
}
|
|
21844
|
+
function slug(target) {
|
|
21845
|
+
return target.replace(/[^A-Za-z0-9@._-]/g, "_");
|
|
21846
|
+
}
|
|
21847
|
+
function cockpitRecordPath(target) {
|
|
21848
|
+
return join8(cockpitsDir(), `${slug(target)}.json`);
|
|
21849
|
+
}
|
|
21850
|
+
function cockpitLogPath(target) {
|
|
21851
|
+
return join8(clientHome(), "logs", `cockpit-${slug(target)}.log`);
|
|
21852
|
+
}
|
|
21853
|
+
function writeCockpitRecord(record2) {
|
|
21854
|
+
mkdirSync5(cockpitsDir(), { recursive: true, mode: 448 });
|
|
21855
|
+
writeFileSync3(cockpitRecordPath(record2.target), JSON.stringify(record2, null, 2) + "\n", {
|
|
21856
|
+
mode: 384
|
|
21857
|
+
});
|
|
21858
|
+
}
|
|
21859
|
+
function readCockpitRecord(target) {
|
|
21399
21860
|
try {
|
|
21400
|
-
|
|
21861
|
+
return JSON.parse(readFileSync6(cockpitRecordPath(target), "utf8"));
|
|
21401
21862
|
} catch {
|
|
21402
|
-
return;
|
|
21863
|
+
return null;
|
|
21403
21864
|
}
|
|
21404
|
-
await Promise.all(
|
|
21405
|
-
entries.filter((name) => !keep.includes(name)).map((name) => rm(join8(dir, name), { recursive: true, force: true }).catch(() => {
|
|
21406
|
-
}))
|
|
21407
|
-
);
|
|
21408
21865
|
}
|
|
21409
|
-
|
|
21410
|
-
|
|
21411
|
-
const script = swapScript(staged, opts);
|
|
21412
|
-
const scriptPath = join8(staged.dir, "apply.sh");
|
|
21413
|
-
await writeFile(scriptPath, script, { mode: 493 });
|
|
21414
|
-
logger.info(`applying ${staged.version} via ${scriptPath}`);
|
|
21415
|
-
if (opts.detach ?? true) {
|
|
21416
|
-
const child = spawn5("/bin/sh", [scriptPath], { detached: true, stdio: "ignore" });
|
|
21417
|
-
child.unref();
|
|
21418
|
-
return;
|
|
21419
|
-
}
|
|
21420
|
-
await run("/bin/sh", [scriptPath]);
|
|
21866
|
+
function removeCockpitRecord(target) {
|
|
21867
|
+
rmSync(cockpitRecordPath(target), { force: true });
|
|
21421
21868
|
}
|
|
21422
|
-
|
|
21423
|
-
|
|
21424
|
-
|
|
21425
|
-
|
|
21426
|
-
|
|
21427
|
-
|
|
21428
|
-
` sleep 0.1`,
|
|
21429
|
-
`done`
|
|
21430
|
-
];
|
|
21431
|
-
if (staged.kind === "mac-app") {
|
|
21432
|
-
return [
|
|
21433
|
-
`#!/bin/sh`,
|
|
21434
|
-
`# Puddle desktop update ${staged.version} \u2014 swap once the app exits`,
|
|
21435
|
-
...wait,
|
|
21436
|
-
`target=${q(opts.targetPath)}`,
|
|
21437
|
-
`target_dir=${q(dirname5(opts.targetPath))}`,
|
|
21438
|
-
`staged=${q(staged.stagedPath)}`,
|
|
21439
|
-
`mkdir -p "$target_dir" || exit 1`,
|
|
21440
|
-
`rm -rf "$target.old"`,
|
|
21441
|
-
`had_old=0`,
|
|
21442
|
-
`if [ -e "$target" ]; then`,
|
|
21443
|
-
` mv "$target" "$target.old" || exit 1`,
|
|
21444
|
-
` had_old=1`,
|
|
21445
|
-
`fi`,
|
|
21446
|
-
`if mv "$staged" "$target" 2>/dev/null || /usr/bin/ditto "$staged" "$target"; then`,
|
|
21447
|
-
// Defence in depth: the normal path never quarantines (node's fetch is
|
|
21448
|
-
// not a quarantine-opted-in app, and ditto only propagates what the
|
|
21449
|
-
// zip already carries), but if the bundle ever acquires the attribute
|
|
21450
|
-
// the swap must not resurrect the Gatekeeper prompt.
|
|
21451
|
-
` /usr/bin/xattr -dr com.apple.quarantine "$target" 2>/dev/null`,
|
|
21452
|
-
` rm -rf "$target.old" ${q(staged.dir)}`,
|
|
21453
|
-
...opts.relaunch ? [` open "$target"`] : [],
|
|
21454
|
-
`else`,
|
|
21455
|
-
` rm -rf "$target"`,
|
|
21456
|
-
` [ "$had_old" -eq 0 ] || mv "$target.old" "$target"`,
|
|
21457
|
-
` exit 1`,
|
|
21458
|
-
`fi`,
|
|
21459
|
-
``
|
|
21460
|
-
].join("\n");
|
|
21869
|
+
function listCockpitRecords() {
|
|
21870
|
+
let names;
|
|
21871
|
+
try {
|
|
21872
|
+
names = readdirSync(cockpitsDir());
|
|
21873
|
+
} catch {
|
|
21874
|
+
return [];
|
|
21461
21875
|
}
|
|
21462
|
-
|
|
21463
|
-
|
|
21464
|
-
|
|
21465
|
-
...wait,
|
|
21466
|
-
`target=${q(opts.targetPath)}`,
|
|
21467
|
-
`cp -f ${q(staged.stagedPath)} "$target" || exit 1`,
|
|
21468
|
-
`chmod +x "$target"`,
|
|
21469
|
-
`rm -rf ${q(staged.dir)}`,
|
|
21470
|
-
...opts.relaunch ? [`"$target" >/dev/null 2>&1 &`] : [],
|
|
21471
|
-
``
|
|
21472
|
-
].join("\n");
|
|
21473
|
-
}
|
|
21474
|
-
async function findInstalledDesktopApp(opts = {}) {
|
|
21475
|
-
if ((opts.platform ?? process.platform) !== "darwin") return null;
|
|
21476
|
-
const { readFile } = await import("node:fs/promises");
|
|
21477
|
-
const { homedir: homedir3 } = await import("node:os");
|
|
21478
|
-
const systemApplications = opts.systemApplicationsDir ?? "/Applications";
|
|
21479
|
-
const home = opts.homeDir ?? homedir3();
|
|
21480
|
-
for (const appPath of [
|
|
21481
|
-
join8(systemApplications, "Puddle.app"),
|
|
21482
|
-
join8(home, "Applications", "Puddle.app")
|
|
21483
|
-
]) {
|
|
21876
|
+
const records = [];
|
|
21877
|
+
for (const name of names) {
|
|
21878
|
+
if (!name.endsWith(".json")) continue;
|
|
21484
21879
|
try {
|
|
21485
|
-
|
|
21486
|
-
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
|
|
21487
|
-
plist
|
|
21488
|
-
);
|
|
21489
|
-
if (match) return { appPath, version: match[1] };
|
|
21880
|
+
records.push(JSON.parse(readFileSync6(join8(cockpitsDir(), name), "utf8")));
|
|
21490
21881
|
} catch {
|
|
21491
21882
|
}
|
|
21492
21883
|
}
|
|
21493
|
-
return
|
|
21884
|
+
return records.sort((a, b) => a.target.localeCompare(b.target));
|
|
21494
21885
|
}
|
|
21495
|
-
|
|
21496
|
-
|
|
21497
|
-
|
|
21498
|
-
|
|
21499
|
-
|
|
21500
|
-
|
|
21501
|
-
|
|
21502
|
-
return true;
|
|
21503
|
-
} catch {
|
|
21504
|
-
return false;
|
|
21505
|
-
}
|
|
21506
|
-
});
|
|
21507
|
-
if (await canWrite(systemApplications)) return join8(systemApplications, "Puddle.app");
|
|
21508
|
-
const userApplications = join8(opts.homeDir ?? homedir3(), "Applications");
|
|
21509
|
-
await mkdir(userApplications, { recursive: true });
|
|
21510
|
-
return join8(userApplications, "Puddle.app");
|
|
21886
|
+
function isPidAlive(pid) {
|
|
21887
|
+
try {
|
|
21888
|
+
process.kill(pid, 0);
|
|
21889
|
+
return true;
|
|
21890
|
+
} catch {
|
|
21891
|
+
return false;
|
|
21892
|
+
}
|
|
21511
21893
|
}
|
|
21512
|
-
function
|
|
21513
|
-
|
|
21514
|
-
|
|
21515
|
-
|
|
21894
|
+
async function checkCockpit(record2) {
|
|
21895
|
+
if (!isPidAlive(record2.pid)) return "dead";
|
|
21896
|
+
if (record2.status === "starting") return "starting";
|
|
21897
|
+
if (record2.status !== "ready" || record2.origin === void 0 || record2.nonce === void 0) {
|
|
21898
|
+
return "unverified";
|
|
21899
|
+
}
|
|
21900
|
+
try {
|
|
21901
|
+
const res = await fetch(`${record2.origin}/`, {
|
|
21902
|
+
method: "HEAD",
|
|
21903
|
+
signal: AbortSignal.timeout(1500)
|
|
21516
21904
|
});
|
|
21517
|
-
|
|
21518
|
-
|
|
21519
|
-
|
|
21520
|
-
}
|
|
21521
|
-
function fileSha256(path) {
|
|
21522
|
-
const hash2 = createHash("sha256");
|
|
21523
|
-
return pipeline(createReadStream2(path), hash2).then(() => hash2.digest("hex"));
|
|
21905
|
+
return res.headers.get("x-puddle-cockpit") === record2.nonce ? "running" : "unverified";
|
|
21906
|
+
} catch {
|
|
21907
|
+
return "unverified";
|
|
21908
|
+
}
|
|
21524
21909
|
}
|
|
21525
|
-
|
|
21526
|
-
|
|
21527
|
-
|
|
21528
|
-
|
|
21529
|
-
|
|
21530
|
-
|
|
21531
|
-
|
|
21532
|
-
|
|
21910
|
+
|
|
21911
|
+
// src/lib/start.ts
|
|
21912
|
+
import { join as join9 } from "node:path";
|
|
21913
|
+
init_types();
|
|
21914
|
+
async function startLocal(opts) {
|
|
21915
|
+
const logger = opts.logger ?? silentLogger;
|
|
21916
|
+
const transport = new LocalTransport();
|
|
21917
|
+
const bootstrap = { tarball: opts.tarball, logger };
|
|
21918
|
+
const endpoint = await ensureDaemon(transport, bootstrap);
|
|
21919
|
+
const client = new DaemonClient(endpoint.port, endpoint.token);
|
|
21920
|
+
const daemon = await runHandshake({
|
|
21921
|
+
client,
|
|
21922
|
+
noUpgrade: opts.noUpgrade,
|
|
21923
|
+
upgradeDaemon: makeUpgrader(transport, client, bootstrap),
|
|
21924
|
+
logger
|
|
21925
|
+
});
|
|
21926
|
+
const ui = await startUiServer({
|
|
21927
|
+
assetsDir: opts.assetsDir,
|
|
21928
|
+
port: opts.port ?? opts.preferPort,
|
|
21929
|
+
strictPort: opts.port !== void 0,
|
|
21930
|
+
// a preferred port stays non-strict
|
|
21931
|
+
avoidPort: endpoint.port,
|
|
21932
|
+
// never squat the daemon's own port
|
|
21933
|
+
target: { host: "127.0.0.1", port: endpoint.port },
|
|
21934
|
+
...opts.onRefreshRequest !== void 0 ? { control: { token: endpoint.token, onRefresh: opts.onRefreshRequest } } : {},
|
|
21935
|
+
localSync: { token: endpoint.token, file: join9(clientHome(), "local-sync.json") }
|
|
21533
21936
|
});
|
|
21937
|
+
const eventCbs = /* @__PURE__ */ new Set();
|
|
21938
|
+
return {
|
|
21939
|
+
origin: ui.origin,
|
|
21940
|
+
browserUrl: `${ui.origin}/#token=${endpoint.token}`,
|
|
21941
|
+
nonce: ui.nonce,
|
|
21942
|
+
daemon,
|
|
21943
|
+
daemonLifetime: "persistent",
|
|
21944
|
+
onEvent(cb) {
|
|
21945
|
+
eventCbs.add(cb);
|
|
21946
|
+
return () => eventCbs.delete(cb);
|
|
21947
|
+
},
|
|
21948
|
+
async stop() {
|
|
21949
|
+
await ui.close();
|
|
21950
|
+
transport.dispose();
|
|
21951
|
+
}
|
|
21952
|
+
};
|
|
21953
|
+
}
|
|
21954
|
+
|
|
21955
|
+
// src/lib/status.ts
|
|
21956
|
+
var LIVE_FIRST = {
|
|
21957
|
+
running: 0,
|
|
21958
|
+
waiting_input: 1,
|
|
21959
|
+
starting: 2,
|
|
21960
|
+
interrupted: 3,
|
|
21961
|
+
exited: 4,
|
|
21962
|
+
archived: 5
|
|
21963
|
+
};
|
|
21964
|
+
async function statusReport(client) {
|
|
21965
|
+
const [daemon, host, sessions] = await Promise.all([
|
|
21966
|
+
client.version(),
|
|
21967
|
+
client.host(),
|
|
21968
|
+
client.sessions()
|
|
21969
|
+
]);
|
|
21970
|
+
sessions.sort(
|
|
21971
|
+
(a, b) => (LIVE_FIRST[a.status] ?? 9) - (LIVE_FIRST[b.status] ?? 9) || (b.last_activity_at ?? "").localeCompare(a.last_activity_at ?? "")
|
|
21972
|
+
);
|
|
21973
|
+
return { daemon, host, sessions: sessions.filter((s) => s.status !== "archived") };
|
|
21534
21974
|
}
|
|
21535
21975
|
|
|
21976
|
+
// src/cli/run.ts
|
|
21977
|
+
init_types();
|
|
21978
|
+
|
|
21979
|
+
// src/cli/manage.ts
|
|
21980
|
+
import { spawn as spawn7 } from "node:child_process";
|
|
21981
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
21982
|
+
import { chmod as chmod2, copyFile, mkdir as mkdir2, rename, rm as rm2 } from "node:fs/promises";
|
|
21983
|
+
import { homedir as homedir2 } from "node:os";
|
|
21984
|
+
import { join as join10 } from "node:path";
|
|
21985
|
+
|
|
21536
21986
|
// src/lib/releases.ts
|
|
21537
21987
|
init_types();
|
|
21538
21988
|
async function latestReleaseVersion(opts = {}) {
|
|
@@ -21614,7 +22064,7 @@ if [ "\${PUDDLE_PURGE:-0}" = 1 ]; then
|
|
|
21614
22064
|
rm -rf "$HOME_DIR"
|
|
21615
22065
|
say "deleted $HOME_DIR"
|
|
21616
22066
|
else
|
|
21617
|
-
rm -rf "$HOME_DIR/bin" "$HOME_DIR/cache" "$HOME_DIR/runtime.json"
|
|
22067
|
+
rm -rf "$HOME_DIR/bin" "$HOME_DIR/cache" "$HOME_DIR/runtime.json" "$HOME_DIR/supervisor"
|
|
21618
22068
|
say "removed the install; data kept under $HOME_DIR"
|
|
21619
22069
|
fi`;
|
|
21620
22070
|
const result = await transport.exec(`PUDDLE_PURGE=${opts.purge ? 1 : 0} sh -s`, {
|
|
@@ -21656,8 +22106,8 @@ async function upgradeDaemon(transport, opts = {}) {
|
|
|
21656
22106
|
|
|
21657
22107
|
// src/cli/detach.ts
|
|
21658
22108
|
import { spawn as spawn6 } from "node:child_process";
|
|
21659
|
-
import { closeSync, mkdirSync as
|
|
21660
|
-
import { dirname as
|
|
22109
|
+
import { closeSync, mkdirSync as mkdirSync6, openSync, readFileSync as readFileSync7 } from "node:fs";
|
|
22110
|
+
import { dirname as dirname7 } from "node:path";
|
|
21661
22111
|
init_types();
|
|
21662
22112
|
var COCKPIT_CHILD_ENV = "PUDDLE_COCKPIT_CHILD";
|
|
21663
22113
|
var READY_TIMEOUT_MS = 3e5;
|
|
@@ -21698,7 +22148,7 @@ async function launchDetached(opts) {
|
|
|
21698
22148
|
}
|
|
21699
22149
|
if (target !== "local") await new SshTransport(target).open();
|
|
21700
22150
|
const logFile = cockpitLogPath(target);
|
|
21701
|
-
|
|
22151
|
+
mkdirSync6(dirname7(logFile), { recursive: true });
|
|
21702
22152
|
const fd = openSync(logFile, "w");
|
|
21703
22153
|
const argv = opts.argv ?? process.argv.slice(2);
|
|
21704
22154
|
const child = spawn6(process.execPath, [process.argv[1] ?? "", ...argv], {
|
|
@@ -21738,7 +22188,7 @@ async function followStartup(target, childPid, logFile) {
|
|
|
21738
22188
|
let offset = 0;
|
|
21739
22189
|
const relay = () => {
|
|
21740
22190
|
try {
|
|
21741
|
-
const buf =
|
|
22191
|
+
const buf = readFileSync7(logFile);
|
|
21742
22192
|
if (buf.length > offset) {
|
|
21743
22193
|
process.stderr.write(buf.subarray(offset));
|
|
21744
22194
|
offset = buf.length;
|
|
@@ -21763,7 +22213,7 @@ async function followStartup(target, childPid, logFile) {
|
|
|
21763
22213
|
}
|
|
21764
22214
|
function spawnDetachedRefresh(target, argv) {
|
|
21765
22215
|
const logFile = cockpitLogPath(target);
|
|
21766
|
-
|
|
22216
|
+
mkdirSync6(dirname7(logFile), { recursive: true });
|
|
21767
22217
|
const fd = openSync(logFile, "a");
|
|
21768
22218
|
const env = { ...process.env };
|
|
21769
22219
|
delete env[COCKPIT_CHILD_ENV];
|
|
@@ -21780,7 +22230,7 @@ async function terminateCockpit(record2) {
|
|
|
21780
22230
|
process.kill(record2.pid, "SIGTERM");
|
|
21781
22231
|
} catch {
|
|
21782
22232
|
}
|
|
21783
|
-
const deadline = Date.now() +
|
|
22233
|
+
const deadline = Date.now() + 1e4;
|
|
21784
22234
|
while (Date.now() < deadline && isPidAlive(record2.pid)) await sleep(100);
|
|
21785
22235
|
if (isPidAlive(record2.pid)) {
|
|
21786
22236
|
try {
|
|
@@ -21800,7 +22250,7 @@ async function ask(question, def) {
|
|
|
21800
22250
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
21801
22251
|
try {
|
|
21802
22252
|
const answer = await new Promise(
|
|
21803
|
-
(
|
|
22253
|
+
(resolve4) => rl.question(`${question} [${def}] `, resolve4)
|
|
21804
22254
|
);
|
|
21805
22255
|
return answer.trim() === "" ? def : answer.trim();
|
|
21806
22256
|
} finally {
|
|
@@ -21819,7 +22269,7 @@ async function confirm(question, opts = {}) {
|
|
|
21819
22269
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
21820
22270
|
try {
|
|
21821
22271
|
const answer = await new Promise(
|
|
21822
|
-
(
|
|
22272
|
+
(resolve4) => rl.question(`${question} [y/N] `, resolve4)
|
|
21823
22273
|
);
|
|
21824
22274
|
return /^y(es)?$/i.test(answer.trim());
|
|
21825
22275
|
} finally {
|
|
@@ -21938,7 +22388,7 @@ async function runUpgrade(cmd, logger) {
|
|
|
21938
22388
|
function upgradeCli(version2, logger) {
|
|
21939
22389
|
const spec = `@puddle-code/cli@${version2 ?? "latest"}`;
|
|
21940
22390
|
logger.info(`puddle CLI ${cliVersion()} \u2014 asking npm for ${version2 ?? "the latest release"}`);
|
|
21941
|
-
return new Promise((
|
|
22391
|
+
return new Promise((resolve4, reject) => {
|
|
21942
22392
|
const child = spawn7("npm", ["install", "-g", spec], { stdio: "inherit" });
|
|
21943
22393
|
child.on(
|
|
21944
22394
|
"error",
|
|
@@ -21953,7 +22403,7 @@ function upgradeCli(version2, logger) {
|
|
|
21953
22403
|
child.on("exit", (code) => {
|
|
21954
22404
|
if (code === 0) {
|
|
21955
22405
|
logger.info("done \u2014 `puddle --version` shows the installed version");
|
|
21956
|
-
|
|
22406
|
+
resolve4(0);
|
|
21957
22407
|
} else {
|
|
21958
22408
|
reject(new CliError("not_installed", `npm install exited with ${code ?? "a signal"}`));
|
|
21959
22409
|
}
|
|
@@ -22013,6 +22463,12 @@ async function setDesktop(opts, logger) {
|
|
|
22013
22463
|
logger.info(move);
|
|
22014
22464
|
const staged = await stageDesktopUpdate(update, { logger });
|
|
22015
22465
|
await applyDesktopUpdate(staged, { targetPath, detach: false, relaunch: false, logger });
|
|
22466
|
+
const protocol = componentProtocolForVersion(update.version);
|
|
22467
|
+
recordDesktopInstallation({
|
|
22468
|
+
path: targetPath,
|
|
22469
|
+
version: update.version,
|
|
22470
|
+
...protocol ? { protocol } : {}
|
|
22471
|
+
});
|
|
22016
22472
|
logger.info(`Puddle ${update.version} installed at ${targetPath}`);
|
|
22017
22473
|
return 0;
|
|
22018
22474
|
}
|
|
@@ -22025,8 +22481,8 @@ async function installDesktopAppImage(version2, logger) {
|
|
|
22025
22481
|
);
|
|
22026
22482
|
}
|
|
22027
22483
|
const dir = expandHome2(await ask("Where should the AppImage be stored?", "~/puddle"));
|
|
22028
|
-
const target =
|
|
22029
|
-
if (
|
|
22484
|
+
const target = join10(dir, "Puddle.AppImage");
|
|
22485
|
+
if (existsSync4(target) && version2 === void 0) {
|
|
22030
22486
|
logger.info(`${target} already exists \u2014 the app updates itself in-app (its update toast)`);
|
|
22031
22487
|
return 0;
|
|
22032
22488
|
}
|
|
@@ -22041,6 +22497,12 @@ async function installDesktopAppImage(version2, logger) {
|
|
|
22041
22497
|
await chmod2(target, 493);
|
|
22042
22498
|
}
|
|
22043
22499
|
await rm2(staged.dir, { recursive: true, force: true });
|
|
22500
|
+
const protocol = componentProtocolForVersion(update.version);
|
|
22501
|
+
recordDesktopInstallation({
|
|
22502
|
+
path: target,
|
|
22503
|
+
version: update.version,
|
|
22504
|
+
...protocol ? { protocol } : {}
|
|
22505
|
+
});
|
|
22044
22506
|
logger.info(`installed \u2014 run it directly (${target}) or add it to your launcher`);
|
|
22045
22507
|
const opener = spawn7("xdg-open", [dir], { stdio: "ignore", detached: true });
|
|
22046
22508
|
opener.on("error", () => void 0);
|
|
@@ -22049,7 +22511,7 @@ async function installDesktopAppImage(version2, logger) {
|
|
|
22049
22511
|
}
|
|
22050
22512
|
function expandHome2(path) {
|
|
22051
22513
|
if (path === "~") return homedir2();
|
|
22052
|
-
return path.startsWith("~/") ?
|
|
22514
|
+
return path.startsWith("~/") ? join10(homedir2(), path.slice(2)) : path;
|
|
22053
22515
|
}
|
|
22054
22516
|
async function runRemove(cmd, logger) {
|
|
22055
22517
|
switch (cmd.what) {
|
|
@@ -22064,10 +22526,10 @@ async function runRemove(cmd, logger) {
|
|
|
22064
22526
|
}
|
|
22065
22527
|
}
|
|
22066
22528
|
async function removeCli(yes, logger) {
|
|
22067
|
-
const managed = await new Promise((
|
|
22529
|
+
const managed = await new Promise((resolve4) => {
|
|
22068
22530
|
const child = spawn7("npm", ["ls", "-g", "@puddle-code/cli", "--depth=0"], { stdio: "ignore" });
|
|
22069
|
-
child.on("error", () =>
|
|
22070
|
-
child.on("exit", (code) =>
|
|
22531
|
+
child.on("error", () => resolve4(false));
|
|
22532
|
+
child.on("exit", (code) => resolve4(code === 0));
|
|
22071
22533
|
});
|
|
22072
22534
|
if (!managed) {
|
|
22073
22535
|
throw new CliError(
|
|
@@ -22084,13 +22546,13 @@ async function removeCli(yes, logger) {
|
|
|
22084
22546
|
logger.info("nothing removed");
|
|
22085
22547
|
return 0;
|
|
22086
22548
|
}
|
|
22087
|
-
return new Promise((
|
|
22549
|
+
return new Promise((resolve4, reject) => {
|
|
22088
22550
|
const child = spawn7("npm", ["uninstall", "-g", "@puddle-code/cli"], { stdio: "inherit" });
|
|
22089
22551
|
child.on("error", () => reject(new CliError("not_installed", "npm is not on PATH")));
|
|
22090
22552
|
child.on("exit", (code) => {
|
|
22091
22553
|
if (code === 0) {
|
|
22092
22554
|
logger.info("removed \u2014 daemons keep running (puddle remove daemon uninstalls one)");
|
|
22093
|
-
|
|
22555
|
+
resolve4(0);
|
|
22094
22556
|
} else {
|
|
22095
22557
|
reject(new CliError("not_installed", `npm uninstall exited with ${code ?? "a signal"}`));
|
|
22096
22558
|
}
|
|
@@ -22241,9 +22703,9 @@ function timestampedLogger() {
|
|
|
22241
22703
|
|
|
22242
22704
|
// src/cli/run.ts
|
|
22243
22705
|
function assetsDir() {
|
|
22244
|
-
const here =
|
|
22245
|
-
for (const candidate of [
|
|
22246
|
-
if (
|
|
22706
|
+
const here = dirname8(fileURLToPath3(import.meta.url));
|
|
22707
|
+
for (const candidate of [join11(here, "public"), join11(here, "..", "..", "dist", "public")]) {
|
|
22708
|
+
if (existsSync5(join11(candidate, "index.html"))) return candidate;
|
|
22247
22709
|
}
|
|
22248
22710
|
throw new CliError(
|
|
22249
22711
|
"not_installed",
|
|
@@ -22283,7 +22745,7 @@ async function openTarget(host) {
|
|
|
22283
22745
|
};
|
|
22284
22746
|
}
|
|
22285
22747
|
function runUntilInterrupted(stop, farewell) {
|
|
22286
|
-
return new Promise((
|
|
22748
|
+
return new Promise((resolve4) => {
|
|
22287
22749
|
let stopping = false;
|
|
22288
22750
|
process.on("SIGINT", () => {
|
|
22289
22751
|
if (stopping) process.exit(130);
|
|
@@ -22291,10 +22753,10 @@ function runUntilInterrupted(stop, farewell) {
|
|
|
22291
22753
|
process.stderr.write(`
|
|
22292
22754
|
${farewell}
|
|
22293
22755
|
`);
|
|
22294
|
-
void stop().then(
|
|
22756
|
+
void stop().then(resolve4);
|
|
22295
22757
|
});
|
|
22296
22758
|
process.on("SIGTERM", () => {
|
|
22297
|
-
void stop().then(
|
|
22759
|
+
void stop().then(resolve4);
|
|
22298
22760
|
});
|
|
22299
22761
|
});
|
|
22300
22762
|
}
|
|
@@ -22305,7 +22767,7 @@ async function run2(command) {
|
|
|
22305
22767
|
process.stdout.write(USAGE + "\n");
|
|
22306
22768
|
return 0;
|
|
22307
22769
|
case "version":
|
|
22308
|
-
process.stdout.write(
|
|
22770
|
+
process.stdout.write(`${formatComponentVersions(await installedComponentVersions())}
|
|
22309
22771
|
`);
|
|
22310
22772
|
return 0;
|
|
22311
22773
|
case "launch": {
|
|
@@ -22438,7 +22900,9 @@ async function run2(command) {
|
|
|
22438
22900
|
for (const victim of victims) {
|
|
22439
22901
|
await terminateCockpit(victim);
|
|
22440
22902
|
const where = victim.target === "local" ? "this machine" : victim.target;
|
|
22441
|
-
logger.info(
|
|
22903
|
+
logger.info(
|
|
22904
|
+
victim.daemonLifetime === "cockpit" ? `stopped the cockpit for ${victim.target} \u2014 daemon data remains on ${where}; interrupted sessions can resume on the next launch` : `stopped the cockpit for ${victim.target} \u2014 sessions keep running on ${where}`
|
|
22905
|
+
);
|
|
22442
22906
|
}
|
|
22443
22907
|
return 0;
|
|
22444
22908
|
}
|
|
@@ -22591,7 +23055,8 @@ async function runCockpit(command, target, terminal) {
|
|
|
22591
23055
|
status: "ready",
|
|
22592
23056
|
origin: cockpit.origin,
|
|
22593
23057
|
browserUrl: cockpit.browserUrl,
|
|
22594
|
-
nonce: cockpit.nonce
|
|
23058
|
+
nonce: cockpit.nonce,
|
|
23059
|
+
daemonLifetime: cockpit.daemonLifetime
|
|
22595
23060
|
});
|
|
22596
23061
|
const arrow = command.target === "local" ? "" : ` \u2192 ${command.target}`;
|
|
22597
23062
|
logger.info(`Puddle cockpit at ${cockpit.origin}${arrow} (daemon ${cockpit.daemon.version})`);
|
|
@@ -22600,10 +23065,13 @@ async function runCockpit(command, target, terminal) {
|
|
|
22600
23065
|
logger.info(`open: ${cockpit.browserUrl}`);
|
|
22601
23066
|
}
|
|
22602
23067
|
const where = command.target === "local" ? "this machine" : command.target;
|
|
22603
|
-
await runUntilInterrupted(
|
|
22604
|
-
|
|
22605
|
-
|
|
22606
|
-
|
|
23068
|
+
await runUntilInterrupted(
|
|
23069
|
+
async () => {
|
|
23070
|
+
await cockpit.stop();
|
|
23071
|
+
removeCockpitRecord(target);
|
|
23072
|
+
},
|
|
23073
|
+
cockpit.daemonLifetime === "cockpit" ? `cockpit closed \u2014 daemon data remains on ${where}; interrupted sessions can resume on the next launch` : `cockpit closed \u2014 sessions keep running on ${where}`
|
|
23074
|
+
);
|
|
22607
23075
|
return 0;
|
|
22608
23076
|
}
|
|
22609
23077
|
function pad(text, width) {
|