huaweicloud-devkit 1.1.8-next.0 → 1.1.8-next.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/package.json +1 -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/openclaw.plugin.json +1 -1
- package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +3 -3
- package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +8 -1
- package/plugins/huaweicloud-core/src/data/icons-manifest.v1.json +23 -5
- package/plugins/huaweicloud-core/src/hcloud-cli.mjs +32 -1
- package/plugins/huaweicloud-core/src/hcloud-probe.mjs +10 -2
- package/plugins/huaweicloud-core/src/mcp-server.mjs +9 -0
- package/plugins/huaweicloud-core/src/preflight.mjs +125 -0
- package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +33 -17
- package/plugins/huaweicloud-core/src/setup-cli.mjs +23 -17
- package/plugins/huaweicloud-core/src/tools.mjs +41 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "huaweicloud-devkit",
|
|
3
3
|
"mcpName": "io.github.huaweicloud/huaweicloud-devkit",
|
|
4
|
-
"version": "1.1.8-next.
|
|
4
|
+
"version": "1.1.8-next.1",
|
|
5
5
|
"kooCliVersion": "7.2.12",
|
|
6
6
|
"description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
|
|
7
7
|
"type": "module",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"mcpServers": "./.mcp.json",
|
|
18
18
|
"description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
|
|
19
19
|
"skills": "./skills/",
|
|
20
|
-
"version": "1.1.8-next.
|
|
20
|
+
"version": "1.1.8-next.1",
|
|
21
21
|
"author": {
|
|
22
22
|
"name": "HuaweiCloud Mate",
|
|
23
23
|
"url": "https://github.com/huaweicloud"
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"mcpServers": "./.mcp.json",
|
|
21
21
|
"description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
|
|
22
22
|
"skills": "./skills/",
|
|
23
|
-
"version": "1.1.8-next.
|
|
23
|
+
"version": "1.1.8-next.1",
|
|
24
24
|
"author": {
|
|
25
25
|
"name": "HuaweiCloud Mate",
|
|
26
26
|
"url": "https://github.com/huaweicloud"
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"mcpServers": "./.mcp.json",
|
|
18
18
|
"description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
|
|
19
19
|
"skills": "./skills/",
|
|
20
|
-
"version": "1.1.8-next.
|
|
20
|
+
"version": "1.1.8-next.1",
|
|
21
21
|
"author": {
|
|
22
22
|
"name": "HuaweiCloud Mate",
|
|
23
23
|
"url": "https://github.com/huaweicloud"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "huaweicloud-devkit",
|
|
3
|
-
"version": "1.1.8-next.
|
|
3
|
+
"version": "1.1.8-next.1",
|
|
4
4
|
"description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "HuaweiCloud Mate",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "huaweicloud-devkit",
|
|
3
|
-
"version": "1.1.8-next.
|
|
3
|
+
"version": "1.1.8-next.1",
|
|
4
4
|
"description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "HuaweiCloud Mate",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "huaweicloud-devkit",
|
|
3
3
|
"id": "huaweicloud-devkit",
|
|
4
4
|
"displayName": "HuaweiCloud DevKit",
|
|
5
|
-
"version": "1.1.8-next.
|
|
5
|
+
"version": "1.1.8-next.1",
|
|
6
6
|
"family": "bundle-plugin",
|
|
7
7
|
"bundleFormat": "codex",
|
|
8
8
|
"description": "Guide coding agents to use Huawei Cloud safely — KooCLI, APIs, SDKs, 28 MCP tools, skills, and safety guardrails.",
|
|
@@ -785,7 +785,7 @@ check_port <nodePort>
|
|
|
785
785
|
| SPA/SSG/Cross-platform | nginx port (from `framework.detect`) | Kill old process, then configure nginx |
|
|
786
786
|
| SSR | nginx public port + Node app port | Kill old processes on both ports |
|
|
787
787
|
|
|
788
|
-
If the port cannot be freed (different user/process),
|
|
788
|
+
If the port cannot be freed (different user/process), the `deploy_nginx` tool auto-increments to the next available port (up to 10 attempts). For SSR (proxy) mode, nginx listens on the resolved `targetPort`, and the Node app follows the returned `nodePort` field — bind the Node process to the `nodePort` that `deploy_nginx` returns, and update all subsequent nginx config and DevBridge references accordingly.
|
|
789
789
|
|
|
790
790
|
#### Configure Nginx
|
|
791
791
|
|
|
@@ -806,7 +806,7 @@ Use `huaweicloud_sandbox_deploy_nginx` to write the correct template, fix direct
|
|
|
806
806
|
- `port` — listen port from framework detection
|
|
807
807
|
- `project` — project dir name under `/workspace`
|
|
808
808
|
- `output_dir` — build output dir relative to `/workspace/<project>`
|
|
809
|
-
- `node_port` — Node.js app port for SSR proxy.
|
|
809
|
+
- `node_port` — Node.js app port for SSR proxy. When omitted it defaults to the resolved nginx listen port plus one (`targetPort + 1`), and the result includes `nodePort` so you know which port to bind the Node process to. When the nginx port auto-increments, `nodePort` follows it.
|
|
810
810
|
- `public_port` — public listen port for SSR proxy (optional, defaults to `port`)
|
|
811
811
|
|
|
812
812
|
The tool automatically handles:
|
|
@@ -834,7 +834,7 @@ If the status code is not 2xx/3xx:
|
|
|
834
834
|
### Step 6: Start the App [REQUIRED]
|
|
835
835
|
|
|
836
836
|
- **Static (SPA/SSG/cross-platform)**: nginx is already serving. Skip.
|
|
837
|
-
- **SSR**: `PORT=<nodePort>` prefix is REQUIRED before `<serveCmd>`. nginx `proxy_pass` targets `<nodePort>`, not `<port>` — the two must differ. `deployNginx` returns `nodePort` in its result (defaults to
|
|
837
|
+
- **SSR**: `PORT=<nodePort>` prefix is REQUIRED before `<serveCmd>`. nginx `proxy_pass` targets `<nodePort>`, not `<port>` — the two must differ. `deployNginx` returns `nodePort` in its result (defaults to the resolved `targetPort + 1` for proxy type). Always use the returned `port`/`nodePort` values — they follow any auto-increment.
|
|
838
838
|
|
|
839
839
|
**Runtime environment variables**: SSR apps often need `DATABASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, etc. at runtime. Before starting, verify env vars from Step 4a are still loaded, and re-source `.env` if the shell session was reset:
|
|
840
840
|
|
|
@@ -65,7 +65,14 @@ One-liner (installs `latest`, may drift from v7.2.12):
|
|
|
65
65
|
curl -sSL https://cn-north-4-hdn-koocli.obs.cn-north-4.myhuaweicloud.com/cli/latest/hcloud_install.sh -o ./hcloud_install.sh && bash ./hcloud_install.sh -y
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
**No restart needed after install**: DevKit locates `hcloud` dynamically on every
|
|
69
|
+
call — it checks `HCLOUD_BIN`, then the fixed install dirs (`~/.local/bin/hcloud`
|
|
70
|
+
on Linux, `/usr/local/bin/hcloud` on macOS, `~/hcloud/hcloud.exe` on Windows),
|
|
71
|
+
then PATH. Once `install-hcloud` or the background auto-install writes the binary
|
|
72
|
+
to one of those dirs, the very next `check_cli` / `run_*` call uses it. Do not
|
|
73
|
+
tell users to restart the agent.
|
|
74
|
+
|
|
75
|
+
Agent processes find executables through `PATH`; `HCLOUD_BIN` overrides it. DevKit re-locates hcloud from its **fixed install dirs** on every call, so a freshly installed binary (`~/.local/bin` on Linux, `/usr/local/bin` on macOS, `~/hcloud` on Windows) or a re-set `HCLOUD_BIN` is picked up by the next `check_cli` / `run_*` call without restarting the agent. A custom `PATH` entry is only honored via `HCLOUD_BIN` or the PATH locator fallback, and shell `PATH` changes take effect only for processes started after the update.
|
|
69
76
|
|
|
70
77
|
## Configure Credentials Outside Chat
|
|
71
78
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"icons": [
|
|
6
6
|
{
|
|
7
7
|
"id": "codeartsdoer",
|
|
8
|
-
"name": "
|
|
8
|
+
"name": "华为云码道CodeArts代码智能体",
|
|
9
9
|
"category": "人工智能",
|
|
10
10
|
"subcategory": "智能体",
|
|
11
11
|
"description": "更懂你编码需求的AI研发专家",
|
|
@@ -41,13 +41,13 @@
|
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"id": "modelarts",
|
|
44
|
-
"name": "魔坊(ModelArts
|
|
44
|
+
"name": "魔坊(ModelArts)",
|
|
45
45
|
"category": "人工智能",
|
|
46
46
|
"subcategory": "模型开发平台",
|
|
47
47
|
"description": "“数-训-推-强”,一站式AI开发平台",
|
|
48
48
|
"product_url": "https://www.huaweicloud.com/product/modelarts.html",
|
|
49
49
|
"logo": {
|
|
50
|
-
"source_url": "https://res-static.hc-cdn.cn/cloudbu-site/public/
|
|
50
|
+
"source_url": "https://res-static.hc-cdn.cn/cloudbu-site/public/%E6%96%B0%E7%89%88/CF057503-AE1E-48CA-E8C1-1919F51A1261.png",
|
|
51
51
|
"local_path": "icons/assets/logo/modelarts.png"
|
|
52
52
|
},
|
|
53
53
|
"architecture": {
|
|
@@ -155,8 +155,8 @@
|
|
|
155
155
|
"description": "高效敏捷安全的一站式企业级智能体平台",
|
|
156
156
|
"product_url": "https://www.huaweicloud.com/product/agentarts.html",
|
|
157
157
|
"logo": {
|
|
158
|
-
"source_url": "https://res-static.hc-cdn.cn/cloudbu-site/
|
|
159
|
-
"local_path": "icons/assets/logo/agentarts.
|
|
158
|
+
"source_url": "https://res-static.hc-cdn.cn/cloudbu-site/china/zh-cn/%E6%99%BA%E6%9E%9CAgentArts/AgentArts-%E5%AE%98%E7%BD%91%E4%BA%A7%E5%93%81%E9%A1%B5%E5%88%87%E5%9B%BE/bannerlogo.png",
|
|
159
|
+
"local_path": "icons/assets/logo/agentarts.png"
|
|
160
160
|
},
|
|
161
161
|
"architecture": {
|
|
162
162
|
"status": "planned",
|
|
@@ -165,6 +165,24 @@
|
|
|
165
165
|
"aliases": ["agentarts"],
|
|
166
166
|
"tags": ["人工智能", "智能体平台", "agentarts"]
|
|
167
167
|
},
|
|
168
|
+
{
|
|
169
|
+
"id": "officeace",
|
|
170
|
+
"name": "果办OfficeAce 办公智能体",
|
|
171
|
+
"category": "人工智能",
|
|
172
|
+
"subcategory": "智能体",
|
|
173
|
+
"description": "专业办公级AI助手,PPT一键生成、专家团研讨,安全高效随时待命",
|
|
174
|
+
"product_url": "https://www.huaweicloud.com/product/agentarts/officeace.html",
|
|
175
|
+
"logo": {
|
|
176
|
+
"source_url": "https://res-static.hc-cdn.cn/cloudbu-site/china/zh-cn/yunying/product/OfficeAce-v2.svg",
|
|
177
|
+
"local_path": "icons/assets/logo/officeace.svg"
|
|
178
|
+
},
|
|
179
|
+
"architecture": {
|
|
180
|
+
"status": "planned",
|
|
181
|
+
"local_path": "icons/assets/architecture/officeace.svg"
|
|
182
|
+
},
|
|
183
|
+
"aliases": ["officeace", "office-ace"],
|
|
184
|
+
"tags": ["人工智能", "智能体", "办公", "officeace"]
|
|
185
|
+
},
|
|
168
186
|
{
|
|
169
187
|
"id": "pangu-nlp",
|
|
170
188
|
"name": "LLM大模型",
|
|
@@ -69,6 +69,17 @@ function pruneStale(map, now = Date.now()) {
|
|
|
69
69
|
return changed;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Approval token lifecycle states, surfaced to callers so they can emit the
|
|
73
|
+
// precise JSON contract required by D4-24 (#745):
|
|
74
|
+
// 'valid' — token exists, not expired, not yet consumed
|
|
75
|
+
// 'expired' — token exists but createdAt is past APPROVAL_TTL_MS
|
|
76
|
+
// 'already_consumed' — token was consumed on a prior call (tombstone kept)
|
|
77
|
+
// 'not_found' — token was never created (unknown / typo / pruned)
|
|
78
|
+
const TOKEN_STATE_VALID = 'valid';
|
|
79
|
+
const TOKEN_STATE_EXPIRED = 'expired';
|
|
80
|
+
const TOKEN_STATE_ALREADY_CONSUMED = 'already_consumed';
|
|
81
|
+
const TOKEN_STATE_NOT_FOUND = 'not_found';
|
|
82
|
+
|
|
72
83
|
export function createApprovalToken(rawArgs) {
|
|
73
84
|
const token = randomUUID();
|
|
74
85
|
const map = readApprovals();
|
|
@@ -82,16 +93,36 @@ export function createApprovalToken(rawArgs) {
|
|
|
82
93
|
return token;
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
// Inspect a token's lifecycle state WITHOUT consuming it. Used by
|
|
97
|
+
// runApprovedCommand to decide between the 'expired' / 'already_consumed'
|
|
98
|
+
// / 'not_found' JSON shapes. A consumed token leaves a tombstone
|
|
99
|
+
// ({ consumedAt }) until it ages out past APPROVAL_TTL_MS.
|
|
100
|
+
export function inspectApprovalToken(token) {
|
|
101
|
+
const map = readApprovals();
|
|
102
|
+
const entry = map[token];
|
|
103
|
+
if (!entry) return { state: TOKEN_STATE_NOT_FOUND };
|
|
104
|
+
if (entry.consumedAt !== undefined) return { state: TOKEN_STATE_ALREADY_CONSUMED };
|
|
105
|
+
if (Date.now() - entry.createdAt > APPROVAL_TTL_MS) return { state: TOKEN_STATE_EXPIRED };
|
|
106
|
+
return { state: TOKEN_STATE_VALID, entry };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Backward-compatible single-use consume. Returns the stored entry on first
|
|
110
|
+
// call, then null. After consumption a tombstone ({ consumedAt }) is written
|
|
111
|
+
// so a second call can be distinguished from an unknown token via
|
|
112
|
+
// inspectApprovalToken. Kept for existing tests that assert null on reuse.
|
|
85
113
|
export function consumeApprovalToken(token) {
|
|
86
114
|
const map = readApprovals();
|
|
87
115
|
const entry = map[token];
|
|
88
116
|
if (!entry) return null;
|
|
117
|
+
if (entry.consumedAt !== undefined) return null;
|
|
89
118
|
if (Date.now() - entry.createdAt > APPROVAL_TTL_MS) {
|
|
90
119
|
delete map[token];
|
|
91
120
|
writeApprovals(map);
|
|
92
121
|
return null;
|
|
93
122
|
}
|
|
94
|
-
|
|
123
|
+
// Mark as consumed (tombstone) instead of deleting outright, so a repeat
|
|
124
|
+
// call can be classified as already_processed rather than not_found.
|
|
125
|
+
map[token] = { consumedAt: Date.now(), createdAt: entry.createdAt };
|
|
95
126
|
writeApprovals(map);
|
|
96
127
|
return entry;
|
|
97
128
|
}
|
|
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
6
|
import { compareVersion, getKooCliVersion, parseHcloudVersion } from './koocli-version.mjs';
|
|
7
|
+
import { kooCliInstallLogPath } from './preflight.mjs';
|
|
7
8
|
import { redactSecrets } from './safety-policy.mjs';
|
|
8
9
|
|
|
9
10
|
const VERSION_RE = /KooCLI|Current.*version|当前KooCLI/i;
|
|
@@ -13,7 +14,11 @@ export function findHcloudBin() {
|
|
|
13
14
|
const candidates =
|
|
14
15
|
process.platform === 'win32'
|
|
15
16
|
? [join(homedir(), 'hcloud', 'hcloud.exe')]
|
|
16
|
-
: [
|
|
17
|
+
: [
|
|
18
|
+
join(homedir(), '.local', 'bin', 'hcloud'),
|
|
19
|
+
join(homedir(), 'hcloud', 'hcloud'),
|
|
20
|
+
join('/', 'usr', 'local', 'bin', 'hcloud'),
|
|
21
|
+
];
|
|
17
22
|
const found = candidates.find((candidate) => existsSync(candidate));
|
|
18
23
|
if (found) return found;
|
|
19
24
|
|
|
@@ -148,7 +153,10 @@ export function hcloudProbeNextStep(probe) {
|
|
|
148
153
|
return 'KooCLI requires accepting its one-time privacy agreement. Run hcloud version in a real terminal and accept the prompt, then restart the agent.';
|
|
149
154
|
}
|
|
150
155
|
if (probe.status === 'not_found') {
|
|
151
|
-
return
|
|
156
|
+
return (
|
|
157
|
+
'KooCLI is not installed yet. Background auto-install may be running — please retry shortly instead of assuming failure. To install now: npx huaweicloud-devkit install-hcloud. DevKit picks up the binary from its fixed install dirs (~/.local/bin on Linux, /usr/local/bin on macOS, ~/hcloud on Windows) or HCLOUD_BIN on the next call — no agent restart needed. Install progress: ' +
|
|
158
|
+
kooCliInstallLogPath()
|
|
159
|
+
);
|
|
152
160
|
}
|
|
153
161
|
return 'Install Huawei Cloud KooCLI: npx huaweicloud-devkit install-hcloud. Configure credentials outside the agent conversation.';
|
|
154
162
|
}
|
|
@@ -9,6 +9,7 @@ import { dispatch } from './mcp-protocol.mjs';
|
|
|
9
9
|
import { DEFAULT_PORT, DEFAULT_HOST } from './mcp-server-remote.mjs';
|
|
10
10
|
import { getCachedUpdateInfo, readInstalledVersion } from './update-check.mjs';
|
|
11
11
|
import { detectAgent } from './telemetry/agent-detect.mjs';
|
|
12
|
+
import { maybePreinstallKooCli } from './preflight.mjs';
|
|
12
13
|
|
|
13
14
|
const transportIdx = process.argv.indexOf('--transport');
|
|
14
15
|
const transport = transportIdx > -1 && process.argv[transportIdx + 1] ? process.argv[transportIdx + 1] : 'stdio';
|
|
@@ -53,6 +54,14 @@ try {
|
|
|
53
54
|
if (existsSync(marker)) rmSync(marker, { force: true });
|
|
54
55
|
} catch {}
|
|
55
56
|
|
|
57
|
+
// First-use UX: if KooCLI is missing, kick off a background install so the first
|
|
58
|
+
// cloud command doesn't hit a bare "command not found". Non-blocking + log-backed.
|
|
59
|
+
// Runs at import time (before the transport branch below) so both the stdio and
|
|
60
|
+
// remote transports benefit, and `HUAWEICLOUD_SKIP_HCLOUD_PREINSTALL=1` disables it.
|
|
61
|
+
try {
|
|
62
|
+
maybePreinstallKooCli();
|
|
63
|
+
} catch {}
|
|
64
|
+
|
|
56
65
|
if (transport === 'remote') {
|
|
57
66
|
const { startRemoteServer } = await import('./mcp-server-remote.mjs');
|
|
58
67
|
startRemoteServer({ port: remotePort, host: remoteHost }).catch((error) => {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { findHcloudBin } from './hcloud-probe.mjs';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
// Cross-process install lock so concurrent MCP processes / sessions don't all
|
|
12
|
+
// spawn `install-hcloud` at cold start. A stale lock (older than TTL) is
|
|
13
|
+
// reclaimed; a live lock makes this process yield to the in-flight installer.
|
|
14
|
+
const INSTALL_LOCK_TTL_MS = 15 * 60 * 1000;
|
|
15
|
+
const INSTALL_LOG_MAX_BYTES = 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
export function kooCliInstallLogPath() {
|
|
18
|
+
return join(homedir(), '.config', 'huaweicloud', 'logs', 'koocli-install.log');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function kooCliInstallLockPath() {
|
|
22
|
+
return join(homedir(), '.config', 'huaweicloud', '.koocli-install.lock');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Atomically acquire the cross-process install lock (exclusive create). Reclaims
|
|
26
|
+
// a stale lock. Returns { acquired: true } or { acquired: false, reason }.
|
|
27
|
+
export function acquireInstallLock(lockPath = kooCliInstallLockPath()) {
|
|
28
|
+
try {
|
|
29
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
30
|
+
let replace = false;
|
|
31
|
+
try {
|
|
32
|
+
const ts = Number(String(readFileSync(lockPath, 'utf8')).trim());
|
|
33
|
+
if (Number.isFinite(ts) && Date.now() - ts > INSTALL_LOCK_TTL_MS) replace = true;
|
|
34
|
+
} catch {}
|
|
35
|
+
if (replace) {
|
|
36
|
+
try {
|
|
37
|
+
rmSync(lockPath, { force: true });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return { acquired: false, reason: `stale lock cleanup failed: ${error.message}` };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
writeFileSync(lockPath, String(Date.now()), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
43
|
+
return { acquired: true };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return {
|
|
46
|
+
acquired: false,
|
|
47
|
+
reason: error.code === 'EEXIST' ? 'another process is installing' : `lock error: ${error.message}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function releaseInstallLock(lockPath = kooCliInstallLockPath()) {
|
|
53
|
+
try {
|
|
54
|
+
rmSync(lockPath, { force: true });
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Open the install log, rotating (reset) once it exceeds INSTALL_LOG_MAX_BYTES
|
|
59
|
+
// and pinning 0600 so the path holds no sensitive mount-down rights for others.
|
|
60
|
+
export function openKooCliInstallLog(logPath = kooCliInstallLogPath()) {
|
|
61
|
+
try {
|
|
62
|
+
const st = existsSync(logPath) ? statSync(logPath) : null;
|
|
63
|
+
if (st && st.size > INSTALL_LOG_MAX_BYTES) rmSync(logPath, { force: true });
|
|
64
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
65
|
+
} catch {}
|
|
66
|
+
return createWriteStream(logPath, { flags: 'a', mode: 0o600 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Entry that dispatches `install-hcloud` into this file's own `main()` on import,
|
|
70
|
+
// exactly like bin/setup.cjs — but resolved as a sibling (src/setup-cli.mjs) so it
|
|
71
|
+
// works in copied install trees where the repo-top bin/ directory is absent.
|
|
72
|
+
export function resolvePreinstallCliEntry() {
|
|
73
|
+
return join(__dirname, 'setup-cli.mjs');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let installStarted = false;
|
|
77
|
+
|
|
78
|
+
// Starts a one-shot background install of KooCLI when it is missing. Never throws
|
|
79
|
+
// and never blocks: on failure the child's output is captured in the log file and
|
|
80
|
+
// the session continues normally (the friendly not_found hint will point at the log).
|
|
81
|
+
//
|
|
82
|
+
// Trust boundary (P4): this runs `install-hcloud` unattended, i.e. it downloads and
|
|
83
|
+
// executes the official HTTPS installer without checksum verification. Only enable
|
|
84
|
+
// on trusted networks / over HTTPS; do not bypass with proxies you don't control.
|
|
85
|
+
export function maybePreinstallKooCli({ force = false } = {}) {
|
|
86
|
+
if (installStarted && !force) return { launched: false, reason: 'already_launched' };
|
|
87
|
+
if (process.env.HUAWEICLOUD_SKIP_HCLOUD_PREINSTALL === '1') {
|
|
88
|
+
return { launched: false, reason: 'disabled_by_env' };
|
|
89
|
+
}
|
|
90
|
+
if (findHcloudBin()) {
|
|
91
|
+
return { launched: false, reason: 'already installed' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lock = acquireInstallLock();
|
|
95
|
+
if (!lock.acquired) {
|
|
96
|
+
return { launched: false, reason: lock.reason };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
installStarted = true;
|
|
100
|
+
const entryPath = resolvePreinstallCliEntry();
|
|
101
|
+
if (!existsSync(entryPath)) {
|
|
102
|
+
releaseInstallLock();
|
|
103
|
+
console.error(`[preflight] install entry missing: ${entryPath}`);
|
|
104
|
+
return { launched: false, reason: `install entry missing: ${entryPath}` };
|
|
105
|
+
}
|
|
106
|
+
const entry = pathToFileURL(entryPath).href;
|
|
107
|
+
const log = openKooCliInstallLog();
|
|
108
|
+
log.on('error', () => {});
|
|
109
|
+
const release = () => {
|
|
110
|
+
log.end();
|
|
111
|
+
releaseInstallLock();
|
|
112
|
+
};
|
|
113
|
+
const child = spawn(process.execPath, [entry, 'install-hcloud'], {
|
|
114
|
+
stdio: ['ignore', log, log],
|
|
115
|
+
detached: true,
|
|
116
|
+
windowsHide: true,
|
|
117
|
+
});
|
|
118
|
+
child.unref();
|
|
119
|
+
child.on('error', (err) => {
|
|
120
|
+
log.write(`[preflight] spawn failed: ${err.message}\n`);
|
|
121
|
+
release();
|
|
122
|
+
});
|
|
123
|
+
child.on('close', () => release());
|
|
124
|
+
return { launched: true, reason: 'background install started' };
|
|
125
|
+
}
|
|
@@ -198,9 +198,17 @@ export function formatPortDriftWarning(basePort, targetPort) {
|
|
|
198
198
|
return `Port ${basePort} was occupied — nginx now listens on port ${targetPort}. Any DevBridge tunnel bound to port ${basePort} is detached: run "devbridge port create <tunnelId> -p ${targetPort} --protocol http -a" and restart "devbridge host" for the new port.`;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
export function
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
export async function resolveProxyNodePort(nodePort, nginxListenPort, isPortInUse) {
|
|
202
|
+
const baseCandidate = nodePort && nodePort !== nginxListenPort ? nodePort : nginxListenPort + 1;
|
|
203
|
+
let candidate = baseCandidate;
|
|
204
|
+
for (
|
|
205
|
+
let attempt = 0;
|
|
206
|
+
attempt < 10 && (candidate === nginxListenPort || (await isPortInUse(candidate)));
|
|
207
|
+
attempt += 1
|
|
208
|
+
) {
|
|
209
|
+
candidate += 1;
|
|
210
|
+
}
|
|
211
|
+
return candidate;
|
|
204
212
|
}
|
|
205
213
|
|
|
206
214
|
export function buildExposeRemediation(port) {
|
|
@@ -775,7 +783,17 @@ export async function deployNginx(
|
|
|
775
783
|
}
|
|
776
784
|
|
|
777
785
|
const effectiveNodePort =
|
|
778
|
-
nginxType === 'proxy'
|
|
786
|
+
nginxType === 'proxy'
|
|
787
|
+
? await resolveProxyNodePort(nodePort, targetPort, async (candidate) => {
|
|
788
|
+
const portCheck = await execOneShot(
|
|
789
|
+
workspaceId,
|
|
790
|
+
`ss -tlnp 2>/dev/null | grep -q ":${candidate} " && echo "IN_USE" || echo "FREE"`,
|
|
791
|
+
username,
|
|
792
|
+
10000,
|
|
793
|
+
);
|
|
794
|
+
return String(portCheck.stdout || '').includes('IN_USE');
|
|
795
|
+
})
|
|
796
|
+
: undefined;
|
|
779
797
|
|
|
780
798
|
const projectPath = `/workspace/${project}`;
|
|
781
799
|
const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
|
|
@@ -803,7 +821,7 @@ fi`;
|
|
|
803
821
|
}
|
|
804
822
|
}`,
|
|
805
823
|
proxy: `server {
|
|
806
|
-
listen ${
|
|
824
|
+
listen ${targetPort};
|
|
807
825
|
server_name _;
|
|
808
826
|
large_client_header_buffers 4 32k;
|
|
809
827
|
|
|
@@ -874,6 +892,15 @@ fi`;
|
|
|
874
892
|
tunnelActive = String(tunnelCheck.stdout || '').includes('ACTIVE');
|
|
875
893
|
} catch {}
|
|
876
894
|
|
|
895
|
+
const warnings = [];
|
|
896
|
+
if (!tunnelActive) {
|
|
897
|
+
warnings.push('No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.');
|
|
898
|
+
}
|
|
899
|
+
const conflictWarning = formatPortConflictWarning(basePort, targetPort);
|
|
900
|
+
if (conflictWarning) warnings.push(conflictWarning);
|
|
901
|
+
const driftWarning = tunnelActive ? formatPortDriftWarning(basePort, targetPort) : undefined;
|
|
902
|
+
if (driftWarning) warnings.push(driftWarning);
|
|
903
|
+
|
|
877
904
|
return {
|
|
878
905
|
ok: result.exitCode === 0,
|
|
879
906
|
nginxType,
|
|
@@ -884,18 +911,7 @@ fi`;
|
|
|
884
911
|
exitCode: result.exitCode,
|
|
885
912
|
stdout: result.stdout,
|
|
886
913
|
nextStep: 'expose_via_devbridge',
|
|
887
|
-
warning:
|
|
888
|
-
[
|
|
889
|
-
!tunnelActive
|
|
890
|
-
? 'No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.'
|
|
891
|
-
: undefined,
|
|
892
|
-
nginxType === 'proxy'
|
|
893
|
-
? formatProxyPortWarning(basePort, targetPort)
|
|
894
|
-
: formatPortConflictWarning(basePort, targetPort),
|
|
895
|
-
tunnelActive && nginxType !== 'proxy' ? formatPortDriftWarning(basePort, targetPort) : undefined,
|
|
896
|
-
]
|
|
897
|
-
.filter(Boolean)
|
|
898
|
-
.join(' ') || undefined,
|
|
914
|
+
warning: warnings.length > 0 ? warnings.join(' | ') : undefined,
|
|
899
915
|
};
|
|
900
916
|
}
|
|
901
917
|
|
|
@@ -4566,27 +4566,33 @@ async function cmdInstallHcloud() {
|
|
|
4566
4566
|
const hcloudBin = join(installDir, 'hcloud.exe');
|
|
4567
4567
|
|
|
4568
4568
|
// Ask user before accepting the privacy agreement — never auto-accept.
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
input: 'y\n',
|
|
4579
|
-
encoding: 'utf8',
|
|
4580
|
-
timeout: 10000,
|
|
4581
|
-
windowsHide: true,
|
|
4569
|
+
// Under non-TTY (e.g. preflight spawns with stdin 'ignore'), rl.question()
|
|
4570
|
+
// never resolves — skip the prompt and let the user accept manually.
|
|
4571
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
4572
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
4573
|
+
const agree = await new Promise((resolve) => {
|
|
4574
|
+
rl.question('\n KooCLI requires accepting its privacy agreement. Do you accept? (y/N) ', (answer) => {
|
|
4575
|
+
rl.close();
|
|
4576
|
+
resolve(/^\s*y\s*$/i.test(answer));
|
|
4577
|
+
});
|
|
4582
4578
|
});
|
|
4583
|
-
if (
|
|
4584
|
-
|
|
4579
|
+
if (agree) {
|
|
4580
|
+
const r = spawnSync(hcloudBin, ['version'], {
|
|
4581
|
+
input: 'y\n',
|
|
4582
|
+
encoding: 'utf8',
|
|
4583
|
+
timeout: 10000,
|
|
4584
|
+
windowsHide: true,
|
|
4585
|
+
});
|
|
4586
|
+
if (r.status === 0) {
|
|
4587
|
+
console.log(' \x1b[32mPrivacy agreement accepted. KooCLI ready.\x1b[0m');
|
|
4588
|
+
} else {
|
|
4589
|
+
console.log(' \x1b[33m无法写入配置目录。请在普通终端运行: echo "y" | hcloud version\x1b[0m');
|
|
4590
|
+
}
|
|
4585
4591
|
} else {
|
|
4586
|
-
console.log(' \x1b[33m
|
|
4592
|
+
console.log(' \x1b[33m请手动接受隐私协议:在终端运行 hcloud version 并按提示操作\x1b[0m');
|
|
4587
4593
|
}
|
|
4588
4594
|
} else {
|
|
4589
|
-
console.log(' \x1b[33m
|
|
4595
|
+
console.log(' \x1b[33m非交互会话:请稍后在终端运行 hcloud version 并按提示接受隐私协议\x1b[0m');
|
|
4590
4596
|
}
|
|
4591
4597
|
|
|
4592
4598
|
console.log(' Or restart terminal and: hcloud version');
|
|
@@ -8,7 +8,7 @@ import { createHmac, createHash } from 'node:crypto';
|
|
|
8
8
|
|
|
9
9
|
import { evaluateArtifacts, evaluateCommandRisk, evaluateDeployPlan } from './risk-rule-engine.mjs';
|
|
10
10
|
import { classifyTextCommand, redactSecrets } from './safety-policy.mjs';
|
|
11
|
-
import { planHcloudCommand, runHcloud, consumeApprovalToken, hashArgs } from './hcloud-cli.mjs';
|
|
11
|
+
import { planHcloudCommand, runHcloud, consumeApprovalToken, inspectApprovalToken, hashArgs } from './hcloud-cli.mjs';
|
|
12
12
|
import { searchMarketplace } from './search-market.mjs';
|
|
13
13
|
import { getServiceIcon } from './icon-library.mjs';
|
|
14
14
|
import { detectFramework } from './detect-framework.mjs';
|
|
@@ -1014,6 +1014,11 @@ async function transferGitRepo(args, devStageId, connectResult) {
|
|
|
1014
1014
|
}
|
|
1015
1015
|
|
|
1016
1016
|
const pendingConfirms = new Map();
|
|
1017
|
+
// Tombstones for confirm tokens already consumed, so a repeat
|
|
1018
|
+
// huaweicloud_auth_confirm call can be classified as already_processed
|
|
1019
|
+
// instead of not_found (D4-24, #745). Kept in-memory only: confirm tokens
|
|
1020
|
+
// are session-scoped and never persisted across processes.
|
|
1021
|
+
const consumedConfirms = new Map();
|
|
1017
1022
|
|
|
1018
1023
|
function readImportFile() {
|
|
1019
1024
|
const path = join(dirname(globalCredentialsPath()), 'creds-import.json');
|
|
@@ -1281,9 +1286,18 @@ export async function callTool(name, rawArgs = {}, opts = {}) {
|
|
|
1281
1286
|
return persisted;
|
|
1282
1287
|
}
|
|
1283
1288
|
case 'huaweicloud_auth_confirm': {
|
|
1289
|
+
// D4-24 precise JSON contract (#745): a missing or already-consumed
|
|
1290
|
+
// confirm token returns a structured result instead of throwing a
|
|
1291
|
+
// generic Error string, mirroring runApprovedCommand's contract.
|
|
1284
1292
|
const pending = pendingConfirms.get(args.token);
|
|
1285
|
-
if (!pending)
|
|
1293
|
+
if (!pending) {
|
|
1294
|
+
if (args.token && consumedConfirms.has(args.token)) {
|
|
1295
|
+
return { status: 'ok', outcome: 'already_processed' };
|
|
1296
|
+
}
|
|
1297
|
+
return { status: 'rejected', code: 'CONFIRM_TOKEN_NOT_FOUND' };
|
|
1298
|
+
}
|
|
1286
1299
|
pendingConfirms.delete(args.token);
|
|
1300
|
+
consumedConfirms.set(args.token, { consumedAt: Date.now() });
|
|
1287
1301
|
if (args.decision === 's1') {
|
|
1288
1302
|
return { status: 'ok', outcome: 'aborted', message: '保持 S1 现有账号,未覆盖。' };
|
|
1289
1303
|
}
|
|
@@ -1897,9 +1911,33 @@ async function runApprovedCommand(args = {}) {
|
|
|
1897
1911
|
throw new Error('approvedByUser must be true after explicit user approval for this exact command.');
|
|
1898
1912
|
}
|
|
1899
1913
|
const token = String(args.approvalToken || '');
|
|
1914
|
+
|
|
1915
|
+
// D4-24 precise JSON contract (#745): expired and already-consumed tokens
|
|
1916
|
+
// return a structured result instead of throwing a generic Error string, so
|
|
1917
|
+
// clients can machine-assert the two non-success states.
|
|
1918
|
+
const inspection = inspectApprovalToken(token);
|
|
1919
|
+
if (inspection.state === 'already_consumed') {
|
|
1920
|
+
return { status: 'ok', outcome: 'already_processed' };
|
|
1921
|
+
}
|
|
1922
|
+
if (inspection.state === 'expired') {
|
|
1923
|
+
return { status: 'rejected', code: 'CONFIRM_TOKEN_EXPIRED' };
|
|
1924
|
+
}
|
|
1925
|
+
if (inspection.state === 'not_found') {
|
|
1926
|
+
// A token that was never created (typo / unknown / pruned beyond the
|
|
1927
|
+
// tombstone window) is still a rejection, but carries no expired/already
|
|
1928
|
+
// semantics — surface a distinct code so clients can tell the cases apart.
|
|
1929
|
+
return { status: 'rejected', code: 'CONFIRM_TOKEN_NOT_FOUND' };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1900
1932
|
const stored = consumeApprovalToken(token);
|
|
1901
1933
|
if (!stored) {
|
|
1902
|
-
|
|
1934
|
+
// Defensive: inspectApprovalToken said 'valid' but consume returned null
|
|
1935
|
+
// (e.g. raced and pruned between the two reads). Re-classify so we never
|
|
1936
|
+
// return a misleading success.
|
|
1937
|
+
const recheck = inspectApprovalToken(token);
|
|
1938
|
+
if (recheck.state === 'expired') return { status: 'rejected', code: 'CONFIRM_TOKEN_EXPIRED' };
|
|
1939
|
+
if (recheck.state === 'already_consumed') return { status: 'ok', outcome: 'already_processed' };
|
|
1940
|
+
return { status: 'rejected', code: 'CONFIRM_TOKEN_NOT_FOUND' };
|
|
1903
1941
|
}
|
|
1904
1942
|
const providedArgs = Array.isArray(args.args) ? args.args.map(String) : [];
|
|
1905
1943
|
if (hashArgs(providedArgs) !== stored.argsHash) {
|