@actiondock/cli 2.5.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +60 -5
  2. package/bin/ad.js +4 -4
  3. package/dist/commands/action/create.js +7 -22
  4. package/dist/commands/add.js +5 -5
  5. package/dist/commands/build.js +4 -5
  6. package/dist/commands/config/delete.js +2 -2
  7. package/dist/commands/config/env.js +6 -15
  8. package/dist/commands/config/get.js +8 -39
  9. package/dist/commands/config/list.js +8 -55
  10. package/dist/commands/config/schema.js +4 -6
  11. package/dist/commands/config/set.js +2 -2
  12. package/dist/commands/describe.js +8 -22
  13. package/dist/commands/doctor.js +3 -10
  14. package/dist/commands/export.js +4 -7
  15. package/dist/commands/generate.js +2 -4
  16. package/dist/commands/index.js +2 -5
  17. package/dist/commands/info.js +7 -15
  18. package/dist/commands/list.js +3 -5
  19. package/dist/commands/mcp.js +10 -3
  20. package/dist/commands/pack.js +4 -5
  21. package/dist/commands/playbook.js +14 -36
  22. package/dist/commands/profile.js +4 -11
  23. package/dist/commands/remove.js +13 -9
  24. package/dist/commands/run.d.ts +10 -1
  25. package/dist/commands/run.js +161 -52
  26. package/dist/commands/runs.js +10 -25
  27. package/dist/commands/serve.js +9 -1
  28. package/dist/commands/state-list.js +2 -5
  29. package/dist/commands/state.js +29 -16
  30. package/dist/commands/test.js +6 -2
  31. package/dist/commands/validate.js +3 -5
  32. package/dist/errors.d.ts +14 -1
  33. package/dist/errors.js +53 -3
  34. package/dist/index.d.ts +8 -1
  35. package/dist/index.js +61 -10
  36. package/dist/renderer.d.ts +3 -4
  37. package/dist/renderer.js +7 -34
  38. package/dist/services/config-merge.d.ts +41 -0
  39. package/dist/services/config-merge.js +93 -0
  40. package/dist/services/index.d.ts +1 -0
  41. package/dist/services/index.js +1 -0
  42. package/dist/standalone/index.d.ts +8 -2
  43. package/dist/standalone/index.js +32 -2
  44. package/dist/types.d.ts +6 -3
  45. package/dist/utils/index.d.ts +1 -0
  46. package/dist/utils/index.js +1 -0
  47. package/dist/utils/input.d.ts +1 -0
  48. package/dist/utils/input.js +1 -0
  49. package/dist/utils/spawn.d.ts +13 -0
  50. package/dist/utils/spawn.js +31 -0
  51. package/dist/utils/target.d.ts +21 -0
  52. package/dist/utils/target.js +29 -14
  53. package/dist/utils/utils.d.ts +2 -3
  54. package/dist/utils/utils.js +6 -6
  55. package/package.json +6 -6
@@ -1,9 +1,18 @@
1
+ import { type InvocationControl } from "@actiondock/core";
2
+ import type { ExecutionResult } from "@actiondock/sdk";
1
3
  import { Command } from "commander";
2
4
  import type { CliContext } from "../types.js";
5
+ /**
6
+ * 以原始纯文本形式渲染 Action 执行终态结果(默认纯文本模式)。
7
+ *
8
+ * 设计契约:
9
+ * 1. stdout: 仅承载业务有效载荷(payload)。保持原生排版、未转义多行与真实换行,供用户阅读或下游管道直接消费。
10
+ */
11
+ export declare function renderRawExecutionResult(targetRef: string, result: ExecutionResult, context?: CliContext): void;
3
12
  /**
4
13
  * 统一执行 Action 核心逻辑(使用 ActionDockTarget 门面)。
5
14
  */
6
- export declare function executeAction(id: string, options: any, context?: CliContext): Promise<void>;
15
+ export declare function executeAction(id: string, options: any, context?: CliContext, flatArgs?: string[], control?: InvocationControl): Promise<void>;
7
16
  /**
8
17
  * 挂载 run 子命令至指定 Commander 节点。
9
18
  *
@@ -1,32 +1,119 @@
1
- import { readFileSync } from "node:fs";
2
- import { parseDuration, resolveTarget } from "@actiondock/core";
3
- import { ArgumentError, ExecutionError, SigintError } from "../errors.js";
4
- import { writeStdout } from "../renderer.js";
5
- import { applyTargetOptions, getEffectiveOptions, resolveLocalPackageRoot, withTarget, } from "../utils/index.js";
1
+ import { parseDuration, resolveTarget, validateActionInputValue, mapInputValidationFailure, } from "@actiondock/core";
2
+ import { ArgumentError, ExecutionError, SigintError, packageNotFoundError } from "../errors.js";
3
+ import { writeStderr, writeStdout } from "../renderer.js";
4
+ import { applyTargetOptions, getEffectiveOptions, resolveActionInput, resolveLocalPackageRoot, withTarget, } from "../utils/index.js";
6
5
  /**
7
- * 统一执行 Action 核心逻辑(使用 ActionDockTarget 门面)。
6
+ * 以原始纯文本形式渲染 Action 执行终态结果(默认纯文本模式)。
7
+ *
8
+ * 设计契约:
9
+ * 1. stdout: 仅承载业务有效载荷(payload)。保持原生排版、未转义多行与真实换行,供用户阅读或下游管道直接消费。
8
10
  */
