@astrale-os/cli 0.8.1-alpha.2 → 0.8.1-alpha.4
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/README.md +1 -1
- package/dist/astrale.js +206 -160
- package/package.json +1 -1
- package/src/commands/__tests__/call-describe.test.ts +67 -0
- package/src/commands/__tests__/install-direct.test.ts +20 -0
- package/src/commands/__tests__/instance-list-admin.test.ts +26 -0
- package/src/commands/__tests__/logs.test.ts +23 -0
- package/src/commands/__tests__/read-commands.test.ts +0 -12
- package/src/commands/call-describe.ts +131 -0
- package/src/commands/call.ts +17 -21
- package/src/commands/domain/install.ts +39 -13
- package/src/commands/domain/list.ts +1 -1
- package/src/commands/get.ts +3 -3
- package/src/commands/instance/list.ts +36 -3
- package/src/commands/logs.ts +29 -9
- package/src/graph/__tests__/mutation.test.ts +3 -1
- package/src/graph/mutation.ts +15 -0
- package/src/lib/__tests__/command-dx.test.ts +13 -1
- package/src/lib/command-dx.ts +12 -0
- package/src/program/__tests__/program.test.ts +3 -3
- package/src/program/build.ts +1 -3
- package/src/telemetry/__tests__/gate.test.ts +1 -1
- package/src/commands/__tests__/ls.test.ts +0 -29
- package/src/commands/ls.ts +0 -125
|
@@ -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
|
})
|
|
@@ -218,18 +218,6 @@ describe('get command', () => {
|
|
|
218
218
|
})
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
-
describe('ls command', () => {
|
|
222
|
-
/** @evidence TEST-CLI-LS-REQUIRES-EXACT-EDGE-BEFORE-CONNECTION */
|
|
223
|
-
test('rejects a generic child listing before opening the command connection', async () => {
|
|
224
|
-
const { lsCommand } = await import('../ls')
|
|
225
|
-
|
|
226
|
-
await expect(lsCommand('@node', { json: true })).rejects.toEqual(new ExitError(1))
|
|
227
|
-
|
|
228
|
-
expect(runKernelCommandMock).not.toHaveBeenCalled()
|
|
229
|
-
expect(errors.join('\n')).toContain('Kernel V2 has no universal child relation')
|
|
230
|
-
})
|
|
231
|
-
})
|
|
232
|
-
|
|
233
221
|
describe('mutate command', () => {
|
|
234
222
|
/** @evidence TEST-CLI-MUTATE-DISPATCHES-CANONICAL-V3 */
|
|
235
223
|
test('admits authoring input and dispatches one canonical Mutation V3 document', async () => {
|
|
@@ -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
|
+
}
|
package/src/commands/call.ts
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
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.
|
|
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 /:
|
|
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.
|
|
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
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
334
|
-
|
|
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 (
|
|
344
|
-
|
|
345
|
-
|
|
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.
|
|
@@ -66,7 +66,7 @@ Behavior:
|
|
|
66
66
|
Reads the admin catalog — every domain that has been \`publish\`ed
|
|
67
67
|
(origin → published worker URL). Listing only shows what is INSTALLABLE;
|
|
68
68
|
what is actually mounted where lives on each instance's own graph
|
|
69
|
-
(\`astrale
|
|
69
|
+
(\`astrale query\` against that instance).
|
|
70
70
|
|
|
71
71
|
Default output is a NAME/ORIGIN/URL/DEFAULT table on a TTY, JSON when piped
|
|
72
72
|
or with --json/--raw (agent-friendly — full DomainInfo objects). -q prints
|
package/src/commands/get.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
+
}
|
package/src/commands/logs.ts
CHANGED
|
@@ -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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
167
|
+
timestamp,
|
|
162
168
|
topic: input.topic,
|
|
163
169
|
payload: input.payload,
|
|
164
|
-
...(
|
|
165
|
-
...(input.
|
|
166
|
-
|
|
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
|
})
|
package/src/graph/mutation.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -22,8 +22,9 @@ describe('command DX suggestions', () => {
|
|
|
22
22
|
expect(usages).toContain('admin use [bookmark]')
|
|
23
23
|
expect(usages).toContain('use <name>')
|
|
24
24
|
expect(usages).toContain('update')
|
|
25
|
-
expect(usages).toContain('
|
|
25
|
+
expect(usages).toContain('query [sources...]')
|
|
26
26
|
expect(usages).toContain('status')
|
|
27
|
+
expect(usages).not.toContain('ls <source>')
|
|
27
28
|
})
|
|
28
29
|
|
|
29
30
|
test('explains shared verbs as namespaced commands, not arity errors', async () => {
|
|
@@ -42,6 +43,17 @@ describe('command DX suggestions', () => {
|
|
|
42
43
|
expect(rendered).not.toContain('too many arguments')
|
|
43
44
|
})
|
|
44
45
|
|
|
46
|
+
test('points retired ls at query', async () => {
|
|
47
|
+
const program = await buildProgram()
|
|
48
|
+
const error = new CommanderError(1, 'commander.unknownCommand', "error: unknown command 'ls'")
|
|
49
|
+
const rendered = stripAnsi(renderCommanderError(program, error, ['ls', '@note']))
|
|
50
|
+
|
|
51
|
+
expect(rendered).toContain('Unknown command: astrale ls @note')
|
|
52
|
+
expect(rendered).toContain('astrale ls` was removed')
|
|
53
|
+
expect(rendered).toContain('astrale query <source> --edge <class>')
|
|
54
|
+
expect(rendered).not.toContain('Did you mean:')
|
|
55
|
+
})
|
|
56
|
+
|
|
45
57
|
test('suggests nearest command for typo paths', async () => {
|
|
46
58
|
const program = await buildProgram()
|
|
47
59
|
const error = new CommanderError(
|
package/src/lib/command-dx.ts
CHANGED
|
@@ -72,9 +72,21 @@ export function renderCommanderError(
|
|
|
72
72
|
].join('\n')
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
const RETIRED_COMMANDS: Record<string, string> = {
|
|
76
|
+
ls: 'astrale query <source> --edge <class>',
|
|
77
|
+
}
|
|
78
|
+
|
|
75
79
|
function renderUnknownCommand(tokens: string[], catalog: CommandCatalogEntry[]): string {
|
|
76
80
|
const command = tokens.join(' ')
|
|
77
81
|
const first = tokens[0]
|
|
82
|
+
const retired = first === undefined ? undefined : RETIRED_COMMANDS[first]
|
|
83
|
+
if (retired !== undefined) {
|
|
84
|
+
return [
|
|
85
|
+
`Unknown command: ${chalk.bold(`astrale ${command}`)}`,
|
|
86
|
+
'',
|
|
87
|
+
`\`astrale ${first}\` was removed. Use ${chalk.bold(retired)} instead.`,
|
|
88
|
+
].join('\n')
|
|
89
|
+
}
|
|
78
90
|
const namespaceMatches = catalog.filter((entry) => entry.path.at(-1) === first)
|
|
79
91
|
if (namespaceMatches.length > 0) {
|
|
80
92
|
return [
|
|
@@ -171,7 +171,6 @@ describe('program composition', () => {
|
|
|
171
171
|
'instance status',
|
|
172
172
|
'instance use',
|
|
173
173
|
'logs',
|
|
174
|
-
'ls',
|
|
175
174
|
'mutate',
|
|
176
175
|
'query',
|
|
177
176
|
'session',
|
|
@@ -187,7 +186,7 @@ describe('program composition', () => {
|
|
|
187
186
|
'whoami',
|
|
188
187
|
])
|
|
189
188
|
expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
|
|
190
|
-
'
|
|
189
|
+
'4b053fe80827aeb40aecc13cd17c293b8701e59cb04f5ccb8185e9e255376dd1',
|
|
191
190
|
)
|
|
192
191
|
})
|
|
193
192
|
|
|
@@ -267,7 +266,7 @@ describe('help contract — connect-only command surface', () => {
|
|
|
267
266
|
const program = await buildProgram()
|
|
268
267
|
const names = allCommands(program).map((command) => command.name())
|
|
269
268
|
|
|
270
|
-
// `logs` and `domain` have managed/admin meanings, so only retired
|
|
269
|
+
// `logs` and `domain` have managed/admin meanings, so only retired verbs are absent.
|
|
271
270
|
for (const removed of [
|
|
272
271
|
'init',
|
|
273
272
|
'start',
|
|
@@ -279,6 +278,7 @@ describe('help contract — connect-only command surface', () => {
|
|
|
279
278
|
'graph',
|
|
280
279
|
'server',
|
|
281
280
|
'env',
|
|
281
|
+
'ls',
|
|
282
282
|
]) {
|
|
283
283
|
expect(names).not.toContain(removed)
|
|
284
284
|
}
|
package/src/program/build.ts
CHANGED
|
@@ -61,7 +61,6 @@ export async function buildProgram(): Promise<Command> {
|
|
|
61
61
|
registerCommand(program, withKernelOptions((await import('../commands/token')).default))
|
|
62
62
|
registerCommand(program, withKernelOptions((await import('../commands/get')).default))
|
|
63
63
|
registerCommand(program, withKernelOptions((await import('../commands/mutate')).default))
|
|
64
|
-
registerCommand(program, withKernelOptions((await import('../commands/ls')).default))
|
|
65
64
|
registerCommand(program, withKernelOptions((await import('../commands/describe')).default))
|
|
66
65
|
registerCommand(program, withKernelOptions((await import('../commands/query')).default))
|
|
67
66
|
registerCommand(program, withKernelOptions((await import('../commands/logs')).default))
|
|
@@ -159,7 +158,7 @@ export async function buildProgram(): Promise<Command> {
|
|
|
159
158
|
`
|
|
160
159
|
Command groups:
|
|
161
160
|
Getting started setup (sign in, pick an instance, equip your workspace)
|
|
162
|
-
Kernel
|
|
161
|
+
Kernel get, mutate, call, query, describe, token
|
|
163
162
|
Management admin, instance, domain, identity, auth, idp, update
|
|
164
163
|
Agent browser (drive the GUI via agent-browser)
|
|
165
164
|
Studio studio (launch the local Domain Studio GUI for a workspace)
|
|
@@ -173,7 +172,6 @@ Path syntax:
|
|
|
173
172
|
@nodeId::method Instance method on a node by UID
|
|
174
173
|
|
|
175
174
|
Examples:
|
|
176
|
-
$ astrale ls @note --edge /:notes.example.dev:class.references
|
|
177
175
|
$ astrale studio
|
|
178
176
|
$ astrale admin status
|
|
179
177
|
$ astrale update --check
|
|
@@ -49,7 +49,7 @@ describe('extractSignals / hasSignals', () => {
|
|
|
49
49
|
})
|
|
50
50
|
|
|
51
51
|
test('quiet session has no signals until a transcript is attached', () => {
|
|
52
|
-
const s = extractSignals([ev(['status'], 0), ev(['
|
|
52
|
+
const s = extractSignals([ev(['status'], 0), ev(['query', '/'], 0)])
|
|
53
53
|
expect(hasSignals(s)).toBe(false)
|
|
54
54
|
s.harnessSessions = [
|
|
55
55
|
{
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import type { Node } from '@astrale-os/sdk/graph/node'
|
|
2
|
-
|
|
3
|
-
import { ClassPath } from '@astrale-os/sdk/graph/class'
|
|
4
|
-
import { NodeId } from '@astrale-os/sdk/graph/node'
|
|
5
|
-
import { normalizeProperties } from '@astrale-os/sdk/graph/properties'
|
|
6
|
-
import { describe, expect, test } from 'bun:test'
|
|
7
|
-
|
|
8
|
-
import { displayName, listProjection } from '../ls'
|
|
9
|
-
|
|
10
|
-
describe('ls display projection', () => {
|
|
11
|
-
const node = {
|
|
12
|
-
id: NodeId('note-1'),
|
|
13
|
-
class: ClassPath.parse('/:notes.example.dev:class.Note'),
|
|
14
|
-
props: normalizeProperties({ 'notes.example.dev:class.Note.property.title': 'Hello' }),
|
|
15
|
-
} satisfies Node
|
|
16
|
-
|
|
17
|
-
/** @evidence TEST-CLI-LS-PROJECTS-CANONICAL-NODES */
|
|
18
|
-
test('uses canonical properties for display and @id for pipeable output', () => {
|
|
19
|
-
expect(displayName(node)).toBe('Hello')
|
|
20
|
-
expect(listProjection([node])).toMatchObject({
|
|
21
|
-
rows: [{ name: 'Hello', class: 'Note', id: 'note-1' }],
|
|
22
|
-
paths: ['@note-1'],
|
|
23
|
-
})
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
test('falls back to the canonical Node ID when no display property exists', () => {
|
|
27
|
-
expect(displayName({ ...node, props: normalizeProperties({}) })).toBe('@note-1')
|
|
28
|
-
})
|
|
29
|
-
})
|