@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.
Files changed (180) hide show
  1. package/README.md +7 -11
  2. package/dist/astrale.js +3769 -3549
  3. package/dist/public/connect-core.js +396 -392
  4. package/dist/public/keys/index.js +371 -378
  5. package/dist/public/paths/index.js +371 -378
  6. package/dist/types/lib/idp.d.ts +6 -0
  7. package/dist/types/lib/meta.d.ts +2 -2
  8. package/package.json +6 -4
  9. package/src/admin/binding.ts +168 -0
  10. package/src/admin/catalog/__tests__/client.test.ts +2 -2
  11. package/src/admin/catalog/client.ts +4 -9
  12. package/src/admin/instance/__tests__/client.test.ts +5 -3
  13. package/src/admin/instance/client.ts +9 -27
  14. package/src/commands/__tests__/auth-login.test.ts +54 -1
  15. package/src/commands/__tests__/call.test.ts +5 -1
  16. package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
  17. package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
  18. package/src/commands/__tests__/domain-list.test.ts +83 -4
  19. package/src/commands/__tests__/install-identity-override.test.ts +30 -19
  20. package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
  21. package/src/commands/__tests__/update.test.ts +73 -0
  22. package/src/commands/domain/install.ts +11 -15
  23. package/src/commands/domain/list.ts +17 -12
  24. package/src/commands/studio.ts +20 -0
  25. package/src/commands/update.ts +39 -13
  26. package/src/connection/__tests__/errors.test.ts +16 -12
  27. package/src/connection/__tests__/exchange.test.ts +6 -5
  28. package/src/connection/exchange.ts +5 -4
  29. package/src/lib/__tests__/domain-publication.test.ts +144 -0
  30. package/src/lib/__tests__/idp-session.test.ts +22 -0
  31. package/src/lib/__tests__/idp.test.ts +2 -0
  32. package/src/lib/__tests__/instance.test.ts +6 -0
  33. package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
  34. package/src/lib/__tests__/update.test.ts +12 -0
  35. package/src/lib/domain-publication.ts +102 -0
  36. package/src/lib/idp.ts +5 -1
  37. package/src/lib/instance.ts +6 -1
  38. package/src/lib/login-flow.ts +13 -3
  39. package/src/lib/meta.ts +2 -2
  40. package/src/lib/update.ts +4 -4
  41. package/src/program/__tests__/program.test.ts +1 -1
  42. package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
  43. package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
  44. package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
  45. package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
  46. package/studio/client/dist/index.html +2 -2
  47. package/studio/package.json +3 -1
  48. package/studio/server/agent/bridge/stdio.ts +8 -1
  49. package/studio/server/agent/conversation.test.ts +10 -5
  50. package/studio/server/agent/conversation.ts +56 -8
  51. package/studio/server/agent/harness/codex/loadout.ts +18 -21
  52. package/studio/server/agent/harness/gateway/config.ts +20 -18
  53. package/studio/server/agent/harness/selection.ts +9 -1
  54. package/studio/server/agent/prompts/system.ts +3 -2
  55. package/studio/server/agent/prompts/turn.ts +13 -4
  56. package/studio/server/agent/run/completion.ts +2 -2
  57. package/studio/server/agent/run/preparation.ts +6 -5
  58. package/studio/server/agent/run/transcript.test.ts +35 -1
  59. package/studio/server/agent/run/transcript.ts +169 -3
  60. package/studio/server/agent/run/usage.test.ts +24 -0
  61. package/studio/server/agent/run/usage.ts +26 -1
  62. package/studio/server/api/canvas.ts +44 -0
  63. package/studio/server/api/comments.ts +85 -0
  64. package/studio/server/api/context.ts +61 -0
  65. package/studio/server/api/deployment.ts +39 -0
  66. package/studio/server/api/documents.ts +55 -0
  67. package/studio/server/api/domain.ts +59 -0
  68. package/studio/server/api/http.ts +45 -0
  69. package/studio/server/api/project.ts +57 -0
  70. package/studio/server/api/schema.ts +12 -0
  71. package/studio/server/api/views.ts +57 -0
  72. package/studio/server/api/workspace.ts +60 -0
  73. package/studio/server/api.test.ts +147 -0
  74. package/studio/server/api.ts +20 -422
  75. package/studio/server/cache.test.ts +111 -0
  76. package/studio/server/cache.ts +163 -9
  77. package/studio/server/cli-consumers.test.ts +494 -0
  78. package/studio/server/cli.test.ts +88 -0
  79. package/studio/server/cli.ts +240 -0
  80. package/studio/server/client-package.ts +25 -1
  81. package/studio/server/domain.test.ts +30 -2
  82. package/studio/server/domain.ts +14 -2
  83. package/studio/server/environment/dotenv-preview.test.ts +39 -0
  84. package/studio/server/environment/dotenv-preview.ts +27 -0
  85. package/studio/server/environment/files.test.ts +53 -0
  86. package/studio/server/{state/env.ts → environment/files.ts} +7 -48
  87. package/studio/server/{state → handoff}/copy.ts +21 -6
  88. package/studio/server/handoff/service.test.ts +22 -0
  89. package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
  90. package/studio/server/instances/active.ts +72 -0
  91. package/studio/server/instances/deploy-record.test.ts +31 -0
  92. package/studio/server/instances/deploy-record.ts +37 -0
  93. package/studio/server/instances/deploy.ts +56 -0
  94. package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
  95. package/studio/server/instances/probe.ts +67 -0
  96. package/studio/server/instances/status.test.ts +12 -0
  97. package/studio/server/instances/status.ts +49 -0
  98. package/studio/server/introspect/anatomy/client-tree.ts +57 -0
  99. package/studio/server/introspect/anatomy/env-fields.ts +66 -0
  100. package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
  101. package/studio/server/introspect/anatomy/source.ts +181 -0
  102. package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
  103. package/studio/server/introspect/anatomy/views/routes.ts +105 -0
  104. package/studio/server/introspect/anatomy/views.ts +58 -0
  105. package/studio/server/introspect/anatomy-extras.test.ts +91 -4
  106. package/studio/server/introspect/anatomy-extras.ts +7 -726
  107. package/studio/server/introspect/anatomy.ts +46 -19
  108. package/studio/server/introspect/bundle.ts +15 -6
  109. package/studio/server/introspect/canonical-schema.test.ts +79 -0
  110. package/studio/server/introspect/canonical-schema.ts +80 -18
  111. package/studio/server/introspect/config-preview.test.ts +32 -0
  112. package/studio/server/introspect/config-preview.ts +119 -0
  113. package/studio/server/introspect/diff.test.ts +7 -11
  114. package/studio/server/introspect/diff.ts +28 -37
  115. package/studio/server/introspect/extractor.ts +8 -6
  116. package/studio/server/introspect/hash.ts +5 -2
  117. package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
  118. package/studio/server/introspect/revision.test.ts +54 -0
  119. package/studio/server/introspect/revision.ts +49 -0
  120. package/studio/server/introspect/runtime.test.ts +63 -5
  121. package/studio/server/introspect/runtime.ts +49 -3
  122. package/studio/server/introspect/schema-ir-json.ts +181 -0
  123. package/studio/server/introspect/source-overlay/annotations.ts +14 -0
  124. package/studio/server/introspect/source-overlay/handlers.ts +681 -0
  125. package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
  126. package/studio/server/introspect/source-overlay/project.ts +248 -0
  127. package/studio/server/introspect/source-overlay/spans.ts +360 -0
  128. package/studio/server/json.ts +46 -0
  129. package/studio/server/lifecycle.ts +5 -1
  130. package/studio/server/sse.test.ts +26 -0
  131. package/studio/server/sse.ts +4 -1
  132. package/studio/server/state/baseline.test.ts +212 -1
  133. package/studio/server/state/baseline.ts +145 -29
  134. package/studio/server/state/comments.test.ts +66 -1
  135. package/studio/server/state/comments.ts +173 -10
  136. package/studio/server/state/context.ts +34 -2
  137. package/studio/server/state/documents.ts +42 -1
  138. package/studio/server/state/integrations.ts +27 -1
  139. package/studio/server/state/layout.test.ts +34 -0
  140. package/studio/server/state/layout.ts +44 -17
  141. package/studio/server/state/settings.ts +29 -22
  142. package/studio/server/state/store.test.ts +122 -0
  143. package/studio/server/state/store.ts +66 -17
  144. package/studio/server/state/visibility.ts +22 -7
  145. package/studio/server/views/model.test.ts +57 -0
  146. package/studio/server/views/model.ts +66 -0
  147. package/studio/server/views/runtime.ts +40 -0
  148. package/studio/server/views/selection-repository.test.ts +31 -0
  149. package/studio/server/views/selection-repository.ts +60 -0
  150. package/studio/server/views/session.test.ts +73 -0
  151. package/studio/server/views/session.ts +166 -0
  152. package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
  153. package/studio/server/views/target.ts +248 -0
  154. package/studio/server/watch.ts +1 -1
  155. package/studio/server/workspace/catalog.test.ts +17 -0
  156. package/studio/server/workspace/catalog.ts +43 -0
  157. package/studio/server/{state → workspace}/create.test.ts +1 -1
  158. package/studio/server/{state → workspace}/create.ts +1 -1
  159. package/studio/server/{state → workspace}/git.ts +1 -1
  160. package/studio/server/workspace/updates.ts +83 -0
  161. package/studio/server/workspace-state.ts +1 -1
  162. package/studio/shared/contracts/README.md +16 -0
  163. package/studio/shared/contracts/agent.ts +270 -0
  164. package/studio/shared/contracts/runtime.ts +50 -0
  165. package/studio/shared/contracts/schema.ts +462 -0
  166. package/studio/shared/contracts/surface.test.ts +319 -0
  167. package/studio/shared/contracts/workspace.ts +274 -0
  168. package/studio/shared/layout.test.ts +116 -0
  169. package/studio/shared/schema/identity.test.ts +58 -0
  170. package/studio/shared/schema/identity.ts +82 -0
  171. package/studio/shared/types.ts +11 -1099
  172. package/studio/tsconfig.json +1 -1
  173. package/viewer/dist/main.js +35 -35
  174. package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
  175. package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
  176. package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
  177. package/studio/server/state/catalog.ts +0 -117
  178. package/studio/server/state/instance.ts +0 -280
  179. package/studio/server/state/updates.ts +0 -63
  180. package/studio/server/state/views.ts +0 -535