9
- export async function executeAction(id, options, context) {
10
- if (!id) {
11
- throw new ArgumentError("Action ID is required for run");
12
- }
13
- let input = {};
14
- if (options.input) {
15
- try {
16
- input = JSON.parse(options.input);
11
+ export function renderRawExecutionResult(targetRef, result, context) {
12
+ if (result.ok) {
13
+ const data = result.data;
14
+ let rawText;
15
+ let metaInfo;
16
+ if (typeof data === "string") {
17
+ rawText = data;
17
18
  }
18
- catch (err) {
19
- throw new ArgumentError(`Error parsing --input JSON: ${err.message}`);
19
+ else if (data !== null && typeof data === "object") {
20
+ if ("content" in data && data.content !== undefined) {
21
+ rawText =
22
+ typeof data.content === "object" && data.content !== null
23
+ ? JSON.stringify(data.content, null, 2)
24
+ : String(data.content);
25
+ const { content, ...rest } = data;
26
+ if (Object.keys(rest).length > 0) {
27
+ metaInfo = rest;
28
+ }
29
+ }
30
+ else if ("text" in data && typeof data.text === "string") {
31
+ rawText = data.text;
32
+ const { text, ...rest } = data;
33
+ if (Object.keys(rest).length > 0) {
34
+ metaInfo = rest;
35
+ }
36
+ }
37
+ else if ("message" in data && typeof data.message === "string") {
38
+ rawText = data.message;
39
+ const { message, ...rest } = data;
40
+ if (Object.keys(rest).length > 0) {
41
+ metaInfo = rest;
42
+ }
43
+ }
44
+ else {
45
+ rawText = JSON.stringify(data, null, 2);
46
+ }
20
47
  }
48
+ else if (data !== undefined) {
49
+ rawText = String(data);
50
+ }
51
+ else {
52
+ rawText = "";
53
+ }
54
+ if (metaInfo) {
55
+ const parts = [];
56
+ const title = metaInfo.path ? String(metaInfo.path) : targetRef;
57
+ parts.push(title);
58
+ if (metaInfo.startLine !== undefined && metaInfo.endLine !== undefined) {
59
+ parts.push(`lines ${metaInfo.startLine}-${metaInfo.endLine}`);
60
+ }
61
+ else if (metaInfo.line !== undefined) {
62
+ parts.push(`line ${metaInfo.line}`);
63
+ }
64
+ if (metaInfo.hasMore !== undefined) {
65
+ parts.push(`hasMore: ${metaInfo.hasMore}`);
66
+ }
67
+ if (metaInfo.truncated) {
68
+ parts.push("truncated: true");
69
+ }
70
+ const handled = new Set(["path", "startLine", "endLine", "line", "hasMore", "truncated"]);
71
+ for (const [k, v] of Object.entries(metaInfo)) {
72
+ if (!handled.has(k) && v !== undefined) {
73
+ parts.push(`${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
74
+ }
75
+ }
76
+ if (parts.length > 0) {
77
+ writeStderr(`[${parts.join(" | ")}]`, context);
78
+ }
79
+ }
80
+ writeStdout(rawText, context);
21
81
  }
22
- else if (options.inputFile) {
23
- try {
24
- input = JSON.parse(readFileSync(options.inputFile, "utf-8"));
82
+ else {
83
+ writeStderr(`Error [${result.error.code}]: ${result.error.message}`, context);
84
+ if (result.error.details) {
85
+ writeStderr(typeof result.error.details === "string"
86
+ ? result.error.details
87
+ : JSON.stringify(result.error.details, null, 2), context);
25
88
  }
26
- catch (err) {
27
- throw new ArgumentError(`Error reading --input-file: ${err.message}`);
89
+ if (context) {
90
+ context.exitCode = 1;
91
+ }
92
+ if (!context?.control) {
93
+ process.exitCode = 1;
28
94
  }
29
95
  }
96
+ }
97
+ /**
98
+ * 统一执行 Action 核心逻辑(使用 ActionDockTarget 门面)。
99
+ */
100
+ export async function executeAction(id, options, context, flatArgs, control) {
101
+ if (!id) {
102
+ throw new ArgumentError("Action ID is required for run");
103
+ }
104
+ const effectiveControl = control ?? context?.control;
105
+ const effectiveSignal = effectiveControl?.signal;
106
+ const effectiveFlatArgs = flatArgs ?? options.flatArgs;
107
+ const input = await resolveActionInput({
108
+ input: options.input,
109
+ inputFile: options.inputFile,
110
+ flatArgs: effectiveFlatArgs && effectiveFlatArgs.length > 0 ? effectiveFlatArgs : undefined,
111
+ stdin: context?.stdin,
112
+ });
113
+ const check = validateActionInputValue(input);
114
+ if (!check.valid) {
115
+ throw mapInputValidationFailure("cli-pre-target", check);
116
+ }
30
117
  let timeoutMs;
31
118
  if (options.timeout) {
32
119
  try {
@@ -45,13 +132,6 @@ export async function executeAction(id, options, context) {
45
132
  configOverrides[k] = v.join("=");
46
133
  }
47
134
  }
48
- const controller = new AbortController();
49
- let receivedSigint = false;
50
- const sigintHandler = () => {
51
- receivedSigint = true;
52
- controller.abort(new Error("Interrupted by SIGINT"));
53
- };
54
- process.once("SIGINT", sigintHandler);
55
135
  try {
56
136
  // 异步执行仅支持远端目标,本地模式直接拒绝
57
137
  const resolvedTopology = resolveTarget({
@@ -66,7 +146,7 @@ export async function executeAction(id, options, context) {
66
146
  // 目标拓扑解析(仅 local 分支需要包寻址)
67
147
  const targetPackageRoot = resolveLocalPackageRoot(options.package);
68
148
  if (options.package && !targetPackageRoot) {
69
- throw new ArgumentError(`Package '${options.package}' not found in linked packages or path`);
149
+ throw packageNotFoundError(options.package);
70
150
  }
71
151
  let targetRef = id;
72
152
  if (options.package && !id.includes("/") && !id.includes(":")) {
@@ -74,48 +154,68 @@ export async function executeAction(id, options, context) {
74
154
  }
75
155
  // 通过 Target 统一执行
76
156
  await withTarget(options, context, async (target) => {
157
+ const isMachine = Boolean(options.json);
77
158
  if (options.async) {
78
159
  const ticket = await target.startAction(targetRef, input, {
79
- signal: controller.signal,
160
+ signal: effectiveSignal,
80
161
  timeoutMs,
81
162
  config: configOverrides,
82
163
  requestId: options.requestId,
83
164
  });
84
- const asyncOutput = {
85
- ok: ticket.status !== "failed",
86
- runId: ticket.runId,
87
- status: ticket.status,
88
- };
89
- writeStdout(JSON.stringify(asyncOutput, null, 2), context);
165
+ if (isMachine) {
166
+ const asyncOutput = {
167
+ ok: ticket.status !== "failed",
168
+ runId: ticket.runId,
169
+ status: ticket.status,
170
+ };
171
+ writeStdout(JSON.stringify(asyncOutput, null, 2), context);
172
+ }
173
+ else {
174
+ writeStderr(`[${ticket.status}] runId: ${ticket.runId}`, context);
175
+ writeStdout(ticket.runId, context);
176
+ }
90
177
  if (ticket.status === "failed") {
91
- process.exitCode = 1;
178
+ if (context) {
179
+ context.exitCode = 1;
180
+ }
92
181
  return;
93
182
  }
94
183
  }
95
184
  else {
96
185
  const result = await target.runAction(targetRef, input, {
97
- signal: controller.signal,
186
+ signal: effectiveSignal,
98
187
  timeoutMs,
99
188
  config: configOverrides,
100
189
  requestId: options.requestId,
101
190
  });
102
- writeStdout(JSON.stringify(result, null, 2), context);
191
+ if (effectiveControl?.cancellationSource === "sigint" &&
192
+ (effectiveSignal?.aborted || (!result.ok && result.error?.code === "ACTION_CANCELLED"))) {
193
+ throw new SigintError();
194
+ }
195
+ if (isMachine) {
196
+ writeStdout(JSON.stringify(result, null, 2), context);
197
+ }
198
+ else {
199
+ renderRawExecutionResult(targetRef, result, context);
200
+ }
103
201
  if (!result.ok) {
104
- process.exitCode = 1;
202
+ if (context) {
203
+ context.exitCode = 1;
204
+ }
105
205
  return;
106
206
  }
107
207
  }
108
- }, { localRoot: targetPackageRoot || undefined, scanLinkedPackages: true });
208
+ }, { localRoot: targetPackageRoot || undefined, scanLinkedPackages: true, ownDataDir: true });
109
209
  }
110
210
  catch (err) {
111
- if (receivedSigint || err?.name === "AbortError" || err?.message?.includes("SIGINT")) {
211
+ const isSigint = effectiveControl?.cancellationSource === "sigint" ||
212
+ err?.message?.includes("Interrupted by SIGINT");
213
+ if (isSigint &&
214
+ (err?.name === "AbortError" || effectiveSignal?.aborted || err?.message?.includes("SIGINT") || err instanceof SigintError)) {
112
215
  throw new SigintError();
113
216
  }
114
217
  throw err;
115
218
  }
116
- finally {
117
- process.removeListener("SIGINT", sigintHandler);
118
- }
119
219
  }
120
220
  /**
121
221
  * 挂载 run 子命令至指定 Commander 节点。
@@ -125,11 +225,11 @@ export async function executeAction(id, options, context) {
125
225
  */
126
226
  export function attachRunCommand(parent, context) {
127
227
  const cmd = parent
128
- .command("run <id>")
228
+ .command("run <id> [params...]")
129
229
  .description("Execute an action (from current project, linked packages, or remote profile)")
130
230
  .option("-P, --package <id>", "Target package ID or path")
131
- .option("-i, --input <json>", "Input as JSON string")
132
- .option("-f, --input-file <path>", "Input from JSON file")
231
+ .option("-i, --input <json>", "Action input as inline JSON")
232
+ .option("-f, --input-file <path>", "Action input from JSON file, or '-' for stdin")
133
233
  .option("-c, --config <key=value...>", "Temporary config override (repeatable)");
134
234
  return applyTargetOptions(cmd)
135
235
  .option("--timeout <duration>", "Execution timeout (e.g. 30s, 5m, 500ms)")
@@ -137,10 +237,19 @@ export function attachRunCommand(parent, context) {
137
237
  .option("--async", "Execute asynchronously in background (requires remote server or profile)")
138
238
  .option("--data-dir <path>", "Custom database directory")
139
239
  .option("--json", "Output as JSON")
140
- .option("--envelope", "Wrap JSON output in standard envelope")
141
- .action(async (id, rawOptions, cmd) => {
142
- const options = getEffectiveOptions(rawOptions, cmd);
143
- await executeAction(id, options, context);
240
+ .action(async (id, params, rawOptions, cmd) => {
241
+ let flatArgs;
242
+ let effectiveRawOptions = rawOptions;
243
+ let effectiveCmd = cmd;
244
+ if (Array.isArray(params)) {
245
+ flatArgs = params.length > 0 ? params : undefined;
246
+ }
247
+ else {
248
+ effectiveCmd = rawOptions;
249
+ effectiveRawOptions = params;
250
+ }
251
+ const options = getEffectiveOptions(effectiveRawOptions, effectiveCmd);
252
+ await executeAction(id, { ...options, flatArgs }, context, undefined, context?.control);
144
253
  });
145
254
  }
146
255
  /**
@@ -1,7 +1,7 @@
1
- import { filterWithFallbackInfo, findProjectRoot, listLinkedPackages, loadProjectConfig, resolvePackageRoot, resolveTarget, } from "@actiondock/core";
2
- import { ArgumentError, ExecutionError } from "../errors.js";
1
+ import { filterWithFallbackInfo, findProjectRoot, listLinkedPackages, loadProjectConfig, resolvePackageRoot, } from "@actiondock/core";
2
+ import { ArgumentError, ExecutionError, NO_PROJECT_NO_LINKED_MESSAGE, notInProjectError, packageNotFoundError, } from "../errors.js";
3
3
  import { renderResult, renderRunDetail, renderRunsList } from "../renderer.js";
4
- import { applyTargetOptions, getEffectiveOptions, resolveIntent, withRemoteTarget, withTarget, } from "../utils/index.js";
4
+ import { applyTargetOptions, getEffectiveOptions, resolveIntent, resolveTargetFromOptions, withRemoteTarget, withTarget, } from "../utils/index.js";
5
5
  /**
6
6
  * 解析本地目标包根目录与工程配置(仅在 local 分支调用)。
7
7
  * 显式指定包且寻址失败时抛出参数错误;工程清单损坏时降级为链接包视图。
@@ -10,7 +10,7 @@ function resolveLocalRunScope(packageOption) {
10
10
  if (packageOption) {
11
11
  const root = resolvePackageRoot(packageOption);
12
12
  if (!root) {
13
- throw new ArgumentError(`Package '${packageOption}' not found in linked packages or path`);
13
+ throw packageNotFoundError(packageOption);
14
14
  }
15
15
  try {
16
16
  return { targetPackageRoot: root, projConfig: loadProjectConfig(root) };
@@ -53,7 +53,6 @@ export function registerRunsCommands(program, context) {
53
53
  .option("--no-fallback", "Disable fallback to full list when no items match intent")
54
54
  .option("--data-dir <path>", "Custom database storage directory")
55
55
  .option("--json", "Output as JSON")
56
- .option("--envelope", "Wrap JSON output in standard envelope")
57
56
  .action(async (patterns = [], rawOptions, cmd) => {
58
57
  const options = getEffectiveOptions(rawOptions, cmd);
59
58
  const effectiveIntent = resolveIntent(options.intent, patterns);
@@ -63,10 +62,9 @@ export function registerRunsCommands(program, context) {
63
62
  // 通过 Target 门面统一访问
64
63
  await withTarget(options, context, async (target, resolved) => {
65
64
  const records = await target.listRuns({
66
- packageId: scope.projConfig?.id || options.package,
65
+ packageId: options.package,
67
66
  actionId: options.action,
68
- intent: effectiveIntent,
69
- limit,
67
+ limit: 500,
70
68
  });
71
69
  // 若处于本地无项目环境且无任何软链接包,则直接输出友好提示
72
70
  if (resolved.type === "local" && !scope.targetPackageRoot) {
@@ -74,8 +72,7 @@ export function registerRunsCommands(program, context) {
74
72
  if (linked.length === 0) {
75
73
  renderResult([], {
76
74
  json: options.json,
77
- envelope: options.envelope,
78
- humanFormatter: () => "No ActionDock project in current directory, and no packages linked.",
75
+ humanFormatter: () => NO_PROJECT_NO_LINKED_MESSAGE,
79
76
  context,
80
77
  });
81
78
  return;
@@ -85,7 +82,6 @@ export function registerRunsCommands(program, context) {
85
82
  const capped = filterRes.items.slice(0, limit);
86
83
  renderResult(capped, {
87
84
  json: options.json,
88
- envelope: options.envelope,
89
85
  humanFormatter: () => {
90
86
  let title = "Execution Runs";
91
87
  if (resolved.type === "remote") {
@@ -110,7 +106,6 @@ export function registerRunsCommands(program, context) {
110
106
  .option("-P, --package <id>", "Target package ID or path"))
111
107
  .option("--data-dir <path>", "Custom database storage directory")
112
108
  .option("--json", "Output as JSON")
113
- .option("--envelope", "Wrap JSON output in standard envelope")
114
109
  .action(async (id, rawOptions, cmd) => {
115
110
  const options = getEffectiveOptions(rawOptions, cmd);
116
111
  if (!id) {
@@ -133,7 +128,6 @@ export function registerRunsCommands(program, context) {
133
128
  }
134
129
  renderResult(run, {
135
130
  json: options.json,
136
- envelope: options.envelope,
137
131
  humanFormatter: () => renderRunDetail(run),
138
132
  context,
139
133
  });
@@ -145,18 +139,12 @@ export function registerRunsCommands(program, context) {
145
139
  .description("Cancel a running action execution on a remote server"))
146
140
  .option("-r, --reason <reason>", "Reason for cancellation")
147
141
  .option("--json", "Output as JSON")
148
- .option("--envelope", "Wrap JSON output in standard envelope")
149
142
  .action(async (id, rawOptions, cmd) => {
150
143
  const options = getEffectiveOptions(rawOptions, cmd);
151
144
  if (!id) {
152
145
  throw new ArgumentError("Run ID is required for cancel");
153
146
  }
154
- const resolved = resolveTarget({
155
- profile: options.profile,
156
- server: options.server,
157
- token: options.token,
158
- insecure: options.insecure,
159
- }, context?.customHome);
147
+ const resolved = resolveTargetFromOptions(options, context);
160
148
  if (resolved.type === "local") {
161
149
  throw new ArgumentError("'ad runs cancel' is only supported for remote execution targets. Use --profile <name> or --server <url>.");
162
150
  }
@@ -165,7 +153,6 @@ export function registerRunsCommands(program, context) {
165
153
  const isErrorOutcome = result.outcome === "not_found" || result.outcome === "not_owner";
166
154
  renderResult(result, {
167
155
  json: options.json,
168
- envelope: options.envelope,
169
156
  humanFormatter: () => {
170
157
  if (result.outcome === "not_found") {
171
158
  return `Error: Run record '${id}' not found on remote server.`;
@@ -190,7 +177,6 @@ export function registerRunsCommands(program, context) {
190
177
  .option("-a, --action <actionId>", "Filter by action ID"))
191
178
  .option("--data-dir <path>", "Custom database storage directory")
192
179
  .option("--json", "Output as JSON")
193
- .option("--envelope", "Wrap JSON output in standard envelope")
194
180
  .action(async (rawOptions, cmd) => {
195
181
  const options = getEffectiveOptions(rawOptions, cmd);
196
182
  let targetPackageRoot;
@@ -199,14 +185,14 @@ export function registerRunsCommands(program, context) {
199
185
  if (options.package) {
200
186
  const root = resolvePackageRoot(options.package);
201
187
  if (!root) {
202
- throw new ArgumentError(`Package '${options.package}' not found in linked packages or path`);
188
+ throw packageNotFoundError(options.package);
203
189
  }
204
190
  targetPackageRoot = root;
205
191
  }
206
192
  else {
207
193
  targetPackageRoot = findProjectRoot() || undefined;
208
194
  if (!targetPackageRoot) {
209
- throw new ArgumentError("Not in an ActionDock project. Please specify -P, --package <id> or cd into a project directory.");
195
+ throw notInProjectError("Please specify -P, --package <id> or cd into a project directory.");
210
196
  }
211
197
  }
212
198
  try {
@@ -223,7 +209,6 @@ export function registerRunsCommands(program, context) {
223
209
  const payload = { ok: true, clearedCount: count };
224
210
  renderResult(payload, {
225
211
  json: options.json,
226
- envelope: options.envelope,
227
212
  humanFormatter: () => resolved.type === "remote"
228
213
  ? `Cleared ${count} execution run(s) on remote server.`
229
214
  : `Cleared ${count} execution run(s) in package '${packageId}'.`,
@@ -192,7 +192,15 @@ export function registerServeCommand(program, context) {
192
192
  writeStdout(`Press Ctrl+C to terminate.\n`, context);
193
193
  const stopSignalHandler = () => {
194
194
  writeStdout("\nStopping ActionDock server...", context);
195
- server.stop();
195
+ Promise.resolve(server.stop())
196
+ .catch((err) => {
197
+ // 停止失败必须可见:写入 stderr 一行并标记失败退出码
198
+ writeStderr(`[ERROR] Failed to stop ActionDock server gracefully: ${err instanceof Error ? err.message : String(err)}`, context);
199
+ process.exitCode = 1;
200
+ })
201
+ .finally(() => {
202
+ process.exit(typeof process.exitCode === "number" ? process.exitCode : 0);
203
+ });
196
204
  };
197
205
  process.once("SIGINT", stopSignalHandler);
198
206
  process.once("SIGTERM", stopSignalHandler);
@@ -1,5 +1,6 @@
1
1
  import { filterWithFallbackInfo, listLinkedPackages, loadProjectConfig, } from "@actiondock/core";
2
2
  import { existsSync } from "node:fs";
3
+ import { NO_PROJECT_NO_LINKED_MESSAGE } from "../errors.js";
3
4
  import { renderResult, renderStateList } from "../renderer.js";
4
5
  /**
5
6
  * 渲染单个项目作用域的状态键列表(含 --detail JSON 明细分支)。
@@ -25,7 +26,6 @@ export async function renderProjectScopedStateList(args) {
25
26
  const filterRes = filterWithFallbackInfo(entries, effectiveIntent, [(e) => e.fullKey, (e) => e.key, (e) => e.namespace], shouldFallback);
26
27
  renderResult(filterRes.items, {
27
28
  json: true,
28
- envelope: options.envelope,
29
29
  context,
30
30
  });
31
31
  return;
@@ -33,7 +33,6 @@ export async function renderProjectScopedStateList(args) {
33
33
  const filterRes = filterWithFallbackInfo(allKeys, effectiveIntent, [(k) => k], shouldFallback);
34
34
  renderResult(filterRes.items, {
35
35
  json: options.json,
36
- envelope: options.envelope,
37
36
  humanFormatter: () => renderStateList(filterRes.items, `${projConfig.name} (${projConfig.id})`, filterRes.isFallback, effectiveIntent),
38
37
  context,
39
38
  });
@@ -48,8 +47,7 @@ export async function renderLinkedPackagesStateList(args) {
48
47
  if (linked.length === 0) {
49
48
  renderResult([], {
50
49
  json: options.json,
51
- envelope: options.envelope,
52
- humanFormatter: () => "No ActionDock project in current directory, and no packages linked.",
50
+ humanFormatter: () => NO_PROJECT_NO_LINKED_MESSAGE,
53
51
  context,
54
52
  });
55
53
  return;
@@ -75,7 +73,6 @@ export async function renderLinkedPackagesStateList(args) {
75
73
  const filterRes = filterWithFallbackInfo(aggregatedKeys, effectiveIntent, [(k) => k], shouldFallback);
76
74
  renderResult(filterRes.items, {
77
75
  json: options.json,
78
- envelope: options.envelope,
79
76
  humanFormatter: () => renderStateList(filterRes.items, "Linked Packages State Store", filterRes.isFallback, effectiveIntent),
80
77
  context,
81
78
  });
@@ -1,8 +1,27 @@
1
1
  import { decodeStateKey, findProjectRoot, loadProjectConfig, resolvePackageRoot, } from "@actiondock/core";
2
- import { ArgumentError, ExecutionError } from "../errors.js";
3
- import { renderResult, renderStateList, writeStdout } from "../renderer.js";
2
+ import { ArgumentError, ExecutionError, packageNotFoundError } from "../errors.js";
3
+ import { renderResult, renderStateList, writeStderr, writeStdout } from "../renderer.js";
4
4
  import { applyTargetOptions, getEffectiveOptions, getTargetRoot, resolveIntent, withTarget, } from "../utils/index.js";
5
5
  import { renderLinkedPackagesStateList, renderProjectScopedStateList, } from "./state-list.js";
6
+ /**
7
+ * 解析复合状态键为命名空间与裸键视图。
8
+ *
9
+ * 多冒号歧义键(如 `review:owner/repo:42`)不再直接崩溃:
10
+ * 降级为「整串作为裸 key」并在 stderr 提示可用 -n/--namespace 显式指定,
11
+ * 保持与本地存储层歧义兜底(deleteStateSmart 的纯键回退)一致的容错语义。
12
+ */
13
+ function decodeStateKeyWithFallback(rawKey, hasExplicitNamespace, context) {
14
+ try {
15
+ const decoded = decodeStateKey(rawKey);
16
+ return { namespace: decoded.namespace || undefined, key: decoded.key };
17
+ }
18
+ catch {
19
+ if (!hasExplicitNamespace) {
20
+ writeStderr(`[WARN] State key '${rawKey}' contains multiple colon delimiters; treating it as a bare key. Use -n/--namespace to specify the namespace explicitly.`, context);
21
+ }
22
+ return { namespace: undefined, key: rawKey };
23
+ }
24
+ }
6
25
  /**
7
26
  * 注册 state 状态管理命令(get、set、delete、clear、keys、list)。
8
27
  *
@@ -28,7 +47,6 @@ export function registerStateCommands(program, context) {
28
47
  });
29
48
  renderResult(keys, {
30
49
  json: options.json,
31
- envelope: options.envelope,
32
50
  humanFormatter: () => renderStateList(keys, `Remote Server ${resolved.serverUrl}${resolved.profileName ? ` (Profile: ${resolved.profileName})` : ""}`, false, effectiveIntent),
33
51
  context,
34
52
  });
@@ -39,7 +57,7 @@ export function registerStateCommands(program, context) {
39
57
  if (options.package) {
40
58
  targetRoot = resolvePackageRoot(options.package);
41
59
  if (!targetRoot) {
42
- throw new ArgumentError(`Package '${options.package}' not found in linked packages or path`);
60
+ throw packageNotFoundError(options.package);
43
61
  }
44
62
  }
45
63
  else {
@@ -81,7 +99,6 @@ export function registerStateCommands(program, context) {
81
99
  .option("--no-fallback", "Disable fallback to full list when no items match intent")
82
100
  .option("--data-dir <path>", "Custom database storage directory")
83
101
  .option("--json", "Output as JSON")
84
- .option("--envelope", "Wrap JSON output in standard envelope")
85
102
  .action(handleListKeys);
86
103
  // state keys 别名
87
104
  applyTargetOptions(stateCmd
@@ -95,7 +112,6 @@ export function registerStateCommands(program, context) {
95
112
  .option("--no-fallback", "Disable fallback")
96
113
  .option("--data-dir <path>", "Custom database storage directory")
97
114
  .option("--json", "Output as JSON")
98
- .option("--envelope", "Wrap JSON output in standard envelope")
99
115
  .action(handleListKeys);
100
116
  // state get <key>
101
117
  applyTargetOptions(stateCmd
@@ -106,7 +122,6 @@ export function registerStateCommands(program, context) {
106
122
  .option("-n, --namespace <ns>", "Explicit namespace scope for key"))
107
123
  .option("--data-dir <path>", "Custom database storage directory")
108
124
  .option("--json", "Output as JSON")
109
- .option("--envelope", "Wrap JSON output in standard envelope")
110
125
  .action(async (rawKey, rawOptions, cmd) => {
111
126
  const options = getEffectiveOptions(rawOptions, cmd);
112
127
  if (!rawKey) {
@@ -115,8 +130,8 @@ export function registerStateCommands(program, context) {
115
130
  await withTarget(options, context, async (target, resolved) => {
116
131
  const actionId = options.action || "";
117
132
  if (resolved.type === "remote") {
118
- const decoded = decodeStateKey(rawKey);
119
- const effectiveNamespace = options.namespace || (decoded.namespace || undefined);
133
+ const decoded = decodeStateKeyWithFallback(rawKey, options.namespace !== undefined, context);
134
+ const effectiveNamespace = options.namespace || decoded.namespace;
120
135
  const actualKey = options.namespace ? rawKey : decoded.key;
121
136
  const entry = await target.getState(options.package || "", actionId, actualKey, {
122
137
  namespace: effectiveNamespace,
@@ -128,7 +143,6 @@ export function registerStateCommands(program, context) {
128
143
  const val = entry.value;
129
144
  renderResult({ key: rawKey, value: val, namespace: entry.namespace || effectiveNamespace }, {
130
145
  json: options.json,
131
- envelope: options.envelope,
132
146
  humanFormatter: () => (typeof val === "object" ? JSON.stringify(val, null, 2) : String(val)),
133
147
  context,
134
148
  });
@@ -148,7 +162,6 @@ export function registerStateCommands(program, context) {
148
162
  const matchedNamespace = entry.namespace;
149
163
  renderResult({ key: rawKey, value: val, namespace: matchedNamespace }, {
150
164
  json: options.json,
151
- envelope: options.envelope,
152
165
  humanFormatter: () => (typeof val === "object" ? JSON.stringify(val, null, 2) : String(val)),
153
166
  context,
154
167
  });
@@ -182,8 +195,8 @@ export function registerStateCommands(program, context) {
182
195
  await withTarget(options, context, async (target, resolved) => {
183
196
  const actionId = options.action || "";
184
197
  if (resolved.type === "remote") {
185
- const decoded = decodeStateKey(rawKey);
186
- const effectiveNamespace = options.namespace || (decoded.namespace || undefined);
198
+ const decoded = decodeStateKeyWithFallback(rawKey, options.namespace !== undefined, context);
199
+ const effectiveNamespace = options.namespace || decoded.namespace;
187
200
  const actualKey = options.namespace ? rawKey : decoded.key;
188
201
  await target.setState(options.package || "", actionId, actualKey, parsedVal, {
189
202
  namespace: effectiveNamespace,
@@ -198,7 +211,7 @@ export function registerStateCommands(program, context) {
198
211
  let actualNamespace = options.namespace;
199
212
  let finalKey = effectiveKey;
200
213
  if (options.namespace === undefined && effectiveKey.includes(":")) {
201
- const decoded = decodeStateKey(effectiveKey);
214
+ const decoded = decodeStateKeyWithFallback(effectiveKey, false, context);
202
215
  actualNamespace = decoded.namespace;
203
216
  finalKey = decoded.key;
204
217
  }
@@ -227,8 +240,8 @@ export function registerStateCommands(program, context) {
227
240
  await withTarget(options, context, async (target, resolved) => {
228
241
  const actionId = options.action || "";
229
242
  if (resolved.type === "remote") {
230
- const decoded = decodeStateKey(rawKey);
231
- const effectiveNamespace = options.namespace || (decoded.namespace || undefined);
243
+ const decoded = decodeStateKeyWithFallback(rawKey, options.namespace !== undefined, context);
244
+ const effectiveNamespace = options.namespace || decoded.namespace;
232
245
  const actualKey = options.namespace ? rawKey : decoded.key;
233
246
  const deleted = await target.deleteState(options.package || "", actionId, actualKey, {
234
247
  namespace: effectiveNamespace,
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { findProjectRoot } from "@actiondock/core";
4
+ import { writeStderr } from "../renderer.js";
4
5
  import { spawnAsync } from "../utils/index.js";
5
6
  export function registerTestCommand(program) {
6
7
  program
@@ -32,11 +33,14 @@ export function registerTestCommand(program) {
32
33
  testArgs.push(pattern);
33
34
  }
34
35
  }
35
- // stdio inherit 保持实时输出;进程异常(如命令不存在)时回退退出码 1
36
+ // stdio inherit 保持实时输出;进程异常(如命令不存在)时透传错误消息并以退出码 1 失败
36
37
  const proc = await spawnAsync(testCmd, testArgs, {
37
38
  cwd,
38
39
  stdio: "inherit",
39
- }).catch(() => ({ status: 1, signal: null, stdout: "", stderr: "" }));
40
+ }).catch((err) => {
41
+ writeStderr(`[ERROR] Failed to run test command '${testCmd}': ${err instanceof Error ? err.message : String(err)}`);
42
+ return { status: 1, signal: null, stdout: "", stderr: "" };
43
+ });
40
44
  if (proc.status !== 0) {
41
45
  process.exitCode = proc.status ?? 1;
42
46
  }