@the-seeker/server-agent 0.1.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/dist/config.js ADDED
@@ -0,0 +1,109 @@
1
+ import { constants, readFileSync } from "node:fs";
2
+ import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir, platform as currentPlatform } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { z } from "zod";
6
+ export const SYSTEM_CONFIG_DIR = "/etc/theseeker-agent";
7
+ export const CONFIG_FILE_NAME = "config.json";
8
+ export const CONFIG_FILE_MODE = 0o600;
9
+ export const CONFIG_DIR_MODE = 0o750;
10
+ export const ENDPOINT_ENV = "THESEEKER_AGENT_ENDPOINT";
11
+ export const TOKEN_ENV = "THESEEKER_AGENT_TOKEN";
12
+ const packageMeta = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
13
+ export const AGENT_VERSION = typeof packageMeta.version === "string" ? packageMeta.version : "0.0.0";
14
+ export const LogLevelSchema = z.enum(["debug", "info", "warn", "error"]);
15
+ const LOG_LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
16
+ export const ConfigFileSchema = z.object({
17
+ endpoint: z.string().min(1).optional(),
18
+ token: z.string().min(1).optional(),
19
+ pm2Home: z.string().min(1).optional(),
20
+ logLevel: LogLevelSchema.optional(),
21
+ });
22
+ export class AgentConfigError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = "AgentConfigError";
26
+ }
27
+ }
28
+ export function systemConfigPath() {
29
+ return join(SYSTEM_CONFIG_DIR, CONFIG_FILE_NAME);
30
+ }
31
+ export function userConfigPath(home = homedir()) {
32
+ return join(home, ".config", "theseeker-agent", CONFIG_FILE_NAME);
33
+ }
34
+ function candidatePaths(options) {
35
+ const user = userConfigPath(options.home ?? homedir());
36
+ return (options.platform ?? currentPlatform()) === "linux" ? [systemConfigPath(), user] : [user];
37
+ }
38
+ async function reachable(path, mode) {
39
+ try {
40
+ await access(path, mode);
41
+ return true;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /** First existing readable config file, or `null` when the agent is env-only. */
48
+ export async function readableConfigPath(options = {}) {
49
+ for (const path of candidatePaths(options))
50
+ if (await reachable(path, constants.R_OK))
51
+ return path;
52
+ return null;
53
+ }
54
+ /** Where `install` should write: the system path on linux when its directory is writable, else the user path. */
55
+ export async function writableConfigPath(options = {}) {
56
+ if ((options.platform ?? currentPlatform()) === "linux") {
57
+ const writable = await reachable(SYSTEM_CONFIG_DIR, constants.W_OK) || await reachable(dirname(SYSTEM_CONFIG_DIR), constants.W_OK);
58
+ if (writable)
59
+ return systemConfigPath();
60
+ }
61
+ return userConfigPath(options.home ?? homedir());
62
+ }
63
+ export async function writeConfigFile(config, path) {
64
+ await mkdir(dirname(path), { recursive: true, mode: CONFIG_DIR_MODE });
65
+ await writeFile(path, `${JSON.stringify(ConfigFileSchema.parse(config), null, 2)}\n`, { mode: CONFIG_FILE_MODE });
66
+ await chmod(path, CONFIG_FILE_MODE);
67
+ }
68
+ export async function readConfigFile(path) {
69
+ let value;
70
+ try {
71
+ value = JSON.parse(await readFile(path, "utf8"));
72
+ }
73
+ catch {
74
+ throw new AgentConfigError(`config file is not valid JSON: ${path}`);
75
+ }
76
+ const parsed = ConfigFileSchema.safeParse(value);
77
+ if (!parsed.success)
78
+ throw new AgentConfigError(`config file has unexpected fields: ${path}`);
79
+ return parsed.data;
80
+ }
81
+ /** Config file merged with the `THESEEKER_AGENT_*` overrides. Never echoes the token back to the caller's logs. */
82
+ export async function loadConfig(options = {}) {
83
+ const environment = options.env ?? process.env;
84
+ const path = await readableConfigPath(options);
85
+ const file = path ? await readConfigFile(path) : {};
86
+ const endpoint = environment[ENDPOINT_ENV]?.trim() || file.endpoint;
87
+ const token = environment[TOKEN_ENV]?.trim() || file.token;
88
+ if (!endpoint)
89
+ throw new AgentConfigError(`missing endpoint: set ${ENDPOINT_ENV} or "endpoint" in the config file`);
90
+ if (!token)
91
+ throw new AgentConfigError(`missing token: set ${TOKEN_ENV} or "token" in the config file`);
92
+ return {
93
+ endpoint, token, logLevel: file.logLevel ?? "info",
94
+ ...(file.pm2Home ? { pm2Home: file.pm2Home } : {}),
95
+ };
96
+ }
97
+ /** Structured stdout-only logger. Callers pass event names and scalar fields; frame bodies and tokens are never accepted. */
98
+ export function createLogger(level = "info", write = (line) => void process.stdout.write(line)) {
99
+ const threshold = LOG_LEVEL_ORDER[level];
100
+ return (entryLevel, event, fields) => {
101
+ if (LOG_LEVEL_ORDER[entryLevel] < threshold)
102
+ return;
103
+ const entry = { ts: new Date().toISOString(), level: entryLevel, event };
104
+ for (const [key, value] of Object.entries(fields ?? {}))
105
+ if (value !== undefined)
106
+ entry[key] = value;
107
+ write(`${JSON.stringify(entry)}\n`);
108
+ };
109
+ }
@@ -0,0 +1,27 @@
1
+ import type { SourceRegistry } from "./collectors/sources.js";
2
+ import type { Logger } from "./config.js";
3
+ import type { ErrorResponseFrame, RequestFrame, ResponseError, ResponseFrame, Source, SuccessResponseFrame } from "./protocol.js";
4
+ export declare const AGENT_REQUEST_TIMEOUT_MS = 15000;
5
+ export declare const MAX_CONCURRENT_REQUESTS = 4;
6
+ export declare const SUPPORTED_ACTIONS: readonly ["logs.tail", "service.status", "pm2.describe", "nginx.sources"];
7
+ export interface HandlerState {
8
+ readonly pm2Home?: string | undefined;
9
+ readonly nginxEnabled: boolean;
10
+ }
11
+ export interface HandlerDependencies {
12
+ readonly registry: SourceRegistry;
13
+ readonly state: () => HandlerState;
14
+ readonly log: Logger;
15
+ readonly platform?: string | undefined;
16
+ readonly timeoutMs?: number | undefined;
17
+ readonly maxConcurrent?: number | undefined;
18
+ readonly onNginxSources?: ((sources: readonly Source[]) => void) | undefined;
19
+ }
20
+ export interface RequestHandlers {
21
+ /** Resolves with the frame to send back, or `null` when the request must be ignored. */
22
+ handle(frame: RequestFrame): Promise<ResponseFrame | null>;
23
+ readonly active: number;
24
+ }
25
+ export declare function successResponse(requestId: string, payload: SuccessResponseFrame["payload"]): SuccessResponseFrame;
26
+ export declare function errorResponse(requestId: string, code: ResponseError["code"], message?: string): ErrorResponseFrame;
27
+ export declare function createRequestHandlers(deps: HandlerDependencies): RequestHandlers;
@@ -0,0 +1,109 @@
1
+ import { homedir, platform as currentPlatform } from "node:os";
2
+ import { join } from "node:path";
3
+ import { CollectionError, locate, responseError, run } from "./collectors/command.js";
4
+ import { collectNginx } from "./collectors/nginx.js";
5
+ import { mapPm2 } from "./collectors/pm2.js";
6
+ export const AGENT_REQUEST_TIMEOUT_MS = 15000;
7
+ export const MAX_CONCURRENT_REQUESTS = 4;
8
+ export const SUPPORTED_ACTIONS = ["logs.tail", "service.status", "pm2.describe", "nginx.sources"];
9
+ const UNIT_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.@:\\-]*$/;
10
+ export function successResponse(requestId, payload) {
11
+ return { type: "response", request_id: requestId, ok: true, payload };
12
+ }
13
+ export function errorResponse(requestId, code, message = code) {
14
+ return { type: "response", request_id: requestId, ok: false, error: { code, message } };
15
+ }
16
+ function capabilityCode(capability) {
17
+ if (capability === "permission_denied")
18
+ return "permission_denied";
19
+ return capability === "error" ? "internal" : "unsupported";
20
+ }
21
+ async function serviceStatus(unit, platform, signal) {
22
+ if (platform !== "linux")
23
+ throw new CollectionError("unsupported");
24
+ if (!UNIT_PATTERN.test(unit))
25
+ throw new CollectionError("permission_denied");
26
+ const binary = await locate("systemctl");
27
+ if (!binary)
28
+ throw new CollectionError("unsupported");
29
+ const { stdout } = await run(binary, ["show", unit, "--property=ActiveState,SubState,MainPID,NRestarts", "--no-pager"], { signal });
30
+ const properties = new Map(stdout.split("\n").map((line) => {
31
+ const split = line.indexOf("=");
32
+ return [line.slice(0, split), line.slice(split + 1)];
33
+ }));
34
+ return { unit, active_state: properties.get("ActiveState") ?? "unknown", sub_state: properties.get("SubState") ?? "unknown" };
35
+ }
36
+ async function describeProcess(name, registry, state, signal) {
37
+ const binary = process.env["PM2_BIN"] || await locate("pm2");
38
+ if (!binary)
39
+ throw new CollectionError("unsupported");
40
+ const { stdout } = await run(binary, ["jlist"], {
41
+ timeout: 8000, maxBuffer: 8 * 1024 * 1024, signal,
42
+ env: { ...process.env, PM2_HOME: state.pm2Home ?? process.env["PM2_HOME"] ?? join(homedir(), ".pm2") },
43
+ });
44
+ const found = mapPm2(stdout, registry).processes.find((entry) => entry.name === name);
45
+ if (!found)
46
+ throw new CollectionError("not_found");
47
+ return found;
48
+ }
49
+ export function createRequestHandlers(deps) {
50
+ const timeoutMs = deps.timeoutMs ?? AGENT_REQUEST_TIMEOUT_MS;
51
+ const maxConcurrent = deps.maxConcurrent ?? MAX_CONCURRENT_REQUESTS;
52
+ let active = 0;
53
+ async function dispatch(frame, signal) {
54
+ const state = deps.state();
55
+ switch (frame.action) {
56
+ case "logs.tail":
57
+ return await deps.registry.tail(frame.params.source_id, frame.params.lines, signal);
58
+ case "service.status":
59
+ return await serviceStatus(frame.params.unit, deps.platform ?? currentPlatform(), signal);
60
+ case "pm2.describe":
61
+ return await describeProcess(frame.params.name, deps.registry, state, signal);
62
+ case "nginx.sources": {
63
+ const result = await collectNginx(state.nginxEnabled, deps.registry, signal);
64
+ if (result.capability !== "available")
65
+ throw new CollectionError(capabilityCode(result.capability));
66
+ deps.onNginxSources?.(result.sources);
67
+ return { sources: result.sources };
68
+ }
69
+ default:
70
+ return null;
71
+ }
72
+ }
73
+ return {
74
+ get active() { return active; },
75
+ async handle(frame) {
76
+ const action = frame.action;
77
+ if (!SUPPORTED_ACTIONS.some((supported) => supported === action)) {
78
+ deps.log("warn", "request_unsupported_action", { action });
79
+ return null;
80
+ }
81
+ if (active >= maxConcurrent) {
82
+ deps.log("warn", "request_rejected_busy", { action });
83
+ return errorResponse(frame.request_id, "internal", "busy");
84
+ }
85
+ active++;
86
+ const controller = new AbortController();
87
+ const timer = setTimeout(() => controller.abort(new CollectionError("timeout")), timeoutMs);
88
+ const startedAt = Date.now();
89
+ try {
90
+ const payload = await dispatch(frame, controller.signal);
91
+ if (!payload) {
92
+ deps.log("warn", "request_unsupported_action", { action });
93
+ return null;
94
+ }
95
+ deps.log("info", "request_completed", { action, duration_ms: Date.now() - startedAt });
96
+ return successResponse(frame.request_id, payload);
97
+ }
98
+ catch (error) {
99
+ const mapped = responseError(error);
100
+ deps.log("warn", "request_failed", { action, code: mapped.code, duration_ms: Date.now() - startedAt });
101
+ return errorResponse(frame.request_id, mapped.code, mapped.message);
102
+ }
103
+ finally {
104
+ clearTimeout(timer);
105
+ active--;
106
+ }
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,64 @@
1
+ import type { ConfigFile } from "../config.js";
2
+ export declare const AGENT_BIN_NAME = "theseeker-agent";
3
+ /** Skips every `launchctl` / `systemctl` invocation so installer QA never touches the real login session. */
4
+ export declare const SKIP_SERVICE_MANAGER_ENV = "THESEEKER_AGENT_SKIP_SERVICE_MANAGER";
5
+ /** Structurally compatible with `process.env` without depending on the app's augmented `ProcessEnv`. */
6
+ export type EnvironmentVariables = Readonly<Record<string, string | undefined>>;
7
+ export interface ExecResult {
8
+ readonly code: number;
9
+ readonly stdout: string;
10
+ readonly stderr: string;
11
+ }
12
+ export type ExecRunner = (file: string, args: readonly string[]) => Promise<ExecResult>;
13
+ /** Every filesystem effect of the installer goes through this seam so unit tests can assert on generated content. */
14
+ export interface InstallFileSystem {
15
+ read(path: string): Promise<string | null>;
16
+ write(path: string, content: string, mode: number): Promise<void>;
17
+ mkdir(path: string, mode: number): Promise<void>;
18
+ remove(path: string): Promise<void>;
19
+ exists(path: string): Promise<boolean>;
20
+ chown(path: string, uid: number, gid: number): Promise<void>;
21
+ chmod(path: string, mode: number): Promise<void>;
22
+ }
23
+ /** Writes the agent config file and returns the path it landed on. */
24
+ export type ConfigWriter = (config: ConfigFile, target: {
25
+ readonly platform: string;
26
+ readonly home: string;
27
+ }) => Promise<string>;
28
+ export interface InstallEnvironment {
29
+ readonly platform: string;
30
+ readonly env: EnvironmentVariables;
31
+ readonly home: string;
32
+ readonly uid: number;
33
+ readonly execPath: string;
34
+ readonly exec: ExecRunner;
35
+ readonly fs: InstallFileSystem;
36
+ readonly writeConfig: ConfigWriter;
37
+ readonly print: (line: string) => void;
38
+ }
39
+ export declare function createInstallEnvironment(overrides?: Partial<InstallEnvironment>): InstallEnvironment;
40
+ export interface Detection {
41
+ readonly platform: string;
42
+ readonly isRoot: boolean;
43
+ readonly hasSystemctl: boolean;
44
+ readonly user: string;
45
+ readonly group: string;
46
+ readonly userHome: string;
47
+ readonly nodePath: string;
48
+ readonly binPath: string;
49
+ readonly pm2Home: string;
50
+ }
51
+ export interface DetectOptions {
52
+ readonly user?: string | undefined;
53
+ readonly pm2Home?: string | undefined;
54
+ }
55
+ /** True when the installer may skip service-manager calls (QA and unit tests). */
56
+ export declare function skipsServiceManager(environment: InstallEnvironment): boolean;
57
+ /** `npm root -g` reports `<prefix>/lib/node_modules`; the global bin directory is `<prefix>/bin`. */
58
+ export declare function globalBinDirectory(npmRoot: string, nodePath: string): string;
59
+ export declare function detect(environment: InstallEnvironment, options?: DetectOptions): Promise<Detection>;
60
+ /** Numeric ids of the service user, or `null` when the user does not exist on this host. */
61
+ export declare function resolveUserIds(environment: InstallEnvironment, user: string): Promise<{
62
+ readonly uid: number;
63
+ readonly gid: number;
64
+ } | null>;
@@ -0,0 +1,154 @@
1
+ import { execFile } from "node:child_process";
2
+ import { chmod, chown, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { homedir, platform as currentPlatform } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { writableConfigPath, writeConfigFile } from "../config.js";
6
+ export const AGENT_BIN_NAME = "theseeker-agent";
7
+ /** Skips every `launchctl` / `systemctl` invocation so installer QA never touches the real login session. */
8
+ export const SKIP_SERVICE_MANAGER_ENV = "THESEEKER_AGENT_SKIP_SERVICE_MANAGER";
9
+ const realExec = (file, args) => new Promise((resolve) => {
10
+ execFile(file, [...args], { timeout: 20000, maxBuffer: 8 * 1024 * 1024, shell: false, encoding: "utf8" }, (error, stdout, stderr) => {
11
+ const raw = error && "code" in error ? error.code : undefined;
12
+ resolve({ code: typeof raw === "number" ? raw : error ? 1 : 0, stdout: String(stdout), stderr: String(stderr) });
13
+ });
14
+ });
15
+ const realFs = {
16
+ async read(path) {
17
+ try {
18
+ return await readFile(path, "utf8");
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ },
24
+ async write(path, content, mode) {
25
+ await mkdir(dirname(path), { recursive: true });
26
+ await writeFile(path, content, { mode });
27
+ await chmod(path, mode);
28
+ },
29
+ async mkdir(path, mode) {
30
+ await mkdir(path, { recursive: true, mode });
31
+ },
32
+ async remove(path) {
33
+ await rm(path, { force: true, recursive: false });
34
+ },
35
+ async exists(path) {
36
+ try {
37
+ await stat(path);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ },
44
+ async chown(path, uid, gid) {
45
+ await chown(path, uid, gid);
46
+ },
47
+ async chmod(path, mode) {
48
+ await chmod(path, mode);
49
+ },
50
+ };
51
+ const realConfigWriter = async (config, target) => {
52
+ const path = await writableConfigPath({ platform: target.platform, home: target.home });
53
+ await writeConfigFile(config, path);
54
+ return path;
55
+ };
56
+ export function createInstallEnvironment(overrides = {}) {
57
+ return {
58
+ platform: overrides.platform ?? currentPlatform(),
59
+ env: overrides.env ?? process.env,
60
+ home: overrides.home ?? process.env["HOME"] ?? homedir(),
61
+ uid: overrides.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0),
62
+ execPath: overrides.execPath ?? process.execPath,
63
+ exec: overrides.exec ?? realExec,
64
+ fs: overrides.fs ?? realFs,
65
+ writeConfig: overrides.writeConfig ?? realConfigWriter,
66
+ print: overrides.print ?? ((line) => void process.stdout.write(`${line}\n`)),
67
+ };
68
+ }
69
+ /** True when the installer may skip service-manager calls (QA and unit tests). */
70
+ export function skipsServiceManager(environment) {
71
+ const flag = environment.env[SKIP_SERVICE_MANAGER_ENV];
72
+ return typeof flag === "string" && flag.trim().length > 0 && flag !== "0";
73
+ }
74
+ function invokingUser(environment) {
75
+ const login = environment.env["USER"] ?? environment.env["LOGNAME"];
76
+ return login && login.trim().length > 0 ? login.trim() : undefined;
77
+ }
78
+ async function detectServiceUser(environment, requested) {
79
+ const explicit = requested?.trim();
80
+ if (explicit)
81
+ return explicit;
82
+ const sudoUser = environment.env["SUDO_USER"]?.trim();
83
+ if (sudoUser)
84
+ return sudoUser;
85
+ const login = invokingUser(environment);
86
+ if (login)
87
+ return login;
88
+ const probe = await environment.exec("id", ["-un"]);
89
+ return probe.code === 0 && probe.stdout.trim() ? probe.stdout.trim() : "root";
90
+ }
91
+ async function detectPrimaryGroup(environment, user) {
92
+ const probe = await environment.exec("id", ["-gn", user]);
93
+ return probe.code === 0 && probe.stdout.trim() ? probe.stdout.trim() : user;
94
+ }
95
+ async function detectUserHome(environment, user) {
96
+ if (!environment.env["SUDO_USER"] && user === invokingUser(environment))
97
+ return environment.home;
98
+ if (environment.platform === "linux") {
99
+ const passwd = await environment.exec("getent", ["passwd", user]);
100
+ const fields = passwd.code === 0 ? passwd.stdout.trim().split(":") : [];
101
+ const home = fields[5];
102
+ if (home)
103
+ return home;
104
+ return user === "root" ? "/root" : join("/home", user);
105
+ }
106
+ if (user === "root")
107
+ return "/var/root";
108
+ return join("/Users", user);
109
+ }
110
+ /** `npm root -g` reports `<prefix>/lib/node_modules`; the global bin directory is `<prefix>/bin`. */
111
+ export function globalBinDirectory(npmRoot, nodePath) {
112
+ const root = npmRoot.trim();
113
+ const suffix = "/lib/node_modules";
114
+ if (root.length > suffix.length && root.endsWith(suffix))
115
+ return join(root.slice(0, root.length - suffix.length), "bin");
116
+ if (root.endsWith("/node_modules"))
117
+ return join(dirname(root), "bin");
118
+ return dirname(nodePath);
119
+ }
120
+ async function detectBinPath(environment, nodePath) {
121
+ const probe = await environment.exec("npm", ["root", "-g"]);
122
+ const directory = globalBinDirectory(probe.code === 0 ? probe.stdout : "", nodePath);
123
+ return join(directory, AGENT_BIN_NAME);
124
+ }
125
+ export async function detect(environment, options = {}) {
126
+ const user = await detectServiceUser(environment, options.user);
127
+ const [group, userHome, binPath] = await Promise.all([
128
+ detectPrimaryGroup(environment, user),
129
+ detectUserHome(environment, user),
130
+ detectBinPath(environment, environment.execPath),
131
+ ]);
132
+ const systemctl = environment.platform === "linux" ? await environment.exec("systemctl", ["--version"]) : { code: 1, stdout: "", stderr: "" };
133
+ const pm2Home = options.pm2Home?.trim() || environment.env["PM2_HOME"]?.trim() || join(userHome, ".pm2");
134
+ return {
135
+ platform: environment.platform,
136
+ isRoot: environment.uid === 0,
137
+ hasSystemctl: systemctl.code === 0,
138
+ user,
139
+ group,
140
+ userHome,
141
+ nodePath: environment.execPath,
142
+ binPath,
143
+ pm2Home,
144
+ };
145
+ }
146
+ /** Numeric ids of the service user, or `null` when the user does not exist on this host. */
147
+ export async function resolveUserIds(environment, user) {
148
+ const [uidProbe, gidProbe] = await Promise.all([environment.exec("id", ["-u", user]), environment.exec("id", ["-g", user])]);
149
+ if (uidProbe.code !== 0 || gidProbe.code !== 0)
150
+ return null;
151
+ const uid = Number.parseInt(uidProbe.stdout.trim(), 10);
152
+ const gid = Number.parseInt(gidProbe.stdout.trim(), 10);
153
+ return Number.isFinite(uid) && Number.isFinite(gid) ? { uid, gid } : null;
154
+ }
@@ -0,0 +1,25 @@
1
+ import type { EnvironmentVariables, InstallEnvironment } from "./detect.js";
2
+ /** Baked at build time from `AGENT_PUBLIC_WS_URL`; `--endpoint` and the env var still win. */
3
+ export declare const DEFAULT_AGENT_ENDPOINT = "wss://ingest.theseeker.io/agent/ws";
4
+ export declare const ENDPOINT_BUILD_ENV = "AGENT_PUBLIC_WS_URL";
5
+ export declare const NOT_ROOT_EXIT_CODE = 2;
6
+ export declare const INSTALL_USAGE: string;
7
+ export declare class InstallArgumentError extends Error {
8
+ constructor(message: string);
9
+ }
10
+ export interface InstallArgs {
11
+ readonly token: string;
12
+ readonly endpoint: string;
13
+ readonly user?: string | undefined;
14
+ readonly pm2Home?: string | undefined;
15
+ readonly addGroups: boolean;
16
+ }
17
+ export declare function parseInstallArgs(argv: readonly string[], env?: EnvironmentVariables): InstallArgs;
18
+ export interface TargetArgs {
19
+ readonly user?: string | undefined;
20
+ readonly pm2Home?: string | undefined;
21
+ }
22
+ export declare function parseTargetArgs(argv: readonly string[]): TargetArgs;
23
+ export declare function runInstall(argv: readonly string[], environment: InstallEnvironment): Promise<number>;
24
+ export declare function runUninstall(argv: readonly string[], environment: InstallEnvironment): Promise<number>;
25
+ export declare function runStatus(argv: readonly string[], environment: InstallEnvironment): Promise<number>;
@@ -0,0 +1,169 @@
1
+ import { dirname } from "node:path";
2
+ import { CONFIG_DIR_MODE, systemConfigPath, userConfigPath } from "../config.js";
3
+ import { detect, resolveUserIds } from "./detect.js";
4
+ import { installLaunchd, statusLaunchd, uninstallLaunchd } from "./launchd.js";
5
+ import { installSystemd, manualInstallCommands, SERVICE_NAME, statusSystemd, uninstallSystemd } from "./systemd.js";
6
+ /** Baked at build time from `AGENT_PUBLIC_WS_URL`; `--endpoint` and the env var still win. */
7
+ export const DEFAULT_AGENT_ENDPOINT = "wss://ingest.theseeker.io/agent/ws";
8
+ export const ENDPOINT_BUILD_ENV = "AGENT_PUBLIC_WS_URL";
9
+ export const NOT_ROOT_EXIT_CODE = 2;
10
+ export const INSTALL_USAGE = [
11
+ `${SERVICE_NAME} install --token <sa_...> [options]`,
12
+ "",
13
+ "Install options:",
14
+ " --token <sa_...> agent token issued in the dashboard (required)",
15
+ ` --endpoint <url> gateway websocket URL (default ${DEFAULT_AGENT_ENDPOINT})`,
16
+ " --user <name> service user (default $SUDO_USER, then $USER)",
17
+ " --pm2-home <path> PM2_HOME of that user (default <user home>/.pm2)",
18
+ " --add-groups add SupplementaryGroups=adm systemd-journal to the systemd unit",
19
+ ].join("\n");
20
+ export class InstallArgumentError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "InstallArgumentError";
24
+ }
25
+ }
26
+ const VALUE_FLAGS = ["--token", "--endpoint", "--user", "--pm2-home"];
27
+ function parseFlags(argv, allowed) {
28
+ const values = new Map();
29
+ for (let index = 0; index < argv.length; index += 1) {
30
+ const argument = argv[index] ?? "";
31
+ const separator = argument.indexOf("=");
32
+ const name = separator === -1 ? argument : argument.slice(0, separator);
33
+ if (!allowed.includes(name))
34
+ throw new InstallArgumentError(`unknown option: ${argument}`);
35
+ if (name === "--add-groups") {
36
+ values.set(name, "true");
37
+ continue;
38
+ }
39
+ if (separator !== -1) {
40
+ values.set(name, argument.slice(separator + 1));
41
+ continue;
42
+ }
43
+ const next = argv[index + 1];
44
+ if (next === undefined || next.startsWith("--"))
45
+ throw new InstallArgumentError(`${name} requires a value`);
46
+ values.set(name, next);
47
+ index += 1;
48
+ }
49
+ return values;
50
+ }
51
+ export function parseInstallArgs(argv, env = {}) {
52
+ const flags = parseFlags(argv, [...VALUE_FLAGS, "--add-groups"]);
53
+ const token = flags.get("--token")?.trim();
54
+ if (!token)
55
+ throw new InstallArgumentError("--token is required");
56
+ const endpoint = flags.get("--endpoint")?.trim() || env[ENDPOINT_BUILD_ENV]?.trim() || DEFAULT_AGENT_ENDPOINT;
57
+ return {
58
+ token,
59
+ endpoint,
60
+ user: flags.get("--user")?.trim() || undefined,
61
+ pm2Home: flags.get("--pm2-home")?.trim() || undefined,
62
+ addGroups: flags.has("--add-groups"),
63
+ };
64
+ }
65
+ export function parseTargetArgs(argv) {
66
+ const flags = parseFlags(argv, ["--user", "--pm2-home"]);
67
+ return { user: flags.get("--user")?.trim() || undefined, pm2Home: flags.get("--pm2-home")?.trim() || undefined };
68
+ }
69
+ function unsupportedPlatform(environment) {
70
+ if (environment.platform === "linux" || environment.platform === "darwin")
71
+ return false;
72
+ environment.print(`unsupported platform: ${environment.platform} (linux and darwin only)`);
73
+ return true;
74
+ }
75
+ async function applyOwnership(environment, detection, configPath) {
76
+ const ids = await resolveUserIds(environment, detection.user);
77
+ if (!ids) {
78
+ environment.print(`warning: user ${detection.user} not found, left ${configPath} owned by root`);
79
+ return;
80
+ }
81
+ const directory = dirname(configPath);
82
+ await environment.fs.chown(directory, ids.uid, ids.gid);
83
+ await environment.fs.chmod(directory, CONFIG_DIR_MODE);
84
+ await environment.fs.chown(configPath, ids.uid, ids.gid);
85
+ environment.print(`owner: ${detection.user} (${ids.uid}:${ids.gid}) on ${directory} (0750) and ${configPath} (0600)`);
86
+ }
87
+ async function removeConfigFiles(environment, detection) {
88
+ const candidates = environment.platform === "linux" ? [systemConfigPath(), userConfigPath(detection.userHome)] : [userConfigPath(detection.userHome)];
89
+ for (const candidate of candidates) {
90
+ if (!(await environment.fs.exists(candidate)))
91
+ continue;
92
+ await environment.fs.remove(candidate);
93
+ environment.print(`removed config: ${candidate}`);
94
+ }
95
+ }
96
+ export async function runInstall(argv, environment) {
97
+ let args;
98
+ try {
99
+ args = parseInstallArgs(argv, environment.env);
100
+ }
101
+ catch (error) {
102
+ environment.print(`${error instanceof Error ? error.message : "invalid arguments"}\n\n${INSTALL_USAGE}`);
103
+ return 1;
104
+ }
105
+ if (unsupportedPlatform(environment))
106
+ return 1;
107
+ const detection = await detect(environment, { user: args.user, pm2Home: args.pm2Home });
108
+ if (environment.platform === "linux") {
109
+ if (!detection.isRoot) {
110
+ for (const line of manualInstallCommands(args.endpoint, detection.user, args.addGroups))
111
+ environment.print(line);
112
+ return NOT_ROOT_EXIT_CODE;
113
+ }
114
+ if (!detection.hasSystemctl) {
115
+ environment.print("systemctl was not found: install the agent with your own supervisor and run `theseeker-agent run`.");
116
+ return 1;
117
+ }
118
+ }
119
+ const config = { endpoint: args.endpoint, token: args.token, pm2Home: detection.pm2Home, logLevel: "info" };
120
+ const configPath = await environment.writeConfig(config, { platform: environment.platform, home: detection.userHome });
121
+ environment.print(`wrote config: ${configPath} (0600)`);
122
+ environment.print(`endpoint: ${args.endpoint}`);
123
+ environment.print(`service user: ${detection.user} (${detection.group}), PM2_HOME=${detection.pm2Home}`);
124
+ if (detection.isRoot)
125
+ await applyOwnership(environment, detection, configPath);
126
+ if (environment.platform === "linux")
127
+ return installSystemd(environment, { detection, addGroups: args.addGroups, endpoint: args.endpoint });
128
+ return installLaunchd(environment, { detection });
129
+ }
130
+ export async function runUninstall(argv, environment) {
131
+ let args;
132
+ try {
133
+ args = parseTargetArgs(argv);
134
+ }
135
+ catch (error) {
136
+ environment.print(error instanceof Error ? error.message : "invalid arguments");
137
+ return 1;
138
+ }
139
+ if (unsupportedPlatform(environment))
140
+ return 1;
141
+ const detection = await detect(environment, { user: args.user, pm2Home: args.pm2Home });
142
+ if (environment.platform === "linux") {
143
+ if (!detection.isRoot) {
144
+ environment.print("uninstall requires root. Run:");
145
+ environment.print(` sudo ${SERVICE_NAME} uninstall --user ${detection.user}`);
146
+ return NOT_ROOT_EXIT_CODE;
147
+ }
148
+ const code = await uninstallSystemd(environment);
149
+ await removeConfigFiles(environment, detection);
150
+ return code;
151
+ }
152
+ const code = await uninstallLaunchd(environment, detection);
153
+ await removeConfigFiles(environment, detection);
154
+ return code;
155
+ }
156
+ export async function runStatus(argv, environment) {
157
+ let args;
158
+ try {
159
+ args = parseTargetArgs(argv);
160
+ }
161
+ catch (error) {
162
+ environment.print(error instanceof Error ? error.message : "invalid arguments");
163
+ return 1;
164
+ }
165
+ if (unsupportedPlatform(environment))
166
+ return 1;
167
+ const detection = await detect(environment, { user: args.user, pm2Home: args.pm2Home });
168
+ return environment.platform === "linux" ? statusSystemd(environment) : statusLaunchd(environment, detection);
169
+ }