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