@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
@@ -8,6 +8,7 @@ import {
8
8
  isEncryptedIdentityExport,
9
9
  } from '../../identity/index'
10
10
  import { fatal, log } from '../../lib/log'
11
+ import { isMachine, output, RAW_OUTPUT_OPTIONS, type RawOutputOpts } from '../../lib/output'
11
12
  import { readPassphrase } from '../../lib/prompt'
12
13
 
13
14
  export default {
@@ -27,8 +28,12 @@ export default {
27
28
  flags: '--replace',
28
29
  description: 'Replace an existing key-backed identity with the imported keypair',
29
30
  },
31
+ ...RAW_OUTPUT_OPTIONS,
30
32
  ],
31
- action: async (path: string, opts: { name?: string; issuer?: string; replace?: boolean }) => {
33
+ action: async (
34
+ path: string,
35
+ opts: { name?: string; issuer?: string; replace?: boolean } & RawOutputOpts,
36
+ ) => {
32
37
  try {
33
38
  const raw = await readFile(path, 'utf-8')
34
39
  const passphrase = isEncryptedIdentityExport(raw)
@@ -42,11 +47,15 @@ export default {
42
47
  replace: opts.replace,
43
48
  })
44
49
 
50
+ if (isMachine(opts)) {
51
+ output({ name, ...identity }, opts)
52
+ return
53
+ }
45
54
  log.success(
46
55
  `Imported identity "${name}" (subject=${identity.subject}, kid=${identity.kid ?? '?'})`,
47
56
  )
48
57
  } catch (e) {
49
- fatal(e)
58
+ fatal(e, opts)
50
59
  }
51
60
  },
52
61
  } satisfies CommandDefinition
@@ -2,6 +2,7 @@ import type { CommandDefinition } from '../../program/index'
2
2
 
3
3
  import { getIdentity, setIdentityMode } from '../../identity/index'
4
4
  import { fatal, fatalNotImplemented, log } from '../../lib/log'
5
+ import { isMachine, output, RAW_OUTPUT_OPTIONS, type RawOutputOpts } from '../../lib/output'
5
6
 
6
7
  export default {
7
8
  name: 'sync',
@@ -12,8 +13,9 @@ export default {
12
13
  flags: '--force',
13
14
  description: 'Tag as remote in the local registry even without cloud login',
14
15
  },
16
+ ...RAW_OUTPUT_OPTIONS,
15
17
  ],
16
- action: async (name: string, opts: { force?: boolean }) => {
18
+ action: async (name: string, opts: { force?: boolean } & RawOutputOpts) => {
17
19
  try {
18
20
  const identity = await getIdentity(name)
19
21
  if (!opts.force) {
@@ -23,10 +25,14 @@ export default {
23
25
  )
24
26
  }
25
27
  await setIdentityMode(name, 'remote')
28
+ if (isMachine(opts)) {
29
+ output({ name, mode: 'remote', subject: identity.subject }, opts)
30
+ return
31
+ }
26
32
  log.warn(`Tagged "${name}" as remote locally (cloud sync stubbed in v1)`)
27
33
  log.dim(` subject=${identity.subject}`)
28
34
  } catch (e) {
29
- fatal(e)
35
+ fatal(e, opts)
30
36
  }
31
37
  },
32
38
  } satisfies CommandDefinition
@@ -2,23 +2,33 @@ import type { CommandDefinition } from '../../program/index'
2
2
 
3
3
  import { getIdentity, setIdentityMode } from '../../identity/index'
4
4
  import { fatal, log } from '../../lib/log'
5
+ import { isMachine, output, RAW_OUTPUT_OPTIONS, type RawOutputOpts } from '../../lib/output'
5
6
 
6
7
  /** Metadata-only flip remote → local until cloud sync ships (§2.7). */
