@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
@@ -7,13 +7,17 @@ import { createHash } from 'node:crypto'
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
8
  import { join, relative } from 'node:path'
9
9
 
10
- import type { DomainAnatomy, StudioCore, StudioSchemaBundle } from '../shared/types'
10
+ import type { DomainAnatomy, SchemaOverlay, StudioCore, StudioSchemaBundle } from '../shared/types'
11
11
 
12
+ import { isSchemaRevision } from '../shared/types'
12
13
  import { invalidateClientPackage, resolveClientPackage } from './client-package'
13
14
  import { getDomain } from './domain'
14
15
  import { buildAnatomy } from './introspect/anatomy'
15
16
  import { buildBundle } from './introspect/bundle'
17
+ import { isCanonicalDomainSchemaV1 } from './introspect/canonical-schema'
16
18
  import { buildCore } from './introspect/core'
19
+ import { decodeIrInterfaceRecord, decodeSchemaIR } from './introspect/schema-ir-json'
20
+ import { asBoolean, asFiniteNumber, asJsonRecord, asString, asStringArray } from './json'
17
21
  import { hashAnatomyFiles } from './state/baseline'
18
22
  import { readJson, writeJson } from './state/store'
19
23
 
@@ -22,7 +26,7 @@ const anatomies = new Map<string, Promise<DomainAnatomy>>()
22
26
  const cores = new Map<string, StudioCore>()
23
27
 
24
28
  const BUNDLE_CACHE_FILE = '.cache/schema-bundle.json'
