@astrale-os/cli 0.4.0-alpha.13 → 0.6.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +27 -0
- package/dist/astrale.js +24396 -9632
- package/package.json +24 -22
- package/src/command.ts +2 -0
- package/src/commands/__tests__/admin-instance.test.ts +3 -2
- package/src/commands/__tests__/domain-list.test.ts +6 -2
- package/src/commands/__tests__/help-contract.test.ts +27 -14
- package/src/commands/__tests__/install-identity-override.test.ts +2 -2
- package/src/commands/__tests__/ls.test.ts +1 -1
- package/src/commands/__tests__/read-commands.test.ts +201 -0
- package/src/commands/__tests__/view.test.ts +100 -0
- package/src/commands/call.ts +27 -44
- package/src/commands/describe.ts +57 -58
- package/src/commands/domain/install.ts +9 -9
- package/src/commands/domain/list.ts +2 -2
- package/src/commands/domain/publish.ts +3 -3
- package/src/commands/get.ts +48 -23
- package/src/commands/identity/register.ts +27 -33
- package/src/commands/instance/active.ts +2 -2
- package/src/commands/instance/create.ts +1 -1
- package/src/commands/instance/delete.ts +3 -3
- package/src/commands/instance/list.ts +8 -3
- package/src/commands/instance/status.ts +4 -3
- package/src/commands/instance/use.ts +2 -2
- package/src/commands/logs.ts +8 -8
- package/src/commands/ls.ts +77 -55
- package/src/commands/mutate.ts +191 -0
- package/src/commands/query.ts +307 -20
- package/src/commands/session/analyze.ts +50 -0
- package/src/commands/session/list.ts +36 -0
- package/src/commands/token.ts +4 -7
- package/src/commands/view-serve.ts +26 -0
- package/src/commands/view.ts +614 -0
- package/src/connect-core.test.ts +42 -0
- package/src/connect-core.ts +53 -0
- package/src/kernel/__tests__/auth.test.ts +1 -0
- package/src/kernel/__tests__/expand.test.ts +123 -0
- package/src/kernel/client.ts +33 -37
- package/src/kernel/expand.ts +55 -61
- package/src/kernel/graph.ts +96 -0
- package/src/kernel/index.ts +18 -3
- package/src/kernel/options.ts +1 -1
- package/src/kernel/run.ts +0 -13
- package/src/lib/__tests__/instance-target.test.ts +34 -0
- package/src/lib/__tests__/table.test.ts +1 -1
- package/src/lib/__tests__/view-open-intent.test.ts +308 -0
- package/src/lib/__tests__/view-snapshot.test.ts +77 -0
- package/src/lib/admin-domain.ts +8 -4
- package/src/lib/admin-instance.ts +5 -1
- package/src/lib/config.ts +1 -0
- package/src/lib/domain-identity.ts +1 -1
- package/src/lib/instance-target.ts +5 -0
- package/src/lib/instance.ts +11 -0
- package/src/lib/log.ts +12 -2
- package/src/lib/login-flow.ts +41 -4
- package/src/lib/provision-instance.ts +2 -2
- package/src/lib/self.ts +1 -3
- package/src/lib/view/open-intent.ts +97 -0
- package/src/lib/view/resolve.ts +104 -0
- package/src/lib/view/server.ts +294 -0
- package/src/lib/view/session.ts +123 -0
- package/src/lib/view/snapshot.ts +101 -0
- package/src/program.ts +16 -3
- package/src/registry.ts +1 -1
- package/src/setup/render.ts +1 -3
- package/src/setup/steps/instance.ts +2 -2
- package/src/telemetry/__tests__/gate.test.ts +63 -0
- package/src/telemetry/__tests__/recorder.test.ts +110 -0
- package/src/telemetry/__tests__/redact.test.ts +79 -0
- package/src/telemetry/__tests__/session.test.ts +166 -0
- package/src/telemetry/__tests__/trigger.test.ts +49 -0
- package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
- package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
- package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
- package/src/telemetry/adapters/claude-code.ts +90 -0
- package/src/telemetry/adapters/codex.ts +160 -0
- package/src/telemetry/adapters/index.ts +45 -0
- package/src/telemetry/adapters/types.ts +21 -0
- package/src/telemetry/analyze.ts +227 -0
- package/src/telemetry/gate.ts +79 -0
- package/src/telemetry/recorder.ts +57 -0
- package/src/telemetry/redact.ts +53 -0
- package/src/telemetry/session.ts +88 -0
- package/src/telemetry/settings.ts +26 -0
- package/src/telemetry/store.ts +91 -0
- package/src/telemetry/trigger.ts +119 -0
- package/src/telemetry/types.ts +64 -0
- package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
- package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.ts +7 -2
- package/studio/server/agent/claude.ts +15 -3
- package/studio/server/agent/runner.ts +4 -1
- package/studio/server/agent/session-id.ts +13 -0
- package/studio/server/api.ts +50 -32
- package/studio/server/cache.ts +17 -6
- package/studio/server/client-package.test.ts +147 -0
- package/studio/server/client-package.ts +242 -0
- package/studio/server/index.ts +13 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -0
- package/studio/server/introspect/anatomy-extras.ts +48 -49
- package/studio/server/introspect/anatomy.ts +7 -7
- package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
- package/studio/server/introspect/overlay-tsmorph.ts +230 -72
- package/studio/server/state/harness-gateway.ts +12 -3
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/views.test.ts +90 -0
- package/studio/server/state/views.ts +396 -99
- package/studio/server/state/visibility.ts +10 -6
- package/studio/server/view-dev-server.test.ts +111 -0
- package/studio/server/view-dev-server.ts +372 -0
- package/studio/shared/types.ts +57 -10
- package/viewer/dist/index.html +93 -0
- package/viewer/dist/main.js +71 -0
- package/src/kernel/__tests__/remote-routing.test.ts +0 -70
- package/src/kernel/remote-routing.ts +0 -88
- package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
- package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
- package/studio/tsconfig.json +0 -23
|
@@ -0,0 +1,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
|
+
})
|
package/src/lib/admin-domain.ts
CHANGED
|
@@ -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 = '
|
|
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
|
-
/**
|
|
19
|
+
/** shell.astrale.ai — the authority origin (== JWT aud + kernel path prefix). */
|
|
16
20
|
origin: string
|
|
17
|
-
/** '
|
|
21
|
+
/** 'shell' — the node slug + the id used in install plans / the CLI. */
|
|
18
22
|
name: string
|
|
19
|
-
/** https://
|
|
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 = '
|
|
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
|
|
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' &&
|
package/src/lib/instance.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/lib/login-flow.ts
CHANGED
|
@@ -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 (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
'
|
|
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
|
}
|