@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.
- package/README.md +7 -11
- package/dist/astrale.js +3769 -3549
- 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 +5 -3
- package/src/admin/instance/client.ts +9 -27
- 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 +73 -0
- 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 +39 -13
- 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
package/studio/server/cache.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
})
|