@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.
Files changed (88) hide show
  1. package/README.md +4 -3
  2. package/dist/astrale.js +3391 -3163
  3. package/dist/public/connect-core.js +2041 -3066
  4. package/dist/public/keys/index.js +1854 -2895
  5. package/dist/public/paths/index.js +1833 -2882
  6. package/dist/types/connection/auth.d.ts +3 -0
  7. package/dist/types/lib/instance-target.d.ts +2 -0
  8. package/dist/types/lib/instance.d.ts +10 -0
  9. package/dist/types/lib/invocation.d.ts +3 -0
  10. package/dist/types/lib/log.d.ts +6 -5
  11. package/dist/types/lib/output.d.ts +6 -2
  12. package/package.json +8 -8
  13. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  14. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  15. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  16. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  17. package/src/commands/__tests__/instance-use.test.ts +67 -0
  18. package/src/commands/__tests__/introspect-parse.test.ts +21 -0
  19. package/src/commands/__tests__/logs.test.ts +5 -0
  20. package/src/commands/__tests__/read-commands.test.ts +11 -1
  21. package/src/commands/__tests__/token-ttl.test.ts +21 -0
  22. package/src/commands/__tests__/view-build.test.ts +58 -0
  23. package/src/commands/auth/token.ts +19 -7
  24. package/src/commands/call.ts +18 -43
  25. package/src/commands/domain/install.ts +6 -5
  26. package/src/commands/domain/uninstall.ts +128 -0
  27. package/src/commands/get.ts +23 -5
  28. package/src/commands/identity/create.ts +11 -2
  29. package/src/commands/identity/delete.ts +8 -2
  30. package/src/commands/identity/export.ts +8 -2
  31. package/src/commands/identity/import.ts +11 -2
  32. package/src/commands/identity/sync.ts +8 -2
  33. package/src/commands/identity/unsync.ts +12 -2
  34. package/src/commands/identity/use.ts +8 -2
  35. package/src/commands/identity/whoami.ts +1 -1
  36. package/src/commands/instance/active.ts +13 -1
  37. package/src/commands/instance/bookmark.ts +26 -3
  38. package/src/commands/instance/list.ts +18 -4
  39. package/src/commands/instance/use.ts +54 -7
  40. package/src/commands/introspect.ts +117 -0
  41. package/src/commands/logs.ts +50 -6
  42. package/src/commands/mutate.ts +2 -3
  43. package/src/commands/query.ts +3 -5
  44. package/src/commands/token.ts +35 -8
  45. package/src/commands/update.ts +30 -9
  46. package/src/commands/view.ts +33 -19
  47. package/src/connection/.spec/architecture.md +5 -0
  48. package/src/connection/.spec/laws/connection.ts +20 -0
  49. package/src/connection/.spec/layout.ts +1 -0
  50. package/src/connection/__tests__/auth.test.ts +27 -1
  51. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  52. package/src/connection/__tests__/credential.test.ts +10 -0
  53. package/src/connection/__tests__/errors.test.ts +430 -36
  54. package/src/connection/__tests__/exchange.test.ts +46 -5
  55. package/src/connection/__tests__/reasons.test.ts +78 -0
  56. package/src/connection/auth.ts +11 -9
  57. package/src/connection/command.ts +1 -1
  58. package/src/connection/credential.ts +3 -0
  59. package/src/connection/errors.ts +195 -154
  60. package/src/connection/exchange.ts +14 -2
  61. package/src/connection/reasons.ts +179 -0
  62. package/src/connection/session.ts +9 -0
  63. package/src/connection/target.ts +1 -0
  64. package/src/graph/__tests__/mutation.test.ts +6 -0
  65. package/src/graph/mutation.ts +13 -0
  66. package/src/identity/__tests__/registry.test.ts +1 -1
  67. package/src/identity/registry.ts +12 -5
  68. package/src/lib/__tests__/command-dx.test.ts +35 -20
  69. package/src/lib/__tests__/instance-target.test.ts +17 -0
  70. package/src/lib/__tests__/instance.test.ts +51 -1
  71. package/src/lib/__tests__/output.test.ts +12 -0
  72. package/src/lib/__tests__/view-assets.test.ts +33 -1
  73. package/src/lib/__tests__/view-server.test.ts +68 -0
  74. package/src/lib/ca-fetch.ts +9 -3
  75. package/src/lib/command-dx.ts +40 -23
  76. package/src/lib/instance-target.ts +18 -2
  77. package/src/lib/instance.ts +31 -0
  78. package/src/lib/invocation.ts +11 -0
  79. package/src/lib/log.ts +17 -9
  80. package/src/lib/output.ts +20 -4
  81. package/src/lib/view/assets.ts +16 -2
  82. package/src/program/__tests__/program.test.ts +4 -1
  83. package/src/program/build.ts +5 -9
  84. package/src/program/options.ts +2 -1
  85. package/src/state/__tests__/identities.test.ts +2 -2
  86. package/src/state/identities.ts +3 -10
  87. package/studio/package.json +7 -8
  88. package/viewer/dist/main.js +57 -57
