@astrale-os/cli 1.0.0-beta.0 → 1.0.0-beta.2
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 +7 -11
- package/dist/astrale.js +3769 -3549
- package/dist/public/connect-core.js +396 -392
- package/dist/public/keys/index.js +371 -378
- package/dist/public/paths/index.js +371 -378
- package/dist/types/lib/idp.d.ts +6 -0
- package/dist/types/lib/meta.d.ts +2 -2
- package/package.json +6 -4
- package/src/admin/binding.ts +168 -0
- package/src/admin/catalog/__tests__/client.test.ts +2 -2
- package/src/admin/catalog/client.ts +4 -9
- package/src/admin/instance/__tests__/client.test.ts +5 -3
- package/src/admin/instance/client.ts +9 -27
- package/src/commands/__tests__/auth-login.test.ts +54 -1
- package/src/commands/__tests__/call.test.ts +5 -1
- package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
- package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
- package/src/commands/__tests__/domain-list.test.ts +83 -4
- package/src/commands/__tests__/install-identity-override.test.ts +30 -19
- package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
- package/src/commands/__tests__/update.test.ts +73 -0
- package/src/commands/domain/install.ts +11 -15
- package/src/commands/domain/list.ts +17 -12
- package/src/commands/studio.ts +20 -0
- package/src/commands/update.ts +39 -13
- package/src/connection/__tests__/errors.test.ts +16 -12
- package/src/connection/__tests__/exchange.test.ts +6 -5
- package/src/connection/exchange.ts +5 -4
- package/src/lib/__tests__/domain-publication.test.ts +144 -0
- package/src/lib/__tests__/idp-session.test.ts +22 -0
- package/src/lib/__tests__/idp.test.ts +2 -0
- package/src/lib/__tests__/instance.test.ts +6 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
- package/src/lib/__tests__/update.test.ts +12 -0
- package/src/lib/domain-publication.ts +102 -0
- package/src/lib/idp.ts +5 -1
- package/src/lib/instance.ts +6 -1
- package/src/lib/login-flow.ts +13 -3
- package/src/lib/meta.ts +2 -2
- package/src/lib/update.ts +4 -4
- package/src/program/__tests__/program.test.ts +1 -1
- package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
- package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
- package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
- package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/package.json +3 -1
- package/studio/server/agent/bridge/stdio.ts +8 -1
- package/studio/server/agent/conversation.test.ts +10 -5
- package/studio/server/agent/conversation.ts +56 -8
- package/studio/server/agent/harness/codex/loadout.ts +18 -21
- package/studio/server/agent/harness/gateway/config.ts +20 -18
- package/studio/server/agent/harness/selection.ts +9 -1
- package/studio/server/agent/prompts/system.ts +3 -2
- package/studio/server/agent/prompts/turn.ts +13 -4
- package/studio/server/agent/run/completion.ts +2 -2
- package/studio/server/agent/run/preparation.ts +6 -5
- package/studio/server/agent/run/transcript.test.ts +35 -1
- package/studio/server/agent/run/transcript.ts +169 -3
- package/studio/server/agent/run/usage.test.ts +24 -0
- package/studio/server/agent/run/usage.ts +26 -1
- package/studio/server/api/canvas.ts +44 -0
- package/studio/server/api/comments.ts +85 -0
- package/studio/server/api/context.ts +61 -0
- package/studio/server/api/deployment.ts +39 -0
- package/studio/server/api/documents.ts +55 -0
- package/studio/server/api/domain.ts +59 -0
- package/studio/server/api/http.ts +45 -0
- package/studio/server/api/project.ts +57 -0
- package/studio/server/api/schema.ts +12 -0
- package/studio/server/api/views.ts +57 -0
- package/studio/server/api/workspace.ts +60 -0
- package/studio/server/api.test.ts +147 -0
- package/studio/server/api.ts +20 -422
- package/studio/server/cache.test.ts +111 -0
- package/studio/server/cache.ts +163 -9
- package/studio/server/cli-consumers.test.ts +494 -0
- package/studio/server/cli.test.ts +88 -0
- package/studio/server/cli.ts +240 -0
- package/studio/server/client-package.ts +25 -1
- package/studio/server/domain.test.ts +30 -2
- package/studio/server/domain.ts +14 -2
- package/studio/server/environment/dotenv-preview.test.ts +39 -0
- package/studio/server/environment/dotenv-preview.ts +27 -0
- package/studio/server/environment/files.test.ts +53 -0
- package/studio/server/{state/env.ts → environment/files.ts} +7 -48
- package/studio/server/{state → handoff}/copy.ts +21 -6
- package/studio/server/handoff/service.test.ts +22 -0
- package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
- package/studio/server/instances/active.ts +72 -0
- package/studio/server/instances/deploy-record.test.ts +31 -0
- package/studio/server/instances/deploy-record.ts +37 -0
- package/studio/server/instances/deploy.ts +56 -0
- package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
- package/studio/server/instances/probe.ts +67 -0
- package/studio/server/instances/status.test.ts +12 -0
- package/studio/server/instances/status.ts +49 -0
- package/studio/server/introspect/anatomy/client-tree.ts +57 -0
- package/studio/server/introspect/anatomy/env-fields.ts +66 -0
- package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
- package/studio/server/introspect/anatomy/source.ts +181 -0
- package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
- package/studio/server/introspect/anatomy/views/routes.ts +105 -0
- package/studio/server/introspect/anatomy/views.ts +58 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -4
- package/studio/server/introspect/anatomy-extras.ts +7 -726
- package/studio/server/introspect/anatomy.ts +46 -19
- package/studio/server/introspect/bundle.ts +15 -6
- package/studio/server/introspect/canonical-schema.test.ts +79 -0
- package/studio/server/introspect/canonical-schema.ts +80 -18
- package/studio/server/introspect/config-preview.test.ts +32 -0
- package/studio/server/introspect/config-preview.ts +119 -0
- package/studio/server/introspect/diff.test.ts +7 -11
- package/studio/server/introspect/diff.ts +28 -37
- package/studio/server/introspect/extractor.ts +8 -6
- package/studio/server/introspect/hash.ts +5 -2
- package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
- package/studio/server/introspect/revision.test.ts +54 -0
- package/studio/server/introspect/revision.ts +49 -0
- package/studio/server/introspect/runtime.test.ts +63 -5
- package/studio/server/introspect/runtime.ts +49 -3
- package/studio/server/introspect/schema-ir-json.ts +181 -0
- package/studio/server/introspect/source-overlay/annotations.ts +14 -0
- package/studio/server/introspect/source-overlay/handlers.ts +681 -0
- package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
- package/studio/server/introspect/source-overlay/project.ts +248 -0
- package/studio/server/introspect/source-overlay/spans.ts +360 -0
- package/studio/server/json.ts +46 -0
- package/studio/server/lifecycle.ts +5 -1
- package/studio/server/sse.test.ts +26 -0
- package/studio/server/sse.ts +4 -1
- package/studio/server/state/baseline.test.ts +212 -1
- package/studio/server/state/baseline.ts +145 -29
- package/studio/server/state/comments.test.ts +66 -1
- package/studio/server/state/comments.ts +173 -10
- package/studio/server/state/context.ts +34 -2
- package/studio/server/state/documents.ts +42 -1
- package/studio/server/state/integrations.ts +27 -1
- package/studio/server/state/layout.test.ts +34 -0
- package/studio/server/state/layout.ts +44 -17
- package/studio/server/state/settings.ts +29 -22
- package/studio/server/state/store.test.ts +122 -0
- package/studio/server/state/store.ts +66 -17
- package/studio/server/state/visibility.ts +22 -7
- package/studio/server/views/model.test.ts +57 -0
- package/studio/server/views/model.ts +66 -0
- package/studio/server/views/runtime.ts +40 -0
- package/studio/server/views/selection-repository.test.ts +31 -0
- package/studio/server/views/selection-repository.ts +60 -0
- package/studio/server/views/session.test.ts +73 -0
- package/studio/server/views/session.ts +166 -0
- package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
- package/studio/server/views/target.ts +248 -0
- package/studio/server/watch.ts +1 -1
- package/studio/server/workspace/catalog.test.ts +17 -0
- package/studio/server/workspace/catalog.ts +43 -0
- package/studio/server/{state → workspace}/create.test.ts +1 -1
- package/studio/server/{state → workspace}/create.ts +1 -1
- package/studio/server/{state → workspace}/git.ts +1 -1
- package/studio/server/workspace/updates.ts +83 -0
- package/studio/server/workspace-state.ts +1 -1
- package/studio/shared/contracts/README.md +16 -0
- package/studio/shared/contracts/agent.ts +270 -0
- package/studio/shared/contracts/runtime.ts +50 -0
- package/studio/shared/contracts/schema.ts +462 -0
- package/studio/shared/contracts/surface.test.ts +319 -0
- package/studio/shared/contracts/workspace.ts +274 -0
- package/studio/shared/layout.test.ts +116 -0
- package/studio/shared/schema/identity.test.ts +58 -0
- package/studio/shared/schema/identity.ts +82 -0
- package/studio/shared/types.ts +11 -1099
- package/studio/tsconfig.json +1 -1
- package/viewer/dist/main.js +35 -35
- package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
- package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
- package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
- package/studio/server/state/catalog.ts +0 -117
- package/studio/server/state/instance.ts +0 -280
- package/studio/server/state/updates.ts +0 -63
- package/studio/server/state/views.ts +0 -535
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { InterfaceDeclaration, Node, Project } from 'ts-morph'
|
|
4
|
+
|
|
5
|
+
import type { EnvField } from '../../../shared/types'
|
|
6
|
+
|
|
7
|
+
import { makeProject } from './source'
|
|
8
|
+
|
|
9
|
+
/** Strip the leading-JSDoc decorations to a single trimmed line of prose. */
|
|
10
|
+
function cleanDoc(raw: string | undefined): string | undefined {
|
|
11
|
+
if (!raw) return undefined
|
|
12
|
+
const text = raw
|
|
13
|
+
.replace(/^\/\*\*?/, '')
|
|
14
|
+
.replace(/\*\/$/, '')
|
|
15
|
+
.split('\n')
|
|
16
|
+
.map((l) => l.replace(/^\s*\*\s?/, '').trimEnd())
|
|
17
|
+
.join('\n')
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
20
|
+
.trim()
|
|
21
|
+
return text.length ? text : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ───────────────────────────── 3) env fields ──────────────────────────────
|
|
25
|
+
|
|
26
|
+
const KNOWN_INFRA_FIELDS = new Set(['WORKER_URL', 'ASSETS', 'SELF', 'VIEW_DEV_URL'])
|
|
27
|
+
|
|
28
|
+
export function buildEnvFields(root: string): EnvField[] {
|
|
29
|
+
const envFile = join(root, 'env.ts')
|
|
30
|
+
if (!existsSync(envFile)) return []
|
|
31
|
+
|
|
32
|
+
let project: Project
|
|
33
|
+
let sf
|
|
34
|
+
try {
|
|
35
|
+
project = makeProject()
|
|
36
|
+
sf = project.addSourceFileAtPath(envFile)
|
|
37
|
+
} catch {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const iface: InterfaceDeclaration | undefined =
|
|
42
|
+
sf.getInterface('Env') ?? sf.getInterfaces().find((i) => i.getName() === 'Env')
|
|
43
|
+
if (!iface) return []
|
|
44
|
+
|
|
45
|
+
const fields: EnvField[] = []
|
|
46
|
+
for (const member of iface.getMembers()) {
|
|
47
|
+
// Skip index signatures: `[key: string]: unknown`.
|
|
48
|
+
if (Node.isIndexSignatureDeclaration(member)) continue
|
|
49
|
+
if (!Node.isPropertySignature(member)) continue
|
|
50
|
+
|
|
51
|
+
const name = member.getName()
|
|
52
|
+
if (!name) continue
|
|
53
|
+
|
|
54
|
+
const optional = member.hasQuestionToken()
|
|
55
|
+
const doc = cleanDoc(member.getJsDocs()[0]?.getInnerText())
|
|
56
|
+
|
|
57
|
+
const isInfra = KNOWN_INFRA_FIELDS.has(name)
|
|
58
|
+
const secret = !isInfra
|
|
59
|
+
|
|
60
|
+
const field: EnvField = { name, optional, secret }
|
|
61
|
+
if (doc) field.doc = doc
|
|
62
|
+
fields.push(field)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return fields
|
|
66
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { Node, type SourceFile, SyntaxKind } from 'ts-morph'
|
|
3
|
+
|
|
4
|
+
import { addSource, listSourceFiles, literalString, makeProject } from './source'
|
|
5
|
+
|
|
6
|
+
// Canonical schema source discovery.
|
|
7
|
+
|
|
8
|
+
export interface SchemaDefinitionLocation {
|
|
9
|
+
origin: string
|
|
10
|
+
file: string
|
|
11
|
+
line: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function schemaProject(root: string, schemaDirName: string): SourceFile[] {
|
|
15
|
+
const project = makeProject()
|
|
16
|
+
return listSourceFiles(join(root, schemaDirName))
|
|
17
|
+
.map((file) => addSource(project, file))
|
|
18
|
+
.filter((source): source is SourceFile => source !== null)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function defineSchemaCalls(source: SourceFile) {
|
|
22
|
+
return source.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call) => {
|
|
23
|
+
const expression = call.getExpression()
|
|
24
|
+
return (
|
|
25
|
+
(Node.isIdentifier(expression) && expression.getText() === 'defineSchema') ||
|
|
26
|
+
(Node.isPropertyAccessExpression(expression) && expression.getName() === 'defineSchema')
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Find the accepted schema without importing it. Current project roots are
|
|
33
|
+
* barrels, so all authored schema modules are inspected and local origin
|
|
34
|
+
* constants such as `defineSchema(ORIGIN, ...)` are resolved.
|
|
35
|
+
*/
|
|
36
|
+
export function findSchemaDefinition(
|
|
37
|
+
root: string,
|
|
38
|
+
schemaDirName = 'schema',
|
|
39
|
+
): SchemaDefinitionLocation | null {
|
|
40
|
+
for (const source of schemaProject(root, schemaDirName)) {
|
|
41
|
+
for (const call of defineSchemaCalls(source)) {
|
|
42
|
+
const origin = literalString(call.getArguments()[0], source)
|
|
43
|
+
if (!origin) continue
|
|
44
|
+
const statement = call.getFirstAncestorByKind(SyntaxKind.VariableStatement)
|
|
45
|
+
return {
|
|
46
|
+
origin,
|
|
47
|
+
file: source.getFilePath(),
|
|
48
|
+
line: statement?.getStartLineNumber() ?? call.getStartLineNumber(),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { Node, Project, type SourceFile } from 'ts-morph'
|
|
4
|
+
|
|
5
|
+
export function readTextSafe(file: string): string {
|
|
6
|
+
try {
|
|
7
|
+
return existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
8
|
+
} catch {
|
|
9
|
+
return ''
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function listFiles(dir: string): string[] {
|
|
14
|
+
try {
|
|
15
|
+
return readdirSync(dir)
|
|
16
|
+
.filter((e) => {
|
|
17
|
+
try {
|
|
18
|
+
return statSync(join(dir, e)).isFile()
|
|
19
|
+
} catch {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
.sort()
|
|
24
|
+
} catch {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function listDirs(dir: string): string[] {
|
|
30
|
+
try {
|
|
31
|
+
return readdirSync(dir)
|
|
32
|
+
.filter((e) => {
|
|
33
|
+
try {
|
|
34
|
+
return statSync(join(dir, e)).isDirectory()
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
.sort()
|
|
40
|
+
} catch {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SOURCE_FILE = /\.[cm]?[jt]sx?$/
|
|
46
|
+
const SKIP_SOURCE_DIRS = new Set(['__tests__', 'node_modules', '.git', '.astrale', '.dist', 'dist'])
|
|
47
|
+
|
|
48
|
+
export function listSourceFiles(dir: string): string[] {
|
|
49
|
+
const files: string[] = []
|
|
50
|
+
const walk = (current: string) => {
|
|
51
|
+
let entries: string[]
|
|
52
|
+
try {
|
|
53
|
+
entries = readdirSync(current).sort()
|
|
54
|
+
} catch {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (SKIP_SOURCE_DIRS.has(entry)) continue
|
|
59
|
+
const file = join(current, entry)
|
|
60
|
+
let stat
|
|
61
|
+
try {
|
|
62
|
+
stat = statSync(file)
|
|
63
|
+
} catch {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (stat.isDirectory()) walk(file)
|
|
67
|
+
else if (
|
|
68
|
+
stat.isFile() &&
|
|
69
|
+
SOURCE_FILE.test(entry) &&
|
|
70
|
+
!entry.endsWith('.d.ts') &&
|
|
71
|
+
!/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry)
|
|
72
|
+
) {
|
|
73
|
+
files.push(file)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
walk(dir)
|
|
78
|
+
return files
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A throwaway in-memory ts-morph project (no tsconfig, no type-checking IO). */
|
|
82
|
+
export function makeProject(): Project {
|
|
83
|
+
return new Project({
|
|
84
|
+
useInMemoryFileSystem: false,
|
|
85
|
+
skipAddingFilesFromTsConfig: true,
|
|
86
|
+
skipFileDependencyResolution: true,
|
|
87
|
+
skipLoadingLibFiles: true,
|
|
88
|
+
compilerOptions: { allowJs: true },
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function addSource(project: Project, file: string): SourceFile | null {
|
|
93
|
+
try {
|
|
94
|
+
return project.getSourceFile(file) ?? project.addSourceFileAtPath(file)
|
|
95
|
+
} catch {
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function unwrap(node: Node): Node {
|
|
101
|
+
let current = node
|
|
102
|
+
while (
|
|
103
|
+
Node.isAsExpression(current) ||
|
|
104
|
+
Node.isSatisfiesExpression(current) ||
|
|
105
|
+
Node.isParenthesizedExpression(current) ||
|
|
106
|
+
Node.isTypeAssertion(current) ||
|
|
107
|
+
Node.isNonNullExpression(current)
|
|
108
|
+
) {
|
|
109
|
+
current = current.getExpression()
|
|
110
|
+
}
|
|
111
|
+
return current
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function localValue(
|
|
115
|
+
node: Node | undefined,
|
|
116
|
+
source: SourceFile,
|
|
117
|
+
seen = new Set<string>(),
|
|
118
|
+
): Node | null {
|
|
119
|
+
if (!node) return null
|
|
120
|
+
const value = unwrap(node)
|
|
121
|
+
if (!Node.isIdentifier(value)) return value
|
|
122
|
+
const key = `${source.getFilePath()}:${value.getText()}`
|
|
123
|
+
if (seen.has(key)) return value
|
|
124
|
+
seen.add(key)
|
|
125
|
+
const init = source.getVariableDeclaration(value.getText())?.getInitializer()
|
|
126
|
+
return init ? localValue(init, source, seen) : value
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function objectValue(node: Node | undefined, source: SourceFile): Node | null {
|
|
130
|
+
const value = localValue(node, source)
|
|
131
|
+
return value && Node.isObjectLiteralExpression(value) ? value : null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function objectProperty(object: Node, name: string, source: SourceFile): Node | null {
|
|
135
|
+
if (!Node.isObjectLiteralExpression(object)) return null
|
|
136
|
+
for (const property of object.getProperties()) {
|
|
137
|
+
if (Node.isPropertyAssignment(property) && property.getName() === name) {
|
|
138
|
+
return localValue(property.getInitializer(), source)
|
|
139
|
+
}
|
|
140
|
+
if (Node.isShorthandPropertyAssignment(property) && property.getName() === name) {
|
|
141
|
+
return localValue(property.getNameNode(), source)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function literalString(node: Node | undefined, source: SourceFile): string | undefined {
|
|
148
|
+
const value = localValue(node, source)
|
|
149
|
+
if (!value) return undefined
|
|
150
|
+
if (Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value)) {
|
|
151
|
+
return value.getLiteralValue()
|
|
152
|
+
}
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function callName(node: Node | null): string | undefined {
|
|
157
|
+
if (!node || !Node.isCallExpression(node)) return undefined
|
|
158
|
+
const expression = node.getExpression()
|
|
159
|
+
if (Node.isIdentifier(expression)) return expression.getText()
|
|
160
|
+
if (Node.isPropertyAccessExpression(expression)) return expression.getName()
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function propertySlug(property: Node): string | undefined {
|
|
165
|
+
if (!Node.isPropertyAssignment(property) && !Node.isShorthandPropertyAssignment(property)) {
|
|
166
|
+
return undefined
|
|
167
|
+
}
|
|
168
|
+
const name = property.getNameNode()
|
|
169
|
+
if (Node.isStringLiteral(name) || Node.isNoSubstitutionTemplateLiteral(name)) {
|
|
170
|
+
return name.getLiteralValue()
|
|
171
|
+
}
|
|
172
|
+
return property.getName()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function propertyValue(property: Node, source: SourceFile): Node | null {
|
|
176
|
+
if (Node.isPropertyAssignment(property)) return localValue(property.getInitializer(), source)
|
|
177
|
+
if (Node.isShorthandPropertyAssignment(property)) {
|
|
178
|
+
return localValue(property.getNameNode(), source)
|
|
179
|
+
}
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative } from 'node:path'
|
|
3
|
+
import { Node, Project, SyntaxKind } from 'ts-morph'
|
|
4
|
+
|
|
5
|
+
import type { ViewInfo } from '../../../../shared/types'
|
|
6
|
+
|
|
7
|
+
import { makeProject } from '../source'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the relative `./welcome` / `../foo` import spec used in views/index.ts
|
|
11
|
+
* to an on-disk `.ts` file under the views dir. Returns absolute path or null.
|
|
12
|
+
*/
|
|
13
|
+
function resolveLocalModule(fromFile: string, spec: string): string | null {
|
|
14
|
+
if (!spec.startsWith('.')) return null
|
|
15
|
+
const base = join(dirname(fromFile), spec)
|
|
16
|
+
const candidates = [
|
|
17
|
+
base,
|
|
18
|
+
`${base}.ts`,
|
|
19
|
+
`${base}.tsx`,
|
|
20
|
+
join(base, 'index.ts'),
|
|
21
|
+
join(base, 'index.tsx'),
|
|
22
|
+
]
|
|
23
|
+
for (const c of candidates) {
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync(c) && statSync(c).isFile()) return c
|
|
26
|
+
} catch {
|
|
27
|
+
/* ignore */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildLegacyViews(root: string): ViewInfo[] {
|
|
34
|
+
const indexFile = join(root, 'views', 'index.ts')
|
|
35
|
+
if (!existsSync(indexFile)) return []
|
|
36
|
+
|
|
37
|
+
let project: Project
|
|
38
|
+
let index
|
|
39
|
+
try {
|
|
40
|
+
project = makeProject()
|
|
41
|
+
index = project.addSourceFileAtPath(indexFile)
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Map identifier name → imported module spec (e.g. `welcome` → './welcome').
|
|
47
|
+
const importSpecByName = new Map<string, string>()
|
|
48
|
+
for (const imp of index.getImportDeclarations()) {
|
|
49
|
+
const spec = imp.getModuleSpecifierValue()
|
|
50
|
+
for (const named of imp.getNamedImports()) {
|
|
51
|
+
importSpecByName.set(named.getAliasNode()?.getText() ?? named.getName(), spec)
|
|
52
|
+
}
|
|
53
|
+
const def = imp.getDefaultImport()
|
|
54
|
+
if (def) importSpecByName.set(def.getText(), spec)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Find the exported `views` object literal: { welcome, 'ui-status-page': statusPage }.
|
|
58
|
+
const viewsDecl =
|
|
59
|
+
index.getVariableDeclaration('views') ??
|
|
60
|
+
index.getVariableDeclarations().find((d) => d.getName() === 'views')
|
|
61
|
+
const init = viewsDecl?.getInitializer()
|
|
62
|
+
if (!init || !Node.isObjectLiteralExpression(init)) return []
|
|
63
|
+
|
|
64
|
+
const views: ViewInfo[] = []
|
|
65
|
+
for (const prop of init.getProperties()) {
|
|
66
|
+
let slug: string | undefined
|
|
67
|
+
let refName: string | undefined
|
|
68
|
+
let inlineArg: Node | undefined // `slug: defineView({...})` declared right here (e.g. mcac)
|
|
69
|
+
|
|
70
|
+
if (Node.isShorthandPropertyAssignment(prop)) {
|
|
71
|
+
// `welcome,` → slug `welcome`, ref `welcome` (imported view file)
|
|
72
|
+
slug = prop.getName()
|
|
73
|
+
refName = prop.getName()
|
|
74
|
+
} else if (Node.isPropertyAssignment(prop)) {
|
|
75
|
+
const nameNode = prop.getNameNode()
|
|
76
|
+
slug = Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : prop.getName()
|
|
77
|
+
const valueInit = prop.getInitializer()
|
|
78
|
+
if (
|
|
79
|
+
valueInit &&
|
|
80
|
+
Node.isCallExpression(valueInit) &&
|
|
81
|
+
valueInit.getExpression().getText() === 'defineView'
|
|
82
|
+
) {
|
|
83
|
+
// `'ui-move': defineView({...})` — parsed in place
|
|
84
|
+
inlineArg = valueInit.getArguments()[0]
|
|
85
|
+
} else if (valueInit && Node.isIdentifier(valueInit)) {
|
|
86
|
+
// `'ui-status-page': statusPage` — resolved from an imported file
|
|
87
|
+
refName = valueInit.getText()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!slug) continue
|
|
92
|
+
|
|
93
|
+
const info: ViewInfo = { slug, kind: 'unknown', url: undefined }
|
|
94
|
+
|
|
95
|
+
if (inlineArg) {
|
|
96
|
+
// inline defineView (e.g. mcac) — the declaration lives in views/index.ts itself
|
|
97
|
+
info.file = relative(root, indexFile)
|
|
98
|
+
applyParsed(info, parseViewObject(inlineArg))
|
|
99
|
+
} else {
|
|
100
|
+
// A shorthand may reference a defineView declared earlier in this same
|
|
101
|
+
// registry file (services), or one imported from another module (ai-gateway).
|
|
102
|
+
const localInit = refName
|
|
103
|
+
? index.getVariableDeclaration(refName)?.getInitializer()
|
|
104
|
+
: undefined
|
|
105
|
+
if (
|
|
106
|
+
localInit &&
|
|
107
|
+
Node.isCallExpression(localInit) &&
|
|
108
|
+
localInit.getExpression().getText() === 'defineView'
|
|
109
|
+
) {
|
|
110
|
+
const arg = localInit.getArguments()[0]
|
|
111
|
+
if (arg) {
|
|
112
|
+
info.file = relative(root, indexFile)
|
|
113
|
+
applyParsed(info, parseViewObject(arg))
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
const spec = refName ? importSpecByName.get(refName) : undefined
|
|
117
|
+
const viewFile = spec ? resolveLocalModule(indexFile, spec) : null
|
|
118
|
+
if (viewFile) {
|
|
119
|
+
info.file = relative(root, viewFile)
|
|
120
|
+
try {
|
|
121
|
+
applyParsed(info, parseViewFile(project, viewFile))
|
|
122
|
+
} catch {
|
|
123
|
+
/* keep the unknown-kind stub */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
views.push(info)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return views
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface ParsedView {
|
|
136
|
+
kind: ViewInfo['kind']
|
|
137
|
+
auth?: string
|
|
138
|
+
mount?: string
|
|
139
|
+
viewFor?: string | string[]
|
|
140
|
+
description?: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Copy a ParsedView's set fields onto a ViewInfo. */
|
|
144
|
+
function applyParsed(info: ViewInfo, parsed: ParsedView): void {
|
|
145
|
+
info.kind = parsed.kind
|
|
146
|
+
if (parsed.auth !== undefined) info.auth = parsed.auth
|
|
147
|
+
if (parsed.mount !== undefined) info.mount = parsed.mount
|
|
148
|
+
if (parsed.viewFor !== undefined) info.viewFor = parsed.viewFor
|
|
149
|
+
if (parsed.description !== undefined) info.description = parsed.description
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Parse a referenced view module: find its defineView({...}) call + parse the literal. */
|
|
153
|
+
function parseViewFile(project: Project, file: string): ParsedView {
|
|
154
|
+
const sf = project.addSourceFileAtPathIfExists(file) ?? project.addSourceFileAtPath(file)
|
|
155
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
156
|
+
if (call.getExpression().getText() === 'defineView') {
|
|
157
|
+
const arg = call.getArguments()[0]
|
|
158
|
+
if (arg) return parseViewObject(arg)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { kind: 'unknown' }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Parse a `defineView({...})` object-literal argument into a ParsedView. */
|
|
165
|
+
function parseViewObject(arg: Node): ParsedView {
|
|
166
|
+
const result: ParsedView = { kind: 'unknown' }
|
|
167
|
+
if (!Node.isObjectLiteralExpression(arg)) return result
|
|
168
|
+
|
|
169
|
+
let hasRender = false
|
|
170
|
+
let hasInlineHtml = false
|
|
171
|
+
let hasMount = false
|
|
172
|
+
|
|
173
|
+
for (const prop of arg.getProperties()) {
|
|
174
|
+
if (
|
|
175
|
+
!Node.isPropertyAssignment(prop) &&
|
|
176
|
+
!Node.isMethodDeclaration(prop) &&
|
|
177
|
+
!Node.isShorthandPropertyAssignment(prop)
|
|
178
|
+
) {
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
const key = prop.getName?.()
|
|
182
|
+
if (!key) continue
|
|
183
|
+
|
|
184
|
+
if (key === 'auth') {
|
|
185
|
+
if (Node.isPropertyAssignment(prop)) {
|
|
186
|
+
const v = prop.getInitializer()
|
|
187
|
+
if (v && Node.isStringLiteral(v)) result.auth = v.getLiteralValue()
|
|
188
|
+
else if (v) result.auth = v.getText().replace(/^['"`]|['"`]$/g, '')
|
|
189
|
+
}
|
|
190
|
+
} else if (key === 'mount') {
|
|
191
|
+
hasMount = true
|
|
192
|
+
if (Node.isPropertyAssignment(prop)) {
|
|
193
|
+
const v = prop.getInitializer()
|
|
194
|
+
if (v && Node.isStringLiteral(v)) result.mount = v.getLiteralValue()
|
|
195
|
+
else if (v) result.mount = v.getText().replace(/^['"`]|['"`]$/g, '')
|
|
196
|
+
}
|
|
197
|
+
} else if (key === 'render') {
|
|
198
|
+
hasRender = true
|
|
199
|
+
// Inline HTML if the render body references c.html(...) or returns an HTML string.
|
|
200
|
+
const text = prop.getText()
|
|
201
|
+
if (/\bc\s*\.\s*html\s*\(/.test(text) || /<!doctype html>|<html\b/i.test(text)) {
|
|
202
|
+
hasInlineHtml = true
|
|
203
|
+
}
|
|
204
|
+
} else if (key === 'viewFor') {
|
|
205
|
+
if (Node.isPropertyAssignment(prop)) {
|
|
206
|
+
const v = prop.getInitializer()
|
|
207
|
+
if (v) {
|
|
208
|
+
// selfOf(A) → 'A' ; [selfOf(A), selfOf(B)] → ['A','B'] ; else best-effort identifier.
|
|
209
|
+
const names = [...v.getText().matchAll(/selfOf\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g)].map(
|
|
210
|
+
(m) => m[1],
|
|
211
|
+
)
|
|
212
|
+
if (names.length > 1) result.viewFor = names
|
|
213
|
+
else if (names.length === 1) result.viewFor = names[0]
|
|
214
|
+
else if (Node.isIdentifier(v)) result.viewFor = v.getText()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} else if (key === 'description') {
|
|
218
|
+
if (Node.isPropertyAssignment(prop)) {
|
|
219
|
+
const v = prop.getInitializer()
|
|
220
|
+
if (v && Node.isStringLiteral(v)) result.description = v.getLiteralValue()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (hasRender && (hasInlineHtml || !hasMount)) result.kind = 'inline-html'
|
|
226
|
+
else if (hasMount) result.kind = 'spa'
|
|
227
|
+
else result.kind = 'unknown'
|
|
228
|
+
|
|
229
|
+
return result
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ───────────────────────────── 2) client tree ─────────────────────────────
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
import { Node, type SourceFile, SyntaxKind } from 'ts-morph'
|
|
4
|
+
|
|
5
|
+
import type { ViewInfo } from '../../../../shared/types'
|
|
6
|
+
|
|
7
|
+
import { defineSchemaCalls, schemaProject } from '../schema-definition'
|
|
8
|
+
import {
|
|
9
|
+
addSource,
|
|
10
|
+
callName,
|
|
11
|
+
listSourceFiles,
|
|
12
|
+
literalString,
|
|
13
|
+
makeProject,
|
|
14
|
+
objectProperty,
|
|
15
|
+
objectValue,
|
|
16
|
+
propertySlug,
|
|
17
|
+
propertyValue,
|
|
18
|
+
} from '../source'
|
|
19
|
+
|
|
20
|
+
/** Locate authored declarations for canonical View keys without projecting any semantics. */
|
|
21
|
+
export function buildSchemaViewSources(root: string, schemaDirName: string): Map<string, string> {
|
|
22
|
+
const sources = new Map<string, string>()
|
|
23
|
+
for (const source of schemaProject(root, schemaDirName)) {
|
|
24
|
+
for (const call of defineSchemaCalls(source)) {
|
|
25
|
+
const input = objectValue(call.getArguments()[1], source)
|
|
26
|
+
const declared = input
|
|
27
|
+
? objectValue(objectProperty(input, 'views', source) ?? undefined, source)
|
|
28
|
+
: null
|
|
29
|
+
if (!declared || !Node.isObjectLiteralExpression(declared)) continue
|
|
30
|
+
for (const property of declared.getProperties()) {
|
|
31
|
+
const slug = propertySlug(property)
|
|
32
|
+
const declaration = propertyValue(property, source)
|
|
33
|
+
if (
|
|
34
|
+
slug &&
|
|
35
|
+
declaration &&
|
|
36
|
+
Node.isCallExpression(declaration) &&
|
|
37
|
+
callName(declaration) === 'view'
|
|
38
|
+
) {
|
|
39
|
+
sources.set(slug, relative(root, source.getFilePath()).replaceAll('\\', '/'))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return sources
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function frontendKind(
|
|
48
|
+
frontendName: string,
|
|
49
|
+
input: Node,
|
|
50
|
+
source: SourceFile,
|
|
51
|
+
): { kind: ViewInfo['kind']; external: boolean } {
|
|
52
|
+
if (frontendName === 'reactFrontend') return { kind: 'spa', external: false }
|
|
53
|
+
const sourceName = callName(objectProperty(input, 'source', source))
|
|
54
|
+
if (sourceName === 'generatedFrontend') return { kind: 'inline-html', external: false }
|
|
55
|
+
if (sourceName === 'viteFrontend' || sourceName === 'prebuiltFrontend') {
|
|
56
|
+
return { kind: 'spa', external: false }
|
|
57
|
+
}
|
|
58
|
+
if (sourceName === 'externalFrontend') return { kind: 'spa', external: true }
|
|
59
|
+
return { kind: 'unknown', external: false }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildFrontendViews(root: string): ViewInfo[] {
|
|
63
|
+
const project = makeProject()
|
|
64
|
+
const compositionFiles = ['implementation.ts', 'domain.ts']
|
|
65
|
+
.map((file) => join(root, file))
|
|
66
|
+
.filter((file) => existsSync(file))
|
|
67
|
+
const sources = [...new Set([...listSourceFiles(join(root, 'views')), ...compositionFiles])]
|
|
68
|
+
.map((file) => addSource(project, file))
|
|
69
|
+
.filter((source): source is SourceFile => source !== null)
|
|
70
|
+
const views: ViewInfo[] = []
|
|
71
|
+
|
|
72
|
+
for (const source of sources) {
|
|
73
|
+
for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
74
|
+
const frontendName = callName(call)
|
|
75
|
+
if (frontendName !== 'frontendArtifact' && frontendName !== 'reactFrontend') continue
|
|
76
|
+
const input = objectValue(call.getArguments()[0], source)
|
|
77
|
+
if (!input) continue
|
|
78
|
+
const routes = objectValue(objectProperty(input, 'routes', source) ?? undefined, source)
|
|
79
|
+
if (!routes || !Node.isObjectLiteralExpression(routes)) continue
|
|
80
|
+
const artifact = frontendKind(frontendName, input, source)
|
|
81
|
+
|
|
82
|
+
for (const property of routes.getProperties()) {
|
|
83
|
+
const slug = propertySlug(property)
|
|
84
|
+
const routeValue = propertyValue(property, source)
|
|
85
|
+
if (!slug || !routeValue) continue
|
|
86
|
+
const routeName = callName(routeValue)
|
|
87
|
+
const routeInput = Node.isCallExpression(routeValue)
|
|
88
|
+
? objectValue(routeValue.getArguments()[0], source)
|
|
89
|
+
: objectValue(routeValue, source)
|
|
90
|
+
if (!routeInput) continue
|
|
91
|
+
|
|
92
|
+
const path = literalString(objectProperty(routeInput, 'path', source) ?? undefined, source)
|
|
93
|
+
const href = literalString(objectProperty(routeInput, 'href', source) ?? undefined, source)
|
|
94
|
+
views.push({
|
|
95
|
+
slug,
|
|
96
|
+
kind: routeName === 'reactRoute' ? 'spa' : artifact.kind,
|
|
97
|
+
...(href ? { url: href } : { url: undefined }),
|
|
98
|
+
...(!href && (path || !artifact.external) ? { mount: path ?? `/ui/${slug}` } : {}),
|
|
99
|
+
file: relative(root, source.getFilePath()).replaceAll('\\', '/'),
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return views
|
|
105
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { SchemaIR, ViewInfo } from '../../../shared/types'
|
|
2
|
+
|
|
3
|
+
import { buildLegacyViews } from './views/legacy'
|
|
4
|
+
import { buildFrontendViews, buildSchemaViewSources } from './views/routes'
|
|
5
|
+
|
|
6
|
+
function mergeRoute(target: ViewInfo, incoming: ViewInfo): void {
|
|
7
|
+
if (incoming.kind !== 'unknown') target.kind = incoming.kind
|
|
8
|
+
if (incoming.mount !== undefined) target.mount = incoming.mount
|
|
9
|
+
if (incoming.url !== undefined) target.url = incoming.url
|
|
10
|
+
if (incoming.file) target.file = incoming.file
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function canonicalViewInfo(slug: string, view: NonNullable<SchemaIR['views']>[string]): ViewInfo {
|
|
14
|
+
const targets =
|
|
15
|
+
view.target.kind === 'definition' ? view.target.definitions.map((ref) => ref.name) : []
|
|
16
|
+
return {
|
|
17
|
+
slug,
|
|
18
|
+
kind: 'unknown',
|
|
19
|
+
auth: view.auth,
|
|
20
|
+
...(view.description ? { description: view.description } : {}),
|
|
21
|
+
...(targets.length === 1
|
|
22
|
+
? { viewFor: targets[0] }
|
|
23
|
+
: targets.length > 1
|
|
24
|
+
? { viewFor: targets }
|
|
25
|
+
: {}),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Join canonical View definitions with route/source metadata. Passing a
|
|
31
|
+
* canonical map (including an empty map) makes it authoritative: static routes
|
|
32
|
+
* cannot invent Views or override identity, target, auth, or description.
|
|
33
|
+
* Without it, the legacy defineView registry remains the compatibility source.
|
|
34
|
+
*/
|
|
35
|
+
export function buildViews(
|
|
36
|
+
root: string,
|
|
37
|
+
_schemaDirName = 'schema',
|
|
38
|
+
canonicalViews?: NonNullable<SchemaIR['views']>,
|
|
39
|
+
): ViewInfo[] {
|
|
40
|
+
const merged = new Map<string, ViewInfo>()
|
|
41
|
+
if (canonicalViews !== undefined) {
|
|
42
|
+
for (const [slug, view] of Object.entries(canonicalViews)) {
|
|
43
|
+
merged.set(slug, canonicalViewInfo(slug, view))
|
|
44
|
+
}
|
|
45
|
+
for (const [slug, file] of buildSchemaViewSources(root, _schemaDirName)) {
|
|
46
|
+
const current = merged.get(slug)
|
|
47
|
+
if (current) current.file = file
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
for (const view of buildLegacyViews(root)) merged.set(view.slug, view)
|
|
51
|
+
}
|
|
52
|
+
for (const view of buildFrontendViews(root)) {
|
|
53
|
+
const current = merged.get(view.slug)
|
|
54
|
+
if (current) mergeRoute(current, view)
|
|
55
|
+
else if (canonicalViews === undefined) merged.set(view.slug, view)
|
|
56
|
+
}
|
|
57
|
+
return [...merged.values()]
|
|
58
|
+
}
|