@astrale-os/cli 0.8.1-alpha.2 → 0.8.1-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/astrale.js CHANGED
@@ -2576,7 +2576,7 @@ var package_default;
2576
2576
  var init_package = __esm(() => {
2577
2577
  package_default = {
2578
2578
  name: "@astrale-os/cli",
2579
- version: "0.8.1-alpha.2",
2579
+ version: "0.8.1-alpha.3",
2580
2580
  description: "Astrale CLI — connect to existing Astrale kernels",
2581
2581
  keywords: [
2582
2582
  "astrale",
@@ -90137,6 +90137,9 @@ var init_mutation2 = __esm(() => {
90137
90137
 
90138
90138
  // src/graph/mutation.ts
90139
90139
  function prepareMutation(input) {
90140
+ if (isLegacyPatchData(input)) {
90141
+ throw new TypeError("Legacy PatchData { nodes, edges } is not Mutation V3. Author { preconditions, operations } or a canonical astrale.graph.mutation/v3 document.");
90142
+ }
90140
90143
  if (isCanonicalCandidate(input))
90141
90144
  return MutationAST.decode(input);
90142
90145
  return MutationAST.create(input);
@@ -90144,6 +90147,9 @@ function prepareMutation(input) {
90144
90147
  function isCanonicalCandidate(input) {
90145
90148
  return input !== null && typeof input === "object" && !Array.isArray(input) && (Object.hasOwn(input, "format") || Object.hasOwn(input, "version"));
90146
90149
  }
90150
+ function isLegacyPatchData(input) {
90151
+ return input !== null && typeof input === "object" && !Array.isArray(input) && !Object.hasOwn(input, "operations") && (Object.hasOwn(input, "nodes") || Object.hasOwn(input, "edges"));
90152
+ }
90147
90153
  var init_mutation3 = __esm(() => {
90148
90154
  init_mutation2();
90149
90155
  });
@@ -90308,6 +90314,99 @@ var init_binary4 = __esm(() => {
90308
90314
  init_output();
90309
90315
  });
90310
90316
 
90317
+ // src/commands/call-describe.ts
90318
+ function describeCallableFromSchema(path5, schema2) {
90319
+ if (path5.ast.anchor.kind !== "domain")
90320
+ return;
90321
+ const origin = path5.ast.anchor.origin;
90322
+ const last = path5.ast.steps.at(-1);
90323
+ const document = asRecord2(schema2);
90324
+ if (document === undefined || last === undefined)
90325
+ return;
90326
+ if (last.kind === "method") {
90327
+ const owner = path5.ast.steps.at(-2);
90328
+ if (owner?.kind === "projection" && (owner.projection.kind === "class" || owner.projection.kind === "interface")) {
90329
+ const bag = asRecord2(owner.projection.kind === "class" ? document.classes : document.interfaces);
90330
+ const definition3 = asRecord2(bag?.[owner.projection.name]);
90331
+ const method = asRecord2(asRecord2(definition3?.methods)?.[last.name]);
90332
+ if (method !== undefined) {
90333
+ return Object.freeze({
90334
+ path: path5.raw,
90335
+ origin,
90336
+ class: owner.projection.name,
90337
+ method: last.name,
90338
+ dispatch: last.dispatch,
90339
+ ...callableFields(method)
90340
+ });
90341
+ }
90342
+ }
90343
+ const matches3 = findNamedMethods(document, origin, path5.raw, last.name, last.dispatch);
90344
+ if (matches3.length === 1)
90345
+ return matches3[0];
90346
+ if (matches3.length > 1) {
90347
+ return Object.freeze({
90348
+ path: path5.raw,
90349
+ origin,
90350
+ method: last.name,
90351
+ dispatch: last.dispatch,
90352
+ candidates: Object.freeze(matches3)
90353
+ });
90354
+ }
90355
+ return;
90356
+ }
90357
+ if (last.kind === "projection" && last.projection.kind === "function") {
90358
+ const fn2 = asRecord2(asRecord2(document.functions)?.[last.projection.name]);
90359
+ if (fn2 === undefined)
90360
+ return;
90361
+ return Object.freeze({
90362
+ path: path5.raw,
90363
+ origin,
90364
+ function: last.projection.name,
90365
+ ...callableFields(fn2)
90366
+ });
90367
+ }
90368
+ return;
90369
+ }
90370
+ function missingCallableDescription(path5) {
90371
+ return new AstraleError("CALL_DESCRIBE_UNAVAILABLE", `No callable schema is installed for ${path5}.`, "Use a Domain-rooted Path such as /:host.astrale.ai:class.Manager:createInstance. Method Paths are not Function nodes.");
90372
+ }
90373
+ function findNamedMethods(schema2, origin, path5, method, dispatch) {
90374
+ const matches3 = [];
90375
+ for (const bag of [schema2.classes, schema2.interfaces]) {
90376
+ const definitions2 = asRecord2(bag);
90377
+ if (definitions2 === undefined)
90378
+ continue;
90379
+ for (const [className, definition3] of Object.entries(definitions2)) {
90380
+ const methodDef = asRecord2(asRecord2(asRecord2(definition3)?.methods)?.[method]);
90381
+ if (methodDef === undefined)
90382
+ continue;
90383
+ matches3.push(Object.freeze({
90384
+ path: path5,
90385
+ origin,
90386
+ class: className,
90387
+ method,
90388
+ dispatch,
90389
+ ...callableFields(methodDef)
90390
+ }));
90391
+ }
90392
+ }
90393
+ return matches3;
90394
+ }
90395
+ function callableFields(value2) {
90396
+ return {
90397
+ ...typeof value2.description === "string" ? { description: value2.description } : {},
90398
+ ...value2.auth === undefined ? {} : { auth: value2.auth },
90399
+ ...value2.input === undefined ? {} : { input: value2.input },
90400
+ ...value2.output === undefined ? {} : { output: value2.output }
90401
+ };
90402
+ }
90403
+ function asRecord2(input) {
90404
+ return input !== null && typeof input === "object" && !Array.isArray(input) ? input : undefined;
90405
+ }
90406
+ var init_call_describe = __esm(() => {
90407
+ init_errors2();
90408
+ });
90409
+
90311
90410
  // src/commands/call.ts
90312
90411
  var exports_call = {};
90313
90412
  __export(exports_call, {
@@ -90374,28 +90473,20 @@ async function describeOperation(path5, opts) {
90374
90473
  await runKernelCommand({
90375
90474
  opts,
90376
90475
  label: `Schema for ${path5}`,
90377
- fn: async ({ graph }) => await graph.get(Path.parse(path5)),
90378
- format: (node4, fmtOpts) => {
90379
- const input = nodeProperty(node4, "inputSchema");
90380
- const outputSchema = nodeProperty(node4, "outputSchema");
90381
- const schema2 = {};
90382
- if (input)
90383
- schema2.input = tryParseJson(input);
90384
- if (outputSchema)
90385
- schema2.output = tryParseJson(outputSchema);
90386
- output(Object.keys(schema2).length > 0 ? schema2 : node4, fmtOpts);
90387
- }
90476
+ fn: async ({ graph }) => {
90477
+ const parsed = Path.parse(path5);
90478
+ if (parsed.ast.anchor.kind !== "domain") {
90479
+ throw missingCallableDescription(path5);
90480
+ }
90481
+ const domain3 = await graph.getOrThrow(Path.parse(`/:${parsed.ast.anchor.origin}`));
90482
+ const described = describeCallableFromSchema(parsed, nodeProperty(domain3, "schema"));
90483
+ if (described === undefined)
90484
+ throw missingCallableDescription(path5);
90485
+ return described;
90486
+ },
90487
+ format: (schema2, fmtOpts) => output(schema2, fmtOpts)
90388
90488
  });
90389
90489
  }
90390
- function tryParseJson(value2) {
90391
- if (typeof value2 !== "string")
90392
- return value2;
90393
- try {
90394
- return JSON.parse(value2);
90395
- } catch {
90396
- return value2;
90397
- }
90398
- }
90399
90490
  async function parseParams(rawParams, dataFlag) {
90400
90491
  if (dataFlag) {
90401
90492
  if (rawParams.length > 0) {
@@ -90471,6 +90562,7 @@ var init_call2 = __esm(() => {
90471
90562
  init_binary4();
90472
90563
  init_log();
90473
90564
  init_output();
90565
+ init_call_describe();
90474
90566
  PARAM_KEY_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
90475
90567
  call_default = {
90476
90568
  name: "call",
@@ -90490,16 +90582,19 @@ Self-reference:
90490
90582
  (e.g. via 'astrale get @self --json'). Resolution authenticates to
90491
90583
  the selected Kernel and never trusts a local registration or JWT sub.
90492
90584
 
90585
+ --describe reads the callable's input/output from the installed Domain
90586
+ schema. Method Paths are not Function nodes.
90587
+
90493
90588
  Examples:
90494
- $ astrale call /:host.astrale.ai:class.KernelInstance:list
90589
+ $ astrale call /:host.astrale.ai:class.Manager:createInstance --describe
90495
90590
  $ astrale call /:blog.acme.com:class.Author:list limit=10
90496
90591
  $ astrale call '@self::deactivate'
90497
- $ astrale call /:shell.astrale.ai:function.search-domains --json
90592
+ $ astrale call /:kernel.astrale.ai:function.journal --data '{"limit":5}' --json
90498
90593
  `,
90499
90594
  arguments: [
90500
90595
  {
90501
90596
  name: "path",
90502
- description: "Operation path (e.g., /:host.astrale.ai:class.KernelInstance:list or /node::method)"
90597
+ description: "Operation path (e.g., /:host.astrale.ai:class.Manager:createInstance or /node::method)"
90503
90598
  },
90504
90599
  { name: "params...", description: "Params as key=value pairs", required: false }
90505
90600
  ],
@@ -90597,7 +90692,7 @@ async function getCommand(target2, opts) {
90597
90692
  try {
90598
90693
  ({ path: path5, meta: meta3 } = await expandSelfInPath(target2, opts));
90599
90694
  } catch (error52) {
90600
- log.error(error52 instanceof Error ? error52.message : "Invalid target");
90695
+ await formatKernelError(error52, isMachine(opts), undefined, opts.debug);
90601
90696
  process.exit(1);
90602
90697
  }
90603
90698
  await runKernelCommand({
@@ -90611,7 +90706,7 @@ var get_default;
90611
90706
  var init_get = __esm(() => {
90612
90707
  init_path5();
90613
90708
  init_connection2();
90614
- init_log();
90709
+ init_errors12();
90615
90710
  init_output();
90616
90711
  get_default = {
90617
90712
  name: "get",
@@ -91173,24 +91268,37 @@ function printRecord(record10) {
91173
91268
  `);
91174
91269
  }
91175
91270
  function acceptRecord2(input, index) {
91176
- if (!isRecord8(input) || !Number.isSafeInteger(input.sequence) || typeof input.timestamp !== "string" || typeof input.topic !== "string") {
91271
+ if (!isRecord8(input) || !Number.isSafeInteger(input.sequence) || typeof input.topic !== "string") {
91177
91272
  throw new TypeError(`Kernel journal record ${index} is invalid`);
91178
91273
  }
91179
- for (const key of ["principal", "correlationId", "causationId"]) {
91180
- if (input[key] !== undefined && typeof input[key] !== "string") {
91181
- throw new TypeError(`Kernel journal record ${index}.${key} must be text`);
91182
- }
91274
+ const occurredAt = optionalText2(input.occurredAt, index, "occurredAt");
91275
+ const timestamp = optionalText2(input.timestamp, index, "timestamp") ?? occurredAt;
91276
+ if (timestamp === undefined) {
91277
+ throw new TypeError(`Kernel journal record ${index} is missing occurredAt/timestamp`);
91183
91278
  }
91279
+ const correlation = isRecord8(input.correlation) ? input.correlation : undefined;
91280
+ const correlationId = optionalText2(input.correlationId, index, "correlationId") ?? optionalText2(correlation?.invocationId, index, "correlation.invocationId");
91281
+ const principal = optionalText2(input.principal, index, "principal");
91184
91282
  return Object.freeze({
91185
91283
  sequence: input.sequence,
91186
- timestamp: input.timestamp,
91284
+ timestamp,
91187
91285
  topic: input.topic,
91188
91286
  payload: input.payload,
91189
- ...input.principal === undefined ? {} : { principal: input.principal },
91190
- ...input.correlationId === undefined ? {} : { correlationId: input.correlationId },
91191
- ...input.causationId === undefined ? {} : { causationId: input.causationId }
91287
+ ...occurredAt === undefined ? {} : { occurredAt },
91288
+ ...optionalText2(input.committedAt, index, "committedAt") === undefined ? {} : { committedAt: input.committedAt },
91289
+ ...principal === undefined ? {} : { principal },
91290
+ ...correlationId === undefined ? {} : { correlationId },
91291
+ ...optionalText2(input.causationId, index, "causationId") === undefined ? {} : { causationId: input.causationId }
91192
91292
  });
91193
91293
  }
91294
+ function optionalText2(input, index, field) {
91295
+ if (input === undefined)
91296
+ return;
91297
+ if (typeof input !== "string") {
91298
+ throw new TypeError(`Kernel journal record ${index}.${field} must be text`);
91299
+ }
91300
+ return input;
91301
+ }
91194
91302
  function positiveInteger3(flag, raw2) {
91195
91303
  if (!/^\d+$/.test(raw2))
91196
91304
  throw new TypeError(`${flag} must be a positive integer`);
@@ -92742,7 +92850,8 @@ Examples:
92742
92850
  var exports_list = {};
92743
92851
  __export(exports_list, {
92744
92852
  default: () => list_default,
92745
- buildInstanceRows: () => buildInstanceRows
92853
+ buildInstanceRows: () => buildInstanceRows,
92854
+ adminInventoryUnavailable: () => adminInventoryUnavailable
92746
92855
  });
92747
92856
  function buildInstanceRows(managed, bookmarks, show) {
92748
92857
  const rows = [];
@@ -92786,9 +92895,19 @@ function buildInstanceRows(managed, bookmarks, show) {
92786
92895
  }
92787
92896
  return rows;
92788
92897
  }
92789
- var COLUMNS, list_default;
92898
+ function adminInventoryUnavailable(cause) {
92899
+ const code = cause instanceof AstraleError ? cause.code : undefined;
92900
+ if (code !== undefined && ADMIN_INVENTORY_CODES.has(code)) {
92901
+ return new AstraleError("ADMIN_INVENTORY_UNAVAILABLE", "Managed instance listing needs the Admin Domain and an IdP-backed identity. Admin is not deployed in this environment.", "Use `astrale instance list --bookmarked` for local kernel bookmarks. Key-backed identities cannot mint an Admin Domain token.");
92902
+ }
92903
+ if (cause instanceof AstraleError)
92904
+ return cause;
92905
+ return new AstraleError("ADMIN_INVENTORY_UNAVAILABLE", cause instanceof Error ? cause.message : String(cause), "Use `astrale instance list --bookmarked` for local kernel bookmarks.");
92906
+ }
92907
+ var COLUMNS, list_default, ADMIN_INVENTORY_CODES;
92790
92908
  var init_list = __esm(() => {
92791
92909
  init_source();
92910
+ init_errors2();
92792
92911
  init_admin_instance();
92793
92912
  init_admin_instance();
92794
92913
  init_admin_target();
@@ -92823,7 +92942,11 @@ var init_list = __esm(() => {
92823
92942
  }));
92824
92943
  let managed = [];
92825
92944
  if (!opts.bookmarked) {
92826
- managed = await withSpinner("Fetching instances", !isMachine(opts), () => listOwnedInstances(opts));
92945
+ try {
92946
+ managed = await withSpinner("Fetching instances", !isMachine(opts), () => listOwnedInstances(opts));
92947
+ } catch (error52) {
92948
+ throw adminInventoryUnavailable(error52);
92949
+ }
92827
92950
  }
92828
92951
  if (isMachine(opts)) {
92829
92952
  output({
@@ -92848,6 +92971,15 @@ var init_list = __esm(() => {
92848
92971
  }
92849
92972
  }
92850
92973
  };
92974
+ ADMIN_INVENTORY_CODES = new Set([
92975
+ "TOKEN_EXCHANGE_SOURCE_INVALID",
92976
+ "TOKEN_EXCHANGE_SOURCE_EXPIRED",
92977
+ "TOKEN_EXCHANGE_UNSUPPORTED",
92978
+ "TOKEN_EXCHANGE_DISCOVERY_FAILED",
92979
+ "TOKEN_EXCHANGE_PROTOCOL_ERROR",
92980
+ "TOKEN_EXCHANGE_INSECURE",
92981
+ "ADMIN_DOMAIN_ISSUER_MISSING"
92982
+ ]);
92851
92983
  });
92852
92984
 
92853
92985
  // src/commands/instance/bookmark.ts
@@ -93591,8 +93723,23 @@ __export(exports_install, {
93591
93723
  isIdentityOverride: () => isIdentityOverride,
93592
93724
  installViaAdmin: () => installViaAdmin,
93593
93725
  domainRefFromTarget: () => domainRefFromTarget,
93726
+ directInstallCallInput: () => directInstallCallInput,
93594
93727
  default: () => install_default
93595
93728
  });
93729
+ function directInstallCallInput(url3, token, operation2 = crypto.randomUUID()) {
93730
+ return Object.freeze({
93731
+ operation: operation2,
93732
+ domains: [
93733
+ Object.freeze({
93734
+ source: Object.freeze({
93735
+ kind: "remote",
93736
+ url: url3,
93737
+ ...token === undefined ? {} : { token }
93738
+ })
93739
+ })
93740
+ ]
93741
+ });
93742
+ }
93596
93743
  async function installViaAdmin(target2, opts, dependencies = {}) {
93597
93744
  const admin = { ...defaultAdminInstallDependencies, ...dependencies };
93598
93745
  const interactive = !!process.stdin.isTTY && !(opts.ci || opts.noPrompt || process.env.CI);
@@ -93710,20 +93857,18 @@ async function installDirect(target2, opts) {
93710
93857
  await runKernelCommand({
93711
93858
  opts,
93712
93859
  label: `Installing domain from ${url3}`,
93713
- fn: async ({ session }) => await session.call(createPathCall(Path.project(exports_syscalls.install.ref).raw, {
93714
- domains: [{ url: url3, ...opts.token ? { token: opts.token } : {} }]
93715
- })),
93860
+ fn: async ({ session }) => await session.call(createPathCall(Path.project(exports_syscalls.install.ref).raw, directInstallCallInput(url3, opts.token))),
93716
93861
  format: (result, fmtOpts, isRaw) => {
93717
93862
  if (isRaw) {
93718
93863
  output(result, fmtOpts);
93719
93864
  return;
93720
93865
  }
93721
- const installed = result[0];
93722
- if (!installed)
93723
- throw new Error("Kernel install returned no installed Domain receipt.");
93724
- log.success(`Domain installed: ${installed.origin}@${installed.revision}`);
93725
- if (installed.etag)
93726
- log.dim(` publication: ${installed.etag}`);
93866
+ const installed = result.transitions[0]?.intent;
93867
+ if (installed === undefined) {
93868
+ throw new Error("Kernel install returned no committed Domain transition.");
93869
+ }
93870
+ const revision = installed.target?.schemaRevision ?? result.operation;
93871
+ log.success(`Domain installed: ${installed.origin}@${revision}`);
93727
93872
  if (isIdentityOverride(installed.origin, host) && installed.origin !== consentedOrigin) {
93728
93873
  log.warn(`Installed origin "${installed.origin}" differs from the serving host "${host}" ` + `and was not confirmed before install (the worker's /meta did not declare it). ` + `Every ${installed.origin}/* call on this instance now routes to ${host}.`);
93729
93874
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "0.8.1-alpha.2",
3
+ "version": "0.8.1-alpha.3",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -0,0 +1,67 @@
1
+ import { Path } from '@astrale-os/sdk/graph/path'
2
+ import { describe, expect, test } from 'bun:test'
3
+
4
+ import { describeCallableFromSchema } from '../call-describe'
5
+
6
+ const schema = {
7
+ classes: {
8
+ Manager: {
9
+ methods: {
10
+ createInstance: {
11
+ auth: 'authorized',
12
+ description: 'Create one child Instance.',
13
+ input: { type: 'object', required: ['operationId', 'slug'] },
14
+ output: { mode: 'value' },
15
+ },
16
+ },
17
+ },
18
+ },
19
+ functions: {
20
+ journal: {
21
+ description: 'Read the authorized Kernel journal.',
22
+ input: { type: 'object' },
23
+ },
24
+ },
25
+ }
26
+
27
+ describe('describeCallableFromSchema', () => {
28
+ test('reads a static Class method from the Domain schema', () => {
29
+ const described = describeCallableFromSchema(
30
+ Path.parse('/:host.astrale.ai:class.Manager:createInstance'),
31
+ schema,
32
+ )
33
+ expect(described).toMatchObject({
34
+ origin: 'host.astrale.ai',
35
+ class: 'Manager',
36
+ method: 'createInstance',
37
+ dispatch: 'static',
38
+ description: 'Create one child Instance.',
39
+ auth: 'authorized',
40
+ })
41
+ expect(described?.input).toMatchObject({ required: ['operationId', 'slug'] })
42
+ })
43
+
44
+ test('finds an instance method when the receiver Path is not a Class projection', () => {
45
+ expect(
46
+ describeCallableFromSchema(
47
+ Path.parse('/:host.astrale.ai:core.manager::createInstance'),
48
+ schema,
49
+ ),
50
+ ).toMatchObject({
51
+ origin: 'host.astrale.ai',
52
+ class: 'Manager',
53
+ method: 'createInstance',
54
+ dispatch: 'instance',
55
+ })
56
+ })
57
+
58
+ test('reads a standalone Function from the Domain schema', () => {
59
+ expect(
60
+ describeCallableFromSchema(Path.parse('/:kernel.astrale.ai:function.journal'), schema),
61
+ ).toMatchObject({
62
+ origin: 'kernel.astrale.ai',
63
+ function: 'journal',
64
+ description: 'Read the authorized Kernel journal.',
65
+ })
66
+ })
67
+ })
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { directInstallCallInput } from '../domain/install'
4
+
5
+ describe('directInstallCallInput', () => {
6
+ test('sends the current remote install syscall, not a legacy url list', () => {
7
+ expect(directInstallCallInput('https://tasks.example.test', 'secret', 'op-1')).toEqual({
8
+ operation: 'op-1',
9
+ domains: [
10
+ {
11
+ source: {
12
+ kind: 'remote',
13
+ url: 'https://tasks.example.test',
14
+ token: 'secret',
15
+ },
16
+ },
17
+ ],
18
+ })
19
+ })
20
+ })
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { AstraleError } from '../../errors'
4
+ import { adminInventoryUnavailable } from '../instance/list'
5
+
6
+ describe('adminInventoryUnavailable', () => {
7
+ test('turns key-backed Admin exchange failures into a bookmark-only instruction', () => {
8
+ const error = adminInventoryUnavailable(
9
+ new AstraleError(
10
+ 'TOKEN_EXCHANGE_SOURCE_INVALID',
11
+ 'The source identity credential has no valid expiration.',
12
+ ),
13
+ )
14
+ expect(error.code).toBe('ADMIN_INVENTORY_UNAVAILABLE')
15
+ expect(error.message).toContain('Admin is not deployed')
16
+ expect(error.hint).toContain('instance list --bookmarked')
17
+ })
18
+
19
+ test('turns a missing Admin Domain issuer into the same instruction', () => {
20
+ const error = adminInventoryUnavailable(
21
+ new AstraleError('ADMIN_DOMAIN_ISSUER_MISSING', 'no Domain issuer'),
22
+ )
23
+ expect(error.code).toBe('ADMIN_INVENTORY_UNAVAILABLE')
24
+ expect(error.hint).toContain('Key-backed identities cannot mint an Admin Domain token')
25
+ })
26
+ })
@@ -66,4 +66,27 @@ describe('acceptJournalPage', () => {
66
66
  expect(() => acceptJournalPage({ records: [{}] })).toThrow('record 0')
67
67
  expect(() => acceptJournalPage({ records: [], cursor: 7 })).toThrow('cursor')
68
68
  })
69
+
70
+ test('admits journal v2 records that use occurredAt instead of timestamp', () => {
71
+ const page = acceptJournalPage({
72
+ records: [
73
+ {
74
+ sequence: 10241,
75
+ topic: 'function.invoke',
76
+ occurredAt: '2026-08-19T16:51:10.049Z',
77
+ committedAt: '2026-08-19T16:51:10.070Z',
78
+ payload: { outcome: 'rejected' },
79
+ correlation: { invocationId: 'cf862a64-3aa1-4343-ba86-f9b516c4ff95' },
80
+ },
81
+ ],
82
+ })
83
+ expect(page.records[0]).toMatchObject({
84
+ sequence: 10241,
85
+ topic: 'function.invoke',
86
+ timestamp: '2026-08-19T16:51:10.049Z',
87
+ occurredAt: '2026-08-19T16:51:10.049Z',
88
+ committedAt: '2026-08-19T16:51:10.070Z',
89
+ correlationId: 'cf862a64-3aa1-4343-ba86-f9b516c4ff95',
90
+ })
91
+ })
69
92
  })
@@ -0,0 +1,131 @@
1
+ import type { Path } from '@astrale-os/sdk/graph/path'
2
+
3
+ import { AstraleError } from '../errors'
4
+
5
+ type JsonRecord = Record<string, unknown>
6
+
7
+ export interface CallableDescription {
8
+ readonly path: string
9
+ readonly origin: string
10
+ readonly method?: string
11
+ readonly function?: string
12
+ readonly class?: string
13
+ readonly dispatch?: 'static' | 'instance'
14
+ readonly description?: string
15
+ readonly auth?: unknown
16
+ readonly input?: unknown
17
+ readonly output?: unknown
18
+ readonly candidates?: readonly CallableDescription[]
19
+ }
20
+
21
+ /** Resolve one callable's input/output from an installed Domain schema document. */
22
+ export function describeCallableFromSchema(
23
+ path: Path,
24
+ schema: unknown,
25
+ ): CallableDescription | undefined {
26
+ if (path.ast.anchor.kind !== 'domain') return undefined
27
+ const origin = path.ast.anchor.origin
28
+ const last = path.ast.steps.at(-1)
29
+ const document = asRecord(schema)
30
+ if (document === undefined || last === undefined) return undefined
31
+
32
+ if (last.kind === 'method') {
33
+ const owner = path.ast.steps.at(-2)
34
+ if (
35
+ owner?.kind === 'projection' &&
36
+ (owner.projection.kind === 'class' || owner.projection.kind === 'interface')
37
+ ) {
38
+ const bag = asRecord(
39
+ owner.projection.kind === 'class' ? document.classes : document.interfaces,
40
+ )
41
+ const definition = asRecord(bag?.[owner.projection.name])
42
+ const method = asRecord(asRecord(definition?.methods)?.[last.name])
43
+ if (method !== undefined) {
44
+ return Object.freeze({
45
+ path: path.raw,
46
+ origin,
47
+ class: owner.projection.name,
48
+ method: last.name,
49
+ dispatch: last.dispatch,
50
+ ...callableFields(method),
51
+ })
52
+ }
53
+ }
54
+ const matches = findNamedMethods(document, origin, path.raw, last.name, last.dispatch)
55
+ if (matches.length === 1) return matches[0]
56
+ if (matches.length > 1) {
57
+ return Object.freeze({
58
+ path: path.raw,
59
+ origin,
60
+ method: last.name,
61
+ dispatch: last.dispatch,
62
+ candidates: Object.freeze(matches),
63
+ })
64
+ }
65
+ return undefined
66
+ }
67
+
68
+ if (last.kind === 'projection' && last.projection.kind === 'function') {
69
+ const fn = asRecord(asRecord(document.functions)?.[last.projection.name])
70
+ if (fn === undefined) return undefined
71
+ return Object.freeze({
72
+ path: path.raw,
73
+ origin,
74
+ function: last.projection.name,
75
+ ...callableFields(fn),
76
+ })
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ export function missingCallableDescription(path: string): AstraleError {
82
+ return new AstraleError(
83
+ 'CALL_DESCRIBE_UNAVAILABLE',
84
+ `No callable schema is installed for ${path}.`,
85
+ 'Use a Domain-rooted Path such as /:host.astrale.ai:class.Manager:createInstance. Method Paths are not Function nodes.',
86
+ )
87
+ }
88
+
89
+ function findNamedMethods(
90
+ schema: JsonRecord,
91
+ origin: string,
92
+ path: string,
93
+ method: string,
94
+ dispatch: 'static' | 'instance',
95
+ ): CallableDescription[] {
96
+ const matches: CallableDescription[] = []
97
+ for (const bag of [schema.classes, schema.interfaces]) {
98
+ const definitions = asRecord(bag)
99
+ if (definitions === undefined) continue
100
+ for (const [className, definition] of Object.entries(definitions)) {
101
+ const methodDef = asRecord(asRecord(asRecord(definition)?.methods)?.[method])
102
+ if (methodDef === undefined) continue
103
+ matches.push(
104
+ Object.freeze({
105
+ path,
106
+ origin,
107
+ class: className,
108
+ method,
109
+ dispatch,
110
+ ...callableFields(methodDef),
111
+ }),
112
+ )
113
+ }
114
+ }
115
+ return matches
116
+ }
117
+
118
+ function callableFields(value: JsonRecord): Partial<CallableDescription> {
119
+ return {
120
+ ...(typeof value.description === 'string' ? { description: value.description } : {}),
121
+ ...(value.auth === undefined ? {} : { auth: value.auth }),
122
+ ...(value.input === undefined ? {} : { input: value.input }),
123
+ ...(value.output === undefined ? {} : { output: value.output }),
124
+ }
125
+ }
126
+
127
+ function asRecord(input: unknown): JsonRecord | undefined {
128
+ return input !== null && typeof input === 'object' && !Array.isArray(input)
129
+ ? (input as JsonRecord)
130
+ : undefined
131
+ }
@@ -8,6 +8,7 @@ import { nodeProperty } from '../graph/index'
8
8
  import { presentBinary } from '../lib/binary'
9
9
  import { log } from '../lib/log'
10
10
  import { output, present } from '../lib/output'
11
+ import { describeCallableFromSchema, missingCallableDescription } from './call-describe'
11
12
 
12
13
  type CallOpts = KernelCommandOpts & {
13
14
  data?: string
@@ -97,28 +98,20 @@ async function describeOperation(path: string, opts: CallOpts): Promise<void> {
97
98
  await runKernelCommand({
98
99
  opts,
99
100
  label: `Schema for ${path}`,
100
- // The Function node carries the schemas as props (function.get depth:0).
101
- fn: async ({ graph }) => await graph.get(Path.parse(path)),
102
- format: (node, fmtOpts) => {
103
- const input = nodeProperty(node, 'inputSchema')
104
- const outputSchema = nodeProperty(node, 'outputSchema')
105
- const schema: Record<string, unknown> = {}
106
- if (input) schema.input = tryParseJson(input)
107
- if (outputSchema) schema.output = tryParseJson(outputSchema)
108
- output(Object.keys(schema).length > 0 ? schema : node, fmtOpts)
101
+ fn: async ({ graph }) => {
102
+ const parsed = Path.parse(path)
103
+ if (parsed.ast.anchor.kind !== 'domain') {
104
+ throw missingCallableDescription(path)
105
+ }
106
+ const domain = await graph.getOrThrow(Path.parse(`/:${parsed.ast.anchor.origin}`))
107
+ const described = describeCallableFromSchema(parsed, nodeProperty(domain, 'schema'))
108
+ if (described === undefined) throw missingCallableDescription(path)
109
+ return described
109
110
  },
111
+ format: (schema, fmtOpts) => output(schema, fmtOpts),
110
112
  })
111
113
  }
112
114
 
113
- function tryParseJson(value: unknown): unknown {
114
- if (typeof value !== 'string') return value
115
- try {
116
- return JSON.parse(value)
117
- } catch {
118
- return value
119
- }
120
- }
121
-
122
115
  // ── Param parsing ───────────────────────────────────────────
123
116
 
124
117
  export async function parseParams(
@@ -221,17 +214,20 @@ Self-reference:
221
214
  (e.g. via 'astrale get @self --json'). Resolution authenticates to
222
215
  the selected Kernel and never trusts a local registration or JWT sub.
223
216
 
217
+ --describe reads the callable's input/output from the installed Domain
218
+ schema. Method Paths are not Function nodes.
219
+
224
220
  Examples:
225
- $ astrale call /:host.astrale.ai:class.KernelInstance:list
221
+ $ astrale call /:host.astrale.ai:class.Manager:createInstance --describe
226
222
  $ astrale call /:blog.acme.com:class.Author:list limit=10
227
223
  $ astrale call '@self::deactivate'
228
- $ astrale call /:shell.astrale.ai:function.search-domains --json
224
+ $ astrale call /:kernel.astrale.ai:function.journal --data '{"limit":5}' --json
229
225
  `,
230
226
  arguments: [
231
227
  {
232
228
  name: 'path',
233
229
  description:
234
- 'Operation path (e.g., /:host.astrale.ai:class.KernelInstance:list or /node::method)',
230
+ 'Operation path (e.g., /:host.astrale.ai:class.Manager:createInstance or /node::method)',
235
231
  },
236
232
  { name: 'params...', description: 'Params as key=value pairs', required: false },
237
233
  ],
@@ -20,12 +20,35 @@ import { isMachine, output } from '../../lib/output'
20
20
  import { confirmWithInput, promptText, selectFrom } from '../../lib/prompt'
21
21
  import { isHttpUrl } from '../../lib/validation'
22
22
 
23
- /** Exact output of the public Kernel install syscall for one requested root. */
24
- type DirectInstallResult = readonly {
25
- origin: string
26
- revision: string
27
- etag?: string
28
- }[]
23
+ /** Public Kernel install syscall input for one remote URL. */
24
+ export function directInstallCallInput(
25
+ url: string,
26
+ token?: string,
27
+ operation: string = crypto.randomUUID(),
28
+ ) {
29
+ return Object.freeze({
30
+ operation,
31
+ domains: [
32
+ Object.freeze({
33
+ source: Object.freeze({
34
+ kind: 'remote' as const,
35
+ url,
36
+ ...(token === undefined ? {} : { token }),
37
+ }),
38
+ }),
39
+ ],
40
+ })
41
+ }
42
+
43
+ type DirectInstallResult = {
44
+ readonly operation: string
45
+ readonly transitions: readonly {
46
+ readonly intent: {
47
+ readonly origin: string
48
+ readonly target?: { readonly schemaRevision?: string } | null
49
+ }
50
+ }[]
51
+ }
29
52
 
30
53
  type InstallOpts = KernelCommandOpts &
31
54
  AdminTargetCommandOpts & {
@@ -330,19 +353,22 @@ async function installDirect(target: string | undefined, opts: InstallOpts): Pro
330
353
  label: `Installing domain from ${url}`,
331
354
  fn: async ({ session }) =>
332
355
  (await session.call(
333
- createPathCall(Path.project(syscalls.install.ref).raw, {
334
- domains: [{ url, ...(opts.token ? { token: opts.token } : {}) }],
335
- }),
356
+ createPathCall(
357
+ Path.project(syscalls.install.ref).raw,
358
+ directInstallCallInput(url, opts.token),
359
+ ),
336
360
  )) as DirectInstallResult,
337
361
  format: (result, fmtOpts, isRaw) => {
338
362
  if (isRaw) {
339
363
  output(result, fmtOpts)
340
364
  return
341
365
  }
342
- const installed = result[0]
343
- if (!installed) throw new Error('Kernel install returned no installed Domain receipt.')
344
- log.success(`Domain installed: ${installed.origin}@${installed.revision}`)
345
- if (installed.etag) log.dim(` publication: ${installed.etag}`)
366
+ const installed = result.transitions[0]?.intent
367
+ if (installed === undefined) {
368
+ throw new Error('Kernel install returned no committed Domain transition.')
369
+ }
370
+ const revision = installed.target?.schemaRevision ?? result.operation
371
+ log.success(`Domain installed: ${installed.origin}@${revision}`)
346
372
  // Belt-and-braces: the kernel-confirmed origin is authoritative. If it
347
373
  // aliases the host and the pre-install gate never consented to THAT
348
374
  // origin (lying or absent `/meta`), say so loudly after the fact.
@@ -4,8 +4,8 @@ import type { KernelCommandOpts } from '../connection'
4
4
  import type { CommandDefinition } from '../program/index'
5
5
 
6
6
  import { expandSelfInPath, runKernelCommand, withSelfHint } from '../connection'
7
- import { log } from '../lib/log'
8
- import { output } from '../lib/output'
7
+ import { formatKernelError } from '../connection/errors'
8
+ import { isMachine, output } from '../lib/output'
9
9
 
10
10
  type GetOpts = KernelCommandOpts
11
11
 
@@ -16,7 +16,7 @@ export async function getCommand(target: string, opts: GetOpts): Promise<void> {
16
16
  try {
17
17
  ;({ path, meta } = await expandSelfInPath(target, opts))
18
18
  } catch (error) {
19
- log.error(error instanceof Error ? error.message : 'Invalid target')
19
+ await formatKernelError(error, isMachine(opts), undefined, opts.debug)
20
20
  process.exit(1)
21
21
  }
22
22
 
@@ -4,6 +4,7 @@ import type { KernelCommandOpts } from '../../connection'
4
4
  import type { Column } from '../../lib/output'
5
5
  import type { CommandDefinition } from '../../program/index'
6
6
 
7
+ import { AstraleError } from '../../errors'
7
8
  import { listOwnedInstances } from '../../lib/admin-instance'
8
9
  import {
9
10
  formatInstanceLocation,
@@ -61,9 +62,13 @@ export default {
61
62
 
62
63
  let managed: OwnedInstanceInfo[] = []
63
64
  if (!opts.bookmarked) {
64
- managed = await withSpinner('Fetching instances', !isMachine(opts), () =>
65
- listOwnedInstances(opts),
66
- )
65
+ try {
66
+ managed = await withSpinner('Fetching instances', !isMachine(opts), () =>
67
+ listOwnedInstances(opts),
68
+ )
69
+ } catch (error) {
70
+ throw adminInventoryUnavailable(error)
71
+ }
67
72
  }
68
73
  if (isMachine(opts)) {
69
74
  output(
@@ -148,3 +153,31 @@ export function buildInstanceRows(
148
153
 
149
154
  return rows
150
155
  }
156
+
157
+ const ADMIN_INVENTORY_CODES = new Set([
158
+ 'TOKEN_EXCHANGE_SOURCE_INVALID',
159
+ 'TOKEN_EXCHANGE_SOURCE_EXPIRED',
160
+ 'TOKEN_EXCHANGE_UNSUPPORTED',
161
+ 'TOKEN_EXCHANGE_DISCOVERY_FAILED',
162
+ 'TOKEN_EXCHANGE_PROTOCOL_ERROR',
163
+ 'TOKEN_EXCHANGE_INSECURE',
164
+ 'ADMIN_DOMAIN_ISSUER_MISSING',
165
+ ])
166
+
167
+ /** Admin-managed inventory is not available without a deployed Admin Domain + IdP identity. */
168
+ export function adminInventoryUnavailable(cause: unknown): AstraleError {
169
+ const code = cause instanceof AstraleError ? cause.code : undefined
170
+ if (code !== undefined && ADMIN_INVENTORY_CODES.has(code)) {
171
+ return new AstraleError(
172
+ 'ADMIN_INVENTORY_UNAVAILABLE',
173
+ 'Managed instance listing needs the Admin Domain and an IdP-backed identity. Admin is not deployed in this environment.',
174
+ 'Use `astrale instance list --bookmarked` for local kernel bookmarks. Key-backed identities cannot mint an Admin Domain token.',
175
+ )
176
+ }
177
+ if (cause instanceof AstraleError) return cause
178
+ return new AstraleError(
179
+ 'ADMIN_INVENTORY_UNAVAILABLE',
180
+ cause instanceof Error ? cause.message : String(cause),
181
+ 'Use `astrale instance list --bookmarked` for local kernel bookmarks.',
182
+ )
183
+ }
@@ -29,6 +29,8 @@ export interface JournalRecord {
29
29
  readonly timestamp: string
30
30
  readonly topic: string
31
31
  readonly payload: unknown
32
+ readonly occurredAt?: string
33
+ readonly committedAt?: string
32
34
  readonly principal?: string
33
35
  readonly correlationId?: string
34
36
  readonly causationId?: string
@@ -146,27 +148,45 @@ function acceptRecord(input: unknown, index: number): JournalRecord {
146
148
  if (
147
149
  !isRecord(input) ||
148
150
  !Number.isSafeInteger(input.sequence) ||
149
- typeof input.timestamp !== 'string' ||
150
151
  typeof input.topic !== 'string'
151
152
  ) {
152
153
  throw new TypeError(`Kernel journal record ${index} is invalid`)
153
154
  }
154
- for (const key of ['principal', 'correlationId', 'causationId'] as const) {
155
- if (input[key] !== undefined && typeof input[key] !== 'string') {
156
- throw new TypeError(`Kernel journal record ${index}.${key} must be text`)
157
- }
155
+ const occurredAt = optionalText(input.occurredAt, index, 'occurredAt')
156
+ const timestamp = optionalText(input.timestamp, index, 'timestamp') ?? occurredAt
157
+ if (timestamp === undefined) {
158
+ throw new TypeError(`Kernel journal record ${index} is missing occurredAt/timestamp`)
158
159
  }
160
+ const correlation = isRecord(input.correlation) ? input.correlation : undefined
161
+ const correlationId =
162
+ optionalText(input.correlationId, index, 'correlationId') ??
163
+ optionalText(correlation?.invocationId, index, 'correlation.invocationId')
164
+ const principal = optionalText(input.principal, index, 'principal')
159
165
  return Object.freeze({
160
166
  sequence: input.sequence as number,
161
- timestamp: input.timestamp,
167
+ timestamp,
162
168
  topic: input.topic,
163
169
  payload: input.payload,
164
- ...(input.principal === undefined ? {} : { principal: input.principal as string }),
165
- ...(input.correlationId === undefined ? {} : { correlationId: input.correlationId as string }),
166
- ...(input.causationId === undefined ? {} : { causationId: input.causationId as string }),
170
+ ...(occurredAt === undefined ? {} : { occurredAt }),
171
+ ...(optionalText(input.committedAt, index, 'committedAt') === undefined
172
+ ? {}
173
+ : { committedAt: input.committedAt as string }),
174
+ ...(principal === undefined ? {} : { principal }),
175
+ ...(correlationId === undefined ? {} : { correlationId }),
176
+ ...(optionalText(input.causationId, index, 'causationId') === undefined
177
+ ? {}
178
+ : { causationId: input.causationId as string }),
167
179
  })
168
180
  }
169
181
 
182
+ function optionalText(input: unknown, index: number, field: string): string | undefined {
183
+ if (input === undefined) return undefined
184
+ if (typeof input !== 'string') {
185
+ throw new TypeError(`Kernel journal record ${index}.${field} must be text`)
186
+ }
187
+ return input
188
+ }
189
+
170
190
  function positiveInteger(flag: string, raw: string): number {
171
191
  if (!/^\d+$/.test(raw)) throw new TypeError(`${flag} must be a positive integer`)
172
192
  const value = Number.parseInt(raw, 10)
@@ -38,6 +38,8 @@ describe('prepareMutation', () => {
38
38
 
39
39
  /** @evidence TEST-CLI-GRAPH-REJECTS-PATCH-DATA */
40
40
  test('rejects legacy PatchData instead of narrowing it', () => {
41
- expect(() => prepareMutation({ nodes: { create: [] }, edges: { create: [] } })).toThrow()
41
+ expect(() => prepareMutation({ nodes: { create: [] }, edges: { create: [] } })).toThrow(
42
+ /Legacy PatchData/,
43
+ )
42
44
  })
43
45
  })
@@ -4,6 +4,11 @@ import { MutationAST } from '@astrale-os/sdk/mutation'
4
4
 
5
5
  /** Admit the canonical document or Core's exact rich authoring input at the CLI JSON boundary. */
6
6
  export function prepareMutation(input: unknown): MutationASTValue {
7
+ if (isLegacyPatchData(input)) {
8
+ throw new TypeError(
9
+ 'Legacy PatchData { nodes, edges } is not Mutation V3. Author { preconditions, operations } or a canonical astrale.graph.mutation/v3 document.',
10
+ )
11
+ }
7
12
  if (isCanonicalCandidate(input)) return MutationAST.decode(input)
8
13
  return MutationAST.create(input as MutationInput)
9
14
  }
@@ -16,3 +21,13 @@ function isCanonicalCandidate(input: unknown): boolean {
16
21
  (Object.hasOwn(input, 'format') || Object.hasOwn(input, 'version'))
17
22
  )
18
23
  }
24
+
25
+ function isLegacyPatchData(input: unknown): boolean {
26
+ return (
27
+ input !== null &&
28
+ typeof input === 'object' &&
29
+ !Array.isArray(input) &&
30
+ !Object.hasOwn(input, 'operations') &&
31
+ (Object.hasOwn(input, 'nodes') || Object.hasOwn(input, 'edges'))
32
+ )
33
+ }
@@ -187,7 +187,7 @@ describe('program composition', () => {
187
187
  'whoami',
188
188
  ])
189
189
  expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
190
- 'e030c6f672dea850a90bef16cf4222ea6af45e60f50cfc829c85bd90d9543632',
190
+ '4a57bfc347f9abebcea239427214eb25fbaf9ca704170c8b4a933d0c9ba4d14e',
191
191
  )
192
192
  })
193
193