@hadooppei/hwcode 0.1.0 → 0.2.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/.env.example +2 -7
- package/.pi/extensions/cloud.ts +587 -0
- package/.pi/extensions/command-filter.ts +2 -6
- package/.pi/extensions/context-policy.ts +58 -0
- package/.pi/extensions/cwd.ts +12 -9
- package/.pi/extensions/model-providers.ts +92 -150
- package/.pi/extensions/welcome.ts +25 -9
- package/.pi/extensions/workflows.ts +41 -77
- package/.pi/lib/cloud/adapters.ts +234 -0
- package/.pi/lib/cloud/process.ts +91 -0
- package/.pi/lib/cloud/templates.ts +148 -0
- package/.pi/lib/cloud-providers.ts +263 -0
- package/.pi/lib/cloud-vault.ts +209 -0
- package/.pi/lib/command-filter.ts +1 -0
- package/.pi/lib/context/compaction.ts +113 -0
- package/.pi/lib/context-policy.ts +120 -0
- package/.pi/lib/models/provider-config.ts +68 -0
- package/.pi/lib/models/readiness.ts +18 -0
- package/.pi/lib/pixel-font.ts +5 -1
- package/.pi/lib/runtime/config.ts +70 -0
- package/.pi/lib/runtime/session-state.ts +61 -0
- package/.pi/lib/workflows/state.ts +138 -0
- package/.pi/lib/working-directory.ts +7 -7
- package/.pi/lib/workspace/access-policy.ts +49 -0
- package/.pi/model-providers.json +4 -44
- package/.pi/settings.json +41 -0
- package/.pi/skills/hwcode-cloud/SKILL.md +91 -0
- package/.pi/skills/hwcode-cloud/agents/openai.yaml +4 -0
- package/.pi/welcome.json +4 -2
- package/README.md +128 -36
- package/bin/hwcode.js +76 -4
- package/package.json +9 -8
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { homedir, tmpdir } from "node:os";
|
|
2
|
-
|
|
3
1
|
import type {
|
|
4
2
|
ExtensionAPI,
|
|
5
3
|
ExtensionCommandContext,
|
|
@@ -8,33 +6,19 @@ import type {
|
|
|
8
6
|
|
|
9
7
|
import {
|
|
10
8
|
canonicalizeWorkspaceRoot,
|
|
11
|
-
findExternalPathReferences,
|
|
12
|
-
isPathInsideRoot,
|
|
13
|
-
resolveToolPath,
|
|
14
9
|
} from "../lib/workflow-guard.ts";
|
|
15
10
|
import { getWorkingDirectory } from "../lib/working-directory.ts";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
root: string;
|
|
28
|
-
activatedAt: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface InactiveWorkflowState {
|
|
32
|
-
version: 1;
|
|
33
|
-
active: false;
|
|
34
|
-
deactivatedAt: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
type WorkflowState = ActiveWorkflowState | InactiveWorkflowState;
|
|
11
|
+
import {
|
|
12
|
+
WORKFLOW_EXTERNAL_AUDIT_TYPE,
|
|
13
|
+
WORKFLOW_STATE_TYPE,
|
|
14
|
+
activeWorkflow,
|
|
15
|
+
createWorkflowState,
|
|
16
|
+
updateWorkflowState,
|
|
17
|
+
workflowLabel,
|
|
18
|
+
type WorkflowMode,
|
|
19
|
+
type WorkflowState,
|
|
20
|
+
} from "../lib/workflows/state.ts";
|
|
21
|
+
import { evaluateToolPathAccess } from "../lib/workspace/access-policy.ts";
|
|
38
22
|
|
|
39
23
|
interface GitState {
|
|
40
24
|
initialized: boolean;
|
|
@@ -42,28 +26,8 @@ interface GitState {
|
|
|
42
26
|
status: string;
|
|
43
27
|
}
|
|
44
28
|
|
|
45
|
-
function isWorkflowState(value: unknown): value is WorkflowState {
|
|
46
|
-
if (!value || typeof value !== "object") return false;
|
|
47
|
-
const data = value as Record<string, unknown>;
|
|
48
|
-
if (data.version !== 1 || typeof data.active !== "boolean") return false;
|
|
49
|
-
if (data.active) {
|
|
50
|
-
return (data.mode === "vibe" || data.mode === "sdd")
|
|
51
|
-
&& typeof data.root === "string"
|
|
52
|
-
&& typeof data.activatedAt === "string";
|
|
53
|
-
}
|
|
54
|
-
return typeof data.deactivatedAt === "string";
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function restoreState(ctx: ExtensionContext): WorkflowState | undefined {
|
|
58
|
-
for (const entry of [...ctx.sessionManager.getEntries()].reverse()) {
|
|
59
|
-
if (entry.type !== "custom" || entry.customType !== STATE_TYPE || !isWorkflowState(entry.data)) continue;
|
|
60
|
-
return entry.data;
|
|
61
|
-
}
|
|
62
|
-
return undefined;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
29
|
function modeLabel(mode: WorkflowMode): string {
|
|
66
|
-
return mode
|
|
30
|
+
return workflowLabel(mode);
|
|
67
31
|
}
|
|
68
32
|
|
|
69
33
|
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
@@ -155,7 +119,7 @@ function activationPrompt(mode: WorkflowMode, root: string, git: GitState | unde
|
|
|
155
119
|
return `/skill:hwcode-${mode} ${context.join("\n\n")}`;
|
|
156
120
|
}
|
|
157
121
|
|
|
158
|
-
function workflowSystemPrompt(state:
|
|
122
|
+
function workflowSystemPrompt(state: WorkflowState): string {
|
|
159
123
|
const common = [
|
|
160
124
|
`HWCODE WORKFLOW ACTIVE: ${state.mode.toUpperCase()}`,
|
|
161
125
|
`The sole project root for this session is ${state.root}.`,
|
|
@@ -174,7 +138,7 @@ function workflowSystemPrompt(state: ActiveWorkflowState): string {
|
|
|
174
138
|
}
|
|
175
139
|
|
|
176
140
|
export default function (pi: ExtensionAPI) {
|
|
177
|
-
let activeState:
|
|
141
|
+
let activeState: WorkflowState | undefined;
|
|
178
142
|
|
|
179
143
|
async function activate(mode: WorkflowMode, args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
180
144
|
if (!ctx.isIdle()) {
|
|
@@ -183,6 +147,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
183
147
|
}
|
|
184
148
|
|
|
185
149
|
const root = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
|
|
150
|
+
const existing = activeWorkflow(ctx.sessionManager.getEntries());
|
|
151
|
+
if (existing) {
|
|
152
|
+
notify(
|
|
153
|
+
ctx,
|
|
154
|
+
`${workflowLabel(existing.mode)} is already active for ${existing.root}. Start a new session before activating another workflow.`,
|
|
155
|
+
"warning",
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
186
159
|
if (!(await confirmWorkspace(mode, root, ctx))) {
|
|
187
160
|
notify(ctx, `${modeLabel(mode)} was not started.`, "warning");
|
|
188
161
|
return;
|
|
@@ -191,14 +164,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
191
164
|
const git = mode === "sdd" ? await inspectOrInitializeGit(pi, root, ctx) : undefined;
|
|
192
165
|
if (mode === "sdd" && !git) return;
|
|
193
166
|
|
|
194
|
-
activeState =
|
|
195
|
-
|
|
196
|
-
active: true,
|
|
197
|
-
mode,
|
|
198
|
-
root,
|
|
199
|
-
activatedAt: new Date().toISOString(),
|
|
200
|
-
};
|
|
201
|
-
pi.appendEntry<WorkflowState>(STATE_TYPE, activeState);
|
|
167
|
+
activeState = createWorkflowState(mode as "vibe" | "sdd", root);
|
|
168
|
+
pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, activeState);
|
|
202
169
|
notify(ctx, `${modeLabel(mode)} activated. Project root locked to ${root}.`);
|
|
203
170
|
pi.sendUserMessage(activationPrompt(mode, root, git, args.trim()), { expandPromptTemplates: true });
|
|
204
171
|
}
|
|
@@ -214,8 +181,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
181
|
});
|
|
215
182
|
|
|
216
183
|
pi.on("session_start", async (_event, ctx) => {
|
|
217
|
-
const restored =
|
|
218
|
-
if (!restored
|
|
184
|
+
const restored = activeWorkflow(ctx.sessionManager.getEntries());
|
|
185
|
+
if (!restored) {
|
|
219
186
|
activeState = undefined;
|
|
220
187
|
return;
|
|
221
188
|
}
|
|
@@ -223,11 +190,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
223
190
|
const currentRoot = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
|
|
224
191
|
if (currentRoot !== restored.root) {
|
|
225
192
|
activeState = undefined;
|
|
226
|
-
pi.appendEntry<WorkflowState>(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
});
|
|
193
|
+
pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, updateWorkflowState(restored, {
|
|
194
|
+
status: "failed",
|
|
195
|
+
phase: "invalid-root",
|
|
196
|
+
reason: "Stored workflow root no longer matches the current execution directory.",
|
|
197
|
+
}));
|
|
231
198
|
notify(ctx, "Stored HWCode workflow disabled because the current directory changed.", "warning");
|
|
232
199
|
return;
|
|
233
200
|
}
|
|
@@ -236,25 +203,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
236
203
|
notify(ctx, `${modeLabel(restored.mode)} restored. Root: ${restored.root}`);
|
|
237
204
|
});
|
|
238
205
|
|
|
239
|
-
pi.on("before_agent_start", async (event) => {
|
|
206
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
207
|
+
activeState = activeWorkflow(ctx.sessionManager.getEntries());
|
|
240
208
|
if (!activeState) return undefined;
|
|
241
209
|
return { systemPrompt: `${event.systemPrompt}\n\n${workflowSystemPrompt(activeState)}` };
|
|
242
210
|
});
|
|
243
211
|
|
|
244
212
|
pi.on("tool_call", async (event, ctx) => {
|
|
213
|
+
activeState = activeWorkflow(ctx.sessionManager.getEntries());
|
|
245
214
|
if (!activeState) return undefined;
|
|
246
215
|
|
|
247
216
|
const input = event.input as Record<string, unknown>;
|
|
248
|
-
|
|
249
|
-
if (FILE_PATH_TOOLS.has(event.toolName) && typeof input.path === "string") {
|
|
250
|
-
const resolved = resolveToolPath(activeState.root, input.path, homedir());
|
|
251
|
-
if (!isPathInsideRoot(activeState.root, resolved)) external = [{ raw: input.path, resolved }];
|
|
252
|
-
} else if (event.toolName === "bash" && typeof input.command === "string") {
|
|
253
|
-
external = findExternalPathReferences(input.command, activeState.root, {
|
|
254
|
-
home: homedir(),
|
|
255
|
-
tmpdir: tmpdir(),
|
|
256
|
-
});
|
|
257
|
-
}
|
|
217
|
+
const external = evaluateToolPathAccess({ toolName: event.toolName, input }, activeState.root);
|
|
258
218
|
|
|
259
219
|
if (external.length === 0) return undefined;
|
|
260
220
|
const details = external.map((entry) => `• ${entry.raw} → ${entry.resolved}`).join("\n");
|
|
@@ -280,7 +240,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
280
240
|
return { block: true, reason: `User denied external path access:\n${details}` };
|
|
281
241
|
}
|
|
282
242
|
|
|
283
|
-
pi.appendEntry(
|
|
243
|
+
pi.appendEntry(WORKFLOW_EXTERNAL_AUDIT_TYPE, {
|
|
284
244
|
mode: activeState.mode,
|
|
285
245
|
root: activeState.root,
|
|
286
246
|
toolName: event.toolName,
|
|
@@ -289,4 +249,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
289
249
|
});
|
|
290
250
|
return undefined;
|
|
291
251
|
});
|
|
252
|
+
|
|
253
|
+
pi.on("session_shutdown", () => {
|
|
254
|
+
activeState = undefined;
|
|
255
|
+
});
|
|
292
256
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
cloudEnvironment,
|
|
6
|
+
getCloudProvider,
|
|
7
|
+
redactCredentialValues,
|
|
8
|
+
type CloudCredentials,
|
|
9
|
+
type CloudProvider,
|
|
10
|
+
type CloudVendorId,
|
|
11
|
+
} from "../cloud-providers.ts";
|
|
12
|
+
import { runProcess, truncateOutput, type ProcessResult } from "./process.ts";
|
|
13
|
+
|
|
14
|
+
export interface TemporaryCredentialStore {
|
|
15
|
+
createDirectory(prefix: string): string;
|
|
16
|
+
cleanupDirectory(directory: string): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AdapterContext {
|
|
20
|
+
root: string;
|
|
21
|
+
credentials: CloudCredentials;
|
|
22
|
+
temporaryStore: TemporaryCredentialStore;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PreparedCloudExecution {
|
|
27
|
+
args: string[];
|
|
28
|
+
env: Record<string, string>;
|
|
29
|
+
cleanup?: () => void;
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CloudProviderAdapter {
|
|
34
|
+
provider: CloudProvider;
|
|
35
|
+
validate(context: AdapterContext): Promise<ProcessResult>;
|
|
36
|
+
prepare(command: string, args: string[], context: AdapterContext): Promise<PreparedCloudExecution>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validationFailure(result: ProcessResult, credentials: CloudCredentials): string {
|
|
40
|
+
const raw = result.stderr.trim() || result.stdout.trim() || `command exited with code ${result.code}`;
|
|
41
|
+
return truncateOutput(redactCredentialValues(raw, credentials));
|
|
42
|
+
}
|
|
43
|
+
|
|
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
|
+
function basicAdapter(
|
|
57
|
+
vendor: CloudVendorId,
|
|
58
|
+
validationCommand: string,
|
|
59
|
+
validationArgs: string[],
|
|
60
|
+
): CloudProviderAdapter {
|
|
61
|
+
return {
|
|
62
|
+
provider: getCloudProvider(vendor),
|
|
63
|
+
validate: ({ root, credentials, signal }) => runProcess(validationCommand, validationArgs, {
|
|
64
|
+
cwd: root,
|
|
65
|
+
env: cloudEnvironment(vendor, credentials),
|
|
66
|
+
signal,
|
|
67
|
+
timeoutMs: 30_000,
|
|
68
|
+
}),
|
|
69
|
+
prepare: async (_command, args, { credentials }) => ({
|
|
70
|
+
args,
|
|
71
|
+
env: cloudEnvironment(vendor, credentials),
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function validateAzure(context: AdapterContext): Promise<ProcessResult> {
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const timeout = setTimeout(() => controller.abort(), 30_000);
|
|
79
|
+
const abort = () => controller.abort();
|
|
80
|
+
context.signal?.addEventListener("abort", abort, { once: true });
|
|
81
|
+
try {
|
|
82
|
+
const form = new URLSearchParams({
|
|
83
|
+
client_id: context.credentials.clientId,
|
|
84
|
+
client_secret: context.credentials.clientSecret,
|
|
85
|
+
grant_type: "client_credentials",
|
|
86
|
+
scope: "https://management.azure.com/.default",
|
|
87
|
+
});
|
|
88
|
+
const tokenResponse = await fetch(
|
|
89
|
+
`https://login.microsoftonline.com/${encodeURIComponent(context.credentials.tenantId)}/oauth2/v2.0/token`,
|
|
90
|
+
{
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
93
|
+
body: form,
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
const tokenBody = await tokenResponse.json() as Record<string, unknown>;
|
|
98
|
+
if (!tokenResponse.ok || typeof tokenBody.access_token !== "string") {
|
|
99
|
+
return { stdout: "", stderr: `Azure token request failed (${tokenResponse.status}).`, code: 1, killed: false };
|
|
100
|
+
}
|
|
101
|
+
const subscriptionResponse = await fetch(
|
|
102
|
+
`https://management.azure.com/subscriptions/${encodeURIComponent(context.credentials.subscriptionId)}?api-version=2022-12-01`,
|
|
103
|
+
{ headers: { authorization: `Bearer ${tokenBody.access_token}` }, signal: controller.signal },
|
|
104
|
+
);
|
|
105
|
+
if (!subscriptionResponse.ok) {
|
|
106
|
+
return { stdout: "", stderr: `Azure subscription check failed (${subscriptionResponse.status}).`, code: 1, killed: false };
|
|
107
|
+
}
|
|
108
|
+
const subscription = await subscriptionResponse.json() as Record<string, unknown>;
|
|
109
|
+
return {
|
|
110
|
+
stdout: JSON.stringify({ subscriptionId: subscription.subscriptionId, displayName: subscription.displayName }),
|
|
111
|
+
stderr: "",
|
|
112
|
+
code: 0,
|
|
113
|
+
killed: false,
|
|
114
|
+
};
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return { stdout: "", stderr: error instanceof Error ? error.message : String(error), code: 1, killed: false };
|
|
117
|
+
} finally {
|
|
118
|
+
clearTimeout(timeout);
|
|
119
|
+
context.signal?.removeEventListener("abort", abort);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const adapters: Record<CloudVendorId, CloudProviderAdapter> = {
|
|
124
|
+
aws: basicAdapter("aws", "aws", ["sts", "get-caller-identity", "--output", "json"]),
|
|
125
|
+
alibaba: basicAdapter("alibaba", "aliyun", ["sts", "get-caller-identity"]),
|
|
126
|
+
tencent: basicAdapter("tencent", "tccli", ["sts", "GetCallerIdentity", "--output", "json"]),
|
|
127
|
+
huawei: {
|
|
128
|
+
provider: getCloudProvider("huawei"),
|
|
129
|
+
validate: ({ root, credentials, signal }) => runProcess(
|
|
130
|
+
"hcloud",
|
|
131
|
+
["IAM", "KeystoneListProjects", ...huaweiAuthenticationArgs(credentials)],
|
|
132
|
+
{ cwd: root, env: cloudEnvironment("huawei", credentials), signal, timeoutMs: 30_000 },
|
|
133
|
+
),
|
|
134
|
+
prepare: async (command, args, { credentials }) => ({
|
|
135
|
+
args: command === "hcloud" ? [...args, ...huaweiAuthenticationArgs(credentials)] : args,
|
|
136
|
+
env: cloudEnvironment("huawei", credentials),
|
|
137
|
+
}),
|
|
138
|
+
},
|
|
139
|
+
gcp: {
|
|
140
|
+
provider: getCloudProvider("gcp"),
|
|
141
|
+
validate: async (context) => {
|
|
142
|
+
const directory = context.temporaryStore.createDirectory("hwcode-cloud-gcp-");
|
|
143
|
+
const path = join(directory, "credential.json");
|
|
144
|
+
writeFileSync(path, context.credentials.serviceAccountJson, { encoding: "utf8", mode: 0o600 });
|
|
145
|
+
try {
|
|
146
|
+
return await runProcess("gcloud", ["projects", "describe", context.credentials.projectId, "--format=json"], {
|
|
147
|
+
cwd: context.root,
|
|
148
|
+
env: {
|
|
149
|
+
...cloudEnvironment("gcp", context.credentials),
|
|
150
|
+
GOOGLE_APPLICATION_CREDENTIALS: path,
|
|
151
|
+
CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: path,
|
|
152
|
+
},
|
|
153
|
+
signal: context.signal,
|
|
154
|
+
timeoutMs: 30_000,
|
|
155
|
+
});
|
|
156
|
+
} finally {
|
|
157
|
+
context.temporaryStore.cleanupDirectory(directory);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
prepare: async (_command, args, context) => {
|
|
161
|
+
const directory = context.temporaryStore.createDirectory("hwcode-cloud-gcp-");
|
|
162
|
+
const path = join(directory, "credential.json");
|
|
163
|
+
writeFileSync(path, context.credentials.serviceAccountJson, { encoding: "utf8", mode: 0o600 });
|
|
164
|
+
return {
|
|
165
|
+
args,
|
|
166
|
+
env: {
|
|
167
|
+
...cloudEnvironment("gcp", context.credentials),
|
|
168
|
+
GOOGLE_APPLICATION_CREDENTIALS: path,
|
|
169
|
+
CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: path,
|
|
170
|
+
},
|
|
171
|
+
cleanup: () => context.temporaryStore.cleanupDirectory(directory),
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
azure: {
|
|
176
|
+
provider: getCloudProvider("azure"),
|
|
177
|
+
validate: validateAzure,
|
|
178
|
+
prepare: async (command, args, context) => {
|
|
179
|
+
const env = cloudEnvironment("azure", context.credentials);
|
|
180
|
+
if (command !== "az") return { args, env };
|
|
181
|
+
const directory = context.temporaryStore.createDirectory("hwcode-cloud-azure-");
|
|
182
|
+
const cleanup = () => context.temporaryStore.cleanupDirectory(directory);
|
|
183
|
+
const azureEnv = { ...env, AZURE_CONFIG_DIR: directory };
|
|
184
|
+
const login = await runProcess("az", [
|
|
185
|
+
"login", "--service-principal",
|
|
186
|
+
"--username", context.credentials.clientId,
|
|
187
|
+
"--password", context.credentials.clientSecret,
|
|
188
|
+
"--tenant", context.credentials.tenantId,
|
|
189
|
+
"--output", "none",
|
|
190
|
+
], { cwd: context.root, env: azureEnv, signal: context.signal, timeoutMs: 60_000 });
|
|
191
|
+
if (login.code !== 0) {
|
|
192
|
+
return { args, env: azureEnv, cleanup, error: validationFailure(login, context.credentials) };
|
|
193
|
+
}
|
|
194
|
+
const selection = await runProcess(
|
|
195
|
+
"az",
|
|
196
|
+
["account", "set", "--subscription", context.credentials.subscriptionId],
|
|
197
|
+
{ cwd: context.root, env: azureEnv, signal: context.signal, timeoutMs: 30_000 },
|
|
198
|
+
);
|
|
199
|
+
if (selection.code !== 0) {
|
|
200
|
+
return { args, env: azureEnv, cleanup, error: validationFailure(selection, context.credentials) };
|
|
201
|
+
}
|
|
202
|
+
return { args, env: azureEnv, cleanup };
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export function getCloudAdapter(vendor: CloudVendorId): CloudProviderAdapter {
|
|
208
|
+
return adapters[vendor];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function validateCloudCredentials(
|
|
212
|
+
vendor: CloudVendorId,
|
|
213
|
+
context: AdapterContext,
|
|
214
|
+
): Promise<ProcessResult> {
|
|
215
|
+
return getCloudAdapter(vendor).validate(context);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function prepareCloudExecution(
|
|
219
|
+
vendor: CloudVendorId,
|
|
220
|
+
command: string,
|
|
221
|
+
args: string[],
|
|
222
|
+
context: AdapterContext,
|
|
223
|
+
): Promise<PreparedCloudExecution> {
|
|
224
|
+
return getCloudAdapter(vendor).prepare(command, args, context);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function formatValidationFailure(result: ProcessResult, credentials: CloudCredentials): string {
|
|
228
|
+
return validationFailure(result, credentials);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function checkProviderCli(vendor: CloudVendorId, root: string): Promise<ProcessResult> {
|
|
232
|
+
const provider = getCloudProvider(vendor);
|
|
233
|
+
return runProcess(provider.cli, provider.versionArgs, { cwd: root, timeoutMs: 10_000 });
|
|
234
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export const MAX_CLOUD_OUTPUT_BYTES = 50_000;
|
|
4
|
+
const MAX_CAPTURE_BYTES = MAX_CLOUD_OUTPUT_BYTES * 2;
|
|
5
|
+
const CLOUD_ENV_PREFIXES = [
|
|
6
|
+
"AWS_", "AZURE_", "ARM_", "GOOGLE_", "CLOUDSDK_", "HW_", "HUAWEI",
|
|
7
|
+
"ALIBABA_CLOUD_", "TENCENTCLOUD_",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export interface ProcessResult {
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
code: number;
|
|
14
|
+
killed: boolean;
|
|
15
|
+
timedOut?: boolean;
|
|
16
|
+
spawnErrorCode?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function captureOutput(current: string, chunk: Buffer): string {
|
|
20
|
+
if (Buffer.byteLength(current, "utf8") >= MAX_CAPTURE_BYTES) return current;
|
|
21
|
+
const remaining = MAX_CAPTURE_BYTES - Buffer.byteLength(current, "utf8");
|
|
22
|
+
return `${current}${chunk.subarray(0, remaining).toString("utf8")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isolatedCloudEnvironment(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
26
|
+
const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => (
|
|
27
|
+
!CLOUD_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))
|
|
28
|
+
)));
|
|
29
|
+
return { ...inherited, ...extra };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runProcess(
|
|
33
|
+
command: string,
|
|
34
|
+
args: readonly string[],
|
|
35
|
+
options: {
|
|
36
|
+
cwd: string;
|
|
37
|
+
env?: Record<string, string>;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
},
|
|
41
|
+
): Promise<ProcessResult> {
|
|
42
|
+
return new Promise((finish) => {
|
|
43
|
+
let stdout = "";
|
|
44
|
+
let stderr = "";
|
|
45
|
+
let killed = false;
|
|
46
|
+
let timedOut = false;
|
|
47
|
+
let settled = false;
|
|
48
|
+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
49
|
+
const child = spawn(command, [...args], {
|
|
50
|
+
cwd: options.cwd,
|
|
51
|
+
env: isolatedCloudEnvironment(options.env),
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
shell: false,
|
|
54
|
+
});
|
|
55
|
+
const complete = (result: ProcessResult) => {
|
|
56
|
+
if (settled) return;
|
|
57
|
+
settled = true;
|
|
58
|
+
clearTimeout(timeoutTimer);
|
|
59
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
60
|
+
options.signal?.removeEventListener("abort", abort);
|
|
61
|
+
finish(result);
|
|
62
|
+
};
|
|
63
|
+
const terminate = (timeout: boolean) => {
|
|
64
|
+
killed = true;
|
|
65
|
+
timedOut ||= timeout;
|
|
66
|
+
child.kill("SIGTERM");
|
|
67
|
+
forceKillTimer ??= setTimeout(() => child.kill("SIGKILL"), 2_000);
|
|
68
|
+
};
|
|
69
|
+
const abort = () => terminate(false);
|
|
70
|
+
const timeoutTimer = setTimeout(() => terminate(true), options.timeoutMs ?? 120_000);
|
|
71
|
+
if (options.signal?.aborted) abort();
|
|
72
|
+
else options.signal?.addEventListener("abort", abort, { once: true });
|
|
73
|
+
child.stdout.on("data", (chunk: Buffer) => { stdout = captureOutput(stdout, chunk); });
|
|
74
|
+
child.stderr.on("data", (chunk: Buffer) => { stderr = captureOutput(stderr, chunk); });
|
|
75
|
+
child.on("error", (error) => complete({
|
|
76
|
+
stdout,
|
|
77
|
+
stderr: `${stderr}${error.message}`,
|
|
78
|
+
code: 127,
|
|
79
|
+
killed,
|
|
80
|
+
timedOut,
|
|
81
|
+
spawnErrorCode: (error as NodeJS.ErrnoException).code,
|
|
82
|
+
}));
|
|
83
|
+
child.on("close", (code) => complete({ stdout, stderr, code: code ?? 1, killed, timedOut }));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function truncateOutput(text: string): string {
|
|
88
|
+
const bytes = Buffer.from(text, "utf8");
|
|
89
|
+
if (bytes.length <= MAX_CLOUD_OUTPUT_BYTES) return text;
|
|
90
|
+
return `${bytes.subarray(0, MAX_CLOUD_OUTPUT_BYTES).toString("utf8")}\n\n[HWCode truncated cloud command output]`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { getCloudProvider, isCloudVendorId, type CloudVendorId } from "../cloud-providers.ts";
|
|
6
|
+
import type { WorkflowState } from "../workflows/state.ts";
|
|
7
|
+
|
|
8
|
+
export interface CloudPromptTemplate {
|
|
9
|
+
id: string;
|
|
10
|
+
description: string;
|
|
11
|
+
vendor: CloudVendorId;
|
|
12
|
+
deployCurrentProject: boolean;
|
|
13
|
+
objective: string;
|
|
14
|
+
body: string;
|
|
15
|
+
path: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function defaultCloudPromptDirectory(home = homedir()): string {
|
|
19
|
+
return join(home, ".hwcode", "cloud", "prompts");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function slugify(value: string): string {
|
|
23
|
+
const slug = value
|
|
24
|
+
.normalize("NFKD")
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/gu, "-")
|
|
27
|
+
.replace(/^-+|-+$/gu, "")
|
|
28
|
+
.slice(0, 48);
|
|
29
|
+
return slug || `task-${new Date().toISOString().slice(0, 10)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function frontmatterValue(text: string, key: string): string | undefined {
|
|
33
|
+
const line = text.match(new RegExp(`^${key}:\\s*(.+)$`, "mu"))?.[1]?.trim();
|
|
34
|
+
if (!line) return undefined;
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(line) as unknown;
|
|
37
|
+
return typeof value === "string" ? value : String(value);
|
|
38
|
+
} catch {
|
|
39
|
+
return line.replace(/^['"]|['"]$/gu, "");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseCloudPromptTemplate(path: string): CloudPromptTemplate | undefined {
|
|
44
|
+
const text = readFileSync(path, "utf8");
|
|
45
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/u);
|
|
46
|
+
if (!match) return undefined;
|
|
47
|
+
const metadata = match[1];
|
|
48
|
+
const vendorValue = frontmatterValue(metadata, "hwcode-cloud-vendor");
|
|
49
|
+
if (!vendorValue || !isCloudVendorId(vendorValue)) return undefined;
|
|
50
|
+
const id = basename(path, ".md");
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
description: frontmatterValue(metadata, "description") ?? id,
|
|
54
|
+
vendor: vendorValue,
|
|
55
|
+
deployCurrentProject: frontmatterValue(metadata, "hwcode-cloud-deploy") === "true",
|
|
56
|
+
objective: frontmatterValue(metadata, "hwcode-cloud-objective") ?? "",
|
|
57
|
+
body: match[2].trim(),
|
|
58
|
+
path,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function listCloudPromptTemplates(
|
|
63
|
+
directory = defaultCloudPromptDirectory(),
|
|
64
|
+
): CloudPromptTemplate[] {
|
|
65
|
+
if (!existsSync(directory)) return [];
|
|
66
|
+
return readdirSync(directory, { withFileTypes: true })
|
|
67
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
68
|
+
.map((entry) => parseCloudPromptTemplate(join(directory, entry.name)))
|
|
69
|
+
.filter((template): template is CloudPromptTemplate => Boolean(template))
|
|
70
|
+
.sort((left, right) => left.description.localeCompare(right.description));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function expandCloudPromptTemplate(template: CloudPromptTemplate, argumentsText: string): string {
|
|
74
|
+
const value = argumentsText.trim();
|
|
75
|
+
return template.body.replace(/\$\{@:-([^}]*)\}/gu, (_match, fallback: string) => value || fallback);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inlineCode(value: string): string {
|
|
79
|
+
return value.replace(/[\r\n]+/gu, " ").replaceAll("`", "\\`");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderCloudPromptTemplate(state: WorkflowState, notes = ""): string {
|
|
83
|
+
if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
|
|
84
|
+
const details = state.details;
|
|
85
|
+
if (details.successfulSteps.length === 0) {
|
|
86
|
+
throw new Error("No successful Cloud execution steps are available to summarize");
|
|
87
|
+
}
|
|
88
|
+
const provider = getCloudProvider(details.vendor);
|
|
89
|
+
const steps = details.successfulSteps.map((step, index) => (
|
|
90
|
+
`${index + 1}. [${step.operation}] ${step.intent}\n`
|
|
91
|
+
+ ` - Approach: ${step.approach}\n`
|
|
92
|
+
+ ` - Reference command: \`${inlineCode([step.command, ...step.args].join(" "))}\``
|
|
93
|
+
)).join("\n");
|
|
94
|
+
return [
|
|
95
|
+
"Execute a reusable HWCode Cloud task based on a previously successful run.",
|
|
96
|
+
"",
|
|
97
|
+
`Preferred provider: ${provider.label}`,
|
|
98
|
+
`Deploy current project: ${details.deployCurrentProject ? "yes" : "no"}`,
|
|
99
|
+
`Objective pattern: ${details.request}`,
|
|
100
|
+
"",
|
|
101
|
+
"Known successful execution sequence (reference only; re-check current account state before reuse):",
|
|
102
|
+
steps,
|
|
103
|
+
"",
|
|
104
|
+
...(notes ? ["Validated-run notes and pitfalls:", notes, ""] : []),
|
|
105
|
+
"Start with read-only discovery and adapt account, region, project, resource names, versions, and paths to the current environment. Present the plan and obtain all normal HWCode Cloud approvals. Never copy credentials or assume resources from the previous run still exist.",
|
|
106
|
+
"If HWCode Cloud is not active, do not run cloud commands directly; ask the user to start this template through /hwcode-cloud-template.",
|
|
107
|
+
"",
|
|
108
|
+
"Additional instructions: ${@:-Use the objective and successful sequence above.}",
|
|
109
|
+
].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function saveCloudPromptTemplate(
|
|
113
|
+
requestedName: string,
|
|
114
|
+
state: WorkflowState,
|
|
115
|
+
directory = defaultCloudPromptDirectory(),
|
|
116
|
+
notes = "",
|
|
117
|
+
): CloudPromptTemplate {
|
|
118
|
+
if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
|
|
119
|
+
const idBase = `hwcloud-${slugify(requestedName || state.details.request)}`;
|
|
120
|
+
const body = renderCloudPromptTemplate(state, notes);
|
|
121
|
+
const description = `Reuse successful ${getCloudProvider(state.details.vendor).label} workflow: ${state.details.request.slice(0, 80)}`;
|
|
122
|
+
const content = [
|
|
123
|
+
"---",
|
|
124
|
+
`description: ${JSON.stringify(description)}`,
|
|
125
|
+
'argument-hint: "[additional instructions]"',
|
|
126
|
+
`hwcode-cloud-vendor: ${JSON.stringify(state.details.vendor)}`,
|
|
127
|
+
`hwcode-cloud-deploy: ${state.details.deployCurrentProject}`,
|
|
128
|
+
`hwcode-cloud-objective: ${JSON.stringify(state.details.request)}`,
|
|
129
|
+
"---",
|
|
130
|
+
body,
|
|
131
|
+
"",
|
|
132
|
+
].join("\n");
|
|
133
|
+
|
|
134
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
135
|
+
chmodSync(directory, 0o700);
|
|
136
|
+
let id = idBase;
|
|
137
|
+
let path = join(directory, `${id}.md`);
|
|
138
|
+
for (let suffix = 2; existsSync(path); suffix++) {
|
|
139
|
+
id = `${idBase}-${suffix}`;
|
|
140
|
+
path = join(directory, `${id}.md`);
|
|
141
|
+
}
|
|
142
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
143
|
+
writeFileSync(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
|
|
144
|
+
chmodSync(temporaryPath, 0o600);
|
|
145
|
+
renameSync(temporaryPath, path);
|
|
146
|
+
chmodSync(path, 0o600);
|
|
147
|
+
return parseCloudPromptTemplate(path)!;
|
|
148
|
+
}
|