kimaki 1.2.0 → 1.4.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 (63) hide show
  1. package/dist/bot.d.ts +4 -2
  2. package/dist/bot.js.map +1 -1
  3. package/dist/builtin-skills.d.ts +3 -0
  4. package/dist/builtin-skills.js +29 -0
  5. package/dist/builtin-skills.js.map +1 -1
  6. package/dist/builtin-skills.test.js +73 -1
  7. package/dist/builtin-skills.test.js.map +1 -1
  8. package/dist/cli/bot.d.ts +1 -1
  9. package/dist/cli/bot.js +15 -6
  10. package/dist/cli/bot.js.map +1 -1
  11. package/dist/cli-lock.e2e.test.js +3 -2
  12. package/dist/cli-lock.e2e.test.js.map +1 -1
  13. package/dist/commands/login-commands.d.ts +1 -1
  14. package/dist/commands/preference-commands.d.ts +1 -1
  15. package/dist/commands/worktree-commands.d.ts +1 -1
  16. package/dist/hello-thread.e2e.test.js +31 -0
  17. package/dist/hello-thread.e2e.test.js.map +1 -1
  18. package/dist/ingress.js +11 -6
  19. package/dist/ingress.js.map +1 -1
  20. package/dist/lock-routes.d.ts +14 -14
  21. package/dist/main.d.ts +2 -0
  22. package/dist/main.js +1 -0
  23. package/dist/main.js.map +1 -1
  24. package/dist/markdown/markdown.test.js +10 -0
  25. package/dist/markdown/markdown.test.js.map +1 -1
  26. package/dist/markdown/render-markdown.js +3 -1
  27. package/dist/markdown/render-markdown.js.map +1 -1
  28. package/dist/plugin/index.js +8 -2
  29. package/dist/plugin/index.js.map +1 -1
  30. package/dist/prompt.d.ts +6 -6
  31. package/dist/scheduler.d.ts +1 -1
  32. package/dist/session-events.js +9 -5
  33. package/dist/session-events.js.map +1 -1
  34. package/dist/sessions.d.ts +10 -10
  35. package/dist/sessions.js +1 -0
  36. package/dist/sessions.js.map +1 -1
  37. package/dist/system-prompt.d.ts +3 -1
  38. package/dist/system-prompt.js +52 -43
  39. package/dist/system-prompt.js.map +1 -1
  40. package/dist/tunnel.d.ts +21 -0
  41. package/dist/tunnel.js +296 -0
  42. package/dist/tunnel.js.map +1 -0
  43. package/dist/tunnel.test.d.ts +1 -0
  44. package/dist/tunnel.test.js +114 -0
  45. package/dist/tunnel.test.js.map +1 -0
  46. package/package.json +3 -3
  47. package/skills/kimaki-tunnel/SKILL.md +16 -11
  48. package/src/bot.ts +3 -0
  49. package/src/builtin-skills.test.ts +74 -1
  50. package/src/builtin-skills.ts +33 -0
  51. package/src/cli/bot.ts +13 -7
  52. package/src/cli-lock.e2e.test.ts +3 -2
  53. package/src/hello-thread.e2e.test.ts +35 -0
  54. package/src/ingress.ts +9 -5
  55. package/src/main.ts +4 -0
  56. package/src/markdown/markdown.test.ts +11 -0
  57. package/src/markdown/render-markdown.ts +3 -1
  58. package/src/plugin/index.ts +9 -2
  59. package/src/session-events.ts +8 -4
  60. package/src/sessions.ts +1 -0
  61. package/src/system-prompt.ts +56 -42
  62. package/src/tunnel.test.ts +115 -0
  63. package/src/tunnel.ts +308 -0
@@ -5,6 +5,8 @@
5
5
  import dedent from 'string-dedent'
6
6
  import { z } from 'zod'
7
7
 
8
+ import type { PromptSection } from './builtin-skills.ts'
9
+
8
10
  export const INSTRUCTION_KEY = 'kimaki'
