@cydm/happy-elves 0.1.0-beta.632 → 0.1.0-beta.633
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/README.md +2 -2
- package/apps/cli/dist/commands/app.js +3 -1
- package/apps/cli/dist/commands/lib/bootstrap-config.js +17 -43
- package/apps/cli/dist/commands/lib/bootstrap-daemon.js +4 -2
- package/apps/cli/dist/commands/lib/bootstrap-output.d.ts +1 -0
- package/apps/cli/dist/commands/lib/bootstrap-output.js +1 -1
- package/apps/cli/dist/commands/lib/bootstrap.d.ts +2 -0
- package/apps/cli/dist/commands/lib/bootstrap.js +53 -11
- package/apps/cli/dist/commands/lib/exit.js +7 -0
- package/apps/cli/dist/commands/lib/local-daemon.d.ts +1 -0
- package/apps/cli/dist/commands/lib/local-daemon.js +72 -55
- package/apps/cli/dist/commands/machine.js +3 -1
- package/apps/cli/package.json +1 -1
- package/apps/daemon/dist/direct/server.js +3 -2
- package/apps/daemon/dist/local-control.d.ts +1 -1
- package/apps/daemon/dist/local-control.js +14 -20
- package/apps/daemon/dist/orchestration/dispatch-delivery.js +1 -1
- package/apps/daemon/dist/orchestration/handler.d.ts +2 -1
- package/apps/daemon/dist/orchestration/handler.js +20 -7
- package/apps/daemon/dist/paths.d.ts +1 -0
- package/apps/daemon/dist/paths.js +10 -0
- package/apps/daemon/dist/process.js +54 -20
- package/apps/daemon/dist/relay/send.d.ts +5 -1
- package/apps/daemon/dist/relay/send.js +24 -4
- package/apps/daemon/dist/session/he-official/orchestration-commands.d.ts +1 -1
- package/apps/daemon/dist/session/lifecycle.d.ts +1 -1
- package/apps/daemon/dist/session/prompt-turn-service.js +1 -1
- package/apps/daemon/dist/session/turn-control-service.js +22 -6
- package/apps/daemon/dist/start.js +3 -2
- package/apps/daemon/package.json +1 -1
- package/apps/relay/dist/http-routes.js +20 -12
- package/apps/relay/dist/packet-store.d.ts +5 -0
- package/apps/relay/dist/packet-store.js +15 -0
- package/apps/relay/dist/relay-context.d.ts +9 -0
- package/apps/relay/dist/relay-context.js +13 -1
- package/apps/relay/dist/storage-worker.d.ts +5 -0
- package/apps/relay/dist/storage-worker.js +46 -36
- package/apps/relay/dist/websocket.d.ts +1 -0
- package/apps/relay/dist/websocket.js +43 -29
- package/apps/updater/dist/autostart.js +2 -1
- package/apps/updater/dist/daemon.js +14 -8
- package/apps/updater/dist/run.d.ts +9 -0
- package/apps/updater/dist/run.js +29 -3
- package/apps/updater/dist/runtime-host-cleanup.d.ts +16 -0
- package/apps/updater/dist/runtime-host-cleanup.js +33 -0
- package/apps/updater/dist/supervisor.js +39 -12
- package/package.json +4 -2
- package/packages/claude-provider/dist/claude-provider.d.ts +3 -1
- package/packages/claude-provider/dist/claude-provider.js +136 -43
- package/packages/client/dist/account.d.ts +1 -0
- package/packages/client/dist/account.js +4 -0
- package/packages/client/dist/client.d.ts +6 -0
- package/packages/client/dist/client.js +156 -21
- package/packages/client/dist/errors.d.ts +4 -0
- package/packages/client/dist/errors.js +4 -0
- package/packages/client/dist/product-materializer.js +13 -4
- package/packages/client/dist/transport.js +49 -41
- package/packages/client/dist/types.d.ts +2 -0
- package/packages/client/dist/update.js +10 -5
- package/packages/codex-provider/dist/codex-provider.js +182 -14
- package/packages/he-kernel/dist/index.js +6 -24
- package/packages/headless-runtime/dist/core.d.ts +6 -1
- package/packages/headless-runtime/dist/core.js +129 -69
- package/packages/headless-runtime/dist/trace-log.d.ts +6 -0
- package/packages/headless-runtime/dist/trace-log.js +35 -1
- package/packages/pie-provider/dist/index.js +3 -1
- package/packages/product-protocol/dist/protocol-schemas.d.ts +1 -0
- package/packages/product-protocol/dist/protocol-schemas.js +1 -0
- package/packages/product-protocol/dist/protocol-types.d.ts +2 -0
- package/packages/product-protocol/dist/protocol.d.ts +3 -0
- package/packages/relay-protocol/dist/packet-relay.d.ts +4 -2
- package/packages/relay-protocol/dist/packet-relay.js +2 -1
- package/packages/runtime-host/dist/host.d.ts +7 -0
- package/packages/runtime-host/dist/host.js +73 -0
- package/packages/runtime-host/dist/provider-command.js +4 -3
- package/packages/runtime-spi/dist/provider.d.ts +4 -1
package/README.md
CHANGED
|
@@ -6,8 +6,8 @@ Public beta CLI for Happy Elves.
|
|
|
6
6
|
|
|
7
7
|
Run once in the existing user account; it detects %USERPROFILE%\.happy-elves\hosted, keeps the current daemon and sessions running, installs the independent updater, and registers per-user startup.
|
|
8
8
|
|
|
9
|
-
npm.cmd install --prefix $HOME\.happy-elves\management-entry --ignore-scripts --no-audit --no-fund --package-lock=false @cydm/happy-elves@0.1.0-beta.
|
|
10
|
-
npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.
|
|
9
|
+
npm.cmd install --prefix $HOME\.happy-elves\management-entry --ignore-scripts --no-audit --no-fund --package-lock=false @cydm/happy-elves@0.1.0-beta.633
|
|
10
|
+
npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.633 -- happy-elves-updater install --state-root $HOME\.happy-elves\hosted --autostart
|
|
11
11
|
|
|
12
12
|
The same updater entrypoint works on macOS after selecting a permanent user prefix.
|
|
13
13
|
|
|
@@ -20,7 +20,7 @@ import { handleSidechat } from "./sidechat.js";
|
|
|
20
20
|
import { handleToken } from "./token.js";
|
|
21
21
|
import { handleTurn } from "./turn.js";
|
|
22
22
|
import { handleWorkspace } from "./workspace.js";
|
|
23
|
-
import { commandUsage, commandUsageText, handleCliError, parseCliFlags, skillText, startBootstrap, topLevelDoctor, topLevelStatus, usage, usageText } from "./lib/index.js";
|
|
23
|
+
import { commandUsage, commandUsageText, delegateHostedStart, handleCliError, parseCliFlags, skillText, startBootstrap, topLevelDoctor, topLevelStatus, usage, usageText } from "./lib/index.js";
|
|
24
24
|
export { handleCliError };
|
|
25
25
|
const domainHandlers = [
|
|
26
26
|
handleAccount,
|
|
@@ -68,6 +68,8 @@ export async function main() {
|
|
|
68
68
|
}
|
|
69
69
|
if (domain === "start") {
|
|
70
70
|
const { flags } = parseCliFlags(argv.slice(1));
|
|
71
|
+
if (await delegateHostedStart(argv, flags))
|
|
72
|
+
return;
|
|
71
73
|
await startBootstrap(flags);
|
|
72
74
|
return;
|
|
73
75
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
import { ControllerClient } from "../../../../../packages/client/dist/index.js";
|
|
4
3
|
import { randomId } from "../../../../../packages/foundation/dist/index.js";
|
|
@@ -7,21 +6,6 @@ import { CliError } from "../../errors.js";
|
|
|
7
6
|
import { configDir, configPath, defaultHostedControllerUrl, defaultHostedRelayUrl } from "./paths.js";
|
|
8
7
|
import { normalizeRelayUrl, requireRelayUrl, writeConfig, writeDaemonConfig } from "./config.js";
|
|
9
8
|
import { cliErrorFromFetch, isAuthTokenInvalid, parsePairingClaimResponse, parsePairingStartResponse, readExistingControllerConfig, readExistingDaemonConfig, readRelayJson, throwRelayHttpError } from "./relay-http.js";
|
|
10
|
-
function backupTimestamp() {
|
|
11
|
-
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
12
|
-
}
|
|
13
|
-
async function backupConfigFile(filePath, reason) {
|
|
14
|
-
const backupPath = `${filePath}.bak.${backupTimestamp()}`;
|
|
15
|
-
try {
|
|
16
|
-
await fs.rename(filePath, backupPath);
|
|
17
|
-
return { path: filePath, backupPath, reason };
|
|
18
|
-
}
|
|
19
|
-
catch (error) {
|
|
20
|
-
if (error.code === "ENOENT")
|
|
21
|
-
return undefined;
|
|
22
|
-
throw new CliError(`Could not back up ${filePath}: ${error instanceof Error ? error.message : String(error)}`, "CONFIG_INVALID");
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
9
|
async function createControllerConfig(params) {
|
|
26
10
|
const accountSecret = generateAccountSecret();
|
|
27
11
|
let response;
|
|
@@ -49,33 +33,28 @@ async function createControllerConfig(params) {
|
|
|
49
33
|
}
|
|
50
34
|
export async function ensureControllerConfig(params) {
|
|
51
35
|
let existing;
|
|
52
|
-
let backup;
|
|
53
36
|
try {
|
|
54
37
|
existing = await readExistingControllerConfig();
|
|
55
38
|
}
|
|
56
|
-
catch
|
|
57
|
-
|
|
58
|
-
throw error;
|
|
59
|
-
backup = await backupConfigFile(configPath, "invalid-controller-config");
|
|
39
|
+
catch {
|
|
40
|
+
throw new CliError(`Existing controller configuration at ${configPath} is invalid; refusing to replace its account.`, "CONFIG_INVALID");
|
|
60
41
|
}
|
|
61
42
|
if (existing) {
|
|
62
43
|
if (params.explicitRelay && existing.relayUrl !== params.relayUrl) {
|
|
63
|
-
|
|
44
|
+
throw new CliError("Existing controller uses a different relay; refusing to create a replacement account during start.", "CONTROLLER_RELAY_MISMATCH");
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await new ControllerClient(existing).snapshotSummary();
|
|
48
|
+
return { config: existing, created: false, reconfigured: false };
|
|
64
49
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
catch (error) {
|
|
71
|
-
if (!isAuthTokenInvalid(error))
|
|
72
|
-
throw error;
|
|
73
|
-
backup = await backupConfigFile(configPath, "controller-token-invalid");
|
|
74
|
-
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (!isAuthTokenInvalid(error))
|
|
52
|
+
throw error;
|
|
53
|
+
throw new CliError("Existing controller token was rejected; refusing to create a replacement account during start.", "CONTROLLER_AUTH_REJECTED");
|
|
75
54
|
}
|
|
76
55
|
}
|
|
77
56
|
const created = await createControllerConfig({ relayUrl: params.relayUrl, deviceName: params.deviceName });
|
|
78
|
-
return { config: created.config, created: true, accountId: created.accountId,
|
|
57
|
+
return { config: created.config, created: true, accountId: created.accountId, reconfigured: false };
|
|
79
58
|
}
|
|
80
59
|
async function claimMachinePairing(params) {
|
|
81
60
|
let response;
|
|
@@ -115,24 +94,19 @@ async function createDaemonConfig(config, machineName) {
|
|
|
115
94
|
export async function ensureDaemonPaired(config, machineName) {
|
|
116
95
|
const daemonConfigPath = path.join(configDir, "daemon.json");
|
|
117
96
|
let existing;
|
|
118
|
-
let backup;
|
|
119
97
|
try {
|
|
120
98
|
existing = await readExistingDaemonConfig();
|
|
121
99
|
}
|
|
122
|
-
catch
|
|
123
|
-
|
|
124
|
-
throw error;
|
|
125
|
-
backup = await backupConfigFile(daemonConfigPath, "invalid-daemon-config");
|
|
100
|
+
catch {
|
|
101
|
+
throw new CliError(`Existing daemon configuration at ${daemonConfigPath} is invalid; refusing to replace its machine.`, "CONFIG_INVALID");
|
|
126
102
|
}
|
|
127
103
|
if (existing) {
|
|
128
104
|
if (existing.relayUrl !== config.relayUrl || existing.accountSecret !== config.accountSecret) {
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
else {
|
|
132
|
-
return { config: existing, created: false, reconfigured: false };
|
|
105
|
+
throw new CliError("Existing daemon belongs to a different relay or account; refusing to replace its machine during start.", "DAEMON_ACCOUNT_MISMATCH");
|
|
133
106
|
}
|
|
107
|
+
return { config: existing, created: false, reconfigured: false };
|
|
134
108
|
}
|
|
135
|
-
return { config: await createDaemonConfig(config, machineName), created: true,
|
|
109
|
+
return { config: await createDaemonConfig(config, machineName), created: true, reconfigured: false };
|
|
136
110
|
}
|
|
137
111
|
export function controllerBaseUrl(flags) {
|
|
138
112
|
const value = typeof flags.controller === "string" ? flags.controller : defaultHostedControllerUrl;
|
|
@@ -6,6 +6,8 @@ import { daemonBinaryUpdatedAfterStart, daemonConfigUpdatedAfterStart, localDaem
|
|
|
6
6
|
export async function ensureDaemonRunning(relayUrl, cwd = process.cwd()) {
|
|
7
7
|
const current = await localDaemonStatus();
|
|
8
8
|
if (current.running) {
|
|
9
|
+
if (current.identityVerified !== true)
|
|
10
|
+
throw new CliError("Daemon identity cannot be verified; refusing to reuse or restart it.", "DAEMON_IDENTITY_UNVERIFIED");
|
|
9
11
|
if ((await daemonBinaryUpdatedAfterStart(current)) || (await daemonConfigUpdatedAfterStart(current))) {
|
|
10
12
|
return await restartDaemonForNewConfig(relayUrl, cwd);
|
|
11
13
|
}
|
|
@@ -42,9 +44,9 @@ export async function waitForMachineProjection(client, machineId, timeoutMs = 50
|
|
|
42
44
|
const deadline = Date.now() + timeoutMs;
|
|
43
45
|
let lastSeen;
|
|
44
46
|
while (Date.now() <= deadline) {
|
|
45
|
-
const snapshot = await client.
|
|
47
|
+
const snapshot = await client.snapshotSummary();
|
|
46
48
|
lastSeen = snapshot.machines.find((machine) => machine.id === machineId);
|
|
47
|
-
if (lastSeen?.
|
|
49
|
+
if (lastSeen?.daemonOnline === true)
|
|
48
50
|
return lastSeen;
|
|
49
51
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
50
52
|
}
|
|
@@ -12,7 +12,7 @@ export function writeHumanStartOutput(data) {
|
|
|
12
12
|
}
|
|
13
13
|
console.log("");
|
|
14
14
|
console.log("Phone");
|
|
15
|
-
console.log("Open the same link on your phone.");
|
|
15
|
+
console.log(data.inviteCreated ? "Open the same link on your phone within 10 minutes." : "To add a new phone, run happy-elves remote invite-controller.");
|
|
16
16
|
console.log("");
|
|
17
17
|
console.log("Machine");
|
|
18
18
|
console.log(`${data.machine.online ? "online" : "starting"}, ${data.machine.name}`);
|
|
@@ -1 +1,3 @@
|
|
|
1
1
|
export declare function startBootstrap(flags: Record<string, string | boolean>): Promise<void>;
|
|
2
|
+
/** The default hosted install is the one managed daemon entrypoint after an updater install. */
|
|
3
|
+
export declare function delegateHostedStart(argv: string[], flags: Record<string, string | boolean>): Promise<boolean>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
3
5
|
import { ControllerClient } from "../../../../../packages/client/dist/index.js";
|
|
4
6
|
import { CliError } from "../../errors.js";
|
|
5
7
|
import { configDir, configPath } from "./paths.js";
|
|
@@ -31,20 +33,21 @@ export async function startBootstrap(flags) {
|
|
|
31
33
|
: await ensureDaemonRunning(controllerState.config.relayUrl, cwd);
|
|
32
34
|
const client = new ControllerClient(controllerState.config);
|
|
33
35
|
const machine = await waitForMachineProjection(client, daemonState.config.machineId);
|
|
34
|
-
if (
|
|
36
|
+
if (machine?.daemonOnline !== true) {
|
|
35
37
|
throw new CliError("Daemon started, but this machine did not appear online in the relay snapshot. Run happy-elves doctor and happy-elves daemon logs.", "DAEMON_ONLINE_TIMEOUT");
|
|
36
38
|
}
|
|
37
|
-
const invite = await client.createControllerInvite({
|
|
39
|
+
const invite = controllerState.created ? await client.createControllerInvite({
|
|
38
40
|
deviceName: "Web controller",
|
|
39
41
|
ttlMs: 10 * 60_000,
|
|
40
42
|
joinUrlBase: controllerBaseUrl(flags),
|
|
41
43
|
relayUrl: controllerState.config.relayUrl,
|
|
42
44
|
}).catch((error) => {
|
|
43
45
|
throw cliErrorFromFetch(error, "INVITE_CREATE_FAILED", "Could not create controller invite");
|
|
44
|
-
});
|
|
46
|
+
}) : undefined;
|
|
47
|
+
const controllerUrl = invite?.joinUrl ?? controllerBaseUrl(flags);
|
|
45
48
|
const data = {
|
|
46
49
|
relayUrl: controllerState.config.relayUrl,
|
|
47
|
-
controllerUrl
|
|
50
|
+
controllerUrl,
|
|
48
51
|
workspace: cwd,
|
|
49
52
|
config: {
|
|
50
53
|
controllerPath: configPath,
|
|
@@ -59,7 +62,7 @@ export async function startBootstrap(flags) {
|
|
|
59
62
|
machine: {
|
|
60
63
|
id: daemonState.config.machineId,
|
|
61
64
|
name: machine.name ?? daemonState.config.machineName,
|
|
62
|
-
online: machine.
|
|
65
|
+
online: machine.daemonOnline,
|
|
63
66
|
lastSeen: machine?.lastSeen,
|
|
64
67
|
},
|
|
65
68
|
daemon: {
|
|
@@ -70,19 +73,16 @@ export async function startBootstrap(flags) {
|
|
|
70
73
|
alreadyRunning: daemonBefore.running,
|
|
71
74
|
restarted: daemonBefore.running && daemonState.reconfigured,
|
|
72
75
|
},
|
|
73
|
-
invite: {
|
|
74
|
-
id: invite.inviteCode,
|
|
75
|
-
expiresAt: invite.expiresAt,
|
|
76
|
-
},
|
|
76
|
+
...(invite ? { invite: { id: invite.inviteCode, expiresAt: invite.expiresAt } } : {}),
|
|
77
77
|
};
|
|
78
78
|
if (flags.json === true) {
|
|
79
79
|
ok("start", data);
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
82
|
-
const openResult = flags["no-open"] === true ? { opened: false } : openUrl(
|
|
82
|
+
const openResult = flags["no-open"] === true ? { opened: false } : openUrl(controllerUrl);
|
|
83
83
|
writeHumanStartOutput({
|
|
84
84
|
workspace: cwd,
|
|
85
|
-
controllerUrl
|
|
85
|
+
controllerUrl,
|
|
86
86
|
relayUrl: controllerState.config.relayUrl,
|
|
87
87
|
opened: openResult.opened,
|
|
88
88
|
openError: openResult.error,
|
|
@@ -91,6 +91,48 @@ export async function startBootstrap(flags) {
|
|
|
91
91
|
online: data.machine.online,
|
|
92
92
|
},
|
|
93
93
|
daemon: data.daemon,
|
|
94
|
+
inviteCreated: Boolean(invite),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** The default hosted install is the one managed daemon entrypoint after an updater install. */
|
|
98
|
+
export async function delegateHostedStart(argv, flags) {
|
|
99
|
+
if (process.env.HAPPY_ELVES_HOME || process.env.HAPPY_ELVES_START_DELEGATED === "1" || flags.relay || flags.controller)
|
|
100
|
+
return false;
|
|
101
|
+
const stateRoot = path.join(os.homedir(), ".happy-elves", "hosted");
|
|
102
|
+
const currentPath = path.join(stateRoot, "updater", "current.json");
|
|
103
|
+
const currentStat = await fs.stat(currentPath).catch((error) => {
|
|
104
|
+
if (error.code === "ENOENT")
|
|
105
|
+
return undefined;
|
|
106
|
+
throw error;
|
|
107
|
+
});
|
|
108
|
+
if (!currentStat)
|
|
109
|
+
return false;
|
|
110
|
+
if (!currentStat.isFile() || currentStat.size > 64 * 1024)
|
|
111
|
+
throw new CliError("Hosted updater current slot is invalid.", "HOSTED_INSTALL_INVALID");
|
|
112
|
+
const current = JSON.parse(await fs.readFile(currentPath, "utf8"));
|
|
113
|
+
if (typeof current.version !== "string" || !/^[A-Za-z0-9._-]+$/u.test(current.version) ||
|
|
114
|
+
typeof current.slotPath !== "string" ||
|
|
115
|
+
path.resolve(current.slotPath) !== path.join(stateRoot, "updater", "versions", current.version)) {
|
|
116
|
+
throw new CliError("Hosted updater current slot does not match its state root.", "HOSTED_INSTALL_INVALID");
|
|
117
|
+
}
|
|
118
|
+
const launcher = path.join(stateRoot, "updater", "bin", process.platform === "win32" ? "happy-elves.cmd" : "happy-elves");
|
|
119
|
+
const installedCli = path.join(current.slotPath, "node_modules", "@cydm", "happy-elves", "apps", "cli", "dist", "index.js");
|
|
120
|
+
await Promise.all([fs.access(launcher), fs.access(installedCli)]).catch(() => {
|
|
121
|
+
throw new CliError("Hosted launcher or active package is missing.", "HOSTED_INSTALL_INVALID");
|
|
122
|
+
});
|
|
123
|
+
const result = await new Promise((resolve, reject) => {
|
|
124
|
+
const child = spawn(launcher, argv, {
|
|
125
|
+
stdio: "inherit",
|
|
126
|
+
env: { ...process.env, HAPPY_ELVES_START_DELEGATED: "1" },
|
|
127
|
+
windowsHide: true,
|
|
128
|
+
...(process.platform === "win32" ? { shell: true } : {}),
|
|
129
|
+
});
|
|
130
|
+
child.once("error", reject);
|
|
131
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
94
132
|
});
|
|
133
|
+
if (result.signal)
|
|
134
|
+
process.kill(process.pid, result.signal);
|
|
135
|
+
process.exitCode = result.code ?? 1;
|
|
136
|
+
return true;
|
|
95
137
|
}
|
|
96
138
|
//# sourceMappingURL=bootstrap.js.map
|
|
@@ -93,6 +93,11 @@ export function handleCliError(error) {
|
|
|
93
93
|
console.error(" happy-elves machine exec <machineId> --shell <command> --wait-timeout-ms <ms>");
|
|
94
94
|
process.exit(1);
|
|
95
95
|
}
|
|
96
|
+
if (enriched.operationId && enriched.machineId && ["UPDATER_UNAVAILABLE", "RELAY_WEBSOCKET_ERROR", "UPDATE_PUBLISH_FAILED"].includes(enriched.code ?? "") && !wantsJsonOutput()) {
|
|
97
|
+
console.error(`Update outcome is unknown: ${enriched.operationId}`);
|
|
98
|
+
printRunCommands([`happy-elves machine update status ${enriched.machineId} --operation ${enriched.operationId}`]);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
96
101
|
if (enriched.code === "TURN_WAIT_TIMEOUT" && enriched.turnId && !wantsJsonOutput()) {
|
|
97
102
|
console.error(message);
|
|
98
103
|
if (enriched.sessionId) {
|
|
@@ -121,6 +126,8 @@ export function handleCliError(error) {
|
|
|
121
126
|
}
|
|
122
127
|
writeError(enriched.code ?? "COMMAND_FAILED", message, {
|
|
123
128
|
requestId: enriched.requestId,
|
|
129
|
+
operationId: enriched.operationId,
|
|
130
|
+
machineId: enriched.machineId,
|
|
124
131
|
sessionId: enriched.sessionId,
|
|
125
132
|
turnId: enriched.turnId,
|
|
126
133
|
activeTurnId: enriched.activeTurnId,
|
|
@@ -2,6 +2,7 @@ export declare function localDaemonStatus(): Promise<{
|
|
|
2
2
|
pidPath: string;
|
|
3
3
|
pid?: number;
|
|
4
4
|
running: boolean;
|
|
5
|
+
identityVerified?: boolean;
|
|
5
6
|
}>;
|
|
6
7
|
export declare function daemonBinaryUpdatedAfterStart(status: Awaited<ReturnType<typeof localDaemonStatus>>): Promise<boolean>;
|
|
7
8
|
export declare function daemonConfigUpdatedAfterStart(status: Awaited<ReturnType<typeof localDaemonStatus>>): Promise<boolean>;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import { appendFileSync, closeSync, mkdirSync, openSync } from "node:fs";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import os from "node:os";
|
|
4
7
|
import path from "node:path";
|
|
5
8
|
import { configDir, daemonPidPath } from "./paths.js";
|
|
6
9
|
function daemonCliPath() {
|
|
7
10
|
return path.resolve(import.meta.dirname, "../../../../daemon/dist/cli.js");
|
|
8
11
|
}
|
|
9
|
-
function daemonSourceCliPath() {
|
|
10
|
-
return path.resolve(import.meta.dirname, "../../../../daemon/src/cli.ts");
|
|
11
|
-
}
|
|
12
12
|
function isPidAlive(pid) {
|
|
13
13
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
14
14
|
return false;
|
|
@@ -20,58 +20,59 @@ function isPidAlive(pid) {
|
|
|
20
20
|
return false;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
function
|
|
24
|
-
if (process.platform === "win32")
|
|
25
|
-
return readWindowsProcessCommand(pid);
|
|
23
|
+
async function readDaemonIdentity() {
|
|
26
24
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
function readWindowsProcessCommand(pid) {
|
|
34
|
-
try {
|
|
35
|
-
const script = `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { [Console]::Out.Write($p.CommandLine) }`;
|
|
36
|
-
return execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
37
|
-
encoding: "utf8",
|
|
38
|
-
windowsHide: true,
|
|
39
|
-
}).trim() || undefined;
|
|
25
|
+
const value = JSON.parse(await fs.readFile(path.join(configDir, "daemon.identity.json"), "utf8"));
|
|
26
|
+
if (!Number.isSafeInteger(value.pid) || !value.pid || value.pid <= 0 || typeof value.stateRoot !== "string" || typeof value.machineId !== "string" || typeof value.nonce !== "string" || typeof value.startedAt !== "string")
|
|
27
|
+
return undefined;
|
|
28
|
+
return value;
|
|
40
29
|
}
|
|
41
30
|
catch {
|
|
42
31
|
return undefined;
|
|
43
32
|
}
|
|
44
33
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const startedAt = Date.parse(text);
|
|
54
|
-
return Number.isFinite(startedAt) ? startedAt : undefined;
|
|
55
|
-
}
|
|
56
|
-
const text = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8" }).trim();
|
|
57
|
-
const startedAt = Date.parse(text);
|
|
58
|
-
return Number.isFinite(startedAt) ? startedAt : undefined;
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
34
|
+
function controlPath(machineId) {
|
|
35
|
+
if (process.platform === "win32")
|
|
36
|
+
return `\\\\.\\pipe\\happy-elves-daemon-${machineId}`;
|
|
37
|
+
const preferred = path.join(configDir, "daemon-control.sock");
|
|
38
|
+
if (preferred.length < 96)
|
|
39
|
+
return preferred;
|
|
40
|
+
const digest = crypto.createHash("sha256").update(path.resolve(configDir)).digest("hex").slice(0, 24);
|
|
41
|
+
return path.join(os.tmpdir(), `happy-elves-daemon-${digest}.sock`);
|
|
63
42
|
}
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
43
|
+
async function controlRequest(machineId, request) {
|
|
44
|
+
return await new Promise((resolve, reject) => {
|
|
45
|
+
const socket = net.createConnection(controlPath(machineId));
|
|
46
|
+
let buffer = "";
|
|
47
|
+
let settled = false;
|
|
48
|
+
const finish = (error, value) => {
|
|
49
|
+
if (settled)
|
|
50
|
+
return;
|
|
51
|
+
settled = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
socket.destroy();
|
|
54
|
+
if (error)
|
|
55
|
+
reject(error);
|
|
56
|
+
else
|
|
57
|
+
resolve(value ?? {});
|
|
58
|
+
};
|
|
59
|
+
const timer = setTimeout(() => finish(new Error("Timed out waiting for daemon control")), 2_000);
|
|
60
|
+
socket.setEncoding("utf8");
|
|
61
|
+
socket.on("data", (chunk) => {
|
|
62
|
+
buffer += chunk;
|
|
63
|
+
const index = buffer.indexOf("\n");
|
|
64
|
+
if (index < 0)
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
finish(undefined, JSON.parse(buffer.slice(0, index)));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
socket.once("error", (error) => finish(error));
|
|
74
|
+
socket.once("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
|
75
|
+
});
|
|
75
76
|
}
|
|
76
77
|
async function readDaemonPid() {
|
|
77
78
|
try {
|
|
@@ -87,12 +88,20 @@ async function readDaemonPid() {
|
|
|
87
88
|
}
|
|
88
89
|
export async function localDaemonStatus() {
|
|
89
90
|
const pid = await readDaemonPid();
|
|
90
|
-
|
|
91
|
+
if (!pid || !isPidAlive(pid))
|
|
92
|
+
return { pidPath: daemonPidPath, pid, running: false };
|
|
93
|
+
const identity = await readDaemonIdentity();
|
|
94
|
+
const handshake = identity?.pid === pid && path.resolve(identity.stateRoot) === path.resolve(configDir)
|
|
95
|
+
? await controlRequest(identity.machineId, { type: "identity" }).catch(() => undefined)
|
|
96
|
+
: undefined;
|
|
97
|
+
const verified = handshake?.ok === true && handshake.pid === pid && handshake.nonce === identity?.nonce && handshake.machineId === identity?.machineId && handshake.stateRoot === path.resolve(configDir);
|
|
98
|
+
return { pidPath: daemonPidPath, pid, running: true, identityVerified: verified };
|
|
91
99
|
}
|
|
92
100
|
export async function daemonBinaryUpdatedAfterStart(status) {
|
|
93
101
|
if (!status.running || !status.pid)
|
|
94
102
|
return false;
|
|
95
|
-
const
|
|
103
|
+
const identity = await readDaemonIdentity();
|
|
104
|
+
const startedAt = status.identityVerified && identity?.pid === status.pid ? Date.parse(identity.startedAt) : undefined;
|
|
96
105
|
if (!startedAt)
|
|
97
106
|
return false;
|
|
98
107
|
try {
|
|
@@ -106,7 +115,8 @@ export async function daemonBinaryUpdatedAfterStart(status) {
|
|
|
106
115
|
export async function daemonConfigUpdatedAfterStart(status) {
|
|
107
116
|
if (!status.running || !status.pid)
|
|
108
117
|
return false;
|
|
109
|
-
const
|
|
118
|
+
const identity = await readDaemonIdentity();
|
|
119
|
+
const startedAt = status.identityVerified && identity?.pid === status.pid ? Date.parse(identity.startedAt) : undefined;
|
|
110
120
|
if (!startedAt)
|
|
111
121
|
return false;
|
|
112
122
|
try {
|
|
@@ -143,13 +153,20 @@ export function spawnDaemonStart(args, cwd = process.cwd()) {
|
|
|
143
153
|
closeSync(logFd);
|
|
144
154
|
}
|
|
145
155
|
}
|
|
146
|
-
export async function stopLocalDaemon(timeoutMs =
|
|
156
|
+
export async function stopLocalDaemon(timeoutMs = 15_000) {
|
|
147
157
|
const status = await localDaemonStatus();
|
|
148
158
|
if (!status.pid || !status.running) {
|
|
149
159
|
await fs.rm(daemonPidPath, { force: true });
|
|
150
160
|
return { ...status, stopped: false, running: false };
|
|
151
161
|
}
|
|
152
|
-
|
|
162
|
+
if (status.identityVerified !== true)
|
|
163
|
+
throw Object.assign(new Error("Daemon identity cannot be verified; refusing to stop it"), { code: "DAEMON_IDENTITY_UNVERIFIED" });
|
|
164
|
+
const identity = await readDaemonIdentity();
|
|
165
|
+
if (!identity || identity.pid !== status.pid)
|
|
166
|
+
throw Object.assign(new Error("Daemon identity changed before shutdown"), { code: "DAEMON_IDENTITY_UNVERIFIED" });
|
|
167
|
+
const shutdown = await controlRequest(identity.machineId, { type: "shutdown", operationId: crypto.randomUUID(), expectedPid: identity.pid, expectedNonce: identity.nonce });
|
|
168
|
+
if (shutdown.ok !== true)
|
|
169
|
+
throw Object.assign(new Error("Daemon refused shutdown"), { code: String(shutdown.code ?? "DAEMON_SHUTDOWN_FAILED") });
|
|
153
170
|
const deadline = Date.now() + timeoutMs;
|
|
154
171
|
while (Date.now() < deadline) {
|
|
155
172
|
if (!isPidAlive(status.pid)) {
|
|
@@ -158,6 +175,6 @@ export async function stopLocalDaemon(timeoutMs = 5000) {
|
|
|
158
175
|
}
|
|
159
176
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
160
177
|
}
|
|
161
|
-
|
|
178
|
+
throw Object.assign(new Error(`Daemon did not stop within ${timeoutMs}ms`), { code: "DAEMON_STOP_TIMEOUT" });
|
|
162
179
|
}
|
|
163
180
|
//# sourceMappingURL=local-daemon.js.map
|
|
@@ -179,6 +179,8 @@ export async function handleMachine({ domain, action, positional, flags }) {
|
|
|
179
179
|
if (updateAction === "status") {
|
|
180
180
|
const operationId = typeof flags.operation === "string" ? flags.operation : undefined;
|
|
181
181
|
const result = await client.machineUpdateStatus(machineId, operationId);
|
|
182
|
+
if (result.errorCode)
|
|
183
|
+
process.exitCode = 1;
|
|
182
184
|
if (!wantsJson(flags)) {
|
|
183
185
|
console.log(`${machineId}: ${result.phase}${result.operationId ? ` ${result.operationId}` : ""}`);
|
|
184
186
|
if (result.currentVersion || result.daemonVersion)
|
|
@@ -187,7 +189,7 @@ export async function handleMachine({ domain, action, positional, flags }) {
|
|
|
187
189
|
console.log(` ${result.errorCode ?? "error"}: ${result.error}`);
|
|
188
190
|
return true;
|
|
189
191
|
}
|
|
190
|
-
ok("machine.update.status", { machineId, result }, { machineId, ...(result.operationId ? { operationId: result.operationId } : {}) });
|
|
192
|
+
ok("machine.update.status", { machineId, result }, { machineId, ...(result.operationId ? { operationId: result.operationId } : {}) }, !result.errorCode);
|
|
191
193
|
return true;
|
|
192
194
|
}
|
|
193
195
|
if (updateAction === "apply") {
|
package/apps/cli/package.json
CHANGED
|
@@ -632,7 +632,7 @@ class DirectState {
|
|
|
632
632
|
}
|
|
633
633
|
const sessionRoutes = [...sessions.keys()].map((sessionId) => ({ sessionId, nodeId: this.config.machineId }));
|
|
634
634
|
const retainedPackets = [manifestPacket];
|
|
635
|
-
if (snapshotMode
|
|
635
|
+
if (snapshotMode !== "routing")
|
|
636
636
|
for (const session of sessions.values()) {
|
|
637
637
|
retainedPackets.push(await this.retainedPacket(`node:${this.config.machineId}:session:${session.sessionId}`, {
|
|
638
638
|
type: "machine:sessionReady",
|
|
@@ -647,7 +647,7 @@ class DirectState {
|
|
|
647
647
|
cursor: this.packetSeq,
|
|
648
648
|
presence: [this.presence()],
|
|
649
649
|
retainedPackets,
|
|
650
|
-
|
|
650
|
+
sessionRoutes,
|
|
651
651
|
};
|
|
652
652
|
}
|
|
653
653
|
presence() {
|
|
@@ -655,6 +655,7 @@ class DirectState {
|
|
|
655
655
|
nodeId: this.config.machineId,
|
|
656
656
|
nodeKind: "machine",
|
|
657
657
|
online: true,
|
|
658
|
+
daemonOnline: true,
|
|
658
659
|
lastSeen: Date.now(),
|
|
659
660
|
};
|
|
660
661
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export type LocalControlServer = {
|
|
2
2
|
close: () => Promise<void>;
|
|
3
3
|
};
|
|
4
|
-
export declare function startLocalControl(machineId: string): Promise<LocalControlServer>;
|
|
4
|
+
export declare function startLocalControl(machineId: string, requestShutdown: () => void): Promise<LocalControlServer>;
|
|
@@ -1,28 +1,14 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
1
|
import fs from "node:fs/promises";
|
|
3
2
|
import net from "node:net";
|
|
4
|
-
import os from "node:os";
|
|
5
3
|
import path from "node:path";
|
|
6
|
-
import { configDir } from "./paths.js";
|
|
4
|
+
import { configDir, daemonControlPath } from "./paths.js";
|
|
7
5
|
import { readDaemonIdentity } from "./process.js";
|
|
8
6
|
import { beginUpdateBarrier, clearUpdateBarrier, markUpdateBarrierPending } from "./update-barrier.js";
|
|
9
7
|
import { pendingOptionsForSession } from "./state.js";
|
|
10
8
|
import { sessionTurns } from "./state.js";
|
|
11
9
|
import { sessions } from "./state.js";
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
? `\\\\.\\pipe\\happy-elves-daemon-${machineId}`
|
|
15
|
-
: unixControlPath(configDir);
|
|
16
|
-
}
|
|
17
|
-
function unixControlPath(root) {
|
|
18
|
-
const preferred = path.join(root, "daemon-control.sock");
|
|
19
|
-
if (preferred.length < 96)
|
|
20
|
-
return preferred;
|
|
21
|
-
const digest = crypto.createHash("sha256").update(path.resolve(root)).digest("hex").slice(0, 24);
|
|
22
|
-
return path.join(os.tmpdir(), `happy-elves-daemon-${digest}.sock`);
|
|
23
|
-
}
|
|
24
|
-
export async function startLocalControl(machineId) {
|
|
25
|
-
const socketPath = localControlPath(machineId);
|
|
10
|
+
export async function startLocalControl(machineId, requestShutdown) {
|
|
11
|
+
const socketPath = daemonControlPath(machineId);
|
|
26
12
|
if (process.platform !== "win32")
|
|
27
13
|
await fs.rm(socketPath, { force: true }).catch(() => undefined);
|
|
28
14
|
const server = net.createServer((socket) => {
|
|
@@ -35,7 +21,7 @@ export async function startLocalControl(machineId) {
|
|
|
35
21
|
const line = buffer.slice(0, index);
|
|
36
22
|
buffer = buffer.slice(index + 1);
|
|
37
23
|
index = buffer.indexOf("\n");
|
|
38
|
-
void handleControlRequest(line, socket);
|
|
24
|
+
void handleControlRequest(line, socket, requestShutdown);
|
|
39
25
|
}
|
|
40
26
|
});
|
|
41
27
|
});
|
|
@@ -53,7 +39,7 @@ export async function startLocalControl(machineId) {
|
|
|
53
39
|
},
|
|
54
40
|
};
|
|
55
41
|
}
|
|
56
|
-
async function handleControlRequest(raw, socket) {
|
|
42
|
+
async function handleControlRequest(raw, socket, requestShutdown) {
|
|
57
43
|
try {
|
|
58
44
|
const request = JSON.parse(raw);
|
|
59
45
|
if (request.type === "identity") {
|
|
@@ -94,8 +80,16 @@ async function handleControlRequest(raw, socket) {
|
|
|
94
80
|
return;
|
|
95
81
|
}
|
|
96
82
|
if (request.type === "shutdown") {
|
|
83
|
+
if (request.expectedPid !== undefined || request.expectedNonce !== undefined) {
|
|
84
|
+
const identity = await readDaemonIdentity();
|
|
85
|
+
if (!identity || identity.pid !== request.expectedPid || identity.nonce !== request.expectedNonce || identity.stateRoot !== path.resolve(configDir)) {
|
|
86
|
+
write(socket, { ok: false, code: "DAEMON_IDENTITY_UNVERIFIED" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
97
90
|
write(socket, { ok: true });
|
|
98
|
-
|
|
91
|
+
socket.once("finish", requestShutdown);
|
|
92
|
+
socket.end();
|
|
99
93
|
return;
|
|
100
94
|
}
|
|
101
95
|
write(socket, { ok: false, code: "INVALID_CONTROL_COMMAND" });
|