letsfg 2026.5.64 → 2026.5.66
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 +32 -32
- package/dist/auth-SYZ5AYZ6.mjs +16 -0
- package/dist/{chunk-ZX2EIEN7.mjs → chunk-2SNO3AGS.mjs} +33 -7
- package/dist/chunk-XNO2W77S.mjs +126 -0
- package/dist/cli.js +280 -30
- package/dist/cli.mjs +90 -24
- package/dist/index.d.mts +14 -4
- package/dist/index.d.ts +14 -4
- package/dist/index.js +33 -7
- package/dist/index.mjs +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
| | **CLI / SDK** (this package) | **Developer API** |
|
|
11
11
|
|---|---|---|
|
|
12
|
-
| **Search cost** | Free (
|
|
13
|
-
| **Booking** | `POST /api/agent-book` | Direct airline URL |
|
|
12
|
+
| **Search cost** | Free (Bearer token via `letsfg auth` — zero-amount card setup) | Prepaid credits |
|
|
13
|
+
| **Booking** | `POST /api/agent-book` — confirmed order or a booking link, no LetsFG fee | Direct airline URL (unlock required first) |
|
|
14
14
|
| **Speed** | 60–90 s | 2–5 s (discover) · 60–90 s (full) |
|
|
15
15
|
| **Setup** | `npm install letsfg` then `letsfg auth` | [letsfg.co/developers](https://letsfg.co/developers) |
|
|
16
16
|
|
|
@@ -27,60 +27,60 @@ npm install letsfg
|
|
|
27
27
|
```typescript
|
|
28
28
|
import { LetsFG, cheapestOffer, offerSummary } from 'letsfg';
|
|
29
29
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// Use
|
|
35
|
-
const bt = new LetsFG({ apiKey: 'trav_...' });
|
|
30
|
+
// PFS — free. Get a Bearer token once with `letsfg auth` (zero-amount card
|
|
31
|
+
// setup, nothing charged), then pass it here.
|
|
32
|
+
const bt = new LetsFG({ bearerToken: 'eyJ...' });
|
|
36
33
|
|
|
37
34
|
// Search — FREE
|
|
38
35
|
const flights = await bt.search('GDN', 'BER', '2026-03-03');
|
|
39
36
|
const best = cheapestOffer(flights);
|
|
40
37
|
console.log(offerSummary(best));
|
|
41
38
|
|
|
42
|
-
//
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
// Book
|
|
46
|
-
const booking = await bt.book(
|
|
39
|
+
// Book — free, ticket price only, no LetsFG fee. No unlock step.
|
|
40
|
+
const result = await bt.book(
|
|
47
41
|
best.id,
|
|
48
|
-
[{
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
gender: 'm',
|
|
54
|
-
title: 'mr',
|
|
55
|
-
email: 'john@example.com',
|
|
56
|
-
}],
|
|
57
|
-
'john@example.com'
|
|
42
|
+
[{ given_name: 'John', family_name: 'Doe', born_on: '1990-01-15', gender: 'm' }],
|
|
43
|
+
'john@example.com',
|
|
44
|
+
'',
|
|
45
|
+
'',
|
|
46
|
+
flights.search_id,
|
|
58
47
|
);
|
|
59
|
-
|
|
48
|
+
if (result.booked) {
|
|
49
|
+
console.log(`Order: ${result.order_id}`);
|
|
50
|
+
} else {
|
|
51
|
+
console.log(`Booking link (nothing charged): ${result.booking_url}`);
|
|
52
|
+
}
|
|
60
53
|
```
|
|
61
54
|
|
|
55
|
+
Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken` —
|
|
56
|
+
`search()`/`book()` dispatch automatically. That path requires `unlock()`
|
|
57
|
+
(1% fee, min $3) before `book()`.
|
|
58
|
+
|
|
62
59
|
## Quick Start (CLI)
|
|
63
60
|
|
|
64
61
|
```bash
|
|
65
|
-
export LETSFG_BEARER_TOKEN=<your-bearer-token>
|
|
62
|
+
export LETSFG_BEARER_TOKEN=<your-bearer-token> # from `letsfg auth`
|
|
66
63
|
|
|
67
64
|
letsfg search GDN BER 2026-03-03 --sort price
|
|
68
65
|
letsfg search LON BCN 2026-04-01 --json # Machine-readable
|
|
69
|
-
letsfg
|
|
70
|
-
letsfg book off_xxx -p '{"id":"pas_xxx","given_name":"John",...}' -e john@example.com
|
|
66
|
+
letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":"Doe","born_on":"1990-01-15","gender":"m"}' -e john@example.com
|
|
71
67
|
```
|
|
72
68
|
|
|
73
69
|
## API
|
|
74
70
|
|
|
75
|
-
### `new LetsFG({ apiKey
|
|
71
|
+
### `new LetsFG({ bearerToken?, apiKey?, baseUrl?, timeout? })`
|
|
76
72
|
|
|
77
73
|
### `bt.search(origin, destination, dateFrom, options?)`
|
|
78
74
|
### `bt.resolveLocation(query)`
|
|
79
|
-
### `bt.unlock(offerId)`
|
|
80
|
-
### `bt.book(offerId, passengers, contactEmail, contactPhone?)`
|
|
81
|
-
|
|
75
|
+
### `bt.unlock(offerId)` — Developer API only
|
|
76
|
+
### `bt.book(offerId, passengers, contactEmail, contactPhone?, idempotencyKey?, searchId?)`
|
|
77
|
+
Dispatches on which credential is set: `bearerToken` → free PFS booking via
|
|
78
|
+
`POST /api/agent-book` (pass `searchId`, one passenger). `apiKey` → paid
|
|
79
|
+
Developer API `book` (requires `unlock()` first, supports multiple passengers
|
|
80
|
+
and `idempotencyKey`).
|
|
81
|
+
### `bt.setupPayment(token?)` — Developer API only
|
|
82
82
|
### `bt.me()`
|
|
83
|
-
### `LetsFG.register(agentName, email, baseUrl?, ownerName?, description?)`
|
|
83
|
+
### `LetsFG.register(agentName, email, baseUrl?, ownerName?, description?)` — Developer API only, most agents don't need this
|
|
84
84
|
|
|
85
85
|
### Helpers
|
|
86
86
|
- `offerSummary(offer)` — One-line string summary
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BearerTokenError,
|
|
3
|
+
getBearerToken,
|
|
4
|
+
paymentAuth,
|
|
5
|
+
requestEnrolment,
|
|
6
|
+
saveToken,
|
|
7
|
+
verifyPaymentMethod
|
|
8
|
+
} from "./chunk-XNO2W77S.mjs";
|
|
9
|
+
export {
|
|
10
|
+
BearerTokenError,
|
|
11
|
+
getBearerToken,
|
|
12
|
+
paymentAuth,
|
|
13
|
+
requestEnrolment,
|
|
14
|
+
saveToken,
|
|
15
|
+
verifyPaymentMethod
|
|
16
|
+
};
|
|
@@ -1629,18 +1629,44 @@ var LetsFG = class {
|
|
|
1629
1629
|
}
|
|
1630
1630
|
/**
|
|
1631
1631
|
* Unlock a flight offer — confirms live price, reveals direct airline booking URL.
|
|
1632
|
-
* Cost: 1% of ticket price, min $3.
|
|
1632
|
+
* Cost: 1% of ticket price, min $3. Developer API only — there is no unlock
|
|
1633
|
+
* endpoint on a PFS Bearer token, so PFS callers use book() directly.
|
|
1633
1634
|
*/
|
|
1634
1635
|
async unlock(offerId) {
|
|
1635
|
-
this.
|
|
1636
|
-
|
|
1637
|
-
return this.postWithAuth(path, { offer_id: offerId });
|
|
1636
|
+
this.requireApiKey();
|
|
1637
|
+
return this.post("/developers/api/v1/bookings/unlock", { offer_id: offerId });
|
|
1638
1638
|
}
|
|
1639
1639
|
/**
|
|
1640
|
-
* Book a flight
|
|
1641
|
-
*
|
|
1640
|
+
* Book a flight.
|
|
1641
|
+
*
|
|
1642
|
+
* PFS (Bearer token): POST /api/agent-book. Free — nothing charged beyond
|
|
1643
|
+
* the ticket price. Pass searchId (from search()'s result). Returns either
|
|
1644
|
+
* { ok, booked: true, order_id } or, when the booking genuinely could not
|
|
1645
|
+
* complete, { ok, booked: false, booking_url } — hand the link to the user,
|
|
1646
|
+
* nothing was charged.
|
|
1647
|
+
*
|
|
1648
|
+
* Developer API (X-API-Key): charges ticket price + service fee via Stripe,
|
|
1649
|
+
* creates a real PNR. Requires unlock() first. Always provide
|
|
1650
|
+
* idempotencyKey to prevent double-bookings on retry.
|
|
1642
1651
|
*/
|
|
1643
|
-
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "") {
|
|
1652
|
+
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "", searchId = "") {
|
|
1653
|
+
this.requireAuth();
|
|
1654
|
+
if (this.usingPFS) {
|
|
1655
|
+
if (!searchId) {
|
|
1656
|
+
throw new LetsFGError(
|
|
1657
|
+
"searchId is required to book via PFS \u2014 pass the search_id from search()'s result.",
|
|
1658
|
+
400
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
const passenger = { ...passengers[0] };
|
|
1662
|
+
if (contactPhone && !passenger.phone_number) passenger.phone_number = contactPhone;
|
|
1663
|
+
return this.postWithBearer("/api/agent-book", {
|
|
1664
|
+
search_id: searchId,
|
|
1665
|
+
offer_id: offerId,
|
|
1666
|
+
passenger,
|
|
1667
|
+
contact_email: contactEmail
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1644
1670
|
this.requireApiKey();
|
|
1645
1671
|
const body = {
|
|
1646
1672
|
offer_id: offerId,
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/auth.ts
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "fs";
|
|
3
|
+
import { homedir, platform } from "os";
|
|
4
|
+
import { join, dirname } from "path";
|
|
5
|
+
var BASE_URL = process.env.LETSFG_BASE_URL || "https://letsfg.co";
|
|
6
|
+
var TOKEN_TTL_MS = 90 * 24 * 3600 * 1e3;
|
|
7
|
+
var BearerTokenError = class extends Error {
|
|
8
|
+
};
|
|
9
|
+
function configPath() {
|
|
10
|
+
const base = platform() === "win32" ? process.env.APPDATA || homedir() : homedir();
|
|
11
|
+
return join(base, ".letsfg", "config.json");
|
|
12
|
+
}
|
|
13
|
+
function loadConfig() {
|
|
14
|
+
const p = configPath();
|
|
15
|
+
if (!existsSync(p)) return {};
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function saveConfig(cfg) {
|
|
23
|
+
const p = configPath();
|
|
24
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
25
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2));
|
|
26
|
+
try {
|
|
27
|
+
chmodSync(p, 384);
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function getBearerToken() {
|
|
32
|
+
const env = process.env.LETSFG_BEARER_TOKEN;
|
|
33
|
+
if (env) return env;
|
|
34
|
+
const auth = loadConfig().pfs_auth;
|
|
35
|
+
if (auth?.token && Date.now() < auth.expires_at - 36e5) {
|
|
36
|
+
return auth.token;
|
|
37
|
+
}
|
|
38
|
+
throw new BearerTokenError(
|
|
39
|
+
"No valid LetsFG Bearer token.\n Run: letsfg auth (adds a payment method \u2014 nothing is charged)\n Or: export LETSFG_BEARER_TOKEN=<token>"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
function saveToken(token, expiresAt) {
|
|
43
|
+
const cfg = loadConfig();
|
|
44
|
+
cfg.pfs_auth = { token, expires_at: expiresAt ?? Date.now() + TOKEN_TTL_MS };
|
|
45
|
+
saveConfig(cfg);
|
|
46
|
+
}
|
|
47
|
+
async function postJson(path, payload) {
|
|
48
|
+
const resp = await fetch(`${BASE_URL}${path}`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json", "User-Agent": "LetsFG-js/0.1.0", "X-Client-Type": "js-sdk" },
|
|
51
|
+
body: JSON.stringify(payload)
|
|
52
|
+
});
|
|
53
|
+
const data = await resp.json().catch(() => ({}));
|
|
54
|
+
return { status: resp.status, data };
|
|
55
|
+
}
|
|
56
|
+
function parseExpiry(raw) {
|
|
57
|
+
if (typeof raw === "number") return raw * 1e3;
|
|
58
|
+
if (typeof raw === "string") {
|
|
59
|
+
const ms = Date.parse(raw);
|
|
60
|
+
if (!Number.isNaN(ms)) return ms;
|
|
61
|
+
}
|
|
62
|
+
return Date.now() + TOKEN_TTL_MS;
|
|
63
|
+
}
|
|
64
|
+
async function requestEnrolment() {
|
|
65
|
+
const { status, data } = await postJson("/api/agent-access/request", {});
|
|
66
|
+
if (status !== 200 && status !== 402) {
|
|
67
|
+
throw new BearerTokenError(`Could not start authentication (HTTP ${status}): ${data.error || ""}`);
|
|
68
|
+
}
|
|
69
|
+
return data;
|
|
70
|
+
}
|
|
71
|
+
async function verifyPaymentMethod(opts) {
|
|
72
|
+
const payload = {};
|
|
73
|
+
if (opts.setupSessionId) payload.setup_session_id = opts.setupSessionId;
|
|
74
|
+
else if (opts.paymentMethodId) payload.payment_method_id = opts.paymentMethodId;
|
|
75
|
+
else if (opts.cardToken) payload.card_token = opts.cardToken;
|
|
76
|
+
else throw new BearerTokenError("Provide one of setupSessionId, paymentMethodId, or cardToken.");
|
|
77
|
+
const { status, data } = await postJson("/api/agent-access/verify", payload);
|
|
78
|
+
if (status !== 200 || !data.token) {
|
|
79
|
+
throw new BearerTokenError(`Verification failed (HTTP ${status}). ${data.hint || data.error || ""}`.trim());
|
|
80
|
+
}
|
|
81
|
+
const token = data.token;
|
|
82
|
+
saveToken(token, parseExpiry(data.expires_at));
|
|
83
|
+
return token;
|
|
84
|
+
}
|
|
85
|
+
function openBrowser(url) {
|
|
86
|
+
import("child_process").then(({ exec }) => {
|
|
87
|
+
const cmd = platform() === "win32" ? `start "" "${url}"` : platform() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
88
|
+
exec(cmd, () => {
|
|
89
|
+
});
|
|
90
|
+
}).catch(() => {
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async function paymentAuth(openBrowserFlag = true) {
|
|
94
|
+
console.log("\n Connecting to LetsFG...");
|
|
95
|
+
const data = await requestEnrolment();
|
|
96
|
+
const setupUrl = data.setup_url;
|
|
97
|
+
const sessionId = data.setup_session_id;
|
|
98
|
+
if (!setupUrl || !sessionId) {
|
|
99
|
+
throw new BearerTokenError("Server did not return a setup URL. Check https://letsfg.co/for-agents");
|
|
100
|
+
}
|
|
101
|
+
console.log("\n LetsFG needs a payment method on file before it can search or book.");
|
|
102
|
+
console.log(" Nothing is charged now \u2014 this is a zero-amount card setup.\n");
|
|
103
|
+
console.log(" Step 1 \u2014 add a card here:\n");
|
|
104
|
+
console.log(` ${setupUrl}
|
|
105
|
+
`);
|
|
106
|
+
if (openBrowserFlag) openBrowser(setupUrl);
|
|
107
|
+
console.log(" Step 2 \u2014 press Enter once you've finished.");
|
|
108
|
+
const readline = await import("readline/promises");
|
|
109
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
110
|
+
await rl.question("");
|
|
111
|
+
rl.close();
|
|
112
|
+
process.stdout.write(" Verifying... ");
|
|
113
|
+
const token = await verifyPaymentMethod({ setupSessionId: sessionId });
|
|
114
|
+
const expiresAt = loadConfig().pfs_auth?.expires_at ?? Date.now() + TOKEN_TTL_MS;
|
|
115
|
+
console.log(`done. Token valid until ${new Date(expiresAt).toISOString().slice(0, 10)}.`);
|
|
116
|
+
return token;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export {
|
|
120
|
+
BearerTokenError,
|
|
121
|
+
getBearerToken,
|
|
122
|
+
saveToken,
|
|
123
|
+
requestEnrolment,
|
|
124
|
+
verifyPaymentMethod,
|
|
125
|
+
paymentAuth
|
|
126
|
+
};
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,167 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __esm = (fn, res) => function __init() {
|
|
10
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
var __copyProps = (to, from, except, desc) => {
|
|
17
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
18
|
+
for (let key of __getOwnPropNames(from))
|
|
19
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
20
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
21
|
+
}
|
|
22
|
+
return to;
|
|
23
|
+
};
|
|
24
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
25
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
26
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
27
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
28
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
29
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
30
|
+
mod
|
|
31
|
+
));
|
|
32
|
+
|
|
33
|
+
// src/auth.ts
|
|
34
|
+
var auth_exports = {};
|
|
35
|
+
__export(auth_exports, {
|
|
36
|
+
BearerTokenError: () => BearerTokenError,
|
|
37
|
+
getBearerToken: () => getBearerToken,
|
|
38
|
+
paymentAuth: () => paymentAuth,
|
|
39
|
+
requestEnrolment: () => requestEnrolment,
|
|
40
|
+
saveToken: () => saveToken,
|
|
41
|
+
verifyPaymentMethod: () => verifyPaymentMethod
|
|
42
|
+
});
|
|
43
|
+
function configPath() {
|
|
44
|
+
const base = (0, import_node_os.platform)() === "win32" ? process.env.APPDATA || (0, import_node_os.homedir)() : (0, import_node_os.homedir)();
|
|
45
|
+
return (0, import_node_path.join)(base, ".letsfg", "config.json");
|
|
46
|
+
}
|
|
47
|
+
function loadConfig() {
|
|
48
|
+
const p = configPath();
|
|
49
|
+
if (!(0, import_node_fs.existsSync)(p)) return {};
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse((0, import_node_fs.readFileSync)(p, "utf-8"));
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function saveConfig(cfg) {
|
|
57
|
+
const p = configPath();
|
|
58
|
+
(0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(p), { recursive: true });
|
|
59
|
+
(0, import_node_fs.writeFileSync)(p, JSON.stringify(cfg, null, 2));
|
|
60
|
+
try {
|
|
61
|
+
(0, import_node_fs.chmodSync)(p, 384);
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function getBearerToken() {
|
|
66
|
+
const env = process.env.LETSFG_BEARER_TOKEN;
|
|
67
|
+
if (env) return env;
|
|
68
|
+
const auth = loadConfig().pfs_auth;
|
|
69
|
+
if (auth?.token && Date.now() < auth.expires_at - 36e5) {
|
|
70
|
+
return auth.token;
|
|
71
|
+
}
|
|
72
|
+
throw new BearerTokenError(
|
|
73
|
+
"No valid LetsFG Bearer token.\n Run: letsfg auth (adds a payment method \u2014 nothing is charged)\n Or: export LETSFG_BEARER_TOKEN=<token>"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
function saveToken(token, expiresAt) {
|
|
77
|
+
const cfg = loadConfig();
|
|
78
|
+
cfg.pfs_auth = { token, expires_at: expiresAt ?? Date.now() + TOKEN_TTL_MS };
|
|
79
|
+
saveConfig(cfg);
|
|
80
|
+
}
|
|
81
|
+
async function postJson(path, payload) {
|
|
82
|
+
const resp = await fetch(`${BASE_URL}${path}`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json", "User-Agent": "LetsFG-js/0.1.0", "X-Client-Type": "js-sdk" },
|
|
85
|
+
body: JSON.stringify(payload)
|
|
86
|
+
});
|
|
87
|
+
const data = await resp.json().catch(() => ({}));
|
|
88
|
+
return { status: resp.status, data };
|
|
89
|
+
}
|
|
90
|
+
function parseExpiry(raw) {
|
|
91
|
+
if (typeof raw === "number") return raw * 1e3;
|
|
92
|
+
if (typeof raw === "string") {
|
|
93
|
+
const ms = Date.parse(raw);
|
|
94
|
+
if (!Number.isNaN(ms)) return ms;
|
|
95
|
+
}
|
|
96
|
+
return Date.now() + TOKEN_TTL_MS;
|
|
97
|
+
}
|
|
98
|
+
async function requestEnrolment() {
|
|
99
|
+
const { status, data } = await postJson("/api/agent-access/request", {});
|
|
100
|
+
if (status !== 200 && status !== 402) {
|
|
101
|
+
throw new BearerTokenError(`Could not start authentication (HTTP ${status}): ${data.error || ""}`);
|
|
102
|
+
}
|
|
103
|
+
return data;
|
|
104
|
+
}
|
|
105
|
+
async function verifyPaymentMethod(opts) {
|
|
106
|
+
const payload = {};
|
|
107
|
+
if (opts.setupSessionId) payload.setup_session_id = opts.setupSessionId;
|
|
108
|
+
else if (opts.paymentMethodId) payload.payment_method_id = opts.paymentMethodId;
|
|
109
|
+
else if (opts.cardToken) payload.card_token = opts.cardToken;
|
|
110
|
+
else throw new BearerTokenError("Provide one of setupSessionId, paymentMethodId, or cardToken.");
|
|
111
|
+
const { status, data } = await postJson("/api/agent-access/verify", payload);
|
|
112
|
+
if (status !== 200 || !data.token) {
|
|
113
|
+
throw new BearerTokenError(`Verification failed (HTTP ${status}). ${data.hint || data.error || ""}`.trim());
|
|
114
|
+
}
|
|
115
|
+
const token = data.token;
|
|
116
|
+
saveToken(token, parseExpiry(data.expires_at));
|
|
117
|
+
return token;
|
|
118
|
+
}
|
|
119
|
+
function openBrowser(url) {
|
|
120
|
+
import("child_process").then(({ exec }) => {
|
|
121
|
+
const cmd = (0, import_node_os.platform)() === "win32" ? `start "" "${url}"` : (0, import_node_os.platform)() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
122
|
+
exec(cmd, () => {
|
|
123
|
+
});
|
|
124
|
+
}).catch(() => {
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function paymentAuth(openBrowserFlag = true) {
|
|
128
|
+
console.log("\n Connecting to LetsFG...");
|
|
129
|
+
const data = await requestEnrolment();
|
|
130
|
+
const setupUrl = data.setup_url;
|
|
131
|
+
const sessionId = data.setup_session_id;
|
|
132
|
+
if (!setupUrl || !sessionId) {
|
|
133
|
+
throw new BearerTokenError("Server did not return a setup URL. Check https://letsfg.co/for-agents");
|
|
134
|
+
}
|
|
135
|
+
console.log("\n LetsFG needs a payment method on file before it can search or book.");
|
|
136
|
+
console.log(" Nothing is charged now \u2014 this is a zero-amount card setup.\n");
|
|
137
|
+
console.log(" Step 1 \u2014 add a card here:\n");
|
|
138
|
+
console.log(` ${setupUrl}
|
|
139
|
+
`);
|
|
140
|
+
if (openBrowserFlag) openBrowser(setupUrl);
|
|
141
|
+
console.log(" Step 2 \u2014 press Enter once you've finished.");
|
|
142
|
+
const readline = await import("readline/promises");
|
|
143
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
144
|
+
await rl.question("");
|
|
145
|
+
rl.close();
|
|
146
|
+
process.stdout.write(" Verifying... ");
|
|
147
|
+
const token = await verifyPaymentMethod({ setupSessionId: sessionId });
|
|
148
|
+
const expiresAt = loadConfig().pfs_auth?.expires_at ?? Date.now() + TOKEN_TTL_MS;
|
|
149
|
+
console.log(`done. Token valid until ${new Date(expiresAt).toISOString().slice(0, 10)}.`);
|
|
150
|
+
return token;
|
|
151
|
+
}
|
|
152
|
+
var import_node_fs, import_node_os, import_node_path, BASE_URL, TOKEN_TTL_MS, BearerTokenError;
|
|
153
|
+
var init_auth = __esm({
|
|
154
|
+
"src/auth.ts"() {
|
|
155
|
+
"use strict";
|
|
156
|
+
import_node_fs = require("fs");
|
|
157
|
+
import_node_os = require("os");
|
|
158
|
+
import_node_path = require("path");
|
|
159
|
+
BASE_URL = process.env.LETSFG_BASE_URL || "https://letsfg.co";
|
|
160
|
+
TOKEN_TTL_MS = 90 * 24 * 3600 * 1e3;
|
|
161
|
+
BearerTokenError = class extends Error {
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
});
|
|
3
165
|
|
|
4
166
|
// src/ranking.ts
|
|
5
167
|
var W = {
|
|
@@ -384,18 +546,44 @@ var LetsFG = class {
|
|
|
384
546
|
}
|
|
385
547
|
/**
|
|
386
548
|
* Unlock a flight offer — confirms live price, reveals direct airline booking URL.
|
|
387
|
-
* Cost: 1% of ticket price, min $3.
|
|
549
|
+
* Cost: 1% of ticket price, min $3. Developer API only — there is no unlock
|
|
550
|
+
* endpoint on a PFS Bearer token, so PFS callers use book() directly.
|
|
388
551
|
*/
|
|
389
552
|
async unlock(offerId) {
|
|
390
|
-
this.
|
|
391
|
-
|
|
392
|
-
return this.postWithAuth(path, { offer_id: offerId });
|
|
553
|
+
this.requireApiKey();
|
|
554
|
+
return this.post("/developers/api/v1/bookings/unlock", { offer_id: offerId });
|
|
393
555
|
}
|
|
394
556
|
/**
|
|
395
|
-
* Book a flight
|
|
396
|
-
*
|
|
557
|
+
* Book a flight.
|
|
558
|
+
*
|
|
559
|
+
* PFS (Bearer token): POST /api/agent-book. Free — nothing charged beyond
|
|
560
|
+
* the ticket price. Pass searchId (from search()'s result). Returns either
|
|
561
|
+
* { ok, booked: true, order_id } or, when the booking genuinely could not
|
|
562
|
+
* complete, { ok, booked: false, booking_url } — hand the link to the user,
|
|
563
|
+
* nothing was charged.
|
|
564
|
+
*
|
|
565
|
+
* Developer API (X-API-Key): charges ticket price + service fee via Stripe,
|
|
566
|
+
* creates a real PNR. Requires unlock() first. Always provide
|
|
567
|
+
* idempotencyKey to prevent double-bookings on retry.
|
|
397
568
|
*/
|
|
398
|
-
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "") {
|
|
569
|
+
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "", searchId = "") {
|
|
570
|
+
this.requireAuth();
|
|
571
|
+
if (this.usingPFS) {
|
|
572
|
+
if (!searchId) {
|
|
573
|
+
throw new LetsFGError(
|
|
574
|
+
"searchId is required to book via PFS \u2014 pass the search_id from search()'s result.",
|
|
575
|
+
400
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const passenger = { ...passengers[0] };
|
|
579
|
+
if (contactPhone && !passenger.phone_number) passenger.phone_number = contactPhone;
|
|
580
|
+
return this.postWithBearer("/api/agent-book", {
|
|
581
|
+
search_id: searchId,
|
|
582
|
+
offer_id: offerId,
|
|
583
|
+
passenger,
|
|
584
|
+
contact_email: contactEmail
|
|
585
|
+
});
|
|
586
|
+
}
|
|
399
587
|
this.requireApiKey();
|
|
400
588
|
const body = {
|
|
401
589
|
offer_id: offerId,
|
|
@@ -658,6 +846,16 @@ var LetsFG = class {
|
|
|
658
846
|
};
|
|
659
847
|
|
|
660
848
|
// src/cli.ts
|
|
849
|
+
init_auth();
|
|
850
|
+
function resolveCredentials(apiKeyFlag) {
|
|
851
|
+
const apiKey = apiKeyFlag || process.env.LETSFG_API_KEY;
|
|
852
|
+
if (apiKey) return { apiKey };
|
|
853
|
+
try {
|
|
854
|
+
return { bearerToken: getBearerToken() };
|
|
855
|
+
} catch {
|
|
856
|
+
return {};
|
|
857
|
+
}
|
|
858
|
+
}
|
|
661
859
|
function getFlag(args, flag, alias) {
|
|
662
860
|
for (let i = 0; i < args.length; i++) {
|
|
663
861
|
if (args[i] === flag || alias && args[i] === alias) {
|
|
@@ -712,7 +910,8 @@ async function cmdSearch(args) {
|
|
|
712
910
|
console.error("Usage: letsfg search <origin> <destination> <date> [options]");
|
|
713
911
|
process.exit(1);
|
|
714
912
|
}
|
|
715
|
-
const
|
|
913
|
+
const creds = resolveCredentials(apiKey);
|
|
914
|
+
const bt = new LetsFG({ ...creds, baseUrl });
|
|
716
915
|
const result = await bt.search(origin, destination, date, {
|
|
717
916
|
returnDate,
|
|
718
917
|
adults,
|
|
@@ -726,6 +925,7 @@ async function cmdSearch(args) {
|
|
|
726
925
|
});
|
|
727
926
|
if (jsonOut) {
|
|
728
927
|
console.log(JSON.stringify({
|
|
928
|
+
search_id: result.search_id,
|
|
729
929
|
passenger_ids: result.passenger_ids,
|
|
730
930
|
total_results: result.total_results,
|
|
731
931
|
offers: result.offers.map((o) => ({
|
|
@@ -749,16 +949,25 @@ async function cmdSearch(args) {
|
|
|
749
949
|
}
|
|
750
950
|
console.log(`
|
|
751
951
|
${result.total_results} offers | ${origin} \u2192 ${destination} | ${date}`);
|
|
952
|
+
if (result.search_id) {
|
|
953
|
+
console.log(` search_id: ${result.search_id} (needed for \`letsfg book\`, offers expire ~15 min after search)`);
|
|
954
|
+
}
|
|
752
955
|
console.log(` Passenger IDs: ${JSON.stringify(result.passenger_ids)}
|
|
753
956
|
`);
|
|
754
957
|
result.offers.forEach((o, i) => {
|
|
755
958
|
console.log(` ${(i + 1).toString().padStart(3)}. ${offerSummary(o)}`);
|
|
756
959
|
console.log(` ID: ${o.id}`);
|
|
757
960
|
});
|
|
758
|
-
|
|
961
|
+
if (creds.bearerToken) {
|
|
962
|
+
console.log(`
|
|
963
|
+
To book: letsfg book <offer_id> --search-id ${result.search_id} --passenger '{...}' --email you@example.com
|
|
964
|
+
`);
|
|
965
|
+
} else {
|
|
966
|
+
console.log(`
|
|
759
967
|
To unlock: letsfg unlock <offer_id>`);
|
|
760
|
-
|
|
968
|
+
console.log(` Passenger IDs needed for booking: ${JSON.stringify(result.passenger_ids)}
|
|
761
969
|
`);
|
|
970
|
+
}
|
|
762
971
|
}
|
|
763
972
|
async function cmdUnlock(args) {
|
|
764
973
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
@@ -792,33 +1001,55 @@ async function cmdBook(args) {
|
|
|
792
1001
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
793
1002
|
const apiKey = getFlag(args, "--api-key", "-k");
|
|
794
1003
|
const baseUrl = getFlag(args, "--base-url");
|
|
1004
|
+
const searchId = getFlag(args, "--search-id");
|
|
795
1005
|
const email = getFlag(args, "--email", "-e") || "";
|
|
796
1006
|
const phone = getFlag(args, "--phone") || "";
|
|
797
1007
|
const passengerStrs = getAllFlags(args, "--passenger", "-p");
|
|
798
1008
|
const offerId = args[0];
|
|
799
1009
|
if (!offerId || !passengerStrs.length || !email) {
|
|
800
|
-
console.error(`Usage: letsfg book <offer_id> --passenger '{"
|
|
1010
|
+
console.error(`Usage: letsfg book <offer_id> --search-id <id> --passenger '{"given_name":"John",...}' --email you@example.com`);
|
|
801
1011
|
process.exit(1);
|
|
802
1012
|
}
|
|
803
1013
|
const passengers = passengerStrs.map((s) => JSON.parse(s));
|
|
804
|
-
const
|
|
805
|
-
|
|
1014
|
+
const creds = resolveCredentials(apiKey);
|
|
1015
|
+
if (creds.bearerToken && !searchId) {
|
|
1016
|
+
console.error("Error: --search-id is required (from your `letsfg search` results) to book via the free PFS path.");
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1019
|
+
const bt = new LetsFG({ ...creds, baseUrl });
|
|
1020
|
+
const result = await bt.book(offerId, passengers, email, phone, "", searchId);
|
|
806
1021
|
if (jsonOut) {
|
|
807
1022
|
console.log(JSON.stringify(result, null, 2));
|
|
808
1023
|
return;
|
|
809
1024
|
}
|
|
810
|
-
if (
|
|
1025
|
+
if ("booked" in result) {
|
|
1026
|
+
if (result.booked) {
|
|
1027
|
+
console.log(`
|
|
1028
|
+
\u2713 Booking confirmed!`);
|
|
1029
|
+
console.log(` Order ID: ${result.order_id}`);
|
|
1030
|
+
console.log(` Charged: ${result.charged ?? 0} ${result.currency ?? ""}
|
|
1031
|
+
`);
|
|
1032
|
+
} else {
|
|
1033
|
+
console.log(`
|
|
1034
|
+
Could not complete a confirmed booking. Nothing was charged.`);
|
|
1035
|
+
console.log(` Booking link: ${result.booking_url ?? "(none)"}
|
|
1036
|
+
`);
|
|
1037
|
+
}
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const br = result;
|
|
1041
|
+
if (br.status === "confirmed") {
|
|
811
1042
|
console.log(`
|
|
812
1043
|
\u2713 Booking confirmed!`);
|
|
813
|
-
console.log(` PNR: ${
|
|
814
|
-
console.log(` Flight: ${
|
|
815
|
-
console.log(` Fee: ${
|
|
816
|
-
console.log(` Total: ${
|
|
817
|
-
console.log(` Order: ${
|
|
1044
|
+
console.log(` PNR: ${br.booking_reference}`);
|
|
1045
|
+
console.log(` Flight: ${br.currency} ${br.flight_price.toFixed(2)}`);
|
|
1046
|
+
console.log(` Fee: ${br.currency} ${br.service_fee.toFixed(2)} (${br.service_fee_percentage}%)`);
|
|
1047
|
+
console.log(` Total: ${br.currency} ${br.total_charged.toFixed(2)}`);
|
|
1048
|
+
console.log(` Order: ${br.order_id}
|
|
818
1049
|
`);
|
|
819
1050
|
} else {
|
|
820
1051
|
console.error(` \u2717 Booking failed`);
|
|
821
|
-
console.error(JSON.stringify(
|
|
1052
|
+
console.error(JSON.stringify(br.details, null, 2));
|
|
822
1053
|
process.exit(1);
|
|
823
1054
|
}
|
|
824
1055
|
}
|
|
@@ -850,6 +1081,19 @@ async function cmdLocations(args) {
|
|
|
850
1081
|
console.log(` ${iata} ${name} (${type}) \u2014 ${city}, ${country}`);
|
|
851
1082
|
}
|
|
852
1083
|
}
|
|
1084
|
+
async function cmdAuth(args) {
|
|
1085
|
+
const cardToken = getFlag(args, "--card-token");
|
|
1086
|
+
const paymentMethodId = getFlag(args, "--payment-method");
|
|
1087
|
+
const noBrowser = hasFlag(args, "--no-browser");
|
|
1088
|
+
if (cardToken || paymentMethodId) {
|
|
1089
|
+
const { verifyPaymentMethod: verifyPaymentMethod2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
|
|
1090
|
+
await verifyPaymentMethod2({ cardToken: cardToken || void 0, paymentMethodId: paymentMethodId || void 0 });
|
|
1091
|
+
console.log("\n \u2713 Authenticated. Nothing was charged.");
|
|
1092
|
+
} else {
|
|
1093
|
+
await paymentAuth(!noBrowser);
|
|
1094
|
+
}
|
|
1095
|
+
console.log("\n You're all set. Run: letsfg search WAW BCN 2026-07-15\n");
|
|
1096
|
+
}
|
|
853
1097
|
async function cmdRegister(args) {
|
|
854
1098
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
855
1099
|
const baseUrl = getFlag(args, "--base-url");
|
|
@@ -930,11 +1174,11 @@ Authenticate once with letsfg auth \u2014 a zero-amount card setup, nothing is
|
|
|
930
1174
|
charged \u2014 then search and book.
|
|
931
1175
|
|
|
932
1176
|
Commands:
|
|
933
|
-
auth
|
|
934
|
-
search <origin> <dest> <date>
|
|
935
|
-
locations <query>
|
|
936
|
-
book <offer_id> --
|
|
937
|
-
me
|
|
1177
|
+
auth Put a card on file -> 90-day token. Nothing charged
|
|
1178
|
+
search <origin> <dest> <date> Search for flights (free), prints search_id
|
|
1179
|
+
locations <query> Resolve city name to IATA codes
|
|
1180
|
+
book <offer_id> --search-id ... Book a flight. No LetsFG fee, no unlock step
|
|
1181
|
+
me Show agent profile
|
|
938
1182
|
|
|
939
1183
|
Developer API only (a SEPARATE paid product \u2014 most agents should not use these;
|
|
940
1184
|
they create a billing account. Use auth above instead):
|
|
@@ -943,20 +1187,26 @@ they create a billing account. Use auth above instead):
|
|
|
943
1187
|
unlock <offer_id> [Developer API only] Unlock offer \u2014 1% of ticket (min $3)
|
|
944
1188
|
|
|
945
1189
|
Options:
|
|
946
|
-
--json, -j
|
|
947
|
-
--api-key, -k
|
|
948
|
-
--base-url
|
|
1190
|
+
--json, -j Output raw JSON
|
|
1191
|
+
--api-key, -k Developer API key (or set LETSFG_API_KEY) \u2014 switches book/search to the paid path
|
|
1192
|
+
--base-url API URL (default: https://letsfg.co)
|
|
1193
|
+
--card-token (auth only) Stripe tok_... you already hold, for a headless auth
|
|
1194
|
+
--payment-method (auth only) Stripe pm_... you already hold, for a headless auth
|
|
1195
|
+
--no-browser (auth only) Don't try to auto-open the card setup page
|
|
949
1196
|
|
|
950
1197
|
Examples:
|
|
951
1198
|
letsfg auth
|
|
952
1199
|
letsfg search GDN BER 2026-03-03 --sort price
|
|
953
|
-
letsfg book off_xxx -p '{"given_name":"Ada"
|
|
1200
|
+
letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"Ada","family_name":"Lovelace","born_on":"1990-04-01","gender":"f"}' -e ada@example.com
|
|
954
1201
|
`;
|
|
955
1202
|
async function main() {
|
|
956
1203
|
const args = process.argv.slice(2);
|
|
957
1204
|
const command = args.shift();
|
|
958
1205
|
try {
|
|
959
1206
|
switch (command) {
|
|
1207
|
+
case "auth":
|
|
1208
|
+
await cmdAuth(args);
|
|
1209
|
+
break;
|
|
960
1210
|
case "search":
|
|
961
1211
|
await cmdSearch(args);
|
|
962
1212
|
break;
|
|
@@ -990,7 +1240,7 @@ async function main() {
|
|
|
990
1240
|
process.exit(1);
|
|
991
1241
|
}
|
|
992
1242
|
} catch (e) {
|
|
993
|
-
if (e instanceof LetsFGError) {
|
|
1243
|
+
if (e instanceof LetsFGError || e instanceof BearerTokenError) {
|
|
994
1244
|
console.error(`Error: ${e.message}`);
|
|
995
1245
|
process.exit(1);
|
|
996
1246
|
}
|
package/dist/cli.mjs
CHANGED
|
@@ -3,9 +3,23 @@ import {
|
|
|
3
3
|
LetsFG,
|
|
4
4
|
LetsFGError,
|
|
5
5
|
offerSummary
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-2SNO3AGS.mjs";
|
|
7
|
+
import {
|
|
8
|
+
BearerTokenError,
|
|
9
|
+
getBearerToken,
|
|
10
|
+
paymentAuth
|
|
11
|
+
} from "./chunk-XNO2W77S.mjs";
|
|
7
12
|
|
|
8
13
|
// src/cli.ts
|
|
14
|
+
function resolveCredentials(apiKeyFlag) {
|
|
15
|
+
const apiKey = apiKeyFlag || process.env.LETSFG_API_KEY;
|
|
16
|
+
if (apiKey) return { apiKey };
|
|
17
|
+
try {
|
|
18
|
+
return { bearerToken: getBearerToken() };
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
9
23
|
function getFlag(args, flag, alias) {
|
|
10
24
|
for (let i = 0; i < args.length; i++) {
|
|
11
25
|
if (args[i] === flag || alias && args[i] === alias) {
|
|
@@ -60,7 +74,8 @@ async function cmdSearch(args) {
|
|
|
60
74
|
console.error("Usage: letsfg search <origin> <destination> <date> [options]");
|
|
61
75
|
process.exit(1);
|
|
62
76
|
}
|
|
63
|
-
const
|
|
77
|
+
const creds = resolveCredentials(apiKey);
|
|
78
|
+
const bt = new LetsFG({ ...creds, baseUrl });
|
|
64
79
|
const result = await bt.search(origin, destination, date, {
|
|
65
80
|
returnDate,
|
|
66
81
|
adults,
|
|
@@ -74,6 +89,7 @@ async function cmdSearch(args) {
|
|
|
74
89
|
});
|
|
75
90
|
if (jsonOut) {
|
|
76
91
|
console.log(JSON.stringify({
|
|
92
|
+
search_id: result.search_id,
|
|
77
93
|
passenger_ids: result.passenger_ids,
|
|
78
94
|
total_results: result.total_results,
|
|
79
95
|
offers: result.offers.map((o) => ({
|
|
@@ -97,16 +113,25 @@ async function cmdSearch(args) {
|
|
|
97
113
|
}
|
|
98
114
|
console.log(`
|
|
99
115
|
${result.total_results} offers | ${origin} \u2192 ${destination} | ${date}`);
|
|
116
|
+
if (result.search_id) {
|
|
117
|
+
console.log(` search_id: ${result.search_id} (needed for \`letsfg book\`, offers expire ~15 min after search)`);
|
|
118
|
+
}
|
|
100
119
|
console.log(` Passenger IDs: ${JSON.stringify(result.passenger_ids)}
|
|
101
120
|
`);
|
|
102
121
|
result.offers.forEach((o, i) => {
|
|
103
122
|
console.log(` ${(i + 1).toString().padStart(3)}. ${offerSummary(o)}`);
|
|
104
123
|
console.log(` ID: ${o.id}`);
|
|
105
124
|
});
|
|
106
|
-
|
|
125
|
+
if (creds.bearerToken) {
|
|
126
|
+
console.log(`
|
|
127
|
+
To book: letsfg book <offer_id> --search-id ${result.search_id} --passenger '{...}' --email you@example.com
|
|
128
|
+
`);
|
|
129
|
+
} else {
|
|
130
|
+
console.log(`
|
|
107
131
|
To unlock: letsfg unlock <offer_id>`);
|
|
108
|
-
|
|
132
|
+
console.log(` Passenger IDs needed for booking: ${JSON.stringify(result.passenger_ids)}
|
|
109
133
|
`);
|
|
134
|
+
}
|
|
110
135
|
}
|
|
111
136
|
async function cmdUnlock(args) {
|
|
112
137
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
@@ -140,33 +165,55 @@ async function cmdBook(args) {
|
|
|
140
165
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
141
166
|
const apiKey = getFlag(args, "--api-key", "-k");
|
|
142
167
|
const baseUrl = getFlag(args, "--base-url");
|
|
168
|
+
const searchId = getFlag(args, "--search-id");
|
|
143
169
|
const email = getFlag(args, "--email", "-e") || "";
|
|
144
170
|
const phone = getFlag(args, "--phone") || "";
|
|
145
171
|
const passengerStrs = getAllFlags(args, "--passenger", "-p");
|
|
146
172
|
const offerId = args[0];
|
|
147
173
|
if (!offerId || !passengerStrs.length || !email) {
|
|
148
|
-
console.error(`Usage: letsfg book <offer_id> --passenger '{"
|
|
174
|
+
console.error(`Usage: letsfg book <offer_id> --search-id <id> --passenger '{"given_name":"John",...}' --email you@example.com`);
|
|
149
175
|
process.exit(1);
|
|
150
176
|
}
|
|
151
177
|
const passengers = passengerStrs.map((s) => JSON.parse(s));
|
|
152
|
-
const
|
|
153
|
-
|
|
178
|
+
const creds = resolveCredentials(apiKey);
|
|
179
|
+
if (creds.bearerToken && !searchId) {
|
|
180
|
+
console.error("Error: --search-id is required (from your `letsfg search` results) to book via the free PFS path.");
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
const bt = new LetsFG({ ...creds, baseUrl });
|
|
184
|
+
const result = await bt.book(offerId, passengers, email, phone, "", searchId);
|
|
154
185
|
if (jsonOut) {
|
|
155
186
|
console.log(JSON.stringify(result, null, 2));
|
|
156
187
|
return;
|
|
157
188
|
}
|
|
158
|
-
if (
|
|
189
|
+
if ("booked" in result) {
|
|
190
|
+
if (result.booked) {
|
|
191
|
+
console.log(`
|
|
192
|
+
\u2713 Booking confirmed!`);
|
|
193
|
+
console.log(` Order ID: ${result.order_id}`);
|
|
194
|
+
console.log(` Charged: ${result.charged ?? 0} ${result.currency ?? ""}
|
|
195
|
+
`);
|
|
196
|
+
} else {
|
|
197
|
+
console.log(`
|
|
198
|
+
Could not complete a confirmed booking. Nothing was charged.`);
|
|
199
|
+
console.log(` Booking link: ${result.booking_url ?? "(none)"}
|
|
200
|
+
`);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const br = result;
|
|
205
|
+
if (br.status === "confirmed") {
|
|
159
206
|
console.log(`
|
|
160
207
|
\u2713 Booking confirmed!`);
|
|
161
|
-
console.log(` PNR: ${
|
|
162
|
-
console.log(` Flight: ${
|
|
163
|
-
console.log(` Fee: ${
|
|
164
|
-
console.log(` Total: ${
|
|
165
|
-
console.log(` Order: ${
|
|
208
|
+
console.log(` PNR: ${br.booking_reference}`);
|
|
209
|
+
console.log(` Flight: ${br.currency} ${br.flight_price.toFixed(2)}`);
|
|
210
|
+
console.log(` Fee: ${br.currency} ${br.service_fee.toFixed(2)} (${br.service_fee_percentage}%)`);
|
|
211
|
+
console.log(` Total: ${br.currency} ${br.total_charged.toFixed(2)}`);
|
|
212
|
+
console.log(` Order: ${br.order_id}
|
|
166
213
|
`);
|
|
167
214
|
} else {
|
|
168
215
|
console.error(` \u2717 Booking failed`);
|
|
169
|
-
console.error(JSON.stringify(
|
|
216
|
+
console.error(JSON.stringify(br.details, null, 2));
|
|
170
217
|
process.exit(1);
|
|
171
218
|
}
|
|
172
219
|
}
|
|
@@ -198,6 +245,19 @@ async function cmdLocations(args) {
|
|
|
198
245
|
console.log(` ${iata} ${name} (${type}) \u2014 ${city}, ${country}`);
|
|
199
246
|
}
|
|
200
247
|
}
|
|
248
|
+
async function cmdAuth(args) {
|
|
249
|
+
const cardToken = getFlag(args, "--card-token");
|
|
250
|
+
const paymentMethodId = getFlag(args, "--payment-method");
|
|
251
|
+
const noBrowser = hasFlag(args, "--no-browser");
|
|
252
|
+
if (cardToken || paymentMethodId) {
|
|
253
|
+
const { verifyPaymentMethod } = await import("./auth-SYZ5AYZ6.mjs");
|
|
254
|
+
await verifyPaymentMethod({ cardToken: cardToken || void 0, paymentMethodId: paymentMethodId || void 0 });
|
|
255
|
+
console.log("\n \u2713 Authenticated. Nothing was charged.");
|
|
256
|
+
} else {
|
|
257
|
+
await paymentAuth(!noBrowser);
|
|
258
|
+
}
|
|
259
|
+
console.log("\n You're all set. Run: letsfg search WAW BCN 2026-07-15\n");
|
|
260
|
+
}
|
|
201
261
|
async function cmdRegister(args) {
|
|
202
262
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
203
263
|
const baseUrl = getFlag(args, "--base-url");
|
|
@@ -278,11 +338,11 @@ Authenticate once with letsfg auth \u2014 a zero-amount card setup, nothing is
|
|
|
278
338
|
charged \u2014 then search and book.
|
|
279
339
|
|
|
280
340
|
Commands:
|
|
281
|
-
auth
|
|
282
|
-
search <origin> <dest> <date>
|
|
283
|
-
locations <query>
|
|
284
|
-
book <offer_id> --
|
|
285
|
-
me
|
|
341
|
+
auth Put a card on file -> 90-day token. Nothing charged
|
|
342
|
+
search <origin> <dest> <date> Search for flights (free), prints search_id
|
|
343
|
+
locations <query> Resolve city name to IATA codes
|
|
344
|
+
book <offer_id> --search-id ... Book a flight. No LetsFG fee, no unlock step
|
|
345
|
+
me Show agent profile
|
|
286
346
|
|
|
287
347
|
Developer API only (a SEPARATE paid product \u2014 most agents should not use these;
|
|
288
348
|
they create a billing account. Use auth above instead):
|
|
@@ -291,20 +351,26 @@ they create a billing account. Use auth above instead):
|
|
|
291
351
|
unlock <offer_id> [Developer API only] Unlock offer \u2014 1% of ticket (min $3)
|
|
292
352
|
|
|
293
353
|
Options:
|
|
294
|
-
--json, -j
|
|
295
|
-
--api-key, -k
|
|
296
|
-
--base-url
|
|
354
|
+
--json, -j Output raw JSON
|
|
355
|
+
--api-key, -k Developer API key (or set LETSFG_API_KEY) \u2014 switches book/search to the paid path
|
|
356
|
+
--base-url API URL (default: https://letsfg.co)
|
|
357
|
+
--card-token (auth only) Stripe tok_... you already hold, for a headless auth
|
|
358
|
+
--payment-method (auth only) Stripe pm_... you already hold, for a headless auth
|
|
359
|
+
--no-browser (auth only) Don't try to auto-open the card setup page
|
|
297
360
|
|
|
298
361
|
Examples:
|
|
299
362
|
letsfg auth
|
|
300
363
|
letsfg search GDN BER 2026-03-03 --sort price
|
|
301
|
-
letsfg book off_xxx -p '{"given_name":"Ada"
|
|
364
|
+
letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"Ada","family_name":"Lovelace","born_on":"1990-04-01","gender":"f"}' -e ada@example.com
|
|
302
365
|
`;
|
|
303
366
|
async function main() {
|
|
304
367
|
const args = process.argv.slice(2);
|
|
305
368
|
const command = args.shift();
|
|
306
369
|
try {
|
|
307
370
|
switch (command) {
|
|
371
|
+
case "auth":
|
|
372
|
+
await cmdAuth(args);
|
|
373
|
+
break;
|
|
308
374
|
case "search":
|
|
309
375
|
await cmdSearch(args);
|
|
310
376
|
break;
|
|
@@ -338,7 +404,7 @@ async function main() {
|
|
|
338
404
|
process.exit(1);
|
|
339
405
|
}
|
|
340
406
|
} catch (e) {
|
|
341
|
-
if (e instanceof LetsFGError) {
|
|
407
|
+
if (e instanceof LetsFGError || e instanceof BearerTokenError) {
|
|
342
408
|
console.error(`Error: ${e.message}`);
|
|
343
409
|
process.exit(1);
|
|
344
410
|
}
|
package/dist/index.d.mts
CHANGED
|
@@ -472,14 +472,24 @@ declare class LetsFG {
|
|
|
472
472
|
resolveLocation(query: string): Promise<Array<Record<string, unknown>>>;
|
|
473
473
|
/**
|
|
474
474
|
* Unlock a flight offer — confirms live price, reveals direct airline booking URL.
|
|
475
|
-
* Cost: 1% of ticket price, min $3.
|
|
475
|
+
* Cost: 1% of ticket price, min $3. Developer API only — there is no unlock
|
|
476
|
+
* endpoint on a PFS Bearer token, so PFS callers use book() directly.
|
|
476
477
|
*/
|
|
477
478
|
unlock(offerId: string): Promise<UnlockResult>;
|
|
478
479
|
/**
|
|
479
|
-
* Book a flight
|
|
480
|
-
*
|
|
480
|
+
* Book a flight.
|
|
481
|
+
*
|
|
482
|
+
* PFS (Bearer token): POST /api/agent-book. Free — nothing charged beyond
|
|
483
|
+
* the ticket price. Pass searchId (from search()'s result). Returns either
|
|
484
|
+
* { ok, booked: true, order_id } or, when the booking genuinely could not
|
|
485
|
+
* complete, { ok, booked: false, booking_url } — hand the link to the user,
|
|
486
|
+
* nothing was charged.
|
|
487
|
+
*
|
|
488
|
+
* Developer API (X-API-Key): charges ticket price + service fee via Stripe,
|
|
489
|
+
* creates a real PNR. Requires unlock() first. Always provide
|
|
490
|
+
* idempotencyKey to prevent double-bookings on retry.
|
|
481
491
|
*/
|
|
482
|
-
book(offerId: string, passengers: Passenger[], contactEmail: string, contactPhone?: string, idempotencyKey?: string): Promise<BookingResult
|
|
492
|
+
book(offerId: string, passengers: Passenger[], contactEmail: string, contactPhone?: string, idempotencyKey?: string, searchId?: string): Promise<BookingResult | Record<string, unknown>>;
|
|
483
493
|
/**
|
|
484
494
|
* Resolve a place name to the city id that searchHotels() needs.
|
|
485
495
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -472,14 +472,24 @@ declare class LetsFG {
|
|
|
472
472
|
resolveLocation(query: string): Promise<Array<Record<string, unknown>>>;
|
|
473
473
|
/**
|
|
474
474
|
* Unlock a flight offer — confirms live price, reveals direct airline booking URL.
|
|
475
|
-
* Cost: 1% of ticket price, min $3.
|
|
475
|
+
* Cost: 1% of ticket price, min $3. Developer API only — there is no unlock
|
|
476
|
+
* endpoint on a PFS Bearer token, so PFS callers use book() directly.
|
|
476
477
|
*/
|
|
477
478
|
unlock(offerId: string): Promise<UnlockResult>;
|
|
478
479
|
/**
|
|
479
|
-
* Book a flight
|
|
480
|
-
*
|
|
480
|
+
* Book a flight.
|
|
481
|
+
*
|
|
482
|
+
* PFS (Bearer token): POST /api/agent-book. Free — nothing charged beyond
|
|
483
|
+
* the ticket price. Pass searchId (from search()'s result). Returns either
|
|
484
|
+
* { ok, booked: true, order_id } or, when the booking genuinely could not
|
|
485
|
+
* complete, { ok, booked: false, booking_url } — hand the link to the user,
|
|
486
|
+
* nothing was charged.
|
|
487
|
+
*
|
|
488
|
+
* Developer API (X-API-Key): charges ticket price + service fee via Stripe,
|
|
489
|
+
* creates a real PNR. Requires unlock() first. Always provide
|
|
490
|
+
* idempotencyKey to prevent double-bookings on retry.
|
|
481
491
|
*/
|
|
482
|
-
book(offerId: string, passengers: Passenger[], contactEmail: string, contactPhone?: string, idempotencyKey?: string): Promise<BookingResult
|
|
492
|
+
book(offerId: string, passengers: Passenger[], contactEmail: string, contactPhone?: string, idempotencyKey?: string, searchId?: string): Promise<BookingResult | Record<string, unknown>>;
|
|
483
493
|
/**
|
|
484
494
|
* Resolve a place name to the city id that searchHotels() needs.
|
|
485
495
|
*
|
package/dist/index.js
CHANGED
|
@@ -1677,18 +1677,44 @@ var LetsFG = class {
|
|
|
1677
1677
|
}
|
|
1678
1678
|
/**
|
|
1679
1679
|
* Unlock a flight offer — confirms live price, reveals direct airline booking URL.
|
|
1680
|
-
* Cost: 1% of ticket price, min $3.
|
|
1680
|
+
* Cost: 1% of ticket price, min $3. Developer API only — there is no unlock
|
|
1681
|
+
* endpoint on a PFS Bearer token, so PFS callers use book() directly.
|
|
1681
1682
|
*/
|
|
1682
1683
|
async unlock(offerId) {
|
|
1683
|
-
this.
|
|
1684
|
-
|
|
1685
|
-
return this.postWithAuth(path, { offer_id: offerId });
|
|
1684
|
+
this.requireApiKey();
|
|
1685
|
+
return this.post("/developers/api/v1/bookings/unlock", { offer_id: offerId });
|
|
1686
1686
|
}
|
|
1687
1687
|
/**
|
|
1688
|
-
* Book a flight
|
|
1689
|
-
*
|
|
1688
|
+
* Book a flight.
|
|
1689
|
+
*
|
|
1690
|
+
* PFS (Bearer token): POST /api/agent-book. Free — nothing charged beyond
|
|
1691
|
+
* the ticket price. Pass searchId (from search()'s result). Returns either
|
|
1692
|
+
* { ok, booked: true, order_id } or, when the booking genuinely could not
|
|
1693
|
+
* complete, { ok, booked: false, booking_url } — hand the link to the user,
|
|
1694
|
+
* nothing was charged.
|
|
1695
|
+
*
|
|
1696
|
+
* Developer API (X-API-Key): charges ticket price + service fee via Stripe,
|
|
1697
|
+
* creates a real PNR. Requires unlock() first. Always provide
|
|
1698
|
+
* idempotencyKey to prevent double-bookings on retry.
|
|
1690
1699
|
*/
|
|
1691
|
-
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "") {
|
|
1700
|
+
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "", searchId = "") {
|
|
1701
|
+
this.requireAuth();
|
|
1702
|
+
if (this.usingPFS) {
|
|
1703
|
+
if (!searchId) {
|
|
1704
|
+
throw new LetsFGError(
|
|
1705
|
+
"searchId is required to book via PFS \u2014 pass the search_id from search()'s result.",
|
|
1706
|
+
400
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
const passenger = { ...passengers[0] };
|
|
1710
|
+
if (contactPhone && !passenger.phone_number) passenger.phone_number = contactPhone;
|
|
1711
|
+
return this.postWithBearer("/api/agent-book", {
|
|
1712
|
+
search_id: searchId,
|
|
1713
|
+
offer_id: offerId,
|
|
1714
|
+
passenger,
|
|
1715
|
+
contact_email: contactEmail
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1692
1718
|
this.requireApiKey();
|
|
1693
1719
|
const body = {
|
|
1694
1720
|
offer_id: offerId,
|
package/dist/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letsfg",
|
|
3
|
-
"version": "2026.5.
|
|
3
|
+
"version": "2026.5.66",
|
|
4
4
|
"description": "Flights and hotels for AI agents. Server-side engine covers hundreds of airlines; hotels are real bookable inventory with free cancellation and pay-later terms. Includes open-source ranking engine.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --clean",
|
|
18
|
-
"test": "tsx --test src/index.test.ts",
|
|
18
|
+
"test": "tsx --test src/index.test.ts src/auth.test.ts",
|
|
19
19
|
"prepublishOnly": "npm run build"
|
|
20
20
|
},
|
|
21
21
|
"keywords": [
|