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,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeWhcPrepare = executeWhcPrepare;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const workspace_state_1 = require("../state/workspace-state");
|
|
8
|
+
async function executeWhcPrepare(_config, request) {
|
|
9
|
+
const startedAt = Date.now();
|
|
10
|
+
const rootDir = request.payload.workspace_root ?? process.cwd();
|
|
11
|
+
const relativeStateRoot = (process.env.WHC_STATE_ROOT ?? workspace_state_1.DEFAULT_STATE_ROOT).trim() || workspace_state_1.DEFAULT_STATE_ROOT;
|
|
12
|
+
const stateRootAbs = (0, workspace_state_1.resolveStateRoot)(rootDir);
|
|
13
|
+
const pipelineStatusFile = (0, workspace_state_1.getPipelineStatusFile)(rootDir);
|
|
14
|
+
const trackedPaths = [
|
|
15
|
+
relativeStateRoot,
|
|
16
|
+
`${relativeStateRoot}/state`,
|
|
17
|
+
`${relativeStateRoot}/state/releases`,
|
|
18
|
+
`${relativeStateRoot}/state/reports`,
|
|
19
|
+
`${relativeStateRoot}/logs`,
|
|
20
|
+
`${relativeStateRoot}/env`,
|
|
21
|
+
];
|
|
22
|
+
const existingBefore = new Set(trackedPaths.filter((relativePath) => (0, node_fs_1.existsSync)((0, node_path_1.join)(rootDir, ...relativePath.split("/").filter(Boolean)))));
|
|
23
|
+
const hadStatusBefore = (0, node_fs_1.existsSync)(pipelineStatusFile);
|
|
24
|
+
if (request.dry_run) {
|
|
25
|
+
const checks = buildChecks();
|
|
26
|
+
const manualActions = checks.filter((item) => item.status === "fail").map((item) => item.details);
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
action_id: (0, node_crypto_1.randomUUID)(),
|
|
30
|
+
tool: "whc_prepare",
|
|
31
|
+
data: {
|
|
32
|
+
state_root: relativeStateRoot,
|
|
33
|
+
created_paths: trackedPaths.filter((item) => !existingBefore.has(item)),
|
|
34
|
+
gitignore_updated: false,
|
|
35
|
+
already_initialized: hadStatusBefore,
|
|
36
|
+
ready: manualActions.length === 0,
|
|
37
|
+
manual_actions: manualActions,
|
|
38
|
+
checks,
|
|
39
|
+
pipeline_status_file: pipelineStatusFile,
|
|
40
|
+
next_step: "Set dry_run=false to materialize state files and continue to whc_deploy.",
|
|
41
|
+
},
|
|
42
|
+
error: null,
|
|
43
|
+
meta: {
|
|
44
|
+
latency_ms: Date.now() - startedAt,
|
|
45
|
+
safety_level: "B",
|
|
46
|
+
delivery_mechanism: "file_transfer",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
(0, workspace_state_1.ensureWorkspaceState)(rootDir);
|
|
51
|
+
const gitignoreUpdated = request.payload.ensure_gitignore_rule !== false
|
|
52
|
+
? ensureGitignoreRule(rootDir, ".mcp/")
|
|
53
|
+
: false;
|
|
54
|
+
if (!(0, node_fs_1.existsSync)(pipelineStatusFile) || request.payload.force_reinitialize) {
|
|
55
|
+
const payload = {
|
|
56
|
+
started: false,
|
|
57
|
+
completed: false,
|
|
58
|
+
status: "idle",
|
|
59
|
+
next_step: "Run whc_deploy after whc_prepare completes.",
|
|
60
|
+
};
|
|
61
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(stateRootAbs, "state"), { recursive: true });
|
|
62
|
+
(0, node_fs_1.writeFileSync)(pipelineStatusFile, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
63
|
+
}
|
|
64
|
+
const checks = buildChecks();
|
|
65
|
+
const manualActions = checks.filter((item) => item.status === "fail").map((item) => item.details);
|
|
66
|
+
const createdPaths = trackedPaths.filter((item) => !existingBefore.has(item));
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
action_id: (0, node_crypto_1.randomUUID)(),
|
|
70
|
+
tool: "whc_prepare",
|
|
71
|
+
data: {
|
|
72
|
+
state_root: relativeStateRoot,
|
|
73
|
+
created_paths: createdPaths,
|
|
74
|
+
gitignore_updated: gitignoreUpdated,
|
|
75
|
+
already_initialized: hadStatusBefore,
|
|
76
|
+
ready: manualActions.length === 0,
|
|
77
|
+
manual_actions: manualActions,
|
|
78
|
+
checks,
|
|
79
|
+
pipeline_status_file: pipelineStatusFile,
|
|
80
|
+
next_step: manualActions.length > 0
|
|
81
|
+
? "Complete manual_actions before running whc_deploy."
|
|
82
|
+
: "Run whc_deploy (dry_run first) then whc_verify.",
|
|
83
|
+
},
|
|
84
|
+
error: null,
|
|
85
|
+
meta: {
|
|
86
|
+
latency_ms: Date.now() - startedAt,
|
|
87
|
+
safety_level: "B",
|
|
88
|
+
delivery_mechanism: "file_transfer",
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function buildChecks() {
|
|
93
|
+
const requiredEnv = [
|
|
94
|
+
"WHC_API_TOKEN",
|
|
95
|
+
"WHC_USER",
|
|
96
|
+
"WHC_HOST",
|
|
97
|
+
"WHC_PROD_SSH_HOST",
|
|
98
|
+
"WHC_PROD_SSH_USERNAME",
|
|
99
|
+
"WHC_PROD_SSH_PRIVATE_KEY_PATH",
|
|
100
|
+
];
|
|
101
|
+
return requiredEnv.map((key) => {
|
|
102
|
+
const value = (process.env[key] ?? "").trim();
|
|
103
|
+
if (value.length > 0) {
|
|
104
|
+
return { name: key, status: "pass", details: `${key} is configured.` };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
name: key,
|
|
108
|
+
status: "fail",
|
|
109
|
+
details: `Set ${key} in your .env before deploy.`,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function ensureGitignoreRule(rootDir, rule) {
|
|
114
|
+
const gitignorePath = (0, node_path_1.join)(rootDir, ".gitignore");
|
|
115
|
+
if (!(0, node_fs_1.existsSync)(gitignorePath)) {
|
|
116
|
+
(0, node_fs_1.writeFileSync)(gitignorePath, `${rule}\n`, "utf8");
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
const content = (0, node_fs_1.readFileSync)(gitignorePath, "utf8");
|
|
120
|
+
const hasRule = content.split(/\r?\n/).some((line) => line.trim() === rule);
|
|
121
|
+
if (hasRule) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const next = content.endsWith("\n") ? `${content}${rule}\n` : `${content}\n${rule}\n`;
|
|
125
|
+
(0, node_fs_1.writeFileSync)(gitignorePath, next, "utf8");
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeWhcRollback = executeWhcRollback;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const whc_uapi_client_1 = require("../clients/whc-uapi-client");
|
|
6
|
+
const ssh_client_1 = require("../clients/ssh-client");
|
|
7
|
+
const workspace_state_1 = require("../state/workspace-state");
|
|
8
|
+
async function executeWhcRollback(config, request, deps = {}) {
|
|
9
|
+
const startedAt = Date.now();
|
|
10
|
+
const actionIdFactory = deps.actionIdFactory ?? node_crypto_1.randomUUID;
|
|
11
|
+
const uapiClient = deps.uapiClient ?? new whc_uapi_client_1.WhcUapiClient(config);
|
|
12
|
+
const sshClient = deps.sshClient ?? new ssh_client_1.WhcSshClient(config);
|
|
13
|
+
const chain = resolveVerifyChain(request);
|
|
14
|
+
if (!chain.accepted) {
|
|
15
|
+
return buildError(actionIdFactory(), startedAt, "BUSINESS_POLICY_BLOCKED", "Rollback requires a PASS verify-chain from whc_verify. Provide verify_release_id/verify_report_path with final_status=PASS.");
|
|
16
|
+
}
|
|
17
|
+
const actions = [];
|
|
18
|
+
const warnings = [];
|
|
19
|
+
if (request.dry_run) {
|
|
20
|
+
if (request.payload.rollback_mode === "git_branch") {
|
|
21
|
+
const repositoryRoot = request.payload.repository_root ?? config.paths.prod;
|
|
22
|
+
actions.push(`Would trigger UAPI deployment on ${repositoryRoot} with branch ${request.payload.rollback_branch}.`);
|
|
23
|
+
}
|
|
24
|
+
if (request.payload.rollback_mode === "db_backup") {
|
|
25
|
+
actions.push(`Would import DB backup reference ${request.payload.backup_reference} on ${request.payload.target_environment}.`);
|
|
26
|
+
warnings.push("DB restore can overwrite runtime data and is irreversible without additional backups.");
|
|
27
|
+
}
|
|
28
|
+
if (request.payload.rollback_mode === "managed_sync_reverse") {
|
|
29
|
+
actions.push(`Would perform managed reverse sync direction ${request.payload.direction}.`);
|
|
30
|
+
warnings.push("Managed reverse sync can overwrite files/data on destination environment.");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
action_id: actionIdFactory(),
|
|
35
|
+
tool: "whc_rollback",
|
|
36
|
+
data: {
|
|
37
|
+
rollback_mode: request.payload.rollback_mode,
|
|
38
|
+
rollback_status: "preview",
|
|
39
|
+
actions,
|
|
40
|
+
warnings,
|
|
41
|
+
verify_required: true,
|
|
42
|
+
verify_chain: chain,
|
|
43
|
+
next_step: "Re-run with dry_run=false and confirmed=true to execute rollback.",
|
|
44
|
+
},
|
|
45
|
+
error: null,
|
|
46
|
+
meta: {
|
|
47
|
+
latency_ms: Date.now() - startedAt,
|
|
48
|
+
safety_level: "D",
|
|
49
|
+
delivery_mechanism: "git_deploy",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (request.payload.rollback_mode === "git_branch") {
|
|
54
|
+
const repositoryRoot = request.payload.repository_root ?? config.paths.prod;
|
|
55
|
+
const result = await uapiClient.triggerDeployment(repositoryRoot, request.payload.rollback_branch);
|
|
56
|
+
if (!result.ok) {
|
|
57
|
+
return buildError(actionIdFactory(), startedAt, "TEMPORARY_UPSTREAM_ERROR", `Rollback deployment failed: ${result.message}`);
|
|
58
|
+
}
|
|
59
|
+
actions.push(`Triggered rollback deployment on ${repositoryRoot} with branch ${request.payload.rollback_branch}.`);
|
|
60
|
+
}
|
|
61
|
+
if (request.payload.rollback_mode === "db_backup") {
|
|
62
|
+
const target = request.payload.target_environment === "staging" ? config.sshTargets.staging : config.sshTargets.prod;
|
|
63
|
+
if (!target || !target.privateKeyPath) {
|
|
64
|
+
return buildError(actionIdFactory(), startedAt, "BUSINESS_POLICY_BLOCKED", "DB rollback requires key-based SSH target configuration.");
|
|
65
|
+
}
|
|
66
|
+
const dumpPath = request.payload.backup_reference;
|
|
67
|
+
const wpPath = request.payload.target_environment === "staging" ? config.paths.staging ?? config.paths.prod : config.paths.prod;
|
|
68
|
+
const cmd = `cd '${wpPath.replace(/'/g, `"'"`)}' && wp db import '${dumpPath.replace(/'/g, `"'"`)}'`;
|
|
69
|
+
const sshResult = await sshClient.execWithKey({
|
|
70
|
+
host: target.host,
|
|
71
|
+
port: target.port,
|
|
72
|
+
username: target.username,
|
|
73
|
+
privateKeyPath: target.privateKeyPath,
|
|
74
|
+
}, cmd);
|
|
75
|
+
if (!sshResult.ok) {
|
|
76
|
+
return buildError(actionIdFactory(), startedAt, "TEMPORARY_UPSTREAM_ERROR", `DB rollback failed: ${sshResult.message}`);
|
|
77
|
+
}
|
|
78
|
+
actions.push(`Imported DB backup ${dumpPath} on ${request.payload.target_environment}.`);
|
|
79
|
+
warnings.push("DB rollback executed. Run whc_verify immediately.");
|
|
80
|
+
}
|
|
81
|
+
if (request.payload.rollback_mode === "managed_sync_reverse") {
|
|
82
|
+
warnings.push("managed_sync_reverse execution is not automated yet; manual WHC action required.");
|
|
83
|
+
actions.push(`Manual action required: execute reverse sync ${request.payload.direction} from WHC panel.`);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
action_id: actionIdFactory(),
|
|
88
|
+
tool: "whc_rollback",
|
|
89
|
+
data: {
|
|
90
|
+
rollback_mode: request.payload.rollback_mode,
|
|
91
|
+
rollback_status: "executed",
|
|
92
|
+
actions,
|
|
93
|
+
warnings,
|
|
94
|
+
verify_required: true,
|
|
95
|
+
verify_chain: chain,
|
|
96
|
+
next_step: "Run whc_verify to confirm rollback integrity.",
|
|
97
|
+
},
|
|
98
|
+
error: null,
|
|
99
|
+
meta: {
|
|
100
|
+
latency_ms: Date.now() - startedAt,
|
|
101
|
+
safety_level: "D",
|
|
102
|
+
delivery_mechanism: request.payload.rollback_mode === "git_branch" ? "git_deploy" : "ssh_exec",
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function resolveVerifyChain(request) {
|
|
107
|
+
const reportPath = request.payload.verify_report_path ?? (0, workspace_state_1.findLatestVerifyReportFile)(process.cwd());
|
|
108
|
+
if (!reportPath) {
|
|
109
|
+
return { source: "missing", final_status: "missing", accepted: false };
|
|
110
|
+
}
|
|
111
|
+
const report = (0, workspace_state_1.readJsonFile)(reportPath);
|
|
112
|
+
if (!report) {
|
|
113
|
+
return { source: reportPath, final_status: "invalid", accepted: false };
|
|
114
|
+
}
|
|
115
|
+
if (request.payload.verify_release_id && report.release_id !== request.payload.verify_release_id) {
|
|
116
|
+
return { source: reportPath, final_status: String(report.final_status ?? "unknown"), accepted: false };
|
|
117
|
+
}
|
|
118
|
+
const finalStatus = String(report.final_status ?? "unknown");
|
|
119
|
+
return {
|
|
120
|
+
source: reportPath,
|
|
121
|
+
final_status: finalStatus,
|
|
122
|
+
accepted: finalStatus === "PASS",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function buildError(actionId, startedAt, code, message) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
action_id: actionId,
|
|
129
|
+
tool: "whc_rollback",
|
|
130
|
+
data: null,
|
|
131
|
+
error: {
|
|
132
|
+
code,
|
|
133
|
+
message,
|
|
134
|
+
retryable: code === "TEMPORARY_UPSTREAM_ERROR",
|
|
135
|
+
},
|
|
136
|
+
meta: {
|
|
137
|
+
latency_ms: Date.now() - startedAt,
|
|
138
|
+
safety_level: "D",
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeWhcSetupRemote = executeWhcSetupRemote;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const whc_uapi_client_1 = require("../clients/whc-uapi-client");
|
|
6
|
+
async function executeWhcSetupRemote(config, request, deps = {}) {
|
|
7
|
+
const startedAt = Date.now();
|
|
8
|
+
const actionIdFactory = deps.actionIdFactory ?? node_crypto_1.randomUUID;
|
|
9
|
+
const uapiClient = deps.uapiClient ?? new whc_uapi_client_1.WhcUapiClient(config);
|
|
10
|
+
const { deploy_target_path, clone_url, source_profile, release_intent, pipeline_id } = request.payload;
|
|
11
|
+
const pathOwner = extractHomeOwner(deploy_target_path);
|
|
12
|
+
if (pathOwner && pathOwner !== config.user) {
|
|
13
|
+
const isStaging = request.payload.target_environment === "staging";
|
|
14
|
+
const lines = [
|
|
15
|
+
`Path owner mismatch: deploy_target_path belongs to '${pathOwner}' but WHC_USER is '${config.user}'.`,
|
|
16
|
+
];
|
|
17
|
+
if (isStaging) {
|
|
18
|
+
lines.push("WHC managed staging uses a separate cPanel account; git repo setup via this user's UAPI cannot target it.", "Use managed_clone_sync mode to sync staging from live, or configure dedicated staging credentials.");
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const suggestedPayload = buildSuggestedSetupRemotePayload(request, config.user);
|
|
22
|
+
lines.push(`Use a repository path under /home/${config.user}/... or switch credentials/workflow mode.`, "Copy-ready payload using current WHC_USER:", JSON.stringify(suggestedPayload, null, 2));
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
action_id: actionIdFactory(),
|
|
27
|
+
tool: "whc_setup_remote",
|
|
28
|
+
data: null,
|
|
29
|
+
error: {
|
|
30
|
+
code: "BUSINESS_POLICY_BLOCKED",
|
|
31
|
+
message: lines.join("\n"),
|
|
32
|
+
retryable: false,
|
|
33
|
+
},
|
|
34
|
+
meta: {
|
|
35
|
+
latency_ms: Date.now() - startedAt,
|
|
36
|
+
safety_level: "C",
|
|
37
|
+
workflow_mode: config.workflowMode,
|
|
38
|
+
pipeline_id,
|
|
39
|
+
release_intent,
|
|
40
|
+
source_profile,
|
|
41
|
+
delivery_mechanism: "git_deploy",
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Dry run: return preview without making changes
|
|
46
|
+
if (request.dry_run) {
|
|
47
|
+
const sshRemoteHint = buildSshRemoteHint(config, deploy_target_path);
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
action_id: actionIdFactory(),
|
|
51
|
+
tool: "whc_setup_remote",
|
|
52
|
+
data: {
|
|
53
|
+
repository_root: deploy_target_path,
|
|
54
|
+
ssh_remote_hint: sshRemoteHint,
|
|
55
|
+
was_existing: false,
|
|
56
|
+
baseline_kind: "unknown",
|
|
57
|
+
migration_guidance: "dry_run: no changes made. Run without dry_run to create repository.",
|
|
58
|
+
pipeline_status: "pass_partial",
|
|
59
|
+
phase_coverage: buildSetupRemotePhaseCoverage(true),
|
|
60
|
+
process_tracking: {
|
|
61
|
+
started: true,
|
|
62
|
+
completed: true,
|
|
63
|
+
stage: "setup_remote",
|
|
64
|
+
status: "needs_manual_input",
|
|
65
|
+
next_step: "Run whc_setup_remote without dry_run to create repository, then push local source to the returned SSH remote.",
|
|
66
|
+
manual_actions: [
|
|
67
|
+
"Ensure cPanel API token is available in WHC_API_TOKEN.",
|
|
68
|
+
"Ensure SSH key is authorized on target cPanel account.",
|
|
69
|
+
"Run local git remote add/set-url and git push to the ssh_remote_hint.",
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
warnings: [],
|
|
73
|
+
},
|
|
74
|
+
error: null,
|
|
75
|
+
meta: {
|
|
76
|
+
latency_ms: Date.now() - startedAt,
|
|
77
|
+
safety_level: "C",
|
|
78
|
+
workflow_mode: config.workflowMode,
|
|
79
|
+
pipeline_id,
|
|
80
|
+
release_intent,
|
|
81
|
+
source_profile,
|
|
82
|
+
delivery_mechanism: "git_deploy",
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// Check for existing repository at target path
|
|
87
|
+
let listResult;
|
|
88
|
+
try {
|
|
89
|
+
listResult = await uapiClient.listRepositories();
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const message = error instanceof Error ? error.message : "Failed to list repositories";
|
|
93
|
+
return buildUapiErrorResponse(actionIdFactory(), startedAt, request, "AUTH_ERROR", message);
|
|
94
|
+
}
|
|
95
|
+
if (!listResult.ok) {
|
|
96
|
+
return buildUapiErrorResponse(actionIdFactory(), startedAt, request, "TEMPORARY_UPSTREAM_ERROR", listResult.message);
|
|
97
|
+
}
|
|
98
|
+
const existing = listResult.repositories.find((r) => r.repository_root === deploy_target_path);
|
|
99
|
+
if (existing) {
|
|
100
|
+
const sshRemoteHint = buildSshRemoteHint(config, deploy_target_path);
|
|
101
|
+
const baseline = inferBaselineKind(source_profile.source_kind);
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
action_id: actionIdFactory(),
|
|
105
|
+
tool: "whc_setup_remote",
|
|
106
|
+
data: {
|
|
107
|
+
repository_root: deploy_target_path,
|
|
108
|
+
ssh_remote_hint: sshRemoteHint,
|
|
109
|
+
was_existing: true,
|
|
110
|
+
baseline_kind: baseline,
|
|
111
|
+
migration_guidance: buildMigrationGuidance(baseline, true),
|
|
112
|
+
pipeline_status: "pass_partial",
|
|
113
|
+
phase_coverage: buildSetupRemotePhaseCoverage(false),
|
|
114
|
+
process_tracking: {
|
|
115
|
+
started: true,
|
|
116
|
+
completed: true,
|
|
117
|
+
stage: "setup_remote",
|
|
118
|
+
status: "needs_manual_input",
|
|
119
|
+
next_step: "Push/update local source to this existing repository root, then run whc_deploy for the target environment.",
|
|
120
|
+
manual_actions: [
|
|
121
|
+
"Verify local git remote points to ssh_remote_hint.",
|
|
122
|
+
"Push target branch from local workspace.",
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
warnings: [],
|
|
126
|
+
},
|
|
127
|
+
error: null,
|
|
128
|
+
meta: {
|
|
129
|
+
latency_ms: Date.now() - startedAt,
|
|
130
|
+
safety_level: "C",
|
|
131
|
+
workflow_mode: config.workflowMode,
|
|
132
|
+
pipeline_id,
|
|
133
|
+
release_intent,
|
|
134
|
+
source_profile,
|
|
135
|
+
delivery_mechanism: "git_deploy",
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// Create repository
|
|
140
|
+
const createResult = await uapiClient.createRepository(deploy_target_path, clone_url);
|
|
141
|
+
if (!createResult.ok) {
|
|
142
|
+
return buildUapiErrorResponse(actionIdFactory(), startedAt, request, "TEMPORARY_UPSTREAM_ERROR", createResult.message);
|
|
143
|
+
}
|
|
144
|
+
const sshRemoteHint = buildSshRemoteHint(config, deploy_target_path);
|
|
145
|
+
const baseline = inferBaselineKind(source_profile.source_kind);
|
|
146
|
+
const warnings = buildWarnings(source_profile.source_kind, release_intent);
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
action_id: actionIdFactory(),
|
|
150
|
+
tool: "whc_setup_remote",
|
|
151
|
+
data: {
|
|
152
|
+
repository_root: createResult.repository_root ?? deploy_target_path,
|
|
153
|
+
ssh_remote_hint: sshRemoteHint,
|
|
154
|
+
was_existing: false,
|
|
155
|
+
baseline_kind: baseline,
|
|
156
|
+
migration_guidance: buildMigrationGuidance(baseline, false),
|
|
157
|
+
pipeline_status: "pass_partial",
|
|
158
|
+
phase_coverage: buildSetupRemotePhaseCoverage(false),
|
|
159
|
+
process_tracking: {
|
|
160
|
+
started: true,
|
|
161
|
+
completed: true,
|
|
162
|
+
stage: "setup_remote",
|
|
163
|
+
status: "needs_manual_input",
|
|
164
|
+
next_step: "Set local git remote to ssh_remote_hint, push source branch, then execute whc_deploy.",
|
|
165
|
+
manual_actions: [
|
|
166
|
+
"Run: git remote add whc <ssh_remote_hint> (or git remote set-url whc <ssh_remote_hint>).",
|
|
167
|
+
"Run: git push whc <branch>.",
|
|
168
|
+
"Run whc_deploy with matching repository_root and branch.",
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
warnings,
|
|
172
|
+
},
|
|
173
|
+
error: null,
|
|
174
|
+
meta: {
|
|
175
|
+
latency_ms: Date.now() - startedAt,
|
|
176
|
+
safety_level: "C",
|
|
177
|
+
workflow_mode: config.workflowMode,
|
|
178
|
+
pipeline_id,
|
|
179
|
+
release_intent,
|
|
180
|
+
source_profile,
|
|
181
|
+
delivery_mechanism: "git_deploy",
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function buildSshRemoteHint(config, deployTargetPath) {
|
|
186
|
+
const { username, host } = config.sshTargets.prod;
|
|
187
|
+
return `${username}@${host}:${deployTargetPath}`;
|
|
188
|
+
}
|
|
189
|
+
function inferBaselineKind(sourceKind) {
|
|
190
|
+
if (sourceKind === "full_site")
|
|
191
|
+
return "custom_app";
|
|
192
|
+
if (sourceKind === "partial_content" || sourceKind === "package_only")
|
|
193
|
+
return "custom_app";
|
|
194
|
+
return "unknown";
|
|
195
|
+
}
|
|
196
|
+
function buildMigrationGuidance(baseline, wasExisting) {
|
|
197
|
+
if (wasExisting) {
|
|
198
|
+
return "Repository already exists at target path. Verify remote URL in your local repo with `git remote -v`.";
|
|
199
|
+
}
|
|
200
|
+
if (baseline === "unknown") {
|
|
201
|
+
return "Repository created. Inspect target path baseline before first deploy: confirm whether default WP or custom app is present, then reconcile runtime state.";
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
function buildWarnings(sourceKind, releaseIntent) {
|
|
206
|
+
const warnings = [];
|
|
207
|
+
if (releaseIntent === "migrate") {
|
|
208
|
+
warnings.push("Release intent is migrate: confirm uploads and content dependencies before promoting to live.");
|
|
209
|
+
}
|
|
210
|
+
if (sourceKind === "full_site" && releaseIntent === "deploy") {
|
|
211
|
+
warnings.push("Full-site source: confirm deploy target path matches the intended webroot, not a subdirectory.");
|
|
212
|
+
}
|
|
213
|
+
return warnings;
|
|
214
|
+
}
|
|
215
|
+
function buildSetupRemotePhaseCoverage(dryRun) {
|
|
216
|
+
return {
|
|
217
|
+
code_state: "excluded",
|
|
218
|
+
runtime_state: "excluded",
|
|
219
|
+
data_state: "excluded",
|
|
220
|
+
deployment_state: dryRun ? "excluded" : "included",
|
|
221
|
+
notes: [
|
|
222
|
+
"whc_setup_remote prepares Git repository wiring only; it does not sync code or bootstrap WordPress runtime/data.",
|
|
223
|
+
"Manual local git push is required after setup to move source code.",
|
|
224
|
+
dryRun ? "dry_run preview only; repository not created yet." : "Repository setup completed; continue with git push and deploy.",
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function extractHomeOwner(pathValue) {
|
|
229
|
+
const match = pathValue.match(/^\/home\/([^\/]+)\//);
|
|
230
|
+
return match?.[1];
|
|
231
|
+
}
|
|
232
|
+
function buildSuggestedSetupRemotePayload(request, currentUser) {
|
|
233
|
+
return {
|
|
234
|
+
request_id: request.request_id,
|
|
235
|
+
actor: request.actor,
|
|
236
|
+
dry_run: request.dry_run,
|
|
237
|
+
confirmed: request.confirmed,
|
|
238
|
+
idempotency_key: request.idempotency_key,
|
|
239
|
+
payload: {
|
|
240
|
+
...request.payload,
|
|
241
|
+
deploy_target_path: `/home/${currentUser}/public_html`,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function buildUapiErrorResponse(actionId, startedAt, request, code, message) {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
action_id: actionId,
|
|
249
|
+
tool: "whc_setup_remote",
|
|
250
|
+
data: null,
|
|
251
|
+
error: { code, message, retryable: code === "TEMPORARY_UPSTREAM_ERROR" },
|
|
252
|
+
meta: {
|
|
253
|
+
latency_ms: Date.now() - startedAt,
|
|
254
|
+
safety_level: "C",
|
|
255
|
+
workflow_mode: undefined,
|
|
256
|
+
pipeline_id: request.payload.pipeline_id,
|
|
257
|
+
release_intent: request.payload.release_intent,
|
|
258
|
+
source_profile: request.payload.source_profile,
|
|
259
|
+
delivery_mechanism: "git_deploy",
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeWhcSshExec = executeWhcSshExec;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const whc_ssh_exec_1 = require("../schemas/whc-ssh-exec");
|
|
6
|
+
const ssh_client_1 = require("../clients/ssh-client");
|
|
7
|
+
async function executeWhcSshExec(config, request, deps = {}) {
|
|
8
|
+
const startedAt = Date.now();
|
|
9
|
+
const actionIdFactory = deps.actionIdFactory ?? node_crypto_1.randomUUID;
|
|
10
|
+
const sshClient = deps.sshClient ?? new ssh_client_1.WhcSshClient(config);
|
|
11
|
+
const { target_environment, raw_command, working_dir } = request.payload;
|
|
12
|
+
// Check allowlist/denylist before any SSH attempt
|
|
13
|
+
const allowed = (0, whc_ssh_exec_1.isCommandAllowed)(raw_command);
|
|
14
|
+
if (!allowed.allowed) {
|
|
15
|
+
return {
|
|
16
|
+
ok: false,
|
|
17
|
+
action_id: actionIdFactory(),
|
|
18
|
+
tool: "whc_ssh_exec",
|
|
19
|
+
data: null,
|
|
20
|
+
error: {
|
|
21
|
+
code: "BUSINESS_POLICY_BLOCKED",
|
|
22
|
+
message: allowed.reason ?? "Command blocked by policy",
|
|
23
|
+
retryable: false,
|
|
24
|
+
},
|
|
25
|
+
meta: { latency_ms: Date.now() - startedAt, safety_level: "C" },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Dry run: return preview without executing
|
|
29
|
+
if (request.dry_run) {
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
action_id: actionIdFactory(),
|
|
33
|
+
tool: "whc_ssh_exec",
|
|
34
|
+
data: {
|
|
35
|
+
target_environment,
|
|
36
|
+
command: raw_command,
|
|
37
|
+
stdout: "",
|
|
38
|
+
stderr: "",
|
|
39
|
+
exit_ok: true,
|
|
40
|
+
},
|
|
41
|
+
error: null,
|
|
42
|
+
meta: {
|
|
43
|
+
latency_ms: Date.now() - startedAt,
|
|
44
|
+
safety_level: "C",
|
|
45
|
+
delivery_mechanism: "ssh_exec",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const finalCommand = working_dir ? `cd ${working_dir} && ${raw_command}` : raw_command;
|
|
50
|
+
let execResult;
|
|
51
|
+
try {
|
|
52
|
+
if (target_environment === "staging") {
|
|
53
|
+
const stagingTarget = config.sshTargets.staging;
|
|
54
|
+
if (!stagingTarget) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
action_id: actionIdFactory(),
|
|
58
|
+
tool: "whc_ssh_exec",
|
|
59
|
+
data: null,
|
|
60
|
+
error: {
|
|
61
|
+
code: "BUSINESS_POLICY_BLOCKED",
|
|
62
|
+
message: "Staging SSH target is not configured.",
|
|
63
|
+
retryable: false,
|
|
64
|
+
},
|
|
65
|
+
meta: { latency_ms: Date.now() - startedAt, safety_level: "C" },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (!stagingTarget.privateKeyPath) {
|
|
69
|
+
if (!stagingTarget.password) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
action_id: actionIdFactory(),
|
|
73
|
+
tool: "whc_ssh_exec",
|
|
74
|
+
data: null,
|
|
75
|
+
error: {
|
|
76
|
+
code: "BUSINESS_POLICY_BLOCKED",
|
|
77
|
+
message: "whc_ssh_exec requires staging SSH credentials. Configure either WHC_STAGING_SSH_PRIVATE_KEY_PATH or WHC_STAGING_SSH_PASSWORD.",
|
|
78
|
+
retryable: false,
|
|
79
|
+
},
|
|
80
|
+
meta: { latency_ms: Date.now() - startedAt, safety_level: "C" },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
execResult = await sshClient.execWithPassword({
|
|
84
|
+
host: stagingTarget.host,
|
|
85
|
+
port: stagingTarget.port,
|
|
86
|
+
username: stagingTarget.username,
|
|
87
|
+
password: stagingTarget.password,
|
|
88
|
+
}, finalCommand);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
execResult = await sshClient.execWithKey({
|
|
92
|
+
host: stagingTarget.host,
|
|
93
|
+
port: stagingTarget.port,
|
|
94
|
+
username: stagingTarget.username,
|
|
95
|
+
privateKeyPath: stagingTarget.privateKeyPath,
|
|
96
|
+
}, finalCommand);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
execResult = await sshClient.execWithKey(config.sshTargets.prod, finalCommand);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
const message = error instanceof Error ? error.message : "SSH exec failed";
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
action_id: actionIdFactory(),
|
|
108
|
+
tool: "whc_ssh_exec",
|
|
109
|
+
data: null,
|
|
110
|
+
error: { code: "AUTH_ERROR", message, retryable: false },
|
|
111
|
+
meta: { latency_ms: Date.now() - startedAt, safety_level: "C" },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: execResult.ok,
|
|
116
|
+
action_id: actionIdFactory(),
|
|
117
|
+
tool: "whc_ssh_exec",
|
|
118
|
+
data: {
|
|
119
|
+
target_environment,
|
|
120
|
+
command: raw_command,
|
|
121
|
+
stdout: execResult.stdout,
|
|
122
|
+
stderr: execResult.stderr,
|
|
123
|
+
exit_ok: execResult.ok,
|
|
124
|
+
},
|
|
125
|
+
error: execResult.ok
|
|
126
|
+
? null
|
|
127
|
+
: {
|
|
128
|
+
code: "UNKNOWN_UPSTREAM_ERROR",
|
|
129
|
+
message: execResult.message || "Command exited with non-zero status",
|
|
130
|
+
retryable: false,
|
|
131
|
+
},
|
|
132
|
+
meta: {
|
|
133
|
+
latency_ms: Date.now() - startedAt,
|
|
134
|
+
safety_level: "C",
|
|
135
|
+
delivery_mechanism: "ssh_exec",
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|