@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.
- package/README.md +4 -3
- package/dist/astrale.js +3391 -3163
- package/dist/public/connect-core.js +2041 -3066
- package/dist/public/keys/index.js +1854 -2895
- package/dist/public/paths/index.js +1833 -2882
- package/dist/types/connection/auth.d.ts +3 -0
- package/dist/types/lib/instance-target.d.ts +2 -0
- package/dist/types/lib/instance.d.ts +10 -0
- package/dist/types/lib/invocation.d.ts +3 -0
- package/dist/types/lib/log.d.ts +6 -5
- package/dist/types/lib/output.d.ts +6 -2
- package/package.json +8 -8
- package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
- package/src/commands/__tests__/install-identity-override.test.ts +14 -3
- package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
- package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
- package/src/commands/__tests__/instance-use.test.ts +67 -0
- package/src/commands/__tests__/introspect-parse.test.ts +21 -0
- package/src/commands/__tests__/logs.test.ts +5 -0
- package/src/commands/__tests__/read-commands.test.ts +11 -1
- package/src/commands/__tests__/token-ttl.test.ts +21 -0
- package/src/commands/__tests__/view-build.test.ts +58 -0
- package/src/commands/auth/token.ts +19 -7
- package/src/commands/call.ts +18 -43
- package/src/commands/domain/install.ts +6 -5
- package/src/commands/domain/uninstall.ts +128 -0
- package/src/commands/get.ts +23 -5
- package/src/commands/identity/create.ts +11 -2
- package/src/commands/identity/delete.ts +8 -2
- package/src/commands/identity/export.ts +8 -2
- package/src/commands/identity/import.ts +11 -2
- package/src/commands/identity/sync.ts +8 -2
- package/src/commands/identity/unsync.ts +12 -2
- package/src/commands/identity/use.ts +8 -2
- package/src/commands/identity/whoami.ts +1 -1
- package/src/commands/instance/active.ts +13 -1
- package/src/commands/instance/bookmark.ts +26 -3
- package/src/commands/instance/list.ts +18 -4
- package/src/commands/instance/use.ts +54 -7
- package/src/commands/introspect.ts +117 -0
- package/src/commands/logs.ts +50 -6
- package/src/commands/mutate.ts +2 -3
- package/src/commands/query.ts +3 -5
- package/src/commands/token.ts +35 -8
- package/src/commands/update.ts +30 -9
- package/src/commands/view.ts +33 -19
- package/src/connection/.spec/architecture.md +5 -0
- package/src/connection/.spec/laws/connection.ts +20 -0
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/auth.test.ts +27 -1
- package/src/connection/__tests__/ca-fetch.test.ts +8 -1
- package/src/connection/__tests__/credential.test.ts +10 -0
- package/src/connection/__tests__/errors.test.ts +430 -36
- package/src/connection/__tests__/exchange.test.ts +46 -5
- package/src/connection/__tests__/reasons.test.ts +78 -0
- package/src/connection/auth.ts +11 -9
- package/src/connection/command.ts +1 -1
- package/src/connection/credential.ts +3 -0
- package/src/connection/errors.ts +195 -154
- package/src/connection/exchange.ts +14 -2
- package/src/connection/reasons.ts +179 -0
- package/src/connection/session.ts +9 -0
- package/src/connection/target.ts +1 -0
- package/src/graph/__tests__/mutation.test.ts +6 -0
- package/src/graph/mutation.ts +13 -0
- package/src/identity/__tests__/registry.test.ts +1 -1
- package/src/identity/registry.ts +12 -5
- package/src/lib/__tests__/command-dx.test.ts +35 -20
- package/src/lib/__tests__/instance-target.test.ts +17 -0
- package/src/lib/__tests__/instance.test.ts +51 -1
- package/src/lib/__tests__/output.test.ts +12 -0
- package/src/lib/__tests__/view-assets.test.ts +33 -1
- package/src/lib/__tests__/view-server.test.ts +68 -0
- package/src/lib/ca-fetch.ts +9 -3
- package/src/lib/command-dx.ts +40 -23
- package/src/lib/instance-target.ts +18 -2
- package/src/lib/instance.ts +31 -0
- package/src/lib/invocation.ts +11 -0
- package/src/lib/log.ts +17 -9
- package/src/lib/output.ts +20 -4
- package/src/lib/view/assets.ts +16 -2
- package/src/program/__tests__/program.test.ts +4 -1
- package/src/program/build.ts +5 -9
- package/src/program/options.ts +2 -1
- package/src/state/__tests__/identities.test.ts +2 -2
- package/src/state/identities.ts +3 -10
- package/studio/package.json +7 -8
- 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(
|
|
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
|
-
|
|
178
|
-
{ token: exchanged, expiresAt: EXPIRES_AT },
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
})
|
package/src/connection/auth.ts
CHANGED
|
@@ -162,7 +162,7 @@ async function resolveIdpAccessToken(
|
|
|
162
162
|
)
|
|
163
163
|
}
|
|
164
164
|
if (e instanceof IdpSessionNoRefreshTokenError) {
|
|
165
|
-
throw
|
|
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
|
|
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']),
|
package/src/connection/errors.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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 '
|
|
56
|
-
|
|
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:
|
|
82
|
+
message: displayMessage,
|
|
185
83
|
...(reason === undefined ? {} : { reason }),
|
|
84
|
+
...(hint === undefined ? {} : { hint }),
|
|
186
85
|
})
|
|
187
86
|
} else {
|
|
188
87
|
log.error(
|
|
189
|
-
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
196
|
-
|
|
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
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 !== '
|
|
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
|
-
|
|
169
|
-
|
|
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 ||
|