@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.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/dist/astrale.js +827 -575
  3. package/dist/public/connect-core.js +22 -16
  4. package/dist/public/keys/index.js +3 -10
  5. package/dist/public/paths/index.js +3 -10
  6. package/dist/types/lib/instance-target.d.ts +2 -0
  7. package/dist/types/lib/invocation.d.ts +3 -0
  8. package/dist/types/lib/log.d.ts +6 -5
  9. package/dist/types/lib/output.d.ts +6 -2
  10. package/package.json +1 -1
  11. package/src/commands/__tests__/introspect-parse.test.ts +21 -0
  12. package/src/commands/__tests__/logs.test.ts +5 -0
  13. package/src/commands/__tests__/read-commands.test.ts +11 -1
  14. package/src/commands/__tests__/token-ttl.test.ts +21 -0
  15. package/src/commands/auth/token.ts +19 -7
  16. package/src/commands/call.ts +18 -43
  17. package/src/commands/get.ts +23 -5
  18. package/src/commands/identity/create.ts +11 -2
  19. package/src/commands/identity/delete.ts +8 -2
  20. package/src/commands/identity/export.ts +8 -2
  21. package/src/commands/identity/import.ts +11 -2
  22. package/src/commands/identity/sync.ts +8 -2
  23. package/src/commands/identity/unsync.ts +12 -2
  24. package/src/commands/identity/use.ts +8 -2
  25. package/src/commands/identity/whoami.ts +1 -1
  26. package/src/commands/introspect.ts +117 -0
  27. package/src/commands/logs.ts +50 -6
  28. package/src/commands/mutate.ts +2 -3
  29. package/src/commands/query.ts +3 -5
  30. package/src/commands/token.ts +35 -8
  31. package/src/commands/update.ts +30 -9
  32. package/src/commands/view.ts +5 -3
  33. package/src/connection/__tests__/credential.test.ts +10 -0
  34. package/src/connection/__tests__/errors.test.ts +20 -0
  35. package/src/connection/credential.ts +3 -0
  36. package/src/connection/errors.ts +74 -12
  37. package/src/connection/session.ts +9 -0
  38. package/src/connection/target.ts +1 -0
  39. package/src/graph/__tests__/mutation.test.ts +6 -0
  40. package/src/graph/mutation.ts +13 -0
  41. package/src/identity/__tests__/registry.test.ts +1 -1
  42. package/src/identity/registry.ts +12 -5
  43. package/src/lib/__tests__/command-dx.test.ts +47 -15
  44. package/src/lib/__tests__/instance-target.test.ts +17 -0
  45. package/src/lib/__tests__/output.test.ts +12 -0
  46. package/src/lib/command-dx.ts +41 -23
  47. package/src/lib/instance-target.ts +18 -2
  48. package/src/lib/invocation.ts +11 -0
  49. package/src/lib/log.ts +17 -9
  50. package/src/lib/output.ts +20 -4
  51. package/src/program/__tests__/program.test.ts +4 -2
  52. package/src/program/build.ts +3 -9
  53. package/src/program/options.ts +2 -1
  54. package/src/state/__tests__/identities.test.ts +2 -2
  55. package/src/state/identities.ts +3 -10
  56. package/src/commands/describe.ts +0 -86
@@ -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 call <path> --describe` to see the expected schema')
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
- reason !== null &&
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
- // Catch-all: include class name so diagnosis is possible even without --debug
212
- if (isRaw) writeRaw({ error: name || 'UNKNOWN', message: error.message })
213
- else log.error(`${chalk.bold(name || 'Error')}: ${error.message}`)
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
 
@@ -17,6 +17,7 @@ export interface ConnectionOptions {
17
17
  readonly as?: string
18
18
  readonly creds?: string
19
19
  readonly anonymous?: boolean
20
+ readonly ci?: boolean
20
21
  }
21
22
 
22
23
  export interface AdminConnectionOptions extends ConnectionOptions {
@@ -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
  })
@@ -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', 'manager', 'workos'])
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',
@@ -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: { ...store, identities: { ...store.identities, [name]: identity } },
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
- const identity = store.identities[store.default]
87
- if (!identity) {
88
- throw new Error(
89
- `Default identity "${store.default}" not found. Run: astrale identity create ${store.default}`,
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 = stripAnsi(renderCommanderError(program, error, ['delete', 'demo-system']))
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 delete demo-system')
40
- expect(rendered).toContain('"delete" is available under:')
41
- expect(rendered).toContain('astrale identity delete <name>')
42
- expect(rendered).toContain('astrale instance delete <id>')
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 = stripAnsi(renderCommanderError(program, error, ['ls', '@note']))
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 = stripAnsi(renderCommanderError(program, error, ['indetityl', 'ist']))
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', () => {
@@ -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
- `Missing required argument${argName ? ` ${chalk.bold(`<${argName}>`)}` : ''} for ${chalk.bold(
45
- `astrale ${matched.path.join(' ')}`,
46
- )}`,
47
- '',
48
- 'Usage:',
49
- ` astrale ${usage}`,
50
- ].join('\n')
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
- `Unexpected argument${extra.includes(' ') ? 's' : ''} for ${chalk.bold(
58
- `astrale ${matched.path.join(' ')}`,
59
- )}${extra ? `: ${extra}` : ''}`,
60
- '',
61
- 'Usage:',
62
- ` astrale ${usage}`,
63
- ].join('\n')
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
- error.message,
69
- ...(suggestions.length > 0
70
- ? ['', 'Did you mean:', ...suggestions.map((s) => ` astrale ${s}`)]
71
- : []),
72
- ].join('\n')
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 (!isManagedInstanceNotFound(e)) throw e
98
- throw notFound
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 RawOutputOpts } from './output'
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. Commands that carry
20
- * RawOutputOpts should pass them so machine consumers (--json/--raw/piped)
21
- * get one structured JSON line on stderr instead of the pretty ✖ view. */
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
- if (opts && isMachine(opts)) {
28
- const error = e instanceof AstraleError ? e.code : e instanceof Error ? e.name : 'Error'
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`, or any non-TTY stdout (pipe, redirect, CI, agent).
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?: RawOutputOpts): boolean {
26
- return !!(opts?.raw || opts?.json) || !(process.stdout.isTTY ?? false)
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 (NOISE_KEYS.has(k)) continue
160
+ if (isNoiseKey(k)) continue
145
161
  out[k] = v && typeof v === 'object' ? denoise(v) : v
146
162
  }
147
163
  return out