@astrale-os/cli 0.8.1-alpha.5 → 0.8.1-alpha.7
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 +4 -3
- package/dist/astrale.js +3391 -3163
- package/dist/public/connect-core.js +2041 -3066
- package/dist/public/keys/index.js +1854 -2895
- package/dist/public/paths/index.js +1833 -2882
- package/dist/types/connection/auth.d.ts +3 -0
- package/dist/types/lib/instance-target.d.ts +2 -0
- package/dist/types/lib/instance.d.ts +10 -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 +8 -8
- package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
- package/src/commands/__tests__/install-identity-override.test.ts +14 -3
- package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
- package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
- package/src/commands/__tests__/instance-use.test.ts +67 -0
- 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/__tests__/view-build.test.ts +58 -0
- package/src/commands/auth/token.ts +19 -7
- package/src/commands/call.ts +18 -43
- package/src/commands/domain/install.ts +6 -5
- package/src/commands/domain/uninstall.ts +128 -0
- 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/instance/active.ts +13 -1
- package/src/commands/instance/bookmark.ts +26 -3
- package/src/commands/instance/list.ts +18 -4
- package/src/commands/instance/use.ts +54 -7
- 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 +33 -19
- package/src/connection/.spec/architecture.md +5 -0
- package/src/connection/.spec/laws/connection.ts +20 -0
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/auth.test.ts +27 -1
- package/src/connection/__tests__/ca-fetch.test.ts +8 -1
- package/src/connection/__tests__/credential.test.ts +10 -0
- package/src/connection/__tests__/errors.test.ts +430 -36
- package/src/connection/__tests__/exchange.test.ts +46 -5
- package/src/connection/__tests__/reasons.test.ts +78 -0
- package/src/connection/auth.ts +11 -9
- package/src/connection/command.ts +1 -1
- package/src/connection/credential.ts +3 -0
- package/src/connection/errors.ts +195 -154
- package/src/connection/exchange.ts +14 -2
- package/src/connection/reasons.ts +179 -0
- 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 +35 -20
- package/src/lib/__tests__/instance-target.test.ts +17 -0
- package/src/lib/__tests__/instance.test.ts +51 -1
- package/src/lib/__tests__/output.test.ts +12 -0
- package/src/lib/__tests__/view-assets.test.ts +33 -1
- package/src/lib/__tests__/view-server.test.ts +68 -0
- package/src/lib/ca-fetch.ts +9 -3
- package/src/lib/command-dx.ts +40 -23
- package/src/lib/instance-target.ts +18 -2
- package/src/lib/instance.ts +31 -0
- package/src/lib/invocation.ts +11 -0
- package/src/lib/log.ts +17 -9
- package/src/lib/output.ts +20 -4
- package/src/lib/view/assets.ts +16 -2
- package/src/program/__tests__/program.test.ts +4 -1
- package/src/program/build.ts +5 -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/studio/package.json +7 -8
- package/viewer/dist/main.js +57 -57
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { CommandDefinition } from '../../program/index'
|
|
2
2
|
|
|
3
3
|
import { fetchWithCaFile } from '../../lib/ca-fetch'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
findBookmarkTrustConflicts,
|
|
6
|
+
normalizeInstanceKernelUrl,
|
|
7
|
+
readInstances,
|
|
8
|
+
setActive,
|
|
9
|
+
upsertInstance,
|
|
10
|
+
} from '../../lib/instance'
|
|
5
11
|
import { fatal, log } from '../../lib/log'
|
|
6
12
|
import { checkIssuerReachability } from '../../lib/meta'
|
|
7
13
|
|
|
@@ -39,14 +45,27 @@ export default {
|
|
|
39
45
|
try {
|
|
40
46
|
if (!opts.url) fatal(new Error('Missing required flag: --url <url>'))
|
|
41
47
|
const url = normalizeInstanceKernelUrl(opts.url)
|
|
42
|
-
const
|
|
48
|
+
const store = await readInstances()
|
|
49
|
+
const expectedIssuer = opts.issuer
|
|
50
|
+
? normalizeInstanceKernelUrl(opts.issuer)
|
|
51
|
+
: store.instances[name]?.issuer
|
|
52
|
+
const effectiveCa = opts.ca ?? store.instances[name]?.caFile
|
|
53
|
+
const trustConflicts = findBookmarkTrustConflicts(store, name, url, effectiveCa)
|
|
54
|
+
if (trustConflicts.length > 0) {
|
|
55
|
+
log.warn(
|
|
56
|
+
`TLS trust differs for the same Kernel URL ${url}: ` +
|
|
57
|
+
`"${name}" uses ${describeCa(effectiveCa)}, while ${trustConflicts
|
|
58
|
+
.map((conflict) => `"${conflict.name}" uses ${describeCa(conflict.caFile)}`)
|
|
59
|
+
.join(', ')}. Remove or update stale bookmarks to avoid certificate surprises.`,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
43
62
|
|
|
44
63
|
if (!opts.skipProbe) {
|
|
45
64
|
try {
|
|
46
65
|
const { issuer, keys } = await checkIssuerReachability(
|
|
47
66
|
url,
|
|
48
67
|
expectedIssuer,
|
|
49
|
-
|
|
68
|
+
effectiveCa ? fetchWithCaFile(effectiveCa) : undefined,
|
|
50
69
|
)
|
|
51
70
|
log.dim(` iss=${issuer} keys=${keys.length}`)
|
|
52
71
|
} catch (e) {
|
|
@@ -76,3 +95,7 @@ export default {
|
|
|
76
95
|
}
|
|
77
96
|
},
|
|
78
97
|
} satisfies CommandDefinition
|
|
98
|
+
|
|
99
|
+
function describeCa(caFile: string | null | undefined): string {
|
|
100
|
+
return caFile ? `CA ${caFile}` : 'the system trust store'
|
|
101
|
+
}
|
|
@@ -30,6 +30,7 @@ export type Bookmark = {
|
|
|
30
30
|
issuer: string | null
|
|
31
31
|
active: boolean
|
|
32
32
|
defaultIdentity: string | null
|
|
33
|
+
caFile: string | null
|
|
33
34
|
createdAt: string | null
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -57,6 +58,7 @@ export default {
|
|
|
57
58
|
issuer: entry.issuer ?? null,
|
|
58
59
|
active: name === store.active,
|
|
59
60
|
defaultIdentity: entry.defaultIdentity ?? null,
|
|
61
|
+
caFile: entry.caFile ?? null,
|
|
60
62
|
createdAt: entry.createdAt ?? null,
|
|
61
63
|
}))
|
|
62
64
|
|
|
@@ -111,13 +113,13 @@ export function buildInstanceRows(
|
|
|
111
113
|
const rows: Array<Record<string, string>> = []
|
|
112
114
|
const merged = new Set<string>()
|
|
113
115
|
|
|
114
|
-
const bookmarkByName = new Map<string, { url: string
|
|
116
|
+
const bookmarkByName = new Map<string, Bookmark & { url: string }>()
|
|
115
117
|
if (show.managed && show.bookmarks) {
|
|
116
118
|
for (const bookmark of bookmarks) {
|
|
117
119
|
if (bookmark.url === null) continue
|
|
118
120
|
bookmarkByName.set(bookmark.name, {
|
|
121
|
+
...bookmark,
|
|
119
122
|
url: normalizeInstanceKernelUrl(bookmark.url),
|
|
120
|
-
active: bookmark.active,
|
|
121
123
|
})
|
|
122
124
|
}
|
|
123
125
|
}
|
|
@@ -134,7 +136,9 @@ export function buildInstanceRows(
|
|
|
134
136
|
name: twin?.active ? `${item.slug} ${chalk.green('*')}` : item.slug,
|
|
135
137
|
kind: 'managed',
|
|
136
138
|
url: item.url ?? '',
|
|
137
|
-
extra: formatInstanceLocation(item),
|
|
139
|
+
extra: [formatInstanceLocation(item), twin ? formatBookmarkConnection(twin) : '']
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.join(' · '),
|
|
138
142
|
})
|
|
139
143
|
}
|
|
140
144
|
}
|
|
@@ -146,7 +150,7 @@ export function buildInstanceRows(
|
|
|
146
150
|
name: item.active ? `${item.name} ${chalk.green('*')}` : item.name,
|
|
147
151
|
kind: 'bookmark',
|
|
148
152
|
url: String(item.url ?? ''),
|
|
149
|
-
extra:
|
|
153
|
+
extra: formatBookmarkConnection(item),
|
|
150
154
|
})
|
|
151
155
|
}
|
|
152
156
|
}
|
|
@@ -154,6 +158,16 @@ export function buildInstanceRows(
|
|
|
154
158
|
return rows
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
function formatBookmarkConnection(bookmark: Bookmark): string {
|
|
162
|
+
return [
|
|
163
|
+
bookmark.issuer && bookmark.issuer !== bookmark.url ? `issuer=${bookmark.issuer}` : '',
|
|
164
|
+
bookmark.caFile ? `ca=${bookmark.caFile}` : '',
|
|
165
|
+
bookmark.defaultIdentity ? `identity=${bookmark.defaultIdentity}` : '',
|
|
166
|
+
]
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.join(' · ')
|
|
169
|
+
}
|
|
170
|
+
|
|
157
171
|
const ADMIN_INVENTORY_CODES = new Set([
|
|
158
172
|
'TOKEN_EXCHANGE_SOURCE_INVALID',
|
|
159
173
|
'TOKEN_EXCHANGE_SOURCE_EXPIRED',
|
|
@@ -5,7 +5,9 @@ import { AstraleError } from '../../errors'
|
|
|
5
5
|
import { getDefault, setDefault } from '../../identity/index'
|
|
6
6
|
import { listOwnedInstances } from '../../lib/admin-instance'
|
|
7
7
|
import { ADMIN_TARGET_OPTIONS } from '../../lib/admin-target'
|
|
8
|
+
import { fetchWithCaFile } from '../../lib/ca-fetch'
|
|
8
9
|
import {
|
|
10
|
+
findBookmarkTrustConflicts,
|
|
9
11
|
getActive,
|
|
10
12
|
readInstances,
|
|
11
13
|
resolveInstance,
|
|
@@ -48,12 +50,8 @@ async function useInstance(name?: string, opts: UseOpts = {}): Promise<void> {
|
|
|
48
50
|
|
|
49
51
|
const resolved = await resolveUseTarget(name, opts)
|
|
50
52
|
|
|
51
|
-
if (!opts.skipJwksCheck
|
|
52
|
-
|
|
53
|
-
await checkIssuerReachability(resolved.url, resolved.issuer)
|
|
54
|
-
} catch (e) {
|
|
55
|
-
fatal(e)
|
|
56
|
-
}
|
|
53
|
+
if (!opts.skipJwksCheck) {
|
|
54
|
+
await probeBookmark(resolved)
|
|
57
55
|
}
|
|
58
56
|
|
|
59
57
|
await setActive(resolved.name)
|
|
@@ -94,6 +92,55 @@ async function useInstance(name?: string, opts: UseOpts = {}): Promise<void> {
|
|
|
94
92
|
}
|
|
95
93
|
}
|
|
96
94
|
|
|
95
|
+
/** Probe with the exact TLS trust configuration stored on this bookmark. */
|
|
96
|
+
export async function probeBookmark(
|
|
97
|
+
resolved: ResolvedInstance,
|
|
98
|
+
dependencies: Partial<BookmarkProbeDependencies> = {},
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const probe = { ...defaultBookmarkProbeDependencies, ...dependencies }
|
|
101
|
+
const store = await probe.readInstances()
|
|
102
|
+
const conflicts = findBookmarkTrustConflicts(store, resolved.name, resolved.url, resolved.caFile)
|
|
103
|
+
try {
|
|
104
|
+
await probe.checkIssuerReachability(
|
|
105
|
+
resolved.url,
|
|
106
|
+
resolved.issuer,
|
|
107
|
+
resolved.caFile ? probe.fetchWithCaFile(resolved.caFile) : undefined,
|
|
108
|
+
)
|
|
109
|
+
} catch (cause) {
|
|
110
|
+
const original = cause instanceof AstraleError ? cause.hint : undefined
|
|
111
|
+
const trust = resolved.caFile
|
|
112
|
+
? `Bookmark "${resolved.name}" trusts CA ${resolved.caFile}.`
|
|
113
|
+
: `Bookmark "${resolved.name}" uses the system trust store.`
|
|
114
|
+
const collision =
|
|
115
|
+
conflicts.length === 0
|
|
116
|
+
? ''
|
|
117
|
+
: ` The same URL is bookmarked with different TLS trust as ${conflicts
|
|
118
|
+
.map((conflict) =>
|
|
119
|
+
conflict.caFile
|
|
120
|
+
? `"${conflict.name}" (CA ${conflict.caFile})`
|
|
121
|
+
: `"${conflict.name}" (system trust)`,
|
|
122
|
+
)
|
|
123
|
+
.join(', ')}.`
|
|
124
|
+
throw new AstraleError(
|
|
125
|
+
cause instanceof AstraleError ? cause.code : 'ISSUER_UNREACHABLE',
|
|
126
|
+
`Issuer/JWKS probe failed for bookmark "${resolved.name}" at ${resolved.url}.`,
|
|
127
|
+
`${trust}${collision}${original ? ` ${original}` : ''} Inspect with \`astrale instance list --bookmarked --json\`.`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface BookmarkProbeDependencies {
|
|
133
|
+
readonly readInstances: typeof readInstances
|
|
134
|
+
readonly checkIssuerReachability: typeof checkIssuerReachability
|
|
135
|
+
readonly fetchWithCaFile: typeof fetchWithCaFile
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const defaultBookmarkProbeDependencies: BookmarkProbeDependencies = Object.freeze({
|
|
139
|
+
readInstances,
|
|
140
|
+
checkIssuerReachability,
|
|
141
|
+
fetchWithCaFile,
|
|
142
|
+
})
|
|
143
|
+
|
|
97
144
|
async function resolveUseTarget(name: string, opts: UseOpts): Promise<ResolvedInstance> {
|
|
98
145
|
const [store, managed] = await Promise.all([readInstances(), fetchManagedInstances(name, opts)])
|
|
99
146
|
const candidates = collectInstanceCandidates(name, store, managed)
|
|
@@ -204,7 +251,7 @@ Examples:
|
|
|
204
251
|
flags: '--adopt-default',
|
|
205
252
|
description: 'Adopt instance default identity without prompt',
|
|
206
253
|
},
|
|
207
|
-
{ flags: '--skip-jwks-check', description: 'Skip the
|
|
254
|
+
{ flags: '--skip-jwks-check', description: 'Skip the OIDC discovery + JWKS liveness probe' },
|
|
208
255
|
],
|
|
209
256
|
action: async (name: string | undefined, opts: UseOpts) => {
|
|
210
257
|
await useInstance(name, opts)
|
|
@@ -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']> {
|