@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
package/src/lib/keys.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { generateKeyPair, exportJWK, importJWK, SignJWT, type JWK } from 'jose'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { readFile, mkdir, access, unlink } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { IdentityKeyMissingError } from '../errors'
|
|
7
|
+
import { inferAlg } from './domain-identity'
|
|
8
|
+
import { atomicWrite } from './fs-atomic'
|
|
9
|
+
import { log } from './log'
|
|
10
|
+
import { KEYS_DIR } from './paths'
|
|
11
|
+
|
|
12
|
+
const LEGACY_MANAGER_PRIVATE = 'manager.private.jwk'
|
|
13
|
+
const LEGACY_MANAGER_PUBLIC = 'manager.public.jwk'
|
|
14
|
+
|
|
15
|
+
// Default issuer/audience for the host-mode manager's own credential. The
|
|
16
|
+
// manager kernel mounts at `/host` (the reserved host slug — SPEC §5.2).
|
|
17
|
+
const DEFAULT_MANAGER_ISSUER = 'http://localhost:4400/host'
|
|
18
|
+
|
|
19
|
+
type AuthOptions = {
|
|
20
|
+
issuer?: string
|
|
21
|
+
subject?: string
|
|
22
|
+
kid?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type AuthBinding = {
|
|
26
|
+
credential: string
|
|
27
|
+
publicKey: { jwk: JWK }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type KeypairPaths = {
|
|
31
|
+
privatePath: string
|
|
32
|
+
publicPath: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Per-identity keypair paths. `manager` stays on the legacy filenames so
|
|
37
|
+
* existing installs keep working; other subjects use `<subject>.*.jwk`.
|
|
38
|
+
*/
|
|
39
|
+
export function keypairPaths(subject: string, keysDir: string = KEYS_DIR): KeypairPaths {
|
|
40
|
+
if (subject === 'manager') {
|
|
41
|
+
return {
|
|
42
|
+
privatePath: join(keysDir, LEGACY_MANAGER_PRIVATE),
|
|
43
|
+
publicPath: join(keysDir, LEGACY_MANAGER_PUBLIC),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
privatePath: join(keysDir, `${subject}.private.jwk`),
|
|
48
|
+
publicPath: join(keysDir, `${subject}.public.jwk`),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function fileExists(path: string): Promise<boolean> {
|
|
53
|
+
try {
|
|
54
|
+
await access(path)
|
|
55
|
+
return true
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** List identity names that have a private key on disk. */
|
|
62
|
+
export async function listIdentityKeys(keysDir: string = KEYS_DIR): Promise<string[]> {
|
|
63
|
+
try {
|
|
64
|
+
const { readdir } = await import('node:fs/promises')
|
|
65
|
+
const entries = await readdir(keysDir)
|
|
66
|
+
const names = new Set<string>()
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (entry === LEGACY_MANAGER_PRIVATE) names.add('manager')
|
|
69
|
+
else if (entry.endsWith('.private.jwk')) names.add(entry.replace(/\.private\.jwk$/, ''))
|
|
70
|
+
}
|
|
71
|
+
return Array.from(names).sort()
|
|
72
|
+
} catch {
|
|
73
|
+
return []
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Generate a fresh Ed25519 keypair as plain JWKs (not persisted).
|
|
79
|
+
*
|
|
80
|
+
* Used by the domain scaffold to stamp worker key files with a real,
|
|
81
|
+
* internally-consistent pair — defense against drift between `d` and `x`
|
|
82
|
+
* (historical template copies had a mismatched `x`, making every scaffold
|
|
83
|
+
* inherit a broken pair). Same crypto as ES256 elsewhere in the CLI but
|
|
84
|
+
* EdDSA/Ed25519 matches the worker runtime convention.
|
|
85
|
+
*/
|
|
86
|
+
export async function generateEd25519Jwk(
|
|
87
|
+
kid: string,
|
|
88
|
+
): Promise<{ privateJwk: JWK; publicJwk: JWK }> {
|
|
89
|
+
const { publicKey, privateKey } = await generateKeyPair('EdDSA', {
|
|
90
|
+
crv: 'Ed25519',
|
|
91
|
+
extractable: true,
|
|
92
|
+
})
|
|
93
|
+
const privateJwk = await exportJWK(privateKey)
|
|
94
|
+
const publicJwk = await exportJWK(publicKey)
|
|
95
|
+
privateJwk.alg = 'EdDSA'
|
|
96
|
+
publicJwk.alg = 'EdDSA'
|
|
97
|
+
privateJwk.kid = kid
|
|
98
|
+
publicJwk.kid = kid
|
|
99
|
+
return { privateJwk, publicJwk }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Generate a new keypair for `subject`, persist atomically with 0o600
|
|
104
|
+
* perms, and return the JWKs + kid. Overwrites any existing keys.
|
|
105
|
+
*/
|
|
106
|
+
export async function persistKeypair(
|
|
107
|
+
subject: string,
|
|
108
|
+
opts?: { keysDir?: string; kid?: string },
|
|
109
|
+
): Promise<{ publicJwk: JWK; privateJwk: JWK; kid: string }> {
|
|
110
|
+
const keysDir = opts?.keysDir ?? KEYS_DIR
|
|
111
|
+
const { privatePath, publicPath } = keypairPaths(subject, keysDir)
|
|
112
|
+
await mkdir(keysDir, { recursive: true })
|
|
113
|
+
|
|
114
|
+
const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true })
|
|
115
|
+
const publicJwk = await exportJWK(publicKey)
|
|
116
|
+
const privateJwk = await exportJWK(privateKey)
|
|
117
|
+
const kid = opts?.kid ?? `${subject}-key-${randomUUID().slice(0, 8)}`
|
|
118
|
+
publicJwk.kid = kid
|
|
119
|
+
privateJwk.kid = kid
|
|
120
|
+
publicJwk.alg = 'ES256'
|
|
121
|
+
privateJwk.alg = 'ES256'
|
|
122
|
+
|
|
123
|
+
await atomicWrite(privatePath, JSON.stringify(privateJwk, null, 2))
|
|
124
|
+
await atomicWrite(publicPath, JSON.stringify(publicJwk, null, 2))
|
|
125
|
+
|
|
126
|
+
return { publicJwk, privateJwk, kid }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Remove a subject's keypair files. Idempotent. */
|
|
130
|
+
export async function removeKeypair(subject: string, keysDir: string = KEYS_DIR): Promise<void> {
|
|
131
|
+
const { privatePath, publicPath } = keypairPaths(subject, keysDir)
|
|
132
|
+
for (const p of [privatePath, publicPath]) {
|
|
133
|
+
try {
|
|
134
|
+
await unlink(p)
|
|
135
|
+
} catch {
|
|
136
|
+
/* ignore */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Legacy signal: warn once per session when an unknown subject falls back
|
|
142
|
+
// to the manager key.
|
|
143
|
+
const warnedFallback = new Set<string>()
|
|
144
|
+
|
|
145
|
+
async function loadSigningMaterial(
|
|
146
|
+
subject: string,
|
|
147
|
+
keysDir: string,
|
|
148
|
+
): Promise<{ privateJwk: JWK; publicJwk: JWK; kid: string }> {
|
|
149
|
+
const { privatePath, publicPath } = keypairPaths(subject, keysDir)
|
|
150
|
+
|
|
151
|
+
if (await fileExists(privatePath)) {
|
|
152
|
+
const privateJwk = JSON.parse(await readFile(privatePath, 'utf-8')) as JWK
|
|
153
|
+
const publicJwk = JSON.parse(await readFile(publicPath, 'utf-8')) as JWK
|
|
154
|
+
return {
|
|
155
|
+
privateJwk,
|
|
156
|
+
publicJwk,
|
|
157
|
+
kid: (privateJwk.kid as string | undefined) ?? `${subject}-key`,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (process.env.ASTRALE_STRICT_IDENTITIES === '1') {
|
|
162
|
+
throw new IdentityKeyMissingError(subject)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Manager always uses its own file. Any other subject falls through
|
|
166
|
+
// to the manager key with a one-shot warning so the migration window
|
|
167
|
+
// stays visible without being noisy.
|
|
168
|
+
if (subject === 'manager') throw new IdentityKeyMissingError(subject)
|
|
169
|
+
|
|
170
|
+
const { privatePath: mgrPrivate, publicPath: mgrPublic } = keypairPaths('manager', keysDir)
|
|
171
|
+
if (!(await fileExists(mgrPrivate))) throw new IdentityKeyMissingError(subject)
|
|
172
|
+
|
|
173
|
+
if (!warnedFallback.has(subject)) {
|
|
174
|
+
log.warn(
|
|
175
|
+
`Legacy shared-key mode for "${subject}" — run \`astrale identity create ${subject}\` to generate a dedicated key.`,
|
|
176
|
+
)
|
|
177
|
+
warnedFallback.add(subject)
|
|
178
|
+
}
|
|
179
|
+
const privateJwk = JSON.parse(await readFile(mgrPrivate, 'utf-8')) as JWK
|
|
180
|
+
const publicJwk = JSON.parse(await readFile(mgrPublic, 'utf-8')) as JWK
|
|
181
|
+
return {
|
|
182
|
+
privateJwk,
|
|
183
|
+
publicJwk,
|
|
184
|
+
kid: (privateJwk.kid as string | undefined) ?? 'manager-key',
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Sign a self-identity credential (`grant: identity/self`) from a private
|
|
190
|
+
* JWK. Shared by `persistAuth`, `loadAuth`, and `signAs` so the protected
|
|
191
|
+
* header and claim set stay identical across all three; the only thing that
|
|
192
|
+
* varies is how each caller obtains the key material and which
|
|
193
|
+
* subject/audience it stamps.
|
|
194
|
+
*/
|
|
195
|
+
async function signIdentityCredential(args: {
|
|
196
|
+
privateJwk: JWK
|
|
197
|
+
kid: string
|
|
198
|
+
issuer: string
|
|
199
|
+
subject: string
|
|
200
|
+
audience: string
|
|
201
|
+
}): Promise<string> {
|
|
202
|
+
const alg = inferAlg(args.privateJwk as Record<string, unknown>)
|
|
203
|
+
const privateKey = await importJWK(args.privateJwk, alg)
|
|
204
|
+
return new SignJWT({ grant: { v: 1, expr: { kind: 'identity', self: true } } })
|
|
205
|
+
.setProtectedHeader({ alg, kid: args.kid })
|
|
206
|
+
.setIssuer(args.issuer)
|
|
207
|
+
.setSubject(args.subject)
|
|
208
|
+
.setAudience(args.audience)
|
|
209
|
+
.sign(privateKey)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Generate a new keypair, persist to disk, and return a signed credential.
|
|
214
|
+
* Wraps the manager init path — use `persistKeypair` for bare keypair generation.
|
|
215
|
+
*/
|
|
216
|
+
export async function persistAuth(
|
|
217
|
+
keysDir: string = KEYS_DIR,
|
|
218
|
+
opts?: AuthOptions,
|
|
219
|
+
): Promise<AuthBinding> {
|
|
220
|
+
const issuer = opts?.issuer ?? DEFAULT_MANAGER_ISSUER
|
|
221
|
+
const subject = opts?.subject ?? 'manager'
|
|
222
|
+
const kid = opts?.kid ?? `${subject}-key`
|
|
223
|
+
|
|
224
|
+
const { privateJwk, publicJwk } = await persistKeypair(subject, { keysDir, kid })
|
|
225
|
+
const credential = await signIdentityCredential({
|
|
226
|
+
privateJwk,
|
|
227
|
+
kid,
|
|
228
|
+
issuer,
|
|
229
|
+
subject,
|
|
230
|
+
audience: issuer,
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
return { credential, publicKey: { jwk: publicJwk } }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Load existing keys from disk and sign a fresh credential.
|
|
238
|
+
*/
|
|
239
|
+
export async function loadAuth(
|
|
240
|
+
keysDir: string = KEYS_DIR,
|
|
241
|
+
opts?: AuthOptions,
|
|
242
|
+
): Promise<AuthBinding> {
|
|
243
|
+
const issuer = opts?.issuer ?? DEFAULT_MANAGER_ISSUER
|
|
244
|
+
const subject = opts?.subject ?? 'manager'
|
|
245
|
+
|
|
246
|
+
const { privateJwk, publicJwk, kid } = await loadSigningMaterial(subject, keysDir)
|
|
247
|
+
const credential = await signIdentityCredential({
|
|
248
|
+
privateJwk,
|
|
249
|
+
kid,
|
|
250
|
+
issuer,
|
|
251
|
+
subject,
|
|
252
|
+
audience: issuer,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
return { credential, publicKey: { jwk: publicJwk } }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Load existing keys if present, otherwise generate and persist new ones.
|
|
260
|
+
*/
|
|
261
|
+
export async function resolveAuth(
|
|
262
|
+
keysDir: string = KEYS_DIR,
|
|
263
|
+
opts?: AuthOptions,
|
|
264
|
+
): Promise<AuthBinding> {
|
|
265
|
+
const subject = opts?.subject ?? 'manager'
|
|
266
|
+
const { privatePath } = keypairPaths(subject, keysDir)
|
|
267
|
+
if (await fileExists(privatePath)) {
|
|
268
|
+
return loadAuth(keysDir, opts)
|
|
269
|
+
}
|
|
270
|
+
return persistAuth(keysDir, opts)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Sign a JWT as a specific subject. Uses the subject's own keypair when
|
|
275
|
+
* present; falls back to the manager key (with a one-shot warning) for
|
|
276
|
+
* unknown subjects until `ASTRALE_STRICT_IDENTITIES=1` is set.
|
|
277
|
+
*/
|
|
278
|
+
export async function signAs(
|
|
279
|
+
subject: string,
|
|
280
|
+
keysDir: string = KEYS_DIR,
|
|
281
|
+
opts?: { issuer?: string; audience?: string; subject?: string },
|
|
282
|
+
): Promise<string> {
|
|
283
|
+
const issuer = opts?.issuer ?? DEFAULT_MANAGER_ISSUER
|
|
284
|
+
const audience = opts?.audience ?? issuer
|
|
285
|
+
const { privateJwk, kid } = await loadSigningMaterial(subject, keysDir)
|
|
286
|
+
|
|
287
|
+
return signIdentityCredential({
|
|
288
|
+
privateJwk,
|
|
289
|
+
kid,
|
|
290
|
+
issuer,
|
|
291
|
+
subject: opts?.subject ?? subject,
|
|
292
|
+
audience,
|
|
293
|
+
})
|
|
294
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { decodeJwt } from 'jose'
|
|
2
|
+
|
|
3
|
+
import type { AstraleConfig } from './config'
|
|
4
|
+
|
|
5
|
+
import { resolveAdminTargetFromStore } from './admin-target'
|
|
6
|
+
import { DEFAULT_CONFIG, readConfig } from './config'
|
|
7
|
+
import { readIdentities, type IdentityStore } from './identity'
|
|
8
|
+
import { isSessionExpired, readIdpSession, type IdpSession } from './idp'
|
|
9
|
+
import { readInstances, type InstanceStore } from './instance'
|
|
10
|
+
|
|
11
|
+
export type LocalInstanceStatus = {
|
|
12
|
+
active: string
|
|
13
|
+
url: string
|
|
14
|
+
issuer: string | null
|
|
15
|
+
defaultIdentity: string | null
|
|
16
|
+
} | null
|
|
17
|
+
|
|
18
|
+
export type LocalAdminStatus =
|
|
19
|
+
| {
|
|
20
|
+
name: string
|
|
21
|
+
url: string
|
|
22
|
+
issuer: string
|
|
23
|
+
source: string
|
|
24
|
+
configured: boolean
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
error: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type LocalIdentityStatus = {
|
|
31
|
+
name: string
|
|
32
|
+
subject: string
|
|
33
|
+
source: 'key' | 'idp'
|
|
34
|
+
idp: string | null
|
|
35
|
+
session: {
|
|
36
|
+
cached: boolean
|
|
37
|
+
requiresLogin?: boolean
|
|
38
|
+
hasRefreshToken?: boolean
|
|
39
|
+
} | null
|
|
40
|
+
} | null
|
|
41
|
+
|
|
42
|
+
export type LocalStatus = {
|
|
43
|
+
admin: LocalAdminStatus
|
|
44
|
+
instance: LocalInstanceStatus
|
|
45
|
+
identity: LocalIdentityStatus
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type JwtExpiration = {
|
|
49
|
+
expiresAt: string
|
|
50
|
+
expired: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readLocalStatus(): Promise<LocalStatus> {
|
|
54
|
+
const [instances, identities, config] = await Promise.all([
|
|
55
|
+
// Read-only: status / `setup --plan` must never trigger a sanitize-writeback.
|
|
56
|
+
readInstances(undefined, { persist: false }),
|
|
57
|
+
readIdentities(),
|
|
58
|
+
readConfig(),
|
|
59
|
+
])
|
|
60
|
+
return buildLocalStatus(instances, identities, async (name) => readIdpSession(name), config)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function buildLocalStatus(
|
|
64
|
+
instances: InstanceStore,
|
|
65
|
+
identities: IdentityStore,
|
|
66
|
+
readSession: (identityName: string) => Promise<IdpSession | null>,
|
|
67
|
+
config: AstraleConfig = DEFAULT_CONFIG,
|
|
68
|
+
): Promise<LocalStatus> {
|
|
69
|
+
const activeEntry = instances.active ? instances.instances[instances.active] : undefined
|
|
70
|
+
const instance = instances.active
|
|
71
|
+
? {
|
|
72
|
+
active: instances.active,
|
|
73
|
+
url: activeEntry?.url ?? '',
|
|
74
|
+
issuer: activeEntry?.issuer ?? null,
|
|
75
|
+
defaultIdentity: activeEntry?.defaultIdentity ?? null,
|
|
76
|
+
}
|
|
77
|
+
: null
|
|
78
|
+
|
|
79
|
+
const identityEntry = identities.identities[identities.default]
|
|
80
|
+
const admin = buildAdminStatus(config, instances)
|
|
81
|
+
if (!identityEntry) {
|
|
82
|
+
return { admin, instance, identity: null }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const source = identityEntry.source ?? 'key'
|
|
86
|
+
const session =
|
|
87
|
+
source === 'idp'
|
|
88
|
+
? await readSession(identities.default)
|
|
89
|
+
.then((value) => {
|
|
90
|
+
if (!value) return { cached: false }
|
|
91
|
+
const expiresAt =
|
|
92
|
+
value.expires_at ??
|
|
93
|
+
expClaimToIso(value.claims?.exp) ??
|
|
94
|
+
expClaimToIso(identityEntry.claims?.exp)
|
|
95
|
+
const hasRefreshToken = !!value.refresh_token
|
|
96
|
+
return {
|
|
97
|
+
cached: true,
|
|
98
|
+
requiresLogin:
|
|
99
|
+
!hasRefreshToken &&
|
|
100
|
+
isSessionExpired({ expires_at: expiresAt, access_token: value.access_token }),
|
|
101
|
+
hasRefreshToken,
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
.catch(() => ({ cached: false }))
|
|
105
|
+
: null
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
admin,
|
|
109
|
+
instance,
|
|
110
|
+
identity: {
|
|
111
|
+
name: identities.default,
|
|
112
|
+
subject: identityEntry.subject,
|
|
113
|
+
source,
|
|
114
|
+
idp: identityEntry.idp ?? null,
|
|
115
|
+
session,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function buildAdminStatus(config: AstraleConfig, instances: InstanceStore): LocalAdminStatus {
|
|
121
|
+
try {
|
|
122
|
+
const target = resolveAdminTargetFromStore({}, config, instances)
|
|
123
|
+
return {
|
|
124
|
+
name: target.name,
|
|
125
|
+
url: target.url,
|
|
126
|
+
issuer: target.issuer,
|
|
127
|
+
source: target.source,
|
|
128
|
+
configured: target.configured,
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return { error: err instanceof Error ? err.message : String(err) }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function expClaimToIso(value: unknown): string | undefined {
|
|
136
|
+
if (typeof value !== 'number') return undefined
|
|
137
|
+
return new Date(value * 1000).toISOString()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function decodeJwtExpiration(token: string, nowMs = Date.now()): JwtExpiration | null {
|
|
141
|
+
try {
|
|
142
|
+
const payload = decodeJwt(token)
|
|
143
|
+
if (typeof payload.exp !== 'number') return null
|
|
144
|
+
const expiresMs = payload.exp * 1000
|
|
145
|
+
return {
|
|
146
|
+
expiresAt: new Date(expiresMs).toISOString(),
|
|
147
|
+
expired: expiresMs <= nowMs,
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/lib/log.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import ora, { type Ora } from 'ora'
|
|
3
|
+
|
|
4
|
+
import { AstraleError, NotImplementedError } from '../errors'
|
|
5
|
+
import { formatElapsed } from './format'
|
|
6
|
+
|
|
7
|
+
export const log = {
|
|
8
|
+
info: (msg: string) => console.log(chalk.blue('ℹ'), msg),
|
|
9
|
+
success: (msg: string) => console.log(chalk.green('✔'), msg),
|
|
10
|
+
warn: (msg: string) => console.log(chalk.yellow('⚠'), msg),
|
|
11
|
+
error: (msg: string) => console.error(chalk.red('✖'), msg),
|
|
12
|
+
step: (msg: string) => console.log(chalk.cyan('→'), msg),
|
|
13
|
+
dim: (msg: string) => console.log(chalk.dim(msg)),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Report an error with hint (when present) and exit. */
|
|
17
|
+
export function fatal(e: unknown): never {
|
|
18
|
+
// Ctrl-C at an interactive (@inquirer/prompts) prompt — exit quietly with the
|
|
19
|
+
// SIGINT convention, not a red error line.
|
|
20
|
+
if (e instanceof Error && e.name === 'ExitPromptError') process.exit(130)
|
|
21
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
22
|
+
log.error(msg)
|
|
23
|
+
if (e instanceof AstraleError && e.hint) log.dim(` hint: ${e.hint}`)
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Shortcut for stub commands that aren't wired in v1 (§15). */
|
|
28
|
+
export function fatalNotImplemented(feature: string, hint?: string): never {
|
|
29
|
+
fatal(new NotImplementedError(feature, hint))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Maximum time a spinner may run before being forcefully stopped. */
|
|
33
|
+
const SPINNER_SAFETY_MS = 60_000
|
|
34
|
+
|
|
35
|
+
const IS_CI = !!(process.env.CI || process.env.CONTINUOUS_INTEGRATION || process.env.NO_SPINNER)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run an async operation behind a spinner. Pass `enabled: false` for
|
|
39
|
+
* machine-readable output modes. Errors are rethrown after the spinner is
|
|
40
|
+
* stopped (✖ label failed).
|
|
41
|
+
*
|
|
42
|
+
* On success the spinner line is cleared — commands print their own result.
|
|
43
|
+
* Pass `opts.success` to instead persist a single final line
|
|
44
|
+
* (✔ <success text> <elapsed>) so a command ends on one line, not a
|
|
45
|
+
* spinner line + result line pair.
|
|
46
|
+
*/
|
|
47
|
+
export async function withSpinner<T>(
|
|
48
|
+
label: string,
|
|
49
|
+
enabled: boolean,
|
|
50
|
+
fn: () => Promise<T>,
|
|
51
|
+
opts: { success?: (result: T) => string } = {},
|
|
52
|
+
): Promise<T> {
|
|
53
|
+
if (!enabled) return await fn()
|
|
54
|
+
const spin = spinner(`${label}...`)
|
|
55
|
+
const start = performance.now()
|
|
56
|
+
try {
|
|
57
|
+
const result = await fn()
|
|
58
|
+
if (opts.success) {
|
|
59
|
+
spin.succeed(`${opts.success(result)} ${chalk.dim(formatElapsed(performance.now() - start))}`)
|
|
60
|
+
} else {
|
|
61
|
+
spin.stop()
|
|
62
|
+
}
|
|
63
|
+
return result
|
|
64
|
+
} catch (error) {
|
|
65
|
+
spin.fail(`${label} failed`)
|
|
66
|
+
throw error
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function spinner(text: string): Ora {
|
|
71
|
+
const target = process.stderr
|
|
72
|
+
|
|
73
|
+
if (!target.writable || IS_CI) {
|
|
74
|
+
return ora({ text, isEnabled: false })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Hand ora the bare stream: ora 9 hooks `stream.write` by assignment to
|
|
78
|
+
// interleave external writes, so any wrapper here must survive that
|
|
79
|
+
// mutation (a get-only Proxy recurses infinitely and kills the spinner).
|
|
80
|
+
// Backpressure is ora's job now — it pauses rendering until 'drain'.
|
|
81
|
+
const spin = ora({ text, color: 'cyan', stream: target }).start()
|
|
82
|
+
|
|
83
|
+
const safety = setTimeout(() => {
|
|
84
|
+
if (spin.isSpinning) spin.stop()
|
|
85
|
+
}, SPINNER_SAFETY_MS)
|
|
86
|
+
safety.unref()
|
|
87
|
+
|
|
88
|
+
const onTargetError = () => {
|
|
89
|
+
if (spin.isSpinning) spin.stop()
|
|
90
|
+
}
|
|
91
|
+
target.once('error', onTargetError)
|
|
92
|
+
|
|
93
|
+
const cleanup = () => {
|
|
94
|
+
clearTimeout(safety)
|
|
95
|
+
target.removeListener('error', onTargetError)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const origSucceed = spin.succeed.bind(spin)
|
|
99
|
+
const origFail = spin.fail.bind(spin)
|
|
100
|
+
const origStop = spin.stop.bind(spin)
|
|
101
|
+
|
|
102
|
+
spin.succeed = (text?: string) => {
|
|
103
|
+
cleanup()
|
|
104
|
+
return origSucceed(text)
|
|
105
|
+
}
|
|
106
|
+
spin.fail = (text?: string) => {
|
|
107
|
+
cleanup()
|
|
108
|
+
return origFail(text)
|
|
109
|
+
}
|
|
110
|
+
spin.stop = () => {
|
|
111
|
+
cleanup()
|
|
112
|
+
return origStop()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return spin
|
|
116
|
+
}
|