huaweicloud-devkit 1.1.2-next.3 → 1.1.2-next.5

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.
Files changed (28) hide show
  1. package/README.md +29 -1
  2. package/README.zh-CN.md +29 -1
  3. package/integrations/dsh/hook-plugin.mjs +11 -6
  4. package/integrations/opencode/hooks/skill-tracker.js +10 -9
  5. package/package.json +1 -1
  6. package/plugins/huaweicloud-core/.claude-plugin/plugin.json +1 -1
  7. package/plugins/huaweicloud-core/.codex-plugin/plugin.json +1 -1
  8. package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +1 -1
  9. package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +1 -1
  10. package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +1 -1
  11. package/plugins/huaweicloud-core/hooks/hooks.json +2 -2
  12. package/plugins/huaweicloud-core/hooks/huaweicloud-safety.mjs +56 -0
  13. package/plugins/huaweicloud-core/openclaw.plugin.json +1 -1
  14. package/plugins/huaweicloud-core/skills/huaweicloud-safety/SKILL.md +1 -1
  15. package/plugins/huaweicloud-core/src/auth/credential-validator.mjs +163 -0
  16. package/plugins/huaweicloud-core/src/auth/credentials.mjs +6 -2
  17. package/plugins/huaweicloud-core/src/auth/project-id.mjs +68 -0
  18. package/plugins/huaweicloud-core/src/auth/reconcile.mjs +30 -6
  19. package/plugins/huaweicloud-core/src/auth/service.mjs +22 -23
  20. package/plugins/huaweicloud-core/src/hcloud-cli.mjs +5 -10
  21. package/plugins/huaweicloud-core/src/hcloud-probe.mjs +154 -0
  22. package/plugins/huaweicloud-core/src/proxy/proxy-agent.mjs +7 -0
  23. package/plugins/huaweicloud-core/src/safety-policy.mjs +17 -5
  24. package/plugins/huaweicloud-core/src/setup-cli.mjs +192 -134
  25. package/plugins/huaweicloud-core/src/telemetry/agent-detect.mjs +2 -2
  26. package/plugins/huaweicloud-core/src/telemetry/agent-registry.mjs +9 -1
  27. package/plugins/huaweicloud-core/src/telemetry/telemetry.mjs +41 -5
  28. package/plugins/huaweicloud-core/src/tools.mjs +86 -38
package/README.md CHANGED
@@ -22,13 +22,19 @@ Supports OpenCode, Codex, CodeArts Agent, WorkBuddy, DeepSeek Harness (DSH), Off
22
22
  > ```
23
23
  >
24
24
  > Restore the default registry: `npm config delete registry`
25
+ >
26
+ > **Mirror lag**: npm mirrors (npmmirror, mirrors.huaweicloud.com) may lag behind the official registry for hours after a new release. If install fails with `ETARGET` or you get an older version, install via the official registry instead:
27
+ >
28
+ > ```bash
29
+ > npx --yes --registry=https://registry.npmjs.org huaweicloud-devkit install --target <target>
30
+ > ```
25
31
 
26
32
  ## Quick Start
27
33
 
28
34
  > If `--target` is omitted, the installer auto-detects agents on your machine. When multiple agents are detected, **all of them** will be installed. Specify `--target` to control which agent receives the install.
29
35
 
30
36
  ```bash
31
- npx --yes huaweicloud-devkit version # print the installed plugin version per agent
37
+ npx --yes huaweicloud-devkit version # print CLI version and installed plugin versions per agent
32
38
  npx --yes huaweicloud-devkit uninstall --target all --clean-global # also remove KooCLI + OBS config
33
39
  ```
34
40
 
@@ -57,14 +63,36 @@ npx --yes huaweicloud-devkit install --target codex
57
63
  **Restart the Codex session** after installation.
58
64
 
59
65
  ```bash
66
+ codex plugin list # verify huaweicloud-devkit@huaweicloud-devkit is installed and enabled
60
67
  npx --yes huaweicloud-devkit doctor --target codex
