@astrale-os/cli 0.4.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.check-workspace.cjs +40 -0
- package/README.md +151 -0
- package/dist/astrale.js +59749 -0
- package/package.json +90 -0
- package/src/command.ts +40 -0
- package/src/commands/__tests__/admin-instance.test.ts +73 -0
- package/src/commands/__tests__/auth-login.test.ts +178 -0
- package/src/commands/__tests__/auth-token.test.ts +223 -0
- package/src/commands/__tests__/call.test.ts +72 -0
- package/src/commands/__tests__/domain-list.test.ts +74 -0
- package/src/commands/__tests__/help-contract.test.ts +136 -0
- package/src/commands/__tests__/install-identity-override.test.ts +65 -0
- package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
- package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
- package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
- package/src/commands/__tests__/logs.test.ts +117 -0
- package/src/commands/__tests__/ls.test.ts +25 -0
- package/src/commands/__tests__/setup-plan.test.ts +61 -0
- package/src/commands/admin/status.ts +61 -0
- package/src/commands/admin/use.ts +77 -0
- package/src/commands/auth/login.ts +82 -0
- package/src/commands/auth/logout.ts +50 -0
- package/src/commands/auth/status.ts +86 -0
- package/src/commands/auth/token.ts +162 -0
- package/src/commands/browser.ts +207 -0
- package/src/commands/call.ts +300 -0
- package/src/commands/describe.ts +182 -0
- package/src/commands/domain/install.ts +420 -0
- package/src/commands/domain/list.ts +154 -0
- package/src/commands/domain/publish.ts +155 -0
- package/src/commands/get.ts +60 -0
- package/src/commands/identity/create.ts +26 -0
- package/src/commands/identity/delete.ts +18 -0
- package/src/commands/identity/export.ts +66 -0
- package/src/commands/identity/import.ts +101 -0
- package/src/commands/identity/list.ts +56 -0
- package/src/commands/identity/register.ts +170 -0
- package/src/commands/identity/sync.ts +32 -0
- package/src/commands/identity/unsync.ts +24 -0
- package/src/commands/identity/use.ts +18 -0
- package/src/commands/identity/whoami.ts +34 -0
- package/src/commands/idp/add.ts +150 -0
- package/src/commands/idp/list.ts +57 -0
- package/src/commands/idp/refresh.ts +38 -0
- package/src/commands/idp/remove.ts +36 -0
- package/src/commands/idp/show.ts +29 -0
- package/src/commands/instance/active.ts +64 -0
- package/src/commands/instance/bookmark.ts +72 -0
- package/src/commands/instance/create.ts +69 -0
- package/src/commands/instance/delete.ts +72 -0
- package/src/commands/instance/forget.ts +26 -0
- package/src/commands/instance/list.ts +149 -0
- package/src/commands/instance/status.ts +42 -0
- package/src/commands/instance/use.ts +210 -0
- package/src/commands/logs.ts +347 -0
- package/src/commands/ls.ts +229 -0
- package/src/commands/query.ts +32 -0
- package/src/commands/setup.ts +54 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/studio.ts +401 -0
- package/src/commands/token.ts +77 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +87 -0
- package/src/errors.ts +65 -0
- package/src/kernel/__tests__/auth.test.ts +77 -0
- package/src/kernel/__tests__/errors.test.ts +43 -0
- package/src/kernel/__tests__/remote-routing.test.ts +70 -0
- package/src/kernel/auth.ts +234 -0
- package/src/kernel/ca-fetch.ts +119 -0
- package/src/kernel/client.ts +191 -0
- package/src/kernel/errors.ts +280 -0
- package/src/kernel/expand.ts +217 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/options.ts +22 -0
- package/src/kernel/remote-routing.ts +88 -0
- package/src/kernel/run.ts +63 -0
- package/src/kernel/types.ts +14 -0
- package/src/lib/__tests__/admin-target.test.ts +112 -0
- package/src/lib/__tests__/binary.test.ts +56 -0
- package/src/lib/__tests__/command-dx.test.ts +58 -0
- package/src/lib/__tests__/concurrency.test.ts +62 -0
- package/src/lib/__tests__/config.test.ts +53 -0
- package/src/lib/__tests__/design.test.ts +99 -0
- package/src/lib/__tests__/domain-identity.test.ts +60 -0
- package/src/lib/__tests__/format.test.ts +22 -0
- package/src/lib/__tests__/fs-atomic.test.ts +104 -0
- package/src/lib/__tests__/identity.test.ts +79 -0
- package/src/lib/__tests__/idp-session.driver.ts +53 -0
- package/src/lib/__tests__/idp-session.test.ts +357 -0
- package/src/lib/__tests__/idp.test.ts +385 -0
- package/src/lib/__tests__/instance-candidates.test.ts +73 -0
- package/src/lib/__tests__/instance-target.test.ts +183 -0
- package/src/lib/__tests__/instance.test.ts +136 -0
- package/src/lib/__tests__/keys.test.ts +129 -0
- package/src/lib/__tests__/local-status.test.ts +202 -0
- package/src/lib/__tests__/output.test.ts +150 -0
- package/src/lib/__tests__/panel.test.ts +40 -0
- package/src/lib/__tests__/port.test.ts +44 -0
- package/src/lib/__tests__/prompt.test.ts +25 -0
- package/src/lib/__tests__/sdk-deps.test.ts +68 -0
- package/src/lib/__tests__/self.test.ts +272 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
- package/src/lib/__tests__/table.test.ts +53 -0
- package/src/lib/__tests__/update.test.ts +246 -0
- package/src/lib/__tests__/use-target.test.ts +56 -0
- package/src/lib/__tests__/validation.test.ts +34 -0
- package/src/lib/admin-domain.ts +25 -0
- package/src/lib/admin-instance.ts +26 -0
- package/src/lib/admin-target.ts +217 -0
- package/src/lib/binary.ts +131 -0
- package/src/lib/browser.ts +150 -0
- package/src/lib/command-dx.ts +161 -0
- package/src/lib/concurrency.ts +31 -0
- package/src/lib/config.ts +45 -0
- package/src/lib/domain-identity.ts +49 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/format.ts +4 -0
- package/src/lib/fs-atomic.ts +126 -0
- package/src/lib/identity.ts +256 -0
- package/src/lib/idp-session.ts +134 -0
- package/src/lib/idp.ts +876 -0
- package/src/lib/instance-candidates.ts +49 -0
- package/src/lib/instance-target.ts +182 -0
- package/src/lib/instance.ts +395 -0
- package/src/lib/keys.ts +294 -0
- package/src/lib/local-status.ts +152 -0
- package/src/lib/log.ts +116 -0
- package/src/lib/login-flow.ts +164 -0
- package/src/lib/meta.ts +86 -0
- package/src/lib/output.ts +222 -0
- package/src/lib/panel.ts +61 -0
- package/src/lib/paths.ts +11 -0
- package/src/lib/port.ts +41 -0
- package/src/lib/proc.ts +82 -0
- package/src/lib/prompt.ts +136 -0
- package/src/lib/provision-instance.ts +170 -0
- package/src/lib/sdk-deps.ts +104 -0
- package/src/lib/self.ts +166 -0
- package/src/lib/skills.ts +171 -0
- package/src/lib/table.ts +62 -0
- package/src/lib/update.ts +315 -0
- package/src/lib/use-target.ts +24 -0
- package/src/lib/validation.ts +59 -0
- package/src/program.ts +200 -0
- package/src/registry.ts +59 -0
- package/src/setup/__tests__/util.test.ts +29 -0
- package/src/setup/engine.ts +83 -0
- package/src/setup/render.ts +109 -0
- package/src/setup/steps/admin.ts +78 -0
- package/src/setup/steps/agent-browser.ts +81 -0
- package/src/setup/steps/auth.ts +54 -0
- package/src/setup/steps/domain.ts +68 -0
- package/src/setup/steps/index.ts +18 -0
- package/src/setup/steps/instance.ts +119 -0
- package/src/setup/steps/skills-bridge.ts +70 -0
- package/src/setup/steps/skills.ts +59 -0
- package/src/setup/types.ts +61 -0
- package/src/setup/util.ts +34 -0
- package/src/test-utils.ts +18 -0
- package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
- package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
- package/studio/client/dist/index.html +13 -0
- package/studio/package.json +62 -0
- package/studio/server/agent/ask.ts +68 -0
- package/studio/server/agent/bridge-mcp.ts +182 -0
- package/studio/server/agent/bridge.ts +188 -0
- package/studio/server/agent/claude.ts +666 -0
- package/studio/server/agent/mock.ts +186 -0
- package/studio/server/agent/prompt.ts +202 -0
- package/studio/server/agent/registry.ts +29 -0
- package/studio/server/agent/runner.ts +484 -0
- package/studio/server/agent/schema-map.ts +112 -0
- package/studio/server/agent/types.ts +120 -0
- package/studio/server/api.ts +574 -0
- package/studio/server/cache.ts +138 -0
- package/studio/server/detect.ts +81 -0
- package/studio/server/domain.ts +70 -0
- package/studio/server/index.ts +136 -0
- package/studio/server/introspect/anatomy-extras.ts +398 -0
- package/studio/server/introspect/anatomy.ts +108 -0
- package/studio/server/introspect/bundle.ts +57 -0
- package/studio/server/introspect/core-extractor.ts +119 -0
- package/studio/server/introspect/core.ts +44 -0
- package/studio/server/introspect/diff.ts +133 -0
- package/studio/server/introspect/extractor.ts +102 -0
- package/studio/server/introspect/hash.ts +21 -0
- package/studio/server/introspect/overlay-tsmorph.ts +874 -0
- package/studio/server/introspect/overlay.ts +57 -0
- package/studio/server/introspect/runtime.ts +99 -0
- package/studio/server/introspect/schema-refs.ts +46 -0
- package/studio/server/lifecycle.ts +38 -0
- package/studio/server/sse.ts +57 -0
- package/studio/server/state/baseline.ts +211 -0
- package/studio/server/state/catalog.ts +117 -0
- package/studio/server/state/comments.ts +321 -0
- package/studio/server/state/context.ts +167 -0
- package/studio/server/state/copy.ts +156 -0
- package/studio/server/state/create.ts +156 -0
- package/studio/server/state/documents.ts +70 -0
- package/studio/server/state/env.ts +161 -0
- package/studio/server/state/git.ts +75 -0
- package/studio/server/state/handoff.ts +55 -0
- package/studio/server/state/harness-gateway.ts +181 -0
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/instance.ts +244 -0
- package/studio/server/state/integrations.ts +55 -0
- package/studio/server/state/layout.ts +55 -0
- package/studio/server/state/settings.ts +39 -0
- package/studio/server/state/store.ts +97 -0
- package/studio/server/state/updates.ts +63 -0
- package/studio/server/state/usage.ts +37 -0
- package/studio/server/state/views.ts +138 -0
- package/studio/server/state/visibility.ts +33 -0
- package/studio/server/watch.ts +81 -0
- package/studio/server/workspace-state.ts +26 -0
- package/studio/server/workspace-watch.ts +101 -0
- package/studio/shared/types.ts +873 -0
- package/studio/tsconfig.json +23 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,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
|
+
}
|