@astrale-os/cli 0.8.1-alpha.6 → 1.0.0-beta.0

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 (98) hide show
  1. package/README.md +18 -5
  2. package/dist/astrale.js +2661 -2714
  3. package/dist/public/connect-core.js +2019 -3050
  4. package/dist/public/keys/index.js +1851 -2885
  5. package/dist/public/paths/index.js +1830 -2872
  6. package/dist/types/connection/auth.d.ts +3 -0
  7. package/dist/types/lib/instance.d.ts +10 -0
  8. package/package.json +9 -9
  9. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  10. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  11. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  12. package/src/commands/__tests__/install-direct.test.ts +3 -2
  13. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  14. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  15. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  16. package/src/commands/__tests__/instance-use.test.ts +67 -0
  17. package/src/commands/__tests__/view-build.test.ts +58 -0
  18. package/src/commands/domain/install.ts +84 -20
  19. package/src/commands/domain/uninstall.ts +128 -0
  20. package/src/commands/instance/active.ts +13 -1
  21. package/src/commands/instance/bookmark.ts +26 -3
  22. package/src/commands/instance/list.ts +18 -4
  23. package/src/commands/instance/use.ts +54 -7
  24. package/src/commands/view.ts +28 -16
  25. package/src/connection/.spec/architecture.md +5 -0
  26. package/src/connection/.spec/laws/connection.ts +20 -0
  27. package/src/connection/.spec/layout.ts +1 -0
  28. package/src/connection/__tests__/auth.test.ts +27 -1
  29. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  30. package/src/connection/__tests__/errors.test.ts +483 -33
  31. package/src/connection/__tests__/exchange.test.ts +46 -5
  32. package/src/connection/__tests__/reasons.test.ts +78 -0
  33. package/src/connection/auth.ts +11 -9
  34. package/src/connection/command.ts +9 -1
  35. package/src/connection/errors.ts +149 -159
  36. package/src/connection/exchange.ts +14 -2
  37. package/src/connection/index.ts +1 -1
  38. package/src/connection/reasons.ts +193 -0
  39. package/src/lib/__tests__/instance.test.ts +51 -1
  40. package/src/lib/__tests__/view-assets.test.ts +33 -1
  41. package/src/lib/__tests__/view-server.test.ts +68 -0
  42. package/src/lib/ca-fetch.ts +9 -3
  43. package/src/lib/instance.ts +31 -0
  44. package/src/lib/view/assets.ts +16 -2
  45. package/src/program/__tests__/program.test.ts +2 -1
  46. package/src/program/build.ts +2 -1
  47. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  48. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  49. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  50. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  51. package/studio/client/dist/index.html +2 -2
  52. package/studio/package.json +8 -9
  53. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  54. package/studio/server/agent/prompts/anchors.ts +85 -15
  55. package/studio/server/agent/prompts/system.test.ts +12 -0
  56. package/studio/server/agent/prompts/system.ts +6 -6
  57. package/studio/server/api.ts +1 -5
  58. package/studio/server/cache.ts +5 -2
  59. package/studio/server/domain.test.ts +67 -0
  60. package/studio/server/domain.ts +29 -6
  61. package/studio/server/index.ts +1 -3
  62. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  63. package/studio/server/introspect/anatomy-extras.ts +340 -8
  64. package/studio/server/introspect/anatomy.test.ts +33 -0
  65. package/studio/server/introspect/anatomy.ts +22 -7
  66. package/studio/server/introspect/bundle.ts +7 -1
  67. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  68. package/studio/server/introspect/canonical-schema.ts +751 -0
  69. package/studio/server/introspect/core-extractor.ts +30 -10
  70. package/studio/server/introspect/core.ts +4 -2
  71. package/studio/server/introspect/diff.test.ts +124 -0
  72. package/studio/server/introspect/diff.ts +252 -23
  73. package/studio/server/introspect/extractor.ts +46 -14
  74. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  75. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  76. package/studio/server/introspect/overlay.test.ts +72 -0
  77. package/studio/server/introspect/overlay.ts +16 -6
  78. package/studio/server/introspect/runtime.test.ts +217 -0
  79. package/studio/server/introspect/runtime.ts +18 -6
  80. package/studio/server/introspect/schema-refs.test.ts +100 -0
  81. package/studio/server/introspect/schema-refs.ts +23 -2
  82. package/studio/server/state/baseline.test.ts +51 -0
  83. package/studio/server/state/baseline.ts +31 -2
  84. package/studio/server/state/create.test.ts +37 -0
  85. package/studio/server/state/create.ts +21 -15
  86. package/studio/server/state/instance.test.ts +63 -0
  87. package/studio/server/state/instance.ts +65 -29
  88. package/studio/server/state/views.test.ts +209 -9
  89. package/studio/server/state/views.ts +176 -69
  90. package/studio/server/watch.test.ts +36 -0
  91. package/studio/server/watch.ts +24 -16
  92. package/studio/server/workspace-watch.ts +8 -3
  93. package/studio/shared/types.ts +164 -33
  94. package/viewer/dist/main.js +57 -57
  95. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  96. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  97. package/studio/server/view-dev-server.test.ts +0 -111
  98. package/studio/server/view-dev-server.ts +0 -372
