@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.
- package/.clauth-skill/SKILL.md +31 -0
- package/.clauth-skill/references/operator-guide.md +27 -0
- package/README.md +48 -0
- package/cli/commands/ops-install.js +211 -0
- package/cli/commands/ops.js +69 -0
- package/cli/commands/serve.js +1241 -1676
- package/cli/commands/watchdog.js +1 -1
- package/cli/index.js +93 -1
- package/cli/ops/coolify-adapter.js +80 -0
- package/cli/ops/deployment-adapter.js +63 -0
- package/cli/ops/job-store.js +116 -0
- package/cli/ops/operation-policy.js +51 -0
- package/cli/ops/pm2-adapter.js +128 -0
- package/cli/ops/serialized-executor.js +9 -0
- package/cli/supervisor-registry.js +112 -6
- package/cli/supervisor-registry.test.js +151 -0
- package/cli/watchdog-registry.js +30 -2
- package/cli/watchdog-registry.test.js +28 -5
- package/package.json +3 -2
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
package/cli/commands/watchdog.js
CHANGED
|
@@ -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
|
+
}
|