@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.
- package/README.md +15 -3
- package/dist/astrale.js +113 -56
- package/package.json +5 -5
- package/src/admin/instance/__tests__/client.test.ts +3 -1
- package/src/admin/instance/client.ts +3 -16
- package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
- package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
- package/src/commands/__tests__/install-direct.test.ts +3 -2
- package/src/commands/__tests__/update.test.ts +66 -0
- package/src/commands/domain/install.ts +78 -15
- package/src/commands/update.ts +32 -10
- package/src/connection/__tests__/errors.test.ts +82 -6
- package/src/connection/command.ts +9 -1
- package/src/connection/errors.ts +19 -8
- package/src/connection/index.ts +1 -1
- package/src/connection/reasons.ts +26 -12
- package/src/program/__tests__/program.test.ts +1 -1
- package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
- package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
- package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
- package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/package.json +1 -1
- package/studio/server/agent/prompts/anchors.test.ts +74 -0
- package/studio/server/agent/prompts/anchors.ts +85 -15
- package/studio/server/agent/prompts/system.test.ts +12 -0
- package/studio/server/agent/prompts/system.ts +6 -6
- package/studio/server/api.ts +1 -5
- package/studio/server/cache.ts +5 -2
- package/studio/server/domain.test.ts +67 -0
- package/studio/server/domain.ts +29 -6
- package/studio/server/index.ts +1 -3
- package/studio/server/introspect/anatomy-extras.test.ts +104 -1
- package/studio/server/introspect/anatomy-extras.ts +340 -8
- package/studio/server/introspect/anatomy.test.ts +33 -0
- package/studio/server/introspect/anatomy.ts +22 -7
- package/studio/server/introspect/bundle.ts +7 -1
- package/studio/server/introspect/canonical-schema.test.ts +395 -0
- package/studio/server/introspect/canonical-schema.ts +751 -0
- package/studio/server/introspect/core-extractor.ts +30 -10
- package/studio/server/introspect/core.ts +4 -2
- package/studio/server/introspect/diff.test.ts +124 -0
- package/studio/server/introspect/diff.ts +252 -23
- package/studio/server/introspect/extractor.ts +46 -14
- package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
- package/studio/server/introspect/overlay-tsmorph.ts +381 -106
- package/studio/server/introspect/overlay.test.ts +72 -0
- package/studio/server/introspect/overlay.ts +16 -6
- package/studio/server/introspect/runtime.test.ts +217 -0
- package/studio/server/introspect/runtime.ts +18 -6
- package/studio/server/introspect/schema-refs.test.ts +100 -0
- package/studio/server/introspect/schema-refs.ts +23 -2
- package/studio/server/state/baseline.test.ts +51 -0
- package/studio/server/state/baseline.ts +31 -2
- package/studio/server/state/create.test.ts +37 -0
- package/studio/server/state/create.ts +21 -15
- package/studio/server/state/instance.test.ts +63 -0
- package/studio/server/state/instance.ts +65 -29
- package/studio/server/state/views.test.ts +209 -9
- package/studio/server/state/views.ts +176 -69
- package/studio/server/watch.test.ts +36 -0
- package/studio/server/watch.ts +24 -16
- package/studio/server/workspace-watch.ts +8 -3
- package/studio/shared/types.ts +164 -33
- package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
- package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
- package/studio/server/view-dev-server.test.ts +0 -111
- 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
|
-
|
|
8
|
-
|
|
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 (
|
|
201
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
package/src/commands/update.ts
CHANGED
|
@@ -115,27 +115,46 @@ export type StaleReport = {
|
|
|
115
115
|
sdk: { stale: boolean; inProject: boolean; outdated: SdkOutdated[] }
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
|
|
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
|
|
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 {
|
|
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:
|
|
150
|
+
stale: r.status === 'available',
|
|
133
151
|
managed: false,
|
|
134
|
-
current:
|
|
135
|
-
latest:
|
|
136
|
-
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
|
|
149
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
}
|
package/src/connection/errors.ts
CHANGED
|
@@ -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?.
|
|
100
|
-
log.dim(` installed issuer: ${upgrade.
|
|
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
|
-
|
|
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 {
|
package/src/connection/index.ts
CHANGED
|
@@ -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'
|