devops-whc 1.0.1
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/AGENT_MCP_USAGE.md +394 -0
- package/LICENSE +15 -0
- package/README.md +208 -0
- package/WHC_MCP_REQUIREMENTS.md +112 -0
- package/dist/audit/audit-logger.js +57 -0
- package/dist/clients/ssh-client.js +199 -0
- package/dist/clients/whc-uapi-client.js +178 -0
- package/dist/clients/wpcli-client.js +125 -0
- package/dist/config/env.js +132 -0
- package/dist/contracts/deployment.js +2 -0
- package/dist/contracts/envelope.js +2 -0
- package/dist/dispatcher/tool-dispatcher.js +145 -0
- package/dist/handlers/whc-check-health.js +131 -0
- package/dist/handlers/whc-db-backup.js +111 -0
- package/dist/handlers/whc-deploy.js +381 -0
- package/dist/handlers/whc-get-logs.js +108 -0
- package/dist/handlers/whc-pipeline-status.js +96 -0
- package/dist/handlers/whc-prepare.js +127 -0
- package/dist/handlers/whc-rollback.js +141 -0
- package/dist/handlers/whc-setup-remote.js +262 -0
- package/dist/handlers/whc-ssh-exec.js +138 -0
- package/dist/handlers/whc-verify.js +304 -0
- package/dist/idempotency/store.js +13 -0
- package/dist/index.js +109 -0
- package/dist/policy/policy-engine.js +41 -0
- package/dist/probes/connectivity.js +41 -0
- package/dist/registry/tool-registry.js +69 -0
- package/dist/schemas/whc-check-health.js +55 -0
- package/dist/schemas/whc-db-backup.js +29 -0
- package/dist/schemas/whc-deploy.js +66 -0
- package/dist/schemas/whc-get-logs.js +25 -0
- package/dist/schemas/whc-pipeline-status.js +24 -0
- package/dist/schemas/whc-prepare.js +29 -0
- package/dist/schemas/whc-rollback.js +58 -0
- package/dist/schemas/whc-setup-remote.js +60 -0
- package/dist/schemas/whc-ssh-exec.js +117 -0
- package/dist/schemas/whc-verify.js +28 -0
- package/dist/server-entry.js +8 -0
- package/dist/server.js +381 -0
- package/dist/services/deploy-runtime-ops.js +104 -0
- package/dist/services/deployment-locks.js +34 -0
- package/dist/state/workspace-state.js +201 -0
- package/package.json +48 -0
- package/scripts/prepare-first-time.cjs +75 -0
- package/scripts/start-mcp.cjs +42 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# WHC.ca (cPanel) MCP Server Requirements
|
|
2
|
+
|
|
3
|
+
## 1. Objective
|
|
4
|
+
Build a specialized MCP (Model Context Protocol) Server to automate the management and deployment of web projects hosted on Web Hosting Canada (WHC.ca). The goal is to facilitate a seamless **Local -> Staging -> Production** workflow directly from the terminal/Gemini CLI.
|
|
5
|
+
|
|
6
|
+
## 2. Core Features
|
|
7
|
+
- **Git Automation:** Remote repository creation and branch-based synchronization.
|
|
8
|
+
- **API Deployment:** Triggering cPanel's native deployment engine via UAPI.
|
|
9
|
+
- **SSH Command Execution:** Remote server management (file manipulation, DB tasks) using WHC's specific port (27).
|
|
10
|
+
- **Status Monitoring:** Real-time health checks, log retrieval, and SSL status.
|
|
11
|
+
|
|
12
|
+
## 3. Tool Definitions (Current)
|
|
13
|
+
|
|
14
|
+
| Tool Name | Mode | Safety | Description |
|
|
15
|
+
| :--- | :--- | :--- | :--- |
|
|
16
|
+
| `whc_prepare` | write | B | Initializes hidden state namespace and readiness checks. |
|
|
17
|
+
| `whc_deploy` | write | C/D | Triggers deployment/sync flow with policy gating. |
|
|
18
|
+
| `whc_verify` | read | A | Runs technical post-deploy verification and writes verify report. |
|
|
19
|
+
| `whc_pipeline_status` | read | A | Reads latest pipeline state and artifact pointers. |
|
|
20
|
+
| `whc_rollback` | write | D | Guarded rollback (dry-run + confirm + verify-chain PASS required). |
|
|
21
|
+
| `whc_setup_remote` | write | C | Creates cPanel Git repo and returns SSH remote hints. |
|
|
22
|
+
| `whc_ssh_exec` | write | C | Executes policy-controlled SSH commands. |
|
|
23
|
+
| `whc_get_logs` | read | A | Fetches server logs. |
|
|
24
|
+
| `whc_db_backup` | write | D | Creates remote SQL backup with audit trail. |
|
|
25
|
+
| `whc_check_health` | read | A | Collects disk, SSL, load, and capability signals. |
|
|
26
|
+
|
|
27
|
+
### 3.4 Rollback Contract (Current)
|
|
28
|
+
`whc_rollback` is implemented as Level D.
|
|
29
|
+
|
|
30
|
+
Policy requirements:
|
|
31
|
+
1. `dry_run=true` is mandatory for preview phase.
|
|
32
|
+
2. `confirmed=true` is mandatory for execution phase.
|
|
33
|
+
3. Verify-chain must be present and PASS from `whc_verify` report.
|
|
34
|
+
|
|
35
|
+
Execution modes:
|
|
36
|
+
1. `git_branch` (UAPI deployment trigger with rollback branch)
|
|
37
|
+
2. `db_backup` (SSH `wp db import`)
|
|
38
|
+
3. `managed_sync_reverse` (manual action response with explicit warning)
|
|
39
|
+
|
|
40
|
+
## 3.1 Deploy Contract (Current)
|
|
41
|
+
`whc_deploy` now reports phase coverage explicitly to prevent false-positive "deploy pass" outcomes.
|
|
42
|
+
|
|
43
|
+
Response fields in `data`:
|
|
44
|
+
1. `pipeline_status`
|
|
45
|
+
- `full_pass` | `pass_partial`
|
|
46
|
+
2. `phase_coverage`
|
|
47
|
+
- `code_state`: `included` | `excluded`
|
|
48
|
+
- `runtime_state`: `included` | `excluded`
|
|
49
|
+
- `data_state`: `included` | `excluded`
|
|
50
|
+
- `deployment_state`: `included` | `excluded`
|
|
51
|
+
- `notes`: explanatory list for what is in/out of scope
|
|
52
|
+
|
|
53
|
+
Interpretation:
|
|
54
|
+
1. `pass_partial` is expected when a run covers only transport/sync/deploy mechanics.
|
|
55
|
+
2. `full_pass` must only be used when code/runtime/data/deployment states are all included and validated.
|
|
56
|
+
|
|
57
|
+
## 3.2 Setup-Remote Contract (Current)
|
|
58
|
+
`whc_setup_remote` is transport preparation, not code deployment.
|
|
59
|
+
|
|
60
|
+
Response fields in `data`:
|
|
61
|
+
1. `pipeline_status`
|
|
62
|
+
- expected default: `pass_partial`
|
|
63
|
+
2. `phase_coverage`
|
|
64
|
+
- deployment state reflects repository wiring only
|
|
65
|
+
3. `process_tracking`
|
|
66
|
+
- `stage`: `setup_remote`
|
|
67
|
+
- `status`: `completed` | `needs_manual_input`
|
|
68
|
+
- `next_step`: actionable follow-up
|
|
69
|
+
- `manual_actions`: explicit operator/agent to-do list
|
|
70
|
+
|
|
71
|
+
Operational implication:
|
|
72
|
+
1. A successful `whc_setup_remote` still requires local source push (`git push`) and subsequent `whc_deploy`.
|
|
73
|
+
|
|
74
|
+
## 3.3 Manual Prerequisites (Must Be Explicit)
|
|
75
|
+
These steps are not auto-solvable by MCP and must be prepared by operator:
|
|
76
|
+
1. WHC API token creation and placement in env.
|
|
77
|
+
2. SSH key provisioning to correct cPanel account.
|
|
78
|
+
3. Credential scope alignment (target path ownership/account).
|
|
79
|
+
|
|
80
|
+
## 4. Technical Stack
|
|
81
|
+
- **Language:** TypeScript / Node.js.
|
|
82
|
+
- **Framework:** MCP SDK (@modelcontextprotocol/sdk).
|
|
83
|
+
- **Communication:**
|
|
84
|
+
- **cPanel UAPI:** Over HTTPS (Port 2083) using API Tokens.
|
|
85
|
+
- **SSH:** Using `ssh2` or system SSH (Port 27).
|
|
86
|
+
- **Configuration:** Global `.env` for sensitive credentials.
|
|
87
|
+
|
|
88
|
+
## 5. Implementation Roadmap
|
|
89
|
+
|
|
90
|
+
### Phase 1: Foundation & Auth
|
|
91
|
+
- [ ] Initialize MCP project.
|
|
92
|
+
- [ ] Implement secure `.env` loading for `WHC_API_TOKEN`, `WHC_USER`, and `WHC_HOST`.
|
|
93
|
+
- [ ] Test connectivity to cPanel API and SSH.
|
|
94
|
+
|
|
95
|
+
### Phase 2: Deployment & Git
|
|
96
|
+
- [ ] Build `whc_setup_remote`: Automate the "Git Version Control" setup in cPanel.
|
|
97
|
+
- [ ] Build `whc_deploy`: Connect to the `deployment` UAPI endpoint.
|
|
98
|
+
- [ ] Create a standard `.cpanel.yml` template generator.
|
|
99
|
+
|
|
100
|
+
### Phase 3: Monitoring & Utilities
|
|
101
|
+
- [ ] Implement remote log tailing via SSH.
|
|
102
|
+
- [ ] Implement database backup/restore helpers.
|
|
103
|
+
- [ ] Add health check dashboard.
|
|
104
|
+
|
|
105
|
+
### Phase 4: Gemini CLI Integration
|
|
106
|
+
- [ ] Register the MCP server in `gemini-config.json`.
|
|
107
|
+
- [ ] Create high-level commands/aliases (e.g., `whc-sync-staging`).
|
|
108
|
+
|
|
109
|
+
## 6. Security Protocol
|
|
110
|
+
- No API tokens or Private Keys are to be hardcoded.
|
|
111
|
+
- All sensitive data must reside in a local `.env` file excluded from git.
|
|
112
|
+
- API requests must use HTTPS with proper header authentication.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CompositeAuditLogger = exports.FileAuditLogger = exports.ConsoleAuditLogger = void 0;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
class ConsoleAuditLogger {
|
|
7
|
+
log(entry) {
|
|
8
|
+
const safeEntry = toSafeEntry(entry);
|
|
9
|
+
// Console logging is temporary; production sink can be added later.
|
|
10
|
+
console.log("[AUDIT]", JSON.stringify(safeEntry));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
exports.ConsoleAuditLogger = ConsoleAuditLogger;
|
|
14
|
+
class FileAuditLogger {
|
|
15
|
+
filePath;
|
|
16
|
+
constructor(filePath) {
|
|
17
|
+
this.filePath = filePath;
|
|
18
|
+
}
|
|
19
|
+
log(entry) {
|
|
20
|
+
const safeEntry = toSafeEntry(entry);
|
|
21
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(this.filePath), { recursive: true });
|
|
22
|
+
(0, node_fs_1.appendFileSync)(this.filePath, JSON.stringify(safeEntry) + "\n", { encoding: "utf8" });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.FileAuditLogger = FileAuditLogger;
|
|
26
|
+
class CompositeAuditLogger {
|
|
27
|
+
sinks;
|
|
28
|
+
constructor(sinks) {
|
|
29
|
+
this.sinks = sinks;
|
|
30
|
+
}
|
|
31
|
+
log(entry) {
|
|
32
|
+
for (const sink of this.sinks) {
|
|
33
|
+
sink.log(entry);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
exports.CompositeAuditLogger = CompositeAuditLogger;
|
|
38
|
+
function toSafeEntry(entry) {
|
|
39
|
+
return {
|
|
40
|
+
...entry,
|
|
41
|
+
details: redactSecrets(entry.details),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function redactSecrets(details) {
|
|
45
|
+
if (!details) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const result = {};
|
|
49
|
+
for (const [key, value] of Object.entries(details)) {
|
|
50
|
+
if (/token|secret|password|key/i.test(key)) {
|
|
51
|
+
result[key] = "[REDACTED]";
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
result[key] = value;
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WhcSshClient = void 0;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const ssh2_1 = require("ssh2");
|
|
6
|
+
class WhcSshClient {
|
|
7
|
+
config;
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
}
|
|
11
|
+
async probe() {
|
|
12
|
+
return this.probeTarget(this.config.sshTargets.prod);
|
|
13
|
+
}
|
|
14
|
+
async probeTarget(target) {
|
|
15
|
+
const startedAt = Date.now();
|
|
16
|
+
const privateKey = (0, node_fs_1.readFileSync)(target.privateKeyPath);
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const client = new ssh2_1.Client();
|
|
19
|
+
const timeoutId = setTimeout(() => {
|
|
20
|
+
client.end();
|
|
21
|
+
resolve({
|
|
22
|
+
ok: false,
|
|
23
|
+
latencyMs: Date.now() - startedAt,
|
|
24
|
+
message: "SSH probe timed out",
|
|
25
|
+
});
|
|
26
|
+
}, this.config.sshTimeoutMs);
|
|
27
|
+
client
|
|
28
|
+
.on("ready", () => {
|
|
29
|
+
clearTimeout(timeoutId);
|
|
30
|
+
client.end();
|
|
31
|
+
resolve({
|
|
32
|
+
ok: true,
|
|
33
|
+
latencyMs: Date.now() - startedAt,
|
|
34
|
+
message: "SSH connectivity probe succeeded",
|
|
35
|
+
});
|
|
36
|
+
})
|
|
37
|
+
.on("error", (error) => {
|
|
38
|
+
clearTimeout(timeoutId);
|
|
39
|
+
client.end();
|
|
40
|
+
resolve({
|
|
41
|
+
ok: false,
|
|
42
|
+
latencyMs: Date.now() - startedAt,
|
|
43
|
+
message: error.message,
|
|
44
|
+
});
|
|
45
|
+
})
|
|
46
|
+
.connect({
|
|
47
|
+
host: target.host,
|
|
48
|
+
port: target.port,
|
|
49
|
+
username: target.username,
|
|
50
|
+
privateKey,
|
|
51
|
+
readyTimeout: this.config.sshTimeoutMs,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async execWithKey(target, command) {
|
|
56
|
+
const startedAt = Date.now();
|
|
57
|
+
const privateKey = (0, node_fs_1.readFileSync)(target.privateKeyPath);
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const client = new ssh2_1.Client();
|
|
60
|
+
const timeoutId = setTimeout(() => {
|
|
61
|
+
client.end();
|
|
62
|
+
resolve({
|
|
63
|
+
ok: false,
|
|
64
|
+
latencyMs: Date.now() - startedAt,
|
|
65
|
+
stdout: "",
|
|
66
|
+
stderr: "",
|
|
67
|
+
message: "SSH command timed out",
|
|
68
|
+
});
|
|
69
|
+
}, this.config.sshTimeoutMs);
|
|
70
|
+
client
|
|
71
|
+
.on("ready", () => {
|
|
72
|
+
client.exec(command, (err, stream) => {
|
|
73
|
+
if (err) {
|
|
74
|
+
clearTimeout(timeoutId);
|
|
75
|
+
client.end();
|
|
76
|
+
resolve({
|
|
77
|
+
ok: false,
|
|
78
|
+
latencyMs: Date.now() - startedAt,
|
|
79
|
+
stdout: "",
|
|
80
|
+
stderr: "",
|
|
81
|
+
message: err.message,
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let stdout = "";
|
|
86
|
+
let stderr = "";
|
|
87
|
+
stream
|
|
88
|
+
.on("close", (code) => {
|
|
89
|
+
clearTimeout(timeoutId);
|
|
90
|
+
client.end();
|
|
91
|
+
resolve({
|
|
92
|
+
ok: code === 0,
|
|
93
|
+
latencyMs: Date.now() - startedAt,
|
|
94
|
+
stdout: stdout.trim(),
|
|
95
|
+
stderr: stderr.trim(),
|
|
96
|
+
message: code === 0 ? "SSH command succeeded" : `SSH command failed with exit code ${code ?? -1}`,
|
|
97
|
+
});
|
|
98
|
+
})
|
|
99
|
+
.on("data", (data) => {
|
|
100
|
+
stdout += data.toString("utf8");
|
|
101
|
+
});
|
|
102
|
+
stream.stderr.on("data", (data) => {
|
|
103
|
+
stderr += data.toString("utf8");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
})
|
|
107
|
+
.on("error", (error) => {
|
|
108
|
+
clearTimeout(timeoutId);
|
|
109
|
+
client.end();
|
|
110
|
+
resolve({
|
|
111
|
+
ok: false,
|
|
112
|
+
latencyMs: Date.now() - startedAt,
|
|
113
|
+
stdout: "",
|
|
114
|
+
stderr: "",
|
|
115
|
+
message: error.message,
|
|
116
|
+
});
|
|
117
|
+
})
|
|
118
|
+
.connect({
|
|
119
|
+
host: target.host,
|
|
120
|
+
port: target.port,
|
|
121
|
+
username: target.username,
|
|
122
|
+
privateKey,
|
|
123
|
+
readyTimeout: this.config.sshTimeoutMs,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async execWithPassword(target, command) {
|
|
128
|
+
const startedAt = Date.now();
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
const client = new ssh2_1.Client();
|
|
131
|
+
const timeoutId = setTimeout(() => {
|
|
132
|
+
client.end();
|
|
133
|
+
resolve({
|
|
134
|
+
ok: false,
|
|
135
|
+
latencyMs: Date.now() - startedAt,
|
|
136
|
+
stdout: "",
|
|
137
|
+
stderr: "",
|
|
138
|
+
message: "SSH command timed out",
|
|
139
|
+
});
|
|
140
|
+
}, this.config.sshTimeoutMs);
|
|
141
|
+
client
|
|
142
|
+
.on("ready", () => {
|
|
143
|
+
client.exec(command, (err, stream) => {
|
|
144
|
+
if (err) {
|
|
145
|
+
clearTimeout(timeoutId);
|
|
146
|
+
client.end();
|
|
147
|
+
resolve({
|
|
148
|
+
ok: false,
|
|
149
|
+
latencyMs: Date.now() - startedAt,
|
|
150
|
+
stdout: "",
|
|
151
|
+
stderr: "",
|
|
152
|
+
message: err.message,
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let stdout = "";
|
|
157
|
+
let stderr = "";
|
|
158
|
+
stream
|
|
159
|
+
.on("close", (code) => {
|
|
160
|
+
clearTimeout(timeoutId);
|
|
161
|
+
client.end();
|
|
162
|
+
resolve({
|
|
163
|
+
ok: code === 0,
|
|
164
|
+
latencyMs: Date.now() - startedAt,
|
|
165
|
+
stdout: stdout.trim(),
|
|
166
|
+
stderr: stderr.trim(),
|
|
167
|
+
message: code === 0 ? "SSH command succeeded" : `SSH command failed with exit code ${code ?? -1}`,
|
|
168
|
+
});
|
|
169
|
+
})
|
|
170
|
+
.on("data", (data) => {
|
|
171
|
+
stdout += data.toString("utf8");
|
|
172
|
+
});
|
|
173
|
+
stream.stderr.on("data", (data) => {
|
|
174
|
+
stderr += data.toString("utf8");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
})
|
|
178
|
+
.on("error", (error) => {
|
|
179
|
+
clearTimeout(timeoutId);
|
|
180
|
+
client.end();
|
|
181
|
+
resolve({
|
|
182
|
+
ok: false,
|
|
183
|
+
latencyMs: Date.now() - startedAt,
|
|
184
|
+
stdout: "",
|
|
185
|
+
stderr: "",
|
|
186
|
+
message: error.message,
|
|
187
|
+
});
|
|
188
|
+
})
|
|
189
|
+
.connect({
|
|
190
|
+
host: target.host,
|
|
191
|
+
port: target.port,
|
|
192
|
+
username: target.username,
|
|
193
|
+
password: target.password,
|
|
194
|
+
readyTimeout: this.config.sshTimeoutMs,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
exports.WhcSshClient = WhcSshClient;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WhcUapiClient = void 0;
|
|
4
|
+
class WhcUapiClient {
|
|
5
|
+
config;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
}
|
|
9
|
+
authHeader() {
|
|
10
|
+
return `cpanel ${this.config.user}:${this.config.apiToken}`;
|
|
11
|
+
}
|
|
12
|
+
async probe() {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timeout = setTimeout(() => controller.abort(), this.config.apiTimeoutMs);
|
|
15
|
+
const startedAt = Date.now();
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(`${this.config.apiBaseUrl}/execute/VersionControl/list_repositories`, {
|
|
18
|
+
method: "GET",
|
|
19
|
+
headers: {
|
|
20
|
+
Authorization: this.authHeader(),
|
|
21
|
+
Accept: "application/json",
|
|
22
|
+
},
|
|
23
|
+
signal: controller.signal,
|
|
24
|
+
});
|
|
25
|
+
const latencyMs = Date.now() - startedAt;
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
status: response.status,
|
|
30
|
+
latencyMs,
|
|
31
|
+
message: `UAPI probe failed with status ${response.status}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
status: response.status,
|
|
37
|
+
latencyMs,
|
|
38
|
+
message: "UAPI connectivity probe succeeded",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const latencyMs = Date.now() - startedAt;
|
|
43
|
+
const message = error instanceof Error ? error.message : "Unknown UAPI probe error";
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
latencyMs,
|
|
47
|
+
message,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
clearTimeout(timeout);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async listRepositories() {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const timeout = setTimeout(() => controller.abort(), this.config.apiTimeoutMs);
|
|
57
|
+
const startedAt = Date.now();
|
|
58
|
+
try {
|
|
59
|
+
const response = await fetch(`${this.config.apiBaseUrl}/execute/VersionControl/list_repositories`, {
|
|
60
|
+
method: "GET",
|
|
61
|
+
headers: { Authorization: this.authHeader(), Accept: "application/json" },
|
|
62
|
+
signal: controller.signal,
|
|
63
|
+
});
|
|
64
|
+
const latencyMs = Date.now() - startedAt;
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
return { ok: false, latencyMs, repositories: [], message: `HTTP ${response.status}` };
|
|
67
|
+
}
|
|
68
|
+
const body = (await response.json());
|
|
69
|
+
if (!body.result || body.result.status !== 1) {
|
|
70
|
+
const err = (body.result?.errors ?? []).join("; ") || "UAPI list_repositories failed";
|
|
71
|
+
return { ok: false, latencyMs, repositories: [], message: err };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
latencyMs,
|
|
76
|
+
repositories: body.result.data ?? [],
|
|
77
|
+
message: "list_repositories succeeded",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const latencyMs = Date.now() - startedAt;
|
|
82
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
83
|
+
return { ok: false, latencyMs, repositories: [], message };
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
clearTimeout(timeout);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async createRepository(repositoryRoot, originUrl) {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timeout = setTimeout(() => controller.abort(), this.config.apiTimeoutMs);
|
|
92
|
+
const startedAt = Date.now();
|
|
93
|
+
const params = new URLSearchParams({ repository_root: repositoryRoot, type: "git" });
|
|
94
|
+
if (originUrl) {
|
|
95
|
+
params.set("origin_url", originUrl);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(`${this.config.apiBaseUrl}/execute/VersionControl/create`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: this.authHeader(),
|
|
102
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
103
|
+
Accept: "application/json",
|
|
104
|
+
},
|
|
105
|
+
body: params.toString(),
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
});
|
|
108
|
+
const latencyMs = Date.now() - startedAt;
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
return { ok: false, latencyMs, message: `HTTP ${response.status}` };
|
|
111
|
+
}
|
|
112
|
+
const body = (await response.json());
|
|
113
|
+
if (!body.result || body.result.status !== 1) {
|
|
114
|
+
const err = (body.result?.errors ?? []).join("; ") || "UAPI create failed";
|
|
115
|
+
return { ok: false, latencyMs, message: err };
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
latencyMs,
|
|
120
|
+
repository_root: body.result.data?.repository_root ?? repositoryRoot,
|
|
121
|
+
message: "Repository created successfully",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
const latencyMs = Date.now() - startedAt;
|
|
126
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
127
|
+
return { ok: false, latencyMs, message };
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
clearTimeout(timeout);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async triggerDeployment(repositoryRoot, branch) {
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const timeout = setTimeout(() => controller.abort(), this.config.apiTimeoutMs);
|
|
136
|
+
const startedAt = Date.now();
|
|
137
|
+
const params = new URLSearchParams({ repository_root: repositoryRoot });
|
|
138
|
+
if (branch) {
|
|
139
|
+
params.set("branch", branch);
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(`${this.config.apiBaseUrl}/execute/VersionControl/deployment`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
Authorization: this.authHeader(),
|
|
146
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
147
|
+
Accept: "application/json",
|
|
148
|
+
},
|
|
149
|
+
body: params.toString(),
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
});
|
|
152
|
+
const latencyMs = Date.now() - startedAt;
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
return { ok: false, latencyMs, message: `HTTP ${response.status}` };
|
|
155
|
+
}
|
|
156
|
+
const body = (await response.json());
|
|
157
|
+
if (!body.result || body.result.status !== 1) {
|
|
158
|
+
const err = (body.result?.errors ?? []).join("; ") || "UAPI deployment trigger failed";
|
|
159
|
+
return { ok: false, latencyMs, message: err };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
latencyMs,
|
|
164
|
+
deployment_id: body.result.data?.deployment_id,
|
|
165
|
+
message: "Deployment triggered successfully",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const latencyMs = Date.now() - startedAt;
|
|
170
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
171
|
+
return { ok: false, latencyMs, message };
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
clearTimeout(timeout);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
exports.WhcUapiClient = WhcUapiClient;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WpCliClient = void 0;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const node_util_1 = require("node:util");
|
|
6
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
7
|
+
const WPCLI_CHECK_COMMAND = 'if command -v wp >/dev/null 2>&1; then wp --info | head -n 1; else echo __WPCLI_NOT_FOUND__; fi';
|
|
8
|
+
class WpCliClient {
|
|
9
|
+
config;
|
|
10
|
+
sshClient;
|
|
11
|
+
constructor(config, sshClient) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.sshClient = sshClient;
|
|
14
|
+
}
|
|
15
|
+
async probe() {
|
|
16
|
+
const prod = await this.probeWithKey(this.config.sshTargets.prod, "production");
|
|
17
|
+
const staging = await this.probeStaging();
|
|
18
|
+
return { prod, staging };
|
|
19
|
+
}
|
|
20
|
+
async probeWithKey(target, envLabel) {
|
|
21
|
+
const result = await this.sshClient.execWithKey(target, WPCLI_CHECK_COMMAND);
|
|
22
|
+
if (!result.ok && result.stdout.length === 0) {
|
|
23
|
+
return {
|
|
24
|
+
reachable: false,
|
|
25
|
+
available: false,
|
|
26
|
+
latencyMs: result.latencyMs,
|
|
27
|
+
message: `${envLabel} SSH command failed: ${result.message}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (result.stdout.includes("__WPCLI_NOT_FOUND__")) {
|
|
31
|
+
return {
|
|
32
|
+
reachable: true,
|
|
33
|
+
available: false,
|
|
34
|
+
latencyMs: result.latencyMs,
|
|
35
|
+
message: `${envLabel} reachable but WP-CLI is not installed or not in PATH`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
reachable: true,
|
|
40
|
+
available: true,
|
|
41
|
+
latencyMs: result.latencyMs,
|
|
42
|
+
message: `${envLabel} WP-CLI available`,
|
|
43
|
+
versionLine: result.stdout.split("\n")[0],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async probeStaging() {
|
|
47
|
+
const stagingTarget = this.config.sshTargets.staging;
|
|
48
|
+
if (!stagingTarget) {
|
|
49
|
+
return {
|
|
50
|
+
reachable: false,
|
|
51
|
+
available: false,
|
|
52
|
+
latencyMs: 0,
|
|
53
|
+
message: "staging SSH target is not configured",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (stagingTarget.privateKeyPath) {
|
|
57
|
+
return this.probeWithKey({
|
|
58
|
+
host: stagingTarget.host,
|
|
59
|
+
port: stagingTarget.port,
|
|
60
|
+
username: stagingTarget.username,
|
|
61
|
+
privateKeyPath: stagingTarget.privateKeyPath,
|
|
62
|
+
}, "staging");
|
|
63
|
+
}
|
|
64
|
+
if (!stagingTarget.password) {
|
|
65
|
+
return {
|
|
66
|
+
reachable: false,
|
|
67
|
+
available: false,
|
|
68
|
+
latencyMs: 0,
|
|
69
|
+
message: "staging key not set and staging password not configured",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const startedAt = Date.now();
|
|
73
|
+
const args = [
|
|
74
|
+
"-ssh",
|
|
75
|
+
"-batch",
|
|
76
|
+
"-P",
|
|
77
|
+
String(stagingTarget.port),
|
|
78
|
+
"-l",
|
|
79
|
+
stagingTarget.username,
|
|
80
|
+
"-pw",
|
|
81
|
+
stagingTarget.password,
|
|
82
|
+
];
|
|
83
|
+
if (stagingTarget.hostKey) {
|
|
84
|
+
args.push("-hostkey", stagingTarget.hostKey);
|
|
85
|
+
}
|
|
86
|
+
args.push(stagingTarget.host, WPCLI_CHECK_COMMAND);
|
|
87
|
+
try {
|
|
88
|
+
const { stdout } = await execFileAsync("plink", args, {
|
|
89
|
+
timeout: this.config.sshTimeoutMs,
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
});
|
|
92
|
+
const latencyMs = Date.now() - startedAt;
|
|
93
|
+
const output = stdout.trim();
|
|
94
|
+
if (output.includes("__WPCLI_NOT_FOUND__")) {
|
|
95
|
+
return {
|
|
96
|
+
reachable: true,
|
|
97
|
+
available: false,
|
|
98
|
+
latencyMs,
|
|
99
|
+
message: "staging reachable but WP-CLI is not installed or not in PATH",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
reachable: true,
|
|
104
|
+
available: output.length > 0,
|
|
105
|
+
latencyMs,
|
|
106
|
+
message: output.length > 0 ? "staging WP-CLI available" : "staging reachable but WP-CLI check returned empty output",
|
|
107
|
+
versionLine: output.split("\n")[0] || undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const latencyMs = Date.now() - startedAt;
|
|
112
|
+
const message = error instanceof Error ? error.message : "unknown staging WP-CLI probe error";
|
|
113
|
+
const hostKeyHint = stagingTarget.hostKey
|
|
114
|
+
? ""
|
|
115
|
+
: " (tip: set WHC_STAGING_SSH_HOSTKEY to avoid first-time hostkey trust blocks)";
|
|
116
|
+
return {
|
|
117
|
+
reachable: false,
|
|
118
|
+
available: false,
|
|
119
|
+
latencyMs,
|
|
120
|
+
message: `staging WP-CLI probe failed: ${message}${hostKeyHint}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
exports.WpCliClient = WpCliClient;
|