orizu 0.5.49 → 0.5.50

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 (74) 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 +22 -30
  6. package/dist/credentials.d.ts +5 -0
  7. package/dist/credentials.d.ts.map +1 -1
  8. package/dist/credentials.js +84 -3
  9. package/dist/dataset-transfer-upload.d.ts.map +1 -1
  10. package/dist/dataset-transfer-upload.js +6 -1
  11. package/dist/directory-list-read.d.ts +37 -0
  12. package/dist/directory-list-read.d.ts.map +1 -0
  13. package/dist/directory-list-read.js +87 -0
  14. package/dist/hosted-boot.d.ts.map +1 -1
  15. package/dist/hosted-boot.js +59 -41
  16. package/dist/hosted-harness-opencode.d.ts +31 -0
  17. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  18. package/dist/hosted-harness-opencode.js +45 -7
  19. package/dist/hosted-loop.d.ts +39 -2
  20. package/dist/hosted-loop.d.ts.map +1 -1
  21. package/dist/hosted-loop.js +127 -18
  22. package/dist/hosted-session-seed.d.ts +2 -0
  23. package/dist/hosted-session-seed.d.ts.map +1 -1
  24. package/dist/hosted-session-seed.js +27 -10
  25. package/dist/http.d.ts.map +1 -1
  26. package/dist/http.js +39 -234
  27. package/dist/keyboard-select.d.ts.map +1 -1
  28. package/dist/keyboard-select.js +23 -45
  29. package/dist/project-create-workflow.d.ts +18 -0
  30. package/dist/project-create-workflow.d.ts.map +1 -0
  31. package/dist/project-create-workflow.js +121 -0
  32. package/dist/self-update-command.d.ts +8 -0
  33. package/dist/self-update-command.d.ts.map +1 -0
  34. package/dist/self-update-command.js +390 -0
  35. package/dist/self-update.d.ts +29 -16
  36. package/dist/self-update.d.ts.map +1 -1
  37. package/dist/self-update.js +55 -244
  38. package/dist/setup-account.d.ts.map +1 -1
  39. package/dist/setup-account.js +4 -1
  40. package/dist/setup-and-skills-commands.d.ts.map +1 -1
  41. package/dist/setup-and-skills-commands.js +88 -102
  42. package/dist/skill-install-workflow.d.ts +23 -0
  43. package/dist/skill-install-workflow.d.ts.map +1 -0
  44. package/dist/skill-install-workflow.js +184 -0
  45. package/dist/skill-installer.d.ts +8 -3
  46. package/dist/skill-installer.d.ts.map +1 -1
  47. package/dist/skill-installer.js +26 -33
  48. package/dist/stored-session-requests.d.ts +27 -0
  49. package/dist/stored-session-requests.d.ts.map +1 -0
  50. package/dist/stored-session-requests.js +264 -0
  51. package/dist/terminal-key-session.d.ts +23 -0
  52. package/dist/terminal-key-session.d.ts.map +1 -0
  53. package/dist/terminal-key-session.js +74 -0
  54. package/package.json +1 -1
  55. package/src/account-directory.ts +8 -42
  56. package/src/auth-commands.ts +20 -29
  57. package/src/credentials.ts +83 -3
  58. package/src/dataset-transfer-upload.ts +7 -1
  59. package/src/directory-list-read.ts +123 -0
  60. package/src/hosted-boot.ts +65 -43
  61. package/src/hosted-harness-opencode.ts +65 -9
  62. package/src/hosted-loop.ts +152 -18
  63. package/src/hosted-session-seed.ts +25 -8
  64. package/src/http.ts +39 -317
  65. package/src/keyboard-select.ts +18 -55
  66. package/src/project-create-workflow.ts +139 -0
  67. package/src/self-update-command.ts +419 -0
  68. package/src/self-update.ts +70 -266
  69. package/src/setup-account.ts +4 -1
  70. package/src/setup-and-skills-commands.ts +89 -121
  71. package/src/skill-install-workflow.ts +215 -0
  72. package/src/skill-installer.ts +31 -38
  73. package/src/stored-session-requests.ts +316 -0
  74. package/src/terminal-key-session.ts +88 -0
