@lifeaitools/clauth 1.31.1 → 2.0.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.
@@ -131,7 +131,7 @@ export async function runWatchdog(action, opts = {}) {
131
131
  console.log("Usage: clauth watchdog restart <service-id>");
132
132
  return;
133
133
  }
134
- const result = restartWatchdogService(serviceId);
134
+ const result = await restartWatchdogService(serviceId);
135
135
  console.log(JSON.stringify(result, null, 2));
136
136
  return;
137
137
  }
package/cli/index.js CHANGED
@@ -149,7 +149,10 @@ program
149
149
  import { runInstall } from './commands/install.js';
150
150
  import { runUninstall } from './commands/uninstall.js';
151
151
  import { runScrub } from './commands/scrub.js';
152
- import { runServe } from './commands/serve.js';
152
+ import { runServe, MCP_TOOLS } from './commands/serve.js';
153
+ import { listPlugins, registerPlugin } from './supervisor-registry.js';
154
+ import { runOps } from './commands/ops.js';
155
+ import { runOpsInstall } from './commands/ops-install.js';
153
156
  import { runCodevelop } from './commands/codevelop.js';
154
157
  import { runNpm, runPublish } from './commands/npm.js';
155
158
  import { runLogin } from './commands/login.js';
@@ -960,6 +963,77 @@ tunnelCmd
960
963
  }
961
964
  });
962
965
 
966
+ // ──────────────────────────────────────────────
967
+ // clauth mcp list
968
+ // ──────────────────────────────────────────────
969
+ // Known MCP-server plugin ids in the managed fleet. Positive allowlist
970
+ // rather than a naming heuristic (credential-name conventions and health
971
+ // route "kind" vary across these) — dev-center and any future non-MCP
972
+ // pm2-managed surface stay excluded by construction. Deliberately distinct
973
+ // from `clauth list` (vault credential services); this never touches those.
974
+ const MCP_SERVER_PLUGIN_IDS = new Set([
975
+ "fs-mcp",
976
+ "web-research",
977
+ "regen-media",
978
+ "regen-media-local",
979
+ "codeflow-mcp",
980
+ "rdc-skills",
981
+ ]);
982
+
983
+ const mcpCmd = program.command("mcp").description("Inspect clauth's own MCP tool catalog and managed MCP-server surfaces");
984
+
985
+ mcpCmd
986
+ .command("list")
987
+ .description("List clauth's advertised MCP tools and MCP-server surfaces (never vault credential services)")
988
+ .action(() => {
989
+ console.log(chalk.cyan(`\n clauth's own MCP tools (${MCP_TOOLS.length}):\n`));
990
+ for (const tool of MCP_TOOLS) {
991
+ console.log(` ${chalk.white(tool.name)} ${chalk.gray(tool.description || "")}`);
992
+ }
993
+ const mcpPlugins = listPlugins().filter((p) => MCP_SERVER_PLUGIN_IDS.has(p.id));
994
+ console.log(chalk.cyan(`\n Managed MCP-server surfaces (${mcpPlugins.length}):\n`));
995
+ if (!mcpPlugins.length) {
996
+ console.log(chalk.gray(" none discovered — is the daemon running? clauth serve"));
997
+ }
998
+ for (const plugin of mcpPlugins) {
999
+ const state = plugin.enabled ? plugin.state : "disabled";
1000
+ const icon = state === "current" ? "✓" : state === "disabled" ? "○" : "⚠";
1001
+ console.log(` ${icon} ${chalk.white(plugin.id)} ${chalk.gray(state)}`);
1002
+ for (const surface of plugin.surfaces || []) {
1003
+ console.log(` ${surface.id} ${chalk.gray(surface.health || surface.routes?.[0]?.url || "no health route")}`);
1004
+ }
1005
+ }
1006
+ console.log("");
1007
+ });
1008
+
1009
+ // ──────────────────────────────────────────────
1010
+ // clauth plugin register <manifest-path>
1011
+ // The self-registration entry point for PLUGIN-ARCHITECTURE-DECISION.md:
1012
+ // a product repo's own install/deploy step calls this after its
1013
+ // clauth-plugin.json is in place — same role as clauth's own
1014
+ // scripts/postinstall.js, for repos that have no npm-install lifecycle
1015
+ // hook of their own (monorepo workspace members, not standalone packages).
1016
+ // ──────────────────────────────────────────────
1017
+ const pluginCmd = program.command('plugin').description("Register and manage clauth-supervised plugin manifests");
1018
+
1019
+ pluginCmd
1020
+ .command('register <manifestPath>')
1021
+ .description('Validate a clauth-plugin.json and register it with the local supervisor, then run discovery')
1022
+ .action((manifestPath) => {
1023
+ const resolved = path.resolve(process.cwd(), manifestPath);
1024
+ const receipt = registerPlugin(resolved, 'cli');
1025
+ const ok = receipt.resulting_state?.ok;
1026
+ const state = receipt.resulting_state?.state;
1027
+ if (!ok) {
1028
+ const pluginState = receipt.resulting_state?.plugin_state;
1029
+ const detail = receipt.resulting_state?.error || (pluginState ? `plugin_state=${pluginState}` : 'registration failed');
1030
+ console.error(` ✗ ${state}: ${detail}`);
1031
+ process.exitCode = 1;
1032
+ return;
1033
+ }
1034
+ console.log(` ✓ ${chalk.white(receipt.target?.plugin_id || '?')} ${state} — surfaces: ${(receipt.resulting_state?.surfaces || []).join(', ') || 'none'}`);
1035
+ });
1036
+
963
1037
  // ──────────────────────────────────────────────
964
1038
  // clauth chitchat --session <id>
965
1039
  // ──────────────────────────────────────────────
@@ -1079,4 +1153,22 @@ Examples:
1079
1153
  await runServe({ ...opts, action: resolvedAction });
1080
1154
  });
1081
1155
 
1156
+ program
1157
+ .command("ops <action>")
1158
+ .description("Call the bearer-authenticated PM2 and Coolify operations control plane")
1159
+ .option("--endpoint <url>", "HTTPS control-plane endpoint (or CLAUTH_OPS_ENDPOINT)")
1160
+ .option("--target <name>", "PM2 process name or id")
1161
+ .option("--script <path>", "PM2 script path for start")
1162
+ .option("--instances <n>", "PM2 scale target")
1163
+ .option("--operation <name>", "PM2 operation for run")
1164
+ .option("--args-json <json>", "JSON positional arguments for a raw pm2_* operation")
1165
+ .option("--options-json <json>", "JSON PM2 options for a typed operation")
1166
+ .option("--application <uuid>", "registered Coolify application UUID for promote")
1167
+ .option("--ref <name>", "registered Git ref for deploy")
1168
+ .option("--job <id>", "job id for status lookup")
1169
+ .option("--config <path>", "server-side JSON policy for ops install")
1170
+ .option("--dry-run", "validate and print ops install configuration without changing PM2")
1171
+ .addHelpText("after", `\nActions: catalog | list | describe | run | deploy | promote | job | install\n\nInstall: clauth ops install --config /etc/clauth/ops-control-plane.json\nThe installer creates or updates a PM2-managed local control plane, then proves /health and the bearer gate without reading a token.\n\nThe bearer is retrieved only from local clauth service vultr-ops-api-token and is never printed.\n`)
1172
+ .action(async (action, opts) => { if (action === "install") await runOpsInstall(opts); else await runOps(action, opts); });
1173
+
1082
1174
  program.parse(process.argv);
@@ -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
+ }