@@ -0,0 +1,122 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { asJsonRecord, asString } from '../json'
7
+ import {
8
+ ensureDir,
9
+ listState,
10
+ readJson,
11
+ readState,
12
+ removeState,
13
+ stateExists,
14
+ statePath,
15
+ writeJson,
16
+ writeState,
17
+ writeStateBuffer,
18
+ } from './store'
19
+
20
+ const roots: string[] = []
21
+
22
+ afterEach(() => {
23
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
24
+ })
25
+
26
+ test('readJson returns only values admitted by its explicit decoder', () => {
27
+ const root = mkdtempSync(join(tmpdir(), 'studio-json-store-'))
28
+ roots.push(root)
29
+ const decodeName = (value: unknown): string | undefined => asString(asJsonRecord(value)?.name)
30
+
31
+ writeState(root, 'value.json', '{not json')
32
+ expect(readJson(root, 'value.json', decodeName, 'fallback')).toBe('fallback')
33
+
34
+ writeJson(root, 'value.json', { name: 42, future: { revision: 2 } })
35
+ expect(readJson(root, 'value.json', decodeName, 'fallback')).toBe('fallback')
36
+
37
+ writeJson(root, 'value.json', { name: 'accepted', future: { revision: 2 } })
38
+ expect(readJson(root, 'value.json', decodeName, 'fallback')).toBe('accepted')
39
+ })
40
+
41
+ const guardedOperations = [
42
+ {
43
+ name: 'ensureDir',
44
+ run: (root: string, prefix: string) => ensureDir(root, `${prefix}/created-dir`),
45
+ },
46
+ {
47
+ name: 'writeState',
48
+ run: (root: string, prefix: string) => writeState(root, `${prefix}/created.txt`, 'nope'),
49
+ },
50
+ {
51
+ name: 'writeStateBuffer',
52
+ run: (root: string, prefix: string) =>
53
+ writeStateBuffer(root, `${prefix}/created.bin`, new Uint8Array([1, 2, 3])),
54
+ },
55
+ {
56
+ name: 'statePath',
57
+ run: (root: string, prefix: string) => statePath(root, `${prefix}/created.txt`),
58
+ },
59
+ {
60
+ name: 'readState',
61
+ run: (root: string, prefix: string) => readState(root, `${prefix}/marker.txt`),
62
+ },
63
+ {
64
+ name: 'listState',
65
+ run: (root: string, prefix: string) => listState(root, prefix),
66
+ },
67
+ {
68
+ name: 'removeState',
69
+ run: (root: string, prefix: string) => removeState(root, prefix),
70
+ },
71
+ {
72
+ name: 'stateExists',
73
+ run: (root: string, prefix: string) => stateExists(root, prefix),
74
+ },
75
+ ] as const
76
+
77
+ for (const operation of guardedOperations) {
78
+ test(`${operation.name} rejects lexical traversal outside .domain-studio`, () => {
79
+ const root = mkdtempSync(join(tmpdir(), 'studio-store-traversal-'))
80
+ roots.push(root)
81
+
82
+ expect(() => operation.run(root, '../escape')).toThrow('state-allowlist violation')
83
+ expect(existsSync(join(root, 'escape'))).toBe(false)
84
+ })
85
+
86
+ test(`${operation.name} rejects an existing descendant symlink escape`, () => {
87
+ const container = mkdtempSync(join(tmpdir(), 'studio-store-symlink-'))
88
+ roots.push(container)
89
+ const root = join(container, 'domain')
90
+ const external = join(container, 'external')
91
+ mkdirSync(join(root, '.domain-studio'), { recursive: true })
92
+ mkdirSync(external)
93
+ writeFileSync(join(external, 'marker.txt'), 'keep')
94
+ symlinkSync(external, join(root, '.domain-studio', 'escape'), 'dir')
95
+
96
+ expect(() => operation.run(root, 'escape')).toThrow('state-allowlist violation')
97
+ expect(existsSync(join(external, 'marker.txt'))).toBe(true)
98
+ expect(existsSync(join(external, 'created.txt'))).toBe(false)
99
+ expect(existsSync(join(external, 'created.bin'))).toBe(false)
100
+ expect(existsSync(join(external, 'created-dir'))).toBe(false)
101
+ })
102
+ }
103
+
104
+ test('rejects a .domain-studio symlink but permits the domain root itself to be symlinked', () => {
105
+ const container = mkdtempSync(join(tmpdir(), 'studio-store-root-symlink-'))
106
+ roots.push(container)
107
+ const physicalDomain = join(container, 'physical-domain')
108
+ const linkedDomain = join(container, 'linked-domain')
109
+ const external = join(container, 'external')
110
+ mkdirSync(physicalDomain)
111
+ mkdirSync(external)
112
+ symlinkSync(physicalDomain, linkedDomain, 'dir')
113
+
114
+ writeState(linkedDomain, 'allowed.txt', 'inside')
115
+ expect(readState(linkedDomain, 'allowed.txt')).toBe('inside')
116
+ expect(existsSync(join(physicalDomain, '.domain-studio', 'allowed.txt'))).toBe(true)
117
+
118
+ rmSync(join(physicalDomain, '.domain-studio'), { recursive: true })
119
+ symlinkSync(external, join(physicalDomain, '.domain-studio'), 'dir')
120
+ expect(() => writeState(linkedDomain, 'escaped.txt', 'nope')).toThrow('state-allowlist violation')
121
+ expect(existsSync(join(external, 'escaped.txt'))).toBe(false)
122
+ })
@@ -1,12 +1,22 @@
1
1
  /**
2
- * store.ts — the ONLY filesystem-write gateway in the studio. It REJECTS any
3
- * write whose resolved path is not under `<domain>/.domain-studio/`. This makes
4
- * the read-only-domain rule a code-enforced invariant (§17 of the spec): the
5
- * studio can never write domain source. Reads of domain source are allowed and
6
- * done elsewhere; this module never writes outside the dotted folder.
2
+ * store.ts — the gateway for Studio-owned persistence under
3
+ * `<domain>/.domain-studio/`. It rejects every target outside that directory.
4
+ * Explicit user-requested domain edits (for example environment files or
5
+ * workspace creation) belong to their own feature boundary, not this state store.
7
6
  */
