huaweicloud-devkit 1.1.0-next.2 → 1.1.0-next.21
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/README.md +64 -1
- package/README.zh-CN.md +63 -1
- package/package.json +3 -1
- package/plugins/huaweicloud-core/.claude-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.codex-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/hooks/huaweicloud-safety.py +46 -21
- package/plugins/huaweicloud-core/openclaw.plugin.json +1 -1
- package/plugins/huaweicloud-core/safety/rules/cloud-risk-rules.json +136 -24
- package/plugins/huaweicloud-core/skills/huawei-billing/SKILL.md +15 -14
- package/plugins/huaweicloud-core/skills/huawei-dew/SKILL.md +1 -1
- package/plugins/huaweicloud-core/skills/huawei-ecs/SKILL.md +5 -1
- package/plugins/huaweicloud-core/skills/huawei-ecs/references/hc-activity.md +27 -0
- package/plugins/huaweicloud-core/skills/huawei-iam/SKILL.md +11 -11
- package/plugins/huaweicloud-core/skills/huawei-rds/SKILL.md +6 -5
- package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +596 -93
- package/plugins/huaweicloud-core/skills/huawei-sandbox/references/framework-commands.md +0 -1
- package/plugins/huaweicloud-core/skills/huawei-sandbox/references/nginx-templates.md +8 -4
- package/plugins/huaweicloud-core/skills/huawei-smn-dms/SKILL.md +19 -12
- package/plugins/huaweicloud-core/skills/huawei-voucher/SKILL.md +52 -0
- package/plugins/huaweicloud-core/skills/huawei-vpc/SKILL.md +26 -26
- package/plugins/huaweicloud-core/skills/huawei-vpc/references/network.md +12 -6
- package/plugins/huaweicloud-core/skills/huawei-waf-aad/SKILL.md +7 -4
- package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +7 -7
- package/plugins/huaweicloud-core/skills/huaweicloud-core/SKILL.md +2 -0
- package/plugins/huaweicloud-core/src/auth/agent-registration.mjs +8 -0
- package/plugins/huaweicloud-core/src/auth/credentials.mjs +30 -1
- package/plugins/huaweicloud-core/src/auth/service.mjs +33 -0
- package/plugins/huaweicloud-core/src/detect-framework.mjs +25 -3
- package/plugins/huaweicloud-core/src/hcloud-cli.mjs +67 -5
- package/plugins/huaweicloud-core/src/mcp-server.mjs +23 -0
- package/plugins/huaweicloud-core/src/sandbox/hdkitservice-api.mjs +11 -3
- package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +436 -21
- package/plugins/huaweicloud-core/src/search-market.mjs +1 -0
- package/plugins/huaweicloud-core/src/setup-cli.mjs +605 -50
- package/plugins/huaweicloud-core/src/tools.mjs +193 -18
- package/plugins/huaweicloud-core/src/ws-exec/ws-exec-client.js +29 -8
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
2
6
|
|
|
3
7
|
import { classifyHcloudArgs, redactSecrets, assertAllowed } from './safety-policy.mjs';
|
|
4
8
|
import { getProxySettings } from './proxy/proxy-config.mjs';
|
|
@@ -6,6 +10,46 @@ import { getProxySettings } from './proxy/proxy-config.mjs';
|
|
|
6
10
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
7
11
|
const DEFAULT_FORCE_KILL_AFTER_MS = 2_000;
|
|
8
12
|
const DEFAULT_MAX_RETRIES = 1;
|
|
13
|
+
const APPROVAL_TTL_MS = 5 * 60_000;
|
|
14
|
+
const LARGE_OUTPUT_THRESHOLD = 50_000;
|
|
15
|
+
const OUTPUT_DIR = join('/tmp', 'huaweicloud-devkit');
|
|
16
|
+
|
|
17
|
+
const approvalStore = new Map();
|
|
18
|
+
|
|
19
|
+
export function createApprovalToken(rawArgs) {
|
|
20
|
+
const token = randomUUID();
|
|
21
|
+
approvalStore.set(token, { rawArgs, createdAt: Date.now() });
|
|
22
|
+
if (approvalStore.size % 20 === 0) {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
for (const [k, v] of approvalStore) {
|
|
25
|
+
if (now - v.createdAt > APPROVAL_TTL_MS) approvalStore.delete(k);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return token;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function consumeApprovalToken(token) {
|
|
32
|
+
const entry = approvalStore.get(token);
|
|
33
|
+
if (!entry) return null;
|
|
34
|
+
if (Date.now() - entry.createdAt > APPROVAL_TTL_MS) {
|
|
35
|
+
approvalStore.delete(token);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
approvalStore.delete(token);
|
|
39
|
+
return entry.rawArgs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function saveLargeOutput(rawStdout) {
|
|
43
|
+
if (rawStdout.length <= LARGE_OUTPUT_THRESHOLD) return null;
|
|
44
|
+
try {
|
|
45
|
+
mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
46
|
+
const filePath = join(OUTPUT_DIR, `output-${Date.now()}.json`);
|
|
47
|
+
writeFileSync(filePath, rawStdout, { encoding: 'utf8' });
|
|
48
|
+
return filePath;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
9
53
|
|
|
10
54
|
export function planHcloudCommand(args, options = {}) {
|
|
11
55
|
const normalizedArgs = Array.isArray(args) ? args.map(String) : [];
|
|
@@ -26,6 +70,7 @@ export function planHcloudCommand(args, options = {}) {
|
|
|
26
70
|
executableBlock: redactOutput(command),
|
|
27
71
|
warnings,
|
|
28
72
|
classification,
|
|
73
|
+
approvalToken: createApprovalToken(normalizedArgs),
|
|
29
74
|
safeToRun: classification.decision === 'allow',
|
|
30
75
|
};
|
|
31
76
|
}
|
|
@@ -53,12 +98,22 @@ export async function runHcloud(args, options = {}) {
|
|
|
53
98
|
throw new Error('Unreachable retry state.');
|
|
54
99
|
}
|
|
55
100
|
|
|
101
|
+
function discoverHcloudPath() {
|
|
102
|
+
if (process.env.HCLOUD_BIN && existsSync(process.env.HCLOUD_BIN)) return process.env.HCLOUD_BIN;
|
|
103
|
+
const candidates =
|
|
104
|
+
process.platform === 'win32'
|
|
105
|
+
? [join(homedir(), 'hcloud', 'hcloud.exe')]
|
|
106
|
+
: [join(homedir(), '.local', 'bin', 'hcloud'), join(homedir(), 'hcloud', 'hcloud')];
|
|
107
|
+
return candidates.find((c) => existsSync(c)) || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
56
110
|
function runHcloudOnce(plan, options) {
|
|
57
111
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
58
112
|
const forceKillAfterMs = options.forceKillAfterMs ?? DEFAULT_FORCE_KILL_AFTER_MS;
|
|
59
|
-
const executable = options.executable || options.env?.HCLOUD_BIN ||
|
|
113
|
+
const executable = options.executable || options.env?.HCLOUD_BIN || discoverHcloudPath() || 'hcloud';
|
|
60
114
|
const executableArgs = Array.isArray(options.executableArgs) ? options.executableArgs.map(String) : [];
|
|
61
115
|
const cwd = options.cwd || undefined;
|
|
116
|
+
const stdin = options.stdin ?? 'y\n';
|
|
62
117
|
|
|
63
118
|
return new Promise((resolve) => {
|
|
64
119
|
const proxySettings = getProxySettings();
|
|
@@ -78,11 +133,11 @@ function runHcloudOnce(plan, options) {
|
|
|
78
133
|
...options.env,
|
|
79
134
|
},
|
|
80
135
|
});
|
|
81
|
-
if (
|
|
82
|
-
if (typeof
|
|
83
|
-
|
|
136
|
+
if (stdin) {
|
|
137
|
+
if (typeof stdin === 'function') {
|
|
138
|
+
stdin(child.stdin);
|
|
84
139
|
} else {
|
|
85
|
-
child.stdin.write(String(
|
|
140
|
+
child.stdin.write(String(stdin));
|
|
86
141
|
child.stdin.end();
|
|
87
142
|
}
|
|
88
143
|
}
|
|
@@ -99,6 +154,13 @@ function runHcloudOnce(plan, options) {
|
|
|
99
154
|
clearTimeout(timer);
|
|
100
155
|
clearTimeout(forceTimer);
|
|
101
156
|
clearTimeout(settleTimer);
|
|
157
|
+
if (result.stdout && String(result.stdout).length > LARGE_OUTPUT_THRESHOLD) {
|
|
158
|
+
const outputFile = saveLargeOutput(stdout);
|
|
159
|
+
if (outputFile) {
|
|
160
|
+
result.outputFile = outputFile;
|
|
161
|
+
result.stdout = String(result.stdout).slice(0, 2000) + `\n...(truncated, full output saved to ${outputFile})`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
102
164
|
resolve(result);
|
|
103
165
|
}
|
|
104
166
|
|
|
@@ -49,6 +49,29 @@ for (const base of [pluginRoot, packageRoot]) {
|
|
|
49
49
|
let buffer = Buffer.alloc(0);
|
|
50
50
|
let useContentLengthFraming = true;
|
|
51
51
|
|
|
52
|
+
// Keep the event loop alive after stdin is closed (Windows Hermes workaround).
|
|
53
|
+
// Node.js exits when no active handles remain; the stdin 'data' listener is
|
|
54
|
+
// the only handle. On Windows, Hermes may close the stdin pipe after the
|
|
55
|
+
// initial handshake, causing the process to exit silently (exit 0).
|
|
56
|
+
//
|
|
57
|
+
// When stdin closes, start a keepalive timer. When stdout also closes (normal
|
|
58
|
+
// shutdown signal from OfficeAce or other agents), clear the timer and exit.
|
|
59
|
+
let keepAlive = null;
|
|
60
|
+
function onStdinClose() {
|
|
61
|
+
if (keepAlive) return;
|
|
62
|
+
keepAlive = setInterval(() => {}, 60000);
|
|
63
|
+
}
|
|
64
|
+
function onStdoutClose() {
|
|
65
|
+
if (keepAlive) {
|
|
66
|
+
clearInterval(keepAlive);
|
|
67
|
+
keepAlive = null;
|
|
68
|
+
}
|
|
69
|
+
process.exitCode = 0;
|
|
70
|
+
}
|
|
71
|
+
stdin.on('close', onStdinClose);
|
|
72
|
+
stdin.on('end', onStdinClose);
|
|
73
|
+
stdout.on('close', onStdoutClose);
|
|
74
|
+
|
|
52
75
|
stdin.on('data', (chunk) => {
|
|
53
76
|
buffer = Buffer.concat([buffer, chunk]);
|
|
54
77
|
readFrames();
|
|
@@ -7,6 +7,12 @@ const HDKIT_BASE_URL =
|
|
|
7
7
|
async function hdkitRequest(method, path, body, timeoutMs = 300000) {
|
|
8
8
|
const { ak, sk, securitytoken } = getCredentials();
|
|
9
9
|
|
|
10
|
+
if (!ak || !sk) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'Huawei Cloud credentials are not configured. ' +
|
|
13
|
+
'Run "npx huaweicloud-devkit auth init" or set HW_ACCESS_KEY/HW_SECRET_KEY.',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
10
16
|
const headers = {
|
|
11
17
|
'Content-Type': 'application/json',
|
|
12
18
|
'X-HW-AK': ak,
|
|
@@ -49,10 +55,12 @@ async function hdkitRequest(method, path, body, timeoutMs = 300000) {
|
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
if (!resp.ok) {
|
|
52
|
-
const
|
|
53
|
-
|
|
58
|
+
const code = data.code || `HTTP_${resp.status}`;
|
|
59
|
+
const trace = data.traceId ? ` [trace: ${data.traceId}]` : '';
|
|
60
|
+
const err = new Error(`${code}: ${data.message || 'hdkitservice error'}${trace}`);
|
|
61
|
+
err.code = code;
|
|
54
62
|
err.status = resp.status;
|
|
55
|
-
err.traceId = data.traceId;
|
|
63
|
+
err.traceId = data.traceId;
|
|
56
64
|
throw err;
|
|
57
65
|
}
|
|
58
66
|
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { spawn, execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { createConnection as netConnect } from 'node:net';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
statSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
createReadStream,
|
|
11
|
+
appendFileSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
} from 'node:fs';
|
|
5
14
|
import { createHash, randomBytes } from 'node:crypto';
|
|
6
15
|
import { join, dirname, basename } from 'node:path';
|
|
7
16
|
import { tmpdir } from 'node:os';
|
|
@@ -155,7 +164,9 @@ export async function execWithSession(workspaceId, command, username, timeoutMs)
|
|
|
155
164
|
|
|
156
165
|
export const UPLOAD_CHUNK_SIZE = 30000;
|
|
157
166
|
|
|
158
|
-
export const UPLOAD_BATCH_SIZE =
|
|
167
|
+
export const UPLOAD_BATCH_SIZE = 2;
|
|
168
|
+
|
|
169
|
+
export const UPLOAD_MAX_RETRIES = 3;
|
|
159
170
|
|
|
160
171
|
export function splitBase64Chunks(base64, chunkSize = UPLOAD_CHUNK_SIZE) {
|
|
161
172
|
const chunks = [];
|
|
@@ -189,11 +200,21 @@ export async function uploadFileWithSession(workspaceId, localPath, remotePath,
|
|
|
189
200
|
const batchNum = Math.floor(batchStart / UPLOAD_BATCH_SIZE) + 1;
|
|
190
201
|
const totalBatches = Math.ceil(chunks.length / UPLOAD_BATCH_SIZE);
|
|
191
202
|
const cmd = `printf '%s' '${combinedChunk}' >> "${tmp}"`;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
);
|
|
203
|
+
|
|
204
|
+
let batchOk = false;
|
|
205
|
+
let lastError;
|
|
206
|
+
for (let retry = 0; retry < UPLOAD_MAX_RETRIES; retry++) {
|
|
207
|
+
const res = await execWithSession(workspaceId, cmd, username, timeoutMs);
|
|
208
|
+
if (res.exitCode === 0) {
|
|
209
|
+
batchOk = true;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
lastError = res.stdout || res.error || res.exitCode;
|
|
213
|
+
console.error(` upload retry ${retry + 1}/${UPLOAD_MAX_RETRIES} for batch ${batchNum}/${totalBatches}`);
|
|
214
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
215
|
+
}
|
|
216
|
+
if (!batchOk) {
|
|
217
|
+
throw new Error(`sandbox upload: failed writing batch ${batchNum}/${totalBatches}: ${lastError}`);
|
|
197
218
|
}
|
|
198
219
|
if (batchNum % 10 === 0 || batchNum === totalBatches) {
|
|
199
220
|
console.error(` upload progress: batch ${batchNum}/${totalBatches}`);
|
|
@@ -257,6 +278,17 @@ function uploadLog(message) {
|
|
|
257
278
|
} catch {}
|
|
258
279
|
}
|
|
259
280
|
|
|
281
|
+
function rotateUploadLog(maxBytes = 100 * 1024) {
|
|
282
|
+
try {
|
|
283
|
+
if (existsSync(UPLOAD_LOG_PATH)) {
|
|
284
|
+
const stat = statSync(UPLOAD_LOG_PATH);
|
|
285
|
+
if (stat.size > maxBytes) {
|
|
286
|
+
unlinkSync(UPLOAD_LOG_PATH);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch {}
|
|
290
|
+
}
|
|
291
|
+
|
|
260
292
|
function generateUploadToken() {
|
|
261
293
|
return randomBytes(16).toString('hex');
|
|
262
294
|
}
|
|
@@ -267,15 +299,34 @@ async function createTarGz(localDir, exclude = []) {
|
|
|
267
299
|
mkdirSync(archiveDir, { recursive: true });
|
|
268
300
|
const archivePath = join(archiveDir, archiveName);
|
|
269
301
|
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
'
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
302
|
+
const hasGit = existsSync(join(localDir, '.git'));
|
|
303
|
+
if (hasGit) {
|
|
304
|
+
await execFileAsync('git', [
|
|
305
|
+
'-C',
|
|
306
|
+
localDir,
|
|
307
|
+
'archive',
|
|
308
|
+
'--format=tar.gz',
|
|
309
|
+
`--prefix=${basename(localDir)}/`,
|
|
310
|
+
`--output=${archivePath}`,
|
|
311
|
+
'HEAD',
|
|
312
|
+
]);
|
|
313
|
+
} else {
|
|
314
|
+
const args = [];
|
|
315
|
+
for (const pattern of exclude) {
|
|
316
|
+
if (pattern.startsWith('**/')) {
|
|
317
|
+
const base = pattern.slice(3);
|
|
318
|
+
for (let depth = 0; depth <= 4; depth++) {
|
|
319
|
+
const prefix = depth === 0 ? '' : '*/'.repeat(depth);
|
|
320
|
+
args.push('--exclude', `${prefix}${base}`);
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
args.push('--exclude', pattern);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
args.push('-czf', archivePath, '-C', dirname(localDir), basename(localDir));
|
|
327
|
+
await execFileAsync('tar', args);
|
|
328
|
+
}
|
|
329
|
+
|
|
279
330
|
return archivePath;
|
|
280
331
|
}
|
|
281
332
|
|
|
@@ -545,6 +596,8 @@ export async function uploadProjectWithSession(
|
|
|
545
596
|
throw new Error(`sandbox upload project: path is not a directory: ${localDir}`);
|
|
546
597
|
}
|
|
547
598
|
|
|
599
|
+
rotateUploadLog();
|
|
600
|
+
|
|
548
601
|
const projectName = basename(localDir);
|
|
549
602
|
const targetParentDir = remoteDir || '/workspace';
|
|
550
603
|
const archiveRemotePath = `${targetParentDir}/${projectName}.tar.gz`;
|
|
@@ -555,15 +608,36 @@ export async function uploadProjectWithSession(
|
|
|
555
608
|
|
|
556
609
|
uploadLog(`uploadProject: ${localDir} -> ${archiveRemotePath} (archive=${archiveSize} bytes, md5=${expectedMd5})`);
|
|
557
610
|
|
|
611
|
+
const SIZE_50MB = 50 * 1024 * 1024;
|
|
612
|
+
if (archiveSize > SIZE_50MB) {
|
|
613
|
+
uploadLog(
|
|
614
|
+
`uploadProject: archive size ${(archiveSize / (1024 * 1024)).toFixed(1)}MB exceeds 50MB. ` +
|
|
615
|
+
`Dependencies or platform binaries may have been included. ` +
|
|
616
|
+
`Ensure exclude list contains "**/node_modules" to match all nesting levels.`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
558
620
|
let result;
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
621
|
+
let tunnelError;
|
|
622
|
+
for (let attempt = 0; attempt < UPLOAD_MAX_RETRIES; attempt++) {
|
|
623
|
+
try {
|
|
624
|
+
await cleanupFileServer(workspaceId, username).catch(() => {});
|
|
625
|
+
result = await uploadViaHttpTunnel(workspaceId, archivePath, archiveRemotePath, username, timeoutMs, options);
|
|
626
|
+
tunnelError = null;
|
|
627
|
+
break;
|
|
628
|
+
} catch (error) {
|
|
629
|
+
tunnelError = error;
|
|
630
|
+
const errorType = error.code || error.name || 'unknown';
|
|
631
|
+
uploadLog(`uploadProject: attempt ${attempt + 1}/${UPLOAD_MAX_RETRIES} failed [${errorType}]: ${error.message}`);
|
|
632
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (tunnelError) {
|
|
636
|
+
uploadLog(`uploadProject: all ${UPLOAD_MAX_RETRIES} attempts failed: ${tunnelError.message}`);
|
|
563
637
|
uploadLog(`uploadProject: NOT falling back to base64 (removed). Rethrowing with diagnostics.`);
|
|
564
638
|
cleanupLocalArchive(archivePath);
|
|
565
639
|
throw new Error(
|
|
566
|
-
`sandbox upload failed: HTTP tunnel could not transfer the project archive. ` +
|
|
640
|
+
`sandbox upload failed after ${UPLOAD_MAX_RETRIES} attempts: HTTP tunnel could not transfer the project archive. ` +
|
|
567
641
|
`Archive size: ${(archiveSize / 1024).toFixed(1)}KB. ` +
|
|
568
642
|
`Root cause: ${tunnelError.message}. ` +
|
|
569
643
|
`Diagnostic log: ${UPLOAD_LOG_PATH}`,
|
|
@@ -582,6 +656,22 @@ export async function uploadProjectWithSession(
|
|
|
582
656
|
username,
|
|
583
657
|
timeoutMs,
|
|
584
658
|
);
|
|
659
|
+
try {
|
|
660
|
+
await execWithSession(
|
|
661
|
+
workspaceId,
|
|
662
|
+
[
|
|
663
|
+
`REAL_PATH=$(readlink -f "${targetParentDir}/${projectName}" 2>/dev/null || echo "${targetParentDir}/${projectName}")`,
|
|
664
|
+
`if [ ! -d "$REAL_PATH" ] && [ -d "${targetParentDir}" ]; then`,
|
|
665
|
+
` REAL_PATH="${targetParentDir}/${projectName}"`,
|
|
666
|
+
`fi`,
|
|
667
|
+
`chmod -R o+rX "$REAL_PATH" 2>/dev/null || true`,
|
|
668
|
+
`find "$REAL_PATH" -type d -exec chmod o+x {} \\; 2>/dev/null || true`,
|
|
669
|
+
`find "$REAL_PATH" -type f -path "*/node_modules/.bin/*" -exec chmod +x {} \\; 2>/dev/null || true`,
|
|
670
|
+
].join('\n'),
|
|
671
|
+
username,
|
|
672
|
+
15000,
|
|
673
|
+
);
|
|
674
|
+
} catch {}
|
|
585
675
|
}
|
|
586
676
|
|
|
587
677
|
try {
|
|
@@ -600,6 +690,331 @@ export async function uploadProjectWithSession(
|
|
|
600
690
|
};
|
|
601
691
|
}
|
|
602
692
|
|
|
693
|
+
export async function deployNginx(
|
|
694
|
+
workspaceId,
|
|
695
|
+
{ nginxType, port, project, outputDir, nodePort, publicPort, configName },
|
|
696
|
+
username = 'root',
|
|
697
|
+
timeoutMs = 60000,
|
|
698
|
+
) {
|
|
699
|
+
if (!workspaceId) {
|
|
700
|
+
throw new Error('sandbox deploy nginx: workspace_id is required.');
|
|
701
|
+
}
|
|
702
|
+
if (!nginxType || !port || !project || !outputDir) {
|
|
703
|
+
throw new Error('sandbox deploy nginx: nginxType, port, project, and outputDir are required.');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const nginxCheck = await execOneShot(
|
|
707
|
+
workspaceId,
|
|
708
|
+
'command -v nginx >/dev/null 2>&1 && echo "INSTALLED" || echo "MISSING"',
|
|
709
|
+
username,
|
|
710
|
+
10000,
|
|
711
|
+
);
|
|
712
|
+
if (!String(nginxCheck.stdout || '').includes('INSTALLED')) {
|
|
713
|
+
throw new Error(
|
|
714
|
+
'sandbox deploy nginx: nginx is not installed. Install it first:\n' +
|
|
715
|
+
' Detect OS: source /etc/os-release && echo $ID\n' +
|
|
716
|
+
' apt: sudo apt-get update -qq && sudo apt-get install -y -qq nginx\n' +
|
|
717
|
+
' yum: sudo yum install -y nginx\n' +
|
|
718
|
+
' dnf: sudo dnf install -y nginx\n' +
|
|
719
|
+
'Alternatively, skip nginx and use Python HTTP server (see nginx-templates.md).',
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const listenPort = publicPort || port;
|
|
724
|
+
const basePort = nginxType === 'proxy' ? listenPort : port;
|
|
725
|
+
|
|
726
|
+
let targetPort = basePort;
|
|
727
|
+
let portWarning;
|
|
728
|
+
const maxPortAttempts = 10;
|
|
729
|
+
for (let offset = 0; offset < maxPortAttempts; offset += 1) {
|
|
730
|
+
targetPort = basePort + offset;
|
|
731
|
+
try {
|
|
732
|
+
const portCheck = await execOneShot(
|
|
733
|
+
workspaceId,
|
|
734
|
+
`ss -tlnp 2>/dev/null | grep -q ":${targetPort} " && echo "IN_USE" || echo "FREE"`,
|
|
735
|
+
username,
|
|
736
|
+
10000,
|
|
737
|
+
);
|
|
738
|
+
if (!String(portCheck.stdout || '').includes('IN_USE')) break;
|
|
739
|
+
if (offset === 0) {
|
|
740
|
+
portWarning = `Port ${basePort} is in use — auto-assigned port ${targetPort}`;
|
|
741
|
+
}
|
|
742
|
+
} catch {}
|
|
743
|
+
if (offset === maxPortAttempts - 1) {
|
|
744
|
+
throw new Error(
|
|
745
|
+
`sandbox deploy nginx: all ports ${basePort}-${basePort + maxPortAttempts - 1} are in use. Free a port and try again.`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const effectiveNodePort =
|
|
751
|
+
nginxType === 'proxy' ? (nodePort && nodePort !== listenPort ? nodePort : listenPort + 1) : undefined;
|
|
752
|
+
|
|
753
|
+
const projectPath = `/workspace/${project}`;
|
|
754
|
+
const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
|
|
755
|
+
|
|
756
|
+
const resolveScript = `REAL_PROJECT=$(readlink -f "${projectPath}" 2>/dev/null || echo "${projectPath}")
|
|
757
|
+
REAL_OUTPUT="${outputPath}"
|
|
758
|
+
if [ "\${REAL_PROJECT}" != "${projectPath}" ]; then
|
|
759
|
+
REL_OUTPUT=$(echo "${outputDir}" | sed "s|${projectPath}/||")
|
|
760
|
+
REAL_OUTPUT="\${REAL_PROJECT}/\${REL_OUTPUT}"
|
|
761
|
+
fi`;
|
|
762
|
+
|
|
763
|
+
const templates = {
|
|
764
|
+
spa: `server {
|
|
765
|
+
listen ${targetPort};
|
|
766
|
+
root ${outputPath};
|
|
767
|
+
index index.html;
|
|
768
|
+
|
|
769
|
+
location / {
|
|
770
|
+
try_files $uri /index.html;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
774
|
+
expires 1h;
|
|
775
|
+
add_header Cache-Control "public, immutable";
|
|
776
|
+
}
|
|
777
|
+
}`,
|
|
778
|
+
proxy: `server {
|
|
779
|
+
listen ${listenPort};
|
|
780
|
+
server_name _;
|
|
781
|
+
large_client_header_buffers 4 32k;
|
|
782
|
+
|
|
783
|
+
location / {
|
|
784
|
+
proxy_pass http://127.0.0.1:${effectiveNodePort};
|
|
785
|
+
proxy_http_version 1.1;
|
|
786
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
787
|
+
proxy_set_header Connection 'upgrade';
|
|
788
|
+
proxy_set_header Host $host;
|
|
789
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
790
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
791
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
792
|
+
proxy_cache_bypass $http_upgrade;
|
|
793
|
+
proxy_read_timeout 60s;
|
|
794
|
+
proxy_buffer_size 128k;
|
|
795
|
+
proxy_buffers 4 256k;
|
|
796
|
+
proxy_busy_buffers_size 256k;
|
|
797
|
+
}
|
|
798
|
+
}`,
|
|
799
|
+
static: `server {
|
|
800
|
+
listen ${targetPort};
|
|
801
|
+
root ${outputPath};
|
|
802
|
+
index index.html;
|
|
803
|
+
|
|
804
|
+
location / {
|
|
805
|
+
autoindex off;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
809
|
+
expires 1h;
|
|
810
|
+
add_header Cache-Control "public, immutable";
|
|
811
|
+
}
|
|
812
|
+
}`,
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
const config = templates[nginxType];
|
|
816
|
+
if (!config) {
|
|
817
|
+
throw new Error(`sandbox deploy nginx: unknown nginxType "${nginxType}". Must be one of: spa, proxy, static`);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const cmd = [
|
|
821
|
+
resolveScript,
|
|
822
|
+
`sudo mkdir -p /etc/nginx/conf.d`,
|
|
823
|
+
`sudo tee /etc/nginx/conf.d/${configName || project}.conf > /dev/null << 'NGINX_EOF'`,
|
|
824
|
+
config,
|
|
825
|
+
`NGINX_EOF`,
|
|
826
|
+
`# Resolve symlinks for chmod (chmod does not follow symlinks on Linux)`,
|
|
827
|
+
`chmod -R o+rX "$REAL_PROJECT" 2>/dev/null || true`,
|
|
828
|
+
`find "$REAL_PROJECT" -type d -exec chmod o+x {} \\; 2>/dev/null || true`,
|
|
829
|
+
`find "$REAL_PROJECT" -type f -path "*/node_modules/.bin/*" -exec chmod +x {} \\; 2>/dev/null || true`,
|
|
830
|
+
`if pgrep -x nginx > /dev/null 2>&1; then sudo nginx -s reload 2>/dev/null || { sudo killall -9 nginx 2>/dev/null; sleep 1; sudo nginx; }; else sudo nginx; fi`,
|
|
831
|
+
].join('\n');
|
|
832
|
+
|
|
833
|
+
const result = await execOneShot(workspaceId, cmd, username, timeoutMs);
|
|
834
|
+
|
|
835
|
+
let tunnelActive = false;
|
|
836
|
+
try {
|
|
837
|
+
const tunnelCheck = await execOneShot(
|
|
838
|
+
workspaceId,
|
|
839
|
+
'devbridge list -j 2>/dev/null | grep -q \'"tunnelId"\' && echo "ACTIVE" || echo "INACTIVE"',
|
|
840
|
+
username,
|
|
841
|
+
10000,
|
|
842
|
+
);
|
|
843
|
+
tunnelActive = String(tunnelCheck.stdout || '').includes('ACTIVE');
|
|
844
|
+
} catch {}
|
|
845
|
+
|
|
846
|
+
return {
|
|
847
|
+
ok: result.exitCode === 0,
|
|
848
|
+
nginxType,
|
|
849
|
+
port: targetPort,
|
|
850
|
+
nodePort: effectiveNodePort || undefined,
|
|
851
|
+
outputPath,
|
|
852
|
+
projectPath,
|
|
853
|
+
exitCode: result.exitCode,
|
|
854
|
+
stdout: result.stdout,
|
|
855
|
+
nextStep: 'expose_via_devbridge',
|
|
856
|
+
warning:
|
|
857
|
+
(!tunnelActive
|
|
858
|
+
? 'No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.'
|
|
859
|
+
: portWarning) || undefined,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export async function deployCheck(
|
|
864
|
+
workspaceId,
|
|
865
|
+
{ port, project, outputDir, frameworkType },
|
|
866
|
+
username = 'root',
|
|
867
|
+
timeoutMs = 30000,
|
|
868
|
+
) {
|
|
869
|
+
if (!workspaceId) {
|
|
870
|
+
throw new Error('sandbox deploy check: workspace_id is required.');
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const projectPath = `/workspace/${project}`;
|
|
874
|
+
const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
|
|
875
|
+
const isCrossPlatform = frameworkType === 'cross-platform';
|
|
876
|
+
|
|
877
|
+
const checkScript = [
|
|
878
|
+
`echo "=== DEPLOY CHECK ==="`,
|
|
879
|
+
`PASS=0`,
|
|
880
|
+
`TOTAL=0`,
|
|
881
|
+
``,
|
|
882
|
+
`TOTAL=$((TOTAL+1))`,
|
|
883
|
+
`if curl -s -o /dev/null -w "%{http_code}" http://localhost:${port} 2>/dev/null | grep -qE "^(2|3)"; then`,
|
|
884
|
+
` echo "nginx_serving:PASS (port ${port})"`,
|
|
885
|
+
` PASS=$((PASS+1))`,
|
|
886
|
+
`else`,
|
|
887
|
+
` echo "nginx_serving:FAIL"`,
|
|
888
|
+
`fi`,
|
|
889
|
+
``,
|
|
890
|
+
`TOTAL=$((TOTAL+1))`,
|
|
891
|
+
`if [ -d "${outputPath}" ] && ls -A "${outputPath}" 2>/dev/null | grep -q .; then`,
|
|
892
|
+
` echo "output_dir:PASS (${outputPath})"`,
|
|
893
|
+
` PASS=$((PASS+1))`,
|
|
894
|
+
`else`,
|
|
895
|
+
` echo "output_dir:FAIL (${outputPath} empty or missing)"`,
|
|
896
|
+
`fi`,
|
|
897
|
+
``,
|
|
898
|
+
`TOTAL=$((TOTAL+1))`,
|
|
899
|
+
`FINGERPRINT_FILE="${outputPath}/.deploy_fingerprint"`,
|
|
900
|
+
`FINGERPRINT_EXPECTED=$(cat "$FINGERPRINT_FILE" 2>/dev/null)`,
|
|
901
|
+
`if [ -n "$FINGERPRINT_EXPECTED" ]; then`,
|
|
902
|
+
` FINGERPRINT_ACTUAL=$(curl -s http://localhost:${port}/.deploy_fingerprint 2>/dev/null)`,
|
|
903
|
+
` if [ "$FINGERPRINT_EXPECTED" = "$FINGERPRINT_ACTUAL" ]; then`,
|
|
904
|
+
` echo "content_verified:PASS"`,
|
|
905
|
+
` PASS=$((PASS+1))`,
|
|
906
|
+
` else`,
|
|
907
|
+
` echo "content_verified:FAIL (fingerprint mismatch — nginx may be serving stale content from a previous deployment)"`,
|
|
908
|
+
` fi`,
|
|
909
|
+
`else`,
|
|
910
|
+
` echo "content_verified:SKIP (no fingerprint file)"`,
|
|
911
|
+
` TOTAL=$((TOTAL-1))`,
|
|
912
|
+
`fi`,
|
|
913
|
+
``,
|
|
914
|
+
`TOTAL=$((TOTAL+1))`,
|
|
915
|
+
`if devbridge list -j 2>/dev/null | grep -q '"tunnelId"'; then`,
|
|
916
|
+
` echo "devbridge_tunnel:PASS"`,
|
|
917
|
+
` PASS=$((PASS+1))`,
|
|
918
|
+
`else`,
|
|
919
|
+
` echo "devbridge_tunnel:FAIL"`,
|
|
920
|
+
`fi`,
|
|
921
|
+
``,
|
|
922
|
+
`TOTAL=$((TOTAL+1))`,
|
|
923
|
+
`TUNNEL_ID=$(devbridge list -j 2>/dev/null | grep -oP '"tunnelId":\\s*"\\K[^"]+' | head -1)`,
|
|
924
|
+
`TUNNEL_URL="https://\${TUNNEL_ID}-${port}.cn-north-4-bridge.myhuaweicloud.com"`,
|
|
925
|
+
`if [ -n "$TUNNEL_ID" ] && [ -n "$TUNNEL_URL" ]; then`,
|
|
926
|
+
` HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
|
|
927
|
+
` if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "304" ]; then`,
|
|
928
|
+
` echo "tunnel_url_accessible:PASS ($TUNNEL_URL -> $HTTP_CODE)"`,
|
|
929
|
+
` PASS=$((PASS+1))`,
|
|
930
|
+
` else`,
|
|
931
|
+
` echo "tunnel_url_accessible:FAIL ($TUNNEL_URL -> HTTP $HTTP_CODE)"`,
|
|
932
|
+
` fi`,
|
|
933
|
+
`else`,
|
|
934
|
+
` echo "tunnel_url_accessible:FAIL (no tunnel URL)"`,
|
|
935
|
+
`fi`,
|
|
936
|
+
``,
|
|
937
|
+
`${`
|
|
938
|
+
TOTAL=$((TOTAL+1))
|
|
939
|
+
if [ -f "${outputPath}/qr.png" ]; then
|
|
940
|
+
echo "qr_code:PASS"
|
|
941
|
+
PASS=$((PASS+1))
|
|
942
|
+
else
|
|
943
|
+
# Auto-detect cross-platform from project files (more reliable than frameworkType param)
|
|
944
|
+
PROJ_DIR="/workspace/${project}"
|
|
945
|
+
IS_CROSS=0
|
|
946
|
+
if [ -f "$PROJ_DIR/manifest.json" ] || grep -qE '"@tarojs/taro"|"@dcloudio/uni-app"' "$PROJ_DIR/package.json" 2>/dev/null; then
|
|
947
|
+
IS_CROSS=1
|
|
948
|
+
fi
|
|
949
|
+
if [ $IS_CROSS -eq 0 ] && ( [ -f "$PROJ_DIR/app.config.ts" ] || [ -f "$PROJ_DIR/app.config.js" ] ) && grep -qE "pages|tabBar" "$PROJ_DIR/app.config."* 2>/dev/null; then
|
|
950
|
+
IS_CROSS=1
|
|
951
|
+
fi
|
|
952
|
+
|
|
953
|
+
if [ $IS_CROSS -eq 1 ]; then
|
|
954
|
+
if [ -n "$TUNNEL_URL" ]; then
|
|
955
|
+
curl -s "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$TUNNEL_URL" 2>/dev/null)" -o "${outputPath}/qr.png" 2>/dev/null
|
|
956
|
+
chmod o+r "${outputPath}/qr.png" 2>/dev/null || true
|
|
957
|
+
if [ -f "${outputPath}/qr.png" ] && [ -s "${outputPath}/qr.png" ]; then
|
|
958
|
+
echo "qr_code:PASS (auto-generated)"
|
|
959
|
+
PASS=$((PASS+1))
|
|
960
|
+
else
|
|
961
|
+
echo "qr_code:FAIL (QR generation failed)"
|
|
962
|
+
fi
|
|
963
|
+
else
|
|
964
|
+
echo "qr_code:FAIL (no tunnel URL for QR generation)"
|
|
965
|
+
fi
|
|
966
|
+
else
|
|
967
|
+
echo "qr_code:SKIP (not a cross-platform project)"
|
|
968
|
+
TOTAL=$((TOTAL-1))
|
|
969
|
+
fi
|
|
970
|
+
fi
|
|
971
|
+
`}`,
|
|
972
|
+
`echo "SCORE:\${PASS}/\${TOTAL}"`,
|
|
973
|
+
`echo "TUNNEL_URL:\${TUNNEL_URL:-}"`,
|
|
974
|
+
`[ "\${PASS}" = "\${TOTAL}" ] && echo "VERDICT:COMPLETE" || echo "VERDICT:INCOMPLETE"`,
|
|
975
|
+
]
|
|
976
|
+
.filter(Boolean)
|
|
977
|
+
.join('\n');
|
|
978
|
+
|
|
979
|
+
const result = await execOneShot(workspaceId, checkScript, username, timeoutMs);
|
|
980
|
+
const stdout = String(result.stdout || '');
|
|
981
|
+
const checks = {};
|
|
982
|
+
const lines = stdout.split('\n');
|
|
983
|
+
for (const line of lines) {
|
|
984
|
+
const m = line.match(/^(\w+):(\w+)\b(.*)/);
|
|
985
|
+
if (m) checks[m[1]] = { status: m[2], detail: (m[3] || '').trim() };
|
|
986
|
+
}
|
|
987
|
+
const scoreMatch = stdout.match(/SCORE:(\d+)\/(\d+)/);
|
|
988
|
+
const tunnelMatch = stdout.match(/TUNNEL_URL:(https:\/\/[^\s]+)/);
|
|
989
|
+
const complete = /VERDICT:COMPLETE/.test(stdout);
|
|
990
|
+
|
|
991
|
+
const missing = [];
|
|
992
|
+
if (!complete) {
|
|
993
|
+
for (const [key, val] of Object.entries(checks)) {
|
|
994
|
+
if (val.status === 'FAIL') missing.push(key);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
return {
|
|
999
|
+
ok: true,
|
|
1000
|
+
complete,
|
|
1001
|
+
checkType: isCrossPlatform ? 'cross-platform' : 'standard',
|
|
1002
|
+
checks,
|
|
1003
|
+
score: scoreMatch ? { pass: parseInt(scoreMatch[1], 10), total: parseInt(scoreMatch[2], 10) } : null,
|
|
1004
|
+
publicUrl: tunnelMatch ? tunnelMatch[1] : undefined,
|
|
1005
|
+
missingSteps: missing.length > 0 ? missing.join(', ') : undefined,
|
|
1006
|
+
nextStep: !complete
|
|
1007
|
+
? missing.includes('devbridge_tunnel') || missing.includes('tunnel_url_accessible')
|
|
1008
|
+
? 'expose_via_devbridge'
|
|
1009
|
+
: missing.includes('nginx_serving')
|
|
1010
|
+
? 'configure_nginx'
|
|
1011
|
+
: missing.includes('qr_code')
|
|
1012
|
+
? 'generate_qr_code'
|
|
1013
|
+
: 'review_checks'
|
|
1014
|
+
: 'complete',
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
603
1018
|
export async function closeSession(workspaceId, username) {
|
|
604
1019
|
const key = `${workspaceId}:${username}`;
|
|
605
1020
|
const session = sessions.get(key);
|