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.
- package/dist/account-directory.d.ts +2 -2
- package/dist/account-directory.d.ts.map +1 -1
- package/dist/account-directory.js +7 -32
- package/dist/auth-commands.d.ts.map +1 -1
- package/dist/auth-commands.js +22 -30
- package/dist/credentials.d.ts +5 -0
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +84 -3
- package/dist/dataset-transfer-upload.d.ts.map +1 -1
- package/dist/dataset-transfer-upload.js +6 -1
- package/dist/directory-list-read.d.ts +37 -0
- package/dist/directory-list-read.d.ts.map +1 -0
- package/dist/directory-list-read.js +87 -0
- package/dist/hosted-boot.d.ts.map +1 -1
- package/dist/hosted-boot.js +59 -41
- package/dist/hosted-harness-opencode.d.ts +31 -0
- package/dist/hosted-harness-opencode.d.ts.map +1 -1
- package/dist/hosted-harness-opencode.js +45 -7
- package/dist/hosted-loop.d.ts +39 -2
- package/dist/hosted-loop.d.ts.map +1 -1
- package/dist/hosted-loop.js +127 -18
- package/dist/hosted-session-seed.d.ts +2 -0
- package/dist/hosted-session-seed.d.ts.map +1 -1
- package/dist/hosted-session-seed.js +27 -10
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +39 -234
- package/dist/keyboard-select.d.ts.map +1 -1
- package/dist/keyboard-select.js +23 -45
- package/dist/project-create-workflow.d.ts +18 -0
- package/dist/project-create-workflow.d.ts.map +1 -0
- package/dist/project-create-workflow.js +121 -0
- package/dist/self-update-command.d.ts +8 -0
- package/dist/self-update-command.d.ts.map +1 -0
- package/dist/self-update-command.js +390 -0
- package/dist/self-update.d.ts +29 -16
- package/dist/self-update.d.ts.map +1 -1
- package/dist/self-update.js +55 -244
- package/dist/setup-account.d.ts.map +1 -1
- package/dist/setup-account.js +4 -1
- package/dist/setup-and-skills-commands.d.ts.map +1 -1
- package/dist/setup-and-skills-commands.js +88 -102
- package/dist/skill-install-workflow.d.ts +23 -0
- package/dist/skill-install-workflow.d.ts.map +1 -0
- package/dist/skill-install-workflow.js +184 -0
- package/dist/skill-installer.d.ts +8 -3
- package/dist/skill-installer.d.ts.map +1 -1
- package/dist/skill-installer.js +26 -33
- package/dist/stored-session-requests.d.ts +27 -0
- package/dist/stored-session-requests.d.ts.map +1 -0
- package/dist/stored-session-requests.js +264 -0
- package/dist/terminal-key-session.d.ts +23 -0
- package/dist/terminal-key-session.d.ts.map +1 -0
- package/dist/terminal-key-session.js +74 -0
- package/package.json +1 -1
- package/src/account-directory.ts +8 -42
- package/src/auth-commands.ts +20 -29
- package/src/credentials.ts +83 -3
- package/src/dataset-transfer-upload.ts +7 -1
- package/src/directory-list-read.ts +123 -0
- package/src/hosted-boot.ts +65 -43
- package/src/hosted-harness-opencode.ts +65 -9
- package/src/hosted-loop.ts +152 -18
- package/src/hosted-session-seed.ts +25 -8
- package/src/http.ts +39 -317
- package/src/keyboard-select.ts +18 -55
- package/src/project-create-workflow.ts +139 -0
- package/src/self-update-command.ts +419 -0
- package/src/self-update.ts +70 -266
- package/src/setup-account.ts +4 -1
- package/src/setup-and-skills-commands.ts +89 -121
- package/src/skill-install-workflow.ts +215 -0
- package/src/skill-installer.ts +31 -38
- package/src/stored-session-requests.ts +316 -0
- package/src/terminal-key-session.ts +88 -0
package/dist/skill-installer.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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(
|
|
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), `${
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
package/src/account-directory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
256
|
-
|
|
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> {
|