7
8
  export default {
8
9
  name: 'unsync',
9
10
  description: 'Migrate an identity remote → local',
10
11
  arguments: [{ name: 'name', description: 'Identity name', required: true }],
11
- action: async (name: string) => {
12
+ options: [...RAW_OUTPUT_OPTIONS],
13
+ action: async (name: string, opts: RawOutputOpts) => {
12
14
  try {
13
15
  const identity = await getIdentity(name)
14
16
  if (identity.mode !== 'remote') {
17
+ if (isMachine(opts)) {
18
+ output({ name, mode: identity.mode ?? 'local', unchanged: true }, opts)
19
+ return
20
+ }
15
21
  log.warn(`Identity "${name}" is already ${identity.mode ?? 'local'}`)
16
22
  return
17
23
  }
18
24
  await setIdentityMode(name, 'local')
25
+ if (isMachine(opts)) {
26
+ output({ name, mode: 'local' }, opts)
27
+ return
28
+ }
19
29
  log.success(`Identity "${name}" → local`)
20
30
  } catch (e) {
21
- fatal(e)
31
+ fatal(e, opts)
22
32
  }
23
33
  },
24
34
  } satisfies CommandDefinition
@@ -2,17 +2,23 @@ import type { CommandDefinition } from '../../program/index'
2
2
 
3
3
  import { setDefault } from '../../identity/index'
4
4
  import { fatal, log } from '../../lib/log'
5
+ import { isMachine, output, RAW_OUTPUT_OPTIONS, type RawOutputOpts } from '../../lib/output'
5
6
 
6
7
  export default {
7
8
  name: 'use',
8
9
  description: 'Set the active CLI identity',
9
10
  arguments: [{ name: 'name', description: 'Identity name', required: true }],
10
- action: async (name: string) => {
11
+ options: [...RAW_OUTPUT_OPTIONS],
12
+ action: async (name: string, opts: RawOutputOpts) => {
11
13
  try {
12
14
  await setDefault(name)
15
+ if (isMachine(opts)) {
16
+ output({ default: name }, opts)
17
+ return
18
+ }
13
19
  log.success(`Active identity set to "${name}"`)
14
20
  } catch (e) {
15
- fatal(e)
21
+ fatal(e, opts)
16
22
  }
17
23
  },
18
24
  } satisfies CommandDefinition
@@ -28,7 +28,7 @@ export default {
28
28
  }
29
29
  console.log(`${chalk.bold(result.name)} (subject: ${result.subject})`)
30
30
  } catch (e) {
31
- fatal(e)
31
+ fatal(e, opts)
32
32
  }
33
33
  },
34
34
  } satisfies CommandDefinition
