@astrale-os/cli 0.8.1-alpha.5 → 0.8.1-alpha.7

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 (88) hide show
  1. package/README.md +4 -3
  2. package/dist/astrale.js +3391 -3163
  3. package/dist/public/connect-core.js +2041 -3066
  4. package/dist/public/keys/index.js +1854 -2895
  5. package/dist/public/paths/index.js +1833 -2882
  6. package/dist/types/connection/auth.d.ts +3 -0
  7. package/dist/types/lib/instance-target.d.ts +2 -0
  8. package/dist/types/lib/instance.d.ts +10 -0
  9. package/dist/types/lib/invocation.d.ts +3 -0
  10. package/dist/types/lib/log.d.ts +6 -5
  11. package/dist/types/lib/output.d.ts +6 -2
  12. package/package.json +8 -8
  13. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  14. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  15. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  16. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  17. package/src/commands/__tests__/instance-use.test.ts +67 -0
  18. package/src/commands/__tests__/introspect-parse.test.ts +21 -0
  19. package/src/commands/__tests__/logs.test.ts +5 -0
  20. package/src/commands/__tests__/read-commands.test.ts +11 -1
  21. package/src/commands/__tests__/token-ttl.test.ts +21 -0
  22. package/src/commands/__tests__/view-build.test.ts +58 -0
  23. package/src/commands/auth/token.ts +19 -7
  24. package/src/commands/call.ts +18 -43
  25. package/src/commands/domain/install.ts +6 -5
  26. package/src/commands/domain/uninstall.ts +128 -0
  27. package/src/commands/get.ts +23 -5
  28. package/src/commands/identity/create.ts +11 -2
  29. package/src/commands/identity/delete.ts +8 -2
  30. package/src/commands/identity/export.ts +8 -2
  31. package/src/commands/identity/import.ts +11 -2
  32. package/src/commands/identity/sync.ts +8 -2
  33. package/src/commands/identity/unsync.ts +12 -2
  34. package/src/commands/identity/use.ts +8 -2
  35. package/src/commands/identity/whoami.ts +1 -1
  36. package/src/commands/instance/active.ts +13 -1
  37. package/src/commands/instance/bookmark.ts +26 -3
  38. package/src/commands/instance/list.ts +18 -4
  39. package/src/commands/instance/use.ts +54 -7
  40. package/src/commands/introspect.ts +117 -0
  41. package/src/commands/logs.ts +50 -6
  42. package/src/commands/mutate.ts +2 -3
  43. package/src/commands/query.ts +3 -5
  44. package/src/commands/token.ts +35 -8
  45. package/src/commands/update.ts +30 -9
  46. package/src/commands/view.ts +33 -19
  47. package/src/connection/.spec/architecture.md +5 -0
  48. package/src/connection/.spec/laws/connection.ts +20 -0
  49. package/src/connection/.spec/layout.ts +1 -0
  50. package/src/connection/__tests__/auth.test.ts +27 -1
  51. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  52. package/src/connection/__tests__/credential.test.ts +10 -0
  53. package/src/connection/__tests__/errors.test.ts +430 -36
  54. package/src/connection/__tests__/exchange.test.ts +46 -5
  55. package/src/connection/__tests__/reasons.test.ts +78 -0
  56. package/src/connection/auth.ts +11 -9
  57. package/src/connection/command.ts +1 -1
  58. package/src/connection/credential.ts +3 -0
  59. package/src/connection/errors.ts +195 -154
  60. package/src/connection/exchange.ts +14 -2
  61. package/src/connection/reasons.ts +179 -0
  62. package/src/connection/session.ts +9 -0
  63. package/src/connection/target.ts +1 -0
  64. package/src/graph/__tests__/mutation.test.ts +6 -0
  65. package/src/graph/mutation.ts +13 -0
  66. package/src/identity/__tests__/registry.test.ts +1 -1
  67. package/src/identity/registry.ts +12 -5
  68. package/src/lib/__tests__/command-dx.test.ts +35 -20
  69. package/src/lib/__tests__/instance-target.test.ts +17 -0
  70. package/src/lib/__tests__/instance.test.ts +51 -1
  71. package/src/lib/__tests__/output.test.ts +12 -0
  72. package/src/lib/__tests__/view-assets.test.ts +33 -1
  73. package/src/lib/__tests__/view-server.test.ts +68 -0
  74. package/src/lib/ca-fetch.ts +9 -3
  75. package/src/lib/command-dx.ts +40 -23
  76. package/src/lib/instance-target.ts +18 -2
  77. package/src/lib/instance.ts +31 -0
  78. package/src/lib/invocation.ts +11 -0
  79. package/src/lib/log.ts +17 -9
  80. package/src/lib/output.ts +20 -4
  81. package/src/lib/view/assets.ts +16 -2
  82. package/src/program/__tests__/program.test.ts +4 -1
  83. package/src/program/build.ts +5 -9
  84. package/src/program/options.ts +2 -1
  85. package/src/state/__tests__/identities.test.ts +2 -2
  86. package/src/state/identities.ts +3 -10
  87. package/studio/package.json +7 -8
  88. package/viewer/dist/main.js +57 -57
