@astrale-os/cli 0.8.1-alpha.4 → 0.8.1-alpha.6
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 +827 -575
- package/dist/public/connect-core.js +22 -16
- package/dist/public/keys/index.js +3 -10
- package/dist/public/paths/index.js +3 -10
- package/dist/types/lib/instance-target.d.ts +2 -0
- package/dist/types/lib/invocation.d.ts +3 -0
- package/dist/types/lib/log.d.ts +6 -5
- package/dist/types/lib/output.d.ts +6 -2
- package/package.json +1 -1
- package/src/commands/__tests__/introspect-parse.test.ts +21 -0
- package/src/commands/__tests__/logs.test.ts +5 -0
- package/src/commands/__tests__/read-commands.test.ts +11 -1
- package/src/commands/__tests__/token-ttl.test.ts +21 -0
- package/src/commands/auth/token.ts +19 -7
- package/src/commands/call.ts +18 -43
- package/src/commands/get.ts +23 -5
- package/src/commands/identity/create.ts +11 -2
- package/src/commands/identity/delete.ts +8 -2
- package/src/commands/identity/export.ts +8 -2
- package/src/commands/identity/import.ts +11 -2
- package/src/commands/identity/sync.ts +8 -2
- package/src/commands/identity/unsync.ts +12 -2
- package/src/commands/identity/use.ts +8 -2
- package/src/commands/identity/whoami.ts +1 -1
- package/src/commands/introspect.ts +117 -0
- package/src/commands/logs.ts +50 -6
- package/src/commands/mutate.ts +2 -3
- package/src/commands/query.ts +3 -5
- package/src/commands/token.ts +35 -8
- package/src/commands/update.ts +30 -9
- package/src/commands/view.ts +5 -3
- package/src/connection/__tests__/credential.test.ts +10 -0
- package/src/connection/__tests__/errors.test.ts +20 -0
- package/src/connection/credential.ts +3 -0
- package/src/connection/errors.ts +74 -12
- package/src/connection/session.ts +9 -0
- package/src/connection/target.ts +1 -0
- package/src/graph/__tests__/mutation.test.ts +6 -0
- package/src/graph/mutation.ts +13 -0
- package/src/identity/__tests__/registry.test.ts +1 -1
- package/src/identity/registry.ts +12 -5
- package/src/lib/__tests__/command-dx.test.ts +47 -15
- package/src/lib/__tests__/instance-target.test.ts +17 -0
- package/src/lib/__tests__/output.test.ts +12 -0
- package/src/lib/command-dx.ts +41 -23
- package/src/lib/instance-target.ts +18 -2
- package/src/lib/invocation.ts +11 -0
- package/src/lib/log.ts +17 -9
- package/src/lib/output.ts +20 -4
- package/src/program/__tests__/program.test.ts +4 -2
- package/src/program/build.ts +3 -9
- package/src/program/options.ts +2 -1
- package/src/state/__tests__/identities.test.ts +2 -2
- package/src/state/identities.ts +3 -10
- package/src/commands/describe.ts +0 -86
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
isEncryptedIdentityExport,
|
|
9
9
|
} from '../../identity/index'
|
|
10
10
|
import { fatal, log } from '../../lib/log'
|
|
11
|
+
import { isMachine, output, RAW_OUTPUT_OPTIONS, type RawOutputOpts } from '../../lib/output'
|
|
11
12
|
import { readPassphrase } from '../../lib/prompt'
|
|
12
13
|
|
|
13
14
|
export default {
|
|
@@ -27,8 +28,12 @@ export default {
|
|
|
27
28
|
flags: '--replace',
|
|
28
29
|
description: 'Replace an existing key-backed identity with the imported keypair',
|
|
29
30
|
},
|
|
31
|
+
...RAW_OUTPUT_OPTIONS,
|
|
30
32
|
],
|
|
31
|
-
action: async (
|
|
33
|
+
action: async (
|
|
34
|
+
path: string,
|
|
35
|
+
opts: { name?: string; issuer?: string; replace?: boolean } & RawOutputOpts,
|
|
36
|
+
) => {
|
|
32
37
|
try {
|
|
33
38
|
const raw = await readFile(path, 'utf-8')
|
|
34
39
|
const passphrase = isEncryptedIdentityExport(raw)
|
|
@@ -42,11 +47,15 @@ export default {
|
|
|
42
47
|
replace: opts.replace,
|
|
43
48
|
})
|
|
44
49
|
|
|
50
|
+
if (isMachine(opts)) {
|
|
51
|
+
output({ name, ...identity }, opts)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
45
54
|
log.success(
|
|
46
55
|
`Imported identity "${name}" (subject=${identity.subject}, kid=${identity.kid ?? '?'})`,
|
|
47
56
|
)
|
|
48
57
|
} catch (e) {
|
|
49
|
-
fatal(e)
|
|
58
|
+
fatal(e, opts)
|
|
50
59
|
}
|
|
51
60
|
},
|
|
52
61
|
} satisfies CommandDefinition
|
|
@@ -2,6 +2,7 @@ import type { CommandDefinition } from '../../program/index'
|
|
|
2
2
|
|
|
3
3
|
import { getIdentity, setIdentityMode } from '../../identity/index'
|
|
4
4
|
import { fatal, fatalNotImplemented, log } from '../../lib/log'
|
|
5
|
+
import { isMachine, output, RAW_OUTPUT_OPTIONS, type RawOutputOpts } from '../../lib/output'
|
|
5
6
|
|
|
6
7
|
export default {
|
|
7
8
|
name: 'sync',
|
|
@@ -12,8 +13,9 @@ export default {
|
|
|
12
13
|
flags: '--force',
|
|
13
14
|
description: 'Tag as remote in the local registry even without cloud login',
|
|
14
15
|
},
|
|
16
|
+
...RAW_OUTPUT_OPTIONS,
|
|
15
17
|
],
|
|
16
|
-
action: async (name: string, opts: { force?: boolean }) => {
|
|
18
|
+
action: async (name: string, opts: { force?: boolean } & RawOutputOpts) => {
|
|
17
19
|
try {
|
|
18
20
|
const identity = await getIdentity(name)
|
|
19
21
|
if (!opts.force) {
|
|
@@ -23,10 +25,14 @@ export default {
|
|
|
23
25
|
)
|
|
24
26
|
}
|
|
25
27
|
await setIdentityMode(name, 'remote')
|
|
28
|
+
if (isMachine(opts)) {
|
|
29
|
+
output({ name, mode: 'remote', subject: identity.subject }, opts)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
26
32
|
log.warn(`Tagged "${name}" as remote locally (cloud sync stubbed in v1)`)
|
|
27
33
|
log.dim(` subject=${identity.subject}`)
|
|
28
34
|
} catch (e) {
|
|
29
|
-
fatal(e)
|
|
35
|
+
fatal(e, opts)
|
|
30
36
|
}
|
|
31
37
|
},
|
|
32
38
|
} satisfies CommandDefinition
|
|
@@ -2,23 +2,33 @@ import type { CommandDefinition } from '../../program/index'
|
|
|
2
2
|
|
|
3
3
|
import { getIdentity, setIdentityMode } from '../../identity/index'
|
|
4
4
|
import { fatal, log } from '../../lib/log'
|
|
5
|
+
import { isMachine, output, RAW_OUTPUT_OPTIONS, type RawOutputOpts } from '../../lib/output'
|
|
5
6
|
|
|
6
7
|
/** Metadata-only flip remote → local until cloud sync ships (§2.7). */
|
|
7
8
|
export default {
|
|
8
9
|
name: 'unsync',
|
|
9
10
|
description: 'Migrate an identity remote → local',
|
|
10
11
|
arguments: [{ name: 'name', description: 'Identity name', required: true }],
|
|
11
|
-
|
|
12
|
+
options: [...RAW_OUTPUT_OPTIONS],
|
|
13
|
+
action: async (name: string, opts: RawOutputOpts) => {
|
|
12
14
|
try {
|
|
13
15
|
const identity = await getIdentity(name)
|
|
14
16
|
if (identity.mode !== 'remote') {
|
|
17
|
+
if (isMachine(opts)) {
|
|
18
|
+
output({ name, mode: identity.mode ?? 'local', unchanged: true }, opts)
|
|
19
|
+
return
|
|
20
|
+
}
|
|
15
21
|
log.warn(`Identity "${name}" is already ${identity.mode ?? 'local'}`)
|
|
16
22
|
return
|
|
17
23
|
}
|
|
18
24
|
await setIdentityMode(name, 'local')
|
|
25
|
+
if (isMachine(opts)) {
|
|
26
|
+
output({ name, mode: 'local' }, opts)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
19
29
|
log.success(`Identity "${name}" → local`)
|
|
20
30
|
} catch (e) {
|
|
21
|
-
fatal(e)
|
|
31
|
+
fatal(e, opts)
|
|
22
32
|
}
|
|
23
33
|
},
|
|
24
34
|
} satisfies CommandDefinition
|
|
@@ -2,17 +2,23 @@ import type { CommandDefinition } from '../../program/index'
|
|
|
2
2
|
|
|
3
3
|
import { setDefault } from '../../identity/index'
|
|
4
4
|
import { fatal, log } from '../../lib/log'
|
|
5
|
+
import { isMachine, output, RAW_OUTPUT_OPTIONS, type RawOutputOpts } from '../../lib/output'
|
|
5
6
|
|
|
6
7
|
export default {
|
|
7
8
|
name: 'use',
|
|
8
9
|
description: 'Set the active CLI identity',
|
|
9
10
|
arguments: [{ name: 'name', description: 'Identity name', required: true }],
|
|
10
|
-
|
|
11
|
+
options: [...RAW_OUTPUT_OPTIONS],
|
|
12
|
+
action: async (name: string, opts: RawOutputOpts) => {
|
|
11
13
|
try {
|
|
12
14
|
await setDefault(name)
|
|
15
|
+
if (isMachine(opts)) {
|
|
16
|
+
output({ default: name }, opts)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
13
19
|
log.success(`Active identity set to "${name}"`)
|
|
14
20
|
} catch (e) {
|
|
15
|
-
fatal(e)
|
|
21
|
+
fatal(e, opts)
|
|
16
22
|
}
|
|
17
23
|
},
|
|
18
24
|
} satisfies CommandDefinition
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Path } from '@astrale-os/sdk/graph/path'
|
|
2
|
+
|
|
3
|
+
import type { KernelCommandOpts } from '../connection'
|
|
4
|
+
import type { CommandDefinition } from '../program/index'
|
|
5
|
+
|
|
6
|
+
import { runKernelCommand } from '../connection'
|
|
7
|
+
import { AstraleError } from '../errors'
|
|
8
|
+
import { failClosed } from '../lib/log'
|
|
9
|
+
import { output } from '../lib/output'
|
|
10
|
+
import { describeCallableFromSchema, missingCallableDescription } from './call-describe'
|
|
11
|
+
|
|
12
|
+
type IntrospectOpts = KernelCommandOpts & { bundle?: boolean }
|
|
13
|
+
|
|
14
|
+
export async function introspectCommand(target: string, opts: IntrospectOpts): Promise<void> {
|
|
15
|
+
let origin: string
|
|
16
|
+
let path: Path
|
|
17
|
+
try {
|
|
18
|
+
;({ origin, path } = parseIntrospectTarget(target))
|
|
19
|
+
} catch (error) {
|
|
20
|
+
failClosed(error, opts)
|
|
21
|
+
}
|
|
22
|
+
const wantsCallable = isCallablePath(path)
|
|
23
|
+
|
|
24
|
+
await runKernelCommand({
|
|
25
|
+
opts,
|
|
26
|
+
label: `Introspect ${origin}`,
|
|
27
|
+
fn: async ({ session }) => {
|
|
28
|
+
const includeBundle = wantsCallable || opts.bundle === true
|
|
29
|
+
const result = await session.schema.introspect({
|
|
30
|
+
from: { kind: 'installation', origin },
|
|
31
|
+
select: {
|
|
32
|
+
state: true,
|
|
33
|
+
target: true,
|
|
34
|
+
source: true,
|
|
35
|
+
readiness: true,
|
|
36
|
+
capabilities: true,
|
|
37
|
+
...(includeBundle ? { bundle: true as const } : {}),
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
if (result === null) {
|
|
41
|
+
throw new AstraleError(
|
|
42
|
+
'DOMAIN_NOT_INSTALLED',
|
|
43
|
+
`Domain ${origin} is not installed on this Kernel.`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
if (wantsCallable) {
|
|
47
|
+
const described = describeCallableFromSchema(path, bundleRoot(result.bundle))
|
|
48
|
+
if (described === undefined) throw missingCallableDescription(path.raw)
|
|
49
|
+
return described
|
|
50
|
+
}
|
|
51
|
+
return result
|
|
52
|
+
},
|
|
53
|
+
format: (value, format) => output(value, format),
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseIntrospectTarget(target: string): { origin: string; path: Path } {
|
|
58
|
+
if (target.startsWith('@')) {
|
|
59
|
+
throw new AstraleError(
|
|
60
|
+
'NOT_A_DOMAIN',
|
|
61
|
+
'introspect requires a Domain origin or Path, not an @id.',
|
|
62
|
+
'Example: astrale introspect host.astrale.ai or /:host.astrale.ai:class.Manager:createInstance',
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
const raw = target.startsWith('/') ? target : `/:${target}`
|
|
66
|
+
let path: Path
|
|
67
|
+
try {
|
|
68
|
+
path = Path.parse(raw)
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw new AstraleError(
|
|
71
|
+
'PATH_INVALID',
|
|
72
|
+
error instanceof Error ? error.message : 'Invalid introspect target',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
if (path.ast.anchor.kind !== 'domain') {
|
|
76
|
+
throw new AstraleError('NOT_A_DOMAIN', 'introspect requires a Domain-rooted Path or origin.')
|
|
77
|
+
}
|
|
78
|
+
return { origin: path.ast.anchor.origin, path }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isCallablePath(path: Path): boolean {
|
|
82
|
+
const last = path.ast.steps.at(-1)
|
|
83
|
+
if (last === undefined) return false
|
|
84
|
+
if (last.kind === 'method') return true
|
|
85
|
+
return last.kind === 'projection' && last.projection.kind === 'function'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function bundleRoot(bundle: unknown): unknown {
|
|
89
|
+
if (bundle !== null && typeof bundle === 'object' && 'root' in bundle) {
|
|
90
|
+
return (bundle as { root: unknown }).root
|
|
91
|
+
}
|
|
92
|
+
return bundle
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default {
|
|
96
|
+
name: 'introspect',
|
|
97
|
+
description: 'Read installed Domain schema from the Kernel Schema syscall',
|
|
98
|
+
afterHelpText: `
|
|
99
|
+
Behavior:
|
|
100
|
+
Calls the public Kernel introspect syscall for one installed Domain.
|
|
101
|
+
A bare origin (host.astrale.ai or /:host.astrale.ai) prints installation
|
|
102
|
+
state, target, source, readiness, and capabilities. --bundle includes the
|
|
103
|
+
schema bundle. A method or Function Path projects that callable's
|
|
104
|
+
input/output from the installed bundle.
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
$ astrale introspect host.astrale.ai
|
|
108
|
+
$ astrale introspect /:host.astrale.ai --bundle
|
|
109
|
+
$ astrale introspect /:host.astrale.ai:class.Manager:createInstance
|
|
110
|
+
$ astrale introspect /:kernel.astrale.ai:function.journal
|
|
111
|
+
`,
|
|
112
|
+
arguments: [{ name: 'target', description: 'Domain origin or canonical Path' }],
|
|
113
|
+
options: [{ flags: '--bundle', description: 'Include the installed schema bundle' }],
|
|
114
|
+
action: async (target, opts) => {
|
|
115
|
+
await introspectCommand(target as string, opts as IntrospectOpts)
|
|
116
|
+
},
|
|
117
|
+
} satisfies CommandDefinition
|
package/src/commands/logs.ts
CHANGED
|
@@ -6,7 +6,13 @@ import type { ConnectionContext, KernelCommandOpts } from '../connection'
|
|
|
6
6
|
import type { Column, ListProjection } from '../lib/output'
|
|
7
7
|
import type { CommandDefinition } from '../program/index'
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createPathCall,
|
|
11
|
+
expandSelfInPath,
|
|
12
|
+
runKernelCommand,
|
|
13
|
+
withClientSession,
|
|
14
|
+
} from '../connection'
|
|
15
|
+
import { failClosed } from '../lib/log'
|
|
10
16
|
import { isMachine, output, presentList } from '../lib/output'
|
|
11
17
|
|
|
12
18
|
const JOURNAL_PATH = Path.project(syscalls.journal.ref).raw
|
|
@@ -50,10 +56,18 @@ export interface JournalInput {
|
|
|
50
56
|
readonly limit: number
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
|
60
|
+
const CURSOR_TOKEN = /^[A-Za-z0-9._:+=/-]{8,}$/
|
|
61
|
+
|
|
53
62
|
/** Map flags to the exact public journal syscall input without legacy glob/sequence lowering. */
|
|
54
63
|
export function buildJournalInput(opts: LogsOpts): JournalInput {
|
|
55
64
|
const exact = nonEmpty(opts.topic)
|
|
56
65
|
const prefix = nonEmpty(opts.topicPrefix)
|
|
66
|
+
const since = timestampFlag('--since', opts.since)
|
|
67
|
+
const until = timestampFlag('--until', opts.until)
|
|
68
|
+
if (since !== undefined && until !== undefined && Date.parse(since) > Date.parse(until)) {
|
|
69
|
+
throw new TypeError('--since must be earlier than or equal to --until')
|
|
70
|
+
}
|
|
57
71
|
return {
|
|
58
72
|
...(exact === undefined && prefix === undefined
|
|
59
73
|
? {}
|
|
@@ -64,13 +78,30 @@ export function buildJournalInput(opts: LogsOpts): JournalInput {
|
|
|
64
78
|
},
|
|
65
79
|
}),
|
|
66
80
|
...(nonEmpty(opts.principal) === undefined ? {} : { principal: opts.principal }),
|
|
67
|
-
...(
|
|
68
|
-
...(
|
|
69
|
-
...(
|
|
81
|
+
...(since === undefined ? {} : { since }),
|
|
82
|
+
...(until === undefined ? {} : { until }),
|
|
83
|
+
...(opts.cursor === undefined ? {} : { cursor: cursorFlag(opts.cursor) }),
|
|
70
84
|
limit: opts.limit === undefined ? DEFAULT_LIMIT : positiveInteger('--limit', opts.limit),
|
|
71
85
|
}
|
|
72
86
|
}
|
|
73
87
|
|
|
88
|
+
function timestampFlag(name: string, raw: string | undefined): string | undefined {
|
|
89
|
+
const value = nonEmpty(raw)
|
|
90
|
+
if (value === undefined) return undefined
|
|
91
|
+
if (!ISO_TIMESTAMP.test(value) || Number.isNaN(Date.parse(value))) {
|
|
92
|
+
throw new TypeError(`${name} must be an ISO-8601 timestamp (e.g. 2026-08-19T16:51:10.049Z)`)
|
|
93
|
+
}
|
|
94
|
+
return value
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cursorFlag(raw: string): string {
|
|
98
|
+
const value = raw.trim()
|
|
99
|
+
if (!CURSOR_TOKEN.test(value)) {
|
|
100
|
+
throw new TypeError('--cursor must be an opaque journal resume token (at least 8 characters)')
|
|
101
|
+
}
|
|
102
|
+
return value
|
|
103
|
+
}
|
|
104
|
+
|
|
74
105
|
/** Validate the record fields the CLI presentation consumes and retain the opaque cursor. */
|
|
75
106
|
export function acceptJournalPage(input: unknown): JournalPage {
|
|
76
107
|
if (!isRecord(input) || !Array.isArray(input.records)) {
|
|
@@ -196,6 +227,14 @@ function positiveInteger(flag: string, raw: string): number {
|
|
|
196
227
|
return value
|
|
197
228
|
}
|
|
198
229
|
|
|
230
|
+
async function prepareLogsOpts(opts: LogsOpts): Promise<LogsOpts> {
|
|
231
|
+
buildJournalInput(opts)
|
|
232
|
+
const principal = nonEmpty(opts.principal)
|
|
233
|
+
if (principal !== '@self') return opts
|
|
234
|
+
const { path } = await expandSelfInPath('@self', opts)
|
|
235
|
+
return { ...opts, principal: path.startsWith('@') ? path.slice(1) : path }
|
|
236
|
+
}
|
|
237
|
+
|
|
199
238
|
function nonEmpty(input: string | undefined): string | undefined {
|
|
200
239
|
const value = input?.trim()
|
|
201
240
|
return value ? value : undefined
|
|
@@ -234,7 +273,12 @@ Examples:
|
|
|
234
273
|
{ flags: '--follow', description: 'Poll using returned cursors until interrupted' },
|
|
235
274
|
],
|
|
236
275
|
action: async (opts: LogsOpts) => {
|
|
237
|
-
|
|
238
|
-
|
|
276
|
+
try {
|
|
277
|
+
const prepared = await prepareLogsOpts(opts)
|
|
278
|
+
if (prepared.follow) await follow(prepared)
|
|
279
|
+
else await runOnce(prepared)
|
|
280
|
+
} catch (error) {
|
|
281
|
+
failClosed(error, opts)
|
|
282
|
+
}
|
|
239
283
|
},
|
|
240
284
|
} satisfies CommandDefinition
|
package/src/commands/mutate.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { CommandDefinition } from '../program/index'
|
|
|
8
8
|
|
|
9
9
|
import { runKernelCommand } from '../connection'
|
|
10
10
|
import { prepareMutation } from '../graph/index'
|
|
11
|
-
import {
|
|
11
|
+
import { failClosed } from '../lib/log'
|
|
12
12
|
import { output } from '../lib/output'
|
|
13
13
|
import { renderTable } from '../lib/table'
|
|
14
14
|
|
|
@@ -23,8 +23,7 @@ export async function mutateCommand(opts: MutateOpts): Promise<void> {
|
|
|
23
23
|
try {
|
|
24
24
|
mutation = prepareMutation(await readDocument(opts))
|
|
25
25
|
} catch (error) {
|
|
26
|
-
|
|
27
|
-
process.exit(1)
|
|
26
|
+
failClosed(error, opts)
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
if (opts.dry) {
|
package/src/commands/query.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { CommandDefinition } from '../program/index'
|
|
|
7
7
|
|
|
8
8
|
import { expandSelfInPath, runKernelCommand, withSelfHint } from '../connection'
|
|
9
9
|
import { prepareQuery, type QueryCommandInput } from '../graph/index'
|
|
10
|
-
import {
|
|
10
|
+
import { failClosed } from '../lib/log'
|
|
11
11
|
import { isMachine, output } from '../lib/output'
|
|
12
12
|
|
|
13
13
|
type QueryOpts = KernelCommandOpts & {
|
|
@@ -48,16 +48,14 @@ export async function queryCommand(sources: string[], opts: QueryOpts): Promise<
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
} catch (error) {
|
|
51
|
-
|
|
52
|
-
process.exit(1)
|
|
51
|
+
failClosed(error, opts)
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
let prepared
|
|
56
55
|
try {
|
|
57
56
|
prepared = prepareQuery(input)
|
|
58
57
|
} catch (error) {
|
|
59
|
-
|
|
60
|
-
process.exit(1)
|
|
58
|
+
failClosed(error, opts)
|
|
61
59
|
}
|
|
62
60
|
|
|
63
61
|
await runKernelCommand({
|
package/src/commands/token.ts
CHANGED
|
@@ -4,7 +4,10 @@ import type { KernelCommandOpts } from '../connection'
|
|
|
4
4
|
import type { CommandDefinition } from '../program/index'
|
|
5
5
|
|
|
6
6
|
import { runKernelCommand } from '../connection'
|
|
7
|
-
import {
|
|
7
|
+
import { AstraleError } from '../errors'
|
|
8
|
+
import { decodeJwtExpiration } from '../lib/local-status'
|
|
9
|
+
import { failClosed, log } from '../lib/log'
|
|
10
|
+
import { output } from '../lib/output'
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* `astrale token` — mint a fresh delegation token for the active instance
|
|
@@ -20,6 +23,7 @@ export type TokenOpts = KernelCommandOpts & {
|
|
|
20
23
|
|
|
21
24
|
export async function tokenCommand(opts: TokenOpts): Promise<void> {
|
|
22
25
|
const commandOpts: TokenOpts = opts.for && !opts.as ? { ...opts, as: opts.for } : opts
|
|
26
|
+
const ttl = parseTtl(commandOpts.ttl)
|
|
23
27
|
await runKernelCommand<string>({
|
|
24
28
|
opts: commandOpts,
|
|
25
29
|
label: 'Minting delegation token',
|
|
@@ -28,8 +32,6 @@ export async function tokenCommand(opts: TokenOpts): Promise<void> {
|
|
|
28
32
|
commandOpts.audience === undefined
|
|
29
33
|
? ctx.target.kernelIssuer
|
|
30
34
|
: issuer.accept(commandOpts.audience)
|
|
31
|
-
const parsedTtl = Number(commandOpts.ttl)
|
|
32
|
-
const ttl = Number.isFinite(parsedTtl) && parsedTtl > 0 ? parsedTtl : 3600
|
|
33
35
|
const self = await ctx.auth.whoami()
|
|
34
36
|
return ctx.auth.delegate(self.id, {
|
|
35
37
|
audience,
|
|
@@ -37,17 +39,37 @@ export async function tokenCommand(opts: TokenOpts): Promise<void> {
|
|
|
37
39
|
attenuation: { kind: 'identity', self: true },
|
|
38
40
|
})
|
|
39
41
|
},
|
|
40
|
-
format: (token, fmtOpts
|
|
41
|
-
if (
|
|
42
|
-
|
|
42
|
+
format: (token, fmtOpts) => {
|
|
43
|
+
if (fmtOpts.json || fmtOpts.format !== undefined) {
|
|
44
|
+
output({ token, expiresAt: decodeJwtExpiration(token)?.expiresAt ?? null }, fmtOpts)
|
|
43
45
|
return
|
|
44
46
|
}
|
|
45
|
-
|
|
47
|
+
if (!fmtOpts.raw && (process.stdout.isTTY ?? false)) {
|
|
48
|
+
log.dim(' (delegation token — ES256, self-identity)')
|
|
49
|
+
}
|
|
46
50
|
process.stdout.write(`${token}\n`)
|
|
47
51
|
},
|
|
48
52
|
})
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
export function parseTtl(raw: string | undefined): number {
|
|
56
|
+
if (raw === undefined) return 3600
|
|
57
|
+
if (!/^\d+$/.test(raw)) {
|
|
58
|
+
throw new AstraleError(
|
|
59
|
+
'INVALID_FLAG',
|
|
60
|
+
`Invalid --ttl value "${raw}" — expected a positive integer (seconds)`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
const value = Number.parseInt(raw, 10)
|
|
64
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
65
|
+
throw new AstraleError(
|
|
66
|
+
'INVALID_FLAG',
|
|
67
|
+
`Invalid --ttl value "${raw}" — must be a positive integer`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
export default {
|
|
52
74
|
name: 'token',
|
|
53
75
|
description: 'Mint a fresh delegation token for the active instance + identity',
|
|
@@ -63,6 +85,7 @@ Behavior:
|
|
|
63
85
|
|
|
64
86
|
Examples:
|
|
65
87
|
$ export TOKEN=$(astrale token --audience shell.astrale.ai --raw)
|
|
88
|
+
$ astrale token --json -i staging
|
|
66
89
|
$ astrale token --audience worker.example.com --for alice -i staging
|
|
67
90
|
`,
|
|
68
91
|
options: [
|
|
@@ -74,6 +97,10 @@ Examples:
|
|
|
74
97
|
{ flags: '--for <identity>', description: 'Mint the token for this identity (alias of --as)' },
|
|
75
98
|
],
|
|
76
99
|
action: async (opts) => {
|
|
77
|
-
|
|
100
|
+
try {
|
|
101
|
+
await tokenCommand(opts as Parameters<typeof tokenCommand>[0])
|
|
102
|
+
} catch (error) {
|
|
103
|
+
failClosed(error, opts as TokenOpts)
|
|
104
|
+
}
|
|
78
105
|
},
|
|
79
106
|
} satisfies CommandDefinition
|
package/src/commands/update.ts
CHANGED
|
@@ -116,26 +116,47 @@ export type StaleReport = {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
async function cliStale(opts: UpdateOpts): Promise<StaleReport['cli']> {
|
|
119
|
+
const running = pkg.version
|
|
120
|
+
const latest = await fetchNpmLatestVersion().catch(() => undefined)
|
|
119
121
|
try {
|
|
120
122
|
const r = await updateAstrale({
|
|
121
123
|
check: true,
|
|
122
124
|
channel: opts.channel,
|
|
123
125
|
version: opts.version,
|
|
124
|
-
currentVersion:
|
|
126
|
+
currentVersion: running,
|
|
125
127
|
})
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
if (r.status === 'updated') {
|
|
129
|
+
return { stale: false, managed: false, current: running, latest: latest ?? running }
|
|
130
|
+
}
|
|
128
131
|
return {
|
|
129
|
-
stale: r.status === 'available',
|
|
132
|
+
stale: latest !== undefined ? latest !== running : r.status === 'available',
|
|
130
133
|
managed: false,
|
|
131
|
-
current:
|
|
132
|
-
latest: r.latestVersion,
|
|
133
|
-
channel: r.channel,
|
|
134
|
+
current: running,
|
|
135
|
+
latest: latest ?? r.latestVersion,
|
|
136
|
+
channel: latest !== undefined ? 'npm' : r.channel,
|
|
134
137
|
}
|
|
135
138
|
} catch {
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
return {
|
|
140
|
+
stale: latest !== undefined && latest !== running,
|
|
141
|
+
managed: true,
|
|
142
|
+
current: running,
|
|
143
|
+
...(latest === undefined ? {} : { latest, channel: 'npm' }),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function fetchNpmLatestVersion(): Promise<string> {
|
|
149
|
+
const response = await fetch('https://registry.npmjs.org/@astrale-os/cli/latest')
|
|
150
|
+
if (!response.ok) throw new Error(`npm registry HTTP ${response.status}`)
|
|
151
|
+
const body: unknown = await response.json()
|
|
152
|
+
if (
|
|
153
|
+
body === null ||
|
|
154
|
+
typeof body !== 'object' ||
|
|
155
|
+
typeof (body as { version?: unknown }).version !== 'string'
|
|
156
|
+
) {
|
|
157
|
+
throw new Error('npm registry latest document is missing version')
|
|
138
158
|
}
|
|
159
|
+
return (body as { version: string }).version
|
|
139
160
|
}
|
|
140
161
|
|
|
141
162
|
async function sdkStale(): Promise<StaleReport['sdk']> {
|
package/src/commands/view.ts
CHANGED
|
@@ -522,15 +522,17 @@ What it does:
|
|
|
522
522
|
an audience-bound credential for shell mounts, and the kernel endpoint.
|
|
523
523
|
|
|
524
524
|
Examples:
|
|
525
|
-
$ astrale view
|
|
526
|
-
$ astrale view /:crm.
|
|
525
|
+
$ astrale view @customer
|
|
526
|
+
$ astrale view /:crm.example.dev:view.dashboard
|
|
527
527
|
$ astrale view /:agents.astrale.ai:view.agent --target @f00d1234 --as alice
|
|
528
|
-
$ astrale view
|
|
528
|
+
$ astrale view @customer --snapshot
|
|
529
|
+
$ astrale view --list
|
|
529
530
|
$ astrale view --sessions ; astrale view --close --all
|
|
530
531
|
`,
|
|
531
532
|
action: async (spec: string | undefined, opts: ViewOpts) => {
|
|
532
533
|
if (opts.close !== undefined) return closeCommand(opts)
|
|
533
534
|
if (opts.sessions) return sessionsCommand(opts)
|
|
535
|
+
if (opts.list && !spec) return sessionsCommand(opts)
|
|
534
536
|
|
|
535
537
|
rejectUnrepresentableOverrides(opts)
|
|
536
538
|
if (!spec) return fatal(new Error('Nothing to open — pass a ViewPath or target node.'))
|
|
@@ -82,6 +82,16 @@ describe('connection credential', () => {
|
|
|
82
82
|
expect(auth).toBeUndefined()
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
test('rejects --as combined with --creds', () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
createCliCredential(
|
|
88
|
+
{ url: `${SOURCE}/invoke`, kernelIssuer: SOURCE },
|
|
89
|
+
{ as: 'alice', creds: 'token' },
|
|
90
|
+
config,
|
|
91
|
+
),
|
|
92
|
+
).toThrow('--as cannot be combined with --creds')
|
|
93
|
+
})
|
|
94
|
+
|
|
85
95
|
/** @evidence TEST-CLI-CONNECTION-PROPAGATES-AUTH-CANCELLATION */
|
|
86
96
|
test('passes the live Session operation signal to source credential resolution', async () => {
|
|
87
97
|
const controller = new AbortController()
|
|
@@ -93,4 +93,24 @@ describe('formatKernelError', () => {
|
|
|
93
93
|
},
|
|
94
94
|
})
|
|
95
95
|
})
|
|
96
|
+
|
|
97
|
+
test('maps SDK class names to stable CLI error codes', async () => {
|
|
98
|
+
const writes: string[] = []
|
|
99
|
+
const original = process.stderr.write
|
|
100
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
101
|
+
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
102
|
+
return true
|
|
103
|
+
}) as typeof process.stderr.write
|
|
104
|
+
try {
|
|
105
|
+
const pathError = new Error('Path must have an Id or Domain anchor.')
|
|
106
|
+
pathError.name = 'PathError'
|
|
107
|
+
await formatKernelError(pathError, true)
|
|
108
|
+
} finally {
|
|
109
|
+
process.stderr.write = original
|
|
110
|
+
}
|
|
111
|
+
expect(JSON.parse(writes[0]!)).toEqual({
|
|
112
|
+
error: 'PATH_INVALID',
|
|
113
|
+
message: 'Path must have an Id or Domain anchor.',
|
|
114
|
+
})
|
|
115
|
+
})
|
|
96
116
|
})
|
|
@@ -94,6 +94,9 @@ export function createCliCredential(
|
|
|
94
94
|
|
|
95
95
|
/** Reject contradictory explicit credential selections before identity or network access. */
|
|
96
96
|
export function validateCredentialSelection(options: ConnectionOptions): void {
|
|
97
|
+
if (options.as !== undefined && options.creds !== undefined) {
|
|
98
|
+
throw new AstraleError('INVALID_FLAG', '--as cannot be combined with --creds.')
|
|
99
|
+
}
|
|
97
100
|
if (options.anonymous !== true) return
|
|
98
101
|
const conflicting = [
|
|
99
102
|
...(options.as === undefined ? [] : ['--as']),
|