@astrale-os/cli 0.8.1-alpha.6 → 0.8.1-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +2599 -2703
  3. package/dist/public/connect-core.js +2019 -3050
  4. package/dist/public/keys/index.js +1851 -2885
  5. package/dist/public/paths/index.js +1830 -2872
  6. package/dist/types/connection/auth.d.ts +3 -0
  7. package/dist/types/lib/instance.d.ts +10 -0
  8. package/package.json +8 -8
  9. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  10. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  11. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  12. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  13. package/src/commands/__tests__/instance-use.test.ts +67 -0
  14. package/src/commands/__tests__/view-build.test.ts +58 -0
  15. package/src/commands/domain/install.ts +6 -5
  16. package/src/commands/domain/uninstall.ts +128 -0
  17. package/src/commands/instance/active.ts +13 -1
  18. package/src/commands/instance/bookmark.ts +26 -3
  19. package/src/commands/instance/list.ts +18 -4
  20. package/src/commands/instance/use.ts +54 -7
  21. package/src/commands/view.ts +28 -16
  22. package/src/connection/.spec/architecture.md +5 -0
  23. package/src/connection/.spec/laws/connection.ts +20 -0
  24. package/src/connection/.spec/layout.ts +1 -0
  25. package/src/connection/__tests__/auth.test.ts +27 -1
  26. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  27. package/src/connection/__tests__/errors.test.ts +410 -36
  28. package/src/connection/__tests__/exchange.test.ts +46 -5
  29. package/src/connection/__tests__/reasons.test.ts +78 -0
  30. package/src/connection/auth.ts +11 -9
  31. package/src/connection/command.ts +1 -1
  32. package/src/connection/errors.ts +139 -160
  33. package/src/connection/exchange.ts +14 -2
  34. package/src/connection/reasons.ts +179 -0
  35. package/src/lib/__tests__/instance.test.ts +51 -1
  36. package/src/lib/__tests__/view-assets.test.ts +33 -1
  37. package/src/lib/__tests__/view-server.test.ts +68 -0
  38. package/src/lib/ca-fetch.ts +9 -3
  39. package/src/lib/instance.ts +31 -0
  40. package/src/lib/view/assets.ts +16 -2
  41. package/src/program/__tests__/program.test.ts +2 -1
  42. package/src/program/build.ts +2 -1
  43. package/studio/package.json +7 -8
  44. package/viewer/dist/main.js +57 -57
@@ -1,5 +1,7 @@
1
1
  import type { AstraleConfig } from '../lib/config';
2
2
  import { type Identity } from '../identity/registry';
