@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.
Files changed (36) hide show
  1. package/.pi/extensions/command-filter.ts +2 -3
  2. package/.pi/extensions/cwd.ts +1 -4
  3. package/.pi/extensions/knowledge.ts +224 -0
  4. package/.pi/extensions/workflows/cloud/activation.ts +57 -43
  5. package/.pi/extensions/workflows/cloud/commands.ts +1 -86
  6. package/.pi/extensions/workflows/cloud/events.ts +16 -19
  7. package/.pi/extensions/workflows/cloud/interactions.ts +102 -0
  8. package/.pi/extensions/workflows/cloud/provider-tools.ts +15 -9
  9. package/.pi/extensions/workflows/cloud/runner-tools.ts +21 -20
  10. package/.pi/extensions/workflows/cloud/runtime.ts +41 -4
  11. package/.pi/extensions/workflows/cloud/terraform-tools.ts +43 -30
  12. package/.pi/extensions/workflows/sdd.ts +2 -1
  13. package/.pi/extensions/workflows/vibe.ts +2 -1
  14. package/.pi/extensions/workflows/workspace-guard.ts +1 -5
  15. package/.pi/lib/extension-ui.ts +52 -0
  16. package/.pi/lib/knowledge/extractor.ts +35 -0
  17. package/.pi/lib/knowledge/matcher.ts +122 -0
  18. package/.pi/lib/knowledge/review-worker.ts +260 -0
  19. package/.pi/lib/knowledge/sanitize.ts +26 -0
  20. package/.pi/lib/knowledge/session-scanner.ts +155 -0
  21. package/.pi/lib/knowledge/store.ts +365 -0
  22. package/.pi/lib/knowledge/types.ts +91 -0
  23. package/.pi/lib/knowledge/worker-protocol.ts +20 -0
  24. package/.pi/lib/runtime/defaults.ts +31 -0
  25. package/.pi/lib/runtime/paths.ts +33 -16
  26. package/.pi/lib/tool-result.ts +7 -0
  27. package/.pi/lib/workflows/cloud/bundles.ts +73 -40
  28. package/.pi/lib/workflows/cloud/workspace.ts +6 -0
  29. package/.pi/lib/workflows/state.ts +6 -26
  30. package/.pi/skills/hwcode-cloud/SKILL.md +2 -2
  31. package/README.md +36 -31
  32. package/bin/hwcode.js +2 -3
  33. package/package.json +1 -1
  34. package/.pi/extensions/workflows/cloud/shared.ts +0 -230
  35. package/.pi/lib/workflows/cloud/template-save.ts +0 -108
  36. 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 { WORKFLOW_EXTERNAL_AUDIT_TYPE } from "../../../lib/workflows/state.ts";
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
- import {
15
- CLOUD_AUDIT_TYPE, ORCHESTRATION_COMMANDS, SENSITIVE_ARGUMENT, approveOperation,
16
- cloudArtifactDirectory, cloudDetails, safeStepText, terraformError, terraformOk,
17
- } from "./shared.ts";
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 terraformError(blocked ?? "HWCode Cloud is not active.");
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 terraformError("Resource id, type, and region are required.");
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 terraformOk(`Resource inventory updated: ${type} ${id} (${params.status}).`, { resource: record });
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 { TerraformRunState } from "../../../lib/workflows/state.ts";
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 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.");
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 terraformError("No local SSH private key was found. Pass keyPath explicitly or create ~/.ssh/id_ed25519.");
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 terraformError(error instanceof Error ? error.message : String(error)); }
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 terraformOk(`Terraform Runner connected: ${profile.user}@${profile.host}:${profile.port}`, { runner: remoteTargetSummary(profile) });
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 terraformError(blocked ?? "HWCode Cloud is not active.");
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 terraformError(error instanceof Error ? error.message : String(error)); }
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 terraformError(reason);
83
+ return toolError(reason);
83
84
  }
