@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
@@ -162,6 +162,8 @@ export declare const IdpSessionSchema: z.ZodObject<{
162
162
  idp: z.ZodString;
163
163
  issuer: z.ZodString;
164
164
  subject: z.ZodString;
165
+ /** OAuth client that issued this session's refresh token. */
166
+ clientId: z.ZodOptional<z.ZodString>;
165
167
  audience: z.ZodOptional<z.ZodString>;
166
168
  organizationId: z.ZodOptional<z.ZodString>;
167
169
  access_token: z.ZodString;
@@ -194,6 +196,8 @@ export declare const IdpSessionSchema: z.ZodObject<{
194
196
  idp: z.ZodString;
195
197
  issuer: z.ZodString;
196
198
  subject: z.ZodString;
199
+ /** OAuth client that issued this session's refresh token. */
200
+ clientId: z.ZodOptional<z.ZodString>;
197
201
  audience: z.ZodOptional<z.ZodString>;
198
202
  organizationId: z.ZodOptional<z.ZodString>;
199
203
  access_token: z.ZodString;
@@ -226,6 +230,8 @@ export declare const IdpSessionSchema: z.ZodObject<{
226
230
  idp: z.ZodString;
227
231
  issuer: z.ZodString;
228
232
  subject: z.ZodString;
233
+ /** OAuth client that issued this session's refresh token. */
234
+ clientId: z.ZodOptional<z.ZodString>;
229
235
  audience: z.ZodOptional<z.ZodString>;
230
236
  organizationId: z.ZodOptional<z.ZodString>;
231
237
  access_token: z.ZodString;
@@ -6,8 +6,8 @@
6
6
  * `jwks_uri`) and JWKS at `/.well-known/jwks.json`. These helpers probe
7
7
  * both to verify the issuer is alive and publishes at least one key.
8
8
  *
9
- * Domain workers may expose `/meta` for deployment drift detection; that is
10
- * outside this connect-only CLI surface.
9
+ * Domain deployment readiness uses the canonical Domain Publication; that is
10
+ * outside this issuer-connectivity helper.
11
11
  */
12
12
  type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
13
13
  export type DiscoveryDocument = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "1.0.0-beta.0",
3
+ "version": "1.0.0-beta.2",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -62,9 +62,10 @@
62
62
  "zod": "^3.25.0"
63
63
  },
64
64
  "devDependencies": {
65
- "@astrale-os/kernel-client": ">=0.6.0-beta.3 <1.0.0",
65
+ "@astrale-os/kernel-client": ">=0.6.0-beta.4 <1.0.0",
66
+ "@astrale-os/kernel-protocol": ">=0.5.0-beta.3 <1.0.0",
66
67
  "@astrale-os/ox": ">=0.1.0 <1.0.0",
67
- "@astrale-os/sdk": ">=0.5.0-beta.3 <1.0.0",
68
+ "@astrale-os/sdk": ">=0.5.0-beta.4 <1.0.0",
68
69
  "@astrale-os/shell": ">=0.3.8-beta.2 <1.0.0",
69
70
  "@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.1",
70
71
  "@commitlint/cli": "~20.3.1",
@@ -107,7 +108,8 @@
107
108
  "lint:fix": "oxlint --fix .",
108
109
  "format": "pnpm exec oxfmt --write .",
109
110
  "format:check": "pnpm exec oxfmt --check .",
110
- "test": "bun test src && node --test scripts/*.test.mjs",
111
+ "test": "bun test src studio/client/src studio/server studio/shared && node --test scripts/*.test.mjs",
112
+ "test:studio": "pnpm --dir studio test",
111
113
  "test:watch": "bun test --watch src"
112
114
  }
113
115
  }
