@hadooppei/hwcode 1.0.7 → 1.0.9
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/command-filter.ts +2 -3
- package/.pi/extensions/cwd.ts +1 -4
- package/.pi/extensions/knowledge.ts +224 -0
- package/.pi/extensions/workflows/cloud/activation.ts +57 -43
- package/.pi/extensions/workflows/cloud/commands.ts +1 -86
- package/.pi/extensions/workflows/cloud/events.ts +16 -19
- package/.pi/extensions/workflows/cloud/interactions.ts +102 -0
- package/.pi/extensions/workflows/cloud/provider-tools.ts +15 -9
- package/.pi/extensions/workflows/cloud/runner-tools.ts +21 -20
- package/.pi/extensions/workflows/cloud/runtime.ts +41 -4
- package/.pi/extensions/workflows/cloud/terraform-tools.ts +43 -30
- package/.pi/extensions/workflows/sdd.ts +2 -1
- package/.pi/extensions/workflows/vibe.ts +2 -1
- package/.pi/extensions/workflows/workspace-guard.ts +1 -5
- package/.pi/lib/extension-ui.ts +52 -0
- package/.pi/lib/knowledge/extractor.ts +35 -0
- package/.pi/lib/knowledge/matcher.ts +122 -0
- package/.pi/lib/knowledge/review-worker.ts +260 -0
- package/.pi/lib/knowledge/sanitize.ts +26 -0
- package/.pi/lib/knowledge/session-scanner.ts +155 -0
- package/.pi/lib/knowledge/store.ts +365 -0
- package/.pi/lib/knowledge/types.ts +91 -0
- package/.pi/lib/knowledge/worker-protocol.ts +20 -0
- package/.pi/lib/runtime/defaults.ts +31 -0
- package/.pi/lib/runtime/paths.ts +33 -16
- package/.pi/lib/tool-result.ts +7 -0
- package/.pi/lib/workflows/cloud/bundles.ts +73 -40
- package/.pi/lib/workflows/cloud/workspace.ts +6 -0
- package/.pi/lib/workflows/state.ts +6 -26
- package/.pi/skills/hwcode-cloud/SKILL.md +2 -2
- package/README.md +36 -31
- package/bin/hwcode.js +2 -3
- package/package.json +1 -1
- package/.pi/extensions/workflows/cloud/shared.ts +0 -230
- package/.pi/lib/workflows/cloud/template-save.ts +0 -108
- package/.pi/lib/workflows/cloud/templates.ts +0 -314
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { notify, secretInput } from "../../../lib/extension-ui.ts";
|
|
7
|
+
import type { RemoteTrustInteraction } from "../../../lib/workflows/cloud/remote/connect.ts";
|
|
8
|
+
import {
|
|
9
|
+
getCloudProvider, type CloudCredentials, type CloudVendorId,
|
|
10
|
+
} from "../../../lib/workflows/cloud/providers.ts";
|
|
11
|
+
import {
|
|
12
|
+
cloudVaultExists, createEmptyVault, defaultCloudVaultPath, readCloudVault,
|
|
13
|
+
} from "../../../lib/workflows/cloud/vault.ts";
|
|
14
|
+
|
|
15
|
+
export async function unlockVault(
|
|
16
|
+
ctx: ExtensionCommandContext,
|
|
17
|
+
): Promise<{ password: string; payload: ReturnType<typeof createEmptyVault> } | undefined> {
|
|
18
|
+
const vaultPath = defaultCloudVaultPath();
|
|
19
|
+
if (!cloudVaultExists(vaultPath)) {
|
|
20
|
+
while (true) {
|
|
21
|
+
const password = await secretInput(ctx, "创建云凭据主密码(至少 8 个字符)");
|
|
22
|
+
if (password === undefined) return undefined;
|
|
23
|
+
if (password.length < 8) {
|
|
24
|
+
notify(ctx, "主密码至少需要 8 个字符。", "warning");
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const confirmation = await secretInput(ctx, "再次输入主密码");
|
|
28
|
+
if (confirmation === undefined) return undefined;
|
|
29
|
+
if (confirmation !== password) {
|
|
30
|
+
notify(ctx, "两次输入的主密码不一致。", "warning");
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
return { password, payload: createEmptyVault() };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
while (true) {
|
|
37
|
+
const password = await secretInput(ctx, "输入云凭据主密码以解锁");
|
|
38
|
+
if (password === undefined) return undefined;
|
|
39
|
+
try {
|
|
40
|
+
return { password, payload: readCloudVault(password, vaultPath) };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
43
|
+
const retry = await ctx.ui.confirm("重新输入主密码?", "凭据未解锁,HWCode Cloud 尚未启动。");
|
|
44
|
+
if (!retry) return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function collectCredentials(
|
|
50
|
+
vendor: CloudVendorId,
|
|
51
|
+
root: string,
|
|
52
|
+
ctx: ExtensionCommandContext,
|
|
53
|
+
): Promise<CloudCredentials | undefined> {
|
|
54
|
+
const credentials: CloudCredentials = {};
|
|
55
|
+
for (const field of getCloudProvider(vendor).credentialFields) {
|
|
56
|
+
let value = field.secret
|
|
57
|
+
? await secretInput(ctx, field.label)
|
|
58
|
+
: await ctx.ui.input(field.label, field.placeholder);
|
|
59
|
+
if (value === undefined) return undefined;
|
|
60
|
+
value = value.trim();
|
|
61
|
+
if (!value && !field.optional) {
|
|
62
|
+
notify(ctx, `${field.label} 不能为空。`, "warning");
|
|
63
|
+
return collectCredentials(vendor, root, ctx);
|
|
64
|
+
}
|
|
65
|
+
if (!value) continue;
|
|
66
|
+
if (field.fileContents) {
|
|
67
|
+
const path = isAbsolute(value) ? value : resolve(root, value);
|
|
68
|
+
try {
|
|
69
|
+
const contents = readFileSync(path, "utf8");
|
|
70
|
+
JSON.parse(contents);
|
|
71
|
+
credentials[field.key] = contents;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
notify(
|
|
74
|
+
ctx,
|
|
75
|
+
`无法读取有效的 JSON 凭据文件 ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
76
|
+
"error",
|
|
77
|
+
);
|
|
78
|
+
return collectCredentials(vendor, root, ctx);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
credentials[field.key] = value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (vendor === "gcp") {
|
|
85
|
+
const account = JSON.parse(credentials.serviceAccountJson) as Record<string, unknown>;
|
|
86
|
+
credentials.projectId ||= typeof account.project_id === "string" ? account.project_id : "";
|
|
87
|
+
if (!credentials.projectId) {
|
|
88
|
+
notify(ctx, "Service Account JSON 中没有 project_id,请重新输入。", "error");
|
|
89
|
+
return collectCredentials(vendor, root, ctx);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return credentials;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function remoteTrustInteraction(ctx: ExtensionContext): RemoteTrustInteraction {
|
|
96
|
+
return {
|
|
97
|
+
confirm: async (host, port, keys, role) => ctx.hasUI && ctx.ui.confirm(
|
|
98
|
+
role === "jump" ? "信任 SSH 跳板机主机密钥?" : "信任 Terraform Runner 主机密钥?",
|
|
99
|
+
`主机:${host}:${port}\n${keys.map((key) => `${key.algorithm} ${key.fingerprint}`).join("\n")}\n\n请仅在指纹与云控制台或可信运维记录一致时确认。`,
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -4,17 +4,23 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
4
4
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
6
|
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
7
|
+
import { toolError, toolOk } from "../../../lib/tool-result.ts";
|
|
7
8
|
import { evaluateCommandArgumentsAccess } from "../../../lib/workspace/access-policy.ts";
|
|
8
9
|
import { prepareCloudExecution } from "../../../lib/workflows/cloud/adapters.ts";
|
|
9
10
|
import { cloudExecutionBlockReason, recordStrategyFailure } from "../../../lib/workflows/cloud/execution.ts";
|
|
10
11
|
import { runProcess, truncateOutput, type ProcessResult } from "../../../lib/workflows/cloud/process.ts";
|
|
11
12
|
import { CLOUD_EXECUTABLES, classifyCloudOperation, getCloudProvider, redactCredentialValues, type CloudOperation } from "../../../lib/workflows/cloud/providers.ts";
|
|
12
|
-
import {
|
|
13
|
+
import { cloudArtifactDirectory } from "../../../lib/workflows/cloud/workspace.ts";
|
|
14
|
+
import { WORKFLOW_EXTERNAL_AUDIT_TYPE, cloudDetails } from "../../../lib/workflows/state.ts";
|
|
13
15
|
import type { CloudExtensionRuntime } from "./runtime.ts";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
|
|
17
|
+
const CLOUD_AUDIT_TYPE = "hwcode-cloud-audit";
|
|
18
|
+
const ORCHESTRATION_COMMANDS = new Set(["terraform", "tofu", "pulumi", "kubectl", "helm"]);
|
|
19
|
+
const SENSITIVE_ARGUMENT = /^--?(?:access[-_]?key|secret(?:[-_]?(?:access|key))?|client[-_]?secret|password|credential|token)(?:=|$)/iu;
|
|
20
|
+
|
|
21
|
+
function safeStepText(value: string, credentials: Record<string, string>): string {
|
|
22
|
+
return redactCredentialValues(value.replace(/[\r\n]+/gu, " ").slice(0, 2_000), credentials);
|
|
23
|
+
}
|
|
18
24
|
|
|
19
25
|
export function registerCloudProviderTools(pi: ExtensionAPI, runtime: CloudExtensionRuntime): void {
|
|
20
26
|
pi.registerTool(defineTool({
|
|
@@ -31,20 +37,20 @@ export function registerCloudProviderTools(pi: ExtensionAPI, runtime: CloudExten
|
|
|
31
37
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
32
38
|
const activeState = runtime.restore(ctx);
|
|
33
39
|
const blocked = cloudExecutionBlockReason(activeState, { allowAfterTerminalFailure: true });
|
|
34
|
-
if (!activeState || blocked) return
|
|
40
|
+
if (!activeState || blocked) return toolError(blocked ?? "HWCode Cloud is not active.");
|
|
35
41
|
const details = cloudDetails(activeState)!;
|
|
36
42
|
const clean = (value: string, limit: number) => value.replace(/[\r\n\0]/gu, " ").trim().slice(0, limit);
|
|
37
43
|
const id = clean(params.id, 256);
|
|
38
44
|
const type = clean(params.type, 128);
|
|
39
45
|
const region = clean(params.region, 128);
|
|
40
|
-
if (!id || !type || !region) return
|
|
46
|
+
if (!id || !type || !region) return toolError("Resource id, type, and region are required.");
|
|
41
47
|
const resources = [...(details.resources ?? [])];
|
|
42
48
|
const index = resources.findIndex((resource) => resource.id === id && resource.type === type && resource.region === region);
|
|
43
49
|
const record = { id, type, region, ownership: params.ownership, status: params.status, updatedAt: new Date().toISOString() } as const;
|
|
44
50
|
if (index >= 0) resources[index] = record;
|
|
45
51
|
else resources.push(record);
|
|
46
52
|
runtime.replaceDetails(activeState, { ...details, resources: resources.slice(-500) });
|
|
47
|
-
return
|
|
53
|
+
return toolOk(`Resource inventory updated: ${type} ${id} (${params.status}).`, { resource: record });
|
|
48
54
|
},
|
|
49
55
|
}));
|
|
50
56
|
|
|
@@ -85,7 +91,7 @@ export function registerCloudProviderTools(pi: ExtensionAPI, runtime: CloudExten
|
|
|
85
91
|
pi.appendEntry(WORKFLOW_EXTERNAL_AUDIT_TYPE, { mode: "cloud", root: activeState.root, toolName: "hwcode_cloud_exec", external, approvedAt: new Date().toISOString() });
|
|
86
92
|
}
|
|
87
93
|
|
|
88
|
-
const approval = await approveOperation(operation, params, activeState, ctx);
|
|
94
|
+
const approval = await runtime.approveOperation(operation, params, activeState, ctx);
|
|
89
95
|
if (approval === "deny") return { content: [{ type: "text" as const, text: `User denied ${operation} operation: ${params.intent}` }], isError: true, details: { denied: true, operation } };
|
|
90
96
|
if (approval === "allow-session") {
|
|
91
97
|
activeState = runtime.replaceDetails(activeState, { ...details, allowNonDeleteChanges: true });
|
|
@@ -7,6 +7,7 @@ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
7
7
|
|
|
8
8
|
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
9
9
|
import { remoteRunnerPaths, userRuntimePaths } from "../../../lib/runtime/paths.ts";
|
|
10
|
+
import { toolError, toolOk } from "../../../lib/tool-result.ts";
|
|
10
11
|
import { prepareRemoteRunner } from "../../../lib/workflows/cloud/remote/bootstrap.ts";
|
|
11
12
|
import { connectRemoteTarget } from "../../../lib/workflows/cloud/remote/connect.ts";
|
|
12
13
|
import { remoteTargetSummary, type RemoteTargetProfile } from "../../../lib/workflows/cloud/remote/profiles.ts";
|
|
@@ -14,9 +15,9 @@ import { createRemoteRunWorkspace, isTerraformSourceFile, syncRemoteWorkspace }
|
|
|
14
15
|
import { truncateOutput } from "../../../lib/workflows/cloud/process.ts";
|
|
15
16
|
import { assertSafeTerraformSource } from "../../../lib/workflows/cloud/terraform/policy.ts";
|
|
16
17
|
import { saveRemoteTargetProfile, writeCloudVault } from "../../../lib/workflows/cloud/vault.ts";
|
|
17
|
-
import type
|
|
18
|
+
import { cloudDetails, type TerraformRunState } from "../../../lib/workflows/state.ts";
|
|
19
|
+
import { remoteTrustInteraction } from "./interactions.ts";
|
|
18
20
|
import type { CloudExtensionRuntime } from "./runtime.ts";
|
|
19
|
-
import { cloudDetails, remoteTrustInteraction, terraformError, terraformOk } from "./shared.ts";
|
|
20
21
|
|
|
21
22
|
export function registerCloudRunnerTools(pi: ExtensionAPI, runtime: CloudExtensionRuntime): void {
|
|
22
23
|
pi.registerTool(defineTool({
|
|
@@ -38,11 +39,11 @@ export function registerCloudRunnerTools(pi: ExtensionAPI, runtime: CloudExtensi
|
|
|
38
39
|
const activeState = runtime.restore(ctx);
|
|
39
40
|
const details = activeState && cloudDetails(activeState);
|
|
40
41
|
const blocked = runtime.blocked(activeState);
|
|
41
|
-
if (!activeState || !details || blocked) return
|
|
42
|
-
if (!runtime.activeVault) return
|
|
42
|
+
if (!activeState || !details || blocked) return toolError(blocked ?? "HWCode Cloud is not active.");
|
|
43
|
+
if (!runtime.activeVault) return toolError("HWCode Cloud credentials are locked. Run /hwcode-cloud before connecting a Runner.");
|
|
43
44
|
const home = homedir();
|
|
44
45
|
const keyPath = params.keyPath || CLOUD_RUNTIME_DEFAULTS.runner.keyCandidates.map((file) => resolve(home, ".ssh", file)).find(existsSync);
|
|
45
|
-
if (!keyPath || !existsSync(keyPath)) return
|
|
46
|
+
if (!keyPath || !existsSync(keyPath)) return toolError("No local SSH private key was found. Pass keyPath explicitly or create ~/.ssh/id_ed25519.");
|
|
46
47
|
const user = params.user || CLOUD_RUNTIME_DEFAULTS.runner.defaultUser;
|
|
47
48
|
const port = params.port || CLOUD_RUNTIME_DEFAULTS.runner.defaultPort;
|
|
48
49
|
let profile: RemoteTargetProfile;
|
|
@@ -55,12 +56,12 @@ export function registerCloudRunnerTools(pi: ExtensionAPI, runtime: CloudExtensi
|
|
|
55
56
|
identityType: params.identityType || "instance-role",
|
|
56
57
|
proxyJump: params.jumpHost ? { host: params.jumpHost, port: params.jumpPort || CLOUD_RUNTIME_DEFAULTS.runner.defaultPort, user: params.jumpUser || user } : undefined,
|
|
57
58
|
}, activeState.root, remoteTrustInteraction(ctx), signal);
|
|
58
|
-
} catch (error) { return
|
|
59
|
+
} catch (error) { return toolError(error instanceof Error ? error.message : String(error)); }
|
|
59
60
|
runtime.activeRunner = profile;
|
|
60
61
|
runtime.activeVault.payload = saveRemoteTargetProfile(runtime.activeVault.payload, profile);
|
|
61
62
|
writeCloudVault(runtime.activeVault.payload, runtime.activeVault.password, runtime.activeVault.path);
|
|
62
63
|
runtime.replaceDetails(activeState, { ...details, runner: remoteTargetSummary(profile) }, "connected");
|
|
63
|
-
return
|
|
64
|
+
return toolOk(`Terraform Runner connected: ${profile.user}@${profile.host}:${profile.port}`, { runner: remoteTargetSummary(profile) });
|
|
64
65
|
},
|
|
65
66
|
}));
|
|
66
67
|
|
|
@@ -71,21 +72,21 @@ export function registerCloudRunnerTools(pi: ExtensionAPI, runtime: CloudExtensi
|
|
|
71
72
|
async execute(_id, _params, signal, _onUpdate, ctx) {
|
|
72
73
|
const activeState = runtime.restore(ctx);
|
|
73
74
|
const blocked = runtime.blocked(activeState);
|
|
74
|
-
if (!activeState || blocked) return
|
|
75
|
+
if (!activeState || blocked) return toolError(blocked ?? "HWCode Cloud is not active.");
|
|
75
76
|
let runner: RemoteTargetProfile;
|
|
76
|
-
try { runner = runtime.requireRunner(activeState); } catch (error) { return
|
|
77
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return toolError(error instanceof Error ? error.message : String(error)); }
|
|
77
78
|
try {
|
|
78
79
|
const report = await prepareRemoteRunner(runner, signal);
|
|
79
80
|
if (!report.ready) {
|
|
80
81
|
const reason = `Runner is reachable but is missing required capabilities: ${report.missing.join(", ")}. Install them through provider bootstrap/cloud-init, then retry.`;
|
|
81
82
|
runtime.runnerFailure(activeState, "runner-prepare", reason);
|
|
82
|
-
return
|
|
83
|
+
return toolError(reason);
|
|
83
84
|
}
|
|
84
|
-
return
|
|
85
|
+
return toolOk(`Runner ${runner.name} is ready.\n${report.terraformVersion || "Terraform available"}`, { runner: remoteTargetSummary(runner), capabilities: report });
|
|
85
86
|
} catch (error) {
|
|
86
87
|
const reason = error instanceof Error ? error.message : String(error);
|
|
87
88
|
runtime.runnerFailure(activeState, "runner-prepare", reason);
|
|
88
|
-
return
|
|
89
|
+
return toolError(`Runner preparation failed:\n${truncateOutput(reason)}`);
|
|
89
90
|
}
|
|
90
91
|
},
|
|
91
92
|
}));
|
|
@@ -98,32 +99,32 @@ export function registerCloudRunnerTools(pi: ExtensionAPI, runtime: CloudExtensi
|
|
|
98
99
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
99
100
|
const activeState = runtime.restore(ctx);
|
|
100
101
|
const blocked = runtime.blocked(activeState);
|
|
101
|
-
if (!activeState || blocked) return
|
|
102
|
+
if (!activeState || blocked) return toolError(blocked ?? "HWCode Cloud is not active.");
|
|
102
103
|
let runner: RemoteTargetProfile;
|
|
103
|
-
try { runner = runtime.requireRunner(activeState); } catch (error) { return
|
|
104
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return toolError(error instanceof Error ? error.message : String(error)); }
|
|
104
105
|
const sourcePath = isAbsolute(params.sourcePath) ? resolve(params.sourcePath) : resolve(activeState.root, params.sourcePath);
|
|
105
106
|
const trustedBundlePath = cloudDetails(activeState)!.terraformSourcePath ? resolve(cloudDetails(activeState)!.terraformSourcePath!) : undefined;
|
|
106
107
|
const outside = relative(activeState.root, sourcePath).startsWith("..") || isAbsolute(relative(activeState.root, sourcePath));
|
|
107
|
-
if (outside && sourcePath !== trustedBundlePath) return
|
|
108
|
-
if (!existsSync(sourcePath) || !statSync(sourcePath).isDirectory()) return
|
|
108
|
+
if (outside && sourcePath !== trustedBundlePath) return toolError("Terraform sourcePath must be inside the locked project root, except for the exact selected local Terraform Template bundle.");
|
|
109
|
+
if (!existsSync(sourcePath) || !statSync(sourcePath).isDirectory()) return toolError("Terraform sourcePath must be an existing directory.");
|
|
109
110
|
let workspace;
|
|
110
111
|
try { workspace = createRemoteRunWorkspace(sourcePath, runner.remoteRoot); }
|
|
111
|
-
catch (error) { return
|
|
112
|
+
catch (error) { return toolError(`Terraform source manifest rejected this bundle:\n${error instanceof Error ? error.message : String(error)}`); }
|
|
112
113
|
const files = workspace.files.filter((file) => isTerraformSourceFile(file.relativePath)).map((file) => ({ relativePath: file.relativePath, content: readFileSync(resolve(sourcePath, file.relativePath), "utf8") }));
|
|
113
114
|
try { assertSafeTerraformSource(files, { requireRemoteBackend: true }); }
|
|
114
|
-
catch (error) { return
|
|
115
|
+
catch (error) { return toolError(`Terraform source policy rejected this bundle:\n${error instanceof Error ? error.message : String(error)}`); }
|
|
115
116
|
try { await syncRemoteWorkspace(runner, sourcePath, workspace, signal); }
|
|
116
117
|
catch (error) {
|
|
117
118
|
const reason = error instanceof Error ? error.message : String(error);
|
|
118
119
|
runtime.runnerFailure(activeState, "terraform-runner-sync", reason);
|
|
119
|
-
return
|
|
120
|
+
return toolError(`Terraform sync failed:\n${truncateOutput(reason)}`);
|
|
120
121
|
}
|
|
121
122
|
const run: TerraformRunState = {
|
|
122
123
|
runId: workspace.runId, runnerId: runner.id, phase: "synced", sourceDigest: workspace.sourceDigest,
|
|
123
124
|
sourcePath, remoteWorkspace: workspace.remotePath, startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
124
125
|
};
|
|
125
126
|
runtime.replaceDetails(activeState, { ...cloudDetails(activeState)!, terraformRun: run }, "executing");
|
|
126
|
-
return
|
|
127
|
+
return toolOk(`Terraform source synced. runId=${run.runId}\nsourceDigest=${run.sourceDigest}\nremoteWorkspace=${run.remoteWorkspace}`, { runId: run.runId, sourceDigest: run.sourceDigest });
|
|
127
128
|
},
|
|
128
129
|
}));
|
|
129
130
|
}
|
|
@@ -9,8 +9,10 @@ import type { CloudCredentials, CloudOperation } from "../../../lib/workflows/cl
|
|
|
9
9
|
import type { RemoteTargetProfile } from "../../../lib/workflows/cloud/remote/profiles.ts";
|
|
10
10
|
import { createEmptyVault } from "../../../lib/workflows/cloud/vault.ts";
|
|
11
11
|
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
12
|
-
import {
|
|
13
|
-
|
|
12
|
+
import {
|
|
13
|
+
WORKFLOW_STATE_TYPE, activeWorkflow, cloudDetails, updateWorkflowState,
|
|
14
|
+
type CloudWorkflowDetails, type TerraformRunState, type WorkflowState,
|
|
15
|
+
} from "../../../lib/workflows/state.ts";
|
|
14
16
|
|
|
15
17
|
export interface ActiveCloudVault {
|
|
16
18
|
password: string;
|
|
@@ -26,6 +28,7 @@ export class CloudExtensionRuntime {
|
|
|
26
28
|
activeVault?: ActiveCloudVault;
|
|
27
29
|
readonly temporaryStore: TemporaryCredentialStore;
|
|
28
30
|
private readonly credentialDirectories = new Set<string>();
|
|
31
|
+
private approvalQueue = Promise.resolve();
|
|
29
32
|
|
|
30
33
|
constructor(pi: ExtensionAPI) {
|
|
31
34
|
this.pi = pi;
|
|
@@ -43,7 +46,8 @@ export class CloudExtensionRuntime {
|
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
restore(ctx: ExtensionContext): WorkflowState | undefined {
|
|
46
|
-
|
|
49
|
+
const state = activeWorkflow(ctx.sessionManager.getEntries());
|
|
50
|
+
this.activeState = state?.mode === "cloud" && state.details ? state : undefined;
|
|
47
51
|
return this.activeState;
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -61,10 +65,43 @@ export class CloudExtensionRuntime {
|
|
|
61
65
|
replaceDetails(state: WorkflowState, details: CloudWorkflowDetails, phase = state.phase): WorkflowState {
|
|
62
66
|
const updated = updateWorkflowState(state, { details, phase });
|
|
63
67
|
this.activeState = updated;
|
|
64
|
-
|
|
68
|
+
this.pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, updated);
|
|
65
69
|
return updated;
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
async approveOperation(
|
|
73
|
+
operation: CloudOperation,
|
|
74
|
+
params: { command: string; args: string[]; intent: string },
|
|
75
|
+
state: WorkflowState,
|
|
76
|
+
ctx: ExtensionContext,
|
|
77
|
+
): Promise<"allow" | "allow-session" | "deny"> {
|
|
78
|
+
if (operation === "read") return "allow";
|
|
79
|
+
if (!ctx.hasUI) return "deny";
|
|
80
|
+
const next = this.approvalQueue.then(async () => {
|
|
81
|
+
const details = cloudDetails(state)!;
|
|
82
|
+
const command = `${params.command} ${params.args.join(" ")}`;
|
|
83
|
+
if (operation === "delete") {
|
|
84
|
+
const approved = await ctx.ui.confirm(
|
|
85
|
+
"确认删除云资源?",
|
|
86
|
+
`目标:${params.intent}\n命令:${command}\n\n删除操作始终逐次确认。`,
|
|
87
|
+
);
|
|
88
|
+
return approved ? "allow" as const : "deny" as const;
|
|
89
|
+
}
|
|
90
|
+
if (details.allowNonDeleteChanges) return "allow" as const;
|
|
91
|
+
const choice = await ctx.ui.select("允许修改云账号内资源?", [
|
|
92
|
+
"仅允许本次",
|
|
93
|
+
"允许本会话后续非删除变更,不再询问",
|
|
94
|
+
"拒绝",
|
|
95
|
+
]);
|
|
96
|
+
if (choice === "仅允许本次") return "allow" as const;
|
|
97
|
+
if (choice === "允许本会话后续非删除变更,不再询问") return "allow-session" as const;
|
|
98
|
+
return "deny" as const;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.approvalQueue = next.then(() => {}, () => {});
|
|
102
|
+
return await next;
|
|
103
|
+
}
|
|
104
|
+
|
|
68
105
|
terraformRun(state: WorkflowState): TerraformRunState | undefined {
|
|
69
106
|
return cloudDetails(state)?.terraformRun;
|
|
70
107
|
}
|
|
@@ -2,6 +2,7 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
4
|
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
5
|
+
import { toolError, toolOk } from "../../../lib/tool-result.ts";
|
|
5
6
|
import type { CloudOperation } from "../../../lib/workflows/cloud/providers.ts";
|
|
6
7
|
import type { RemoteTargetProfile } from "../../../lib/workflows/cloud/remote/profiles.ts";
|
|
7
8
|
import {
|
|
@@ -11,8 +12,11 @@ import {
|
|
|
11
12
|
import { digestTerraformPlan, executeTerraformRunner } from "../../../lib/workflows/cloud/terraform/runner.ts";
|
|
12
13
|
import { cloudExecutionBlockReason } from "../../../lib/workflows/cloud/execution.ts";
|
|
13
14
|
import { truncateOutput } from "../../../lib/workflows/cloud/process.ts";
|
|
15
|
+
import {
|
|
16
|
+
cloudTerraformTemplateSource, persistAppliedTerraformArtifact,
|
|
17
|
+
} from "../../../lib/workflows/cloud/bundles.ts";
|
|
18
|
+
import { cloudDetails } from "../../../lib/workflows/state.ts";
|
|
14
19
|
import type { CloudExtensionRuntime } from "./runtime.ts";
|
|
15
|
-
import { approveOperation, cloudDetails, terraformError, terraformOk } from "./shared.ts";
|
|
16
20
|
|
|
17
21
|
const PLAN_FILE = CLOUD_RUNTIME_DEFAULTS.terraform.planFile;
|
|
18
22
|
|
|
@@ -25,21 +29,21 @@ export function registerCloudTerraformTools(pi: ExtensionAPI, runtime: CloudExte
|
|
|
25
29
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
26
30
|
const activeState = runtime.restore(ctx);
|
|
27
31
|
const blocked = runtime.blocked(activeState);
|
|
28
|
-
if (!activeState || blocked) return
|
|
32
|
+
if (!activeState || blocked) return toolError(blocked ?? "HWCode Cloud is not active.");
|
|
29
33
|
const run = runtime.terraformRun(activeState);
|
|
30
|
-
if (!run || run.runId !== params.runId || run.phase !== "synced") return
|
|
34
|
+
if (!run || run.runId !== params.runId || run.phase !== "synced") return toolError("Terraform validate requires a newly synced run.");
|
|
31
35
|
let runner: RemoteTargetProfile;
|
|
32
|
-
try { runner = runtime.requireRunner(activeState); } catch (error) { return
|
|
36
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return toolError(error instanceof Error ? error.message : String(error)); }
|
|
33
37
|
for (const action of ["fmt", "init", "validate", "test"] as const) {
|
|
34
38
|
const result = await executeTerraformRunner(runner, { action, workspace: run.remoteWorkspace }, { signal });
|
|
35
39
|
if (result.code !== 0) {
|
|
36
40
|
runtime.runnerFailure(activeState, `terraform-${action}`, result.stderr || result.stdout || `exit code ${result.code}`);
|
|
37
|
-
return
|
|
41
|
+
return toolError(`terraform ${action} failed:\n${truncateOutput(result.stderr || result.stdout)}`);
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
runtime.replaceDetails(activeState, { ...cloudDetails(activeState)!, terraformRun: { ...run, phase: "validated", updatedAt: new Date().toISOString() } }, "executing");
|
|
41
45
|
runtime.recordTerraformSuccess(runtime.activeState!, ["fmt", "init", "validate", "test"], "read", "Validate Terraform source and tests", "terraform-runner");
|
|
42
|
-
return
|
|
46
|
+
return toolOk("Terraform fmt/init/validate/test completed successfully.", { runId: run.runId });
|
|
43
47
|
},
|
|
44
48
|
}));
|
|
45
49
|
|
|
@@ -51,22 +55,22 @@ export function registerCloudTerraformTools(pi: ExtensionAPI, runtime: CloudExte
|
|
|
51
55
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
52
56
|
const activeState = runtime.restore(ctx);
|
|
53
57
|
const blocked = runtime.blocked(activeState);
|
|
54
|
-
if (!activeState || blocked) return
|
|
58
|
+
if (!activeState || blocked) return toolError(blocked ?? "HWCode Cloud is not active.");
|
|
55
59
|
const run = runtime.terraformRun(activeState);
|
|
56
|
-
if (!run || run.runId !== params.runId || run.phase !== "validated") return
|
|
60
|
+
if (!run || run.runId !== params.runId || run.phase !== "validated") return toolError("Terraform plan requires a validated run.");
|
|
57
61
|
let runner: RemoteTargetProfile;
|
|
58
|
-
try { runner = runtime.requireRunner(activeState); } catch (error) { return
|
|
62
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return toolError(error instanceof Error ? error.message : String(error)); }
|
|
59
63
|
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
|
|
64
|
+
if (planned.code !== 0) { runtime.runnerFailure(activeState, `terraform-plan-${params.mode}`, planned.stderr || planned.stdout || `exit code ${planned.code}`); return toolError(`terraform plan failed:\n${truncateOutput(planned.stderr || planned.stdout)}`); }
|
|
61
65
|
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
|
|
66
|
+
if (inspected.code !== 0) { runtime.runnerFailure(activeState, "terraform-show-plan", inspected.stderr || inspected.stdout); return toolError(`terraform show plan failed:\n${truncateOutput(inspected.stderr || inspected.stdout)}`); }
|
|
63
67
|
let summary;
|
|
64
68
|
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
|
|
69
|
+
catch (error) { const reason = error instanceof Error ? error.message : String(error); runtime.runnerFailure(activeState, "terraform-parse-plan", reason); return toolError(`Unable to parse Terraform plan JSON: ${reason}`); }
|
|
66
70
|
const planDigest = digestTerraformPlan(inspected.stdout);
|
|
67
71
|
runtime.replaceDetails(activeState, { ...cloudDetails(activeState)!, terraformRun: { ...run, phase: "planned", planDigest, planSummary: summary, updatedAt: new Date().toISOString() } }, "executing");
|
|
68
72
|
runtime.recordTerraformSuccess(runtime.activeState!, ["plan", params.mode], "read", `Create reviewed Terraform ${params.mode} plan`, "terraform-runner");
|
|
69
|
-
return
|
|
73
|
+
return toolOk(`${terraformPlanSummaryText(summary)}\nplanDigest=${planDigest}`, { runId: run.runId, planDigest, requiresApproval: terraformPlanRequiresApproval(summary), hasDeletion: terraformPlanHasDeletion(summary), summary });
|
|
70
74
|
},
|
|
71
75
|
}));
|
|
72
76
|
|
|
@@ -78,27 +82,36 @@ export function registerCloudTerraformTools(pi: ExtensionAPI, runtime: CloudExte
|
|
|
78
82
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
79
83
|
let activeState = runtime.restore(ctx);
|
|
80
84
|
const blocked = runtime.blocked(activeState);
|
|
81
|
-
if (!activeState || blocked) return
|
|
85
|
+
if (!activeState || blocked) return toolError(blocked ?? "HWCode Cloud is not active.");
|
|
82
86
|
const run = runtime.terraformRun(activeState);
|
|
83
|
-
if (!run || run.runId !== params.runId || run.phase !== "planned" || run.planDigest !== params.planDigest) return
|
|
87
|
+
if (!run || run.runId !== params.runId || run.phase !== "planned" || run.planDigest !== params.planDigest) return toolError("Plan digest mismatch or no planned run is ready. Re-plan before apply.");
|
|
84
88
|
let runner: RemoteTargetProfile;
|
|
85
|
-
try { runner = runtime.requireRunner(activeState); } catch (error) { return
|
|
89
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return toolError(error instanceof Error ? error.message : String(error)); }
|
|
86
90
|
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
|
|
91
|
+
if (currentPlan.code !== 0) { runtime.runnerFailure(activeState, "terraform-reverify-plan", currentPlan.stderr || currentPlan.stdout); return toolError(`Unable to re-verify the managed Terraform plan:\n${truncateOutput(currentPlan.stderr || currentPlan.stdout)}`); }
|
|
88
92
|
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
|
|
93
|
+
if (currentDigest !== run.planDigest || currentDigest !== params.planDigest) { runtime.runnerFailure(activeState, "terraform-plan-integrity", "remote plan digest changed after review"); return toolError("The remote Terraform plan changed after review. Apply is blocked; create and review a new plan."); }
|
|
90
94
|
let currentSummary;
|
|
91
95
|
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
|
|
96
|
+
catch (error) { const reason = error instanceof Error ? error.message : String(error); runtime.runnerFailure(activeState, "terraform-parse-current-plan", reason); return toolError(`Unable to parse the current Terraform plan: ${reason}`); }
|
|
93
97
|
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
|
|
98
|
+
const approval = await runtime.approveOperation(operation, { command: "terraform", args: ["apply", PLAN_FILE], intent: params.intent }, activeState, ctx);
|
|
99
|
+
if (approval === "deny") return toolError("User denied Terraform apply.");
|
|
96
100
|
if (approval === "allow-session") activeState = runtime.replaceDetails(activeState, { ...cloudDetails(activeState)!, allowNonDeleteChanges: true });
|
|
97
101
|
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
|
|
102
|
+
if (result.code !== 0) { runtime.runnerFailure(activeState, "terraform-apply", result.stderr || result.stdout || `exit code ${result.code}`); return toolError(`terraform apply failed:\n${truncateOutput(result.stderr || result.stdout)}`); }
|
|
99
103
|
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
|
-
|
|
104
|
+
const appliedState = runtime.recordTerraformSuccess(runtime.activeState!, ["apply", PLAN_FILE], operation, params.intent, "terraform-runner");
|
|
105
|
+
const appliedDetails = cloudDetails(appliedState)!;
|
|
106
|
+
let templateMessage = "";
|
|
107
|
+
try {
|
|
108
|
+
const artifact = persistAppliedTerraformArtifact(appliedState, run.sourcePath!, appliedDetails.artifactDirectory);
|
|
109
|
+
runtime.replaceDetails(appliedState, { ...appliedDetails, sourceTemplate: cloudTerraformTemplateSource(artifact.template) });
|
|
110
|
+
templateMessage = `\nTerraform artifact ${artifact.action}: ${artifact.template.manifest.name}`;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
templateMessage = `\nTerraform apply succeeded, but the local artifact snapshot failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
113
|
+
}
|
|
114
|
+
return toolOk(`Terraform apply completed.\n${truncateOutput(result.stdout).trim()}${templateMessage}`, { runId: run.runId, planDigest: params.planDigest });
|
|
102
115
|
},
|
|
103
116
|
}));
|
|
104
117
|
|
|
@@ -110,17 +123,17 @@ export function registerCloudTerraformTools(pi: ExtensionAPI, runtime: CloudExte
|
|
|
110
123
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
111
124
|
const activeState = runtime.restore(ctx);
|
|
112
125
|
const blocked = cloudExecutionBlockReason(activeState, { allowAfterTerminalFailure: true });
|
|
113
|
-
if (!activeState || blocked) return
|
|
126
|
+
if (!activeState || blocked) return toolError(blocked ?? "HWCode Cloud is not active.");
|
|
114
127
|
const run = runtime.terraformRun(activeState);
|
|
115
|
-
if (!run || run.runId !== params.runId || !["planned", "applied"].includes(run.phase)) return
|
|
128
|
+
if (!run || run.runId !== params.runId || !["planned", "applied"].includes(run.phase)) return toolError("Terraform status requires a planned or applied run.");
|
|
116
129
|
let runner: RemoteTargetProfile;
|
|
117
|
-
try { runner = runtime.requireRunner(activeState); } catch (error) { return
|
|
130
|
+
try { runner = runtime.requireRunner(activeState); } catch (error) { return toolError(error instanceof Error ? error.message : String(error)); }
|
|
118
131
|
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
|
|
132
|
+
if (result.code !== 0) { runtime.runnerFailure(activeState, "terraform-state", result.stderr || result.stdout); return toolError(`terraform state inspection failed:\n${truncateOutput(result.stderr || result.stdout)}`); }
|
|
120
133
|
let summary;
|
|
121
134
|
try { summary = summarizeTerraformState(JSON.parse(result.stdout)); }
|
|
122
|
-
catch (error) { return
|
|
123
|
-
return
|
|
135
|
+
catch (error) { return toolError(`Unable to summarize Terraform state safely: ${error instanceof Error ? error.message : String(error)}`); }
|
|
136
|
+
return toolOk(`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
137
|
},
|
|
125
138
|
}));
|
|
126
139
|
}
|
|
@@ -6,6 +6,7 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
6
6
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
8
8
|
|
|
9
|
+
import { notify } from "../../lib/extension-ui.ts";
|
|
9
10
|
import {
|
|
10
11
|
canonicalizeWorkspaceRoot,
|
|
11
12
|
} from "../../lib/workflow-guard.ts";
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
} from "../../lib/workflows/state.ts";
|
|
20
21
|
import { advanceSddProgress, SDD_PHASES } from "../../lib/workflows/sdd.ts";
|
|
21
22
|
import { PROJECT_SDD_SPECS_RELATIVE } from "../../lib/runtime/paths.ts";
|
|
22
|
-
import { confirmWorkspace, modeLabel
|
|
23
|
+
import { confirmWorkspace, modeLabel } from "./workspace-guard.ts";
|
|
23
24
|
|
|
24
25
|
interface GitState {
|
|
25
26
|
initialized: boolean;
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
} from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
5
|
+
import { notify } from "../../lib/extension-ui.ts";
|
|
5
6
|
import {
|
|
6
7
|
canonicalizeWorkspaceRoot,
|
|
7
8
|
} from "../../lib/workflow-guard.ts";
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
createWorkflowState,
|
|
13
14
|
type WorkflowState,
|
|
14
15
|
} from "../../lib/workflows/state.ts";
|
|
15
|
-
import { confirmWorkspace, modeLabel
|
|
16
|
+
import { confirmWorkspace, modeLabel } from "./workspace-guard.ts";
|
|
16
17
|
|
|
17
18
|
export function registerVibeWorkflow(pi: ExtensionAPI) {
|
|
18
19
|
pi.registerCommand("hwcode-vibe", {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionCommandContext,
|
|
4
|
-
ExtensionContext,
|
|
5
4
|
} from "@earendil-works/pi-coding-agent";
|
|
6
5
|
|
|
6
|
+
import { notify } from "../../lib/extension-ui.ts";
|
|
7
7
|
import {
|
|
8
8
|
canonicalizeWorkspaceRoot,
|
|
9
9
|
} from "../../lib/workflow-guard.ts";
|
|
@@ -23,10 +23,6 @@ export function modeLabel(mode: WorkflowMode): string {
|
|
|
23
23
|
return workflowLabel(mode);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
27
|
-
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
26
|
export async function confirmWorkspace(mode: WorkflowMode, root: string, ctx: ExtensionCommandContext): Promise<boolean> {
|
|
31
27
|
if (!ctx.hasUI) return false;
|
|
32
28
|
return ctx.ui.confirm(
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CURSOR_MARKER, Input, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
class MaskedInput implements Component {
|
|
5
|
+
private readonly input = new Input();
|
|
6
|
+
private _focused = true;
|
|
7
|
+
private readonly tui: TUI;
|
|
8
|
+
private readonly theme: Theme;
|
|
9
|
+
private readonly title: string;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
tui: TUI,
|
|
13
|
+
theme: Theme,
|
|
14
|
+
title: string,
|
|
15
|
+
done: (value: string | undefined) => void,
|
|
16
|
+
) {
|
|
17
|
+
this.tui = tui;
|
|
18
|
+
this.theme = theme;
|
|
19
|
+
this.title = title;
|
|
20
|
+
this.input.onSubmit = (value) => done(value);
|
|
21
|
+
this.input.onEscape = () => done(undefined);
|
|
22
|
+
this.input.focused = true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get focused(): boolean { return this._focused; }
|
|
26
|
+
set focused(value: boolean) { this._focused = value; this.input.focused = value; }
|
|
27
|
+
handleInput(data: string): void { this.input.handleInput(data); this.tui.requestRender(); }
|
|
28
|
+
invalidate(): void { this.input.invalidate(); }
|
|
29
|
+
render(width: number): string[] {
|
|
30
|
+
const masked = "•".repeat(Math.min(Array.from(this.input.getValue()).length, Math.max(1, width - 1)));
|
|
31
|
+
return [
|
|
32
|
+
this.theme.fg("accent", this.title),
|
|
33
|
+
`${masked}${CURSOR_MARKER}`,
|
|
34
|
+
this.theme.fg("dim", "Enter 确认 · Esc 取消 · 输入内容不会显示"),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function notify(
|
|
40
|
+
ctx: ExtensionContext,
|
|
41
|
+
message: string,
|
|
42
|
+
level: "info" | "warning" | "error" = "info",
|
|
43
|
+
): void {
|
|
44
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function secretInput(ctx: ExtensionContext, title: string): Promise<string | undefined> {
|
|
48
|
+
if (ctx.mode !== "tui") return undefined;
|
|
49
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => (
|
|
50
|
+
new MaskedInput(tui, theme, title, done)
|
|
51
|
+
));
|
|
52
|
+
}
|