@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.
- package/.pi/extensions/hwcode.ts +35 -2
- package/.pi/extensions/model-providers.ts +1 -2
- package/.pi/extensions/workflows/cloud/activation.ts +235 -0
- package/.pi/extensions/workflows/cloud/commands.ts +96 -0
- package/.pi/extensions/workflows/cloud/events.ts +58 -0
- package/.pi/extensions/workflows/cloud/index.ts +17 -0
- package/.pi/extensions/workflows/cloud/provider-tools.ts +127 -0
- package/.pi/extensions/workflows/cloud/runner-tools.ts +129 -0
- package/.pi/extensions/workflows/cloud/runtime.ts +97 -0
- package/.pi/extensions/workflows/cloud/shared.ts +213 -0
- package/.pi/extensions/workflows/cloud/terraform-tools.ts +126 -0
- package/.pi/extensions/workflows/vibe-sdd.ts +300 -0
- package/.pi/extensions/workflows.ts +6 -253
- package/.pi/lib/runtime/config.ts +3 -7
- package/.pi/lib/runtime/defaults.ts +20 -0
- package/.pi/lib/runtime/paths.ts +68 -0
- package/.pi/lib/runtime/session-state.ts +0 -34
- package/.pi/lib/workflow-guard.ts +8 -1
- package/.pi/lib/{cloud → workflows/cloud}/adapters.ts +7 -6
- package/.pi/lib/workflows/cloud/bundles.ts +358 -0
- package/.pi/lib/workflows/cloud/execution.ts +28 -0
- package/.pi/lib/{cloud → workflows/cloud}/process.ts +5 -3
- package/.pi/lib/workflows/cloud/remote/bootstrap.ts +23 -0
- package/.pi/lib/workflows/cloud/remote/connect.ts +83 -0
- package/.pi/lib/workflows/cloud/remote/host-key.ts +35 -0
- package/.pi/lib/workflows/cloud/remote/profiles.ts +100 -0
- package/.pi/lib/workflows/cloud/remote/ssh-transport.ts +104 -0
- package/.pi/lib/workflows/cloud/remote/workspace.ts +109 -0
- package/.pi/lib/workflows/cloud/template-save.ts +108 -0
- package/.pi/lib/workflows/cloud/templates.ts +256 -0
- package/.pi/lib/workflows/cloud/terraform/plan.ts +109 -0
- package/.pi/lib/workflows/cloud/terraform/policy.ts +36 -0
- package/.pi/lib/workflows/cloud/terraform/runner.ts +64 -0
- package/.pi/lib/{cloud-vault.ts → workflows/cloud/vault.ts} +26 -24
- package/.pi/lib/workflows/cloud/workspace.ts +37 -0
- package/.pi/lib/workflows/sdd.ts +11 -0
- package/.pi/lib/workflows/state.ts +165 -1
- package/.pi/lib/working-directory.ts +0 -58
- package/.pi/lib/workspace/access-policy.ts +2 -2
- package/.pi/skills/hwcode-cloud/SKILL.md +32 -1
- package/.pi/skills/hwcode-sdd/SKILL.md +2 -0
- package/README.md +52 -12
- package/bin/hwcode.js +2 -6
- package/package.json +8 -3
- package/.pi/extensions/cloud.ts +0 -587
- package/.pi/lib/cloud/templates.ts +0 -148
- /package/.pi/lib/{cloud-providers.ts → workflows/cloud/providers.ts} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { CloudPromptTemplate } from "./templates.ts";
|
|
2
|
+
import { cloudPromptTemplateSource } from "./templates.ts";
|
|
3
|
+
import type { CloudWorkflowDetails, WorkflowState } from "../state.ts";
|
|
4
|
+
import { CLOUD_RUNTIME_DEFAULTS } from "../../runtime/defaults.ts";
|
|
5
|
+
|
|
6
|
+
export type CloudTemplateSourceAction = "update" | "create" | "cancel";
|
|
7
|
+
export type CloudTemplateSaveStage = "source-action" | "name" | "notes";
|
|
8
|
+
|
|
9
|
+
export interface CloudTemplateSaveInteraction {
|
|
10
|
+
chooseSourceAction(source: CloudPromptTemplate): Promise<CloudTemplateSourceAction | undefined>;
|
|
11
|
+
inputName(defaultName: string): Promise<string | undefined>;
|
|
12
|
+
editNotes(initialNotes: string): Promise<string | undefined>;
|
|
13
|
+
sourceMissing(sourceName: string): void | Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CloudTemplateStore {
|
|
17
|
+
list(): CloudPromptTemplate[];
|
|
18
|
+
create(name: string, state: WorkflowState, notes: string): CloudPromptTemplate;
|
|
19
|
+
update(template: CloudPromptTemplate, state: WorkflowState, notes: string): CloudPromptTemplate;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CloudTemplateSaveResult =
|
|
23
|
+
| { status: "cancelled"; stage: CloudTemplateSaveStage }
|
|
24
|
+
| {
|
|
25
|
+
status: "saved";
|
|
26
|
+
action: "created" | "updated";
|
|
27
|
+
template: CloudPromptTemplate;
|
|
28
|
+
details: CloudWorkflowDetails;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface SaveCloudWorkflowTemplateOptions {
|
|
32
|
+
state: WorkflowState;
|
|
33
|
+
requestedName?: string;
|
|
34
|
+
interaction: CloudTemplateSaveInteraction;
|
|
35
|
+
store: CloudTemplateStore;
|
|
36
|
+
redact?: (value: string) => string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Coordinates the save/update decision without depending on Pi UI or filesystem APIs.
|
|
41
|
+
* Storage is invoked only after every interactive step succeeds, so cancellation and
|
|
42
|
+
* storage failures cannot partially change the workflow state.
|
|
43
|
+
*/
|
|
44
|
+
export async function saveCloudWorkflowTemplate(
|
|
45
|
+
options: SaveCloudWorkflowTemplateOptions,
|
|
46
|
+
): Promise<CloudTemplateSaveResult> {
|
|
47
|
+
const { state, interaction, store } = options;
|
|
48
|
+
if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
|
|
49
|
+
if (state.details.successfulSteps.length === 0) {
|
|
50
|
+
throw new Error("No successful Cloud execution steps are available to summarize");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const details = state.details;
|
|
54
|
+
let target: CloudPromptTemplate | undefined;
|
|
55
|
+
if (details.sourceTemplate) {
|
|
56
|
+
target = store.list().find((template) => template.id === details.sourceTemplate?.id);
|
|
57
|
+
if (target) {
|
|
58
|
+
const sourceAction = await interaction.chooseSourceAction(target);
|
|
59
|
+
if (!sourceAction || sourceAction === "cancel") {
|
|
60
|
+
return { status: "cancelled", stage: "source-action" };
|
|
61
|
+
}
|
|
62
|
+
if (sourceAction === "create") target = undefined;
|
|
63
|
+
} else {
|
|
64
|
+
await interaction.sourceMissing(details.sourceTemplate.name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let name = target?.name;
|
|
69
|
+
if (!name) {
|
|
70
|
+
const suppliedName = options.requestedName?.trim();
|
|
71
|
+
if (suppliedName) {
|
|
72
|
+
name = suppliedName;
|
|
73
|
+
} else {
|
|
74
|
+
const defaultName = details.sourceTemplate
|
|
75
|
+
? `${details.sourceTemplate.name} 副本`
|
|
76
|
+
: details.request.slice(0, CLOUD_RUNTIME_DEFAULTS.templates.slugMaxLength);
|
|
77
|
+
name = (await interaction.inputName(defaultName))?.trim();
|
|
78
|
+
}
|
|
79
|
+
if (!name) return { status: "cancelled", stage: "name" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const notes = await interaction.editNotes(target?.notes ?? "");
|
|
83
|
+
if (notes === undefined) return { status: "cancelled", stage: "notes" };
|
|
84
|
+
|
|
85
|
+
const redact = options.redact ?? ((value: string) => value);
|
|
86
|
+
const safeName = redact(name);
|
|
87
|
+
const safeNotes = redact(notes.trim());
|
|
88
|
+
const safeState: WorkflowState = {
|
|
89
|
+
...state,
|
|
90
|
+
details: {
|
|
91
|
+
...details,
|
|
92
|
+
request: redact(details.request),
|
|
93
|
+
templateGuidance: details.templateGuidance === undefined
|
|
94
|
+
? undefined
|
|
95
|
+
: redact(details.templateGuidance),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const template = target
|
|
99
|
+
? store.update(target, safeState, safeNotes)
|
|
100
|
+
: store.create(safeName, safeState, safeNotes);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
status: "saved",
|
|
104
|
+
action: target ? "updated" : "created",
|
|
105
|
+
template,
|
|
106
|
+
details: { ...details, sourceTemplate: cloudPromptTemplateSource(template) },
|
|
107
|
+
};
|
|
108
|
+
}
|