@@ -0,0 +1,168 @@
1
+ import type { Input, Result } from '@astrale-os/kernel-client'
2
+ import type {
3
+ ClientSession,
4
+ SessionRequestOptions,
5
+ SessionSnapshot,
6
+ } from '@astrale-os/kernel-client/session'
7
+ import type {
8
+ Definition,
9
+ Domain as RichDomain,
10
+ InstanceMethod,
11
+ StaticMethod,
12
+ } from '@astrale-os/sdk/domain'
13
+ import type { PathLike } from '@astrale-os/sdk/graph/path'
14
+ import type { Key } from '@astrale-os/sdk/schema'
15
+
16
+ import { call } from '@astrale-os/kernel-client'
17
+ import * as sessionModule from '@astrale-os/kernel-client/session'
18
+ import { Domain } from '@astrale-os/sdk/domain'
19
+ import { Path } from '@astrale-os/sdk/graph/path'
20
+
21
+ const ADMIN_ORIGIN = 'admin.astrale.ai'
22
+
23
+ type DynamicCallable =
24
+ | (Definition<'function'> & { readonly path: NonNullable<Definition<'function'>['path']> })
25
+ | StaticMethod
26
+ | InstanceMethod
27
+
28
+ export interface AdminBindingOperations {
29
+ readonly installation: unknown
30
+ readonly publication: SessionSnapshot['publication']
31
+ invoke(
32
+ callable: DynamicCallable,
33
+ receiverOrInput: PathLike | Input,
34
+ inputOrOptions?: Input | SessionRequestOptions,
35
+ options?: SessionRequestOptions,
36
+ ): Promise<unknown>
37
+ }
38
+
39
+ export type AdminBinding = RichDomain & {
40
+ readonly $: RichDomain['$'] & AdminBindingOperations
41
+ }
42
+
43
+ interface CompatibleCallableReference {
44
+ readonly origin: typeof ADMIN_ORIGIN
45
+ readonly revision: SessionSnapshot['publication']['schema']['revision']
46
+ readonly callable: Key.Callable
47
+ readonly target: PathLike
48
+ readonly outputMode: DynamicCallable['definition']['output']['mode']
49
+ }
50
+
51
+ interface CompatibleSession {
52
+ readonly installation?: (origin: typeof ADMIN_ORIGIN) => Promise<unknown>
53
+ readonly installed?: (origin: typeof ADMIN_ORIGIN) => Promise<unknown>
54
+ readonly invoke?: (
55
+ reference: CompatibleCallableReference,
56
+ input: Input,
57
+ options?: SessionRequestOptions,
58
+ ) => Promise<unknown>
59
+ }
60
+
61
+ type LegacyDispatchBound = (
62
+ session: ClientSession,
63
+ request: ReturnType<typeof call>,
64
+ expected: {
65
+ readonly schema: { readonly revision: CompatibleCallableReference['revision'] }
66
+ readonly publication: {
67
+ readonly origin: SessionSnapshot['publication']['origin']
68
+ readonly identity: SessionSnapshot['publication']['identity']
69
+ readonly revision: CompatibleCallableReference['revision']
70
+ readonly etag: SessionSnapshot['publication']['etag']
71
+ }
72
+ },
73
+ options?: SessionRequestOptions,
74
+ ) => Promise<Result>
75
+
76
+ /** Bind the Admin revision advertised by this exact Session snapshot. */
77
+ export async function bindAdmin(session: ClientSession): Promise<AdminBinding> {
78
+ const snapshot = await session.snapshot()
79
+ if (
80
+ snapshot.publication.origin !== ADMIN_ORIGIN ||
81
+ snapshot.bundle.root.origin !== ADMIN_ORIGIN
82
+ ) {
83
+ throw new TypeError('Configured Admin target does not serve the Admin Domain.')
84
+ }
85
+ const compatible = session as unknown as CompatibleSession
86
+ const resolveInstallation = compatible.installation ?? compatible.installed
87
+ if (resolveInstallation === undefined) {
88
+ throw new TypeError('Client Session cannot resolve an installed Admin Domain.')
89
+ }
90
+ const installation = await resolveInstallation.call(session, ADMIN_ORIGIN)
91
+ const domain = Domain.fromSchema(snapshot.bundle.root)
92
+ const invoke = ((
93
+ callable: DynamicCallable,
94
+ receiverOrInput: PathLike | Input,
95
+ inputOrOptions?: Input | SessionRequestOptions,
96
+ options?: SessionRequestOptions,
97
+ ) => {
98
+ const target =
99
+ 'on' in callable ? callable.on(Path.from(receiverOrInput as PathLike)) : callable.path
100
+ const reference: CompatibleCallableReference = Object.freeze({
101
+ origin: ADMIN_ORIGIN,
102
+ revision: snapshot.publication.schema.revision,
103
+ callable: callable.key as Key.Callable,
104
+ target,
105
+ outputMode: callable.definition.output.mode,
106
+ })
107
+ return invokeCompatible(
108
+ session,
109
+ snapshot,
110
+ reference,
111
+ ('on' in callable ? inputOrOptions : receiverOrInput) as Input,
112
+ ('on' in callable ? options : inputOrOptions) as SessionRequestOptions | undefined,
113
+ )
114
+ }) as AdminBindingOperations['invoke']
115
+
116
+ return Object.freeze({
117
+ ...domain,
118
+ $: Object.freeze({
119
+ ...domain.$,
120
+ installation,
121
+ publication: snapshot.publication,
122
+ invoke,
123
+ }),
124
+ }) as AdminBinding
125
+ }
126
+
127
+ async function invokeCompatible(
128
+ session: ClientSession,
129
+ snapshot: SessionSnapshot,
130
+ reference: CompatibleCallableReference,
131
+ input: Input,
132
+ options: SessionRequestOptions | undefined,
133
+ ): Promise<unknown> {
134
+ const current = session as unknown as CompatibleSession
135
+ if (current.invoke !== undefined) return current.invoke(reference, input, options)
136
+
137
+ const dispatchBound = (sessionModule as unknown as { dispatchBound?: LegacyDispatchBound })
138
+ .dispatchBound
139
+ if (dispatchBound === undefined) {
140
+ throw new TypeError('Client Session cannot invoke an exact Admin publication.')
141
+ }
142
+ const result = await dispatchBound(
143
+ session,
144
+ call(reference.target, input),
145
+ {
146
+ schema: { revision: reference.revision },
147
+ publication: {
148
+ origin: snapshot.publication.origin,
149
+ identity: snapshot.publication.identity,
150
+ revision: snapshot.publication.schema.revision,
151
+ etag: snapshot.publication.etag,
152
+ },
153
+ },
154
+ options,
155
+ )
156
+ return requireOutput(reference.outputMode, result)
157
+ }
158
+
159
+ function requireOutput(
160
+ expected: CompatibleCallableReference['outputMode'],
161
+ result: Result,
162
+ ): unknown {
163
+ if (result.kind !== expected) {
164
+ if (result.kind === 'stream') void result.stream.cancel(`Admin callable declared ${expected}.`)
165
+ throw new TypeError(`Admin callable declared ${expected} output but returned ${result.kind}.`)
166
+ }
167
+ return result.kind === 'stream' ? result.stream : result.value
168
+ }
@@ -1,4 +1,3 @@
1
- import type { DomainBinding } from '@astrale-os/kernel-client/domain'
2
1
  import type { Node } from '@astrale-os/sdk/graph/node'
