@actiondock/cli 2.5.0 → 2.6.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 (51) hide show
  1. package/README.md +44 -3
  2. package/dist/commands/action/create.js +2 -2
  3. package/dist/commands/add.js +5 -5
  4. package/dist/commands/build.js +4 -5
  5. package/dist/commands/config/delete.js +2 -2
  6. package/dist/commands/config/env.js +6 -15
  7. package/dist/commands/config/get.js +8 -39
  8. package/dist/commands/config/list.js +8 -55
  9. package/dist/commands/config/schema.js +4 -6
  10. package/dist/commands/config/set.js +2 -2
  11. package/dist/commands/describe.js +2 -4
  12. package/dist/commands/doctor.js +3 -10
  13. package/dist/commands/export.js +4 -7
  14. package/dist/commands/generate.js +2 -4
  15. package/dist/commands/index.js +2 -5
  16. package/dist/commands/info.js +7 -15
  17. package/dist/commands/list.js +3 -5
  18. package/dist/commands/mcp.js +23 -3
  19. package/dist/commands/pack.js +4 -5
  20. package/dist/commands/playbook.js +14 -36
  21. package/dist/commands/profile.js +4 -11
  22. package/dist/commands/remove.js +13 -9
  23. package/dist/commands/run.d.ts +8 -0
  24. package/dist/commands/run.js +118 -33
  25. package/dist/commands/runs.js +10 -25
  26. package/dist/commands/serve.js +31 -4
  27. package/dist/commands/state-list.js +2 -5
  28. package/dist/commands/state.js +29 -16
  29. package/dist/commands/test.js +6 -2
  30. package/dist/commands/validate.js +3 -5
  31. package/dist/errors.d.ts +14 -1
  32. package/dist/errors.js +20 -2
  33. package/dist/index.js +1 -2
  34. package/dist/renderer.d.ts +0 -2
  35. package/dist/renderer.js +3 -15
  36. package/dist/services/config-merge.d.ts +41 -0
  37. package/dist/services/config-merge.js +93 -0
  38. package/dist/services/index.d.ts +1 -0
  39. package/dist/services/index.js +1 -0
  40. package/dist/types.d.ts +0 -2
  41. package/dist/utils/index.d.ts +1 -0
  42. package/dist/utils/index.js +1 -0
  43. package/dist/utils/input.d.ts +46 -0
  44. package/dist/utils/input.js +89 -0
  45. package/dist/utils/spawn.d.ts +13 -0
  46. package/dist/utils/spawn.js +31 -0
  47. package/dist/utils/target.d.ts +21 -0
  48. package/dist/utils/target.js +29 -14
  49. package/dist/utils/utils.d.ts +2 -3
  50. package/dist/utils/utils.js +6 -6
  51. package/package.json +6 -6
@@ -1,32 +1,106 @@
1
- import { readFileSync } from "node:fs";
2
1
  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";
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
  }
