@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,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
|
+
}
|