@hadooppei/hwcode 0.2.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.pi/extensions/hwcode.ts +35 -2
  2. package/.pi/extensions/model-providers.ts +1 -2
  3. package/.pi/extensions/workflows/cloud/activation.ts +235 -0
  4. package/.pi/extensions/workflows/cloud/commands.ts +96 -0
  5. package/.pi/extensions/workflows/cloud/events.ts +58 -0
  6. package/.pi/extensions/workflows/cloud/index.ts +17 -0
  7. package/.pi/extensions/workflows/cloud/provider-tools.ts +127 -0
  8. package/.pi/extensions/workflows/cloud/runner-tools.ts +129 -0
  9. package/.pi/extensions/workflows/cloud/runtime.ts +97 -0
  10. package/.pi/extensions/workflows/cloud/shared.ts +213 -0
  11. package/.pi/extensions/workflows/cloud/terraform-tools.ts +126 -0
  12. package/.pi/extensions/workflows/vibe-sdd.ts +300 -0
  13. package/.pi/extensions/workflows.ts +6 -253
  14. package/.pi/lib/runtime/config.ts +3 -7
  15. package/.pi/lib/runtime/defaults.ts +20 -0
  16. package/.pi/lib/runtime/paths.ts +68 -0
  17. package/.pi/lib/runtime/session-state.ts +0 -34
  18. package/.pi/lib/{cloud → workflows/cloud}/adapters.ts +7 -6
  19. package/.pi/lib/workflows/cloud/bundles.ts +358 -0
  20. package/.pi/lib/workflows/cloud/execution.ts +28 -0
  21. package/.pi/lib/{cloud → workflows/cloud}/process.ts +5 -3
  22. package/.pi/lib/workflows/cloud/remote/bootstrap.ts +23 -0
  23. package/.pi/lib/workflows/cloud/remote/connect.ts +83 -0
  24. package/.pi/lib/workflows/cloud/remote/host-key.ts +35 -0
  25. package/.pi/lib/workflows/cloud/remote/profiles.ts +100 -0
  26. package/.pi/lib/workflows/cloud/remote/ssh-transport.ts +104 -0
  27. package/.pi/lib/workflows/cloud/remote/workspace.ts +109 -0
  28. package/.pi/lib/{cloud → workflows/cloud}/template-save.ts +3 -2
  29. package/.pi/lib/{cloud → workflows/cloud}/templates.ts +18 -10
  30. package/.pi/lib/workflows/cloud/terraform/plan.ts +109 -0
  31. package/.pi/lib/workflows/cloud/terraform/policy.ts +36 -0
  32. package/.pi/lib/workflows/cloud/terraform/runner.ts +64 -0
  33. package/.pi/lib/{cloud-vault.ts → workflows/cloud/vault.ts} +26 -24
  34. package/.pi/lib/workflows/cloud/workspace.ts +37 -0
  35. package/.pi/lib/workflows/sdd.ts +11 -0
  36. package/.pi/lib/workflows/state.ts +114 -2
  37. package/.pi/lib/working-directory.ts +0 -58
  38. package/.pi/skills/hwcode-cloud/SKILL.md +32 -1
  39. package/.pi/skills/hwcode-sdd/SKILL.md +2 -0
  40. package/README.md +41 -8
  41. package/bin/hwcode.js +2 -6
  42. package/package.json +8 -3
  43. package/.pi/extensions/cloud.ts +0 -629
  44. /package/.pi/lib/{cloud-providers.ts → workflows/cloud/providers.ts} +0 -0
@@ -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;
@@ -64,7 +65,7 @@ function basicAdapter(
64
65
  cwd: root,
65
66
  env: cloudEnvironment(vendor, credentials),
66
67
  signal,
67
- timeoutMs: 30_000,
68
+ timeoutMs: CLOUD_RUNTIME_DEFAULTS.process.providerValidationTimeoutMs,
68
69
  }),
