@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/src/cli.ts DELETED
@@ -1,1019 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { cac } from 'cac'
4
- import { chmodSync, closeSync, openSync, readFileSync } from 'node:fs'
5
- import { fork } from 'node:child_process'
6
- import { mkdir } from 'node:fs/promises'
7
- import { resolve } from 'node:path'
8
- import { serveStdio } from '@modelcontextprotocol/server/stdio'
9
- import { DEFAULT_HOST, DEFAULT_PORT, VERSION } from './constants.js'
10
- import { BridgeError, serializeBridgeError } from './errors.js'
11
- import { createHttpMcpApi, createTapickerMcpServer } from './mcp.js'
12
- import {
13
- DataInputSchema,
14
- WorkflowControlInputSchema,
15
- WorkflowStartInputSchema,
16
- WorkflowStatusEventSchema,
17
- type WorkflowStartInput,
18
- } from './protocol/schema.js'
19
- import { connectWorkflowWatch, waitForWorkflowStatus, workflowWatchWebSocketUrl } from './workflow-watch.js'
20
- import type { WorkflowStatusEvent } from './protocol/schema.js'
21
- import type { WorkflowWatchConnection } from './workflow-watch.js'
22
- import { BridgeServer } from './server.js'
23
- import { getDefaultBridgeDataDir } from './bridge/pairing-state.js'
24
- import { sendBridgeControlRequest, startBridgeControlServer } from './bridge/control.js'
25
- import {
26
- getBridgeDaemonLogPath,
27
- isProcessRunning,
28
- readBridgeDaemonState,
29
- removeBridgeDaemonState,
30
- writeBridgeDaemonState,
31
- } from './bridge/daemon.js'
32
-
33
- const DAEMON_CHILD_ENV = 'TAPICKER_BRIDGE_DAEMON_CHILD'
34
-
35
- const cli = cac('tapicker')
36
-
37
- cli
38
- .command('bridge <action>', 'Manage Bridge: start/stop it, view the pairing code, or list connected Extensions')
39
- .option('--host <host>', 'Host to bind', { default: DEFAULT_HOST })
40
- .option('--port <port>', 'Port to bind; defaults to the first available port from 9520 to 9530')
41
- .option('--token <token>', 'Bearer token for HTTP command clients')
42
- .option('--cors-origin <origins>', 'Comma-separated allowed HTTP origins (default: *)', { default: '*' })
43
- .option('--tls-key <path>', 'TLS private key path')
44
- .option('--tls-cert <path>', 'TLS certificate path')
45
- .option('--data-dir <path>', 'Directory for Bridge state and logs')
46
- .option('--daemon', 'Run Bridge as a daemon')
47
- .option('--refresh', 'Rotate the pairing code and unlock pairing')
48
- .action(async (action: string, options: BridgeStartOptions) => {
49
- if (action === 'start') {
50
- if (options.daemon && process.env[DAEMON_CHILD_ENV] !== '1') {
51
- await startBridgeInBackground(options)
52
- } else {
53
- await startBridge(options)
54
- }
55
- return
56
- }
57
- if (action === 'stop') {
58
- await stopBridge(options)
59
- return
60
- }
61
- if (action === 'code') {
62
- await showPairingCode(options)
63
- return
64
- }
65
- if (action === 'extensions') {
66
- await showExtensions(options)
67
- return
68
- }
69
- throw new BridgeError('CommandNotFound', `Unsupported bridge action: ${action}`, { status: 404 })
70
- })
71
-
72
- cli
73
- .command('workflow <action> <workflowId>', 'Start, pause, resume, stop, query, or watch a Workflow')
74
- .option('--args <json>', 'Workflow input as a JSON object (start only)', { default: '{}' })
75
- .option('--tab <target>', 'Target tab: active, new, or id:<tabId> (start only)')
76
- .option('--host <host>', 'Bridge host', { default: DEFAULT_HOST })
77
- .option('--port <port>', 'Bridge port', { default: String(DEFAULT_PORT) })
78
- .option('--token <token>', 'Bridge Bearer token')
79
- .option('--client-id <clientId>', 'Target Extension clientId when multiple Extensions are connected')
80
- .option('--https', 'Use HTTPS when calling the Bridge')
81
- .option('--watch', 'After start, stream status updates until the Workflow reaches a terminal state')
82
- .option('--timeout <milliseconds>', 'Maximum watch duration; does not stop the Workflow')
83
- .action(async (action: string, workflowId: string, options: WorkflowStartOptions) => {
84
- if (action === 'start') {
85
- await startWorkflow(workflowId, options)
86
- return
87
- }
88
- if (action === 'pause' || action === 'resume' || action === 'stop' || action === 'status') {
89
- await controlWorkflow(action, workflowId, options)
90
- return
91
- }
92
- if (action === 'watch') {
93
- await watchWorkflow(workflowId, options)
94
- return
95
- }
96
- throw new BridgeError('CommandNotFound', `Unsupported workflow action: ${action}`, { status: 404 })
97
- })
98
-
99
- cli
100
- .command('data <action> <tableId>', 'Get or delete an extracted data table by its ID')
101
- .option('--host <host>', 'Bridge host', { default: DEFAULT_HOST })
102
- .option('--port <port>', 'Bridge port', { default: String(DEFAULT_PORT) })
103
- .option('--token <token>', 'Bridge Bearer token')
104
- .option('--client-id <clientId>', 'Target Extension clientId when multiple Extensions are connected')
105
- .option('--https', 'Use HTTPS when calling the Bridge')
106
- .action(async (action: string, tableId: string, options: DataCommandOptions) => {
107
- if (action !== 'get' && action !== 'delete') {
108
- throw new BridgeError('CommandNotFound', `Unsupported data action: ${action}`, { status: 404 })
109
- }
110
-
111
- const parsed = DataInputSchema.safeParse({ tableId })
112
- if (!parsed.success) {
113
- throw new BridgeError('InvalidArguments', `Invalid data ${action} arguments`, {
114
- status: 400,
115
- details: parsed.error.issues,
116
- })
117
- }
118
-
119
- await sendDataCommand(`data.${action}`, parsed.data, options)
120
- })
121
-
122
- cli
123
- .command('mcp', 'Expose a running Tapicker Bridge to an MCP client over stdio')
124
- .option('--url <url>', 'Bridge HTTP(S) URL', {
125
- default: process.env.TAPICKER_BRIDGE_URL || `http://${DEFAULT_HOST}:${DEFAULT_PORT}`,
126
- })
127
- .option('--token <token>', 'Bridge Bearer token; prefer TAPICKER_BRIDGE_TOKEN for MCP host configuration')
128
- .example('tapicker mcp')
129
- .example('tapicker mcp --url https://bridge.example.com --token <token>')
130
- .action((options: McpOptions) => {
131
- const token = options.token || process.env.TAPICKER_BRIDGE_TOKEN
132
- const api = createHttpMcpApi(options.url, token)
133
- serveStdio(() => createTapickerMcpServer(api), {
134
- onerror: (error) => console.error('Tapicker MCP stdio error:', error),
135
- })
136
- })
137
-
138
- cli.help((sections) => formatCliHelp(sections))
139
- cli.version(VERSION)
140
-
141
- type CliHelpSection = { title?: string; body: string }
142
-
143
- const bridgeActions: Record<string, { summary: string; description: string; example: string }> = {
144
- start: {
145
- summary: 'Start the Bridge server in the foreground or as a daemon.',
146
- description:
147
- 'Start the Bridge server. It runs in the foreground unless --daemon is set. The startup output includes the address and pairing code.',
148
- example: 'tapicker bridge start [--daemon] [options]',
149
- },
150
- stop: {
151
- summary: 'Stop a running Bridge.',
152
- description: 'Stop a running Bridge. Use the same --data-dir used when it was started, if customized.',
153
- example: 'tapicker bridge stop [--data-dir <path>]',
154
- },
155
- code: {
156
- summary: 'Show or rotate the Extension pairing code.',
157
- description:
158
- 'Show the current Extension pairing code. Add --refresh to rotate the code and unlock pairing after rate limiting.',
159
- example: 'tapicker bridge code [--refresh] [--data-dir <path>]',
160
- },
161
- extensions: {
162
- summary: 'List connected Extension clients.',
163
- description:
164
- 'List Extensions currently connected to the Bridge, including connections that have not finished pairing.',
165
- example: 'tapicker bridge extensions [--data-dir <path>]',
166
- },
167
- }
168
-
169
- const workflowActions: Record<string, { summary: string; description: string; example: string }> = {
170
- start: {
171
- summary: 'Start one Workflow instance; optionally stream its status with --watch.',
172
- description:
173
- '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.',
174
- example: `tapicker workflow start <workflowId> [--args '{"name":"Tom"}'] [--tab active|new|id:<tabId>] [--watch]`,
175
- },
176
- pause: {
177
- summary: 'Pause the active instance of a Workflow.',
178
- description: 'Pause the active instance of the specified Workflow.',
179
- example: 'tapicker workflow pause <workflowId>',
180
- },
181
- resume: {
182
- summary: 'Resume a paused Workflow instance.',
183
- description: 'Resume a paused instance of the specified Workflow.',
184
- example: 'tapicker workflow resume <workflowId>',
185
- },
186
- stop: {
187
- summary: 'Stop the active instance of a Workflow.',
188
- description: 'Stop the active instance of the specified Workflow.',
189
- example: 'tapicker workflow stop <workflowId>',
190
- },
191
- status: {
192
- summary: 'Fetch one current Workflow status snapshot.',
193
- description: 'Fetch a single current-state snapshot. Use watch to receive ongoing updates without polling.',
194
- example: 'tapicker workflow status <workflowId>',
195
- },
196
- watch: {
197
- summary: 'Stream Workflow status until it completes, stops, or fails.',
198
- description:
199
- '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.',
200
- example: 'tapicker workflow watch <workflowId> [--timeout <milliseconds>]',
201
- },
202
- }
203
-
204
- const dataActions: Record<string, { summary: string; description: string; example: string }> = {
205
- get: {
206
- summary: 'Get all rows from an extracted data table; consumes export quota.',
207
- description:
208
- 'Retrieve the complete data table identified by tableId. The row count is charged against the connected Extension account export quota.',
209
- example: 'tapicker data get <tableId>',
210
- },
211
- delete: {
212
- summary: 'Delete an extracted data table.',
213
- description: 'Delete the data table identified by tableId and remove it from Tapicker Data.',
214
- example: 'tapicker data delete <tableId>',
215
- },
216
- }
217
-
218
- const workflowCommonOptions = [
219
- ['--host <host>', `Bridge host (default: ${DEFAULT_HOST})`],
220
- ['--port <port>', `Bridge port (default: ${DEFAULT_PORT})`],
221
- ['--token <token>', 'Bridge Bearer token'],
222
- ['--client-id <clientId>', 'Select an Extension; required when multiple are ready'],
223
- ['--https', 'Use HTTPS/WSS instead of HTTP/WS'],
224
- ]
225
-
226
- const dataCommonOptions = [
227
- ['--host <host>', `Bridge host (default: ${DEFAULT_HOST})`],
228
- ['--port <port>', `Bridge port (default: ${DEFAULT_PORT})`],
229
- ['--token <token>', 'Bridge Bearer token'],
230
- ['--client-id <clientId>', 'Select an Extension; required when multiple are ready'],
231
- ['--https', 'Use HTTPS instead of HTTP'],
232
- ]
233
-
234
- const workflowActionOptions: Record<string, string[][]> = {
235
- start: [
236
- ['--args <json>', 'Workflow input object (default: {})'],
237
- ['--tab <target>', 'Target tab: active (default), new, or id:<positive-tabId>'],
238
- ['--watch', 'Stream status updates after starting, until a terminal state'],
239
- ['--timeout <milliseconds>', 'Limit the --watch duration; does not stop the Workflow'],
240
- ],
241
- pause: [],
242
- resume: [],
243
- stop: [],
244
- status: [],
245
- watch: [['--timeout <milliseconds>', 'Limit how long to wait; does not stop the Workflow']],
246
- }
247
-
248
- const workflowGeneralOptions = [
249
- ...workflowCommonOptions,
250
- ['--args <json>', 'Workflow input object (default: {}; start only)'],
251
- ['--tab <target>', 'Target tab: active (default), new, or id:<positive-tabId> (start only)'],
252
- ['--watch', 'Stream status after start until a terminal state (start only)'],
253
- ['--timeout <milliseconds>', 'Limit watch duration (watch or start --watch); does not stop the Workflow'],
254
- ]
255
-
256
- const bridgeCommonOptions = [['--data-dir <path>', 'Bridge state directory (TAPICKER_DATA_DIR or ~/.tapicker)']]
257
-
258
- const bridgeActionOptions: Record<string, string[][]> = {
259
- start: [
260
- ['--host <host>', `Bind address (default: ${DEFAULT_HOST}); remote binding requires token and TLS`],
261
- ['--port <port>', 'Listen port (default: first available from 9520 to 9530)'],
262
- ['--token <token>', 'HTTP Bearer token (or TAPICKER_BRIDGE_TOKEN)'],
263
- ['--cors-origin <origins>', 'Comma-separated CORS allowlist (default: *)'],
264
- ['--tls-key <path>', 'TLS private key; required with --tls-cert for remote binding'],
265
- ['--tls-cert <path>', 'TLS certificate; required with --tls-key for remote binding'],
266
- ['--daemon', 'Run in the background'],
267
- ],
268
- stop: [],
269
- code: [['--refresh', 'Rotate the pairing code and unlock pairing after rate limiting']],
270
- extensions: [],
271
- }
272
-
273
- function formatCliHelp(sections: CliHelpSection[]): CliHelpSection[] {
274
- const command = cli.matchedCommandName
275
- if (command === 'mcp') {
276
- const usage = sections.find((section) => section.title === 'Usage')
277
- if (usage) usage.body = ' $ tapicker mcp [options]'
278
- const usageIndex = sections.findIndex((section) => section.title === 'Usage')
279
- sections.splice(usageIndex + 1, 0, {
280
- title: 'Description',
281
- body: ' Start an MCP stdio server that connects to an already-running Tapicker Bridge. This does not start the Bridge itself.',
282
- })
283
- return sections
284
- }
285
- if (command !== 'workflow' && command !== 'bridge' && command !== 'data') return sections
286
-
287
- const action = cli.args[0]
288
- const actionMap = command === 'workflow' ? workflowActions : command === 'bridge' ? bridgeActions : dataActions
289
- const actionInfo = actionMap[action]
290
- const actionOptions = command === 'workflow' ? workflowActionOptions : command === 'bridge' ? bridgeActionOptions : {}
291
- const commonOptions =
292
- command === 'workflow' ? workflowCommonOptions : command === 'bridge' ? bridgeCommonOptions : dataCommonOptions
293
- const usage = sections.find((section) => section.title === 'Usage')
294
-
295
- if (usage) {
296
- if (actionInfo && command === 'workflow') {
297
- usage.body = ` $ tapicker workflow ${action} <workflowId> [options]`
298
- } else if (actionInfo && command === 'data') {
299
- usage.body = ` $ tapicker data ${action} <tableId> [options]`
300
- } else if (actionInfo) {
301
- usage.body = ` $ tapicker bridge ${action} [options]`
302
- } else {
303
- const target = command === 'workflow' ? ' <workflowId>' : command === 'data' ? ' <tableId>' : ''
304
- usage.body = ` $ tapicker ${command} <action>${target} [options]`
305
- }
306
- }
307
-
308
- const options = actionInfo
309
- ? [...commonOptions, ...(actionOptions[action] || [])]
310
- : command === 'workflow'
311
- ? workflowGeneralOptions
312
- : command === 'bridge'
313
- ? [
314
- ...commonOptions,
315
- ...Object.entries(actionOptions).flatMap(([name, entries]) =>
316
- entries.map(([option, description]) => [option, `${description} (${name} only)`]),
317
- ),
318
- ]
319
- : commonOptions
320
- const optionsSection = sections.find((section) => section.title === 'Options')
321
- if (optionsSection) optionsSection.body = formatOptionRows(options)
322
-
323
- const detailSections: CliHelpSection[] = actionInfo
324
- ? [
325
- { title: 'Description', body: ` ${actionInfo.description}` },
326
- { title: 'Example', body: ` ${actionInfo.example}` },
327
- ]
328
- : [
329
- {
330
- title: 'Actions',
331
- body: Object.entries(actionMap)
332
- .map(([name, info]) => ` ${name.padEnd(12)} ${info.summary}`)
333
- .join('\n'),
334
- },
335
- {
336
- title: 'Examples',
337
- body: Object.entries(actionMap)
338
- .map(([, info]) => ` ${info.example}`)
339
- .join('\n'),
340
- },
341
- ]
342
-
343
- const usageIndex = sections.findIndex((section) => section.title === 'Usage')
344
- sections.splice(usageIndex + 1, 0, ...detailSections)
345
- return sections
346
- }
347
-
348
- function formatOptionRows(options: string[][]) {
349
- const width = Math.max(...options.map(([name]) => name.length))
350
- return options.map(([name, description]) => ` ${name.padEnd(width)} ${description}`).join('\n')
351
- }
352
-
353
- type BridgeStartOptions = {
354
- host: string
355
- port?: string
356
- token?: string
357
- corsOrigin?: string
358
- tlsKey?: string
359
- tlsCert?: string
360
- dataDir?: string
361
- daemon?: boolean
362
- refresh?: boolean
363
- }
364
-
365
- type WorkflowStartOptions = {
366
- args: string
367
- tab?: string
368
- host: string
369
- port: string
370
- token?: string
371
- clientId?: string
372
- https?: boolean
373
- watch?: boolean
374
- timeout?: string
375
- }
376
-
377
- type DataCommandOptions = {
378
- host: string
379
- port: string
380
- token?: string
381
- clientId?: string
382
- https?: boolean
383
- }
384
-
385
- type BridgeCommandOptions = Pick<DataCommandOptions, 'host' | 'port' | 'token' | 'clientId' | 'https'>
386
-
387
- type McpOptions = {
388
- url: string
389
- token?: string
390
- }
391
-
392
- type DaemonReadyMessage = {
393
- type: 'ready'
394
- pid: number
395
- bridgeId: string
396
- httpUrl: string
397
- wsUrl: string
398
- workflowWatchWsUrl: string
399
- dataDir: string
400
- logPath: string
401
- pairingCode?: string
402
- authenticationEnabled: boolean
403
- }
404
-
405
- async function startBridge(options: BridgeStartOptions) {
406
- const token = options.token || process.env.TAPICKER_BRIDGE_TOKEN
407
- const host = options.host || DEFAULT_HOST
408
- const port = options.port === undefined ? undefined : parsePort(options.port)
409
- const tls = resolveTls(options)
410
- const dataDir = getDataDirectory(options)
411
- const isDaemonChild = process.env[DAEMON_CHILD_ENV] === '1'
412
-
413
- if (!isLoopback(host) && (!token || !tls)) {
414
- throw new BridgeError(
415
- 'InsecureRemoteBinding',
416
- 'Non-loopback Bridge binding requires --token, --tls-key and --tls-cert',
417
- {
418
- status: 400,
419
- },
420
- )
421
- }
422
-
423
- const server = new BridgeServer({
424
- host,
425
- port,
426
- token,
427
- corsOrigins: parseCorsOrigins(options.corsOrigin),
428
- tls,
429
- dataDir,
430
- })
431
- const address = await server.start()
432
- let controlServer: Awaited<ReturnType<typeof startBridgeControlServer>> | undefined
433
- let shuttingDown = false
434
- const shutdown = async () => {
435
- if (shuttingDown) return
436
- shuttingDown = true
437
- await controlServer?.close()
438
- await server.close()
439
- if (isDaemonChild) {
440
- await removeBridgeDaemonState(dataDir, process.pid)
441
- }
442
- process.exit(0)
443
- }
444
-
445
- process.once('SIGINT', () => void shutdown())
446
- process.once('SIGTERM', () => void shutdown())
447
-
448
- try {
449
- controlServer = await startBridgeControlServer(dataDir, async (action) => {
450
- if (action === 'code.get') {
451
- const code = server.getPairingCode()
452
- return { code, locked: code === undefined }
453
- }
454
- if (action === 'code.refresh') {
455
- return { code: server.refreshPairingCode(), locked: false }
456
- }
457
- if (action === 'extensions.list') {
458
- return server.bridge.getExtensionStatuses()
459
- }
460
- setTimeout(() => void shutdown(), 50)
461
- return { pid: process.pid, bridgeId: server.bridgeId }
462
- })
463
-
464
- if (isDaemonChild) {
465
- await writeBridgeDaemonState(dataDir, {
466
- pid: process.pid,
467
- bridgeId: server.bridgeId,
468
- httpUrl: address.httpUrl,
469
- startedAt: Date.now(),
470
- })
471
- }
472
- } catch (error) {
473
- await controlServer?.close()
474
- await server.close()
475
- throw error
476
- }
477
-
478
- console.log(`Tapicker Bridge ${VERSION} is running`)
479
- console.log(`HTTP: ${address.httpUrl}`)
480
- console.log(`WebSocket: ${address.wsUrl}`)
481
- console.log(`MCP Server: ${address.mcpUrl}`)
482
- console.log(`Data Directory: ${dataDir}`)
483
- const pairingCode = server.getPairingCode()
484
- if (pairingCode) {
485
- console.log(`Pairing Code: ${pairingCode}`)
486
- } else {
487
- console.log('Pairing is locked; run `tapicker bridge code --refresh` locally to unlock it')
488
- }
489
- const connectedExtensions = server.bridge.getExtensionStatuses().filter((extension) => extension.ready).length
490
- console.log(`Extensions: ${connectedExtensions} connected`)
491
- if (token) {
492
- console.log('Authentication: enabled')
493
- } else {
494
- console.warn('Authentication: disabled (loopback mode only)')
495
- }
496
-
497
- if (isDaemonChild && process.send) {
498
- process.send({
499
- type: 'ready',
500
- pid: process.pid,
501
- bridgeId: server.bridgeId,
502
- httpUrl: address.httpUrl,
503
- wsUrl: address.wsUrl,
504
- workflowWatchWsUrl: address.workflowWatchWsUrl,
505
- dataDir,
506
- logPath: getBridgeDaemonLogPath(dataDir),
507
- pairingCode,
508
- authenticationEnabled: Boolean(token),
509
- })
510
- }
511
- await new Promise<void>(() => undefined)
512
- }
513
-
514
- async function startBridgeInBackground(options: BridgeStartOptions) {
515
- const dataDir = getDataDirectory(options)
516
- await mkdir(dataDir, { recursive: true, mode: 0o700 })
517
-
518
- const existingState = await readBridgeDaemonState(dataDir)
519
- if (existingState) {
520
- if (await isManagedBridgeRunning(existingState)) {
521
- throw new BridgeError('BridgeAlreadyRunning', `Tapicker Bridge is already running (PID ${existingState.pid})`, {
522
- status: 409,
523
- })
524
- }
525
- await removeBridgeDaemonState(dataDir, existingState.pid)
526
- }
527
-
528
- const logPath = getBridgeDaemonLogPath(dataDir)
529
- const logFile = openSync(logPath, 'a', 0o600)
530
- let child: ReturnType<typeof fork>
531
- try {
532
- chmodSync(logPath, 0o600)
533
- const entryPoint = process.argv[1]
534
- if (!entryPoint) {
535
- throw new BridgeError('InvalidCliEntryPoint', 'Unable to determine the Tapicker Bridge CLI entry point', {
536
- status: 500,
537
- })
538
- }
539
-
540
- const childArgs = process.argv.slice(2).filter((argument) => argument !== '--daemon')
541
- child = fork(entryPoint, childArgs, {
542
- detached: true,
543
- env: { ...process.env, [DAEMON_CHILD_ENV]: '1' },
544
- stdio: ['ignore', logFile, logFile, 'ipc'],
545
- })
546
- } finally {
547
- closeSync(logFile)
548
- }
549
-
550
- let ready: DaemonReadyMessage
551
- try {
552
- ready = await waitForDaemonReady(child, logPath)
553
- } catch (error) {
554
- if (child.connected) child.disconnect()
555
- child.kill('SIGTERM')
556
- child.unref()
557
- throw error
558
- }
559
-
560
- if (child.connected) child.disconnect()
561
- child.unref()
562
-
563
- console.log(`Tapicker Bridge ${VERSION} started as a daemon (PID ${ready.pid})`)
564
- console.log(`HTTP: ${ready.httpUrl}`)
565
- console.log(`WebSocket: ${ready.wsUrl}`)
566
- console.log(`MCP Server: ${new URL(ready.httpUrl).origin}/mcp`)
567
- console.log(`Data Directory: ${ready.dataDir}`)
568
- console.log(`Log: ${ready.logPath}`)
569
- if (ready.pairingCode) {
570
- console.log(`Pairing Code: ${ready.pairingCode}`)
571
- } else {
572
- console.log('Pairing is locked; run `tapicker bridge code --refresh` locally to unlock it')
573
- }
574
- console.log(`Authentication: ${ready.authenticationEnabled ? 'enabled' : 'disabled (loopback mode only)'}`)
575
- }
576
-
577
- async function stopBridge(options: BridgeStartOptions) {
578
- const dataDir = getDataDirectory(options)
579
- const state = await readBridgeDaemonState(dataDir)
580
-
581
- let result: { pid?: number }
582
- try {
583
- result = await sendBridgeControlRequest<{ pid?: number }>(dataDir, 'bridge.stop')
584
- } catch (error) {
585
- if (!(error instanceof BridgeError) || error.code !== 'BridgeControlUnavailable') throw error
586
- if (!state) {
587
- console.log('No Tapicker Bridge is running.')
588
- return
589
- }
590
- if (!isProcessRunning(state.pid)) {
591
- await removeBridgeDaemonState(dataDir, state.pid)
592
- console.log('Tapicker Bridge daemon was not running; removed the stale process file.')
593
- return
594
- }
595
- if (!(await isManagedBridgeRunning(state))) {
596
- await removeBridgeDaemonState(dataDir, state.pid)
597
- console.log('Tapicker Bridge daemon was not running; removed the stale process file.')
598
- return
599
- }
600
-
601
- try {
602
- process.kill(state.pid, 'SIGTERM')
603
- } catch (signalError) {
604
- if ((signalError as NodeJS.ErrnoException).code !== 'ESRCH') throw signalError
605
- }
606
-
607
- const stopped = await waitForProcessExit(state.pid)
608
- if (!stopped) {
609
- throw new BridgeError('BridgeStopTimeout', `Tapicker Bridge (PID ${state.pid}) did not stop in time`, {
610
- status: 500,
611
- cause: error,
612
- })
613
- }
614
-
615
- await removeBridgeDaemonState(dataDir, state.pid)
616
- console.log(`Tapicker Bridge stopped (PID ${state.pid}; verified PID fallback)`)
617
- return
618
- }
619
-
620
- const pid = result.pid ?? state?.pid
621
- if (pid === undefined) {
622
- console.log('Tapicker Bridge shutdown requested.')
623
- return
624
- }
625
-
626
- const stopped = await waitForProcessExit(pid)
627
- if (!stopped) {
628
- throw new BridgeError('BridgeStopTimeout', `Tapicker Bridge (PID ${pid}) did not stop in time`, {
629
- status: 500,
630
- })
631
- }
632
-
633
- await removeBridgeDaemonState(dataDir, pid)
634
- console.log(`Tapicker Bridge stopped (PID ${pid})`)
635
- }
636
-
637
- async function showPairingCode(options: BridgeStartOptions) {
638
- const dataDir = getDataDirectory(options)
639
- const result = await sendBridgeControlRequest<{ code?: string; locked: boolean }>(
640
- dataDir,
641
- options.refresh ? 'code.refresh' : 'code.get',
642
- )
643
-
644
- if (!result.code || result.locked) {
645
- throw new BridgeError(
646
- 'PairingRateLimited',
647
- 'Pairing is locked; run `tapicker bridge code --refresh` to rotate the code and unlock pairing',
648
- { status: 429 },
649
- )
650
- }
651
-
652
- console.log(`Pairing Code: ${result.code}`)
653
- }
654
-
655
- async function showExtensions(options: BridgeStartOptions) {
656
- const dataDir = getDataDirectory(options)
657
- const extensions = await sendBridgeControlRequest<unknown[]>(dataDir, 'extensions.list')
658
- if (extensions.length === 0) {
659
- console.log('No Extensions are currently connected.')
660
- return
661
- }
662
- console.log(JSON.stringify(extensions, null, 2))
663
- }
664
-
665
- function getDataDirectory(options: BridgeStartOptions) {
666
- return resolve(options.dataDir || process.env.TAPICKER_DATA_DIR || getDefaultBridgeDataDir())
667
- }
668
-
669
- function parseCorsOrigins(value: unknown = '*') {
670
- if (typeof value !== 'string') {
671
- throw new BridgeError(
672
- 'InvalidCorsOrigins',
673
- 'Pass multiple CORS origins as one comma-separated --cors-origin value',
674
- {
675
- status: 400,
676
- },
677
- )
678
- }
679
-
680
- const origins = value.split(',').map((origin) => origin.trim())
681
- if (origins.some((origin) => !origin)) {
682
- throw new BridgeError('InvalidCorsOrigins', '--cors-origin must contain one or more non-empty origins', {
683
- status: 400,
684
- })
685
- }
686
- return origins
687
- }
688
-
689
- function waitForDaemonReady(child: ReturnType<typeof fork>, logPath: string) {
690
- return new Promise<DaemonReadyMessage>((resolveReady, rejectReady) => {
691
- let settled = false
692
- const timeout = setTimeout(() => {
693
- fail(
694
- new BridgeError('BridgeStartTimeout', `Timed out waiting for Bridge startup; see ${logPath}`, { status: 504 }),
695
- )
696
- }, 15_000)
697
-
698
- const finish = (callback: () => void) => {
699
- if (settled) return
700
- settled = true
701
- clearTimeout(timeout)
702
- callback()
703
- }
704
-
705
- const fail = (error: Error) => finish(() => rejectReady(error))
706
-
707
- child.on('message', (message: unknown) => {
708
- if (!isDaemonReadyMessage(message)) return
709
- finish(() => resolveReady(message))
710
- })
711
- child.once('error', (error) => fail(error))
712
- child.once('exit', (code, signal) => {
713
- fail(
714
- new BridgeError(
715
- 'BridgeStartFailed',
716
- `Tapicker Bridge exited before startup completed (${signal || `code ${code}`}); see ${logPath}`,
717
- { status: 500 },
718
- ),
719
- )
720
- })
721
- })
722
- }
723
-
724
- function isDaemonReadyMessage(value: unknown): value is DaemonReadyMessage {
725
- if (!value || typeof value !== 'object') return false
726
- const message = value as Partial<DaemonReadyMessage>
727
- return (
728
- message.type === 'ready' &&
729
- typeof message.pid === 'number' &&
730
- typeof message.bridgeId === 'string' &&
731
- typeof message.httpUrl === 'string' &&
732
- typeof message.wsUrl === 'string' &&
733
- typeof message.workflowWatchWsUrl === 'string' &&
734
- typeof message.dataDir === 'string' &&
735
- typeof message.logPath === 'string' &&
736
- typeof message.authenticationEnabled === 'boolean'
737
- )
738
- }
739
-
740
- async function waitForProcessExit(pid: number, timeoutMs = 10_000) {
741
- const deadline = Date.now() + timeoutMs
742
- while (isProcessRunning(pid) && Date.now() < deadline) {
743
- await new Promise((resolveDelay) => setTimeout(resolveDelay, 100))
744
- }
745
- return !isProcessRunning(pid)
746
- }
747
-
748
- async function isManagedBridgeRunning(state: { pid: number; bridgeId: string; httpUrl: string }) {
749
- if (!isProcessRunning(state.pid)) return false
750
-
751
- let response: Response
752
- try {
753
- response = await fetch(`${state.httpUrl}/v1/health`, { signal: AbortSignal.timeout(2_000) })
754
- } catch (cause) {
755
- throw new BridgeError(
756
- 'BridgeProcessUnverified',
757
- 'Could not verify the saved Bridge process; refusing to signal it',
758
- {
759
- status: 503,
760
- cause,
761
- },
762
- )
763
- }
764
-
765
- if (!response.ok) return false
766
-
767
- let health: unknown
768
- try {
769
- health = await response.json()
770
- } catch {
771
- return false
772
- }
773
-
774
- if (!health || typeof health !== 'object') return false
775
- return (health as { bridgeId?: unknown }).bridgeId === state.bridgeId
776
- }
777
-
778
- async function startWorkflow(workflowId: string, options: WorkflowStartOptions) {
779
- let args: unknown
780
- try {
781
- args = JSON.parse(options.args)
782
- } catch (error) {
783
- throw new BridgeError('InvalidArguments', '--args must be valid JSON', { status: 400, cause: error })
784
- }
785
-
786
- const parsed = WorkflowStartInputSchema.safeParse({ workflowId, args, tab: parseTabTarget(options.tab) })
787
- if (!parsed.success) {
788
- throw new BridgeError('InvalidArguments', 'Invalid Workflow start arguments', {
789
- status: 400,
790
- details: parsed.error.issues,
791
- })
792
- }
793
-
794
- if (!options.watch) {
795
- await sendWorkflowCommand('workflow.start', parsed.data, options)
796
- return
797
- }
798
-
799
- const timeoutMs = parseWatchTimeout(options.timeout)
800
- const connection = await openWorkflowWatch(workflowId, options)
801
- try {
802
- const startResult = await requestBridgeCommand('workflow.start', parsed.data, {
803
- ...options,
804
- clientId: connection.clientId,
805
- })
806
- console.log(JSON.stringify({ type: 'workflow.start', data: startResult }))
807
- const result = await waitForWorkflowStatus({
808
- clientId: connection.clientId,
809
- workflowId,
810
- subscribe: (onStatus, onClose) => subscribeToWorkflowWatch(connection, onStatus, onClose),
811
- readStatus: () => readWorkflowStatus(workflowId, connection.clientId, options),
812
- onStatus: printWorkflowStatus,
813
- timeoutMs,
814
- })
815
- printWatchTimeout(result)
816
- } finally {
817
- connection.close()
818
- }
819
- }
820
-
821
- async function watchWorkflow(workflowId: string, options: WorkflowStartOptions) {
822
- const timeoutMs = parseWatchTimeout(options.timeout)
823
- const connection = await openWorkflowWatch(workflowId, options)
824
- try {
825
- const result = await waitForWorkflowStatus({
826
- clientId: connection.clientId,
827
- workflowId,
828
- subscribe: (onStatus, onClose) => subscribeToWorkflowWatch(connection, onStatus, onClose),
829
- readStatus: () => readWorkflowStatus(workflowId, connection.clientId, options),
830
- onStatus: printWorkflowStatus,
831
- timeoutMs,
832
- })
833
- printWatchTimeout(result)
834
- } finally {
835
- connection.close()
836
- }
837
- }
838
-
839
- async function openWorkflowWatch(workflowId: string, options: WorkflowStartOptions) {
840
- const protocol = options.https ? 'https' : 'http'
841
- const bridgeUrl = new URL(`${protocol}://${options.host}:${parsePort(options.port)}`)
842
- return connectWorkflowWatch(
843
- workflowWatchWebSocketUrl(bridgeUrl),
844
- { clientId: options.clientId, workflowId },
845
- options.token,
846
- )
847
- }
848
-
849
- function subscribeToWorkflowWatch(
850
- connection: WorkflowWatchConnection,
851
- onStatus: (event: WorkflowStatusEvent) => void,
852
- onClose: (error: BridgeError) => void,
853
- ) {
854
- const removeStatusListener = connection.onStatus(onStatus)
855
- const removeCloseListener = connection.onClose(onClose)
856
- return () => {
857
- removeStatusListener()
858
- removeCloseListener()
859
- }
860
- }
861
-
862
- function printWatchTimeout(result: { timedOut: boolean } & WorkflowStatusEvent) {
863
- if (result.timedOut) {
864
- console.log(JSON.stringify({ type: 'workflow.watch:res', data: { ok: true, data: result } }))
865
- }
866
- }
867
-
868
- async function readWorkflowStatus(workflowId: string, clientId: string, options: WorkflowStartOptions) {
869
- const result = await requestBridgeCommand('workflow.status', { workflowId }, { ...options, clientId })
870
- const parsed = WorkflowStatusEventSchema.safeParse({ ...(result as object), clientId })
871
- if (!parsed.success || parsed.data.workflowId !== workflowId) {
872
- throw new BridgeError('InvalidBridgeResponse', 'Bridge returned an invalid Workflow status response', {
873
- status: 502,
874
- details: parsed.success ? undefined : parsed.error.issues,
875
- })
876
- }
877
- return parsed.data
878
- }
879
-
880
- function printWorkflowStatus(status: { clientId: string; workflowId: string; state: string; updatedAt: string }) {
881
- console.log(JSON.stringify({ type: 'workflow.status', data: status }))
882
- }
883
-
884
- function parseWatchTimeout(value?: string) {
885
- if (value === undefined) return undefined
886
- const timeoutMs = Number(value)
887
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
888
- throw new BridgeError('InvalidArguments', '--timeout must be a positive integer no greater than 2147483647', {
889
- status: 400,
890
- })
891
- }
892
- return timeoutMs
893
- }
894
-
895
- function parseTabTarget(value?: string): WorkflowStartInput['tab'] | undefined {
896
- if (value === undefined || value === 'active') {
897
- return undefined
898
- }
899
- if (value === 'new') {
900
- return { mode: 'new' }
901
- }
902
-
903
- const match = /^id:(\d+)$/.exec(value)
904
- const tabId = match ? Number(match[1]) : Number.NaN
905
- if (!Number.isSafeInteger(tabId) || tabId <= 0) {
906
- throw new BridgeError('InvalidArguments', '--tab must be "active", "new", or "id:<positive-tabId>"', {
907
- status: 400,
908
- })
909
- }
910
-
911
- return { mode: 'id', tabId }
912
- }
913
-
914
- async function controlWorkflow(
915
- action: 'pause' | 'resume' | 'stop' | 'status',
916
- workflowId: string,
917
- options: WorkflowStartOptions,
918
- ) {
919
- const parsed = WorkflowControlInputSchema.safeParse({ workflowId })
920
- if (!parsed.success) {
921
- throw new BridgeError('InvalidArguments', `Invalid Workflow ${action} arguments`, {
922
- status: 400,
923
- details: parsed.error.issues,
924
- })
925
- }
926
-
927
- await sendWorkflowCommand(`workflow.${action}`, parsed.data, options)
928
- }
929
-
930
- async function sendWorkflowCommand(command: string, input: unknown, options: WorkflowStartOptions) {
931
- const result = await requestBridgeCommand(command, input, options)
932
- console.log(JSON.stringify(result, null, 2))
933
- return result
934
- }
935
-
936
- async function sendDataCommand(command: string, input: unknown, options: DataCommandOptions) {
937
- const result = await requestBridgeCommand(command, input, options)
938
- console.log(JSON.stringify(result, null, 2))
939
- return result
940
- }
941
-
942
- async function requestBridgeCommand(command: string, input: unknown, options: BridgeCommandOptions) {
943
- const protocol = options.https ? 'https' : 'http'
944
- const url = `${protocol}://${options.host}:${parsePort(options.port)}/v1/commands/${command}`
945
- const headers: Record<string, string> = { 'Content-Type': 'application/json' }
946
- if (options.token) {
947
- headers.Authorization = `Bearer ${options.token}`
948
- }
949
-
950
- const response = await fetch(url, {
951
- method: 'POST',
952
- headers,
953
- body: JSON.stringify({ clientId: options.clientId, input }),
954
- })
955
- const body = await readResponseBody(response)
956
-
957
- if (!response.ok) {
958
- throw new BridgeError(body.error?.code || 'BridgeRequestFailed', body.error?.message || response.statusText, {
959
- status: response.status,
960
- details: body.error?.details,
961
- })
962
- }
963
-
964
- return body.data ?? body
965
- }
966
-
967
- function resolveTls(options: BridgeStartOptions) {
968
- if (!options.tlsKey && !options.tlsCert) {
969
- return undefined
970
- }
971
-
972
- if (!options.tlsKey || !options.tlsCert) {
973
- throw new BridgeError('InvalidTlsOptions', '--tls-key and --tls-cert must be provided together', { status: 400 })
974
- }
975
-
976
- readFileSync(options.tlsKey)
977
- readFileSync(options.tlsCert)
978
- return { key: options.tlsKey, cert: options.tlsCert }
979
- }
980
-
981
- function parsePort(value: string) {
982
- const port = Number(value)
983
- if (!Number.isInteger(port) || port < 0 || port > 65_535) {
984
- throw new BridgeError('InvalidPort', `Invalid port: ${value}`, { status: 400 })
985
- }
986
- return port
987
- }
988
-
989
- function isLoopback(host: string) {
990
- return host === '127.0.0.1' || host === 'localhost' || host === '::1'
991
- }
992
-
993
- async function readResponseBody(response: Response) {
994
- const text = await response.text()
995
- if (!text) {
996
- return {}
997
- }
998
-
999
- try {
1000
- return JSON.parse(text) as {
1001
- data?: unknown
1002
- error?: { code?: string; message?: string; details?: unknown }
1003
- }
1004
- } catch {
1005
- return { error: { message: text } }
1006
- }
1007
- }
1008
-
1009
- async function main() {
1010
- try {
1011
- cli.parse(process.argv, { run: false })
1012
- await cli.runMatchedCommand()
1013
- } catch (error) {
1014
- console.error(JSON.stringify({ ok: false, error: serializeBridgeError(error) }, null, 2))
1015
- process.exitCode = 1
1016
- }
1017
- }
1018
-
1019
- void main()