@tapicker/bridge 1.0.0 → 1.1.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 (49) hide show
  1. package/README.md +15 -15
  2. package/dist/bridge/bridge-service.d.ts +1 -1
  3. package/dist/bridge/bridge-service.js +7 -7
  4. package/dist/bridge/control.js +1 -1
  5. package/dist/bridge/export-store.d.ts +35 -0
  6. package/dist/bridge/export-store.js +195 -0
  7. package/dist/bridge/extension-session.d.ts +9 -2
  8. package/dist/bridge/extension-session.js +56 -8
  9. package/dist/cli.js +563 -148
  10. package/dist/commands.d.ts +8 -0
  11. package/dist/commands.js +276 -40
  12. package/dist/constants.d.ts +2 -2
  13. package/dist/constants.js +4 -2
  14. package/dist/http-client.d.ts +24 -0
  15. package/dist/http-client.js +101 -0
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +2 -2
  18. package/dist/mcp.d.ts +4 -3
  19. package/dist/mcp.js +221 -134
  20. package/dist/protocol/schema.d.ts +503 -34
  21. package/dist/protocol/schema.js +396 -15
  22. package/dist/server.d.ts +7 -4
  23. package/dist/server.js +70 -26
  24. package/dist/update-notifier-worker.d.ts +2 -0
  25. package/dist/update-notifier-worker.js +3 -0
  26. package/dist/update-notifier.d.ts +9 -0
  27. package/dist/update-notifier.js +178 -0
  28. package/dist/workflow-watch.d.ts +8 -7
  29. package/dist/workflow-watch.js +39 -29
  30. package/package.json +16 -6
  31. package/docs/cli.md +0 -112
  32. package/docs/http-api.md +0 -149
  33. package/docs/mcp.md +0 -90
  34. package/docs/operations.md +0 -85
  35. package/docs/workflow-watch.md +0 -86
  36. package/src/bridge/bridge-service.ts +0 -291
  37. package/src/bridge/control.ts +0 -281
  38. package/src/bridge/daemon.ts +0 -102
  39. package/src/bridge/extension-session.ts +0 -286
  40. package/src/bridge/pairing-state.ts +0 -92
  41. package/src/cli.ts +0 -1019
  42. package/src/commands.ts +0 -158
  43. package/src/constants.ts +0 -10
  44. package/src/errors.ts +0 -72
  45. package/src/index.ts +0 -35
  46. package/src/mcp.ts +0 -386
  47. package/src/protocol/schema.ts +0 -202
  48. package/src/server.ts +0 -623
  49. package/src/workflow-watch.ts +0 -364
package/dist/cli.js CHANGED
@@ -1,33 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
  import { cac } from 'cac';
3
- import { chmodSync, closeSync, openSync, readFileSync } from 'node:fs';
3
+ import { chmodSync, closeSync, constants, openSync, readFileSync } from 'node:fs';
4
4
  import { fork } from 'node:child_process';
5
- import { mkdir } from 'node:fs/promises';
6
- import { resolve } from 'node:path';
5
+ import { access, mkdir, open, rm, stat } from 'node:fs/promises';
6
+ import { Readable } from 'node:stream';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { join, parse, resolve, sep } from 'node:path';
7
9
  import { serveStdio } from '@modelcontextprotocol/server/stdio';
8
10
  import { DEFAULT_HOST, DEFAULT_PORT, VERSION } from './constants.js';
9
11
  import { BridgeError, serializeBridgeError } from './errors.js';
10
12
  import { createHttpMcpApi, createTapickerMcpServer } from './mcp.js';
11
- import { DataInputSchema, WorkflowControlInputSchema, WorkflowStartInputSchema, WorkflowStatusEventSchema, } from './protocol/schema.js';
12
- import { connectWorkflowWatch, waitForWorkflowStatus, workflowWatchWebSocketUrl } from './workflow-watch.js';
13
+ import { createHttpBridgeApi, parseBridgeUrl } from './http-client.js';
14
+ import { DataInputSchema, DataGetInputSchema, DataExportInputSchema, DataExportResultSchema, DataListInputSchema, DataPreviewInputSchema, WorkflowControlInputSchema, WorkflowStartInputSchema, WorkflowStartResultSchema, WorkflowStatusEventSchema, WorkflowStatusInputSchema, WorkflowLogsInputSchema, WorkflowLogInputSchema, WorkflowListInputSchema, WorkflowGetInputSchema, WorkflowCreateInputSchema, WorkflowUpdateInputSchema, WorkflowGuideInputSchema, WorkflowSchemaInputSchema, WorkflowValidateInputSchema, } from './protocol/schema.js';
15
+ import { connectWorkflowStatusWatch, waitForWorkflowStatus, workflowStatusWatchWebSocketUrl } from './workflow-watch.js';
13
16
  import { BridgeServer } from './server.js';
17
+ import { showCachedUpdate } from './update-notifier.js';
14
18
  import { getDefaultBridgeDataDir } from './bridge/pairing-state.js';
15
19
  import { sendBridgeControlRequest, startBridgeControlServer } from './bridge/control.js';
16
20
  import { getBridgeDaemonLogPath, isProcessRunning, readBridgeDaemonState, removeBridgeDaemonState, writeBridgeDaemonState, } from './bridge/daemon.js';
17
21
  const DAEMON_CHILD_ENV = 'TAPICKER_BRIDGE_DAEMON_CHILD';
18
22
  const cli = cac('tapicker');
23
+ const defaultBridgeUrl = process.env.TAPICKER_BRIDGE_URL || `http://${DEFAULT_HOST}:${DEFAULT_PORT}`;
19
24
  cli
20
25
  .command('bridge <action>', 'Manage Bridge: start/stop it, view the pairing code, or list connected Extensions')
21
- .option('--host <host>', 'Host to bind', { default: DEFAULT_HOST })
26
+ .option('--host <host>', 'Host to bind')
22
27
  .option('--port <port>', 'Port to bind; defaults to the first available port from 9520 to 9530')
23
28
  .option('--token <token>', 'Bearer token for HTTP command clients')
24
- .option('--cors-origin <origins>', 'Comma-separated allowed HTTP origins (default: *)', { default: '*' })
29
+ .option('--cors-origin <origins>', 'Comma-separated allowed HTTP origins (default: *)')
25
30
  .option('--tls-key <path>', 'TLS private key path')
26
31
  .option('--tls-cert <path>', 'TLS certificate path')
27
32
  .option('--data-dir <path>', 'Directory for Bridge state and logs')
28
33
  .option('--daemon', 'Run Bridge as a daemon')
29
34
  .option('--refresh', 'Rotate the pairing code and unlock pairing')
