@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
@@ -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
+ }
@@ -0,0 +1,100 @@
1
+ import { isAbsolute } from "node:path";
2
+
3
+ import type { CloudVendorId } from "../providers.ts";
4
+
5
+ export type RemoteIdentityType = "instance-role" | "agency" | "ssh-only";
6
+
7
+ export interface RemoteTargetProfile {
8
+ id: string;
9
+ name: string;
10
+ vendor: CloudVendorId;
11
+ region: string;
12
+ host: string;
13
+ port: number;
14
+ user: string;
15
+ keyPath: string;
16
+ hostKeyFingerprint: string;
17
+ knownHostsPath: string;
18
+ remoteRoot: string;
19
+ identityType: RemoteIdentityType;
20
+ terraformPath?: string;
21
+ proxyJump?: {
22
+ host: string;
23
+ port: number;
24
+ user: string;
25
+ };
26
+ }
27
+
28
+ export interface RemoteTargetSummary {
29
+ id: string;
30
+ name: string;
31
+ vendor: CloudVendorId;
32
+ region: string;
33
+ host: string;
34
+ port: number;
35
+ user: string;
36
+ remoteRoot: string;
37
+ identityType: RemoteIdentityType;
38
+ hostKeyFingerprint: string;
39
+ }
40
+
41
+ function nonEmpty(value: unknown): value is string {
42
+ return typeof value === "string" && value.trim().length > 0;
43
+ }
44
+
45
+ function validHost(value: unknown): value is string {
46
+ return nonEmpty(value) && !/[\s\0]/u.test(value);
47
+ }
48
+
49
+ export function validateRemoteTargetProfile(value: unknown): RemoteTargetProfile {
50
+ if (!value || typeof value !== "object") throw new Error("invalid remote target profile");
51
+ const data = value as Record<string, unknown>;
52
+ if (!nonEmpty(data.id) || !nonEmpty(data.name) || !nonEmpty(data.vendor)
53
+ || !nonEmpty(data.region) || !validHost(data.host) || !nonEmpty(data.user)
54
+ || !nonEmpty(data.keyPath) || !nonEmpty(data.hostKeyFingerprint)
55
+ || !nonEmpty(data.knownHostsPath) || !nonEmpty(data.remoteRoot)
56
+ || !isAbsolute(data.keyPath as string) || !isAbsolute(data.knownHostsPath as string)
57
+ || !isAbsolute(data.remoteRoot as string)
58
+ || !Number.isInteger(data.port) || (data.port as number) < 1 || (data.port as number) > 65_535
59
+ || !(["aws", "azure", "gcp", "huawei", "alibaba", "tencent"] as unknown[]).includes(data.vendor)
60
+ || !(["instance-role", "agency", "ssh-only"] as unknown[]).includes(data.identityType)) {
61
+ throw new Error("invalid remote target profile");
62
+ }
63
+ if ((data.remoteRoot as string) === "/") throw new Error("remote root cannot be filesystem root");
64
+ if (data.terraformPath !== undefined && !nonEmpty(data.terraformPath)) {
65
+ throw new Error("invalid terraform path");
66
+ }
67
+ let proxyJump: RemoteTargetProfile["proxyJump"];
68
+ if (data.proxyJump !== undefined) {
69
+ if (!data.proxyJump || typeof data.proxyJump !== "object") throw new Error("invalid SSH ProxyJump");
70
+ const jump = data.proxyJump as Record<string, unknown>;
71
+ if (!validHost(jump.host) || !nonEmpty(jump.user) || !Number.isInteger(jump.port)
72
+ || (jump.port as number) < 1 || (jump.port as number) > 65_535) throw new Error("invalid SSH ProxyJump");
73
+ proxyJump = { host: jump.host, port: jump.port as number, user: jump.user };
74
+ }
75
+ return {
76
+ id: data.id as string,
77
+ name: data.name as string,
78
+ vendor: data.vendor as CloudVendorId,
79
+ region: data.region as string,
80
+ host: data.host as string,
81
+ port: data.port as number,
82
+ user: data.user as string,
83
+ keyPath: data.keyPath as string,
84
+ hostKeyFingerprint: data.hostKeyFingerprint as string,
85
+ knownHostsPath: data.knownHostsPath as string,
86
+ remoteRoot: data.remoteRoot as string,
87
+ identityType: data.identityType as RemoteIdentityType,
88
+ terraformPath: data.terraformPath as string | undefined,
89
+ proxyJump,
90
+ };
91
+ }
92
+
93
+ export function remoteTargetSummary(profile: RemoteTargetProfile): RemoteTargetSummary {
94
+ const { keyPath: _keyPath, knownHostsPath: _knownHostsPath, terraformPath: _terraformPath, proxyJump: _proxyJump, ...summary } = profile;
95
+ return summary;
96
+ }
97
+
98
+ export function remoteTargetLabel(profile: RemoteTargetProfile): string {
99
+ return `${profile.name} · ${profile.vendor}/${profile.region} · ${profile.user}@${profile.host}:${profile.port}`;
100
+ }
@@ -0,0 +1,104 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import { shellQuote } from "../../../working-directory.ts";
4
+ import { isolatedCloudEnvironment } from "../process.ts";
5
+ import { CLOUD_RUNTIME_DEFAULTS } from "../../../runtime/defaults.ts";
6
+ import type { RemoteTargetProfile } from "./profiles.ts";
7
+
8
+ export interface SshRunResult {
9
+ stdout: string;
10
+ stderr: string;
11
+ code: number;
12
+ killed: boolean;
13
+ timedOut: boolean;
14
+ }
15
+
16
+ export interface SshRunOptions {
17
+ cwd?: string;
18
+ remoteCwd?: string;
19
+ signal?: AbortSignal;
20
+ timeoutMs?: number;
21
+ input?: string | Uint8Array;
22
+ onOutput?: (chunk: string, stream: "stdout" | "stderr") => void;
23
+ }
24
+
25
+ function remoteTarget(profile: RemoteTargetProfile): string {
26
+ return `${profile.user}@${profile.host}`;
27
+ }
28
+
29
+ export function buildSshArgs(profile: RemoteTargetProfile, command: string, args: readonly string[] = [], remoteCwd = profile.remoteRoot): string[] {
30
+ const remoteCommand = [
31
+ ...(remoteCwd ? [`cd ${shellQuote(remoteCwd)}`] : []),
32
+ [command, ...args].map(shellQuote).join(" "),
33
+ ].join(" && ");
34
+ const connection = [
35
+ "-T",
36
+ "-o", "BatchMode=yes",
37
+ "-o", "StrictHostKeyChecking=yes",
38
+ "-o", `UserKnownHostsFile=${profile.knownHostsPath}`,
39
+ "-o", `ConnectTimeout=${CLOUD_RUNTIME_DEFAULTS.runner.connectTimeoutSeconds}`,
40
+ ...(profile.proxyJump ? ["-J", `${profile.proxyJump.user}@${profile.proxyJump.host}:${profile.proxyJump.port}`] : []),
41
+ "-p", String(profile.port),
42
+ "-i", profile.keyPath,
43
+ remoteTarget(profile),
44
+ remoteCommand,
45
+ ];
46
+ return connection;
47
+ }
48
+
49
+ export function runSsh(
50
+ profile: RemoteTargetProfile,
51
+ command: string,
52
+ args: readonly string[] = [],
53
+ options: SshRunOptions = {},
54
+ ): Promise<SshRunResult> {
55
+ return new Promise((resolve) => {
56
+ const child = spawn("ssh", buildSshArgs(profile, command, args, options.remoteCwd), {
57
+ cwd: options.cwd,
58
+ env: isolatedCloudEnvironment(),
59
+ stdio: ["pipe", "pipe", "pipe"],
60
+ shell: false,
61
+ });
62
+ let stdout = "";
63
+ let stderr = "";
64
+ let killed = false;
65
+ let timedOut = false;
66
+ let settled = false;
67
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
68
+ const timeout = setTimeout(() => {
69
+ timedOut = true;
70
+ killed = true;
71
+ child.kill("SIGTERM");
72
+ forceKillTimer ??= setTimeout(() => child.kill("SIGKILL"), CLOUD_RUNTIME_DEFAULTS.process.forceKillGraceMs);
73
+ }, options.timeoutMs ?? CLOUD_RUNTIME_DEFAULTS.runner.commandTimeoutMs);
74
+ const abort = () => {
75
+ killed = true;
76
+ child.kill("SIGTERM");
77
+ forceKillTimer ??= setTimeout(() => child.kill("SIGKILL"), CLOUD_RUNTIME_DEFAULTS.process.forceKillGraceMs);
78
+ };
79
+ const finish = (code: number) => {
80
+ if (settled) return;
81
+ settled = true;
82
+ clearTimeout(timeout);
83
+ if (forceKillTimer) clearTimeout(forceKillTimer);
84
+ options.signal?.removeEventListener("abort", abort);
85
+ resolve({ stdout, stderr, code, killed, timedOut });
86
+ };
87
+ if (options.signal?.aborted) abort();
88
+ else options.signal?.addEventListener("abort", abort, { once: true });
89
+ child.stdout.on("data", (chunk: Buffer) => {
90
+ const text = chunk.toString("utf8");
91
+ stdout += text;
92
+ options.onOutput?.(text, "stdout");
93
+ });
94
+ child.stderr.on("data", (chunk: Buffer) => {
95
+ const text = chunk.toString("utf8");
96
+ stderr += text;
97
+ options.onOutput?.(text, "stderr");
98
+ });
99
+ child.on("error", (error) => { stderr += error.message; finish(127); });
100
+ child.on("close", (code) => finish(code ?? 1));
101
+ if (options.input !== undefined) child.stdin.end(options.input);
102
+ else child.stdin.end();
103
+ });
104
+ }
@@ -0,0 +1,109 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { readdirSync, readFileSync } from "node:fs";
4
+ import { isAbsolute, join, relative, sep } from "node:path";
5
+ import type { RemoteTargetProfile } from "./profiles.ts";
6
+ import { runSsh, type SshRunResult } from "./ssh-transport.ts";
7
+
8
+ const EXCLUDED_NAMES = new Set([".terraform", "terraform.tfstate", "terraform.tfstate.backup"]);
9
+ const EXCLUDED_SUFFIXES = [".tfplan", ".tfstate", ".tfstate.backup", ".tfvars", ".tfvars.json"];
10
+
11
+ export interface SyncFile {
12
+ relativePath: string;
13
+ bytes: number;
14
+ sha256: string;
15
+ }
16
+
17
+ export interface RemoteRunWorkspace {
18
+ runId: string;
19
+ remotePath: string;
20
+ sourceDigest: string;
21
+ files: SyncFile[];
22
+ }
23
+
24
+ function isExcluded(name: string): boolean {
25
+ return EXCLUDED_NAMES.has(name) || EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix));
26
+ }
27
+
28
+ function collectFiles(root: string, directory: string, output: SyncFile[]): void {
29
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
30
+ if (isExcluded(entry.name)) continue;
31
+ const path = join(directory, entry.name);
32
+ if (entry.isDirectory()) collectFiles(root, path, output);
33
+ else if (entry.isSymbolicLink()) throw new Error(`Terraform source cannot contain symbolic links: ${relative(root, path)}`);
34
+ else if (entry.isFile()) {
35
+ const content = readFileSync(path);
36
+ const relativePath = relative(root, path).split(sep).join("/");
37
+ if (/[\r\n\0]/u.test(relativePath)) throw new Error(`Terraform source contains an unsupported file name: ${relativePath}`);
38
+ output.push({
39
+ relativePath,
40
+ bytes: content.byteLength,
41
+ sha256: createHash("sha256").update(content).digest("hex"),
42
+ });
43
+ }
44
+ }
45
+ }
46
+
47
+ export function createRemoteRunWorkspace(root: string, remoteRoot: string, runId = randomUUID()): RemoteRunWorkspace {
48
+ if (!isAbsolute(root) || !isAbsolute(remoteRoot) || remoteRoot === "/") throw new Error("absolute workspace roots are required");
49
+ const files: SyncFile[] = [];
50
+ collectFiles(root, root, files);
51
+ const sourceDigest = createHash("sha256")
52
+ .update(files.map((file) => `${file.relativePath}\0${file.sha256}`).join("\n"))
53
+ .digest("hex");
54
+ return { runId, remotePath: join(remoteRoot, "runs", runId), sourceDigest, files };
55
+ }
56
+
57
+ export function assertRemoteWorkspacePath(remoteRoot: string, remotePath: string): void {
58
+ const root = remoteRoot.endsWith(sep) ? remoteRoot : `${remoteRoot}${sep}`;
59
+ if (!isAbsolute(remotePath) || !(remotePath === remoteRoot || remotePath.startsWith(root))) {
60
+ throw new Error("remote workspace path is outside the configured runner root");
61
+ }
62
+ }
63
+
64
+ export function isTerraformSourceFile(path: string): boolean {
65
+ return path.endsWith(".tf") || path.endsWith(".tf.json") || path.endsWith(".hcl");
66
+ }
67
+
68
+ function createTarArchive(root: string, files: readonly SyncFile[], signal?: AbortSignal): Promise<Buffer> {
69
+ return new Promise((resolve, reject) => {
70
+ const child = spawn("tar", [
71
+ "-czf", "-",
72
+ "-C", root, "--",
73
+ ...files.map((file) => file.relativePath),
74
+ ], { stdio: ["ignore", "pipe", "pipe"], shell: false });
75
+ const chunks: Buffer[] = [];
76
+ const errors: Buffer[] = [];
77
+ const abort = () => child.kill("SIGTERM");
78
+ signal?.addEventListener("abort", abort, { once: true });
79
+ child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
80
+ child.stderr.on("data", (chunk: Buffer) => errors.push(chunk));
81
+ child.on("error", reject);
82
+ child.on("close", (code) => {
83
+ signal?.removeEventListener("abort", abort);
84
+ if (signal?.aborted) reject(new Error("Terraform source sync aborted"));
85
+ else if (code !== 0) reject(new Error(`cannot archive Terraform source: ${Buffer.concat(errors).toString("utf8")}`));
86
+ else resolve(Buffer.concat(chunks));
87
+ });
88
+ });
89
+ }
90
+
91
+ export async function syncRemoteWorkspace(
92
+ profile: RemoteTargetProfile,
93
+ sourceRoot: string,
94
+ workspace: RemoteRunWorkspace,
95
+ signal?: AbortSignal,
96
+ ): Promise<SshRunResult> {
97
+ assertRemoteWorkspacePath(profile.remoteRoot, workspace.remotePath);
98
+ const mkdir = await runSsh(profile, "mkdir", ["-p", workspace.remotePath], { signal, remoteCwd: "" });
99
+ if (mkdir.code !== 0) throw new Error(`cannot create remote Terraform workspace: ${mkdir.stderr.trim() || mkdir.code}`);
100
+ const archive = await createTarArchive(sourceRoot, workspace.files, signal);
101
+ const unpack = await runSsh(profile, "tar", ["-xzf", "-"], { signal, remoteCwd: workspace.remotePath, input: archive });
102
+ if (unpack.code !== 0) throw new Error(`cannot sync Terraform source: ${unpack.stderr.trim() || unpack.code}`);
103
+ const verified = await runSsh(profile, "sha256sum", workspace.files.map((file) => file.relativePath), { signal, remoteCwd: workspace.remotePath });
104
+ if (verified.code !== 0) throw new Error(`cannot verify remote Terraform source: ${verified.stderr.trim() || verified.code}`);
105
+ const actual = new Map(verified.stdout.split(/\r?\n/u).map((line) => line.match(/^([a-f0-9]{64})\s+\*?(.+)$/u)).filter((match): match is RegExpMatchArray => Boolean(match)).map((match) => [match[2]!, match[1]!]));
106
+ const mismatch = workspace.files.find((file) => actual.get(file.relativePath) !== file.sha256);
107
+ if (mismatch) throw new Error(`remote Terraform source digest mismatch: ${mismatch.relativePath}`);
108
+ return unpack;
109
+ }
@@ -1,6 +1,7 @@
1
1
  import type { CloudPromptTemplate } from "./templates.ts";
