@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.
- package/dist/astrale.js +12962 -487
- package/package.json +7 -3
- 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__/install-identity-override.test.ts +1 -1
- package/src/commands/__tests__/view.test.ts +100 -0
- package/src/commands/call.ts +1 -1
- package/src/commands/domain/install.ts +5 -5
- package/src/commands/domain/list.ts +2 -2
- package/src/commands/domain/publish.ts +3 -3
- 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/query.ts +27 -8
- package/src/commands/session/analyze.ts +50 -0
- package/src/commands/session/list.ts +36 -0
- package/src/commands/token.ts +1 -1
- 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/client.ts +30 -24
- package/src/kernel/expand.ts +3 -2
- package/src/kernel/index.ts +7 -1
- package/src/kernel/options.ts +1 -1
- package/src/lib/__tests__/instance-target.test.ts +34 -0
- 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/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/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 +12 -1
- package/src/registry.ts +1 -1
- 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 +2 -1
- package/studio/server/agent/runner.ts +4 -1
- package/studio/server/agent/session-id.ts +13 -0
- package/studio/server/api.ts +43 -25
- 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 +122 -22
- package/studio/server/state/views.test.ts +90 -0
- package/studio/server/state/views.ts +396 -99
- package/studio/server/state/visibility.ts +5 -5
- 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/studio/client/dist/assets/index-BcejyJpa.css +0 -1
- 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' &&
|
package/src/lib/instance.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/lib/login-flow.ts
CHANGED
|
@@ -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 (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 {
|
|
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(
|
|
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
|
+
}
|