61
68
  npx --yes huaweicloud-devkit status --target codex
62
69
  npx --yes huaweicloud-devkit update --target codex
63
70
  npx --yes huaweicloud-devkit uninstall --target codex
64
71
  ```
65
72
 
73
+ Then mention `@huaweicloud-devkit` in Codex or describe your Huawei Cloud task directly.
74
+
66
75
  > **Requires Codex CLI** — the `codex` command must be in PATH. If Codex is installed via WindowsApps (Microsoft Store), use `--target codex-desktop` instead. Run `codex --version` to verify CLI availability.
67
76
 
77
+ ### Codex Desktop
78
+
79
+ Use this target when the Codex CLI is unavailable or when Codex is installed through WindowsApps on Windows.
80
+
81
+ ```bash
82
+ npx --yes huaweicloud-devkit install --target codex-desktop
83
+ ```
84
+
85
+ **Restart the Codex Desktop session** after installation.
86
+
87
+ ```bash
88
+ npx --yes huaweicloud-devkit doctor --target codex-desktop
89
+ npx --yes huaweicloud-devkit status --target codex-desktop
90
+ npx --yes huaweicloud-devkit update --target codex-desktop
91
+ npx --yes huaweicloud-devkit uninstall --target codex-desktop
92
+ ```
93
+
94
+ Then mention `@huaweicloud-devkit` in a new Codex Desktop task or describe your Huawei Cloud task directly.
95
+
68
96
  ### CodeArts Agent
69
97
 
70
98
  ```bash
package/README.zh-CN.md CHANGED
@@ -22,13 +22,19 @@
22
22
  > ```
23
23
  >
24
24
  > 恢复默认镜像:`npm config delete registry`
25
+ >
26
+ > **镜像滞后**:npm 镜像(npmmirror、mirrors.huaweicloud.com)在新版本发布后可能滞后官方源数小时。若安装报 `ETARGET` 或拿到旧版本,改用官方源安装:
27
+ >
28
+ > ```bash
29
+ > npx --yes --registry=https://registry.npmjs.org huaweicloud-devkit install --target <target>
30
+ > ```
25
31
 
26
32
  ## 快速开始
27
33
 
28
34
  > 省略 `--target` 时,安装器会自动检测机器上的 agent,检测到多个时**全部安装**。建议始终指定 `--target` 以明确安装目标。
29
35
 
30
36
  ```bash
31
- npx --yes huaweicloud-devkit version # 查看各 agent 已安装的插件版本
37
+ npx --yes huaweicloud-devkit version # 查看 CLI 版本和各 agent 已安装的插件版本
32
38
  npx --yes huaweicloud-devkit uninstall --target all --clean-global # 一并删除 KooCLI 与 OBS 配置
33
39
  ```
34
40
 
@@ -57,14 +63,36 @@ npx --yes huaweicloud-devkit install --target codex
57
63
  安装后**重启 Codex 会话**。
58
64
 
59
65
  ```bash
66
+ codex plugin list # 验证 huaweicloud-devkit@huaweicloud-devkit 已安装并启用
60
67
  npx --yes huaweicloud-devkit doctor --target codex
61
68
  npx --yes huaweicloud-devkit status --target codex
62
69
  npx --yes huaweicloud-devkit update --target codex
63
70
  npx --yes huaweicloud-devkit uninstall --target codex
