@tks/wayfinder 0.2.1 → 0.2.3
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 +20 -3
- package/package.json +1 -1
- package/src/cli.ts +126 -3
- package/src/config.ts +44 -8
- package/src/parse.ts +27 -2
- package/src/types.ts +9 -1
package/README.md
CHANGED
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## Install
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Primary install method:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
brew install tksohishi/tap/wayfinder
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Secondary fallback method (requires [Bun](https://bun.sh/) runtime):
|
|
6
12
|
|
|
7
13
|
```bash
|
|
8
14
|
bun install -g @tks/wayfinder
|
|
9
15
|
```
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
Install from source:
|
|
12
18
|
|
|
13
19
|
```bash
|
|
14
20
|
git clone https://github.com/tksohishi/wayfinder.git
|
|
@@ -19,7 +25,18 @@ bun link
|
|
|
19
25
|
|
|
20
26
|
## Setup
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
First run:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
wayfinder setup
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This interactive command explains why the key is needed, where to get it, and saves it to your local config.
|
|
35
|
+
SerpApi is the API provider wayfinder uses to fetch Google Flights and Google Hotels results.
|
|
36
|
+
|
|
37
|
+
Advanced alternatives:
|
|
38
|
+
|
|
39
|
+
Set API key by environment variable:
|
|
23
40
|
|
|
24
41
|
```bash
|
|
25
42
|
export SERPAPI_API_KEY="your_key"
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { resolveApiKey } from "./config";
|
|
1
|
+
import { getConfigPath, readConfigApiKey, resolveApiKey, writeConfigApiKey } from "./config";
|
|
2
2
|
import { CliError } from "./errors";
|
|
3
3
|
import { renderFlightTable, renderHotelTable } from "./format";
|
|
4
4
|
import { parseCliArgs } from "./parse";
|
|
5
5
|
import { searchFlightBookingOptions, searchFlights, searchHotels } from "./serpapi";
|
|
6
6
|
import { ExitCode } from "./types";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
|
|
9
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
7
10
|
|
|
8
11
|
interface Output {
|
|
9
12
|
stdout: (message: string) => void;
|
|
@@ -15,16 +18,23 @@ interface RunOptions {
|
|
|
15
18
|
homeDir?: string;
|
|
16
19
|
fetchImpl?: typeof fetch;
|
|
17
20
|
output?: Output;
|
|
21
|
+
isInteractive?: boolean;
|
|
22
|
+
promptImpl?: (prompt: string) => Promise<string>;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
const HELP_TEXT = `wayfinder v0.2.
|
|
25
|
+
const HELP_TEXT = `wayfinder v0.2.1 travel search
|
|
21
26
|
|
|
22
27
|
Usage:
|
|
28
|
+
wayfinder setup [--reset]
|
|
23
29
|
wayfinder flights --from SFO --to JFK --date 2026-03-21 [filters]
|
|
24
30
|
wayfinder flights one-way --from SFO --to JFK --date 2026-03-21 [filters]
|
|
25
31
|
wayfinder flights booking --from SFO --to JFK --date 2026-03-21 --token <BOOKING_TOKEN> [--token <BOOKING_TOKEN>] [--json]
|
|
26
32
|
wayfinder hotels --where "New York, NY" --check-in 2026-03-21 --check-out 2026-03-23 [filters]
|
|
27
33
|
|
|
34
|
+
Setup:
|
|
35
|
+
Runs interactive key setup and stores your SerpApi key in local config.
|
|
36
|
+
--reset Remove existing local config and reconfigure
|
|
37
|
+
|
|
28
38
|
Flights required:
|
|
29
39
|
--from <IATA> Origin airport code
|
|
30
40
|
--to <IATA> Destination airport code
|
|
@@ -62,12 +72,40 @@ export async function runWayfinder(
|
|
|
62
72
|
argv: string[] = process.argv.slice(2),
|
|
63
73
|
options: RunOptions = {},
|
|
64
74
|
): Promise<number> {
|
|
75
|
+
const env = options.env ?? process.env;
|
|
76
|
+
const homeDir = options.homeDir;
|
|
77
|
+
const isInteractive = options.isInteractive ?? Boolean(defaultStdin.isTTY && defaultStdout.isTTY);
|
|
65
78
|
const output = options.output ?? {
|
|
66
79
|
stdout: (message: string) => console.log(message),
|
|
67
80
|
stderr: (message: string) => console.error(message),
|
|
68
81
|
};
|
|
69
82
|
|
|
70
83
|
try {
|
|
84
|
+
if (argv.length === 0) {
|
|
85
|
+
try {
|
|
86
|
+
resolveApiKey(env, homeDir);
|
|
87
|
+
output.stdout(HELP_TEXT);
|
|
88
|
+
return ExitCode.Success;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof CliError && error.exitCode === ExitCode.MissingApiKey) {
|
|
91
|
+
if (!isInteractive) {
|
|
92
|
+
output.stderr(error.message);
|
|
93
|
+
return error.exitCode;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return await runSetupFlow(output, homeDir, options.promptImpl, isInteractive, false);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (error instanceof CliError) {
|
|
100
|
+
output.stderr(error.message);
|
|
101
|
+
return error.exitCode;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
output.stderr("Unexpected internal error");
|
|
105
|
+
return ExitCode.InternalError;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
71
109
|
const parsed = parseCliArgs(argv);
|
|
72
110
|
|
|
73
111
|
if (parsed.help) {
|
|
@@ -75,7 +113,17 @@ export async function runWayfinder(
|
|
|
75
113
|
return ExitCode.Success;
|
|
76
114
|
}
|
|
77
115
|
|
|
78
|
-
|
|
116
|
+
if (parsed.mode === "setup") {
|
|
117
|
+
return await runSetupFlow(
|
|
118
|
+
output,
|
|
119
|
+
homeDir,
|
|
120
|
+
options.promptImpl,
|
|
121
|
+
isInteractive,
|
|
122
|
+
parsed.reset,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const apiKey = resolveApiKey(env, homeDir);
|
|
79
127
|
if (parsed.mode === "flights") {
|
|
80
128
|
const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
|
|
81
129
|
|
|
@@ -186,3 +234,78 @@ function renderFlightBookingText(
|
|
|
186
234
|
|
|
187
235
|
return lines.join("\n").trimEnd();
|
|
188
236
|
}
|
|
237
|
+
|
|
238
|
+
async function runSetupFlow(
|
|
239
|
+
output: Output,
|
|
240
|
+
homeDir: string | undefined,
|
|
241
|
+
promptImpl: ((prompt: string) => Promise<string>) | undefined,
|
|
242
|
+
isInteractive: boolean,
|
|
243
|
+
forceReset: boolean,
|
|
244
|
+
): Promise<number> {
|
|
245
|
+
if (!isInteractive) {
|
|
246
|
+
throw new CliError(
|
|
247
|
+
"wayfinder setup requires an interactive terminal. Use SERPAPI_API_KEY or write ~/.config/wayfinder/config.json manually.",
|
|
248
|
+
ExitCode.InvalidInput,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
output.stdout("Wayfinder needs a SerpApi API key to fetch live flight and hotel data.");
|
|
253
|
+
output.stdout("SerpApi is the API provider that returns Google Flights and Google Hotels search results.");
|
|
254
|
+
output.stdout("Get your key at: https://serpapi.com/manage-api-key");
|
|
255
|
+
output.stdout("");
|
|
256
|
+
|
|
257
|
+
const configPath = getConfigPath(homeDir);
|
|
258
|
+
if (forceReset) {
|
|
259
|
+
if (existsSync(configPath)) {
|
|
260
|
+
unlinkSync(configPath);
|
|
261
|
+
output.stdout("Existing config removed due to --reset.");
|
|
262
|
+
} else {
|
|
263
|
+
output.stdout("No existing config found. Starting fresh setup.");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const existingKey = readConfigApiKey(homeDir);
|
|
268
|
+
if (existingKey && !forceReset) {
|
|
269
|
+
const overwrite = await promptWithFallback(promptImpl, "A key already exists. Overwrite? [y/N]: ");
|
|
270
|
+
if (!/^(y|yes)$/i.test(overwrite.trim())) {
|
|
271
|
+
output.stdout("Setup cancelled. Existing key kept.");
|
|
272
|
+
return ExitCode.Success;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let apiKey = "";
|
|
277
|
+
while (apiKey.length === 0) {
|
|
278
|
+
apiKey = (await promptWithFallback(promptImpl, "Enter your SerpApi API key: ")).trim();
|
|
279
|
+
if (apiKey.length === 0) {
|
|
280
|
+
output.stdout("API key cannot be empty.");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
writeConfigApiKey(apiKey, homeDir);
|
|
285
|
+
output.stdout("");
|
|
286
|
+
output.stdout(`Setup complete. Saved key to ${configPath}.`);
|
|
287
|
+
output.stdout("Next step: wayfinder --help");
|
|
288
|
+
output.stdout("Quick start: wayfinder flights --from SFO --to JFK --date 2026-04-10");
|
|
289
|
+
|
|
290
|
+
return ExitCode.Success;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function promptWithFallback(
|
|
294
|
+
promptImpl: ((prompt: string) => Promise<string>) | undefined,
|
|
295
|
+
message: string,
|
|
296
|
+
): Promise<string> {
|
|
297
|
+
if (promptImpl) {
|
|
298
|
+
return promptImpl(message);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const rl = createInterface({
|
|
302
|
+
input: defaultStdin,
|
|
303
|
+
output: defaultStdout,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
return await rl.question(message);
|
|
308
|
+
} finally {
|
|
309
|
+
rl.close();
|
|
310
|
+
}
|
|
311
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { CliError } from "./errors";
|
|
@@ -14,10 +14,18 @@ export function resolveApiKey(env: NodeJS.ProcessEnv = process.env, homeDir = os
|
|
|
14
14
|
return envKey;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const configKey = readConfigApiKey(homeDir);
|
|
18
|
+
if (!configKey) {
|
|
19
|
+
throw missingKeyError();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return configKey;
|
|
23
|
+
}
|
|
18
24
|
|
|
25
|
+
export function readConfigApiKey(homeDir = os.homedir()): string | undefined {
|
|
26
|
+
const configPath = getConfigPath(homeDir);
|
|
19
27
|
if (!existsSync(configPath)) {
|
|
20
|
-
|
|
28
|
+
return undefined;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
let parsed: ConfigFile;
|
|
@@ -26,21 +34,49 @@ export function resolveApiKey(env: NodeJS.ProcessEnv = process.env, homeDir = os
|
|
|
26
34
|
parsed = JSON.parse(raw) as ConfigFile;
|
|
27
35
|
} catch {
|
|
28
36
|
throw new CliError(
|
|
29
|
-
"Could not read ~/.config/wayfinder/config.json. Ensure it is valid JSON
|
|
37
|
+
"Could not read ~/.config/wayfinder/config.json. Ensure it is valid JSON or rerun `wayfinder setup`.",
|
|
30
38
|
ExitCode.MissingApiKey,
|
|
31
39
|
);
|
|
32
40
|
}
|
|
33
41
|
|
|
34
|
-
if (typeof parsed.serpApiKey !== "string"
|
|
35
|
-
|
|
42
|
+
if (typeof parsed.serpApiKey !== "string") {
|
|
43
|
+
return undefined;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
|
|
46
|
+
const key = parsed.serpApiKey.trim();
|
|
47
|
+
return key.length > 0 ? key : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function writeConfigApiKey(apiKey: string, homeDir = os.homedir()): void {
|
|
51
|
+
const configPath = getConfigPath(homeDir);
|
|
52
|
+
const configDir = path.dirname(configPath);
|
|
53
|
+
mkdirSync(configDir, { recursive: true });
|
|
54
|
+
writeFileSync(
|
|
55
|
+
configPath,
|
|
56
|
+
`${JSON.stringify(
|
|
57
|
+
{
|
|
58
|
+
serpApiKey: apiKey,
|
|
59
|
+
},
|
|
60
|
+
null,
|
|
61
|
+
2,
|
|
62
|
+
)}\n`,
|
|
63
|
+
"utf8",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getConfigPath(homeDir = os.homedir()): string {
|
|
68
|
+
return path.join(homeDir, ".config", "wayfinder", "config.json");
|
|
39
69
|
}
|
|
40
70
|
|
|
41
71
|
function missingKeyError(): CliError {
|
|
42
72
|
return new CliError(
|
|
43
|
-
|
|
73
|
+
[
|
|
74
|
+
"Missing SerpApi API key.",
|
|
75
|
+
"Wayfinder needs a SerpApi key to fetch live flight and hotel results.",
|
|
76
|
+
"SerpApi is the API provider used for Google Flights and Google Hotels data.",
|
|
77
|
+
"Get a key at: https://serpapi.com/manage-api-key",
|
|
78
|
+
"Then run: wayfinder setup",
|
|
79
|
+
].join("\n"),
|
|
44
80
|
ExitCode.MissingApiKey,
|
|
45
81
|
);
|
|
46
82
|
}
|
package/src/parse.ts
CHANGED
|
@@ -35,7 +35,7 @@ interface FlightBookingRawOptions {
|
|
|
35
35
|
help: boolean;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
type SearchMode = "flights" | "hotels" | "flight-booking";
|
|
38
|
+
type SearchMode = "flights" | "hotels" | "flight-booking" | "setup";
|
|
39
39
|
|
|
40
40
|
const HELP_FLAGS = new Set(["-h", "--help"]);
|
|
41
41
|
|
|
@@ -57,6 +57,10 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
57
57
|
return parseFlightBookingArgs(args);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
if (mode === "setup") {
|
|
61
|
+
return parseSetupArgs(args);
|
|
62
|
+
}
|
|
63
|
+
|
|
60
64
|
return parseFlightsArgs(args);
|
|
61
65
|
}
|
|
62
66
|
|
|
@@ -277,6 +281,22 @@ function parseFlightBookingArgs(args: string[]): ParsedArgs {
|
|
|
277
281
|
};
|
|
278
282
|
}
|
|
279
283
|
|
|
284
|
+
function parseSetupArgs(args: string[]): ParsedArgs {
|
|
285
|
+
const outputJson = args.includes("--json");
|
|
286
|
+
const reset = args.includes("--reset");
|
|
287
|
+
const unsupported = args.filter((token) => token !== "--json" && token !== "--reset");
|
|
288
|
+
if (unsupported.length > 0) {
|
|
289
|
+
throw new CliError(`Unknown argument for setup: ${unsupported[0]}`, ExitCode.InvalidInput);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
help: false,
|
|
294
|
+
mode: "setup",
|
|
295
|
+
outputJson,
|
|
296
|
+
reset,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
280
300
|
function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
|
|
281
301
|
if (!raw.from || !raw.to || !raw.date) {
|
|
282
302
|
throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
|
|
@@ -410,8 +430,13 @@ function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] }
|
|
|
410
430
|
return { mode: "flights", args };
|
|
411
431
|
}
|
|
412
432
|
|
|
433
|
+
if (args[0] === "setup") {
|
|
434
|
+
args.shift();
|
|
435
|
+
return { mode: "setup", args };
|
|
436
|
+
}
|
|
437
|
+
|
|
413
438
|
throw new CliError(
|
|
414
|
-
"Missing subcommand: use `flights
|
|
439
|
+
"Missing subcommand: use `setup`, `flights`, or `hotels`",
|
|
415
440
|
ExitCode.InvalidInput,
|
|
416
441
|
);
|
|
417
442
|
}
|
package/src/types.ts
CHANGED
|
@@ -63,11 +63,19 @@ export interface ParsedArgsFlightBooking {
|
|
|
63
63
|
query: FlightBookingQuery;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
export interface ParsedArgsSetup {
|
|
67
|
+
help: false;
|
|
68
|
+
mode: "setup";
|
|
69
|
+
outputJson: boolean;
|
|
70
|
+
reset: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
export type ParsedArgs =
|
|
67
74
|
| ParsedArgsHelp
|
|
68
75
|
| ParsedArgsFlights
|
|
69
76
|
| ParsedArgsHotels
|
|
70
|
-
| ParsedArgsFlightBooking
|
|
77
|
+
| ParsedArgsFlightBooking
|
|
78
|
+
| ParsedArgsSetup;
|
|
71
79
|
|
|
72
80
|
export interface FlightOption {
|
|
73
81
|
price: number;
|