@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.
Files changed (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
@@ -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
+ }