@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.
Files changed (98) hide show
  1. package/README.md +18 -5
  2. package/dist/astrale.js +2661 -2714
  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 +9 -9
  9. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  10. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  11. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  12. package/src/commands/__tests__/install-direct.test.ts +3 -2
  13. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  14. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  15. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  16. package/src/commands/__tests__/instance-use.test.ts +67 -0
  17. package/src/commands/__tests__/view-build.test.ts +58 -0
  18. package/src/commands/domain/install.ts +84 -20
  19. package/src/commands/domain/uninstall.ts +128 -0
  20. package/src/commands/instance/active.ts +13 -1
  21. package/src/commands/instance/bookmark.ts +26 -3
  22. package/src/commands/instance/list.ts +18 -4
  23. package/src/commands/instance/use.ts +54 -7
  24. package/src/commands/view.ts +28 -16
  25. package/src/connection/.spec/architecture.md +5 -0
  26. package/src/connection/.spec/laws/connection.ts +20 -0
  27. package/src/connection/.spec/layout.ts +1 -0
  28. package/src/connection/__tests__/auth.test.ts +27 -1
  29. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  30. package/src/connection/__tests__/errors.test.ts +483 -33
  31. package/src/connection/__tests__/exchange.test.ts +46 -5
  32. package/src/connection/__tests__/reasons.test.ts +78 -0
  33. package/src/connection/auth.ts +11 -9
  34. package/src/connection/command.ts +9 -1
  35. package/src/connection/errors.ts +149 -159
  36. package/src/connection/exchange.ts +14 -2
  37. package/src/connection/index.ts +1 -1
  38. package/src/connection/reasons.ts +193 -0
  39. package/src/lib/__tests__/instance.test.ts +51 -1
  40. package/src/lib/__tests__/view-assets.test.ts +33 -1
  41. package/src/lib/__tests__/view-server.test.ts +68 -0
  42. package/src/lib/ca-fetch.ts +9 -3
  43. package/src/lib/instance.ts +31 -0
  44. package/src/lib/view/assets.ts +16 -2
  45. package/src/program/__tests__/program.test.ts +2 -1
  46. package/src/program/build.ts +2 -1
  47. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  48. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  49. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  50. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  51. package/studio/client/dist/index.html +2 -2
  52. package/studio/package.json +8 -9
  53. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  54. package/studio/server/agent/prompts/anchors.ts +85 -15
  55. package/studio/server/agent/prompts/system.test.ts +12 -0
  56. package/studio/server/agent/prompts/system.ts +6 -6
  57. package/studio/server/api.ts +1 -5
  58. package/studio/server/cache.ts +5 -2
  59. package/studio/server/domain.test.ts +67 -0
  60. package/studio/server/domain.ts +29 -6
  61. package/studio/server/index.ts +1 -3
  62. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  63. package/studio/server/introspect/anatomy-extras.ts +340 -8
  64. package/studio/server/introspect/anatomy.test.ts +33 -0
  65. package/studio/server/introspect/anatomy.ts +22 -7
  66. package/studio/server/introspect/bundle.ts +7 -1
  67. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  68. package/studio/server/introspect/canonical-schema.ts +751 -0
  69. package/studio/server/introspect/core-extractor.ts +30 -10
  70. package/studio/server/introspect/core.ts +4 -2
  71. package/studio/server/introspect/diff.test.ts +124 -0
  72. package/studio/server/introspect/diff.ts +252 -23
  73. package/studio/server/introspect/extractor.ts +46 -14
  74. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  75. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  76. package/studio/server/introspect/overlay.test.ts +72 -0
  77. package/studio/server/introspect/overlay.ts +16 -6
  78. package/studio/server/introspect/runtime.test.ts +217 -0
  79. package/studio/server/introspect/runtime.ts +18 -6
  80. package/studio/server/introspect/schema-refs.test.ts +100 -0
  81. package/studio/server/introspect/schema-refs.ts +23 -2
  82. package/studio/server/state/baseline.test.ts +51 -0
  83. package/studio/server/state/baseline.ts +31 -2
  84. package/studio/server/state/create.test.ts +37 -0
  85. package/studio/server/state/create.ts +21 -15
  86. package/studio/server/state/instance.test.ts +63 -0
  87. package/studio/server/state/instance.ts +65 -29
  88. package/studio/server/state/views.test.ts +209 -9
  89. package/studio/server/state/views.ts +176 -69
  90. package/studio/server/watch.test.ts +36 -0
  91. package/studio/server/watch.ts +24 -16
  92. package/studio/server/workspace-watch.ts +8 -3
  93. package/studio/shared/types.ts +164 -33
  94. package/viewer/dist/main.js +57 -57
  95. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  96. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  97. package/studio/server/view-dev-server.test.ts +0 -111
  98. 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.8.1-alpha.6",
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
- expect(directInstallCallInput('https://tasks.example.test', 'secret', 'op-1')).toEqual({
8
- operation: 'op-1',
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 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
+ })