@astrale-os/cli 0.4.0-alpha.13 → 0.6.0-alpha.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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/THIRD-PARTY-NOTICES.md +27 -0
  4. package/dist/astrale.js +24396 -9632
  5. package/package.json +24 -22
  6. package/src/command.ts +2 -0
  7. package/src/commands/__tests__/admin-instance.test.ts +3 -2
  8. package/src/commands/__tests__/domain-list.test.ts +6 -2
  9. package/src/commands/__tests__/help-contract.test.ts +27 -14
  10. package/src/commands/__tests__/install-identity-override.test.ts +2 -2
  11. package/src/commands/__tests__/ls.test.ts +1 -1
  12. package/src/commands/__tests__/read-commands.test.ts +201 -0
  13. package/src/commands/__tests__/view.test.ts +100 -0
  14. package/src/commands/call.ts +27 -44
  15. package/src/commands/describe.ts +57 -58
  16. package/src/commands/domain/install.ts +9 -9
  17. package/src/commands/domain/list.ts +2 -2
  18. package/src/commands/domain/publish.ts +3 -3
  19. package/src/commands/get.ts +48 -23
  20. package/src/commands/identity/register.ts +27 -33
  21. package/src/commands/instance/active.ts +2 -2
  22. package/src/commands/instance/create.ts +1 -1
  23. package/src/commands/instance/delete.ts +3 -3
  24. package/src/commands/instance/list.ts +8 -3
  25. package/src/commands/instance/status.ts +4 -3
  26. package/src/commands/instance/use.ts +2 -2
  27. package/src/commands/logs.ts +8 -8
  28. package/src/commands/ls.ts +77 -55
  29. package/src/commands/mutate.ts +191 -0
  30. package/src/commands/query.ts +307 -20
  31. package/src/commands/session/analyze.ts +50 -0
  32. package/src/commands/session/list.ts +36 -0
  33. package/src/commands/token.ts +4 -7
  34. package/src/commands/view-serve.ts +26 -0
  35. package/src/commands/view.ts +614 -0
  36. package/src/connect-core.test.ts +42 -0
  37. package/src/connect-core.ts +53 -0
  38. package/src/kernel/__tests__/auth.test.ts +1 -0
  39. package/src/kernel/__tests__/expand.test.ts +123 -0
  40. package/src/kernel/client.ts +33 -37
  41. package/src/kernel/expand.ts +55 -61
  42. package/src/kernel/graph.ts +96 -0
  43. package/src/kernel/index.ts +18 -3
  44. package/src/kernel/options.ts +1 -1
  45. package/src/kernel/run.ts +0 -13
  46. package/src/lib/__tests__/instance-target.test.ts +34 -0
  47. package/src/lib/__tests__/table.test.ts +1 -1
  48. package/src/lib/__tests__/view-open-intent.test.ts +308 -0
  49. package/src/lib/__tests__/view-snapshot.test.ts +77 -0
  50. package/src/lib/admin-domain.ts +8 -4
  51. package/src/lib/admin-instance.ts +5 -1
  52. package/src/lib/config.ts +1 -0
  53. package/src/lib/domain-identity.ts +1 -1
  54. package/src/lib/instance-target.ts +5 -0
  55. package/src/lib/instance.ts +11 -0
  56. package/src/lib/log.ts +12 -2
  57. package/src/lib/login-flow.ts +41 -4
  58. package/src/lib/provision-instance.ts +2 -2
  59. package/src/lib/self.ts +1 -3
  60. package/src/lib/view/open-intent.ts +97 -0
  61. package/src/lib/view/resolve.ts +104 -0
  62. package/src/lib/view/server.ts +294 -0
  63. package/src/lib/view/session.ts +123 -0
  64. package/src/lib/view/snapshot.ts +101 -0
  65. package/src/program.ts +16 -3
  66. package/src/registry.ts +1 -1
  67. package/src/setup/render.ts +1 -3
  68. package/src/setup/steps/instance.ts +2 -2
  69. package/src/telemetry/__tests__/gate.test.ts +63 -0
  70. package/src/telemetry/__tests__/recorder.test.ts +110 -0
  71. package/src/telemetry/__tests__/redact.test.ts +79 -0
  72. package/src/telemetry/__tests__/session.test.ts +166 -0
  73. package/src/telemetry/__tests__/trigger.test.ts +49 -0
  74. package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
  75. package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
  76. package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
  77. package/src/telemetry/adapters/claude-code.ts +90 -0
  78. package/src/telemetry/adapters/codex.ts +160 -0
  79. package/src/telemetry/adapters/index.ts +45 -0
  80. package/src/telemetry/adapters/types.ts +21 -0
  81. package/src/telemetry/analyze.ts +227 -0
  82. package/src/telemetry/gate.ts +79 -0
  83. package/src/telemetry/recorder.ts +57 -0
  84. package/src/telemetry/redact.ts +53 -0
  85. package/src/telemetry/session.ts +88 -0
  86. package/src/telemetry/settings.ts +26 -0
  87. package/src/telemetry/store.ts +91 -0
  88. package/src/telemetry/trigger.ts +119 -0
  89. package/src/telemetry/types.ts +64 -0
  90. package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
  91. package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
  92. package/studio/client/dist/index.html +2 -2
  93. package/studio/server/agent/ask.ts +7 -2
  94. package/studio/server/agent/claude.ts +15 -3
  95. package/studio/server/agent/runner.ts +4 -1
  96. package/studio/server/agent/session-id.ts +13 -0
  97. package/studio/server/api.ts +50 -32
  98. package/studio/server/cache.ts +17 -6
  99. package/studio/server/client-package.test.ts +147 -0
  100. package/studio/server/client-package.ts +242 -0
  101. package/studio/server/index.ts +13 -0
  102. package/studio/server/introspect/anatomy-extras.test.ts +91 -0
  103. package/studio/server/introspect/anatomy-extras.ts +48 -49
  104. package/studio/server/introspect/anatomy.ts +7 -7
  105. package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
  106. package/studio/server/introspect/overlay-tsmorph.ts +230 -72
  107. package/studio/server/state/harness-gateway.ts +12 -3
  108. package/studio/server/state/harness-token.ts +0 -0
  109. package/studio/server/state/views.test.ts +90 -0
  110. package/studio/server/state/views.ts +396 -99
  111. package/studio/server/state/visibility.ts +10 -6
  112. package/studio/server/view-dev-server.test.ts +111 -0
  113. package/studio/server/view-dev-server.ts +372 -0
  114. package/studio/shared/types.ts +57 -10
  115. package/viewer/dist/index.html +93 -0
  116. package/viewer/dist/main.js +71 -0
  117. package/src/kernel/__tests__/remote-routing.test.ts +0 -70
  118. package/src/kernel/remote-routing.ts +0 -88
  119. package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
  120. package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
  121. package/studio/tsconfig.json +0 -23