@@ -0,0 +1,117 @@
1
+ import { Path } from '@astrale-os/sdk/graph/path'
2
+
3
+ import type { KernelCommandOpts } from '../connection'
4
+ import type { CommandDefinition } from '../program/index'
5
+
6
+ import { runKernelCommand } from '../connection'
7
+ import { AstraleError } from '../errors'
8
+ import { failClosed } from '../lib/log'
9
+ import { output } from '../lib/output'
10
+ import { describeCallableFromSchema, missingCallableDescription } from './call-describe'
11
+
12
+ type IntrospectOpts = KernelCommandOpts & { bundle?: boolean }
13
+
14
+ export async function introspectCommand(target: string, opts: IntrospectOpts): Promise<void> {
15
+ let origin: string
16
+ let path: Path
17
+ try {
18
+ ;({ origin, path } = parseIntrospectTarget(target))
19
+ } catch (error) {
20
+ failClosed(error, opts)
21
+ }
22
+ const wantsCallable = isCallablePath(path)
23
+
24
+ await runKernelCommand({
25
+ opts,
26
+ label: `Introspect ${origin}`,
27
+ fn: async ({ session }) => {
28
+ const includeBundle = wantsCallable || opts.bundle === true
29
+ const result = await session.schema.introspect({
30
+ from: { kind: 'installation', origin },
31
+ select: {
32
+ state: true,
33
+ target: true,
34
+ source: true,
35
+ readiness: true,
36
+ capabilities: true,
37
+ ...(includeBundle ? { bundle: true as const } : {}),
38
+ },
39
+ })
40
+ if (result === null) {
41
+ throw new AstraleError(
42
+ 'DOMAIN_NOT_INSTALLED',
43
+ `Domain ${origin} is not installed on this Kernel.`,
44
+ )
45
+ }
46
+ if (wantsCallable) {
47
+ const described = describeCallableFromSchema(path, bundleRoot(result.bundle))
48
+ if (described === undefined) throw missingCallableDescription(path.raw)
49
+ return described
50
+ }
51
+ return result
52
+ },
53
+ format: (value, format) => output(value, format),
54
+ })
55
+ }
56
+
57
+ export function parseIntrospectTarget(target: string): { origin: string; path: Path } {
58
+ if (target.startsWith('@')) {
59
+ throw new AstraleError(
60
+ 'NOT_A_DOMAIN',
61
+ 'introspect requires a Domain origin or Path, not an @id.',
62
+ 'Example: astrale introspect host.astrale.ai or /:host.astrale.ai:class.Manager:createInstance',
63
+ )
64
+ }
65
+ const raw = target.startsWith('/') ? target : `/:${target}`
66
+ let path: Path
67
+ try {
68
+ path = Path.parse(raw)
69
+ } catch (error) {
70
+ throw new AstraleError(
71
+ 'PATH_INVALID',
72
+ error instanceof Error ? error.message : 'Invalid introspect target',
73
+ )
74
+ }
75
+ if (path.ast.anchor.kind !== 'domain') {
76
+ throw new AstraleError('NOT_A_DOMAIN', 'introspect requires a Domain-rooted Path or origin.')
77
+ }
78
+ return { origin: path.ast.anchor.origin, path }
79
+ }
80
+
81
+ function isCallablePath(path: Path): boolean {
82
+ const last = path.ast.steps.at(-1)
83
+ if (last === undefined) return false
84
+ if (last.kind === 'method') return true
85
+ return last.kind === 'projection' && last.projection.kind === 'function'
86
+ }
87
+
88
+ function bundleRoot(bundle: unknown): unknown {
89
+ if (bundle !== null && typeof bundle === 'object' && 'root' in bundle) {
90
+ return (bundle as { root: unknown }).root
91
+ }
92
+ return bundle
93
+ }
94
+
95
+ export default {
96
+ name: 'introspect',
97
+ description: 'Read installed Domain schema from the Kernel Schema syscall',
98
+ afterHelpText: `
99
+ Behavior:
100
+ Calls the public Kernel introspect syscall for one installed Domain.
101
+ A bare origin (host.astrale.ai or /:host.astrale.ai) prints installation
102
+ state, target, source, readiness, and capabilities. --bundle includes the
103
+ schema bundle. A method or Function Path projects that callable's
104
+ input/output from the installed bundle.
105
+
106
+ Examples:
107
+ $ astrale introspect host.astrale.ai
108
+ $ astrale introspect /:host.astrale.ai --bundle
109
+ $ astrale introspect /:host.astrale.ai:class.Manager:createInstance
110
+ $ astrale introspect /:kernel.astrale.ai:function.journal
111
+ `,
112
+ arguments: [{ name: 'target', description: 'Domain origin or canonical Path' }],
113
+ options: [{ flags: '--bundle', description: 'Include the installed schema bundle' }],
114
+ action: async (target, opts) => {
115
+ await introspectCommand(target as string, opts as IntrospectOpts)
116
+ },
117
+ } satisfies CommandDefinition
@@ -6,7 +6,13 @@ import type { ConnectionContext, KernelCommandOpts } from '../connection'
6
6
  import type { Column, ListProjection } from '../lib/output'
7
7
  import type { CommandDefinition } from '../program/index'
8
8
 
9
- import { createPathCall, runKernelCommand, withClientSession } from '../connection'
9
+ import {
10
+ createPathCall,
11
+ expandSelfInPath,
12
+ runKernelCommand,
13
+ withClientSession,
14
+ } from '../connection'
15
+ import { failClosed } from '../lib/log'
10
16
  import { isMachine, output, presentList } from '../lib/output'
11
17
 
12
18
  const JOURNAL_PATH = Path.project(syscalls.journal.ref).raw
@@ -50,10 +56,18 @@ export interface JournalInput {
50
56
  readonly limit: number
51
57
  }
52
58
 
