letsfg 2026.5.54 → 2026.5.56
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 +9 -98
- package/dist/chunk-3GLZR3AT.mjs +1770 -0
- package/dist/cli.js +274 -280
- package/dist/cli.mjs +15 -66
- package/dist/index.d.mts +302 -84
- package/dist/index.d.ts +302 -84
- package/dist/index.js +1527 -248
- package/dist/index.mjs +21 -7
- package/package.json +4 -2
- package/dist/chunk-LBHJ4GE4.mjs +0 -487
package/dist/cli.js
CHANGED
|
@@ -1,27 +1,171 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
var
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
|
|
4
|
+
// src/ranking.ts
|
|
5
|
+
var W = {
|
|
6
|
+
// Generic solo / default — price-driven
|
|
7
|
+
default: {
|
|
8
|
+
price: 0.34,
|
|
9
|
+
stops: 0.22,
|
|
10
|
+
duration: 0.12,
|
|
11
|
+
depTime: 0.08,
|
|
12
|
+
arrivalTime: 0.04,
|
|
13
|
+
baggage: 0.02,
|
|
14
|
+
savings: 0.06,
|
|
15
|
+
comfortHours: 0.04,
|
|
16
|
+
layover: 0.08
|
|
17
|
+
},
|
|
18
|
+
// Business — time and directness > price; long layovers are unacceptable
|
|
19
|
+
business_traveler: {
|
|
20
|
+
price: 0.1,
|
|
21
|
+
stops: 0.26,
|
|
22
|
+
duration: 0.2,
|
|
23
|
+
depTime: 0.18,
|
|
24
|
+
arrivalTime: 0.04,
|
|
25
|
+
baggage: 0.06,
|
|
26
|
+
savings: 0,
|
|
27
|
+
comfortHours: 0.06,
|
|
28
|
+
layover: 0.1
|
|
29
|
+
},
|
|
30
|
+
// Family — directness + baggage practicality; kids + 8h layover = nightmare
|
|
31
|
+
family: {
|
|
32
|
+
price: 0.12,
|
|
33
|
+
stops: 0.2,
|
|
34
|
+
duration: 0.16,
|
|
35
|
+
depTime: 0.08,
|
|
36
|
+
arrivalTime: 0.04,
|
|
37
|
+
baggage: 0.2,
|
|
38
|
+
savings: 0.04,
|
|
39
|
+
comfortHours: 0.08,
|
|
40
|
+
layover: 0.08
|
|
41
|
+
},
|
|
42
|
+
// Couple — balance of price, comfort, arrival time, savings
|
|
43
|
+
couple: {
|
|
44
|
+
price: 0.22,
|
|
45
|
+
stops: 0.2,
|
|
46
|
+
duration: 0.12,
|
|
47
|
+
depTime: 0.1,
|
|
48
|
+
arrivalTime: 0.14,
|
|
49
|
+
baggage: 0.02,
|
|
50
|
+
savings: 0.1,
|
|
51
|
+
comfortHours: 0.04,
|
|
52
|
+
layover: 0.06
|
|
53
|
+
},
|
|
54
|
+
// Honeymoon — direct > everything; no one wants a 10h layover on their honeymoon
|
|
55
|
+
honeymoon: {
|
|
56
|
+
price: 0.08,
|
|
57
|
+
stops: 0.28,
|
|
58
|
+
duration: 0.1,
|
|
59
|
+
depTime: 0.14,
|
|
60
|
+
arrivalTime: 0.18,
|
|
61
|
+
baggage: 0.02,
|
|
62
|
+
savings: 0.02,
|
|
63
|
+
comfortHours: 0.08,
|
|
64
|
+
layover: 0.1
|
|
65
|
+
},
|
|
66
|
+
// Special occasion — still prioritize smooth timing/directness, but keep price meaningful.
|
|
67
|
+
special_occasion: {
|
|
68
|
+
price: 0.14,
|
|
69
|
+
stops: 0.24,
|
|
70
|
+
duration: 0.1,
|
|
71
|
+
depTime: 0.12,
|
|
72
|
+
arrivalTime: 0.16,
|
|
73
|
+
baggage: 0.02,
|
|
74
|
+
savings: 0.06,
|
|
75
|
+
comfortHours: 0.08,
|
|
76
|
+
layover: 0.08
|
|
77
|
+
},
|
|
78
|
+
// Ski — bag essential (equipment); early arrival to maximize slopes
|
|
79
|
+
ski: {
|
|
80
|
+
price: 0.12,
|
|
81
|
+
stops: 0.16,
|
|
82
|
+
duration: 0.1,
|
|
83
|
+
depTime: 0.16,
|
|
84
|
+
arrivalTime: 0.08,
|
|
85
|
+
baggage: 0.24,
|
|
86
|
+
savings: 0.04,
|
|
87
|
+
comfortHours: 0.02,
|
|
88
|
+
layover: 0.08
|
|
89
|
+
},
|
|
90
|
+
// Beach — arrive early to enjoy the day; price matters
|
|
91
|
+
beach: {
|
|
92
|
+
price: 0.24,
|
|
93
|
+
stops: 0.16,
|
|
94
|
+
duration: 0.1,
|
|
95
|
+
depTime: 0.1,
|
|
96
|
+
arrivalTime: 0.14,
|
|
97
|
+
baggage: 0.1,
|
|
98
|
+
savings: 0.06,
|
|
99
|
+
comfortHours: 0.04,
|
|
100
|
+
layover: 0.06
|
|
101
|
+
},
|
|
102
|
+
// City break — maximize time on the ground; 2-day trip loses half a day to a 6h layover
|
|
103
|
+
city_break: {
|
|
104
|
+
price: 0.26,
|
|
105
|
+
stops: 0.2,
|
|
106
|
+
duration: 0.08,
|
|
107
|
+
depTime: 0.12,
|
|
108
|
+
arrivalTime: 0.16,
|
|
109
|
+
baggage: 0.02,
|
|
110
|
+
savings: 0.04,
|
|
111
|
+
comfortHours: 0.04,
|
|
112
|
+
layover: 0.08
|
|
113
|
+
},
|
|
114
|
+
// Quick flight — user explicitly wants shortest possible total duration
|
|
115
|
+
quick_flight: {
|
|
116
|
+
price: 0.14,
|
|
117
|
+
stops: 0.2,
|
|
118
|
+
duration: 0.4,
|
|
119
|
+
depTime: 0.06,
|
|
120
|
+
arrivalTime: 0.04,
|
|
121
|
+
baggage: 0.02,
|
|
122
|
+
savings: 0.04,
|
|
123
|
+
comfortHours: 0.04,
|
|
124
|
+
layover: 0.06
|
|
125
|
+
},
|
|
126
|
+
// Cheapest — user explicitly asked for lowest price; price overwhelms all other factors
|
|
127
|
+
cheapest: {
|
|
128
|
+
price: 0.88,
|
|
129
|
+
stops: 0.06,
|
|
130
|
+
duration: 0.03,
|
|
131
|
+
depTime: 0.01,
|
|
132
|
+
arrivalTime: 0.01,
|
|
133
|
+
baggage: 0.01,
|
|
134
|
+
savings: 0,
|
|
135
|
+
comfortHours: 0,
|
|
136
|
+
layover: 0
|
|
137
|
+
},
|
|
138
|
+
// Cheapest direct — user asked for BOTH cheapest AND direct/nonstop.
|
|
139
|
+
// Stops must dominate enough that a direct flight wins even if it costs more;
|
|
140
|
+
// price wins within the same stop tier (all directs sorted by price, all 1-stops sorted
|
|
141
|
+
// by price below them, etc.). The scoreStops delta between 0-stop (1.00) and 1-stop (0.40)
|
|
142
|
+
// is 0.60; with stops weight 0.38 that gap is worth 0.228, which means a 1-stop would
|
|
143
|
+
// need an enormous price advantage to beat a direct — effectively "direct first".
|
|
144
|
+
cheapest_direct: {
|
|
145
|
+
price: 0.52,
|
|
146
|
+
stops: 0.38,
|
|
147
|
+
duration: 0.04,
|
|
148
|
+
depTime: 0.02,
|
|
149
|
+
arrivalTime: 0.01,
|
|
150
|
+
baggage: 0.01,
|
|
151
|
+
savings: 0,
|
|
152
|
+
comfortHours: 0,
|
|
153
|
+
layover: 0.02
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
var PURPOSE_WEIGHT_PROFILES = {
|
|
157
|
+
honeymoon: W.honeymoon,
|
|
158
|
+
special_occasion: W.special_occasion,
|
|
159
|
+
business: W.business_traveler,
|
|
160
|
+
ski: W.ski,
|
|
161
|
+
beach: W.beach,
|
|
162
|
+
city_break: W.city_break,
|
|
163
|
+
family_holiday: W.family,
|
|
164
|
+
graduation: W.city_break,
|
|
165
|
+
concert_festival: W.city_break,
|
|
166
|
+
sports_event: W.city_break,
|
|
167
|
+
spring_break: W.beach
|
|
16
168
|
};
|
|
17
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
-
mod
|
|
24
|
-
));
|
|
25
169
|
|
|
26
170
|
// src/index.ts
|
|
27
171
|
var ErrorCode = {
|
|
@@ -134,7 +278,7 @@ var ValidationError = class extends LetsFGError {
|
|
|
134
278
|
function routeStr(route) {
|
|
135
279
|
if (!route.segments.length) return "";
|
|
136
280
|
const codes = [route.segments[0].origin, ...route.segments.map((s) => s.destination)];
|
|
137
|
-
return codes.join("
|
|
281
|
+
return codes.join(" -> ");
|
|
138
282
|
}
|
|
139
283
|
function durationHuman(seconds) {
|
|
140
284
|
const h = Math.floor(seconds / 3600);
|
|
@@ -147,79 +291,45 @@ function offerSummary(offer) {
|
|
|
147
291
|
const airline = offer.owner_airline || offer.airlines[0] || "?";
|
|
148
292
|
return `${offer.currency} ${offer.price.toFixed(2)} | ${airline} | ${route} | ${dur} | ${offer.outbound.stopovers} stop(s)`;
|
|
149
293
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
origin: origin.toUpperCase(),
|
|
154
|
-
destination: destination.toUpperCase(),
|
|
155
|
-
date_from: dateFrom,
|
|
156
|
-
adults: options.adults ?? 1,
|
|
157
|
-
children: options.children ?? 0,
|
|
158
|
-
currency: options.currency ?? "EUR",
|
|
159
|
-
limit: options.limit ?? 50,
|
|
160
|
-
return_date: options.returnDate,
|
|
161
|
-
cabin_class: options.cabinClass,
|
|
162
|
-
...options.maxBrowsers != null && { max_browsers: options.maxBrowsers },
|
|
163
|
-
...options.mode != null && { mode: options.mode }
|
|
164
|
-
});
|
|
165
|
-
return new Promise((resolve, reject) => {
|
|
166
|
-
const pythonCmd = process.platform === "win32" ? "python" : "python3";
|
|
167
|
-
const child = spawn(pythonCmd, ["-m", "letsfg.local"], {
|
|
168
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
169
|
-
});
|
|
170
|
-
let stdout = "";
|
|
171
|
-
let stderr = "";
|
|
172
|
-
child.stdout.on("data", (d) => {
|
|
173
|
-
stdout += d.toString();
|
|
174
|
-
});
|
|
175
|
-
child.stderr.on("data", (d) => {
|
|
176
|
-
stderr += d.toString();
|
|
177
|
-
});
|
|
178
|
-
child.on("close", (code) => {
|
|
179
|
-
try {
|
|
180
|
-
const data = JSON.parse(stdout);
|
|
181
|
-
if (data.error) reject(new LetsFGError(data.error));
|
|
182
|
-
else resolve(data);
|
|
183
|
-
} catch {
|
|
184
|
-
reject(new LetsFGError(
|
|
185
|
-
`Python search failed (code ${code}): ${stdout || stderr}
|
|
186
|
-
Make sure LetsFG is installed: pip install letsfg && playwright install chromium`
|
|
187
|
-
));
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
child.on("error", (err) => {
|
|
191
|
-
reject(new LetsFGError(
|
|
192
|
-
`Cannot start Python: ${err.message}
|
|
193
|
-
Install: pip install letsfg && playwright install chromium`
|
|
194
|
-
));
|
|
195
|
-
});
|
|
196
|
-
child.stdin.write(params);
|
|
197
|
-
child.stdin.end();
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
var DEFAULT_BASE_URL = "https://letsfg.co/developers";
|
|
294
|
+
var DEFAULT_BASE_URL = "https://letsfg.co";
|
|
295
|
+
var PFS_POLL_INTERVAL_MS = 1e4;
|
|
296
|
+
var PFS_POLL_TIMEOUT_MS = 12e4;
|
|
201
297
|
var LetsFG = class {
|
|
298
|
+
bearerToken;
|
|
202
299
|
apiKey;
|
|
203
300
|
baseUrl;
|
|
204
301
|
timeout;
|
|
205
302
|
constructor(config = {}) {
|
|
303
|
+
this.bearerToken = config.bearerToken || process.env.LETSFG_BEARER_TOKEN || "";
|
|
206
304
|
this.apiKey = config.apiKey || process.env.LETSFG_API_KEY || "";
|
|
207
305
|
this.baseUrl = (config.baseUrl || process.env.LETSFG_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
208
306
|
this.timeout = config.timeout || 3e4;
|
|
209
307
|
}
|
|
308
|
+
requireAuth() {
|
|
309
|
+
if (!this.bearerToken && !this.apiKey) {
|
|
310
|
+
throw new AuthenticationError(
|
|
311
|
+
"Authentication required. Set bearerToken (from `letsfg auth`) or apiKey in config, or set LETSFG_BEARER_TOKEN / LETSFG_API_KEY env vars."
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
210
315
|
requireApiKey() {
|
|
211
316
|
if (!this.apiKey) {
|
|
212
317
|
throw new AuthenticationError(
|
|
213
|
-
"API key required for this operation. Set apiKey in config or LETSFG_API_KEY env var
|
|
318
|
+
"Developer API key required for this operation. Set apiKey in config or LETSFG_API_KEY env var. Register at letsfg.co/developers."
|
|
214
319
|
);
|
|
215
320
|
}
|
|
216
321
|
}
|
|
322
|
+
/** True when using PFS Bearer token (free search path) */
|
|
323
|
+
get usingPFS() {
|
|
324
|
+
return !!this.bearerToken;
|
|
325
|
+
}
|
|
217
326
|
// ── Core methods ─────────────────────────────────────────────────────
|
|
218
327
|
/**
|
|
219
|
-
* Search for flights — FREE
|
|
328
|
+
* Search for flights — FREE.
|
|
220
329
|
*
|
|
221
|
-
* Uses
|
|
222
|
-
*
|
|
330
|
+
* Uses PFS (Bearer token) or Developer API (X-API-Key) depending on config.
|
|
331
|
+
* PFS: async polling (POST /api/search -> poll /api/results/<id> every 10s).
|
|
332
|
+
* Developer API: synchronous 60-90s call.
|
|
223
333
|
*
|
|
224
334
|
* @param origin - IATA code (e.g., "GDN", "LON")
|
|
225
335
|
* @param destination - IATA code (e.g., "BER", "BCN")
|
|
@@ -227,57 +337,62 @@ var LetsFG = class {
|
|
|
227
337
|
* @param options - Optional search parameters
|
|
228
338
|
*/
|
|
229
339
|
async search(origin, destination, dateFrom, options = {}) {
|
|
230
|
-
|
|
340
|
+
this.requireAuth();
|
|
341
|
+
const body = {
|
|
342
|
+
origin: origin.toUpperCase(),
|
|
343
|
+
destination: destination.toUpperCase(),
|
|
344
|
+
date_from: dateFrom,
|
|
345
|
+
adults: options.adults ?? 1,
|
|
346
|
+
children: options.children ?? 0,
|
|
347
|
+
currency: options.currency ?? "EUR",
|
|
348
|
+
limit: options.limit ?? 50
|
|
349
|
+
};
|
|
350
|
+
if (options.returnDate) body.return_date = options.returnDate;
|
|
351
|
+
if (options.cabinClass) body.cabin_class = options.cabinClass;
|
|
352
|
+
if (options.maxStopovers != null) body.max_stopovers = options.maxStopovers;
|
|
353
|
+
if (options.sort) body.sort = options.sort;
|
|
354
|
+
if (options.departureTimeFrom) body.departure_time_from = options.departureTimeFrom;
|
|
355
|
+
if (options.departureTimeTo) body.departure_time_to = options.departureTimeTo;
|
|
356
|
+
if (this.usingPFS) {
|
|
357
|
+
return this.searchPFS(body);
|
|
358
|
+
}
|
|
359
|
+
return this.post("/developers/api/v1/flights/search", body);
|
|
360
|
+
}
|
|
361
|
+
/** PFS path: POST /api/search -> poll /api/results/<id> */
|
|
362
|
+
async searchPFS(body) {
|
|
363
|
+
const { search_id } = await this.postWithBearer("/api/search", body);
|
|
364
|
+
const deadline = Date.now() + PFS_POLL_TIMEOUT_MS;
|
|
365
|
+
while (Date.now() < deadline) {
|
|
366
|
+
await new Promise((r) => setTimeout(r, PFS_POLL_INTERVAL_MS));
|
|
367
|
+
const result = await this.getNoAuth(
|
|
368
|
+
`/api/results/${search_id}`
|
|
369
|
+
);
|
|
370
|
+
if (result.status !== "pending") {
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
throw new LetsFGError("Search timed out after 120s. Try polling /api/results/<id> directly.", 504);
|
|
231
375
|
}
|
|
232
376
|
/**
|
|
233
|
-
* Resolve a city/airport name to IATA codes
|
|
377
|
+
* Resolve a city/airport name to IATA codes.
|
|
234
378
|
*/
|
|
235
379
|
async resolveLocation(query) {
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const child = spawn(pythonCmd, ["-m", "letsfg.local"], {
|
|
241
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
242
|
-
});
|
|
243
|
-
let stdout = "";
|
|
244
|
-
let stderr = "";
|
|
245
|
-
child.stdout.on("data", (d) => {
|
|
246
|
-
stdout += d.toString();
|
|
247
|
-
});
|
|
248
|
-
child.stderr.on("data", (d) => {
|
|
249
|
-
stderr += d.toString();
|
|
250
|
-
});
|
|
251
|
-
child.on("close", (code) => {
|
|
252
|
-
try {
|
|
253
|
-
const data = JSON.parse(stdout);
|
|
254
|
-
if (data.error) reject(new LetsFGError(data.error));
|
|
255
|
-
else resolve(Array.isArray(data) ? data : data.locations || [data]);
|
|
256
|
-
} catch {
|
|
257
|
-
reject(new LetsFGError(
|
|
258
|
-
`Location resolution failed (code ${code}): ${stdout || stderr}`
|
|
259
|
-
));
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
child.on("error", (err) => {
|
|
263
|
-
reject(new LetsFGError(`Cannot start Python: ${err.message}`));
|
|
264
|
-
});
|
|
265
|
-
child.stdin.write(params);
|
|
266
|
-
child.stdin.end();
|
|
267
|
-
});
|
|
380
|
+
this.requireAuth();
|
|
381
|
+
const path = this.usingPFS ? `/api/locations?q=${encodeURIComponent(query)}` : `/developers/api/v1/flights/locations/${encodeURIComponent(query)}`;
|
|
382
|
+
const data = await this.getWithAuth(path);
|
|
383
|
+
return Array.isArray(data) ? data : data.locations || [];
|
|
268
384
|
}
|
|
269
385
|
/**
|
|
270
|
-
* Unlock a flight offer —
|
|
271
|
-
*
|
|
386
|
+
* Unlock a flight offer — confirms live price, reveals direct airline booking URL.
|
|
387
|
+
* Cost: 1% of ticket price, min $3. Free with Developer API.
|
|
272
388
|
*/
|
|
273
389
|
async unlock(offerId) {
|
|
274
|
-
this.
|
|
275
|
-
|
|
390
|
+
this.requireAuth();
|
|
391
|
+
const path = this.usingPFS ? "/api/unlock" : "/developers/api/v1/bookings/unlock";
|
|
392
|
+
return this.postWithAuth(path, { offer_id: offerId });
|
|
276
393
|
}
|
|
277
394
|
/**
|
|
278
|
-
* Book a flight — charges ticket price via Stripe.
|
|
279
|
-
* Creates a real airline reservation with PNR.
|
|
280
|
-
*
|
|
395
|
+
* Book a flight via Developer API — charges ticket price via Stripe, creates real PNR.
|
|
281
396
|
* Always provide idempotencyKey to prevent double-bookings on retry.
|
|
282
397
|
*/
|
|
283
398
|
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "") {
|
|
@@ -290,113 +405,32 @@ var LetsFG = class {
|
|
|
290
405
|
contact_phone: contactPhone
|
|
291
406
|
};
|
|
292
407
|
if (idempotencyKey) body.idempotency_key = idempotencyKey;
|
|
293
|
-
return this.post("/api/v1/bookings/book", body);
|
|
408
|
+
return this.post("/developers/api/v1/bookings/book", body);
|
|
294
409
|
}
|
|
295
410
|
/**
|
|
296
|
-
* Set up payment method (required before booking).
|
|
411
|
+
* Set up payment method (required before unlock/booking).
|
|
297
412
|
*/
|
|
298
413
|
async setupPayment(token = "tok_visa") {
|
|
299
414
|
this.requireApiKey();
|
|
300
|
-
return this.post("/api/v1/agents/setup-payment", { token });
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* Start automated checkout — drives to payment page, NEVER submits payment.
|
|
304
|
-
*
|
|
305
|
-
* Requires unlock first. Returns progress with screenshot and
|
|
306
|
-
* booking URL for manual completion.
|
|
307
|
-
*
|
|
308
|
-
* @param offerId - Offer ID from search results
|
|
309
|
-
* @param passengers - Passenger details (use test data for safety)
|
|
310
|
-
* @param checkoutToken - Token from unlock() response
|
|
311
|
-
*/
|
|
312
|
-
async startCheckout(offerId, passengers, checkoutToken) {
|
|
313
|
-
this.requireApiKey();
|
|
314
|
-
return this.post("/api/v1/bookings/start-checkout", {
|
|
315
|
-
offer_id: offerId,
|
|
316
|
-
passengers,
|
|
317
|
-
checkout_token: checkoutToken
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
/**
|
|
321
|
-
* Start checkout locally via Python (runs on your machine).
|
|
322
|
-
* Requires: pip install letsfg && playwright install chromium
|
|
323
|
-
*
|
|
324
|
-
* @param offer - Full FlightOffer object from search results
|
|
325
|
-
* @param passengers - Passenger details
|
|
326
|
-
* @param checkoutToken - Token from unlock()
|
|
327
|
-
*/
|
|
328
|
-
async startCheckoutLocal(offer, passengers, checkoutToken) {
|
|
329
|
-
const { spawn } = await import("child_process");
|
|
330
|
-
const input = JSON.stringify({
|
|
331
|
-
__checkout: true,
|
|
332
|
-
offer,
|
|
333
|
-
passengers,
|
|
334
|
-
checkout_token: checkoutToken,
|
|
335
|
-
api_key: this.apiKey,
|
|
336
|
-
base_url: this.baseUrl
|
|
337
|
-
});
|
|
338
|
-
return new Promise((resolve, reject) => {
|
|
339
|
-
const pythonCmd = process.platform === "win32" ? "python" : "python3";
|
|
340
|
-
const child = spawn(pythonCmd, ["-m", "letsfg.local"], {
|
|
341
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
342
|
-
timeout: 18e4
|
|
343
|
-
});
|
|
344
|
-
let stdout = "";
|
|
345
|
-
let stderr = "";
|
|
346
|
-
child.stdout.on("data", (d) => {
|
|
347
|
-
stdout += d.toString();
|
|
348
|
-
});
|
|
349
|
-
child.stderr.on("data", (d) => {
|
|
350
|
-
stderr += d.toString();
|
|
351
|
-
});
|
|
352
|
-
child.on("close", (code) => {
|
|
353
|
-
try {
|
|
354
|
-
const data = JSON.parse(stdout);
|
|
355
|
-
if (data.error) reject(new LetsFGError(data.error));
|
|
356
|
-
else resolve(data);
|
|
357
|
-
} catch {
|
|
358
|
-
reject(new LetsFGError(`Checkout failed (code ${code}): ${stdout || stderr}`));
|
|
359
|
-
}
|
|
360
|
-
});
|
|
361
|
-
child.on("error", (err) => {
|
|
362
|
-
reject(new LetsFGError(`Cannot start Python: ${err.message}`));
|
|
363
|
-
});
|
|
364
|
-
child.stdin.write(input);
|
|
365
|
-
child.stdin.end();
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* Link GitHub account for FREE unlimited access.
|
|
370
|
-
*
|
|
371
|
-
* Star https://github.com/LetsFG/LetsFG, then call this with your username.
|
|
372
|
-
* Once verified, access is permanent.
|
|
373
|
-
*/
|
|
374
|
-
async linkGithub(githubUsername) {
|
|
375
|
-
this.requireApiKey();
|
|
376
|
-
return this.post("/api/v1/agents/link-github", { github_username: githubUsername });
|
|
415
|
+
return this.post("/developers/api/v1/agents/setup-payment", { token });
|
|
377
416
|
}
|
|
378
417
|
/**
|
|
379
418
|
* Get current agent profile and usage stats.
|
|
380
419
|
*/
|
|
381
420
|
async me() {
|
|
382
421
|
this.requireApiKey();
|
|
383
|
-
return this.get("/api/v1/agents/me");
|
|
422
|
+
return this.get("/developers/api/v1/agents/me");
|
|
384
423
|
}
|
|
385
424
|
// ── Static methods ───────────────────────────────────────────────────
|
|
386
425
|
/**
|
|
387
|
-
* Register a new agent — no
|
|
426
|
+
* Register a new Developer API agent — no auth needed.
|
|
388
427
|
*/
|
|
389
428
|
static async register(agentName, email, baseUrl, ownerName = "", description = "") {
|
|
390
429
|
const url = (baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
391
|
-
const resp = await fetch(`${url}/api/v1/agents/register`, {
|
|
430
|
+
const resp = await fetch(`${url}/developers/api/v1/agents/register`, {
|
|
392
431
|
method: "POST",
|
|
393
432
|
headers: { "Content-Type": "application/json" },
|
|
394
|
-
body: JSON.stringify({
|
|
395
|
-
agent_name: agentName,
|
|
396
|
-
email,
|
|
397
|
-
owner_name: ownerName,
|
|
398
|
-
description
|
|
399
|
-
})
|
|
433
|
+
body: JSON.stringify({ agent_name: agentName, email, owner_name: ownerName, description })
|
|
400
434
|
});
|
|
401
435
|
const data = await resp.json();
|
|
402
436
|
if (!resp.ok) {
|
|
@@ -409,28 +443,39 @@ var LetsFG = class {
|
|
|
409
443
|
return data;
|
|
410
444
|
}
|
|
411
445
|
// ── Internal ────────────────────────────────────────────────────────
|
|
446
|
+
async postWithBearer(path, body) {
|
|
447
|
+
return this.requestWithHeaders(path, "POST", { "Authorization": `Bearer ${this.bearerToken}` }, body);
|
|
448
|
+
}
|
|
449
|
+
async getNoAuth(path) {
|
|
450
|
+
return this.requestWithHeaders(path, "GET", {});
|
|
451
|
+
}
|
|
452
|
+
async getWithAuth(path) {
|
|
453
|
+
const headers = this.usingPFS ? { "Authorization": `Bearer ${this.bearerToken}` } : { "X-API-Key": this.apiKey };
|
|
454
|
+
return this.requestWithHeaders(path, "GET", headers);
|
|
455
|
+
}
|
|
456
|
+
async postWithAuth(path, body) {
|
|
457
|
+
const headers = this.usingPFS ? { "Authorization": `Bearer ${this.bearerToken}` } : { "X-API-Key": this.apiKey };
|
|
458
|
+
return this.requestWithHeaders(path, "POST", headers, body);
|
|
459
|
+
}
|
|
412
460
|
async post(path, body) {
|
|
413
|
-
return this.
|
|
414
|
-
method: "POST",
|
|
415
|
-
body: JSON.stringify(body)
|
|
416
|
-
});
|
|
461
|
+
return this.requestWithHeaders(path, "POST", { "X-API-Key": this.apiKey }, body);
|
|
417
462
|
}
|
|
418
463
|
async get(path) {
|
|
419
|
-
return this.
|
|
464
|
+
return this.requestWithHeaders(path, "GET", { "X-API-Key": this.apiKey });
|
|
420
465
|
}
|
|
421
|
-
async
|
|
466
|
+
async requestWithHeaders(path, method, extraHeaders, body) {
|
|
422
467
|
const controller = new AbortController();
|
|
423
468
|
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
424
469
|
try {
|
|
425
470
|
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
426
|
-
|
|
471
|
+
method,
|
|
427
472
|
headers: {
|
|
428
473
|
"Content-Type": "application/json",
|
|
429
|
-
"X-API-Key": this.apiKey,
|
|
430
474
|
"User-Agent": "LetsFG-js/0.1.0",
|
|
431
475
|
"X-Client-Type": "js-sdk",
|
|
432
|
-
...
|
|
476
|
+
...extraHeaders
|
|
433
477
|
},
|
|
478
|
+
...body != null ? { body: JSON.stringify(body) } : {},
|
|
434
479
|
signal: controller.signal
|
|
435
480
|
});
|
|
436
481
|
const data = await resp.json();
|
|
@@ -498,6 +543,8 @@ async function cmdSearch(args) {
|
|
|
498
543
|
const currency = getFlag(args, "--currency") || "EUR";
|
|
499
544
|
const limit = parseInt(getFlag(args, "--limit", "-l") || "20");
|
|
500
545
|
const sort = getFlag(args, "--sort") || "price";
|
|
546
|
+
const departureFrom = getFlag(args, "--departure-from");
|
|
547
|
+
const departureTo = getFlag(args, "--departure-to");
|
|
501
548
|
const [origin, destination, date] = args;
|
|
502
549
|
if (!origin || !destination || !date) {
|
|
503
550
|
console.error("Usage: letsfg search <origin> <destination> <date> [options]");
|
|
@@ -511,7 +558,9 @@ async function cmdSearch(args) {
|
|
|
511
558
|
maxStopovers: stops,
|
|
512
559
|
currency,
|
|
513
560
|
limit,
|
|
514
|
-
sort
|
|
561
|
+
sort,
|
|
562
|
+
departureTimeFrom: departureFrom,
|
|
563
|
+
departureTimeTo: departureTo
|
|
515
564
|
});
|
|
516
565
|
if (jsonOut) {
|
|
517
566
|
console.log(JSON.stringify({
|
|
@@ -663,50 +712,9 @@ async function cmdRegister(args) {
|
|
|
663
712
|
Save your API key:`);
|
|
664
713
|
console.log(` export LETSFG_API_KEY=${result.api_key}`);
|
|
665
714
|
console.log(`
|
|
666
|
-
Next:
|
|
667
|
-
console.log(` 1. Star https://github.com/LetsFG/LetsFG`);
|
|
668
|
-
console.log(` 2. letsfg star --github <your-github-username>
|
|
715
|
+
Next: letsfg search GDN BCN 2026-07-15
|
|
669
716
|
`);
|
|
670
717
|
}
|
|
671
|
-
async function cmdStar(args) {
|
|
672
|
-
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
673
|
-
const apiKey = getFlag(args, "--api-key", "-k");
|
|
674
|
-
const baseUrl = getFlag(args, "--base-url");
|
|
675
|
-
const github = getFlag(args, "--github", "-g");
|
|
676
|
-
if (!github) {
|
|
677
|
-
console.error("Usage: letsfg star --github <your-github-username>");
|
|
678
|
-
process.exit(1);
|
|
679
|
-
}
|
|
680
|
-
const bt = new LetsFG({ apiKey, baseUrl });
|
|
681
|
-
const result = await bt.linkGithub(github);
|
|
682
|
-
if (jsonOut) {
|
|
683
|
-
console.log(JSON.stringify(result, null, 2));
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
const status = result.status;
|
|
687
|
-
if (status === "verified") {
|
|
688
|
-
console.log(`
|
|
689
|
-
\u2713 GitHub star verified! Unlimited access granted.`);
|
|
690
|
-
console.log(` Username: ${result.github_username}`);
|
|
691
|
-
console.log(`
|
|
692
|
-
You're all set \u2014 search, unlock, and book for free.
|
|
693
|
-
`);
|
|
694
|
-
} else if (status === "already_verified") {
|
|
695
|
-
console.log(`
|
|
696
|
-
\u2713 Already verified! You have unlimited access.`);
|
|
697
|
-
console.log(` Username: ${result.github_username}
|
|
698
|
-
`);
|
|
699
|
-
} else if (status === "star_required") {
|
|
700
|
-
console.log(`
|
|
701
|
-
\u2717 Star not found for '${github}'.`);
|
|
702
|
-
console.log(` 1. Star the repo: https://github.com/LetsFG/LetsFG`);
|
|
703
|
-
console.log(` 2. Run this command again.
|
|
704
|
-
`);
|
|
705
|
-
} else {
|
|
706
|
-
console.error(` \u2717 Unexpected status: ${status}`);
|
|
707
|
-
process.exit(1);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
718
|
async function cmdSetupPayment(args) {
|
|
711
719
|
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
712
720
|
const apiKey = getFlag(args, "--api-key", "-k");
|
|
@@ -743,17 +751,8 @@ async function cmdMe(args) {
|
|
|
743
751
|
Agent: ${p.agent_name} (${p.agent_id})`);
|
|
744
752
|
console.log(` Email: ${p.email}`);
|
|
745
753
|
console.log(` Tier: ${p.tier}`);
|
|
746
|
-
const gh = p.github_username || "";
|
|
747
|
-
const starOk = p.github_star_verified || false;
|
|
748
|
-
if (starOk) {
|
|
749
|
-
console.log(` GitHub: \u2713 ${gh} (star verified)`);
|
|
750
|
-
} else if (gh) {
|
|
751
|
-
console.log(` GitHub: ${gh} (star not yet verified \u2014 run: letsfg star --github ${gh})`);
|
|
752
|
-
} else {
|
|
753
|
-
console.log(` GitHub: Not linked \u2014 run: letsfg star --github <username>`);
|
|
754
|
-
}
|
|
755
754
|
const access = p.access_granted || false;
|
|
756
|
-
console.log(` Access: ${access ? "\u2713 Granted (search, unlock, book)" : "\u2717 Not granted
|
|
755
|
+
console.log(` Access: ${access ? "\u2713 Granted (search, unlock, book)" : "\u2717 Not granted"}`);
|
|
757
756
|
console.log(` Payment: ${p.payment_ready ? "\u2713 Ready" : "\u2014"}`);
|
|
758
757
|
console.log(` Searches: ${u.total_searches || 0}`);
|
|
759
758
|
console.log(` Unlocks: ${u.total_unlocks || 0}`);
|
|
@@ -764,17 +763,16 @@ async function cmdMe(args) {
|
|
|
764
763
|
var HELP = `
|
|
765
764
|
LetsFG \u2014 Agent-native flight search & booking.
|
|
766
765
|
|
|
767
|
-
Search
|
|
768
|
-
|
|
766
|
+
Search hundreds of airlines via the LetsFG cloud engine.
|
|
767
|
+
Free search: authenticate once with Twitter/X, then search instantly.
|
|
769
768
|
|
|
770
769
|
Commands:
|
|
771
|
-
search <origin> <dest> <date> Search for flights (
|
|
770
|
+
search <origin> <dest> <date> Search for flights (free)
|
|
772
771
|
locations <query> Resolve city name to IATA codes
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
setup-payment Legacy payment setup
|
|
772
|
+
unlock <offer_id> Unlock offer \u2014 1% of ticket (min $3)
|
|
773
|
+
book <offer_id> --passenger ... Book flight \u2014 charges ticket price via Stripe
|
|
774
|
+
register --name ... --email ... Register for a Developer API key
|
|
775
|
+
setup-payment Attach Stripe payment method
|
|
778
776
|
me Show agent profile
|
|
779
777
|
|
|
780
778
|
Options:
|
|
@@ -784,7 +782,6 @@ Options:
|
|
|
784
782
|
|
|
785
783
|
Examples:
|
|
786
784
|
letsfg register --name my-agent --email me@example.com
|
|
787
|
-
letsfg star --github octocat
|
|
788
785
|
letsfg search GDN BER 2026-03-03 --sort price
|
|
789
786
|
letsfg unlock off_xxx
|
|
790
787
|
letsfg book off_xxx -p '{"id":"pas_xxx",...}' -e john@ex.com
|
|
@@ -809,9 +806,6 @@ async function main() {
|
|
|
809
806
|
case "register":
|
|
810
807
|
await cmdRegister(args);
|
|
811
808
|
break;
|
|
812
|
-
case "star":
|
|
813
|
-
await cmdStar(args);
|
|
814
|
-
break;
|
|
815
809
|
case "setup-payment":
|
|
816
810
|
await cmdSetupPayment(args);
|
|
817
811
|
break;
|