@@ -0,0 +1,97 @@
1
+ import type { IntentMessage, MountedWindow, ResolvedView, Shell } from '@astrale-os/shell'
2
+
3
+ export interface OpenIntentHost {
4
+ current(): MountedWindow | null
5
+ setCurrent(window: MountedWindow): void
6
+ mount(view: ResolvedView, nodeId: string): Promise<MountedWindow>
7
+ opened(view: ResolvedView, nodeId: string): void
8
+ failed(error: unknown): void
9
+ reply(message: IntentMessage<'open'>, windowId: string): void
10
+ reject(message: IntentMessage<'open'>, error: unknown): void
11
+ }
12
+
13
+ /** Register the root host's serialized node-to-View navigation handler. */
14
+ export function installOpenIntentHandler(shell: Shell, host: OpenIntentHost): () => void {
15
+ let queue = Promise.resolve()
16
+ return shell.onIntent('open', (message) => {
17
+ const run = queue.then(() => handleOpenIntent(shell, host, message))
18
+ queue = run.catch(() => {})
19
+ return run
20
+ })
21
+ }
22
+
23
+ export async function handleOpenIntent(
24
+ shell: Pick<Shell, 'views'>,
25
+ host: OpenIntentHost,
26
+ message: IntentMessage<'open'>,
27
+ ): Promise<void> {
28
+ const { nodeId, viewId } = message.envelope.payload
29
+ try {
30
+ const selected = selectResolvedView(await shell.views.resolve(nodeId), viewId)
31
+ const previous = host.current()
32
+ const next = await host.mount(selected, nodeId)
33
+
34
+ host.setCurrent(next)
35
+ host.opened(selected, nodeId)
36
+ // A correlated requester is normally `previous`; answer while its channel
37
+ // still exists, then retire the old mount.
38
+ host.reply(message, next.windowId)
39
+
40
+ if (previous && previous.windowId !== next.windowId) {
41
+ try {
42
+ const closed = await previous.close({ force: true })
43
+ if (closed.kind === 'refused') {
44
+ host.failed(new Error(closed.reason ?? `Window ${previous.windowId} refused to close`))
45
+ }
46
+ } catch (error) {
47
+ host.failed(error)
48
+ }
49
+ }
50
+ } catch (error) {
51
+ host.reject(message, error)
52
+ host.failed(error)
53
+ }
54
+ }
55
+
56
+ export function selectResolvedView(
57
+ views: readonly ResolvedView[],
58
+ viewId: string | undefined,
59
+ ): ResolvedView {
60
+ const selected = viewId ? views.find((view) => view.id === viewId) : views[0]
61
+ if (selected) return selected
62
+ throw new Error(
63
+ viewId ? `View ${viewId} does not resolve for this node` : 'No view resolves for this node',
64
+ )
65
+ }
66
+
67
+ /** Mount a shell view with bounded handshake retries and a plain fallback. */
68
+ export async function mountWithHandshakeFallback<T>(opts: {
69
+ handshake: 'shell' | 'none'
70
+ attempts: number
71
+ mount(handshake: 'shell' | 'none'): Promise<T>
72
+ cleanupFailedAttempt(): void
73
+ }): Promise<{ mounted: T; handshake: 'shell' | 'none' }> {
74
+ if (opts.handshake === 'none') {
75
+ try {
76
+ return { mounted: await opts.mount('none'), handshake: 'none' }
77
+ } catch (error) {
78
+ opts.cleanupFailedAttempt()
79
+ throw error
80
+ }
81
+ }
82
+ let lastError: unknown
83
+ for (let attempt = 0; attempt < opts.attempts; attempt++) {
84
+ try {
85
+ return { mounted: await opts.mount('shell'), handshake: 'shell' }
86
+ } catch (error) {
87
+ lastError = error
88
+ opts.cleanupFailedAttempt()
89
+ }
90
+ }
91
+ try {
92
+ return { mounted: await opts.mount('none'), handshake: 'none' }
93
+ } catch (error) {
94
+ opts.cleanupFailedAttempt()
95
+ throw error ?? lastError
96
+ }
97
+ }
@@ -0,0 +1,104 @@
1
+ import type { ClientContext } from '../../kernel'
2
+
3
+ import { AstraleError } from '../../errors'
4
+
5
+ /**
6
+ * View + target resolution for `astrale view`. Both spec shapes funnel through
7
+ * the kernel's `View:resolve` syscall: a ViewPath resolves to the view node
8
+ * itself (its class implements UI), a target path lists the views attached to
9
+ * it via `view_for`.
10
+ */
11
+
12
+ const VIEW_RESOLVE_PATH = '/:kernel.astrale.ai:class.View:resolve'
13
+ const VIEW_PATH_RE = /^\/:[^\s/:@]+:view\.[a-z][a-z0-9-]*$/
14
+
15
+ export type ViewSpec = { kind: 'view' | 'target'; path: string }
16
+
17
+ export function parseViewSpec(spec: string): ViewSpec {
18
+ if (VIEW_PATH_RE.test(spec)) return { kind: 'view', path: spec }
19
+ if (spec.startsWith('/') || spec.startsWith('@')) return { kind: 'target', path: spec }
20
+ throw new AstraleError(
21
+ 'INVALID_ARGUMENT',
22
+ `"${spec}" is neither a ViewPath (/:origin:view.slug) nor a node path (/… or @id)`,
23
+ )
24
+ }
25
+
26
+ /** Wire shape of one `View:resolve` entry. */
27
+ export type ViewCandidate = {
28
+ id: string
29
+ path: string
30
+ url: string
31
+ name?: string
32
+ handshake?: 'shell' | 'none'
33
+ origin: 'self' | 'class'
34
+ }
35
+
36
+ export async function resolveViewCandidates(
37
+ ctx: ClientContext,
38
+ nodePath: string,
39
+ ): Promise<ViewCandidate[]> {
40
+ const result = await ctx.client.call(VIEW_RESOLVE_PATH, { node: nodePath })
41
+ if (!Array.isArray(result)) {
42
+ throw new AstraleError('UNEXPECTED_RESULT', `View:resolve returned a non-array for ${nodePath}`)
43
+ }
44
+ return result as ViewCandidate[]
45
+ }
46
+
47
+ /** The slug tail of a candidate: `view.dashboard` → `dashboard`. */
48
+ export function candidateSlug(candidate: ViewCandidate): string {
49
+ const fromPath = candidate.path?.split('/').pop() ?? ''
50
+ return candidate.name ?? fromPath
51
+ }
52
+
53
+ export function pickCandidate(
54
+ candidates: ViewCandidate[],
55
+ nodePath: string,
56
+ slug?: string,
57
+ ): ViewCandidate | 'ambiguous' {
58
+ if (slug) {
59
+ const match = candidates.find((c) => candidateSlug(c) === slug || c.path.endsWith(`/${slug}`))
60
+ if (!match) {
61
+ throw new AstraleError(
62
+ 'VIEW_NOT_FOUND',
63
+ `No view "${slug}" on ${nodePath} — available: ${candidates.map(candidateSlug).join(', ') || '(none)'}`,
64
+ )
65
+ }
66
+ return match
67
+ }
68
+ if (candidates.length === 0) {
69
+ throw new AstraleError('VIEW_NOT_FOUND', `No views resolve on ${nodePath}`)
70
+ }
71
+ if (candidates.length === 1) return candidates[0]
72
+ return 'ambiguous'
73
+ }
74
+
75
+ /**
76
+ * Apply a `--view-url` override: an origin-only value swaps the origin and
77
+ * keeps the resolved path, a value with a path replaces the URL wholesale.
78
+ */
79
+ export function applyViewUrlOverride(resolvedUrl: string, override: string): string {
80
+ const parsed = parseUrl(override)
81
+ if (parsed.pathname !== '/') return parsed.toString()
82
+ const original = parseUrl(resolvedUrl)
83
+ return new URL(original.pathname + original.search + original.hash, parsed.origin).toString()
84
+ }
85
+
86
+ /**
87
+ * The kernel addresses locally-served workers as `host.docker.internal`
88
+ * (reachable from its container); the host browser reaches the same worker on
89
+ * loopback. Rewrite so the iframe loads without an /etc/hosts entry.
90
+ */
91
+ export function rewriteLocalViewUrl(url: string): string {
92
+ const parsed = parseUrl(url)
93
+ if (parsed.hostname !== 'host.docker.internal') return url
94
+ parsed.hostname = '127.0.0.1'
95
+ return parsed.toString()
96
+ }
97
+
98
+ function parseUrl(raw: string): URL {
99
+ try {
100
+ return new URL(raw)
101
+ } catch {
102
+ throw new AstraleError('INVALID_URL', `Not a valid URL: ${raw}`)
103
+ }
104
+ }
@@ -0,0 +1,294 @@
1
+ import type { ReadableStream as WebReadableStream } from 'node:stream/web'
2
+
3
+ import { existsSync } from 'node:fs'
4
+ import { readFile } from 'node:fs/promises'
5
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
6
+ import { dirname, join } from 'node:path'
7
+ import { Readable } from 'node:stream'
8
+
9
+ import type { ViewServeConfig } from './session'
10
+
11
+ import { withKernelClient } from '../../kernel'
12
+ import { fetchWithCaFile } from '../../kernel/ca-fetch'
13
+ import { removeSessionFiles } from './session'
14
+
15
+ /**
16
+ * The view-session server: serves the host page, hands it config and fresh
17
+ * credentials, receives its lifecycle reports, and proxies the view's kernel
18
+ * calls (one mechanism for CORS, self-signed local CAs, and keeping the kernel
19
+ * origin out of the page). Everything is nonce-scoped under `/s/<nonce>/`;
20
+ * loopback-only. Exits after `idleMs` without a request — the host page's
21
+ * heartbeat keeps a live session alive.
22
+ */
23
+
24
+ const TOKEN_REFRESH_MARGIN_MS = 5 * 60_000
25
+ const FALLBACK_TOKEN_TTL_MS = 3600_000
26
+ const IDLE_SWEEP_MS = 60_000
27
+
28
+ export type PageStatus = { state: string; error?: string; at: string }
29
+
30
+ type TokenGrant = { token: string; expiresAt: number; kind: 'minted' | 'raw' }
31
+
32
+ export function startViewServer(config: ViewServeConfig): Server {
33
+ const { session, proxy } = config
34
+ const base = `/s/${session.nonce}`
35
+ const hostDir = viewerDistDir()
36
+ const proxyFetch = proxy.caFile ? fetchWithCaFile(proxy.caFile) : globalThis.fetch
37
+ let status: PageStatus = { state: 'waiting', at: new Date().toISOString() }
38
+ let grant: TokenGrant | null = null
39
+ let lastActivity = Date.now()
40
+
41
+ /**
42
+ * Prefer a kernel-minted TTL-bound identity credential (what the GUI hands
43
+ * its iframes); fall back to the raw CLI credential so the session works
44
+ * anywhere the CLI itself can call.
45
+ */
46
+ async function freshGrant(): Promise<TokenGrant> {
47
+ if (grant && grant.expiresAt - Date.now() > TOKEN_REFRESH_MARGIN_MS) return grant
48
+ grant = await withKernelClient(config.kernel, async (ctx) => {
49
+ try {
50
+ const token = await ctx.client.as(ctx.credential).auth.mint()
51
+ return {
52
+ token,
53
+ expiresAt: jwtExpiry(token) ?? Date.now() + FALLBACK_TOKEN_TTL_MS,
54
+ kind: 'minted' as const,
55
+ }
56
+ } catch (error) {
57
+ console.log(
58
+ `mint failed (${error instanceof Error ? error.message : String(error)}) — falling back to the raw CLI credential`,
59
+ )
60
+ const token = ctx.credential
61
+ return {
62
+ token,
63
+ expiresAt: jwtExpiry(token) ?? Date.now() + FALLBACK_TOKEN_TTL_MS,
64
+ kind: 'raw' as const,
65
+ }
66
+ }
67
+ })
68
+ return grant
69
+ }
70
+
71
+ const server = createServer((req, res) => {
72
+ lastActivity = Date.now()
73
+ void route(req, res).catch((error: unknown) => {
74
+ const message = error instanceof Error ? error.message : String(error)
75
+ // CORS headers even on failures — the caller may be the cross-origin
76
+ // view iframe, and an opaque error reads as a network failure there.
77
+ if (!res.headersSent) {
78
+ res.writeHead(502, {
79
+ 'content-type': 'application/json',
80
+ ...corsHeaders(req.headers.origin),
81
+ })
82
+ }
83
+ res.end(JSON.stringify({ error: message }))
84
+ })
85
+ })
86
+
87
+ async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
88
+ const url = new URL(req.url ?? '/', 'http://localhost')
89
+ if (!url.pathname.startsWith(`${base}/`) && url.pathname !== base) {
90
+ res.writeHead(404).end()
91
+ return
92
+ }
93
+ const sub = url.pathname.slice(base.length) || '/'
94
+
95
+ if (sub === '/k' || sub.startsWith('/k/')) {
96
+ await proxyKernel(req, res, sub.slice('/k'.length), url.search)
97
+ return
98
+ }
99
+ if (sub === '/' || sub === '/index.html') {
100
+ await serveAsset(res, join(hostDir, 'index.html'), 'text/html; charset=utf-8')
101
+ return
102
+ }
103
+ if (sub === '/main.js') {
104
+ await serveAsset(res, join(hostDir, 'main.js'), 'text/javascript; charset=utf-8')
105
+ return
106
+ }
107
+ if (sub === '/config.json' && req.method === 'GET') {
108
+ json(res, 200, {
109
+ viewUrl: session.view.url,
110
+ viewPath: session.view.path ?? null,
111
+ viewName: session.view.name ?? null,
112
+ functionId: session.view.functionId,
113
+ handshake: session.view.handshake,
114
+ targetNodeId: session.target?.id ?? null,
115
+ targetPath: session.target?.path ?? null,
116
+ kernelUrl: proxy.direct ? proxy.kernelUrl : `${base}/k`,
117
+ identity: session.identity ?? null,
118
+ instance: session.instance ?? null,
119
+ sessionId: session.id,
120
+ })
121
+ return
122
+ }
123
+ if (sub === '/token' && req.method === 'POST') {
124
+ const fresh = await freshGrant()
125
+ json(res, 200, { token: fresh.token, expiresAt: fresh.expiresAt, kind: fresh.kind })
126
+ return
127
+ }
128
+ if (sub === '/status' && req.method === 'POST') {
129
+ const body = await readJson(req)
130
+ if (body && typeof body.state === 'string' && body.state !== 'alive') {
131
+ status = { state: body.state, error: asString(body.error), at: new Date().toISOString() }
132
+ }
133
+ res.writeHead(204).end()
134
+ return
135
+ }
136
+ if (sub === '/state' && req.method === 'GET') {
137
+ json(res, 200, status)
138
+ return
139
+ }
140
+ res.writeHead(404).end()
141
+ }
142
+
143
+ /** Forward a kernel call: verbatim headers/body both ways, CORS added. */
144
+ async function proxyKernel(
145
+ req: IncomingMessage,
146
+ res: ServerResponse,
147
+ suffix: string,
148
+ search: string,
149
+ ): Promise<void> {
150
+ const origin = req.headers.origin
151
+ if (req.method === 'OPTIONS') {
152
+ res.writeHead(204, corsHeaders(origin)).end()
153
+ return
154
+ }
155
+ const target = joinUrl(proxy.kernelUrl, suffix) + search
156
+ const headers: Record<string, string> = {}
157
+ for (const name of ['authorization', 'content-type', 'accept']) {
158
+ const value = req.headers[name]
159
+ if (typeof value === 'string') headers[name] = value
160
+ }
161
+ // Buffer the request body (envelope requests are small JSON): the caFile
162
+ // fetch path speaks node https and cannot consume a web-stream body.
163
+ // Response bodies still stream through.
164
+ const hasBody = req.method !== 'GET' && req.method !== 'HEAD'
165
+ const body = hasBody ? Buffer.concat(await collect(req)) : undefined
166
+ const upstream = await proxyFetch(target, {
167
+ method: req.method,
168
+ headers,
169
+ ...(body !== undefined ? { body } : {}),
170
+ } as RequestInit)
171
+ const responseHeaders: Record<string, string> = corsHeaders(origin)
172
+ const contentType = upstream.headers.get('content-type')
173
+ if (contentType) responseHeaders['content-type'] = contentType
174
+ res.writeHead(upstream.status, responseHeaders)
175
+ if (upstream.body) {
176
+ Readable.fromWeb(upstream.body as unknown as WebReadableStream).pipe(res)
177
+ } else {
178
+ res.end()
179
+ }
180
+ }
181
+
182
+ const idleTimer = setInterval(() => {
183
+ if (Date.now() - lastActivity > config.idleMs) void shutdown(0)
184
+ }, IDLE_SWEEP_MS)
185
+ idleTimer.unref()
186
+
187
+ async function shutdown(code: number): Promise<void> {
188
+ clearInterval(idleTimer)
189
+ server.close()
190
+ await removeSessionFiles(session.id)
191
+ process.exit(code)
192
+ }
193
+ process.on('SIGTERM', () => void shutdown(0))
194
+ process.on('SIGINT', () => void shutdown(0))
195
+
196
+ server.listen(session.port, '127.0.0.1')
197
+ return server
198
+ }
199
+
200
+ /**
201
+ * The prebuilt host-page bundle, shipped next to the CLI entry
202
+ * (`<pkg>/viewer/dist`). Dev runs (bun, no dist) build it on demand.
203
+ */
204
+ export function viewerDistDir(): string {
205
+ const override = process.env.ASTRALE_VIEWER_DIR
206
+ if (override) return override
207
+ return join(dirname(process.argv[1] ?? '.'), '..', 'viewer', 'dist')
208
+ }
209
+
210
+ /** Ensure the host bundle exists; on a dev checkout, build it with Bun. */
211
+ export async function ensureViewerAssets(): Promise<string> {
212
+ const dist = viewerDistDir()
213
+ if (existsSync(join(dist, 'main.js')) && existsSync(join(dist, 'index.html'))) return dist
214
+ const srcDir = join(dist, '..')
215
+ const bun = (
216
+ globalThis as { Bun?: { build: (o: object) => Promise<{ success: boolean; logs: unknown[] }> } }
217
+ ).Bun
218
+ if (bun && existsSync(join(srcDir, 'main.ts'))) {
219
+ const result = await bun.build({
220
+ entrypoints: [join(srcDir, 'main.ts')],
221
+ outdir: dist,
222
+ target: 'browser',
223
+ minify: false,
224
+ })
225
+ if (!result.success) throw new Error(`viewer build failed: ${result.logs.join('\n')}`)
226
+ const { copyFile } = await import('node:fs/promises')
227
+ await copyFile(join(srcDir, 'index.html'), join(dist, 'index.html'))
228
+ return dist
229
+ }
230
+ throw new Error(
231
+ `viewer bundle missing at ${dist} — reinstall the CLI (or run \`bun scripts/build.ts\` in a dev checkout)`,
232
+ )
233
+ }
234
+
235
+ function jwtExpiry(token: string): number | null {
236
+ try {
237
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')) as {
238
+ exp?: number
239
+ }
240
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : null
241
+ } catch {
242
+ return null
243
+ }
244
+ }
245
+
246
+ function corsHeaders(origin: string | undefined): Record<string, string> {
247
+ return {
248
+ 'access-control-allow-origin': origin ?? '*',
249
+ 'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
250
+ 'access-control-allow-headers': 'authorization, content-type, accept',
251
+ // Chrome private-network-access preflight (public page → loopback proxy).
252
+ 'access-control-allow-private-network': 'true',
253
+ 'access-control-max-age': '600',
254
+ vary: 'origin',
255
+ }
256
+ }
257
+
258
+ function joinUrl(baseUrl: string, suffix: string): string {
259
+ if (!suffix) return baseUrl
260
+ return baseUrl.replace(/\/$/, '') + suffix
261
+ }
262
+
263
+ function json(res: ServerResponse, code: number, body: unknown): void {
264
+ res.writeHead(code, { 'content-type': 'application/json' })
265
+ res.end(JSON.stringify(body))
266
+ }
267
+
268
+ async function serveAsset(res: ServerResponse, file: string, contentType: string): Promise<void> {
269
+ try {
270
+ const content = await readFile(file)
271
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' })
272
+ res.end(content)
273
+ } catch {
274
+ res.writeHead(404).end()
275
+ }
276
+ }
277
+
278
+ async function collect(req: IncomingMessage): Promise<Buffer[]> {
279
+ const chunks: Buffer[] = []
280
+ for await (const chunk of req) chunks.push(chunk as Buffer)
281
+ return chunks
282
+ }
283
+
284
+ async function readJson(req: IncomingMessage): Promise<Record<string, unknown> | null> {
285
+ try {
286
+ return JSON.parse(Buffer.concat(await collect(req)).toString('utf8')) as Record<string, unknown>
287
+ } catch {
288
+ return null
289
+ }
290
+ }
291
+
292
+ function asString(value: unknown): string | undefined {
293
+ return typeof value === 'string' ? value : undefined
294
+ }
@@ -0,0 +1,123 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ import { paths } from '../env'
5
+
6
+ /**
7
+ * View-session records: one detached server process per open view, tracked as
8
+ * `~/.astrale/view/<id>.json` (+ `<id>.log`, `<id>.config.json`). Records are
9
+ * garbage-collected on every `astrale view` invocation by probing the pid.
10
+ */
11
+
12
+ export const VIEW_DIR = join(paths.home, 'view')
13
+
14
+ export type ViewSessionRecord = {
15
+ id: string
16
+ pid: number
17
+ port: number
18
+ nonce: string
19
+ /** Host page URL, nonce-scoped: `http://127.0.0.1:<port>/s/<nonce>/`. */
20
+ pageUrl: string
21
+ view: {
22
+ url: string
23
+ functionId: string
24
+ handshake: 'shell' | 'none'
25
+ /** ViewPath or resolved view node path, when the view came from the graph. */
26
+ path?: string
27
+ name?: string
28
+ }
29
+ target?: { id?: string; path?: string }
30
+ instance?: string
31
+ identity?: string
32
+ createdAt: string
33
+ }
34
+
35
+ /** Everything the detached `view __serve` process needs, written before spawn. */
36
+ export type ViewServeConfig = {
37
+ session: ViewSessionRecord
38
+ /** Kernel passthrough opts, re-resolved server-side for token mints. */
39
+ kernel: { url?: string; instance?: string; as?: string; creds?: string; timeout?: string }
40
+ /**
41
+ * Kernel the view dispatches to. `direct: true` hands the child the kernel
42
+ * URL itself (public https kernels — the router serves CORS, and Chrome's
43
+ * local-network-access rules forbid a public view origin fetching our
44
+ * loopback proxy). Otherwise the child gets the nonce-scoped proxy, which
45
+ * handles CORS and self-signed local CAs.
46
+ */
47
+ proxy: { kernelUrl: string; caFile?: string; direct: boolean }
48
+ idleMs: number
49
+ }
50
+
51
+ export const recordPath = (id: string): string => join(VIEW_DIR, `${id}.json`)
52
+ export const logPath = (id: string): string => join(VIEW_DIR, `${id}.log`)
53
+ export const configPath = (id: string): string => join(VIEW_DIR, `${id}.config.json`)
54
+
55
+ export async function saveRecord(record: ViewSessionRecord): Promise<void> {
56
+ await mkdir(VIEW_DIR, { recursive: true })
57
+ await writeFile(recordPath(record.id), `${JSON.stringify(record, null, 2)}\n`)
58
+ }
59
+
60
+ export async function removeSessionFiles(id: string): Promise<void> {
61
+ await Promise.all([
62
+ rm(recordPath(id), { force: true }),
63
+ rm(configPath(id), { force: true }),
64
+ rm(logPath(id), { force: true }),
65
+ ])
66
+ }
67
+
68
+ export function isAlive(pid: number): boolean {
69
+ try {
70
+ process.kill(pid, 0)
71
+ return true
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ /** List live sessions, removing records whose server process is gone. */
78
+ export async function listSessions(): Promise<ViewSessionRecord[]> {
79
+ let entries: string[]
80
+ try {
81
+ entries = await readdir(VIEW_DIR)
82
+ } catch {
83
+ return []
84
+ }
85
+ const live: ViewSessionRecord[] = []
86
+ for (const entry of entries) {
87
+ if (!/^v-[0-9a-f]+\.json$/.test(entry)) continue
88
+ let record: ViewSessionRecord
89
+ try {
90
+ record = JSON.parse(await readFile(join(VIEW_DIR, entry), 'utf8')) as ViewSessionRecord
91
+ } catch {
92
+ continue
93
+ }
94
+ if (isAlive(record.pid)) live.push(record)
95
+ else await removeSessionFiles(record.id)
96
+ }
97
+ return live.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
98
+ }
99
+
100
+ const CLOSE_GRACE_MS = 2000
101
+
102
+ /** SIGTERM the session server (SIGKILL after a grace period), drop its files. */
103
+ export async function closeSession(record: ViewSessionRecord): Promise<void> {
104
+ if (isAlive(record.pid)) {
105
+ try {
106
+ process.kill(record.pid, 'SIGTERM')
107
+ } catch {
108
+ // already gone
109
+ }
110
+ const deadline = Date.now() + CLOSE_GRACE_MS
111
+ while (isAlive(record.pid) && Date.now() < deadline) {
112
+ await new Promise((resolve) => setTimeout(resolve, 100))
113
+ }
114
+ if (isAlive(record.pid)) {
115
+ try {
116
+ process.kill(record.pid, 'SIGKILL')
117
+ } catch {
118
+ // already gone
119
+ }
120
+ }
121
+ }
122
+ await removeSessionFiles(record.id)
123
+ }