zooid 0.7.3 → 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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  buildAcpRegistry
4
- } from "./chunk-3IKBBKGI.js";
4
+ } from "./chunk-R6EDLH23.js";
5
5
  export {
6
6
  buildAcpRegistry
7
7
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zooid",
3
- "version": "0.7.3",
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.7.3",
41
- "@zooid/acp-client": "^0.7.3",
42
- "@zooid/transport-http": "^0.7.3",
43
- "@zooid/runtime-docker": "^0.7.3",
44
- "@zooid/transport-matrix": "^0.7.3",
45
- "@zooid/runtime-local": "^0.7.3",
46
- "@zooid/core": "^0.7.3"
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/web. Path defaults to sibling ../zoon/packages/web.',
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({
@@ -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 { resolveWebRoot, webSourcePackage } from '../web/resolve.js'
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/web package and serve its dist.
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/web watcher (vite build --watch)',
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/web not found at ${defaultPath}.\n` +
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/web on http://localhost:${flags.uiPort}`,
235
- task: () => {
236
- const webRoot = ctx.webWatch?.distPath ?? resolveWebRoot(CLI_ROOT)
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/web)`]
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
+ })
@@ -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
+ }
@@ -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 { resolveWebRoot, webSourcePackage } from './resolve.js'
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(() => rmSync(root, { recursive: true, force: true }))
12
-
13
- describe('resolveWebRoot', () => {
14
- it('returns dist/web when present (published-package layout)', () => {
15
- const distWeb = join(root, 'dist', 'web')
16
- mkdirSync(distWeb, { recursive: true })
17
- writeFileSync(join(distWeb, 'index.html'), '<html/>')
18
- expect(resolveWebRoot(root)).toBe(distWeb)
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('falls back to ../../zoon/packages/web/dist when running from source', () => {
22
- const cliRoot = join(root, 'zooid', 'packages', 'cli')
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 webPkg = join(root, 'zoon', 'packages', 'web')
25
- const webDist = join(webPkg, 'dist')
26
- mkdirSync(webDist, { recursive: true })
27
- writeFileSync(join(webPkg, 'package.json'), '{"name":"@zoon/web"}')
28
- writeFileSync(join(webDist, 'index.html'), '<html/>')
29
- expect(resolveWebRoot(cliRoot)).toBe(webDist)
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 a helpful error when neither layout has been built', () => {
33
- expect(() => resolveWebRoot(root)).toThrow(/@zoon\/web.*build/i)
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 = join(root, 'zooid', 'packages', 'cli')
40
- mkdirSync(cliRoot, { recursive: true })
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', () => {