@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,126 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+
5
+ /** Write-tmp-then-rename so readers never observe a partial file. Mode 0600. */
6
+ export async function atomicWrite(path: string, data: string): Promise<void> {
7
+ const tmp = `${path}.${randomUUID()}.tmp`
8
+ await writeFile(tmp, data, { mode: 0o600 })
9
+ await rename(tmp, path)
10
+ }
11
+
12
+ export type FileLockOptions = {
13
+ /** Delay between acquisition attempts while another process holds the lock. */
14
+ pollIntervalMs?: number
15
+ /** A lock file whose mtime is older than this is considered abandoned. */
16
+ staleAfterMs?: number
17
+ /** Give up acquiring after this long. */
18
+ timeoutMs?: number
19
+ }
20
+
21
+ /**
22
+ * Run `fn` under a cross-process mutex backed by a create-exclusive lock file.
23
+ *
24
+ * Contenders poll until the holder releases (unlinks) the lock. Abandoned
25
+ * locks are taken over when the holder pid is dead or the file's mtime
26
+ * exceeds `staleAfterMs` — a crash mid-section can therefore delay other
27
+ * processes by up to `staleAfterMs`, never deadlock them.
28
+ */
29
+ export async function withFileLock<T>(
30
+ lockPath: string,
31
+ fn: () => Promise<T>,
32
+ opts: FileLockOptions = {},
33
+ ): Promise<T> {
34
+ const pollIntervalMs = opts.pollIntervalMs ?? 100
35
+ const staleAfterMs = opts.staleAfterMs ?? 30_000
36
+ const timeoutMs = opts.timeoutMs ?? 60_000
37
+ const deadline = Date.now() + timeoutMs
38
+
39
+ for (;;) {
40
+ const acquired = await tryAcquire(lockPath)
41
+ if (acquired) {
42
+ try {
43
+ return await fn()
44
+ } finally {
45
+ await unlink(lockPath).catch(() => undefined)
46
+ }
47
+ }
48
+ if (await takeoverIfAbandoned(lockPath, staleAfterMs)) continue
49
+ if (Date.now() >= deadline) {
50
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for lock ${lockPath}`)
51
+ }
52
+ await sleep(pollIntervalMs)
53
+ }
54
+ }
55
+
56
+ async function tryAcquire(lockPath: string): Promise<boolean> {
57
+ try {
58
+ const handle = await open(lockPath, 'wx', 0o600)
59
+ try {
60
+ await handle.writeFile(
61
+ JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }),
62
+ )
63
+ } finally {
64
+ await handle.close()
65
+ }
66
+ return true
67
+ } catch (e) {
68
+ const code = (e as { code?: string }).code
69
+ if (code === 'EEXIST') return false
70
+ if (code === 'ENOENT') {
71
+ await mkdir(dirname(lockPath), { recursive: true })
72
+ return tryAcquire(lockPath)
73
+ }
74
+ throw e
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Remove the lock if its holder is gone (dead pid, fast) or it outlived
80
+ * `staleAfterMs` (mtime, covers unreadable pids and cross-container holders).
81
+ * Returns true when the caller should immediately retry acquisition —
82
+ * `open('wx')` re-arbitrates between concurrent takeover candidates.
83
+ */
84
+ async function takeoverIfAbandoned(lockPath: string, staleAfterMs: number): Promise<boolean> {
85
+ try {
86
+ const info = await stat(lockPath)
87
+ if (Date.now() - info.mtimeMs > staleAfterMs) {
88
+ await unlink(lockPath).catch(() => undefined)
89
+ return true
90
+ }
91
+ } catch (e) {
92
+ // Lock vanished between attempts: holder released — retry right away.
93
+ if ((e as { code?: string }).code === 'ENOENT') return true
94
+ throw e
95
+ }
96
+ const pid = await readLockPid(lockPath)
97
+ if (pid !== undefined && pid !== process.pid && !isPidAlive(pid)) {
98
+ await unlink(lockPath).catch(() => undefined)
99
+ return true
100
+ }
101
+ return false
102
+ }
103
+
104
+ async function readLockPid(lockPath: string): Promise<number | undefined> {
105
+ try {
106
+ const raw = await readFile(lockPath, 'utf-8')
107
+ const pid = (JSON.parse(raw) as { pid?: unknown }).pid
108
+ return typeof pid === 'number' ? pid : undefined
109
+ } catch {
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ function isPidAlive(pid: number): boolean {
115
+ try {
116
+ process.kill(pid, 0)
117
+ return true
118
+ } catch (e) {
119
+ // EPERM means the pid exists but belongs to another user — still alive.
120
+ return (e as { code?: string }).code === 'EPERM'
121
+ }
122
+ }
123
+
124
+ function sleep(ms: number): Promise<void> {
125
+ return new Promise((resolve) => setTimeout(resolve, ms))
126
+ }
@@ -0,0 +1,256 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import { z } from 'zod'
4
+
5
+ import { deleteIdpSession } from './idp'
6
+ import { persistKeypair, removeKeypair } from './keys'
7
+ import { log } from './log'
8
+ import { IDENTITIES_PATH } from './paths'
9
+ import { RegistryModeSchema, validateName, type RegistryMode } from './validation'
10
+
11
+ export const RegistrationSchema = z.object({
12
+ iss: z.string(),
13
+ sub: z.string(),
14
+ registeredAt: z.string(),
15
+ })
16
+
17
+ export const IdentitySchema = z.object({
18
+ subject: z.string(),
19
+ createdAt: z.string(),
20
+ /** `key` identities sign local JWTs; `idp` identities reuse OAuth/OIDC access tokens. */
21
+ source: z.enum(['key', 'idp']).optional(),
22
+ // `local` = machine-only, `remote` = mirrored via astrale cloud (§2.7).
23
+ mode: RegistryModeSchema.optional(),
24
+ /** JWK thumbprint of the identity keypair. Optional for legacy entries. */
25
+ kid: z.string().optional(),
26
+ /** IdP registry name for source=idp identities. */
27
+ idp: z.string().optional(),
28
+ /** OIDC issuer for source=idp identities. */
29
+ issuer: z.string().url().optional(),
30
+ /** Optional preferred audience used during IdP login/refresh flows. */
31
+ audience: z.string().optional(),
32
+ /** Non-secret claims snapshot from the last successful IdP login. */
33
+ claims: z.record(z.string(), z.unknown()).optional(),
34
+ /**
35
+ * Cache of `(iss, sub)` pairs returned by `Identity::registerIdentity`,
36
+ * keyed by instance slug. Populated by `astrale identity register`; consulted
37
+ * by `resolveCredential` so `--as <name> -i <instance>` signs JWTs the kernel
38
+ * accepts.
39
+ */
40
+ registrations: z.record(z.string(), RegistrationSchema).optional(),
41
+ })
42
+
43
+ export type Registration = z.infer<typeof RegistrationSchema>
44
+
45
+ export const IdentityStoreSchema = z.object({
46
+ default: z.string(),
47
+ identities: z.record(z.string(), IdentitySchema),
48
+ })
49
+
50
+ export type Identity = z.infer<typeof IdentitySchema>
51
+ export type IdentityStore = z.infer<typeof IdentityStoreSchema>
52
+
53
+ function seed(): IdentityStore {
54
+ return {
55
+ default: 'manager',
56
+ identities: {
57
+ manager: {
58
+ subject: 'manager',
59
+ createdAt: new Date().toISOString(),
60
+ source: 'key',
61
+ mode: 'local',
62
+ },
63
+ },
64
+ }
65
+ }
66
+
67
+ export async function readIdentities(): Promise<IdentityStore> {
68
+ try {
69
+ const raw = await readFile(IDENTITIES_PATH, 'utf-8')
70
+ return IdentityStoreSchema.parse(JSON.parse(raw))
71
+ } catch (e) {
72
+ if (e instanceof z.ZodError) {
73
+ log.warn(`Invalid identities at ${IDENTITIES_PATH} — using defaults`)
74
+ } else if ((e as { code?: string }).code !== 'ENOENT') {
75
+ // Present-but-unreadable file (malformed JSON → SyntaxError, or a
76
+ // non-missing-file read error). ENOENT is the legitimate first-run
77
+ // case and stays silent; anything else would otherwise be discarded
78
+ // and could be overwritten by a later writeIdentities.
79
+ log.warn(`Could not read identities at ${IDENTITIES_PATH} — using defaults`)
80
+ }
81
+ return seed()
82
+ }
83
+ }
84
+
85
+ export async function writeIdentities(store: IdentityStore): Promise<void> {
86
+ await mkdir(dirname(IDENTITIES_PATH), { recursive: true })
87
+ await writeFile(IDENTITIES_PATH, JSON.stringify(store, null, 2) + '\n')
88
+ }
89
+
90
+ export async function createIdentity(
91
+ name: string,
92
+ opts: {
93
+ subject?: string
94
+ mode?: RegistryMode
95
+ issuer?: string
96
+ kid?: string
97
+ skipKeygen?: boolean
98
+ } = {},
99
+ ): Promise<Identity> {
100
+ validateName(name, 'Identity')
101
+ const store = await readIdentities()
102
+ if (store.identities[name]) {
103
+ throw new Error(`Identity "${name}" already exists`)
104
+ }
105
+ const subject = opts.subject ?? name
106
+ let kid: string | undefined
107
+ if (!opts.skipKeygen) {
108
+ const result = await persistKeypair(subject)
109
+ kid = result.kid
110
+ }
111
+ const identity: Identity = {
112
+ subject,
113
+ createdAt: new Date().toISOString(),
114
+ source: 'key',
115
+ mode: opts.mode ?? 'local',
116
+ kid: opts.kid ?? kid,
117
+ issuer: opts.issuer,
118
+ }
119
+ store.identities[name] = identity
120
+ await writeIdentities(store)
121
+ return identity
122
+ }
123
+
124
+ export async function upsertKeyIdentity(
125
+ name: string,
126
+ opts: {
127
+ subject: string
128
+ mode?: RegistryMode
129
+ issuer?: string
130
+ kid?: string
131
+ },
132
+ ): Promise<Identity> {
133
+ validateName(name, 'Identity')
134
+ const store = await readIdentities()
135
+ const existing = store.identities[name]
136
+ if (existing && (existing.source ?? 'key') !== 'key') {
137
+ throw new Error(`Identity "${name}" already exists and is IdP-backed`)
138
+ }
139
+ const identity: Identity = {
140
+ subject: opts.subject,
141
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
142
+ source: 'key',
143
+ mode: opts.mode ?? existing?.mode ?? 'local',
144
+ kid: opts.kid,
145
+ issuer: opts.issuer,
146
+ }
147
+ store.identities[name] = identity
148
+ await writeIdentities(store)
149
+ return identity
150
+ }
151
+
152
+ export async function deleteIdentity(name: string): Promise<void> {
153
+ const store = await readIdentities()
154
+ const entry = store.identities[name]
155
+ if (!entry) {
156
+ throw new Error(`Identity "${name}" not found`)
157
+ }
158
+ if (store.default === name) {
159
+ throw new Error(
160
+ `Cannot delete the default identity "${name}". Switch default first with: astrale identity use <other>`,
161
+ )
162
+ }
163
+ if ((entry.source ?? 'key') === 'idp') {
164
+ await deleteIdpSession(name)
165
+ } else {
166
+ await removeKeypair(entry.subject)
167
+ }
168
+ delete store.identities[name]
169
+ await writeIdentities(store)
170
+ }
171
+
172
+ export async function setDefault(name: string): Promise<void> {
173
+ const store = await readIdentities()
174
+ if (!store.identities[name]) {
175
+ throw new Error(`Identity "${name}" not found`)
176
+ }
177
+ store.default = name
178
+ await writeIdentities(store)
179
+ }
180
+
181
+ export async function getDefault(): Promise<Identity & { name: string }> {
182
+ const store = await readIdentities()
183
+ const identity = store.identities[store.default]
184
+ if (!identity) {
185
+ throw new Error(
186
+ `Default identity "${store.default}" not found. Run: astrale identity create ${store.default}`,
187
+ )
188
+ }
189
+ return { ...identity, name: store.default }
190
+ }
191
+
192
+ export async function getIdentity(name: string): Promise<Identity> {
193
+ const store = await readIdentities()
194
+ const identity = store.identities[name]
195
+ if (!identity) {
196
+ throw new Error(`Identity "${name}" not found. Run: astrale identity create ${name}`)
197
+ }
198
+ return identity
199
+ }
200
+
201
+ export async function upsertIdpIdentity(
202
+ name: string,
203
+ opts: {
204
+ subject: string
205
+ idp: string
206
+ issuer: string
207
+ audience?: string
208
+ claims?: Record<string, unknown>
209
+ use?: boolean
210
+ },
211
+ ): Promise<Identity> {
212
+ validateName(name, 'Identity')
213
+ validateName(opts.idp, 'IdP')
214
+ const store = await readIdentities()
215
+ const existing = store.identities[name]
216
+ if (existing && (existing.source ?? 'key') !== 'idp') {
217
+ throw new Error(`Identity "${name}" already exists and is key-backed`)
218
+ }
219
+ const identity: Identity = {
220
+ subject: opts.subject,
221
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
222
+ source: 'idp',
223
+ mode: 'remote',
224
+ idp: opts.idp,
225
+ issuer: opts.issuer,
226
+ audience: opts.audience,
227
+ claims: opts.claims,
228
+ registrations: existing?.registrations,
229
+ }
230
+ store.identities[name] = identity
231
+ if (opts.use !== false) store.default = name
232
+ await writeIdentities(store)
233
+ return identity
234
+ }
235
+
236
+ /** Record (or replace) the kernel-derived (iss, sub) pair for an identity on a target instance. */
237
+ export async function setRegistration(
238
+ name: string,
239
+ instanceSlug: string,
240
+ registration: Registration,
241
+ ): Promise<void> {
242
+ const store = await readIdentities()
243
+ const entry = store.identities[name]
244
+ if (!entry) throw new Error(`Identity "${name}" not found`)
245
+ entry.registrations = { ...entry.registrations, [instanceSlug]: registration }
246
+ await writeIdentities(store)
247
+ }
248
+
249
+ /** Migrate an identity to a new registry mode (local ↔ remote, §2.7). */
250
+ export async function setIdentityMode(name: string, mode: RegistryMode): Promise<void> {
251
+ const store = await readIdentities()
252
+ const entry = store.identities[name]
253
+ if (!entry) throw new Error(`Identity "${name}" not found`)
254
+ entry.mode = mode
255
+ await writeIdentities(store)
256
+ }
@@ -0,0 +1,134 @@
1
+ import { withFileLock } from './fs-atomic'
2
+ import {
3
+ accessTokenForAudience,
4
+ classifyRefreshFailure,
5
+ idpSessionPath,
6
+ OAuthTokenError,
7
+ readIdpSession,
8
+ refreshSession,
9
+ type IdpSession,
10
+ } from './idp'
11
+ import { orgIdForAudience } from './instance'
12
+ import { fetchOrgHint } from './meta'
13
+
14
+ /** No cached session file exists for the identity — only a login creates one. */
15
+ export class IdpSessionMissingError extends Error {
16
+ readonly identityName: string
17
+
18
+ constructor(identityName: string) {
19
+ super(`No cached IdP session for "${identityName}"`)
20
+ this.name = 'IdpSessionMissingError'
21
+ this.identityName = identityName
22
+ }
23
+ }
24
+
25
+ /** The session needs a refresh but never received a refresh token. */
26
+ export class IdpSessionNoRefreshTokenError extends Error {
27
+ readonly identityName: string
28
+
29
+ constructor(identityName: string) {
30
+ super(`IdP session for "${identityName}" has no refresh token`)
31
+ this.name = 'IdpSessionNoRefreshTokenError'
32
+ this.identityName = identityName
33
+ }
34
+ }
35
+
36
+ export type EnsureFreshSessionOptions = {
37
+ audience?: string
38
+ organizationId?: string
39
+ /**
40
+ * Org-hint resolver, consulted only when a refresh actually happens.
41
+ * Defaults to `fetchOrgHint`; injectable for tests.
42
+ */
43
+ resolveOrganizationId?: (audience: string) => Promise<string | undefined>
44
+ }
45
+
46
+ export function idpSessionLockPath(identityName: string): string {
47
+ // Lives next to the session file; listIdpSessions only picks up *.json.
48
+ return `${idpSessionPath(identityName)}.lock`
49
+ }
50
+
51
+ /**
52
+ * Return a session holding a fresh access token for `audience`, refreshing it
53
+ * if needed. This is the ONLY correct way to refresh: WorkOS refresh tokens
54
+ * are single-use, so two processes refreshing concurrently burn the same
55
+ * token — the loser's `invalid_grant` can revoke the whole upstream session.
56
+ * All refreshes therefore serialize on a per-identity file lock, and waiters
57
+ * re-read the rotated session instead of re-exchanging.
58
+ *
59
+ * `IdpAudienceMismatchError` propagates verbatim (the session is healthy; the
60
+ * IdP just won't mint that audience).
61
+ */
62
+ export async function ensureFreshSession(
63
+ identityName: string,
64
+ opts: EnsureFreshSessionOptions = {},
65
+ ): Promise<IdpSession> {
66
+ const session = await readIdpSession(identityName)
67
+ if (!session) throw new IdpSessionMissingError(identityName)
68
+ if (accessTokenForAudience(session, opts.audience)) return session
69
+ if (!session.refresh_token) throw new IdpSessionNoRefreshTokenError(identityName)
70
+
71
+ return withFileLock(idpSessionLockPath(identityName), async () => {
72
+ // Re-read under the lock: a process that held the lock before us may have
73
+ // already rotated the session — using its result is the whole point.
74
+ const current = await readIdpSession(identityName)
75
+ if (!current) throw new IdpSessionMissingError(identityName)
76
+ if (accessTokenForAudience(current, opts.audience)) return current
77
+
78
+ // Org resolution order: explicit > bookmarked-at-create > router lookup.
79
+ const bookmarkOrg =
80
+ opts.organizationId ?? (opts.audience ? await orgIdForAudience(opts.audience) : undefined)
81
+ const organizationId =
82
+ bookmarkOrg ??
83
+ (opts.audience
84
+ ? await (opts.resolveOrganizationId ?? fetchOrgHint)(opts.audience)
85
+ : undefined)
86
+ try {
87
+ return await refreshSession(identityName, current, {
88
+ audience: opts.audience,
89
+ organizationId,
90
+ })
91
+ } catch (e) {
92
+ // A bookmarked org can go stale (instance deleted/recreated elsewhere).
93
+ // On an org rejection, retry once with the router's view and let the
94
+ // bookmark heal on the next `instance create`.
95
+ if (
96
+ bookmarkOrg &&
97
+ !opts.organizationId &&
98
+ opts.audience &&
99
+ classifyRefreshFailure(e) === 'org-rejected'
100
+ ) {
101
+ const routerOrg = await (opts.resolveOrganizationId ?? fetchOrgHint)(opts.audience)
102
+ if (routerOrg && routerOrg !== bookmarkOrg) {
103
+ return await refreshSession(identityName, current, {
104
+ audience: opts.audience,
105
+ organizationId: routerOrg,
106
+ })
107
+ }
108
+ }
109
+ const rescued = await rescueAfterInvalidGrant(identityName, current, opts.audience, e)
110
+ if (rescued) return rescued
111
+ throw e
112
+ }
113
+ })
114
+ }
115
+
116
+ /**
117
+ * Last-resort race recovery: an `invalid_grant` ("refresh token already
118
+ * exchanged") can mean a non-locking process — an older CLI build, or a
119
+ * stale-lock takeover window — won the exchange and saved the rotated
120
+ * session. If the file changed since we read it and now serves the audience,
121
+ * use it instead of declaring the session dead.
122
+ */
123
+ async function rescueAfterInvalidGrant(
124
+ identityName: string,
125
+ seen: IdpSession,
126
+ audience: string | undefined,
127
+ error: unknown,
128
+ ): Promise<IdpSession | undefined> {
129
+ if (!(error instanceof OAuthTokenError) || error.code !== 'invalid_grant') return undefined
130
+ const latest = await readIdpSession(identityName).catch(() => null)
131
+ if (!latest || latest.updatedAt === seen.updatedAt) return undefined
132
+ if (!accessTokenForAudience(latest, audience)) return undefined
133
+ return latest
134
+ }