@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.
Files changed (56) hide show
  1. package/dist/astrale.js +15432 -14745
  2. package/dist/types/admin/contract.d.ts +26 -0
  3. package/dist/types/admin/instance/client.d.ts +2 -4
  4. package/dist/types/admin/instance/model.d.ts +3 -0
  5. package/package.json +1 -1
  6. package/src/admin/.spec/architecture.md +12 -5
  7. package/src/admin/__tests__/fixture.ts +19 -95
  8. package/src/admin/catalog/.spec/api.d.ts +0 -2
  9. package/src/admin/catalog/.spec/architecture.md +5 -4
  10. package/src/admin/catalog/__tests__/client.test.ts +136 -33
  11. package/src/admin/catalog/client.ts +38 -61
  12. package/src/admin/contract.ts +46 -0
  13. package/src/admin/instance/.spec/api.d.ts +4 -8
  14. package/src/admin/instance/.spec/architecture.md +7 -7
  15. package/src/admin/instance/__tests__/client.test.ts +138 -31
  16. package/src/admin/instance/client.ts +32 -37
  17. package/src/admin/instance/model.ts +3 -0
  18. package/src/commands/__tests__/call.test.ts +34 -0
  19. package/src/commands/__tests__/read-commands.test.ts +27 -0
  20. package/src/commands/__tests__/token-ttl.test.ts +49 -4
  21. package/src/commands/call.ts +28 -3
  22. package/src/commands/query.ts +8 -2
  23. package/src/commands/token.ts +34 -20
  24. package/src/commands/ui/__tests__/commands.test.ts +129 -0
  25. package/src/commands/ui/add.ts +51 -0
  26. package/src/commands/ui/doctor.ts +13 -0
  27. package/src/commands/ui/init.ts +38 -0
  28. package/src/commands/ui/list.ts +26 -0
  29. package/src/commands/ui/preset-apply.ts +19 -0
  30. package/src/commands/ui/preset-list.ts +12 -0
  31. package/src/commands/ui/shared.ts +25 -0
  32. package/src/lib/__tests__/binary.test.ts +16 -1
  33. package/src/lib/binary.ts +22 -5
  34. package/src/lib/proc.ts +7 -2
  35. package/src/program/.spec/api.d.ts +1 -0
  36. package/src/program/__tests__/program.test.ts +32 -2
  37. package/src/program/build.ts +23 -1
  38. package/src/program/command.ts +1 -0
  39. package/src/program/registry.ts +2 -1
  40. package/src/ui/.spec/api.d.ts +14 -0
  41. package/src/ui/.spec/architecture.md +10 -0
  42. package/src/ui/.spec/laws.ts +36 -0
  43. package/src/ui/.spec/layout.ts +16 -0
  44. package/src/ui/__tests__/ui.test.ts +542 -0
  45. package/src/ui/index.ts +13 -0
  46. package/src/ui/lock.ts +87 -0
  47. package/src/ui/model.ts +83 -0
  48. package/src/ui/operations.ts +539 -0
  49. package/src/ui/project.ts +146 -0
  50. package/src/ui/release.ts +267 -0
  51. package/src/ui/runner.ts +18 -0
  52. package/studio/server/agent/harness/gateway/token.test.ts +1 -1
  53. package/studio/server/agent/harness/gateway/token.ts +3 -3
  54. package/dist/types/admin/binding.d.ts +0 -19
  55. package/src/admin/__tests__/binding.test.ts +0 -31
  56. 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
- * Bind one discovered Admin root revision and expose only the public Instance
41
- * product journey. No Host lifecycle method is present on this capability.
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 invokeAdminMethod(
42
+ const result: unknown = await callAdminMethod(
57
43
  context.session,
58
- binding,
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 invokeAdminMethod(
80
- context.session,
81
- binding,
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 invokeAdminMethod(context.session, binding, Fleet, 'createInstance', fleet, input),
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 invokeAdminMethod(
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: requiredString(value.id, 'Admin Instance 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: requiredString(value.domain, 'Admin Domain reference'),
150
- instance: requiredString(value.instance, 'Admin Instance reference'),
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 { describe, expect, test } from 'bun:test'
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 to 3600 seconds', () => {
8
- expect(parseTtl(undefined)).toBe(3600)
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
+ })
@@ -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(result: CallResult): Promise<MaterializedCallResult> {
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
@@ -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(response.result, format)
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.
@@ -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 delegation token for the active instance
14
- * + active identity through the bound AuthApi.
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 delegation token',
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
- const self = await ctx.auth.whoami()
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(' (delegation token — ES256, self-identity)')
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 3600
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 delegation token for the active instance + identity',
93
+ description: 'Mint a fresh audience-bound credential for the active instance + identity',
81
94
  afterHelpText: `
82
95
  Behavior:
83
- Delegates the selected authenticated identity for 3600 seconds by default.
84
- The default audience is the target Kernel issuer; choose --audience when the
85
- credential is intended for another service. --for is an alias of --as.
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
- The receiver must admit the exact token audience. A Kernel-audience token can
88
- be reused with --creds; a service-audience token can be sent as a Bearer token
89
- to that service's authenticated endpoint.
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: 3600)' },
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