autowonder 0.2.136 → 0.2.138
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 +9 -9
- package/bin/cli.js +11 -211
- package/bin/qoder-bootstrap.js +578 -0
- package/bin/shared.js +35 -0
- package/package.json +1 -1
- package/vendor/autowonder-daemon-darwin-amd64 +0 -0
- package/vendor/autowonder-daemon-darwin-arm64 +0 -0
- package/vendor/autowonder-daemon-linux-amd64 +0 -0
- package/vendor/autowonder-daemon-linux-arm64 +0 -0
- package/vendor/autowonder-daemon-win32-amd64.exe +0 -0
- package/vendor/autowonder-daemon-win32-arm64.exe +0 -0
package/README.md
CHANGED
|
@@ -6,16 +6,16 @@ AutoWonder 本地 agent runtime。安装后启动 daemon,持续轮询本地 as
|
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
# Qoder CLI(模型使用 provider model ID;Context Window 使用固定档位)
|
|
9
|
-
npx -y autowonder@0.2.
|
|
9
|
+
npx -y autowonder@0.2.138 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qoder --model qmodel_latest --reasoning-effort medium --context-window 260000
|
|
10
10
|
|
|
11
11
|
# Qoder CLI CN
|
|
12
|
-
npx -y autowonder@0.2.
|
|
12
|
+
npx -y autowonder@0.2.138 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
|
|
13
13
|
|
|
14
14
|
# Claude Code
|
|
15
|
-
npx -y autowonder@0.2.
|
|
15
|
+
npx -y autowonder@0.2.138 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider claude
|
|
16
16
|
|
|
17
17
|
# Codex CLI
|
|
18
|
-
npx -y autowonder@0.2.
|
|
18
|
+
npx -y autowonder@0.2.138 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider codex --model gpt-5.5 --reasoning-effort medium
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
`connect` 会安装当前 npm 包内置的 daemon,并使用页面生成的 WebSocket endpoint、Token 和执行器 ID 建立连接。选择 Qoder 时需要 Node.js 20+;runtime 会在缺少 `qodercli` 时自动通过 npm 安装,并在尚未登录时打开 Qoder 浏览器登录。Claude Code 和 Codex CLI 仍需提前安装并登录。runtime 会继承当前用户的 HOME、环境变量和 CLI 登录状态。
|
|
@@ -62,7 +62,7 @@ mv "$queue/.assignment.tmp" "$queue/assignment.json"
|
|
|
62
62
|
也可以直接提交到本地 API:
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
npx -y autowonder@0.2.
|
|
65
|
+
npx -y autowonder@0.2.138 dispatch ./assignment.json
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
## 上传工单附件
|
|
@@ -70,7 +70,7 @@ npx -y autowonder@0.2.136 dispatch ./assignment.json
|
|
|
70
70
|
把本地需求/设计文档直接上传到工单,避免在会话里传递大文件:
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
|
-
npx -y autowonder@0.2.
|
|
73
|
+
npx -y autowonder@0.2.138 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
上传令牌通过 MCP 工具 `autowonder.workitem_cli_upload_token` 签发,经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 环境变量传入。`--file` 可重复指定多个文件;`--json` 输出机器可读结果。失败时按错误类型返回不同退出码(401→3、403→4、404→5、409→6、413→7、其他 4xx→8、网络/重定向→9)。
|
|
@@ -78,8 +78,8 @@ npx -y autowonder@0.2.136 workitem upload --server-url <autowonder-server-url> -
|
|
|
78
78
|
## 管理 daemon
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
|
-
npx -y autowonder@0.2.
|
|
82
|
-
npx -y autowonder@0.2.
|
|
81
|
+
npx -y autowonder@0.2.138 status
|
|
82
|
+
npx -y autowonder@0.2.138 stop
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
默认 API 是 `http://127.0.0.1:34989`,日志位于 `~/.autowonder/daemon.log`。npm 包不包含任何 agent、MCP 或服务端凭证。
|
|
@@ -87,7 +87,7 @@ npx -y autowonder@0.2.136 stop
|
|
|
87
87
|
## 调试
|
|
88
88
|
|
|
89
89
|
```bash
|
|
90
|
-
npx -y autowonder@0.2.
|
|
90
|
+
npx -y autowonder@0.2.138 connect ... --debug
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
`--debug` 会让 CLI 以 `AUTOWONDER_DEBUG=1` 启动 daemon,把完整的 daemon 与 agent 活动(runtime 事件流、provider 事件解码、重试退避与会话门等待、空闲时的阻塞点)同时输出到控制台和会话调试日志。调试日志不做脱敏,仅用于本机排障;不传 `--debug` 时该环境变量不会注入。
|
package/bin/cli.js
CHANGED
|
@@ -9,6 +9,13 @@ const os = require("os");
|
|
|
9
9
|
const https = require("https");
|
|
10
10
|
const http = require("http");
|
|
11
11
|
|
|
12
|
+
const { ensureDir, error, findOnPath, log, resolveUserPath } = require("./shared");
|
|
13
|
+
const {
|
|
14
|
+
ensureQoderReady,
|
|
15
|
+
findQoderExecutable,
|
|
16
|
+
isQoderFamilyProvider,
|
|
17
|
+
} = require("./qoder-bootstrap");
|
|
18
|
+
|
|
12
19
|
const VERSION = require("../package.json").version;
|
|
13
20
|
const AUTOWONDER_HOME = path.join(os.homedir(), ".autowonder");
|
|
14
21
|
const BIN_DIR = path.join(AUTOWONDER_HOME, "bin");
|
|
@@ -23,47 +30,6 @@ const CONFIG_PATH = path.join(AUTOWONDER_HOME, "config.json");
|
|
|
23
30
|
const PID_PATH = path.join(AUTOWONDER_HOME, "daemon.pid");
|
|
24
31
|
const STATE_PATH = path.join(AUTOWONDER_HOME, "daemon-state.json");
|
|
25
32
|
const LOG_PATH = path.join(AUTOWONDER_HOME, "daemon.log");
|
|
26
|
-
const MANAGED_QODER_DIR = path.join(AUTOWONDER_HOME, "qoder-cli");
|
|
27
|
-
const MANAGED_QODERCN_DIR = path.join(AUTOWONDER_HOME, "qoder-cn-cli");
|
|
28
|
-
|
|
29
|
-
// qoder and qodercn are two distinct provider identities that share the same
|
|
30
|
-
// bootstrap flow. Each identity keeps its own executable, npm package, managed
|
|
31
|
-
// install dir and env override so the two CLIs never shadow each other.
|
|
32
|
-
const QODER_FAMILY_PROFILES = {
|
|
33
|
-
qoder: {
|
|
34
|
-
displayName: "Qoder CLI",
|
|
35
|
-
executable: "qodercli",
|
|
36
|
-
envVar: "AUTOWONDER_QODER_CLI",
|
|
37
|
-
npmPackage: "@qoder-ai/qodercli@latest",
|
|
38
|
-
managedDir: MANAGED_QODER_DIR,
|
|
39
|
-
managedBinName: "qodercli",
|
|
40
|
-
windowsShimName: "qodercli.cmd",
|
|
41
|
-
unixCandidates: [
|
|
42
|
-
path.join(os.homedir(), ".local", "bin", "qodercli"),
|
|
43
|
-
path.join(os.homedir(), ".qoder", "bin", "qodercli", "qodercli"),
|
|
44
|
-
],
|
|
45
|
-
},
|
|
46
|
-
qodercn: {
|
|
47
|
-
displayName: "Qoder CLI CN",
|
|
48
|
-
executable: "qodercn",
|
|
49
|
-
envVar: "AUTOWONDER_QODERCN_CLI",
|
|
50
|
-
npmPackage: "@qodercn-ai/qoderclicn@latest",
|
|
51
|
-
managedDir: MANAGED_QODERCN_DIR,
|
|
52
|
-
managedBinName: "qodercn",
|
|
53
|
-
windowsShimName: "qodercn.cmd",
|
|
54
|
-
unixCandidates: [
|
|
55
|
-
path.join(os.homedir(), ".local", "bin", "qodercn"),
|
|
56
|
-
],
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
function profileForExecutable(name) {
|
|
61
|
-
return Object.values(QODER_FAMILY_PROFILES).find((profile) => profile.executable === name) || null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function isQoderFamilyProvider(provider) {
|
|
65
|
-
return provider === "qoder" || provider === "qodercn";
|
|
66
|
-
}
|
|
67
33
|
|
|
68
34
|
const BINARY_BASE_URL = process.env.AUTOWONDER_BINARY_URL || "";
|
|
69
35
|
const SOURCE_REPO = process.env.AUTOWONDER_SOURCE_REPO || "";
|
|
@@ -78,172 +44,14 @@ function safeChmod(filePath, mode) {
|
|
|
78
44
|
|
|
79
45
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
80
46
|
|
|
81
|
-
function ensureDir(dir) {
|
|
82
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function log(msg) {
|
|
86
|
-
console.log(`\x1b[36m[autowonder]\x1b[0m ${msg}`);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function error(msg) {
|
|
90
|
-
console.error(`\x1b[31m[autowonder]\x1b[0m ${msg}`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
47
|
function detectProvider() {
|
|
94
48
|
const providers = [];
|
|
95
|
-
if (
|
|
96
|
-
if (
|
|
97
|
-
if (
|
|
49
|
+
if (findOnPath("claude")) providers.push("claude");
|
|
50
|
+
if (findOnPath("codex")) providers.push("codex");
|
|
51
|
+
if (findQoderExecutable("qoder")) providers.push("qoder");
|
|
98
52
|
return providers;
|
|
99
53
|
}
|
|
100
54
|
|
|
101
|
-
function findExecutable(name) {
|
|
102
|
-
const profile = profileForExecutable(name);
|
|
103
|
-
if (profile && process.env[profile.envVar]) {
|
|
104
|
-
const configured = resolveUserPath(process.env[profile.envVar]);
|
|
105
|
-
try { fs.accessSync(configured, fs.constants.X_OK); return configured; } catch {}
|
|
106
|
-
}
|
|
107
|
-
const finder = process.platform === "win32" ? "where" : "which";
|
|
108
|
-
const found = spawnSync(finder, [name], { encoding: "utf8", timeout: 3000 });
|
|
109
|
-
if (found.status === 0) {
|
|
110
|
-
const first = (found.stdout || "").split(/\r?\n/).map((value) => value.trim()).find(Boolean);
|
|
111
|
-
if (first) return first;
|
|
112
|
-
}
|
|
113
|
-
if (profile && process.platform !== "win32") {
|
|
114
|
-
const candidates = [
|
|
115
|
-
path.join(profile.managedDir, "node_modules", ".bin", profile.managedBinName),
|
|
116
|
-
...profile.unixCandidates,
|
|
117
|
-
];
|
|
118
|
-
for (const candidate of candidates) {
|
|
119
|
-
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
if (profile && process.platform === "win32") {
|
|
123
|
-
const candidate = path.join(profile.managedDir, "node_modules", ".bin", profile.windowsShimName);
|
|
124
|
-
try { fs.accessSync(candidate, fs.constants.F_OK); return candidate; } catch {}
|
|
125
|
-
}
|
|
126
|
-
return "";
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function spawnQoderSync(executable, args, options = {}) {
|
|
130
|
-
// npm exposes package binaries as .cmd shims on Windows. CreateProcess
|
|
131
|
-
// cannot execute those shims directly, so let ComSpec resolve them.
|
|
132
|
-
return spawnSync(executable, args, { ...options, shell: IS_WIN32 });
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function qoderAuthenticated(executable) {
|
|
136
|
-
const result = spawnQoderSync(executable, ["--list-models"], {
|
|
137
|
-
encoding: "utf8",
|
|
138
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
139
|
-
timeout: 20000,
|
|
140
|
-
});
|
|
141
|
-
return result.status === 0 && Boolean((result.stdout || "").trim());
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function installQoderCLI(provider) {
|
|
145
|
-
const profile = QODER_FAMILY_PROFILES[provider];
|
|
146
|
-
log(`${profile.displayName} is not installed. Installing ${profile.npmPackage}...`);
|
|
147
|
-
const args = [
|
|
148
|
-
"install", "--prefix", profile.managedDir, profile.npmPackage, "--no-audit", "--no-fund",
|
|
149
|
-
];
|
|
150
|
-
// npm is a .cmd shim on Windows. When launched by npx, npm_execpath points
|
|
151
|
-
// at npm-cli.js, which Node can execute directly without cmd.exe quoting.
|
|
152
|
-
const runNpm = (npmArgs) => IS_WIN32 && process.env.npm_execpath
|
|
153
|
-
? spawnSync(process.execPath, [process.env.npm_execpath, ...npmArgs], { stdio: "inherit" })
|
|
154
|
-
: spawnSync("npm", npmArgs, { stdio: "inherit" });
|
|
155
|
-
let result = runNpm(args);
|
|
156
|
-
if (result.status !== 0 && IS_WIN32) {
|
|
157
|
-
log(`Default npm registry failed; retrying ${profile.displayName} installation with npmmirror...`);
|
|
158
|
-
result = runNpm([...args, "--registry", "https://registry.npmmirror.com"]);
|
|
159
|
-
}
|
|
160
|
-
if (result.status !== 0) {
|
|
161
|
-
const detail = result.error?.message || (result.signal ? `signal ${result.signal}` : `exit status ${result.status}`);
|
|
162
|
-
error(`Failed to install ${profile.displayName} into ${profile.managedDir}: ${detail}`);
|
|
163
|
-
return "";
|
|
164
|
-
}
|
|
165
|
-
const executable = findExecutable(profile.executable);
|
|
166
|
-
if (!executable) error(`${profile.displayName} was installed but ${profile.executable} is not available on PATH.`);
|
|
167
|
-
return executable;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function qoderNodeVersion() {
|
|
171
|
-
if (process.env.NODE_ENV === "test" && process.env.AUTOWONDER_TEST_NODE_VERSION) {
|
|
172
|
-
return process.env.AUTOWONDER_TEST_NODE_VERSION;
|
|
173
|
-
}
|
|
174
|
-
return process.versions.node;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const QODER_AUTH_RECHECK_TIMEOUT_MS = 3 * 60 * 1000;
|
|
178
|
-
|
|
179
|
-
function qoderAuthRecheckTimeoutMs() {
|
|
180
|
-
if (process.env.NODE_ENV === "test" && process.env.AUTOWONDER_TEST_QODER_AUTH_RECHECK_TIMEOUT_MS) {
|
|
181
|
-
return Number.parseInt(process.env.AUTOWONDER_TEST_QODER_AUTH_RECHECK_TIMEOUT_MS, 10) || 1;
|
|
182
|
-
}
|
|
183
|
-
return QODER_AUTH_RECHECK_TIMEOUT_MS;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function qoderAuthRecheckDelayMs() {
|
|
187
|
-
if (process.env.NODE_ENV === "test" && process.env.AUTOWONDER_TEST_QODER_AUTH_RECHECK_DELAY_MS) {
|
|
188
|
-
return Number.parseInt(process.env.AUTOWONDER_TEST_QODER_AUTH_RECHECK_DELAY_MS, 10) || 0;
|
|
189
|
-
}
|
|
190
|
-
return 3000;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Credentials written by `qodercli login` may not be readable immediately,
|
|
194
|
-
// so the post-login authentication check retries before declaring failure.
|
|
195
|
-
async function qoderAuthenticatedAfterLogin(executable) {
|
|
196
|
-
const deadline = Date.now() + qoderAuthRecheckTimeoutMs();
|
|
197
|
-
while (true) {
|
|
198
|
-
if (qoderAuthenticated(executable)) return true;
|
|
199
|
-
const remainingMs = deadline - Date.now();
|
|
200
|
-
if (remainingMs <= 0) return false;
|
|
201
|
-
log("Waiting for Qoder login to finish...");
|
|
202
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(qoderAuthRecheckDelayMs(), remainingMs)));
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function qoderLoginCommand(executable) {
|
|
207
|
-
const quoted = `'${executable.replace(/'/g, "''")}'`;
|
|
208
|
-
return IS_WIN32 ? `& ${quoted} login` : `${quoted} login`;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async function ensureQoderReady(provider) {
|
|
212
|
-
if (process.env.AUTOWONDER_SKIP_QODER_BOOTSTRAP === "1") return true;
|
|
213
|
-
const profile = QODER_FAMILY_PROFILES[provider];
|
|
214
|
-
const nodeVersion = qoderNodeVersion();
|
|
215
|
-
const nodeMajor = Number.parseInt(nodeVersion.split(".")[0], 10);
|
|
216
|
-
if (!Number.isInteger(nodeMajor) || nodeMajor < 20) {
|
|
217
|
-
error(`${profile.displayName} requires Node.js 20 or newer; current version is ${nodeVersion}. Upgrade Node.js and retry.`);
|
|
218
|
-
return false;
|
|
219
|
-
}
|
|
220
|
-
let executable = findExecutable(profile.executable);
|
|
221
|
-
if (!executable) executable = installQoderCLI(provider);
|
|
222
|
-
if (!executable) return false;
|
|
223
|
-
if (!qoderAuthenticated(executable)) {
|
|
224
|
-
log(`${profile.displayName} login is required. Opening browser login...`);
|
|
225
|
-
const login = spawnQoderSync(executable, ["login"], { stdio: "inherit" });
|
|
226
|
-
if (login.status !== 0) {
|
|
227
|
-
const detail = login.error?.message || `exit status ${login.status}`;
|
|
228
|
-
error(`Qoder login command failed: ${detail}`);
|
|
229
|
-
error(`Run ${qoderLoginCommand(executable)} and retry.`);
|
|
230
|
-
return false;
|
|
231
|
-
}
|
|
232
|
-
if (!(await qoderAuthenticatedAfterLogin(executable))) {
|
|
233
|
-
error(`Qoder login did not complete. Run ${qoderLoginCommand(executable)} and retry.`);
|
|
234
|
-
return false;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
process.env[profile.envVar] = executable;
|
|
238
|
-
const executableDir = path.dirname(executable);
|
|
239
|
-
const pathEntries = (process.env.PATH || "").split(path.delimiter);
|
|
240
|
-
if (!pathEntries.includes(executableDir)) {
|
|
241
|
-
process.env.PATH = `${executableDir}${path.delimiter}${process.env.PATH || ""}`;
|
|
242
|
-
}
|
|
243
|
-
log(`${profile.displayName} is installed and authenticated.`);
|
|
244
|
-
return true;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
55
|
const QODER_MODELS = new Set([
|
|
248
56
|
"auto", "ultimate", "performance", "efficient", "lite", "cmodel",
|
|
249
57
|
"qmodel_preview", "qmodel_latest", "qmodel", "kmodel_latest", "kmodel",
|
|
@@ -539,7 +347,7 @@ async function cmdConnect(args) {
|
|
|
539
347
|
error(`No agent CLI detected. Install one of: claude, codex, qodercli`);
|
|
540
348
|
process.exit(1);
|
|
541
349
|
}
|
|
542
|
-
if (isQoderFamilyProvider(provider) && !(await ensureQoderReady(provider))) process.exit(1);
|
|
350
|
+
if (isQoderFamilyProvider(provider) && !(await ensureQoderReady(provider, { debug: Boolean(flags.debug) }))) process.exit(1);
|
|
543
351
|
|
|
544
352
|
// A version-pinned npx command must always run the binary bundled with that package.
|
|
545
353
|
// Windows locks a running .exe. Its versioned path is immutable, so another
|
|
@@ -625,14 +433,6 @@ async function cmdConnect(args) {
|
|
|
625
433
|
process.on("SIGTERM", () => { child.kill("SIGTERM"); });
|
|
626
434
|
}
|
|
627
435
|
|
|
628
|
-
function resolveUserPath(value) {
|
|
629
|
-
if (value === "~") return os.homedir();
|
|
630
|
-
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
631
|
-
return path.resolve(os.homedir(), value.slice(2));
|
|
632
|
-
}
|
|
633
|
-
return path.resolve(value);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
436
|
async function cmdStart(args) {
|
|
637
437
|
const flags = parseFlags(args, ["provider", "max-tasks", "addr"]);
|
|
638
438
|
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("child_process");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const { ensureDir, error, findOnPath, log, resolveUserPath } = require("./shared");
|
|
9
|
+
|
|
10
|
+
const IS_WIN32 = process.platform === "win32";
|
|
11
|
+
|
|
12
|
+
// qoder and qodercn are two distinct provider identities that share the same
|
|
13
|
+
// bootstrap flow. Each identity keeps its own executable, npm package, managed
|
|
14
|
+
// install dir, managed config root and env overrides so the two CLIs never
|
|
15
|
+
// shadow each other.
|
|
16
|
+
const QODER_FAMILY_PROFILES = {
|
|
17
|
+
qoder: {
|
|
18
|
+
displayName: "Qoder CLI",
|
|
19
|
+
executable: "qodercli",
|
|
20
|
+
envVar: "AUTOWONDER_QODER_CLI",
|
|
21
|
+
npmPackage: "@qoder-ai/qodercli@latest",
|
|
22
|
+
managedDirName: "qoder-cli",
|
|
23
|
+
managedConfigRootName: "qoder-home",
|
|
24
|
+
configDirEnv: "QODER_CONFIG_DIR",
|
|
25
|
+
defaultConfigRootName: ".qoder",
|
|
26
|
+
managedBinName: "qodercli",
|
|
27
|
+
windowsShimName: "qodercli.cmd",
|
|
28
|
+
unixCandidateRelPaths: [
|
|
29
|
+
[".local", "bin", "qodercli"],
|
|
30
|
+
[".qoder", "bin", "qodercli", "qodercli"],
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
qodercn: {
|
|
34
|
+
displayName: "Qoder CLI CN",
|
|
35
|
+
executable: "qodercn",
|
|
36
|
+
envVar: "AUTOWONDER_QODERCN_CLI",
|
|
37
|
+
npmPackage: "@qodercn-ai/qoderclicn@latest",
|
|
38
|
+
managedDirName: "qoder-cn-cli",
|
|
39
|
+
managedConfigRootName: "qoder-cn-home",
|
|
40
|
+
configDirEnv: "QODERCN_CONFIG_DIR",
|
|
41
|
+
defaultConfigRootName: ".qoder-cn",
|
|
42
|
+
managedBinName: "qodercn",
|
|
43
|
+
windowsShimName: "qodercn.cmd",
|
|
44
|
+
unixCandidateRelPaths: [[".local", "bin", "qodercn"]],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Paths are derived per call instead of at module load so in-process tests can
|
|
49
|
+
// relocate HOME between cases.
|
|
50
|
+
function qoderPaths(profile) {
|
|
51
|
+
const home = os.homedir();
|
|
52
|
+
const autowonderHome = path.join(home, ".autowonder");
|
|
53
|
+
return {
|
|
54
|
+
managedDir: path.join(autowonderHome, profile.managedDirName),
|
|
55
|
+
managedConfigRoot: path.join(autowonderHome, profile.managedConfigRootName),
|
|
56
|
+
defaultConfigRoot: path.join(home, profile.defaultConfigRootName),
|
|
57
|
+
statePath: path.join(autowonderHome, "qoder-cli-state.json"),
|
|
58
|
+
unixCandidates: profile.unixCandidateRelPaths.map((parts) => path.join(home, ...parts)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isQoderFamilyProvider(provider) {
|
|
63
|
+
return provider === "qoder" || provider === "qodercn";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function spawnQoderSync(executable, args, options = {}) {
|
|
67
|
+
// npm exposes package binaries as .cmd shims on Windows. CreateProcess
|
|
68
|
+
// cannot execute those shims directly, so let ComSpec resolve them.
|
|
69
|
+
return spawnSync(executable, args, { ...options, shell: IS_WIN32 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findQoderExecutable(provider) {
|
|
73
|
+
const profile = QODER_FAMILY_PROFILES[provider];
|
|
74
|
+
const paths = qoderPaths(profile);
|
|
75
|
+
const configured = process.env[profile.envVar];
|
|
76
|
+
if (configured) {
|
|
77
|
+
const resolved = resolveUserPath(configured);
|
|
78
|
+
try {
|
|
79
|
+
fs.accessSync(resolved, fs.constants.X_OK);
|
|
80
|
+
return resolved;
|
|
81
|
+
} catch {
|
|
82
|
+
error(`${profile.envVar} points at ${resolved}, which is not executable; falling back to PATH.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const onPath = findOnPath(profile.executable);
|
|
86
|
+
if (onPath) return onPath;
|
|
87
|
+
if (IS_WIN32) {
|
|
88
|
+
const shim = path.join(paths.managedDir, "node_modules", ".bin", profile.windowsShimName);
|
|
89
|
+
try { fs.accessSync(shim, fs.constants.F_OK); return shim; } catch {}
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
const candidates = [
|
|
93
|
+
path.join(paths.managedDir, "node_modules", ".bin", profile.managedBinName),
|
|
94
|
+
...paths.unixCandidates,
|
|
95
|
+
];
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
|
|
98
|
+
}
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function installQoderCLI(provider) {
|
|
103
|
+
const profile = QODER_FAMILY_PROFILES[provider];
|
|
104
|
+
const managedDir = qoderPaths(profile).managedDir;
|
|
105
|
+
log(`${profile.displayName} is not installed. Installing ${profile.npmPackage}...`);
|
|
106
|
+
const args = [
|
|
107
|
+
"install", "--prefix", managedDir, profile.npmPackage, "--no-audit", "--no-fund",
|
|
108
|
+
];
|
|
109
|
+
// npm is a .cmd shim on Windows. When launched by npx, npm_execpath points
|
|
110
|
+
// at npm-cli.js, which Node can execute directly without cmd.exe quoting.
|
|
111
|
+
const runNpm = (npmArgs) => IS_WIN32 && process.env.npm_execpath
|
|
112
|
+
? spawnSync(process.execPath, [process.env.npm_execpath, ...npmArgs], { stdio: "inherit" })
|
|
113
|
+
: spawnSync("npm", npmArgs, { stdio: "inherit" });
|
|
114
|
+
let result = runNpm(args);
|
|
115
|
+
if (result.status !== 0 && IS_WIN32) {
|
|
116
|
+
log(`Default npm registry failed; retrying ${profile.displayName} installation with npmmirror...`);
|
|
117
|
+
result = runNpm([...args, "--registry", "https://registry.npmmirror.com"]);
|
|
118
|
+
}
|
|
119
|
+
if (result.status !== 0) {
|
|
120
|
+
const detail = result.error?.message || (result.signal ? `signal ${result.signal}` : `exit status ${result.status}`);
|
|
121
|
+
error(`Failed to install ${profile.displayName} into ${managedDir}: ${detail}`);
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
const executable = findQoderExecutable(provider);
|
|
125
|
+
if (!executable) error(`${profile.displayName} was installed but ${profile.executable} is not available on PATH.`);
|
|
126
|
+
return executable;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const MACHINE_ID_REL_PATH = path.join(".auth", "machine_id");
|
|
130
|
+
const CREDENTIAL_REL_PATH = path.join(".auth", "user");
|
|
131
|
+
|
|
132
|
+
// The version is captured for diagnostics and identity comparison only. No
|
|
133
|
+
// control-flow decision anywhere in this module reads it.
|
|
134
|
+
function qoderBinaryVersion(executable) {
|
|
135
|
+
const result = spawnQoderSync(executable, ["--version"], {
|
|
136
|
+
encoding: "utf8",
|
|
137
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
138
|
+
timeout: 20000,
|
|
139
|
+
});
|
|
140
|
+
// stdout wins so a warning on stderr cannot masquerade as the version and
|
|
141
|
+
// make the identity comparison see a change that did not happen.
|
|
142
|
+
const firstLine = (stream) => (stream || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
143
|
+
return firstLine(result.stdout) || firstLine(result.stderr) || "unknown";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A missing or unreadable record means "no history", never a bootstrap failure.
|
|
147
|
+
function readQoderState(profile) {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(fs.readFileSync(qoderPaths(profile).statePath, "utf8"));
|
|
150
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
151
|
+
} catch { return {}; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function writeQoderState(profile, provider, entry) {
|
|
155
|
+
const statePath = qoderPaths(profile).statePath;
|
|
156
|
+
const state = readQoderState(profile);
|
|
157
|
+
state[provider] = entry;
|
|
158
|
+
try {
|
|
159
|
+
ensureDir(path.dirname(statePath));
|
|
160
|
+
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readMachineId(configRoot) {
|
|
165
|
+
try {
|
|
166
|
+
return { present: true, value: fs.readFileSync(path.join(configRoot, MACHINE_ID_REL_PATH), "utf8").trim() };
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (err.code === "ENOENT") return { present: false, value: "" };
|
|
169
|
+
return { present: false, value: "", unreadable: err.code || String(err) };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Probes the nearest existing ancestor rather than creating the root, so merely
|
|
174
|
+
// inspecting a root leaves it absent — rung D promises it did not touch the
|
|
175
|
+
// customer's own configuration.
|
|
176
|
+
function configRootWritable(configRoot) {
|
|
177
|
+
let target = configRoot;
|
|
178
|
+
while (!fs.existsSync(target)) {
|
|
179
|
+
const parent = path.dirname(target);
|
|
180
|
+
if (parent === target) return false;
|
|
181
|
+
target = parent;
|
|
182
|
+
}
|
|
183
|
+
const probe = path.join(target, `.autowonder-probe-${process.pid}`);
|
|
184
|
+
try {
|
|
185
|
+
fs.writeFileSync(probe, "probe");
|
|
186
|
+
fs.unlinkSync(probe);
|
|
187
|
+
return true;
|
|
188
|
+
} catch { return false; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function machineIdDrift(before, after) {
|
|
192
|
+
if (after.unreadable) return `${MACHINE_ID_REL_PATH} became unreadable (${after.unreadable})`;
|
|
193
|
+
if (before.present && !after.present) return `${MACHINE_ID_REL_PATH} disappeared while probing authentication`;
|
|
194
|
+
if (before.present && after.present && before.value !== after.value) {
|
|
195
|
+
return `${MACHINE_ID_REL_PATH} changed while probing authentication (${before.value} -> ${after.value})`;
|
|
196
|
+
}
|
|
197
|
+
// An absent id becoming present is the CLI's legal first write.
|
|
198
|
+
return "";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 126 is "found but not executable", 127 is the shell's "not found", and
|
|
202
|
+
// cmd.exe reports the same miss as 9009. All three mean the binary is broken
|
|
203
|
+
// rather than unauthenticated, which is what sends rung B to reinstall it.
|
|
204
|
+
function isCliUnusable(probe) {
|
|
205
|
+
if (probe.spawnError) return true;
|
|
206
|
+
if (probe.status === null || probe.status === undefined) return true;
|
|
207
|
+
if (IS_WIN32 && probe.status === 9009) return true;
|
|
208
|
+
return probe.status === 126 || probe.status === 127;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cliUnusableReason(probe) {
|
|
212
|
+
if (probe.spawnError) return `the CLI could not be started: ${probe.spawnError}`;
|
|
213
|
+
if (probe.status === null || probe.status === undefined) return "the CLI did not exit (timed out or was signalled)";
|
|
214
|
+
return `the CLI exited with status ${probe.status}, which is not an authentication failure`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// `--list-models` is the cheapest command that requires valid credentials, so
|
|
218
|
+
// exit 0 with non-empty stdout is the authentication oracle. Probes always run
|
|
219
|
+
// against the config root under evaluation, otherwise the verdict would
|
|
220
|
+
// describe whatever root the ambient environment points at.
|
|
221
|
+
function probeQoderAuth(profile, executable, configRoot, options = {}) {
|
|
222
|
+
const result = spawnQoderSync(executable, ["--list-models"], {
|
|
223
|
+
encoding: "utf8",
|
|
224
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
225
|
+
timeout: 20000,
|
|
226
|
+
env: { ...process.env, [profile.configDirEnv]: configRoot },
|
|
227
|
+
});
|
|
228
|
+
const probe = {
|
|
229
|
+
ok: result.status === 0 && Boolean((result.stdout || "").trim()),
|
|
230
|
+
status: result.status === undefined ? null : result.status,
|
|
231
|
+
stdout: (result.stdout || "").trim(),
|
|
232
|
+
stderr: (result.stderr || "").trim(),
|
|
233
|
+
spawnError: result.error ? result.error.message : "",
|
|
234
|
+
};
|
|
235
|
+
if (options.debug) {
|
|
236
|
+
log(`[qoder] probe ${executable} --list-models (${profile.configDirEnv}=${configRoot}) -> status=${probe.status} stdout=${probe.stdout || "<empty>"} stderr=${probe.stderr || "<empty>"}`);
|
|
237
|
+
}
|
|
238
|
+
return probe;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function inspectQoderHealth(profile, executable, configRoot, options = {}) {
|
|
242
|
+
const probes = [];
|
|
243
|
+
const machineIds = [];
|
|
244
|
+
const verdict = (state, reason) => ({ state, reason, configRoot, probes, machineIds });
|
|
245
|
+
|
|
246
|
+
if (!configRootWritable(configRoot)) {
|
|
247
|
+
return verdict("store_unusable", `the configuration root is not writable: ${configRoot}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const first = readMachineId(configRoot);
|
|
251
|
+
machineIds.push(first);
|
|
252
|
+
if (options.debug) log(`[qoder] ${MACHINE_ID_REL_PATH} #1 -> ${describeMachineId(first)}`);
|
|
253
|
+
if (first.unreadable) {
|
|
254
|
+
return verdict("store_unusable", `${MACHINE_ID_REL_PATH} exists but cannot be read (${first.unreadable})`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const probeOne = probeQoderAuth(profile, executable, configRoot, options);
|
|
258
|
+
probes.push(probeOne);
|
|
259
|
+
if (isCliUnusable(probeOne)) return verdict("cli_unusable", cliUnusableReason(probeOne));
|
|
260
|
+
|
|
261
|
+
const second = readMachineId(configRoot);
|
|
262
|
+
machineIds.push(second);
|
|
263
|
+
if (options.debug) log(`[qoder] ${MACHINE_ID_REL_PATH} #2 -> ${describeMachineId(second)}`);
|
|
264
|
+
const driftOne = machineIdDrift(first, second);
|
|
265
|
+
if (driftOne) return verdict("store_unusable", driftOne);
|
|
266
|
+
|
|
267
|
+
if (!probeOne.ok) {
|
|
268
|
+
return verdict("needs_login", probeOne.stderr || `the authentication probe exited with status ${probeOne.status}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// A single probe always succeeds right after login, so only a second one can
|
|
272
|
+
// expose a credential store that is usable exactly once.
|
|
273
|
+
const probeTwo = probeQoderAuth(profile, executable, configRoot, options);
|
|
274
|
+
probes.push(probeTwo);
|
|
275
|
+
if (isCliUnusable(probeTwo)) return verdict("cli_unusable", cliUnusableReason(probeTwo));
|
|
276
|
+
|
|
277
|
+
const third = readMachineId(configRoot);
|
|
278
|
+
machineIds.push(third);
|
|
279
|
+
if (options.debug) log(`[qoder] ${MACHINE_ID_REL_PATH} #3 -> ${describeMachineId(third)}`);
|
|
280
|
+
const driftTwo = machineIdDrift(second, third);
|
|
281
|
+
if (driftTwo) return verdict("store_unusable", driftTwo);
|
|
282
|
+
|
|
283
|
+
if (!probeTwo.ok) {
|
|
284
|
+
return verdict("needs_login", probeTwo.stderr || `the second authentication probe exited with status ${probeTwo.status}`);
|
|
285
|
+
}
|
|
286
|
+
return verdict("ready", "");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function describeMachineId(read) {
|
|
290
|
+
if (read.unreadable) return `unreadable (${read.unreadable})`;
|
|
291
|
+
return read.present ? read.value : "absent";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The managed root existing and being healthy is itself the record that a
|
|
295
|
+
// previous run already migrated; no extra state file is involved.
|
|
296
|
+
function selectQoderConfigRoot(profile, executable, options = {}) {
|
|
297
|
+
const paths = qoderPaths(profile);
|
|
298
|
+
const explicit = process.env[profile.configDirEnv];
|
|
299
|
+
if (explicit) return { root: resolveUserPath(explicit), source: "explicit" };
|
|
300
|
+
if (fs.existsSync(paths.managedConfigRoot)) {
|
|
301
|
+
const verdict = inspectQoderHealth(profile, executable, paths.managedConfigRoot, options);
|
|
302
|
+
if (verdict.state === "ready") return { root: paths.managedConfigRoot, source: "managed", verdict };
|
|
303
|
+
}
|
|
304
|
+
return { root: paths.defaultConfigRoot, source: "default" };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Explicit roots are never overwritten; managed and default roots are exported
|
|
308
|
+
// so the daemon and every task it spawns inherit the same root.
|
|
309
|
+
function applyQoderConfigRoot(profile, source, configRoot) {
|
|
310
|
+
if (source === "explicit") return;
|
|
311
|
+
process.env[profile.configDirEnv] = configRoot;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function qoderNodeVersion() {
|
|
315
|
+
if (process.env.NODE_ENV === "test" && process.env.AUTOWONDER_TEST_NODE_VERSION) {
|
|
316
|
+
return process.env.AUTOWONDER_TEST_NODE_VERSION;
|
|
317
|
+
}
|
|
318
|
+
return process.versions.node;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const QODER_AUTH_RECHECK_TIMEOUT_MS = 3 * 60 * 1000;
|
|
322
|
+
|
|
323
|
+
function qoderAuthRecheckTimeoutMs() {
|
|
324
|
+
if (process.env.NODE_ENV === "test" && process.env.AUTOWONDER_TEST_QODER_AUTH_RECHECK_TIMEOUT_MS) {
|
|
325
|
+
return Number.parseInt(process.env.AUTOWONDER_TEST_QODER_AUTH_RECHECK_TIMEOUT_MS, 10) || 1;
|
|
326
|
+
}
|
|
327
|
+
return QODER_AUTH_RECHECK_TIMEOUT_MS;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function qoderAuthRecheckDelayMs() {
|
|
331
|
+
if (process.env.NODE_ENV === "test" && process.env.AUTOWONDER_TEST_QODER_AUTH_RECHECK_DELAY_MS) {
|
|
332
|
+
return Number.parseInt(process.env.AUTOWONDER_TEST_QODER_AUTH_RECHECK_DELAY_MS, 10) || 0;
|
|
333
|
+
}
|
|
334
|
+
return 3000;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function qoderLoginCommand(executable) {
|
|
338
|
+
const quoted = `'${executable.replace(/'/g, "''")}'`;
|
|
339
|
+
return IS_WIN32 ? `& ${quoted} login` : `${quoted} login`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const QODER_STATE_LABELS = {
|
|
343
|
+
ready: "credentials are usable",
|
|
344
|
+
needs_login: "an interactive login is required",
|
|
345
|
+
store_unusable: "the credential store cannot be used",
|
|
346
|
+
cli_unusable: "the CLI itself cannot run",
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
function credentialMtimeMs(credentialPath) {
|
|
350
|
+
try { return fs.statSync(credentialPath).mtimeMs; } catch { return null; }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// A human needs time to finish the browser flow, so keep waiting while the
|
|
354
|
+
// credential file is still unwritten. Once it lands and is still unusable,
|
|
355
|
+
// waiting longer cannot help.
|
|
356
|
+
async function waitForQoderLogin(profile, executable, configRoot, options = {}, baselineMtime) {
|
|
357
|
+
const startedAt = Date.now();
|
|
358
|
+
const deadline = startedAt + qoderAuthRecheckTimeoutMs();
|
|
359
|
+
const credentialPath = path.join(configRoot, CREDENTIAL_REL_PATH);
|
|
360
|
+
if (baselineMtime === undefined) baselineMtime = credentialMtimeMs(credentialPath);
|
|
361
|
+
while (true) {
|
|
362
|
+
const verdict = inspectQoderHealth(profile, executable, configRoot, options);
|
|
363
|
+
if (verdict.state !== "needs_login") return verdict;
|
|
364
|
+
const remainingMs = deadline - Date.now();
|
|
365
|
+
if (remainingMs <= 0) return verdict;
|
|
366
|
+
const currentMtime = credentialMtimeMs(credentialPath);
|
|
367
|
+
const written = currentMtime !== null && currentMtime !== baselineMtime;
|
|
368
|
+
const qualifier = written ? "credentials written, still not usable" : "credentials not written yet";
|
|
369
|
+
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
|
|
370
|
+
log(`Waiting for Qoder login to finish... (${elapsedSeconds}s elapsed, ${qualifier})`);
|
|
371
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(qoderAuthRecheckDelayMs(), remainingMs)));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function latestQoderRunLog(configRoot) {
|
|
376
|
+
const runsDir = path.join(configRoot, "logs", "runs");
|
|
377
|
+
try {
|
|
378
|
+
const entries = fs.readdirSync(runsDir).sort();
|
|
379
|
+
if (!entries.length) return `${runsDir} (no runs recorded)`;
|
|
380
|
+
return path.join(runsDir, entries[entries.length - 1]);
|
|
381
|
+
} catch { return `${runsDir} (unavailable)`; }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function reportQoderDiagnostics(profile, info) {
|
|
385
|
+
const { executable, version, configRoot, rootSource, verdict, identityChanged, previous } = info;
|
|
386
|
+
error(`--- ${profile.displayName} bootstrap diagnostics ---`);
|
|
387
|
+
error(`executable : ${executable}`);
|
|
388
|
+
error(`version : ${version}`);
|
|
389
|
+
error(`config root : ${configRoot} (${rootSource})`);
|
|
390
|
+
error(`verdict : ${verdict.state} - ${verdict.reason || "no further detail"}`);
|
|
391
|
+
if (identityChanged && previous) {
|
|
392
|
+
error(`previous binary : ${previous.resolvedPath} @ ${previous.version} (config root ${previous.configRoot})`);
|
|
393
|
+
}
|
|
394
|
+
(verdict.machineIds || []).forEach((read, index) => {
|
|
395
|
+
error(`machine_id #${index + 1} : ${describeMachineId(read)}`);
|
|
396
|
+
});
|
|
397
|
+
(verdict.probes || []).slice(-2).forEach((probe, index) => {
|
|
398
|
+
error(`probe ${index + 1} status : ${probe.status}${probe.spawnError ? ` (${probe.spawnError})` : ""}`);
|
|
399
|
+
error(`probe ${index + 1} stdout : ${probe.stdout || "<empty>"}`);
|
|
400
|
+
error(`probe ${index + 1} stderr : ${probe.stderr || "<empty>"}`);
|
|
401
|
+
});
|
|
402
|
+
error(`cli run logs : ${latestQoderRunLog(configRoot)}`);
|
|
403
|
+
error("Next step:");
|
|
404
|
+
error(` ${profile.configDirEnv}=${configRoot} ${qoderLoginCommand(executable)}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function runQoderLogin(profile, executable, configRoot) {
|
|
408
|
+
const login = spawnQoderSync(executable, ["login"], {
|
|
409
|
+
stdio: "inherit",
|
|
410
|
+
env: { ...process.env, [profile.configDirEnv]: configRoot },
|
|
411
|
+
});
|
|
412
|
+
if (login.status === 0) return true;
|
|
413
|
+
const detail = login.error?.message || `exit status ${login.status}`;
|
|
414
|
+
error(`Qoder login command failed: ${detail}`);
|
|
415
|
+
error(`Run ${qoderLoginCommand(executable)} and retry.`);
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// The credential mtime baseline must be sampled before login runs: the command
|
|
420
|
+
// can write the file before it exits, and a baseline taken afterwards would
|
|
421
|
+
// report that fresh write as "not written yet".
|
|
422
|
+
async function loginThenWait(profile, executable, configRoot, options) {
|
|
423
|
+
const baselineMtime = credentialMtimeMs(path.join(configRoot, CREDENTIAL_REL_PATH));
|
|
424
|
+
if (!runQoderLogin(profile, executable, configRoot)) return null;
|
|
425
|
+
return waitForQoderLogin(profile, executable, configRoot, options, baselineMtime);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function finishQoderReady(profile, provider, executable, version, configRoot, rootSource) {
|
|
429
|
+
writeQoderState(profile, provider, { resolvedPath: executable, version, configRoot });
|
|
430
|
+
process.env[profile.envVar] = executable;
|
|
431
|
+
const executableDir = path.dirname(executable);
|
|
432
|
+
const pathEntries = (process.env.PATH || "").split(path.delimiter);
|
|
433
|
+
if (!pathEntries.includes(executableDir)) {
|
|
434
|
+
process.env.PATH = `${executableDir}${path.delimiter}${process.env.PATH || ""}`;
|
|
435
|
+
}
|
|
436
|
+
log(`${profile.displayName} is installed and authenticated (${executable}, config root ${configRoot} [${rootSource}]).`);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function ensureQoderReady(provider, options = {}) {
|
|
441
|
+
if (process.env.AUTOWONDER_SKIP_QODER_BOOTSTRAP === "1") return true;
|
|
442
|
+
const profile = QODER_FAMILY_PROFILES[provider];
|
|
443
|
+
const inspectOptions = { debug: Boolean(options.debug) };
|
|
444
|
+
|
|
445
|
+
const nodeVersion = qoderNodeVersion();
|
|
446
|
+
const nodeMajor = Number.parseInt(nodeVersion.split(".")[0], 10);
|
|
447
|
+
if (!Number.isInteger(nodeMajor) || nodeMajor < 20) {
|
|
448
|
+
error(`${profile.displayName} requires Node.js 20 or newer; current version is ${nodeVersion}. Upgrade Node.js and retry.`);
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Never anchored to a version: the customer's own binary always wins, and the
|
|
453
|
+
// managed copy only serves machines that have none.
|
|
454
|
+
let executable = findQoderExecutable(provider);
|
|
455
|
+
// A fresh install spends the single npm budget, so the rungs below must not
|
|
456
|
+
// reinstall the package that just landed.
|
|
457
|
+
let refreshed = false;
|
|
458
|
+
if (!executable) {
|
|
459
|
+
executable = installQoderCLI(provider);
|
|
460
|
+
refreshed = true;
|
|
461
|
+
}
|
|
462
|
+
if (!executable) return false;
|
|
463
|
+
let version = qoderBinaryVersion(executable);
|
|
464
|
+
|
|
465
|
+
const paths = qoderPaths(profile);
|
|
466
|
+
const selection = selectQoderConfigRoot(profile, executable, inspectOptions);
|
|
467
|
+
let configRoot = selection.root;
|
|
468
|
+
let rootSource = selection.source;
|
|
469
|
+
applyQoderConfigRoot(profile, rootSource, configRoot);
|
|
470
|
+
|
|
471
|
+
const previous = readQoderState(profile)[provider] || null;
|
|
472
|
+
const identityChanged = Boolean(previous
|
|
473
|
+
&& (previous.resolvedPath !== executable || previous.version !== version));
|
|
474
|
+
|
|
475
|
+
// A — already healthy.
|
|
476
|
+
let verdict = selection.verdict || inspectQoderHealth(profile, executable, configRoot, inspectOptions);
|
|
477
|
+
const diagnostics = () => reportQoderDiagnostics(profile, {
|
|
478
|
+
executable, version, configRoot, rootSource, verdict, identityChanged, previous,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
if (verdict.state === "ready") {
|
|
482
|
+
return finishQoderReady(profile, provider, executable, version, configRoot, rootSource);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (process.env.AUTOWONDER_QODER_NO_SELF_HEAL === "1") {
|
|
486
|
+
error(`${profile.displayName} is not ready (${QODER_STATE_LABELS[verdict.state]}) and self-healing is disabled by AUTOWONDER_QODER_NO_SELF_HEAL=1.`);
|
|
487
|
+
diagnostics();
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const managedCopy = executable.startsWith(`${paths.managedDir}${path.sep}`);
|
|
492
|
+
|
|
493
|
+
// B — a managed copy that cannot run at all gets refreshed once.
|
|
494
|
+
if (verdict.state === "cli_unusable" && managedCopy && !refreshed) {
|
|
495
|
+
log(`${profile.displayName} cannot run (${verdict.reason}). Refreshing the managed installation...`);
|
|
496
|
+
refreshed = true;
|
|
497
|
+
const reinstalled = installQoderCLI(provider);
|
|
498
|
+
if (reinstalled) {
|
|
499
|
+
executable = reinstalled;
|
|
500
|
+
version = qoderBinaryVersion(executable);
|
|
501
|
+
verdict = inspectQoderHealth(profile, executable, configRoot, inspectOptions);
|
|
502
|
+
if (verdict.state === "ready") {
|
|
503
|
+
return finishQoderReady(profile, provider, executable, version, configRoot, rootSource);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// C — one interactive login in the selected root. Most customers end here.
|
|
509
|
+
if (verdict.state === "needs_login") {
|
|
510
|
+
if (identityChanged) {
|
|
511
|
+
log(`${profile.displayName} executable changed (was ${previous.resolvedPath} @ ${previous.version}, now ${executable} @ ${version}); one login is needed to rebuild credentials.`);
|
|
512
|
+
}
|
|
513
|
+
log(`${profile.displayName} login is required (${verdict.reason || "no usable credentials"}). Opening browser login...`);
|
|
514
|
+
const settled = await loginThenWait(profile, executable, configRoot, inspectOptions);
|
|
515
|
+
if (settled) {
|
|
516
|
+
verdict = settled;
|
|
517
|
+
if (verdict.state === "ready") {
|
|
518
|
+
return finishQoderReady(profile, provider, executable, version, configRoot, rootSource);
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
verdict = { state: "needs_login", reason: "the login command failed", configRoot, probes: [], machineIds: [] };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// D — migrate to an isolated managed configuration root and log in there.
|
|
526
|
+
if (verdict.state !== "cli_unusable") {
|
|
527
|
+
if (rootSource === "explicit") {
|
|
528
|
+
error(`${profile.displayName} cannot use the configuration root set through ${profile.configDirEnv} (${configRoot}): ${verdict.reason}`);
|
|
529
|
+
error(`Point ${profile.configDirEnv} at a usable directory, or unset it so autowonder can manage one, then retry.`);
|
|
530
|
+
diagnostics();
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
if (managedCopy && !refreshed) {
|
|
534
|
+
log(`Refreshing the managed ${profile.displayName} installation before retrying with an isolated configuration root...`);
|
|
535
|
+
refreshed = true;
|
|
536
|
+
const reinstalled = installQoderCLI(provider);
|
|
537
|
+
if (reinstalled) {
|
|
538
|
+
executable = reinstalled;
|
|
539
|
+
version = qoderBinaryVersion(executable);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
log(`${profile.displayName} credentials under ${configRoot} are not usable (${verdict.reason}).`);
|
|
543
|
+
configRoot = paths.managedConfigRoot;
|
|
544
|
+
rootSource = "managed";
|
|
545
|
+
log(`Switching to an isolated configuration root: ${configRoot}`);
|
|
546
|
+
log(`Your own ${profile.displayName} configuration was not modified.`);
|
|
547
|
+
ensureDir(configRoot);
|
|
548
|
+
applyQoderConfigRoot(profile, rootSource, configRoot);
|
|
549
|
+
const settled = await loginThenWait(profile, executable, configRoot, inspectOptions);
|
|
550
|
+
if (settled) {
|
|
551
|
+
verdict = settled;
|
|
552
|
+
if (verdict.state === "ready") {
|
|
553
|
+
return finishQoderReady(profile, provider, executable, version, configRoot, rootSource);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// E — stop guessing and hand the operator the facts.
|
|
559
|
+
error(`${profile.displayName} could not be brought to a usable state (${QODER_STATE_LABELS[verdict.state]}). Run ${qoderLoginCommand(executable)} and retry.`);
|
|
560
|
+
diagnostics();
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
module.exports = {
|
|
565
|
+
QODER_FAMILY_PROFILES,
|
|
566
|
+
ensureQoderReady,
|
|
567
|
+
findQoderExecutable,
|
|
568
|
+
inspectQoderHealth,
|
|
569
|
+
isQoderFamilyProvider,
|
|
570
|
+
qoderBinaryVersion,
|
|
571
|
+
qoderPaths,
|
|
572
|
+
readQoderState,
|
|
573
|
+
reportQoderDiagnostics,
|
|
574
|
+
selectQoderConfigRoot,
|
|
575
|
+
spawnQoderSync,
|
|
576
|
+
waitForQoderLogin,
|
|
577
|
+
writeQoderState,
|
|
578
|
+
};
|
package/bin/shared.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("child_process");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
function ensureDir(dir) {
|
|
9
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function log(msg) {
|
|
13
|
+
console.log(`\x1b[36m[autowonder]\x1b[0m ${msg}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function error(msg) {
|
|
17
|
+
console.error(`\x1b[31m[autowonder]\x1b[0m ${msg}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveUserPath(value) {
|
|
21
|
+
if (value === "~") return os.homedir();
|
|
22
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
23
|
+
return path.resolve(os.homedir(), value.slice(2));
|
|
24
|
+
}
|
|
25
|
+
return path.resolve(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function findOnPath(name) {
|
|
29
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
30
|
+
const found = spawnSync(finder, [name], { encoding: "utf8", timeout: 3000 });
|
|
31
|
+
if (found.status !== 0) return "";
|
|
32
|
+
return (found.stdout || "").split(/\r?\n/).map((value) => value.trim()).find(Boolean) || "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { ensureDir, error, findOnPath, log, resolveUserPath };
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|