letsfg 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -0
- package/dist/chunk-LKAF7U4R.mjs +460 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +708 -0
- package/dist/cli.mjs +339 -0
- package/dist/index.d.mts +278 -0
- package/dist/index.d.ts +278 -0
- package/dist/index.js +511 -0
- package/dist/index.mjs +36 -0
- package/package.json +50 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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 __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
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
|
+
|
|
26
|
+
// src/index.ts
|
|
27
|
+
var ErrorCode = {
|
|
28
|
+
// Transient (safe to retry after short delay)
|
|
29
|
+
SUPPLIER_TIMEOUT: "SUPPLIER_TIMEOUT",
|
|
30
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
31
|
+
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
|
|
32
|
+
NETWORK_ERROR: "NETWORK_ERROR",
|
|
33
|
+
// Validation (fix input, then retry)
|
|
34
|
+
INVALID_IATA: "INVALID_IATA",
|
|
35
|
+
INVALID_DATE: "INVALID_DATE",
|
|
36
|
+
INVALID_PASSENGERS: "INVALID_PASSENGERS",
|
|
37
|
+
UNSUPPORTED_ROUTE: "UNSUPPORTED_ROUTE",
|
|
38
|
+
MISSING_PARAMETER: "MISSING_PARAMETER",
|
|
39
|
+
INVALID_PARAMETER: "INVALID_PARAMETER",
|
|
40
|
+
// Business (requires human decision)
|
|
41
|
+
AUTH_INVALID: "AUTH_INVALID",
|
|
42
|
+
PAYMENT_REQUIRED: "PAYMENT_REQUIRED",
|
|
43
|
+
PAYMENT_DECLINED: "PAYMENT_DECLINED",
|
|
44
|
+
OFFER_EXPIRED: "OFFER_EXPIRED",
|
|
45
|
+
OFFER_NOT_UNLOCKED: "OFFER_NOT_UNLOCKED",
|
|
46
|
+
FARE_CHANGED: "FARE_CHANGED",
|
|
47
|
+
ALREADY_BOOKED: "ALREADY_BOOKED",
|
|
48
|
+
BOOKING_FAILED: "BOOKING_FAILED"
|
|
49
|
+
};
|
|
50
|
+
var ErrorCategory = {
|
|
51
|
+
TRANSIENT: "transient",
|
|
52
|
+
VALIDATION: "validation",
|
|
53
|
+
BUSINESS: "business"
|
|
54
|
+
};
|
|
55
|
+
var CODE_TO_CATEGORY = {
|
|
56
|
+
[ErrorCode.SUPPLIER_TIMEOUT]: ErrorCategory.TRANSIENT,
|
|
57
|
+
[ErrorCode.RATE_LIMITED]: ErrorCategory.TRANSIENT,
|
|
58
|
+
[ErrorCode.SERVICE_UNAVAILABLE]: ErrorCategory.TRANSIENT,
|
|
59
|
+
[ErrorCode.NETWORK_ERROR]: ErrorCategory.TRANSIENT,
|
|
60
|
+
[ErrorCode.INVALID_IATA]: ErrorCategory.VALIDATION,
|
|
61
|
+
[ErrorCode.INVALID_DATE]: ErrorCategory.VALIDATION,
|
|
62
|
+
[ErrorCode.INVALID_PASSENGERS]: ErrorCategory.VALIDATION,
|
|
63
|
+
[ErrorCode.UNSUPPORTED_ROUTE]: ErrorCategory.VALIDATION,
|
|
64
|
+
[ErrorCode.MISSING_PARAMETER]: ErrorCategory.VALIDATION,
|
|
65
|
+
[ErrorCode.INVALID_PARAMETER]: ErrorCategory.VALIDATION,
|
|
66
|
+
[ErrorCode.AUTH_INVALID]: ErrorCategory.BUSINESS,
|
|
67
|
+
[ErrorCode.PAYMENT_REQUIRED]: ErrorCategory.BUSINESS,
|
|
68
|
+
[ErrorCode.PAYMENT_DECLINED]: ErrorCategory.BUSINESS,
|
|
69
|
+
[ErrorCode.OFFER_EXPIRED]: ErrorCategory.BUSINESS,
|
|
70
|
+
[ErrorCode.OFFER_NOT_UNLOCKED]: ErrorCategory.BUSINESS,
|
|
71
|
+
[ErrorCode.FARE_CHANGED]: ErrorCategory.BUSINESS,
|
|
72
|
+
[ErrorCode.ALREADY_BOOKED]: ErrorCategory.BUSINESS,
|
|
73
|
+
[ErrorCode.BOOKING_FAILED]: ErrorCategory.BUSINESS
|
|
74
|
+
};
|
|
75
|
+
function inferErrorCode(statusCode, detail) {
|
|
76
|
+
const d = detail.toLowerCase();
|
|
77
|
+
if (statusCode === 401) return ErrorCode.AUTH_INVALID;
|
|
78
|
+
if (statusCode === 402) return d.includes("declined") ? ErrorCode.PAYMENT_DECLINED : ErrorCode.PAYMENT_REQUIRED;
|
|
79
|
+
if (statusCode === 410) return ErrorCode.OFFER_EXPIRED;
|
|
80
|
+
if (statusCode === 422) {
|
|
81
|
+
if (d.includes("iata") || d.includes("airport")) return ErrorCode.INVALID_IATA;
|
|
82
|
+
if (d.includes("date")) return ErrorCode.INVALID_DATE;
|
|
83
|
+
if (d.includes("passenger")) return ErrorCode.INVALID_PASSENGERS;
|
|
84
|
+
if (d.includes("route")) return ErrorCode.UNSUPPORTED_ROUTE;
|
|
85
|
+
return ErrorCode.INVALID_PARAMETER;
|
|
86
|
+
}
|
|
87
|
+
if (statusCode === 429) return ErrorCode.RATE_LIMITED;
|
|
88
|
+
if (statusCode === 503) return ErrorCode.SERVICE_UNAVAILABLE;
|
|
89
|
+
if (statusCode === 504) return ErrorCode.SUPPLIER_TIMEOUT;
|
|
90
|
+
if (statusCode === 409) return ErrorCode.ALREADY_BOOKED;
|
|
91
|
+
return statusCode >= 500 ? ErrorCode.BOOKING_FAILED : ErrorCode.INVALID_PARAMETER;
|
|
92
|
+
}
|
|
93
|
+
var LetsFGError = class extends Error {
|
|
94
|
+
statusCode;
|
|
95
|
+
response;
|
|
96
|
+
errorCode;
|
|
97
|
+
errorCategory;
|
|
98
|
+
isRetryable;
|
|
99
|
+
constructor(message, statusCode = 0, response = {}, errorCode = "") {
|
|
100
|
+
super(message);
|
|
101
|
+
this.name = "LetsFGError";
|
|
102
|
+
this.statusCode = statusCode;
|
|
103
|
+
this.response = response;
|
|
104
|
+
this.errorCode = errorCode || response.error_code || "";
|
|
105
|
+
this.errorCategory = CODE_TO_CATEGORY[this.errorCode] || ErrorCategory.BUSINESS;
|
|
106
|
+
this.isRetryable = this.errorCategory === ErrorCategory.TRANSIENT;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var AuthenticationError = class extends LetsFGError {
|
|
110
|
+
constructor(message, response = {}) {
|
|
111
|
+
super(message, 401, response, ErrorCode.AUTH_INVALID);
|
|
112
|
+
this.name = "AuthenticationError";
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
var PaymentRequiredError = class extends LetsFGError {
|
|
116
|
+
constructor(message, response = {}) {
|
|
117
|
+
const code = message.toLowerCase().includes("declined") ? ErrorCode.PAYMENT_DECLINED : ErrorCode.PAYMENT_REQUIRED;
|
|
118
|
+
super(message, 402, response, code);
|
|
119
|
+
this.name = "PaymentRequiredError";
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var OfferExpiredError = class extends LetsFGError {
|
|
123
|
+
constructor(message, response = {}) {
|
|
124
|
+
super(message, 410, response, ErrorCode.OFFER_EXPIRED);
|
|
125
|
+
this.name = "OfferExpiredError";
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
var ValidationError = class extends LetsFGError {
|
|
129
|
+
constructor(message, statusCode = 422, response = {}, errorCode = "") {
|
|
130
|
+
super(message, statusCode, response, errorCode || ErrorCode.INVALID_PARAMETER);
|
|
131
|
+
this.name = "ValidationError";
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
function routeStr(route) {
|
|
135
|
+
if (!route.segments.length) return "";
|
|
136
|
+
const codes = [route.segments[0].origin, ...route.segments.map((s) => s.destination)];
|
|
137
|
+
return codes.join(" \u2192 ");
|
|
138
|
+
}
|
|
139
|
+
function durationHuman(seconds) {
|
|
140
|
+
const h = Math.floor(seconds / 3600);
|
|
141
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
142
|
+
return `${h}h${m.toString().padStart(2, "0")}m`;
|
|
143
|
+
}
|
|
144
|
+
function offerSummary(offer) {
|
|
145
|
+
const route = routeStr(offer.outbound);
|
|
146
|
+
const dur = durationHuman(offer.outbound.total_duration_seconds);
|
|
147
|
+
const airline = offer.owner_airline || offer.airlines[0] || "?";
|
|
148
|
+
return `${offer.currency} ${offer.price.toFixed(2)} | ${airline} | ${route} | ${dur} | ${offer.outbound.stopovers} stop(s)`;
|
|
149
|
+
}
|
|
150
|
+
var DEFAULT_BASE_URL = "https://api.letsfg.co";
|
|
151
|
+
var LetsFG = class {
|
|
152
|
+
apiKey;
|
|
153
|
+
baseUrl;
|
|
154
|
+
timeout;
|
|
155
|
+
constructor(config = {}) {
|
|
156
|
+
this.apiKey = config.apiKey || process.env.LETSFG_API_KEY || "";
|
|
157
|
+
this.baseUrl = (config.baseUrl || process.env.LETSFG_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
158
|
+
this.timeout = config.timeout || 3e4;
|
|
159
|
+
}
|
|
160
|
+
requireApiKey() {
|
|
161
|
+
if (!this.apiKey) {
|
|
162
|
+
throw new AuthenticationError(
|
|
163
|
+
"API key required for this operation. Set apiKey in config or LETSFG_API_KEY env var.\nNote: searchLocal() works without an API key."
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ── Core methods ─────────────────────────────────────────────────────
|
|
168
|
+
/**
|
|
169
|
+
* Search for flights — FREE, unlimited.
|
|
170
|
+
*
|
|
171
|
+
* @param origin - IATA code (e.g., "GDN", "LON")
|
|
172
|
+
* @param destination - IATA code (e.g., "BER", "BCN")
|
|
173
|
+
* @param dateFrom - Departure date "YYYY-MM-DD"
|
|
174
|
+
* @param options - Optional search parameters
|
|
175
|
+
*/
|
|
176
|
+
async search(origin, destination, dateFrom, options = {}) {
|
|
177
|
+
this.requireApiKey();
|
|
178
|
+
const body = {
|
|
179
|
+
origin: origin.toUpperCase(),
|
|
180
|
+
destination: destination.toUpperCase(),
|
|
181
|
+
date_from: dateFrom,
|
|
182
|
+
adults: options.adults ?? 1,
|
|
183
|
+
children: options.children ?? 0,
|
|
184
|
+
infants: options.infants ?? 0,
|
|
185
|
+
max_stopovers: options.maxStopovers ?? 2,
|
|
186
|
+
currency: options.currency ?? "EUR",
|
|
187
|
+
limit: options.limit ?? 20,
|
|
188
|
+
sort: options.sort ?? "price"
|
|
189
|
+
};
|
|
190
|
+
if (options.returnDate) body.return_from = options.returnDate;
|
|
191
|
+
if (options.cabinClass) body.cabin_class = options.cabinClass;
|
|
192
|
+
return this.post("/api/v1/flights/search", body);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Resolve a city/airport name to IATA codes.
|
|
196
|
+
*/
|
|
197
|
+
async resolveLocation(query) {
|
|
198
|
+
this.requireApiKey();
|
|
199
|
+
const data = await this.get(`/api/v1/flights/locations/${encodeURIComponent(query)}`);
|
|
200
|
+
if (Array.isArray(data)) return data;
|
|
201
|
+
if (data && Array.isArray(data.locations)) return data.locations;
|
|
202
|
+
return data ? [data] : [];
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Unlock a flight offer — $1 fee.
|
|
206
|
+
* Confirms price, reserves for 30 minutes.
|
|
207
|
+
*/
|
|
208
|
+
async unlock(offerId) {
|
|
209
|
+
this.requireApiKey();
|
|
210
|
+
return this.post("/api/v1/bookings/unlock", { offer_id: offerId });
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Book a flight — FREE after unlock.
|
|
214
|
+
* Creates a real airline reservation with PNR.
|
|
215
|
+
*
|
|
216
|
+
* Always provide idempotencyKey to prevent double-bookings on retry.
|
|
217
|
+
*/
|
|
218
|
+
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "") {
|
|
219
|
+
this.requireApiKey();
|
|
220
|
+
const body = {
|
|
221
|
+
offer_id: offerId,
|
|
222
|
+
booking_type: "flight",
|
|
223
|
+
passengers,
|
|
224
|
+
contact_email: contactEmail,
|
|
225
|
+
contact_phone: contactPhone
|
|
226
|
+
};
|
|
227
|
+
if (idempotencyKey) body.idempotency_key = idempotencyKey;
|
|
228
|
+
return this.post("/api/v1/bookings/book", body);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Set up payment method (payment token).
|
|
232
|
+
*/
|
|
233
|
+
async setupPayment(token = "tok_visa") {
|
|
234
|
+
this.requireApiKey();
|
|
235
|
+
return this.post("/api/v1/agents/setup-payment", { token });
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Start automated checkout — drives to payment page, NEVER submits payment.
|
|
239
|
+
*
|
|
240
|
+
* Requires unlock first ($1 fee). Returns progress with screenshot and
|
|
241
|
+
* booking URL for manual completion.
|
|
242
|
+
*
|
|
243
|
+
* @param offerId - Offer ID from search results
|
|
244
|
+
* @param passengers - Passenger details (use test data for safety)
|
|
245
|
+
* @param checkoutToken - Token from unlock() response
|
|
246
|
+
*/
|
|
247
|
+
async startCheckout(offerId, passengers, checkoutToken) {
|
|
248
|
+
this.requireApiKey();
|
|
249
|
+
return this.post("/api/v1/bookings/start-checkout", {
|
|
250
|
+
offer_id: offerId,
|
|
251
|
+
passengers,
|
|
252
|
+
checkout_token: checkoutToken
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Start checkout locally via Python (runs on your machine).
|
|
257
|
+
* Requires: pip install letsfg && playwright install chromium
|
|
258
|
+
*
|
|
259
|
+
* @param offer - Full FlightOffer object from search results
|
|
260
|
+
* @param passengers - Passenger details
|
|
261
|
+
* @param checkoutToken - Token from unlock()
|
|
262
|
+
*/
|
|
263
|
+
async startCheckoutLocal(offer, passengers, checkoutToken) {
|
|
264
|
+
const { spawn } = await import("child_process");
|
|
265
|
+
const input = JSON.stringify({
|
|
266
|
+
__checkout: true,
|
|
267
|
+
offer,
|
|
268
|
+
passengers,
|
|
269
|
+
checkout_token: checkoutToken,
|
|
270
|
+
api_key: this.apiKey,
|
|
271
|
+
base_url: this.baseUrl
|
|
272
|
+
});
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const pythonCmd = process.platform === "win32" ? "python" : "python3";
|
|
275
|
+
const child = spawn(pythonCmd, ["-m", "letsfg.local"], {
|
|
276
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
277
|
+
timeout: 18e4
|
|
278
|
+
});
|
|
279
|
+
let stdout = "";
|
|
280
|
+
let stderr = "";
|
|
281
|
+
child.stdout.on("data", (d) => {
|
|
282
|
+
stdout += d.toString();
|
|
283
|
+
});
|
|
284
|
+
child.stderr.on("data", (d) => {
|
|
285
|
+
stderr += d.toString();
|
|
286
|
+
});
|
|
287
|
+
child.on("close", (code) => {
|
|
288
|
+
try {
|
|
289
|
+
const data = JSON.parse(stdout);
|
|
290
|
+
if (data.error) reject(new LetsFGError(data.error));
|
|
291
|
+
else resolve(data);
|
|
292
|
+
} catch {
|
|
293
|
+
reject(new LetsFGError(`Checkout failed (code ${code}): ${stdout || stderr}`));
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
child.on("error", (err) => {
|
|
297
|
+
reject(new LetsFGError(`Cannot start Python: ${err.message}`));
|
|
298
|
+
});
|
|
299
|
+
child.stdin.write(input);
|
|
300
|
+
child.stdin.end();
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Get current agent profile and usage stats.
|
|
305
|
+
*/
|
|
306
|
+
async me() {
|
|
307
|
+
this.requireApiKey();
|
|
308
|
+
return this.get("/api/v1/agents/me");
|
|
309
|
+
}
|
|
310
|
+
// ── Static methods ───────────────────────────────────────────────────
|
|
311
|
+
/**
|
|
312
|
+
* Register a new agent — no API key needed.
|
|
313
|
+
*/
|
|
314
|
+
static async register(agentName, email, baseUrl, ownerName = "", description = "") {
|
|
315
|
+
const url = (baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
316
|
+
const resp = await fetch(`${url}/api/v1/agents/register`, {
|
|
317
|
+
method: "POST",
|
|
318
|
+
headers: { "Content-Type": "application/json" },
|
|
319
|
+
body: JSON.stringify({
|
|
320
|
+
agent_name: agentName,
|
|
321
|
+
email,
|
|
322
|
+
owner_name: ownerName,
|
|
323
|
+
description
|
|
324
|
+
})
|
|
325
|
+
});
|
|
326
|
+
const data = await resp.json();
|
|
327
|
+
if (!resp.ok) {
|
|
328
|
+
throw new LetsFGError(
|
|
329
|
+
data.detail || `Registration failed (${resp.status})`,
|
|
330
|
+
resp.status,
|
|
331
|
+
data
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return data;
|
|
335
|
+
}
|
|
336
|
+
// ── Internal ────────────────────────────────────────────────────────
|
|
337
|
+
async post(path, body) {
|
|
338
|
+
return this.request(path, {
|
|
339
|
+
method: "POST",
|
|
340
|
+
body: JSON.stringify(body)
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async get(path) {
|
|
344
|
+
return this.request(path, { method: "GET" });
|
|
345
|
+
}
|
|
346
|
+
async request(path, init) {
|
|
347
|
+
const controller = new AbortController();
|
|
348
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
349
|
+
try {
|
|
350
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
351
|
+
...init,
|
|
352
|
+
headers: {
|
|
353
|
+
"Content-Type": "application/json",
|
|
354
|
+
"X-API-Key": this.apiKey,
|
|
355
|
+
"User-Agent": "LetsFG-js/0.1.0",
|
|
356
|
+
...init.headers || {}
|
|
357
|
+
},
|
|
358
|
+
signal: controller.signal
|
|
359
|
+
});
|
|
360
|
+
const data = await resp.json();
|
|
361
|
+
if (!resp.ok) {
|
|
362
|
+
const detail = data.detail || `API error (${resp.status})`;
|
|
363
|
+
const code = data.error_code || inferErrorCode(resp.status, detail);
|
|
364
|
+
if (resp.status === 401) throw new AuthenticationError(detail, data);
|
|
365
|
+
if (resp.status === 402) throw new PaymentRequiredError(detail, data);
|
|
366
|
+
if (resp.status === 410) throw new OfferExpiredError(detail, data);
|
|
367
|
+
if (resp.status === 422) throw new ValidationError(detail, resp.status, data, code);
|
|
368
|
+
throw new LetsFGError(detail, resp.status, data, code);
|
|
369
|
+
}
|
|
370
|
+
return data;
|
|
371
|
+
} finally {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// src/cli.ts
|
|
378
|
+
function getFlag(args, flag, alias) {
|
|
379
|
+
for (let i = 0; i < args.length; i++) {
|
|
380
|
+
if (args[i] === flag || alias && args[i] === alias) {
|
|
381
|
+
const val = args[i + 1];
|
|
382
|
+
args.splice(i, 2);
|
|
383
|
+
return val;
|
|
384
|
+
}
|
|
385
|
+
if (args[i].startsWith(`${flag}=`)) {
|
|
386
|
+
const val = args[i].split("=").slice(1).join("=");
|
|
387
|
+
args.splice(i, 1);
|
|
388
|
+
return val;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return void 0;
|
|
392
|
+
}
|
|
393
|
+
function hasFlag(args, flag) {
|
|
394
|
+
const idx = args.indexOf(flag);
|
|
395
|
+
if (idx >= 0) {
|
|
396
|
+
args.splice(idx, 1);
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
function getAllFlags(args, flag, alias) {
|
|
402
|
+
const results = [];
|
|
403
|
+
let i = 0;
|
|
404
|
+
while (i < args.length) {
|
|
405
|
+
if (args[i] === flag || alias && args[i] === alias) {
|
|
406
|
+
results.push(args[i + 1]);
|
|
407
|
+
args.splice(i, 2);
|
|
408
|
+
} else {
|
|
409
|
+
i++;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return results;
|
|
413
|
+
}
|
|
414
|
+
async function cmdSearch(args) {
|
|
415
|
+
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
416
|
+
const apiKey = getFlag(args, "--api-key", "-k");
|
|
417
|
+
const baseUrl = getFlag(args, "--base-url");
|
|
418
|
+
const returnDate = getFlag(args, "--return", "-r");
|
|
419
|
+
const adults = parseInt(getFlag(args, "--adults", "-a") || "1");
|
|
420
|
+
const cabin = getFlag(args, "--cabin", "-c");
|
|
421
|
+
const stops = parseInt(getFlag(args, "--max-stops", "-s") || "2");
|
|
422
|
+
const currency = getFlag(args, "--currency") || "EUR";
|
|
423
|
+
const limit = parseInt(getFlag(args, "--limit", "-l") || "20");
|
|
424
|
+
const sort = getFlag(args, "--sort") || "price";
|
|
425
|
+
const [origin, destination, date] = args;
|
|
426
|
+
if (!origin || !destination || !date) {
|
|
427
|
+
console.error("Usage: letsfg search <origin> <destination> <date> [options]");
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
const bt = new LetsFG({ apiKey, baseUrl });
|
|
431
|
+
const result = await bt.search(origin, destination, date, {
|
|
432
|
+
returnDate,
|
|
433
|
+
adults,
|
|
434
|
+
cabinClass: cabin,
|
|
435
|
+
maxStopovers: stops,
|
|
436
|
+
currency,
|
|
437
|
+
limit,
|
|
438
|
+
sort
|
|
439
|
+
});
|
|
440
|
+
if (jsonOut) {
|
|
441
|
+
console.log(JSON.stringify({
|
|
442
|
+
passenger_ids: result.passenger_ids,
|
|
443
|
+
total_results: result.total_results,
|
|
444
|
+
offers: result.offers.map((o) => ({
|
|
445
|
+
id: o.id,
|
|
446
|
+
price: o.price,
|
|
447
|
+
currency: o.currency,
|
|
448
|
+
airlines: o.airlines,
|
|
449
|
+
owner_airline: o.owner_airline,
|
|
450
|
+
route: [o.outbound.segments[0]?.origin, ...o.outbound.segments.map((s) => s.destination)].join(" \u2192 "),
|
|
451
|
+
duration_seconds: o.outbound.total_duration_seconds,
|
|
452
|
+
stopovers: o.outbound.stopovers,
|
|
453
|
+
conditions: o.conditions,
|
|
454
|
+
is_locked: o.is_locked
|
|
455
|
+
}))
|
|
456
|
+
}, null, 2));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (!result.offers.length) {
|
|
460
|
+
console.log(`No flights found for ${origin} \u2192 ${destination} on ${date}`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
console.log(`
|
|
464
|
+
${result.total_results} offers | ${origin} \u2192 ${destination} | ${date}`);
|
|
465
|
+
console.log(` Passenger IDs: ${JSON.stringify(result.passenger_ids)}
|
|
466
|
+
`);
|
|
467
|
+
result.offers.forEach((o, i) => {
|
|
468
|
+
console.log(` ${(i + 1).toString().padStart(3)}. ${offerSummary(o)}`);
|
|
469
|
+
console.log(` ID: ${o.id}`);
|
|
470
|
+
});
|
|
471
|
+
console.log(`
|
|
472
|
+
To unlock: letsfg unlock <offer_id>`);
|
|
473
|
+
console.log(` Passenger IDs needed for booking: ${JSON.stringify(result.passenger_ids)}
|
|
474
|
+
`);
|
|
475
|
+
}
|
|
476
|
+
async function cmdUnlock(args) {
|
|
477
|
+
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
478
|
+
const apiKey = getFlag(args, "--api-key", "-k");
|
|
479
|
+
const baseUrl = getFlag(args, "--base-url");
|
|
480
|
+
const offerId = args[0];
|
|
481
|
+
if (!offerId) {
|
|
482
|
+
console.error("Usage: letsfg unlock <offer_id>");
|
|
483
|
+
process.exit(1);
|
|
484
|
+
}
|
|
485
|
+
const bt = new LetsFG({ apiKey, baseUrl });
|
|
486
|
+
const result = await bt.unlock(offerId);
|
|
487
|
+
if (jsonOut) {
|
|
488
|
+
console.log(JSON.stringify(result, null, 2));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (result.unlock_status === "unlocked") {
|
|
492
|
+
console.log(`
|
|
493
|
+
\u2713 Offer unlocked!`);
|
|
494
|
+
console.log(` Confirmed price: ${result.confirmed_currency} ${result.confirmed_price?.toFixed(2)}`);
|
|
495
|
+
console.log(` Expires at: ${result.offer_expires_at}`);
|
|
496
|
+
console.log(` $1 unlock fee charged`);
|
|
497
|
+
console.log(`
|
|
498
|
+
Next: letsfg book ${offerId} --passenger '{...}' --email you@example.com
|
|
499
|
+
`);
|
|
500
|
+
} else {
|
|
501
|
+
console.error(` \u2717 Unlock failed: ${result.message}`);
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async function cmdBook(args) {
|
|
506
|
+
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
507
|
+
const apiKey = getFlag(args, "--api-key", "-k");
|
|
508
|
+
const baseUrl = getFlag(args, "--base-url");
|
|
509
|
+
const email = getFlag(args, "--email", "-e") || "";
|
|
510
|
+
const phone = getFlag(args, "--phone") || "";
|
|
511
|
+
const passengerStrs = getAllFlags(args, "--passenger", "-p");
|
|
512
|
+
const offerId = args[0];
|
|
513
|
+
if (!offerId || !passengerStrs.length || !email) {
|
|
514
|
+
console.error(`Usage: letsfg book <offer_id> --passenger '{"id":"pas_xxx",...}' --email you@example.com`);
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
const passengers = passengerStrs.map((s) => JSON.parse(s));
|
|
518
|
+
const bt = new LetsFG({ apiKey, baseUrl });
|
|
519
|
+
const result = await bt.book(offerId, passengers, email, phone);
|
|
520
|
+
if (jsonOut) {
|
|
521
|
+
console.log(JSON.stringify(result, null, 2));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (result.status === "confirmed") {
|
|
525
|
+
console.log(`
|
|
526
|
+
\u2713 Booking confirmed!`);
|
|
527
|
+
console.log(` PNR: ${result.booking_reference}`);
|
|
528
|
+
console.log(` Flight: ${result.currency} ${result.flight_price.toFixed(2)}`);
|
|
529
|
+
console.log(` Fee: ${result.currency} ${result.service_fee.toFixed(2)} (${result.service_fee_percentage}%)`);
|
|
530
|
+
console.log(` Total: ${result.currency} ${result.total_charged.toFixed(2)}`);
|
|
531
|
+
console.log(` Order: ${result.order_id}
|
|
532
|
+
`);
|
|
533
|
+
} else {
|
|
534
|
+
console.error(` \u2717 Booking failed`);
|
|
535
|
+
console.error(JSON.stringify(result.details, null, 2));
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async function cmdLocations(args) {
|
|
540
|
+
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
541
|
+
const apiKey = getFlag(args, "--api-key", "-k");
|
|
542
|
+
const baseUrl = getFlag(args, "--base-url");
|
|
543
|
+
const query = args[0];
|
|
544
|
+
if (!query) {
|
|
545
|
+
console.error("Usage: letsfg locations <city-or-airport-name>");
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
const bt = new LetsFG({ apiKey, baseUrl });
|
|
549
|
+
const result = await bt.resolveLocation(query);
|
|
550
|
+
if (jsonOut) {
|
|
551
|
+
console.log(JSON.stringify(result, null, 2));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (!result.length) {
|
|
555
|
+
console.log(`No locations found for '${query}'`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
for (const loc of result) {
|
|
559
|
+
const iata = (loc.iata_code || "???").padEnd(5);
|
|
560
|
+
const name = loc.name || "";
|
|
561
|
+
const type = loc.type || "";
|
|
562
|
+
const city = loc.city_name || "";
|
|
563
|
+
const country = loc.country || "";
|
|
564
|
+
console.log(` ${iata} ${name} (${type}) \u2014 ${city}, ${country}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async function cmdRegister(args) {
|
|
568
|
+
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
569
|
+
const baseUrl = getFlag(args, "--base-url");
|
|
570
|
+
const name = getFlag(args, "--name", "-n");
|
|
571
|
+
const email = getFlag(args, "--email", "-e");
|
|
572
|
+
const owner = getFlag(args, "--owner") || "";
|
|
573
|
+
const desc = getFlag(args, "--desc") || "";
|
|
574
|
+
if (!name || !email) {
|
|
575
|
+
console.error("Usage: letsfg register --name my-agent --email agent@example.com");
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
578
|
+
const result = await LetsFG.register(name, email, baseUrl, owner, desc);
|
|
579
|
+
if (jsonOut) {
|
|
580
|
+
console.log(JSON.stringify(result, null, 2));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
console.log(`
|
|
584
|
+
\u2713 Agent registered!`);
|
|
585
|
+
console.log(` Agent ID: ${result.agent_id}`);
|
|
586
|
+
console.log(` API Key: ${result.api_key}`);
|
|
587
|
+
console.log(`
|
|
588
|
+
Save your API key:`);
|
|
589
|
+
console.log(` export LETSFG_API_KEY=${result.api_key}`);
|
|
590
|
+
console.log(`
|
|
591
|
+
Next: LetsFG setup-payment --token tok_visa
|
|
592
|
+
`);
|
|
593
|
+
}
|
|
594
|
+
async function cmdSetupPayment(args) {
|
|
595
|
+
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
596
|
+
const apiKey = getFlag(args, "--api-key", "-k");
|
|
597
|
+
const baseUrl = getFlag(args, "--base-url");
|
|
598
|
+
const token = getFlag(args, "--token", "-t") || "tok_visa";
|
|
599
|
+
const bt = new LetsFG({ apiKey, baseUrl });
|
|
600
|
+
const result = await bt.setupPayment(token);
|
|
601
|
+
if (jsonOut) {
|
|
602
|
+
console.log(JSON.stringify(result, null, 2));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (result.status === "ready") {
|
|
606
|
+
console.log(`
|
|
607
|
+
\u2713 Payment ready! You can now unlock offers and book flights.
|
|
608
|
+
`);
|
|
609
|
+
} else {
|
|
610
|
+
console.error(` \u2717 Payment setup failed: ${result.message || result.status}`);
|
|
611
|
+
process.exit(1);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async function cmdMe(args) {
|
|
615
|
+
const jsonOut = hasFlag(args, "--json") || hasFlag(args, "-j");
|
|
616
|
+
const apiKey = getFlag(args, "--api-key", "-k");
|
|
617
|
+
const baseUrl = getFlag(args, "--base-url");
|
|
618
|
+
const bt = new LetsFG({ apiKey, baseUrl });
|
|
619
|
+
const profile = await bt.me();
|
|
620
|
+
if (jsonOut) {
|
|
621
|
+
console.log(JSON.stringify(profile, null, 2));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const p = profile;
|
|
625
|
+
const u = p.usage || {};
|
|
626
|
+
console.log(`
|
|
627
|
+
Agent: ${p.agent_name} (${p.agent_id})`);
|
|
628
|
+
console.log(` Email: ${p.email}`);
|
|
629
|
+
console.log(` Tier: ${p.tier}`);
|
|
630
|
+
console.log(` Payment: ${p.payment_ready ? "\u2713 Ready" : "\u2717 Not set up"}`);
|
|
631
|
+
console.log(` Searches: ${u.total_searches || 0}`);
|
|
632
|
+
console.log(` Unlocks: ${u.total_unlocks || 0}`);
|
|
633
|
+
console.log(` Bookings: ${u.total_bookings || 0}`);
|
|
634
|
+
console.log(` Total spent: $${((u.total_spent_cents || 0) / 100).toFixed(2)}
|
|
635
|
+
`);
|
|
636
|
+
}
|
|
637
|
+
var HELP = `
|
|
638
|
+
LetsFG \u2014 Agent-native flight search & booking.
|
|
639
|
+
|
|
640
|
+
Search 400+ airlines at raw airline prices \u2014 $20-50 cheaper than OTAs.
|
|
641
|
+
Search is FREE. Unlock: $1. Book: FREE after unlock.
|
|
642
|
+
|
|
643
|
+
Commands:
|
|
644
|
+
search <origin> <dest> <date> Search for flights (FREE)
|
|
645
|
+
locations <query> Resolve city name to IATA codes
|
|
646
|
+
unlock <offer_id> Unlock offer ($1)
|
|
647
|
+
book <offer_id> --passenger ... Book flight (FREE after unlock)
|
|
648
|
+
register --name ... --email ... Register new agent
|
|
649
|
+
setup-payment Set up payment card
|
|
650
|
+
me Show agent profile
|
|
651
|
+
|
|
652
|
+
Options:
|
|
653
|
+
--json, -j Output raw JSON
|
|
654
|
+
--api-key, -k API key (or set LETSFG_API_KEY)
|
|
655
|
+
--base-url API URL (default: https://api.letsfg.co)
|
|
656
|
+
|
|
657
|
+
Examples:
|
|
658
|
+
letsfg search GDN BER 2026-03-03 --sort price
|
|
659
|
+
letsfg search LON BCN 2026-04-01 --return 2026-04-08 --json
|
|
660
|
+
letsfg unlock off_xxx
|
|
661
|
+
letsfg book off_xxx -p '{"id":"pas_xxx","given_name":"John","family_name":"Doe","born_on":"1990-01-15"}' -e john@ex.com
|
|
662
|
+
`;
|
|
663
|
+
async function main() {
|
|
664
|
+
const args = process.argv.slice(2);
|
|
665
|
+
const command = args.shift();
|
|
666
|
+
try {
|
|
667
|
+
switch (command) {
|
|
668
|
+
case "search":
|
|
669
|
+
await cmdSearch(args);
|
|
670
|
+
break;
|
|
671
|
+
case "unlock":
|
|
672
|
+
await cmdUnlock(args);
|
|
673
|
+
break;
|
|
674
|
+
case "book":
|
|
675
|
+
await cmdBook(args);
|
|
676
|
+
break;
|
|
677
|
+
case "locations":
|
|
678
|
+
await cmdLocations(args);
|
|
679
|
+
break;
|
|
680
|
+
case "register":
|
|
681
|
+
await cmdRegister(args);
|
|
682
|
+
break;
|
|
683
|
+
case "setup-payment":
|
|
684
|
+
await cmdSetupPayment(args);
|
|
685
|
+
break;
|
|
686
|
+
case "me":
|
|
687
|
+
await cmdMe(args);
|
|
688
|
+
break;
|
|
689
|
+
case "--help":
|
|
690
|
+
case "-h":
|
|
691
|
+
case "help":
|
|
692
|
+
case void 0:
|
|
693
|
+
console.log(HELP);
|
|
694
|
+
break;
|
|
695
|
+
default:
|
|
696
|
+
console.error(`Unknown command: ${command}`);
|
|
697
|
+
console.log(HELP);
|
|
698
|
+
process.exit(1);
|
|
699
|
+
}
|
|
700
|
+
} catch (e) {
|
|
701
|
+
if (e instanceof LetsFGError) {
|
|
702
|
+
console.error(`Error: ${e.message}`);
|
|
703
|
+
process.exit(1);
|
|
704
|
+
}
|
|
705
|
+
throw e;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
main();
|