@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
@@ -1,138 +1,435 @@
1
1
  /**
2
- * state/views.ts resolve a view's LIVE serving URL from the instance it is
3
- * installed on. The kernel stamps each View node (/<origin>/core/views/<slug>)
4
- * with a `binding` prop = {"remoteUrl": "<svc-url>/ui/…"}; we read it back via
5
- * `astrale get` (read-only, no install needed). This is the GROUND TRUTH for
6
- * "where does this view actually run" — there is no local copy.
2
+ * View Studio state and runtime bridge.
3
+ *
4
+ * Discovery and launches deliberately go through the public `astrale view`
5
+ * and `astrale query` commands. The Studio therefore shares the CLI's real
6
+ * View:resolve, identity, instance, delegation, proxy, and shell-handshake
7
+ * behavior instead of maintaining a second preview implementation.
7
8
  */
8
- import type { PreviewToken, ViewUrlResult } from '../../shared/types'
9
+ import type {
10
+ RememberedViewTarget,
11
+ StudioSchemaBundle,
12
+ ViewInfo,
13
+ ViewRuntime,
14
+ ViewSessionResult,
15
+ ViewTargetCandidate,
16
+ ViewTargetResult,
17
+ } from '../../shared/types'
9
18
 
10
- const BINDING_KEY = 'kernel.astrale.ai:interface.Function.property.binding'
19
+ import { ensureViewDevServer } from '../view-dev-server'
20
+ import { activeInstanceName } from './instance'
21
+ import { readJson, writeJson } from './store'
11
22
 
