@indigoai-us/hq-cli 5.162.0 → 5.163.0
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 +13 -0
- package/dist/command-catalog.generated.d.ts +188 -0
- package/dist/command-catalog.generated.js +235 -0
- package/dist/command-registration-plan.d.ts +7 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +6 -1
- package/dist/commands/agent-enroll.js +30 -3
- package/dist/commands/agents.js +15 -22
- package/dist/commands/bot.js +56 -6
- package/dist/commands/daemon.d.ts +53 -0
- package/dist/commands/daemon.js +554 -0
- package/dist/commands/lanes.js +7 -0
- package/dist/commands/meetings.js +18 -16
- package/dist/commands/mesh.js +9 -0
- package/dist/lib/daemon/catalog.d.ts +85 -0
- package/dist/lib/daemon/catalog.js +274 -0
- package/dist/lib/daemon/config.d.ts +26 -0
- package/dist/lib/daemon/config.js +85 -0
- package/dist/lib/daemon/handoff.d.ts +53 -0
- package/dist/lib/daemon/handoff.js +136 -0
- package/dist/lib/daemon/install.d.ts +76 -0
- package/dist/lib/daemon/install.js +299 -0
- package/dist/lib/daemon/legacy.d.ts +35 -0
- package/dist/lib/daemon/legacy.js +120 -0
- package/dist/lib/daemon/lock.d.ts +47 -0
- package/dist/lib/daemon/lock.js +138 -0
- package/dist/lib/daemon/machine-type.d.ts +48 -0
- package/dist/lib/daemon/machine-type.js +139 -0
- package/dist/lib/daemon/migrate.d.ts +61 -0
- package/dist/lib/daemon/migrate.js +238 -0
- package/dist/lib/daemon/paths.d.ts +22 -0
- package/dist/lib/daemon/paths.js +42 -0
- package/dist/lib/daemon/remote-control.d.ts +35 -0
- package/dist/lib/daemon/remote-control.js +47 -0
- package/dist/lib/daemon/requests.d.ts +24 -0
- package/dist/lib/daemon/requests.js +63 -0
- package/dist/lib/daemon/run.d.ts +41 -0
- package/dist/lib/daemon/run.js +319 -0
- package/dist/lib/daemon/supervisor.d.ts +87 -0
- package/dist/lib/daemon/supervisor.js +523 -0
- package/dist/lib/daemon/updater.d.ts +39 -0
- package/dist/lib/daemon/updater.js +103 -0
- package/dist/lib/flag-registry.d.ts +7 -1
- package/dist/lib/flag-registry.js +8 -0
- package/dist/lib/mesh/live/backoff.d.ts +2 -2
- package/dist/lib/mesh/live/backoff.js +16 -3
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -2
- package/dist/lib/mesh/live/daemon/run.js +5 -2
- package/dist/lib/mesh/live/flush.d.ts +3 -2
- package/dist/lib/mesh/live/flush.js +34 -7
- package/dist/lib/mesh/live/session-events-client.d.ts +2 -0
- package/dist/lib/mesh/live/session-events-client.js +25 -0
- package/dist/lib/service-manager/systemd.d.ts +2 -2
- package/dist/lib/service-manager/systemd.js +1 -1
- package/dist/lib/service-manager/types.d.ts +2 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +53 -13
- package/dist/utils/cognito-session.d.ts +4 -0
- package/dist/utils/cognito-session.js +11 -10
- package/dist/utils/plan-gate-error.d.ts +27 -13
- package/dist/utils/plan-gate-error.js +133 -58
- package/dist/utils/self-update.d.ts +9 -0
- package/dist/utils/self-update.js +15 -2
- package/package.json +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lets commands that manage one service on their own (`hq bot start`, and the
|
|
3
|
+
* older per-service installers such as `hq mesh daemon install`) hand the job
|
|
4
|
+
* to the daemon when the daemon runs that service, so a second copy is never
|
|
5
|
+
* started or installed beside it.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { DEFAULT_SERVICES, enabledServices, isServiceName } from "./catalog.js";
|
|
12
|
+
import { readDaemonConfig, writeDaemonConfig } from "./config.js";
|
|
13
|
+
import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK, SYSTEM_UNIT_DIR } from "./install.js";
|
|
14
|
+
import { isDaemonProcess, runningDaemonPid } from "./lock.js";
|
|
15
|
+
import { detectMachineType } from "./machine-type.js";
|
|
16
|
+
import { daemonPaths } from "./paths.js";
|
|
17
|
+
import { writeControlRequest } from "./requests.js";
|
|
18
|
+
/** Statuses where the daemon itself owns the unit's process. */
|
|
19
|
+
const OWNED = new Set(["running", "backoff", "exited", "idle", "stopped"]);
|
|
20
|
+
/** The live daemon's state, or null when no daemon is running. */
|
|
21
|
+
function liveState(paths, alive) {
|
|
22
|
+
if (runningDaemonPid(paths.lockPath, alive) === null)
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(paths.statePath, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err.code === "ENOENT")
|
|
29
|
+
return null;
|
|
30
|
+
throw new Error(`could not read hq daemon state at ${paths.statePath}: ${err.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** A live daemon runs `unitId` (or held it after it was stopped from outside). */
|
|
34
|
+
export function daemonSupervises(paths, unitId, alive = (pid) => isDaemonProcess(pid)) {
|
|
35
|
+
const unit = liveState(paths, alive)?.units?.find((u) => u.id === unitId);
|
|
36
|
+
return unit !== undefined && OWNED.has(unit.status);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A live daemon runs the service `unitId` belongs to (`bots` for
|
|
40
|
+
* `bots:<name>`), so it will run this instance once its plan includes it. For
|
|
41
|
+
* a bot created since the daemon last read its plan; the caller must first
|
|
42
|
+
* rule out an older unit that runs the instance itself.
|
|
43
|
+
*/
|
|
44
|
+
export function daemonWillSupervise(paths, unitId, alive = (pid) => isDaemonProcess(pid)) {
|
|
45
|
+
const service = unitId.split(":")[0];
|
|
46
|
+
if (liveState(paths, alive)?.units?.some((u) => u.service === service))
|
|
47
|
+
return true;
|
|
48
|
+
// The first bot on a fresh daemon: no bots unit is planned yet, but the saved config turns the service on.
|
|
49
|
+
return isServiceName(service) && daemonRunsService(paths, service, alive);
|
|
50
|
+
}
|
|
51
|
+
/** The live daemon's child for `unitId` is `pid` (a bot started before the daemon is not). */
|
|
52
|
+
export function daemonOwnsProcess(paths, unitId, pid, alive = (p) => isDaemonProcess(p)) {
|
|
53
|
+
return liveState(paths, alive)?.units?.find((u) => u.id === unitId)?.pid === pid;
|
|
54
|
+
}
|
|
55
|
+
/** A live daemon has `service` enabled for its saved machine type, whether or not it has planned a unit for it yet. */
|
|
56
|
+
export function daemonRunsService(paths, service, alive = (pid) => isDaemonProcess(pid)) {
|
|
57
|
+
if (runningDaemonPid(paths.lockPath, alive) === null)
|
|
58
|
+
return false;
|
|
59
|
+
const config = readDaemonConfig(paths.configPath);
|
|
60
|
+
return config.machineType !== undefined && enabledServices(config.machineType, config).includes(service);
|
|
61
|
+
}
|
|
62
|
+
/** Ask the running daemon to (re)start a unit it supervises. */
|
|
63
|
+
export function askDaemonToStart(paths, unitId) {
|
|
64
|
+
writeControlRequest(paths.requestsDir, { action: "restart", unit: unitId });
|
|
65
|
+
}
|
|
66
|
+
/** Ask the running daemon to stop a unit and keep it down until it is started again. */
|
|
67
|
+
export function askDaemonToStop(paths, unitId) {
|
|
68
|
+
writeControlRequest(paths.requestsDir, { action: "stop", unit: unitId });
|
|
69
|
+
}
|
|
70
|
+
/** The `hq daemon` OS unit is installed on this machine (for this user, or as the box's system unit). */
|
|
71
|
+
export function daemonUnitInstalled(host) {
|
|
72
|
+
switch (host.platform) {
|
|
73
|
+
case "darwin":
|
|
74
|
+
return fs.existsSync(path.join(host.home, "Library", "LaunchAgents", `${DAEMON_LAUNCHD_LABEL}.plist`));
|
|
75
|
+
case "win32":
|
|
76
|
+
return host.windowsTaskExists(DAEMON_WINDOWS_TASK);
|
|
77
|
+
default:
|
|
78
|
+
return (fs.existsSync(path.join(host.home, ".config", "systemd", "user", DAEMON_SYSTEMD_UNIT)) ||
|
|
79
|
+
fs.existsSync(path.join(host.systemUnitDir ?? SYSTEM_UNIT_DIR, DAEMON_SYSTEMD_UNIT)));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* For an older per-service install command: when hq daemon is installed here,
|
|
84
|
+
* make sure it runs `services` and return what to tell the user instead of
|
|
85
|
+
* installing a separate unit. Returns null when hq daemon is not installed, so
|
|
86
|
+
* the older install runs exactly as before.
|
|
87
|
+
*/
|
|
88
|
+
export function handOffInstall(opts) {
|
|
89
|
+
if (!opts.installed)
|
|
90
|
+
return null;
|
|
91
|
+
const config = readDaemonConfig(opts.paths.configPath);
|
|
92
|
+
let rootNote = "";
|
|
93
|
+
if (opts.hqRoot && config.hqRoot !== opts.hqRoot) {
|
|
94
|
+
config.hqRoot = opts.hqRoot;
|
|
95
|
+
rootNote = ` Its HQ folder is now ${opts.hqRoot}.`;
|
|
96
|
+
}
|
|
97
|
+
const defaults = DEFAULT_SERVICES[opts.machineType];
|
|
98
|
+
let changed = false;
|
|
99
|
+
for (const service of opts.services) {
|
|
100
|
+
if (config.disable.includes(service)) {
|
|
101
|
+
config.disable = config.disable.filter((s) => s !== service);
|
|
102
|
+
changed = true;
|
|
103
|
+
}
|
|
104
|
+
if (!defaults.includes(service) && !config.enable.includes(service)) {
|
|
105
|
+
config.enable = [...config.enable, service];
|
|
106
|
+
changed = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (changed || rootNote)
|
|
110
|
+
writeDaemonConfig(opts.paths.configPath, config);
|
|
111
|
+
const names = opts.services.join(", ");
|
|
112
|
+
return `hq daemon runs ${names} on this machine, so there is nothing separate to install.${rootNote} See \`hq daemon status\`.`;
|
|
113
|
+
}
|
|
114
|
+
/** handOffInstall for this machine and user, for the older install commands. */
|
|
115
|
+
export function handOffInstallHere(services, env = process.env, opts = {}) {
|
|
116
|
+
const home = os.homedir();
|
|
117
|
+
const installed = daemonUnitInstalled({
|
|
118
|
+
platform: process.platform,
|
|
119
|
+
home,
|
|
120
|
+
windowsTaskExists: (task) => spawnSync("schtasks.exe", ["/Query", "/TN", task], { encoding: "utf8", windowsHide: true }).status === 0,
|
|
121
|
+
});
|
|
122
|
+
if (!installed)
|
|
123
|
+
return null;
|
|
124
|
+
const paths = daemonPaths(home, env);
|
|
125
|
+
const config = readDaemonConfig(paths.configPath);
|
|
126
|
+
const machineType = detectMachineType(undefined, {
|
|
127
|
+
platform: process.platform,
|
|
128
|
+
env,
|
|
129
|
+
home,
|
|
130
|
+
savedType: config.machineType,
|
|
131
|
+
existsSync: fs.existsSync,
|
|
132
|
+
readFileSync: (p) => fs.readFileSync(p, "utf8"),
|
|
133
|
+
}).type;
|
|
134
|
+
return handOffInstall({ paths, installed, machineType, services, hqRoot: opts.hqRoot });
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=handoff.js.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install / uninstall the ONE OS unit that runs `node <hq> daemon run --managed`.
|
|
3
|
+
* `--managed` tells the daemon a service manager relaunches it when it exits,
|
|
4
|
+
* which is how it restarts itself onto a new CLI version after an update.
|
|
5
|
+
*
|
|
6
|
+
* - macOS: LaunchAgent ai.getindigo.hq-daemon (RunAtLoad + KeepAlive)
|
|
7
|
+
* - Linux, per-user: systemd user unit hq-daemon.service
|
|
8
|
+
* - Linux, system (outposts and agent boxes): /etc/systemd/system/hq-daemon.service
|
|
9
|
+
* running as the box's login user; needs root
|
|
10
|
+
* - Windows: per-user logon Scheduled Task ai.getindigo.hq-daemon
|
|
11
|
+
* - anything else: no unit; the caller prints the command to run
|
|
12
|
+
*
|
|
13
|
+
* The unit never names services. `hq daemon run` reads the machine type and
|
|
14
|
+
* overrides from ~/.hq/daemon/config.json, so the unit does not change when
|
|
15
|
+
* services are added. Every service-manager call goes through `run`, so tests
|
|
16
|
+
* never touch launchctl, systemctl or schtasks.
|
|
17
|
+
*/
|
|
18
|
+
import { type RunCommand } from "../service-manager/launchd.js";
|
|
19
|
+
import type { MachineType } from "./machine-type.js";
|
|
20
|
+
export declare const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
|
|
21
|
+
export declare const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
|
|
22
|
+
export declare const DAEMON_WINDOWS_TASK = "ai.getindigo.hq-daemon";
|
|
23
|
+
export declare const SYSTEM_UNIT_DIR = "/etc/systemd/system";
|
|
24
|
+
export type InstallScope = "user" | "system";
|
|
25
|
+
export interface DaemonHost {
|
|
26
|
+
platform: NodeJS.Platform;
|
|
27
|
+
home: string;
|
|
28
|
+
nodeBinary: string;
|
|
29
|
+
hqBinary: string;
|
|
30
|
+
/** ~/.hq/daemon (or HQ_DAEMON_DIR). */
|
|
31
|
+
daemonDir: string;
|
|
32
|
+
uid: number;
|
|
33
|
+
isRoot: boolean;
|
|
34
|
+
/** `DOMAIN\user` the Windows task runs as. */
|
|
35
|
+
windowsUser: string;
|
|
36
|
+
/** conhost.exe path when `--headless` is supported, else undefined. */
|
|
37
|
+
windowsHeadlessLauncher: string | undefined;
|
|
38
|
+
/** Where system units are written (tests point this at a temp dir). */
|
|
39
|
+
systemUnitDir: string;
|
|
40
|
+
/** Extra environment baked into the unit (PATH of the installing shell, HQ_DAEMON_DIR). */
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
export interface InstallOptions {
|
|
44
|
+
scope: InstallScope;
|
|
45
|
+
/** Login user a system unit runs as. Required for scope "system". */
|
|
46
|
+
runAsUser?: string;
|
|
47
|
+
/** That user's home directory, from the account database. Required for scope "system". */
|
|
48
|
+
runAsHome?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface DaemonUnitPlan {
|
|
51
|
+
kind: "launchd" | "systemd-user" | "systemd-system" | "schtasks" | "manual";
|
|
52
|
+
unitPath: string;
|
|
53
|
+
rendered: string;
|
|
54
|
+
manualCommand?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface DaemonUnitResult {
|
|
57
|
+
kind: DaemonUnitPlan["kind"];
|
|
58
|
+
unitPath: string;
|
|
59
|
+
installed: boolean;
|
|
60
|
+
running: boolean;
|
|
61
|
+
error?: string;
|
|
62
|
+
manualCommand?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface InstallDeps {
|
|
65
|
+
run: RunCommand;
|
|
66
|
+
}
|
|
67
|
+
/** Boxes run the daemon as a system unit; everything else per user. */
|
|
68
|
+
export declare function installScopeFor(machineType: MachineType): InstallScope;
|
|
69
|
+
export declare function planDaemonUnit(host: DaemonHost, options: InstallOptions): DaemonUnitPlan;
|
|
70
|
+
export declare function installDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): DaemonUnitResult;
|
|
71
|
+
export declare function uninstallDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): {
|
|
72
|
+
kind: DaemonUnitPlan["kind"];
|
|
73
|
+
unitPath: string;
|
|
74
|
+
removed: boolean;
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=install.d.ts.map
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install / uninstall the ONE OS unit that runs `node <hq> daemon run --managed`.
|
|
3
|
+
* `--managed` tells the daemon a service manager relaunches it when it exits,
|
|
4
|
+
* which is how it restarts itself onto a new CLI version after an update.
|
|
5
|
+
*
|
|
6
|
+
* - macOS: LaunchAgent ai.getindigo.hq-daemon (RunAtLoad + KeepAlive)
|
|
7
|
+
* - Linux, per-user: systemd user unit hq-daemon.service
|
|
8
|
+
* - Linux, system (outposts and agent boxes): /etc/systemd/system/hq-daemon.service
|
|
9
|
+
* running as the box's login user; needs root
|
|
10
|
+
* - Windows: per-user logon Scheduled Task ai.getindigo.hq-daemon
|
|
11
|
+
* - anything else: no unit; the caller prints the command to run
|
|
12
|
+
*
|
|
13
|
+
* The unit never names services. `hq daemon run` reads the machine type and
|
|
14
|
+
* overrides from ~/.hq/daemon/config.json, so the unit does not change when
|
|
15
|
+
* services are added. Every service-manager call goes through `run`, so tests
|
|
16
|
+
* never touch launchctl, systemctl or schtasks.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { activateLaunchd, deactivateLaunchd, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
|
|
21
|
+
import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitPath, } from "../service-manager/systemd.js";
|
|
22
|
+
import { escapeXml, shellQuote } from "../service-manager/types.js";
|
|
23
|
+
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
24
|
+
export const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
|
|
25
|
+
export const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
|
|
26
|
+
export const DAEMON_WINDOWS_TASK = "ai.getindigo.hq-daemon";
|
|
27
|
+
export const SYSTEM_UNIT_DIR = "/etc/systemd/system";
|
|
28
|
+
/** Boxes run the daemon as a system unit; everything else per user. */
|
|
29
|
+
export function installScopeFor(machineType) {
|
|
30
|
+
return machineType === "outpost" || machineType === "agent-box" ? "system" : "user";
|
|
31
|
+
}
|
|
32
|
+
function daemonSpec(host) {
|
|
33
|
+
return {
|
|
34
|
+
name: "daemon",
|
|
35
|
+
label: DAEMON_LAUNCHD_LABEL,
|
|
36
|
+
description: "HQ daemon: runs this machine's HQ background services",
|
|
37
|
+
args: ["daemon", "run", "--managed"],
|
|
38
|
+
logPath: path.join(host.daemonDir, "logs", "daemon.stdout.log"),
|
|
39
|
+
workingDir: host.daemonDir,
|
|
40
|
+
env: host.env ?? {},
|
|
41
|
+
restartSec: 10,
|
|
42
|
+
unitName: DAEMON_SYSTEMD_UNIT,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function serviceHost(host) {
|
|
46
|
+
return { home: host.home, nodeBinary: host.nodeBinary, hqBinary: host.hqBinary };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Output goes to the journal: an `append:` log file would be created by
|
|
50
|
+
* systemd as root inside the user's home, and the daemon (running as the user)
|
|
51
|
+
* writes its own logs under ~/.hq/daemon/logs.
|
|
52
|
+
*/
|
|
53
|
+
function renderSystemdSystemUnit(host, user, userHome) {
|
|
54
|
+
const exec = [host.nodeBinary, host.hqBinary, "daemon", "run", "--managed"].map(shellQuote).join(" ");
|
|
55
|
+
const env = {
|
|
56
|
+
...host.env,
|
|
57
|
+
HOME: userHome,
|
|
58
|
+
PATH: host.env?.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
59
|
+
};
|
|
60
|
+
const envLines = Object.entries(env)
|
|
61
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
62
|
+
.map(([k, v]) => `Environment=${shellQuote(`${k}=${v}`)}`)
|
|
63
|
+
.join("\n");
|
|
64
|
+
return `[Unit]
|
|
65
|
+
Description=HQ daemon: runs this machine's HQ background services (user=${user})
|
|
66
|
+
After=network-online.target
|
|
67
|
+
Wants=network-online.target
|
|
68
|
+
|
|
69
|
+
[Service]
|
|
70
|
+
Type=simple
|
|
71
|
+
User=${user}
|
|
72
|
+
ExecStart=${exec}
|
|
73
|
+
WorkingDirectory=${userHome}
|
|
74
|
+
Restart=always
|
|
75
|
+
RestartSec=10
|
|
76
|
+
KillMode=control-group
|
|
77
|
+
TimeoutStopSec=20
|
|
78
|
+
${envLines}
|
|
79
|
+
|
|
80
|
+
[Install]
|
|
81
|
+
WantedBy=multi-user.target
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
function windowsArgQuote(value) {
|
|
85
|
+
if (/^[A-Za-z0-9_.:\\/-]+$/.test(value))
|
|
86
|
+
return value;
|
|
87
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Logon-triggered task for the installing user. InteractiveToken is what an
|
|
91
|
+
* unprivileged install can register, so the task runs while the user is
|
|
92
|
+
* logged on. `conhost.exe --headless` keeps a console window from opening.
|
|
93
|
+
*/
|
|
94
|
+
function renderWindowsTaskXml(host) {
|
|
95
|
+
const user = escapeXml(host.windowsUser);
|
|
96
|
+
const nodeArg = windowsArgQuote(host.nodeBinary);
|
|
97
|
+
const hqArg = windowsArgQuote(host.hqBinary);
|
|
98
|
+
const command = host.windowsHeadlessLauncher ?? host.nodeBinary;
|
|
99
|
+
// A task cannot set environment variables, so an HQ_DAEMON_DIR override
|
|
100
|
+
// travels as an argument.
|
|
101
|
+
const dirOverride = host.env?.HQ_DAEMON_DIR;
|
|
102
|
+
const runArgs = dirOverride ? `daemon run --managed --daemon-dir ${windowsArgQuote(dirOverride)}` : "daemon run --managed";
|
|
103
|
+
const args = host.windowsHeadlessLauncher ? `--headless ${nodeArg} ${hqArg} ${runArgs}` : `${hqArg} ${runArgs}`;
|
|
104
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
105
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
106
|
+
<RegistrationInfo>
|
|
107
|
+
<URI>\\${DAEMON_WINDOWS_TASK}</URI>
|
|
108
|
+
<Description>HQ daemon: runs this machine's HQ background services</Description>
|
|
109
|
+
</RegistrationInfo>
|
|
110
|
+
<Triggers>
|
|
111
|
+
<LogonTrigger>
|
|
112
|
+
<Enabled>true</Enabled>
|
|
113
|
+
<UserId>${user}</UserId>
|
|
114
|
+
</LogonTrigger>
|
|
115
|
+
</Triggers>
|
|
116
|
+
<Principals>
|
|
117
|
+
<Principal id="Author">
|
|
118
|
+
<UserId>${user}</UserId>
|
|
119
|
+
<LogonType>InteractiveToken</LogonType>
|
|
120
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
121
|
+
</Principal>
|
|
122
|
+
</Principals>
|
|
123
|
+
<Settings>
|
|
124
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
125
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
126
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
127
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
128
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
129
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
130
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
131
|
+
<Enabled>true</Enabled>
|
|
132
|
+
<Hidden>true</Hidden>
|
|
133
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
134
|
+
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
|
|
135
|
+
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
|
|
136
|
+
<WakeToRun>false</WakeToRun>
|
|
137
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
138
|
+
<Priority>7</Priority>
|
|
139
|
+
<RestartOnFailure>
|
|
140
|
+
<Interval>PT1M</Interval>
|
|
141
|
+
<Count>999</Count>
|
|
142
|
+
</RestartOnFailure>
|
|
143
|
+
</Settings>
|
|
144
|
+
<Actions Context="Author">
|
|
145
|
+
<Exec>
|
|
146
|
+
<Command>${escapeXml(command)}</Command>
|
|
147
|
+
<Arguments>${escapeXml(args)}</Arguments>
|
|
148
|
+
<WorkingDirectory>${escapeXml(host.daemonDir)}</WorkingDirectory>
|
|
149
|
+
</Exec>
|
|
150
|
+
</Actions>
|
|
151
|
+
</Task>
|
|
152
|
+
`;
|
|
153
|
+
}
|
|
154
|
+
/** schtasks reads task XML as UTF-16 LE with a BOM. */
|
|
155
|
+
function encodeWindowsTaskXml(rendered) {
|
|
156
|
+
return Buffer.from(`\uFEFF${rendered}`, "utf16le");
|
|
157
|
+
}
|
|
158
|
+
export function planDaemonUnit(host, options) {
|
|
159
|
+
const spec = daemonSpec(host);
|
|
160
|
+
if (host.platform === "darwin") {
|
|
161
|
+
return { kind: "launchd", unitPath: launchdPlistPath(host.home, spec), rendered: renderLaunchdPlist(spec, serviceHost(host)) };
|
|
162
|
+
}
|
|
163
|
+
if (host.platform === "linux") {
|
|
164
|
+
if (options.scope === "system") {
|
|
165
|
+
if (!options.runAsUser) {
|
|
166
|
+
throw new Error("A system install needs the box's login user. Provisioning writes it to /etc/hq/machine-type (user=<name>).");
|
|
167
|
+
}
|
|
168
|
+
if (!options.runAsHome)
|
|
169
|
+
throw new Error(`Could not find the home directory of ${options.runAsUser}.`);
|
|
170
|
+
return {
|
|
171
|
+
kind: "systemd-system",
|
|
172
|
+
unitPath: path.join(host.systemUnitDir, DAEMON_SYSTEMD_UNIT),
|
|
173
|
+
rendered: renderSystemdSystemUnit(host, options.runAsUser, options.runAsHome),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { kind: "systemd-user", unitPath: systemdUnitPath(host.home, spec), rendered: renderSystemdUserUnit(spec, serviceHost(host)) };
|
|
177
|
+
}
|
|
178
|
+
if (host.platform === "win32") {
|
|
179
|
+
return {
|
|
180
|
+
kind: "schtasks",
|
|
181
|
+
unitPath: path.join(host.daemonDir, `${DAEMON_WINDOWS_TASK}.xml`),
|
|
182
|
+
rendered: renderWindowsTaskXml(host),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const manualCommand = [host.nodeBinary, host.hqBinary, "daemon", "run"].map(shellQuote).join(" ");
|
|
186
|
+
return { kind: "manual", unitPath: "", rendered: "", manualCommand };
|
|
187
|
+
}
|
|
188
|
+
function commandError(what, r) {
|
|
189
|
+
return new Error(`${what} failed: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.status}`}`);
|
|
190
|
+
}
|
|
191
|
+
function activate(plan, host, run) {
|
|
192
|
+
switch (plan.kind) {
|
|
193
|
+
case "launchd":
|
|
194
|
+
activateLaunchd(plan.unitPath, run, host.uid);
|
|
195
|
+
return;
|
|
196
|
+
case "systemd-user":
|
|
197
|
+
activateSystemd(DAEMON_SYSTEMD_UNIT, run);
|
|
198
|
+
return;
|
|
199
|
+
case "systemd-system": {
|
|
200
|
+
const reload = run("systemctl", ["daemon-reload"]);
|
|
201
|
+
if (reload.status !== 0)
|
|
202
|
+
throw commandError("systemctl daemon-reload", reload);
|
|
203
|
+
const enable = run("systemctl", ["enable", "--now", DAEMON_SYSTEMD_UNIT]);
|
|
204
|
+
if (enable.status !== 0)
|
|
205
|
+
throw commandError(`systemctl enable --now ${DAEMON_SYSTEMD_UNIT}`, enable);
|
|
206
|
+
run("systemctl", ["restart", DAEMON_SYSTEMD_UNIT]);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
case "schtasks": {
|
|
210
|
+
const create = run("schtasks.exe", ["/Create", "/TN", DAEMON_WINDOWS_TASK, "/XML", plan.unitPath, "/F"]);
|
|
211
|
+
if (create.status !== 0)
|
|
212
|
+
throw commandError("schtasks /Create", create);
|
|
213
|
+
const start = run("schtasks.exe", ["/Run", "/TN", DAEMON_WINDOWS_TASK]);
|
|
214
|
+
if (start.status !== 0)
|
|
215
|
+
throw commandError("schtasks /Run", start);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
case "manual":
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export function installDaemonUnit(host, options, deps) {
|
|
223
|
+
const plan = planDaemonUnit(host, options);
|
|
224
|
+
if (plan.kind === "manual") {
|
|
225
|
+
return { kind: plan.kind, unitPath: "", installed: false, running: false, manualCommand: plan.manualCommand };
|
|
226
|
+
}
|
|
227
|
+
if (plan.kind === "systemd-system" && !host.isRoot) {
|
|
228
|
+
throw new Error("Installing the daemon on a box writes a system unit, which needs root. Run: sudo hq daemon install");
|
|
229
|
+
}
|
|
230
|
+
fs.mkdirSync(path.dirname(plan.unitPath), { recursive: true });
|
|
231
|
+
if (plan.kind !== "systemd-system")
|
|
232
|
+
fs.mkdirSync(path.join(host.daemonDir, "logs"), { recursive: true });
|
|
233
|
+
if (plan.kind === "schtasks")
|
|
234
|
+
fs.writeFileSync(plan.unitPath, encodeWindowsTaskXml(plan.rendered));
|
|
235
|
+
else
|
|
236
|
+
fs.writeFileSync(plan.unitPath, plan.rendered, { mode: plan.kind === "systemd-system" ? 0o644 : 0o600 });
|
|
237
|
+
try {
|
|
238
|
+
activate(plan, host, deps.run);
|
|
239
|
+
return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true };
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
return {
|
|
243
|
+
kind: plan.kind,
|
|
244
|
+
unitPath: plan.unitPath,
|
|
245
|
+
installed: true,
|
|
246
|
+
running: false,
|
|
247
|
+
error: err instanceof Error ? err.message : String(err),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
export function uninstallDaemonUnit(host, options, deps) {
|
|
252
|
+
// A system uninstall does not need the login user; only the unit path matters.
|
|
253
|
+
const plan = host.platform === "linux" && options.scope === "system"
|
|
254
|
+
? { kind: "systemd-system", unitPath: path.join(host.systemUnitDir, DAEMON_SYSTEMD_UNIT), rendered: "" }
|
|
255
|
+
: planDaemonUnit(host, options);
|
|
256
|
+
if (plan.kind === "manual")
|
|
257
|
+
return { kind: plan.kind, unitPath: plan.unitPath, removed: false };
|
|
258
|
+
// A registered task is removed even when its XML file has gone missing.
|
|
259
|
+
const present = fs.existsSync(plan.unitPath) ||
|
|
260
|
+
(plan.kind === "schtasks" && deps.run("schtasks.exe", ["/Query", "/TN", DAEMON_WINDOWS_TASK]).status === 0);
|
|
261
|
+
if (!present)
|
|
262
|
+
return { kind: plan.kind, unitPath: plan.unitPath, removed: false };
|
|
263
|
+
switch (plan.kind) {
|
|
264
|
+
case "launchd":
|
|
265
|
+
deactivateLaunchd(plan.unitPath, deps.run, host.uid);
|
|
266
|
+
break;
|
|
267
|
+
case "systemd-user":
|
|
268
|
+
deactivateSystemd(DAEMON_SYSTEMD_UNIT, deps.run);
|
|
269
|
+
break;
|
|
270
|
+
case "systemd-system": {
|
|
271
|
+
if (!host.isRoot)
|
|
272
|
+
throw new Error("Removing the box's system unit needs root. Run: sudo hq daemon uninstall");
|
|
273
|
+
const disable = deps.run("systemctl", ["disable", "--now", DAEMON_SYSTEMD_UNIT]);
|
|
274
|
+
// Keep the unit file so a later uninstall still finds it.
|
|
275
|
+
if (disable.status !== 0)
|
|
276
|
+
throw commandError(`systemctl disable --now ${DAEMON_SYSTEMD_UNIT}`, disable);
|
|
277
|
+
deps.run("systemctl", ["daemon-reload"]);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
case "schtasks": {
|
|
281
|
+
deps.run("schtasks.exe", ["/End", "/TN", DAEMON_WINDOWS_TASK]);
|
|
282
|
+
// Ending the task stops only its root process (conhost when --headless is
|
|
283
|
+
// used); node and the services it runs keep going. End the recorded
|
|
284
|
+
// daemon's whole process tree.
|
|
285
|
+
const owner = readPidLock(path.join(host.daemonDir, "daemon.pid"));
|
|
286
|
+
if (owner && owner.pid !== process.pid) {
|
|
287
|
+
deps.run("taskkill.exe", ["/PID", String(owner.pid), "/T", "/F"]);
|
|
288
|
+
}
|
|
289
|
+
const del = deps.run("schtasks.exe", ["/Delete", "/TN", DAEMON_WINDOWS_TASK, "/F"]);
|
|
290
|
+
// Keep the XML so a later uninstall still finds the task.
|
|
291
|
+
if (del.status !== 0)
|
|
292
|
+
throw commandError(`schtasks /Delete /TN ${DAEMON_WINDOWS_TASK}`, del);
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
fs.rmSync(plan.unitPath, { force: true });
|
|
297
|
+
return { kind: plan.kind, unitPath: plan.unitPath, removed: true };
|
|
298
|
+
}
|
|
299
|
+
//# sourceMappingURL=install.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Units HQ installed before `hq daemon`, one per service. While one of these
|
|
3
|
+
* still runs a service, the daemon leaves that service alone instead of
|
|
4
|
+
* starting a second copy, and `hq daemon migrate` lists it.
|
|
5
|
+
*
|
|
6
|
+
* Detection only reads the filesystem (and asks schtasks on Windows). Nothing
|
|
7
|
+
* here stops or removes a unit.
|
|
8
|
+
*/
|
|
9
|
+
import type { ServiceName } from "./catalog.js";
|
|
10
|
+
export interface LegacyUnit {
|
|
11
|
+
/** Daemon unit id it overlaps with (`mesh`, `bots:<name>`, ...). */
|
|
12
|
+
unitId: string;
|
|
13
|
+
service: ServiceName;
|
|
14
|
+
kind: "launchd" | "systemd-user" | "systemd-system" | "schtasks";
|
|
15
|
+
name: string;
|
|
16
|
+
path?: string;
|
|
17
|
+
/** False for units owned by another product (the desktop app's own agent). */
|
|
18
|
+
migratable: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface LegacyProbeDeps {
|
|
21
|
+
platform: NodeJS.Platform;
|
|
22
|
+
home: string;
|
|
23
|
+
existsSync: (p: string) => boolean;
|
|
24
|
+
listDir: (dir: string) => string[];
|
|
25
|
+
windowsTaskExists: (taskName: string) => boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare function describeLegacyUnit(unit: LegacyUnit): string;
|
|
28
|
+
export declare function findLegacyUnits(deps: LegacyProbeDeps): LegacyUnit[];
|
|
29
|
+
/**
|
|
30
|
+
* unit id → description of the migratable legacy unit that still runs it.
|
|
31
|
+
* Units owned by another product (the desktop app) are handled by the plan's
|
|
32
|
+
* own checks, because `hq daemon migrate` will never take them over.
|
|
33
|
+
*/
|
|
34
|
+
export declare function legacyOwnerLookup(units: readonly LegacyUnit[]): (unitId: string) => string | null;
|
|
35
|
+
//# sourceMappingURL=legacy.d.ts.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Units HQ installed before `hq daemon`, one per service. While one of these
|
|
3
|
+
* still runs a service, the daemon leaves that service alone instead of
|
|
4
|
+
* starting a second copy, and `hq daemon migrate` lists it.
|
|
5
|
+
*
|
|
6
|
+
* Detection only reads the filesystem (and asks schtasks on Windows). Nothing
|
|
7
|
+
* here stops or removes a unit.
|
|
8
|
+
*/
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
const KIT_SERVICES = ["sync", "inbox", "heartbeat"];
|
|
11
|
+
/** Box units rendered by hq-pro-agents / `outposts self-deploy`, mapped to the service they overlap. */
|
|
12
|
+
const SYSTEM_UNITS = [
|
|
13
|
+
["hq-mesh-daemon.service", "mesh"],
|
|
14
|
+
["outpost-sync.service", "sync"],
|
|
15
|
+
["outpost-full-sync.timer", "sync"],
|
|
16
|
+
["hq-agent-sync.service", "sync"],
|
|
17
|
+
["outpost-session-heartbeat.service", "heartbeat"],
|
|
18
|
+
["hq-agent-heartbeat.timer", "heartbeat"],
|
|
19
|
+
["hq-agent-inbox.service", "inbox"],
|
|
20
|
+
["hq-agent-qmd-index.timer", "search-index"],
|
|
21
|
+
["outpost-jobs-reconcile.timer", "jobs-reconcile"],
|
|
22
|
+
["outpost-runner.service", "remote-control"],
|
|
23
|
+
["outpost-codex-runner.service", "remote-control"],
|
|
24
|
+
["hq-cli-update.timer", "updater"],
|
|
25
|
+
];
|
|
26
|
+
const KIND_LABEL = {
|
|
27
|
+
launchd: "LaunchAgent",
|
|
28
|
+
"systemd-user": "systemd user unit",
|
|
29
|
+
"systemd-system": "systemd unit",
|
|
30
|
+
schtasks: "scheduled task",
|
|
31
|
+
};
|
|
32
|
+
export function describeLegacyUnit(unit) {
|
|
33
|
+
return `${KIND_LABEL[unit.kind]} ${unit.name}`;
|
|
34
|
+
}
|
|
35
|
+
function safeList(deps, dir) {
|
|
36
|
+
try {
|
|
37
|
+
return deps.listDir(dir);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err.code === "ENOENT")
|
|
41
|
+
return [];
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function darwinUnits(deps) {
|
|
46
|
+
const dir = path.join(deps.home, "Library", "LaunchAgents");
|
|
47
|
+
const units = [];
|
|
48
|
+
const at = (label) => path.join(dir, `${label}.plist`);
|
|
49
|
+
const add = (unitId, service, label, migratable = true) => units.push({ unitId, service, kind: "launchd", name: label, path: at(label), migratable });
|
|
50
|
+
if (deps.existsSync(at("ai.getindigo.hq-mesh-daemon")))
|
|
51
|
+
add("mesh", "mesh", "ai.getindigo.hq-mesh-daemon");
|
|
52
|
+
for (const file of safeList(deps, dir).sort()) {
|
|
53
|
+
const m = /^ai\.getindigo\.hq-bot\.(.+)\.plist$/.exec(file);
|
|
54
|
+
if (m)
|
|
55
|
+
add(`bots:${m[1]}`, "bots", `ai.getindigo.hq-bot.${m[1]}`);
|
|
56
|
+
}
|
|
57
|
+
for (const s of KIT_SERVICES) {
|
|
58
|
+
const label = `ai.getindigo.hq-agent.${s}`;
|
|
59
|
+
if (deps.existsSync(at(label)))
|
|
60
|
+
add(s, s, label);
|
|
61
|
+
}
|
|
62
|
+
if (deps.existsSync(at("ai.indigo.hq-sync-menubar")))
|
|
63
|
+
add("sync", "sync", "ai.indigo.hq-sync-menubar", false);
|
|
64
|
+
return units;
|
|
65
|
+
}
|
|
66
|
+
function linuxUnits(deps) {
|
|
67
|
+
const userDir = path.join(deps.home, ".config", "systemd", "user");
|
|
68
|
+
const units = [];
|
|
69
|
+
const user = (unitId, service, name) => units.push({ unitId, service, kind: "systemd-user", name, path: path.join(userDir, name), migratable: true });
|
|
70
|
+
if (deps.existsSync(path.join(userDir, "hq-mesh-daemon.service")))
|
|
71
|
+
user("mesh", "mesh", "hq-mesh-daemon.service");
|
|
72
|
+
for (const file of safeList(deps, userDir).sort()) {
|
|
73
|
+
const m = /^hq-bot-(.+)\.service$/.exec(file);
|
|
74
|
+
if (m)
|
|
75
|
+
user(`bots:${m[1]}`, "bots", file);
|
|
76
|
+
}
|
|
77
|
+
for (const s of KIT_SERVICES) {
|
|
78
|
+
const name = `hq-agent-${s}.service`;
|
|
79
|
+
if (deps.existsSync(path.join(userDir, name)))
|
|
80
|
+
user(s, s, name);
|
|
81
|
+
}
|
|
82
|
+
if (deps.existsSync(path.join(userDir, "hq-lanes-reconcile.timer"))) {
|
|
83
|
+
user("lanes-reconcile", "lanes-reconcile", "hq-lanes-reconcile.timer");
|
|
84
|
+
}
|
|
85
|
+
const systemDir = "/etc/systemd/system";
|
|
86
|
+
for (const [name, service] of SYSTEM_UNITS) {
|
|
87
|
+
const file = path.posix.join(systemDir, name);
|
|
88
|
+
if (deps.existsSync(file)) {
|
|
89
|
+
units.push({ unitId: service, service, kind: "systemd-system", name, path: file, migratable: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return units;
|
|
93
|
+
}
|
|
94
|
+
function windowsUnits(deps) {
|
|
95
|
+
const task = "ai.getindigo.hq-mesh-daemon";
|
|
96
|
+
return deps.windowsTaskExists(task)
|
|
97
|
+
? [{ unitId: "mesh", service: "mesh", kind: "schtasks", name: task, migratable: true }]
|
|
98
|
+
: [];
|
|
99
|
+
}
|
|
100
|
+
export function findLegacyUnits(deps) {
|
|
101
|
+
if (deps.platform === "darwin")
|
|
102
|
+
return darwinUnits(deps);
|
|
103
|
+
if (deps.platform === "linux")
|
|
104
|
+
return linuxUnits(deps);
|
|
105
|
+
if (deps.platform === "win32")
|
|
106
|
+
return windowsUnits(deps);
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* unit id → description of the migratable legacy unit that still runs it.
|
|
111
|
+
* Units owned by another product (the desktop app) are handled by the plan's
|
|
112
|
+
* own checks, because `hq daemon migrate` will never take them over.
|
|
113
|
+
*/
|
|
114
|
+
export function legacyOwnerLookup(units) {
|
|
115
|
+
return (unitId) => {
|
|
116
|
+
const hit = units.find((u) => u.unitId === unitId && u.migratable);
|
|
117
|
+
return hit ? describeLegacyUnit(hit) : null;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=legacy.js.map
|