@itpay/cli 2.0.2 → 2.0.5

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 (70) hide show
  1. package/README.md +92 -142
  2. package/dist/src/client/backend.js +27 -9
  3. package/dist/src/commands/buy.js +84 -132
  4. package/dist/src/commands/cart.js +274 -169
  5. package/dist/src/commands/catalog.js +63 -38
  6. package/dist/src/commands/checkout.js +117 -78
  7. package/dist/src/commands/docs.js +97 -51
  8. package/dist/src/commands/guidance.js +87 -15
  9. package/dist/src/commands/install.js +50 -87
  10. package/dist/src/commands/next.js +45 -0
  11. package/dist/src/commands/order.js +44 -69
  12. package/dist/src/commands/orders.js +43 -15
  13. package/dist/src/commands/pay.js +51 -22
  14. package/dist/src/commands/readyz.js +8 -4
  15. package/dist/src/commands/refund.js +132 -14
  16. package/dist/src/commands/services.js +719 -148
  17. package/dist/src/main.js +711 -201
  18. package/dist/src/render/output.js +2 -3
  19. package/dist/src/state/cart_session.js +13 -17
  20. package/dist/src/state/client_context.js +4 -2
  21. package/dist/src/state/config.js +3 -5
  22. package/dist/src/state/device_authority.js +1 -1
  23. package/docs/agent/buyer/cart-checkout.json +27 -83
  24. package/docs/agent/buyer/install-and-setup.json +23 -67
  25. package/docs/agent/buyer/orders-refunds.json +31 -53
  26. package/docs/agent/buyer/payment-flow.json +24 -57
  27. package/docs/agent/buyer/quickstart.json +39 -162
  28. package/docs/agent/buyer/render-hosts.json +43 -57
  29. package/docs/cli-reference/agent-types.md +45 -0
  30. package/docs/cli-reference/commands/buy.md +167 -0
  31. package/docs/cli-reference/commands/cart/add.md +86 -0
  32. package/docs/cli-reference/commands/cart/clear.md +53 -0
  33. package/docs/cli-reference/commands/cart/index.md +30 -0
  34. package/docs/cli-reference/commands/cart/next.md +71 -0
  35. package/docs/cli-reference/commands/cart/remove.md +53 -0
  36. package/docs/cli-reference/commands/cart/show.md +65 -0
  37. package/docs/cli-reference/commands/catalog/index.md +26 -0
  38. package/docs/cli-reference/commands/catalog/list.md +45 -0
  39. package/docs/cli-reference/commands/checkout.md +74 -0
  40. package/docs/cli-reference/commands/docs/index.md +28 -0
  41. package/docs/cli-reference/commands/docs/list.md +51 -0
  42. package/docs/cli-reference/commands/docs/search.md +69 -0
  43. package/docs/cli-reference/commands/docs/show.md +68 -0
  44. package/docs/cli-reference/commands/install.md +112 -0
  45. package/docs/cli-reference/commands/next.md +87 -0
  46. package/docs/cli-reference/commands/order.md +92 -0
  47. package/docs/cli-reference/commands/orders.md +83 -0
  48. package/docs/cli-reference/commands/pay.md +103 -0
  49. package/docs/cli-reference/commands/readyz.md +39 -0
  50. package/docs/cli-reference/commands/refund/cancel.md +62 -0
  51. package/docs/cli-reference/commands/refund/create.md +85 -0
  52. package/docs/cli-reference/commands/refund/get.md +60 -0
  53. package/docs/cli-reference/commands/refund/index.md +33 -0
  54. package/docs/cli-reference/commands/refund/list.md +68 -0
  55. package/docs/cli-reference/commands/refund/watch.md +73 -0
  56. package/docs/cli-reference/commands/services/action.md +43 -0
  57. package/docs/cli-reference/commands/services/checkout.md +82 -0
  58. package/docs/cli-reference/commands/services/events.md +73 -0
  59. package/docs/cli-reference/commands/services/get.md +66 -0
  60. package/docs/cli-reference/commands/services/index.md +45 -0
  61. package/docs/cli-reference/commands/services/invoke.md +67 -0
  62. package/docs/cli-reference/commands/services/list.md +61 -0
  63. package/docs/cli-reference/commands/services/next.md +162 -0
  64. package/docs/cli-reference/commands/services/quote.md +59 -0
  65. package/docs/cli-reference/commands/services/read-result.md +98 -0
  66. package/docs/cli-reference/commands/services/start.md +53 -0
  67. package/docs/cli-reference/conventions.md +94 -0
  68. package/docs/cli-reference/index.md +64 -0
  69. package/package.json +1 -1
  70. package/skills/itpay-buyer/SKILL.md +47 -113
@@ -1,95 +1,58 @@
1
1
  import { DEFAULT_BASE_URL } from "../state/config.js";
