@lifeaitools/clauth 1.30.24 → 1.30.25

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.
@@ -58,6 +58,14 @@ import {
58
58
  DEFAULT_BOOTSTRAP,
59
59
  } from "./agent-pool.js";
60
60
  import { AgentCron, cronEnabled, nextRun } from "./agent-cron.js";
61
+ import pm2 from "pm2";
62
+ import { createPm2Adapter, PM2_OPERATION_CATALOG } from "../ops/pm2-adapter.js";
63
+ import { createOperationPolicy } from "../ops/operation-policy.js";
64
+ import { createJobStore } from "../ops/job-store.js";
65
+ import { createCoolifyAdapter, deploymentUuidFrom } from "../ops/coolify-adapter.js";
66
+ import { createDeploymentAdapter, parseDeploymentRegistry } from "../ops/deployment-adapter.js";
67
+ import { createSerializedExecutor } from "../ops/serialized-executor.js";
68
+ import { requestOps } from "./ops.js";
61
69
 
62
70
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
63
71
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
@@ -1231,11 +1239,16 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
1231
1239
  <span id="service-search-count" class="service-search-count"></span>
1232
1240
  </div>
1233
1241
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
1234
- <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
1242
+ <div class="footer" id="originFooter">checking origin… · 10-strike lockout</div>
1235
1243
  </div>
1236
1244
 
1237
1245
  <script>
1238
- const BASE = "http://127.0.0.1:${port}";
1246
+ const BASE = location.origin;
1247
+ (function reportOrigin() {
1248
+ const isLocal = /^(127\\.0\\.0\\.1|localhost|\\[::1\\])$/.test(location.hostname);
1249
+ const el = document.getElementById("originFooter");
1250
+ if (el) el.textContent = (isLocal ? "LOCAL" : "REMOTE") + " · " + BASE + " · 10-strike lockout";
1251
+ })();
1239
1252
 
1240
1253
  const SERVICE_HINTS = {
1241
1254
  "neo4j": "neo4j+s://username:password@instance.databases.neo4j.io",
@@ -4027,6 +4040,170 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4027
4040
  });
4028
4041
  const isSupervisorPort = port === getSupervisorPort();
4029
4042
  const supervisorTestNoToken = process.env.CLAUTH_SUPERVISOR_TEST_NO_TOKEN === "1";