64
71
  ```
65
72
 
73
+ 随后在 Codex 中提及 `@huaweicloud-devkit`,或直接描述华为云任务。
74
+
66
75
  > **需要 Codex CLI** — `codex` 命令必须在 PATH 中。若 Codex 通过 WindowsApps(Microsoft Store)安装,请使用 `--target codex-desktop` 替代。运行 `codex --version` 验证 CLI 可用性。
67
76
 
77
+ ### Codex Desktop
78
+
79
+ 当 Codex CLI 不可用,或 Windows 上通过 WindowsApps 安装 Codex 时,使用此目标。
80
+
81
+ ```bash
82
+ npx --yes huaweicloud-devkit install --target codex-desktop
83
+ ```
84
+
85
+ 安装后**重启 Codex Desktop 会话**。
86
+
87
+ ```bash
88
+ npx --yes huaweicloud-devkit doctor --target codex-desktop
89
+ npx --yes huaweicloud-devkit status --target codex-desktop
90
+ npx --yes huaweicloud-devkit update --target codex-desktop
91
+ npx --yes huaweicloud-devkit uninstall --target codex-desktop
92
+ ```
93
+
94
+ 随后在新的 Codex Desktop 任务中提及 `@huaweicloud-devkit`,或直接描述华为云任务。
95
+
68
96
  ### CodeArts Agent(码道)
69
97
 
70
98
  ```bash
@@ -65,18 +65,23 @@ function isHuaweiCloudSkill(name) {
65
65
  // hcloud command classification (mirrors skill-tracker.js)
66
66
  // ══════════════════════════════════════════════════════════════════
67
67
 
68
- const HCLOUD_RE = /(?:^|[;&|]\s*)hcloud(?:\.exe)?\s+(.+)/i;
68
+ const HCLOUD_RE = /(?:^|[;&|]\s*)hcloud(?:\.exe)?\s+([^\s;&|"<>]+(?:\s+[^\s;&|"<>]+){0,3})/i;
69
69
  const READ_VERBS = /\b(List|Show|Get|Describe|NovaList|NovaShow)\w*/i;
70
70
  const WRITE_VERBS =
71
71
  /\b(Create|Delete|Update|Modify|Remove|Revoke|Grant|Attach|Detach|Enable|Disable|Set|Add|Bind|Unbind|Reset|Change|Activate|Deactivate|Register|Unregister|Import|Export|Download|Upload|Copy|Move|Convert|Migrate|Run|Execute|Invoke|Trigger|Deploy|Push|Start|Stop|Restart|Reboot|Suspend|Resume|Terminate|Release|Allocate)\w*/i;
72
72
 
