@tapicker/bridge 1.0.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.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/bridge/bridge-service.d.ts +46 -0
- package/dist/bridge/bridge-service.js +245 -0
- package/dist/bridge/control.d.ts +7 -0
- package/dist/bridge/control.js +211 -0
- package/dist/bridge/daemon.d.ts +16 -0
- package/dist/bridge/daemon.js +97 -0
- package/dist/bridge/extension-session.d.ts +43 -0
- package/dist/bridge/extension-session.js +204 -0
- package/dist/bridge/pairing-state.d.ts +22 -0
- package/dist/bridge/pairing-state.js +76 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +835 -0
- package/dist/commands.d.ts +33 -0
- package/dist/commands.js +123 -0
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +10 -0
- package/dist/errors.d.ts +27 -0
- package/dist/errors.js +52 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +8 -0
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +300 -0
- package/dist/protocol/schema.d.ts +149 -0
- package/dist/protocol/schema.js +155 -0
- package/dist/server.d.ts +63 -0
- package/dist/server.js +486 -0
- package/dist/workflow-watch.d.ts +31 -0
- package/dist/workflow-watch.js +304 -0
- package/docs/cli.md +112 -0
- package/docs/http-api.md +149 -0
- package/docs/mcp.md +90 -0
- package/docs/operations.md +85 -0
- package/docs/workflow-watch.md +86 -0
- package/package.json +55 -0
- package/src/bridge/bridge-service.ts +291 -0
- package/src/bridge/control.ts +281 -0
- package/src/bridge/daemon.ts +102 -0
- package/src/bridge/extension-session.ts +286 -0
- package/src/bridge/pairing-state.ts +92 -0
- package/src/cli.ts +1019 -0
- package/src/commands.ts +158 -0
- package/src/constants.ts +10 -0
- package/src/errors.ts +72 -0
- package/src/index.ts +35 -0
- package/src/mcp.ts +386 -0
- package/src/protocol/schema.ts +202 -0
- package/src/server.ts +623 -0
- package/src/workflow-watch.ts +364 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cac } from 'cac';
|
|
3
|
+
import { chmodSync, closeSync, openSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { fork } from 'node:child_process';
|
|
5
|
+
import { mkdir } from 'node:fs/promises';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
8
|
+
import { DEFAULT_HOST, DEFAULT_PORT, VERSION } from './constants.js';
|
|
9
|
+
import { BridgeError, serializeBridgeError } from './errors.js';
|
|
10
|
+
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 { BridgeServer } from './server.js';
|
|
14
|
+
import { getDefaultBridgeDataDir } from './bridge/pairing-state.js';
|
|
15
|
+
import { sendBridgeControlRequest, startBridgeControlServer } from './bridge/control.js';
|
|
16
|
+
import { getBridgeDaemonLogPath, isProcessRunning, readBridgeDaemonState, removeBridgeDaemonState, writeBridgeDaemonState, } from './bridge/daemon.js';
|
|
17
|
+
const DAEMON_CHILD_ENV = 'TAPICKER_BRIDGE_DAEMON_CHILD';
|
|
18
|
+
const cli = cac('tapicker');
|
|
19
|
+
cli
|
|
20
|
+
.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 })
|
|
22
|
+
.option('--port <port>', 'Port to bind; defaults to the first available port from 9520 to 9530')
|
|
23
|
+
.option('--token <token>', 'Bearer token for HTTP command clients')
|
|
24
|
+
.option('--cors-origin <origins>', 'Comma-separated allowed HTTP origins (default: *)', { default: '*' })
|
|
25
|
+
.option('--tls-key <path>', 'TLS private key path')
|
|
26
|
+
.option('--tls-cert <path>', 'TLS certificate path')
|
|
27
|
+
.option('--data-dir <path>', 'Directory for Bridge state and logs')
|
|
28
|
+
.option('--daemon', 'Run Bridge as a daemon')
|
|
29
|
+
.option('--refresh', 'Rotate the pairing code and unlock pairing')
|
|
30
|
+
.action(async (action, options) => {
|
|
31
|
+
if (action === 'start') {
|
|
32
|
+
if (options.daemon && process.env[DAEMON_CHILD_ENV] !== '1') {
|
|
33
|
+
await startBridgeInBackground(options);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
await startBridge(options);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (action === 'stop') {
|
|
41
|
+
await stopBridge(options);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (action === 'code') {
|
|
45
|
+
await showPairingCode(options);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (action === 'extensions') {
|
|
49
|
+
await showExtensions(options);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
throw new BridgeError('CommandNotFound', `Unsupported bridge action: ${action}`, { status: 404 });
|
|
53
|
+
});
|
|
54
|
+
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) })
|
|
60
|
+
.option('--token <token>', 'Bridge Bearer token')
|
|
61
|
+
.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')
|
|
64
|
+
.option('--timeout <milliseconds>', 'Maximum watch duration; does not stop the Workflow')
|
|
65
|
+
.action(async (action, workflowId, options) => {
|
|
66
|
+
if (action === 'start') {
|
|
67
|
+
await startWorkflow(workflowId, options);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (action === 'pause' || action === 'resume' || action === 'stop' || action === 'status') {
|
|
71
|
+
await controlWorkflow(action, workflowId, options);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (action === 'watch') {
|
|
75
|
+
await watchWorkflow(workflowId, options);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw new BridgeError('CommandNotFound', `Unsupported workflow action: ${action}`, { status: 404 });
|
|
79
|
+
});
|
|
80
|
+
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) })
|
|
84
|
+
.option('--token <token>', 'Bridge Bearer token')
|
|
85
|
+
.option('--client-id <clientId>', 'Target Extension clientId when multiple Extensions are connected')
|
|
86
|
+
.option('--https', 'Use HTTPS when calling the Bridge')
|
|
87
|
+
.action(async (action, tableId, options) => {
|
|
88
|
+
if (action !== 'get' && action !== 'delete') {
|
|
89
|
+
throw new BridgeError('CommandNotFound', `Unsupported data action: ${action}`, { status: 404 });
|
|
90
|
+
}
|
|
91
|
+
const parsed = DataInputSchema.safeParse({ tableId });
|
|
92
|
+
if (!parsed.success) {
|
|
93
|
+
throw new BridgeError('InvalidArguments', `Invalid data ${action} arguments`, {
|
|
94
|
+
status: 400,
|
|
95
|
+
details: parsed.error.issues,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
await sendDataCommand(`data.${action}`, parsed.data, options);
|
|
99
|
+
});
|
|
100
|
+
cli
|
|
101
|
+
.command('mcp', 'Expose a running Tapicker Bridge to an MCP client over stdio')
|
|
102
|
+
.option('--url <url>', 'Bridge HTTP(S) URL', {
|
|
103
|
+
default: process.env.TAPICKER_BRIDGE_URL || `http://${DEFAULT_HOST}:${DEFAULT_PORT}`,
|
|
104
|
+
})
|
|
105
|
+
.option('--token <token>', 'Bridge Bearer token; prefer TAPICKER_BRIDGE_TOKEN for MCP host configuration')
|
|
106
|
+
.example('tapicker mcp')
|
|
107
|
+
.example('tapicker mcp --url https://bridge.example.com --token <token>')
|
|
108
|
+
.action((options) => {
|
|
109
|
+
const token = options.token || process.env.TAPICKER_BRIDGE_TOKEN;
|
|
110
|
+
const api = createHttpMcpApi(options.url, token);
|
|
111
|
+
serveStdio(() => createTapickerMcpServer(api), {
|
|
112
|
+
onerror: (error) => console.error('Tapicker MCP stdio error:', error),
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
cli.help((sections) => formatCliHelp(sections));
|
|
116
|
+
cli.version(VERSION);
|
|
117
|
+
const bridgeActions = {
|
|
118
|
+
start: {
|
|
119
|
+
summary: 'Start the Bridge server in the foreground or as a daemon.',
|
|
120
|
+
description: 'Start the Bridge server. It runs in the foreground unless --daemon is set. The startup output includes the address and pairing code.',
|
|
121
|
+
example: 'tapicker bridge start [--daemon] [options]',
|
|
122
|
+
},
|
|
123
|
+
stop: {
|
|
124
|
+
summary: 'Stop a running Bridge.',
|
|
125
|
+
description: 'Stop a running Bridge. Use the same --data-dir used when it was started, if customized.',
|
|
126
|
+
example: 'tapicker bridge stop [--data-dir <path>]',
|
|
127
|
+
},
|
|
128
|
+
code: {
|
|
129
|
+
summary: 'Show or rotate the Extension pairing code.',
|
|
130
|
+
description: 'Show the current Extension pairing code. Add --refresh to rotate the code and unlock pairing after rate limiting.',
|
|
131
|
+
example: 'tapicker bridge code [--refresh] [--data-dir <path>]',
|
|
132
|
+
},
|
|
133
|
+
extensions: {
|
|
134
|
+
summary: 'List connected Extension clients.',
|
|
135
|
+
description: 'List Extensions currently connected to the Bridge, including connections that have not finished pairing.',
|
|
136
|
+
example: 'tapicker bridge extensions [--data-dir <path>]',
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const workflowActions = {
|
|
140
|
+
start: {
|
|
141
|
+
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]`,
|
|
144
|
+
},
|
|
145
|
+
pause: {
|
|
146
|
+
summary: 'Pause the active instance of a Workflow.',
|
|
147
|
+
description: 'Pause the active instance of the specified Workflow.',
|
|
148
|
+
example: 'tapicker workflow pause <workflowId>',
|
|
149
|
+
},
|
|
150
|
+
resume: {
|
|
151
|
+
summary: 'Resume a paused Workflow instance.',
|
|
152
|
+
description: 'Resume a paused instance of the specified Workflow.',
|
|
153
|
+
example: 'tapicker workflow resume <workflowId>',
|
|
154
|
+
},
|
|
155
|
+
stop: {
|
|
156
|
+
summary: 'Stop the active instance of a Workflow.',
|
|
157
|
+
description: 'Stop the active instance of the specified Workflow.',
|
|
158
|
+
example: 'tapicker workflow stop <workflowId>',
|
|
159
|
+
},
|
|
160
|
+
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>',
|
|
164
|
+
},
|
|
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>]',
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const dataActions = {
|
|
172
|
+
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.',
|
|
175
|
+
example: 'tapicker data get <tableId>',
|
|
176
|
+
},
|
|
177
|
+
delete: {
|
|
178
|
+
summary: 'Delete an extracted data table.',
|
|
179
|
+
description: 'Delete the data table identified by tableId and remove it from Tapicker Data.',
|
|
180
|
+
example: 'tapicker data delete <tableId>',
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
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'],
|
|
194
|
+
['--client-id <clientId>', 'Select an Extension; required when multiple are ready'],
|
|
195
|
+
['--https', 'Use HTTPS instead of HTTP'],
|
|
196
|
+
];
|
|
197
|
+
const workflowActionOptions = {
|
|
198
|
+
start: [
|
|
199
|
+
['--args <json>', 'Workflow input object (default: {})'],
|
|
200
|
+
['--tab <target>', 'Target tab: active (default), new, or id:<positive-tabId>'],
|
|
201
|
+
['--watch', 'Stream status updates after starting, until a terminal state'],
|
|
202
|
+
['--timeout <milliseconds>', 'Limit the --watch duration; does not stop the Workflow'],
|
|
203
|
+
],
|
|
204
|
+
pause: [],
|
|
205
|
+
resume: [],
|
|
206
|
+
stop: [],
|
|
207
|
+
status: [],
|
|
208
|
+
watch: [['--timeout <milliseconds>', 'Limit how long to wait; does not stop the Workflow']],
|
|
209
|
+
};
|
|
210
|
+
const workflowGeneralOptions = [
|
|
211
|
+
...workflowCommonOptions,
|
|
212
|
+
['--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'],
|
|
216
|
+
];
|
|
217
|
+
const bridgeCommonOptions = [['--data-dir <path>', 'Bridge state directory (TAPICKER_DATA_DIR or ~/.tapicker)']];
|
|
218
|
+
const bridgeActionOptions = {
|
|
219
|
+
start: [
|
|
220
|
+
['--host <host>', `Bind address (default: ${DEFAULT_HOST}); remote binding requires token and TLS`],
|
|
221
|
+
['--port <port>', 'Listen port (default: first available from 9520 to 9530)'],
|
|
222
|
+
['--token <token>', 'HTTP Bearer token (or TAPICKER_BRIDGE_TOKEN)'],
|
|
223
|
+
['--cors-origin <origins>', 'Comma-separated CORS allowlist (default: *)'],
|
|
224
|
+
['--tls-key <path>', 'TLS private key; required with --tls-cert for remote binding'],
|
|
225
|
+
['--tls-cert <path>', 'TLS certificate; required with --tls-key for remote binding'],
|
|
226
|
+
['--daemon', 'Run in the background'],
|
|
227
|
+
],
|
|
228
|
+
stop: [],
|
|
229
|
+
code: [['--refresh', 'Rotate the pairing code and unlock pairing after rate limiting']],
|
|
230
|
+
extensions: [],
|
|
231
|
+
};
|
|
232
|
+
function formatCliHelp(sections) {
|
|
233
|
+
const command = cli.matchedCommandName;
|
|
234
|
+
if (command === 'mcp') {
|
|
235
|
+
const usage = sections.find((section) => section.title === 'Usage');
|
|
236
|
+
if (usage)
|
|
237
|
+
usage.body = ' $ tapicker mcp [options]';
|
|
238
|
+
const usageIndex = sections.findIndex((section) => section.title === 'Usage');
|
|
239
|
+
sections.splice(usageIndex + 1, 0, {
|
|
240
|
+
title: 'Description',
|
|
241
|
+
body: ' Start an MCP stdio server that connects to an already-running Tapicker Bridge. This does not start the Bridge itself.',
|
|
242
|
+
});
|
|
243
|
+
return sections;
|
|
244
|
+
}
|
|
245
|
+
if (command !== 'workflow' && command !== 'bridge' && command !== 'data')
|
|
246
|
+
return sections;
|
|
247
|
+
const action = cli.args[0];
|
|
248
|
+
const actionMap = command === 'workflow' ? workflowActions : command === 'bridge' ? bridgeActions : dataActions;
|
|
249
|
+
const actionInfo = actionMap[action];
|
|
250
|
+
const actionOptions = command === 'workflow' ? workflowActionOptions : command === 'bridge' ? bridgeActionOptions : {};
|
|
251
|
+
const commonOptions = command === 'workflow' ? workflowCommonOptions : command === 'bridge' ? bridgeCommonOptions : dataCommonOptions;
|
|
252
|
+
const usage = sections.find((section) => section.title === 'Usage');
|
|
253
|
+
if (usage) {
|
|
254
|
+
if (actionInfo && command === 'workflow') {
|
|
255
|
+
usage.body = ` $ tapicker workflow ${action} <workflowId> [options]`;
|
|
256
|
+
}
|
|
257
|
+
else if (actionInfo && command === 'data') {
|
|
258
|
+
usage.body = ` $ tapicker data ${action} <tableId> [options]`;
|
|
259
|
+
}
|
|
260
|
+
else if (actionInfo) {
|
|
261
|
+
usage.body = ` $ tapicker bridge ${action} [options]`;
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const target = command === 'workflow' ? ' <workflowId>' : command === 'data' ? ' <tableId>' : '';
|
|
265
|
+
usage.body = ` $ tapicker ${command} <action>${target} [options]`;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const options = actionInfo
|
|
269
|
+
? [...commonOptions, ...(actionOptions[action] || [])]
|
|
270
|
+
: command === 'workflow'
|
|
271
|
+
? workflowGeneralOptions
|
|
272
|
+
: command === 'bridge'
|
|
273
|
+
? [
|
|
274
|
+
...commonOptions,
|
|
275
|
+
...Object.entries(actionOptions).flatMap(([name, entries]) => entries.map(([option, description]) => [option, `${description} (${name} only)`])),
|
|
276
|
+
]
|
|
277
|
+
: commonOptions;
|
|
278
|
+
const optionsSection = sections.find((section) => section.title === 'Options');
|
|
279
|
+
if (optionsSection)
|
|
280
|
+
optionsSection.body = formatOptionRows(options);
|
|
281
|
+
const detailSections = actionInfo
|
|
282
|
+
? [
|
|
283
|
+
{ title: 'Description', body: ` ${actionInfo.description}` },
|
|
284
|
+
{ title: 'Example', body: ` ${actionInfo.example}` },
|
|
285
|
+
]
|
|
286
|
+
: [
|
|
287
|
+
{
|
|
288
|
+
title: 'Actions',
|
|
289
|
+
body: Object.entries(actionMap)
|
|
290
|
+
.map(([name, info]) => ` ${name.padEnd(12)} ${info.summary}`)
|
|
291
|
+
.join('\n'),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
title: 'Examples',
|
|
295
|
+
body: Object.entries(actionMap)
|
|
296
|
+
.map(([, info]) => ` ${info.example}`)
|
|
297
|
+
.join('\n'),
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
const usageIndex = sections.findIndex((section) => section.title === 'Usage');
|
|
301
|
+
sections.splice(usageIndex + 1, 0, ...detailSections);
|
|
302
|
+
return sections;
|
|
303
|
+
}
|
|
304
|
+
function formatOptionRows(options) {
|
|
305
|
+
const width = Math.max(...options.map(([name]) => name.length));
|
|
306
|
+
return options.map(([name, description]) => ` ${name.padEnd(width)} ${description}`).join('\n');
|
|
307
|
+
}
|
|
308
|
+
async function startBridge(options) {
|
|
309
|
+
const token = options.token || process.env.TAPICKER_BRIDGE_TOKEN;
|
|
310
|
+
const host = options.host || DEFAULT_HOST;
|
|
311
|
+
const port = options.port === undefined ? undefined : parsePort(options.port);
|
|
312
|
+
const tls = resolveTls(options);
|
|
313
|
+
const dataDir = getDataDirectory(options);
|
|
314
|
+
const isDaemonChild = process.env[DAEMON_CHILD_ENV] === '1';
|
|
315
|
+
if (!isLoopback(host) && (!token || !tls)) {
|
|
316
|
+
throw new BridgeError('InsecureRemoteBinding', 'Non-loopback Bridge binding requires --token, --tls-key and --tls-cert', {
|
|
317
|
+
status: 400,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
const server = new BridgeServer({
|
|
321
|
+
host,
|
|
322
|
+
port,
|
|
323
|
+
token,
|
|
324
|
+
corsOrigins: parseCorsOrigins(options.corsOrigin),
|
|
325
|
+
tls,
|
|
326
|
+
dataDir,
|
|
327
|
+
});
|
|
328
|
+
const address = await server.start();
|
|
329
|
+
let controlServer;
|
|
330
|
+
let shuttingDown = false;
|
|
331
|
+
const shutdown = async () => {
|
|
332
|
+
if (shuttingDown)
|
|
333
|
+
return;
|
|
334
|
+
shuttingDown = true;
|
|
335
|
+
await controlServer?.close();
|
|
336
|
+
await server.close();
|
|
337
|
+
if (isDaemonChild) {
|
|
338
|
+
await removeBridgeDaemonState(dataDir, process.pid);
|
|
339
|
+
}
|
|
340
|
+
process.exit(0);
|
|
341
|
+
};
|
|
342
|
+
process.once('SIGINT', () => void shutdown());
|
|
343
|
+
process.once('SIGTERM', () => void shutdown());
|
|
344
|
+
try {
|
|
345
|
+
controlServer = await startBridgeControlServer(dataDir, async (action) => {
|
|
346
|
+
if (action === 'code.get') {
|
|
347
|
+
const code = server.getPairingCode();
|
|
348
|
+
return { code, locked: code === undefined };
|
|
349
|
+
}
|
|
350
|
+
if (action === 'code.refresh') {
|
|
351
|
+
return { code: server.refreshPairingCode(), locked: false };
|
|
352
|
+
}
|
|
353
|
+
if (action === 'extensions.list') {
|
|
354
|
+
return server.bridge.getExtensionStatuses();
|
|
355
|
+
}
|
|
356
|
+
setTimeout(() => void shutdown(), 50);
|
|
357
|
+
return { pid: process.pid, bridgeId: server.bridgeId };
|
|
358
|
+
});
|
|
359
|
+
if (isDaemonChild) {
|
|
360
|
+
await writeBridgeDaemonState(dataDir, {
|
|
361
|
+
pid: process.pid,
|
|
362
|
+
bridgeId: server.bridgeId,
|
|
363
|
+
httpUrl: address.httpUrl,
|
|
364
|
+
startedAt: Date.now(),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
await controlServer?.close();
|
|
370
|
+
await server.close();
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
console.log(`Tapicker Bridge ${VERSION} is running`);
|
|
374
|
+
console.log(`HTTP: ${address.httpUrl}`);
|
|
375
|
+
console.log(`WebSocket: ${address.wsUrl}`);
|
|
376
|
+
console.log(`MCP Server: ${address.mcpUrl}`);
|
|
377
|
+
console.log(`Data Directory: ${dataDir}`);
|
|
378
|
+
const pairingCode = server.getPairingCode();
|
|
379
|
+
if (pairingCode) {
|
|
380
|
+
console.log(`Pairing Code: ${pairingCode}`);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
console.log('Pairing is locked; run `tapicker bridge code --refresh` locally to unlock it');
|
|
384
|
+
}
|
|
385
|
+
const connectedExtensions = server.bridge.getExtensionStatuses().filter((extension) => extension.ready).length;
|
|
386
|
+
console.log(`Extensions: ${connectedExtensions} connected`);
|
|
387
|
+
if (token) {
|
|
388
|
+
console.log('Authentication: enabled');
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
console.warn('Authentication: disabled (loopback mode only)');
|
|
392
|
+
}
|
|
393
|
+
if (isDaemonChild && process.send) {
|
|
394
|
+
process.send({
|
|
395
|
+
type: 'ready',
|
|
396
|
+
pid: process.pid,
|
|
397
|
+
bridgeId: server.bridgeId,
|
|
398
|
+
httpUrl: address.httpUrl,
|
|
399
|
+
wsUrl: address.wsUrl,
|
|
400
|
+
workflowWatchWsUrl: address.workflowWatchWsUrl,
|
|
401
|
+
dataDir,
|
|
402
|
+
logPath: getBridgeDaemonLogPath(dataDir),
|
|
403
|
+
pairingCode,
|
|
404
|
+
authenticationEnabled: Boolean(token),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
await new Promise(() => undefined);
|
|
408
|
+
}
|
|
409
|
+
async function startBridgeInBackground(options) {
|
|
410
|
+
const dataDir = getDataDirectory(options);
|
|
411
|
+
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
412
|
+
const existingState = await readBridgeDaemonState(dataDir);
|
|
413
|
+
if (existingState) {
|
|
414
|
+
if (await isManagedBridgeRunning(existingState)) {
|
|
415
|
+
throw new BridgeError('BridgeAlreadyRunning', `Tapicker Bridge is already running (PID ${existingState.pid})`, {
|
|
416
|
+
status: 409,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
await removeBridgeDaemonState(dataDir, existingState.pid);
|
|
420
|
+
}
|
|
421
|
+
const logPath = getBridgeDaemonLogPath(dataDir);
|
|
422
|
+
const logFile = openSync(logPath, 'a', 0o600);
|
|
423
|
+
let child;
|
|
424
|
+
try {
|
|
425
|
+
chmodSync(logPath, 0o600);
|
|
426
|
+
const entryPoint = process.argv[1];
|
|
427
|
+
if (!entryPoint) {
|
|
428
|
+
throw new BridgeError('InvalidCliEntryPoint', 'Unable to determine the Tapicker Bridge CLI entry point', {
|
|
429
|
+
status: 500,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
const childArgs = process.argv.slice(2).filter((argument) => argument !== '--daemon');
|
|
433
|
+
child = fork(entryPoint, childArgs, {
|
|
434
|
+
detached: true,
|
|
435
|
+
env: { ...process.env, [DAEMON_CHILD_ENV]: '1' },
|
|
436
|
+
stdio: ['ignore', logFile, logFile, 'ipc'],
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
closeSync(logFile);
|
|
441
|
+
}
|
|
442
|
+
let ready;
|
|
443
|
+
try {
|
|
444
|
+
ready = await waitForDaemonReady(child, logPath);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (child.connected)
|
|
448
|
+
child.disconnect();
|
|
449
|
+
child.kill('SIGTERM');
|
|
450
|
+
child.unref();
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
if (child.connected)
|
|
454
|
+
child.disconnect();
|
|
455
|
+
child.unref();
|
|
456
|
+
console.log(`Tapicker Bridge ${VERSION} started as a daemon (PID ${ready.pid})`);
|
|
457
|
+
console.log(`HTTP: ${ready.httpUrl}`);
|
|
458
|
+
console.log(`WebSocket: ${ready.wsUrl}`);
|
|
459
|
+
console.log(`MCP Server: ${new URL(ready.httpUrl).origin}/mcp`);
|
|
460
|
+
console.log(`Data Directory: ${ready.dataDir}`);
|
|
461
|
+
console.log(`Log: ${ready.logPath}`);
|
|
462
|
+
if (ready.pairingCode) {
|
|
463
|
+
console.log(`Pairing Code: ${ready.pairingCode}`);
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
console.log('Pairing is locked; run `tapicker bridge code --refresh` locally to unlock it');
|
|
467
|
+
}
|
|
468
|
+
console.log(`Authentication: ${ready.authenticationEnabled ? 'enabled' : 'disabled (loopback mode only)'}`);
|
|
469
|
+
}
|
|
470
|
+
async function stopBridge(options) {
|
|
471
|
+
const dataDir = getDataDirectory(options);
|
|
472
|
+
const state = await readBridgeDaemonState(dataDir);
|
|
473
|
+
let result;
|
|
474
|
+
try {
|
|
475
|
+
result = await sendBridgeControlRequest(dataDir, 'bridge.stop');
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
if (!(error instanceof BridgeError) || error.code !== 'BridgeControlUnavailable')
|
|
479
|
+
throw error;
|
|
480
|
+
if (!state) {
|
|
481
|
+
console.log('No Tapicker Bridge is running.');
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (!isProcessRunning(state.pid)) {
|
|
485
|
+
await removeBridgeDaemonState(dataDir, state.pid);
|
|
486
|
+
console.log('Tapicker Bridge daemon was not running; removed the stale process file.');
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (!(await isManagedBridgeRunning(state))) {
|
|
490
|
+
await removeBridgeDaemonState(dataDir, state.pid);
|
|
491
|
+
console.log('Tapicker Bridge daemon was not running; removed the stale process file.');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
process.kill(state.pid, 'SIGTERM');
|
|
496
|
+
}
|
|
497
|
+
catch (signalError) {
|
|
498
|
+
if (signalError.code !== 'ESRCH')
|
|
499
|
+
throw signalError;
|
|
500
|
+
}
|
|
501
|
+
const stopped = await waitForProcessExit(state.pid);
|
|
502
|
+
if (!stopped) {
|
|
503
|
+
throw new BridgeError('BridgeStopTimeout', `Tapicker Bridge (PID ${state.pid}) did not stop in time`, {
|
|
504
|
+
status: 500,
|
|
505
|
+
cause: error,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
await removeBridgeDaemonState(dataDir, state.pid);
|
|
509
|
+
console.log(`Tapicker Bridge stopped (PID ${state.pid}; verified PID fallback)`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const pid = result.pid ?? state?.pid;
|
|
513
|
+
if (pid === undefined) {
|
|
514
|
+
console.log('Tapicker Bridge shutdown requested.');
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const stopped = await waitForProcessExit(pid);
|
|
518
|
+
if (!stopped) {
|
|
519
|
+
throw new BridgeError('BridgeStopTimeout', `Tapicker Bridge (PID ${pid}) did not stop in time`, {
|
|
520
|
+
status: 500,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
await removeBridgeDaemonState(dataDir, pid);
|
|
524
|
+
console.log(`Tapicker Bridge stopped (PID ${pid})`);
|
|
525
|
+
}
|
|
526
|
+
async function showPairingCode(options) {
|
|
527
|
+
const dataDir = getDataDirectory(options);
|
|
528
|
+
const result = await sendBridgeControlRequest(dataDir, options.refresh ? 'code.refresh' : 'code.get');
|
|
529
|
+
if (!result.code || result.locked) {
|
|
530
|
+
throw new BridgeError('PairingRateLimited', 'Pairing is locked; run `tapicker bridge code --refresh` to rotate the code and unlock pairing', { status: 429 });
|
|
531
|
+
}
|
|
532
|
+
console.log(`Pairing Code: ${result.code}`);
|
|
533
|
+
}
|
|
534
|
+
async function showExtensions(options) {
|
|
535
|
+
const dataDir = getDataDirectory(options);
|
|
536
|
+
const extensions = await sendBridgeControlRequest(dataDir, 'extensions.list');
|
|
537
|
+
if (extensions.length === 0) {
|
|
538
|
+
console.log('No Extensions are currently connected.');
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
console.log(JSON.stringify(extensions, null, 2));
|
|
542
|
+
}
|
|
543
|
+
function getDataDirectory(options) {
|
|
544
|
+
return resolve(options.dataDir || process.env.TAPICKER_DATA_DIR || getDefaultBridgeDataDir());
|
|
545
|
+
}
|
|
546
|
+
function parseCorsOrigins(value = '*') {
|
|
547
|
+
if (typeof value !== 'string') {
|
|
548
|
+
throw new BridgeError('InvalidCorsOrigins', 'Pass multiple CORS origins as one comma-separated --cors-origin value', {
|
|
549
|
+
status: 400,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const origins = value.split(',').map((origin) => origin.trim());
|
|
553
|
+
if (origins.some((origin) => !origin)) {
|
|
554
|
+
throw new BridgeError('InvalidCorsOrigins', '--cors-origin must contain one or more non-empty origins', {
|
|
555
|
+
status: 400,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return origins;
|
|
559
|
+
}
|
|
560
|
+
function waitForDaemonReady(child, logPath) {
|
|
561
|
+
return new Promise((resolveReady, rejectReady) => {
|
|
562
|
+
let settled = false;
|
|
563
|
+
const timeout = setTimeout(() => {
|
|
564
|
+
fail(new BridgeError('BridgeStartTimeout', `Timed out waiting for Bridge startup; see ${logPath}`, { status: 504 }));
|
|
565
|
+
}, 15_000);
|
|
566
|
+
const finish = (callback) => {
|
|
567
|
+
if (settled)
|
|
568
|
+
return;
|
|
569
|
+
settled = true;
|
|
570
|
+
clearTimeout(timeout);
|
|
571
|
+
callback();
|
|
572
|
+
};
|
|
573
|
+
const fail = (error) => finish(() => rejectReady(error));
|
|
574
|
+
child.on('message', (message) => {
|
|
575
|
+
if (!isDaemonReadyMessage(message))
|
|
576
|
+
return;
|
|
577
|
+
finish(() => resolveReady(message));
|
|
578
|
+
});
|
|
579
|
+
child.once('error', (error) => fail(error));
|
|
580
|
+
child.once('exit', (code, signal) => {
|
|
581
|
+
fail(new BridgeError('BridgeStartFailed', `Tapicker Bridge exited before startup completed (${signal || `code ${code}`}); see ${logPath}`, { status: 500 }));
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
function isDaemonReadyMessage(value) {
|
|
586
|
+
if (!value || typeof value !== 'object')
|
|
587
|
+
return false;
|
|
588
|
+
const message = value;
|
|
589
|
+
return (message.type === 'ready' &&
|
|
590
|
+
typeof message.pid === 'number' &&
|
|
591
|
+
typeof message.bridgeId === 'string' &&
|
|
592
|
+
typeof message.httpUrl === 'string' &&
|
|
593
|
+
typeof message.wsUrl === 'string' &&
|
|
594
|
+
typeof message.workflowWatchWsUrl === 'string' &&
|
|
595
|
+
typeof message.dataDir === 'string' &&
|
|
596
|
+
typeof message.logPath === 'string' &&
|
|
597
|
+
typeof message.authenticationEnabled === 'boolean');
|
|
598
|
+
}
|
|
599
|
+
async function waitForProcessExit(pid, timeoutMs = 10_000) {
|
|
600
|
+
const deadline = Date.now() + timeoutMs;
|
|
601
|
+
while (isProcessRunning(pid) && Date.now() < deadline) {
|
|
602
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
|
603
|
+
}
|
|
604
|
+
return !isProcessRunning(pid);
|
|
605
|
+
}
|
|
606
|
+
async function isManagedBridgeRunning(state) {
|
|
607
|
+
if (!isProcessRunning(state.pid))
|
|
608
|
+
return false;
|
|
609
|
+
let response;
|
|
610
|
+
try {
|
|
611
|
+
response = await fetch(`${state.httpUrl}/v1/health`, { signal: AbortSignal.timeout(2_000) });
|
|
612
|
+
}
|
|
613
|
+
catch (cause) {
|
|
614
|
+
throw new BridgeError('BridgeProcessUnverified', 'Could not verify the saved Bridge process; refusing to signal it', {
|
|
615
|
+
status: 503,
|
|
616
|
+
cause,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
if (!response.ok)
|
|
620
|
+
return false;
|
|
621
|
+
let health;
|
|
622
|
+
try {
|
|
623
|
+
health = await response.json();
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
if (!health || typeof health !== 'object')
|
|
629
|
+
return false;
|
|
630
|
+
return health.bridgeId === state.bridgeId;
|
|
631
|
+
}
|
|
632
|
+
async function startWorkflow(workflowId, options) {
|
|
633
|
+
let args;
|
|
634
|
+
try {
|
|
635
|
+
args = JSON.parse(options.args);
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
throw new BridgeError('InvalidArguments', '--args must be valid JSON', { status: 400, cause: error });
|
|
639
|
+
}
|
|
640
|
+
const parsed = WorkflowStartInputSchema.safeParse({ workflowId, args, tab: parseTabTarget(options.tab) });
|
|
641
|
+
if (!parsed.success) {
|
|
642
|
+
throw new BridgeError('InvalidArguments', 'Invalid Workflow start arguments', {
|
|
643
|
+
status: 400,
|
|
644
|
+
details: parsed.error.issues,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
if (!options.watch) {
|
|
648
|
+
await sendWorkflowCommand('workflow.start', parsed.data, options);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const timeoutMs = parseWatchTimeout(options.timeout);
|
|
652
|
+
const connection = await openWorkflowWatch(workflowId, options);
|
|
653
|
+
try {
|
|
654
|
+
const startResult = await requestBridgeCommand('workflow.start', parsed.data, {
|
|
655
|
+
...options,
|
|
656
|
+
clientId: connection.clientId,
|
|
657
|
+
});
|
|
658
|
+
console.log(JSON.stringify({ type: 'workflow.start', data: startResult }));
|
|
659
|
+
const result = await waitForWorkflowStatus({
|
|
660
|
+
clientId: connection.clientId,
|
|
661
|
+
workflowId,
|
|
662
|
+
subscribe: (onStatus, onClose) => subscribeToWorkflowWatch(connection, onStatus, onClose),
|
|
663
|
+
readStatus: () => readWorkflowStatus(workflowId, connection.clientId, options),
|
|
664
|
+
onStatus: printWorkflowStatus,
|
|
665
|
+
timeoutMs,
|
|
666
|
+
});
|
|
667
|
+
printWatchTimeout(result);
|
|
668
|
+
}
|
|
669
|
+
finally {
|
|
670
|
+
connection.close();
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
async function watchWorkflow(workflowId, options) {
|
|
674
|
+
const timeoutMs = parseWatchTimeout(options.timeout);
|
|
675
|
+
const connection = await openWorkflowWatch(workflowId, options);
|
|
676
|
+
try {
|
|
677
|
+
const result = await waitForWorkflowStatus({
|
|
678
|
+
clientId: connection.clientId,
|
|
679
|
+
workflowId,
|
|
680
|
+
subscribe: (onStatus, onClose) => subscribeToWorkflowWatch(connection, onStatus, onClose),
|
|
681
|
+
readStatus: () => readWorkflowStatus(workflowId, connection.clientId, options),
|
|
682
|
+
onStatus: printWorkflowStatus,
|
|
683
|
+
timeoutMs,
|
|
684
|
+
});
|
|
685
|
+
printWatchTimeout(result);
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
connection.close();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
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);
|
|
695
|
+
}
|
|
696
|
+
function subscribeToWorkflowWatch(connection, onStatus, onClose) {
|
|
697
|
+
const removeStatusListener = connection.onStatus(onStatus);
|
|
698
|
+
const removeCloseListener = connection.onClose(onClose);
|
|
699
|
+
return () => {
|
|
700
|
+
removeStatusListener();
|
|
701
|
+
removeCloseListener();
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function printWatchTimeout(result) {
|
|
705
|
+
if (result.timedOut) {
|
|
706
|
+
console.log(JSON.stringify({ type: 'workflow.watch:res', data: { ok: true, data: result } }));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
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) {
|
|
713
|
+
throw new BridgeError('InvalidBridgeResponse', 'Bridge returned an invalid Workflow status response', {
|
|
714
|
+
status: 502,
|
|
715
|
+
details: parsed.success ? undefined : parsed.error.issues,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return parsed.data;
|
|
719
|
+
}
|
|
720
|
+
function printWorkflowStatus(status) {
|
|
721
|
+
console.log(JSON.stringify({ type: 'workflow.status', data: status }));
|
|
722
|
+
}
|
|
723
|
+
function parseWatchTimeout(value) {
|
|
724
|
+
if (value === undefined)
|
|
725
|
+
return undefined;
|
|
726
|
+
const timeoutMs = Number(value);
|
|
727
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
|
|
728
|
+
throw new BridgeError('InvalidArguments', '--timeout must be a positive integer no greater than 2147483647', {
|
|
729
|
+
status: 400,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return timeoutMs;
|
|
733
|
+
}
|
|
734
|
+
function parseTabTarget(value) {
|
|
735
|
+
if (value === undefined || value === 'active') {
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
if (value === 'new') {
|
|
739
|
+
return { mode: 'new' };
|
|
740
|
+
}
|
|
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>"', {
|
|
745
|
+
status: 400,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
return { mode: 'id', tabId };
|
|
749
|
+
}
|
|
750
|
+
async function controlWorkflow(action, workflowId, options) {
|
|
751
|
+
const parsed = WorkflowControlInputSchema.safeParse({ workflowId });
|
|
752
|
+
if (!parsed.success) {
|
|
753
|
+
throw new BridgeError('InvalidArguments', `Invalid Workflow ${action} arguments`, {
|
|
754
|
+
status: 400,
|
|
755
|
+
details: parsed.error.issues,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
await sendWorkflowCommand(`workflow.${action}`, parsed.data, options);
|
|
759
|
+
}
|
|
760
|
+
async function sendWorkflowCommand(command, input, options) {
|
|
761
|
+
const result = await requestBridgeCommand(command, input, options);
|
|
762
|
+
console.log(JSON.stringify(result, null, 2));
|
|
763
|
+
return result;
|
|
764
|
+
}
|
|
765
|
+
async function sendDataCommand(command, input, options) {
|
|
766
|
+
const result = await requestBridgeCommand(command, input, options);
|
|
767
|
+
console.log(JSON.stringify(result, null, 2));
|
|
768
|
+
return result;
|
|
769
|
+
}
|
|
770
|
+
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;
|
|
790
|
+
}
|
|
791
|
+
function resolveTls(options) {
|
|
792
|
+
if (!options.tlsKey && !options.tlsCert) {
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
795
|
+
if (!options.tlsKey || !options.tlsCert) {
|
|
796
|
+
throw new BridgeError('InvalidTlsOptions', '--tls-key and --tls-cert must be provided together', { status: 400 });
|
|
797
|
+
}
|
|
798
|
+
readFileSync(options.tlsKey);
|
|
799
|
+
readFileSync(options.tlsCert);
|
|
800
|
+
return { key: options.tlsKey, cert: options.tlsCert };
|
|
801
|
+
}
|
|
802
|
+
function parsePort(value) {
|
|
803
|
+
const port = Number(value);
|
|
804
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
805
|
+
throw new BridgeError('InvalidPort', `Invalid port: ${value}`, { status: 400 });
|
|
806
|
+
}
|
|
807
|
+
return port;
|
|
808
|
+
}
|
|
809
|
+
function isLoopback(host) {
|
|
810
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
811
|
+
}
|
|
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
|
+
async function main() {
|
|
825
|
+
try {
|
|
826
|
+
cli.parse(process.argv, { run: false });
|
|
827
|
+
await cli.runMatchedCommand();
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
console.error(JSON.stringify({ ok: false, error: serializeBridgeError(error) }, null, 2));
|
|
831
|
+
process.exitCode = 1;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
void main();
|
|
835
|
+
//# sourceMappingURL=cli.js.map
|