@hadooppei/hwcode 0.2.1 → 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 (47) 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/workflow-guard.ts +8 -1
  19. package/.pi/lib/{cloud → workflows/cloud}/adapters.ts +7 -6
  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/workflows/cloud/remote/bootstrap.ts +23 -0
  24. package/.pi/lib/workflows/cloud/remote/connect.ts +83 -0
  25. package/.pi/lib/workflows/cloud/remote/host-key.ts +35 -0
  26. package/.pi/lib/workflows/cloud/remote/profiles.ts +100 -0
  27. package/.pi/lib/workflows/cloud/remote/ssh-transport.ts +104 -0
  28. package/.pi/lib/workflows/cloud/remote/workspace.ts +109 -0
  29. package/.pi/lib/workflows/cloud/template-save.ts +108 -0
  30. package/.pi/lib/workflows/cloud/templates.ts +256 -0
  31. package/.pi/lib/workflows/cloud/terraform/plan.ts +109 -0
  32. package/.pi/lib/workflows/cloud/terraform/policy.ts +36 -0
  33. package/.pi/lib/workflows/cloud/terraform/runner.ts +64 -0
  34. package/.pi/lib/{cloud-vault.ts → workflows/cloud/vault.ts} +26 -24
  35. package/.pi/lib/workflows/cloud/workspace.ts +37 -0
  36. package/.pi/lib/workflows/sdd.ts +11 -0
  37. package/.pi/lib/workflows/state.ts +165 -1
  38. package/.pi/lib/working-directory.ts +0 -58
  39. package/.pi/lib/workspace/access-policy.ts +2 -2
  40. package/.pi/skills/hwcode-cloud/SKILL.md +32 -1
  41. package/.pi/skills/hwcode-sdd/SKILL.md +2 -0
  42. package/README.md +52 -12
  43. package/bin/hwcode.js +2 -6
  44. package/package.json +8 -3
  45. package/.pi/extensions/cloud.ts +0 -587
  46. package/.pi/lib/cloud/templates.ts +0 -148
  47. /package/.pi/lib/{cloud-providers.ts → workflows/cloud/providers.ts} +0 -0
