zooid 0.9.0 → 0.9.1
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 +182 -142
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-LOILUENL.js → chunk-PDSJJWQW.js} +1827 -967
- package/dist/chunk-PDSJJWQW.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +9 -9
- package/src/bootstrap/configs.ts +19 -6
- package/src/bootstrap/derive.ts +3 -2
- package/src/bootstrap/identity-bootstrap.integration.test.ts +53 -0
- package/src/bootstrap/registration-url.test.ts +14 -0
- package/src/bootstrap/registration-url.ts +7 -0
- package/src/commands/dev.ts +1 -1
- package/src/commands/init/generators.test.ts +30 -12
- package/src/commands/init/generators.ts +23 -12
- package/src/commands/init/prompts.ts +9 -34
- package/src/commands/init/registry.test.ts +2 -2
- package/src/commands/init/registry.ts +9 -8
- package/src/commands/init/scaffold-roundtrip.test.ts +38 -0
- package/src/commands/init.test.ts +17 -12
- package/src/commands/init.ts +2 -4
- package/src/daemon/pull-wiring.test.ts +14 -0
- package/src/daemon/pull-wiring.ts +4 -0
- package/src/daemon/start-daemon-pull.integration.test.ts +95 -0
- package/src/daemon/start-daemon.ts +53 -6
- package/src/daemon/sync-cursors.test.ts +38 -0
- package/src/daemon/sync-cursors.ts +31 -0
- package/src/web/resolve.test.ts +2 -2
- package/src/web/resolve.ts +2 -2
- package/dist/chunk-LOILUENL.js.map +0 -1
package/src/commands/init.ts
CHANGED
|
@@ -21,9 +21,9 @@ export interface InitOptions {
|
|
|
21
21
|
preset: 'claude' | 'codex' | 'opencode'
|
|
22
22
|
/** Required for claude/codex; ignored for opencode. */
|
|
23
23
|
auth?: 'subscription' | 'api-key'
|
|
24
|
-
/**
|
|
24
|
+
/** Optional model pin. Omitted by default — the harness uses its own default. */
|
|
25
25
|
model?: string
|
|
26
|
-
/**
|
|
26
|
+
/** opencode provider id; defaults to `opencode-go` in the wizard. */
|
|
27
27
|
provider?: string
|
|
28
28
|
/** Required on api-key path; required for opencode. */
|
|
29
29
|
apiKey?: string
|
|
@@ -68,7 +68,6 @@ export async function runInit(opts: InitOptions): Promise<void> {
|
|
|
68
68
|
const writes: WriteSpec[] = []
|
|
69
69
|
|
|
70
70
|
if (opts.preset === 'claude' || opts.preset === 'codex') {
|
|
71
|
-
if (!opts.model) throw new Error(`--model is required for preset ${opts.preset}`)
|
|
72
71
|
if (!opts.auth) throw new Error('--auth (subscription|api-key) is required for claude/codex')
|
|
73
72
|
const meta = findSimplePreset(opts.preset)!
|
|
74
73
|
|
|
@@ -101,7 +100,6 @@ export async function runInit(opts: InitOptions): Promise<void> {
|
|
|
101
100
|
const isCustom = opts.provider === 'custom'
|
|
102
101
|
const providerMeta = isCustom ? undefined : findOpencodeProvider(opts.provider)
|
|
103
102
|
if (!isCustom && !providerMeta) throw new Error(`unknown opencode provider: ${opts.provider}`)
|
|
104
|
-
if (!isCustom && !opts.model) throw new Error('--model is required (paired with --provider)')
|
|
105
103
|
if (!isCustom && !opts.apiKey) throw new Error('--api-key is required for opencode')
|
|
106
104
|
|
|
107
105
|
writes.push({ path: 'zooid.yaml', content: generateZooidYaml({ preset: 'opencode' }) })
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { shouldBindHttpListener } from './pull-wiring.js'
|
|
3
|
+
|
|
4
|
+
describe('shouldBindHttpListener', () => {
|
|
5
|
+
it('binds in appservice (push) mode — homeserver pushes to the daemon', () => {
|
|
6
|
+
expect(shouldBindHttpListener('appservice')).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
it('does NOT bind in client (pull) mode — daemon polls outbound /sync', () => {
|
|
9
|
+
expect(shouldBindHttpListener('client')).toBe(false)
|
|
10
|
+
})
|
|
11
|
+
it('defaults to binding when mode is undefined', () => {
|
|
12
|
+
expect(shouldBindHttpListener(undefined)).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { startDaemon } from './start-daemon.js'
|
|
6
|
+
|
|
7
|
+
let dir: string
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
dir = mkdtempSync(join(tmpdir(), 'zooid-pull-'))
|
|
10
|
+
})
|
|
11
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
12
|
+
|
|
13
|
+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
14
|
+
|
|
15
|
+
// Permissive stub: bootstrap CS calls all succeed with a benign body; the
|
|
16
|
+
// /sync endpoint returns one page (carrying next_batch `s-1`) then idles. The
|
|
17
|
+
// idle page is *paced* (~150ms) to mimic the long-poll — SyncLoop.run() has no
|
|
18
|
+
// inter-tick delay, so an instant stub would busy-loop. This proves the daemon
|
|
19
|
+
// selects + drives the pull loop; event→runTurn dispatch is covered in ZOD064.
|
|
20
|
+
function pullFetchStub() {
|
|
21
|
+
let synced = false
|
|
22
|
+
return vi.fn(async (input: string | URL) => {
|
|
23
|
+
const url = new URL(typeof input === 'string' ? input : input.toString())
|
|
24
|
+
if (url.pathname === '/_matrix/client/v3/sync') {
|
|
25
|
+
if (synced) {
|
|
26
|
+
await delay(150)
|
|
27
|
+
return new Response(JSON.stringify({ next_batch: 's-idle', rooms: { join: {} } }), {
|
|
28
|
+
status: 200,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
synced = true
|
|
32
|
+
return new Response(JSON.stringify({ next_batch: 's-1', rooms: { join: {} } }), {
|
|
33
|
+
status: 200,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
if (url.pathname.endsWith('/account/whoami')) {
|
|
37
|
+
return new Response(JSON.stringify({ user_id: '@laptop:zoon.eco' }), { status: 200 })
|
|
38
|
+
}
|
|
39
|
+
// createRoom / register / join / send / state — benign shapes.
|
|
40
|
+
if (url.pathname.endsWith('/createRoom')) {
|
|
41
|
+
return new Response(JSON.stringify({ room_id: '!stub:zoon.eco' }), { status: 200 })
|
|
42
|
+
}
|
|
43
|
+
return new Response('{}', { status: 200 })
|
|
44
|
+
}) as unknown as typeof globalThis.fetch
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('startDaemon — matrix client (pull) mode', () => {
|
|
48
|
+
it('binds no HTTP listener, runs the sync loop, persists a cursor, stops cleanly', async () => {
|
|
49
|
+
const yamlPath = join(dir, 'zooid.yaml')
|
|
50
|
+
writeFileSync(
|
|
51
|
+
yamlPath,
|
|
52
|
+
[
|
|
53
|
+
'runtime: local',
|
|
54
|
+
'workstation: laptop',
|
|
55
|
+
'transports:',
|
|
56
|
+
' matrix:',
|
|
57
|
+
' homeserver: https://zoon.eco',
|
|
58
|
+
' mode: client',
|
|
59
|
+
' as_token: test-as-token',
|
|
60
|
+
' hs_token: test-hs-token',
|
|
61
|
+
'agents:',
|
|
62
|
+
' docs:',
|
|
63
|
+
' workdir: .',
|
|
64
|
+
' acp: { preset: claude }',
|
|
65
|
+
' matrix:',
|
|
66
|
+
' transport: matrix',
|
|
67
|
+
" rooms: ['#general']",
|
|
68
|
+
].join('\n'),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const agentsDir = join(dir, 'data', 'agents')
|
|
72
|
+
const handle = await startDaemon({
|
|
73
|
+
configPath: yamlPath,
|
|
74
|
+
installSignalHandlers: false,
|
|
75
|
+
agentsDir,
|
|
76
|
+
fetch: pullFetchStub(),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// (a) No inbound listener in pull mode.
|
|
80
|
+
expect(handle.port).toBe(0)
|
|
81
|
+
expect(handle.agentNames).toEqual(['docs'])
|
|
82
|
+
|
|
83
|
+
// (c) Cursor persisted for the agent after a sync page is consumed, beside
|
|
84
|
+
// sessions.json under <agentsDir>/<agentName>/. run() ticks async.
|
|
85
|
+
const sincePath = join(agentsDir, 'docs', 'sync-since')
|
|
86
|
+
await vi.waitFor(() => expect(existsSync(sincePath)).toBe(true), {
|
|
87
|
+
timeout: 3000,
|
|
88
|
+
interval: 50,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// (b) Clean, idempotent shutdown stops the loop (no hanging long-poll).
|
|
92
|
+
await handle.stop()
|
|
93
|
+
await handle.stop()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
startWorkforcePublisher,
|
|
25
25
|
type AgentBinding,
|
|
26
26
|
type PublisherHandle,
|
|
27
|
+
type SyncLoop,
|
|
27
28
|
} from '@zooid/transport-matrix'
|
|
28
29
|
import {
|
|
29
30
|
SpawnRegistry,
|
|
@@ -32,6 +33,8 @@ import {
|
|
|
32
33
|
} from '@zooid/context-mcp'
|
|
33
34
|
import { buildAcpRegistry } from '../build-registry.js'
|
|
34
35
|
import { prepullImages } from '../prepull-images.js'
|
|
36
|
+
import { makeSyncCursorStore } from './sync-cursors.js'
|
|
37
|
+
import { shouldBindHttpListener } from './pull-wiring.js'
|
|
35
38
|
|
|
36
39
|
export interface StartDaemonOpts {
|
|
37
40
|
configPath?: string
|
|
@@ -66,6 +69,11 @@ export interface StartDaemonOpts {
|
|
|
66
69
|
* task instead of leaving the user staring at an unmoving spinner.
|
|
67
70
|
*/
|
|
68
71
|
prepullLog?: (line: string) => void
|
|
72
|
+
/**
|
|
73
|
+
* Override the global fetch used by the daemon's MatrixClient. Test seam for
|
|
74
|
+
* driving pull mode against canned /sync responses without a homeserver.
|
|
75
|
+
*/
|
|
76
|
+
fetch?: typeof globalThis.fetch
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
export interface DaemonHandle {
|
|
@@ -149,6 +157,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
149
157
|
)
|
|
150
158
|
|
|
151
159
|
let server: ServerType | null = null
|
|
160
|
+
let syncLoops: SyncLoop[] | undefined
|
|
152
161
|
let stopped = false
|
|
153
162
|
let resolveStopped!: () => void
|
|
154
163
|
const whenStopped = new Promise<void>((r) => {
|
|
@@ -159,9 +168,17 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
159
168
|
let port: number
|
|
160
169
|
|
|
161
170
|
if (matrix) {
|
|
171
|
+
const mode = matrix.transport.mode ?? 'appservice'
|
|
172
|
+
// Pull (client) mode persists a per-agent `since` cursor for offline resume,
|
|
173
|
+
// so it needs a data dir. Push mode doesn't.
|
|
174
|
+
if (mode === 'client' && !opts.agentsDir) {
|
|
175
|
+
throw new Error('matrix client (pull) mode requires a data dir for since-cursor persistence')
|
|
176
|
+
}
|
|
177
|
+
const cursors = opts.agentsDir ? makeSyncCursorStore(opts.agentsDir) : undefined
|
|
162
178
|
const client = new MatrixClient({
|
|
163
179
|
homeserver: matrix.transport.homeserver,
|
|
164
180
|
asToken: matrix.transport.as_token,
|
|
181
|
+
...(opts.fetch ? { fetch: opts.fetch } : {}),
|
|
165
182
|
})
|
|
166
183
|
const mediaClient = new MediaClient({
|
|
167
184
|
homeserver: matrix.transport.homeserver,
|
|
@@ -191,6 +208,9 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
191
208
|
matrix.transport.user_namespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '') ||
|
|
192
209
|
new URL(matrix.transport.homeserver).hostname
|
|
193
210
|
const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
|
|
211
|
+
// Pull mode's loadSince/saveSince are keyed by MXID; the cursor store is
|
|
212
|
+
// keyed by agent name. Translate via the bindings we just built.
|
|
213
|
+
const nameByUserId = new Map(bindings.map((b) => [b.userId, b.name]))
|
|
194
214
|
const transport = createMatrixTransport({
|
|
195
215
|
agents: registry,
|
|
196
216
|
approvals,
|
|
@@ -200,13 +220,27 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
200
220
|
adminUserId: opts.adminUserId,
|
|
201
221
|
botUserId: asUserId,
|
|
202
222
|
media: mediaClient,
|
|
223
|
+
mode,
|
|
224
|
+
loadSince: (uid) => {
|
|
225
|
+
const name = nameByUserId.get(uid)
|
|
226
|
+
return name && cursors ? cursors.loadSince(name) : null
|
|
227
|
+
},
|
|
228
|
+
saveSince: (uid, since) => {
|
|
229
|
+
const name = nameByUserId.get(uid)
|
|
230
|
+
if (name && cursors) cursors.saveSince(name, since)
|
|
231
|
+
},
|
|
203
232
|
})
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
233
|
+
if (shouldBindHttpListener(mode)) {
|
|
234
|
+
const requestedPort = matrix.transport.port ?? 9000
|
|
235
|
+
// Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
|
|
236
|
+
// macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
|
|
237
|
+
// events back to host.docker.internal:<port>.
|
|
238
|
+
server = serve({ fetch: transport.app.fetch, port: requestedPort, hostname: '0.0.0.0' })
|
|
239
|
+
port = await listenAsync(server)
|
|
240
|
+
} else {
|
|
241
|
+
// Pull (client) mode runs outbound /sync loops; no inbound listener.
|
|
242
|
+
port = 0
|
|
243
|
+
}
|
|
210
244
|
const spaceLocalpart = matrix.transport.space ?? 'dev'
|
|
211
245
|
const adminUserIds = opts.adminUserId ? [opts.adminUserId] : []
|
|
212
246
|
let spaceRoomId: string | undefined
|
|
@@ -233,6 +267,14 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
233
267
|
// m.space.child while joining it.
|
|
234
268
|
await transport.bootstrap({ spaceRoomId, asUserId, adminUserIds })
|
|
235
269
|
|
|
270
|
+
// Pull mode: start the outbound /sync loops after bootstrap so the rooms
|
|
271
|
+
// each agent syncs already exist. Loops long-poll until stop().
|
|
272
|
+
syncLoops = transport.syncLoops
|
|
273
|
+
if (syncLoops?.length) {
|
|
274
|
+
for (const loop of syncLoops) void loop.run()
|
|
275
|
+
console.log(`[matrix] pull mode: ${syncLoops.length} sync loop(s) running`)
|
|
276
|
+
}
|
|
277
|
+
|
|
236
278
|
if (spaceRoomId) {
|
|
237
279
|
try {
|
|
238
280
|
const generalRoomId = await ensureDefaultChannel({
|
|
@@ -272,6 +314,11 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
272
314
|
const stop = async (): Promise<void> => {
|
|
273
315
|
if (stopped) return whenStopped
|
|
274
316
|
stopped = true
|
|
317
|
+
try {
|
|
318
|
+
if (syncLoops) for (const loop of syncLoops) loop.stop()
|
|
319
|
+
} catch {
|
|
320
|
+
// swallow
|
|
321
|
+
}
|
|
275
322
|
try {
|
|
276
323
|
if (server) await closeAsync(server)
|
|
277
324
|
} catch {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } 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 { makeSyncCursorStore } from './sync-cursors.js'
|
|
6
|
+
|
|
7
|
+
let agentsDir: string
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
agentsDir = mkdtempSync(join(tmpdir(), 'zooid-agents-'))
|
|
10
|
+
})
|
|
11
|
+
afterEach(() => rmSync(agentsDir, { recursive: true, force: true }))
|
|
12
|
+
|
|
13
|
+
describe('makeSyncCursorStore', () => {
|
|
14
|
+
it('returns null before any save (cold start)', () => {
|
|
15
|
+
const store = makeSyncCursorStore(agentsDir)
|
|
16
|
+
expect(store.loadSince('docs')).toBeNull()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('round-trips a since cursor per agent name', () => {
|
|
20
|
+
const store = makeSyncCursorStore(agentsDir)
|
|
21
|
+
store.saveSince('docs', 's42')
|
|
22
|
+
store.saveSince('triage', 's7')
|
|
23
|
+
expect(store.loadSince('docs')).toBe('s42')
|
|
24
|
+
expect(store.loadSince('triage')).toBe('s7')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('persists across store instances (simulates daemon restart)', () => {
|
|
28
|
+
makeSyncCursorStore(agentsDir).saveSince('docs', 's99')
|
|
29
|
+
expect(makeSyncCursorStore(agentsDir).loadSince('docs')).toBe('s99')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('writes sync-since beside sessions.json under <agentsDir>/<agentName>/', () => {
|
|
33
|
+
makeSyncCursorStore(agentsDir).saveSince('docs', 's1')
|
|
34
|
+
const path = join(agentsDir, 'docs', 'sync-since')
|
|
35
|
+
expect(existsSync(path)).toBe(true)
|
|
36
|
+
expect(readFileSync(path, 'utf8')).toBe('s1')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface SyncCursorStore {
|
|
5
|
+
loadSince(agentName: string): string | null
|
|
6
|
+
saveSince(agentName: string, since: string): void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* File-backed `since` cursor store: `<agentsDir>/<agentName>/sync-since`, next
|
|
11
|
+
* to the `(threadId → sessionId)` `sessions.json` buildAcpRegistry writes. Same
|
|
12
|
+
* per-agent durable-state root, so an agent's restart-surviving state lives in
|
|
13
|
+
* one folder. Agent names are validated kebab-case keys (ZOD063 isValidAgentKey),
|
|
14
|
+
* so they're safe path segments — no hashing needed.
|
|
15
|
+
*/
|
|
16
|
+
export function makeSyncCursorStore(agentsDir: string): SyncCursorStore {
|
|
17
|
+
const fileFor = (agentName: string): string => join(agentsDir, agentName, 'sync-since')
|
|
18
|
+
return {
|
|
19
|
+
loadSince(agentName) {
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(fileFor(agentName), 'utf8').trim() || null
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
saveSince(agentName, since) {
|
|
27
|
+
mkdirSync(join(agentsDir, agentName), { recursive: true })
|
|
28
|
+
writeFileSync(fileFor(agentName), since, 'utf8')
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/web/resolve.test.ts
CHANGED
|
@@ -16,7 +16,7 @@ afterEach(() => {
|
|
|
16
16
|
function makeSibling(rootDir: string): { cliRoot: string; webDist: string } {
|
|
17
17
|
const cliRoot = join(rootDir, 'zooid', 'packages', 'cli')
|
|
18
18
|
mkdirSync(cliRoot, { recursive: true })
|
|
19
|
-
const webPkg = join(rootDir, '
|
|
19
|
+
const webPkg = join(rootDir, 'zooid-clients', 'packages', 'web')
|
|
20
20
|
const webDist = join(webPkg, 'dist')
|
|
21
21
|
mkdirSync(webDist, { recursive: true })
|
|
22
22
|
writeFileSync(join(webPkg, 'package.json'), '{"name":"@zooid/web"}')
|
|
@@ -69,7 +69,7 @@ describe('ensureWebRoot', () => {
|
|
|
69
69
|
describe('webSourcePackage', () => {
|
|
70
70
|
it('returns the source package dir when sibling zooid-clients/packages/web exists', () => {
|
|
71
71
|
const { cliRoot } = makeSibling(root)
|
|
72
|
-
expect(webSourcePackage(cliRoot)).toBe(join(root, '
|
|
72
|
+
expect(webSourcePackage(cliRoot)).toBe(join(root, 'zooid-clients', 'packages', 'web'))
|
|
73
73
|
})
|
|
74
74
|
|
|
75
75
|
it('returns null outside the monorepo', () => {
|
package/src/web/resolve.ts
CHANGED
|
@@ -35,9 +35,9 @@ export async function ensureWebRoot(opts: EnsureWebRootOptions): Promise<string>
|
|
|
35
35
|
|
|
36
36
|
// Returns the path to the in-tree @zooid/web package (zooid-clients/packages/web) if the
|
|
37
37
|
// CLI is running from the monorepo source. Returns null when running from an
|
|
38
|
-
// installed package (no sibling
|
|
38
|
+
// installed package (no sibling zooid-clients/ tree).
|
|
39
39
|
export function webSourcePackage(cliRoot: string): string | null {
|
|
40
40
|
const workspaceRoot = dirname(dirname(dirname(cliRoot)))
|
|
41
|
-
const candidate = join(workspaceRoot, '
|
|
41
|
+
const candidate = join(workspaceRoot, 'zooid-clients', 'packages', 'web')
|
|
42
42
|
return existsSync(join(candidate, 'package.json')) ? candidate : null
|
|
43
43
|
}
|