8
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
9
- import { dirname, join, resolve, sep } from 'node:path'
7
+ import {
8
+ existsSync,
9
+ lstatSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ readdirSync,
13
+ realpathSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from 'node:fs'
17
+ import { dirname, join, relative, resolve, sep } from 'node:path'
18
+
19
+ import { parseJson, type JsonDecoder } from '../json'
10
20
 
11
21
  export const DOT = '.domain-studio'
12
22
 
@@ -18,7 +28,43 @@ function assertInsideDot(domainRoot: string, target: string): string {
18
28
  const abs = resolve(target)
19
29
  const root = resolve(dotDir(domainRoot))
20
30
  if (abs !== root && !abs.startsWith(root + sep)) {
21
- throw new Error(`write-allowlist violation: ${abs} is outside ${root}`)
31
+ throw new Error(`state-allowlist violation: ${abs} is outside ${root}`)
32
+ }
33
+
34
+ // Lexical containment alone is insufficient: an existing `.domain-studio`
35
+ // symlink (or any symlink below it) could otherwise redirect an allowed path
36
+ // outside the state directory. Resolve every existing prefix against the
37
+ // physical domain root. Resolving domainRoot first intentionally permits the
38
+ // domain root itself to be a symlink.
39
+ let physicalDomainRoot: string
40
+ try {
41
+ physicalDomainRoot = realpathSync(domainRoot)
42
+ } catch {
43
+ physicalDomainRoot = resolve(domainRoot)
44
+ }
45
+ const physicalRoot = resolve(physicalDomainRoot, DOT)
46
+ const relativeTarget = relative(root, abs)
47
+ const parts = relativeTarget === '' ? [] : relativeTarget.split(sep)
48
+ let prefix = root
49
+ for (const part of ['', ...parts]) {
50
+ if (part) prefix = join(prefix, part)
51
+ try {
52
+ lstatSync(prefix)
53
+ } catch (error) {
54
+ const code = (error as NodeJS.ErrnoException).code
55
+ if (code === 'ENOENT') break
56
+ throw error
57
+ }
58
+
59
+ let physicalPrefix: string
60
+ try {
61
+ physicalPrefix = realpathSync(prefix)
62
+ } catch {
63
+ throw new Error(`state-allowlist violation: ${prefix} is an unresolved symlink`)
64
+ }
65
+ if (physicalPrefix !== physicalRoot && !physicalPrefix.startsWith(physicalRoot + sep)) {
66
+ throw new Error(`state-allowlist violation: ${prefix} resolves outside ${physicalRoot}`)
67
+ }
22
68
  }
23
69
  return abs
24
70
  }
@@ -55,23 +101,26 @@ export function statePath(domainRoot: string, subpath: string): string {
55
101
  }
56
102
 
57
103
  export function readState(domainRoot: string, subpath: string): string | null {
58
- const target = join(dotDir(domainRoot), subpath)
104
+ const target = assertInsideDot(domainRoot, join(dotDir(domainRoot), subpath))
59
105
  if (!existsSync(target)) return null
60
106
  return readFileSync(target, 'utf8')
61
107
  }
62
108
 
63
- export function readJson<T>(domainRoot: string, subpath: string, fallback: T): T {
109
+ export function readJson<T>(
110
+ domainRoot: string,
111
+ subpath: string,
112
+ decode: JsonDecoder<T>,
113
+ fallback: T,
114
+ ): T {
64
115
  const raw = readState(domainRoot, subpath)
65
116
  if (raw == null) return fallback
66
- try {
67
- return JSON.parse(raw) as T
68
- } catch {
69
- return fallback
70
- }
117
+ const parsed = parseJson(raw)
118
+ if (parsed === undefined) return fallback
119
+ return decode(parsed) ?? fallback
71
120
  }
72
121
 
73
122
  export function listState(domainRoot: string, subpath: string): string[] {
74
- const target = join(dotDir(domainRoot), subpath)
123
+ const target = assertInsideDot(domainRoot, join(dotDir(domainRoot), subpath))
75
124
  if (!existsSync(target)) return []
76
125
  return readdirSync(target)
77
126
  }
@@ -83,7 +132,7 @@ export function removeState(domainRoot: string, subpath: string): void {
83
132
  }
84
133
 
85
134
  export function stateExists(domainRoot: string, subpath: string): boolean {
86
- return existsSync(join(dotDir(domainRoot), subpath))
135
+ return existsSync(assertInsideDot(domainRoot, join(dotDir(domainRoot), subpath)))
87
136
  }
88
137
 
89
138
  /** Initialise the dotted folder skeleton + a .cache/.gitignore (never touches the user's root .gitignore). */
@@ -5,13 +5,10 @@
5
5
  * stable refs, so they survive schema edits (a removed ref is harmless).
6
6
  * Persisted via the allow-listed store (visibility.json).
7
7
  */
8
- import { readJson, removeState, writeJson } from './store'
8
+ import type { VisibilityState } from '../../shared/types'
9
9
 
10
- export interface VisibilityState {
11
- hidden: Record<string, true>
12
- showInheritedEdges: boolean
13
- materializedInterfaces: Record<string, true>
14
- }
10
+ import { asBoolean, asJsonRecord } from '../json'
11
+ import { readJson, removeState, writeJson } from './store'
15
12
 
16
13
  const FILE = 'visibility.json'
17
14
  const DEFAULT: VisibilityState = {
@@ -20,10 +17,28 @@ const DEFAULT: VisibilityState = {
20
17
  materializedInterfaces: {},
21
18
  }
22
19
 
20
+ function trueSet(value: unknown): Record<string, true> {
21
+ return Object.fromEntries(
22
+ Object.entries(asJsonRecord(value) ?? {}).filter(
23
+ (entry): entry is [string, true] => entry[1] === true,
24
+ ),
25
+ )
26
+ }
27
+
28
+ function decodeVisibility(value: unknown): VisibilityState | undefined {
29
+ const record = asJsonRecord(value)
30
+ if (!record) return undefined
31
+ return {
32
+ hidden: trueSet(record.hidden),
33
+ showInheritedEdges: asBoolean(record.showInheritedEdges) ?? DEFAULT.showInheritedEdges,
34
+ materializedInterfaces: trueSet(record.materializedInterfaces),
35
+ }
36
+ }
37
+
23
38
  export function readVisibility(root: string): VisibilityState {
24
39
  // merge over DEFAULT so a file written before a field existed (e.g. pre-materialize)
25
40
  // still returns the complete shape — the client reads every field unconditionally.
26
- return { ...DEFAULT, ...readJson<Partial<VisibilityState>>(root, FILE, DEFAULT) }
41
+ return readJson(root, FILE, decodeVisibility, { ...DEFAULT })
27
42
  }
28
43
 
29
44
  export function saveVisibility(root: string, state: VisibilityState): VisibilityState {
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { RememberedViewTarget, ViewTargetCandidate } from '../../shared/types'
4
+
5
+ import { reconcileRememberedTarget, targetFromRow } from './model'
6
+
7
+ describe('target candidates', () => {
8
+ const remembered: RememberedViewTarget = {
9
+ id: 'gone-id',
10
+ className: 'Issue',
11
+ classOrigin: 'issues.astrale.ai',
12
+ label: 'Deleted issue',
13
+ }
14
+ const candidate: ViewTargetCandidate = {
15
+ id: 'live-id',
16
+ ref: '@live-id',
17
+ className: 'Issue',
18
+ classOrigin: 'issues.astrale.ai',
19
+ label: 'Live issue',
20
+ }
21
+
22
+ test('turns a missing remembered node into an explicit stale selection', () => {
23
+ expect(reconcileRememberedTarget(remembered, [candidate])).toEqual({
24
+ selected: null,
25
+ stale: remembered,
26
+ })
27
+ expect(reconcileRememberedTarget({ ...remembered, id: 'live-id' }, [candidate])).toEqual({
28
+ selected: candidate,
29
+ stale: null,
30
+ })
31
+ })
32
+
33
+ test('builds friendly candidates from generic qualified node properties', () => {
34
+ expect(
35
+ targetFromRow(
36
+ {
37
+ id: 'iss-1',
38
+ props: {
39
+ 'kernel.astrale.ai:interface.Named.property.name': 'Broken authorization badge',
40
+ 'kernel.astrale.ai:interface.Descriptable.property.description': 'Studio regression',
41
+ 'kernel.astrale.ai:interface.Statused.property.status': 'open',
42
+ },
43
+ },
44
+ 'Issue',
45
+ 'issues.astrale.ai',
46
+ ),
47
+ ).toEqual({
48
+ id: 'iss-1',
49
+ ref: '@iss-1',
50
+ className: 'Issue',
51
+ classOrigin: 'issues.astrale.ai',
52
+ label: 'Broken authorization badge',
53
+ description: 'Studio regression',
54
+ status: 'open',
55
+ })
56
+ })
57
+ })
@@ -0,0 +1,66 @@
1
+ import type {
2
+ RememberedViewTarget,
3
+ ViewTargetCandidate,
4
+ ViewTargetResult,
5
+ } from '../../shared/types'
6
+
7
+ const NAMED_NAME = 'kernel.astrale.ai:interface.Named.property.name'
8
+ const DESCRIPTABLE_DESCRIPTION = 'kernel.astrale.ai:interface.Descriptable.property.description'
9
+ const STATUSED_STATUS = 'kernel.astrale.ai:interface.Statused.property.status'
10
+
11
+ export interface RawTargetRow {
12
+ id?: unknown
13
+ props?: Record<string, unknown>
14
+ }
15
+
16
+ export function reconcileRememberedTarget(
17
+ remembered: RememberedViewTarget | null,
18
+ items: ViewTargetCandidate[],
19
+ ): Pick<ViewTargetResult, 'selected' | 'stale'> {
20
+ if (!remembered) return { selected: null, stale: null }
21
+ const selected = items.find((item) => item.id === remembered.id) ?? null
22
+ return { selected, stale: selected ? null : remembered }
23
+ }
24
+
25
+ export function targetFromRow(
26
+ row: RawTargetRow,
27
+ className: string,
28
+ classOrigin: string,
29
+ ): ViewTargetCandidate | null {
30
+ const id =
31
+ typeof row.id === 'string' ? row.id : typeof row.props?.id === 'string' ? row.props.id : ''
32
+ if (!id) return null
33
+ const props = row.props ?? {}
34
+ const firstName = stringProp(props, ['.property.firstName'])
35
+ const lastName = stringProp(props, ['.property.lastName'])
36
+ const label =
37
+ asNonEmptyString(props[NAMED_NAME]) ??
38
+ stringProp(props, ['.property.title', '.property.label', '.property.name', '.property.slug']) ??
39
+ ([firstName, lastName].filter(Boolean).join(' ') || `${className} · ${id.slice(0, 8)}`)
40
+ return {
41
+ id,
42
+ ref: `@${id}`,
43
+ className,
44
+ classOrigin,
45
+ label,
46
+ description:
47
+ asNonEmptyString(props[DESCRIPTABLE_DESCRIPTION]) ??
48
+ stringProp(props, ['.property.description', '.property.email']),
49
+ status: asNonEmptyString(props[STATUSED_STATUS]) ?? stringProp(props, ['.property.status']),
50
+ }
51
+ }
52
+
53
+ function stringProp(props: Record<string, unknown>, suffixes: string[]): string | undefined {
54
+ for (const suffix of suffixes) {
55
+ for (const [key, value] of Object.entries(props)) {
56
+ if (!key.endsWith(suffix)) continue
57
+ const text = asNonEmptyString(value)
58
+ if (text) return text
59
+ }
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ function asNonEmptyString(value: unknown): string | undefined {
65
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
66
+ }
@@ -0,0 +1,40 @@
1
+ import type {
2
+ StudioSchemaBundle,
3
+ ViewInfo,
4
+ ViewRuntime,
5
+ ViewTargetResult,
6
+ } from '../../shared/types'
7
+
8
+ import { activeInstanceName } from '../instances/active'
9
+ import { listViewTargets, viewDefinitionBindings } from './target'
10
+
11
+ export async function getViewRuntime(
12
+ root: string,
13
+ origin: string,
14
+ view: ViewInfo,
15
+ bundle: StudioSchemaBundle | null,
16
+ timeoutMs: number,
17
+ ): Promise<ViewRuntime> {
18
+ const instance = await activeInstanceName()
19
+ const targetRequired = viewDefinitionBindings(origin, view, bundle).length > 0
20
+ const targets = targetRequired
21
+ ? instance
22
+ ? await listViewTargets(root, origin, view, bundle, instance, timeoutMs)
23
+ : ({
24
+ status: 'unavailable',
25
+ items: [],
26
+ selected: null,
27
+ stale: null,
28
+ truncated: false,
29
+ reason: 'No active Astrale instance.',
30
+ } satisfies ViewTargetResult)
31
+ : ({
32
+ status: 'available',
33
+ items: [],
34
+ selected: null,
35
+ stale: null,
36
+ truncated: false,
37
+ } satisfies ViewTargetResult)
38
+
39
+ return { slug: view.slug, instance, targetRequired, targets }
40
+ }
@@ -0,0 +1,31 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { readRememberedTarget, rememberTarget } from './selection-repository'
7
+
8
+ const roots: string[] = []
9
+
10
+ afterEach(() => {
11
+ while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
12
+ })
13
+
14
+ test('persists the remembered target through the view-owned repository', () => {
15
+ const root = mkdtempSync(join(tmpdir(), 'studio-view-selection-'))
16
+ roots.push(root)
17
+ rememberTarget(root, 'staging', 'issue-detail', {
18
+ id: 'issue-1',
19
+ ref: '@issue-1',
20
+ className: 'Issue',
21
+ classOrigin: 'issues.example.dev',
22
+ label: 'First issue',
23
+ })
24
+
25
+ expect(readRememberedTarget(root, 'staging', 'issue-detail')).toEqual({
26
+ id: 'issue-1',
27
+ className: 'Issue',
28
+ classOrigin: 'issues.example.dev',
29
+ label: 'First issue',
30
+ })
31
+ })
@@ -0,0 +1,60 @@
1
+ import type { RememberedViewTarget, ViewTargetCandidate } from '../../shared/types'
2
+
3
+ import { asJsonRecord, asString } from '../json'
4
+ import { readJson, writeJson } from '../state/store'
5
+
6
+ const STATE_FILE = 'views.json'
7
+
8
+ interface StoredViewState {
9
+ targets?: Record<string, Record<string, RememberedViewTarget>>
10
+ }
11
+
12
+ function decodeRememberedTarget(value: unknown): RememberedViewTarget | undefined {
13
+ const record = asJsonRecord(value)
14
+ const id = asString(record?.id)
15
+ const className = asString(record?.className)
16
+ const classOrigin = asString(record?.classOrigin)
17
+ const label = asString(record?.label)
18
+ return id && className && classOrigin && label ? { id, className, classOrigin, label } : undefined
19
+ }
20
+
21
+ function decodeStoredViewState(value: unknown): StoredViewState | undefined {
22
+ const record = asJsonRecord(value)
23
+ if (!record) return undefined
24
+ const targets: NonNullable<StoredViewState['targets']> = {}
25
+ for (const [instance, bySlugValue] of Object.entries(asJsonRecord(record.targets) ?? {})) {
26
+ const bySlug: Record<string, RememberedViewTarget> = {}
27
+ for (const [slug, targetValue] of Object.entries(asJsonRecord(bySlugValue) ?? {})) {
28
+ const target = decodeRememberedTarget(targetValue)
29
+ if (target) bySlug[slug] = target
30
+ }
31
+ targets[instance] = bySlug
32
+ }
33
+ return { targets }
34
+ }
35
+
36
+ export function rememberTarget(
37
+ root: string,
38
+ instance: string,
39
+ slug: string,
40
+ target: ViewTargetCandidate,
41
+ ): void {
42
+ const stored = readJson(root, STATE_FILE, decodeStoredViewState, {})
43
+ stored.targets ??= {}
44
+ stored.targets[instance] ??= {}
45
+ stored.targets[instance][slug] = {
46
+ id: target.id,
47
+ className: target.className,
48
+ classOrigin: target.classOrigin,
49
+ label: target.label,
50
+ }
51
+ writeJson(root, STATE_FILE, stored)
52
+ }
53
+
54
+ export function readRememberedTarget(
55
+ root: string,
56
+ instance: string,
57
+ slug: string,
58
+ ): RememberedViewTarget | null {
59
+ return readJson(root, STATE_FILE, decodeStoredViewState, {}).targets?.[instance]?.[slug] ?? null
60
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { conciseCliFailure, readyViewSession, viewSessionArgs } from './session'
4
+
5
+ describe('view session command', () => {
6
+ test('opens the installed ViewPath without rejected placement overrides', () => {
7
+ const args = viewSessionArgs('issues.astrale.ai', 'issue-detail', 'staging', '@issue-1')
8
+ expect(args).toEqual([
9
+ 'view',
10
+ '/:issues.astrale.ai:view.issue-detail',
11
+ '--target',
12
+ '@issue-1',
13
+ '--no-open',
14
+ '--json',
15
+ '-i',
16
+ 'staging',
17
+ ])
18
+ expect(args).not.toContain('--view-url')
19
+ expect(args).not.toContain('--handshake')
20
+ })
21
+
22
+ test('omits --target for a standalone view', () => {
23
+ expect(viewSessionArgs('issues.astrale.ai', 'dashboard', 'local')).toEqual([
24
+ 'view',
25
+ '/:issues.astrale.ai:view.dashboard',
26
+ '--no-open',
27
+ '--json',
28
+ '-i',
29
+ 'local',
30
+ ])
31
+ })
32
+
33
+ test('reads the verified placement URL from the current CLI session route', () => {
34
+ expect(
35
+ readyViewSession(
36
+ {
37
+ session: {
38
+ id: 'v-a1b2',
39
+ pageUrl: 'http://127.0.0.1:4419/s/nonce/',
40
+ view: { route: { href: 'https://issues.example/ui/issue' } },
41
+ },
42
+ },
43
+ null,
44
+ ),
45
+ ).toEqual({
46
+ status: 'ready',
47
+ sessionId: 'v-a1b2',
48
+ pageUrl: 'http://127.0.0.1:4419/s/nonce/',
49
+ viewUrl: 'https://issues.example/ui/issue',
50
+ target: null,
51
+ })
52
+ expect(
53
+ readyViewSession(
54
+ {
55
+ session: {
56
+ id: 'v-old',
57
+ pageUrl: 'http://127.0.0.1:4419/s/old/',
58
+ view: { url: 'https://legacy.invalid/view' },
59
+ },
60
+ } as never,
61
+ null,
62
+ ),
63
+ ).toBeNull()
64
+ })
65
+ })
66
+
67
+ test('reduces CLI stack output to the actionable kernel failure', () => {
68
+ expect(
69
+ conciseCliFailure(
70
+ `275 | invalid\nPermissionDeniedError: Permission denied: READ on /:issues.astrale.ai:view.issue\n details: {}\n at mapServerError (errors.ts:280:61)`,
71
+ ),
72
+ ).toBe('PermissionDeniedError: Permission denied: READ on /:issues.astrale.ai:view.issue')
73
+ })