@@ -158,9 +158,44 @@ describe('Domain token exchange', () => {
158
158
  code: 'TOKEN_EXCHANGE_PROTOCOL_ERROR',
159
159
  })
160
160
  })
161
+
162
+ test('rejects success responses without no-store or with malformed fields', async () => {
163
+ const exchanged = token(DOMAIN, KERNEL, 'user-1', EXPIRES_AT)
164
+ const cache = () => new ExchangeCredentialCache(join(directory, crypto.randomUUID()))
165
+ const resolver = (fetch: Fetch) =>
166
+ createExchangeCredentialResolver(
167
+ TARGET,
168
+ { resolve: async () => SOURCE_TOKEN },
169
+ fetch,
170
+ 5_000,
171
+ cache(),
172
+ )
173
+
174
+ await expect(
175
+ resolver(exchangeFetch(exchanged, { cacheControl: false })).resolve(
176
+ KERNEL,
177
+ new AbortController().signal,
178
+ ),
179
+ ).rejects.toMatchObject({
180
+ code: 'TOKEN_EXCHANGE_PROTOCOL_ERROR',
181
+ message: 'Token exchange response is missing Cache-Control: no-store.',
182
+ })
183
+ await expect(
184
+ resolver(exchangeFetch(exchanged, { body: { token: 7, expiresAt: 'soon' } })).resolve(
185
+ KERNEL,
186
+ new AbortController().signal,
187
+ ),
188
+ ).rejects.toMatchObject({
189
+ code: 'TOKEN_EXCHANGE_PROTOCOL_ERROR',
190
+ message: 'Token exchange returned an invalid success response.',
191
+ })
192
+ })
161
193
  })
162
194
 
163
- function exchangeFetch(exchanged: string): Fetch {
195
+ function exchangeFetch(
196
+ exchanged: string,
197
+ options: { readonly body?: unknown; readonly cacheControl?: boolean } = {},
198
+ ): Fetch {
164
199
  return async (input, init) => {
165
200
  const url = String(input)
166
201
  if (url === INVOCATION) {
@@ -174,11 +209,17 @@ function exchangeFetch(exchanged: string): Fetch {
174
209
  )
175
210
  }
176
211
  if (url.endsWith('/.well-known/openid-configuration')) return jsonResponse(configuration(true))
177
- return jsonResponse(
178
- { token: exchanged, expiresAt: EXPIRES_AT },
179
- 200,
180
- 'application/vnd.astrale+json',
212
+ const response = new Response(
213
+ JSON.stringify(options.body ?? { token: exchanged, expiresAt: EXPIRES_AT }),
214
+ {
215
+ status: 200,
216
+ headers: {
217
+ 'content-type': 'application/vnd.astrale+json',
218
+ ...(options.cacheControl === false ? {} : { 'cache-control': 'no-store' }),
219
+ },
220
+ },
181
221
  )
222
+ return response
182
223
  }
183
224
  }
184
225
 
@@ -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
- }
@@ -53,7 +53,7 @@ export async function runKernelCommand<T>(input: {
53
53
  }
54
54
  } catch (error) {
55
55
  if (!isRaw && spin) spin.fail(`${label} failed`)
56
- await formatKernelError(error, isRaw, undefined, opts.debug, { credential: opts.creds })
56
+ await formatKernelError(error, isRaw, undefined, opts.debug)
57
57
  process.exit(1)
58
58
  }
59
59
  }
