@astrale-os/cli 0.8.1-alpha.5 → 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 +4 -3
- package/dist/astrale.js +3391 -3163
- package/dist/public/connect-core.js +2041 -3066
- package/dist/public/keys/index.js +1854 -2895
- package/dist/public/paths/index.js +1833 -2882
- package/dist/types/connection/auth.d.ts +3 -0
- package/dist/types/lib/instance-target.d.ts +2 -0
- package/dist/types/lib/instance.d.ts +10 -0
- package/dist/types/lib/invocation.d.ts +3 -0
- package/dist/types/lib/log.d.ts +6 -5
- package/dist/types/lib/output.d.ts +6 -2
- 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__/introspect-parse.test.ts +21 -0
- package/src/commands/__tests__/logs.test.ts +5 -0
- package/src/commands/__tests__/read-commands.test.ts +11 -1
- package/src/commands/__tests__/token-ttl.test.ts +21 -0
- package/src/commands/__tests__/view-build.test.ts +58 -0
- package/src/commands/auth/token.ts +19 -7
- package/src/commands/call.ts +18 -43
- package/src/commands/domain/install.ts +6 -5
- package/src/commands/domain/uninstall.ts +128 -0
- package/src/commands/get.ts +23 -5
- package/src/commands/identity/create.ts +11 -2
- package/src/commands/identity/delete.ts +8 -2
- package/src/commands/identity/export.ts +8 -2
- package/src/commands/identity/import.ts +11 -2
- package/src/commands/identity/sync.ts +8 -2
- package/src/commands/identity/unsync.ts +12 -2
- package/src/commands/identity/use.ts +8 -2
- package/src/commands/identity/whoami.ts +1 -1
- 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/introspect.ts +117 -0
- package/src/commands/logs.ts +50 -6
- package/src/commands/mutate.ts +2 -3
- package/src/commands/query.ts +3 -5
- package/src/commands/token.ts +35 -8
- package/src/commands/update.ts +30 -9
- package/src/commands/view.ts +33 -19
- 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__/credential.test.ts +10 -0
- package/src/connection/__tests__/errors.test.ts +430 -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/credential.ts +3 -0
- package/src/connection/errors.ts +195 -154
- package/src/connection/exchange.ts +14 -2
- package/src/connection/reasons.ts +179 -0
- package/src/connection/session.ts +9 -0
- package/src/connection/target.ts +1 -0
- package/src/graph/__tests__/mutation.test.ts +6 -0
- package/src/graph/mutation.ts +13 -0
- package/src/identity/__tests__/registry.test.ts +1 -1
- package/src/identity/registry.ts +12 -5
- package/src/lib/__tests__/command-dx.test.ts +35 -20
- package/src/lib/__tests__/instance-target.test.ts +17 -0
- package/src/lib/__tests__/instance.test.ts +51 -1
- package/src/lib/__tests__/output.test.ts +12 -0
- 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/command-dx.ts +40 -23
- package/src/lib/instance-target.ts +18 -2
- package/src/lib/instance.ts +31 -0
- package/src/lib/invocation.ts +11 -0
- package/src/lib/log.ts +17 -9
- package/src/lib/output.ts +20 -4
- package/src/lib/view/assets.ts +16 -2
- package/src/program/__tests__/program.test.ts +4 -1
- package/src/program/build.ts +5 -9
- package/src/program/options.ts +2 -1
- package/src/state/__tests__/identities.test.ts +2 -2
- package/src/state/identities.ts +3 -10
- package/studio/package.json +7 -8
- package/viewer/dist/main.js +57 -57
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export type FunctionInputIssue = Readonly<{
|
|
2
|
+
code: string
|
|
3
|
+
path?: string
|
|
4
|
+
message: string
|
|
5
|
+
}>
|
|
6
|
+
|
|
7
|
+
export type QueryInputRepair =
|
|
8
|
+
| Readonly<{ phase: 'decode' | 'input'; path: string }>
|
|
9
|
+
| Readonly<{ phase: 'plan'; issue: string; path?: string }>
|
|
10
|
+
| Readonly<{
|
|
11
|
+
phase: 'limit'
|
|
12
|
+
limit: string
|
|
13
|
+
maximum: number
|
|
14
|
+
actual: number
|
|
15
|
+
path?: string
|
|
16
|
+
}>
|
|
17
|
+
|
|
18
|
+
export type SchemaUpgradeDetails = {
|
|
19
|
+
readonly origin?: string
|
|
20
|
+
readonly issue?: string
|
|
21
|
+
readonly expected?: string
|
|
22
|
+
readonly actual?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const JSON_POINTER = /^(?:\/(?:[^~/]|~[01])*)*$/u
|
|
26
|
+
const MAXIMUM_FUNCTION_ISSUES = 32
|
|
27
|
+
const MAXIMUM_FUNCTION_ISSUE_MESSAGE_LENGTH = 512
|
|
28
|
+
|
|
29
|
+
export function reasonCode(reason: unknown): string | undefined {
|
|
30
|
+
if (!record(reason) || typeof reason.code !== 'string') return undefined
|
|
31
|
+
return stableCode(reason.code) ? reason.code : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Admit only bounded caller-safe Function input issues established by the Kernel. */
|
|
35
|
+
export function functionInputIssues(reason: unknown): readonly FunctionInputIssue[] {
|
|
36
|
+
if (!reasonWithCode(reason, 'FUNCTION_INPUT_INVALID')) return Object.freeze([])
|
|
37
|
+
const issues = reason.details.issues
|
|
38
|
+
if (!Array.isArray(issues)) return Object.freeze([])
|
|
39
|
+
return Object.freeze(
|
|
40
|
+
issues.slice(0, MAXIMUM_FUNCTION_ISSUES).flatMap((candidate): FunctionInputIssue[] => {
|
|
41
|
+
if (
|
|
42
|
+
!record(candidate) ||
|
|
43
|
+
typeof candidate.code !== 'string' ||
|
|
44
|
+
!stableCode(candidate.code) ||
|
|
45
|
+
typeof candidate.message !== 'string' ||
|
|
46
|
+
candidate.message.length === 0 ||
|
|
47
|
+
candidate.message.length > MAXIMUM_FUNCTION_ISSUE_MESSAGE_LENGTH ||
|
|
48
|
+
candidate.message.normalize('NFC') !== candidate.message ||
|
|
49
|
+
containsControl(candidate.message) ||
|
|
50
|
+
(candidate.path !== undefined && !coordinate(candidate.path))
|
|
51
|
+
) {
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
return [
|
|
55
|
+
Object.freeze({
|
|
56
|
+
code: candidate.code,
|
|
57
|
+
...(candidate.path === undefined ? {} : { path: candidate.path }),
|
|
58
|
+
message: candidate.message,
|
|
59
|
+
}),
|
|
60
|
+
]
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Admit only public Query-input repair variants; unknown details remain machine-only. */
|
|
66
|
+
export function queryInputRepair(reason: unknown): QueryInputRepair | undefined {
|
|
67
|
+
if (!reasonWithCode(reason, 'QUERY_INPUT_INVALID')) return undefined
|
|
68
|
+
const details = reason.details
|
|
69
|
+
if (details.phase === 'decode' || details.phase === 'input') {
|
|
70
|
+
if (!exact(details, ['phase', 'path']) || !coordinate(details.path)) return undefined
|
|
71
|
+
return Object.freeze({ phase: details.phase, path: details.path })
|
|
72
|
+
}
|
|
73
|
+
if (details.phase === 'plan') {
|
|
74
|
+
const fields = details.path === undefined ? ['phase', 'issue'] : ['phase', 'issue', 'path']
|
|
75
|
+
if (
|
|
76
|
+
!exact(details, fields) ||
|
|
77
|
+
typeof details.issue !== 'string' ||
|
|
78
|
+
!stableCode(details.issue) ||
|
|
79
|
+
(details.path !== undefined && !coordinate(details.path))
|
|
80
|
+
) {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
phase: 'plan',
|
|
85
|
+
issue: details.issue,
|
|
86
|
+
...(details.path === undefined ? {} : { path: details.path }),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
if (details.phase !== 'limit') return undefined
|
|
90
|
+
const fields =
|
|
91
|
+
details.path === undefined
|
|
92
|
+
? ['phase', 'limit', 'maximum', 'actual']
|
|
93
|
+
: ['phase', 'limit', 'maximum', 'actual', 'path']
|
|
94
|
+
if (
|
|
95
|
+
!exact(details, fields) ||
|
|
96
|
+
typeof details.limit !== 'string' ||
|
|
97
|
+
!Number.isSafeInteger(details.maximum) ||
|
|
98
|
+
(details.maximum as number) < 0 ||
|
|
99
|
+
!Number.isSafeInteger(details.actual) ||
|
|
100
|
+
(details.actual as number) <= (details.maximum as number) ||
|
|
101
|
+
(details.path !== undefined && !coordinate(details.path))
|
|
102
|
+
) {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
return Object.freeze({
|
|
106
|
+
phase: 'limit',
|
|
107
|
+
limit: details.limit,
|
|
108
|
+
maximum: details.maximum as number,
|
|
109
|
+
actual: details.actual as number,
|
|
110
|
+
...(details.path === undefined ? {} : { path: details.path }),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Decode bounded recovery guidance while preserving the admitted reason itself elsewhere. */
|
|
115
|
+
export function schemaUpgradeDetails(reason: unknown): SchemaUpgradeDetails | undefined {
|
|
116
|
+
if (!record(reason) || reason.code !== 'SCHEMA_UPGRADE_INCOMPATIBLE') return undefined
|
|
117
|
+
const details = record(reason.details) ? reason.details : {}
|
|
118
|
+
return {
|
|
119
|
+
...(typeof details.origin === 'string' ? { origin: details.origin } : {}),
|
|
120
|
+
...(typeof details.issue === 'string' ? { issue: details.issue } : {}),
|
|
121
|
+
...(typeof details.expected === 'string' ? { expected: details.expected } : {}),
|
|
122
|
+
...(typeof details.actual === 'string' ? { actual: details.actual } : {}),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function schemaUpgradeHint(details: SchemaUpgradeDetails): string {
|
|
127
|
+
const target = details.origin ?? '<origin>'
|
|
128
|
+
const explanation =
|
|
129
|
+
details.expected !== undefined && details.actual !== undefined
|
|
130
|
+
? 'A replacement cannot change an installed Domain issuer.'
|
|
131
|
+
: 'The replacement changes an immutable part of the installed Domain.'
|
|
132
|
+
return (
|
|
133
|
+
`${explanation} If this change is intentional, first run ` +
|
|
134
|
+
`\`astrale domain uninstall ${target}\`, then install it again. ` +
|
|
135
|
+
'The Kernel refuses uninstall while dependents or business data remain; uninstall never deletes business data.'
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function reasonWithCode(
|
|
140
|
+
input: unknown,
|
|
141
|
+
code: string,
|
|
142
|
+
): input is Readonly<{ code: string; details: Readonly<Record<string, unknown>> }> {
|
|
143
|
+
return (
|
|
144
|
+
record(input) &&
|
|
145
|
+
exact(input, ['code', 'details']) &&
|
|
146
|
+
input.code === code &&
|
|
147
|
+
record(input.details)
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function coordinate(input: unknown): input is string {
|
|
152
|
+
return (
|
|
153
|
+
typeof input === 'string' &&
|
|
154
|
+
input.length <= 1_024 &&
|
|
155
|
+
input.normalize('NFC') === input &&
|
|
156
|
+
JSON_POINTER.test(input)
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stableCode(input: string): boolean {
|
|
161
|
+
return /^[A-Z][A-Z0-9_]{0,127}$/u.test(input) && input.normalize('NFC') === input
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function containsControl(input: string): boolean {
|
|
165
|
+
for (const character of input) {
|
|
166
|
+
const point = character.codePointAt(0)!
|
|
167
|
+
if (point <= 0x1f || point === 0x7f) return true
|
|
168
|
+
}
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function record(input: unknown): input is Readonly<Record<string, unknown>> {
|
|
173
|
+
return input !== null && typeof input === 'object' && !Array.isArray(input)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function exact(input: Readonly<Record<string, unknown>>, fields: readonly string[]): boolean {
|
|
177
|
+
const actual = Object.keys(input)
|
|
178
|
+
return actual.length === fields.length && actual.every((field) => fields.includes(field))
|
|
179
|
+
}
|
|
@@ -19,12 +19,20 @@ import {
|
|
|
19
19
|
import { AstraleError } from '../errors'
|
|
20
20
|
import { fetchWithCaFile } from '../lib/ca-fetch'
|
|
21
21
|
import { readConfig } from '../lib/config'
|
|
22
|
+
import { log } from '../lib/log'
|
|
23
|
+
import { isMachine } from '../lib/output'
|
|
22
24
|
import { createCliCredential, validateCredentialSelection } from './credential'
|
|
23
25
|
import { resolveAdminConnectionTarget, resolveConnectionTarget } from './target'
|
|
24
26
|
|
|
25
27
|
const DEFAULT_TIMEOUT_MS = 30_000
|
|
26
28
|
const MAXIMUM_ROUTE_AGE_MS = 5 * 60_000
|
|
27
29
|
|
|
30
|
+
function warnMissingExplicitTarget(options: ConnectionOptions, target: ConnectionTarget): void {
|
|
31
|
+
if (options.instance !== undefined || options.url !== undefined) return
|
|
32
|
+
if (!isMachine(options)) return
|
|
33
|
+
log.warn(`No -i/--url; using ${target.slug ?? target.url}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export interface ConnectionContext {
|
|
29
37
|
readonly session: ClientSession
|
|
30
38
|
readonly graph: GraphApi
|
|
@@ -55,6 +63,7 @@ export async function withClientSession<Value>(
|
|
|
55
63
|
const target = await resolveConnectionTarget(options, config, {
|
|
56
64
|
managed: (slug) => lookupManagedInstance(slug, options),
|
|
57
65
|
})
|
|
66
|
+
warnMissingExplicitTarget(options, target)
|
|
58
67
|
return runResolvedClientSession(target, timeoutMs, options, config, action, openConnection)
|
|
59
68
|
}
|
|
60
69
|
|
package/src/connection/target.ts
CHANGED
|
@@ -42,4 +42,10 @@ describe('prepareMutation', () => {
|
|
|
42
42
|
/Legacy PatchData/,
|
|
43
43
|
)
|
|
44
44
|
})
|
|
45
|
+
|
|
46
|
+
test('rejects an empty operations list in CLI language', () => {
|
|
47
|
+
expect(() => prepareMutation({ preconditions: [], operations: [] })).toThrow(
|
|
48
|
+
'Mutation V3 requires at least one operation',
|
|
49
|
+
)
|
|
50
|
+
})
|
|
45
51
|
})
|
package/src/graph/mutation.ts
CHANGED
|
@@ -9,10 +9,23 @@ export function prepareMutation(input: unknown): MutationASTValue {
|
|
|
9
9
|
'Legacy PatchData { nodes, edges } is not Mutation V3. Author { preconditions, operations } or a canonical astrale.graph.mutation/v3 document.',
|
|
10
10
|
)
|
|
11
11
|
}
|
|
12
|
+
if (hasEmptyOperations(input)) {
|
|
13
|
+
throw new TypeError('Mutation V3 requires at least one operation')
|
|
14
|
+
}
|
|
12
15
|
if (isCanonicalCandidate(input)) return MutationAST.decode(input)
|
|
13
16
|
return MutationAST.create(input as MutationInput)
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
function hasEmptyOperations(input: unknown): boolean {
|
|
20
|
+
return (
|
|
21
|
+
input !== null &&
|
|
22
|
+
typeof input === 'object' &&
|
|
23
|
+
!Array.isArray(input) &&
|
|
24
|
+
Array.isArray((input as { operations?: unknown }).operations) &&
|
|
25
|
+
(input as { operations: unknown[] }).operations.length === 0
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
function isCanonicalCandidate(input: unknown): boolean {
|
|
17
30
|
return (
|
|
18
31
|
input !== null &&
|
|
@@ -49,7 +49,7 @@ describe('identity registry journey', () => {
|
|
|
49
49
|
bobKey: boolean
|
|
50
50
|
}
|
|
51
51
|
expect(result.store.default).toBe('alice')
|
|
52
|
-
expect(Object.keys(result.store.identities).sort()).toEqual(['alice', '
|
|
52
|
+
expect(Object.keys(result.store.identities).sort()).toEqual(['alice', 'workos'])
|
|
53
53
|
expect(result.store.identities.alice.registrations?.production).toEqual({
|
|
54
54
|
iss: 'https://kernel.example',
|
|
55
55
|
sub: 'node-alice',
|
package/src/identity/registry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AstraleError } from '../errors'
|
|
1
2
|
import { persistKeypair, removeKeypair } from '../keys/index'
|
|
2
3
|
import { deleteIdpSession } from '../lib/idp'
|
|
3
4
|
import { validateName } from '../lib/validation'
|
|
@@ -40,8 +41,12 @@ export async function createIdentity(
|
|
|
40
41
|
kid: generated.kid,
|
|
41
42
|
issuer: options.issuer,
|
|
42
43
|
}
|
|
44
|
+
const hasDefault = store.default !== '' && store.identities[store.default] !== undefined
|
|
43
45
|
return {
|
|
44
|
-
next: {
|
|
46
|
+
next: {
|
|
47
|
+
default: hasDefault ? store.default : name,
|
|
48
|
+
identities: { ...store.identities, [name]: identity },
|
|
49
|
+
},
|
|
45
50
|
value: identity,
|
|
46
51
|
}
|
|
47
52
|
})
|
|
@@ -83,12 +88,14 @@ export async function setDefault(name: string): Promise<void> {
|
|
|
83
88
|
|
|
84
89
|
export async function getDefault(): Promise<Identity & { readonly name: string }> {
|
|
85
90
|
const store = await readIdentities()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
if (store.default === '' || store.identities[store.default] === undefined) {
|
|
92
|
+
throw new AstraleError(
|
|
93
|
+
'NO_IDENTITY',
|
|
94
|
+
'No default identity.',
|
|
95
|
+
'Run: astrale identity create <name>',
|
|
90
96
|
)
|
|
91
97
|
}
|
|
98
|
+
const identity = store.identities[store.default]!
|
|
92
99
|
return { ...identity, name: store.default }
|
|
93
100
|
}
|
|
94
101
|
|
|
@@ -11,6 +11,20 @@ import { collectCommandCatalog, renderCommanderError } from '../command-dx'
|
|
|
11
11
|
const ANSI_RE = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g')
|
|
12
12
|
const stripAnsi = (s: string): string => s.replace(ANSI_RE, '')
|
|
13
13
|
|
|
14
|
+
function dx(
|
|
15
|
+
program: Awaited<ReturnType<typeof buildProgram>>,
|
|
16
|
+
error: CommanderError,
|
|
17
|
+
argv: string[],
|
|
18
|
+
) {
|
|
19
|
+
const rendered = JSON.parse(stripAnsi(renderCommanderError(program, error, argv))) as {
|
|
20
|
+
error: string
|
|
21
|
+
message: string
|
|
22
|
+
detail: string
|
|
23
|
+
}
|
|
24
|
+
expect(rendered.error).toBe('USAGE_ERROR')
|
|
25
|
+
return rendered
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
describe('command DX suggestions', () => {
|
|
15
29
|
test('collects command paths from the registered program tree', async () => {
|
|
16
30
|
const program = await buildProgram()
|
|
@@ -24,6 +38,7 @@ describe('command DX suggestions', () => {
|
|
|
24
38
|
expect(usages).toContain('update')
|
|
25
39
|
expect(usages).toContain('query [sources...]')
|
|
26
40
|
expect(usages).toContain('get <target>')
|
|
41
|
+
expect(usages).toContain('introspect <target>')
|
|
27
42
|
expect(usages).toContain('status')
|
|
28
43
|
expect(usages).not.toContain('ls <source>')
|
|
29
44
|
expect(usages).not.toContain('describe <target>')
|
|
@@ -36,13 +51,13 @@ describe('command DX suggestions', () => {
|
|
|
36
51
|
'commander.excessArguments',
|
|
37
52
|
'error: too many arguments. Expected 0 arguments but got 2.',
|
|
38
53
|
)
|
|
39
|
-
const rendered =
|
|
54
|
+
const rendered = dx(program, error, ['delete', 'demo-system'])
|
|
40
55
|
|
|
41
|
-
expect(rendered).toContain('Unknown command: astrale delete demo-system')
|
|
42
|
-
expect(rendered).toContain('"delete" is available under:')
|
|
43
|
-
expect(rendered).toContain('astrale identity delete <name>')
|
|
44
|
-
expect(rendered).toContain('astrale instance delete <id>')
|
|
45
|
-
expect(rendered).not.toContain('too many arguments')
|
|
56
|
+
expect(rendered.message).toContain('Unknown command: astrale delete demo-system')
|
|
57
|
+
expect(rendered.detail).toContain('"delete" is available under:')
|
|
58
|
+
expect(rendered.detail).toContain('astrale identity delete <name>')
|
|
59
|
+
expect(rendered.detail).toContain('astrale instance delete <id>')
|
|
60
|
+
expect(rendered.detail).not.toContain('too many arguments')
|
|
46
61
|
})
|
|
47
62
|
|
|
48
63
|
test('points retired describe at get', async () => {
|
|
@@ -52,23 +67,23 @@ describe('command DX suggestions', () => {
|
|
|
52
67
|
'commander.unknownCommand',
|
|
53
68
|
"error: unknown command 'describe'",
|
|
54
69
|
)
|
|
55
|
-
const rendered =
|
|
70
|
+
const rendered = dx(program, error, ['describe', '@note'])
|
|
56
71
|
|
|
57
|
-
expect(rendered).toContain('Unknown command: astrale describe @note')
|
|
58
|
-
expect(rendered).toContain('astrale describe` was removed')
|
|
59
|
-
expect(rendered).toContain('astrale get <target>')
|
|
60
|
-
expect(rendered).not.toContain('Did you mean:')
|
|
72
|
+
expect(rendered.message).toContain('Unknown command: astrale describe @note')
|
|
73
|
+
expect(rendered.detail).toContain('astrale describe` was removed')
|
|
74
|
+
expect(rendered.detail).toContain('astrale get <target>')
|
|
75
|
+
expect(rendered.detail).not.toContain('Did you mean:')
|
|
61
76
|
})
|
|
62
77
|
|
|
63
78
|
test('points retired ls at query', async () => {
|
|
64
79
|
const program = await buildProgram()
|
|
65
80
|
const error = new CommanderError(1, 'commander.unknownCommand', "error: unknown command 'ls'")
|
|
66
|
-
const rendered =
|
|
81
|
+
const rendered = dx(program, error, ['ls', '@note'])
|
|
67
82
|
|
|
68
|
-
expect(rendered).toContain('Unknown command: astrale ls @note')
|
|
69
|
-
expect(rendered).toContain('astrale ls` was removed')
|
|
70
|
-
expect(rendered).toContain('astrale query <source> --edge <class>')
|
|
71
|
-
expect(rendered).not.toContain('Did you mean:')
|
|
83
|
+
expect(rendered.message).toContain('Unknown command: astrale ls @note')
|
|
84
|
+
expect(rendered.detail).toContain('astrale ls` was removed')
|
|
85
|
+
expect(rendered.detail).toContain('astrale query <source> --edge <class>')
|
|
86
|
+
expect(rendered.detail).not.toContain('Did you mean:')
|
|
72
87
|
})
|
|
73
88
|
|
|
74
89
|
test('suggests nearest command for typo paths', async () => {
|
|
@@ -78,10 +93,10 @@ describe('command DX suggestions', () => {
|
|
|
78
93
|
'commander.excessArguments',
|
|
79
94
|
'error: too many arguments. Expected 0 arguments but got 2.',
|
|
80
95
|
)
|
|
81
|
-
const rendered =
|
|
96
|
+
const rendered = dx(program, error, ['indetityl', 'ist'])
|
|
82
97
|
|
|
83
|
-
expect(rendered).toContain('Unknown command: astrale indetityl ist')
|
|
84
|
-
expect(rendered).toContain('Did you mean:')
|
|
85
|
-
expect(rendered).toContain('astrale identity list')
|
|
98
|
+
expect(rendered.message).toContain('Unknown command: astrale indetityl ist')
|
|
99
|
+
expect(rendered.detail).toContain('Did you mean:')
|
|
100
|
+
expect(rendered.detail).toContain('astrale identity list')
|
|
86
101
|
})
|
|
87
102
|
})
|
|
@@ -225,4 +225,21 @@ describe('isManagedInstanceNotFound: kernel InternalKernelError wrap', () => {
|
|
|
225
225
|
expect(caught).toBeInstanceOf(AstraleError)
|
|
226
226
|
expect((caught as AstraleError).code).toBe('INSTANCE_NOT_FOUND')
|
|
227
227
|
})
|
|
228
|
+
|
|
229
|
+
test('maps Admin token-exchange failure to INSTANCE_NOT_FOUND', async () => {
|
|
230
|
+
const managed = async () => {
|
|
231
|
+
throw new AstraleError(
|
|
232
|
+
'TOKEN_EXCHANGE_SOURCE_INVALID',
|
|
233
|
+
'The source identity credential has no valid expiration.',
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
const opts = {
|
|
237
|
+
config: DEFAULT_CONFIG,
|
|
238
|
+
instances: { instances: {} },
|
|
239
|
+
managed,
|
|
240
|
+
} as unknown as Parameters<typeof resolveInstanceTarget>[1]
|
|
241
|
+
await expect(
|
|
242
|
+
resolveInstanceTarget({ source: 'name', name: 'ghost' }, opts),
|
|
243
|
+
).rejects.toMatchObject({ code: 'INSTANCE_NOT_FOUND' })
|
|
244
|
+
})
|
|
228
245
|
})
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
findBookmarkTrustConflicts,
|
|
5
|
+
InstanceStoreSchema,
|
|
6
|
+
normalizeInstanceKernelUrl,
|
|
7
|
+
sanitizeStore,
|
|
8
|
+
} from '../instance'
|
|
4
9
|
|
|
5
10
|
describe('InstanceStoreSchema', () => {
|
|
6
11
|
test('parses valid store with url', () => {
|
|
@@ -134,3 +139,48 @@ describe('sanitizeStore — read must not rewrite', () => {
|
|
|
134
139
|
expect(changed).toBe(false)
|
|
135
140
|
})
|
|
136
141
|
})
|
|
142
|
+
|
|
143
|
+
describe('bookmark TLS trust collisions', () => {
|
|
144
|
+
test('finds the same normalized URL with a different CA configuration', () => {
|
|
145
|
+
const store = InstanceStoreSchema.parse({
|
|
146
|
+
active: 'stable',
|
|
147
|
+
instances: {
|
|
148
|
+
stable: {
|
|
149
|
+
url: 'https://local.example/kernel/',
|
|
150
|
+
caFile: '/certs/stable.pem',
|
|
151
|
+
},
|
|
152
|
+
alias: {
|
|
153
|
+
url: 'https://local.example/kernel',
|
|
154
|
+
caFile: '/certs/old.pem',
|
|
155
|
+
},
|
|
156
|
+
other: {
|
|
157
|
+
url: 'https://other.example/kernel',
|
|
158
|
+
caFile: '/certs/old.pem',
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
expect(
|
|
164
|
+
findBookmarkTrustConflicts(
|
|
165
|
+
store,
|
|
166
|
+
'stable',
|
|
167
|
+
'https://local.example/kernel',
|
|
168
|
+
'/certs/stable.pem',
|
|
169
|
+
),
|
|
170
|
+
).toEqual([{ name: 'alias', caFile: '/certs/old.pem' }])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('treats custom CA versus system trust as a meaningful difference', () => {
|
|
174
|
+
const store = InstanceStoreSchema.parse({
|
|
175
|
+
active: 'custom',
|
|
176
|
+
instances: {
|
|
177
|
+
custom: { url: 'https://local.example', caFile: '/certs/local.pem' },
|
|
178
|
+
system: { url: 'https://local.example' },
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
expect(
|
|
183
|
+
findBookmarkTrustConflicts(store, 'custom', 'https://local.example', '/certs/local.pem'),
|
|
184
|
+
).toEqual([{ name: 'system', caFile: null }])
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -112,6 +112,18 @@ describe('denoise', () => {
|
|
|
112
112
|
})
|
|
113
113
|
expect(out).toEqual({ id: 'x', props: { name: 'n' } })
|
|
114
114
|
})
|
|
115
|
+
|
|
116
|
+
test('strips qualified property keys whose leaf is schema', () => {
|
|
117
|
+
const out = denoise({
|
|
118
|
+
props: {
|
|
119
|
+
'kernel.astrale.ai:class.Domain.property.schema': { huge: true },
|
|
120
|
+
'kernel.astrale.ai:class.Domain.property.origin': 'kernel.astrale.ai',
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
expect(out).toEqual({
|
|
124
|
+
props: { 'kernel.astrale.ai:class.Domain.property.origin': 'kernel.astrale.ai' },
|
|
125
|
+
})
|
|
126
|
+
})
|
|
115
127
|
})
|
|
116
128
|
|
|
117
129
|
describe('presentList', () => {
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
mkdtemp,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
realpath,
|
|
7
|
+
rm,
|
|
8
|
+
symlink,
|
|
9
|
+
utimes,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from 'node:fs/promises'
|
|
3
12
|
import { tmpdir } from 'node:os'
|
|
4
13
|
import { dirname, join, relative } from 'node:path'
|
|
5
14
|
import { pathToFileURL } from 'node:url'
|
|
@@ -61,6 +70,29 @@ describe('viewer asset resolution', () => {
|
|
|
61
70
|
expect(await readFile(join(dist, 'main.js'), 'utf8')).toContain('viewer ready')
|
|
62
71
|
})
|
|
63
72
|
|
|
73
|
+
test('rebuilds stale viewer assets in a source checkout', async () => {
|
|
74
|
+
const root = await mkdtemp(join(tmpdir(), 'astrale-view-stale-'))
|
|
75
|
+
temporaryDirectories.push(root)
|
|
76
|
+
const module = join(root, 'src', 'lib', 'view', 'assets.ts')
|
|
77
|
+
const source = join(root, 'viewer')
|
|
78
|
+
const dist = join(source, 'dist')
|
|
79
|
+
|
|
80
|
+
await mkdir(dirname(module), { recursive: true })
|
|
81
|
+
await mkdir(dist, { recursive: true })
|
|
82
|
+
await writeFile(module, '')
|
|
83
|
+
await writeFile(join(source, 'main.ts'), 'document.body.textContent = "fresh viewer"\n')
|
|
84
|
+
await writeFile(join(source, 'index.html'), '<!doctype html><body>fresh</body>\n')
|
|
85
|
+
await writeFile(join(dist, 'main.js'), 'document.body.textContent = "stale viewer"\n')
|
|
86
|
+
await writeFile(join(dist, 'index.html'), '<!doctype html><body>stale</body>\n')
|
|
87
|
+
const future = new Date(Date.now() + 2_000)
|
|
88
|
+
await utimes(join(source, 'main.ts'), future, future)
|
|
89
|
+
|
|
90
|
+
await ensureViewerAssets(pathToFileURL(module).href, join(root, 'bin', 'astrale'))
|
|
91
|
+
|
|
92
|
+
expect(await readFile(join(dist, 'main.js'), 'utf8')).toContain('fresh viewer')
|
|
93
|
+
expect(await readFile(join(dist, 'index.html'), 'utf8')).toContain('fresh')
|
|
94
|
+
})
|
|
95
|
+
|
|
64
96
|
test('uses the bundled module location when invoked through a global bin symlink', async () => {
|
|
65
97
|
const root = await mkdtemp(join(tmpdir(), 'astrale-view-bundle-'))
|
|
66
98
|
temporaryDirectories.push(root)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { once } from 'node:events'
|
|
3
|
+
|
|
4
|
+
import type { ViewServeConfig } from '../view/session'
|
|
5
|
+
|
|
6
|
+
import { findFreePort } from '../port'
|
|
7
|
+
import { startViewServer } from '../view/server'
|
|
8
|
+
|
|
9
|
+
const digest = (character: string) => `sha256:${character.repeat(64)}` as const
|
|
10
|
+
const target = (value: string) => value as ViewServeConfig['session']['view']['target']
|
|
11
|
+
const issuer = (value: string) => value as ViewServeConfig['session']['view']['route']['issuer']
|
|
12
|
+
const revision = (character: string) =>
|
|
13
|
+
digest(character) as ViewServeConfig['session']['view']['route']['revision']
|
|
14
|
+
|
|
15
|
+
describe('view session server credentials', () => {
|
|
16
|
+
/** @evidence TEST-CLI-PLAIN-VIEW-RECEIVES-NO-CREDENTIAL */
|
|
17
|
+
test('refuses to mint a token for a handshake-none View', async () => {
|
|
18
|
+
const nonce = 'plain-view'
|
|
19
|
+
const port = await findFreePort(48_000, 200)
|
|
20
|
+
if (port === null) throw new Error('test port window exhausted')
|
|
21
|
+
const config = {
|
|
22
|
+
session: {
|
|
23
|
+
id: 'v-plain',
|
|
24
|
+
pid: 0,
|
|
25
|
+
port,
|
|
26
|
+
nonce,
|
|
27
|
+
pageUrl: `http://127.0.0.1:${port}/`,
|
|
28
|
+
view: {
|
|
29
|
+
target: target('/:example.test'),
|
|
30
|
+
route: {
|
|
31
|
+
key: 'example.test:view.public',
|
|
32
|
+
declaration: { target: { kind: 'domain' }, auth: 'required' },
|
|
33
|
+
href: 'https://example.test/ui/public',
|
|
34
|
+
handshake: 'none',
|
|
35
|
+
issuer: issuer('https://example.test'),
|
|
36
|
+
etag: digest('a'),
|
|
37
|
+
revision: revision('b'),
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
createdAt: '2026-08-20T00:00:00.000Z',
|
|
41
|
+
},
|
|
42
|
+
kernel: { creds: 'must-not-be-used' },
|
|
43
|
+
proxy: {
|
|
44
|
+
kernelUrl: 'https://kernel.test',
|
|
45
|
+
issuer: 'https://kernel.test',
|
|
46
|
+
direct: true,
|
|
47
|
+
},
|
|
48
|
+
idleMs: 60_000,
|
|
49
|
+
} satisfies ViewServeConfig
|
|
50
|
+
const server = startViewServer(config)
|
|
51
|
+
await once(server, 'listening')
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch(`http://127.0.0.1:${port}/s/${nonce}/token`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(response.status).toBe(403)
|
|
59
|
+
expect(await response.json()).toEqual({
|
|
60
|
+
error: 'plain views have no Astrale credential privilege',
|
|
61
|
+
})
|
|
62
|
+
} finally {
|
|
63
|
+
await new Promise<void>((resolve, reject) => {
|
|
64
|
+
server.close((error) => (error ? reject(error) : resolve()))
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
package/src/lib/ca-fetch.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { IncomingHttpHeaders, request as httpRequest } from 'node:http'
|
|
|
3
3
|
import { Buffer } from 'node:buffer'
|
|
4
4
|
import { readFileSync } from 'node:fs'
|
|
5
5
|
import { request as httpsRequest } from 'node:https'
|
|
6
|
+
import { rootCertificates } from 'node:tls'
|
|
6
7
|
|
|
7
8
|
/** Create a Fetch capability whose HTTPS requests trust one CLI-selected CA file. */
|
|
8
9
|
export function fetchWithCaFile(
|
|
@@ -32,7 +33,9 @@ function fetchWithNode(url: URL, init: RequestInit | undefined, ca: Buffer): Pro
|
|
|
32
33
|
{
|
|
33
34
|
method: init?.method ?? 'GET',
|
|
34
35
|
headers: headersInitToRecord(init?.headers),
|
|
35
|
-
ca
|
|
36
|
+
// `ca` replaces Node's default trust set. Retain public roots because one
|
|
37
|
+
// Client Session may reach both a private Kernel and a public Domain issuer.
|
|
38
|
+
ca: [...rootCertificates, ca],
|
|
36
39
|
},
|
|
37
40
|
(response) => {
|
|
38
41
|
const chunks: Buffer[] = []
|
|
@@ -40,8 +43,11 @@ function fetchWithNode(url: URL, init: RequestInit | undefined, ca: Buffer): Pro
|
|
|
40
43
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)),
|
|
41
44
|
)
|
|
42
45
|
response.on('end', () => {
|
|
43
|
-
const
|
|
44
|
-
|
|
46
|
+
const status = response.statusCode ?? 0
|
|
47
|
+
const body =
|
|
48
|
+
status === 204 || status === 205 || status === 304 ? null : Buffer.concat(chunks)
|
|
49
|
+
const result = new Response(body, {
|
|
50
|
+
status,
|
|
45
51
|
statusText: response.statusMessage,
|
|
46
52
|
headers: responseHeaders(response.headers),
|
|
47
53
|
})
|