fortrip 0.0.0-stage → 0.1.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/README.md +65 -2
- package/dist/cli.js +575 -0
- package/package.json +29 -4
- package/skills/fortrip-hotels/SKILL.md +124 -0
package/README.md
CHANGED
|
@@ -1,3 +1,66 @@
|
|
|
1
|
-
#
|
|
1
|
+
# fortrip-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Search, price and book Fortrip hotels from the terminal or an AI agent. The
|
|
4
|
+
The npm package and the command are both `fortrip`.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx fortrip login # sign in through the browser
|
|
8
|
+
npx fortrip search --query Bangkok --check-in 2026-11-10 --check-out 2026-11-12
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node.js 22 or newer. No runtime dependencies.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
| Command | What it does |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `login [--no-browser]` | Device-code login: prints a code, opens `/cli/authorize`, waits for you to approve |
|
|
18
|
+
| `logout` | Revokes the saved token on the server and deletes it locally |
|
|
19
|
+
| `whoami` | The signed-in account |
|
|
20
|
+
| `search` | Hotels with indicative prices |
|
|
21
|
+
| `hotel <hotelId> [--with-rooms]` | Static hotel detail |
|
|
22
|
+
| `rates` | Live rooms and rates |
|
|
23
|
+
| `book` | Order + Stripe payment link |
|
|
24
|
+
|
|
25
|
+
Run `fortrip --help` for every flag. `search`, `rates` and `book` also accept
|
|
26
|
+
`--body '<json>'` or `--body @file.json` with the raw request body; flags
|
|
27
|
+
override its fields.
|
|
28
|
+
|
|
29
|
+
Output is JSON on stdout. Errors are JSON on stderr, with fixed exit codes:
|
|
30
|
+
|
|
31
|
+
| Code | Meaning |
|
|
32
|
+
|---|---|
|
|
33
|
+
| 0 | Success |
|
|
34
|
+
| 1 | Other error |
|
|
35
|
+
| 2 | Invalid arguments (local or HTTP 400) |
|
|
36
|
+
| 3 | Not logged in / token revoked (HTTP 401) |
|
|
37
|
+
| 4 | Rate limited (HTTP 429; `retryAfterSeconds` on stderr) |
|
|
38
|
+
|
|
39
|
+
## Credentials
|
|
40
|
+
|
|
41
|
+
- `login` saves the token to `~/.config/fortrip/credentials.json`
|
|
42
|
+
(`$XDG_CONFIG_HOME` is honoured), mode `0600`. Tokens are stored per API URL,
|
|
43
|
+
so a token is only ever sent to the server that issued it.
|
|
44
|
+
- `FORTRIP_TOKEN` overrides the saved login (CI, remote sandboxes).
|
|
45
|
+
- `FORTRIP_API_URL` overrides the base URL (default `https://fortrip.ai`).
|
|
46
|
+
Plain `http` is only accepted for `localhost`.
|
|
47
|
+
|
|
48
|
+
Tokens don't expire; `logout` revokes them.
|
|
49
|
+
|
|
50
|
+
## Agent skill
|
|
51
|
+
|
|
52
|
+
[`skills/fortrip-hotels/SKILL.md`](skills/fortrip-hotels/SKILL.md) tells an AI
|
|
53
|
+
agent when to use the CLI, the call order, and what to show the user before
|
|
54
|
+
handing over a payment link.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pnpm install
|
|
60
|
+
pnpm dev search --query Bangkok ... # run from source
|
|
61
|
+
pnpm test
|
|
62
|
+
pnpm typecheck
|
|
63
|
+
pnpm build # dist/cli.js
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Point at a local Fortrip app with `FORTRIP_API_URL=http://localhost:3000`.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
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;
|
|
16
|
+
}
|
|
17
|
+
exitCode;
|
|
18
|
+
details;
|
|
19
|
+
};
|
|
20
|
+
function usageError(message) {
|
|
21
|
+
return new CliError(message, EXIT.usage);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/version.ts
|
|
25
|
+
var VERSION = "0.1.0";
|
|
26
|
+
|
|
27
|
+
// src/api.ts
|
|
28
|
+
var LOGIN_HINT = "Run `npx fortrip login` first.";
|
|
29
|
+
var REQUEST_TIMEOUT_MS = 12e4;
|
|
30
|
+
async function request(apiUrl, path, {
|
|
31
|
+
method = "GET",
|
|
32
|
+
token,
|
|
33
|
+
body
|
|
34
|
+
} = {}) {
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await fetch(`${apiUrl}${path}`, {
|
|
38
|
+
method,
|
|
39
|
+
headers: {
|
|
40
|
+
accept: "application/json",
|
|
41
|
+
"user-agent": `fortrip-cli/${VERSION}`,
|
|
42
|
+
...body !== void 0 ? { "content-type": "application/json" } : {},
|
|
43
|
+
...token ? { authorization: `Bearer ${token}` } : {}
|
|
44
|
+
},
|
|
45
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
46
|
+
// A redirect to another origin would drop the Authorization header and
|
|
47
|
+
// look like a logout; surface it instead.
|
|
48
|
+
redirect: "manual",
|
|
49
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
50
|
+
});
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new CliError(
|
|
53
|
+
`Could not reach ${apiUrl}: ${error.message}`,
|
|
54
|
+
EXIT.error
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
let parsed = null;
|
|
59
|
+
try {
|
|
60
|
+
parsed = text ? JSON.parse(text) : {};
|
|
61
|
+
} catch {
|
|
62
|
+
parsed = null;
|
|
63
|
+
}
|
|
64
|
+
const isObject = parsed !== null && typeof parsed === "object";
|
|
65
|
+
return {
|
|
66
|
+
status: response.status,
|
|
67
|
+
body: isObject ? parsed : { error: `Unexpected ${response.status} response`, raw: text.slice(0, 200) },
|
|
68
|
+
headers: response.headers
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function unwrap(response) {
|
|
72
|
+
const { status, body, headers } = response;
|
|
73
|
+
if (status >= 200 && status < 300 && body.success === true) return body.data;
|
|
74
|
+
const { success: _success, error, message, ...rest } = body;
|
|
75
|
+
const text = typeof error === "string" ? error : typeof message === "string" ? message : `Request failed with status ${status}`;
|
|
76
|
+
const details = { status, ...rest };
|
|
77
|
+
if (status === 401) {
|
|
78
|
+
throw new CliError(`${text}. ${LOGIN_HINT}`, EXIT.unauthenticated, details);
|
|
79
|
+
}
|
|
80
|
+
if (status === 429) {
|
|
81
|
+
const retryAfter = Number(headers.get("retry-after"));
|
|
82
|
+
throw new CliError(text, EXIT.rateLimited, {
|
|
83
|
+
...details,
|
|
84
|
+
...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (status === 400) throw new CliError(text, EXIT.usage, details);
|
|
88
|
+
if (status >= 300 && status < 400) {
|
|
89
|
+
throw new CliError(
|
|
90
|
+
`Unexpected redirect to ${headers.get("location") ?? "?"}; check FORTRIP_API_URL`,
|
|
91
|
+
EXIT.error,
|
|
92
|
+
details
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
throw new CliError(text, EXIT.error, details);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/args.ts
|
|
99
|
+
import { readFileSync } from "fs";
|
|
100
|
+
import { parseArgs } from "util";
|
|
101
|
+
var BODY_OPTION = { body: { type: "string" } };
|
|
102
|
+
function parse(argv, options) {
|
|
103
|
+
try {
|
|
104
|
+
return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw usageError(error.message);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function baseBody(values) {
|
|
110
|
+
const raw = values.body;
|
|
111
|
+
if (typeof raw !== "string") return {};
|
|
112
|
+
let text = raw;
|
|
113
|
+
if (raw.startsWith("@")) {
|
|
114
|
+
try {
|
|
115
|
+
text = readFileSync(raw.slice(1), "utf8");
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw usageError(`Cannot read --body file: ${error.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(text);
|
|
122
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
123
|
+
return parsed;
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
throw usageError("--body must be a JSON object");
|
|
128
|
+
}
|
|
129
|
+
function num(flag, value) {
|
|
130
|
+
if (value === void 0) return void 0;
|
|
131
|
+
const parsed = Number(value);
|
|
132
|
+
if (value.trim() === "" || !Number.isFinite(parsed)) {
|
|
133
|
+
throw usageError(`--${flag} must be a number, got "${value}"`);
|
|
134
|
+
}
|
|
135
|
+
return parsed;
|
|
136
|
+
}
|
|
137
|
+
function nums(flag, values) {
|
|
138
|
+
if (!values?.length) return void 0;
|
|
139
|
+
return values.flatMap((value) => value.split(",")).map((value) => num(flag, value.trim()));
|
|
140
|
+
}
|
|
141
|
+
function str(value) {
|
|
142
|
+
return typeof value === "string" ? value : void 0;
|
|
143
|
+
}
|
|
144
|
+
function list(value) {
|
|
145
|
+
return Array.isArray(value) && value.length ? value : void 0;
|
|
146
|
+
}
|
|
147
|
+
function merge(base, overrides) {
|
|
148
|
+
const body = { ...base };
|
|
149
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
150
|
+
if (value !== void 0) body[key] = value;
|
|
151
|
+
}
|
|
152
|
+
return body;
|
|
153
|
+
}
|
|
154
|
+
var SEARCH_OPTIONS = {
|
|
155
|
+
...BODY_OPTION,
|
|
156
|
+
query: { type: "string", short: "q" },
|
|
157
|
+
"city-code": { type: "string" },
|
|
158
|
+
near: { type: "string" },
|
|
159
|
+
"check-in": { type: "string" },
|
|
160
|
+
"check-out": { type: "string" },
|
|
161
|
+
"star-min": { type: "string" },
|
|
162
|
+
"star-max": { type: "string" },
|
|
163
|
+
facility: { type: "string", multiple: true },
|
|
164
|
+
"price-min": { type: "string" },
|
|
165
|
+
"price-max": { type: "string" },
|
|
166
|
+
sort: { type: "string" },
|
|
167
|
+
page: { type: "string" },
|
|
168
|
+
"page-size": { type: "string" }
|
|
169
|
+
};
|
|
170
|
+
function searchBody(values) {
|
|
171
|
+
let near;
|
|
172
|
+
const nearRaw = str(values.near);
|
|
173
|
+
if (nearRaw !== void 0) {
|
|
174
|
+
const [lat, lng, radiusKm] = nearRaw.split(",").map((part) => part.trim());
|
|
175
|
+
if (!lat || !lng || !radiusKm) {
|
|
176
|
+
throw usageError("--near must be lat,lng,radiusKm (e.g. 13.75,100.5,5)");
|
|
177
|
+
}
|
|
178
|
+
near = {
|
|
179
|
+
lat: num("near", lat),
|
|
180
|
+
lng: num("near", lng),
|
|
181
|
+
radiusKm: num("near", radiusKm)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return merge(baseBody(values), {
|
|
185
|
+
query: str(values.query),
|
|
186
|
+
cityCode: num("city-code", str(values["city-code"])),
|
|
187
|
+
near,
|
|
188
|
+
checkIn: str(values["check-in"]),
|
|
189
|
+
checkOut: str(values["check-out"]),
|
|
190
|
+
starMin: num("star-min", str(values["star-min"])),
|
|
191
|
+
starMax: num("star-max", str(values["star-max"])),
|
|
192
|
+
facilities: list(values.facility),
|
|
193
|
+
priceMin: num("price-min", str(values["price-min"])),
|
|
194
|
+
priceMax: num("price-max", str(values["price-max"])),
|
|
195
|
+
sortBy: str(values.sort),
|
|
196
|
+
page: num("page", str(values.page)),
|
|
197
|
+
pageSize: num("page-size", str(values["page-size"]))
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
var HOTEL_OPTIONS = {
|
|
201
|
+
"with-rooms": { type: "boolean" }
|
|
202
|
+
};
|
|
203
|
+
function hotelPath(positionals, values) {
|
|
204
|
+
const [id, ...extra] = positionals;
|
|
205
|
+
if (!id || extra.length || !/^\d+$/.test(id)) {
|
|
206
|
+
throw usageError("Usage: fortrip hotel <hotelId> [--with-rooms]");
|
|
207
|
+
}
|
|
208
|
+
return `/api/huizhi/hotels/${id}${values["with-rooms"] ? "?include=rooms" : ""}`;
|
|
209
|
+
}
|
|
210
|
+
var STAY_OPTIONS = {
|
|
211
|
+
"check-in": { type: "string" },
|
|
212
|
+
"check-out": { type: "string" },
|
|
213
|
+
rooms: { type: "string" },
|
|
214
|
+
adults: { type: "string" },
|
|
215
|
+
"child-age": { type: "string", multiple: true }
|
|
216
|
+
};
|
|
217
|
+
function stayFields(values, base) {
|
|
218
|
+
return {
|
|
219
|
+
checkIn: str(values["check-in"]),
|
|
220
|
+
checkOut: str(values["check-out"]),
|
|
221
|
+
rooms: num("rooms", str(values.rooms)) ?? (base.rooms === void 0 ? 1 : void 0),
|
|
222
|
+
adults: num("adults", str(values.adults)) ?? (base.adults === void 0 ? 2 : void 0),
|
|
223
|
+
childrenAges: nums("child-age", list(values["child-age"]))
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
var RATES_OPTIONS = {
|
|
227
|
+
...BODY_OPTION,
|
|
228
|
+
...STAY_OPTIONS,
|
|
229
|
+
hotel: { type: "string", multiple: true },
|
|
230
|
+
nationality: { type: "string" },
|
|
231
|
+
board: { type: "string", multiple: true },
|
|
232
|
+
"free-cancellation": { type: "boolean" },
|
|
233
|
+
"include-unbookable": { type: "boolean" },
|
|
234
|
+
"max-rooms": { type: "string" }
|
|
235
|
+
};
|
|
236
|
+
function ratesBody(values) {
|
|
237
|
+
const base = baseBody(values);
|
|
238
|
+
return merge(base, {
|
|
239
|
+
hotelIds: nums("hotel", list(values.hotel)),
|
|
240
|
+
...stayFields(values, base),
|
|
241
|
+
nationality: str(values.nationality)?.toUpperCase(),
|
|
242
|
+
boardCodes: list(values.board)?.flatMap(
|
|
243
|
+
(code) => code.split(",").map((part) => part.trim().toUpperCase())
|
|
244
|
+
),
|
|
245
|
+
freeCancellation: values["free-cancellation"] ? true : void 0,
|
|
246
|
+
bookableOnly: values["include-unbookable"] ? false : void 0,
|
|
247
|
+
maxRoomsPerHotel: num("max-rooms", str(values["max-rooms"]))
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
var BOOK_OPTIONS = {
|
|
251
|
+
...BODY_OPTION,
|
|
252
|
+
...STAY_OPTIONS,
|
|
253
|
+
hotel: { type: "string" },
|
|
254
|
+
"room-id": { type: "string" },
|
|
255
|
+
"rate-plan": { type: "string" },
|
|
256
|
+
guest: { type: "string", multiple: true },
|
|
257
|
+
"contact-name": { type: "string" },
|
|
258
|
+
"contact-phone": { type: "string" },
|
|
259
|
+
"contact-email": { type: "string" },
|
|
260
|
+
"special-requests": { type: "string" }
|
|
261
|
+
};
|
|
262
|
+
function parseGuest(value) {
|
|
263
|
+
const match = value.trim().match(/^(.+?)(?::(\d+))?$/);
|
|
264
|
+
const words = match?.[1]?.trim().split(/\s+/) ?? [];
|
|
265
|
+
if (words.length < 2) {
|
|
266
|
+
throw usageError(
|
|
267
|
+
`--guest must be "First Last" or "First Last:roomNo", got "${value}"`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
firstName: words.slice(0, -1).join(" "),
|
|
272
|
+
lastName: words.at(-1),
|
|
273
|
+
roomNo: match?.[2] ? Number(match[2]) : 1
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function bookBody(values) {
|
|
277
|
+
const base = baseBody(values);
|
|
278
|
+
return merge(base, {
|
|
279
|
+
hotelId: num("hotel", str(values.hotel)),
|
|
280
|
+
roomId: str(values["room-id"]),
|
|
281
|
+
ratePlanCode: str(values["rate-plan"]),
|
|
282
|
+
...stayFields(values, base),
|
|
283
|
+
guests: list(values.guest)?.map(parseGuest),
|
|
284
|
+
contactName: str(values["contact-name"]),
|
|
285
|
+
contactPhone: str(values["contact-phone"]),
|
|
286
|
+
contactEmail: str(values["contact-email"]),
|
|
287
|
+
specialRequests: str(values["special-requests"])
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/auth.ts
|
|
292
|
+
import { hostname } from "os";
|
|
293
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
294
|
+
|
|
295
|
+
// src/browser.ts
|
|
296
|
+
import { spawn } from "child_process";
|
|
297
|
+
function openBrowser(url) {
|
|
298
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
|
|
299
|
+
try {
|
|
300
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
301
|
+
child.on("error", () => {
|
|
302
|
+
});
|
|
303
|
+
child.unref();
|
|
304
|
+
} catch {
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/credentials.ts
|
|
309
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
310
|
+
import { homedir } from "os";
|
|
311
|
+
import { dirname, join } from "path";
|
|
312
|
+
var DEFAULT_API_URL = "https://fortrip.ai";
|
|
313
|
+
function credentialsPath(env = process.env) {
|
|
314
|
+
const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
315
|
+
return join(base, "fortrip", "credentials.json");
|
|
316
|
+
}
|
|
317
|
+
function resolveApiUrl(env = process.env) {
|
|
318
|
+
const raw = env.FORTRIP_API_URL?.trim() || DEFAULT_API_URL;
|
|
319
|
+
let url;
|
|
320
|
+
try {
|
|
321
|
+
url = new URL(raw);
|
|
322
|
+
} catch {
|
|
323
|
+
throw usageError(`FORTRIP_API_URL is not a valid URL: ${raw}`);
|
|
324
|
+
}
|
|
325
|
+
const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
326
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocal)) {
|
|
327
|
+
throw usageError("FORTRIP_API_URL must use https (http only for localhost)");
|
|
328
|
+
}
|
|
329
|
+
return url.origin;
|
|
330
|
+
}
|
|
331
|
+
async function readCredentials(path) {
|
|
332
|
+
try {
|
|
333
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
334
|
+
return { tokens: parsed?.tokens ?? {} };
|
|
335
|
+
} catch {
|
|
336
|
+
return { tokens: {} };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function loadStoredToken(apiUrl, env = process.env) {
|
|
340
|
+
const file = await readCredentials(credentialsPath(env));
|
|
341
|
+
return file.tokens[apiUrl]?.accessToken ?? null;
|
|
342
|
+
}
|
|
343
|
+
async function resolveToken(apiUrl, env = process.env) {
|
|
344
|
+
const fromEnv = env.FORTRIP_TOKEN?.trim();
|
|
345
|
+
if (fromEnv) return { token: fromEnv, source: "env" };
|
|
346
|
+
const stored = await loadStoredToken(apiUrl, env);
|
|
347
|
+
return stored ? { token: stored, source: "file" } : null;
|
|
348
|
+
}
|
|
349
|
+
async function saveToken(apiUrl, accessToken, env = process.env) {
|
|
350
|
+
const path = credentialsPath(env);
|
|
351
|
+
const file = await readCredentials(path);
|
|
352
|
+
file.tokens[apiUrl] = { accessToken, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
353
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
354
|
+
await writeFile(path, `${JSON.stringify(file, null, 2)}
|
|
355
|
+
`, { mode: 384 });
|
|
356
|
+
await chmod(path, 384);
|
|
357
|
+
}
|
|
358
|
+
async function deleteToken(apiUrl, env = process.env) {
|
|
359
|
+
const path = credentialsPath(env);
|
|
360
|
+
const file = await readCredentials(path);
|
|
361
|
+
delete file.tokens[apiUrl];
|
|
362
|
+
if (Object.keys(file.tokens).length === 0) {
|
|
363
|
+
await rm(path, { force: true });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
await writeFile(path, `${JSON.stringify(file, null, 2)}
|
|
367
|
+
`, { mode: 384 });
|
|
368
|
+
await chmod(path, 384);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/auth.ts
|
|
372
|
+
async function requireToken(apiUrl) {
|
|
373
|
+
const resolved = await resolveToken(apiUrl);
|
|
374
|
+
if (!resolved) {
|
|
375
|
+
throw new CliError(`Not logged in to ${apiUrl}. ${LOGIN_HINT}`, EXIT.unauthenticated);
|
|
376
|
+
}
|
|
377
|
+
return resolved.token;
|
|
378
|
+
}
|
|
379
|
+
async function login({
|
|
380
|
+
apiUrl,
|
|
381
|
+
openBrowser: shouldOpen,
|
|
382
|
+
log,
|
|
383
|
+
wait = sleep
|
|
384
|
+
}) {
|
|
385
|
+
const start = unwrap(
|
|
386
|
+
await request(apiUrl, "/api/cli/auth/device", {
|
|
387
|
+
method: "POST",
|
|
388
|
+
body: { label: deviceLabel() }
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
log(`Confirm this code in your browser: ${start.userCode}`);
|
|
392
|
+
log(`If the browser doesn't open, visit: ${start.verificationUriComplete}`);
|
|
393
|
+
if (shouldOpen) openBrowser(start.verificationUriComplete);
|
|
394
|
+
log("Waiting for approval\u2026");
|
|
395
|
+
let interval = start.interval;
|
|
396
|
+
const deadline = Date.now() + start.expiresIn * 1e3;
|
|
397
|
+
while (Date.now() < deadline) {
|
|
398
|
+
await wait(interval * 1e3);
|
|
399
|
+
const poll = await request(apiUrl, "/api/cli/auth/token", {
|
|
400
|
+
method: "POST",
|
|
401
|
+
body: { deviceCode: start.deviceCode }
|
|
402
|
+
});
|
|
403
|
+
if (poll.status === 200 && poll.body.success === true) {
|
|
404
|
+
const { accessToken } = poll.body.data;
|
|
405
|
+
await saveToken(apiUrl, accessToken);
|
|
406
|
+
log("Logged in.");
|
|
407
|
+
return whoami(apiUrl, accessToken);
|
|
408
|
+
}
|
|
409
|
+
switch (poll.body.error) {
|
|
410
|
+
case "authorization_pending":
|
|
411
|
+
continue;
|
|
412
|
+
case "slow_down":
|
|
413
|
+
interval = typeof poll.body.interval === "number" ? poll.body.interval : interval + 5;
|
|
414
|
+
continue;
|
|
415
|
+
case "access_denied":
|
|
416
|
+
throw new CliError("Login was denied in the browser.", EXIT.error);
|
|
417
|
+
case "expired_token":
|
|
418
|
+
throw new CliError(
|
|
419
|
+
"The login code expired. Run `login` again.",
|
|
420
|
+
EXIT.error
|
|
421
|
+
);
|
|
422
|
+
default:
|
|
423
|
+
unwrap(poll);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
throw new CliError("The login code expired. Run `login` again.", EXIT.error);
|
|
427
|
+
}
|
|
428
|
+
async function logout(apiUrl) {
|
|
429
|
+
const token = await loadStoredToken(apiUrl);
|
|
430
|
+
if (!token) {
|
|
431
|
+
throw new CliError(`Not logged in to ${apiUrl}.`, EXIT.unauthenticated);
|
|
432
|
+
}
|
|
433
|
+
const response = await request(apiUrl, "/api/cli/auth/revoke", {
|
|
434
|
+
method: "POST",
|
|
435
|
+
token
|
|
436
|
+
});
|
|
437
|
+
if (response.status !== 401) unwrap(response);
|
|
438
|
+
await deleteToken(apiUrl);
|
|
439
|
+
return { loggedOut: true, apiUrl };
|
|
440
|
+
}
|
|
441
|
+
async function whoami(apiUrl, token) {
|
|
442
|
+
const resolved = token ?? await requireToken(apiUrl);
|
|
443
|
+
const data = unwrap(
|
|
444
|
+
await request(apiUrl, "/api/cli/auth/whoami", { token: resolved })
|
|
445
|
+
);
|
|
446
|
+
return { ...data, apiUrl };
|
|
447
|
+
}
|
|
448
|
+
function deviceLabel() {
|
|
449
|
+
return hostname().replace(/[^\w .@-]/g, "").slice(0, 64);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/cli.ts
|
|
453
|
+
var HELP = `fortrip ${VERSION} \u2014 search, price and book Fortrip hotels
|
|
454
|
+
|
|
455
|
+
Usage: fortrip <command> [options]
|
|
456
|
+
|
|
457
|
+
Commands:
|
|
458
|
+
login [--no-browser] Sign in through the browser (device code)
|
|
459
|
+
logout Revoke and forget the saved token
|
|
460
|
+
whoami Show the signed-in account
|
|
461
|
+
search Find hotels with indicative prices (CNY)
|
|
462
|
+
hotel <hotelId> Static hotel detail [--with-rooms]
|
|
463
|
+
rates Live rooms and rates (CNY) for up to 15 hotels
|
|
464
|
+
book Create an order and get a payment link
|
|
465
|
+
|
|
466
|
+
search: --query <text> | --city-code <n> | --near <lat,lng,km>
|
|
467
|
+
--check-in YYYY-MM-DD --check-out YYYY-MM-DD
|
|
468
|
+
[--star-min n] [--star-max n] [--facility <name>]... [--price-min n]
|
|
469
|
+
[--price-max n] [--sort recommended|price-asc|price-desc|star-desc|distance]
|
|
470
|
+
[--page n] [--page-size n]
|
|
471
|
+
rates: --hotel <id>... --check-in --check-out [--rooms 1] [--adults 2]
|
|
472
|
+
[--child-age n]... [--nationality CN] [--board BB]...
|
|
473
|
+
[--free-cancellation] [--include-unbookable] [--max-rooms n]
|
|
474
|
+
book: --hotel <id> --room-id <id> --rate-plan <code> --check-in --check-out
|
|
475
|
+
[--rooms 1] [--adults 2] [--child-age n]...
|
|
476
|
+
--guest "First Last[:roomNo]"... --contact-name <name>
|
|
477
|
+
--contact-phone <phone> [--contact-email <email>]
|
|
478
|
+
[--special-requests <english text>]
|
|
479
|
+
|
|
480
|
+
search, rates and book also take --body '<json>' (or --body @file.json) with
|
|
481
|
+
the raw request body; flags override its fields.
|
|
482
|
+
|
|
483
|
+
Output is JSON on stdout; errors are JSON on stderr.
|
|
484
|
+
Exit codes: 0 ok, 1 error, 2 invalid arguments, 3 not logged in, 4 rate limited.
|
|
485
|
+
|
|
486
|
+
Environment:
|
|
487
|
+
FORTRIP_TOKEN Use this token instead of the saved login (CI, sandboxes)
|
|
488
|
+
FORTRIP_API_URL API base URL (default https://fortrip.ai)
|
|
489
|
+
`;
|
|
490
|
+
async function run(argv) {
|
|
491
|
+
const [command, ...rest] = argv;
|
|
492
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
493
|
+
process.stdout.write(HELP);
|
|
494
|
+
return void 0;
|
|
495
|
+
}
|
|
496
|
+
if (command === "--version" || command === "-v") {
|
|
497
|
+
process.stdout.write(`${VERSION}
|
|
498
|
+
`);
|
|
499
|
+
return void 0;
|
|
500
|
+
}
|
|
501
|
+
const apiUrl = resolveApiUrl();
|
|
502
|
+
const log = (line) => process.stderr.write(`${line}
|
|
503
|
+
`);
|
|
504
|
+
switch (command) {
|
|
505
|
+
case "login": {
|
|
506
|
+
const { values } = parse(rest, { "no-browser": { type: "boolean" } });
|
|
507
|
+
if (process.env.FORTRIP_TOKEN) {
|
|
508
|
+
log("Note: FORTRIP_TOKEN is set and takes precedence over this login.");
|
|
509
|
+
}
|
|
510
|
+
return login({ apiUrl, openBrowser: !values["no-browser"], log });
|
|
511
|
+
}
|
|
512
|
+
case "logout":
|
|
513
|
+
parse(rest, {});
|
|
514
|
+
return logout(apiUrl);
|
|
515
|
+
case "whoami":
|
|
516
|
+
parse(rest, {});
|
|
517
|
+
return whoami(apiUrl);
|
|
518
|
+
case "search": {
|
|
519
|
+
const { values } = parse(rest, SEARCH_OPTIONS);
|
|
520
|
+
return call(apiUrl, "POST", "/api/huizhi/search", searchBody(values));
|
|
521
|
+
}
|
|
522
|
+
case "hotel": {
|
|
523
|
+
const { values, positionals } = parse(rest, HOTEL_OPTIONS);
|
|
524
|
+
return call(apiUrl, "GET", hotelPath(positionals, values));
|
|
525
|
+
}
|
|
526
|
+
case "rates": {
|
|
527
|
+
const { values } = parse(rest, RATES_OPTIONS);
|
|
528
|
+
return call(apiUrl, "POST", "/api/huizhi/rates", ratesBody(values));
|
|
529
|
+
}
|
|
530
|
+
case "book": {
|
|
531
|
+
const { values } = parse(rest, BOOK_OPTIONS);
|
|
532
|
+
const data = await call(
|
|
533
|
+
apiUrl,
|
|
534
|
+
"POST",
|
|
535
|
+
"/api/huizhi/bookings",
|
|
536
|
+
bookBody(values)
|
|
537
|
+
);
|
|
538
|
+
return absolutize(apiUrl, data, ["orderUrl", "statusUrl"]);
|
|
539
|
+
}
|
|
540
|
+
default:
|
|
541
|
+
throw usageError(`Unknown command "${command}". Run \`fortrip --help\`.`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
async function call(apiUrl, method, path, body) {
|
|
545
|
+
const token = await requireToken(apiUrl);
|
|
546
|
+
return unwrap(await request(apiUrl, path, { method, token, body }));
|
|
547
|
+
}
|
|
548
|
+
function absolutize(apiUrl, data, keys) {
|
|
549
|
+
const out = { ...data };
|
|
550
|
+
for (const key of keys) {
|
|
551
|
+
const value = out[key];
|
|
552
|
+
if (typeof value === "string" && value.startsWith("/")) {
|
|
553
|
+
out[key] = `${apiUrl}${value}`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
async function main() {
|
|
559
|
+
try {
|
|
560
|
+
const result = await run(process.argv.slice(2));
|
|
561
|
+
if (result !== void 0) {
|
|
562
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
563
|
+
`);
|
|
564
|
+
}
|
|
565
|
+
return EXIT.ok;
|
|
566
|
+
} catch (error) {
|
|
567
|
+
const failure = error instanceof CliError ? error : new CliError(error?.message ?? String(error), EXIT.error);
|
|
568
|
+
process.stderr.write(
|
|
569
|
+
`${JSON.stringify({ error: failure.message, exitCode: failure.exitCode, ...failure.details }, null, 2)}
|
|
570
|
+
`
|
|
571
|
+
);
|
|
572
|
+
return failure.exitCode;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
process.exitCode = await main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fortrip",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Search, price and book Fortrip hotels from the terminal or an AI agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fortrip": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"skills",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"dev": "tsx src/cli.ts",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
22
|
+
"prepublishOnly": "pnpm typecheck && pnpm test && pnpm build"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22.15.0",
|
|
26
|
+
"tsup": "^8.5.1",
|
|
27
|
+
"tsx": "^4.20.0",
|
|
28
|
+
"typescript": "^5.9.0"
|
|
29
|
+
},
|
|
30
|
+
"packageManager": "pnpm@11.21.0"
|
|
31
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fortrip-hotels
|
|
3
|
+
description: Search hotels, compare live room rates and create a hotel booking with a payment link through Fortrip. Use when the user wants to find, price or book a hotel stay for specific dates, or asks for a hotel payment link.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Fortrip hotels
|
|
7
|
+
|
|
8
|
+
Fortrip sells hotel rooms at supplier net prices. The
|
|
9
|
+
`fortrip` CLI (npm package `fortrip`) calls the Fortrip API as the
|
|
10
|
+
signed-in user and prints JSON.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- The user wants hotels in a city, near a point, or by name, for given dates.
|
|
15
|
+
- The user wants live rooms, meal plans, cancellation terms or prices for
|
|
16
|
+
specific hotels.
|
|
17
|
+
- The user has picked a rate and wants to book it (you return a payment link;
|
|
18
|
+
the user pays in their browser).
|
|
19
|
+
|
|
20
|
+
Not for flights, activities, or changing/cancelling an existing booking.
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
Run commands with `npx -y fortrip <command>` (or `fortrip <command>`
|
|
25
|
+
if installed globally).
|
|
26
|
+
|
|
27
|
+
If any command exits with code **3** (not logged in, or the token was revoked),
|
|
28
|
+
ask the user to run this in their own terminal, then retry:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx fortrip login
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
It opens a browser page where they sign in to Fortrip and confirm a code that
|
|
35
|
+
matches the one in the terminal. In CI or a remote sandbox, the user can set
|
|
36
|
+
`FORTRIP_TOKEN` instead. Never ask the user to paste a token into the chat.
|
|
37
|
+
|
|
38
|
+
## Exit codes
|
|
39
|
+
|
|
40
|
+
| Code | Meaning | What to do |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| 0 | Success; JSON on stdout | Continue |
|
|
43
|
+
| 1 | Other error (supplier error, rate no longer bookable, network) | Read `error` on stderr; re-run `rates` if a rate went stale |
|
|
44
|
+
| 2 | Invalid arguments | Fix the flags; stderr `issues` lists the bad fields |
|
|
45
|
+
| 3 | Not logged in | Ask the user to run `login` (above) |
|
|
46
|
+
| 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |
|
|
47
|
+
|
|
48
|
+
## Recommended order
|
|
49
|
+
|
|
50
|
+
1. **Search** — find candidates and indicative prices:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
fortrip search --query "Bangkok" --check-in 2026-11-10 --check-out 2026-11-12 \
|
|
54
|
+
--star-min 4 --sort price-asc --page-size 10
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Use `--near lat,lng,radiusKm` for "near X", `--city-code` when known.
|
|
58
|
+
`--facility` names must match the catalogue exactly (e.g. `Swimming pool`,
|
|
59
|
+
`Parking`), otherwise nothing matches. Prices here (`price.nightlyFrom`,
|
|
60
|
+
`totalFrom`, CNY) are **indicative floors**, not quotes — say so.
|
|
61
|
+
|
|
62
|
+
2. **Hotel detail** (optional) — description, policies, facilities, photos:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
fortrip hotel 266000 [--with-rooms]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
3. **Rates** — live rooms and prices for up to 15 hotels:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
fortrip rates --hotel 266000 --hotel 266001 --check-in 2026-11-10 \
|
|
72
|
+
--check-out 2026-11-12 --rooms 1 --adults 2 [--child-age 6] \
|
|
73
|
+
[--nationality CN] [--board BB] [--free-cancellation]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This call takes about 15 seconds. `totalPrice` is CNY at a fixed exchange
|
|
77
|
+
rate. Present each option with meal plan (`boardCode`: RO room only,
|
|
78
|
+
BB breakfast, HB half board, FB full board, AI all inclusive),
|
|
79
|
+
`freeCancelUntil` / `refundable`, `isInstantConfirm`, and any `fees`
|
|
80
|
+
(taxes paid at the hotel, in their own currency).
|
|
81
|
+
|
|
82
|
+
4. **Book** — only after the user picks one rate and gives guest details:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
fortrip book --hotel 266000 --room-id 9191658 --rate-plan "<ratePlanCode>" \
|
|
86
|
+
--check-in 2026-11-10 --check-out 2026-11-12 --rooms 1 --adults 2 \
|
|
87
|
+
--guest "John Smith" --contact-name "John Smith" \
|
|
88
|
+
--contact-phone "+8613800000000" [--contact-email john@example.com]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`--hotel`, `--room-id`, `--rate-plan`, dates, `--rooms`, `--adults` and
|
|
92
|
+
`--child-age` must be exactly the values used in `rates`. Guest names must
|
|
93
|
+
be in Latin letters as on the passport ("First Last"; last word is the last
|
|
94
|
+
name; `"First Last:2"` puts the guest in room 2). Each room needs at least
|
|
95
|
+
one guest. `--special-requests` must be in English and isn't guaranteed.
|
|
96
|
+
|
|
97
|
+
## Before giving the payment link
|
|
98
|
+
|
|
99
|
+
`book` returns `paymentUrl`, `amount`, `currency`, `terms`, `statusUrl` and
|
|
100
|
+
`orderUrl`. Before you hand over `paymentUrl`:
|
|
101
|
+
|
|
102
|
+
1. **Show the `terms`** and get the user's confirmation: meal plan
|
|
103
|
+
(`boardCode`, `boardCount`), cancellation (`refundable`, `freeCancelUntil`,
|
|
104
|
+
the `cancellation` ladder), confirmation (`isInstantConfirm`,
|
|
105
|
+
`confirmByMins`) and `payAtHotelFees`.
|
|
106
|
+
2. **Explain the amount.** `rates` showed CNY, but the charge is `amount` in
|
|
107
|
+
`currency` (usually USD), and the Stripe page may display the user's local
|
|
108
|
+
currency at Stripe's rate. The amount on the payment page is what will be
|
|
109
|
+
charged; the CNY figure was a reference.
|
|
110
|
+
3. The link is for **this user only**: whoever has it can pay. Don't post it
|
|
111
|
+
in shared channels, logs or tickets. It expires after about 3 hours 45
|
|
112
|
+
minutes; call `book` again with the same details for a fresh one.
|
|
113
|
+
|
|
114
|
+
After paying, the user lands on `statusUrl`, which shows confirmation progress
|
|
115
|
+
without signing in. `orderUrl` is the signed-in order page.
|
|
116
|
+
|
|
117
|
+
## Errors from `book`
|
|
118
|
+
|
|
119
|
+
- `409` with `agentOrderId`: this exact stay is already paid or completed —
|
|
120
|
+
show `orderUrl`, don't book again.
|
|
121
|
+
- `409` with `changes`: price or terms changed — re-run `rates` and confirm
|
|
122
|
+
again with the user.
|
|
123
|
+
- `422`: the booking was refused (guest names, too many attempts, bookings
|
|
124
|
+
paused, or a preview deployment). Read `error`.
|