@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
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { functionInputIssues, queryInputRepair } from '../reasons'
4
+
5
+ describe('public reason presentation admission', () => {
6
+ /** @evidence TEST-CLI-CONNECTION-ADMITS-BOUNDED-REASONS */
7
+ test('admits bounded public callable issues and filters unsafe additions', () => {
8
+ expect(
9
+ functionInputIssues({
10
+ code: 'FUNCTION_INPUT_INVALID',
11
+ details: {
12
+ issues: [
13
+ {
14
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
15
+ path: '/issuer',
16
+ message: 'Object is missing required property issuer.',
17
+ },
18
+ {
19
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
20
+ path: '/slug',
21
+ message: 'Object is missing required property slug.',
22
+ },
23
+ {
24
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
25
+ path: '/unsafe',
26
+ message: 'private\ndiagnostic',
27
+ },
28
+ ],
29
+ },
30
+ }),
31
+ ).toEqual([
32
+ {
33
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
34
+ path: '/issuer',
35
+ message: 'Object is missing required property issuer.',
36
+ },
37
+ {
38
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
39
+ path: '/slug',
40
+ message: 'Object is missing required property slug.',
41
+ },
42
+ ])
43
+ })
44
+
45
+ test('admits exact Query repair variants and rejects unbounded additions', () => {
46
+ expect(
47
+ queryInputRepair({
48
+ code: 'QUERY_INPUT_INVALID',
49
+ details: {
50
+ phase: 'plan',
51
+ issue: 'QUERY_DEFINITION_NOT_EDGE',
52
+ path: '/steps/0/via/0',
53
+ },
54
+ }),
55
+ ).toEqual({
56
+ phase: 'plan',
57
+ issue: 'QUERY_DEFINITION_NOT_EDGE',
58
+ path: '/steps/0/via/0',
59
+ })
60
+ expect(
61
+ queryInputRepair({
62
+ code: 'QUERY_INPUT_INVALID',
63
+ details: { phase: 'limit', limit: 'steps', maximum: 4, actual: 5 },
64
+ }),
65
+ ).toEqual({ phase: 'limit', limit: 'steps', maximum: 4, actual: 5 })
66
+ expect(
67
+ queryInputRepair({
68
+ code: 'QUERY_INPUT_INVALID',
69
+ details: {
70
+ phase: 'plan',
71
+ issue: 'QUERY_DEFINITION_NOT_EDGE',
72
+ path: '/steps/0/via/0',
73
+ provider: 'private',
74
+ },
75
+ }),
76
+ ).toBeUndefined()
77
+ })
78
+ })
@@ -162,7 +162,7 @@ async function resolveIdpAccessToken(
162
162
  )
163
163
  }
164
164
  if (e instanceof IdpSessionNoRefreshTokenError) {
165
- throw new Error(wrongAudienceHint(identityName, identity, audience))
165
+ throw classifyNoRefreshTokenError(audience, identity.audience, e)
166
166
  }
167
167
  // An audience mismatch means the session is healthy but the IdP won't
168
168
  // mint this audience — re-login is futile, so propagate it verbatim for
@@ -182,6 +182,16 @@ async function resolveIdpAccessToken(
182
182
  return token
183
183
  }
184
184
 
185
+ export function classifyNoRefreshTokenError(
186
+ requestedAudience: string,
187
+ sourceAudience: string | undefined,
188
+ error: IdpSessionNoRefreshTokenError,
189
+ ): IdpSessionNoRefreshTokenError | IdpAudienceMismatchError {
190
+ return sourceAudience !== undefined && sourceAudience !== requestedAudience
191
+ ? new IdpAudienceMismatchError(requestedAudience, sourceAudience)
192
+ : error
193
+ }
194
+
185
195
  /** A refresh attempt failed for a reason that re-login will NOT fix. */
186
196
  export class IdpRefreshTransientError extends Error {
187
197
  constructor(message: string) {
@@ -224,11 +234,3 @@ function refreshFailureError(identityName: string, identity: Identity, cause: un
224
234
  )
225
235
  }
226
236
  }
227
-
228
- function wrongAudienceHint(identityName: string, identity: Identity, audience: string): string {
229
- return (
230
- `IdP token for "${identityName}" was not minted for target audience ${audience}, ` +
231
- 'and the cached session cannot be refreshed. ' +
232
- `Run: astrale auth login --name ${identityName} --idp ${identity.idp ?? '<idp>'} --audience ${audience}`
233
- )
234
- }
@@ -16,6 +16,11 @@ export interface KernelCommandOpts extends ConnectionOptions {
16
16
  readonly debug?: boolean
17
17
  }
