@hadooppei/hwcode 0.2.4 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.pi/APPEND_SYSTEM.md +2 -0
  2. package/.pi/extensions/hwcode.ts +35 -2
  3. package/.pi/extensions/model-providers.ts +1 -2
  4. package/.pi/extensions/workflows/cloud/activation.ts +235 -0
  5. package/.pi/extensions/workflows/cloud/commands.ts +96 -0
  6. package/.pi/extensions/workflows/cloud/events.ts +58 -0
  7. package/.pi/extensions/workflows/cloud/index.ts +17 -0
  8. package/.pi/extensions/workflows/cloud/provider-tools.ts +127 -0
  9. package/.pi/extensions/workflows/cloud/runner-tools.ts +129 -0
  10. package/.pi/extensions/workflows/cloud/runtime.ts +97 -0
  11. package/.pi/extensions/workflows/cloud/shared.ts +213 -0
  12. package/.pi/extensions/workflows/cloud/terraform-tools.ts +126 -0
  13. package/.pi/extensions/workflows/vibe-sdd.ts +303 -0
  14. package/.pi/extensions/workflows.ts +6 -253
  15. package/.pi/lib/runtime/config.ts +3 -7
  16. package/.pi/lib/runtime/defaults.ts +20 -0
  17. package/.pi/lib/runtime/paths.ts +68 -0
  18. package/.pi/lib/runtime/session-state.ts +0 -34
  19. package/.pi/lib/{cloud → workflows/cloud}/adapters.ts +38 -20
  20. package/.pi/lib/workflows/cloud/bundles.ts +358 -0
  21. package/.pi/lib/workflows/cloud/execution.ts +28 -0
  22. package/.pi/lib/{cloud → workflows/cloud}/process.ts +5 -3
  23. package/.pi/lib/{cloud-providers.ts → workflows/cloud/providers.ts} +4 -4
  24. package/.pi/lib/workflows/cloud/remote/bootstrap.ts +23 -0
  25. package/.pi/lib/workflows/cloud/remote/connect.ts +83 -0
  26. package/.pi/lib/workflows/cloud/remote/host-key.ts +35 -0
  27. package/.pi/lib/workflows/cloud/remote/profiles.ts +100 -0
  28. package/.pi/lib/workflows/cloud/remote/ssh-transport.ts +104 -0
  29. package/.pi/lib/workflows/cloud/remote/workspace.ts +109 -0
  30. package/.pi/lib/{cloud → workflows/cloud}/template-save.ts +3 -2
  31. package/.pi/lib/{cloud → workflows/cloud}/templates.ts +18 -10
  32. package/.pi/lib/workflows/cloud/terraform/plan.ts +109 -0
  33. package/.pi/lib/workflows/cloud/terraform/policy.ts +36 -0
  34. package/.pi/lib/workflows/cloud/terraform/runner.ts +64 -0
  35. package/.pi/lib/{cloud-vault.ts → workflows/cloud/vault.ts} +26 -24
  36. package/.pi/lib/workflows/cloud/workspace.ts +37 -0
  37. package/.pi/lib/workflows/sdd.ts +11 -0
  38. package/.pi/lib/workflows/state.ts +114 -2
  39. package/.pi/lib/working-directory.ts +0 -58
  40. package/.pi/skills/hwcode-cloud/SKILL.md +32 -1
  41. package/.pi/skills/hwcode-sdd/SKILL.md +2 -0
  42. package/README.md +41 -8
  43. package/bin/hwcode.js +2 -6
  44. package/package.json +8 -3
  45. package/.pi/extensions/cloud.ts +0 -629
@@ -4,11 +4,6 @@ export interface CustomSessionEntry {
4
4
  data?: unknown;
5
5
  }
6
6
 
7
- export interface SessionEntrySource {
8
- getEntries(): readonly CustomSessionEntry[];
9
- getSessionId(): string;
10
- }
11
-
12
7
  export interface StateCodec<T> {
13
8
  customType: string;
14
9
  decode(value: unknown): T | undefined;
@@ -30,32 +25,3 @@ export function findLatestState<T>(
30
25
  }
31
26
  return { found: false };
32
27
  }
33
-
34
- export class SessionStateCache<T> {
35
- private readonly values = new Map<string, T | undefined>();
36
- private readonly codec: StateCodec<T>;
37
-
38
- constructor(codec: StateCodec<T>) {
39
- this.codec = codec;
40
- }
41
-
42
- restore(source: SessionEntrySource): T | undefined {
43
- const lookup = findLatestState(source.getEntries(), this.codec);
44
- const value = lookup.value;
45
- this.values.set(source.getSessionId(), value);
46
- return value;
47
- }
48
-
49
- get(source: SessionEntrySource): T | undefined {
50
- if (!this.values.has(source.getSessionId())) return this.restore(source);
51
- return this.values.get(source.getSessionId());
52
- }
53
-
54
- set(source: SessionEntrySource, value: T | undefined): void {
55
- this.values.set(source.getSessionId(), value);
56
- }
57
-
58
- clear(sessionId: string): void {
59
- this.values.delete(sessionId);
60
- }
61
- }
@@ -8,8 +8,9 @@ import {
8
8
  type CloudCredentials,
9
9
  type CloudProvider,
10
10
  type CloudVendorId,
11
- } from "../cloud-providers.ts";
11
+ } from "./providers.ts";
12
12
  import { runProcess, truncateOutput, type ProcessResult } from "./process.ts";