@@ -1,7 +1,13 @@
1
1
  import type { CommandDefinition } from '../../program/index'
2
2
 
3
3
  import { fetchWithCaFile } from '../../lib/ca-fetch'
4
- import { normalizeInstanceKernelUrl, setActive, upsertInstance } from '../../lib/instance'
4
+ import {
5
+ findBookmarkTrustConflicts,
6
+ normalizeInstanceKernelUrl,
7
+ readInstances,
8
+ setActive,
9
+ upsertInstance,
10
+ } from '../../lib/instance'
5
11
  import { fatal, log } from '../../lib/log'
6
12
  import { checkIssuerReachability } from '../../lib/meta'
7
13
 
@@ -39,14 +45,27 @@ export default {
39
45
  try {
40
46
  if (!opts.url) fatal(new Error('Missing required flag: --url <url>'))
41
47
  const url = normalizeInstanceKernelUrl(opts.url)
42
- const expectedIssuer = opts.issuer ? normalizeInstanceKernelUrl(opts.issuer) : undefined
48
+ const store = await readInstances()
49
+ const expectedIssuer = opts.issuer
50
+ ? normalizeInstanceKernelUrl(opts.issuer)
51
+ : store.instances[name]?.issuer
52
+ const effectiveCa = opts.ca ?? store.instances[name]?.caFile
53
+ const trustConflicts = findBookmarkTrustConflicts(store, name, url, effectiveCa)
54
+ if (trustConflicts.length > 0) {
55
+ log.warn(
56
+ `TLS trust differs for the same Kernel URL ${url}: ` +
57
+ `"${name}" uses ${describeCa(effectiveCa)}, while ${trustConflicts
58
+ .map((conflict) => `"${conflict.name}" uses ${describeCa(conflict.caFile)}`)
59
+ .join(', ')}. Remove or update stale bookmarks to avoid certificate surprises.`,
60
+ )
61
+ }
43
62
 
44
63
  if (!opts.skipProbe) {
45
64
  try {
46
65
  const { issuer, keys } = await checkIssuerReachability(
47
66
  url,
48
67
  expectedIssuer,
49
- opts.ca ? fetchWithCaFile(opts.ca) : undefined,
68
+ effectiveCa ? fetchWithCaFile(effectiveCa) : undefined,
50
69
  )
51
70
  log.dim(` iss=${issuer} keys=${keys.length}`)
52
71
  } catch (e) {
@@ -76,3 +95,7 @@ export default {
76
95
  }
77
96
  },
78
97
  } satisfies CommandDefinition
98
+
99
+ function describeCa(caFile: string | null | undefined): string {
100
+ return caFile ? `CA ${caFile}` : 'the system trust store'
101
+ }
@@ -30,6 +30,7 @@ export type Bookmark = {
30
30
  issuer: string | null
31
31
  active: boolean
32
32
  defaultIdentity: string | null
33
+ caFile: string | null
33
34
  createdAt: string | null
34
35
  }
35
36
 
@@ -57,6 +58,7 @@ export default {
57
58
  issuer: entry.issuer ?? null,
58
59
  active: name === store.active,
59
60
  defaultIdentity: entry.defaultIdentity ?? null,
61
+ caFile: entry.caFile ?? null,
60
62
  createdAt: entry.createdAt ?? null,
61
63
  }))
