zooid 0.11.2 → 0.13.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 (40) hide show
  1. package/README.md +8 -3
  2. package/dist/bin.js +503 -124
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-KGYQ5YNP.js → chunk-YZ4IO5MR.js} +78 -11
  5. package/dist/chunk-YZ4IO5MR.js.map +1 -0
  6. package/dist/index.js +1 -1
  7. package/package.json +11 -9
  8. package/src/bin.test.ts +63 -0
  9. package/src/bin.ts +55 -6
  10. package/src/bootstrap/configs.test.ts +7 -0
  11. package/src/bootstrap/configs.ts +14 -0
  12. package/src/build-registry.ts +1 -1
  13. package/src/build-registry.zod044.test.ts +23 -0
  14. package/src/commands/dev.ts +37 -7
  15. package/src/commands/init/generators.test.ts +43 -0
  16. package/src/commands/init/generators.ts +24 -5
  17. package/src/commands/init/pi-scaffold.test.ts +151 -0
  18. package/src/commands/init/prompts.ts +53 -2
  19. package/src/commands/init/registry.test.ts +60 -0
  20. package/src/commands/init/registry.ts +58 -0
  21. package/src/commands/init/sniff.test.ts +61 -2
  22. package/src/commands/init/sniff.ts +43 -12
  23. package/src/commands/init.ts +91 -5
  24. package/src/commands/status.test.ts +15 -0
  25. package/src/commands/status.ts +27 -2
  26. package/src/daemon/start-daemon.ts +15 -1
  27. package/src/push-gateway/gateway.test.ts +150 -0
  28. package/src/push-gateway/gateway.ts +78 -0
  29. package/src/push-gateway/index.ts +17 -0
  30. package/src/push-gateway/payload.test.ts +88 -0
  31. package/src/push-gateway/payload.ts +39 -0
  32. package/src/push-gateway/types.ts +37 -0
  33. package/src/push-gateway/vapid.test.ts +45 -0
  34. package/src/push-gateway/vapid.ts +34 -0
  35. package/src/services/tuwunel.ts +21 -2
  36. package/src/version.test.ts +67 -0
  37. package/src/version.ts +30 -0
  38. package/src/web/static.test.ts +22 -0
  39. package/src/web/static.ts +9 -1
  40. package/dist/chunk-KGYQ5YNP.js.map +0 -1
