kimaki 1.2.0 → 1.3.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/dist/bot.d.ts +7 -3
- package/dist/bot.js +9 -3
- package/dist/bot.js.map +1 -1
- package/dist/cli/bot.js +10 -5
- package/dist/cli/bot.js.map +1 -1
- package/dist/cli-lock.e2e.test.js +3 -2
- package/dist/cli-lock.e2e.test.js.map +1 -1
- package/dist/commands/login-commands.d.ts +1 -1
- package/dist/commands/preference-commands.d.ts +1 -1
- package/dist/commands/worktree-commands.d.ts +1 -1
- package/dist/event-loop.d.ts +2 -1
- package/dist/event-loop.js +2 -2
- package/dist/event-loop.js.map +1 -1
- package/dist/hello-thread.e2e.test.js +31 -0
- package/dist/hello-thread.e2e.test.js.map +1 -1
- package/dist/ingress.js +11 -6
- package/dist/ingress.js.map +1 -1
- package/dist/lock-routes.d.ts +14 -14
- package/dist/main.d.ts +1 -0
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/plugin/index.js +42 -9
- package/dist/plugin/index.js.map +1 -1
- package/dist/prompt.d.ts +6 -6
- package/dist/scheduler.d.ts +1 -1
- package/dist/sessions.d.ts +10 -10
- package/dist/sessions.js +3 -3
- package/dist/sessions.js.map +1 -1
- package/dist/system-prompt.d.ts +14 -0
- package/dist/system-prompt.js +34 -0
- package/dist/system-prompt.js.map +1 -1
- package/dist/test/harness.d.ts +2 -1
- package/dist/test/harness.js +2 -1
- package/dist/test/harness.js.map +1 -1
- package/dist/test/replay.js +1 -0
- package/dist/test/replay.js.map +1 -1
- package/dist/thread-reducer.d.ts +3 -0
- package/dist/thread-reducer.fixtures.test.js +2 -2
- package/dist/thread-reducer.fixtures.test.js.map +1 -1
- package/dist/thread-reducer.js +32 -4
- package/dist/thread-reducer.js.map +1 -1
- package/dist/thread-reducer.test.js +31 -0
- package/dist/thread-reducer.test.js.map +1 -1
- package/dist/tunnel.d.ts +12 -0
- package/dist/tunnel.js +207 -0
- package/dist/tunnel.js.map +1 -0
- package/dist/tunnel.test.d.ts +1 -0
- package/dist/tunnel.test.js +38 -0
- package/dist/tunnel.test.js.map +1 -0
- package/package.json +5 -5
- package/skills/kimaki-tunnel/SKILL.md +14 -10
- package/src/bot.ts +14 -4
- package/src/cli/bot.ts +9 -5
- package/src/cli-lock.e2e.test.ts +3 -2
- package/src/event-loop.ts +4 -1
- package/src/hello-thread.e2e.test.ts +35 -0
- package/src/ingress.ts +9 -5
- package/src/main.ts +4 -1
- package/src/message-tool.e2e.test.ts +45 -0
- package/src/plugin/index.ts +42 -9
- package/src/sessions.ts +3 -2
- package/src/system-prompt.ts +38 -0
- package/src/test/harness.ts +3 -0
- package/src/test/replay.ts +1 -0
- package/src/thread-reducer.fixtures.test.ts +2 -2
- package/src/thread-reducer.test.ts +31 -0
- package/src/thread-reducer.ts +36 -4
- package/src/tunnel.test.ts +39 -0
- package/src/tunnel.ts +209 -0
package/src/thread-reducer.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
import { closePermission, hydratePermissions, showPermission, STATUS, type PendingPermission } from './permissions.ts'
|
|
56
56
|
import { closeForm, formatAnswer, hydrateForms, showForm, withAnswer, type PendingForm } from './questions.ts'
|
|
57
57
|
import { cancelQueued, deliverQueued, enqueueInput, hydrateQueue, promoteQueued, queuedItems, type PendingInput } from './queue.ts'
|
|
58
|
+
import { MESSAGE_TOOL } from './system-prompt.ts'
|
|
58
59
|
|
|
59
60
|
export type Turn = {
|
|
60
61
|
startedAt: number
|
|
@@ -64,6 +65,14 @@ export type Turn = {
|
|
|
64
65
|
tokens: number
|
|
65
66
|
// `kimaki sleep` ran in this turn: the session wakes later, so the footer is silent.
|
|
66
67
|
slept: boolean
|
|
68
|
+
// Message mode (Prefs.messageTool): a send_message call was shown in this turn.
|
|
69
|
+
messaged: boolean
|
|
70
|
+
// Message mode: the last hidden text, shown at the end if the turn sent no message.
|
|
71
|
+
unsent: string | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function newTurn(at: number): Turn {
|
|
75
|
+
return { startedAt: at, model: null, agent: null, tokens: 0, slept: false, messaged: false, unsent: null }
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
export type Child = {
|
|
@@ -186,6 +195,8 @@ export type Prefs = {
|
|
|
186
195
|
verbosity: Verbosity
|
|
187
196
|
// "providerID/modelID" -> context window size in tokens.
|
|
188
197
|
contextLimits: Readonly<Record<string, number>>
|
|
198
|
+
// `kimaki --message-tool`: Discord hides root plain text (kept for the TUI), only send_message shows.
|
|
199
|
+
messageTool: boolean
|
|
189
200
|
}
|
|
190
201
|
|
|
191
202
|
const RETRY_NOTICE_INTERVAL_MS = 10_000
|
|
@@ -281,6 +292,9 @@ function contextPercent({ turn, prefs }: { turn: Draft<Turn>; prefs: Prefs }): n
|
|
|
281
292
|
|
|
282
293
|
// The footer closing the answer of `turn` at `at`.
|
|
283
294
|
function emitFooter({ draft, prefs, emit, turn, at, notify }: Context & { turn: Draft<Turn>; at: number; notify: boolean }) {
|
|
295
|
+
// Message mode: a model that never called send_message would leave the user in silence.
|
|
296
|
+
if (!turn.messaged && turn.unsent) emit({ type: 'markdown', text: turn.unsent, blankLineBefore: draft.lastKind === 'tool' })
|
|
297
|
+
turn.unsent = null
|
|
284
298
|
// No model step ran (a compaction-only execution): nothing to summarize.
|
|
285
299
|
if (!turn.model) return
|
|
286
300
|
// A child still runs: the answer comes later.
|
|
@@ -346,11 +360,11 @@ function applyRoot(context: Context & { event: V2Event }) {
|
|
|
346
360
|
emit({ type: 'send', text: asSubtext(`Working directory changed to ${event.data.location.directory}`) })
|
|
347
361
|
return
|
|
348
362
|
case 'session.execution.started':
|
|
349
|
-
draft.turn ??=
|
|
363
|
+
draft.turn ??= newTurn(event.created)
|
|
350
364
|
return
|
|
351
365
|
case 'session.step.started': {
|
|
352
366
|
const model = { providerID: event.data.model.providerID, id: event.data.model.id }
|
|
353
|
-
draft.turn ??=
|
|
367
|
+
draft.turn ??= newTurn(event.created)
|
|
354
368
|
draft.turn.model = model
|
|
355
369
|
draft.turn.agent = event.data.agent
|
|
356
370
|
if (draft.bannerPending) emit({ type: 'send', text: formatBanner({ model: event.data.model, agent: event.data.agent }) })
|
|
@@ -373,6 +387,10 @@ function applyRoot(context: Context & { event: V2Event }) {
|
|
|
373
387
|
case 'session.text.ended': {
|
|
374
388
|
const text = event.data.text.trim()
|
|
375
389
|
if (!text) return
|
|
390
|
+
if (prefs.messageTool && draft.turn) {
|
|
391
|
+
draft.turn.unsent = text
|
|
392
|
+
return
|
|
393
|
+
}
|
|
376
394
|
emit({ type: 'markdown', text, blankLineBefore: draft.lastKind === 'tool' })
|
|
377
395
|
draft.lastKind = 'text'
|
|
378
396
|
return
|
|
@@ -434,6 +452,7 @@ function applyRoot(context: Context & { event: V2Event }) {
|
|
|
434
452
|
emitFooter({ ...context, turn: draft.turn, at: event.created, notify: false })
|
|
435
453
|
draft.turn.startedAt = event.created
|
|
436
454
|
draft.turn.slept = false
|
|
455
|
+
draft.turn.messaged = false
|
|
437
456
|
draft.lastKind = null
|
|
438
457
|
}
|
|
439
458
|
return deliverQueued({ draft, emit, inboxID: event.data.inboxID })
|
|
@@ -494,6 +513,15 @@ function applyTool(context: Context & { event: V2Event; label: string | undefine
|
|
|
494
513
|
if (reused) Object.assign(reused, { ...call, agent: call.agent || reused.agent })
|
|
495
514
|
}
|
|
496
515
|
draft.tools[key] = tool
|
|
516
|
+
// The plugin offers send_message to root sessions only (MESSAGE_TOOL).
|
|
517
|
+
if (name === MESSAGE_TOOL && label === undefined) {
|
|
518
|
+
const text = stringInput(input, 'text').trim()
|
|
519
|
+
if (draft.turn) draft.turn.messaged = true
|
|
520
|
+
if (!text) return
|
|
521
|
+
context.emit({ type: 'markdown', text, blankLineBefore: draft.lastKind === 'tool' })
|
|
522
|
+
draft.lastKind = 'text'
|
|
523
|
+
return
|
|
524
|
+
}
|
|
497
525
|
if (render && isToolVisible({ name, input }, prefs.verbosity)) toolLine(context, formatToolLine({ name, input }, { label }))
|
|
498
526
|
return
|
|
499
527
|
}
|
|
@@ -613,7 +641,7 @@ function applyKimaki({ draft, event, prefs, emit }: Context & { event: KimakiEve
|
|
|
613
641
|
const active = new Set(event.activeSessionIds)
|
|
614
642
|
for (const [id, child] of Object.entries(draft.children)) child.running = active.has(id)
|
|
615
643
|
if (!active.has(draft.sessionId)) draft.turn = null
|
|
616
|
-
else draft.turn ??=
|
|
644
|
+
else draft.turn ??= newTurn(event.at)
|
|
617
645
|
// Nothing runs: tool calls whose end was missed while disconnected are over.
|
|
618
646
|
if (!isBusy(draft)) draft.tools = {}
|
|
619
647
|
for (const session of event.sessions) {
|
|
@@ -645,9 +673,13 @@ export function replayEffects({
|
|
|
645
673
|
const blocks = messages.flatMap((message): Block[] => {
|
|
646
674
|
if (message.type !== 'assistant') return []
|
|
647
675
|
return message.content.flatMap((part): Block[] => {
|
|
648
|
-
if (part.type === 'text') return part.text.trim() ? [{ kind: 'text', text: part.text.trim() }] : []
|
|
676
|
+
if (part.type === 'text') return part.text.trim() && !prefs.messageTool ? [{ kind: 'text', text: part.text.trim() }] : []
|
|
649
677
|
if (part.type !== 'tool' || part.state.status === 'streaming') return []
|
|
650
678
|
const { input, metadata } = part.state
|
|
679
|
+
if (part.name === MESSAGE_TOOL) {
|
|
680
|
+
const text = stringInput(input, 'text').trim()
|
|
681
|
+
return text ? [{ kind: 'text', text }] : []
|
|
682
|
+
}
|
|
651
683
|
const isExecute = part.name === 'execute'
|
|
652
684
|
const lines = [{ name: part.name, input }, ...(isExecute ? executeCalls(metadata) : [])]
|
|
653
685
|
.filter((call) => isToolVisible(call, prefs.verbosity))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test } from 'vitest'
|
|
2
|
+
import { detectPortFromText, routeName } from './tunnel.ts'
|
|
3
|
+
|
|
4
|
+
test('detects the dev server port and skips noise lines', () => {
|
|
5
|
+
const vite = '\u001b[33mPort 5173 is in use, trying another one...\u001b[39m\n ➜ Local: \u001b[36mhttp://localhost:\u001b[1m5174\u001b[22m/\u001b[39m\n'
|
|
6
|
+
expect({
|
|
7
|
+
vite: detectPortFromText(vite),
|
|
8
|
+
next: detectPortFromText(' - Local: http://127.0.0.1:3000'),
|
|
9
|
+
bare: detectPortFromText('Server running at 0.0.0.0:4321'),
|
|
10
|
+
inspector: detectPortFromText('Debugger listening on ws://127.0.0.1:9229/abc\nready on http://localhost:4173'),
|
|
11
|
+
outOfRange: detectPortFromText('listening on localhost:70000'),
|
|
12
|
+
portOnly: detectPortFromText('Port 5173 is in use'),
|
|
13
|
+
}).toMatchInlineSnapshot(`
|
|
14
|
+
{
|
|
15
|
+
"bare": 4321,
|
|
16
|
+
"inspector": 4173,
|
|
17
|
+
"next": 3000,
|
|
18
|
+
"outOfRange": null,
|
|
19
|
+
"portOnly": null,
|
|
20
|
+
"vite": 5174,
|
|
21
|
+
}
|
|
22
|
+
`)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('route name: explicit, then tuistory session, then random', () => {
|
|
26
|
+
expect({
|
|
27
|
+
explicit: routeName({ name: 'docs', tuistorySessionId: 'abc123' }),
|
|
28
|
+
invalid: String(routeName({ name: 'Bad_Name' })),
|
|
29
|
+
tuistory: routeName({ tuistorySessionId: 'ABC123' }),
|
|
30
|
+
random: String(routeName({})).length,
|
|
31
|
+
}).toMatchInlineSnapshot(`
|
|
32
|
+
{
|
|
33
|
+
"explicit": "docs",
|
|
34
|
+
"invalid": "Error: Invalid tunnel ID "Bad_Name": use lowercase letters, digits and dashes",
|
|
35
|
+
"random": 16,
|
|
36
|
+
"tuistory": "abc123",
|
|
37
|
+
}
|
|
38
|
+
`)
|
|
39
|
+
})
|
package/src/tunnel.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// `kimaki tunnel`: run a command, find the local port it listens on, and expose
|
|
2
|
+
// it at https://<route>.<id>.opentunnel.xyz. TLS ends in this process, so the
|
|
3
|
+
// relay never sees plaintext. The tunnel identity is the default opentunnel
|
|
4
|
+
// profile in $XDG_DATA_HOME/opentunnel, shared with the opentunnel CLI.
|
|
5
|
+
|
|
6
|
+
import crypto from 'node:crypto'
|
|
7
|
+
import { execFile, spawn, type ChildProcess } from 'node:child_process'
|
|
8
|
+
import net from 'node:net'
|
|
9
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
10
|
+
import { promisify, stripVTControlCharacters } from 'node:util'
|
|
11
|
+
import { create as createOpenTunnel, type OpenTunnelConnection } from '@opentunnel/client'
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile)
|
|
14
|
+
|
|
15
|
+
const ROUTE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
|
|
16
|
+
const PORT_WAIT_MS = 30_000
|
|
17
|
+
const ATTACH_TIMEOUT_MS = 60_000
|
|
18
|
+
const CHILD_KILL_GRACE_MS = 5_000
|
|
19
|
+
|
|
20
|
+
// Only lines with host:port, so "Port 5173 is in use" does not match.
|
|
21
|
+
const LOCAL_PORT_PATTERN = /(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::\]):(\d{1,5})/i
|
|
22
|
+
const IGNORED_LINE_PATTERNS = [/\bdebugger (?:listening|attached)\b/i, /\bdevtools listening\b/i]
|
|
23
|
+
|
|
24
|
+
export function detectPortFromText(text: string): number | null {
|
|
25
|
+
// Vite colors the port separately, so strip ANSI before matching.
|
|
26
|
+
for (const line of stripVTControlCharacters(text).split('\n')) {
|
|
27
|
+
if (IGNORED_LINE_PATTERNS.some((pattern) => pattern.test(line))) continue
|
|
28
|
+
const port = Number(line.match(LOCAL_PORT_PATTERN)?.[1])
|
|
29
|
+
if (port >= 1 && port <= 65_535) return port
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Explicit name, then the tuistory session ID (stable URL across restarts of one session), then random.
|
|
35
|
+
export function routeName({ name, tuistorySessionId }: { name?: string; tuistorySessionId?: string }): string | Error {
|
|
36
|
+
if (name) return ROUTE_NAME.test(name) ? name : new Error(`Invalid tunnel ID ${JSON.stringify(name)}: use lowercase letters, digits and dashes`)
|
|
37
|
+
const fromSession = tuistorySessionId?.toLowerCase()
|
|
38
|
+
if (fromSession && ROUTE_NAME.test(fromSession)) return fromSession
|
|
39
|
+
return crypto.randomBytes(8).toString('hex')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isPortInUse({ port, host }: { port: number; host: string }): Promise<boolean> {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const socket = net.connect({ port, host })
|
|
45
|
+
socket.once('connect', () => { socket.destroy(); resolve(true) })
|
|
46
|
+
socket.once('error', () => { socket.destroy(); resolve(false) })
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function waitForPort({ port, host, signal }: { port: number; host: string; signal: AbortSignal }): Promise<void | Error> {
|
|
51
|
+
while (!(await isPortInUse({ port, host }))) {
|
|
52
|
+
if (signal.aborted) return new Error(`Timed out waiting for ${host}:${port} to accept connections`)
|
|
53
|
+
await sleep(300)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function listeningPids(port: number): Promise<number[]> {
|
|
58
|
+
const { stdout } = await execFileAsync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { timeout: 5_000 }).catch(() => ({ stdout: '' }))
|
|
59
|
+
return stdout.split('\n').map(Number).filter((pid) => Number.isInteger(pid) && pid > 0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function killProcessOnPort({ port, host }: { port: number; host: string }): Promise<void | Error> {
|
|
63
|
+
for (const signal of ['SIGTERM', 'SIGKILL'] as const) {
|
|
64
|
+
for (const pid of await listeningPids(port)) {
|
|
65
|
+
try { process.kill(pid, signal) } catch {}
|
|
66
|
+
}
|
|
67
|
+
for (let waited = 0; waited < 3_000; waited += 200) {
|
|
68
|
+
if (!(await isPortInUse({ port, host }))) return
|
|
69
|
+
await sleep(200)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return new Error(`Port ${port} is still in use after --kill`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function detectPortFromOutput({ child, signal }: { child: ChildProcess; signal: AbortSignal }): Promise<number | Error> {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
let buffer = ''
|
|
78
|
+
const finish = (result: number | Error) => {
|
|
79
|
+
child.stdout?.off('data', onData)
|
|
80
|
+
child.stderr?.off('data', onData)
|
|
81
|
+
child.off('exit', onExit)
|
|
82
|
+
signal.removeEventListener('abort', onAbort)
|
|
83
|
+
resolve(result)
|
|
84
|
+
}
|
|
85
|
+
const onData = (chunk: Buffer) => {
|
|
86
|
+
buffer = (buffer + chunk.toString()).slice(-8_000)
|
|
87
|
+
const port = detectPortFromText(buffer)
|
|
88
|
+
if (port) finish(port)
|
|
89
|
+
}
|
|
90
|
+
const onExit = (code: number | null) => finish(new Error(`Command exited with code ${code} before printing a localhost URL. Pass --port`))
|
|
91
|
+
const onAbort = () => finish(new Error(`No localhost URL in the command output after ${PORT_WAIT_MS / 1000}s. Pass --port`))
|
|
92
|
+
child.stdout?.on('data', onData)
|
|
93
|
+
child.stderr?.on('data', onData)
|
|
94
|
+
child.once('exit', onExit)
|
|
95
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function killChild(child: ChildProcess | undefined, signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {
|
|
100
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); resolve() }, CHILD_KILL_GRACE_MS)
|
|
103
|
+
child.once('exit', () => { clearTimeout(timer); resolve() })
|
|
104
|
+
child.kill(signal)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const log = (line: string) => process.stderr.write(`${line}\n`)
|
|
109
|
+
|
|
110
|
+
export async function runTunnel(options: {
|
|
111
|
+
port?: number
|
|
112
|
+
command?: string[]
|
|
113
|
+
name?: string
|
|
114
|
+
host?: string
|
|
115
|
+
kill?: boolean
|
|
116
|
+
}): Promise<void | Error> {
|
|
117
|
+
const host = options.host ?? 'localhost'
|
|
118
|
+
const route = routeName({ name: options.name, tuistorySessionId: process.env['TUISTORY_SESSION_ID'] })
|
|
119
|
+
if (route instanceof Error) return route
|
|
120
|
+
if (options.kill && !options.port) return new Error('--kill needs --port')
|
|
121
|
+
|
|
122
|
+
if (options.port && options.kill) {
|
|
123
|
+
const killed = await killProcessOnPort({ port: options.port, host })
|
|
124
|
+
if (killed instanceof Error) return killed
|
|
125
|
+
} else if (options.port && options.command && (await isPortInUse({ port: options.port, host }))) {
|
|
126
|
+
return new Error(`Port ${options.port} is already in use. Pass --kill to stop the process on it first`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const client = createOpenTunnel()
|
|
130
|
+
const existing = await client.tunnel.get().catch(() => undefined)
|
|
131
|
+
if (!existing) log('Creating the opentunnel.xyz tunnel of this machine (waits for a certificate, up to a minute)...')
|
|
132
|
+
const identity = await client.tunnel.ensure().catch((cause: unknown) => new Error('Could not create the opentunnel tunnel', { cause }))
|
|
133
|
+
if (identity instanceof Error) return identity
|
|
134
|
+
const url = `https://${route}.${identity.hostname}`
|
|
135
|
+
|
|
136
|
+
let child: ChildProcess | undefined
|
|
137
|
+
let connection: OpenTunnelConnection | undefined
|
|
138
|
+
// Set before we kill the child ourselves, so its exit does not end the process first.
|
|
139
|
+
let stopping = false
|
|
140
|
+
const shutdown = async (signal: NodeJS.Signals) => {
|
|
141
|
+
if (stopping) return
|
|
142
|
+
stopping = true
|
|
143
|
+
log('Shutting down...')
|
|
144
|
+
await connection?.close().catch(() => undefined)
|
|
145
|
+
await killChild(child, signal)
|
|
146
|
+
process.exit(0)
|
|
147
|
+
}
|
|
148
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.once(signal, () => void shutdown(signal))
|
|
149
|
+
|
|
150
|
+
if (options.command) {
|
|
151
|
+
const [cmd, ...args] = options.command
|
|
152
|
+
child = spawn(cmd!, args, {
|
|
153
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
154
|
+
env: {
|
|
155
|
+
...process.env,
|
|
156
|
+
...(options.port && { PORT: String(options.port) }),
|
|
157
|
+
KIMAKI_TUNNEL_URL: url,
|
|
158
|
+
// Old name from traforo, kept so existing app configs still work.
|
|
159
|
+
TRAFORO_URL: url,
|
|
160
|
+
// wrangler dev passes parent env vars as worker bindings, so workers can read KIMAKI_TUNNEL_URL.
|
|
161
|
+
CLOUDFLARE_INCLUDE_PROCESS_ENV: 'true',
|
|
162
|
+
FORCE_COLOR: '1',
|
|
163
|
+
VITE_CLS: 'false',
|
|
164
|
+
NEXT_TELEMETRY_DISABLED: '1',
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
child.stdout?.pipe(process.stdout)
|
|
168
|
+
child.stderr?.pipe(process.stderr)
|
|
169
|
+
child.on('error', (error) => { log(`Failed to start command: ${error.message}`); process.exit(1) })
|
|
170
|
+
child.on('exit', (code, signal) => {
|
|
171
|
+
if (stopping) return
|
|
172
|
+
log(`Command exited with ${signal ?? `code ${code}`}`)
|
|
173
|
+
process.exit(code ?? 1)
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const fail = async (error: Error) => { stopping = true; await killChild(child); return error }
|
|
178
|
+
|
|
179
|
+
let port = options.port
|
|
180
|
+
const portSignal = AbortSignal.timeout(PORT_WAIT_MS)
|
|
181
|
+
if (!port && child) {
|
|
182
|
+
const detected = await detectPortFromOutput({ child, signal: portSignal })
|
|
183
|
+
if (detected instanceof Error) return fail(detected)
|
|
184
|
+
port = detected
|
|
185
|
+
}
|
|
186
|
+
if (!port) return fail(new Error('Could not find the local port. Pass --port'))
|
|
187
|
+
const ready = await waitForPort({ port, host, signal: portSignal })
|
|
188
|
+
if (ready instanceof Error) return fail(ready)
|
|
189
|
+
|
|
190
|
+
// connect retries route_conflict forever, so a route held by another process would hang here.
|
|
191
|
+
const connecting = client.tunnel.connect({ routes: { [route]: `${host}:${port}` } })
|
|
192
|
+
const timeout = sleep(ATTACH_TIMEOUT_MS, null, { ref: false })
|
|
193
|
+
const attached = await Promise.race([connecting, timeout]).catch((cause: unknown) => new Error('Could not connect the tunnel', { cause }))
|
|
194
|
+
if (attached instanceof Error) return fail(attached)
|
|
195
|
+
if (!attached) return fail(new Error(`Tunnel did not attach in ${ATTACH_TIMEOUT_MS / 1000}s. Another process may use the tunnel ID ${route}`))
|
|
196
|
+
connection = attached
|
|
197
|
+
|
|
198
|
+
process.stdout.write(`\nLocal: http://${host}:${port}\nTunnel: ${url}\n\n`)
|
|
199
|
+
|
|
200
|
+
void (async () => {
|
|
201
|
+
for await (const event of attached.events) {
|
|
202
|
+
if (event.type === 'disconnected') log(`Tunnel disconnected: ${event.reason}`)
|
|
203
|
+
if (event.type === 'connected') log('Tunnel connected')
|
|
204
|
+
}
|
|
205
|
+
})()
|
|
206
|
+
|
|
207
|
+
const closed = await attached.closed.then(() => undefined, (cause: unknown) => new Error('Tunnel stopped', { cause }))
|
|
208
|
+
return fail(closed ?? new Error('Tunnel stopped'))
|
|
209
|
+
}
|