3
+ import { IdpAudienceMismatchError } from '../lib/idp';
4
+ import { IdpSessionNoRefreshTokenError } from '../lib/idp-session';
3
5
  export type KeyIdentityAuthOptions = {
4
6
  issuer: string;
5
7
  subject?: string;
@@ -23,6 +25,7 @@ export declare function resolveCredential(opts: {
23
25
  defaultIdentity?: string;
24
26
  }, config: AstraleConfig, audience?: string, registrationKey?: string): Promise<string>;
25
27
  export declare function resolveKeyIdentityAuthOptions(identity: Identity, config: AstraleConfig, audience?: string, registrationKey?: string): KeyIdentityAuthOptions;
28
+ export declare function classifyNoRefreshTokenError(requestedAudience: string, sourceAudience: string | undefined, error: IdpSessionNoRefreshTokenError): IdpSessionNoRefreshTokenError | IdpAudienceMismatchError;
26
29
  /** A refresh attempt failed for a reason that re-login will NOT fix. */
27
30
  export declare class IdpRefreshTransientError extends Error {
28
31
  constructor(message: string);
@@ -136,6 +136,10 @@ export type ResolvedInstance = {
136
136
  mode?: RegistryMode;
137
137
  status?: string;
138
138
  };
139
+ export type BookmarkTrustConflict = {
140
+ readonly name: string;
141
+ readonly caFile: string | null;
142
+ };
139
143
  export declare function sanitizeStore(store: InstanceStore): {
140
144
  store: InstanceStore;
141
145
  changed: boolean;
@@ -152,6 +156,12 @@ export declare function readInstances(_config?: AstraleConfig, opts?: {
152
156
  export declare function writeInstances(store: InstanceStore): Promise<void>;
153
157
  export declare function assertNoCollision(store: InstanceStore, identifiers: string[], ignoreKey?: string): void;
154
158
  export declare function resolveInstanceKey(store: InstanceStore, identifier: string): string | null;
159
+ /**
160
+ * Find other bookmarks for the same normalized Kernel URL whose TLS trust
161
+ * configuration differs. System trust (`undefined`) is a configuration too:
162
+ * mixing it with a custom CA is exactly as significant as mixing two CA files.
163
+ */
164
+ export declare function findBookmarkTrustConflicts(store: InstanceStore, name: string, url: string, caFile?: string): BookmarkTrustConflict[];
155
165
  export declare function addInstance(key: string, opts?: AddInstanceOpts): Promise<InstanceEntry>;
156
166
  export declare function upsertInstance(key: string, opts?: AddInstanceOpts): Promise<{
157
167
  entry: InstanceEntry;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "0.8.1-alpha.6",
3
+ "version": "0.8.1-alpha.7",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -62,10 +62,10 @@
62
62
  "zod": "^3.25.0"
63
63
  },
64
64
  "devDependencies": {
65
- "@astrale-os/kernel-client": "0.6.0-beta.1",
65
+ "@astrale-os/kernel-client": "0.6.0-beta.3",
66
66
  "@astrale-os/ox": ">=0.1.0 <1.0.0",
67
- "@astrale-os/sdk": "0.5.0-beta.1",
68
- "@astrale-os/shell": "0.3.8-beta.1",
67
+ "@astrale-os/sdk": "0.5.0-beta.3",
68
+ "@astrale-os/shell": "0.3.8-beta.2",
69
69
  "@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.1",
70
70
  "@commitlint/cli": "~20.3.1",
71
71
  "@commitlint/config-conventional": "~20.3.1",
@@ -82,16 +82,16 @@
82
82
  "lint-staged": {
83
83
  "*.{js,cjs,mjs,ts,tsx}": [
84
84
  "oxlint --fix --no-error-on-unmatched-pattern",
85
- "oxfmt --write"
85
+ "oxfmt --write --no-error-on-unmatched-pattern"
86
86
  ],
87
87
  "*.{json,yml,yaml}": [
88
- "oxfmt --write"
88
+ "oxfmt --write --no-error-on-unmatched-pattern"
89
89
  ]
90
90
  },
91
91
  "devEngines": {
92
92
  "runtime": {
93
93
  "name": "node",
94
- "version": ">=24 <25",
94
+ "version": ">=24 <25 || >=26 <27",
95
95
  "onFail": "error"
96
96
  }
97
97
  },
@@ -101,7 +101,7 @@
101
101
  "scripts": {
102
102
  "preinstall": "node .check-workspace.cjs",
103
103
  "build": "bun scripts/build.ts",
104
- "package:check": "pnpm run typecheck && pnpm run test && pnpm run build && node scripts/verify-public-exports.mjs",
104
+ "package:check": "pnpm run typecheck && pnpm run test && pnpm run build && node scripts/verify-public-exports.mjs && node scripts/verify-private-ports-boundary.mjs",
105
105
  "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.tests.json && tsgo --noEmit -p viewer && tsgo --noEmit -p studio",
106
106
  "lint": "oxlint .",
107
107
  "lint:fix": "oxlint --fix .",
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { join } from 'node:path'
3
+
4
+ import { uninstallCallInput } from '../domain/uninstall'
5
+
6
+ const cliRoot = join(import.meta.dir, '../../..')
7
+
8
+ describe('domain uninstall', () => {
9
+ test('sends the public Kernel uninstall request', () => {
10
+ expect(uninstallCallInput('grc.example', 'op-1')).toEqual({
11
+ operation: 'op-1',
12
+ origin: 'grc.example',
13
+ })
14
+ })
15
+
16
+ test('requires --yes outside a TTY before connecting to a Kernel', async () => {
17
+ const proc = Bun.spawn({
18
+ cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), 'domain', 'uninstall', 'grc.example', '--json'],
19
+ stdout: 'pipe',
20
+ stderr: 'pipe',
21
+ })
22
+ const [stdout, stderr, exitCode] = await Promise.all([
23
+ new Response(proc.stdout).text(),
24
+ new Response(proc.stderr).text(),
25
+ proc.exited,
26
+ ])
27
+
28
+ expect(exitCode).toBe(1)
29
+ expect(stdout).toBe('')
30
+ expect(JSON.parse(stderr)).toMatchObject({
31
+ error: 'CONFIRMATION_REQUIRED',
32
+ message: 'Uninstalling Domain "grc.example" requires explicit confirmation.',
33
+ })
34
+ })
35
+
36
+ test('states that uninstall never deletes business data', async () => {
37
+ const proc = Bun.spawn({
38
+ cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), 'domain', 'uninstall', '--help'],
39
+ stdout: 'pipe',
40
+ stderr: 'pipe',
41
+ })
42
+ const [stdout, stderr, exitCode] = await Promise.all([
43
+ new Response(proc.stdout).text(),
44
+ new Response(proc.stderr).text(),
45
+ proc.exited,
46
+ ])
47
+
48
+ expect(exitCode).toBe(0)
49
+ expect(stderr).toBe('')
50
+ expect(stdout).toContain('Uninstall never deletes business data.')
51
+ expect(stdout).toContain('business data still uses its schema')
52
+ })
53
+ })
@@ -41,16 +41,27 @@ describe('declared-origin probe (/meta)', () => {
41
41
  return `http://localhost:${server.port}`
42
42
  }
43
43
 
44
- test('reads domainName from a well-formed /meta', async () => {
44
+ test('reads origin from a well-formed /meta', async () => {
45
+ // The exact shape SDK workers serve (adapter-cloudflare worker `/meta`).
45
46
  const url = serveMeta((req) =>
46
47
  new URL(req.url).pathname === '/meta'
47
- ? Response.json({ iss: 'https://x', domainName: 'crm.acme.dev' })
48
+ ? Response.json({
49
+ origin: 'crm.acme.dev',
50
+ issuer: 'https://crm.acme.dev',
51
+ schemaRevision: 'sha256:abc',
52
+ deploymentVersion: 'v1',
53
+ })
48
54
  : new Response('nope', { status: 404 }),
49
55
  )
50
56
  expect(await probeDeclaredOrigin(url)).toBe('crm.acme.dev')
51
57
  })
52
58
 
53
- test('degrades to undefined on missing domainName, non-200, bad JSON, or dead host', async () => {
59
+ test('falls back to the pre-Kernel-V2 domainName field', async () => {
60
+ const legacy = serveMeta(() => Response.json({ iss: 'https://x', domainName: 'crm.acme.dev' }))
61
+ expect(await probeDeclaredOrigin(legacy)).toBe('crm.acme.dev')
62
+ })
63
+
64
+ test('degrades to undefined on missing origin, non-200, bad JSON, or dead host', async () => {
54
65
  const noName = serveMeta(() => Response.json({ iss: 'https://x' }))
55
66
  expect(await probeDeclaredOrigin(noName)).toBeUndefined()
56
67
 
@@ -72,6 +72,63 @@ describe('instance bookmark command', () => {
72
72
  expect(store.instances.testmarc.caFile).toBe('/tmp/ca.pem')
73
73
  expect(store.instances.testmarc.createdAt).toBe('2026-06-10T00:00:00.000Z')
74
74
  })
75
+
76
+ test('warns when another bookmark uses different TLS trust for the same URL', async () => {
77
+ await writeFile(
78
+ join(tmp, 'instances.json'),
79
+ JSON.stringify({
80
+ active: 'stable',
81
+ instances: {
82
+ stable: {
83
+ url: 'https://local.example/kernel',
84
+ caFile: '/certs/stable.pem',
85
+ },
86
+ },
87
+ }),
88
+ )
89
+
90
+ const result = await runBookmark(
91
+ 'stale',
92
+ '--url',
93
+ 'https://local.example/kernel/',
94
+ '--ca',
95
+ '/certs/old.pem',
96
+ '--skip-probe',
97
+ )
98
+
99
+ expect(result.exitCode).toBe(0)
100
+ expect(result.stderr).toContain('TLS trust differs for the same Kernel URL')
101
+ expect(result.stderr).toContain('"stable" uses CA /certs/stable.pem')
102
+ })
103
+
104
+ test('active --json exposes the TLS and identity configuration', async () => {
105
+ await writeFile(
106
+ join(tmp, 'instances.json'),
107
+ JSON.stringify({
108
+ active: 'stable',
109
+ instances: {
110
+ stable: {
111
+ url: 'https://local.example/kernel',
112
+ issuer: 'https://issuer.example',
113
+ defaultIdentity: 'marc',
114
+ caFile: '/certs/stable.pem',
115
+ createdAt: '2026-08-20T00:00:00.000Z',
116
+ },
117
+ },
118
+ }),
119
+ )
120
+
121
+ const result = await runCli('instance', 'active', '--json')
122
+ expect(result.exitCode).toBe(0)
123
+ expect(JSON.parse(result.stdout)).toEqual({
124
+ name: 'stable',
125
+ url: 'https://local.example/kernel',
126
+ issuer: 'https://issuer.example',
127
+ defaultIdentity: 'marc',
128
+ caFile: '/certs/stable.pem',
129
+ createdAt: '2026-08-20T00:00:00.000Z',
130
+ })
131
+ })
75
132
  })
76
133
 
77
134
  async function readInstances(): Promise<{
@@ -85,9 +142,17 @@ async function runBookmark(...args: string[]): Promise<{
85
142
  exitCode: number
86
143
  stdout: string
87
144
  stderr: string
145
+ }> {
146
+ return runCli('instance', 'bookmark', ...args)
147
+ }
148
+
149
+ async function runCli(...args: string[]): Promise<{
150
+ exitCode: number
151
+ stdout: string
152
+ stderr: string
88
153
  }> {
89
154
  const proc = Bun.spawn({
90
- cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), 'instance', 'bookmark', ...args],
155
+ cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), ...args],
91
156
  env: { ...process.env, ASTRALE_HOME: tmp },
92
157
  stdout: 'pipe',
93
158
  stderr: 'pipe',
@@ -15,6 +15,7 @@ function bookmark(overrides: Partial<Bookmark> = {}): Bookmark {
15
15
  issuer: null,
16
16
  active: false,
17
17
  defaultIdentity: null,
18
+ caFile: null,
18
19
  createdAt: null,
19
20
  ...overrides,
20
21
  }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { IssuerUnreachableError } from '../../errors'
4
+ import { InstanceStoreSchema, type ResolvedInstance } from '../../lib/instance'
5
+ import { probeBookmark } from '../instance/use'
6
+
7
+ const resolved: ResolvedInstance = {
8
+ name: 'stable',
9
+ kind: 'bookmark',
10
+ url: 'https://local.example/kernel',
11
+ issuer: 'https://issuer.example',
12
+ caFile: '/certs/stable.pem',
13
+ }
14
+
15
+ describe('instance use bookmark probe', () => {
16
+ test('uses the CA stored on the selected bookmark', async () => {
17
+ const scopedFetch = globalThis.fetch
18
+
19
+ await expect(
20
+ probeBookmark(resolved, {
21
+ readInstances: async () =>
22
+ InstanceStoreSchema.parse({
23
+ active: 'stable',
24
+ instances: {
25
+ stable: { url: resolved.url, caFile: resolved.caFile },
26
+ },
27
+ }),
28
+ fetchWithCaFile: (path) => {
29
+ expect(path).toBe('/certs/stable.pem')
30
+ return scopedFetch
31
+ },
32
+ checkIssuerReachability: async (url, issuer, fetchImpl) => {
33
+ expect(url).toBe(resolved.url)
34
+ expect(issuer).toBe(resolved.issuer)
35
+ expect(fetchImpl).toBe(scopedFetch)
36
+ return { issuer: resolved.issuer!, keys: [{ kid: 'key-1' }] }
37
+ },
38
+ }),
39
+ ).resolves.toBeUndefined()
40
+ })
41
+
42
+ test('names conflicting bookmark CAs in a failed TLS probe', async () => {
43
+ const failure = probeBookmark(resolved, {
44
+ readInstances: async () =>
45
+ InstanceStoreSchema.parse({
46
+ active: 'stable',
47
+ instances: {
48
+ stable: { url: resolved.url, caFile: resolved.caFile },
49
+ stale: { url: resolved.url, caFile: '/certs/old.pem' },
50
+ },
51
+ }),
52
+ fetchWithCaFile: () => fetch,
53
+ checkIssuerReachability: async () => {
54
+ throw new IssuerUnreachableError(resolved.url, 'certificate verify failed')
55
+ },
56
+ })
57
+
58
+ await expect(failure).rejects.toMatchObject({
59
+ code: 'ISSUER_UNREACHABLE',
60
+ message: `Issuer/JWKS probe failed for bookmark "stable" at ${resolved.url}.`,
61
+ })
62
+ await expect(failure).rejects.toHaveProperty(
63
+ 'hint',
64
+ expect.stringContaining('"stale" (CA /certs/old.pem)'),
65
+ )
66
+ })
67
+ })
@@ -0,0 +1,58 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { devDistIsStale } from '../view'
7
+
8
+ const temporaryDirectories: string[] = []
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(
12
+ temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
13
+ )
14
+ })
15
+
16
+ describe('view development runtime build', () => {
17
+ test('reuses a current official build and detects every runtime build input', async () => {
18
+ const root = await mkdtemp(join(tmpdir(), 'astrale-view-build-'))
19
+ temporaryDirectories.push(root)
20
+ const entry = join(root, 'bin', 'astrale.ts')
21
+ const source = join(root, 'src', 'command.ts')
22
+ const vendor = join(root, 'vendor', 'dependency.tgz')
23
+ const buildScript = join(root, 'scripts', 'build.ts')
24
+ const packageJson = join(root, 'package.json')
25
+ const lockfile = join(root, 'pnpm-lock.yaml')
26
+ const dist = join(root, 'dist', 'astrale.js')
27
+
28
+ await Promise.all(
29
+ ['bin', 'src', 'vendor', 'scripts', 'dist'].map((directory) =>
30
+ mkdir(join(root, directory), { recursive: true }),
31
+ ),
32
+ )
33
+ const inputs = [entry, source, vendor, buildScript, packageJson, lockfile]
34
+ await Promise.all(inputs.map((file) => writeFile(file, 'input')))
35
+ await writeFile(dist, 'built')
36
+ const past = new Date(Date.now() - 2_000)
37
+ const present = new Date()
38
+ await Promise.all(inputs.map((file) => utimes(file, past, past)))
39
+ await utimes(dist, present, present)
40
+
41
+ expect(await devDistIsStale(entry, dist)).toBe(false)
42
+
43
+ for (const input of inputs) {
44
+ await utimes(input, new Date(Date.now() + 2_000), new Date(Date.now() + 2_000))
45
+ expect(await devDistIsStale(entry, dist)).toBe(true)
46
+ await utimes(input, past, past)
47
+ }
48
+ })
49
+
50
+ test('requires a build when dist/astrale.js is missing', async () => {
51
+ const root = await mkdtemp(join(tmpdir(), 'astrale-view-build-missing-'))
52
+ temporaryDirectories.push(root)
53
+
54
+ expect(
55
+ await devDistIsStale(join(root, 'bin', 'astrale.ts'), join(root, 'dist', 'astrale.js')),
56
+ ).toBe(true)
57
+ })
58
+ })
@@ -415,7 +415,7 @@ export function isIdentityOverride(origin: string, host: string): boolean {
415
415
  * Claiming an origin that differs from the serving host is an explicit actAs
416
416
  * and needs typed consent (or `--allow-identity-override` in scripts).
417
417
  *
418
- * The pre-install check reads the worker's self-reported `/meta.domainName`,
418
+ * The pre-install check reads the worker's self-reported `/meta.origin`,
419
419
  * so it is consent UX, not enforcement — a hostile worker can lie here, and
420
420
  * the kernel anchors the cryptographic identity (`iss`) on the real URL
421
421
  * regardless. When `/meta` is unreachable or silent on the origin, the gate
@@ -476,10 +476,11 @@ export async function probeDeclaredOrigin(url: string): Promise<string | undefin
476
476
  try {
477
477
  const res = await fetch(new URL('/meta', url), { signal: AbortSignal.timeout(10_000) })
478
478
  if (!res.ok) return undefined
479
- const body = (await res.json()) as { domainName?: unknown }
480
- return typeof body.domainName === 'string' && body.domainName.length > 0
481
- ? body.domainName
482
- : undefined
479
+ // SDK workers serve `origin`; `domainName` is the pre-Kernel-V2 name, kept
480
+ // as a fallback for workers deployed before the rename.
481
+ const body = (await res.json()) as { origin?: unknown; domainName?: unknown }
482
+ const declared = body.origin ?? body.domainName
483
+ return typeof declared === 'string' && declared.length > 0 ? declared : undefined
483
484
  } catch {
484
485
  // Unreachable /meta is not fatal here: the caller warns and the install
485
486
  // 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 { normalizeInstanceKernelUrl, setActive, upsertInstance } from '../../lib/instance'
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 expectedIssuer = opts.issuer ? normalizeInstanceKernelUrl(opts.issuer) : undefined
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
- opts.ca ? fetchWithCaFile(opts.ca) : undefined,
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
+ }