@astrale-os/cli 0.4.0-alpha.13 → 0.6.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/THIRD-PARTY-NOTICES.md +27 -0
  4. package/dist/astrale.js +24396 -9632
  5. package/package.json +24 -22
  6. package/src/command.ts +2 -0
  7. package/src/commands/__tests__/admin-instance.test.ts +3 -2
  8. package/src/commands/__tests__/domain-list.test.ts +6 -2
  9. package/src/commands/__tests__/help-contract.test.ts +27 -14
  10. package/src/commands/__tests__/install-identity-override.test.ts +2 -2
  11. package/src/commands/__tests__/ls.test.ts +1 -1
  12. package/src/commands/__tests__/read-commands.test.ts +201 -0
  13. package/src/commands/__tests__/view.test.ts +100 -0
  14. package/src/commands/call.ts +27 -44
  15. package/src/commands/describe.ts +57 -58
  16. package/src/commands/domain/install.ts +9 -9
  17. package/src/commands/domain/list.ts +2 -2
  18. package/src/commands/domain/publish.ts +3 -3
  19. package/src/commands/get.ts +48 -23
  20. package/src/commands/identity/register.ts +27 -33
  21. package/src/commands/instance/active.ts +2 -2
  22. package/src/commands/instance/create.ts +1 -1
  23. package/src/commands/instance/delete.ts +3 -3
  24. package/src/commands/instance/list.ts +8 -3
  25. package/src/commands/instance/status.ts +4 -3
  26. package/src/commands/instance/use.ts +2 -2
  27. package/src/commands/logs.ts +8 -8
  28. package/src/commands/ls.ts +77 -55
  29. package/src/commands/mutate.ts +191 -0
  30. package/src/commands/query.ts +307 -20
  31. package/src/commands/session/analyze.ts +50 -0
  32. package/src/commands/session/list.ts +36 -0
  33. package/src/commands/token.ts +4 -7
  34. package/src/commands/view-serve.ts +26 -0
  35. package/src/commands/view.ts +614 -0
  36. package/src/connect-core.test.ts +42 -0
  37. package/src/connect-core.ts +53 -0
  38. package/src/kernel/__tests__/auth.test.ts +1 -0
  39. package/src/kernel/__tests__/expand.test.ts +123 -0
  40. package/src/kernel/client.ts +33 -37
  41. package/src/kernel/expand.ts +55 -61
  42. package/src/kernel/graph.ts +96 -0
  43. package/src/kernel/index.ts +18 -3
  44. package/src/kernel/options.ts +1 -1
  45. package/src/kernel/run.ts +0 -13
  46. package/src/lib/__tests__/instance-target.test.ts +34 -0
  47. package/src/lib/__tests__/table.test.ts +1 -1
  48. package/src/lib/__tests__/view-open-intent.test.ts +308 -0
  49. package/src/lib/__tests__/view-snapshot.test.ts +77 -0
  50. package/src/lib/admin-domain.ts +8 -4
  51. package/src/lib/admin-instance.ts +5 -1
  52. package/src/lib/config.ts +1 -0
  53. package/src/lib/domain-identity.ts +1 -1
  54. package/src/lib/instance-target.ts +5 -0
  55. package/src/lib/instance.ts +11 -0
  56. package/src/lib/log.ts +12 -2
  57. package/src/lib/login-flow.ts +41 -4
  58. package/src/lib/provision-instance.ts +2 -2
  59. package/src/lib/self.ts +1 -3
  60. package/src/lib/view/open-intent.ts +97 -0
  61. package/src/lib/view/resolve.ts +104 -0
  62. package/src/lib/view/server.ts +294 -0
  63. package/src/lib/view/session.ts +123 -0
  64. package/src/lib/view/snapshot.ts +101 -0
  65. package/src/program.ts +16 -3
  66. package/src/registry.ts +1 -1
  67. package/src/setup/render.ts +1 -3
  68. package/src/setup/steps/instance.ts +2 -2
  69. package/src/telemetry/__tests__/gate.test.ts +63 -0
  70. package/src/telemetry/__tests__/recorder.test.ts +110 -0
  71. package/src/telemetry/__tests__/redact.test.ts +79 -0
  72. package/src/telemetry/__tests__/session.test.ts +166 -0
  73. package/src/telemetry/__tests__/trigger.test.ts +49 -0
  74. package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
  75. package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
  76. package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
  77. package/src/telemetry/adapters/claude-code.ts +90 -0
  78. package/src/telemetry/adapters/codex.ts +160 -0
  79. package/src/telemetry/adapters/index.ts +45 -0
  80. package/src/telemetry/adapters/types.ts +21 -0
  81. package/src/telemetry/analyze.ts +227 -0
  82. package/src/telemetry/gate.ts +79 -0
  83. package/src/telemetry/recorder.ts +57 -0
  84. package/src/telemetry/redact.ts +53 -0
  85. package/src/telemetry/session.ts +88 -0
  86. package/src/telemetry/settings.ts +26 -0
  87. package/src/telemetry/store.ts +91 -0
  88. package/src/telemetry/trigger.ts +119 -0
  89. package/src/telemetry/types.ts +64 -0
  90. package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
  91. package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
  92. package/studio/client/dist/index.html +2 -2
  93. package/studio/server/agent/ask.ts +7 -2
  94. package/studio/server/agent/claude.ts +15 -3
  95. package/studio/server/agent/runner.ts +4 -1
  96. package/studio/server/agent/session-id.ts +13 -0
  97. package/studio/server/api.ts +50 -32
  98. package/studio/server/cache.ts +17 -6
  99. package/studio/server/client-package.test.ts +147 -0
  100. package/studio/server/client-package.ts +242 -0
  101. package/studio/server/index.ts +13 -0
  102. package/studio/server/introspect/anatomy-extras.test.ts +91 -0
  103. package/studio/server/introspect/anatomy-extras.ts +48 -49
  104. package/studio/server/introspect/anatomy.ts +7 -7
  105. package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
  106. package/studio/server/introspect/overlay-tsmorph.ts +230 -72
  107. package/studio/server/state/harness-gateway.ts +12 -3
  108. package/studio/server/state/harness-token.ts +0 -0
  109. package/studio/server/state/views.test.ts +90 -0
  110. package/studio/server/state/views.ts +396 -99
  111. package/studio/server/state/visibility.ts +10 -6
  112. package/studio/server/view-dev-server.test.ts +111 -0
  113. package/studio/server/view-dev-server.ts +372 -0
  114. package/studio/shared/types.ts +57 -10
  115. package/viewer/dist/index.html +93 -0
  116. package/viewer/dist/main.js +71 -0
  117. package/src/kernel/__tests__/remote-routing.test.ts +0 -70
  118. package/src/kernel/remote-routing.ts +0 -88
  119. package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
  120. package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
  121. package/studio/tsconfig.json +0 -23