62
64
 
@@ -111,13 +113,13 @@ export function buildInstanceRows(
111
113
  const rows: Array<Record<string, string>> = []
112
114
  const merged = new Set<string>()
113
115
 
114
- const bookmarkByName = new Map<string, { url: string; active: boolean }>()
116
+ const bookmarkByName = new Map<string, Bookmark & { url: string }>()
115
117
  if (show.managed && show.bookmarks) {
116
118
  for (const bookmark of bookmarks) {
117
119
  if (bookmark.url === null) continue
118
120
  bookmarkByName.set(bookmark.name, {
121
+ ...bookmark,
119
122
  url: normalizeInstanceKernelUrl(bookmark.url),
120
- active: bookmark.active,
121
123
  })
122
124
  }
123
125
  }
@@ -134,7 +136,9 @@ export function buildInstanceRows(
134
136
  name: twin?.active ? `${item.slug} ${chalk.green('*')}` : item.slug,
135
137
  kind: 'managed',
136
138
  url: item.url ?? '',
137
- extra: formatInstanceLocation(item),
139
+ extra: [formatInstanceLocation(item), twin ? formatBookmarkConnection(twin) : '']
140
+ .filter(Boolean)
141
+ .join(' · '),
138
142
  })
139
143
  }
140
144
  }
@@ -146,7 +150,7 @@ export function buildInstanceRows(
146
150
  name: item.active ? `${item.name} ${chalk.green('*')}` : item.name,
147
151
  kind: 'bookmark',
148
152
  url: String(item.url ?? ''),
149
- extra: '',
153
+ extra: formatBookmarkConnection(item),
150
154
  })
151
155
  }
152
156
  }
@@ -154,6 +158,16 @@ export function buildInstanceRows(
154
158
  return rows
155
159
  }
156
160
 
