@astrale-os/cli 1.0.0-beta.12 → 1.0.0-beta.13
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 +15432 -14745
- package/dist/types/admin/contract.d.ts +26 -0
- package/dist/types/admin/instance/client.d.ts +2 -4
- package/dist/types/admin/instance/model.d.ts +3 -0
- package/package.json +1 -1
- package/src/admin/.spec/architecture.md +12 -5
- package/src/admin/__tests__/fixture.ts +19 -95
- package/src/admin/catalog/.spec/api.d.ts +0 -2
- package/src/admin/catalog/.spec/architecture.md +5 -4
- package/src/admin/catalog/__tests__/client.test.ts +136 -33
- package/src/admin/catalog/client.ts +38 -61
- package/src/admin/contract.ts +46 -0
- package/src/admin/instance/.spec/api.d.ts +4 -8
- package/src/admin/instance/.spec/architecture.md +7 -7
- package/src/admin/instance/__tests__/client.test.ts +138 -31
- package/src/admin/instance/client.ts +32 -37
- package/src/admin/instance/model.ts +3 -0
- package/src/commands/__tests__/call.test.ts +34 -0
- package/src/commands/__tests__/read-commands.test.ts +27 -0
- package/src/commands/__tests__/token-ttl.test.ts +49 -4
- package/src/commands/call.ts +28 -3
- package/src/commands/query.ts +8 -2
- package/src/commands/token.ts +34 -20
- package/src/commands/ui/__tests__/commands.test.ts +129 -0
- package/src/commands/ui/add.ts +51 -0
- package/src/commands/ui/doctor.ts +13 -0
- package/src/commands/ui/init.ts +38 -0
- package/src/commands/ui/list.ts +26 -0
- package/src/commands/ui/preset-apply.ts +19 -0
- package/src/commands/ui/preset-list.ts +12 -0
- package/src/commands/ui/shared.ts +25 -0
- package/src/lib/__tests__/binary.test.ts +16 -1
- package/src/lib/binary.ts +22 -5
- package/src/lib/proc.ts +7 -2
- package/src/program/.spec/api.d.ts +1 -0
- package/src/program/__tests__/program.test.ts +32 -2
- package/src/program/build.ts +23 -1
- package/src/program/command.ts +1 -0
- package/src/program/registry.ts +2 -1
- package/src/ui/.spec/api.d.ts +14 -0
- package/src/ui/.spec/architecture.md +10 -0
- package/src/ui/.spec/laws.ts +36 -0
- package/src/ui/.spec/layout.ts +16 -0
- package/src/ui/__tests__/ui.test.ts +542 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/lock.ts +87 -0
- package/src/ui/model.ts +83 -0
- package/src/ui/operations.ts +539 -0
- package/src/ui/project.ts +146 -0
- package/src/ui/release.ts +267 -0
- package/src/ui/runner.ts +18 -0
- package/studio/server/agent/harness/gateway/token.test.ts +1 -1
- package/studio/server/agent/harness/gateway/token.ts +3 -3
- package/dist/types/admin/binding.d.ts +0 -19
- package/src/admin/__tests__/binding.test.ts +0 -31
- package/src/admin/binding.ts +0 -98
|
@@ -2,14 +2,7 @@ import type { ClientSession } from '@astrale-os/sdk/client/session'
|
|
|
2
2
|
|
|
3
3
|
import { Path } from '@astrale-os/sdk/graph/path'
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
bindAdmin,
|
|
7
|
-
invokeAdminMethod,
|
|
8
|
-
requireAdminBinding,
|
|
9
|
-
requireAdminClass,
|
|
10
|
-
requireAdminCore,
|
|
11
|
-
type AdminBinding,
|
|
12
|
-
} from '../binding'
|
|
5
|
+
import { AdminContract, callAdminMethod } from '../contract'
|
|
13
6
|
import {
|
|
14
7
|
AdminInstanceNotFoundError,
|
|
15
8
|
findOwnedInstance,
|
|
@@ -32,33 +25,24 @@ export interface AdminInstanceApi {
|
|
|
32
25
|
}
|
|
33
26
|
|
|
34
27
|
export interface AdminInstanceDependencies {
|
|
35
|
-
readonly bind?: (session: ClientSession) => Promise<AdminBinding>
|
|
36
28
|
readonly operationId?: (kind: 'create' | 'status' | 'delete' | 'install-domain') => string
|
|
37
29
|
}
|
|
38
30
|
|
|
39
31
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
32
|
+
* Connect the public Instance journey through stable Admin call paths. Routine
|
|
33
|
+
* operations perform no schema discovery. No Host lifecycle method is present.
|
|
42
34
|
*/
|
|
43
35
|
export async function connectAdminInstances(
|
|
44
36
|
context: AdminInstanceContext,
|
|
45
37
|
dependencies: AdminInstanceDependencies = {},
|
|
46
38
|
): Promise<AdminInstanceApi> {
|
|
47
|
-
const binding = requireAdminBinding(await (dependencies.bind ?? bindAdmin)(context.session))
|
|
48
|
-
|
|
49
|
-
const Instance = requireAdminClass(binding, 'Instance', 'node')
|
|
50
|
-
const Fleet = requireAdminClass(binding, 'Fleet', 'node')
|
|
51
|
-
const fleet = requireAdminCore(binding, 'fleet')
|
|
52
|
-
|
|
53
39
|
const operationId = dependencies.operationId ?? defaultOperationId
|
|
54
40
|
|
|
55
41
|
const list = async (): Promise<OwnedInstanceInfo[]> => {
|
|
56
|
-
const result: unknown = await
|
|
42
|
+
const result: unknown = await callAdminMethod(
|
|
57
43
|
context.session,
|
|
58
|
-
|
|
59
|
-
Fleet,
|
|
44
|
+
AdminContract.fleet,
|
|
60
45
|
'listInstances',
|
|
61
|
-
fleet,
|
|
62
46
|
{},
|
|
63
47
|
)
|
|
64
48
|
if (!Array.isArray(result)) throw new TypeError('Admin Instance inventory is invalid.')
|
|
@@ -76,14 +60,9 @@ export async function connectAdminInstances(
|
|
|
76
60
|
identifier: string,
|
|
77
61
|
): Promise<InstanceInfo> => {
|
|
78
62
|
const instance = await requireInstance(identifier)
|
|
79
|
-
const output = await
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Instance,
|
|
83
|
-
method,
|
|
84
|
-
Path.parse(instance.id),
|
|
85
|
-
{ operationId: operationId(method) },
|
|
86
|
-
)
|
|
63
|
+
const output = await callAdminMethod(context.session, Path.parse(instance.id), method, {
|
|
64
|
+
operationId: operationId(method),
|
|
65
|
+
})
|
|
87
66
|
return instanceFromSummary(output)
|
|
88
67
|
}
|
|
89
68
|
|
|
@@ -95,19 +74,17 @@ export async function connectAdminInstances(
|
|
|
95
74
|
slug,
|
|
96
75
|
})
|
|
97
76
|
return instanceFromSummary(
|
|
98
|
-
await
|
|
77
|
+
await callAdminMethod(context.session, AdminContract.fleet, 'createInstance', input),
|
|
99
78
|
)
|
|
100
79
|
},
|
|
101
80
|
status: (identifier: string) => invokeInstance('status', identifier),
|
|
102
81
|
delete: (identifier: string) => invokeInstance('delete', identifier),
|
|
103
82
|
async installDomain(identifier: string, domain: string): Promise<DomainInstallReceipt> {
|
|
104
83
|
const instance = await requireInstance(identifier)
|
|
105
|
-
const output = await
|
|
84
|
+
const output = await callAdminMethod(
|
|
106
85
|
context.session,
|
|
107
|
-
binding,
|
|
108
|
-
Instance,
|
|
109
|
-
'installDomain',
|
|
110
86
|
Path.parse(instance.id),
|
|
87
|
+
'installDomain',
|
|
111
88
|
{
|
|
112
89
|
operationId: operationId('install-domain'),
|
|
113
90
|
domain: Path.parse(domain).raw,
|
|
@@ -122,9 +99,15 @@ function instanceFromSummary(input: unknown): InstanceInfo {
|
|
|
122
99
|
const value = record(input, 'Admin Instance summary')
|
|
123
100
|
const failure = value.failure === undefined ? undefined : record(value.failure, 'Admin failure')
|
|
124
101
|
return Object.freeze({
|
|
125
|
-
id:
|
|
102
|
+
id: requiredNodePath(value.id, 'Admin Instance id'),
|
|
126
103
|
slug: requiredString(value.slug, 'Admin Instance slug'),
|
|
127
104
|
url: optionalStringValue(value.url) ?? '',
|
|
105
|
+
...(value.hostId === undefined
|
|
106
|
+
? {}
|
|
107
|
+
: { hostId: requiredNodePath(value.hostId, 'Admin Host id') }),
|
|
108
|
+
...(value.region === undefined
|
|
109
|
+
? {}
|
|
110
|
+
: { region: requiredString(value.region, 'Admin Host region') }),
|
|
128
111
|
state: instanceState(value.state),
|
|
129
112
|
...(value.phase === undefined
|
|
130
113
|
? {}
|
|
@@ -135,6 +118,9 @@ function instanceFromSummary(input: unknown): InstanceInfo {
|
|
|
135
118
|
...(value.createdAt === undefined
|
|
136
119
|
? {}
|
|
137
120
|
: { createdAt: requiredString(value.createdAt, 'Admin Instance creation time') }),
|
|
121
|
+
...(value.updatedAt === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { updatedAt: requiredString(value.updatedAt, 'Admin Instance update time') }),
|
|
138
124
|
...(value.organizationId === undefined
|
|
139
125
|
? {}
|
|
140
126
|
: { organizationId: requiredString(value.organizationId, 'Admin organization id') }),
|
|
@@ -146,8 +132,8 @@ function domainInstallReceipt(input: unknown): DomainInstallReceipt {
|
|
|
146
132
|
const failure = value.failure === undefined ? undefined : record(value.failure, 'Admin failure')
|
|
147
133
|
if (typeof value.ok !== 'boolean') throw new TypeError('Admin Domain install outcome is invalid.')
|
|
148
134
|
return Object.freeze({
|
|
149
|
-
domain:
|
|
150
|
-
instance:
|
|
135
|
+
domain: requiredNodePath(value.domain, 'Admin Domain reference'),
|
|
136
|
+
instance: requiredNodePath(value.instance, 'Admin Instance reference'),
|
|
151
137
|
origin: requiredString(value.origin, 'Installed Domain origin'),
|
|
152
138
|
ok: value.ok,
|
|
153
139
|
...(value.installedRevision === undefined
|
|
@@ -179,6 +165,15 @@ function requiredString(input: unknown, label: string): string {
|
|
|
179
165
|
return input
|
|
180
166
|
}
|
|
181
167
|
|
|
168
|
+
function requiredNodePath(input: unknown, label: string): string {
|
|
169
|
+
const value = requiredString(input, label)
|
|
170
|
+
try {
|
|
171
|
+
return Path.parse(value).raw
|
|
172
|
+
} catch {
|
|
173
|
+
throw new TypeError(`${label} is invalid.`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
182
177
|
function optionalStringValue(input: unknown): string | undefined {
|
|
183
178
|
if (input === undefined) return undefined
|
|
184
179
|
return requiredString(input, 'Admin string value')
|
|
@@ -5,10 +5,13 @@ export interface InstanceInfo {
|
|
|
5
5
|
readonly id: string
|
|
6
6
|
readonly slug: string
|
|
7
7
|
readonly url: string
|
|
8
|
+
readonly hostId?: string
|
|
9
|
+
readonly region?: string
|
|
8
10
|
readonly state: InstanceState
|
|
9
11
|
readonly phase?: string
|
|
10
12
|
readonly error?: string | null
|
|
11
13
|
readonly createdAt?: string
|
|
14
|
+
readonly updatedAt?: string
|
|
12
15
|
readonly organizationId?: string
|
|
13
16
|
}
|
|
14
17
|
|
|
@@ -26,4 +26,38 @@ describe('call command result lifetime', () => {
|
|
|
26
26
|
if (materialized.kind !== 'stream') throw new Error('expected a materialized stream')
|
|
27
27
|
expect(Object.isFrozen(materialized.values)).toBe(true)
|
|
28
28
|
})
|
|
29
|
+
|
|
30
|
+
test('drains streaming binary bytes before the command session closes', async () => {
|
|
31
|
+
let sessionOpen = true
|
|
32
|
+
async function* body() {
|
|
33
|
+
for (const chunk of [new Uint8Array([1, 2]), new Uint8Array([3])]) {
|
|
34
|
+
if (!sessionOpen) throw new Error('session closed before binary consumption')
|
|
35
|
+
yield chunk
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const materialized = await materializeCallResult({
|
|
40
|
+
kind: 'binary',
|
|
41
|
+
invocation: { source: 'https://kernel.test' as never, id: 'test-binary' },
|
|
42
|
+
value: {
|
|
43
|
+
body: body(),
|
|
44
|
+
mediaType: 'application/octet-stream',
|
|
45
|
+
status: 206,
|
|
46
|
+
headers: { 'content-range': 'bytes 0-2/3' },
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
sessionOpen = false
|
|
50
|
+
|
|
51
|
+
expect(materialized).toMatchObject({
|
|
52
|
+
kind: 'binary',
|
|
53
|
+
value: {
|
|
54
|
+
mediaType: 'application/octet-stream',
|
|
55
|
+
status: 206,
|
|
56
|
+
headers: { 'content-range': 'bytes 0-2/3' },
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
if (materialized.kind !== 'binary') throw new Error('expected materialized binary')
|
|
60
|
+
expect([...materialized.value.body]).toEqual([1, 2, 3])
|
|
61
|
+
expect(Object.isFrozen(materialized.value)).toBe(true)
|
|
62
|
+
})
|
|
29
63
|
})
|
|
@@ -200,6 +200,33 @@ describe('query command', () => {
|
|
|
200
200
|
])
|
|
201
201
|
})
|
|
202
202
|
|
|
203
|
+
test('retains the opaque continuation in machine output without wrapping the graph result', async () => {
|
|
204
|
+
const { queryCommand } = await import('../query')
|
|
205
|
+
const result = {
|
|
206
|
+
kind: 'graph' as const,
|
|
207
|
+
graph: {
|
|
208
|
+
nodes: [{ id: 'issue-1', class: 'issues.astrale.ai:class.Issue', props: {} }],
|
|
209
|
+
edges: [],
|
|
210
|
+
},
|
|
211
|
+
selection: { kind: 'node' as const, ids: ['issue-1'] },
|
|
212
|
+
}
|
|
213
|
+
queryResult = {
|
|
214
|
+
result,
|
|
215
|
+
page: { next: 'opaque-next-page' },
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await queryCommand([], {
|
|
219
|
+
json: true,
|
|
220
|
+
definition: '/:issues.astrale.ai:class.Issue',
|
|
221
|
+
limit: '1',
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
expect(JSON.parse(stdout)).toEqual({
|
|
225
|
+
...result,
|
|
226
|
+
page: { next: 'opaque-next-page' },
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
203
230
|
test('resolves @self inside the command session and never relabels transport failure as input', async () => {
|
|
204
231
|
const { queryCommand } = await import('../query')
|
|
205
232
|
await queryCommand(['@self'], { json: true, limit: '2' })
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AuthApi, Identity, IssuerId, MintedCredential } from '@astrale-os/sdk/auth'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, mock, test } from 'bun:test'
|
|
2
4
|
|
|
3
5
|
import { AstraleError } from '../../errors'
|
|
4
|
-
import { parseTtl } from '../token'
|
|
6
|
+
import { issueToken, parseTtl } from '../token'
|
|
5
7
|
|
|
6
8
|
describe('parseTtl', () => {
|
|
7
|
-
test('defaults
|
|
8
|
-
expect(parseTtl(undefined)).toBe(
|
|
9
|
+
test('defaults below the five-minute local source-credential lifetime', () => {
|
|
10
|
+
expect(parseTtl(undefined)).toBe(240)
|
|
9
11
|
})
|
|
10
12
|
|
|
11
13
|
test('rejects non-positive and non-integer values', () => {
|
|
@@ -19,3 +21,46 @@ describe('parseTtl', () => {
|
|
|
19
21
|
expect(parseTtl('90')).toBe(90)
|
|
20
22
|
})
|
|
21
23
|
})
|
|
24
|
+
|
|
25
|
+
describe('issueToken', () => {
|
|
26
|
+
const kernel = 'https://kernel.test' as IssuerId
|
|
27
|
+
const identity = {
|
|
28
|
+
id: 'caller' as Identity['id'],
|
|
29
|
+
issuer: 'https://issuer.test' as Identity['issuer'],
|
|
30
|
+
subject: 'caller' as Identity['subject'],
|
|
31
|
+
frozen: false,
|
|
32
|
+
requiredClaims: [],
|
|
33
|
+
} satisfies Identity
|
|
34
|
+
|
|
35
|
+
test('mints a reusable top-level credential for the Kernel audience', async () => {
|
|
36
|
+
const mint = mock(async () => 'kernel-token' as MintedCredential)
|
|
37
|
+
const delegate = mock(async () => 'delegated-token' as MintedCredential)
|
|
38
|
+
const whoami = mock(async () => identity)
|
|
39
|
+
const auth = { mint, delegate, whoami } satisfies Pick<AuthApi, 'delegate' | 'mint' | 'whoami'>
|
|
40
|
+
|
|
41
|
+
await expect(issueToken(auth, kernel, kernel, 90)).resolves.toBe(
|
|
42
|
+
'kernel-token' as MintedCredential,
|
|
43
|
+
)
|
|
44
|
+
expect(mint).toHaveBeenCalledWith({ ttlSeconds: 90 })
|
|
45
|
+
expect(whoami).not.toHaveBeenCalled()
|
|
46
|
+
expect(delegate).not.toHaveBeenCalled()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('delegates the selected identity only for an external audience', async () => {
|
|
50
|
+
const mint = mock(async () => 'kernel-token' as MintedCredential)
|
|
51
|
+
const delegate = mock(async () => 'delegated-token' as MintedCredential)
|
|
52
|
+
const whoami = mock(async () => identity)
|
|
53
|
+
const auth = { mint, delegate, whoami } satisfies Pick<AuthApi, 'delegate' | 'mint' | 'whoami'>
|
|
54
|
+
|
|
55
|
+
await expect(issueToken(auth, kernel, 'https://service.test' as IssuerId, 120)).resolves.toBe(
|
|
56
|
+
'delegated-token' as MintedCredential,
|
|
57
|
+
)
|
|
58
|
+
expect(mint).not.toHaveBeenCalled()
|
|
59
|
+
expect(whoami).toHaveBeenCalledTimes(1)
|
|
60
|
+
expect(delegate).toHaveBeenCalledWith(identity.id, {
|
|
61
|
+
audience: 'https://service.test',
|
|
62
|
+
ttlSeconds: 120,
|
|
63
|
+
attenuation: { kind: 'identity', self: true },
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
})
|
package/src/commands/call.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { ConnectionContext, KernelCommandOpts } from '../connection'
|
|
|
4
4
|
import type { CommandDefinition } from '../program/index'
|
|
5
5
|
|
|
6
6
|
import { createPathCall, expandSelfInCall, runKernelCommand, withSelfHint } from '../connection'
|
|
7
|
-
import { presentBinary } from '../lib/binary'
|
|
7
|
+
import { presentBinary, readBinaryBody } from '../lib/binary'
|
|
8
8
|
import { failInput, log } from '../lib/log'
|
|
9
9
|
import { output, present } from '../lib/output'
|
|
10
10
|
|
|
@@ -15,8 +15,20 @@ type CallOpts = KernelCommandOpts & {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
type CallResult = Awaited<ReturnType<ConnectionContext['session']['dispatch']>>
|
|
18
|
+
type BinaryCallResult = Extract<CallResult, { readonly kind: 'binary' }>
|
|
19
|
+
type BinaryCallInput = Omit<BinaryCallResult, 'value'> & {
|
|
20
|
+
readonly value: Omit<BinaryCallResult['value'], 'body' | 'status'> & {
|
|
21
|
+
readonly body: Uint8Array | AsyncIterable<Uint8Array>
|
|
22
|
+
readonly status?: number
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
type CallResultInput = Exclude<CallResult, { readonly kind: 'binary' }> | BinaryCallInput
|
|
26
|
+
type MaterializedBinaryCallResult = Omit<BinaryCallResult, 'value'> & {
|
|
27
|
+
readonly value: Omit<BinaryCallInput['value'], 'body'> & { readonly body: Uint8Array }
|
|
28
|
+
}
|
|
18
29
|
type MaterializedCallResult =
|
|
19
|
-
| Exclude<CallResult, { readonly kind: 'stream' }>
|
|
30
|
+
| Exclude<CallResult, { readonly kind: 'binary' | 'stream' }>
|
|
31
|
+
| MaterializedBinaryCallResult
|
|
20
32
|
| { readonly kind: 'stream'; readonly values: readonly unknown[] }
|
|
21
33
|
|
|
22
34
|
export async function callCommand(
|
|
@@ -68,7 +80,16 @@ export async function callCommand(
|
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
/** Drain a session-backed stream before the command-scoped Client Session closes. */
|
|
71
|
-
export async function materializeCallResult(
|
|
83
|
+
export async function materializeCallResult(
|
|
84
|
+
result: CallResultInput,
|
|
85
|
+
): Promise<MaterializedCallResult> {
|
|
86
|
+
if (result.kind === 'binary') {
|
|
87
|
+
const body = await readBinaryBody(result.value.body)
|
|
88
|
+
return Object.freeze({
|
|
89
|
+
...result,
|
|
90
|
+
value: Object.freeze({ ...result.value, body }),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
72
93
|
if (result.kind !== 'stream') return result
|
|
73
94
|
const values: unknown[] = []
|
|
74
95
|
for await (const value of result.stream) values.push(value)
|
|
@@ -171,6 +192,10 @@ Behavior:
|
|
|
171
192
|
input without executing. Remote-bound functions auto-mint a
|
|
172
193
|
worker-scoped credential; --creds overrides it.
|
|
173
194
|
|
|
195
|
+
Streaming binary bodies are consumed while the Client session remains live,
|
|
196
|
+
then presented through the same --output, --raw, and --json paths as buffered
|
|
197
|
+
binary. JSON retains the application HTTP status and text/base64 body.
|
|
198
|
+
|
|
174
199
|
Self-reference:
|
|
175
200
|
@self expands to your nodeId on the active instance (path head or
|
|
176
201
|
bare param value, e.g. node=@self). --data and stdin payloads are
|
package/src/commands/query.ts
CHANGED
|
@@ -60,7 +60,12 @@ export async function queryCommand(sources: string[], opts: QueryOpts): Promise<
|
|
|
60
60
|
return withSelfHint(() => context.graph.query(resolved.ast, { page: resolved.page }), meta)
|
|
61
61
|
},
|
|
62
62
|
format: (response, format) => {
|
|
63
|
-
output(
|
|
63
|
+
output(
|
|
64
|
+
response.page.next === undefined
|
|
65
|
+
? response.result
|
|
66
|
+
: { ...response.result, page: { next: response.page.next } },
|
|
67
|
+
format,
|
|
68
|
+
)
|
|
64
69
|
if (response.page.next && !isMachine(format)) {
|
|
65
70
|
process.stderr.write(` cursor: ${response.page.next}\n`)
|
|
66
71
|
}
|
|
@@ -102,7 +107,8 @@ Behavior:
|
|
|
102
107
|
one exact Edge-Class expansion; --direction defaults to outgoing. --ast and
|
|
103
108
|
--file accept a complete canonical astrale.graph.query/v6 document, including
|
|
104
109
|
Property ordering and Node or Edge reference/value projections. --cursor resumes
|
|
105
|
-
one caller-bound query scope.
|
|
110
|
+
one caller-bound query scope. Machine output adds page.next only when another
|
|
111
|
+
page exists; pass that opaque value back through --cursor.
|
|
106
112
|
|
|
107
113
|
Legacy depth/children selector JSON and raw Cypher are not portable Kernel
|
|
108
114
|
V2 query contracts and are not accepted.
|
package/src/commands/token.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { issuer } from '@astrale-os/sdk/auth'
|
|
1
|
+
import { issuer, type AuthApi, type IssuerId, type MintedCredential } from '@astrale-os/sdk/auth'
|
|
2
2
|
|
|
3
3
|
import type { KernelCommandOpts } from '../connection'
|
|
4
4
|
import type { CommandDefinition } from '../program/index'
|
|
@@ -10,8 +10,8 @@ import { failInput, log } from '../lib/log'
|
|
|
10
10
|
import { output } from '../lib/output'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* `astrale token` — mint a fresh
|
|
14
|
-
*
|
|
13
|
+
* `astrale token` — mint a fresh audience-bound token for the active instance
|
|
14
|
+
* and selected identity through the bound AuthApi.
|
|
15
15
|
*/
|
|
16
16
|
export type TokenOpts = KernelCommandOpts & {
|
|
17
17
|
audience?: string
|
|
@@ -21,6 +21,8 @@ export type TokenOpts = KernelCommandOpts & {
|
|
|
21
21
|
for?: string
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const DEFAULT_TOKEN_TTL_SECONDS = 4 * 60
|
|
25
|
+
|
|
24
26
|
export async function tokenCommand(opts: TokenOpts): Promise<void> {
|
|
25
27
|
const commandOpts: TokenOpts = opts.for && !opts.as ? { ...opts, as: opts.for } : opts
|
|
26
28
|
let ttl: number
|
|
@@ -31,18 +33,13 @@ export async function tokenCommand(opts: TokenOpts): Promise<void> {
|
|
|
31
33
|
}
|
|
32
34
|
await runKernelCommand<string>({
|
|
33
35
|
opts: commandOpts,
|
|
34
|
-
label: 'Minting
|
|
36
|
+
label: 'Minting token',
|
|
35
37
|
fn: async (ctx) => {
|
|
36
38
|
const audience =
|
|
37
39
|
commandOpts.audience === undefined
|
|
38
40
|
? ctx.target.kernelIssuer
|
|
39
41
|
: issuer.accept(commandOpts.audience)
|
|
40
|
-
|
|
41
|
-
return ctx.auth.delegate(self.id, {
|
|
42
|
-
audience,
|
|
43
|
-
ttlSeconds: ttl,
|
|
44
|
-
attenuation: { kind: 'identity', self: true },
|
|
45
|
-
})
|
|
42
|
+
return issueToken(ctx.auth, ctx.target.kernelIssuer, audience, ttl)
|
|
46
43
|
},
|
|
47
44
|
format: (token, fmtOpts) => {
|
|
48
45
|
if (fmtOpts.json || fmtOpts.format !== undefined) {
|
|
@@ -50,15 +47,31 @@ export async function tokenCommand(opts: TokenOpts): Promise<void> {
|
|
|
50
47
|
return
|
|
51
48
|
}
|
|
52
49
|
if (!fmtOpts.raw && (process.stdout.isTTY ?? false)) {
|
|
53
|
-
log.dim(' (
|
|
50
|
+
log.dim(' (audience-bound token — ES256, self-identity)')
|
|
54
51
|
}
|
|
55
52
|
process.stdout.write(`${token}\n`)
|
|
56
53
|
},
|
|
57
54
|
})
|
|
58
55
|
}
|
|
59
56
|
|
|
57
|
+
/** Issue either a top-level Kernel credential or an external-audience delegation. */
|
|
58
|
+
export async function issueToken(
|
|
59
|
+
auth: Pick<AuthApi, 'delegate' | 'mint' | 'whoami'>,
|
|
60
|
+
kernel: IssuerId,
|
|
61
|
+
audience: IssuerId,
|
|
62
|
+
ttlSeconds: number,
|
|
63
|
+
): Promise<MintedCredential> {
|
|
64
|
+
if (audience === kernel) return auth.mint({ ttlSeconds })
|
|
65
|
+
const self = await auth.whoami()
|
|
66
|
+
return auth.delegate(self.id, {
|
|
67
|
+
audience,
|
|
68
|
+
ttlSeconds,
|
|
69
|
+
attenuation: { kind: 'identity', self: true },
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
export function parseTtl(raw: string | undefined): number {
|
|
61
|
-
if (raw === undefined) return
|
|
74
|
+
if (raw === undefined) return DEFAULT_TOKEN_TTL_SECONDS
|
|
62
75
|
if (!/^\d+$/.test(raw)) {
|
|
63
76
|
throw new AstraleError(
|
|
64
77
|
'INVALID_FLAG',
|
|
@@ -77,16 +90,17 @@ export function parseTtl(raw: string | undefined): number {
|
|
|
77
90
|
|
|
78
91
|
export default {
|
|
79
92
|
name: 'token',
|
|
80
|
-
description: 'Mint a fresh
|
|
93
|
+
description: 'Mint a fresh audience-bound credential for the active instance + identity',
|
|
81
94
|
afterHelpText: `
|
|
82
95
|
Behavior:
|
|
83
|
-
|
|
84
|
-
The default audience
|
|
85
|
-
|
|
96
|
+
Mints a token for the selected authenticated identity for 240 seconds by default.
|
|
97
|
+
The default Kernel audience produces a top-level Grant credential reusable with
|
|
98
|
+
--creds. A different --audience produces a delegated service envelope. --for is
|
|
99
|
+
an alias of --as.
|
|
86
100
|
|
|
87
|
-
|
|
88
|
-
be
|
|
89
|
-
|
|
101
|
+
Every receiver must admit the exact token audience. A service-audience token can
|
|
102
|
+
be sent as a Bearer token only to that service's authenticated endpoint. The
|
|
103
|
+
requested lifetime cannot exceed the selected source credential's remaining life.
|
|
90
104
|
|
|
91
105
|
Examples:
|
|
92
106
|
$ export TOKEN=$(astrale token --audience shell.astrale.ai --raw)
|
|
@@ -98,7 +112,7 @@ Examples:
|
|
|
98
112
|
flags: '--audience <aud>',
|
|
99
113
|
description: 'Token audience (default: target Kernel issuer)',
|
|
100
114
|
},
|
|
101
|
-
{ flags: '--ttl <sec>', description: 'TTL in seconds (default:
|
|
115
|
+
{ flags: '--ttl <sec>', description: 'TTL in seconds (default: 240)' },
|
|
102
116
|
{ flags: '--for <identity>', description: 'Mint the token for this identity (alias of --as)' },
|
|
103
117
|
],
|
|
104
118
|
action: async (opts) => {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import addCommand from '../add'
|
|
4
|
+
import listCommand from '../list'
|
|
5
|
+
|
|
6
|
+
class ExitError extends Error {}
|
|
7
|
+
|
|
8
|
+
const commit = 'a'.repeat(40)
|
|
9
|
+
const item = {
|
|
10
|
+
name: 'pattern-chart-line-basic',
|
|
11
|
+
type: 'registry:block',
|
|
12
|
+
title: 'Line chart',
|
|
13
|
+
description: 'A controlled chart.',
|
|
14
|
+
dependencies: ['@astrale-os/ui@^0.3.0-beta.0'],
|
|
15
|
+
files: [
|
|
16
|
+
{
|
|
17
|
+
path: 'registry/patterns/chart/line-basic.tsx',
|
|
18
|
+
type: 'registry:component',
|
|
19
|
+
target: 'components/astrale/pattern/chart/line-basic.tsx',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
meta: { canonicalAddress: 'pattern/chart/line/basic' },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let stdout = ''
|
|
26
|
+
let stderr = ''
|
|
27
|
+
let originalArgv: string[]
|
|
28
|
+
let originalExit: typeof process.exit
|
|
29
|
+
let originalFetch: typeof globalThis.fetch
|
|
30
|
+
let originalStdout: typeof process.stdout.write
|
|
31
|
+
let originalStderr: typeof process.stderr.write
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
stdout = ''
|
|
35
|
+
stderr = ''
|
|
36
|
+
originalArgv = process.argv
|
|
37
|
+
originalExit = process.exit
|
|
38
|
+
originalFetch = globalThis.fetch
|
|
39
|
+
originalStdout = process.stdout.write
|
|
40
|
+
originalStderr = process.stderr.write
|
|
41
|
+
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
42
|
+
stdout += String(chunk)
|
|
43
|
+
return true
|
|
44
|
+
}) as typeof process.stdout.write
|
|
45
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
46
|
+
stderr += String(chunk)
|
|
47
|
+
return true
|
|
48
|
+
}) as typeof process.stderr.write
|
|
49
|
+
process.exit = (() => {
|
|
50
|
+
throw new ExitError()
|
|
51
|
+
}) as typeof process.exit
|
|
52
|
+
globalThis.fetch = mockFetch()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
process.argv = originalArgv
|
|
57
|
+
process.exit = originalExit
|
|
58
|
+
globalThis.fetch = originalFetch
|
|
59
|
+
process.stdout.write = originalStdout
|
|
60
|
+
process.stderr.write = originalStderr
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('UI command machine contracts', () => {
|
|
64
|
+
test('list emits exactly one parseable JSON value', async () => {
|
|
65
|
+
const action = listCommand.action as (
|
|
66
|
+
query: string | undefined,
|
|
67
|
+
options: { json?: boolean; limit?: string },
|
|
68
|
+
) => Promise<void>
|
|
69
|
+
await action('line-basic', { json: true, limit: '100' })
|
|
70
|
+
|
|
71
|
+
expect(stderr).toBe('')
|
|
72
|
+
expect(JSON.parse(stdout)).toEqual([item])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('add rejects missing items without prompting in machine mode', async () => {
|
|
76
|
+
process.argv = ['node', 'astrale', '--no-prompt', 'ui', 'add']
|
|
77
|
+
const add = addCommand.action as (items: string[], options: { json?: boolean }) => Promise<void>
|
|
78
|
+
await expect(add([], { json: true })).rejects.toBeInstanceOf(ExitError)
|
|
79
|
+
expect(JSON.parse(stderr)).toMatchObject({ error: 'UI_ITEM_NOT_FOUND' })
|
|
80
|
+
expect(stdout).toBe('')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('registry failures retain a stable code without leaking transport secrets', async () => {
|
|
84
|
+
globalThis.fetch = (async () => {
|
|
85
|
+
throw new Error('Authorization: Bearer npm_super_secret_value_that_must_not_escape')
|
|
86
|
+
}) as unknown as typeof fetch
|
|
87
|
+
const action = listCommand.action as (
|
|
88
|
+
query: string | undefined,
|
|
89
|
+
options: { json?: boolean; limit?: string },
|
|
90
|
+
) => Promise<void>
|
|
91
|
+
await expect(action('line-basic', { json: true, limit: '100' })).rejects.toBeInstanceOf(
|
|
92
|
+
ExitError,
|
|
93
|
+
)
|
|
94
|
+
expect(JSON.parse(stderr)).toEqual({
|
|
95
|
+
error: 'UI_REGISTRY_UNAVAILABLE',
|
|
96
|
+
message: 'Unable to reach npm UI release.',
|
|
97
|
+
})
|
|
98
|
+
expect(stderr).not.toContain('super_secret')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
function mockFetch(): typeof fetch {
|
|
103
|
+
return (async (input: string | URL | Request) => {
|
|
104
|
+
const url = String(input)
|
|
105
|
+
if (url.endsWith('/@astrale-os/ui/latest')) return Response.json({ version: '0.3.0-beta.0' })
|
|
106
|
+
if (url.includes('/git/ref/tags/')) {
|
|
107
|
+
return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
|
|
108
|
+
}
|
|
109
|
+
if (url.endsWith('/tooling/compatibility.json')) {
|
|
110
|
+
return Response.json({
|
|
111
|
+
version: 1,
|
|
112
|
+
shadcn: '4.18.0',
|
|
113
|
+
base: 'base',
|
|
114
|
+
style: 'nova',
|
|
115
|
+
baseUi: '1.7.0',
|
|
116
|
+
react: '^18.3.1 || ^19.0.0',
|
|
117
|
+
tailwind: '^4.3.3',
|
|
118
|
+
presets: ['astrale', 'compact', 'expressive'],
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
if (url.endsWith('/registry/patterns/chart/registry.json')) {
|
|
122
|
+
return Response.json({ items: [item] })
|
|
123
|
+
}
|
|
124
|
+
if (url.endsWith('/registry.json')) {
|
|
125
|
+
return Response.json({ include: ['registry/patterns/chart/registry.json'] })
|
|
126
|
+
}
|
|
127
|
+
return new Response('not found', { status: 404 })
|
|
128
|
+
}) as typeof fetch
|
|
129
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CommandDefinition } from '../../program'
|
|
2
|
+
|
|
3
|
+
import { promptMultiSelect } from '../../lib/prompt'
|
|
4
|
+
import { addUi, listLockedUi, UiError } from '../../ui'
|
|
5
|
+
import { UI_JSON_OPTION, UI_PROJECT_OPTION, runUiCommand, type UiCommandOptions } from './shared'
|
|
6
|
+
|
|
7
|
+
type Options = UiCommandOptions & { dryRun?: boolean; overwrite?: boolean; yes?: boolean }
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
name: 'add',
|
|
11
|
+
description: 'Install consumer-owned Astrale pattern or block source',
|
|
12
|
+
arguments: [
|
|
13
|
+
{
|
|
14
|
+
name: 'items',
|
|
15
|
+
description: 'Canonical pattern or block addresses',
|
|
16
|
+
required: false,
|
|
17
|
+
variadic: true,
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
options: [
|
|
21
|
+
UI_PROJECT_OPTION,
|
|
22
|
+
{ flags: '--dry-run', description: 'Show exact files and dependencies without writing' },
|
|
23
|
+
{ flags: '--overwrite', description: 'Allow replacing locally changed installed source' },
|
|
24
|
+
{ flags: '--yes', description: 'Confirm the planned operation non-interactively' },
|
|
25
|
+
UI_JSON_OPTION,
|
|
26
|
+
],
|
|
27
|
+
afterHelpText:
|
|
28
|
+
'\nInstalled source belongs to the application. Ordinary add never overwrites local edits.\nUse diff first, then --overwrite --yes when replacement is intentional.\n',
|
|
29
|
+
action: async (items: string[], options: Options) =>
|
|
30
|
+
runUiCommand(options, async () => {
|
|
31
|
+
let selected = items
|
|
32
|
+
if (selected.length === 0) {
|
|
33
|
+
if (process.argv.includes('--ci') || process.argv.includes('--no-prompt')) {
|
|
34
|
+
throw new UiError('UI_ITEM_NOT_FOUND', 'No UI item was provided in non-interactive mode.')
|
|
35
|
+
}
|
|
36
|
+
const available = await listLockedUi(options.project)
|
|
37
|
+
selected =
|
|
38
|
+
(await promptMultiSelect(
|
|
39
|
+
'Choose Astrale UI source to install',
|
|
40
|
+
available.map((item) => ({
|
|
41
|
+
name: item.title ?? item.meta.canonicalAddress,
|
|
42
|
+
value: item.meta.canonicalAddress,
|
|
43
|
+
description: item.description,
|
|
44
|
+
})),
|
|
45
|
+
)) ?? []
|
|
46
|
+
if (selected.length === 0)
|
|
47
|
+
throw new UiError('UI_ITEM_NOT_FOUND', 'No UI item was selected.')
|
|
48
|
+
}
|
|
49
|
+
return addUi(selected, options)
|
|
50
|
+
}),
|
|
51
|
+
} satisfies CommandDefinition
|