18
18
 
19
+ export interface OperationRecovery {
20
+ readonly operation: string
21
+ readonly retry: string
22
+ }
23
+
19
24
  /**
20
25
  * Encapsulates the standard kernel command lifecycle:
21
26
  * spinner → connect → call → timing → output → error handling.
@@ -27,6 +32,7 @@ export interface KernelCommandOpts extends ConnectionOptions {
27
32
  export async function runKernelCommand<T>(input: {
28
33
  readonly opts: KernelCommandOpts
29
34
  readonly label: string
35
+ readonly recovery?: OperationRecovery
30
36
  readonly fn: (context: ConnectionContext) => Promise<T>
31
37
  readonly format?: (
32
38
  result: T,
@@ -53,7 +59,9 @@ export async function runKernelCommand<T>(input: {
53
59
  }
54
60
  } catch (error) {
55
61
  if (!isRaw && spin) spin.fail(`${label} failed`)
56
- await formatKernelError(error, isRaw, undefined, opts.debug, { credential: opts.creds })
62
+ await formatKernelError(error, isRaw, undefined, opts.debug, {
63
+ recovery: input.recovery,
64
+ })
57
65
  process.exit(1)
58
66
  }
59
67
  }
@@ -1,21 +1,26 @@
1
1
  import chalk from 'chalk'
2
2
 
3
- import type { SelfExpansionMeta } from './self'
3
+ import type { OperationRecovery } from './command'
4
4
 
5
5
  import { AstraleError } from '../errors'
6
- import { decodeJwtExpiration, readLocalStatus, type LocalStatus } from '../lib/local-status'
6
+ import { readLocalStatus, type LocalStatus } from '../lib/local-status'
7
7
  import { log } from '../lib/log'
8
+ import {
9
+ functionInputIssues,
10
+ queryInputRepair,
11
+ reasonCode,
12
+ schemaUpgradeDetails,
13
+ schemaUpgradeHint,
14
+ type FunctionInputIssue,
15
+ type QueryInputRepair,
16
+ } from './reasons'
8
17
 
9
- type FieldError = { path: string[]; code: string; message: string }
10
- type InvariantError = { code: string; message: string; context?: unknown }
18
+ export { functionInputIssues, schemaUpgradeHint } from './reasons'
11
19
 
12
20
  /**
13
21
  * Format and display a kernel client error.
14
22
  *
15
- * Handles AstraleError (CLI-local) and every error class exported by
16
- * @astrale-os/kernel-client: ConnectionError, DisconnectedError,
17
- * TimeoutError, AuthenticationError, PermissionDeniedError, NotFoundError,
18
- * KernelError and its subclasses (ValidationError, InvariantViolationError).
23
+ * Handles CLI-local errors plus the current Kernel Client public error families.
19
24
  *
20
25
  * When `debug` is true, additional diagnostic information (class name, full
21
26
  * error chain, attached url/details) is printed after the user-facing line.
@@ -25,12 +30,11 @@ export async function formatKernelError(
25
30
  isRaw: boolean,
26
31
  urlArg = '',
27
32
  debug = false,
28
- opts: { credential?: string } = {},
33
+ opts: { recovery?: OperationRecovery } = {},
29
34
  ): Promise<void> {
30
35
  const url =
31
36
  urlArg || (error instanceof Error ? ((error as Error & { url?: string }).url ?? '') : '')
32
37
  const localContext = await contextForError(error)
33
- const credentialExpiration = opts.credential ? decodeJwtExpiration(opts.credential) : null
34
38
  // Handle AstraleError (AuthError, etc.) with structured hints
35
39
  if (error instanceof AstraleError) {
36
40
  if (isRaw) {
@@ -52,167 +56,58 @@ export async function formatKernelError(
52
56
  const name = error.name
53
57
 
54
58
  switch (name) {
55
- case 'ConnectionError':
56
- if (isRaw)
57
- writeRaw({ error: 'CONNECTION_ERROR', message: error.message, url, context: localContext })
58
- else {
59
- log.error(`Could not connect to ${chalk.bold(url || 'kernel')}`)
60
- log.dim(` ${error.message}`)
61
- log.dim(' Is the kernel running? Try: astrale status')
62
- printLocalContext(localContext)
63
- }
64
- break
65
-
66
- case 'DisconnectedError':
67
- if (isRaw) writeRaw({ error: 'DISCONNECTED', message: error.message })
68
- else {
69
- log.error('Connection closed while request was pending')
70
- log.dim(' The kernel may have been stopped or restarted. Retry the command.')
71
- }
72
- break
73
-
74
- case 'TimeoutError': {
75
- const timeoutMs = (error as { timeoutMs?: number }).timeoutMs
76
- if (isRaw) writeRaw({ error: 'TIMEOUT', message: error.message, timeoutMs })
77
- else {
78
- log.error(`Request timed out after ${timeoutMs ?? '?'}ms`)
79
- log.dim(' Try increasing with --timeout')
80
- }
81
- break
82
- }
83
-
84
- case 'AuthenticationError': {
85
- const reason = (error as { reason?: string }).reason ?? 'unknown'
86
- if (isRaw)
87
- writeRaw({
88
- error: 'AUTH_ERROR',
89
- reason,
90
- message: error.message,
91
- credential: credentialExpiration,
92
- context: localContext,
93
- })
94
- else {
95
- log.error(`Authentication failed: ${error.message}`)
96
- if (reason === 'missing')
97
- log.dim(' No credential was sent. Run: astrale identity create <name>')
98
- else if (reason === 'invalid')
99
- log.dim(' Credential is invalid — check issuer/keypair. Try: astrale identity whoami')
100
- else if (reason === 'expired') log.dim(' Credential expired — sign a fresh one')
101
- if (credentialExpiration) {
102
- const state = credentialExpiration.expired ? 'expired' : 'expires'
103
- log.dim(` Credential ${state} at ${credentialExpiration.expiresAt}`)
104
- }
105
- printLocalContext(localContext)
106
- }
107
- break
108
- }
109
-
110
- case 'PermissionDeniedError':
111
- if (isRaw) writeRaw({ error: 'PERMISSION_DENIED', message: error.message })
112
- else {
113
- log.error(`Permission denied: ${error.message}`)
114
- log.dim(' Your identity does not have the required permissions for this operation')
115
- }
59
+ case 'TransportError':
60
+ presentTransportError(error, isRaw, url, localContext, opts.recovery)
116
61
  break
117
62
 
118
- case 'NotFoundError': {
119
- const cleanMsg = stripMethodSuffix(error.message)
120
- const selfMeta = (error as Error & { expandedFromSelf?: SelfExpansionMeta }).expandedFromSelf
121
- // kernel-client maps both NOT_FOUND (the node doesn't exist) and
122
- // METHOD_NOT_FOUND (the method doesn't exist on a real node) to
123
- // `NotFoundError`. Firing the authenticated-principal hint for the
124
- // method case is misleading. Gate on the message referencing the
125
- // expanded id — node lookup errors mention `@<id>` whereas method
126
- // errors mention the method path.
127
- const selfHintApplies = selfMeta && error.message.includes(`@${selfMeta.selfId}`)
128
- if (isRaw) {
129
- const payload: Record<string, unknown> = { error: 'NOT_FOUND', message: cleanMsg }
130
- if (selfHintApplies) payload.expandedFromSelf = selfMeta
131
- writeRaw(payload)
132
- } else {
133
- log.error(`Not found: ${cleanMsg}`)
134
- log.dim(' Check the path/ID and that the instance is booted')
135
- if (selfHintApplies && selfMeta) {
136
- const where = selfMeta.slug ? ` on "${selfMeta.slug}"` : ''
137
- log.dim(
138
- ` @self resolved through authenticated Identity.whoami to @${selfMeta.selfId}${where}.`,
139
- )
140
- log.dim(' Check that the requested node path still exists for that principal.')
141
- }
142
- }
143
- break
144
- }
145
-
146
- case 'ValidationError': {
147
- const errors = (error as { errors?: FieldError[] }).errors ?? []
148
- if (isRaw) writeRaw({ error: 'VALIDATION_ERROR', message: error.message, details: errors })
149
- else {
150
- log.error('Validation Error')
151
- if (errors.length > 0) {
152
- for (const e of errors) {
153
- console.log(chalk.red(` ${e.path.join('.')}: ${e.message} (${chalk.dim(e.code)})`))
154
- }
155
- } else {
156
- // Server often sends details in message but empty errors array
157
- console.log(chalk.red(` ${error.message}`))
158
- }
159
- log.dim(' Use `astrale introspect <path>` to see the expected schema')
160
- }
161
- break
162
- }
163
-
164
- case 'InvariantViolationError': {
165
- const errors = (error as { errors?: InvariantError[] }).errors ?? []
166
- if (isRaw) writeRaw({ error: 'INVARIANT_VIOLATION', message: error.message, details: errors })
167
- else {
168
- log.error('Invariant Violation')
169
- for (const e of errors) {
170
- console.log(chalk.red(` ${e.code}: ${e.message}`))
171
- if (e.context) console.log(chalk.dim(` ${JSON.stringify(e.context)}`))
172
- }
173
- }
174
- break
175
- }
176
-
177
63
  case 'ResponseError': {
178
64
  const code = (error as { readonly code?: unknown }).code
179
65
  const reason = (error as { readonly reason?: unknown }).reason
180
- const reasonCode =
181
- reason !== null &&
182
- typeof reason === 'object' &&
183
- typeof (reason as { readonly code?: unknown }).code === 'string'
184
- ? (reason as { readonly code: string }).code
185
- : undefined
66
+ const codeOfReason = reasonCode(reason)
67
+ const inputIssues = functionInputIssues(reason)
68
+ const queryRepair = queryInputRepair(reason)
69
+ const upgrade = schemaUpgradeDetails(reason)
70
+ const removalHint = schemaDataRemovalHint(reason)
71
+ const domainAddressNotPublic = codeOfReason === 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC'
72
+ const displayMessage = domainAddressNotPublic
73
+ ? 'Expose the Domain through a public HTTPS URL or public tunnel, then retry.'
74
+ : removalHint !== undefined && !isRaw
75
+ ? 'Existing business data still uses schema definitions being removed.'
76
+ : error.message
186
77
  const hint =
187
- reasonCode === 'FUNCTION_INPUT_INVALID'
78
+ codeOfReason === 'FUNCTION_INPUT_INVALID' && (isRaw || inputIssues.length === 0)
188
79
  ? 'Use `astrale introspect <path>` to see the callable input.'
189
- : undefined
80
+ : (removalHint ?? (upgrade === undefined ? undefined : schemaUpgradeHint(upgrade)))
190
81
  if (isRaw) {
191
82
  writeRaw({
192
83
  error: 'RESPONSE_ERROR',
193
84
  ...(code === undefined ? {} : { code }),
194
- message: error.message,
85
+ message: displayMessage,
195
86
  ...(reason === undefined ? {} : { reason }),
196
87
  ...(hint === undefined ? {} : { hint }),
197
88
  })
198
89
  } else {
199
90
  log.error(
200
- `${chalk.bold(code === undefined ? 'RESPONSE_ERROR' : `RESPONSE_ERROR(${String(code)})`)}: ${error.message}`,
91
+ domainAddressNotPublic
92
+ ? `${chalk.bold('SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC')}: ${displayMessage}`
93
+ : removalHint !== undefined
94
+ ? `${chalk.bold('DATA_MIGRATION_REQUIRED')}: ${displayMessage}`
95
+ : `${chalk.bold(code === undefined ? 'RESPONSE_ERROR' : `RESPONSE_ERROR(${String(code)})`)}: ${displayMessage}`,
201
96
  )
202
- if (reasonCode !== undefined) log.dim(` reason: ${reasonCode}`)
97
+ if (codeOfReason !== undefined && !domainAddressNotPublic && removalHint === undefined) {
98
+ log.dim(` reason: ${codeOfReason}`)
99
+ }
100
+ presentFunctionInputIssues(inputIssues)
101
+ if (queryRepair !== undefined) presentQueryInputRepair(queryRepair)
102
+ if (upgrade?.issue === 'issuer-changed') {
103
+ log.dim(` installed issuer: ${upgrade.installedIssuer}`)
104
+ log.dim(` replacement issuer: ${upgrade.replacementIssuer}`)
105
+ }
203
106
  if (hint !== undefined) log.dim(` ${hint}`)
204
107
  }
205
108
  break
206
109
  }
207
110
 
208
- case 'KernelError': {
209
- const code = (error as { code?: number | string }).code ?? 'UNKNOWN'
210
- const type = (error as { type?: string }).type ?? 'KERNEL_ERROR'
211
- if (isRaw) writeRaw({ error: type, code, message: error.message })
212
- else log.error(`${chalk.bold(`${type}(${code})`)}: ${error.message}`)
213
- break
214
- }
215
-
216
111
  default: {
217
112
  const mapped = mapPublicError(error)
218
113
  if (isRaw) {
@@ -232,9 +127,28 @@ export async function formatKernelError(
232
127
  if (debug) printDebug(error, url)
233
128
  }
234
129
 
235
- /** Strip internal `::methodName` suffixes from paths in error messages (e.g., "/path::listChildren" → "/path") */
236
- export function stripMethodSuffix(msg: string): string {
237
- return msg.replace(/(\/[^"\s:]+)::([a-zA-Z]\w*)/g, '$1')
130
+ function schemaDataRemovalHint(reason: unknown): string | undefined {
131
+ if (reason === null || typeof reason !== 'object') return undefined
132
+ const value = reason as { readonly code?: unknown; readonly details?: unknown }
133
+ if (value.code !== 'DATA_MIGRATION_REQUIRED') return undefined
134
+ if (value.details === null || typeof value.details !== 'object') return undefined
135
+
136
+ const requirements = (value.details as { readonly requirements?: unknown }).requirements
137
+ if (
138
+ !Array.isArray(requirements) ||
139
+ requirements.length === 0 ||
140
+ !requirements.every(
141
+ (requirement) =>
142
+ requirement !== null &&
143
+ typeof requirement === 'object' &&
144
+ (requirement as { readonly operation?: unknown }).operation === 'remove-facts' &&
145
+ (requirement as { readonly reason?: unknown }).reason === 'destructive-change',
146
+ )
147
+ ) {
148
+ return undefined
149
+ }
150
+
151
+ return 'Delete this data explicitly, then retry. No data was deleted.'
238
152
  }
239
153
 
240
154
  function mapPublicError(error: Error): {
@@ -273,14 +187,90 @@ function mapPublicError(error: Error): {
273
187
  hint: 'Pass the Kernel issuer URL (no /invoke suffix), e.g. https://host/kernel/host',
274
188
  }
275
189
  }
276
- if (name === 'Error' && /unable to connect/i.test(error.message)) {
277
- return {
278
- code: 'CONNECTION_ERROR',
190
+ return { code: name && name !== 'Error' ? name : 'UNKNOWN', message: error.message }
191
+ }
192
+
193
+ function presentTransportError(
194
+ error: Error,
195
+ isRaw: boolean,
196
+ url: string,
197
+ context: LocalStatus | undefined,
198
+ recovery: OperationRecovery | undefined,
199
+ ): void {
200
+ const phase = transportPhase(error)
201
+ const delivery = transportDelivery(error)
202
+ const code =
203
+ phase === 'connect'
204
+ ? 'CONNECTION_ERROR'
205
+ : phase === 'timeout'
206
+ ? 'TIMEOUT'
207
+ : phase === 'closed'
208
+ ? 'DISCONNECTED'
209
+ : 'TRANSPORT_ERROR'
210
+ if (isRaw) {
211
+ writeRaw({
212
+ error: code,
279
213
  message: error.message,
280
- hint: 'Check --url / -i and that the Kernel is reachable. Try: astrale status',
281
- }
214
+ ...(url === '' ? {} : { url }),
215
+ ...(phase === undefined ? {} : { phase }),
216
+ ...(delivery === undefined ? {} : { delivery }),
217
+ ...(context === undefined ? {} : { context }),
218
+ ...(delivery === 'unknown' && recovery !== undefined ? recovery : {}),
219
+ })
220
+ return
282
221
  }
283
- return { code: name && name !== 'Error' ? name : 'UNKNOWN', message: error.message }
222
+ log.error(`${chalk.bold(code)}: ${error.message}`)
223
+ if (url !== '') log.dim(` target: ${url}`)
224
+ if (phase !== undefined) log.dim(` phase: ${phase}`)
225
+ if (phase === 'connect') log.dim(' Check the target and run `astrale status`.')
226
+ else if (phase === 'timeout') log.dim(' Try increasing `--timeout`.')
227
+ else if (delivery === 'unknown') printOperationRecovery(recovery)
228
+ printLocalContext(context)
229
+ }
230
+
231
+ function printOperationRecovery(recovery: OperationRecovery | undefined): void {
232
+ if (recovery === undefined) {
233
+ log.dim(' Delivery is unknown; do not automatically retry a mutating call.')
234
+ return
235
+ }
236
+ log.dim(' Delivery is unknown; retry with the same operation id:')
237
+ log.dim(` operation: ${recovery.operation}`)
238
+ log.dim(` ${recovery.retry}`)
239
+ }
240
+
241
+ function presentFunctionInputIssues(issues: readonly FunctionInputIssue[]): void {
242
+ for (const issue of issues) {
243
+ const location = issue.path === undefined || issue.path === '' ? '<input>' : issue.path
244
+ console.log(chalk.red(` ${location}: ${issue.message} (${chalk.dim(issue.code)})`))
245
+ }
246
+ }
247
+
248
+ function presentQueryInputRepair(repair: QueryInputRepair): void {
249
+ if (repair.phase === 'plan') {
250
+ log.dim(` ${repair.path ?? '/'} ${repair.issue}`)
251
+ return
252
+ }
253
+ if (repair.phase === 'limit') {
254
+ log.dim(` ${repair.path ?? '/'} ${repair.limit} limit ${repair.actual}/${repair.maximum}`)
255
+ return
256
+ }
257
+ log.dim(` ${repair.path} ${repair.phase} input`)
258
+ }
259
+
260
+ function transportPhase(error: Error): string | undefined {
261
+ const phase = (error as Error & { readonly phase?: unknown }).phase
262
+ return phase === 'connect' ||
263
+ phase === 'send' ||
264
+ phase === 'receive' ||
265
+ phase === 'timeout' ||
266
+ phase === 'closed'
267
+ ? phase
268
+ : undefined
269
+ }
270
+
271
+ function transportDelivery(error: Error): string | undefined {
272
+ const delivery = (error as Error & { readonly delivery?: unknown }).delivery
273
+ return delivery === 'not-sent' || delivery === 'unknown' ? delivery : undefined
284
274
  }
285
275
 
286
276
  function writeRaw(payload: Record<string, unknown>): void {
@@ -289,7 +279,7 @@ function writeRaw(payload: Record<string, unknown>): void {
289
279
 
290
280
  async function contextForError(error: unknown): Promise<LocalStatus | undefined> {
291
281
  if (!(error instanceof Error)) return undefined
292
- if (error.name !== 'AuthenticationError' && error.name !== 'ConnectionError') return undefined
282
+ if (error.name !== 'TransportError') return undefined
293
283
  return readLocalStatus().catch(() => undefined)
294
284
  }
295
285
 
@@ -156,6 +156,7 @@ async function exchange(
156
156
  try {
157
157
  admitted = exchangeProtocol.acceptErrorResponse(body)
158
158
  } catch (cause) {
159
+ if (!(cause instanceof TypeError)) throw cause
159
160
  throw new AstraleError(
160
161
  'TOKEN_EXCHANGE_PROTOCOL_ERROR',
161
162
  `Token exchange failed with HTTP ${response.status} and an invalid error response.`,
@@ -165,8 +166,19 @@ async function exchange(
165
166
  throw new AstraleError(String(admitted.error.code), admitted.error.message)
166
167
  }
167
168
  requireExchangeResponseHeaders(response)
168
- const exchanged = exchangeProtocol.acceptResponse(body)
169
- const inspected = credential.inspect(exchanged.token)
169
+ let exchanged: exchangeProtocol.Response
170
+ let inspected: ReturnType<typeof credential.inspect>
171
+ try {
172
+ exchanged = exchangeProtocol.acceptResponse(body)
173
+ inspected = credential.inspect(exchanged.token)
174
+ } catch (cause) {
175
+ if (!(cause instanceof TypeError)) throw cause
176
+ throw new AstraleError(
177
+ 'TOKEN_EXCHANGE_PROTOCOL_ERROR',
178
+ 'Token exchange returned an invalid success response.',
179
+ cause.message,
180
+ )
181
+ }
170
182
  if (
171
183
  inspected.iss !== domainIssuer ||
172
184
  inspected.aud !== kernelIssuer ||
@@ -1,6 +1,6 @@
1
1
  export { createPathCall } from './call'
2
2
  export { runKernelCommand } from './command'
3
- export type { KernelCommandOpts } from './command'
3
+ export type { KernelCommandOpts, OperationRecovery } from './command'
4
4
  export { expandSelfInCall, expandSelfInPath, withSelfHint } from './self'
5
5
  export type { SelfExpansionMeta } from './self'
6
6
  export { withAdminClientSession, withClientSession, type ConnectionContext } from './session'