@astrale-os/cli 0.4.0-alpha.13
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/.check-workspace.cjs +40 -0
- package/README.md +151 -0
- package/dist/astrale.js +59749 -0
- package/package.json +90 -0
- package/src/command.ts +40 -0
- package/src/commands/__tests__/admin-instance.test.ts +73 -0
- package/src/commands/__tests__/auth-login.test.ts +178 -0
- package/src/commands/__tests__/auth-token.test.ts +223 -0
- package/src/commands/__tests__/call.test.ts +72 -0
- package/src/commands/__tests__/domain-list.test.ts +74 -0
- package/src/commands/__tests__/help-contract.test.ts +136 -0
- package/src/commands/__tests__/install-identity-override.test.ts +65 -0
- package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
- package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
- package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
- package/src/commands/__tests__/logs.test.ts +117 -0
- package/src/commands/__tests__/ls.test.ts +25 -0
- package/src/commands/__tests__/setup-plan.test.ts +61 -0
- package/src/commands/admin/status.ts +61 -0
- package/src/commands/admin/use.ts +77 -0
- package/src/commands/auth/login.ts +82 -0
- package/src/commands/auth/logout.ts +50 -0
- package/src/commands/auth/status.ts +86 -0
- package/src/commands/auth/token.ts +162 -0
- package/src/commands/browser.ts +207 -0
- package/src/commands/call.ts +300 -0
- package/src/commands/describe.ts +182 -0
- package/src/commands/domain/install.ts +420 -0
- package/src/commands/domain/list.ts +154 -0
- package/src/commands/domain/publish.ts +155 -0
- package/src/commands/get.ts +60 -0
- package/src/commands/identity/create.ts +26 -0
- package/src/commands/identity/delete.ts +18 -0
- package/src/commands/identity/export.ts +66 -0
- package/src/commands/identity/import.ts +101 -0
- package/src/commands/identity/list.ts +56 -0
- package/src/commands/identity/register.ts +170 -0
- package/src/commands/identity/sync.ts +32 -0
- package/src/commands/identity/unsync.ts +24 -0
- package/src/commands/identity/use.ts +18 -0
- package/src/commands/identity/whoami.ts +34 -0
- package/src/commands/idp/add.ts +150 -0
- package/src/commands/idp/list.ts +57 -0
- package/src/commands/idp/refresh.ts +38 -0
- package/src/commands/idp/remove.ts +36 -0
- package/src/commands/idp/show.ts +29 -0
- package/src/commands/instance/active.ts +64 -0
- package/src/commands/instance/bookmark.ts +72 -0
- package/src/commands/instance/create.ts +69 -0
- package/src/commands/instance/delete.ts +72 -0
- package/src/commands/instance/forget.ts +26 -0
- package/src/commands/instance/list.ts +149 -0
- package/src/commands/instance/status.ts +42 -0
- package/src/commands/instance/use.ts +210 -0
- package/src/commands/logs.ts +347 -0
- package/src/commands/ls.ts +229 -0
- package/src/commands/query.ts +32 -0
- package/src/commands/setup.ts +54 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/studio.ts +401 -0
- package/src/commands/token.ts +77 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +87 -0
- package/src/errors.ts +65 -0
- package/src/kernel/__tests__/auth.test.ts +77 -0
- package/src/kernel/__tests__/errors.test.ts +43 -0
- package/src/kernel/__tests__/remote-routing.test.ts +70 -0
- package/src/kernel/auth.ts +234 -0
- package/src/kernel/ca-fetch.ts +119 -0
- package/src/kernel/client.ts +191 -0
- package/src/kernel/errors.ts +280 -0
- package/src/kernel/expand.ts +217 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/options.ts +22 -0
- package/src/kernel/remote-routing.ts +88 -0
- package/src/kernel/run.ts +63 -0
- package/src/kernel/types.ts +14 -0
- package/src/lib/__tests__/admin-target.test.ts +112 -0
- package/src/lib/__tests__/binary.test.ts +56 -0
- package/src/lib/__tests__/command-dx.test.ts +58 -0
- package/src/lib/__tests__/concurrency.test.ts +62 -0
- package/src/lib/__tests__/config.test.ts +53 -0
- package/src/lib/__tests__/design.test.ts +99 -0
- package/src/lib/__tests__/domain-identity.test.ts +60 -0
- package/src/lib/__tests__/format.test.ts +22 -0
- package/src/lib/__tests__/fs-atomic.test.ts +104 -0
- package/src/lib/__tests__/identity.test.ts +79 -0
- package/src/lib/__tests__/idp-session.driver.ts +53 -0
- package/src/lib/__tests__/idp-session.test.ts +357 -0
- package/src/lib/__tests__/idp.test.ts +385 -0
- package/src/lib/__tests__/instance-candidates.test.ts +73 -0
- package/src/lib/__tests__/instance-target.test.ts +183 -0
- package/src/lib/__tests__/instance.test.ts +136 -0
- package/src/lib/__tests__/keys.test.ts +129 -0
- package/src/lib/__tests__/local-status.test.ts +202 -0
- package/src/lib/__tests__/output.test.ts +150 -0
- package/src/lib/__tests__/panel.test.ts +40 -0
- package/src/lib/__tests__/port.test.ts +44 -0
- package/src/lib/__tests__/prompt.test.ts +25 -0
- package/src/lib/__tests__/sdk-deps.test.ts +68 -0
- package/src/lib/__tests__/self.test.ts +272 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
- package/src/lib/__tests__/table.test.ts +53 -0
- package/src/lib/__tests__/update.test.ts +246 -0
- package/src/lib/__tests__/use-target.test.ts +56 -0
- package/src/lib/__tests__/validation.test.ts +34 -0
- package/src/lib/admin-domain.ts +25 -0
- package/src/lib/admin-instance.ts +26 -0
- package/src/lib/admin-target.ts +217 -0
- package/src/lib/binary.ts +131 -0
- package/src/lib/browser.ts +150 -0
- package/src/lib/command-dx.ts +161 -0
- package/src/lib/concurrency.ts +31 -0
- package/src/lib/config.ts +45 -0
- package/src/lib/domain-identity.ts +49 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/format.ts +4 -0
- package/src/lib/fs-atomic.ts +126 -0
- package/src/lib/identity.ts +256 -0
- package/src/lib/idp-session.ts +134 -0
- package/src/lib/idp.ts +876 -0
- package/src/lib/instance-candidates.ts +49 -0
- package/src/lib/instance-target.ts +182 -0
- package/src/lib/instance.ts +395 -0
- package/src/lib/keys.ts +294 -0
- package/src/lib/local-status.ts +152 -0
- package/src/lib/log.ts +116 -0
- package/src/lib/login-flow.ts +164 -0
- package/src/lib/meta.ts +86 -0
- package/src/lib/output.ts +222 -0
- package/src/lib/panel.ts +61 -0
- package/src/lib/paths.ts +11 -0
- package/src/lib/port.ts +41 -0
- package/src/lib/proc.ts +82 -0
- package/src/lib/prompt.ts +136 -0
- package/src/lib/provision-instance.ts +170 -0
- package/src/lib/sdk-deps.ts +104 -0
- package/src/lib/self.ts +166 -0
- package/src/lib/skills.ts +171 -0
- package/src/lib/table.ts +62 -0
- package/src/lib/update.ts +315 -0
- package/src/lib/use-target.ts +24 -0
- package/src/lib/validation.ts +59 -0
- package/src/program.ts +200 -0
- package/src/registry.ts +59 -0
- package/src/setup/__tests__/util.test.ts +29 -0
- package/src/setup/engine.ts +83 -0
- package/src/setup/render.ts +109 -0
- package/src/setup/steps/admin.ts +78 -0
- package/src/setup/steps/agent-browser.ts +81 -0
- package/src/setup/steps/auth.ts +54 -0
- package/src/setup/steps/domain.ts +68 -0
- package/src/setup/steps/index.ts +18 -0
- package/src/setup/steps/instance.ts +119 -0
- package/src/setup/steps/skills-bridge.ts +70 -0
- package/src/setup/steps/skills.ts +59 -0
- package/src/setup/types.ts +61 -0
- package/src/setup/util.ts +34 -0
- package/src/test-utils.ts +18 -0
- package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
- package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
- package/studio/client/dist/index.html +13 -0
- package/studio/package.json +62 -0
- package/studio/server/agent/ask.ts +68 -0
- package/studio/server/agent/bridge-mcp.ts +182 -0
- package/studio/server/agent/bridge.ts +188 -0
- package/studio/server/agent/claude.ts +666 -0
- package/studio/server/agent/mock.ts +186 -0
- package/studio/server/agent/prompt.ts +202 -0
- package/studio/server/agent/registry.ts +29 -0
- package/studio/server/agent/runner.ts +484 -0
- package/studio/server/agent/schema-map.ts +112 -0
- package/studio/server/agent/types.ts +120 -0
- package/studio/server/api.ts +574 -0
- package/studio/server/cache.ts +138 -0
- package/studio/server/detect.ts +81 -0
- package/studio/server/domain.ts +70 -0
- package/studio/server/index.ts +136 -0
- package/studio/server/introspect/anatomy-extras.ts +398 -0
- package/studio/server/introspect/anatomy.ts +108 -0
- package/studio/server/introspect/bundle.ts +57 -0
- package/studio/server/introspect/core-extractor.ts +119 -0
- package/studio/server/introspect/core.ts +44 -0
- package/studio/server/introspect/diff.ts +133 -0
- package/studio/server/introspect/extractor.ts +102 -0
- package/studio/server/introspect/hash.ts +21 -0
- package/studio/server/introspect/overlay-tsmorph.ts +874 -0
- package/studio/server/introspect/overlay.ts +57 -0
- package/studio/server/introspect/runtime.ts +99 -0
- package/studio/server/introspect/schema-refs.ts +46 -0
- package/studio/server/lifecycle.ts +38 -0
- package/studio/server/sse.ts +57 -0
- package/studio/server/state/baseline.ts +211 -0
- package/studio/server/state/catalog.ts +117 -0
- package/studio/server/state/comments.ts +321 -0
- package/studio/server/state/context.ts +167 -0
- package/studio/server/state/copy.ts +156 -0
- package/studio/server/state/create.ts +156 -0
- package/studio/server/state/documents.ts +70 -0
- package/studio/server/state/env.ts +161 -0
- package/studio/server/state/git.ts +75 -0
- package/studio/server/state/handoff.ts +55 -0
- package/studio/server/state/harness-gateway.ts +181 -0
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/instance.ts +244 -0
- package/studio/server/state/integrations.ts +55 -0
- package/studio/server/state/layout.ts +55 -0
- package/studio/server/state/settings.ts +39 -0
- package/studio/server/state/store.ts +97 -0
- package/studio/server/state/updates.ts +63 -0
- package/studio/server/state/usage.ts +37 -0
- package/studio/server/state/views.ts +138 -0
- package/studio/server/state/visibility.ts +33 -0
- package/studio/server/watch.ts +81 -0
- package/studio/server/workspace-state.ts +26 -0
- package/studio/server/workspace-watch.ts +101 -0
- package/studio/shared/types.ts +873 -0
- package/studio/tsconfig.json +23 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { writeFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import type { OutputOpts } from './output'
|
|
5
|
+
|
|
6
|
+
import { output } from './output'
|
|
7
|
+
|
|
8
|
+
/** A binary kernel result: bytes (or a stream of them) plus its content-type. */
|
|
9
|
+
export type BinaryLike = {
|
|
10
|
+
status: number
|
|
11
|
+
contentType: string
|
|
12
|
+
body: Uint8Array | ReadableStream<Uint8Array>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Collapse a (possibly streamed) binary body into a single byte array. */
|
|
16
|
+
export async function readBinaryBody(
|
|
17
|
+
body: Uint8Array | ReadableStream<Uint8Array>,
|
|
18
|
+
): Promise<Uint8Array> {
|
|
19
|
+
if (body instanceof Uint8Array) return body
|
|
20
|
+
|
|
21
|
+
const chunks: Uint8Array[] = []
|
|
22
|
+
const reader = body.getReader()
|
|
23
|
+
let total = 0
|
|
24
|
+
while (true) {
|
|
25
|
+
const { done, value } = await reader.read()
|
|
26
|
+
if (done) break
|
|
27
|
+
if (!value) continue
|
|
28
|
+
chunks.push(value)
|
|
29
|
+
total += value.byteLength
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const out = new Uint8Array(total)
|
|
33
|
+
let offset = 0
|
|
34
|
+
for (const chunk of chunks) {
|
|
35
|
+
out.set(chunk, offset)
|
|
36
|
+
offset += chunk.byteLength
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isTextLike(contentType: string): boolean {
|
|
42
|
+
const ct = contentType.toLowerCase()
|
|
43
|
+
return (
|
|
44
|
+
ct.startsWith('text/') ||
|
|
45
|
+
ct.includes('json') ||
|
|
46
|
+
ct.includes('xml') ||
|
|
47
|
+
ct.includes('event-stream')
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function humanSize(bytes: number): string {
|
|
52
|
+
if (bytes < 1024) return `${bytes} B`
|
|
53
|
+
const units = ['kB', 'MB', 'GB']
|
|
54
|
+
let v = bytes / 1024
|
|
55
|
+
let i = 0
|
|
56
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
57
|
+
v /= 1024
|
|
58
|
+
i++
|
|
59
|
+
}
|
|
60
|
+
return `${v.toFixed(1)} ${units[i]}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The `--json` envelope: text-like inlines decoded text, otherwise base64. */
|
|
64
|
+
function jsonEnvelope(resp: BinaryLike, bytes: Uint8Array): Record<string, unknown> {
|
|
65
|
+
if (isTextLike(resp.contentType)) {
|
|
66
|
+
return {
|
|
67
|
+
status: resp.status,
|
|
68
|
+
contentType: resp.contentType,
|
|
69
|
+
body: new TextDecoder().decode(bytes),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
status: resp.status,
|
|
74
|
+
contentType: resp.contentType,
|
|
75
|
+
bodyBase64: Buffer.from(bytes).toString('base64'),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Present a binary kind result.
|
|
81
|
+
*
|
|
82
|
+
* Precedence:
|
|
83
|
+
* -o <file> → write raw bytes to a file (+ a dim summary on stderr)
|
|
84
|
+
* --raw → raw bytes to stdout
|
|
85
|
+
* --json → base64/text-wrapped JSON object (jq-friendly)
|
|
86
|
+
* non-TTY (pipe) → raw bytes to stdout (`astrale call … > out.png`)
|
|
87
|
+
* TTY, text-like → decode and print the text
|
|
88
|
+
* TTY, otherwise → a one-line summary (don't spew binary at a terminal)
|
|
89
|
+
*/
|
|
90
|
+
export async function presentBinary(
|
|
91
|
+
resp: BinaryLike,
|
|
92
|
+
opts: OutputOpts,
|
|
93
|
+
io?: { outFile?: string },
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const bytes = await readBinaryBody(resp.body)
|
|
96
|
+
|
|
97
|
+
if (io?.outFile) {
|
|
98
|
+
await writeFile(io.outFile, bytes)
|
|
99
|
+
process.stderr.write(
|
|
100
|
+
chalk.dim(` wrote ${humanSize(bytes.length)} (${resp.contentType}) → ${io.outFile}\n`),
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (opts.raw) {
|
|
106
|
+
process.stdout.write(bytes)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (opts.json) {
|
|
111
|
+
output(jsonEnvelope(resp, bytes), opts)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!(process.stdout.isTTY ?? false)) {
|
|
116
|
+
process.stdout.write(bytes)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isTextLike(resp.contentType)) {
|
|
121
|
+
const text = new TextDecoder().decode(bytes)
|
|
122
|
+
process.stdout.write(text.endsWith('\n') ? text : text + '\n')
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
process.stdout.write(
|
|
127
|
+
chalk.dim(
|
|
128
|
+
` <binary · ${resp.contentType} · ${humanSize(bytes.length)}> — pipe or use -o <file> to save\n`,
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { paths } from './env'
|
|
5
|
+
import { run } from './proc'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `astrale browser` is a thin orchestrator over `agent-browser`
|
|
9
|
+
* (https://github.com/vercel-labs/agent-browser) — the Rust browser-automation
|
|
10
|
+
* CLI that AI agents drive. We own only the *session*: resolve the active
|
|
11
|
+
* instance's GUI origin, pin a persistent per-instance profile so the WorkOS
|
|
12
|
+
* login cookie survives across runs, and verify auth. Driving (snapshot/click/
|
|
13
|
+
* eval/…) is agent-browser's job, invoked directly by the agent afterward.
|
|
14
|
+
*
|
|
15
|
+
* Why a profile and not an injected token: the GUI session is an httpOnly
|
|
16
|
+
* sealed cookie (`astrale_router_session`) with no token-injection path, so the
|
|
17
|
+
* only way to "use my session" is to drive a browser whose profile already
|
|
18
|
+
* holds the cookie — minted once via interactive login, reused thereafter.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const BROWSER_DIR = join(paths.home, 'browser')
|
|
22
|
+
export const BROWSER_SESSION_PATH = join(paths.home, 'browser.json')
|
|
23
|
+
export const AGENT_BROWSER_REPO = 'vercel-labs/agent-browser'
|
|
24
|
+
|
|
25
|
+
/** Per-instance persistent profile dir handed to `agent-browser --profile`. */
|
|
26
|
+
export function profileDirFor(host: string): string {
|
|
27
|
+
return join(BROWSER_DIR, host)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The last-connected browser session, read by agents to know how to drive. */
|
|
31
|
+
export type BrowserSession = {
|
|
32
|
+
/** GUI origin, e.g. `https://alpha1.eu.astrale.ai`. */
|
|
33
|
+
url: string
|
|
34
|
+
host: string
|
|
35
|
+
/** Profile dir, or null when attached to an external Chrome over CDP. */
|
|
36
|
+
profile: string | null
|
|
37
|
+
/** CDP endpoint (`9222` or a ws URL) when in attach mode. */
|
|
38
|
+
cdp: string | null
|
|
39
|
+
email?: string
|
|
40
|
+
updatedAt: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readSession(): Promise<BrowserSession | null> {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(await readFile(BROWSER_SESSION_PATH, 'utf8')) as BrowserSession
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function saveSession(session: BrowserSession): Promise<void> {
|
|
52
|
+
await mkdir(paths.home, { recursive: true })
|
|
53
|
+
await writeFile(BROWSER_SESSION_PATH, `${JSON.stringify(session, null, 2)}\n`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Resolve `agent-browser` on PATH; null when not installed. */
|
|
57
|
+
export async function findAgentBrowser(): Promise<string | null> {
|
|
58
|
+
// `command -v` is a POSIX shell builtin (no `command` executable exists on
|
|
59
|
+
// Windows), so resolve via `where` on Windows and a shell on Unix.
|
|
60
|
+
const lookup =
|
|
61
|
+
process.platform === 'win32'
|
|
62
|
+
? run('where', ['agent-browser'])
|
|
63
|
+
: run('sh', ['-c', 'command -v agent-browser'])
|
|
64
|
+
const res = await lookup.catch(() => null)
|
|
65
|
+
if (!res || res.code !== 0) return null
|
|
66
|
+
const path = res.stdout.split(/\r?\n/)[0]?.trim()
|
|
67
|
+
return path || null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type AbResult = {
|
|
71
|
+
ok: boolean
|
|
72
|
+
data: unknown
|
|
73
|
+
error: string | null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Connection target for an agent-browser invocation: either a persistent
|
|
78
|
+
* profile dir or a CDP endpoint (mutually exclusive).
|
|
79
|
+
*/
|
|
80
|
+
export type AbTarget = { profile?: string; cdp?: string }
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run an `agent-browser` command with `--json` and parse its envelope
|
|
84
|
+
* (`{success, data, error}`). Global flags (`--profile`/`--cdp`/`--headed`)
|
|
85
|
+
* precede the subcommand; `--json` trails the args, matching the CLI's parser.
|
|
86
|
+
*/
|
|
87
|
+
export async function ab(
|
|
88
|
+
args: string[],
|
|
89
|
+
opts: AbTarget & { headed?: boolean } = {},
|
|
90
|
+
): Promise<AbResult> {
|
|
91
|
+
const argv = ['agent-browser']
|
|
92
|
+
if (opts.profile) argv.push('--profile', opts.profile)
|
|
93
|
+
if (opts.cdp) argv.push('--cdp', opts.cdp)
|
|
94
|
+
if (opts.headed) argv.push('--headed')
|
|
95
|
+
argv.push(...args, '--json')
|
|
96
|
+
|
|
97
|
+
const { code, stdout, stderr } = await run(argv[0], argv.slice(1))
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(stdout) as { success?: boolean; data?: unknown; error?: string }
|
|
101
|
+
if (parsed && typeof parsed === 'object' && 'success' in parsed) {
|
|
102
|
+
return { ok: !!parsed.success, data: parsed.data ?? null, error: parsed.error ?? null }
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// fall through to exit-code interpretation
|
|
106
|
+
}
|
|
107
|
+
return { ok: code === 0, data: null, error: stderr.trim() || null }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Reads the GUI session from whatever origin the page currently sits on. During
|
|
111
|
+
// an interactive login the page is on the IdP origin, where `/auth/me` 404s and
|
|
112
|
+
// the fetch rejects → {authed:false}; once the router redirects back to the GUI
|
|
113
|
+
// it resolves to the authenticated user. Returned as a value (not top-level
|
|
114
|
+
// await — agent-browser's `eval` forbids it) so page.evaluate auto-resolves it.
|
|
115
|
+
const AUTH_EVAL =
|
|
116
|
+
"fetch('/auth/me',{credentials:'include'})" +
|
|
117
|
+
'.then(r=>r.json())' +
|
|
118
|
+
'.then(j=>({authed:!!j.authenticated,email:j.user&&j.user.email}))' +
|
|
119
|
+
'.catch(()=>({authed:false}))'
|
|
120
|
+
|
|
121
|
+
export type AuthState = { authed: boolean; email?: string }
|
|
122
|
+
|
|
123
|
+
function readAuthResult(res: AbResult): AuthState {
|
|
124
|
+
const result = (res.data as { result?: unknown } | null)?.result
|
|
125
|
+
if (result && typeof result === 'object') {
|
|
126
|
+
const r = result as { authed?: boolean; email?: string }
|
|
127
|
+
return { authed: !!r.authed, email: r.email }
|
|
128
|
+
}
|
|
129
|
+
return { authed: false }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Poll auth on the current page (no navigation — safe during login redirects).
|
|
134
|
+
* Pass `headed:true` while a sign-in window is open: agent-browser defaults to
|
|
135
|
+
* headless, so an unflagged command flips the live window back to headless and
|
|
136
|
+
* closes it.
|
|
137
|
+
*/
|
|
138
|
+
export async function pollAuth(target: AbTarget & { headed?: boolean }): Promise<AuthState> {
|
|
139
|
+
return readAuthResult(await ab(['eval', AUTH_EVAL], target))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Navigate to the GUI, then read auth (used for the silent reuse check). */
|
|
143
|
+
export async function navigateAndCheck(
|
|
144
|
+
url: string,
|
|
145
|
+
target: AbTarget & { headed?: boolean },
|
|
146
|
+
): Promise<AuthState> {
|
|
147
|
+
const opened = await ab(['open', url], target)
|
|
148
|
+
if (!opened.ok) return { authed: false }
|
|
149
|
+
return pollAuth(target)
|
|
150
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { Command, CommanderError } from 'commander'
|
|
2
|
+
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
|
|
5
|
+
export type CommandCatalogEntry = {
|
|
6
|
+
path: string[]
|
|
7
|
+
usage: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function collectCommandCatalog(program: Command): CommandCatalogEntry[] {
|
|
11
|
+
const out: CommandCatalogEntry[] = []
|
|
12
|
+
|
|
13
|
+
function walk(command: Command, path: string[]): void {
|
|
14
|
+
for (const child of command.commands) {
|
|
15
|
+
const childPath = [...path, child.name()]
|
|
16
|
+
if (child.commands.length === 0) {
|
|
17
|
+
out.push({ path: childPath, usage: usageFor(childPath, child) })
|
|
18
|
+
}
|
|
19
|
+
walk(child, childPath)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
walk(program, [])
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderCommanderError(
|
|
28
|
+
program: Command,
|
|
29
|
+
error: CommanderError,
|
|
30
|
+
argv = process.argv.slice(2),
|
|
31
|
+
): string {
|
|
32
|
+
const tokens = stripOptions(argv)
|
|
33
|
+
const catalog = collectCommandCatalog(program)
|
|
34
|
+
const matched = matchRegisteredPrefix(program, tokens)
|
|
35
|
+
|
|
36
|
+
if (matched.path.length === 0 && tokens.length > 0) {
|
|
37
|
+
return renderUnknownCommand(tokens, catalog)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (error.code === 'commander.missingArgument') {
|
|
41
|
+
const usage = usageFor(matched.path, matched.command)
|
|
42
|
+
const argName = error.message.match(/'([^']+)'/)?.[1]
|
|
43
|
+
return [
|
|
44
|
+
`Missing required argument${argName ? ` ${chalk.bold(`<${argName}>`)}` : ''} for ${chalk.bold(
|
|
45
|
+
`astrale ${matched.path.join(' ')}`,
|
|
46
|
+
)}`,
|
|
47
|
+
'',
|
|
48
|
+
'Usage:',
|
|
49
|
+
` astrale ${usage}`,
|
|
50
|
+
].join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (error.code === 'commander.excessArguments') {
|
|
54
|
+
const usage = usageFor(matched.path, matched.command)
|
|
55
|
+
const extra = tokens.slice(matched.path.length).join(' ')
|
|
56
|
+
return [
|
|
57
|
+
`Unexpected argument${extra.includes(' ') ? 's' : ''} for ${chalk.bold(
|
|
58
|
+
`astrale ${matched.path.join(' ')}`,
|
|
59
|
+
)}${extra ? `: ${extra}` : ''}`,
|
|
60
|
+
'',
|
|
61
|
+
'Usage:',
|
|
62
|
+
` astrale ${usage}`,
|
|
63
|
+
].join('\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const suggestions = nearestCommands(tokens.join(' '), catalog)
|
|
67
|
+
return [
|
|
68
|
+
error.message,
|
|
69
|
+
...(suggestions.length > 0
|
|
70
|
+
? ['', 'Did you mean:', ...suggestions.map((s) => ` astrale ${s}`)]
|
|
71
|
+
: []),
|
|
72
|
+
].join('\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderUnknownCommand(tokens: string[], catalog: CommandCatalogEntry[]): string {
|
|
76
|
+
const command = tokens.join(' ')
|
|
77
|
+
const first = tokens[0]
|
|
78
|
+
const namespaceMatches = catalog.filter((entry) => entry.path.at(-1) === first)
|
|
79
|
+
if (namespaceMatches.length > 0) {
|
|
80
|
+
return [
|
|
81
|
+
`Unknown command: ${chalk.bold(`astrale ${command}`)}`,
|
|
82
|
+
'',
|
|
83
|
+
`"${first}" is available under:`,
|
|
84
|
+
...namespaceMatches.map((entry) => ` astrale ${entry.usage}`),
|
|
85
|
+
].join('\n')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const suggestions = nearestCommands(command, catalog)
|
|
89
|
+
return [
|
|
90
|
+
`Unknown command: ${chalk.bold(`astrale ${command}`)}`,
|
|
91
|
+
...(suggestions.length > 0
|
|
92
|
+
? ['', 'Did you mean:', ...suggestions.map((s) => ` astrale ${s}`)]
|
|
93
|
+
: []),
|
|
94
|
+
].join('\n')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function matchRegisteredPrefix(
|
|
98
|
+
program: Command,
|
|
99
|
+
tokens: string[],
|
|
100
|
+
): { command: Command; path: string[] } {
|
|
101
|
+
let current = program
|
|
102
|
+
const path: string[] = []
|
|
103
|
+
for (const token of tokens) {
|
|
104
|
+
const next = current.commands.find(
|
|
105
|
+
(cmd) => cmd.name() === token || cmd.aliases().includes(token),
|
|
106
|
+
)
|
|
107
|
+
if (!next) break
|
|
108
|
+
current = next
|
|
109
|
+
path.push(current.name())
|
|
110
|
+
}
|
|
111
|
+
return { command: current, path }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function usageFor(path: string[], command: Command): string {
|
|
115
|
+
const suffix = command
|
|
116
|
+
.usage()
|
|
117
|
+
.replace(/\[options\]\s*/g, '')
|
|
118
|
+
.replace(/\s+/g, ' ')
|
|
119
|
+
.trim()
|
|
120
|
+
return [path.join(' '), suffix].filter(Boolean).join(' ')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stripOptions(argv: string[]): string[] {
|
|
124
|
+
const out: string[] = []
|
|
125
|
+
for (const token of argv) {
|
|
126
|
+
if (token === '--') break
|
|
127
|
+
if (token.startsWith('-')) continue
|
|
128
|
+
out.push(token)
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function nearestCommands(input: string, catalog: CommandCatalogEntry[]): string[] {
|
|
134
|
+
if (!input) return []
|
|
135
|
+
return catalog
|
|
136
|
+
.map((entry) => ({ usage: entry.usage, score: similarity(input, entry.path.join(' ')) }))
|
|
137
|
+
.filter((entry) => entry.score >= 0.45)
|
|
138
|
+
.sort((a, b) => b.score - a.score || a.usage.localeCompare(b.usage))
|
|
139
|
+
.slice(0, 3)
|
|
140
|
+
.map((entry) => entry.usage)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function similarity(a: string, b: string): number {
|
|
144
|
+
const max = Math.max(a.length, b.length)
|
|
145
|
+
if (max === 0) return 1
|
|
146
|
+
return 1 - levenshtein(a, b) / max
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function levenshtein(a: string, b: string): number {
|
|
150
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
151
|
+
const curr = Array.from({ length: b.length + 1 }, () => 0)
|
|
152
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
153
|
+
curr[0] = i
|
|
154
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
155
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
156
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
|
|
157
|
+
}
|
|
158
|
+
prev.splice(0, prev.length, ...curr)
|
|
159
|
+
}
|
|
160
|
+
return prev[b.length] ?? 0
|
|
161
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal bounded-concurrency map. No external dependency (the repo
|
|
3
|
+
* hand-rolls with `Promise.all`); a tiny worker pool is enough for the
|
|
4
|
+
* `domain dev up` fan-out.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run `fn` over `items` with at most `limit` in flight at once. Results
|
|
9
|
+
* are returned in input order regardless of completion order. The first
|
|
10
|
+
* rejection rejects the returned promise (in-flight work still settles).
|
|
11
|
+
*/
|
|
12
|
+
export async function mapBounded<T, R>(
|
|
13
|
+
items: readonly T[],
|
|
14
|
+
limit: number,
|
|
15
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
16
|
+
): Promise<R[]> {
|
|
17
|
+
const results: R[] = []
|
|
18
|
+
const bound = Math.max(1, Math.min(limit, items.length))
|
|
19
|
+
let next = 0
|
|
20
|
+
|
|
21
|
+
async function worker(): Promise<void> {
|
|
22
|
+
while (true) {
|
|
23
|
+
const i = next++
|
|
24
|
+
if (i >= items.length) return
|
|
25
|
+
results[i] = await fn(items[i] as T, i)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await Promise.all(Array.from({ length: bound }, () => worker()))
|
|
30
|
+
return results
|
|
31
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
import { AdminTargetConfigSchema, DEFAULT_ADMIN_TARGET_CONFIG } from './admin-target'
|
|
6
|
+
import { log } from './log'
|
|
7
|
+
import { CONFIG_PATH } from './paths'
|
|
8
|
+
|
|
9
|
+
export const AstraleConfigSchema = z.object({
|
|
10
|
+
issuer: z.string().url().default('https://unregistered.invalid'),
|
|
11
|
+
admin: AdminTargetConfigSchema.default(DEFAULT_ADMIN_TARGET_CONFIG),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export type AstraleConfig = z.infer<typeof AstraleConfigSchema>
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_CONFIG: AstraleConfig = AstraleConfigSchema.parse({})
|
|
17
|
+
|
|
18
|
+
export async function readConfig(): Promise<AstraleConfig> {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(CONFIG_PATH, 'utf-8')
|
|
21
|
+
return AstraleConfigSchema.parse(JSON.parse(raw))
|
|
22
|
+
} catch (e) {
|
|
23
|
+
// A present-but-broken config (invalid JSON or failed validation) should
|
|
24
|
+
// surface; a missing file (readFile ENOENT) stays silent — that's the
|
|
25
|
+
// normal first-run case and defaults are expected.
|
|
26
|
+
if (e instanceof z.ZodError || e instanceof SyntaxError) {
|
|
27
|
+
log.warn(`Invalid config at ${CONFIG_PATH} — using defaults`)
|
|
28
|
+
}
|
|
29
|
+
return DEFAULT_CONFIG
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function writeConfig(config: AstraleConfig): Promise<void> {
|
|
34
|
+
await mkdir(dirname(CONFIG_PATH), { recursive: true })
|
|
35
|
+
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function configExists(): Promise<boolean> {
|
|
39
|
+
try {
|
|
40
|
+
await readFile(CONFIG_PATH)
|
|
41
|
+
return true
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { AstraleError } from '../errors'
|
|
5
|
+
|
|
6
|
+
export async function loadPrivateJwk(keyPath: string): Promise<Record<string, unknown>> {
|
|
7
|
+
const filePath = resolve(keyPath)
|
|
8
|
+
const raw = await readFile(filePath, 'utf-8')
|
|
9
|
+
return JSON.parse(raw) as Record<string, unknown>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* True when the kernel rejected an `installDomain` call because the
|
|
14
|
+
* identity binding's signature failed to verify — i.e. the private/public
|
|
15
|
+
* JWK halves don't match. Callers re-throw with a regenerate-the-keypair
|
|
16
|
+
* hint instead of leaking the raw `AuthenticationError`.
|
|
17
|
+
*/
|
|
18
|
+
export function isSignatureVerificationError(e: unknown): boolean {
|
|
19
|
+
if (!e || typeof e !== 'object') return false
|
|
20
|
+
const name = (e as { name?: unknown }).name
|
|
21
|
+
const msg = (e as { message?: unknown }).message
|
|
22
|
+
return (
|
|
23
|
+
name === 'AuthenticationError' &&
|
|
24
|
+
typeof msg === 'string' &&
|
|
25
|
+
/signature verification failed/i.test(msg)
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the JOSE algorithm to use with this key. Prefers `privateJwk.alg`
|
|
31
|
+
* when present, falls back to inferring from `crv`/`kty` so keys generated
|
|
32
|
+
* by older CLIs (which didn't stamp `alg`) keep working without manual
|
|
33
|
+
* editing — see META_TRACE #34. Throws a clean error if neither path
|
|
34
|
+
* resolves an algorithm.
|
|
35
|
+
*/
|
|
36
|
+
export function inferAlg(privateJwk: Record<string, unknown>, keyPath?: string): string {
|
|
37
|
+
const explicit = privateJwk.alg
|
|
38
|
+
if (typeof explicit === 'string' && explicit.length > 0) return explicit
|
|
39
|
+
const crv = privateJwk.crv
|
|
40
|
+
const kty = privateJwk.kty
|
|
41
|
+
if (kty === 'EC' && crv === 'P-256') return 'ES256'
|
|
42
|
+
if (kty === 'OKP' && crv === 'Ed25519') return 'EdDSA'
|
|
43
|
+
const where = keyPath ? ` at ${keyPath}` : ''
|
|
44
|
+
throw new AstraleError(
|
|
45
|
+
'INVALID_KEY_FILE',
|
|
46
|
+
`JWK${where} is missing both \`alg\` and a recognizable \`(kty, crv)\` pair — cannot pick a signing algorithm.`,
|
|
47
|
+
'Re-stamp the file with `"alg": "ES256"` (P-256 EC keys) or `"alg": "EdDSA"` (Ed25519 OKP keys), or regenerate via `astrale domain init` / `astrale identity create`.',
|
|
48
|
+
)
|
|
49
|
+
}
|
package/src/lib/env.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type Paths = {
|
|
5
|
+
home: string
|
|
6
|
+
keys: string
|
|
7
|
+
data: string
|
|
8
|
+
config: string
|
|
9
|
+
install: string
|
|
10
|
+
identities: string
|
|
11
|
+
instances: string
|
|
12
|
+
idps: string
|
|
13
|
+
idpSessionsDir: string
|
|
14
|
+
/** Per-IdP config dir: `~/.astrale/idps/<name>/`. */
|
|
15
|
+
idpDir: (name: string) => string
|
|
16
|
+
/** Per-identity IdP session cache: `~/.astrale/idp-sessions/<name>.json`. */
|
|
17
|
+
idpSession: (identityName: string) => string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the Astrale home dir.
|
|
22
|
+
*
|
|
23
|
+
* Priority: explicit arg → `ASTRALE_HOME` env var → `$HOME/.astrale`.
|
|
24
|
+
*/
|
|
25
|
+
function resolveHome(home?: string): string {
|
|
26
|
+
return home ?? process.env.ASTRALE_HOME ?? join(homedir(), '.astrale')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createPaths(home?: string): Paths {
|
|
30
|
+
const base = resolveHome(home)
|
|
31
|
+
const idpsDir = join(base, 'idps')
|
|
32
|
+
const idpSessionsDir = join(base, 'idp-sessions')
|
|
33
|
+
return {
|
|
34
|
+
home: base,
|
|
35
|
+
keys: process.env.ASTRALE_KEYS_DIR ?? join(base, 'keys'),
|
|
36
|
+
data: process.env.ASTRALE_DATA_DIR ?? join(base, 'data'),
|
|
37
|
+
config: join(base, 'config.json'),
|
|
38
|
+
install: join(base, 'install.json'),
|
|
39
|
+
identities: join(base, 'identities.json'),
|
|
40
|
+
instances: join(base, 'instances.json'),
|
|
41
|
+
idps: join(idpsDir, 'index.json'),
|
|
42
|
+
idpSessionsDir,
|
|
43
|
+
idpDir: (name: string) => join(idpsDir, name),
|
|
44
|
+
idpSession: (identityName: string) => join(idpSessionsDir, `${identityName}.json`),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Default singleton used by all lib modules. */
|
|
49
|
+
export const paths: Paths = createPaths()
|