letsfg 2026.5.71 → 2026.5.72
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 +77 -17
- package/dist/cli.js +223 -157
- package/dist/cli.mjs +223 -21
- package/package.json +56 -56
- package/dist/auth-SYZ5AYZ6.mjs +0 -16
- package/dist/chunk-XNO2W77S.mjs +0 -126
package/README.md
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
| | **CLI / SDK** (this package) | **Developer API** |
|
|
11
11
|
|---|---|---|
|
|
12
|
-
| **Search cost** | Free (
|
|
13
|
-
| **Booking** | `POST /api/agent-book` —
|
|
12
|
+
| **Search cost** | Free (card-backed token from [letsfg.co/connect](https://letsfg.co/connect), nothing charged) | Prepaid credits |
|
|
13
|
+
| **Booking** | `POST /api/agent-book` — fare held on your card, a LetsFG agent buys the ticket, captured only on a real PNR. Every offer. | Direct airline URL (unlock required first) |
|
|
14
14
|
| **Speed** | 8–10 s to first results; longer on a split | 2–5 s (discover) · 8–10 s to first results (full) |
|
|
15
|
-
| **Setup** | `npm install letsfg
|
|
15
|
+
| **Setup** | `npm install letsfg`, then connect at [letsfg.co/developers/api/mcp](https://letsfg.co/developers/api/mcp) | [letsfg.co/developers](https://letsfg.co/developers) |
|
|
16
16
|
|
|
17
17
|
> **Want direct airline URLs without any per-booking fee?** Use the [Developer API](https://letsfg.co/developers) — prepaid credits, results in seconds, no checkout step.
|
|
18
18
|
|
|
@@ -22,13 +22,31 @@
|
|
|
22
22
|
npm install letsfg
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
## Getting a token
|
|
26
|
+
|
|
27
|
+
Connect LetsFG as an MCP server at `https://letsfg.co/developers/api/mcp` and
|
|
28
|
+
approve the connection — in Claude, ChatGPT, Cursor, Windsurf, or Claude Code
|
|
29
|
+
(`claude mcp add --transport http letsfg https://letsfg.co/developers/api/mcp`).
|
|
30
|
+
The consent step opens [letsfg.co/connect](https://letsfg.co/connect), where you
|
|
31
|
+
add a card (any card, or Revolut Pay / Google Pay) in a 0.00 Revolut setup.
|
|
32
|
+
Nothing is charged, no Revolut account is needed, and the card details go to
|
|
33
|
+
Revolut, never to LetsFG. The token you get back is card-backed: it searches
|
|
34
|
+
and it books. One card = one account; quotas are per card (10 searches per
|
|
35
|
+
10 min, 30 per hour, 100 per day — polling never counts).
|
|
36
|
+
|
|
37
|
+
Pass it as `bearerToken`, or set `LETSFG_BEARER_TOKEN` for the CLI.
|
|
38
|
+
|
|
39
|
+
> `letsfg auth` still runs the Stripe card setup that was retired on
|
|
40
|
+
> 2026-09-02 and cannot get a token today; every token issued that way was
|
|
41
|
+
> revoked (401 `TOKEN_REVOKED`). A connect-flow login for the CLI and SDKs is
|
|
42
|
+
> coming — until then, connect through the MCP.
|
|
43
|
+
|
|
25
44
|
## Quick Start (SDK)
|
|
26
45
|
|
|
27
46
|
```typescript
|
|
28
47
|
import { LetsFG, cheapestOffer, offerSummary } from 'letsfg';
|
|
29
48
|
|
|
30
|
-
// PFS — free.
|
|
31
|
-
// setup, nothing charged), then pass it here.
|
|
49
|
+
// PFS — free. The card-backed token from the connect flow (see above).
|
|
32
50
|
const bt = new LetsFG({ bearerToken: 'eyJ...' });
|
|
33
51
|
|
|
34
52
|
// Search — FREE
|
|
@@ -36,22 +54,62 @@ const flights = await bt.search('GDN', 'BER', '2026-03-03');
|
|
|
36
54
|
const best = cheapestOffer(flights);
|
|
37
55
|
console.log(offerSummary(best));
|
|
38
56
|
|
|
39
|
-
// Book —
|
|
57
|
+
// Book — ticket price only, no LetsFG fee, no unlock step. Starts the booking:
|
|
58
|
+
// the fare is HELD on your card and a LetsFG agent buys the ticket (4-11 min).
|
|
40
59
|
const result = await bt.book(
|
|
41
60
|
best.id,
|
|
42
|
-
[{
|
|
61
|
+
[{
|
|
62
|
+
given_name: 'John', family_name: 'Doe', born_on: '1990-01-15', gender: 'm',
|
|
63
|
+
nationality: 'GB', phone_number: '+447700900123', phone_country: 'GB',
|
|
64
|
+
address_line1: '1 Analytical Way', address_city: 'London',
|
|
65
|
+
address_postal: 'N1 9GU', address_country: 'GB',
|
|
66
|
+
}],
|
|
43
67
|
'john@example.com',
|
|
44
68
|
'',
|
|
45
69
|
'',
|
|
46
70
|
flights.search_id,
|
|
47
71
|
);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
const bookingRef = result.booking_ref as string;
|
|
73
|
+
|
|
74
|
+
// Poll until it lands (every 20-30 s): completed | failed | needs_attention
|
|
75
|
+
let status: Record<string, unknown>;
|
|
76
|
+
do {
|
|
77
|
+
await new Promise(r => setTimeout(r, 25_000));
|
|
78
|
+
status = await (await fetch('https://letsfg.co/api/agent-book/status', {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { Authorization: 'Bearer eyJ...', 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ booking_ref: bookingRef }),
|
|
82
|
+
})).json();
|
|
83
|
+
} while (status.state === 'booking_in_progress');
|
|
84
|
+
console.log(status); // { state: 'completed', pnr: 'ABC123', charged_amount: 93, currency: 'EUR' }
|
|
53
85
|
```
|
|
54
86
|
|
|
87
|
+
### How booking works
|
|
88
|
+
|
|
89
|
+
`bt.book()` posts to `POST /api/agent-book` and does exactly what the website
|
|
90
|
+
checkout does: the fare plus LetsFG's markup is **held** on the connected card
|
|
91
|
+
(not taken), a LetsFG booking agent buys the ticket from the seller, and the
|
|
92
|
+
hold is captured only once a real airline PNR exists. If the booking fails the
|
|
93
|
+
hold is released and nothing is charged. Every offer can be booked this way —
|
|
94
|
+
no unlock step, no booking-link fallback, no separate LetsFG fee.
|
|
95
|
+
|
|
96
|
+
The call returns within seconds with a `booking_ref`; the booking itself takes
|
|
97
|
+
4–11 minutes. Poll `POST /api/agent-book/status` with `{"booking_ref": ...}`
|
|
98
|
+
every 20–30 s (the SDK has no helper for this yet):
|
|
99
|
+
|
|
100
|
+
| `state` | Meaning |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `booking_in_progress` | the agent is at the seller's checkout — keep waiting |
|
|
103
|
+
| `completed` | booked — `pnr`, `charged_amount`, `currency` are in the answer |
|
|
104
|
+
| `failed` | not booked — the hold was released, nothing charged; see `failure_reason` |
|
|
105
|
+
| `needs_attention` | a human at LetsFG is checking it — do **not** book again |
|
|
106
|
+
|
|
107
|
+
One traveller per call, with the details an airline checkout asks for: name,
|
|
108
|
+
date of birth, gender, nationality, email, phone with its country, residence
|
|
109
|
+
address (passport optional). A missing detail returns `missing_details` with
|
|
110
|
+
`missing_fields` and charges nothing. Never start a second booking for the
|
|
111
|
+
same trip while one is in progress — that would place a second hold.
|
|
112
|
+
|
|
55
113
|
Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken` —
|
|
56
114
|
`search()`/`book()` dispatch automatically. That path requires `unlock()`
|
|
57
115
|
(1% fee, min $3) before `book()`.
|
|
@@ -59,11 +117,12 @@ Prefer the paid Developer API instead? Pass `apiKey` instead of `bearerToken`
|
|
|
59
117
|
## Quick Start (CLI)
|
|
60
118
|
|
|
61
119
|
```bash
|
|
62
|
-
export LETSFG_BEARER_TOKEN=<your-bearer-token> # from
|
|
120
|
+
export LETSFG_BEARER_TOKEN=<your-bearer-token> # card-backed, from the connect flow
|
|
63
121
|
|
|
64
122
|
letsfg search GDN BER 2026-03-03 --sort price
|
|
65
123
|
letsfg search LON BCN 2026-04-01 --json # Machine-readable
|
|
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
|
|
124
|
+
letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":"Doe","born_on":"1990-01-15","gender":"m","nationality":"GB","phone_number":"+447700900123","phone_country":"GB","address_line1":"1 Analytical Way","address_city":"London","address_postal":"N1 9GU","address_country":"GB"}' -e john@example.com
|
|
125
|
+
# prints the booking_ref — poll POST /api/agent-book/status until completed
|
|
67
126
|
```
|
|
68
127
|
|
|
69
128
|
## API
|
|
@@ -74,8 +133,9 @@ letsfg book off_xxx --search-id srch_xxx -p '{"given_name":"John","family_name":
|
|
|
74
133
|
### `bt.resolveLocation(query)`
|
|
75
134
|
### `bt.unlock(offerId)` — Developer API only
|
|
76
135
|
### `bt.book(offerId, passengers, contactEmail, contactPhone?, idempotencyKey?, searchId?)`
|
|
77
|
-
Dispatches on which credential is set: `bearerToken` →
|
|
78
|
-
`POST /api/agent-book` (pass `searchId`, one passenger
|
|
136
|
+
Dispatches on which credential is set: `bearerToken` → PFS booking via
|
|
137
|
+
`POST /api/agent-book` (pass `searchId`, one passenger with full details;
|
|
138
|
+
returns `booking_ref` — poll `POST /api/agent-book/status`). `apiKey` → paid
|
|
79
139
|
Developer API `book` (requires `unlock()` first, supports multiple passengers
|
|
80
140
|
and `idempotencyKey`).
|
|
81
141
|
### `bt.setupPayment(token?)` — Developer API only
|
|
@@ -118,7 +178,7 @@ MIT
|
|
|
118
178
|
|
|
119
179
|
## 🏨 Hotels — new, and live
|
|
120
180
|
|
|
121
|
-
Your agent can
|
|
181
|
+
Your agent can book hotels as well as flights. Same card-backed token or API key, same card on file.
|
|
122
182
|
|
|
123
183
|
```python
|
|
124
184
|
from letsfg import LetsFG
|
package/dist/cli.js
CHANGED
|
@@ -6,13 +6,6 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
6
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
7
|
var __getProtoOf = Object.getPrototypeOf;
|
|
8
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
9
|
var __copyProps = (to, from, except, desc) => {
|
|
17
10
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
18
11
|
for (let key of __getOwnPropNames(from))
|
|
@@ -30,139 +23,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
30
23
|
mod
|
|
31
24
|
));
|
|
32
25
|
|
|
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
|
-
});
|
|
165
|
-
|
|
166
26
|
// src/ranking.ts
|
|
167
27
|
var W = {
|
|
168
28
|
// Generic solo / default — price-driven
|
|
@@ -867,8 +727,213 @@ var LetsFG = class {
|
|
|
867
727
|
}
|
|
868
728
|
};
|
|
869
729
|
|
|
730
|
+
// src/auth.ts
|
|
731
|
+
var import_node_fs = require("fs");
|
|
732
|
+
var import_node_os = require("os");
|
|
733
|
+
var import_node_path = require("path");
|
|
734
|
+
var import_node_crypto = require("crypto");
|
|
735
|
+
var import_node_http = require("http");
|
|
736
|
+
var BASE_URL = process.env.LETSFG_BASE_URL || "https://letsfg.co";
|
|
737
|
+
var DEV_ROOT = `${BASE_URL}/developers/api`;
|
|
738
|
+
var FALLBACK_TTL_MS = 55 * 60 * 1e3;
|
|
739
|
+
var REFRESH_SKEW_MS = 5 * 60 * 1e3;
|
|
740
|
+
var BearerTokenError = class extends Error {
|
|
741
|
+
};
|
|
742
|
+
function configPath() {
|
|
743
|
+
const base = (0, import_node_os.platform)() === "win32" ? process.env.APPDATA || (0, import_node_os.homedir)() : (0, import_node_os.homedir)();
|
|
744
|
+
return (0, import_node_path.join)(base, ".letsfg", "config.json");
|
|
745
|
+
}
|
|
746
|
+
function loadConfig() {
|
|
747
|
+
const p = configPath();
|
|
748
|
+
if (!(0, import_node_fs.existsSync)(p)) return {};
|
|
749
|
+
try {
|
|
750
|
+
return JSON.parse((0, import_node_fs.readFileSync)(p, "utf-8"));
|
|
751
|
+
} catch {
|
|
752
|
+
return {};
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function saveConfig(cfg) {
|
|
756
|
+
const p = configPath();
|
|
757
|
+
(0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(p), { recursive: true });
|
|
758
|
+
(0, import_node_fs.writeFileSync)(p, JSON.stringify(cfg, null, 2));
|
|
759
|
+
try {
|
|
760
|
+
(0, import_node_fs.chmodSync)(p, 384);
|
|
761
|
+
} catch {
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function saveToken(token, expiresAt, extra) {
|
|
765
|
+
const cfg = loadConfig();
|
|
766
|
+
cfg.pfs_auth = {
|
|
767
|
+
...cfg.pfs_auth ?? {},
|
|
768
|
+
...extra ?? {},
|
|
769
|
+
token,
|
|
770
|
+
expires_at: expiresAt ?? Date.now() + FALLBACK_TTL_MS
|
|
771
|
+
};
|
|
772
|
+
saveConfig(cfg);
|
|
773
|
+
}
|
|
774
|
+
function getBearerToken() {
|
|
775
|
+
const env = process.env.LETSFG_BEARER_TOKEN;
|
|
776
|
+
if (env) return env;
|
|
777
|
+
const auth = loadConfig().pfs_auth;
|
|
778
|
+
if (auth?.token && Date.now() < auth.expires_at - REFRESH_SKEW_MS) {
|
|
779
|
+
return auth.token;
|
|
780
|
+
}
|
|
781
|
+
if (auth?.refresh_token) {
|
|
782
|
+
throw new BearerTokenError(
|
|
783
|
+
"LetsFG token expired. Call ensureBearerToken() to refresh it, or run: letsfg auth"
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
throw new BearerTokenError(
|
|
787
|
+
"No valid LetsFG Bearer token.\n Run: letsfg auth (connects a card at letsfg.co/connect - nothing is charged)\n Or: export LETSFG_BEARER_TOKEN=<token>"
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
async function discover() {
|
|
791
|
+
const fallback = {
|
|
792
|
+
authorization_endpoint: `${BASE_URL}/connect`,
|
|
793
|
+
token_endpoint: `${DEV_ROOT}/oauth/token`,
|
|
794
|
+
registration_endpoint: `${DEV_ROOT}/oauth/register`
|
|
795
|
+
};
|
|
796
|
+
try {
|
|
797
|
+
const resp = await fetch(`${DEV_ROOT}/.well-known/oauth-authorization-server`);
|
|
798
|
+
if (!resp.ok) return fallback;
|
|
799
|
+
const d = await resp.json();
|
|
800
|
+
return {
|
|
801
|
+
authorization_endpoint: d.authorization_endpoint || fallback.authorization_endpoint,
|
|
802
|
+
token_endpoint: d.token_endpoint || fallback.token_endpoint,
|
|
803
|
+
registration_endpoint: d.registration_endpoint || fallback.registration_endpoint
|
|
804
|
+
};
|
|
805
|
+
} catch {
|
|
806
|
+
return fallback;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function pkce() {
|
|
810
|
+
const verifier = (0, import_node_crypto.randomBytes)(32).toString("base64url");
|
|
811
|
+
const challenge = (0, import_node_crypto.createHash)("sha256").update(verifier).digest("base64url");
|
|
812
|
+
return { verifier, challenge };
|
|
813
|
+
}
|
|
814
|
+
async function postForm(url, form) {
|
|
815
|
+
const resp = await fetch(url, {
|
|
816
|
+
method: "POST",
|
|
817
|
+
// RFC 6749 4.1.3 - the token endpoint takes form encoding. The server also
|
|
818
|
+
// accepts JSON, but sending the spec encoding keeps this honest.
|
|
819
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Client-Type": "js-sdk" },
|
|
820
|
+
body: new URLSearchParams(form).toString()
|
|
821
|
+
});
|
|
822
|
+
const data = await resp.json().catch(() => ({}));
|
|
823
|
+
return { status: resp.status, data };
|
|
824
|
+
}
|
|
825
|
+
function openBrowser(url) {
|
|
826
|
+
import("child_process").then(({ exec }) => {
|
|
827
|
+
const cmd = (0, import_node_os.platform)() === "win32" ? `start "" "${url}"` : (0, import_node_os.platform)() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
828
|
+
exec(cmd, () => {
|
|
829
|
+
});
|
|
830
|
+
}).catch(() => {
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
function startCallbackServer(expectedState) {
|
|
834
|
+
return new Promise((resolve, reject) => {
|
|
835
|
+
let settle = null;
|
|
836
|
+
let fail = null;
|
|
837
|
+
const codePromise = new Promise((res, rej) => {
|
|
838
|
+
settle = res;
|
|
839
|
+
fail = rej;
|
|
840
|
+
});
|
|
841
|
+
const server = (0, import_node_http.createServer)((req, res) => {
|
|
842
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
843
|
+
if (!url.pathname.startsWith("/callback")) {
|
|
844
|
+
res.writeHead(404).end();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const err = url.searchParams.get("error");
|
|
848
|
+
const code = url.searchParams.get("code");
|
|
849
|
+
const state = url.searchParams.get("state");
|
|
850
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
851
|
+
if (err || !code) {
|
|
852
|
+
res.end("<h1>LetsFG</h1><p>That did not complete. Nothing was charged - run <code>letsfg auth</code> again.</p>");
|
|
853
|
+
fail?.(new BearerTokenError(`Authorisation failed: ${err || "no code returned"}`));
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (state !== expectedState) {
|
|
857
|
+
res.end("<h1>LetsFG</h1><p>State mismatch - nothing was charged. Run <code>letsfg auth</code> again.</p>");
|
|
858
|
+
fail?.(new BearerTokenError("State mismatch on the OAuth callback - aborted."));
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
res.end("<h1>LetsFG</h1><p>Connected. You can close this tab and go back to your terminal.</p>");
|
|
862
|
+
settle?.(code);
|
|
863
|
+
});
|
|
864
|
+
server.on("error", reject);
|
|
865
|
+
server.listen(0, "127.0.0.1", () => {
|
|
866
|
+
const addr = server.address();
|
|
867
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
868
|
+
if (!port) {
|
|
869
|
+
reject(new BearerTokenError("Could not open a local callback port."));
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
resolve({ port, waitForCode: () => codePromise, close: () => server.close() });
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
async function connectAuth(openBrowserFlag = true) {
|
|
877
|
+
const meta = await discover();
|
|
878
|
+
const { verifier, challenge } = pkce();
|
|
879
|
+
const state = (0, import_node_crypto.randomBytes)(16).toString("base64url");
|
|
880
|
+
const { port, waitForCode, close } = await startCallbackServer(state);
|
|
881
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
882
|
+
try {
|
|
883
|
+
const reg = await fetch(meta.registration_endpoint, {
|
|
884
|
+
method: "POST",
|
|
885
|
+
headers: { "Content-Type": "application/json" },
|
|
886
|
+
body: JSON.stringify({
|
|
887
|
+
client_name: "letsfg-cli",
|
|
888
|
+
redirect_uris: [redirectUri],
|
|
889
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
890
|
+
response_types: ["code"],
|
|
891
|
+
token_endpoint_auth_method: "none"
|
|
892
|
+
})
|
|
893
|
+
});
|
|
894
|
+
const regData = await reg.json().catch(() => ({}));
|
|
895
|
+
const clientId = typeof regData.client_id === "string" ? regData.client_id : "";
|
|
896
|
+
if (!reg.ok || !clientId) {
|
|
897
|
+
throw new BearerTokenError(
|
|
898
|
+
`Could not register with LetsFG (HTTP ${reg.status}). See ${BASE_URL}/for-agents`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
const authUrl = `${meta.authorization_endpoint}?response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256&state=${encodeURIComponent(state)}&scope=flights`;
|
|
902
|
+
console.log("\n LetsFG needs a card connected before it can search or book.");
|
|
903
|
+
console.log(" Nothing is charged now - you pay the fare only when you book,");
|
|
904
|
+
console.log(" and it is held, not taken, until the airline confirms.\n");
|
|
905
|
+
console.log(" Open this and add a card (or pay 0.00 with Revolut Pay):\n");
|
|
906
|
+
console.log(` ${authUrl}
|
|
907
|
+
`);
|
|
908
|
+
if (openBrowserFlag) openBrowser(authUrl);
|
|
909
|
+
process.stdout.write(" Waiting for you to finish... ");
|
|
910
|
+
const code = await waitForCode();
|
|
911
|
+
const { status, data } = await postForm(meta.token_endpoint, {
|
|
912
|
+
grant_type: "authorization_code",
|
|
913
|
+
code,
|
|
914
|
+
client_id: clientId,
|
|
915
|
+
redirect_uri: redirectUri,
|
|
916
|
+
code_verifier: verifier
|
|
917
|
+
});
|
|
918
|
+
if (status !== 200 || !data.access_token) {
|
|
919
|
+
throw new BearerTokenError(
|
|
920
|
+
`Could not complete authentication (HTTP ${status}). ${String(data.error_description || data.error || "")}`.trim()
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in * 1e3 : FALLBACK_TTL_MS;
|
|
924
|
+
saveToken(String(data.access_token), Date.now() + expiresIn, {
|
|
925
|
+
refresh_token: typeof data.refresh_token === "string" ? data.refresh_token : void 0,
|
|
926
|
+
client_id: clientId
|
|
927
|
+
});
|
|
928
|
+
console.log("done. Card connected - the token refreshes itself from now on.");
|
|
929
|
+
return String(data.access_token);
|
|
930
|
+
} finally {
|
|
931
|
+
close();
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
var paymentAuth = connectAuth;
|
|
935
|
+
|
|
870
936
|
// src/cli.ts
|
|
871
|
-
init_auth();
|
|
872
937
|
function resolveCredentials(apiKeyFlag) {
|
|
873
938
|
const apiKey = apiKeyFlag || process.env.LETSFG_API_KEY;
|
|
874
939
|
if (apiKey) return { apiKey };
|
|
@@ -1104,17 +1169,19 @@ async function cmdLocations(args) {
|
|
|
1104
1169
|
}
|
|
1105
1170
|
}
|
|
1106
1171
|
async function cmdAuth(args) {
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1172
|
+
for (const dead of ["--card-token", "--payment-method"]) {
|
|
1173
|
+
if (getFlag(args, dead)) {
|
|
1174
|
+
console.error(
|
|
1175
|
+
`
|
|
1176
|
+
${dead} was part of the Stripe enrolment, retired 2026-09-02.
|
|
1177
|
+
Run plain \`letsfg auth\` instead - it opens letsfg.co/connect to add a card.
|
|
1178
|
+
`
|
|
1179
|
+
);
|
|
1180
|
+
process.exit(1);
|
|
1181
|
+
}
|
|
1116
1182
|
}
|
|
1117
|
-
|
|
1183
|
+
await paymentAuth(!hasFlag(args, "--no-browser"));
|
|
1184
|
+
console.log("\n You are all set. Run: letsfg search WAW BCN 2026-07-15\n");
|
|
1118
1185
|
}
|
|
1119
1186
|
async function cmdRegister(args) {
|
|
1120
1187
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
@@ -1192,11 +1259,12 @@ var HELP = `
|
|
|
1192
1259
|
LetsFG \u2014 Agent-native flight search & booking.
|
|
1193
1260
|
|
|
1194
1261
|
Search hundreds of airlines via the LetsFG cloud engine.
|
|
1195
|
-
Authenticate once with letsfg auth \u2014
|
|
1196
|
-
charged
|
|
1262
|
+
Authenticate once with letsfg auth \u2014 it opens letsfg.co/connect so you can add
|
|
1263
|
+
a card. Nothing is charged; you pay the fare only when you book, and it is held,
|
|
1264
|
+
not taken, until the airline confirms.
|
|
1197
1265
|
|
|
1198
1266
|
Commands:
|
|
1199
|
-
auth
|
|
1267
|
+
auth Connect a card at letsfg.co/connect. Nothing charged
|
|
1200
1268
|
search <origin> <dest> <date> Search for flights (free), prints search_id
|
|
1201
1269
|
locations <query> Resolve city name to IATA codes
|
|
1202
1270
|
book <offer_id> --search-id ... Book a flight. No LetsFG fee, no unlock step
|
|
@@ -1212,9 +1280,7 @@ Options:
|
|
|
1212
1280
|
--json, -j Output raw JSON
|
|
1213
1281
|
--api-key, -k Developer API key (or set LETSFG_API_KEY) \u2014 switches book/search to the paid path
|
|
1214
1282
|
--base-url API URL (default: https://letsfg.co)
|
|
1215
|
-
--
|
|
1216
|
-
--payment-method (auth only) Stripe pm_... you already hold, for a headless auth
|
|
1217
|
-
--no-browser (auth only) Don't try to auto-open the card setup page
|
|
1283
|
+
--no-browser (auth only) Print the connect URL instead of opening a browser
|
|
1218
1284
|
|
|
1219
1285
|
Examples:
|
|
1220
1286
|
letsfg auth
|
package/dist/cli.mjs
CHANGED
|
@@ -4,11 +4,212 @@ import {
|
|
|
4
4
|
LetsFGError,
|
|
5
5
|
offerSummary
|
|
6
6
|
} from "./chunk-GO3FXQXC.mjs";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "
|
|
7
|
+
|
|
8
|
+
// src/auth.ts
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "fs";
|
|
10
|
+
import { homedir, platform } from "os";
|
|
11
|
+
import { join, dirname } from "path";
|
|
12
|
+
import { createHash, randomBytes } from "crypto";
|
|
13
|
+
import { createServer } from "http";
|
|
14
|
+
var BASE_URL = process.env.LETSFG_BASE_URL || "https://letsfg.co";
|
|
15
|
+
var DEV_ROOT = `${BASE_URL}/developers/api`;
|
|
16
|
+
var FALLBACK_TTL_MS = 55 * 60 * 1e3;
|
|
17
|
+
var REFRESH_SKEW_MS = 5 * 60 * 1e3;
|
|
18
|
+
var BearerTokenError = class extends Error {
|
|
19
|
+
};
|
|
20
|
+
function configPath() {
|
|
21
|
+
const base = platform() === "win32" ? process.env.APPDATA || homedir() : homedir();
|
|
22
|
+
return join(base, ".letsfg", "config.json");
|
|
23
|
+
}
|
|
24
|
+
function loadConfig() {
|
|
25
|
+
const p = configPath();
|
|
26
|
+
if (!existsSync(p)) return {};
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function saveConfig(cfg) {
|
|
34
|
+
const p = configPath();
|
|
35
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
36
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2));
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(p, 384);
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function saveToken(token, expiresAt, extra) {
|
|
43
|
+
const cfg = loadConfig();
|
|
44
|
+
cfg.pfs_auth = {
|
|
45
|
+
...cfg.pfs_auth ?? {},
|
|
46
|
+
...extra ?? {},
|
|
47
|
+
token,
|
|
48
|
+
expires_at: expiresAt ?? Date.now() + FALLBACK_TTL_MS
|
|
49
|
+
};
|
|
50
|
+
saveConfig(cfg);
|
|
51
|
+
}
|
|
52
|
+
function getBearerToken() {
|
|
53
|
+
const env = process.env.LETSFG_BEARER_TOKEN;
|
|
54
|
+
if (env) return env;
|
|
55
|
+
const auth = loadConfig().pfs_auth;
|
|
56
|
+
if (auth?.token && Date.now() < auth.expires_at - REFRESH_SKEW_MS) {
|
|
57
|
+
return auth.token;
|
|
58
|
+
}
|
|
59
|
+
if (auth?.refresh_token) {
|
|
60
|
+
throw new BearerTokenError(
|
|
61
|
+
"LetsFG token expired. Call ensureBearerToken() to refresh it, or run: letsfg auth"
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
throw new BearerTokenError(
|
|
65
|
+
"No valid LetsFG Bearer token.\n Run: letsfg auth (connects a card at letsfg.co/connect - nothing is charged)\n Or: export LETSFG_BEARER_TOKEN=<token>"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
async function discover() {
|
|
69
|
+
const fallback = {
|
|
70
|
+
authorization_endpoint: `${BASE_URL}/connect`,
|
|
71
|
+
token_endpoint: `${DEV_ROOT}/oauth/token`,
|
|
72
|
+
registration_endpoint: `${DEV_ROOT}/oauth/register`
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const resp = await fetch(`${DEV_ROOT}/.well-known/oauth-authorization-server`);
|
|
76
|
+
if (!resp.ok) return fallback;
|
|
77
|
+
const d = await resp.json();
|
|
78
|
+
return {
|
|
79
|
+
authorization_endpoint: d.authorization_endpoint || fallback.authorization_endpoint,
|
|
80
|
+
token_endpoint: d.token_endpoint || fallback.token_endpoint,
|
|
81
|
+
registration_endpoint: d.registration_endpoint || fallback.registration_endpoint
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
return fallback;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function pkce() {
|
|
88
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
89
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
90
|
+
return { verifier, challenge };
|
|
91
|
+
}
|
|
92
|
+
async function postForm(url, form) {
|
|
93
|
+
const resp = await fetch(url, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
// RFC 6749 4.1.3 - the token endpoint takes form encoding. The server also
|
|
96
|
+
// accepts JSON, but sending the spec encoding keeps this honest.
|
|
97
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Client-Type": "js-sdk" },
|
|
98
|
+
body: new URLSearchParams(form).toString()
|
|
99
|
+
});
|
|
100
|
+
const data = await resp.json().catch(() => ({}));
|
|
101
|
+
return { status: resp.status, data };
|
|
102
|
+
}
|
|
103
|
+
function openBrowser(url) {
|
|
104
|
+
import("child_process").then(({ exec }) => {
|
|
105
|
+
const cmd = platform() === "win32" ? `start "" "${url}"` : platform() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
106
|
+
exec(cmd, () => {
|
|
107
|
+
});
|
|
108
|
+
}).catch(() => {
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function startCallbackServer(expectedState) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
let settle = null;
|
|
114
|
+
let fail = null;
|
|
115
|
+
const codePromise = new Promise((res, rej) => {
|
|
116
|
+
settle = res;
|
|
117
|
+
fail = rej;
|
|
118
|
+
});
|
|
119
|
+
const server = createServer((req, res) => {
|
|
120
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
121
|
+
if (!url.pathname.startsWith("/callback")) {
|
|
122
|
+
res.writeHead(404).end();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const err = url.searchParams.get("error");
|
|
126
|
+
const code = url.searchParams.get("code");
|
|
127
|
+
const state = url.searchParams.get("state");
|
|
128
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
129
|
+
if (err || !code) {
|
|
130
|
+
res.end("<h1>LetsFG</h1><p>That did not complete. Nothing was charged - run <code>letsfg auth</code> again.</p>");
|
|
131
|
+
fail?.(new BearerTokenError(`Authorisation failed: ${err || "no code returned"}`));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (state !== expectedState) {
|
|
135
|
+
res.end("<h1>LetsFG</h1><p>State mismatch - nothing was charged. Run <code>letsfg auth</code> again.</p>");
|
|
136
|
+
fail?.(new BearerTokenError("State mismatch on the OAuth callback - aborted."));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
res.end("<h1>LetsFG</h1><p>Connected. You can close this tab and go back to your terminal.</p>");
|
|
140
|
+
settle?.(code);
|
|
141
|
+
});
|
|
142
|
+
server.on("error", reject);
|
|
143
|
+
server.listen(0, "127.0.0.1", () => {
|
|
144
|
+
const addr = server.address();
|
|
145
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
146
|
+
if (!port) {
|
|
147
|
+
reject(new BearerTokenError("Could not open a local callback port."));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
resolve({ port, waitForCode: () => codePromise, close: () => server.close() });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async function connectAuth(openBrowserFlag = true) {
|
|
155
|
+
const meta = await discover();
|
|
156
|
+
const { verifier, challenge } = pkce();
|
|
157
|
+
const state = randomBytes(16).toString("base64url");
|
|
158
|
+
const { port, waitForCode, close } = await startCallbackServer(state);
|
|
159
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
160
|
+
try {
|
|
161
|
+
const reg = await fetch(meta.registration_endpoint, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "Content-Type": "application/json" },
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
client_name: "letsfg-cli",
|
|
166
|
+
redirect_uris: [redirectUri],
|
|
167
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
168
|
+
response_types: ["code"],
|
|
169
|
+
token_endpoint_auth_method: "none"
|
|
170
|
+
})
|
|
171
|
+
});
|
|
172
|
+
const regData = await reg.json().catch(() => ({}));
|
|
173
|
+
const clientId = typeof regData.client_id === "string" ? regData.client_id : "";
|
|
174
|
+
if (!reg.ok || !clientId) {
|
|
175
|
+
throw new BearerTokenError(
|
|
176
|
+
`Could not register with LetsFG (HTTP ${reg.status}). See ${BASE_URL}/for-agents`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const authUrl = `${meta.authorization_endpoint}?response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256&state=${encodeURIComponent(state)}&scope=flights`;
|
|
180
|
+
console.log("\n LetsFG needs a card connected before it can search or book.");
|
|
181
|
+
console.log(" Nothing is charged now - you pay the fare only when you book,");
|
|
182
|
+
console.log(" and it is held, not taken, until the airline confirms.\n");
|
|
183
|
+
console.log(" Open this and add a card (or pay 0.00 with Revolut Pay):\n");
|
|
184
|
+
console.log(` ${authUrl}
|
|
185
|
+
`);
|
|
186
|
+
if (openBrowserFlag) openBrowser(authUrl);
|
|
187
|
+
process.stdout.write(" Waiting for you to finish... ");
|
|
188
|
+
const code = await waitForCode();
|
|
189
|
+
const { status, data } = await postForm(meta.token_endpoint, {
|
|
190
|
+
grant_type: "authorization_code",
|
|
191
|
+
code,
|
|
192
|
+
client_id: clientId,
|
|
193
|
+
redirect_uri: redirectUri,
|
|
194
|
+
code_verifier: verifier
|
|
195
|
+
});
|
|
196
|
+
if (status !== 200 || !data.access_token) {
|
|
197
|
+
throw new BearerTokenError(
|
|
198
|
+
`Could not complete authentication (HTTP ${status}). ${String(data.error_description || data.error || "")}`.trim()
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in * 1e3 : FALLBACK_TTL_MS;
|
|
202
|
+
saveToken(String(data.access_token), Date.now() + expiresIn, {
|
|
203
|
+
refresh_token: typeof data.refresh_token === "string" ? data.refresh_token : void 0,
|
|
204
|
+
client_id: clientId
|
|
205
|
+
});
|
|
206
|
+
console.log("done. Card connected - the token refreshes itself from now on.");
|
|
207
|
+
return String(data.access_token);
|
|
208
|
+
} finally {
|
|
209
|
+
close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
var paymentAuth = connectAuth;
|
|
12
213
|
|
|
13
214
|
// src/cli.ts
|
|
14
215
|
function resolveCredentials(apiKeyFlag) {
|
|
@@ -246,17 +447,19 @@ async function cmdLocations(args) {
|
|
|
246
447
|
}
|
|
247
448
|
}
|
|
248
449
|
async function cmdAuth(args) {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
450
|
+
for (const dead of ["--card-token", "--payment-method"]) {
|
|
451
|
+
if (getFlag(args, dead)) {
|
|
452
|
+
console.error(
|
|
453
|
+
`
|
|
454
|
+
${dead} was part of the Stripe enrolment, retired 2026-09-02.
|
|
455
|
+
Run plain \`letsfg auth\` instead - it opens letsfg.co/connect to add a card.
|
|
456
|
+
`
|
|
457
|
+
);
|
|
458
|
+
process.exit(1);
|
|
459
|
+
}
|
|
258
460
|
}
|
|
259
|
-
|
|
461
|
+
await paymentAuth(!hasFlag(args, "--no-browser"));
|
|
462
|
+
console.log("\n You are all set. Run: letsfg search WAW BCN 2026-07-15\n");
|
|
260
463
|
}
|
|
261
464
|
async function cmdRegister(args) {
|
|
262
465
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
@@ -334,11 +537,12 @@ var HELP = `
|
|
|
334
537
|
LetsFG \u2014 Agent-native flight search & booking.
|
|
335
538
|
|
|
336
539
|
Search hundreds of airlines via the LetsFG cloud engine.
|
|
337
|
-
Authenticate once with letsfg auth \u2014
|
|
338
|
-
charged
|
|
540
|
+
Authenticate once with letsfg auth \u2014 it opens letsfg.co/connect so you can add
|
|
541
|
+
a card. Nothing is charged; you pay the fare only when you book, and it is held,
|
|
542
|
+
not taken, until the airline confirms.
|
|
339
543
|
|
|
340
544
|
Commands:
|
|
341
|
-
auth
|
|
545
|
+
auth Connect a card at letsfg.co/connect. Nothing charged
|
|
342
546
|
search <origin> <dest> <date> Search for flights (free), prints search_id
|
|
343
547
|
locations <query> Resolve city name to IATA codes
|
|
344
548
|
book <offer_id> --search-id ... Book a flight. No LetsFG fee, no unlock step
|
|
@@ -354,9 +558,7 @@ Options:
|
|
|
354
558
|
--json, -j Output raw JSON
|
|
355
559
|
--api-key, -k Developer API key (or set LETSFG_API_KEY) \u2014 switches book/search to the paid path
|
|
356
560
|
--base-url API URL (default: https://letsfg.co)
|
|
357
|
-
--
|
|
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
|
|
561
|
+
--no-browser (auth only) Print the connect URL instead of opening a browser
|
|
360
562
|
|
|
361
563
|
Examples:
|
|
362
564
|
letsfg auth
|
package/package.json
CHANGED
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "letsfg",
|
|
3
|
-
"version": "2026.5.
|
|
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
|
-
"main": "dist/index.js",
|
|
6
|
-
"module": "dist/index.mjs",
|
|
7
|
-
"types": "dist/index.d.ts",
|
|
8
|
-
"bin": {
|
|
9
|
-
"letsfg": "dist/cli.js"
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"dist/",
|
|
13
|
-
"README.md",
|
|
14
|
-
"LICENSE"
|
|
15
|
-
],
|
|
16
|
-
"scripts": {
|
|
17
|
-
"build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --clean",
|
|
18
|
-
"test": "tsx --test src/index.test.ts src/auth.test.ts",
|
|
19
|
-
"prepublishOnly": "npm run build"
|
|
20
|
-
},
|
|
21
|
-
"keywords": [
|
|
22
|
-
"flights",
|
|
23
|
-
"travel",
|
|
24
|
-
"booking",
|
|
25
|
-
"agent",
|
|
26
|
-
"ai",
|
|
27
|
-
"cli",
|
|
28
|
-
"autonomous",
|
|
29
|
-
"mcp",
|
|
30
|
-
"openai",
|
|
31
|
-
"airline",
|
|
32
|
-
"ndc",
|
|
33
|
-
"search",
|
|
34
|
-
"ndc",
|
|
35
|
-
"hotels",
|
|
36
|
-
"hotel-booking",
|
|
37
|
-
"hotel-search",
|
|
38
|
-
"accommodation"
|
|
39
|
-
],
|
|
40
|
-
"author": "LetsFG",
|
|
41
|
-
"license": "MIT",
|
|
42
|
-
"repository": {
|
|
43
|
-
"type": "git",
|
|
44
|
-
"url": "https://github.com/LetsFG/LetsFG.git"
|
|
45
|
-
},
|
|
46
|
-
"homepage": "https://letsfg.co",
|
|
47
|
-
"devDependencies": {
|
|
48
|
-
"@types/node": "^25.3.3",
|
|
49
|
-
"tsup": "^8.0.0",
|
|
50
|
-
"tsx": "^4.19.0",
|
|
51
|
-
"typescript": "^5.3.0"
|
|
52
|
-
},
|
|
53
|
-
"engines": {
|
|
54
|
-
"node": ">=18.0.0"
|
|
55
|
-
}
|
|
56
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "letsfg",
|
|
3
|
+
"version": "2026.5.72",
|
|
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
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"letsfg": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --clean",
|
|
18
|
+
"test": "tsx --test src/index.test.ts src/auth.test.ts",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"flights",
|
|
23
|
+
"travel",
|
|
24
|
+
"booking",
|
|
25
|
+
"agent",
|
|
26
|
+
"ai",
|
|
27
|
+
"cli",
|
|
28
|
+
"autonomous",
|
|
29
|
+
"mcp",
|
|
30
|
+
"openai",
|
|
31
|
+
"airline",
|
|
32
|
+
"ndc",
|
|
33
|
+
"search",
|
|
34
|
+
"ndc",
|
|
35
|
+
"hotels",
|
|
36
|
+
"hotel-booking",
|
|
37
|
+
"hotel-search",
|
|
38
|
+
"accommodation"
|
|
39
|
+
],
|
|
40
|
+
"author": "LetsFG",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/LetsFG/LetsFG.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://letsfg.co",
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^25.3.3",
|
|
49
|
+
"tsup": "^8.0.0",
|
|
50
|
+
"tsx": "^4.19.0",
|
|
51
|
+
"typescript": "^5.3.0"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=18.0.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/auth-SYZ5AYZ6.mjs
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
};
|
package/dist/chunk-XNO2W77S.mjs
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
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
|
-
};
|