@astrale-os/cli 0.4.0-alpha.13 → 0.5.0-alpha.0
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/LICENSE +202 -0
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +27 -0
- package/dist/astrale.js +8687 -6398
- package/package.json +19 -21
- package/src/commands/__tests__/help-contract.test.ts +27 -14
- package/src/commands/__tests__/install-identity-override.test.ts +2 -2
- package/src/commands/__tests__/ls.test.ts +1 -1
- package/src/commands/__tests__/read-commands.test.ts +201 -0
- package/src/commands/call.ts +27 -44
- package/src/commands/describe.ts +57 -58
- package/src/commands/domain/install.ts +4 -4
- package/src/commands/get.ts +48 -23
- package/src/commands/identity/register.ts +27 -33
- package/src/commands/logs.ts +8 -8
- package/src/commands/ls.ts +77 -55
- package/src/commands/mutate.ts +191 -0
- package/src/commands/query.ts +288 -20
- package/src/commands/token.ts +4 -7
- package/src/kernel/__tests__/expand.test.ts +123 -0
- package/src/kernel/client.ts +3 -13
- package/src/kernel/expand.ts +52 -59
- package/src/kernel/graph.ts +96 -0
- package/src/kernel/index.ts +11 -2
- package/src/kernel/run.ts +0 -13
- package/src/lib/__tests__/table.test.ts +1 -1
- package/src/lib/admin-domain.ts +3 -3
- package/src/lib/domain-identity.ts +1 -1
- package/src/lib/self.ts +1 -3
- package/src/program.ts +5 -3
- package/src/setup/render.ts +1 -3
- package/studio/client/dist/assets/index-BcejyJpa.css +1 -0
- package/studio/client/dist/assets/index-Cqz3Oy_B.js +179 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.ts +5 -1
- package/studio/server/agent/claude.ts +15 -3
- package/studio/server/api.ts +7 -7
- package/studio/server/introspect/overlay-tsmorph.ts +108 -50
- package/studio/server/state/harness-gateway.ts +12 -3
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/visibility.ts +5 -1
- package/src/kernel/__tests__/remote-routing.test.ts +0 -70
- package/src/kernel/remote-routing.ts +0 -88
- package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
- package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
- package/studio/tsconfig.json +0 -23
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrale-os/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-alpha.0",
|
|
4
4
|
"description": "Astrale CLI — connect to existing Astrale kernels",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astrale",
|
|
7
7
|
"cli"
|
|
8
8
|
],
|
|
9
|
-
"license": "
|
|
9
|
+
"license": "Apache-2.0",
|
|
10
10
|
"author": "Astrale",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"studio/server",
|
|
24
24
|
"studio/shared",
|
|
25
25
|
"studio/client/dist",
|
|
26
|
-
"studio/package.json"
|
|
26
|
+
"studio/package.json",
|
|
27
|
+
"THIRD-PARTY-NOTICES.md"
|
|
27
28
|
],
|
|
28
29
|
"type": "module",
|
|
29
30
|
"exports": {
|
|
@@ -34,22 +35,9 @@
|
|
|
34
35
|
"access": "public",
|
|
35
36
|
"registry": "https://registry.npmjs.org"
|
|
36
37
|
},
|
|
37
|
-
"scripts": {
|
|
38
|
-
"preinstall": "node .check-workspace.cjs",
|
|
39
|
-
"prepare": "husky",
|
|
40
|
-
"build": "bun scripts/build.ts",
|
|
41
|
-
"prepack": "bun scripts/build.ts",
|
|
42
|
-
"typecheck": "tsgo --noEmit",
|
|
43
|
-
"lint": "oxlint .",
|
|
44
|
-
"lint:fix": "oxlint --fix .",
|
|
45
|
-
"format": "pnpm exec oxfmt --write .",
|
|
46
|
-
"format:check": "pnpm exec oxfmt --check .",
|
|
47
|
-
"test": "bun test src",
|
|
48
|
-
"test:watch": "bun test --watch src"
|
|
49
|
-
},
|
|
50
38
|
"dependencies": {
|
|
51
|
-
"@astrale-os/kernel-client": ">=0.
|
|
52
|
-
"@astrale-os/kernel-core": ">=0.
|
|
39
|
+
"@astrale-os/kernel-client": ">=0.3.1 <1.0.0",
|
|
40
|
+
"@astrale-os/kernel-core": ">=0.7.0 <1.0.0",
|
|
53
41
|
"@inquirer/prompts": "^8.5.2",
|
|
54
42
|
"chalk": "^5.6.2",
|
|
55
43
|
"chokidar": "^4.0.3",
|
|
@@ -62,7 +50,7 @@
|
|
|
62
50
|
},
|
|
63
51
|
"devDependencies": {
|
|
64
52
|
"@astrale-os/ox": ">=0.1.0 <1.0.0",
|
|
65
|
-
"@astrale/commitlint-config": "jsr
|
|
53
|
+
"@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.1",
|
|
66
54
|
"@commitlint/cli": "~20.3.1",
|
|
67
55
|
"@commitlint/config-conventional": "~20.3.1",
|
|
68
56
|
"@types/bun": "^1.1.16",
|
|
@@ -86,5 +74,15 @@
|
|
|
86
74
|
"engines": {
|
|
87
75
|
"node": ">=20"
|
|
88
76
|
},
|
|
89
|
-
"
|
|
90
|
-
|
|
77
|
+
"scripts": {
|
|
78
|
+
"preinstall": "node .check-workspace.cjs",
|
|
79
|
+
"build": "bun scripts/build.ts",
|
|
80
|
+
"typecheck": "tsgo --noEmit",
|
|
81
|
+
"lint": "oxlint .",
|
|
82
|
+
"lint:fix": "oxlint --fix .",
|
|
83
|
+
"format": "pnpm exec oxfmt --write .",
|
|
84
|
+
"format:check": "pnpm exec oxfmt --check .",
|
|
85
|
+
"test": "bun test src",
|
|
86
|
+
"test:watch": "bun test --watch src"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -6,10 +6,7 @@ import { join } from 'node:path'
|
|
|
6
6
|
|
|
7
7
|
import { buildProgram } from '../../program'
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
// "never drifts". These tests hold that claim to account: the version is
|
|
11
|
-
// single-sourced from package.json, no internal `§` spec anchors leak into
|
|
12
|
-
// rendered help, and the workspace skill mirror stays byte-identical.
|
|
9
|
+
// Help output is the public CLI contract: version, spec anchors, and skill mirror stay in sync.
|
|
13
10
|
|
|
14
11
|
const cliRoot = join(import.meta.dir, '../../..')
|
|
15
12
|
|
|
@@ -28,9 +25,7 @@ describe('help contract — version is single-sourced', () => {
|
|
|
28
25
|
readFileSync(join(cliRoot, '.release-please-manifest.json'), 'utf8'),
|
|
29
26
|
) as Record<string, string>
|
|
30
27
|
|
|
31
|
-
//
|
|
32
|
-
// rendered version equals BOTH JSON sources catches a re-hardcoded literal
|
|
33
|
-
// and a manifest/package.json divergence (release-please bumps both).
|
|
28
|
+
// Program version must follow both package.json and release-please metadata.
|
|
34
29
|
expect(program.version()).toBe(pkg.version)
|
|
35
30
|
expect(manifest['.']).toBe(pkg.version)
|
|
36
31
|
})
|
|
@@ -92,13 +87,7 @@ describe('help contract — connect-only command surface', () => {
|
|
|
92
87
|
const program = await buildProgram()
|
|
93
88
|
const names = allCommands(program).map((command) => command.name())
|
|
94
89
|
|
|
95
|
-
//
|
|
96
|
-
// runtime management (the historical `astrale logs`, removed with
|
|
97
|
-
// start/stop; the historical `astrale domain`, local domain wiring) and each
|
|
98
|
-
// has since been RECLAIMED for a connect-side meaning through the admin
|
|
99
|
-
// control plane — `astrale logs <service>` tails a MANAGED service's buffer;
|
|
100
|
-
// `astrale domain publish` registers an installable domain in the admin's
|
|
101
|
-
// catalog and `astrale domain install <url>` mounts one on an instance.
|
|
90
|
+
// `logs` and `domain` have managed/admin meanings, so only retired local-runtime verbs are absent.
|
|
102
91
|
for (const removed of [
|
|
103
92
|
'init',
|
|
104
93
|
'start',
|
|
@@ -116,6 +105,30 @@ describe('help contract — connect-only command surface', () => {
|
|
|
116
105
|
})
|
|
117
106
|
})
|
|
118
107
|
|
|
108
|
+
describe('help contract — read command split', () => {
|
|
109
|
+
test('get is point-read only and query owns structured read flags', async () => {
|
|
110
|
+
const program = await buildProgram()
|
|
111
|
+
const get = allCommands(program).find((command) => command.name() === 'get')
|
|
112
|
+
const query = allCommands(program).find((command) => command.name() === 'query')
|
|
113
|
+
const getHelp = get?.helpInformation() ?? ''
|
|
114
|
+
const queryHelp = query?.helpInformation() ?? ''
|
|
115
|
+
|
|
116
|
+
expect(getHelp).toContain('Usage: astrale get [options] <path>')
|
|
117
|
+
expect(getHelp).toContain('-l, --long')
|
|
118
|
+
expect(getHelp).not.toContain('--depth')
|
|
119
|
+
expect(getHelp).not.toContain('--children')
|
|
120
|
+
expect(getHelp).not.toContain('--edges')
|
|
121
|
+
expect(getHelp).not.toContain('--graph')
|
|
122
|
+
|
|
123
|
+
expect(queryHelp).toContain('Usage: astrale query [options] [paths...]')
|
|
124
|
+
expect(queryHelp).toContain('--depth <n>')
|
|
125
|
+
expect(queryHelp).toContain('--children <json>')
|
|
126
|
+
expect(queryHelp).toContain('--edges <json>')
|
|
127
|
+
expect(queryHelp).toContain('--ast <json>')
|
|
128
|
+
expect(queryHelp).toContain('--cypher <query>')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
119
132
|
describe('help contract — skill is single-source, not duplicated', () => {
|
|
120
133
|
const canonical = join(cliRoot, 'skills/astrale-cli/SKILL.md')
|
|
121
134
|
// Workspace mirror lives in the superrepo, outside this submodule. Absent
|
|
@@ -11,7 +11,7 @@ describe('admin-path target classification', () => {
|
|
|
11
11
|
test('a bare origin installs by catalog origin (the unique registry key)', () => {
|
|
12
12
|
expect(domainRefFromTarget('crm.acme.dev')).toEqual({ origin: 'crm.acme.dev' })
|
|
13
13
|
// A host:port that is not a url is still an origin, not a url.
|
|
14
|
-
expect(domainRefFromTarget('
|
|
14
|
+
expect(domainRefFromTarget('example.astrale.ai')).toEqual({ origin: 'example.astrale.ai' })
|
|
15
15
|
})
|
|
16
16
|
})
|
|
17
17
|
|
|
@@ -23,7 +23,7 @@ describe('identity-override detection', () => {
|
|
|
23
23
|
|
|
24
24
|
test('origin differing from the serving host is an override', () => {
|
|
25
25
|
// The spec §5 attack shape: a fork on workers.dev claiming a well-known origin.
|
|
26
|
-
expect(isIdentityOverride('
|
|
26
|
+
expect(isIdentityOverride('workspace.astrale.ai', 'crm.workers.dev')).toBe(true)
|
|
27
27
|
// The scaffold default also aliases until the placeholder origin is edited.
|
|
28
28
|
expect(isIdentityOverride('hldom.example.dev', 'hldom-example-dev.acme.workers.dev')).toBe(true)
|
|
29
29
|
})
|
|
@@ -7,7 +7,7 @@ import { basename, classNameOf } from '../ls'
|
|
|
7
7
|
// `path` (absolute) and `class` (serialized ClassPath).
|
|
8
8
|
describe('ls — display projection (slug-bug regression)', () => {
|
|
9
9
|
test('basename derives the display name from the absolute path', () => {
|
|
10
|
-
expect(basename('/
|
|
10
|
+
expect(basename('/example.astrale.ai')).toBe('example.astrale.ai')
|
|
11
11
|
expect(basename('/kernel.astrale.ai')).toBe('kernel.astrale.ai')
|
|
12
12
|
expect(basename('/')).toBe('/')
|
|
13
13
|
expect(basename(undefined)).toBe('')
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
type Wire = { nodes: unknown[]; edges: unknown[]; roots: string[]; next?: Record<string, unknown> }
|
|
4
|
+
type RunOpts = {
|
|
5
|
+
opts: Record<string, unknown>
|
|
6
|
+
fn: (ctx: { client: { call: typeof clientCallMock } }) => Promise<unknown>
|
|
7
|
+
format?: (result: unknown, opts: Record<string, unknown>, isRaw: boolean) => void | Promise<void>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class ExitError extends Error {
|
|
11
|
+
constructor(readonly code: string | number | null | undefined) {
|
|
12
|
+
super('process.exit(' + String(code) + ')')
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const emptyWire = (): Wire => ({ nodes: [], edges: [], roots: [] })
|
|
17
|
+
|
|
18
|
+
let stdout = ''
|
|
19
|
+
let errors: string[] = []
|
|
20
|
+
let originalStdoutWrite: typeof process.stdout.write
|
|
21
|
+
let originalConsoleError: typeof console.error
|
|
22
|
+
let originalExit: typeof process.exit
|
|
23
|
+
let queryAsts: unknown[] = []
|
|
24
|
+
let getPaths: string[] = []
|
|
25
|
+
let queryResult: { wire: Wire } = { wire: emptyWire() }
|
|
26
|
+
let getResult: unknown = null
|
|
27
|
+
let clientCallResult: unknown = { rows: [] }
|
|
28
|
+
|
|
29
|
+
const clientCallMock = mock(async () => clientCallResult)
|
|
30
|
+
const queryMock = mock(async (ast: unknown) => {
|
|
31
|
+
queryAsts.push(ast)
|
|
32
|
+
return queryResult
|
|
33
|
+
})
|
|
34
|
+
const getMock = mock(async (path: string) => {
|
|
35
|
+
getPaths.push(path)
|
|
36
|
+
return getResult
|
|
37
|
+
})
|
|
38
|
+
const bindGraphMock = mock(() => ({ query: queryMock, get: getMock }))
|
|
39
|
+
const expandSelfInPathMock = mock(async (path: string) => {
|
|
40
|
+
if (path === '@self') {
|
|
41
|
+
return {
|
|
42
|
+
path: '@expanded-self',
|
|
43
|
+
meta: { original: '@self', expanded: '@expanded-self', selfId: 'expanded-self' },
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { path, meta: undefined }
|
|
47
|
+
})
|
|
48
|
+
const withSelfHintMock = mock(async (fn: () => Promise<unknown>) => fn())
|
|
49
|
+
const runKernelCommandMock = mock(async (run: RunOpts) => {
|
|
50
|
+
const result = await run.fn({ client: { call: clientCallMock } })
|
|
51
|
+
if (run.format) await run.format(result, run.opts, true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
mock.module('../../kernel', () => ({
|
|
55
|
+
bindGraph: bindGraphMock,
|
|
56
|
+
expandSelfInPath: expandSelfInPathMock,
|
|
57
|
+
runKernelCommand: runKernelCommandMock,
|
|
58
|
+
withSelfHint: withSelfHintMock,
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
stdout = ''
|
|
63
|
+
errors = []
|
|
64
|
+
queryAsts = []
|
|
65
|
+
getPaths = []
|
|
66
|
+
queryResult = { wire: emptyWire() }
|
|
67
|
+
getResult = null
|
|
68
|
+
clientCallResult = { rows: [] }
|
|
69
|
+
clientCallMock.mockClear()
|
|
70
|
+
queryMock.mockClear()
|
|
71
|
+
getMock.mockClear()
|
|
72
|
+
bindGraphMock.mockClear()
|
|
73
|
+
expandSelfInPathMock.mockClear()
|
|
74
|
+
withSelfHintMock.mockClear()
|
|
75
|
+
runKernelCommandMock.mockClear()
|
|
76
|
+
|
|
77
|
+
originalStdoutWrite = process.stdout.write.bind(process.stdout)
|
|
78
|
+
originalConsoleError = console.error
|
|
79
|
+
originalExit = process.exit
|
|
80
|
+
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
81
|
+
stdout += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
|
82
|
+
return true
|
|
83
|
+
}) as typeof process.stdout.write
|
|
84
|
+
console.error = ((...args: unknown[]) => {
|
|
85
|
+
errors.push(args.map(String).join(' '))
|
|
86
|
+
}) as typeof console.error
|
|
87
|
+
process.exit = ((code?: string | number | null) => {
|
|
88
|
+
throw new ExitError(code)
|
|
89
|
+
}) as typeof process.exit
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
process.stdout.write = originalStdoutWrite
|
|
94
|
+
console.error = originalConsoleError
|
|
95
|
+
process.exit = originalExit
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('query command', () => {
|
|
99
|
+
test('lowers roots and selectors to the expected QueryASTInput', async () => {
|
|
100
|
+
const { queryCommand } = await import('../query')
|
|
101
|
+
|
|
102
|
+
await queryCommand(['/root', '@self'], {
|
|
103
|
+
json: true,
|
|
104
|
+
depth: '2',
|
|
105
|
+
children:
|
|
106
|
+
'{"classes":["/:kernel.astrale.ai:class.Folder"],"limit":50,"cursor":"child-cursor","order":{"by":"id","dir":"asc"}}',
|
|
107
|
+
edges:
|
|
108
|
+
'[{"as":"perm","classes":["/:kernel.astrale.ai:class.has_perm"],"direction":"out","limit":10,"cursor":"edge-cursor","order":{"by":"id","dir":"desc"}},{"direction":"both"}]',
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
expect(queryAsts).toEqual([
|
|
112
|
+
{
|
|
113
|
+
version: 1,
|
|
114
|
+
from: ['/root', '@expanded-self'],
|
|
115
|
+
steps: [
|
|
116
|
+
{
|
|
117
|
+
expand: {
|
|
118
|
+
edge: 'has_parent',
|
|
119
|
+
dir: 'in',
|
|
120
|
+
depth: 2,
|
|
121
|
+
filter: { class: ['/:kernel.astrale.ai:class.Folder'] },
|
|
122
|
+
page: { limit: 50, cursor: 'child-cursor' },
|
|
123
|
+
order: { by: 'id', dir: 'asc' },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
expand: {
|
|
128
|
+
edge: ['/:kernel.astrale.ai:class.has_perm'],
|
|
129
|
+
dir: 'out',
|
|
130
|
+
as: 'perm',
|
|
131
|
+
page: { limit: 10, cursor: 'edge-cursor' },
|
|
132
|
+
order: { by: 'id', dir: 'desc' },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{ expand: { dir: 'both', as: 'e1' } },
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
])
|
|
139
|
+
expect(expandSelfInPathMock.mock.calls.map((call) => call[0])).toEqual(['/root', '@self'])
|
|
140
|
+
expect(withSelfHintMock).toHaveBeenCalledTimes(1)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('rejects an invalid children selector before dispatch', async () => {
|
|
144
|
+
const { queryCommand } = await import('../query')
|
|
145
|
+
|
|
146
|
+
await expect(queryCommand(['/'], { json: true, children: '{"bogus":true}' })).rejects.toEqual(
|
|
147
|
+
new ExitError(1),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
expect(errors.join('\n')).toContain('--children invalid selector')
|
|
151
|
+
expect(queryMock).not.toHaveBeenCalled()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('rejects mixing --ast with positional roots', async () => {
|
|
155
|
+
const { queryCommand } = await import('../query')
|
|
156
|
+
|
|
157
|
+
await expect(
|
|
158
|
+
queryCommand(['/'], { json: true, ast: '{"version":1,"from":["/"]}' }),
|
|
159
|
+
).rejects.toEqual(new ExitError(1))
|
|
160
|
+
|
|
161
|
+
expect(errors.join('\n')).toContain(
|
|
162
|
+
'--ast cannot be used with positional roots or --depth/--children/--edges',
|
|
163
|
+
)
|
|
164
|
+
expect(queryMock).not.toHaveBeenCalled()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('get command', () => {
|
|
169
|
+
test('dispatches a point read and prints the flat wire projection', async () => {
|
|
170
|
+
const { getCommand } = await import('../get')
|
|
171
|
+
const row = {
|
|
172
|
+
id: 'n1',
|
|
173
|
+
class: '/:demo:class.Widget',
|
|
174
|
+
path: '/demo/widget',
|
|
175
|
+
props: { name: 'Widget' },
|
|
176
|
+
__labels: ['Node', 'Widget'],
|
|
177
|
+
classId: 'class-1',
|
|
178
|
+
}
|
|
179
|
+
getResult = row
|
|
180
|
+
|
|
181
|
+
await getCommand('/demo/widget', { json: true })
|
|
182
|
+
|
|
183
|
+
expect(getPaths).toEqual(['/demo/widget'])
|
|
184
|
+
expect(JSON.parse(stdout)).toEqual({
|
|
185
|
+
id: 'n1',
|
|
186
|
+
class: '/:demo:class.Widget',
|
|
187
|
+
path: '/demo/widget',
|
|
188
|
+
props: { name: 'Widget' },
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('errors with exit 1 when no node resolves', async () => {
|
|
193
|
+
const { getCommand } = await import('../get')
|
|
194
|
+
getResult = null
|
|
195
|
+
|
|
196
|
+
await expect(getCommand('/missing', { json: true })).rejects.toEqual(new ExitError(1))
|
|
197
|
+
|
|
198
|
+
expect(getPaths).toEqual(['/missing'])
|
|
199
|
+
expect(errors.join('\n')).toContain('node "/missing" not found or not visible')
|
|
200
|
+
})
|
|
201
|
+
})
|
package/src/commands/call.ts
CHANGED
|
@@ -3,7 +3,14 @@ import { K } from '@astrale-os/kernel-core'
|
|
|
3
3
|
import type { CommandDefinition } from '../command'
|
|
4
4
|
import type { CallCommandOpts, ClientContext, SelfExpansionMeta } from '../kernel'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
bindGraph,
|
|
8
|
+
buildSelfContext,
|
|
9
|
+
nodeProp,
|
|
10
|
+
resolveSelfIdLazy,
|
|
11
|
+
runKernelCommand,
|
|
12
|
+
withSelfHint,
|
|
13
|
+
} from '../kernel'
|
|
7
14
|
import { presentBinary, type BinaryLike } from '../lib/binary'
|
|
8
15
|
import { log } from '../lib/log'
|
|
9
16
|
import { output, present } from '../lib/output'
|
|
@@ -19,12 +26,8 @@ export async function callCommand(
|
|
|
19
26
|
rawParams: string[],
|
|
20
27
|
opts: CallOpts,
|
|
21
28
|
): Promise<void> {
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// missing a cached registration (persisted, so it's local afterwards).
|
|
25
|
-
// Throws a typed SelfRefusalError (manager, instance-signed, …) which we
|
|
26
|
-
// surface as a fatal CLI error. Runs BEFORE `--describe` so users get the
|
|
27
|
-
// typed refusal instead of a generic NotFoundError from the kernel.
|
|
29
|
+
// Expand @self before describe/execute so refusals are typed and stale IdP
|
|
30
|
+
// registrations can refresh through whoami.
|
|
28
31
|
let expandedPath = path
|
|
29
32
|
let expandedRaw = rawParams
|
|
30
33
|
let selfMeta: SelfExpansionMeta | undefined
|
|
@@ -35,9 +38,7 @@ export async function callCommand(
|
|
|
35
38
|
const selfId = await resolveSelfIdLazy(selfCtx, opts)
|
|
36
39
|
expandedPath = expandSelfReferences(path, selfId)
|
|
37
40
|
expandedRaw = rawParams.map((p) => expandSelfReferences(p, selfId))
|
|
38
|
-
//
|
|
39
|
-
// hint in `formatKernelError` is just as useful when `@self` lived in
|
|
40
|
-
// a param (`node=@self`) as when it was in the path head.
|
|
41
|
+
// Stale-registration hints apply when @self appears in either path or params.
|
|
41
42
|
const rawMutated = expandedRaw.some((p, i) => p !== rawParams[i])
|
|
42
43
|
if (expandedPath !== path || rawMutated) {
|
|
43
44
|
selfMeta = {
|
|
@@ -80,14 +81,7 @@ export async function callCommand(
|
|
|
80
81
|
opts,
|
|
81
82
|
label: expandedPath,
|
|
82
83
|
fn: async (ctx): Promise<CallResult> => {
|
|
83
|
-
//
|
|
84
|
-
// the call to a redirect carrying the worker's URL + `iss`; the session
|
|
85
|
-
// follows it, minting a worker-scoped delegation for that `iss` (the
|
|
86
|
-
// delegation cache wired in `client.ts`). No client-side binding lookup.
|
|
87
|
-
//
|
|
88
|
-
// The one thing the reactive path can't discover after dispatch is a
|
|
89
|
-
// binary output mode (a JSON decode would corrupt the bytes), so detect
|
|
90
|
-
// it up front and route to the binary transport — which also auto-follows.
|
|
84
|
+
// Binary outputs must be routed before dispatch; JSON decoding would corrupt bytes.
|
|
91
85
|
if (await isBinaryOutput(ctx, expandedPath)) {
|
|
92
86
|
const response = await withSelfHint(() => ctx.client.binary(expandedPath, params), selfMeta)
|
|
93
87
|
return { kind: 'binary', response }
|
|
@@ -106,26 +100,15 @@ export async function callCommand(
|
|
|
106
100
|
}
|
|
107
101
|
|
|
108
102
|
/**
|
|
109
|
-
* Best-effort
|
|
110
|
-
*
|
|
111
|
-
* decode and corrupt the bytes), and the client can't discover that after
|
|
112
|
-
* dispatch. For a static path the path IS the Function node — read `output`
|
|
113
|
-
* off it via `::get`. For an instance-method path (`<node>::method`) the
|
|
114
|
-
* Function node isn't addressable that way: resolve the instance's class
|
|
115
|
-
* first, then probe the class's method node (`<classPath>:method`). An
|
|
116
|
-
* interface-hosted instance method still escapes the probe (its Function node
|
|
117
|
-
* hangs off the interface, not the class) — that and any other failure returns
|
|
118
|
-
* false and falls through to the value path, letting the kernel surface the
|
|
119
|
-
* real error. `::get` is a kernel syscall (same origin) so it neither
|
|
120
|
-
* redirects nor triggers delegation.
|
|
103
|
+
* Best-effort binary preflight. Static paths read the Function node directly;
|
|
104
|
+
* instance paths probe the class method node and fall back to value transport on miss.
|
|
121
105
|
*/
|
|
122
106
|
async function isBinaryOutput(ctx: ClientContext, path: string): Promise<boolean> {
|
|
123
107
|
try {
|
|
124
108
|
const target = path.includes('::') ? await instanceMethodNodePath(ctx, path) : path
|
|
125
109
|
if (!target) return false
|
|
126
|
-
const node =
|
|
127
|
-
|
|
128
|
-
} | null
|
|
110
|
+
const node = await bindGraph(ctx).get(target)
|
|
111
|
+
// Props travel under fully-qualified keys; `output.key` IS that key.
|
|
129
112
|
return node?.props?.[K.$.i('Function').output.key] === 'binary'
|
|
130
113
|
} catch {
|
|
131
114
|
return false
|
|
@@ -141,22 +124,23 @@ async function instanceMethodNodePath(
|
|
|
141
124
|
const source = path.slice(0, sep)
|
|
142
125
|
const method = path.slice(sep + 2)
|
|
143
126
|
if (!source || !method) return undefined
|
|
144
|
-
const node =
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
return node?.class ? `${node.class}:${method}` : undefined
|
|
127
|
+
const node = await bindGraph(ctx).get(source)
|
|
128
|
+
// ClassPath + method name forms the class-owned Function MethodPath.
|
|
129
|
+
return node?.class ? `${node.class.raw}:${method}` : undefined
|
|
148
130
|
}
|
|
149
131
|
|
|
150
132
|
async function describeOperation(path: string, opts: CallOpts): Promise<void> {
|
|
151
|
-
await runKernelCommand
|
|
133
|
+
await runKernelCommand({
|
|
152
134
|
opts,
|
|
153
135
|
label: `Schema for ${path}`,
|
|
154
|
-
|
|
136
|
+
// The Function node carries the schemas as props (function.get depth:0).
|
|
137
|
+
fn: async (ctx) => await bindGraph(ctx).get(path),
|
|
155
138
|
format: (node, fmtOpts) => {
|
|
156
|
-
const
|
|
139
|
+
const input = nodeProp(node, 'inputSchema')
|
|
140
|
+
const outputSchema = nodeProp(node, 'outputSchema')
|
|
157
141
|
const schema: Record<string, unknown> = {}
|
|
158
|
-
if (
|
|
159
|
-
if (
|
|
142
|
+
if (input) schema.input = tryParseJson(input)
|
|
143
|
+
if (outputSchema) schema.output = tryParseJson(outputSchema)
|
|
160
144
|
output(Object.keys(schema).length > 0 ? schema : node, fmtOpts)
|
|
161
145
|
},
|
|
162
146
|
})
|
|
@@ -277,8 +261,7 @@ Examples:
|
|
|
277
261
|
$ astrale call /:host.astrale.ai:class.KernelInstance:list
|
|
278
262
|
$ astrale call /:blog.acme.com:class.Author:list limit=10
|
|
279
263
|
$ astrale call '@self::deactivate'
|
|
280
|
-
$ astrale call /:
|
|
281
|
-
-d "$(cat spec.json)"
|
|
264
|
+
$ astrale call /:workspace.astrale.ai:class.App:available --json
|
|
282
265
|
`,
|
|
283
266
|
arguments: [
|
|
284
267
|
{
|