3
2
  import type { QueryAST } from '@astrale-os/sdk/query'
4
3
 
@@ -7,6 +6,7 @@ import { NodeId } from '@astrale-os/sdk/graph/node'
7
6
  import { Path } from '@astrale-os/sdk/graph/path'
8
7
  import { describe, expect, mock, test } from 'bun:test'
9
8
 
9
+ import type { AdminBinding } from '../../binding'
10
10
  import type { AdminGraphApi } from '../../graph'
11
11
 
12
12
  import { connectAdminCatalog } from '../client'
@@ -61,7 +61,7 @@ function fixture(input: {
61
61
  core: { nodes: { fleet: { path: Path.id(NodeId('fleet')) } } },
62
62
  invoke,
63
63
  },
64
- } as unknown as DomainBinding
64
+ } as unknown as AdminBinding
65
65
  const query = mock(async (_ast: QueryAST) => ({
66
66
  result: {
67
67
  kind: 'nodes' as const,
@@ -1,11 +1,10 @@
1
- import type { DomainBinding } from '@astrale-os/kernel-client/domain'
2
1
  import type { ClientSession } from '@astrale-os/kernel-client/session'
3
2
  import type { Node } from '@astrale-os/sdk/graph/node'
4
3
 
5
- import { bind } from '@astrale-os/kernel-client/domain'
6
4
  import { Path } from '@astrale-os/sdk/graph/path'
7
5
  import { Query } from '@astrale-os/sdk/query'
8
6
 
7
+ import { bindAdmin, type AdminBinding } from '../binding'
9
8
  import { readAllNodes, type AdminGraphApi } from '../graph'
10
9
  import {
11
10
  AdminDomainNotFoundError,
@@ -37,7 +36,7 @@ export interface AdminCatalogApi {
37
36
  }
38
37
 
39
38
  export interface AdminCatalogDependencies {
40
- readonly bind?: (session: ClientSession) => Promise<DomainBinding>
39
+ readonly bind?: (session: ClientSession) => Promise<AdminBinding>
41
40
  readonly operationId?: (kind: 'publish' | 'configure-default') => string
42
41
  }
43
42
 
@@ -46,7 +45,7 @@ export async function connectAdminCatalog(
46
45
  context: AdminCatalogContext,
47
46
  dependencies: AdminCatalogDependencies = {},
48
47
  ): Promise<AdminCatalogApi> {
49
- const binding = await (dependencies.bind ?? bindInstalledAdmin)(context.session)
48
+ const binding = await (dependencies.bind ?? bindAdmin)(context.session)
50
49
  if (binding.$.publication?.origin !== ADMIN_ORIGIN || binding.$.origin !== ADMIN_ORIGIN) {
51
50
  throw new TypeError('Configured Admin target does not serve the Admin Domain.')
52
51
  }
@@ -145,11 +144,7 @@ export async function connectAdminCatalog(
145
144
  })
146
145
  }
147
146
 
148
- async function bindInstalledAdmin(session: ClientSession): Promise<DomainBinding> {
149
- return bind(session, await session.installed(ADMIN_ORIGIN))
150
- }
151
-
152
- type DynamicDefinition = ReturnType<DomainBinding['$']['class']>
147
+ type DynamicDefinition = ReturnType<AdminBinding['$']['class']>
153
148
 
154
149
  function domainFromNode(
155
150
  definition: DynamicDefinition,
@@ -1,4 +1,3 @@
1
- import type { DomainBinding } from '@astrale-os/kernel-client/domain'
2
1
  import type { Node } from '@astrale-os/sdk/graph/node'
3
2
  import type { QueryAST } from '@astrale-os/sdk/query'
4
3
 
@@ -7,6 +6,7 @@ import { NodeId } from '@astrale-os/sdk/graph/node'
7
6
  import { Path } from '@astrale-os/sdk/graph/path'
8
7
  import { describe, expect, mock, test } from 'bun:test'
9
8
 
9
+ import type { AdminBinding } from '../../binding'
10
10
  import type { AdminGraphApi } from '../../graph'
11
11
 
12
12
  import { connectAdminInstances } from '../client'
@@ -78,7 +78,7 @@ function fixture(input: {
78
78
  core: { nodes: { fleet: { path: Path.id(NodeId('fleet')) } } },
79
79
  invoke,
80
80
  },
81
- } as unknown as DomainBinding
81
+ } as unknown as AdminBinding
82
82
  const query = mock(async (ast: QueryAST) => {
83
83
  const name = sourceName(ast)
84
84
  if (name === 'Instance') return graphResult(input.instances ?? [])
@@ -153,7 +153,7 @@ describe('V2 Admin Instance adapter', () => {
153
153
  )
154
154
  })
155
155
 
156
- test('invokes Fleet.createInstance with an internal operation identity', async () => {
156
+ test('delegates default placement to Fleet without reading Host inventory', async () => {
157
157
  const created = {
158
158
  id: '@instance-node',
159
159
  slug: 'demo',
@@ -179,6 +179,8 @@ describe('V2 Admin Instance adapter', () => {
179
179
  Path.id(NodeId('fleet')),
180
180
  { operationId: 'cli.instance.create:test', slug: 'demo' },
181
181
  )
182
+ expect(contract.graph.query).not.toHaveBeenCalled()
183
+ expect(contract.graph.neighbors).not.toHaveBeenCalled()
182
184
  })
183
185
 
184
186
  test('resolves --host-id to an exact Host receiver and rejects the reserved Admin Host', async () => {
@@ -1,11 +1,10 @@
1
- import type { DomainBinding } from '@astrale-os/kernel-client/domain'
2
1
  import type { ClientSession } from '@astrale-os/kernel-client/session'
3
2
  import type { Node } from '@astrale-os/sdk/graph/node'
4
3
 
5
- import { bind } from '@astrale-os/kernel-client/domain'
6
4
  import { Path } from '@astrale-os/sdk/graph/path'
7
5
  import { Query } from '@astrale-os/sdk/query'
8
6
 
7
+ import { bindAdmin, type AdminBinding } from '../binding'
9
8
  import { readAllNodes, type AdminGraphApi } from '../graph'
10
9
  import {
11
10
  AdminHostNotFoundError,
@@ -43,7 +42,7 @@ export interface AdminInstanceApi {
43
42
  }
44
43
 
45
44
  export interface AdminInstanceDependencies {
46
- readonly bind?: (session: ClientSession) => Promise<DomainBinding>
45
+ readonly bind?: (session: ClientSession) => Promise<AdminBinding>
47
46
  readonly operationId?: (kind: 'create' | 'status' | 'delete' | 'install-domain') => string
48
47
  }
49
48
 
@@ -55,7 +54,7 @@ export async function connectAdminInstances(
55
54
  context: AdminInstanceContext,
56
55
  dependencies: AdminInstanceDependencies = {},
57
56
  ): Promise<AdminInstanceApi> {
58
- const binding = await (dependencies.bind ?? bindInstalledAdmin)(context.session)
57
+ const binding = await (dependencies.bind ?? bindAdmin)(context.session)
59
58
  if (binding.$.publication?.origin !== ADMIN_ORIGIN || binding.$.origin !== ADMIN_ORIGIN) {
60
59
  throw new TypeError('Configured Admin target does not serve the Admin Domain.')
61
60
  }
@@ -119,15 +118,9 @@ export async function connectAdminInstances(
119
118
  )
120
119
  }
121
120
 
122
- // Preserve the existing interactive multi-Host picker without accepting a
123
- // doomed operation first. Graph read policy exposes only caller-usable
124
- // Hosts; the reserved Admin Host is excluded explicitly.
125
- const inventory = await hostInventory(context.graph, binding, Host, fleet)
126
- const eligible = inventory.hosts.filter(
127
- (host) => host.state === 'ready' && host.path !== inventory.reserved,
128
- )
129
- if (eligible.length > 1) throw hostSelectionRequired(eligible.map((host) => host.id))
130
-
121
+ // Default placement belongs to Admin. Avoid a redundant Host graph read:
122
+ // it requires broader graph authority than this Fleet operation and can
123
+ // deny an otherwise authorized caller before the server selects a Host.
131
124
  return instanceFromSummary(
132
125
  await binding.$.invoke(Fleet.$.method('createInstance') as never, fleet, input as never),
133
126
  )
@@ -149,10 +142,6 @@ export async function connectAdminInstances(
149
142
  })
150
143
  }
151
144
 
152
- async function bindInstalledAdmin(session: ClientSession): Promise<DomainBinding> {
153
- return bind(session, await session.installed(ADMIN_ORIGIN))
154
- }
155
-
156
145
  interface HostInventoryItem {
157
146
  readonly id: string
158
147
  readonly nodeId: string
@@ -162,8 +151,8 @@ interface HostInventoryItem {
162
151
 
163
152
  async function hostInventory(
164
153
  graph: AdminGraphApi,
165
- binding: DomainBinding,
166
- Host: ReturnType<DomainBinding['$']['class']>,
154
+ binding: AdminBinding,
155
+ Host: ReturnType<AdminBinding['$']['class']>,
167
156
  fleet: Path,
168
157
  ): Promise<{ readonly hosts: readonly HostInventoryItem[]; readonly reserved?: string }> {
169
158
  const [nodes, reservedPage] = await Promise.all([
@@ -200,14 +189,7 @@ async function hostInventory(
200
189
  })
201
190
  }
202
191
 
203
- function hostSelectionRequired(ids: readonly string[]): Error {
204
- return new Error(
205
- `alphaCreate could not choose a host: ${ids.length} ready hosts are assigned (${[...ids].sort().join(', ')}). ` +
206
- 'Specify host_id once multi-host placement is enabled.',
207
- )
208
- }
209
-
210
- type DynamicDefinition = ReturnType<DomainBinding['$']['class']>
192
+ type DynamicDefinition = ReturnType<AdminBinding['$']['class']>
211
193
 
212
194
  function instanceFromSummary(input: unknown): InstanceInfo {
213
195
  const value = record(input, 'Admin Instance summary')
@@ -81,11 +81,56 @@ describe('auth login command', () => {
81
81
  expect(result.exitCode).toBe(0)
82
82
  const session = JSON.parse(await readFile(join(tmp, 'idp-sessions', 'alice.json'), 'utf-8'))
83
83
  expect(session.audience).toBe('https://kernel.example.com')
84
+ expect(session.clientId).toBe('client_123')
84
85
  expect(session.subject).toBe('user_123')
85
86
  } finally {
86
87
  await server.stop()
87
88
  }
88
89
  })
90
+
91
+ test('persists an explicit client id and reuses it for a named login', async () => {
92
+ const server = oauthServer({
93
+ accessToken: unsignedJwt({
94
+ iss: 'http://127.0.0.1',
95
+ sub: 'user_123',
96
+ exp: 1893456000,
97
+ }),
98
+ })
99
+ try {
100
+ await writeIdpConfig(server.url)
101
+
102
+ const explicit = await runAuthLogin(
103
+ '--idp',
104
+ 'test',
105
+ '--name',
106
+ 'alice',
107
+ '--client-id',
108
+ 'client_override',
109
+ '--client-credentials',
110
+ '--client-secret-env',
111
+ 'TEST_CLIENT_SECRET',
112
+ '--raw',
113
+ )
114
+ const reused = await runAuthLogin(
115
+ '--idp',
116
+ 'test',
117
+ '--name',
118
+ 'alice',
119
+ '--client-credentials',
120
+ '--client-secret-env',
121
+ 'TEST_CLIENT_SECRET',
122
+ '--raw',
123
+ )
124
+
125
+ expect(explicit.exitCode).toBe(0)
126
+ expect(reused.exitCode).toBe(0)
127
+ expect(server.clientIds()).toEqual(['client_override', 'client_override'])
128
+ const session = JSON.parse(await readFile(join(tmp, 'idp-sessions', 'alice.json'), 'utf-8'))
129
+ expect(session.clientId).toBe('client_override')
130
+ } finally {
131
+ await server.stop()
132
+ }
133
+ })
89
134
  })
90
135
 
91
136
  async function writeIdpConfig(baseUrl: string): Promise<void> {
@@ -131,12 +176,19 @@ async function writeIdpConfig(baseUrl: string): Promise<void> {
131
176
  )
132
177
  }
133
178
 
134
- function oauthServer(args: { accessToken: string }): { url: string; stop: () => Promise<void> } {
179
+ function oauthServer(args: { accessToken: string }): {
180
+ url: string
181
+ clientIds: () => Array<string | null>
182
+ stop: () => Promise<void>
183
+ } {
184
+ const clientIds: Array<string | null> = []
135
185
  const server = Bun.serve({
136
186
  port: 0,
137
187
  fetch: async (request) => {
138
188
  const url = new URL(request.url)
139
189
  if (url.pathname !== '/token') return new Response('not found', { status: 404 })
190
+ const form = new URLSearchParams(await request.text())
191
+ clientIds.push(form.get('client_id'))
140
192
  return Response.json({
141
193
  access_token: args.accessToken,
142
194
  token_type: 'Bearer',
@@ -146,6 +198,7 @@ function oauthServer(args: { accessToken: string }): { url: string; stop: () =>
146
198
  })
147
199
  return {
148
200
  url: `http://${server.hostname}:${server.port}`,
201
+ clientIds: () => clientIds,
149
202
  stop: () => server.stop(true),
150
203
  }
151
204
  }
@@ -14,7 +14,11 @@ describe('call command result lifetime', () => {
14
14
  }
15
15
 
16
16
  const sessionStream = Object.assign(stream(), { async cancel() {} })
17
- const materialized = await materializeCallResult({ kind: 'stream', stream: sessionStream })
17
+ const materialized = await materializeCallResult({
18
+ kind: 'stream',
19
+ invocation: { source: 'https://kernel.test' as never, id: 'test-stream' },
20
+ stream: sessionStream,
21
+ })
18
22
  sessionOpen = false
19
23
 
20
24
  expect(materialized).toEqual({ kind: 'stream', values: ['first', 'second'] })
@@ -1,3 +1,7 @@
1
+ import { defineDomain } from '@astrale-os/sdk'
2
+ import { issuer } from '@astrale-os/sdk/auth'
3
+ import { createDeployment } from '@astrale-os/sdk/deployment'
4
+ import { defineSchema } from '@astrale-os/sdk/schema/v1'
1
5
  import { afterEach, describe, expect, mock, test } from 'bun:test'
2
6
 
3
7
  import { installDirect } from '../domain/install'
@@ -7,6 +11,22 @@ const RETRY = '139137b5-af47-47ce-92b2-b64a2b0c63d7'
7
11
 
8
12
  const originalFetch = globalThis.fetch
9
13
 
14
+ const schema = defineSchema('crm.test', {})
15
+ const definition = defineDomain({
16
+ schema,
17
+ handlers: { functions: {}, classes: {}, interfaces: {} },
18
+ })
19
+ const deployed = createDeployment({
20
+ definition,
21
+ issuer: issuer.accept('https://crm.test'),
22
+ bundleHref: 'https://crm.test/domain.bundle.json',
23
+ bindings: { callables: [] },
24
+ }).publication
25
+
26
+ function publicationResponse(): Response {
27
+ return Response.json(deployed)
28
+ }
29
+
10
30
  class ExitError extends Error {}
11
31
 
12
32
  afterEach(() => {
@@ -15,9 +35,7 @@ afterEach(() => {
15
35
 
16
36
  describe('direct domain install operation identity', () => {
17
37
  test('generates one operation before transport and exposes it for recovery', async () => {
18
- globalThis.fetch = mock(async () =>
19
- Response.json({ domainName: 'crm.test' }),
20
- ) as unknown as typeof fetch
38
+ globalThis.fetch = mock(async () => publicationResponse()) as unknown as typeof fetch
21
39
  const create = mock(() => GENERATED)
22
40
  const requests: unknown[] = []
23
41
  let recovery: unknown
@@ -54,9 +72,7 @@ describe('direct domain install operation identity', () => {
54
72
  })
55
73
 
56
74
  test('accepts an explicit operation only as the exact retry identity', async () => {
57
- globalThis.fetch = mock(async () =>
58
- Response.json({ domainName: 'crm.test' }),
59
- ) as unknown as typeof fetch
75
+ globalThis.fetch = mock(async () => publicationResponse()) as unknown as typeof fetch
60
76
  const create = mock(() => GENERATED)
61
77
  const accepted: string[] = []
62
78
  const requests: unknown[] = []
@@ -95,7 +111,7 @@ describe('direct domain install operation identity', () => {
95
111
  test('rejects a non-UUID operation before metadata or Kernel transport', async () => {
96
112
  const originalExit = process.exit
97
113
  const originalWrite = process.stderr.write
98
- const fetch = mock(async () => Response.json({ domainName: 'crm.test' }))
114
+ const fetch = mock(async () => publicationResponse())
99
115
  const run = mock(async () => undefined)
100
116
  globalThis.fetch = fetch as unknown as typeof globalThis.fetch
101
117
  process.exit = (() => {
@@ -11,6 +11,10 @@ class ExitError extends Error {
11
11
 
12
12
  const calls: Array<{ path: string; params: unknown }> = []
13
13
  let inventory: OwnedInstanceInfo[] = []
14
+ const TEST_INVOCATION = {
15
+ source: 'https://kernel.test',
16
+ id: 'domain-install-owned',
17
+ } as ConstructorParameters<typeof ResponseError>[2]
14
18
 
15
19
  const hostCall = mock(async (call: { target: string; input: unknown }): Promise<unknown> => {
16
20
  const path = call.target
@@ -137,7 +141,7 @@ describe('admin domain install owner boundary', () => {
137
141
  createdAt: '2026-08-20T00:00:00.000Z',
138
142
  updatedAt: '2026-08-20T00:00:00.000Z',
139
143
  }
140
- const failure = new ResponseError(5001, 'Schema operation is not supported.', {
144
+ const failure = new ResponseError(5001, 'Schema operation is not supported.', TEST_INVOCATION, {
141
145
  code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
142
146
  details: {
143
147
  phase: 'upgrade',