fortrip 0.3.0 → 1.0.0

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
@@ -1,122 +1,193 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ API,
4
+ COMMANDS,
5
+ CliError,
6
+ EXIT,
7
+ GLOBAL_FLAGS,
8
+ VERSION,
9
+ authorizedRequest,
10
+ login,
11
+ logout,
12
+ notices,
13
+ renderGuide,
14
+ resolveApiUrl,
15
+ unwrap,
16
+ usageError,
17
+ whoami
18
+ } from "./chunk-OBK2KTLC.js";
2
19
 
3
- // src/errors.ts
4
- var EXIT = {
5
- ok: 0,
6
- error: 1,
7
- usage: 2,
8
- unauthenticated: 3,
9
- rateLimited: 4
10
- };
11
- var CliError = class extends Error {
12
- constructor(message, exitCode, details = {}) {
13
- super(message);
14
- this.exitCode = exitCode;
15
- this.details = details;
20
+ // src/commands.ts
21
+ import { readFileSync } from "fs";
22
+ import { parseArgs } from "util";
23
+
24
+ // src/help.ts
25
+ var WIDTH = 78;
26
+ var OPTIONAL_POSITIONALS = /* @__PURE__ */ new Set(["topic"]);
27
+ function wrap(text, indent) {
28
+ const pad = " ".repeat(indent);
29
+ const lines = [];
30
+ let line = "";
31
+ for (const word of text.split(/\s+/).filter(Boolean)) {
32
+ if (line && indent + line.length + 1 + word.length > WIDTH) {
33
+ lines.push(pad + line);
34
+ line = word;
35
+ } else {
36
+ line = line ? `${line} ${word}` : word;
37
+ }
16
38
  }
17
- exitCode;
18
- details;
19
- };
20
- function usageError(message) {
21
- return new CliError(message, EXIT.usage);
39
+ if (line) lines.push(pad + line);
40
+ return lines;
22
41
  }
42
+ function invocation(spec) {
43
+ const positionals = (spec.positionals ?? []).map(
44
+ (name) => OPTIONAL_POSITIONALS.has(name) ? `[${name}]` : `<${name}>`
45
+ );
46
+ return ["fortrip", spec.name, ...positionals].join(" ");
47
+ }
48
+ function usageLine(spec) {
49
+ return `${invocation(spec)} [options]`;
50
+ }
51
+ function names(option, aligned) {
52
+ const short = option.short ? `-${option.short}, ` : aligned ? " " : "";
53
+ const placeholder = option.placeholder ? ` ${option.placeholder}` : "";
54
+ const repeats = "multiple" in option && option.multiple ? "..." : "";
55
+ return `${short}--${option.flag}${placeholder}${repeats}`;
56
+ }
57
+ function optionList(options) {
58
+ const aligned = options.some((option) => option.short);
59
+ return options.map(
60
+ (option) => [` ${names(option, aligned)}`, ...wrap(option.description, 8)].join("\n")
61
+ ).join("\n");
62
+ }
63
+ var HELP_FLAG = GLOBAL_FLAGS.filter((option) => option.flag === "help");
64
+ function commandHelp(spec) {
65
+ const options = [...spec.flags, ...spec.options ?? [], ...HELP_FLAG];
66
+ return `Usage: ${usageLine(spec)}
23
67
 
24
- // src/version.ts
25
- import { readFileSync } from "fs";
26
- var VERSION = JSON.parse(
27
- readFileSync(new URL("../package.json", import.meta.url), "utf8")
28
- ).version;
68
+ ${wrap(spec.summary, 0).join("\n")}
69
+
70
+ Options:
71
+ ${optionList(options)}
72
+ `;
73
+ }
74
+ function commandList() {
75
+ const labels = COMMANDS.map((spec) => invocation(spec).slice("fortrip ".length));
76
+ const column = 2 + Math.max(...labels.map((label) => label.length)) + 2;
77
+ return COMMANDS.map((spec, n) => {
78
+ const [first = "", ...rest] = wrap(spec.summary, column);
79
+ const label = ` ${labels[n]}`.padEnd(column);
80
+ return [label + first.slice(column), ...rest].join("\n");
81
+ }).join("\n");
82
+ }
83
+ function overallHelp() {
84
+ return `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
85
+
86
+ Usage: fortrip <command> [options]
87
+ fortrip <command> --help
88
+
89
+ Commands:
90
+ ${commandList()}
91
+
92
+ Options:
93
+ ${optionList(GLOBAL_FLAGS)}
94
+
95
+ Output is JSON on stdout; errors are JSON on stderr.
96
+ Exit codes: 0 ok, 1 error (retry once when stderr has "retryable": true),
97
+ 2 invalid arguments, 3 not logged in, 4 rate limited.
98
+
99
+ Environment:
100
+ FORTRIP_TOKEN Use this token instead of the saved login (CI, sandboxes)
101
+ FORTRIP_API_URL API base URL (default https://fortrip.ai)
102
+ FORTRIP_CURRENCY Default --currency for search and rates
103
+ `;
104
+ }
29
105
 
30
- // src/api.ts
31
- var LOGIN_HINT = "Run `npx fortrip login` first.";
32
- var REQUEST_TIMEOUT_MS = 12e4;
33
- async function request(apiUrl, path, {
34
- method = "GET",
35
- token,
36
- body
37
- } = {}) {
38
- let response;
106
+ // src/commands.ts
107
+ var COMMA_LISTS = /* @__PURE__ */ new Set(["hotelIds", "childrenAges", "boardCodes"]);
108
+ var UPPER_CASE = /* @__PURE__ */ new Set(["nationality", "boardCodes", "currency"]);
109
+ var TURNS_OFF = /* @__PURE__ */ new Set(["bookableOnly"]);
110
+ var POSTS = {
111
+ search: API.search,
112
+ rates: API.rates,
113
+ book: API.bookings
114
+ };
115
+ function parseOptions(spec, argv) {
116
+ const options = {};
117
+ for (const option of [...spec.flags, ...spec.options ?? []]) {
118
+ options[option.flag] = {
119
+ type: option.type === "boolean" ? "boolean" : "string",
120
+ ...option.short ? { short: option.short } : {},
121
+ ..."multiple" in option && option.multiple ? { multiple: true } : {}
122
+ };
123
+ }
124
+ let parsed;
39
125
  try {
40
- response = await fetch(`${apiUrl}${path}`, {
41
- method,
42
- headers: {
43
- accept: "application/json",
44
- "user-agent": `fortrip-cli/${VERSION}`,
45
- ...body !== void 0 ? { "content-type": "application/json" } : {},
46
- ...token ? { authorization: `Bearer ${token}` } : {}
47
- },
48
- body: body !== void 0 ? JSON.stringify(body) : void 0,
49
- // A redirect to another origin would drop the Authorization header and
50
- // look like a logout; surface it instead.
51
- redirect: "manual",
52
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
126
+ parsed = parseArgs({
127
+ args: argv,
128
+ options,
129
+ allowPositionals: true,
130
+ strict: true,
131
+ tokens: true
53
132
  });
54
133
  } catch (error) {
55
- throw new CliError(
56
- `Could not reach ${apiUrl}: ${error.message}`,
57
- EXIT.error
58
- );
134
+ throw usageError(error.message);
59
135
  }
60
- const text = await response.text();
61
- let parsed = null;
62
- try {
63
- parsed = text ? JSON.parse(text) : {};
64
- } catch {
65
- parsed = null;
136
+ const seen = /* @__PURE__ */ new Set();
137
+ for (const token of parsed.tokens) {
138
+ if (token.kind !== "option" || options[token.name]?.multiple) continue;
139
+ if (seen.has(token.name)) {
140
+ throw usageError(`--${token.name} was given more than once`);
141
+ }
142
+ seen.add(token.name);
66
143
  }
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
- };
144
+ if (!spec.positionals && parsed.positionals.length) {
145
+ throw usageError(
146
+ `${spec.name} takes no arguments, got "${parsed.positionals[0]}"`
147
+ );
148
+ }
149
+ return { values: parsed.values, positionals: parsed.positionals };
73
150
  }
74
- function unwrap(response) {
75
- const { status, body, headers } = response;
76
- if (status >= 200 && status < 300 && body.success === true) return body.data;
77
- const { success: _success, error, message, ...rest } = body;
78
- const text = typeof error === "string" ? error : typeof message === "string" ? message : `Request failed with status ${status}`;
79
- const details = { status, ...rest };
80
- if (status === 401) {
81
- throw new CliError(`${text}. ${LOGIN_HINT}`, EXIT.unauthenticated, details);
151
+ function parseCommand(spec, argv, env) {
152
+ const post = POSTS[spec.name];
153
+ if (!post && spec.name !== "hotel") {
154
+ throw new Error(`${spec.name} sends no request`);
82
155
  }
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
- });
156
+ const { values, positionals } = parseOptions(spec, argv);
157
+ const input = baseBody(values.body);
158
+ for (const flag of spec.flags) {
159
+ const raw = values[flag.flag];
160
+ if (raw !== void 0) setField(input, flag.field, readFlag(flag, raw));
89
161
  }
90
- if (status === 400) throw new CliError(text, EXIT.usage, details);
91
- if (status >= 300 && status < 400) {
92
- throw new CliError(
93
- `Unexpected redirect to ${headers.get("location") ?? "?"}; check FORTRIP_API_URL`,
94
- EXIT.error,
95
- details
96
- );
162
+ if (spec.name === "hotel") {
163
+ const [hotelId, ...extra] = positionals;
164
+ const parsed = Number(hotelId);
165
+ if (!hotelId || extra.length || !/^\d+$/.test(hotelId) || !Number.isSafeInteger(parsed)) {
166
+ throw usageError(`Usage: ${usageLine(spec)}`);
167
+ }
168
+ return { path: API.hotel(parsed, input.includeRooms === true) };
97
169
  }
98
- throw new CliError(text, EXIT.error, details);
99
- }
100
-
101
- // src/args.ts
102
- import { readFileSync as readFileSync2 } from "fs";
103
- import { parseArgs } from "util";
104
- var BODY_OPTION = { body: { type: "string" } };
105
- var CURRENCY_OPTION = { currency: { type: "string" } };
106
- function parse(argv, options) {
107
- try {
108
- return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
109
- } catch (error) {
110
- throw usageError(error.message);
170
+ if (spec.flags.some((flag) => flag.field === "currency") && input.currency === void 0) {
171
+ const fallback = env.FORTRIP_CURRENCY?.trim().toUpperCase();
172
+ if (fallback) input.currency = fallback;
111
173
  }
174
+ const occupancy = spec.flags.filter(
175
+ (flag) => flag.field === "rooms" || flag.field === "adults"
176
+ );
177
+ const missing = occupancy.filter((flag) => input[flag.field] === void 0);
178
+ if (occupancy.length === 2 && missing.length) {
179
+ throw usageError(
180
+ `${spec.name} needs ${missing.map((flag) => `--${flag.flag}`).join(" and ")}, per room and the same for every room (2 rooms for 4 adults is --rooms 2 --adults 2)`
181
+ );
182
+ }
183
+ return { body: input, path: post };
112
184
  }
113
- function baseBody(values) {
114
- const raw = values.body;
185
+ function baseBody(raw) {
115
186
  if (typeof raw !== "string") return {};
116
187
  let text = raw;
117
188
  if (raw.startsWith("@")) {
118
189
  try {
119
- text = readFileSync2(raw.slice(1), "utf8");
190
+ text = readFileSync(raw.slice(1), "utf8");
120
191
  } catch (error) {
121
192
  throw usageError(`Cannot read --body file: ${error.message}`);
122
193
  }
@@ -130,151 +201,55 @@ function baseBody(values) {
130
201
  }
131
202
  throw usageError("--body must be a JSON object");
132
203
  }
204
+ function setField(body, field, value) {
205
+ const path = field.split(".");
206
+ const last = path.pop();
207
+ let node = body;
208
+ for (const part of path) {
209
+ const next = node[part];
210
+ node[part] = next && typeof next === "object" && !Array.isArray(next) ? next : {};
211
+ node = node[part];
212
+ }
213
+ node[last] = value;
214
+ }
215
+ function readFlag(flag, raw) {
216
+ if (flag.type === "boolean") return !TURNS_OFF.has(flag.field);
217
+ const given = Array.isArray(raw) ? raw : [raw];
218
+ if (flag.field === "near") return parseNear(given[0]);
219
+ if (flag.field === "guests") return given.map(parseGuest);
220
+ const parts = COMMA_LISTS.has(flag.field) ? given.flatMap((value) => value.split(",")).map((part) => part.trim()) : given;
221
+ const values = parts.map((part) => {
222
+ const text = UPPER_CASE.has(flag.field) ? part.trim().toUpperCase() : part;
223
+ if (flag.field === "currency" && text === "") {
224
+ throw usageError(`--${flag.flag} must not be empty`);
225
+ }
226
+ if (flag.type === "number") return num(flag.flag, text);
227
+ return flag.type === "id" ? id(flag.flag, text) : text;
228
+ });
229
+ return flag.multiple ? values : values[0];
230
+ }
133
231
  function num(flag, value) {
134
- if (value === void 0) return void 0;
135
232
  const parsed = Number(value);
136
233
  if (value.trim() === "" || !Number.isFinite(parsed)) {
137
234
  throw usageError(`--${flag} must be a number, got "${value}"`);
138
235
  }
139
236
  return parsed;
140
237
  }
141
- function nums(flag, values) {
142
- if (!values?.length) return void 0;
143
- return values.flatMap((value) => value.split(",")).map((value) => num(flag, value.trim()));
144
- }
145
- function str(value) {
146
- return typeof value === "string" ? value : void 0;
147
- }
148
- function list(value) {
149
- return Array.isArray(value) && value.length ? value : void 0;
150
- }
151
- function currency(values, base, env) {
152
- const flag = str(values.currency)?.trim().toUpperCase();
153
- if (flag === "") throw usageError("--currency must not be empty");
154
- if (flag) return flag;
155
- if (base.currency !== void 0) return void 0;
156
- return env.FORTRIP_CURRENCY?.trim().toUpperCase() || void 0;
157
- }
158
- function merge(base, overrides) {
159
- const body = { ...base };
160
- for (const [key, value] of Object.entries(overrides)) {
161
- if (value !== void 0) body[key] = value;
162
- }
163
- return body;
164
- }
165
- var SEARCH_OPTIONS = {
166
- ...BODY_OPTION,
167
- ...CURRENCY_OPTION,
168
- query: { type: "string", short: "q" },
169
- "city-code": { type: "string" },
170
- near: { type: "string" },
171
- "check-in": { type: "string" },
172
- "check-out": { type: "string" },
173
- "star-min": { type: "string" },
174
- "star-max": { type: "string" },
175
- facility: { type: "string", multiple: true },
176
- "price-min": { type: "string" },
177
- "price-max": { type: "string" },
178
- sort: { type: "string" },
179
- page: { type: "string" },
180
- "page-size": { type: "string" }
181
- };
182
- function searchBody(values, env = process.env) {
183
- const base = baseBody(values);
184
- let near;
185
- const nearRaw = str(values.near);
186
- if (nearRaw !== void 0) {
187
- const [lat, lng, radiusKm] = nearRaw.split(",").map((part) => part.trim());
188
- if (!lat || !lng || !radiusKm) {
189
- throw usageError("--near must be lat,lng,radiusKm (e.g. 13.75,100.5,5)");
190
- }
191
- near = {
192
- lat: num("near", lat),
193
- lng: num("near", lng),
194
- radiusKm: num("near", radiusKm)
195
- };
238
+ function id(flag, value) {
239
+ const parsed = Number(value.trim());
240
+ if (!/^\d+$/.test(value.trim()) || !Number.isSafeInteger(parsed)) {
241
+ throw usageError(`--${flag} must be a numeric id, got "${value}"`);
196
242
  }
197
- return merge(base, {
198
- query: str(values.query),
199
- cityCode: num("city-code", str(values["city-code"])),
200
- near,
201
- checkIn: str(values["check-in"]),
202
- checkOut: str(values["check-out"]),
203
- starMin: num("star-min", str(values["star-min"])),
204
- starMax: num("star-max", str(values["star-max"])),
205
- facilities: list(values.facility),
206
- priceMin: num("price-min", str(values["price-min"])),
207
- priceMax: num("price-max", str(values["price-max"])),
208
- sortBy: str(values.sort),
209
- page: num("page", str(values.page)),
210
- pageSize: num("page-size", str(values["page-size"])),
211
- currency: currency(values, base, env)
212
- });
243
+ return parsed;
213
244
  }
214
- var HOTEL_OPTIONS = {
215
- "with-rooms": { type: "boolean" }
216
- };
217
- function hotelPath(positionals, values) {
218
- const [id, ...extra] = positionals;
219
- if (!id || extra.length || !/^\d+$/.test(id)) {
220
- throw usageError("Usage: fortrip hotel <hotelId> [--with-rooms]");
245
+ function parseNear(value) {
246
+ const parts = value.split(",").map((part) => part.trim());
247
+ if (parts.length !== 3 || parts.some((part) => !part)) {
248
+ throw usageError("--near must be lat,lng,radiusKm (e.g. 13.75,100.5,5)");
221
249
  }
222
- return `/api/huizhi/hotels/${id}${values["with-rooms"] ? "?include=rooms" : ""}`;
250
+ const [lat, lng, radiusKm] = parts.map((part) => num("near", part));
251
+ return { lat, lng, radiusKm };
223
252
  }
224
- var STAY_OPTIONS = {
225
- "check-in": { type: "string" },
226
- "check-out": { type: "string" },
227
- rooms: { type: "string" },
228
- adults: { type: "string" },
229
- "child-age": { type: "string", multiple: true }
230
- };
231
- function stayFields(values, base) {
232
- return {
233
- checkIn: str(values["check-in"]),
234
- checkOut: str(values["check-out"]),
235
- rooms: num("rooms", str(values.rooms)) ?? (base.rooms === void 0 ? 1 : void 0),
236
- adults: num("adults", str(values.adults)) ?? (base.adults === void 0 ? 2 : void 0),
237
- childrenAges: nums("child-age", list(values["child-age"]))
238
- };
239
- }
240
- var RATES_OPTIONS = {
241
- ...BODY_OPTION,
242
- ...CURRENCY_OPTION,
243
- ...STAY_OPTIONS,
244
- hotel: { type: "string", multiple: true },
245
- nationality: { type: "string" },
246
- board: { type: "string", multiple: true },
247
- "free-cancellation": { type: "boolean" },
248
- "include-unbookable": { type: "boolean" },
249
- "max-rooms": { type: "string" }
250
- };
251
- function ratesBody(values, env = process.env) {
252
- const base = baseBody(values);
253
- return merge(base, {
254
- hotelIds: nums("hotel", list(values.hotel)),
255
- ...stayFields(values, base),
256
- nationality: str(values.nationality)?.toUpperCase(),
257
- boardCodes: list(values.board)?.flatMap(
258
- (code) => code.split(",").map((part) => part.trim().toUpperCase())
259
- ),
260
- freeCancellation: values["free-cancellation"] ? true : void 0,
261
- bookableOnly: values["include-unbookable"] ? false : void 0,
262
- maxRoomsPerHotel: num("max-rooms", str(values["max-rooms"])),
263
- currency: currency(values, base, env)
264
- });
265
- }
266
- var BOOK_OPTIONS = {
267
- ...BODY_OPTION,
268
- ...STAY_OPTIONS,
269
- hotel: { type: "string" },
270
- "room-id": { type: "string" },
271
- "rate-plan": { type: "string" },
272
- guest: { type: "string", multiple: true },
273
- "contact-name": { type: "string" },
274
- "contact-phone": { type: "string" },
275
- "contact-email": { type: "string" },
276
- "special-requests": { type: "string" }
277
- };
278
253
  function parseGuest(value) {
279
254
  const match = value.trim().match(/^(.+?)(?::(\d+))?$/);
280
255
  const words = match?.[1]?.trim().split(/\s+/) ?? [];
@@ -289,390 +264,98 @@ function parseGuest(value) {
289
264
  roomNo: match?.[2] ? Number(match[2]) : 1
290
265
  };
291
266
  }
292
- function bookBody(values) {
293
- const base = baseBody(values);
294
- return merge(base, {
295
- hotelId: num("hotel", str(values.hotel)),
296
- roomId: str(values["room-id"]),
297
- ratePlanCode: str(values["rate-plan"]),
298
- ...stayFields(values, base),
299
- guests: list(values.guest)?.map(parseGuest),
300
- contactName: str(values["contact-name"]),
301
- contactPhone: str(values["contact-phone"]),
302
- contactEmail: str(values["contact-email"]),
303
- specialRequests: str(values["special-requests"])
304
- });
305
- }
306
-
307
- // src/auth.ts
308
- import { hostname } from "os";
309
- import { setTimeout as sleep } from "timers/promises";
310
-
311
- // src/browser.ts
312
- import { spawn } from "child_process";
313
- function openBrowser(url) {
314
- const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
315
- try {
316
- const child = spawn(command, args, { stdio: "ignore", detached: true });
317
- child.on("error", () => {
318
- });
319
- child.unref();
320
- } catch {
321
- }
322
- }
323
-
324
- // src/credentials.ts
325
- import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
326
- import { homedir } from "os";
327
- import { dirname, join } from "path";
328
- var DEFAULT_API_URL = "https://fortrip.ai";
329
- function credentialsPath(env = process.env) {
330
- const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
331
- return join(base, "fortrip", "credentials.json");
332
- }
333
- function resolveApiUrl(env = process.env) {
334
- const raw = env.FORTRIP_API_URL?.trim() || DEFAULT_API_URL;
335
- let url;
336
- try {
337
- url = new URL(raw);
338
- } catch {
339
- throw usageError(`FORTRIP_API_URL is not a valid URL: ${raw}`);
340
- }
341
- const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
342
- if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocal)) {
343
- throw usageError("FORTRIP_API_URL must use https (http only for localhost)");
344
- }
345
- return url.origin;
346
- }
347
- async function readCredentials(path) {
348
- try {
349
- const parsed = JSON.parse(await readFile(path, "utf8"));
350
- return { tokens: parsed?.tokens ?? {}, pending: parsed?.pending ?? {} };
351
- } catch {
352
- return { tokens: {}, pending: {} };
353
- }
354
- }
355
- async function writeCredentials(path, file) {
356
- if (Object.keys(file.tokens).length === 0 && Object.keys(file.pending).length === 0) {
357
- await rm(path, { force: true });
358
- return;
359
- }
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
- }
365
- async function loadStoredToken(apiUrl, env = process.env) {
366
- const file = await readCredentials(credentialsPath(env));
367
- return file.tokens[apiUrl]?.accessToken ?? null;
368
- }
369
- async function resolveToken(apiUrl, env = process.env) {
370
- const fromEnv = env.FORTRIP_TOKEN?.trim();
371
- if (fromEnv) return { token: fromEnv, source: "env" };
372
- const stored = await loadStoredToken(apiUrl, env);
373
- return stored ? { token: stored, source: "file" } : null;
374
- }
375
- async function saveToken(apiUrl, accessToken, env = process.env) {
376
- const path = credentialsPath(env);
377
- const file = await readCredentials(path);
378
- file.tokens[apiUrl] = { accessToken, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
379
- delete file.pending[apiUrl];
380
- await writeCredentials(path, file);
381
- }
382
- async function deleteToken(apiUrl, env = process.env) {
383
- const path = credentialsPath(env);
384
- const file = await readCredentials(path);
385
- delete file.tokens[apiUrl];
386
- await writeCredentials(path, file);
387
- }
388
- async function loadPendingLogin(apiUrl, env = process.env) {
389
- const pending = (await readCredentials(credentialsPath(env))).pending[apiUrl];
390
- return pending && Date.parse(pending.expiresAt) > Date.now() ? pending : null;
391
- }
392
- async function savePendingLogin(apiUrl, pending, env = process.env) {
393
- const path = credentialsPath(env);
394
- const file = await readCredentials(path);
395
- if (pending) file.pending[apiUrl] = pending;
396
- else delete file.pending[apiUrl];
397
- await writeCredentials(path, file);
398
- }
399
-
400
- // src/auth.ts
401
- async function requireToken(apiUrl) {
402
- const resolved = await resolveToken(apiUrl);
403
- if (resolved) return resolved.token;
404
- const pending = await loadPendingLogin(apiUrl);
405
- if (pending) {
406
- const result = await exchange(apiUrl, pending.deviceCode);
407
- if (result.status === "ok") {
408
- await saveToken(apiUrl, result.accessToken);
409
- return result.accessToken;
410
- }
411
- if (result.status === "authorization_pending" || result.status === "slow_down") {
412
- throw loginRequired(apiUrl, pending);
413
- }
414
- }
415
- const start = await startDeviceLogin(apiUrl);
416
- const fresh = {
417
- deviceCode: start.deviceCode,
418
- userCode: start.userCode,
419
- loginUrl: start.verificationUriComplete,
420
- expiresAt: new Date(Date.now() + start.expiresIn * 1e3).toISOString()
421
- };
422
- await savePendingLogin(apiUrl, fresh);
423
- throw loginRequired(apiUrl, fresh);
424
- }
425
- async function login({
426
- apiUrl,
427
- openBrowser: shouldOpen,
428
- log,
429
- wait = sleep
430
- }) {
431
- const start = await startDeviceLogin(apiUrl);
432
- log(`Confirm this code in your browser: ${start.userCode}`);
433
- log(`If the browser doesn't open, visit: ${start.verificationUriComplete}`);
434
- if (shouldOpen) openBrowser(start.verificationUriComplete);
435
- log("Waiting for approval\u2026");
436
- let interval = start.interval;
437
- const deadline = Date.now() + start.expiresIn * 1e3;
438
- while (Date.now() < deadline) {
439
- await wait(interval * 1e3);
440
- const result = await exchange(apiUrl, start.deviceCode);
441
- switch (result.status) {
442
- case "ok":
443
- await saveToken(apiUrl, result.accessToken);
444
- log("Logged in.");
445
- return whoami(apiUrl, result.accessToken);
446
- case "authorization_pending":
447
- continue;
448
- case "slow_down":
449
- interval = result.interval ?? interval + 5;
450
- continue;
451
- case "access_denied":
452
- throw new CliError("Login was denied in the browser.", EXIT.error);
453
- case "expired_token":
454
- throw new CliError(
455
- "The login code expired. Run `login` again.",
456
- EXIT.error
457
- );
458
- }
459
- }
460
- throw new CliError("The login code expired. Run `login` again.", EXIT.error);
461
- }
462
- async function startDeviceLogin(apiUrl) {
463
- return unwrap(
464
- await request(apiUrl, "/api/cli/auth/device", {
465
- method: "POST",
466
- body: { label: deviceLabel(), platform: process.platform }
467
- })
468
- );
469
- }
470
- async function exchange(apiUrl, deviceCode) {
471
- const poll = await request(apiUrl, "/api/cli/auth/token", {
472
- method: "POST",
473
- body: { deviceCode }
474
- });
475
- if (poll.status === 200 && poll.body.success === true) {
476
- const { accessToken } = poll.body.data;
477
- return { status: "ok", accessToken };
478
- }
479
- switch (poll.body.error) {
480
- case "authorization_pending":
481
- case "access_denied":
482
- case "expired_token":
483
- return { status: poll.body.error };
484
- case "slow_down":
485
- return {
486
- status: "slow_down",
487
- interval: typeof poll.body.interval === "number" ? poll.body.interval : void 0
488
- };
489
- default:
490
- unwrap(poll);
491
- throw new CliError(`Unexpected login response ${poll.status}`, EXIT.error);
492
- }
493
- }
494
- function loginRequired(apiUrl, pending) {
495
- return new CliError(
496
- `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).`,
497
- EXIT.unauthenticated,
498
- {
499
- loginUrl: pending.loginUrl,
500
- userCode: pending.userCode,
501
- expiresInSeconds: Math.max(
502
- 0,
503
- Math.round((Date.parse(pending.expiresAt) - Date.now()) / 1e3)
504
- )
505
- }
506
- );
507
- }
508
- async function logout(apiUrl) {
509
- const token = await loadStoredToken(apiUrl);
510
- if (!token) {
511
- throw new CliError(`Not logged in to ${apiUrl}.`, EXIT.unauthenticated);
512
- }
513
- const response = await request(apiUrl, "/api/cli/auth/revoke", {
514
- method: "POST",
515
- token
516
- });
517
- if (response.status !== 401) unwrap(response);
518
- await deleteToken(apiUrl);
519
- return { loggedOut: true, apiUrl };
520
- }
521
- async function whoami(apiUrl, token) {
522
- const resolved = token ?? await requireToken(apiUrl);
523
- const data = unwrap(
524
- await request(apiUrl, "/api/cli/auth/whoami", { token: resolved })
525
- );
526
- return { ...data, apiUrl };
527
- }
528
- function deviceLabel() {
529
- return hostname().replace(/[^\w .@-]/g, "").slice(0, 64);
530
- }
531
267
 
532
268
  // src/guide.ts
533
- import { readFile as readFile2 } from "fs/promises";
534
- var GUIDES = {
535
- main: "../AGENT_GUIDE.md",
536
- planning: "../guides/planning.md"
537
- };
538
- async function readGuide(topic = "main") {
539
- const path = GUIDES[topic];
540
- if (!path) throw usageError("Usage: fortrip skill [planning]");
541
- const guide = await readFile2(new URL(path, import.meta.url), "utf8");
542
- return guide.replaceAll("fortrip@latest", `fortrip@${VERSION}`);
269
+ function readGuide(topic) {
270
+ return renderGuide("cli", topic).replaceAll("fortrip@latest", `fortrip@${VERSION}`);
543
271
  }
544
272
 
545
273
  // src/cli.ts
546
- var HELP = `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
547
-
548
- Usage: fortrip <command> [options]
549
-
550
- Commands:
551
- login [--no-browser] Sign in through the browser (device code)
552
- logout Revoke and forget the saved token
553
- whoami Show the signed-in account
554
- search Find hotels with indicative prices
555
- hotel <hotelId> Static hotel detail [--with-rooms]
556
- rates Live rooms and rates for up to 15 hotels
557
- book Create an order and get a payment link
558
- skill [planning] Print the guide for AI agents (Markdown)
559
-
560
- search: --query <text> | --city-code <n> | --near <lat,lng,km>
561
- --check-in YYYY-MM-DD --check-out YYYY-MM-DD
562
- [--star-min n] [--star-max n] [--facility <name>]... [--price-min n]
563
- [--price-max n] [--sort recommended|price-asc|price-desc|star-desc|distance]
564
- [--page n] [--page-size n] [--currency CNY|USD]
565
- rates: --hotel <id>... --check-in --check-out [--rooms 1] [--adults 2]
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>]
574
-
575
- search, rates and book also take --body '<json>' (or --body @file.json) with
576
- the raw request body; flags override its fields.
577
-
578
- search and rates ask for prices in --currency (CNY by default, or USD),
579
- converted at Stripe's rate or a fixed fallback rate; the response's currency
580
- and fx fields say which. book still charges the currency it returns (usually
581
- USD).
582
-
583
- Output is JSON on stdout; errors are JSON on stderr.
584
- Exit codes: 0 ok, 1 error, 2 invalid arguments, 3 not logged in, 4 rate limited.
585
-
586
- Environment:
587
- FORTRIP_TOKEN Use this token instead of the saved login (CI, sandboxes)
588
- FORTRIP_API_URL API base URL (default https://fortrip.ai)
589
- FORTRIP_CURRENCY Default --currency for search and rates
590
- `;
274
+ function globalFlag(name) {
275
+ const option = GLOBAL_FLAGS.find((candidate) => candidate.flag === name);
276
+ return option ? [`--${option.flag}`, ...option.short ? [`-${option.short}`] : []] : [];
277
+ }
278
+ var HELP_FLAGS = globalFlag("help");
279
+ var VERSION_FLAGS = globalFlag("version");
591
280
  async function run(argv) {
592
281
  const [command, ...rest] = argv;
593
- if (!command || command === "help" || command === "--help" || command === "-h") {
594
- process.stdout.write(HELP);
282
+ if (!command || command === "help" || HELP_FLAGS.includes(command)) {
283
+ process.stdout.write(overallHelp());
595
284
  return void 0;
596
285
  }
597
- if (command === "--version" || command === "-v") {
286
+ if (VERSION_FLAGS.includes(command)) {
598
287
  process.stdout.write(`${VERSION}
599
288
  `);
600
289
  return void 0;
601
290
  }
602
- if (command === "skill") {
603
- const { positionals } = parse(rest, {});
604
- process.stdout.write(await readGuide(positionals.join(" ") || void 0));
291
+ const spec = COMMANDS.find((candidate) => candidate.name === command);
292
+ if (!spec) {
293
+ throw usageError(`Unknown command "${command}". Run \`fortrip --help\`.`);
294
+ }
295
+ if (wantsHelp(rest)) {
296
+ process.stdout.write(commandHelp(spec));
297
+ return void 0;
298
+ }
299
+ if (spec.name === "guide") {
300
+ const { positionals } = parseOptions(spec, rest);
301
+ const topic = positionals.join(" ");
302
+ if (topic !== "" && topic !== "planning") {
303
+ throw usageError("Usage: fortrip guide [planning]");
304
+ }
305
+ process.stdout.write(readGuide(topic || "main"));
605
306
  return void 0;
606
307
  }
607
308
  const apiUrl = resolveApiUrl();
608
309
  const log = (line) => process.stderr.write(`${line}
609
310
  `);
610
- switch (command) {
311
+ switch (spec.name) {
611
312
  case "login": {
612
- const { values } = parse(rest, { "no-browser": { type: "boolean" } });
313
+ const { values } = parseOptions(spec, rest);
613
314
  if (process.env.FORTRIP_TOKEN) {
614
315
  log("Note: FORTRIP_TOKEN is set and takes precedence over this login.");
615
316
  }
616
317
  return login({ apiUrl, openBrowser: !values["no-browser"], log });
617
318
  }
618
319
  case "logout":
619
- parse(rest, {});
320
+ parseOptions(spec, rest);
620
321
  return logout(apiUrl);
621
322
  case "whoami":
622
- parse(rest, {});
323
+ parseOptions(spec, rest);
623
324
  return whoami(apiUrl);
624
- case "search": {
625
- const { values } = parse(rest, SEARCH_OPTIONS);
626
- return call(apiUrl, "POST", "/api/huizhi/search", searchBody(values));
627
- }
628
- case "hotel": {
629
- const { values, positionals } = parse(rest, HOTEL_OPTIONS);
630
- return call(apiUrl, "GET", hotelPath(positionals, values));
325
+ case "mcp": {
326
+ parseOptions(spec, rest);
327
+ const { runMcpBridge } = await import("./mcp-bridge-VEJ33LYD.js");
328
+ return runMcpBridge(apiUrl);
631
329
  }
632
- case "rates": {
633
- const { values } = parse(rest, RATES_OPTIONS);
634
- return call(apiUrl, "POST", "/api/huizhi/rates", ratesBody(values));
330
+ default: {
331
+ const { body, path } = parseCommand(spec, rest, process.env);
332
+ return call(apiUrl, body ? "POST" : "GET", path, body);
635
333
  }
636
- case "book": {
637
- const { values } = parse(rest, BOOK_OPTIONS);
638
- const data = await call(
639
- apiUrl,
640
- "POST",
641
- "/api/huizhi/bookings",
642
- bookBody(values)
643
- );
644
- return absolutize(apiUrl, data, ["orderUrl", "statusUrl"]);
645
- }
646
- default:
647
- throw usageError(`Unknown command "${command}". Run \`fortrip --help\`.`);
648
334
  }
649
335
  }
650
336
  async function call(apiUrl, method, path, body) {
651
- const token = await requireToken(apiUrl);
652
- return unwrap(await request(apiUrl, path, { method, token, body }));
337
+ return unwrap(await authorizedRequest(apiUrl, path, { method, body }));
653
338
  }
654
- function absolutize(apiUrl, data, keys) {
655
- const out = { ...data };
656
- for (const key of keys) {
657
- const value = out[key];
658
- if (typeof value === "string" && value.startsWith("/")) {
659
- out[key] = `${apiUrl}${value}`;
660
- }
339
+ function wantsHelp(args) {
340
+ for (const arg of args) {
341
+ if (arg === "--") return false;
342
+ if (HELP_FLAGS.includes(arg)) return true;
661
343
  }
662
- return out;
344
+ return false;
663
345
  }
664
346
  async function main() {
665
347
  try {
666
348
  const result = await run(process.argv.slice(2));
667
349
  if (result !== void 0) {
668
- process.stdout.write(`${JSON.stringify(result, null, 2)}
350
+ const output = result && typeof result === "object" && !Array.isArray(result) ? { ...result, ...notices } : result;
351
+ process.stdout.write(`${JSON.stringify(output, null, 2)}
669
352
  `);
670
353
  }
671
354
  return EXIT.ok;
672
355
  } catch (error) {
673
356
  const failure = error instanceof CliError ? error : new CliError(error?.message ?? String(error), EXIT.error);
674
357
  process.stderr.write(
675
- `${JSON.stringify({ error: failure.message, exitCode: failure.exitCode, ...failure.details }, null, 2)}
358
+ `${JSON.stringify({ error: failure.message, exitCode: failure.exitCode, ...failure.details, ...notices }, null, 2)}
676
359
  `
677
360
  );
678
361
  return failure.exitCode;