autowonder 0.2.150 → 0.2.152
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 +22 -16
- package/bin/cli.js +86 -22
- package/lib/workitem-download.js +239 -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,23 +6,19 @@ 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.152 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.
|
|
13
|
-
|
|
14
|
-
# Claude Code
|
|
15
|
-
npx -y autowonder@0.2.150 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider claude
|
|
16
|
-
|
|
17
|
-
# Codex CLI
|
|
18
|
-
npx -y autowonder@0.2.150 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider codex --model gpt-5.5 --reasoning-effort medium
|
|
12
|
+
npx -y autowonder@0.2.152 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
|
|
19
13
|
```
|
|
20
14
|
|
|
21
|
-
`connect` 会安装当前 npm 包内置的 daemon,并使用页面生成的 WebSocket endpoint、Token 和执行器 ID
|
|
15
|
+
`connect` 会安装当前 npm 包内置的 daemon,并使用页面生成的 WebSocket endpoint、Token 和执行器 ID 建立连接。客户端只支持通过 Qoder 系列 CLI 启动(`qoder` 对应 `qodercli`,`qodercn` 对应 `qodercn`),其他 `--provider` 取值会在启动 daemon 前直接报错拦截并提示 `Unsupported provider`。选择 Qoder 时需要 Node.js 20+;runtime 会在缺少 `qodercli` 时自动通过 npm 安装,并在尚未登录时打开 Qoder 浏览器登录。runtime 会继承当前用户的 HOME、环境变量和 CLI 登录状态。
|
|
22
16
|
|
|
23
17
|
`qodercn` 是国内版 Qoder CLI。runtime 会在缺少 `qodercn` 时自动把 `@qodercn-ai/qoderclicn` 安装到 `~/.autowonder/qoder-cn-cli`,并在尚未登录时打开浏览器登录。它使用自己的配置根(`QODERCN_CONFIG_DIR`,默认 `~/.qoder-cn`),与国际版 `qoder` 不共享可执行文件、凭证和会话。可用 `AUTOWONDER_QODERCN_CLI` 指定自定义可执行文件路径。
|
|
24
18
|
|
|
25
|
-
Qoder 的 Context Window 只能使用 `1000000`、`400000` 或 `260000`,Reasoning Effort 只能使用 `none`、`low`、`medium`、`high`、`xhigh` 或 `max
|
|
19
|
+
Qoder 的 Context Window 只能使用 `1000000`、`400000` 或 `260000`,Reasoning Effort 只能使用 `none`、`low`、`medium`、`high`、`xhigh` 或 `max`。`--model` 不再校验固定白名单:取值经 `AUTOWONDER_MODEL` 原样透传给 Qoder CLI,因此服务端上线新模型无需升级本客户端。建议直接复制 AutoWonder 执行器页面生成的命令,避免把模型展示名称误当成 provider model ID。
|
|
20
|
+
|
|
21
|
+
daemon 会响应服务端的 `QODER_MODEL_CATALOG_REQUEST` 帧,用当前 provider 对应的本机 Qoder CLI 发现可用模型目录,只回传 `{id, name}`,让执行器页面展示这台机器真实可选的模型。该请求仅在 `executorId` 与 `provider` 都与本机一致时处理,发现超时 20 秒,并发请求合并为一次发现,`qoder` 与 `qodercn` 的发现互相隔离、不共享配置根。
|
|
26
22
|
|
|
27
23
|
给 `connect` 追加 `--token-aware-enable` 可开启 qoder / qodercn 的 token 用量采集:CLI 会以 sidecar 方式启动本地 HTTPS 转发代理(`bin/token-proxy.js`),从 Qoder API 的 SSE 响应流中提取 usage 数据并按 dispatch ID 归属上报。该功能旁路设计、不阻塞任务执行,代理启动失败时仅告警并继续。代理端口可用 `TOKEN_PROXY_PORT` 环境变量调整(默认 19876);CA 证书位于 `~/.autowonder/token-tap/ca-cert.pem`。daemon 退出时 sidecar 一并停止。
|
|
28
24
|
|
|
@@ -66,7 +62,7 @@ mv "$queue/.assignment.tmp" "$queue/assignment.json"
|
|
|
66
62
|
也可以直接提交到本地 API:
|
|
67
63
|
|
|
68
64
|
```bash
|
|
69
|
-
npx -y autowonder@0.2.
|
|
65
|
+
npx -y autowonder@0.2.152 dispatch ./assignment.json
|
|
70
66
|
```
|
|
71
67
|
|
|
72
68
|
## 上传工单附件
|
|
@@ -74,17 +70,27 @@ npx -y autowonder@0.2.150 dispatch ./assignment.json
|
|
|
74
70
|
把本地需求/设计文档直接上传到工单,避免在会话里传递大文件:
|
|
75
71
|
|
|
76
72
|
```bash
|
|
77
|
-
npx -y autowonder@0.2.
|
|
73
|
+
npx -y autowonder@0.2.152 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
|
|
78
74
|
```
|
|
79
75
|
|
|
80
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)。
|
|
81
77
|
|
|
78
|
+
## 下载工单附件
|
|
79
|
+
|
|
80
|
+
把工单上已有的需求/设计文档下载到本地目录,再用本地文件工具阅读,避免把大文件或二进制附件搬进会话上下文:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npx -y autowonder@0.2.152 workitem download --server-url <autowonder-server-url> --workitem-id <id> --file <name-or-id> --output-dir <dir> --json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
下载令牌通过 MCP 工具 `autowonder.workitem_cli_download_token` 签发(与上传令牌相互独立),经 `--token` 或 `AUTOWONDER_DOWNLOAD_TOKEN` 环境变量传入。`--file` 可重复指定文档名或 artifact id,省略则下载该工单的全部附件;`--output-dir` 默认为当前目录;`--json` 输出机器可读结果,含每个文件的 `id`、`name`、`path`、`size`。退出码约定与 `workitem upload` 相同(401→3、403→4、404→5、409→6、413→7、其他 4xx→8、网络/重定向→9)。
|
|
87
|
+
|
|
82
88
|
## 上传定时任务附件
|
|
83
89
|
|
|
84
90
|
把需求/设计文档上传到 7x24 定时任务,用法与工单附件一致(同一套上传令牌与预检规则):
|
|
85
91
|
|
|
86
92
|
```bash
|
|
87
|
-
npx -y autowonder@0.2.
|
|
93
|
+
npx -y autowonder@0.2.152 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
|
|
88
94
|
```
|
|
89
95
|
|
|
90
96
|
上传令牌同样通过 `autowonder.workitem_cli_upload_token` 签发(令牌为用户级,对当前可修改的任意定时任务生效),经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 传入;退出码约定与 `workitem upload` 相同。
|
|
@@ -92,8 +98,8 @@ npx -y autowonder@0.2.150 scheduled-task upload --server-url <autowonder-server-
|
|
|
92
98
|
## 管理 daemon
|
|
93
99
|
|
|
94
100
|
```bash
|
|
95
|
-
npx -y autowonder@0.2.
|
|
96
|
-
npx -y autowonder@0.2.
|
|
101
|
+
npx -y autowonder@0.2.152 status
|
|
102
|
+
npx -y autowonder@0.2.152 stop
|
|
97
103
|
```
|
|
98
104
|
|
|
99
105
|
默认 API 是 `http://127.0.0.1:34989`,日志位于 `~/.autowonder/daemon.log`。npm 包不包含任何 agent、MCP 或服务端凭证。
|
|
@@ -101,7 +107,7 @@ npx -y autowonder@0.2.150 stop
|
|
|
101
107
|
## 调试
|
|
102
108
|
|
|
103
109
|
```bash
|
|
104
|
-
npx -y autowonder@0.2.
|
|
110
|
+
npx -y autowonder@0.2.152 connect ... --debug
|
|
105
111
|
```
|
|
106
112
|
|
|
107
113
|
`--debug` 会让 CLI 以 `AUTOWONDER_DEBUG=1` 启动 daemon,把完整的 daemon 与 agent 活动(runtime 事件流、provider 事件解码、重试退避与会话门等待、空闲时的阻塞点)同时输出到控制台和会话调试日志。调试日志不做脱敏,仅用于本机排障;不传 `--debug` 时该环境变量不会注入。
|
package/bin/cli.js
CHANGED
|
@@ -9,7 +9,7 @@ const os = require("os");
|
|
|
9
9
|
const https = require("https");
|
|
10
10
|
const http = require("http");
|
|
11
11
|
|
|
12
|
-
const { ensureDir, error,
|
|
12
|
+
const { ensureDir, error, log, resolveUserPath } = require("./shared");
|
|
13
13
|
const {
|
|
14
14
|
ensureQoderReady,
|
|
15
15
|
findQoderExecutable,
|
|
@@ -46,26 +46,23 @@ function safeChmod(filePath, mode) {
|
|
|
46
46
|
|
|
47
47
|
function detectProvider() {
|
|
48
48
|
const providers = [];
|
|
49
|
-
if (findOnPath("claude")) providers.push("claude");
|
|
50
|
-
if (findOnPath("codex")) providers.push("codex");
|
|
51
49
|
if (findQoderExecutable("qoder")) providers.push("qoder");
|
|
52
50
|
return providers;
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
// The client only launches the Qoder-family CLIs, so every other provider is
|
|
54
|
+
// rejected here before any unsupported command can be spawned.
|
|
55
|
+
function assertSupportedProvider(provider) {
|
|
56
|
+
if (isQoderFamilyProvider(provider)) return provider;
|
|
57
|
+
const shown = provider ? `: ${provider}` : "";
|
|
58
|
+
error(`Unsupported provider${shown}. The AutoWonder client only launches the Qoder CLI (qodercli) or Qoder CLI CN (qodercn) command; use --provider qoder or --provider qodercn`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
61
62
|
const QODER_CONTEXT_WINDOWS = new Set(["1000000", "400000", "260000"]);
|
|
62
63
|
const QODER_REASONING_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh", "max"]);
|
|
63
64
|
|
|
64
65
|
function validateQoderOptions(flags) {
|
|
65
|
-
if (flags.model && !QODER_MODELS.has(flags.model)) {
|
|
66
|
-
error(`Unsupported Qoder model ID: ${flags.model}. Use a provider model ID shown in the executor form.`);
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
66
|
if (flags["reasoning-effort"] && !QODER_REASONING_EFFORTS.has(flags["reasoning-effort"])) {
|
|
70
67
|
error("--reasoning-effort must be one of: none, low, medium, high, xhigh, max");
|
|
71
68
|
return false;
|
|
@@ -340,14 +337,16 @@ async function cmdConnect(args) {
|
|
|
340
337
|
}
|
|
341
338
|
|
|
342
339
|
const requestedProvider = flags.provider;
|
|
340
|
+
if (requestedProvider) assertSupportedProvider(requestedProvider);
|
|
343
341
|
if (isQoderFamilyProvider(requestedProvider) && !validateQoderOptions(flags)) process.exit(1);
|
|
344
342
|
|
|
345
343
|
const providers = detectProvider();
|
|
346
344
|
const provider = requestedProvider || providers[0];
|
|
347
345
|
if (!provider) {
|
|
348
|
-
error(`No agent CLI detected. Install one of:
|
|
346
|
+
error(`No agent CLI detected. Install one of: qodercli, qodercn`);
|
|
349
347
|
process.exit(1);
|
|
350
348
|
}
|
|
349
|
+
assertSupportedProvider(provider);
|
|
351
350
|
if (isQoderFamilyProvider(provider) && !(await ensureQoderReady(provider, { debug: Boolean(flags.debug) }))) process.exit(1);
|
|
352
351
|
|
|
353
352
|
// A version-pinned npx command must always run the binary bundled with that package.
|
|
@@ -495,17 +494,24 @@ async function cmdConnect(args) {
|
|
|
495
494
|
|
|
496
495
|
async function cmdStart(args) {
|
|
497
496
|
const flags = parseFlags(args, ["provider", "max-tasks", "addr"]);
|
|
497
|
+
if (flags.provider) assertSupportedProvider(flags.provider);
|
|
498
498
|
|
|
499
499
|
if (daemonRunning()) {
|
|
500
500
|
log("Daemon is already running.");
|
|
501
501
|
return true;
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
const providers = detectProvider();
|
|
505
|
+
const provider = flags.provider || providers[0];
|
|
506
|
+
if (!provider) {
|
|
507
|
+
error(`No agent CLI detected. Install one of: qodercli, qodercn`);
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
assertSupportedProvider(provider);
|
|
511
|
+
|
|
504
512
|
const installed = await installBinary();
|
|
505
513
|
if (!installed) process.exit(1);
|
|
506
514
|
|
|
507
|
-
const providers = detectProvider();
|
|
508
|
-
const provider = flags.provider || providers[0] || "claude";
|
|
509
515
|
const addr = flags.addr || DEFAULT_API_ADDR;
|
|
510
516
|
const maxTasks = flags["max-tasks"] || "10";
|
|
511
517
|
|
|
@@ -673,6 +679,9 @@ async function cmdWorkitem(args) {
|
|
|
673
679
|
if (sub === "upload") {
|
|
674
680
|
return cmdWorkitemUpload(args.slice(1));
|
|
675
681
|
}
|
|
682
|
+
if (sub === "download") {
|
|
683
|
+
return cmdWorkitemDownload(args.slice(1));
|
|
684
|
+
}
|
|
676
685
|
error(`Unknown workitem command: ${sub || "(none)"}`);
|
|
677
686
|
cmdHelp();
|
|
678
687
|
process.exit(1);
|
|
@@ -756,6 +765,53 @@ async function cmdWorkitemUpload(args) {
|
|
|
756
765
|
}
|
|
757
766
|
}
|
|
758
767
|
|
|
768
|
+
async function cmdWorkitemDownload(args) {
|
|
769
|
+
const download = require("../lib/workitem-download");
|
|
770
|
+
const json = args.includes("--json");
|
|
771
|
+
try {
|
|
772
|
+
const parsed = download.parseDownloadFlags(args);
|
|
773
|
+
const serverUrl = download.validateServerUrl(parsed.serverUrl);
|
|
774
|
+
const workitemId = download.validateWorkitemId(parsed.workitemId);
|
|
775
|
+
const token = parsed.token || process.env.AUTOWONDER_DOWNLOAD_TOKEN;
|
|
776
|
+
if (!token) {
|
|
777
|
+
throw new download.DownloadError(
|
|
778
|
+
"no download token provided. Invoke the MCP tool autowonder.workitem_cli_download_token to mint one, " +
|
|
779
|
+
"then pass it via --token or the AUTOWONDER_DOWNLOAD_TOKEN environment variable.",
|
|
780
|
+
{ kind: "token" },
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
const outputDir = parsed.outputDir || ".";
|
|
784
|
+
ensureDir(outputDir);
|
|
785
|
+
|
|
786
|
+
const index = await download.fetchDocumentIndex({ serverUrl, workitemId, token });
|
|
787
|
+
const selected = download.selectDocuments(index, parsed.files);
|
|
788
|
+
|
|
789
|
+
const downloaded = [];
|
|
790
|
+
for (const entry of selected) {
|
|
791
|
+
const res = await download.downloadDocument({
|
|
792
|
+
serverUrl, workitemId, artifactId: entry.id, token,
|
|
793
|
+
});
|
|
794
|
+
const filename = download.documentFilename(entry);
|
|
795
|
+
const target = download.writeDocument({ outputDir, filename, body: res.body });
|
|
796
|
+
downloaded.push({ id: entry.id, name: filename, path: target, size: res.body.length });
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (parsed.json) {
|
|
800
|
+
console.log(JSON.stringify({
|
|
801
|
+
success: true,
|
|
802
|
+
workitemId: Number(workitemId),
|
|
803
|
+
outputDir: path.resolve(outputDir),
|
|
804
|
+
downloaded,
|
|
805
|
+
}));
|
|
806
|
+
} else {
|
|
807
|
+
log(`Downloaded ${downloaded.length} file(s) from workitem ${workitemId} to ${path.resolve(outputDir)}: ${downloaded.map((d) => d.name).join(", ")}`);
|
|
808
|
+
}
|
|
809
|
+
} catch (err) {
|
|
810
|
+
reportUploadFailure(err, json);
|
|
811
|
+
process.exit(uploadExitCode(err));
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
759
815
|
async function cmdScheduledTask(args) {
|
|
760
816
|
const sub = args[0];
|
|
761
817
|
if (sub === "upload") {
|
|
@@ -818,6 +874,7 @@ async function cmdScheduledTaskUpload(args) {
|
|
|
818
874
|
|
|
819
875
|
async function cmdInstall(args) {
|
|
820
876
|
const flags = parseFlags(args, ["force", "provider", "max-tasks", "addr"]);
|
|
877
|
+
if (flags.provider) assertSupportedProvider(flags.provider);
|
|
821
878
|
const force = flags.force === "true" || args.includes("--force");
|
|
822
879
|
if (force && daemonRunning() && !await cmdStop({ quiet: true })) {
|
|
823
880
|
process.exit(1);
|
|
@@ -833,18 +890,20 @@ function cmdHelp() {
|
|
|
833
890
|
|
|
834
891
|
Usage:
|
|
835
892
|
autowonder connect --ws-url <url> --token <token> --executor-id <id> Connect to server and run
|
|
836
|
-
autowonder start [--provider
|
|
893
|
+
autowonder start [--provider qoder|qodercn] Start daemon in background
|
|
837
894
|
autowonder stop Stop background daemon
|
|
838
895
|
autowonder status Show daemon status
|
|
839
896
|
autowonder dispatch <assignment.json> Submit a dispatch
|
|
840
897
|
autowonder install [--force] Install/update and start daemon
|
|
841
898
|
autowonder workitem upload --server-url <url> --workitem-id <id> --file <path> [--file <path>...]
|
|
842
899
|
Upload local requirement/design files to a workitem
|
|
900
|
+
autowonder workitem download --server-url <url> --workitem-id <id> [--file <name-or-id>...] --output-dir <dir>
|
|
901
|
+
Download workitem requirement/design files (no --file = all)
|
|
843
902
|
autowonder scheduled-task upload --server-url <url> --scheduled-task-id <id> --file <path> [--file <path>...]
|
|
844
903
|
Upload local requirement/design files to a scheduled task
|
|
845
904
|
|
|
846
905
|
Options:
|
|
847
|
-
--provider <name> Agent provider:
|
|
906
|
+
--provider <name> Agent provider: qoder, qodercn only; other providers are rejected (default: auto-detect; qodercn must be explicit)
|
|
848
907
|
--max-tasks <n> Max concurrent dispatches (default: 10)
|
|
849
908
|
--addr <host:port> Local API address (default: 127.0.0.1:34989)
|
|
850
909
|
--ws-url <url> Executor WebSocket endpoint
|
|
@@ -861,11 +920,13 @@ function cmdHelp() {
|
|
|
861
920
|
also settable via AUTOWONDER_WORKSPACE_ROOT. On Windows use a
|
|
862
921
|
non-C drive path here when the default location is not writable)
|
|
863
922
|
--debug Stream full daemon and agent activity to console + log file
|
|
864
|
-
--server-url <url> AutoWonder server base URL (workitem/scheduled-task upload)
|
|
865
|
-
--workitem-id <id> Target workitem ID (workitem upload)
|
|
923
|
+
--server-url <url> AutoWonder server base URL (workitem/scheduled-task upload, workitem download)
|
|
924
|
+
--workitem-id <id> Target workitem ID (workitem upload/download)
|
|
866
925
|
--scheduled-task-id <id> Target scheduled task ID (scheduled-task upload)
|
|
867
|
-
--file <path> Local file to upload, repeatable (workitem/scheduled-task upload)
|
|
868
|
-
|
|
926
|
+
--file <path> Local file to upload, repeatable (workitem/scheduled-task upload);
|
|
927
|
+
on download, a document name or artifact id to fetch, repeatable (omit for all)
|
|
928
|
+
--output-dir <dir> Directory to save downloaded files into (workitem download; default: .)
|
|
929
|
+
--json Machine-readable output (workitem/scheduled-task upload, workitem download)
|
|
869
930
|
-h, --help Show this help
|
|
870
931
|
|
|
871
932
|
Examples:
|
|
@@ -882,6 +943,9 @@ function cmdHelp() {
|
|
|
882
943
|
# Upload requirement/design documents from local files
|
|
883
944
|
npx -y autowonder@0.2.130 workitem upload --server-url https://private-autowonder.example.com --workitem-id 50063 --file <filepath-1> --file <filepath-2> --file <images-1> --json
|
|
884
945
|
|
|
946
|
+
# Download workitem requirement/design documents to a local directory (omit --file for all)
|
|
947
|
+
npx -y autowonder@0.2.130 workitem download --server-url https://private-autowonder.example.com --workitem-id 50063 --file <name-or-id> --output-dir <dir> --json
|
|
948
|
+
|
|
885
949
|
# Upload requirement/design documents to a 7x24 scheduled task
|
|
886
950
|
npx -y autowonder scheduled-task upload --server-url https://auto-wonder.example.com --scheduled-task-id 123 --file <filepath-1> --file <filepath-2> --json
|
|
887
951
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Workitem requirement/design document download: streaming GET over Node >= 16
|
|
4
|
+
// built-ins only. Bodies are collected as Buffers (never strings) so binary
|
|
5
|
+
// attachments survive intact; the server is authoritative for authorization and
|
|
6
|
+
// validation. Mirrors lib/workitem-upload.js.
|
|
7
|
+
|
|
8
|
+
const fs = require("node:fs");
|
|
9
|
+
const http = require("node:http");
|
|
10
|
+
const https = require("node:https");
|
|
11
|
+
const path = require("node:path");
|
|
12
|
+
|
|
13
|
+
const DOCUMENT_PREFIX = "requirements/";
|
|
14
|
+
const INDEX_PATH_PREFIX = "/api/cli/workitems/";
|
|
15
|
+
const INDEX_PATH_SUFFIX = "/requirement-documents/index";
|
|
16
|
+
|
|
17
|
+
class DownloadError extends Error {
|
|
18
|
+
constructor(message, details = {}) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "DownloadError";
|
|
21
|
+
Object.assign(this, details);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseDownloadFlags(args) {
|
|
26
|
+
const FLAG_KEYS = {
|
|
27
|
+
"server-url": "serverUrl",
|
|
28
|
+
"workitem-id": "workitemId",
|
|
29
|
+
token: "token",
|
|
30
|
+
file: "file",
|
|
31
|
+
"output-dir": "outputDir",
|
|
32
|
+
};
|
|
33
|
+
const options = { files: [], json: false };
|
|
34
|
+
for (let i = 0; i < args.length; i++) {
|
|
35
|
+
const arg = args[i];
|
|
36
|
+
if (arg === "--json") {
|
|
37
|
+
options.json = true;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!arg.startsWith("--")) {
|
|
41
|
+
throw new DownloadError(`unexpected argument: ${arg}`, { kind: "usage" });
|
|
42
|
+
}
|
|
43
|
+
const key = arg.slice(2);
|
|
44
|
+
if (!FLAG_KEYS[key]) {
|
|
45
|
+
throw new DownloadError(`unknown option: ${arg}`, { kind: "usage" });
|
|
46
|
+
}
|
|
47
|
+
if (i + 1 >= args.length || args[i + 1].startsWith("--")) {
|
|
48
|
+
throw new DownloadError(`--${key} requires a value`, { kind: "usage" });
|
|
49
|
+
}
|
|
50
|
+
const value = args[++i];
|
|
51
|
+
if (key === "file") {
|
|
52
|
+
options.files.push(value);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const optionKey = FLAG_KEYS[key];
|
|
56
|
+
if (options[optionKey] !== undefined) {
|
|
57
|
+
throw new DownloadError(`--${key} may only be specified once`, { kind: "usage" });
|
|
58
|
+
}
|
|
59
|
+
options[optionKey] = value;
|
|
60
|
+
}
|
|
61
|
+
return options;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateServerUrl(value) {
|
|
65
|
+
if (!value) {
|
|
66
|
+
throw new DownloadError("--server-url is required", { kind: "usage" });
|
|
67
|
+
}
|
|
68
|
+
let url;
|
|
69
|
+
try {
|
|
70
|
+
url = new URL(value);
|
|
71
|
+
} catch {
|
|
72
|
+
throw new DownloadError(`--server-url must be an absolute http(s) URL: ${value}`, { kind: "usage" });
|
|
73
|
+
}
|
|
74
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
75
|
+
throw new DownloadError(`--server-url must use http or https: ${value}`, { kind: "usage" });
|
|
76
|
+
}
|
|
77
|
+
if (url.username || url.password) {
|
|
78
|
+
throw new DownloadError("--server-url must not embed credentials", { kind: "usage" });
|
|
79
|
+
}
|
|
80
|
+
if (url.search) {
|
|
81
|
+
throw new DownloadError("--server-url must not contain a query string", { kind: "usage" });
|
|
82
|
+
}
|
|
83
|
+
if (url.hash) {
|
|
84
|
+
throw new DownloadError("--server-url must not contain a fragment", { kind: "usage" });
|
|
85
|
+
}
|
|
86
|
+
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateWorkitemId(value) {
|
|
90
|
+
if (value === undefined || value === null || !/^[1-9][0-9]*$/.test(String(value))) {
|
|
91
|
+
throw new DownloadError(`--workitem-id must be a positive integer: ${value}`, { kind: "usage" });
|
|
92
|
+
}
|
|
93
|
+
return String(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseJson(body) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(body);
|
|
99
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
100
|
+
} catch {
|
|
101
|
+
// non-JSON bodies are kept as-is by callers
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Original on-disk filename for an index entry: the stored name carries the
|
|
107
|
+
// "requirements/" prefix, so reduce it to a safe basename.
|
|
108
|
+
function documentFilename(entry) {
|
|
109
|
+
const raw = entry && typeof entry.name === "string" ? entry.name : "";
|
|
110
|
+
const base = raw.startsWith(DOCUMENT_PREFIX) ? raw.slice(DOCUMENT_PREFIX.length) : path.posix.basename(raw);
|
|
111
|
+
const filename = path.basename(base);
|
|
112
|
+
if (!filename || filename === "." || filename === "..") {
|
|
113
|
+
throw new DownloadError(`document has an unusable filename: ${raw || "(empty)"}`, { kind: "http" });
|
|
114
|
+
}
|
|
115
|
+
return filename;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Resolve --file selectors (artifact id or filename, repeatable) against the
|
|
119
|
+
// index. An empty selector list means "every document". Results are deduped by
|
|
120
|
+
// id and preserve index order.
|
|
121
|
+
function selectDocuments(index, selectors) {
|
|
122
|
+
const entries = Array.isArray(index) ? index : [];
|
|
123
|
+
if (!selectors || selectors.length === 0) {
|
|
124
|
+
return entries.slice();
|
|
125
|
+
}
|
|
126
|
+
const chosen = new Map();
|
|
127
|
+
for (const selector of selectors) {
|
|
128
|
+
const match = entries.find((entry) => {
|
|
129
|
+
if (/^[1-9][0-9]*$/.test(String(selector))) {
|
|
130
|
+
return String(entry.id) === String(selector);
|
|
131
|
+
}
|
|
132
|
+
const filename = documentFilename(entry);
|
|
133
|
+
return entry.name === selector || filename === selector || filename === path.basename(selector);
|
|
134
|
+
});
|
|
135
|
+
if (!match) {
|
|
136
|
+
throw new DownloadError(`no requirement document matches: ${selector}`, { kind: "usage" });
|
|
137
|
+
}
|
|
138
|
+
chosen.set(String(match.id), match);
|
|
139
|
+
}
|
|
140
|
+
return entries.filter((entry) => chosen.has(String(entry.id)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function httpGet({ serverUrl, path: requestPath, token }) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const url = new URL(`${serverUrl}${requestPath}`);
|
|
146
|
+
const client = url.protocol === "https:" ? https : http;
|
|
147
|
+
let settled = false;
|
|
148
|
+
const fail = (err) => { if (!settled) { settled = true; reject(err); } };
|
|
149
|
+
const finish = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
150
|
+
|
|
151
|
+
const request = client.request(
|
|
152
|
+
url,
|
|
153
|
+
{ method: "GET", headers: { Authorization: `Bearer ${token}` } },
|
|
154
|
+
(response) => {
|
|
155
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
156
|
+
request.destroy();
|
|
157
|
+
fail(new DownloadError(
|
|
158
|
+
`server responded with a redirect (HTTP ${response.statusCode}); refusing to follow redirects`,
|
|
159
|
+
{ kind: "redirect", statusCode: response.statusCode },
|
|
160
|
+
));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const chunks = [];
|
|
164
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
165
|
+
response.on("end", () => finish({
|
|
166
|
+
statusCode: response.statusCode,
|
|
167
|
+
headers: response.headers,
|
|
168
|
+
body: Buffer.concat(chunks),
|
|
169
|
+
}));
|
|
170
|
+
response.on("error", (err) => fail(
|
|
171
|
+
new DownloadError(`response error: ${err.message}`, { kind: "network" }),
|
|
172
|
+
));
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
request.on("error", (err) => fail(
|
|
177
|
+
new DownloadError(`connection failed: ${err.message}`, { kind: "network" }),
|
|
178
|
+
));
|
|
179
|
+
request.end();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function fetchDocumentIndex({ serverUrl, workitemId, token }) {
|
|
184
|
+
const res = await httpGet({
|
|
185
|
+
serverUrl,
|
|
186
|
+
path: `${INDEX_PATH_PREFIX}${workitemId}${INDEX_PATH_SUFFIX}`,
|
|
187
|
+
token,
|
|
188
|
+
});
|
|
189
|
+
const text = res.body.toString("utf8");
|
|
190
|
+
if (res.statusCode !== 200) {
|
|
191
|
+
throw new DownloadError(
|
|
192
|
+
`server rejected the document list (HTTP ${res.statusCode})`,
|
|
193
|
+
{ kind: "http", statusCode: res.statusCode, serverError: parseJson(text) },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const parsed = parseJson(text);
|
|
197
|
+
if (!parsed || parsed.success !== true || !Array.isArray(parsed.data)) {
|
|
198
|
+
throw new DownloadError("unexpected document list response", { kind: "http", statusCode: res.statusCode });
|
|
199
|
+
}
|
|
200
|
+
return parsed.data;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function downloadDocument({ serverUrl, workitemId, artifactId, token }) {
|
|
204
|
+
const res = await httpGet({
|
|
205
|
+
serverUrl,
|
|
206
|
+
path: `${INDEX_PATH_PREFIX}${workitemId}/requirement-documents/${artifactId}/content`,
|
|
207
|
+
token,
|
|
208
|
+
});
|
|
209
|
+
if (res.statusCode !== 200) {
|
|
210
|
+
throw new DownloadError(
|
|
211
|
+
`server rejected the download (HTTP ${res.statusCode})`,
|
|
212
|
+
{ kind: "http", statusCode: res.statusCode, serverError: parseJson(res.body.toString("utf8")) },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return res;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function writeDocument({ outputDir, filename, body }) {
|
|
219
|
+
if (path.basename(filename) !== filename) {
|
|
220
|
+
throw new DownloadError(`refusing to write outside the output directory: ${filename}`, { kind: "read" });
|
|
221
|
+
}
|
|
222
|
+
const target = path.join(outputDir, filename);
|
|
223
|
+
fs.writeFileSync(target, body);
|
|
224
|
+
return target;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
DOCUMENT_PREFIX,
|
|
229
|
+
DownloadError,
|
|
230
|
+
parseDownloadFlags,
|
|
231
|
+
validateServerUrl,
|
|
232
|
+
validateWorkitemId,
|
|
233
|
+
parseJson,
|
|
234
|
+
documentFilename,
|
|
235
|
+
selectDocuments,
|
|
236
|
+
fetchDocumentIndex,
|
|
237
|
+
downloadDocument,
|
|
238
|
+
writeDocument,
|
|
239
|
+
};
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|