13
+ import { CLOUD_RUNTIME_DEFAULTS } from "../../runtime/defaults.ts";
13
14
 
14
15
  export interface TemporaryCredentialStore {
15
16
  createDirectory(prefix: string): string;
@@ -41,18 +42,6 @@ function validationFailure(result: ProcessResult, credentials: CloudCredentials)
41
42
  return truncateOutput(redactCredentialValues(raw, credentials));
42
43
  }
43
44
 
44
- function huaweiAuthenticationArgs(credentials: CloudCredentials): string[] {
45
- return [
46
- `--cli-access-key=${credentials.accessKey}`,
47
- `--cli-secret-key=${credentials.secretKey}`,
48
- `--cli-region=${credentials.region}`,
49
- ...(credentials.securityToken ? [`--cli-security-token=${credentials.securityToken}`] : []),
50
- ...(credentials.projectId ? [`--cli-project-id=${credentials.projectId}`] : []),
51
- ...(credentials.domainId ? [`--cli-domain-id=${credentials.domainId}`] : []),
52
- "--cli-output=json",
53
- ];
54
- }
55
-
56
45
  function basicAdapter(
57
46
  vendor: CloudVendorId,
58
47
  validationCommand: string,
@@ -64,7 +53,7 @@ function basicAdapter(
64
53
  cwd: root,
65
54
  env: cloudEnvironment(vendor, credentials),
66
55
  signal,
67
- timeoutMs: 30_000,
56
+ timeoutMs: CLOUD_RUNTIME_DEFAULTS.process.providerValidationTimeoutMs,
68
57
  }),