2
2
  import { cloudPromptTemplateSource } from "./templates.ts";
3
- import type { CloudWorkflowDetails, WorkflowState } from "../workflows/state.ts";
3
+ import type { CloudWorkflowDetails, WorkflowState } from "../state.ts";
4
+ import { CLOUD_RUNTIME_DEFAULTS } from "../../runtime/defaults.ts";
4
5
 
5
6
  export type CloudTemplateSourceAction = "update" | "create" | "cancel";
6
7
  export type CloudTemplateSaveStage = "source-action" | "name" | "notes";
@@ -72,7 +73,7 @@ export async function saveCloudWorkflowTemplate(
72
73
  } else {
73
74
  const defaultName = details.sourceTemplate
74
75
  ? `${details.sourceTemplate.name} 副本`
75
- : details.request.slice(0, 48);
76
+ : details.request.slice(0, CLOUD_RUNTIME_DEFAULTS.templates.slugMaxLength);
76
77
  name = (await interaction.inputName(defaultName))?.trim();
77
78
  }
78
79
  if (!name) return { status: "cancelled", stage: "name" };
@@ -2,8 +2,10 @@ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, join } from "node:path";
4
4
 
5
- import { getCloudProvider, isCloudVendorId, type CloudVendorId } from "../cloud-providers.ts";
6
- import type { CloudTemplateSource, WorkflowState } from "../workflows/state.ts";
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";
7
9
 
