@astrale-os/cli 0.5.0-alpha.0 → 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 (96) hide show
  1. package/dist/astrale.js +12962 -487
  2. package/package.json +7 -3
  3. package/src/command.ts +2 -0
  4. package/src/commands/__tests__/admin-instance.test.ts +3 -2
  5. package/src/commands/__tests__/domain-list.test.ts +6 -2
  6. package/src/commands/__tests__/install-identity-override.test.ts +1 -1
  7. package/src/commands/__tests__/view.test.ts +100 -0
  8. package/src/commands/call.ts +1 -1
  9. package/src/commands/domain/install.ts +5 -5
  10. package/src/commands/domain/list.ts +2 -2
  11. package/src/commands/domain/publish.ts +3 -3
  12. package/src/commands/instance/active.ts +2 -2
  13. package/src/commands/instance/create.ts +1 -1
  14. package/src/commands/instance/delete.ts +3 -3
  15. package/src/commands/instance/list.ts +8 -3
  16. package/src/commands/instance/status.ts +4 -3
  17. package/src/commands/instance/use.ts +2 -2
  18. package/src/commands/query.ts +27 -8
  19. package/src/commands/session/analyze.ts +50 -0
  20. package/src/commands/session/list.ts +36 -0
  21. package/src/commands/token.ts +1 -1
  22. package/src/commands/view-serve.ts +26 -0
  23. package/src/commands/view.ts +614 -0
  24. package/src/connect-core.test.ts +42 -0
  25. package/src/connect-core.ts +53 -0
  26. package/src/kernel/__tests__/auth.test.ts +1 -0
  27. package/src/kernel/client.ts +30 -24
  28. package/src/kernel/expand.ts +3 -2
  29. package/src/kernel/index.ts +7 -1
  30. package/src/kernel/options.ts +1 -1
  31. package/src/lib/__tests__/instance-target.test.ts +34 -0
  32. package/src/lib/__tests__/view-open-intent.test.ts +308 -0
  33. package/src/lib/__tests__/view-snapshot.test.ts +77 -0
  34. package/src/lib/admin-domain.ts +8 -4
  35. package/src/lib/admin-instance.ts +5 -1
  36. package/src/lib/config.ts +1 -0
  37. package/src/lib/instance-target.ts +5 -0
  38. package/src/lib/instance.ts +11 -0
  39. package/src/lib/log.ts +12 -2
  40. package/src/lib/login-flow.ts +41 -4
  41. package/src/lib/provision-instance.ts +2 -2
  42. package/src/lib/view/open-intent.ts +97 -0
  43. package/src/lib/view/resolve.ts +104 -0
  44. package/src/lib/view/server.ts +294 -0
  45. package/src/lib/view/session.ts +123 -0
  46. package/src/lib/view/snapshot.ts +101 -0
  47. package/src/program.ts +12 -1
  48. package/src/registry.ts +1 -1
  49. package/src/setup/steps/instance.ts +2 -2
  50. package/src/telemetry/__tests__/gate.test.ts +63 -0
  51. package/src/telemetry/__tests__/recorder.test.ts +110 -0
  52. package/src/telemetry/__tests__/redact.test.ts +79 -0
  53. package/src/telemetry/__tests__/session.test.ts +166 -0
  54. package/src/telemetry/__tests__/trigger.test.ts +49 -0
  55. package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
  56. package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
  57. package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
  58. package/src/telemetry/adapters/claude-code.ts +90 -0
  59. package/src/telemetry/adapters/codex.ts +160 -0
  60. package/src/telemetry/adapters/index.ts +45 -0
  61. package/src/telemetry/adapters/types.ts +21 -0
  62. package/src/telemetry/analyze.ts +227 -0
  63. package/src/telemetry/gate.ts +79 -0
  64. package/src/telemetry/recorder.ts +57 -0
  65. package/src/telemetry/redact.ts +53 -0
  66. package/src/telemetry/session.ts +88 -0
  67. package/src/telemetry/settings.ts +26 -0
  68. package/src/telemetry/store.ts +91 -0
  69. package/src/telemetry/trigger.ts +119 -0
  70. package/src/telemetry/types.ts +64 -0
  71. package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
  72. package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
  73. package/studio/client/dist/index.html +2 -2
  74. package/studio/server/agent/ask.ts +2 -1
  75. package/studio/server/agent/runner.ts +4 -1
  76. package/studio/server/agent/session-id.ts +13 -0
  77. package/studio/server/api.ts +43 -25
  78. package/studio/server/cache.ts +17 -6
  79. package/studio/server/client-package.test.ts +147 -0
  80. package/studio/server/client-package.ts +242 -0
  81. package/studio/server/index.ts +13 -0
  82. package/studio/server/introspect/anatomy-extras.test.ts +91 -0
  83. package/studio/server/introspect/anatomy-extras.ts +48 -49
  84. package/studio/server/introspect/anatomy.ts +7 -7
  85. package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
  86. package/studio/server/introspect/overlay-tsmorph.ts +122 -22
  87. package/studio/server/state/views.test.ts +90 -0
  88. package/studio/server/state/views.ts +396 -99
  89. package/studio/server/state/visibility.ts +5 -5
  90. package/studio/server/view-dev-server.test.ts +111 -0
  91. package/studio/server/view-dev-server.ts +372 -0
  92. package/studio/shared/types.ts +57 -10
  93. package/viewer/dist/index.html +93 -0
  94. package/viewer/dist/main.js +71 -0
  95. package/studio/client/dist/assets/index-BcejyJpa.css +0 -1
  96. package/studio/client/dist/assets/index-Cqz3Oy_B.js +0 -179