@@ -0,0 +1,150 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const sendNotification = vi.fn()
4
+ class WebPushError extends Error {
5
+ constructor(
6
+ message: string,
7
+ public statusCode: number,
8
+ ) {
9
+ super(message)
10
+ }
11
+ }
12
+ vi.mock('web-push', () => ({
13
+ default: { sendNotification, WebPushError },
14
+ sendNotification,
15
+ WebPushError,
16
+ }))
17
+
18
+ const { pushGateway } = await import('./gateway.js')
19
+
20
+ const KEYS = { publicKey: 'pub', privateKey: 'priv' }
21
+
22
+ function device(over: Record<string, unknown> = {}) {
23
+ return {
24
+ app_id: 'dev.zooid.web',
25
+ pushkey: 'BPk_device_one',
26
+ data: { endpoint: 'https://fcm.example/one', auth: 'auth1' },
27
+ ...over,
28
+ }
29
+ }
30
+
31
+ function notification(devices: unknown[]) {
32
+ return {
33
+ notification: {
34
+ event_id: '$e:example.org',
35
+ room_id: '!r:example.org',
36
+ room_name: 'general',
37
+ sender_display_name: 'Alice',
38
+ type: 'm.room.message',
39
+ content: { msgtype: 'm.text', body: 'hello' },
40
+ counts: { unread: 1 },
41
+ devices,
42
+ },
43
+ }
44
+ }
45
+
46
+ async function notify(body: unknown) {
47
+ const app = pushGateway({ keys: KEYS, subject: 'mailto:ops@example.org' })
48
+ return app.request('/_matrix/push/v1/notify', {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify(body),
52
+ })
53
+ }
54
+
55
+ beforeEach(() => {
56
+ sendNotification.mockReset()
57
+ sendNotification.mockResolvedValue({ statusCode: 201 })
58
+ })
59
+
60
+ describe('pushGateway', () => {
61
+ it('encrypts to the device subscription reassembled from pushkey + data', async () => {
62
+ const res = await notify(notification([device()]))
63
+ expect(res.status).toBe(200)
64
+ expect(await res.json()).toEqual({ rejected: [] })
65
+
66
+ const [subscription, payload, options] = sendNotification.mock.calls[0]!
67
+ expect(subscription).toEqual({
68
+ endpoint: 'https://fcm.example/one',
69
+ keys: { p256dh: 'BPk_device_one', auth: 'auth1' },
70
+ })
71
+ expect(JSON.parse(payload as string).room_name).toBe('general')
72
+ expect((options as { vapidDetails: unknown }).vapidDetails).toEqual({
73
+ subject: 'mailto:ops@example.org',
74
+ publicKey: 'pub',
75
+ privateKey: 'priv',
76
+ })
77
+ })
78
+
79
+ it('fans out to every matching device', async () => {
80
+ await notify(
81
+ notification([
82
+ device(),
83
+ device({ pushkey: 'BPk_two', data: { endpoint: 'https://fcm.example/two', auth: 'a2' } }),
84
+ ]),
85
+ )
86
+ expect(sendNotification).toHaveBeenCalledTimes(2)
87
+ })
88
+
89
+ it('skips a foreign app_id silently — never rejects it', async () => {
90
+ const res = await notify(notification([device({ app_id: 'im.vector.app.ios' })]))
91
+ expect(sendNotification).not.toHaveBeenCalled()
92
+ // Rejecting would make the homeserver permanently delete another client's pusher.
93
+ expect(await res.json()).toEqual({ rejected: [] })
94
+ })
95
+
96
+ it('rejects a pushkey on 410 Gone so the homeserver garbage-collects it', async () => {
97
+ sendNotification.mockRejectedValueOnce(new WebPushError('gone', 410))
98
+ const res = await notify(notification([device()]))
99
+ expect(await res.json()).toEqual({ rejected: ['BPk_device_one'] })
100
+ })
101
+
102
+ it('rejects a pushkey on 404 too', async () => {
103
+ sendNotification.mockRejectedValueOnce(new WebPushError('not found', 404))
104
+ expect(await (await notify(notification([device()]))).json()).toEqual({
105
+ rejected: ['BPk_device_one'],
106
+ })
107
+ })
108
+
109
+ it('does NOT reject on a transient 429 or 5xx', async () => {
110
+ sendNotification.mockRejectedValueOnce(new WebPushError('slow down', 429))
111
+ expect(await (await notify(notification([device()]))).json()).toEqual({ rejected: [] })
112
+
113
+ sendNotification.mockRejectedValueOnce(new WebPushError('bad gateway', 502))
114
+ expect(await (await notify(notification([device()]))).json()).toEqual({ rejected: [] })
115
+ })
116
+
117
+ it('does not let one dead device stop delivery to a live one', async () => {
118
+ sendNotification
119
+ .mockRejectedValueOnce(new WebPushError('gone', 410))
120
+ .mockResolvedValueOnce({ statusCode: 201 })
121
+ const res = await notify(
122
+ notification([
123
+ device(),
124
+ device({ pushkey: 'BPk_two', data: { endpoint: 'https://fcm.example/two', auth: 'a2' } }),
125
+ ]),
126
+ )
127
+ expect(sendNotification).toHaveBeenCalledTimes(2)
128
+ expect(await res.json()).toEqual({ rejected: ['BPk_device_one'] })
129
+ })
130
+
131
+ it('skips a device missing endpoint or auth without rejecting it', async () => {
132
+ const res = await notify(notification([device({ data: { endpoint: 'https://x' } })]))
133
+ expect(sendNotification).not.toHaveBeenCalled()
134
+ expect(await res.json()).toEqual({ rejected: [] })
135
+ })
136
+
137
+ it('400s on a malformed body instead of throwing', async () => {
138
+ expect((await notify({ nope: true })).status).toBe(400)
139
+ })
140
+
141
+ it('logs receipt and outcome — the only way to see a Tuwunel call in the daemon log', async () => {
142
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
143
+ await notify(notification([device()]))
144
+ expect(log).toHaveBeenCalledWith(
145
+ expect.stringContaining('notify room=!r:example.org type=m.room.message devices=1'),
146
+ )
147
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('delivered=1 rejected=0'))
148
+ log.mockRestore()
149
+ })
150
+ })
@@ -0,0 +1,78 @@
1
+ import { Hono } from 'hono'
2
+ import webpush from 'web-push'
3
+ import { buildPushPayload, ZOOID_APP_ID } from './payload.js'
4
+ import type { PushNotification } from './types.js'
5
+ import type { VapidKeys } from './vapid.js'
6
+
7
+ export interface PushGatewayOpts {
8
+ keys: VapidKeys
9
+ /** VAPID `sub` claim — a mailto: or https: the push service can complain to. */
10
+ subject: string
11
+ }
12
+
13
+ function parseNotifyBody(raw: unknown): PushNotification | null {
14
+ if (!raw || typeof raw !== 'object') return null
15
+ const notification = (raw as { notification?: unknown }).notification
16
+ if (!notification || typeof notification !== 'object') return null
17
+ const n = notification as Record<string, unknown>
18
+ if (typeof n.event_id !== 'string' || typeof n.room_id !== 'string' || typeof n.type !== 'string')
19
+ return null
20
+ if (!Array.isArray(n.devices)) return null
21
+ return n as unknown as PushNotification
22
+ }
23
+
24
+ export function pushGateway(opts: PushGatewayOpts): Hono {
25
+ const app = new Hono()
26
+
27
+ app.post('/_matrix/push/v1/notify', async (c) => {
28
+ const parsed = parseNotifyBody(await c.req.json().catch(() => null))
29
+ if (!parsed) {
30
+ console.warn('[push] notify: malformed body')
31
+ return c.json({ error: 'malformed notification' }, 400)
32
+ }
33
+ // Logged unconditionally: this is the only way to tell "Tuwunel never
34
+ // called us" (nothing below appears at all — check ip_range_denylist)
35
+ // apart from "it called us and here's what happened."
36
+ console.log(
37
+ `[push] notify room=${parsed.room_id} type=${parsed.type} devices=${parsed.devices.length}`,
38
+ )
39
+
40
+ let delivered = 0
41
+ const rejected: string[] = []
42
+ await Promise.all(
43
+ parsed.devices.map(async (device) => {
44
+ // An app_id we don't own belongs to some other client sharing this
45
+ // gateway URL. Skipping is mandatory: naming it in `rejected` makes the
46
+ // homeserver PERMANENTLY DELETE that client's pusher (spec §4).
47
+ if (device.app_id !== ZOOID_APP_ID) return
48
+ const endpoint = device.data?.endpoint
49
+ const auth = device.data?.auth
50
+ if (typeof endpoint !== 'string' || typeof auth !== 'string') return
51
+
52
+ try {
53
+ await webpush.sendNotification(
54
+ { endpoint, keys: { p256dh: device.pushkey, auth } },
55
+ JSON.stringify(buildPushPayload(parsed, device)),
56
+ {
57
+ vapidDetails: { subject: opts.subject, ...opts.keys },
58
+ TTL: 60 * 60 * 12,
59
+ urgency: device.tweaks?.sound !== undefined ? 'high' : 'normal',
60
+ },
61
+ )
62
+ delivered++
63
+ } catch (err) {
64
+ const status = (err as { statusCode?: number }).statusCode
65
+ // 404/410 are the ONLY statuses that mean "this device is gone".
66
+ // 429 and 5xx are transient; rejecting on those deletes live pushers.
67
+ if (status === 404 || status === 410) rejected.push(device.pushkey)
68
+ else console.warn(`[push] delivery to ${device.pushkey} failed (${status ?? '?'}):`, err)
69
+ }
70
+ }),
71
+ )
72
+
73
+ console.log(`[push] notify done: delivered=${delivered} rejected=${rejected.length}`)
74
+ return c.json({ rejected })
75
+ })
76
+
77
+ return app
78
+ }
@@ -0,0 +1,17 @@
1
+ import type { Hono } from 'hono'
2
+ import { pushGateway } from './gateway.js'
3
+ import { loadOrCreateVapidKeys } from './vapid.js'
4
+
5
+ export interface MountPushGatewayOpts {
6
+ /** Directory the VAPID keypair is persisted under (`<dataDir>/vapid.json`). */
7
+ dataDir: string
8
+ /** VAPID `sub` claim — a mailto: or https: the push service can complain to. */
9
+ subject: string
10
+ }
11
+
12
+ /** Mount the push gateway's routes onto an existing Hono app. */
13
+ export function mountPushGateway(app: Hono, opts: MountPushGatewayOpts): { publicKey: string } {
14
+ const keys = loadOrCreateVapidKeys(opts.dataDir)
15
+ app.route('/', pushGateway({ keys, subject: opts.subject }))
16
+ return { publicKey: keys.publicKey }
17
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildPushPayload, MAX_BODY } from './payload.js'
3
+ import type { PushNotification } from './types.js'
4
+
5
+ const base: PushNotification = {
6
+ event_id: '$evt:example.org',
7
+ room_id: '!room:example.org',
8
+ room_name: 'general',
9
+ sender_display_name: 'Alice',
10
+ type: 'm.room.message',
11
+ content: { msgtype: 'm.text', body: 'hello there' },
12
+ counts: { unread: 3 },
13
+ devices: [],
14
+ }
15
+
16
+ describe('buildPushPayload', () => {
17
+ it('carries the fields the service worker renders', () => {
18
+ expect(buildPushPayload(base)).toEqual({
19
+ event_id: '$evt:example.org',
20
+ room_id: '!room:example.org',
21
+ room_name: 'general',
22
+ sender_display_name: 'Alice',
23
+ type: 'm.room.message',
24
+ body: 'hello there',
25
+ unread: 3,
26
+ sound: false,
27
+ })
28
+ })
29
+
30
+ it("forwards turn.end's last_message as the preview the worker renders", () => {
31
+ const out = buildPushPayload({
32
+ ...base,
33
+ type: 'dev.zooid.turn.end',
34
+ content: { body: 'claude finished', last_message: 'the deploy is green' },
35
+ })
36
+ expect(out.preview).toBe('the deploy is green')
37
+ })
38
+
39
+ it('truncates a long preview too — push payloads are size-capped', () => {
40
+ const out = buildPushPayload({
41
+ ...base,
42
+ type: 'dev.zooid.turn.end',
43
+ content: { body: 'claude finished', last_message: 'y'.repeat(MAX_BODY + 50) },
44
+ })
45
+ expect(out.preview!.length).toBe(MAX_BODY)
46
+ expect(out.preview!.endsWith('…')).toBe(true)
47
+ })
48
+
49
+ it('omits preview when the event carries no last_message', () => {
50
+ expect(buildPushPayload(base).preview).toBeUndefined()
51
+ })
52
+
53
+ it('truncates a long body rather than shipping the whole message', () => {
54
+ const long = 'x'.repeat(MAX_BODY + 50)
55
+ const out = buildPushPayload({ ...base, content: { msgtype: 'm.text', body: long } })
56
+ expect(out.body!.length).toBe(MAX_BODY)
57
+ expect(out.body!.endsWith('…')).toBe(true)
58
+ })
59
+
60
+ it('omits body for an agent event that carries no prose', () => {
61
+ const out = buildPushPayload({
62
+ ...base,
63
+ type: 'dev.zooid.turn.end',
64
+ content: { produced_output: true },
65
+ })
66
+ expect(out.type).toBe('dev.zooid.turn.end')
67
+ expect(out.body).toBeUndefined()
68
+ })
69
+
70
+ it('sets sound from the push rule tweak, not from the event type', () => {
71
+ const device = { app_id: 'dev.zooid.web', pushkey: 'pk', tweaks: { sound: 'default' } }
72
+ const out = buildPushPayload({ ...base, type: 'dev.zooid.turn.end', content: {} }, device)
73
+ expect(out.sound).toBe(true)
74
+ })
75
+
76
+ it('tolerates a notification with no counts and no display name', () => {
77
+ const out = buildPushPayload({
78
+ event_id: '$e',
79
+ room_id: '!r:example.org',
80
+ type: 'm.room.message',
81
+ content: { msgtype: 'm.text', body: 'hi' },
82
+ devices: [],
83
+ })
84
+ expect(out.unread).toBe(0)
85
+ expect(out.room_name).toBe('!r:example.org')
86
+ expect(out.sender_display_name).toBeUndefined()
87
+ })
88
+ })
@@ -0,0 +1,39 @@
1
+ import type { PushDevice, PushNotification, PushPayload } from './types.js'
2
+
3
+ export const MAX_BODY = 140
4
+ export const ZOOID_APP_ID = 'dev.zooid.web'
5
+
6
+ function truncate(s: string, max: number): string {
7
+ return s.length > max ? s.slice(0, max - 1) + '…' : s
8
+ }
9
+
10
+ /**
11
+ * Trim a homeserver notification down to what the service worker renders.
12
+ *
13
+ * Full content, not `event_id_only`: rooms are unencrypted, so the homeserver
14
+ * already holds plaintext and the gateway is ours. `event_id_only` would force
15
+ * the worker to hold an access token and fetch each event before it could show
16
+ * anything (spec §7). RFC 8291 means the browser vendor sees ciphertext either
17
+ * way.
18
+ */
19
+ export function buildPushPayload(n: PushNotification, device?: PushDevice): PushPayload {
20
+ const body =
21
+ typeof n.content?.body === 'string' ? truncate(n.content.body as string, MAX_BODY) : undefined
22
+ const preview =
23
+ typeof n.content?.last_message === 'string'
24
+ ? truncate(n.content.last_message as string, MAX_BODY)
25
+ : undefined
26
+ return {
27
+ event_id: n.event_id,
28
+ room_id: n.room_id,
29
+ room_name: n.room_name ?? n.room_id,
30
+ ...(n.sender_display_name !== undefined ? { sender_display_name: n.sender_display_name } : {}),
31
+ type: n.type,
32
+ ...(body !== undefined ? { body } : {}),
33
+ ...(preview !== undefined ? { preview } : {}),
34
+ unread: n.counts?.unread ?? 0,
35
+ // Whether this makes a noise is a push-rule property the server evaluated,
36
+ // not a second decision made here (spec §12).
37
+ sound: device?.tweaks?.sound !== undefined,
38
+ }
39
+ }
@@ -0,0 +1,37 @@
1
+ /** Mirrors the Matrix Push Gateway API notify request body — the subset the gateway reads. */
2
+
3
+ export interface PushDevice {
4
+ app_id: string
5
+ pushkey: string
6
+ data?: {
7
+ endpoint?: string
8
+ auth?: string
9
+ }
10
+ tweaks?: {
11
+ sound?: string
12
+ }
13
+ }
14
+
15
+ export interface PushNotification {
16
+ event_id: string
17
+ room_id: string
18
+ room_name?: string
19
+ sender_display_name?: string
20
+ type: string
21
+ content?: Record<string, unknown>
22
+ counts?: { unread?: number }
23
+ devices: PushDevice[]
24
+ }
25
+
26
+ export interface PushPayload {
27
+ event_id: string
28
+ room_id: string
29
+ room_name: string
30
+ sender_display_name?: string
31
+ type: string
32
+ body?: string
33
+ /** The agent's final message on a turn.end — the prose itself never pushes. */
34
+ preview?: string
35
+ unread: number
36
+ sound: boolean
37
+ }
@@ -0,0 +1,45 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { tmpdir } from 'node:os'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { loadOrCreateVapidKeys, VAPID_FILENAME } from './vapid.js'
6
+
7
+ let dir: string
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(join(tmpdir(), 'zooid-vapid-'))
11
+ })
12
+ afterEach(() => {
13
+ rmSync(dir, { recursive: true, force: true })
14
+ })
15
+
16
+ describe('loadOrCreateVapidKeys', () => {
17
+ it('generates a keypair on first call and persists it', () => {
18
+ const keys = loadOrCreateVapidKeys(dir)
19
+ expect(keys.publicKey).toMatch(/^[A-Za-z0-9_-]+$/)
20
+ expect(keys.privateKey).toMatch(/^[A-Za-z0-9_-]+$/)
21
+ // base64url-encoded uncompressed P-256 point: 65 bytes → 87 chars
22
+ expect(keys.publicKey.length).toBe(87)
23
+
24
+ const onDisk = JSON.parse(readFileSync(join(dir, VAPID_FILENAME), 'utf8'))
25
+ expect(onDisk).toEqual({ publicKey: keys.publicKey, privateKey: keys.privateKey })
26
+ })
27
+
28
+ it('is stable across calls — regenerating would invalidate every subscription', () => {
29
+ const first = loadOrCreateVapidKeys(dir)
30
+ const second = loadOrCreateVapidKeys(dir)
31
+ expect(second).toEqual(first)
32
+ })
33
+
34
+ it('creates the directory when it does not exist yet', () => {
35
+ const nested = join(dir, 'a', 'b')
36
+ const keys = loadOrCreateVapidKeys(nested)
37
+ expect(readFileSync(join(nested, VAPID_FILENAME), 'utf8')).toContain(keys.publicKey)
38
+ })
39
+
40
+ it('regenerates rather than throwing when the file is corrupt', () => {
41
+ writeFileSync(join(dir, VAPID_FILENAME), 'not json')
42
+ const keys = loadOrCreateVapidKeys(dir)
43
+ expect(keys.publicKey.length).toBe(87)
44
+ })
45
+ })
@@ -0,0 +1,34 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import webpush from 'web-push'
4
+
5
+ export const VAPID_FILENAME = 'vapid.json'
6
+
7
+ export interface VapidKeys {
8
+ publicKey: string
9
+ privateKey: string
10
+ }
11
+
12
+ /**
13
+ * Load the daemon's VAPID keypair, generating it on first call.
14
+ *
15
+ * Regenerating invalidates every existing browser subscription — every device
16
+ * has to re-enable notifications by hand. It is a one-way door, so this never
17
+ * regenerates over a readable file; only over a missing or corrupt one, and it
18
+ * says so on the way past.
19
+ */
20
+ export function loadOrCreateVapidKeys(dataDir: string): VapidKeys {
21
+ const path = join(dataDir, VAPID_FILENAME)
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<VapidKeys>
24
+ if (typeof parsed.publicKey === 'string' && typeof parsed.privateKey === 'string')
25
+ return { publicKey: parsed.publicKey, privateKey: parsed.privateKey }
26
+ console.warn(`[push] ${path} is malformed; generating a new VAPID keypair.`)
27
+ } catch {
28
+ // Missing file on first start is the normal path — say nothing.
29
+ }
30
+ const keys = webpush.generateVAPIDKeys()
31
+ mkdirSync(dataDir, { recursive: true })
32
+ writeFileSync(path, JSON.stringify(keys, null, 2), { mode: 0o600 })
33
+ return keys
34
+ }
@@ -52,9 +52,28 @@ export class TuwunelService {
52
52
  async stop(): Promise<void> {
53
53
  // Foregrounded container: kill the engine process; --rm cleans up after.
54
54
  if (this.child && this.child.exitCode === null) {
55
- this.child.kill('SIGTERM')
55
+ const child = this.child
56
+ child.kill('SIGTERM')
57
+ // Bounded: `docker stop` below is the real cleanup and is a no-op if the
58
+ // container is already gone, so a wedged engine process must not be able
59
+ // to hang shutdown forever. Measured ~450ms for a healthy Tuwunel.
56
60
  await new Promise<void>((resolve) => {
57
- this.child!.on('exit', () => resolve())
61
+ let done = false
62
+ const finish = (): void => {
63
+ if (done) return
64
+ done = true
65
+ clearTimeout(timer)
66
+ resolve()
67
+ }
68
+ const timer = setTimeout(() => {
69
+ try {
70
+ child.kill('SIGKILL')
71
+ } catch {
72
+ // already gone
73
+ }
74
+ finish()
75
+ }, 5000)
76
+ child.once('exit', finish)
58
77
  })
59
78
  }
60
79
  this.child = null
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+ import { readFileSync } from 'node:fs'
7
+ import { CLI_VERSION, readCliVersion } from './version.js'
8
+
9
+ const manifestVersion = (
10
+ JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
11
+ version: string
12
+ }
13
+ ).version
14
+
15
+ describe('readCliVersion', () => {
16
+ it('reports the version from the package manifest', () => {
17
+ expect(readCliVersion()).toBe(manifestVersion)
18
+ })
19
+
20
+ // The regression this exists for: `cli.version()` was a hardcoded '0.0.1'
21
+ // from the first release through 0.12.0, so every published build claimed
22
+ // to be a version that was never released.
23
+ it('is not a hardcoded placeholder', () => {
24
+ expect(CLI_VERSION).toBe(manifestVersion)
25
+ expect(CLI_VERSION).not.toBe('0.0.1')
26
+ expect(CLI_VERSION).toMatch(/^\d+\.\d+\.\d+/)
27
+ })
28
+
29
+ // dist/bin.js and src/bin.ts are the same depth from package.json, so the
30
+ // single '../package.json' has to work from either. Simulate the built
31
+ // bundle's location rather than trusting that they stay in step.
32
+ it('resolves from a sibling directory the way dist/ does', () => {
33
+ const root = mkdtempSync(join(tmpdir(), 'zooid-version-'))
34
+ try {
35
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ version: '9.9.9' }))
36
+ const fromDist = pathToFileURL(join(root, 'dist', 'bin.js')).href
37
+ expect(readCliVersion(fromDist)).toBe('9.9.9')
38
+ const fromSrc = pathToFileURL(join(root, 'src', 'bin.ts')).href
39
+ expect(readCliVersion(fromSrc)).toBe('9.9.9')
40
+ } finally {
41
+ rmSync(root, { recursive: true, force: true })
42
+ }
43
+ })
44
+
45
+ it('falls back to "unknown" rather than throwing when the manifest is missing', () => {
46
+ const root = mkdtempSync(join(tmpdir(), 'zooid-version-'))
47
+ try {
48
+ const url = pathToFileURL(join(root, 'dist', 'bin.js')).href
49
+ expect(readCliVersion(url)).toBe('unknown')
50
+ } finally {
51
+ rmSync(root, { recursive: true, force: true })
52
+ }
53
+ })
54
+
55
+ it('falls back to "unknown" on a manifest with no usable version', () => {
56
+ const root = mkdtempSync(join(tmpdir(), 'zooid-version-'))
57
+ try {
58
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'zooid' }))
59
+ const url = pathToFileURL(join(root, 'dist', 'bin.js')).href
60
+ expect(readCliVersion(url)).toBe('unknown')
61
+ writeFileSync(join(root, 'package.json'), '{not json')
62
+ expect(readCliVersion(url)).toBe('unknown')
63
+ } finally {
64
+ rmSync(root, { recursive: true, force: true })
65
+ }
66
+ })
67
+ })
package/src/version.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ /**
4
+ * The CLI's own version, read from the package manifest at startup.
5
+ *
6
+ * Never hardcode this. A literal goes stale on the very next release, and
7
+ * `zooid --version` then lies about which build is installed — which is
8
+ * exactly what it did from the first release through 0.12.0, where it
9
+ * reported `0.0.1` forever.
10
+ *
11
+ * `dist/bin.js` (the published bundle) and `src/bin.ts` (a source run under
12
+ * tsx) sit at the same depth relative to package.json, so a single
13
+ * `../package.json` works for both without a build-time define.
14
+ *
15
+ * `url` is overridable for tests.
16
+ */
17
+ export function readCliVersion(url: string = import.meta.url): string {
18
+ try {
19
+ const manifest = new URL('../package.json', url)
20
+ const raw = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: unknown }
21
+ if (typeof raw.version === 'string' && raw.version) return raw.version
22
+ return 'unknown'
23
+ } catch {
24
+ // A missing or malformed manifest must never stop the CLI from running —
25
+ // `--version` is the least important thing it does.
26
+ return 'unknown'
27
+ }
28
+ }
29
+
30
+ export const CLI_VERSION = readCliVersion()
@@ -48,4 +48,26 @@ describe('webStatic', () => {
48
48
  const r = await app.request('/assets/nope.js')
49
49
  expect(r.status).toBe(404)
50
50
  })
