@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.
Files changed (64) hide show
  1. package/README.md +15 -3
  2. package/dist/astrale.js +86 -35
  3. package/package.json +5 -5
  4. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  5. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  6. package/src/commands/__tests__/install-direct.test.ts +3 -2
  7. package/src/commands/domain/install.ts +78 -15
  8. package/src/connection/__tests__/errors.test.ts +82 -6
  9. package/src/connection/command.ts +9 -1
  10. package/src/connection/errors.ts +19 -8
  11. package/src/connection/index.ts +1 -1
  12. package/src/connection/reasons.ts +26 -12
  13. package/src/program/__tests__/program.test.ts +1 -1
  14. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  15. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  16. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  17. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  18. package/studio/client/dist/index.html +2 -2
  19. package/studio/package.json +1 -1
  20. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  21. package/studio/server/agent/prompts/anchors.ts +85 -15
  22. package/studio/server/agent/prompts/system.test.ts +12 -0
  23. package/studio/server/agent/prompts/system.ts +6 -6
  24. package/studio/server/api.ts +1 -5
  25. package/studio/server/cache.ts +5 -2
  26. package/studio/server/domain.test.ts +67 -0
  27. package/studio/server/domain.ts +29 -6
  28. package/studio/server/index.ts +1 -3
  29. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  30. package/studio/server/introspect/anatomy-extras.ts +340 -8
  31. package/studio/server/introspect/anatomy.test.ts +33 -0
  32. package/studio/server/introspect/anatomy.ts +22 -7
  33. package/studio/server/introspect/bundle.ts +7 -1
  34. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  35. package/studio/server/introspect/canonical-schema.ts +751 -0
  36. package/studio/server/introspect/core-extractor.ts +30 -10
  37. package/studio/server/introspect/core.ts +4 -2
  38. package/studio/server/introspect/diff.test.ts +124 -0
  39. package/studio/server/introspect/diff.ts +252 -23
  40. package/studio/server/introspect/extractor.ts +46 -14
  41. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  42. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  43. package/studio/server/introspect/overlay.test.ts +72 -0
  44. package/studio/server/introspect/overlay.ts +16 -6
  45. package/studio/server/introspect/runtime.test.ts +217 -0
  46. package/studio/server/introspect/runtime.ts +18 -6
  47. package/studio/server/introspect/schema-refs.test.ts +100 -0
  48. package/studio/server/introspect/schema-refs.ts +23 -2
  49. package/studio/server/state/baseline.test.ts +51 -0
  50. package/studio/server/state/baseline.ts +31 -2
  51. package/studio/server/state/create.test.ts +37 -0
  52. package/studio/server/state/create.ts +21 -15
  53. package/studio/server/state/instance.test.ts +63 -0
  54. package/studio/server/state/instance.ts +65 -29
  55. package/studio/server/state/views.test.ts +209 -9
  56. package/studio/server/state/views.ts +176 -69
  57. package/studio/server/watch.test.ts +36 -0
  58. package/studio/server/watch.ts +24 -16
  59. package/studio/server/workspace-watch.ts +8 -3
  60. package/studio/shared/types.ts +164 -33
  61. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  62. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  63. package/studio/server/view-dev-server.test.ts +0 -111
  64. package/studio/server/view-dev-server.ts +0 -372
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Domain Studio</title>
7
- <script type="module" crossorigin src="/assets/index-Dspir4w7.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BQnJ5sgd.css">
7
+ <script type="module" crossorigin src="/assets/index-LGSWRrk8.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BMdnsIJA.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -54,7 +54,7 @@
54
54
  "@types/react": "^19.2.0",
55
55
  "@types/react-dom": "^19.2.0",
56
56
  "@vitejs/plugin-react": "^5.0.0",
57
- "bun-types": "^1.3.14",
57
+ "bun-types": "^1.4.0",
58
58
  "tailwindcss": "^4.0.6",
59
59
  "typescript": "^5.7.3",
