@astrale-os/cli 0.8.1-alpha.7 → 1.0.0-beta.1

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 (68) hide show
  1. package/README.md +15 -3
  2. package/dist/astrale.js +113 -56
  3. package/package.json +5 -5
  4. package/src/admin/instance/__tests__/client.test.ts +3 -1
  5. package/src/admin/instance/client.ts +3 -16
  6. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  7. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  8. package/src/commands/__tests__/install-direct.test.ts +3 -2
  9. package/src/commands/__tests__/update.test.ts +66 -0
  10. package/src/commands/domain/install.ts +78 -15
  11. package/src/commands/update.ts +32 -10
  12. package/src/connection/__tests__/errors.test.ts +82 -6
  13. package/src/connection/command.ts +9 -1
  14. package/src/connection/errors.ts +19 -8
  15. package/src/connection/index.ts +1 -1
  16. package/src/connection/reasons.ts +26 -12
  17. package/src/program/__tests__/program.test.ts +1 -1
  18. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  19. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  20. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  21. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  22. package/studio/client/dist/index.html +2 -2
  23. package/studio/package.json +1 -1
  24. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  25. package/studio/server/agent/prompts/anchors.ts +85 -15
  26. package/studio/server/agent/prompts/system.test.ts +12 -0
  27. package/studio/server/agent/prompts/system.ts +6 -6
  28. package/studio/server/api.ts +1 -5
  29. package/studio/server/cache.ts +5 -2
  30. package/studio/server/domain.test.ts +67 -0
  31. package/studio/server/domain.ts +29 -6
  32. package/studio/server/index.ts +1 -3
  33. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  34. package/studio/server/introspect/anatomy-extras.ts +340 -8
  35. package/studio/server/introspect/anatomy.test.ts +33 -0
  36. package/studio/server/introspect/anatomy.ts +22 -7
  37. package/studio/server/introspect/bundle.ts +7 -1
  38. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  39. package/studio/server/introspect/canonical-schema.ts +751 -0
  40. package/studio/server/introspect/core-extractor.ts +30 -10
  41. package/studio/server/introspect/core.ts +4 -2
  42. package/studio/server/introspect/diff.test.ts +124 -0
  43. package/studio/server/introspect/diff.ts +252 -23
  44. package/studio/server/introspect/extractor.ts +46 -14
  45. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  46. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  47. package/studio/server/introspect/overlay.test.ts +72 -0
  48. package/studio/server/introspect/overlay.ts +16 -6
  49. package/studio/server/introspect/runtime.test.ts +217 -0
  50. package/studio/server/introspect/runtime.ts +18 -6
  51. package/studio/server/introspect/schema-refs.test.ts +100 -0
  52. package/studio/server/introspect/schema-refs.ts +23 -2
  53. package/studio/server/state/baseline.test.ts +51 -0
  54. package/studio/server/state/baseline.ts +31 -2
  55. package/studio/server/state/create.test.ts +37 -0
  56. package/studio/server/state/create.ts +21 -15
  57. package/studio/server/state/instance.test.ts +63 -0
  58. package/studio/server/state/instance.ts +65 -29
  59. package/studio/server/state/views.test.ts +209 -9
  60. package/studio/server/state/views.ts +176 -69
  61. package/studio/server/watch.test.ts +36 -0
  62. package/studio/server/watch.ts +24 -16
  63. package/studio/server/workspace-watch.ts +8 -3
  64. package/studio/shared/types.ts +164 -33
  65. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  66. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  67. package/studio/server/view-dev-server.test.ts +0 -111
  68. package/studio/server/view-dev-server.ts +0 -372
@@ -1,3 +1,4 @@
1
+ import { ResponseError } from '@astrale-os/kernel-client'
1
2
  import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2
3
 
3
4
  import type { OwnedInstanceInfo } from '../../lib/admin-instance'
