@astrale-os/cli 1.0.0-beta.17 → 1.0.0-beta.19

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.
@@ -1,11 +1,11 @@
1
- import { Path } from '@astrale-os/sdk/graph/path'
2
- import { K } from '@astrale-os/sdk/schema'
1
+ import type { InstallRequest, InstallResult, OperationId } from '@astrale-os/sdk/client/schema'
2
+
3
3
  import chalk from 'chalk'
4
4
 
5
5
  import type { KernelCommandOpts } from '../../connection'
6
6
  import type { CommandDefinition } from '../../program/index'
7
7
 
8
- import { createPathCall, runKernelCommand, withAdminClientSession } from '../../connection'
8
+ import { runKernelCommand, withAdminClientSession } from '../../connection'
9
9
  import { formatKernelError } from '../../connection/errors'
10
10
  import { AstraleError } from '../../errors'
11
11
  import {
@@ -23,35 +23,61 @@ import { confirmWithInput, promptText, selectFrom } from '../../lib/prompt'
23
23
  import { isHttpUrl } from '../../lib/validation'
24
24
 
25
25
  /** Public Kernel install syscall input for one remote URL. */
26
- export function directInstallCallInput(url: string, operation: string, token?: string) {
26
+ export function directInstallCallInput(
27
+ url: string,
28
+ operation: string,
29
+ token?: string,
30
+ ): InstallRequest {
31
+ const domain = Object.freeze({
32
+ publication: Object.freeze({
33
+ url,
34
+ ...(token === undefined ? {} : { token }),
35
+ }),
36
+ })
27
37
  return Object.freeze({
28
- operation,
29
- domains: [
30
- Object.freeze({
31
- source: Object.freeze({
32
- kind: 'remote' as const,
33
- url,
34
- ...(token === undefined ? {} : { token }),
35
- }),
36
- }),
37
- ],
38
+ operation: acceptOperationId(operation),
39
+ domains: Object.freeze([domain] as const),
38
40
  })
39
41
  }
40
42
 
41
- type DirectInstallResult = {
43
+ export interface DirectInstallPresentation {
42
44
  readonly operation: string
43
- readonly transitions: readonly {
44
- readonly intent: {
45
- readonly origin: string
46
- readonly target?: { readonly schemaRevision?: string } | null
45
+ readonly origin: string
46
+ readonly revision: string
47
+ readonly status: 'installed' | 'already current'
48
+ }
49
+
50
+ /** Stable CLI presentation derived from the canonical binary install result. */
51
+ export function directInstallPresentation(
52
+ result: InstallResult,
53
+ requestedOperation: string,
54
+ ): DirectInstallPresentation {
55
+ if (result.changed) {
56
+ const installed = result.receipt.transitions[0]?.intent
57
+ if (!installed) throw new Error('Kernel install returned no committed Domain transition.')
58
+ if (!installed.generation) {
59
+ throw new Error('Kernel install returned a committed transition without a Domain generation.')
47
60
  }
48
- }[]
61
+ return Object.freeze({
62
+ operation: result.receipt.operation,
63
+ origin: installed.origin,
64
+ revision: installed.generation.revision,
65
+ status: 'installed' as const,
66
+ })
67
+ }
68
+ const installed = result.domains[0]
69
+ return Object.freeze({
70
+ operation: requestedOperation,
71
+ origin: installed.origin,
72
+ revision: installed.revision,
73
+ status: 'already current' as const,
74
+ })
49
75
  }
50
76
 
51
77
  const OPERATION_ID_PATTERN =
52
78
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
53
79
 
54
- function acceptOperationId(input: unknown): string {
80
+ function acceptOperationId(input: unknown): OperationId {
55
81
  if (typeof input !== 'string' || !OPERATION_ID_PATTERN.test(input)) {
56
82
  throw new AstraleError(
57
83
  'INVALID_FLAG',
@@ -59,10 +85,10 @@ function acceptOperationId(input: unknown): string {
59
85
  'Omit --operation for a fresh install; use it only with the exact UUID printed for recovery.',
60
86
  )
61
87
  }
62
- return input
88
+ return input as OperationId
63
89
  }
64
90
 
65
- function createOperationId(): string {
91
+ function createOperationId(): OperationId {
66
92
  return acceptOperationId(globalThis.crypto.randomUUID())
67
93
  }
68
94
 
@@ -407,27 +433,20 @@ export async function installDirect(
407
433
  const url = target as string
408
434
  const retry = directInstallRetry(url, operation, opts)
409
435
 
410
- await direct.runKernelCommand<DirectInstallResult>({
436
+ await direct.runKernelCommand<InstallResult>({
411
437
  opts,
412
438
  label: `Installing domain from ${url} (operation ${operation})`,
413
439
  recovery: { operation, retry },
414
440
  fn: async ({ session }) =>
415
- (await session.call(
416
- createPathCall(
417
- Path.project(K.functions.install.ref).raw,
418
- directInstallCallInput(url, operation, opts.token),
419
- ),
420
- )) as DirectInstallResult,
441
+ session.schema.install(directInstallCallInput(url, operation, opts.token)),
421
442
  format: (result, fmtOpts, isRaw) => {
422
443
  if (isRaw) {
423
444
  output(result, fmtOpts)
424
445
  return
425
446
  }
426
- const installed = result.transitions[0]?.intent
427
- if (!installed) throw new Error('Kernel install returned no committed Domain transition.')
428
- const revision = installed.target?.schemaRevision ?? result.operation
429
- log.success(`Domain installed: ${installed.origin}@${revision}`)
430
- log.dim(` operation: ${result.operation}`)
447
+ const installed = directInstallPresentation(result, operation)
448
+ log.success(`Domain ${installed.status}: ${installed.origin}@${installed.revision}`)
449
+ log.dim(` operation: ${installed.operation}`)
431
450
  // Belt-and-braces: the kernel-confirmed origin is authoritative. If it
432
451
  // aliases the host and the pre-install gate never consented to THAT
433
452
  // origin (lying or unavailable Publication), say so loudly after the fact.
@@ -1,3 +1,5 @@
1
+ import type { DomainBundle, DomainInfo, SchemaApi } from '@astrale-os/sdk/client/schema'
2
+
1
3
  import { Path } from '@astrale-os/sdk/graph/path'
2
4
 
3
5
  import type { KernelCommandOpts } from '../connection'
@@ -11,7 +13,18 @@ import { describeCallableFromBundle, missingCallableDescription } from './call-d
11
13
 
12
14
  type IntrospectOpts = KernelCommandOpts & { bundle?: boolean }
13
15
 
14
- export async function introspectCommand(target: string, opts: IntrospectOpts): Promise<void> {
16
+ interface IntrospectDependencies {
17
+ readonly runKernelCommand: typeof runKernelCommand
18
+ }
19
+
20
+ const defaultIntrospectDependencies = Object.freeze({ runKernelCommand })
21
+
22
+ export async function introspectCommand(
23
+ target: string,
24
+ opts: IntrospectOpts,
25
+ dependencies: Partial<IntrospectDependencies> = {},
26
+ ): Promise<void> {
27
+ const introspect = { ...defaultIntrospectDependencies, ...dependencies }
15
28
  let origin: string
16
29
  let path: Path
17
30
  try {
@@ -21,30 +34,14 @@ export async function introspectCommand(target: string, opts: IntrospectOpts): P
21
34
  }
22
35
  const wantsCallable = isCallablePath(path)
23
36
 
24
- await runKernelCommand({
37
+ await introspect.runKernelCommand({
25
38
  opts,
26
39
  label: `Introspect ${origin}`,
27
40
  fn: async ({ session }) => {
28
41
  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
- }
42
+ const result = await readInstalledDomain(session.schema, origin, includeBundle)
46
43
  if (wantsCallable) {
47
- const described = describeCallableFromBundle(path, result.bundle)
44
+ const described = describeCallableFromBundle(path, (result as DomainBundle).bundle)
48
45
  if (described === undefined) throw missingCallableDescription(path.raw)
49
46
  return described
50
47
  }
@@ -54,6 +51,29 @@ export async function introspectCommand(target: string, opts: IntrospectOpts): P
54
51
  })
55
52
  }
56
53
 
54
+ export function readInstalledDomain(
55
+ schema: Pick<SchemaApi, 'inspect' | 'bundle'>,
56
+ origin: string,
57
+ includeBundle: true,
58
+ ): Promise<DomainBundle>
59
+ export function readInstalledDomain(
60
+ schema: Pick<SchemaApi, 'inspect' | 'bundle'>,
61
+ origin: string,
62
+ includeBundle: false,
63
+ ): Promise<DomainInfo>
64
+ export function readInstalledDomain(
65
+ schema: Pick<SchemaApi, 'inspect' | 'bundle'>,
66
+ origin: string,
67
+ includeBundle: boolean,
68
+ ): Promise<DomainInfo | DomainBundle>
69
+ export function readInstalledDomain(
70
+ schema: Pick<SchemaApi, 'inspect' | 'bundle'>,
71
+ origin: string,
72
+ includeBundle: boolean,
73
+ ): Promise<DomainInfo | DomainBundle> {
74
+ return includeBundle ? schema.bundle(origin) : schema.inspect(origin)
75
+ }
76
+
57
77
  export function parseIntrospectTarget(target: string): { origin: string; path: Path } {
58
78
  if (target.startsWith('@')) {
59
79
  throw new AstraleError(
@@ -33,10 +33,21 @@ export interface JournalRecord {
33
33
  readonly occurredAt?: string
34
34
  readonly committedAt?: string
35
35
  readonly principal?: string
36
+ readonly correlation?: JournalCorrelation
36
37
  readonly correlationId?: string
37
38
  readonly causationId?: string
38
39
  }
39
40
 
41
+ export interface JournalCorrelation {
42
+ readonly operationId?: string
43
+ readonly parentOperationId?: string
44
+ readonly invocationId?: string
45
+ readonly rootInvocationId?: string
46
+ readonly parentInvocationId?: string
47
+ readonly traceId?: string
48
+ readonly spanId?: string
49
+ }
50
+
40
51
  export interface JournalPage {
41
52
  readonly records: readonly JournalRecord[]
42
53
  readonly cursor?: string
@@ -133,8 +144,21 @@ async function runOnce(opts: LogsOpts): Promise<void> {
133
144
  })
134
145
  }
135
146
 
136
- async function follow(opts: LogsOpts): Promise<void> {
137
- await runKernelCommand({
147
+ type FollowDependencies = {
148
+ readonly run: typeof runKernelCommand
149
+ readonly pause: (milliseconds: number) => Promise<void>
150
+ }
151
+
152
+ /** Follow one admitted journal stream. Dependencies are explicit so routing is proven at the command boundary. */
153
+ export async function followLogs(
154
+ opts: LogsOpts,
155
+ dependencies: FollowDependencies = {
156
+ run: runKernelCommand,
157
+ pause: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
158
+ },
159
+ ): Promise<void> {
160
+ validateLogsOpts(opts)
161
+ await dependencies.run({
138
162
  opts,
139
163
  label: 'Kernel journal',
140
164
  fn: async (context): Promise<never> => {
@@ -142,9 +166,9 @@ async function follow(opts: LogsOpts): Promise<void> {
142
166
  let cursor = resolved.cursor
143
167
  for (;;) {
144
168
  const page = await fetchPage(context, { ...resolved, cursor })
145
- for (const record of page.records) printRecord(record)
169
+ for (const record of page.records) printRecord(record, opts)
146
170
  cursor = page.cursor ?? cursor
147
- await new Promise((resolve) => setTimeout(resolve, FOLLOW_INTERVAL_MS))
171
+ await dependencies.pause(FOLLOW_INTERVAL_MS)
148
172
  }
149
173
  },
150
174
  })
@@ -169,12 +193,28 @@ function journalProjection(records: JournalRecord[]): ListProjection {
169
193
  }
170
194
  }
171
195
 
172
- function printRecord(record: JournalRecord): void {
196
+ function printRecord(record: JournalRecord, opts: LogsOpts): void {
197
+ if (isMachine(opts) || opts.format === 'json') {
198
+ process.stdout.write(formatFollowRecord(record))
199
+ return
200
+ }
173
201
  process.stdout.write(
174
202
  `${chalk.dim(String(record.sequence).padStart(6))} ${chalk.dim(record.timestamp)} ${chalk.cyan(record.topic)} ${chalk.dim(record.principal ?? '')}\n`,
175
203
  )
176
204
  }
177
205
 
206
+ /** Machine follow is an NDJSON stream: one complete admitted record per line. */
207
+ export function formatFollowRecord(record: JournalRecord): string {
208
+ return `${JSON.stringify(record)}\n`
209
+ }
210
+
211
+ export function validateLogsOpts(opts: LogsOpts): void {
212
+ buildJournalInput(opts)
213
+ if (opts.follow && opts.format === 'yaml' && !opts.json && !opts.raw) {
214
+ throw new TypeError('--follow does not support YAML; use --json for an NDJSON stream')
215
+ }
216
+ }
217
+
178
218
  function acceptRecord(input: unknown, index: number): JournalRecord {
179
219
  if (
180
220
  !isRecord(input) ||
@@ -188,10 +228,17 @@ function acceptRecord(input: unknown, index: number): JournalRecord {
188
228
  if (timestamp === undefined) {
189
229
  throw new TypeError(`Kernel journal record ${index} is missing occurredAt/timestamp`)
190
230
  }
191
- const correlation = isRecord(input.correlation) ? input.correlation : undefined
192
- const correlationId =
193
- optionalText(input.correlationId, index, 'correlationId') ??
194
- optionalText(correlation?.invocationId, index, 'correlation.invocationId')
231
+ const correlation = acceptCorrelation(input.correlation, index)
232
+ const legacyCorrelationId = optionalIdentifier(input.correlationId, index, 'correlationId')
233
+ const structuredCorrelationId = correlation?.invocationId
234
+ if (
235
+ legacyCorrelationId !== undefined &&
236
+ structuredCorrelationId !== undefined &&
237
+ legacyCorrelationId !== structuredCorrelationId
238
+ ) {
239
+ throw new TypeError(`Kernel journal record ${index} has conflicting correlation identifiers`)
240
+ }
241
+ const correlationId = structuredCorrelationId ?? legacyCorrelationId
195
242
  const principal = optionalText(input.principal, index, 'principal')
196
243
  return Object.freeze({
197
244
  sequence: input.sequence as number,
@@ -203,13 +250,56 @@ function acceptRecord(input: unknown, index: number): JournalRecord {
203
250
  ? {}
204
251
  : { committedAt: input.committedAt as string }),
205
252
  ...(principal === undefined ? {} : { principal }),
253
+ ...(correlation === undefined ? {} : { correlation }),
206
254
  ...(correlationId === undefined ? {} : { correlationId }),
207
- ...(optionalText(input.causationId, index, 'causationId') === undefined
255
+ ...(optionalIdentifier(input.causationId, index, 'causationId') === undefined
208
256
  ? {}
209
257
  : { causationId: input.causationId as string }),
210
258
  })
211
259
  }
212
260
 
261
+ const correlationFields = [
262
+ 'operationId',
263
+ 'parentOperationId',
264
+ 'invocationId',
265
+ 'rootInvocationId',
266
+ 'parentInvocationId',
267
+ 'traceId',
268
+ 'spanId',
269
+ ] as const
270
+
271
+ function acceptCorrelation(input: unknown, index: number): JournalCorrelation | undefined {
272
+ if (input === undefined) return undefined
273
+ if (!isRecord(input)) {
274
+ throw new TypeError(`Kernel journal record ${index}.correlation must be an object`)
275
+ }
276
+ const unknown = Object.keys(input).find(
277
+ (field) => !correlationFields.includes(field as (typeof correlationFields)[number]),
278
+ )
279
+ if (unknown !== undefined) {
280
+ throw new TypeError(`Kernel journal record ${index}.correlation.${unknown} is unsupported`)
281
+ }
282
+ return Object.freeze(
283
+ Object.fromEntries(
284
+ correlationFields.flatMap((field) => {
285
+ const value = optionalIdentifier(input[field], index, `correlation.${field}`)
286
+ return value === undefined ? [] : [[field, value]]
287
+ }),
288
+ ),
289
+ )
290
+ }
291
+
292
+ function optionalIdentifier(input: unknown, index: number, field: string): string | undefined {
293
+ const value = optionalText(input, index, field)
294
+ if (value === undefined) return undefined
295
+ if (value.trim() === '' || new TextEncoder().encode(value).byteLength > 256) {
296
+ throw new TypeError(
297
+ `Kernel journal record ${index}.${field} must be non-empty and at most 256 UTF-8 bytes`,
298
+ )
299
+ }
300
+ return value
301
+ }
302
+
213
303
  function optionalText(input: unknown, index: number, field: string): string | undefined {
214
304
  if (input === undefined) return undefined
215
305
  if (typeof input !== 'string') {
@@ -251,7 +341,8 @@ Behavior:
251
341
  Calls the public Kernel journal syscall and emits its { records, cursor }
252
342
  page. Topic selection is exact or prefix-based; cursors and timestamps are
253
343
  opaque strings owned by the journal backend. --follow reuses one Client Session
254
- and advances only with the returned cursor.
344
+ and advances only with the returned cursor. Machine follow output is NDJSON:
345
+ one complete admitted record per line; YAML follow is unsupported.
255
346
 
256
347
  Historical event-glob lowering and the application-specific services-domain
257
348
  log buffer are not part of the Kernel V2 journal contract.
@@ -273,11 +364,11 @@ Examples:
273
364
  ],
274
365
  action: async (opts: LogsOpts) => {
275
366
  try {
276
- buildJournalInput(opts)
367
+ validateLogsOpts(opts)
277
368
  } catch (error) {
278
369
  failInput(error, opts)
279
370
  }
280
- if (opts.follow) await follow(opts)
371
+ if (opts.follow) await followLogs(opts)
281
372
  else await runOnce(opts)
282
373
  },
283
374
  } satisfies CommandDefinition
package/src/lib/output.ts CHANGED
@@ -17,7 +17,7 @@ export type RawOutputOpts = Pick<OutputOpts, 'raw' | 'json'>
17
17
  export type MachineOpts = RawOutputOpts & { readonly ci?: boolean }
18
18
 
19
19
  export const RAW_OUTPUT_OPTIONS = [
20
- { flags: '--json', description: 'Always-valid JSON (for jq)' },
20
+ { flags: '--json', description: 'JSON; streaming commands emit one JSON value per line' },
21
21
  { flags: '--raw', description: 'Unwrapped: bare scalar / raw bytes / JSON for objects' },
22
22
  ] as const
23
23
 
@@ -195,7 +195,7 @@ describe('program composition', () => {
195
195
  'whoami',
196
196
  ])
197
197
  expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
198
- 'd37c3d80b3067b3553b8527b10bf1c5632922d624b3d6072c3b03e7a507efc4f',
198
+ '958b54c4eeee1bab77efeac734bd11fe5a0b103aae69788de7db5418e5652f64',
199
199
  )
200
200
  })
201
201
 
@@ -21,7 +21,7 @@
21
21
  "typecheck": "tsgo --noEmit"
22
22
  },
23
23
  "dependencies": {
24
- "@astrale-os/sdk": "0.5.0-beta.38",
24
+ "@astrale-os/sdk": "0.5.0-beta.49",
25
25
  "@astrale-os/shell": "0.4.2-beta.3",
26
26
  "@dagrejs/dagre": "^3.0.0",
27
27
  "@radix-ui/react-collapsible": "^1.1.0",