60
60
  "vite": "^7.0.0"
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { SchemaIR } from '../../../shared/types'
4
+
5
+ import { describeAnchor } from './anchors'
6
+
7
+ const importedKey = 'directory.example.dev:interface.Named' as const
8
+
9
+ const ir: SchemaIR = {
10
+ format: 'astrale.dsl',
11
+ version: 'v1',
12
+ domain: 'example.dev',
13
+ types: {},
14
+ interfaces: {},
15
+ classes: {},
16
+ imports: {},
17
+ importsByKey: {
18
+ [importedKey]: {
19
+ origin: 'directory.example.dev',
20
+ definition: 'interface',
21
+ key: importedKey,
22
+ ref: { origin: 'directory.example.dev', kind: 'interface', name: 'Named' },
23
+ },
24
+ },
25
+ importedInterfacesByKey: {
26
+ [importedKey]: {
27
+ type: 'interface',
28
+ name: 'Named',
29
+ origin: 'directory.example.dev',
30
+ ref: { origin: 'directory.example.dev', kind: 'interface', name: 'Named' },
31
+ properties: { label: { type: 'string' } },
32
+ required: [],
33
+ methods: {},
34
+ },
35
+ },
36
+ functions: {
37
+ inspect: {
38
+ name: 'inspect',
39
+ input: {
40
+ type: 'object',
41
+ properties: { cursor: { type: 'string' } },
42
+ required: [],
43
+ },
44
+ params: { cursor: { type: 'string' } },
45
+ requiredParams: [],
46
+ output: { mode: 'stream', item: { type: 'integer' } },
47
+ returns: { type: 'integer' },
48
+ static: true,
49
+ inheritance: 'default',
50
+ auth: 'authenticated',
51
+ },
52
+ },
53
+ }
54
+
55
+ describe('canonical anchor descriptions', () => {
56
+ test('describes standalone Functions with exact input, output mode, and auth', () => {
57
+ expect(describeAnchor('function.inspect', ir, undefined)).toContain(
58
+ 'inspect(cursor:string?)→stream<int> [static,authenticated]',
59
+ )
60
+ })
61
+
62
+ test('resolves an imported interface member by its qualified identity', () => {
63
+ expect(
64
+ describeAnchor(
65
+ 'interface.directory.example.dev:interface.Named.property.label',
66
+ ir,
67
+ undefined,
68
+ ),
69
+ ).toBe('**Named.label** : string?')
70
+ expect(
71
+ describeAnchor('interface.directory.example.dev:interface.Named', ir, undefined),
72
+ ).toContain('from directory.example.dev')
73
+ })
74
+ })
@@ -3,13 +3,22 @@
3
3
  * The whole-domain schema map is intentionally not embedded in agent turns; the
4
4
  * agent reads schema/ directly when it needs broader context.
5
5
  */
6
- import type { Comment, IrMethod, JsonSchema, SchemaIR, SchemaOverlay } from '../../../shared/types'
6
+ import type {
7
+ Comment,
8
+ IrDefinitionKey,
9
+ IrFunction,
10
+ IrInterface,
11
+ IrMethod,
12
+ JsonSchema,
13
+ SchemaIR,
14
+ SchemaOverlay,
15
+ } from '../../../shared/types'
7
16
 
8
17
  /** Terse JSON-Schema type label (mirrors the client's format.tsx describe/typeLabel). */