69
70
  prepare: async (_command, args, { credentials }) => ({
70
71
  args,
@@ -75,7 +76,7 @@ function basicAdapter(
75
76
 
76
77
  async function validateAzure(context: AdapterContext): Promise<ProcessResult> {
77
78
  const controller = new AbortController();
78
- const timeout = setTimeout(() => controller.abort(), 30_000);
79
+ const timeout = setTimeout(() => controller.abort(), CLOUD_RUNTIME_DEFAULTS.process.providerValidationTimeoutMs);
79
80
  const abort = () => controller.abort();
80
81
  context.signal?.addEventListener("abort", abort, { once: true });
81
82
  try {
@@ -129,7 +130,7 @@ const adapters: Record<CloudVendorId, CloudProviderAdapter> = {
129
130
  validate: ({ root, credentials, signal }) => runProcess(
130
131
  "hcloud",
131
132
  ["IAM", "KeystoneListProjects", ...huaweiAuthenticationArgs(credentials)],
132
- { cwd: root, env: cloudEnvironment("huawei", credentials), signal, timeoutMs: 30_000 },
133
+ { cwd: root, env: cloudEnvironment("huawei", credentials), signal, timeoutMs: CLOUD_RUNTIME_DEFAULTS.process.providerValidationTimeoutMs },
133
134
  ),
134
135
  prepare: async (command, args, { credentials }) => ({
135
136
  args: command === "hcloud" ? [...args, ...huaweiAuthenticationArgs(credentials)] : args,
@@ -151,7 +152,7 @@ const adapters: Record<CloudVendorId, CloudProviderAdapter> = {
151
152
  CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: path,
152
153
  },
153
154
  signal: context.signal,
154
- timeoutMs: 30_000,
155
+ timeoutMs: CLOUD_RUNTIME_DEFAULTS.process.providerValidationTimeoutMs,
155
156
  });
156
157
  } finally {
157
158
  context.temporaryStore.cleanupDirectory(directory);
@@ -194,7 +195,7 @@ const adapters: Record<CloudVendorId, CloudProviderAdapter> = {
194
195
  const selection = await runProcess(
195
196
  "az",
196
197
  ["account", "set", "--subscription", context.credentials.subscriptionId],
197
- { cwd: context.root, env: azureEnv, signal: context.signal, timeoutMs: 30_000 },
198
+ { cwd: context.root, env: azureEnv, signal: context.signal, timeoutMs: CLOUD_RUNTIME_DEFAULTS.process.providerValidationTimeoutMs },
198
199
  );
199
200
  if (selection.code !== 0) {
200
201
  return { args, env: azureEnv, cleanup, error: validationFailure(selection, context.credentials) };
@@ -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); });
@@ -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
+ }
@@ -0,0 +1,83 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname } from "node:path";
4
+
5
+ import type { CloudVendorId } from "../providers.ts";
6
+ import { runProcess, truncateOutput } from "../process.ts";
7
+ import { CLOUD_RUNTIME_DEFAULTS } from "../../../runtime/defaults.ts";
8
+ import { mergeKnownHosts, parseSshKeyscan, type SshHostKey } from "./host-key.ts";
9
+ import { validateRemoteTargetProfile, type RemoteIdentityType, type RemoteTargetProfile } from "./profiles.ts";
10
+ import { runSsh } from "./ssh-transport.ts";
11
+
12
+ export interface RemoteConnectionRequest {
13
+ id: string;
14
+ name: string;
15
+ vendor: CloudVendorId;
16
+ region: string;
17
+ host: string;
18
+ port: number;
19
+ user: string;
20
+ keyPath: string;
21
+ knownHostsPath: string;
22
+ remoteRoot: string;
23
+ identityType: RemoteIdentityType;
24
+ proxyJump?: { host: string; port: number; user: string };
25
+ }
26
+
27
+ export interface RemoteTrustInteraction {
28
+ confirm(host: string, port: number, keys: readonly SshHostKey[], role: "target" | "jump"): Promise<boolean>;
29
+ }
30
+
31
+ function storeKeys(path: string, keys: readonly SshHostKey[]): void {
32
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
33
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
34
+ writeFileSync(path, mergeKnownHosts(existing, keys), { encoding: "utf8", mode: 0o600 });
35
+ }
36
+
37
+ async function scanDirect(host: string, port: number, root: string, signal?: AbortSignal): Promise<SshHostKey[]> {
38
+ const result = await runProcess("ssh-keyscan", ["-p", String(port), host], { cwd: root, signal, timeoutMs: CLOUD_RUNTIME_DEFAULTS.runner.hostKeyScanTimeoutMs });
39
+ if (result.code !== 0 || !result.stdout.trim()) throw new Error(`Unable to read SSH host keys for ${host}: ${truncateOutput(result.stderr || result.stdout)}`);
40
+ const keys = parseSshKeyscan(result.stdout);
41
+ if (keys.length === 0) throw new Error(`ssh-keyscan returned no valid OpenSSH host keys for ${host}`);
42
+ return keys;
43
+ }
44
+
45
+ export async function connectRemoteTarget(
46
+ request: RemoteConnectionRequest,
47
+ root: string,
48
+ interaction: RemoteTrustInteraction,
49
+ signal?: AbortSignal,
50
+ ): Promise<RemoteTargetProfile> {
51
+ if (!existsSync(request.keyPath)) throw new Error(`SSH private key does not exist: ${request.keyPath}`);
52
+ let targetKeys: SshHostKey[];
53
+ let jumpKeysToStore: SshHostKey[] = [];
54
+ if (request.proxyJump) {
55
+ const jumpKeys = await scanDirect(request.proxyJump.host, request.proxyJump.port, root, signal);
56
+ if (!await interaction.confirm(request.proxyJump.host, request.proxyJump.port, jumpKeys, "jump")) throw new Error("User declined the ProxyJump SSH host key");
57
+ jumpKeysToStore = jumpKeys;
58
+ const temporaryKnownHosts = `${request.knownHostsPath}.${process.pid}.${randomUUID()}.tmp`;
59
+ mkdirSync(dirname(temporaryKnownHosts), { recursive: true, mode: 0o700 });
60
+ const existing = existsSync(request.knownHostsPath) ? readFileSync(request.knownHostsPath, "utf8") : "";
61
+ writeFileSync(temporaryKnownHosts, mergeKnownHosts(existing, jumpKeys), { encoding: "utf8", mode: 0o600 });
62
+ const jumpProfile = validateRemoteTargetProfile({
63
+ ...request,
64
+ id: `${request.id}-jump`, name: `${request.name}-jump`, host: request.proxyJump.host,
65
+ port: request.proxyJump.port, user: request.proxyJump.user, hostKeyFingerprint: jumpKeys[0]!.fingerprint,
66
+ identityType: "ssh-only", proxyJump: undefined, knownHostsPath: temporaryKnownHosts,
67
+ });
68
+ try {
69
+ const scanned = await runSsh(jumpProfile, "ssh-keyscan", ["-p", String(request.port), request.host], { signal, remoteCwd: "", timeoutMs: CLOUD_RUNTIME_DEFAULTS.runner.hostKeyScanTimeoutMs });
70
+ if (scanned.code !== 0 || !scanned.stdout.trim()) throw new Error(`Unable to scan the private target through ProxyJump: ${truncateOutput(scanned.stderr || scanned.stdout)}`);
71
+ targetKeys = parseSshKeyscan(scanned.stdout);
72
+ if (targetKeys.length === 0) throw new Error("ProxyJump returned no valid target host keys");
73
+ } finally {
74
+ rmSync(temporaryKnownHosts, { force: true });
75
+ }
76
+ } else {
77
+ targetKeys = await scanDirect(request.host, request.port, root, signal);
78
+ }
79
+ if (!await interaction.confirm(request.host, request.port, targetKeys, "target")) throw new Error("User declined the target SSH host key");
80
+ if (jumpKeysToStore.length > 0) storeKeys(request.knownHostsPath, jumpKeysToStore);
81
+ storeKeys(request.knownHostsPath, targetKeys);
82
+ return validateRemoteTargetProfile({ ...request, hostKeyFingerprint: targetKeys[0]!.fingerprint });
83
+ }
@@ -0,0 +1,35 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export interface SshHostKey {
4
+ line: string;
5
+ algorithm: string;
6
+ fingerprint: string;
7
+ }
8
+
9
+ export function parseSshKeyscan(output: string): SshHostKey[] {
10
+ const keys: SshHostKey[] = [];
11
+ const seen = new Set<string>();
12
+ for (const rawLine of output.split(/\r?\n/u)) {
13
+ const line = rawLine.trim();
14
+ if (!line || line.startsWith("#")) continue;
15
+ const fields = line.split(/\s+/u);
16
+ if (fields.length < 3 || !/^ssh-|^ecdsa-/u.test(fields[1]!)) continue;
17
+ let decoded: Buffer;
18
+ try { decoded = Buffer.from(fields[2]!, "base64"); } catch { continue; }
19
+ if (decoded.length === 0 || decoded.toString("base64").replace(/=+$/u, "") !== fields[2]!.replace(/=+$/u, "")) continue;
20
+ const fingerprint = `SHA256:${createHash("sha256").update(decoded).digest("base64").replace(/=+$/u, "")}`;
21
+ if (seen.has(fingerprint)) continue;
22
+ seen.add(fingerprint);
23
+ keys.push({ line, algorithm: fields[1]!, fingerprint });
24
+ }
25
+ return keys.sort((left, right) => {
26
+ const rank = (algorithm: string) => algorithm === "ssh-ed25519" ? 0 : algorithm.startsWith("ecdsa-") ? 1 : 2;
27
+ return rank(left.algorithm) - rank(right.algorithm);
28
+ });
29
+ }
30
+
31
+ export function mergeKnownHosts(existing: string, scanned: readonly SshHostKey[]): string {
32
+ const lines = new Set(existing.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean));
33
+ for (const key of scanned) lines.add(key.line);
34
+ return `${[...lines].join("\n")}\n`;
35
+ }