73
- function classifyHcloud(text) {
73
+ export function classifyHcloud(text) {
74
74
  const m = HCLOUD_RE.exec(text);
75
75
  if (!m) return null;
76
- const rest = m[1].trim();
77
- const parts = rest.split(/\s+/).filter((p) => !p.startsWith('--'));
78
- const cmd = parts.join(' ');
79
- if (!cmd) return null;
76
+ const raw = m[1].trim();
77
+ if (!raw) return null;
78
+ const cmdTokens = [];
79
+ for (const t of raw.split(/\s+/)) {
80
+ if (t.startsWith('--')) break;
81
+ cmdTokens.push(t);
82
+ }
83
+ if (cmdTokens.length === 0) return null;
84
+ const cmd = cmdTokens.join(' ');
80
85
  if (READ_VERBS.test(cmd)) return { key: 'cli:read', value: `hcloud ${cmd}`, capability: 'cli' };
81
86
  if (WRITE_VERBS.test(cmd)) return { key: 'cli:write', value: `hcloud ${cmd}`, capability: 'cli' };
82
87
  return { key: 'cli:invoke', value: `hcloud ${cmd}`, capability: 'cli' };
@@ -28,7 +28,7 @@ function writeEvent(key, value, extra = {}) {
28
28
 
29
29
  // ── CLI command classification ────────────────────────────────
30
30
 
31
- const HCLOUD_RE = /(?:^|[;&|]\s*)hcloud(?:\.exe)?\s+(.+)/i;
31
+ const HCLOUD_RE = /(?:^|[;&|]\s*)hcloud(?:\.exe)?\s+([^\s;&|"<>]+(?:\s+[^\s;&|"<>]+){0,3})/i;
32
32
  const READ_VERBS = /\b(List|Show|Get|Describe|NovaList|NovaShow)\w*/i;
33
33
  const WRITE_VERBS = new RegExp(
34
34
  '\\b(Create|Delete|Update|Modify|Remove|Revoke|Grant|Attach|Detach|' +
@@ -42,14 +42,15 @@ const WRITE_VERBS = new RegExp(
42
42
  function classifyHcloud(text) {
43
43
  const m = HCLOUD_RE.exec(text);
44
44
  if (!m) return null;
45
- const rest = m[1].trim();
46
- const cmdEnd = rest.search(/\s[|&<>;]/);
47
- const cmdPart = cmdEnd > -1 ? rest.slice(0, cmdEnd) : rest;
48
- const parts = cmdPart
49
- .split(/\s+/)
50
- .filter((p) => !p.startsWith('--') && !/^\d*>(&?\d*|%devnull)/.test(p) && !/^(&\d+)$/.test(p));
51
- const cmd = parts.join(' ');
52
- if (!cmd) return null;
45
+ const raw = m[1].trim();
46
+ if (!raw) return null;
47
+ const cmdTokens = [];
48
+ for (const t of raw.split(/\s+/)) {
49
+ if (t.startsWith('--')) break;
50
+ cmdTokens.push(t);
51
+ }
52
+ if (cmdTokens.length === 0) return null;
53
+ const cmd = cmdTokens.join(' ');
53
54
  if (READ_VERBS.test(cmd)) return { key: 'cli:read', value: `hcloud ${cmd}` };
54
55
  if (WRITE_VERBS.test(cmd)) return { key: 'cli:write', value: `hcloud ${cmd}` };
55
56
  return { key: 'cli:invoke', value: `hcloud ${cmd}` };
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.2-next.3",
4
+ "version": "1.1.2-next.5",
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.2-next.3",
20
+ "version": "1.1.2-next.5",
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.2-next.3",
23
+ "version": "1.1.2-next.5",
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.2-next.3",
20
+ "version": "1.1.2-next.5",
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.2-next.3",
3
+ "version": "1.1.2-next.5",
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.2-next.3",
3
+ "version": "1.1.2-next.5",
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",
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/huaweicloud-safety.py\"",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/huaweicloud-safety.mjs\"",
10
10
  "timeout": 5
11
11
  }
12
12
  ]
@@ -16,7 +16,7 @@
16
16
  "hooks": [
17
17
  {
18
18
  "type": "command",
19
- "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/huaweicloud-safety.py\"",
19
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/huaweicloud-safety.mjs\"",
20
20
  "timeout": 5
21
21
  }
22
22
  ]
@@ -0,0 +1,56 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ import { classifyTextCommand } from '../src/safety-policy.mjs';
4
+
5
+ const DENY_PREFIX = 'Huawei Cloud safety hook blocked this action: ';
6
+
7
+ function commandText(toolInput) {
8
+ if (typeof toolInput === 'string') return toolInput;
9
+ if (toolInput && typeof toolInput === 'object') {
10
+ const values = [];
11
+ for (const key of ['command', 'cmd', 'script', 'args', 'arguments']) {
12
+ const value = toolInput[key];
13
+ if (Array.isArray(value)) values.push(value.map(String).join(' '));
14
+ else if (value !== undefined && value !== null) values.push(String(value));
15
+ }
16
+ if (values.length > 0) return values.join('\n');
17
+ return JSON.stringify(toolInput);
18
+ }
19
+ return JSON.stringify(toolInput);
20
+ }
21
+
22
+ function deny(reason) {
23
+ process.stdout.write(
24
+ JSON.stringify({
25
+ hookSpecificOutput: {
26
+ hookEventName: 'PreToolUse',
27
+ permissionDecision: 'deny',
28
+ permissionDecisionReason: DENY_PREFIX + reason,
29
+ },
30
+ }) + '\n',
31
+ );
32
+ }
33
+
34
+ function readStdin() {
35
+ try {
36
+ return readFileSync(0, 'utf8');
37
+ } catch {
38
+ return '';
39
+ }
40
+ }
41
+
42
+ function main() {
43
+ const input = readStdin();
44
+ let data;
45
+ try {
46
+ data = JSON.parse(input);
47
+ } catch {
48
+ return;
49
+ }
50
+
51
+ const text = commandText(data.tool_input ?? {});
52
+ const result = classifyTextCommand(text);
53
+ if (result.decision === 'deny') deny(result.reason);
54
+ }
55
+
56
+ main();
@@ -2,7 +2,7 @@
2
2
  "name": "huaweicloud-devkit",
3
3
  "id": "huaweicloud-devkit",
4
4
  "displayName": "HuaweiCloud DevKit",
5
- "version": "1.1.2-next.3",
5
+ "version": "1.1.2-next.5",
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.",
@@ -27,7 +27,7 @@ Use this skill before any Huawei Cloud action that may expose secrets, change re
27
27
 
28
28
  ## Enforcement Layers
29
29
 
30
- - Hooks: `hooks/huaweicloud-safety.py` can block risky tool calls on platforms that support plugin hooks.
30
+ - Hooks: `hooks/huaweicloud-safety.mjs` blocks risky Codex native hook calls without requiring Python; `hooks/huaweicloud-safety.py` remains for Hermes/Python hook compatibility.
31
31
  - MCP wrapper: the Node MCP server applies the same safety policy for Codex/OpenCode paths that do not enforce hooks.
32
32
  - Skills: this document teaches agents the rule before they act.
33
33
 
@@ -0,0 +1,163 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ import { getProxyDispatcher } from '../proxy/proxy-agent.mjs';
4
+
5
+ function iamBaseUrl() {
6
+ return process.env.HW_IAM_ENDPOINT || 'https://iam.myhuaweicloud.com';
7
+ }
8
+
9
+ function sha256Hex(data) {
10
+ return crypto.createHash('sha256').update(data).digest('hex');
11
+ }
12
+
13
+ function hmacSha256(key, data) {
14
+ return crypto.createHmac('sha256', key).update(data).digest('hex');
15
+ }
16
+
17
+ function urlEncode(str) {
18
+ const hex = (c) => '%' + (c < 16 ? '0' : '') + c.toString(16).toUpperCase();
19
+ const noEscape = new Set('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~'.split(''));
20
+ let out = '';
21
+ for (const ch of str) {
22
+ const c = ch.codePointAt(0);
23
+ out += noEscape.has(ch) && c < 0x80 ? ch : c < 0x80 ? hex(c) : encodeURIComponent(ch);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ function timestamp() {
29
+ return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '') + 'Z';
30
+ }
31
+
32
+ function signIamRequest(path, query, ak, sk, securitytoken) {
33
+ const ts = timestamp();
34
+ const host = new URL(iamBaseUrl()).host;
35
+
36
+ const cqs = Object.entries(query)
37
+ .sort(([a], [b]) => a.localeCompare(b))
38
+ .map(([k, v]) => `${urlEncode(k)}=${urlEncode(v)}`)
39
+ .join('&');
40
+
41
+ const curi =
42
+ '/' +
43
+ path
44
+ .split('/')
45
+ .filter(Boolean)
46
+ .map((s) => urlEncode(s))
47
+ .join('/') +
48
+ '/';
49
+
50
+ const signedHeaders = securitytoken ? 'host;x-sdk-date;x-security-token' : 'host;x-sdk-date';
51
+ const canonicalHeaders = securitytoken
52
+ ? `host:${host}\nx-sdk-date:${ts}\nx-security-token:${securitytoken}\n`
53
+ : `host:${host}\nx-sdk-date:${ts}\n`;
54
+
55
+ const payloadHash = sha256Hex('');
56
+ const canonicalRequest = ['GET', curi, cqs, canonicalHeaders, signedHeaders, payloadHash].join('\n');
57
+ const stringToSign = `SDK-HMAC-SHA256\n${ts}\n${sha256Hex(canonicalRequest)}`;
58
+ const signature = hmacSha256(sk, stringToSign);
59
+
60
+ const headers = {
61
+ host,
62
+ 'x-sdk-date': ts,
63
+ Authorization: `SDK-HMAC-SHA256 Access=${ak}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
64
+ };
65
+ if (securitytoken) {
66
+ headers['x-security-token'] = securitytoken;
67
+ }
68
+ return headers;
69
+ }
70
+
71
+ function projectForRegion(projects, region) {
72
+ if (region) {
73
+ const match = projects.find((p) => p && p.name === region && p.id);
74
+ if (match) return match.id;
75
+ }
76
+ return projects.find((p) => p && p.id)?.id || null;
77
+ }
78
+
79
+ /**
80
+ * Validate AK/SK by calling IAM KeystoneListProjects (read-only) with
81
+ * SDK-HMAC-SHA256 request signing. A wrong SK produces an invalid signature,
82
+ * which IAM rejects with HTTP 401 before any project data is returned.
83
+ *
84
+ * Returns { valid, projectId, error, warning }:
85
+ * - valid: true when IAM verified the signature (or the rejection is not
86
+ * authentication-related), false when the credentials are unusable.
87
+ * - projectId: first project matching `region`, else the first visible project.
88
+ * - warning: set when credentials passed but project discovery was denied.
89
+ */
90
+ export async function validateIamCredentials({ ak, sk, securityToken, region, timeoutMs = 15000 } = {}) {
91
+ if (!ak || !sk) {
92
+ return { valid: false, projectId: null, error: 'AK and SK are both required for credential validation.' };
93
+ }
94
+
95
+ const base = iamBaseUrl();
96
+ const path = '/v3/projects';
97
+ const query = region ? { name: region } : {};
98
+ const qs = Object.entries(query)
99
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
100
+ .join('&');
101
+ const url = `${base}${path}${qs ? `?${qs}` : ''}`;
102
+ const controller = new AbortController();
103
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
104
+
105
+ let resp;
106
+ try {
107
+ const headers = signIamRequest(path, query, ak, sk, securityToken);
108
+ const fetchOpts = { headers, signal: controller.signal };
109
+ const dispatcher = await getProxyDispatcher(url);
110
+ if (dispatcher) {
111
+ fetchOpts.dispatcher = dispatcher;
112
+ const { fetch: undiciFetch } = await import('undici');
113
+ resp = await undiciFetch(url, fetchOpts);
114
+ } else {
115
+ resp = await fetch(url, fetchOpts);
116
+ }
117
+ } catch (error) {
118
+ return {
119
+ valid: false,
120
+ projectId: null,
121
+ skipped: true,
122
+ error: `IAM validation request failed (treating credentials as unverified): ${error.message}`,
123
+ };
124
+ } finally {
125
+ clearTimeout(timer);
126
+ }
127
+
128
+ const text = await resp.text();
129
+ let data = null;
130
+ try {
131
+ data = JSON.parse(text);
132
+ } catch {}
133
+
134
+ if (resp.status === 200) {
135
+ const projects = Array.isArray(data?.projects) ? data.projects : [];
136
+ return { valid: true, projectId: projectForRegion(projects, region), error: null, warning: null };
137
+ }
138
+
139
+ if (resp.status === 401) {
140
+ const msg = data?.error?.message || text.slice(0, 200);
141
+ return {
142
+ valid: false,
143
+ projectId: null,
144
+ error: `IAM rejected the credentials (HTTP 401: ${msg}). The AK/SK is invalid - check the SK for typos or expired security tokens.`,
145
+ };
146
+ }
147
+
148
+ if (resp.status === 403) {
149
+ return {
150
+ valid: true,
151
+ projectId: null,
152
+ error: null,
153
+ warning: `Credentials signed successfully but project listing was denied (HTTP 403). Continuing without project_id.`,
154
+ };
155
+ }
156
+
157
+ return {
158
+ valid: false,
159
+ projectId: null,
160
+ skipped: true,
161
+ error: `Unexpected IAM response (HTTP ${resp.status}): ${text.slice(0, 200)}`,
162
+ };
163
+ }
@@ -272,10 +272,14 @@ export function readLastSync() {
272
272
  }
273
273
  }
274
274
 
275
- export function writeLastSync() {
275
+ export function writeLastSync(metadata = {}) {
276
276
  const path = lastSyncPath();
277
277
  mkdirSync(dirname(path), { recursive: true });
278
- const payload = { ts: Date.now() };
278
+ const payload = {
279
+ ts: Date.now(),
280
+ ...(metadata.kooCliProfile ? { kooCliProfile: String(metadata.kooCliProfile) } : {}),
281
+ ...(metadata.s1Fingerprint ? { s1Fingerprint: String(metadata.s1Fingerprint) } : {}),
282
+ };
279
283
  writeFileSync(path, JSON.stringify(payload), { encoding: 'utf8', mode: 0o600 });
280
284
  ensurePrivateMode(path);
281
285
  }
@@ -0,0 +1,68 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ function hcloudCommand(args) {
5
+ const bin = process.env.HCLOUD_BIN || 'hcloud';
6
+ // Test doubles (and exotic setups) may point HCLOUD_BIN at a Node script;
7
+ // those must be launched through node instead of the shell.
8
+ if (/\.(mjs|cjs|js)$/i.test(bin) && existsSync(bin)) {
9
+ return { file: process.execPath, args: [bin, ...args] };
10
+ }
11
+ return { file: bin, args };
12
+ }
13
+
14
+ function runHcloud(args, timeoutMs = 20000) {
15
+ const { file, args: spawnArgs } = hcloudCommand(args);
16
+ return spawnSync(file, spawnArgs, {
17
+ windowsHide: true,
18
+ stdio: 'pipe',
19
+ timeout: timeoutMs,
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Resolve the project_id for `region` via IAM KeystoneListProjects using the
25
+ * credentials already stored in the KooCLI profile, then write it back with
26
+ * `hcloud configure set --cli-project-id=<id>`. Read-only discovery + local
27
+ * config write only - no secrets appear in process arguments because the
28
+ * profile carries the credentials.
29
+ *
30
+ * Best-effort by design: any failure returns { ok: false, reason } and never
31
+ * throws, so callers can stay non-fatal.
32
+ */
33
+ export function resolveAndApplyProjectId({ region, profile } = {}) {
34
+ if (!region) return { ok: false, reason: 'region is required' };
35
+ const profileArgs = profile ? [`--cli-profile=${profile}`] : [];
36
+ try {
37
+ const list = runHcloud([
38
+ 'IAM',
39
+ 'KeystoneListProjects',
40
+ ...profileArgs,
41
+ `--cli-region=${region}`,
42
+ `--name=${region}`,
43
+ ]);
44
+ if (list.status !== 0) {
45
+ return { ok: false, reason: `KeystoneListProjects failed (exit ${list.status})` };
46
+ }
47
+ let projects = null;
48
+ try {
49
+ const parsed = JSON.parse(String(list.stdout || ''));
50
+ projects = Array.isArray(parsed?.projects) ? parsed.projects : Array.isArray(parsed) ? parsed : null;
51
+ } catch {
52
+ return { ok: false, reason: 'could not parse KeystoneListProjects output' };
53
+ }
54
+ if (!projects || projects.length === 0) {
55
+ return { ok: false, reason: `no projects found for region ${region}` };
56
+ }
57
+ const match = projects.find((p) => p && p.name === region && p.id) || projects.find((p) => p && p.id);
58
+ if (!match) return { ok: false, reason: 'project list contained no usable ids' };
59
+
60
+ const set = runHcloud(['configure', 'set', ...profileArgs, `--cli-project-id=${match.id}`]);
61
+ if (set.status !== 0) {
62
+ return { ok: false, reason: `configure set --cli-project-id failed (exit ${set.status})` };
63
+ }
64
+ return { ok: true, projectId: match.id };
65
+ } catch (error) {
66
+ return { ok: false, reason: error.message };
67
+ }
68
+ }