@@ -118,4 +119,69 @@ describe('admin domain install owner boundary', () => {
118
119
  expect(calls).toEqual([])
119
120
  expect(calls).toEqual([])
120
121
  })
122
+
123
+ test('preserves an incompatible issuer reason returned through Admin', async () => {
124
+ inventory = [
125
+ {
126
+ id: 'owned-id',
127
+ slug: 'owned',
128
+ url: 'https://owned.eu.astrale.ai',
129
+ state: 'ready',
130
+ },
131
+ ]
132
+ const domain = {
133
+ id: 'crm-id',
134
+ origin: 'crm.acme.dev',
135
+ name: 'CRM',
136
+ url: 'https://crm.acme.dev',
137
+ createdAt: '2026-08-20T00:00:00.000Z',
138
+ updatedAt: '2026-08-20T00:00:00.000Z',
139
+ }
140
+ const failure = new ResponseError(5001, 'Schema operation is not supported.', {
141
+ code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
142
+ details: {
143
+ phase: 'upgrade',
144
+ origin: domain.origin,
145
+ issue: 'issuer-changed',
146
+ installedIssuer: 'https://old.example',
147
+ replacementIssuer: 'https://new.example',
148
+ },
149
+ })
150
+ const { installViaAdmin } = await import('../domain/install')
151
+
152
+ await expect(
153
+ installViaAdmin(
154
+ domain.origin,
155
+ {
156
+ instance: 'owned',
157
+ json: true,
158
+ noPrompt: true,
159
+ },
160
+ {
161
+ listInstances: async () => inventory,
162
+ listDomains: async () => [domain],
163
+ install: async () => {
164
+ throw failure
165
+ },
166
+ },
167
+ ),
168
+ ).rejects.toEqual(new ExitError(1))
169
+
170
+ expect(JSON.parse(stderr)).toEqual({
171
+ error: 'RESPONSE_ERROR',
172
+ code: 5001,
173
+ message: 'Schema operation is not supported.',
174
+ reason: {
175
+ code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
176
+ details: {
177
+ phase: 'upgrade',
178
+ origin: 'crm.acme.dev',
179
+ issue: 'issuer-changed',
180
+ installedIssuer: 'https://old.example',
181
+ replacementIssuer: 'https://new.example',
182
+ },
183
+ },
184
+ hint: expect.stringContaining('astrale domain uninstall crm.acme.dev'),
185
+ })
186
+ })
121
187
  })
@@ -4,8 +4,9 @@ import { directInstallCallInput } from '../domain/install'
4
4
 
