autowonder 0.2.130 → 0.2.132

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,17 +6,22 @@ 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.130 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.132 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qoder --model qmodel_latest --reasoning-effort medium --context-window 260000
10
+
11
+ # Qoder CLI CN
12
+ npx -y autowonder@0.2.132 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
10
13
 
11
14
  # Claude Code
12
- npx -y autowonder@0.2.130 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider claude
15
+ npx -y autowonder@0.2.132 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider claude
13
16
 
14
17
  # Codex CLI
15
- npx -y autowonder@0.2.130 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider codex --model gpt-5.5 --reasoning-effort medium
18
+ npx -y autowonder@0.2.132 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider codex --model gpt-5.5 --reasoning-effort medium
16
19
  ```
17
20
 
18
21
  `connect` 会安装当前 npm 包内置的 daemon,并使用页面生成的 WebSocket endpoint、Token 和执行器 ID 建立连接。选择 Qoder 时需要 Node.js 20+;runtime 会在缺少 `qodercli` 时自动通过 npm 安装,并在尚未登录时打开 Qoder 浏览器登录。Claude Code 和 Codex CLI 仍需提前安装并登录。runtime 会继承当前用户的 HOME、环境变量和 CLI 登录状态。
19
22
 
23
+ `qodercn` 是国内版 Qoder CLI。runtime 会在缺少 `qodercn` 时自动把 `@qodercn-ai/qoderclicn` 安装到 `~/.autowonder/qoder-cn-cli`,并在尚未登录时打开浏览器登录。它使用自己的配置根(`QODERCN_CONFIG_DIR`,默认 `~/.qoder-cn`),与国际版 `qoder` 不共享可执行文件、凭证和会话。可用 `AUTOWONDER_QODERCN_CLI` 指定自定义可执行文件路径。
24
+
20
25
  Qoder 的 Context Window 只能使用 `1000000`、`400000` 或 `260000`,Reasoning Effort 只能使用 `none`、`low`、`medium`、`high`、`xhigh` 或 `max`。建议直接复制 AutoWonder 执行器页面生成的命令,避免把模型展示名称误当成 provider model ID。
21
26
 
22
27
  ## 自动更新
@@ -57,14 +62,32 @@ mv "$queue/.assignment.tmp" "$queue/assignment.json"
57
62
  也可以直接提交到本地 API:
58
63
 
59
64
  ```bash
60
- npx -y autowonder@0.2.130 dispatch ./assignment.json
65
+ npx -y autowonder@0.2.132 dispatch ./assignment.json
66
+ ```
67
+
68
+ ## 上传工单附件
69
+
70
+ 把本地需求/设计文档直接上传到工单,避免在会话里传递大文件:
71
+
72
+ ```bash
73
+ npx -y autowonder@0.2.132 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
61
74
  ```
62
75
 
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)。
77
+
63
78
  ## 管理 daemon
64
79
 
65
80
  ```bash
66
- npx -y autowonder@0.2.130 status
67
- npx -y autowonder@0.2.130 stop
81
+ npx -y autowonder@0.2.132 status
82
+ npx -y autowonder@0.2.132 stop
68
83
  ```
69
84
 
70
85
  默认 API 是 `http://127.0.0.1:34989`,日志位于 `~/.autowonder/daemon.log`。npm 包不包含任何 agent、MCP 或服务端凭证。
86
+
87
+ ## 调试
88
+
89
+ ```bash
90
+ npx -y autowonder@0.2.132 connect ... --debug
91
+ ```
92
+
93
+ `--debug` 会让 CLI 以 `AUTOWONDER_DEBUG=1` 启动 daemon,把完整的 daemon 与 agent 活动(runtime 事件流、provider 事件解码、重试退避与会话门等待、空闲时的阻塞点)同时输出到控制台和会话调试日志。调试日志不做脱敏,仅用于本机排障;不传 `--debug` 时该环境变量不会注入。
package/bin/cli.js CHANGED
@@ -24,6 +24,46 @@ const PID_PATH = path.join(AUTOWONDER_HOME, "daemon.pid");
24
24
  const STATE_PATH = path.join(AUTOWONDER_HOME, "daemon-state.json");