9
- function propType(s: JsonSchema | undefined): string {
18
+ function propType(s: JsonSchema | undefined, optionalOverride?: boolean): string {
10
19
  if (!s) return 'any'
11
20
  const t = s.type
12
- const optional = Array.isArray(t) ? t.includes('null') : false
21
+ const optional = optionalOverride ?? (Array.isArray(t) ? t.includes('null') : false)
13
22
  const base = Array.isArray(t) ? t.find((x) => x !== 'null') : t
14
23
  let label: string
15
24
  if (s.enum) label = `enum(${s.enum.map(String).join('|')})`
@@ -23,14 +32,33 @@ function propType(s: JsonSchema | undefined): string {
23
32
  return label + (optional ? '?' : '')
24
33
  }
25
34
 
26
- function methodSig(name: string, m: IrMethod): string {
35
+ function methodSig(name: string, m: IrMethod | IrFunction): string {
27
36
  const params = Object.entries(m.params ?? {})
28
- .map(([p, s]) => `${p}:${propType(s)}`)
37
+ .map(
38
+ ([p, s]) =>
39
+ `${p}:${propType(s, m.requiredParams ? !m.requiredParams.includes(p) : undefined)}`,
40
+ )
29
41
  .join(', ')
30
- const tags = [m.static ? 'static' : '', m.inheritance === 'abstract' ? 'abstract' : ''].filter(
31
- Boolean,
32
- )
33
- return `${name}(${params})→${propType(m.returns)}${tags.length ? ` [${tags.join(',')}]` : ''}`
42
+ const output =
43
+ m.output?.mode === 'stream'
44
+ ? `stream<${propType(m.output.item)}>`
45
+ : m.output?.mode === 'binary'
46
+ ? 'binary'
47
+ : propType(m.output?.mode === 'value' ? m.output.schema : m.returns)
48
+ const tags = [
49
+ m.static ? 'static' : '',
50
+ m.inheritance === 'abstract' ? 'abstract' : '',
51
+ m.auth ?? '',
52
+ ].filter(Boolean)
53
+ return `${name}(${params})→${output}${tags.length ? ` [${tags.join(',')}]` : ''}`
54
+ }
55
+
56
+ function interfaceByToken(ir: SchemaIR, token: string): IrInterface | undefined {
57
+ const exact = /^(.+):interface\.([A-Za-z_$][\w$]*)$/.exec(token)
58
+ if (!exact) return ir.interfaces?.[token]
59
+ const [, origin, name] = exact
60
+ if (origin === ir.domain) return ir.interfaces?.[name]
61
+ return ir.importedInterfacesByKey?.[`${origin}:interface.${name}` as IrDefinitionKey]
34
62
  }
35
63
 
36
64
  /**
@@ -54,7 +82,7 @@ export function describeAnchor(
54
82
  const [, cls, kind, name] = member
55
83
  const c = ir.classes?.[cls]
56
84
  if (c && kind === 'property' && c.properties?.[name])
57
- return `**${cls}.${name}** : ${propType(c.properties[name])}${loc ? ` (${loc})` : ''}${doc}`
85
+ return `**${cls}.${name}** : ${propType(c.properties[name], c.required ? !c.required.includes(name) : undefined)}${loc ? ` (${loc})` : ''}${doc}`
58
86
  if (c && kind === 'method' && c.methods?.[name])
59
87
  return `**${cls}.${name}** — ${methodSig(name, c.methods[name])}${loc ? ` (${loc})` : ''}${doc}`
60
88
  }
@@ -67,25 +95,67 @@ export function describeAnchor(
67
95
  const L = [`**${c.name}** (${c.type})${loc ? ` (${loc})` : ''}${doc}`]
68
96
  const props = Object.entries(c.properties ?? {})
69
97
  if (props.length)
70
- L.push(` props: ${props.map(([p, s]) => `${p}:${propType(s)}`).join(' · ')}`)
98
+ L.push(
99
+ ` props: ${props
100
+ .map(
101
+ ([p, s]) => `${p}:${propType(s, c.required ? !c.required.includes(p) : undefined)}`,
102
+ )
103
+ .join(' · ')}`,
104
+ )
71
105
  const ms = Object.entries(c.methods ?? {})
72
106
  if (ms.length) L.push(` methods: ${ms.map(([n, m]) => methodSig(n, m)).join(' · ')}`)
73
107
  if (c.type === 'edge' && c.endpoints?.length)
74
- L.push(` endpoints: ${c.endpoints.map((e) => e.types?.join('|') || e.name).join(' → ')}`)
108
+ L.push(
109
+ ` endpoints: ${c.endpoints
110
+ .map(
111
+ (e) =>
112
+ e.refs
113
+ ?.map((target) => `${target.origin}:${target.kind}.${target.name}`)
114
+ .join('|') ||
115
+ e.types?.join('|') ||
116
+ e.name,
117
+ )
118
+ .join(' → ')}`,
119
+ )
75
120
  return L.join('\n')
76
121
  }
77
122
  }
78
123
 
79
- // interface.X
124
+ // interface.X.property.y / interface.<origin>:interface.X.method.m
125
+ const interfaceMember = ref.match(/^interface\.(.+)\.(property|method)\.([^.]+)$/)
126
+ if (interfaceMember && ir) {
127
+ const [, token, kind, name] = interfaceMember
128
+ const iface = interfaceByToken(ir, token)
129
+ if (iface && kind === 'property' && iface.properties?.[name]) {
130
+ return `**${iface.name}.${name}** : ${propType(iface.properties[name], iface.required ? !iface.required.includes(name) : undefined)}${loc ? ` (${loc})` : ''}${doc}`
131
+ }
132
+ if (iface && kind === 'method' && iface.methods?.[name]) {
133
+ return `**${iface.name}.${name}** — ${methodSig(name, iface.methods[name])}${loc ? ` (${loc})` : ''}${doc}`
134
+ }
135
+ }
136
+
137
+ // interface.X or exact interface.<origin>:interface.X
80
138
  const im = ref.match(/^interface\.(.+)$/)
81
139
  if (im && ir) {
82
- const i = ir.interfaces?.[im[1]]
140
+ const i = interfaceByToken(ir, im[1])
83
141
  if (i) {
142
+ const props = Object.entries(i.properties ?? {}).map(
143
+ ([name, schema]) =>
144
+ `${name}:${propType(schema, i.required ? !i.required.includes(name) : undefined)}`,
145
+ )
84
146
  const ms = Object.entries(i.methods ?? {}).map(([n, m]) => methodSig(n, m))
85
- return `**${i.name}** (interface)${loc ? ` (${loc})` : ''}${doc}${ms.length ? `\n methods: ${ms.join(' · ')}` : ''}`
147
+ return `**${i.name}** (interface)${i.origin && i.origin !== ir.domain ? ` from ${i.origin}` : ''}${loc ? ` (${loc})` : ''}${doc}${props.length ? `\n props: ${props.join(' · ')}` : ''}${ms.length ? `\n methods: ${ms.join(' · ')}` : ''}`
86
148
  }
87
149
  }
88
150
 
151
+ // Standalone canonical Function.
152
+ const fm = ref.match(/^function\.([A-Za-z_$][\w$]*)$/)
153
+ if (fm && ir) {
154
+ const fn = ir.functions?.[fm[1]]
155
+ if (fn)
156
+ return `**${fn.name}** (function) — ${methodSig(fn.name, fn)}${loc ? ` (${loc})` : ''}${doc}`
157
+ }
158
+
89
159
  // module / section / file / free — not a specific code element
90
160
  if (loc) return `\`${ref}\` (${loc})${doc}`
91
161
  return ''
@@ -0,0 +1,12 @@
1
+ import { expect, test } from 'bun:test'
2
+
3
+ import { buildSystemPrompt } from './system'
4
+
5
+ test('embedded agents receive the current SDK layout contract', () => {
6
+ const prompt = buildSystemPrompt({ bridge: false })
7
+
8
+ expect(prompt).toContain('implementation.ts')
9
+ expect(prompt).toContain('pre-Kernel-V2 APIs or layouts')
10
+ expect(prompt).toContain('domain.ts/runtime only when this is already a legacy project')
11
+ expect(prompt).not.toContain('schema/ runtime/ views/')
12
+ })
@@ -25,12 +25,12 @@ export function buildSystemPrompt(options: { bridge: boolean }): string {
25
25
  'Operating rules (when a thread genuinely calls for a code change):',
26
26
  '- Read the skills under `.agents/skills/` FIRST and follow them: **astrale-domain**',
27
27
  ' (schema modeling, handlers, views, deploy/install) before editing schema/handlers,',
28
- ' and **astrale-cli** when you run the `astrale` CLI. Honor their conventions (edges',
29
- ' snake_case, compiled key accessors, `::update` drops `z.enum()`, ports/adapters for',
30
- ' external APIs, idempotent postInstall, colon MethodPaths in postInstall).',
31
- '- Prefer editing existing',
32
- ' schema/ runtime/ views/ files over inventing new structure; wire new modules',
33
- ' EXPLICITLY in domain.ts / schema/index.ts (no folder magic).',
28
+ ' and **astrale-cli** when you run the `astrale` CLI. Treat those current skills and',
29
+ ' the project itself as authoritative; do not restore pre-Kernel-V2 APIs or layouts.',
30
+ '- Prefer the project’s existing semantic modules over inventing new structure. Current',
31
+ ' SDK projects compose schema, handlers, and frontend metadata in implementation.ts;',
32
+ ' wire new modules explicitly through their layer facades and that entry. Preserve',
33
+ ' domain.ts/runtime only when this is already a legacy project (no folder magic).',
34
34
  '- The studio re-renders automatically as you save files — never start, build, or refresh',
35
35
  ' anything for the UI to update.',
36
36
  '- Sanity-check schema/handler edits with `pnpm typecheck` (or `tsgo --noEmit`).',
@@ -51,7 +51,6 @@ import { readSettings, updateSettings } from './state/settings'
51
51
  import { applyUpdates, getUpdates } from './state/updates'
52
52
  import { closeViewSession, getViewRuntime, launchViewSession } from './state/views'
53
53
  import { readVisibility, resetVisibility, saveVisibility } from './state/visibility'
54
- import { restartViewDevServer } from './view-dev-server'
55
54
 
56
55
  function json(data: unknown, status = 200): Response {
57
56
  return new Response(JSON.stringify(data), {
@@ -186,10 +185,7 @@ export async function handleApi(req: Request, url: URL, notify: Notify): Promise
186
185
  // ── core (genesis) data ──
187
186
  if (rest === '/core') return json(await getCore(id, url.searchParams.has('fresh')))
188
187
 
189
- // ── views: Studio-owned local Vite lifecycle + `astrale view` auth/session ──
190
- if (rest === '/views/dev-server/restart' && req.method === 'POST') {
191
- return json(await restartViewDevServer(root))
192
- }
188
+ // ── views: CLI-owned resolution, identity, publication, and Shell session ──
193
189
  if (rest === '/views/sessions/close' && req.method === 'POST') {
194
190
  return json(await closeViewSession(String(body.sessionId ?? '')))
195
191
  }
@@ -22,13 +22,15 @@ const anatomies = new Map<string, Promise<DomainAnatomy>>()
22
22
  const cores = new Map<string, StudioCore>()
23
23
 
24
24
  const BUNDLE_CACHE_FILE = '.cache/schema-bundle.json'
25
- const BUNDLE_CACHE_VERSION = 2
25
+ const BUNDLE_CACHE_VERSION = 3
26
26
  const LOCKFILES = ['bun.lock', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']
27
27
  const TOOL_INPUTS = [
28
28
  'cache.ts',
29
+ 'domain.ts',
29
30
  'introspect/bundle.ts',
30
31
  'introspect/runtime.ts',
31
32
  'introspect/extractor.ts',
33
+ 'introspect/canonical-schema.ts',
32
34
  'introspect/overlay.ts',
33
35
  'introspect/overlay-tsmorph.ts',
34
36
  'introspect/hash.ts',
@@ -144,6 +146,7 @@ export function invalidate(id: string, what: 'schema' | 'anatomy' | 'all'): void
144
146
  const domain = getDomain(id)
145
147
  if (domain) invalidateClientPackage(domain.root)
146
148
  }
147
- // Core is derived from domain.ts (in the anatomy fileset), so it tracks anatomy.
149
+ // Core is derived from the canonical schema (or the legacy composition entry),
150
+ // both of which belong to the anatomy invalidation set.
148
151
  if (what !== 'schema') cores.delete(id)
149
152
  }
@@ -0,0 +1,67 @@
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 { basename, join } from 'node:path'
5
+
6
+ import { resolveTarget } from './detect'
7
+ import {
8
+ depsInstalled,
9
+ isDomainDir,
10
+ registerDomain,
11
+ resolveDomainEntry,
12
+ unregisterDomain,
13
+ } from './domain'
14
+
15
+ const roots: string[] = []
16
+ const domainIds: string[] = []
17
+
18
+ afterEach(() => {
19
+ while (domainIds.length) unregisterDomain(domainIds.pop()!)
20
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
21
+ })
22
+
23
+ function fixture(entry: 'implementation.ts' | 'domain.ts'): string {
24
+ const root = mkdtempSync(join(tmpdir(), 'studio-domain-layout-'))
25
+ roots.push(root)
26
+ mkdirSync(join(root, 'schema'), { recursive: true })
27
+ writeFileSync(join(root, 'astrale.config.ts'), 'export default {}\n')
28
+ writeFileSync(join(root, entry), 'export const domain = {}\n')
29
+ writeFileSync(join(root, 'schema/index.ts'), 'export const schema = {}\n')
30
+ return root
31
+ }
32
+
33
+ test('detects the current implementation.ts layout and requires its SDK dependency', () => {
34
+ const root = fixture('implementation.ts')
35
+
36
+ expect(isDomainDir(root)).toBe(true)
37
+ expect(basename(resolveDomainEntry(root)!)).toBe('implementation.ts')
38
+ expect(depsInstalled(root)).toBe(false)
39
+
40
+ mkdirSync(join(root, 'node_modules', '@astrale-os', 'kernel-core'), { recursive: true })
41
+ expect(depsInstalled(root)).toBe(false)
42
+ mkdirSync(join(root, 'node_modules', '@astrale-os', 'sdk'), { recursive: true })
43
+ expect(depsInstalled(root)).toBe(true)
44
+
45
+ const handle = registerDomain(root)!
46
+ domainIds.push(handle.id)
47
+ expect(basename(handle.domainFile)).toBe('implementation.ts')
48
+ expect(resolveTarget(root).map((domain) => domain.root)).toEqual([root])
49
+ })
50
+
51
+ test('preserves domain.ts and kernel-core as the legacy fallback', () => {
52
+ const root = fixture('domain.ts')
53
+ mkdirSync(join(root, 'node_modules', '@astrale-os', 'kernel-core'), { recursive: true })
54
+
55
+ expect(isDomainDir(root)).toBe(true)
56
+ expect(depsInstalled(root)).toBe(true)
57
+ const handle = registerDomain(root)!
58
+ domainIds.push(handle.id)
59
+ expect(basename(handle.domainFile)).toBe('domain.ts')
60
+ })
61
+
62
+ test('prefers implementation.ts when both composition entries exist', () => {
63
+ const root = fixture('domain.ts')
64
+ writeFileSync(join(root, 'implementation.ts'), 'export const domain = {}\n')
65
+
66
+ expect(basename(resolveDomainEntry(root)!)).toBe('implementation.ts')
67
+ })
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * domain.ts — DomainHandle resolution + the in-process registry. A "domain" is
3
- * confirmed by the triple: astrale.config.ts + domain.ts + <schemaDir>/index.ts.
4
- * The schema dir is configurable (default 'schema') and threaded everywhere.
3
+ * confirmed by astrale.config.ts + a composition entry + <schemaDir>/index.ts.
4
+ * Current SDK projects use implementation.ts; domain.ts remains the legacy
5
+ * fallback. The schema dir is configurable (default 'schema') and threaded
6
+ * everywhere.
5
7
  */
6
8
  import { existsSync } from 'node:fs'
7
9
  import { basename, join, resolve } from 'node:path'
@@ -10,6 +12,7 @@ export interface DomainHandle {
10
12
  id: string
11
13
  root: string
12
14
  configFile: string
15
+ /** Active composition entry (implementation.ts when present, otherwise legacy domain.ts). */
13
16
  domainFile: string
14
17
  schemaDirName: string
15
18
  schemaDir: string
@@ -19,16 +22,28 @@ export interface DomainHandle {
19
22
 
20
23
  const registry = new Map<string, DomainHandle>()
21
24
 
25
+ export const DOMAIN_ENTRY_FILES = ['implementation.ts', 'domain.ts'] as const
26
+
22
27
  export function makeId(root: string): string {
23
28
  return basename(resolve(root)).replace(/[^a-zA-Z0-9_-]/g, '-') || 'domain'
24
29
  }
25
30
 
31
+ /** Resolve the current composition entry, preferring the SDK layout. */
32
+ export function resolveDomainEntry(root: string): string | null {
33
+ const r = resolve(root)
34
+ for (const file of DOMAIN_ENTRY_FILES) {
35
+ const candidate = join(r, file)
36
+ if (existsSync(candidate)) return candidate
37
+ }
38
+ return null
39
+ }
40
+
26
41
  /** The single definition of "is this dir an Astrale domain": the triple must all exist. */
27
42
  export function isDomainDir(root: string, schemaDirName = 'schema'): boolean {
28
43
  const r = resolve(root)
29
44
  return (
30
45
  existsSync(join(r, 'astrale.config.ts')) &&
31
- existsSync(join(r, 'domain.ts')) &&
46
+ resolveDomainEntry(r) !== null &&
32
47
  existsSync(join(r, schemaDirName, 'index.ts'))
33
48
  )
34
49
  }
@@ -37,12 +52,14 @@ export function isDomainDir(root: string, schemaDirName = 'schema'): boolean {
37
52
  export function registerDomain(root: string, schemaDirName = 'schema'): DomainHandle | null {
38
53
  const r = resolve(root)
39
54
  if (!isDomainDir(r, schemaDirName)) return null
55
+ const domainFile = resolveDomainEntry(r)
56
+ if (!domainFile) return null
40
57
  const schemaDir = join(r, schemaDirName)
41
58
  const handle: DomainHandle = {
42
59
  id: makeId(r),
43
60
  root: r,
44
61
  configFile: join(r, 'astrale.config.ts'),
45
- domainFile: join(r, 'domain.ts'),
62
+ domainFile,
46
63
  schemaDirName,
47
64
  schemaDir,
48
65
  schemaIndex: join(schemaDir, 'index.ts'),
@@ -64,7 +81,13 @@ export function allDomains(): DomainHandle[] {
64
81
  return [...registry.values()]
65
82
  }
66
83
 
67
- /** Does the domain have @astrale-os deps installed (precondition for runtime introspection)? */
84
+ /** Does the domain have the dependency cohort required by its project layout installed? */
68
85
  export function depsInstalled(root: string): boolean {
69
- return existsSync(join(root, 'node_modules', '@astrale-os', 'kernel-core'))
86
+ const sdk = existsSync(join(root, 'node_modules', '@astrale-os', 'sdk'))
87
+ const entry = resolveDomainEntry(root)
88
+
89
+ // implementation.ts is an SDK boundary by contract. Legacy domain.ts
90
+ // projects may predate the SDK facade and depend on kernel-core directly.
91
+ if (entry?.endsWith('implementation.ts')) return sdk
92
+ return sdk || existsSync(join(root, 'node_modules', '@astrale-os', 'kernel-core'))
70
93
  }
@@ -17,7 +17,6 @@ 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'
21
20
  import { initWorkspaceState, stoppers } from './workspace-state'
22
21
  import { watchWorkspace } from './workspace-watch'
23
22
 
@@ -43,7 +42,7 @@ const domains = resolveTarget(target, schemaDir)
43
42
  if (!domains.length) {
44
43
  console.error(`\n ✗ No Astrale domains found at ${target}`)
45
44
  console.error(
46
- ` (looking for the triple: astrale.config.ts + domain.ts + ${schemaDir}/index.ts)\n`,
45
+ ` (looking for: astrale.config.ts + implementation.ts (or legacy domain.ts) + ${schemaDir}/index.ts)\n`,
47
46
  )
48
47
  process.exit(1)
49
48
  }
@@ -122,7 +121,6 @@ let shuttingDown = false
122
121
  async function shutdown(signal: NodeJS.Signals): Promise<void> {
123
122
  if (shuttingDown) return
124
123
  shuttingDown = true
125
- await shutdownViewDevServers()
126
124
  for (const stop of stoppers.values()) stop()
127
125
  server.stop(true)
128
126
  process.exit(signal === 'SIGINT' ? 130 : 143)
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
 
6
- import { buildClientTree, buildViews } from './anatomy-extras'
6
+ import { buildClientTree, buildViews, findSchemaDefinition } from './anatomy-extras'
7
7
 
8
8
  const roots: string[] = []
9
9
 
@@ -89,3 +89,106 @@ export const views = { services, service }
89
89
  },
90
90
  ])
91
91
  })
92
+
93
+ test('joins current schema Views through a barrel with reactFrontend routes', () => {
94
+ const root = mkdtempSync(join(tmpdir(), 'studio-current-react-view-'))
95
+ roots.push(root)
96
+ mkdirSync(join(root, 'schema', 'application'), { recursive: true })
97
+ mkdirSync(join(root, 'views'), { recursive: true })
98
+ writeFileSync(
99
+ join(root, 'schema', 'index.ts'),
100
+ `export { schema } from './application/index.js'\n`,
101
+ )
102
+ writeFileSync(
103
+ join(root, 'schema', 'application', 'index.ts'),
104
+ `import { defineSchema, view } from '@astrale-os/sdk/schema'
105
+ export const ORIGIN = 'current.example.dev' as const
106
+ const schemaInput = {
107
+ classes: {},
108
+ views: {
109
+ issue: view({
110
+ target: [Issue, Group],
111
+ auth: 'optional',
112
+ description: 'Inspect an issue.',
113
+ }),
114
+ },
115
+ } as const
116
+ export const schema = defineSchema(ORIGIN, schemaInput)
117
+ `,
118
+ )
119
+ writeFileSync(
120
+ join(root, 'views', 'routes.ts'),
121
+ `import { reactFrontend, reactRoute } from '@astrale-os/sdk/react'
122
+ const routes = {
123
+ issue: reactRoute({ path: '/ui/issues/:id', component: { module: './views/issue.tsx' } }),
124
+ }
125
+ export const frontend = reactFrontend({ schema, routes, entrypoint: 'issue' })
126
+ `,
127
+ )
128
+
129
+ expect(findSchemaDefinition(root)).toMatchObject({ origin: 'current.example.dev' })
130
+ expect(buildViews(root)).toEqual([
131
+ {
132
+ slug: 'issue',
133
+ kind: 'spa',
134
+ auth: 'optional',
135
+ mount: '/ui/issues/:id',
136
+ url: undefined,
137
+ viewFor: ['Issue', 'Group'],
138
+ file: 'views/routes.ts',
139
+ description: 'Inspect an issue.',
140
+ },
141
+ ])
142
+ })
143
+
144
+ test('discovers generated frontend routes and applies current View defaults', () => {
145
+ const root = mkdtempSync(join(tmpdir(), 'studio-current-generated-view-'))
146
+ roots.push(root)
147
+ mkdirSync(join(root, 'schema'), { recursive: true })
148
+ mkdirSync(join(root, 'views', 'summary'), { recursive: true })
149
+ writeFileSync(
150
+ join(root, 'schema', 'index.ts'),
151
+ `export const schema = defineSchema('generated.example.dev', {
152
+ views: { summary: view({ target: 'domain' }) },
153
+ })
154
+ `,
155
+ )
156
+ writeFileSync(
157
+ join(root, 'views', 'summary', 'index.ts'),
158
+ `const source = generatedFrontend({ files: [{ path: 'index.html', content: '<h1>Hi</h1>' }] })
159
+ export const frontend = frontendArtifact({
160
+ schema,
161
+ source,
162
+ routes: { summary: frontendRoute({}) },
163
+ entrypoint: 'summary',
164
+ })
165
+ `,
166
+ )
167
+
168
+ expect(buildViews(root)).toEqual([
169
+ {
170
+ slug: 'summary',
171
+ kind: 'inline-html',
172
+ auth: 'required',
173
+ mount: '/ui/summary',
174
+ url: undefined,
175
+ file: 'views/summary/index.ts',
176
+ },
177
+ ])
178
+ })
179
+
180
+ test('uses current ui modules as the client anatomy when no legacy client package is selected', () => {
181
+ const root = mkdtempSync(join(tmpdir(), 'studio-current-ui-tree-'))
182
+ roots.push(root)
183
+ mkdirSync(join(root, 'ui', 'application'), { recursive: true })
184
+ writeFileSync(
185
+ join(root, 'ui', 'application', 'index.ts'),
186
+ `export { Screen } from './screen.js'\n`,
187
+ )
188
+ writeFileSync(join(root, 'ui', 'application', 'screen.tsx'), `export const Screen = () => null\n`)
189
+
190
+ expect(buildClientTree(root, null)).toMatchObject({
191
+ present: true,
192
+ features: [{ name: 'application', files: ['index.ts', 'screen.tsx'] }],
193
+ })
194
+ })