diff --git a/package.json b/package.json index 3774d76..42efd1f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@eslint/js": "^9.39.2", "@types/make-fetch-happen": "^10.0.4", "@types/node": "^25.0.2", - "@vitest/coverage-v8": "^4.0.15", + "@vitest/coverage-v8": "^4.0.17", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "make-fetch-happen": "^15.0.3", @@ -22,9 +22,11 @@ "tsdown": "^0.19.0", "typescript": "^5.3.3", "typescript-eslint": "^8.49.0", - "vitest": "^4.0.15" + "vitest": "^4.0.17" }, "workspaces": [ - "packages/*" + "packages/tide-predictor", + "packages/neaps", + "packages/cli" ] } diff --git a/packages/cli/bin/dev.cmd b/packages/cli/bin/dev.cmd new file mode 100644 index 0000000..c72d7dc --- /dev/null +++ b/packages/cli/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +node --no-warnings=ExperimentalWarning "%~dp0\dev" %* diff --git a/packages/cli/bin/dev.js b/packages/cli/bin/dev.js new file mode 100755 index 0000000..4b9d0ff --- /dev/null +++ b/packages/cli/bin/dev.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { execute } from "@oclif/core"; + +await execute({ development: true, dir: import.meta.url }); diff --git a/packages/cli/bin/run.cmd b/packages/cli/bin/run.cmd new file mode 100644 index 0000000..968fc30 --- /dev/null +++ b/packages/cli/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js new file mode 100755 index 0000000..94a0d6f --- /dev/null +++ b/packages/cli/bin/run.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { execute } from "@oclif/core"; + +await execute({ dir: import.meta.url }); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..06754a9 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,55 @@ +{ + "name": "@neaps/cli", + "version": "0.1.0", + "description": "Command line interface for Neaps tide prediction", + "keywords": [ + "tides", + "harmonics" + ], + "homepage": "https://github.com/neaps/neaps#readme", + "bugs": { + "url": "https://github.com/neaps/neaps/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/neaps/neaps.git", + "directory": "packages/cli" + }, + "license": "MIT", + "author": "Brandon Keepers ", + "type": "module", + "main": "dist/index.cjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "bin": { + "neaps": "./bin/run.js" + }, + "scripts": { + "build": "tsc -b", + "prepack": "npm run build", + "test": "vitest" + }, + "dependencies": { + "@oclif/core": "^4.8.0", + "neaps": "^0.2" + }, + "oclif": { + "bin": "neaps", + "commands": "./dist/commands", + "dirname": "neaps", + "topicSeparator": " " + }, + "devDependencies": { + "@oclif/test": "^4.1.15", + "@types/node": "^18.19.130", + "nock": "^14.0.10" + } +} diff --git a/packages/cli/src/command.ts b/packages/cli/src/command.ts new file mode 100644 index 0000000..f7b8d68 --- /dev/null +++ b/packages/cli/src/command.ts @@ -0,0 +1,13 @@ +import { Command, Flags } from "@oclif/core"; +import getFormat, { availableFormats, type Formats, type Formatter } from "./formatters/index.js"; + +export abstract class BaseCommand extends Command { + static baseFlags = { + format: Flags.custom({ + parse: async (input: string) => getFormat(input as Formats), + default: async () => getFormat("text"), + helpValue: `<${availableFormats.join("|")}>`, + description: "Output format", + })(), + }; +} diff --git a/packages/cli/src/commands/extremes.ts b/packages/cli/src/commands/extremes.ts new file mode 100644 index 0000000..0ded6be --- /dev/null +++ b/packages/cli/src/commands/extremes.ts @@ -0,0 +1,85 @@ +import { BaseCommand } from "../command.js"; +import { Flags } from "@oclif/core"; +import { findStation, nearestStation } from "neaps"; + +export default class Extremes extends BaseCommand { + static override description = "Get tide extremes for a station"; + + static override flags = { + station: Flags.string({ + description: "Use the specified station ID", + helpValue: "", + exclusive: ["near", "ip"], + }), + ip: Flags.boolean({ + default: false, + description: "Use IP geolocation to find nearest station", + exclusive: ["station", "near"], + }), + near: Flags.string({ + description: "Use specified lat,lon to find nearest station", + helpValue: "lat,lon", + exclusive: ["station", "ip"], + }), + start: Flags.string({ + description: "ISO date", + default: new Date().toISOString(), + parse: async (input: string) => new Date(input).toISOString(), + noCacheDefault: true, + validate: (input: string) => new Date(input), + helpValue: "YYYY-MM-DD", + }), + end: Flags.string({ + description: "ISO date", + helpValue: "YYYY-MM-DD", + }), + units: Flags.string({ + description: "Units for output (meters or feet)", + default: "meters", + helpValue: "", + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(Extremes); + + const station = await getStation(flags); + const start = new Date(flags.start); + const end = flags.end ? new Date(flags.end) : new Date(start.getTime() + 72 * 60 * 60 * 1000); + + const prediction = station.getExtremesPrediction({ + start, + end, + units: flags.units as "meters" | "feet", + }); + + flags.format.extremes(prediction); + } +} + +async function getStation({ + station, + near, + ip, +}: { + station?: string; + near?: string; + ip?: boolean; +}) { + if (station) { + return findStation(station); + } + + if (near) { + const [lat, lon] = near.split(",").map(Number); + return nearestStation({ latitude: lat, longitude: lon }); + } + + if (ip) { + const res = await fetch("https://reallyfreegeoip.org/json/"); + if (!res.ok) throw new Error(`Failed to fetch IP geolocation: ${res.statusText}`); + return nearestStation(await res.json()); + } else { + throw new Error("No station specified. Use --station or --ip flag."); + } +} diff --git a/packages/cli/src/commands/stations.ts b/packages/cli/src/commands/stations.ts new file mode 100644 index 0000000..408eca5 --- /dev/null +++ b/packages/cli/src/commands/stations.ts @@ -0,0 +1,54 @@ +import { BaseCommand } from "../command.js"; +import { Args, Flags } from "@oclif/core"; +import { stations } from "@neaps/tide-database"; +import { stationsNear } from "neaps"; + +export default class Stations extends BaseCommand { + static override description = "List available tide stations"; + + static override args = { + query: Args.string({ description: "Station name or id" }), + }; + + static override flags = { + all: Flags.boolean({ + description: "List all stations. Same as setting --limit=0", + exclusive: ["limit"], + }), + limit: Flags.integer({ + description: "Limit number of stations displayed", + default: 10, + exclusive: ["all"], + }), + near: Flags.string({ + description: "Use specified lat,lon to find nearest stations", + helpValue: "", + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(Stations); + + if (flags.all) flags.limit = 0; + + let list = stations; + + if (flags.near) { + const [lat, lon] = flags.near.split(",").map(Number); + list = stationsNear({ lat, lon }, Number(flags.limit)); + } + + if (args.query) { + const search = args.query.toLowerCase(); + list = list.filter((s) => s.id.includes(search) || s.name.toLowerCase().includes(search)); + } + + if (flags.limit > 0) { + list = list.slice(0, flags.limit); + } + + if (!list.length) throw new Error("No stations found"); + + flags.format.listStations(list); + } +} diff --git a/packages/cli/src/formatters/index.ts b/packages/cli/src/formatters/index.ts new file mode 100644 index 0000000..c020c90 --- /dev/null +++ b/packages/cli/src/formatters/index.ts @@ -0,0 +1,32 @@ +import text from "./text.js"; +import json from "./json.js"; +import type { Station } from "@neaps/tide-database"; +import { getExtremesPrediction } from "neaps"; + +export const formatters = { + text, + json, +}; + +export type Formats = keyof typeof formatters; +export type FormatterFactory = (stdout: NodeJS.WriteStream) => Formatter; +// TODO: export a proper type from neaps +export type ExtremesPrediction = ReturnType; + +export interface Formatter { + extremes(prediction: ExtremesPrediction): void; + listStations(stations: Station[]): void; +} + +export const availableFormats = Object.keys(formatters) as Formats[]; + +export default function getFormat( + format: Formats, + stdout: NodeJS.WriteStream = process.stdout, +): Formatter { + const formatter = formatters[format]; + if (!formatter) { + throw new Error(`Unknown output format: ${format}`); + } + return formatter(stdout); +} diff --git a/packages/cli/src/formatters/json.ts b/packages/cli/src/formatters/json.ts new file mode 100644 index 0000000..197c036 --- /dev/null +++ b/packages/cli/src/formatters/json.ts @@ -0,0 +1,16 @@ +import type { FormatterFactory } from "./index.js"; + +const formatter: FormatterFactory = function (stdout) { + function write(data: object) { + stdout.write(JSON.stringify(data, null, 2)); + stdout.write("\n"); + } + + return { + extremes: write, + listStations: write, + toString: () => "text", + }; +}; + +export default formatter; diff --git a/packages/cli/src/formatters/text.ts b/packages/cli/src/formatters/text.ts new file mode 100644 index 0000000..7e358b0 --- /dev/null +++ b/packages/cli/src/formatters/text.ts @@ -0,0 +1,31 @@ +import type { FormatterFactory } from "./index.js"; +import { Console } from "console"; + +const formatter: FormatterFactory = function (stdout) { + const console = new Console(stdout); + + return { + extremes(prediction) { + console.log(`Station: ${prediction.station.name}`); + console.log(`Datum: ${prediction.datum}`); + + prediction.extremes.forEach((extreme) => { + console.log( + `${extreme.time.toISOString()} | ${extreme.high ? "High" : "Low "} | ${extreme.level.toFixed( + 2, + )} ${prediction.units === "meters" ? "m" : "ft"}`, + ); + }); + }, + + listStations(stations) { + stations.forEach((s) => { + console.log(`${s.id}\t${s.name} (${s.country})`); + }); + }, + + toString: () => "text", + }; +}; + +export default formatter; diff --git a/packages/cli/test/commands/extremes.test.ts b/packages/cli/test/commands/extremes.test.ts new file mode 100644 index 0000000..a19b326 --- /dev/null +++ b/packages/cli/test/commands/extremes.test.ts @@ -0,0 +1,110 @@ +import { describe, test, expect } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; + +nock.disableNetConnect(); + +describe("extremes", async () => { + function run(args: string[]) { + return runCommand(["extremes", ...args]); + } + + test("--help", async () => { + const { stdout } = await run(["--help"]); + expect(stdout).toMatch(/extremes/); + expect(stdout).toMatch(/--station/); + expect(stdout).toMatch(/--start/); + expect(stdout).toMatch(/--end/); + expect(stdout).toMatch(/--format/); + }); + + test("--station ", async () => { + const { stdout } = await run(["--station", "9414290", "--start", "2026-01-01"]); + expect(stdout).toMatch(/SAN FRANCISCO/); + }); + + test("--near 22.24,-75.75", async () => { + const { stdout } = await run(["--near", "22.24,-75.75"]); + expect(stdout).toMatch(/Nurse Channel/); + }); + + test("--ip", async () => { + nock("https://reallyfreegeoip.org").get("/json/").reply(200, { + latitude: 25.0565, + longitude: -77.3524, + }); + + const { stdout } = await run(["--ip"]); + expect(stdout).toMatch(/Nassau, New Providence Island/); + }); + + describe("--start", () => { + test("defaults to today", async () => { + const today = new Date().toISOString().split("T")[0]; + + const { stdout } = await run(["--station", "9414290"]); + expect(stdout).toMatch(new RegExp(today)); + }); + + test("accepts partial date", async () => { + const { stdout } = await run(["--station", "9414290", "--start", "2025-12-25"]); + expect(stdout).toMatch(/2025-12-25/); + }); + + test("accepts full date", async () => { + const { stdout } = await run(["--station", "9414290", "--start", "2025-12-25T00:00:00Z"]); + expect(stdout).toMatch(/2025-12-25/); + }); + + test("with invalid input", async () => { + const { error } = await run(["--station", "9414290", "--start", "invalid-date"]); + expect(error?.message).toMatch(/Invalid time value/); + }); + }); + + describe("--end", () => { + test("defaults to 72 hours from start", async () => { + const { stdout } = await run(["--station", "9414290", "--start", "2025-12-25"]); + expect(stdout).toMatch(/2025-12-27/); + }); + + test("accepts partial date", async () => { + const { stdout } = await run([ + "--station", + "9414290", + "--start", + "2025-12-25", + "--end", + "2025-12-26", + ]); + expect(stdout).toMatch(/2025-12-25/); + expect(stdout).not.toMatch(/2025-12-26/); + }); + }); + + describe("--format", () => { + test("json", async () => { + const { stdout } = await run(["--station", "9414290", "--format", "json"]); + const result = JSON.parse(stdout); + expect(result.station.name).toMatch(/SAN FRANCISCO/i); + expect(result.datum).toEqual("MLLW"); + expect(result.extremes.length).toBeGreaterThan(0); + }); + }); + + test("no station specified throws error", async () => { + const { error } = await run([]); + expect(error?.message).toMatch(/No station specified/); + }); + + test("invalid station ID throws error", async () => { + const { error } = await run(["--station", "invalid-station-id-xyz"]); + expect(error?.message).toMatch(/Station not found/); + }); + + test("--ip with failed geolocation", async () => { + nock("https://reallyfreegeoip.org").get("/json/").reply(500); + const { error } = await run(["--ip"]); + expect(error?.message).toMatch(/Failed to fetch IP geolocation/); + }); +}); diff --git a/packages/cli/test/commands/stations.test.ts b/packages/cli/test/commands/stations.test.ts new file mode 100644 index 0000000..11581df --- /dev/null +++ b/packages/cli/test/commands/stations.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { runCommand } from "@oclif/test"; + +function run(args: string[]) { + return runCommand(["stations", ...args]); +} + +describe("stations", () => { + test("lists default limited set", async () => { + const { stdout } = await run(["--format", "json"]); + const list = JSON.parse(stdout); + + expect(list).toHaveLength(10); + }); + + test("supports --all while filtering by query", async () => { + const { stdout } = await run(["--all", "Nonopapa", "--format", "json"]); + const list = JSON.parse(stdout); + + expect(list.length).toBeGreaterThan(0); + list.forEach((station: { id: string; name: string }) => { + const search = "nonopapa"; + expect( + station.id.toLowerCase().includes(search) || station.name.toLowerCase().includes(search), + ).toBe(true); + }); + }); + + test("finds nearest stations", async () => { + const { stdout } = await run(["--near", "37.8,-122.5", "--limit", "1", "--format", "json"]); + const list = JSON.parse(stdout); + + expect(list).toHaveLength(1); + expect(list[0].source.id).toBe("9414275"); + }); + + test("throws when no stations match", async () => { + const { error } = await run(["--format", "json", "this-will-not-match"]); + + expect(error?.message).toMatch(/No stations found/); + }); +}); diff --git a/packages/cli/test/setup.ts b/packages/cli/test/setup.ts new file mode 100644 index 0000000..f778a63 --- /dev/null +++ b/packages/cli/test/setup.ts @@ -0,0 +1,2 @@ +// Force to use cli directory as root for oclif +process.env.OCLIF_TEST_ROOT = new URL("..", import.meta.url).pathname; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..d1b2c09 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "declaration": true, + "module": "nodenext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "target": "es2022", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["./src/**/*"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..d6fdece --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + disableConsoleIntercept: true, + setupFiles: ["./test/setup.ts"], + }, +});