@astrale-os/cli 0.8.1-alpha.7 → 1.0.0-beta.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 (64) hide show
  1. package/README.md +15 -3
  2. package/dist/astrale.js +86 -35
  3. package/package.json +5 -5
  4. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  5. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  6. package/src/commands/__tests__/install-direct.test.ts +3 -2
  7. package/src/commands/domain/install.ts +78 -15
  8. package/src/connection/__tests__/errors.test.ts +82 -6
  9. package/src/connection/command.ts +9 -1
  10. package/src/connection/errors.ts +19 -8
  11. package/src/connection/index.ts +1 -1
  12. package/src/connection/reasons.ts +26 -12
  13. package/src/program/__tests__/program.test.ts +1 -1
  14. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  15. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  16. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  17. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  18. package/studio/client/dist/index.html +2 -2
  19. package/studio/package.json +1 -1
  20. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  21. package/studio/server/agent/prompts/anchors.ts +85 -15
  22. package/studio/server/agent/prompts/system.test.ts +12 -0
  23. package/studio/server/agent/prompts/system.ts +6 -6
  24. package/studio/server/api.ts +1 -5
  25. package/studio/server/cache.ts +5 -2
  26. package/studio/server/domain.test.ts +67 -0
  27. package/studio/server/domain.ts +29 -6
  28. package/studio/server/index.ts +1 -3
  29. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  30. package/studio/server/introspect/anatomy-extras.ts +340 -8
  31. package/studio/server/introspect/anatomy.test.ts +33 -0
  32. package/studio/server/introspect/anatomy.ts +22 -7
  33. package/studio/server/introspect/bundle.ts +7 -1
  34. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  35. package/studio/server/introspect/canonical-schema.ts +751 -0
  36. package/studio/server/introspect/core-extractor.ts +30 -10
  37. package/studio/server/introspect/core.ts +4 -2
  38. package/studio/server/introspect/diff.test.ts +124 -0
  39. package/studio/server/introspect/diff.ts +252 -23
  40. package/studio/server/introspect/extractor.ts +46 -14
  41. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  42. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  43. package/studio/server/introspect/overlay.test.ts +72 -0
  44. package/studio/server/introspect/overlay.ts +16 -6
  45. package/studio/server/introspect/runtime.test.ts +217 -0
  46. package/studio/server/introspect/runtime.ts +18 -6
  47. package/studio/server/introspect/schema-refs.test.ts +100 -0
  48. package/studio/server/introspect/schema-refs.ts +23 -2
  49. package/studio/server/state/baseline.test.ts +51 -0
  50. package/studio/server/state/baseline.ts +31 -2
  51. package/studio/server/state/create.test.ts +37 -0
  52. package/studio/server/state/create.ts +21 -15
  53. package/studio/server/state/instance.test.ts +63 -0
  54. package/studio/server/state/instance.ts +65 -29
  55. package/studio/server/state/views.test.ts +209 -9
  56. package/studio/server/state/views.ts +176 -69
  57. package/studio/server/watch.test.ts +36 -0
  58. package/studio/server/watch.ts +24 -16
  59. package/studio/server/workspace-watch.ts +8 -3
  60. package/studio/shared/types.ts +164 -33
  61. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  62. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  63. package/studio/server/view-dev-server.test.ts +0 -111
  64. package/studio/server/view-dev-server.ts +0 -372