package/src/http.ts CHANGED
@@ -1,20 +1,12 @@
1
1
  import {
2
- clearServerCredentialsIfCurrent,
3
- credentialsEqual,
4
2
  resolveCredentialServer,
5
3
  getActiveBaseUrl,
6
4
  getServerCredentials,
7
5
  rememberResolvedBearer,
8
6
  resolveEnvBearerToken,
9
- updateServerCredentialsIfCurrent,
10
- withCredentialsTransactionLockAsync,
11
7
  } from './credentials.js'
12
8
  import { canonicalServerUrl, getFlagBaseUrl, GlobalFlags, normalizeBaseUrl } from './global-flags.js'
13
- import {
14
- describeLogoutHttpFailure,
15
- describeLogoutTransportFailure,
16
- } from './logout-diagnostic.js'
17
- import { LoginResponse, ServerCredentials, SessionServerCredentials } from './types.js'
9
+ import { ServerCredentials } from './types.js'
18
10
 
19
11
  let runtimeFlags: GlobalFlags = { local: false, server: null }
20
12
  let lastResolvedBaseUrl: string | null = null
@@ -125,199 +117,11 @@ export function assertSecureTokenTransport(baseUrl: string) {
125
117
  )
126
118
  }
127
119
 
128
- function isSessionCredentials(credentials: ServerCredentials): credentials is SessionServerCredentials {
129
- return 'accessToken' in credentials
130
- }
131
-
132
- function getAuthorizationToken(credentials: ServerCredentials): string {
133
- return isSessionCredentials(credentials)
134
- ? rememberSessionCredentials(credentials).accessToken
135
- : rememberResolvedBearer(credentials.apiKey)
136
- }
137
-
138
- function rememberSessionCredentials(
139
- credentials: SessionServerCredentials
140
- ): SessionServerCredentials {
141
- rememberResolvedBearer(credentials.accessToken)
142
- rememberResolvedBearer(credentials.refreshToken)
143
- return credentials
144
- }
145
-
146
- function isExpired(expiresAt: number): boolean {
147
- const nowUnix = Math.floor(Date.now() / 1000)
148
- return expiresAt <= nowUnix + 30
149
- }
150
-
151
- const DEFAULT_CREDENTIALS_REFRESH_TIMEOUT_MS = 10_000
152
- const MAX_CREDENTIALS_REFRESH_TIMEOUT_MS = 30_000
153
- const DEFAULT_LOGOUT_TIMEOUT_MS = 10_000
154
- const MAX_LOGOUT_TIMEOUT_MS = 30_000
155
-
156
- function getCredentialsRefreshTimeoutMs(): number {
157
- const configured = Number(process.env.ORIZU_CREDENTIALS_REFRESH_TIMEOUT_MS)
158
- if (!Number.isSafeInteger(configured) || configured <= 0) {
159
- return DEFAULT_CREDENTIALS_REFRESH_TIMEOUT_MS
160
- }
161
- return Math.min(configured, MAX_CREDENTIALS_REFRESH_TIMEOUT_MS)
162
- }
163
-
164
- function getLogoutTimeoutMs(): number {
165
- const configured = Number(process.env.ORIZU_LOGOUT_TIMEOUT_MS)
166
- if (!Number.isSafeInteger(configured) || configured <= 0) {
167
- return DEFAULT_LOGOUT_TIMEOUT_MS
168
- }
169
- return Math.min(configured, MAX_LOGOUT_TIMEOUT_MS)
170
- }
171
-
172
- async function withAbortDeadline<T>(
173
- operation: (signal: AbortSignal) => Promise<T>,
174
- callerSignal: AbortSignal | null | undefined,
175
- timeoutMs: number,
176
- timeoutError: Error
177
- ): Promise<T> {
178
- const controller = new AbortController()
179
- const handleCallerAbort = () => controller.abort(callerSignal?.reason)
180
- if (callerSignal?.aborted) handleCallerAbort()
181
- else callerSignal?.addEventListener('abort', handleCallerAbort, { once: true })
182
-
183
- const timer = setTimeout(() => controller.abort(timeoutError), timeoutMs)
184
- const cancellation = new Promise<never>((_resolve, reject) => {
185
- const handleAbort = () => reject(controller.signal.reason ?? new Error('Request aborted.'))
186
- if (controller.signal.aborted) handleAbort()
187
- else controller.signal.addEventListener('abort', handleAbort, { once: true })
188
- })
189
-
190
- try {
191
- return await Promise.race([operation(controller.signal), cancellation])
192
- } finally {
193
- clearTimeout(timer)
194
- callerSignal?.removeEventListener('abort', handleCallerAbort)
195
- }
196
- }
197
-
198
- async function withCredentialsRefreshDeadline<T>(
199
- operation: (signal: AbortSignal) => Promise<T>,
200
- callerSignal?: AbortSignal | null
201
- ): Promise<T> {
202
- const timeoutMs = getCredentialsRefreshTimeoutMs()
203
- const timeoutError = new Error(
204
- `ORIZU_CREDENTIALS_REFRESH_TIMEOUT: session refresh exceeded ${timeoutMs}ms.`
205
- )
206
- return await withAbortDeadline(operation, callerSignal, timeoutMs, timeoutError)
207
- }
208
-
209
- async function withLogoutDeadline<T>(
210
- operation: (signal: AbortSignal) => Promise<T>,
211
- callerSignal?: AbortSignal | null
212
- ): Promise<T> {
213
- const timeoutMs = getLogoutTimeoutMs()
214
- const timeoutError = new Error(
215
- `ORIZU_LOGOUT_TIMEOUT: remote logout exceeded ${timeoutMs}ms.`
216
- )
217
- timeoutError.name = 'TimeoutError'
218
- return await withAbortDeadline(operation, callerSignal, timeoutMs, timeoutError)
219
- }
220
-
221
120
  export function credentialRequestRedirectPolicy(): Pick<RequestInit, 'redirect'> {
222
121
  return process.env.ORIZU_CLI_AUTH_REDIRECT === 'error' ? { redirect: 'error' } : {}
223
122
  }
