@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,614 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { closeSync, existsSync, openSync, statSync } from 'node:fs'
|
|
4
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import type { CommandDefinition } from '../command'
|
|
8
|
+
import type { KernelCommandOpts } from '../kernel'
|
|
9
|
+
import type { ViewServeConfig, ViewSessionRecord } from '../lib/view/session'
|
|
10
|
+
|
|
11
|
+
import { AstraleError } from '../errors'
|
|
12
|
+
import { bindGraph, expandSelfInPath, resolveKernelTarget, withKernelClient } from '../kernel'
|
|
13
|
+
import { ab, AGENT_BROWSER_REPO, BROWSER_DIR, findAgentBrowser } from '../lib/browser'
|
|
14
|
+
import { readConfig } from '../lib/config'
|
|
15
|
+
import { readIdentities } from '../lib/identity'
|
|
16
|
+
import { readInstances } from '../lib/instance'
|
|
17
|
+
import { fatal, log } from '../lib/log'
|
|
18
|
+
import { isMachine, output, type RawOutputOpts } from '../lib/output'
|
|
19
|
+
import { findFreePort } from '../lib/port'
|
|
20
|
+
import { run, spawnHandle } from '../lib/proc'
|
|
21
|
+
import {
|
|
22
|
+
applyViewUrlOverride,
|
|
23
|
+
candidateSlug,
|
|
24
|
+
parseViewSpec,
|
|
25
|
+
pickCandidate,
|
|
26
|
+
resolveViewCandidates,
|
|
27
|
+
rewriteLocalViewUrl,
|
|
28
|
+
type ViewCandidate,
|
|
29
|
+
} from '../lib/view/resolve'
|
|
30
|
+
import { ensureViewerAssets } from '../lib/view/server'
|
|
31
|
+
import {
|
|
32
|
+
closeSession,
|
|
33
|
+
configPath,
|
|
34
|
+
listSessions,
|
|
35
|
+
logPath,
|
|
36
|
+
saveRecord,
|
|
37
|
+
VIEW_DIR,
|
|
38
|
+
} from '../lib/view/session'
|
|
39
|
+
import { snapshotText, waitForSettledSnapshot } from '../lib/view/snapshot'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `astrale view` — open ONE view in an emulated host shell, authenticated as
|
|
43
|
+
* the CLI identity, driveable by agent-browser (default) or a real browser.
|
|
44
|
+
* Design + protocol details: VIEW_CLI_SPEC.md at the workspace root.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
type ViewOpts = KernelCommandOpts &
|
|
48
|
+
RawOutputOpts & {
|
|
49
|
+
target?: string
|
|
50
|
+
view?: string
|
|
51
|
+
list?: boolean
|
|
52
|
+
viewUrl?: string
|
|
53
|
+
handshake?: 'shell' | 'none'
|
|
54
|
+
headed?: boolean
|
|
55
|
+
browser?: boolean
|
|
56
|
+
open?: boolean
|
|
57
|
+
snapshot?: boolean
|
|
58
|
+
screenshot?: string
|
|
59
|
+
sessions?: boolean
|
|
60
|
+
close?: string | boolean
|
|
61
|
+
all?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const VIEW_PORT_BASE = 4419
|
|
65
|
+
const VIEW_PORT_SPAN = 20
|
|
66
|
+
const IDLE_MS = 30 * 60_000
|
|
67
|
+
const READY_TIMEOUT_MS = 8000
|
|
68
|
+
const STATE_TIMEOUT_MS = 25_000
|
|
69
|
+
const POLL_MS = 250
|
|
70
|
+
/** Dedicated agent-browser profile for view sessions (no cookies involved). */
|
|
71
|
+
const VIEW_PROFILE = `${BROWSER_DIR}/_view`
|
|
72
|
+
|
|
73
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
74
|
+
|
|
75
|
+
type ResolvedTarget = { id: string; path: string }
|
|
76
|
+
type ResolvedView = {
|
|
77
|
+
url: string
|
|
78
|
+
functionId: string
|
|
79
|
+
handshake: 'shell' | 'none'
|
|
80
|
+
path?: string
|
|
81
|
+
name?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function resolveSession(
|
|
85
|
+
spec: string | undefined,
|
|
86
|
+
opts: ViewOpts,
|
|
87
|
+
): Promise<{ view?: ResolvedView; target?: ResolvedTarget; candidates: ViewCandidate[] }> {
|
|
88
|
+
const parsed = spec ? parseViewSpec(spec) : undefined
|
|
89
|
+
if (parsed?.kind === 'target' && opts.target) {
|
|
90
|
+
fatal(new Error('Pass the target either as the positional or as --target, not both'))
|
|
91
|
+
}
|
|
92
|
+
const targetInput = parsed?.kind === 'target' ? parsed.path : opts.target
|
|
93
|
+
|
|
94
|
+
const resolved = await withKernelClient(opts, async (ctx) => {
|
|
95
|
+
let target: ResolvedTarget | undefined
|
|
96
|
+
if (targetInput) {
|
|
97
|
+
const { path } = await expandSelfInPath(targetInput, opts)
|
|
98
|
+
const node = (await bindGraph(ctx).get(path)) as { id?: string } | null
|
|
99
|
+
if (!node?.id) {
|
|
100
|
+
throw new AstraleError('NOT_FOUND', `target ${path} not found or not visible`)
|
|
101
|
+
}
|
|
102
|
+
target = { id: node.id, path }
|
|
103
|
+
}
|
|
104
|
+
// A bare --view-url already identifies the frontend. Its target is only
|
|
105
|
+
// shell context, so asking View:resolve for installed candidates is both
|
|
106
|
+
// unnecessary and incorrect for classes without a class-owned self view.
|
|
107
|
+
const anchor = parsed ? (parsed.kind === 'view' ? parsed.path : target?.path) : undefined
|
|
108
|
+
const candidates = anchor ? await resolveViewCandidates(ctx, anchor) : []
|
|
109
|
+
return { target, candidates }
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Bare --view-url: mount an arbitrary URL as a view (nothing installed yet).
|
|
113
|
+
if (!parsed) {
|
|
114
|
+
return {
|
|
115
|
+
view: {
|
|
116
|
+
url: rewriteLocalViewUrl(opts.viewUrl!),
|
|
117
|
+
functionId: 'dev-view',
|
|
118
|
+
handshake: opts.handshake ?? 'shell',
|
|
119
|
+
name: 'dev',
|
|
120
|
+
},
|
|
121
|
+
target: resolved.target,
|
|
122
|
+
candidates: [],
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const anchor = parsed.kind === 'view' ? parsed.path : resolved.target!.path
|
|
127
|
+
if (opts.list) {
|
|
128
|
+
return { target: resolved.target, candidates: resolved.candidates }
|
|
129
|
+
}
|
|
130
|
+
const picked = await chooseCandidate(resolved.candidates, anchor, opts)
|
|
131
|
+
let url = picked.url
|
|
132
|
+
if (opts.viewUrl) url = applyViewUrlOverride(url, opts.viewUrl)
|
|
133
|
+
url = rewriteLocalViewUrl(url)
|
|
134
|
+
return {
|
|
135
|
+
view: {
|
|
136
|
+
url,
|
|
137
|
+
functionId: picked.id,
|
|
138
|
+
handshake: opts.handshake ?? picked.handshake ?? 'shell',
|
|
139
|
+
path: picked.path,
|
|
140
|
+
name: candidateSlug(picked),
|
|
141
|
+
},
|
|
142
|
+
target: resolved.target,
|
|
143
|
+
candidates: resolved.candidates,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function chooseCandidate(
|
|
148
|
+
candidates: ViewCandidate[],
|
|
149
|
+
anchor: string,
|
|
150
|
+
opts: ViewOpts,
|
|
151
|
+
): Promise<ViewCandidate> {
|
|
152
|
+
const picked = pickCandidate(candidates, anchor, opts.view)
|
|
153
|
+
if (picked !== 'ambiguous') return picked
|
|
154
|
+
if (process.stdin.isTTY && !isMachine(opts)) {
|
|
155
|
+
const { select } = await import('@inquirer/prompts')
|
|
156
|
+
return select({
|
|
157
|
+
message: `${anchor} has ${candidates.length} views — open which?`,
|
|
158
|
+
choices: candidates.map((c) => ({
|
|
159
|
+
name: `${candidateSlug(c)} ${chalk.dim(c.url)}`,
|
|
160
|
+
value: c,
|
|
161
|
+
})),
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
throw new AstraleError(
|
|
165
|
+
'AMBIGUOUS_VIEW',
|
|
166
|
+
`${anchor} resolves ${candidates.length} views — pick one with --view <slug>: ${candidates.map(candidateSlug).join(', ')}`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The session server must run under NODE when possible: an orphaned Bun
|
|
172
|
+
* process on macOS cannot open TLS sockets at all (its TLS init needs the
|
|
173
|
+
* user session's trust services), so token mints and the kernel proxy would
|
|
174
|
+
* die once the CLI exits. The published CLI entry is node-runnable; a dev
|
|
175
|
+
* checkout builds `dist/astrale.js` on demand (Bun is present there).
|
|
176
|
+
*/
|
|
177
|
+
async function resolveServeRuntime(): Promise<{ file: string; args: string[] }> {
|
|
178
|
+
const entry = process.argv[1]
|
|
179
|
+
const node = await findOnPath('node')
|
|
180
|
+
if (node && entry?.endsWith('.js') && existsSync(entry)) return { file: node, args: [entry] }
|
|
181
|
+
if (node && entry?.endsWith('.ts')) {
|
|
182
|
+
const dist = join(dirname(entry), '..', 'dist', 'astrale.js')
|
|
183
|
+
await ensureDevDist(entry, dist)
|
|
184
|
+
if (existsSync(dist)) return { file: node, args: [dist] }
|
|
185
|
+
}
|
|
186
|
+
return { file: process.execPath, args: entry && existsSync(entry) ? [entry] : [] }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function findOnPath(name: string): Promise<string | null> {
|
|
190
|
+
const lookup =
|
|
191
|
+
process.platform === 'win32' ? run('where', [name]) : run('sh', ['-c', `command -v ${name}`])
|
|
192
|
+
const res = await lookup.catch(() => null)
|
|
193
|
+
if (!res || res.code !== 0) return null
|
|
194
|
+
return res.stdout.split(/\r?\n/)[0]?.trim() || null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Dev checkout: (re)build the node-runnable CLI bundle when missing or stale. */
|
|
198
|
+
async function ensureDevDist(entry: string, dist: string): Promise<void> {
|
|
199
|
+
const bun = (
|
|
200
|
+
globalThis as {
|
|
201
|
+
Bun?: { build: (o: object) => Promise<{ success: boolean; logs: unknown[] }> }
|
|
202
|
+
}
|
|
203
|
+
).Bun
|
|
204
|
+
if (!bun) return
|
|
205
|
+
const srcDir = join(dirname(entry), '..', 'src')
|
|
206
|
+
if (existsSync(dist) && !(await newerThan(srcDir, statSync(dist).mtimeMs))) return
|
|
207
|
+
// stderr: --json consumers parse stdout.
|
|
208
|
+
console.error('(dev) building dist/astrale.js for the session server…')
|
|
209
|
+
await bun.build({
|
|
210
|
+
entrypoints: [entry],
|
|
211
|
+
outdir: dirname(dist),
|
|
212
|
+
target: 'node',
|
|
213
|
+
format: 'esm',
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function newerThan(dir: string, mtimeMs: number): Promise<boolean> {
|
|
218
|
+
const entries = await readdir(dir, { withFileTypes: true, recursive: true })
|
|
219
|
+
for (const item of entries) {
|
|
220
|
+
if (!item.isFile()) continue
|
|
221
|
+
if (statSync(join(item.parentPath, item.name)).mtimeMs > mtimeMs) return true
|
|
222
|
+
}
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Spawn the detached session server (the CLI re-invoking itself) and wait for it. */
|
|
227
|
+
async function startSession(
|
|
228
|
+
view: ResolvedView,
|
|
229
|
+
target: ResolvedTarget | undefined,
|
|
230
|
+
opts: ViewOpts,
|
|
231
|
+
): Promise<ViewSessionRecord> {
|
|
232
|
+
await ensureViewerAssets()
|
|
233
|
+
const config = await readConfig()
|
|
234
|
+
const kernelTarget = await resolveKernelTarget(opts, config)
|
|
235
|
+
const port = await findFreePort(VIEW_PORT_BASE, VIEW_PORT_SPAN)
|
|
236
|
+
if (port === null) {
|
|
237
|
+
fatal(
|
|
238
|
+
new Error(
|
|
239
|
+
`No free port in ${VIEW_PORT_BASE}-${VIEW_PORT_BASE + VIEW_PORT_SPAN - 1} — close sessions with \`astrale view --close --all\``,
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const [instances, identities] = await Promise.all([readInstances(), readIdentities()])
|
|
245
|
+
const id = `v-${randomBytes(3).toString('hex')}`
|
|
246
|
+
const nonce = randomBytes(12).toString('hex')
|
|
247
|
+
const record: ViewSessionRecord = {
|
|
248
|
+
id,
|
|
249
|
+
pid: 0,
|
|
250
|
+
port,
|
|
251
|
+
nonce,
|
|
252
|
+
pageUrl: `http://127.0.0.1:${port}/s/${nonce}/`,
|
|
253
|
+
view,
|
|
254
|
+
target,
|
|
255
|
+
instance: opts.instance ?? (opts.url ? opts.url : instances.active),
|
|
256
|
+
identity: opts.creds ? '(pre-signed creds)' : (opts.as ?? identities.default),
|
|
257
|
+
createdAt: new Date().toISOString(),
|
|
258
|
+
}
|
|
259
|
+
const serveConfig: ViewServeConfig = {
|
|
260
|
+
session: record,
|
|
261
|
+
kernel: {
|
|
262
|
+
url: opts.url,
|
|
263
|
+
instance: opts.instance,
|
|
264
|
+
as: opts.as,
|
|
265
|
+
creds: opts.creds,
|
|
266
|
+
timeout: opts.timeout,
|
|
267
|
+
},
|
|
268
|
+
proxy: {
|
|
269
|
+
kernelUrl: kernelTarget.url,
|
|
270
|
+
caFile: kernelTarget.caFile,
|
|
271
|
+
direct: isPublicHttps(kernelTarget.url) && !kernelTarget.caFile,
|
|
272
|
+
},
|
|
273
|
+
idleMs: IDLE_MS,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await mkdir(VIEW_DIR, { recursive: true })
|
|
277
|
+
await writeFile(configPath(id), JSON.stringify(serveConfig, null, 2))
|
|
278
|
+
const logFd = openSync(logPath(id), 'a')
|
|
279
|
+
const runtime = await resolveServeRuntime()
|
|
280
|
+
const child = spawnHandle(
|
|
281
|
+
runtime.file,
|
|
282
|
+
[...runtime.args, '__view-serve', '--config', configPath(id)],
|
|
283
|
+
{
|
|
284
|
+
detached: true,
|
|
285
|
+
stdio: ['ignore', logFd, logFd],
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
child.unref()
|
|
289
|
+
closeSync(logFd)
|
|
290
|
+
if (!child.pid) fatal(new Error('Failed to spawn the view session server'))
|
|
291
|
+
const live = { ...record, pid: child.pid }
|
|
292
|
+
await saveRecord(live)
|
|
293
|
+
|
|
294
|
+
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
295
|
+
while (Date.now() < deadline) {
|
|
296
|
+
try {
|
|
297
|
+
const res = await fetch(`${live.pageUrl}state`)
|
|
298
|
+
if (res.ok) return live
|
|
299
|
+
} catch {
|
|
300
|
+
// not up yet
|
|
301
|
+
}
|
|
302
|
+
if (child.exitCode !== null) break
|
|
303
|
+
await sleep(POLL_MS)
|
|
304
|
+
}
|
|
305
|
+
const tail = await readFile(logPath(id), 'utf8').catch(() => '')
|
|
306
|
+
await closeSession(live)
|
|
307
|
+
return fatal(
|
|
308
|
+
new Error(
|
|
309
|
+
`View session server did not come up.${tail ? `\n--- server log ---\n${tail.slice(-2000)}` : ''}`,
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
type PageState = { state: string; error?: string }
|
|
315
|
+
|
|
316
|
+
async function waitForPageState(record: ViewSessionRecord): Promise<PageState> {
|
|
317
|
+
const deadline = Date.now() + STATE_TIMEOUT_MS
|
|
318
|
+
let last: PageState = { state: 'waiting' }
|
|
319
|
+
while (Date.now() < deadline) {
|
|
320
|
+
try {
|
|
321
|
+
last = (await (await fetch(`${record.pageUrl}state`)).json()) as PageState
|
|
322
|
+
if (last.state === 'connected' || last.state === 'plain' || last.state === 'failed') {
|
|
323
|
+
return last
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
// transient
|
|
327
|
+
}
|
|
328
|
+
await sleep(POLL_MS)
|
|
329
|
+
}
|
|
330
|
+
return last
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Public https kernels are dialed directly by the view iframe (the router
|
|
335
|
+
* serves CORS; Chrome's local-network-access rules forbid a public view
|
|
336
|
+
* origin fetching our loopback proxy). Local/self-signed kernels go through
|
|
337
|
+
* the proxy.
|
|
338
|
+
*/
|
|
339
|
+
function isPublicHttps(url: string): boolean {
|
|
340
|
+
try {
|
|
341
|
+
const parsed = new URL(url)
|
|
342
|
+
return (
|
|
343
|
+
parsed.protocol === 'https:' &&
|
|
344
|
+
!['localhost', '127.0.0.1', '::1', 'host.docker.internal'].includes(parsed.hostname)
|
|
345
|
+
)
|
|
346
|
+
} catch {
|
|
347
|
+
return false
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function openSystemBrowser(url: string): void {
|
|
352
|
+
const argv =
|
|
353
|
+
process.platform === 'darwin'
|
|
354
|
+
? ['open', url]
|
|
355
|
+
: process.platform === 'win32'
|
|
356
|
+
? ['cmd', '/c', 'start', '', url]
|
|
357
|
+
: ['xdg-open', url]
|
|
358
|
+
void run(argv[0], argv.slice(1)).catch(() => {})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function driveHint(headed: boolean): string {
|
|
362
|
+
return `agent-browser --profile ${VIEW_PROFILE}${headed ? ' --headed' : ''}`
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function describeState(state: PageState): string {
|
|
366
|
+
switch (state.state) {
|
|
367
|
+
case 'connected':
|
|
368
|
+
return 'connected (shell handshake, kernel calls live)'
|
|
369
|
+
case 'plain':
|
|
370
|
+
return 'mounted (plain iframe, no shell handshake)'
|
|
371
|
+
case 'failed':
|
|
372
|
+
return `failed — ${state.error ?? 'unknown error'}`
|
|
373
|
+
case 'mounting':
|
|
374
|
+
return 'still mounting (check again with a snapshot)'
|
|
375
|
+
default:
|
|
376
|
+
return 'page not loaded yet'
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function reportOpened(
|
|
381
|
+
record: ViewSessionRecord,
|
|
382
|
+
state: PageState | null,
|
|
383
|
+
mode: 'agent' | 'system' | 'none',
|
|
384
|
+
opts: ViewOpts,
|
|
385
|
+
): Promise<void> {
|
|
386
|
+
if (isMachine(opts)) {
|
|
387
|
+
output(
|
|
388
|
+
{
|
|
389
|
+
session: record,
|
|
390
|
+
state: state?.state ?? 'unopened',
|
|
391
|
+
error: state?.error,
|
|
392
|
+
driver: mode === 'agent' ? { profile: VIEW_PROFILE, headed: !!opts.headed } : mode,
|
|
393
|
+
},
|
|
394
|
+
opts,
|
|
395
|
+
)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
const label = record.view.path ?? record.view.url
|
|
399
|
+
log.success(`View session ${chalk.bold(record.id)} — ${chalk.bold(label)}`)
|
|
400
|
+
if (record.target) log.dim(` target ${record.target.path} (${record.target.id})`)
|
|
401
|
+
log.dim(
|
|
402
|
+
` identity ${record.identity ?? '(default)'} instance ${record.instance ?? '(active)'}`,
|
|
403
|
+
)
|
|
404
|
+
log.dim(` page ${record.pageUrl}`)
|
|
405
|
+
if (state) log.dim(` state ${describeState(state)}`)
|
|
406
|
+
console.log('')
|
|
407
|
+
if (mode === 'agent') {
|
|
408
|
+
console.log(chalk.bold('Drive it:'))
|
|
409
|
+
console.log(` ${driveHint(!!opts.headed)} snapshot`)
|
|
410
|
+
console.log(` ${driveHint(!!opts.headed)} click @e3`)
|
|
411
|
+
} else if (mode === 'none') {
|
|
412
|
+
console.log(chalk.bold('Open it:'))
|
|
413
|
+
console.log(` ${record.pageUrl}`)
|
|
414
|
+
}
|
|
415
|
+
console.log(`${chalk.bold('Close:')} astrale view --close ${record.id}`)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function runSnapshotExtras(
|
|
419
|
+
opts: Pick<ViewOpts, 'headed' | 'screenshot' | 'snapshot'>,
|
|
420
|
+
): Promise<void> {
|
|
421
|
+
const target = { profile: VIEW_PROFILE, headed: !!opts.headed }
|
|
422
|
+
const settled =
|
|
423
|
+
opts.screenshot || opts.snapshot
|
|
424
|
+
? await waitForSettledSnapshot(() => ab(['snapshot'], target))
|
|
425
|
+
: null
|
|
426
|
+
|
|
427
|
+
if (opts.screenshot) {
|
|
428
|
+
const shot = await ab(['screenshot', opts.screenshot], target)
|
|
429
|
+
if (!shot.ok) log.warn(`screenshot failed: ${shot.error ?? 'unknown error'}`)
|
|
430
|
+
else log.dim(` screenshot → ${opts.screenshot}`)
|
|
431
|
+
}
|
|
432
|
+
if (opts.snapshot && settled) {
|
|
433
|
+
if (!settled.ok) {
|
|
434
|
+
log.warn(`snapshot failed: ${settled.error ?? 'unknown error'}`)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
console.log(snapshotText(settled) ?? JSON.stringify(settled.data, null, 2))
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function closeCommand(opts: ViewOpts): Promise<void> {
|
|
442
|
+
const sessions = await listSessions()
|
|
443
|
+
let targets: ViewSessionRecord[]
|
|
444
|
+
if (opts.all) targets = sessions
|
|
445
|
+
else if (typeof opts.close === 'string') {
|
|
446
|
+
const match = sessions.find((s) => s.id === opts.close)
|
|
447
|
+
if (!match)
|
|
448
|
+
return fatal(new Error(`No view session "${opts.close}" — see \`astrale view --sessions\``))
|
|
449
|
+
targets = [match]
|
|
450
|
+
} else if (sessions.length <= 1) targets = sessions
|
|
451
|
+
else {
|
|
452
|
+
return fatal(
|
|
453
|
+
new Error(
|
|
454
|
+
`${sessions.length} sessions open — pass --close <id> or --close --all:\n${sessions
|
|
455
|
+
.map((s) => ` ${s.id} ${s.view.path ?? s.view.url}`)
|
|
456
|
+
.join('\n')}`,
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
for (const session of targets) await closeSession(session)
|
|
461
|
+
if (isMachine(opts)) output({ closed: targets.map((s) => s.id) }, opts)
|
|
462
|
+
else if (targets.length === 0) log.dim('No view sessions.')
|
|
463
|
+
else log.success(`Closed ${targets.map((s) => s.id).join(', ')}`)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function sessionsCommand(opts: ViewOpts): Promise<void> {
|
|
467
|
+
const sessions = await listSessions()
|
|
468
|
+
if (isMachine(opts)) {
|
|
469
|
+
output(sessions, opts)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
if (sessions.length === 0) {
|
|
473
|
+
log.dim('No view sessions.')
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
for (const s of sessions) {
|
|
477
|
+
console.log(
|
|
478
|
+
`${chalk.bold(s.id)} ${s.view.path ?? s.view.url}${s.target ? ` target ${s.target.path}` : ''} ${chalk.dim(s.pageUrl)}`,
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export default {
|
|
484
|
+
name: 'view',
|
|
485
|
+
description: 'Open one view in an emulated host shell your agent can drive',
|
|
486
|
+
arguments: [
|
|
487
|
+
{
|
|
488
|
+
name: 'spec',
|
|
489
|
+
description: 'ViewPath (/:origin:view.slug) or target node (/path or @id)',
|
|
490
|
+
required: false,
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
options: [
|
|
494
|
+
{
|
|
495
|
+
flags: '--target <path>',
|
|
496
|
+
description:
|
|
497
|
+
'Target node to open the view on (optional — some views are standalone); @self works',
|
|
498
|
+
},
|
|
499
|
+
{ flags: '--view <slug>', description: 'Pick a view when the target resolves several' },
|
|
500
|
+
{ flags: '--list', description: 'Resolve and print the candidate views; do not open' },
|
|
501
|
+
{
|
|
502
|
+
flags: '--view-url <url>',
|
|
503
|
+
description: 'Override the view frontend URL (origin swaps origin; full URL replaces)',
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
flags: '--handshake <mode>',
|
|
507
|
+
description: 'Override the mount mode (needed with a bare --view-url)',
|
|
508
|
+
choices: ['shell', 'none'],
|
|
509
|
+
},
|
|
510
|
+
{ flags: '--headed', description: 'Visible agent-browser window' },
|
|
511
|
+
{ flags: '--browser', description: 'Open in the system default browser instead' },
|
|
512
|
+
{ flags: '--no-open', description: 'Start the session and print the URL only' },
|
|
513
|
+
{ flags: '--snapshot', description: 'Print an accessibility snapshot once the view is up' },
|
|
514
|
+
{ flags: '--screenshot <file>', description: 'Save a screenshot once the view is up' },
|
|
515
|
+
{ flags: '--sessions', description: 'List active view sessions' },
|
|
516
|
+
{
|
|
517
|
+
flags: '--close [id]',
|
|
518
|
+
description: 'Close a view session (bare: the only open one; with --all: every session)',
|
|
519
|
+
},
|
|
520
|
+
{ flags: '--all', description: 'With --close: close every session' },
|
|
521
|
+
],
|
|
522
|
+
afterHelpText: `
|
|
523
|
+
What it does:
|
|
524
|
+
Renders ONE view — no GUI, no cookies, no WorkOS. It resolves the view on the
|
|
525
|
+
kernel, starts a loopback session server that emulates the shell host (real
|
|
526
|
+
handshake via @astrale-os/shell, token minted from YOUR CLI identity, kernel
|
|
527
|
+
calls proxied), and opens the page headless in agent-browser. Driving stays
|
|
528
|
+
agent-browser's job; auth follows --as/--creds/-i like any kernel command.
|
|
529
|
+
|
|
530
|
+
A session stays up ~30 min idle (heartbeat while the page is open). The view
|
|
531
|
+
gets exactly what the GUI would hand it: a delegation token, a kernel URL,
|
|
532
|
+
and your target node id.
|
|
533
|
+
|
|
534
|
+
Examples:
|
|
535
|
+
$ astrale view /crm/customers/ada # views on a node
|
|
536
|
+
$ astrale view /:crm.acme.dev:view.dashboard # explicit ViewPath
|
|
537
|
+
$ astrale view /:agents.astrale.ai:view.agent --target @f00d1234 --as alice
|
|
538
|
+
$ astrale view /crm/customers/ada --snapshot # open + show it
|
|
539
|
+
$ astrale view /:d:view.x --view-url http://localhost:8787 # local frontend, live data
|
|
540
|
+
$ astrale view --view-url http://localhost:8787/ui/x --handshake shell --target /a/b
|
|
541
|
+
$ astrale view --sessions ; astrale view --close --all
|
|
542
|
+
`,
|
|
543
|
+
action: async (spec: string | undefined, opts: ViewOpts) => {
|
|
544
|
+
if (opts.close !== undefined) return closeCommand(opts)
|
|
545
|
+
if (opts.sessions) return sessionsCommand(opts)
|
|
546
|
+
|
|
547
|
+
if (!spec && !opts.viewUrl) {
|
|
548
|
+
return fatal(
|
|
549
|
+
new Error('Nothing to open — pass a ViewPath or target node, or --view-url <url>.'),
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
const wantsAgentBrowser = !opts.browser && opts.open !== false
|
|
553
|
+
if ((opts.snapshot || opts.screenshot) && !wantsAgentBrowser) {
|
|
554
|
+
return fatal(
|
|
555
|
+
new Error('--snapshot/--screenshot need the agent-browser page (drop --browser/--no-open)'),
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
if (wantsAgentBrowser && !(await findAgentBrowser())) {
|
|
559
|
+
log.error('agent-browser is not installed — it is the engine `astrale view` drives.')
|
|
560
|
+
log.dim(' npm install -g agent-browser && agent-browser install')
|
|
561
|
+
log.dim(` npx skills add ${AGENT_BROWSER_REPO}`)
|
|
562
|
+
log.dim(' (or use --browser / --no-open)')
|
|
563
|
+
process.exit(1)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const { view, target, candidates } = await resolveSession(spec, opts)
|
|
567
|
+
if (opts.list) {
|
|
568
|
+
if (isMachine(opts)) output(candidates, opts)
|
|
569
|
+
else if (candidates.length === 0) log.dim('No views resolve here.')
|
|
570
|
+
else {
|
|
571
|
+
for (const c of candidates) {
|
|
572
|
+
console.log(
|
|
573
|
+
`${chalk.bold(candidateSlug(c))} ${c.handshake ?? 'shell'} ${c.origin} ${chalk.dim(c.url)} ${c.path}`,
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
if (!view) throw new Error('View resolution completed without a selected view')
|
|
580
|
+
|
|
581
|
+
const record = await startSession(view, target, opts)
|
|
582
|
+
|
|
583
|
+
let mode: 'agent' | 'system' | 'none' = 'none'
|
|
584
|
+
if (wantsAgentBrowser) {
|
|
585
|
+
mode = 'agent'
|
|
586
|
+
const opened = await ab(['open', record.pageUrl], {
|
|
587
|
+
profile: VIEW_PROFILE,
|
|
588
|
+
headed: !!opts.headed,
|
|
589
|
+
})
|
|
590
|
+
if (!opened.ok) {
|
|
591
|
+
await closeSession(record)
|
|
592
|
+
return fatal(
|
|
593
|
+
new Error(`agent-browser could not open the page: ${opened.error ?? 'unknown error'}`),
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
} else if (opts.browser) {
|
|
597
|
+
mode = 'system'
|
|
598
|
+
openSystemBrowser(record.pageUrl)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const state = mode === 'none' ? null : await waitForPageState(record)
|
|
602
|
+
if (state?.state === 'failed') {
|
|
603
|
+
await reportOpened(record, state, mode, opts)
|
|
604
|
+
if (opts.debug) {
|
|
605
|
+
const tail = await readFile(logPath(record.id), 'utf8').catch(() => '')
|
|
606
|
+
if (tail) console.error(`--- server log ---\n${tail.slice(-3000)}`)
|
|
607
|
+
}
|
|
608
|
+
await closeSession(record)
|
|
609
|
+
process.exit(1)
|
|
610
|
+
}
|
|
611
|
+
await reportOpened(record, state, mode, opts)
|
|
612
|
+
if (mode === 'agent') await runSnapshotExtras(opts)
|
|
613
|
+
},
|
|
614
|
+
} satisfies CommandDefinition
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import * as C from './connect-core'
|
|
4
|
+
|
|
5
|
+
// Guards the frozen export surface `@astrale-os/connect-host` borrows. If a
|
|
6
|
+
// re-export name drifts (renamed / dropped upstream), this smoke fails loudly
|
|
7
|
+
// instead of the connect-host adapter breaking at type-check time in another
|
|
8
|
+
// submodule.
|
|
9
|
+
describe('connect-core barrel', () => {
|
|
10
|
+
test('re-exports every borrowed function', () => {
|
|
11
|
+
expect(C.readIdentities).toBeTypeOf('function')
|
|
12
|
+
expect(C.getDefault).toBeTypeOf('function')
|
|
13
|
+
expect(C.getIdentity).toBeTypeOf('function')
|
|
14
|
+
expect(C.signAs).toBeTypeOf('function')
|
|
15
|
+
expect(C.listIdentityKeys).toBeTypeOf('function')
|
|
16
|
+
expect(C.readInstances).toBeTypeOf('function')
|
|
17
|
+
expect(C.resetInstancesMemo).toBeTypeOf('function')
|
|
18
|
+
expect(C.resolveInstance).toBeTypeOf('function')
|
|
19
|
+
expect(C.normalizeInstanceKernelUrl).toBeTypeOf('function')
|
|
20
|
+
expect(C.orgIdForAudience).toBeTypeOf('function')
|
|
21
|
+
expect(C.resolveInstanceTarget).toBeTypeOf('function')
|
|
22
|
+
expect(C.readConfig).toBeTypeOf('function')
|
|
23
|
+
expect(C.resolveCredential).toBeTypeOf('function')
|
|
24
|
+
expect(C.fetchWithCaFile).toBeTypeOf('function')
|
|
25
|
+
expect(C.createPaths).toBeTypeOf('function')
|
|
26
|
+
expect(C.loginViaIdp).toBeTypeOf('function')
|
|
27
|
+
expect(C.resolveIdpName).toBeTypeOf('function')
|
|
28
|
+
expect(C.readIdpSession).toBeTypeOf('function')
|
|
29
|
+
expect(C.isSessionExpired).toBeTypeOf('function')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('re-exports the error classes', () => {
|
|
33
|
+
expect(C.AuthError).toBeTypeOf('function')
|
|
34
|
+
expect(C.IdpRefreshTransientError).toBeTypeOf('function')
|
|
35
|
+
expect(C.IdpOrgMembershipError).toBeTypeOf('function')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('re-exports the paths singleton', () => {
|
|
39
|
+
expect(C.paths).toBeTypeOf('object')
|
|
40
|
+
expect(C.paths.home).toBeTypeOf('string')
|
|
41
|
+
})
|
|
42
|
+
})
|