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
@@ -12,7 +12,6 @@ import {
12
12
  renameSync,
13
13
  rmdirSync,
14
14
  rmSync,
15
- symlinkSync,
16
15
  unlinkSync,
17
16
  writeFileSync,
18
17
  } from 'fs'
@@ -87,6 +86,10 @@ export interface SkillInstallResult {
87
86
  path: string
88
87
  action: 'created' | 'updated' | 'would-create' | 'would-update'
89
88
  mode: 'link' | 'copy' | 'section'
89
+ /** Where a person's own folder at the path was moved before replacing it. */
90
+ backup?: string
91
+ /** Why a person's folder, or part of it, is still beside the skill. */
92
+ warning?: string
90
93
  }
91
94
 
92
95
  export interface SkillInstallMeta {
@@ -384,7 +387,7 @@ export function resolveEffectiveInstallMode(
384
387
  return isStableSkillSourcePath(sourceRoot) ? 'link' : 'copy'
385
388
  }
386
389
 
387
- function shouldCopySkillPath(sourceRoot: string, sourcePath: string): boolean {
390
+ export function shouldCopySkillPath(sourceRoot: string, sourcePath: string): boolean {
388
391
  const rel = relative(sourceRoot, sourcePath).replace(/\\/g, '/')
389
392
  if (!rel) {
390
393
  return true
@@ -402,15 +405,6 @@ function shouldCopySkillPath(sourceRoot: string, sourcePath: string): boolean {
402
405
  ) && !rel.endsWith('.pyc') && !rel.endsWith('.pyo')
403
406
  }
404
407
 
405
- function copySkillTree(sourceDir: string, targetDir: string) {
406
- rmSync(targetDir, { recursive: true, force: true })
407
- mkdirSync(dirname(targetDir), { recursive: true })
408
- cpSync(sourceDir, targetDir, {
409
- recursive: true,
410
- filter: sourcePath => shouldCopySkillPath(sourceDir, sourcePath),
411
- })
412
- }
413
-
414
408
  function stripFrontmatter(markdown: string): string {
415
409
  const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/)
416
410
  if (!match) {
@@ -537,16 +531,10 @@ export function readSkillMeta(targetDir: string): SkillInstallMeta | null {
537
531
  }
538
532
  }
539
533
 
540
- function linkSkillTree(sourceDir: string, targetDir: string) {
541
- rmSync(targetDir, { recursive: true, force: true })
542
- mkdirSync(dirname(targetDir), { recursive: true })
543
- symlinkSync(sourceDir, targetDir, 'dir')
544
- }
545
-
546
- export function installSkillTarget(
534
+ export async function installSkillTarget(
547
535
  target: SkillInstallTarget,
548
536
  options?: SkillInstallOptions
549
- ): SkillInstallResult {
537
+ ): Promise<SkillInstallResult> {
550
538
  const source = resolveSkillSource()
551
539
  const targetPath = getSkillInstallPath(target, options)
552
540
 
@@ -570,18 +558,23 @@ export function installSkillTarget(
570
558
  throw new Error(`${targetPath} already exists. Pass --yes to replace it.`)
571
559
  }
572
560
 
573
- if (mode === 'link') {
574
- linkSkillTree(source.root, targetPath)
575
- } else {
576
- copySkillTree(source.root, targetPath)
577
- writeSkillMeta(targetPath, source, options)
578
- }
561
+ const { runSkillFolderReplacement } = await import('./skill-install-workflow.js')
562
+ const { backup, warning } = await runSkillFolderReplacement({
563
+ sourceRoot: source.root,
564
+ targetPath,
565
+ // `section` is only AGENTS.md, handled above.
566
+ mode: mode === 'link' ? 'link' : 'copy',
567
+ backupPath: uniqueBackupPath(SKILL_NAME, target, options),
568
+ writeMarker: dir => writeSkillMeta(dir, source, options),
569
+ })
579
570
 
580
571
  return {
581
572
  target,
582
573
  path: targetPath,
583
574
  action: exists ? 'updated' : 'created',
584
575
  mode,
576
+ ...(backup ? { backup } : {}),
577
+ ...(warning ? { warning } : {}),
585
578
  }
586
579
  }
587
580
 
@@ -849,9 +842,9 @@ function backupDirectoryFor(options?: SkillInstallOptions): string {
849
842
  return resolve(resolveHome(options), '.orizu', 'skill-backups')
850
843
  }
851
844
 
852
- function uniqueBackupPath(install: LegacySkillInstall, options?: SkillInstallOptions): string {
845
+ function uniqueBackupPath(name: string, target: SkillInstallTarget, options?: SkillInstallOptions): string {
853
846
  const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
854
- const base = join(backupDirectoryFor(options), `${LEGACY_SKILL_NAME}-${stamp}-${install.target}`)
847
+ const base = join(backupDirectoryFor(options), `${name}-${stamp}-${target}`)
855
848
  let candidate = base
856
849
  for (let attempt = 2; pathEntryExists(candidate); attempt += 1) {
857
850
  candidate = `${base}-${attempt}`
@@ -903,16 +896,16 @@ function removeLegacySkillInstall(
903
896
  removeLink(install.path)
904
897
  return null
905
898
  }
906
- const backupPath = uniqueBackupPath(install, options)
899
+ const backupPath = uniqueBackupPath(LEGACY_SKILL_NAME, install.target, options)
907
900
  moveToBackup(install.path, backupPath)
908
901
  return backupPath
909
902
  }
910
903
 
911
- function replaceOneLegacySkillInstall(
904
+ async function replaceOneLegacySkillInstall(
912
905
  install: LegacySkillInstall,
913
906
  options: LegacySkillReplaceOptions | undefined,
914
907
  heldBackReason: string | null
915
- ): LegacySkillReplacement {
908
+ ): Promise<LegacySkillReplacement> {
916
909
  const installedPath = getSkillInstallPath(install.target, options)
917
910
  const needsInstall = !pathEntryExists(installedPath)
918
911
  const result = {
@@ -933,7 +926,7 @@ function replaceOneLegacySkillInstall(
933
926
  let didInstall = false
934
927
  try {
935
928
  if (needsInstall) {
936
- installSkillTarget(install.target, {
929
+ await installSkillTarget(install.target, {
937
930
  ...options,
938
931
  overwrite: false,
939
932
  dryRun: false,
@@ -988,9 +981,9 @@ function heldBackReasonFor(
988
981
  return blockingLink ? `${blockingLink.path} links to it and is left in place` : null
989
982
  }
990
983
 
991
- export function replaceLegacySkillInstalls(
984
+ export async function replaceLegacySkillInstalls(
992
985
  options?: LegacySkillReplaceOptions
993
- ): { replacements: LegacySkillReplacement[], problems: LegacySkillProblem[] } {
986
+ ): Promise<{ replacements: LegacySkillReplacement[], problems: LegacySkillProblem[] }> {
994
987
  const { installs, problems } = findLegacySkillInstalls(options)
995
988
  // Remove links before folders, so no link is left pointing at a moved folder.
996
989
  const ordered = [
@@ -998,10 +991,10 @@ export function replaceLegacySkillInstalls(
998
991
  ...installs.filter(install => install.kind === 'directory'),
999
992
  ]
1000
993
  const reasons = new Map(ordered.map(install => [install, heldBackReasonFor(install, installs, options)]))
1001
- return {
1002
- replacements: ordered.map(install =>
1003
- replaceOneLegacySkillInstall(install, options, reasons.get(install) ?? null)
1004
- ),
1005
- problems,
994
+ // One at a time, in order: links go before the folders they point to.
995
+ const replacements: LegacySkillReplacement[] = []
996
+ for (const install of ordered) {
997
+ replacements.push(await replaceOneLegacySkillInstall(install, options, reasons.get(install) ?? null))
1006
998
  }
999
+ return { replacements, problems }
1007
1000
  }
@@ -0,0 +1,316 @@
1
+ import { Cause, Effect, Exit, Result, Schema } from 'effect'
2
+
3
+ import {
4
+ clearServerCredentialsIfCurrent,
5
+ credentialsEqual,
6
+ getServerCredentials,
7
+ rememberResolvedBearer,
8
+ updateServerCredentialsIfCurrent,
9
+ withCredentialsTransactionLockAsync,
10
+ } from './credentials.js'
11
+ import { assertSecureTokenTransport, credentialRequestRedirectPolicy } from './http.js'
12
+ import { describeLogoutHttpFailure, describeLogoutTransportFailure } from './logout-diagnostic.js'
13
+ import { ServerCredentials, SessionServerCredentials } from './types.js'
14
+
15
+ // Requests signed with a stored login (`orizu login`): the sign-in refresh, the
16
+ // request and its one replay after a 401, and logout. Loaded only on that path;
17
+ // ORIZU_TOKEN and ORIZU_TOKEN_FILE requests stay in http.ts and never load this.
18
+
19
+ function isSessionCredentials(credentials: ServerCredentials): credentials is SessionServerCredentials {
20
+ return 'accessToken' in credentials
21
+ }
22
+
23
+ function rememberSessionCredentials(credentials: SessionServerCredentials): SessionServerCredentials {
24
+ rememberResolvedBearer(credentials.accessToken)
25
+ rememberResolvedBearer(credentials.refreshToken)
26
+ return credentials
27
+ }
28
+
29
+ function getAuthorizationToken(credentials: ServerCredentials): string {
30
+ return isSessionCredentials(credentials)
31
+ ? rememberSessionCredentials(credentials).accessToken
32
+ : rememberResolvedBearer(credentials.apiKey)
33
+ }
34
+
35
+ function isExpired(expiresAt: number): boolean {
36
+ const nowUnix = Math.floor(Date.now() / 1000)
37
+ return expiresAt <= nowUnix + 30
38
+ }
39
+
40
+ function boundedTimeoutMs(name: string, fallback: number, maximum: number): number {
41
+ const configured = Number(process.env[name])
42
+ if (!Number.isSafeInteger(configured) || configured <= 0) return fallback
43
+ return Math.min(configured, maximum)
44
+ }
45
+
46
+ const isRedirectRefusal = (error: unknown) => error instanceof TypeError
47
+ && error.cause instanceof Error && /redirect/i.test(error.cause.message)
48
+
49
+ /** fetch under the CLI's redirect policy, saying so when that policy refused a redirect. */
50
+ async function credentialFetch(baseUrl: string, path: string, init: RequestInit): Promise<Response> {
51
+ const policy = credentialRequestRedirectPolicy()
52
+ try {
53
+ return await fetch(`${baseUrl}${path}`, { ...init, ...policy })
54
+ } catch (error) {
55
+ if (policy.redirect === 'error' && isRedirectRefusal(error)) {
56
+ throw new Error(`Refusing to follow a redirect from ${baseUrl} with CLI credentials.`, { cause: error })
57
+ }
58
+ throw error
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Runs `program`, stopped by the caller's signal or `stop`. A stop by either
64
+ * rejects with that signal's reason, as an aborted fetch would.
65
+ */
66
+ async function runStoppable<A>(program: Effect.Effect<A, unknown>, ...signals: Array<AbortSignal | null | undefined>): Promise<A> {
67
+ const present = signals.filter((signal): signal is AbortSignal => Boolean(signal))
68
+ const exit = await Effect.runPromiseExit(program, { signal: AbortSignal.any(present) })
69
+ if (Exit.isSuccess(exit)) return exit.value
70
+ const stoppedBy = present.find(signal => signal.aborted)
71
+ if (stoppedBy && Cause.hasInterruptsOnly(exit.cause)) throw stoppedBy.reason
72
+ throw Cause.squash(exit.cause)
73
+ }
74
+
75
+ const RefreshReply = Schema.Struct({
76
+ accessToken: Schema.NonEmptyString,
77
+ refreshToken: Schema.NonEmptyString,
78
+ expiresAt: Schema.Finite,
79
+ })
80
+
81
+ const INVALID_REFRESH = 'Server returned invalid refresh credentials. Run `orizu login` again.'
82
+
83
+ function refreshRequest(baseUrl: string, credentials: SessionServerCredentials): Effect.Effect<SessionServerCredentials, Error> {
84
+ const timeoutMs = boundedTimeoutMs('ORIZU_CREDENTIALS_REFRESH_TIMEOUT_MS', 10_000, 30_000)
85
+ const refreshToken = rememberResolvedBearer(credentials.refreshToken)
86
+ return Effect.tryPromise({
87
+ try: async signal => {
88
+ const response = await credentialFetch(baseUrl, '/api/cli/auth/refresh', {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ refreshToken }),
92
+ signal,
93
+ })
94
+ if (!response.ok) throw new Error('Session expired. Run `orizu login` again.')
95
+ // Reading the body stays under the deadline too.
96
+ try {
97
+ return await response.json() as unknown
98
+ } catch (error) {
99
+ throw error instanceof SyntaxError ? new Error(INVALID_REFRESH) : error
100
+ }
101
+ },
102
+ catch: cause => cause instanceof Error ? cause : new Error(String(cause)),
103
+ }).pipe(
104
+ Effect.timeoutOrElse({
105
+ duration: timeoutMs,
106
+ orElse: () => Effect.fail(new Error(`ORIZU_CREDENTIALS_REFRESH_TIMEOUT: session refresh exceeded ${timeoutMs}ms.`)),
107
+ }),
108
+ Effect.flatMap(data => {
109
+ const decoded = Schema.decodeUnknownResult(RefreshReply)(data)
110
+ if (Result.isFailure(decoded) || decoded.success.expiresAt === 0) return Effect.fail(new Error(INVALID_REFRESH))
111
+ const refreshed = rememberSessionCredentials({ credentialType: 'session', ...decoded.success })
112
+ if (isExpired(refreshed.expiresAt)) {
113
+ return Effect.fail(new Error(
114
+ 'ORIZU_CREDENTIALS_REFRESH_UNUSABLE: refreshed session expires within the 30-second safety window.'
115
+ ))
116
+ }
117
+ return Effect.succeed(refreshed)
118
+ }),
119
+ )
120
+ }
121
+
122
+ const LOCK_STOP_SIGNALS = ['SIGINT', 'SIGTERM'] as const
123
+
124
+ /**
125
+ * Stop handling for the time the credentials lock is held across a refresh.
126
+ * Without it a Ctrl-C ends the CLI with credentials.json.lock left behind, and
127
+ * every later refresh and logout fails until someone deletes it by hand. The
128
+ * handlers are added before the lock is taken and removed after it is
129
+ * released, so no stop lands between the two. A stop aborts the refresh; the
130
+ * lock is released as it unwinds; then the CLI ends by the same signal, unless
131
+ * the caller's own request was cancelled by it: then the command that owns the
132
+ * stop (run-gepa, a download) reports and ends the CLI itself. Other listeners
133
+ * don't count as owners: the git step's listener steps aside whenever another
134
+ * one is present.
135
+ */
136
+ function lockStopWindow(callerSignal: AbortSignal | null | undefined) {
137
+ const controller = new AbortController()
138
+ let stoppedBy: NodeJS.Signals | null = null
139
+ const handlers = LOCK_STOP_SIGNALS.map(signal => ({
140
+ signal,
141
+ handle: () => {
142
+ if (stoppedBy) return
143
+ stoppedBy = signal
144
+ controller.abort(new Error(`Session refresh stopped by ${signal}.`))
145
+ },
146
+ }))
147
+ return {
148
+ signal: controller.signal,
149
+ listen(): void { for (const entry of handlers) process.on(entry.signal, entry.handle) },
150
+ unlisten(): void { for (const entry of handlers) process.off(entry.signal, entry.handle) },
151
+ /**
152
+ * After the lock is released and the handlers are gone: end by the stop
153
+ * signal, if there was one. With no listener left the signal ends the CLI
154
+ * at once; a remaining listener gets it when the event loop next polls,
155
+ * which a second check phase is always after. If the CLI is still running
156
+ * then, a listener owns the stop
157
+ * (run-gepa may not have cancelled the request yet), and the refresh fails
158
+ * with the stop as its reason so the command can unwind and end itself.
159
+ */
160
+ async endIfStopped(): Promise<void> {
161
+ if (!stoppedBy || callerSignal?.aborted) return
162
+ process.kill(process.pid, stoppedBy)
163
+ for (let turn = 0; turn < 2; turn += 1) await new Promise(resolve => setImmediate(resolve))
164
+ },
165
+ }
166
+ }
167
+
168
+ interface StoredRefreshResult {
169
+ status: 'refreshed' | 'changed'
170
+ credentials: ServerCredentials | null
171
+ }
172
+
173
+ async function refreshStoredCredentialsIfCurrent(
174
+ baseUrl: string,
175
+ expected: ServerCredentials,
176
+ callerSignal?: AbortSignal | null
177
+ ): Promise<StoredRefreshResult> {
178
+ if (!isSessionCredentials(expected)) {
179
+ throw new Error('API key credentials do not refresh. Run `orizu login` again if access fails.')
180
+ }
181
+ assertSecureTokenTransport(baseUrl)
182
+ const stop = lockStopWindow(callerSignal)
183
+ stop.listen()
184
+ try {
185
+ return await withCredentialsTransactionLockAsync(async () => {
186
+ const current = getServerCredentials(baseUrl)
187
+ if (!current || !credentialsEqual(current, expected)) {
188
+ return { status: 'changed', credentials: current }
189
+ }
190
+ const refreshed = await runStoppable(refreshRequest(baseUrl, expected), callerSignal, stop.signal)
191
+ // Written inside the lock's own async context, never from the Effect fiber.
192
+ if (!updateServerCredentialsIfCurrent(baseUrl, expected, refreshed)) {
193
+ return { status: 'changed', credentials: getServerCredentials(baseUrl) }
194
+ }
195
+ return { status: 'refreshed', credentials: refreshed }
196
+ })
197
+ } finally {
198
+ stop.unlisten()
199
+ await stop.endIfStopped()
200
+ }
201
+ }
202
+
203
+ function requireCurrentCredentials(baseUrl: string, credentials: ServerCredentials | null): ServerCredentials {
204
+ if (credentials) return credentials
205
+ throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`)
206
+ }
207
+
208
+ /**
209
+ * Marks an error raised after the server answered 401 and before the request
210
+ * was sent again: the server refused the request, so nothing was written
211
+ * (ORI-2431). The message is unchanged.
212
+ */
213
+ function markRequestRejected(error: unknown): unknown {
214
+ if (error instanceof Error && Object.isExtensible(error)) Object.assign(error, { requestRejected: true })
215
+ return error
216
+ }
217
+
218
+ export interface StoredRequestOptions {
219
+ baseUrl: string
220
+ credentials: ServerCredentials
221
+ allowRefresh: boolean
222
+ /** Messages when another login replaced the stored one, before the first request and before the replay. */
223
+ changedBeforeRequest: string
224
+ changedBeforeReplay: string
225
+ /** Called with each refreshed login as soon as it is stored, even if the send after it fails. */
226
+ onRefreshed?: (credentials: ServerCredentials) => void
227
+ }
228
+
229
+ /**
230
+ * Refresh first when the login has expired, then send. On a 401, refresh once
231
+ * and send once more, only if the stored login is still the one this request
232
+ * used. `onRequestStart` runs before each send, never for the refresh.
233
+ */
234
+ export async function storedRequest(
235
+ options: StoredRequestOptions,
236
+ path: string,
237
+ init: RequestInit,
238
+ onRequestStart?: () => void
239
+ ): Promise<Response> {
240
+ const { baseUrl } = options
241
+ let credentials = options.credentials
242
+ const refresh = async (changedMessage: string) => {
243
+ const result = await refreshStoredCredentialsIfCurrent(baseUrl, credentials, init.signal)
244
+ if (result.status === 'changed') throw new Error(changedMessage)
245
+ credentials = requireCurrentCredentials(baseUrl, result.credentials)
246
+ options.onRefreshed?.(credentials)
247
+ }
248
+ const send = () => {
249
+ onRequestStart?.()
250
+ return credentialFetch(baseUrl, path, {
251
+ ...init,
252
+ headers: { ...(init.headers || {}), Authorization: `Bearer ${getAuthorizationToken(credentials)}` },
253
+ })
254
+ }
255
+
256
+ let didRefresh = false
257
+ if (options.allowRefresh && isSessionCredentials(credentials) && isExpired(credentials.expiresAt)) {
258
+ await refresh(options.changedBeforeRequest)
259
+ didRefresh = true
260
+ }
261
+ let response = await send()
262
+ if (options.allowRefresh && response.status === 401 && isSessionCredentials(credentials) && !didRefresh) {
263
+ try {
264
+ await refresh(options.changedBeforeReplay)
265
+ } catch (error) {
266
+ // A stop by the caller's own signal is the caller's to describe.
267
+ throw error === init.signal?.reason ? error : markRequestRejected(error)
268
+ }
269
+ response = await send()
270
+ }
271
+ return response
272
+ }
273
+
274
+ export interface LogoutResult {
275
+ remoteError: string | null
276
+ localCredentialCleared: boolean
277
+ }
278
+
279
+ /**
280
+ * Revoke the login on the server within ORIZU_LOGOUT_TIMEOUT_MS, then remove it
281
+ * locally only if it is still the stored one: another login saved meanwhile is kept.
282
+ */
283
+ export async function storedLogout(
284
+ baseUrl: string,
285
+ credentialKey: string,
286
+ credentials: ServerCredentials,
287
+ callerSignal?: AbortSignal | null
288
+ ): Promise<LogoutResult> {
289
+ const authorizationToken = getAuthorizationToken(credentials)
290
+ const refreshToken = isSessionCredentials(credentials) ? rememberResolvedBearer(credentials.refreshToken) : null
291
+ const secrets = [authorizationToken, ...(refreshToken !== null ? [refreshToken] : [])]
292
+ const timeoutMs = boundedTimeoutMs('ORIZU_LOGOUT_TIMEOUT_MS', 10_000, 30_000)
293
+ const timeoutError = Object.assign(new Error(`ORIZU_LOGOUT_TIMEOUT: remote logout exceeded ${timeoutMs}ms.`), { name: 'TimeoutError' })
294
+ const remote = Effect.tryPromise({
295
+ try: async signal => {
296
+ assertSecureTokenTransport(baseUrl)
297
+ const response = await credentialFetch(baseUrl, '/api/cli/auth/logout', {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authorizationToken}` },
300
+ body: refreshToken !== null ? JSON.stringify({ refreshToken }) : undefined,
301
+ signal,
302
+ })
303
+ return response.ok ? null : await describeLogoutHttpFailure(response, secrets)
304
+ },
305
+ catch: error => error,
306
+ }).pipe(Effect.timeoutOrElse({ duration: timeoutMs, orElse: () => Effect.fail(timeoutError) }))
307
+
308
+ let remoteError: string | null
309
+ try {
310
+ remoteError = await runStoppable(remote, callerSignal)
311
+ } catch (error) {
312
+ remoteError = describeLogoutTransportFailure(error, secrets)
313
+ }
314
+ const localCredentialCleared = clearServerCredentialsIfCurrent(credentialKey, credentials)
315
+ return { remoteError, localCredentialCleared }
316
+ }
@@ -0,0 +1,88 @@
1
+ // Reads keys from the person's terminal for setup's pickers. Loaded only
2
+ // through `await import(...)`, so commands without a picker never load Effect.
3
+ import { emitKeypressEvents } from 'readline'
4
+ import { stdin as input, stdout as output } from 'process'
5
+
6
+ import { Effect } from 'effect'
7
+
8
+ export interface KeypressInfo {
9
+ name?: string
10
+ sequence?: string
11
+ ctrl?: boolean
12
+ }
13
+
14
+ /** What a key does: nothing yet, finish with a value, or cancel setup. */
15
+ export type KeyOutcome<T> = { kind: 'wait' } | { kind: 'done', value: T } | { kind: 'cancel' }
16
+
17
+ type RawInput = typeof input & {
18
+ setRawMode?: (mode: boolean) => void
19
+ isRaw?: boolean
20
+ }
21
+
22
+ // The terminal goes into raw mode (keys arrive one by one, nothing is echoed)
23
+ // and, for pickers, the cursor is hidden. The release always undoes both.
24
+ const rawKeys = (hideCursor: boolean) => Effect.acquireRelease(
25
+ Effect.sync(() => {
26
+ const rawInput = input as RawInput
27
+ const wasRaw = Boolean(rawInput.isRaw)
28
+ emitKeypressEvents(input)
29
+ if (hideCursor) output.write('\x1b[?25l')
30
+ input.resume()
31
+ rawInput.setRawMode?.(true)
32
+ return wasRaw
33
+ }),
34
+ wasRaw => Effect.sync(() => {
35
+ const rawInput = input as RawInput
36
+ if (rawInput.setRawMode && !wasRaw) rawInput.setRawMode(false)
37
+ input.pause()
38
+ if (hideCursor) output.write('\x1b[?25h')
39
+ }),
40
+ )
41
+
42
+ const nextOutcome = <T>(onKey: (key: KeypressInfo) => KeyOutcome<T>) => Effect.acquireUseRelease(
43
+ Effect.sync(() => ({ listener: null as ((chunk: string, key: KeypressInfo) => void) | null })),
44
+ held => Effect.callback<T, Error>(resume => {
45
+ held.listener = (_chunk, key) => {
46
+ // Ctrl-C arrives as a key in raw mode, so every picker cancels on it.
47
+ const outcome: KeyOutcome<T> = key.ctrl && key.name === 'c' ? { kind: 'cancel' } : onKey(key)
48
+ if (outcome.kind === 'done') resume(Effect.succeed(outcome.value))
49
+ else if (outcome.kind === 'cancel') resume(Effect.fail(new Error('Setup cancelled.')))
50
+ }
51
+ input.on('keypress', held.listener)
52
+ }),
53
+ held => Effect.sync(() => { if (held.listener) input.off('keypress', held.listener) }),
54
+ )
55
+
56
+ const STOP_SIGNALS = ['SIGINT', 'SIGTERM'] as const
57
+
58
+ /**
59
+ * Hands each key to `onKey` until it finishes or cancels. A SIGINT or SIGTERM
60
+ * from outside ends the wait, puts the terminal back, then ends the process
61
+ * by that same signal.
62
+ */
63
+ export async function readKeys<T>(onKey: (key: KeypressInfo) => KeyOutcome<T>, options: { hideCursor: boolean }): Promise<T> {
64
+ const controller = new AbortController()
65
+ const stop: { by: NodeJS.Signals | null } = { by: null }
66
+ const handlers = STOP_SIGNALS.map(signal => [signal, () => { stop.by = signal; controller.abort() }] as const)
67
+ for (const [signal, handler] of handlers) process.once(signal, handler)
68
+ try {
69
+ const value = await Effect.runPromise(Effect.scoped(Effect.andThen(rawKeys(options.hideCursor), nextOutcome(onKey))), { signal: controller.signal })
70
+ if (stop.by) return await endBy(stop.by, handlers)
71
+ return value
72
+ } catch (error: unknown) {
73
+ if (!stop.by) throw error
74
+ return await endBy(stop.by, handlers)
75
+ } finally {
76
+ for (const [signal, handler] of handlers) process.removeListener(signal, handler)
77
+ }
78
+ }
79
+
80
+ /** Ends the process by the signal that stopped it, once its handlers are gone. */
81
+ async function endBy(signal: NodeJS.Signals, handlers: ReadonlyArray<readonly [NodeJS.Signals, () => void]>): Promise<never> {
82
+ for (const [name, handler] of handlers) process.removeListener(name, handler)
83
+ output.write('\n')
84
+ process.kill(process.pid, signal)
85
+ // The signal ends the process; if another listener holds it, exit as a shell would.
86
+ setTimeout(() => process.exit(signal === 'SIGINT' ? 130 : 143), 1_000)
87
+ return await new Promise<never>(() => {})
88
+ }