9
11
 
10
12
  export type InstructionAgent = { name: string; description: string }
@@ -15,8 +17,8 @@ export type ScheduledRun = { id: number; cronExpr: string | null; timezone: stri
15
17
 
16
18
  const PARENT_SESSION_LINE = 'Your parent OpenCode session ID is: '
17
19
 
18
- // Every system-level instruction of Kimaki is built in this file. Sections
19
- // are joined in order; each is one dedent block.
20
+ // Every system-level instruction of Kimaki is built in this file. Parts are
21
+ // joined in order; a tagged part is static and `--disable-prompt <tag>` drops it.
20
22
  export function baseInstructions({
21
23
  sessionId,
22
24
  threadId,
@@ -28,6 +30,7 @@ export function baseInstructions({
28
30
  agents,
29
31
  parentSessionId,
30
32
  scheduledTask = null,
33
+ disabled = [],
31
34
  }: {
32
35
  sessionId: string
33
36
  threadId: string
@@ -41,13 +44,15 @@ export function baseInstructions({
41
44
  // Only from an explicit `kimaki send --parent-session`.
42
45
  parentSessionId: string | null
43
46
  scheduledTask?: ScheduledRun | null
47
+ // `kimaki --disable-prompt`: tagged static parts to leave out.
48
+ disabled?: readonly PromptSection[]
44
49
  }): string {
45
50
  const userArg = ` --user '${userId}'`
46
51
  const parentArg = ` --parent-session ${sessionId}`
47
52
  const topic = channelTopic?.trim()
48
53
  const agentList = agents.map((agent) => `- \`${agent.name}\`${agent.description ? `: ${agent.description}` : ''}`).join('\n')
49
- const sections = [
50
- dedent`
54
+ const parts: Array<[PromptSection | null, string]> = [
55
+ [null, dedent`
51
56
  The user is reading your messages from inside Discord, via kimaki.dev.
52
57
 
53
58
  ## Discord output
@@ -76,8 +81,8 @@ export function baseInstructions({
76
81
  \`description\` is shown in Discord when the shell command is longer than 50 characters.
77
82
  \`hasSideEffect\` only decides if Discord shows the shell call. Set it to \`true\` for every command the user should be aware of, for example commands that change state or that the user asked you to run (the user says "run sleep 100", so \`sleep 100\` gets \`true\`). Set \`false\` only for routine commands the user does not need to see.
78
83
  The shell environment provides the \`kimaki\` command and OPENCODE_SESSION_ID. Do not pass stale IDs copied from another session.
79
- `,
80
- dedent`
84
+ `],
85
+ [null, dedent`
81
86
  Discord channel ID: ${channelId}
82
87
  Discord thread ID: ${threadId}
83
88
  Discord guild ID: ${guildId}
@@ -94,8 +99,8 @@ export function baseInstructions({
94
99
  \`\`\`
95
100
 
96
101
  Links have the form \`https://discord.com/channels/<guild_id>/<thread_id>\`. For changes made by another session, use that session's link (see "who edited a file").
97
- `,
98
- dedent`
102
+ `],
103
+ ['discord-permissions', dedent`
99
104
  ## permissions
100
105
 
101
106
  Only users with these Discord permissions can send messages to the bot:
@@ -105,11 +110,13 @@ export function baseInstructions({
105
110
  - "Kimaki" role (case-insensitive)
106
111
 
107
112
  Other Discord bots are ignored by default. To allow another bot to trigger sessions (for multi-agent orchestration), assign it the "Kimaki" role.
108
-
113
+ `],
114
+ ['kimaki-debugging', dedent`
109
115
  ## debugging kimaki issues
110
116
 
111
117
  When sessions do not respond, the bot shows errors, or you need the Kimaki or OpenCode logs, load the \`kimaki-debug\` skill (logs, profiling, \`kimaki login\` for provider credentials) and read the logs before guessing. Do not restart the bot unless the user explicitly asks. ALWAYS read https://kimaki.dev/docs/guides/report-bugs before submitting any issue to Kimaki. Never open a pull request on remorses/kimaki unless remorses asked for one in a comment on the issue.
112
-
118
+ `],
119
+ [null, dedent`
113
120
  ## uploading files to discord
114
121
 
115
122
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
@@ -137,20 +144,23 @@ export function baseInstructions({
137
144
  \`\`\`
138
145
 
139
146
  See \`kimaki tts --help\` for options like voice, speed, and style instructions.
140
-
147
+ `],
148
+ ['file-upload-requests', dedent`
141
149
  ## requesting files from the user
142
150
 
143
151
  To ask the user to upload files from their device, write all visible text first, then run \`kimaki upload-request --prompt 'Send the files' --max-files 5\`. This shows a native file picker in Discord. Set the shell timeout to 600000 ms.
144
152
  The request waits at most 6 minutes and returns the local paths (in the session folder under \`uploads/\`) or cancelled. A new user prompt cancels the request. Do not keep waiting after cancellation.
145
153
  You MUST call it LAST, after ALL text.
146
-
154
+ `],
155
+ ['action-buttons', dedent`
147
156
  ## action buttons
148
157
 
149
158
  To show 1 to 3 buttons, write all visible text first, then run \`kimaki buttons --button 'Label'\`. Repeat \`--button\` for more. Prefer a single button whenever possible. Colors are white, blue, green, red: \`--button 'Label:green'\`.
150
159
  A click sends "User clicked: Label" to this session as a new prompt. Labels have at most 80 chars.
151
160
  You MUST call \`kimaki buttons\` LAST, after ALL text. Never call it in a turn that has no text before it. The text must explain the choice. Labels alone are not an explanation.
152
161
  The user does not see tool calls, command outputs, or subagent and task results. Before the buttons, concisely explain what was done in the session and any findings the choice depends on, then what each button does.
153
-
162
+ `],
163
+ ['session-sleep', dedent`
154
164
  ## sleeping the session
155
165
 
156
166
  Use \`kimaki sleep\` to pause this session for hours or days, then continue when the time is reached. The wake is stored in SQLite and survives bot restarts.
@@ -161,8 +171,9 @@ export function baseInstructions({
161
171
  Wake is a later message that starts with \`Woke after sleeping until\`. Only then continue the wait reason.
162
172
  To monitor something slow (email replies, PR reviews, long CI), use long waits: 2h or more, up to 1d. Never poll every few minutes. Prefer \`--until\` at a natural check time, for example the next morning.
163
173
  On a wake where nothing changed, do not post a status report. Write one short line, then sleep again with a longer wait (double it, up to 1d).
164
- ${scheduledTask ? scheduledTaskSection(scheduledTask) : ''}
165
-
174
+ `],
175
+ [null, scheduledTask ? scheduledTaskSection(scheduledTask) : ''],
176
+ [null, dedent`
166
177
  ## archiving the current thread
167
178
 
168
179
  To archive the current Discord thread (hide it from sidebar) without stopping the session, run:
@@ -170,7 +181,8 @@ export function baseInstructions({
170
181
  kimaki session archive ${threadId}
171
182
 
172
183
  Only do this when the user explicitly asks to close or archive the thread, and only after your final message.
173
-
184
+ `],
185
+ ['session-abort', dedent`
174
186
  ## aborting a session
175
187
 
176
188
  If you made a mistake with \`kimaki send\` (wrong prompt, wrong channel, mangled heredoc), abort the session immediately using the session ID printed in the output:
@@ -178,7 +190,8 @@ export function baseInstructions({
178
190
  kimaki session abort <session_id>
179
191
 
180
192
  This stops the AI from processing but keeps the thread visible in Discord. Different from \`kimaki session archive\` which hides the thread.
181
-
193
+ `],
194
+ [null, dedent`
182
195
  ## updating the session title
183
196
 
184
197
  Skip the first turn. OpenCode already auto-generates the title from the first message.
@@ -201,8 +214,8 @@ export function baseInstructions({
201
214
  kimaki user list --guild ${guildId} --query "username"
202
215
 
203
216
  This returns user IDs you can use for Discord mentions. It can fail when Server Members Intent is disabled, so prefer IDs from existing Discord metadata or raw mentions when possible.
204
- `,
205
- dedent`
217
+ `],
218
+ [null, dedent`
206
219
  ## starting new sessions from CLI
207
220
 
208
221
  Start a new thread/session in this channel with:
@@ -356,7 +369,8 @@ export function baseInstructions({
356
369
  Thread reminders: when the user says "remind me about this in 2 hours", schedule a send to this thread. \`--notify-only\` does not work with \`--thread\`; the scheduled prompt always runs in that thread's session:
357
370
 
358
371
  kimaki send --thread ${threadId} --prompt 'Reminder: you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --agent <current_agent>${userArg}
359
-
372
+ `],
373
+ [null, dedent`
360
374
  ## reading other sessions
361
375
 
362
376
  \`\`\`bash
@@ -369,7 +383,7 @@ export function baseInstructions({
369
383
 
370
384
  Each row shows the session ID, status (\`busy\`, \`waiting\` for input, or \`idle\`), the title, and \`tokens: N\`. Titles prefixed with \`btw:\` are side sessions that answer a related user question in parallel. They are not duplicate sessions of the main task.
371
385
 
372
- To search past sessions, search the transcripts. Kimaki writes one markdown file per session to \`${dataDir}/transcripts/<session id>.md\` when a run ends (user messages, assistant text and short tool inputs; no tool outputs, no thinking). The first lines are \`# <title> (<session id>)\` and \`directory: <folder>\`; messages start with \`## user <time>\` or \`## assistant <time> (<agent>, <model>)\`. Use rg (or \`grep -r\` when rg is missing):
386
+ To search past sessions, search the transcripts. Kimaki writes one markdown file per session to \`${dataDir}/transcripts/<session id>.md\` when a run ends (user messages, assistant text, thinking, tool inputs up to 5000 chars and compaction summaries; no tool outputs). The first lines are \`# <title> (<session id>)\` and \`directory: <folder>\`; messages start with \`## user <time>\` or \`## assistant <time> (<agent>, <model>)\`. Use rg (or \`grep -r\` when rg is missing):
373
387
 
374
388
  \`\`\`bash
375
389
  rg -il 'auth timeout' ${dataDir}/transcripts # sessions that mention it; the file name is the session ID
@@ -418,7 +432,8 @@ export function baseInstructions({
418
432
  Output is newest first. Each row has the **session ID** (\`ses_xxx\`), the **title** (Discord thread name, so you can tell what that session was doing), and **time ago** (when it last edited the file).
419
433
 
420
434
  Use this before committing a file this session did not edit. Get the original session's link with \`kimaki session url <session_id>\` and put it as the last line of the commit message: \`Discord: https://discord.com/channels/<guild_id>/<thread_id>\`. If that session has no Kimaki thread, use \`Session: ses_xxx\` instead. If this session edited the file, use this thread's link. If files come from different sessions, split the commit by session. Do not attribute another session's edits to this one.
421
-
435
+ `],
436
+ ['cross-project-commands', dedent`
422
437
  ## cross-project commands
423
438
 
424
439
  When the user references another project by name, run \`kimaki project list\` to find its directory and channel ID, then read files, search code, or run commands directly in that directory. If the project is not listed, register its root with \`kimaki project add /path/to/repo\` (this creates a Discord channel). Never add subfolders of an existing project.
@@ -441,7 +456,8 @@ export function baseInstructions({
441
456
  kimaki send --channel <channel_id> --prompt 'Plan how to update the API client to v2' --agent <current_agent>
442
457
  kimaki send --project /path/to/other-repo --prompt 'Plan how to bump version to 1.2.0' --agent <current_agent>
443
458
  \`\`\`
444
-
459
+ `],
460
+ ['waiting-sessions', dedent`
445
461
  ## waiting for a session to finish
446
462
 
447
463
  \`--wait\` blocks until a session completes and prints its full conversation to stdout. Use it when you need another session's result before continuing: fixing a bug in another project first, or chaining sessions where the next depends on the previous output. When the user asks you to wait for an existing session, run \`kimaki session wait <session_id>\` yourself via the shell tool and continue from the printed markdown. Do not tell the user to run it.
@@ -461,12 +477,8 @@ export function baseInstructions({
461
477
  \`\`\`
462
478
 
463
479
  \`session list --active\` exits 0 while it finds active sessions, 1 when none remain, and 64 on errors. The loop stops only on 1, so an error never looks like "no active sessions". \`--active\` never lists the calling session (\`OPENCODE_SESSION_ID\`), so the loop does not wait for itself. Sessions can start again after the loop ends, so run the check again right before each commit. \`session wait\` returns once the model finishes responding, or when the session pauses to show the user a question (it does not finish on its own until answered).
464
-
465
- ## submodules
466
-
467
- When pulling submodules and they jump to a new commit, commit that submodule pointer update right away before doing other work. Otherwise critique diffs later will include the noisy submodule jump along with the real changes.
468
- `,
469
- dedent`
480
+ `],
481
+ ['critique-diffs', dedent`
470
482
  ## showing diffs
471
483
 
472
484
  The user cannot see tool output. Share diffs as critique web URLs, never raw \`git diff\` output:
@@ -495,14 +507,13 @@ export function baseInstructions({
495
507
 
496
508
  If the changes are already committed (only commit when the user asks), show one URL per commit with \`bunx critique --commit <hash> --web\`, running the critique calls in parallel.
497
509
 
510
+ When pulling submodules and they jump to a new commit, commit that submodule pointer update right away before doing other work. Otherwise critique diffs later will include the noisy submodule jump along with the real changes.
511
+
498
512
  Users can leave line comments on a diff page (Agentation widget, bottom right). When they say they did, read them with \`curl https://critique.work/v/<id>/annotations\` (or WebFetch). It returns markdown with file, line, and comment text.
499
513
 
500
514
  critique is open source (MIT, https://github.com/remorses/critique). Diff URLs are unique, unguessable, not indexed, and ephemeral. If the user is worried about uploading code, tell them this.
501
-
502
- ## running dev servers with tunnel access
503
-
504
- Localhost URLs are useless from Discord. When the user should open a local dev server in a browser, load the \`kimaki-tunnel\` skill: it wraps the server in \`kimaki tunnel\` to get a public URL.
505
-
515
+ `],
516
+ ['markdown-formatting', dedent`
506
517
  ## markdown formatting
507
518
 
508
519
  Format responses in **Claude-style markdown** - structured, scannable, never walls of text. Use:
@@ -519,7 +530,8 @@ export function baseInstructions({
519
530
  Discord supports: headings, bold, italic, strikethrough, code blocks, inline code, quotes, lists, and links.
520
531
 
521
532
  NEVER wrap URLs in inline code or code blocks - this breaks clickability in Discord. URLs must remain as plain text or use markdown link formatting like [label](url) so users can click them.
522
-
533
+ `],
534
+ ['discord-callouts', dedent`
523
535
  ## Callouts in Kimaki Discord
524
536
 
525
537
  Use \`<callout>\` HTML blocks for important notices in Discord. Do **not** use GitHub callout syntax like \`> [!WARNING]\`, because Kimaki renders \`<callout>\` natively.
@@ -553,15 +565,18 @@ export function baseInstructions({
553
565
  - action-required notes, breaking caveats, or important limitations, use **purple** like \`#8b5cf6\`
554
566
 
555
567
  Do not wrap the whole response in callouts. Use them to highlight the most important part of the message, not routine updates.
556
-
568
+ `],
569
+ ['search-result-urls', dedent`
557
570
  ## URLs in search results
558
571
 
559
572
  When performing web searches, code searches, or any lookup that returns URLs (GitHub repos, docs, Stack Overflow, npm packages, etc.), ALWAYS include the URLs in your response so the user can click them. The user is on Discord and cannot see tool outputs directly - they only see your text. If you found a relevant link, show it. Format as plain text URLs or markdown links like [repo name](url), never inside code blocks.
560
-
573
+ `],
574
+ ['ascii-diagrams', dedent`
561
575
  ## diagrams
562
576
 
563
577
  Make heavy use of diagrams to explain architecture, flows, and relationships. Create diagrams using ASCII art inside code blocks. Prefer diagrams over lengthy text explanations whenever possible. Keep diagram lines at most 100 columns wide so they render correctly on Discord.
564
-
578
+ `],
579
+ ['action-buttons', dedent`
565
580
  ## ending conversations with options
566
581
 
567
582
  The \`question\` tool is disabled in Kimaki sessions. To offer choices, use \`kimaki buttons\` instead.
@@ -577,10 +592,10 @@ export function baseInstructions({
577
592
  - After completing edits: offer "Commit changes?"
578
593
  - If a plan has multiple strategy of implementation show these as options
579
594
  - After a genuinely ambiguous request where you cannot infer intent: offer the different approaches
580
- `,
581
- ...(topic ? [`<channel-topic>\n${topic}\n</channel-topic>`] : []),
595
+ `],
596
+ [null, topic ? `<channel-topic>\n${topic}\n</channel-topic>` : ''],
582
597
  ]
583
- return sections.join('\n\n')
598
+ return parts.filter(([tag, text]) => text !== '' && !(tag && disabled.includes(tag))).map(([, text]) => text).join('\n\n')
584
599
  }
585
600
 
586
601
  function scheduledTaskSection(task: ScheduledRun): string {
@@ -588,7 +603,6 @@ function scheduledTaskSection(task: ScheduledRun): string {
588
603
  ? `Schedule: cron \`${task.cronExpr}\` in ${task.timezone || 'UTC'}. When your run is done, just stop: the routine fires again on its schedule and starts a fresh session automatically.`
589
604
  : 'This routine runs once and does not repeat. When your run is done, just stop.'
590
605
  return dedent`
591
-
592
606
  ## routine session
593
607
 
594
608
  This session was started automatically by kimaki routine #${task.id}.
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { createServer } from 'vite'
6
+ import { expect, test } from 'vitest'
7
+ import { detectPortFromText, parseNetstatListeningPids, routeName, startHostProxy } from './tunnel.ts'
8
+
9
+ test('detects the dev server port and skips noise lines', () => {
10
+ 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'
11
+ expect({
12
+ vite: detectPortFromText(vite),
13
+ next: detectPortFromText(' - Local: http://127.0.0.1:3000'),
14
+ bare: detectPortFromText('Server running at 0.0.0.0:4321'),
15
+ inspector: detectPortFromText('Debugger listening on ws://127.0.0.1:9229/abc\nready on http://localhost:4173'),
16
+ outOfRange: detectPortFromText('listening on localhost:70000'),
17
+ portOnly: detectPortFromText('Port 5173 is in use'),
18
+ }).toMatchInlineSnapshot(`
19
+ {
20
+ "bare": 4321,
21
+ "inspector": 4173,
22
+ "next": 3000,
23
+ "outOfRange": null,
24
+ "portOnly": null,
25
+ "vite": 5174,
26
+ }
27
+ `)
28
+ })
29
+
30
+ test('route name: explicit, then tuistory session, then random', () => {
31
+ expect({
32
+ explicit: routeName({ name: 'docs', tuistorySessionId: 'abc123' }),
33
+ normalized: routeName({ name: '_My_App.dev_' }),
34
+ invalid: String(routeName({ name: '___' })),
35
+ tuistory: routeName({ tuistorySessionId: 'Holocron_Dev' }),
36
+ tuistoryInvalid: String(routeName({ tuistorySessionId: '___' })).length,
37
+ random: String(routeName({})).length,
38
+ }).toMatchInlineSnapshot(`
39
+ {
40
+ "explicit": "docs",
41
+ "invalid": "Error: Invalid tunnel ID "___": use letters, digits and dashes",
42
+ "normalized": "my-app-dev",
43
+ "random": 16,
44
+ "tuistory": "holocron-dev",
45
+ "tuistoryInvalid": 16,
46
+ }
47
+ `)
48
+ })
49
+
50
+ test('netstat listening pids on Windows', () => {
51
+ const stdout = [
52
+ 'Active Connections',
53
+ ' Proto Local Address Foreign Address State PID',
54
+ ' TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 1234',
55
+ ' TCP [::]:5173 [::]:0 LISTENING 1234',
56
+ ' TCP 127.0.0.1:51730 0.0.0.0:0 LISTENING 999',
57
+ ' TCP 127.0.0.1:5173 127.0.0.1:60000 ESTABLISHED 777',
58
+ ' UDP 0.0.0.0:5173 *:* 555',
59
+ ].join('\r\n')
60
+ expect(parseNetstatListeningPids({ stdout, port: 5173 })).toMatchInlineSnapshot(`
61
+ [
62
+ 1234,
63
+ ]
64
+ `)
65
+ })
66
+
67
+ function request({ port, headers }: { port: number; headers: http.OutgoingHttpHeaders }): Promise<string> {
68
+ return new Promise((resolve, reject) => {
69
+ const req = http.request({ host: '127.0.0.1', port, path: '/', headers })
70
+ req.on('response', (res) => {
71
+ res.resume()
72
+ resolve(String(res.statusCode))
73
+ req.destroy()
74
+ })
75
+ req.on('upgrade', (res, socket) => {
76
+ resolve(`${res.statusCode} ${res.headers['sec-websocket-protocol']}`)
77
+ socket.destroy()
78
+ })
79
+ req.on('error', reject)
80
+ req.end()
81
+ })
82
+ }
83
+
84
+ test('host proxy lets the public tunnel host through Vite host check, HMR included', async () => {
85
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-tunnel-'))
86
+ fs.writeFileSync(path.join(root, 'index.html'), '<p>hi</p>')
87
+ const vite = await createServer({ root, configFile: false, logLevel: 'silent', server: { host: '127.0.0.1', port: 0 } })
88
+ await vite.listen()
89
+ const address = vite.httpServer!.address()
90
+ if (!address || typeof address === 'string') throw new Error('no vite address')
91
+ const proxy = await startHostProxy({ host: '127.0.0.1', port: address.port })
92
+ if (proxy instanceof Error) throw proxy
93
+ const proxyAddress = proxy.address()
94
+ if (!proxyAddress || typeof proxyAddress === 'string') throw new Error('no proxy address')
95
+
96
+ const publicHost = { host: 'docs.abc.opentunnel.xyz' }
97
+ const hmr = { ...publicHost, connection: 'Upgrade', upgrade: 'websocket', 'sec-websocket-version': '13', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'sec-websocket-protocol': 'vite-hmr' }
98
+ try {
99
+ expect({
100
+ direct: await request({ port: address.port, headers: publicHost }),
101
+ proxied: await request({ port: proxyAddress.port, headers: publicHost }),
102
+ proxiedHmr: await request({ port: proxyAddress.port, headers: hmr }),
103
+ }).toMatchInlineSnapshot(`
104
+ {
105
+ "direct": "403",
106
+ "proxied": "200",
107
+ "proxiedHmr": "101 vite-hmr",
108
+ }
109
+ `)
110
+ } finally {
111
+ proxy.close()
112
+ await vite.close()
113
+ fs.rmSync(root, { recursive: true, force: true })
114
+ }
115
+ })