224
123
 
225
- async function refreshCredentials(
226
- baseUrl: string,
227
- credentials: ServerCredentials,
228
- callerSignal?: AbortSignal | null
229
- ): Promise<ServerCredentials> {
230
- if (!isSessionCredentials(credentials)) {
231
- throw new Error('API key credentials do not refresh. Run `orizu login` again if access fails.')
232
- }
233
-
234
- assertSecureTokenTransport(baseUrl)
235
- const refreshToken = rememberResolvedBearer(credentials.refreshToken)
236
- const data = await withCredentialsRefreshDeadline(async signal => {
237
- const response = await fetch(`${baseUrl}/api/cli/auth/refresh`, {
238
- method: 'POST',
239
- headers: { 'Content-Type': 'application/json' },
240
- body: JSON.stringify({ refreshToken }),
241
- ...credentialRequestRedirectPolicy(),
242
- signal,
243
- })
244
-
245
- if (!response.ok) {
246
- throw new Error('Session expired. Run `orizu login` again.')
247
- }
248
- return await response.json() as LoginResponse
249
- }, callerSignal)
250
- if (!data.accessToken || !data.refreshToken || !data.expiresAt) {
251
- throw new Error('Server returned invalid refresh credentials. Run `orizu login` again.')
252
- }
253
-
254
- const refreshed = rememberSessionCredentials({
255
- credentialType: 'session' as const,
256
- accessToken: data.accessToken,
257
- refreshToken: data.refreshToken,
258
- expiresAt: data.expiresAt,
259
- })
260
- if (isExpired(refreshed.expiresAt)) {
261
- throw new Error(
262
- 'ORIZU_CREDENTIALS_REFRESH_UNUSABLE: refreshed session expires within the 30-second safety window.'
263
- )
264
- }
265
- return refreshed
266
- }
267
-
268
- interface StoredRefreshResult {
269
- status: 'refreshed' | 'changed'
270
- credentials: ServerCredentials | null
271
- }
272
-
273
- async function refreshStoredCredentialsIfCurrent(
274
- baseUrl: string,
275
- expected: ServerCredentials,
276
- callerSignal?: AbortSignal | null
277
- ): Promise<StoredRefreshResult> {
278
- return await withCredentialsTransactionLockAsync(async () => {
279
- const current = getServerCredentials(baseUrl)
280
- if (!current || !credentialsEqual(current, expected)) {
281
- return { status: 'changed', credentials: current }
282
- }
283
-
284
- const refreshed = await refreshCredentials(baseUrl, expected, callerSignal)
285
- if (!updateServerCredentialsIfCurrent(baseUrl, expected, refreshed)) {
286
- return { status: 'changed', credentials: getServerCredentials(baseUrl) }
287
- }
288
- return { status: 'refreshed', credentials: refreshed }
289
- })
290
- }
291
-
292
- function requireCurrentCredentials(baseUrl: string, credentials: ServerCredentials | null): ServerCredentials {
293
- if (credentials) return credentials
294
- throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`)
295
- }
296
-
297
- interface ResolvedStoredCredentials {
298
- credentials: ServerCredentials
299
- didRefresh: boolean
300
- }
301
-
302
- async function refreshExpiredStoredCredentials(
303
- baseUrl: string,
304
- initial: ServerCredentials,
305
- callerSignal?: AbortSignal | null
306
- ): Promise<ResolvedStoredCredentials> {
307
- if (!isSessionCredentials(initial) || !isExpired(initial.expiresAt)) {
308
- return { credentials: initial, didRefresh: false }
309
- }
310
- const result = await refreshStoredCredentialsIfCurrent(baseUrl, initial, callerSignal)
311
- if (result.status === 'changed') {
312
- throw new Error(
313
- 'ORIZU_AUTH_CONTEXT_CHANGED: stored credentials changed before this request began; the request was not sent.'
314
- )
315
- }
316
- return {
317
- credentials: requireCurrentCredentials(baseUrl, result.credentials),
318
- didRefresh: true,
319
- }
320
- }
124
+ const storedSessionRequests = () => import('./stored-session-requests.js')
321
125
 
322
126
  export type AuthenticatedRequestSource = 'ORIZU_TOKEN' | 'ORIZU_TOKEN_FILE' | 'stored'
323
127
 
@@ -334,6 +138,10 @@ export interface LogoutResult {
334
138
  localCredentialCleared: boolean
335
139
  }
336
140
 
141
+ function notLoggedIn(baseUrl: string): Error {
142
+ return new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`)
143
+ }
144
+
337
145
  /**
338
146
  * Captures one authenticated server context for a multi-step operation. The
339
147
  * returned interface intentionally exposes no credential material. Its closure
@@ -387,95 +195,42 @@ export function captureAuthenticatedRequestContext(
387
195
  }
388
196
 
389
197
  const capturedCredentials = getServerCredentials(baseUrl)
390
- if (!capturedCredentials) {
391
- throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`)
392
- }
393
- void getAuthorizationToken(capturedCredentials)
198
+ if (!capturedCredentials) throw notLoggedIn(baseUrl)
199
+ rememberStoredBearers(capturedCredentials)
394
200
  let activeCredentials = capturedCredentials
395
-
396
- async function refreshCapturedCredentials(callerSignal?: AbortSignal | null): Promise<void> {
397
- const result = await refreshStoredCredentialsIfCurrent(baseUrl, activeCredentials, callerSignal)
398
- if (result.status === 'changed') {
399
- throw new Error(
400
- 'ORIZU_CREDENTIALS_CHANGED: stored credentials changed before the captured session could refresh. Reconfirm the account and retry.'
401
- )
402
- }
403
- activeCredentials = requireCurrentCredentials(baseUrl, result.credentials)
404
- }
201
+ const changed = 'ORIZU_CREDENTIALS_CHANGED: stored credentials changed before the captured session could refresh. Reconfirm the account and retry.'
405
202
 
406
203
  return {
407
204
  baseUrl,
408
205
  source: 'stored',
409
206
  async fetch(path: string, init: RequestInit = {}, onRequestStart?: () => void) {
410
- let didRefresh = false
411
- if (
412
- allowRefresh
413
- && isSessionCredentials(activeCredentials)
414
- && isExpired(activeCredentials.expiresAt)
415
- ) {
416
- await refreshCapturedCredentials(init.signal)
417
- didRefresh = true
418
- }
419
-
420
- onRequestStart?.()
421
- let response = await fetch(`${baseUrl}${path}`, {
422
- ...init,
423
- headers: {
424
- ...(init.headers || {}),
425
- Authorization: `Bearer ${getAuthorizationToken(activeCredentials)}`,
426
- },
427
- ...credentialRequestRedirectPolicy(),
428
- })
429
-
430
- if (
431
- allowRefresh
432
- && response.status === 401
433
- && isSessionCredentials(activeCredentials)
434
- && !didRefresh
435
- ) {
436
- await refreshCapturedCredentials(init.signal)
437
- onRequestStart?.()
438
- response = await fetch(`${baseUrl}${path}`, {
439
- ...init,
440
- headers: {
441
- ...(init.headers || {}),
442
- Authorization: `Bearer ${getAuthorizationToken(activeCredentials)}`,
443
- },
444
- ...credentialRequestRedirectPolicy(),
445
- })
446
- }
447
- return response
207
+ const { storedRequest } = await storedSessionRequests()
208
+ return await storedRequest({
209
+ baseUrl,
210
+ credentials: activeCredentials,
211
+ allowRefresh,
212
+ changedBeforeRequest: changed,
213
+ changedBeforeReplay: changed,
214
+ onRefreshed: refreshed => { activeCredentials = refreshed },
215
+ }, path, init, onRequestStart)
448
216
  },
449
217
  async logout(callerSignal?: AbortSignal | null) {
450
- const authorizationToken = getAuthorizationToken(activeCredentials)
451
- const refreshToken = isSessionCredentials(activeCredentials)
452
- ? rememberResolvedBearer(activeCredentials.refreshToken)
453
- : null
454
- const secrets = [authorizationToken, ...(refreshToken !== null ? [refreshToken] : [])]
455
- let remoteLogoutReason: string | null = null
456
- try {
457
- remoteLogoutReason = await withLogoutDeadline(async signal => {
458
- const response = await fetch(`${baseUrl}/api/cli/auth/logout`, {
459
- method: 'POST',
460
- headers: {
461
- 'Content-Type': 'application/json',
462
- Authorization: `Bearer ${authorizationToken}`,
463
- },
464
- body: refreshToken !== null ? JSON.stringify({ refreshToken }) : undefined,
465
- ...credentialRequestRedirectPolicy(),
466
- signal,
467
- })
468
- return response.ok ? null : await describeLogoutHttpFailure(response, secrets)
469
- }, callerSignal)
470
- } catch (error) {
471
- remoteLogoutReason = describeLogoutTransportFailure(error, secrets)
472
- }
473
- const localCredentialCleared = clearServerCredentialsIfCurrent(baseUrl, activeCredentials)
474
- return { remoteError: remoteLogoutReason, localCredentialCleared }
218
+ const { storedLogout } = await storedSessionRequests()
219
+ return await storedLogout(baseUrl, baseUrl, activeCredentials, callerSignal)
475
220
  },
476
221
  }
477
222
  }
478
223
 
224
+ // Redacts a stored login from diagnostics from the moment it is read.
225
+ function rememberStoredBearers(credentials: ServerCredentials): void {
226
+ if ('accessToken' in credentials) {
227
+ rememberResolvedBearer(credentials.accessToken)
228
+ rememberResolvedBearer(credentials.refreshToken)
229
+ } else {
230
+ rememberResolvedBearer(credentials.apiKey)
231
+ }
232
+ }
233
+
479
234
  // The optional observer runs immediately before a resource fetch, after auth
480
235
  // preflight. Authentication refresh requests do not mark the resource as sent.
481
236
  export async function authedFetch(path: string, init: RequestInit = {}, onRequestStart?: () => void) {
@@ -504,47 +259,14 @@ export async function authedFetch(path: string, init: RequestInit = {}, onReques
504
259
  }
505
260
 
506
261
  const credentials = getServerCredentials(baseUrl)
507
- if (!credentials) {
508
- throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`)
509
- }
510
-
511
- const resolved = await refreshExpiredStoredCredentials(baseUrl, credentials, init.signal)
512
- let activeCredentials = resolved.credentials
513
-
514
- let response = await fetchResource(`${baseUrl}${path}`, {
515
- ...init,
516
- headers: {
517
- ...(init.headers || {}),
518
- Authorization: `Bearer ${getAuthorizationToken(activeCredentials)}`,
519
- },
520
- ...credentialRequestRedirectPolicy(),
521
- })
522
-
523
- if (
524
- response.status === 401
525
- && isSessionCredentials(activeCredentials)
526
- && !resolved.didRefresh
527
- ) {
528
- const refreshResult = await refreshStoredCredentialsIfCurrent(
529
- baseUrl,
530
- activeCredentials,
531
- init.signal
532
- )
533
- if (refreshResult.status === 'changed') {
534
- throw new Error(
535
- 'ORIZU_AUTH_CONTEXT_CHANGED: stored credentials changed after this request began; the request was not replayed.'
536
- )
537
- }
538
- activeCredentials = requireCurrentCredentials(baseUrl, refreshResult.credentials)
539
- response = await fetchResource(`${baseUrl}${path}`, {
540
- ...init,
541
- headers: {
542
- ...(init.headers || {}),
543
- Authorization: `Bearer ${getAuthorizationToken(activeCredentials)}`,
544
- },
545
- ...credentialRequestRedirectPolicy(),
546
- })
547
- }
262
+ if (!credentials) throw notLoggedIn(baseUrl)
548
263
 
549
- return response
264
+ const { storedRequest } = await storedSessionRequests()
265
+ return await storedRequest({
266
+ baseUrl,
267
+ credentials,
268
+ allowRefresh: true,
269
+ changedBeforeRequest: 'ORIZU_AUTH_CONTEXT_CHANGED: stored credentials changed before this request began; the request was not sent.',
270
+ changedBeforeReplay: 'ORIZU_AUTH_CONTEXT_CHANGED: stored credentials changed after this request began; the request was not replayed.',
271
+ }, path, init, onRequestStart)
550
272
  }
@@ -1,23 +1,12 @@
1
- import { emitKeypressEvents } from 'readline'
2
1
  import { stdin as input, stdout as output } from 'process'
3
2
 
4
3
  import { sanitizeTerminalText } from './json-response.js'
5
4
 
6
- interface KeypressInfo {
7
- name?: string
8
- ctrl?: boolean
9
- }
10
-
11
5
  interface KeyboardSelectOptions<T> {
12
6
  onEscape?: () => T
13
7
  escapeLabel?: string
14
8
  }
15
9
 
16
- type RawInput = typeof input & {
17
- setRawMode?: (mode: boolean) => void
18
- isRaw?: boolean
19
- }
20
-
21
10
  export async function promptKeyboardSelect<T>(
22
11
  title: string,
23
12
  items: T[],
@@ -36,9 +25,6 @@ export async function promptKeyboardSelect<T>(
36
25
 
37
26
  let selected = 0
38
27
  let rendered = false
39
- const rawInput = input as RawInput
40
- const wasRaw = Boolean(rawInput.isRaw)
41
- emitKeypressEvents(input)
42
28
 
43
29
  function render() {
44
30
  if (rendered) output.write(`\x1b[${items.length}A`)
@@ -51,47 +37,24 @@ export async function promptKeyboardSelect<T>(
51
37
  }
52
38
 
53
39
  render()
54
- output.write('\x1b[?25l')
55
-
56
- return new Promise((resolvePromise, rejectPromise) => {
57
- function cleanup() {
58
- input.off('keypress', onKeypress)
59
- if (rawInput.setRawMode && !wasRaw) rawInput.setRawMode(false)
60
- input.pause()
61
- output.write('\x1b[?25h\n')
62
- }
63
-
64
- function onKeypress(_chunk: string, key: KeypressInfo) {
65
- if (key.ctrl && key.name === 'c') {
66
- cleanup()
67
- rejectPromise(new Error('Setup cancelled.'))
68
- return
69
- }
70
- if (key.name === 'escape') {
71
- cleanup()
72
- if (options?.onEscape) resolvePromise(options.onEscape())
73
- else rejectPromise(new Error('Setup cancelled.'))
74
- return
75
- }
76
- if (key.name === 'up') {
77
- selected = (selected - 1 + items.length) % items.length
78
- render()
79
- return
80
- }
81
- if (key.name === 'down') {
82
- selected = (selected + 1) % items.length
40
+ const onEscape = options?.onEscape
41
+ const { readKeys } = await import('./terminal-key-session.js')
42
+ let choice: { item: T } | { escaped: true }
43
+ try {
44
+ choice = await readKeys<{ item: T } | { escaped: true }>(key => {
45
+ if (key.name === 'escape') return onEscape ? { kind: 'done', value: { escaped: true } } : { kind: 'cancel' }
46
+ if (key.name === 'up' || key.name === 'down') {
47
+ selected = (selected + (key.name === 'up' ? -1 : 1) + items.length) % items.length
83
48
  render()
84
- return
49
+ return { kind: 'wait' }
85
50
  }
86
- if (key.name === 'return' || key.name === 'enter') {
87
- const item = items[selected]
88
- cleanup()
89
- resolvePromise(item)
90
- }
91
- }
92
-
93
- input.on('keypress', onKeypress)
94
- input.resume()
95
- rawInput.setRawMode?.(true)
96
- })
51
+ if (key.name === 'return' || key.name === 'enter') return { kind: 'done', value: { item: items[selected] } }
52
+ return { kind: 'wait' }
53
+ }, { hideCursor: true })
54
+ } finally {
55
+ output.write('\n')
56
+ }
57
+ if ('item' in choice) return choice.item
58
+ if (!onEscape) throw new Error('Setup cancelled.')
59
+ return onEscape()
97
60
  }
@@ -0,0 +1,139 @@
1
+ import { Cause, Effect, Exit, Result, Schema } from 'effect'
2
+
3
+ import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
4
+ import { extractErrorMessage } from './error-response.js'
5
+ import { appErrorOf, unreachedCode } from './request-outcome.js'
6
+
7
+ // Loaded only through `await import(...)`, like the team create workflow:
8
+ // the project create that setup and `orizu projects create` send.
9
+
10
+ /** Sends one request; calls `onRequestStart` just before anything goes out. */
11
+ export type ProjectCreateRequest = (path: string, init: RequestInit, onRequestStart?: () => void) => Promise<Response>
12
+
13
+ const CreateReply = Schema.Struct({
14
+ project: Schema.Struct({
15
+ id: Schema.NonEmptyString,
16
+ name: Schema.String,
17
+ slug: Schema.NonEmptyString,
18
+ teamSlug: Schema.NonEmptyString,
19
+ teamId: Schema.optional(Schema.String),
20
+ teamName: Schema.optional(Schema.String),
21
+ role: Schema.optional(Schema.String),
22
+ }),
23
+ })
24
+
25
+ export interface CreatedProject {
26
+ id: string
27
+ name: string
28
+ slug: string
29
+ teamId: string
30
+ teamName: string
31
+ teamSlug: string
32
+ role: string
33
+ }
34
+
35
+ /**
36
+ * Runs one request under the time limit. `run` gets the signal to pass on and
37
+ * the hook that marks the request as sent; the body is read inside `run`, so a
38
+ * reply that stalls part way is under the limit too.
39
+ */
40
+ function withDeadline<A, E>(
41
+ timeoutMs: number,
42
+ run: (signal: AbortSignal, markSent: () => void) => Promise<A>,
43
+ onFailure: (cause: unknown, outcome: { timedOut: boolean, sent: boolean }) => E,
44
+ ): Effect.Effect<A, E> {
45
+ return Effect.suspend(() => {
46
+ let sent = false
47
+ let timedOut = false
48
+ return Effect.tryPromise({
49
+ try: async signal => {
50
+ const controller = new AbortController()
51
+ const abort = () => controller.abort()
52
+ const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
53
+ if (signal.aborted) abort()
54
+ else signal.addEventListener('abort', abort, { once: true })
55
+ try {
56
+ return await run(controller.signal, () => { sent = true })
57
+ } finally {
58
+ clearTimeout(timer)
59
+ signal.removeEventListener('abort', abort)
60
+ controller.abort()
61
+ }
62
+ },
63
+ catch: cause => onFailure(cause, { timedOut, sent }),
64
+ })
65
+ })
66
+ }
67
+
68
+ async function runToPromise<A>(effect: Effect.Effect<A, Error>): Promise<A> {
69
+ const exit = await Effect.runPromiseExit(effect)
70
+ if (Exit.isSuccess(exit)) return exit.value
71
+ throw Cause.squash(exit.cause)
72
+ }
73
+
74
+ const FAILED = 'Failed to create project'
75
+
76
+ const projectMayBeCreated = (teamSlug: string, name: string) =>
77
+ `The project may already be created: run \`orizu projects list --team ${teamSlug}\` and look for "${name}" before creating it again.`
78
+
79
+ class ProjectCreateFailure extends Error {
80
+ readonly _tag = 'ProjectCreateFailure'
81
+ constructor(message: string, readonly mayBeCreated: boolean) { super(message) }
82
+ }
83
+
84
+ /**
85
+ * `orizu projects create` and setup's project create: one request with a time
86
+ * limit, never retried, since a retry after an unclear outcome could make a
87
+ * second project. When the outcome is unclear, the error says how to check.
88
+ */
89
+ export async function createProjectWithDeadline(teamSlug: string, name: string, request: ProjectCreateRequest): Promise<CreatedProject> {
90
+ const create = withDeadline(datasetTransferTimeoutMs(), async (signal, markSent) => {
91
+ const response = await request('/api/cli/projects', {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ teamSlug, name }),
95
+ signal,
96
+ }, markSent)
97
+ if (!response.ok) {
98
+ const appError = await appErrorOf(response.clone())
99
+ const detail = await extractErrorMessage(response)
100
+ // A 5xx that is not the app's own error may come from a gateway that gave up while the app kept working.
101
+ if (response.status < 400 || (response.status >= 500 && appError === null)) {
102
+ throw new ProjectCreateFailure(`Project create got no clear answer (status ${response.status}).`, true)
103
+ }
104
+ throw new ProjectCreateFailure(detail.startsWith(FAILED) ? detail : `${FAILED}: ${detail}`, false)
105
+ }
106
+ const body = await response.text()
107
+ let data: unknown
108
+ try {
109
+ data = JSON.parse(body)
110
+ } catch {
111
+ data = undefined
112
+ }
113
+ const decoded = Schema.decodeUnknownResult(CreateReply)(data)
114
+ if (Result.isFailure(decoded)) {
115
+ throw new ProjectCreateFailure(`Project create returned an unexpected response (status ${response.status}).`, true)
116
+ }
117
+ const project = decoded.success.project
118
+ return {
119
+ id: project.id,
120
+ name: project.name,
121
+ slug: project.slug,
122
+ teamId: project.teamId || '',
123
+ teamName: project.teamName || project.teamSlug,
124
+ teamSlug: project.teamSlug,
125
+ role: project.role || 'admin',
126
+ }
127
+ }, (cause, { timedOut, sent }) => {
128
+ if (cause instanceof ProjectCreateFailure) return cause
129
+ if (timedOut) return new ProjectCreateFailure('Project create exceeded its deadline.', sent)
130
+ const unreached = unreachedCode(cause)
131
+ if (unreached) return new ProjectCreateFailure(`${FAILED}: could not reach the server (${unreached}).`, false)
132
+ const message = cause instanceof Error ? cause.message : String(cause)
133
+ if (!sent || message.startsWith('ORIZU_AUTH_CONTEXT_CHANGED:')) return new ProjectCreateFailure(message, false)
134
+ return new ProjectCreateFailure(`Project create did not finish: ${message}`, true)
135
+ })
136
+ return await runToPromise(create.pipe(
137
+ Effect.mapError(failure => new Error(failure.mayBeCreated ? `${failure.message}\n${projectMayBeCreated(teamSlug, name)}` : failure.message)),
138
+ ))
139
+ }