@@ -0,0 +1,256 @@
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+
5
+ import { getCloudProvider, isCloudVendorId, type CloudVendorId } from "./providers.ts";
6
+ import type { CloudTemplateSource, WorkflowState } from "../state.ts";
7
+ import { legacyUserCloudTemplatePaths, userRuntimePaths } from "../../runtime/paths.ts";
8
+ import { CLOUD_RUNTIME_DEFAULTS } from "../../runtime/defaults.ts";
9
+
10
+ export interface CloudPromptTemplate {
11
+ id: string;
12
+ name: string;
13
+ description: string;
14
+ createdAt: string;
15
+ updatedAt: string;
16
+ vendor: CloudVendorId;
17
+ deployCurrentProject: boolean;
18
+ objective: string;
19
+ notes: string;
20
+ body: string;
21
+ path: string;
22
+ }
23
+
24
+ export function cloudPromptTemplateSource(template: CloudPromptTemplate): CloudTemplateSource {
25
+ return {
26
+ id: template.id,
27
+ name: template.name,
28
+ createdAt: template.createdAt,
29
+ updatedAt: template.updatedAt,
30
+ kind: "prompt",
31
+ };
32
+ }
33
+
34
+ export function defaultCloudPromptDirectory(home = homedir()): string {
35
+ return userRuntimePaths(home).cloudPromptTemplates;
36
+ }
37
+
38
+ function slugify(value: string): string {
39
+ const slug = value
40
+ .normalize("NFKD")
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9]+/gu, "-")
43
+ .replace(/^-+|-+$/gu, "")
44
+ .slice(0, CLOUD_RUNTIME_DEFAULTS.templates.slugMaxLength);
45
+ return slug || `task-${new Date().toISOString().slice(0, 10)}`;
46
+ }
47
+
48
+ function frontmatterValue(text: string, key: string): string | undefined {
49
+ const line = text.match(new RegExp(`^${key}:\\s*(.+)$`, "mu"))?.[1]?.trim();
50
+ if (!line) return undefined;
51
+ try {
52
+ const value = JSON.parse(line) as unknown;
53
+ return typeof value === "string" ? value : String(value);
54
+ } catch {
55
+ return line.replace(/^['"]|['"]$/gu, "");
56
+ }
57
+ }
58
+
59
+ function normalizeTimestamp(value: string | undefined, fallback: string): string {
60
+ if (!value) return fallback;
61
+ const date = new Date(value);
62
+ return Number.isNaN(date.valueOf()) ? fallback : date.toISOString();
63
+ }
64
+
65
+ function legacyTemplateName(id: string): string {
66
+ const words = id.replace(/^hwcloud-/u, "").split("-").filter(Boolean);
67
+ if (words.length === 0) return "Legacy Cloud Template";
68
+ return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
69
+ }
70
+
71
+ function cleanTemplateName(value: string): string {
72
+ return value.replace(/[\r\n]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, CLOUD_RUNTIME_DEFAULTS.templates.nameMaxLength);
73
+ }
74
+
75
+ export function parseCloudPromptTemplate(path: string): CloudPromptTemplate | undefined {
76
+ const text = readFileSync(path, "utf8");
77
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/u);
78
+ if (!match) return undefined;
79
+ const metadata = match[1];
80
+ const vendorValue = frontmatterValue(metadata, "hwcode-cloud-vendor");
81
+ if (!vendorValue || !isCloudVendorId(vendorValue)) return undefined;
82
+ const id = basename(path, ".md");
83
+ const stats = statSync(path);
84
+ const modifiedAt = stats.mtime.toISOString();
85
+ const bornAt = stats.birthtimeMs > 0 ? stats.birthtime.toISOString() : modifiedAt;
86
+ const name = cleanTemplateName(frontmatterValue(metadata, "hwcode-cloud-name") ?? "") || legacyTemplateName(id);
87
+ return {
88
+ id,
89
+ name,
90
+ description: frontmatterValue(metadata, "description") ?? name,
91
+ createdAt: normalizeTimestamp(frontmatterValue(metadata, "hwcode-cloud-created-at"), bornAt),
92
+ updatedAt: normalizeTimestamp(frontmatterValue(metadata, "hwcode-cloud-updated-at"), modifiedAt),
93
+ vendor: vendorValue,
94
+ deployCurrentProject: frontmatterValue(metadata, "hwcode-cloud-deploy") === "true",
95
+ objective: frontmatterValue(metadata, "hwcode-cloud-objective") ?? "",
96
+ notes: frontmatterValue(metadata, "hwcode-cloud-notes") ?? "",
97
+ body: match[2].trim(),
98
+ path,
99
+ };
100
+ }
101
+
102
+ export function listCloudPromptTemplates(
103
+ directory?: string,
104
+ home = homedir(),
105
+ ): CloudPromptTemplate[] {
106
+ const directories = directory
107
+ ? [directory]
108
+ : [...new Set([defaultCloudPromptDirectory(home), legacyUserCloudTemplatePaths(home).prompts])];
109
+ return directories
110
+ .filter(existsSync)
111
+ .flatMap((path) => readdirSync(path, { withFileTypes: true })
112
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
113
+ .map((entry) => parseCloudPromptTemplate(join(path, entry.name))))
114
+ .filter((template): template is CloudPromptTemplate => Boolean(template))
115
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.name.localeCompare(right.name));
116
+ }
117
+
118
+ export function expandCloudPromptTemplate(template: CloudPromptTemplate, argumentsText: string): string {
119
+ const value = argumentsText.trim();
120
+ return template.body.replace(/\$\{@:-([^}]*)\}/gu, (_match, fallback: string) => value || fallback);
121
+ }
122
+
123
+ function inlineCode(value: string): string {
124
+ return value.replace(/[\r\n]+/gu, " ").replaceAll("`", "\\`");
125
+ }
126
+
127
+ function singleLine(value: string): string {
128
+ return value.replace(/[\r\n]+/gu, " ").replace(/\s+/gu, " ").trim();
129
+ }
130
+
131
+ export function renderCloudPromptTemplate(state: WorkflowState, notes = ""): string {
132
+ if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
133
+ const details = state.details;
134
+ if (details.successfulSteps.length === 0) {
135
+ throw new Error("No successful Cloud execution steps are available to summarize");
136
+ }
137
+ const provider = getCloudProvider(details.vendor);
138
+ const steps = details.successfulSteps.map((step, index) => (
139
+ `${index + 1}. [${step.operation}] ${singleLine(step.intent)}\n`
140
+ + ` - Validated approach: ${singleLine(step.approach)}\n`
141
+ + ` - Known-good command and path: \`${inlineCode([step.command, ...step.args].join(" "))}\``
142
+ )).join("\n");
143
+ const failedPaths = details.failedApproaches.length > 0
144
+ ? details.failedApproaches.map((failure, index) => (
145
+ `${index + 1}. **DO NOT RETRY:** ${singleLine(failure.approach)}\n`
146
+ + ` - Recorded failure: ${singleLine(failure.reason)}`
147
+ )).join("\n")
148
+ : "No failed execution paths were recorded for this run.";
149
+ return [
150
+ "Execute this HWCode Cloud task from a layered record of a previously successful run.",
151
+ "",
152
+ "## Layer 1 — Stable intent and boundaries",
153
+ "",
154
+ `Preferred provider: ${provider.label}`,
155
+ `Deploy current project: ${details.deployCurrentProject ? "yes" : "no"}`,
156
+ `Objective pattern: ${details.request}`,
157
+ "",
158
+ "Preserve the objective, provider boundary, credential isolation, resource approvals, and deletion confirmations. Adapt only environment-specific values.",
159
+ "",
160
+ "## Layer 2 — Preconditions and operator knowledge",
161
+ "",
162
+ "Start with read-only discovery. Re-check the active account, region, project, resource state, tool versions, and workspace paths before making changes.",
163
+ ...(notes ? ["", "Validated prerequisites, lessons, and expensive pitfalls:", notes] : []),
164
+ "",
165
+ "## Layer 3 — Preferred validated execution path",
166
+ "",
167
+ "Use this path first and preserve its order unless read-only evidence requires an adaptation. The commands and paths below are the known-good reference; do not replace them with speculative alternatives.",
168
+ "",
169
+ steps,
170
+ "",
171
+ "## Layer 4 — Expensive failed paths to avoid",
172
+ "",
173
+ "Do not retry any path below, including renamed or parameter-only variants. Reconsider one only when the user explicitly directs it after reviewing the recorded failure.",
174
+ "",
175
+ failedPaths,
176
+ "",
177
+ "## Layer 5 — Execution contract",
178
+ "",
179
+ "Present the adapted plan and obtain all normal HWCode Cloud approvals. Never copy credentials, assume old resources still exist, or skip current-state verification.",
180
+ "If HWCode Cloud is not active, do not run cloud commands directly; ask the user to start this template through /hwcode-cloud-template.",
181
+ "",
182
+ "Additional instructions: ${@:-Use the objective and successful sequence above.}",
183
+ ].join("\n");
184
+ }
185
+
186
+ function renderTemplateFile(
187
+ name: string,
188
+ state: WorkflowState,
189
+ notes: string,
190
+ createdAt: string,
191
+ updatedAt: string,
192
+ ): string {
193
+ if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
194
+ const body = renderCloudPromptTemplate(state, notes);
195
+ return [
196
+ "---",
197
+ `description: ${JSON.stringify(name)}`,
198
+ 'argument-hint: "[additional instructions]"',
199
+ `hwcode-cloud-name: ${JSON.stringify(name)}`,
200
+ `hwcode-cloud-created-at: ${JSON.stringify(createdAt)}`,
201
+ `hwcode-cloud-updated-at: ${JSON.stringify(updatedAt)}`,
202
+ `hwcode-cloud-vendor: ${JSON.stringify(state.details.vendor)}`,
203
+ `hwcode-cloud-deploy: ${state.details.deployCurrentProject}`,
204
+ `hwcode-cloud-objective: ${JSON.stringify(state.details.request)}`,
205
+ `hwcode-cloud-notes: ${JSON.stringify(notes)}`,
206
+ "---",
207
+ body,
208
+ "",
209
+ ].join("\n");
210
+ }
211
+
212
+ function writeTemplateFile(path: string, content: string): void {
213
+ const directory = dirname(path);
214
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
215
+ chmodSync(directory, 0o700);
216
+ const temporaryPath = `${path}.${process.pid}.tmp`;
217
+ writeFileSync(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
218
+ chmodSync(temporaryPath, 0o600);
219
+ renameSync(temporaryPath, path);
220
+ chmodSync(path, 0o600);
221
+ }
222
+
223
+ export function saveCloudPromptTemplate(
224
+ requestedName: string,
225
+ state: WorkflowState,
226
+ directory = defaultCloudPromptDirectory(),
227
+ notes = "",
228
+ savedAt = new Date(),
229
+ ): CloudPromptTemplate {
230
+ if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
231
+ const name = cleanTemplateName(requestedName);
232
+ if (!name) throw new Error("Cloud Prompt Template name is required");
233
+ const idBase = `hwcloud-${slugify(name)}`;
234
+ const timestamp = savedAt.toISOString();
235
+ const content = renderTemplateFile(name, state, notes, timestamp, timestamp);
236
+ let id = idBase;
237
+ let path = join(directory, `${id}.md`);
238
+ for (let suffix = 2; existsSync(path); suffix++) {
239
+ id = `${idBase}-${suffix}`;
240
+ path = join(directory, `${id}.md`);
241
+ }
242
+ writeTemplateFile(path, content);
243
+ return parseCloudPromptTemplate(path)!;
244
+ }
245
+
246
+ export function updateCloudPromptTemplate(
247
+ template: CloudPromptTemplate,
248
+ state: WorkflowState,
249
+ notes = template.notes,
250
+ updatedAt = new Date(),
251
+ ): CloudPromptTemplate {
252
+ const timestamp = updatedAt.toISOString();
253
+ const content = renderTemplateFile(template.name, state, notes, template.createdAt, timestamp);
254
+ writeTemplateFile(template.path, content);
255
+ return parseCloudPromptTemplate(template.path)!;
256
+ }
@@ -0,0 +1,109 @@
1
+ export type TerraformPlanAction = "no-op" | "read" | "create" | "update" | "delete" | "replace";
2
+
3
+ export interface TerraformResourceChange {
4
+ address: string;
5
+ providerName?: string;
6
+ actions: TerraformPlanAction[];
7
+ sensitive: boolean;
8
+ }
9
+
10
+ export interface TerraformPlanSummary {
11
+ create: number;
12
+ update: number;
13
+ delete: number;
14
+ replace: number;
15
+ read: number;
16
+ noOp: number;
17
+ sensitiveChangesHidden: number;
18
+ resources: TerraformResourceChange[];
19
+ }
20
+
21
+ function classifyActions(actions: readonly string[]): TerraformPlanAction {
22
+ if (actions.includes("delete") && actions.includes("create")) return "replace";
23
+ if (actions.includes("create")) return "create";
24
+ if (actions.includes("update")) return "update";
25
+ if (actions.includes("delete")) return "delete";
26
+ if (actions.includes("read")) return "read";
27
+ return "no-op";
28
+ }
29
+
30
+ export function classifyTerraformPlan(value: unknown): TerraformPlanSummary {
31
+ if (!value || typeof value !== "object") throw new Error("invalid Terraform plan JSON");
32
+ const data = value as Record<string, unknown>;
33
+ if (!Array.isArray(data.resource_changes)) throw new Error("Terraform plan JSON has no resource_changes");
34
+ const resources: TerraformResourceChange[] = [];
35
+ for (const entry of data.resource_changes) {
36
+ if (!entry || typeof entry !== "object") continue;
37
+ const resource = entry as Record<string, unknown>;
38
+ if (typeof resource.address !== "string" || !resource.change || typeof resource.change !== "object") continue;
39
+ const change = resource.change as Record<string, unknown>;
40
+ if (!Array.isArray(change.actions) || change.actions.some((action) => typeof action !== "string")) continue;
41
+ const action = classifyActions(change.actions as string[]);
42
+ const sensitive = Boolean(change.after_sensitive) || Boolean(change.before_sensitive);
43
+ resources.push({
44
+ address: resource.address,
45
+ providerName: typeof resource.provider_name === "string" ? resource.provider_name : undefined,
46
+ actions: [action],
47
+ sensitive,
48
+ });
49
+ }
50
+ const summary: TerraformPlanSummary = {
51
+ create: resources.filter((resource) => resource.actions.includes("create") && !resource.actions.includes("replace")).length,
52
+ update: resources.filter((resource) => resource.actions.includes("update")).length,
53
+ delete: resources.filter((resource) => resource.actions.includes("delete")).length,
54
+ replace: resources.filter((resource) => resource.actions.includes("replace")).length,
55
+ read: resources.filter((resource) => resource.actions.includes("read")).length,
56
+ noOp: resources.filter((resource) => resource.actions.includes("no-op")).length,
57
+ sensitiveChangesHidden: resources.filter((resource) => resource.sensitive && !resource.actions.includes("no-op")).length,
58
+ resources,
59
+ };
60
+ return summary;
61
+ }
62
+
63
+ export function terraformPlanRequiresApproval(summary: TerraformPlanSummary): boolean {
64
+ return summary.create > 0 || summary.update > 0 || summary.delete > 0 || summary.replace > 0;
65
+ }
66
+
67
+ export function terraformPlanHasDeletion(summary: TerraformPlanSummary): boolean {
68
+ return summary.delete > 0 || summary.replace > 0;
69
+ }
70
+
71
+ export function terraformPlanSummaryText(summary: TerraformPlanSummary): string {
72
+ return [
73
+ `create=${summary.create}`,
74
+ `update=${summary.update}`,
75
+ `delete=${summary.delete}`,
76
+ `replace=${summary.replace}`,
77
+ `read=${summary.read}`,
78
+ `sensitive-hidden=${summary.sensitiveChangesHidden}`,
79
+ ].join(", ");
80
+ }
81
+
82
+ export interface TerraformStateSummary {
83
+ resourceCount: number;
84
+ resourceTypes: Record<string, number>;
85
+ }
86
+
87
+ export function summarizeTerraformState(value: unknown): TerraformStateSummary {
88
+ if (!value || typeof value !== "object") throw new Error("invalid Terraform state JSON");
89
+ const root = (value as Record<string, unknown>).values;
90
+ const rootModule = root && typeof root === "object" ? (root as Record<string, unknown>).root_module : undefined;
91
+ const resourceTypes: Record<string, number> = {};
92
+ let resourceCount = 0;
93
+ const visit = (module: unknown): void => {
94
+ if (!module || typeof module !== "object") return;
95
+ const data = module as Record<string, unknown>;
96
+ if (Array.isArray(data.resources)) {
97
+ for (const resource of data.resources) {
98
+ if (!resource || typeof resource !== "object") continue;
99
+ const type = (resource as Record<string, unknown>).type;
100
+ if (typeof type !== "string") continue;
101
+ resourceCount += 1;
102
+ resourceTypes[type] = (resourceTypes[type] ?? 0) + 1;
103
+ }
104
+ }
105
+ if (Array.isArray(data.child_modules)) for (const child of data.child_modules) visit(child);
106
+ };
107
+ visit(rootModule);
108
+ return { resourceCount, resourceTypes };
109
+ }
@@ -0,0 +1,36 @@
1
+ export interface TerraformPolicyViolation {
2
+ code: "provisioner" | "external-data" | "unlocked-module" | "plain-secret" | "unsafe-backend";
3
+ file: string;
4
+ detail: string;
5
+ }
6
+
7
+ export interface TerraformPolicyOptions { requireRemoteBackend?: boolean }
8
+
9
+ const PROVISIONER_PATTERN = /\bprovisioner\s+"(?:local-exec|remote-exec|file)"/u;
10
+ const EXTERNAL_DATA_PATTERN = /data\s+"external"/u;
11
+ const PLAIN_SECRET_PATTERN = /(?:access[_-]?key|secret[_-]?key|password|token)\s*=\s*"[^"$]+"/iu;
12
+ const UNLOCKED_MODULE_PATTERN = /module\s+"[^"]+"\s*\{[\s\S]*?source\s*=\s*"(?![^"?]+\?ref=|[^"/]+\/[^"/]+\/[^"/]+\/v?\d)[^"}]+"/u;
13
+
14
+ export function scanTerraformSource(files: readonly { relativePath: string; content: string }[], options: TerraformPolicyOptions = {}): TerraformPolicyViolation[] {
15
+ const violations: TerraformPolicyViolation[] = [];
16
+ for (const file of files) {
17
+ if (!file.relativePath.endsWith(".tf") && !file.relativePath.endsWith(".tf.json")) continue;
18
+ if (PROVISIONER_PATTERN.test(file.content)) violations.push({ code: "provisioner", file: file.relativePath, detail: "Terraform provisioners are disabled; use cloud-init or provider resources." });
19
+ if (EXTERNAL_DATA_PATTERN.test(file.content)) violations.push({ code: "external-data", file: file.relativePath, detail: "external data sources can execute arbitrary programs." });
20
+ if (PLAIN_SECRET_PATTERN.test(file.content)) violations.push({ code: "plain-secret", file: file.relativePath, detail: "credential-like literal detected in Terraform source." });
21
+ if (UNLOCKED_MODULE_PATTERN.test(file.content)) violations.push({ code: "unlocked-module", file: file.relativePath, detail: "module source must be version or commit pinned." });
22
+ }
23
+ if (options.requireRemoteBackend) {
24
+ const terraform = files.filter((file) => file.relativePath.endsWith(".tf") || file.relativePath.endsWith(".tf.json"));
25
+ const source = terraform.map((file) => file.content).join("\n");
26
+ const hasRemoteBackend = /backend\s+"(?!local")[^"]+"\s*\{/u.test(source)
27
+ || /"backend"\s*:\s*\{\s*"(?!local")[^"]+"\s*:/u.test(source);
28
+ if (!hasRemoteBackend) violations.push({ code: "unsafe-backend", file: "<terraform>", detail: "remote Runner execution requires an explicit non-local Terraform backend." });
29
+ }
30
+ return violations;
31
+ }
32
+
33
+ export function assertSafeTerraformSource(files: readonly { relativePath: string; content: string }[], options: TerraformPolicyOptions = {}): void {
34
+ const violations = scanTerraformSource(files, options);
35
+ if (violations.length > 0) throw new Error(violations.map((violation) => `${violation.file}: ${violation.detail}`).join("\n"));
36
+ }
@@ -0,0 +1,64 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import type { RemoteTargetProfile } from "../remote/profiles.ts";
4
+ import { runSsh, type SshRunResult } from "../remote/ssh-transport.ts";
5
+ import { CLOUD_RUNTIME_DEFAULTS } from "../../../runtime/defaults.ts";
6
+
7
+ export type TerraformRunnerAction = "fmt" | "init" | "validate" | "test" | "plan" | "show-plan" | "show-state" | "apply";
8
+
9
+ export interface TerraformRunnerRequest {
10
+ action: TerraformRunnerAction;
11
+ workspace: string;
12
+ terraformPath?: string;
13
+ planPath?: string;
14
+ mode?: "normal" | "destroy" | "refresh-only";
15
+ }
16
+
17
+ const ACTIONS: Record<TerraformRunnerAction, readonly string[]> = {
18
+ fmt: ["fmt", "-check", "-no-color"],
19
+ init: ["init", "-input=false", "-no-color"],
20
+ validate: ["validate", "-no-color"],
21
+ test: ["test", "-no-color"],
22
+ plan: ["plan", "-input=false", "-no-color"],
23
+ "show-plan": ["show", "-json", "-no-color"],
24
+ "show-state": ["show", "-json", "-no-color"],
25
+ apply: ["apply", "-input=false", "-no-color"],
26
+ };
27
+
28
+ function assertWorkspace(workspace: string): void {
29
+ if (!workspace.startsWith("/") || workspace === "/" || workspace.includes("..")) {
30
+ throw new Error("invalid Terraform workspace path");
31
+ }
32
+ }
33
+
34
+ export function buildTerraformRunnerArgs(request: TerraformRunnerRequest): string[] {
35
+ assertWorkspace(request.workspace);
36
+ const executable = request.terraformPath?.trim() || CLOUD_RUNTIME_DEFAULTS.terraform.executable;
37
+ if (!executable || executable.includes(" ") || executable.includes(";")) throw new Error("invalid Terraform executable");
38
+ const args = [...ACTIONS[request.action]];
39
+ if (request.action === "plan") {
40
+ if (request.mode === "destroy") args.push("-destroy");
41
+ if (request.mode === "refresh-only") args.push("-refresh-only");
42
+ args.push(`-out=${CLOUD_RUNTIME_DEFAULTS.terraform.planFile}`);
43
+ }
44
+ if (request.action === "show-plan" || request.action === "apply") {
45
+ if (!request.planPath || request.planPath.includes("..") || !request.planPath.endsWith(".tfplan")) {
46
+ throw new Error("a managed Terraform plan path is required");
47
+ }
48
+ args.push(request.planPath);
49
+ }
50
+ return [executable, ...args];
51
+ }
52
+
53
+ export async function executeTerraformRunner(
54
+ profile: RemoteTargetProfile,
55
+ request: TerraformRunnerRequest,
56
+ options: { signal?: AbortSignal; timeoutMs?: number; onOutput?: (chunk: string, stream: "stdout" | "stderr") => void } = {},
57
+ ): Promise<SshRunResult> {
58
+ const [command, ...args] = buildTerraformRunnerArgs({ ...request, terraformPath: request.terraformPath ?? profile.terraformPath });
59
+ return runSsh(profile, command, args, { ...options, remoteCwd: request.workspace });
60
+ }
61
+
62
+ export function digestTerraformPlan(planJson: string): string {
63
+ return createHash("sha256").update(planJson, "utf8").digest("hex");
64
+ }
@@ -14,9 +14,11 @@ import {
14
14
  writeFileSync,
15
15
  } from "node:fs";
16
16
  import { homedir } from "node:os";
17
- import { dirname, join } from "node:path";
17
+ import { dirname } from "node:path";
18
18
 
19
- import { isCloudVendorId, type CloudCredentials, type CloudVendorId } from "./cloud-providers.ts";
19
+ import { isCloudVendorId, type CloudCredentials, type CloudVendorId } from "./providers.ts";
20
+ import { validateRemoteTargetProfile, type RemoteTargetProfile } from "./remote/profiles.ts";
21
+ import { userRuntimePaths } from "../../runtime/paths.ts";
20
22
 
21
23
  const AAD = Buffer.from("hwcode-cloud-credentials-v1", "utf8");
22
24
  const KEY_LENGTH = 32;
@@ -30,8 +32,9 @@ export interface CloudCredentialProfile {
30
32
  }
31
33
 
32
34
  export interface VaultPayload {
33
- version: 2;
34
- providers: Partial<Record<CloudVendorId, CloudCredentialProfile[]>>;
35
+ version: 2;
36
+ providers: Partial<Record<CloudVendorId, CloudCredentialProfile[]>>;
37
+ runners?: RemoteTargetProfile[];
35
38
  }
36
39
 
37
40
  interface EncryptedVault {
@@ -45,7 +48,7 @@ interface EncryptedVault {
45
48
  }
46
49
 
47
50
  export function defaultCloudVaultPath(home = homedir()): string {
48
- return join(home, ".hwcode", "cloud", "credentials.enc");
51
+ return userRuntimePaths(home).cloudVault;
49
52
  }
50
53
 
51
54
  export function cloudVaultExists(path = defaultCloudVaultPath()): boolean {
@@ -69,7 +72,7 @@ export function normalizeCloudVaultPayload(value: unknown): VaultPayload {
69
72
  if (!payload.providers || typeof payload.providers !== "object" || Array.isArray(payload.providers)) {
70
73
  throw new Error("invalid vault payload");
71
74
  }
72
- const providers: VaultPayload["providers"] = {};
75
+ const providers: VaultPayload["providers"] = {};
73
76
  for (const [vendor, stored] of Object.entries(payload.providers)) {
74
77
  if (!isCloudVendorId(vendor)) throw new Error("unsupported cloud provider in vault");
75
78
  if (payload.version === 1) {
@@ -96,7 +99,10 @@ export function normalizeCloudVaultPayload(value: unknown): VaultPayload {
96
99
  });
97
100
  }
98
101
  if (payload.version !== 1 && payload.version !== 2) throw new Error("unsupported vault payload");
99
- return { version: 2, providers };
102
+ const runners = payload.runners === undefined
103
+ ? undefined
104
+ : Array.isArray(payload.runners) ? payload.runners.map(validateRemoteTargetProfile) : (() => { throw new Error("invalid remote runner profiles"); })();
105
+ return { version: 2, providers, ...(runners ? { runners } : {}) };
100
106
  }
101
107
 
102
108
  export function readCloudVault(password: string, path = defaultCloudVaultPath()): VaultPayload {
@@ -151,23 +157,6 @@ export function writeCloudVault(payload: VaultPayload, password: string, path =
151
157
  chmodSync(path, 0o600);
152
158
  }
153
159
 
154
- export function setCloudCredentials(
155
- payload: VaultPayload,
156
- vendor: CloudVendorId,
157
- credentials: CloudCredentials,
158
- ): VaultPayload {
159
- const first = payload.providers[vendor]?.[0];
160
- return saveCloudCredentialProfile(payload, vendor, credentials, first?.id);
161
- }
162
-
163
- export function getCloudCredentials(
164
- payload: VaultPayload,
165
- vendor: CloudVendorId,
166
- ): CloudCredentials | undefined {
167
- const credentials = payload.providers[vendor]?.[0]?.credentials;
168
- return credentials ? { ...credentials } : undefined;
169
- }
170
-
171
160
  export function listCloudCredentialProfiles(
172
161
  payload: VaultPayload,
173
162
  vendor: CloudVendorId,
@@ -207,3 +196,16 @@ export function cloudCredentialProfileLabel(
207
196
  const region = profile.credentials.region?.trim() || "未配置";
208
197
  return `使用已有凭据${number} · Region: ${region}`;
209
198
  }
199
+
200
+ export function listRemoteTargetProfiles(payload: VaultPayload): RemoteTargetProfile[] {
201
+ return (payload.runners ?? []).map((profile) => ({ ...profile }));
202
+ }
203
+
204
+ export function saveRemoteTargetProfile(payload: VaultPayload, profile: RemoteTargetProfile): VaultPayload {
205
+ const validated = validateRemoteTargetProfile(profile);
206
+ const profiles = listRemoteTargetProfiles(payload);
207
+ const existingIndex = profiles.findIndex((entry) => entry.id === validated.id);
208
+ if (existingIndex >= 0) profiles[existingIndex] = validated;
209
+ else profiles.push(validated);
210
+ return { version: 2, providers: { ...payload.providers }, runners: profiles };
211
+ }
@@ -0,0 +1,37 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+
5
+ import { PROJECT_CLOUD_RUNS_RELATIVE, projectRuntimePaths } from "../../runtime/paths.ts";
6
+
7
+ export interface CloudRunWorkspace {
8
+ runId: string;
9
+ path: string;
10
+ discoveryPath: string;
11
+ terraformPath: string;
12
+ chartsPath: string;
13
+ reportsPath: string;
14
+ }
15
+
16
+ export function createCloudRunWorkspace(root: string, runId = randomUUID()): CloudRunWorkspace {
17
+ const path = join(projectRuntimePaths(root).cloudRuns, runId);
18
+ const workspace: CloudRunWorkspace = {
19
+ runId, path,
20
+ discoveryPath: join(path, "discovery"),
21
+ terraformPath: join(path, "terraform"),
22
+ chartsPath: join(path, "charts"),
23
+ reportsPath: join(path, "reports"),
24
+ };
25
+ for (const directory of [workspace.discoveryPath, workspace.terraformPath, workspace.chartsPath, workspace.reportsPath]) {
26
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
27
+ }
28
+ return workspace;
29
+ }
30
+
31
+ export function isCloudRunWorkspace(root: string, path: string): boolean {
32
+ const resolvedRoot = resolve(root);
33
+ const resolvedPath = resolve(path);
34
+ const remainder = relative(resolvedRoot, resolvedPath);
35
+ return !isAbsolute(remainder) && !remainder.startsWith("..")
36
+ && remainder.split(/[\\/]/u).slice(0, 3).join("/") === PROJECT_CLOUD_RUNS_RELATIVE;
37
+ }
@@ -0,0 +1,11 @@
1
+ import type { SddPhase, SddWorkflowProgress } from "./state.ts";
2
+
3
+ export const SDD_PHASES: readonly SddPhase[] = ["discovery", "requirements", "design", "test-plan", "tasks", "tests", "implementation", "verification"];
4
+
5
+ export function advanceSddProgress(progress: SddWorkflowProgress, nextPhase: SddPhase, evidence: string, approvedAt = new Date()): SddWorkflowProgress {
6
+ const currentIndex = SDD_PHASES.indexOf(progress.phase);
7
+ if (SDD_PHASES[currentIndex + 1] !== nextPhase) throw new Error(`Invalid SDD transition ${progress.phase} → ${nextPhase}. Complete phases in order.`);
8
+ const normalizedEvidence = evidence.replace(/[\r\n]+/gu, " ").trim().slice(0, 1_000);
9
+ if (!normalizedEvidence) throw new Error("SDD phase approval evidence is required");
10
+ return { phase: nextPhase, approvals: [...progress.approvals, { phase: nextPhase, evidence: normalizedEvidence, approvedAt: approvedAt.toISOString() }] };
11
+ }