@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
|
@@ -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.
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
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 <1.0.0",
|
|
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 <1.0.0",
|
|
68
|
+
"@astrale-os/shell": ">=0.3.8-beta.2 <1.0.0",
|
|
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,13 +101,13 @@
|
|
|
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 .",
|
|
108
108
|
"format": "pnpm exec oxfmt --write .",
|
|
109
109
|
"format:check": "pnpm exec oxfmt --check .",
|
|
110
|
-
"test": "bun test src",
|
|
110
|
+
"test": "bun test src && node --test scripts/*.test.mjs",
|
|
111
111
|
"test:watch": "bun test --watch src"
|
|
112
112
|
}
|
|
113
113
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { installDirect } from '../domain/install'
|
|
4
|
+
|
|
5
|
+
const GENERATED = '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8'
|
|
6
|
+
const RETRY = '139137b5-af47-47ce-92b2-b64a2b0c63d7'
|
|
7
|
+
|
|
8
|
+
const originalFetch = globalThis.fetch
|
|
9
|
+
|
|
10
|
+
class ExitError extends Error {}
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
globalThis.fetch = originalFetch
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('direct domain install operation identity', () => {
|
|
17
|
+
test('generates one operation before transport and exposes it for recovery', async () => {
|
|
18
|
+
globalThis.fetch = mock(async () =>
|
|
19
|
+
Response.json({ domainName: 'crm.test' }),
|
|
20
|
+
) as unknown as typeof fetch
|
|
21
|
+
const create = mock(() => GENERATED)
|
|
22
|
+
const requests: unknown[] = []
|
|
23
|
+
let recovery: unknown
|
|
24
|
+
|
|
25
|
+
await installDirect(
|
|
26
|
+
'https://crm.test',
|
|
27
|
+
{ direct: true, json: true },
|
|
28
|
+
{
|
|
29
|
+
createOperationId: create,
|
|
30
|
+
runKernelCommand: async (input) => {
|
|
31
|
+
recovery = input.recovery
|
|
32
|
+
requests.push(
|
|
33
|
+
await input.fn({
|
|
34
|
+
session: {
|
|
35
|
+
call: async (call: { input: unknown }) => call.input,
|
|
36
|
+
},
|
|
37
|
+
} as never),
|
|
38
|
+
)
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
expect(create).toHaveBeenCalledTimes(1)
|
|
44
|
+
expect(requests).toEqual([
|
|
45
|
+
{
|
|
46
|
+
operation: GENERATED,
|
|
47
|
+
domains: [{ source: { kind: 'remote', url: 'https://crm.test' } }],
|
|
48
|
+
},
|
|
49
|
+
])
|
|
50
|
+
expect(recovery).toEqual({
|
|
51
|
+
operation: GENERATED,
|
|
52
|
+
retry: `astrale domain install https://crm.test --direct --operation ${GENERATED}`,
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('accepts an explicit operation only as the exact retry identity', async () => {
|
|
57
|
+
globalThis.fetch = mock(async () =>
|
|
58
|
+
Response.json({ domainName: 'crm.test' }),
|
|
59
|
+
) as unknown as typeof fetch
|
|
60
|
+
const create = mock(() => GENERATED)
|
|
61
|
+
const accepted: string[] = []
|
|
62
|
+
const requests: unknown[] = []
|
|
63
|
+
|
|
64
|
+
await installDirect(
|
|
65
|
+
'https://crm.test',
|
|
66
|
+
{ direct: true, operation: RETRY, json: true },
|
|
67
|
+
{
|
|
68
|
+
createOperationId: create,
|
|
69
|
+
acceptOperationId: (input) => {
|
|
70
|
+
accepted.push(String(input))
|
|
71
|
+
return RETRY
|
|
72
|
+
},
|
|
73
|
+
runKernelCommand: async (input) => {
|
|
74
|
+
requests.push(
|
|
75
|
+
await input.fn({
|
|
76
|
+
session: {
|
|
77
|
+
call: async (call: { input: unknown }) => call.input,
|
|
78
|
+
},
|
|
79
|
+
} as never),
|
|
80
|
+
)
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(create).not.toHaveBeenCalled()
|
|
86
|
+
expect(accepted).toEqual([RETRY])
|
|
87
|
+
expect(requests).toEqual([
|
|
88
|
+
{
|
|
89
|
+
operation: RETRY,
|
|
90
|
+
domains: [{ source: { kind: 'remote', url: 'https://crm.test' } }],
|
|
91
|
+
},
|
|
92
|
+
])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('rejects a non-UUID operation before metadata or Kernel transport', async () => {
|
|
96
|
+
const originalExit = process.exit
|
|
97
|
+
const originalWrite = process.stderr.write
|
|
98
|
+
const fetch = mock(async () => Response.json({ domainName: 'crm.test' }))
|
|
99
|
+
const run = mock(async () => undefined)
|
|
100
|
+
globalThis.fetch = fetch as unknown as typeof globalThis.fetch
|
|
101
|
+
process.exit = (() => {
|
|
102
|
+
throw new ExitError('exit')
|
|
103
|
+
}) as typeof process.exit
|
|
104
|
+
process.stderr.write = (() => true) as typeof process.stderr.write
|
|
105
|
+
try {
|
|
106
|
+
await expect(
|
|
107
|
+
installDirect(
|
|
108
|
+
'https://crm.test',
|
|
109
|
+
{ direct: true, operation: 'guessable-operation', json: true },
|
|
110
|
+
{ runKernelCommand: run },
|
|
111
|
+
),
|
|
112
|
+
).rejects.toBeInstanceOf(ExitError)
|
|
113
|
+
} finally {
|
|
114
|
+
process.exit = originalExit
|
|
115
|
+
process.stderr.write = originalWrite
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
expect(fetch).not.toHaveBeenCalled()
|
|
119
|
+
expect(run).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ResponseError } from '@astrale-os/kernel-client'
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
3
|
|
|
3
4
|
import type { OwnedInstanceInfo } from '../../lib/admin-instance'
|
|
@@ -118,4 +119,69 @@ describe('admin domain install owner boundary', () => {
|
|
|
118
119
|
expect(calls).toEqual([])
|
|
119
120
|
expect(calls).toEqual([])
|
|
120
121
|
})
|
|
122
|
+
|
|
123
|
+
test('preserves an incompatible issuer reason returned through Admin', async () => {
|
|
124
|
+
inventory = [
|
|
125
|
+
{
|
|
126
|
+
id: 'owned-id',
|
|
127
|
+
slug: 'owned',
|
|
128
|
+
url: 'https://owned.eu.astrale.ai',
|
|
129
|
+
state: 'ready',
|
|
130
|
+
},
|
|
131
|
+
]
|
|
132
|
+
const domain = {
|
|
133
|
+
id: 'crm-id',
|
|
134
|
+
origin: 'crm.acme.dev',
|
|
135
|
+
name: 'CRM',
|
|
136
|
+
url: 'https://crm.acme.dev',
|
|
137
|
+
createdAt: '2026-08-20T00:00:00.000Z',
|
|
138
|
+
updatedAt: '2026-08-20T00:00:00.000Z',
|
|
139
|
+
}
|
|
140
|
+
const failure = new ResponseError(5001, 'Schema operation is not supported.', {
|
|
141
|
+
code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
|
|
142
|
+
details: {
|
|
143
|
+
phase: 'upgrade',
|
|
144
|
+
origin: domain.origin,
|
|
145
|
+
issue: 'issuer-changed',
|
|
146
|
+
installedIssuer: 'https://old.example',
|
|
147
|
+
replacementIssuer: 'https://new.example',
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
const { installViaAdmin } = await import('../domain/install')
|
|
151
|
+
|
|
152
|
+
await expect(
|
|
153
|
+
installViaAdmin(
|
|
154
|
+
domain.origin,
|
|
155
|
+
{
|
|
156
|
+
instance: 'owned',
|
|
157
|
+
json: true,
|
|
158
|
+
noPrompt: true,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
listInstances: async () => inventory,
|
|
162
|
+
listDomains: async () => [domain],
|
|
163
|
+
install: async () => {
|
|
164
|
+
throw failure
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
),
|
|
168
|
+
).rejects.toEqual(new ExitError(1))
|
|
169
|
+
|
|
170
|
+
expect(JSON.parse(stderr)).toEqual({
|
|
171
|
+
error: 'RESPONSE_ERROR',
|
|
172
|
+
code: 5001,
|
|
173
|
+
message: 'Schema operation is not supported.',
|
|
174
|
+
reason: {
|
|
175
|
+
code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
|
|
176
|
+
details: {
|
|
177
|
+
phase: 'upgrade',
|
|
178
|
+
origin: 'crm.acme.dev',
|
|
179
|
+
issue: 'issuer-changed',
|
|
180
|
+
installedIssuer: 'https://old.example',
|
|
181
|
+
replacementIssuer: 'https://new.example',
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
hint: expect.stringContaining('astrale domain uninstall crm.acme.dev'),
|
|
185
|
+
})
|
|
186
|
+
})
|
|
121
187
|
})
|
|
@@ -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
|
+
})
|
|
@@ -4,8 +4,9 @@ import { directInstallCallInput } from '../domain/install'
|
|
|
4
4
|
|
|
5
5
|
describe('directInstallCallInput', () => {
|
|
6
6
|
test('sends the current remote install syscall, not a legacy url list', () => {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const operation = '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8'
|
|
8
|
+
expect(directInstallCallInput('https://tasks.example.test', operation, 'secret')).toEqual({
|
|
9
|
+
operation,
|
|
9
10
|
domains: [
|
|
10
11
|
{
|
|
11
12
|
source: {
|
|
@@ -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
|
+
})
|