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 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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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), increment to the next available port: `<port>+1`, update all subsequent nginx config and DevBridge references accordingly.
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. Defaults to `<port> + 1` if omitted, and the result includes `nodePort` so you know which port to bind the Node process to.
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 `<port> + 1` for proxy type).
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
- Agent processes find executables through `PATH`. If OpenCode/Codex cannot find `hcloud`, restart after updating `PATH`, or set `HCLOUD_BIN`.
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": "华为云码道(CodeArts)代码智能体",
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/new-product-icon/AI/ModelArts.png",
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/public/new-product-icon/aPaaS/Versatile.svg",
159
- "local_path": "icons/assets/logo/agentarts.svg"
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
- delete map[token];
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
- : [join(homedir(), '.local', 'bin', 'hcloud'), join(homedir(), 'hcloud', 'hcloud')];
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 'hcloud executable not found. Set HCLOUD_BIN to the full hcloud path, or install KooCLI: npx huaweicloud-devkit install-hcloud. Then restart the agent.';
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 formatProxyPortWarning(basePort, targetPort) {
202
- if (targetPort === basePort) return undefined;
203
- return `Port ${basePort} is in use — the proxy template still listens on port ${basePort}: auto-increment does not apply to proxy configs, so nginx may fail to bind. Free the port or deploy a static/spa build instead.`;
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' ? (nodePort && nodePort !== listenPort ? nodePort : listenPort + 1) : undefined;
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 ${listenPort};
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
- const rl = createInterface({ input: process.stdin, output: process.stdout });
4570
- const agree = await new Promise((resolve) => {
4571
- rl.question('\n KooCLI requires accepting its privacy agreement. Do you accept? (y/N) ', (answer) => {
4572
- rl.close();
4573
- resolve(/^\s*y\s*$/i.test(answer));
4574
- });
4575
- });
4576
- if (agree) {
4577
- const r = spawnSync(hcloudBin, ['version'], {
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 (r.status === 0) {
4584
- console.log(' \x1b[32mPrivacy agreement accepted. KooCLI ready.\x1b[0m');
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无法写入配置目录。请在普通终端运行: echo "y" | hcloud version\x1b[0m');
4592
+ console.log(' \x1b[33m请手动接受隐私协议:在终端运行 hcloud version 并按提示操作\x1b[0m');
4587
4593
  }
4588
4594
  } else {
4589
- console.log(' \x1b[33m请手动接受隐私协议:在终端运行 hcloud version 并按提示操作\x1b[0m');
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) throw new Error('confirmToken not found or expired.');
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
- throw new Error('Invalid or expired approval token. Please re-plan the command.');
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) {