@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,88 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import {
7
+ decodeJsonObject,
8
+ runStudioCliJson,
9
+ STUDIO_CLI_DESCRIPTOR_ENV,
10
+ studioCliCommand,
11
+ } from './cli'
12
+
13
+ const priorDescriptor = process.env[STUDIO_CLI_DESCRIPTOR_ENV]
14
+ const roots: string[] = []
15
+
16
+ afterEach(() => {
17
+ if (priorDescriptor === undefined) delete process.env[STUDIO_CLI_DESCRIPTOR_ENV]
18
+ else process.env[STUDIO_CLI_DESCRIPTOR_ENV] = priorDescriptor
19
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
20
+ })
21
+
22
+ test('exact command rejects missing and incompatible descriptors instead of using PATH', () => {
23
+ delete process.env[STUDIO_CLI_DESCRIPTOR_ENV]
24
+ expect(() => studioCliCommand(['instance', 'active'])).toThrow(
25
+ 'launch Studio through this Astrale CLI',
26
+ )
27
+ expect(() =>
28
+ studioCliCommand(
29
+ ['instance', 'active'],
30
+ JSON.stringify({ version: 2, executable: '/usr/bin/astrale', args: [] }),
31
+ ),
32
+ ).toThrow('launch Studio through this Astrale CLI')
33
+ })
34
+
35
+ test('machine runner uses the exact descriptor, appends json mode, and versions decoded output', async () => {
36
+ const root = mkdtempSync(join(tmpdir(), 'studio-cli-runner-'))
37
+ roots.push(root)
38
+ mkdirSync(root, { recursive: true })
39
+ const entry = join(root, 'fake-cli.ts')
40
+ writeFileSync(entry, `process.stdout.write(JSON.stringify({ command: process.argv.slice(2) }))\n`)
41
+ process.env[STUDIO_CLI_DESCRIPTOR_ENV] = JSON.stringify({
42
+ version: 1,
43
+ executable: process.execPath,
44
+ args: [realpathSync(entry)],
45
+ })
46
+
47
+ const result = await runStudioCliJson(
48
+ ['query', '--definition', '/:example.dev:class.Issue'],
49
+ decodeJsonObject,
50
+ )
51
+
52
+ expect(result).toMatchObject({
53
+ version: 1,
54
+ ok: true,
55
+ exitCode: 0,
56
+ timedOut: false,
57
+ data: {
58
+ command: ['query', '--definition', '/:example.dev:class.Issue', '--json'],
59
+ },
60
+ })
61
+ })
62
+
63
+ test('machine runner decodes structured CLI errors without treating them as success', async () => {
64
+ const root = mkdtempSync(join(tmpdir(), 'studio-cli-error-'))
65
+ roots.push(root)
66
+ const entry = join(root, 'fake-cli.ts')
67
+ writeFileSync(
68
+ entry,
69
+ `process.stderr.write(JSON.stringify({ error: 'NOT_FOUND', message: 'Missing instance.' }))
70
+ process.exit(7)
71
+ `,
72
+ )
73
+ process.env[STUDIO_CLI_DESCRIPTOR_ENV] = JSON.stringify({
74
+ version: 1,
75
+ executable: process.execPath,
76
+ args: [realpathSync(entry)],
77
+ })
78
+
79
+ const result = await runStudioCliJson(['instance', 'active'], decodeJsonObject)
80
+
81
+ expect(result).toMatchObject({
82
+ version: 1,
83
+ ok: false,
84
+ exitCode: 7,
85
+ detail: 'Missing instance.',
86
+ data: { error: 'NOT_FOUND', message: 'Missing instance.' },
87
+ })
88
+ })
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Exact Astrale CLI bridge for Studio.
3
+ *
4
+ * The launching `astrale studio` process passes its runtime + entrypoint through a versioned
5
+ * descriptor. Every Studio CLI delegation uses that exact pair; this module never falls back to
6
+ * resolving an unrelated `astrale` binary from PATH.
7
+ */
8
+ import { isAbsolute } from 'node:path'
9
+
10
+ import { asJsonRecord, asStringArray, parseJson as parseUntrustedJson } from './json'
11
+
12
+ export const STUDIO_CLI_DESCRIPTOR_ENV = 'DOMAIN_STUDIO_CLI_DESCRIPTOR'
13
+
14
+ export interface StudioCliDescriptorV1 {
15
+ version: 1
16
+ executable: string
17
+ args: string[]
18
+ }
19
+
20
+ export type StudioCliDecoder<T> = (value: unknown) => T | null
21
+
22
+ export interface StudioCliMachineResult<T> {
23
+ version: 1
24
+ ok: boolean
25
+ data: T | null
26
+ value: unknown | null
27
+ detail: string
28
+ exitCode: number
29
+ stdout: string
30
+ stderr: string
31
+ timedOut: boolean
32
+ }
33
+
34
+ export interface StudioCliTextResult {
35
+ version: 1
36
+ ok: boolean
37
+ detail: string
38
+ exitCode: number
39
+ stdout: string
40
+ stderr: string
41
+ timedOut: boolean
42
+ }
43
+
44
+ interface RunOptions {
45
+ cwd?: string
46
+ timeoutMs?: number
47
+ acceptedExitCodes?: readonly number[]
48
+ }
49
+
50
+ interface CapturedProcess {
51
+ exitCode: number
52
+ stdout: string
53
+ stderr: string
54
+ timedOut: boolean
55
+ spawnError?: string
56
+ }
57
+
58
+ export function decodeStudioCliDescriptor(value: string | undefined): StudioCliDescriptorV1 | null {
59
+ if (!value) return null
60
+ const parsed = asJsonRecord(parseUntrustedJson(value))
61
+ const args = asStringArray(parsed?.args)
62
+ if (
63
+ parsed?.version !== 1 ||
64
+ typeof parsed.executable !== 'string' ||
65
+ !isAbsolute(parsed.executable) ||
66
+ !args ||
67
+ args.length > 1 ||
68
+ !args.every((arg) => isAbsolute(arg))
69
+ ) {
70
+ return null
71
+ }
72
+ return { version: 1, executable: parsed.executable, args }
73
+ }
74
+
75
+ export function studioCliCommand(
76
+ args: readonly string[],
77
+ encodedDescriptor = process.env[STUDIO_CLI_DESCRIPTOR_ENV],
78
+ ): string[] {
79
+ const descriptor = decodeStudioCliDescriptor(encodedDescriptor)
80
+ if (!descriptor) {
81
+ throw new Error(
82
+ `${STUDIO_CLI_DESCRIPTOR_ENV} is missing or invalid; launch Studio through this Astrale CLI`,
83
+ )
84
+ }
85
+ return [descriptor.executable, ...descriptor.args, ...args]
86
+ }
87
+
88
+ export function decodeJsonObject(value: unknown): Record<string, unknown> | null {
89
+ return asJsonRecord(value) ?? null
90
+ }
91
+
92
+ export function conciseCliFailure(raw: string): string | undefined {
93
+ const lines = raw
94
+ .split(/\r?\n/)
95
+ .map((line) => line.trim())
96
+ .filter(Boolean)
97
+ const error = lines.find((line) => /^[A-Za-z_$][\w$]*(?:Error|Exception):\s+\S/.test(line))
98
+ if (error) return error.slice(0, 600)
99
+ const explicit = lines.find((line) => /^(?:error|failed):\s+\S/i.test(line))
100
+ if (explicit) return explicit.slice(0, 600)
101
+ const useful = lines.find(
102
+ (line) =>
103
+ !/^\d+\s+\|/.test(line) &&
104
+ line !== '^' &&
105
+ !/^at\s/.test(line) &&
106
+ !/^Bun v\d/.test(line) &&
107
+ !/^details?:\s*[{[]?$/i.test(line),
108
+ )
109
+ return useful?.slice(0, 600)
110
+ }
111
+
112
+ function nonEmptyString(value: unknown): string | undefined {
113
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
114
+ }
115
+
116
+ function resultDetail(value: unknown, stdout: string, stderr: string): string {
117
+ const object = decodeJsonObject(value)
118
+ return (
119
+ nonEmptyString(object?.message) ??
120
+ nonEmptyString(object?.error) ??
121
+ conciseCliFailure(stderr) ??
122
+ conciseCliFailure(stdout) ??
123
+ ''
124
+ )
125
+ }
126
+
127
+ function parseJson(text: string): unknown | null {
128
+ const normalized = text.replace(/^\uFEFF/, '').trim()
129
+ if (!normalized) return null
130
+ try {
131
+ return JSON.parse(normalized)
132
+ } catch {
133
+ return null
134
+ }
135
+ }
136
+
137
+ async function captureStudioCli(
138
+ args: readonly string[],
139
+ options: RunOptions,
140
+ ): Promise<CapturedProcess> {
141
+ let command: string[]
142
+ try {
143
+ command = studioCliCommand(args)
144
+ } catch (error) {
145
+ return {
146
+ exitCode: -1,
147
+ stdout: '',
148
+ stderr: '',
149
+ timedOut: false,
150
+ spawnError: error instanceof Error ? error.message : String(error),
151
+ }
152
+ }
153
+
154
+ try {
155
+ const proc = Bun.spawn(command, {
156
+ ...(options.cwd ? { cwd: options.cwd } : {}),
157
+ stdout: 'pipe',
158
+ stderr: 'pipe',
159
+ })
160
+ let timedOut = false
161
+ const timer = options.timeoutMs
162
+ ? setTimeout(() => {
163
+ timedOut = true
164
+ try {
165
+ proc.kill()
166
+ } catch {
167
+ // Already exited.
168
+ }
169
+ }, options.timeoutMs)
170
+ : undefined
171
+ try {
172
+ const [stdout, stderr, exitCode] = await Promise.all([
173
+ new Response(proc.stdout).text(),
174
+ new Response(proc.stderr).text(),
175
+ proc.exited,
176
+ ])
177
+ return { exitCode, stdout, stderr, timedOut }
178
+ } finally {
179
+ if (timer) clearTimeout(timer)
180
+ }
181
+ } catch (error) {
182
+ return {
183
+ exitCode: -1,
184
+ stdout: '',
185
+ stderr: '',
186
+ timedOut: false,
187
+ spawnError: error instanceof Error ? error.message : String(error),
188
+ }
189
+ }
190
+ }
191
+
192
+ export async function runStudioCliJson<T>(
193
+ args: readonly string[],
194
+ decoder: StudioCliDecoder<T>,
195
+ options: RunOptions = {},
196
+ ): Promise<StudioCliMachineResult<T>> {
197
+ const machineArgs = args.includes('--json') ? [...args] : [...args, '--json']
198
+ const captured = await captureStudioCli(machineArgs, options)
199
+ const value = parseJson(captured.stdout) ?? parseJson(captured.stderr)
200
+ const data = value === null ? null : decoder(value)
201
+ const accepted = options.acceptedExitCodes ?? [0]
202
+ const detail =
203
+ captured.spawnError ??
204
+ (captured.timedOut
205
+ ? `Astrale CLI timed out after ${options.timeoutMs ?? 0}ms`
206
+ : resultDetail(value, captured.stdout, captured.stderr))
207
+ return {
208
+ version: 1,
209
+ ok: accepted.includes(captured.exitCode) && data !== null && !captured.timedOut,
210
+ data,
211
+ value,
212
+ detail,
213
+ exitCode: captured.exitCode,
214
+ stdout: captured.stdout,
215
+ stderr: captured.stderr,
216
+ timedOut: captured.timedOut,
217
+ }
218
+ }
219
+
220
+ export async function runStudioCliText(
221
+ args: readonly string[],
222
+ options: RunOptions = {},
223
+ ): Promise<StudioCliTextResult> {
224
+ const captured = await captureStudioCli(args, options)
225
+ const accepted = options.acceptedExitCodes ?? [0]
226
+ const detail =
227
+ captured.spawnError ??
228
+ (captured.timedOut
229
+ ? `Astrale CLI timed out after ${options.timeoutMs ?? 0}ms`
230
+ : (conciseCliFailure(captured.stderr) ?? conciseCliFailure(captured.stdout) ?? ''))
231
+ return {
232
+ version: 1,
233
+ ok: accepted.includes(captured.exitCode) && !captured.timedOut,
234
+ detail,
235
+ exitCode: captured.exitCode,
236
+ stdout: captured.stdout,
237
+ stderr: captured.stderr,
238
+ timedOut: captured.timedOut,
239
+ }
240
+ }
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, statSync } from 'node:fs'
2
2
  import { isAbsolute, join, relative, resolve } from 'node:path'
3
3
  import { parse as parseYaml } from 'yaml'
4
4
 
5
+ import { asJsonRecord, asStringArray, parseJson } from './json'
6
+
5
7
  export type ClientPackageResolution =
6
8
  | {
7
9
  status: 'available'
@@ -203,7 +205,29 @@ function packageWorkspacePatterns(pkg?: PackageManifest): string[] {
203
205
  }
204
206
 
205
207
  function readPackage(packageFile: string): PackageManifest {
206
- return JSON.parse(readFileSync(packageFile, 'utf8')) as PackageManifest
208
+ const record = asJsonRecord(parseJson(readFileSync(packageFile, 'utf8')))
209
+ if (!record) throw new TypeError('package.json must contain an object')
210
+ const scriptsRecord = asJsonRecord(record.scripts)
211
+ const scripts = scriptsRecord
212
+ ? Object.fromEntries(
213
+ Object.entries(scriptsRecord).filter(
214
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
215
+ ),
216
+ )
217
+ : undefined
218
+ let workspaces: PackageManifest['workspaces']
219
+ const workspaceList = asStringArray(record.workspaces)
220
+ if (workspaceList) {
221
+ workspaces = workspaceList
222
+ } else {
223
+ const workspaceObject = asJsonRecord(record.workspaces)
224
+ const packages = asStringArray(workspaceObject?.packages)
225
+ if (workspaceObject) workspaces = packages ? { packages } : {}
226
+ }
227
+ return {
228
+ ...(scripts === undefined ? {} : { scripts }),
229
+ ...(workspaces === undefined ? {} : { workspaces }),
230
+ }
207
231
  }
208
232
 
209
233
  function previewScript(pkg?: PackageManifest): string | undefined {
@@ -20,8 +20,8 @@ afterEach(() => {
20
20
  while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
21
21
  })
22
22
 
23
- function fixture(entry: 'implementation.ts' | 'domain.ts'): string {
24
- const root = mkdtempSync(join(tmpdir(), 'studio-domain-layout-'))
23
+ function fixture(entry: 'implementation.ts' | 'domain.ts', parent = tmpdir()): string {
24
+ const root = mkdtempSync(join(parent, 'studio-domain-layout-'))
25
25
  roots.push(root)
26
26
  mkdirSync(join(root, 'schema'), { recursive: true })
27
27
  writeFileSync(join(root, 'astrale.config.ts'), 'export default {}\n')
@@ -65,3 +65,31 @@ test('prefers implementation.ts when both composition entries exist', () => {
65
65
 
66
66
  expect(basename(resolveDomainEntry(root)!)).toBe('implementation.ts')
67
67
  })
68
+
69
+ test('recognizes only an SDK that resolves from the Domain through an autonomous hoist', () => {
70
+ const isolatedWorkspace = mkdtempSync(join(tmpdir(), 'studio-domain-no-hoist-'))
71
+ roots.push(isolatedWorkspace)
72
+ mkdirSync(join(isolatedWorkspace, 'packages'))
73
+ const isolatedRoot = fixture('implementation.ts', join(isolatedWorkspace, 'packages'))
74
+ expect(depsInstalled(isolatedRoot)).toBe(false)
75
+
76
+ const hoistedWorkspace = mkdtempSync(join(tmpdir(), 'studio-domain-hoist-'))
77
+ roots.push(hoistedWorkspace)
78
+ mkdirSync(join(hoistedWorkspace, 'packages'))
79
+ mkdirSync(join(hoistedWorkspace, 'node_modules', '@astrale-os', 'sdk'), { recursive: true })
80
+ writeFileSync(
81
+ join(hoistedWorkspace, 'node_modules', '@astrale-os', 'sdk', 'package.json'),
82
+ JSON.stringify({
83
+ name: '@astrale-os/sdk',
84
+ type: 'module',
85
+ exports: { './schema': './schema.js' },
86
+ }),
87
+ )
88
+ writeFileSync(
89
+ join(hoistedWorkspace, 'node_modules', '@astrale-os', 'sdk', 'schema.js'),
90
+ 'export const schema = {}\n',
91
+ )
92
+ const hoistedRoot = fixture('implementation.ts', join(hoistedWorkspace, 'packages'))
93
+
94
+ expect(depsInstalled(hoistedRoot)).toBe(true)
95
+ })
@@ -83,11 +83,23 @@ export function allDomains(): DomainHandle[] {
83
83
 
84
84
  /** Does the domain have the dependency cohort required by its project layout installed? */
85
85
  export function depsInstalled(root: string): boolean {
86
- const sdk = existsSync(join(root, 'node_modules', '@astrale-os', 'sdk'))
86
+ const installed = (packageDir: string, specifier: string): boolean => {
87
+ if (existsSync(join(root, 'node_modules', '@astrale-os', packageDir))) return true
88
+ try {
89
+ Bun.resolveSync(specifier, root)
90
+ return true
91
+ } catch {
92
+ return false
93
+ }
94
+ }
95
+
96
+ // A workspace may hoist the package above the Domain root. Check both the
97
+ // conventional local link and Bun's real resolver from the Domain boundary.
98
+ const sdk = installed('sdk', '@astrale-os/sdk/schema')
87
99
  const entry = resolveDomainEntry(root)
88
100
 
89
101
  // implementation.ts is an SDK boundary by contract. Legacy domain.ts
90
102
  // projects may predate the SDK facade and depend on kernel-core directly.
91
103
  if (entry?.endsWith('implementation.ts')) return sdk
92
- return sdk || existsSync(join(root, 'node_modules', '@astrale-os', 'kernel-core'))
104
+ return sdk || installed('kernel-core', '@astrale-os/kernel-core')
93
105
  }
@@ -0,0 +1,39 @@
1
+ import { expect, test } from 'bun:test'
2
+
3
+ import { parseDotenvPreview } from './dotenv-preview'
4
+
5
+ test('environment preview mirrors the SDK declared-secrets subset without mutating process.env', () => {
6
+ const keys = ['BASE', 'EXPANDED', 'LITERAL', 'EMPTY'] as const
7
+ const before = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
8
+ const seeded = {
9
+ BASE: 'process-base',
10
+ EXPANDED: 'process-expanded',
11
+ LITERAL: 'process-literal',
12
+ EMPTY: 'process-empty',
13
+ } as const
14
+ Object.assign(process.env, seeded)
15
+
16
+ try {
17
+ const values = parseDotenvPreview(`
18
+ BASE=alpha
19
+ EXPANDED="${'${BASE}'}/beta"
20
+ LITERAL='${'${BASE}'}/beta'
21
+ export EMPTY=
22
+ # IGNORED=value
23
+ `)
24
+
25
+ expect(values).toEqual({
26
+ BASE: 'alpha',
27
+ EXPANDED: 'alpha/beta',
28
+ LITERAL: '${BASE}/beta',
29
+ EMPTY: '',
30
+ })
31
+ expect(Object.fromEntries(keys.map((key) => [key, process.env[key]]))).toEqual(seeded)
32
+ } finally {
33
+ for (const key of keys) {
34
+ const value = before[key]
35
+ if (value === undefined) delete process.env[key]
36
+ else process.env[key] = value
37
+ }
38
+ }
39
+ })
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Isolated preview of an adapter secrets file for Studio's editor.
3
+ *
4
+ * The SDK adapter remains authoritative when deploying. It is not a runtime
5
+ * dependency of the raw Studio server shipped inside the CLI package, so this
6
+ * isolated parser intentionally mirrors only the SDK's documented dotenv
7
+ * subset and never mutates process.env.
8
+ */
9
+ export function parseDotenvPreview(contents: string): Record<string, string> {
10
+ const values: Record<string, string> = {}
11
+ for (const raw of contents.split('\n')) {
12
+ const line = raw.trim()
13
+ if (!line || line.startsWith('#')) continue
14
+ const match = /^(?:export\s+)?([A-Za-z_]\w*)\s*=\s*(.*)$/.exec(line)
15
+ if (!match) continue
16
+ const key = match[1]!
17
+ let value = match[2]!.trim()
18
+ const singleQuoted = value.length >= 2 && value.startsWith("'") && value.endsWith("'")
19
+ if (singleQuoted || (value.length >= 2 && value.startsWith('"') && value.endsWith('"'))) {
20
+ value = value.slice(1, -1)
21
+ }
22
+ values[key] = singleQuoted
23
+ ? value
24
+ : value.replace(/\$\{(\w+)\}/g, (_, name: string) => values[name] ?? '')
25
+ }
26
+ return values
27
+ }
@@ -0,0 +1,53 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { readEnvModel } from './files'
7
+
8
+ const roots: string[] = []
9
+
10
+ afterEach(() => {
11
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
12
+ })
13
+
14
+ test('env editor composes typed fields with isolated config and dotenv previews', () => {
15
+ const root = mkdtempSync(join(tmpdir(), 'studio-env-preview-'))
16
+ roots.push(root)
17
+ writeFileSync(
18
+ join(root, 'env.ts'),
19
+ `export interface Env {
20
+ /** API credential. */
21
+ API_TOKEN: string
22
+ OPTIONAL_NOTE?: string
23
+ WORKER_URL: string
24
+ }`,
25
+ )
26
+ writeFileSync(
27
+ join(root, 'astrale.config.ts'),
28
+ `// astrale({ dev: { secrets: '.env.fake' } })
29
+ export default cloudflare({ dev: { secrets: '.env.dev' } })`,
30
+ )
31
+ writeFileSync(join(root, '.env.dev'), `BASE=token\nAPI_TOKEN="${'${BASE}'}-value"\nORPHAN=kept\n`)
32
+
33
+ expect(readEnvModel(root, 'dev')).toEqual({
34
+ env: 'dev',
35
+ file: '.env.dev',
36
+ configured: true,
37
+ exists: true,
38
+ adapter: 'cloudflare',
39
+ requiredMissing: 0,
40
+ rows: [
41
+ {
42
+ name: 'API_TOKEN',
43
+ value: 'token-value',
44
+ declared: true,
45
+ optional: false,
46
+ doc: 'API credential.',
47
+ },
48
+ { name: 'OPTIONAL_NOTE', value: '', declared: true, optional: true },
49
+ { name: 'BASE', value: 'token', declared: false, optional: true },
50
+ { name: 'ORPHAN', value: 'kept', declared: false, optional: true },
51
+ ],
52
+ })
53
+ })
@@ -1,5 +1,5 @@
1
1
  /**
2
- * env.ts the env-vars editor backend. Reconciles a domain's `.env.<env>` file
2
+ * Environment file editor. Reconciles a domain's `.env.<env>` file
3
3
  * (the actual secrets/vars, dotenv format) against the `Env` interface in
4
4
  * `env.ts` (the typed contract, parsed by buildEnvFields), and is the ONE place
5
5
  * the otherwise read-only studio writes a domain file — the explicit exception
@@ -16,6 +16,8 @@ import { join, resolve } from 'node:path'
16
16
  import type { EnvFileModel, EnvName, EnvVarRow } from '../../shared/types'
17
17
 
18
18
  import { buildEnvFields } from '../introspect/anatomy-extras'
19
+ import { readConfigPreview } from '../introspect/config-preview'
20
+ import { parseDotenvPreview } from './dotenv-preview'
19
21
 
20
22
  const ENV_NAMES: EnvName[] = ['dev', 'prod']
21
23
  export const isEnvName = (v: unknown): v is EnvName =>
@@ -25,55 +27,12 @@ export const isEnvName = (v: unknown): v is EnvName =>
25
27
  * and every domain uses. The filename is fixed, so no path-traversal is possible. */
26
28
  const envFileName = (env: EnvName) => `.env.${env}`
27
29
 
28
- /** Mirror of the SDK's minimal dotenv parser (`@astrale-os/sdk` cli/dotenv) so the
29
- * studio reads values exactly as the deploy path will: `#` comments, `export`,
30
- * quotes (single = literal), `${VAR}` interpolation against earlier keys. */
31
- function parseDotenv(contents: string): Record<string, string> {
32
- const out: Record<string, string> = {}
33
- for (const raw of contents.split('\n')) {
34
- const line = raw.trim()
35
- if (!line || line.startsWith('#')) continue
36
- const m = /^(?:export\s+)?([A-Za-z_]\w*)\s*=\s*(.*)$/.exec(line)
37
- if (!m) continue
38
- let value = m[2].trim()
39
- const single = value.length >= 2 && value.startsWith("'") && value.endsWith("'")
40
- if (single || (value.length >= 2 && value.startsWith('"') && value.endsWith('"')))
41
- value = value.slice(1, -1)
42
- out[m[1]] = single ? value : value.replace(/\$\{(\w+)\}/g, (_, n: string) => out[n] ?? '')
43
- }
44
- return out
45
- }
46
-
47
- /** astrale.config.ts with comments stripped — so a COMMENTED-OUT adapter block
48
- * (e.g. the scaffold's "swap to cloudflare" example) never reads as active config.
49
- * The `[^:]` guard keeps `https://` in URLs from being mistaken for a line comment. */
50
- function configText(root: string): string {
51
- let s: string
52
- try {
53
- s = readFileSync(join(root, 'astrale.config.ts'), 'utf8')
54
- } catch {
55
- return ''
56
- }
57
- return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
58
- }
59
-
60
- /** Is this `.env.<env>` wired into astrale.config's ACTIVE `secrets:`? (vs convention only) */
61
- function isConfigured(config: string, env: EnvName): boolean {
62
- return new RegExp(`secrets\\s*:\\s*['"]\\.env\\.${env}['"]`).test(config)
63
- }
64
-
65
- function adapterOf(config: string): EnvFileModel['adapter'] {
66
- if (/\bastrale\s*\(/.test(config)) return 'astrale'
67
- if (/\bcloudflare\s*\(/.test(config)) return 'cloudflare'
68
- return 'unknown'
69
- }
70
-
71
30
  /** Build the merged model for one env: env.ts fillable fields ⨯ the `.env.<env>` values. */
72
31
  export function readEnvModel(root: string, env: EnvName): EnvFileModel {
73
32
  const file = envFileName(env)
74
33
  const abs = join(root, file)
75
34
  const exists = existsSync(abs)
76
- const values = exists ? parseDotenv(readFileSync(abs, 'utf8')) : {}
35
+ const values = exists ? parseDotenvPreview(readFileSync(abs, 'utf8')) : {}
77
36
 
78
37
  // env.ts contract — only fillable (non-binding) fields; bindings are adapter-injected.
79
38
  const declared = buildEnvFields(root).filter((f) => f.secret)
@@ -92,13 +51,13 @@ export function readEnvModel(root: string, env: EnvName): EnvFileModel {
92
51
  }
93
52
 
94
53
  const requiredMissing = rows.filter((r) => r.declared && !r.optional && r.value === '').length
95
- const config = configText(root)
54
+ const config = readConfigPreview(root)
96
55
  return {
97
56
  env,
98
57
  file,
99
- configured: isConfigured(config, env),
58
+ configured: config.configuredSecretFiles.includes(file),
100
59
  exists,
101
- adapter: adapterOf(config),
60
+ adapter: config.adapter,
102
61
  rows,
103
62
  requiredMissing,
104
63
  }