@hyperdrive.bot/paseo-cli 0.3.6 → 0.3.8
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.
|
@@ -3,6 +3,7 @@ import { startCommand } from "./start.js";
|
|
|
3
3
|
import { runStatusCommand } from "./status.js";
|
|
4
4
|
import { runStopCommand } from "./stop.js";
|
|
5
5
|
import { runRestartCommand } from "./restart.js";
|
|
6
|
+
import { runUpdateCommand } from "./update.js";
|
|
6
7
|
import { runSetPasswordCommand } from "./set-password.js";
|
|
7
8
|
import { pairCommand } from "./pair.js";
|
|
8
9
|
import { withOutput } from "../../output/index.js";
|
|
@@ -47,6 +48,17 @@ export function createDaemonCommand() {
|
|
|
47
48
|
hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts),
|
|
48
49
|
}, command);
|
|
49
50
|
}));
|
|
51
|
+
addJsonOption(daemon
|
|
52
|
+
.command("update")
|
|
53
|
+
.description("Install a newer daemon version into its own slot, with rollback"))
|
|
54
|
+
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
|
55
|
+
.option("--to <version>", "Install this exact version instead of the channel's latest")
|
|
56
|
+
.option("--channel <channel>", "Release channel: stable, beta or dev (default: stable)")
|
|
57
|
+
.option("--check", "Report whether an update is available without installing it")
|
|
58
|
+
.option("--force", "Reinstall even if current, or retry a version pinned after a failure")
|
|
59
|
+
.option("--use-staged", "Activate a slot already present under <home>/versions instead of downloading it (still verified before activation)")
|
|
60
|
+
.option("--ready-timeout <seconds>", "How long the new version has to come up before rollback (default: 20)")
|
|
61
|
+
.action(withOutput(runUpdateCommand));
|
|
50
62
|
addJsonOption(daemon
|
|
51
63
|
.command("set-password")
|
|
52
64
|
.description("Prompt for and save a hashed daemon password to config.json"))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { CommandOptions, SingleResult } from "../../output/index.js";
|
|
3
|
+
interface UpdateResult {
|
|
4
|
+
action: "up-to-date" | "available" | "updated" | "rolled-back" | "pinned";
|
|
5
|
+
currentVersion: string;
|
|
6
|
+
targetVersion: string;
|
|
7
|
+
channel: string;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
export type UpdateCommandResult = SingleResult<UpdateResult>;
|
|
11
|
+
export declare function runUpdateCommand(options: CommandOptions, _command: Command): Promise<UpdateCommandResult>;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=update.d.ts.map
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { fetchChannelVersion, isNewer, isPinned, readSlotVersion, readUpdateState, resolvePaseoHome, slotPath, rollback, stageVersion, swapTo, verifySlot, writeUpdateState, } from "@hyperdrive.bot/paseo-server";
|
|
2
|
+
import { resolveCliVersion } from "../../version.js";
|
|
3
|
+
import { resolveLocalDaemonState, startLocalDaemonDetached, stopLocalDaemon, } from "./local-daemon.js";
|
|
4
|
+
const updateResultSchema = {
|
|
5
|
+
idField: "action",
|
|
6
|
+
columns: [
|
|
7
|
+
{
|
|
8
|
+
header: "STATUS",
|
|
9
|
+
field: "action",
|
|
10
|
+
color: (row) => {
|
|
11
|
+
const action = row.action;
|
|
12
|
+
if (action === "updated" || action === "up-to-date")
|
|
13
|
+
return "green";
|
|
14
|
+
if (action === "available")
|
|
15
|
+
return "yellow";
|
|
16
|
+
return "red";
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
{ header: "CURRENT", field: "currentVersion" },
|
|
20
|
+
{ header: "TARGET", field: "targetVersion" },
|
|
21
|
+
{ header: "CHANNEL", field: "channel" },
|
|
22
|
+
{ header: "MESSAGE", field: "message" },
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
function parseChannel(raw) {
|
|
26
|
+
const value = typeof raw === "string" && raw.length > 0 ? raw : "stable";
|
|
27
|
+
if (value === "stable" || value === "beta" || value === "dev") {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
const error = {
|
|
31
|
+
code: "INVALID_CHANNEL",
|
|
32
|
+
message: `Invalid channel: ${value}`,
|
|
33
|
+
details: "Channel must be one of: stable, beta, dev",
|
|
34
|
+
};
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Version the daemon is actually running.
|
|
39
|
+
*
|
|
40
|
+
* Prefers the active slot, because that is what the supervisor will exec on the next
|
|
41
|
+
* spawn. Falls back to the CLI's own version for an npm-global or checkout install,
|
|
42
|
+
* where the two are the same package.
|
|
43
|
+
*/
|
|
44
|
+
function resolveRunningVersion(paseoHome) {
|
|
45
|
+
return readSlotVersion(paseoHome, "current") ?? resolveCliVersion();
|
|
46
|
+
}
|
|
47
|
+
async function waitForDaemonVersion(paseoHome, expected, budgetMs) {
|
|
48
|
+
const deadline = Date.now() + budgetMs;
|
|
49
|
+
let observed = null;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
const state = resolveLocalDaemonState({ home: paseoHome });
|
|
52
|
+
// `state.running` only means the SUPERVISOR is alive, which is true even while
|
|
53
|
+
// it crash-loops a worker that dies on boot, and `current` is whatever we just
|
|
54
|
+
// pointed it at. Neither says the daemon is serving.
|
|
55
|
+
//
|
|
56
|
+
// The real signal is the pid lock's own `listen` field: the entrypoint writes it
|
|
57
|
+
// from `onWorkerReady`, which fires only when the worker emits `paseo:ready`.
|
|
58
|
+
// Read `pidInfo` rather than `state.listen`, because the latter falls back to
|
|
59
|
+
// the configured listen target and so is set before the worker is up.
|
|
60
|
+
if (state.running && state.pidInfo?.listen) {
|
|
61
|
+
observed = readSlotVersion(paseoHome, "current");
|
|
62
|
+
if (observed === expected) {
|
|
63
|
+
return { ok: true, observed };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
67
|
+
}
|
|
68
|
+
return { ok: false, observed };
|
|
69
|
+
}
|
|
70
|
+
function single(data) {
|
|
71
|
+
return { type: "single", data, schema: updateResultSchema };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Version this run is aiming at: either the explicit `--to`, or whatever the
|
|
75
|
+
* channel's dist-tag currently points at.
|
|
76
|
+
*
|
|
77
|
+
* Records the lookup in update state even when nothing is installed, so a later
|
|
78
|
+
* `--check` (or the notify path) can answer without hitting the network.
|
|
79
|
+
*/
|
|
80
|
+
async function resolveTargetVersion(paseoHome, channel, to) {
|
|
81
|
+
if (typeof to === "string" && to.trim().length > 0) {
|
|
82
|
+
return to.trim();
|
|
83
|
+
}
|
|
84
|
+
let lookup;
|
|
85
|
+
try {
|
|
86
|
+
lookup = await fetchChannelVersion(channel);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
+
const error = {
|
|
91
|
+
code: "REGISTRY_UNAVAILABLE",
|
|
92
|
+
message: `Could not reach the npm registry: ${message}`,
|
|
93
|
+
};
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if (!lookup.version) {
|
|
97
|
+
const error = {
|
|
98
|
+
code: "NO_CHANNEL_RELEASE",
|
|
99
|
+
message: `No release published on the "${channel}" channel`,
|
|
100
|
+
};
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
writeUpdateState(paseoHome, { lastCheckedAt: Date.now(), lastSeenVersion: lookup.version });
|
|
104
|
+
return lookup.version;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Download into a slot of its own and prove the tree can run, without touching
|
|
108
|
+
* the slot currently serving.
|
|
109
|
+
*
|
|
110
|
+
* An npm install exiting 0 is not evidence: 0.2.6 through 0.3.2 installed
|
|
111
|
+
* cleanly and still could not boot, because a declared dependency was never
|
|
112
|
+
* published. A tree that fails here is pinned so the next run skips it.
|
|
113
|
+
*/
|
|
114
|
+
async function stageAndVerify(paseoHome, targetVersion, reuseExisting) {
|
|
115
|
+
try {
|
|
116
|
+
await stageVersion(paseoHome, targetVersion, { reuseExisting });
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
120
|
+
const error = {
|
|
121
|
+
code: "STAGE_FAILED",
|
|
122
|
+
message: `Failed to install ${targetVersion}: ${message}`,
|
|
123
|
+
};
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
const verification = await verifySlot(slotPath(paseoHome, targetVersion), {});
|
|
127
|
+
if (verification.ok)
|
|
128
|
+
return;
|
|
129
|
+
writeUpdateState(paseoHome, {
|
|
130
|
+
pinned: targetVersion,
|
|
131
|
+
pinnedReason: `staged tree failed verification: ${verification.problems.join("; ")}`,
|
|
132
|
+
});
|
|
133
|
+
const error = {
|
|
134
|
+
code: "VERIFY_FAILED",
|
|
135
|
+
message: `Staged ${targetVersion} did not verify, so it was not activated`,
|
|
136
|
+
details: verification.problems.join("\n"),
|
|
137
|
+
};
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
async function restartDaemon(home) {
|
|
141
|
+
try {
|
|
142
|
+
await stopLocalDaemon({ home, timeoutMs: 15000, force: false });
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// A slow graceful stop is normal here: the daemon logs "Forcing shutdown -
|
|
146
|
+
// HTTP server didn't close in time" and exits a moment later. Force it.
|
|
147
|
+
await stopLocalDaemon({ home, timeoutMs: 15000, force: true });
|
|
148
|
+
}
|
|
149
|
+
await startLocalDaemonDetached({ home });
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Restart onto the freshly-swapped slot and hold it to a deadline. If it does
|
|
153
|
+
* not report ready in time, roll the symlink back, pin the bad version, and
|
|
154
|
+
* bring the previous one up again.
|
|
155
|
+
*
|
|
156
|
+
* Returns null when the new version came up, or the result to report when it
|
|
157
|
+
* did not.
|
|
158
|
+
*/
|
|
159
|
+
async function restartWithRollback(paseoHome, home, targetVersion, fallbackVersion, channel, readyBudgetMs) {
|
|
160
|
+
await restartDaemon(home);
|
|
161
|
+
const ready = await waitForDaemonVersion(paseoHome, targetVersion, readyBudgetMs);
|
|
162
|
+
if (ready.ok)
|
|
163
|
+
return null;
|
|
164
|
+
const { rolledBackTo } = rollback(paseoHome, targetVersion, `daemon did not report ${targetVersion} within ${readyBudgetMs}ms`);
|
|
165
|
+
try {
|
|
166
|
+
await stopLocalDaemon({ home, timeoutMs: 15000, force: true });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* already down */
|
|
170
|
+
}
|
|
171
|
+
await startLocalDaemonDetached({ home });
|
|
172
|
+
return single({
|
|
173
|
+
action: "rolled-back",
|
|
174
|
+
currentVersion: rolledBackTo ?? fallbackVersion,
|
|
175
|
+
targetVersion,
|
|
176
|
+
channel,
|
|
177
|
+
message: `${targetVersion} did not come up within ${readyBudgetMs}ms. Rolled back to ` +
|
|
178
|
+
`${rolledBackTo ?? "the bundled version"} and pinned ${targetVersion}.`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
export async function runUpdateCommand(options, _command) {
|
|
182
|
+
const home = typeof options.home === "string" ? options.home : undefined;
|
|
183
|
+
const paseoHome = resolvePaseoHome(home ? { PASEO_HOME: home } : {});
|
|
184
|
+
const channel = parseChannel(options.channel);
|
|
185
|
+
const force = options.force === true;
|
|
186
|
+
const readyBudgetMs = typeof options.readyTimeout === "string" && options.readyTimeout.trim().length > 0
|
|
187
|
+
? Math.ceil(Number(options.readyTimeout) * 1000)
|
|
188
|
+
: 20000;
|
|
189
|
+
const currentVersion = resolveRunningVersion(paseoHome);
|
|
190
|
+
const state = readUpdateState(paseoHome);
|
|
191
|
+
const targetVersion = await resolveTargetVersion(paseoHome, channel, options.to);
|
|
192
|
+
const base = { currentVersion, targetVersion, channel };
|
|
193
|
+
// A version that already failed to boot here is not retried unless forced.
|
|
194
|
+
// Checked before the up-to-date short circuit: a pin on the version we are
|
|
195
|
+
// already running is still worth reporting.
|
|
196
|
+
if (!force && isPinned(state, targetVersion)) {
|
|
197
|
+
return single({
|
|
198
|
+
...base,
|
|
199
|
+
action: "pinned",
|
|
200
|
+
message: `${targetVersion} is pinned after a previous failure (${state.pinnedReason ?? "unknown"}). Use --force to retry.`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (!force && !isNewer(targetVersion, currentVersion)) {
|
|
204
|
+
return single({
|
|
205
|
+
...base,
|
|
206
|
+
action: "up-to-date",
|
|
207
|
+
message: `Already running ${currentVersion} (channel ${channel} is at ${targetVersion})`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (options.check === true) {
|
|
211
|
+
return single({
|
|
212
|
+
...base,
|
|
213
|
+
action: "available",
|
|
214
|
+
message: `Update available: ${currentVersion} -> ${targetVersion}. Run without --check to install.`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
await stageAndVerify(paseoHome, targetVersion, options.useStaged === true);
|
|
218
|
+
// Only now does the running version change. The swap is a symlink rename, so
|
|
219
|
+
// the previous slot stays on disk and rollback is another rename.
|
|
220
|
+
swapTo(paseoHome, targetVersion);
|
|
221
|
+
const wasRunning = resolveLocalDaemonState({ home: paseoHome }).running;
|
|
222
|
+
if (wasRunning) {
|
|
223
|
+
const rolledBack = await restartWithRollback(paseoHome, home, targetVersion, currentVersion, channel, readyBudgetMs);
|
|
224
|
+
if (rolledBack)
|
|
225
|
+
return rolledBack;
|
|
226
|
+
}
|
|
227
|
+
writeUpdateState(paseoHome, { lastInstalled: targetVersion, pinned: null, pinnedReason: null });
|
|
228
|
+
return single({
|
|
229
|
+
...base,
|
|
230
|
+
action: "updated",
|
|
231
|
+
currentVersion: targetVersion,
|
|
232
|
+
message: wasRunning
|
|
233
|
+
? `Daemon updated ${currentVersion} -> ${targetVersion} and is serving.`
|
|
234
|
+
: `Installed ${targetVersion}. Start the daemon to run it.`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
//# sourceMappingURL=update.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperdrive.bot/paseo-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
|
5
5
|
"bin": {
|
|
6
6
|
"paseo": "bin/paseo"
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@clack/prompts": "^1.0.0",
|
|
30
|
-
"@hyperdrive.bot/paseo-client": "0.
|
|
31
|
-
"@hyperdrive.bot/paseo-protocol": "0.
|
|
32
|
-
"@hyperdrive.bot/paseo-server": "0.3.
|
|
30
|
+
"@hyperdrive.bot/paseo-client": "0.3.8",
|
|
31
|
+
"@hyperdrive.bot/paseo-protocol": "0.3.8",
|
|
32
|
+
"@hyperdrive.bot/paseo-server": "0.3.8",
|
|
33
33
|
"chalk": "^5.3.0",
|
|
34
34
|
"commander": "^12.0.0",
|
|
35
35
|
"mime-types": "^2.1.35",
|