4043
+ const opsAdapter = createPm2Adapter(pm2);
4044
+ const executePm2 = createSerializedExecutor();
4045
+ const opsPolicy = createOperationPolicy({
4046
+ enabled: String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
4047
+ applications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_APPLICATIONS || "{}"); } catch { return {}; } })(),
4048
+ adminEnabled: String(process.env.CLAUTH_OPS_ADMIN_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
4049
+ adminApplications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_ADMIN_APPLICATIONS || "{}"); } catch { return {}; } })(),
4050
+ allowHostWide: process.env.CLAUTH_OPS_ALLOW_HOST_WIDE === "1",
4051
+ });
4052
+ const opsJobs = createJobStore({
4053
+ filePath: process.env.CLAUTH_OPS_JOB_STORE_PATH || path.join(getSupervisorDir(), "ops-jobs.json"),
4054
+ });
4055
+
4056
+ async function getLoopbackSecret(service) {
4057
+ const response = await fetch(`http://127.0.0.1:${LIVE_PORT}/v/${encodeURIComponent(service)}`, { signal: AbortSignal.timeout(5000) });
4058
+ if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
4059
+ const value = (await response.text()).trim();
4060
+ if (!value) throw new Error(`${service} is empty`);
4061
+ return value;
4062
+ }
4063
+ const coolify = createCoolifyAdapter({
4064
+ baseUrl: process.env.CLAUTH_COOLIFY_BASE_URL || "https://deploy.regendevcorp.com",
4065
+ getToken: () => getLoopbackSecret("coolify-api"),
4066
+ });
4067
+ const deployments = (() => { try { return parseDeploymentRegistry(process.env.CLAUTH_OPS_DEPLOYMENTS || "{}"); } catch { return {}; } })();
4068
+ const deploymentAdapter = createDeploymentAdapter({
4069
+ deployments,
4070
+ reload: async (target) => {
4071
+ await executePm2(async () => {
4072
+ await opsAdapter.connect();
4073
+ try { await opsAdapter.execute("reload", { target, options: { updateEnv: true } }); } finally { await opsAdapter.disconnect(); }
4074
+ });
4075
+ },
4076
+ });
4077
+
4078
+ /**
4079
+ * Record an ops failure's upstream message to the LOCAL log only.
4080
+ *
4081
+ * job-store's sanitizer deliberately drops free-form `error` text so an
4082
+ * upstream message cannot carry a credential into the persisted job file or
4083
+ * the API response. That protection left every failure with an empty detail,
4084
+ * so jobs reported `failed` with no reason at all. Jobs now carry an
4085
+ * enumerated `code`; the underlying message goes here, to the same
4086
+ * operator-only log as the rest of the daemon's diagnostics.
4087
+ */
4088
+ function logOpsFailure(kind, operation, error) {
4089
+ const message = String(error?.message || error || "unknown");
4090
+ try {
4091
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [OPS ${kind}/${operation}] ${message}\n`);
4092
+ } catch {}
4093
+ }
4094
+
4095
+ async function opsBearerRole(req) {
4096
+ const header = req.headers.authorization;
4097
+ const supplied = Array.isArray(header) ? header[0] : header;
4098
+ if (!supplied || !String(supplied).startsWith("Bearer ")) return null;
4099
+ const actual = String(supplied).slice(7).trim();
4100
+ let admin; let agent;
4101
+ try { admin = await getLoopbackSecret(process.env.CLAUTH_OPS_ADMIN_TOKEN_SERVICE || "vultr-ops-admin-token"); } catch {}
4102
+ try { agent = await getLoopbackSecret(process.env.CLAUTH_OPS_AGENT_TOKEN_SERVICE || "vultr-ops-api-token"); } catch {}
4103
+ if (admin && agent && admin === agent) return null;
4104
+ for (const [role, expected] of [["admin", admin], ["agent", agent]]) {
4105
+ if (!expected) continue;
4106
+ const a = Buffer.from(actual); const b = Buffer.from(expected);
4107
+ if (a.length === b.length && crypto.timingSafeEqual(a, b)) return role;
4108
+ }
4109
+ return null;
4110
+ }
4111
+
4112
+ async function requireOpsBearer(req, res) {
4113
+ const role = await opsBearerRole(req);
4114
+ if (role) { req._opsRole = role; return true; }
4115
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
4116
+ res.end(JSON.stringify({ error: "ops_bearer_required" }));
4117
+ return false;
4118
+ }
4119
+
4120
+ function submitOpsJob(operation, input, role = "agent") {
4121
+ const authorization = opsPolicy.authorize(operation, input, role);
4122
+ const job = opsJobs.create({ kind: "pm2", operation, target: input.target || input.name || null });
4123
+ if (!authorization.ok) {
4124
+ return opsJobs.event(job.id, "rejected", { code: authorization.code });
4125
+ }
4126
+ void (async () => {
4127
+ opsJobs.event(job.id, "running");
4128
+ try {
4129
+ const result = await executePm2(async () => {
4130
+ await opsAdapter.connect();
4131
+ try { return await opsAdapter.execute(operation, input); } finally { await opsAdapter.disconnect(); }
4132
+ });
4133
+ opsJobs.event(job.id, "succeeded", operationReceipt(operation, result, authorization.allowed_targets || []));
4134
+ } catch (error) {
4135
+ // `error` alone is dropped by job-store's sanitizer (it refuses
4136
+ // free-form upstream text so a credential cannot ride along), which
4137
+ // left every failure with an empty detail. Emit an enumerated code so
4138
+ // the failure has a reason; keep the message for the local log only.
4139
+ logOpsFailure("pm2", operation, error);
4140
+ opsJobs.event(job.id, "failed", { code: "pm2_operation_failed" });
4141
+ }
4142
+ })();
4143
+ return opsJobs.get(job.id);
4144
+ }
4145
+
4146
+ function operationReceipt(operation, result, allowedTargets) {
4147
+ if (["list", "describe", "logs"].includes(operation)) {
4148
+ const processes = Array.isArray(result)
4149
+ ? result.filter((process) => allowedTargets.includes("*") || allowedTargets.includes(process?.name))
4150
+ : [];
4151
+ return { processes };
4152
+ }
4153
+ if (operation === "ping") return { status: "connected" };
4154
+ return { status: "completed" };
4155
+ }
4156
+
4157
+ function submitPromotionJob(applicationUuid) {
4158
+ const job = opsJobs.create({ kind: "coolify", operation: "promote", target: applicationUuid });
4159
+ const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("coolify_promote");
4160
+ const allowlist = (() => { try { return JSON.parse(process.env.CLAUTH_COOLIFY_PROMOTE_UUIDS || "[]"); } catch { return []; } })();
4161
+ if (!enabled || !Array.isArray(allowlist) || !allowlist.includes(applicationUuid)) {
4162
+ return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
4163
+ }
4164
+ void (async () => {
4165
+ opsJobs.event(job.id, "running");
4166
+ try {
4167
+ const deployment = await coolify.promote(applicationUuid);
4168
+ // Coolify answers with a `deployments` ARRAY, not a flat object — see
4169
+ // deploymentUuidFrom. Reading the flat field alone marked the job failed
4170
+ // while the deployment was actually running.
4171
+ const deploymentUuid = deploymentUuidFrom(deployment);
4172
+ if (!deploymentUuid) {
4173
+ // The deploy request itself SUCCEEDED (no throw); only the UUID was
4174
+ // unreadable, so the deployment may well be RUNNING. The code says so
4175
+ // explicitly rather than a bare "failed", because a plain failure
4176
+ // invites a retry and a duplicate production deploy.
4177
+ //
4178
+ // An enumerated code, not a free-form error: job-store's sanitizer
4179
+ // drops `error` on purpose to keep upstream text (and any credential
4180
+ // inside it) out of the persisted job.
4181
+ return opsJobs.event(job.id, "failed", { code: "coolify_deploy_accepted_uuid_unreadable" });
4182
+ }
4183
+ opsJobs.event(job.id, "waiting", { deployment_uuid: deploymentUuid });
4184
+ const terminal = await coolify.poll(deploymentUuid, { attempts: Number(process.env.CLAUTH_COOLIFY_POLL_ATTEMPTS || 60), delay: () => new Promise((resolve) => setTimeout(resolve, 5000)) });
4185
+ opsJobs.event(job.id, terminal.state === "succeeded" ? "succeeded" : terminal.state, { deployment_uuid: deploymentUuid, status: terminal.deployment?.status || null });
4186
+ } catch (error) {
4187
+ // The throw may have happened AFTER Coolify accepted the deploy (e.g.
4188
+ // the poll lost the network), so this is not proof nothing shipped.
4189
+ logOpsFailure("coolify", "promote", error);
4190
+ opsJobs.event(job.id, "failed", { code: "coolify_promote_failed" });
4191
+ }
4192
+ })();
4193
+ return opsJobs.get(job.id);
4194
+ }
4195
+
4196
+ function submitDeploymentJob(application, ref) {
4197
+ const job = opsJobs.create({ kind: "deployment", operation: "deploy", target: application });
4198
+ const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("deploy");
4199
+ if (!enabled || !deployments[application]) return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
4200
+ void (async () => {
4201
+ opsJobs.event(job.id, "running");
4202
+ try { opsJobs.event(job.id, "building"); opsJobs.event(job.id, "succeeded", { result: await deploymentAdapter.deploy({ application, ref }) }); }
4203
+ catch (error) { logOpsFailure("deployment", "deploy", error); opsJobs.event(job.id, "failed", { code: "deployment_failed" }); }
4204
+ })();
4205
+ return opsJobs.get(job.id);
4206
+ }
4030
4207
 
4031
4208
  function hasSupervisorWrite(req) {
4032
4209
  if (validateWriteToken(req, writeSession)) return true;
@@ -4623,7 +4800,104 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4623
4800
  }
4624
4801
 
4625
4802
  if (method === "GET" && reqPath === "/health") {
4626
- return ok(res, { ...supervisorHealth(), vault_locked: !password, clauth_version: VERSION });
4803
+ return ok(res, { ...supervisorHealth(), listening_port: port, process_id: process.pid, vault_locked: !password, clauth_version: VERSION });
4804
+ }
4805
+
4806
+ // Bearer-gated remote operations surface. It remains loopback-only at this
4807
+ // layer; ingress/tunnel policy decides whether it is reachable remotely.
4808
+ if (method === "GET" && reqPath === "/v1/ops/catalog") {
4809
+ if (!await requireOpsBearer(req, res)) return;
4810
+ return ok(res, { schema: "clauth.ops.v1", operations: PM2_OPERATION_CATALOG });
4811
+ }
4812
+
4813
+ if (method === "GET" && reqPath === "/v1/ops/processes") {
4814
+ if (!await requireOpsBearer(req, res)) return;
4815
+ const job = submitOpsJob("list", {}, req._opsRole);
4816
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4817
+ return res.end(JSON.stringify(job));
4818
+ }
4819
+
4820
+ const opsProcessMatch = reqPath.match(/^\/v1\/ops\/processes\/([^/]+)$/);
4821
+ if (method === "GET" && opsProcessMatch) {
4822
+ if (!await requireOpsBearer(req, res)) return;
4823
+ const job = submitOpsJob("describe", { target: decodeURIComponent(opsProcessMatch[1]) }, req._opsRole);
4824
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4825
+ return res.end(JSON.stringify(job));
4826
+ }
4827
+
4828
+ if (method === "POST" && reqPath === "/v1/ops/operations") {
4829
+ if (!await requireOpsBearer(req, res)) return;
4830
+ let body;
4831
+ try { body = await readBody(req); } catch {
4832
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4833
+ return res.end(JSON.stringify({ error: "invalid_json" }));
4834
+ }
4835
+ const operation = String(body?.operation || "");
4836
+ if (!PM2_OPERATION_CATALOG[operation]) {
4837
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4838
+ return res.end(JSON.stringify({ error: "unknown_operation" }));
4839
+ }
4840
+ const job = submitOpsJob(operation, body?.input && typeof body.input === "object" ? body.input : {}, req._opsRole);
4841
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4842
+ return res.end(JSON.stringify(job));
4843
+ }
4844
+
4845
+ if (method === "POST" && reqPath === "/v1/ops/promotions") {
4846
+ if (!await requireOpsBearer(req, res)) return;
4847
+ let body;
4848
+ try { body = await readBody(req); } catch {
4849
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4850
+ return res.end(JSON.stringify({ error: "invalid_json" }));
4851
+ }
4852
+ const applicationUuid = String(body?.application_uuid || "").trim();
4853
+ if (!applicationUuid) {
4854
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4855
+ return res.end(JSON.stringify({ error: "application_uuid_required" }));
4856
+ }
4857
+ const job = submitPromotionJob(applicationUuid);
4858
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4859
+ return res.end(JSON.stringify(job));
4860
+ }
4861
+
4862
+ if (method === "POST" && reqPath === "/v1/ops/deployments") {
4863
+ if (!await requireOpsBearer(req, res)) return;
4864
+ let body;
4865
+ try { body = await readBody(req); } catch {
4866
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4867
+ return res.end(JSON.stringify({ error: "invalid_json" }));
4868
+ }
4869
+ const application = String(body?.application || "").trim();
4870
+ if (!application) {
4871
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4872
+ return res.end(JSON.stringify({ error: "application_required" }));
4873
+ }
4874
+ const job = submitDeploymentJob(application, body?.ref ? String(body.ref) : undefined);
4875
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4876
+ return res.end(JSON.stringify(job));
4877
+ }
4878
+
4879
+ const opsJobMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)$/);
4880
+ if (method === "GET" && opsJobMatch) {
4881
+ if (!await requireOpsBearer(req, res)) return;
4882
+ const job = opsJobs.get(decodeURIComponent(opsJobMatch[1]));
4883
+ res.writeHead(job ? 200 : 404, { "Content-Type": "application/json", ...CORS });
4884
+ return res.end(JSON.stringify(job || { error: "job_not_found" }));
4885
+ }
4886
+
4887
+ const opsJobEventsMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)\/events$/);
4888
+ if (method === "GET" && opsJobEventsMatch) {
4889
+ if (!await requireOpsBearer(req, res)) return;
4890
+ const jobId = decodeURIComponent(opsJobEventsMatch[1]);
4891
+ if (!opsJobs.get(jobId)) {
4892
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS });
4893
+ return res.end(JSON.stringify({ error: "job_not_found" }));
4894
+ }
4895
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...CORS });
4896
+ const unsubscribe = opsJobs.subscribe(jobId, (job) => {
4897
+ if (!res.writableEnded) res.write(`event: job\ndata: ${JSON.stringify(job)}\n\n`);
4898
+ });
4899
+ req.on("close", unsubscribe);
4900
+ return;
4627
4901
  }
4628
4902
 
4629
4903
  if (method === "GET" && reqPath === "/v1/plugins") {
@@ -11343,6 +11617,41 @@ const MCP_TOOLS = [
11343
11617
  additionalProperties: false,
11344
11618
  },
11345
11619
  },
11620
+ {
11621
+ name: "clauth_ops_catalog",
11622
+ description: "Return the Vultr deployment-control API catalog. Availability is still decided on Vultr.",
11623
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
11624
+ },
11625
+ {
11626
+ name: "clauth_ops_processes",
11627
+ description: "Submit a scoped PM2 process-status query to the Vultr control plane. Only server-approved applications are returned.",
11628
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
11629
+ },
11630
+ {
11631
+ name: "clauth_ops_describe",
11632
+ description: "Submit a scoped PM2 status query for one server-approved application.",
11633
+ inputSchema: { type: "object", properties: { application: { type: "string" } }, required: ["application"], additionalProperties: false },
11634
+ },
11635
+ {
11636
+ name: "clauth_ops_deploy",
11637
+ description: "Submit one manifest-scoped development deployment to Vultr. The server chooses the repository, build, PM2 process, and allowed ref.",
11638
+ inputSchema: { type: "object", properties: { application: { type: "string" }, ref: { type: "string" } }, required: ["application"], additionalProperties: false },
11639
+ },
11640
+ {
11641
+ name: "clauth_ops_promote",
11642
+ description: "Submit one server-allowlisted Coolify promotion and return its asynchronous job receipt.",
11643
+ inputSchema: { type: "object", properties: { application_uuid: { type: "string" } }, required: ["application_uuid"], additionalProperties: false },
11644
+ },
11645
+ {
11646
+ name: "clauth_ops_job",
11647
+ description: "Read the terminal or in-progress receipt for a deployment-control job.",
11648
+ inputSchema: { type: "object", properties: { job_id: { type: "string" } }, required: ["job_id"], additionalProperties: false },
11649
+ },
11650
+ {
11651
+ name: "clauth_ops_run",
11652
+ description: "Submit one PM2 operation through the Vultr control plane. The remote agent/admin profile decides whether the operation and target are available.",
11653
+ inputSchema: { type: "object", properties: { operation: { type: "string" }, input: { type: "object" } }, required: ["operation"], additionalProperties: false },
11654
+ },
11346
11655
  ];
11347
11656
 
11348
11657
  const MCP_WRITE_TOOL_NAMES = new Set([
@@ -11350,6 +11659,9 @@ const MCP_WRITE_TOOL_NAMES = new Set([
11350
11659
  "clauth_disable",
11351
11660
  "clauth_set_project",
11352
11661
  "clauth_generate_token",
11662
+ "clauth_ops_deploy",
11663
+ "clauth_ops_promote",
11664
+ "clauth_ops_run",
11353
11665
  ]);
11354
11666
 
11355
11667
  function filterMcpToolsForWriteMode(tools) {
@@ -11383,6 +11695,24 @@ function mcpError(text) {
11383
11695
  return { content: [{ type: "text", text }], isError: true };
11384
11696
  }
11385
11697
 
11698
+ async function callOpsFromMcp(vault, method, requestPath, body, { write = false } = {}) {
11699
+ if (write && !vault.writeEnabled) return mcpError("MCP write tools are disabled by default. Launch clauth with CLAUTH_MCP_WRITE=1 for an explicit write-capable session.");
11700
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
11701
+ const endpoint = process.env.CLAUTH_OPS_APPROVED_ORIGIN;
11702
+ if (!endpoint) return mcpError("service_not_available");
11703
+ try {
11704
+ const url = new URL(endpoint);
11705
+ if (url.protocol !== "https:") return mcpError("service_not_available");
11706
+ const service = process.env.CLAUTH_OPS_TOKEN_SERVICE || "vultr-ops-api-token";
11707
+ const credential = await vaultRetrieveValue(vault, service);
11708
+ if (credential.error || !credential.value) return mcpError("service_not_available");
11709
+ const payload = await requestOps({ endpoint: url.toString().replace(/\/$/, ""), token: String(credential.value), method, path: requestPath, body });
11710
+ return mcpResult(JSON.stringify(payload, null, 2));
11711
+ } catch {
11712
+ return mcpError("service_not_available");
11713
+ }
11714
+ }
11715
+
11386
11716
  // Windows cmd.exe doesn't support single quotes — use bash for gws JSON args
11387
11717
  const GWS_EXEC_OPTS = { encoding: "utf8", timeout: 30000, windowsHide: true, shell: os.platform() === "win32" ? "bash" : undefined };
11388
11718
 
@@ -11393,6 +11723,13 @@ async function handleMcpTool(vault, name, args) {
11393
11723
  };
11394
11724
 
11395
11725
  switch (name) {
11726
+ case "clauth_ops_catalog": return callOpsFromMcp(vault, "GET", "/v1/ops/catalog");
11727
+ case "clauth_ops_processes": return callOpsFromMcp(vault, "GET", "/v1/ops/processes");
11728
+ case "clauth_ops_describe": return callOpsFromMcp(vault, "GET", `/v1/ops/processes/${encodeURIComponent(args.application || "")}`);
11729
+ case "clauth_ops_deploy": return callOpsFromMcp(vault, "POST", "/v1/ops/deployments", { application: args.application, ...(args.ref ? { ref: args.ref } : {}) }, { write: true });
11730
+ case "clauth_ops_promote": return callOpsFromMcp(vault, "POST", "/v1/ops/promotions", { application_uuid: args.application_uuid }, { write: true });
11731
+ case "clauth_ops_job": return callOpsFromMcp(vault, "GET", `/v1/ops/jobs/${encodeURIComponent(args.job_id || "")}`);
11732
+ case "clauth_ops_run": return callOpsFromMcp(vault, "POST", "/v1/ops/operations", { operation: args.operation, input: args.input && typeof args.input === "object" ? args.input : {} }, { write: true });
11396
11733
  case "clauth_ping": {
11397
11734
  return mcpResult(
11398
11735
  vault.password
@@ -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
@@ -150,6 +150,8 @@ import { runInstall } from './commands/install.js';
150
150
  import { runUninstall } from './commands/uninstall.js';
151
151
  import { runScrub } from './commands/scrub.js';
152
152
  import { runServe } from './commands/serve.js';
153
+ import { runOps } from './commands/ops.js';
154
+ import { runOpsInstall } from './commands/ops-install.js';
153
155
  import { runCodevelop } from './commands/codevelop.js';
154
156
  import { runNpm, runPublish } from './commands/npm.js';
155
157
 
@@ -1050,4 +1052,22 @@ Examples:
1050
1052
  await runServe({ ...opts, action: resolvedAction });
1051
1053
  });
1052
1054
 
1055
+ program
1056
+ .command("ops <action>")
1057
+ .description("Call the bearer-authenticated PM2 and Coolify operations control plane")
1058
+ .option("--endpoint <url>", "HTTPS control-plane endpoint (or CLAUTH_OPS_ENDPOINT)")
1059
+ .option("--target <name>", "PM2 process name or id")
1060
+ .option("--script <path>", "PM2 script path for start")
1061
+ .option("--instances <n>", "PM2 scale target")
1062
+ .option("--operation <name>", "PM2 operation for run")
1063
+ .option("--args-json <json>", "JSON positional arguments for a raw pm2_* operation")
1064
+ .option("--options-json <json>", "JSON PM2 options for a typed operation")
1065
+ .option("--application <uuid>", "registered Coolify application UUID for promote")
1066
+ .option("--ref <name>", "registered Git ref for deploy")
1067
+ .option("--job <id>", "job id for status lookup")
1068
+ .option("--config <path>", "server-side JSON policy for ops install")
1069
+ .option("--dry-run", "validate and print ops install configuration without changing PM2")
1070
+ .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`)
1071
+ .action(async (action, opts) => { if (action === "install") await runOpsInstall(opts); else await runOps(action, opts); });
1072
+
1053
1073
  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
+ }