@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
|
@@ -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
|
+
})
|
|
@@ -3,7 +3,9 @@ import { describe, expect, test } from 'bun:test'
|
|
|
3
3
|
import type { Identity } from '../../identity/index'
|
|
4
4
|
import type { AstraleConfig } from '../../lib/config'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { IdpAudienceMismatchError } from '../../lib/idp'
|
|
7
|
+
import { IdpSessionNoRefreshTokenError } from '../../lib/idp-session'
|
|
8
|
+
import { classifyNoRefreshTokenError, resolveKeyIdentityAuthOptions } from '../auth'
|
|
7
9
|
|
|
8
10
|
const config: AstraleConfig = {
|
|
9
11
|
issuer: 'https://unregistered.invalid',
|
|
@@ -100,3 +102,27 @@ describe('resolveKeyIdentityAuthOptions', () => {
|
|
|
100
102
|
})
|
|
101
103
|
})
|
|
102
104
|
})
|
|
105
|
+
|
|
106
|
+
describe('classifyNoRefreshTokenError', () => {
|
|
107
|
+
test('preserves expiry when the non-refreshable session already targets the required audience', () => {
|
|
108
|
+
const expired = new IdpSessionNoRefreshTokenError('alice')
|
|
109
|
+
|
|
110
|
+
expect(
|
|
111
|
+
classifyNoRefreshTokenError('https://kernel.example', 'https://kernel.example', expired),
|
|
112
|
+
).toBe(expired)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('reports a real audience mismatch when the source audience differs', () => {
|
|
116
|
+
const result = classifyNoRefreshTokenError(
|
|
117
|
+
'https://child.example',
|
|
118
|
+
'https://manager.example',
|
|
119
|
+
new IdpSessionNoRefreshTokenError('alice'),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(result).toBeInstanceOf(IdpAudienceMismatchError)
|
|
123
|
+
expect(result).toMatchObject({
|
|
124
|
+
requested: 'https://child.example',
|
|
125
|
+
actual: 'https://manager.example',
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -29,7 +29,10 @@ describe('connection CA fetch', () => {
|
|
|
29
29
|
hostname: '127.0.0.1',
|
|
30
30
|
port: 0,
|
|
31
31
|
tls: { cert: certificate, key },
|
|
32
|
-
fetch: () =>
|
|
32
|
+
fetch: (request) =>
|
|
33
|
+
new URL(request.url).pathname === '/cached'
|
|
34
|
+
? new Response(null, { status: 304, headers: { etag: 'retained' } })
|
|
35
|
+
: new Response('trusted'),
|
|
33
36
|
})
|
|
34
37
|
|
|
35
38
|
const requests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
|
|
@@ -45,6 +48,10 @@ describe('connection CA fetch', () => {
|
|
|
45
48
|
expect(await httpsResponse.text()).toBe('trusted')
|
|
46
49
|
expect(httpsResponse.url).toBe(httpsUrl)
|
|
47
50
|
expect(httpsResponse.redirected).toBe(false)
|
|
51
|
+
const cachedResponse = await scoped(`https://127.0.0.1:${server.port}/cached`)
|
|
52
|
+
expect(cachedResponse.status).toBe(304)
|
|
53
|
+
expect(cachedResponse.body).toBeNull()
|
|
54
|
+
expect(cachedResponse.headers.get('etag')).toBe('retained')
|
|
48
55
|
await expect(
|
|
49
56
|
scoped('http://localhost:8080/invoke', init).then((value) => value.text()),
|
|
50
57
|
).resolves.toBe('fallback')
|