orizu 0.5.49 → 0.5.51
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/dist/account-directory.d.ts +2 -2
- package/dist/account-directory.d.ts.map +1 -1
- package/dist/account-directory.js +7 -32
- package/dist/auth-commands.d.ts.map +1 -1
- package/dist/auth-commands.js +32 -36
- package/dist/credentials-transaction.d.ts +7 -0
- package/dist/credentials-transaction.d.ts.map +1 -0
- package/dist/credentials-transaction.js +60 -0
- package/dist/credentials.d.ts +24 -1
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +202 -76
- package/dist/dataset-transfer-upload.d.ts.map +1 -1
- package/dist/dataset-transfer-upload.js +6 -1
- package/dist/directory-list-read.d.ts +37 -0
- package/dist/directory-list-read.d.ts.map +1 -0
- package/dist/directory-list-read.js +87 -0
- package/dist/hosted-boot.d.ts.map +1 -1
- package/dist/hosted-boot.js +59 -41
- package/dist/hosted-harness-opencode.d.ts +31 -0
- package/dist/hosted-harness-opencode.d.ts.map +1 -1
- package/dist/hosted-harness-opencode.js +45 -7
- package/dist/hosted-loop.d.ts +39 -2
- package/dist/hosted-loop.d.ts.map +1 -1
- package/dist/hosted-loop.js +127 -18
- package/dist/hosted-session-seed.d.ts +2 -0
- package/dist/hosted-session-seed.d.ts.map +1 -1
- package/dist/hosted-session-seed.js +27 -10
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +39 -234
- package/dist/keyboard-select.d.ts.map +1 -1
- package/dist/keyboard-select.js +23 -45
- package/dist/project-create-workflow.d.ts +18 -0
- package/dist/project-create-workflow.d.ts.map +1 -0
- package/dist/project-create-workflow.js +121 -0
- package/dist/self-update-command.d.ts +8 -0
- package/dist/self-update-command.d.ts.map +1 -0
- package/dist/self-update-command.js +390 -0
- package/dist/self-update.d.ts +29 -16
- package/dist/self-update.d.ts.map +1 -1
- package/dist/self-update.js +55 -244
- package/dist/setup-account.d.ts.map +1 -1
- package/dist/setup-account.js +4 -1
- package/dist/setup-and-skills-commands.d.ts.map +1 -1
- package/dist/setup-and-skills-commands.js +88 -102
- package/dist/skill-install-workflow.d.ts +23 -0
- package/dist/skill-install-workflow.d.ts.map +1 -0
- package/dist/skill-install-workflow.js +184 -0
- package/dist/skill-installer.d.ts +8 -3
- package/dist/skill-installer.d.ts.map +1 -1
- package/dist/skill-installer.js +26 -33
- package/dist/stored-session-requests.d.ts +27 -0
- package/dist/stored-session-requests.d.ts.map +1 -0
- package/dist/stored-session-requests.js +268 -0
- package/dist/terminal-key-session.d.ts +23 -0
- package/dist/terminal-key-session.d.ts.map +1 -0
- package/dist/terminal-key-session.js +74 -0
- package/package.json +1 -1
- package/src/account-directory.ts +8 -42
- package/src/auth-commands.ts +31 -35
- package/src/credentials-transaction.ts +73 -0
- package/src/credentials.ts +211 -89
- package/src/dataset-transfer-upload.ts +7 -1
- package/src/directory-list-read.ts +123 -0
- package/src/hosted-boot.ts +65 -43
- package/src/hosted-harness-opencode.ts +65 -9
- package/src/hosted-loop.ts +152 -18
- package/src/hosted-session-seed.ts +25 -8
- package/src/http.ts +39 -317
- package/src/keyboard-select.ts +18 -55
- package/src/project-create-workflow.ts +139 -0
- package/src/self-update-command.ts +419 -0
- package/src/self-update.ts +70 -266
- package/src/setup-account.ts +4 -1
- package/src/setup-and-skills-commands.ts +89 -121
- package/src/skill-install-workflow.ts +215 -0
- package/src/skill-installer.ts +31 -38
- package/src/stored-session-requests.ts +319 -0
- package/src/terminal-key-session.ts +88 -0
package/src/auth-commands.ts
CHANGED
|
@@ -11,20 +11,18 @@ import {
|
|
|
11
11
|
resolveLogoutTarget,
|
|
12
12
|
assertSecureTokenTransport,
|
|
13
13
|
authedFetch,
|
|
14
|
-
credentialRequestRedirectPolicy,
|
|
15
14
|
} from './http.js'
|
|
16
15
|
import {
|
|
16
|
+
clearUnreadableServerCredentials,
|
|
17
17
|
hasResolvableAuth,
|
|
18
18
|
rememberProcessSecret,
|
|
19
19
|
saveServerCredentials,
|
|
20
20
|
getServerCredentials,
|
|
21
|
-
clearServerCredentials,
|
|
22
21
|
resolveAuthTokenForBaseUrl,
|
|
23
22
|
} from './credentials.js'
|
|
24
23
|
import { printJson, printLine, printLoginProgress } from './cli-console-output.js'
|
|
25
24
|
import { shouldUseHeadlessLogin, waitForHeadlessAuthorization } from './headless-login.js'
|
|
26
25
|
import { renderCliAuthBrowserPage } from './cli-auth-browser-page.js'
|
|
27
|
-
import { describeLogoutHttpFailure, describeLogoutTransportFailure } from './logout-diagnostic.js'
|
|
28
26
|
import { resolveProjectSelection } from './account-directory.js'
|
|
29
27
|
|
|
30
28
|
import type { LoginResponse } from './types.js'
|
|
@@ -144,6 +142,10 @@ interface AuthenticatedLoginResponse extends LoginResponse {
|
|
|
144
142
|
apiKey: string
|
|
145
143
|
}
|
|
146
144
|
|
|
145
|
+
function isCredentialsFileFailure(message: string): boolean {
|
|
146
|
+
return message.startsWith('ORIZU_CREDENTIALS_READ_FAILED:') || message.startsWith('ORIZU_CREDENTIALS_WRITE_FAILED:')
|
|
147
|
+
}
|
|
148
|
+
|
|
147
149
|
async function login(baseUrlOverride?: string) {
|
|
148
150
|
const target = baseUrlOverride !== undefined
|
|
149
151
|
? { baseUrl: baseUrlOverride, isDefault: false }
|
|
@@ -155,13 +157,14 @@ async function login(baseUrlOverride?: string) {
|
|
|
155
157
|
try {
|
|
156
158
|
loginData = await authenticateAtBaseUrl(baseUrl)
|
|
157
159
|
if (!loginData) return
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
})
|
|
160
|
+
const apiKey = loginData.apiKey
|
|
161
|
+
// Waits for the lock without freezing the CLI (ORI-2421).
|
|
162
|
+
const { withCredentialsTransaction } = await import('./credentials-transaction.js')
|
|
163
|
+
await withCredentialsTransaction(async () => saveServerCredentials(baseUrl, { credentialType: 'pat', apiKey }))
|
|
162
164
|
} catch (error) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
166
|
+
// Reading or saving credentials.json is a disk problem, not a connection one.
|
|
167
|
+
if (target.isDefault && !isCredentialsFileFailure(detail)) {
|
|
165
168
|
throw new Error(
|
|
166
169
|
`${detail} Check connectivity to https://app.orizu.ai, then retry \`orizu login\`.`,
|
|
167
170
|
{ cause: error }
|
|
@@ -312,6 +315,15 @@ async function logout(baseUrlOverride?: string) {
|
|
|
312
315
|
const { baseUrl, credentialBaseUrl } = resolveLogoutTarget(baseUrlOverride)
|
|
313
316
|
const credentials = getServerCredentials(credentialBaseUrl)
|
|
314
317
|
if (!credentials) {
|
|
318
|
+
// A saved sign-in this CLI cannot read is removed: it may be a pasted token (ORI-2675).
|
|
319
|
+
if (clearUnreadableServerCredentials(credentialBaseUrl)) {
|
|
320
|
+
if (hasJsonFlag()) {
|
|
321
|
+
printJson({ status: 'removed-unreadable', server: baseUrl })
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
printLine(`Removed the saved sign-in for ${sanitizeTerminalText(baseUrl)}, which this CLI could not read.`)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
315
327
|
if (hasJsonFlag()) {
|
|
316
328
|
printJson({ status: 'already-logged-out', server: baseUrl })
|
|
317
329
|
return
|
|
@@ -319,34 +331,18 @@ async function logout(baseUrlOverride?: string) {
|
|
|
319
331
|
printLine(`Already logged out for ${sanitizeTerminalText(baseUrl)}.`)
|
|
320
332
|
return
|
|
321
333
|
}
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
assertSecureTokenTransport(baseUrl)
|
|
332
|
-
const response = await fetch(`${baseUrl}/api/cli/auth/logout`, {
|
|
333
|
-
method: 'POST',
|
|
334
|
-
headers: {
|
|
335
|
-
'Content-Type': 'application/json',
|
|
336
|
-
Authorization: `Bearer ${authorizationToken}`,
|
|
337
|
-
},
|
|
338
|
-
body: 'refreshToken' in credentials
|
|
339
|
-
? JSON.stringify({ refreshToken: credentials.refreshToken })
|
|
340
|
-
: undefined,
|
|
341
|
-
...credentialRequestRedirectPolicy(),
|
|
342
|
-
})
|
|
343
|
-
if (!response.ok) {
|
|
344
|
-
remoteLogoutError = await describeLogoutHttpFailure(response, logoutSecrets)
|
|
334
|
+
const { storedLogout } = await import('./stored-session-requests.js')
|
|
335
|
+
const { remoteError: remoteLogoutError, localCredentialCleared } = await storedLogout(baseUrl, credentialBaseUrl, credentials)
|
|
336
|
+
// Another login saved for this server while logout was out is kept.
|
|
337
|
+
if (!localCredentialCleared && getServerCredentials(credentialBaseUrl)) {
|
|
338
|
+
if (hasJsonFlag()) {
|
|
339
|
+
printJson({ status: 'changed', server: baseUrl, remoteLogoutError })
|
|
340
|
+
process.exitCode = 1
|
|
341
|
+
return
|
|
345
342
|
}
|
|
346
|
-
|
|
347
|
-
|
|
343
|
+
if (remoteLogoutError) console.warn(`Warning: remote logout failed: ${remoteLogoutError}`)
|
|
344
|
+
throw new Error(`ORIZU_CREDENTIALS_CHANGED: another login was saved for ${sanitizeTerminalText(baseUrl)} during logout; it was kept. Run orizu logout again to remove it.`)
|
|
348
345
|
}
|
|
349
|
-
clearServerCredentials(credentialBaseUrl)
|
|
350
346
|
if (hasJsonFlag()) {
|
|
351
347
|
printJson({ status: 'logged-out', server: baseUrl, remoteLogoutError })
|
|
352
348
|
return
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Waiting for the credentials lock without freezing the process (ORI-2421).
|
|
2
|
+
// Loaded only by stored-login code (refresh, logout, login), never on the
|
|
3
|
+
// ORIZU_TOKEN / ORIZU_TOKEN_FILE path, which must not load Effect.
|
|
4
|
+
import { Cause, Effect, Exit } from 'effect'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
CREDENTIALS_LOCK_RETRY_MS,
|
|
8
|
+
credentialsLockTimeoutError,
|
|
9
|
+
credentialsLockTimeoutMs,
|
|
10
|
+
type HeldCredentialsLock,
|
|
11
|
+
releaseCredentialsLock,
|
|
12
|
+
tryAcquireCredentialsLock,
|
|
13
|
+
withCredentialsTransactionLockAsync,
|
|
14
|
+
} from './credentials.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Try the lock, sleep, try again, until ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS.
|
|
18
|
+
* Timers and stop handlers keep running while it waits, and `signal` stops
|
|
19
|
+
* the wait. A try is one synchronous step, so a stop never lands inside it.
|
|
20
|
+
* A lock a try takes is noted at once, and handed back if the run then ends
|
|
21
|
+
* any way other than returning it, wherever a stop lands.
|
|
22
|
+
*/
|
|
23
|
+
function acquireLock(signal: AbortSignal | null | undefined): Promise<HeldCredentialsLock> {
|
|
24
|
+
const timeoutMs = credentialsLockTimeoutMs()
|
|
25
|
+
const deadline = Date.now() + timeoutMs
|
|
26
|
+
let taken: HeldCredentialsLock | null = null
|
|
27
|
+
const attempt = Effect.try({
|
|
28
|
+
try: () => (taken = tryAcquireCredentialsLock()),
|
|
29
|
+
catch: cause => cause instanceof Error ? cause : new Error(String(cause)),
|
|
30
|
+
})
|
|
31
|
+
const program = Effect.gen(function*() {
|
|
32
|
+
while (true) {
|
|
33
|
+
const lock = yield* attempt
|
|
34
|
+
if (lock) return lock
|
|
35
|
+
const remainingMs = deadline - Date.now()
|
|
36
|
+
if (remainingMs <= 0) return yield* Effect.fail(credentialsLockTimeoutError(timeoutMs))
|
|
37
|
+
yield* Effect.sleep(Math.min(CREDENTIALS_LOCK_RETRY_MS, remainingMs))
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
return run(program, signal, () => {
|
|
41
|
+
if (!taken) return
|
|
42
|
+
try {
|
|
43
|
+
releaseCredentialsLock(taken)
|
|
44
|
+
} catch {
|
|
45
|
+
// The stop or failure is what the caller needs to see.
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A stop by `signal` rejects with its reason, as an aborted fetch would. */
|
|
51
|
+
async function run<A>(
|
|
52
|
+
program: Effect.Effect<A, Error>,
|
|
53
|
+
signal: AbortSignal | null | undefined,
|
|
54
|
+
onNotReturned: () => void
|
|
55
|
+
): Promise<A> {
|
|
56
|
+
const exit = await Effect.runPromiseExit(program, signal ? { signal } : undefined)
|
|
57
|
+
if (Exit.isSuccess(exit)) return exit.value
|
|
58
|
+
onNotReturned()
|
|
59
|
+
if (signal?.aborted && Cause.hasInterruptsOnly(exit.cause)) throw signal.reason
|
|
60
|
+
throw Cause.squash(exit.cause)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A credentials transaction whose wait for the lock stays responsive. The
|
|
65
|
+
* in-process rules are credentials.ts's: one holder per process, and nested
|
|
66
|
+
* writes only from the holder's own async context.
|
|
67
|
+
*/
|
|
68
|
+
export function withCredentialsTransaction<T>(
|
|
69
|
+
operation: () => Promise<T>,
|
|
70
|
+
signal?: AbortSignal | null
|
|
71
|
+
): Promise<T> {
|
|
72
|
+
return withCredentialsTransactionLockAsync(operation, () => acquireLock(signal))
|
|
73
|
+
}
|
package/src/credentials.ts
CHANGED
|
@@ -29,6 +29,12 @@ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
|
29
29
|
return error instanceof Error
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// The system's short error code (EACCES, EPERM), never the message: messages
|
|
33
|
+
// from fs calls carry paths, and this one goes to the terminal.
|
|
34
|
+
function errorCode(error: unknown): string {
|
|
35
|
+
return isNodeError(error) && typeof error.code === 'string' ? error.code : 'unknown error'
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
export function getConfigDir(): string {
|
|
33
39
|
if (process.env.ORIZU_CONFIG_DIR) {
|
|
34
40
|
return process.env.ORIZU_CONFIG_DIR
|
|
@@ -42,7 +48,7 @@ function getCredentialsPath(): string {
|
|
|
42
48
|
|
|
43
49
|
const DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS = 2_000
|
|
44
50
|
const MAX_CREDENTIALS_LOCK_TIMEOUT_MS = 10_000
|
|
45
|
-
const CREDENTIALS_LOCK_RETRY_MS = 20
|
|
51
|
+
export const CREDENTIALS_LOCK_RETRY_MS = 20
|
|
46
52
|
|
|
47
53
|
interface CredentialsLockMetadata {
|
|
48
54
|
version: 1
|
|
@@ -52,7 +58,7 @@ interface CredentialsLockMetadata {
|
|
|
52
58
|
instanceId: string
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
interface HeldCredentialsLock {
|
|
61
|
+
export interface HeldCredentialsLock {
|
|
56
62
|
path: string
|
|
57
63
|
metadata: string
|
|
58
64
|
depth: number
|
|
@@ -81,69 +87,85 @@ function sleepSync(milliseconds: number): void {
|
|
|
81
87
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
function
|
|
90
|
+
export function credentialsLockTimeoutMs(): number {
|
|
91
|
+
return readLockDuration(
|
|
92
|
+
'ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS',
|
|
93
|
+
DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS,
|
|
94
|
+
MAX_CREDENTIALS_LOCK_TIMEOUT_MS
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function credentialsLockTimeoutError(timeoutMs: number): Error {
|
|
99
|
+
return new Error(
|
|
100
|
+
`ORIZU_CREDENTIALS_LOCK_TIMEOUT: credentials mutation did not acquire the lock within ${timeoutMs}ms. Verify no Orizu process is running, then remove credentials.json.lock manually.`
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One try at the cross-process lock. Returns null when another owner holds
|
|
106
|
+
* it. The waits between tries belong to the caller: a blocking one below for
|
|
107
|
+
* synchronous writers, an Effect sleep in credentials-transaction.ts.
|
|
108
|
+
*/
|
|
109
|
+
export function tryAcquireCredentialsLock(): HeldCredentialsLock | null {
|
|
85
110
|
const dir = getConfigDir()
|
|
86
111
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
87
112
|
chmodSync(dir, 0o700)
|
|
88
113
|
|
|
89
114
|
const lockPath = getCredentialsLockPath()
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
const metadata = JSON.stringify({
|
|
116
|
+
version: 1,
|
|
117
|
+
pid: process.pid,
|
|
118
|
+
hostname: hostname(),
|
|
119
|
+
createdAt: Date.now(),
|
|
120
|
+
instanceId: randomBytes(16).toString('hex'),
|
|
121
|
+
} satisfies CredentialsLockMetadata)
|
|
122
|
+
const tempPath = join(
|
|
123
|
+
dir,
|
|
124
|
+
`.credentials.json.lock.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
|
|
94
125
|
)
|
|
95
|
-
const deadline = Date.now() + timeoutMs
|
|
96
126
|
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
dir,
|
|
107
|
-
`.credentials.json.lock.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
|
|
108
|
-
)
|
|
127
|
+
try {
|
|
128
|
+
const fd = openSync(tempPath, 'wx', 0o600)
|
|
129
|
+
try {
|
|
130
|
+
writeFileSync(fd, metadata, 'utf8')
|
|
131
|
+
fsyncSync(fd)
|
|
132
|
+
chmodSync(tempPath, 0o600)
|
|
133
|
+
} finally {
|
|
134
|
+
closeSync(fd)
|
|
135
|
+
}
|
|
109
136
|
|
|
110
137
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
138
|
+
// link(2) publishes the already-complete inode without replacing an
|
|
139
|
+
// existing owner. No contender ever unlinks the canonical lock.
|
|
140
|
+
linkSync(tempPath, lockPath)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (isNodeError(error) && error.code === 'EEXIST') return null
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
119
145
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
)
|
|
130
|
-
}
|
|
131
|
-
sleepSync(Math.min(CREDENTIALS_LOCK_RETRY_MS, Math.max(1, deadline - Date.now())))
|
|
132
|
-
continue
|
|
133
|
-
}
|
|
146
|
+
let resolveReleased = () => {}
|
|
147
|
+
const released = new Promise<void>(resolve => { resolveReleased = resolve })
|
|
148
|
+
return { path: lockPath, metadata, depth: 1, released, resolveReleased }
|
|
149
|
+
} finally {
|
|
150
|
+
// The unique owner-only preparation link is never retained, whether
|
|
151
|
+
// publication succeeds, loses to an owner, or fails unexpectedly.
|
|
152
|
+
rmSync(tempPath, { force: true })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
134
155
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
156
|
+
/** Blocks the process between tries; only for synchronous writers. */
|
|
157
|
+
function acquireCredentialsLock(): HeldCredentialsLock {
|
|
158
|
+
const timeoutMs = credentialsLockTimeoutMs()
|
|
159
|
+
const deadline = Date.now() + timeoutMs
|
|
160
|
+
while (true) {
|
|
161
|
+
const lock = tryAcquireCredentialsLock()
|
|
162
|
+
if (lock) return lock
|
|
163
|
+
if (Date.now() >= deadline) throw credentialsLockTimeoutError(timeoutMs)
|
|
164
|
+
sleepSync(Math.min(CREDENTIALS_LOCK_RETRY_MS, Math.max(1, deadline - Date.now())))
|
|
143
165
|
}
|
|
144
166
|
}
|
|
145
167
|
|
|
146
|
-
function releaseCredentialsLock(lock: HeldCredentialsLock): void {
|
|
168
|
+
export function releaseCredentialsLock(lock: HeldCredentialsLock): void {
|
|
147
169
|
let currentMetadata: string
|
|
148
170
|
try {
|
|
149
171
|
currentMetadata = readFileSync(lock.path, 'utf8')
|
|
@@ -183,15 +205,26 @@ export function withCredentialsTransactionLock<T>(operation: () => T): T {
|
|
|
183
205
|
|
|
184
206
|
const lock = acquireCredentialsLock()
|
|
185
207
|
heldCredentialsLock = lock
|
|
208
|
+
let failed = false
|
|
186
209
|
try {
|
|
187
210
|
return operation()
|
|
211
|
+
} catch (error) {
|
|
212
|
+
failed = true
|
|
213
|
+
throw error
|
|
188
214
|
} finally {
|
|
189
215
|
heldCredentialsLock = null
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
216
|
+
endTransaction(lock, failed)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Release the lock if it is still ours. A release error never hides the transaction's own error. */
|
|
221
|
+
function endTransaction(lock: HeldCredentialsLock, failed: boolean): void {
|
|
222
|
+
try {
|
|
223
|
+
releaseCredentialsLock(lock)
|
|
224
|
+
} catch (releaseError) {
|
|
225
|
+
if (!failed) throw releaseError
|
|
226
|
+
} finally {
|
|
227
|
+
lock.resolveReleased()
|
|
195
228
|
}
|
|
196
229
|
}
|
|
197
230
|
|
|
@@ -201,7 +234,10 @@ export function withCredentialsTransactionLock<T>(operation: () => T): T {
|
|
|
201
234
|
* async execution context; unrelated same-process work must wait or fail.
|
|
202
235
|
*/
|
|
203
236
|
export async function withCredentialsTransactionLockAsync<T>(
|
|
204
|
-
operation: () => Promise<T
|
|
237
|
+
operation: () => Promise<T>,
|
|
238
|
+
// How to wait for the cross-process lock. The default blocks the process
|
|
239
|
+
// between tries; stored-login commands pass credentials-transaction.ts's.
|
|
240
|
+
acquire: () => HeldCredentialsLock | Promise<HeldCredentialsLock> = acquireCredentialsLock
|
|
205
241
|
): Promise<T> {
|
|
206
242
|
const lockPath = getCredentialsLockPath()
|
|
207
243
|
const currentOwner = credentialsTransactionOwner.getStore()
|
|
@@ -218,38 +254,30 @@ export async function withCredentialsTransactionLockAsync<T>(
|
|
|
218
254
|
}
|
|
219
255
|
}
|
|
220
256
|
|
|
221
|
-
const timeoutMs =
|
|
222
|
-
'ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS',
|
|
223
|
-
DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS,
|
|
224
|
-
MAX_CREDENTIALS_LOCK_TIMEOUT_MS
|
|
225
|
-
)
|
|
257
|
+
const timeoutMs = credentialsLockTimeoutMs()
|
|
226
258
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
227
259
|
const didRelease = await Promise.race([
|
|
228
260
|
heldCredentialsLock.released.then(() => true),
|
|
229
261
|
new Promise<false>(resolve => { timer = setTimeout(() => resolve(false), timeoutMs) }),
|
|
230
262
|
])
|
|
231
263
|
if (timer) clearTimeout(timer)
|
|
232
|
-
if (!didRelease)
|
|
233
|
-
|
|
234
|
-
`ORIZU_CREDENTIALS_LOCK_TIMEOUT: credentials mutation did not acquire the lock within ${timeoutMs}ms. Verify no Orizu process is running, then remove credentials.json.lock manually.`
|
|
235
|
-
)
|
|
236
|
-
}
|
|
237
|
-
return await withCredentialsTransactionLockAsync(operation)
|
|
264
|
+
if (!didRelease) throw credentialsLockTimeoutError(timeoutMs)
|
|
265
|
+
return await withCredentialsTransactionLockAsync(operation, acquire)
|
|
238
266
|
}
|
|
239
267
|
|
|
240
|
-
const lock =
|
|
268
|
+
const lock = await acquire()
|
|
241
269
|
const owner = Symbol('credentials-transaction-owner')
|
|
242
270
|
lock.asyncOwner = owner
|
|
243
271
|
heldCredentialsLock = lock
|
|
272
|
+
let failed = false
|
|
244
273
|
try {
|
|
245
274
|
return await credentialsTransactionOwner.run(owner, operation)
|
|
275
|
+
} catch (error) {
|
|
276
|
+
failed = true
|
|
277
|
+
throw error
|
|
246
278
|
} finally {
|
|
247
279
|
heldCredentialsLock = null
|
|
248
|
-
|
|
249
|
-
releaseCredentialsLock(lock)
|
|
250
|
-
} finally {
|
|
251
|
-
lock.resolveReleased()
|
|
252
|
-
}
|
|
280
|
+
endTransaction(lock, failed)
|
|
253
281
|
}
|
|
254
282
|
}
|
|
255
283
|
|
|
@@ -319,18 +347,26 @@ function writeCredentials(config: StoredCredentialsV2 | StoredCredentialsV3) {
|
|
|
319
347
|
const payload = JSON.stringify(config, null, 2) + '\n'
|
|
320
348
|
|
|
321
349
|
try {
|
|
322
|
-
const fd = openSync(tempPath, 'wx', 0o600)
|
|
323
350
|
try {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
351
|
+
const fd = openSync(tempPath, 'wx', 0o600)
|
|
352
|
+
try {
|
|
353
|
+
writeFileSync(fd, payload, 'utf-8')
|
|
354
|
+
fsyncSync(fd)
|
|
355
|
+
} finally {
|
|
356
|
+
closeSync(fd)
|
|
357
|
+
}
|
|
329
358
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
359
|
+
chmodSync(tempPath, 0o600)
|
|
360
|
+
// Never delete the destination to emulate replacement. If this platform
|
|
361
|
+
// cannot atomically replace it, preserve the old file and surface the error.
|
|
362
|
+
renameSync(tempPath, path)
|
|
363
|
+
} catch (error) {
|
|
364
|
+
// Until the rename succeeds the saved file is untouched.
|
|
365
|
+
throw new Error(
|
|
366
|
+
`ORIZU_CREDENTIALS_WRITE_FAILED: could not save sign-ins to ${path} (${errorCode(error)}). The saved file is unchanged.`,
|
|
367
|
+
{ cause: error }
|
|
368
|
+
)
|
|
369
|
+
}
|
|
334
370
|
chmodSync(path, 0o600)
|
|
335
371
|
} finally {
|
|
336
372
|
// On success rename has already removed this path; on every error this
|
|
@@ -347,11 +383,17 @@ function createEmptyCredentialsConfig(): StoredCredentialsV3 {
|
|
|
347
383
|
}
|
|
348
384
|
}
|
|
349
385
|
|
|
386
|
+
// A missing file, invalid JSON or an unknown format start empty (the "log in
|
|
387
|
+
// again" recovery). A file that exists but cannot be read is never replaced:
|
|
388
|
+
// that would delete every other saved sign-in.
|
|
350
389
|
function loadCredentialsConfigForWrite(): StoredCredentialsV2 | StoredCredentialsV3 {
|
|
351
390
|
try {
|
|
352
391
|
return loadCredentialsConfig() || createEmptyCredentialsConfig()
|
|
353
|
-
} catch {
|
|
354
|
-
|
|
392
|
+
} catch (error) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`ORIZU_CREDENTIALS_READ_FAILED: could not read ${getCredentialsPath()} (${errorCode(error)}). Saved sign-ins were not changed. Move it aside, then run \`orizu login\` again.`,
|
|
395
|
+
{ cause: error }
|
|
396
|
+
)
|
|
355
397
|
}
|
|
356
398
|
}
|
|
357
399
|
|
|
@@ -387,6 +429,30 @@ export function loadCredentialsConfig(): StoredCredentialsV2 | StoredCredentials
|
|
|
387
429
|
return null
|
|
388
430
|
}
|
|
389
431
|
|
|
432
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
433
|
+
return typeof value === 'string' && value.length > 0
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* A saved sign-in this CLI can use: a session (it has an access token) or an
|
|
438
|
+
* API key. Plain code, not Effect Schema: this file is loaded by every
|
|
439
|
+
* command, including hosted sessions signed with ORIZU_TOKEN, which must never
|
|
440
|
+
* load Effect.
|
|
441
|
+
*/
|
|
442
|
+
function isServerCredentials(value: unknown): value is ServerCredentials {
|
|
443
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
444
|
+
const entry = value as Record<string, unknown>
|
|
445
|
+
if ('accessToken' in entry) {
|
|
446
|
+
return isNonEmptyString(entry.accessToken)
|
|
447
|
+
&& isNonEmptyString(entry.refreshToken)
|
|
448
|
+
&& typeof entry.expiresAt === 'number'
|
|
449
|
+
&& Number.isFinite(entry.expiresAt)
|
|
450
|
+
}
|
|
451
|
+
return isNonEmptyString(entry.apiKey)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const warnedUnreadableServers = new Set<string>()
|
|
455
|
+
|
|
390
456
|
export function getServerCredentials(baseUrl: string): ServerCredentials | null {
|
|
391
457
|
const config = loadCredentialsConfig()
|
|
392
458
|
if (!config) {
|
|
@@ -397,7 +463,19 @@ export function getServerCredentials(baseUrl: string): ServerCredentials | null
|
|
|
397
463
|
return null
|
|
398
464
|
}
|
|
399
465
|
|
|
400
|
-
|
|
466
|
+
const entry: unknown = config.servers[baseUrl]
|
|
467
|
+
if (entry === null || entry === undefined) return null
|
|
468
|
+
if (!isServerCredentials(entry)) {
|
|
469
|
+
// Never the value: a hand-edited file may hold a token here (ORI-2675).
|
|
470
|
+
if (!warnedUnreadableServers.has(baseUrl)) {
|
|
471
|
+
warnedUnreadableServers.add(baseUrl)
|
|
472
|
+
console.warn(
|
|
473
|
+
`Warning: the saved sign-in for ${baseUrl} is not in a format this CLI understands. Run \`orizu login --server ${baseUrl}\` to replace it, or \`orizu logout --server ${baseUrl}\` to remove it.`
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
return null
|
|
477
|
+
}
|
|
478
|
+
return entry
|
|
401
479
|
}
|
|
402
480
|
|
|
403
481
|
export function saveServerCredentials(baseUrl: string, credentials: ServerCredentials) {
|
|
@@ -433,6 +511,8 @@ export function updateServerCredentials(baseUrl: string, credentials: ServerCred
|
|
|
433
511
|
}
|
|
434
512
|
|
|
435
513
|
export function credentialsEqual(left: ServerCredentials, right: ServerCredentials): boolean {
|
|
514
|
+
// Entries read straight from the file may be malformed; those never match.
|
|
515
|
+
if (!isServerCredentials(left) || !isServerCredentials(right)) return false
|
|
436
516
|
if ('apiKey' in left || 'apiKey' in right) {
|
|
437
517
|
return 'apiKey' in left && 'apiKey' in right && left.apiKey === right.apiKey
|
|
438
518
|
}
|
|
@@ -457,9 +537,29 @@ export function updateServerCredentialsIfCurrent(
|
|
|
457
537
|
})
|
|
458
538
|
}
|
|
459
539
|
|
|
540
|
+
function isServerAddress(value: unknown): value is string {
|
|
541
|
+
if (typeof value !== 'string') return false
|
|
542
|
+
try {
|
|
543
|
+
normalizeBaseUrl(value)
|
|
544
|
+
return true
|
|
545
|
+
} catch {
|
|
546
|
+
return false
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Never the value: the "Invalid server URL" error would print it (ORI-2675).
|
|
551
|
+
// No fallback to the default server either: with an app.orizu.ai sign-in
|
|
552
|
+
// saved, that would run the command as a different account.
|
|
553
|
+
function unreadableActiveServerError(): Error {
|
|
554
|
+
return new Error('The saved server in credentials.json isn\'t a valid address. Run `orizu login --server <url>`.')
|
|
555
|
+
}
|
|
556
|
+
|
|
460
557
|
export function getActiveBaseUrl(): string | null {
|
|
461
558
|
const config = loadCredentialsConfig()
|
|
462
|
-
|
|
559
|
+
const active: unknown = config?.activeBaseUrl
|
|
560
|
+
if (active === null || active === undefined || active === '') return null
|
|
561
|
+
if (isServerAddress(active)) return active
|
|
562
|
+
throw unreadableActiveServerError()
|
|
463
563
|
}
|
|
464
564
|
|
|
465
565
|
/** Resolve the selected server and move a retired production credential atomically. */
|
|
@@ -476,7 +576,11 @@ export function resolveCredentialServer(selected?: string): string | null {
|
|
|
476
576
|
return withCredentialsTransactionLock(() => {
|
|
477
577
|
const config = loadCredentialsConfig()
|
|
478
578
|
// Another process may have selected a different server while we waited.
|
|
479
|
-
const
|
|
579
|
+
const storedSource: unknown = config?.activeBaseUrl
|
|
580
|
+
if (!selected && storedSource !== null && storedSource !== undefined && storedSource !== '' && !isServerAddress(storedSource)) {
|
|
581
|
+
throw unreadableActiveServerError()
|
|
582
|
+
}
|
|
583
|
+
const currentSource = selected ?? (isServerAddress(storedSource) ? storedSource : null)
|
|
480
584
|
if (!currentSource) return null
|
|
481
585
|
const currentTarget = canonicalServerUrl(currentSource)
|
|
482
586
|
if (!config || normalizeBaseUrl(currentSource) === currentTarget) return currentTarget
|
|
@@ -645,6 +749,24 @@ export function clearServerCredentials(baseUrl: string): boolean {
|
|
|
645
749
|
})
|
|
646
750
|
}
|
|
647
751
|
|
|
752
|
+
/**
|
|
753
|
+
* Remove a saved sign-in this CLI cannot read (it may be a pasted token).
|
|
754
|
+
* Returns false when there is none, or when it has become readable since.
|
|
755
|
+
*/
|
|
756
|
+
export function clearUnreadableServerCredentials(baseUrl: string): boolean {
|
|
757
|
+
return withCredentialsTransactionLock(() => {
|
|
758
|
+
const config = loadCredentialsConfig()
|
|
759
|
+
if (!config || !Object.hasOwn(config.servers, baseUrl)) return false
|
|
760
|
+
const entry: unknown = config.servers[baseUrl]
|
|
761
|
+
if (entry === null || entry === undefined || isServerCredentials(entry)) return false
|
|
762
|
+
|
|
763
|
+
delete config.servers[baseUrl]
|
|
764
|
+
if (config.activeBaseUrl === baseUrl) config.activeBaseUrl = null
|
|
765
|
+
writeCredentials(config)
|
|
766
|
+
return true
|
|
767
|
+
})
|
|
768
|
+
}
|
|
769
|
+
|
|
648
770
|
export function clearCredentialsFile() {
|
|
649
771
|
return withCredentialsTransactionLock(() => {
|
|
650
772
|
const path = getCredentialsPath()
|
|
@@ -52,6 +52,12 @@ function isAuthContextChange(cause: unknown) {
|
|
|
52
52
|
return cause instanceof Error && cause.message.startsWith('ORIZU_AUTH_CONTEXT_CHANGED:')
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// authedFetch sets this when the server answered 401 and the sign-in refresh
|
|
56
|
+
// then failed, so the request was not sent again: nothing was written.
|
|
57
|
+
function isRequestRejected(cause: unknown) {
|
|
58
|
+
return cause instanceof Error && (cause as { requestRejected?: unknown }).requestRejected === true
|
|
59
|
+
}
|
|
60
|
+
|
|
55
61
|
// Only the existing authentication client's rejected-token refresh may replay a
|
|
56
62
|
// request. No transient/transport retries belong around these non-idempotent writes.
|
|
57
63
|
function post<T>(path: string, body: unknown, context: string, prefix: string, timeoutMs: number): Effect.Effect<T, DatasetWriteFailure> {
|
|
@@ -69,7 +75,7 @@ function post<T>(path: string, body: unknown, context: string, prefix: string, t
|
|
|
69
75
|
return await parseJsonResponse<T>(response, context)
|
|
70
76
|
},
|
|
71
77
|
catch: cause => cause instanceof DatasetWriteFailure ? cause : new DatasetWriteFailure(
|
|
72
|
-
cause instanceof Error ? cause.message : String(cause), requestStarted && !isAuthContextChange(cause) ? 'unknown' : 'rejected', { cause },
|
|
78
|
+
cause instanceof Error ? cause.message : String(cause), requestStarted && !isAuthContextChange(cause) && !isRequestRejected(cause) ? 'unknown' : 'rejected', { cause },
|
|
73
79
|
),
|
|
74
80
|
}).pipe(Effect.timeoutOrElse({
|
|
75
81
|
duration: timeoutMs,
|