@hadooppei/hwcode 0.2.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/hwcode.ts +35 -2
- package/.pi/extensions/model-providers.ts +1 -2
- package/.pi/extensions/workflows/cloud/activation.ts +235 -0
- package/.pi/extensions/workflows/cloud/commands.ts +96 -0
- package/.pi/extensions/workflows/cloud/events.ts +58 -0
- package/.pi/extensions/workflows/cloud/index.ts +17 -0
- package/.pi/extensions/workflows/cloud/provider-tools.ts +127 -0
- package/.pi/extensions/workflows/cloud/runner-tools.ts +129 -0
- package/.pi/extensions/workflows/cloud/runtime.ts +97 -0
- package/.pi/extensions/workflows/cloud/shared.ts +213 -0
- package/.pi/extensions/workflows/cloud/terraform-tools.ts +126 -0
- package/.pi/extensions/workflows/vibe-sdd.ts +300 -0
- package/.pi/extensions/workflows.ts +6 -253
- package/.pi/lib/runtime/config.ts +3 -7
- package/.pi/lib/runtime/defaults.ts +20 -0
- package/.pi/lib/runtime/paths.ts +68 -0
- package/.pi/lib/runtime/session-state.ts +0 -34
- package/.pi/lib/{cloud → workflows/cloud}/adapters.ts +7 -6
- package/.pi/lib/workflows/cloud/bundles.ts +358 -0
- package/.pi/lib/workflows/cloud/execution.ts +28 -0
- package/.pi/lib/{cloud → workflows/cloud}/process.ts +5 -3
- package/.pi/lib/workflows/cloud/remote/bootstrap.ts +23 -0
- package/.pi/lib/workflows/cloud/remote/connect.ts +83 -0
- package/.pi/lib/workflows/cloud/remote/host-key.ts +35 -0
- package/.pi/lib/workflows/cloud/remote/profiles.ts +100 -0
- package/.pi/lib/workflows/cloud/remote/ssh-transport.ts +104 -0
- package/.pi/lib/workflows/cloud/remote/workspace.ts +109 -0
- package/.pi/lib/{cloud → workflows/cloud}/template-save.ts +3 -2
- package/.pi/lib/{cloud → workflows/cloud}/templates.ts +18 -10
- package/.pi/lib/workflows/cloud/terraform/plan.ts +109 -0
- package/.pi/lib/workflows/cloud/terraform/policy.ts +36 -0
- package/.pi/lib/workflows/cloud/terraform/runner.ts +64 -0
- package/.pi/lib/{cloud-vault.ts → workflows/cloud/vault.ts} +26 -24
- package/.pi/lib/workflows/cloud/workspace.ts +37 -0
- package/.pi/lib/workflows/sdd.ts +11 -0
- package/.pi/lib/workflows/state.ts +114 -2
- package/.pi/lib/working-directory.ts +0 -58
- package/.pi/skills/hwcode-cloud/SKILL.md +32 -1
- package/.pi/skills/hwcode-sdd/SKILL.md +2 -0
- package/README.md +41 -8
- package/bin/hwcode.js +2 -6
- package/package.json +8 -3
- package/.pi/extensions/cloud.ts +0 -629
- /package/.pi/lib/{cloud-providers.ts → workflows/cloud/providers.ts} +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
6
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
9
|
+
import { remoteRunnerPaths, userRuntimePaths } from "../../../lib/runtime/paths.ts";
|
|
10
|
+
import { prepareRemoteRunner } from "../../../lib/workflows/cloud/remote/bootstrap.ts";
|
|
11
|
+
import { connectRemoteTarget } from "../../../lib/workflows/cloud/remote/connect.ts";
|
|
12
|
+
import { remoteTargetSummary, type RemoteTargetProfile } from "../../../lib/workflows/cloud/remote/profiles.ts";
|
|
13
|
+
import { createRemoteRunWorkspace, isTerraformSourceFile, syncRemoteWorkspace } from "../../../lib/workflows/cloud/remote/workspace.ts";
|
|
14
|
+
import { truncateOutput } from "../../../lib/workflows/cloud/process.ts";
|
|
15
|
+
import { assertSafeTerraformSource } from "../../../lib/workflows/cloud/terraform/policy.ts";
|
|
16
|
+
import { saveRemoteTargetProfile, writeCloudVault } from "../../../lib/workflows/cloud/vault.ts";
|
|
17
|
+
import type { TerraformRunState } from "../../../lib/workflows/state.ts";
|
|
18
|
+
import type { CloudExtensionRuntime } from "./runtime.ts";
|
|
19
|
+
import { cloudDetails, remoteTrustInteraction, terraformError, terraformOk } from "./shared.ts";
|
|
20
|
+
|
|
21
|
+
export function registerCloudRunnerTools(pi: ExtensionAPI, runtime: CloudExtensionRuntime): void {
|
|
22
|
+
pi.registerTool(defineTool({
|
|
23
|
+
name: "hwcode_runner_connect", label: "HWCode Runner Connect",
|
|
24
|
+
description: "Register a newly provisioned or existing SSH host as the Terraform Runner. It uses a local default SSH key and asks before trusting a new host key.",
|
|
25
|
+
promptSnippet: "Connect the discovered temporary Runner by public address; do not ask the user to name it.",
|
|
26
|
+
parameters: Type.Object({
|
|
27
|
+
host: Type.String({ description: "Public IP address or DNS returned by the provider" }),
|
|
28
|
+
user: Type.Optional(Type.String({ description: "SSH user; defaults to ubuntu" })),
|
|
29
|
+
port: Type.Optional(Type.Integer({ description: "SSH port; defaults to 22" })),
|
|
30
|
+
keyPath: Type.Optional(Type.String({ description: "Optional local private-key path; defaults to ~/.ssh/id_ed25519 then id_rsa" })),
|
|
31
|
+
jumpHost: Type.Optional(Type.String({ description: "Optional SSH bastion host used with ProxyJump" })),
|
|
32
|
+
jumpUser: Type.Optional(Type.String({ description: "ProxyJump SSH user; defaults to the target SSH user" })),
|
|
33
|
+
jumpPort: Type.Optional(Type.Integer({ description: "ProxyJump SSH port; defaults to 22" })),
|
|
34
|
+
identityType: Type.Optional(Type.Union([Type.Literal("instance-role"), Type.Literal("agency"), Type.Literal("ssh-only")], { description: "Cloud workload identity already configured on the target; temporary managed Runners default to instance-role" })),
|
|
35
|
+
}),
|
|
36
|
+
executionMode: "sequential",
|
|
37
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
38
|
+
const activeState = runtime.restore(ctx);
|
|
39
|
+
const details = activeState && cloudDetails(activeState);
|
|
40
|
+
const blocked = runtime.blocked(activeState);
|
|
41
|
+
if (!activeState || !details || blocked) return terraformError(blocked ?? "HWCode Cloud is not active.");
|
|
42
|
+
if (!runtime.activeVault) return terraformError("HWCode Cloud credentials are locked. Run /hwcode-cloud before connecting a Runner.");
|
|
43
|
+
const home = homedir();
|
|
44
|
+
const keyPath = params.keyPath || CLOUD_RUNTIME_DEFAULTS.runner.keyCandidates.map((file) => resolve(home, ".ssh", file)).find(existsSync);
|
|
45
|
+
if (!keyPath || !existsSync(keyPath)) return terraformError("No local SSH private key was found. Pass keyPath explicitly or create ~/.ssh/id_ed25519.");
|
|
46
|
+
const user = params.user || CLOUD_RUNTIME_DEFAULTS.runner.defaultUser;
|
|
47
|
+
const port = params.port || CLOUD_RUNTIME_DEFAULTS.runner.defaultPort;
|
|
48
|
+
let profile: RemoteTargetProfile;
|
|
49
|
+
try {
|
|
50
|
+
profile = await connectRemoteTarget({
|
|
51
|
+
id: `runner-${details.vendor}-${Date.now()}`, name: `${details.vendor}-temporary-runner`, vendor: details.vendor,
|
|
52
|
+
region: runtime.activeCredentials?.region || "default", host: params.host, port, user, keyPath,
|
|
53
|
+
knownHostsPath: userRuntimePaths(home).cloudKnownHosts,
|
|
54
|
+
remoteRoot: remoteRunnerPaths(user).root,
|
|
55
|
+
identityType: params.identityType || "instance-role",
|
|
56
|
+
proxyJump: params.jumpHost ? { host: params.jumpHost, port: params.jumpPort || CLOUD_RUNTIME_DEFAULTS.runner.defaultPort, user: params.jumpUser || user } : undefined,
|
|
57
|
+
}, activeState.root, remoteTrustInteraction(ctx), signal);
|
|
58
|
+
} catch (error) { return terraformError(error instanceof Error ? error.message : String(error)); }
|
|
59
|
+
runtime.activeRunner = profile;
|
|
60
|
+
runtime.activeVault.payload = saveRemoteTargetProfile(runtime.activeVault.payload, profile);
|
|
61
|
+
writeCloudVault(runtime.activeVault.payload, runtime.activeVault.password, runtime.activeVault.path);
|
|
62
|
+
runtime.replaceDetails(activeState, { ...details, runner: remoteTargetSummary(profile) }, "connected");
|
|
63
|
+
return terraformOk(`Terraform Runner connected: ${profile.user}@${profile.host}:${profile.port}`, { runner: remoteTargetSummary(profile) });
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
pi.registerTool(defineTool({
|
|
68
|
+
name: "hwcode_runner_prepare", label: "HWCode Runner",
|
|
69
|
+
description: "Check the selected remote Terraform Runner without exposing credentials.",
|
|
70
|
+
promptSnippet: "Prepare the selected remote Terraform Runner.", parameters: Type.Object({}), executionMode: "sequential",
|
|
71
|
+
async execute(_id, _params, signal, _onUpdate, ctx) {
|
|
72
|
+
const activeState = runtime.restore(ctx);
|
|
73
|
+
const blocked = runtime.blocked(activeState);
|
|
74
|
+
if (!activeState || blocked) return terraformError(blocked ?? "HWCode Cloud is not active.");
|
|
75
|
+
let runner: RemoteTargetProfile;
|
|
76
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return terraformError(error instanceof Error ? error.message : String(error)); }
|
|
77
|
+
try {
|
|
78
|
+
const report = await prepareRemoteRunner(runner, signal);
|
|
79
|
+
if (!report.ready) {
|
|
80
|
+
const reason = `Runner is reachable but is missing required capabilities: ${report.missing.join(", ")}. Install them through provider bootstrap/cloud-init, then retry.`;
|
|
81
|
+
runtime.runnerFailure(activeState, "runner-prepare", reason);
|
|
82
|
+
return terraformError(reason);
|
|
83
|
+
}
|
|
84
|
+
return terraformOk(`Runner ${runner.name} is ready.\n${report.terraformVersion || "Terraform available"}`, { runner: remoteTargetSummary(runner), capabilities: report });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
87
|
+
runtime.runnerFailure(activeState, "runner-prepare", reason);
|
|
88
|
+
return terraformError(`Runner preparation failed:\n${truncateOutput(reason)}`);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
pi.registerTool(defineTool({
|
|
94
|
+
name: "hwcode_terraform_sync", label: "HWCode Terraform Sync",
|
|
95
|
+
description: "Synchronize a project-local Terraform source directory to the selected Runner.",
|
|
96
|
+
promptSnippet: "Sync Terraform source to the remote Runner.",
|
|
97
|
+
parameters: Type.Object({ sourcePath: Type.String({ description: "Directory inside the locked project root" }) }), executionMode: "sequential",
|
|
98
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
99
|
+
const activeState = runtime.restore(ctx);
|
|
100
|
+
const blocked = runtime.blocked(activeState);
|
|
101
|
+
if (!activeState || blocked) return terraformError(blocked ?? "HWCode Cloud is not active.");
|
|
102
|
+
let runner: RemoteTargetProfile;
|
|
103
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return terraformError(error instanceof Error ? error.message : String(error)); }
|
|
104
|
+
const sourcePath = isAbsolute(params.sourcePath) ? resolve(params.sourcePath) : resolve(activeState.root, params.sourcePath);
|
|
105
|
+
const trustedBundlePath = cloudDetails(activeState)!.terraformSourcePath ? resolve(cloudDetails(activeState)!.terraformSourcePath!) : undefined;
|
|
106
|
+
const outside = relative(activeState.root, sourcePath).startsWith("..") || isAbsolute(relative(activeState.root, sourcePath));
|
|
107
|
+
if (outside && sourcePath !== trustedBundlePath) return terraformError("Terraform sourcePath must be inside the locked project root, except for the exact selected local Deployment Template bundle.");
|
|
108
|
+
if (!existsSync(sourcePath) || !statSync(sourcePath).isDirectory()) return terraformError("Terraform sourcePath must be an existing directory.");
|
|
109
|
+
let workspace;
|
|
110
|
+
try { workspace = createRemoteRunWorkspace(sourcePath, runner.remoteRoot); }
|
|
111
|
+
catch (error) { return terraformError(`Terraform source manifest rejected this bundle:\n${error instanceof Error ? error.message : String(error)}`); }
|
|
112
|
+
const files = workspace.files.filter((file) => isTerraformSourceFile(file.relativePath)).map((file) => ({ relativePath: file.relativePath, content: readFileSync(resolve(sourcePath, file.relativePath), "utf8") }));
|
|
113
|
+
try { assertSafeTerraformSource(files, { requireRemoteBackend: true }); }
|
|
114
|
+
catch (error) { return terraformError(`Terraform source policy rejected this bundle:\n${error instanceof Error ? error.message : String(error)}`); }
|
|
115
|
+
try { await syncRemoteWorkspace(runner, sourcePath, workspace, signal); }
|
|
116
|
+
catch (error) {
|
|
117
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
118
|
+
runtime.runnerFailure(activeState, "terraform-runner-sync", reason);
|
|
119
|
+
return terraformError(`Terraform sync failed:\n${truncateOutput(reason)}`);
|
|
120
|
+
}
|
|
121
|
+
const run: TerraformRunState = {
|
|
122
|
+
runId: workspace.runId, runnerId: runner.id, phase: "synced", sourceDigest: workspace.sourceDigest,
|
|
123
|
+
sourcePath, remoteWorkspace: workspace.remotePath, startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
124
|
+
};
|
|
125
|
+
runtime.replaceDetails(activeState, { ...cloudDetails(activeState)!, terraformRun: run }, "executing");
|
|
126
|
+
return terraformOk(`Terraform source synced. runId=${run.runId}\nsourceDigest=${run.sourceDigest}\nremoteWorkspace=${run.remoteWorkspace}`, { runId: run.runId, sourceDigest: run.sourceDigest });
|
|
127
|
+
},
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import type { TemporaryCredentialStore } from "../../../lib/workflows/cloud/adapters.ts";
|
|
7
|
+
import { cloudExecutionBlockReason, recordStrategyFailure } from "../../../lib/workflows/cloud/execution.ts";
|
|
8
|
+
import type { CloudCredentials, CloudOperation } from "../../../lib/workflows/cloud/providers.ts";
|
|
9
|
+
import type { RemoteTargetProfile } from "../../../lib/workflows/cloud/remote/profiles.ts";
|
|
10
|
+
import { createEmptyVault } from "../../../lib/workflows/cloud/vault.ts";
|
|
11
|
+
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
12
|
+
import { updateWorkflowState, type CloudWorkflowDetails, type TerraformRunState, type WorkflowState } from "../../../lib/workflows/state.ts";
|
|
13
|
+
import { appendWorkflowState, cloudDetails, restoreCloudWorkflow } from "./shared.ts";
|
|
14
|
+
|
|
15
|
+
export interface ActiveCloudVault {
|
|
16
|
+
password: string;
|
|
17
|
+
payload: ReturnType<typeof createEmptyVault>;
|
|
18
|
+
path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class CloudExtensionRuntime {
|
|
22
|
+
readonly pi: ExtensionAPI;
|
|
23
|
+
activeState?: WorkflowState;
|
|
24
|
+
activeCredentials?: CloudCredentials;
|
|
25
|
+
activeRunner?: RemoteTargetProfile;
|
|
26
|
+
activeVault?: ActiveCloudVault;
|
|
27
|
+
readonly temporaryStore: TemporaryCredentialStore;
|
|
28
|
+
private readonly credentialDirectories = new Set<string>();
|
|
29
|
+
|
|
30
|
+
constructor(pi: ExtensionAPI) {
|
|
31
|
+
this.pi = pi;
|
|
32
|
+
this.temporaryStore = {
|
|
33
|
+
createDirectory: (prefix) => {
|
|
34
|
+
const directory = mkdtempSync(`${tmpdir()}/${prefix}`);
|
|
35
|
+
this.credentialDirectories.add(directory);
|
|
36
|
+
return directory;
|
|
37
|
+
},
|
|
38
|
+
cleanupDirectory: (directory) => {
|
|
39
|
+
if (existsSync(directory)) rmSync(directory, { recursive: true, force: true });
|
|
40
|
+
this.credentialDirectories.delete(directory);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
restore(ctx: ExtensionContext): WorkflowState | undefined {
|
|
46
|
+
this.activeState = restoreCloudWorkflow(ctx);
|
|
47
|
+
return this.activeState;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cleanupCredentialDirectories(): void {
|
|
51
|
+
for (const directory of [...this.credentialDirectories]) this.temporaryStore.cleanupDirectory(directory);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
clearSecrets(): void {
|
|
55
|
+
this.activeCredentials = undefined;
|
|
56
|
+
this.activeRunner = undefined;
|
|
57
|
+
this.activeVault = undefined;
|
|
58
|
+
this.cleanupCredentialDirectories();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
replaceDetails(state: WorkflowState, details: CloudWorkflowDetails, phase = state.phase): WorkflowState {
|
|
62
|
+
const updated = updateWorkflowState(state, { details, phase });
|
|
63
|
+
this.activeState = updated;
|
|
64
|
+
appendWorkflowState(this.pi, updated);
|
|
65
|
+
return updated;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
terraformRun(state: WorkflowState): TerraformRunState | undefined {
|
|
69
|
+
return cloudDetails(state)?.terraformRun;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
recordTerraformSuccess(state: WorkflowState, args: string[], operation: CloudOperation, intent: string, approach: string): WorkflowState {
|
|
73
|
+
const details = cloudDetails(state)!;
|
|
74
|
+
const successfulSteps = [...details.successfulSteps, {
|
|
75
|
+
command: "terraform", args, operation, intent, approach, completedAt: new Date().toISOString(),
|
|
76
|
+
}].slice(-CLOUD_RUNTIME_DEFAULTS.workflow.maxSuccessfulSteps);
|
|
77
|
+
return this.replaceDetails(state, { ...details, successfulSteps }, "executing");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
runnerFailure(state: WorkflowState, stage: string, reason: string): void {
|
|
81
|
+
const details = cloudDetails(state);
|
|
82
|
+
if (!details) return;
|
|
83
|
+
const failed = recordStrategyFailure(details, "terraform-runner", stage, reason, CLOUD_RUNTIME_DEFAULTS.workflow.maxFailedApproaches);
|
|
84
|
+
const terraformRun = details.terraformRun ? { ...details.terraformRun, phase: "failed" as const, updatedAt: new Date().toISOString() } : undefined;
|
|
85
|
+
this.replaceDetails(state, { ...failed, ...(terraformRun ? { terraformRun } : {}) }, failed.terminalFailure ? "failed" : "executing");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
blocked(state: WorkflowState | undefined): string | undefined {
|
|
89
|
+
return cloudExecutionBlockReason(state);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
requireRunner(state: WorkflowState): RemoteTargetProfile {
|
|
93
|
+
if (!this.activeRunner) throw new Error("No Terraform Runner is connected yet. Provision the temporary Runner for this workflow, then register its SSH endpoint before Terraform execution.");
|
|
94
|
+
if (this.activeRunner.id !== cloudDetails(state)?.runner?.id) throw new Error("The selected Terraform Runner does not match this Cloud workflow.");
|
|
95
|
+
return this.activeRunner;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { CURSOR_MARKER, Input, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
import { isCloudRunWorkspace } from "../../../lib/workflows/cloud/workspace.ts";
|
|
8
|
+
import { type RemoteTrustInteraction } from "../../../lib/workflows/cloud/remote/connect.ts";
|
|
9
|
+
import { getCloudProvider, CLOUD_PROVIDERS, redactCredentialValues, type CloudCredentials, type CloudOperation, type CloudVendorId } from "../../../lib/workflows/cloud/providers.ts";
|
|
10
|
+
import {
|
|
11
|
+
cloudVaultExists, createEmptyVault, defaultCloudVaultPath, readCloudVault,
|
|
12
|
+
} from "../../../lib/workflows/cloud/vault.ts";
|
|
13
|
+
import { WORKFLOW_STATE_TYPE, activeWorkflow, type CloudWorkflowDetails, type WorkflowState } from "../../../lib/workflows/state.ts";
|
|
14
|
+
|
|
15
|
+
export const CLOUD_AUDIT_TYPE = "hwcode-cloud-audit";
|
|
16
|
+
export const ORCHESTRATION_COMMANDS = new Set(["terraform", "tofu", "pulumi", "kubectl", "helm"]);
|
|
17
|
+
export const SENSITIVE_ARGUMENT = /^--?(?:access[-_]?key|secret(?:[-_]?(?:access|key))?|client[-_]?secret|password|credential|token)(?:=|$)/iu;
|
|
18
|
+
const LEGACY_CLOUD_STATE_TYPE = "hwcode-cloud-state";
|
|
19
|
+
|
|
20
|
+
interface LegacyCloudWorkflowState {
|
|
21
|
+
version: 1;
|
|
22
|
+
active: true;
|
|
23
|
+
root: string;
|
|
24
|
+
vendor: CloudVendorId;
|
|
25
|
+
deployCurrentProject: boolean;
|
|
26
|
+
request: string;
|
|
27
|
+
allowNonDeleteChanges: boolean;
|
|
28
|
+
failedApproaches: CloudWorkflowDetails["failedApproaches"];
|
|
29
|
+
terminalFailure: boolean;
|
|
30
|
+
activatedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class MaskedInput implements Component {
|
|
34
|
+
private readonly input = new Input();
|
|
35
|
+
private _focused = true;
|
|
36
|
+
private readonly tui: TUI;
|
|
37
|
+
private readonly theme: Theme;
|
|
38
|
+
private readonly title: string;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
tui: TUI,
|
|
42
|
+
theme: Theme,
|
|
43
|
+
title: string,
|
|
44
|
+
done: (value: string | undefined) => void,
|
|
45
|
+
) {
|
|
46
|
+
this.tui = tui;
|
|
47
|
+
this.theme = theme;
|
|
48
|
+
this.title = title;
|
|
49
|
+
this.input.onSubmit = (value) => done(value);
|
|
50
|
+
this.input.onEscape = () => done(undefined);
|
|
51
|
+
this.input.focused = true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get focused(): boolean { return this._focused; }
|
|
55
|
+
set focused(value: boolean) { this._focused = value; this.input.focused = value; }
|
|
56
|
+
handleInput(data: string): void { this.input.handleInput(data); this.tui.requestRender(); }
|
|
57
|
+
invalidate(): void { this.input.invalidate(); }
|
|
58
|
+
render(width: number): string[] {
|
|
59
|
+
const masked = "•".repeat(Math.min(Array.from(this.input.getValue()).length, Math.max(1, width - 1)));
|
|
60
|
+
return [this.theme.fg("accent", this.title), `${masked}${CURSOR_MARKER}`, this.theme.fg("dim", "Enter 确认 · Esc 取消 · 输入内容不会显示")];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
65
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function cloudDetails(state: WorkflowState): CloudWorkflowDetails | undefined {
|
|
69
|
+
return state.mode === "cloud" ? state.details : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function cloudArtifactDirectory(state: WorkflowState): string {
|
|
73
|
+
const path = cloudDetails(state)?.artifactDirectory;
|
|
74
|
+
return path && isCloudRunWorkspace(state.root, path) ? path : state.root;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatTemplateTime(value: string): string {
|
|
78
|
+
const date = new Date(value);
|
|
79
|
+
if (Number.isNaN(date.valueOf())) return value;
|
|
80
|
+
const pad = (part: number) => String(part).padStart(2, "0");
|
|
81
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function restoreCloudWorkflow(ctx: ExtensionContext): WorkflowState | undefined {
|
|
85
|
+
const state = activeWorkflow(ctx.sessionManager.getEntries());
|
|
86
|
+
return state?.mode === "cloud" && state.details ? state : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function decodeLegacyCloudState(value: unknown): LegacyCloudWorkflowState | undefined {
|
|
90
|
+
if (!value || typeof value !== "object") return undefined;
|
|
91
|
+
const data = value as Record<string, unknown>;
|
|
92
|
+
if (data.version !== 1 || data.active !== true || typeof data.root !== "string"
|
|
93
|
+
|| typeof data.vendor !== "string" || !CLOUD_PROVIDERS.some((provider) => provider.id === data.vendor)
|
|
94
|
+
|| typeof data.deployCurrentProject !== "boolean" || typeof data.request !== "string"
|
|
95
|
+
|| typeof data.allowNonDeleteChanges !== "boolean" || !Array.isArray(data.failedApproaches)
|
|
96
|
+
|| typeof data.terminalFailure !== "boolean" || typeof data.activatedAt !== "string") return undefined;
|
|
97
|
+
return data as unknown as LegacyCloudWorkflowState;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function restoreLegacyCloudWorkflow(ctx: ExtensionContext): WorkflowState | undefined {
|
|
101
|
+
for (const entry of [...ctx.sessionManager.getEntries()].reverse()) {
|
|
102
|
+
if (entry.type !== "custom" || entry.customType !== LEGACY_CLOUD_STATE_TYPE) continue;
|
|
103
|
+
const legacy = decodeLegacyCloudState(entry.data);
|
|
104
|
+
if (!legacy) return undefined;
|
|
105
|
+
return {
|
|
106
|
+
version: 2, status: "active", mode: "cloud", root: legacy.root,
|
|
107
|
+
phase: legacy.terminalFailure ? "failed" : "connected",
|
|
108
|
+
activatedAt: legacy.activatedAt, updatedAt: legacy.activatedAt,
|
|
109
|
+
details: {
|
|
110
|
+
vendor: legacy.vendor, deployCurrentProject: legacy.deployCurrentProject,
|
|
111
|
+
request: legacy.request, allowNonDeleteChanges: legacy.allowNonDeleteChanges,
|
|
112
|
+
failedApproaches: legacy.failedApproaches, successfulSteps: [], terminalFailure: legacy.terminalFailure,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function appendWorkflowState(pi: ExtensionAPI, state: WorkflowState): void {
|
|
120
|
+
pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, state);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function secretInput(ctx: ExtensionContext, title: string): Promise<string | undefined> {
|
|
124
|
+
if (ctx.mode !== "tui") return undefined;
|
|
125
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => new MaskedInput(tui, theme, title, done));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function unlockVault(ctx: ExtensionCommandContext): Promise<{ password: string; payload: ReturnType<typeof createEmptyVault> } | undefined> {
|
|
129
|
+
const vaultPath = defaultCloudVaultPath();
|
|
130
|
+
if (!cloudVaultExists(vaultPath)) {
|
|
131
|
+
while (true) {
|
|
132
|
+
const password = await secretInput(ctx, "创建云凭据主密码(至少 8 个字符)");
|
|
133
|
+
if (password === undefined) return undefined;
|
|
134
|
+
if (password.length < 8) { notify(ctx, "主密码至少需要 8 个字符。", "warning"); continue; }
|
|
135
|
+
const confirmation = await secretInput(ctx, "再次输入主密码");
|
|
136
|
+
if (confirmation === undefined) return undefined;
|
|
137
|
+
if (confirmation !== password) { notify(ctx, "两次输入的主密码不一致。", "warning"); continue; }
|
|
138
|
+
return { password, payload: createEmptyVault() };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
while (true) {
|
|
142
|
+
const password = await secretInput(ctx, "输入云凭据主密码以解锁");
|
|
143
|
+
if (password === undefined) return undefined;
|
|
144
|
+
try { return { password, payload: readCloudVault(password, vaultPath) }; }
|
|
145
|
+
catch (error) {
|
|
146
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
147
|
+
if (!(await ctx.ui.confirm("重新输入主密码?", "凭据未解锁,HWCode Cloud 尚未启动。"))) return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function collectCredentials(vendor: CloudVendorId, root: string, ctx: ExtensionCommandContext): Promise<CloudCredentials | undefined> {
|
|
153
|
+
const credentials: CloudCredentials = {};
|
|
154
|
+
for (const field of getCloudProvider(vendor).credentialFields) {
|
|
155
|
+
let value = field.secret ? await secretInput(ctx, field.label) : await ctx.ui.input(field.label, field.placeholder);
|
|
156
|
+
if (value === undefined) return undefined;
|
|
157
|
+
value = value.trim();
|
|
158
|
+
if (!value && !field.optional) { notify(ctx, `${field.label} 不能为空。`, "warning"); return collectCredentials(vendor, root, ctx); }
|
|
159
|
+
if (!value) continue;
|
|
160
|
+
if (field.fileContents) {
|
|
161
|
+
const path = isAbsolute(value) ? value : resolve(root, value);
|
|
162
|
+
try { const contents = readFileSync(path, "utf8"); JSON.parse(contents); credentials[field.key] = contents; }
|
|
163
|
+
catch (error) {
|
|
164
|
+
notify(ctx, `无法读取有效的 JSON 凭据文件 ${path}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
165
|
+
return collectCredentials(vendor, root, ctx);
|
|
166
|
+
}
|
|
167
|
+
} else credentials[field.key] = value;
|
|
168
|
+
}
|
|
169
|
+
if (vendor === "gcp") {
|
|
170
|
+
const account = JSON.parse(credentials.serviceAccountJson) as Record<string, unknown>;
|
|
171
|
+
credentials.projectId ||= typeof account.project_id === "string" ? account.project_id : "";
|
|
172
|
+
if (!credentials.projectId) { notify(ctx, "Service Account JSON 中没有 project_id,请重新输入。", "error"); return collectCredentials(vendor, root, ctx); }
|
|
173
|
+
}
|
|
174
|
+
return credentials;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function activationPrompt(state: WorkflowState): string {
|
|
178
|
+
const details = cloudDetails(state)!;
|
|
179
|
+
const guidance = details.templateGuidance
|
|
180
|
+
? `\n\nReusable template guidance (${details.sourceTemplate?.name ?? "saved template"}):\n${details.templateGuidance}`
|
|
181
|
+
: "";
|
|
182
|
+
const runner = details.runner?.name ?? (details.runnerPreference === "automatic" ? "automatic temporary Runner requested" : "not selected");
|
|
183
|
+
return `/skill:hwcode-cloud HWCode Cloud workflow activated.\n\nLocked project root: ${state.root}\nTask artifact workspace: ${cloudArtifactDirectory(state)}\nCloud provider: ${getCloudProvider(details.vendor).label}\nTerraform Runner: ${runner}\nDeploy current project: ${details.deployCurrentProject ? "yes" : "no"}\nUser objective:\n${details.request}${guidance}\n\nCredentials were validated and remain outside model context. Use hwcode_cloud_exec for provider CLI operations. If no Runner is selected and Terraform is required, first present the temporary Runner architecture and obtain approval to create its billable cloud resources; then use the configured Runner tools for Terraform execution.`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function approveOperation(operation: CloudOperation, params: { command: string; args: string[]; intent: string }, state: WorkflowState, ctx: ExtensionContext): Promise<"allow" | "allow-session" | "deny"> {
|
|
187
|
+
if (operation === "read") return "allow";
|
|
188
|
+
if (!ctx.hasUI) return "deny";
|
|
189
|
+
const details = cloudDetails(state)!;
|
|
190
|
+
const command = `${params.command} ${params.args.join(" ")}`;
|
|
191
|
+
if (operation === "delete") return await ctx.ui.confirm("确认删除云资源?", `目标:${params.intent}\n命令:${command}\n\n删除操作始终逐次确认。`) ? "allow" : "deny";
|
|
192
|
+
if (details.allowNonDeleteChanges) return "allow";
|
|
193
|
+
const choice = await ctx.ui.select("允许修改云账号内资源?", ["仅允许本次", "允许本会话后续非删除变更,不再询问", "拒绝"]);
|
|
194
|
+
if (choice === "仅允许本次") return "allow";
|
|
195
|
+
if (choice === "允许本会话后续非删除变更,不再询问") return "allow-session";
|
|
196
|
+
return "deny";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function remoteTrustInteraction(ctx: ExtensionContext): RemoteTrustInteraction {
|
|
200
|
+
return {
|
|
201
|
+
confirm: async (host, port, keys, role) => ctx.hasUI && ctx.ui.confirm(
|
|
202
|
+
role === "jump" ? "信任 SSH 跳板机主机密钥?" : "信任 Terraform Runner 主机密钥?",
|
|
203
|
+
`主机:${host}:${port}\n${keys.map((key) => `${key.algorithm} ${key.fingerprint}`).join("\n")}\n\n请仅在指纹与云控制台或可信运维记录一致时确认。`,
|
|
204
|
+
),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function safeStepText(value: string, credentials: CloudCredentials): string {
|
|
209
|
+
return redactCredentialValues(value.replace(/[\r\n]+/gu, " ").slice(0, 2_000), credentials);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const terraformError = (message: string) => ({ content: [{ type: "text" as const, text: message }], isError: true, details: {} });
|
|
213
|
+
export const terraformOk = (message: string, details: Record<string, unknown> = {}) => ({ content: [{ type: "text" as const, text: message }], details });
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
5
|
+
import type { CloudOperation } from "../../../lib/workflows/cloud/providers.ts";
|
|
6
|
+
import type { RemoteTargetProfile } from "../../../lib/workflows/cloud/remote/profiles.ts";
|
|
7
|
+
import {
|
|
8
|
+
classifyTerraformPlan, summarizeTerraformState, terraformPlanHasDeletion,
|
|
9
|
+
terraformPlanRequiresApproval, terraformPlanSummaryText,
|
|
10
|
+
} from "../../../lib/workflows/cloud/terraform/plan.ts";
|
|
11
|
+
import { digestTerraformPlan, executeTerraformRunner } from "../../../lib/workflows/cloud/terraform/runner.ts";
|
|
12
|
+
import { cloudExecutionBlockReason } from "../../../lib/workflows/cloud/execution.ts";
|
|
13
|
+
import { truncateOutput } from "../../../lib/workflows/cloud/process.ts";
|
|
14
|
+
import type { CloudExtensionRuntime } from "./runtime.ts";
|
|
15
|
+
import { approveOperation, cloudDetails, terraformError, terraformOk } from "./shared.ts";
|
|
16
|
+
|
|
17
|
+
const PLAN_FILE = CLOUD_RUNTIME_DEFAULTS.terraform.planFile;
|
|
18
|
+
|
|
19
|
+
export function registerCloudTerraformTools(pi: ExtensionAPI, runtime: CloudExtensionRuntime): void {
|
|
20
|
+
pi.registerTool(defineTool({
|
|
21
|
+
name: "hwcode_terraform_validate", label: "HWCode Terraform Validate",
|
|
22
|
+
description: "Run fmt, init, validate, and test on the remote Terraform Runner.",
|
|
23
|
+
promptSnippet: "Validate the synchronized Terraform bundle remotely.",
|
|
24
|
+
parameters: Type.Object({ runId: Type.String() }), executionMode: "sequential",
|
|
25
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
26
|
+
const activeState = runtime.restore(ctx);
|
|
27
|
+
const blocked = runtime.blocked(activeState);
|
|
28
|
+
if (!activeState || blocked) return terraformError(blocked ?? "HWCode Cloud is not active.");
|
|
29
|
+
const run = runtime.terraformRun(activeState);
|
|
30
|
+
if (!run || run.runId !== params.runId || run.phase !== "synced") return terraformError("Terraform validate requires a newly synced run.");
|
|
31
|
+
let runner: RemoteTargetProfile;
|
|
32
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return terraformError(error instanceof Error ? error.message : String(error)); }
|
|
33
|
+
for (const action of ["fmt", "init", "validate", "test"] as const) {
|
|
34
|
+
const result = await executeTerraformRunner(runner, { action, workspace: run.remoteWorkspace }, { signal });
|
|
35
|
+
if (result.code !== 0) {
|
|
36
|
+
runtime.runnerFailure(activeState, `terraform-${action}`, result.stderr || result.stdout || `exit code ${result.code}`);
|
|
37
|
+
return terraformError(`terraform ${action} failed:\n${truncateOutput(result.stderr || result.stdout)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
runtime.replaceDetails(activeState, { ...cloudDetails(activeState)!, terraformRun: { ...run, phase: "validated", updatedAt: new Date().toISOString() } }, "executing");
|
|
41
|
+
runtime.recordTerraformSuccess(runtime.activeState!, ["fmt", "init", "validate", "test"], "read", "Validate Terraform source and tests", "terraform-runner");
|
|
42
|
+
return terraformOk("Terraform fmt/init/validate/test completed successfully.", { runId: run.runId });
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
pi.registerTool(defineTool({
|
|
47
|
+
name: "hwcode_terraform_plan", label: "HWCode Terraform Plan",
|
|
48
|
+
description: "Create and inspect a JSON Terraform plan on the remote Runner.",
|
|
49
|
+
promptSnippet: "Create a Terraform plan and report exact resource changes.",
|
|
50
|
+
parameters: Type.Object({ runId: Type.String(), mode: Type.Union([Type.Literal("normal"), Type.Literal("destroy"), Type.Literal("refresh-only")]) }), executionMode: "sequential",
|
|
51
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
52
|
+
const activeState = runtime.restore(ctx);
|
|
53
|
+
const blocked = runtime.blocked(activeState);
|
|
54
|
+
if (!activeState || blocked) return terraformError(blocked ?? "HWCode Cloud is not active.");
|
|
55
|
+
const run = runtime.terraformRun(activeState);
|
|
56
|
+
if (!run || run.runId !== params.runId || run.phase !== "validated") return terraformError("Terraform plan requires a validated run.");
|
|
57
|
+
let runner: RemoteTargetProfile;
|
|
58
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return terraformError(error instanceof Error ? error.message : String(error)); }
|
|
59
|
+
const planned = await executeTerraformRunner(runner, { action: "plan", workspace: run.remoteWorkspace, mode: params.mode }, { signal });
|
|
60
|
+
if (planned.code !== 0) { runtime.runnerFailure(activeState, `terraform-plan-${params.mode}`, planned.stderr || planned.stdout || `exit code ${planned.code}`); return terraformError(`terraform plan failed:\n${truncateOutput(planned.stderr || planned.stdout)}`); }
|
|
61
|
+
const inspected = await executeTerraformRunner(runner, { action: "show-plan", workspace: run.remoteWorkspace, planPath: PLAN_FILE }, { signal });
|
|
62
|
+
if (inspected.code !== 0) { runtime.runnerFailure(activeState, "terraform-show-plan", inspected.stderr || inspected.stdout); return terraformError(`terraform show plan failed:\n${truncateOutput(inspected.stderr || inspected.stdout)}`); }
|
|
63
|
+
let summary;
|
|
64
|
+
try { summary = classifyTerraformPlan(JSON.parse(inspected.stdout)); }
|
|
65
|
+
catch (error) { const reason = error instanceof Error ? error.message : String(error); runtime.runnerFailure(activeState, "terraform-parse-plan", reason); return terraformError(`Unable to parse Terraform plan JSON: ${reason}`); }
|
|
66
|
+
const planDigest = digestTerraformPlan(inspected.stdout);
|
|
67
|
+
runtime.replaceDetails(activeState, { ...cloudDetails(activeState)!, terraformRun: { ...run, phase: "planned", planDigest, planSummary: summary, updatedAt: new Date().toISOString() } }, "executing");
|
|
68
|
+
runtime.recordTerraformSuccess(runtime.activeState!, ["plan", params.mode], "read", `Create reviewed Terraform ${params.mode} plan`, "terraform-runner");
|
|
69
|
+
return terraformOk(`${terraformPlanSummaryText(summary)}\nplanDigest=${planDigest}`, { runId: run.runId, planDigest, requiresApproval: terraformPlanRequiresApproval(summary), hasDeletion: terraformPlanHasDeletion(summary), summary });
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
pi.registerTool(defineTool({
|
|
74
|
+
name: "hwcode_terraform_apply", label: "HWCode Terraform Apply",
|
|
75
|
+
description: "Apply only the exact approved Terraform plan on the remote Runner.",
|
|
76
|
+
promptSnippet: "Apply the reviewed Terraform plan after approval.",
|
|
77
|
+
parameters: Type.Object({ runId: Type.String(), planDigest: Type.String(), intent: Type.String() }), executionMode: "sequential",
|
|
78
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
79
|
+
let activeState = runtime.restore(ctx);
|
|
80
|
+
const blocked = runtime.blocked(activeState);
|
|
81
|
+
if (!activeState || blocked) return terraformError(blocked ?? "HWCode Cloud is not active.");
|
|
82
|
+
const run = runtime.terraformRun(activeState);
|
|
83
|
+
if (!run || run.runId !== params.runId || run.phase !== "planned" || run.planDigest !== params.planDigest) return terraformError("Plan digest mismatch or no planned run is ready. Re-plan before apply.");
|
|
84
|
+
let runner: RemoteTargetProfile;
|
|
85
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return terraformError(error instanceof Error ? error.message : String(error)); }
|
|
86
|
+
const currentPlan = await executeTerraformRunner(runner, { action: "show-plan", workspace: run.remoteWorkspace, planPath: PLAN_FILE }, { signal });
|
|
87
|
+
if (currentPlan.code !== 0) { runtime.runnerFailure(activeState, "terraform-reverify-plan", currentPlan.stderr || currentPlan.stdout); return terraformError(`Unable to re-verify the managed Terraform plan:\n${truncateOutput(currentPlan.stderr || currentPlan.stdout)}`); }
|
|
88
|
+
const currentDigest = digestTerraformPlan(currentPlan.stdout);
|
|
89
|
+
if (currentDigest !== run.planDigest || currentDigest !== params.planDigest) { runtime.runnerFailure(activeState, "terraform-plan-integrity", "remote plan digest changed after review"); return terraformError("The remote Terraform plan changed after review. Apply is blocked; create and review a new plan."); }
|
|
90
|
+
let currentSummary;
|
|
91
|
+
try { currentSummary = classifyTerraformPlan(JSON.parse(currentPlan.stdout)); }
|
|
92
|
+
catch (error) { const reason = error instanceof Error ? error.message : String(error); runtime.runnerFailure(activeState, "terraform-parse-current-plan", reason); return terraformError(`Unable to parse the current Terraform plan: ${reason}`); }
|
|
93
|
+
const operation: CloudOperation = terraformPlanHasDeletion(currentSummary) ? "delete" : terraformPlanRequiresApproval(currentSummary) ? "change" : "read";
|
|
94
|
+
const approval = await approveOperation(operation, { command: "terraform", args: ["apply", PLAN_FILE], intent: params.intent }, activeState, ctx);
|
|
95
|
+
if (approval === "deny") return terraformError("User denied Terraform apply.");
|
|
96
|
+
if (approval === "allow-session") activeState = runtime.replaceDetails(activeState, { ...cloudDetails(activeState)!, allowNonDeleteChanges: true });
|
|
97
|
+
const result = await executeTerraformRunner(runner, { action: "apply", workspace: run.remoteWorkspace, planPath: PLAN_FILE }, { signal });
|
|
98
|
+
if (result.code !== 0) { runtime.runnerFailure(activeState, "terraform-apply", result.stderr || result.stdout || `exit code ${result.code}`); return terraformError(`terraform apply failed:\n${truncateOutput(result.stderr || result.stdout)}`); }
|
|
99
|
+
runtime.replaceDetails(activeState, { ...cloudDetails(activeState)!, terraformRun: { ...run, phase: "applied", planSummary: currentSummary, updatedAt: new Date().toISOString() } }, "executing");
|
|
100
|
+
runtime.recordTerraformSuccess(runtime.activeState!, ["apply", PLAN_FILE], operation, params.intent, "terraform-runner");
|
|
101
|
+
return terraformOk(`Terraform apply completed.\n${truncateOutput(result.stdout).trim()}`, { runId: run.runId, planDigest: params.planDigest });
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
pi.registerTool(defineTool({
|
|
106
|
+
name: "hwcode_terraform_status", label: "HWCode Terraform Status",
|
|
107
|
+
description: "Read remote Terraform state for the active managed run.",
|
|
108
|
+
promptSnippet: "Inspect the current remote Terraform state.",
|
|
109
|
+
parameters: Type.Object({ runId: Type.String() }), executionMode: "sequential",
|
|
110
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
111
|
+
const activeState = runtime.restore(ctx);
|
|
112
|
+
const blocked = cloudExecutionBlockReason(activeState, { allowAfterTerminalFailure: true });
|
|
113
|
+
if (!activeState || blocked) return terraformError(blocked ?? "HWCode Cloud is not active.");
|
|
114
|
+
const run = runtime.terraformRun(activeState);
|
|
115
|
+
if (!run || run.runId !== params.runId || !["planned", "applied"].includes(run.phase)) return terraformError("Terraform status requires a planned or applied run.");
|
|
116
|
+
let runner: RemoteTargetProfile;
|
|
117
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return terraformError(error instanceof Error ? error.message : String(error)); }
|
|
118
|
+
const result = await executeTerraformRunner(runner, { action: "show-state", workspace: run.remoteWorkspace }, { signal });
|
|
119
|
+
if (result.code !== 0) { runtime.runnerFailure(activeState, "terraform-state", result.stderr || result.stdout); return terraformError(`terraform state inspection failed:\n${truncateOutput(result.stderr || result.stdout)}`); }
|
|
120
|
+
let summary;
|
|
121
|
+
try { summary = summarizeTerraformState(JSON.parse(result.stdout)); }
|
|
122
|
+
catch (error) { return terraformError(`Unable to summarize Terraform state safely: ${error instanceof Error ? error.message : String(error)}`); }
|
|
123
|
+
return terraformOk(`Terraform state contains ${summary.resourceCount} managed resources.\n${Object.entries(summary.resourceTypes).map(([type, count]) => `${type}: ${count}`).join("\n") || "No managed resources."}`, { runId: run.runId, summary });
|
|
124
|
+
},
|
|
125
|
+
}));
|
|
126
|
+
}
|