zooid 0.7.4 → 0.8.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/bin.js +214 -109
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-OKGNOROJ.js → chunk-R6EDLH23.js} +269 -9
- package/dist/chunk-R6EDLH23.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +12 -8
- package/src/bin.ts +1 -1
- package/src/commands/dev.ts +23 -9
- package/src/daemon/start-daemon.ts +20 -1
- package/src/web/fetch.integration.test.ts +61 -0
- package/src/web/fetch.test.ts +139 -0
- package/src/web/fetch.ts +85 -0
- package/src/web/pin.test.ts +24 -0
- package/src/web/pin.ts +13 -0
- package/src/web/resolve.test.ts +54 -26
- package/src/web/resolve.ts +22 -10
- package/src/web/test-helpers.ts +28 -0
- package/dist/chunk-OKGNOROJ.js.map +0 -1
- package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/dist/web/assets/index-BqOX0Pv4.js +0 -305
- package/dist/web/assets/index-C-ZtBp7U.css +0 -1
- package/dist/web/assets/index-Dc8BJYf_.js +0 -118066
- package/dist/web/assets/reaction-picker-emoji-xD2HkULN.js +0 -716
- package/dist/web/favicon.svg +0 -1
- package/dist/web/index.html +0 -14
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zooid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Open-source tool for defining and running AI agents alongside you and your team. Any model, any CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
|
+
"zooid": {
|
|
28
|
+
"zoonWebVersion": "0.1.0"
|
|
29
|
+
},
|
|
27
30
|
"engines": {
|
|
28
31
|
"node": ">=22"
|
|
29
32
|
},
|
|
@@ -36,14 +39,15 @@
|
|
|
36
39
|
"listr2": "^8.2.5",
|
|
37
40
|
"marked": "^18.0.4",
|
|
38
41
|
"sanitize-html": "^2.17.4",
|
|
42
|
+
"tar": "^7.5.16",
|
|
39
43
|
"yaml": "^2.5.0",
|
|
40
|
-
"@zooid/context-mcp": "^0.
|
|
41
|
-
"@zooid/
|
|
42
|
-
"@zooid/
|
|
43
|
-
"@zooid/
|
|
44
|
-
"@zooid/
|
|
45
|
-
"@zooid/runtime-
|
|
46
|
-
"@zooid/
|
|
44
|
+
"@zooid/context-mcp": "^0.8.0",
|
|
45
|
+
"@zooid/core": "^0.8.0",
|
|
46
|
+
"@zooid/acp-client": "^0.8.0",
|
|
47
|
+
"@zooid/transport-http": "^0.8.0",
|
|
48
|
+
"@zooid/transport-matrix": "^0.8.0",
|
|
49
|
+
"@zooid/runtime-docker": "^0.8.0",
|
|
50
|
+
"@zooid/runtime-local": "^0.8.0"
|
|
47
51
|
},
|
|
48
52
|
"devDependencies": {
|
|
49
53
|
"@agentclientprotocol/sdk": "^0.21.0",
|
package/src/bin.ts
CHANGED
|
@@ -33,7 +33,7 @@ cli
|
|
|
33
33
|
.option('--admin-password <pw>', 'Admin password', { default: 'admin' })
|
|
34
34
|
.option(
|
|
35
35
|
'--watch-web [path]',
|
|
36
|
-
'Run vite build --watch on @zoon
|
|
36
|
+
'Run vite build --watch on @zooid/zoon-web. Path defaults to sibling ../zoon/packages/web.',
|
|
37
37
|
)
|
|
38
38
|
.action(async (flags) => {
|
|
39
39
|
await runDev({
|
package/src/commands/dev.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk'
|
|
2
2
|
import { Listr } from 'listr2'
|
|
3
3
|
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
-
import { dirname, resolve } from 'node:path'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import { serve, type ServerType } from '@hono/node-server'
|
|
7
7
|
import {
|
|
@@ -17,7 +17,8 @@ import { resolvePaths } from '../bootstrap/paths.js'
|
|
|
17
17
|
import { ensureTokens, type Tokens } from '../bootstrap/tokens.js'
|
|
18
18
|
import { startDaemon, type DaemonHandle } from '../daemon/start-daemon.js'
|
|
19
19
|
import { TuwunelService } from '../services/tuwunel.js'
|
|
20
|
-
import {
|
|
20
|
+
import { ensureWebRoot, webSourcePackage } from '../web/resolve.js'
|
|
21
|
+
import { readZoonWebPin } from '../web/pin.js'
|
|
21
22
|
import { webStatic } from '../web/static.js'
|
|
22
23
|
import { startWebWatch, type WebWatchHandle } from '../web/watch.js'
|
|
23
24
|
import {
|
|
@@ -66,7 +67,7 @@ export interface DevFlags {
|
|
|
66
67
|
adminPassword: string
|
|
67
68
|
installSignalHandlers?: boolean
|
|
68
69
|
foreground?: boolean
|
|
69
|
-
// Run `vite build --watch` against a @zoon
|
|
70
|
+
// Run `vite build --watch` against a @zooid/zoon-web package and serve its dist.
|
|
70
71
|
// true = auto-detect (sibling ../zoon/packages/web or in-monorepo); string = explicit path.
|
|
71
72
|
watchWeb?: string | boolean
|
|
72
73
|
}
|
|
@@ -202,6 +203,10 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
202
203
|
installSignalHandlers: false,
|
|
203
204
|
adminUserId: `@${flags.adminUser}:${shape.serverName}`,
|
|
204
205
|
agentsDir: layout.agentsDir,
|
|
206
|
+
// Local-only homeserver: make the workforce space publicly joinable
|
|
207
|
+
// so a self-service-registered dev account lands in #<space> from
|
|
208
|
+
// the web client without needing an invite.
|
|
209
|
+
publicWorkforceSpace: true,
|
|
205
210
|
onTap: (agentName, event) => captures[agentName]?.onTap(event),
|
|
206
211
|
prepullLog: (line) => {
|
|
207
212
|
t.output = line.replace(/^\[zooid\]\s+/, '').trim()
|
|
@@ -212,7 +217,7 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
212
217
|
...(flags.watchWeb
|
|
213
218
|
? [
|
|
214
219
|
{
|
|
215
|
-
title: 'Start @zoon
|
|
220
|
+
title: 'Start @zooid/zoon-web watcher (vite build --watch)',
|
|
216
221
|
task: async (): Promise<void> => {
|
|
217
222
|
const pkgDir =
|
|
218
223
|
typeof flags.watchWeb === 'string'
|
|
@@ -221,7 +226,7 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
221
226
|
if (!pkgDir) {
|
|
222
227
|
const defaultPath = dirname(dirname(dirname(CLI_ROOT))) + '/zoon/packages/web'
|
|
223
228
|
throw new Error(
|
|
224
|
-
`--watch-web: @zoon
|
|
229
|
+
`--watch-web: @zooid/zoon-web not found at ${defaultPath}.\n` +
|
|
225
230
|
`Pass an explicit path: --watch-web=/path/to/zoon/packages/web`,
|
|
226
231
|
)
|
|
227
232
|
}
|
|
@@ -231,9 +236,18 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
231
236
|
]
|
|
232
237
|
: []),
|
|
233
238
|
{
|
|
234
|
-
title: `Serve @zoon
|
|
235
|
-
task: () => {
|
|
236
|
-
const webRoot =
|
|
239
|
+
title: `Serve @zooid/zoon-web on http://localhost:${flags.uiPort}`,
|
|
240
|
+
task: async (_, t) => {
|
|
241
|
+
const webRoot =
|
|
242
|
+
ctx.webWatch?.distPath ??
|
|
243
|
+
(await ensureWebRoot({
|
|
244
|
+
cliRoot: CLI_ROOT,
|
|
245
|
+
cacheDir: join(layout.dataRoot, 'web'),
|
|
246
|
+
version: readZoonWebPin(CLI_ROOT),
|
|
247
|
+
onProgress: (msg) => {
|
|
248
|
+
t.output = msg
|
|
249
|
+
},
|
|
250
|
+
}))
|
|
237
251
|
const app = webStatic({ webRoot, homeserverUrl: homeserver })
|
|
238
252
|
ctx.uiServer = serve({ fetch: app.fetch, port: flags.uiPort })
|
|
239
253
|
},
|
|
@@ -290,7 +304,7 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
|
|
|
290
304
|
` ${chalk.cyan('admin user:')} ${flags.adminUser} / ${flags.adminPassword}`,
|
|
291
305
|
` ${chalk.cyan('data dir:')} ${layout.dataRoot}`,
|
|
292
306
|
...(ctx.webWatch
|
|
293
|
-
? [` ${chalk.cyan('web watcher:')} live (vite build --watch on @zoon
|
|
307
|
+
? [` ${chalk.cyan('web watcher:')} live (vite build --watch on @zooid/zoon-web)`]
|
|
294
308
|
: []),
|
|
295
309
|
'',
|
|
296
310
|
chalk.dim('Press Ctrl-C to stop.'),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { mkdir, unlink } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
|
-
import { dirname, join } from 'node:path'
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
5
5
|
import type { AddressInfo } from 'node:net'
|
|
6
6
|
import { serve, type ServerType } from '@hono/node-server'
|
|
7
7
|
import {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { createApp } from '@zooid/transport-http'
|
|
18
18
|
import {
|
|
19
19
|
MatrixClient,
|
|
20
|
+
MediaClient,
|
|
20
21
|
createMatrixTransport,
|
|
21
22
|
ensureDefaultChannel,
|
|
22
23
|
ensureWorkforceSpace,
|
|
@@ -51,6 +52,13 @@ export interface StartDaemonOpts {
|
|
|
51
52
|
noPrepull?: boolean
|
|
52
53
|
/** Force a re-pull of every resolved image at startup (no inspect check). */
|
|
53
54
|
refreshImages?: boolean
|
|
55
|
+
/**
|
|
56
|
+
* Create the workforce space publicly joinable instead of invite-only. Set
|
|
57
|
+
* only by `zooid dev`: the local homeserver is never deployed, so a
|
|
58
|
+
* self-service-registered account can join `#<space>` from the web client
|
|
59
|
+
* without an invite. Production (`zooid start`) leaves this off.
|
|
60
|
+
*/
|
|
61
|
+
publicWorkforceSpace?: boolean
|
|
54
62
|
/**
|
|
55
63
|
* Per-line progress hook for the image-prepull pass. Called once per
|
|
56
64
|
* top-level summary plus once per image (start + completion). Used by
|
|
@@ -155,6 +163,11 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
155
163
|
homeserver: matrix.transport.homeserver,
|
|
156
164
|
asToken: matrix.transport.as_token,
|
|
157
165
|
})
|
|
166
|
+
const mediaClient = new MediaClient({
|
|
167
|
+
homeserver: matrix.transport.homeserver,
|
|
168
|
+
asToken: matrix.transport.as_token,
|
|
169
|
+
})
|
|
170
|
+
const isContainerRuntime = config.runtime !== 'local'
|
|
158
171
|
const bindings: AgentBinding[] = []
|
|
159
172
|
for (const a of Object.values(config.agents)) {
|
|
160
173
|
if (a.matrix?.transport !== matrix.name) continue
|
|
@@ -165,6 +178,10 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
165
178
|
trigger: a.matrix.trigger,
|
|
166
179
|
}
|
|
167
180
|
if (a.matrix.display_name !== undefined) binding.displayName = a.matrix.display_name
|
|
181
|
+
// Resolve workspace dirs for media attachment routing.
|
|
182
|
+
const workspaceDir = isAbsolute(a.workdir) ? a.workdir : resolve(configDir, a.workdir)
|
|
183
|
+
binding.workspaceDir = workspaceDir
|
|
184
|
+
binding.agentWorkspacePath = isContainerRuntime ? '/workspace' : workspaceDir
|
|
168
185
|
bindings.push(binding)
|
|
169
186
|
}
|
|
170
187
|
const transport = createMatrixTransport({
|
|
@@ -174,6 +191,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
174
191
|
bindings,
|
|
175
192
|
hsToken: matrix.transport.hs_token,
|
|
176
193
|
adminUserId: opts.adminUserId,
|
|
194
|
+
media: mediaClient,
|
|
177
195
|
})
|
|
178
196
|
const requestedPort = matrix.transport.port ?? 9000
|
|
179
197
|
// Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
|
|
@@ -200,6 +218,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
200
218
|
spaceLocalpart,
|
|
201
219
|
preset: 'public_chat',
|
|
202
220
|
admins: adminUserIds,
|
|
221
|
+
joinRule: opts.publicWorkforceSpace ? 'public' : 'invite',
|
|
203
222
|
})
|
|
204
223
|
console.log(`[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`)
|
|
205
224
|
} catch (err) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
2
|
+
import { createServer, type Server } from 'node:http'
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { type AddressInfo } from 'node:net'
|
|
7
|
+
import { fetchWebBundle } from './fetch.js'
|
|
8
|
+
import { makeBundleTgz } from './test-helpers.js'
|
|
9
|
+
|
|
10
|
+
let server: Server
|
|
11
|
+
let base = ''
|
|
12
|
+
const { tgz, integrity } = makeBundleTgz({
|
|
13
|
+
'package/package.json': '{"name":"@zooid/zoon-web"}',
|
|
14
|
+
'package/dist/index.html': '<html>integration</html>',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
server = createServer((req, res) => {
|
|
19
|
+
if (req.url === '/@zooid/zoon-web') {
|
|
20
|
+
res.setHeader('content-type', 'application/json')
|
|
21
|
+
res.end(
|
|
22
|
+
JSON.stringify({
|
|
23
|
+
versions: {
|
|
24
|
+
'0.1.0': {
|
|
25
|
+
dist: { tarball: `${base}/@zooid/zoon-web/-/zoon-web-0.1.0.tgz`, integrity },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
if (req.url === '/@zooid/zoon-web/-/zoon-web-0.1.0.tgz') {
|
|
33
|
+
res.setHeader('content-type', 'application/octet-stream')
|
|
34
|
+
res.end(tgz)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
res.statusCode = 404
|
|
38
|
+
res.end()
|
|
39
|
+
})
|
|
40
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r))
|
|
41
|
+
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterAll(() => new Promise<void>((r) => server.close(() => r())))
|
|
45
|
+
|
|
46
|
+
describe('fetchWebBundle over real HTTP', () => {
|
|
47
|
+
it('fetches, verifies, extracts, and serves from cache on rerun', async () => {
|
|
48
|
+
const cacheDir = mkdtempSync(join(tmpdir(), 'zooid-webint-'))
|
|
49
|
+
try {
|
|
50
|
+
const root = await fetchWebBundle({ version: '0.1.0', cacheDir, registryUrl: base })
|
|
51
|
+
expect(readFileSync(join(root, 'index.html'), 'utf8')).toBe('<html>integration</html>')
|
|
52
|
+
|
|
53
|
+
const serverPort = (server.address() as AddressInfo).port
|
|
54
|
+
const newBase = `http://127.0.0.1:${serverPort}`
|
|
55
|
+
const again = await fetchWebBundle({ version: '0.1.0', cacheDir, registryUrl: newBase })
|
|
56
|
+
expect(again).toBe(root)
|
|
57
|
+
} finally {
|
|
58
|
+
rmSync(cacheDir, { recursive: true, force: true })
|
|
59
|
+
}
|
|
60
|
+
}, 30_000)
|
|
61
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, existsSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { fetchWebBundle } from './fetch.js'
|
|
6
|
+
import { makeBundleTgz } from './test-helpers.js'
|
|
7
|
+
|
|
8
|
+
function registryFetch(opts: { tgz: Buffer; integrity: string; version?: string }) {
|
|
9
|
+
const version = opts.version ?? '0.1.0'
|
|
10
|
+
return vi.fn(async (url: string | URL) => {
|
|
11
|
+
const u = String(url)
|
|
12
|
+
if (u === 'https://registry.npmjs.org/@zooid/zoon-web') {
|
|
13
|
+
return new Response(
|
|
14
|
+
JSON.stringify({
|
|
15
|
+
'dist-tags': { latest: version },
|
|
16
|
+
versions: {
|
|
17
|
+
[version]: {
|
|
18
|
+
dist: {
|
|
19
|
+
tarball: `https://registry.npmjs.org/@zooid/zoon-web/-/zoon-web-${version}.tgz`,
|
|
20
|
+
integrity: opts.integrity,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
{ status: 200 },
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
if (u.endsWith(`/zoon-web-${version}.tgz`)) {
|
|
29
|
+
return new Response(new Uint8Array(opts.tgz), { status: 200 })
|
|
30
|
+
}
|
|
31
|
+
return new Response('not found', { status: 404 })
|
|
32
|
+
}) as unknown as typeof fetch
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('fetchWebBundle', () => {
|
|
36
|
+
let cacheDir: string
|
|
37
|
+
const setup = () => (cacheDir = mkdtempSync(join(tmpdir(), 'zooid-webcache-')))
|
|
38
|
+
const teardown = () => rmSync(cacheDir, { recursive: true, force: true })
|
|
39
|
+
|
|
40
|
+
it('downloads, verifies, extracts dist/ into <cacheDir>/<version>', async () => {
|
|
41
|
+
setup()
|
|
42
|
+
try {
|
|
43
|
+
const { tgz, integrity } = makeBundleTgz()
|
|
44
|
+
const f = registryFetch({ tgz, integrity })
|
|
45
|
+
const root = await fetchWebBundle({ version: '0.1.0', cacheDir, fetch: f })
|
|
46
|
+
expect(root).toBe(join(cacheDir, '0.1.0'))
|
|
47
|
+
expect(readFileSync(join(root, 'index.html'), 'utf8')).toBe('<html>zoon</html>')
|
|
48
|
+
expect(readFileSync(join(root, 'assets', 'app.js'), 'utf8')).toBe('// app')
|
|
49
|
+
} finally {
|
|
50
|
+
teardown()
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns the cache on a second call without touching the network', async () => {
|
|
55
|
+
setup()
|
|
56
|
+
try {
|
|
57
|
+
const { tgz, integrity } = makeBundleTgz()
|
|
58
|
+
const f = registryFetch({ tgz, integrity })
|
|
59
|
+
await fetchWebBundle({ version: '0.1.0', cacheDir, fetch: f })
|
|
60
|
+
const fCold = vi.fn() as unknown as typeof fetch
|
|
61
|
+
const root = await fetchWebBundle({ version: '0.1.0', cacheDir, fetch: fCold })
|
|
62
|
+
expect(root).toBe(join(cacheDir, '0.1.0'))
|
|
63
|
+
expect(fCold).not.toHaveBeenCalled()
|
|
64
|
+
} finally {
|
|
65
|
+
teardown()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('rejects on integrity mismatch and leaves no version dir behind', async () => {
|
|
70
|
+
setup()
|
|
71
|
+
try {
|
|
72
|
+
const { tgz } = makeBundleTgz()
|
|
73
|
+
const f = registryFetch({ tgz, integrity: 'sha512-' + 'A'.repeat(88) })
|
|
74
|
+
await expect(fetchWebBundle({ version: '0.1.0', cacheDir, fetch: f })).rejects.toThrow(
|
|
75
|
+
/integrity/i,
|
|
76
|
+
)
|
|
77
|
+
expect(existsSync(join(cacheDir, '0.1.0'))).toBe(false)
|
|
78
|
+
} finally {
|
|
79
|
+
teardown()
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('fails with an actionable error when the pinned version is not on the registry', async () => {
|
|
84
|
+
setup()
|
|
85
|
+
try {
|
|
86
|
+
const { tgz, integrity } = makeBundleTgz()
|
|
87
|
+
const f = registryFetch({ tgz, integrity, version: '0.0.9' })
|
|
88
|
+
await expect(fetchWebBundle({ version: '0.1.0', cacheDir, fetch: f })).rejects.toThrow(
|
|
89
|
+
/@zooid\/zoon-web@0\.1\.0/,
|
|
90
|
+
)
|
|
91
|
+
} finally {
|
|
92
|
+
teardown()
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('names the cache path and the env override when the network is down', async () => {
|
|
97
|
+
setup()
|
|
98
|
+
try {
|
|
99
|
+
const f = vi.fn(async () => {
|
|
100
|
+
throw new TypeError('fetch failed')
|
|
101
|
+
}) as unknown as typeof fetch
|
|
102
|
+
await expect(fetchWebBundle({ version: '0.1.0', cacheDir, fetch: f })).rejects.toThrow(
|
|
103
|
+
/ZOOID_DEV_WEB_ROOT_OVERRIDE/,
|
|
104
|
+
)
|
|
105
|
+
} finally {
|
|
106
|
+
teardown()
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('prunes other version dirs after a successful fetch', async () => {
|
|
111
|
+
setup()
|
|
112
|
+
try {
|
|
113
|
+
const stale = join(cacheDir, '0.0.9')
|
|
114
|
+
mkdirSync(stale, { recursive: true })
|
|
115
|
+
writeFileSync(join(stale, 'index.html'), 'old')
|
|
116
|
+
const { tgz, integrity } = makeBundleTgz()
|
|
117
|
+
await fetchWebBundle({ version: '0.1.0', cacheDir, fetch: registryFetch({ tgz, integrity }) })
|
|
118
|
+
expect(readdirSync(cacheDir).sort()).toEqual(['0.1.0'])
|
|
119
|
+
} finally {
|
|
120
|
+
teardown()
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('does not treat a half-extracted dir as a cache hit', async () => {
|
|
125
|
+
setup()
|
|
126
|
+
try {
|
|
127
|
+
// a version dir without the completion marker = crashed mid-extract
|
|
128
|
+
const partial = join(cacheDir, '0.1.0')
|
|
129
|
+
mkdirSync(partial, { recursive: true })
|
|
130
|
+
const { tgz, integrity } = makeBundleTgz()
|
|
131
|
+
const f = registryFetch({ tgz, integrity })
|
|
132
|
+
const root = await fetchWebBundle({ version: '0.1.0', cacheDir, fetch: f })
|
|
133
|
+
expect(readFileSync(join(root, 'index.html'), 'utf8')).toBe('<html>zoon</html>')
|
|
134
|
+
expect(f).toHaveBeenCalled()
|
|
135
|
+
} finally {
|
|
136
|
+
teardown()
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
})
|
package/src/web/fetch.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, rmSync, existsSync, readdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
4
|
+
import * as tar from 'tar'
|
|
5
|
+
|
|
6
|
+
const PKG = '@zooid/zoon-web'
|
|
7
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
|
|
8
|
+
|
|
9
|
+
export interface FetchWebBundleOptions {
|
|
10
|
+
version: string
|
|
11
|
+
cacheDir: string
|
|
12
|
+
registryUrl?: string
|
|
13
|
+
fetch?: typeof globalThis.fetch
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ensure <cacheDir>/<version> holds the extracted dist/ of @zooid/zoon-web.
|
|
18
|
+
* Atomic: extracts into a temp dir and renames, so a crash never leaves a
|
|
19
|
+
* half-populated version dir. A populated version dir (index.html present)
|
|
20
|
+
* is the completion marker.
|
|
21
|
+
*/
|
|
22
|
+
export async function fetchWebBundle(opts: FetchWebBundleOptions): Promise<string> {
|
|
23
|
+
const target = join(opts.cacheDir, opts.version)
|
|
24
|
+
if (existsSync(join(target, 'index.html'))) return target
|
|
25
|
+
if (existsSync(target)) rmSync(target, { recursive: true, force: true }) // crashed extract
|
|
26
|
+
|
|
27
|
+
const f = opts.fetch ?? globalThis.fetch
|
|
28
|
+
const registry = (opts.registryUrl ?? DEFAULT_REGISTRY).replace(/\/$/, '')
|
|
29
|
+
let tgz: Buffer
|
|
30
|
+
try {
|
|
31
|
+
const metaRes = await f(`${registry}/${PKG}`)
|
|
32
|
+
if (!metaRes.ok) throw new Error(`registry metadata: HTTP ${metaRes.status}`)
|
|
33
|
+
const meta = (await metaRes.json()) as {
|
|
34
|
+
versions?: Record<string, { dist?: { tarball?: string; integrity?: string } }>
|
|
35
|
+
}
|
|
36
|
+
const dist = meta.versions?.[opts.version]?.dist
|
|
37
|
+
if (!dist?.tarball || !dist.integrity) {
|
|
38
|
+
throw new Error(`${PKG}@${opts.version} not found on the registry`)
|
|
39
|
+
}
|
|
40
|
+
const tgzRes = await f(dist.tarball)
|
|
41
|
+
if (!tgzRes.ok) throw new Error(`tarball: HTTP ${tgzRes.status}`)
|
|
42
|
+
tgz = Buffer.from(await tgzRes.arrayBuffer())
|
|
43
|
+
const sha = 'sha512-' + createHash('sha512').update(tgz).digest('base64')
|
|
44
|
+
if (sha !== dist.integrity) {
|
|
45
|
+
throw new Error(`integrity mismatch for ${PKG}@${opts.version}: got ${sha}`)
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Failed to fetch ${PKG}@${opts.version} from ${registry}.\n` +
|
|
50
|
+
` Cache: ${opts.cacheDir}\n` +
|
|
51
|
+
` Offline? Point ZOOID_DEV_WEB_ROOT_OVERRIDE at a built dist as a manual escape hatch.\n` +
|
|
52
|
+
` Cause: ${err instanceof Error ? err.message : String(err)}`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const tmp = join(opts.cacheDir, `.tmp-${randomUUID().slice(0, 8)}`)
|
|
57
|
+
mkdirSync(tmp, { recursive: true })
|
|
58
|
+
const tgzPath = join(tmp, '.bundle.tgz')
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(tgzPath, tgz)
|
|
61
|
+
// node-tar handles gunzip, pax headers, and long names itself, and its
|
|
62
|
+
// defaults refuse absolute paths and `..` traversal.
|
|
63
|
+
await tar.extract({
|
|
64
|
+
file: tgzPath,
|
|
65
|
+
cwd: tmp,
|
|
66
|
+
strip: 2, // package/dist/<file> → <file>
|
|
67
|
+
filter: (p) => p === 'package/dist' || p.startsWith('package/dist/'),
|
|
68
|
+
})
|
|
69
|
+
rmSync(tgzPath)
|
|
70
|
+
if (!existsSync(join(tmp, 'index.html'))) {
|
|
71
|
+
throw new Error(`${PKG}@${opts.version} tarball has no dist/index.html`)
|
|
72
|
+
}
|
|
73
|
+
renameSync(tmp, target)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
76
|
+
throw err
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const entry of readdirSync(opts.cacheDir)) {
|
|
80
|
+
if (entry !== opts.version) {
|
|
81
|
+
rmSync(join(opts.cacheDir, entry), { recursive: true, force: true })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return target
|
|
85
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { readZoonWebPin } from './pin.js'
|
|
6
|
+
|
|
7
|
+
let dir: string
|
|
8
|
+
beforeEach(() => (dir = mkdtempSync(join(tmpdir(), 'zooid-pin-'))))
|
|
9
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
10
|
+
|
|
11
|
+
describe('readZoonWebPin', () => {
|
|
12
|
+
it('reads zooid.zoonWebVersion from the cli package.json', () => {
|
|
13
|
+
writeFileSync(
|
|
14
|
+
join(dir, 'package.json'),
|
|
15
|
+
JSON.stringify({ name: 'zooid', zooid: { zoonWebVersion: '0.1.0' } }),
|
|
16
|
+
)
|
|
17
|
+
expect(readZoonWebPin(dir)).toBe('0.1.0')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns undefined when the field is absent', () => {
|
|
21
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'zooid' }))
|
|
22
|
+
expect(readZoonWebPin(dir)).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
})
|
package/src/web/pin.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export function readZoonWebPin(cliRoot: string): string | undefined {
|
|
5
|
+
try {
|
|
6
|
+
const pkg = JSON.parse(readFileSync(join(cliRoot, 'package.json'), 'utf8')) as {
|
|
7
|
+
zooid?: { zoonWebVersion?: string }
|
|
8
|
+
}
|
|
9
|
+
return pkg.zooid?.zoonWebVersion
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/web/resolve.test.ts
CHANGED
|
@@ -1,47 +1,75 @@
|
|
|
1
1
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
-
import {
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ensureWebRoot, webSourcePackage } from './resolve.js'
|
|
6
6
|
|
|
7
7
|
let root: string
|
|
8
8
|
beforeEach(() => {
|
|
9
9
|
root = mkdtempSync(join(tmpdir(), 'zooid-resolve-'))
|
|
10
10
|
})
|
|
11
|
-
afterEach(() =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
rmSync(root, { recursive: true, force: true })
|
|
13
|
+
delete process.env.ZOOID_DEV_WEB_ROOT_OVERRIDE
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function makeSibling(rootDir: string): { cliRoot: string; webDist: string } {
|
|
17
|
+
const cliRoot = join(rootDir, 'zooid', 'packages', 'cli')
|
|
18
|
+
mkdirSync(cliRoot, { recursive: true })
|
|
19
|
+
const webPkg = join(rootDir, 'zoon', 'packages', 'web')
|
|
20
|
+
const webDist = join(webPkg, 'dist')
|
|
21
|
+
mkdirSync(webDist, { recursive: true })
|
|
22
|
+
writeFileSync(join(webPkg, 'package.json'), '{"name":"@zooid/zoon-web"}')
|
|
23
|
+
writeFileSync(join(webDist, 'index.html'), '<html/>')
|
|
24
|
+
return { cliRoot, webDist }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('ensureWebRoot', () => {
|
|
28
|
+
it('env override wins over everything and skips the fetch', async () => {
|
|
29
|
+
const { cliRoot } = makeSibling(root)
|
|
30
|
+
const override = join(root, 'override')
|
|
31
|
+
mkdirSync(override, { recursive: true })
|
|
32
|
+
writeFileSync(join(override, 'index.html'), '<html/>')
|
|
33
|
+
process.env.ZOOID_DEV_WEB_ROOT_OVERRIDE = override
|
|
34
|
+
const fetchBundle = vi.fn()
|
|
35
|
+
const out = await ensureWebRoot({ cliRoot, cacheDir: join(root, 'cache'), version: '0.1.0', fetchBundle })
|
|
36
|
+
expect(out).toBe(override)
|
|
37
|
+
expect(fetchBundle).not.toHaveBeenCalled()
|
|
19
38
|
})
|
|
20
39
|
|
|
21
|
-
it('
|
|
22
|
-
const cliRoot =
|
|
40
|
+
it('prefers the monorepo sibling dist over the cache (contributor path)', async () => {
|
|
41
|
+
const { cliRoot, webDist } = makeSibling(root)
|
|
42
|
+
const fetchBundle = vi.fn()
|
|
43
|
+
const out = await ensureWebRoot({ cliRoot, cacheDir: join(root, 'cache'), version: '0.1.0', fetchBundle })
|
|
44
|
+
expect(out).toBe(webDist)
|
|
45
|
+
expect(fetchBundle).not.toHaveBeenCalled()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('falls back to fetchBundle outside the monorepo (installed-package path)', async () => {
|
|
49
|
+
const cliRoot = join(root, 'lonely', 'cli')
|
|
23
50
|
mkdirSync(cliRoot, { recursive: true })
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
const cached = join(root, 'cache', '0.1.0')
|
|
52
|
+
const fetchBundle = vi.fn(async () => cached)
|
|
53
|
+
const out = await ensureWebRoot({ cliRoot, cacheDir: join(root, 'cache'), version: '0.1.0', fetchBundle })
|
|
54
|
+
expect(out).toBe(cached)
|
|
55
|
+
expect(fetchBundle).toHaveBeenCalledWith(
|
|
56
|
+
expect.objectContaining({ version: '0.1.0', cacheDir: join(root, 'cache') }),
|
|
57
|
+
)
|
|
30
58
|
})
|
|
31
59
|
|
|
32
|
-
it('throws
|
|
33
|
-
|
|
60
|
+
it('throws an actionable error when no version pin is available outside the monorepo', async () => {
|
|
61
|
+
const cliRoot = join(root, 'lonely', 'cli')
|
|
62
|
+
mkdirSync(cliRoot, { recursive: true })
|
|
63
|
+
await expect(
|
|
64
|
+
ensureWebRoot({ cliRoot, cacheDir: join(root, 'cache'), version: undefined, fetchBundle: vi.fn() }),
|
|
65
|
+
).rejects.toThrow(/zoonWebVersion/)
|
|
34
66
|
})
|
|
35
67
|
})
|
|
36
68
|
|
|
37
69
|
describe('webSourcePackage', () => {
|
|
38
70
|
it('returns the source package dir when sibling zoon/packages/web exists', () => {
|
|
39
|
-
const cliRoot =
|
|
40
|
-
|
|
41
|
-
const webPkg = join(root, 'zoon', 'packages', 'web')
|
|
42
|
-
mkdirSync(webPkg, { recursive: true })
|
|
43
|
-
writeFileSync(join(webPkg, 'package.json'), '{"name":"@zoon/web"}')
|
|
44
|
-
expect(webSourcePackage(cliRoot)).toBe(webPkg)
|
|
71
|
+
const { cliRoot } = makeSibling(root)
|
|
72
|
+
expect(webSourcePackage(cliRoot)).toBe(join(root, 'zoon', 'packages', 'web'))
|
|
45
73
|
})
|
|
46
74
|
|
|
47
75
|
it('returns null outside the monorepo', () => {
|