@@ -94,6 +94,9 @@ export function createCliCredential(
94
94
 
95
95
  /** Reject contradictory explicit credential selections before identity or network access. */
96
96
  export function validateCredentialSelection(options: ConnectionOptions): void {
97
+ if (options.as !== undefined && options.creds !== undefined) {
98
+ throw new AstraleError('INVALID_FLAG', '--as cannot be combined with --creds.')
99
+ }
97
100
  if (options.anonymous !== true) return
98
101
  const conflicting = [
99
102
  ...(options.as === undefined ? [] : ['--as']),
@@ -1,21 +1,24 @@
1
1
  import chalk from 'chalk'
2
2
 
3
- import type { SelfExpansionMeta } from './self'
4
-
5
3
  import { AstraleError } from '../errors'
6
- import { decodeJwtExpiration, readLocalStatus, type LocalStatus } from '../lib/local-status'
4
+ import { readLocalStatus, type LocalStatus } from '../lib/local-status'
7
5
  import { log } from '../lib/log'
6
+ import {
7
+ functionInputIssues,
8
+ queryInputRepair,
9
+ reasonCode,
10
+ schemaUpgradeDetails,
11
+ schemaUpgradeHint,
12
+ type FunctionInputIssue,
13
+ type QueryInputRepair,
14
+ } from './reasons'
8
15
 
9
- type FieldError = { path: string[]; code: string; message: string }
10
- type InvariantError = { code: string; message: string; context?: unknown }
16
+ export { functionInputIssues, schemaUpgradeHint } from './reasons'
11
17
 
12
18
  /**
13
19
  * Format and display a kernel client error.
14
20
  *
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).
21
+ * Handles CLI-local errors plus the current Kernel Client public error families.
19
22
  *
20
23
  * When `debug` is true, additional diagnostic information (class name, full
21
24
  * error chain, attached url/details) is printed after the user-facing line.
@@ -25,12 +28,10 @@ export async function formatKernelError(
25
28
  isRaw: boolean,
26
29
  urlArg = '',
27
30
  debug = false,
28
- opts: { credential?: string } = {},
29
31
  ): Promise<void> {
30
32
  const url =
31
33
  urlArg || (error instanceof Error ? ((error as Error & { url?: string }).url ?? '') : '')
32
34
  const localContext = await contextForError(error)
33
- const credentialExpiration = opts.credential ? decodeJwtExpiration(opts.credential) : null
34
35
  // Handle AstraleError (AuthError, etc.) with structured hints
35
36
  if (error instanceof AstraleError) {
36
37
  if (isRaw) {
@@ -52,173 +53,213 @@ export async function formatKernelError(
52
53
  const name = error.name
53
54
 
54
55
  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
- }
116
- break
117
-
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 call <path> --describe` to see the expected schema')
160
- }
56
+ case 'TransportError':
57
+ presentTransportError(error, isRaw, url, localContext)
161
58
  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
59
 
177
60
  case 'ResponseError': {
178
61
  const code = (error as { readonly code?: unknown }).code
179
62
  const reason = (error as { readonly reason?: unknown }).reason
63
+ const codeOfReason = reasonCode(reason)
64
+ const inputIssues = functionInputIssues(reason)
65
+ const queryRepair = queryInputRepair(reason)
66
+ const upgrade = schemaUpgradeDetails(reason)
67
+ const removalHint = schemaDataRemovalHint(reason)
68
+ const domainAddressNotPublic = codeOfReason === 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC'
69
+ const displayMessage = domainAddressNotPublic
70
+ ? 'Expose the Domain through a public HTTPS URL or public tunnel, then retry.'
71
+ : removalHint !== undefined && !isRaw
72
+ ? 'Existing business data still uses schema definitions being removed.'
73
+ : error.message
74
+ const hint =
75
+ codeOfReason === 'FUNCTION_INPUT_INVALID' && (isRaw || inputIssues.length === 0)
76
+ ? 'Use `astrale introspect <path>` to see the callable input.'
77
+ : (removalHint ?? (upgrade === undefined ? undefined : schemaUpgradeHint(upgrade)))
180
78
  if (isRaw) {
181
79
  writeRaw({
182
80
  error: 'RESPONSE_ERROR',
183
81
  ...(code === undefined ? {} : { code }),
184
- message: error.message,
82
+ message: displayMessage,
185
83
  ...(reason === undefined ? {} : { reason }),
84
+ ...(hint === undefined ? {} : { hint }),
186
85
  })
187
86
  } else {
188
87
  log.error(
189
- `${chalk.bold(code === undefined ? 'RESPONSE_ERROR' : `RESPONSE_ERROR(${String(code)})`)}: ${error.message}`,
88
+ domainAddressNotPublic
89
+ ? `${chalk.bold('SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC')}: ${displayMessage}`
90
+ : removalHint !== undefined
91
+ ? `${chalk.bold('DATA_MIGRATION_REQUIRED')}: ${displayMessage}`
92
+ : `${chalk.bold(code === undefined ? 'RESPONSE_ERROR' : `RESPONSE_ERROR(${String(code)})`)}: ${displayMessage}`,
190
93
  )
191
- if (
192
- reason !== null &&
193
- typeof reason === 'object' &&
194
- typeof (reason as { readonly code?: unknown }).code === 'string'
195
- ) {
196
- log.dim(` reason: ${(reason as { readonly code: string }).code}`)
94
+ if (codeOfReason !== undefined && !domainAddressNotPublic && removalHint === undefined) {
95
+ log.dim(` reason: ${codeOfReason}`)
96
+ }
97
+ presentFunctionInputIssues(inputIssues)
98
+ if (queryRepair !== undefined) presentQueryInputRepair(queryRepair)
99
+ if (upgrade?.expected !== undefined) {
100
+ log.dim(` installed issuer: ${upgrade.expected}`)
101
+ }
102
+ if (upgrade?.actual !== undefined) {
103
+ log.dim(` replacement issuer: ${upgrade.actual}`)
197
104
  }
105
+ if (hint !== undefined) log.dim(` ${hint}`)
198
106
  }
199
107
  break
200
108
  }
201
109
 
202
- case 'KernelError': {
203
- const code = (error as { code?: number | string }).code ?? 'UNKNOWN'
204
- const type = (error as { type?: string }).type ?? 'KERNEL_ERROR'
205
- if (isRaw) writeRaw({ error: type, code, message: error.message })
206
- else log.error(`${chalk.bold(`${type}(${code})`)}: ${error.message}`)
207
- break
110
+ default: {
111
+ const mapped = mapPublicError(error)
112
+ if (isRaw) {
113
+ writeRaw({
114
+ error: mapped.code,
115
+ message: mapped.message,
116
+ ...(mapped.hint === undefined ? {} : { hint: mapped.hint }),
117
+ ...(mapped.timeoutMs === undefined ? {} : { timeoutMs: mapped.timeoutMs }),
118
+ })
119
+ } else {
120
+ log.error(`${chalk.bold(mapped.code)}: ${mapped.message}`)
121
+ if (mapped.hint) log.dim(` ${mapped.hint}`)
122
+ }
208
123
  }
209
-
210
- default:
211
- // Catch-all: include class name so diagnosis is possible even without --debug
212
- if (isRaw) writeRaw({ error: name || 'UNKNOWN', message: error.message })
213
- else log.error(`${chalk.bold(name || 'Error')}: ${error.message}`)
214
124
  }
215
125
 
216
126
  if (debug) printDebug(error, url)
217
127
  }
218
128
 
219
- /** Strip internal `::methodName` suffixes from paths in error messages (e.g., "/path::listChildren" → "/path") */
220
- export function stripMethodSuffix(msg: string): string {
221
- return msg.replace(/(\/[^"\s:]+)::([a-zA-Z]\w*)/g, '$1')
129
+ function schemaDataRemovalHint(reason: unknown): string | undefined {
130
+ if (reason === null || typeof reason !== 'object') return undefined
131
+ const value = reason as { readonly code?: unknown; readonly details?: unknown }
132
+ if (value.code !== 'DATA_MIGRATION_REQUIRED') return undefined
133
+ if (value.details === null || typeof value.details !== 'object') return undefined
134
+
135
+ const requirements = (value.details as { readonly requirements?: unknown }).requirements
136
+ if (
137
+ !Array.isArray(requirements) ||
138
+ requirements.length === 0 ||
139
+ !requirements.every(
140
+ (requirement) =>
141
+ requirement !== null &&
142
+ typeof requirement === 'object' &&
143
+ (requirement as { readonly operation?: unknown }).operation === 'remove-facts' &&
144
+ (requirement as { readonly reason?: unknown }).reason === 'destructive-change',
145
+ )
146
+ ) {
147
+ return undefined
148
+ }
149
+
150
+ return 'Delete this data explicitly, then retry. No data was deleted.'
151
+ }
152
+
153
+ function mapPublicError(error: Error): {
154
+ code: string
155
+ message: string
156
+ hint?: string
157
+ timeoutMs?: number
158
+ } {
159
+ const name = error.name
160
+ if (name === 'PathError') {
161
+ return { code: 'PATH_INVALID', message: error.message }
162
+ }
163
+ if (name === 'NodeUnavailableError') {
164
+ return {
165
+ code: 'NODE_UNAVAILABLE',
166
+ message: error.message,
167
+ hint: 'If this is a callable Path, use `astrale call` or `astrale introspect`.',
168
+ }
169
+ }
170
+ if (name === 'AuthValueError') {
171
+ return { code: 'AUTH_VALUE_INVALID', message: error.message }
172
+ }
173
+ if (name === 'ClientError' && /timed out/i.test(error.message)) {
174
+ const timeoutMs = (error as { timeoutMs?: number }).timeoutMs
175
+ return {
176
+ code: 'TIMEOUT',
177
+ message: error.message,
178
+ hint: 'Try increasing with --timeout',
179
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
180
+ }
181
+ }
182
+ if (name === 'ClientError' && /Publication discovery returned HTTP/i.test(error.message)) {
183
+ return {
184
+ code: 'KERNEL_DISCOVERY_FAILED',
185
+ message: error.message,
186
+ hint: 'Pass the Kernel issuer URL (no /invoke suffix), e.g. https://host/kernel/host',
187
+ }
188
+ }
189
+ return { code: name && name !== 'Error' ? name : 'UNKNOWN', message: error.message }
190
+ }
191
+
192
+ function presentTransportError(
193
+ error: Error,
194
+ isRaw: boolean,
195
+ url: string,
196
+ context: LocalStatus | undefined,
197
+ ): void {
198
+ const phase = transportPhase(error)
199
+ const delivery = transportDelivery(error)
200
+ const code =
201
+ phase === 'connect'
202
+ ? 'CONNECTION_ERROR'
203
+ : phase === 'timeout'
204
+ ? 'TIMEOUT'
205
+ : phase === 'closed'
206
+ ? 'DISCONNECTED'
207
+ : 'TRANSPORT_ERROR'
208
+ if (isRaw) {
209
+ writeRaw({
210
+ error: code,
211
+ message: error.message,
212
+ ...(url === '' ? {} : { url }),
213
+ ...(phase === undefined ? {} : { phase }),
214
+ ...(delivery === undefined ? {} : { delivery }),
215
+ ...(context === undefined ? {} : { context }),
216
+ })
217
+ return
218
+ }
219
+ log.error(`${chalk.bold(code)}: ${error.message}`)
220
+ if (url !== '') log.dim(` target: ${url}`)
221
+ if (phase !== undefined) log.dim(` phase: ${phase}`)
222
+ if (phase === 'connect') log.dim(' Check the target and run `astrale status`.')
223
+ else if (phase === 'timeout') log.dim(' Try increasing `--timeout`.')
224
+ else if (delivery === 'unknown') {
225
+ log.dim(' Delivery is unknown; do not automatically retry a mutating call.')
226
+ }
227
+ printLocalContext(context)
228
+ }
229
+
230
+ function presentFunctionInputIssues(issues: readonly FunctionInputIssue[]): void {
231
+ for (const issue of issues) {
232
+ const location = issue.path === undefined || issue.path === '' ? '<input>' : issue.path
233
+ console.log(chalk.red(` ${location}: ${issue.message} (${chalk.dim(issue.code)})`))
234
+ }
235
+ }
236
+
237
+ function presentQueryInputRepair(repair: QueryInputRepair): void {
238
+ if (repair.phase === 'plan') {
239
+ log.dim(` ${repair.path ?? '/'} ${repair.issue}`)
240
+ return
241
+ }
242
+ if (repair.phase === 'limit') {
243
+ log.dim(` ${repair.path ?? '/'} ${repair.limit} limit ${repair.actual}/${repair.maximum}`)
244
+ return
245
+ }
246
+ log.dim(` ${repair.path} ${repair.phase} input`)
247
+ }
248
+
249
+ function transportPhase(error: Error): string | undefined {
250
+ const phase = (error as Error & { readonly phase?: unknown }).phase
251
+ return phase === 'connect' ||
252
+ phase === 'send' ||
253
+ phase === 'receive' ||
254
+ phase === 'timeout' ||
255
+ phase === 'closed'
256
+ ? phase
257
+ : undefined
258
+ }
259
+
260
+ function transportDelivery(error: Error): string | undefined {
261
+ const delivery = (error as Error & { readonly delivery?: unknown }).delivery
262
+ return delivery === 'not-sent' || delivery === 'unknown' ? delivery : undefined
222
263
  }
223
264
 
224
265
  function writeRaw(payload: Record<string, unknown>): void {
@@ -227,7 +268,7 @@ function writeRaw(payload: Record<string, unknown>): void {
227
268
 
228
269
  async function contextForError(error: unknown): Promise<LocalStatus | undefined> {
229
270
  if (!(error instanceof Error)) return undefined
230
- if (error.name !== 'AuthenticationError' && error.name !== 'ConnectionError') return undefined
271
+ if (error.name !== 'TransportError') return undefined
231
272
  return readLocalStatus().catch(() => undefined)
232
273
  }
233
274
 
@@ -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 ||