@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
@@ -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; active: boolean }>()
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 && resolved.issuer) {
52
- try {
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 /meta JWKS match check' },
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)
@@ -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
- const bun = (
176
- globalThis as {
177
- Bun?: { build: (o: object) => Promise<{ success: boolean; logs: unknown[] }> }
178
- }
179
- ).Bun
180
- if (!bun) return
181
- const srcDir = join(dirname(entry), '..', 'src')
182
- if (existsSync(dist) && !(await newerThan(srcDir, statSync(dist).mtimeMs))) return
183
- // stderr: --json consumers parse stdout.
184
- console.error('(dev) building dist/astrale.js for the session server…')
185
- await bun.build({
186
- entrypoints: [entry],
187
- outdir: dirname(dist),
188
- target: 'node',
189
- format: 'esm',
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
+ })
@@ -10,6 +10,7 @@ export default defineLayout({
10
10
  'errors.ts',
11
11
  'exchange.ts',
12
12
  'index.ts',
13
+ 'reasons.ts',
13
14
  'self.ts',
14
15
  'session.ts',
15
16
  'target.ts',
@@ -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 { resolveKeyIdentityAuthOptions } from '../auth'
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: () => new Response('trusted'),
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')