84
- return terraformOk(`Runner ${runner.name} is ready.\n${report.terraformVersion || "Terraform available"}`, { runner: remoteTargetSummary(runner), capabilities: report });
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 terraformError(`Runner preparation failed:\n${truncateOutput(reason)}`);
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 terraformError(blocked ?? "HWCode Cloud is not active.");
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 terraformError(error instanceof Error ? error.message : String(error)); }
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 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.");
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 terraformError(`Terraform source manifest rejected this bundle:\n${error instanceof Error ? error.message : String(error)}`); }
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 terraformError(`Terraform source policy rejected this bundle:\n${error instanceof Error ? error.message : String(error)}`); }
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 terraformError(`Terraform sync failed:\n${truncateOutput(reason)}`);
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 terraformOk(`Terraform source synced. runId=${run.runId}\nsourceDigest=${run.sourceDigest}\nremoteWorkspace=${run.remoteWorkspace}`, { runId: run.runId, sourceDigest: run.sourceDigest });
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 { updateWorkflowState, type CloudWorkflowDetails, type TerraformRunState, type WorkflowState } from "../../../lib/workflows/state.ts";
13
- import { appendWorkflowState, cloudDetails, restoreCloudWorkflow } from "./shared.ts";
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
- this.activeState = restoreCloudWorkflow(ctx);
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
- appendWorkflowState(this.pi, updated);
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 terraformError(blocked ?? "HWCode Cloud is not active.");
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 terraformError("Terraform validate requires a newly synced run.");
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 terraformError(error instanceof Error ? error.message : String(error)); }
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 terraformError(`terraform ${action} failed:\n${truncateOutput(result.stderr || result.stdout)}`);
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 terraformOk("Terraform fmt/init/validate/test completed successfully.", { runId: run.runId });
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 terraformError(blocked ?? "HWCode Cloud is not active.");
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 terraformError("Terraform plan requires a validated run.");
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 terraformError(error instanceof Error ? error.message : String(error)); }
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 terraformError(`terraform plan failed:\n${truncateOutput(planned.stderr || planned.stdout)}`); }
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 terraformError(`terraform show plan failed:\n${truncateOutput(inspected.stderr || inspected.stdout)}`); }
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 terraformError(`Unable to parse Terraform plan JSON: ${reason}`); }
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 terraformOk(`${terraformPlanSummaryText(summary)}\nplanDigest=${planDigest}`, { runId: run.runId, planDigest, requiresApproval: terraformPlanRequiresApproval(summary), hasDeletion: terraformPlanHasDeletion(summary), summary });
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 terraformError(blocked ?? "HWCode Cloud is not active.");
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 terraformError("Plan digest mismatch or no planned run is ready. Re-plan before apply.");
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 terraformError(error instanceof Error ? error.message : String(error)); }
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 terraformError(`Unable to re-verify the managed Terraform plan:\n${truncateOutput(currentPlan.stderr || currentPlan.stdout)}`); }
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 terraformError("The remote Terraform plan changed after review. Apply is blocked; create and review a new plan."); }
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 terraformError(`Unable to parse the current Terraform plan: ${reason}`); }
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 terraformError("User denied Terraform apply.");
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 terraformError(`terraform apply failed:\n${truncateOutput(result.stderr || result.stdout)}`); }
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
- return terraformOk(`Terraform apply completed.\n${truncateOutput(result.stdout).trim()}`, { runId: run.runId, planDigest: params.planDigest });
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 terraformError(blocked ?? "HWCode Cloud is not active.");
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 terraformError("Terraform status requires a planned or applied run.");
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 terraformError(error instanceof Error ? error.message : String(error)); }
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 terraformError(`terraform state inspection failed:\n${truncateOutput(result.stderr || result.stdout)}`); }
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 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 });
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, notify } from "./workspace-guard.ts";
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, notify } from "./workspace-guard.ts";
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
+ }