fortrip 0.3.0 → 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 +102 -35
- package/LICENSE +21 -0
- package/README.md +64 -19
- package/dist/cli.js +737 -127
- 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(
|
|
@@ -104,11 +111,27 @@ import { parseArgs } from "util";
|
|
|
104
111
|
var BODY_OPTION = { body: { type: "string" } };
|
|
105
112
|
var CURRENCY_OPTION = { currency: { type: "string" } };
|
|
106
113
|
function parse(argv, options) {
|
|
114
|
+
let parsed;
|
|
107
115
|
try {
|
|
108
|
-
|
|
116
|
+
parsed = parseArgs({
|
|
117
|
+
args: argv,
|
|
118
|
+
options,
|
|
119
|
+
allowPositionals: true,
|
|
120
|
+
strict: true,
|
|
121
|
+
tokens: true
|
|
122
|
+
});
|
|
109
123
|
} catch (error) {
|
|
110
124
|
throw usageError(error.message);
|
|
111
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;
|
|
112
135
|
}
|
|
113
136
|
function baseBody(values) {
|
|
114
137
|
const raw = values.body;
|
|
@@ -142,6 +165,18 @@ function nums(flag, values) {
|
|
|
142
165
|
if (!values?.length) return void 0;
|
|
143
166
|
return values.flatMap((value) => value.split(",")).map((value) => num(flag, value.trim()));
|
|
144
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
|
+
}
|
|
145
180
|
function str(value) {
|
|
146
181
|
return typeof value === "string" ? value : void 0;
|
|
147
182
|
}
|
|
@@ -196,7 +231,7 @@ function searchBody(values, env = process.env) {
|
|
|
196
231
|
}
|
|
197
232
|
return merge(base, {
|
|
198
233
|
query: str(values.query),
|
|
199
|
-
cityCode:
|
|
234
|
+
cityCode: id("city-code", str(values["city-code"])),
|
|
200
235
|
near,
|
|
201
236
|
checkIn: str(values["check-in"]),
|
|
202
237
|
checkOut: str(values["check-out"]),
|
|
@@ -215,11 +250,11 @@ var HOTEL_OPTIONS = {
|
|
|
215
250
|
"with-rooms": { type: "boolean" }
|
|
216
251
|
};
|
|
217
252
|
function hotelPath(positionals, values) {
|
|
218
|
-
const [
|
|
219
|
-
if (!
|
|
253
|
+
const [id2, ...extra] = positionals;
|
|
254
|
+
if (!id2 || extra.length || !/^\d+$/.test(id2)) {
|
|
220
255
|
throw usageError("Usage: fortrip hotel <hotelId> [--with-rooms]");
|
|
221
256
|
}
|
|
222
|
-
return `/api/huizhi/hotels/${
|
|
257
|
+
return `/api/huizhi/hotels/${id2}${values["with-rooms"] ? "?include=rooms" : ""}`;
|
|
223
258
|
}
|
|
224
259
|
var STAY_OPTIONS = {
|
|
225
260
|
"check-in": { type: "string" },
|
|
@@ -228,12 +263,12 @@ var STAY_OPTIONS = {
|
|
|
228
263
|
adults: { type: "string" },
|
|
229
264
|
"child-age": { type: "string", multiple: true }
|
|
230
265
|
};
|
|
231
|
-
function stayFields(values, base) {
|
|
266
|
+
function stayFields(values, base, defaults = {}) {
|
|
232
267
|
return {
|
|
233
268
|
checkIn: str(values["check-in"]),
|
|
234
269
|
checkOut: str(values["check-out"]),
|
|
235
|
-
rooms: num("rooms", str(values.rooms)) ?? (base.rooms === void 0 ?
|
|
236
|
-
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),
|
|
237
272
|
childrenAges: nums("child-age", list(values["child-age"]))
|
|
238
273
|
};
|
|
239
274
|
}
|
|
@@ -251,8 +286,8 @@ var RATES_OPTIONS = {
|
|
|
251
286
|
function ratesBody(values, env = process.env) {
|
|
252
287
|
const base = baseBody(values);
|
|
253
288
|
return merge(base, {
|
|
254
|
-
hotelIds:
|
|
255
|
-
...stayFields(values, base),
|
|
289
|
+
hotelIds: ids("hotel", list(values.hotel)),
|
|
290
|
+
...stayFields(values, base, { rooms: 1, adults: 2 }),
|
|
256
291
|
nationality: str(values.nationality)?.toUpperCase(),
|
|
257
292
|
boardCodes: list(values.board)?.flatMap(
|
|
258
293
|
(code) => code.split(",").map((part) => part.trim().toUpperCase())
|
|
@@ -291,8 +326,8 @@ function parseGuest(value) {
|
|
|
291
326
|
}
|
|
292
327
|
function bookBody(values) {
|
|
293
328
|
const base = baseBody(values);
|
|
294
|
-
|
|
295
|
-
hotelId:
|
|
329
|
+
const body = merge(base, {
|
|
330
|
+
hotelId: id("hotel", str(values.hotel)),
|
|
296
331
|
roomId: str(values["room-id"]),
|
|
297
332
|
ratePlanCode: str(values["rate-plan"]),
|
|
298
333
|
...stayFields(values, base),
|
|
@@ -302,11 +337,18 @@ function bookBody(values) {
|
|
|
302
337
|
contactEmail: str(values["contact-email"]),
|
|
303
338
|
specialRequests: str(values["special-requests"])
|
|
304
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;
|
|
305
347
|
}
|
|
306
348
|
|
|
307
349
|
// src/auth.ts
|
|
308
|
-
import { hostname } from "os";
|
|
309
|
-
import { setTimeout as
|
|
350
|
+
import { hostname as hostname2 } from "os";
|
|
351
|
+
import { setTimeout as sleep2 } from "timers/promises";
|
|
310
352
|
|
|
311
353
|
// src/browser.ts
|
|
312
354
|
import { spawn } from "child_process";
|
|
@@ -322,10 +364,31 @@ function openBrowser(url) {
|
|
|
322
364
|
}
|
|
323
365
|
|
|
324
366
|
// src/credentials.ts
|
|
325
|
-
import {
|
|
326
|
-
import {
|
|
327
|
-
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";
|
|
328
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];
|
|
329
392
|
function credentialsPath(env = process.env) {
|
|
330
393
|
const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
331
394
|
return join(base, "fortrip", "credentials.json");
|
|
@@ -344,27 +407,300 @@ function resolveApiUrl(env = process.env) {
|
|
|
344
407
|
}
|
|
345
408
|
return url.origin;
|
|
346
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
|
+
}
|
|
347
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`;
|
|
348
541
|
try {
|
|
349
|
-
|
|
350
|
-
|
|
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 = (() => {
|
|
607
|
+
try {
|
|
608
|
+
return readlinkSync("/proc/self/ns/pid");
|
|
351
609
|
} catch {
|
|
352
|
-
return
|
|
610
|
+
return "";
|
|
353
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
|
+
});
|
|
354
620
|
}
|
|
355
|
-
async function
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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";
|
|
359
699
|
}
|
|
360
|
-
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
361
|
-
await writeFile(path, `${JSON.stringify(file, null, 2)}
|
|
362
|
-
`, { mode: 384 });
|
|
363
|
-
await chmod(path, 384);
|
|
364
700
|
}
|
|
365
701
|
async function loadStoredToken(apiUrl, env = process.env) {
|
|
366
|
-
const
|
|
367
|
-
return
|
|
702
|
+
const { credentials } = await readCredentials(credentialsPath(env));
|
|
703
|
+
return credentials.tokens[apiUrl]?.accessToken ?? null;
|
|
368
704
|
}
|
|
369
705
|
async function resolveToken(apiUrl, env = process.env) {
|
|
370
706
|
const fromEnv = env.FORTRIP_TOKEN?.trim();
|
|
@@ -374,60 +710,205 @@ async function resolveToken(apiUrl, env = process.env) {
|
|
|
374
710
|
}
|
|
375
711
|
async function saveToken(apiUrl, accessToken, env = process.env) {
|
|
376
712
|
const path = credentialsPath(env);
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
+
}
|
|
381
744
|
}
|
|
382
|
-
async function deleteToken(apiUrl, env = process.env) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
+
});
|
|
387
752
|
}
|
|
388
|
-
async function
|
|
389
|
-
const
|
|
390
|
-
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));
|
|
391
756
|
}
|
|
392
|
-
async function
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
+
});
|
|
398
770
|
}
|
|
399
771
|
|
|
400
772
|
// src/auth.ts
|
|
401
|
-
|
|
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();
|
|
402
778
|
const resolved = await resolveToken(apiUrl);
|
|
403
|
-
if (resolved) return resolved
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
);
|
|
410
799
|
}
|
|
411
|
-
|
|
412
|
-
|
|
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);
|
|
413
818
|
}
|
|
819
|
+
const saved = await savedToken(apiUrl, signIn);
|
|
820
|
+
if (saved) return saved;
|
|
821
|
+
throw loginRequired(apiUrl, pending, signIn);
|
|
822
|
+
} finally {
|
|
823
|
+
await release();
|
|
414
824
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
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 });
|
|
424
904
|
}
|
|
425
905
|
async function login({
|
|
426
906
|
apiUrl,
|
|
427
907
|
openBrowser: shouldOpen,
|
|
428
908
|
log,
|
|
429
|
-
wait =
|
|
909
|
+
wait = sleep2
|
|
430
910
|
}) {
|
|
911
|
+
await loadStoredToken(apiUrl);
|
|
431
912
|
const start = await startDeviceLogin(apiUrl);
|
|
432
913
|
log(`Confirm this code in your browser: ${start.userCode}`);
|
|
433
914
|
log(`If the browser doesn't open, visit: ${start.verificationUriComplete}`);
|
|
@@ -491,9 +972,9 @@ async function exchange(apiUrl, deviceCode) {
|
|
|
491
972
|
throw new CliError(`Unexpected login response ${poll.status}`, EXIT.error);
|
|
492
973
|
}
|
|
493
974
|
}
|
|
494
|
-
function loginRequired(apiUrl, pending) {
|
|
975
|
+
function loginRequired(apiUrl, pending, { rejected }) {
|
|
495
976
|
return new CliError(
|
|
496
|
-
|
|
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.`,
|
|
497
978
|
EXIT.unauthenticated,
|
|
498
979
|
{
|
|
499
980
|
loginUrl: pending.loginUrl,
|
|
@@ -507,26 +988,34 @@ function loginRequired(apiUrl, pending) {
|
|
|
507
988
|
}
|
|
508
989
|
async function logout(apiUrl) {
|
|
509
990
|
const token = await loadStoredToken(apiUrl);
|
|
510
|
-
|
|
991
|
+
const recovered = await loadRecoveredTokens(apiUrl);
|
|
992
|
+
if (!token && !recovered.length) {
|
|
511
993
|
throw new CliError(`Not logged in to ${apiUrl}.`, EXIT.unauthenticated);
|
|
512
994
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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]);
|
|
519
1008
|
return { loggedOut: true, apiUrl };
|
|
520
1009
|
}
|
|
521
1010
|
async function whoami(apiUrl, token) {
|
|
522
|
-
const
|
|
1011
|
+
const path = "/api/cli/auth/whoami";
|
|
523
1012
|
const data = unwrap(
|
|
524
|
-
await request(apiUrl,
|
|
1013
|
+
token ? await request(apiUrl, path, { token }) : await authorizedRequest(apiUrl, path)
|
|
525
1014
|
);
|
|
526
1015
|
return { ...data, apiUrl };
|
|
527
1016
|
}
|
|
528
1017
|
function deviceLabel() {
|
|
529
|
-
return
|
|
1018
|
+
return hostname2().replace(/[^\w .@-]/g, "").slice(0, 64);
|
|
530
1019
|
}
|
|
531
1020
|
|
|
532
1021
|
// src/guide.ts
|
|
@@ -543,9 +1032,100 @@ async function readGuide(topic = "main") {
|
|
|
543
1032
|
}
|
|
544
1033
|
|
|
545
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
|
+
};
|
|
546
1125
|
var HELP = `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
|
|
547
1126
|
|
|
548
1127
|
Usage: fortrip <command> [options]
|
|
1128
|
+
fortrip <command> --help
|
|
549
1129
|
|
|
550
1130
|
Commands:
|
|
551
1131
|
login [--no-browser] Sign in through the browser (device code)
|
|
@@ -557,43 +1137,45 @@ Commands:
|
|
|
557
1137
|
book Create an order and get a payment link
|
|
558
1138
|
skill [planning] Print the guide for AI agents (Markdown)
|
|
559
1139
|
|
|
560
|
-
search:
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
[--child-age n]... [--nationality CN] [--board BB]...
|
|
567
|
-
[--free-cancellation] [--include-unbookable] [--max-rooms n]
|
|
568
|
-
[--currency CNY|USD]
|
|
569
|
-
book: --hotel <id> --room-id <id> --rate-plan <code> --check-in --check-out
|
|
570
|
-
[--rooms 1] [--adults 2] [--child-age n]...
|
|
571
|
-
--guest "First Last[:roomNo]"... --contact-name <name>
|
|
572
|
-
--contact-phone <phone> [--contact-email <email>]
|
|
573
|
-
[--special-requests <english text>]
|
|
1140
|
+
search:
|
|
1141
|
+
${FLAGS.search}
|
|
1142
|
+
rates:
|
|
1143
|
+
${FLAGS.rates}
|
|
1144
|
+
book:
|
|
1145
|
+
${FLAGS.book}
|
|
574
1146
|
|
|
575
1147
|
search, rates and book also take --body '<json>' (or --body @file.json) with
|
|
576
1148
|
the raw request body; flags override its fields.
|
|
577
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
|
+
|
|
578
1153
|
search and rates ask for prices in --currency (CNY by default, or USD),
|
|
579
1154
|
converted at Stripe's rate or a fixed fallback rate; the response's currency
|
|
580
1155
|
and fx fields say which. book still charges the currency it returns (usually
|
|
581
1156
|
USD).
|
|
582
1157
|
|
|
583
1158
|
Output is JSON on stdout; errors are JSON on stderr.
|
|
584
|
-
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.
|
|
585
1161
|
|
|
586
1162
|
Environment:
|
|
587
1163
|
FORTRIP_TOKEN Use this token instead of the saved login (CI, sandboxes)
|
|
588
1164
|
FORTRIP_API_URL API base URL (default https://fortrip.ai)
|
|
589
1165
|
FORTRIP_CURRENCY Default --currency for search and rates
|
|
590
1166
|
`;
|
|
1167
|
+
var LINK_KEYS = ["paymentUrl", "statusUrl", "orderUrl"];
|
|
591
1168
|
async function run(argv) {
|
|
592
1169
|
const [command, ...rest] = argv;
|
|
593
1170
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
594
1171
|
process.stdout.write(HELP);
|
|
595
1172
|
return void 0;
|
|
596
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
|
+
}
|
|
597
1179
|
if (command === "--version" || command === "-v") {
|
|
598
1180
|
process.stdout.write(`${VERSION}
|
|
599
1181
|
`);
|
|
@@ -635,21 +1217,37 @@ async function run(argv) {
|
|
|
635
1217
|
}
|
|
636
1218
|
case "book": {
|
|
637
1219
|
const { values } = parse(rest, BOOK_OPTIONS);
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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));
|
|
645
1237
|
}
|
|
646
1238
|
default:
|
|
647
1239
|
throw usageError(`Unknown command "${command}". Run \`fortrip --help\`.`);
|
|
648
1240
|
}
|
|
649
1241
|
}
|
|
650
1242
|
async function call(apiUrl, method, path, body) {
|
|
651
|
-
|
|
652
|
-
|
|
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;
|
|
653
1251
|
}
|
|
654
1252
|
function absolutize(apiUrl, data, keys) {
|
|
655
1253
|
const out = { ...data };
|
|
@@ -661,18 +1259,30 @@ function absolutize(apiUrl, data, keys) {
|
|
|
661
1259
|
}
|
|
662
1260
|
return out;
|
|
663
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
|
+
}
|
|
664
1273
|
async function main() {
|
|
665
1274
|
try {
|
|
666
1275
|
const result = await run(process.argv.slice(2));
|
|
667
1276
|
if (result !== void 0) {
|
|
668
|
-
|
|
1277
|
+
const output = result && typeof result === "object" && !Array.isArray(result) ? { ...result, ...notices } : result;
|
|
1278
|
+
process.stdout.write(`${JSON.stringify(output, null, 2)}
|
|
669
1279
|
`);
|
|
670
1280
|
}
|
|
671
1281
|
return EXIT.ok;
|
|
672
1282
|
} catch (error) {
|
|
673
1283
|
const failure = error instanceof CliError ? error : new CliError(error?.message ?? String(error), EXIT.error);
|
|
674
1284
|
process.stderr.write(
|
|
675
|
-
`${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)}
|
|
676
1286
|
`
|
|
677
1287
|
);
|
|
678
1288
|
return failure.exitCode;
|