@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
@@ -172,22 +172,34 @@ async function findOnPath(name: string): Promise<string | null> {
172
172
 
173
173
  /** Dev checkout: (re)build the node-runnable CLI bundle when missing or stale. */
174
174
  async function ensureDevDist(entry: string, dist: string): Promise<void> {
175
- const bun = (
176
- globalThis as {
177
- Bun?: { build: (o: object) => Promise<{ success: boolean; logs: unknown[] }> }
178
- }
179
- ).Bun
180
- if (!bun) return
181
- const srcDir = join(dirname(entry), '..', 'src')
182
- if (existsSync(dist) && !(await newerThan(srcDir, statSync(dist).mtimeMs))) return
183
- // stderr: --json consumers parse stdout.
184
- console.error('(dev) building dist/astrale.js for the session server…')
185
- await bun.build({
186
- entrypoints: [entry],
187
- outdir: dirname(dist),
188
- target: 'node',
189
- format: 'esm',
190
- })
175
+ if (!(await devDistIsStale(entry, dist))) return
176
+ const projectDir = join(dirname(entry), '..')
177
+ const buildScript = join(projectDir, 'scripts', 'build.ts')
178
+ const bun = await findOnPath('bun')
179
+ if (!bun || !existsSync(buildScript)) return
180
+
181
+ // Build output goes to stderr so --json stdout remains valid.
182
+ console.error('(dev) dist/astrale.js is stale — running the official CLI build…')
183
+ const built = await run(bun, [buildScript], { cwd: projectDir })
184
+ if (built.stdout) process.stderr.write(built.stdout)
185
+ if (built.stderr) process.stderr.write(built.stderr)
186
+ if (built.code !== 0) throw new Error(`Official CLI build failed with exit code ${built.code}.`)
187
+ }
188
+
189
+ export async function devDistIsStale(entry: string, dist: string): Promise<boolean> {
190
+ if (!existsSync(dist)) return true
191
+ const projectDir = join(dirname(entry), '..')
192
+ const builtAt = statSync(dist).mtimeMs
193
+ const directories = [join(projectDir, 'src'), join(projectDir, 'bin'), join(projectDir, 'vendor')]
194
+ const files = [
195
+ join(projectDir, 'scripts', 'build.ts'),
196
+ join(projectDir, 'package.json'),
197
+ join(projectDir, 'pnpm-lock.yaml'),
198
+ ]
199
+ for (const directory of directories) {
200
+ if (existsSync(directory) && (await newerThan(directory, builtAt))) return true
201
+ }
202
+ return files.some((file) => existsSync(file) && statSync(file).mtimeMs > builtAt)
191
203
  }
192
204
 
193
205
  async function newerThan(dir: string, mtimeMs: number): Promise<boolean> {
@@ -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 /crm/customers/ada # views on a node
526
- $ astrale view /:crm.acme.dev:view.dashboard # explicit ViewPath
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 /crm/customers/ada --snapshot # open + show it
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
+ })
@@ -10,6 +10,7 @@ export default defineLayout({
10
10
  'errors.ts',
11
11
  'exchange.ts',
12
12
  'index.ts',
13
+ 'reasons.ts',
13
14
  'self.ts',
14
15
  'session.ts',
15
16
  'target.ts',
@@ -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 { resolveKeyIdentityAuthOptions } from '../auth'
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: () => new Response('trusted'),
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, stripMethodSuffix } from '../errors'
4
+ import { formatKernelError, functionInputIssues, schemaUpgradeHint } from '../errors'
5
5
 
6
6
  const CONFLICT = 4001
7
+ const VALIDATION = 1003
7
8
 
8
- describe('stripMethodSuffix', () => {
9
- test('strips ::listChildren from path', () => {
10
- expect(stripMethodSuffix('Path not found: "/nonexistent::listChildren"')).toBe(
11
- 'Path not found: "/nonexistent"',
12
- )
13
- })
14
-
15
- test('strips ::get from path', () => {
16
- expect(stripMethodSuffix('Path not found: "/some/path::get"')).toBe(
17
- 'Path not found: "/some/path"',
18
- )
19
- })
20
-
21
- test('strips any method name', () => {
22
- expect(stripMethodSuffix('"/kernel.astrale.ai/Root::describe"')).toBe(
23
- '"/kernel.astrale.ai/Root"',
24
- )
25
- })
26
-
27
- test('does not strip from non-path strings', () => {
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
- test('does not strip single colon (not a method dispatch)', () => {
40
- expect(stripMethodSuffix('"/a/b:notMethod"')).toBe('"/a/b:notMethod"')
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
  })