8
10
  export interface CloudPromptTemplate {
9
11
  id: string;
@@ -25,11 +27,12 @@ export function cloudPromptTemplateSource(template: CloudPromptTemplate): CloudT
25
27
  name: template.name,
26
28
  createdAt: template.createdAt,
27
29
  updatedAt: template.updatedAt,
30
+ kind: "prompt",
28
31
  };
29
32
  }
30
33
 
31
34
  export function defaultCloudPromptDirectory(home = homedir()): string {
32
- return join(home, ".hwcode", "cloud", "prompts");
35
+ return userRuntimePaths(home).cloudPromptTemplates;
33
36
  }
34
37
 
35
38
  function slugify(value: string): string {
@@ -38,7 +41,7 @@ function slugify(value: string): string {
38
41
  .toLowerCase()
39
42
  .replace(/[^a-z0-9]+/gu, "-")
40
43
  .replace(/^-+|-+$/gu, "")
41
- .slice(0, 48);
44
+ .slice(0, CLOUD_RUNTIME_DEFAULTS.templates.slugMaxLength);
42
45
  return slug || `task-${new Date().toISOString().slice(0, 10)}`;
43
46
  }
44
47
 
@@ -66,7 +69,7 @@ function legacyTemplateName(id: string): string {
66
69
  }
67
70
 
68
71
  function cleanTemplateName(value: string): string {
69
- return value.replace(/[\r\n]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, 80);
72
+ return value.replace(/[\r\n]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, CLOUD_RUNTIME_DEFAULTS.templates.nameMaxLength);
70
73
  }
71
74
 
72
75
  export function parseCloudPromptTemplate(path: string): CloudPromptTemplate | undefined {
@@ -97,12 +100,17 @@ export function parseCloudPromptTemplate(path: string): CloudPromptTemplate | un
97
100
  }
98
101
 
99
102
  export function listCloudPromptTemplates(
100
- directory = defaultCloudPromptDirectory(),
103
+ directory?: string,
104
+ home = homedir(),
101
105
  ): CloudPromptTemplate[] {
102
- if (!existsSync(directory)) return [];
103
- return readdirSync(directory, { withFileTypes: true })
104
- .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
105
- .map((entry) => parseCloudPromptTemplate(join(directory, entry.name)))
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))))
106
114
  .filter((template): template is CloudPromptTemplate => Boolean(template))
107
115
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.name.localeCompare(right.name));
108
116
  }
@@ -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
+ }