straeto 0.1.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.
@@ -0,0 +1,189 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ const CLI = ["bun", "run", "src/cli.ts"];
4
+
5
+ async function run(
6
+ ...args: string[]
7
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
8
+ const proc = Bun.spawn([...CLI, ...args], {
9
+ stdout: "pipe",
10
+ stderr: "pipe",
11
+ env: { ...process.env, FORCE_COLOR: "0" },
12
+ });
13
+ const [stdout, stderr] = await Promise.all([
14
+ new Response(proc.stdout).text(),
15
+ new Response(proc.stderr).text(),
16
+ ]);
17
+ const exitCode = await proc.exited;
18
+ return { stdout, stderr, exitCode };
19
+ }
20
+
21
+ describe("cli --help", () => {
22
+ test("shows usage and all commands", async () => {
23
+ const { stdout, exitCode } = await run("--help");
24
+
25
+ expect(exitCode).toBe(0);
26
+ expect(stdout).toContain("Usage: straeto");
27
+ expect(stdout).toContain("route");
28
+ expect(stdout).toContain("stops");
29
+ expect(stdout).toContain("stop");
30
+ expect(stdout).toContain("alerts");
31
+ expect(stdout).toContain("plan");
32
+ });
33
+ });
34
+
35
+ describe("cli --version", () => {
36
+ test("prints version number", async () => {
37
+ const { stdout, exitCode } = await run("--version");
38
+
39
+ expect(exitCode).toBe(0);
40
+ expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
41
+ });
42
+
43
+ test("works with -v shorthand", async () => {
44
+ const { stdout, exitCode } = await run("-v");
45
+
46
+ expect(exitCode).toBe(0);
47
+ expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
48
+ });
49
+ });
50
+
51
+ describe("cli route", () => {
52
+ test("shows bus overview for a route", async () => {
53
+ const { stdout, exitCode } = await run("route", "3");
54
+
55
+ expect(exitCode).toBe(0);
56
+ expect(stdout).toContain("Route 3");
57
+ });
58
+
59
+ test("shows help for route command", async () => {
60
+ const { stdout, exitCode } = await run("route", "--help");
61
+
62
+ expect(exitCode).toBe(0);
63
+ expect(stdout).toContain("Route number");
64
+ expect(stdout).toContain("--watch");
65
+ expect(stdout).toContain("--interval");
66
+ });
67
+
68
+ test("fails without route argument", async () => {
69
+ const { stderr, exitCode } = await run("route");
70
+
71
+ expect(exitCode).toBe(1);
72
+ expect(stderr).toContain("missing required argument");
73
+ });
74
+ });
75
+
76
+ describe("cli stops", () => {
77
+ test("lists stops with default limit", async () => {
78
+ const { stdout, exitCode } = await run("stops");
79
+
80
+ expect(exitCode).toBe(0);
81
+ expect(stdout).toContain("Bus stops");
82
+ expect(stdout).toContain("more");
83
+ });
84
+
85
+ test("filters stops by search", async () => {
86
+ const { stdout, exitCode } = await run("stops", "-s", "Hamraborg");
87
+
88
+ expect(exitCode).toBe(0);
89
+ expect(stdout).toContain("Hamraborg");
90
+ });
91
+
92
+ test("respects -n limit flag", async () => {
93
+ const { stdout, exitCode } = await run("stops", "-n", "3");
94
+
95
+ expect(exitCode).toBe(0);
96
+ expect(stdout).toContain("Bus stops");
97
+ // Should show "and N more" since 3 is less than total
98
+ expect(stdout).toContain("more");
99
+ });
100
+ });
101
+
102
+ describe("cli stop", () => {
103
+ test("looks up a stop by name", async () => {
104
+ const { stdout, exitCode } = await run("stop", "Hamraborg");
105
+
106
+ expect(exitCode).toBe(0);
107
+ expect(stdout).toContain("Hamraborg");
108
+ });
109
+
110
+ test("shows suggestions for partial match", async () => {
111
+ const { stdout, exitCode } = await run("stop", "Hamra");
112
+
113
+ expect(exitCode).toBe(0);
114
+ expect(stdout).toContain("Hamraborg");
115
+ });
116
+
117
+ test("fails for nonexistent stop", async () => {
118
+ const { stderr, exitCode } = await run("stop", "xyznonexistent123");
119
+
120
+ expect(exitCode).toBe(1);
121
+ expect(stderr).toContain("No stops matching");
122
+ });
123
+ });
124
+
125
+ describe("cli alerts", () => {
126
+ test("shows alerts in Icelandic by default", async () => {
127
+ const { stdout, exitCode } = await run("alerts");
128
+
129
+ expect(exitCode).toBe(0);
130
+ // May have 0 alerts, but should still show the header or "No active alerts"
131
+ expect(stdout.length).toBeGreaterThan(0);
132
+ });
133
+
134
+ test("shows alerts in English with -l EN", async () => {
135
+ const { stdout, exitCode } = await run("alerts", "-l", "EN");
136
+
137
+ expect(exitCode).toBe(0);
138
+ expect(stdout.length).toBeGreaterThan(0);
139
+ });
140
+ });
141
+
142
+ describe("cli plan", () => {
143
+ test("plans a trip with coordinates", async () => {
144
+ // Use tomorrow to avoid time-of-day edge cases
145
+ const tomorrow = new Date(Date.now() + 86400000);
146
+ const date = tomorrow.toISOString().slice(0, 10);
147
+ const { stdout, exitCode } = await run(
148
+ "plan",
149
+ "-f",
150
+ "64.1426,-21.9009",
151
+ "-t",
152
+ "64.1117,-21.8437",
153
+ "--at",
154
+ "10:00",
155
+ "-d",
156
+ date,
157
+ );
158
+
159
+ expect(exitCode).toBe(0);
160
+ expect(stdout).toContain("→");
161
+ });
162
+
163
+ test("fails without from/to in non-interactive mode", async () => {
164
+ const { stderr, exitCode } = await run("plan", "-f", "64.14,-21.90");
165
+
166
+ expect(exitCode).toBe(1);
167
+ expect(stderr).toContain("Missing --from and --to");
168
+ });
169
+
170
+ test("shows help for plan command", async () => {
171
+ const { stdout, exitCode } = await run("plan", "--help");
172
+
173
+ expect(exitCode).toBe(0);
174
+ expect(stdout).toContain("--from");
175
+ expect(stdout).toContain("--to");
176
+ expect(stdout).toContain("--at");
177
+ expect(stdout).toContain("--by");
178
+ expect(stdout).toContain("--interactive");
179
+ });
180
+ });
181
+
182
+ describe("cli unknown command", () => {
183
+ test("shows error for unknown command", async () => {
184
+ const { stderr, exitCode } = await run("foobar");
185
+
186
+ expect(exitCode).toBe(1);
187
+ expect(stderr).toContain("unknown command");
188
+ });
189
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env bun
2
+ import chalk from "chalk";
3
+ import { program } from "commander";
4
+ import { alertsCommand } from "./commands/alerts";
5
+ import { planCommand } from "./commands/plan";
6
+ import { routeCommand } from "./commands/route";
7
+ import { AlertsOpts, PlanOpts, RouteOpts, StopsOpts } from "./commands/schemas";
8
+ import { stopCommand } from "./commands/stop";
9
+ import { stopsCommand } from "./commands/stops";
10
+ import { writeErr } from "./terminal";
11
+
12
+ function handleError(error: unknown) {
13
+ const msg = error instanceof Error ? error.message : String(error);
14
+ writeErr(`${chalk.red("Error:")} ${msg}`);
15
+ process.exit(1);
16
+ }
17
+
18
+ program
19
+ .name("straeto")
20
+ .description("Strætó — Icelandic bus system CLI")
21
+ .version("0.1.0", "-v, --version");
22
+
23
+ program
24
+ .command("route")
25
+ .description("Show buses on a route, or detail a specific bus (e.g. route 3 A)")
26
+ .argument("<route>", "Route number")
27
+ .argument("[bus]", "Bus letter for detail view (e.g. A)")
28
+ .option("-w, --watch", "Live-track with updates every second")
29
+ .option("-i, --interval <ms>", "Poll interval in ms (with --watch)", "1000")
30
+ .action(async (route: string, busLetter: string | undefined, rawOpts) => {
31
+ try {
32
+ await routeCommand(route, busLetter, RouteOpts.parse(rawOpts));
33
+ } catch (error) {
34
+ handleError(error);
35
+ }
36
+ });
37
+
38
+ program
39
+ .command("stops")
40
+ .description("List all bus stops")
41
+ .option("-r, --route <route>", "Filter by route number")
42
+ .option("-s, --search <query>", "Search stops by name")
43
+ .option("-n, --limit <n>", "Limit results", "10")
44
+ .option("-a, --all", "Show all results")
45
+ .action(async (rawOpts) => {
46
+ try {
47
+ await stopsCommand(StopsOpts.parse(rawOpts));
48
+ } catch (error) {
49
+ handleError(error);
50
+ }
51
+ });
52
+
53
+ program
54
+ .command("stop")
55
+ .description("Show details for a specific stop (by ID or name)")
56
+ .argument("<query...>", "Stop ID or name")
57
+ .action(async (parts: string[]) => {
58
+ try {
59
+ await stopCommand(parts);
60
+ } catch (error) {
61
+ handleError(error);
62
+ }
63
+ });
64
+
65
+ program
66
+ .command("alerts")
67
+ .description("Show active service alerts")
68
+ .option("-l, --lang <lang>", "Language (IS or EN)", "IS")
69
+ .action(async (rawOpts) => {
70
+ try {
71
+ await alertsCommand(AlertsOpts.parse(rawOpts));
72
+ } catch (error) {
73
+ handleError(error);
74
+ }
75
+ });
76
+
77
+ program
78
+ .command("plan")
79
+ .description("Plan a trip between two locations")
80
+ .option("-f, --from <coords>", "Origin as lat,lon")
81
+ .option("-t, --to <coords>", "Destination as lat,lon")
82
+ .option("-i, --interactive", "Interactive mode — search for places by name")
83
+ .option("-d, --date <date>", "Date (YYYY-MM-DD, defaults to today)")
84
+ .option("--at <time>", "Depart at time (HH:MM, defaults to now)")
85
+ .option("--by <time>", "Arrive by time (HH:MM)")
86
+ .action(async (rawOpts) => {
87
+ try {
88
+ await planCommand(PlanOpts.parse(rawOpts));
89
+ } catch (error) {
90
+ handleError(error);
91
+ }
92
+ });
93
+
94
+ program.parse();
@@ -0,0 +1,10 @@
1
+ import type { z } from "zod/v4";
2
+ import * as api from "../api";
3
+ import * as fmt from "../format";
4
+ import { writeLn } from "../terminal";
5
+ import type { AlertsOpts } from "./schemas";
6
+
7
+ export async function alertsCommand(opts: z.infer<typeof AlertsOpts>) {
8
+ const alerts = await api.getAlerts(opts.lang);
9
+ writeLn(fmt.formatAlerts(alerts));
10
+ }
@@ -0,0 +1,36 @@
1
+ import type { z } from "zod/v4";
2
+ import * as api from "../api";
3
+ import * as fmt from "../format";
4
+ import { writeLn } from "../terminal";
5
+ import type { PlanOpts } from "./schemas";
6
+
7
+ export async function planCommand(opts: z.infer<typeof PlanOpts>) {
8
+ const date = opts.date ?? fmt.currentDate();
9
+ let arrivalBy = !!opts.by;
10
+ let time = opts.by ?? opts.at ?? fmt.currentTime();
11
+
12
+ let from = opts.from;
13
+ let to = opts.to;
14
+
15
+ if (opts.interactive || (!from && !to)) {
16
+ const { promptLocation, promptTimeMode } = await import("../prompt");
17
+ writeLn("");
18
+ const origin = await promptLocation("From", api.geocode);
19
+ const dest = await promptLocation("To", api.geocode);
20
+ from = `${origin.lat},${origin.lon}`;
21
+ to = `${dest.lat},${dest.lon}`;
22
+
23
+ if (!opts.at && !opts.by) {
24
+ const mode = await promptTimeMode();
25
+ time = mode.time;
26
+ arrivalBy = mode.arrivalBy;
27
+ }
28
+
29
+ writeLn("");
30
+ }
31
+
32
+ if (!from || !to) throw new Error("Missing --from and --to (or use -i for interactive mode)");
33
+
34
+ const trips = await api.planTrip({ from, to, date, time, arrivalBy });
35
+ writeLn(fmt.formatTrips(trips));
36
+ }
@@ -0,0 +1,88 @@
1
+ import chalk from "chalk";
2
+ import type { z } from "zod/v4";
3
+ import * as api from "../api";
4
+ import * as fmt from "../format";
5
+ import { ANSI, write, writeLn } from "../terminal";
6
+ import type { RouteOpts } from "./schemas";
7
+
8
+ export async function routeCommand(
9
+ route: string,
10
+ busLetter: string | undefined,
11
+ opts: z.infer<typeof RouteOpts>,
12
+ ) {
13
+ let allStops: api.Stop[] | null = null;
14
+ const getStops = async () => {
15
+ if (!allStops) allStops = await api.getStops();
16
+ return allStops;
17
+ };
18
+
19
+ const render = async () => {
20
+ const data = await api.getBusLocations([route]);
21
+ if (busLetter) {
22
+ const match = api.findBus(data.results, route, busLetter);
23
+ if (!match) {
24
+ if (opts.watch)
25
+ return chalk.dim(`Bus ${route}-${busLetter.toUpperCase()} not currently active.`);
26
+ throw new Error(`Bus ${route}-${busLetter.toUpperCase()} not found.`);
27
+ }
28
+ const stops = await getStops();
29
+ const lastStop = api.findLastStop(match, stops);
30
+ return fmt.formatBusDetail(match, data.lastUpdate, lastStop);
31
+ }
32
+ return fmt.formatBusOverview(route, data.results, data.lastUpdate);
33
+ };
34
+
35
+ if (opts.watch) {
36
+ const interval = parseInt(opts.interval, 10);
37
+ const label = busLetter ? `bus ${route}-${busLetter.toUpperCase()}` : `route ${route}`;
38
+ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
39
+ let prevLineCount = 0;
40
+ let tick = 0;
41
+ let lastOutput = "";
42
+
43
+ const drawFrame = async () => {
44
+ const dot = chalk.cyan(spinner[tick++ % spinner.length]);
45
+ const footer = `\n ${dot} ${chalk.dim(`Live · ${label} · Ctrl+C to stop`)}`;
46
+ const frame = lastOutput + footer;
47
+ const lines = frame.split("\n");
48
+
49
+ let buf = prevLineCount > 0 ? ANSI.moveToStart(prevLineCount) : "";
50
+ for (const line of lines) buf += `${line + ANSI.clearLine}\n`;
51
+ for (let idx = lines.length; idx < prevLineCount; idx++) buf += `${ANSI.clearLine}\n`;
52
+ await write(buf);
53
+ prevLineCount = Math.max(lines.length, prevLineCount);
54
+ };
55
+
56
+ const fetchData = async () => {
57
+ try {
58
+ lastOutput = await render();
59
+ } catch {
60
+ // silently retry on transient errors
61
+ }
62
+ };
63
+
64
+ await write(ANSI.hideCursor);
65
+ process.on("SIGINT", async () => {
66
+ await write(ANSI.showCursor);
67
+ process.exit(0);
68
+ });
69
+
70
+ await fetchData();
71
+
72
+ // Data polling in background
73
+ (async () => {
74
+ while (true) {
75
+ await Bun.sleep(interval);
76
+ await fetchData();
77
+ }
78
+ })();
79
+
80
+ // Fast spinner render loop
81
+ while (true) {
82
+ await drawFrame();
83
+ await Bun.sleep(80);
84
+ }
85
+ } else {
86
+ writeLn(await render());
87
+ }
88
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from "zod/v4";
2
+
3
+ export const RouteOpts = z.object({
4
+ watch: z.boolean().optional(),
5
+ interval: z.string(),
6
+ });
7
+
8
+ export const StopsOpts = z.object({
9
+ route: z.string().optional(),
10
+ search: z.string().optional(),
11
+ limit: z.string(),
12
+ all: z.boolean().optional(),
13
+ });
14
+
15
+ export const AlertsOpts = z.object({
16
+ lang: z.string(),
17
+ });
18
+
19
+ export const PlanOpts = z.object({
20
+ from: z.string().optional(),
21
+ to: z.string().optional(),
22
+ interactive: z.boolean().optional(),
23
+ date: z.string().optional(),
24
+ at: z.string().optional(),
25
+ by: z.string().optional(),
26
+ });
@@ -0,0 +1,37 @@
1
+ import chalk from "chalk";
2
+ import * as api from "../api";
3
+ import * as fmt from "../format";
4
+ import { writeLn } from "../terminal";
5
+
6
+ export async function stopCommand(parts: string[]) {
7
+ const input = parts.join(" ");
8
+ const isId = /^\d+$/.test(input);
9
+
10
+ if (isId) {
11
+ const stop = await api.getStop(input);
12
+ if (!stop) throw new Error(`Stop "${input}" not found.`);
13
+ writeLn(fmt.formatStop(stop));
14
+ return;
15
+ }
16
+
17
+ const stops = await api.getStops();
18
+ const query = input.toLowerCase();
19
+ const matches = stops.filter((stop) => stop.name.toLowerCase() === query);
20
+
21
+ if (matches.length === 0) {
22
+ const fuzzy = stops.filter((stop) => stop.name.toLowerCase().includes(query));
23
+ if (fuzzy.length === 0) throw new Error(`No stops matching "${input}".`);
24
+ writeLn(chalk.dim(`No exact match. Did you mean:\n`));
25
+ writeLn(fmt.formatStops(fuzzy.slice(0, 10)));
26
+ return;
27
+ }
28
+
29
+ if (matches.length === 1) {
30
+ const stop = await api.getStop(String(matches[0]?.id));
31
+ if (!stop) throw new Error(`Stop "${input}" not found.`);
32
+ writeLn(fmt.formatStop(stop));
33
+ } else {
34
+ writeLn(chalk.dim(`Multiple stops named "${input}":\n`));
35
+ writeLn(fmt.formatStops(matches));
36
+ }
37
+ }
@@ -0,0 +1,30 @@
1
+ import chalk from "chalk";
2
+ import type { z } from "zod/v4";
3
+ import * as api from "../api";
4
+ import * as fmt from "../format";
5
+ import { writeLn } from "../terminal";
6
+ import type { StopsOpts } from "./schemas";
7
+
8
+ export async function stopsCommand(opts: z.infer<typeof StopsOpts>) {
9
+ let stops = await api.getStops();
10
+
11
+ if (opts.route) {
12
+ const routeFilter = opts.route;
13
+ stops = stops.filter((stop) => stop.routes.includes(routeFilter));
14
+ }
15
+
16
+ if (opts.search) {
17
+ const query = opts.search.toLowerCase();
18
+ stops = stops.filter((stop) => stop.name.toLowerCase().includes(query));
19
+ }
20
+
21
+ const total = stops.length;
22
+ if (!opts.all) {
23
+ stops = stops.slice(0, parseInt(opts.limit, 10));
24
+ }
25
+
26
+ writeLn(fmt.formatStops(stops));
27
+ if (!opts.all && total > stops.length) {
28
+ writeLn(chalk.dim(`\n … and ${total - stops.length} more (use --all to show all)`));
29
+ }
30
+ }