@astrale-os/cli 0.4.0-alpha.13

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 (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
@@ -0,0 +1,420 @@
1
+ import { K } from '@astrale-os/kernel-core'
2
+ import chalk from 'chalk'
3
+
4
+ import type { CommandDefinition } from '../../command'
5
+ import type { KernelCommandOpts } from '../../kernel'
6
+
7
+ import { AstraleError } from '../../errors'
8
+ import { runKernelCommand } from '../../kernel'
9
+ import { withAdminKernelClient } from '../../kernel/client'
10
+ import { ADMIN_DOMAIN, type DomainInfo } from '../../lib/admin-domain'
11
+ import { ADMIN_INSTANCE, type InstanceInfo } from '../../lib/admin-instance'
12
+ import { ADMIN_TARGET_OPTIONS, type AdminTargetCommandOpts } from '../../lib/admin-target'
13
+ import { getActive } from '../../lib/instance'
14
+ import { fatal, log, withSpinner } from '../../lib/log'
15
+ import { isMachine, output } from '../../lib/output'
16
+ import { confirmWithInput, promptText, selectFrom } from '../../lib/prompt'
17
+ import { isHttpUrl } from '../../lib/validation'
18
+
19
+ /** Mirrors the admin worker's `DomainInstallResult` (schema/domains.ts). */
20
+ type DomainInstallResult = {
21
+ name: string
22
+ origin: string
23
+ instanceId: string
24
+ url: string
25
+ ok: boolean
26
+ error?: string | null
27
+ }
28
+
29
+ /** Mirrors the kernel's `Root.installDomain` return. */
30
+ type DirectInstallResult = { domainId: string; origin: string }
31
+
32
+ type InstallOpts = KernelCommandOpts &
33
+ AdminTargetCommandOpts & {
34
+ direct?: boolean
35
+ token?: string
36
+ allowIdentityOverride?: boolean
37
+ // Global flags (program.ts) that force non-interactive.
38
+ ci?: boolean
39
+ noPrompt?: boolean
40
+ }
41
+
42
+ export default {
43
+ name: 'install',
44
+ description: 'Install a domain on an instance (via the admin catalog, or --direct from a url)',
45
+ afterHelpText: `
46
+ Behavior:
47
+ Default: installs a PUBLISHED domain through the admin control plane
48
+ (DomainEntry.install). Address it by its catalog \`origin\` (the unique
49
+ registry key) or by its \`url\`; run it bare to pick from the catalog
50
+ interactively. The target instance is the active one, or -i <slug>; it must be
51
+ admin-managed (otherwise the command fails loudly and points you at --direct).
52
+
53
+ --direct: installs a url STRAIGHT onto the instance kernel (Root.installDomain),
54
+ bypassing the admin/catalog. Works on any instance you can authenticate to
55
+ (managed, bookmarked, or local), using your own authority. This is the only
56
+ mode that runs the identity-override consent gate: when the domain's declared
57
+ origin differs from its serving host, it requires explicit consent (an
58
+ interactive DANGER prompt, or --allow-identity-override in scripts).
59
+
60
+ Examples:
61
+ $ astrale domain install crm.acme.dev -i staging # by origin, via admin
62
+ $ astrale domain install https://crm.acme.dev # by url, via admin
63
+ $ astrale domain install # interactive: pick domain + instance
64
+ $ astrale domain install https://crm.workers.dev --direct # straight to the instance kernel
65
+ $ astrale domain install http://localhost:8787 --direct --token "$INSTALL_TOKEN"
66
+ `,
67
+ arguments: [
68
+ {
69
+ name: 'target',
70
+ description: 'Domain origin or url (omit to pick from the catalog interactively)',
71
+ required: false,
72
+ },
73
+ ],
74
+ options: [
75
+ ...ADMIN_TARGET_OPTIONS,
76
+ {
77
+ flags: '--direct',
78
+ description:
79
+ 'Install a url straight onto the instance kernel, bypassing the admin catalog (any instance; runs the identity-override gate)',
80
+ },
81
+ {
82
+ flags: '--token <token>',
83
+ description: 'Bearer token for private domain install endpoints (--direct only)',
84
+ },
85
+ {
86
+ flags: '--allow-identity-override',
87
+ description: 'Consent to a domain whose origin differs from its serving host (--direct only)',
88
+ },
89
+ ],
90
+ action: async (target: string | undefined, opts: InstallOpts) => {
91
+ if (opts.direct) {
92
+ await installDirect(target, opts)
93
+ return
94
+ }
95
+ await installViaAdmin(target, opts)
96
+ },
97
+ } satisfies CommandDefinition
98
+
99
+ // ── Admin path (default) ──────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Install a published domain through the admin control plane. The domain is
103
+ * addressed by `origin` (catalog key) or `url`; the target instance is the
104
+ * active one or `-i <slug>` and must be admin-managed.
105
+ *
106
+ * `-i` here means the INSTALL TARGET, not the admin target — so it is stripped
107
+ * before resolving the admin client (which would otherwise read `-i`/`--url` as
108
+ * an admin-kernel override). The admin kernel is chosen via --admin/--admin-url
109
+ * or config, exactly like `domain publish`.
110
+ */
111
+ async function installViaAdmin(target: string | undefined, opts: InstallOpts): Promise<void> {
112
+ const interactive = !!process.stdin.isTTY && !(opts.ci || opts.noPrompt || process.env.CI)
113
+ if (!target && !interactive) {
114
+ fatal(
115
+ new AstraleError(
116
+ 'MISSING_ARG',
117
+ 'No domain given and no TTY for interactive selection.',
118
+ 'Pass an origin or url, e.g. astrale domain install crm.acme.dev — or use --direct <url>.',
119
+ ),
120
+ )
121
+ }
122
+ // Strip the install-target selector so the admin client resolves only the
123
+ // admin kernel (via --admin/--admin-url/config), not the instance under `-i`.
124
+ const adminOpts: InstallOpts = { ...opts, instance: undefined }
125
+
126
+ try {
127
+ await withAdminKernelClient(adminOpts, async (ctx) => {
128
+ const instances = (await ctx.client.call(`${ADMIN_INSTANCE}/list`, {})) as InstanceInfo[]
129
+
130
+ const ref = await resolveDomainRef(ctx, target, interactive)
131
+ const slug = await resolveTargetSlug(opts, target, interactive, instances)
132
+
133
+ const match = instances.find((i) => i.slug === slug)
134
+ if (!match) {
135
+ const known = instances.map((i) => i.slug).join(', ') || '(none)'
136
+ throw new AstraleError(
137
+ 'INSTANCE_NOT_MANAGED',
138
+ `Instance "${slug}" is not admin-managed (managed: ${known}).`,
139
+ `Install the url directly onto it instead: astrale domain install <url> --direct -i ${slug}`,
140
+ )
141
+ }
142
+
143
+ const label = ref.origin ?? ref.url ?? 'domain'
144
+ const result = await withSpinner(
145
+ `Installing ${label} on ${match.slug}`,
146
+ !isMachine(opts),
147
+ () =>
148
+ ctx.client.call(`${ADMIN_DOMAIN}/install`, {
149
+ instanceId: match.slug,
150
+ ...ref,
151
+ }) as Promise<DomainInstallResult>,
152
+ { success: (r) => `Installed ${r.origin} on ${match.slug}` },
153
+ )
154
+
155
+ if (isMachine(opts)) {
156
+ output(result, opts)
157
+ return
158
+ }
159
+ // The admin returns install failures as `ok:false` (it never throws past
160
+ // the saga). Surface them loudly rather than printing a quiet success.
161
+ if (!result.ok) {
162
+ throw new AstraleError(
163
+ 'INSTALL_FAILED',
164
+ `Install failed on ${match.slug}: ${result.error ?? 'unknown error'}`,
165
+ )
166
+ }
167
+ log.dim(` origin: ${result.origin}`)
168
+ log.dim(` url: ${result.url}`)
169
+ })
170
+ } catch (e) {
171
+ fatal(e)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Classify the positional install target for the admin path: an http(s) URL
177
+ * installs by `url`, anything else is treated as a catalog `origin` (the unique
178
+ * registry key). The admin method accepts either.
179
+ */
180
+ export function domainRefFromTarget(target: string): { origin?: string; url?: string } {
181
+ return isHttpUrl(target) ? { url: target } : { origin: target }
182
+ }
183
+
184
+ /** Resolve the domain to install: the positional `target`, or an interactive pick. */
185
+ async function resolveDomainRef(
186
+ ctx: { client: { call: (path: string, params: unknown) => Promise<unknown> } },
187
+ target: string | undefined,
188
+ interactive: boolean,
189
+ ): Promise<{ origin?: string; url?: string }> {
190
+ if (target) return domainRefFromTarget(target)
191
+ if (!interactive) throw new AstraleError('MISSING_ARG', 'No domain given.')
192
+
193
+ const catalog = (await ctx.client.call(`${ADMIN_DOMAIN}/list`, {})) as DomainInfo[]
194
+ const installable = catalog.filter((d) => d.url)
195
+ if (installable.length === 0) {
196
+ throw new AstraleError(
197
+ 'EMPTY_CATALOG',
198
+ 'No published domains in the admin catalog.',
199
+ 'Publish one first: astrale domain publish --origin … --name … --public-url …',
200
+ )
201
+ }
202
+ const origin = await selectFrom(
203
+ 'Select a domain to install',
204
+ installable.map((d) => ({
205
+ label: `${d.name} ${chalk.dim(`${d.origin} → ${d.url}`)}`,
206
+ value: d.origin,
207
+ })),
208
+ )
209
+ if (!origin) throw new AstraleError('CANCELLED', 'No domain selected.')
210
+ return { origin }
211
+ }
212
+
213
+ /**
214
+ * Resolve the target instance slug: `-i`, else the active instance. When run
215
+ * bare (no positional) in a TTY, prompt for it with the active instance
216
+ * pre-filled (Enter accepts) and validated against the managed list.
217
+ */
218
+ async function resolveTargetSlug(
219
+ opts: InstallOpts,
220
+ target: string | undefined,
221
+ interactive: boolean,
222
+ instances: InstanceInfo[],
223
+ ): Promise<string> {
224
+ if (opts.instance) return opts.instance
225
+ const active = await activeSlug()
226
+
227
+ if (target === undefined && interactive) {
228
+ const slugs = instances.map((i) => i.slug)
229
+ if (slugs.length > 0) log.dim(` managed instances: ${slugs.join(', ')}`)
230
+ const chosen = await promptText('Instance to install on', {
231
+ default: active && slugs.includes(active) ? active : undefined,
232
+ validate: (v) =>
233
+ slugs.includes(v) ||
234
+ `unknown managed instance "${v}" (one of: ${slugs.join(', ') || 'none'})`,
235
+ })
236
+ if (!chosen) throw new AstraleError('CANCELLED', 'No instance selected.')
237
+ return chosen
238
+ }
239
+
240
+ if (!active) {
241
+ throw new AstraleError(
242
+ 'NO_TARGET_INSTANCE',
243
+ 'No target instance: none active and no -i <slug> given.',
244
+ 'Pass -i <slug>, or select one with astrale instance use <slug>.',
245
+ )
246
+ }
247
+ return active
248
+ }
249
+
250
+ /** The active instance's slug (its admin-side id), or undefined when none. */
251
+ async function activeSlug(): Promise<string | undefined> {
252
+ try {
253
+ const a = await getActive()
254
+ return a.slug ?? a.name
255
+ } catch {
256
+ return undefined
257
+ }
258
+ }
259
+
260
+ // ── Direct path (--direct) ────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Install a url straight onto the instance kernel (`Root.installDomain`),
264
+ * bypassing the admin/catalog — the classic path, with the §5 identity-override
265
+ * consent gate. Works on any instance the caller can authenticate to.
266
+ */
267
+ async function installDirect(target: string | undefined, opts: InstallOpts): Promise<void> {
268
+ let host = ''
269
+ let consentedOrigin: string | undefined
270
+ try {
271
+ if (!target) {
272
+ throw new AstraleError(
273
+ 'MISSING_ARG',
274
+ '--direct requires a domain url.',
275
+ 'e.g. astrale domain install https://crm.acme.dev --direct',
276
+ )
277
+ }
278
+ host = validateInstallUrl(target)
279
+ consentedOrigin = await ensureIdentityOverrideConsent(
280
+ target,
281
+ host,
282
+ opts.allowIdentityOverride ?? false,
283
+ )
284
+ } catch (e) {
285
+ fatal(e)
286
+ }
287
+ const url = target as string
288
+
289
+ await runKernelCommand<DirectInstallResult>({
290
+ opts,
291
+ label: `Installing domain from ${url}`,
292
+ fn: async (ctx) =>
293
+ (await ctx.client.call(K.Root.installDomain.path.method.raw, {
294
+ url,
295
+ ...(opts.token ? { token: opts.token } : {}),
296
+ })) as DirectInstallResult,
297
+ format: (result, fmtOpts, isRaw) => {
298
+ if (isRaw) {
299
+ output(result, fmtOpts)
300
+ return
301
+ }
302
+ log.success(`Domain installed: ${result.origin}`)
303
+ log.dim(` domainId: ${result.domainId}`)
304
+ // Belt-and-braces: the kernel-confirmed origin is authoritative. If it
305
+ // aliases the host and the pre-install gate never consented to THAT
306
+ // origin (lying or absent `/meta`), say so loudly after the fact.
307
+ if (isIdentityOverride(result.origin, host) && result.origin !== consentedOrigin) {
308
+ log.warn(
309
+ `Installed origin "${result.origin}" differs from the serving host "${host}" ` +
310
+ `and was not confirmed before install (the worker's /meta did not declare it). ` +
311
+ `Every ${result.origin}/* call on this instance now routes to ${host}.`,
312
+ )
313
+ }
314
+ },
315
+ })
316
+ }
317
+
318
+ function validateInstallUrl(value: string): string {
319
+ let url: URL
320
+ try {
321
+ url = new URL(value)
322
+ } catch {
323
+ throw new AstraleError(
324
+ 'INVALID_DOMAIN_URL',
325
+ `Domain install source must be an http(s) URL, got "${value}".`,
326
+ 'Run or deploy the domain service, then install its base URL, for example: astrale domain install https://contract.astrale.ai --direct',
327
+ )
328
+ }
329
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
330
+ throw new AstraleError(
331
+ 'INVALID_DOMAIN_URL',
332
+ `Domain install URL must use http or https, got "${url.protocol}".`,
333
+ )
334
+ }
335
+ return url.hostname
336
+ }
337
+
338
+ /** An override = the declared origin and the serving host name differ. */
339
+ export function isIdentityOverride(origin: string, host: string): boolean {
340
+ return origin.toLowerCase() !== host.toLowerCase()
341
+ }
342
+
343
+ /**
344
+ * The §5 identity-override gate (spec: CREATE_ASTRALE_DOMAIN_DX). A domain's
345
+ * `origin` is its addressing identity on the instance: every `<origin>/*` call
346
+ * — including other domains' `requires` — routes to the installed URL.
347
+ * Claiming an origin that differs from the serving host is an explicit actAs
348
+ * and needs typed consent (or `--allow-identity-override` in scripts).
349
+ *
350
+ * The pre-install check reads the worker's self-reported `/meta.domainName`,
351
+ * so it is consent UX, not enforcement — a hostile worker can lie here, and
352
+ * the kernel anchors the cryptographic identity (`iss`) on the real URL
353
+ * regardless. When `/meta` is unreachable or silent on the origin, the gate
354
+ * degrades to a warning and the kernel-confirmed origin is re-checked after
355
+ * install (see `format` above).
356
+ *
357
+ * Returns the origin the user consented to (or `undefined` when no override
358
+ * was detected / verifiable pre-install).
359
+ */
360
+ async function ensureIdentityOverrideConsent(
361
+ url: string,
362
+ host: string,
363
+ allow: boolean,
364
+ ): Promise<string | undefined> {
365
+ const origin = await probeDeclaredOrigin(url)
366
+ if (origin === undefined) {
367
+ log.warn(
368
+ `Could not read a declared origin from ${new URL('/meta', url).href} — ` +
369
+ `skipping the pre-install identity check (the installed origin is verified after install).`,
370
+ )
371
+ return undefined
372
+ }
373
+ if (!isIdentityOverride(origin, host)) return undefined
374
+
375
+ if (allow) {
376
+ log.warn(`Identity override consented via --allow-identity-override: ${origin} ← ${host}`)
377
+ return origin
378
+ }
379
+
380
+ const banner =
381
+ chalk.red.bold('⚠ DANGER — IDENTITY OVERRIDE') +
382
+ '\n' +
383
+ chalk.dim('│') +
384
+ ` deployed ${host}\n` +
385
+ chalk.dim('│') +
386
+ ` origin ${chalk.bold(origin)} ${chalk.red('(≠)')}\n` +
387
+ chalk.dim('│') +
388
+ '\n' +
389
+ chalk.dim('│') +
390
+ ` Every call to ${origin}/* on this instance —\n` +
391
+ chalk.dim('│') +
392
+ ` including from other domains — will hit ${host}.\n` +
393
+ chalk.dim('│') +
394
+ ` Only proceed if you trust ${host}.`
395
+ const confirmed = await confirmWithInput(banner, origin)
396
+ if (!confirmed) {
397
+ throw new AstraleError(
398
+ 'IDENTITY_OVERRIDE_REJECTED',
399
+ `Install aborted: the domain at ${host} declares origin "${origin}" (identity override) and it was not confirmed.`,
400
+ 'Re-run interactively and type the origin to confirm, or pass --allow-identity-override in scripts.',
401
+ )
402
+ }
403
+ return origin
404
+ }
405
+
406
+ /** Best-effort read of the worker's declared origin from its `/meta` probe. */
407
+ export async function probeDeclaredOrigin(url: string): Promise<string | undefined> {
408
+ try {
409
+ const res = await fetch(new URL('/meta', url), { signal: AbortSignal.timeout(10_000) })
410
+ if (!res.ok) return undefined
411
+ const body = (await res.json()) as { domainName?: unknown }
412
+ return typeof body.domainName === 'string' && body.domainName.length > 0
413
+ ? body.domainName
414
+ : undefined
415
+ } catch {
416
+ // Unreachable /meta is not fatal here: the caller warns and the install
417
+ // itself will surface a dead worker with its own error.
418
+ return undefined
419
+ }
420
+ }
@@ -0,0 +1,154 @@
1
+ import chalk from 'chalk'
2
+
3
+ import type { CommandDefinition } from '../../command'
4
+ import type { KernelCommandOpts } from '../../kernel'
5
+ import type { ListProjection, RawOutputOpts } from '../../lib/output'
6
+
7
+ import { withAdminKernelClient } from '../../kernel/client'
8
+ import { ADMIN_DOMAIN, type DomainInfo } from '../../lib/admin-domain'
9
+ import { ADMIN_TARGET_OPTIONS, type AdminTargetCommandOpts } from '../../lib/admin-target'
10
+ import { fatal, withSpinner } from '../../lib/log'
11
+ import { isMachine, presentList } from '../../lib/output'
12
+
13
+ type ListOpts = KernelCommandOpts &
14
+ AdminTargetCommandOpts &
15
+ RawOutputOpts & {
16
+ check?: boolean
17
+ defaultOnly?: boolean
18
+ quiet?: boolean
19
+ count?: boolean
20
+ long?: boolean
21
+ format?: 'yaml' | 'json'
22
+ }
23
+
24
+ /** A catalog entry, optionally enriched with a live `--check` probe. */
25
+ export type DomainRow = DomainInfo & {
26
+ reachable?: boolean
27
+ schemaHash?: string
28
+ checkError?: string | null
29
+ }
30
+
31
+ /**
32
+ * Catalog rows for the human table. `paths` is the published URL (falling back
33
+ * to origin) so `astrale domain list -q | xargs -I{} astrale domain install {}`
34
+ * composes — install takes a URL. The STATUS column is dropped by `renderTable`
35
+ * unless `--check` filled it in.
36
+ */
37
+ export function domainProjection(items: DomainRow[]): ListProjection {
38
+ return {
39
+ columns: [
40
+ { key: 'name', header: 'NAME', color: chalk.bold },
41
+ { key: 'origin', header: 'ORIGIN', color: chalk.cyan },
42
+ { key: 'url', header: 'URL', color: chalk.dim },
43
+ { key: 'default', header: 'DEFAULT' },
44
+ { key: 'status', header: 'STATUS' },
45
+ ],
46
+ rows: items.map((d) => ({
47
+ name: d.name,
48
+ origin: d.origin,
49
+ url: d.url ?? chalk.dim('(unpublished)'),
50
+ default: d.installByDefault ? chalk.green('default') : '',
51
+ status: statusCell(d),
52
+ })),
53
+ paths: items.map((d) => d.url ?? d.origin),
54
+ }
55
+ }
56
+
57
+ function statusCell(d: DomainRow): string {
58
+ if (d.reachable === undefined) return ''
59
+ return d.reachable ? chalk.green('● live') : chalk.red(`○ ${d.checkError ?? 'unreachable'}`)
60
+ }
61
+
62
+ export default {
63
+ name: 'list',
64
+ description: 'List domains published in the admin catalog (DomainEntry.list)',
65
+ afterHelpText: `
66
+ Behavior:
67
+ Reads the admin catalog — every domain that has been \`publish\`ed
68
+ (origin → published worker URL). Listing only shows what is INSTALLABLE;
69
+ what is actually mounted where lives on each instance's own graph
70
+ (\`astrale ls /\` against that instance).
71
+
72
+ Default output is a NAME/ORIGIN/URL/DEFAULT table on a TTY, JSON when piped
73
+ or with --json/--raw (agent-friendly — full DomainInfo objects). -q prints
74
+ one install URL per line (pipeable into \`domain install\`); --count prints
75
+ only the number. --default-only keeps the install-by-default entries (what
76
+ every new instance gets via alphaCreate). --check probes each published URL's
77
+ /meta and adds a live/unreachable STATUS column (+ reachable/schemaHash in
78
+ machine output).
79
+
80
+ The admin kernel is selected like every admin op — the configured default,
81
+ or --admin <bookmark> / --admin-url <url>.
82
+
83
+ Examples:
84
+ $ astrale domain list
85
+ $ astrale domain list --check
86
+ $ astrale domain list --default-only -q
87
+ $ astrale domain list --json | jq -r '.[].url'
88
+ `,
89
+ options: [
90
+ ...ADMIN_TARGET_OPTIONS,
91
+ { flags: '--check', description: "Probe each domain's URL for reachability + schema hash" },
92
+ { flags: '--default-only', description: 'Only show install-by-default domains' },
93
+ { flags: '-q, --quiet', description: 'One install URL per line (unix-pipeable)' },
94
+ { flags: '--count', description: 'Print only the number of published domains' },
95
+ { flags: '-l, --long', description: 'Full catalog records in machine output' },
96
+ ],
97
+ action: async (opts: ListOpts) => {
98
+ try {
99
+ const domains = await withSpinner(
100
+ 'Fetching domains',
101
+ !isMachine(opts),
102
+ async (): Promise<DomainRow[]> => {
103
+ const list = await withAdminKernelClient(
104
+ opts,
105
+ async (ctx) => (await ctx.client.call(`${ADMIN_DOMAIN}/list`, {})) as DomainInfo[],
106
+ )
107
+ const filtered = opts.defaultOnly ? list.filter((d) => d.installByDefault) : list
108
+ filtered.sort(byDefaultThenName)
109
+ if (!opts.check) return filtered as DomainRow[]
110
+ // Reachability is a direct client-side /meta fetch per entry, in
111
+ // parallel — no admin round-trip, and version-independent of the
112
+ // admin worker (mirrors `domain install`'s probeDeclaredOrigin).
113
+ return Promise.all(filtered.map(probe))
114
+ },
115
+ )
116
+
117
+ presentList(
118
+ domains,
119
+ { ...opts, quiet: opts.quiet, count: opts.count, long: opts.long },
120
+ domainProjection,
121
+ )
122
+ } catch (e) {
123
+ fatal(e)
124
+ }
125
+ },
126
+ } satisfies CommandDefinition
127
+
128
+ /** Install-by-default first, then alphabetical by origin — a stable display order. */
129
+ export function byDefaultThenName(a: DomainInfo, b: DomainInfo): number {
130
+ if (!!a.installByDefault !== !!b.installByDefault) return a.installByDefault ? -1 : 1
131
+ return a.origin.localeCompare(b.origin)
132
+ }
133
+
134
+ /**
135
+ * Enrich one entry with a reachability probe: fetch the published worker's
136
+ * `/meta` (the same endpoint the admin `DomainEntry.check` hits) and read its
137
+ * schema hash. A dead / missing url is itself a result, not a throw.
138
+ */
139
+ async function probe(d: DomainInfo): Promise<DomainRow> {
140
+ if (!d.url) return { ...d, reachable: false, checkError: 'no url published' }
141
+ try {
142
+ const res = await fetch(new URL('/meta', d.url), { signal: AbortSignal.timeout(10_000) })
143
+ if (!res.ok) return { ...d, reachable: false, checkError: `meta HTTP ${res.status}` }
144
+ const meta = (await res.json()) as { schemaHash?: unknown }
145
+ return {
146
+ ...d,
147
+ reachable: true,
148
+ ...(typeof meta.schemaHash === 'string' ? { schemaHash: meta.schemaHash } : {}),
149
+ checkError: null,
150
+ }
151
+ } catch (err) {
152
+ return { ...d, reachable: false, checkError: err instanceof Error ? err.message : String(err) }
153
+ }
154
+ }