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 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.150 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qoder --model qmodel_latest --reasoning-effort medium --context-window 260000
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.150 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
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 建立连接。选择 Qoder 时需要 Node.js 20+;runtime 会在缺少 `qodercli` 时自动通过 npm 安装,并在尚未登录时打开 Qoder 浏览器登录。Claude Code 和 Codex CLI 仍需提前安装并登录。runtime 会继承当前用户的 HOME、环境变量和 CLI 登录状态。
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`。建议直接复制 AutoWonder 执行器页面生成的命令,避免把模型展示名称误当成 provider model ID。
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.150 dispatch ./assignment.json
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.150 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
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.150 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
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.150 status
96
- npx -y autowonder@0.2.150 stop
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.150 connect ... --debug
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, findOnPath, log, resolveUserPath } = require("./shared");
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
- const QODER_MODELS = new Set([
56
- "auto", "ultimate", "performance", "efficient", "lite",
57
- "qmodel_38max", "qfmodel", "qmodel_latest", "qmodel",
58
- "kmodel_latest", "kmodel", "gmodel", "gfmodel",
59
- "dmodel", "dfmodel", "mmodel",
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: claude, codex, qodercli`);
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 claude] Start daemon in background
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: claude, codex, qoder, qodercn (default: auto-detect; qodercn must be explicit)
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
- --json Machine-readable output (workitem/scheduled-task upload)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autowonder",
3
- "version": "0.2.150",
3
+ "version": "0.2.152",
4
4
  "description": "AutoWonder local runtime — execute AI agent dispatch packages on your machine",
5
5
  "bin": {
6
6
  "autowonder": "bin/cli.js"