25
25
  const LOG_PATH = path.join(AUTOWONDER_HOME, "daemon.log");
26
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
+ }
27
67
 
28
68
  const BINARY_BASE_URL = process.env.AUTOWONDER_BINARY_URL || "";
29
69
  const SOURCE_REPO = process.env.AUTOWONDER_SOURCE_REPO || "";
@@ -59,8 +99,9 @@ function detectProvider() {
59
99
  }
60
100
 
61
101
  function findExecutable(name) {
62
- if (name === "qodercli" && process.env.AUTOWONDER_QODER_CLI) {
63
- const configured = resolveUserPath(process.env.AUTOWONDER_QODER_CLI);
102
+ const profile = profileForExecutable(name);
103
+ if (profile && process.env[profile.envVar]) {
104
+ const configured = resolveUserPath(process.env[profile.envVar]);
64
105
  try { fs.accessSync(configured, fs.constants.X_OK); return configured; } catch {}
65
106
  }
66
107
  const finder = process.platform === "win32" ? "where" : "which";
@@ -69,17 +110,17 @@ function findExecutable(name) {
69
110
  const first = (found.stdout || "").split(/\r?\n/).map((value) => value.trim()).find(Boolean);
70
111
  if (first) return first;
71
112
  }
72
- if (name === "qodercli" && process.platform !== "win32") {
73
- for (const candidate of [
74
- path.join(MANAGED_QODER_DIR, "node_modules", ".bin", "qodercli"),
75
- path.join(os.homedir(), ".local", "bin", "qodercli"),
76
- path.join(os.homedir(), ".qoder", "bin", "qodercli", "qodercli"),
77
- ]) {
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) {
78
119
  try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
79
120
  }
80
121
  }
81
- if (name === "qodercli" && process.platform === "win32") {
82
- const candidate = path.join(MANAGED_QODER_DIR, "node_modules", ".bin", "qodercli.cmd");
122
+ if (profile && process.platform === "win32") {
123
+ const candidate = path.join(profile.managedDir, "node_modules", ".bin", profile.windowsShimName);
83
124
  try { fs.accessSync(candidate, fs.constants.F_OK); return candidate; } catch {}
84
125
  }
85
126
  return "";
@@ -100,10 +141,11 @@ function qoderAuthenticated(executable) {
100
141
  return result.status === 0 && Boolean((result.stdout || "").trim());
101
142
  }
102
143
 
103
- function installQoderCLI() {
104
- log("Qoder CLI is not installed. Installing @qoder-ai/qodercli...");
144
+ function installQoderCLI(provider) {
145
+ const profile = QODER_FAMILY_PROFILES[provider];
146
+ log(`${profile.displayName} is not installed. Installing ${profile.npmPackage}...`);
105
147
  const args = [
106
- "install", "--prefix", MANAGED_QODER_DIR, "@qoder-ai/qodercli@latest", "--no-audit", "--no-fund",
148
+ "install", "--prefix", profile.managedDir, profile.npmPackage, "--no-audit", "--no-fund",
107
149
  ];
108
150
  // npm is a .cmd shim on Windows. When launched by npx, npm_execpath points
109
151
  // at npm-cli.js, which Node can execute directly without cmd.exe quoting.
@@ -112,16 +154,16 @@ function installQoderCLI() {
112
154
  : spawnSync("npm", npmArgs, { stdio: "inherit" });
113
155
  let result = runNpm(args);
114
156
  if (result.status !== 0 && IS_WIN32) {
115
- log("Default npm registry failed; retrying Qoder CLI installation with npmmirror...");
157
+ log(`Default npm registry failed; retrying ${profile.displayName} installation with npmmirror...`);
116
158
  result = runNpm([...args, "--registry", "https://registry.npmmirror.com"]);
117
159
  }
118
160
  if (result.status !== 0) {
119
161
  const detail = result.error?.message || (result.signal ? `signal ${result.signal}` : `exit status ${result.status}`);
120
- error(`Failed to install Qoder CLI into ${MANAGED_QODER_DIR}: ${detail}`);
162
+ error(`Failed to install ${profile.displayName} into ${profile.managedDir}: ${detail}`);
121
163
  return "";
122
164
  }
123
- const executable = findExecutable("qodercli");
124
- if (!executable) error("Qoder CLI was installed but qodercli is not available on PATH.");
165
+ const executable = findExecutable(profile.executable);
166
+ if (!executable) error(`${profile.displayName} was installed but ${profile.executable} is not available on PATH.`);
125
167
  return executable;
126
168
  }
127
169
 
@@ -166,19 +208,20 @@ function qoderLoginCommand(executable) {
166
208
  return IS_WIN32 ? `& ${quoted} login` : `${quoted} login`;
167
209
  }
168
210
 
169
- async function ensureQoderReady() {
211
+ async function ensureQoderReady(provider) {
170
212
  if (process.env.AUTOWONDER_SKIP_QODER_BOOTSTRAP === "1") return true;
213
+ const profile = QODER_FAMILY_PROFILES[provider];
171
214
  const nodeVersion = qoderNodeVersion();
172
215
  const nodeMajor = Number.parseInt(nodeVersion.split(".")[0], 10);
173
216
  if (!Number.isInteger(nodeMajor) || nodeMajor < 20) {
174
- error(`Qoder CLI requires Node.js 20 or newer; current version is ${nodeVersion}. Upgrade Node.js and retry.`);
217
+ error(`${profile.displayName} requires Node.js 20 or newer; current version is ${nodeVersion}. Upgrade Node.js and retry.`);
175
218
  return false;
176
219
  }
177
- let executable = findExecutable("qodercli");
178
- if (!executable) executable = installQoderCLI();
220
+ let executable = findExecutable(profile.executable);
221
+ if (!executable) executable = installQoderCLI(provider);
179
222
  if (!executable) return false;
180
223
  if (!qoderAuthenticated(executable)) {
181
- log("Qoder login is required. Opening browser login...");
224
+ log(`${profile.displayName} login is required. Opening browser login...`);
182
225
  const login = spawnQoderSync(executable, ["login"], { stdio: "inherit" });
183
226
  if (login.status !== 0) {
184
227
  const detail = login.error?.message || `exit status ${login.status}`;
@@ -191,13 +234,13 @@ async function ensureQoderReady() {
191
234
  return false;
192
235
  }
193
236
  }
194
- process.env.AUTOWONDER_QODER_CLI = executable;
237
+ process.env[profile.envVar] = executable;
195
238
  const executableDir = path.dirname(executable);
196
239
  const pathEntries = (process.env.PATH || "").split(path.delimiter);
197
240
  if (!pathEntries.includes(executableDir)) {
198
241
  process.env.PATH = `${executableDir}${path.delimiter}${process.env.PATH || ""}`;
199
242
  }
200
- log("Qoder CLI is installed and authenticated.");
243
+ log(`${profile.displayName} is installed and authenticated.`);
201
244
  return true;
202
245
  }
203
246
 
@@ -483,12 +526,12 @@ async function cmdConnect(args) {
483
526
  const flags = parseFlags(args, ["ws-url", "token", "executor-id", "provider", "name", "max-tasks", "model", "reasoning-effort", "context-window", "memory-mode", "settings"]);
484
527
 
485
528
  if (!flags["ws-url"] || !flags.token || !flags["executor-id"]) {
486
- error("Usage: autowonder connect --ws-url <url> --token <token> --executor-id <id> [--provider qoder]");
529
+ error("Usage: autowonder connect --ws-url <url> --token <token> --executor-id <id> [--provider qoder|qodercn]");
487
530
  process.exit(1);
488
531
  }
489
532
 
490
533
  const requestedProvider = flags.provider;
491
- if (requestedProvider === "qoder" && !validateQoderOptions(flags)) process.exit(1);
534
+ if (isQoderFamilyProvider(requestedProvider) && !validateQoderOptions(flags)) process.exit(1);
492
535
 
493
536
  const providers = detectProvider();
494
537
  const provider = requestedProvider || providers[0];
@@ -496,7 +539,7 @@ async function cmdConnect(args) {
496
539
  error(`No agent CLI detected. Install one of: claude, codex, qodercli`);
497
540
  process.exit(1);
498
541
  }
499
- if (provider === "qoder" && !(await ensureQoderReady())) process.exit(1);
542
+ if (isQoderFamilyProvider(provider) && !(await ensureQoderReady(provider))) process.exit(1);
500
543
 
501
544
  // A version-pinned npx command must always run the binary bundled with that package.
502
545
  // Windows locks a running .exe. Its versioned path is immutable, so another
@@ -505,15 +548,15 @@ async function cmdConnect(args) {
505
548
  if (!installed) process.exit(1);
506
549
 
507
550
  const hasProviderSettings = Boolean(flags.model || flags["reasoning-effort"]);
508
- if (hasProviderSettings && provider !== "codex" && provider !== "qoder") {
509
- error("--model and --reasoning-effort are supported only for codex and qoder");
551
+ if (hasProviderSettings && provider !== "codex" && !isQoderFamilyProvider(provider)) {
552
+ error("--model and --reasoning-effort are supported only for codex, qoder and qodercn");
510
553
  process.exit(1);
511
554
  }
512
- if (flags["context-window"] && provider !== "qoder") {
513
- error("--context-window is supported only for qoder");
555
+ if (flags["context-window"] && !isQoderFamilyProvider(provider)) {
556
+ error("--context-window is supported only for qoder and qodercn");
514
557
  process.exit(1);
515
558
  }
516
- if (provider === "qoder" && !validateQoderOptions(flags)) process.exit(1);
559
+ if (isQoderFamilyProvider(provider) && !validateQoderOptions(flags)) process.exit(1);
517
560
  let claudeSettingsPath = "";
518
561
  if (flags.settings) {
519
562
  if (provider !== "claude") {
@@ -574,6 +617,7 @@ async function cmdConnect(args) {
574
617
  if (flags["reasoning-effort"]) env.AUTOWONDER_REASONING_EFFORT = flags["reasoning-effort"];
575
618
  if (flags["context-window"]) env.AUTOWONDER_CONTEXT_WINDOW = flags["context-window"];
576
619
  if (config.claudeSettingsPath) env.AUTOWONDER_CLAUDE_SETTINGS = config.claudeSettingsPath;
620
+ if (flags.debug) env.AUTOWONDER_DEBUG = "1";
577
621
 
578
622
  const child = spawn(DAEMON_BIN, [], { env, stdio: "inherit" });
579
623
  child.on("exit", (code) => process.exit(code || 0));
@@ -764,6 +808,94 @@ async function cmdDispatch(args) {
764
808
  }
765
809
  }
766
810
 
811
+ async function cmdWorkitem(args) {
812
+ const sub = args[0];
813
+ if (sub === "upload") {
814
+ return cmdWorkitemUpload(args.slice(1));
815
+ }
816
+ error(`Unknown workitem command: ${sub || "(none)"}`);
817
+ cmdHelp();
818
+ process.exit(1);
819
+ }
820
+
821
+ function uploadExitCode(err) {
822
+ if (err && err.statusCode) {
823
+ switch (err.statusCode) {
824
+ case 401: return 3;
825
+ case 403: return 4;
826
+ case 404: return 5;
827
+ case 409: return 6;
828
+ case 413: return 7;
829
+ }
830
+ if (err.statusCode >= 400) return 8;
831
+ return 9;
832
+ }
833
+ if (err && (err.kind === "network" || err.kind === "redirect")) return 9;
834
+ return 2;
835
+ }
836
+
837
+ function reportUploadFailure(err, json) {
838
+ if (json) {
839
+ const payload = { success: false, error: err.message };
840
+ if (err.statusCode) payload.status = err.statusCode;
841
+ if (err.kind) payload.kind = err.kind;
842
+ if (err.serverError) payload.server = err.serverError;
843
+ console.log(JSON.stringify(payload));
844
+ } else {
845
+ error(err.message);
846
+ if (err.serverError && (err.serverError.message || err.serverError.code)) {
847
+ error(`server: ${err.serverError.message || "rejected"} (code ${err.serverError.code})`);
848
+ }
849
+ }
850
+ }
851
+
852
+ async function cmdWorkitemUpload(args) {
853
+ const upload = require("../lib/workitem-upload");
854
+ const json = args.includes("--json");
855
+ try {
856
+ const parsed = upload.parseUploadFlags(args);
857
+ const serverUrl = upload.validateServerUrl(parsed.serverUrl);
858
+ const workitemId = upload.validateWorkitemId(parsed.workitemId);
859
+ const token = parsed.token || process.env.AUTOWONDER_UPLOAD_TOKEN;
860
+ if (!token) {
861
+ throw new upload.UploadError(
862
+ "no upload token provided. Invoke the MCP tool autowonder.workitem_cli_upload_token to mint one, " +
863
+ "then pass it via --token or the AUTOWONDER_UPLOAD_TOKEN environment variable.",
864
+ { kind: "token" },
865
+ );
866
+ }
867
+ const files = upload.preflightFiles(parsed.files);
868
+
869
+ const { statusCode, body } = await upload.uploadWorkitemDocuments({
870
+ serverUrl, workitemId, files, token,
871
+ });
872
+
873
+ if (statusCode === 200) {
874
+ let parsedBody = null;
875
+ try { parsedBody = JSON.parse(body); } catch { /* non-JSON success bodies are ignored */ }
876
+ if (parsed.json) {
877
+ console.log(JSON.stringify({
878
+ success: true,
879
+ workitemId: Number(workitemId),
880
+ uploaded: files.map((f) => f.filename),
881
+ response: parsedBody,
882
+ }));
883
+ } else {
884
+ log(`Uploaded ${files.length} file(s) to workitem ${workitemId}: ${files.map((f) => f.filename).join(", ")}`);
885
+ }
886
+ return;
887
+ }
888
+
889
+ throw new upload.UploadError(
890
+ `server rejected the upload (HTTP ${statusCode})`,
891
+ { kind: "http", statusCode, serverError: upload.parseServerError(body) },
892
+ );
893
+ } catch (err) {
894
+ reportUploadFailure(err, json);
895
+ process.exit(uploadExitCode(err));
896
+ }
897
+ }
898
+
767
899
  async function cmdInstall(args) {
768
900
  const flags = parseFlags(args, ["force", "provider", "max-tasks", "addr"]);
769
901
  const force = flags.force === "true" || args.includes("--force");
@@ -786,9 +918,11 @@ function cmdHelp() {
786
918
  autowonder status Show daemon status
787
919
  autowonder dispatch <assignment.json> Submit a dispatch
788
920
  autowonder install [--force] Install/update and start daemon
921
+ autowonder workitem upload --server-url <url> --workitem-id <id> --file <path> [--file <path>...]
922
+ Upload local requirement/design files to a workitem
789
923
 
790
924
  Options:
791
- --provider <name> Agent provider: claude, codex, qoder (default: auto-detect)
925
+ --provider <name> Agent provider: claude, codex, qoder, qodercn (default: auto-detect; qodercn must be explicit)
792
926
  --max-tasks <n> Max concurrent dispatches (default: 10)
793
927
  --addr <host:port> Local API address (default: 127.0.0.1:34989)
794
928
  --ws-url <url> Executor WebSocket endpoint
@@ -800,6 +934,11 @@ function cmdHelp() {
800
934
  --settings <file> Claude settings JSON file
801
935
  --memory-mode <mode> Memory: platform, provider-local, or none (default: platform)
802
936
  --name <name> Runtime name (default: provider + executor ID)
937
+ --debug Stream full daemon and agent activity to console + log file
938
+ --server-url <url> AutoWonder server base URL (workitem upload)
939
+ --workitem-id <id> Target workitem ID (workitem upload)
940
+ --file <path> Local file to upload, repeatable (workitem upload)
941
+ --json Machine-readable output (workitem upload)
803
942
  -h, --help Show this help
804
943
 
805
944
  Examples:
@@ -810,6 +949,9 @@ function cmdHelp() {
810
949
  npx -y autowonder start
811
950
  npx -y autowonder dispatch ./assignment.json
812
951
 
952
+ # Upload requirement/design documents from local files
953
+ 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
954
+
813
955
  # Check what's happening
814
956
  npx -y autowonder status
815
957
  `);
@@ -847,6 +989,7 @@ async function main() {
847
989
  case "status": return cmdStatus();
848
990
  case "dispatch": return cmdDispatch(commandArgs);
849
991
  case "install": return cmdInstall(commandArgs);
992
+ case "workitem": return cmdWorkitem(commandArgs);
850
993
  case "help":
851
994
  case "--help":
852
995
  case "-h": return cmdHelp();
@@ -0,0 +1,285 @@
1
+ "use strict";
2
+
3
+ // Workitem requirement/design document upload: streaming multipart over
4
+ // Node >= 16 built-ins only. Never Base64 encodes and never buffers whole
5
+ // file bodies; the server is authoritative for validation.
6
+
7
+ const crypto = require("node:crypto");
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 ALLOWED_EXTENSIONS = [".md", ".markdown", ".png", ".jpg", ".jpeg", ".webp"];
14
+ const MAX_FILES = 10;
15
+ const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
16
+ const MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024;
17
+ const UPLOAD_PATH_PREFIX = "/api/cli/workitems/";
18
+ const UPLOAD_PATH_SUFFIX = "/requirement-documents";
19
+
20
+ const CONTENT_TYPES = {
21
+ ".md": "text/markdown",
22
+ ".markdown": "text/markdown",
23
+ ".png": "image/png",
24
+ ".jpg": "image/jpeg",
25
+ ".jpeg": "image/jpeg",
26
+ ".webp": "image/webp",
27
+ };
28
+
29
+ class UploadError extends Error {
30
+ constructor(message, details = {}) {
31
+ super(message);
32
+ this.name = "UploadError";
33
+ Object.assign(this, details);
34
+ }
35
+ }
36
+
37
+ function parseUploadFlags(args) {
38
+ const FLAG_KEYS = {
39
+ "server-url": "serverUrl",
40
+ "workitem-id": "workitemId",
41
+ token: "token",
42
+ file: "file",
43
+ };
44
+ const options = { files: [], json: false };
45
+ for (let i = 0; i < args.length; i++) {
46
+ const arg = args[i];
47
+ if (arg === "--json") {
48
+ options.json = true;
49
+ continue;
50
+ }
51
+ if (!arg.startsWith("--")) {
52
+ throw new UploadError(`unexpected argument: ${arg}`, { kind: "usage" });
53
+ }
54
+ const key = arg.slice(2);
55
+ if (!FLAG_KEYS[key]) {
56
+ throw new UploadError(`unknown option: ${arg}`, { kind: "usage" });
57
+ }
58
+ if (i + 1 >= args.length || args[i + 1].startsWith("--")) {
59
+ throw new UploadError(`--${key} requires a value`, { kind: "usage" });
60
+ }
61
+ const value = args[++i];
62
+ if (key === "file") {
63
+ options.files.push(value);
64
+ continue;
65
+ }
66
+ const optionKey = FLAG_KEYS[key];
67
+ if (options[optionKey] !== undefined) {
68
+ throw new UploadError(`--${key} may only be specified once`, { kind: "usage" });
69
+ }
70
+ options[optionKey] = value;
71
+ }
72
+ return options;
73
+ }
74
+
75
+ function validateServerUrl(value) {
76
+ if (!value) {
77
+ throw new UploadError("--server-url is required", { kind: "usage" });
78
+ }
79
+ let url;
80
+ try {
81
+ url = new URL(value);
82
+ } catch {
83
+ throw new UploadError(`--server-url must be an absolute http(s) URL: ${value}`, { kind: "usage" });
84
+ }
85
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
86
+ throw new UploadError(`--server-url must use http or https: ${value}`, { kind: "usage" });
87
+ }
88
+ if (url.username || url.password) {
89
+ throw new UploadError("--server-url must not embed credentials", { kind: "usage" });
90
+ }
91
+ if (url.search) {
92
+ throw new UploadError("--server-url must not contain a query string", { kind: "usage" });
93
+ }
94
+ if (url.hash) {
95
+ throw new UploadError("--server-url must not contain a fragment", { kind: "usage" });
96
+ }
97
+ return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
98
+ }
99
+
100
+ function validateWorkitemId(value) {
101
+ if (value === undefined || value === null || !/^[1-9][0-9]*$/.test(String(value))) {
102
+ throw new UploadError(`--workitem-id must be a positive integer: ${value}`, { kind: "usage" });
103
+ }
104
+ return String(value);
105
+ }
106
+
107
+ function preflightFiles(filePaths) {
108
+ if (!filePaths.length) {
109
+ throw new UploadError("at least one --file is required", { kind: "usage" });
110
+ }
111
+ if (filePaths.length > MAX_FILES) {
112
+ throw new UploadError(`too many files: at most ${MAX_FILES} per upload`, { kind: "usage" });
113
+ }
114
+ let total = 0;
115
+ const prepared = [];
116
+ for (const filePath of filePaths) {
117
+ let stat;
118
+ try {
119
+ fs.accessSync(filePath, fs.constants.R_OK);
120
+ stat = fs.statSync(filePath);
121
+ } catch {
122
+ throw new UploadError(`file not found or not readable: ${filePath}`, { kind: "usage" });
123
+ }
124
+ if (!stat.isFile()) {
125
+ throw new UploadError(`not a regular file: ${filePath}`, { kind: "usage" });
126
+ }
127
+ const extension = path.extname(filePath).toLowerCase();
128
+ if (!ALLOWED_EXTENSIONS.includes(extension)) {
129
+ throw new UploadError(
130
+ `unsupported file type: ${filePath} (allowed: ${ALLOWED_EXTENSIONS.join(", ")})`,
131
+ { kind: "usage" },
132
+ );
133
+ }
134
+ if (stat.size > MAX_FILE_SIZE_BYTES) {
135
+ throw new UploadError(
136
+ `file too large: ${filePath} (${stat.size} bytes; limit is ${MAX_FILE_SIZE_BYTES})`,
137
+ { kind: "usage" },
138
+ );
139
+ }
140
+ total += stat.size;
141
+ prepared.push({ filePath, filename: path.basename(filePath), size: stat.size });
142
+ }
143
+ if (total > MAX_TOTAL_SIZE_BYTES) {
144
+ throw new UploadError(
145
+ `total upload size too large: ${total} bytes (limit is ${MAX_TOTAL_SIZE_BYTES})`,
146
+ { kind: "usage" },
147
+ );
148
+ }
149
+ return prepared;
150
+ }
151
+
152
+ function writeChunk(request, chunk) {
153
+ return new Promise((resolve) => {
154
+ if (request.write(chunk)) {
155
+ resolve();
156
+ } else {
157
+ request.once("drain", resolve);
158
+ }
159
+ });
160
+ }
161
+
162
+ async function writePart(request, boundary, file) {
163
+ const safeName = file.filename.replace(/["\r\n]/g, "_");
164
+ const contentType = CONTENT_TYPES[path.extname(file.filename).toLowerCase()] || "application/octet-stream";
165
+ await writeChunk(
166
+ request,
167
+ `--${boundary}\r\n` +
168
+ `Content-Disposition: form-data; name="files"; filename="${safeName}"\r\n` +
169
+ `Content-Type: ${contentType}\r\n\r\n`,
170
+ );
171
+ await new Promise((resolve, reject) => {
172
+ const stream = fs.createReadStream(file.filePath);
173
+ stream.on("error", (err) => {
174
+ err.filePath = file.filePath;
175
+ reject(err);
176
+ });
177
+ stream.on("data", (chunk) => {
178
+ if (!request.write(chunk)) {
179
+ stream.pause();
180
+ }
181
+ });
182
+ request.on("drain", () => stream.resume());
183
+ stream.on("end", resolve);
184
+ });
185
+ await writeChunk(request, "\r\n");
186
+ }
187
+
188
+ function parseServerError(body) {
189
+ try {
190
+ const parsed = JSON.parse(body);
191
+ if (parsed && typeof parsed === "object") return parsed;
192
+ } catch {
193
+ // non-JSON bodies are kept as-is by callers
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function uploadWorkitemDocuments({ serverUrl, workitemId, files, token }) {
199
+ return new Promise((resolve, reject) => {
200
+ const boundary = `----autowonder${crypto.randomBytes(16).toString("hex")}`;
201
+ const url = new URL(`${serverUrl}${UPLOAD_PATH_PREFIX}${workitemId}${UPLOAD_PATH_SUFFIX}`);
202
+ const client = url.protocol === "https:" ? https : http;
203
+ let settled = false;
204
+ let sentAny = false;
205
+
206
+ const fail = (err) => {
207
+ if (!settled) {
208
+ settled = true;
209
+ reject(err);
210
+ }
211
+ };
212
+ const finish = (value) => {
213
+ if (!settled) {
214
+ settled = true;
215
+ resolve(value);
216
+ }
217
+ };
218
+
219
+ const request = client.request(
220
+ url,
221
+ {
222
+ method: "POST",
223
+ headers: {
224
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
225
+ Authorization: `Bearer ${token}`,
226
+ },
227
+ },
228
+ (response) => {
229
+ if (response.statusCode >= 300 && response.statusCode < 400) {
230
+ request.destroy();
231
+ fail(new UploadError(
232
+ `server responded with a redirect (HTTP ${response.statusCode}); refusing to follow redirects`,
233
+ { kind: "redirect", statusCode: response.statusCode },
234
+ ));
235
+ return;
236
+ }
237
+ let body = "";
238
+ response.on("data", (chunk) => { body += chunk; });
239
+ response.on("end", () => finish({ statusCode: response.statusCode, body }));
240
+ response.on("error", (err) => fail(new UploadError(`response error: ${err.message}`, { kind: "network" })));
241
+ },
242
+ );
243
+
244
+ request.on("error", (err) => {
245
+ fail(new UploadError(
246
+ sentAny
247
+ ? "connection lost after part of the upload was sent; the outcome is ambiguous. " +
248
+ "List the workitem's documents to see what arrived before retrying."
249
+ : `connection failed: ${err.message}`,
250
+ { kind: "network" },
251
+ ));
252
+ });
253
+
254
+ (async () => {
255
+ try {
256
+ for (const file of files) {
257
+ await writePart(request, boundary, file);
258
+ sentAny = true;
259
+ }
260
+ await writeChunk(request, `--${boundary}--\r\n`);
261
+ request.end();
262
+ } catch (err) {
263
+ request.destroy();
264
+ fail(new UploadError(
265
+ `failed reading ${err.filePath || "file"}: ${err.message}`,
266
+ { kind: "read" },
267
+ ));
268
+ }
269
+ })();
270
+ });
271
+ }
272
+
273
+ module.exports = {
274
+ ALLOWED_EXTENSIONS,
275
+ MAX_FILES,
276
+ MAX_FILE_SIZE_BYTES,
277
+ MAX_TOTAL_SIZE_BYTES,
278
+ UploadError,
279
+ parseUploadFlags,
280
+ validateServerUrl,
281
+ validateWorkitemId,
282
+ preflightFiles,
283
+ parseServerError,
284
+ uploadWorkitemDocuments,
285
+ };
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "autowonder",
3
- "version": "0.2.130",
3
+ "version": "0.2.132",
4
4
  "description": "AutoWonder local runtime — execute AI agent dispatch packages on your machine",
5
5
  "bin": {
6
6
  "autowonder": "bin/cli.js"
7
7
  },
8
8
  "files": [
9
9
  "bin/",
10
+ "lib/",
10
11
  "scripts/",
11
12
  "vendor/"
12
13
  ],