59
+ const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
60
+ const CURSOR_TOKEN = /^[A-Za-z0-9._:+=/-]{8,}$/
61
+
53
62
  /** Map flags to the exact public journal syscall input without legacy glob/sequence lowering. */
54
63
  export function buildJournalInput(opts: LogsOpts): JournalInput {
55
64
  const exact = nonEmpty(opts.topic)
56
65
  const prefix = nonEmpty(opts.topicPrefix)
66
+ const since = timestampFlag('--since', opts.since)
67
+ const until = timestampFlag('--until', opts.until)
68
+ if (since !== undefined && until !== undefined && Date.parse(since) > Date.parse(until)) {
69
+ throw new TypeError('--since must be earlier than or equal to --until')
70
+ }
57
71
  return {
58
72
  ...(exact === undefined && prefix === undefined
59
73
  ? {}
@@ -64,13 +78,30 @@ export function buildJournalInput(opts: LogsOpts): JournalInput {
64
78
  },
65
79
  }),
66
80
  ...(nonEmpty(opts.principal) === undefined ? {} : { principal: opts.principal }),
67
- ...(nonEmpty(opts.since) === undefined ? {} : { since: opts.since }),
68
- ...(nonEmpty(opts.until) === undefined ? {} : { until: opts.until }),
69
- ...(nonEmpty(opts.cursor) === undefined ? {} : { cursor: opts.cursor }),
81
+ ...(since === undefined ? {} : { since }),
82
+ ...(until === undefined ? {} : { until }),
83
+ ...(opts.cursor === undefined ? {} : { cursor: cursorFlag(opts.cursor) }),
70
84
  limit: opts.limit === undefined ? DEFAULT_LIMIT : positiveInteger('--limit', opts.limit),
71
85
  }
72
86
  }
73
87
 
88
+ function timestampFlag(name: string, raw: string | undefined): string | undefined {
89
+ const value = nonEmpty(raw)
90
+ if (value === undefined) return undefined
91
+ if (!ISO_TIMESTAMP.test(value) || Number.isNaN(Date.parse(value))) {
92
+ throw new TypeError(`${name} must be an ISO-8601 timestamp (e.g. 2026-08-19T16:51:10.049Z)`)
93
+ }
94
+ return value
95
+ }
96
+
97
+ function cursorFlag(raw: string): string {
98
+ const value = raw.trim()
99
+ if (!CURSOR_TOKEN.test(value)) {
100
+ throw new TypeError('--cursor must be an opaque journal resume token (at least 8 characters)')
101
+ }
102
+ return value
103
+ }
104
+
74
105
  /** Validate the record fields the CLI presentation consumes and retain the opaque cursor. */
