@astrale-os/cli 0.8.1-alpha.4 → 0.8.1-alpha.6
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 +1 -1
- package/dist/astrale.js +827 -575
- package/dist/public/connect-core.js +22 -16
- package/dist/public/keys/index.js +3 -10
- package/dist/public/paths/index.js +3 -10
- package/dist/types/lib/instance-target.d.ts +2 -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 +1 -1
- 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/auth/token.ts +19 -7
- package/src/commands/call.ts +18 -43
- 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/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 +5 -3
- package/src/connection/__tests__/credential.test.ts +10 -0
- package/src/connection/__tests__/errors.test.ts +20 -0
- package/src/connection/credential.ts +3 -0
- package/src/connection/errors.ts +74 -12
- 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 +47 -15
- package/src/lib/__tests__/instance-target.test.ts +17 -0
- package/src/lib/__tests__/output.test.ts +12 -0
- package/src/lib/command-dx.ts +41 -23
- package/src/lib/instance-target.ts +18 -2
- package/src/lib/invocation.ts +11 -0
- package/src/lib/log.ts +17 -9
- package/src/lib/output.ts +20 -4
- package/src/program/__tests__/program.test.ts +4 -2
- package/src/program/build.ts +3 -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/src/commands/describe.ts +0 -86
package/src/connection/errors.ts
CHANGED
|
@@ -156,7 +156,7 @@ export async function formatKernelError(
|
|
|
156
156
|
// Server often sends details in message but empty errors array
|
|
157
157
|
console.log(chalk.red(` ${error.message}`))
|
|
158
158
|
}
|
|
159
|
-
log.dim(' Use `astrale
|
|
159
|
+
log.dim(' Use `astrale introspect <path>` to see the expected schema')
|
|
160
160
|
}
|
|
161
161
|
break
|
|
162
162
|
}
|
|
@@ -177,24 +177,30 @@ export async function formatKernelError(
|
|
|
177
177
|
case 'ResponseError': {
|
|
178
178
|
const code = (error as { readonly code?: unknown }).code
|
|
179
179
|
const reason = (error as { readonly reason?: unknown }).reason
|
|
180
|
+
const reasonCode =
|
|
181
|
+
reason !== null &&
|
|
182
|
+
typeof reason === 'object' &&
|
|
183
|
+
typeof (reason as { readonly code?: unknown }).code === 'string'
|
|
184
|
+
? (reason as { readonly code: string }).code
|
|
185
|
+
: undefined
|
|
186
|
+
const hint =
|
|
187
|
+
reasonCode === 'FUNCTION_INPUT_INVALID'
|
|
188
|
+
? 'Use `astrale introspect <path>` to see the callable input.'
|
|
189
|
+
: undefined
|
|
180
190
|
if (isRaw) {
|
|
181
191
|
writeRaw({
|
|
182
192
|
error: 'RESPONSE_ERROR',
|
|
183
193
|
...(code === undefined ? {} : { code }),
|
|
184
194
|
message: error.message,
|
|
185
195
|
...(reason === undefined ? {} : { reason }),
|
|
196
|
+
...(hint === undefined ? {} : { hint }),
|
|
186
197
|
})
|
|
187
198
|
} else {
|
|
188
199
|
log.error(
|
|
189
200
|
`${chalk.bold(code === undefined ? 'RESPONSE_ERROR' : `RESPONSE_ERROR(${String(code)})`)}: ${error.message}`,
|
|
190
201
|
)
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
typeof reason === 'object' &&
|
|
194
|
-
typeof (reason as { readonly code?: unknown }).code === 'string'
|
|
195
|
-
) {
|
|
196
|
-
log.dim(` reason: ${(reason as { readonly code: string }).code}`)
|
|
197
|
-
}
|
|
202
|
+
if (reasonCode !== undefined) log.dim(` reason: ${reasonCode}`)
|
|
203
|
+
if (hint !== undefined) log.dim(` ${hint}`)
|
|
198
204
|
}
|
|
199
205
|
break
|
|
200
206
|
}
|
|
@@ -207,10 +213,20 @@ export async function formatKernelError(
|
|
|
207
213
|
break
|
|
208
214
|
}
|
|
209
215
|
|
|
210
|
-
default:
|
|
211
|
-
|
|
212
|
-
if (isRaw)
|
|
213
|
-
|
|
216
|
+
default: {
|
|
217
|
+
const mapped = mapPublicError(error)
|
|
218
|
+
if (isRaw) {
|
|
219
|
+
writeRaw({
|
|
220
|
+
error: mapped.code,
|
|
221
|
+
message: mapped.message,
|
|
222
|
+
...(mapped.hint === undefined ? {} : { hint: mapped.hint }),
|
|
223
|
+
...(mapped.timeoutMs === undefined ? {} : { timeoutMs: mapped.timeoutMs }),
|
|
224
|
+
})
|
|
225
|
+
} else {
|
|
226
|
+
log.error(`${chalk.bold(mapped.code)}: ${mapped.message}`)
|
|
227
|
+
if (mapped.hint) log.dim(` ${mapped.hint}`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
214
230
|
}
|
|
215
231
|
|
|
216
232
|
if (debug) printDebug(error, url)
|
|
@@ -221,6 +237,52 @@ export function stripMethodSuffix(msg: string): string {
|
|
|
221
237
|
return msg.replace(/(\/[^"\s:]+)::([a-zA-Z]\w*)/g, '$1')
|
|
222
238
|
}
|
|
223
239
|
|
|
240
|
+
function mapPublicError(error: Error): {
|
|
241
|
+
code: string
|
|
242
|
+
message: string
|
|
243
|
+
hint?: string
|
|
244
|
+
timeoutMs?: number
|
|
245
|
+
} {
|
|
246
|
+
const name = error.name
|
|
247
|
+
if (name === 'PathError') {
|
|
248
|
+
return { code: 'PATH_INVALID', message: error.message }
|
|
249
|
+
}
|
|
250
|
+
if (name === 'NodeUnavailableError') {
|
|
251
|
+
return {
|
|
252
|
+
code: 'NODE_UNAVAILABLE',
|
|
253
|
+
message: error.message,
|
|
254
|
+
hint: 'If this is a callable Path, use `astrale call` or `astrale introspect`.',
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (name === 'AuthValueError') {
|
|
258
|
+
return { code: 'AUTH_VALUE_INVALID', message: error.message }
|
|
259
|
+
}
|
|
260
|
+
if (name === 'ClientError' && /timed out/i.test(error.message)) {
|
|
261
|
+
const timeoutMs = (error as { timeoutMs?: number }).timeoutMs
|
|
262
|
+
return {
|
|
263
|
+
code: 'TIMEOUT',
|
|
264
|
+
message: error.message,
|
|
265
|
+
hint: 'Try increasing with --timeout',
|
|
266
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (name === 'ClientError' && /Publication discovery returned HTTP/i.test(error.message)) {
|
|
270
|
+
return {
|
|
271
|
+
code: 'KERNEL_DISCOVERY_FAILED',
|
|
272
|
+
message: error.message,
|
|
273
|
+
hint: 'Pass the Kernel issuer URL (no /invoke suffix), e.g. https://host/kernel/host',
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (name === 'Error' && /unable to connect/i.test(error.message)) {
|
|
277
|
+
return {
|
|
278
|
+
code: 'CONNECTION_ERROR',
|
|
279
|
+
message: error.message,
|
|
280
|
+
hint: 'Check --url / -i and that the Kernel is reachable. Try: astrale status',
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { code: name && name !== 'Error' ? name : 'UNKNOWN', message: error.message }
|
|
284
|
+
}
|
|
285
|
+
|
|
224
286
|
function writeRaw(payload: Record<string, unknown>): void {
|
|
225
287
|
process.stderr.write(JSON.stringify(payload) + '\n')
|
|
226
288
|
}
|
|
@@ -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()
|
|
@@ -23,8 +37,11 @@ describe('command DX suggestions', () => {
|
|
|
23
37
|
expect(usages).toContain('use <name>')
|
|
24
38
|
expect(usages).toContain('update')
|
|
25
39
|
expect(usages).toContain('query [sources...]')
|
|
40
|
+
expect(usages).toContain('get <target>')
|
|
41
|
+
expect(usages).toContain('introspect <target>')
|
|
26
42
|
expect(usages).toContain('status')
|
|
27
43
|
expect(usages).not.toContain('ls <source>')
|
|
44
|
+
expect(usages).not.toContain('describe <target>')
|
|
28
45
|
})
|
|
29
46
|
|
|
30
47
|
test('explains shared verbs as namespaced commands, not arity errors', async () => {
|
|
@@ -34,24 +51,39 @@ describe('command DX suggestions', () => {
|
|
|
34
51
|
'commander.excessArguments',
|
|
35
52
|
'error: too many arguments. Expected 0 arguments but got 2.',
|
|
36
53
|
)
|
|
37
|
-
const rendered =
|
|
54
|
+
const rendered = dx(program, error, ['delete', 'demo-system'])
|
|
55
|
+
|
|
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')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('points retired describe at get', async () => {
|
|
64
|
+
const program = await buildProgram()
|
|
65
|
+
const error = new CommanderError(
|
|
66
|
+
1,
|
|
67
|
+
'commander.unknownCommand',
|
|
68
|
+
"error: unknown command 'describe'",
|
|
69
|
+
)
|
|
70
|
+
const rendered = dx(program, error, ['describe', '@note'])
|
|
38
71
|
|
|
39
|
-
expect(rendered).toContain('Unknown command: astrale
|
|
40
|
-
expect(rendered).toContain('
|
|
41
|
-
expect(rendered).toContain('astrale
|
|
42
|
-
expect(rendered).toContain('
|
|
43
|
-
expect(rendered).not.toContain('too many arguments')
|
|
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:')
|
|
44
76
|
})
|
|
45
77
|
|
|
46
78
|
test('points retired ls at query', async () => {
|
|
47
79
|
const program = await buildProgram()
|
|
48
80
|
const error = new CommanderError(1, 'commander.unknownCommand', "error: unknown command 'ls'")
|
|
49
|
-
const rendered =
|
|
81
|
+
const rendered = dx(program, error, ['ls', '@note'])
|
|
50
82
|
|
|
51
|
-
expect(rendered).toContain('Unknown command: astrale ls @note')
|
|
52
|
-
expect(rendered).toContain('astrale ls` was removed')
|
|
53
|
-
expect(rendered).toContain('astrale query <source> --edge <class>')
|
|
54
|
-
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:')
|
|
55
87
|
})
|
|
56
88
|
|
|
57
89
|
test('suggests nearest command for typo paths', async () => {
|
|
@@ -61,10 +93,10 @@ describe('command DX suggestions', () => {
|
|
|
61
93
|
'commander.excessArguments',
|
|
62
94
|
'error: too many arguments. Expected 0 arguments but got 2.',
|
|
63
95
|
)
|
|
64
|
-
const rendered =
|
|
96
|
+
const rendered = dx(program, error, ['indetityl', 'ist'])
|
|
65
97
|
|
|
66
|
-
expect(rendered).toContain('Unknown command: astrale indetityl ist')
|
|
67
|
-
expect(rendered).toContain('Did you mean:')
|
|
68
|
-
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')
|
|
69
101
|
})
|
|
70
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
|
})
|
|
@@ -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', () => {
|
package/src/lib/command-dx.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { Command, CommanderError } from 'commander'
|
|
|
2
2
|
|
|
3
3
|
import chalk from 'chalk'
|
|
4
4
|
|
|
5
|
+
import { isMachine } from './output'
|
|
6
|
+
|
|
5
7
|
export type CommandCatalogEntry = {
|
|
6
8
|
path: string[]
|
|
7
9
|
usage: string
|
|
@@ -34,46 +36,53 @@ export function renderCommanderError(
|
|
|
34
36
|
const matched = matchRegisteredPrefix(program, tokens)
|
|
35
37
|
|
|
36
38
|
if (matched.path.length === 0 && tokens.length > 0) {
|
|
37
|
-
return renderUnknownCommand(tokens, catalog)
|
|
39
|
+
return maybeMachine(renderUnknownCommand(tokens, catalog))
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
if (error.code === 'commander.missingArgument') {
|
|
41
43
|
const usage = usageFor(matched.path, matched.command)
|
|
42
44
|
const argName = error.message.match(/'([^']+)'/)?.[1]
|
|
43
|
-
return
|
|
44
|
-
|
|
45
|
-
`
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
return maybeMachine(
|
|
46
|
+
[
|
|
47
|
+
`Missing required argument${argName ? ` ${chalk.bold(`<${argName}>`)}` : ''} for ${chalk.bold(
|
|
48
|
+
`astrale ${matched.path.join(' ')}`,
|
|
49
|
+
)}`,
|
|
50
|
+
'',
|
|
51
|
+
'Usage:',
|
|
52
|
+
` astrale ${usage}`,
|
|
53
|
+
].join('\n'),
|
|
54
|
+
)
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
if (error.code === 'commander.excessArguments') {
|
|
54
58
|
const usage = usageFor(matched.path, matched.command)
|
|
55
59
|
const extra = tokens.slice(matched.path.length).join(' ')
|
|
56
|
-
return
|
|
57
|
-
|
|
58
|
-
`
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
return maybeMachine(
|
|
61
|
+
[
|
|
62
|
+
`Unexpected argument${extra.includes(' ') ? 's' : ''} for ${chalk.bold(
|
|
63
|
+
`astrale ${matched.path.join(' ')}`,
|
|
64
|
+
)}${extra ? `: ${extra}` : ''}`,
|
|
65
|
+
'',
|
|
66
|
+
'Usage:',
|
|
67
|
+
` astrale ${usage}`,
|
|
68
|
+
].join('\n'),
|
|
69
|
+
)
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
const suggestions = nearestCommands(tokens.join(' '), catalog)
|
|
67
|
-
return
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
return maybeMachine(
|
|
74
|
+
[
|
|
75
|
+
error.message,
|
|
76
|
+
...(suggestions.length > 0
|
|
77
|
+
? ['', 'Did you mean:', ...suggestions.map((s) => ` astrale ${s}`)]
|
|
78
|
+
: []),
|
|
79
|
+
].join('\n'),
|
|
80
|
+
)
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
const RETIRED_COMMANDS: Record<string, string> = {
|
|
76
84
|
ls: 'astrale query <source> --edge <class>',
|
|
85
|
+
describe: 'astrale get <target>',
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
function renderUnknownCommand(tokens: string[], catalog: CommandCatalogEntry[]): string {
|
|
@@ -132,6 +141,15 @@ function usageFor(path: string[], command: Command): string {
|
|
|
132
141
|
return [path.join(' '), suffix].filter(Boolean).join(' ')
|
|
133
142
|
}
|
|
134
143
|
|
|
144
|
+
const ANSI_RE = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g')
|
|
145
|
+
|
|
146
|
+
function maybeMachine(text: string): string {
|
|
147
|
+
if (!isMachine()) return text
|
|
148
|
+
const plain = text.replace(ANSI_RE, '')
|
|
149
|
+
const first = plain.split('\n').find((line) => line.trim().length > 0) ?? plain
|
|
150
|
+
return JSON.stringify({ error: 'USAGE_ERROR', message: first, detail: plain })
|
|
151
|
+
}
|
|
152
|
+
|
|
135
153
|
function stripOptions(argv: string[]): string[] {
|
|
136
154
|
const out: string[] = []
|
|
137
155
|
for (const token of argv) {
|
|
@@ -94,8 +94,8 @@ async function resolveNamedInstanceTarget(
|
|
|
94
94
|
try {
|
|
95
95
|
managed = await opts.managed(identifier)
|
|
96
96
|
} catch (e) {
|
|
97
|
-
if (
|
|
98
|
-
throw
|
|
97
|
+
if (isManagedInstanceNotFound(e) || isAdminDiscoveryFailure(e)) throw notFound
|
|
98
|
+
throw e
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
const url = normalizeInstanceKernelUrl(managed.url)
|
|
@@ -173,6 +173,22 @@ export function adminTargetToInstance(target: ResolvedAdminTarget): ResolvedInst
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
const ADMIN_DISCOVERY_CODES = new Set([
|
|
177
|
+
'TOKEN_EXCHANGE_SOURCE_INVALID',
|
|
178
|
+
'TOKEN_EXCHANGE_SOURCE_EXPIRED',
|
|
179
|
+
'TOKEN_EXCHANGE_UNSUPPORTED',
|
|
180
|
+
'TOKEN_EXCHANGE_DISCOVERY_FAILED',
|
|
181
|
+
'TOKEN_EXCHANGE_PROTOCOL_ERROR',
|
|
182
|
+
'TOKEN_EXCHANGE_INSECURE',
|
|
183
|
+
'ADMIN_DOMAIN_ISSUER_MISSING',
|
|
184
|
+
'ADMIN_INVENTORY_UNAVAILABLE',
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
/** Admin lookup failed before it could say whether the slug exists. */
|
|
188
|
+
export function isAdminDiscoveryFailure(error: unknown): boolean {
|
|
189
|
+
return error instanceof AstraleError && ADMIN_DISCOVERY_CODES.has(error.code)
|
|
190
|
+
}
|
|
191
|
+
|
|
176
192
|
export function isManagedInstanceNotFound(error: unknown): boolean {
|
|
177
193
|
if (error instanceof AstraleError && error.code === 'INSTANCE_NOT_FOUND') return true
|
|
178
194
|
if (!(error instanceof Error)) return false
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Process-wide machine mode from argv (`--json` / `--raw` / `--ci`). */
|
|
2
|
+
|
|
3
|
+
let argvMachine = false
|
|
4
|
+
|
|
5
|
+
export function configureInvocation(argv: readonly string[]): void {
|
|
6
|
+
argvMachine = argv.some((token) => token === '--json' || token === '--raw' || token === '--ci')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function invocationWantsMachine(): boolean {
|
|
10
|
+
return argvMachine
|
|
11
|
+
}
|
package/src/lib/log.ts
CHANGED
|
@@ -3,7 +3,7 @@ import ora, { type Ora } from 'ora'
|
|
|
3
3
|
|
|
4
4
|
import { AstraleError, NotImplementedError } from '../errors'
|
|
5
5
|
import { formatElapsed } from './format'
|
|
6
|
-
import { isMachine, type
|
|
6
|
+
import { isMachine, type MachineOpts } from './output'
|
|
7
7
|
|
|
8
8
|
export const log = {
|
|
9
9
|
info: (msg: string) => console.log(chalk.blue('ℹ'), msg),
|
|
@@ -16,26 +16,34 @@ export const log = {
|
|
|
16
16
|
dim: (msg: string) => console.log(chalk.dim(msg)),
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/** Report an error with hint (when present) and exit.
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
export function fatal(e: unknown, opts?: RawOutputOpts): never {
|
|
19
|
+
/** Report an error with hint (when present) and exit. `--json` / `--ci` / a
|
|
20
|
+
* non-TTY stdout always get one structured JSON line on stderr. */
|
|
21
|
+
export function fatal(e: unknown, opts?: MachineOpts): never {
|
|
23
22
|
// Ctrl-C at an interactive (@inquirer/prompts) prompt — exit quietly with the
|
|
24
23
|
// SIGINT convention, not a red error line.
|
|
25
24
|
if (e instanceof Error && e.name === 'ExitPromptError') process.exit(130)
|
|
26
25
|
const msg = e instanceof Error ? e.message : String(e)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const payload: Record<string, unknown> = { error, message: msg }
|
|
26
|
+
const code = e instanceof AstraleError ? e.code : e instanceof Error ? e.name : 'Error'
|
|
27
|
+
if (isMachine(opts)) {
|
|
28
|
+
const payload: Record<string, unknown> = { error: code, message: msg }
|
|
30
29
|
if (e instanceof AstraleError && e.hint) payload.hint = e.hint
|
|
31
30
|
process.stderr.write(JSON.stringify(payload) + '\n')
|
|
32
31
|
process.exit(1)
|
|
33
32
|
}
|
|
34
|
-
log.error(msg)
|
|
33
|
+
log.error(e instanceof AstraleError ? `${code}: ${msg}` : msg)
|
|
35
34
|
if (e instanceof AstraleError && e.hint) log.dim(` hint: ${e.hint}`)
|
|
36
35
|
process.exit(1)
|
|
37
36
|
}
|
|
38
37
|
|
|
38
|
+
/** Admit expected invalid input and exit through {@link fatal}. */
|
|
39
|
+
export function failClosed(error: unknown, opts?: MachineOpts): never {
|
|
40
|
+
if (error instanceof AstraleError) fatal(error, opts)
|
|
41
|
+
fatal(
|
|
42
|
+
new AstraleError('INVALID_INPUT', error instanceof Error ? error.message : String(error)),
|
|
43
|
+
opts,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
/** Shortcut for stub commands that aren't wired in v1 (§15). */
|
|
40
48
|
export function fatalNotImplemented(feature: string, hint?: string): never {
|
|
41
49
|
fatal(new NotImplementedError(feature, hint))
|
package/src/lib/output.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk'
|
|
2
2
|
import { stringify as yamlStringify } from 'yaml'
|
|
3
3
|
|
|
4
|
+
import { invocationWantsMachine } from './invocation'
|
|
4
5
|
import { renderTable, type Column } from './table'
|
|
5
6
|
|
|
6
7
|
export type { Column } from './table'
|
|
@@ -13,6 +14,8 @@ export type OutputOpts = {
|
|
|
13
14
|
|
|
14
15
|
export type RawOutputOpts = Pick<OutputOpts, 'raw' | 'json'>
|
|
15
16
|
|
|
17
|
+
export type MachineOpts = RawOutputOpts & { readonly ci?: boolean }
|
|
18
|
+
|
|
16
19
|
export const RAW_OUTPUT_OPTIONS = [
|
|
17
20
|
{ flags: '--json', description: 'Always-valid JSON (for jq)' },
|
|
18
21
|
{ flags: '--raw', description: 'Unwrapped: bare scalar / raw bytes / JSON for objects' },
|
|
@@ -20,10 +23,15 @@ export const RAW_OUTPUT_OPTIONS = [
|
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* Is the consumer a machine (emit structured data, not a pretty view)?
|
|
23
|
-
* True for `--json`, `--raw`,
|
|
26
|
+
* True for `--json`, `--raw`, `--ci`, a process-wide `--ci/--json/--raw` on argv,
|
|
27
|
+
* or any non-TTY stdout (pipe, redirect, agent).
|
|
24
28
|
*/
|
|
25
|
-
export function isMachine(opts?:
|
|
26
|
-
return
|
|
29
|
+
export function isMachine(opts?: MachineOpts): boolean {
|
|
30
|
+
return (
|
|
31
|
+
!!(opts?.raw || opts?.json || opts?.ci) ||
|
|
32
|
+
invocationWantsMachine() ||
|
|
33
|
+
!(process.stdout.isTTY ?? false)
|
|
34
|
+
)
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
/**
|
|
@@ -132,6 +140,14 @@ export type ListOpts = OutputOpts & {
|
|
|
132
140
|
|
|
133
141
|
const NOISE_KEYS = new Set(['schema', 'icon', 'code', 'inputSchema', 'outputSchema'])
|
|
134
142
|
|
|
143
|
+
function isNoiseKey(key: string): boolean {
|
|
144
|
+
if (NOISE_KEYS.has(key)) return true
|
|
145
|
+
const dot = key.lastIndexOf('.')
|
|
146
|
+
const colon = key.lastIndexOf(':')
|
|
147
|
+
const split = Math.max(dot, colon)
|
|
148
|
+
return split >= 0 && NOISE_KEYS.has(key.slice(split + 1))
|
|
149
|
+
}
|
|
150
|
+
|
|
135
151
|
/**
|
|
136
152
|
* Strip heavy, low-signal keys (serialized schema blobs, SVG icons, code) at any
|
|
137
153
|
* depth — so machine output is the kernel's data minus the noise, never a wall.
|
|
@@ -141,7 +157,7 @@ export function denoise(value: unknown): unknown {
|
|
|
141
157
|
if (value && typeof value === 'object') {
|
|
142
158
|
const out: Record<string, unknown> = {}
|
|
143
159
|
for (const [k, v] of Object.entries(value)) {
|
|
144
|
-
if (
|
|
160
|
+
if (isNoiseKey(k)) continue
|
|
145
161
|
out[k] = v && typeof v === 'object' ? denoise(v) : v
|
|
146
162
|
}
|
|
147
163
|
return out
|