fortrip 0.2.1 → 0.3.1
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/AGENT_GUIDE.md +114 -42
- package/LICENSE +21 -0
- package/README.md +67 -19
- package/dist/cli.js +763 -133
- package/guides/planning.md +8 -5
- package/package.json +2 -2
- package/skills/fortrip-hotels/SKILL.md +0 -20
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ var CliError = class extends Error {
|
|
|
17
17
|
exitCode;
|
|
18
18
|
details;
|
|
19
19
|
};
|
|
20
|
+
var notices = {};
|
|
20
21
|
function usageError(message) {
|
|
21
22
|
return new CliError(message, EXIT.usage);
|
|
22
23
|
}
|
|
@@ -28,8 +29,8 @@ var VERSION = JSON.parse(
|
|
|
28
29
|
).version;
|
|
29
30
|
|
|
30
31
|
// src/api.ts
|
|
31
|
-
var LOGIN_HINT = "
|
|
32
|
-
var REQUEST_TIMEOUT_MS =
|
|
32
|
+
var LOGIN_HINT = "Rerun the command to get a new sign-in link.";
|
|
33
|
+
var REQUEST_TIMEOUT_MS = 15e4;
|
|
33
34
|
async function request(apiUrl, path, {
|
|
34
35
|
method = "GET",
|
|
35
36
|
token,
|
|
@@ -52,41 +53,47 @@ async function request(apiUrl, path, {
|
|
|
52
53
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
53
54
|
});
|
|
54
55
|
} catch (error) {
|
|
55
|
-
throw
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
throw transportError(apiUrl, error);
|
|
57
|
+
}
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = await response.text();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw transportError(apiUrl, error, response.status);
|
|
59
63
|
}
|
|
60
|
-
const text = await response.text();
|
|
61
64
|
let parsed = null;
|
|
62
65
|
try {
|
|
63
66
|
parsed = text ? JSON.parse(text) : {};
|
|
64
67
|
} catch {
|
|
65
68
|
parsed = null;
|
|
66
69
|
}
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const isObject2 = parsed !== null && typeof parsed === "object";
|
|
71
|
+
const result = isObject2 ? parsed : { error: `Unexpected ${response.status} response`, raw: text.slice(0, 200) };
|
|
72
|
+
if (response.status >= 500 && !(isObject2 && text)) result.retryable = true;
|
|
73
|
+
return { status: response.status, body: result, headers: response.headers };
|
|
74
|
+
}
|
|
75
|
+
function transportError(apiUrl, error, status) {
|
|
76
|
+
const message = error?.name === "TimeoutError" ? `No response from ${apiUrl} within ${REQUEST_TIMEOUT_MS / 1e3} s` : `Could not reach ${apiUrl}: ${error?.message ?? String(error)}`;
|
|
77
|
+
return new CliError(message, EXIT.error, {
|
|
78
|
+
...status !== void 0 ? { status } : {},
|
|
79
|
+
retryable: true
|
|
80
|
+
});
|
|
73
81
|
}
|
|
74
82
|
function unwrap(response) {
|
|
75
83
|
const { status, body, headers } = response;
|
|
76
84
|
if (status >= 200 && status < 300 && body.success === true) return body.data;
|
|
77
85
|
const { success: _success, error, message, ...rest } = body;
|
|
78
86
|
const text = typeof error === "string" ? error : typeof message === "string" ? message : `Request failed with status ${status}`;
|
|
79
|
-
const
|
|
87
|
+
const retryAfter = Number(headers.get("retry-after"));
|
|
88
|
+
const details = {
|
|
89
|
+
status,
|
|
90
|
+
...rest,
|
|
91
|
+
...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {}
|
|
92
|
+
};
|
|
80
93
|
if (status === 401) {
|
|
81
94
|
throw new CliError(`${text}. ${LOGIN_HINT}`, EXIT.unauthenticated, details);
|
|
82
95
|
}
|
|
83
|
-
if (status === 429)
|
|
84
|
-
const retryAfter = Number(headers.get("retry-after"));
|
|
85
|
-
throw new CliError(text, EXIT.rateLimited, {
|
|
86
|
-
...details,
|
|
87
|
-
...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {}
|
|
88
|
-
});
|
|
89
|
-
}
|
|
96
|
+
if (status === 429) throw new CliError(text, EXIT.rateLimited, details);
|
|
90
97
|
if (status === 400) throw new CliError(text, EXIT.usage, details);
|
|
91
98
|
if (status >= 300 && status < 400) {
|
|
92
99
|
throw new CliError(
|
|
@@ -102,12 +109,29 @@ function unwrap(response) {
|
|
|
102
109
|
import { readFileSync as readFileSync2 } from "fs";
|
|
103
110
|
import { parseArgs } from "util";
|
|
104
111
|
var BODY_OPTION = { body: { type: "string" } };
|
|
112
|
+
var CURRENCY_OPTION = { currency: { type: "string" } };
|
|
105
113
|
function parse(argv, options) {
|
|
114
|
+
let parsed;
|
|
106
115
|
try {
|
|
107
|
-
|
|
116
|
+
parsed = parseArgs({
|
|
117
|
+
args: argv,
|
|
118
|
+
options,
|
|
119
|
+
allowPositionals: true,
|
|
120
|
+
strict: true,
|
|
121
|
+
tokens: true
|
|
122
|
+
});
|
|
108
123
|
} catch (error) {
|
|
109
124
|
throw usageError(error.message);
|
|
110
125
|
}
|
|
126
|
+
const seen = /* @__PURE__ */ new Set();
|
|
127
|
+
for (const token of parsed.tokens) {
|
|
128
|
+
if (token.kind !== "option" || options[token.name]?.multiple) continue;
|
|
129
|
+
if (seen.has(token.name)) {
|
|
130
|
+
throw usageError(`--${token.name} was given more than once`);
|
|
131
|
+
}
|
|
132
|
+
seen.add(token.name);
|
|
133
|
+
}
|
|
134
|
+
return parsed;
|
|
111
135
|
}
|
|
112
136
|
function baseBody(values) {
|
|
113
137
|
const raw = values.body;
|
|
@@ -141,12 +165,31 @@ function nums(flag, values) {
|
|
|
141
165
|
if (!values?.length) return void 0;
|
|
142
166
|
return values.flatMap((value) => value.split(",")).map((value) => num(flag, value.trim()));
|
|
143
167
|
}
|
|
168
|
+
function id(flag, value) {
|
|
169
|
+
if (value === void 0) return void 0;
|
|
170
|
+
const parsed = Number(value.trim());
|
|
171
|
+
if (!/^\d+$/.test(value.trim()) || !Number.isSafeInteger(parsed)) {
|
|
172
|
+
throw usageError(`--${flag} must be a numeric id, got "${value}"`);
|
|
173
|
+
}
|
|
174
|
+
return parsed;
|
|
175
|
+
}
|
|
176
|
+
function ids(flag, values) {
|
|
177
|
+
if (!values?.length) return void 0;
|
|
178
|
+
return values.flatMap((value) => value.split(",")).map((value) => id(flag, value));
|
|
179
|
+
}
|
|
144
180
|
function str(value) {
|
|
145
181
|
return typeof value === "string" ? value : void 0;
|
|
146
182
|
}
|
|
147
183
|
function list(value) {
|
|
148
184
|
return Array.isArray(value) && value.length ? value : void 0;
|
|
149
185
|
}
|
|
186
|
+
function currency(values, base, env) {
|
|
187
|
+
const flag = str(values.currency)?.trim().toUpperCase();
|
|
188
|
+
if (flag === "") throw usageError("--currency must not be empty");
|
|
189
|
+
if (flag) return flag;
|
|
190
|
+
if (base.currency !== void 0) return void 0;
|
|
191
|
+
return env.FORTRIP_CURRENCY?.trim().toUpperCase() || void 0;
|
|
192
|
+
}
|
|
150
193
|
function merge(base, overrides) {
|
|
151
194
|
const body = { ...base };
|
|
152
195
|
for (const [key, value] of Object.entries(overrides)) {
|
|
@@ -156,6 +199,7 @@ function merge(base, overrides) {
|
|
|
156
199
|
}
|
|
157
200
|
var SEARCH_OPTIONS = {
|
|
158
201
|
...BODY_OPTION,
|
|
202
|
+
...CURRENCY_OPTION,
|
|
159
203
|
query: { type: "string", short: "q" },
|
|
160
204
|
"city-code": { type: "string" },
|
|
161
205
|
near: { type: "string" },
|
|
@@ -170,7 +214,8 @@ var SEARCH_OPTIONS = {
|
|
|
170
214
|
page: { type: "string" },
|
|
171
215
|
"page-size": { type: "string" }
|
|
172
216
|
};
|
|
173
|
-
function searchBody(values) {
|
|
217
|
+
function searchBody(values, env = process.env) {
|
|
218
|
+
const base = baseBody(values);
|
|
174
219
|
let near;
|
|
175
220
|
const nearRaw = str(values.near);
|
|
176
221
|
if (nearRaw !== void 0) {
|
|
@@ -184,9 +229,9 @@ function searchBody(values) {
|
|
|
184
229
|
radiusKm: num("near", radiusKm)
|
|
185
230
|
};
|
|
186
231
|
}
|
|
187
|
-
return merge(
|
|
232
|
+
return merge(base, {
|
|
188
233
|
query: str(values.query),
|
|
189
|
-
cityCode:
|
|
234
|
+
cityCode: id("city-code", str(values["city-code"])),
|
|
190
235
|
near,
|
|
191
236
|
checkIn: str(values["check-in"]),
|
|
192
237
|
checkOut: str(values["check-out"]),
|
|
@@ -197,18 +242,19 @@ function searchBody(values) {
|
|
|
197
242
|
priceMax: num("price-max", str(values["price-max"])),
|
|
198
243
|
sortBy: str(values.sort),
|
|
199
244
|
page: num("page", str(values.page)),
|
|
200
|
-
pageSize: num("page-size", str(values["page-size"]))
|
|
245
|
+
pageSize: num("page-size", str(values["page-size"])),
|
|
246
|
+
currency: currency(values, base, env)
|
|
201
247
|
});
|
|
202
248
|
}
|
|
203
249
|
var HOTEL_OPTIONS = {
|
|
204
250
|
"with-rooms": { type: "boolean" }
|
|
205
251
|
};
|
|
206
252
|
function hotelPath(positionals, values) {
|
|
207
|
-
const [
|
|
208
|
-
if (!
|
|
253
|
+
const [id2, ...extra] = positionals;
|
|
254
|
+
if (!id2 || extra.length || !/^\d+$/.test(id2)) {
|
|
209
255
|
throw usageError("Usage: fortrip hotel <hotelId> [--with-rooms]");
|
|
210
256
|
}
|
|
211
|
-
return `/api/huizhi/hotels/${
|
|
257
|
+
return `/api/huizhi/hotels/${id2}${values["with-rooms"] ? "?include=rooms" : ""}`;
|
|
212
258
|
}
|
|
213
259
|
var STAY_OPTIONS = {
|
|
214
260
|
"check-in": { type: "string" },
|
|
@@ -217,17 +263,18 @@ var STAY_OPTIONS = {
|
|
|
217
263
|
adults: { type: "string" },
|
|
218
264
|
"child-age": { type: "string", multiple: true }
|
|
219
265
|
};
|
|
220
|
-
function stayFields(values, base) {
|
|
266
|
+
function stayFields(values, base, defaults = {}) {
|
|
221
267
|
return {
|
|
222
268
|
checkIn: str(values["check-in"]),
|
|
223
269
|
checkOut: str(values["check-out"]),
|
|
224
|
-
rooms: num("rooms", str(values.rooms)) ?? (base.rooms === void 0 ?
|
|
225
|
-
adults: num("adults", str(values.adults)) ?? (base.adults === void 0 ?
|
|
270
|
+
rooms: num("rooms", str(values.rooms)) ?? (base.rooms === void 0 ? defaults.rooms : void 0),
|
|
271
|
+
adults: num("adults", str(values.adults)) ?? (base.adults === void 0 ? defaults.adults : void 0),
|
|
226
272
|
childrenAges: nums("child-age", list(values["child-age"]))
|
|
227
273
|
};
|
|
228
274
|
}
|
|
229
275
|
var RATES_OPTIONS = {
|
|
230
276
|
...BODY_OPTION,
|
|
277
|
+
...CURRENCY_OPTION,
|
|
231
278
|
...STAY_OPTIONS,
|
|
232
279
|
hotel: { type: "string", multiple: true },
|
|
233
280
|
nationality: { type: "string" },
|
|
@@ -236,18 +283,19 @@ var RATES_OPTIONS = {
|
|
|
236
283
|
"include-unbookable": { type: "boolean" },
|
|
237
284
|
"max-rooms": { type: "string" }
|
|
238
285
|
};
|
|
239
|
-
function ratesBody(values) {
|
|
286
|
+
function ratesBody(values, env = process.env) {
|
|
240
287
|
const base = baseBody(values);
|
|
241
288
|
return merge(base, {
|
|
242
|
-
hotelIds:
|
|
243
|
-
...stayFields(values, base),
|
|
289
|
+
hotelIds: ids("hotel", list(values.hotel)),
|
|
290
|
+
...stayFields(values, base, { rooms: 1, adults: 2 }),
|
|
244
291
|
nationality: str(values.nationality)?.toUpperCase(),
|
|
245
292
|
boardCodes: list(values.board)?.flatMap(
|
|
246
293
|
(code) => code.split(",").map((part) => part.trim().toUpperCase())
|
|
247
294
|
),
|
|
248
295
|
freeCancellation: values["free-cancellation"] ? true : void 0,
|
|
249
296
|
bookableOnly: values["include-unbookable"] ? false : void 0,
|
|
250
|
-
maxRoomsPerHotel: num("max-rooms", str(values["max-rooms"]))
|
|
297
|
+
maxRoomsPerHotel: num("max-rooms", str(values["max-rooms"])),
|
|
298
|
+
currency: currency(values, base, env)
|
|
251
299
|
});
|
|
252
300
|
}
|
|
253
301
|
var BOOK_OPTIONS = {
|
|
@@ -278,8 +326,8 @@ function parseGuest(value) {
|
|
|
278
326
|
}
|
|
279
327
|
function bookBody(values) {
|
|
280
328
|
const base = baseBody(values);
|
|
281
|
-
|
|
282
|
-
hotelId:
|
|
329
|
+
const body = merge(base, {
|
|
330
|
+
hotelId: id("hotel", str(values.hotel)),
|
|
283
331
|
roomId: str(values["room-id"]),
|
|
284
332
|
ratePlanCode: str(values["rate-plan"]),
|
|
285
333
|
...stayFields(values, base),
|
|
@@ -289,11 +337,18 @@ function bookBody(values) {
|
|
|
289
337
|
contactEmail: str(values["contact-email"]),
|
|
290
338
|
specialRequests: str(values["special-requests"])
|
|
291
339
|
});
|
|
340
|
+
const missing = ["rooms", "adults"].filter((key) => body[key] === void 0);
|
|
341
|
+
if (missing.length) {
|
|
342
|
+
throw usageError(
|
|
343
|
+
`book needs ${missing.map((key) => `--${key}`).join(" and ")}, the same per-room values used in rates`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return body;
|
|
292
347
|
}
|
|
293
348
|
|
|
294
349
|
// src/auth.ts
|
|
295
|
-
import { hostname } from "os";
|
|
296
|
-
import { setTimeout as
|
|
350
|
+
import { hostname as hostname2 } from "os";
|
|
351
|
+
import { setTimeout as sleep2 } from "timers/promises";
|
|
297
352
|
|
|
298
353
|
// src/browser.ts
|
|
299
354
|
import { spawn } from "child_process";
|
|
@@ -309,10 +364,31 @@ function openBrowser(url) {
|
|
|
309
364
|
}
|
|
310
365
|
|
|
311
366
|
// src/credentials.ts
|
|
312
|
-
import {
|
|
313
|
-
import {
|
|
314
|
-
import {
|
|
367
|
+
import { randomBytes } from "crypto";
|
|
368
|
+
import { readlinkSync } from "fs";
|
|
369
|
+
import {
|
|
370
|
+
chmod,
|
|
371
|
+
lstat,
|
|
372
|
+
mkdir,
|
|
373
|
+
readdir,
|
|
374
|
+
readFile,
|
|
375
|
+
rename,
|
|
376
|
+
rm,
|
|
377
|
+
stat,
|
|
378
|
+
writeFile
|
|
379
|
+
} from "fs/promises";
|
|
380
|
+
import { homedir, hostname } from "os";
|
|
381
|
+
import { basename, dirname, join } from "path";
|
|
382
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
315
383
|
var DEFAULT_API_URL = "https://fortrip.ai";
|
|
384
|
+
var CREDENTIALS_LOCK_STALE_MS = 1e4;
|
|
385
|
+
var LOGIN_LOCK_STALE_MS = REQUEST_TIMEOUT_MS + 3e4;
|
|
386
|
+
var TAKEOVER_STALE_MS = 2e3;
|
|
387
|
+
var READ_RETRY_MS = [20, 50, 100, 200, 300, 400];
|
|
388
|
+
var LEFT_EMPTY_MS = 2e3;
|
|
389
|
+
var LOCK_WAIT_MS = 3e4;
|
|
390
|
+
var SAVE_CHECK_MS = 200;
|
|
391
|
+
var RENAME_RETRY_MS = [20, 50, 100, 200, 300, 400, 500, 500];
|
|
316
392
|
function credentialsPath(env = process.env) {
|
|
317
393
|
const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
318
394
|
return join(base, "fortrip", "credentials.json");
|
|
@@ -331,27 +407,300 @@ function resolveApiUrl(env = process.env) {
|
|
|
331
407
|
}
|
|
332
408
|
return url.origin;
|
|
333
409
|
}
|
|
410
|
+
function isObject(value) {
|
|
411
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
412
|
+
}
|
|
413
|
+
function isLogin(value) {
|
|
414
|
+
return isObject(value) && typeof value.deviceCode === "string" && typeof value.userCode === "string" && typeof value.loginUrl === "string" && typeof value.expiresAt === "string";
|
|
415
|
+
}
|
|
416
|
+
function parse2(text) {
|
|
417
|
+
let raw;
|
|
418
|
+
try {
|
|
419
|
+
raw = JSON.parse(text);
|
|
420
|
+
} catch {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
if (!isObject(raw)) return null;
|
|
424
|
+
const { tokens, pending, pendingLogins, ...other } = raw;
|
|
425
|
+
const logins = {};
|
|
426
|
+
const add = (apiUrl, login2) => {
|
|
427
|
+
const list2 = logins[apiUrl] ??= [];
|
|
428
|
+
if (isLogin(login2) && !list2.some((known) => known.deviceCode === login2.deviceCode)) {
|
|
429
|
+
list2.push(login2);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
for (const [apiUrl, list2] of Object.entries(isObject(pendingLogins) ? pendingLogins : {})) {
|
|
433
|
+
for (const login2 of Array.isArray(list2) ? list2 : []) add(apiUrl, login2);
|
|
434
|
+
}
|
|
435
|
+
for (const [apiUrl, login2] of Object.entries(isObject(pending) ? pending : {})) {
|
|
436
|
+
for (const each of Array.isArray(login2) ? login2 : [login2]) add(apiUrl, each);
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
tokens: isObject(tokens) ? tokens : {},
|
|
440
|
+
logins,
|
|
441
|
+
other
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function serialize({ tokens, logins, other }) {
|
|
445
|
+
const pending = {};
|
|
446
|
+
const pendingLogins = {};
|
|
447
|
+
for (const [apiUrl, list2] of Object.entries(logins)) {
|
|
448
|
+
const live = list2.filter((login2) => !isExpired(login2));
|
|
449
|
+
const newest = live.at(-1);
|
|
450
|
+
if (!newest) continue;
|
|
451
|
+
pendingLogins[apiUrl] = live;
|
|
452
|
+
pending[apiUrl] = newest;
|
|
453
|
+
}
|
|
454
|
+
return `${JSON.stringify({ ...other, tokens, pending, pendingLogins }, null, 2)}
|
|
455
|
+
`;
|
|
456
|
+
}
|
|
457
|
+
async function readText(path) {
|
|
458
|
+
try {
|
|
459
|
+
return await readFile(path, "utf8");
|
|
460
|
+
} catch (error) {
|
|
461
|
+
if (error.code === "ENOENT") return null;
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
334
465
|
async function readCredentials(path) {
|
|
466
|
+
for (let attempt = 0; ; attempt++) {
|
|
467
|
+
const text = await readText(path);
|
|
468
|
+
if (text === null) return { text, credentials: emptyCredentials() };
|
|
469
|
+
const credentials = parse2(text);
|
|
470
|
+
if (credentials) return { text, credentials };
|
|
471
|
+
if (text === "" && await isLeftEmpty(path)) {
|
|
472
|
+
return { text, credentials: emptyCredentials() };
|
|
473
|
+
}
|
|
474
|
+
const delay = READ_RETRY_MS[attempt];
|
|
475
|
+
if (delay === void 0) {
|
|
476
|
+
throw text === "" ? new CliError(`${path} is being written by another program; retry.`, EXIT.error, {
|
|
477
|
+
retryable: true
|
|
478
|
+
}) : new CliError(
|
|
479
|
+
`${path} isn't valid JSON. Move it away (for example to ${basename(path)}.broken) and sign in again.`,
|
|
480
|
+
EXIT.error,
|
|
481
|
+
{ file: path }
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
await sleep(delay);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function emptyCredentials() {
|
|
488
|
+
return { tokens: {}, logins: {}, other: {} };
|
|
489
|
+
}
|
|
490
|
+
async function isLeftEmpty(path) {
|
|
491
|
+
const info = await stat(path).catch(() => null);
|
|
492
|
+
return info !== null && info.size === 0 && Date.now() - info.mtimeMs > LEFT_EMPTY_MS;
|
|
493
|
+
}
|
|
494
|
+
async function updateCredentials(env, change) {
|
|
495
|
+
const path = credentialsPath(env);
|
|
496
|
+
await withLock(path, async () => {
|
|
497
|
+
const recovered = await readRecovered(path);
|
|
498
|
+
for (let attempt = 0; ; attempt++) {
|
|
499
|
+
const { text, credentials } = await readCredentials(path);
|
|
500
|
+
for (const { tokens } of recovered) mergeTokens(credentials, tokens);
|
|
501
|
+
change(credentials);
|
|
502
|
+
const next = serialize(credentials);
|
|
503
|
+
if (next === text || await replace(path, next, text)) break;
|
|
504
|
+
if (attempt === 20) {
|
|
505
|
+
throw new CliError(`${path} keeps changing; retry.`, EXIT.error, {
|
|
506
|
+
retryable: true
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
await sleep(Math.random() * 20);
|
|
510
|
+
}
|
|
511
|
+
for (const { file } of recovered) await rm(file, { force: true });
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
function mergeTokens(credentials, tokens) {
|
|
515
|
+
for (const [apiUrl, token] of Object.entries(tokens)) {
|
|
516
|
+
const saved = credentials.tokens[apiUrl];
|
|
517
|
+
if (!saved || saved.createdAt < token.createdAt) credentials.tokens[apiUrl] = token;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function recoveryPrefix(path) {
|
|
521
|
+
return `${basename(path, ".json")}.recovered-`;
|
|
522
|
+
}
|
|
523
|
+
async function readRecovered(path) {
|
|
524
|
+
const prefix = recoveryPrefix(path);
|
|
525
|
+
const names = (await readdir(dirname(path)).catch(() => [])).filter((name) => name.startsWith(prefix) && name.endsWith(".json")).sort();
|
|
526
|
+
const recovered = [];
|
|
527
|
+
for (const name of names) {
|
|
528
|
+
const file = join(dirname(path), name);
|
|
529
|
+
const parsed = parse2(await readText(file).catch(() => null) ?? "");
|
|
530
|
+
if (parsed) recovered.push({ file, tokens: parsed.tokens });
|
|
531
|
+
}
|
|
532
|
+
return recovered;
|
|
533
|
+
}
|
|
534
|
+
async function loadRecoveredTokens(apiUrl, env = process.env) {
|
|
535
|
+
return (await readRecovered(credentialsPath(env))).flatMap(
|
|
536
|
+
({ tokens }) => tokens[apiUrl] ? [tokens[apiUrl].accessToken] : []
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
async function replace(path, text, expected) {
|
|
540
|
+
const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
541
|
+
try {
|
|
542
|
+
await writeFile(temp, text, { mode: 384, flag: "wx" });
|
|
543
|
+
await chmod(temp, 384);
|
|
544
|
+
if (await readText(path) !== expected) {
|
|
545
|
+
await rm(temp, { force: true });
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
for (let attempt = 0; ; attempt++) {
|
|
549
|
+
try {
|
|
550
|
+
await rename(temp, path);
|
|
551
|
+
return true;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
const delay = RENAME_RETRY_MS[attempt];
|
|
554
|
+
if (!isBusy(error) || delay === void 0) throw error;
|
|
555
|
+
await sleep(delay);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
} catch (error) {
|
|
559
|
+
await rm(temp, { force: true });
|
|
560
|
+
const { code } = error;
|
|
561
|
+
throw new CliError(
|
|
562
|
+
`Could not write ${path}: ${error.message}`,
|
|
563
|
+
EXIT.error,
|
|
564
|
+
isBusy(error) ? { code, retryable: true } : { code }
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function isBusy(error) {
|
|
569
|
+
const { code } = error;
|
|
570
|
+
return code === "EPERM" || code === "EACCES" || code === "EBUSY";
|
|
571
|
+
}
|
|
572
|
+
function isExpired(login2) {
|
|
573
|
+
return !(Date.parse(login2.expiresAt) > Date.now());
|
|
574
|
+
}
|
|
575
|
+
async function withLock(path, fn) {
|
|
576
|
+
await ensureDir(path);
|
|
577
|
+
const lock = `${path}.lock`;
|
|
578
|
+
const giveUpAt = Date.now() + LOCK_WAIT_MS;
|
|
579
|
+
let release;
|
|
580
|
+
while (!(release = await tryLock(lock, CREDENTIALS_LOCK_STALE_MS))) {
|
|
581
|
+
if (Date.now() > giveUpAt) {
|
|
582
|
+
throw new CliError(
|
|
583
|
+
`Timed out waiting for ${lock}. If no other fortrip command is running, delete it.`,
|
|
584
|
+
EXIT.error,
|
|
585
|
+
{ retryable: true, lockFile: lock }
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
await sleep(10 + Math.random() * 40);
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
await fn();
|
|
592
|
+
} finally {
|
|
593
|
+
await release();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async function ensureDir(path) {
|
|
597
|
+
const dir = dirname(path);
|
|
598
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
599
|
+
await chmod(dir, 448);
|
|
600
|
+
}
|
|
601
|
+
async function tryLoginLock(env = process.env) {
|
|
602
|
+
const path = credentialsPath(env);
|
|
603
|
+
await ensureDir(path);
|
|
604
|
+
return tryLock(`${path}.login-lock`, LOGIN_LOCK_STALE_MS);
|
|
605
|
+
}
|
|
606
|
+
var PID_NAMESPACE = (() => {
|
|
335
607
|
try {
|
|
336
|
-
|
|
337
|
-
return { tokens: parsed?.tokens ?? {}, pending: parsed?.pending ?? {} };
|
|
608
|
+
return readlinkSync("/proc/self/ns/pid");
|
|
338
609
|
} catch {
|
|
339
|
-
return
|
|
610
|
+
return "";
|
|
340
611
|
}
|
|
612
|
+
})();
|
|
613
|
+
function newOwner() {
|
|
614
|
+
return JSON.stringify({
|
|
615
|
+
pid: process.pid,
|
|
616
|
+
host: hostname(),
|
|
617
|
+
pidns: PID_NAMESPACE,
|
|
618
|
+
id: randomBytes(8).toString("hex")
|
|
619
|
+
});
|
|
341
620
|
}
|
|
342
|
-
async function
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
621
|
+
async function tryLock(path, staleMs) {
|
|
622
|
+
const owner = newOwner();
|
|
623
|
+
if (await create(path, owner)) return releaser(path, owner);
|
|
624
|
+
const seen = await inspect(path);
|
|
625
|
+
if (!seen || !isAbandoned(path, seen, staleMs)) return null;
|
|
626
|
+
const guard = `${path}.takeover`;
|
|
627
|
+
const guardOwner = newOwner();
|
|
628
|
+
if (!await create(guard, guardOwner)) {
|
|
629
|
+
const held = await inspect(guard);
|
|
630
|
+
if (held && isAbandoned(guard, held, TAKEOVER_STALE_MS)) {
|
|
631
|
+
await rm(guard, { force: true });
|
|
632
|
+
}
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
const current = await inspect(path);
|
|
637
|
+
if (current?.content === seen.content && current.mtimeMs === seen.mtimeMs) {
|
|
638
|
+
await rm(path, { force: true });
|
|
639
|
+
}
|
|
640
|
+
} finally {
|
|
641
|
+
await releaser(guard, guardOwner)();
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
async function create(path, content) {
|
|
646
|
+
try {
|
|
647
|
+
await writeFile(path, content, { flag: "wx", mode: 384 });
|
|
648
|
+
return true;
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (error.code === "EEXIST" || process.platform === "win32" && isBusy(error)) {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function releaser(path, owner) {
|
|
657
|
+
return async () => {
|
|
658
|
+
try {
|
|
659
|
+
if (await readFile(path, "utf8") === owner) await rm(path, { force: true });
|
|
660
|
+
} catch {
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
async function inspect(path) {
|
|
665
|
+
const info = await lstat(path).catch(() => null);
|
|
666
|
+
if (!info) return null;
|
|
667
|
+
const content = await readFile(path, "utf8").catch(() => "");
|
|
668
|
+
return { content, mtimeMs: info.mtimeMs };
|
|
669
|
+
}
|
|
670
|
+
var firstSeen = /* @__PURE__ */ new Map();
|
|
671
|
+
function isAbandoned(path, { content, mtimeMs }, staleMs) {
|
|
672
|
+
const now = Date.now();
|
|
673
|
+
if (now - mtimeMs > staleMs) return true;
|
|
674
|
+
const holder = parseOwner(content);
|
|
675
|
+
if (holder && holder.host === hostname() && holder.pidns === PID_NAMESPACE && !isRunning(holder.pid)) {
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
const first = firstSeen.get(path);
|
|
679
|
+
if (first?.content !== content || first.mtimeMs !== mtimeMs) {
|
|
680
|
+
firstSeen.set(path, { content, mtimeMs, at: now });
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
return now - first.at > staleMs;
|
|
684
|
+
}
|
|
685
|
+
function parseOwner(content) {
|
|
686
|
+
try {
|
|
687
|
+
const owner = JSON.parse(content);
|
|
688
|
+
return isObject(owner) && Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.host === "string" && typeof owner.pidns === "string" ? owner : null;
|
|
689
|
+
} catch {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function isRunning(pid) {
|
|
694
|
+
try {
|
|
695
|
+
process.kill(pid, 0);
|
|
696
|
+
return true;
|
|
697
|
+
} catch (error) {
|
|
698
|
+
return error.code === "EPERM";
|
|
346
699
|
}
|
|
347
|
-
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
348
|
-
await writeFile(path, `${JSON.stringify(file, null, 2)}
|
|
349
|
-
`, { mode: 384 });
|
|
350
|
-
await chmod(path, 384);
|
|
351
700
|
}
|
|
352
701
|
async function loadStoredToken(apiUrl, env = process.env) {
|
|
353
|
-
const
|
|
354
|
-
return
|
|
702
|
+
const { credentials } = await readCredentials(credentialsPath(env));
|
|
703
|
+
return credentials.tokens[apiUrl]?.accessToken ?? null;
|
|
355
704
|
}
|
|
356
705
|
async function resolveToken(apiUrl, env = process.env) {
|
|
357
706
|
const fromEnv = env.FORTRIP_TOKEN?.trim();
|
|
@@ -361,60 +710,205 @@ async function resolveToken(apiUrl, env = process.env) {
|
|
|
361
710
|
}
|
|
362
711
|
async function saveToken(apiUrl, accessToken, env = process.env) {
|
|
363
712
|
const path = credentialsPath(env);
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
713
|
+
const token = { accessToken, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
714
|
+
try {
|
|
715
|
+
for (let attempt = 0; ; attempt++) {
|
|
716
|
+
await updateCredentials(env, (credentials) => {
|
|
717
|
+
credentials.tokens[apiUrl] = token;
|
|
718
|
+
delete credentials.logins[apiUrl];
|
|
719
|
+
});
|
|
720
|
+
await sleep(SAVE_CHECK_MS);
|
|
721
|
+
const saved = (await readCredentials(path)).credentials.tokens[apiUrl];
|
|
722
|
+
if (saved && (saved.accessToken === accessToken || saved.createdAt > token.createdAt)) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (attempt === 4) throw new Error("another program keeps overwriting it");
|
|
726
|
+
}
|
|
727
|
+
} catch (error) {
|
|
728
|
+
const kept = join(
|
|
729
|
+
dirname(path),
|
|
730
|
+
`${recoveryPrefix(path)}${Date.now()}-${randomBytes(3).toString("hex")}.json`
|
|
731
|
+
);
|
|
732
|
+
const content = `${JSON.stringify({ tokens: { [apiUrl]: token } }, null, 2)}
|
|
733
|
+
`;
|
|
734
|
+
const saved = await writeFile(kept, content, { mode: 384, flag: "wx" }).then(
|
|
735
|
+
() => true,
|
|
736
|
+
() => false
|
|
737
|
+
);
|
|
738
|
+
throw new CliError(
|
|
739
|
+
saved ? `Signed in, but ${path} couldn't be updated (${error.message}). The sign-in is kept in ${kept} until the next successful save merges it back.` : `Signed in, but the sign-in couldn't be saved: ${error.message}`,
|
|
740
|
+
EXIT.error,
|
|
741
|
+
saved ? { recoveryFile: kept } : {}
|
|
742
|
+
);
|
|
743
|
+
}
|
|
368
744
|
}
|
|
369
|
-
async function deleteToken(apiUrl, env = process.env) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
745
|
+
async function deleteToken(apiUrl, env = process.env, only) {
|
|
746
|
+
await updateCredentials(env, (credentials) => {
|
|
747
|
+
const saved = credentials.tokens[apiUrl]?.accessToken;
|
|
748
|
+
if (only === void 0 || saved !== void 0 && only.includes(saved)) {
|
|
749
|
+
delete credentials.tokens[apiUrl];
|
|
750
|
+
}
|
|
751
|
+
});
|
|
374
752
|
}
|
|
375
|
-
async function
|
|
376
|
-
const
|
|
377
|
-
return
|
|
753
|
+
async function loadPendingLogins(apiUrl, env = process.env) {
|
|
754
|
+
const { credentials } = await readCredentials(credentialsPath(env));
|
|
755
|
+
return (credentials.logins[apiUrl] ?? []).filter((login2) => !isExpired(login2));
|
|
378
756
|
}
|
|
379
|
-
async function
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
757
|
+
async function addPendingLogin(apiUrl, pending, env = process.env) {
|
|
758
|
+
await updateCredentials(env, (credentials) => {
|
|
759
|
+
credentials.logins[apiUrl] = [...credentials.logins[apiUrl] ?? [], pending];
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
async function updatePendingLogin(apiUrl, deviceCode, changes, env = process.env) {
|
|
763
|
+
await updateCredentials(env, (credentials) => {
|
|
764
|
+
const logins = credentials.logins[apiUrl];
|
|
765
|
+
if (!logins) return;
|
|
766
|
+
credentials.logins[apiUrl] = logins.flatMap(
|
|
767
|
+
(login2) => login2.deviceCode !== deviceCode ? [login2] : changes ? [{ ...login2, ...changes }] : []
|
|
768
|
+
);
|
|
769
|
+
});
|
|
385
770
|
}
|
|
386
771
|
|
|
387
772
|
// src/auth.ts
|
|
388
|
-
|
|
773
|
+
var DEFAULT_INTERVAL_SECONDS = 5;
|
|
774
|
+
var LOGIN_WAIT_MS = 3e4;
|
|
775
|
+
var SHOWABLE_FOR_MS = 12e4;
|
|
776
|
+
async function requireToken(apiUrl, options = {}) {
|
|
777
|
+
const started = (/* @__PURE__ */ new Date()).toISOString();
|
|
389
778
|
const resolved = await resolveToken(apiUrl);
|
|
390
|
-
if (resolved) return resolved
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
779
|
+
if (resolved) return resolved;
|
|
780
|
+
return {
|
|
781
|
+
token: await finishOrStartLogin(apiUrl, { started, ...options }),
|
|
782
|
+
source: "file"
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
async function finishOrStartLogin(apiUrl, signIn) {
|
|
786
|
+
const giveUpAt = Date.now() + (signIn.loginWaitMs ?? LOGIN_WAIT_MS);
|
|
787
|
+
let release;
|
|
788
|
+
while (!(release = await tryLoginLock())) {
|
|
789
|
+
const saved = await savedToken(apiUrl, signIn);
|
|
790
|
+
if (saved) return saved;
|
|
791
|
+
if (Date.now() > giveUpAt) {
|
|
792
|
+
const pending = (await loadPendingLogins(apiUrl)).at(-1);
|
|
793
|
+
if (pending && isShowable(pending)) throw loginRequired(apiUrl, pending, signIn);
|
|
794
|
+
throw new CliError(
|
|
795
|
+
`Another fortrip command is still signing in to ${apiUrl}.`,
|
|
796
|
+
EXIT.error,
|
|
797
|
+
{ retryable: true }
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
await sleep2(250);
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
const approved = await pollPendingLogins(apiUrl, signIn);
|
|
804
|
+
if (approved) return approved;
|
|
805
|
+
let pending = (await loadPendingLogins(apiUrl)).at(-1);
|
|
806
|
+
if (!pending || !isShowable(pending)) {
|
|
807
|
+
const start = await startDeviceLogin(apiUrl);
|
|
808
|
+
const now = /* @__PURE__ */ new Date();
|
|
809
|
+
pending = {
|
|
810
|
+
deviceCode: start.deviceCode,
|
|
811
|
+
userCode: start.userCode,
|
|
812
|
+
loginUrl: start.verificationUriComplete,
|
|
813
|
+
expiresAt: new Date(now.getTime() + start.expiresIn * 1e3).toISOString(),
|
|
814
|
+
interval: start.interval,
|
|
815
|
+
checkedAt: now.toISOString()
|
|
816
|
+
};
|
|
817
|
+
await addPendingLogin(apiUrl, pending);
|
|
397
818
|
}
|
|
398
|
-
|
|
399
|
-
|
|
819
|
+
const saved = await savedToken(apiUrl, signIn);
|
|
820
|
+
if (saved) return saved;
|
|
821
|
+
throw loginRequired(apiUrl, pending, signIn);
|
|
822
|
+
} finally {
|
|
823
|
+
await release();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function isShowable(login2) {
|
|
827
|
+
return Date.parse(login2.expiresAt) - Date.now() >= SHOWABLE_FOR_MS;
|
|
828
|
+
}
|
|
829
|
+
async function savedToken(apiUrl, { rejected }) {
|
|
830
|
+
const saved = await loadStoredToken(apiUrl);
|
|
831
|
+
return saved !== rejected ? saved : null;
|
|
832
|
+
}
|
|
833
|
+
async function pollPendingLogins(apiUrl, signIn) {
|
|
834
|
+
const saved = await savedToken(apiUrl, signIn);
|
|
835
|
+
if (saved) return saved;
|
|
836
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
837
|
+
for (let login2 of await loadPendingLogins(apiUrl)) {
|
|
838
|
+
const { checkedAt } = login2;
|
|
839
|
+
if (checkedAt !== void 0 && checkedAt > signIn.started && checkedAt <= now) {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
843
|
+
const wait = nextPollAt(login2) - Date.now();
|
|
844
|
+
if (wait > 0) await sleep2(wait);
|
|
845
|
+
const asked = (/* @__PURE__ */ new Date()).toISOString();
|
|
846
|
+
const result = await exchange(apiUrl, login2.deviceCode);
|
|
847
|
+
const lastPolledAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
848
|
+
if (result.status === "ok") {
|
|
849
|
+
try {
|
|
850
|
+
await saveToken(apiUrl, result.accessToken);
|
|
851
|
+
} catch (error) {
|
|
852
|
+
notices.warning = error.message;
|
|
853
|
+
if (error instanceof CliError && error.details.recoveryFile) {
|
|
854
|
+
notices.recoveryFile = error.details.recoveryFile;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return result.accessToken;
|
|
858
|
+
}
|
|
859
|
+
if (result.status === "slow_down") {
|
|
860
|
+
const interval = result.interval ?? intervalOf(login2) + 5;
|
|
861
|
+
login2 = { ...login2, lastPolledAt, interval };
|
|
862
|
+
await updatePendingLogin(apiUrl, login2.deviceCode, { lastPolledAt, interval });
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
await updatePendingLogin(
|
|
866
|
+
apiUrl,
|
|
867
|
+
login2.deviceCode,
|
|
868
|
+
result.status === "authorization_pending" ? { lastPolledAt, checkedAt: asked } : null
|
|
869
|
+
// denied, expired or already redeemed
|
|
870
|
+
);
|
|
871
|
+
break;
|
|
400
872
|
}
|
|
401
873
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
874
|
+
return savedToken(apiUrl, signIn);
|
|
875
|
+
}
|
|
876
|
+
function intervalOf(login2) {
|
|
877
|
+
const { interval } = login2;
|
|
878
|
+
return typeof interval === "number" && Number.isFinite(interval) ? Math.min(Math.max(interval, 0), 60) : DEFAULT_INTERVAL_SECONDS;
|
|
879
|
+
}
|
|
880
|
+
function nextPollAt(login2) {
|
|
881
|
+
const last = Date.parse(login2.lastPolledAt ?? "");
|
|
882
|
+
if (!Number.isFinite(last)) return 0;
|
|
883
|
+
return Math.min(last, Date.now()) + intervalOf(login2) * 1e3;
|
|
884
|
+
}
|
|
885
|
+
async function authorizedRequest(apiUrl, path, init = {}) {
|
|
886
|
+
const auth = await requireToken(apiUrl);
|
|
887
|
+
const response = await request(apiUrl, path, { ...init, token: auth.token });
|
|
888
|
+
if (response.status !== 401) return response;
|
|
889
|
+
if (auth.source === "env") {
|
|
890
|
+
throw new CliError(
|
|
891
|
+
`${apiUrl} didn't accept FORTRIP_TOKEN. If this keeps happening, the user should replace it, or unset it to sign in through the browser.`,
|
|
892
|
+
EXIT.unauthenticated,
|
|
893
|
+
{ status: 401, tokenSource: "FORTRIP_TOKEN" }
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
const token = await finishOrStartLogin(apiUrl, {
|
|
897
|
+
started: (/* @__PURE__ */ new Date()).toISOString(),
|
|
898
|
+
rejected: auth.token
|
|
899
|
+
});
|
|
900
|
+
await request(apiUrl, "/api/cli/auth/revoke", { method: "POST", token: auth.token }).catch(
|
|
901
|
+
() => void 0
|
|
902
|
+
);
|
|
903
|
+
return request(apiUrl, path, { ...init, token });
|
|
411
904
|
}
|
|
412
905
|
async function login({
|
|
413
906
|
apiUrl,
|
|
414
907
|
openBrowser: shouldOpen,
|
|
415
908
|
log,
|
|
416
|
-
wait =
|
|
909
|
+
wait = sleep2
|
|
417
910
|
}) {
|
|
911
|
+
await loadStoredToken(apiUrl);
|
|
418
912
|
const start = await startDeviceLogin(apiUrl);
|
|
419
913
|
log(`Confirm this code in your browser: ${start.userCode}`);
|
|
420
914
|
log(`If the browser doesn't open, visit: ${start.verificationUriComplete}`);
|
|
@@ -478,9 +972,9 @@ async function exchange(apiUrl, deviceCode) {
|
|
|
478
972
|
throw new CliError(`Unexpected login response ${poll.status}`, EXIT.error);
|
|
479
973
|
}
|
|
480
974
|
}
|
|
481
|
-
function loginRequired(apiUrl, pending) {
|
|
975
|
+
function loginRequired(apiUrl, pending, { rejected }) {
|
|
482
976
|
return new CliError(
|
|
483
|
-
|
|
977
|
+
rejected ? `${apiUrl} didn't accept the saved sign-in (it may have been revoked or expired, or the server had a passing problem). Ask the user to open loginUrl and approve, then rerun this command. The saved sign-in is kept until then.` : `Not logged in to ${apiUrl}. Ask the user to open loginUrl and approve, then rerun this command.`,
|
|
484
978
|
EXIT.unauthenticated,
|
|
485
979
|
{
|
|
486
980
|
loginUrl: pending.loginUrl,
|
|
@@ -494,26 +988,34 @@ function loginRequired(apiUrl, pending) {
|
|
|
494
988
|
}
|
|
495
989
|
async function logout(apiUrl) {
|
|
496
990
|
const token = await loadStoredToken(apiUrl);
|
|
497
|
-
|
|
991
|
+
const recovered = await loadRecoveredTokens(apiUrl);
|
|
992
|
+
if (!token && !recovered.length) {
|
|
498
993
|
throw new CliError(`Not logged in to ${apiUrl}.`, EXIT.unauthenticated);
|
|
499
994
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
995
|
+
if (token) {
|
|
996
|
+
const response = await request(apiUrl, "/api/cli/auth/revoke", {
|
|
997
|
+
method: "POST",
|
|
998
|
+
token
|
|
999
|
+
});
|
|
1000
|
+
if (response.status !== 401) unwrap(response);
|
|
1001
|
+
}
|
|
1002
|
+
for (const kept of recovered) {
|
|
1003
|
+
await request(apiUrl, "/api/cli/auth/revoke", { method: "POST", token: kept }).catch(
|
|
1004
|
+
() => void 0
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
await deleteToken(apiUrl, process.env, [...token ? [token] : [], ...recovered]);
|
|
506
1008
|
return { loggedOut: true, apiUrl };
|
|
507
1009
|
}
|
|
508
1010
|
async function whoami(apiUrl, token) {
|
|
509
|
-
const
|
|
1011
|
+
const path = "/api/cli/auth/whoami";
|
|
510
1012
|
const data = unwrap(
|
|
511
|
-
await request(apiUrl,
|
|
1013
|
+
token ? await request(apiUrl, path, { token }) : await authorizedRequest(apiUrl, path)
|
|
512
1014
|
);
|
|
513
1015
|
return { ...data, apiUrl };
|
|
514
1016
|
}
|
|
515
1017
|
function deviceLabel() {
|
|
516
|
-
return
|
|
1018
|
+
return hostname2().replace(/[^\w .@-]/g, "").slice(0, 64);
|
|
517
1019
|
}
|
|
518
1020
|
|
|
519
1021
|
// src/guide.ts
|
|
@@ -530,50 +1032,150 @@ async function readGuide(topic = "main") {
|
|
|
530
1032
|
}
|
|
531
1033
|
|
|
532
1034
|
// src/cli.ts
|
|
1035
|
+
var FLAGS = {
|
|
1036
|
+
search: ` --query <text> | --city-code <n> | --near <lat,lng,km>
|
|
1037
|
+
--check-in YYYY-MM-DD --check-out YYYY-MM-DD
|
|
1038
|
+
[--star-min n] [--star-max n] [--facility <name>]... [--price-min n]
|
|
1039
|
+
[--price-max n] [--sort recommended|price-asc|price-desc|star-desc|distance]
|
|
1040
|
+
[--page n] [--page-size n] [--currency CNY|USD]`,
|
|
1041
|
+
rates: ` --hotel <id>... --check-in YYYY-MM-DD --check-out YYYY-MM-DD
|
|
1042
|
+
[--rooms 1] [--adults 2] [--child-age n]... [--nationality CN]
|
|
1043
|
+
[--board BB]... [--free-cancellation] [--include-unbookable]
|
|
1044
|
+
[--max-rooms n] [--currency CNY|USD]`,
|
|
1045
|
+
book: ` --hotel <id> --room-id <id> --rate-plan <code>
|
|
1046
|
+
--check-in YYYY-MM-DD --check-out YYYY-MM-DD --rooms <n> --adults <n>
|
|
1047
|
+
[--child-age n]... --guest "First Last[:roomNo]"...
|
|
1048
|
+
--contact-name <name> --contact-phone <phone> [--contact-email <email>]
|
|
1049
|
+
[--special-requests <english text>]`
|
|
1050
|
+
};
|
|
1051
|
+
var BODY_NOTE = `Also takes --body '<json>' (or --body @file.json) with the raw request
|
|
1052
|
+
body; flags override its fields.`;
|
|
1053
|
+
var OCCUPANCY_NOTE = `--adults and --child-age are per room, and every room has the same
|
|
1054
|
+
occupancy: 2 rooms for 4 adults is --rooms 2 --adults 2. Limits: 1-8 rooms,
|
|
1055
|
+
1-8 adults and 0-8 children (aged 0-17) per room, 1-15 nights.`;
|
|
1056
|
+
var USAGE = {
|
|
1057
|
+
login: `Usage: fortrip login [--no-browser]
|
|
1058
|
+
|
|
1059
|
+
Signs in through the browser with a device code and waits up to 10 minutes
|
|
1060
|
+
for approval. Any other command starts the same login itself and exits 3
|
|
1061
|
+
with loginUrl instead of waiting; AI agents should rely on that.
|
|
1062
|
+
`,
|
|
1063
|
+
logout: `Usage: fortrip logout
|
|
1064
|
+
|
|
1065
|
+
Revokes the saved token on the server and deletes it locally.
|
|
1066
|
+
`,
|
|
1067
|
+
whoami: `Usage: fortrip whoami
|
|
1068
|
+
|
|
1069
|
+
Shows the signed-in account.
|
|
1070
|
+
`,
|
|
1071
|
+
search: `Usage: fortrip search [options]
|
|
1072
|
+
|
|
1073
|
+
Finds hotels with indicative prices: floors, not quotes.
|
|
1074
|
+
|
|
1075
|
+
${FLAGS.search}
|
|
1076
|
+
|
|
1077
|
+
${BODY_NOTE}
|
|
1078
|
+
`,
|
|
1079
|
+
hotel: `Usage: fortrip hotel <hotelId> [--with-rooms]
|
|
1080
|
+
|
|
1081
|
+
Static hotel detail: description, policies, facilities and photos;
|
|
1082
|
+
--with-rooms adds the room types.
|
|
1083
|
+
`,
|
|
1084
|
+
rates: `Usage: fortrip rates [options]
|
|
1085
|
+
|
|
1086
|
+
Live rooms and rates for up to 15 hotels; takes about 15 seconds.
|
|
1087
|
+
|
|
1088
|
+
${FLAGS.rates}
|
|
1089
|
+
|
|
1090
|
+
${OCCUPANCY_NOTE}
|
|
1091
|
+
|
|
1092
|
+
Meal codes (--board, boardCode): RO room only, BB breakfast, HB breakfast +
|
|
1093
|
+
dinner, FB breakfast + lunch + dinner, BL lunch, BD dinner, HBBL breakfast +
|
|
1094
|
+
lunch, HBLD lunch + dinner, AI all inclusive. boardCount is how many guests
|
|
1095
|
+
per room the meal plan covers; fewer than --adults means not everyone gets
|
|
1096
|
+
the meals (BB with boardCount 1 for 2 adults is breakfast for 1 of 2).
|
|
1097
|
+
|
|
1098
|
+
--nationality isn't sent with the booking: a rate restricted to that
|
|
1099
|
+
nationality may change or be refused at book.
|
|
1100
|
+
|
|
1101
|
+
${BODY_NOTE}
|
|
1102
|
+
`,
|
|
1103
|
+
book: `Usage: fortrip book [options]
|
|
1104
|
+
|
|
1105
|
+
Creates the order and returns paymentUrl with the amount, currency, terms and
|
|
1106
|
+
stay. Show those to the user and get their confirmation, then give them
|
|
1107
|
+
paymentUrl exactly as returned. Payment happens only on that page: never ask
|
|
1108
|
+
for or pass on card details. Takes up to about 2 minutes; rerunning with the
|
|
1109
|
+
same flags returns the same order.
|
|
1110
|
+
|
|
1111
|
+
${FLAGS.book}
|
|
1112
|
+
|
|
1113
|
+
Use exactly the hotel, room, rate plan, dates, rooms, adults and child ages
|
|
1114
|
+
from rates. ${OCCUPANCY_NOTE} Guest names are 1-60 characters each,
|
|
1115
|
+
--contact-name up to 120, --contact-phone 3-40.
|
|
1116
|
+
|
|
1117
|
+
${BODY_NOTE}
|
|
1118
|
+
`,
|
|
1119
|
+
skill: `Usage: fortrip skill [planning]
|
|
1120
|
+
|
|
1121
|
+
Prints the guide for AI agents (Markdown); planning covers flexible dates,
|
|
1122
|
+
budgets, split stays and several destinations.
|
|
1123
|
+
`
|
|
1124
|
+
};
|
|
533
1125
|
var HELP = `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
|
|
534
1126
|
|
|
535
1127
|
Usage: fortrip <command> [options]
|
|
1128
|
+
fortrip <command> --help
|
|
536
1129
|
|
|
537
1130
|
Commands:
|
|
538
1131
|
login [--no-browser] Sign in through the browser (device code)
|
|
539
1132
|
logout Revoke and forget the saved token
|
|
540
1133
|
whoami Show the signed-in account
|
|
541
|
-
search Find hotels with indicative prices
|
|
1134
|
+
search Find hotels with indicative prices
|
|
542
1135
|
hotel <hotelId> Static hotel detail [--with-rooms]
|
|
543
|
-
rates Live rooms and rates
|
|
1136
|
+
rates Live rooms and rates for up to 15 hotels
|
|
544
1137
|
book Create an order and get a payment link
|
|
545
1138
|
skill [planning] Print the guide for AI agents (Markdown)
|
|
546
1139
|
|
|
547
|
-
search:
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
[--child-age n]... [--nationality CN] [--board BB]...
|
|
554
|
-
[--free-cancellation] [--include-unbookable] [--max-rooms n]
|
|
555
|
-
book: --hotel <id> --room-id <id> --rate-plan <code> --check-in --check-out
|
|
556
|
-
[--rooms 1] [--adults 2] [--child-age n]...
|
|
557
|
-
--guest "First Last[:roomNo]"... --contact-name <name>
|
|
558
|
-
--contact-phone <phone> [--contact-email <email>]
|
|
559
|
-
[--special-requests <english text>]
|
|
1140
|
+
search:
|
|
1141
|
+
${FLAGS.search}
|
|
1142
|
+
rates:
|
|
1143
|
+
${FLAGS.rates}
|
|
1144
|
+
book:
|
|
1145
|
+
${FLAGS.book}
|
|
560
1146
|
|
|
561
1147
|
search, rates and book also take --body '<json>' (or --body @file.json) with
|
|
562
1148
|
the raw request body; flags override its fields.
|
|
563
1149
|
|
|
1150
|
+
--adults and --child-age are per room: every room has the same occupancy
|
|
1151
|
+
(2 rooms for 4 adults is --rooms 2 --adults 2).
|
|
1152
|
+
|
|
1153
|
+
search and rates ask for prices in --currency (CNY by default, or USD),
|
|
1154
|
+
converted at Stripe's rate or a fixed fallback rate; the response's currency
|
|
1155
|
+
and fx fields say which. book still charges the currency it returns (usually
|
|
1156
|
+
USD).
|
|
1157
|
+
|
|
564
1158
|
Output is JSON on stdout; errors are JSON on stderr.
|
|
565
|
-
Exit codes: 0 ok, 1 error
|
|
1159
|
+
Exit codes: 0 ok, 1 error (retry once when stderr has "retryable": true),
|
|
1160
|
+
2 invalid arguments, 3 not logged in, 4 rate limited.
|
|
566
1161
|
|
|
567
1162
|
Environment:
|
|
568
1163
|
FORTRIP_TOKEN Use this token instead of the saved login (CI, sandboxes)
|
|
569
1164
|
FORTRIP_API_URL API base URL (default https://fortrip.ai)
|
|
1165
|
+
FORTRIP_CURRENCY Default --currency for search and rates
|
|
570
1166
|
`;
|
|
1167
|
+
var LINK_KEYS = ["paymentUrl", "statusUrl", "orderUrl"];
|
|
571
1168
|
async function run(argv) {
|
|
572
1169
|
const [command, ...rest] = argv;
|
|
573
1170
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
574
1171
|
process.stdout.write(HELP);
|
|
575
1172
|
return void 0;
|
|
576
1173
|
}
|
|
1174
|
+
const usage = Object.hasOwn(USAGE, command) ? USAGE[command] : void 0;
|
|
1175
|
+
if (usage && wantsHelp(rest)) {
|
|
1176
|
+
process.stdout.write(usage);
|
|
1177
|
+
return void 0;
|
|
1178
|
+
}
|
|
577
1179
|
if (command === "--version" || command === "-v") {
|
|
578
1180
|
process.stdout.write(`${VERSION}
|
|
579
1181
|
`);
|
|
@@ -615,21 +1217,37 @@ async function run(argv) {
|
|
|
615
1217
|
}
|
|
616
1218
|
case "book": {
|
|
617
1219
|
const { values } = parse(rest, BOOK_OPTIONS);
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
1220
|
+
let data;
|
|
1221
|
+
try {
|
|
1222
|
+
data = await call(
|
|
1223
|
+
apiUrl,
|
|
1224
|
+
"POST",
|
|
1225
|
+
"/api/huizhi/bookings",
|
|
1226
|
+
bookBody(values)
|
|
1227
|
+
);
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
if (!(error instanceof CliError)) throw error;
|
|
1230
|
+
throw new CliError(
|
|
1231
|
+
error.message,
|
|
1232
|
+
error.exitCode,
|
|
1233
|
+
absolutize(apiUrl, error.details, LINK_KEYS)
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
return paymentPage(absolutize(apiUrl, data, LINK_KEYS));
|
|
625
1237
|
}
|
|
626
1238
|
default:
|
|
627
1239
|
throw usageError(`Unknown command "${command}". Run \`fortrip --help\`.`);
|
|
628
1240
|
}
|
|
629
1241
|
}
|
|
630
1242
|
async function call(apiUrl, method, path, body) {
|
|
631
|
-
|
|
632
|
-
|
|
1243
|
+
return unwrap(await authorizedRequest(apiUrl, path, { method, body }));
|
|
1244
|
+
}
|
|
1245
|
+
function wantsHelp(args) {
|
|
1246
|
+
for (const arg of args) {
|
|
1247
|
+
if (arg === "--") return false;
|
|
1248
|
+
if (arg === "--help" || arg === "-h") return true;
|
|
1249
|
+
}
|
|
1250
|
+
return false;
|
|
633
1251
|
}
|
|
634
1252
|
function absolutize(apiUrl, data, keys) {
|
|
635
1253
|
const out = { ...data };
|
|
@@ -641,18 +1259,30 @@ function absolutize(apiUrl, data, keys) {
|
|
|
641
1259
|
}
|
|
642
1260
|
return out;
|
|
643
1261
|
}
|
|
1262
|
+
function paymentPage(data) {
|
|
1263
|
+
const { paymentUrl, statusUrl } = data;
|
|
1264
|
+
if (typeof statusUrl !== "string" || typeof paymentUrl !== "string") return data;
|
|
1265
|
+
let host;
|
|
1266
|
+
try {
|
|
1267
|
+
host = new URL(paymentUrl).hostname;
|
|
1268
|
+
} catch {
|
|
1269
|
+
return data;
|
|
1270
|
+
}
|
|
1271
|
+
return host === "checkout.stripe.com" ? { ...data, paymentUrl: statusUrl } : data;
|
|
1272
|
+
}
|
|
644
1273
|
async function main() {
|
|
645
1274
|
try {
|
|
646
1275
|
const result = await run(process.argv.slice(2));
|
|
647
1276
|
if (result !== void 0) {
|
|
648
|
-
|
|
1277
|
+
const output = result && typeof result === "object" && !Array.isArray(result) ? { ...result, ...notices } : result;
|
|
1278
|
+
process.stdout.write(`${JSON.stringify(output, null, 2)}
|
|
649
1279
|
`);
|
|
650
1280
|
}
|
|
651
1281
|
return EXIT.ok;
|
|
652
1282
|
} catch (error) {
|
|
653
1283
|
const failure = error instanceof CliError ? error : new CliError(error?.message ?? String(error), EXIT.error);
|
|
654
1284
|
process.stderr.write(
|
|
655
|
-
`${JSON.stringify({ error: failure.message, exitCode: failure.exitCode, ...failure.details }, null, 2)}
|
|
1285
|
+
`${JSON.stringify({ error: failure.message, exitCode: failure.exitCode, ...failure.details, ...notices }, null, 2)}
|
|
656
1286
|
`
|
|
657
1287
|
);
|
|
658
1288
|
return failure.exitCode;
|