75
106
  export function acceptJournalPage(input: unknown): JournalPage {
76
107
  if (!isRecord(input) || !Array.isArray(input.records)) {
@@ -196,6 +227,14 @@ function positiveInteger(flag: string, raw: string): number {
196
227
  return value
197
228
  }
198
229
 
230
+ async function prepareLogsOpts(opts: LogsOpts): Promise<LogsOpts> {
231
+ buildJournalInput(opts)
232
+ const principal = nonEmpty(opts.principal)
233
+ if (principal !== '@self') return opts
234
+ const { path } = await expandSelfInPath('@self', opts)
235
+ return { ...opts, principal: path.startsWith('@') ? path.slice(1) : path }
236
+ }
237
+
199
238
  function nonEmpty(input: string | undefined): string | undefined {
200
239
  const value = input?.trim()
201
240
  return value ? value : undefined
@@ -234,7 +273,12 @@ Examples:
234
273
  { flags: '--follow', description: 'Poll using returned cursors until interrupted' },
235
274
  ],
236
275
  action: async (opts: LogsOpts) => {
237
- if (opts.follow) await follow(opts)
238
- else await runOnce(opts)
276
+ try {
277
+ const prepared = await prepareLogsOpts(opts)
278
+ if (prepared.follow) await follow(prepared)
279
+ else await runOnce(prepared)
280
+ } catch (error) {
281
+ failClosed(error, opts)
282
+ }
239
283
  },
240
284
  } satisfies CommandDefinition
@@ -8,7 +8,7 @@ import type { CommandDefinition } from '../program/index'
8
8
 
9
9
  import { runKernelCommand } from '../connection'
10
10
  import { prepareMutation } from '../graph/index'
11
- import { log } from '../lib/log'
11
+ import { failClosed } from '../lib/log'
12
12
  import { output } from '../lib/output'
13
13
  import { renderTable } from '../lib/table'
14
14
 
@@ -23,8 +23,7 @@ export async function mutateCommand(opts: MutateOpts): Promise<void> {
23
23
  try {
24
24
  mutation = prepareMutation(await readDocument(opts))
25
25
  } catch (error) {
26
- log.error(error instanceof Error ? error.message : 'Invalid Mutation V3 document')
27
- process.exit(1)
26
+ failClosed(error, opts)
28
27
  }
29
28
 
30
29
  if (opts.dry) {
@@ -7,7 +7,7 @@ import type { CommandDefinition } from '../program/index'
7
7
 
8
8
  import { expandSelfInPath, runKernelCommand, withSelfHint } from '../connection'
9
9
  import { prepareQuery, type QueryCommandInput } from '../graph/index'
10
- import { log } from '../lib/log'
10
+ import { failClosed } from '../lib/log'
11
11
  import { isMachine, output } from '../lib/output'
12
12
 
13
13
  type QueryOpts = KernelCommandOpts & {
@@ -48,16 +48,14 @@ export async function queryCommand(sources: string[], opts: QueryOpts): Promise<
48
48
  }
49
49
  }
50
50
  } catch (error) {
51
- log.error(error instanceof Error ? error.message : 'Invalid query')
52
- process.exit(1)
51
+ failClosed(error, opts)
53
52
  }
54
53
 
55
54
  let prepared
56
55
  try {
57
56
  prepared = prepareQuery(input)
58
57
  } catch (error) {
59
- log.error(error instanceof Error ? error.message : 'Invalid Query V6 document')
60
- process.exit(1)
58
+ failClosed(error, opts)
61
59
  }
62
60
 
63
61
  await runKernelCommand({
@@ -4,7 +4,10 @@ import type { KernelCommandOpts } from '../connection'
4
4
  import type { CommandDefinition } from '../program/index'
5
5
 
6
6
  import { runKernelCommand } from '../connection'
7
- import { log } from '../lib/log'
7
+ import { AstraleError } from '../errors'
8
+ import { decodeJwtExpiration } from '../lib/local-status'
9
+ import { failClosed, log } from '../lib/log'
10
+ import { output } from '../lib/output'
8
11
 
9
12
  /**
10
13
  * `astrale token` — mint a fresh delegation token for the active instance
@@ -20,6 +23,7 @@ export type TokenOpts = KernelCommandOpts & {
20
23
 
21
24
  export async function tokenCommand(opts: TokenOpts): Promise<void> {
22
25
  const commandOpts: TokenOpts = opts.for && !opts.as ? { ...opts, as: opts.for } : opts
26
+ const ttl = parseTtl(commandOpts.ttl)
23
27
  await runKernelCommand<string>({
24
28
  opts: commandOpts,
25
29
  label: 'Minting delegation token',
@@ -28,8 +32,6 @@ export async function tokenCommand(opts: TokenOpts): Promise<void> {
28
32
  commandOpts.audience === undefined
29
33
  ? ctx.target.kernelIssuer
30
34
  : issuer.accept(commandOpts.audience)
31
- const parsedTtl = Number(commandOpts.ttl)
32
- const ttl = Number.isFinite(parsedTtl) && parsedTtl > 0 ? parsedTtl : 3600
33
35
  const self = await ctx.auth.whoami()
34
36
  return ctx.auth.delegate(self.id, {
35
37
  audience,
@@ -37,17 +39,37 @@ export async function tokenCommand(opts: TokenOpts): Promise<void> {
37
39
  attenuation: { kind: 'identity', self: true },
38
40
  })
39
41
  },
40
- format: (token, fmtOpts, isRaw) => {
41
- if (isRaw) {
42
- process.stdout.write(token + '\n')
42
+ format: (token, fmtOpts) => {
43
+ if (fmtOpts.json || fmtOpts.format !== undefined) {
44
+ output({ token, expiresAt: decodeJwtExpiration(token)?.expiresAt ?? null }, fmtOpts)
43
45
  return
44
46
  }
45
- log.dim(' (delegation token ES256, self-identity)')
47
+ if (!fmtOpts.raw && (process.stdout.isTTY ?? false)) {
48
+ log.dim(' (delegation token — ES256, self-identity)')
49
+ }
46
50
  process.stdout.write(`${token}\n`)
47
51
  },
48
52
  })
49
53
  }
50
54
 
55
+ export function parseTtl(raw: string | undefined): number {
56
+ if (raw === undefined) return 3600
57
+ if (!/^\d+$/.test(raw)) {
58
+ throw new AstraleError(
59
+ 'INVALID_FLAG',
60
+ `Invalid --ttl value "${raw}" — expected a positive integer (seconds)`,
61
+ )
62
+ }
63
+ const value = Number.parseInt(raw, 10)
64
+ if (!Number.isSafeInteger(value) || value <= 0) {
65
+ throw new AstraleError(
66
+ 'INVALID_FLAG',
67
+ `Invalid --ttl value "${raw}" — must be a positive integer`,
68
+ )
69
+ }
70
+ return value
71
+ }
72
+
51
73
  export default {
52
74
  name: 'token',
53
75
  description: 'Mint a fresh delegation token for the active instance + identity',
@@ -63,6 +85,7 @@ Behavior:
63
85
 
64
86
  Examples:
65
87
  $ export TOKEN=$(astrale token --audience shell.astrale.ai --raw)
88
+ $ astrale token --json -i staging
66
89
  $ astrale token --audience worker.example.com --for alice -i staging
67
90
  `,
68
91
  options: [
@@ -74,6 +97,10 @@ Examples:
74
97
  { flags: '--for <identity>', description: 'Mint the token for this identity (alias of --as)' },
75
98
  ],
76
99
  action: async (opts) => {
77
- await tokenCommand(opts as Parameters<typeof tokenCommand>[0])
100
+ try {
101
+ await tokenCommand(opts as Parameters<typeof tokenCommand>[0])
102
+ } catch (error) {
103
+ failClosed(error, opts as TokenOpts)
104
+ }
78
105
  },
79
106
  } satisfies CommandDefinition
@@ -116,26 +116,47 @@ export type StaleReport = {
116
116
  }
117
117
 
118
118
  async function cliStale(opts: UpdateOpts): Promise<StaleReport['cli']> {
119
+ const running = pkg.version
120
+ const latest = await fetchNpmLatestVersion().catch(() => undefined)
119
121
  try {
120
122
  const r = await updateAstrale({
121
123
  check: true,
122
124
  channel: opts.channel,
123
125
  version: opts.version,
124
- currentVersion: pkg.version,
126
+ currentVersion: running,
125
127
  })
126
- // `check: true` never returns 'updated'; narrow for the type checker.
127
- if (r.status === 'updated') return { stale: false, managed: false, current: r.currentVersion }
128
+ if (r.status === 'updated') {
129
+ return { stale: false, managed: false, current: running, latest: latest ?? running }
130
+ }
128
131
  return {
129
- stale: r.status === 'available',
132
+ stale: latest !== undefined ? latest !== running : r.status === 'available',
130
133
  managed: false,
131
- current: r.currentVersion,
132
- latest: r.latestVersion,
133
- channel: r.channel,
134
+ current: running,
135
+ latest: latest ?? r.latestVersion,
136
+ channel: latest !== undefined ? 'npm' : r.channel,
134
137
  }
135
138
  } catch {
136
- // A package-managed install (or missing metadata) can't self-update — not stale.
137
- return { stale: false, managed: true }
139
+ return {
140
+ stale: latest !== undefined && latest !== running,
141
+ managed: true,
142
+ current: running,
143
+ ...(latest === undefined ? {} : { latest, channel: 'npm' }),
144
+ }
145
+ }
146
+ }
147
+
148
+ async function fetchNpmLatestVersion(): Promise<string> {
149
+ const response = await fetch('https://registry.npmjs.org/@astrale-os/cli/latest')
150
+ if (!response.ok) throw new Error(`npm registry HTTP ${response.status}`)
151
+ const body: unknown = await response.json()
152
+ if (
153
+ body === null ||
154
+ typeof body !== 'object' ||
155
+ typeof (body as { version?: unknown }).version !== 'string'
156
+ ) {
157
+ throw new Error('npm registry latest document is missing version')
138
158
  }
159
+ return (body as { version: string }).version
139
160
  }
140
161
 
141
162
  async function sdkStale(): Promise<StaleReport['sdk']> {
@@ -522,15 +522,17 @@ What it does:
522
522
  an audience-bound credential for shell mounts, and the kernel endpoint.
523
523
 
524
524
  Examples:
525
- $ astrale view /crm/customers/ada # views on a node
526
- $ astrale view /:crm.acme.dev:view.dashboard # explicit ViewPath
525
+ $ astrale view @customer
526
+ $ astrale view /:crm.example.dev:view.dashboard
527
527
  $ astrale view /:agents.astrale.ai:view.agent --target @f00d1234 --as alice
528
- $ astrale view /crm/customers/ada --snapshot # open + show it
528
+ $ astrale view @customer --snapshot
529
+ $ astrale view --list
529
530
  $ astrale view --sessions ; astrale view --close --all
530
531
  `,
531
532
  action: async (spec: string | undefined, opts: ViewOpts) => {
532
533
  if (opts.close !== undefined) return closeCommand(opts)
533
534
  if (opts.sessions) return sessionsCommand(opts)
535
+ if (opts.list && !spec) return sessionsCommand(opts)
534
536
 
535
537
  rejectUnrepresentableOverrides(opts)
536
538
  if (!spec) return fatal(new Error('Nothing to open — pass a ViewPath or target node.'))
@@ -82,6 +82,16 @@ describe('connection credential', () => {
82
82
  expect(auth).toBeUndefined()
83
83
  })
84
84
 
85
+ test('rejects --as combined with --creds', () => {
86
+ expect(() =>
87
+ createCliCredential(
88
+ { url: `${SOURCE}/invoke`, kernelIssuer: SOURCE },
89
+ { as: 'alice', creds: 'token' },
90
+ config,
91
+ ),
92
+ ).toThrow('--as cannot be combined with --creds')
93
+ })
94
+
85
95
  /** @evidence TEST-CLI-CONNECTION-PROPAGATES-AUTH-CANCELLATION */
86
96
  test('passes the live Session operation signal to source credential resolution', async () => {
87
97
  const controller = new AbortController()
@@ -93,4 +93,24 @@ describe('formatKernelError', () => {
93
93
  },
94
94
  })
95
95
  })
96
+
97
+ test('maps SDK class names to stable CLI error codes', async () => {
98
+ const writes: string[] = []
99
+ const original = process.stderr.write
100
+ process.stderr.write = ((chunk: string | Uint8Array) => {
101
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
102
+ return true
103
+ }) as typeof process.stderr.write
104
+ try {
105
+ const pathError = new Error('Path must have an Id or Domain anchor.')
106
+ pathError.name = 'PathError'
107
+ await formatKernelError(pathError, true)
108
+ } finally {
109
+ process.stderr.write = original
110
+ }
111
+ expect(JSON.parse(writes[0]!)).toEqual({
112
+ error: 'PATH_INVALID',
113
+ message: 'Path must have an Id or Domain anchor.',
114
+ })
115
+ })
96
116
  })
@@ -94,6 +94,9 @@ export function createCliCredential(
94
94
 
95
95
  /** Reject contradictory explicit credential selections before identity or network access. */
96
96
  export function validateCredentialSelection(options: ConnectionOptions): void {
97
+ if (options.as !== undefined && options.creds !== undefined) {
98
+ throw new AstraleError('INVALID_FLAG', '--as cannot be combined with --creds.')
99
+ }
97
100
  if (options.anonymous !== true) return
98
101
  const conflicting = [
99
102
  ...(options.as === undefined ? [] : ['--as']),