@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
@@ -0,0 +1,147 @@
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, resolveClientPackage } from './client-package'
7
+
8
+ const roots: string[] = []
9
+
10
+ function fixture(rootPackage: Record<string, unknown> = {}): string {
11
+ const root = mkdtempSync(join(tmpdir(), 'studio-client-package-'))
12
+ roots.push(root)
13
+ writePackage(root, rootPackage)
14
+ return root
15
+ }
16
+
17
+ function writePackage(dir: string, pkg: Record<string, unknown>): void {
18
+ mkdirSync(dir, { recursive: true })
19
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ private: true, ...pkg }))
20
+ }
21
+
22
+ afterEach(() => {
23
+ for (const root of roots.splice(0)) {
24
+ invalidateClientPackage(root)
25
+ rmSync(root, { recursive: true, force: true })
26
+ }
27
+ })
28
+
29
+ describe('client package discovery', () => {
30
+ test('discovers the sole workspace preview package without executing domain config', async () => {
31
+ const root = fixture({ workspaces: ['frontend', 'schema'] })
32
+ const frontend = join(root, 'frontend')
33
+ writePackage(frontend, { scripts: { 'dev:hmr': 'vite' } })
34
+ writePackage(join(root, 'schema'), {})
35
+ writeFileSync(join(root, 'astrale.config.ts'), 'throw new Error("must not execute")\n')
36
+
37
+ const first = await resolveClientPackage(root)
38
+ const second = await resolveClientPackage(root)
39
+
40
+ expect(first).toEqual({
41
+ status: 'available',
42
+ packageDir: frontend,
43
+ sourceDir: frontend,
44
+ packageFile: join(frontend, 'package.json'),
45
+ devScript: 'vite',
46
+ source: 'workspace',
47
+ })
48
+ expect(second).toEqual(first)
49
+ })
50
+
51
+ test('discovers a conventional direct child without workspace metadata', async () => {
52
+ const root = fixture()
53
+ const client = join(root, 'client')
54
+ writePackage(client, { scripts: { 'dev:hmr': 'vite' } })
55
+
56
+ expect(await resolveClientPackage(root)).toMatchObject({
57
+ status: 'available',
58
+ packageDir: client,
59
+ sourceDir: client,
60
+ source: 'workspace',
61
+ })
62
+ })
63
+
64
+ test('discovers nested packages from pnpm-workspace.yaml', async () => {
65
+ const root = fixture()
66
+ const web = join(root, 'apps', 'web')
67
+ writeFileSync(join(root, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n")
68
+ writePackage(web, { scripts: { 'dev:hmr': 'vite' } })
69
+
70
+ expect(await resolveClientPackage(root)).toMatchObject({
71
+ status: 'available',
72
+ packageDir: web,
73
+ sourceDir: web,
74
+ source: 'workspace',
75
+ })
76
+ })
77
+
78
+ test('uses an explicit root script to disambiguate multiple preview packages', async () => {
79
+ const root = fixture({
80
+ workspaces: ['apps/*'],
81
+ scripts: { 'dev:hmr': 'pnpm --dir apps/web dev:hmr' },
82
+ })
83
+ writePackage(join(root, 'apps', 'admin'), { scripts: { 'dev:hmr': 'vite' } })
84
+ writePackage(join(root, 'apps', 'web'), { scripts: { 'dev:hmr': 'vite' } })
85
+
86
+ expect(await resolveClientPackage(root)).toEqual({
87
+ status: 'available',
88
+ packageDir: root,
89
+ sourceDir: root,
90
+ packageFile: join(root, 'package.json'),
91
+ devScript: 'pnpm --dir apps/web dev:hmr',
92
+ source: 'root',
93
+ })
94
+ })
95
+
96
+ test('rejects ambiguous preview packages without guessing', async () => {
97
+ const root = fixture({ workspaces: ['apps/*'] })
98
+ writePackage(join(root, 'apps', 'admin'), { scripts: { 'dev:hmr': 'vite' } })
99
+ writePackage(join(root, 'apps', 'web'), { scripts: { 'dev:hmr': 'vite' } })
100
+
101
+ expect(await resolveClientPackage(root)).toEqual({
102
+ status: 'unavailable',
103
+ reason:
104
+ 'Multiple packages define dev:hmr (apps/admin, apps/web). Add a dev:hmr script to the domain root to choose one.',
105
+ })
106
+ })
107
+
108
+ test('reports a domain with no preview package', async () => {
109
+ const root = fixture({ workspaces: ['frontend', 'schema'] })
110
+ writePackage(join(root, 'frontend'), {})
111
+ writePackage(join(root, 'schema'), {})
112
+
113
+ expect(await resolveClientPackage(root)).toEqual({
114
+ status: 'unavailable',
115
+ reason: 'This domain has no package that defines a dev:hmr script.',
116
+ })
117
+ })
118
+
119
+ test('invalidates cached discovery when an inspected package changes', async () => {
120
+ const root = fixture({ workspaces: ['frontend'] })
121
+ const frontend = join(root, 'frontend')
122
+ writePackage(frontend, {})
123
+ expect((await resolveClientPackage(root)).status).toBe('unavailable')
124
+
125
+ writePackage(frontend, { scripts: { 'dev:hmr': 'vite' } })
126
+
127
+ expect(await resolveClientPackage(root)).toMatchObject({
128
+ status: 'available',
129
+ packageDir: frontend,
130
+ })
131
+ })
132
+
133
+ test('invalidates cached discovery when a nested workspace package is added', async () => {
134
+ const root = fixture({ workspaces: ['apps/*'] })
135
+ const apps = join(root, 'apps')
136
+ mkdirSync(apps)
137
+ expect((await resolveClientPackage(root)).status).toBe('unavailable')
138
+
139
+ const web = join(apps, 'web')
140
+ writePackage(web, { scripts: { 'dev:hmr': 'vite' } })
141
+
142
+ expect(await resolveClientPackage(root)).toMatchObject({
143
+ status: 'available',
144
+ packageDir: web,
145
+ })
146
+ })
147
+ })
@@ -0,0 +1,242 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { isAbsolute, join, relative, resolve } from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+
5
+ export type ClientPackageResolution =
6
+ | {
7
+ status: 'available'
8
+ packageDir: string
9
+ sourceDir: string
10
+ packageFile: string
11
+ devScript: string
12
+ source: 'root' | 'workspace'
13
+ }
14
+ | { status: 'unavailable'; reason: string }
15
+
16
+ interface CacheEntry {
17
+ fingerprint: string
18
+ value: ClientPackageResolution
19
+ }
20
+
21
+ interface PackageManifest {
22
+ scripts?: Record<string, unknown>
23
+ workspaces?: string[] | { packages?: string[] }
24
+ }
25
+
26
+ interface PreviewPackage {
27
+ packageDir: string
28
+ packageFile: string
29
+ devScript: string
30
+ }
31
+
32
+ interface DiscoveryInputs {
33
+ packageFiles: string[]
34
+ rootPackage?: PackageManifest
35
+ error?: string
36
+ }
37
+
38
+ const cache = new Map<string, CacheEntry>()
39
+
40
+ export async function resolveClientPackage(
41
+ root: string,
42
+ force = false,
43
+ ): Promise<ClientPackageResolution> {
44
+ const projectDir = resolve(root)
45
+ const inputs = discoverInputs(projectDir)
46
+ if (inputs.error) return unavailable(inputs.error)
47
+
48
+ const fingerprint = inputFingerprint(projectDir, inputs.packageFiles)
49
+ const previous = cache.get(projectDir)
50
+ if (!force && previous?.fingerprint === fingerprint) {
51
+ return previous.value
52
+ }
53
+
54
+ const value = resolveDiscovery(projectDir, inputs.rootPackage, inputs.packageFiles)
55
+ const entry: CacheEntry = {
56
+ fingerprint,
57
+ value,
58
+ }
59
+ cache.set(projectDir, entry)
60
+ return value
61
+ }
62
+
63
+ export function invalidateClientPackage(root: string): void {
64
+ cache.delete(resolve(root))
65
+ }
66
+
67
+ function discoverInputs(projectDir: string): DiscoveryInputs {
68
+ const rootPackageFile = join(projectDir, 'package.json')
69
+ let rootPackage: PackageManifest | undefined
70
+ try {
71
+ if (existsSync(rootPackageFile)) rootPackage = readPackage(rootPackageFile)
72
+ } catch (error) {
73
+ const reason = `Could not read ${rootPackageFile}: ${errorMessage(error)}`
74
+ return { packageFiles: [rootPackageFile], error: reason }
75
+ }
76
+
77
+ try {
78
+ const packageFiles = discoverPackageFiles(projectDir, rootPackage)
79
+ return {
80
+ packageFiles: [...new Set([rootPackageFile, ...packageFiles])],
81
+ rootPackage,
82
+ }
83
+ } catch (error) {
84
+ const reason = `Could not discover domain workspace packages: ${errorMessage(error)}`
85
+ return { packageFiles: [rootPackageFile], error: reason }
86
+ }
87
+ }
88
+
89
+ function resolveDiscovery(
90
+ projectDir: string,
91
+ rootPackage: PackageManifest | undefined,
92
+ inspectedFiles: string[],
93
+ ): ClientPackageResolution {
94
+ const rootPackageFile = join(projectDir, 'package.json')
95
+ const candidates: PreviewPackage[] = []
96
+ for (const packageFile of inspectedFiles) {
97
+ if (packageFile === rootPackageFile) continue
98
+ try {
99
+ const devScript = previewScript(readPackage(packageFile))
100
+ if (devScript) {
101
+ candidates.push({ packageDir: resolve(packageFile, '..'), packageFile, devScript })
102
+ }
103
+ } catch (error) {
104
+ return unavailable(`Could not read ${packageFile}: ${errorMessage(error)}`)
105
+ }
106
+ }
107
+
108
+ const rootScript = previewScript(rootPackage)
109
+ if (rootScript) {
110
+ return {
111
+ status: 'available',
112
+ packageDir: projectDir,
113
+ sourceDir: projectDir,
114
+ packageFile: rootPackageFile,
115
+ devScript: rootScript,
116
+ source: 'root',
117
+ }
118
+ }
119
+
120
+ if (candidates.length === 1) {
121
+ const candidate = candidates[0]!
122
+ return {
123
+ status: 'available',
124
+ ...candidate,
125
+ sourceDir: candidate.packageDir,
126
+ source: 'workspace',
127
+ }
128
+ }
129
+
130
+ if (candidates.length > 1) {
131
+ const names = candidates
132
+ .map((candidate) => relative(projectDir, candidate.packageDir).replaceAll('\\', '/'))
133
+ .sort()
134
+ .join(', ')
135
+ return unavailable(
136
+ `Multiple packages define dev:hmr (${names}). Add a dev:hmr script to the domain root to choose one.`,
137
+ )
138
+ }
139
+
140
+ return unavailable('This domain has no package that defines a dev:hmr script.')
141
+ }
142
+
143
+ function discoverPackageFiles(projectDir: string, rootPackage?: PackageManifest): string[] {
144
+ const workspaceFile = join(projectDir, 'pnpm-workspace.yaml')
145
+ const patterns = [...packageWorkspacePatterns(rootPackage)]
146
+ if (existsSync(workspaceFile)) {
147
+ const workspace = parseYaml(readFileSync(workspaceFile, 'utf8')) as {
148
+ packages?: unknown
149
+ } | null
150
+ if (Array.isArray(workspace?.packages)) {
151
+ patterns.push(
152
+ ...workspace.packages.filter((value): value is string => typeof value === 'string'),
153
+ )
154
+ }
155
+ }
156
+
157
+ const includes = ['*/package.json']
158
+ const excludes: string[] = []
159
+ for (const pattern of patterns) {
160
+ const raw = pattern.trim()
161
+ const excluded = raw.startsWith('!')
162
+ const normalized = normalizeWorkspacePattern(excluded ? raw.slice(1) : raw)
163
+ if (!normalized) continue
164
+ if (excluded) excludes.push(`${normalized}/package.json`)
165
+ else includes.push(`${normalized}/package.json`)
166
+ }
167
+
168
+ const excludedGlobs = excludes.map((pattern) => new Bun.Glob(pattern))
169
+ const files = new Set<string>()
170
+ for (const pattern of includes) {
171
+ const glob = new Bun.Glob(pattern)
172
+ for (const file of glob.scanSync({ cwd: projectDir, onlyFiles: true })) {
173
+ const normalized = file.replaceAll('\\', '/')
174
+ if (isIgnoredPackagePath(normalized)) continue
175
+ if (excludedGlobs.some((excluded) => excluded.match(normalized))) continue
176
+ const absolute = resolve(projectDir, normalized)
177
+ if (inside(projectDir, absolute)) files.add(absolute)
178
+ }
179
+ }
180
+
181
+ return [...files].sort()
182
+ }
183
+
184
+ function normalizeWorkspacePattern(pattern: string): string | undefined {
185
+ const normalized = pattern.replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/+$/, '')
186
+ if (!normalized || normalized === '.' || isAbsolute(normalized)) return undefined
187
+ if (normalized.split('/').includes('..')) return undefined
188
+ return normalized
189
+ }
190
+
191
+ function isIgnoredPackagePath(path: string): boolean {
192
+ const segments = path.split('/')
193
+ return segments.includes('node_modules') || segments.includes('.git')
194
+ }
195
+
196
+ function packageWorkspacePatterns(pkg?: PackageManifest): string[] {
197
+ const workspaces = Array.isArray(pkg?.workspaces)
198
+ ? pkg.workspaces
199
+ : Array.isArray(pkg?.workspaces?.packages)
200
+ ? pkg.workspaces.packages
201
+ : []
202
+ return workspaces.filter((value): value is string => typeof value === 'string')
203
+ }
204
+
205
+ function readPackage(packageFile: string): PackageManifest {
206
+ return JSON.parse(readFileSync(packageFile, 'utf8')) as PackageManifest
207
+ }
208
+
209
+ function previewScript(pkg?: PackageManifest): string | undefined {
210
+ const script = pkg?.scripts?.['dev:hmr']
211
+ return typeof script === 'string' && script.trim() ? script : undefined
212
+ }
213
+
214
+ function inside(root: string, file: string): boolean {
215
+ const path = relative(root, file)
216
+ return path !== '..' && !path.startsWith('../') && !path.startsWith('..\\')
217
+ }
218
+
219
+ function inputFingerprint(projectDir: string, packageFiles: string[]): string {
220
+ return [
221
+ fileFingerprint(projectDir),
222
+ fileFingerprint(join(projectDir, 'pnpm-workspace.yaml')),
223
+ ...packageFiles.map(fileFingerprint),
224
+ ].join('|')
225
+ }
226
+
227
+ function fileFingerprint(path: string): string {
228
+ try {
229
+ const stat = statSync(path)
230
+ return `${path}:${stat.size}:${stat.mtimeMs}`
231
+ } catch {
232
+ return `${path}:missing`
233
+ }
234
+ }
235
+
236
+ function unavailable(reason: string): ClientPackageResolution {
237
+ return { status: 'unavailable', reason }
238
+ }
239
+
240
+ function errorMessage(error: unknown): string {
241
+ return error instanceof Error ? error.message : String(error)
242
+ }
@@ -17,6 +17,7 @@ import { resolveTarget } from './detect'
17
17
  import { allDomains } from './domain'
18
18
  import { bootDomain } from './lifecycle'
19
19
  import { broadcast, sseResponse } from './sse'
20
+ import { shutdownViewDevServers } from './view-dev-server'
20
21
  import { initWorkspaceState, stoppers } from './workspace-state'
21
22
  import { watchWorkspace } from './workspace-watch'
22
23
 
@@ -116,6 +117,18 @@ const server = Bun.serve({
116
117
  },
117
118
  })
