@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.
- package/README.md +3 -2
- package/dist/astrale.js +2599 -2703
- 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 +8 -8
- package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
- 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 +6 -5
- 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 +410 -36
- 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 +1 -1
- package/src/connection/errors.ts +139 -160
- package/src/connection/exchange.ts +14 -2
- package/src/connection/reasons.ts +179 -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/package.json +7 -8
- 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.
|
|
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.
|
|
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.
|
|
68
|
-
"@astrale-os/shell": "0.3.8-beta.
|
|
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
|
|
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({
|
|
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('
|
|
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'),
|
|
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',
|
|
@@ -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.
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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 {
|
|
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
|
+
}
|