161
+ function formatBookmarkConnection(bookmark: Bookmark): string {
162
+ return [
163
+ bookmark.issuer && bookmark.issuer !== bookmark.url ? `issuer=${bookmark.issuer}` : '',
164
+ bookmark.caFile ? `ca=${bookmark.caFile}` : '',
165
+ bookmark.defaultIdentity ? `identity=${bookmark.defaultIdentity}` : '',
166
+ ]
167
+ .filter(Boolean)
168
+ .join(' · ')
169
+ }
170
+
157
171
  const ADMIN_INVENTORY_CODES = new Set([
158
172
  'TOKEN_EXCHANGE_SOURCE_INVALID',
159
173
  'TOKEN_EXCHANGE_SOURCE_EXPIRED',
@@ -5,7 +5,9 @@ import { AstraleError } from '../../errors'
5
5
  import { getDefault, setDefault } from '../../identity/index'
6
6
  import { listOwnedInstances } from '../../lib/admin-instance'
7
7
  import { ADMIN_TARGET_OPTIONS } from '../../lib/admin-target'
8
+ import { fetchWithCaFile } from '../../lib/ca-fetch'
8
9
  import {
10
+ findBookmarkTrustConflicts,
9
11
  getActive,
10
12
  readInstances,
11
13
  resolveInstance,
@@ -48,12 +50,8 @@ async function useInstance(name?: string, opts: UseOpts = {}): Promise<void> {
48
50
 
49
51
  const resolved = await resolveUseTarget(name, opts)
50
52
 
51
- if (!opts.skipJwksCheck && resolved.issuer) {
52
- try {
53
- await checkIssuerReachability(resolved.url, resolved.issuer)
54
- } catch (e) {
55
- fatal(e)
56
- }
53
+ if (!opts.skipJwksCheck) {
54
+ await probeBookmark(resolved)
57
55
  }
58
56
 
59
57
  await setActive(resolved.name)
@@ -94,6 +92,55 @@ async function useInstance(name?: string, opts: UseOpts = {}): Promise<void> {
94
92
  }
95
93
  }
96
94
 
95
+ /** Probe with the exact TLS trust configuration stored on this bookmark. */
96
+ export async function probeBookmark(
97
+ resolved: ResolvedInstance,
98
+ dependencies: Partial<BookmarkProbeDependencies> = {},
99
+ ): Promise<void> {
100
+ const probe = { ...defaultBookmarkProbeDependencies, ...dependencies }
101
+ const store = await probe.readInstances()
102
+ const conflicts = findBookmarkTrustConflicts(store, resolved.name, resolved.url, resolved.caFile)
103
+ try {
104
+ await probe.checkIssuerReachability(
105
+ resolved.url,
106
+ resolved.issuer,
107
+ resolved.caFile ? probe.fetchWithCaFile(resolved.caFile) : undefined,
108
+ )
109
+ } catch (cause) {
110
+ const original = cause instanceof AstraleError ? cause.hint : undefined
111
+ const trust = resolved.caFile
112
+ ? `Bookmark "${resolved.name}" trusts CA ${resolved.caFile}.`
113
+ : `Bookmark "${resolved.name}" uses the system trust store.`
114
+ const collision =
115
+ conflicts.length === 0
116
+ ? ''
117
+ : ` The same URL is bookmarked with different TLS trust as ${conflicts
118
+ .map((conflict) =>
119
+ conflict.caFile
120
+ ? `"${conflict.name}" (CA ${conflict.caFile})`
121
+ : `"${conflict.name}" (system trust)`,
122
+ )
123
+ .join(', ')}.`
124
+ throw new AstraleError(
125
+ cause instanceof AstraleError ? cause.code : 'ISSUER_UNREACHABLE',
126
+ `Issuer/JWKS probe failed for bookmark "${resolved.name}" at ${resolved.url}.`,
127
+ `${trust}${collision}${original ? ` ${original}` : ''} Inspect with \`astrale instance list --bookmarked --json\`.`,
128
+ )
129
+ }
130
+ }
131
+
132
+ interface BookmarkProbeDependencies {
133
+ readonly readInstances: typeof readInstances
134
+ readonly checkIssuerReachability: typeof checkIssuerReachability
135
+ readonly fetchWithCaFile: typeof fetchWithCaFile
136
+ }
137
+
138
+ const defaultBookmarkProbeDependencies: BookmarkProbeDependencies = Object.freeze({
139
+ readInstances,
140
+ checkIssuerReachability,
141
+ fetchWithCaFile,
142
+ })
143
+
97
144
  async function resolveUseTarget(name: string, opts: UseOpts): Promise<ResolvedInstance> {
98
145
  const [store, managed] = await Promise.all([readInstances(), fetchManagedInstances(name, opts)])
99
146
  const candidates = collectInstanceCandidates(name, store, managed)
@@ -204,7 +251,7 @@ Examples:
204
251
  flags: '--adopt-default',
205
252
  description: 'Adopt instance default identity without prompt',
206
253
  },
207
- { flags: '--skip-jwks-check', description: 'Skip the /meta JWKS match check' },
254
+ { flags: '--skip-jwks-check', description: 'Skip the OIDC discovery + JWKS liveness probe' },
208
255
  ],
209
256
  action: async (name: string | undefined, opts: UseOpts) => {
210
257
  await useInstance(name, opts)
@@ -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']> {