@@ -6,6 +6,7 @@ import type { KernelCommandOpts } from '../../connection'
6
6
  import type { CommandDefinition } from '../../program/index'
7
7
 
8
8
  import { createPathCall, runKernelCommand, withAdminClientSession } from '../../connection'
9
+ import { formatKernelError } from '../../connection/errors'
9
10
  import { AstraleError } from '../../errors'
10
11
  import {
11
12
  installAdminDomainInContext,
@@ -21,11 +22,7 @@ import { confirmWithInput, promptText, selectFrom } from '../../lib/prompt'
21
22
  import { isHttpUrl } from '../../lib/validation'
22
23
 
23
24
  /** Public Kernel install syscall input for one remote URL. */
24
- export function directInstallCallInput(
25
- url: string,
26
- token?: string,
27
- operation: string = crypto.randomUUID(),
28
- ) {
25
+ export function directInstallCallInput(url: string, operation: string, token?: string) {
29
26
  return Object.freeze({
30
27
  operation,
31
28
  domains: [
@@ -50,9 +47,28 @@ type DirectInstallResult = {
50
47
  }[]
51
48
  }
52
49
 
50
+ const OPERATION_ID_PATTERN =
51
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
52
+
53
+ function acceptOperationId(input: unknown): string {
54
+ if (typeof input !== 'string' || !OPERATION_ID_PATTERN.test(input)) {
55
+ throw new AstraleError(
56
+ 'INVALID_FLAG',
57
+ '--operation must be a canonical lowercase UUIDv4.',
58
+ 'Omit --operation for a fresh install; use it only with the exact UUID printed for recovery.',
59
+ )
60
+ }
61
+ return input
62
+ }
63
+
64
+ function createOperationId(): string {
65
+ return acceptOperationId(globalThis.crypto.randomUUID())
66
+ }
67
+
53
68
  type InstallOpts = KernelCommandOpts &
54
69
  AdminTargetCommandOpts & {
55
70
  direct?: boolean
71
+ operation?: string
56
72
  token?: string
57
73
  allowIdentityOverride?: boolean
58
74
  // Global flags (program.ts) that force non-interactive.
@@ -78,6 +94,10 @@ Behavior:
78
94
  origin differs from its serving host, it requires explicit consent (an
79
95
  interactive DANGER prompt, or --allow-identity-override in scripts).
80
96
 
97
+ A fresh, strong operation id is generated automatically. Use --operation
98
+ only to retry or recover the exact same direct install after an outcome-unknown
99
+ timeout or disconnect.
100
+
81
101
  Examples:
82
102
  $ astrale domain install crm.acme.dev -i staging # by origin, via admin
83
103
  $ astrale domain install https://crm.acme.dev # by url, via admin
@@ -103,12 +123,26 @@ Examples:
103
123
  flags: '--token <token>',
104
124
  description: 'Bearer token for private domain install endpoints (--direct only)',
105
125
  },
126
+ {
127
+ flags: '--operation <uuid>',
128
+ description: 'Reuse an exact direct-install operation id for explicit retry/recovery',
129
+ },
106
130
  {
107
131
  flags: '--allow-identity-override',
108
132
  description: 'Consent to a domain whose origin differs from its serving host (--direct only)',
109
133
  },
110
134
  ],
111
135
  action: async (target: string | undefined, opts: InstallOpts) => {
136
+ if (opts.operation !== undefined && !opts.direct) {
137
+ fatal(
138
+ new AstraleError(
139
+ 'INVALID_FLAG',
140
+ '--operation is valid only with --direct.',
141
+ 'Ordinary direct installs generate a fresh operation id automatically.',
142
+ ),
143
+ opts,
144
+ )
145
+ }
112
146
  if (opts.direct) {
113
147
  await installDirect(target, opts)
114
148
  return
@@ -197,8 +231,9 @@ export async function installViaAdmin(
197
231
  log.dim(` origin: ${result.origin}`)
198
232
  log.dim(` url: ${result.url}`)
199
233
  })
200
- } catch (e) {
201
- fatal(e, opts)
234
+ } catch (error) {
235
+ await formatKernelError(error, isMachine(opts), undefined, opts.debug)
236
+ process.exit(1)
202
237
  }
203
238
  }
204
239
 
@@ -326,9 +361,27 @@ async function activeSlug(): Promise<string | undefined> {
326
361
  * bypassing the admin catalog, with the identity-override
327
362
  * consent gate. Works on any instance the caller can authenticate to.
328
363
  */
329
- async function installDirect(target: string | undefined, opts: InstallOpts): Promise<void> {
364
+ interface DirectInstallDependencies {
365
+ readonly acceptOperationId: (input: unknown) => string
366
+ readonly createOperationId: () => string
367
+ readonly runKernelCommand: typeof runKernelCommand
368
+ }
369
+
370
+ const defaultDirectInstallDependencies: DirectInstallDependencies = Object.freeze({
371
+ acceptOperationId,
372
+ createOperationId,
373
+ runKernelCommand,
374
+ })
375
+
376
+ export async function installDirect(
377
+ target: string | undefined,
378
+ opts: InstallOpts,
379
+ dependencies: Partial<DirectInstallDependencies> = {},
380
+ ): Promise<void> {
381
+ const direct = { ...defaultDirectInstallDependencies, ...dependencies }
330
382
  let host = ''
331
383
  let consentedOrigin: string | undefined
384
+ let operation: string
332
385
  try {
333
386
  if (!target) {
334
387
  throw new AstraleError(
@@ -338,24 +391,30 @@ async function installDirect(target: string | undefined, opts: InstallOpts): Pro
338
391
  )
339
392
  }
340
393
  host = validateInstallUrl(target)
394
+ operation =
395
+ opts.operation === undefined
396
+ ? direct.createOperationId()
397
+ : direct.acceptOperationId(opts.operation)
341
398
  consentedOrigin = await ensureIdentityOverrideConsent(
342
399
  target,
343
400
  host,
344
401
  opts.allowIdentityOverride ?? false,
345
402
  )
346
403
  } catch (e) {
347
- fatal(e)
404
+ fatal(e, opts)
348
405
  }
349
406
  const url = target as string
407
+ const retry = directInstallRetry(url, operation, opts)
350
408
 
351
- await runKernelCommand<DirectInstallResult>({
409
+ await direct.runKernelCommand<DirectInstallResult>({
352
410
  opts,
353
- label: `Installing domain from ${url}`,
411
+ label: `Installing domain from ${url} (operation ${operation})`,
412
+ recovery: { operation, retry },
354
413
  fn: async ({ session }) =>
355
414
  (await session.call(
356
415
  createPathCall(
357
416
  Path.project(syscalls.install.ref).raw,
358
- directInstallCallInput(url, opts.token),
417
+ directInstallCallInput(url, operation, opts.token),
359
418
  ),
360
419
  )) as DirectInstallResult,
361
420
  format: (result, fmtOpts, isRaw) => {
@@ -364,11 +423,10 @@ async function installDirect(target: string | undefined, opts: InstallOpts): Pro
364
423
  return
365
424
  }
366
425
  const installed = result.transitions[0]?.intent
367
- if (installed === undefined) {
368
- throw new Error('Kernel install returned no committed Domain transition.')
369
- }
426
+ if (!installed) throw new Error('Kernel install returned no committed Domain transition.')
370
427
  const revision = installed.target?.schemaRevision ?? result.operation
371
428
  log.success(`Domain installed: ${installed.origin}@${revision}`)
429
+ log.dim(` operation: ${result.operation}`)
372
430
  // Belt-and-braces: the kernel-confirmed origin is authoritative. If it
373
431
  // aliases the host and the pre-install gate never consented to THAT
374
432
  // origin (lying or absent `/meta`), say so loudly after the fact.
@@ -383,6 +441,11 @@ async function installDirect(target: string | undefined, opts: InstallOpts): Pro
383
441
  })
384
442
  }
385
443
 
444
+ function directInstallRetry(url: string, operation: string, opts: InstallOpts): string {
445
+ const instance = opts.instance === undefined ? '' : ` -i ${opts.instance}`
446
+ return `astrale domain install ${url} --direct --operation ${operation}${instance}`
447
+ }
448
+
386
449
  function validateInstallUrl(value: string): string {
387
450
  let url: URL
388
451
  try {
@@ -415,7 +478,7 @@ export function isIdentityOverride(origin: string, host: string): boolean {
415
478
  * Claiming an origin that differs from the serving host is an explicit actAs
416
479
  * and needs typed consent (or `--allow-identity-override` in scripts).
417
480
  *
418
- * The pre-install check reads the worker's self-reported `/meta.domainName`,
481
+ * The pre-install check reads the worker's self-reported `/meta.origin`,
419
482
  * so it is consent UX, not enforcement — a hostile worker can lie here, and
420
483
  * the kernel anchors the cryptographic identity (`iss`) on the real URL
421
484
  * regardless. When `/meta` is unreachable or silent on the origin, the gate
@@ -476,10 +539,11 @@ export async function probeDeclaredOrigin(url: string): Promise<string | undefin
476
539
  try {
477
540
  const res = await fetch(new URL('/meta', url), { signal: AbortSignal.timeout(10_000) })
478
541
  if (!res.ok) return undefined
479
- const body = (await res.json()) as { domainName?: unknown }
480
- return typeof body.domainName === 'string' && body.domainName.length > 0
481
- ? body.domainName
482
- : undefined
542
+ // SDK workers serve `origin`; `domainName` is the pre-Kernel-V2 name, kept
543
+ // as a fallback for workers deployed before the rename.
544
+ const body = (await res.json()) as { origin?: unknown; domainName?: unknown }
545
+ const declared = body.origin ?? body.domainName
546
+ return typeof declared === 'string' && declared.length > 0 ? declared : undefined
483
547
  } catch {
484
548
  // Unreachable /meta is not fatal here: the caller warns and the install
485
549
  // itself will surface a dead worker with its own error.
@@ -0,0 +1,128 @@
1
+ import { Path } from '@astrale-os/sdk/graph/path'
2
+ import { syscalls } from '@astrale-os/sdk/schema/kernel'
3
+ import chalk from 'chalk'
4
+
5
+ import type { KernelCommandOpts } from '../../connection'
6
+ import type { CommandDefinition } from '../../program/index'
7
+
8
+ import { createPathCall, runKernelCommand } from '../../connection'
9
+ import { AstraleError } from '../../errors'
10
+ import { fatal, log } from '../../lib/log'
11
+ import { output } from '../../lib/output'
12
+ import { confirmWithInput } from '../../lib/prompt'
13
+
14
+ type UninstallOpts = KernelCommandOpts & {
15
+ readonly yes?: boolean
16
+ readonly ci?: boolean
17
+ readonly noPrompt?: boolean
18
+ }
19
+
20
+ type UninstallResult = {
21
+ readonly operation: string
22
+ readonly transition: {
23
+ readonly intent: {
24
+ readonly origin: string
25
+ }
26
+ }
27
+ }
28
+
29
+ /** Public Kernel uninstall syscall input for one installed Domain origin. */
30
+ export function uninstallCallInput(
31
+ origin: string,
32
+ operation: string = crypto.randomUUID(),
33
+ ): Readonly<{ operation: string; origin: string }> {
34
+ return Object.freeze({ operation, origin })
35
+ }
36
+
37
+ export default {
38
+ name: 'uninstall',
39
+ description: 'Uninstall a domain from an instance through the public Kernel syscall',
40
+ afterHelpText: `
41
+ Behavior:
42
+ Removes one installed Domain origin from the target instance. The Kernel
43
+ refuses the operation while another installed Domain depends on it or while
44
+ business data still uses its schema. Uninstall never deletes business data.
45
+ Type the exact origin to confirm, or pass --yes in automation.
46
+
47
+ Use this before reinstalling only when an immutable Domain property (such as
48
+ its issuer) intentionally changed. Ordinary compatible upgrades should use
49
+ domain install directly and preserve the existing Domain identity.
50
+
51
+ Examples:
52
+ $ astrale domain uninstall grc.example -i staging
53
+ $ astrale domain uninstall grc.example -i staging --yes --json
54
+ `,
55
+ arguments: [
56
+ {
57
+ name: 'origin',
58
+ description: 'Installed Domain origin to remove',
59
+ required: true,
60
+ },
61
+ ],
62
+ options: [
63
+ {
64
+ flags: '--yes',
65
+ description: 'Confirm Domain uninstall without prompting',
66
+ },
67
+ ],
68
+ action: async (origin: string, opts: UninstallOpts) => {
69
+ try {
70
+ await confirmUninstall(origin, opts)
71
+ } catch (error) {
72
+ fatal(error, opts)
73
+ }
74
+
75
+ await runKernelCommand<UninstallResult>({
76
+ opts,
77
+ label: `Uninstalling domain ${origin}`,
78
+ fn: async ({ session }) =>
79
+ (await session.call(
80
+ createPathCall(Path.project(syscalls.uninstall.ref).raw, uninstallCallInput(origin)),
81
+ )) as UninstallResult,
82
+ format: (result, formatOpts, machine) => {
83
+ if (machine) {
84
+ output(result, formatOpts)
85
+ return
86
+ }
87
+ log.success(`Domain uninstalled: ${result.transition.intent.origin}`)
88
+ log.dim(` operation: ${result.operation}`)
89
+ },
90
+ })
91
+ },
92
+ } satisfies CommandDefinition
93
+
94
+ async function confirmUninstall(origin: string, opts: UninstallOpts): Promise<void> {
95
+ if (opts.yes) return
96
+
97
+ const nonInteractive =
98
+ opts.ci ||
99
+ opts.noPrompt ||
100
+ process.env.CI ||
101
+ process.argv.includes('--ci') ||
102
+ process.argv.includes('--no-prompt') ||
103
+ !process.stdin.isTTY
104
+ if (nonInteractive) {
105
+ throw new AstraleError(
106
+ 'CONFIRMATION_REQUIRED',
107
+ `Uninstalling Domain "${origin}" requires explicit confirmation.`,
108
+ `Re-run with --yes: astrale domain uninstall ${origin} --yes`,
109
+ )
110
+ }
111
+
112
+ const warning =
113
+ chalk.red.bold('⚠ DANGER — DOMAIN UNINSTALL') +
114
+ '\n' +
115
+ chalk.dim('│') +
116
+ ` origin ${chalk.bold(origin)}\n` +
117
+ chalk.dim('│') +
118
+ '\n' +
119
+ chalk.dim('│') +
120
+ ' This removes the installed Domain from the target instance.\n' +
121
+ chalk.dim('│') +
122
+ ' This command never deletes business data.\n' +
123
+ chalk.dim('│') +
124
+ ' The Kernel refuses removal while dependents or business data remain.'
125
+ if (!(await confirmWithInput(warning, origin))) {
126
+ throw new AstraleError('UNINSTALL_CANCELLED', `Domain uninstall cancelled for "${origin}".`)
127
+ }
128
+ }
@@ -19,13 +19,19 @@ export default {
19
19
  const { name } = active
20
20
  const url = active.url ?? null
21
21
  const createdAt = active.createdAt ?? null
22
+ const issuer = active.issuer ?? null
23
+ const defaultIdentity = active.defaultIdentity ?? null
24
+ const caFile = active.caFile ?? null
22
25
 
23
26
  if (isRaw) {
24
- output({ name, url, createdAt }, opts)
27
+ output({ name, url, issuer, defaultIdentity, caFile, createdAt }, opts)
25
28
  return
26
29
  }
27
30
 
28
31
  console.log(`${chalk.bold(name)} (${url ?? 'local'})`)
32
+ if (issuer && issuer !== url) log.dim(` issuer: ${issuer}`)
33
+ if (defaultIdentity) log.dim(` identity: ${defaultIdentity}`)
34
+ if (caFile) log.dim(` ca: ${caFile}`)
29
35
  } catch (e) {
30
36
  log.error(e instanceof Error ? e.message : String(e))
31
37
  process.exit(1)
@@ -36,6 +42,9 @@ export default {
36
42
  async function resolveActiveForDisplay(): Promise<{
37
43
  name: string
38
44
  url?: string
45
+ issuer?: string
46
+ defaultIdentity?: string
47
+ caFile?: string
39
48
  createdAt?: string
40
49
  }> {
41
50
  const active = await getActive()
@@ -43,6 +52,9 @@ async function resolveActiveForDisplay(): Promise<{
43
52
  return {
44
53
  name: active.name,
45
54
  url: active.url,
55
+ issuer: active.issuer,
56
+ defaultIdentity: active.defaultIdentity,
57
+ caFile: active.caFile,
46
58
  createdAt: active.createdAt,
47
59
  }
48
60
  }
@@ -1,7 +1,13 @@
1
1
  import type { CommandDefinition } from '../../program/index'
2
2
 
3
3
  import { fetchWithCaFile } from '../../lib/ca-fetch'
4
- import { normalizeInstanceKernelUrl, setActive, upsertInstance } from '../../lib/instance'
4
+ import {
5
+ findBookmarkTrustConflicts,
6
+ normalizeInstanceKernelUrl,
7
+ readInstances,
8
+ setActive,
9
+ upsertInstance,
10
+ } from '../../lib/instance'
5
11
  import { fatal, log } from '../../lib/log'
6
12
  import { checkIssuerReachability } from '../../lib/meta'
7
13
 
@@ -39,14 +45,27 @@ export default {
39
45
  try {
40
46
  if (!opts.url) fatal(new Error('Missing required flag: --url <url>'))
41
47
  const url = normalizeInstanceKernelUrl(opts.url)
42
- const expectedIssuer = opts.issuer ? normalizeInstanceKernelUrl(opts.issuer) : undefined
48
+ const store = await readInstances()
49
+ const expectedIssuer = opts.issuer
50
+ ? normalizeInstanceKernelUrl(opts.issuer)
51
+ : store.instances[name]?.issuer
52
+ const effectiveCa = opts.ca ?? store.instances[name]?.caFile
53
+ const trustConflicts = findBookmarkTrustConflicts(store, name, url, effectiveCa)
54
+ if (trustConflicts.length > 0) {
55
+ log.warn(
56
+ `TLS trust differs for the same Kernel URL ${url}: ` +
57
+ `"${name}" uses ${describeCa(effectiveCa)}, while ${trustConflicts
58
+ .map((conflict) => `"${conflict.name}" uses ${describeCa(conflict.caFile)}`)
59
+ .join(', ')}. Remove or update stale bookmarks to avoid certificate surprises.`,
60
+ )
61
+ }
43
62
 
44
63
  if (!opts.skipProbe) {
45
64
  try {
46
65
  const { issuer, keys } = await checkIssuerReachability(
47
66
  url,
48
67
  expectedIssuer,
49
- opts.ca ? fetchWithCaFile(opts.ca) : undefined,
68
+ effectiveCa ? fetchWithCaFile(effectiveCa) : undefined,
50
69
  )
51
70
  log.dim(` iss=${issuer} keys=${keys.length}`)
52
71
  } catch (e) {
@@ -76,3 +95,7 @@ export default {
76
95
  }
77
96
  },
78
97
  } satisfies CommandDefinition
98
+
99
+ function describeCa(caFile: string | null | undefined): string {
100
+ return caFile ? `CA ${caFile}` : 'the system trust store'
101
+ }
@@ -30,6 +30,7 @@ export type Bookmark = {
30
30
  issuer: string | null
31
31
  active: boolean
32
32
  defaultIdentity: string | null
33
+ caFile: string | null
33
34
  createdAt: string | null
34
35
  }
35
36
 
@@ -57,6 +58,7 @@ export default {
57
58
  issuer: entry.issuer ?? null,
58
59
  active: name === store.active,
59
60
  defaultIdentity: entry.defaultIdentity ?? null,
61
+ caFile: entry.caFile ?? null,
60
62
  createdAt: entry.createdAt ?? null,
61
63
  }))
62
64
 
@@ -111,13 +113,13 @@ export function buildInstanceRows(
111
113
  const rows: Array<Record<string, string>> = []
112
114
  const merged = new Set<string>()
113
115
 
114
- const bookmarkByName = new Map<string, { url: string; active: boolean }>()
116
+ const bookmarkByName = new Map<string, Bookmark & { url: string }>()
115
117
  if (show.managed && show.bookmarks) {
116
118
  for (const bookmark of bookmarks) {
117
119
  if (bookmark.url === null) continue
118
120
  bookmarkByName.set(bookmark.name, {
121
+ ...bookmark,
119
122
  url: normalizeInstanceKernelUrl(bookmark.url),
120
- active: bookmark.active,
121
123
  })
122
124
  }
123
125
  }
@@ -134,7 +136,9 @@ export function buildInstanceRows(
134
136
  name: twin?.active ? `${item.slug} ${chalk.green('*')}` : item.slug,
135
137
  kind: 'managed',
136
138
  url: item.url ?? '',
137
- extra: formatInstanceLocation(item),
139
+ extra: [formatInstanceLocation(item), twin ? formatBookmarkConnection(twin) : '']
140
+ .filter(Boolean)
141
+ .join(' · '),
138
142
  })
139
143
  }
140
144
  }
@@ -146,7 +150,7 @@ export function buildInstanceRows(
146
150
  name: item.active ? `${item.name} ${chalk.green('*')}` : item.name,
147
151
  kind: 'bookmark',
148
152
  url: String(item.url ?? ''),
149
- extra: '',
153
+ extra: formatBookmarkConnection(item),
150
154
  })
151
155
  }
152
156
  }
@@ -154,6 +158,16 @@ export function buildInstanceRows(
154
158
  return rows
155
159
  }
156
160
 
161
+ function formatBookmarkConnection(bookmark: Bookmark): string {
162
+ return [
163
+ bookmark.issuer && bookmark.issuer !== bookmark.url ? `issuer=${bookmark.issuer}` : '',
164
+ bookmark.caFile ? `ca=${bookmark.caFile}` : '',
165
+ bookmark.defaultIdentity ? `identity=${bookmark.defaultIdentity}` : '',
166
+ ]
167
+ .filter(Boolean)
168
+ .join(' · ')
169
+ }
170
+
157
171
  const ADMIN_INVENTORY_CODES = new Set([
158
172
  'TOKEN_EXCHANGE_SOURCE_INVALID',
159
173
  'TOKEN_EXCHANGE_SOURCE_EXPIRED',
@@ -5,7 +5,9 @@ import { AstraleError } from '../../errors'
5
5
  import { getDefault, setDefault } from '../../identity/index'
6
6
  import { listOwnedInstances } from '../../lib/admin-instance'
7
7
  import { ADMIN_TARGET_OPTIONS } from '../../lib/admin-target'
8
+ import { fetchWithCaFile } from '../../lib/ca-fetch'
8
9
  import {
10
+ findBookmarkTrustConflicts,
9
11
  getActive,
10
12
  readInstances,
11
13
  resolveInstance,
@@ -48,12 +50,8 @@ async function useInstance(name?: string, opts: UseOpts = {}): Promise<void> {
48
50
 
49
51
  const resolved = await resolveUseTarget(name, opts)
50
52
 
51
- if (!opts.skipJwksCheck && resolved.issuer) {
52
- try {
53
- await checkIssuerReachability(resolved.url, resolved.issuer)
54
- } catch (e) {
55
- fatal(e)
56
- }
53
+ if (!opts.skipJwksCheck) {
54
+ await probeBookmark(resolved)
57
55
  }
58
56
 
59
57
  await setActive(resolved.name)
@@ -94,6 +92,55 @@ async function useInstance(name?: string, opts: UseOpts = {}): Promise<void> {
94
92
  }
95
93
  }
96
94
 
95
+ /** Probe with the exact TLS trust configuration stored on this bookmark. */
96
+ export async function probeBookmark(
97
+ resolved: ResolvedInstance,
98
+ dependencies: Partial<BookmarkProbeDependencies> = {},
99
+ ): Promise<void> {
100
+ const probe = { ...defaultBookmarkProbeDependencies, ...dependencies }
101
+ const store = await probe.readInstances()
102
+ const conflicts = findBookmarkTrustConflicts(store, resolved.name, resolved.url, resolved.caFile)
103
+ try {
104
+ await probe.checkIssuerReachability(
105
+ resolved.url,
106
+ resolved.issuer,
107
+ resolved.caFile ? probe.fetchWithCaFile(resolved.caFile) : undefined,
108
+ )
109
+ } catch (cause) {
110
+ const original = cause instanceof AstraleError ? cause.hint : undefined
111
+ const trust = resolved.caFile
112
+ ? `Bookmark "${resolved.name}" trusts CA ${resolved.caFile}.`
113
+ : `Bookmark "${resolved.name}" uses the system trust store.`
114
+ const collision =
115
+ conflicts.length === 0
116
+ ? ''
117
+ : ` The same URL is bookmarked with different TLS trust as ${conflicts
118
+ .map((conflict) =>
119
+ conflict.caFile
120
+ ? `"${conflict.name}" (CA ${conflict.caFile})`
121
+ : `"${conflict.name}" (system trust)`,
122
+ )
123
+ .join(', ')}.`
124
+ throw new AstraleError(
125
+ cause instanceof AstraleError ? cause.code : 'ISSUER_UNREACHABLE',
126
+ `Issuer/JWKS probe failed for bookmark "${resolved.name}" at ${resolved.url}.`,
127
+ `${trust}${collision}${original ? ` ${original}` : ''} Inspect with \`astrale instance list --bookmarked --json\`.`,
128
+ )
129
+ }
130
+ }
131
+
132
+ interface BookmarkProbeDependencies {
133
+ readonly readInstances: typeof readInstances
134
+ readonly checkIssuerReachability: typeof checkIssuerReachability
135
+ readonly fetchWithCaFile: typeof fetchWithCaFile
136
+ }
137
+
138
+ const defaultBookmarkProbeDependencies: BookmarkProbeDependencies = Object.freeze({
139
+ readInstances,
140
+ checkIssuerReachability,
141
+ fetchWithCaFile,
142
+ })
143
+
97
144
  async function resolveUseTarget(name: string, opts: UseOpts): Promise<ResolvedInstance> {
98
145
  const [store, managed] = await Promise.all([readInstances(), fetchManagedInstances(name, opts)])
99
146
  const candidates = collectInstanceCandidates(name, store, managed)
@@ -204,7 +251,7 @@ Examples:
204
251
  flags: '--adopt-default',
205
252
  description: 'Adopt instance default identity without prompt',
206
253
  },
207
- { flags: '--skip-jwks-check', description: 'Skip the /meta JWKS match check' },
254
+ { flags: '--skip-jwks-check', description: 'Skip the OIDC discovery + JWKS liveness probe' },
208
255
  ],
209
256
  action: async (name: string | undefined, opts: UseOpts) => {
210
257
  await useInstance(name, opts)
@@ -172,22 +172,34 @@ async function findOnPath(name: string): Promise<string | null> {
172
172
 
173
173
  /** Dev checkout: (re)build the node-runnable CLI bundle when missing or stale. */
174
174
  async function ensureDevDist(entry: string, dist: string): Promise<void> {
175
- const bun = (
176
- globalThis as {
177
- Bun?: { build: (o: object) => Promise<{ success: boolean; logs: unknown[] }> }
178
- }
179
- ).Bun
180
- if (!bun) return
181
- const srcDir = join(dirname(entry), '..', 'src')
182
- if (existsSync(dist) && !(await newerThan(srcDir, statSync(dist).mtimeMs))) return
183
- // stderr: --json consumers parse stdout.
184
- console.error('(dev) building dist/astrale.js for the session server…')
185
- await bun.build({
186
- entrypoints: [entry],
187
- outdir: dirname(dist),
188
- target: 'node',
189
- format: 'esm',
190
- })
175
+ if (!(await devDistIsStale(entry, dist))) return
176
+ const projectDir = join(dirname(entry), '..')
177
+ const buildScript = join(projectDir, 'scripts', 'build.ts')
178
+ const bun = await findOnPath('bun')
179
+ if (!bun || !existsSync(buildScript)) return
180
+
181
+ // Build output goes to stderr so --json stdout remains valid.
182
+ console.error('(dev) dist/astrale.js is stale — running the official CLI build…')
183
+ const built = await run(bun, [buildScript], { cwd: projectDir })
184
+ if (built.stdout) process.stderr.write(built.stdout)
185
+ if (built.stderr) process.stderr.write(built.stderr)
186
+ if (built.code !== 0) throw new Error(`Official CLI build failed with exit code ${built.code}.`)
187
+ }
188
+
189
+ export async function devDistIsStale(entry: string, dist: string): Promise<boolean> {
190
+ if (!existsSync(dist)) return true
191
+ const projectDir = join(dirname(entry), '..')
192
+ const builtAt = statSync(dist).mtimeMs
193
+ const directories = [join(projectDir, 'src'), join(projectDir, 'bin'), join(projectDir, 'vendor')]
194
+ const files = [
195
+ join(projectDir, 'scripts', 'build.ts'),
196
+ join(projectDir, 'package.json'),
197
+ join(projectDir, 'pnpm-lock.yaml'),
198
+ ]
199
+ for (const directory of directories) {
200
+ if (existsSync(directory) && (await newerThan(directory, builtAt))) return true
201
+ }
202
+ return files.some((file) => existsSync(file) && statSync(file).mtimeMs > builtAt)
191
203
  }
192
204
 
193
205
  async function newerThan(dir: string, mtimeMs: number): Promise<boolean> {
@@ -39,3 +39,8 @@ The target, timeout, and optional CA file are resolved before constructing the s
39
39
  customizes only the Fetch capability passed to Client. `withClientSession` and
40
40
  `withAdminClientSession` are terminal lifecycle boundaries: success, failure, and cancellation all
41
41
  close both the Client Session and its direct source-Auth client.
42
+
43
+ The command boundary maps typed Client transport phase and delivery evidence without inspecting a
44
+ private cause message. It preserves every admitted Kernel reason in machine output; human repair
45
+ details are rendered only after the connection owner admits bounded public Function issues or one
46
+ exact Query reason variant.
@@ -179,3 +179,23 @@ export const CLI_CONNECTION_PUBLIC_SEMANTIC_REASON = defineLaw({
179
179
  },
180
180
  ],
181
181
  })
182
+
183
+ export const CLI_CONNECTION_TYPED_ERROR_PRESENTATION = defineLaw({
184
+ id: 'CLI-CONNECTION-TYPED-ERROR-PRESENTATION',
185
+ statement:
186
+ 'The command boundary maps typed Client transport phase and delivery evidence without parsing a private cause, preserves the admitted Kernel reason in machine output, and renders only bounded public Function issues or one exact Query repair variant for humans.',
187
+ tests: [
188
+ {
189
+ file: '__tests__/errors.test.ts',
190
+ id: 'TEST-CLI-CONNECTION-MAPS-TYPED-TRANSPORT',
191
+ },
192
+ {
193
+ file: '__tests__/errors.test.ts',
194
+ id: 'TEST-CLI-CONNECTION-PRESENTS-BOUNDED-REPAIRS',
195
+ },
196
+ {
197
+ file: '__tests__/reasons.test.ts',
198
+ id: 'TEST-CLI-CONNECTION-ADMITS-BOUNDED-REASONS',
199
+ },
200
+ ],
201
+ })