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.
Files changed (78) hide show
  1. package/dist/account-directory.d.ts +2 -2
  2. package/dist/account-directory.d.ts.map +1 -1
  3. package/dist/account-directory.js +7 -32
  4. package/dist/auth-commands.d.ts.map +1 -1
  5. package/dist/auth-commands.js +32 -36
  6. package/dist/credentials-transaction.d.ts +7 -0
  7. package/dist/credentials-transaction.d.ts.map +1 -0
  8. package/dist/credentials-transaction.js +60 -0
  9. package/dist/credentials.d.ts +24 -1
  10. package/dist/credentials.d.ts.map +1 -1
  11. package/dist/credentials.js +202 -76
  12. package/dist/dataset-transfer-upload.d.ts.map +1 -1
  13. package/dist/dataset-transfer-upload.js +6 -1
  14. package/dist/directory-list-read.d.ts +37 -0
  15. package/dist/directory-list-read.d.ts.map +1 -0
  16. package/dist/directory-list-read.js +87 -0
  17. package/dist/hosted-boot.d.ts.map +1 -1
  18. package/dist/hosted-boot.js +59 -41
  19. package/dist/hosted-harness-opencode.d.ts +31 -0
  20. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  21. package/dist/hosted-harness-opencode.js +45 -7
  22. package/dist/hosted-loop.d.ts +39 -2
  23. package/dist/hosted-loop.d.ts.map +1 -1
  24. package/dist/hosted-loop.js +127 -18
  25. package/dist/hosted-session-seed.d.ts +2 -0
  26. package/dist/hosted-session-seed.d.ts.map +1 -1
  27. package/dist/hosted-session-seed.js +27 -10
  28. package/dist/http.d.ts.map +1 -1
  29. package/dist/http.js +39 -234
  30. package/dist/keyboard-select.d.ts.map +1 -1
  31. package/dist/keyboard-select.js +23 -45
  32. package/dist/project-create-workflow.d.ts +18 -0
  33. package/dist/project-create-workflow.d.ts.map +1 -0
  34. package/dist/project-create-workflow.js +121 -0
  35. package/dist/self-update-command.d.ts +8 -0
  36. package/dist/self-update-command.d.ts.map +1 -0
  37. package/dist/self-update-command.js +390 -0
  38. package/dist/self-update.d.ts +29 -16
  39. package/dist/self-update.d.ts.map +1 -1
  40. package/dist/self-update.js +55 -244
  41. package/dist/setup-account.d.ts.map +1 -1
  42. package/dist/setup-account.js +4 -1
  43. package/dist/setup-and-skills-commands.d.ts.map +1 -1
  44. package/dist/setup-and-skills-commands.js +88 -102
  45. package/dist/skill-install-workflow.d.ts +23 -0
  46. package/dist/skill-install-workflow.d.ts.map +1 -0
  47. package/dist/skill-install-workflow.js +184 -0
  48. package/dist/skill-installer.d.ts +8 -3
  49. package/dist/skill-installer.d.ts.map +1 -1
  50. package/dist/skill-installer.js +26 -33
  51. package/dist/stored-session-requests.d.ts +27 -0
  52. package/dist/stored-session-requests.d.ts.map +1 -0
  53. package/dist/stored-session-requests.js +268 -0
  54. package/dist/terminal-key-session.d.ts +23 -0
  55. package/dist/terminal-key-session.d.ts.map +1 -0
  56. package/dist/terminal-key-session.js +74 -0
  57. package/package.json +1 -1
  58. package/src/account-directory.ts +8 -42
  59. package/src/auth-commands.ts +31 -35
  60. package/src/credentials-transaction.ts +73 -0
  61. package/src/credentials.ts +211 -89
  62. package/src/dataset-transfer-upload.ts +7 -1
  63. package/src/directory-list-read.ts +123 -0
  64. package/src/hosted-boot.ts +65 -43
  65. package/src/hosted-harness-opencode.ts +65 -9
  66. package/src/hosted-loop.ts +152 -18
  67. package/src/hosted-session-seed.ts +25 -8
  68. package/src/http.ts +39 -317
  69. package/src/keyboard-select.ts +18 -55
  70. package/src/project-create-workflow.ts +139 -0
  71. package/src/self-update-command.ts +419 -0
  72. package/src/self-update.ts +70 -266
  73. package/src/setup-account.ts +4 -1
  74. package/src/setup-and-skills-commands.ts +89 -121
  75. package/src/skill-install-workflow.ts +215 -0
  76. package/src/skill-installer.ts +31 -38
  77. package/src/stored-session-requests.ts +319 -0
  78. package/src/terminal-key-session.ts +88 -0
