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
@@ -1,6 +1,6 @@
1
1
  // Reads the bundled skill (the repository copy when run from source) and the install targets it is given.
2
2
  // @tia-reads: skills/orizu
3
- import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'fs';
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync, } from 'fs';
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { homedir } from 'os';
@@ -266,7 +266,7 @@ export function resolveEffectiveInstallMode(target, sourceRoot, mode = 'auto') {
266
266
  }
267
267
  return isStableSkillSourcePath(sourceRoot) ? 'link' : 'copy';
268
268
  }
269
- function shouldCopySkillPath(sourceRoot, sourcePath) {
269
+ export function shouldCopySkillPath(sourceRoot, sourcePath) {
270
270
  const rel = relative(sourceRoot, sourcePath).replace(/\\/g, '/');
271
271
  if (!rel) {
272
272
  return true;
@@ -280,14 +280,6 @@ function shouldCopySkillPath(sourceRoot, sourcePath) {
280
280
  part === '.pytest_cache' ||
281
281
  part === SKILL_META_FILENAME) && !rel.endsWith('.pyc') && !rel.endsWith('.pyo');
282
282
  }
283
- function copySkillTree(sourceDir, targetDir) {
284
- rmSync(targetDir, { recursive: true, force: true });
285
- mkdirSync(dirname(targetDir), { recursive: true });
286
- cpSync(sourceDir, targetDir, {
287
- recursive: true,
288
- filter: sourcePath => shouldCopySkillPath(sourceDir, sourcePath),
289
- });
290
- }
291
283
  function stripFrontmatter(markdown) {
292
284
  const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
293
285
  if (!match) {
@@ -384,12 +376,7 @@ export function readSkillMeta(targetDir) {
384
376
  return null;
385
377
  }
386
378
  }
387
- function linkSkillTree(sourceDir, targetDir) {
388
- rmSync(targetDir, { recursive: true, force: true });
389
- mkdirSync(dirname(targetDir), { recursive: true });
390
- symlinkSync(sourceDir, targetDir, 'dir');
391
- }
392
- export function installSkillTarget(target, options) {
379
+ export async function installSkillTarget(target, options) {
393
380
  const source = resolveSkillSource();
394
381
  const targetPath = getSkillInstallPath(target, options);
395
382
  if (target === 'agents-md') {
@@ -408,18 +395,22 @@ export function installSkillTarget(target, options) {
408
395
  if (exists && !options?.overwrite) {
409
396
  throw new Error(`${targetPath} already exists. Pass --yes to replace it.`);
410
397
  }
411
- if (mode === 'link') {
412
- linkSkillTree(source.root, targetPath);
413
- }
414
- else {
415
- copySkillTree(source.root, targetPath);
416
- writeSkillMeta(targetPath, source, options);
417
- }
398
+ const { runSkillFolderReplacement } = await import('./skill-install-workflow.js');
399
+ const { backup, warning } = await runSkillFolderReplacement({
400
+ sourceRoot: source.root,
401
+ targetPath,
402
+ // `section` is only AGENTS.md, handled above.
403
+ mode: mode === 'link' ? 'link' : 'copy',
404
+ backupPath: uniqueBackupPath(SKILL_NAME, target, options),
405
+ writeMarker: dir => writeSkillMeta(dir, source, options),
406
+ });
418
407
  return {
419
408
  target,
420
409
  path: targetPath,
421
410
  action: exists ? 'updated' : 'created',
422
411
  mode,
412
+ ...(backup ? { backup } : {}),
413
+ ...(warning ? { warning } : {}),
423
414
  };
424
415
  }
425
416
  function getAgentsMdStatus(targetPath, source, sourceHash) {
@@ -586,9 +577,9 @@ export function findLegacySkillInstalls(options) {
586
577
  function backupDirectoryFor(options) {
587
578
  return resolve(resolveHome(options), '.orizu', 'skill-backups');
588
579
  }
589
- function uniqueBackupPath(install, options) {
580
+ function uniqueBackupPath(name, target, options) {
590
581
  const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
591
- const base = join(backupDirectoryFor(options), `${LEGACY_SKILL_NAME}-${stamp}-${install.target}`);
582
+ const base = join(backupDirectoryFor(options), `${name}-${stamp}-${target}`);
592
583
  let candidate = base;
593
584
  for (let attempt = 2; pathEntryExists(candidate); attempt += 1) {
594
585
  candidate = `${base}-${attempt}`;
@@ -636,11 +627,11 @@ function removeLegacySkillInstall(install, options) {
636
627
  removeLink(install.path);
637
628
  return null;
638
629
  }
639
- const backupPath = uniqueBackupPath(install, options);
630
+ const backupPath = uniqueBackupPath(LEGACY_SKILL_NAME, install.target, options);
640
631
  moveToBackup(install.path, backupPath);
641
632
  return backupPath;
642
633
  }
643
- function replaceOneLegacySkillInstall(install, options, heldBackReason) {
634
+ async function replaceOneLegacySkillInstall(install, options, heldBackReason) {
644
635
  const installedPath = getSkillInstallPath(install.target, options);
645
636
  const needsInstall = !pathEntryExists(installedPath);
646
637
  const result = {
@@ -659,7 +650,7 @@ function replaceOneLegacySkillInstall(install, options, heldBackReason) {
659
650
  let didInstall = false;
660
651
  try {
661
652
  if (needsInstall) {
662
- installSkillTarget(install.target, {
653
+ await installSkillTarget(install.target, {
663
654
  ...options,
664
655
  overwrite: false,
665
656
  dryRun: false,
@@ -707,7 +698,7 @@ function heldBackReasonFor(install, installs, options) {
707
698
  && (!isSelected(other.target) || Boolean(options?.heldBack?.[other.target])));
708
699
  return blockingLink ? `${blockingLink.path} links to it and is left in place` : null;
709
700
  }
710
- export function replaceLegacySkillInstalls(options) {
701
+ export async function replaceLegacySkillInstalls(options) {
711
702
  const { installs, problems } = findLegacySkillInstalls(options);
712
703
  // Remove links before folders, so no link is left pointing at a moved folder.
713
704
  const ordered = [
@@ -715,8 +706,10 @@ export function replaceLegacySkillInstalls(options) {
715
706
  ...installs.filter(install => install.kind === 'directory'),
716
707
  ];
717
708
  const reasons = new Map(ordered.map(install => [install, heldBackReasonFor(install, installs, options)]));
718
- return {
719
- replacements: ordered.map(install => replaceOneLegacySkillInstall(install, options, reasons.get(install) ?? null)),
720
- problems,
721
- };
709
+ // One at a time, in order: links go before the folders they point to.
710
+ const replacements = [];
711
+ for (const install of ordered) {
712
+ replacements.push(await replaceOneLegacySkillInstall(install, options, reasons.get(install) ?? null));
713
+ }
714
+ return { replacements, problems };
722
715
  }
@@ -0,0 +1,27 @@
1
+ import { ServerCredentials } from './types.js';
2
+ export interface StoredRequestOptions {
3
+ baseUrl: string;
4
+ credentials: ServerCredentials;
5
+ allowRefresh: boolean;
6
+ /** Messages when another login replaced the stored one, before the first request and before the replay. */
7
+ changedBeforeRequest: string;
8
+ changedBeforeReplay: string;
9
+ /** Called with each refreshed login as soon as it is stored, even if the send after it fails. */
10
+ onRefreshed?: (credentials: ServerCredentials) => void;
11
+ }
12
+ /**
13
+ * Refresh first when the login has expired, then send. On a 401, refresh once
14
+ * and send once more, only if the stored login is still the one this request
15
+ * used. `onRequestStart` runs before each send, never for the refresh.
16
+ */
17
+ export declare function storedRequest(options: StoredRequestOptions, path: string, init: RequestInit, onRequestStart?: () => void): Promise<Response>;
18
+ export interface LogoutResult {
19
+ remoteError: string | null;
20
+ localCredentialCleared: boolean;
21
+ }
22
+ /**
23
+ * Revoke the login on the server within ORIZU_LOGOUT_TIMEOUT_MS, then remove it
24
+ * locally only if it is still the stored one: another login saved meanwhile is kept.
25
+ */
26
+ export declare function storedLogout(baseUrl: string, credentialKey: string, credentials: ServerCredentials, callerSignal?: AbortSignal | null): Promise<LogoutResult>;
27
+ //# sourceMappingURL=stored-session-requests.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stored-session-requests.d.ts","sourceRoot":"","sources":["../src/stored-session-requests.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,iBAAiB,EAA4B,MAAM,YAAY,CAAA;AA6MxE,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,iBAAiB,CAAA;IAC9B,YAAY,EAAE,OAAO,CAAA;IACrB,2GAA2G;IAC3G,oBAAoB,EAAE,MAAM,CAAA;IAC5B,mBAAmB,EAAE,MAAM,CAAA;IAC3B,iGAAiG;IACjG,WAAW,CAAC,EAAE,CAAC,WAAW,EAAE,iBAAiB,KAAK,IAAI,CAAA;CACvD;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,oBAAoB,EAC7B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,WAAW,EACjB,cAAc,CAAC,EAAE,MAAM,IAAI,GAC1B,OAAO,CAAC,QAAQ,CAAC,CAiCnB;AAED,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,sBAAsB,EAAE,OAAO,CAAA;CAChC;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,iBAAiB,EAC9B,YAAY,CAAC,EAAE,WAAW,GAAG,IAAI,GAChC,OAAO,CAAC,YAAY,CAAC,CA4BvB"}
@@ -0,0 +1,264 @@
1
+ import { Cause, Effect, Exit, Result, Schema } from 'effect';
2
+ import { clearServerCredentialsIfCurrent, credentialsEqual, getServerCredentials, rememberResolvedBearer, updateServerCredentialsIfCurrent, withCredentialsTransactionLockAsync, } from './credentials.js';
3
+ import { assertSecureTokenTransport, credentialRequestRedirectPolicy } from './http.js';
4
+ import { describeLogoutHttpFailure, describeLogoutTransportFailure } from './logout-diagnostic.js';
5
+ // Requests signed with a stored login (`orizu login`): the sign-in refresh, the
6
+ // request and its one replay after a 401, and logout. Loaded only on that path;
7
+ // ORIZU_TOKEN and ORIZU_TOKEN_FILE requests stay in http.ts and never load this.
8
+ function isSessionCredentials(credentials) {
9
+ return 'accessToken' in credentials;
10
+ }
11
+ function rememberSessionCredentials(credentials) {
12
+ rememberResolvedBearer(credentials.accessToken);
13
+ rememberResolvedBearer(credentials.refreshToken);
14
+ return credentials;
15
+ }
16
+ function getAuthorizationToken(credentials) {
17
+ return isSessionCredentials(credentials)
18
+ ? rememberSessionCredentials(credentials).accessToken
19
+ : rememberResolvedBearer(credentials.apiKey);
20
+ }
21
+ function isExpired(expiresAt) {
22
+ const nowUnix = Math.floor(Date.now() / 1000);
23
+ return expiresAt <= nowUnix + 30;
24
+ }
25
+ function boundedTimeoutMs(name, fallback, maximum) {
26
+ const configured = Number(process.env[name]);
27
+ if (!Number.isSafeInteger(configured) || configured <= 0)
28
+ return fallback;
29
+ return Math.min(configured, maximum);
30
+ }
31
+ const isRedirectRefusal = (error) => error instanceof TypeError
32
+ && error.cause instanceof Error && /redirect/i.test(error.cause.message);
33
+ /** fetch under the CLI's redirect policy, saying so when that policy refused a redirect. */
34
+ async function credentialFetch(baseUrl, path, init) {
35
+ const policy = credentialRequestRedirectPolicy();
36
+ try {
37
+ return await fetch(`${baseUrl}${path}`, { ...init, ...policy });
38
+ }
39
+ catch (error) {
40
+ if (policy.redirect === 'error' && isRedirectRefusal(error)) {
41
+ throw new Error(`Refusing to follow a redirect from ${baseUrl} with CLI credentials.`, { cause: error });
42
+ }
43
+ throw error;
44
+ }
45
+ }
46
+ /**
47
+ * Runs `program`, stopped by the caller's signal or `stop`. A stop by either
48
+ * rejects with that signal's reason, as an aborted fetch would.
49
+ */
50
+ async function runStoppable(program, ...signals) {
51
+ const present = signals.filter((signal) => Boolean(signal));
52
+ const exit = await Effect.runPromiseExit(program, { signal: AbortSignal.any(present) });
53
+ if (Exit.isSuccess(exit))
54
+ return exit.value;
55
+ const stoppedBy = present.find(signal => signal.aborted);
56
+ if (stoppedBy && Cause.hasInterruptsOnly(exit.cause))
57
+ throw stoppedBy.reason;
58
+ throw Cause.squash(exit.cause);
59
+ }
60
+ const RefreshReply = Schema.Struct({
61
+ accessToken: Schema.NonEmptyString,
62
+ refreshToken: Schema.NonEmptyString,
63
+ expiresAt: Schema.Finite,
64
+ });
65
+ const INVALID_REFRESH = 'Server returned invalid refresh credentials. Run `orizu login` again.';
66
+ function refreshRequest(baseUrl, credentials) {
67
+ const timeoutMs = boundedTimeoutMs('ORIZU_CREDENTIALS_REFRESH_TIMEOUT_MS', 10_000, 30_000);
68
+ const refreshToken = rememberResolvedBearer(credentials.refreshToken);
69
+ return Effect.tryPromise({
70
+ try: async (signal) => {
71
+ const response = await credentialFetch(baseUrl, '/api/cli/auth/refresh', {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ refreshToken }),
75
+ signal,
76
+ });
77
+ if (!response.ok)
78
+ throw new Error('Session expired. Run `orizu login` again.');
79
+ // Reading the body stays under the deadline too.
80
+ try {
81
+ return await response.json();
82
+ }
83
+ catch (error) {
84
+ throw error instanceof SyntaxError ? new Error(INVALID_REFRESH) : error;
85
+ }
86
+ },
87
+ catch: cause => cause instanceof Error ? cause : new Error(String(cause)),
88
+ }).pipe(Effect.timeoutOrElse({
89
+ duration: timeoutMs,
90
+ orElse: () => Effect.fail(new Error(`ORIZU_CREDENTIALS_REFRESH_TIMEOUT: session refresh exceeded ${timeoutMs}ms.`)),
91
+ }), Effect.flatMap(data => {
92
+ const decoded = Schema.decodeUnknownResult(RefreshReply)(data);
93
+ if (Result.isFailure(decoded) || decoded.success.expiresAt === 0)
94
+ return Effect.fail(new Error(INVALID_REFRESH));
95
+ const refreshed = rememberSessionCredentials({ credentialType: 'session', ...decoded.success });
96
+ if (isExpired(refreshed.expiresAt)) {
97
+ return Effect.fail(new Error('ORIZU_CREDENTIALS_REFRESH_UNUSABLE: refreshed session expires within the 30-second safety window.'));
98
+ }
99
+ return Effect.succeed(refreshed);
100
+ }));
101
+ }
102
+ const LOCK_STOP_SIGNALS = ['SIGINT', 'SIGTERM'];
103
+ /**
104
+ * Stop handling for the time the credentials lock is held across a refresh.
105
+ * Without it a Ctrl-C ends the CLI with credentials.json.lock left behind, and
106
+ * every later refresh and logout fails until someone deletes it by hand. The
107
+ * handlers are added before the lock is taken and removed after it is
108
+ * released, so no stop lands between the two. A stop aborts the refresh; the
109
+ * lock is released as it unwinds; then the CLI ends by the same signal, unless
110
+ * the caller's own request was cancelled by it: then the command that owns the
111
+ * stop (run-gepa, a download) reports and ends the CLI itself. Other listeners
112
+ * don't count as owners: the git step's listener steps aside whenever another
113
+ * one is present.
114
+ */
115
+ function lockStopWindow(callerSignal) {
116
+ const controller = new AbortController();
117
+ let stoppedBy = null;
118
+ const handlers = LOCK_STOP_SIGNALS.map(signal => ({
119
+ signal,
120
+ handle: () => {
121
+ if (stoppedBy)
122
+ return;
123
+ stoppedBy = signal;
124
+ controller.abort(new Error(`Session refresh stopped by ${signal}.`));
125
+ },
126
+ }));
127
+ return {
128
+ signal: controller.signal,
129
+ listen() { for (const entry of handlers)
130
+ process.on(entry.signal, entry.handle); },
131
+ unlisten() { for (const entry of handlers)
132
+ process.off(entry.signal, entry.handle); },
133
+ /**
134
+ * After the lock is released and the handlers are gone: end by the stop
135
+ * signal, if there was one. With no listener left the signal ends the CLI
136
+ * at once; a remaining listener gets it when the event loop next polls,
137
+ * which a second check phase is always after. If the CLI is still running
138
+ * then, a listener owns the stop
139
+ * (run-gepa may not have cancelled the request yet), and the refresh fails
140
+ * with the stop as its reason so the command can unwind and end itself.
141
+ */
142
+ async endIfStopped() {
143
+ if (!stoppedBy || callerSignal?.aborted)
144
+ return;
145
+ process.kill(process.pid, stoppedBy);
146
+ for (let turn = 0; turn < 2; turn += 1)
147
+ await new Promise(resolve => setImmediate(resolve));
148
+ },
149
+ };
150
+ }
151
+ async function refreshStoredCredentialsIfCurrent(baseUrl, expected, callerSignal) {
152
+ if (!isSessionCredentials(expected)) {
153
+ throw new Error('API key credentials do not refresh. Run `orizu login` again if access fails.');
154
+ }
155
+ assertSecureTokenTransport(baseUrl);
156
+ const stop = lockStopWindow(callerSignal);
157
+ stop.listen();
158
+ try {
159
+ return await withCredentialsTransactionLockAsync(async () => {
160
+ const current = getServerCredentials(baseUrl);
161
+ if (!current || !credentialsEqual(current, expected)) {
162
+ return { status: 'changed', credentials: current };
163
+ }
164
+ const refreshed = await runStoppable(refreshRequest(baseUrl, expected), callerSignal, stop.signal);
165
+ // Written inside the lock's own async context, never from the Effect fiber.
166
+ if (!updateServerCredentialsIfCurrent(baseUrl, expected, refreshed)) {
167
+ return { status: 'changed', credentials: getServerCredentials(baseUrl) };
168
+ }
169
+ return { status: 'refreshed', credentials: refreshed };
170
+ });
171
+ }
172
+ finally {
173
+ stop.unlisten();
174
+ await stop.endIfStopped();
175
+ }
176
+ }
177
+ function requireCurrentCredentials(baseUrl, credentials) {
178
+ if (credentials)
179
+ return credentials;
180
+ throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`);
181
+ }
182
+ /**
183
+ * Marks an error raised after the server answered 401 and before the request
184
+ * was sent again: the server refused the request, so nothing was written
185
+ * (ORI-2431). The message is unchanged.
186
+ */
187
+ function markRequestRejected(error) {
188
+ if (error instanceof Error && Object.isExtensible(error))
189
+ Object.assign(error, { requestRejected: true });
190
+ return error;
191
+ }
192
+ /**
193
+ * Refresh first when the login has expired, then send. On a 401, refresh once
194
+ * and send once more, only if the stored login is still the one this request
195
+ * used. `onRequestStart` runs before each send, never for the refresh.
196
+ */
197
+ export async function storedRequest(options, path, init, onRequestStart) {
198
+ const { baseUrl } = options;
199
+ let credentials = options.credentials;
200
+ const refresh = async (changedMessage) => {
201
+ const result = await refreshStoredCredentialsIfCurrent(baseUrl, credentials, init.signal);
202
+ if (result.status === 'changed')
203
+ throw new Error(changedMessage);
204
+ credentials = requireCurrentCredentials(baseUrl, result.credentials);
205
+ options.onRefreshed?.(credentials);
206
+ };
207
+ const send = () => {
208
+ onRequestStart?.();
209
+ return credentialFetch(baseUrl, path, {
210
+ ...init,
211
+ headers: { ...(init.headers || {}), Authorization: `Bearer ${getAuthorizationToken(credentials)}` },
212
+ });
213
+ };
214
+ let didRefresh = false;
215
+ if (options.allowRefresh && isSessionCredentials(credentials) && isExpired(credentials.expiresAt)) {
216
+ await refresh(options.changedBeforeRequest);
217
+ didRefresh = true;
218
+ }
219
+ let response = await send();
220
+ if (options.allowRefresh && response.status === 401 && isSessionCredentials(credentials) && !didRefresh) {
221
+ try {
222
+ await refresh(options.changedBeforeReplay);
223
+ }
224
+ catch (error) {
225
+ // A stop by the caller's own signal is the caller's to describe.
226
+ throw error === init.signal?.reason ? error : markRequestRejected(error);
227
+ }
228
+ response = await send();
229
+ }
230
+ return response;
231
+ }
232
+ /**
233
+ * Revoke the login on the server within ORIZU_LOGOUT_TIMEOUT_MS, then remove it
234
+ * locally only if it is still the stored one: another login saved meanwhile is kept.
235
+ */
236
+ export async function storedLogout(baseUrl, credentialKey, credentials, callerSignal) {
237
+ const authorizationToken = getAuthorizationToken(credentials);
238
+ const refreshToken = isSessionCredentials(credentials) ? rememberResolvedBearer(credentials.refreshToken) : null;
239
+ const secrets = [authorizationToken, ...(refreshToken !== null ? [refreshToken] : [])];
240
+ const timeoutMs = boundedTimeoutMs('ORIZU_LOGOUT_TIMEOUT_MS', 10_000, 30_000);
241
+ const timeoutError = Object.assign(new Error(`ORIZU_LOGOUT_TIMEOUT: remote logout exceeded ${timeoutMs}ms.`), { name: 'TimeoutError' });
242
+ const remote = Effect.tryPromise({
243
+ try: async (signal) => {
244
+ assertSecureTokenTransport(baseUrl);
245
+ const response = await credentialFetch(baseUrl, '/api/cli/auth/logout', {
246
+ method: 'POST',
247
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authorizationToken}` },
248
+ body: refreshToken !== null ? JSON.stringify({ refreshToken }) : undefined,
249
+ signal,
250
+ });
251
+ return response.ok ? null : await describeLogoutHttpFailure(response, secrets);
252
+ },
253
+ catch: error => error,
254
+ }).pipe(Effect.timeoutOrElse({ duration: timeoutMs, orElse: () => Effect.fail(timeoutError) }));
255
+ let remoteError;
256
+ try {
257
+ remoteError = await runStoppable(remote, callerSignal);
258
+ }
259
+ catch (error) {
260
+ remoteError = describeLogoutTransportFailure(error, secrets);
261
+ }
262
+ const localCredentialCleared = clearServerCredentialsIfCurrent(credentialKey, credentials);
263
+ return { remoteError, localCredentialCleared };
264
+ }
@@ -0,0 +1,23 @@
1
+ export interface KeypressInfo {
2
+ name?: string;
3
+ sequence?: string;
4
+ ctrl?: boolean;
5
+ }
6
+ /** What a key does: nothing yet, finish with a value, or cancel setup. */
7
+ export type KeyOutcome<T> = {
8
+ kind: 'wait';
9
+ } | {
10
+ kind: 'done';
11
+ value: T;
12
+ } | {
13
+ kind: 'cancel';
14
+ };
15
+ /**
16
+ * Hands each key to `onKey` until it finishes or cancels. A SIGINT or SIGTERM
17
+ * from outside ends the wait, puts the terminal back, then ends the process
18
+ * by that same signal.
19
+ */
20
+ export declare function readKeys<T>(onKey: (key: KeypressInfo) => KeyOutcome<T>, options: {
21
+ hideCursor: boolean;
22
+ }): Promise<T>;
23
+ //# sourceMappingURL=terminal-key-session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terminal-key-session.d.ts","sourceRoot":"","sources":["../src/terminal-key-session.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,0EAA0E;AAC1E,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAA;AA2C9F;;;;GAIG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,UAAU,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE;IAAE,UAAU,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,CAAC,CAAC,CAe3H"}
@@ -0,0 +1,74 @@
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
+ import { Effect } from 'effect';
6
+ // The terminal goes into raw mode (keys arrive one by one, nothing is echoed)
7
+ // and, for pickers, the cursor is hidden. The release always undoes both.
8
+ const rawKeys = (hideCursor) => Effect.acquireRelease(Effect.sync(() => {
9
+ const rawInput = input;
10
+ const wasRaw = Boolean(rawInput.isRaw);
11
+ emitKeypressEvents(input);
12
+ if (hideCursor)
13
+ output.write('\x1b[?25l');
14
+ input.resume();
15
+ rawInput.setRawMode?.(true);
16
+ return wasRaw;
17
+ }), wasRaw => Effect.sync(() => {
18
+ const rawInput = input;
19
+ if (rawInput.setRawMode && !wasRaw)
20
+ rawInput.setRawMode(false);
21
+ input.pause();
22
+ if (hideCursor)
23
+ output.write('\x1b[?25h');
24
+ }));
25
+ const nextOutcome = (onKey) => Effect.acquireUseRelease(Effect.sync(() => ({ listener: null })), held => Effect.callback(resume => {
26
+ held.listener = (_chunk, key) => {
27
+ // Ctrl-C arrives as a key in raw mode, so every picker cancels on it.
28
+ const outcome = key.ctrl && key.name === 'c' ? { kind: 'cancel' } : onKey(key);
29
+ if (outcome.kind === 'done')
30
+ resume(Effect.succeed(outcome.value));
31
+ else if (outcome.kind === 'cancel')
32
+ resume(Effect.fail(new Error('Setup cancelled.')));
33
+ };
34
+ input.on('keypress', held.listener);
35
+ }), held => Effect.sync(() => { if (held.listener)
36
+ input.off('keypress', held.listener); }));
37
+ const STOP_SIGNALS = ['SIGINT', 'SIGTERM'];
38
+ /**
39
+ * Hands each key to `onKey` until it finishes or cancels. A SIGINT or SIGTERM
40
+ * from outside ends the wait, puts the terminal back, then ends the process
41
+ * by that same signal.
42
+ */
43
+ export async function readKeys(onKey, options) {
44
+ const controller = new AbortController();
45
+ const stop = { by: null };
46
+ const handlers = STOP_SIGNALS.map(signal => [signal, () => { stop.by = signal; controller.abort(); }]);
47
+ for (const [signal, handler] of handlers)
48
+ process.once(signal, handler);
49
+ try {
50
+ const value = await Effect.runPromise(Effect.scoped(Effect.andThen(rawKeys(options.hideCursor), nextOutcome(onKey))), { signal: controller.signal });
51
+ if (stop.by)
52
+ return await endBy(stop.by, handlers);
53
+ return value;
54
+ }
55
+ catch (error) {
56
+ if (!stop.by)
57
+ throw error;
58
+ return await endBy(stop.by, handlers);
59
+ }
60
+ finally {
61
+ for (const [signal, handler] of handlers)
62
+ process.removeListener(signal, handler);
63
+ }
64
+ }
65
+ /** Ends the process by the signal that stopped it, once its handlers are gone. */
66
+ async function endBy(signal, handlers) {
67
+ for (const [name, handler] of handlers)
68
+ process.removeListener(name, handler);
69
+ output.write('\n');
70
+ process.kill(process.pid, signal);
71
+ // The signal ends the process; if another listener holds it, exit as a shell would.
72
+ setTimeout(() => process.exit(signal === 'SIGINT' ? 130 : 143), 1_000);
73
+ return await new Promise(() => { });
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.49",
3
+ "version": "0.5.50",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -5,6 +5,7 @@ import { createInterface } from 'readline/promises'
5
5
  import { isInteractiveTerminal, normalizeSlugInput, hasJsonFlag, getArg } from './command-line-arguments.js'
6
6
  import { printLine, printError, printJson } from './cli-console-output.js'
7
7
  import { sanitizeTerminalText, parseJsonResponse } from './json-response.js'
8
+ import { PROJECT_LIST, readDirectoryList, TEAM_LIST } from './directory-list-read.js'
8
9
  import { type AuthenticatedRequestContext, authedFetch } from './http.js'
9
10
 
10
11
  interface Team {
@@ -168,27 +169,18 @@ async function promptSelect<T>(
168
169
  }
169
170
 
170
171
  type AuthenticatedRequest = AuthenticatedRequestContext['fetch']
171
- async function fetchTeams(request: AuthenticatedRequest = authedFetch): Promise<Team[]> {
172
- const response = await request('/api/cli/teams')
173
- if (!response.ok) {
174
- throw new Error(`Failed to fetch teams: ${await response.text()}`)
175
- }
176
- const data = await parseJsonResponse<{ teams: Team[] }>(response, 'Teams list')
177
- return data.teams
172
+ async function fetchTeams(request: AuthenticatedRequest = authedFetch, timeoutMs?: number): Promise<Team[]> {
173
+ return await readDirectoryList(request, '/api/cli/teams', TEAM_LIST, timeoutMs)
178
174
  }
179
175
 
180
176
  async function fetchProjects(
181
177
  teamSlug?: string,
182
- request: AuthenticatedRequest = authedFetch
178
+ request: AuthenticatedRequest = authedFetch,
179
+ timeoutMs?: number
183
180
  ): Promise<Project[]> {
184
181
  const normalizedTeamSlug = teamSlug ? normalizeSlugInput(teamSlug) : undefined
185
182
  const query = normalizedTeamSlug ? `?teamSlug=${encodeURIComponent(normalizedTeamSlug)}` : ''
186
- const response = await request(`/api/cli/projects${query}`)
187
- if (!response.ok) {
188
- throw new Error(`Failed to fetch projects: ${await response.text()}`)
189
- }
190
- const data = await parseJsonResponse<{ projects: Project[] }>(response, 'Projects list')
191
- return data.projects
183
+ return await readDirectoryList(request, `/api/cli/projects${query}`, PROJECT_LIST, timeoutMs)
192
184
  }
193
185
 
194
186
  async function fetchTasks(project?: string, status = 'active'): Promise<Task[]> {
@@ -252,34 +244,8 @@ async function createProjectOnServer(
252
244
  name: string,
253
245
  request: AuthenticatedRequest = authedFetch
254
246
  ): Promise<Project> {
255
- const response = await request('/api/cli/projects', {
256
- method: 'POST',
257
- headers: { 'Content-Type': 'application/json' },
258
- body: JSON.stringify({ teamSlug, name }),
259
- })
260
- if (!response.ok) {
261
- throw new Error(`Failed to create project: ${await response.text()}`)
262
- }
263
- const data = await parseJsonResponse<{
264
- project: {
265
- id: string
266
- name: string
267
- slug: string
268
- teamId?: string
269
- teamName?: string
270
- teamSlug: string
271
- role?: string
272
- }
273
- }>(response, 'Project create')
274
- return {
275
- id: data.project.id,
276
- name: data.project.name,
277
- slug: data.project.slug,
278
- teamId: data.project.teamId || '',
279
- teamName: data.project.teamName || data.project.teamSlug,
280
- teamSlug: data.project.teamSlug,
281
- role: data.project.role || 'admin',
282
- }
247
+ const { createProjectWithDeadline } = await import('./project-create-workflow.js')
248
+ return await createProjectWithDeadline(teamSlug, name, request)
283
249
  }
284
250
 
285
251
  async function resolveProjectSelection(projectArg: string | null): Promise<Project> {