@astrale-os/cli 0.8.1-alpha.6 → 1.0.0-beta.0
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 +18 -5
- package/dist/astrale.js +2661 -2714
- package/dist/public/connect-core.js +2019 -3050
- package/dist/public/keys/index.js +1851 -2885
- package/dist/public/paths/index.js +1830 -2872
- package/dist/types/connection/auth.d.ts +3 -0
- package/dist/types/lib/instance.d.ts +10 -0
- package/package.json +9 -9
- 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__/domain-uninstall.test.ts +53 -0
- package/src/commands/__tests__/install-direct.test.ts +3 -2
- 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__/view-build.test.ts +58 -0
- package/src/commands/domain/install.ts +84 -20
- package/src/commands/domain/uninstall.ts +128 -0
- 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/view.ts +28 -16
- 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__/errors.test.ts +483 -33
- 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 +9 -1
- package/src/connection/errors.ts +149 -159
- package/src/connection/exchange.ts +14 -2
- package/src/connection/index.ts +1 -1
- package/src/connection/reasons.ts +193 -0
- package/src/lib/__tests__/instance.test.ts +51 -1
- 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/instance.ts +31 -0
- package/src/lib/view/assets.ts +16 -2
- package/src/program/__tests__/program.test.ts +2 -1
- package/src/program/build.ts +2 -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 +8 -9
- 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/viewer/dist/main.js +57 -57
- 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
|
@@ -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 {
|
|
@@ -415,7 +478,7 @@ export function isIdentityOverride(origin: string, host: string): boolean {
|
|
|
415
478
|
* Claiming an origin that differs from the serving host is an explicit actAs
|
|
416
479
|
* and needs typed consent (or `--allow-identity-override` in scripts).
|
|
417
480
|
*
|
|
418
|
-
* The pre-install check reads the worker's self-reported `/meta.
|
|
481
|
+
* The pre-install check reads the worker's self-reported `/meta.origin`,
|
|
419
482
|
* so it is consent UX, not enforcement — a hostile worker can lie here, and
|
|
420
483
|
* the kernel anchors the cryptographic identity (`iss`) on the real URL
|
|
421
484
|
* regardless. When `/meta` is unreachable or silent on the origin, the gate
|
|
@@ -476,10 +539,11 @@ export async function probeDeclaredOrigin(url: string): Promise<string | undefin
|
|
|
476
539
|
try {
|
|
477
540
|
const res = await fetch(new URL('/meta', url), { signal: AbortSignal.timeout(10_000) })
|
|
478
541
|
if (!res.ok) return undefined
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
542
|
+
// SDK workers serve `origin`; `domainName` is the pre-Kernel-V2 name, kept
|
|
543
|
+
// as a fallback for workers deployed before the rename.
|
|
544
|
+
const body = (await res.json()) as { origin?: unknown; domainName?: unknown }
|
|
545
|
+
const declared = body.origin ?? body.domainName
|
|
546
|
+
return typeof declared === 'string' && declared.length > 0 ? declared : undefined
|
|
483
547
|
} catch {
|
|
484
548
|
// Unreachable /meta is not fatal here: the caller warns and the install
|
|
485
549
|
// itself will surface a dead worker with its own error.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Path } from '@astrale-os/sdk/graph/path'
|
|
2
|
+
import { syscalls } from '@astrale-os/sdk/schema/kernel'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
|
|
5
|
+
import type { KernelCommandOpts } from '../../connection'
|
|
6
|
+
import type { CommandDefinition } from '../../program/index'
|
|
7
|
+
|
|
8
|
+
import { createPathCall, runKernelCommand } from '../../connection'
|
|
9
|
+
import { AstraleError } from '../../errors'
|
|
10
|
+
import { fatal, log } from '../../lib/log'
|
|
11
|
+
import { output } from '../../lib/output'
|
|
12
|
+
import { confirmWithInput } from '../../lib/prompt'
|
|
13
|
+
|
|
14
|
+
type UninstallOpts = KernelCommandOpts & {
|
|
15
|
+
readonly yes?: boolean
|
|
16
|
+
readonly ci?: boolean
|
|
17
|
+
readonly noPrompt?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type UninstallResult = {
|
|
21
|
+
readonly operation: string
|
|
22
|
+
readonly transition: {
|
|
23
|
+
readonly intent: {
|
|
24
|
+
readonly origin: string
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Public Kernel uninstall syscall input for one installed Domain origin. */
|
|
30
|
+
export function uninstallCallInput(
|
|
31
|
+
origin: string,
|
|
32
|
+
operation: string = crypto.randomUUID(),
|
|
33
|
+
): Readonly<{ operation: string; origin: string }> {
|
|
34
|
+
return Object.freeze({ operation, origin })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
name: 'uninstall',
|
|
39
|
+
description: 'Uninstall a domain from an instance through the public Kernel syscall',
|
|
40
|
+
afterHelpText: `
|
|
41
|
+
Behavior:
|
|
42
|
+
Removes one installed Domain origin from the target instance. The Kernel
|
|
43
|
+
refuses the operation while another installed Domain depends on it or while
|
|
44
|
+
business data still uses its schema. Uninstall never deletes business data.
|
|
45
|
+
Type the exact origin to confirm, or pass --yes in automation.
|
|
46
|
+
|
|
47
|
+
Use this before reinstalling only when an immutable Domain property (such as
|
|
48
|
+
its issuer) intentionally changed. Ordinary compatible upgrades should use
|
|
49
|
+
domain install directly and preserve the existing Domain identity.
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
$ astrale domain uninstall grc.example -i staging
|
|
53
|
+
$ astrale domain uninstall grc.example -i staging --yes --json
|
|
54
|
+
`,
|
|
55
|
+
arguments: [
|
|
56
|
+
{
|
|
57
|
+
name: 'origin',
|
|
58
|
+
description: 'Installed Domain origin to remove',
|
|
59
|
+
required: true,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
options: [
|
|
63
|
+
{
|
|
64
|
+
flags: '--yes',
|
|
65
|
+
description: 'Confirm Domain uninstall without prompting',
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
action: async (origin: string, opts: UninstallOpts) => {
|
|
69
|
+
try {
|
|
70
|
+
await confirmUninstall(origin, opts)
|
|
71
|
+
} catch (error) {
|
|
72
|
+
fatal(error, opts)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await runKernelCommand<UninstallResult>({
|
|
76
|
+
opts,
|
|
77
|
+
label: `Uninstalling domain ${origin}`,
|
|
78
|
+
fn: async ({ session }) =>
|
|
79
|
+
(await session.call(
|
|
80
|
+
createPathCall(Path.project(syscalls.uninstall.ref).raw, uninstallCallInput(origin)),
|
|
81
|
+
)) as UninstallResult,
|
|
82
|
+
format: (result, formatOpts, machine) => {
|
|
83
|
+
if (machine) {
|
|
84
|
+
output(result, formatOpts)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
log.success(`Domain uninstalled: ${result.transition.intent.origin}`)
|
|
88
|
+
log.dim(` operation: ${result.operation}`)
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
},
|
|
92
|
+
} satisfies CommandDefinition
|
|
93
|
+
|
|
94
|
+
async function confirmUninstall(origin: string, opts: UninstallOpts): Promise<void> {
|
|
95
|
+
if (opts.yes) return
|
|
96
|
+
|
|
97
|
+
const nonInteractive =
|
|
98
|
+
opts.ci ||
|
|
99
|
+
opts.noPrompt ||
|
|
100
|
+
process.env.CI ||
|
|
101
|
+
process.argv.includes('--ci') ||
|
|
102
|
+
process.argv.includes('--no-prompt') ||
|
|
103
|
+
!process.stdin.isTTY
|
|
104
|
+
if (nonInteractive) {
|
|
105
|
+
throw new AstraleError(
|
|
106
|
+
'CONFIRMATION_REQUIRED',
|
|
107
|
+
`Uninstalling Domain "${origin}" requires explicit confirmation.`,
|
|
108
|
+
`Re-run with --yes: astrale domain uninstall ${origin} --yes`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const warning =
|
|
113
|
+
chalk.red.bold('⚠ DANGER — DOMAIN UNINSTALL') +
|
|
114
|
+
'\n' +
|
|
115
|
+
chalk.dim('│') +
|
|
116
|
+
` origin ${chalk.bold(origin)}\n` +
|
|
117
|
+
chalk.dim('│') +
|
|
118
|
+
'\n' +
|
|
119
|
+
chalk.dim('│') +
|
|
120
|
+
' This removes the installed Domain from the target instance.\n' +
|
|
121
|
+
chalk.dim('│') +
|
|
122
|
+
' This command never deletes business data.\n' +
|
|
123
|
+
chalk.dim('│') +
|
|
124
|
+
' The Kernel refuses removal while dependents or business data remain.'
|
|
125
|
+
if (!(await confirmWithInput(warning, origin))) {
|
|
126
|
+
throw new AstraleError('UNINSTALL_CANCELLED', `Domain uninstall cancelled for "${origin}".`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -19,13 +19,19 @@ export default {
|
|
|
19
19
|
const { name } = active
|
|
20
20
|
const url = active.url ?? null
|
|
21
21
|
const createdAt = active.createdAt ?? null
|
|
22
|
+
const issuer = active.issuer ?? null
|
|
23
|
+
const defaultIdentity = active.defaultIdentity ?? null
|
|
24
|
+
const caFile = active.caFile ?? null
|
|
22
25
|
|
|
23
26
|
if (isRaw) {
|
|
24
|
-
output({ name, url, createdAt }, opts)
|
|
27
|
+
output({ name, url, issuer, defaultIdentity, caFile, createdAt }, opts)
|
|
25
28
|
return
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
console.log(`${chalk.bold(name)} (${url ?? 'local'})`)
|
|
32
|
+
if (issuer && issuer !== url) log.dim(` issuer: ${issuer}`)
|
|
33
|
+
if (defaultIdentity) log.dim(` identity: ${defaultIdentity}`)
|
|
34
|
+
if (caFile) log.dim(` ca: ${caFile}`)
|
|
29
35
|
} catch (e) {
|
|
30
36
|
log.error(e instanceof Error ? e.message : String(e))
|
|
31
37
|
process.exit(1)
|
|
@@ -36,6 +42,9 @@ export default {
|
|
|
36
42
|
async function resolveActiveForDisplay(): Promise<{
|
|
37
43
|
name: string
|
|
38
44
|
url?: string
|
|
45
|
+
issuer?: string
|
|
46
|
+
defaultIdentity?: string
|
|
47
|
+
caFile?: string
|
|
39
48
|
createdAt?: string
|
|
40
49
|
}> {
|
|
41
50
|
const active = await getActive()
|
|
@@ -43,6 +52,9 @@ async function resolveActiveForDisplay(): Promise<{
|
|
|
43
52
|
return {
|
|
44
53
|
name: active.name,
|
|
45
54
|
url: active.url,
|
|
55
|
+
issuer: active.issuer,
|
|
56
|
+
defaultIdentity: active.defaultIdentity,
|
|
57
|
+
caFile: active.caFile,
|
|
46
58
|
createdAt: active.createdAt,
|
|
47
59
|
}
|
|
48
60
|
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { CommandDefinition } from '../../program/index'
|
|
2
2
|
|
|
3
3
|
import { fetchWithCaFile } from '../../lib/ca-fetch'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
findBookmarkTrustConflicts,
|
|
6
|
+
normalizeInstanceKernelUrl,
|
|
7
|
+
readInstances,
|
|
8
|
+
setActive,
|
|
9
|
+
upsertInstance,
|
|
10
|
+
} from '../../lib/instance'
|
|
5
11
|
import { fatal, log } from '../../lib/log'
|
|
6
12
|
import { checkIssuerReachability } from '../../lib/meta'
|
|
7
13
|
|
|
@@ -39,14 +45,27 @@ export default {
|
|
|
39
45
|
try {
|
|
40
46
|
if (!opts.url) fatal(new Error('Missing required flag: --url <url>'))
|
|
41
47
|
const url = normalizeInstanceKernelUrl(opts.url)
|
|
42
|
-
const
|
|
48
|
+
const store = await readInstances()
|
|
49
|
+
const expectedIssuer = opts.issuer
|
|
50
|
+
? normalizeInstanceKernelUrl(opts.issuer)
|
|
51
|
+
: store.instances[name]?.issuer
|
|
52
|
+
const effectiveCa = opts.ca ?? store.instances[name]?.caFile
|
|
53
|
+
const trustConflicts = findBookmarkTrustConflicts(store, name, url, effectiveCa)
|
|
54
|
+
if (trustConflicts.length > 0) {
|
|
55
|
+
log.warn(
|
|
56
|
+
`TLS trust differs for the same Kernel URL ${url}: ` +
|
|
57
|
+
`"${name}" uses ${describeCa(effectiveCa)}, while ${trustConflicts
|
|
58
|
+
.map((conflict) => `"${conflict.name}" uses ${describeCa(conflict.caFile)}`)
|
|
59
|
+
.join(', ')}. Remove or update stale bookmarks to avoid certificate surprises.`,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
43
62
|
|
|
44
63
|
if (!opts.skipProbe) {
|
|
45
64
|
try {
|
|
46
65
|
const { issuer, keys } = await checkIssuerReachability(
|
|
47
66
|
url,
|
|
48
67
|
expectedIssuer,
|
|
49
|
-
|
|
68
|
+
effectiveCa ? fetchWithCaFile(effectiveCa) : undefined,
|
|
50
69
|
)
|
|
51
70
|
log.dim(` iss=${issuer} keys=${keys.length}`)
|
|
52
71
|
} catch (e) {
|
|
@@ -76,3 +95,7 @@ export default {
|
|
|
76
95
|
}
|
|
77
96
|
},
|
|
78
97
|
} satisfies CommandDefinition
|
|
98
|
+
|
|
99
|
+
function describeCa(caFile: string | null | undefined): string {
|
|
100
|
+
return caFile ? `CA ${caFile}` : 'the system trust store'
|
|
101
|
+
}
|
|
@@ -30,6 +30,7 @@ export type Bookmark = {
|
|
|
30
30
|
issuer: string | null
|
|
31
31
|
active: boolean
|
|
32
32
|
defaultIdentity: string | null
|
|
33
|
+
caFile: string | null
|
|
33
34
|
createdAt: string | null
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -57,6 +58,7 @@ export default {
|
|
|
57
58
|
issuer: entry.issuer ?? null,
|
|
58
59
|
active: name === store.active,
|
|
59
60
|
defaultIdentity: entry.defaultIdentity ?? null,
|
|
61
|
+
caFile: entry.caFile ?? null,
|
|
60
62
|
createdAt: entry.createdAt ?? null,
|
|
61
63
|
}))
|
|
62
64
|
|
|
@@ -111,13 +113,13 @@ export function buildInstanceRows(
|
|
|
111
113
|
const rows: Array<Record<string, string>> = []
|
|
112
114
|
const merged = new Set<string>()
|
|
113
115
|
|
|
114
|
-
const bookmarkByName = new Map<string, { url: string
|
|
116
|
+
const bookmarkByName = new Map<string, Bookmark & { url: string }>()
|
|
115
117
|
if (show.managed && show.bookmarks) {
|
|
116
118
|
for (const bookmark of bookmarks) {
|
|
117
119
|
if (bookmark.url === null) continue
|
|
118
120
|
bookmarkByName.set(bookmark.name, {
|
|
121
|
+
...bookmark,
|
|
119
122
|
url: normalizeInstanceKernelUrl(bookmark.url),
|
|
120
|
-
active: bookmark.active,
|
|
121
123
|
})
|
|
122
124
|
}
|
|
123
125
|
}
|
|
@@ -134,7 +136,9 @@ export function buildInstanceRows(
|
|
|
134
136
|
name: twin?.active ? `${item.slug} ${chalk.green('*')}` : item.slug,
|
|
135
137
|
kind: 'managed',
|
|
136
138
|
url: item.url ?? '',
|
|
137
|
-
extra: formatInstanceLocation(item),
|
|
139
|
+
extra: [formatInstanceLocation(item), twin ? formatBookmarkConnection(twin) : '']
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.join(' · '),
|
|
138
142
|
})
|
|
139
143
|
}
|
|
140
144
|
}
|
|
@@ -146,7 +150,7 @@ export function buildInstanceRows(
|
|
|
146
150
|
name: item.active ? `${item.name} ${chalk.green('*')}` : item.name,
|
|
147
151
|
kind: 'bookmark',
|
|
148
152
|
url: String(item.url ?? ''),
|
|
149
|
-
extra:
|
|
153
|
+
extra: formatBookmarkConnection(item),
|
|
150
154
|
})
|
|
151
155
|
}
|
|
152
156
|
}
|
|
@@ -154,6 +158,16 @@ export function buildInstanceRows(
|
|
|
154
158
|
return rows
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
function formatBookmarkConnection(bookmark: Bookmark): string {
|
|
162
|
+
return [
|
|
163
|
+
bookmark.issuer && bookmark.issuer !== bookmark.url ? `issuer=${bookmark.issuer}` : '',
|
|
164
|
+
bookmark.caFile ? `ca=${bookmark.caFile}` : '',
|
|
165
|
+
bookmark.defaultIdentity ? `identity=${bookmark.defaultIdentity}` : '',
|
|
166
|
+
]
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.join(' · ')
|
|
169
|
+
}
|
|
170
|
+
|
|
157
171
|
const ADMIN_INVENTORY_CODES = new Set([
|
|
158
172
|
'TOKEN_EXCHANGE_SOURCE_INVALID',
|
|
159
173
|
'TOKEN_EXCHANGE_SOURCE_EXPIRED',
|
|
@@ -5,7 +5,9 @@ import { AstraleError } from '../../errors'
|
|
|
5
5
|
import { getDefault, setDefault } from '../../identity/index'
|
|
6
6
|
import { listOwnedInstances } from '../../lib/admin-instance'
|
|
7
7
|
import { ADMIN_TARGET_OPTIONS } from '../../lib/admin-target'
|
|
8
|
+
import { fetchWithCaFile } from '../../lib/ca-fetch'
|
|
8
9
|
import {
|
|
10
|
+
findBookmarkTrustConflicts,
|
|
9
11
|
getActive,
|
|
10
12
|
readInstances,
|
|
11
13
|
resolveInstance,
|
|
@@ -48,12 +50,8 @@ async function useInstance(name?: string, opts: UseOpts = {}): Promise<void> {
|
|
|
48
50
|
|
|
49
51
|
const resolved = await resolveUseTarget(name, opts)
|
|
50
52
|
|
|
51
|
-
if (!opts.skipJwksCheck
|
|
52
|
-
|
|
53
|
-
await checkIssuerReachability(resolved.url, resolved.issuer)
|
|
54
|
-
} catch (e) {
|
|
55
|
-
fatal(e)
|
|
56
|
-
}
|
|
53
|
+
if (!opts.skipJwksCheck) {
|
|
54
|
+
await probeBookmark(resolved)
|
|
57
55
|
}
|
|
58
56
|
|
|
59
57
|
await setActive(resolved.name)
|
|
@@ -94,6 +92,55 @@ async function useInstance(name?: string, opts: UseOpts = {}): Promise<void> {
|
|
|
94
92
|
}
|
|
95
93
|
}
|
|
96
94
|
|
|
95
|
+
/** Probe with the exact TLS trust configuration stored on this bookmark. */
|
|
96
|
+
export async function probeBookmark(
|
|
97
|
+
resolved: ResolvedInstance,
|
|
98
|
+
dependencies: Partial<BookmarkProbeDependencies> = {},
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const probe = { ...defaultBookmarkProbeDependencies, ...dependencies }
|
|
101
|
+
const store = await probe.readInstances()
|
|
102
|
+
const conflicts = findBookmarkTrustConflicts(store, resolved.name, resolved.url, resolved.caFile)
|
|
103
|
+
try {
|
|
104
|
+
await probe.checkIssuerReachability(
|
|
105
|
+
resolved.url,
|
|
106
|
+
resolved.issuer,
|
|
107
|
+
resolved.caFile ? probe.fetchWithCaFile(resolved.caFile) : undefined,
|
|
108
|
+
)
|
|
109
|
+
} catch (cause) {
|
|
110
|
+
const original = cause instanceof AstraleError ? cause.hint : undefined
|
|
111
|
+
const trust = resolved.caFile
|
|
112
|
+
? `Bookmark "${resolved.name}" trusts CA ${resolved.caFile}.`
|
|
113
|
+
: `Bookmark "${resolved.name}" uses the system trust store.`
|
|
114
|
+
const collision =
|
|
115
|
+
conflicts.length === 0
|
|
116
|
+
? ''
|
|
117
|
+
: ` The same URL is bookmarked with different TLS trust as ${conflicts
|
|
118
|
+
.map((conflict) =>
|
|
119
|
+
conflict.caFile
|
|
120
|
+
? `"${conflict.name}" (CA ${conflict.caFile})`
|
|
121
|
+
: `"${conflict.name}" (system trust)`,
|
|
122
|
+
)
|
|
123
|
+
.join(', ')}.`
|
|
124
|
+
throw new AstraleError(
|
|
125
|
+
cause instanceof AstraleError ? cause.code : 'ISSUER_UNREACHABLE',
|
|
126
|
+
`Issuer/JWKS probe failed for bookmark "${resolved.name}" at ${resolved.url}.`,
|
|
127
|
+
`${trust}${collision}${original ? ` ${original}` : ''} Inspect with \`astrale instance list --bookmarked --json\`.`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface BookmarkProbeDependencies {
|
|
133
|
+
readonly readInstances: typeof readInstances
|
|
134
|
+
readonly checkIssuerReachability: typeof checkIssuerReachability
|
|
135
|
+
readonly fetchWithCaFile: typeof fetchWithCaFile
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const defaultBookmarkProbeDependencies: BookmarkProbeDependencies = Object.freeze({
|
|
139
|
+
readInstances,
|
|
140
|
+
checkIssuerReachability,
|
|
141
|
+
fetchWithCaFile,
|
|
142
|
+
})
|
|
143
|
+
|
|
97
144
|
async function resolveUseTarget(name: string, opts: UseOpts): Promise<ResolvedInstance> {
|
|
98
145
|
const [store, managed] = await Promise.all([readInstances(), fetchManagedInstances(name, opts)])
|
|
99
146
|
const candidates = collectInstanceCandidates(name, store, managed)
|
|
@@ -204,7 +251,7 @@ Examples:
|
|
|
204
251
|
flags: '--adopt-default',
|
|
205
252
|
description: 'Adopt instance default identity without prompt',
|
|
206
253
|
},
|
|
207
|
-
{ flags: '--skip-jwks-check', description: 'Skip the
|
|
254
|
+
{ flags: '--skip-jwks-check', description: 'Skip the OIDC discovery + JWKS liveness probe' },
|
|
208
255
|
],
|
|
209
256
|
action: async (name: string | undefined, opts: UseOpts) => {
|
|
210
257
|
await useInstance(name, opts)
|
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> {
|
|
@@ -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
|
+
})
|