@astrale-os/cli 0.4.0-alpha.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/.check-workspace.cjs +40 -0
- package/README.md +151 -0
- package/dist/astrale.js +59749 -0
- package/package.json +90 -0
- package/src/command.ts +40 -0
- package/src/commands/__tests__/admin-instance.test.ts +73 -0
- package/src/commands/__tests__/auth-login.test.ts +178 -0
- package/src/commands/__tests__/auth-token.test.ts +223 -0
- package/src/commands/__tests__/call.test.ts +72 -0
- package/src/commands/__tests__/domain-list.test.ts +74 -0
- package/src/commands/__tests__/help-contract.test.ts +136 -0
- package/src/commands/__tests__/install-identity-override.test.ts +65 -0
- package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
- package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
- package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
- package/src/commands/__tests__/logs.test.ts +117 -0
- package/src/commands/__tests__/ls.test.ts +25 -0
- package/src/commands/__tests__/setup-plan.test.ts +61 -0
- package/src/commands/admin/status.ts +61 -0
- package/src/commands/admin/use.ts +77 -0
- package/src/commands/auth/login.ts +82 -0
- package/src/commands/auth/logout.ts +50 -0
- package/src/commands/auth/status.ts +86 -0
- package/src/commands/auth/token.ts +162 -0
- package/src/commands/browser.ts +207 -0
- package/src/commands/call.ts +300 -0
- package/src/commands/describe.ts +182 -0
- package/src/commands/domain/install.ts +420 -0
- package/src/commands/domain/list.ts +154 -0
- package/src/commands/domain/publish.ts +155 -0
- package/src/commands/get.ts +60 -0
- package/src/commands/identity/create.ts +26 -0
- package/src/commands/identity/delete.ts +18 -0
- package/src/commands/identity/export.ts +66 -0
- package/src/commands/identity/import.ts +101 -0
- package/src/commands/identity/list.ts +56 -0
- package/src/commands/identity/register.ts +170 -0
- package/src/commands/identity/sync.ts +32 -0
- package/src/commands/identity/unsync.ts +24 -0
- package/src/commands/identity/use.ts +18 -0
- package/src/commands/identity/whoami.ts +34 -0
- package/src/commands/idp/add.ts +150 -0
- package/src/commands/idp/list.ts +57 -0
- package/src/commands/idp/refresh.ts +38 -0
- package/src/commands/idp/remove.ts +36 -0
- package/src/commands/idp/show.ts +29 -0
- package/src/commands/instance/active.ts +64 -0
- package/src/commands/instance/bookmark.ts +72 -0
- package/src/commands/instance/create.ts +69 -0
- package/src/commands/instance/delete.ts +72 -0
- package/src/commands/instance/forget.ts +26 -0
- package/src/commands/instance/list.ts +149 -0
- package/src/commands/instance/status.ts +42 -0
- package/src/commands/instance/use.ts +210 -0
- package/src/commands/logs.ts +347 -0
- package/src/commands/ls.ts +229 -0
- package/src/commands/query.ts +32 -0
- package/src/commands/setup.ts +54 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/studio.ts +401 -0
- package/src/commands/token.ts +77 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +87 -0
- package/src/errors.ts +65 -0
- package/src/kernel/__tests__/auth.test.ts +77 -0
- package/src/kernel/__tests__/errors.test.ts +43 -0
- package/src/kernel/__tests__/remote-routing.test.ts +70 -0
- package/src/kernel/auth.ts +234 -0
- package/src/kernel/ca-fetch.ts +119 -0
- package/src/kernel/client.ts +191 -0
- package/src/kernel/errors.ts +280 -0
- package/src/kernel/expand.ts +217 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/options.ts +22 -0
- package/src/kernel/remote-routing.ts +88 -0
- package/src/kernel/run.ts +63 -0
- package/src/kernel/types.ts +14 -0
- package/src/lib/__tests__/admin-target.test.ts +112 -0
- package/src/lib/__tests__/binary.test.ts +56 -0
- package/src/lib/__tests__/command-dx.test.ts +58 -0
- package/src/lib/__tests__/concurrency.test.ts +62 -0
- package/src/lib/__tests__/config.test.ts +53 -0
- package/src/lib/__tests__/design.test.ts +99 -0
- package/src/lib/__tests__/domain-identity.test.ts +60 -0
- package/src/lib/__tests__/format.test.ts +22 -0
- package/src/lib/__tests__/fs-atomic.test.ts +104 -0
- package/src/lib/__tests__/identity.test.ts +79 -0
- package/src/lib/__tests__/idp-session.driver.ts +53 -0
- package/src/lib/__tests__/idp-session.test.ts +357 -0
- package/src/lib/__tests__/idp.test.ts +385 -0
- package/src/lib/__tests__/instance-candidates.test.ts +73 -0
- package/src/lib/__tests__/instance-target.test.ts +183 -0
- package/src/lib/__tests__/instance.test.ts +136 -0
- package/src/lib/__tests__/keys.test.ts +129 -0
- package/src/lib/__tests__/local-status.test.ts +202 -0
- package/src/lib/__tests__/output.test.ts +150 -0
- package/src/lib/__tests__/panel.test.ts +40 -0
- package/src/lib/__tests__/port.test.ts +44 -0
- package/src/lib/__tests__/prompt.test.ts +25 -0
- package/src/lib/__tests__/sdk-deps.test.ts +68 -0
- package/src/lib/__tests__/self.test.ts +272 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
- package/src/lib/__tests__/table.test.ts +53 -0
- package/src/lib/__tests__/update.test.ts +246 -0
- package/src/lib/__tests__/use-target.test.ts +56 -0
- package/src/lib/__tests__/validation.test.ts +34 -0
- package/src/lib/admin-domain.ts +25 -0
- package/src/lib/admin-instance.ts +26 -0
- package/src/lib/admin-target.ts +217 -0
- package/src/lib/binary.ts +131 -0
- package/src/lib/browser.ts +150 -0
- package/src/lib/command-dx.ts +161 -0
- package/src/lib/concurrency.ts +31 -0
- package/src/lib/config.ts +45 -0
- package/src/lib/domain-identity.ts +49 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/format.ts +4 -0
- package/src/lib/fs-atomic.ts +126 -0
- package/src/lib/identity.ts +256 -0
- package/src/lib/idp-session.ts +134 -0
- package/src/lib/idp.ts +876 -0
- package/src/lib/instance-candidates.ts +49 -0
- package/src/lib/instance-target.ts +182 -0
- package/src/lib/instance.ts +395 -0
- package/src/lib/keys.ts +294 -0
- package/src/lib/local-status.ts +152 -0
- package/src/lib/log.ts +116 -0
- package/src/lib/login-flow.ts +164 -0
- package/src/lib/meta.ts +86 -0
- package/src/lib/output.ts +222 -0
- package/src/lib/panel.ts +61 -0
- package/src/lib/paths.ts +11 -0
- package/src/lib/port.ts +41 -0
- package/src/lib/proc.ts +82 -0
- package/src/lib/prompt.ts +136 -0
- package/src/lib/provision-instance.ts +170 -0
- package/src/lib/sdk-deps.ts +104 -0
- package/src/lib/self.ts +166 -0
- package/src/lib/skills.ts +171 -0
- package/src/lib/table.ts +62 -0
- package/src/lib/update.ts +315 -0
- package/src/lib/use-target.ts +24 -0
- package/src/lib/validation.ts +59 -0
- package/src/program.ts +200 -0
- package/src/registry.ts +59 -0
- package/src/setup/__tests__/util.test.ts +29 -0
- package/src/setup/engine.ts +83 -0
- package/src/setup/render.ts +109 -0
- package/src/setup/steps/admin.ts +78 -0
- package/src/setup/steps/agent-browser.ts +81 -0
- package/src/setup/steps/auth.ts +54 -0
- package/src/setup/steps/domain.ts +68 -0
- package/src/setup/steps/index.ts +18 -0
- package/src/setup/steps/instance.ts +119 -0
- package/src/setup/steps/skills-bridge.ts +70 -0
- package/src/setup/steps/skills.ts +59 -0
- package/src/setup/types.ts +61 -0
- package/src/setup/util.ts +34 -0
- package/src/test-utils.ts +18 -0
- package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
- package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
- package/studio/client/dist/index.html +13 -0
- package/studio/package.json +62 -0
- package/studio/server/agent/ask.ts +68 -0
- package/studio/server/agent/bridge-mcp.ts +182 -0
- package/studio/server/agent/bridge.ts +188 -0
- package/studio/server/agent/claude.ts +666 -0
- package/studio/server/agent/mock.ts +186 -0
- package/studio/server/agent/prompt.ts +202 -0
- package/studio/server/agent/registry.ts +29 -0
- package/studio/server/agent/runner.ts +484 -0
- package/studio/server/agent/schema-map.ts +112 -0
- package/studio/server/agent/types.ts +120 -0
- package/studio/server/api.ts +574 -0
- package/studio/server/cache.ts +138 -0
- package/studio/server/detect.ts +81 -0
- package/studio/server/domain.ts +70 -0
- package/studio/server/index.ts +136 -0
- package/studio/server/introspect/anatomy-extras.ts +398 -0
- package/studio/server/introspect/anatomy.ts +108 -0
- package/studio/server/introspect/bundle.ts +57 -0
- package/studio/server/introspect/core-extractor.ts +119 -0
- package/studio/server/introspect/core.ts +44 -0
- package/studio/server/introspect/diff.ts +133 -0
- package/studio/server/introspect/extractor.ts +102 -0
- package/studio/server/introspect/hash.ts +21 -0
- package/studio/server/introspect/overlay-tsmorph.ts +874 -0
- package/studio/server/introspect/overlay.ts +57 -0
- package/studio/server/introspect/runtime.ts +99 -0
- package/studio/server/introspect/schema-refs.ts +46 -0
- package/studio/server/lifecycle.ts +38 -0
- package/studio/server/sse.ts +57 -0
- package/studio/server/state/baseline.ts +211 -0
- package/studio/server/state/catalog.ts +117 -0
- package/studio/server/state/comments.ts +321 -0
- package/studio/server/state/context.ts +167 -0
- package/studio/server/state/copy.ts +156 -0
- package/studio/server/state/create.ts +156 -0
- package/studio/server/state/documents.ts +70 -0
- package/studio/server/state/env.ts +161 -0
- package/studio/server/state/git.ts +75 -0
- package/studio/server/state/handoff.ts +55 -0
- package/studio/server/state/harness-gateway.ts +181 -0
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/instance.ts +244 -0
- package/studio/server/state/integrations.ts +55 -0
- package/studio/server/state/layout.ts +55 -0
- package/studio/server/state/settings.ts +39 -0
- package/studio/server/state/store.ts +97 -0
- package/studio/server/state/updates.ts +63 -0
- package/studio/server/state/usage.ts +37 -0
- package/studio/server/state/views.ts +138 -0
- package/studio/server/state/visibility.ts +33 -0
- package/studio/server/watch.ts +81 -0
- package/studio/server/workspace-state.ts +26 -0
- package/studio/server/workspace-watch.ts +101 -0
- package/studio/shared/types.ts +873 -0
- package/studio/tsconfig.json +23 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
import type { SelfExpansionMeta } from './expand'
|
|
4
|
+
|
|
5
|
+
import { AstraleError } from '../errors'
|
|
6
|
+
import { decodeJwtExpiration, readLocalStatus, type LocalStatus } from '../lib/local-status'
|
|
7
|
+
import { log } from '../lib/log'
|
|
8
|
+
|
|
9
|
+
type FieldError = { path: string[]; code: string; message: string }
|
|
10
|
+
type InvariantError = { code: string; message: string; context?: unknown }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Format and display a kernel client error.
|
|
14
|
+
*
|
|
15
|
+
* Handles AstraleError (CLI-local) and every error class exported by
|
|
16
|
+
* @astrale-os/kernel-client: ConnectionError, DisconnectedError,
|
|
17
|
+
* TimeoutError, AuthenticationError, PermissionDeniedError, NotFoundError,
|
|
18
|
+
* KernelError and its subclasses (ValidationError, InvariantViolationError).
|
|
19
|
+
*
|
|
20
|
+
* When `debug` is true, additional diagnostic information (class name, full
|
|
21
|
+
* error chain, attached url/details) is printed after the user-facing line.
|
|
22
|
+
*/
|
|
23
|
+
export async function formatKernelError(
|
|
24
|
+
error: unknown,
|
|
25
|
+
isRaw: boolean,
|
|
26
|
+
urlArg = '',
|
|
27
|
+
debug = false,
|
|
28
|
+
opts: { credential?: string } = {},
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const url =
|
|
31
|
+
urlArg || (error instanceof Error ? ((error as Error & { url?: string }).url ?? '') : '')
|
|
32
|
+
const localContext = await contextForError(error)
|
|
33
|
+
const credentialExpiration = opts.credential ? decodeJwtExpiration(opts.credential) : null
|
|
34
|
+
// Handle AstraleError (AuthError, etc.) with structured hints
|
|
35
|
+
if (error instanceof AstraleError) {
|
|
36
|
+
if (isRaw) {
|
|
37
|
+
writeRaw({ error: error.code, message: error.message, hint: error.hint })
|
|
38
|
+
} else {
|
|
39
|
+
log.error(`${chalk.bold(error.code)}: ${error.message}`)
|
|
40
|
+
if (error.hint) log.dim(` ${error.hint}`)
|
|
41
|
+
}
|
|
42
|
+
if (debug) printDebug(error, url)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!(error instanceof Error)) {
|
|
47
|
+
if (isRaw) writeRaw({ error: 'UNKNOWN', message: String(error) })
|
|
48
|
+
else log.error(String(error))
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const name = error.name
|
|
53
|
+
|
|
54
|
+
switch (name) {
|
|
55
|
+
case 'ConnectionError':
|
|
56
|
+
if (isRaw)
|
|
57
|
+
writeRaw({ error: 'CONNECTION_ERROR', message: error.message, url, context: localContext })
|
|
58
|
+
else {
|
|
59
|
+
log.error(`Could not connect to ${chalk.bold(url || 'kernel')}`)
|
|
60
|
+
log.dim(` ${error.message}`)
|
|
61
|
+
log.dim(' Is the kernel running? Try: astrale status')
|
|
62
|
+
printLocalContext(localContext)
|
|
63
|
+
}
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
case 'DisconnectedError':
|
|
67
|
+
if (isRaw) writeRaw({ error: 'DISCONNECTED', message: error.message })
|
|
68
|
+
else {
|
|
69
|
+
log.error('Connection closed while request was pending')
|
|
70
|
+
log.dim(' The kernel may have been stopped or restarted. Retry the command.')
|
|
71
|
+
}
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
case 'TimeoutError': {
|
|
75
|
+
const timeoutMs = (error as { timeoutMs?: number }).timeoutMs
|
|
76
|
+
if (isRaw) writeRaw({ error: 'TIMEOUT', message: error.message, timeoutMs })
|
|
77
|
+
else {
|
|
78
|
+
log.error(`Request timed out after ${timeoutMs ?? '?'}ms`)
|
|
79
|
+
log.dim(' Try increasing with --timeout')
|
|
80
|
+
}
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
case 'AuthenticationError': {
|
|
85
|
+
const reason = (error as { reason?: string }).reason ?? 'unknown'
|
|
86
|
+
if (isRaw)
|
|
87
|
+
writeRaw({
|
|
88
|
+
error: 'AUTH_ERROR',
|
|
89
|
+
reason,
|
|
90
|
+
message: error.message,
|
|
91
|
+
credential: credentialExpiration,
|
|
92
|
+
context: localContext,
|
|
93
|
+
})
|
|
94
|
+
else {
|
|
95
|
+
log.error(`Authentication failed: ${error.message}`)
|
|
96
|
+
if (reason === 'missing')
|
|
97
|
+
log.dim(' No credential was sent. Run: astrale identity create <name>')
|
|
98
|
+
else if (reason === 'invalid')
|
|
99
|
+
log.dim(' Credential is invalid — check issuer/keypair. Try: astrale identity whoami')
|
|
100
|
+
else if (reason === 'expired') log.dim(' Credential expired — sign a fresh one')
|
|
101
|
+
if (credentialExpiration) {
|
|
102
|
+
const state = credentialExpiration.expired ? 'expired' : 'expires'
|
|
103
|
+
log.dim(` Credential ${state} at ${credentialExpiration.expiresAt}`)
|
|
104
|
+
}
|
|
105
|
+
printLocalContext(localContext)
|
|
106
|
+
}
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case 'PermissionDeniedError':
|
|
111
|
+
if (isRaw) writeRaw({ error: 'PERMISSION_DENIED', message: error.message })
|
|
112
|
+
else {
|
|
113
|
+
log.error(`Permission denied: ${error.message}`)
|
|
114
|
+
log.dim(' Your identity does not have the required permissions for this operation')
|
|
115
|
+
}
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
case 'NotFoundError': {
|
|
119
|
+
const cleanMsg = stripMethodSuffix(error.message)
|
|
120
|
+
const selfMeta = (error as Error & { expandedFromSelf?: SelfExpansionMeta }).expandedFromSelf
|
|
121
|
+
// kernel-client maps both NOT_FOUND (the node doesn't exist) and
|
|
122
|
+
// METHOD_NOT_FOUND (the method doesn't exist on a real node) to
|
|
123
|
+
// `NotFoundError`. Firing the "refresh registration" hint for the
|
|
124
|
+
// method case is misleading. Gate on the message referencing the
|
|
125
|
+
// expanded id — node lookup errors mention `@<id>` whereas method
|
|
126
|
+
// errors mention the method path.
|
|
127
|
+
const selfHintApplies = selfMeta && error.message.includes(`@${selfMeta.selfId}`)
|
|
128
|
+
if (isRaw) {
|
|
129
|
+
const payload: Record<string, unknown> = { error: 'NOT_FOUND', message: cleanMsg }
|
|
130
|
+
if (selfHintApplies) payload.expandedFromSelf = selfMeta
|
|
131
|
+
writeRaw(payload)
|
|
132
|
+
} else {
|
|
133
|
+
log.error(`Not found: ${cleanMsg}`)
|
|
134
|
+
log.dim(' Check the path/ID and that the instance is booted')
|
|
135
|
+
if (selfHintApplies && selfMeta) {
|
|
136
|
+
const who = selfMeta.identity ?? 'your identity'
|
|
137
|
+
const where = selfMeta.slug ? ` on "${selfMeta.slug}"` : ''
|
|
138
|
+
log.dim(` @self expanded to @${selfMeta.selfId} from ${who}'s registration${where}.`)
|
|
139
|
+
const fixCmd = `astrale identity register${
|
|
140
|
+
selfMeta.identity ? ` ${selfMeta.identity}` : ''
|
|
141
|
+
}${selfMeta.slug ? ` -i ${selfMeta.slug}` : ''}`
|
|
142
|
+
log.dim(` If the node was deleted, refresh with: ${fixCmd}`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
break
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case 'ValidationError': {
|
|
149
|
+
const errors = (error as { errors?: FieldError[] }).errors ?? []
|
|
150
|
+
if (isRaw) writeRaw({ error: 'VALIDATION_ERROR', message: error.message, details: errors })
|
|
151
|
+
else {
|
|
152
|
+
log.error('Validation Error')
|
|
153
|
+
if (errors.length > 0) {
|
|
154
|
+
for (const e of errors) {
|
|
155
|
+
console.log(chalk.red(` ${e.path.join('.')}: ${e.message} (${chalk.dim(e.code)})`))
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
// Server often sends details in message but empty errors array
|
|
159
|
+
console.log(chalk.red(` ${error.message}`))
|
|
160
|
+
}
|
|
161
|
+
log.dim(' Use `astrale call <path> --describe` to see the expected schema')
|
|
162
|
+
}
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case 'InvariantViolationError': {
|
|
167
|
+
const errors = (error as { errors?: InvariantError[] }).errors ?? []
|
|
168
|
+
if (isRaw) writeRaw({ error: 'INVARIANT_VIOLATION', message: error.message, details: errors })
|
|
169
|
+
else {
|
|
170
|
+
log.error('Invariant Violation')
|
|
171
|
+
for (const e of errors) {
|
|
172
|
+
console.log(chalk.red(` ${e.code}: ${e.message}`))
|
|
173
|
+
if (e.context) console.log(chalk.dim(` ${JSON.stringify(e.context)}`))
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
case 'KernelError': {
|
|
180
|
+
const code = (error as { code?: number | string }).code ?? 'UNKNOWN'
|
|
181
|
+
const type = (error as { type?: string }).type ?? 'KERNEL_ERROR'
|
|
182
|
+
if (isRaw) writeRaw({ error: type, code, message: error.message })
|
|
183
|
+
else log.error(`${chalk.bold(`${type}(${code})`)}: ${error.message}`)
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
default:
|
|
188
|
+
// Catch-all: include class name so diagnosis is possible even without --debug
|
|
189
|
+
if (isRaw) writeRaw({ error: name || 'UNKNOWN', message: error.message })
|
|
190
|
+
else log.error(`${chalk.bold(name || 'Error')}: ${error.message}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (debug) printDebug(error, url)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Strip internal `::methodName` suffixes from paths in error messages (e.g., "/path::listChildren" → "/path") */
|
|
197
|
+
function stripMethodSuffix(msg: string): string {
|
|
198
|
+
return msg.replace(/(\/[^"\s:]+)::([a-zA-Z]\w*)/g, '$1')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function writeRaw(payload: Record<string, unknown>): void {
|
|
202
|
+
process.stderr.write(JSON.stringify(payload) + '\n')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function contextForError(error: unknown): Promise<LocalStatus | undefined> {
|
|
206
|
+
if (!(error instanceof Error)) return undefined
|
|
207
|
+
if (error.name !== 'AuthenticationError' && error.name !== 'ConnectionError') return undefined
|
|
208
|
+
return readLocalStatus().catch(() => undefined)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function printLocalContext(context: LocalStatus | undefined): void {
|
|
212
|
+
if (!context) return
|
|
213
|
+
process.stderr.write(chalk.dim('\nContext:\n'))
|
|
214
|
+
if ('error' in context.admin) {
|
|
215
|
+
process.stderr.write(chalk.dim(` admin: invalid (${context.admin.error})\n`))
|
|
216
|
+
} else {
|
|
217
|
+
process.stderr.write(chalk.dim(` admin: ${context.admin.name} -> ${context.admin.url}\n`))
|
|
218
|
+
}
|
|
219
|
+
if (context.instance) {
|
|
220
|
+
process.stderr.write(
|
|
221
|
+
chalk.dim(` instance: ${context.instance.active} -> ${context.instance.url}\n`),
|
|
222
|
+
)
|
|
223
|
+
} else {
|
|
224
|
+
process.stderr.write(chalk.dim(' instance: none\n'))
|
|
225
|
+
}
|
|
226
|
+
if (context.identity) {
|
|
227
|
+
const source =
|
|
228
|
+
context.identity.source === 'idp'
|
|
229
|
+
? `idp:${context.identity.idp ?? 'unknown'}`
|
|
230
|
+
: context.identity.source
|
|
231
|
+
process.stderr.write(chalk.dim(` identity: ${context.identity.name} [${source}]\n`))
|
|
232
|
+
if (context.identity.session?.cached) {
|
|
233
|
+
const state = context.identity.session.requiresLogin ? 'login required' : 'ready'
|
|
234
|
+
process.stderr.write(chalk.dim(` session: ${state}\n`))
|
|
235
|
+
} else if (context.identity.source === 'idp') {
|
|
236
|
+
process.stderr.write(chalk.dim(' session: not cached\n'))
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
process.stderr.write(chalk.dim(' identity: none\n'))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function printDebug(error: unknown, url: string): void {
|
|
244
|
+
process.stderr.write('\n' + chalk.dim('── debug ─────────────────') + '\n')
|
|
245
|
+
if (url) process.stderr.write(chalk.dim(`url: ${url}`) + '\n')
|
|
246
|
+
if (error instanceof Error) {
|
|
247
|
+
process.stderr.write(chalk.dim(`class: ${error.constructor.name}`) + '\n')
|
|
248
|
+
if (error.stack) process.stderr.write(chalk.dim(error.stack) + '\n')
|
|
249
|
+
// Walk cause chain
|
|
250
|
+
let cause = (error as Error & { cause?: unknown }).cause
|
|
251
|
+
while (cause instanceof Error) {
|
|
252
|
+
process.stderr.write(
|
|
253
|
+
chalk.dim(`caused by: ${cause.constructor.name}: ${cause.message}`) + '\n',
|
|
254
|
+
)
|
|
255
|
+
if (cause.stack) process.stderr.write(chalk.dim(cause.stack) + '\n')
|
|
256
|
+
cause = (cause as Error & { cause?: unknown }).cause
|
|
257
|
+
}
|
|
258
|
+
// Any attached fields (code, details, etc.)
|
|
259
|
+
const extras: Record<string, unknown> = {}
|
|
260
|
+
const bag = error as unknown as Record<string, unknown>
|
|
261
|
+
for (const key of [
|
|
262
|
+
'code',
|
|
263
|
+
'type',
|
|
264
|
+
'reason',
|
|
265
|
+
'details',
|
|
266
|
+
'errors',
|
|
267
|
+
'data',
|
|
268
|
+
'timeoutMs',
|
|
269
|
+
'requestId',
|
|
270
|
+
]) {
|
|
271
|
+
const v = bag[key]
|
|
272
|
+
if (v !== undefined) extras[key] = v
|
|
273
|
+
}
|
|
274
|
+
if (Object.keys(extras).length > 0) {
|
|
275
|
+
process.stderr.write(chalk.dim(`extras: ${JSON.stringify(extras, null, 2)}`) + '\n')
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
process.stderr.write(chalk.dim(String(error)) + '\n')
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { InstanceInfo } from '../lib/admin-instance'
|
|
2
|
+
import type { KernelCommandOpts } from './types'
|
|
3
|
+
|
|
4
|
+
import { ADMIN_INSTANCE } from '../lib/admin-instance'
|
|
5
|
+
/**
|
|
6
|
+
* Bridges `lib/self.ts` to CLI command sites: builds a `SelfResolverContext`
|
|
7
|
+
* from CLI opts, resolves a nodeId via
|
|
8
|
+
* `resolveOrThrow` (throwing `SelfRefusalError` on refusal), and wraps async
|
|
9
|
+
* calls with `withSelfHint` so `NotFoundError`s carry expansion metadata for
|
|
10
|
+
* the stale-registration hint emitted by `formatKernelError`.
|
|
11
|
+
*/
|
|
12
|
+
import { readConfig } from '../lib/config'
|
|
13
|
+
import { getDefault, getIdentity, setRegistration } from '../lib/identity'
|
|
14
|
+
import { decodeTokenClaims, readIdpSession } from '../lib/idp'
|
|
15
|
+
import { resolveInstanceTarget } from '../lib/instance-target'
|
|
16
|
+
import { fileExists, keypairPaths } from '../lib/keys'
|
|
17
|
+
import { KEYS_DIR } from '../lib/paths'
|
|
18
|
+
import {
|
|
19
|
+
containsSelfRef,
|
|
20
|
+
expandSelfReferences,
|
|
21
|
+
resolveSelfNodeId,
|
|
22
|
+
selfRefusalError,
|
|
23
|
+
type SelfResolverContext,
|
|
24
|
+
type SelfResolution,
|
|
25
|
+
} from '../lib/self'
|
|
26
|
+
import { withAdminKernelClient, withKernelClient } from './client'
|
|
27
|
+
|
|
28
|
+
/** Metadata attached to errors so the NotFoundError path can hint at stale `@self` expansions. */
|
|
29
|
+
export type SelfExpansionMeta = {
|
|
30
|
+
original: string
|
|
31
|
+
expanded: string
|
|
32
|
+
selfId: string
|
|
33
|
+
identity?: string
|
|
34
|
+
slug?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a `SelfResolverContext` from CLI opts. Mirrors the target +
|
|
39
|
+
* signing-mode logic in `withKernelClient` / `resolveCredential`.
|
|
40
|
+
*
|
|
41
|
+
* Cheap enough to call eagerly; commands skip the call entirely when
|
|
42
|
+
* `containsSelfRef` returns false on every input.
|
|
43
|
+
*/
|
|
44
|
+
export async function buildSelfContext(opts: KernelCommandOpts): Promise<SelfResolverContext> {
|
|
45
|
+
const config = await readConfig()
|
|
46
|
+
// Mirror withKernelClient's slug logic: --url without -i ⇒ no slug.
|
|
47
|
+
let slug: string | undefined
|
|
48
|
+
let defaultIdentity: string | undefined
|
|
49
|
+
if (opts.url && !opts.instance) {
|
|
50
|
+
slug = undefined
|
|
51
|
+
} else {
|
|
52
|
+
const resolved = await resolveInstanceTarget(
|
|
53
|
+
opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
|
|
54
|
+
{
|
|
55
|
+
config,
|
|
56
|
+
admin: adminLookupOpts(opts),
|
|
57
|
+
managed: (instanceSlug) => lookupManagedInstance(instanceSlug, opts),
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
slug = resolved.name
|
|
61
|
+
defaultIdentity = resolved.defaultIdentity
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Mirror resolveCredential's instance-signed branch: when targeting a
|
|
65
|
+
// child for which the CLI generated a dedicated keypair, the call signs
|
|
66
|
+
// as the instance itself, not as a user identity.
|
|
67
|
+
let instanceSigned = false
|
|
68
|
+
if (!opts.creds && !opts.as && !defaultIdentity && slug && slug !== 'manager') {
|
|
69
|
+
const { privatePath } = keypairPaths(slug, KEYS_DIR)
|
|
70
|
+
instanceSigned = await fileExists(privatePath)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Identity that will sign (only relevant when not instance-signed and not --creds).
|
|
74
|
+
// Failures here (corrupt identities.json, missing --as identity) are
|
|
75
|
+
// re-thrown rather than swallowed — swallowing produces a useless
|
|
76
|
+
// refusal naming `identityName: '(unknown)'`. The fatal-error UX is
|
|
77
|
+
// honest about the actual problem.
|
|
78
|
+
let identity: SelfResolverContext['identity']
|
|
79
|
+
if (!opts.creds) {
|
|
80
|
+
const identityName = opts.as ?? defaultIdentity
|
|
81
|
+
if (identityName) {
|
|
82
|
+
const i = await getIdentity(identityName)
|
|
83
|
+
identity = { ...i, name: identityName }
|
|
84
|
+
} else {
|
|
85
|
+
identity = await getDefault()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let idpSubject: string | undefined
|
|
90
|
+
if (identity && (identity.source ?? 'key') === 'idp') {
|
|
91
|
+
instanceSigned = false
|
|
92
|
+
const session = await readIdpSession(identity.name)
|
|
93
|
+
const claims = decodeTokenClaims(session?.id_token ?? session?.access_token)
|
|
94
|
+
if (typeof claims?.sub === 'string' && claims.sub.trim().length > 0) {
|
|
95
|
+
idpSubject = claims.sub
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { identity, instanceSlug: slug, credsJwt: opts.creds, instanceSigned, idpSubject }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function adminLookupOpts(opts: KernelCommandOpts): KernelCommandOpts {
|
|
103
|
+
return {
|
|
104
|
+
as: opts.as,
|
|
105
|
+
creds: opts.creds,
|
|
106
|
+
timeout: opts.timeout,
|
|
107
|
+
debug: opts.debug,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
|
|
112
|
+
return await withAdminKernelClient(
|
|
113
|
+
adminLookupOpts(opts),
|
|
114
|
+
async (ctx) => (await ctx.client.call(`${ADMIN_INSTANCE}/info`, { id: slug })) as InstanceInfo,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Run `fn`; if it throws a `NotFoundError`, stamp expansion metadata onto the
|
|
120
|
+
* error so `formatKernelError` can append the stale-registration hint. The
|
|
121
|
+
* error is re-thrown either way.
|
|
122
|
+
*/
|
|
123
|
+
export async function withSelfHint<T>(
|
|
124
|
+
fn: () => Promise<T>,
|
|
125
|
+
meta: SelfExpansionMeta | undefined,
|
|
126
|
+
): Promise<T> {
|
|
127
|
+
if (!meta) return fn()
|
|
128
|
+
try {
|
|
129
|
+
return await fn()
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof Error && err.name === 'NotFoundError') {
|
|
132
|
+
;(err as Error & { expandedFromSelf?: SelfExpansionMeta }).expandedFromSelf = meta
|
|
133
|
+
}
|
|
134
|
+
throw err
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Convenience: resolve the nodeId once, or throw the typed refusal. */
|
|
139
|
+
export function resolveOrThrow(selfCtx: SelfResolverContext): string {
|
|
140
|
+
const r: SelfResolution = resolveSelfNodeId(selfCtx)
|
|
141
|
+
if ('reason' in r) throw selfRefusalError(r)
|
|
142
|
+
return r.id
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The kernel's whoami — returns the AUTHENTICATED principal's graph node.
|
|
146
|
+
// Static interface method, so the colon form (slash form is rejected).
|
|
147
|
+
const WHOAMI_PATH = '/:kernel.astrale.ai:interface.Identity:whoami'
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve `@self`, falling back to ONE kernel `whoami` round-trip when an
|
|
151
|
+
* IdP identity merely lacks a cached registration on this instance (the
|
|
152
|
+
* normal `astrale auth login` flow — the IdP subject is never a node id).
|
|
153
|
+
* The resolved id is persisted as a registration so subsequent expansions
|
|
154
|
+
* are local again. Every other refusal (manager, instance-signed, …) and a
|
|
155
|
+
* failed whoami throw the typed refusal unchanged.
|
|
156
|
+
*/
|
|
157
|
+
export async function resolveSelfIdLazy(
|
|
158
|
+
selfCtx: SelfResolverContext,
|
|
159
|
+
opts: KernelCommandOpts,
|
|
160
|
+
): Promise<string> {
|
|
161
|
+
const r: SelfResolution = resolveSelfNodeId(selfCtx)
|
|
162
|
+
if (!('reason' in r)) return r.id
|
|
163
|
+
if (r.reason !== 'idp-no-sub' || !selfCtx.instanceSlug || !selfCtx.identity) {
|
|
164
|
+
throw selfRefusalError(r)
|
|
165
|
+
}
|
|
166
|
+
let me: { id?: unknown } | null
|
|
167
|
+
let kernelUrl = ''
|
|
168
|
+
try {
|
|
169
|
+
me = (await withKernelClient(opts, (ctx) => {
|
|
170
|
+
kernelUrl = ctx.url
|
|
171
|
+
return ctx.client.call(WHOAMI_PATH as never, {} as never)
|
|
172
|
+
})) as { id?: unknown } | null
|
|
173
|
+
} catch {
|
|
174
|
+
// Network/auth failure — surface the original recipe, not a stack.
|
|
175
|
+
throw selfRefusalError(r)
|
|
176
|
+
}
|
|
177
|
+
const id = typeof me?.id === 'string' && me.id.trim().length > 0 ? me.id : undefined
|
|
178
|
+
if (!id) throw selfRefusalError(r)
|
|
179
|
+
await setRegistration(selfCtx.identity.name, selfCtx.instanceSlug, {
|
|
180
|
+
iss: kernelUrl,
|
|
181
|
+
sub: id,
|
|
182
|
+
registeredAt: new Date().toISOString(),
|
|
183
|
+
})
|
|
184
|
+
return id
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Expand `@self` in a single path string for the common command shape
|
|
189
|
+
* (`get`, `ls`, `describe`). Returns the expanded path AND the metadata
|
|
190
|
+
* needed by `withSelfHint` to attach the stale-registration hint to a
|
|
191
|
+
* downstream `NotFoundError`.
|
|
192
|
+
*
|
|
193
|
+
* No-op (returns the input unchanged with `meta: undefined`) when the path
|
|
194
|
+
* contains no `@self` — avoids the I/O of `buildSelfContext`.
|
|
195
|
+
*
|
|
196
|
+
* Throws `SelfRefusalError` when `@self` is present but unresolvable.
|
|
197
|
+
*/
|
|
198
|
+
export async function expandSelfInPath(
|
|
199
|
+
path: string,
|
|
200
|
+
opts: KernelCommandOpts,
|
|
201
|
+
): Promise<{ path: string; meta: SelfExpansionMeta | undefined }> {
|
|
202
|
+
if (!containsSelfRef(path)) return { path, meta: undefined }
|
|
203
|
+
const selfCtx = await buildSelfContext(opts)
|
|
204
|
+
const id = await resolveSelfIdLazy(selfCtx, opts)
|
|
205
|
+
const expanded = expandSelfReferences(path, id)
|
|
206
|
+
if (expanded === path) return { path, meta: undefined }
|
|
207
|
+
return {
|
|
208
|
+
path: expanded,
|
|
209
|
+
meta: {
|
|
210
|
+
original: path,
|
|
211
|
+
expanded,
|
|
212
|
+
selfId: id,
|
|
213
|
+
identity: selfCtx.identity?.name,
|
|
214
|
+
slug: selfCtx.instanceSlug,
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { withKernelClient, withAdminKernelClient, type ClientContext } from './client'
|
|
2
|
+
export { resolveCredential } from './auth'
|
|
3
|
+
export { formatKernelError } from './errors'
|
|
4
|
+
export {
|
|
5
|
+
buildSelfContext,
|
|
6
|
+
expandSelfInPath,
|
|
7
|
+
withSelfHint,
|
|
8
|
+
resolveOrThrow,
|
|
9
|
+
resolveSelfIdLazy,
|
|
10
|
+
type SelfExpansionMeta,
|
|
11
|
+
} from './expand'
|
|
12
|
+
export { mintRemoteCredential } from './remote-routing'
|
|
13
|
+
export { runKernelCommand, extractItems } from './run'
|
|
14
|
+
export type { KernelCommandOpts, CallCommandOpts } from './types'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CommandOption } from '../command'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flags consumed by `withKernelClient` (and therefore valid on any command
|
|
5
|
+
* that talks to a kernel). Spread these into a command's `options` to expose
|
|
6
|
+
* the same `--url / --instance / --timeout / --as / --creds / --debug`
|
|
7
|
+
* surface as `astrale call`.
|
|
8
|
+
*/
|
|
9
|
+
export const KERNEL_PASSTHROUGH_OPTIONS: CommandOption[] = [
|
|
10
|
+
{
|
|
11
|
+
flags: '--url <url>',
|
|
12
|
+
description: 'Target a kernel URL directly (overrides instance resolution)',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
flags: '-i, --instance <name>',
|
|
16
|
+
description: 'Target a specific instance (overrides active)',
|
|
17
|
+
},
|
|
18
|
+
{ flags: '--timeout <ms>', description: 'Request timeout in ms', default: '30000' },
|
|
19
|
+
{ flags: '--as <identity>', description: 'Call as a specific identity' },
|
|
20
|
+
{ flags: '--creds <token>', description: 'Use a pre-signed credential (e.g. delegation token)' },
|
|
21
|
+
{ flags: '--debug', description: 'Print full error diagnostics on failure' },
|
|
22
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { FnMap } from '@astrale-os/kernel-client'
|
|
2
|
+
import type { ClientSession } from '@astrale-os/kernel-client/session'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Credential-minting helpers for remote-bound kernel calls.
|
|
6
|
+
*
|
|
7
|
+
* The CLI no longer resolves remote bindings client-side: the kernel emits a
|
|
8
|
+
* redirect carrying the target worker's `iss`, and the `ClientSession` follows
|
|
9
|
+
* it, minting a worker-scoped delegation via `mintRemoteCredential` (wired as
|
|
10
|
+
* the session's delegation-cache mint in `client.ts`). These helpers (whoami →
|
|
11
|
+
* self-delegation) are the mint itself.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Mint a delegation credential scoped to the remote worker's audience.
|
|
16
|
+
*
|
|
17
|
+
* The Function lives under the caller's self identity (`@__system__` in manager
|
|
18
|
+
* kernels), so the invariant on `mintDelegationCredential` passes without
|
|
19
|
+
* additional claims. A self-delegation is the least-privilege shape — the
|
|
20
|
+
* worker inherits whatever grants the caller already has.
|
|
21
|
+
*
|
|
22
|
+
* Internal calls pass `skipDelegation: true`: they target the default kernel
|
|
23
|
+
* (same origin), so the delegation cache would skip them anyway, but being
|
|
24
|
+
* explicit guarantees the mint can never recurse into itself.
|
|
25
|
+
*/
|
|
26
|
+
export async function mintRemoteCredential(
|
|
27
|
+
client: ClientSession<FnMap>,
|
|
28
|
+
audience: string,
|
|
29
|
+
callerCredential: string,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const mintPath = await mintDelegationPath(client, callerCredential)
|
|
32
|
+
const result = await client.call(
|
|
33
|
+
mintPath,
|
|
34
|
+
{
|
|
35
|
+
audience,
|
|
36
|
+
delegation: { kind: 'identity', self: true },
|
|
37
|
+
ttl: 3600,
|
|
38
|
+
},
|
|
39
|
+
{ credential: callerCredential, skipDelegation: true },
|
|
40
|
+
)
|
|
41
|
+
if (typeof result !== 'string') {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`mintDelegationCredential returned non-string: ${typeof result} — cannot use as credential`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return result
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the kernel path that mints a self-delegation credential for the
|
|
51
|
+
* CALLER: `@<nodeId>::mintDelegationCredential`.
|
|
52
|
+
*/
|
|
53
|
+
export async function mintDelegationPath(
|
|
54
|
+
client: ClientSession<FnMap>,
|
|
55
|
+
credential: string,
|
|
56
|
+
): Promise<string> {
|
|
57
|
+
const sub = readJwtSub(credential)
|
|
58
|
+
// System / opaque credential: the graph node id is literally `__system__`,
|
|
59
|
+
// so we can skip the whoami round-trip.
|
|
60
|
+
if (!sub || sub === 'system') return '@__system__::mintDelegationCredential'
|
|
61
|
+
const self = (await client.call(
|
|
62
|
+
'/:kernel.astrale.ai:interface.Identity:whoami',
|
|
63
|
+
{},
|
|
64
|
+
{ credential, skipDelegation: true },
|
|
65
|
+
)) as { id?: unknown } | null
|
|
66
|
+
const id = self && typeof self.id === 'string' ? self.id : undefined
|
|
67
|
+
if (!id) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'Could not resolve the caller identity (whoami returned no id) — cannot mint a delegation credential.',
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
return `@${id}::mintDelegationCredential`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readJwtSub(credential: string): string | null {
|
|
76
|
+
const [, payload] = credential.split('.')
|
|
77
|
+
if (!payload) return null
|
|
78
|
+
try {
|
|
79
|
+
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
|
|
80
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
|
81
|
+
const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as {
|
|
82
|
+
sub?: unknown
|
|
83
|
+
}
|
|
84
|
+
return typeof decoded.sub === 'string' && decoded.sub.length > 0 ? decoded.sub : null
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
import type { ClientContext } from './client'
|
|
4
|
+
import type { KernelCommandOpts } from './types'
|
|
5
|
+
|
|
6
|
+
import { formatElapsed } from '../lib/format'
|
|
7
|
+
import { spinner } from '../lib/log'
|
|
8
|
+
import { isMachine, present } from '../lib/output'
|
|
9
|
+
import { withKernelClient } from './client'
|
|
10
|
+
import { formatKernelError } from './errors'
|
|
11
|
+
|
|
12
|
+
type RunOpts<T> = {
|
|
13
|
+
opts: KernelCommandOpts
|
|
14
|
+
label: string
|
|
15
|
+
fn: (ctx: ClientContext) => Promise<T>
|
|
16
|
+
format?: (result: T, opts: KernelCommandOpts, isRaw: boolean) => void | Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Encapsulates the standard kernel command lifecycle:
|
|
21
|
+
* spinner → connect → call → timing → output → error handling.
|
|
22
|
+
*
|
|
23
|
+
* Commands provide a `fn` that does the actual work and an optional
|
|
24
|
+
* `format` callback for custom output. If `format` is omitted, the
|
|
25
|
+
* result is passed to the standard `output()` function.
|
|
26
|
+
*/
|
|
27
|
+
export async function runKernelCommand<T>(run: RunOpts<T>): Promise<void> {
|
|
28
|
+
const { opts, label, fn } = run
|
|
29
|
+
const isRaw = isMachine(opts)
|
|
30
|
+
const spin = !isRaw ? spinner(`${label}...`) : null
|
|
31
|
+
const startTime = performance.now()
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const result = await withKernelClient(opts, fn)
|
|
35
|
+
const elapsed = performance.now() - startTime
|
|
36
|
+
|
|
37
|
+
spin?.succeed(`${label} ${chalk.dim(formatElapsed(elapsed))}`)
|
|
38
|
+
if (!isRaw) console.log('')
|
|
39
|
+
|
|
40
|
+
if (run.format) {
|
|
41
|
+
await run.format(result, opts, isRaw)
|
|
42
|
+
} else {
|
|
43
|
+
present(result, opts)
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!isRaw && spin) spin.fail(`${label} failed`)
|
|
47
|
+
await formatKernelError(error, isRaw, undefined, opts.debug, { credential: opts.creds })
|
|
48
|
+
process.exit(1)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalize a `listChildren` response into an array of items.
|
|
54
|
+
* The kernel may return a bare array or `{ items: [...] }`.
|
|
55
|
+
*/
|
|
56
|
+
export function extractItems<T = Record<string, unknown>>(result: unknown): T[] {
|
|
57
|
+
if (Array.isArray(result)) return result as T[]
|
|
58
|
+
if (result && typeof result === 'object') {
|
|
59
|
+
const items = (result as { items?: unknown }).items
|
|
60
|
+
if (Array.isArray(items)) return items as T[]
|
|
61
|
+
}
|
|
62
|
+
return []
|
|
63
|
+
}
|