@astrale-os/cli 0.4.0-alpha.13 → 0.6.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +27 -0
- package/dist/astrale.js +24396 -9632
- package/package.json +24 -22
- package/src/command.ts +2 -0
- package/src/commands/__tests__/admin-instance.test.ts +3 -2
- package/src/commands/__tests__/domain-list.test.ts +6 -2
- package/src/commands/__tests__/help-contract.test.ts +27 -14
- package/src/commands/__tests__/install-identity-override.test.ts +2 -2
- package/src/commands/__tests__/ls.test.ts +1 -1
- package/src/commands/__tests__/read-commands.test.ts +201 -0
- package/src/commands/__tests__/view.test.ts +100 -0
- package/src/commands/call.ts +27 -44
- package/src/commands/describe.ts +57 -58
- package/src/commands/domain/install.ts +9 -9
- package/src/commands/domain/list.ts +2 -2
- package/src/commands/domain/publish.ts +3 -3
- package/src/commands/get.ts +48 -23
- package/src/commands/identity/register.ts +27 -33
- package/src/commands/instance/active.ts +2 -2
- package/src/commands/instance/create.ts +1 -1
- package/src/commands/instance/delete.ts +3 -3
- package/src/commands/instance/list.ts +8 -3
- package/src/commands/instance/status.ts +4 -3
- package/src/commands/instance/use.ts +2 -2
- package/src/commands/logs.ts +8 -8
- package/src/commands/ls.ts +77 -55
- package/src/commands/mutate.ts +191 -0
- package/src/commands/query.ts +307 -20
- package/src/commands/session/analyze.ts +50 -0
- package/src/commands/session/list.ts +36 -0
- package/src/commands/token.ts +4 -7
- package/src/commands/view-serve.ts +26 -0
- package/src/commands/view.ts +614 -0
- package/src/connect-core.test.ts +42 -0
- package/src/connect-core.ts +53 -0
- package/src/kernel/__tests__/auth.test.ts +1 -0
- package/src/kernel/__tests__/expand.test.ts +123 -0
- package/src/kernel/client.ts +33 -37
- package/src/kernel/expand.ts +55 -61
- package/src/kernel/graph.ts +96 -0
- package/src/kernel/index.ts +18 -3
- package/src/kernel/options.ts +1 -1
- package/src/kernel/run.ts +0 -13
- package/src/lib/__tests__/instance-target.test.ts +34 -0
- package/src/lib/__tests__/table.test.ts +1 -1
- package/src/lib/__tests__/view-open-intent.test.ts +308 -0
- package/src/lib/__tests__/view-snapshot.test.ts +77 -0
- package/src/lib/admin-domain.ts +8 -4
- package/src/lib/admin-instance.ts +5 -1
- package/src/lib/config.ts +1 -0
- package/src/lib/domain-identity.ts +1 -1
- package/src/lib/instance-target.ts +5 -0
- package/src/lib/instance.ts +11 -0
- package/src/lib/log.ts +12 -2
- package/src/lib/login-flow.ts +41 -4
- package/src/lib/provision-instance.ts +2 -2
- package/src/lib/self.ts +1 -3
- package/src/lib/view/open-intent.ts +97 -0
- package/src/lib/view/resolve.ts +104 -0
- package/src/lib/view/server.ts +294 -0
- package/src/lib/view/session.ts +123 -0
- package/src/lib/view/snapshot.ts +101 -0
- package/src/program.ts +16 -3
- package/src/registry.ts +1 -1
- package/src/setup/render.ts +1 -3
- package/src/setup/steps/instance.ts +2 -2
- package/src/telemetry/__tests__/gate.test.ts +63 -0
- package/src/telemetry/__tests__/recorder.test.ts +110 -0
- package/src/telemetry/__tests__/redact.test.ts +79 -0
- package/src/telemetry/__tests__/session.test.ts +166 -0
- package/src/telemetry/__tests__/trigger.test.ts +49 -0
- package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
- package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
- package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
- package/src/telemetry/adapters/claude-code.ts +90 -0
- package/src/telemetry/adapters/codex.ts +160 -0
- package/src/telemetry/adapters/index.ts +45 -0
- package/src/telemetry/adapters/types.ts +21 -0
- package/src/telemetry/analyze.ts +227 -0
- package/src/telemetry/gate.ts +79 -0
- package/src/telemetry/recorder.ts +57 -0
- package/src/telemetry/redact.ts +53 -0
- package/src/telemetry/session.ts +88 -0
- package/src/telemetry/settings.ts +26 -0
- package/src/telemetry/store.ts +91 -0
- package/src/telemetry/trigger.ts +119 -0
- package/src/telemetry/types.ts +64 -0
- package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
- package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.ts +7 -2
- package/studio/server/agent/claude.ts +15 -3
- package/studio/server/agent/runner.ts +4 -1
- package/studio/server/agent/session-id.ts +13 -0
- package/studio/server/api.ts +50 -32
- package/studio/server/cache.ts +17 -6
- package/studio/server/client-package.test.ts +147 -0
- package/studio/server/client-package.ts +242 -0
- package/studio/server/index.ts +13 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -0
- package/studio/server/introspect/anatomy-extras.ts +48 -49
- package/studio/server/introspect/anatomy.ts +7 -7
- package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
- package/studio/server/introspect/overlay-tsmorph.ts +230 -72
- package/studio/server/state/harness-gateway.ts +12 -3
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/views.test.ts +90 -0
- package/studio/server/state/views.ts +396 -99
- package/studio/server/state/visibility.ts +10 -6
- package/studio/server/view-dev-server.test.ts +111 -0
- package/studio/server/view-dev-server.ts +372 -0
- package/studio/shared/types.ts +57 -10
- package/viewer/dist/index.html +93 -0
- package/viewer/dist/main.js +71 -0
- package/src/kernel/__tests__/remote-routing.test.ts +0 -70
- package/src/kernel/remote-routing.ts +0 -88
- package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
- package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
- package/studio/tsconfig.json +0 -23
|
@@ -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
|
+
}
|
package/studio/server/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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(
|
|
298
|
-
|
|
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(
|
|
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
|
-
/**
|
|
315
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|