@@ -1,111 +0,0 @@
1
- import { afterEach, describe, expect, test } from 'bun:test'
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
- import { tmpdir } from 'node:os'
4
- import { join } from 'node:path'
5
-
6
- import { invalidateClientPackage } from './client-package'
7
- import { ViewDevServerManager } from './view-dev-server'
8
-
9
- const roots: string[] = []
10
- const managers: ViewDevServerManager[] = []
11
-
12
- function domainFixture(name: string, packageDir = 'client'): string {
13
- const root = mkdtempSync(join(tmpdir(), `studio-view-server-${name}-`))
14
- const client = join(root, packageDir)
15
- roots.push(root)
16
- mkdirSync(client, { recursive: true })
17
- writeFileSync(
18
- join(client, 'package.json'),
19
- JSON.stringify({ private: true, scripts: { 'dev:hmr': 'bun server.ts' } }),
20
- )
21
- writeFileSync(
22
- join(client, 'server.ts'),
23
- `const args = process.argv.slice(2)
24
- const port = Number(args[args.indexOf('--port') + 1])
25
- const server = Bun.serve({ hostname: '127.0.0.1', port, fetch: () => new Response(${JSON.stringify(name)}) })
26
- const stop = () => { server.stop(true); process.exit(0) }
27
- process.on('SIGTERM', stop)
28
- process.on('SIGINT', stop)
29
- `,
30
- )
31
- return root
32
- }
33
-
34
- afterEach(async () => {
35
- await Promise.all(managers.splice(0).map((manager) => manager.shutdown()))
36
- for (const root of roots.splice(0)) {
37
- invalidateClientPackage(root)
38
- rmSync(root, { recursive: true, force: true })
39
- }
40
- })
41
-
42
- describe('view dev server lifecycle', () => {
43
- test('runs domains lazily on distinct OS-assigned ports, reuses them, and tears them down', async () => {
44
- const manager = new ViewDevServerManager({
45
- idleTimeoutMs: 250,
46
- startTimeoutMs: 5_000,
47
- failureRetryMs: 50,
48
- })
49
- managers.push(manager)
50
- const alpha = domainFixture('alpha')
51
- const beta = domainFixture('beta')
52
-
53
- expect(manager.status(alpha)).toBeNull()
54
- const [alphaServer, sameAlphaServer, betaServer] = await Promise.all([
55
- manager.ensure(alpha),
56
- manager.ensure(alpha),
57
- manager.ensure(beta),
58
- ])
59
- expect(alphaServer).toMatchObject({ status: 'running' })
60
- expect(betaServer).toMatchObject({ status: 'running' })
61
- if (alphaServer.status !== 'running' || betaServer.status !== 'running') return
62
- expect(sameAlphaServer.status === 'running' ? sameAlphaServer.port : null).toBe(
63
- alphaServer.port,
64
- )
65
- expect(alphaServer.port).not.toBe(betaServer.port)
66
- expect(await fetch(alphaServer.url).then((response) => response.text())).toBe('alpha')
67
- expect(await fetch(betaServer.url).then((response) => response.text())).toBe('beta')
68
-
69
- const reused = await manager.ensure(alpha)
70
- expect(reused.status === 'running' ? reused.port : null).toBe(alphaServer.port)
71
-
72
- await Bun.sleep(600)
73
- expect(manager.status(alpha)).toBeNull()
74
- expect(manager.status(beta)).toBeNull()
75
-
76
- const restarted = await manager.ensure(alpha)
77
- expect(restarted.status).toBe('running')
78
- if (restarted.status === 'running') {
79
- expect(await fetch(restarted.url).then((response) => response.text())).toBe('alpha')
80
- }
81
- await manager.shutdown()
82
- expect(manager.status(alpha)).toBeNull()
83
- }, 15_000)
84
-
85
- test('returns a useful unavailable state when the domain has no HMR script', async () => {
86
- const manager = new ViewDevServerManager({ idleTimeoutMs: 250 })
87
- managers.push(manager)
88
- const root = domainFixture('missing-script')
89
- writeFileSync(join(root, 'client', 'package.json'), JSON.stringify({ scripts: {} }))
90
-
91
- expect(await manager.ensure(root)).toEqual({
92
- status: 'unavailable',
93
- reason: 'This domain has no package that defines a dev:hmr script.',
94
- })
95
- })
96
-
97
- test('starts the sole frontend preview package without adapter metadata', async () => {
98
- const manager = new ViewDevServerManager({ idleTimeoutMs: 250, startTimeoutMs: 5_000 })
99
- managers.push(manager)
100
- const root = domainFixture('configured-frontend', 'frontend')
101
-
102
- const server = await manager.ensure(root)
103
-
104
- expect(server).toMatchObject({ status: 'running' })
105
- if (server.status === 'running') {
106
- expect(await fetch(server.url).then((response) => response.text())).toBe(
107
- 'configured-frontend',
108
- )
109
- }
110
- }, 10_000)
111
- })
@@ -1,372 +0,0 @@
1
- import { spawn, type ChildProcess } from 'node:child_process'
2
- import { createServer } from 'node:net'
3
- import { resolve } from 'node:path'
4
-
5
- import type { ViewDevServerStatus } from '../shared/types'
6
-
7
- import { resolveClientPackage } from './client-package'
8
-
9
- const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000
10
- const DEFAULT_START_TIMEOUT_MS = 20_000
11
- const DEFAULT_FAILURE_RETRY_MS = 10_000
12
- const STOP_GRACE_MS = 2_000
13
- const MAX_OUTPUT = 8_000
14
-
15
- interface DevServerRecord {
16
- root: string
17
- port: number
18
- url: string
19
- child: ChildProcess
20
- output: string
21
- startedAt: string
22
- lastUsedAt: number
23
- ready: boolean
24
- idleTimer?: ReturnType<typeof setTimeout>
25
- stopping: boolean
26
- stopPromise?: Promise<void>
27
- }
28
-
29
- interface FailedStart {
30
- status: Extract<ViewDevServerStatus, { status: 'failed' }>
31
- retryAt: number
32
- }
33
-
34
- interface ManagerOptions {
35
- idleTimeoutMs?: number
36
- startTimeoutMs?: number
37
- failureRetryMs?: number
38
- }
39
-
40
- /**
41
- * Owns one lazy Vite process per domain. Ports are leased from the OS rather
42
- * than inferred from project config, so independently opened domains cannot
43
- * collide. Runtime probes from an open modal act as the lease heartbeat; once
44
- * they stop, the process group is retired after the idle window.
45
- */
46
- export class ViewDevServerManager {
47
- private readonly idleTimeoutMs: number
48
- private readonly startTimeoutMs: number
49
- private readonly failureRetryMs: number
50
- private readonly servers = new Map<string, DevServerRecord>()
51
- private readonly starts = new Map<string, Promise<ViewDevServerStatus>>()
52
- private readonly failures = new Map<string, FailedStart>()
53
- private readonly reservedPorts = new Set<number>()
54
- private shuttingDown = false
55
-
56
- constructor(options: ManagerOptions = {}) {
57
- this.idleTimeoutMs =
58
- options.idleTimeoutMs === undefined
59
- ? Math.max(
60
- 30_000,
61
- positiveMs(process.env.DOMAIN_STUDIO_VIEW_IDLE_MS, DEFAULT_IDLE_TIMEOUT_MS),
62
- )
63
- : positiveMs(options.idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS)
64
- this.startTimeoutMs = positiveMs(options.startTimeoutMs, DEFAULT_START_TIMEOUT_MS)
65
- this.failureRetryMs = positiveMs(options.failureRetryMs, DEFAULT_FAILURE_RETRY_MS)
66
- }
67
-
68
- async ensure(root: string, force = false): Promise<ViewDevServerStatus> {
69
- const key = resolve(root)
70
- if (this.shuttingDown) {
71
- return { status: 'failed', reason: 'Domain Studio is shutting down.' }
72
- }
73
-
74
- const pending = this.starts.get(key)
75
- if (pending) return pending
76
-
77
- const running = this.servers.get(key)
78
- if (running && childAlive(running.child)) {
79
- this.touch(running)
80
- return this.snapshot(running)
81
- }
82
- if (running) await this.stopRecord(running)
83
-
84
- const client = await resolveClientPackage(key, force)
85
- if (client.status === 'unavailable') return client
86
-
87
- // Concurrent cold requests share package discovery. Re-check after that
88
- // await so only one of them gets to spawn the Vite process.
89
- const pendingAfterProbe = this.starts.get(key)
90
- if (pendingAfterProbe) return pendingAfterProbe
91
- const runningAfterProbe = this.servers.get(key)
92
- if (runningAfterProbe && childAlive(runningAfterProbe.child)) {
93
- this.touch(runningAfterProbe)
94
- return this.snapshot(runningAfterProbe)
95
- }
96
-
97
- const failed = this.failures.get(key)
98
- if (!force && failed && Date.now() < failed.retryAt) return failed.status
99
- this.failures.delete(key)
100
-
101
- const started = this.start(key, client.packageDir).catch((error: unknown) => {
102
- const status: Extract<ViewDevServerStatus, { status: 'failed' }> = {
103
- status: 'failed',
104
- reason: error instanceof Error ? error.message : String(error),
105
- }
106
- this.failures.set(key, { status, retryAt: Date.now() + this.failureRetryMs })
107
- return status
108
- })
109
- this.starts.set(key, started)
110
- try {
111
- return await started
112
- } finally {
113
- if (this.starts.get(key) === started) this.starts.delete(key)
114
- }
115
- }
116
-
117
- async restart(root: string): Promise<ViewDevServerStatus> {
118
- const key = resolve(root)
119
- await this.stop(key)
120
- this.failures.delete(key)
121
- return this.ensure(key, true)
122
- }
123
-
124
- status(root: string): ViewDevServerStatus | null {
125
- const key = resolve(root)
126
- const running = this.servers.get(key)
127
- if (running?.ready && childAlive(running.child)) return this.snapshot(running)
128
- return this.failures.get(key)?.status ?? null
129
- }
130
-
131
- async stop(root: string): Promise<void> {
132
- const key = resolve(root)
133
- const running = this.servers.get(key)
134
- if (running) await this.stopRecord(running)
135
- this.failures.delete(key)
136
- }
137
-
138
- async shutdown(): Promise<void> {
139
- this.shuttingDown = true
140
- const pendingStarts = [...this.starts.values()]
141
- await Promise.all([...this.servers.values()].map((record) => this.stopRecord(record)))
142
- await Promise.allSettled(pendingStarts)
143
- await Promise.all([...this.servers.values()].map((record) => this.stopRecord(record)))
144
- this.failures.clear()
145
- }
146
-
147
- private async start(root: string, clientDir: string): Promise<ViewDevServerStatus> {
148
- let lastReason = 'The local frontend server did not become ready.'
149
- for (let attempt = 0; attempt < 3; attempt++) {
150
- const port = await this.reservePort()
151
- const url = `http://127.0.0.1:${port}`
152
- const child = spawn(
153
- 'pnpm',
154
- ['run', 'dev:hmr', '--host', '127.0.0.1', '--port', String(port), '--strictPort'],
155
- {
156
- cwd: clientDir,
157
- detached: process.platform !== 'win32',
158
- env: { ...process.env, BROWSER: 'none', FORCE_COLOR: '0', NO_UPDATE_NOTIFIER: '1' },
159
- stdio: ['ignore', 'pipe', 'pipe'],
160
- },
161
- )
162
- const now = Date.now()
163
- const record: DevServerRecord = {
164
- root,
165
- port,
166
- url,
167
- child,
168
- output: '',
169
- startedAt: new Date(now).toISOString(),
170
- lastUsedAt: now,
171
- ready: false,
172
- stopping: false,
173
- }
174
- this.servers.set(root, record)
175
- this.capture(record, child.stdout)
176
- this.capture(record, child.stderr)
177
- child.once('error', (error) => this.append(record, error.message))
178
- child.once('exit', () => {
179
- this.reservedPorts.delete(port)
180
- if (!record.stopping && this.servers.get(root) === record) this.servers.delete(root)
181
- })
182
-
183
- if (await waitForHttp(record, this.startTimeoutMs)) {
184
- record.ready = true
185
- this.touch(record)
186
- return this.snapshot(record)
187
- }
188
-
189
- lastReason = failureReason(record.output)
190
- const portConflict = /address already in use|eaddrinuse|port \d+ is already in use/i.test(
191
- record.output,
192
- )
193
- await this.stopRecord(record)
194
- if (!portConflict) break
195
- }
196
-
197
- const status: Extract<ViewDevServerStatus, { status: 'failed' }> = {
198
- status: 'failed',
199
- reason: lastReason,
200
- }
201
- this.failures.set(root, { status, retryAt: Date.now() + this.failureRetryMs })
202
- return status
203
- }
204
-
205
- private touch(record: DevServerRecord): void {
206
- record.lastUsedAt = Date.now()
207
- this.scheduleIdleStop(record, this.idleTimeoutMs)
208
- }
209
-
210
- private scheduleIdleStop(record: DevServerRecord, delayMs: number): void {
211
- if (record.idleTimer) clearTimeout(record.idleTimer)
212
- record.idleTimer = setTimeout(() => {
213
- const remaining = this.idleTimeoutMs - (Date.now() - record.lastUsedAt)
214
- if (remaining > 0) {
215
- this.scheduleIdleStop(record, remaining)
216
- return
217
- }
218
- void this.stopRecord(record)
219
- }, delayMs)
220
- record.idleTimer.unref?.()
221
- }
222
-
223
- private snapshot(record: DevServerRecord): Extract<ViewDevServerStatus, { status: 'running' }> {
224
- return {
225
- status: 'running',
226
- url: record.url,
227
- port: record.port,
228
- startedAt: record.startedAt,
229
- idleTimeoutMs: this.idleTimeoutMs,
230
- }
231
- }
232
-
233
- private capture(record: DevServerRecord, stream: NodeJS.ReadableStream | null): void {
234
- stream?.on('data', (chunk) => this.append(record, String(chunk)))
235
- }
236
-
237
- private append(record: DevServerRecord, chunk: string): void {
238
- record.output = `${record.output}${chunk.replace(/\u001b\[[0-9;]*m/g, '')}`.slice(-MAX_OUTPUT)
239
- }
240
-
241
- private async reservePort(): Promise<number> {
242
- for (let attempt = 0; attempt < 20; attempt++) {
243
- const port = await availablePort()
244
- if (this.reservedPorts.has(port)) continue
245
- this.reservedPorts.add(port)
246
- return port
247
- }
248
- throw new Error('Could not allocate an available loopback port for the frontend server.')
249
- }
250
-
251
- private stopRecord(record: DevServerRecord): Promise<void> {
252
- if (record.stopPromise) return record.stopPromise
253
- record.stopPromise = this.stopRecordNow(record)
254
- return record.stopPromise
255
- }
256
-
257
- private async stopRecordNow(record: DevServerRecord): Promise<void> {
258
- record.stopping = true
259
- if (record.idleTimer) clearTimeout(record.idleTimer)
260
- if (this.servers.get(record.root) === record) this.servers.delete(record.root)
261
- this.reservedPorts.delete(record.port)
262
- if (!childAlive(record.child)) return
263
-
264
- signalProcessGroup(record.child, 'SIGTERM')
265
- if (await waitForExit(record.child, STOP_GRACE_MS)) return
266
- signalProcessGroup(record.child, 'SIGKILL')
267
- await waitForExit(record.child, 500)
268
- }
269
- }
270
-
271
- function positiveMs(value: unknown, fallback: number): number {
272
- const parsed = Number(value)
273
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
274
- }
275
-
276
- function childAlive(child: ChildProcess): boolean {
277
- return child.exitCode === null && child.signalCode === null && child.pid !== undefined
278
- }
279
-
280
- function signalProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void {
281
- if (!child.pid) return
282
- try {
283
- if (process.platform !== 'win32') process.kill(-child.pid, signal)
284
- else child.kill(signal)
285
- } catch {
286
- try {
287
- child.kill(signal)
288
- } catch {
289
- // Already gone.
290
- }
291
- }
292
- }
293
-
294
- async function availablePort(): Promise<number> {
295
- return new Promise((resolvePort, reject) => {
296
- const server = createServer()
297
- server.unref()
298
- server.once('error', reject)
299
- server.listen(0, '127.0.0.1', () => {
300
- const address = server.address()
301
- if (!address || typeof address === 'string') {
302
- server.close()
303
- reject(new Error('The operating system did not allocate a TCP port.'))
304
- return
305
- }
306
- server.close((error) => (error ? reject(error) : resolvePort(address.port)))
307
- })
308
- })
309
- }
310
-
311
- async function waitForHttp(record: DevServerRecord, timeoutMs: number): Promise<boolean> {
312
- const deadline = Date.now() + timeoutMs
313
- while (Date.now() < deadline && childAlive(record.child)) {
314
- try {
315
- const response = await fetch(record.url, {
316
- signal: AbortSignal.timeout(600),
317
- headers: { accept: 'text/html' },
318
- })
319
- if (response.status < 500) return true
320
- } catch {
321
- // Vite has not bound yet.
322
- }
323
- await Bun.sleep(100)
324
- }
325
- return false
326
- }
327
-
328
- async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
329
- if (!childAlive(child)) return true
330
- return new Promise((resolveExit) => {
331
- const timer = setTimeout(() => {
332
- child.off('exit', onExit)
333
- resolveExit(false)
334
- }, timeoutMs)
335
- const onExit = () => {
336
- clearTimeout(timer)
337
- resolveExit(true)
338
- }
339
- child.once('exit', onExit)
340
- if (!childAlive(child)) {
341
- child.off('exit', onExit)
342
- clearTimeout(timer)
343
- resolveExit(true)
344
- }
345
- })
346
- }
347
-
348
- function failureReason(output: string): string {
349
- const lines = output
350
- .split(/\r?\n/)
351
- .map((line) => line.trim())
352
- .filter(Boolean)
353
- const useful = [...lines]
354
- .reverse()
355
- .find(
356
- (line) =>
357
- /error|failed|not found|missing|cannot|unable|eaddrinuse/i.test(line) &&
358
- !/^at\s/.test(line),
359
- )
360
- if (useful) return useful.slice(0, 600)
361
- const last = lines.at(-1)
362
- return (
363
- 'The dev:hmr script did not bind the Studio-assigned port.' +
364
- (last ? ` Last output: ${last}` : '')
365
- ).slice(0, 600)
366
- }
367
-
368
- export const viewDevServers = new ViewDevServerManager()
369
-
370
- export const ensureViewDevServer = (root: string) => viewDevServers.ensure(root)
371
- export const restartViewDevServer = (root: string) => viewDevServers.restart(root)
372
- export const shutdownViewDevServers = () => viewDevServers.shutdown()