@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,280 @@
1
+ import chalk from 'chalk'
2
+
3
+ import type { SelfExpansionMeta } from './expand'
4
+
5
+ import { AstraleError } from '../errors'
6
+ import { decodeJwtExpiration, readLocalStatus, type LocalStatus } from '../lib/local-status'
7
+ import { log } from '../lib/log'
8
+
9
+ type FieldError = { path: string[]; code: string; message: string }
10
+ type InvariantError = { code: string; message: string; context?: unknown }
11
+
12
+ /**
13
+ * Format and display a kernel client error.
14
+ *
15
+ * Handles AstraleError (CLI-local) and every error class exported by
16
+ * @astrale-os/kernel-client: ConnectionError, DisconnectedError,
17
+ * TimeoutError, AuthenticationError, PermissionDeniedError, NotFoundError,
18
+ * KernelError and its subclasses (ValidationError, InvariantViolationError).
19
+ *
20
+ * When `debug` is true, additional diagnostic information (class name, full
21
+ * error chain, attached url/details) is printed after the user-facing line.
22
+ */
23
+ export async function formatKernelError(
24
+ error: unknown,
25
+ isRaw: boolean,
26
+ urlArg = '',
27
+ debug = false,
28
+ opts: { credential?: string } = {},
29
+ ): Promise<void> {
30
+ const url =
31
+ urlArg || (error instanceof Error ? ((error as Error & { url?: string }).url ?? '') : '')
32
+ const localContext = await contextForError(error)
33
+ const credentialExpiration = opts.credential ? decodeJwtExpiration(opts.credential) : null
34
+ // Handle AstraleError (AuthError, etc.) with structured hints
35
+ if (error instanceof AstraleError) {
36
+ if (isRaw) {
37
+ writeRaw({ error: error.code, message: error.message, hint: error.hint })
38
+ } else {
39
+ log.error(`${chalk.bold(error.code)}: ${error.message}`)
40
+ if (error.hint) log.dim(` ${error.hint}`)
41
+ }
42
+ if (debug) printDebug(error, url)
43
+ return
44
+ }
45
+
46
+ if (!(error instanceof Error)) {
47
+ if (isRaw) writeRaw({ error: 'UNKNOWN', message: String(error) })
48
+ else log.error(String(error))
49
+ return
50
+ }
51
+
52
+ const name = error.name
53
+
54
+ switch (name) {
55
+ case 'ConnectionError':
56
+ if (isRaw)
57
+ writeRaw({ error: 'CONNECTION_ERROR', message: error.message, url, context: localContext })
58
+ else {
59
+ log.error(`Could not connect to ${chalk.bold(url || 'kernel')}`)
60
+ log.dim(` ${error.message}`)
61
+ log.dim(' Is the kernel running? Try: astrale status')
62
+ printLocalContext(localContext)
63
+ }
64
+ break
65
+
66
+ case 'DisconnectedError':
67
+ if (isRaw) writeRaw({ error: 'DISCONNECTED', message: error.message })
68
+ else {
69
+ log.error('Connection closed while request was pending')
70
+ log.dim(' The kernel may have been stopped or restarted. Retry the command.')
71
+ }
72
+ break
73
+
74
+ case 'TimeoutError': {
75
+ const timeoutMs = (error as { timeoutMs?: number }).timeoutMs
76
+ if (isRaw) writeRaw({ error: 'TIMEOUT', message: error.message, timeoutMs })
77
+ else {
78
+ log.error(`Request timed out after ${timeoutMs ?? '?'}ms`)
79
+ log.dim(' Try increasing with --timeout')
80
+ }
81
+ break
82
+ }
83
+
84
+ case 'AuthenticationError': {
85
+ const reason = (error as { reason?: string }).reason ?? 'unknown'
86
+ if (isRaw)
87
+ writeRaw({
88
+ error: 'AUTH_ERROR',
89
+ reason,
90
+ message: error.message,
91
+ credential: credentialExpiration,
92
+ context: localContext,
93
+ })
94
+ else {
95
+ log.error(`Authentication failed: ${error.message}`)
96
+ if (reason === 'missing')
97
+ log.dim(' No credential was sent. Run: astrale identity create <name>')
98
+ else if (reason === 'invalid')
99
+ log.dim(' Credential is invalid — check issuer/keypair. Try: astrale identity whoami')
100
+ else if (reason === 'expired') log.dim(' Credential expired — sign a fresh one')
101
+ if (credentialExpiration) {
102
+ const state = credentialExpiration.expired ? 'expired' : 'expires'
103
+ log.dim(` Credential ${state} at ${credentialExpiration.expiresAt}`)
104
+ }
105
+ printLocalContext(localContext)
106
+ }
107
+ break
108
+ }
109
+
110
+ case 'PermissionDeniedError':
111
+ if (isRaw) writeRaw({ error: 'PERMISSION_DENIED', message: error.message })
112
+ else {
113
+ log.error(`Permission denied: ${error.message}`)
114
+ log.dim(' Your identity does not have the required permissions for this operation')
115
+ }
116
+ break
117
+
118
+ case 'NotFoundError': {
119
+ const cleanMsg = stripMethodSuffix(error.message)
120
+ const selfMeta = (error as Error & { expandedFromSelf?: SelfExpansionMeta }).expandedFromSelf
121
+ // kernel-client maps both NOT_FOUND (the node doesn't exist) and
122
+ // METHOD_NOT_FOUND (the method doesn't exist on a real node) to
123
+ // `NotFoundError`. Firing the "refresh registration" hint for the
124
+ // method case is misleading. Gate on the message referencing the
125
+ // expanded id — node lookup errors mention `@<id>` whereas method
126
+ // errors mention the method path.
127
+ const selfHintApplies = selfMeta && error.message.includes(`@${selfMeta.selfId}`)
128
+ if (isRaw) {
129
+ const payload: Record<string, unknown> = { error: 'NOT_FOUND', message: cleanMsg }
130
+ if (selfHintApplies) payload.expandedFromSelf = selfMeta
131
+ writeRaw(payload)
132
+ } else {
133
+ log.error(`Not found: ${cleanMsg}`)
134
+ log.dim(' Check the path/ID and that the instance is booted')
135
+ if (selfHintApplies && selfMeta) {
136
+ const who = selfMeta.identity ?? 'your identity'
137
+ const where = selfMeta.slug ? ` on "${selfMeta.slug}"` : ''
138
+ log.dim(` @self expanded to @${selfMeta.selfId} from ${who}'s registration${where}.`)
139
+ const fixCmd = `astrale identity register${
140
+ selfMeta.identity ? ` ${selfMeta.identity}` : ''
141
+ }${selfMeta.slug ? ` -i ${selfMeta.slug}` : ''}`
142
+ log.dim(` If the node was deleted, refresh with: ${fixCmd}`)
143
+ }
144
+ }
145
+ break
146
+ }
147
+
148
+ case 'ValidationError': {
149
+ const errors = (error as { errors?: FieldError[] }).errors ?? []
150
+ if (isRaw) writeRaw({ error: 'VALIDATION_ERROR', message: error.message, details: errors })
151
+ else {
152
+ log.error('Validation Error')
153
+ if (errors.length > 0) {
154
+ for (const e of errors) {
155
+ console.log(chalk.red(` ${e.path.join('.')}: ${e.message} (${chalk.dim(e.code)})`))
156
+ }
157
+ } else {
158
+ // Server often sends details in message but empty errors array
159
+ console.log(chalk.red(` ${error.message}`))
160
+ }
161
+ log.dim(' Use `astrale call <path> --describe` to see the expected schema')
162
+ }
163
+ break
164
+ }
165
+
166
+ case 'InvariantViolationError': {
167
+ const errors = (error as { errors?: InvariantError[] }).errors ?? []
168
+ if (isRaw) writeRaw({ error: 'INVARIANT_VIOLATION', message: error.message, details: errors })
169
+ else {
170
+ log.error('Invariant Violation')
171
+ for (const e of errors) {
172
+ console.log(chalk.red(` ${e.code}: ${e.message}`))
173
+ if (e.context) console.log(chalk.dim(` ${JSON.stringify(e.context)}`))
174
+ }
175
+ }
176
+ break
177
+ }
178
+
179
+ case 'KernelError': {
180
+ const code = (error as { code?: number | string }).code ?? 'UNKNOWN'
181
+ const type = (error as { type?: string }).type ?? 'KERNEL_ERROR'
182
+ if (isRaw) writeRaw({ error: type, code, message: error.message })
183
+ else log.error(`${chalk.bold(`${type}(${code})`)}: ${error.message}`)
184
+ break
185
+ }
186
+
187
+ default:
188
+ // Catch-all: include class name so diagnosis is possible even without --debug
189
+ if (isRaw) writeRaw({ error: name || 'UNKNOWN', message: error.message })
190
+ else log.error(`${chalk.bold(name || 'Error')}: ${error.message}`)
191
+ }
192
+
193
+ if (debug) printDebug(error, url)
194
+ }
195
+
196
+ /** Strip internal `::methodName` suffixes from paths in error messages (e.g., "/path::listChildren" → "/path") */
197
+ function stripMethodSuffix(msg: string): string {
198
+ return msg.replace(/(\/[^"\s:]+)::([a-zA-Z]\w*)/g, '$1')
199
+ }
200
+
201
+ function writeRaw(payload: Record<string, unknown>): void {
202
+ process.stderr.write(JSON.stringify(payload) + '\n')
203
+ }
204
+
205
+ async function contextForError(error: unknown): Promise<LocalStatus | undefined> {
206
+ if (!(error instanceof Error)) return undefined
207
+ if (error.name !== 'AuthenticationError' && error.name !== 'ConnectionError') return undefined
208
+ return readLocalStatus().catch(() => undefined)
209
+ }
210
+
211
+ function printLocalContext(context: LocalStatus | undefined): void {
212
+ if (!context) return
213
+ process.stderr.write(chalk.dim('\nContext:\n'))
214
+ if ('error' in context.admin) {
215
+ process.stderr.write(chalk.dim(` admin: invalid (${context.admin.error})\n`))
216
+ } else {
217
+ process.stderr.write(chalk.dim(` admin: ${context.admin.name} -> ${context.admin.url}\n`))
218
+ }
219
+ if (context.instance) {
220
+ process.stderr.write(
221
+ chalk.dim(` instance: ${context.instance.active} -> ${context.instance.url}\n`),
222
+ )
223
+ } else {
224
+ process.stderr.write(chalk.dim(' instance: none\n'))
225
+ }
226
+ if (context.identity) {
227
+ const source =
228
+ context.identity.source === 'idp'
229
+ ? `idp:${context.identity.idp ?? 'unknown'}`
230
+ : context.identity.source
231
+ process.stderr.write(chalk.dim(` identity: ${context.identity.name} [${source}]\n`))
232
+ if (context.identity.session?.cached) {
233
+ const state = context.identity.session.requiresLogin ? 'login required' : 'ready'
234
+ process.stderr.write(chalk.dim(` session: ${state}\n`))
235
+ } else if (context.identity.source === 'idp') {
236
+ process.stderr.write(chalk.dim(' session: not cached\n'))
237
+ }
238
+ } else {
239
+ process.stderr.write(chalk.dim(' identity: none\n'))
240
+ }
241
+ }
242
+
243
+ function printDebug(error: unknown, url: string): void {
244
+ process.stderr.write('\n' + chalk.dim('── debug ─────────────────') + '\n')
245
+ if (url) process.stderr.write(chalk.dim(`url: ${url}`) + '\n')
246
+ if (error instanceof Error) {
247
+ process.stderr.write(chalk.dim(`class: ${error.constructor.name}`) + '\n')
248
+ if (error.stack) process.stderr.write(chalk.dim(error.stack) + '\n')
249
+ // Walk cause chain
250
+ let cause = (error as Error & { cause?: unknown }).cause
251
+ while (cause instanceof Error) {
252
+ process.stderr.write(
253
+ chalk.dim(`caused by: ${cause.constructor.name}: ${cause.message}`) + '\n',
254
+ )
255
+ if (cause.stack) process.stderr.write(chalk.dim(cause.stack) + '\n')
256
+ cause = (cause as Error & { cause?: unknown }).cause
257
+ }
258
+ // Any attached fields (code, details, etc.)
259
+ const extras: Record<string, unknown> = {}
260
+ const bag = error as unknown as Record<string, unknown>
261
+ for (const key of [
262
+ 'code',
263
+ 'type',
264
+ 'reason',
265
+ 'details',
266
+ 'errors',
267
+ 'data',
268
+ 'timeoutMs',
269
+ 'requestId',
270
+ ]) {
271
+ const v = bag[key]
272
+ if (v !== undefined) extras[key] = v
273
+ }
274
+ if (Object.keys(extras).length > 0) {
275
+ process.stderr.write(chalk.dim(`extras: ${JSON.stringify(extras, null, 2)}`) + '\n')
276
+ }
277
+ } else {
278
+ process.stderr.write(chalk.dim(String(error)) + '\n')
279
+ }
280
+ }
@@ -0,0 +1,217 @@
1
+ import type { InstanceInfo } from '../lib/admin-instance'
2
+ import type { KernelCommandOpts } from './types'
3
+
4
+ import { ADMIN_INSTANCE } from '../lib/admin-instance'
5
+ /**
6
+ * Bridges `lib/self.ts` to CLI command sites: builds a `SelfResolverContext`
7
+ * from CLI opts, resolves a nodeId via
8
+ * `resolveOrThrow` (throwing `SelfRefusalError` on refusal), and wraps async
9
+ * calls with `withSelfHint` so `NotFoundError`s carry expansion metadata for
10
+ * the stale-registration hint emitted by `formatKernelError`.
11
+ */
12
+ import { readConfig } from '../lib/config'
13
+ import { getDefault, getIdentity, setRegistration } from '../lib/identity'
14
+ import { decodeTokenClaims, readIdpSession } from '../lib/idp'
15
+ import { resolveInstanceTarget } from '../lib/instance-target'
16
+ import { fileExists, keypairPaths } from '../lib/keys'
17
+ import { KEYS_DIR } from '../lib/paths'
18
+ import {
19
+ containsSelfRef,
20
+ expandSelfReferences,
21
+ resolveSelfNodeId,
22
+ selfRefusalError,
23
+ type SelfResolverContext,
24
+ type SelfResolution,
25
+ } from '../lib/self'
26
+ import { withAdminKernelClient, withKernelClient } from './client'
27
+
28
+ /** Metadata attached to errors so the NotFoundError path can hint at stale `@self` expansions. */
29
+ export type SelfExpansionMeta = {
30
+ original: string
31
+ expanded: string
32
+ selfId: string
33
+ identity?: string
34
+ slug?: string
35
+ }
36
+
37
+ /**
38
+ * Build a `SelfResolverContext` from CLI opts. Mirrors the target +
39
+ * signing-mode logic in `withKernelClient` / `resolveCredential`.
40
+ *
41
+ * Cheap enough to call eagerly; commands skip the call entirely when
42
+ * `containsSelfRef` returns false on every input.
43
+ */
44
+ export async function buildSelfContext(opts: KernelCommandOpts): Promise<SelfResolverContext> {
45
+ const config = await readConfig()
46
+ // Mirror withKernelClient's slug logic: --url without -i ⇒ no slug.
47
+ let slug: string | undefined
48
+ let defaultIdentity: string | undefined
49
+ if (opts.url && !opts.instance) {
50
+ slug = undefined
51
+ } else {
52
+ const resolved = await resolveInstanceTarget(
53
+ opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
54
+ {
55
+ config,
56
+ admin: adminLookupOpts(opts),
57
+ managed: (instanceSlug) => lookupManagedInstance(instanceSlug, opts),
58
+ },
59
+ )
60
+ slug = resolved.name
61
+ defaultIdentity = resolved.defaultIdentity
62
+ }
63
+
64
+ // Mirror resolveCredential's instance-signed branch: when targeting a
65
+ // child for which the CLI generated a dedicated keypair, the call signs
66
+ // as the instance itself, not as a user identity.
67
+ let instanceSigned = false
68
+ if (!opts.creds && !opts.as && !defaultIdentity && slug && slug !== 'manager') {
69
+ const { privatePath } = keypairPaths(slug, KEYS_DIR)
70
+ instanceSigned = await fileExists(privatePath)
71
+ }
72
+
73
+ // Identity that will sign (only relevant when not instance-signed and not --creds).
74
+ // Failures here (corrupt identities.json, missing --as identity) are
75
+ // re-thrown rather than swallowed — swallowing produces a useless
76
+ // refusal naming `identityName: '(unknown)'`. The fatal-error UX is
77
+ // honest about the actual problem.
78
+ let identity: SelfResolverContext['identity']
79
+ if (!opts.creds) {
80
+ const identityName = opts.as ?? defaultIdentity
81
+ if (identityName) {
82
+ const i = await getIdentity(identityName)
83
+ identity = { ...i, name: identityName }
84
+ } else {
85
+ identity = await getDefault()
86
+ }
87
+ }
88
+
89
+ let idpSubject: string | undefined
90
+ if (identity && (identity.source ?? 'key') === 'idp') {
91
+ instanceSigned = false
92
+ const session = await readIdpSession(identity.name)
93
+ const claims = decodeTokenClaims(session?.id_token ?? session?.access_token)
94
+ if (typeof claims?.sub === 'string' && claims.sub.trim().length > 0) {
95
+ idpSubject = claims.sub
96
+ }
97
+ }
98
+
99
+ return { identity, instanceSlug: slug, credsJwt: opts.creds, instanceSigned, idpSubject }
100
+ }
101
+
102
+ function adminLookupOpts(opts: KernelCommandOpts): KernelCommandOpts {
103
+ return {
104
+ as: opts.as,
105
+ creds: opts.creds,
106
+ timeout: opts.timeout,
107
+ debug: opts.debug,
108
+ }
109
+ }
110
+
111
+ async function lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
112
+ return await withAdminKernelClient(
113
+ adminLookupOpts(opts),
114
+ async (ctx) => (await ctx.client.call(`${ADMIN_INSTANCE}/info`, { id: slug })) as InstanceInfo,
115
+ )
116
+ }
117
+
118
+ /**
119
+ * Run `fn`; if it throws a `NotFoundError`, stamp expansion metadata onto the
120
+ * error so `formatKernelError` can append the stale-registration hint. The
121
+ * error is re-thrown either way.
122
+ */
123
+ export async function withSelfHint<T>(
124
+ fn: () => Promise<T>,
125
+ meta: SelfExpansionMeta | undefined,
126
+ ): Promise<T> {
127
+ if (!meta) return fn()
128
+ try {
129
+ return await fn()
130
+ } catch (err) {
131
+ if (err instanceof Error && err.name === 'NotFoundError') {
132
+ ;(err as Error & { expandedFromSelf?: SelfExpansionMeta }).expandedFromSelf = meta
133
+ }
134
+ throw err
135
+ }
136
+ }
137
+
138
+ /** Convenience: resolve the nodeId once, or throw the typed refusal. */
139
+ export function resolveOrThrow(selfCtx: SelfResolverContext): string {
140
+ const r: SelfResolution = resolveSelfNodeId(selfCtx)
141
+ if ('reason' in r) throw selfRefusalError(r)
142
+ return r.id
143
+ }
144
+
145
+ // The kernel's whoami — returns the AUTHENTICATED principal's graph node.
146
+ // Static interface method, so the colon form (slash form is rejected).
147
+ const WHOAMI_PATH = '/:kernel.astrale.ai:interface.Identity:whoami'
148
+
149
+ /**
150
+ * Resolve `@self`, falling back to ONE kernel `whoami` round-trip when an
151
+ * IdP identity merely lacks a cached registration on this instance (the
152
+ * normal `astrale auth login` flow — the IdP subject is never a node id).
153
+ * The resolved id is persisted as a registration so subsequent expansions
154
+ * are local again. Every other refusal (manager, instance-signed, …) and a
155
+ * failed whoami throw the typed refusal unchanged.
156
+ */
157
+ export async function resolveSelfIdLazy(
158
+ selfCtx: SelfResolverContext,
159
+ opts: KernelCommandOpts,
160
+ ): Promise<string> {
161
+ const r: SelfResolution = resolveSelfNodeId(selfCtx)
162
+ if (!('reason' in r)) return r.id
163
+ if (r.reason !== 'idp-no-sub' || !selfCtx.instanceSlug || !selfCtx.identity) {
164
+ throw selfRefusalError(r)
165
+ }
166
+ let me: { id?: unknown } | null
167
+ let kernelUrl = ''
168
+ try {
169
+ me = (await withKernelClient(opts, (ctx) => {
170
+ kernelUrl = ctx.url
171
+ return ctx.client.call(WHOAMI_PATH as never, {} as never)
172
+ })) as { id?: unknown } | null
173
+ } catch {
174
+ // Network/auth failure — surface the original recipe, not a stack.
175
+ throw selfRefusalError(r)
176
+ }
177
+ const id = typeof me?.id === 'string' && me.id.trim().length > 0 ? me.id : undefined
178
+ if (!id) throw selfRefusalError(r)
179
+ await setRegistration(selfCtx.identity.name, selfCtx.instanceSlug, {
180
+ iss: kernelUrl,
181
+ sub: id,
182
+ registeredAt: new Date().toISOString(),
183
+ })
184
+ return id
185
+ }
186
+
187
+ /**
188
+ * Expand `@self` in a single path string for the common command shape
189
+ * (`get`, `ls`, `describe`). Returns the expanded path AND the metadata
190
+ * needed by `withSelfHint` to attach the stale-registration hint to a
191
+ * downstream `NotFoundError`.
192
+ *
193
+ * No-op (returns the input unchanged with `meta: undefined`) when the path
194
+ * contains no `@self` — avoids the I/O of `buildSelfContext`.
195
+ *
196
+ * Throws `SelfRefusalError` when `@self` is present but unresolvable.
197
+ */
198
+ export async function expandSelfInPath(
199
+ path: string,
200
+ opts: KernelCommandOpts,
201
+ ): Promise<{ path: string; meta: SelfExpansionMeta | undefined }> {
202
+ if (!containsSelfRef(path)) return { path, meta: undefined }
203
+ const selfCtx = await buildSelfContext(opts)
204
+ const id = await resolveSelfIdLazy(selfCtx, opts)
205
+ const expanded = expandSelfReferences(path, id)
206
+ if (expanded === path) return { path, meta: undefined }
207
+ return {
208
+ path: expanded,
209
+ meta: {
210
+ original: path,
211
+ expanded,
212
+ selfId: id,
213
+ identity: selfCtx.identity?.name,
214
+ slug: selfCtx.instanceSlug,
215
+ },
216
+ }
217
+ }
@@ -0,0 +1,14 @@
1
+ export { withKernelClient, withAdminKernelClient, type ClientContext } from './client'
2
+ export { resolveCredential } from './auth'
3
+ export { formatKernelError } from './errors'
4
+ export {
5
+ buildSelfContext,
6
+ expandSelfInPath,
7
+ withSelfHint,
8
+ resolveOrThrow,
9
+ resolveSelfIdLazy,
10
+ type SelfExpansionMeta,
11
+ } from './expand'
12
+ export { mintRemoteCredential } from './remote-routing'
13
+ export { runKernelCommand, extractItems } from './run'
14
+ export type { KernelCommandOpts, CallCommandOpts } from './types'
@@ -0,0 +1,22 @@
1
+ import type { CommandOption } from '../command'
2
+
3
+ /**
4
+ * Flags consumed by `withKernelClient` (and therefore valid on any command
5
+ * that talks to a kernel). Spread these into a command's `options` to expose
6
+ * the same `--url / --instance / --timeout / --as / --creds / --debug`
7
+ * surface as `astrale call`.
8
+ */
9
+ export const KERNEL_PASSTHROUGH_OPTIONS: CommandOption[] = [
10
+ {
11
+ flags: '--url <url>',
12
+ description: 'Target a kernel URL directly (overrides instance resolution)',
13
+ },
14
+ {
15
+ flags: '-i, --instance <name>',
16
+ description: 'Target a specific instance (overrides active)',
17
+ },
18
+ { flags: '--timeout <ms>', description: 'Request timeout in ms', default: '30000' },
19
+ { flags: '--as <identity>', description: 'Call as a specific identity' },
20
+ { flags: '--creds <token>', description: 'Use a pre-signed credential (e.g. delegation token)' },
21
+ { flags: '--debug', description: 'Print full error diagnostics on failure' },
22
+ ]
@@ -0,0 +1,88 @@
1
+ import type { FnMap } from '@astrale-os/kernel-client'
2
+ import type { ClientSession } from '@astrale-os/kernel-client/session'
3
+
4
+ /**
5
+ * Credential-minting helpers for remote-bound kernel calls.
6
+ *
7
+ * The CLI no longer resolves remote bindings client-side: the kernel emits a
8
+ * redirect carrying the target worker's `iss`, and the `ClientSession` follows
9
+ * it, minting a worker-scoped delegation via `mintRemoteCredential` (wired as
10
+ * the session's delegation-cache mint in `client.ts`). These helpers (whoami →
11
+ * self-delegation) are the mint itself.
12
+ */
13
+
14
+ /**
15
+ * Mint a delegation credential scoped to the remote worker's audience.
16
+ *
17
+ * The Function lives under the caller's self identity (`@__system__` in manager
18
+ * kernels), so the invariant on `mintDelegationCredential` passes without
19
+ * additional claims. A self-delegation is the least-privilege shape — the
20
+ * worker inherits whatever grants the caller already has.
21
+ *
22
+ * Internal calls pass `skipDelegation: true`: they target the default kernel
23
+ * (same origin), so the delegation cache would skip them anyway, but being
24
+ * explicit guarantees the mint can never recurse into itself.
25
+ */
26
+ export async function mintRemoteCredential(
27
+ client: ClientSession<FnMap>,
28
+ audience: string,
29
+ callerCredential: string,
30
+ ): Promise<string> {
31
+ const mintPath = await mintDelegationPath(client, callerCredential)
32
+ const result = await client.call(
33
+ mintPath,
34
+ {
35
+ audience,
36
+ delegation: { kind: 'identity', self: true },
37
+ ttl: 3600,
38
+ },
39
+ { credential: callerCredential, skipDelegation: true },
40
+ )
41
+ if (typeof result !== 'string') {
42
+ throw new Error(
43
+ `mintDelegationCredential returned non-string: ${typeof result} — cannot use as credential`,
44
+ )
45
+ }
46
+ return result
47
+ }
48
+
49
+ /**
50
+ * Resolve the kernel path that mints a self-delegation credential for the
51
+ * CALLER: `@<nodeId>::mintDelegationCredential`.
52
+ */
53
+ export async function mintDelegationPath(
54
+ client: ClientSession<FnMap>,
55
+ credential: string,
56
+ ): Promise<string> {
57
+ const sub = readJwtSub(credential)
58
+ // System / opaque credential: the graph node id is literally `__system__`,
59
+ // so we can skip the whoami round-trip.
60
+ if (!sub || sub === 'system') return '@__system__::mintDelegationCredential'
61
+ const self = (await client.call(
62
+ '/:kernel.astrale.ai:interface.Identity:whoami',
63
+ {},
64
+ { credential, skipDelegation: true },
65
+ )) as { id?: unknown } | null
66
+ const id = self && typeof self.id === 'string' ? self.id : undefined
67
+ if (!id) {
68
+ throw new Error(
69
+ 'Could not resolve the caller identity (whoami returned no id) — cannot mint a delegation credential.',
70
+ )
71
+ }
72
+ return `@${id}::mintDelegationCredential`
73
+ }
74
+
75
+ function readJwtSub(credential: string): string | null {
76
+ const [, payload] = credential.split('.')
77
+ if (!payload) return null
78
+ try {
79
+ const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
80
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
81
+ const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as {
82
+ sub?: unknown
83
+ }
84
+ return typeof decoded.sub === 'string' && decoded.sub.length > 0 ? decoded.sub : null
85
+ } catch {
86
+ return null
87
+ }
88
+ }
@@ -0,0 +1,63 @@
1
+ import chalk from 'chalk'
2
+
3
+ import type { ClientContext } from './client'
4
+ import type { KernelCommandOpts } from './types'
5
+
6
+ import { formatElapsed } from '../lib/format'
7
+ import { spinner } from '../lib/log'
8
+ import { isMachine, present } from '../lib/output'
9
+ import { withKernelClient } from './client'
10
+ import { formatKernelError } from './errors'
11
+
12
+ type RunOpts<T> = {
13
+ opts: KernelCommandOpts
14
+ label: string
15
+ fn: (ctx: ClientContext) => Promise<T>
16
+ format?: (result: T, opts: KernelCommandOpts, isRaw: boolean) => void | Promise<void>
17
+ }
18
+
19
+ /**
20
+ * Encapsulates the standard kernel command lifecycle:
21
+ * spinner → connect → call → timing → output → error handling.
22
+ *
23
+ * Commands provide a `fn` that does the actual work and an optional
24
+ * `format` callback for custom output. If `format` is omitted, the
25
+ * result is passed to the standard `output()` function.
26
+ */
27
+ export async function runKernelCommand<T>(run: RunOpts<T>): Promise<void> {
28
+ const { opts, label, fn } = run
29
+ const isRaw = isMachine(opts)
30
+ const spin = !isRaw ? spinner(`${label}...`) : null
31
+ const startTime = performance.now()
32
+
33
+ try {
34
+ const result = await withKernelClient(opts, fn)
35
+ const elapsed = performance.now() - startTime
36
+
37
+ spin?.succeed(`${label} ${chalk.dim(formatElapsed(elapsed))}`)
38
+ if (!isRaw) console.log('')
39
+
40
+ if (run.format) {
41
+ await run.format(result, opts, isRaw)
42
+ } else {
43
+ present(result, opts)
44
+ }
45
+ } catch (error) {
46
+ if (!isRaw && spin) spin.fail(`${label} failed`)
47
+ await formatKernelError(error, isRaw, undefined, opts.debug, { credential: opts.creds })
48
+ process.exit(1)
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Normalize a `listChildren` response into an array of items.
54
+ * The kernel may return a bare array or `{ items: [...] }`.
55
+ */
56
+ export function extractItems<T = Record<string, unknown>>(result: unknown): T[] {
57
+ if (Array.isArray(result)) return result as T[]
58
+ if (result && typeof result === 'object') {
59
+ const items = (result as { items?: unknown }).items
60
+ if (Array.isArray(items)) return items as T[]
61
+ }
62
+ return []
63
+ }