pi-free 2.2.3 → 2.2.4
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/CHANGELOG.md +16 -49
- package/README.md +41 -532
- package/banner.svg +23 -20
- package/config.ts +82 -10
- package/constants.ts +11 -1
- package/index.ts +15 -1
- package/lib/model-detection.ts +296 -296
- package/lib/model-metadata.ts +10 -3
- package/lib/telemetry.ts +36 -44
- package/package.json +3 -2
- package/provider-failover/benchmark-lookup.ts +30 -15
- package/provider-helper.ts +27 -8
- package/providers/bai/bai.ts +2 -7
- package/providers/cline/cline-xml-bridge.ts +31 -25
- package/providers/cline/cline.ts +17 -8
- package/providers/kilo/kilo.ts +11 -6
- package/providers/model-fetcher.ts +1 -1
- package/providers/opencode-session.ts +2 -2
- package/providers/openmodel/openmodel.ts +525 -0
- package/providers/qoder/auth.ts +548 -0
- package/providers/qoder/cosy.ts +236 -0
- package/providers/qoder/encoding.ts +48 -0
- package/providers/qoder/models.ts +321 -0
- package/providers/qoder/qoder.ts +154 -0
- package/providers/qoder/stream.ts +677 -0
- package/providers/qoder/thinking-parser.ts +251 -0
- package/providers/qoder/transform.ts +189 -0
- package/providers/tokenrouter/tokenrouter.ts +3 -6
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qoder authentication — OAuth device flow + PAT exchange + token refresh.
|
|
3
|
+
*
|
|
4
|
+
* Qoder supports two authentication methods:
|
|
5
|
+
* 1. **Personal Access Token (PAT):** A long-lived `pt-...` token that must
|
|
6
|
+
* be exchanged for a short-lived job token before it can be used for API calls.
|
|
7
|
+
* The PAT is stored and transparently re-exchanged on expiry.
|
|
8
|
+
* 2. **OAuth Device Flow:** PKCE-based browser login, polls for token completion.
|
|
9
|
+
*
|
|
10
|
+
* This module handles login orchestration, token refresh, and credential caching.
|
|
11
|
+
* It conforms to pi's OAuthLoginCallbacks interface so it works with `/login qoder`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import type {
|
|
19
|
+
OAuthCredentials,
|
|
20
|
+
OAuthLoginCallbacks,
|
|
21
|
+
} from "@earendil-works/pi-ai";
|
|
22
|
+
import { getMachineId } from "./cosy.ts";
|
|
23
|
+
|
|
24
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const EXCHANGE_URL = "https://openapi.qoder.sh/api/v1/jobToken/exchange";
|
|
27
|
+
const USERINFO_URL = "https://openapi.qoder.sh/api/v1/userinfo";
|
|
28
|
+
const POLL_URL = "https://openapi.qoder.sh/api/v1/deviceToken/poll";
|
|
29
|
+
const REFRESH_URL = "https://center.qoder.sh/algo/api/v3/user/refresh_token";
|
|
30
|
+
const AUTH_FILE = join(homedir(), ".pi", "agent", "auth.json");
|
|
31
|
+
const UA = "pi-free-providers";
|
|
32
|
+
|
|
33
|
+
const PAT_REFRESH_PREFIX = "pat";
|
|
34
|
+
|
|
35
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Extended credentials with Qoder-specific identity fields. */
|
|
38
|
+
export interface QoderCredentials extends OAuthCredentials {
|
|
39
|
+
userID: string;
|
|
40
|
+
email: string;
|
|
41
|
+
name: string;
|
|
42
|
+
machineID: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface PatExchangeResult {
|
|
46
|
+
jobToken: string;
|
|
47
|
+
jobRefreshToken: string;
|
|
48
|
+
expiresAt: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── PAT helpers ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function isPatRefresh(refresh: string): boolean {
|
|
54
|
+
return refresh.startsWith(`${PAT_REFRESH_PREFIX}|`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function encodePatRefresh(
|
|
58
|
+
pat: string,
|
|
59
|
+
jobRefreshToken: string,
|
|
60
|
+
userID: string,
|
|
61
|
+
machineID: string,
|
|
62
|
+
): string {
|
|
63
|
+
return [PAT_REFRESH_PREFIX, pat, jobRefreshToken, userID, machineID].join(
|
|
64
|
+
"|",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decodePatRefresh(refresh: string): {
|
|
69
|
+
pat: string;
|
|
70
|
+
jobRefreshToken: string;
|
|
71
|
+
userID: string;
|
|
72
|
+
machineID: string;
|
|
73
|
+
} {
|
|
74
|
+
const parts = refresh.split("|");
|
|
75
|
+
return {
|
|
76
|
+
pat: parts[1] || "",
|
|
77
|
+
jobRefreshToken: parts[2] || "",
|
|
78
|
+
userID: parts[3] || "",
|
|
79
|
+
machineID: parts[4] || "",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Exchange a Qoder PAT (pt-...) for a short-lived job token (jt-...).
|
|
85
|
+
* This mirrors the official qodercli flow: PATs cannot authenticate API
|
|
86
|
+
* calls directly — they must first be exchanged.
|
|
87
|
+
*/
|
|
88
|
+
async function exchangeJobToken(pat: string): Promise<PatExchangeResult> {
|
|
89
|
+
const res = await fetch(EXCHANGE_URL, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
Accept: "application/json",
|
|
94
|
+
"User-Agent": UA,
|
|
95
|
+
"Cosy-Version": "1.0.1",
|
|
96
|
+
"Cosy-ClientType": "5",
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({ personal_token: pat }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
const text = await res.text().catch(() => "");
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Qoder PAT exchange failed: ${res.status} ${res.statusText}. ${text.slice(0, 200)}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = (await res.json()) as {
|
|
109
|
+
token?: string;
|
|
110
|
+
refresh_token?: string;
|
|
111
|
+
expires_at?: string;
|
|
112
|
+
expires_in?: number;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (!data.token) {
|
|
116
|
+
throw new Error("Qoder PAT exchange returned no job token");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let expiresAt = Date.now() + 24 * 60 * 60 * 1000;
|
|
120
|
+
if (data.expires_at) {
|
|
121
|
+
const parsed = Date.parse(data.expires_at);
|
|
122
|
+
if (!Number.isNaN(parsed)) expiresAt = parsed;
|
|
123
|
+
} else if (data.expires_in) {
|
|
124
|
+
// expires_in is in milliseconds per the observed API response
|
|
125
|
+
expiresAt = Date.now() + data.expires_in;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
jobToken: data.token,
|
|
130
|
+
jobRefreshToken: data.refresh_token || "",
|
|
131
|
+
expiresAt,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fetch user profile using a job token. Best-effort; returns empty strings
|
|
137
|
+
* on failure.
|
|
138
|
+
*/
|
|
139
|
+
async function fetchUserInfo(jobToken: string): Promise<{
|
|
140
|
+
userID: string;
|
|
141
|
+
email: string;
|
|
142
|
+
name: string;
|
|
143
|
+
}> {
|
|
144
|
+
let userID = "";
|
|
145
|
+
let email = "";
|
|
146
|
+
let name = "";
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(USERINFO_URL, {
|
|
149
|
+
headers: {
|
|
150
|
+
Authorization: `Bearer ${jobToken}`,
|
|
151
|
+
Accept: "application/json",
|
|
152
|
+
"User-Agent": UA,
|
|
153
|
+
"Cosy-Version": "1.0.1",
|
|
154
|
+
"Cosy-ClientType": "5",
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
if (res.ok) {
|
|
158
|
+
const info = (await res.json()) as {
|
|
159
|
+
id?: string;
|
|
160
|
+
email?: string;
|
|
161
|
+
name?: string;
|
|
162
|
+
username?: string;
|
|
163
|
+
};
|
|
164
|
+
userID = info.id || "";
|
|
165
|
+
email = info.email || "";
|
|
166
|
+
name = info.name || info.username || "";
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// Best-effort
|
|
170
|
+
}
|
|
171
|
+
return { userID, email, name };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build full Qoder credentials from a Personal Access Token.
|
|
176
|
+
* Exchanges the PAT for a job token and resolves user identity.
|
|
177
|
+
*/
|
|
178
|
+
async function credentialsFromPat(pat: string): Promise<QoderCredentials> {
|
|
179
|
+
const { jobToken, jobRefreshToken, expiresAt } = await exchangeJobToken(pat);
|
|
180
|
+
const { userID, email, name } = await fetchUserInfo(jobToken);
|
|
181
|
+
const machineID = getMachineId();
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
refresh: encodePatRefresh(pat, jobRefreshToken, userID, machineID),
|
|
185
|
+
access: jobToken,
|
|
186
|
+
expires: expiresAt - 5 * 60 * 1000, // 5 min buffer
|
|
187
|
+
userID,
|
|
188
|
+
email,
|
|
189
|
+
name,
|
|
190
|
+
machineID,
|
|
191
|
+
} as QoderCredentials;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── PKCE helpers ────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
function generatePKCE() {
|
|
197
|
+
const codeVerifier = crypto.randomBytes(32).toString("base64url");
|
|
198
|
+
const codeChallenge = crypto
|
|
199
|
+
.createHash("sha256")
|
|
200
|
+
.update(codeVerifier)
|
|
201
|
+
.digest("base64url");
|
|
202
|
+
return { codeVerifier, codeChallenge };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseExpiresAt(s?: string, expiresInSeconds?: number): number {
|
|
206
|
+
if (s) {
|
|
207
|
+
const t = Date.parse(s);
|
|
208
|
+
if (!Number.isNaN(t)) return t;
|
|
209
|
+
const ms = Number.parseInt(s, 10);
|
|
210
|
+
if (!Number.isNaN(ms) && ms > 0) return ms;
|
|
211
|
+
}
|
|
212
|
+
if (expiresInSeconds && expiresInSeconds > 0) {
|
|
213
|
+
return Date.now() + expiresInSeconds * 1000;
|
|
214
|
+
}
|
|
215
|
+
return Date.now() + 30 * 24 * 60 * 60 * 1000; // default 30 days
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
219
|
+
if (signal?.aborted)
|
|
220
|
+
return Promise.reject(signal.reason || new Error("Login cancelled"));
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const timer = setTimeout(() => {
|
|
223
|
+
signal?.removeEventListener("abort", onAbort);
|
|
224
|
+
resolve();
|
|
225
|
+
}, ms);
|
|
226
|
+
const onAbort = () => {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
reject(signal?.reason || new Error("Login cancelled"));
|
|
229
|
+
};
|
|
230
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── OAuth Device Flow ───────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
async function buildDeviceFlowCredentials(
|
|
237
|
+
callbacks: OAuthLoginCallbacks,
|
|
238
|
+
tokenData: {
|
|
239
|
+
token: string;
|
|
240
|
+
user_id: string;
|
|
241
|
+
refresh_token: string;
|
|
242
|
+
expires_at?: string;
|
|
243
|
+
expires_in?: number;
|
|
244
|
+
},
|
|
245
|
+
machineID: string,
|
|
246
|
+
): Promise<QoderCredentials> {
|
|
247
|
+
const expireMs = parseExpiresAt(tokenData.expires_at, tokenData.expires_in);
|
|
248
|
+
|
|
249
|
+
(callbacks as unknown as { onProgress?: (msg: string) => void }).onProgress?.(
|
|
250
|
+
"Fetching user profile...",
|
|
251
|
+
);
|
|
252
|
+
let email = "";
|
|
253
|
+
let name = "";
|
|
254
|
+
try {
|
|
255
|
+
const userinfoRes = await fetch(USERINFO_URL, {
|
|
256
|
+
method: "GET",
|
|
257
|
+
headers: {
|
|
258
|
+
Authorization: `Bearer ${tokenData.token}`,
|
|
259
|
+
Accept: "application/json",
|
|
260
|
+
"User-Agent": UA,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
if (userinfoRes.ok) {
|
|
264
|
+
const userinfo = (await userinfoRes.json()) as {
|
|
265
|
+
email?: string;
|
|
266
|
+
name?: string;
|
|
267
|
+
username?: string;
|
|
268
|
+
};
|
|
269
|
+
email = userinfo.email || "";
|
|
270
|
+
name = userinfo.name || userinfo.username || "";
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// Best-effort
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
(callbacks as unknown as { onProgress?: (msg: string) => void }).onProgress?.(
|
|
277
|
+
"Login successful!",
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
refresh: `${tokenData.refresh_token}|${tokenData.user_id}|${machineID}`,
|
|
282
|
+
access: tokenData.token,
|
|
283
|
+
expires: expireMs - 5 * 60 * 1000,
|
|
284
|
+
userID: tokenData.user_id,
|
|
285
|
+
email,
|
|
286
|
+
name,
|
|
287
|
+
machineID,
|
|
288
|
+
} as QoderCredentials;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── OAuth Device Flow ───────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Run the PKCE-based OAuth device code flow.
|
|
295
|
+
* Opens a browser URL for the user to authenticate, then polls for the token.
|
|
296
|
+
*/
|
|
297
|
+
async function runDeviceFlow(
|
|
298
|
+
callbacks: OAuthLoginCallbacks,
|
|
299
|
+
): Promise<QoderCredentials> {
|
|
300
|
+
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
301
|
+
const nonce = crypto.randomUUID();
|
|
302
|
+
const machineID = getMachineId();
|
|
303
|
+
|
|
304
|
+
const verificationURI = `https://qoder.com/device/selectAccounts?challenge=${codeChallenge}&challenge_method=S256&machine_id=${machineID}&nonce=${nonce}`;
|
|
305
|
+
|
|
306
|
+
// Notify user
|
|
307
|
+
(callbacks as unknown as { onProgress?: (msg: string) => void }).onProgress?.(
|
|
308
|
+
"Please complete login in your browser...",
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
(
|
|
312
|
+
callbacks as unknown as {
|
|
313
|
+
onAuth?: (info: { url: string; instructions: string }) => void;
|
|
314
|
+
}
|
|
315
|
+
).onAuth?.({
|
|
316
|
+
url: verificationURI,
|
|
317
|
+
instructions: "Click to sign in with your Qoder account in the browser.",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const pollURL = `${POLL_URL}?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`;
|
|
321
|
+
const pollInterval = 2000;
|
|
322
|
+
const maxAttempts = 90; // 3 minutes
|
|
323
|
+
|
|
324
|
+
// Helper to read signal
|
|
325
|
+
const getSignal = (): AbortSignal | undefined =>
|
|
326
|
+
(callbacks as unknown as { signal?: AbortSignal }).signal;
|
|
327
|
+
|
|
328
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
329
|
+
if (getSignal()?.aborted) throw new Error("Login cancelled");
|
|
330
|
+
await abortableDelay(pollInterval, getSignal());
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const response = await fetch(pollURL, {
|
|
334
|
+
method: "GET",
|
|
335
|
+
headers: {
|
|
336
|
+
Accept: "application/json",
|
|
337
|
+
"User-Agent": UA,
|
|
338
|
+
},
|
|
339
|
+
signal: getSignal(),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (response.status === 202 || response.status === 404) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
const errText = await response.text();
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Device token poll failed: ${response.status} ${response.statusText}. Response: ${errText}`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const tokenData = (await response.json()) as {
|
|
354
|
+
token: string;
|
|
355
|
+
user_id: string;
|
|
356
|
+
refresh_token: string;
|
|
357
|
+
expires_at?: string;
|
|
358
|
+
expires_in?: number;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
if (!tokenData.token) {
|
|
362
|
+
throw new Error("Device token poll returned empty access token");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return buildDeviceFlowCredentials(callbacks, tokenData, machineID);
|
|
366
|
+
} catch (e: unknown) {
|
|
367
|
+
const err = e as { name?: string };
|
|
368
|
+
if (err.name === "AbortError" || getSignal()?.aborted) {
|
|
369
|
+
throw new Error("Login cancelled");
|
|
370
|
+
}
|
|
371
|
+
throw e;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
throw new Error("Authorization timed out");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Retrieve cached Qoder credentials (userID/email/name/machineID) from
|
|
382
|
+
* pi's auth store. Best-effort — returns null if not found.
|
|
383
|
+
*/
|
|
384
|
+
export function getCachedCredentials(): QoderCredentials | null {
|
|
385
|
+
if (existsSync(AUTH_FILE)) {
|
|
386
|
+
try {
|
|
387
|
+
const auth = JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
|
|
388
|
+
const creds = auth?.qoder;
|
|
389
|
+
if (creds?.userID) {
|
|
390
|
+
return creds as QoderCredentials;
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
// Best-effort
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Main login handler for `/login qoder`.
|
|
401
|
+
*
|
|
402
|
+
* Flow:
|
|
403
|
+
* 1. Check for PAT in env vars (QODER_PERSONAL_ACCESS_TOKEN or QODER_PAT)
|
|
404
|
+
* 2. If no PAT, prompt user for one (or choose browser login)
|
|
405
|
+
* 3. Exchange PAT for job token or run OAuth device flow
|
|
406
|
+
* 4. Cache models after login
|
|
407
|
+
*/
|
|
408
|
+
export async function loginQoder(
|
|
409
|
+
callbacks: OAuthLoginCallbacks,
|
|
410
|
+
): Promise<OAuthCredentials> {
|
|
411
|
+
// 1. Try environment variables first (PAT)
|
|
412
|
+
const pat = process.env.QODER_PERSONAL_ACCESS_TOKEN || process.env.QODER_PAT;
|
|
413
|
+
if (pat) {
|
|
414
|
+
try {
|
|
415
|
+
const creds = await credentialsFromPat(pat);
|
|
416
|
+
return creds as OAuthCredentials;
|
|
417
|
+
} catch {
|
|
418
|
+
(
|
|
419
|
+
callbacks as unknown as { onProgress?: (msg: string) => void }
|
|
420
|
+
).onProgress?.(
|
|
421
|
+
"Environment PAT invalid, falling back to interactive login...",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 2. Prompt for PAT or browser login
|
|
427
|
+
const prompt = (
|
|
428
|
+
callbacks as unknown as {
|
|
429
|
+
onPrompt: (p: {
|
|
430
|
+
message: string;
|
|
431
|
+
placeholder?: string;
|
|
432
|
+
allowEmpty?: boolean;
|
|
433
|
+
}) => Promise<string>;
|
|
434
|
+
}
|
|
435
|
+
).onPrompt;
|
|
436
|
+
|
|
437
|
+
if (!prompt) {
|
|
438
|
+
throw new Error("Login cancelled: no prompt handler available");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const entered = await prompt({
|
|
442
|
+
message:
|
|
443
|
+
"Paste a Qoder Personal Access Token (pt-...), or leave empty for browser login",
|
|
444
|
+
placeholder: "pt-...",
|
|
445
|
+
allowEmpty: true,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
if ((callbacks as unknown as { signal?: AbortSignal }).signal?.aborted) {
|
|
449
|
+
throw new Error("Login cancelled");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (entered?.trim()) {
|
|
453
|
+
const creds = await credentialsFromPat(entered.trim());
|
|
454
|
+
return creds as OAuthCredentials;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 3. OAuth device flow
|
|
458
|
+
return runDeviceFlow(callbacks) as Promise<OAuthCredentials>;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Token refresh handler.
|
|
463
|
+
*
|
|
464
|
+
* For PAT-based credentials: re-exchanges the stored PAT for a fresh job token.
|
|
465
|
+
* For OAuth-based credentials: calls the refresh_token endpoint.
|
|
466
|
+
* Falls back to extending validity by 1 hour if refresh fails.
|
|
467
|
+
*/
|
|
468
|
+
export async function refreshQoderToken(
|
|
469
|
+
credentials: OAuthCredentials,
|
|
470
|
+
): Promise<OAuthCredentials> {
|
|
471
|
+
// PAT-based: re-exchange
|
|
472
|
+
if (isPatRefresh(credentials.refresh)) {
|
|
473
|
+
const { pat } = decodePatRefresh(credentials.refresh);
|
|
474
|
+
if (pat) {
|
|
475
|
+
try {
|
|
476
|
+
const refreshed = await credentialsFromPat(pat);
|
|
477
|
+
return refreshed as OAuthCredentials;
|
|
478
|
+
} catch {
|
|
479
|
+
// Fall through to validity extension
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
...credentials,
|
|
484
|
+
expires: Date.now() + 60 * 60 * 1000, // extend 1 hour
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// OAuth-based: use refresh token
|
|
489
|
+
const parts = credentials.refresh.split("|");
|
|
490
|
+
const refreshToken = parts[0] || "";
|
|
491
|
+
const userID = parts[1] || "";
|
|
492
|
+
const machineID = parts[2] || getMachineId();
|
|
493
|
+
const prev = credentials as Partial<QoderCredentials>;
|
|
494
|
+
const prevName = prev.name || "";
|
|
495
|
+
const prevEmail = prev.email || "";
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
const response = await fetch(REFRESH_URL, {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: {
|
|
501
|
+
"Content-Type": "application/json",
|
|
502
|
+
Authorization: `Bearer ${credentials.access}`,
|
|
503
|
+
Accept: "application/json",
|
|
504
|
+
"User-Agent": UA,
|
|
505
|
+
},
|
|
506
|
+
body: JSON.stringify({ refreshToken }),
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
if (response.ok) {
|
|
510
|
+
const data = (await response.json()) as {
|
|
511
|
+
token: string;
|
|
512
|
+
refresh_token?: string;
|
|
513
|
+
expires_at?: string;
|
|
514
|
+
expires_in?: number;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const newAccess = data.token;
|
|
518
|
+
const newRefresh = data.refresh_token || refreshToken;
|
|
519
|
+
|
|
520
|
+
let expireMs = Date.now() + 30 * 24 * 60 * 60 * 1000;
|
|
521
|
+
if (data.expires_at) {
|
|
522
|
+
const parsed = Date.parse(data.expires_at);
|
|
523
|
+
if (!Number.isNaN(parsed)) expireMs = parsed;
|
|
524
|
+
} else if (data.expires_in) {
|
|
525
|
+
expireMs = Date.now() + data.expires_in * 1000;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
...credentials,
|
|
530
|
+
refresh: `${newRefresh}|${userID}|${machineID}`,
|
|
531
|
+
access: newAccess,
|
|
532
|
+
expires: expireMs - 5 * 60 * 1000,
|
|
533
|
+
userID,
|
|
534
|
+
email: prevEmail,
|
|
535
|
+
name: prevName,
|
|
536
|
+
machineID,
|
|
537
|
+
} as QoderCredentials;
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
// Fall through
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Fallback: extend validity by 1 hour
|
|
544
|
+
return {
|
|
545
|
+
...credentials,
|
|
546
|
+
expires: Date.now() + 60 * 60 * 1000,
|
|
547
|
+
};
|
|
548
|
+
}
|