@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,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `hq daemon run` per user per machine: a pid file created with `wx`,
|
|
3
|
+
* taken over only when the recorded process is gone. Same file format as the
|
|
4
|
+
* mesh daemon's lock (`pid=<n>\nts=<unix seconds>`).
|
|
5
|
+
*
|
|
6
|
+
* Replacing a stale lock is guarded by a short-lived `<lock>.takeover` claim,
|
|
7
|
+
* so two starters that both saw the same stale owner cannot both win. A claim
|
|
8
|
+
* left behind by a starter that died mid-takeover expires after
|
|
9
|
+
* TAKEOVER_TTL_MS.
|
|
10
|
+
*/
|
|
11
|
+
import { type PidLockRecord } from "../mesh/live/daemon/pid-lock.js";
|
|
12
|
+
import type { RunCommand } from "../service-manager/launchd.js";
|
|
13
|
+
export declare const TAKEOVER_TTL_MS = 30000;
|
|
14
|
+
type Kill = (pid: number, signal: 0) => unknown;
|
|
15
|
+
/**
|
|
16
|
+
* The process exists and this user can signal it. EPERM means it belongs to
|
|
17
|
+
* another user, so it cannot be this user's daemon.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isProcessAlive(pid: number, kill?: Kill): boolean;
|
|
20
|
+
export interface CommandLineDeps {
|
|
21
|
+
platform: NodeJS.Platform;
|
|
22
|
+
run: RunCommand;
|
|
23
|
+
}
|
|
24
|
+
/** The process's command line, or null when it cannot be read. */
|
|
25
|
+
export declare function processCommandLine(pid: number, deps?: CommandLineDeps): string | null;
|
|
26
|
+
export interface DaemonProcessDeps {
|
|
27
|
+
alive: (pid: number) => boolean;
|
|
28
|
+
commandLine: (pid: number) => string | null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The pid is alive and is an `hq daemon run`. After a reboot the pid in an old
|
|
32
|
+
* lock file often belongs to an unrelated program; that must not keep the
|
|
33
|
+
* daemon from starting.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isDaemonProcess(pid: number, deps?: DaemonProcessDeps): boolean;
|
|
36
|
+
export type DaemonLockResult = {
|
|
37
|
+
ok: true;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
owner: PidLockRecord | null;
|
|
41
|
+
};
|
|
42
|
+
export declare function acquireDaemonLock(lockPath: string, pid?: number, alive?: (pid: number) => boolean, now?: () => number): DaemonLockResult;
|
|
43
|
+
export declare function releaseDaemonLock(lockPath: string, pid?: number): void;
|
|
44
|
+
/** The live supervisor's pid, or null when none is running. */
|
|
45
|
+
export declare function runningDaemonPid(lockPath: string, alive?: (pid: number) => boolean): number | null;
|
|
46
|
+
export {};
|
|
47
|
+
//# sourceMappingURL=lock.d.ts.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `hq daemon run` per user per machine: a pid file created with `wx`,
|
|
3
|
+
* taken over only when the recorded process is gone. Same file format as the
|
|
4
|
+
* mesh daemon's lock (`pid=<n>\nts=<unix seconds>`).
|
|
5
|
+
*
|
|
6
|
+
* Replacing a stale lock is guarded by a short-lived `<lock>.takeover` claim,
|
|
7
|
+
* so two starters that both saw the same stale owner cannot both win. A claim
|
|
8
|
+
* left behind by a starter that died mid-takeover expires after
|
|
9
|
+
* TAKEOVER_TTL_MS.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
15
|
+
import { ensurePrivateDir } from "./paths.js";
|
|
16
|
+
export const TAKEOVER_TTL_MS = 30_000;
|
|
17
|
+
/**
|
|
18
|
+
* The process exists and this user can signal it. EPERM means it belongs to
|
|
19
|
+
* another user, so it cannot be this user's daemon.
|
|
20
|
+
*/
|
|
21
|
+
export function isProcessAlive(pid, kill = (p, s) => process.kill(p, s)) {
|
|
22
|
+
try {
|
|
23
|
+
kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const defaultCommandLineDeps = () => ({
|
|
31
|
+
platform: process.platform,
|
|
32
|
+
run: (cmd, args) => {
|
|
33
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", windowsHide: true, timeout: 15_000 });
|
|
34
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
/** The process's command line, or null when it cannot be read. */
|
|
38
|
+
export function processCommandLine(pid, deps = defaultCommandLineDeps()) {
|
|
39
|
+
const r = deps.platform === "win32"
|
|
40
|
+
? deps.run("powershell.exe", [
|
|
41
|
+
"-NoProfile",
|
|
42
|
+
"-NonInteractive",
|
|
43
|
+
"-Command",
|
|
44
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${Math.trunc(pid)}").CommandLine`,
|
|
45
|
+
])
|
|
46
|
+
: deps.run("ps", ["-o", "command=", "-p", String(pid)]);
|
|
47
|
+
if (r.status !== 0)
|
|
48
|
+
return null;
|
|
49
|
+
const line = r.stdout.trim();
|
|
50
|
+
return line || null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The pid is alive and is an `hq daemon run`. After a reboot the pid in an old
|
|
54
|
+
* lock file often belongs to an unrelated program; that must not keep the
|
|
55
|
+
* daemon from starting.
|
|
56
|
+
*/
|
|
57
|
+
export function isDaemonProcess(pid, deps = { alive: (p) => isProcessAlive(p), commandLine: (p) => processCommandLine(p) }) {
|
|
58
|
+
if (!deps.alive(pid))
|
|
59
|
+
return false;
|
|
60
|
+
const cmd = deps.commandLine(pid);
|
|
61
|
+
return cmd === null || /\bdaemon\s+run\b/.test(cmd);
|
|
62
|
+
}
|
|
63
|
+
function claimTakeover(claim, pid, now) {
|
|
64
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(claim, `pid=${pid}\n`, { flag: "wx", mode: 0o600 });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (err.code !== "EEXIST")
|
|
71
|
+
throw err;
|
|
72
|
+
let age;
|
|
73
|
+
try {
|
|
74
|
+
age = now() - fs.statSync(claim).mtimeMs;
|
|
75
|
+
}
|
|
76
|
+
catch (statErr) {
|
|
77
|
+
if (statErr.code === "ENOENT")
|
|
78
|
+
continue;
|
|
79
|
+
throw statErr;
|
|
80
|
+
}
|
|
81
|
+
if (age <= TAKEOVER_TTL_MS)
|
|
82
|
+
return false;
|
|
83
|
+
fs.rmSync(claim, { force: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
export function acquireDaemonLock(lockPath, pid = process.pid, alive = isDaemonProcess, now = Date.now) {
|
|
89
|
+
ensurePrivateDir(path.dirname(lockPath));
|
|
90
|
+
const body = `pid=${pid}\nts=${Math.floor(now() / 1000)}\n`;
|
|
91
|
+
const claim = `${lockPath}.takeover`;
|
|
92
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
93
|
+
try {
|
|
94
|
+
fs.writeFileSync(lockPath, body, { flag: "wx", mode: 0o600 });
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err.code !== "EEXIST")
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
const owner = readPidLock(lockPath);
|
|
102
|
+
if (owner && owner.pid !== pid && alive(owner.pid))
|
|
103
|
+
return { ok: false, owner };
|
|
104
|
+
// Another starter is replacing the same stale lock; let it.
|
|
105
|
+
if (!claimTakeover(claim, pid, now))
|
|
106
|
+
return { ok: false, owner: null };
|
|
107
|
+
try {
|
|
108
|
+
// Re-check under the claim: the lock may have been replaced since the read above.
|
|
109
|
+
const current = readPidLock(lockPath);
|
|
110
|
+
if (current && current.pid !== pid && alive(current.pid))
|
|
111
|
+
return { ok: false, owner: current };
|
|
112
|
+
fs.rmSync(lockPath, { force: true });
|
|
113
|
+
try {
|
|
114
|
+
fs.writeFileSync(lockPath, body, { flag: "wx", mode: 0o600 });
|
|
115
|
+
return { ok: true };
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code !== "EEXIST")
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
fs.rmSync(claim, { force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`could not take the daemon lock at ${lockPath}`);
|
|
127
|
+
}
|
|
128
|
+
export function releaseDaemonLock(lockPath, pid = process.pid) {
|
|
129
|
+
const owner = readPidLock(lockPath);
|
|
130
|
+
if (owner?.pid === pid)
|
|
131
|
+
fs.rmSync(lockPath, { force: true });
|
|
132
|
+
}
|
|
133
|
+
/** The live supervisor's pid, or null when none is running. */
|
|
134
|
+
export function runningDaemonPid(lockPath, alive = isDaemonProcess) {
|
|
135
|
+
const owner = readPidLock(lockPath);
|
|
136
|
+
return owner && alive(owner.pid) ? owner.pid : null;
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=lock.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which kind of machine `hq daemon` is running on. The machine type picks the
|
|
3
|
+
* default service list, so nobody has to name services for a normal setup.
|
|
4
|
+
*
|
|
5
|
+
* Resolution order (first match wins):
|
|
6
|
+
* 1. `--machine-type` flag
|
|
7
|
+
* 2. the type saved in ~/.hq/daemon/config.json by an earlier install
|
|
8
|
+
* 3. /etc/hq/machine-type, written by box provisioning (may name a login user)
|
|
9
|
+
* 4. agent-kit machine credentials present → agent-kit
|
|
10
|
+
* 5. running in a container → agent-kit
|
|
11
|
+
* 6. macOS or Windows → desktop
|
|
12
|
+
* 7. any other host → self-hosted
|
|
13
|
+
*/
|
|
14
|
+
export declare const MACHINE_TYPES: readonly ["desktop", "self-hosted", "outpost", "agent-box", "agent-kit"];
|
|
15
|
+
export type MachineType = (typeof MACHINE_TYPES)[number];
|
|
16
|
+
export declare const MACHINE_TYPE_MARKER_PATH = "/etc/hq/machine-type";
|
|
17
|
+
export type MachineTypeSource = "flag" | "config" | "marker" | "agent-kit-creds" | "container" | "platform";
|
|
18
|
+
export interface MachineTypeResult {
|
|
19
|
+
type: MachineType;
|
|
20
|
+
source: MachineTypeSource;
|
|
21
|
+
/** Plain-language reason, printed by `hq daemon status` and install. */
|
|
22
|
+
detail: string;
|
|
23
|
+
/** Login user the system unit runs as (from the marker file). */
|
|
24
|
+
runAsUser?: string;
|
|
25
|
+
/** Problems found on the way (for example an unreadable marker file). */
|
|
26
|
+
warnings?: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface DetectMachineTypeDeps {
|
|
29
|
+
platform: NodeJS.Platform;
|
|
30
|
+
env: NodeJS.ProcessEnv;
|
|
31
|
+
home: string;
|
|
32
|
+
savedType: string | undefined;
|
|
33
|
+
existsSync: (p: string) => boolean;
|
|
34
|
+
readFileSync: (p: string) => string;
|
|
35
|
+
}
|
|
36
|
+
export declare function isMachineType(value: unknown): value is MachineType;
|
|
37
|
+
/**
|
|
38
|
+
* Marker format: either a bare type on the first line, or `type=<type>` with an
|
|
39
|
+
* optional `user=<login>` line. `#` comments and blank lines are ignored.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseMachineTypeMarker(text: string): {
|
|
42
|
+
type: MachineType;
|
|
43
|
+
user?: string;
|
|
44
|
+
} | null;
|
|
45
|
+
export declare function detectMachineType(flag: string | undefined, deps: DetectMachineTypeDeps): MachineTypeResult;
|
|
46
|
+
/** The home directory of `user` in passwd-format text, or null when the account is not listed. */
|
|
47
|
+
export declare function homeFromPasswd(passwd: string, user: string): string | null;
|
|
48
|
+
//# sourceMappingURL=machine-type.d.ts.map
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which kind of machine `hq daemon` is running on. The machine type picks the
|
|
3
|
+
* default service list, so nobody has to name services for a normal setup.
|
|
4
|
+
*
|
|
5
|
+
* Resolution order (first match wins):
|
|
6
|
+
* 1. `--machine-type` flag
|
|
7
|
+
* 2. the type saved in ~/.hq/daemon/config.json by an earlier install
|
|
8
|
+
* 3. /etc/hq/machine-type, written by box provisioning (may name a login user)
|
|
9
|
+
* 4. agent-kit machine credentials present → agent-kit
|
|
10
|
+
* 5. running in a container → agent-kit
|
|
11
|
+
* 6. macOS or Windows → desktop
|
|
12
|
+
* 7. any other host → self-hosted
|
|
13
|
+
*/
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { AGENT_DIR_ENV, MACHINE_CREDS_NAME, agentRootDir } from "../agent-kit/paths.js";
|
|
16
|
+
export const MACHINE_TYPES = ["desktop", "self-hosted", "outpost", "agent-box", "agent-kit"];
|
|
17
|
+
export const MACHINE_TYPE_MARKER_PATH = "/etc/hq/machine-type";
|
|
18
|
+
export function isMachineType(value) {
|
|
19
|
+
return typeof value === "string" && MACHINE_TYPES.includes(value);
|
|
20
|
+
}
|
|
21
|
+
const ACCOUNT_NAME = /^[a-z_][a-z0-9_-]{0,31}$/i;
|
|
22
|
+
/**
|
|
23
|
+
* Marker format: either a bare type on the first line, or `type=<type>` with an
|
|
24
|
+
* optional `user=<login>` line. `#` comments and blank lines are ignored.
|
|
25
|
+
*/
|
|
26
|
+
export function parseMachineTypeMarker(text) {
|
|
27
|
+
let type;
|
|
28
|
+
let user;
|
|
29
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (!line || line.startsWith("#"))
|
|
32
|
+
continue;
|
|
33
|
+
const eq = line.indexOf("=");
|
|
34
|
+
if (eq === -1) {
|
|
35
|
+
type ??= line;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const key = line.slice(0, eq).trim();
|
|
39
|
+
const value = line.slice(eq + 1).trim();
|
|
40
|
+
if (key === "type")
|
|
41
|
+
type = value;
|
|
42
|
+
else if (key === "user")
|
|
43
|
+
user = value;
|
|
44
|
+
}
|
|
45
|
+
if (!isMachineType(type))
|
|
46
|
+
return null;
|
|
47
|
+
if (user !== undefined && !ACCOUNT_NAME.test(user))
|
|
48
|
+
return null;
|
|
49
|
+
return user ? { type, user } : { type };
|
|
50
|
+
}
|
|
51
|
+
function platformName(platform) {
|
|
52
|
+
if (platform === "darwin")
|
|
53
|
+
return "macOS";
|
|
54
|
+
if (platform === "win32")
|
|
55
|
+
return "Windows";
|
|
56
|
+
return platform;
|
|
57
|
+
}
|
|
58
|
+
function withWarnings(result, warnings) {
|
|
59
|
+
return warnings.length > 0 ? { ...result, warnings } : result;
|
|
60
|
+
}
|
|
61
|
+
function readMarker(deps, warnings) {
|
|
62
|
+
if (!deps.existsSync(MACHINE_TYPE_MARKER_PATH))
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
const marker = parseMachineTypeMarker(deps.readFileSync(MACHINE_TYPE_MARKER_PATH));
|
|
66
|
+
if (!marker)
|
|
67
|
+
warnings.push(`${MACHINE_TYPE_MARKER_PATH} does not name a known machine type; ignored it`);
|
|
68
|
+
return marker;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
warnings.push(`could not read ${MACHINE_TYPE_MARKER_PATH}: ${err.message}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Box types run as the login user the provisioning marker names, however the type itself was chosen. */
|
|
76
|
+
function withMarkerUser(result, deps) {
|
|
77
|
+
if (result.type !== "outpost" && result.type !== "agent-box")
|
|
78
|
+
return result;
|
|
79
|
+
const warnings = [];
|
|
80
|
+
const user = readMarker(deps, warnings)?.user;
|
|
81
|
+
return withWarnings(user ? { ...result, runAsUser: user } : result, warnings);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Where a dedicated agent-kit host keeps its identity: an explicit
|
|
85
|
+
* HQ_MACHINE_CREDS_FILE or HQ_AGENT_DIR, else ~/.hq-agent itself. A local
|
|
86
|
+
* bot's identity under ~/.hq-agent/<name> sits beside a person's desktop and
|
|
87
|
+
* does not make the machine an agent kit.
|
|
88
|
+
*/
|
|
89
|
+
function hostAgentCredsPath(home, env) {
|
|
90
|
+
const credsFile = env.HQ_MACHINE_CREDS_FILE?.trim();
|
|
91
|
+
if (credsFile)
|
|
92
|
+
return credsFile;
|
|
93
|
+
const dir = env[AGENT_DIR_ENV]?.trim() || agentRootDir(home);
|
|
94
|
+
return path.join(dir, MACHINE_CREDS_NAME);
|
|
95
|
+
}
|
|
96
|
+
export function detectMachineType(flag, deps) {
|
|
97
|
+
if (flag !== undefined) {
|
|
98
|
+
if (!isMachineType(flag)) {
|
|
99
|
+
throw new Error(`Unknown machine type "${flag}". Use one of: ${MACHINE_TYPES.join(", ")}.`);
|
|
100
|
+
}
|
|
101
|
+
return withMarkerUser({ type: flag, source: "flag", detail: "set with --machine-type" }, deps);
|
|
102
|
+
}
|
|
103
|
+
if (isMachineType(deps.savedType)) {
|
|
104
|
+
return withMarkerUser({ type: deps.savedType, source: "config", detail: "saved by an earlier install" }, deps);
|
|
105
|
+
}
|
|
106
|
+
const warnings = [];
|
|
107
|
+
const marker = readMarker(deps, warnings);
|
|
108
|
+
if (marker) {
|
|
109
|
+
return {
|
|
110
|
+
type: marker.type,
|
|
111
|
+
source: "marker",
|
|
112
|
+
detail: `from ${MACHINE_TYPE_MARKER_PATH}`,
|
|
113
|
+
...(marker.user ? { runAsUser: marker.user } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const kitCreds = hostAgentCredsPath(deps.home, deps.env);
|
|
117
|
+
if (deps.existsSync(kitCreds)) {
|
|
118
|
+
return withWarnings({ type: "agent-kit", source: "agent-kit-creds", detail: `agent kit credentials at ${kitCreds}` }, warnings);
|
|
119
|
+
}
|
|
120
|
+
if (deps.env.HQ_CONTAINER === "1" || deps.existsSync("/.dockerenv")) {
|
|
121
|
+
return withWarnings({ type: "agent-kit", source: "container", detail: "running in a container" }, warnings);
|
|
122
|
+
}
|
|
123
|
+
if (deps.platform === "darwin" || deps.platform === "win32") {
|
|
124
|
+
return withWarnings({ type: "desktop", source: "platform", detail: platformName(deps.platform) }, warnings);
|
|
125
|
+
}
|
|
126
|
+
return withWarnings({ type: "self-hosted", source: "platform", detail: `${platformName(deps.platform)} host` }, warnings);
|
|
127
|
+
}
|
|
128
|
+
/** The home directory of `user` in passwd-format text, or null when the account is not listed. */
|
|
129
|
+
export function homeFromPasswd(passwd, user) {
|
|
130
|
+
for (const line of passwd.split(/\r?\n/)) {
|
|
131
|
+
if (!line || line.startsWith("#"))
|
|
132
|
+
continue;
|
|
133
|
+
const fields = line.split(":");
|
|
134
|
+
if (fields[0] === user && fields.length >= 7)
|
|
135
|
+
return fields[5] || null;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=machine-type.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taking over the units HQ installed before `hq daemon` (`hq daemon migrate
|
|
3
|
+
* --apply`) and giving them back (`hq daemon uninstall --restore-legacy`).
|
|
4
|
+
*
|
|
5
|
+
* Adopting a unit stops it and moves its definition into ~/.hq/daemon/adopted,
|
|
6
|
+
* so legacy detection no longer finds it and the daemon starts the service
|
|
7
|
+
* itself on its next plan. Each adopted unit is recorded in adopted.json with
|
|
8
|
+
* where its definition went, which is exactly what restore needs to put it
|
|
9
|
+
* back. A unit whose stop fails is left where it was and not recorded.
|
|
10
|
+
*
|
|
11
|
+
* Units owned by another product (the desktop app's sync agent) are never
|
|
12
|
+
* touched, and box system units need root. System units are recorded and
|
|
13
|
+
* saved in root-owned storage (not the login user's daemon folder, which the
|
|
14
|
+
* user can edit), and a restore checks each one lands in the system unit
|
|
15
|
+
* folder, so nobody but root decides what gets installed there.
|
|
16
|
+
*/
|
|
17
|
+
import type { RunCommand } from "../service-manager/launchd.js";
|
|
18
|
+
import type { LegacyUnit } from "./legacy.js";
|
|
19
|
+
export interface AdoptedUnit extends LegacyUnit {
|
|
20
|
+
/** Where the unit's definition (plist, unit file, task XML) was moved. */
|
|
21
|
+
movedTo: string;
|
|
22
|
+
adoptedAt: number;
|
|
23
|
+
}
|
|
24
|
+
export interface MigrateDeps {
|
|
25
|
+
run: RunCommand;
|
|
26
|
+
uid: number;
|
|
27
|
+
isRoot: boolean;
|
|
28
|
+
/** The login user's store: ~/.hq/daemon/adopted and adopted.json. */
|
|
29
|
+
adoptedDir: string;
|
|
30
|
+
recordPath: string;
|
|
31
|
+
/** Root-owned store for box system units. */
|
|
32
|
+
systemAdoptedDir: string;
|
|
33
|
+
systemRecordPath: string;
|
|
34
|
+
/** Where box system units live (/etc/systemd/system); restores must land here. */
|
|
35
|
+
systemUnitDir: string;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
/** Tests inject a failing writer. */
|
|
38
|
+
writeRecord?: (recordPath: string, units: AdoptedUnit[]) => void;
|
|
39
|
+
}
|
|
40
|
+
export interface AdoptResult {
|
|
41
|
+
adopted: AdoptedUnit[];
|
|
42
|
+
skipped: {
|
|
43
|
+
unit: LegacyUnit;
|
|
44
|
+
reason: string;
|
|
45
|
+
}[];
|
|
46
|
+
failed: {
|
|
47
|
+
unit: LegacyUnit;
|
|
48
|
+
error: string;
|
|
49
|
+
}[];
|
|
50
|
+
}
|
|
51
|
+
export interface RestoreResult {
|
|
52
|
+
restored: AdoptedUnit[];
|
|
53
|
+
failed: {
|
|
54
|
+
unit: AdoptedUnit;
|
|
55
|
+
error: string;
|
|
56
|
+
}[];
|
|
57
|
+
}
|
|
58
|
+
export declare function readAdoptedUnits(recordPath: string): AdoptedUnit[];
|
|
59
|
+
export declare function adoptLegacyUnits(units: readonly LegacyUnit[], deps: MigrateDeps): AdoptResult;
|
|
60
|
+
export declare function restoreLegacyUnits(deps: MigrateDeps): RestoreResult;
|
|
61
|
+
//# sourceMappingURL=migrate.d.ts.map
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taking over the units HQ installed before `hq daemon` (`hq daemon migrate
|
|
3
|
+
* --apply`) and giving them back (`hq daemon uninstall --restore-legacy`).
|
|
4
|
+
*
|
|
5
|
+
* Adopting a unit stops it and moves its definition into ~/.hq/daemon/adopted,
|
|
6
|
+
* so legacy detection no longer finds it and the daemon starts the service
|
|
7
|
+
* itself on its next plan. Each adopted unit is recorded in adopted.json with
|
|
8
|
+
* where its definition went, which is exactly what restore needs to put it
|
|
9
|
+
* back. A unit whose stop fails is left where it was and not recorded.
|
|
10
|
+
*
|
|
11
|
+
* Units owned by another product (the desktop app's sync agent) are never
|
|
12
|
+
* touched, and box system units need root. System units are recorded and
|
|
13
|
+
* saved in root-owned storage (not the login user's daemon folder, which the
|
|
14
|
+
* user can edit), and a restore checks each one lands in the system unit
|
|
15
|
+
* folder, so nobody but root decides what gets installed there.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { ensurePrivateDir } from "./paths.js";
|
|
20
|
+
export function readAdoptedUnits(recordPath) {
|
|
21
|
+
let text;
|
|
22
|
+
try {
|
|
23
|
+
text = fs.readFileSync(recordPath, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (err.code === "ENOENT")
|
|
27
|
+
return [];
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
const parsed = JSON.parse(text);
|
|
31
|
+
if (!Array.isArray(parsed))
|
|
32
|
+
throw new Error(`${recordPath} is not a list of adopted units`);
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
function writeAdoptedUnits(recordPath, units) {
|
|
36
|
+
ensurePrivateDir(path.dirname(recordPath));
|
|
37
|
+
const tmp = `${recordPath}.${process.pid}.tmp`;
|
|
38
|
+
fs.writeFileSync(tmp, `${JSON.stringify(units, null, 2)}\n`, { mode: 0o600 });
|
|
39
|
+
fs.renameSync(tmp, recordPath);
|
|
40
|
+
}
|
|
41
|
+
function storeFor(kind, deps) {
|
|
42
|
+
return kind === "systemd-system"
|
|
43
|
+
? { dir: deps.systemAdoptedDir, record: deps.systemRecordPath }
|
|
44
|
+
: { dir: deps.adoptedDir, record: deps.recordPath };
|
|
45
|
+
}
|
|
46
|
+
/** The saved definition's file name, prefixed with the unit kind so a user and a system unit of the same name never collide. */
|
|
47
|
+
function savedName(unit) {
|
|
48
|
+
return unit.kind === "schtasks" ? `schtasks-${unit.name}.xml` : `${unit.kind}-${path.basename(unit.path ?? unit.name)}`;
|
|
49
|
+
}
|
|
50
|
+
/** Run a service-manager command; throw with its stderr when it fails. */
|
|
51
|
+
function must(run, cmd, args) {
|
|
52
|
+
const r = run(cmd, args);
|
|
53
|
+
if (r.status !== 0) {
|
|
54
|
+
const detail = r.stderr.trim().split("\n").slice(-2).join(" ").slice(0, 300);
|
|
55
|
+
throw new Error(`${cmd} ${args.join(" ")} failed${detail ? `: ${detail}` : ` (exit ${r.status})`}`);
|
|
56
|
+
}
|
|
57
|
+
return r.stdout;
|
|
58
|
+
}
|
|
59
|
+
function moveFile(from, to) {
|
|
60
|
+
ensurePrivateDir(path.dirname(to));
|
|
61
|
+
try {
|
|
62
|
+
fs.renameSync(from, to);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err.code !== "EXDEV")
|
|
66
|
+
throw err;
|
|
67
|
+
fs.copyFileSync(from, to);
|
|
68
|
+
fs.rmSync(from);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** schtasks /Create /XML reads UTF-16LE with a BOM, the format /Query /XML declares. */
|
|
72
|
+
function encodeTaskXml(xml) {
|
|
73
|
+
return Buffer.from(`\uFEFF${xml.replace(/^\uFEFF/, "")}`, "utf16le");
|
|
74
|
+
}
|
|
75
|
+
function adoptOne(unit, deps) {
|
|
76
|
+
const to = path.join(storeFor(unit.kind, deps).dir, savedName(unit));
|
|
77
|
+
switch (unit.kind) {
|
|
78
|
+
case "launchd": {
|
|
79
|
+
if (!unit.path)
|
|
80
|
+
throw new Error(`no plist path for ${unit.name}`);
|
|
81
|
+
const target = `gui/${deps.uid}/${unit.name}`;
|
|
82
|
+
const bootout = deps.run("launchctl", ["bootout", target]);
|
|
83
|
+
// bootout also fails when the agent is not loaded, which is fine; any
|
|
84
|
+
// other failure leaves it running, so do not take it over.
|
|
85
|
+
if (bootout.status !== 0 && deps.run("launchctl", ["print", target]).status === 0) {
|
|
86
|
+
must(deps.run, "launchctl", ["bootout", target]);
|
|
87
|
+
}
|
|
88
|
+
moveFile(unit.path, to);
|
|
89
|
+
return to;
|
|
90
|
+
}
|
|
91
|
+
case "systemd-user":
|
|
92
|
+
case "systemd-system": {
|
|
93
|
+
if (!unit.path)
|
|
94
|
+
throw new Error(`no unit file path for ${unit.name}`);
|
|
95
|
+
const scope = unit.kind === "systemd-user" ? ["--user"] : [];
|
|
96
|
+
must(deps.run, "systemctl", [...scope, "disable", "--now", unit.name]);
|
|
97
|
+
moveFile(unit.path, to);
|
|
98
|
+
deps.run("systemctl", [...scope, "daemon-reload"]);
|
|
99
|
+
return to;
|
|
100
|
+
}
|
|
101
|
+
case "schtasks": {
|
|
102
|
+
const xml = must(deps.run, "schtasks.exe", ["/Query", "/TN", unit.name, "/XML"]);
|
|
103
|
+
ensurePrivateDir(path.dirname(to));
|
|
104
|
+
fs.writeFileSync(to, encodeTaskXml(xml), { mode: 0o600 });
|
|
105
|
+
deps.run("schtasks.exe", ["/End", "/TN", unit.name]);
|
|
106
|
+
must(deps.run, "schtasks.exe", ["/Delete", "/TN", unit.name, "/F"]);
|
|
107
|
+
return to;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function adoptLegacyUnits(units, deps) {
|
|
112
|
+
const now = deps.now ?? Date.now;
|
|
113
|
+
const write = deps.writeRecord ?? writeAdoptedUnits;
|
|
114
|
+
const result = { adopted: [], skipped: [], failed: [] };
|
|
115
|
+
const records = new Map();
|
|
116
|
+
const recordOf = (store) => {
|
|
117
|
+
if (!records.has(store.record))
|
|
118
|
+
records.set(store.record, readAdoptedUnits(store.record));
|
|
119
|
+
return records.get(store.record);
|
|
120
|
+
};
|
|
121
|
+
for (const unit of units) {
|
|
122
|
+
if (!unit.migratable) {
|
|
123
|
+
result.skipped.push({ unit, reason: "owned by the HQ desktop app" });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (unit.kind === "systemd-system" && !deps.isRoot) {
|
|
127
|
+
result.skipped.push({ unit, reason: "a system unit; run `sudo hq daemon migrate --apply`" });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const store = storeFor(unit.kind, deps);
|
|
131
|
+
let movedTo;
|
|
132
|
+
try {
|
|
133
|
+
movedTo = adoptOne(unit, deps);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
result.failed.push({ unit, error: err.message });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const adopted = { ...unit, movedTo, adoptedAt: now() };
|
|
140
|
+
const next = [...recordOf(store), adopted];
|
|
141
|
+
try {
|
|
142
|
+
// Record each unit as soon as it is moved, so a failure later in the
|
|
143
|
+
// list never loses track of one already taken over.
|
|
144
|
+
write(store.record, next);
|
|
145
|
+
records.set(store.record, next);
|
|
146
|
+
result.adopted.push(adopted);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
// Without a record nothing could restore it, so put it back now.
|
|
150
|
+
let rollback = "";
|
|
151
|
+
try {
|
|
152
|
+
restoreOne(adopted, deps);
|
|
153
|
+
}
|
|
154
|
+
catch (restoreErr) {
|
|
155
|
+
rollback = `; putting it back also failed (${restoreErr.message}), its definition is at ${movedTo}`;
|
|
156
|
+
}
|
|
157
|
+
result.failed.push({ unit, error: `could not record the takeover: ${err.message}${rollback}` });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
/** Move the definition back, unless an earlier restore that failed later already did. */
|
|
163
|
+
function moveBack(unit, to) {
|
|
164
|
+
if (!fs.existsSync(unit.movedTo) && fs.existsSync(to))
|
|
165
|
+
return;
|
|
166
|
+
moveFile(unit.movedTo, to);
|
|
167
|
+
}
|
|
168
|
+
const SYSTEM_UNIT_NAME = /^[A-Za-z0-9@._-]+\.service$/;
|
|
169
|
+
/** A system record entry may only put a .service file back into the system unit folder, from the root-owned store. */
|
|
170
|
+
function checkSystemEntry(unit, deps) {
|
|
171
|
+
const expectedPath = path.join(deps.systemUnitDir, unit.name);
|
|
172
|
+
const expectedSaved = path.join(deps.systemAdoptedDir, savedName(unit));
|
|
173
|
+
if (!SYSTEM_UNIT_NAME.test(unit.name) || unit.path !== expectedPath || unit.movedTo !== expectedSaved) {
|
|
174
|
+
throw new Error(`the recorded unit ${unit.name} does not go back into the system unit folder ${deps.systemUnitDir}; not restoring it`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function restoreOne(unit, deps) {
|
|
178
|
+
switch (unit.kind) {
|
|
179
|
+
case "launchd": {
|
|
180
|
+
if (!unit.path)
|
|
181
|
+
throw new Error(`no plist path recorded for ${unit.name}`);
|
|
182
|
+
moveBack(unit, unit.path);
|
|
183
|
+
must(deps.run, "launchctl", ["bootstrap", `gui/${deps.uid}`, unit.path]);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
case "systemd-user":
|
|
187
|
+
case "systemd-system": {
|
|
188
|
+
if (!unit.path)
|
|
189
|
+
throw new Error(`no unit file path recorded for ${unit.name}`);
|
|
190
|
+
if (unit.kind === "systemd-system" && !deps.isRoot) {
|
|
191
|
+
throw new Error("a system unit; run `sudo hq daemon uninstall --restore-legacy`");
|
|
192
|
+
}
|
|
193
|
+
const scope = unit.kind === "systemd-user" ? ["--user"] : [];
|
|
194
|
+
moveBack(unit, unit.path);
|
|
195
|
+
deps.run("systemctl", [...scope, "daemon-reload"]);
|
|
196
|
+
must(deps.run, "systemctl", [...scope, "enable", "--now", unit.name]);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
case "schtasks":
|
|
200
|
+
must(deps.run, "schtasks.exe", ["/Create", "/TN", unit.name, "/XML", unit.movedTo, "/F"]);
|
|
201
|
+
// Keep the saved XML until the task has started, so a failed start can be retried.
|
|
202
|
+
must(deps.run, "schtasks.exe", ["/Run", "/TN", unit.name]);
|
|
203
|
+
fs.rmSync(unit.movedTo, { force: true });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function restoreFrom(record, deps, check, result) {
|
|
208
|
+
const units = readAdoptedUnits(record);
|
|
209
|
+
if (units.length === 0)
|
|
210
|
+
return;
|
|
211
|
+
const remaining = [];
|
|
212
|
+
for (const unit of units) {
|
|
213
|
+
try {
|
|
214
|
+
check(unit);
|
|
215
|
+
restoreOne(unit, deps);
|
|
216
|
+
result.restored.push(unit);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
result.failed.push({ unit, error: err.message });
|
|
220
|
+
remaining.push(unit);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
writeAdoptedUnits(record, remaining);
|
|
224
|
+
}
|
|
225
|
+
export function restoreLegacyUnits(deps) {
|
|
226
|
+
const result = { restored: [], failed: [] };
|
|
227
|
+
restoreFrom(deps.recordPath, deps, (unit) => {
|
|
228
|
+
// The login user can edit this record; a system unit is only ever restored from root-owned storage.
|
|
229
|
+
if (unit.kind === "systemd-system") {
|
|
230
|
+
throw new Error("system units are only restored from the root-owned record; this entry was not written by hq daemon migrate");
|
|
231
|
+
}
|
|
232
|
+
}, result);
|
|
233
|
+
// Only root can read (or act on) the system store.
|
|
234
|
+
if (deps.isRoot)
|
|
235
|
+
restoreFrom(deps.systemRecordPath, deps, (unit) => checkSystemEntry(unit, deps), result);
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=migrate.js.map
|