2
- const INSTALL_TARGETS = {
3
- "claude-code": {
4
- name: "Claude Code",
5
- configFile: "~/.claude/settings.json",
6
- instructions: [
7
- "1. Ensure itpay is installed: npm install -g @itpay/cli",
8
- `2. Default API: ${DEFAULT_BASE_URL}`,
9
- "3. Use --agent-type claude-code-cli or claude-code-desktop",
10
- "4. Use --host claude-code for human-facing output",
11
- "5. The CLI renders checkout QR as markdown images and links",
12
- ],
13
- },
14
- codex: {
15
- name: "Codex / Trae",
16
- configFile: "~/.codex/config.toml",
17
- instructions: [
18
- "1. Ensure itpay is installed: npm install -g @itpay/cli",
19
- `2. Default API: ${DEFAULT_BASE_URL}`,
20
- "3. Use --agent-type codex-cli or codex-desktop",
21
- "4. Use --host trae or --host codex for human-facing output",
22
- "5. Attach the emitted QR image and show the checkout link",
23
- "6. Collect missing contact fields from the user; never invent them",
24
- ],
25
- },
26
- terminal: {
27
- name: "Terminal",
28
- configFile: "shell profile (~/.zshrc, ~/.bashrc)",
29
- instructions: [
30
- "1. Install globally: npm install -g @itpay/cli",
31
- `2. Default API: ${DEFAULT_BASE_URL}`,
32
- "3. Set the real runtime type with --agent-type <type>",
33
- "4. Use --host terminal for text/QR output in terminal",
34
- "5. Override ITPAY_BACKEND_URL only for local or test backends",
35
- ],
36
- },
37
- telegram: {
38
- name: "Telegram",
39
- configFile: "OpenClaw gateway config",
40
- instructions: [
41
- "1. Install itpay: npm install -g @itpay/cli",
42
- `2. Default API: ${DEFAULT_BASE_URL}`,
43
- "3. Set the real OpenClaw runtime with --agent-type <type>",
44
- "4. Use --host telegram --target <chat_id> for human-facing output",
45
- "5. The CLI emits openclaw_message payloads with buttons and QR images",
46
- ],
47
- },
48
- feishu: {
49
- name: "Feishu / Lark",
50
- configFile: "Feishu bot config",
51
- instructions: [
52
- "1. Install itpay: npm install -g @itpay/cli",
53
- `2. Default API: ${DEFAULT_BASE_URL}`,
54
- "3. Set the real agent runtime with --agent-type <type>",
55
- "4. Use --host feishu --target <open_id> or --host lark --target <open_id>",
56
- "5. The CLI emits Interactive Card JSON with buttons and QR images",
57
- ],
58
- },
2
+ import { defaultHostForAgentType } from "../state/client_context.js";
3
+ import { CommandContractError, writeCommandEnvelope } from "./guidance.js";
4
+ export const INSTALL_AGENT_TYPES = [
5
+ "codex-desktop",
6
+ "codex-cli",
7
+ "claude-code-desktop",
8
+ "claude-code-cli",
9
+ "workbuddy",
10
+ ];
11
+ const INSTRUCTIONS = {
12
+ "codex-desktop": "在 Codex Desktop 中始终传这个 Agent Type;付款时把返回的二维码和链接实际展示到当前对话。",
13
+ "codex-cli": "在 Codex CLI 中始终传这个 Agent Type;付款交接只应显示在用户可见终端。",
14
+ "claude-code-desktop": "在 Claude Code Desktop 中始终传这个 Agent Type;付款时把返回的二维码和链接实际展示到当前对话。",
15
+ "claude-code-cli": " Claude Code CLI 中始终传这个 Agent Type;付款交接只应显示在用户可见终端。",
16
+ workbuddy: "在 WorkBuddy 中始终传这个 Agent Type;当前使用 plain-chat 交接,必须把链接和可用图片发到当前会话。",
59
17
  };
60
- export function runInstall(target) {
18
+ export function runInstall(target, options = {}) {
61
19
  if (!target || target === "list") {
62
- listTargets();
20
+ const agentTypes = INSTALL_AGENT_TYPES.map((agentType) => ({
21
+ agent_type: agentType,
22
+ default_host: defaultHostForAgentType(agentType),
23
+ }));
24
+ writeCommandEnvelope({
25
+ status: "install_targets",
26
+ result: { agent_types: agentTypes },
27
+ instruction: "选择当前真实运行环境;同一 Agent 不要临时更换 Agent Type。",
28
+ next: null,
29
+ recovery: [{ command: "itpay docs show install-and-setup", reason: "查看安装与环境说明" }],
30
+ }, {
31
+ ...options,
32
+ plainResult: agentTypes.map((item) => `${item.agent_type}: ${item.default_host}`),
33
+ });
63
34
  return;
64
35
  }
65
- const normalized = target.toLowerCase();
66
- if (normalized === "trae") {
67
- // Trae uses the codex install target
68
- printInstall("codex");
69
- return;
70
- }
71
- if (INSTALL_TARGETS[normalized]) {
72
- printInstall(normalized);
73
- }
74
- else {
75
- process.stderr.write(`unknown target "${target}". Available: ${Object.keys(INSTALL_TARGETS).join(", ")}\n`);
76
- process.exitCode = 1;
36
+ const normalized = target.trim().toLowerCase();
37
+ if (!isInstallAgentType(normalized)) {
38
+ throw new CommandContractError("unsupported_agent_type", `unsupported install target: ${target}`, `target 只接受:${INSTALL_AGENT_TYPES.join(", ")}。`, [{ command: "itpay install --json", reason: "列出正式支持的 Agent Type" }]);
77
39
  }
40
+ writeCommandEnvelope({
41
+ status: "instructions_ready",
42
+ result: {
43
+ agent_type: normalized,
44
+ default_host: defaultHostForAgentType(normalized),
45
+ default_api: DEFAULT_BASE_URL,
46
+ install_command: "npm install -g @itpay/cli",
47
+ },
48
+ instruction: INSTRUCTIONS[normalized],
49
+ next: {
50
+ command: `itpay --agent-type ${normalized} readyz --json`,
51
+ reason: "验证 CLI 与默认 ItPay API 的兼容性",
52
+ },
53
+ recovery: [{ command: "itpay docs show install-and-setup", reason: "查看环境覆盖和首次使用说明" }],
54
+ }, options);
78
55
  }
79
- function printInstall(key) {
80
- const target = INSTALL_TARGETS[key];
81
- if (!target)
82
- return;
83
- process.stdout.write(`\n=== ItPay V3 CLI — Install for ${target.name} ===\n`);
84
- process.stdout.write(`Config file: ${target.configFile}\n\n`);
85
- for (const line of target.instructions) {
86
- process.stdout.write(`${line}\n`);
87
- }
88
- process.stdout.write("\n");
89
- }
90
- function listTargets() {
91
- process.stdout.write("Available install targets:\n\n");
92
- for (const [key, target] of Object.entries(INSTALL_TARGETS)) {
93
- process.stdout.write(` ${key.padEnd(14)} ${target.name.padEnd(18)} ${target.configFile}\n`);
94
- }
56
+ function isInstallAgentType(value) {
57
+ return INSTALL_AGENT_TYPES.includes(value);
95
58
  }
@@ -0,0 +1,45 @@
1
+ import { writeCommandEnvelope } from "./guidance.js";
2
+ export function runNext(session, options = {}) {
3
+ const envelope = nextEnvelope(session);
4
+ writeCommandEnvelope(envelope, {
5
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
6
+ ...(options.output ? { output: options.output } : {}),
7
+ });
8
+ }
9
+ function nextEnvelope(session) {
10
+ if (session.stateLoadFailed) {
11
+ return {
12
+ status: "local_state_invalid",
13
+ result: {},
14
+ instruction: "本地恢复句柄无法读取;不要猜测资源 ID,改从当前设备可见的服务执行恢复。",
15
+ next: { command: "itpay services list --json", reason: "从服务端恢复当前设备可见的执行" },
16
+ recovery: [],
17
+ };
18
+ }
19
+ const state = session.show();
20
+ if (state.lastServiceExecutionID) {
21
+ return resumeEnvelope("service_execution", state.lastServiceExecutionID, `itpay services next ${state.lastServiceExecutionID} --json`, "读取服务端最新状态");
22
+ }
23
+ if (state.lastCheckoutID && state.lastDisplayToken) {
24
+ return resumeEnvelope("checkout", state.lastCheckoutID, `itpay checkout --id ${state.lastCheckoutID} --token ${state.lastDisplayToken} --json`, "恢复同一 Checkout");
25
+ }
26
+ if (state.lastCartID) {
27
+ return resumeEnvelope("cart", state.lastCartID, "itpay cart next --json", "读取同一 Cart 的服务端状态");
28
+ }
29
+ return {
30
+ status: "nothing_to_resume",
31
+ result: {},
32
+ instruction: "本地没有可恢复句柄;先读取已发布目录,不要猜测 service_id。",
33
+ next: { command: "itpay catalog list --json", reason: "选择已发布服务" },
34
+ recovery: [],
35
+ };
36
+ }
37
+ function resumeEnvelope(resourceType, resourceID, command, reason) {
38
+ return {
39
+ status: "resume_available",
40
+ result: { resource_type: resourceType, resource_id: resourceID },
41
+ instruction: "继续已有资源,不要创建重复订单或 Checkout。",
42
+ next: { command, reason },
43
+ recovery: [],
44
+ };
45
+ }
@@ -1,78 +1,53 @@
1
- // Read a single V3 order by id. Supports terminal text and agent markdown output.
2
- import { formatMoney, renderOrder } from "../render/output.js";
3
- import { hintFor } from "../render/status.js";
4
- import { resolveOutput } from "../render/sink.js";
5
- import { ensureIdeImageAttach, ideImageAttachBlock } from "../render/ide.js";
1
+ import { formatMoney } from "../render/output.js";
2
+ import { writeCommandEnvelope } from "./guidance.js";
6
3
  export async function runOrder(backend, orderID, options = {}) {
7
- const out = resolveOutput(options.output);
8
4
  const order = await backend.getOrder(orderID);
9
- // Prepare an IDE image attach slot. Order responses usually do not
10
- // carry a brand QR the previous buy already produced one. We still
11
- // call ensureIdeImageAttach so the disabled / failed / no-source
12
- // states get surfaced consistently across commands.
13
- const plan = {
14
- kind: "checkout_qr",
15
- host: (options.host ?? "terminal"),
16
- summary: "order presentation",
17
- url: "",
18
- preferredQRSources: [],
19
- platform: {
20
- text: "order presentation",
21
- links: [],
22
- buttons: [],
23
- blocks: [],
24
- },
25
- };
26
- await ensureIdeImageAttach(plan, {
27
- ...(options.baseURL ? { baseURL: options.baseURL } : {}),
5
+ const [delivery, refundResponse] = await Promise.all([
6
+ order.status === "delivered" ? backend.getOrderDeliveryAccess(orderID) : Promise.resolve(undefined),
7
+ backend.listOrderRefunds(orderID),
8
+ ]);
9
+ const lockedRefund = refundResponse.refunds.find((refund) => refund.access_locked);
10
+ const envelope = orderEnvelope(order, delivery, lockedRefund);
11
+ writeCommandEnvelope(envelope, {
12
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
13
+ ...(options.output ? { output: options.output } : {}),
14
+ plainResult: orderPlainResult(envelope.result),
28
15
  });
29
- if (options.host === "codex" || options.host === "claude-code" || options.host === "trae") {
30
- out(renderOrderMarkdown(order, plan) + "\n");
31
- }
32
- else {
33
- out(renderOrder(order) + "\n");
34
- if (plan.ideImageAttach) {
35
- out(ideImageAttachBlock(plan.ideImageAttach).filter((l) => l.length > 0).join("\n") + "\n");
36
- }
37
- out(`hint: ${hintFor("order", order.status)}\n`);
38
- }
39
16
  }
40
- function renderOrderMarkdown(order, plan) {
41
- const lines = [];
42
- lines.push(`## :package: 订单 ${order.order_id}`);
43
- lines.push("");
44
- const statusEmoji = order.status === "delivered" ? ":white_check_mark:" : order.status === "refunded" ? ":arrows_counterclockwise:" : ":hourglass:";
45
- lines.push(`| 字段 | |`);
46
- lines.push(`|------|-----|`);
47
- lines.push(`| 状态 | ${statusEmoji} ${order.status} |`);
48
- lines.push(`| 金额 | ${formatMoney(order.amount_minor, order.currency)} |`);
49
- lines.push(`| Checkout | \`${order.checkout_id}\``);
50
- if (order.paid_at)
51
- lines.push(`| 支付时间 | ${order.paid_at} |`);
52
- lines.push("");
53
- if (order.items.length > 0) {
54
- lines.push(`| 项目 | 数量 | 单价 |`);
55
- lines.push(`|------|:----:|------|`);
56
- for (const item of order.items) {
57
- lines.push(`| ${item.title} | ${item.quantity} | ${formatMoney(item.amount_minor, item.currency)} |`);
17
+ function orderEnvelope(order, delivery, lockedRefund) {
18
+ const refundTerminal = lockedRefund && ["succeeded", "failed", "cancelled", "rejected"].includes(lockedRefund.status);
19
+ let instruction = "订单状态已读取;当前没有可用交付入口。";
20
+ let next = null;
21
+ if (lockedRefund) {
22
+ instruction = "退款访问锁已生效;不要 reveal、创建 grant 或读取交付结果。";
23
+ if (!refundTerminal) {
24
+ next = { command: `itpay refund get ${lockedRefund.refund_request_id} --json`, reason: "读取退款的服务器状态" };
58
25
  }
59
- lines.push("");
60
26
  }
61
- if (order.delivery_artifacts.length > 0) {
62
- lines.push(`### :lock: 交付物`);
63
- lines.push("");
64
- for (const artifact of order.delivery_artifacts) {
65
- const notification = artifact.notification_status ? ` — notification:${artifact.notification_status}` : "";
66
- const vault = artifact.vault_artifact_id ? ` — vault:\`${artifact.vault_artifact_id}\`` : "";
67
- lines.push(`- \`${artifact.delivery_artifact_id}\` — ${artifact.artifact_type} — ${artifact.status}${notification}${vault}`);
68
- if (artifact.public_preview)
69
- lines.push(` > ${artifact.public_preview}`);
70
- }
71
- lines.push("");
27
+ else if (delivery?.service_execution_id) {
28
+ instruction = "根据 delivery_mode 使用对应读取入口;不要从订单摘要猜测受保护内容。";
29
+ next = { command: `itpay services next ${delivery.service_execution_id} --json`, reason: "读取交付状态" };
72
30
  }
73
- if (plan.ideImageAttach) {
74
- lines.push(...ideImageAttachBlock(plan.ideImageAttach));
31
+ else if (!["delivered", "refunded", "failed", "cancelled"].includes(order.status)) {
32
+ instruction = "订单尚未进入交付终态;稍后查询同一订单,不要创建替代订单。";
33
+ next = { command: `itpay order ${order.order_id} --json`, reason: "刷新订单状态" };
75
34
  }
76
- lines.push(`> :bulb: ${hintFor("order", order.status)}`);
77
- return lines.join("\n");
35
+ return {
36
+ status: order.status,
37
+ result: {
38
+ order_id: order.order_id,
39
+ ...(order.order_code ? { order_code: order.order_code } : {}),
40
+ amount: formatMoney(order.amount_minor, order.currency),
41
+ ...(delivery ? { delivery_mode: delivery.delivery_mode } : {}),
42
+ access_locked: Boolean(lockedRefund),
43
+ ...(delivery?.service_execution_id ? { service_execution_id: delivery.service_execution_id } : {}),
44
+ ...(lockedRefund ? { refund: { refund_request_id: lockedRefund.refund_request_id, status: lockedRefund.status } } : {}),
45
+ },
46
+ instruction,
47
+ next,
48
+ recovery: [],
49
+ };
50
+ }
51
+ function orderPlainResult(result) {
52
+ return Object.entries(result).map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
78
53
  }
@@ -1,22 +1,50 @@
1
- // List V3 orders visible to the account-scoped session. Requires
2
- // ITPAY_BEARER_TOKEN to be set to an account-scoped buyer session token.
3
- // Order-scoped sessions are rejected by the backend with 403 and the
4
- // CLI surfaces the typed `HttpError` so `main.ts` can render it.
5
- import { renderOrder } from "../render/output.js";
6
- import { hintFor } from "../render/status.js";
1
+ import { formatMoney } from "../render/output.js";
7
2
  import { resolveOutput } from "../render/sink.js";
3
+ import { CommandContractError, writeCommandEnvelope } from "./guidance.js";
4
+ const ORDER_STATUSES = new Set([
5
+ "pending_payment",
6
+ "paid",
7
+ "delivery_pending",
8
+ "delivered",
9
+ "failed",
10
+ "partially_refunded",
11
+ "refunded",
12
+ "cancelled",
13
+ ]);
8
14
  export async function runListOrders(backend, config, options) {
9
15
  const out = resolveOutput(options.output);
10
- if (!config.bearerToken) {
11
- throw new Error("ITPAY_BEARER_TOKEN is required to list account orders");
16
+ if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 100) {
17
+ throw new CommandContractError("limit_invalid", "--limit must be an integer from 1 to 100", "使用 1 到 100 的整数 limit;本次未读取订单列表。", [{ command: "itpay orders --limit 20 --json", reason: "使用默认上限重试" }]);
12
18
  }
13
- const response = await backend.listAccountOrders(options.limit, options.status, config.bearerToken);
14
- if (response.orders.length === 0) {
15
- out("no orders found\n");
16
- return;
19
+ if (options.status && !ORDER_STATUSES.has(options.status)) {
20
+ throw new CommandContractError("order_status_invalid", `unsupported order status: ${options.status}`, "使用订单合同中的有效 status;本次未读取订单列表。", [{ command: "itpay orders --limit 20 --json", reason: "移除状态过滤后重试" }]);
17
21
  }
18
- for (const order of response.orders) {
19
- out(renderOrder(order) + "\n");
20
- out(`hint: ${hintFor("order", order.status)}\n\n`);
22
+ if (!config.bearerToken) {
23
+ throw new CommandContractError("session_required", "account-scoped Buyer session is required", "订单历史只对网页登录账号开放;不要伪造 Buyer token。Agent 可改为恢复当前设备绑定的 Service Execution。", [{ command: "itpay services list --json", reason: "恢复当前 Agent 设备可见的执行" }]);
21
24
  }
25
+ const response = await backend.listAccountOrders(options.limit, options.status, config.bearerToken);
26
+ const orders = response.orders.map((order) => ({
27
+ order_id: order.order_id,
28
+ ...(order.order_code ? { order_code: order.order_code } : {}),
29
+ status: order.status,
30
+ amount: formatMoney(order.amount_minor, order.currency),
31
+ created_at: order.created_at,
32
+ }));
33
+ const latest = orders[0];
34
+ const envelope = {
35
+ status: latest ? "listed" : "no_orders",
36
+ result: { orders },
37
+ instruction: latest
38
+ ? "结果按最新到最旧排列;按页面编号、时间和状态选择目标订单,不要假设第一笔就是当前任务。"
39
+ : "当前账号没有符合条件的订单;不要猜测订单 ID。",
40
+ next: latest
41
+ ? { command: `itpay order ${latest.order_id} --json`, reason: "默认读取最新订单" }
42
+ : null,
43
+ recovery: latest ? [] : [{ command: "itpay services list --json", reason: "恢复当前 Agent 设备可见的执行" }],
44
+ };
45
+ writeCommandEnvelope(envelope, {
46
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
47
+ output: out,
48
+ plainResult: orders.map((order) => `${order.order_code ?? order.order_id}: ${order.status} ${order.amount} created=${order.created_at}`),
49
+ });
22
50
  }
@@ -1,26 +1,55 @@
1
- // Optional CLI helper: only intended for the V3 `payment-intent` flow when
2
- // the human checkout page is unavailable. The V3 architecture prefers
3
- // that the human checkout page creates the payment intent, but the CLI
4
- // still needs an escape hatch for sandbox/manual testing.
5
- import { operationID } from "../state/config.js";
6
- import { resolveOutput } from "../render/sink.js";
7
- export async function runPay(backend, config, options) {
8
- const out = resolveOutput(options.output);
9
- const request = {
1
+ // Explicit Payment Intent escape hatch. Normal buyers should use the ItPay
2
+ // Checkout page; this command exists for controlled integration recovery.
3
+ import { formatMoney } from "../render/output.js";
4
+ import { writeCommandEnvelope } from "./guidance.js";
5
+ export async function runPay(backend, options) {
6
+ const intent = await backend.createPaymentIntent(options.checkoutID, {
10
7
  payment_method_type: options.method,
11
- ...(options.preferredProvider ? { preferred_provider: options.preferredProvider } : {}),
12
- ...(options.buyerID ? { buyer_id: options.buyerID } : {}),
8
+ display_token: options.displayToken,
13
9
  ...(options.refreshAction ? { refresh_action: true } : {}),
10
+ });
11
+ const envelope = payEnvelope(intent, options);
12
+ writeCommandEnvelope(envelope, {
13
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
14
+ ...(options.output ? { output: options.output } : {}),
15
+ });
16
+ }
17
+ function payEnvelope(intent, options) {
18
+ const terminal = ["failed", "expired", "refunded"].includes(intent.status);
19
+ const verified = intent.status === "verified" || intent.status === "partially_refunded";
20
+ const handoff = {};
21
+ if (!terminal && !verified && intent.action?.qr_image_url)
22
+ handoff.qr_image_url = intent.action.qr_image_url;
23
+ if (!terminal && !verified && intent.action?.mobile_wallet_url)
24
+ handoff.mobile_wallet_url = intent.action.mobile_wallet_url;
25
+ const hasAction = Object.keys(handoff).length > 0;
26
+ return {
27
+ status: verified ? "payment_verified" : terminal ? "payment_unavailable" : hasAction ? "payment_action_ready" : "payment_action_pending",
28
+ result: {
29
+ checkout_id: options.checkoutID,
30
+ payment_intent_id: intent.payment_intent_id,
31
+ payment: verified ? "verified" : intent.status,
32
+ amount: formatMoney(intent.amount_minor, intent.currency),
33
+ },
34
+ ...(hasAction ? { handoff } : {}),
35
+ instruction: payInstruction(options.host, verified, terminal, hasAction),
36
+ next: {
37
+ command: `itpay checkout --id ${options.checkoutID} --token ${options.displayToken} --json`,
38
+ reason: verified ? "读取权威订单和履约状态" : "读取同一 Checkout 的权威付款状态",
39
+ },
40
+ recovery: [],
14
41
  };
15
- const intent = await backend.createPaymentIntent(options.checkoutID, request, await operationID(config, `payment.intent:${options.checkoutID}:${options.method}`));
16
- out(`payment_intent_id: ${intent.payment_intent_id}\n`);
17
- out(`status: ${intent.status}\n`);
18
- out(`method: ${intent.payment_method_type}\n`);
19
- out(`amount: ${(intent.amount_minor / 100).toFixed(2)} ${intent.currency}\n`);
20
- if (intent.action?.qr_image_url) {
21
- out(`qr_image_url: ${intent.action.qr_image_url}\n`);
22
- }
23
- if (intent.action?.mobile_wallet_url) {
24
- out(`mobile_wallet_url: ${intent.action.mobile_wallet_url}\n`);
25
- }
42
+ }
43
+ function payInstruction(host, verified, terminal, hasAction) {
44
+ if (verified)
45
+ return "付款已确认;不要再次展示付款动作,继续读取同一 Checkout。";
46
+ if (terminal)
47
+ return "Payment Intent 已终止;不要自行创建替代付款,回到同一 Checkout 读取恢复方向。";
48
+ if (!hasAction)
49
+ return "Payment Intent 尚未返回可展示动作;不要猜测渠道链接,回到同一 Checkout 查询。";
50
+ if (host === "codex" || host === "claude-code")
51
+ return "这是受控逃生入口;把 handoff 中的二维码或钱包链接实际发到当前桌面对话,然后查询同一 Checkout。";
52
+ if (host === "terminal")
53
+ return "这是受控逃生入口;只在用户可见终端展示 handoff,然后查询同一 Checkout。";
54
+ return "这是受控逃生入口;把 handoff 中的二维码或钱包链接发送到当前会话,然后查询同一 Checkout。";
26
55
  }
@@ -1,8 +1,12 @@
1
1
  // Liveness probe. Useful for smoke testing CLI wiring before running `buy`.
2
- import { renderReady } from "../render/output.js";
3
- import { resolveOutput } from "../render/sink.js";
2
+ import { writeCommandEnvelope } from "./guidance.js";
4
3
  export async function runReadyz(backend, options = {}) {
5
- const out = resolveOutput(options.output);
6
4
  const response = await backend.readyz();
7
- out(renderReady(response) + "\n");
5
+ writeCommandEnvelope({
6
+ status: response.status,
7
+ result: { backend: "available" },
8
+ instruction: "ItPay 可用,可以读取服务目录。",
9
+ next: { command: "itpay catalog list", reason: "发现可用服务" },
10
+ recovery: [],
11
+ }, options);
8
12
  }
@@ -1,20 +1,138 @@
1
- // Create a V3 refund request. The backend decides policy state and
2
- // returns the canonical refund status. The CLI never assumes a refund
3
- // succeeded locally — it always re-reads canonical state.
4
1
  import { operationID } from "../state/config.js";
5
- import { renderRefund } from "../render/output.js";
6
- import { hintFor } from "../render/status.js";
2
+ import { formatMoney } from "../render/output.js";
7
3
  import { resolveOutput } from "../render/sink.js";
4
+ import { writeCommandEnvelope } from "./guidance.js";
8
5
  export async function runRefund(backend, config, options) {
6
+ const reason = options.reason?.trim() || "buyer_requested";
7
+ const refund = await backend.createRefund(options.orderID, { reason }, config.bearerToken, await operationID(config, `refund.create:${options.orderID}:${reason}`));
8
+ const envelope = refundStateEnvelope(refund, "requested");
9
+ writeCommandEnvelope(envelope, {
10
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
11
+ ...(options.output ? { output: options.output } : {}),
12
+ plainResult: Object.entries(envelope.result).map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`),
13
+ });
14
+ }
15
+ function refundStateEnvelope(refund, status) {
16
+ const terminal = ["succeeded", "failed", "cancelled", "rejected"].includes(refund.status);
17
+ let instruction = "退款处理中,交付已冻结;不要 reveal、授权或读取结果。";
18
+ if (refund.decision_mode === "manual")
19
+ instruction = "退款已进入人工审核,交付保持冻结;等待服务器决定。";
20
+ if (!refund.access_locked)
21
+ instruction = "退款当前未锁定交付;按服务器状态处理,不要自行推断退款结果。";
22
+ if (refund.status === "succeeded")
23
+ instruction = "退款已成功;交付永久关闭。";
24
+ if (refund.status === "cancelled" || refund.status === "rejected")
25
+ instruction = "退款未执行,交付资格可恢复;旧 grant 不会复活,需要用户重新授权。";
26
+ return {
27
+ status,
28
+ result: {
29
+ refund_request_id: refund.refund_request_id,
30
+ order_id: refund.order_id,
31
+ decision_mode: refund.decision_mode,
32
+ refund_status: refund.status,
33
+ consumption_state: refund.consumption_state,
34
+ ...(refund.failure_class ? { failure_class: refund.failure_class } : {}),
35
+ access_locked: refund.access_locked,
36
+ can_cancel: refund.can_cancel,
37
+ },
38
+ instruction,
39
+ next: terminal ? null : { command: `itpay refund watch ${refund.refund_request_id} --json`, reason: "跟踪同一退款" },
40
+ recovery: [],
41
+ };
42
+ }
43
+ export async function runListRefunds(backend, options) {
9
44
  const out = resolveOutput(options.output);
10
- const request = {
11
- payment_intent_id: options.paymentIntentID,
12
- amount_minor: options.amountMinor,
13
- currency: options.currency,
14
- ...(options.reason ? { reason: options.reason } : {}),
15
- ...(options.createdBy ? { created_by: options.createdBy } : {}),
45
+ const response = await backend.listOrderRefunds(options.orderID);
46
+ const refunds = response.refunds.map((refund) => ({
47
+ refund_request_id: refund.refund_request_id,
48
+ status: refund.status,
49
+ amount: formatMoney(refund.amount_minor, refund.currency),
50
+ created_at: refund.created_at,
51
+ }));
52
+ const active = refunds.find((refund) => !["succeeded", "failed", "cancelled", "rejected"].includes(refund.status));
53
+ const selected = active ?? refunds[0];
54
+ const envelope = {
55
+ status: selected ? "listed" : "empty",
56
+ result: { order_id: options.orderID, refunds },
57
+ instruction: active
58
+ ? "已有活跃退款;继续跟踪同一笔,不要为该订单重复创建。"
59
+ : selected
60
+ ? "结果按最新到最旧排列;按时间和状态选择退款记录,再读取权威详情。"
61
+ : "该订单没有退款记录;确认用户确实要求退款后再创建。",
62
+ next: selected
63
+ ? { command: `itpay refund get ${selected.refund_request_id} --json`, reason: active ? "读取活跃退款" : "读取最新退款" }
64
+ : { command: `itpay refund create --order ${options.orderID} --json`, reason: "为该订单创建退款" },
65
+ recovery: [],
66
+ };
67
+ writeCommandEnvelope(envelope, {
68
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
69
+ output: out,
70
+ plainResult: [
71
+ `order_id: ${options.orderID}`,
72
+ ...refunds.map((refund) => `${refund.refund_request_id}: ${refund.status} ${refund.amount} created=${refund.created_at}`),
73
+ ],
74
+ });
75
+ }
76
+ export async function runGetRefund(backend, refundID, options = {}) {
77
+ const envelope = refundStateEnvelope(await backend.getRefund(refundID), "shown");
78
+ writeCommandEnvelope(envelope, {
79
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
80
+ ...(options.output ? { output: options.output } : {}),
81
+ plainResult: Object.entries(envelope.result).map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`),
82
+ });
83
+ }
84
+ export async function runCancelRefund(backend, refundID, reason, options = {}) {
85
+ const refund = await backend.cancelRefund(refundID, reason?.trim() || "buyer_cancelled");
86
+ const envelope = {
87
+ status: "cancelled",
88
+ result: {
89
+ refund_request_id: refund.refund_request_id,
90
+ order_id: refund.order_id,
91
+ access_locked: refund.access_locked,
92
+ },
93
+ instruction: "退款已取消;如需交付,重新进入订单并取得新的授权。",
94
+ next: { command: `itpay order ${refund.order_id} --json`, reason: "确认订单访问状态" },
95
+ recovery: [],
16
96
  };
17
- const refund = await backend.createRefund(options.orderID, request, await operationID(config, `refund.create:${options.orderID}:${options.paymentIntentID}:${options.amountMinor}:${options.currency}`));
18
- out(renderRefund(refund) + "\n");
19
- out(`hint: ${hintFor("refund", refund.status)}\n`);
97
+ writeRefundEnvelope(envelope, options);
98
+ }
99
+ export async function runWatchRefund(backend, refundID, options = {}) {
100
+ const intervalSeconds = options.intervalSeconds ?? 2;
101
+ const timeoutSeconds = options.timeoutSeconds ?? 120;
102
+ if (!Number.isFinite(intervalSeconds) || intervalSeconds < 1)
103
+ throw new Error("--interval must be at least 1 second");
104
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)
105
+ throw new Error("--timeout must be a positive number");
106
+ const deadline = Date.now() + timeoutSeconds * 1000;
107
+ let refund;
108
+ for (;;) {
109
+ refund = await backend.getRefund(refundID);
110
+ if (["succeeded", "failed", "cancelled", "rejected"].includes(refund.status)) {
111
+ writeRefundEnvelope(refundStateEnvelope(refund, "watch_complete"), options);
112
+ return;
113
+ }
114
+ const remaining = deadline - Date.now();
115
+ if (remaining <= 0)
116
+ break;
117
+ await new Promise((resolve) => setTimeout(resolve, Math.min(intervalSeconds * 1000, remaining)));
118
+ }
119
+ writeRefundEnvelope({
120
+ status: "watch_timeout",
121
+ result: {
122
+ refund_request_id: refund.refund_request_id,
123
+ last_status: refund.status,
124
+ access_locked: refund.access_locked,
125
+ can_cancel: refund.can_cancel,
126
+ },
127
+ instruction: "退款仍在处理,稍后继续跟踪同一退款;不要重复申请。",
128
+ next: { command: `itpay refund watch ${refund.refund_request_id} --json`, reason: "恢复轮询" },
129
+ recovery: [],
130
+ }, options);
131
+ }
132
+ function writeRefundEnvelope(envelope, options) {
133
+ writeCommandEnvelope(envelope, {
134
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
135
+ ...(options.output ? { output: options.output } : {}),
136
+ plainResult: Object.entries(envelope.result).map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`),
137
+ });
20
138
  }