51
+
52
+ it('serves push_gateway_url and vapid_public_key when configured', async () => {
53
+ const app = webStatic({
54
+ webRoot: dir,
55
+ homeserverUrl: 'http://localhost:8448',
56
+ pushGatewayUrl: 'http://host.docker.internal:9000/_matrix/push/v1/notify',
57
+ vapidPublicKey: 'BPk',
58
+ })
59
+ const res = await app.request('/config.json')
60
+ expect(await res.json()).toEqual({
61
+ homeserver_url: 'http://localhost:8448',
62
+ push_gateway_url: 'http://host.docker.internal:9000/_matrix/push/v1/notify',
63
+ vapid_public_key: 'BPk',
64
+ })
65
+ })
66
+
67
+ it('omits both when unconfigured, so the client falls back instead of half-subscribing', async () => {
68
+ const app = webStatic({ webRoot: dir, homeserverUrl: 'http://localhost:8448' })
69
+ expect(await (await app.request('/config.json')).json()).toEqual({
70
+ homeserver_url: 'http://localhost:8448',
71
+ })
72
+ })
51
73
  })
package/src/web/static.ts CHANGED
@@ -5,6 +5,8 @@ import { Hono } from 'hono'
5
5
  export interface WebStaticOpts {
6
6
  webRoot: string
7
7
  homeserverUrl: string
8
+ pushGatewayUrl?: string
9
+ vapidPublicKey?: string
8
10
  }
9
11
 
10
12
  const MIME: Record<string, string> = {
@@ -28,7 +30,13 @@ function isAssetPath(p: string): boolean {
28
30
  export function webStatic(opts: WebStaticOpts): Hono {
29
31
  const app = new Hono()
30
32
 
31
- app.get('/config.json', (c) => c.json({ homeserver_url: opts.homeserverUrl }))
33
+ app.get('/config.json', (c) =>
34
+ c.json({
35
+ homeserver_url: opts.homeserverUrl,
36
+ ...(opts.pushGatewayUrl ? { push_gateway_url: opts.pushGatewayUrl } : {}),
37
+ ...(opts.vapidPublicKey ? { vapid_public_key: opts.vapidPublicKey } : {}),
38
+ }),
39
+ )
32
40
 
33
41
  app.get('*', (c) => {
34
42
  const url = new URL(c.req.url)