69
58
  prepare: async (_command, args, { credentials }) => ({
70
59
  args,
@@ -75,7 +64,7 @@ function basicAdapter(
75
64
 
76
65
  async function validateAzure(context: AdapterContext): Promise<ProcessResult> {
77
66
  const controller = new AbortController();
78
- const timeout = setTimeout(() => controller.abort(), 30_000);
67
+ const timeout = setTimeout(() => controller.abort(), CLOUD_RUNTIME_DEFAULTS.process.providerValidationTimeoutMs);
79
68
  const abort = () => controller.abort();
80
69
  context.signal?.addEventListener("abort", abort, { once: true });
81
70
  try {
@@ -128,11 +117,11 @@ const adapters: Record<CloudVendorId, CloudProviderAdapter> = {
128
117
  provider: getCloudProvider("huawei"),
129
118
  validate: ({ root, credentials, signal }) => runProcess(
130
119
  "hcloud",
131
- ["IAM", "KeystoneListProjects", ...huaweiAuthenticationArgs(credentials)],
132
- { cwd: root, env: cloudEnvironment("huawei", credentials), signal, timeoutMs: 30_000 },
120
+ ["IAM", "KeystoneListProjects", ...mergeHuaweiArgs([], credentials)],
121
+ { cwd: root, env: cloudEnvironment("huawei", credentials), signal, timeoutMs: CLOUD_RUNTIME_DEFAULTS.process.providerValidationTimeoutMs },
133
122
  ),
134
123
  prepare: async (command, args, { credentials }) => ({
135
- args: command === "hcloud" ? [...args, ...huaweiAuthenticationArgs(credentials)] : args,
124
+ args: command === "hcloud" ? mergeHuaweiArgs(args, credentials) : args,
136
125
  env: cloudEnvironment("huawei", credentials),
137
126
  }),
138
127
  },
@@ -151,7 +140,7 @@ const adapters: Record<CloudVendorId, CloudProviderAdapter> = {
151
140
  CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: path,
152
141
  },
153
142
  signal: context.signal,
154
- timeoutMs: 30_000,
143
+ timeoutMs: CLOUD_RUNTIME_DEFAULTS.process.providerValidationTimeoutMs,
155
144
  });
156
145
  } finally {
157
146
  context.temporaryStore.cleanupDirectory(directory);
@@ -194,7 +183,7 @@ const adapters: Record<CloudVendorId, CloudProviderAdapter> = {
194
183
  const selection = await runProcess(
195
184
  "az",
196
185
  ["account", "set", "--subscription", context.credentials.subscriptionId],
197
- { cwd: context.root, env: azureEnv, signal: context.signal, timeoutMs: 30_000 },
186
+ { cwd: context.root, env: azureEnv, signal: context.signal, timeoutMs: CLOUD_RUNTIME_DEFAULTS.process.providerValidationTimeoutMs },
198
187
  );
199
188
  if (selection.code !== 0) {
200
189
  return { args, env: azureEnv, cleanup, error: validationFailure(selection, context.credentials) };
@@ -232,3 +221,32 @@ export function checkProviderCli(vendor: CloudVendorId, root: string): Promise<P
232
221
  const provider = getCloudProvider(vendor);
233
222
  return runProcess(provider.cli, provider.versionArgs, { cwd: root, timeoutMs: 10_000 });
234
223
  }
224
+
225
+ function mergeHuaweiArgs(args: readonly string[], credentials: CloudCredentials): string[] {
226
+ const result = [...args];
227
+ const hasArg = (prefix: string) => result.some((arg) => arg.startsWith(prefix));
228
+ if (!hasArg("--cli-access-key=")) {
229
+ result.push(`--cli-access-key=${credentials.accessKey}`);
230
+ }
231
+ if (!hasArg("--cli-secret-key=")) {
232
+ result.push(`--cli-secret-key=${credentials.secretKey}`);
233
+ }
234
+ if (!hasArg("--cli-region=")) {
235
+ if (credentials.region?.trim()) {
236
+ result.push(`--cli-region=${credentials.region.trim()}`);
237
+ }
238
+ }
239
+ if (credentials.securityToken && !hasArg("--cli-security-token=")) {
240
+ result.push(`--cli-security-token=${credentials.securityToken}`);
241
+ }
242
+ if (credentials.projectId && !hasArg("--cli-project-id=")) {
243
+ result.push(`--cli-project-id=${credentials.projectId}`);
244
+ }
245
+ if (credentials.domainId && !hasArg("--cli-domain-id=")) {
246
+ result.push(`--cli-domain-id=${credentials.domainId}`);
247
+ }
248
+ if (!hasArg("--cli-output=")) {
249
+ result.push("--cli-output=json");
250
+ }
251
+ return result;
252
+ }
@@ -0,0 +1,358 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import { dirname, extname, isAbsolute, join, relative, sep } from "node:path";
5
+
6
+ import { isCloudVendorId, type CloudVendorId } from "./providers.ts";
7
+ import type { WorkflowState } from "../state.ts";
8
+ import { legacyUserCloudTemplatePaths, userRuntimePaths } from "../../runtime/paths.ts";
9
+ import { CLOUD_RUNTIME_DEFAULTS } from "../../runtime/defaults.ts";
10
+ import { renderCloudPromptTemplate } from "./templates.ts";
11
+
12
+ export interface TerraformTemplateManifest {
13
+ schemaVersion: 2;
14
+ id: string;
15
+ name: string;
16
+ vendor: CloudVendorId;
17
+ createdAt: string;
18
+ updatedAt: string;
19
+ sourceDigest: string;
20
+ contentDigest: string;
21
+ metadataDigest?: string;
22
+ migratedFrom?: 1;
23
+ failedApproaches: Array<{ approach: string; reason: string }>;
24
+ artifacts: {
25
+ terraform: boolean;
26
+ charts: boolean;
27
+ discovery: string[];
28
+ reports: string[];
29
+ verification: boolean;
30
+ excluded: Array<{ path: string; reason: string }>;
31
+ };
32
+ }
33
+
34
+ export interface CloudDeploymentTemplate {
35
+ manifest: TerraformTemplateManifest;
36
+ path: string;
37
+ terraformPath: string;
38
+ chartsPath: string;
39
+ discoveryPath: string;
40
+ summaryPath: string;
41
+ verificationPath: string;
42
+ }
43
+
44
+ export function cloudDeploymentTemplateSource(template: CloudDeploymentTemplate): {
45
+ id: string; name: string; createdAt: string; updatedAt: string; kind: "terraform";
46
+ } {
47
+ return { id: template.manifest.id, name: template.manifest.name, createdAt: template.manifest.createdAt, updatedAt: template.manifest.updatedAt, kind: "terraform" };
48
+ }
49
+
50
+ export function defaultCloudBundleDirectory(home = homedir()): string {
51
+ return userRuntimePaths(home).cloudDeploymentTemplates;
52
+ }
53
+
54
+ function excluded(name: string): boolean {
55
+ return name === ".terraform"
56
+ || name === "terraform.tfstate"
57
+ || name === "terraform.tfstate.backup"
58
+ || name.endsWith(".tfplan")
59
+ || name.endsWith(".tfstate")
60
+ || name.endsWith(".tfvars")
61
+ || name.endsWith(".tfvars.json");
62
+ }
63
+
64
+ function credentialLikeContent(content: string): boolean {
65
+ return /(?:access[_-]?key|secret(?:[_-]?key)?|password|token)\s*["'=:\s]+[A-Za-z0-9+/=_-]{8,}/iu.test(content);
66
+ }
67
+
68
+ interface CopyReport { copied: string[]; excluded: Array<{ path: string; reason: string }> }
69
+
70
+ function copySafeDirectory(source: string, destination: string, include: (path: string, name: string) => boolean, category = "artifact"): CopyReport {
71
+ const copied: string[] = [];
72
+ const skipped: Array<{ path: string; reason: string }> = [];
73
+ const visit = (from: string, to: string) => {
74
+ mkdirSync(to, { recursive: true, mode: 0o700 });
75
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
76
+ if (excluded(entry.name)) {
77
+ skipped.push({ path: `${category}/${relative(source, join(from, entry.name)).split(sep).join("/")}`, reason: "runtime-or-sensitive-Terraform-file" });
78
+ continue;
79
+ }
80
+ const sourcePath = join(from, entry.name);
81
+ const destinationPath = join(to, entry.name);
82
+ if (entry.isDirectory()) visit(sourcePath, destinationPath);
83
+ else if (entry.isFile()) {
84
+ if (!include(sourcePath, entry.name)) {
85
+ skipped.push({ path: `${category}/${relative(source, sourcePath).split(sep).join("/")}`, reason: "unsupported-or-sensitive-content" });
86
+ continue;
87
+ }
88
+ copyFileSync(sourcePath, destinationPath);
89
+ chmodSync(destinationPath, 0o600);
90
+ copied.push(destinationPath);
91
+ }
92
+ }
93
+ };
94
+ if (existsSync(source)) visit(source, destination);
95
+ return { copied, excluded: skipped };
96
+ }
97
+
98
+ function digestDirectory(root: string): string {
99
+ const entries: string[] = [];
100
+ const visit = (directory: string) => {
101
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
102
+ if (excluded(entry.name)) continue;
103
+ const path = join(directory, entry.name);
104
+ if (entry.isDirectory()) visit(path);
105
+ else if (entry.isFile()) entries.push(`${relative(root, path).split(sep).join("/")}\0${createHash("sha256").update(readFileSync(path)).digest("hex")}`);
106
+ }
107
+ };
108
+ visit(root);
109
+ return createHash("sha256").update(entries.join("\n")).digest("hex");
110
+ }
111
+
112
+ function copyTerraformDirectory(source: string, destination: string): CopyReport {
113
+ return copySafeDirectory(source, destination, (path) => !credentialLikeContent(readFileSync(path, "utf8")), "terraform");
114
+ }
115
+
116
+ function copyChartsDirectory(source: string, destination: string): CopyReport {
117
+ return copySafeDirectory(source, destination, (path, name) => (
118
+ name !== "Chart.lock" || !credentialLikeContent(readFileSync(path, "utf8"))
119
+ ) && !name.endsWith(".tgz") && [".yaml", ".yml", ".tpl", ".json", ".md", ".txt"].includes(extname(name).toLowerCase()), "charts");
120
+ }
121
+
122
+ function sanitizeDiscovery(value: unknown, key = ""): unknown {
123
+ if (/(?:secret|password|token|credential|access[_-]?key)/iu.test(key)) return "<redacted>";
124
+ if (/(?:^|_)(?:id|ip|address|endpoint|url)$/iu.test(key) && (typeof value === "string" || typeof value === "number")) return "<runtime-value>";
125
+ if (Array.isArray(value)) return value.map((item) => sanitizeDiscovery(item, key));
126
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([childKey, child]) => [childKey, sanitizeDiscovery(child, childKey)]));
127
+ return value;
128
+ }
129
+
130
+ function copyDiscoveryDirectory(source: string, destination: string): CopyReport {
131
+ const report = copySafeDirectory(source, destination, (path, name) => extname(name).toLowerCase() === ".json" && !credentialLikeContent(readFileSync(path, "utf8")), "discovery");
132
+ for (const path of report.copied) {
133
+ try { writeFileSync(path, `${JSON.stringify(sanitizeDiscovery(JSON.parse(readFileSync(path, "utf8"))), null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); }
134
+ catch {
135
+ rmSync(path, { force: true });
136
+ report.excluded.push({ path: `discovery/${relative(destination, path).split(sep).join("/")}`, reason: "invalid-json" });
137
+ }
138
+ }
139
+ report.copied = report.copied.filter(existsSync);
140
+ return report;
141
+ }
142
+
143
+ function sanitizeReports(report: CopyReport, destination: string): CopyReport {
144
+ for (const path of report.copied) {
145
+ const extension = extname(path).toLowerCase();
146
+ if (extension === ".json") {
147
+ try { writeFileSync(path, `${JSON.stringify(sanitizeDiscovery(JSON.parse(readFileSync(path, "utf8"))), null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); }
148
+ catch {
149
+ rmSync(path, { force: true });
150
+ report.excluded.push({ path: `reports/${relative(destination, path).split(sep).join("/")}`, reason: "invalid-json" });
151
+ }
152
+ continue;
153
+ }
154
+ const sanitized = readFileSync(path, "utf8")
155
+ .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/gu, "<runtime-ip>")
156
+ .replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/giu, "<runtime-id>")
157
+ .replace(/https?:\/\/[^\s)`]+/giu, "<runtime-endpoint>");
158
+ writeFileSync(path, sanitized, { encoding: "utf8", mode: 0o600 });
159
+ }
160
+ report.copied = report.copied.filter(existsSync);
161
+ return report;
162
+ }
163
+
164
+ function writeAtomic(path: string, content: string): void {
165
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
166
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
167
+ writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
168
+ renameSync(temporary, path);
169
+ chmodSync(path, 0o600);
170
+ }
171
+
172
+ function buildManifest(
173
+ state: WorkflowState,
174
+ name: string,
175
+ sourceDigest: string,
176
+ createdAt: string,
177
+ updatedAt: string,
178
+ artifacts: TerraformTemplateManifest["artifacts"],
179
+ contentDigest: string,
180
+ ): TerraformTemplateManifest {
181
+ return {
182
+ schemaVersion: 2, id: `hwcloud-${name.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, CLOUD_RUNTIME_DEFAULTS.templates.slugMaxLength) || randomUUID()}`,
183
+ name, vendor: state.details!.vendor, createdAt, updatedAt, sourceDigest, contentDigest,
184
+ failedApproaches: state.details!.failedApproaches.map(({ approach, reason }) => ({ approach, reason })),
185
+ artifacts,
186
+ };
187
+ }
188
+
189
+ function digestManifestMetadata(manifest: TerraformTemplateManifest): string {
190
+ const { metadataDigest: _metadataDigest, ...metadata } = manifest;
191
+ return createHash("sha256").update(JSON.stringify(metadata)).digest("hex");
192
+ }
193
+
194
+ function sealManifest(manifest: TerraformTemplateManifest): TerraformTemplateManifest {
195
+ return { ...manifest, metadataDigest: digestManifestMetadata(manifest) };
196
+ }
197
+
198
+ function renderVerification(state: WorkflowState): string {
199
+ const steps = state.details!.successfulSteps
200
+ .map((step) => `- [${step.operation}] ${step.intent} (${step.approach})`)
201
+ .join("\n");
202
+ return `# Verification path\n\n${steps || "No successful verification steps were recorded."}\n`;
203
+ }
204
+
205
+ function writeBundleArtifacts(
206
+ destination: string,
207
+ terraformSource: string,
208
+ artifactWorkspace: string | undefined,
209
+ state: WorkflowState,
210
+ ): TerraformTemplateManifest["artifacts"] {
211
+ const terraform = copyTerraformDirectory(terraformSource, join(destination, "terraform"));
212
+ const charts = artifactWorkspace ? copyChartsDirectory(join(artifactWorkspace, "charts"), join(destination, "charts")) : { copied: [], excluded: [] };
213
+ const discovery = artifactWorkspace ? copyDiscoveryDirectory(join(artifactWorkspace, "discovery"), join(destination, "discovery")) : { copied: [], excluded: [] };
214
+ const reportsDestination = join(destination, "reports");
215
+ const reports = artifactWorkspace ? sanitizeReports(copySafeDirectory(join(artifactWorkspace, "reports"), reportsDestination, (path, name) => !credentialLikeContent(readFileSync(path, "utf8")) && [".md", ".json", ".txt"].includes(extname(name).toLowerCase()), "reports"), reportsDestination) : { copied: [], excluded: [] };
216
+ writeAtomic(join(destination, "verification.md"), renderVerification(state));
217
+ return {
218
+ terraform: terraform.copied.length > 0,
219
+ charts: charts.copied.length > 0,
220
+ discovery: discovery.copied.map((path) => relative(join(destination, "discovery"), path).split(sep).join("/")),
221
+ reports: reports.copied.map((path) => relative(join(destination, "reports"), path).split(sep).join("/")),
222
+ verification: true,
223
+ excluded: [...terraform.excluded, ...charts.excluded, ...discovery.excluded, ...reports.excluded],
224
+ };
225
+ }
226
+
227
+ function digestBundleContent(path: string): string {
228
+ const parts: string[] = [];
229
+ for (const name of ["terraform", "charts", "discovery", "reports"]) {
230
+ const directory = join(path, name);
231
+ if (existsSync(directory)) parts.push(`${name}\0${digestDirectory(directory)}`);
232
+ }
233
+ for (const name of ["summary.md", "verification.md"]) {
234
+ const file = join(path, name);
235
+ if (existsSync(file)) parts.push(`${name}\0${createHash("sha256").update(readFileSync(file)).digest("hex")}`);
236
+ }
237
+ return createHash("sha256").update(parts.join("\n")).digest("hex");
238
+ }
239
+
240
+ export function saveCloudDeploymentTemplate(
241
+ requestedName: string,
242
+ terraformSource: string,
243
+ state: WorkflowState,
244
+ directory = defaultCloudBundleDirectory(),
245
+ savedAt = new Date(),
246
+ artifactWorkspace?: string,
247
+ ): CloudDeploymentTemplate {
248
+ if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
249
+ if (!isAbsolute(terraformSource) || !existsSync(terraformSource) || !statSync(terraformSource).isDirectory()) throw new Error("Terraform source directory is required");
250
+ const name = requestedName.replace(/[\r\n]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, CLOUD_RUNTIME_DEFAULTS.templates.nameMaxLength);
251
+ if (!name) throw new Error("Cloud Deployment Template name is required");
252
+ const temporary = `${directory}/.${process.pid}.${randomUUID()}.tmp`;
253
+ const artifacts = writeBundleArtifacts(temporary, terraformSource, artifactWorkspace, state);
254
+ writeAtomic(join(temporary, "summary.md"), `${renderCloudPromptTemplate(state)}\n`);
255
+ const sourceDigest = digestDirectory(join(temporary, "terraform"));
256
+ const manifest = sealManifest(buildManifest(state, name, sourceDigest, savedAt.toISOString(), savedAt.toISOString(), artifacts, digestBundleContent(temporary)));
257
+ const path = join(directory, manifest.id);
258
+ if (existsSync(path)) {
259
+ rmSync(temporary, { recursive: true, force: true });
260
+ throw new Error(`Cloud Deployment Template already exists: ${manifest.id}`);
261
+ }
262
+ try {
263
+ writeAtomic(join(temporary, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
264
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
265
+ renameSync(temporary, path);
266
+ } catch (error) {
267
+ if (existsSync(temporary)) rmSync(temporary, { recursive: true, force: true });
268
+ throw error;
269
+ }
270
+ return {
271
+ manifest,
272
+ path,
273
+ terraformPath: join(path, "terraform"),
274
+ chartsPath: join(path, "charts"),
275
+ discoveryPath: join(path, "discovery"),
276
+ summaryPath: join(path, "summary.md"),
277
+ verificationPath: join(path, "verification.md"),
278
+ };
279
+ }
280
+
281
+ export function updateCloudDeploymentTemplate(
282
+ template: CloudDeploymentTemplate,
283
+ terraformSource: string,
284
+ state: WorkflowState,
285
+ savedAt = new Date(),
286
+ artifactWorkspace?: string,
287
+ ): CloudDeploymentTemplate {
288
+ if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
289
+ if (!isAbsolute(terraformSource) || !existsSync(terraformSource) || !statSync(terraformSource).isDirectory()) throw new Error("Terraform source directory is required");
290
+ const temporary = `${template.path}.${process.pid}.${randomUUID()}.tmp`;
291
+ const backup = `${template.path}.${process.pid}.${randomUUID()}.bak`;
292
+ let manifest: TerraformTemplateManifest;
293
+ try {
294
+ const artifacts = writeBundleArtifacts(temporary, terraformSource, artifactWorkspace, state);
295
+ writeAtomic(join(temporary, "summary.md"), `${renderCloudPromptTemplate(state)}\n`);
296
+ manifest = sealManifest({
297
+ ...buildManifest(state, template.manifest.name, digestDirectory(join(temporary, "terraform")), template.manifest.createdAt, savedAt.toISOString(), artifacts, digestBundleContent(temporary)),
298
+ id: template.manifest.id,
299
+ });
300
+ writeAtomic(join(temporary, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
301
+ renameSync(template.path, backup);
302
+ renameSync(temporary, template.path);
303
+ rmSync(backup, { recursive: true, force: true });
304
+ } catch (error) {
305
+ if (existsSync(temporary)) rmSync(temporary, { recursive: true, force: true });
306
+ if (existsSync(backup) && !existsSync(template.path)) renameSync(backup, template.path);
307
+ throw error;
308
+ }
309
+ return { manifest: manifest!, path: template.path, terraformPath: join(template.path, "terraform"), chartsPath: join(template.path, "charts"), discoveryPath: join(template.path, "discovery"), summaryPath: join(template.path, "summary.md"), verificationPath: join(template.path, "verification.md") };
310
+ }
311
+
312
+ export function parseCloudDeploymentTemplate(path: string): CloudDeploymentTemplate | undefined {
313
+ const manifestPath = join(path, "manifest.json");
314
+ if (!existsSync(manifestPath)) return undefined;
315
+ const raw = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
316
+ if (![1, 2].includes(raw.schemaVersion as number) || typeof raw.id !== "string" || typeof raw.name !== "string"
317
+ || typeof raw.vendor !== "string" || !isCloudVendorId(raw.vendor)
318
+ || typeof raw.createdAt !== "string" || typeof raw.updatedAt !== "string"
319
+ || typeof raw.sourceDigest !== "string" || !existsSync(join(path, "terraform"))) return undefined;
320
+ const oldArtifacts = (raw.artifacts && typeof raw.artifacts === "object" ? raw.artifacts : {}) as TerraformTemplateManifest["artifacts"];
321
+ let manifest = raw.schemaVersion === 2 ? raw as unknown as TerraformTemplateManifest : ({
322
+ ...raw,
323
+ schemaVersion: 2,
324
+ migratedFrom: 1,
325
+ contentDigest: digestBundleContent(path),
326
+ artifacts: { ...oldArtifacts, reports: [], excluded: [] },
327
+ } as unknown as TerraformTemplateManifest);
328
+ if (typeof manifest.contentDigest !== "string") return undefined;
329
+ if (manifest.metadataDigest && manifest.metadataDigest !== digestManifestMetadata(manifest)) return undefined;
330
+ if (!manifest.metadataDigest) manifest = sealManifest(manifest);
331
+ return { manifest, path, terraformPath: join(path, "terraform"), chartsPath: join(path, "charts"), discoveryPath: join(path, "discovery"), summaryPath: join(path, "summary.md"), verificationPath: join(path, "verification.md") };
332
+ }
333
+
334
+ export function listCloudDeploymentTemplates(directory?: string, home = homedir()): CloudDeploymentTemplate[] {
335
+ const directories = directory
336
+ ? [directory]
337
+ : [...new Set([defaultCloudBundleDirectory(home), legacyUserCloudTemplatePaths(home).deployments])];
338
+ return directories
339
+ .filter(existsSync)
340
+ .flatMap((path) => readdirSync(path, { withFileTypes: true })
341
+ .filter((entry) => entry.isDirectory())
342
+ .map((entry) => parseCloudDeploymentTemplate(join(path, entry.name))))
343
+ .filter((template): template is CloudDeploymentTemplate => Boolean(template))
344
+ .sort((left, right) => right.manifest.updatedAt.localeCompare(left.manifest.updatedAt) || left.manifest.name.localeCompare(right.manifest.name));
345
+ }
346
+
347
+ export function materializeCloudDeploymentTemplate(template: CloudDeploymentTemplate, workspace: string): { terraformPath: string } {
348
+ if (!isAbsolute(workspace)) throw new Error("Cloud artifact workspace must be absolute");
349
+ const actualDigest = digestBundleContent(template.path);
350
+ if (actualDigest !== template.manifest.contentDigest) throw new Error("Cloud Deployment Template content digest mismatch; reuse is blocked");
351
+ if (template.manifest.metadataDigest !== digestManifestMetadata(template.manifest)) throw new Error("Cloud Deployment Template metadata digest mismatch; reuse is blocked");
352
+ for (const name of ["terraform", "charts", "discovery", "reports"]) {
353
+ const source = join(template.path, name);
354
+ if (!existsSync(source)) continue;
355
+ copySafeDirectory(source, join(workspace, name), () => true, name);
356
+ }
357
+ return { terraformPath: join(workspace, "terraform") };
358
+ }
@@ -0,0 +1,28 @@
1
+ import type { CloudWorkflowDetails, WorkflowState } from "../state.ts";
2
+
3
+ export function cloudExecutionBlockReason(state: WorkflowState | undefined, options: { allowAfterTerminalFailure?: boolean } = {}): string | undefined {
4
+ if (!state || state.mode !== "cloud" || !state.details) return "HWCode Cloud is not active.";
5
+ if (state.status !== "active") return `HWCode Cloud is ${state.status}.`;
6
+ if (state.details.terminalFailure && !options.allowAfterTerminalFailure) return "Cloud workflow reached its failure budget; only read-only inspection, cleanup deletion, or workflow completion is allowed.";
7
+ return undefined;
8
+ }
9
+
10
+ export function recordStrategyFailure(
11
+ details: CloudWorkflowDetails,
12
+ strategy: string,
13
+ stage: string,
14
+ reason: string,
15
+ maxStrategies: number,
16
+ ): CloudWorkflowDetails {
17
+ const normalized = strategy.trim().toLowerCase().replace(/\s+/gu, " ").slice(0, 160);
18
+ if (!normalized) return details;
19
+ const index = details.failedApproaches.findIndex((item) => item.approach.trim().toLowerCase().replace(/\s+/gu, " ") === normalized);
20
+ const failedApproaches = [...details.failedApproaches];
21
+ if (index >= 0) {
22
+ const current = failedApproaches[index]!;
23
+ failedApproaches[index] = { ...current, reason: reason.slice(0, 1_000), stage, attempts: (current.attempts ?? 1) + 1, failedAt: new Date().toISOString() };
24
+ } else {
25
+ failedApproaches.push({ approach: strategy.trim().slice(0, 160), reason: reason.slice(0, 1_000), stage, attempts: 1, failedAt: new Date().toISOString() });
26
+ }
27
+ return { ...details, failedApproaches, terminalFailure: failedApproaches.length >= maxStrategies };
28
+ }
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
- export const MAX_CLOUD_OUTPUT_BYTES = 50_000;
3
+ import { CLOUD_RUNTIME_DEFAULTS } from "../../runtime/defaults.ts";
4
+
5
+ export const MAX_CLOUD_OUTPUT_BYTES = CLOUD_RUNTIME_DEFAULTS.process.maxOutputBytes;
4
6
  const MAX_CAPTURE_BYTES = MAX_CLOUD_OUTPUT_BYTES * 2;
5
7
  const CLOUD_ENV_PREFIXES = [
6
8
  "AWS_", "AZURE_", "ARM_", "GOOGLE_", "CLOUDSDK_", "HW_", "HUAWEI",
@@ -64,10 +66,10 @@ export function runProcess(
64
66
  killed = true;
65
67
  timedOut ||= timeout;
66
68
  child.kill("SIGTERM");
67
- forceKillTimer ??= setTimeout(() => child.kill("SIGKILL"), 2_000);
69
+ forceKillTimer ??= setTimeout(() => child.kill("SIGKILL"), CLOUD_RUNTIME_DEFAULTS.process.forceKillGraceMs);
68
70
  };
69
71
  const abort = () => terminate(false);
70
- const timeoutTimer = setTimeout(() => terminate(true), options.timeoutMs ?? 120_000);
72
+ const timeoutTimer = setTimeout(() => terminate(true), options.timeoutMs ?? CLOUD_RUNTIME_DEFAULTS.process.commandTimeoutMs);
71
73
  if (options.signal?.aborted) abort();
72
74
  else options.signal?.addEventListener("abort", abort, { once: true });
73
75
  child.stdout.on("data", (chunk: Buffer) => { stdout = captureOutput(stdout, chunk); });
@@ -30,7 +30,7 @@ export const CLOUD_PROVIDERS: readonly CloudProvider[] = [
30
30
  { key: "accessKeyId", label: "Access Key ID" },
31
31
  { key: "secretAccessKey", label: "Secret Access Key", secret: true },
32
32
  { key: "sessionToken", label: "Session Token(临时凭据可选)", secret: true, optional: true },
33
- { key: "region", label: "默认 Region", placeholder: "us-east-1" },
33
+ { key: "region", label: "默认 Region", placeholder: "请输入目标 Region(必填)" },
34
34
  ],
35
35
  },
36
36
  {
@@ -64,7 +64,7 @@ export const CLOUD_PROVIDERS: readonly CloudProvider[] = [
64
64
  { key: "accessKey", label: "Access Key (AK)" },
65
65
  { key: "secretKey", label: "Secret Access Key (SK)", secret: true },
66
66
  { key: "securityToken", label: "Security Token(临时凭据可选)", secret: true, optional: true },
67
- { key: "region", label: "Region", placeholder: "cn-north-4" },
67
+ { key: "region", label: "Region", placeholder: "请输入目标 Region(必填)" },
68
68
  { key: "projectId", label: "Project ID(可选)", optional: true },
69
69
  { key: "domainId", label: "Domain ID(全局服务可选)", optional: true },
70
70
  ],
@@ -78,7 +78,7 @@ export const CLOUD_PROVIDERS: readonly CloudProvider[] = [
78
78
  { key: "accessKeyId", label: "AccessKey ID" },
79
79
  { key: "accessKeySecret", label: "AccessKey Secret", secret: true },
80
80
  { key: "securityToken", label: "STS Security Token(可选)", secret: true, optional: true },
81
- { key: "region", label: "默认 Region", placeholder: "cn-hangzhou" },
81
+ { key: "region", label: "默认 Region", placeholder: "请输入目标 Region(必填)" },
82
82
  ],
83
83
  },
84
84
  {
@@ -90,7 +90,7 @@ export const CLOUD_PROVIDERS: readonly CloudProvider[] = [
90
90
  { key: "secretId", label: "SecretId" },
91
91
  { key: "secretKey", label: "SecretKey", secret: true },
92
92
  { key: "token", label: "临时凭据 Token(可选)", secret: true, optional: true },
93
- { key: "region", label: "默认 Region", placeholder: "ap-guangzhou" },
93
+ { key: "region", label: "默认 Region", placeholder: "请输入目标 Region(必填)" },
94
94
  ],
95
95
  },
96
96
  ] as const;
@@ -0,0 +1,23 @@
1
+ import type { RemoteTargetProfile } from "./profiles.ts";
2
+ import { runSsh } from "./ssh-transport.ts";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { parseSshKeyscan } from "./host-key.ts";
5
+
6
+ export interface RunnerCapabilityReport {
7
+ ready: boolean;
8
+ terraformVersion?: string;
9
+ missing: string[];
10
+ }
11
+
12
+ export async function prepareRemoteRunner(profile: RemoteTargetProfile, signal?: AbortSignal): Promise<RunnerCapabilityReport> {
13
+ if (!existsSync(profile.knownHostsPath) || !parseSshKeyscan(readFileSync(profile.knownHostsPath, "utf8")).some((key) => key.fingerprint === profile.hostKeyFingerprint)) {
14
+ throw new Error("Runner host fingerprint is not present in the pinned known_hosts file; reconnect and verify the host key");
15
+ }
16
+ const created = await runSsh(profile, "mkdir", ["-p", profile.remoteRoot, `${profile.remoteRoot}/runs`], { signal, remoteCwd: "" });
17
+ if (created.code !== 0) throw new Error(created.stderr.trim() || "cannot create the managed Runner directory");
18
+ const tar = await runSsh(profile, "tar", ["--version"], { signal, remoteCwd: "" });
19
+ const sha256 = await runSsh(profile, "sha256sum", ["--version"], { signal, remoteCwd: "" });
20
+ const terraform = await runSsh(profile, profile.terraformPath || "terraform", ["version"], { signal, remoteCwd: profile.remoteRoot });
21
+ const missing = [tar.code === 0 ? undefined : "tar", sha256.code === 0 ? undefined : "sha256sum", terraform.code === 0 ? undefined : "terraform", profile.identityType === "ssh-only" ? "cloud workload identity" : undefined].filter((item): item is string => Boolean(item));
22
+ return { ready: missing.length === 0, terraformVersion: terraform.code === 0 ? terraform.stdout.split(/\r?\n/u)[0]?.trim() : undefined, missing };
23
+ }