fortrip 0.3.1 → 2.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/LICENSE +202 -21
- package/NOTICE +4 -0
- package/README.md +204 -115
- package/THIRD-PARTY-NOTICES +30 -0
- package/dist/chunk-53656LB2.js +16486 -0
- package/dist/cli.js +292 -1157
- package/dist/mcp-bridge-LPQBHWLD.js +339 -0
- package/package.json +21 -15
- package/AGENT_GUIDE.md +0 -200
- package/guides/planning.md +0 -57
package/dist/cli.js
CHANGED
|
@@ -1,116 +1,152 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
API,
|
|
4
|
+
COMMANDS,
|
|
5
|
+
COMMAND_GROUPS,
|
|
6
|
+
CliError,
|
|
7
|
+
EXIT,
|
|
8
|
+
GLOBAL_FLAGS,
|
|
9
|
+
VERSION,
|
|
10
|
+
authorizedRequest,
|
|
11
|
+
commandInvocation,
|
|
12
|
+
login,
|
|
13
|
+
logout,
|
|
14
|
+
notices,
|
|
15
|
+
renderGuide,
|
|
16
|
+
resolveApiUrl,
|
|
17
|
+
unwrap,
|
|
18
|
+
usageError,
|
|
19
|
+
whoami
|
|
20
|
+
} from "./chunk-53656LB2.js";
|
|
21
|
+
|
|
22
|
+
// src/commands.ts
|
|
23
|
+
import { readFileSync } from "fs";
|
|
24
|
+
import { parseArgs } from "util";
|
|
2
25
|
|
|
3
|
-
// src/
|
|
4
|
-
var
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
26
|
+
// src/help.ts
|
|
27
|
+
var WIDTH = 78;
|
|
28
|
+
var OPTIONAL_POSITIONALS = /* @__PURE__ */ new Set(["topic"]);
|
|
29
|
+
function wrap(text, indent) {
|
|
30
|
+
const pad = " ".repeat(indent);
|
|
31
|
+
const lines = [];
|
|
32
|
+
let line = "";
|
|
33
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
34
|
+
if (line && indent + line.length + 1 + word.length > WIDTH) {
|
|
35
|
+
lines.push(pad + line);
|
|
36
|
+
line = word;
|
|
37
|
+
} else {
|
|
38
|
+
line = line ? `${line} ${word}` : word;
|
|
39
|
+
}
|
|
16
40
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
if (line) lines.push(pad + line);
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
44
|
+
function positionals(spec) {
|
|
45
|
+
return (spec.positionals ?? []).map(
|
|
46
|
+
(name) => OPTIONAL_POSITIONALS.has(name) ? `[${name}]` : `<${name}>`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
function label(spec) {
|
|
50
|
+
return [spec.name, ...positionals(spec)].join(" ");
|
|
51
|
+
}
|
|
52
|
+
function usageLine(spec) {
|
|
53
|
+
return [commandInvocation(spec), ...positionals(spec), "[options]"].join(" ");
|
|
54
|
+
}
|
|
55
|
+
function names(option, aligned) {
|
|
56
|
+
const short = option.short ? `-${option.short}, ` : aligned ? " " : "";
|
|
57
|
+
const placeholder = option.placeholder ? ` ${option.placeholder}` : "";
|
|
58
|
+
const repeats = "multiple" in option && option.multiple ? "..." : "";
|
|
59
|
+
return `${short}--${option.flag}${placeholder}${repeats}`;
|
|
23
60
|
}
|
|
61
|
+
function optionList(options) {
|
|
62
|
+
const aligned = options.some((option) => option.short);
|
|
63
|
+
return options.map(
|
|
64
|
+
(option) => [` ${names(option, aligned)}`, ...wrap(option.description, 8)].join("\n")
|
|
65
|
+
).join("\n");
|
|
66
|
+
}
|
|
67
|
+
var HELP_FLAG = GLOBAL_FLAGS.filter((option) => option.flag === "help");
|
|
68
|
+
function commandHelp(spec) {
|
|
69
|
+
const options = [...spec.flags, ...spec.options ?? [], ...HELP_FLAG];
|
|
70
|
+
return `Usage: ${usageLine(spec)}
|
|
24
71
|
|
|
25
|
-
|
|
26
|
-
import { readFileSync } from "fs";
|
|
27
|
-
var VERSION = JSON.parse(
|
|
28
|
-
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
29
|
-
).version;
|
|
72
|
+
${wrap(spec.summary, 0).join("\n")}
|
|
30
73
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
async function request(apiUrl, path, {
|
|
35
|
-
method = "GET",
|
|
36
|
-
token,
|
|
37
|
-
body
|
|
38
|
-
} = {}) {
|
|
39
|
-
let response;
|
|
40
|
-
try {
|
|
41
|
-
response = await fetch(`${apiUrl}${path}`, {
|
|
42
|
-
method,
|
|
43
|
-
headers: {
|
|
44
|
-
accept: "application/json",
|
|
45
|
-
"user-agent": `fortrip-cli/${VERSION}`,
|
|
46
|
-
...body !== void 0 ? { "content-type": "application/json" } : {},
|
|
47
|
-
...token ? { authorization: `Bearer ${token}` } : {}
|
|
48
|
-
},
|
|
49
|
-
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
50
|
-
// A redirect to another origin would drop the Authorization header and
|
|
51
|
-
// look like a logout; surface it instead.
|
|
52
|
-
redirect: "manual",
|
|
53
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
54
|
-
});
|
|
55
|
-
} catch (error) {
|
|
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);
|
|
63
|
-
}
|
|
64
|
-
let parsed = null;
|
|
65
|
-
try {
|
|
66
|
-
parsed = text ? JSON.parse(text) : {};
|
|
67
|
-
} catch {
|
|
68
|
-
parsed = null;
|
|
69
|
-
}
|
|
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
|
+
Options:
|
|
75
|
+
${optionList(options)}
|
|
76
|
+
`;
|
|
74
77
|
}
|
|
75
|
-
function
|
|
76
|
-
const
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
});
|
|
78
|
+
function commandList(rows) {
|
|
79
|
+
const column = 2 + Math.max(...rows.map(([name]) => name.length)) + 2;
|
|
80
|
+
return rows.map(([name, summary]) => {
|
|
81
|
+
const [first = "", ...rest] = wrap(summary, column);
|
|
82
|
+
return [` ${name}`.padEnd(column) + first.slice(column), ...rest].join("\n");
|
|
83
|
+
}).join("\n");
|
|
81
84
|
}
|
|
82
|
-
function
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (status === 429) throw new CliError(text, EXIT.rateLimited, details);
|
|
97
|
-
if (status === 400) throw new CliError(text, EXIT.usage, details);
|
|
98
|
-
if (status >= 300 && status < 400) {
|
|
99
|
-
throw new CliError(
|
|
100
|
-
`Unexpected redirect to ${headers.get("location") ?? "?"}; check FORTRIP_API_URL`,
|
|
101
|
-
EXIT.error,
|
|
102
|
-
details
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
throw new CliError(text, EXIT.error, details);
|
|
85
|
+
function groupHelp(group) {
|
|
86
|
+
const usage = `fortrip ${group.name} <command>`;
|
|
87
|
+
const commands = COMMANDS.filter((spec) => spec.group === group.name);
|
|
88
|
+
return `Usage: ${usage} [options]
|
|
89
|
+
${usage} --help
|
|
90
|
+
|
|
91
|
+
${wrap(group.summary, 0).join("\n")}
|
|
92
|
+
|
|
93
|
+
Commands:
|
|
94
|
+
${commandList(commands.map((spec) => [label(spec), spec.summary]))}
|
|
95
|
+
|
|
96
|
+
Options:
|
|
97
|
+
${optionList(HELP_FLAG)}
|
|
98
|
+
`;
|
|
106
99
|
}
|
|
100
|
+
var CURRENCY_COMMANDS = COMMANDS.filter(
|
|
101
|
+
(spec) => spec.flags.some((flag) => flag.field === "currency")
|
|
102
|
+
).map((spec) => commandInvocation(spec).slice("fortrip ".length)).join(" and ");
|
|
103
|
+
function overallHelp() {
|
|
104
|
+
const rows = [
|
|
105
|
+
...COMMAND_GROUPS.map((group) => [`${group.name} <command>`, group.summary]),
|
|
106
|
+
...COMMANDS.filter((spec) => spec.group === null).map(
|
|
107
|
+
(spec) => [label(spec), spec.summary]
|
|
108
|
+
)
|
|
109
|
+
];
|
|
110
|
+
return `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
|
|
107
111
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
Usage: fortrip <command> [options]
|
|
113
|
+
fortrip <command> --help
|
|
114
|
+
|
|
115
|
+
Commands:
|
|
116
|
+
${commandList(rows)}
|
|
117
|
+
|
|
118
|
+
Options:
|
|
119
|
+
${optionList(GLOBAL_FLAGS)}
|
|
120
|
+
|
|
121
|
+
Output is JSON on stdout; errors are JSON on stderr.
|
|
122
|
+
Exit codes: 0 ok, 1 error (retry once when stderr has "retryable": true),
|
|
123
|
+
2 invalid arguments, 3 not logged in, 4 rate limited.
|
|
124
|
+
|
|
125
|
+
Environment:
|
|
126
|
+
FORTRIP_TOKEN Use this token instead of the saved login (CI, sandboxes)
|
|
127
|
+
FORTRIP_API_URL API base URL (default https://fortrip.ai)
|
|
128
|
+
FORTRIP_CURRENCY Default --currency for ${CURRENCY_COMMANDS}
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/commands.ts
|
|
133
|
+
var COMMA_LISTS = /* @__PURE__ */ new Set(["hotelIds", "childrenAges", "boardCodes"]);
|
|
134
|
+
var UPPER_CASE = /* @__PURE__ */ new Set(["nationality", "boardCodes", "currency"]);
|
|
135
|
+
var TURNS_OFF = /* @__PURE__ */ new Set(["bookableOnly"]);
|
|
136
|
+
var POSTS = {
|
|
137
|
+
search_hotels: API.search,
|
|
138
|
+
get_hotel_rates: API.rates,
|
|
139
|
+
book_hotel: API.bookings
|
|
140
|
+
};
|
|
141
|
+
function parseOptions(spec, argv) {
|
|
142
|
+
const options = {};
|
|
143
|
+
for (const option of [...spec.flags, ...spec.options ?? []]) {
|
|
144
|
+
options[option.flag] = {
|
|
145
|
+
type: option.type === "boolean" ? "boolean" : "string",
|
|
146
|
+
...option.short ? { short: option.short } : {},
|
|
147
|
+
..."multiple" in option && option.multiple ? { multiple: true } : {}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
114
150
|
let parsed;
|
|
115
151
|
try {
|
|
116
152
|
parsed = parseArgs({
|
|
@@ -131,15 +167,53 @@ function parse(argv, options) {
|
|
|
131
167
|
}
|
|
132
168
|
seen.add(token.name);
|
|
133
169
|
}
|
|
134
|
-
|
|
170
|
+
if (!spec.positionals && parsed.positionals.length) {
|
|
171
|
+
throw usageError(
|
|
172
|
+
`${commandInvocation(spec)} takes no arguments, got "${parsed.positionals[0]}"`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return { values: parsed.values, positionals: parsed.positionals };
|
|
135
176
|
}
|
|
136
|
-
function
|
|
137
|
-
const
|
|
177
|
+
function parseCommand(spec, argv, env) {
|
|
178
|
+
const post = spec.tool ? POSTS[spec.tool] : void 0;
|
|
179
|
+
if (!post && spec.tool !== "get_hotel") {
|
|
180
|
+
throw new Error(`${commandInvocation(spec)} sends no request`);
|
|
181
|
+
}
|
|
182
|
+
const { values, positionals: positionals2 } = parseOptions(spec, argv);
|
|
183
|
+
const input = baseBody(values.body);
|
|
184
|
+
for (const flag of spec.flags) {
|
|
185
|
+
const raw = values[flag.flag];
|
|
186
|
+
if (raw !== void 0) setField(input, flag.field, readFlag(flag, raw));
|
|
187
|
+
}
|
|
188
|
+
if (spec.tool === "get_hotel") {
|
|
189
|
+
const [hotelId, ...extra] = positionals2;
|
|
190
|
+
const parsed = Number(hotelId);
|
|
191
|
+
if (!hotelId || extra.length || !/^\d+$/.test(hotelId) || !Number.isSafeInteger(parsed)) {
|
|
192
|
+
throw usageError(`Usage: ${usageLine(spec)}`);
|
|
193
|
+
}
|
|
194
|
+
return { path: API.hotel(parsed, input.includeRooms === true) };
|
|
195
|
+
}
|
|
196
|
+
if (spec.flags.some((flag) => flag.field === "currency") && input.currency === void 0) {
|
|
197
|
+
const fallback = env.FORTRIP_CURRENCY?.trim().toUpperCase();
|
|
198
|
+
if (fallback) input.currency = fallback;
|
|
199
|
+
}
|
|
200
|
+
const occupancy = spec.flags.filter(
|
|
201
|
+
(flag) => flag.field === "rooms" || flag.field === "adults"
|
|
202
|
+
);
|
|
203
|
+
const missing = occupancy.filter((flag) => input[flag.field] === void 0);
|
|
204
|
+
if (occupancy.length === 2 && missing.length) {
|
|
205
|
+
throw usageError(
|
|
206
|
+
`${commandInvocation(spec)} 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)`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return { body: input, path: post };
|
|
210
|
+
}
|
|
211
|
+
function baseBody(raw) {
|
|
138
212
|
if (typeof raw !== "string") return {};
|
|
139
213
|
let text = raw;
|
|
140
214
|
if (raw.startsWith("@")) {
|
|
141
215
|
try {
|
|
142
|
-
text =
|
|
216
|
+
text = readFileSync(raw.slice(1), "utf8");
|
|
143
217
|
} catch (error) {
|
|
144
218
|
throw usageError(`Cannot read --body file: ${error.message}`);
|
|
145
219
|
}
|
|
@@ -153,163 +227,55 @@ function baseBody(values) {
|
|
|
153
227
|
}
|
|
154
228
|
throw usageError("--body must be a JSON object");
|
|
155
229
|
}
|
|
230
|
+
function setField(body, field, value) {
|
|
231
|
+
const path = field.split(".");
|
|
232
|
+
const last = path.pop();
|
|
233
|
+
let node = body;
|
|
234
|
+
for (const part of path) {
|
|
235
|
+
const next = node[part];
|
|
236
|
+
node[part] = next && typeof next === "object" && !Array.isArray(next) ? next : {};
|
|
237
|
+
node = node[part];
|
|
238
|
+
}
|
|
239
|
+
node[last] = value;
|
|
240
|
+
}
|
|
241
|
+
function readFlag(flag, raw) {
|
|
242
|
+
if (flag.type === "boolean") return !TURNS_OFF.has(flag.field);
|
|
243
|
+
const given = Array.isArray(raw) ? raw : [raw];
|
|
244
|
+
if (flag.field === "near") return parseNear(given[0]);
|
|
245
|
+
if (flag.field === "guests") return given.map(parseGuest);
|
|
246
|
+
const parts = COMMA_LISTS.has(flag.field) ? given.flatMap((value) => value.split(",")).map((part) => part.trim()) : given;
|
|
247
|
+
const values = parts.map((part) => {
|
|
248
|
+
const text = UPPER_CASE.has(flag.field) ? part.trim().toUpperCase() : part;
|
|
249
|
+
if (flag.field === "currency" && text === "") {
|
|
250
|
+
throw usageError(`--${flag.flag} must not be empty`);
|
|
251
|
+
}
|
|
252
|
+
if (flag.type === "number") return num(flag.flag, text);
|
|
253
|
+
return flag.type === "id" ? id(flag.flag, text) : text;
|
|
254
|
+
});
|
|
255
|
+
return flag.multiple ? values : values[0];
|
|
256
|
+
}
|
|
156
257
|
function num(flag, value) {
|
|
157
|
-
if (value === void 0) return void 0;
|
|
158
258
|
const parsed = Number(value);
|
|
159
259
|
if (value.trim() === "" || !Number.isFinite(parsed)) {
|
|
160
260
|
throw usageError(`--${flag} must be a number, got "${value}"`);
|
|
161
261
|
}
|
|
162
262
|
return parsed;
|
|
163
263
|
}
|
|
164
|
-
function nums(flag, values) {
|
|
165
|
-
if (!values?.length) return void 0;
|
|
166
|
-
return values.flatMap((value) => value.split(",")).map((value) => num(flag, value.trim()));
|
|
167
|
-
}
|
|
168
264
|
function id(flag, value) {
|
|
169
|
-
if (value === void 0) return void 0;
|
|
170
265
|
const parsed = Number(value.trim());
|
|
171
266
|
if (!/^\d+$/.test(value.trim()) || !Number.isSafeInteger(parsed)) {
|
|
172
267
|
throw usageError(`--${flag} must be a numeric id, got "${value}"`);
|
|
173
268
|
}
|
|
174
269
|
return parsed;
|
|
175
270
|
}
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
function str(value) {
|
|
181
|
-
return typeof value === "string" ? value : void 0;
|
|
182
|
-
}
|
|
183
|
-
function list(value) {
|
|
184
|
-
return Array.isArray(value) && value.length ? value : void 0;
|
|
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
|
-
}
|
|
193
|
-
function merge(base, overrides) {
|
|
194
|
-
const body = { ...base };
|
|
195
|
-
for (const [key, value] of Object.entries(overrides)) {
|
|
196
|
-
if (value !== void 0) body[key] = value;
|
|
197
|
-
}
|
|
198
|
-
return body;
|
|
199
|
-
}
|
|
200
|
-
var SEARCH_OPTIONS = {
|
|
201
|
-
...BODY_OPTION,
|
|
202
|
-
...CURRENCY_OPTION,
|
|
203
|
-
query: { type: "string", short: "q" },
|
|
204
|
-
"city-code": { type: "string" },
|
|
205
|
-
near: { type: "string" },
|
|
206
|
-
"check-in": { type: "string" },
|
|
207
|
-
"check-out": { type: "string" },
|
|
208
|
-
"star-min": { type: "string" },
|
|
209
|
-
"star-max": { type: "string" },
|
|
210
|
-
facility: { type: "string", multiple: true },
|
|
211
|
-
"price-min": { type: "string" },
|
|
212
|
-
"price-max": { type: "string" },
|
|
213
|
-
sort: { type: "string" },
|
|
214
|
-
page: { type: "string" },
|
|
215
|
-
"page-size": { type: "string" }
|
|
216
|
-
};
|
|
217
|
-
function searchBody(values, env = process.env) {
|
|
218
|
-
const base = baseBody(values);
|
|
219
|
-
let near;
|
|
220
|
-
const nearRaw = str(values.near);
|
|
221
|
-
if (nearRaw !== void 0) {
|
|
222
|
-
const [lat, lng, radiusKm] = nearRaw.split(",").map((part) => part.trim());
|
|
223
|
-
if (!lat || !lng || !radiusKm) {
|
|
224
|
-
throw usageError("--near must be lat,lng,radiusKm (e.g. 13.75,100.5,5)");
|
|
225
|
-
}
|
|
226
|
-
near = {
|
|
227
|
-
lat: num("near", lat),
|
|
228
|
-
lng: num("near", lng),
|
|
229
|
-
radiusKm: num("near", radiusKm)
|
|
230
|
-
};
|
|
271
|
+
function parseNear(value) {
|
|
272
|
+
const parts = value.split(",").map((part) => part.trim());
|
|
273
|
+
if (parts.length !== 3 || parts.some((part) => !part)) {
|
|
274
|
+
throw usageError("--near must be lat,lng,radiusKm (e.g. 13.75,100.5,5)");
|
|
231
275
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
cityCode: id("city-code", str(values["city-code"])),
|
|
235
|
-
near,
|
|
236
|
-
checkIn: str(values["check-in"]),
|
|
237
|
-
checkOut: str(values["check-out"]),
|
|
238
|
-
starMin: num("star-min", str(values["star-min"])),
|
|
239
|
-
starMax: num("star-max", str(values["star-max"])),
|
|
240
|
-
facilities: list(values.facility),
|
|
241
|
-
priceMin: num("price-min", str(values["price-min"])),
|
|
242
|
-
priceMax: num("price-max", str(values["price-max"])),
|
|
243
|
-
sortBy: str(values.sort),
|
|
244
|
-
page: num("page", str(values.page)),
|
|
245
|
-
pageSize: num("page-size", str(values["page-size"])),
|
|
246
|
-
currency: currency(values, base, env)
|
|
247
|
-
});
|
|
276
|
+
const [lat, lng, radiusKm] = parts.map((part) => num("near", part));
|
|
277
|
+
return { lat, lng, radiusKm };
|
|
248
278
|
}
|
|
249
|
-
var HOTEL_OPTIONS = {
|
|
250
|
-
"with-rooms": { type: "boolean" }
|
|
251
|
-
};
|
|
252
|
-
function hotelPath(positionals, values) {
|
|
253
|
-
const [id2, ...extra] = positionals;
|
|
254
|
-
if (!id2 || extra.length || !/^\d+$/.test(id2)) {
|
|
255
|
-
throw usageError("Usage: fortrip hotel <hotelId> [--with-rooms]");
|
|
256
|
-
}
|
|
257
|
-
return `/api/huizhi/hotels/${id2}${values["with-rooms"] ? "?include=rooms" : ""}`;
|
|
258
|
-
}
|
|
259
|
-
var STAY_OPTIONS = {
|
|
260
|
-
"check-in": { type: "string" },
|
|
261
|
-
"check-out": { type: "string" },
|
|
262
|
-
rooms: { type: "string" },
|
|
263
|
-
adults: { type: "string" },
|
|
264
|
-
"child-age": { type: "string", multiple: true }
|
|
265
|
-
};
|
|
266
|
-
function stayFields(values, base, defaults = {}) {
|
|
267
|
-
return {
|
|
268
|
-
checkIn: str(values["check-in"]),
|
|
269
|
-
checkOut: str(values["check-out"]),
|
|
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),
|
|
272
|
-
childrenAges: nums("child-age", list(values["child-age"]))
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
var RATES_OPTIONS = {
|
|
276
|
-
...BODY_OPTION,
|
|
277
|
-
...CURRENCY_OPTION,
|
|
278
|
-
...STAY_OPTIONS,
|
|
279
|
-
hotel: { type: "string", multiple: true },
|
|
280
|
-
nationality: { type: "string" },
|
|
281
|
-
board: { type: "string", multiple: true },
|
|
282
|
-
"free-cancellation": { type: "boolean" },
|
|
283
|
-
"include-unbookable": { type: "boolean" },
|
|
284
|
-
"max-rooms": { type: "string" }
|
|
285
|
-
};
|
|
286
|
-
function ratesBody(values, env = process.env) {
|
|
287
|
-
const base = baseBody(values);
|
|
288
|
-
return merge(base, {
|
|
289
|
-
hotelIds: ids("hotel", list(values.hotel)),
|
|
290
|
-
...stayFields(values, base, { rooms: 1, adults: 2 }),
|
|
291
|
-
nationality: str(values.nationality)?.toUpperCase(),
|
|
292
|
-
boardCodes: list(values.board)?.flatMap(
|
|
293
|
-
(code) => code.split(",").map((part) => part.trim().toUpperCase())
|
|
294
|
-
),
|
|
295
|
-
freeCancellation: values["free-cancellation"] ? true : void 0,
|
|
296
|
-
bookableOnly: values["include-unbookable"] ? false : void 0,
|
|
297
|
-
maxRoomsPerHotel: num("max-rooms", str(values["max-rooms"])),
|
|
298
|
-
currency: currency(values, base, env)
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
var BOOK_OPTIONS = {
|
|
302
|
-
...BODY_OPTION,
|
|
303
|
-
...STAY_OPTIONS,
|
|
304
|
-
hotel: { type: "string" },
|
|
305
|
-
"room-id": { type: "string" },
|
|
306
|
-
"rate-plan": { type: "string" },
|
|
307
|
-
guest: { type: "string", multiple: true },
|
|
308
|
-
"contact-name": { type: "string" },
|
|
309
|
-
"contact-phone": { type: "string" },
|
|
310
|
-
"contact-email": { type: "string" },
|
|
311
|
-
"special-requests": { type: "string" }
|
|
312
|
-
};
|
|
313
279
|
function parseGuest(value) {
|
|
314
280
|
const match = value.trim().match(/^(.+?)(?::(\d+))?$/);
|
|
315
281
|
const words = match?.[1]?.trim().split(/\s+/) ?? [];
|
|
@@ -324,920 +290,110 @@ function parseGuest(value) {
|
|
|
324
290
|
roomNo: match?.[2] ? Number(match[2]) : 1
|
|
325
291
|
};
|
|
326
292
|
}
|
|
327
|
-
function bookBody(values) {
|
|
328
|
-
const base = baseBody(values);
|
|
329
|
-
const body = merge(base, {
|
|
330
|
-
hotelId: id("hotel", str(values.hotel)),
|
|
331
|
-
roomId: str(values["room-id"]),
|
|
332
|
-
ratePlanCode: str(values["rate-plan"]),
|
|
333
|
-
...stayFields(values, base),
|
|
334
|
-
guests: list(values.guest)?.map(parseGuest),
|
|
335
|
-
contactName: str(values["contact-name"]),
|
|
336
|
-
contactPhone: str(values["contact-phone"]),
|
|
337
|
-
contactEmail: str(values["contact-email"]),
|
|
338
|
-
specialRequests: str(values["special-requests"])
|
|
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;
|
|
347
|
-
}
|
|
348
293
|
|
|
349
|
-
// src/
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
// src/browser.ts
|
|
354
|
-
import { spawn } from "child_process";
|
|
355
|
-
function openBrowser(url) {
|
|
356
|
-
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
|
|
357
|
-
try {
|
|
358
|
-
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
359
|
-
child.on("error", () => {
|
|
360
|
-
});
|
|
361
|
-
child.unref();
|
|
362
|
-
} catch {
|
|
363
|
-
}
|
|
294
|
+
// src/guide.ts
|
|
295
|
+
function readGuide(topic) {
|
|
296
|
+
return renderGuide("cli", topic).replaceAll("fortrip@latest", `fortrip@${VERSION}`);
|
|
364
297
|
}
|
|
365
298
|
|
|
366
|
-
// src/
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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];
|
|
392
|
-
function credentialsPath(env = process.env) {
|
|
393
|
-
const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
394
|
-
return join(base, "fortrip", "credentials.json");
|
|
395
|
-
}
|
|
396
|
-
function resolveApiUrl(env = process.env) {
|
|
397
|
-
const raw = env.FORTRIP_API_URL?.trim() || DEFAULT_API_URL;
|
|
398
|
-
let url;
|
|
399
|
-
try {
|
|
400
|
-
url = new URL(raw);
|
|
401
|
-
} catch {
|
|
402
|
-
throw usageError(`FORTRIP_API_URL is not a valid URL: ${raw}`);
|
|
403
|
-
}
|
|
404
|
-
const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
405
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocal)) {
|
|
406
|
-
throw usageError("FORTRIP_API_URL must use https (http only for localhost)");
|
|
407
|
-
}
|
|
408
|
-
return url.origin;
|
|
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
|
-
}
|
|
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 = (() => {
|
|
607
|
-
try {
|
|
608
|
-
return readlinkSync("/proc/self/ns/pid");
|
|
609
|
-
} catch {
|
|
610
|
-
return "";
|
|
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
|
-
});
|
|
620
|
-
}
|
|
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";
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
async function loadStoredToken(apiUrl, env = process.env) {
|
|
702
|
-
const { credentials } = await readCredentials(credentialsPath(env));
|
|
703
|
-
return credentials.tokens[apiUrl]?.accessToken ?? null;
|
|
704
|
-
}
|
|
705
|
-
async function resolveToken(apiUrl, env = process.env) {
|
|
706
|
-
const fromEnv = env.FORTRIP_TOKEN?.trim();
|
|
707
|
-
if (fromEnv) return { token: fromEnv, source: "env" };
|
|
708
|
-
const stored = await loadStoredToken(apiUrl, env);
|
|
709
|
-
return stored ? { token: stored, source: "file" } : null;
|
|
710
|
-
}
|
|
711
|
-
async function saveToken(apiUrl, accessToken, env = process.env) {
|
|
712
|
-
const path = credentialsPath(env);
|
|
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
|
-
}
|
|
744
|
-
}
|
|
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];
|
|
299
|
+
// src/cli.ts
|
|
300
|
+
function globalFlag(name) {
|
|
301
|
+
const option = GLOBAL_FLAGS.find((candidate) => candidate.flag === name);
|
|
302
|
+
return option ? [`--${option.flag}`, ...option.short ? [`-${option.short}`] : []] : [];
|
|
303
|
+
}
|
|
304
|
+
var HELP_FLAGS = globalFlag("help");
|
|
305
|
+
var VERSION_FLAGS = globalFlag("version");
|
|
306
|
+
var RENAMED = /* @__PURE__ */ new Map([
|
|
307
|
+
["hotel", { group: "hotels", name: "get" }]
|
|
308
|
+
]);
|
|
309
|
+
function findCommand(argv) {
|
|
310
|
+
const [word, ...afterWord] = argv;
|
|
311
|
+
const group = COMMAND_GROUPS.find((candidate) => candidate.name === word);
|
|
312
|
+
if (group) {
|
|
313
|
+
const [name, ...rest] = afterWord;
|
|
314
|
+
if (!name || name === "help" || HELP_FLAGS.includes(name)) {
|
|
315
|
+
return { groupHelp: group };
|
|
750
316
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
async function loadPendingLogins(apiUrl, env = process.env) {
|
|
754
|
-
const { credentials } = await readCredentials(credentialsPath(env));
|
|
755
|
-
return (credentials.logins[apiUrl] ?? []).filter((login2) => !isExpired(login2));
|
|
756
|
-
}
|
|
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 }] : []
|
|
317
|
+
const spec2 = COMMANDS.find(
|
|
318
|
+
(candidate) => candidate.group === group.name && candidate.name === name
|
|
768
319
|
);
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
// src/auth.ts
|
|
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();
|
|
778
|
-
const resolved = await resolveToken(apiUrl);
|
|
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);
|
|
818
|
-
}
|
|
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
|
|
320
|
+
if (!spec2) {
|
|
321
|
+
throw usageError(
|
|
322
|
+
`Unknown command "${group.name} ${name}". Run \`fortrip ${group.name} --help\`.`
|
|
870
323
|
);
|
|
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 });
|
|
904
|
-
}
|
|
905
|
-
async function login({
|
|
906
|
-
apiUrl,
|
|
907
|
-
openBrowser: shouldOpen,
|
|
908
|
-
log,
|
|
909
|
-
wait = sleep2
|
|
910
|
-
}) {
|
|
911
|
-
await loadStoredToken(apiUrl);
|
|
912
|
-
const start = await startDeviceLogin(apiUrl);
|
|
913
|
-
log(`Confirm this code in your browser: ${start.userCode}`);
|
|
914
|
-
log(`If the browser doesn't open, visit: ${start.verificationUriComplete}`);
|
|
915
|
-
if (shouldOpen) openBrowser(start.verificationUriComplete);
|
|
916
|
-
log("Waiting for approval\u2026");
|
|
917
|
-
let interval = start.interval;
|
|
918
|
-
const deadline = Date.now() + start.expiresIn * 1e3;
|
|
919
|
-
while (Date.now() < deadline) {
|
|
920
|
-
await wait(interval * 1e3);
|
|
921
|
-
const result = await exchange(apiUrl, start.deviceCode);
|
|
922
|
-
switch (result.status) {
|
|
923
|
-
case "ok":
|
|
924
|
-
await saveToken(apiUrl, result.accessToken);
|
|
925
|
-
log("Logged in.");
|
|
926
|
-
return whoami(apiUrl, result.accessToken);
|
|
927
|
-
case "authorization_pending":
|
|
928
|
-
continue;
|
|
929
|
-
case "slow_down":
|
|
930
|
-
interval = result.interval ?? interval + 5;
|
|
931
|
-
continue;
|
|
932
|
-
case "access_denied":
|
|
933
|
-
throw new CliError("Login was denied in the browser.", EXIT.error);
|
|
934
|
-
case "expired_token":
|
|
935
|
-
throw new CliError(
|
|
936
|
-
"The login code expired. Run `login` again.",
|
|
937
|
-
EXIT.error
|
|
938
|
-
);
|
|
939
324
|
}
|
|
325
|
+
return { spec: spec2, rest };
|
|
940
326
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
async function startDeviceLogin(apiUrl) {
|
|
944
|
-
return unwrap(
|
|
945
|
-
await request(apiUrl, "/api/cli/auth/device", {
|
|
946
|
-
method: "POST",
|
|
947
|
-
body: { label: deviceLabel(), platform: process.platform }
|
|
948
|
-
})
|
|
327
|
+
const spec = COMMANDS.find(
|
|
328
|
+
(candidate) => candidate.group === null && candidate.name === word
|
|
949
329
|
);
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
});
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
return { status: "ok", accessToken };
|
|
959
|
-
}
|
|
960
|
-
switch (poll.body.error) {
|
|
961
|
-
case "authorization_pending":
|
|
962
|
-
case "access_denied":
|
|
963
|
-
case "expired_token":
|
|
964
|
-
return { status: poll.body.error };
|
|
965
|
-
case "slow_down":
|
|
966
|
-
return {
|
|
967
|
-
status: "slow_down",
|
|
968
|
-
interval: typeof poll.body.interval === "number" ? poll.body.interval : void 0
|
|
969
|
-
};
|
|
970
|
-
default:
|
|
971
|
-
unwrap(poll);
|
|
972
|
-
throw new CliError(`Unexpected login response ${poll.status}`, EXIT.error);
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
function loginRequired(apiUrl, pending, { rejected }) {
|
|
976
|
-
return new CliError(
|
|
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.`,
|
|
978
|
-
EXIT.unauthenticated,
|
|
979
|
-
{
|
|
980
|
-
loginUrl: pending.loginUrl,
|
|
981
|
-
userCode: pending.userCode,
|
|
982
|
-
expiresInSeconds: Math.max(
|
|
983
|
-
0,
|
|
984
|
-
Math.round((Date.parse(pending.expiresAt) - Date.now()) / 1e3)
|
|
985
|
-
)
|
|
986
|
-
}
|
|
330
|
+
if (spec) return { spec, rest: afterWord };
|
|
331
|
+
const renamed = RENAMED.get(word ?? "");
|
|
332
|
+
const meant = [
|
|
333
|
+
...COMMANDS.filter((candidate) => candidate.group !== null && candidate.name === word),
|
|
334
|
+
...renamed ? [renamed] : []
|
|
335
|
+
].map((candidate) => `\`${commandInvocation(candidate)}\``);
|
|
336
|
+
throw usageError(
|
|
337
|
+
meant.length ? `Unknown command "${word}". Did you mean ${meant.join(" or ")}?` : `Unknown command "${word}". Run \`fortrip --help\`.`
|
|
987
338
|
);
|
|
988
339
|
}
|
|
989
|
-
async function logout(apiUrl) {
|
|
990
|
-
const token = await loadStoredToken(apiUrl);
|
|
991
|
-
const recovered = await loadRecoveredTokens(apiUrl);
|
|
992
|
-
if (!token && !recovered.length) {
|
|
993
|
-
throw new CliError(`Not logged in to ${apiUrl}.`, EXIT.unauthenticated);
|
|
994
|
-
}
|
|
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]);
|
|
1008
|
-
return { loggedOut: true, apiUrl };
|
|
1009
|
-
}
|
|
1010
|
-
async function whoami(apiUrl, token) {
|
|
1011
|
-
const path = "/api/cli/auth/whoami";
|
|
1012
|
-
const data = unwrap(
|
|
1013
|
-
token ? await request(apiUrl, path, { token }) : await authorizedRequest(apiUrl, path)
|
|
1014
|
-
);
|
|
1015
|
-
return { ...data, apiUrl };
|
|
1016
|
-
}
|
|
1017
|
-
function deviceLabel() {
|
|
1018
|
-
return hostname2().replace(/[^\w .@-]/g, "").slice(0, 64);
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// src/guide.ts
|
|
1022
|
-
import { readFile as readFile2 } from "fs/promises";
|
|
1023
|
-
var GUIDES = {
|
|
1024
|
-
main: "../AGENT_GUIDE.md",
|
|
1025
|
-
planning: "../guides/planning.md"
|
|
1026
|
-
};
|
|
1027
|
-
async function readGuide(topic = "main") {
|
|
1028
|
-
const path = GUIDES[topic];
|
|
1029
|
-
if (!path) throw usageError("Usage: fortrip skill [planning]");
|
|
1030
|
-
const guide = await readFile2(new URL(path, import.meta.url), "utf8");
|
|
1031
|
-
return guide.replaceAll("fortrip@latest", `fortrip@${VERSION}`);
|
|
1032
|
-
}
|
|
1033
|
-
|
|
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
|
-
};
|
|
1125
|
-
var HELP = `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
|
|
1126
|
-
|
|
1127
|
-
Usage: fortrip <command> [options]
|
|
1128
|
-
fortrip <command> --help
|
|
1129
|
-
|
|
1130
|
-
Commands:
|
|
1131
|
-
login [--no-browser] Sign in through the browser (device code)
|
|
1132
|
-
logout Revoke and forget the saved token
|
|
1133
|
-
whoami Show the signed-in account
|
|
1134
|
-
search Find hotels with indicative prices
|
|
1135
|
-
hotel <hotelId> Static hotel detail [--with-rooms]
|
|
1136
|
-
rates Live rooms and rates for up to 15 hotels
|
|
1137
|
-
book Create an order and get a payment link
|
|
1138
|
-
skill [planning] Print the guide for AI agents (Markdown)
|
|
1139
|
-
|
|
1140
|
-
search:
|
|
1141
|
-
${FLAGS.search}
|
|
1142
|
-
rates:
|
|
1143
|
-
${FLAGS.rates}
|
|
1144
|
-
book:
|
|
1145
|
-
${FLAGS.book}
|
|
1146
|
-
|
|
1147
|
-
search, rates and book also take --body '<json>' (or --body @file.json) with
|
|
1148
|
-
the raw request body; flags override its fields.
|
|
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
|
-
|
|
1158
|
-
Output is JSON on stdout; errors are JSON on stderr.
|
|
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.
|
|
1161
|
-
|
|
1162
|
-
Environment:
|
|
1163
|
-
FORTRIP_TOKEN Use this token instead of the saved login (CI, sandboxes)
|
|
1164
|
-
FORTRIP_API_URL API base URL (default https://fortrip.ai)
|
|
1165
|
-
FORTRIP_CURRENCY Default --currency for search and rates
|
|
1166
|
-
`;
|
|
1167
|
-
var LINK_KEYS = ["paymentUrl", "statusUrl", "orderUrl"];
|
|
1168
340
|
async function run(argv) {
|
|
1169
|
-
const [command
|
|
1170
|
-
if (!command || command === "help" || command
|
|
1171
|
-
process.stdout.write(
|
|
1172
|
-
return void 0;
|
|
1173
|
-
}
|
|
1174
|
-
const usage = Object.hasOwn(USAGE, command) ? USAGE[command] : void 0;
|
|
1175
|
-
if (usage && wantsHelp(rest)) {
|
|
1176
|
-
process.stdout.write(usage);
|
|
341
|
+
const [command] = argv;
|
|
342
|
+
if (!command || command === "help" || HELP_FLAGS.includes(command)) {
|
|
343
|
+
process.stdout.write(overallHelp());
|
|
1177
344
|
return void 0;
|
|
1178
345
|
}
|
|
1179
|
-
if (command
|
|
346
|
+
if (VERSION_FLAGS.includes(command)) {
|
|
1180
347
|
process.stdout.write(`${VERSION}
|
|
1181
348
|
`);
|
|
1182
349
|
return void 0;
|
|
1183
350
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
process.stdout.write(
|
|
351
|
+
const found = findCommand(argv);
|
|
352
|
+
if ("groupHelp" in found) {
|
|
353
|
+
process.stdout.write(groupHelp(found.groupHelp));
|
|
354
|
+
return void 0;
|
|
355
|
+
}
|
|
356
|
+
const { spec, rest } = found;
|
|
357
|
+
if (wantsHelp(rest)) {
|
|
358
|
+
process.stdout.write(commandHelp(spec));
|
|
359
|
+
return void 0;
|
|
360
|
+
}
|
|
361
|
+
if (spec.group === null && spec.name === "guide") {
|
|
362
|
+
const { positionals: positionals2 } = parseOptions(spec, rest);
|
|
363
|
+
const topic = positionals2.join(" ");
|
|
364
|
+
if (topic !== "" && topic !== "planning") {
|
|
365
|
+
throw usageError("Usage: fortrip guide [planning]");
|
|
366
|
+
}
|
|
367
|
+
process.stdout.write(readGuide(topic || "main"));
|
|
1187
368
|
return void 0;
|
|
1188
369
|
}
|
|
1189
370
|
const apiUrl = resolveApiUrl();
|
|
1190
371
|
const log = (line) => process.stderr.write(`${line}
|
|
1191
372
|
`);
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
373
|
+
if (spec.group === null) {
|
|
374
|
+
switch (spec.name) {
|
|
375
|
+
case "login": {
|
|
376
|
+
const { values } = parseOptions(spec, rest);
|
|
377
|
+
if (process.env.FORTRIP_TOKEN) {
|
|
378
|
+
log("Note: FORTRIP_TOKEN is set and takes precedence over this login.");
|
|
379
|
+
}
|
|
380
|
+
return login({ apiUrl, openBrowser: !values["no-browser"], log });
|
|
1197
381
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
return call(apiUrl, "POST", "/api/huizhi/search", searchBody(values));
|
|
1209
|
-
}
|
|
1210
|
-
case "hotel": {
|
|
1211
|
-
const { values, positionals } = parse(rest, HOTEL_OPTIONS);
|
|
1212
|
-
return call(apiUrl, "GET", hotelPath(positionals, values));
|
|
1213
|
-
}
|
|
1214
|
-
case "rates": {
|
|
1215
|
-
const { values } = parse(rest, RATES_OPTIONS);
|
|
1216
|
-
return call(apiUrl, "POST", "/api/huizhi/rates", ratesBody(values));
|
|
1217
|
-
}
|
|
1218
|
-
case "book": {
|
|
1219
|
-
const { values } = parse(rest, BOOK_OPTIONS);
|
|
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
|
-
);
|
|
382
|
+
case "logout":
|
|
383
|
+
parseOptions(spec, rest);
|
|
384
|
+
return logout(apiUrl);
|
|
385
|
+
case "whoami":
|
|
386
|
+
parseOptions(spec, rest);
|
|
387
|
+
return whoami(apiUrl);
|
|
388
|
+
case "mcp": {
|
|
389
|
+
parseOptions(spec, rest);
|
|
390
|
+
const { runMcpBridge } = await import("./mcp-bridge-LPQBHWLD.js");
|
|
391
|
+
return runMcpBridge(apiUrl);
|
|
1235
392
|
}
|
|
1236
|
-
return paymentPage(absolutize(apiUrl, data, LINK_KEYS));
|
|
1237
393
|
}
|
|
1238
|
-
default:
|
|
1239
|
-
throw usageError(`Unknown command "${command}". Run \`fortrip --help\`.`);
|
|
1240
394
|
}
|
|
395
|
+
const { body, path } = parseCommand(spec, rest, process.env);
|
|
396
|
+
return call(apiUrl, body ? "POST" : "GET", path, body);
|
|
1241
397
|
}
|
|
1242
398
|
async function call(apiUrl, method, path, body) {
|
|
1243
399
|
return unwrap(await authorizedRequest(apiUrl, path, { method, body }));
|
|
@@ -1245,31 +401,10 @@ async function call(apiUrl, method, path, body) {
|
|
|
1245
401
|
function wantsHelp(args) {
|
|
1246
402
|
for (const arg of args) {
|
|
1247
403
|
if (arg === "--") return false;
|
|
1248
|
-
if (arg
|
|
404
|
+
if (HELP_FLAGS.includes(arg)) return true;
|
|
1249
405
|
}
|
|
1250
406
|
return false;
|
|
1251
407
|
}
|
|
1252
|
-
function absolutize(apiUrl, data, keys) {
|
|
1253
|
-
const out = { ...data };
|
|
1254
|
-
for (const key of keys) {
|
|
1255
|
-
const value = out[key];
|
|
1256
|
-
if (typeof value === "string" && value.startsWith("/")) {
|
|
1257
|
-
out[key] = `${apiUrl}${value}`;
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
return out;
|
|
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
|
-
}
|
|
1273
408
|
async function main() {
|
|
1274
409
|
try {
|
|
1275
410
|
const result = await run(process.argv.slice(2));
|