118
119
 
120
+ let shuttingDown = false
121
+ async function shutdown(signal: NodeJS.Signals): Promise<void> {
122
+ if (shuttingDown) return
123
+ shuttingDown = true
124
+ await shutdownViewDevServers()
125
+ for (const stop of stoppers.values()) stop()
126
+ server.stop(true)
127
+ process.exit(signal === 'SIGINT' ? 130 : 143)
128
+ }
129
+ process.once('SIGINT', () => void shutdown('SIGINT'))
130
+ process.once('SIGTERM', () => void shutdown('SIGTERM'))
131
+
119
132
  setBridgePort(server.port ?? port)
120
133
  const urlStr = `http://localhost:${server.port}`
121
134
  console.log(`\n ▸ ${urlStr}${DEV ? ' (dev proxy → Vite)' : ''}\n`)
@@ -0,0 +1,91 @@
1
+ import { afterEach, 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 { buildClientTree, buildViews } from './anatomy-extras'
7
+
8
+ const roots: string[] = []
9
+
10
+ afterEach(() => {
11
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
12
+ })
13
+
14
+ test('discovers view mounts from any top-level client route registry', () => {
15
+ const root = mkdtempSync(join(tmpdir(), 'studio-client-tree-'))
16
+ roots.push(root)
17
+ const src = join(root, 'client', 'src')
18
+ mkdirSync(src, { recursive: true })
19
+ writeFileSync(
20
+ join(src, 'views.tsx'),
21
+ `const VIEW_REGISTRY = {
22
+ '/ui/issues': IssuesView,
23
+ '/ui/issue': TargetIssueView,
24
+ }`,
25
+ )
26
+ writeFileSync(join(src, 'app.tsx'), `const ROUTES = { '/ui/settings': SettingsView }`)
27
+
28
+ expect(buildClientTree(root).routes).toEqual({
29
+ '/ui/issue': 'TargetIssueView',
30
+ '/ui/issues': 'IssuesView',
31
+ '/ui/settings': 'SettingsView',
32
+ })
33
+ })
34
+
35
+ test('reads the discovered client directory', () => {
36
+ const root = mkdtempSync(join(tmpdir(), 'studio-client-tree-configured-'))
37
+ roots.push(root)
38
+ const clientDir = join(root, 'frontend')
39
+ const src = join(clientDir, 'src')
40
+ mkdirSync(src, { recursive: true })
41
+ writeFileSync(join(src, 'views.tsx'), `const VIEWS = { '/ui/models': ModelsView }`)
42
+
43
+ expect(buildClientTree(root, clientDir)).toMatchObject({
44
+ present: true,
45
+ routes: { '/ui/models': 'ModelsView' },
46
+ })
47
+ })
48
+
49
+ test('resolves views declared in the registry file itself', () => {
50
+ const root = mkdtempSync(join(tmpdir(), 'studio-inline-view-registry-'))
51
+ roots.push(root)
52
+ const viewsDir = join(root, 'views')
53
+ mkdirSync(viewsDir, { recursive: true })
54
+ writeFileSync(
55
+ join(viewsDir, 'index.ts'),
56
+ `import { defineView } from '@astrale-os/sdk'
57
+ const services = defineView({
58
+ auth: 'required',
59
+ mount: '/ui/services',
60
+ description: 'Browse services.',
61
+ })
62
+ const service = defineView({
63
+ auth: 'required',
64
+ mount: '/ui/service',
65
+ viewFor: [selfOf(CloudflareWorker)],
66
+ })
67
+ export const views = { services, service }
68
+ `,
69
+ )
70
+
71
+ expect(buildViews(root)).toEqual([
72
+ {
73
+ slug: 'services',
74
+ kind: 'spa',
75
+ url: undefined,
76
+ file: 'views/index.ts',
77
+ auth: 'required',
78
+ mount: '/ui/services',
79
+ description: 'Browse services.',
80
+ },
81
+ {
82
+ slug: 'service',
83
+ kind: 'spa',
84
+ url: undefined,
85
+ file: 'views/index.ts',
86
+ auth: 'required',
87
+ mount: '/ui/service',
88
+ viewFor: 'CloudflareWorker',
89
+ },
90
+ ])
91
+ })
@@ -125,7 +125,7 @@ export function buildViews(root: string): ViewInfo[] {
125
125
  for (const imp of index.getImportDeclarations()) {
126
126
  const spec = imp.getModuleSpecifierValue()
127
127
  for (const named of imp.getNamedImports()) {
128
- importSpecByName.set(named.getName(), spec)
128
+ importSpecByName.set(named.getAliasNode()?.getText() ?? named.getName(), spec)
129
129
  }
130
130
  const def = imp.getDefaultImport()
131
131
  if (def) importSpecByName.set(def.getText(), spec)
@@ -174,15 +174,31 @@ export function buildViews(root: string): ViewInfo[] {
174
174
  info.file = relative(root, indexFile)
175
175
  applyParsed(info, parseViewObject(inlineArg))
176
176
  } else {
177
- // referenced view file (e.g. my-domain) resolve the import + parse the file
178
- const spec = refName ? importSpecByName.get(refName) : undefined
179
- const viewFile = spec ? resolveLocalModule(indexFile, spec) : null
180
- if (viewFile) {
181
- info.file = relative(root, viewFile)
182
- try {
183
- applyParsed(info, parseViewFile(project, viewFile))
184
- } catch {
185
- /* keep the unknown-kind stub */
177
+ // A shorthand may reference a defineView declared earlier in this same
178
+ // registry file (services), or one imported from another module (ai-gateway).
179
+ const localInit = refName
180
+ ? index.getVariableDeclaration(refName)?.getInitializer()
181
+ : undefined
182
+ if (
183
+ localInit &&
184
+ Node.isCallExpression(localInit) &&
185
+ localInit.getExpression().getText() === 'defineView'
186
+ ) {
187
+ const arg = localInit.getArguments()[0]
188
+ if (arg) {
189
+ info.file = relative(root, indexFile)
190
+ applyParsed(info, parseViewObject(arg))
191
+ }
192
+ } else {
193
+ const spec = refName ? importSpecByName.get(refName) : undefined
194
+ const viewFile = spec ? resolveLocalModule(indexFile, spec) : null
195
+ if (viewFile) {
196
+ info.file = relative(root, viewFile)
197
+ try {
198
+ applyParsed(info, parseViewFile(project, viewFile))
199
+ } catch {
200
+ /* keep the unknown-kind stub */
201
+ }
186
202
  }
187
203
  }
188
204
  }
@@ -294,8 +310,11 @@ function parseViewObject(arg: Node): ParsedView {
294
310
 
295
311
  const RESERVED_CLIENT_DIRS = new Set(['shell', 'ui', 'views'])
296
312
 
297
- export function buildClientTree(root: string): ClientTree {
298
- const srcDir = join(root, 'client', 'src')
313
+ export function buildClientTree(
314
+ root: string,
315
+ clientDir: string | null = join(root, 'client'),
316
+ ): ClientTree {
317
+ const srcDir = clientDir ? join(clientDir, 'src') : ''
299
318
  if (!existsSync(srcDir)) {
300
319
  return { shell: [], features: [], routes: {}, present: false }
301
320
  }
@@ -306,50 +325,30 @@ export function buildClientTree(root: string): ClientTree {
306
325
  .filter((d) => !RESERVED_CLIENT_DIRS.has(d))
307
326
  .map((name) => ({ name, files: listFiles(join(srcDir, name)) }))
308
327
 
309
- const routes = parseRoutes(join(srcDir, 'app.tsx'))
328
+ const routes = parseRoutes(
329
+ listFiles(srcDir)
330
+ .filter((file) => /\.[cm]?[jt]sx?$/.test(file))
331
+ .map((file) => join(srcDir, file)),
332
+ )
310
333
 
311
334
  return { shell, features, routes, present: true }
312
335
  }
313
336
 
314
- /** Best-effort parse of the `ROUTES` map (mountPath → ComponentName) in app.tsx. */
315
- function parseRoutes(appFile: string): Record<string, string> {
337
+ /**
338
+ * Best-effort parse of top-level client route registries. Domains call these
339
+ * maps ROUTES, VIEW_REGISTRY, or another local name; the stable contract is a
340
+ * quoted `/ui/…` key pointing at a component identifier.
341
+ */
342
+ function parseRoutes(files: string[]): Record<string, string> {
316
343
  const routes: Record<string, string> = {}
317
- const src = readTextSafe(appFile)
318
- if (!src) return routes
319
-
320
- // Isolate the ROUTES object literal body: `const ROUTES[: Type] = { ... }`.
321
- // The identifier may carry a type annotation but never a newline/backtick — that
322
- // keeps us from latching onto a `ROUTES` mention inside a JSDoc comment.
323
- const startMatch = src.match(/\bROUTES\b\s*(?::\s*[\w<>.\[\], ]+)?\s*=\s*\{/)
324
- if (!startMatch || startMatch.index === undefined) {
325
- return routes
326
- }
327
- const openIdx = src.indexOf('{', startMatch.index + startMatch[0].length - 1)
328
- if (openIdx === -1) return routes
329
-
330
- // Balance braces to find the matching close.
331
- let depth = 0
332
- let endIdx = -1
333
- for (let i = openIdx; i < src.length; i++) {
334
- const ch = src[i]
335
- if (ch === '{') depth++
336
- else if (ch === '}') {
337
- depth--
338
- if (depth === 0) {
339
- endIdx = i
340
- break
341
- }
344
+ for (const file of files) {
345
+ const src = readTextSafe(file)
346
+ const entryRe = /['"`](\/ui\/[^'"`]+)['"`]\s*:\s*([A-Za-z_$][\w$.]*)/g
347
+ let match: RegExpExecArray | null
348
+ while ((match = entryRe.exec(src)) !== null) {
349
+ routes[match[1]] = match[2]
342
350
  }
343
351
  }
344
- if (endIdx === -1) return routes
345
-
346
- const body = src.slice(openIdx + 1, endIdx)
347
- // Each entry: '/ui/status-page': StatusView (quoted key → identifier/expr).
348
- const entryRe = /['"`]([^'"`]+)['"`]\s*:\s*([A-Za-z_$][\w$.]*)/g
349
- let m: RegExpExecArray | null
350
- while ((m = entryRe.exec(body)) !== null) {
351
- routes[m[1]] = m[2]
352
- }
353
352
  return routes
354
353
  }
355
354
 
@@ -8,7 +8,7 @@
8
8
  * never executed (its deps→integrations chain has import side effects).
9
9
  */
10
10
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
11
- import { join } from 'node:path'
11
+ import { join, relative } from 'node:path'
12
12
 
13
13
  import type { DomainAnatomy, DomainOverview } from '../../shared/types'
14
14
 
@@ -18,19 +18,20 @@ import { buildClientTree, buildEnvFields, buildViews } from './anatomy-extras'
18
18
  export interface AnatomyArgs {
19
19
  root: string
20
20
  schemaDirName: string
21
+ clientDir?: string
21
22
  }
22
23
 
23
- export function buildAnatomy({ root, schemaDirName }: AnatomyArgs): DomainAnatomy {
24
+ export function buildAnatomy({ root, schemaDirName, clientDir }: AnatomyArgs): DomainAnatomy {
24
25
  return {
25
- overview: buildOverview(root, schemaDirName),
26
+ overview: buildOverview(root, schemaDirName, clientDir),
26
27
  views: buildViews(root),
27
- client: buildClientTree(root),
28
+ client: buildClientTree(root, clientDir ?? null),
28
29
  env: buildEnvFields(root),
29
30
  detectedIntegrations: detectIntegrations(root),
30
31
  }
31
32
  }
32
33
 
33
- function buildOverview(root: string, schemaDirName: string): DomainOverview {
34
+ function buildOverview(root: string, schemaDirName: string, clientDir?: string): DomainOverview {
34
35
  const pkg = readJsonSafe(join(root, 'package.json'))
35
36
  const astraleDeps: Record<string, string> = {}
36
37
  for (const [k, v] of Object.entries({
@@ -52,7 +53,6 @@ function buildOverview(root: string, schemaDirName: string): DomainOverview {
52
53
  config.match(/secrets\s*:\s*['"]([^'"]+)['"]/)?.[1]
53
54
 
54
55
  const domainSrc = readTextSafe(join(root, 'domain.ts'))
55
- const clientDir = domainSrc.match(/client\s*:\s*\{\s*dir\s*:\s*['"]([^'"]+)['"]/)?.[1]
56
56
  const origin =
57
57
  domainSrc.match(/defineSchema\(\s*['"]([^'"]+)['"]/)?.[1] ??
58
58
  readTextSafe(join(root, schemaDirName, 'index.ts')).match(
@@ -71,7 +71,7 @@ function buildOverview(root: string, schemaDirName: string): DomainOverview {
71
71
  packageVersion: pkg?.version,
72
72
  astraleDeps,
73
73
  schemaDir: schemaDirName,
74
- client: clientDir,
74
+ client: clientDir ? relative(root, clientDir).replaceAll('\\', '/') || '.' : undefined,
75
75
  }
76
76
  }
77
77