21
- }
22
- else if (options.inputFile) {
23
- try {
24
- input = JSON.parse(readFileSync(options.inputFile, "utf-8"));
48
+ else if (data !== undefined) {
49
+ rawText = String(data);
25
50
  }
26
- catch (err) {
27
- throw new ArgumentError(`Error reading --input-file: ${err.message}`);
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
+ }
28
79
  }
80
+ writeStdout(rawText, context);
29
81
  }
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);
88
+ }
89
+ process.exitCode = 1;
90
+ }
91
+ }
92
+ /**
93
+ * 统一执行 Action 核心逻辑(使用 ActionDockTarget 门面)。
94
+ */
95
+ export async function executeAction(id, options, context) {
96
+ if (!id) {
97
+ throw new ArgumentError("Action ID is required for run");
98
+ }
99
+ const input = await resolveActionInput({
100
+ input: options.input,
101
+ inputFile: options.inputFile,
102
+ stdin: context?.stdin,
103
+ });
30
104
  let timeoutMs;
31
105
  if (options.timeout) {
32
106
  try {
@@ -66,7 +140,7 @@ export async function executeAction(id, options, context) {
66
140
  // 目标拓扑解析(仅 local 分支需要包寻址)
67
141
  const targetPackageRoot = resolveLocalPackageRoot(options.package);
68
142
  if (options.package && !targetPackageRoot) {
69
- throw new ArgumentError(`Package '${options.package}' not found in linked packages or path`);
143
+ throw packageNotFoundError(options.package);
70
144
  }
71
145
  let targetRef = id;
72
146
  if (options.package && !id.includes("/") && !id.includes(":")) {
@@ -74,6 +148,7 @@ export async function executeAction(id, options, context) {
74
148
  }
75
149
  // 通过 Target 统一执行
76
150
  await withTarget(options, context, async (target) => {
151
+ const isMachine = Boolean(options.json);
77
152
  if (options.async) {
78
153
  const ticket = await target.startAction(targetRef, input, {
79
154
  signal: controller.signal,
@@ -81,12 +156,18 @@ export async function executeAction(id, options, context) {
81
156
  config: configOverrides,
82
157
  requestId: options.requestId,
83
158
  });
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);
159
+ if (isMachine) {
160
+ const asyncOutput = {
161
+ ok: ticket.status !== "failed",
162
+ runId: ticket.runId,
163
+ status: ticket.status,
164
+ };
165
+ writeStdout(JSON.stringify(asyncOutput, null, 2), context);
166
+ }
167
+ else {
168
+ writeStderr(`[${ticket.status}] runId: ${ticket.runId}`, context);
169
+ writeStdout(ticket.runId, context);
170
+ }
90
171
  if (ticket.status === "failed") {
91
172
  process.exitCode = 1;
92
173
  return;
@@ -99,13 +180,18 @@ export async function executeAction(id, options, context) {
99
180
  config: configOverrides,
100
181
  requestId: options.requestId,
101
182
  });
102
- writeStdout(JSON.stringify(result, null, 2), context);
183
+ if (isMachine) {
184
+ writeStdout(JSON.stringify(result, null, 2), context);
185
+ }
186
+ else {
187
+ renderRawExecutionResult(targetRef, result, context);
188
+ }
103
189
  if (!result.ok) {
104
190
  process.exitCode = 1;
105
191
  return;
106
192
  }
107
193
  }
108
- }, { localRoot: targetPackageRoot || undefined, scanLinkedPackages: true });
194
+ }, { localRoot: targetPackageRoot || undefined, scanLinkedPackages: true, ownDataDir: true });
109
195
  }
110
196
  catch (err) {
111
197
  if (receivedSigint || err?.name === "AbortError" || err?.message?.includes("SIGINT")) {
@@ -128,8 +214,8 @@ export function attachRunCommand(parent, context) {
128
214
  .command("run <id>")
129
215
  .description("Execute an action (from current project, linked packages, or remote profile)")
130
216
  .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")
217
+ .option("-i, --input <json>", "Action input as inline JSON")
218
+ .option("-f, --input-file <path>", "Action input from JSON file, or '-' for stdin")
133
219
  .option("-c, --config <key=value...>", "Temporary config override (repeatable)");
134
220
  return applyTargetOptions(cmd)
135
221
  .option("--timeout <duration>", "Execution timeout (e.g. 30s, 5m, 500ms)")
@@ -137,7 +223,6 @@ export function attachRunCommand(parent, context) {
137
223
  .option("--async", "Execute asynchronously in background (requires remote server or profile)")
138
224
  .option("--data-dir <path>", "Custom database directory")
139
225
  .option("--json", "Output as JSON")
140
- .option("--envelope", "Wrap JSON output in standard envelope")
141
226
  .action(async (id, rawOptions, cmd) => {
142
227
  const options = getEffectiveOptions(rawOptions, cmd);
143
228
  await executeAction(id, options, context);
@@ -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}'.`,
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { findProjectRoot, formatHostForUrl, loadProjectConfig, startActionDockServer, } from "@actiondock/core";
3
+ import { createActionDockTarget, findProjectRoot, formatHostForUrl, loadProjectConfig, startActionDockServer, } from "@actiondock/core";
4
+ import { createNodePlatform } from "@actiondock/runtime-node";
4
5
  import { ArgumentError, ExecutionError } from "../errors.js";
5
6
  import { writeStderr, writeStdout } from "../renderer.js";
6
7
  import { ensureSelfSignedCertificate, getEffectiveOptions, parseByteSize } from "../utils/index.js";
@@ -30,9 +31,11 @@ export function registerServeCommand(program, context) {
30
31
  .option("--tls-ca <path>", "Path to TLS CA certificate file (or set ACTIONDOCK_TLS_CA)")
31
32
  .option("--tls-passphrase <passphrase>", "Passphrase for TLS private key (or set ACTIONDOCK_TLS_PASSPHRASE)")
32
33
  .option("-d, --dir <path>", "Project root directory (default: current working directory)")
34
+ .option("--data-dir <path>", "Custom database storage directory")
33
35
  .action(async (rawOptions, cmd) => {
34
36
  const options = getEffectiveOptions(rawOptions, cmd);
35
- const port = parseInt(options.port, 10) || 5177;
37
+ const parsedPort = parseInt(options.port, 10);
38
+ const port = Number.isNaN(parsedPort) ? 5177 : parsedPort;
36
39
  const host = options.host || "127.0.0.1";
37
40
  const token = options.token || (typeof process !== "undefined" ? process.env?.ACTIONDOCK_TOKEN : undefined);
38
41
  const allowInsecureNoAuth = Boolean(options.allowInsecureNoAuth);
@@ -101,6 +104,19 @@ export function registerServeCommand(program, context) {
101
104
  // 降级处理
102
105
  }
103
106
  }
107
+ const platform = createNodePlatform({
108
+ customHome: context?.customHome,
109
+ dataDir: options.dataDir || context?.dataDir,
110
+ rootDir: projectRoot || undefined,
111
+ });
112
+ const target = await createActionDockTarget({
113
+ type: "local",
114
+ projectRoot: projectRoot || undefined,
115
+ customHome: context?.customHome,
116
+ dataDir: options.dataDir || context?.dataDir,
117
+ platform,
118
+ scanLinkedPackages: !projectRoot,
119
+ });
104
120
  let mcpHandler;
105
121
  const enableMcp = options.mcp !== false;
106
122
  if (enableMcp) {
@@ -112,7 +128,7 @@ export function registerServeCommand(program, context) {
112
128
  ]);
113
129
  const handler = createMcpHandler(() => {
114
130
  return createActionDockMcpServer({
115
- projectRoot: projectRoot || undefined,
131
+ target,
116
132
  });
117
133
  }, {
118
134
  onerror: (err) => {
@@ -124,6 +140,7 @@ export function registerServeCommand(program, context) {
124
140
  };
125
141
  }
126
142
  catch (err) {
143
+ await target.close().catch(() => { });
127
144
  throw new ExecutionError(`Failed to initialize MCP endpoint: ${err?.message || String(err)}`, err);
128
145
  }
129
146
  }
@@ -142,6 +159,7 @@ export function registerServeCommand(program, context) {
142
159
  mcpHandler,
143
160
  tls,
144
161
  projectRoot: projectRoot || undefined,
162
+ target,
145
163
  });
146
164
  const displayHost = formatHostForUrl(host);
147
165
  const actualEndpointHost = formatHostForUrl(host === "0.0.0.0" ? "127.0.0.1" : host);
@@ -174,12 +192,21 @@ export function registerServeCommand(program, context) {
174
192
  writeStdout(`Press Ctrl+C to terminate.\n`, context);
175
193
  const stopSignalHandler = () => {
176
194
  writeStdout("\nStopping ActionDock server...", context);
177
- 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
+ });
178
204
  };
179
205
  process.once("SIGINT", stopSignalHandler);
180
206
  process.once("SIGTERM", stopSignalHandler);
181
207
  }
182
208
  catch (err) {
209
+ await target.close().catch(() => { });
183
210
  throw new ExecutionError(`Failed to start ActionDock server: ${err.message}`, err);
184
211
  }
185
212
  });
@@ -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
  }