@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
|
@@ -1,138 +1,435 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `astrale
|
|
6
|
-
*
|
|
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 {
|
|
9
|
+
import type {
|
|
10
|
+
RememberedViewTarget,
|
|
11
|
+
StudioSchemaBundle,
|
|
12
|
+
ViewInfo,
|
|
13
|
+
ViewRuntime,
|
|
14
|
+
ViewSessionResult,
|
|
15
|
+
ViewTargetCandidate,
|
|
16
|
+
ViewTargetResult,
|
|
17
|
+
} from '../../shared/types'
|
|
9
18
|
|
|
10
|
-
|
|
19
|
+
import { ensureViewDevServer } from '../view-dev-server'
|
|
20
|
+
import { activeInstanceName } from './instance'
|
|
21
|
+
import { readJson, writeJson } from './store'
|
|
11
22
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
instance: string
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
functionId: `/${origin}/core/views/${slug}`,
|
|
258
|
+
status: 'available',
|
|
259
|
+
items,
|
|
260
|
+
...reconciled,
|
|
261
|
+
truncated,
|
|
77
262
|
}
|
|
78
263
|
}
|
|
79
264
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
402
|
+
// Already exited.
|
|
102
403
|
}
|
|
103
404
|
}, timeoutMs)
|
|
104
405
|
try {
|
|
105
|
-
const [
|
|
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
|
-
|
|
110
|
-
let parsed: any = null
|
|
411
|
+
let data: T | null = null
|
|
111
412
|
try {
|
|
112
|
-
|
|
413
|
+
data = JSON.parse(stdout) as T
|
|
113
414
|
} catch {
|
|
114
|
-
|
|
415
|
+
// The detail below remains useful for CLI failures and old versions.
|
|
115
416
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 {
|
|
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
|
-
*
|
|
4
|
-
* POSITIONS, this owns what's SHOWN.
|
|
5
|
-
* survive schema edits (a removed ref
|
|
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 = {
|
|
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
|
+
})
|