@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.
- package/LICENSE +202 -0
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +27 -0
- package/dist/astrale.js +24396 -9632
- package/package.json +24 -22
- package/src/command.ts +2 -0
- package/src/commands/__tests__/admin-instance.test.ts +3 -2
- package/src/commands/__tests__/domain-list.test.ts +6 -2
- package/src/commands/__tests__/help-contract.test.ts +27 -14
- package/src/commands/__tests__/install-identity-override.test.ts +2 -2
- package/src/commands/__tests__/ls.test.ts +1 -1
- package/src/commands/__tests__/read-commands.test.ts +201 -0
- package/src/commands/__tests__/view.test.ts +100 -0
- package/src/commands/call.ts +27 -44
- package/src/commands/describe.ts +57 -58
- package/src/commands/domain/install.ts +9 -9
- package/src/commands/domain/list.ts +2 -2
- package/src/commands/domain/publish.ts +3 -3
- package/src/commands/get.ts +48 -23
- package/src/commands/identity/register.ts +27 -33
- package/src/commands/instance/active.ts +2 -2
- package/src/commands/instance/create.ts +1 -1
- package/src/commands/instance/delete.ts +3 -3
- package/src/commands/instance/list.ts +8 -3
- package/src/commands/instance/status.ts +4 -3
- package/src/commands/instance/use.ts +2 -2
- package/src/commands/logs.ts +8 -8
- package/src/commands/ls.ts +77 -55
- package/src/commands/mutate.ts +191 -0
- package/src/commands/query.ts +307 -20
- package/src/commands/session/analyze.ts +50 -0
- package/src/commands/session/list.ts +36 -0
- package/src/commands/token.ts +4 -7
- package/src/commands/view-serve.ts +26 -0
- package/src/commands/view.ts +614 -0
- package/src/connect-core.test.ts +42 -0
- package/src/connect-core.ts +53 -0
- package/src/kernel/__tests__/auth.test.ts +1 -0
- package/src/kernel/__tests__/expand.test.ts +123 -0
- package/src/kernel/client.ts +33 -37
- package/src/kernel/expand.ts +55 -61
- package/src/kernel/graph.ts +96 -0
- package/src/kernel/index.ts +18 -3
- package/src/kernel/options.ts +1 -1
- package/src/kernel/run.ts +0 -13
- package/src/lib/__tests__/instance-target.test.ts +34 -0
- package/src/lib/__tests__/table.test.ts +1 -1
- package/src/lib/__tests__/view-open-intent.test.ts +308 -0
- package/src/lib/__tests__/view-snapshot.test.ts +77 -0
- package/src/lib/admin-domain.ts +8 -4
- package/src/lib/admin-instance.ts +5 -1
- package/src/lib/config.ts +1 -0
- package/src/lib/domain-identity.ts +1 -1
- package/src/lib/instance-target.ts +5 -0
- package/src/lib/instance.ts +11 -0
- package/src/lib/log.ts +12 -2
- package/src/lib/login-flow.ts +41 -4
- package/src/lib/provision-instance.ts +2 -2
- package/src/lib/self.ts +1 -3
- package/src/lib/view/open-intent.ts +97 -0
- package/src/lib/view/resolve.ts +104 -0
- package/src/lib/view/server.ts +294 -0
- package/src/lib/view/session.ts +123 -0
- package/src/lib/view/snapshot.ts +101 -0
- package/src/program.ts +16 -3
- package/src/registry.ts +1 -1
- package/src/setup/render.ts +1 -3
- package/src/setup/steps/instance.ts +2 -2
- package/src/telemetry/__tests__/gate.test.ts +63 -0
- package/src/telemetry/__tests__/recorder.test.ts +110 -0
- package/src/telemetry/__tests__/redact.test.ts +79 -0
- package/src/telemetry/__tests__/session.test.ts +166 -0
- package/src/telemetry/__tests__/trigger.test.ts +49 -0
- package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
- package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
- package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
- package/src/telemetry/adapters/claude-code.ts +90 -0
- package/src/telemetry/adapters/codex.ts +160 -0
- package/src/telemetry/adapters/index.ts +45 -0
- package/src/telemetry/adapters/types.ts +21 -0
- package/src/telemetry/analyze.ts +227 -0
- package/src/telemetry/gate.ts +79 -0
- package/src/telemetry/recorder.ts +57 -0
- package/src/telemetry/redact.ts +53 -0
- package/src/telemetry/session.ts +88 -0
- package/src/telemetry/settings.ts +26 -0
- package/src/telemetry/store.ts +91 -0
- package/src/telemetry/trigger.ts +119 -0
- package/src/telemetry/types.ts +64 -0
- package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
- package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.ts +7 -2
- package/studio/server/agent/claude.ts +15 -3
- package/studio/server/agent/runner.ts +4 -1
- package/studio/server/agent/session-id.ts +13 -0
- package/studio/server/api.ts +50 -32
- package/studio/server/cache.ts +17 -6
- package/studio/server/client-package.test.ts +147 -0
- package/studio/server/client-package.ts +242 -0
- package/studio/server/index.ts +13 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -0
- package/studio/server/introspect/anatomy-extras.ts +48 -49
- package/studio/server/introspect/anatomy.ts +7 -7
- package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
- package/studio/server/introspect/overlay-tsmorph.ts +230 -72
- package/studio/server/state/harness-gateway.ts +12 -3
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/views.test.ts +90 -0
- package/studio/server/state/views.ts +396 -99
- package/studio/server/state/visibility.ts +10 -6
- package/studio/server/view-dev-server.test.ts +111 -0
- package/studio/server/view-dev-server.ts +372 -0
- package/studio/shared/types.ts +57 -10
- package/viewer/dist/index.html +93 -0
- package/viewer/dist/main.js +71 -0
- package/src/kernel/__tests__/remote-routing.test.ts +0 -70
- package/src/kernel/remote-routing.ts +0 -88
- package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
- package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
- 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
|
+
}
|