@astrale-os/cli 0.8.1-alpha.7 → 1.0.0-beta.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/README.md +15 -3
- package/dist/astrale.js +86 -35
- package/package.json +5 -5
- package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
- package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
- package/src/commands/__tests__/install-direct.test.ts +3 -2
- package/src/commands/domain/install.ts +78 -15
- package/src/connection/__tests__/errors.test.ts +82 -6
- package/src/connection/command.ts +9 -1
- package/src/connection/errors.ts +19 -8
- package/src/connection/index.ts +1 -1
- package/src/connection/reasons.ts +26 -12
- package/src/program/__tests__/program.test.ts +1 -1
- package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
- package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
- package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
- package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/package.json +1 -1
- package/studio/server/agent/prompts/anchors.test.ts +74 -0
- package/studio/server/agent/prompts/anchors.ts +85 -15
- package/studio/server/agent/prompts/system.test.ts +12 -0
- package/studio/server/agent/prompts/system.ts +6 -6
- package/studio/server/api.ts +1 -5
- package/studio/server/cache.ts +5 -2
- package/studio/server/domain.test.ts +67 -0
- package/studio/server/domain.ts +29 -6
- package/studio/server/index.ts +1 -3
- package/studio/server/introspect/anatomy-extras.test.ts +104 -1
- package/studio/server/introspect/anatomy-extras.ts +340 -8
- package/studio/server/introspect/anatomy.test.ts +33 -0
- package/studio/server/introspect/anatomy.ts +22 -7
- package/studio/server/introspect/bundle.ts +7 -1
- package/studio/server/introspect/canonical-schema.test.ts +395 -0
- package/studio/server/introspect/canonical-schema.ts +751 -0
- package/studio/server/introspect/core-extractor.ts +30 -10
- package/studio/server/introspect/core.ts +4 -2
- package/studio/server/introspect/diff.test.ts +124 -0
- package/studio/server/introspect/diff.ts +252 -23
- package/studio/server/introspect/extractor.ts +46 -14
- package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
- package/studio/server/introspect/overlay-tsmorph.ts +381 -106
- package/studio/server/introspect/overlay.test.ts +72 -0
- package/studio/server/introspect/overlay.ts +16 -6
- package/studio/server/introspect/runtime.test.ts +217 -0
- package/studio/server/introspect/runtime.ts +18 -6
- package/studio/server/introspect/schema-refs.test.ts +100 -0
- package/studio/server/introspect/schema-refs.ts +23 -2
- package/studio/server/state/baseline.test.ts +51 -0
- package/studio/server/state/baseline.ts +31 -2
- package/studio/server/state/create.test.ts +37 -0
- package/studio/server/state/create.ts +21 -15
- package/studio/server/state/instance.test.ts +63 -0
- package/studio/server/state/instance.ts +65 -29
- package/studio/server/state/views.test.ts +209 -9
- package/studio/server/state/views.ts +176 -69
- package/studio/server/watch.test.ts +36 -0
- package/studio/server/watch.ts +24 -16
- package/studio/server/workspace-watch.ts +8 -3
- package/studio/shared/types.ts +164 -33
- package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
- package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
- package/studio/server/view-dev-server.test.ts +0 -111
- package/studio/server/view-dev-server.ts +0 -372
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* anatomy-extras.ts — views / functions / client tree / env fields extraction.
|
|
3
3
|
*
|
|
4
|
-
* Pure static parsing only — domain TS is NEVER executed. Views
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Pure static parsing only — domain TS is NEVER executed. Current Views are
|
|
5
|
+
* joined from schema `view(...)` declarations and frontend route artifacts;
|
|
6
|
+
* legacy `defineView` registries remain supported. The client tree is a shallow
|
|
7
|
+
* readdir + best-effort ROUTES parse; env fields come from a ts-morph pass over
|
|
8
|
+
* the exported `Env` interface. Every entry point is defensive: missing
|
|
9
|
+
* files/dirs yield safe empties, never throws.
|
|
9
10
|
*/
|
|
10
11
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
11
12
|
import { dirname, join, relative } from 'node:path'
|
|
12
|
-
import { InterfaceDeclaration, Node, Project, SyntaxKind } from 'ts-morph'
|
|
13
|
+
import { InterfaceDeclaration, Node, Project, type SourceFile, SyntaxKind } from 'ts-morph'
|
|
13
14
|
|
|
14
15
|
import type { ClientTree, EnvField, ViewInfo } from '../../shared/types'
|
|
15
16
|
|
|
@@ -55,6 +56,42 @@ function listDirs(dir: string): string[] {
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
const SOURCE_FILE = /\.[cm]?[jt]sx?$/
|
|
60
|
+
const SKIP_SOURCE_DIRS = new Set(['__tests__', 'node_modules', '.git', '.astrale', '.dist', 'dist'])
|
|
61
|
+
|
|
62
|
+
function listSourceFiles(dir: string): string[] {
|
|
63
|
+
const files: string[] = []
|
|
64
|
+
const walk = (current: string) => {
|
|
65
|
+
let entries: string[]
|
|
66
|
+
try {
|
|
67
|
+
entries = readdirSync(current).sort()
|
|
68
|
+
} catch {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (SKIP_SOURCE_DIRS.has(entry)) continue
|
|
73
|
+
const file = join(current, entry)
|
|
74
|
+
let stat
|
|
75
|
+
try {
|
|
76
|
+
stat = statSync(file)
|
|
77
|
+
} catch {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (stat.isDirectory()) walk(file)
|
|
81
|
+
else if (
|
|
82
|
+
stat.isFile() &&
|
|
83
|
+
SOURCE_FILE.test(entry) &&
|
|
84
|
+
!entry.endsWith('.d.ts') &&
|
|
85
|
+
!/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry)
|
|
86
|
+
) {
|
|
87
|
+
files.push(file)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
walk(dir)
|
|
92
|
+
return files
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
/** A throwaway in-memory ts-morph project (no tsconfig, no type-checking IO). */
|
|
59
96
|
function makeProject(): Project {
|
|
60
97
|
return new Project({
|
|
@@ -66,6 +103,97 @@ function makeProject(): Project {
|
|
|
66
103
|
})
|
|
67
104
|
}
|
|
68
105
|
|
|
106
|
+
function addSource(project: Project, file: string): SourceFile | null {
|
|
107
|
+
try {
|
|
108
|
+
return project.getSourceFile(file) ?? project.addSourceFileAtPath(file)
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function unwrap(node: Node): Node {
|
|
115
|
+
let current = node
|
|
116
|
+
while (
|
|
117
|
+
Node.isAsExpression(current) ||
|
|
118
|
+
Node.isSatisfiesExpression(current) ||
|
|
119
|
+
Node.isParenthesizedExpression(current) ||
|
|
120
|
+
Node.isTypeAssertion(current) ||
|
|
121
|
+
Node.isNonNullExpression(current)
|
|
122
|
+
) {
|
|
123
|
+
current = current.getExpression()
|
|
124
|
+
}
|
|
125
|
+
return current
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function localValue(
|
|
129
|
+
node: Node | undefined,
|
|
130
|
+
source: SourceFile,
|
|
131
|
+
seen = new Set<string>(),
|
|
132
|
+
): Node | null {
|
|
133
|
+
if (!node) return null
|
|
134
|
+
const value = unwrap(node)
|
|
135
|
+
if (!Node.isIdentifier(value)) return value
|
|
136
|
+
const key = `${source.getFilePath()}:${value.getText()}`
|
|
137
|
+
if (seen.has(key)) return value
|
|
138
|
+
seen.add(key)
|
|
139
|
+
const init = source.getVariableDeclaration(value.getText())?.getInitializer()
|
|
140
|
+
return init ? localValue(init, source, seen) : value
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function objectValue(node: Node | undefined, source: SourceFile): Node | null {
|
|
144
|
+
const value = localValue(node, source)
|
|
145
|
+
return value && Node.isObjectLiteralExpression(value) ? value : null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function objectProperty(object: Node, name: string, source: SourceFile): Node | null {
|
|
149
|
+
if (!Node.isObjectLiteralExpression(object)) return null
|
|
150
|
+
for (const property of object.getProperties()) {
|
|
151
|
+
if (Node.isPropertyAssignment(property) && property.getName() === name) {
|
|
152
|
+
return localValue(property.getInitializer(), source)
|
|
153
|
+
}
|
|
154
|
+
if (Node.isShorthandPropertyAssignment(property) && property.getName() === name) {
|
|
155
|
+
return localValue(property.getNameNode(), source)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function literalString(node: Node | undefined, source: SourceFile): string | undefined {
|
|
162
|
+
const value = localValue(node, source)
|
|
163
|
+
if (!value) return undefined
|
|
164
|
+
if (Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value)) {
|
|
165
|
+
return value.getLiteralValue()
|
|
166
|
+
}
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function callName(node: Node | null): string | undefined {
|
|
171
|
+
if (!node || !Node.isCallExpression(node)) return undefined
|
|
172
|
+
const expression = node.getExpression()
|
|
173
|
+
if (Node.isIdentifier(expression)) return expression.getText()
|
|
174
|
+
if (Node.isPropertyAccessExpression(expression)) return expression.getName()
|
|
175
|
+
return undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function propertySlug(property: Node): string | undefined {
|
|
179
|
+
if (!Node.isPropertyAssignment(property) && !Node.isShorthandPropertyAssignment(property)) {
|
|
180
|
+
return undefined
|
|
181
|
+
}
|
|
182
|
+
const name = property.getNameNode()
|
|
183
|
+
if (Node.isStringLiteral(name) || Node.isNoSubstitutionTemplateLiteral(name)) {
|
|
184
|
+
return name.getLiteralValue()
|
|
185
|
+
}
|
|
186
|
+
return property.getName()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function propertyValue(property: Node, source: SourceFile): Node | null {
|
|
190
|
+
if (Node.isPropertyAssignment(property)) return localValue(property.getInitializer(), source)
|
|
191
|
+
if (Node.isShorthandPropertyAssignment(property)) {
|
|
192
|
+
return localValue(property.getNameNode(), source)
|
|
193
|
+
}
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
69
197
|
/** Strip the leading-JSDoc decorations to a single trimmed line of prose. */
|
|
70
198
|
function cleanDoc(raw: string | undefined): string | undefined {
|
|
71
199
|
if (!raw) return undefined
|
|
@@ -83,6 +211,53 @@ function cleanDoc(raw: string | undefined): string | undefined {
|
|
|
83
211
|
|
|
84
212
|
// ──────────────────────────────── 1) views ────────────────────────────────
|
|
85
213
|
|
|
214
|
+
export interface SchemaDefinitionLocation {
|
|
215
|
+
origin: string
|
|
216
|
+
file: string
|
|
217
|
+
line: number
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function schemaProject(root: string, schemaDirName: string): SourceFile[] {
|
|
221
|
+
const project = makeProject()
|
|
222
|
+
return listSourceFiles(join(root, schemaDirName))
|
|
223
|
+
.map((file) => addSource(project, file))
|
|
224
|
+
.filter((source): source is SourceFile => source !== null)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function defineSchemaCalls(source: SourceFile) {
|
|
228
|
+
return source.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call) => {
|
|
229
|
+
const expression = call.getExpression()
|
|
230
|
+
return (
|
|
231
|
+
(Node.isIdentifier(expression) && expression.getText() === 'defineSchema') ||
|
|
232
|
+
(Node.isPropertyAccessExpression(expression) && expression.getName() === 'defineSchema')
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Find the accepted schema without importing it. Current project roots are
|
|
239
|
+
* barrels, so all authored schema modules are inspected and local origin
|
|
240
|
+
* constants such as `defineSchema(ORIGIN, ...)` are resolved.
|
|
241
|
+
*/
|
|
242
|
+
export function findSchemaDefinition(
|
|
243
|
+
root: string,
|
|
244
|
+
schemaDirName = 'schema',
|
|
245
|
+
): SchemaDefinitionLocation | null {
|
|
246
|
+
for (const source of schemaProject(root, schemaDirName)) {
|
|
247
|
+
for (const call of defineSchemaCalls(source)) {
|
|
248
|
+
const origin = literalString(call.getArguments()[0], source)
|
|
249
|
+
if (!origin) continue
|
|
250
|
+
const statement = call.getFirstAncestorByKind(SyntaxKind.VariableStatement)
|
|
251
|
+
return {
|
|
252
|
+
origin,
|
|
253
|
+
file: source.getFilePath(),
|
|
254
|
+
line: statement?.getStartLineNumber() ?? call.getStartLineNumber(),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
|
|
86
261
|
/**
|
|
87
262
|
* Resolve the relative `./welcome` / `../foo` import spec used in views/index.ts
|
|
88
263
|
* to an on-disk `.ts` file under the views dir. Returns absolute path or null.
|
|
@@ -107,7 +282,7 @@ function resolveLocalModule(fromFile: string, spec: string): string | null {
|
|
|
107
282
|
return null
|
|
108
283
|
}
|
|
109
284
|
|
|
110
|
-
|
|
285
|
+
function buildLegacyViews(root: string): ViewInfo[] {
|
|
111
286
|
const indexFile = join(root, 'views', 'index.ts')
|
|
112
287
|
if (!existsSync(indexFile)) return []
|
|
113
288
|
|
|
@@ -308,13 +483,170 @@ function parseViewObject(arg: Node): ParsedView {
|
|
|
308
483
|
|
|
309
484
|
// ───────────────────────────── 2) client tree ─────────────────────────────
|
|
310
485
|
|
|
486
|
+
// Current SDK View declarations and frontend routes complete the View section.
|
|
487
|
+
function modernTargetNames(node: Node | null, source: SourceFile): string[] {
|
|
488
|
+
if (!node) return []
|
|
489
|
+
const value = localValue(node, source)
|
|
490
|
+
if (!value) return []
|
|
491
|
+
if (Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value)) return []
|
|
492
|
+
if (Node.isArrayLiteralExpression(value)) {
|
|
493
|
+
return value.getElements().flatMap((entry) => modernTargetNames(entry, source))
|
|
494
|
+
}
|
|
495
|
+
if (Node.isIdentifier(value)) return [value.getText()]
|
|
496
|
+
if (Node.isPropertyAccessExpression(value)) return [value.getText()]
|
|
497
|
+
if (Node.isCallExpression(value) && callName(value) === 'selfOf') {
|
|
498
|
+
return value.getArguments().flatMap((entry) => modernTargetNames(entry, source))
|
|
499
|
+
}
|
|
500
|
+
return []
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function buildSchemaViews(root: string, schemaDirName: string): ViewInfo[] {
|
|
504
|
+
const views: ViewInfo[] = []
|
|
505
|
+
for (const source of schemaProject(root, schemaDirName)) {
|
|
506
|
+
for (const call of defineSchemaCalls(source)) {
|
|
507
|
+
const input = objectValue(call.getArguments()[1], source)
|
|
508
|
+
const declared = input
|
|
509
|
+
? objectValue(objectProperty(input, 'views', source) ?? undefined, source)
|
|
510
|
+
: null
|
|
511
|
+
if (!declared || !Node.isObjectLiteralExpression(declared)) continue
|
|
512
|
+
|
|
513
|
+
for (const property of declared.getProperties()) {
|
|
514
|
+
const slug = propertySlug(property)
|
|
515
|
+
const declaration = propertyValue(property, source)
|
|
516
|
+
if (
|
|
517
|
+
!slug ||
|
|
518
|
+
!declaration ||
|
|
519
|
+
callName(declaration) !== 'view' ||
|
|
520
|
+
!Node.isCallExpression(declaration)
|
|
521
|
+
) {
|
|
522
|
+
continue
|
|
523
|
+
}
|
|
524
|
+
const config = objectValue(declaration.getArguments()[0], source)
|
|
525
|
+
const info: ViewInfo = {
|
|
526
|
+
slug,
|
|
527
|
+
kind: 'unknown',
|
|
528
|
+
auth: config
|
|
529
|
+
? (literalString(objectProperty(config, 'auth', source) ?? undefined, source) ??
|
|
530
|
+
'required')
|
|
531
|
+
: 'required',
|
|
532
|
+
url: undefined,
|
|
533
|
+
file: relative(root, source.getFilePath()).replaceAll('\\', '/'),
|
|
534
|
+
}
|
|
535
|
+
if (config) {
|
|
536
|
+
const description = literalString(
|
|
537
|
+
objectProperty(config, 'description', source) ?? undefined,
|
|
538
|
+
source,
|
|
539
|
+
)
|
|
540
|
+
const targets = modernTargetNames(objectProperty(config, 'target', source), source)
|
|
541
|
+
if (description) info.description = description
|
|
542
|
+
if (targets.length === 1) info.viewFor = targets[0]
|
|
543
|
+
else if (targets.length > 1) info.viewFor = targets
|
|
544
|
+
}
|
|
545
|
+
views.push(info)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return views
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function frontendKind(
|
|
553
|
+
frontendName: string,
|
|
554
|
+
input: Node,
|
|
555
|
+
source: SourceFile,
|
|
556
|
+
): { kind: ViewInfo['kind']; external: boolean } {
|
|
557
|
+
if (frontendName === 'reactFrontend') return { kind: 'spa', external: false }
|
|
558
|
+
const sourceName = callName(objectProperty(input, 'source', source))
|
|
559
|
+
if (sourceName === 'generatedFrontend') return { kind: 'inline-html', external: false }
|
|
560
|
+
if (sourceName === 'viteFrontend' || sourceName === 'prebuiltFrontend') {
|
|
561
|
+
return { kind: 'spa', external: false }
|
|
562
|
+
}
|
|
563
|
+
if (sourceName === 'externalFrontend') return { kind: 'spa', external: true }
|
|
564
|
+
return { kind: 'unknown', external: false }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function buildFrontendViews(root: string): ViewInfo[] {
|
|
568
|
+
const project = makeProject()
|
|
569
|
+
const sources = listSourceFiles(join(root, 'views'))
|
|
570
|
+
.map((file) => addSource(project, file))
|
|
571
|
+
.filter((source): source is SourceFile => source !== null)
|
|
572
|
+
const views: ViewInfo[] = []
|
|
573
|
+
|
|
574
|
+
for (const source of sources) {
|
|
575
|
+
for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
576
|
+
const frontendName = callName(call)
|
|
577
|
+
if (frontendName !== 'frontendArtifact' && frontendName !== 'reactFrontend') continue
|
|
578
|
+
const input = objectValue(call.getArguments()[0], source)
|
|
579
|
+
if (!input) continue
|
|
580
|
+
const routes = objectValue(objectProperty(input, 'routes', source) ?? undefined, source)
|
|
581
|
+
if (!routes || !Node.isObjectLiteralExpression(routes)) continue
|
|
582
|
+
const artifact = frontendKind(frontendName, input, source)
|
|
583
|
+
|
|
584
|
+
for (const property of routes.getProperties()) {
|
|
585
|
+
const slug = propertySlug(property)
|
|
586
|
+
const routeValue = propertyValue(property, source)
|
|
587
|
+
if (!slug || !routeValue) continue
|
|
588
|
+
const routeName = callName(routeValue)
|
|
589
|
+
const routeInput = Node.isCallExpression(routeValue)
|
|
590
|
+
? objectValue(routeValue.getArguments()[0], source)
|
|
591
|
+
: objectValue(routeValue, source)
|
|
592
|
+
if (!routeInput) continue
|
|
593
|
+
|
|
594
|
+
const path = literalString(objectProperty(routeInput, 'path', source) ?? undefined, source)
|
|
595
|
+
const href = literalString(objectProperty(routeInput, 'href', source) ?? undefined, source)
|
|
596
|
+
views.push({
|
|
597
|
+
slug,
|
|
598
|
+
kind: routeName === 'reactRoute' ? 'spa' : artifact.kind,
|
|
599
|
+
...(href ? { url: href } : { url: undefined }),
|
|
600
|
+
...(!href && (path || !artifact.external) ? { mount: path ?? `/ui/${slug}` } : {}),
|
|
601
|
+
file: relative(root, source.getFilePath()).replaceAll('\\', '/'),
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return views
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function mergeView(target: ViewInfo, incoming: ViewInfo, route: boolean): void {
|
|
610
|
+
if (incoming.kind !== 'unknown') target.kind = incoming.kind
|
|
611
|
+
if (incoming.auth !== undefined) target.auth = incoming.auth
|
|
612
|
+
if (incoming.mount !== undefined) target.mount = incoming.mount
|
|
613
|
+
if (incoming.url !== undefined) target.url = incoming.url
|
|
614
|
+
if (incoming.viewFor !== undefined) target.viewFor = incoming.viewFor
|
|
615
|
+
if (incoming.description !== undefined) target.description = incoming.description
|
|
616
|
+
if (incoming.file && (route || !target.file)) target.file = incoming.file
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Join legacy registries with current schema declarations and frontend routes. */
|
|
620
|
+
export function buildViews(root: string, schemaDirName = 'schema'): ViewInfo[] {
|
|
621
|
+
const merged = new Map<string, ViewInfo>()
|
|
622
|
+
for (const view of buildLegacyViews(root)) merged.set(view.slug, view)
|
|
623
|
+
for (const view of buildSchemaViews(root, schemaDirName)) {
|
|
624
|
+
const current = merged.get(view.slug)
|
|
625
|
+
if (current) mergeView(current, view, false)
|
|
626
|
+
else merged.set(view.slug, view)
|
|
627
|
+
}
|
|
628
|
+
for (const view of buildFrontendViews(root)) {
|
|
629
|
+
const current = merged.get(view.slug)
|
|
630
|
+
if (current) mergeView(current, view, true)
|
|
631
|
+
else merged.set(view.slug, view)
|
|
632
|
+
}
|
|
633
|
+
return [...merged.values()]
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Client tree parsing starts here after the current View join above.
|
|
311
637
|
const RESERVED_CLIENT_DIRS = new Set(['shell', 'ui', 'views'])
|
|
312
638
|
|
|
313
639
|
export function buildClientTree(
|
|
314
640
|
root: string,
|
|
315
641
|
clientDir: string | null = join(root, 'client'),
|
|
316
642
|
): ClientTree {
|
|
317
|
-
|
|
643
|
+
// Current SDK projects own presentation modules directly under ui/. Legacy
|
|
644
|
+
// projects still point at a client package whose authored tree is client/src.
|
|
645
|
+
const srcDir = clientDir
|
|
646
|
+
? join(clientDir, 'src')
|
|
647
|
+
: existsSync(join(root, 'ui'))
|
|
648
|
+
? join(root, 'ui')
|
|
649
|
+
: ''
|
|
318
650
|
if (!existsSync(srcDir)) {
|
|
319
651
|
return { shell: [], features: [], routes: {}, present: false }
|
|
320
652
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 { buildAnatomy } from './anatomy'
|
|
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
|
+
function project(entry: 'implementation.ts' | 'domain.ts') {
|
|
15
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-anatomy-entry-'))
|
|
16
|
+
roots.push(root)
|
|
17
|
+
mkdirSync(join(root, 'schema'), { recursive: true })
|
|
18
|
+
writeFileSync(join(root, 'package.json'), '{}\n')
|
|
19
|
+
writeFileSync(join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
20
|
+
writeFileSync(join(root, entry), 'export const domain = {}\n')
|
|
21
|
+
writeFileSync(join(root, 'schema/index.ts'), "defineSchema('example.astrale.ai', {})\n")
|
|
22
|
+
return root
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('overview anchors current projects to implementation.ts', () => {
|
|
26
|
+
const anatomy = buildAnatomy({ root: project('implementation.ts'), schemaDirName: 'schema' })
|
|
27
|
+
expect(anatomy.overview.compositionFile).toBe('implementation.ts')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('overview preserves domain.ts as the legacy composition entry', () => {
|
|
31
|
+
const anatomy = buildAnatomy({ root: project('domain.ts'), schemaDirName: 'schema' })
|
|
32
|
+
expect(anatomy.overview.compositionFile).toBe('domain.ts')
|
|
33
|
+
})
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* (dir names only — a hint, never a parse).
|
|
5
5
|
*
|
|
6
6
|
* Overview is implemented here; views/client/env are filled by the
|
|
7
|
-
* introspection swarm in anatomy-extras.ts.
|
|
8
|
-
* never executed (its
|
|
7
|
+
* introspection swarm in anatomy-extras.ts. The composition entry is statically
|
|
8
|
+
* parsed, never executed (its dependency graph may have import side effects).
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
11
11
|
import { join, relative } from 'node:path'
|
|
@@ -13,7 +13,7 @@ import { join, relative } from 'node:path'
|
|
|
13
13
|
import type { DomainAnatomy, DomainOverview } from '../../shared/types'
|
|
14
14
|
|
|
15
15
|
import { readSettings } from '../state/settings'
|
|
16
|
-
import { buildClientTree, buildEnvFields, buildViews } from './anatomy-extras'
|
|
16
|
+
import { buildClientTree, buildEnvFields, buildViews, findSchemaDefinition } from './anatomy-extras'
|
|
17
17
|
|
|
18
18
|
export interface AnatomyArgs {
|
|
19
19
|
root: string
|
|
@@ -22,16 +22,24 @@ export interface AnatomyArgs {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function buildAnatomy({ root, schemaDirName, clientDir }: AnatomyArgs): DomainAnatomy {
|
|
25
|
+
const schema = findSchemaDefinition(root, schemaDirName)
|
|
26
|
+
const authoredClientDir =
|
|
27
|
+
clientDir ?? (existsSync(join(root, 'ui')) ? join(root, 'ui') : undefined)
|
|
25
28
|
return {
|
|
26
|
-
overview: buildOverview(root, schemaDirName,
|
|
27
|
-
views: buildViews(root),
|
|
29
|
+
overview: buildOverview(root, schemaDirName, authoredClientDir, schema?.origin),
|
|
30
|
+
views: buildViews(root, schemaDirName),
|
|
28
31
|
client: buildClientTree(root, clientDir ?? null),
|
|
29
32
|
env: buildEnvFields(root),
|
|
30
33
|
detectedIntegrations: detectIntegrations(root),
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
function buildOverview(
|
|
37
|
+
function buildOverview(
|
|
38
|
+
root: string,
|
|
39
|
+
schemaDirName: string,
|
|
40
|
+
clientDir?: string,
|
|
41
|
+
schemaOrigin?: string,
|
|
42
|
+
): DomainOverview {
|
|
35
43
|
const pkg = readJsonSafe(join(root, 'package.json'))
|
|
36
44
|
const astraleDeps: Record<string, string> = {}
|
|
37
45
|
for (const [k, v] of Object.entries({
|
|
@@ -52,8 +60,14 @@ function buildOverview(root: string, schemaDirName: string, clientDir?: string):
|
|
|
52
60
|
config.match(/dev\s*:\s*\{[^}]*secrets\s*:\s*['"]([^'"]+)['"]/)?.[1] ??
|
|
53
61
|
config.match(/secrets\s*:\s*['"]([^'"]+)['"]/)?.[1]
|
|
54
62
|
|
|
55
|
-
const
|
|
63
|
+
const compositionFile = existsSync(join(root, 'implementation.ts'))
|
|
64
|
+
? 'implementation.ts'
|
|
65
|
+
: existsSync(join(root, 'domain.ts'))
|
|
66
|
+
? 'domain.ts'
|
|
67
|
+
: undefined
|
|
68
|
+
const domainSrc = compositionFile ? readTextSafe(join(root, compositionFile)) : ''
|
|
56
69
|
const origin =
|
|
70
|
+
schemaOrigin ??
|
|
57
71
|
domainSrc.match(/defineSchema\(\s*['"]([^'"]+)['"]/)?.[1] ??
|
|
58
72
|
readTextSafe(join(root, schemaDirName, 'index.ts')).match(
|
|
59
73
|
/defineSchema\(\s*['"]([^'"]+)['"]/,
|
|
@@ -62,6 +76,7 @@ function buildOverview(root: string, schemaDirName: string, clientDir?: string):
|
|
|
62
76
|
|
|
63
77
|
return {
|
|
64
78
|
origin,
|
|
79
|
+
compositionFile,
|
|
65
80
|
adapter,
|
|
66
81
|
prodTarget: instance ? `instance: ${instance}` : route ? `route: ${route}` : undefined,
|
|
67
82
|
devSecrets,
|
|
@@ -15,6 +15,7 @@ import { runtimeExtract } from './runtime'
|
|
|
15
15
|
export async function buildBundle(handle: DomainHandle): Promise<StudioSchemaBundle> {
|
|
16
16
|
const installed = depsInstalled(handle.root)
|
|
17
17
|
let ir = null
|
|
18
|
+
let schemaRoot: unknown | undefined
|
|
18
19
|
let importedInterfaces: StudioSchemaBundle['importedInterfaces']
|
|
19
20
|
let error: StudioSchemaBundle['error'] = null
|
|
20
21
|
let extractedBy: StudioSchemaBundle['extractedBy'] = 'runtime-bun'
|
|
@@ -27,6 +28,7 @@ export async function buildBundle(handle: DomainHandle): Promise<StudioSchemaBun
|
|
|
27
28
|
)
|
|
28
29
|
if (r.ok) {
|
|
29
30
|
ir = r.ir
|
|
31
|
+
if (r.root !== null) schemaRoot = r.root
|
|
30
32
|
importedInterfaces = r.importedInterfaces
|
|
31
33
|
} else {
|
|
32
34
|
error = { message: r.error?.message ?? 'schema failed to compile' }
|
|
@@ -45,10 +47,14 @@ export async function buildBundle(handle: DomainHandle): Promise<StudioSchemaBun
|
|
|
45
47
|
|
|
46
48
|
return {
|
|
47
49
|
domainId: handle.id,
|
|
48
|
-
|
|
50
|
+
// The Kernel installs the canonical DomainSchema root, not the Studio's
|
|
51
|
+
// lossy render projection. Legacy domains have no root and retain IR hashing.
|
|
52
|
+
schemaHash:
|
|
53
|
+
schemaRoot !== undefined ? schemaHashOf(schemaRoot) : ir ? schemaHashOf(ir) : 'sha-none',
|
|
49
54
|
extractedBy,
|
|
50
55
|
depsInstalled: installed,
|
|
51
56
|
ir,
|
|
57
|
+
...(schemaRoot === undefined ? {} : { schemaRoot }),
|
|
52
58
|
overlay,
|
|
53
59
|
importedInterfaces,
|
|
54
60
|
error,
|