@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,49 @@
1
+ import type { InstanceEntry, InstanceStore } from './instance'
2
+
3
+ import { formatInstanceLocation, type InstanceInfo } from './admin-instance'
4
+ import { normalizeInstanceKernelUrl, resolveInstanceKey } from './instance'
5
+
6
+ export type InstanceCandidate =
7
+ | { source: 'bookmark'; key: string; url: string; entry: InstanceEntry }
8
+ | { source: 'managed'; key: string; url: string; info: InstanceInfo }
9
+
10
+ /**
11
+ * Gather every instance a bare name can refer to — the local bookmark
12
+ * (collision-guarded, so at most one) plus admin-managed instances matching
13
+ * the slug. A managed instance already bookmarked at the same kernel URL
14
+ * collapses into the bookmark candidate instead of appearing twice.
15
+ */
16
+ export function collectInstanceCandidates(
17
+ name: string,
18
+ store: InstanceStore,
19
+ managed: InstanceInfo[],
20
+ ): InstanceCandidate[] {
21
+ const out: InstanceCandidate[] = []
22
+
23
+ const key = resolveInstanceKey(store, name)
24
+ const entry = key ? store.instances[key] : undefined
25
+ const bookmarkUrl = entry?.url ? normalizeInstanceKernelUrl(entry.url) : null
26
+ if (key && entry && bookmarkUrl) {
27
+ out.push({ source: 'bookmark', key, url: bookmarkUrl, entry })
28
+ }
29
+
30
+ for (const info of managed) {
31
+ if (info.slug !== name && info.id !== name) continue
32
+ // The type says url is set, but a still-provisioning instance can
33
+ // come back without one — not a usable target.
34
+ if (!info.url) continue
35
+ const url = normalizeInstanceKernelUrl(info.url)
36
+ if (bookmarkUrl && url === bookmarkUrl) continue
37
+ out.push({ source: 'managed', key: info.slug, url, info })
38
+ }
39
+
40
+ return out
41
+ }
42
+
43
+ export function describeInstanceCandidate(candidate: InstanceCandidate): string {
44
+ if (candidate.source === 'bookmark') {
45
+ return `${candidate.key} (bookmark) ${candidate.url}`
46
+ }
47
+ const extra = formatInstanceLocation(candidate.info)
48
+ return `${candidate.key} (managed) ${candidate.url}${extra ? ` — ${extra}` : ''}`
49
+ }
@@ -0,0 +1,182 @@
1
+ import type { InstanceInfo } from './admin-instance'
2
+ import type { AstraleConfig } from './config'
3
+
4
+ import { AstraleError } from '../errors'
5
+ import {
6
+ DEFAULT_ADMIN_TARGET_NAME,
7
+ resolveAdminTarget,
8
+ type AdminTargetCommandOpts,
9
+ type ResolvedAdminTarget,
10
+ } from './admin-target'
11
+ import {
12
+ getActive,
13
+ normalizeInstanceKernelUrl,
14
+ resolveInstance,
15
+ resolveInstanceKey,
16
+ type InstanceStore,
17
+ } from './instance'
18
+ import { validateSlug } from './validation'
19
+
20
+ export type InstanceTargetRequest =
21
+ | { source: 'active' }
22
+ | { source: 'name'; name: string }
23
+ | { source: 'admin' }
24
+ | { source: 'url'; url: string; name?: string; issuer?: string }
25
+
26
+ export type ResolvedInstanceTarget = {
27
+ name?: string
28
+ source: 'bookmark' | 'managed' | 'admin' | 'url'
29
+ url: string
30
+ issuer: string
31
+ defaultIdentity?: string
32
+ caFile?: string
33
+ }
34
+
35
+ export type ResolveInstanceTargetOpts = {
36
+ config: AstraleConfig
37
+ instances?: InstanceStore
38
+ admin?: AdminTargetCommandOpts
39
+ managed?: (slug: string) => Promise<InstanceInfo>
40
+ }
41
+
42
+ export async function resolveInstanceTarget(
43
+ request: InstanceTargetRequest,
44
+ opts: ResolveInstanceTargetOpts,
45
+ ): Promise<ResolvedInstanceTarget> {
46
+ switch (request.source) {
47
+ case 'active': {
48
+ const active = opts.instances ? activeFromStore(opts.instances) : await getActive(opts.config)
49
+ return resolveNamedInstanceTarget(active.name, opts)
50
+ }
51
+ case 'name':
52
+ return resolveNamedInstanceTarget(request.name, opts)
53
+ case 'admin':
54
+ return adminTargetToInstance(
55
+ await resolveAdminTarget(opts.admin ?? {}, opts.config, opts.instances),
56
+ )
57
+ case 'url':
58
+ return {
59
+ name: request.name,
60
+ source: 'url',
61
+ url: request.url,
62
+ issuer: request.issuer ?? request.url,
63
+ }
64
+ }
65
+ }
66
+
67
+ async function resolveNamedInstanceTarget(
68
+ identifier: string,
69
+ opts: ResolveInstanceTargetOpts,
70
+ ): Promise<ResolvedInstanceTarget> {
71
+ let notFound: AstraleError
72
+ try {
73
+ return await resolveBookmarkedInstanceTarget(identifier, opts)
74
+ } catch (e) {
75
+ if (!(e instanceof AstraleError) || e.code !== 'INSTANCE_NOT_FOUND') throw e
76
+ notFound = e
77
+ }
78
+
79
+ if (couldBeConfiguredAdminInstance(identifier, opts.config)) {
80
+ const admin = await resolveAdminTarget(opts.admin ?? {}, opts.config, opts.instances)
81
+ const target = adminTargetToInstance(admin)
82
+ if (identifier === target.name) return target
83
+ }
84
+
85
+ try {
86
+ validateSlug(identifier)
87
+ } catch {
88
+ throw notFound
89
+ }
90
+ if (!opts.managed) throw notFound
91
+
92
+ let managed: InstanceInfo
93
+ try {
94
+ managed = await opts.managed(identifier)
95
+ } catch (e) {
96
+ if (!isManagedInstanceNotFound(e)) throw e
97
+ throw notFound
98
+ }
99
+
100
+ const url = normalizeInstanceKernelUrl(managed.url)
101
+ return {
102
+ name: managed.slug,
103
+ source: 'managed',
104
+ url,
105
+ issuer: url,
106
+ }
107
+ }
108
+
109
+ async function resolveBookmarkedInstanceTarget(
110
+ identifier: string,
111
+ opts: ResolveInstanceTargetOpts,
112
+ ): Promise<ResolvedInstanceTarget> {
113
+ if (opts.instances) {
114
+ const key = resolveInstanceKey(opts.instances, identifier)
115
+ const entry = key ? opts.instances.instances[key] : undefined
116
+ if (!key || !entry?.url) throw instanceNotFound(identifier)
117
+ const url = normalizeInstanceKernelUrl(entry.url)
118
+ return {
119
+ name: key,
120
+ source: 'bookmark',
121
+ url,
122
+ issuer: entry.issuer ? normalizeInstanceKernelUrl(entry.issuer) : url,
123
+ defaultIdentity: entry.defaultIdentity,
124
+ caFile: entry.caFile,
125
+ }
126
+ }
127
+
128
+ const resolved = await resolveInstance(identifier, opts.config)
129
+ return {
130
+ name: resolved.name,
131
+ source: 'bookmark',
132
+ url: resolved.url,
133
+ issuer: resolved.issuer ?? resolved.url,
134
+ defaultIdentity: resolved.defaultIdentity,
135
+ caFile: resolved.caFile,
136
+ }
137
+ }
138
+
139
+ function activeFromStore(store: InstanceStore): { name: string } {
140
+ if (!store.active) {
141
+ throw new Error('No active instance. Run: astrale instance bookmark <name> --url <url> --use')
142
+ }
143
+ return { name: store.active }
144
+ }
145
+
146
+ function instanceNotFound(identifier: string): AstraleError {
147
+ return new AstraleError(
148
+ 'INSTANCE_NOT_FOUND',
149
+ `Instance "${identifier}" is not bookmarked.\n` +
150
+ ` Bookmark: astrale instance bookmark ${identifier} --url <url>\n` +
151
+ ` Or pass --url <kernel-url> directly.`,
152
+ )
153
+ }
154
+
155
+ export function couldBeConfiguredAdminInstance(identifier: string, config: AstraleConfig): boolean {
156
+ const admin = config.admin
157
+ if (admin.instance) return identifier === admin.instance
158
+ return identifier === (admin.name ?? DEFAULT_ADMIN_TARGET_NAME)
159
+ }
160
+
161
+ export function adminTargetToInstance(target: ResolvedAdminTarget): ResolvedInstanceTarget {
162
+ return {
163
+ name: target.registrationSlug,
164
+ source: 'admin',
165
+ url: target.url,
166
+ issuer: target.issuer,
167
+ defaultIdentity: target.defaultIdentity,
168
+ ...(target.caFile ? { caFile: target.caFile } : {}),
169
+ }
170
+ }
171
+
172
+ export function isManagedInstanceNotFound(error: unknown): boolean {
173
+ if (!(error instanceof Error)) return false
174
+ if (error.name === 'NotFoundError') return true
175
+ const data = (error as Error & { data?: unknown }).data
176
+ return (
177
+ error.name === 'KernelError' &&
178
+ !!data &&
179
+ typeof data === 'object' &&
180
+ (data as { type?: unknown }).type === 'NotFoundError'
181
+ )
182
+ }
@@ -0,0 +1,395 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import { z } from 'zod'
4
+
5
+ import type { AstraleConfig } from './config'
6
+
7
+ import { AstraleError, IdentifierCollisionError, ReservedSlugError } from '../errors'
8
+ import { log } from './log'
9
+ import { INSTANCES_PATH } from './paths'
10
+ import {
11
+ RESERVED_SLUGS,
12
+ RegistryModeSchema,
13
+ validateName,
14
+ validateUrl,
15
+ type RegistryMode,
16
+ } from './validation'
17
+
18
+ export const InstanceKindSchema = z.enum(['bookmark'])
19
+ export type InstanceKind = z.infer<typeof InstanceKindSchema>
20
+
21
+ export const InstanceEntrySchema = z.object({
22
+ url: z.string().optional(),
23
+ issuer: z.string().optional(),
24
+ createdAt: z.string().optional(),
25
+ slug: z.string().optional(),
26
+ name: z.string().optional(),
27
+ kind: InstanceKindSchema.optional(),
28
+ mode: RegistryModeSchema.optional(),
29
+ defaultIdentity: z.string().optional(),
30
+ caFile: z.string().optional(),
31
+ // WorkOS org captured from `instance create` — authoritative for token
32
+ // scoping (the router's /auth/org lookup is eventually consistent).
33
+ organizationId: z.string().optional(),
34
+ })
35
+
36
+ export const InstanceStoreSchema = z.object({
37
+ active: z.string(),
38
+ instances: z.record(z.string(), InstanceEntrySchema),
39
+ })
40
+
41
+ export type InstanceEntry = z.infer<typeof InstanceEntrySchema>
42
+ export type InstanceStore = z.infer<typeof InstanceStoreSchema>
43
+
44
+ export type AddInstanceOpts = {
45
+ url?: string
46
+ slug?: string
47
+ name?: string
48
+ kind?: InstanceKind
49
+ mode?: RegistryMode
50
+ issuer?: string
51
+ defaultIdentity?: string
52
+ caFile?: string
53
+ organizationId?: string
54
+ }
55
+
56
+ export type ResolvedInstance = {
57
+ name: string
58
+ kind: InstanceKind | 'managed'
59
+ url: string
60
+ issuer?: string
61
+ createdAt?: string
62
+ defaultIdentity?: string
63
+ caFile?: string
64
+ mode?: RegistryMode
65
+ status?: string
66
+ }
67
+
68
+ function seed(): InstanceStore {
69
+ return { active: '', instances: {} }
70
+ }
71
+
72
+ export function sanitizeStore(store: InstanceStore): { store: InstanceStore; changed: boolean } {
73
+ let changed = false
74
+ const instances: Record<string, InstanceEntry> = {}
75
+
76
+ for (const [key, entry] of Object.entries(store.instances)) {
77
+ if (key === 'manager') {
78
+ changed = true
79
+ continue
80
+ }
81
+ if (!entry.url) {
82
+ changed = true
83
+ continue
84
+ }
85
+ const normalizedUrl = normalizeInstanceKernelUrl(entry.url)
86
+ const normalizedIssuer = entry.issuer ? normalizeInstanceKernelUrl(entry.issuer) : entry.issuer
87
+ const next: InstanceEntry = {
88
+ ...entry,
89
+ url: normalizedUrl,
90
+ issuer: normalizedIssuer,
91
+ kind: 'bookmark',
92
+ }
93
+ // VALUE comparison only. The old `next !== entry` (object identity) was
94
+ // ALWAYS true, so every read rewrote instances.json fire-and-forget —
95
+ // concurrent astrale processes clobbered each other's `active` from
96
+ // stale snapshots (observed: `instance create` setting the active
97
+ // target, then a parallel command silently reverting it — every call
98
+ // after that wrote to the WRONG instance).
99
+ if (
100
+ entry.kind !== 'bookmark' ||
101
+ normalizedUrl !== entry.url ||
102
+ normalizedIssuer !== entry.issuer
103
+ ) {
104
+ changed = true
105
+ }
106
+ instances[key] = next
107
+ }
108
+
109
+ const active = store.active || Object.keys(instances)[0] || ''
110
+ if (active !== store.active) changed = true
111
+ return { store: { active, instances }, changed }
112
+ }
113
+
114
+ let instancesMemo: InstanceStore | null = null
115
+
116
+ export async function readInstances(
117
+ _config?: AstraleConfig,
118
+ opts: { persist?: boolean } = {},
119
+ ): Promise<InstanceStore> {
120
+ if (instancesMemo) return instancesMemo
121
+ let raw: string
122
+ try {
123
+ raw = await readFile(INSTANCES_PATH, 'utf-8')
124
+ } catch {
125
+ instancesMemo = seed()
126
+ return instancesMemo
127
+ }
128
+
129
+ let parsed: InstanceStore
130
+ try {
131
+ parsed = InstanceStoreSchema.parse(JSON.parse(raw))
132
+ } catch (e) {
133
+ const reason = e instanceof z.ZodError ? 'invalid schema' : 'corrupt JSON'
134
+ log.warn(`Could not read instances at ${INSTANCES_PATH} (${reason}) — using empty registry`)
135
+ instancesMemo = seed()
136
+ return instancesMemo
137
+ }
138
+
139
+ const { store, changed } = sanitizeStore(parsed)
140
+ instancesMemo = store
141
+ // Self-heal a stale/unnormalized store on read — but never from a read-only
142
+ // caller (`opts.persist: false`, e.g. status / `setup --plan`). Any mutating
143
+ // path reads with the default and rewrites, so healing still happens there.
144
+ if (changed && opts.persist !== false) writeInstances(store).catch(() => undefined)
145
+ return store
146
+ }
147
+
148
+ export async function writeInstances(store: InstanceStore): Promise<void> {
149
+ await mkdir(dirname(INSTANCES_PATH), { recursive: true })
150
+ await writeFile(INSTANCES_PATH, JSON.stringify(store, null, 2) + '\n')
151
+ instancesMemo = store
152
+ }
153
+
154
+ function collectIdentifiers(store: InstanceStore, skipKey?: string): Map<string, string> {
155
+ const out = new Map<string, string>()
156
+ for (const [key, entry] of Object.entries(store.instances)) {
157
+ if (key === skipKey) continue
158
+ out.set(key, `instance key "${key}"`)
159
+ if (entry.slug && !out.has(entry.slug)) out.set(entry.slug, `slug of instance "${key}"`)
160
+ if (entry.name && !out.has(entry.name)) out.set(entry.name, `name of instance "${key}"`)
161
+ }
162
+ return out
163
+ }
164
+
165
+ export function assertNoCollision(
166
+ store: InstanceStore,
167
+ identifiers: string[],
168
+ ignoreKey?: string,
169
+ ): void {
170
+ // Skip the ignored entry at collection time (never delete by identifier
171
+ // string: another entry may own the same slug/name alias).
172
+ const existing = collectIdentifiers(store, ignoreKey)
173
+ for (const id of identifiers) {
174
+ if (!id) continue
175
+ const owner = existing.get(id)
176
+ if (owner) throw new IdentifierCollisionError(id, owner)
177
+ }
178
+ }
179
+
180
+ export function resolveInstanceKey(store: InstanceStore, identifier: string): string | null {
181
+ if (store.instances[identifier]) return identifier
182
+ for (const [key, entry] of Object.entries(store.instances)) {
183
+ if (entry.slug === identifier || entry.name === identifier) return key
184
+ }
185
+ return null
186
+ }
187
+
188
+ export async function addInstance(key: string, opts: AddInstanceOpts = {}): Promise<InstanceEntry> {
189
+ validateName(key, 'Instance')
190
+ if (RESERVED_SLUGS.has(key)) throw new ReservedSlugError(key)
191
+ if (!opts.url) throw new Error('Instance bookmarks require --url <url>')
192
+ const normalizedUrl = normalizeInstanceKernelUrl(opts.url)
193
+ validateUrl(normalizedUrl)
194
+
195
+ const store = await readInstances()
196
+ if (store.instances[key]) {
197
+ throw new Error(
198
+ `Instance "${key}" already exists. Remove it first with: astrale instance forget ${key}`,
199
+ )
200
+ }
201
+ assertNoCollision(store, [key, opts.slug, opts.name].filter(Boolean) as string[])
202
+
203
+ const entry: InstanceEntry = {
204
+ ...opts,
205
+ url: normalizedUrl,
206
+ issuer: opts.issuer ? normalizeInstanceKernelUrl(opts.issuer) : opts.issuer,
207
+ kind: 'bookmark',
208
+ createdAt: new Date().toISOString(),
209
+ }
210
+ store.instances[key] = entry
211
+ if (!store.active) store.active = key
212
+ await writeInstances(store)
213
+ return entry
214
+ }
215
+
216
+ export async function upsertInstance(
217
+ key: string,
218
+ opts: AddInstanceOpts = {},
219
+ ): Promise<{ entry: InstanceEntry; created: boolean }> {
220
+ validateName(key, 'Instance')
221
+ if (RESERVED_SLUGS.has(key)) throw new ReservedSlugError(key)
222
+ if (!opts.url) throw new Error('Instance bookmarks require --url <url>')
223
+ const normalizedUrl = normalizeInstanceKernelUrl(opts.url)
224
+ validateUrl(normalizedUrl)
225
+
226
+ const store = await readInstances()
227
+ assertNoCollision(store, [key, opts.slug, opts.name].filter(Boolean) as string[], key)
228
+
229
+ const existing = store.instances[key]
230
+ const normalizedIssuer = opts.issuer ? normalizeInstanceKernelUrl(opts.issuer) : undefined
231
+ const entry: InstanceEntry = {
232
+ ...existing,
233
+ ...definedEntry(opts),
234
+ url: normalizedUrl,
235
+ ...(normalizedIssuer ? { issuer: normalizedIssuer } : {}),
236
+ kind: 'bookmark',
237
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
238
+ }
239
+ store.instances[key] = entry
240
+ if (!store.active) store.active = key
241
+ await writeInstances(store)
242
+ return { entry, created: !existing }
243
+ }
244
+
245
+ /**
246
+ * Bookmark an admin-managed instance under `key` (url = issuer = the
247
+ * normalized kernel URL). Reports the previous URL when an existing
248
+ * bookmark was repointed to a different kernel so callers can warn.
249
+ */
250
+ export async function upsertManagedBookmark(
251
+ key: string,
252
+ slug: string,
253
+ rawUrl: string,
254
+ organizationId?: string,
255
+ ): Promise<{ entry: InstanceEntry; repointedFrom?: string }> {
256
+ const store = await readInstances()
257
+ const previousUrl = store.instances[key]?.url
258
+ const url = normalizeInstanceKernelUrl(rawUrl)
259
+ const { entry } = await upsertInstance(key, {
260
+ url,
261
+ issuer: url,
262
+ slug,
263
+ name: slug,
264
+ kind: 'bookmark',
265
+ mode: 'remote',
266
+ ...(organizationId ? { organizationId } : {}),
267
+ })
268
+ return {
269
+ entry,
270
+ ...(previousUrl && previousUrl !== entry.url ? { repointedFrom: previousUrl } : {}),
271
+ }
272
+ }
273
+
274
+ export async function removeInstance(key: string): Promise<void> {
275
+ const store = await readInstances()
276
+ if (!store.instances[key]) throw new Error(`Instance "${key}" not found`)
277
+ delete store.instances[key]
278
+ if (store.active === key) store.active = Object.keys(store.instances)[0] ?? ''
279
+ await writeInstances(store)
280
+ }
281
+
282
+ export async function setActive(identifier: string): Promise<string> {
283
+ const store = await readInstances()
284
+ const key = resolveInstanceKey(store, identifier)
285
+ if (!key) {
286
+ throw new Error(
287
+ `Instance "${identifier}" is not bookmarked. Run: astrale instance bookmark ${identifier} --url <url>`,
288
+ )
289
+ }
290
+ store.active = key
291
+ await writeInstances(store)
292
+ return key
293
+ }
294
+
295
+ export async function clearActive(identifier: string): Promise<void> {
296
+ const store = await readInstances()
297
+ if (store.active !== identifier) return
298
+ store.active = Object.keys(store.instances)[0] ?? ''
299
+ await writeInstances(store)
300
+ }
301
+
302
+ export async function getActive(config?: AstraleConfig): Promise<InstanceEntry & { name: string }> {
303
+ const store = await readInstances(config)
304
+ if (!store.active) {
305
+ throw new Error('No active instance. Run: astrale instance bookmark <name> --url <url> --use')
306
+ }
307
+ const entry = store.instances[store.active]
308
+ return { ...entry, name: store.active }
309
+ }
310
+
311
+ export async function resolveInstance(
312
+ identifier: string,
313
+ config?: AstraleConfig,
314
+ ): Promise<ResolvedInstance> {
315
+ const store = await readInstances(config)
316
+ const key = resolveInstanceKey(store, identifier)
317
+ const entry = key ? store.instances[key] : undefined
318
+ if (!key || !entry?.url) {
319
+ throw new AstraleError(
320
+ 'INSTANCE_NOT_FOUND',
321
+ `Instance "${identifier}" is not bookmarked.\n` +
322
+ ` Bookmark: astrale instance bookmark ${identifier} --url <url>\n` +
323
+ ` Or pass --url <kernel-url> directly.`,
324
+ )
325
+ }
326
+ return {
327
+ name: key,
328
+ kind: 'bookmark',
329
+ url: normalizeInstanceKernelUrl(entry.url),
330
+ issuer: entry.issuer ? normalizeInstanceKernelUrl(entry.issuer) : entry.issuer,
331
+ createdAt: entry.createdAt,
332
+ defaultIdentity: entry.defaultIdentity,
333
+ caFile: entry.caFile,
334
+ mode: entry.mode,
335
+ }
336
+ }
337
+
338
+ export async function resolveKernelUrl(
339
+ opts: { url?: string; instance?: string },
340
+ config: AstraleConfig,
341
+ ): Promise<string> {
342
+ if (opts.url) return opts.url
343
+ const identifier = opts.instance ?? (await getActive(config)).name
344
+ return (await resolveInstance(identifier, config)).url
345
+ }
346
+
347
+ /**
348
+ * The bookmarked org id for the instance matching `audience`'s origin —
349
+ * consulted before the router's eventually-consistent `/auth/org`.
350
+ */
351
+ export async function orgIdForAudience(audience: string): Promise<string | undefined> {
352
+ let origin: string
353
+ try {
354
+ origin = new URL(audience).origin
355
+ } catch {
356
+ return undefined
357
+ }
358
+ const store = await readInstances()
359
+ for (const entry of Object.values(store.instances)) {
360
+ if (!entry.organizationId || !entry.url) continue
361
+ try {
362
+ if (new URL(entry.url).origin === origin) return entry.organizationId
363
+ } catch {
364
+ // unparsable bookmark URL — skip
365
+ }
366
+ }
367
+ return undefined
368
+ }
369
+
370
+ export function normalizeInstanceKernelUrl(url: string): string {
371
+ let parsed: URL
372
+ try {
373
+ parsed = new URL(url)
374
+ } catch {
375
+ return url
376
+ }
377
+ if (!isRegionRoutedInstanceRoot(parsed)) return url.replace(/\/$/, '')
378
+
379
+ parsed.pathname = '/api'
380
+ return parsed.toString().replace(/\/$/, '')
381
+ }
382
+
383
+ function isRegionRoutedInstanceRoot(url: URL): boolean {
384
+ if (url.pathname !== '' && url.pathname !== '/') return false
385
+ if (url.search || url.hash) return false
386
+
387
+ const labels = url.hostname.toLowerCase().split('.')
388
+ return labels.length === 4 && labels[2] === 'astrale' && labels[3] === 'ai'
389
+ }
390
+
391
+ function definedEntry(entry: AddInstanceOpts): AddInstanceOpts {
392
+ return Object.fromEntries(
393
+ Object.entries(entry).filter(([, value]) => value !== undefined),
394
+ ) as AddInstanceOpts
395
+ }