@@ -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
- saveServerCredentials(baseUrl, {
159
- credentialType: 'pat',
160
- apiKey: loginData.apiKey,
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
- if (target.isDefault) {
164
- const detail = error instanceof Error ? error.message : String(error)
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 authorizationToken = 'accessToken' in credentials
323
- ? credentials.accessToken
324
- : credentials.apiKey
325
- const logoutSecrets = [
326
- authorizationToken,
327
- ...('refreshToken' in credentials ? [credentials.refreshToken] : []),
328
- ]
329
- let remoteLogoutError: string | null = null
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
- } catch (error) {
347
- remoteLogoutError = describeLogoutTransportFailure(error, logoutSecrets)
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
+ }
@@ -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 acquireCredentialsLock(): HeldCredentialsLock {
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 timeoutMs = readLockDuration(
91
- 'ORIZU_CREDENTIALS_LOCK_TIMEOUT_MS',
92
- DEFAULT_CREDENTIALS_LOCK_TIMEOUT_MS,
93
- MAX_CREDENTIALS_LOCK_TIMEOUT_MS
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
- while (true) {
98
- const metadata = JSON.stringify({
99
- version: 1,
100
- pid: process.pid,
101
- hostname: hostname(),
102
- createdAt: Date.now(),
103
- instanceId: randomBytes(16).toString('hex'),
104
- } satisfies CredentialsLockMetadata)
105
- const tempPath = join(
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
- const fd = openSync(tempPath, 'wx', 0o600)
112
- try {
113
- writeFileSync(fd, metadata, 'utf8')
114
- fsyncSync(fd)
115
- chmodSync(tempPath, 0o600)
116
- } finally {
117
- closeSync(fd)
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
- try {
121
- // link(2) publishes the already-complete inode without replacing an
122
- // existing owner. No contender ever unlinks the canonical lock.
123
- linkSync(tempPath, lockPath)
124
- } catch (error) {
125
- if (!(isNodeError(error) && error.code === 'EEXIST')) throw error
126
- if (Date.now() >= deadline) {
127
- throw new Error(
128
- `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.`
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
- let resolveReleased = () => {}
136
- const released = new Promise<void>(resolve => { resolveReleased = resolve })
137
- return { path: lockPath, metadata, depth: 1, released, resolveReleased }
138
- } finally {
139
- // The unique owner-only preparation link is never retained, whether
140
- // publication succeeds, loses to an owner, or fails unexpectedly.
141
- rmSync(tempPath, { force: true })
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
- try {
191
- releaseCredentialsLock(lock)
192
- } finally {
193
- lock.resolveReleased()
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 = readLockDuration(
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
- throw new Error(
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 = acquireCredentialsLock()
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
- try {
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
- writeFileSync(fd, payload, 'utf-8')
325
- fsyncSync(fd)
326
- } finally {
327
- closeSync(fd)
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
- chmodSync(tempPath, 0o600)
331
- // Never delete the destination to emulate replacement. If this platform
332
- // cannot atomically replace it, preserve the old file and surface the error.
333
- renameSync(tempPath, path)
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
- return createEmptyCredentialsConfig()
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
- return config.servers[baseUrl] || null
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
- return config?.activeBaseUrl || null
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 currentSource = selected ?? config?.activeBaseUrl
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,