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/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 = "Run `npx fortrip login` first.";
32
- var REQUEST_TIMEOUT_MS = 12e4;
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 new CliError(
56
- `Could not reach ${apiUrl}: ${error.message}`,
57
- EXIT.error
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 isObject = parsed !== null && typeof parsed === "object";
68
- return {
69
- status: response.status,
70
- body: isObject ? parsed : { error: `Unexpected ${response.status} response`, raw: text.slice(0, 200) },
71
- headers: response.headers
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 details = { status, ...rest };
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
- return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
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(baseBody(values), {
232
+ return merge(base, {
188
233
  query: str(values.query),
189
- cityCode: num("city-code", str(values["city-code"])),
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 [id, ...extra] = positionals;
208
- if (!id || extra.length || !/^\d+$/.test(id)) {
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/${id}${values["with-rooms"] ? "?include=rooms" : ""}`;
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 ? 1 : void 0),
225
- adults: num("adults", str(values.adults)) ?? (base.adults === void 0 ? 2 : 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: nums("hotel", list(values.hotel)),
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
- return merge(base, {
282
- hotelId: num("hotel", str(values.hotel)),
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 sleep } from "timers/promises";
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 { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
313
- import { homedir } from "os";
314
- import { dirname, join } from "path";
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
- const parsed = JSON.parse(await readFile(path, "utf8"));
337
- return { tokens: parsed?.tokens ?? {}, pending: parsed?.pending ?? {} };
608
+ return readlinkSync("/proc/self/ns/pid");
338
609
  } catch {
339
- return { tokens: {}, pending: {} };
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 writeCredentials(path, file) {
343
- if (Object.keys(file.tokens).length === 0 && Object.keys(file.pending).length === 0) {
344
- await rm(path, { force: true });
345
- return;
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 file = await readCredentials(credentialsPath(env));
354
- return file.tokens[apiUrl]?.accessToken ?? null;
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 file = await readCredentials(path);
365
- file.tokens[apiUrl] = { accessToken, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
366
- delete file.pending[apiUrl];
367
- await writeCredentials(path, file);
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
- const path = credentialsPath(env);
371
- const file = await readCredentials(path);
372
- delete file.tokens[apiUrl];
373
- await writeCredentials(path, file);
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 loadPendingLogin(apiUrl, env = process.env) {
376
- const pending = (await readCredentials(credentialsPath(env))).pending[apiUrl];
377
- return pending && Date.parse(pending.expiresAt) > Date.now() ? pending : null;
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 savePendingLogin(apiUrl, pending, env = process.env) {
380
- const path = credentialsPath(env);
381
- const file = await readCredentials(path);
382
- if (pending) file.pending[apiUrl] = pending;
383
- else delete file.pending[apiUrl];
384
- await writeCredentials(path, file);
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
- async function requireToken(apiUrl) {
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.token;
391
- const pending = await loadPendingLogin(apiUrl);
392
- if (pending) {
393
- const result = await exchange(apiUrl, pending.deviceCode);
394
- if (result.status === "ok") {
395
- await saveToken(apiUrl, result.accessToken);
396
- return result.accessToken;
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
- if (result.status === "authorization_pending" || result.status === "slow_down") {
399
- throw loginRequired(apiUrl, pending);
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
- const start = await startDeviceLogin(apiUrl);
403
- const fresh = {
404
- deviceCode: start.deviceCode,
405
- userCode: start.userCode,
406
- loginUrl: start.verificationUriComplete,
407
- expiresAt: new Date(Date.now() + start.expiresIn * 1e3).toISOString()
408
- };
409
- await savePendingLogin(apiUrl, fresh);
410
- throw loginRequired(apiUrl, fresh);
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 = sleep
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
- `Not logged in to ${apiUrl}. Ask the user to open loginUrl and approve, then rerun this command (or run \`npx fortrip login\` in a terminal).`,
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
- if (!token) {
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
- const response = await request(apiUrl, "/api/cli/auth/revoke", {
501
- method: "POST",
502
- token
503
- });
504
- if (response.status !== 401) unwrap(response);
505
- await deleteToken(apiUrl);
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 resolved = token ?? await requireToken(apiUrl);
1011
+ const path = "/api/cli/auth/whoami";
510
1012
  const data = unwrap(
511
- await request(apiUrl, "/api/cli/auth/whoami", { token: resolved })
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 hostname().replace(/[^\w .@-]/g, "").slice(0, 64);
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 (CNY)
1134
+ search Find hotels with indicative prices
542
1135
  hotel <hotelId> Static hotel detail [--with-rooms]
543
- rates Live rooms and rates (CNY) for up to 15 hotels
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: --query <text> | --city-code <n> | --near <lat,lng,km>
548
- --check-in YYYY-MM-DD --check-out YYYY-MM-DD
549
- [--star-min n] [--star-max n] [--facility <name>]... [--price-min n]
550
- [--price-max n] [--sort recommended|price-asc|price-desc|star-desc|distance]
551
- [--page n] [--page-size n]
552
- rates: --hotel <id>... --check-in --check-out [--rooms 1] [--adults 2]
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, 2 invalid arguments, 3 not logged in, 4 rate limited.
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
- const data = await call(
619
- apiUrl,
620
- "POST",
621
- "/api/huizhi/bookings",
622
- bookBody(values)
623
- );
624
- return absolutize(apiUrl, data, ["orderUrl", "statusUrl"]);
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
- const token = await requireToken(apiUrl);
632
- return unwrap(await request(apiUrl, path, { method, token, body }));
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
- process.stdout.write(`${JSON.stringify(result, null, 2)}
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;