5
5
  describe('directInstallCallInput', () => {
6
6
  test('sends the current remote install syscall, not a legacy url list', () => {
7
- expect(directInstallCallInput('https://tasks.example.test', 'secret', 'op-1')).toEqual({
8
- operation: 'op-1',
7
+ const operation = '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8'
8
+ expect(directInstallCallInput('https://tasks.example.test', operation, 'secret')).toEqual({
9
+ operation,
9
10
  domains: [
10
11
  {
11
12
  source: {
@@ -0,0 +1,66 @@
1
+ import { describe, expect, mock, test } from 'bun:test'
2
+
3
+ import { cliStale, fetchNpmTargetVersion } from '../update'
4
+
5
+ describe('CLI update staleness', () => {
6
+ test('trusts the release manifest for a script install without consulting npm latest', async () => {
7
+ const fetchPackageVersion = mock(async () => '0.8.1-alpha.7')
8
+ const result = await cliStale(
9
+ { channel: 'beta' },
10
+ {
11
+ update: async () => ({
12
+ status: 'up-to-date',
13
+ currentVersion: '1.0.0-beta.0',
14
+ latestVersion: '1.0.0-beta.0',
15
+ channel: 'beta',
16
+ }),
17
+ fetchPackageVersion,
18
+ },
19
+ )
20
+
21
+ expect(result).toEqual({
22
+ stale: false,
23
+ managed: false,
24
+ current: '1.0.0-beta.0',
25
+ latest: '1.0.0-beta.0',
26
+ channel: 'beta',
27
+ })
28
+ expect(fetchPackageVersion).not.toHaveBeenCalled()
29
+ })
30
+
31
+ test('uses the selected npm dist-tag only for package-managed installs', async () => {
32
+ const result = await cliStale(
33
+ { channel: 'beta' },
34
+ {
35
+ update: async () => {
36
+ throw new Error('package managed')
37
+ },
38
+ fetchPackageVersion: async ({ channel }) => {
39
+ expect(channel).toBe('beta')
40
+ return '1.0.0-beta.0'
41
+ },
42
+ },
43
+ )
44
+
45
+ expect(result).toMatchObject({
46
+ managed: true,
47
+ latest: '1.0.0-beta.0',
48
+ channel: 'npm',
49
+ })
50
+ })
51
+
52
+ test('maps the stable channel to the npm latest dist-tag', async () => {
53
+ const originalFetch = globalThis.fetch
54
+ const fetchMock = mock(async (input: RequestInfo | URL) => {
55
+ expect(String(input)).toBe('https://registry.npmjs.org/@astrale-os/cli/latest')
56
+ return Response.json({ version: '1.0.0' })
57
+ })
58
+ globalThis.fetch = fetchMock as unknown as typeof fetch
59
+ try {
60
+ expect(await fetchNpmTargetVersion({ channel: 'stable' })).toBe('1.0.0')
61
+ expect(fetchMock).toHaveBeenCalledTimes(1)
62
+ } finally {
63
+ globalThis.fetch = originalFetch
64
+ }
65
+ })
66
+ })
@@ -6,6 +6,7 @@ import type { KernelCommandOpts } from '../../connection'
6
6
  import type { CommandDefinition } from '../../program/index'
7
7
 
8
8
  import { createPathCall, runKernelCommand, withAdminClientSession } from '../../connection'
9
+ import { formatKernelError } from '../../connection/errors'
9
10
  import { AstraleError } from '../../errors'
10
11
  import {
11
12
  installAdminDomainInContext,
@@ -21,11 +22,7 @@ import { confirmWithInput, promptText, selectFrom } from '../../lib/prompt'
21
22
  import { isHttpUrl } from '../../lib/validation'
22
23
 
23
24
  /** Public Kernel install syscall input for one remote URL. */
24
- export function directInstallCallInput(
25
- url: string,
26
- token?: string,
27
- operation: string = crypto.randomUUID(),
28
- ) {
25
+ export function directInstallCallInput(url: string, operation: string, token?: string) {
29
26
  return Object.freeze({
30
27
  operation,
31
28
  domains: [
@@ -50,9 +47,28 @@ type DirectInstallResult = {
50
47
  }[]
51
48
  }
52
49
 
50
+ const OPERATION_ID_PATTERN =
51
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
52
+
53
+ function acceptOperationId(input: unknown): string {
54
+ if (typeof input !== 'string' || !OPERATION_ID_PATTERN.test(input)) {
55
+ throw new AstraleError(
56
+ 'INVALID_FLAG',
57
+ '--operation must be a canonical lowercase UUIDv4.',
58
+ 'Omit --operation for a fresh install; use it only with the exact UUID printed for recovery.',
59
+ )
60
+ }
61
+ return input
62
+ }
63
+
64
+ function createOperationId(): string {
65
+ return acceptOperationId(globalThis.crypto.randomUUID())
66
+ }
67
+
53
68
  type InstallOpts = KernelCommandOpts &
54
69
  AdminTargetCommandOpts & {
55
70
  direct?: boolean
71
+ operation?: string
56
72
  token?: string
57
73
  allowIdentityOverride?: boolean
58
74
  // Global flags (program.ts) that force non-interactive.
@@ -78,6 +94,10 @@ Behavior:
78
94
  origin differs from its serving host, it requires explicit consent (an
79
95
  interactive DANGER prompt, or --allow-identity-override in scripts).
80
96
 
97
+ A fresh, strong operation id is generated automatically. Use --operation
98
+ only to retry or recover the exact same direct install after an outcome-unknown
99
+ timeout or disconnect.
100
+
81
101
  Examples:
82
102
  $ astrale domain install crm.acme.dev -i staging # by origin, via admin
83
103
  $ astrale domain install https://crm.acme.dev # by url, via admin
@@ -103,12 +123,26 @@ Examples:
103
123
  flags: '--token <token>',
104
124
  description: 'Bearer token for private domain install endpoints (--direct only)',
105
125
  },
126
+ {
127
+ flags: '--operation <uuid>',
128
+ description: 'Reuse an exact direct-install operation id for explicit retry/recovery',
129
+ },
106
130
  {
107
131
  flags: '--allow-identity-override',
108
132
  description: 'Consent to a domain whose origin differs from its serving host (--direct only)',
109
133
  },
110
134
  ],
111
135
  action: async (target: string | undefined, opts: InstallOpts) => {
136
+ if (opts.operation !== undefined && !opts.direct) {
137
+ fatal(
138
+ new AstraleError(
139
+ 'INVALID_FLAG',
140
+ '--operation is valid only with --direct.',
141
+ 'Ordinary direct installs generate a fresh operation id automatically.',
142
+ ),
143
+ opts,
144
+ )
145
+ }
112
146
  if (opts.direct) {
113
147
  await installDirect(target, opts)
114
148
  return
@@ -197,8 +231,9 @@ export async function installViaAdmin(
197
231
  log.dim(` origin: ${result.origin}`)
198
232
  log.dim(` url: ${result.url}`)
199
233
  })
200
- } catch (e) {
201
- fatal(e, opts)
234
+ } catch (error) {
235
+ await formatKernelError(error, isMachine(opts), undefined, opts.debug)
236
+ process.exit(1)
202
237
  }
203
238
  }
204
239
 
@@ -326,9 +361,27 @@ async function activeSlug(): Promise<string | undefined> {
326
361
  * bypassing the admin catalog, with the identity-override
327
362
  * consent gate. Works on any instance the caller can authenticate to.
328
363
  */
329
- async function installDirect(target: string | undefined, opts: InstallOpts): Promise<void> {
364
+ interface DirectInstallDependencies {
365
+ readonly acceptOperationId: (input: unknown) => string
366
+ readonly createOperationId: () => string
367
+ readonly runKernelCommand: typeof runKernelCommand
368
+ }
369
+
370
+ const defaultDirectInstallDependencies: DirectInstallDependencies = Object.freeze({
371
+ acceptOperationId,
372
+ createOperationId,
373
+ runKernelCommand,
374
+ })
375
+
376
+ export async function installDirect(
377
+ target: string | undefined,
378
+ opts: InstallOpts,
379
+ dependencies: Partial<DirectInstallDependencies> = {},
380
+ ): Promise<void> {
381
+ const direct = { ...defaultDirectInstallDependencies, ...dependencies }
330
382
  let host = ''
331
383
  let consentedOrigin: string | undefined
384
+ let operation: string
332
385
  try {
333
386
  if (!target) {
334
387
  throw new AstraleError(
@@ -338,24 +391,30 @@ async function installDirect(target: string | undefined, opts: InstallOpts): Pro
338
391
  )
339
392
  }
340
393
  host = validateInstallUrl(target)
394
+ operation =
395
+ opts.operation === undefined
396
+ ? direct.createOperationId()
397
+ : direct.acceptOperationId(opts.operation)
341
398
  consentedOrigin = await ensureIdentityOverrideConsent(
342
399
  target,
343
400
  host,
344
401
  opts.allowIdentityOverride ?? false,
345
402
  )
346
403
  } catch (e) {
347
- fatal(e)
404
+ fatal(e, opts)
348
405
  }
349
406
  const url = target as string
407
+ const retry = directInstallRetry(url, operation, opts)
350
408
 
351
- await runKernelCommand<DirectInstallResult>({
409
+ await direct.runKernelCommand<DirectInstallResult>({
352
410
  opts,
353
- label: `Installing domain from ${url}`,
411
+ label: `Installing domain from ${url} (operation ${operation})`,
412
+ recovery: { operation, retry },
354
413
  fn: async ({ session }) =>
355
414
  (await session.call(
356
415
  createPathCall(
357
416
  Path.project(syscalls.install.ref).raw,
358
- directInstallCallInput(url, opts.token),
417
+ directInstallCallInput(url, operation, opts.token),
359
418
  ),
360
419
  )) as DirectInstallResult,
361
420
  format: (result, fmtOpts, isRaw) => {
@@ -364,11 +423,10 @@ async function installDirect(target: string | undefined, opts: InstallOpts): Pro
364
423
  return
365
424
  }
366
425
  const installed = result.transitions[0]?.intent
367
- if (installed === undefined) {
368
- throw new Error('Kernel install returned no committed Domain transition.')
369
- }
426
+ if (!installed) throw new Error('Kernel install returned no committed Domain transition.')
370
427
  const revision = installed.target?.schemaRevision ?? result.operation
371
428
  log.success(`Domain installed: ${installed.origin}@${revision}`)
429
+ log.dim(` operation: ${result.operation}`)
372
430
  // Belt-and-braces: the kernel-confirmed origin is authoritative. If it
373
431
  // aliases the host and the pre-install gate never consented to THAT
374
432
  // origin (lying or absent `/meta`), say so loudly after the fact.
@@ -383,6 +441,11 @@ async function installDirect(target: string | undefined, opts: InstallOpts): Pro
383
441
  })
384
442
  }
385
443
 
444
+ function directInstallRetry(url: string, operation: string, opts: InstallOpts): string {
445
+ const instance = opts.instance === undefined ? '' : ` -i ${opts.instance}`
446
+ return `astrale domain install ${url} --direct --operation ${operation}${instance}`
447
+ }
448
+
386
449
  function validateInstallUrl(value: string): string {
387
450
  let url: URL
388
451
  try {
@@ -115,27 +115,46 @@ export type StaleReport = {
115
115
  sdk: { stale: boolean; inProject: boolean; outdated: SdkOutdated[] }
116
116
  }
117
117
 
118
- async function cliStale(opts: UpdateOpts): Promise<StaleReport['cli']> {
118
+ type CliStaleDependencies = {
119
+ update: typeof updateAstrale
120
+ fetchPackageVersion: (opts: Pick<UpdateOpts, 'channel' | 'version'>) => Promise<string>
121
+ }
122
+
123
+ const CLI_STALE_DEPENDENCIES: CliStaleDependencies = {
124
+ update: updateAstrale,
125
+ fetchPackageVersion: fetchNpmTargetVersion,
126
+ }
127
+
128
+ export async function cliStale(
129
+ opts: Pick<UpdateOpts, 'channel' | 'version'>,
130
+ dependencies: CliStaleDependencies = CLI_STALE_DEPENDENCIES,
131
+ ): Promise<StaleReport['cli']> {
119
132
  const running = pkg.version
120
- const latest = await fetchNpmLatestVersion().catch(() => undefined)
121
133
  try {
122
- const r = await updateAstrale({
134
+ const r = await dependencies.update({
123
135
  check: true,
124
136
  channel: opts.channel,
125
137
  version: opts.version,
126
138
  currentVersion: running,
127
139
  })
128
140
  if (r.status === 'updated') {
129
- return { stale: false, managed: false, current: running, latest: latest ?? running }
141
+ return {
142
+ stale: false,
143
+ managed: false,
144
+ current: r.currentVersion,
145
+ latest: r.currentVersion,
146
+ channel: r.channel,
147
+ }
130
148
  }
131
149
  return {
132
- stale: latest !== undefined ? latest !== running : r.status === 'available',
150
+ stale: r.status === 'available',
133
151
  managed: false,
134
- current: running,
135
- latest: latest ?? r.latestVersion,
136
- channel: latest !== undefined ? 'npm' : r.channel,
152
+ current: r.currentVersion,
153
+ latest: r.latestVersion,
154
+ channel: r.channel,
137
155
  }
138
156
  } catch {
157
+ const latest = await dependencies.fetchPackageVersion(opts).catch(() => undefined)
139
158
  return {
140
159
  stale: latest !== undefined && latest !== running,
141
160
  managed: true,
@@ -145,8 +164,11 @@ async function cliStale(opts: UpdateOpts): Promise<StaleReport['cli']> {
145
164
  }
146
165
  }
147
166
 
148
- async function fetchNpmLatestVersion(): Promise<string> {
149
- const response = await fetch('https://registry.npmjs.org/@astrale-os/cli/latest')
167
+ export async function fetchNpmTargetVersion(
168
+ opts: Pick<UpdateOpts, 'channel' | 'version'>,
169
+ ): Promise<string> {
170
+ const target = opts.version ?? (opts.channel === 'stable' ? 'latest' : opts.channel) ?? 'latest'
171
+ const response = await fetch(`https://registry.npmjs.org/@astrale-os/cli/${target}`)
150
172
  if (!response.ok) throw new Error(`npm registry HTTP ${response.status}`)
151
173
  const body: unknown = await response.json()
152
174
  if (
@@ -37,6 +37,46 @@ describe('formatKernelError', () => {
37
37
  expect(writes[0]).not.toContain('ECONNREFUSED')
38
38
  })
39
39
 
40
+ test('retains operation recovery only for outcome-unknown transport failure', async () => {
41
+ const writes: string[] = []
42
+ const original = process.stderr.write
43
+ process.stderr.write = ((chunk: string | Uint8Array) => {
44
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
45
+ return true
46
+ }) as typeof process.stderr.write
47
+ try {
48
+ await formatKernelError(
49
+ new TransportError('Request timed out.', {
50
+ cause: new Error('timeout'),
51
+ phase: 'timeout',
52
+ delivery: 'unknown',
53
+ }),
54
+ true,
55
+ undefined,
56
+ false,
57
+ {
58
+ recovery: {
59
+ operation: '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
60
+ retry:
61
+ 'astrale domain install https://crm.test --direct --operation 4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
62
+ },
63
+ },
64
+ )
65
+ } finally {
66
+ process.stderr.write = original
67
+ }
68
+
69
+ expect(writes).toHaveLength(1)
70
+ expect(JSON.parse(writes[0]!)).toMatchObject({
71
+ error: 'TIMEOUT',
72
+ phase: 'timeout',
73
+ delivery: 'unknown',
74
+ operation: '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
75
+ retry:
76
+ 'astrale domain install https://crm.test --direct --operation 4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
77
+ })
78
+ })
79
+
40
80
  /** @evidence TEST-CLI-CONNECTION-PRESERVES-PUBLIC-SEMANTIC-REASON */
41
81
  test('preserves a Kernel-admitted semantic reason in machine output', async () => {
42
82
  const writes: string[] = []
@@ -275,8 +315,9 @@ describe('formatKernelError', () => {
275
315
  details: {
276
316
  phase: 'upgrade',
277
317
  origin: 'grc.example',
278
- expected: 'https://old.example',
279
- actual: 'https://new.example',
318
+ issue: 'issuer-changed',
319
+ installedIssuer: 'https://old.example',
320
+ replacementIssuer: 'https://new.example',
280
321
  },
281
322
  }),
282
323
  true,
@@ -294,18 +335,53 @@ describe('formatKernelError', () => {
294
335
  details: {
295
336
  phase: 'upgrade',
296
337
  origin: 'grc.example',
297
- expected: 'https://old.example',
298
- actual: 'https://new.example',
338
+ issue: 'issuer-changed',
339
+ installedIssuer: 'https://old.example',
340
+ replacementIssuer: 'https://new.example',
299
341
  },
300
342
  },
301
343
  hint: schemaUpgradeHint({
302
344
  origin: 'grc.example',
303
- expected: 'https://old.example',
304
- actual: 'https://new.example',
345
+ issue: 'issuer-changed',
346
+ installedIssuer: 'https://old.example',
347
+ replacementIssuer: 'https://new.example',
305
348
  }),
306
349
  })
307
350
  })
308
351
 
352
+ test('prints both issuers and the recovery command for an incompatible replacement', async () => {
353
+ const errors: string[] = []
354
+ const details: string[] = []
355
+ const originalError = console.error
356
+ const originalLog = console.log
357
+ console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
358
+ console.log = (...values: unknown[]) => details.push(values.map(String).join(' '))
359
+ try {
360
+ await formatKernelError(
361
+ new ResponseError(5001, 'Schema operation is not supported.', {
362
+ code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
363
+ details: {
364
+ phase: 'upgrade',
365
+ origin: 'grc.example',
366
+ issue: 'issuer-changed',
367
+ installedIssuer: 'https://old.example',
368
+ replacementIssuer: 'https://new.example',
369
+ },
370
+ }),
371
+ false,
372
+ )
373
+ } finally {
374
+ console.error = originalError
375
+ console.log = originalLog
376
+ }
377
+
378
+ expect(errors.join('\n')).toContain('RESPONSE_ERROR(5001)')
379
+ expect(details.join('\n')).toContain('reason: SCHEMA_UPGRADE_INCOMPATIBLE')
380
+ expect(details.join('\n')).toContain('installed issuer: https://old.example')
381
+ expect(details.join('\n')).toContain('replacement issuer: https://new.example')
382
+ expect(details.join('\n')).toContain('astrale domain uninstall grc.example')
383
+ })
384
+
309
385
  test('explains a private Domain source without exposing transport diagnostics', async () => {
310
386
  const writes: string[] = []
311
387
  const original = process.stderr.write
@@ -16,6 +16,11 @@ export interface KernelCommandOpts extends ConnectionOptions {
16
16
  readonly debug?: boolean
17
17
  }
18
18
 
19
+ export interface OperationRecovery {
20
+ readonly operation: string
21
+ readonly retry: string
22
+ }
23
+
19
24
  /**
20
25
  * Encapsulates the standard kernel command lifecycle:
21
26
  * spinner → connect → call → timing → output → error handling.
@@ -27,6 +32,7 @@ export interface KernelCommandOpts extends ConnectionOptions {
27
32
  export async function runKernelCommand<T>(input: {
28
33
  readonly opts: KernelCommandOpts
29
34
  readonly label: string
35
+ readonly recovery?: OperationRecovery
30
36
  readonly fn: (context: ConnectionContext) => Promise<T>
31
37
  readonly format?: (
32
38
  result: T,
@@ -53,7 +59,9 @@ export async function runKernelCommand<T>(input: {
53
59
  }
54
60
  } catch (error) {
55
61
  if (!isRaw && spin) spin.fail(`${label} failed`)
56
- await formatKernelError(error, isRaw, undefined, opts.debug)
62
+ await formatKernelError(error, isRaw, undefined, opts.debug, {
63
+ recovery: input.recovery,
64
+ })
57
65
  process.exit(1)
58
66
  }
59
67
  }
@@ -1,5 +1,7 @@
1
1
  import chalk from 'chalk'
2
2
 
3
+ import type { OperationRecovery } from './command'
4
+
3
5
  import { AstraleError } from '../errors'
4
6
  import { readLocalStatus, type LocalStatus } from '../lib/local-status'
5
7
  import { log } from '../lib/log'
@@ -28,6 +30,7 @@ export async function formatKernelError(
28
30
  isRaw: boolean,
29
31
  urlArg = '',
30
32
  debug = false,
33
+ opts: { recovery?: OperationRecovery } = {},
31
34
  ): Promise<void> {
32
35
  const url =
33
36
  urlArg || (error instanceof Error ? ((error as Error & { url?: string }).url ?? '') : '')
@@ -54,7 +57,7 @@ export async function formatKernelError(
54
57
 
55
58
  switch (name) {
56
59
  case 'TransportError':
57
- presentTransportError(error, isRaw, url, localContext)
60
+ presentTransportError(error, isRaw, url, localContext, opts.recovery)
58
61
  break
59
62
 
60
63
  case 'ResponseError': {
@@ -96,11 +99,9 @@ export async function formatKernelError(
96
99
  }
97
100
  presentFunctionInputIssues(inputIssues)
98
101
  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}`)
102
+ if (upgrade?.issue === 'issuer-changed') {
103
+ log.dim(` installed issuer: ${upgrade.installedIssuer}`)
104
+ log.dim(` replacement issuer: ${upgrade.replacementIssuer}`)
104
105
  }
105
106
  if (hint !== undefined) log.dim(` ${hint}`)
106
107
  }
@@ -194,6 +195,7 @@ function presentTransportError(
194
195
  isRaw: boolean,
195
196
  url: string,
196
197
  context: LocalStatus | undefined,
198
+ recovery: OperationRecovery | undefined,
197
199
  ): void {
198
200
  const phase = transportPhase(error)
199
201
  const delivery = transportDelivery(error)
@@ -213,6 +215,7 @@ function presentTransportError(
213
215
  ...(phase === undefined ? {} : { phase }),
214
216
  ...(delivery === undefined ? {} : { delivery }),
215
217
  ...(context === undefined ? {} : { context }),
218
+ ...(delivery === 'unknown' && recovery !== undefined ? recovery : {}),
216
219
  })
217
220
  return
218
221
  }
@@ -221,10 +224,18 @@ function presentTransportError(
221
224
  if (phase !== undefined) log.dim(` phase: ${phase}`)
222
225
  if (phase === 'connect') log.dim(' Check the target and run `astrale status`.')
223
226
  else if (phase === 'timeout') log.dim(' Try increasing `--timeout`.')
224
- else if (delivery === 'unknown') {
227
+ else if (delivery === 'unknown') printOperationRecovery(recovery)
228
+ printLocalContext(context)
229
+ }
230
+
231
+ function printOperationRecovery(recovery: OperationRecovery | undefined): void {
232
+ if (recovery === undefined) {
225
233
  log.dim(' Delivery is unknown; do not automatically retry a mutating call.')
234
+ return
226
235
  }
227
- printLocalContext(context)
236
+ log.dim(' Delivery is unknown; retry with the same operation id:')
237
+ log.dim(` operation: ${recovery.operation}`)
238
+ log.dim(` ${recovery.retry}`)
228
239
  }
229
240
 
230
241
  function presentFunctionInputIssues(issues: readonly FunctionInputIssue[]): void {
@@ -1,6 +1,6 @@
1
1
  export { createPathCall } from './call'
2
2
  export { runKernelCommand } from './command'
3
- export type { KernelCommandOpts } from './command'
3
+ export type { KernelCommandOpts, OperationRecovery } from './command'
4
4
  export { expandSelfInCall, expandSelfInPath, withSelfHint } from './self'
5
5
  export type { SelfExpansionMeta } from './self'
6
6
  export { withAdminClientSession, withClientSession, type ConnectionContext } from './session'