voratiq 0.1.0-beta.25 → 0.1.0-beta.27
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/README.md +1 -1
- package/dist/agents/runtime/harness.js +10 -1
- package/dist/app-session/api-client.d.ts +42 -0
- package/dist/app-session/api-client.js +143 -0
- package/dist/app-session/authenticated-api.d.ts +31 -0
- package/dist/app-session/authenticated-api.js +143 -0
- package/dist/app-session/browser.d.ts +1 -0
- package/dist/app-session/browser.js +43 -0
- package/dist/app-session/callback.d.ts +19 -0
- package/dist/app-session/callback.js +126 -0
- package/dist/app-session/login.d.ts +27 -0
- package/dist/app-session/login.js +107 -0
- package/dist/app-session/repository-connections.d.ts +26 -0
- package/dist/app-session/repository-connections.js +32 -0
- package/dist/app-session/repository-link-sync.d.ts +11 -0
- package/dist/app-session/repository-link-sync.js +51 -0
- package/dist/app-session/session.d.ts +34 -0
- package/dist/app-session/session.js +32 -0
- package/dist/app-session/state-path.d.ts +2 -0
- package/dist/app-session/state-path.js +15 -0
- package/dist/app-session/state.d.ts +54 -0
- package/dist/app-session/state.js +336 -0
- package/dist/app-session/workflow-sessions.d.ts +14 -0
- package/dist/app-session/workflow-sessions.js +32 -0
- package/dist/app-session/workflow-upload.d.ts +93 -0
- package/dist/app-session/workflow-upload.js +473 -0
- package/dist/bin.js +32 -0
- package/dist/cli/apply.js +5 -0
- package/dist/cli/auto.js +3 -0
- package/dist/cli/login.d.ts +6 -0
- package/dist/cli/login.js +20 -0
- package/dist/cli/message.js +2 -0
- package/dist/cli/reduce.js +2 -0
- package/dist/cli/repository-link.d.ts +23 -0
- package/dist/cli/repository-link.js +76 -0
- package/dist/cli/root-launcher.d.ts +13 -1
- package/dist/cli/root-launcher.js +19 -1
- package/dist/cli/run.js +40 -15
- package/dist/cli/spec.js +2 -0
- package/dist/cli/status.d.ts +46 -0
- package/dist/cli/status.js +179 -0
- package/dist/cli/verify.js +2 -0
- package/dist/commands/apply/command.js +1 -0
- package/dist/commands/list/command.js +60 -2
- package/dist/commands/list/normalization.d.ts +2 -1
- package/dist/commands/root-launcher/command.d.ts +4 -0
- package/dist/commands/root-launcher/command.js +7 -1
- package/dist/configs/agents/defaults.js +6 -15
- package/dist/contracts/list.d.ts +154 -0
- package/dist/contracts/list.js +91 -0
- package/dist/domain/interactive/prompt.d.ts +1 -1
- package/dist/domain/interactive/prompt.js +1 -1
- package/dist/domain/message/model/types.d.ts +4 -0
- package/dist/domain/message/persistence/adapter.js +9 -0
- package/dist/domain/reduce/model/types.d.ts +4 -0
- package/dist/domain/reduce/persistence/adapter.js +9 -0
- package/dist/domain/run/model/types.d.ts +10 -0
- package/dist/domain/run/model/types.js +2 -0
- package/dist/domain/run/persistence/adapter.d.ts +2 -0
- package/dist/domain/run/persistence/adapter.js +17 -3
- package/dist/domain/shared/workflow-record-events.d.ts +39 -0
- package/dist/domain/shared/workflow-record-events.js +12 -0
- package/dist/domain/spec/model/types.d.ts +4 -0
- package/dist/domain/spec/persistence/adapter.js +9 -0
- package/dist/domain/verify/model/types.d.ts +4 -0
- package/dist/domain/verify/persistence/adapter.js +9 -0
- package/dist/interactive/providers/launch.js +6 -2
- package/dist/mcp/server.js +45 -40
- package/dist/persistence/session-store.d.ts +10 -0
- package/dist/persistence/session-store.js +56 -2
- package/dist/utils/git.d.ts +5 -0
- package/dist/utils/git.js +1 -1
- package/dist/workspace/chat/usage-extractor.d.ts +2 -2
- package/dist/workspace/chat/usage-extractor.js +85 -103
- package/dist/workspace/chat/usage-mappings.d.ts +1 -0
- package/dist/workspace/chat/usage-mappings.js +20 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ npm install -g voratiq
|
|
|
15
15
|
|
|
16
16
|
- Node 20+
|
|
17
17
|
- git
|
|
18
|
-
- 1+ AI coding agent (Claude [>=2.1.111](https://github.com/anthropics/claude-code?tab=readme-ov-file#get-started), Codex [>=0.122.0](https://github.com/openai/codex?tab=readme-ov-file#quickstart), or Gemini [>=0.
|
|
18
|
+
- 1+ AI coding agent (Claude [>=2.1.111](https://github.com/anthropics/claude-code?tab=readme-ov-file#get-started), Codex [>=0.122.0](https://github.com/openai/codex?tab=readme-ov-file#quickstart), or Gemini [>=0.40.0](https://github.com/google-gemini/gemini-cli?tab=readme-ov-file#quick-install))
|
|
19
19
|
- macOS: `ripgrep`
|
|
20
20
|
- Linux (Debian/Ubuntu): `bubblewrap`, `socat`, `ripgrep`
|
|
21
21
|
|
|
@@ -42,7 +42,7 @@ export async function runSandboxedAgent(input) {
|
|
|
42
42
|
runtimeManifestPath: paths.runtimeManifestPath,
|
|
43
43
|
promptPath,
|
|
44
44
|
workspacePath: paths.workspacePath,
|
|
45
|
-
env: staged.env,
|
|
45
|
+
env: applyProviderRunEnvironmentOverrides(providerId, staged.env),
|
|
46
46
|
environment,
|
|
47
47
|
});
|
|
48
48
|
const denialBackoff = resolveDenialBackoff({
|
|
@@ -111,6 +111,15 @@ export async function runSandboxedAgent(input) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
+
function applyProviderRunEnvironmentOverrides(providerId, env) {
|
|
115
|
+
if (providerId !== "gemini") {
|
|
116
|
+
return env;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...env,
|
|
120
|
+
GEMINI_CLI_TRUST_WORKSPACE: "true",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
114
123
|
function resolveDenialBackoff(options) {
|
|
115
124
|
if (options.override) {
|
|
116
125
|
return options.override;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AppSessionPayload } from "./session.js";
|
|
2
|
+
export declare const DEFAULT_CLI_SESSION_REFRESH_WINDOW_MS = 60000;
|
|
3
|
+
export declare class AppApiError extends Error {
|
|
4
|
+
readonly code: string | null;
|
|
5
|
+
readonly statusCode: number;
|
|
6
|
+
constructor(message: string, code: string | null, statusCode: number);
|
|
7
|
+
}
|
|
8
|
+
export declare class AppSessionRefreshResponseError extends Error {
|
|
9
|
+
constructor(message: string, options?: {
|
|
10
|
+
cause?: unknown;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export interface CreateCliLoginAttemptResult {
|
|
14
|
+
attemptId: string;
|
|
15
|
+
authorizeUrl: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
}
|
|
18
|
+
export interface RefreshCliSessionOptions {
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
now?: () => Date;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
export declare function resolveVoratiqApiOrigin(env?: NodeJS.ProcessEnv): string;
|
|
25
|
+
export declare function resolveVoratiqAppOrigin(env?: NodeJS.ProcessEnv): string;
|
|
26
|
+
export declare function buildAppApiUrl(path: string, options?: {
|
|
27
|
+
env?: NodeJS.ProcessEnv;
|
|
28
|
+
}): URL;
|
|
29
|
+
export declare function readAppApiError(response: Response): Promise<AppApiError>;
|
|
30
|
+
export declare function isAbortError(error: unknown): boolean;
|
|
31
|
+
export declare function throwIfAppApiRequestAborted(signal?: AbortSignal): void;
|
|
32
|
+
export declare function createCliLoginAttempt(input: {
|
|
33
|
+
installationId: string;
|
|
34
|
+
callbackUrl: string;
|
|
35
|
+
callbackState: string;
|
|
36
|
+
}, env?: NodeJS.ProcessEnv): Promise<CreateCliLoginAttemptResult>;
|
|
37
|
+
export declare function exchangeCliLoginCode(input: {
|
|
38
|
+
code: string;
|
|
39
|
+
}, env?: NodeJS.ProcessEnv): Promise<AppSessionPayload>;
|
|
40
|
+
export declare function refreshCliSession(input: {
|
|
41
|
+
refreshToken: string;
|
|
42
|
+
}, options?: RefreshCliSessionOptions): Promise<AppSessionPayload>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { parseAppSessionPayloadFromUnknown } from "./state.js";
|
|
2
|
+
const DEFAULT_API_ORIGIN = "https://voratiq-api.fly.dev";
|
|
3
|
+
const DEFAULT_APP_ORIGIN = "https://voratiq.com";
|
|
4
|
+
export const DEFAULT_CLI_SESSION_REFRESH_WINDOW_MS = 60_000;
|
|
5
|
+
export class AppApiError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
statusCode;
|
|
8
|
+
constructor(message, code, statusCode) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.statusCode = statusCode;
|
|
12
|
+
this.name = "AppApiError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class AppSessionRefreshResponseError extends Error {
|
|
16
|
+
constructor(message, options) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.name = "AppSessionRefreshResponseError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function resolveVoratiqApiOrigin(env = process.env) {
|
|
22
|
+
return env.VORATIQ_API_ORIGIN?.trim() || DEFAULT_API_ORIGIN;
|
|
23
|
+
}
|
|
24
|
+
export function resolveVoratiqAppOrigin(env = process.env) {
|
|
25
|
+
return env.VORATIQ_SITE_URL?.trim() || DEFAULT_APP_ORIGIN;
|
|
26
|
+
}
|
|
27
|
+
export function buildAppApiUrl(path, options = {}) {
|
|
28
|
+
const origin = resolveVoratiqApiOrigin(options.env ?? process.env);
|
|
29
|
+
return new URL(path, new URL("/api/v1/", origin));
|
|
30
|
+
}
|
|
31
|
+
export async function readAppApiError(response) {
|
|
32
|
+
try {
|
|
33
|
+
const payload = (await response.json());
|
|
34
|
+
return new AppApiError(payload.error?.message ?? `${response.status} ${response.statusText}`, payload.error?.code ?? null, response.status);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return new AppApiError(`${response.status} ${response.statusText}`, null, response.status);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function isAbortError(error) {
|
|
41
|
+
return (hasErrorName(error) &&
|
|
42
|
+
(error.name === "AbortError" || error.name === "TimeoutError"));
|
|
43
|
+
}
|
|
44
|
+
export function throwIfAppApiRequestAborted(signal) {
|
|
45
|
+
if (!signal?.aborted) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
}
|
|
50
|
+
export async function createCliLoginAttempt(input, env = process.env) {
|
|
51
|
+
const response = await fetch(buildAppApiUrl("auth/cli/attempts", { env }), {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
accept: "application/json",
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
...input,
|
|
59
|
+
appOrigin: resolveVoratiqAppOrigin(env),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw await readAppApiError(response);
|
|
64
|
+
}
|
|
65
|
+
return (await response.json());
|
|
66
|
+
}
|
|
67
|
+
export async function exchangeCliLoginCode(input, env = process.env) {
|
|
68
|
+
const response = await fetch(buildAppApiUrl("auth/cli/exchange", { env }), {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
accept: "application/json",
|
|
72
|
+
"content-type": "application/json",
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(input),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
throw await readAppApiError(response);
|
|
78
|
+
}
|
|
79
|
+
const payload = (await response.json());
|
|
80
|
+
return parseAppSessionPayloadFromUnknown(payload, "Voratiq App exchange response");
|
|
81
|
+
}
|
|
82
|
+
export async function refreshCliSession(input, options = {}) {
|
|
83
|
+
const env = options.env ?? process.env;
|
|
84
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
85
|
+
const now = options.now ?? (() => new Date());
|
|
86
|
+
const response = await fetchImpl(buildAppApiUrl("auth/cli/refresh", {
|
|
87
|
+
env,
|
|
88
|
+
}), {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: {
|
|
91
|
+
accept: "application/json",
|
|
92
|
+
"content-type": "application/json",
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
refreshToken: input.refreshToken,
|
|
96
|
+
}),
|
|
97
|
+
signal: options.signal,
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
throw await readAppApiError(response);
|
|
101
|
+
}
|
|
102
|
+
let payload;
|
|
103
|
+
try {
|
|
104
|
+
payload = (await response.json());
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new AppSessionRefreshResponseError("Invalid Voratiq App refresh response: expected JSON.", {
|
|
108
|
+
cause: error,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
let refreshedSession;
|
|
112
|
+
try {
|
|
113
|
+
refreshedSession = parseAppSessionPayloadFromUnknown(payload, "Voratiq App refresh response");
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
throw new AppSessionRefreshResponseError("Invalid Voratiq App refresh response: expected a complete CLI session payload.", {
|
|
117
|
+
cause: error,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
validateRefreshedSessionPayload(refreshedSession, now());
|
|
121
|
+
return refreshedSession;
|
|
122
|
+
}
|
|
123
|
+
function hasErrorName(error) {
|
|
124
|
+
return (typeof error === "object" &&
|
|
125
|
+
error !== null &&
|
|
126
|
+
"name" in error &&
|
|
127
|
+
typeof error.name === "string");
|
|
128
|
+
}
|
|
129
|
+
function validateRefreshedSessionPayload(payload, now) {
|
|
130
|
+
const nowMs = now.getTime();
|
|
131
|
+
const accessTokenExpiresAt = Date.parse(payload.session.accessTokenExpiresAt);
|
|
132
|
+
const refreshTokenExpiresAt = Date.parse(payload.session.refreshTokenExpiresAt);
|
|
133
|
+
if (!Number.isFinite(accessTokenExpiresAt) || accessTokenExpiresAt <= nowMs) {
|
|
134
|
+
throw new AppSessionRefreshResponseError("Invalid Voratiq App refresh response: access token expiry is unusable.");
|
|
135
|
+
}
|
|
136
|
+
if (!Number.isFinite(refreshTokenExpiresAt) ||
|
|
137
|
+
refreshTokenExpiresAt <= nowMs) {
|
|
138
|
+
throw new AppSessionRefreshResponseError("Invalid Voratiq App refresh response: refresh token expiry is unusable.");
|
|
139
|
+
}
|
|
140
|
+
if (refreshTokenExpiresAt <= accessTokenExpiresAt) {
|
|
141
|
+
throw new AppSessionRefreshResponseError("Invalid Voratiq App refresh response: refresh token expires too early.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { refreshCliSession } from "./api-client.js";
|
|
2
|
+
import type { AppSessionPayload } from "./session.js";
|
|
3
|
+
import { writeAppSessionState } from "./session.js";
|
|
4
|
+
import { readAppSessionState } from "./state.js";
|
|
5
|
+
export type AppSessionAuthErrorCode = "session_missing" | "session_expired" | "session_read_failed" | "session_write_failed" | "refresh_failed" | "invalid_refresh_response";
|
|
6
|
+
export declare class AppSessionAuthError extends Error {
|
|
7
|
+
readonly code: AppSessionAuthErrorCode;
|
|
8
|
+
constructor(message: string, code: AppSessionAuthErrorCode, options?: {
|
|
9
|
+
cause?: unknown;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export interface AuthenticatedAppSessionRequestContext {
|
|
13
|
+
session: AppSessionPayload;
|
|
14
|
+
accessToken: string;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
export interface RunWithAuthenticatedAppSessionOptions<Result> {
|
|
18
|
+
run: (context: AuthenticatedAppSessionRequestContext) => Promise<Result>;
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
refreshWindowMs?: number;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
isAuthenticationFailure?: (error: unknown) => boolean;
|
|
23
|
+
}
|
|
24
|
+
interface RunWithAuthenticatedAppSessionDependencies {
|
|
25
|
+
readAppSessionState: typeof readAppSessionState;
|
|
26
|
+
writeAppSessionState: typeof writeAppSessionState;
|
|
27
|
+
refreshCliSession: typeof refreshCliSession;
|
|
28
|
+
now: () => Date;
|
|
29
|
+
}
|
|
30
|
+
export declare function runWithAuthenticatedAppSession<Result>(options: RunWithAuthenticatedAppSessionOptions<Result>, dependencies?: Partial<RunWithAuthenticatedAppSessionDependencies>): Promise<Result>;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { AppApiError, AppSessionRefreshResponseError, DEFAULT_CLI_SESSION_REFRESH_WINDOW_MS, isAbortError, refreshCliSession, throwIfAppApiRequestAborted, } from "./api-client.js";
|
|
2
|
+
import { writeAppSessionState } from "./session.js";
|
|
3
|
+
import { readAppSessionState } from "./state.js";
|
|
4
|
+
export class AppSessionAuthError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(message, code, options) {
|
|
7
|
+
super(message, options);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "AppSessionAuthError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function runWithAuthenticatedAppSession(options, dependencies = {}) {
|
|
13
|
+
const env = options.env ?? process.env;
|
|
14
|
+
const refreshWindowMs = normalizeRefreshWindowMs(options.refreshWindowMs);
|
|
15
|
+
const isAuthenticationFailure = options.isAuthenticationFailure ?? isAuthenticationFailureByDefault;
|
|
16
|
+
const deps = {
|
|
17
|
+
readAppSessionState,
|
|
18
|
+
writeAppSessionState,
|
|
19
|
+
refreshCliSession,
|
|
20
|
+
now: () => new Date(),
|
|
21
|
+
...dependencies,
|
|
22
|
+
};
|
|
23
|
+
throwIfAppApiRequestAborted(options.signal);
|
|
24
|
+
let session = await readStoredSessionOrThrow({
|
|
25
|
+
env,
|
|
26
|
+
readAppSessionState: deps.readAppSessionState,
|
|
27
|
+
});
|
|
28
|
+
if (hasExpired(session.session.refreshTokenExpiresAt, deps.now())) {
|
|
29
|
+
throw buildSessionExpiredError();
|
|
30
|
+
}
|
|
31
|
+
let refreshedForRequest = false;
|
|
32
|
+
if (shouldRefreshAccessToken(session.session.accessTokenExpiresAt, deps.now(), refreshWindowMs)) {
|
|
33
|
+
session = await refreshAndPersistSession({
|
|
34
|
+
session,
|
|
35
|
+
env,
|
|
36
|
+
signal: options.signal,
|
|
37
|
+
dependencies: deps,
|
|
38
|
+
});
|
|
39
|
+
refreshedForRequest = true;
|
|
40
|
+
}
|
|
41
|
+
throwIfAppApiRequestAborted(options.signal);
|
|
42
|
+
try {
|
|
43
|
+
return await options.run({
|
|
44
|
+
session,
|
|
45
|
+
accessToken: session.session.accessToken,
|
|
46
|
+
signal: options.signal,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (!isAuthenticationFailure(error) || refreshedForRequest) {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
if (hasExpired(session.session.refreshTokenExpiresAt, deps.now())) {
|
|
54
|
+
throw buildSessionExpiredError();
|
|
55
|
+
}
|
|
56
|
+
throwIfAppApiRequestAborted(options.signal);
|
|
57
|
+
session = await refreshAndPersistSession({
|
|
58
|
+
session,
|
|
59
|
+
env,
|
|
60
|
+
signal: options.signal,
|
|
61
|
+
dependencies: deps,
|
|
62
|
+
});
|
|
63
|
+
throwIfAppApiRequestAborted(options.signal);
|
|
64
|
+
return await options.run({
|
|
65
|
+
session,
|
|
66
|
+
accessToken: session.session.accessToken,
|
|
67
|
+
signal: options.signal,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function readStoredSessionOrThrow(options) {
|
|
72
|
+
try {
|
|
73
|
+
const state = await options.readAppSessionState(options.env);
|
|
74
|
+
if (!state.raw) {
|
|
75
|
+
throw new AppSessionAuthError("Voratiq App login is required. Run `voratiq login`.", "session_missing");
|
|
76
|
+
}
|
|
77
|
+
return state.raw;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof AppSessionAuthError) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
throw new AppSessionAuthError("Could not read saved Voratiq App sign-in state.", "session_read_failed", { cause: error });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function refreshAndPersistSession(options) {
|
|
87
|
+
if (hasExpired(options.session.session.refreshTokenExpiresAt, options.dependencies.now())) {
|
|
88
|
+
throw buildSessionExpiredError();
|
|
89
|
+
}
|
|
90
|
+
let refreshed;
|
|
91
|
+
try {
|
|
92
|
+
throwIfAppApiRequestAborted(options.signal);
|
|
93
|
+
refreshed = await options.dependencies.refreshCliSession({
|
|
94
|
+
refreshToken: options.session.session.refreshToken,
|
|
95
|
+
}, {
|
|
96
|
+
env: options.env,
|
|
97
|
+
now: options.dependencies.now,
|
|
98
|
+
signal: options.signal,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (isAbortForSignal(error, options.signal)) {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
if (error instanceof AppSessionRefreshResponseError) {
|
|
106
|
+
throw new AppSessionAuthError("Invalid Voratiq App session refresh response.", "invalid_refresh_response", { cause: error });
|
|
107
|
+
}
|
|
108
|
+
throw new AppSessionAuthError("Voratiq App session refresh failed. Run `voratiq login` again.", "refresh_failed", { cause: error });
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await options.dependencies.writeAppSessionState(refreshed, options.env);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
throw new AppSessionAuthError("Could not update saved Voratiq App sign-in state.", "session_write_failed", {
|
|
115
|
+
cause: error,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return refreshed;
|
|
119
|
+
}
|
|
120
|
+
function isAbortForSignal(error, signal) {
|
|
121
|
+
return (signal?.aborted === true || (signal !== undefined && isAbortError(error)));
|
|
122
|
+
}
|
|
123
|
+
function isAuthenticationFailureByDefault(error) {
|
|
124
|
+
return (error instanceof AppApiError &&
|
|
125
|
+
(error.statusCode === 401 || error.statusCode === 403));
|
|
126
|
+
}
|
|
127
|
+
function shouldRefreshAccessToken(accessTokenExpiresAt, now, refreshWindowMs) {
|
|
128
|
+
return Date.parse(accessTokenExpiresAt) - now.getTime() <= refreshWindowMs;
|
|
129
|
+
}
|
|
130
|
+
function hasExpired(timestamp, now) {
|
|
131
|
+
return Date.parse(timestamp) <= now.getTime();
|
|
132
|
+
}
|
|
133
|
+
function normalizeRefreshWindowMs(refreshWindowMs) {
|
|
134
|
+
if (typeof refreshWindowMs !== "number" ||
|
|
135
|
+
!Number.isFinite(refreshWindowMs) ||
|
|
136
|
+
refreshWindowMs < 0) {
|
|
137
|
+
return DEFAULT_CLI_SESSION_REFRESH_WINDOW_MS;
|
|
138
|
+
}
|
|
139
|
+
return refreshWindowMs;
|
|
140
|
+
}
|
|
141
|
+
function buildSessionExpiredError() {
|
|
142
|
+
return new AppSessionAuthError("Voratiq App session expired. Run `voratiq login` again.", "session_expired");
|
|
143
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function openExternalUrl(url: string): Promise<boolean>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
function resolveOpenCommand(url) {
|
|
3
|
+
switch (process.platform) {
|
|
4
|
+
case "darwin":
|
|
5
|
+
return { command: "open", args: [url] };
|
|
6
|
+
case "win32":
|
|
7
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
8
|
+
default:
|
|
9
|
+
return { command: "xdg-open", args: [url] };
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function openExternalUrl(url) {
|
|
13
|
+
if (process.env.VORATIQ_DISABLE_BROWSER_OPEN === "1") {
|
|
14
|
+
return Promise.resolve(false);
|
|
15
|
+
}
|
|
16
|
+
const { command, args } = resolveOpenCommand(url);
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let settled = false;
|
|
19
|
+
const settle = (opened) => {
|
|
20
|
+
if (settled) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
settled = true;
|
|
24
|
+
resolve(opened);
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
const child = spawn(command, args, {
|
|
28
|
+
detached: true,
|
|
29
|
+
stdio: "ignore",
|
|
30
|
+
});
|
|
31
|
+
child.once("spawn", () => {
|
|
32
|
+
settle(true);
|
|
33
|
+
});
|
|
34
|
+
child.once("error", () => {
|
|
35
|
+
settle(false);
|
|
36
|
+
});
|
|
37
|
+
child.unref();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
settle(false);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare class AppSignInCallbackError extends Error {
|
|
2
|
+
readonly code: "bind_failed" | "timed_out" | "state_mismatch" | "malformed_callback";
|
|
3
|
+
constructor(code: "bind_failed" | "timed_out" | "state_mismatch" | "malformed_callback", message: string);
|
|
4
|
+
}
|
|
5
|
+
export interface AppSignInCallbackResult {
|
|
6
|
+
code: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AppSignInCallbackServer {
|
|
9
|
+
callbackUrl: string;
|
|
10
|
+
waitForResult(timeoutMs: number): Promise<AppSignInCallbackResult>;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function startAppSignInCallbackServer(options: {
|
|
14
|
+
expectedState: string;
|
|
15
|
+
completionUrl: string;
|
|
16
|
+
host?: string;
|
|
17
|
+
pathname?: string;
|
|
18
|
+
port?: number;
|
|
19
|
+
}): Promise<AppSignInCallbackServer>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
|
+
export class AppSignInCallbackError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "AppSignInCallbackError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function redirectResponse(response, location) {
|
|
11
|
+
response.statusCode = 303;
|
|
12
|
+
response.setHeader("connection", "close");
|
|
13
|
+
response.setHeader("location", location);
|
|
14
|
+
response.end();
|
|
15
|
+
}
|
|
16
|
+
function buildCompletionUrl(completionUrl, status, reason) {
|
|
17
|
+
const url = new URL(completionUrl);
|
|
18
|
+
if (status !== "success") {
|
|
19
|
+
url.searchParams.set("status", status);
|
|
20
|
+
}
|
|
21
|
+
if (reason) {
|
|
22
|
+
url.searchParams.set("reason", reason);
|
|
23
|
+
}
|
|
24
|
+
return url.toString();
|
|
25
|
+
}
|
|
26
|
+
async function closeServer(server) {
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
server.close((error) => {
|
|
29
|
+
if (error) {
|
|
30
|
+
reject(error);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolve();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function startAppSignInCallbackServer(options) {
|
|
38
|
+
const host = options.host ?? "127.0.0.1";
|
|
39
|
+
const pathname = options.pathname ?? "/callback";
|
|
40
|
+
const port = options.port ?? 0;
|
|
41
|
+
let resolved = false;
|
|
42
|
+
let resolveResult = null;
|
|
43
|
+
let rejectResult = null;
|
|
44
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
45
|
+
resolveResult = resolve;
|
|
46
|
+
rejectResult = reject;
|
|
47
|
+
});
|
|
48
|
+
async function settleWithError(error) {
|
|
49
|
+
if (resolved) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
resolved = true;
|
|
53
|
+
await closeServer(server).catch(() => { });
|
|
54
|
+
rejectResult?.(error);
|
|
55
|
+
}
|
|
56
|
+
async function settleWithResult(result) {
|
|
57
|
+
if (resolved) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
resolved = true;
|
|
61
|
+
await closeServer(server).catch(() => { });
|
|
62
|
+
resolveResult?.(result);
|
|
63
|
+
}
|
|
64
|
+
const server = createServer((request, response) => {
|
|
65
|
+
const url = new URL(request.url ?? pathname, `http://${host}`);
|
|
66
|
+
if (url.pathname !== pathname) {
|
|
67
|
+
redirectResponse(response, buildCompletionUrl(options.completionUrl, "failed", "not_found"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const state = url.searchParams.get("state");
|
|
71
|
+
const code = url.searchParams.get("code");
|
|
72
|
+
if (state !== options.expectedState) {
|
|
73
|
+
redirectResponse(response, buildCompletionUrl(options.completionUrl, "failed", "state_mismatch"));
|
|
74
|
+
void settleWithError(new AppSignInCallbackError("state_mismatch", "The browser callback state did not match the active login request."));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!code) {
|
|
78
|
+
redirectResponse(response, buildCompletionUrl(options.completionUrl, "failed", "malformed_callback"));
|
|
79
|
+
void settleWithError(new AppSignInCallbackError("malformed_callback", "The browser callback did not include a usable exchange code."));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
redirectResponse(response, buildCompletionUrl(options.completionUrl, "success"));
|
|
83
|
+
void settleWithResult({ code });
|
|
84
|
+
});
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
server.once("error", (error) => {
|
|
87
|
+
reject(new AppSignInCallbackError("bind_failed", error instanceof Error
|
|
88
|
+
? error.message
|
|
89
|
+
: "Failed to bind the localhost callback server."));
|
|
90
|
+
});
|
|
91
|
+
server.listen(port, host, () => {
|
|
92
|
+
server.removeAllListeners("error");
|
|
93
|
+
resolve();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
const address = server.address();
|
|
97
|
+
if (!address || typeof address === "string") {
|
|
98
|
+
await closeServer(server);
|
|
99
|
+
throw new AppSignInCallbackError("bind_failed", "Failed to resolve the localhost callback address.");
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
callbackUrl: `http://${host}:${address.port}${pathname}`,
|
|
103
|
+
async waitForResult(timeoutMs) {
|
|
104
|
+
let timeoutHandle;
|
|
105
|
+
try {
|
|
106
|
+
return await Promise.race([
|
|
107
|
+
resultPromise,
|
|
108
|
+
new Promise((_, reject) => {
|
|
109
|
+
timeoutHandle = setTimeout(() => {
|
|
110
|
+
reject(new AppSignInCallbackError("timed_out", "Timed out waiting for the browser to finish login approval."));
|
|
111
|
+
void closeServer(server).catch(() => { });
|
|
112
|
+
}, timeoutMs);
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
if (timeoutHandle) {
|
|
118
|
+
clearTimeout(timeoutHandle);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
async close() {
|
|
123
|
+
await closeServer(server);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { CommandOutputWriter } from "../cli/output.js";
|
|
3
|
+
import { createCliLoginAttempt, exchangeCliLoginCode } from "./api-client.js";
|
|
4
|
+
import { openExternalUrl } from "./browser.js";
|
|
5
|
+
import { startAppSignInCallbackServer } from "./callback.js";
|
|
6
|
+
import { type AppSessionPayload, writeAppSessionState } from "./session.js";
|
|
7
|
+
import { readAppSessionState } from "./state.js";
|
|
8
|
+
export interface AppSignInDependencies {
|
|
9
|
+
createLoginAttempt: typeof createCliLoginAttempt;
|
|
10
|
+
exchangeLoginCode: typeof exchangeCliLoginCode;
|
|
11
|
+
openExternalUrl: typeof openExternalUrl;
|
|
12
|
+
readAppSessionState: typeof readAppSessionState;
|
|
13
|
+
startCallbackServer: typeof startAppSignInCallbackServer;
|
|
14
|
+
writeAppSessionState: typeof writeAppSessionState;
|
|
15
|
+
randomUUID: typeof randomUUID;
|
|
16
|
+
now: () => Date;
|
|
17
|
+
}
|
|
18
|
+
export interface AppSignInResult {
|
|
19
|
+
body: string;
|
|
20
|
+
statePath: string;
|
|
21
|
+
session: AppSessionPayload;
|
|
22
|
+
}
|
|
23
|
+
export declare function performAppSignIn(options?: {
|
|
24
|
+
writeOutput?: CommandOutputWriter;
|
|
25
|
+
env?: NodeJS.ProcessEnv;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}, dependencies?: Partial<AppSignInDependencies>): Promise<AppSignInResult>;
|