@@ -172,6 +172,11 @@ export function adminTargetToInstance(target: ResolvedAdminTarget): ResolvedInst
172
172
  export function isManagedInstanceNotFound(error: unknown): boolean {
173
173
  if (!(error instanceof Error)) return false
174
174
  if (error.name === 'NotFoundError') return true
175
+ // The admin kernel reports a missing instance node as InternalKernelError
176
+ // with a NOT_FOUND-prefixed message. In this lookup that's an instance
177
+ // miss (config problem), not a kernel fault — map it so callers get the
178
+ // typed INSTANCE_NOT_FOUND with remediation instead of a raw kernel error.
179
+ if (error.name === 'InternalKernelError' && /^NOT_FOUND\b/.test(error.message)) return true
175
180
  const data = (error as Error & { data?: unknown }).data
176
181
  return (
177
182
  error.name === 'KernelError' &&
@@ -113,6 +113,17 @@ export function sanitizeStore(store: InstanceStore): { store: InstanceStore; cha
113
113
 
114
114
  let instancesMemo: InstanceStore | null = null
115
115
 
116
+ /**
117
+ * Drop the in-process bookmark cache so the next `readInstances` re-reads disk.
118
+ *
119
+ * A long-lived host (e.g. `@astrale-os/connect-host`) would otherwise serve a
120
+ * stale bookmark list after the user runs `astrale instance bookmark`. The CLI
121
+ * itself is one-shot, so this is a no-op for command usage.
122
+ */
123
+ export function resetInstancesMemo(): void {
124
+ instancesMemo = null
125
+ }
126
+
116
127
  export async function readInstances(
117
128
  _config?: AstraleConfig,
118
129
  opts: { persist?: boolean } = {},
package/src/lib/log.ts CHANGED
@@ -3,6 +3,7 @@ import ora, { type Ora } from 'ora'
3
3
 
4
4
  import { AstraleError, NotImplementedError } from '../errors'
5
5
  import { formatElapsed } from './format'
6
+ import { isMachine, type RawOutputOpts } from './output'
6
7
 
7
8
  export const log = {
8
9
  info: (msg: string) => console.log(chalk.blue('ℹ'), msg),
@@ -13,12 +14,21 @@ export const log = {
13
14
  dim: (msg: string) => console.log(chalk.dim(msg)),
14
15
  }
15
16
 
16
- /** Report an error with hint (when present) and exit. */
17
- export function fatal(e: unknown): never {
17
+ /** Report an error with hint (when present) and exit. Commands that carry
18
+ * RawOutputOpts should pass them so machine consumers (--json/--raw/piped)
19
+ * get one structured JSON line on stderr instead of the pretty ✖ view. */
20
+ export function fatal(e: unknown, opts?: RawOutputOpts): never {
18
21
  // Ctrl-C at an interactive (@inquirer/prompts) prompt — exit quietly with the
19
22
  // SIGINT convention, not a red error line.
20
23
  if (e instanceof Error && e.name === 'ExitPromptError') process.exit(130)
21
24
  const msg = e instanceof Error ? e.message : String(e)
25
+ if (opts && isMachine(opts)) {
26
+ const error = e instanceof AstraleError ? e.code : e instanceof Error ? e.name : 'Error'
27
+ const payload: Record<string, unknown> = { error, message: msg }
28
+ if (e instanceof AstraleError && e.hint) payload.hint = e.hint
29
+ process.stderr.write(JSON.stringify(payload) + '\n')
30
+ process.exit(1)
31
+ }
22
32
  log.error(msg)
23
33
  if (e instanceof AstraleError && e.hint) log.dim(` hint: ${e.hint}`)
24
34
  process.exit(1)
@@ -29,6 +29,27 @@ import { log } from './log'
29
29
  * behavior; presentation (the device URL is logged here; success lines are the
30
30
  * caller's) does not.
31
31
  */
32
+ /**
33
+ * Interactive device-flow progress. Emitted once, when the device authorization
34
+ * has been requested and the user must approve it in a browser. The caller
35
+ * decides how to present it (the CLI logs it; the desktop opens the URL and
36
+ * pushes it to the renderer). Structurally identical to connect-host's wire
37
+ * `AuthVerificationEvent`, but declared here so the CLI carries no dependency on
38
+ * connect-host (which depends on the CLI, not the reverse).
39
+ */
40
+ export type DeviceVerification = {
41
+ /** The base verification URL (fallback to the complete one when absent). */
42
+ verificationUri: string
43
+ /** Pre-filled verification URL (opens straight to the approval page). */
44
+ verificationUriComplete?: string
45
+ /** The short user code to confirm — a reassurance fallback when the URL is pre-filled. */
46
+ userCode?: string
47
+ /** Seconds until the device code expires. */
48
+ expiresIn?: number
49
+ /** Human-readable instruction from the IdP. */
50
+ message?: string
51
+ }
52
+
32
53
  export type LoginFlowOpts = {
33
54
  idp?: string
34
55
  name?: string
@@ -42,6 +63,12 @@ export type LoginFlowOpts = {
42
63
  codeVerifier?: string
43
64
  /** Switch the default identity to the one we just logged in (default true). */
44
65
  use?: boolean
66
+ /**
67
+ * When set, receives the device-flow verification details and REPLACES the
68
+ * default `log.info` presentation (a headless/GUI caller drives the UI). The
69
+ * `auth login` command passes nothing, so its terminal output is unchanged.
70
+ */
71
+ onVerification?: (e: DeviceVerification) => void
45
72
  }
46
73
 
47
74
  export type LoginResult = {
@@ -148,10 +175,20 @@ async function obtainToken(
148
175
  scope,
149
176
  audience: opts.audience,
150
177
  })
151
- if (device.verification_uri_complete) log.info(`Open: ${device.verification_uri_complete}`)
152
- else if (device.verification_uri) log.info(`Open: ${device.verification_uri}`)
153
- if (device.user_code) log.info(`Code: ${device.user_code}`)
154
- if (device.message) log.dim(` ${device.message}`)
178
+ if (opts.onVerification) {
179
+ opts.onVerification({
180
+ verificationUri: device.verification_uri ?? device.verification_uri_complete ?? '',
181
+ verificationUriComplete: device.verification_uri_complete,
182
+ userCode: device.user_code,
183
+ expiresIn: device.expires_in,
184
+ message: device.message,
185
+ })
186
+ } else {
187
+ if (device.verification_uri_complete) log.info(`Open: ${device.verification_uri_complete}`)
188
+ else if (device.verification_uri) log.info(`Open: ${device.verification_uri}`)
189
+ if (device.user_code) log.info(`Code: ${device.user_code}`)
190
+ if (device.message) log.dim(` ${device.message}`)
191
+ }
155
192
 
156
193
  return pollDeviceToken({
157
194
  idp,
@@ -5,7 +5,7 @@ import type { AdminTargetCommandOpts } from './admin-target'
5
5
 
6
6
  import { AuthError } from '../errors'
7
7
  import { withAdminKernelClient } from '../kernel/client'
8
- import { ADMIN_INSTANCE } from './admin-instance'
8
+ import { adminInstanceMethod } from './admin-instance'
9
9
  import { readIdentities, type IdentityStore } from './identity'
10
10
  import { setActive, upsertManagedBookmark } from './instance'
11
11
  import { withSpinner } from './log'
@@ -74,7 +74,7 @@ export async function provisionInstance(
74
74
  const created = await withAdminKernelClient(
75
75
  createOpts,
76
76
  async (ctx) =>
77
- (await ctx.client.call(`${ADMIN_INSTANCE}/alphaCreate`, {
77
+ (await ctx.client.call(adminInstanceMethod('alphaCreate'), {
78
78
  slug,
79
79
  ...(hostId ? { host_id: hostId } : {}),
80
80
  })) as { url: string; organizationId?: string },
@@ -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
+ }