12
- /** Run an astrale CLI command, return trimmed stdout (or null on failure), killed past timeout. */
13
- async function astraleText(args: string[], timeoutMs = 8000): Promise<string | null> {
14
- try {
15
- const proc = Bun.spawn(['astrale', ...args], { stdout: 'pipe', stderr: 'pipe' })
16
- const timer = setTimeout(() => {
17
- try {
18
- proc.kill()
19
- } catch {
20
- /* already exited */
23
+ const STATE_FILE = 'views.json'
24
+ const TARGET_LIMIT = 200
25
+ const NAMED_NAME = 'kernel.astrale.ai:interface.Named.property.name'
26
+ const DESCRIPTABLE_DESCRIPTION = 'kernel.astrale.ai:interface.Descriptable.property.description'
27
+ const STATUSED_STATUS = 'kernel.astrale.ai:interface.Statused.property.status'
28
+ const DOMAIN_ORIGIN = 'kernel.astrale.ai:class.Domain.property.domain'
29
+
30
+ interface StoredViewState {
31
+ targets?: Record<string, Record<string, RememberedViewTarget>>
32
+ }
33
+
34
+ interface AstraleResult<T> {
35
+ ok: boolean
36
+ data: T | null
37
+ detail: string
38
+ }
39
+
40
+ interface RawTargetRow {
41
+ id?: unknown
42
+ props?: Record<string, unknown>
43
+ }
44
+
45
+ // A modal cleanup and the next modal launch can arrive almost simultaneously.
46
+ // The CLI view host owns a small local port range, so serialize its lifecycle
47
+ // commands and make the close complete before the next open allocates a port.
48
+ let viewSessionCommandTail: Promise<void> = Promise.resolve()
49
+
50
+ export function localViewUrl(serverUrl: string, mount: string): string {
51
+ return new URL(mount.replace(/^\//, ''), `${new URL(serverUrl).origin}/`).toString()
52
+ }
53
+
54
+ export async function getViewRuntime(
55
+ root: string,
56
+ origin: string,
57
+ view: ViewInfo,
58
+ bundle: StudioSchemaBundle | null,
59
+ timeoutMs: number,
60
+ ): Promise<ViewRuntime> {
61
+ const instance = await activeInstanceName()
62
+ const serverPromise = !instance
63
+ ? Promise.resolve({
64
+ status: 'unavailable' as const,
65
+ reason: 'Choose an Astrale instance before opening a local view.',
66
+ })
67
+ : view.mount
68
+ ? ensureViewDevServer(root)
69
+ : Promise.resolve({
70
+ status: 'unavailable' as const,
71
+ reason: 'This view has no local SPA mount.',
72
+ })
73
+
74
+ const targetRequired = viewClasses(view).length > 0
75
+ const targetsPromise = targetRequired
76
+ ? instance
77
+ ? listViewTargets(root, origin, view, bundle, instance, timeoutMs)
78
+ : Promise.resolve<ViewTargetResult>({
79
+ status: 'unavailable',
80
+ items: [],
81
+ selected: null,
82
+ stale: null,
83
+ truncated: false,
84
+ reason: 'No active Astrale instance.',
85
+ })
86
+ : Promise.resolve<ViewTargetResult>({
87
+ status: 'available',
88
+ items: [],
89
+ selected: null,
90
+ stale: null,
91
+ truncated: false,
92
+ })
93
+
94
+ const [server, targets] = await Promise.all([serverPromise, targetsPromise])
95
+
96
+ return {
97
+ slug: view.slug,
98
+ instance,
99
+ targetRequired,
100
+ server,
101
+ targets,
102
+ }
103
+ }
104
+
105
+ export async function launchViewSession(
106
+ root: string,
107
+ origin: string,
108
+ view: ViewInfo,
109
+ bundle: StudioSchemaBundle | null,
110
+ request: { targetId?: unknown },
111
+ timeoutMs: number,
112
+ ): Promise<ViewSessionResult> {
113
+ const instance = await activeInstanceName()
114
+ if (!instance) return { status: 'unavailable', reason: 'No active Astrale instance.' }
115
+ if (!view.mount) {
116
+ return { status: 'unavailable', reason: 'This view has no local SPA mount.' }
117
+ }
118
+
119
+ let target: ViewTargetCandidate | null = null
120
+ if (viewClasses(view).length > 0) {
121
+ const targetId = typeof request.targetId === 'string' ? request.targetId.trim() : ''
122
+ if (!targetId) return { status: 'unavailable', reason: 'Select a target before opening.' }
123
+ const targets = await listViewTargets(root, origin, view, bundle, instance, timeoutMs)
124
+ if (targets.status !== 'available') {
125
+ return { status: 'unavailable', reason: targets.reason ?? 'Targets could not be queried.' }
126
+ }
127
+ target = targets.items.find((item) => item.id === targetId) ?? null
128
+ if (!target) {
129
+ return {
130
+ status: 'unavailable',
131
+ reason: 'That target no longer exists or is no longer visible. Choose another target.',
21
132
  }
22
- }, timeoutMs)
23
- try {
24
- const out = await new Response(proc.stdout).text()
25
- const code = await proc.exited
26
- return code === 0 ? out.trim() || null : null
27
- } finally {
28
- clearTimeout(timer)
29
133
  }
30
- } catch {
31
- return null
32
134
  }
33
- }
34
135
 
35
- /** The `iss` claim of a JWT = the kernel URL the token is for (e.g. https://mcac.eu.astrale.ai/api). */
36
- function jwtIssuer(jwt: string): string | null {
37
- try {
38
- const payload = jwt.split('.')[1]
39
- const json = Buffer.from(payload, 'base64url').toString('utf8')
40
- const iss = JSON.parse(json)?.iss
41
- return typeof iss === 'string' ? iss : null
42
- } catch {
43
- return null
136
+ const server = await ensureViewDevServer(root)
137
+ if (server.status !== 'running') {
138
+ return {
139
+ status: 'unavailable',
140
+ reason: server.reason,
141
+ }
142
+ }
143
+
144
+ const args = ['view', '--view-url', localViewUrl(server.url, view.mount), '--handshake', 'shell']
145
+
146
+ if (target) {
147
+ const targetPath = await resolveTargetPath(target.id, instance, timeoutMs)
148
+ if (!targetPath) {
149
+ return {
150
+ status: 'unavailable',
151
+ reason: 'That target no longer exists or is no longer visible. Choose another target.',
152
+ }
153
+ }
154
+ args.push('--target', targetPath)
155
+ }
156
+
157
+ args.push('--no-open', '--json', '-i', instance)
158
+ const opened = await runViewSessionCommand<{
159
+ session?: { id?: string; pageUrl?: string; view?: { url?: string } }
160
+ }>(args, Math.max(20_000, timeoutMs + 12_000))
161
+ const session = opened.data?.session
162
+ if (!opened.ok || !session?.id || !session.pageUrl || !session.view?.url) {
163
+ return {
164
+ status: 'unavailable',
165
+ reason: opened.detail || '`astrale view` could not start the preview session.',
166
+ }
167
+ }
168
+
169
+ if (target) rememberTarget(root, instance, view.slug, target)
170
+ return {
171
+ status: 'ready',
172
+ sessionId: session.id,
173
+ pageUrl: session.pageUrl,
174
+ viewUrl: session.view.url,
175
+ target,
44
176
  }
45
177
  }
46
178
 
47
- /**
48
- * Mint a short-lived delegation token so the studio can act as the AUTHENTICATED shell
49
- * HOST for a live view preview. The studio's `astrale` CLI session is the source of auth;
50
- * `astrale token` mints a delegation for the active identity on the target instance. The
51
- * browser parent never calls the kernel — it just hands this token to the view iframe.
52
- */
53
- export async function mintPreviewToken(
54
- origin: string | null,
55
- instance: string | null,
56
- slug: string,
57
- ttlSeconds = 1800,
58
- ): Promise<PreviewToken> {
59
- if (!origin || !instance || !slug)
60
- return { status: 'unavailable', reason: 'no target instance for this domain' }
61
- const token = await astraleText(
62
- ['token', '--ttl', String(ttlSeconds), '--raw', '-i', instance],
63
- 12000,
179
+ export async function closeViewSession(sessionId: string): Promise<{ ok: true }> {
180
+ if (!/^v-[0-9a-f]+$/.test(sessionId)) return { ok: true }
181
+ await runViewSessionCommand(['view', '--close', sessionId, '--json'], 6000)
182
+ return { ok: true }
183
+ }
184
+
185
+ async function resolveTargetPath(
186
+ id: string,
187
+ instance: string,
188
+ timeoutMs: number,
189
+ ): Promise<string | null> {
190
+ const result = await runAstraleJson<{ path?: unknown }>(
191
+ ['get', `@${id}`, '--json', '-i', instance],
192
+ timeoutMs,
64
193
  )
65
- if (!token || token.split('.').length !== 3)
194
+ return result.ok && typeof result.data?.path === 'string' ? result.data.path : null
195
+ }
196
+
197
+ async function listViewTargets(
198
+ root: string,
199
+ origin: string,
200
+ view: ViewInfo,
201
+ bundle: StudioSchemaBundle | null,
202
+ instance: string,
203
+ timeoutMs: number,
204
+ ): Promise<ViewTargetResult> {
205
+ const bindings = viewClasses(view).map((className) => ({
206
+ className,
207
+ classOrigin: bundle?.ir?.imports[className]?.origin ?? origin,
208
+ }))
209
+ if (bindings.length === 0) {
210
+ return {
211
+ status: 'available',
212
+ items: [],
213
+ selected: null,
214
+ stale: null,
215
+ truncated: false,
216
+ }
217
+ }
218
+
219
+ const queried = await Promise.all(
220
+ bindings.map(async (binding) => {
221
+ const cypher = targetQuery(binding.className, binding.classOrigin)
222
+ const result = await runAstraleJson<RawTargetRow[]>(
223
+ ['query', '--cypher', cypher, '--json', '-i', instance],
224
+ timeoutMs,
225
+ )
226
+ return { binding, result }
227
+ }),
228
+ )
229
+ const successes = queried.filter((item) => item.result.ok && Array.isArray(item.result.data))
230
+ if (successes.length === 0) {
231
+ const reason = queried.map((item) => item.result.detail).find(Boolean)
66
232
  return {
67
233
  status: 'unavailable',
68
- reason: 'could not mint a token (instance unreachable or not authenticated)',
234
+ items: [],
235
+ selected: null,
236
+ stale: null,
237
+ truncated: false,
238
+ reason: reason || 'The active instance could not be queried for view targets.',
239
+ }
240
+ }
241
+
242
+ const byId = new Map<string, ViewTargetCandidate>()
243
+ let truncated = false
244
+ for (const { binding, result } of successes) {
245
+ const rows = result.data ?? []
246
+ if (rows.length > TARGET_LIMIT) truncated = true
247
+ for (const row of rows.slice(0, TARGET_LIMIT)) {
248
+ const target = targetFromRow(row, binding.className, binding.classOrigin)
249
+ if (target) byId.set(target.id, target)
69
250
  }
70
- const kernelUrl = jwtIssuer(token)
251
+ }
252
+ const items = [...byId.values()].sort(
253
+ (a, b) => a.label.localeCompare(b.label) || a.className.localeCompare(b.className),
254
+ )
255
+ const remembered = readRememberedTarget(root, instance, view.slug)
256
+ const reconciled = reconcileRememberedTarget(remembered, items)
71
257
  return {
72
- status: 'ok',
73
- token,
74
- expiresAt: Date.now() + ttlSeconds * 1000,
75
- kernelUrl: kernelUrl ?? '',
76
- functionId: `/${origin}/core/views/${slug}`,
258
+ status: 'available',
259
+ items,
260
+ ...reconciled,
261
+ truncated,
77
262
  }
78
263
  }
79
264
 
80
- /**
81
- * @param origin the domain origin (e.g. mcac.app)
82
- * @param instance the instance to query (the domain's deploy target)
83
- * @param slug the view slug (its key in views/index.ts)
84
- */
85
- export async function resolveViewUrl(
86
- origin: string | null,
87
- instance: string | null,
265
+ export function reconcileRememberedTarget(
266
+ remembered: RememberedViewTarget | null,
267
+ items: ViewTargetCandidate[],
268
+ ): Pick<ViewTargetResult, 'selected' | 'stale'> {
269
+ if (!remembered) return { selected: null, stale: null }
270
+ const selected = items.find((item) => item.id === remembered.id) ?? null
271
+ return { selected, stale: selected ? null : remembered }
272
+ }
273
+
274
+ export function targetFromRow(
275
+ row: RawTargetRow,
276
+ className: string,
277
+ classOrigin: string,
278
+ ): ViewTargetCandidate | null {
279
+ const id =
280
+ typeof row.id === 'string' ? row.id : typeof row.props?.id === 'string' ? row.props.id : ''
281
+ if (!id) return null
282
+ const props = row.props ?? {}
283
+ const firstName = stringProp(props, ['.property.firstName'])
284
+ const lastName = stringProp(props, ['.property.lastName'])
285
+ const label =
286
+ asNonEmptyString(props[NAMED_NAME]) ??
287
+ stringProp(props, ['.property.title', '.property.label', '.property.name', '.property.slug']) ??
288
+ ([firstName, lastName].filter(Boolean).join(' ') || `${className} · ${id.slice(0, 8)}`)
289
+ return {
290
+ id,
291
+ ref: `@${id}`,
292
+ className,
293
+ classOrigin,
294
+ label,
295
+ description:
296
+ asNonEmptyString(props[DESCRIPTABLE_DESCRIPTION]) ??
297
+ stringProp(props, ['.property.description', '.property.email']),
298
+ status: asNonEmptyString(props[STATUSED_STATUS]) ?? stringProp(props, ['.property.status']),
299
+ }
300
+ }
301
+
302
+ export function targetQuery(className: string, classOrigin: string): string {
303
+ const name = JSON.stringify(assertSchemaName(className))
304
+ const origin = JSON.stringify(assertOrigin(classOrigin))
305
+ return `MATCH (n:Node)-[:instance_of]->(c:Class)-[:of_domain]->(d:Domain) WHERE c.\`${NAMED_NAME}\` = ${name} AND (d.origin = ${origin} OR d.\`${NAMED_NAME}\` = ${origin} OR d.\`${DOMAIN_ORIGIN}\` = ${origin}) RETURN n.id AS id, n AS props LIMIT ${TARGET_LIMIT + 1}`
306
+ }
307
+
308
+ function viewClasses(view: ViewInfo): string[] {
309
+ return Array.isArray(view.viewFor) ? view.viewFor : view.viewFor ? [view.viewFor] : []
310
+ }
311
+
312
+ function rememberTarget(
313
+ root: string,
314
+ instance: string,
315
+ slug: string,
316
+ target: ViewTargetCandidate,
317
+ ): void {
318
+ const stored = readJson<StoredViewState>(root, STATE_FILE, {})
319
+ stored.targets ??= {}
320
+ stored.targets[instance] ??= {}
321
+ stored.targets[instance][slug] = {
322
+ id: target.id,
323
+ className: target.className,
324
+ classOrigin: target.classOrigin,
325
+ label: target.label,
326
+ }
327
+ writeJson(root, STATE_FILE, stored)
328
+ }
329
+
330
+ function readRememberedTarget(
331
+ root: string,
332
+ instance: string,
88
333
  slug: string,
89
- timeoutMs = 8000,
90
- ): Promise<ViewUrlResult> {
91
- if (!origin || !instance || !slug) return { status: 'unknown' }
334
+ ): RememberedViewTarget | null {
335
+ return readJson<StoredViewState>(root, STATE_FILE, {}).targets?.[instance]?.[slug] ?? null
336
+ }
337
+
338
+ function assertOrigin(value: string): string {
339
+ if (!/^[a-z0-9][a-z0-9.-]*$/i.test(value)) throw new Error(`Invalid domain origin: ${value}`)
340
+ return value
341
+ }
342
+
343
+ function assertSchemaName(value: string): string {
344
+ if (!/^[A-Za-z_$][\w$]*$/.test(value)) throw new Error(`Invalid class name: ${value}`)
345
+ return value
346
+ }
347
+
348
+ function stringProp(props: Record<string, unknown>, suffixes: string[]): string | undefined {
349
+ for (const suffix of suffixes) {
350
+ for (const [key, value] of Object.entries(props)) {
351
+ if (!key.endsWith(suffix)) continue
352
+ const text = asNonEmptyString(value)
353
+ if (text) return text
354
+ }
355
+ }
356
+ return undefined
357
+ }
358
+
359
+ function asNonEmptyString(value: unknown): string | undefined {
360
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
361
+ }
362
+
363
+ export function conciseCliFailure(raw: string): string | undefined {
364
+ const lines = raw
365
+ .split(/\r?\n/)
366
+ .map((line) => line.trim())
367
+ .filter(Boolean)
368
+ const error = lines.find((line) => /^[A-Za-z_$][\w$]*(?:Error|Exception):\s+\S/.test(line))
369
+ if (error) return error.slice(0, 600)
370
+ const explicit = lines.find((line) => /^(?:error|failed):\s+\S/i.test(line))
371
+ if (explicit) return explicit.slice(0, 600)
372
+ const useful = lines.find(
373
+ (line) =>
374
+ !/^\d+\s+\|/.test(line) &&
375
+ line !== '^' &&
376
+ !/^at\s/.test(line) &&
377
+ !/^Bun v\d/.test(line) &&
378
+ !/^details?:\s*[{[]?$/i.test(line),
379
+ )
380
+ return useful?.slice(0, 600)
381
+ }
382
+
383
+ function runViewSessionCommand<T>(args: string[], timeoutMs: number): Promise<AstraleResult<T>> {
384
+ const command = viewSessionCommandTail.then(
385
+ () => runAstraleJson<T>(args, timeoutMs),
386
+ () => runAstraleJson<T>(args, timeoutMs),
387
+ )
388
+ viewSessionCommandTail = command.then(
389
+ () => undefined,
390
+ () => undefined,
391
+ )
392
+ return command
393
+ }
394
+
395
+ async function runAstraleJson<T>(args: string[], timeoutMs: number): Promise<AstraleResult<T>> {
92
396
  try {
93
- const proc = Bun.spawn(
94
- ['astrale', 'get', `/${origin}/core/views/${slug}`, '-i', instance, '--json'],
95
- { stdout: 'pipe', stderr: 'pipe' },
96
- )
397
+ const proc = Bun.spawn(['astrale', ...args], { stdout: 'pipe', stderr: 'pipe' })
97
398
  const timer = setTimeout(() => {
98
399
  try {
99
400
  proc.kill()
100
401
  } catch {
101
- /* already exited */
402
+ // Already exited.
102
403
  }
103
404
  }, timeoutMs)
104
405
  try {
105
- const [out, err] = await Promise.all([
406
+ const [stdout, stderr, code] = await Promise.all([
106
407
  new Response(proc.stdout).text(),
107
408
  new Response(proc.stderr).text(),
409
+ proc.exited,
108
410
  ])
109
- await proc.exited
110
- let parsed: any = null
411
+ let data: T | null = null
111
412
  try {
112
- parsed = JSON.parse(out)
413
+ data = JSON.parse(stdout) as T
113
414
  } catch {
114
- /* non-JSON */
415
+ // The detail below remains useful for CLI failures and old versions.
115
416
  }
116
- if (parsed?.error === 'NOT_FOUND' || /\bNOT_FOUND\b/.test(`${out}\n${err}`))
117
- return { status: 'not-installed' }
118
- if (parsed?.path) {
119
- let url: string | null = null
120
- const binding = parsed.props?.[BINDING_KEY]
121
- if (typeof binding === 'string') {
122
- try {
123
- const u = JSON.parse(binding)?.remoteUrl
124
- if (typeof u === 'string' && u) url = u
125
- } catch {
126
- /* malformed binding */
127
- }
128
- }
129
- return { status: 'installed', url }
130
- }
131
- return { status: 'unknown' }
417
+ const parsed = data as { message?: unknown; error?: unknown } | null
418
+ const detail =
419
+ asNonEmptyString(parsed?.message) ??
420
+ asNonEmptyString(parsed?.error) ??
421
+ conciseCliFailure(stderr) ??
422
+ conciseCliFailure(stdout) ??
423
+ ''
424
+ return { ok: code === 0 && data !== null, data, detail }
132
425
  } finally {
133
426
  clearTimeout(timer)
134
427
  }
135
- } catch {
136
- return { status: 'unknown' }
428
+ } catch (error) {
429
+ return {
430
+ ok: false,
431
+ data: null,
432
+ detail: error instanceof Error ? error.message : String(error),
433
+ }
137
434
  }
138
435
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
- * visibility.ts — persisted per-domain canvas visibility (the manual hide-set +
3
- * the inherited-edge category toggle). Sibling of layout.ts: layout owns node
4
- * POSITIONS, this owns what's SHOWN. Both keyed only by stable refs, so they
5
- * survive schema edits (a removed ref's hidden flag is harmless). Persisted via
6
- * the allow-listed store (visibility.json).
2
+ * visibility.ts — persisted per-domain canvas visibility (the manual hide-set,
3
+ * inherited-edge category toggle, and materialized-interface set). Sibling of
4
+ * layout.ts: layout owns node POSITIONS, this owns what's SHOWN. All entries use
5
+ * stable refs, so they survive schema edits (a removed ref is harmless).
6
+ * Persisted via the allow-listed store (visibility.json).
7
7
  */
8
8
  import { readJson, removeState, writeJson } from './store'
9
9
 
@@ -14,7 +14,11 @@ export interface VisibilityState {
14
14
  }
15
15
 
16
16
  const FILE = 'visibility.json'
17
- const DEFAULT: VisibilityState = { hidden: {}, showInheritedEdges: true, materializedInterfaces: {} }
17
+ const DEFAULT: VisibilityState = {
18
+ hidden: {},
19
+ showInheritedEdges: true,
20
+ materializedInterfaces: {},
21
+ }
18
22
 
19
23
  export function readVisibility(root: string): VisibilityState {
20
24
  // merge over DEFAULT so a file written before a field existed (e.g. pre-materialize)
@@ -0,0 +1,111 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { invalidateClientPackage } from './client-package'
7
+ import { ViewDevServerManager } from './view-dev-server'
8
+
9
+ const roots: string[] = []
10
+ const managers: ViewDevServerManager[] = []
11
+
12
+ function domainFixture(name: string, packageDir = 'client'): string {
13
+ const root = mkdtempSync(join(tmpdir(), `studio-view-server-${name}-`))
14
+ const client = join(root, packageDir)
15
+ roots.push(root)
16
+ mkdirSync(client, { recursive: true })
17
+ writeFileSync(
18
+ join(client, 'package.json'),
19
+ JSON.stringify({ private: true, scripts: { 'dev:hmr': 'bun server.ts' } }),
20
+ )
21
+ writeFileSync(
22
+ join(client, 'server.ts'),
23
+ `const args = process.argv.slice(2)
24
+ const port = Number(args[args.indexOf('--port') + 1])
25
+ const server = Bun.serve({ hostname: '127.0.0.1', port, fetch: () => new Response(${JSON.stringify(name)}) })
26
+ const stop = () => { server.stop(true); process.exit(0) }
27
+ process.on('SIGTERM', stop)
28
+ process.on('SIGINT', stop)
29
+ `,
30
+ )
31
+ return root
32
+ }
33
+
34
+ afterEach(async () => {
35
+ await Promise.all(managers.splice(0).map((manager) => manager.shutdown()))
36
+ for (const root of roots.splice(0)) {
37
+ invalidateClientPackage(root)
38
+ rmSync(root, { recursive: true, force: true })
39
+ }
40
+ })
41
+
42
+ describe('view dev server lifecycle', () => {
43
+ test('runs domains lazily on distinct OS-assigned ports, reuses them, and tears them down', async () => {
44
+ const manager = new ViewDevServerManager({
45
+ idleTimeoutMs: 250,
46
+ startTimeoutMs: 5_000,
47
+ failureRetryMs: 50,
48
+ })
49
+ managers.push(manager)
50
+ const alpha = domainFixture('alpha')
51
+ const beta = domainFixture('beta')
52
+
53
+ expect(manager.status(alpha)).toBeNull()
54
+ const [alphaServer, sameAlphaServer, betaServer] = await Promise.all([
55
+ manager.ensure(alpha),
56
+ manager.ensure(alpha),
57
+ manager.ensure(beta),
58
+ ])
59
+ expect(alphaServer).toMatchObject({ status: 'running' })
60
+ expect(betaServer).toMatchObject({ status: 'running' })
61
+ if (alphaServer.status !== 'running' || betaServer.status !== 'running') return
62
+ expect(sameAlphaServer.status === 'running' ? sameAlphaServer.port : null).toBe(
63
+ alphaServer.port,
64
+ )
65
+ expect(alphaServer.port).not.toBe(betaServer.port)
66
+ expect(await fetch(alphaServer.url).then((response) => response.text())).toBe('alpha')
67
+ expect(await fetch(betaServer.url).then((response) => response.text())).toBe('beta')
68
+
69
+ const reused = await manager.ensure(alpha)
70
+ expect(reused.status === 'running' ? reused.port : null).toBe(alphaServer.port)
71
+
72
+ await Bun.sleep(600)
73
+ expect(manager.status(alpha)).toBeNull()
74
+ expect(manager.status(beta)).toBeNull()
75
+
76
+ const restarted = await manager.ensure(alpha)
77
+ expect(restarted.status).toBe('running')
78
+ if (restarted.status === 'running') {
79
+ expect(await fetch(restarted.url).then((response) => response.text())).toBe('alpha')
80
+ }
81
+ await manager.shutdown()
82
+ expect(manager.status(alpha)).toBeNull()
83
+ }, 15_000)
84
+
85
+ test('returns a useful unavailable state when the domain has no HMR script', async () => {
86
+ const manager = new ViewDevServerManager({ idleTimeoutMs: 250 })
87
+ managers.push(manager)
88
+ const root = domainFixture('missing-script')
89
+ writeFileSync(join(root, 'client', 'package.json'), JSON.stringify({ scripts: {} }))
90
+
91
+ expect(await manager.ensure(root)).toEqual({
92
+ status: 'unavailable',
93
+ reason: 'This domain has no package that defines a dev:hmr script.',
94
+ })
95
+ })
96
+
97
+ test('starts the sole frontend preview package without adapter metadata', async () => {
98
+ const manager = new ViewDevServerManager({ idleTimeoutMs: 250, startTimeoutMs: 5_000 })
99
+ managers.push(manager)
100
+ const root = domainFixture('configured-frontend', 'frontend')
101
+
102
+ const server = await manager.ensure(root)
103
+
104
+ expect(server).toMatchObject({ status: 'running' })
105
+ if (server.status === 'running') {
106
+ expect(await fetch(server.url).then((response) => response.text())).toBe(
107
+ 'configured-frontend',
108
+ )
109
+ }
110
+ }, 10_000)
111
+ })