@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.
Files changed (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. 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()
@@ -0,0 +1,4 @@
1
+ export function formatElapsed(ms: number): string {
2
+ if (ms < 1000) return `${Math.round(ms)}ms`
3
+ return `${(ms / 1000).toFixed(2)}s`
4
+ }