30
35
  .action(async (action, options) => {
36
+ validateActionOptions(action, options, bridgeCommonOptions, bridgeActionOptions);
31
37
  if (action === 'start') {
32
38
  if (options.daemon && process.env[DAEMON_CHILD_ENV] !== '1') {
33
39
  await startBridgeInBackground(options);
@@ -52,61 +58,136 @@ cli
52
58
  throw new BridgeError('CommandNotFound', `Unsupported bridge action: ${action}`, { status: 404 });
53
59
  });
54
60
  cli
55
- .command('workflow <action> <workflowId>', 'Start, pause, resume, stop, query, or watch a Workflow')
56
- .option('--args <json>', 'Workflow input as a JSON object (start only)', { default: '{}' })
57
- .option('--tab <target>', 'Target tab: active, new, or id:<tabId> (start only)')
58
- .option('--host <host>', 'Bridge host', { default: DEFAULT_HOST })
59
- .option('--port <port>', 'Bridge port', { default: String(DEFAULT_PORT) })
61
+ .command('workflow <action> [target] [logId]', 'Read guides, validate nodes, list/get/create/update Workflows, control execution, or query logs')
62
+ .option('--scope <scope>', 'Workflow list scope: own, shared or pinned (list only)')
63
+ .option('--name <name>', 'Match a substring of Workflow full names (list only)')
64
+ .option('--host <host>', 'Match Workflow host exactly, without a scheme or path (list only)')
65
+ .option('--page <page>', 'Page number (list only; default: 1)')
66
+ .option('--page-size <count>', 'Page size, 1–100 (list only; default: 20)')
67
+ .option('--node-types <types>', 'Comma-separated node types to use (schema only; omit for all business nodes)')
68
+ .option('--json <json>', 'Inline JSON request (validate, create or update only)')
69
+ .option('--args <json>', 'Workflow input as a JSON object (start only; default: {})')
70
+ .option('--url <url>', 'Bridge HTTP(S) URL (or TAPICKER_BRIDGE_URL)', { default: defaultBridgeUrl })
60
71
  .option('--token <token>', 'Bridge Bearer token')
61
72
  .option('--client-id <clientId>', 'Target Extension clientId when multiple Extensions are connected')
62
- .option('--https', 'Use HTTPS when calling the Bridge')
63
- .option('--watch', 'After start, stream status updates until the Workflow reaches a terminal state')
73
+ .option('--watch', 'Wait for status updates until stopped (start or status only)')
64
74
  .option('--timeout <milliseconds>', 'Maximum watch duration; does not stop the Workflow')
65
- .action(async (action, workflowId, options) => {
75
+ .option('--levels <levels>', 'Comma-separated exact log levels (logs only)')
76
+ .option('--limit <count>', 'Number of matching logs, 1–500 (default: 100)')
77
+ .option('--before <id>', 'Read logs before this ID (exclusive)')
78
+ .option('--after <id>', 'Read logs after this ID (exclusive)')
79
+ .option('--run-at <timestamp>', 'Require the saved runAt to match (status, logs, or log)')
80
+ .option('--include-data-summary', 'Include saved data changes (status without --watch only)')
81
+ .action(async (action, workflowId, logId, options) => {
82
+ validateActionOptions(action, options, workflowCommonOptions, workflowActionOptions);
83
+ if (options.timeout !== undefined && !options.watch) {
84
+ throw new BridgeError('InvalidArguments', '--timeout requires --watch', { status: 400 });
85
+ }
86
+ if (action === 'guide' ||
87
+ action === 'schema' ||
88
+ action === 'validate' ||
89
+ action === 'list' ||
90
+ action === 'get' ||
91
+ action === 'create' ||
92
+ action === 'update') {
93
+ // cac 忽略单独的 -;恢复约定的 stdin 标记,不影响其他动作的位置参数。
94
+ if ((action === 'validate' || action === 'create' || action === 'update') &&
95
+ workflowId === undefined &&
96
+ process.argv.includes('-'))
97
+ workflowId = '-';
98
+ await queryWorkflowAuthoring(action, workflowId, logId, options);
99
+ return;
100
+ }
101
+ if (!workflowId) {
102
+ throw new BridgeError('InvalidArguments', 'workflowId is required for this action', { status: 400 });
103
+ }
104
+ if (action === 'logs' || action === 'log') {
105
+ await queryWorkflowLogs(action, workflowId, logId, options);
106
+ return;
107
+ }
108
+ if (logId !== undefined)
109
+ throw new BridgeError('InvalidArguments', 'logId is only valid for workflow log', { status: 400 });
66
110
  if (action === 'start') {
67
111
  await startWorkflow(workflowId, options);
68
112
  return;
69
113
  }
70
- if (action === 'pause' || action === 'resume' || action === 'stop' || action === 'status') {
71
- await controlWorkflow(action, workflowId, options);
114
+ if (action === 'status' && options.watch) {
115
+ if (options.runAt !== undefined || options.includeDataSummary !== undefined) {
116
+ throw new BridgeError('InvalidArguments', '--run-at and --include-data-summary require a single status query', { status: 400 });
117
+ }
118
+ await watchWorkflowStatus(workflowId, options);
72
119
  return;
73
120
  }
74
- if (action === 'watch') {
75
- await watchWorkflow(workflowId, options);
121
+ if (action === 'pause' || action === 'resume' || action === 'stop' || action === 'status') {
122
+ await controlWorkflow(action, workflowId, options);
76
123
  return;
77
124
  }
78
125
  throw new BridgeError('CommandNotFound', `Unsupported workflow action: ${action}`, { status: 404 });
79
126
  });
80
127
  cli
81
- .command('data <action> <tableId>', 'Get or delete an extracted data table by its ID')
82
- .option('--host <host>', 'Bridge host', { default: DEFAULT_HOST })
83
- .option('--port <port>', 'Bridge port', { default: String(DEFAULT_PORT) })
128
+ .command('data <action> [tableId]', 'List, preview, read, export, or delete saved data tables')
129
+ .option('--url <url>', 'Bridge HTTP(S) URL (or TAPICKER_BRIDGE_URL)', { default: defaultBridgeUrl })
84
130
  .option('--token <token>', 'Bridge Bearer token')
85
131
  .option('--client-id <clientId>', 'Target Extension clientId when multiple Extensions are connected')
86
- .option('--https', 'Use HTTPS when calling the Bridge')
132
+ .option('--sample <sample>', 'Oldest or latest rows (preview only; default: oldest)')
133
+ .option('--all', 'Read the full table (get only)')
134
+ .option('--format <format>', 'File format (export only; default: csv)')
135
+ .option('--output <path>', 'Destination file or existing directory (export only)')
136
+ .option('--table-name <name>', 'Filter by table name (list only)')
137
+ .option('--workflow-id <workflowId>', 'Filter by owning Workflow ID (list only)')
138
+ .option('--page <page>', 'Page number (list or get; default: 1)')
139
+ .option('--page-size <count>', 'Page size (list or get)')
87
140
  .action(async (action, tableId, options) => {
88
- if (action !== 'get' && action !== 'delete') {
89
- throw new BridgeError('CommandNotFound', `Unsupported data action: ${action}`, { status: 404 });
141
+ validateActionOptions(action, options, dataCommonOptions, dataActionOptions);
142
+ if (action === 'export') {
143
+ await exportDataTable(tableId, options);
144
+ return;
145
+ }
146
+ if (action === 'list' && tableId !== undefined) {
147
+ throw new BridgeError('InvalidArguments', 'data list accepts filters as options', { status: 400 });
90
148
  }
91
- const parsed = DataInputSchema.safeParse({ tableId });
149
+ const pagination = {
150
+ page: options.page === undefined ? undefined : Number(options.page),
151
+ pageSize: options.pageSize === undefined ? undefined : Number(options.pageSize),
152
+ };
153
+ const parsed = action === 'list'
154
+ ? DataListInputSchema.safeParse({ ...pagination, tableName: options.tableName, workflowId: options.workflowId })
155
+ : action === 'preview'
156
+ ? DataPreviewInputSchema.safeParse({ tableId, sample: options.sample })
157
+ : action === 'get'
158
+ ? DataGetInputSchema.safeParse({ ...pagination, tableId, all: options.all })
159
+ : DataInputSchema.safeParse({ tableId });
92
160
  if (!parsed.success) {
93
161
  throw new BridgeError('InvalidArguments', `Invalid data ${action} arguments`, {
94
162
  status: 400,
95
- details: parsed.error.issues,
163
+ details: { issues: parsed.error.issues },
96
164
  });
97
165
  }
98
- await sendDataCommand(`data.${action}`, parsed.data, options);
166
+ await printBridgeCommandResult(`data.${action}`, parsed.data, options);
167
+ });
168
+ cli
169
+ .command('user <action>', 'Read the current Tapicker account in an Extension')
170
+ .option('--url <url>', 'Bridge HTTP(S) URL (or TAPICKER_BRIDGE_URL)', { default: defaultBridgeUrl })
171
+ .option('--token <token>', 'Bridge Bearer token')
172
+ .option('--client-id <clientId>', 'Target Extension clientId when multiple Extensions are connected')
173
+ .example('tapicker user me')
174
+ .action(async (action, options) => {
175
+ validateActionOptions(action, options, workflowCommonOptions, { me: [] });
176
+ if (cli.args.length > 1) {
177
+ throw new BridgeError('InvalidArguments', 'user me does not take positional arguments', { status: 400 });
178
+ }
179
+ await printBridgeCommandResult('user.me', {}, options);
99
180
  });
100
181
  cli
101
182
  .command('mcp', 'Expose a running Tapicker Bridge to an MCP client over stdio')
102
183
  .option('--url <url>', 'Bridge HTTP(S) URL', {
103
- default: process.env.TAPICKER_BRIDGE_URL || `http://${DEFAULT_HOST}:${DEFAULT_PORT}`,
184
+ default: defaultBridgeUrl,
104
185
  })
105
186
  .option('--token <token>', 'Bridge Bearer token; prefer TAPICKER_BRIDGE_TOKEN for MCP host configuration')
106
187
  .example('tapicker mcp')
107
188
  .example('tapicker mcp --url https://bridge.example.com --token <token>')
108
189
  .action((options) => {
109
- const token = options.token || process.env.TAPICKER_BRIDGE_TOKEN;
190
+ const token = options.token ?? process.env.TAPICKER_BRIDGE_TOKEN;
110
191
  const api = createHttpMcpApi(options.url, token);
111
192
  serveStdio(() => createTapickerMcpServer(api), {
112
193
  onerror: (error) => console.error('Tapicker MCP stdio error:', error),
@@ -114,6 +195,9 @@ cli
114
195
  });
115
196
  cli.help((sections) => formatCliHelp(sections));
116
197
  cli.version(VERSION);
198
+ // 统一在命令输出前处理更新提示,包括 CAC 默认会在 parse 中直接输出的帮助和版本。
199
+ cli.showHelpOnExit = false;
200
+ cli.showVersionOnExit = false;
117
201
  const bridgeActions = {
118
202
  start: {
119
203
  summary: 'Start the Bridge server in the foreground or as a daemon.',
@@ -137,82 +221,177 @@ const bridgeActions = {
137
221
  },
138
222
  };
139
223
  const workflowActions = {
224
+ guide: {
225
+ summary: 'Read Workflow authoring documentation by topic.',
226
+ description: 'Read overview by default. Follow references.topic with another guide call; reuse the returned clientId.',
227
+ example: 'tapicker workflow guide [topic] [--client-id <uuid>]',
228
+ },
229
+ schema: {
230
+ summary: 'Get a self-contained Workflow document JSON Schema.',
231
+ description: 'Get the schema for the node types you plan to use, as comma-separated names. Omit --node-types for all business nodes.',
232
+ example: 'tapicker workflow schema [--node-types NodeLoop,NodeExtractData]',
233
+ },
234
+ validate: {
235
+ summary: 'Validate a Workflow document.',
236
+ description: 'Read a tapicker/nodes or tapicker/workflow document from --json, a file or stdin (-). Inspect isValid and normalized nodes or issues; does not save or run.',
237
+ example: 'tapicker workflow validate <file|-|--json JSON>',
238
+ },
239
+ list: {
240
+ summary: 'List your Workflow summaries with pagination.',
241
+ description: 'List own Workflows by default, or select shared/pinned. Filter by name or exact host. Does not return nodes.',
242
+ example: 'tapicker workflow list --name example --scope own',
243
+ },
244
+ get: {
245
+ summary: 'Read current Workflow nodes and metadata.',
246
+ description: 'Read the latest Workflow metadata and startup arguments using an ID from workflow list. Nodes are returned with view, edit or manage permission.',
247
+ example: 'tapicker workflow get <workflowId>',
248
+ },
249
+ create: {
250
+ summary: 'Validate and create a private Workflow from JSON.',
251
+ description: 'Read {name, intro?, href?, nodes} from --json, a file or stdin (-). Does not run. Check the list before retrying after timeout or connection failure.',
252
+ example: 'tapicker workflow create <file|-|--json JSON>',
253
+ },
254
+ update: {
255
+ summary: 'Update a Workflow using its last-read revision.',
256
+ description: 'Read {workflowId, revision, name?, intro?, href?, nodes?} from --json, a file or stdin (-). Provide at least one change. On WorkflowConflict, read the latest Workflow and reconcile changes before submitting again.',
257
+ example: 'tapicker workflow update <file|-|--json JSON>',
258
+ },
140
259
  start: {
141
260
  summary: 'Start one Workflow instance; optionally stream its status with --watch.',
142
- description: 'Start the specified Workflow and return its workflowId, full workflowName, and selected clientId. A Workflow supports one active instance at a time. By default this returns after the start response; add --watch to stream status until it completes, stops, or fails.',
143
- example: `tapicker workflow start <workflowId> [--args '{"name":"Tom"}'] [--tab active|new|id:<tabId>] [--watch]`,
261
+ description: 'Start a saved Workflow from the browser’s active tab and return an accepted receipt with workflowId, workflowName, and clientId. A Workflow supports one active instance at a time. By default this returns after the start response; add --watch to stream status until it completes, stops, or fails.',
262
+ example: `tapicker workflow start <workflowId> [--args '{"name":"Tom"}'] [--watch]`,
144
263
  },
145
264
  pause: {
146
265
  summary: 'Pause the active instance of a Workflow.',
147
- description: 'Pause the active instance of the specified Workflow.',
266
+ description: 'Pause the active instance at its next pause point. An already paused instance stays paused.',
148
267
  example: 'tapicker workflow pause <workflowId>',
149
268
  },
150
269
  resume: {
151
270
  summary: 'Resume a paused Workflow instance.',
152
- description: 'Resume a paused instance of the specified Workflow.',
271
+ description: 'Resume the same paused instance. An already running instance continues.',
153
272
  example: 'tapicker workflow resume <workflowId>',
154
273
  },
155
274
  stop: {
156
- summary: 'Stop the active instance of a Workflow.',
157
- description: 'Stop the active instance of the specified Workflow.',
275
+ summary: 'Request a stop for the active Workflow instance.',
276
+ description: 'Request a stop for the active instance, including a paused instance. Use workflow status to confirm it has stopped.',
158
277
  example: 'tapicker workflow stop <workflowId>',
159
278
  },
160
279
  status: {
161
- summary: 'Fetch one current Workflow status snapshot.',
162
- description: 'Fetch a single current-state snapshot. Use watch to receive ongoing updates without polling.',
163
- example: 'tapicker workflow status <workflowId>',
280
+ summary: 'Query Workflow status; optionally wait for updates with --watch.',
281
+ description: 'Read the latest recorded status, or add --watch to wait until stopped, timeout, or cancellation. Does not start the Workflow. An already stopped snapshot returns immediately.',
282
+ example: 'tapicker workflow status <workflowId> [--watch] [--timeout <milliseconds>]',
283
+ },
284
+ logs: {
285
+ summary: 'Query latest-run log previews with optional level filters and pagination.',
286
+ description: 'Without a cursor, return the newest matching page in ascending ID order. Use before for older logs, after for new logs, and log for full details. run-at optionally checks the run identity.',
287
+ example: 'tapicker workflow logs <workflowId> [--levels warn,error] [--limit 100] [--before <id> | --after <id>]',
164
288
  },
165
- watch: {
166
- summary: 'Stream Workflow status until it completes, stops, or fails.',
167
- description: 'Listen for status updates until the Workflow reaches completed, stopped, or failed. Timeout or Ctrl+C ends only the watch; it does not stop the Workflow.',
168
- example: 'tapicker workflow watch <workflowId> [--timeout <milliseconds>]',
289
+ log: {
290
+ summary: 'Read one latest-run log with full details.',
291
+ description: 'Return one log by workflowId and logId without preview truncation. run-at optionally checks the run identity.',
292
+ example: 'tapicker workflow log <workflowId> <logId> [--run-at <timestamp>]',
169
293
  },
170
294
  };
171
295
  const dataActions = {
296
+ list: {
297
+ summary: 'List saved data tables.',
298
+ description: 'Filter by table name or owning Workflow ID. Returns table metadata without using export quota.',
299
+ example: 'tapicker data list --table-name products --page 1',
300
+ },
301
+ preview: {
302
+ summary: 'Preview up to 10 rows without using export quota.',
303
+ description: 'Inspect the oldest or latest inserted rows. A size-limited response can contain fewer rows.',
304
+ example: 'tapicker data preview <tableId> --sample latest',
305
+ },
172
306
  get: {
173
- summary: 'Get all rows from an extracted data table; consumes export quota.',
174
- description: 'Retrieve the complete data table identified by tableId. The row count is charged against the connected Extension account export quota.',
307
+ summary: 'Read a page or all rows from an extracted data table.',
308
+ description: 'Read page 1 with 50 rows by default, or use --all to read the full table. Returned rows consume export quota. If the response is lost, quota may already have been deducted.',
175
309
  example: 'tapicker data get <tableId>',
176
310
  },
311
+ export: {
312
+ summary: 'Export a saved data table to a local file.',
313
+ description: 'Export the entire table. Returned rows count against export quota. Use --output to choose a new file or an existing directory.',
314
+ example: 'tapicker data export <tableId> --format xlsx --output ./products.xlsx',
315
+ },
177
316
  delete: {
178
- summary: 'Delete an extracted data table.',
179
- description: 'Delete the data table identified by tableId and remove it from Tapicker Data.',
317
+ summary: 'Delete a saved table and all its rows.',
318
+ description: 'Delete the saved table and all its rows. Use the ID from Copy ID in Tapicker Data.',
180
319
  example: 'tapicker data delete <tableId>',
181
320
  },
182
321
  };
183
322
  const workflowCommonOptions = [
184
- ['--host <host>', `Bridge host (default: ${DEFAULT_HOST})`],
185
- ['--port <port>', `Bridge port (default: ${DEFAULT_PORT})`],
186
- ['--token <token>', 'Bridge Bearer token'],
187
- ['--client-id <clientId>', 'Select an Extension; required when multiple are ready'],
188
- ['--https', 'Use HTTPS/WSS instead of HTTP/WS'],
189
- ];
190
- const dataCommonOptions = [
191
- ['--host <host>', `Bridge host (default: ${DEFAULT_HOST})`],
192
- ['--port <port>', `Bridge port (default: ${DEFAULT_PORT})`],
193
- ['--token <token>', 'Bridge Bearer token'],
323
+ ['--url <url>', `Bridge HTTP(S) URL (TAPICKER_BRIDGE_URL or http://${DEFAULT_HOST}:${DEFAULT_PORT})`],
324
+ ['--token <token>', 'Bridge Bearer token (or TAPICKER_BRIDGE_TOKEN)'],
194
325
  ['--client-id <clientId>', 'Select an Extension; required when multiple are ready'],
195
- ['--https', 'Use HTTPS instead of HTTP'],
196
326
  ];
327
+ const dataCommonOptions = workflowCommonOptions;
328
+ const dataActionOptions = {
329
+ list: [
330
+ ['--table-name <name>', 'Match a case-insensitive substring of table names'],
331
+ ['--workflow-id <workflowId>', 'Match the owning Workflow ID'],
332
+ ['--page <page>', 'Page number (default: 1)'],
333
+ ['--page-size <count>', 'Tables per page (default: 20, max: 100)'],
334
+ ],
335
+ preview: [['--sample <sample>', 'oldest or latest (default: oldest)']],
336
+ get: [
337
+ ['--all', 'Read the full table; omit pagination options'],
338
+ ['--page <page>', 'Page number (default: 1)'],
339
+ ['--page-size <count>', 'Rows per page (default: 50, max: 500)'],
340
+ ],
341
+ export: [
342
+ ['--format <format>', 'xlsx, xls, csv, json, xml, or txt (default: csv)'],
343
+ ['--output <path>', 'Destination file or existing directory (required)'],
344
+ ],
345
+ delete: [],
346
+ };
347
+ const workflowJsonOptions = [['--json <json>', 'Inline JSON input; use instead of a file or -']];
197
348
  const workflowActionOptions = {
349
+ guide: [],
350
+ schema: [['--node-types <types>', 'Comma-separated node types to use; omit for all business nodes']],
351
+ validate: workflowJsonOptions,
352
+ list: [
353
+ ['--scope <scope>', 'own (default), shared or pinned'],
354
+ ['--name <name>', 'Match a substring of full names'],
355
+ ['--host <host>', 'Match host exactly; omit for all sites'],
356
+ ['--page <page>', 'Page number (default: 1)'],
357
+ ['--page-size <count>', 'Page size, 1–100 (default: 20)'],
358
+ ],
359
+ get: [],
360
+ create: workflowJsonOptions,
361
+ update: workflowJsonOptions,
198
362
  start: [
199
363
  ['--args <json>', 'Workflow input object (default: {})'],
200
- ['--tab <target>', 'Target tab: active (default), new, or id:<positive-tabId>'],
201
364
  ['--watch', 'Stream status updates after starting, until a terminal state'],
202
365
  ['--timeout <milliseconds>', 'Limit the --watch duration; does not stop the Workflow'],
203
366
  ],
204
367
  pause: [],
205
368
  resume: [],
206
369
  stop: [],
207
- status: [],
208
- watch: [['--timeout <milliseconds>', 'Limit how long to wait; does not stop the Workflow']],
370
+ status: [
371
+ ['--run-at <timestamp>', 'Require the saved runAt to match (single query only)'],
372
+ ['--include-data-summary', 'Include saved data changes (single query only)'],
373
+ ['--watch', 'Wait for status updates without starting the Workflow; return immediately if stopped'],
374
+ ['--timeout <milliseconds>', 'Limit the --watch duration; does not stop the Workflow'],
375
+ ],
376
+ logs: [
377
+ ['--levels <levels>', 'Comma-separated exact levels: trace,debug,info,warn,error'],
378
+ ['--limit <count>', 'Matching records per page, 1–500 (default: 100)'],
379
+ ['--before <id>', 'Read older matching logs before this ID (exclusive)'],
380
+ ['--after <id>', 'Read newer matching logs after this ID (exclusive)'],
381
+ ['--run-at <timestamp>', 'Require the saved runAt to match'],
382
+ ],
383
+ log: [['--run-at <timestamp>', 'Require the saved runAt to match']],
209
384
  };
210
385
  const workflowGeneralOptions = [
211
386
  ...workflowCommonOptions,
387
+ ...workflowActionOptions.schema,
388
+ ...workflowActionOptions.list,
389
+ ['--json <json>', 'Inline JSON input (validate, create or update only)'],
212
390
  ['--args <json>', 'Workflow input object (default: {}; start only)'],
213
- ['--tab <target>', 'Target tab: active (default), new, or id:<positive-tabId> (start only)'],
214
- ['--watch', 'Stream status after start until a terminal state (start only)'],
215
- ['--timeout <milliseconds>', 'Limit watch duration (watch or start --watch); does not stop the Workflow'],
391
+ ['--watch', 'Wait for status updates until stopped (start or status only)'],
392
+ ['--timeout <milliseconds>', 'Limit --watch duration; does not stop the Workflow'],
393
+ ...workflowActionOptions.logs,
394
+ ['--include-data-summary', 'Include saved data changes (status without --watch only)'],
216
395
  ];
217
396
  const bridgeCommonOptions = [['--data-dir <path>', 'Bridge state directory (TAPICKER_DATA_DIR or ~/.tapicker)']];
218
397
  const bridgeActionOptions = {
@@ -229,8 +408,32 @@ const bridgeActionOptions = {
229
408
  code: [['--refresh', 'Rotate the pairing code and unlock pairing after rate limiting']],
230
409
  extensions: [],
231
410
  };
411
+ /**
412
+ * 按动作限制参数,避免注册在同一 CLI 分组上的选项被静默忽略。
413
+ */
414
+ function validateActionOptions(action, options, common, actions) {
415
+ if (!Object.hasOwn(actions, action)) {
416
+ throw new BridgeError('CommandNotFound', `Unsupported action: ${action}`, { status: 404 });
417
+ }
418
+ const allowed = new Set([...common, ...actions[action]].map(([flag]) => flag
419
+ .split(' ')[0]
420
+ .slice(2)
421
+ .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())));
422
+ for (const [key, value] of Object.entries(options)) {
423
+ if (key === '--' || value === undefined || allowed.has(key))
424
+ continue;
425
+ const flag = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
426
+ throw new BridgeError('InvalidArguments', `--${flag} is not valid for ${action}`, { status: 400 });
427
+ }
428
+ }
232
429
  function formatCliHelp(sections) {
233
430
  const command = cli.matchedCommandName;
431
+ if (command === 'user') {
432
+ const usage = sections.find((section) => section.title === 'Usage');
433
+ if (usage)
434
+ usage.body = ' $ tapicker user me [options]';
435
+ return sections;
436
+ }
234
437
  if (command === 'mcp') {
235
438
  const usage = sections.find((section) => section.title === 'Usage');
236
439
  if (usage)
@@ -247,21 +450,28 @@ function formatCliHelp(sections) {
247
450
  const action = cli.args[0];
248
451
  const actionMap = command === 'workflow' ? workflowActions : command === 'bridge' ? bridgeActions : dataActions;
249
452
  const actionInfo = actionMap[action];
250
- const actionOptions = command === 'workflow' ? workflowActionOptions : command === 'bridge' ? bridgeActionOptions : {};
453
+ const actionOptions = command === 'workflow' ? workflowActionOptions : command === 'bridge' ? bridgeActionOptions : dataActionOptions;
251
454
  const commonOptions = command === 'workflow' ? workflowCommonOptions : command === 'bridge' ? bridgeCommonOptions : dataCommonOptions;
252
455
  const usage = sections.find((section) => section.title === 'Usage');
253
456
  if (usage) {
254
457
  if (actionInfo && command === 'workflow') {
255
- usage.body = ` $ tapicker workflow ${action} <workflowId> [options]`;
458
+ const target = action === 'guide'
459
+ ? ' [topic]'
460
+ : action === 'schema' || action === 'list'
461
+ ? ''
462
+ : action === 'validate' || action === 'create' || action === 'update'
463
+ ? ' <file|-|--json JSON>'
464
+ : ' <workflowId>';
465
+ usage.body = ` $ tapicker workflow ${action}${target}${action === 'log' ? ' <logId>' : ''} [options]`;
256
466
  }
257
467
  else if (actionInfo && command === 'data') {
258
- usage.body = ` $ tapicker data ${action} <tableId> [options]`;
468
+ usage.body = ` $ tapicker data ${action}${action === 'list' ? '' : ' <tableId>'} [options]`;
259
469
  }
260
470
  else if (actionInfo) {
261
471
  usage.body = ` $ tapicker bridge ${action} [options]`;
262
472
  }
263
473
  else {
264
- const target = command === 'workflow' ? ' <workflowId>' : command === 'data' ? ' <tableId>' : '';
474
+ const target = command === 'workflow' ? ' [target]' : command === 'data' ? ' [tableId]' : '';
265
475
  usage.body = ` $ tapicker ${command} <action>${target} [options]`;
266
476
  }
267
477
  }
@@ -274,7 +484,15 @@ function formatCliHelp(sections) {
274
484
  ...commonOptions,
275
485
  ...Object.entries(actionOptions).flatMap(([name, entries]) => entries.map(([option, description]) => [option, `${description} (${name} only)`])),
276
486
  ]
277
- : commonOptions;
487
+ : [
488
+ ...commonOptions,
489
+ ...dataActionOptions.list.slice(0, 2),
490
+ ...dataActionOptions.preview,
491
+ dataActionOptions.get[0],
492
+ ...dataActionOptions.export,
493
+ ['--page <page>', 'Page number (list or get)'],
494
+ ['--page-size <count>', 'Page size (list or get)'],
495
+ ];
278
496
  const optionsSection = sections.find((section) => section.title === 'Options');
279
497
  if (optionsSection)
280
498
  optionsSection.body = formatOptionRows(options);
@@ -339,8 +557,12 @@ async function startBridge(options) {
339
557
  }
340
558
  process.exit(0);
341
559
  };
342
- process.once('SIGINT', () => void shutdown());
343
- process.once('SIGTERM', () => void shutdown());
560
+ process.once('SIGINT', () => {
561
+ shutdown();
562
+ });
563
+ process.once('SIGTERM', () => {
564
+ shutdown();
565
+ });
344
566
  try {
345
567
  controlServer = await startBridgeControlServer(dataDir, async (action) => {
346
568
  if (action === 'code.get') {
@@ -353,7 +575,9 @@ async function startBridge(options) {
353
575
  if (action === 'extensions.list') {
354
576
  return server.bridge.getExtensionStatuses();
355
577
  }
356
- setTimeout(() => void shutdown(), 50);
578
+ setTimeout(() => {
579
+ shutdown();
580
+ }, 50);
357
581
  return { pid: process.pid, bridgeId: server.bridgeId };
358
582
  });
359
583
  if (isDaemonChild) {
@@ -397,7 +621,7 @@ async function startBridge(options) {
397
621
  bridgeId: server.bridgeId,
398
622
  httpUrl: address.httpUrl,
399
623
  wsUrl: address.wsUrl,
400
- workflowWatchWsUrl: address.workflowWatchWsUrl,
624
+ workflowStatusWatchWsUrl: address.workflowStatusWatchWsUrl,
401
625
  dataDir,
402
626
  logPath: getBridgeDaemonLogPath(dataDir),
403
627
  pairingCode,
@@ -591,7 +815,7 @@ function isDaemonReadyMessage(value) {
591
815
  typeof message.bridgeId === 'string' &&
592
816
  typeof message.httpUrl === 'string' &&
593
817
  typeof message.wsUrl === 'string' &&
594
- typeof message.workflowWatchWsUrl === 'string' &&
818
+ typeof message.workflowStatusWatchWsUrl === 'string' &&
595
819
  typeof message.dataDir === 'string' &&
596
820
  typeof message.logPath === 'string' &&
597
821
  typeof message.authenticationEnabled === 'boolean');
@@ -632,16 +856,16 @@ async function isManagedBridgeRunning(state) {
632
856
  async function startWorkflow(workflowId, options) {
633
857
  let args;
634
858
  try {
635
- args = JSON.parse(options.args);
859
+ args = JSON.parse(options.args ?? '{}');
636
860
  }
637
861
  catch (error) {
638
862
  throw new BridgeError('InvalidArguments', '--args must be valid JSON', { status: 400, cause: error });
639
863
  }
640
- const parsed = WorkflowStartInputSchema.safeParse({ workflowId, args, tab: parseTabTarget(options.tab) });
864
+ const parsed = WorkflowStartInputSchema.safeParse({ workflowId, args });
641
865
  if (!parsed.success) {
642
866
  throw new BridgeError('InvalidArguments', 'Invalid Workflow start arguments', {
643
867
  status: 400,
644
- details: parsed.error.issues,
868
+ details: { issues: parsed.error.issues },
645
869
  });
646
870
  }
647
871
  if (!options.watch) {
@@ -649,51 +873,60 @@ async function startWorkflow(workflowId, options) {
649
873
  return;
650
874
  }
651
875
  const timeoutMs = parseWatchTimeout(options.timeout);
652
- const connection = await openWorkflowWatch(workflowId, options);
876
+ const connection = await openWorkflowStatusWatch(parsed.data.workflowId, options);
653
877
  try {
654
- const startResult = await requestBridgeCommand('workflow.start', parsed.data, {
878
+ const receipt = await requestBridgeCommand('workflow.start', parsed.data, {
655
879
  ...options,
656
880
  clientId: connection.clientId,
657
881
  });
658
- console.log(JSON.stringify({ type: 'workflow.start', data: startResult }));
882
+ const startResult = WorkflowStartResultSchema.safeParse(receipt);
883
+ if (!startResult.success ||
884
+ startResult.data.workflowId !== parsed.data.workflowId ||
885
+ startResult.data.clientId !== connection.clientId) {
886
+ throw new BridgeError('InvalidBridgeResponse', 'Bridge returned an invalid Workflow start receipt', {
887
+ status: 502,
888
+ });
889
+ }
890
+ const { runAt } = startResult.data;
891
+ console.log(JSON.stringify({ type: 'workflow.start', data: startResult.data }));
659
892
  const result = await waitForWorkflowStatus({
660
893
  clientId: connection.clientId,
661
- workflowId,
662
- subscribe: (onStatus, onClose) => subscribeToWorkflowWatch(connection, onStatus, onClose),
663
- readStatus: () => readWorkflowStatus(workflowId, connection.clientId, options),
894
+ workflowId: parsed.data.workflowId,
895
+ runAt,
896
+ subscribe: (onStatus, onClose) => subscribeToWorkflowStatusWatch(connection, onStatus, onClose),
897
+ readStatus: () => readWorkflowStatus(parsed.data.workflowId, connection.clientId, options, runAt),
664
898
  onStatus: printWorkflowStatus,
665
899
  timeoutMs,
666
900
  });
667
- printWatchTimeout(result);
901
+ printWatchResult(result);
668
902
  }
669
903
  finally {
670
904
  connection.close();
671
905
  }
672
906
  }
673
- async function watchWorkflow(workflowId, options) {
907
+ async function watchWorkflowStatus(workflowId, options) {
674
908
  const timeoutMs = parseWatchTimeout(options.timeout);
675
- const connection = await openWorkflowWatch(workflowId, options);
909
+ const connection = await openWorkflowStatusWatch(workflowId, options);
676
910
  try {
677
911
  const result = await waitForWorkflowStatus({
678
912
  clientId: connection.clientId,
679
913
  workflowId,
680
- subscribe: (onStatus, onClose) => subscribeToWorkflowWatch(connection, onStatus, onClose),
914
+ subscribe: (onStatus, onClose) => subscribeToWorkflowStatusWatch(connection, onStatus, onClose),
681
915
  readStatus: () => readWorkflowStatus(workflowId, connection.clientId, options),
682
916
  onStatus: printWorkflowStatus,
683
917
  timeoutMs,
684
918
  });
685
- printWatchTimeout(result);
919
+ printWatchResult(result);
686
920
  }
687
921
  finally {
688
922
  connection.close();
689
923
  }
690
924
  }
691
- async function openWorkflowWatch(workflowId, options) {
692
- const protocol = options.https ? 'https' : 'http';
693
- const bridgeUrl = new URL(`${protocol}://${options.host}:${parsePort(options.port)}`);
694
- return connectWorkflowWatch(workflowWatchWebSocketUrl(bridgeUrl), { clientId: options.clientId, workflowId }, options.token);
925
+ async function openWorkflowStatusWatch(workflowId, options) {
926
+ const bridgeUrl = parseBridgeUrl(options.url);
927
+ return connectWorkflowStatusWatch(workflowStatusWatchWebSocketUrl(bridgeUrl), { clientId: options.clientId, workflowId }, options.token ?? process.env.TAPICKER_BRIDGE_TOKEN);
695
928
  }
696
- function subscribeToWorkflowWatch(connection, onStatus, onClose) {
929
+ function subscribeToWorkflowStatusWatch(connection, onStatus, onClose) {
697
930
  const removeStatusListener = connection.onStatus(onStatus);
698
931
  const removeCloseListener = connection.onClose(onClose);
699
932
  return () => {
@@ -701,15 +934,15 @@ function subscribeToWorkflowWatch(connection, onStatus, onClose) {
701
934
  removeCloseListener();
702
935
  };
703
936
  }
704
- function printWatchTimeout(result) {
705
- if (result.timedOut) {
706
- console.log(JSON.stringify({ type: 'workflow.watch:res', data: { ok: true, data: result } }));
707
- }
937
+ function printWatchResult(result) {
938
+ console.log(JSON.stringify({ type: 'workflow.status.watch', data: result }));
708
939
  }
709
- async function readWorkflowStatus(workflowId, clientId, options) {
710
- const result = await requestBridgeCommand('workflow.status', { workflowId }, { ...options, clientId });
711
- const parsed = WorkflowStatusEventSchema.safeParse({ ...result, clientId });
712
- if (!parsed.success || parsed.data.workflowId !== workflowId) {
940
+ async function readWorkflowStatus(workflowId, clientId, options, runAt) {
941
+ const result = await requestBridgeCommand('workflow.status', { workflowId, runAt }, { ...options, clientId });
942
+ const parsed = WorkflowStatusEventSchema.strip().safeParse({ ...result, clientId });
943
+ if (!parsed.success ||
944
+ parsed.data.workflowId !== workflowId ||
945
+ (runAt !== undefined && parsed.data.runAt !== runAt)) {
713
946
  throw new BridgeError('InvalidBridgeResponse', 'Bridge returned an invalid Workflow status response', {
714
947
  status: 502,
715
948
  details: parsed.success ? undefined : parsed.error.issues,
@@ -718,7 +951,7 @@ async function readWorkflowStatus(workflowId, clientId, options) {
718
951
  return parsed.data;
719
952
  }
720
953
  function printWorkflowStatus(status) {
721
- console.log(JSON.stringify({ type: 'workflow.status', data: status }));
954
+ console.log(JSON.stringify({ type: 'workflow.status.changed', data: status }));
722
955
  }
723
956
  function parseWatchTimeout(value) {
724
957
  if (value === undefined)
@@ -731,28 +964,129 @@ function parseWatchTimeout(value) {
731
964
  }
732
965
  return timeoutMs;
733
966
  }
734
- function parseTabTarget(value) {
735
- if (value === undefined || value === 'active') {
736
- return undefined;
967
+ /**
968
+ * 编写命令复用既有 HTTP 协议,只在 CLI 层读取片段文件和转换参数。
969
+ */
970
+ async function queryWorkflowAuthoring(action, target, extra, options) {
971
+ if (extra !== undefined || ((action === 'schema' || action === 'list') && target !== undefined)) {
972
+ throw new BridgeError('InvalidArguments', `Unexpected positional argument for workflow ${action}`, { status: 400 });
973
+ }
974
+ if ([options.levels, options.limit, options.before, options.after, options.runAt].some((value) => value !== undefined)) {
975
+ throw new BridgeError('InvalidArguments', 'Log query options are not valid for authoring commands', { status: 400 });
976
+ }
977
+ let input;
978
+ if (action === 'guide')
979
+ input = { topic: target };
980
+ else if (action === 'schema') {
981
+ if (options.nodeTypes !== undefined && typeof options.nodeTypes !== 'string') {
982
+ throw new BridgeError('InvalidArguments', 'Provide --node-types once with comma-separated node type names', {
983
+ status: 400,
984
+ });
985
+ }
986
+ input = { nodeTypes: options.nodeTypes?.split(',').map((type) => type.trim()) };
987
+ }
988
+ else if (action === 'list')
989
+ input = {
990
+ scope: options.scope,
991
+ name: options.name,
992
+ host: options.host,
993
+ page: options.page === undefined ? undefined : Number(options.page),
994
+ pageSize: options.pageSize === undefined ? undefined : Number(options.pageSize),
995
+ };
996
+ else if (action === 'get')
997
+ input = { workflowId: target };
998
+ else {
999
+ const inline = options.json;
1000
+ if (inline !== undefined && (typeof inline !== 'string' || target !== undefined)) {
1001
+ throw new BridgeError('InvalidArguments', 'Use --json once, without a file path or -', { status: 400 });
1002
+ }
1003
+ if (inline === undefined && !target) {
1004
+ throw new BridgeError('InvalidArguments', 'Provide --json, a JSON file path, or - for stdin', { status: 400 });
1005
+ }
1006
+ try {
1007
+ const value = JSON.parse(inline ?? readFileSync(target === '-' ? 0 : target, 'utf8'));
1008
+ input = action === 'validate' ? { fragment: value } : value;
1009
+ }
1010
+ catch (cause) {
1011
+ throw new BridgeError('InvalidArguments', inline === undefined ? 'Unable to read Workflow JSON' : '--json must be valid JSON', {
1012
+ status: 400,
1013
+ cause,
1014
+ });
1015
+ }
737
1016
  }
738
- if (value === 'new') {
739
- return { mode: 'new' };
1017
+ const schema = action === 'guide'
1018
+ ? WorkflowGuideInputSchema
1019
+ : action === 'schema'
1020
+ ? WorkflowSchemaInputSchema
1021
+ : action === 'validate'
1022
+ ? WorkflowValidateInputSchema
1023
+ : action === 'list'
1024
+ ? WorkflowListInputSchema
1025
+ : action === 'get'
1026
+ ? WorkflowGetInputSchema
1027
+ : action === 'create'
1028
+ ? WorkflowCreateInputSchema
1029
+ : WorkflowUpdateInputSchema;
1030
+ const parsed = schema.safeParse(input);
1031
+ if (!parsed.success) {
1032
+ throw new BridgeError('InvalidArguments', `Invalid workflow.${action} arguments`, {
1033
+ status: 400,
1034
+ details: { issues: parsed.error.issues },
1035
+ });
740
1036
  }
741
- const match = /^id:(\d+)$/.exec(value);
742
- const tabId = match ? Number(match[1]) : Number.NaN;
743
- if (!Number.isSafeInteger(tabId) || tabId <= 0) {
744
- throw new BridgeError('InvalidArguments', '--tab must be "active", "new", or "id:<positive-tabId>"', {
1037
+ await sendWorkflowCommand(`workflow.${action}`, parsed.data, options);
1038
+ }
1039
+ /**
1040
+ * 将命令行参数转换为统一日志查询协议,发出请求前校验分页约束。
1041
+ */
1042
+ async function queryWorkflowLogs(action, workflowId, logId, options) {
1043
+ if (action === 'logs' && logId !== undefined)
1044
+ throw new BridgeError('InvalidArguments', 'logId is only valid for workflow log', { status: 400 });
1045
+ if (action === 'log' &&
1046
+ [options.levels, options.limit, options.before, options.after].some((value) => value !== undefined)) {
1047
+ throw new BridgeError('InvalidArguments', 'Log filters and pagination are only valid for workflow logs', {
745
1048
  status: 400,
746
1049
  });
747
1050
  }
748
- return { mode: 'id', tabId };
1051
+ if (options.levels !== undefined && typeof options.levels !== 'string') {
1052
+ throw new BridgeError('InvalidArguments', 'Provide --levels once with comma-separated levels, such as --levels warn,error', {
1053
+ status: 400,
1054
+ });
1055
+ }
1056
+ const input = action === 'log'
1057
+ ? {
1058
+ workflowId,
1059
+ logId: Number(logId),
1060
+ runAt: options.runAt,
1061
+ }
1062
+ : {
1063
+ workflowId,
1064
+ runAt: options.runAt,
1065
+ levels: options.levels?.split(',').map((level) => level.trim()),
1066
+ limit: options.limit === undefined ? undefined : Number(options.limit),
1067
+ before: options.before === undefined ? undefined : Number(options.before),
1068
+ after: options.after === undefined ? undefined : Number(options.after),
1069
+ };
1070
+ const parsed = (action === 'log' ? WorkflowLogInputSchema : WorkflowLogsInputSchema).safeParse(input);
1071
+ if (!parsed.success)
1072
+ throw new BridgeError('InvalidArguments', `Invalid workflow.${action} arguments`, {
1073
+ status: 400,
1074
+ details: { issues: parsed.error.issues },
1075
+ });
1076
+ await sendWorkflowCommand(`workflow.${action}`, parsed.data, options);
749
1077
  }
750
1078
  async function controlWorkflow(action, workflowId, options) {
751
- const parsed = WorkflowControlInputSchema.safeParse({ workflowId });
1079
+ const parsed = action === 'status'
1080
+ ? WorkflowStatusInputSchema.safeParse({
1081
+ workflowId,
1082
+ runAt: options.runAt,
1083
+ includeDataSummary: options.includeDataSummary,
1084
+ })
1085
+ : WorkflowControlInputSchema.safeParse({ workflowId });
752
1086
  if (!parsed.success) {
753
1087
  throw new BridgeError('InvalidArguments', `Invalid Workflow ${action} arguments`, {
754
1088
  status: 400,
755
- details: parsed.error.issues,
1089
+ details: { issues: parsed.error.issues },
756
1090
  });
757
1091
  }
758
1092
  await sendWorkflowCommand(`workflow.${action}`, parsed.data, options);
@@ -762,31 +1096,103 @@ async function sendWorkflowCommand(command, input, options) {
762
1096
  console.log(JSON.stringify(result, null, 2));
763
1097
  return result;
764
1098
  }
765
- async function sendDataCommand(command, input, options) {
1099
+ /**
1100
+ * 先验证目标路径,再发起导出;目录模式使用回执文件名并避开重名。
1101
+ */
1102
+ async function exportDataTable(tableId, options) {
1103
+ const input = DataExportInputSchema.safeParse({ tableId, format: options.format });
1104
+ if (!input.success || typeof options.output !== 'string' || !options.output.trim()) {
1105
+ throw new BridgeError('InvalidArguments', 'Provide a tableId, --output, and a supported --format', { status: 400 });
1106
+ }
1107
+ const output = resolve(options.output);
1108
+ const directory = await stat(output)
1109
+ .then((entry) => entry.isDirectory())
1110
+ .catch((cause) => {
1111
+ if (cause.code === 'ENOENT' && !options.output?.endsWith(sep) && !options.output?.endsWith('/'))
1112
+ return false;
1113
+ throw new BridgeError('ExportDestinationUnavailable', 'Cannot access the destination path', {
1114
+ status: 400,
1115
+ details: { output },
1116
+ cause,
1117
+ });
1118
+ });
1119
+ if (directory) {
1120
+ await access(output, constants.W_OK).catch((cause) => {
1121
+ throw new BridgeError('ExportDestinationUnavailable', 'Cannot write to the destination directory', {
1122
+ status: 400,
1123
+ details: { output },
1124
+ cause,
1125
+ });
1126
+ });
1127
+ }
1128
+ let target = directory ? undefined : await openExportOutput(output);
1129
+ let saved = false;
1130
+ try {
1131
+ const receipt = DataExportResultSchema.safeParse(await requestBridgeCommand('data.export', input.data, options));
1132
+ if (!receipt.success)
1133
+ throw new BridgeError('InvalidBridgeResponse', 'Bridge returned an invalid export receipt', { status: 502 });
1134
+ const token = options.token ?? process.env.TAPICKER_BRIDGE_TOKEN;
1135
+ try {
1136
+ target ??= await openExportOutput(output, receipt.data.filename);
1137
+ const response = await fetch(new URL(receipt.data.downloadPath, parseBridgeUrl(options.url)), {
1138
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
1139
+ redirect: 'error',
1140
+ signal: AbortSignal.timeout(120_000),
1141
+ });
1142
+ if (!response.ok || !response.body)
1143
+ throw new Error(`HTTP ${response.status}`);
1144
+ await pipeline(Readable.fromWeb(response.body), target.file.createWriteStream());
1145
+ }
1146
+ catch (cause) {
1147
+ throw new BridgeError('ExportDownloadFailed', 'Failed to save the export. The file can be downloaded again before it expires.', {
1148
+ status: 502,
1149
+ details: { downloadPath: receipt.data.downloadPath, expiresAt: receipt.data.expiresAt },
1150
+ cause,
1151
+ });
1152
+ }
1153
+ saved = true;
1154
+ console.log(JSON.stringify({
1155
+ ...receipt.data,
1156
+ output: target.path,
1157
+ }, null, 2));
1158
+ }
1159
+ finally {
1160
+ if (target) {
1161
+ await target.file.close();
1162
+ if (!saved)
1163
+ await rm(target.path, { force: true });
1164
+ }
1165
+ }
1166
+ }
1167
+ /**
1168
+ * 以独占方式创建文件,目录模式在同名时追加编号。
1169
+ */
1170
+ async function openExportOutput(output, filename) {
1171
+ if (filename !== undefined && (filename === '.' || filename === '..' || /[\\/:\x00-\x1F]/.test(filename))) {
1172
+ throw new BridgeError('InvalidBridgeResponse', 'Bridge returned an invalid filename', { status: 502 });
1173
+ }
1174
+ const { name, ext } = parse(filename ?? '');
1175
+ for (let suffix = 0;; suffix++) {
1176
+ const path = filename === undefined ? output : join(output, suffix ? `${name} (${suffix})${ext}` : filename);
1177
+ try {
1178
+ return { path, file: await open(path, 'wx') };
1179
+ }
1180
+ catch (error) {
1181
+ const cause = error;
1182
+ if (cause.code === 'EEXIST' && filename !== undefined)
1183
+ continue;
1184
+ throw new BridgeError(cause.code === 'EEXIST' ? 'ExportDestinationExists' : 'ExportDestinationUnavailable', cause.code === 'EEXIST' ? 'The destination file already exists' : 'Cannot open the destination file', { status: 400, details: { output: path }, cause });
1185
+ }
1186
+ }
1187
+ }
1188
+ async function printBridgeCommandResult(command, input, options) {
766
1189
  const result = await requestBridgeCommand(command, input, options);
767
1190
  console.log(JSON.stringify(result, null, 2));
768
1191
  return result;
769
1192
  }
770
1193
  async function requestBridgeCommand(command, input, options) {
771
- const protocol = options.https ? 'https' : 'http';
772
- const url = `${protocol}://${options.host}:${parsePort(options.port)}/v1/commands/${command}`;
773
- const headers = { 'Content-Type': 'application/json' };
774
- if (options.token) {
775
- headers.Authorization = `Bearer ${options.token}`;
776
- }
777
- const response = await fetch(url, {
778
- method: 'POST',
779
- headers,
780
- body: JSON.stringify({ clientId: options.clientId, input }),
781
- });
782
- const body = await readResponseBody(response);
783
- if (!response.ok) {
784
- throw new BridgeError(body.error?.code || 'BridgeRequestFailed', body.error?.message || response.statusText, {
785
- status: response.status,
786
- details: body.error?.details,
787
- });
788
- }
789
- return body.data ?? body;
1194
+ const api = createHttpBridgeApi(options.url, options.token ?? process.env.TAPICKER_BRIDGE_TOKEN);
1195
+ return api.executeCommand(command, { clientId: options.clientId, input });
790
1196
  }
791
1197
  function resolveTls(options) {
792
1198
  if (!options.tlsKey && !options.tlsCert) {
@@ -809,27 +1215,36 @@ function parsePort(value) {
809
1215
  function isLoopback(host) {
810
1216
  return host === '127.0.0.1' || host === 'localhost' || host === '::1';
811
1217
  }
812
- async function readResponseBody(response) {
813
- const text = await response.text();
814
- if (!text) {
815
- return {};
816
- }
817
- try {
818
- return JSON.parse(text);
819
- }
820
- catch {
821
- return { error: { message: text } };
822
- }
823
- }
824
1218
  async function main() {
825
1219
  try {
826
1220
  cli.parse(process.argv, { run: false });
1221
+ const showHelp = Boolean(cli.options.help);
1222
+ const showVersion = cli.options.version && (showHelp || cli.matchedCommandName === undefined);
1223
+ const isMcpStdio = cli.matchedCommandName === 'mcp' && !showHelp;
1224
+ if ((cli.matchedCommand || showHelp || showVersion) &&
1225
+ !isMcpStdio &&
1226
+ process.env[DAEMON_CHILD_ENV] !== '1' &&
1227
+ process.env.TAPICKER_NO_UPDATE_NOTIFIER !== '1' &&
1228
+ process.stdin.isTTY &&
1229
+ process.stdout.isTTY &&
1230
+ process.stderr.isTTY) {
1231
+ await showCachedUpdate();
1232
+ }
1233
+ if (showHelp)
1234
+ cli.outputHelp();
1235
+ if (showVersion)
1236
+ cli.outputVersion();
1237
+ if (showHelp || showVersion)
1238
+ return;
827
1239
  await cli.runMatchedCommand();
828
1240
  }
829
1241
  catch (error) {
830
- console.error(JSON.stringify({ ok: false, error: serializeBridgeError(error) }, null, 2));
1242
+ const failure = error instanceof Error && error.name === 'CACError'
1243
+ ? new BridgeError('InvalidArguments', error.message, { status: 400, cause: error })
1244
+ : error;
1245
+ console.error(JSON.stringify({ ok: false, error: serializeBridgeError(failure) }, null, 2));
831
1246
  process.exitCode = 1;
832
1247
  }
833
1248
  }
834
- void main();
1249
+ main();
835
1250
  //# sourceMappingURL=cli.js.map