@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
package/src/commands/view.ts
CHANGED
|
@@ -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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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> {
|
|
@@ -522,15 +534,17 @@ What it does:
|
|
|
522
534
|
an audience-bound credential for shell mounts, and the kernel endpoint.
|
|
523
535
|
|
|
524
536
|
Examples:
|
|
525
|
-
$ astrale view
|
|
526
|
-
$ astrale view /:crm.
|
|
537
|
+
$ astrale view @customer
|
|
538
|
+
$ astrale view /:crm.example.dev:view.dashboard
|
|
527
539
|
$ astrale view /:agents.astrale.ai:view.agent --target @f00d1234 --as alice
|
|
528
|
-
$ astrale view
|
|
540
|
+
$ astrale view @customer --snapshot
|
|
541
|
+
$ astrale view --list
|
|
529
542
|
$ astrale view --sessions ; astrale view --close --all
|
|
530
543
|
`,
|
|
531
544
|
action: async (spec: string | undefined, opts: ViewOpts) => {
|
|
532
545
|
if (opts.close !== undefined) return closeCommand(opts)
|
|
533
546
|
if (opts.sessions) return sessionsCommand(opts)
|
|
547
|
+
if (opts.list && !spec) return sessionsCommand(opts)
|
|
534
548
|
|
|
535
549
|
rejectUnrepresentableOverrides(opts)
|
|
536
550
|
if (!spec) return fatal(new Error('Nothing to open — pass a ViewPath or target node.'))
|
|
@@ -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
|
+
})
|
|
@@ -3,7 +3,9 @@ import { describe, expect, test } from 'bun:test'
|
|
|
3
3
|
import type { Identity } from '../../identity/index'
|
|
4
4
|
import type { AstraleConfig } from '../../lib/config'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { IdpAudienceMismatchError } from '../../lib/idp'
|
|
7
|
+
import { IdpSessionNoRefreshTokenError } from '../../lib/idp-session'
|
|
8
|
+
import { classifyNoRefreshTokenError, resolveKeyIdentityAuthOptions } from '../auth'
|
|
7
9
|
|
|
8
10
|
const config: AstraleConfig = {
|
|
9
11
|
issuer: 'https://unregistered.invalid',
|
|
@@ -100,3 +102,27 @@ describe('resolveKeyIdentityAuthOptions', () => {
|
|
|
100
102
|
})
|
|
101
103
|
})
|
|
102
104
|
})
|
|
105
|
+
|
|
106
|
+
describe('classifyNoRefreshTokenError', () => {
|
|
107
|
+
test('preserves expiry when the non-refreshable session already targets the required audience', () => {
|
|
108
|
+
const expired = new IdpSessionNoRefreshTokenError('alice')
|
|
109
|
+
|
|
110
|
+
expect(
|
|
111
|
+
classifyNoRefreshTokenError('https://kernel.example', 'https://kernel.example', expired),
|
|
112
|
+
).toBe(expired)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('reports a real audience mismatch when the source audience differs', () => {
|
|
116
|
+
const result = classifyNoRefreshTokenError(
|
|
117
|
+
'https://child.example',
|
|
118
|
+
'https://manager.example',
|
|
119
|
+
new IdpSessionNoRefreshTokenError('alice'),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(result).toBeInstanceOf(IdpAudienceMismatchError)
|
|
123
|
+
expect(result).toMatchObject({
|
|
124
|
+
requested: 'https://child.example',
|
|
125
|
+
actual: 'https://manager.example',
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -29,7 +29,10 @@ describe('connection CA fetch', () => {
|
|
|
29
29
|
hostname: '127.0.0.1',
|
|
30
30
|
port: 0,
|
|
31
31
|
tls: { cert: certificate, key },
|
|
32
|
-
fetch: () =>
|
|
32
|
+
fetch: (request) =>
|
|
33
|
+
new URL(request.url).pathname === '/cached'
|
|
34
|
+
? new Response(null, { status: 304, headers: { etag: 'retained' } })
|
|
35
|
+
: new Response('trusted'),
|
|
33
36
|
})
|
|
34
37
|
|
|
35
38
|
const requests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
|
|
@@ -45,6 +48,10 @@ describe('connection CA fetch', () => {
|
|
|
45
48
|
expect(await httpsResponse.text()).toBe('trusted')
|
|
46
49
|
expect(httpsResponse.url).toBe(httpsUrl)
|
|
47
50
|
expect(httpsResponse.redirected).toBe(false)
|
|
51
|
+
const cachedResponse = await scoped(`https://127.0.0.1:${server.port}/cached`)
|
|
52
|
+
expect(cachedResponse.status).toBe(304)
|
|
53
|
+
expect(cachedResponse.body).toBeNull()
|
|
54
|
+
expect(cachedResponse.headers.get('etag')).toBe('retained')
|
|
48
55
|
await expect(
|
|
49
56
|
scoped('http://localhost:8080/invoke', init).then((value) => value.text()),
|
|
50
57
|
).resolves.toBe('fallback')
|
|
@@ -82,6 +82,16 @@ describe('connection credential', () => {
|
|
|
82
82
|
expect(auth).toBeUndefined()
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
test('rejects --as combined with --creds', () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
createCliCredential(
|
|
88
|
+
{ url: `${SOURCE}/invoke`, kernelIssuer: SOURCE },
|
|
89
|
+
{ as: 'alice', creds: 'token' },
|
|
90
|
+
config,
|
|
91
|
+
),
|
|
92
|
+
).toThrow('--as cannot be combined with --creds')
|
|
93
|
+
})
|
|
94
|
+
|
|
85
95
|
/** @evidence TEST-CLI-CONNECTION-PROPAGATES-AUTH-CANCELLATION */
|
|
86
96
|
test('passes the live Session operation signal to source credential resolution', async () => {
|
|
87
97
|
const controller = new AbortController()
|
|
@@ -1,47 +1,42 @@
|
|
|
1
|
-
import { ResponseError } from '@astrale-os/kernel-client'
|
|
1
|
+
import { ResponseError, TransportError } from '@astrale-os/kernel-client'
|
|
2
2
|
import { describe, expect, test } from 'bun:test'
|
|
3
3
|
|
|
4
|
-
import { formatKernelError,
|
|
4
|
+
import { formatKernelError, functionInputIssues, schemaUpgradeHint } from '../errors'
|
|
5
5
|
|
|
6
6
|
const CONFLICT = 4001
|
|
7
|
+
const VALIDATION = 1003
|
|
7
8
|
|
|
8
|
-
describe('
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
expect(stripMethodSuffix('regular::text')).toBe('regular::text')
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
test('handles multiple paths in one message', () => {
|
|
32
|
-
expect(stripMethodSuffix('"/a/b::get" and "/c/d::list"')).toBe('"/a/b" and "/c/d"')
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
test('preserves messages without method suffixes', () => {
|
|
36
|
-
expect(stripMethodSuffix('No path found')).toBe('No path found')
|
|
37
|
-
})
|
|
9
|
+
describe('formatKernelError', () => {
|
|
10
|
+
/** @evidence TEST-CLI-CONNECTION-MAPS-TYPED-TRANSPORT */
|
|
11
|
+
test('maps typed discovery transport evidence without inspecting its cause message', async () => {
|
|
12
|
+
const writes: string[] = []
|
|
13
|
+
const original = process.stderr.write
|
|
14
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
15
|
+
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
16
|
+
return true
|
|
17
|
+
}) as typeof process.stderr.write
|
|
18
|
+
const error = new TransportError('Publication discovery request failed.', {
|
|
19
|
+
cause: Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }),
|
|
20
|
+
phase: 'connect',
|
|
21
|
+
delivery: 'unknown',
|
|
22
|
+
}) as TransportError & { url?: string }
|
|
23
|
+
error.url = 'https://localhost:8443/kernel/host'
|
|
24
|
+
try {
|
|
25
|
+
await formatKernelError(error, true)
|
|
26
|
+
} finally {
|
|
27
|
+
process.stderr.write = original
|
|
28
|
+
}
|
|
38
29
|
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
expect(JSON.parse(writes[0]!)).toMatchObject({
|
|
31
|
+
error: 'CONNECTION_ERROR',
|
|
32
|
+
message: 'Publication discovery request failed.',
|
|
33
|
+
url: 'https://localhost:8443/kernel/host',
|
|
34
|
+
phase: 'connect',
|
|
35
|
+
delivery: 'unknown',
|
|
36
|
+
})
|
|
37
|
+
expect(writes[0]).not.toContain('ECONNREFUSED')
|
|
41
38
|
})
|
|
42
|
-
})
|
|
43
39
|
|
|
44
|
-
describe('formatKernelError', () => {
|
|
45
40
|
/** @evidence TEST-CLI-CONNECTION-PRESERVES-PUBLIC-SEMANTIC-REASON */
|
|
46
41
|
test('preserves a Kernel-admitted semantic reason in machine output', async () => {
|
|
47
42
|
const writes: string[] = []
|
|
@@ -93,4 +88,403 @@ describe('formatKernelError', () => {
|
|
|
93
88
|
},
|
|
94
89
|
})
|
|
95
90
|
})
|
|
91
|
+
|
|
92
|
+
test('explains deletion-only schema work without changing the Kernel reason', async () => {
|
|
93
|
+
const writes: string[] = []
|
|
94
|
+
const original = process.stderr.write
|
|
95
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
96
|
+
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
97
|
+
return true
|
|
98
|
+
}) as typeof process.stderr.write
|
|
99
|
+
try {
|
|
100
|
+
await formatKernelError(
|
|
101
|
+
new ResponseError(CONFLICT, 'Schema change requires migration.', {
|
|
102
|
+
code: 'DATA_MIGRATION_REQUIRED',
|
|
103
|
+
details: {
|
|
104
|
+
requirements: [
|
|
105
|
+
{
|
|
106
|
+
operation: 'remove-facts',
|
|
107
|
+
reason: 'destructive-change',
|
|
108
|
+
subject: {
|
|
109
|
+
kind: 'definition',
|
|
110
|
+
ref: { origin: 'x.test', kind: 'class', name: 'X' },
|
|
111
|
+
},
|
|
112
|
+
work: { observedFacts: 1 },
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
true,
|
|
118
|
+
)
|
|
119
|
+
} finally {
|
|
120
|
+
process.stderr.write = original
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
expect(JSON.parse(writes[0]!)).toEqual({
|
|
124
|
+
error: 'RESPONSE_ERROR',
|
|
125
|
+
code: CONFLICT,
|
|
126
|
+
message: 'Schema change requires migration.',
|
|
127
|
+
reason: {
|
|
128
|
+
code: 'DATA_MIGRATION_REQUIRED',
|
|
129
|
+
details: {
|
|
130
|
+
requirements: [
|
|
131
|
+
{
|
|
132
|
+
operation: 'remove-facts',
|
|
133
|
+
reason: 'destructive-change',
|
|
134
|
+
subject: {
|
|
135
|
+
kind: 'definition',
|
|
136
|
+
ref: { origin: 'x.test', kind: 'class', name: 'X' },
|
|
137
|
+
},
|
|
138
|
+
work: { observedFacts: 1 },
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
hint: 'Delete this data explicitly, then retry. No data was deleted.',
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('prints an actionable message for deletion-only schema work', async () => {
|
|
148
|
+
const errors: string[] = []
|
|
149
|
+
const hints: string[] = []
|
|
150
|
+
const originalError = console.error
|
|
151
|
+
const originalLog = console.log
|
|
152
|
+
console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
|
|
153
|
+
console.log = (...values: unknown[]) => hints.push(values.map(String).join(' '))
|
|
154
|
+
try {
|
|
155
|
+
await formatKernelError(
|
|
156
|
+
new ResponseError(CONFLICT, 'Schema change requires migration.', {
|
|
157
|
+
code: 'DATA_MIGRATION_REQUIRED',
|
|
158
|
+
details: {
|
|
159
|
+
requirements: [
|
|
160
|
+
{
|
|
161
|
+
operation: 'remove-facts',
|
|
162
|
+
reason: 'destructive-change',
|
|
163
|
+
subject: {
|
|
164
|
+
kind: 'definition',
|
|
165
|
+
ref: { origin: 'x.test', kind: 'class', name: 'X' },
|
|
166
|
+
},
|
|
167
|
+
work: { observedFacts: 1 },
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
false,
|
|
173
|
+
)
|
|
174
|
+
} finally {
|
|
175
|
+
console.error = originalError
|
|
176
|
+
console.log = originalLog
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
expect(errors.join('\n')).toContain('DATA_MIGRATION_REQUIRED')
|
|
180
|
+
expect(errors.join('\n')).toContain(
|
|
181
|
+
'Existing business data still uses schema definitions being removed.',
|
|
182
|
+
)
|
|
183
|
+
expect(hints.join('\n')).toContain(
|
|
184
|
+
'Delete this data explicitly, then retry. No data was deleted.',
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('maps SDK class names to stable CLI error codes', async () => {
|
|
189
|
+
const writes: string[] = []
|
|
190
|
+
const original = process.stderr.write
|
|
191
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
192
|
+
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
193
|
+
return true
|
|
194
|
+
}) as typeof process.stderr.write
|
|
195
|
+
try {
|
|
196
|
+
const pathError = new Error('Path must have an Id or Domain anchor.')
|
|
197
|
+
pathError.name = 'PathError'
|
|
198
|
+
await formatKernelError(pathError, true)
|
|
199
|
+
} finally {
|
|
200
|
+
process.stderr.write = original
|
|
201
|
+
}
|
|
202
|
+
expect(JSON.parse(writes[0]!)).toEqual({
|
|
203
|
+
error: 'PATH_INVALID',
|
|
204
|
+
message: 'Path must have an Id or Domain anchor.',
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
/** @evidence TEST-CLI-CONNECTION-PRESENTS-BOUNDED-REPAIRS */
|
|
209
|
+
test('renders callable and Query repair coordinates for humans', async () => {
|
|
210
|
+
const lines: string[] = []
|
|
211
|
+
const originalError = console.error
|
|
212
|
+
const originalLog = console.log
|
|
213
|
+
console.error = (...values: unknown[]) => lines.push(values.join(' '))
|
|
214
|
+
console.log = (...values: unknown[]) => lines.push(values.join(' '))
|
|
215
|
+
try {
|
|
216
|
+
await formatKernelError(
|
|
217
|
+
new ResponseError(VALIDATION, 'Function input is invalid.', {
|
|
218
|
+
code: 'FUNCTION_INPUT_INVALID',
|
|
219
|
+
details: {
|
|
220
|
+
issues: [
|
|
221
|
+
{
|
|
222
|
+
code: 'VALUE_SCHEMA_INSTANCE_INVALID',
|
|
223
|
+
path: '/issuer',
|
|
224
|
+
message: 'Object is missing required property issuer.',
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
code: 'VALUE_SCHEMA_INSTANCE_INVALID',
|
|
228
|
+
path: '/operationId',
|
|
229
|
+
message: 'Object is missing required property operationId.',
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
code: 'VALUE_SCHEMA_INSTANCE_INVALID',
|
|
233
|
+
path: '/slug',
|
|
234
|
+
message: 'Object is missing required property slug.',
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
false,
|
|
240
|
+
)
|
|
241
|
+
await formatKernelError(
|
|
242
|
+
new ResponseError(VALIDATION, 'Query input is invalid.', {
|
|
243
|
+
code: 'QUERY_INPUT_INVALID',
|
|
244
|
+
details: {
|
|
245
|
+
phase: 'plan',
|
|
246
|
+
issue: 'QUERY_DEFINITION_NOT_EDGE',
|
|
247
|
+
path: '/steps/0/via/0',
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
false,
|
|
251
|
+
)
|
|
252
|
+
} finally {
|
|
253
|
+
console.error = originalError
|
|
254
|
+
console.log = originalLog
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const rendered = lines.join('\n')
|
|
258
|
+
expect(rendered).toContain('/issuer: Object is missing required property issuer.')
|
|
259
|
+
expect(rendered).toContain('/operationId: Object is missing required property operationId.')
|
|
260
|
+
expect(rendered).toContain('/slug: Object is missing required property slug.')
|
|
261
|
+
expect(rendered).toContain('/steps/0/via/0 QUERY_DEFINITION_NOT_EDGE')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('explains immutable issuer replacements and preserves the Kernel reason', async () => {
|
|
265
|
+
const writes: string[] = []
|
|
266
|
+
const original = process.stderr.write
|
|
267
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
268
|
+
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
269
|
+
return true
|
|
270
|
+
}) as typeof process.stderr.write
|
|
271
|
+
try {
|
|
272
|
+
await formatKernelError(
|
|
273
|
+
new ResponseError(5001, 'Schema operation is not supported.', {
|
|
274
|
+
code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
|
|
275
|
+
details: {
|
|
276
|
+
phase: 'upgrade',
|
|
277
|
+
origin: 'grc.example',
|
|
278
|
+
expected: 'https://old.example',
|
|
279
|
+
actual: 'https://new.example',
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
true,
|
|
283
|
+
)
|
|
284
|
+
} finally {
|
|
285
|
+
process.stderr.write = original
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
expect(JSON.parse(writes[0]!)).toEqual({
|
|
289
|
+
error: 'RESPONSE_ERROR',
|
|
290
|
+
code: 5001,
|
|
291
|
+
message: 'Schema operation is not supported.',
|
|
292
|
+
reason: {
|
|
293
|
+
code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
|
|
294
|
+
details: {
|
|
295
|
+
phase: 'upgrade',
|
|
296
|
+
origin: 'grc.example',
|
|
297
|
+
expected: 'https://old.example',
|
|
298
|
+
actual: 'https://new.example',
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
hint: schemaUpgradeHint({
|
|
302
|
+
origin: 'grc.example',
|
|
303
|
+
expected: 'https://old.example',
|
|
304
|
+
actual: 'https://new.example',
|
|
305
|
+
}),
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('explains a private Domain source without exposing transport diagnostics', async () => {
|
|
310
|
+
const writes: string[] = []
|
|
311
|
+
const original = process.stderr.write
|
|
312
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
313
|
+
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
314
|
+
return true
|
|
315
|
+
}) as typeof process.stderr.write
|
|
316
|
+
try {
|
|
317
|
+
await formatKernelError(
|
|
318
|
+
new ResponseError(1003, 'Function input is invalid.', {
|
|
319
|
+
code: 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC',
|
|
320
|
+
details: { phase: 'publication', requiredScope: 'public' },
|
|
321
|
+
}),
|
|
322
|
+
true,
|
|
323
|
+
)
|
|
324
|
+
} finally {
|
|
325
|
+
process.stderr.write = original
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
expect(JSON.parse(writes[0]!)).toEqual({
|
|
329
|
+
error: 'RESPONSE_ERROR',
|
|
330
|
+
code: 1003,
|
|
331
|
+
message: 'Expose the Domain through a public HTTPS URL or public tunnel, then retry.',
|
|
332
|
+
reason: {
|
|
333
|
+
code: 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC',
|
|
334
|
+
details: { phase: 'publication', requiredScope: 'public' },
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('prints the actionable private-source message in interactive output', async () => {
|
|
340
|
+
const errors: string[] = []
|
|
341
|
+
const hints: string[] = []
|
|
342
|
+
const originalError = console.error
|
|
343
|
+
const originalLog = console.log
|
|
344
|
+
console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
|
|
345
|
+
console.log = (...values: unknown[]) => hints.push(values.map(String).join(' '))
|
|
346
|
+
try {
|
|
347
|
+
await formatKernelError(
|
|
348
|
+
new ResponseError(1003, 'Function input is invalid.', {
|
|
349
|
+
code: 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC',
|
|
350
|
+
details: { phase: 'publication', requiredScope: 'public' },
|
|
351
|
+
}),
|
|
352
|
+
false,
|
|
353
|
+
)
|
|
354
|
+
} finally {
|
|
355
|
+
console.error = originalError
|
|
356
|
+
console.log = originalLog
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
expect(errors.join('\n')).toContain('SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC')
|
|
360
|
+
expect(errors.join('\n')).toContain(
|
|
361
|
+
'Expose the Domain through a public HTTPS URL or public tunnel, then retry.',
|
|
362
|
+
)
|
|
363
|
+
expect(hints).toEqual([])
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('preserves structured Function input issues in machine output', async () => {
|
|
367
|
+
const writes: string[] = []
|
|
368
|
+
const original = process.stderr.write
|
|
369
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
370
|
+
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
371
|
+
return true
|
|
372
|
+
}) as typeof process.stderr.write
|
|
373
|
+
try {
|
|
374
|
+
await formatKernelError(
|
|
375
|
+
new ResponseError(1003, 'Function input is invalid.', {
|
|
376
|
+
code: 'FUNCTION_INPUT_INVALID',
|
|
377
|
+
details: {
|
|
378
|
+
issues: [
|
|
379
|
+
{
|
|
380
|
+
code: 'INVALID_FORMAT',
|
|
381
|
+
path: '/customer/email',
|
|
382
|
+
message: 'Must be a valid email address.',
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
},
|
|
386
|
+
}),
|
|
387
|
+
true,
|
|
388
|
+
)
|
|
389
|
+
} finally {
|
|
390
|
+
process.stderr.write = original
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
expect(JSON.parse(writes[0]!)).toEqual({
|
|
394
|
+
error: 'RESPONSE_ERROR',
|
|
395
|
+
code: 1003,
|
|
396
|
+
message: 'Function input is invalid.',
|
|
397
|
+
reason: {
|
|
398
|
+
code: 'FUNCTION_INPUT_INVALID',
|
|
399
|
+
details: {
|
|
400
|
+
issues: [
|
|
401
|
+
{
|
|
402
|
+
code: 'INVALID_FORMAT',
|
|
403
|
+
path: '/customer/email',
|
|
404
|
+
message: 'Must be a valid email address.',
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
hint: 'Use `astrale introspect <path>` to see the callable input.',
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('prints Kernel-provided Function input messages in interactive output', async () => {
|
|
414
|
+
const errors: string[] = []
|
|
415
|
+
const details: string[] = []
|
|
416
|
+
const originalError = console.error
|
|
417
|
+
const originalLog = console.log
|
|
418
|
+
console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
|
|
419
|
+
console.log = (...values: unknown[]) => details.push(values.map(String).join(' '))
|
|
420
|
+
try {
|
|
421
|
+
await formatKernelError(
|
|
422
|
+
new ResponseError(1003, 'Function input is invalid.', {
|
|
423
|
+
code: 'FUNCTION_INPUT_INVALID',
|
|
424
|
+
details: {
|
|
425
|
+
issues: [
|
|
426
|
+
{
|
|
427
|
+
code: 'INVALID_FORMAT',
|
|
428
|
+
path: '/customer/email',
|
|
429
|
+
message: 'Must be a valid email address.',
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
},
|
|
433
|
+
}),
|
|
434
|
+
false,
|
|
435
|
+
)
|
|
436
|
+
} finally {
|
|
437
|
+
console.error = originalError
|
|
438
|
+
console.log = originalLog
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
expect(errors.join('\n')).toContain('Function input is invalid.')
|
|
442
|
+
expect(details.join('\n')).toContain(
|
|
443
|
+
'/customer/email: Must be a valid email address. (INVALID_FORMAT)',
|
|
444
|
+
)
|
|
445
|
+
expect(details.join('\n')).not.toContain('astrale introspect')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
test('keeps the introspection fallback for legacy Function input issues', async () => {
|
|
449
|
+
const writes: string[] = []
|
|
450
|
+
const original = process.stderr.write
|
|
451
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
452
|
+
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
453
|
+
return true
|
|
454
|
+
}) as typeof process.stderr.write
|
|
455
|
+
try {
|
|
456
|
+
await formatKernelError(
|
|
457
|
+
new ResponseError(1003, 'Function input is invalid.', {
|
|
458
|
+
code: 'FUNCTION_INPUT_INVALID',
|
|
459
|
+
details: { issues: [{ code: 'INVALID_FORMAT', path: '/customer/email' }] },
|
|
460
|
+
}),
|
|
461
|
+
true,
|
|
462
|
+
)
|
|
463
|
+
} finally {
|
|
464
|
+
process.stderr.write = original
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
expect(JSON.parse(writes[0]!)).toMatchObject({
|
|
468
|
+
reason: {
|
|
469
|
+
code: 'FUNCTION_INPUT_INVALID',
|
|
470
|
+
details: { issues: [{ code: 'INVALID_FORMAT', path: '/customer/email' }] },
|
|
471
|
+
},
|
|
472
|
+
hint: 'Use `astrale introspect <path>` to see the callable input.',
|
|
473
|
+
})
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
test('rejects malformed public Function issue messages', () => {
|
|
477
|
+
expect(
|
|
478
|
+
functionInputIssues({
|
|
479
|
+
code: 'FUNCTION_INPUT_INVALID',
|
|
480
|
+
details: {
|
|
481
|
+
issues: [
|
|
482
|
+
{ code: 'INVALID_FORMAT', path: '/safe', message: 'Safe message.' },
|
|
483
|
+
{ code: 'INVALID_FORMAT', path: 'not-a-pointer', message: 'Unsafe path.' },
|
|
484
|
+
{ code: 'INVALID_FORMAT', path: '/unsafe', message: 'Unsafe\nmessage.' },
|
|
485
|
+
],
|
|
486
|
+
},
|
|
487
|
+
}),
|
|
488
|
+
).toEqual([{ code: 'INVALID_FORMAT', path: '/safe', message: 'Safe message.' }])
|
|
489
|
+
})
|
|
96
490
|
})
|