@@ -15,7 +15,7 @@ describe('renderTable', () => {
15
15
  const out = strip(
16
16
  renderTable(
17
17
  [
18
- { name: 'dist.astrale.ai', kind: 'Domain' },
18
+ { name: 'example.astrale.ai', kind: 'Domain' },
19
19
  { name: 'workspace', kind: 'Folder' },
20
20
  ],
21
21
  { columns: COLS },
@@ -0,0 +1,308 @@
1
+ import type { IntentMessage, MountedWindow, ResolvedView, Shell } from '@astrale-os/shell'
2
+
3
+ import { describe, expect, test } from 'bun:test'
4
+
5
+ import {
6
+ handleOpenIntent,
7
+ installOpenIntentHandler,
8
+ mountWithHandshakeFallback,
9
+ type OpenIntentHost,
10
+ } from '../view/open-intent'
11
+
12
+ const profile: ResolvedView = {
13
+ id: 'profile-view',
14
+ path: '/shell/views/profile',
15
+ url: 'https://shell.test/profile',
16
+ origin: 'class',
17
+ }
18
+ const card: ResolvedView = {
19
+ id: 'card-view',
20
+ path: '/shell/views/card',
21
+ url: 'https://shell.test/card',
22
+ handshake: 'none',
23
+ origin: 'class',
24
+ }
25
+
26
+ function openMessage(viewId?: string, correlationId?: string): IntentMessage<'open'> {
27
+ return {
28
+ type: 'intent',
29
+ version: 1,
30
+ envelope: {
31
+ name: 'open',
32
+ payload: { nodeId: 'person-1', ...(viewId ? { viewId } : {}) },
33
+ sender: { windowId: 'old-window' },
34
+ ...(correlationId ? { correlationId } : {}),
35
+ },
36
+ }
37
+ }
38
+
39
+ function mounted(windowId: string, onClose?: () => void): MountedWindow {
40
+ return {
41
+ windowId,
42
+ window: {
43
+ windowId,
44
+ functionId: 'view',
45
+ isolation: 'shared',
46
+ state: 'active',
47
+ delegationToken: null,
48
+ capabilities: { intents: [] },
49
+ },
50
+ handle: { element: {} as HTMLElement },
51
+ close: async () => {
52
+ onClose?.()
53
+ return { kind: 'closed' }
54
+ },
55
+ }
56
+ }
57
+
58
+ function harness(views: readonly ResolvedView[] = [profile, card]) {
59
+ const events: string[] = []
60
+ const sent: { windowId: string; message: IntentMessage }[] = []
61
+ const old = mounted('old-window', () => events.push('close:old-window'))
62
+ let current: MountedWindow | null = old
63
+ const shell = {
64
+ views: {
65
+ resolve: async (node: string) => {
66
+ events.push(`resolve:${node}`)
67
+ return views
68
+ },
69
+ },
70
+ } as unknown as Pick<Shell, 'views'>
71
+ const host: OpenIntentHost = {
72
+ current: () => current,
73
+ setCurrent: (next) => {
74
+ events.push(`current:${next.windowId}`)
75
+ current = next
76
+ },
77
+ mount: async (view, nodeId) => {
78
+ events.push(`mount:${view.id}:${nodeId}`)
79
+ return mounted('new-window')
80
+ },
81
+ opened: (view, nodeId) => events.push(`opened:${view.path}:${nodeId}`),
82
+ failed: (error) => events.push(`failed:${error instanceof Error ? error.message : error}`),
83
+ reply: (message, windowId) => {
84
+ if (!message.envelope.correlationId) return
85
+ events.push(`reply:${message.envelope.sender.windowId}`)
86
+ sent.push({
87
+ windowId: message.envelope.sender.windowId,
88
+ message: {
89
+ type: 'intent',
90
+ version: 1,
91
+ envelope: {
92
+ name: 'intentReply',
93
+ payload: {
94
+ correlationId: message.envelope.correlationId,
95
+ result: { windowId },
96
+ },
97
+ sender: { windowId: 'root' },
98
+ },
99
+ },
100
+ })
101
+ },
102
+ reject: (message, error) => {
103
+ if (!message.envelope.correlationId) return
104
+ events.push(`reply:${message.envelope.sender.windowId}`)
105
+ sent.push({
106
+ windowId: message.envelope.sender.windowId,
107
+ message: {
108
+ type: 'intent',
109
+ version: 1,
110
+ envelope: {
111
+ name: 'intentReply',
112
+ payload: {
113
+ correlationId: message.envelope.correlationId,
114
+ error: { message: error instanceof Error ? error.message : String(error) },
115
+ },
116
+ sender: { windowId: 'root' },
117
+ },
118
+ },
119
+ })
120
+ },
121
+ }
122
+ return { events, sent, shell, host, current: () => current }
123
+ }
124
+
125
+ describe('open intent host', () => {
126
+ test('uses kernel preference and replies before retiring the requesting child', async () => {
127
+ const h = harness()
128
+ await handleOpenIntent(h.shell, h.host, openMessage(undefined, 'corr-1'))
129
+
130
+ expect(h.events).toEqual([
131
+ 'resolve:person-1',
132
+ 'mount:profile-view:person-1',
133
+ 'current:new-window',
134
+ 'opened:/shell/views/profile:person-1',
135
+ 'reply:old-window',
136
+ 'close:old-window',
137
+ ])
138
+ expect(h.current()?.windowId).toBe('new-window')
139
+ expect(h.sent).toEqual([
140
+ {
141
+ windowId: 'old-window',
142
+ message: {
143
+ type: 'intent',
144
+ version: 1,
145
+ envelope: {
146
+ name: 'intentReply',
147
+ payload: { correlationId: 'corr-1', result: { windowId: 'new-window' } },
148
+ sender: { windowId: 'root' },
149
+ },
150
+ },
151
+ },
152
+ ])
153
+ })
154
+
155
+ test('honors an explicit view and keeps fire-and-forget navigation reply-free', async () => {
156
+ const h = harness()
157
+ await handleOpenIntent(h.shell, h.host, openMessage('card-view'))
158
+
159
+ expect(h.events).toContain('mount:card-view:person-1')
160
+ expect(h.sent).toEqual([])
161
+ })
162
+
163
+ test('keeps the current view and rejects a correlated resolution failure', async () => {
164
+ const h = harness([])
165
+ await handleOpenIntent(h.shell, h.host, openMessage(undefined, 'corr-fail'))
166
+
167
+ expect(h.current()?.windowId).toBe('old-window')
168
+ expect(h.events).toEqual([
169
+ 'resolve:person-1',
170
+ 'reply:old-window',
171
+ 'failed:No view resolves for this node',
172
+ ])
173
+ expect(h.sent[0]!.message.envelope).toEqual({
174
+ name: 'intentReply',
175
+ payload: {
176
+ correlationId: 'corr-fail',
177
+ error: { message: 'No view resolves for this node' },
178
+ },
179
+ sender: { windowId: 'root' },
180
+ })
181
+ })
182
+
183
+ test('keeps the current view when the replacement mount fails', async () => {
184
+ const h = harness([profile])
185
+ h.host.mount = async () => {
186
+ h.events.push('mount:failed')
187
+ throw new Error('replacement failed')
188
+ }
189
+
190
+ await handleOpenIntent(h.shell, h.host, openMessage(undefined, 'corr-mount'))
191
+
192
+ expect(h.current()?.windowId).toBe('old-window')
193
+ expect(h.events).toEqual([
194
+ 'resolve:person-1',
195
+ 'mount:failed',
196
+ 'reply:old-window',
197
+ 'failed:replacement failed',
198
+ ])
199
+ expect(h.sent[0]!.message.envelope.payload).toEqual({
200
+ correlationId: 'corr-mount',
201
+ error: { message: 'replacement failed' },
202
+ })
203
+ })
204
+
205
+ test('serializes overlapping opens', async () => {
206
+ let handler: ((message: IntentMessage<'open'>) => Promise<void>) | undefined
207
+ let releaseFirst!: () => void
208
+ const firstGate = new Promise<void>((resolve) => {
209
+ releaseFirst = resolve
210
+ })
211
+ let resolveCount = 0
212
+ const base = harness([profile])
213
+ const events = base.events
214
+ const shell = {
215
+ ...base.shell,
216
+ views: {
217
+ resolve: async () => {
218
+ resolveCount += 1
219
+ events.push(`resolve:${resolveCount}`)
220
+ if (resolveCount === 1) await firstGate
221
+ return [profile]
222
+ },
223
+ },
224
+ onIntent: (_name: 'open', next: typeof handler) => {
225
+ handler = next
226
+ return () => {}
227
+ },
228
+ } as unknown as Shell
229
+ const host = {
230
+ ...base.host,
231
+ mount: async () => {
232
+ const id = `new-${resolveCount}`
233
+ events.push(`mount:${id}`)
234
+ return mounted(id, () => events.push(`close:${id}`))
235
+ },
236
+ }
237
+ installOpenIntentHandler(shell, host)
238
+
239
+ const first = handler!(openMessage())
240
+ const second = handler!(openMessage())
241
+ await Promise.resolve()
242
+ expect(events).toEqual(['resolve:1'])
243
+ releaseFirst()
244
+ await Promise.all([first, second])
245
+ expect(events).toEqual([
246
+ 'resolve:1',
247
+ 'mount:new-1',
248
+ 'current:new-1',
249
+ 'opened:/shell/views/profile:person-1',
250
+ 'close:old-window',
251
+ 'resolve:2',
252
+ 'mount:new-2',
253
+ 'current:new-2',
254
+ 'opened:/shell/views/profile:person-1',
255
+ 'close:new-1',
256
+ ])
257
+ expect(base.current()?.windowId).toBe('new-2')
258
+ })
259
+ })
260
+
261
+ describe('mountWithHandshakeFallback', () => {
262
+ test('mounts a plain view once', async () => {
263
+ const attempts: string[] = []
264
+ const result = await mountWithHandshakeFallback({
265
+ handshake: 'none',
266
+ attempts: 2,
267
+ mount: async (handshake) => {
268
+ attempts.push(handshake)
269
+ return 'plain-window'
270
+ },
271
+ cleanupFailedAttempt: () => attempts.push('cleanup'),
272
+ })
273
+ expect(result).toEqual({ mounted: 'plain-window', handshake: 'none' })
274
+ expect(attempts).toEqual(['none'])
275
+ })
276
+
277
+ test('retries shell mounts, cleans each failure, then falls back to plain', async () => {
278
+ const attempts: string[] = []
279
+ const result = await mountWithHandshakeFallback({
280
+ handshake: 'shell',
281
+ attempts: 2,
282
+ mount: async (handshake) => {
283
+ attempts.push(handshake)
284
+ if (handshake === 'shell') throw new Error('handshake failed')
285
+ return 'plain-window'
286
+ },
287
+ cleanupFailedAttempt: () => attempts.push('cleanup'),
288
+ })
289
+ expect(result).toEqual({ mounted: 'plain-window', handshake: 'none' })
290
+ expect(attempts).toEqual(['shell', 'cleanup', 'shell', 'cleanup', 'none'])
291
+ })
292
+
293
+ test('cleans and surfaces the decisive plain fallback failure', async () => {
294
+ const attempts: string[] = []
295
+ const run = mountWithHandshakeFallback({
296
+ handshake: 'shell',
297
+ attempts: 2,
298
+ mount: async (handshake) => {
299
+ attempts.push(handshake)
300
+ throw new Error(handshake === 'shell' ? 'handshake failed' : 'plain failed')
301
+ },
302
+ cleanupFailedAttempt: () => attempts.push('cleanup'),
303
+ })
304
+
305
+ await expect(run).rejects.toThrow('plain failed')
306
+ expect(attempts).toEqual(['shell', 'cleanup', 'shell', 'cleanup', 'none', 'cleanup'])
307
+ })
308
+ })
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { AbResult } from '../browser'
4
+
5
+ import { snapshotText, waitForSettledSnapshot } from '../view/snapshot'
6
+
7
+ const pending = result('- paragraph\n - StaticText "Opening integrations…"')
8
+ const settled = result('- heading "Integrations" [level=1]\n- button "Connect Google" [ref=e4]')
9
+
10
+ function result(snapshot: string): AbResult {
11
+ return { ok: true, data: { snapshot }, error: null }
12
+ }
13
+
14
+ function clock() {
15
+ let elapsed = 0
16
+ return {
17
+ now: () => elapsed,
18
+ sleep: async (ms: number) => {
19
+ elapsed += ms
20
+ },
21
+ elapsed: () => elapsed,
22
+ }
23
+ }
24
+
25
+ describe('view snapshot settling', () => {
26
+ test('waits through a late loading-to-content transition and a quiet window', async () => {
27
+ const fakeClock = clock()
28
+ let calls = 0
29
+ const takeSnapshot = async () => {
30
+ calls += 1
31
+ return fakeClock.elapsed() < 1_250 ? pending : settled
32
+ }
33
+
34
+ const snapshot = await waitForSettledSnapshot(takeSnapshot, fakeClock, {
35
+ timeoutMs: 4_000,
36
+ quietWindowMs: 750,
37
+ pollMs: 250,
38
+ })
39
+
40
+ expect(snapshotText(snapshot)).toContain('Connect Google')
41
+ expect(fakeClock.elapsed()).toBe(2_000)
42
+ expect(calls).toBe(10)
43
+ })
44
+
45
+ test('returns the latest permanently pending snapshot at the bound', async () => {
46
+ const fakeClock = clock()
47
+ let calls = 0
48
+ const takeSnapshot = async () => {
49
+ calls += 1
50
+ return pending
51
+ }
52
+
53
+ const snapshot = await waitForSettledSnapshot(takeSnapshot, fakeClock, {
54
+ timeoutMs: 1_000,
55
+ quietWindowMs: 500,
56
+ pollMs: 250,
57
+ })
58
+
59
+ expect(snapshot).toBe(pending)
60
+ expect(fakeClock.elapsed()).toBe(1_000)
61
+ expect(calls).toBe(6)
62
+ })
63
+
64
+ test('ignores changing agent-browser refs when measuring stability', async () => {
65
+ const fakeClock = clock()
66
+ let ref = 0
67
+
68
+ const snapshot = await waitForSettledSnapshot(
69
+ async () => result(`- button "Deploy" [ref=e${++ref}]`),
70
+ fakeClock,
71
+ { timeoutMs: 2_000, quietWindowMs: 500, pollMs: 250 },
72
+ )
73
+
74
+ expect(snapshotText(snapshot)).toContain('Deploy')
75
+ expect(fakeClock.elapsed()).toBe(500)
76
+ })
77
+ })
@@ -7,16 +7,20 @@
7
7
  * mounting it on an instance is the separate `domain install` step (or the
8
8
  * admin's install-by-default policy).
9
9
  */
10
- export const ADMIN_DOMAIN = '/admin.astrale.ai/class.DomainEntry'
10
+ export const ADMIN_DOMAIN = '/:admin.astrale.ai:class.DomainEntry'
11
+
12
+ export function adminDomainMethod(method: 'info' | 'install' | 'list' | 'publish'): string {
13
+ return `${ADMIN_DOMAIN}:${method}`
14
+ }
11
15
 
12
16
  /** Read shape returned by `DomainEntry.publish` / `info` / `list` (domain `DomainInfoSchema`). */
13
17
  export type DomainInfo = {
14
18
  id: string
15
- /** dist.astrale.ai — the authority origin (== JWT aud + kernel path prefix). */
19
+ /** shell.astrale.ai — the authority origin (== JWT aud + kernel path prefix). */
16
20
  origin: string
17
- /** 'distribution' — the node slug + the id used in install plans / the CLI. */
21
+ /** 'shell' — the node slug + the id used in install plans / the CLI. */
18
22
  name: string
19
- /** https://dist.astrale.ai — the published worker URL the kernel installs from. */
23
+ /** https://shell.astrale.ai — the published worker URL the kernel installs from. */
20
24
  url?: string
21
25
  description?: string
22
26
  installByDefault?: boolean
@@ -3,7 +3,11 @@
3
3
  * list/status/create/delete`. The merged admin domain models instances as the
4
4
  * `Instance` class (provisioned via `Instance.init`); these commands target it.
5
5
  */
6
- export const ADMIN_INSTANCE = '/admin.astrale.ai/class.Instance'
6
+ export const ADMIN_INSTANCE = '/:admin.astrale.ai:class.Instance'
7
+
8
+ export function adminInstanceMethod(method: 'alphaCreate' | 'delete' | 'info' | 'list'): string {
9
+ return `${ADMIN_INSTANCE}:${method}`
10
+ }
7
11
 
8
12
  /** Read shape returned by `Instance.list` / `info` / `delete` (domain `InstanceInfoSchema`). */
9
13
  export type InstanceInfo = {
package/src/lib/config.ts CHANGED
@@ -9,6 +9,7 @@ import { CONFIG_PATH } from './paths'
9
9
  export const AstraleConfigSchema = z.object({
10
10
  issuer: z.string().url().default('https://unregistered.invalid'),
11
11
  admin: AdminTargetConfigSchema.default(DEFAULT_ADMIN_TARGET_CONFIG),
12
+ telemetry: z.object({ enabled: z.boolean().default(true) }).default({ enabled: true }),
12
13
  })
13
14
 
14
15
  export type AstraleConfig = z.infer<typeof AstraleConfigSchema>
@@ -10,7 +10,7 @@ export async function loadPrivateJwk(keyPath: string): Promise<Record<string, un
10
10
  }
11
11
 
12
12
  /**
13
- * True when the kernel rejected an `installDomain` call because the
13
+ * True when the kernel rejected a `Domain.install` call because the
14
14
  * identity binding's signature failed to verify — i.e. the private/public
15
15
  * JWK halves don't match. Callers re-throw with a regenerate-the-keypair
16
16
  * hint instead of leaking the raw `AuthenticationError`.
@@ -172,6 +172,11 @@ export function adminTargetToInstance(target: ResolvedAdminTarget): ResolvedInst
172
172
  export function isManagedInstanceNotFound(error: unknown): boolean {
173
173
  if (!(error instanceof Error)) return false
174
174
  if (error.name === 'NotFoundError') return true
175
+ // The admin kernel reports a missing instance node as InternalKernelError
176
+ // with a NOT_FOUND-prefixed message. In this lookup that's an instance
177
+ // miss (config problem), not a kernel fault — map it so callers get the
178
+ // typed INSTANCE_NOT_FOUND with remediation instead of a raw kernel error.
179
+ if (error.name === 'InternalKernelError' && /^NOT_FOUND\b/.test(error.message)) return true
175
180
  const data = (error as Error & { data?: unknown }).data
176
181
  return (
177
182
  error.name === 'KernelError' &&
@@ -113,6 +113,17 @@ export function sanitizeStore(store: InstanceStore): { store: InstanceStore; cha
113
113
 
114
114
  let instancesMemo: InstanceStore | null = null
115
115
 
116
+ /**
117
+ * Drop the in-process bookmark cache so the next `readInstances` re-reads disk.
118
+ *
119
+ * A long-lived host (e.g. `@astrale-os/connect-host`) would otherwise serve a
120
+ * stale bookmark list after the user runs `astrale instance bookmark`. The CLI
121
+ * itself is one-shot, so this is a no-op for command usage.
122
+ */
123
+ export function resetInstancesMemo(): void {
124
+ instancesMemo = null
125
+ }
126
+
116
127
  export async function readInstances(
117
128
  _config?: AstraleConfig,
118
129
  opts: { persist?: boolean } = {},
package/src/lib/log.ts CHANGED
@@ -3,6 +3,7 @@ import ora, { type Ora } from 'ora'
3
3
 
4
4
  import { AstraleError, NotImplementedError } from '../errors'
5
5
  import { formatElapsed } from './format'
6
+ import { isMachine, type RawOutputOpts } from './output'
6
7
 
7
8
  export const log = {
8
9
  info: (msg: string) => console.log(chalk.blue('ℹ'), msg),
@@ -13,12 +14,21 @@ export const log = {
13
14
  dim: (msg: string) => console.log(chalk.dim(msg)),
14
15
  }
15
16
 
16
- /** Report an error with hint (when present) and exit. */
17
- export function fatal(e: unknown): never {
17
+ /** Report an error with hint (when present) and exit. Commands that carry
18
+ * RawOutputOpts should pass them so machine consumers (--json/--raw/piped)
19
+ * get one structured JSON line on stderr instead of the pretty ✖ view. */
20
+ export function fatal(e: unknown, opts?: RawOutputOpts): never {
18
21
  // Ctrl-C at an interactive (@inquirer/prompts) prompt — exit quietly with the
19
22
  // SIGINT convention, not a red error line.
20
23
  if (e instanceof Error && e.name === 'ExitPromptError') process.exit(130)
21
24
  const msg = e instanceof Error ? e.message : String(e)
25
+ if (opts && isMachine(opts)) {
26
+ const error = e instanceof AstraleError ? e.code : e instanceof Error ? e.name : 'Error'
27
+ const payload: Record<string, unknown> = { error, message: msg }
28
+ if (e instanceof AstraleError && e.hint) payload.hint = e.hint
29
+ process.stderr.write(JSON.stringify(payload) + '\n')
30
+ process.exit(1)
31
+ }
22
32
  log.error(msg)
23
33
  if (e instanceof AstraleError && e.hint) log.dim(` hint: ${e.hint}`)
24
34
  process.exit(1)
@@ -29,6 +29,27 @@ import { log } from './log'
29
29
  * behavior; presentation (the device URL is logged here; success lines are the
30
30
  * caller's) does not.
31
31
  */
32
+ /**
33
+ * Interactive device-flow progress. Emitted once, when the device authorization
34
+ * has been requested and the user must approve it in a browser. The caller
35
+ * decides how to present it (the CLI logs it; the desktop opens the URL and
36
+ * pushes it to the renderer). Structurally identical to connect-host's wire
37
+ * `AuthVerificationEvent`, but declared here so the CLI carries no dependency on
38
+ * connect-host (which depends on the CLI, not the reverse).
39
+ */
40
+ export type DeviceVerification = {
41
+ /** The base verification URL (fallback to the complete one when absent). */
42
+ verificationUri: string
43
+ /** Pre-filled verification URL (opens straight to the approval page). */
44
+ verificationUriComplete?: string
45
+ /** The short user code to confirm — a reassurance fallback when the URL is pre-filled. */
46
+ userCode?: string
47
+ /** Seconds until the device code expires. */
48
+ expiresIn?: number
49
+ /** Human-readable instruction from the IdP. */
50
+ message?: string
51
+ }
52
+
32
53
  export type LoginFlowOpts = {
33
54
  idp?: string
34
55
  name?: string
@@ -42,6 +63,12 @@ export type LoginFlowOpts = {
42
63
  codeVerifier?: string
43
64
  /** Switch the default identity to the one we just logged in (default true). */
44
65
  use?: boolean
66
+ /**
67
+ * When set, receives the device-flow verification details and REPLACES the
68
+ * default `log.info` presentation (a headless/GUI caller drives the UI). The
69
+ * `auth login` command passes nothing, so its terminal output is unchanged.
70
+ */
71
+ onVerification?: (e: DeviceVerification) => void
45
72
  }
46
73
 
47
74
  export type LoginResult = {
@@ -148,10 +175,20 @@ async function obtainToken(
148
175
  scope,
149
176
  audience: opts.audience,
150
177
  })
151
- if (device.verification_uri_complete) log.info(`Open: ${device.verification_uri_complete}`)
152
- else if (device.verification_uri) log.info(`Open: ${device.verification_uri}`)
153
- if (device.user_code) log.info(`Code: ${device.user_code}`)
154
- if (device.message) log.dim(` ${device.message}`)
178
+ if (opts.onVerification) {
179
+ opts.onVerification({
180
+ verificationUri: device.verification_uri ?? device.verification_uri_complete ?? '',
181
+ verificationUriComplete: device.verification_uri_complete,
182
+ userCode: device.user_code,
183
+ expiresIn: device.expires_in,
184
+ message: device.message,
185
+ })
186
+ } else {
187
+ if (device.verification_uri_complete) log.info(`Open: ${device.verification_uri_complete}`)
188
+ else if (device.verification_uri) log.info(`Open: ${device.verification_uri}`)
189
+ if (device.user_code) log.info(`Code: ${device.user_code}`)
190
+ if (device.message) log.dim(` ${device.message}`)
191
+ }
155
192
 
156
193
  return pollDeviceToken({
157
194
  idp,
@@ -5,7 +5,7 @@ import type { AdminTargetCommandOpts } from './admin-target'
5
5
 
6
6
  import { AuthError } from '../errors'
7
7
  import { withAdminKernelClient } from '../kernel/client'
8
- import { ADMIN_INSTANCE } from './admin-instance'
8
+ import { adminInstanceMethod } from './admin-instance'
9
9
  import { readIdentities, type IdentityStore } from './identity'
10
10
  import { setActive, upsertManagedBookmark } from './instance'
11
11
  import { withSpinner } from './log'
@@ -74,7 +74,7 @@ export async function provisionInstance(
74
74
  const created = await withAdminKernelClient(
75
75
  createOpts,
76
76
  async (ctx) =>
77
- (await ctx.client.call(`${ADMIN_INSTANCE}/alphaCreate`, {
77
+ (await ctx.client.call(adminInstanceMethod('alphaCreate'), {
78
78
  slug,
79
79
  ...(hostId ? { host_id: hostId } : {}),
80
80
  })) as { url: string; organizationId?: string },
package/src/lib/self.ts CHANGED
@@ -158,9 +158,7 @@ function refusalMessage(r: SelfRefusal): string {
158
158
  case 'idp-no-sub':
159
159
  return [
160
160
  `\`@self\` could not be resolved for IdP identity "${r.identityName}": no cached registration, and the automatic whoami lookup did not return a node id (offline, auth failure, or unprovisioned identity).`,
161
- 'Get your node id manually, then use it directly:',
162
- ' astrale call "/:kernel.astrale.ai:interface.Identity:whoami" --json # → { id: <nodeId> }',
163
- 'and address yourself as `@<nodeId>`.',
161
+ 'The CLI resolves `@self` through the bound auth.whoami() flow; fix auth/registration and rerun, or pass a literal `@<nodeId>`.',
164
162
  ].join('\n ')
165
163
  }
166
164
  }