@astrale-os/cli 1.0.0-beta.1 → 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.
- package/README.md +7 -11
- package/dist/astrale.js +3737 -3523
- package/dist/public/connect-core.js +396 -392
- package/dist/public/keys/index.js +371 -378
- package/dist/public/paths/index.js +371 -378
- package/dist/types/lib/idp.d.ts +6 -0
- package/dist/types/lib/meta.d.ts +2 -2
- package/package.json +6 -4
- package/src/admin/binding.ts +168 -0
- package/src/admin/catalog/__tests__/client.test.ts +2 -2
- package/src/admin/catalog/client.ts +4 -9
- package/src/admin/instance/__tests__/client.test.ts +2 -2
- package/src/admin/instance/client.ts +6 -11
- package/src/commands/__tests__/auth-login.test.ts +54 -1
- package/src/commands/__tests__/call.test.ts +5 -1
- package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
- package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
- package/src/commands/__tests__/domain-list.test.ts +83 -4
- package/src/commands/__tests__/install-identity-override.test.ts +30 -19
- package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
- package/src/commands/__tests__/update.test.ts +23 -16
- package/src/commands/domain/install.ts +11 -15
- package/src/commands/domain/list.ts +17 -12
- package/src/commands/studio.ts +20 -0
- package/src/commands/update.ts +9 -5
- package/src/connection/__tests__/errors.test.ts +16 -12
- package/src/connection/__tests__/exchange.test.ts +6 -5
- package/src/connection/exchange.ts +5 -4
- package/src/lib/__tests__/domain-publication.test.ts +144 -0
- package/src/lib/__tests__/idp-session.test.ts +22 -0
- package/src/lib/__tests__/idp.test.ts +2 -0
- package/src/lib/__tests__/instance.test.ts +6 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
- package/src/lib/__tests__/update.test.ts +12 -0
- package/src/lib/domain-publication.ts +102 -0
- package/src/lib/idp.ts +5 -1
- package/src/lib/instance.ts +6 -1
- package/src/lib/login-flow.ts +13 -3
- package/src/lib/meta.ts +2 -2
- package/src/lib/update.ts +4 -4
- package/src/program/__tests__/program.test.ts +1 -1
- package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
- package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
- package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
- package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/package.json +3 -1
- package/studio/server/agent/bridge/stdio.ts +8 -1
- package/studio/server/agent/conversation.test.ts +10 -5
- package/studio/server/agent/conversation.ts +56 -8
- package/studio/server/agent/harness/codex/loadout.ts +18 -21
- package/studio/server/agent/harness/gateway/config.ts +20 -18
- package/studio/server/agent/harness/selection.ts +9 -1
- package/studio/server/agent/prompts/system.ts +3 -2
- package/studio/server/agent/prompts/turn.ts +13 -4
- package/studio/server/agent/run/completion.ts +2 -2
- package/studio/server/agent/run/preparation.ts +6 -5
- package/studio/server/agent/run/transcript.test.ts +35 -1
- package/studio/server/agent/run/transcript.ts +169 -3
- package/studio/server/agent/run/usage.test.ts +24 -0
- package/studio/server/agent/run/usage.ts +26 -1
- package/studio/server/api/canvas.ts +44 -0
- package/studio/server/api/comments.ts +85 -0
- package/studio/server/api/context.ts +61 -0
- package/studio/server/api/deployment.ts +39 -0
- package/studio/server/api/documents.ts +55 -0
- package/studio/server/api/domain.ts +59 -0
- package/studio/server/api/http.ts +45 -0
- package/studio/server/api/project.ts +57 -0
- package/studio/server/api/schema.ts +12 -0
- package/studio/server/api/views.ts +57 -0
- package/studio/server/api/workspace.ts +60 -0
- package/studio/server/api.test.ts +147 -0
- package/studio/server/api.ts +20 -422
- package/studio/server/cache.test.ts +111 -0
- package/studio/server/cache.ts +163 -9
- package/studio/server/cli-consumers.test.ts +494 -0
- package/studio/server/cli.test.ts +88 -0
- package/studio/server/cli.ts +240 -0
- package/studio/server/client-package.ts +25 -1
- package/studio/server/domain.test.ts +30 -2
- package/studio/server/domain.ts +14 -2
- package/studio/server/environment/dotenv-preview.test.ts +39 -0
- package/studio/server/environment/dotenv-preview.ts +27 -0
- package/studio/server/environment/files.test.ts +53 -0
- package/studio/server/{state/env.ts → environment/files.ts} +7 -48
- package/studio/server/{state → handoff}/copy.ts +21 -6
- package/studio/server/handoff/service.test.ts +22 -0
- package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
- package/studio/server/instances/active.ts +72 -0
- package/studio/server/instances/deploy-record.test.ts +31 -0
- package/studio/server/instances/deploy-record.ts +37 -0
- package/studio/server/instances/deploy.ts +56 -0
- package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
- package/studio/server/instances/probe.ts +67 -0
- package/studio/server/instances/status.test.ts +12 -0
- package/studio/server/instances/status.ts +49 -0
- package/studio/server/introspect/anatomy/client-tree.ts +57 -0
- package/studio/server/introspect/anatomy/env-fields.ts +66 -0
- package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
- package/studio/server/introspect/anatomy/source.ts +181 -0
- package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
- package/studio/server/introspect/anatomy/views/routes.ts +105 -0
- package/studio/server/introspect/anatomy/views.ts +58 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -4
- package/studio/server/introspect/anatomy-extras.ts +7 -726
- package/studio/server/introspect/anatomy.ts +46 -19
- package/studio/server/introspect/bundle.ts +15 -6
- package/studio/server/introspect/canonical-schema.test.ts +79 -0
- package/studio/server/introspect/canonical-schema.ts +80 -18
- package/studio/server/introspect/config-preview.test.ts +32 -0
- package/studio/server/introspect/config-preview.ts +119 -0
- package/studio/server/introspect/diff.test.ts +7 -11
- package/studio/server/introspect/diff.ts +28 -37
- package/studio/server/introspect/extractor.ts +8 -6
- package/studio/server/introspect/hash.ts +5 -2
- package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
- package/studio/server/introspect/revision.test.ts +54 -0
- package/studio/server/introspect/revision.ts +49 -0
- package/studio/server/introspect/runtime.test.ts +63 -5
- package/studio/server/introspect/runtime.ts +49 -3
- package/studio/server/introspect/schema-ir-json.ts +181 -0
- package/studio/server/introspect/source-overlay/annotations.ts +14 -0
- package/studio/server/introspect/source-overlay/handlers.ts +681 -0
- package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
- package/studio/server/introspect/source-overlay/project.ts +248 -0
- package/studio/server/introspect/source-overlay/spans.ts +360 -0
- package/studio/server/json.ts +46 -0
- package/studio/server/lifecycle.ts +5 -1
- package/studio/server/sse.test.ts +26 -0
- package/studio/server/sse.ts +4 -1
- package/studio/server/state/baseline.test.ts +212 -1
- package/studio/server/state/baseline.ts +145 -29
- package/studio/server/state/comments.test.ts +66 -1
- package/studio/server/state/comments.ts +173 -10
- package/studio/server/state/context.ts +34 -2
- package/studio/server/state/documents.ts +42 -1
- package/studio/server/state/integrations.ts +27 -1
- package/studio/server/state/layout.test.ts +34 -0
- package/studio/server/state/layout.ts +44 -17
- package/studio/server/state/settings.ts +29 -22
- package/studio/server/state/store.test.ts +122 -0
- package/studio/server/state/store.ts +66 -17
- package/studio/server/state/visibility.ts +22 -7
- package/studio/server/views/model.test.ts +57 -0
- package/studio/server/views/model.ts +66 -0
- package/studio/server/views/runtime.ts +40 -0
- package/studio/server/views/selection-repository.test.ts +31 -0
- package/studio/server/views/selection-repository.ts +60 -0
- package/studio/server/views/session.test.ts +73 -0
- package/studio/server/views/session.ts +166 -0
- package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
- package/studio/server/views/target.ts +248 -0
- package/studio/server/watch.ts +1 -1
- package/studio/server/workspace/catalog.test.ts +17 -0
- package/studio/server/workspace/catalog.ts +43 -0
- package/studio/server/{state → workspace}/create.test.ts +1 -1
- package/studio/server/{state → workspace}/create.ts +1 -1
- package/studio/server/{state → workspace}/git.ts +1 -1
- package/studio/server/workspace/updates.ts +83 -0
- package/studio/server/workspace-state.ts +1 -1
- package/studio/shared/contracts/README.md +16 -0
- package/studio/shared/contracts/agent.ts +270 -0
- package/studio/shared/contracts/runtime.ts +50 -0
- package/studio/shared/contracts/schema.ts +462 -0
- package/studio/shared/contracts/surface.test.ts +319 -0
- package/studio/shared/contracts/workspace.ts +274 -0
- package/studio/shared/layout.test.ts +116 -0
- package/studio/shared/schema/identity.test.ts +58 -0
- package/studio/shared/schema/identity.ts +82 -0
- package/studio/shared/types.ts +11 -1099
- package/studio/tsconfig.json +1 -1
- package/viewer/dist/main.js +35 -35
- package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
- package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
- package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
- package/studio/server/state/catalog.ts +0 -117
- package/studio/server/state/instance.ts +0 -280
- package/studio/server/state/updates.ts +0 -63
- 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
|
-
|
|
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(
|
|
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
|
+
})
|
package/studio/server/domain.ts
CHANGED
|
@@ -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
|
|
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 ||
|
|
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
|
-
*
|
|
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 ?
|
|
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 =
|
|
54
|
+
const config = readConfigPreview(root)
|
|
96
55
|
return {
|
|
97
56
|
env,
|
|
98
57
|
file,
|
|
99
|
-
configured:
|
|
58
|
+
configured: config.configuredSecretFiles.includes(file),
|
|
100
59
|
exists,
|
|
101
|
-
adapter:
|
|
60
|
+
adapter: config.adapter,
|
|
102
61
|
rows,
|
|
103
62
|
requiredMissing,
|
|
104
63
|
}
|