@lifeaitools/clauth 1.30.24 → 1.30.26

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.
@@ -0,0 +1,80 @@
1
+ const TERMINAL = new Map([
2
+ ["finished", "succeeded"], ["success", "succeeded"], ["successful", "succeeded"],
3
+ ["failed", "failed"], ["error", "failed"], ["cancelled", "failed"],
4
+ ]);
5
+
6
+ function required(value, name) {
7
+ if (!value) throw new Error(`${name} is required`);
8
+ return value;
9
+ }
10
+
11
+ export function normalizeCoolifyState(value) {
12
+ const raw = String(value || "unknown").toLowerCase();
13
+ return TERMINAL.get(raw) || (raw.includes("progress") || raw.includes("queue") || raw.includes("running") ? "running" : "unknown");
14
+ }
15
+
16
+ /**
17
+ * Extract the deployment UUID from a Coolify deploy response.
18
+ *
19
+ * Coolify's `/api/v1/deploy` does NOT return the UUID at the top level — it
20
+ * answers with a `deployments` ARRAY:
21
+ *
22
+ * { "deployments": [ { "message": "...", "resource_uuid": "...",
23
+ * "deployment_uuid": "ha9xjqcldy7duf2diyjdc20p" } ] }
24
+ *
25
+ * Reading only `body.deployment_uuid` therefore yields undefined, the caller
26
+ * concludes the promotion never started, and the job is marked failed — while
27
+ * Coolify is in fact building. That happened on a real life.ai promote:
28
+ * deployment ha9xjqcldy7duf2diyjdc20p was created at the same second the job
29
+ * reported failure. A failed job invites a retry, so the bug turns one
30
+ * production deploy into several.
31
+ *
32
+ * Accepts the array shape first, then the flat shapes, so a future/simplified
33
+ * response still resolves.
34
+ */
35
+ export function deploymentUuidFrom(body) {
36
+ if (!body || typeof body !== "object") return null;
37
+ const list = Array.isArray(body.deployments) ? body.deployments : [];
38
+ for (const entry of list) {
39
+ if (!entry || typeof entry !== "object") continue;
40
+ const nested = entry.deployment_uuid || entry.uuid || entry.id;
41
+ if (typeof nested === "string" && nested) return nested;
42
+ }
43
+ const flat = body.deployment_uuid || body.uuid || body.id;
44
+ return typeof flat === "string" && flat ? flat : null;
45
+ }
46
+
47
+ export function createCoolifyAdapter({ baseUrl, getToken, fetchImpl = globalThis.fetch } = {}) {
48
+ const api = String(required(baseUrl, "baseUrl")).replace(/\/$/, "");
49
+ if (typeof getToken !== "function") throw new Error("getToken is required");
50
+ async function request(path, options = {}) {
51
+ const token = await getToken();
52
+ if (!token) throw new Error("coolify credential unavailable");
53
+ const headers = new Headers(options.headers || {});
54
+ headers.set("Accept", "application/json");
55
+ headers.set("Authorization", `Bearer ${token}`);
56
+ const response = await fetchImpl(`${api}${path}`, {
57
+ ...options,
58
+ headers,
59
+ });
60
+ if (!response.ok) throw new Error(`Coolify HTTP ${response.status}`);
61
+ return response.json();
62
+ }
63
+ return {
64
+ inspect(applicationUuid) { return request(`/api/v1/applications/${encodeURIComponent(required(applicationUuid, "applicationUuid"))}`); },
65
+ async promote(applicationUuid, options = {}) {
66
+ const uuid = encodeURIComponent(required(applicationUuid, "applicationUuid"));
67
+ return request(`/api/v1/deploy?uuid=${uuid}&force=true`, options);
68
+ },
69
+ async deployment(deploymentUuid) { return request(`/api/v1/deployments/${encodeURIComponent(required(deploymentUuid, "deploymentUuid"))}`); },
70
+ async poll(deploymentUuid, { attempts = 30, delay = async () => {} } = {}) {
71
+ for (let attempt = 1; attempt <= attempts; attempt++) {
72
+ const deployment = await this.deployment(deploymentUuid);
73
+ const state = normalizeCoolifyState(deployment.status || deployment.state);
74
+ if (state === "succeeded" || state === "failed") return { state, deployment, attempts: attempt };
75
+ await delay(attempt);
76
+ }
77
+ return { state: "timed_out", deployment: null, attempts };
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,63 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
2
+
3
+ function execFile(command, args, options) {
4
+ return new Promise((resolve, reject) => execFileCallback(command, args, { ...options, windowsHide: true }, (error, stdout, stderr) => {
5
+ if (error) return reject(new Error(`${command} ${args.join(" ")}: ${String(stderr || error.message).trim()}`));
6
+ resolve({ stdout: String(stdout || "").trim(), stderr: String(stderr || "").trim() });
7
+ }));
8
+ }
9
+
10
+ function requireString(value, name) {
11
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required`);
12
+ return value.trim();
13
+ }
14
+
15
+ function validateApplication(config) {
16
+ if (!config || typeof config !== "object") throw new Error("deployment_not_registered");
17
+ requireString(config.repo_path, "deployment.repo_path");
18
+ requireString(config.pm2_name, "deployment.pm2_name");
19
+ if (!Array.isArray(config.build) || !config.build.every((value) => typeof value === "string" && value)) throw new Error("deployment.build must be a non-empty argv array");
20
+ if (!Array.isArray(config.allowed_refs) || !config.allowed_refs.every((value) => typeof value === "string" && value)) throw new Error("deployment.allowed_refs must be a string array");
21
+ if (!requireString(config.health_url, "deployment.health_url").startsWith("http")) throw new Error("deployment.health_url must be HTTP(S)");
22
+ }
23
+
24
+ export function parseDeploymentRegistry(raw = "{}") {
25
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
26
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("CLAUTH_OPS_DEPLOYMENTS must be a JSON object");
27
+ for (const config of Object.values(parsed)) validateApplication(config);
28
+ return parsed;
29
+ }
30
+
31
+ export function createDeploymentAdapter({ deployments, run = execFile, fetch = globalThis.fetch, delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), reload }) {
32
+ if (typeof reload !== "function") throw new Error("reload callback is required");
33
+ return {
34
+ async deploy({ application, ref }) {
35
+ const name = requireString(application, "application");
36
+ const config = deployments[name];
37
+ validateApplication(config);
38
+ const requestedRef = requireString(ref || config.default_ref, "ref");
39
+ if (!config.allowed_refs.includes(requestedRef)) throw new Error("deployment_ref_not_allowed");
40
+ const cwd = config.repo_path;
41
+ const steps = [];
42
+ await run("git", ["fetch", "origin", requestedRef], { cwd });
43
+ steps.push("fetched");
44
+ const sha = (await run("git", ["rev-parse", "FETCH_HEAD"], { cwd })).stdout;
45
+ if (!/^[0-9a-f]{7,64}$/i.test(sha)) throw new Error("deployment_invalid_git_sha");
46
+ await run("git", ["checkout", "--detach", sha], { cwd });
47
+ steps.push("checked_out");
48
+ await run(config.build[0], config.build.slice(1), { cwd });
49
+ steps.push("built");
50
+ await reload(config.pm2_name);
51
+ steps.push("reloaded");
52
+ const attempts = Number(config.health_attempts || 30);
53
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
54
+ try {
55
+ const response = await fetch(config.health_url, { signal: AbortSignal.timeout(Number(config.health_timeout_ms || 5000)) });
56
+ if (response.ok) return { application: name, ref: requestedRef, sha, health_url: config.health_url, attempts: attempt, steps };
57
+ } catch {}
58
+ await delay(Number(config.health_delay_ms || 1000));
59
+ }
60
+ throw new Error("deployment_health_check_timed_out");
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,116 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const TERMINAL = new Set(["succeeded", "failed", "timed_out", "rejected"]);
6
+
7
+ export function createJobStore({ now = () => new Date().toISOString(), maxJobs = 500, filePath = null } = {}) {
8
+ const { jobs, recovered } = load(filePath, maxJobs, now);
9
+ const subscribers = new Map();
10
+ const persist = () => {
11
+ if (!filePath) return;
12
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
13
+ const tmp = `${filePath}.${process.pid}.tmp`;
14
+ fs.writeFileSync(tmp, JSON.stringify({ schema: "clauth.ops.jobs.v1", jobs: [...jobs.values()].map(snapshot) }), { mode: 0o600 });
15
+ fs.renameSync(tmp, filePath);
16
+ };
17
+ if (recovered) persist();
18
+ const publish = (id) => {
19
+ const value = get(id);
20
+ for (const listener of subscribers.get(id) || []) listener(value);
21
+ };
22
+ const create = ({ kind, operation, target = null }) => {
23
+ const id = crypto.randomUUID();
24
+ const job = { id, kind, operation, target, phase: "queued", created_at: now(), updated_at: now(), events: [] };
25
+ jobs.set(id, job);
26
+ event(id, "queued");
27
+ while (jobs.size > maxJobs) jobs.delete(jobs.keys().next().value);
28
+ persist();
29
+ return snapshot(job);
30
+ };
31
+ const event = (id, phase, detail = null) => {
32
+ const job = jobs.get(id);
33
+ if (!job) return null;
34
+ if (TERMINAL.has(job.phase)) throw new Error(`job already terminal: ${job.phase}`);
35
+ job.phase = phase;
36
+ job.updated_at = now();
37
+ job.events.push({ phase, at: job.updated_at, detail: detail == null ? null : sanitize(detail) });
38
+ persist();
39
+ publish(id);
40
+ return snapshot(job);
41
+ };
42
+ const get = (id) => jobs.has(id) ? snapshot(jobs.get(id)) : null;
43
+ const subscribe = (id, listener) => {
44
+ if (!jobs.has(id)) return null;
45
+ const listeners = subscribers.get(id) || new Set();
46
+ subscribers.set(id, listeners);
47
+ listeners.add(listener);
48
+ listener(get(id));
49
+ return () => {
50
+ listeners.delete(listener);
51
+ if (!listeners.size) subscribers.delete(id);
52
+ };
53
+ };
54
+ return { create, event, get, subscribe };
55
+ }
56
+
57
+ function load(filePath, maxJobs, now) {
58
+ if (!filePath || !fs.existsSync(filePath)) return { jobs: new Map(), recovered: false };
59
+ try {
60
+ const stored = JSON.parse(fs.readFileSync(filePath, "utf8"));
61
+ const values = Array.isArray(stored?.jobs) ? stored.jobs.slice(-maxJobs) : [];
62
+ let recovered = false;
63
+ const restored = values.filter((job) => job && typeof job.id === "string").map((job) => {
64
+ const safe = snapshot(job);
65
+ if (!TERMINAL.has(safe.phase)) {
66
+ recovered = true;
67
+ safe.phase = "failed";
68
+ safe.updated_at = now();
69
+ safe.events.push({ phase: "failed", at: safe.updated_at, detail: { code: "daemon_restarted" } });
70
+ }
71
+ return [safe.id, safe];
72
+ });
73
+ return { jobs: new Map(restored), recovered };
74
+ } catch {
75
+ return { jobs: new Map(), recovered: false };
76
+ }
77
+ }
78
+
79
+ function sanitize(value) {
80
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
81
+ // `error` is deliberately NOT allowlisted. Free-form error strings are the
82
+ // classic credential-leak path -- an upstream message like
83
+ // `API_KEY=... rejected` would be persisted verbatim to disk and served over
84
+ // the API. ops-job-store.test.mjs pins this behaviour with an explicit
85
+ // API_KEY=hidden payload; do not "fix" a missing failure reason by adding
86
+ // `error` here.
87
+ //
88
+ // Producers that need a machine-readable failure reason must emit `code` with
89
+ // an enumerated value (see submitPromotionJob's coolify_* codes). That keeps
90
+ // diagnosis available without letting arbitrary upstream text through.
91
+ const allowed = new Set(["code", "checkout_sha", "ref", "pm2_name", "health_url", "health_status", "deployment_uuid", "status", "processes"]);
92
+ return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
93
+ if (!allowed.has(key)) return [];
94
+ if (key === "processes" && Array.isArray(entry)) {
95
+ return [[key, entry.filter((process) => process && typeof process === "object").map((process) => ({
96
+ name: typeof process.name === "string" ? process.name : null,
97
+ pm_id: typeof process.pm_id === "number" ? process.pm_id : null,
98
+ pid: typeof process.pid === "number" ? process.pid : null,
99
+ status: typeof process.status === "string" ? process.status : null,
100
+ namespace: typeof process.namespace === "string" ? process.namespace : null,
101
+ }))]];
102
+ }
103
+ return (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean" || entry === null) ? [[key, entry]] : [];
104
+ }));
105
+ }
106
+
107
+ function snapshot(job) {
108
+ return JSON.parse(JSON.stringify({
109
+ ...job,
110
+ events: job.events.map((event) => ({
111
+ phase: event.phase,
112
+ at: event.at,
113
+ detail: sanitize(event.detail),
114
+ })),
115
+ }));
116
+ }
@@ -0,0 +1,51 @@
1
+ import { PM2_OPERATION_CATALOG, isReadOperation } from "./pm2-adapter.js";
2
+
3
+ function configuredProfile(config, role) {
4
+ if (config?.[role] && typeof config[role] === "object" && !Array.isArray(config[role])) return config[role];
5
+ return config && typeof config === "object" && !Array.isArray(config) ? config : {};
6
+ }
7
+
8
+ function targetFrom(input = {}) {
9
+ const value = input.target || input.name || input.options?.name || (Array.isArray(input.args) ? input.args[0] : null);
10
+ return value == null ? null : String(value);
11
+ }
12
+
13
+ function enabled(enabled, operation) {
14
+ return enabled.has(operation) || enabled.has("*");
15
+ }
16
+
17
+ export function createOperationPolicy({ enabled: agentEnabled = [], applications = {}, adminEnabled = [], adminApplications = {}, allowHostWide = false } = {}) {
18
+ const agentEnabledSet = new Set(agentEnabled);
19
+ const adminEnabledSet = new Set(adminEnabled);
20
+ return {
21
+ authorize(operation, input = {}, role = "agent") {
22
+ if (!PM2_OPERATION_CATALOG[operation]) return { ok: false, code: "service_not_available" };
23
+ const isAdmin = role === "admin";
24
+ const profile = configuredProfile(isAdmin ? adminApplications : applications, role);
25
+ const allowed = Array.isArray(profile[operation])
26
+ ? profile[operation].filter((target) => typeof target === "string" && target.length > 0)
27
+ : [];
28
+ const target = targetFrom(input);
29
+ const hostWide = operation === "kill_daemon" || operation === "pm2_killDaemon" || target === "all";
30
+
31
+ // The public catalog remains complete. Only the remote admin capability
32
+ // may execute raw PM2 calls or host-level lifecycle actions, and every
33
+ // one still has to be enabled and allowlisted in Vultr configuration.
34
+ if (!isAdmin && (operation.startsWith("pm2_") || ["start", "dump", "resurrect", "kill_daemon", "startup", "unstartup", "update", "deep_update"].includes(operation))) {
35
+ return { ok: false, code: "service_not_available" };
36
+ }
37
+ if (hostWide && (!isAdmin || !allowHostWide)) return { ok: false, code: "service_not_available" };
38
+ if (!isReadOperation(operation) && !enabled(isAdmin ? adminEnabledSet : agentEnabledSet, operation)) {
39
+ return { ok: false, code: "service_not_available" };
40
+ }
41
+ if (!allowed.length) return { ok: false, code: "service_not_available" };
42
+ if (hostWide) {
43
+ if (!allowed.includes("*") && !allowed.includes("all")) return { ok: false, code: "service_not_available" };
44
+ return { ok: true, mode: isReadOperation(operation) ? "read" : "mutate", allowed_targets: allowed };
45
+ }
46
+ if (operation === "list" || operation === "ping") return { ok: true, mode: isReadOperation(operation) ? "read" : "mutate", allowed_targets: allowed };
47
+ if (!target || (!allowed.includes("*") && !allowed.includes(target))) return { ok: false, code: "service_not_available" };
48
+ return { ok: true, mode: isReadOperation(operation) ? "read" : "mutate", allowed_targets: allowed };
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,128 @@
1
+ const READ_OPERATIONS = new Set(["list", "describe", "ping", "logs", "bus", "pm2_list", "pm2_describe", "pm2_ping", "pm2_get", "pm2_getPID", "pm2_getVersion", "pm2_inspect", "pm2_jlist", "pm2_slist", "pm2_monitorState"]);
2
+
3
+ // Derived from PM2 v7's public API prototype. We intentionally expose every
4
+ // method through a stable prefixed operation name; potentially destructive or
5
+ // interactive methods stay policy-disabled unless an operator explicitly opts in.
6
+ export const PM2_PUBLIC_METHODS = Object.freeze([
7
+ "agentInfos", "attach", "autodump", "autoinstall", "backward", "boilerplate", "clearDump", "clearSetup", "close", "conf", "connect", "dashboard", "deepUpdate", "delete", "deleteModule", "deploy", "describe", "destroy", "disconnect", "dockerMode", "dump", "env", "exitCli", "flush", "forward", "generateDockerfile", "generateModuleSample", "generateSample", "get", "getPID", "getProcessIdByName", "getVersion", "inspect", "install", "jlist", "kill", "killAgent", "killDaemon", "launchAll", "launchBus", "launchModules", "launchSysMonitoring", "link", "linkManagement", "list", "logrotate", "minimumSetup", "monit", "monitorState", "msgProcess", "multiset", "openDashboard", "package", "ping", "printLogs", "profile", "publish", "pullAndReload", "pullAndRestart", "pullCommitId", "reload", "reloadLogs", "remote", "remoteV2", "report", "reset", "restart", "resurrect", "scale", "sendDataToProcessId", "sendLineToStdin", "sendSignalToProcessId", "sendSignalToProcessName", "serve", "set", "slist", "speedList", "start", "startup", "stop", "streamLogs", "trigger", "uninstall", "uninstallStartup", "unlink", "unset", "update",
8
+ ]);
9
+
10
+ const CORE_OPERATIONS = {
11
+ list: { mode: "read", args: [] },
12
+ describe: { mode: "read", args: ["target"] },
13
+ ping: { mode: "read", args: [] },
14
+ logs: { mode: "read", args: ["target"] },
15
+ bus: { mode: "read", args: [] },
16
+ start: { mode: "mutate", args: ["script", "options"] },
17
+ stop: { mode: "mutate", args: ["target"] },
18
+ restart: { mode: "mutate", args: ["target", "options"] },
19
+ reload: { mode: "mutate", args: ["target", "options"] },
20
+ delete: { mode: "mutate", args: ["target"] },
21
+ scale: { mode: "mutate", args: ["target", "instances"] },
22
+ reset: { mode: "mutate", args: ["target"] },
23
+ dump: { mode: "mutate", args: [] },
24
+ resurrect: { mode: "mutate", args: [] },
25
+ kill_daemon: { mode: "mutate", args: [] },
26
+ startup: { mode: "mutate", args: ["platform", "options"] },
27
+ unstartup: { mode: "mutate", args: ["platform"] },
28
+ update: { mode: "mutate", args: [] },
29
+ deep_update: { mode: "mutate", args: [] },
30
+ send_data: { mode: "mutate", args: ["target", "packet"] },
31
+ send_signal: { mode: "mutate", args: ["target", "signal"] },
32
+ trigger: { mode: "mutate", args: ["target", "action", "params"] },
33
+ };
34
+
35
+ export const PM2_OPERATION_CATALOG = Object.freeze({
36
+ ...CORE_OPERATIONS,
37
+ ...Object.fromEntries(PM2_PUBLIC_METHODS.map((method) => [`pm2_${method}`, {
38
+ mode: READ_OPERATIONS.has(`pm2_${method}`) ? "read" : "mutate",
39
+ args: ["args"],
40
+ pm2_method: method,
41
+ }])),
42
+ });
43
+
44
+ function missing(value, name) {
45
+ if (value === undefined || value === null || value === "") throw new Error(`${name} is required`);
46
+ return value;
47
+ }
48
+
49
+ function callbackCall(pm2, method, args = []) {
50
+ return new Promise((resolve, reject) => {
51
+ if (typeof pm2?.[method] !== "function") return reject(new Error(`PM2 method unavailable: ${method}`));
52
+ pm2[method](...args, (error, result) => error ? reject(error) : resolve(result));
53
+ });
54
+ }
55
+
56
+ function normalizeProcess(process) {
57
+ if (!process || typeof process !== "object") return process;
58
+ return {
59
+ name: process.name || process.pm2_env?.name || null,
60
+ pm_id: process.pm_id ?? process.pm2_env?.pm_id ?? null,
61
+ pid: process.pid ?? null,
62
+ status: process.pm2_env?.status ?? process.status ?? null,
63
+ restart_time: process.pm2_env?.restart_time ?? null,
64
+ created_at: process.pm2_env?.created_at ?? null,
65
+ cwd: process.pm2_env?.pm_cwd ?? null,
66
+ script: process.pm2_env?.pm_exec_path ?? null,
67
+ namespace: process.pm2_env?.namespace ?? null,
68
+ };
69
+ }
70
+
71
+ function normalizeProcessResult(result) {
72
+ // PM2's lower-level aliases (jlist, inspect, get) can return the same rich
73
+ // process object as list/describe. Normalize it at the adapter boundary so a
74
+ // future caller cannot accidentally serialize pm2_env or other credentials.
75
+ if (Array.isArray(result)) {
76
+ return result.map((item) => item && typeof item === "object" && (item.pm2_env || "pm_id" in item || "pid" in item)
77
+ ? normalizeProcess(item)
78
+ : item);
79
+ }
80
+ if (result && typeof result === "object" && (result.pm2_env || "pm_id" in result || "pid" in result)) return normalizeProcess(result);
81
+ return result;
82
+ }
83
+
84
+ export function createPm2Adapter(pm2) {
85
+ if (!pm2) throw new Error("pm2 facade is required");
86
+ return {
87
+ catalog() { return PM2_OPERATION_CATALOG; },
88
+ async connect() { await callbackCall(pm2, "connect", [false]); return { connected: true }; },
89
+ async disconnect() { await callbackCall(pm2, "disconnect"); return { connected: false }; },
90
+ async execute(operation, input = {}) {
91
+ if (!PM2_OPERATION_CATALOG[operation]) throw new Error(`unknown PM2 operation: ${operation}`);
92
+ if (operation.startsWith("pm2_")) {
93
+ const method = PM2_OPERATION_CATALOG[operation].pm2_method;
94
+ const args = Array.isArray(input.args) ? input.args : [];
95
+ return normalizeProcessResult(await callbackCall(pm2, method, args));
96
+ }
97
+ switch (operation) {
98
+ case "list": return (await callbackCall(pm2, "list")).map(normalizeProcess);
99
+ case "describe": return (await callbackCall(pm2, "describe", [missing(input.target, "target")])).map(normalizeProcess);
100
+ case "ping": return { connected: true, processes: (await callbackCall(pm2, "list")).length };
101
+ case "logs": return (await callbackCall(pm2, "describe", [missing(input.target, "target")])).map(normalizeProcess);
102
+ case "bus": return callbackCall(pm2, "launchBus");
103
+ case "start": return callbackCall(pm2, "start", [missing(input.script, "script"), input.options || {}]);
104
+ case "stop": return callbackCall(pm2, "stop", [missing(input.target, "target")]);
105
+ case "restart": return callbackCall(pm2, "restart", [missing(input.target, "target"), input.options || {}]);
106
+ case "reload": return callbackCall(pm2, "reload", [missing(input.target, "target"), input.options || {}]);
107
+ case "delete": return callbackCall(pm2, "delete", [missing(input.target, "target")]);
108
+ case "scale": return callbackCall(pm2, "scale", [missing(input.target, "target"), missing(input.instances, "instances")]);
109
+ case "reset": return callbackCall(pm2, "reset", [missing(input.target, "target")]);
110
+ case "dump": return callbackCall(pm2, "dump");
111
+ case "resurrect": return callbackCall(pm2, "resurrect");
112
+ case "kill_daemon": return callbackCall(pm2, "killDaemon");
113
+ case "startup": return callbackCall(pm2, "startup", [input.platform, input.options || {}]);
114
+ case "unstartup": return callbackCall(pm2, "uninstallStartup", [input.platform]);
115
+ case "update": return callbackCall(pm2, "update");
116
+ case "deep_update": return callbackCall(pm2, "deepUpdate");
117
+ case "send_data": return callbackCall(pm2, "sendDataToProcessId", [missing(input.target, "target"), missing(input.packet, "packet")]);
118
+ case "send_signal": return callbackCall(pm2, "sendSignalToProcessName", [missing(input.target, "target"), input.signal || "SIGINT"]);
119
+ case "trigger": return callbackCall(pm2, "trigger", [missing(input.target, "target"), missing(input.action, "action"), input.params || {}]);
120
+ default: throw new Error(`unimplemented PM2 operation: ${operation}`);
121
+ }
122
+ },
123
+ };
124
+ }
125
+
126
+ export function isReadOperation(operation) {
127
+ return READ_OPERATIONS.has(operation);
128
+ }
@@ -0,0 +1,9 @@
1
+ export function createSerializedExecutor() {
2
+ let tail = Promise.resolve();
3
+ return function execute(task) {
4
+ if (typeof task !== "function") throw new Error("task must be a function");
5
+ const run = tail.then(task, task);
6
+ tail = run.catch(() => {});
7
+ return run;
8
+ };
9
+ }
@@ -178,7 +178,30 @@ export function readWatchdogEvents(limit = 100) {
178
178
  }
179
179
  }
180
180
 
181
- export function restartWatchdogService(id) {
181
+ async function verifyRestartHealth(service) {
182
+ if (!service.health?.url) return { ok: true, health_status: "not_configured" };
183
+
184
+ const attempts = Number(service.health.readyAttempts || 20);
185
+ const delayMs = Number(service.health.readyDelayMs || 250);
186
+ let observed;
187
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
188
+ observed = await evaluateWatchdogService(service);
189
+ if (observed.status === "healthy") {
190
+ return { ok: true, health_status: observed.status, health_http_status: observed.httpStatus, attempts: attempt };
191
+ }
192
+ if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, delayMs));
193
+ }
194
+ return {
195
+ ok: false,
196
+ error: "restart_health_unreachable",
197
+ health_status: observed?.status || "unknown",
198
+ health_http_status: observed?.httpStatus,
199
+ health_error: observed?.error,
200
+ attempts,
201
+ };
202
+ }
203
+
204
+ export async function restartWatchdogService(id) {
182
205
  const service = loadRegistry().services.find((candidate) => candidate.id === id);
183
206
  if (!service) return { ok: false, error: "service_not_registered" };
184
207
  if (!service.restart) return { ok: false, error: "restart_not_configured" };
@@ -192,18 +215,23 @@ export function restartWatchdogService(id) {
192
215
  encoding: "utf8",
193
216
  timeout: Number(service.restart.timeoutMs || 30000),
194
217
  });
218
+ const health = result.status === 0 ? await verifyRestartHealth(service) : { ok: false, health_status: "not_checked" };
195
219
  const event = {
196
220
  kind: "restart",
197
221
  service_id: id,
198
222
  status: result.status,
223
+ health_status: health.health_status,
224
+ health_http_status: health.health_http_status,
225
+ health_error: health.health_error,
199
226
  error: result.error ? result.error.message : undefined,
200
227
  };
201
228
  appendEvent(event);
202
229
  return {
203
- ok: result.status === 0,
230
+ ok: result.status === 0 && health.ok,
204
231
  status: result.status,
205
232
  stdout: result.stdout,
206
233
  stderr: result.stderr,
207
234
  error: result.error ? result.error.message : undefined,
235
+ ...health,
208
236
  };
209
237
  }
@@ -13,12 +13,12 @@ import {
13
13
  validateWatchdogService,
14
14
  } from "./watchdog-registry.js";
15
15
 
16
- function withTempRegistry(fn) {
16
+ async function withTempRegistry(fn) {
17
17
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-watchdog-"));
18
18
  const old = process.env.CLAUTH_WATCHDOG_DIR;
19
19
  process.env.CLAUTH_WATCHDOG_DIR = dir;
20
20
  try {
21
- return fn(dir);
21
+ return await fn(dir);
22
22
  } finally {
23
23
  if (old === undefined) delete process.env.CLAUTH_WATCHDOG_DIR;
24
24
  else process.env.CLAUTH_WATCHDOG_DIR = old;
@@ -78,12 +78,35 @@ test("registerWatchdogManifest upserts services by id", () => withTempRegistry((
78
78
  assert.equal(registry.services.find((service) => service.id === "codeflow").label, "CodeFlow Updated");
79
79
  }));
80
80
 
81
- test("restartWatchdogService rejects missing and unapproved services", () => withTempRegistry(() => {
82
- assert.deepEqual(restartWatchdogService("missing"), { ok: false, error: "service_not_registered" });
81
+ test("restartWatchdogService rejects missing and unapproved services", async () => withTempRegistry(async () => {
82
+ assert.deepEqual(await restartWatchdogService("missing"), { ok: false, error: "service_not_registered" });
83
83
  registerWatchdogManifest({
84
84
  services: [
85
85
  { id: "dev-center", label: "Dev Center", kind: "process", restart: { cmd: "node", args: ["--version"] } },
86
86
  ],
87
87
  });
88
- assert.deepEqual(restartWatchdogService("dev-center"), { ok: false, error: "approval_required" });
88
+ assert.deepEqual(await restartWatchdogService("dev-center"), { ok: false, error: "approval_required" });
89
+ }));
90
+
91
+ test("restartWatchdogService requires registered health after launching", async () => withTempRegistry(async () => {
92
+ registerWatchdogManifest({
93
+ services: [{
94
+ id: "health-gated",
95
+ label: "Health gated",
96
+ kind: "http",
97
+ health: { url: "http://127.0.0.1:3109/health", readyAttempts: 2, readyDelayMs: 0 },
98
+ restart: { cmd: process.execPath, args: ["--version"] },
99
+ approvalRequired: false,
100
+ }],
101
+ });
102
+ const originalFetch = globalThis.fetch;
103
+ globalThis.fetch = async () => ({ ok: false, status: 503 });
104
+ try {
105
+ const result = await restartWatchdogService("health-gated");
106
+ assert.equal(result.ok, false);
107
+ assert.equal(result.error, "restart_health_unreachable");
108
+ assert.equal(result.health_status, "degraded");
109
+ } finally {
110
+ globalThis.fetch = originalFetch;
111
+ }
89
112
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.30.24",
3
+ "version": "1.30.26",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "inquirer": "^10.1.0",
29
29
  "node-fetch": "^3.3.2",
30
30
  "ora": "^8.1.0",
31
+ "pm2": "^7.0.3",
31
32
  "regen-root": "file:../../regen-root.wt/x-claude-sv",
32
33
  "typescript": "^5.9.3"
33
34
  },
@@ -56,6 +57,7 @@
56
57
  "scripts/bootstrap.cjs",
57
58
  "scripts/build.sh",
58
59
  "scripts/postinstall.js",
60
+ "cli/commands/ops-install.js",
59
61
  "supabase/",
60
62
  ".clauth-skill/",
61
63
  "install.sh",