25
- const BUNDLE_CACHE_VERSION = 3
29
+ const BUNDLE_CACHE_VERSION = 4
26
30
  const LOCKFILES = ['bun.lock', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']
27
31
  const TOOL_INPUTS = [
28
32
  'cache.ts',
@@ -33,6 +37,11 @@ const TOOL_INPUTS = [
33
37
  'introspect/canonical-schema.ts',
34
38
  'introspect/overlay.ts',
35
39
  'introspect/overlay-tsmorph.ts',
40
+ 'introspect/source-overlay/annotations.ts',
41
+ 'introspect/source-overlay/handlers.ts',
42
+ 'introspect/source-overlay/kernel-calls.ts',
43
+ 'introspect/source-overlay/project.ts',
44
+ 'introspect/source-overlay/spans.ts',
36
45
  'introspect/hash.ts',
37
46
  'state/baseline.ts',
38
47
  '../shared/types.ts',
@@ -44,6 +53,149 @@ interface BundleCacheEntry {
44
53
  bundle: StudioSchemaBundle
45
54
  }
46
55
 
56
+ function isCrossDomainImport(value: unknown): boolean {
57
+ const record = asJsonRecord(value)
58
+ return (
59
+ typeof record?.name === 'string' &&
60
+ typeof record.origin === 'string' &&
61
+ (record.definition === 'class' || record.definition === 'interface')
62
+ )
63
+ }
64
+
65
+ function isHandlerLink(value: unknown): boolean {
66
+ const record = asJsonRecord(value)
67
+ return (
68
+ typeof record?.owner === 'string' &&
69
+ ['class', 'interface', 'function'].includes(String(record.ownerKind)) &&
70
+ typeof record.method === 'string' &&
71
+ typeof record.static === 'boolean' &&
72
+ typeof record.implemented === 'boolean'
73
+ )
74
+ }
75
+
76
+ function isSourceSpan(value: unknown): boolean {
77
+ const record = asJsonRecord(value)
78
+ return (
79
+ typeof record?.file === 'string' &&
80
+ asFiniteNumber(record.startLine) !== undefined &&
81
+ asFiniteNumber(record.endLine) !== undefined &&
82
+ (record.doc === undefined || typeof record.doc === 'string')
83
+ )
84
+ }
85
+
86
+ function isAnnotation(value: unknown): boolean {
87
+ const record = asJsonRecord(value)
88
+ return (
89
+ typeof record?.target === 'string' &&
90
+ (record.severity === 'warn' || record.severity === 'info') &&
91
+ (record.code === 'COMPILE_ERROR' || record.code === 'EDGE_PROP_TYPE_MISSING') &&
92
+ typeof record.message === 'string'
93
+ )
94
+ }
95
+
96
+ function decodeOverlay(value: unknown): SchemaOverlay | undefined {
97
+ const record = asJsonRecord(value)
98
+ const requires = asStringArray(record?.requires)
99
+ const crossDomainImports = record?.crossDomainImports
100
+ const mixins = record?.mixins
101
+ const handlerLinks = record?.handlerLinks
102
+ const sourceSpans = asJsonRecord(record?.sourceSpans)
103
+ const annotations = record?.annotations
104
+ if (
105
+ typeof record?.origin !== 'string' ||
106
+ !requires ||
107
+ !Array.isArray(crossDomainImports) ||
108
+ !crossDomainImports.every(isCrossDomainImport) ||
109
+ !Array.isArray(mixins) ||
110
+ !mixins.every(isCrossDomainImport) ||
111
+ !Array.isArray(handlerLinks) ||
112
+ !handlerLinks.every(isHandlerLink) ||
113
+ !sourceSpans ||
114
+ !Object.values(sourceSpans).every(isSourceSpan) ||
115
+ !Array.isArray(annotations) ||
116
+ !annotations.every(isAnnotation)
117
+ ) {
118
+ return undefined
119
+ }
120
+ return record as unknown as SchemaOverlay
121
+ }
122
+
123
+ function decodeBundle(value: unknown): StudioSchemaBundle | undefined {
124
+ const record = asJsonRecord(value)
125
+ if (!record) return undefined
126
+ const domainId = asString(record.domainId)
127
+ const renderFingerprint = asString(record.renderFingerprint)
128
+ const schemaMode = (
129
+ ['canonical-admitted', 'canonical-preview', 'legacy', 'unavailable'] as const
130
+ ).find((candidate) => candidate === record.schemaMode)
131
+ const extractedBy = record.extractedBy
132
+ const depsInstalled = asBoolean(record.depsInstalled)
133
+ const ir = record.ir === null ? null : decodeSchemaIR(record.ir)
134
+ const overlay = decodeOverlay(record.overlay)
135
+ const extractedAt = asString(record.extractedAt)
136
+ if (
137
+ !domainId ||
138
+ renderFingerprint === undefined ||
139
+ !schemaMode ||
140
+ (extractedBy !== 'runtime-bun' && extractedBy !== 'static-tsmorph-fallback') ||
141
+ depsInstalled === undefined ||
142
+ ir === undefined ||
143
+ !overlay ||
144
+ !extractedAt
145
+ ) {
146
+ return undefined
147
+ }
148
+ const schemaRevision = record.schemaRevision
149
+ if (schemaRevision !== undefined && !isSchemaRevision(schemaRevision)) return undefined
150
+ if (
151
+ schemaMode === 'canonical-admitted' &&
152
+ (!isSchemaRevision(schemaRevision) || !isCanonicalDomainSchemaV1(record.schemaRoot))
153
+ ) {
154
+ return undefined
155
+ }
156
+ const importedInterfaces =
157
+ record.importedInterfaces === undefined
158
+ ? undefined
159
+ : decodeIrInterfaceRecord(record.importedInterfaces)
160
+ if (record.importedInterfaces !== undefined && importedInterfaces === undefined) return undefined
161
+ const errorRecord = record.error === null ? null : asJsonRecord(record.error)
162
+ const errorMessage = asString(errorRecord?.message)
163
+ if (record.error !== undefined && record.error !== null && errorMessage === undefined) {
164
+ return undefined
165
+ }
166
+ return {
167
+ domainId,
168
+ renderFingerprint,
169
+ schemaMode,
170
+ ...(schemaRevision === undefined ? {} : { schemaRevision }),
171
+ extractedBy,
172
+ depsInstalled,
173
+ ir,
174
+ ...(record.schemaRoot === undefined ? {} : { schemaRoot: record.schemaRoot }),
175
+ overlay,
176
+ ...(importedInterfaces === undefined ? {} : { importedInterfaces }),
177
+ ...(record.error === undefined
178
+ ? {}
179
+ : record.error === null
180
+ ? { error: null }
181
+ : {
182
+ error: {
183
+ message: errorMessage!,
184
+ ...(typeof errorRecord!.file === 'string' ? { file: errorRecord!.file } : {}),
185
+ },
186
+ }),
187
+ extractedAt,
188
+ }
189
+ }
190
+
191
+ export function decodeBundleCacheEntry(value: unknown): BundleCacheEntry | undefined {
192
+ const record = asJsonRecord(value)
193
+ const version = asFiniteNumber(record?.version)
194
+ const key = asString(record?.key)
195
+ const bundle = decodeBundle(record?.bundle)
196
+ return version !== undefined && key !== undefined && bundle ? { version, key, bundle } : undefined
197
+ }
198
+
47
199
  function hashFileIfPresent(hash: ReturnType<typeof createHash>, label: string, file: string): void {
48
200
  try {
49
201
  if (!existsSync(file)) return
@@ -77,7 +229,7 @@ function bundleCacheKey(root: string, schemaDirName: string): string {
77
229
  }
78
230
 
79
231
  function readCachedBundle(root: string, key: string): StudioSchemaBundle | null {
80
- const entry = readJson<BundleCacheEntry | null>(root, BUNDLE_CACHE_FILE, null)
232
+ const entry = readJson(root, BUNDLE_CACHE_FILE, decodeBundleCacheEntry, null)
81
233
  if (!entry || entry.version !== BUNDLE_CACHE_VERSION || entry.key !== key || !entry.bundle)
82
234
  return null
83
235
  return entry.bundle
@@ -119,12 +271,14 @@ export async function getAnatomy(id: string, rebuild = false): Promise<DomainAna
119
271
  const h = getDomain(id)
120
272
  if (!h) return null
121
273
  if (!rebuild && anatomies.has(id)) return anatomies.get(id)!
122
- const anatomy = resolveClientPackage(h.root, rebuild).then((client) =>
123
- buildAnatomy({
124
- root: h.root,
125
- schemaDirName: h.schemaDirName,
126
- clientDir: client.status === 'available' ? client.sourceDir : undefined,
127
- }),
274
+ const anatomy = Promise.all([resolveClientPackage(h.root, rebuild), getBundle(id, rebuild)]).then(
275
+ ([client, bundle]) =>
276
+ buildAnatomy({
277
+ root: h.root,
278
+ schemaDirName: h.schemaDirName,
279
+ clientDir: client.status === 'available' ? client.sourceDir : undefined,
280
+ canonicalViews: bundle?.ir?.format === 'astrale.dsl' ? (bundle.ir.views ?? {}) : undefined,
281
+ }),
128
282
  )
129
283
  anatomies.set(id, anatomy)
130
284
  return anatomy
@@ -0,0 +1,494 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import type { StudioSchemaBundle, ViewInfo } from '../shared/types'
7
+
8
+ import { STUDIO_CLI_DESCRIPTOR_ENV } from './cli'
9
+ import { activeInstanceName, listInstances, setActiveInstance } from './instances/active'
10
+ import { readRememberedTarget } from './views/selection-repository'
11
+ import { launchViewSession } from './views/session'
12
+ import { listViewTargets } from './views/target'
13
+ import { applyUpdates, getUpdates } from './workspace/updates'
14
+
15
+ interface FakeResponse {
16
+ stdout?: unknown
17
+ stderr?: unknown
18
+ exitCode?: number
19
+ }
20
+
21
+ interface FakeCall {
22
+ args: string[]
23
+ cwd: string
24
+ }
25
+
26
+ const priorDescriptor = process.env[STUDIO_CLI_DESCRIPTOR_ENV]
27
+ const roots: string[] = []
28
+
29
+ afterEach(() => {
30
+ if (priorDescriptor === undefined) delete process.env[STUDIO_CLI_DESCRIPTOR_ENV]
31
+ else process.env[STUDIO_CLI_DESCRIPTOR_ENV] = priorDescriptor
32
+ while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
33
+ })
34
+
35
+ function exactArgs(args: string[]): string {
36
+ return JSON.stringify(args)
37
+ }
38
+
39
+ function installFakeCli(responses: Array<{ args: string[]; responses: FakeResponse[] }>): {
40
+ root: string
41
+ calls: () => FakeCall[]
42
+ } {
43
+ const root = mkdtempSync(join(tmpdir(), 'studio-cli-consumer-'))
44
+ roots.push(root)
45
+ const scenarioFile = join(root, 'scenario.json')
46
+ const callsFile = join(root, 'calls.ndjson')
47
+ const entry = join(root, 'fake-cli.ts')
48
+ const scenario = Object.fromEntries(
49
+ responses.map((rule) => [exactArgs(rule.args), rule.responses]),
50
+ )
51
+ writeFileSync(scenarioFile, JSON.stringify(scenario))
52
+ writeFileSync(
53
+ entry,
54
+ `import { appendFileSync, existsSync, readFileSync } from 'node:fs'
55
+
56
+ const scenarioFile = ${JSON.stringify(scenarioFile)}
57
+ const callsFile = ${JSON.stringify(callsFile)}
58
+ const args = process.argv.slice(2)
59
+ const prior = existsSync(callsFile)
60
+ ? readFileSync(callsFile, 'utf8').split('\\n').filter(Boolean).map((line) => JSON.parse(line))
61
+ : []
62
+ const matchingCalls = prior.filter((call) => JSON.stringify(call.args) === JSON.stringify(args)).length
63
+ appendFileSync(callsFile, JSON.stringify({ args, cwd: process.cwd() }) + '\\n')
64
+ const scenario = JSON.parse(readFileSync(scenarioFile, 'utf8'))
65
+ const responses = scenario[JSON.stringify(args)]
66
+ const response = Array.isArray(responses) ? responses[matchingCalls] : undefined
67
+ if (!response) {
68
+ process.stderr.write(JSON.stringify({ message: 'Unexpected fake CLI call', args }))
69
+ process.exit(91)
70
+ }
71
+ const render = (value) => typeof value === 'string' ? value : JSON.stringify(value)
72
+ if (response.stdout !== undefined) process.stdout.write(render(response.stdout))
73
+ if (response.stderr !== undefined) process.stderr.write(render(response.stderr))
74
+ process.exit(response.exitCode ?? 0)
75
+ `,
76
+ )
77
+ process.env[STUDIO_CLI_DESCRIPTOR_ENV] = JSON.stringify({
78
+ version: 1,
79
+ executable: process.execPath,
80
+ args: [realpathSync(entry)],
81
+ })
82
+ return {
83
+ root,
84
+ calls: () =>
85
+ existsSync(callsFile)
86
+ ? readFileSync(callsFile, 'utf8')
87
+ .split('\n')
88
+ .filter(Boolean)
89
+ .map((line) => JSON.parse(line) as FakeCall)
90
+ : [],
91
+ }
92
+ }
93
+
94
+ const activeArgs = ['instance', 'active', '--json']
95
+ const bookmarkedArgs = ['instance', 'list', '--bookmarked', '--json']
96
+ const managedArgs = ['instance', 'list', '--admin-only', '--json']
97
+
98
+ describe('instance CLI orchestration', () => {
99
+ test('reads active/list from the exact CLI and merges bookmarks with managed instances', async () => {
100
+ const fake = installFakeCli([
101
+ { args: activeArgs, responses: [{ stdout: { name: 'local' } }] },
102
+ {
103
+ args: bookmarkedArgs,
104
+ responses: [
105
+ {
106
+ stdout: {
107
+ active: 'staging',
108
+ bookmarks: [
109
+ { name: 'local', url: 'http://127.0.0.1:7200' },
110
+ { name: 'staging', url: 'https://staging.example', active: true },
111
+ ],
112
+ },
113
+ },
114
+ ],
115
+ },
116
+ {
117
+ args: managedArgs,
118
+ responses: [
119
+ {
120
+ stdout: {
121
+ instances: [
122
+ { slug: 'staging', url: 'https://duplicate.example' },
123
+ { slug: 'production', url: 'https://production.example' },
124
+ ],
125
+ },
126
+ },
127
+ ],
128
+ },
129
+ ])
130
+
131
+ expect(await activeInstanceName()).toBe('local')
132
+ expect(await listInstances()).toEqual({
133
+ active: 'staging',
134
+ instances: [
135
+ {
136
+ name: 'local',
137
+ url: 'http://127.0.0.1:7200',
138
+ active: false,
139
+ kind: 'bookmark',
140
+ },
141
+ {
142
+ name: 'staging',
143
+ url: 'https://staging.example',
144
+ active: true,
145
+ kind: 'bookmark',
146
+ },
147
+ {
148
+ name: 'production',
149
+ url: 'https://production.example',
150
+ active: false,
151
+ kind: 'managed',
152
+ },
153
+ ],
154
+ })
155
+ expect(fake.calls().map((call) => call.args)).toEqual([activeArgs, bookmarkedArgs, managedArgs])
156
+ })
157
+
158
+ test('uses the exact non-interactive instance switch flags and re-reads CLI-owned state', async () => {
159
+ const useArgs = ['instance', 'use', 'staging', '--adopt-default', '--skip-jwks-check']
160
+ const fake = installFakeCli([
161
+ { args: useArgs, responses: [{ stdout: 'Now using staging.\n' }] },
162
+ { args: activeArgs, responses: [{ stdout: { name: 'staging' } }] },
163
+ ])
164
+
165
+ expect(await setActiveInstance('staging')).toEqual({
166
+ ok: true,
167
+ active: 'staging',
168
+ output: 'Now using staging.',
169
+ })
170
+ expect(fake.calls().map((call) => call.args)).toEqual([useArgs, activeArgs])
171
+ })
172
+
173
+ test('does not invent active state from malformed or failed CLI output', async () => {
174
+ const fake = installFakeCli([
175
+ {
176
+ args: activeArgs,
177
+ responses: [
178
+ { stdout: 'not-json' },
179
+ {
180
+ stdout: { name: 'must-not-be-used' },
181
+ stderr: 'Instance store is unavailable.',
182
+ exitCode: 7,
183
+ },
184
+ ],
185
+ },
186
+ ])
187
+
188
+ expect(await activeInstanceName()).toBeNull()
189
+ expect(await activeInstanceName()).toBeNull()
190
+ expect(fake.calls().map((call) => call.args)).toEqual([activeArgs, activeArgs])
191
+ })
192
+ })
193
+
194
+ describe('workspace update CLI orchestration', () => {
195
+ test('accepts stale exit 10 and applies updates in the domain root', async () => {
196
+ const checkArgs = ['update', '--check', '--json']
197
+ const applyArgs = ['update', '--yes']
198
+ const fake = installFakeCli([
199
+ {
200
+ args: checkArgs,
201
+ responses: [
202
+ {
203
+ stdout: {
204
+ stale: true,
205
+ cli: {
206
+ stale: true,
207
+ managed: true,
208
+ current: '0.7.0',
209
+ latest: '0.8.0',
210
+ channel: 'latest',
211
+ },
212
+ sdk: {
213
+ stale: true,
214
+ inProject: true,
215
+ outdated: [{ pkg: '@astrale-os/sdk', current: '0.5.0', latest: '0.6.0' }],
216
+ },
217
+ },
218
+ exitCode: 10,
219
+ },
220
+ ],
221
+ },
222
+ {
223
+ args: applyArgs,
224
+ responses: [{ stdout: 'CLI updated.\n', stderr: 'SDK updated.\n' }],
225
+ },
226
+ ])
227
+ const domainRoot = realpathSync(fake.root)
228
+
229
+ expect(await getUpdates(domainRoot)).toEqual({
230
+ stale: true,
231
+ cli: {
232
+ stale: true,
233
+ managed: true,
234
+ current: '0.7.0',
235
+ latest: '0.8.0',
236
+ channel: 'latest',
237
+ },
238
+ sdk: {
239
+ stale: true,
240
+ inProject: true,
241
+ outdated: [{ pkg: '@astrale-os/sdk', current: '0.5.0', latest: '0.6.0' }],
242
+ },
243
+ })
244
+ expect(await applyUpdates(domainRoot)).toEqual({
245
+ ok: true,
246
+ output: 'CLI updated.\nSDK updated.',
247
+ })
248
+ expect(fake.calls()).toEqual([
249
+ { args: checkArgs, cwd: domainRoot },
250
+ { args: applyArgs, cwd: domainRoot },
251
+ ])
252
+ })
253
+
254
+ test('hides the badge when check output is malformed or fails', async () => {
255
+ const checkArgs = ['update', '--check', '--json']
256
+ const fake = installFakeCli([
257
+ {
258
+ args: checkArgs,
259
+ responses: [
260
+ { stdout: { stale: true, cli: {}, sdk: {} }, exitCode: 10 },
261
+ {
262
+ stdout: {
263
+ stale: true,
264
+ cli: { stale: true, managed: true },
265
+ sdk: {
266
+ stale: true,
267
+ inProject: true,
268
+ outdated: [{ pkg: '@astrale-os/sdk', current: '0.5.0', latest: '0.6.0' }],
269
+ },
270
+ },
271
+ stderr: 'Registry offline.',
272
+ exitCode: 7,
273
+ },
274
+ ],
275
+ },
276
+ ])
277
+ const expected = {
278
+ stale: false,
279
+ cli: { stale: false, managed: true },
280
+ sdk: { stale: false, inProject: false, outdated: [] },
281
+ }
282
+
283
+ expect(await getUpdates(fake.root)).toEqual(expected)
284
+ expect(await getUpdates(fake.root)).toEqual(expected)
285
+ const domainRoot = realpathSync(fake.root)
286
+ expect(fake.calls()).toEqual([
287
+ { args: checkArgs, cwd: domainRoot },
288
+ { args: checkArgs, cwd: domainRoot },
289
+ ])
290
+ })
291
+ })
292
+
293
+ const view = {
294
+ slug: 'issue-detail',
295
+ kind: 'unknown',
296
+ viewFor: 'Issue',
297
+ } satisfies ViewInfo
298
+
299
+ const bundle = {
300
+ ir: {
301
+ views: {
302
+ 'issue-detail': {
303
+ name: 'issue-detail',
304
+ auth: 'required',
305
+ target: {
306
+ kind: 'definition',
307
+ definitions: [{ origin: 'issues.example.dev', kind: 'class', name: 'Issue' }],
308
+ },
309
+ },
310
+ },
311
+ },
312
+ } as unknown as StudioSchemaBundle
313
+
314
+ const queryArgs = [
315
+ 'query',
316
+ '--definition',
317
+ '/:issues.example.dev:class.Issue',
318
+ '--limit',
319
+ '201',
320
+ '--json',
321
+ '-i',
322
+ 'staging',
323
+ ]
324
+
325
+ const targetQuery = {
326
+ graph: {
327
+ nodes: [
328
+ {
329
+ id: 'issue-1',
330
+ props: {
331
+ 'kernel.astrale.ai:interface.Named.property.name': 'First issue',
332
+ },
333
+ },
334
+ ],
335
+ },
336
+ }
337
+
338
+ describe('View CLI orchestration', () => {
339
+ test('queries exact target coordinates and preserves malformed/nonzero failures', async () => {
340
+ const fake = installFakeCli([
341
+ {
342
+ args: queryArgs,
343
+ responses: [
344
+ { stdout: targetQuery },
345
+ { stdout: { graph: { nodes: 'invalid' } } },
346
+ { stderr: { message: 'Instance is offline.' }, exitCode: 7 },
347
+ ],
348
+ },
349
+ ])
350
+
351
+ expect(
352
+ await listViewTargets(fake.root, 'issues.example.dev', view, bundle, 'staging', 2000),
353
+ ).toEqual({
354
+ status: 'available',
355
+ items: [
356
+ {
357
+ id: 'issue-1',
358
+ ref: '@issue-1',
359
+ className: 'Issue',
360
+ classOrigin: 'issues.example.dev',
361
+ label: 'First issue',
362
+ },
363
+ ],
364
+ selected: null,
365
+ stale: null,
366
+ truncated: false,
367
+ })
368
+ expect(
369
+ await listViewTargets(fake.root, 'issues.example.dev', view, bundle, 'staging', 2000),
370
+ ).toMatchObject({ status: 'unavailable', items: [] })
371
+ expect(
372
+ await listViewTargets(fake.root, 'issues.example.dev', view, bundle, 'staging', 2000),
373
+ ).toMatchObject({ status: 'unavailable', reason: 'Instance is offline.' })
374
+ expect(fake.calls().map((call) => call.args)).toEqual([queryArgs, queryArgs, queryArgs])
375
+ })
376
+
377
+ test('launches against the selected target, trusts route.href, and remembers the target', async () => {
378
+ const launchArgs = [
379
+ 'view',
380
+ '/:issues.example.dev:view.issue-detail',
381
+ '--target',
382
+ '@issue-1',
383
+ '--no-open',
384
+ '--json',
385
+ '-i',
386
+ 'staging',
387
+ ]
388
+ const fake = installFakeCli([
389
+ { args: activeArgs, responses: [{ stdout: { name: 'staging' } }] },
390
+ { args: queryArgs, responses: [{ stdout: targetQuery }] },
391
+ {
392
+ args: launchArgs,
393
+ responses: [
394
+ {
395
+ stdout: {
396
+ session: {
397
+ id: 'v-a1b2',
398
+ pageUrl: 'http://127.0.0.1:4419/s/nonce/',
399
+ view: { route: { href: 'https://shell.example.dev/views/issue-1' } },
400
+ },
401
+ },
402
+ },
403
+ ],
404
+ },
405
+ ])
406
+
407
+ expect(
408
+ await launchViewSession(
409
+ fake.root,
410
+ 'issues.example.dev',
411
+ view,
412
+ bundle,
413
+ { targetId: 'issue-1' },
414
+ 2000,
415
+ ),
416
+ ).toEqual({
417
+ status: 'ready',
418
+ sessionId: 'v-a1b2',
419
+ pageUrl: 'http://127.0.0.1:4419/s/nonce/',
420
+ viewUrl: 'https://shell.example.dev/views/issue-1',
421
+ target: {
422
+ id: 'issue-1',
423
+ ref: '@issue-1',
424
+ className: 'Issue',
425
+ classOrigin: 'issues.example.dev',
426
+ label: 'First issue',
427
+ },
428
+ })
429
+ expect(readRememberedTarget(fake.root, 'staging', 'issue-detail')).toEqual({
430
+ id: 'issue-1',
431
+ className: 'Issue',
432
+ classOrigin: 'issues.example.dev',
433
+ label: 'First issue',
434
+ })
435
+ expect(fake.calls().map((call) => call.args)).toEqual([activeArgs, queryArgs, launchArgs])
436
+ })
437
+
438
+ test('does not expose a session when the CLI payload is malformed or exits nonzero', async () => {
439
+ const standalone = { slug: 'dashboard', kind: 'unknown' } satisfies ViewInfo
440
+ const launchArgs = [
441
+ 'view',
442
+ '/:issues.example.dev:view.dashboard',
443
+ '--no-open',
444
+ '--json',
445
+ '-i',
446
+ 'staging',
447
+ ]
448
+ const fake = installFakeCli([
449
+ {
450
+ args: activeArgs,
451
+ responses: [{ stdout: { name: 'staging' } }, { stdout: { name: 'staging' } }],
452
+ },
453
+ {
454
+ args: launchArgs,
455
+ responses: [
456
+ { stdout: { session: { id: 'v-no-route', pageUrl: 'http://127.0.0.1/' } } },
457
+ {
458
+ stdout: {
459
+ session: {
460
+ id: 'v-must-not-open',
461
+ pageUrl: 'http://127.0.0.1/forbidden',
462
+ view: { route: { href: 'https://shell.example.dev/forbidden' } },
463
+ },
464
+ },
465
+ stderr: 'Permission denied.',
466
+ exitCode: 7,
467
+ },
468
+ ],
469
+ },
470
+ ])
471
+
472
+ const malformed = await launchViewSession(
473
+ fake.root,
474
+ 'issues.example.dev',
475
+ standalone,
476
+ null,
477
+ {},
478
+ 2000,
479
+ )
480
+ expect(malformed.status).toBe('unavailable')
481
+ if (malformed.status !== 'unavailable')
482
+ throw new Error('expected the malformed session to fail')
483
+ expect(malformed.reason).toContain('v-no-route')
484
+ expect(
485
+ await launchViewSession(fake.root, 'issues.example.dev', standalone, null, {}, 2000),
486
+ ).toEqual({ status: 'unavailable', reason: 'Permission denied.' })
487
+ expect(fake.calls().map((call) => call.args)).toEqual([
488
+ activeArgs,
489
+ launchArgs,
490
+ activeArgs,
491
+ launchArgs,
492
+ ])
493
+ })
494
+ })