From 415101f821d3ba9cefacd8c90be0afa62ea9cdc1 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Tue, 6 Jan 2026 11:24:09 -0500 Subject: [PATCH 1/7] First pass at CLI --- packages/cli/bin/dev.cmd | 3 + packages/cli/bin/dev.js | 5 + packages/cli/bin/run.cmd | 3 + packages/cli/bin/run.js | 5 + packages/cli/package.json | 55 +++++++++++ packages/cli/src/command.ts | 17 ++++ packages/cli/src/commands/extremes.ts | 88 +++++++++++++++++ packages/cli/src/commands/stations.ts | 56 +++++++++++ packages/cli/src/formatters/index.ts | 32 +++++++ packages/cli/src/formatters/json.ts | 16 ++++ packages/cli/src/formatters/text.ts | 31 ++++++ packages/cli/test/commands/extremes.test.ts | 100 ++++++++++++++++++++ packages/cli/tsconfig.json | 13 +++ packages/cli/vitest.config.ts | 7 ++ 14 files changed, 431 insertions(+) create mode 100644 packages/cli/bin/dev.cmd create mode 100755 packages/cli/bin/dev.js create mode 100644 packages/cli/bin/run.cmd create mode 100755 packages/cli/bin/run.js create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/command.ts create mode 100644 packages/cli/src/commands/extremes.ts create mode 100644 packages/cli/src/commands/stations.ts create mode 100644 packages/cli/src/formatters/index.ts create mode 100644 packages/cli/src/formatters/json.ts create mode 100644 packages/cli/src/formatters/text.ts create mode 100644 packages/cli/test/commands/extremes.test.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/vitest.config.ts 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..62c5574 --- /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..92b78ec --- /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..884d09f --- /dev/null +++ b/packages/cli/src/command.ts @@ -0,0 +1,17 @@ +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..669d2e5 --- /dev/null +++ b/packages/cli/src/commands/extremes.ts @@ -0,0 +1,88 @@ +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..ef719ea --- /dev/null +++ b/packages/cli/src/commands/stations.ts @@ -0,0 +1,56 @@ +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..1de9f53 --- /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..424096a --- /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..73fb537 --- /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..488cea7 --- /dev/null +++ b/packages/cli/test/commands/extremes.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect } from 'vitest' +import { Config } from '@oclif/core' +import { runCommand } from '@oclif/test' +import nock from 'nock' + +nock.disableNetConnect() + +describe('extremes', async () => { + const config = await Config.load(new URL('../..', import.meta.url).pathname) + + test('--station ', async () => { + const { stdout } = await runCommand( + ['extremes', '--station', '9414290', '--start', '2026-01-01'], + config + ) + expect(stdout).toMatch(/SAN FRANCISCO/) + }) + + test('--near 22.24,-75.75', async () => { + const { stdout } = await runCommand( + ['extremes', '--near', '22.24,-75.75'], + config + ) + expect(stdout).toMatch(/Nurse Channel/) + }) + + test('--ip', async () => { + nock('https://reallyfreegeoip.org').get('/json/').reply(200, { + latitude: 25.0565, + longitude: -77.3524 + }) + + const { stdout, error } = await runCommand(['extremes', '--ip'], config) + expect(error).toBeUndefined() + 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 runCommand( + ['extremes', '--station', '9414290'], + config + ) + expect(stdout).toMatch(new RegExp(today)) + }) + + test('accepts partial date', async () => { + const { stdout } = await runCommand( + ['extremes', '--station', '9414290', '--start', '2025-12-25'], + config + ) + expect(stdout).toMatch(/2025-12-25/) + }) + + test('accepts full date', async () => { + const { stdout } = await runCommand( + ['extremes', '--station', '9414290', '--start', '2025-12-25T00:00:00Z'], + config + ) + expect(stdout).toMatch(/2025-12-25/) + }) + + test('with invalid input', async () => { + const { error } = await runCommand( + ['extremes', '--station', '9414290', '--start', 'invalid-date'], + config + ) + expect(error?.message).toMatch(/Invalid time value/) + }) + }) + + describe('--end', () => { + test('defaults to 72 hours from start', async () => { + const { stdout } = await runCommand( + ['extremes', '--station', '9414290', '--start', '2025-12-25'], + config + ) + expect(stdout).toMatch(/2025-12-27/) + }) + + test('accepts partial date', async () => { + const { stdout } = await runCommand( + [ + 'extremes', + '--station', + '9414290', + '--start', + '2025-12-25', + '--end', + '2025-12-26' + ], + config + ) + expect(stdout).toMatch(/2025-12-25/) + expect(stdout).not.toMatch(/2025-12-26/) + }) + }) +}) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..9833b16 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "declaration": true, + "module": "nodenext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "target": "es2022", + "moduleResolution": "nodenext", + "resolveJsonModule": true + }, + "include": ["./src/**/*"] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..da2845f --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + disableConsoleIntercept: true + } +}) From 7a70e8fa3765c67c9d49bab40dbdd8e37e50867a Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Wed, 7 Jan 2026 08:45:29 -0500 Subject: [PATCH 2/7] Specify workspace order to fix build dependencies --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3774d76..295fff1 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "vitest": "^4.0.15" }, "workspaces": [ - "packages/*" + "packages/tide-predictor", + "packages/neaps", + "packages/cli" ] } From ce80217aef4275ab6fead4f6306ce37936a568ac Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Wed, 7 Jan 2026 09:01:51 -0500 Subject: [PATCH 3/7] Emit source map for CLI for coverage --- packages/cli/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 9833b16..d1b2c09 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -7,7 +7,8 @@ "strict": true, "target": "es2022", "moduleResolution": "nodenext", - "resolveJsonModule": true + "resolveJsonModule": true, + "sourceMap": true }, "include": ["./src/**/*"] } From 8d27ba3c4bd9b84531990c31447cc4a094292194 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Wed, 7 Jan 2026 09:41:14 -0500 Subject: [PATCH 4/7] Simplify testing setup --- packages/cli/test/commands/extremes.test.ts | 106 ++++++++++++-------- packages/cli/test/setup.ts | 2 + packages/cli/vitest.config.ts | 3 +- 3 files changed, 67 insertions(+), 44 deletions(-) create mode 100644 packages/cli/test/setup.ts diff --git a/packages/cli/test/commands/extremes.test.ts b/packages/cli/test/commands/extremes.test.ts index 488cea7..899ca08 100644 --- a/packages/cli/test/commands/extremes.test.ts +++ b/packages/cli/test/commands/extremes.test.ts @@ -1,26 +1,35 @@ import { describe, test, expect } from 'vitest' -import { Config } from '@oclif/core' import { runCommand } from '@oclif/test' import nock from 'nock' nock.disableNetConnect() describe('extremes', async () => { - const config = await Config.load(new URL('../..', import.meta.url).pathname) + 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 runCommand( - ['extremes', '--station', '9414290', '--start', '2026-01-01'], - config - ) + 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 runCommand( - ['extremes', '--near', '22.24,-75.75'], - config - ) + const { stdout } = await run(['--near', '22.24,-75.75']) expect(stdout).toMatch(/Nurse Channel/) }) @@ -30,7 +39,7 @@ describe('extremes', async () => { longitude: -77.3524 }) - const { stdout, error } = await runCommand(['extremes', '--ip'], config) + const { stdout, error } = await run(['--ip']) expect(error).toBeUndefined() expect(stdout).toMatch(/Nassau, New Providence Island/) }) @@ -39,62 +48,73 @@ describe('extremes', async () => { test('defaults to today', async () => { const today = new Date().toISOString().split('T')[0] - const { stdout } = await runCommand( - ['extremes', '--station', '9414290'], - config - ) + const { stdout } = await run(['--station', '9414290']) expect(stdout).toMatch(new RegExp(today)) }) test('accepts partial date', async () => { - const { stdout } = await runCommand( - ['extremes', '--station', '9414290', '--start', '2025-12-25'], - config - ) + const { stdout } = await run([ + '--station', + '9414290', + '--start', + '2025-12-25' + ]) expect(stdout).toMatch(/2025-12-25/) }) test('accepts full date', async () => { - const { stdout } = await runCommand( - ['extremes', '--station', '9414290', '--start', '2025-12-25T00:00:00Z'], - config - ) + 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 runCommand( - ['extremes', '--station', '9414290', '--start', 'invalid-date'], - config - ) + 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 runCommand( - ['extremes', '--station', '9414290', '--start', '2025-12-25'], - config - ) + const { stdout } = await run([ + '--station', + '9414290', + '--start', + '2025-12-25' + ]) expect(stdout).toMatch(/2025-12-27/) }) test('accepts partial date', async () => { - const { stdout } = await runCommand( - [ - 'extremes', - '--station', - '9414290', - '--start', - '2025-12-25', - '--end', - '2025-12-26' - ], - config - ) + 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) + }) + }) }) diff --git a/packages/cli/test/setup.ts b/packages/cli/test/setup.ts new file mode 100644 index 0000000..64c7327 --- /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/vitest.config.ts b/packages/cli/vitest.config.ts index da2845f..423c2e0 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -2,6 +2,7 @@ import { defineProject } from 'vitest/config' export default defineProject({ test: { - disableConsoleIntercept: true + disableConsoleIntercept: true, + setupFiles: ['./test/setup.ts'] } }) From 7c7d6d33a97403e61ba3e9b1ff94b85ab31728be Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 18 Jan 2026 09:34:52 -0500 Subject: [PATCH 5/7] npm run for at --- packages/cli/bin/dev.js | 4 +- packages/cli/bin/run.js | 4 +- packages/cli/src/command.ts | 18 +- packages/cli/src/commands/extremes.ts | 81 +++++---- packages/cli/src/commands/stations.ts | 52 +++--- packages/cli/src/formatters/index.ts | 32 ++-- packages/cli/src/formatters/json.ts | 14 +- packages/cli/src/formatters/text.ts | 32 ++-- packages/cli/test/commands/extremes.test.ts | 177 +++++++++----------- packages/cli/test/setup.ts | 2 +- packages/cli/vitest.config.ts | 8 +- 11 files changed, 195 insertions(+), 229 deletions(-) diff --git a/packages/cli/bin/dev.js b/packages/cli/bin/dev.js index 62c5574..4b9d0ff 100755 --- a/packages/cli/bin/dev.js +++ b/packages/cli/bin/dev.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { execute } from '@oclif/core' +import { execute } from "@oclif/core"; -await execute({ development: true, dir: import.meta.url }) +await execute({ development: true, dir: import.meta.url }); diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index 92b78ec..94a0d6f 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { execute } from '@oclif/core' +import { execute } from "@oclif/core"; -await execute({ dir: import.meta.url }) +await execute({ dir: import.meta.url }); diff --git a/packages/cli/src/command.ts b/packages/cli/src/command.ts index 884d09f..f7b8d68 100644 --- a/packages/cli/src/command.ts +++ b/packages/cli/src/command.ts @@ -1,17 +1,13 @@ -import { Command, Flags } from '@oclif/core' -import getFormat, { - availableFormats, - type Formats, - type Formatter -} from './formatters/index.js' +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' - })() - } + 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 index 669d2e5..0ded6be 100644 --- a/packages/cli/src/commands/extremes.ts +++ b/packages/cli/src/commands/extremes.ts @@ -1,88 +1,85 @@ -import { BaseCommand } from '../command.js' -import { Flags } from '@oclif/core' -import { findStation, nearestStation } from 'neaps' +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 description = "Get tide extremes for a station"; static override flags = { station: Flags.string({ - description: 'Use the specified station ID', - helpValue: '', - exclusive: ['near', 'ip'] + 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'] + 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'] + description: "Use specified lat,lon to find nearest station", + helpValue: "lat,lon", + exclusive: ["station", "ip"], }), start: Flags.string({ - description: 'ISO date', + 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' + helpValue: "YYYY-MM-DD", }), end: Flags.string({ - description: 'ISO date', - helpValue: 'YYYY-MM-DD' + description: "ISO date", + helpValue: "YYYY-MM-DD", }), units: Flags.string({ - description: 'Units for output (meters or feet)', - default: 'meters', - helpValue: '' - }) - } + description: "Units for output (meters or feet)", + default: "meters", + helpValue: "", + }), + }; public async run(): Promise { - const { flags } = await this.parse(Extremes) + 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 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' - }) + units: flags.units as "meters" | "feet", + }); - flags.format.extremes(prediction) + flags.format.extremes(prediction); } } async function getStation({ station, near, - ip + ip, }: { - station?: string - near?: string - ip?: boolean + station?: string; + near?: string; + ip?: boolean; }) { if (station) { - return findStation(station) + return findStation(station); } if (near) { - const [lat, lon] = near.split(',').map(Number) - return nearestStation({ latitude: lat, longitude: lon }) + 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()) + 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.') + 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 index ef719ea..408eca5 100644 --- a/packages/cli/src/commands/stations.ts +++ b/packages/cli/src/commands/stations.ts @@ -1,56 +1,54 @@ -import { BaseCommand } from '../command.js' -import { Args, Flags } from '@oclif/core' -import { stations } from '@neaps/tide-database' -import { stationsNear } from 'neaps' +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 description = "List available tide stations"; static override args = { - query: Args.string({ description: 'Station name or id' }) - } + 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'] + description: "List all stations. Same as setting --limit=0", + exclusive: ["limit"], }), limit: Flags.integer({ - description: 'Limit number of stations displayed', + description: "Limit number of stations displayed", default: 10, - exclusive: ['all'] + exclusive: ["all"], }), near: Flags.string({ - description: 'Use specified lat,lon to find nearest stations', - helpValue: '' - }) - } + description: "Use specified lat,lon to find nearest stations", + helpValue: "", + }), + }; public async run(): Promise { - const { args, flags } = await this.parse(Stations) + const { args, flags } = await this.parse(Stations); - if (flags.all) flags.limit = 0 + if (flags.all) flags.limit = 0; - let list = stations + let list = stations; if (flags.near) { - const [lat, lon] = flags.near.split(',').map(Number) - list = stationsNear({ lat, lon }, Number(flags.limit)) + 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) - ) + 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) + list = list.slice(0, flags.limit); } - if (!list.length) throw new Error('No stations found') + if (!list.length) throw new Error("No stations found"); - flags.format.listStations(list) + flags.format.listStations(list); } } diff --git a/packages/cli/src/formatters/index.ts b/packages/cli/src/formatters/index.ts index 1de9f53..c020c90 100644 --- a/packages/cli/src/formatters/index.ts +++ b/packages/cli/src/formatters/index.ts @@ -1,32 +1,32 @@ -import text from './text.js' -import json from './json.js' -import type { Station } from '@neaps/tide-database' -import { getExtremesPrediction } from 'neaps' +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 -} + json, +}; -export type Formats = keyof typeof formatters -export type FormatterFactory = (stdout: NodeJS.WriteStream) => Formatter +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 type ExtremesPrediction = ReturnType; export interface Formatter { - extremes(prediction: ExtremesPrediction): void - listStations(stations: Station[]): void + extremes(prediction: ExtremesPrediction): void; + listStations(stations: Station[]): void; } -export const availableFormats = Object.keys(formatters) as Formats[] +export const availableFormats = Object.keys(formatters) as Formats[]; export default function getFormat( format: Formats, - stdout: NodeJS.WriteStream = process.stdout + stdout: NodeJS.WriteStream = process.stdout, ): Formatter { - const formatter = formatters[format] + const formatter = formatters[format]; if (!formatter) { - throw new Error(`Unknown output format: ${format}`) + throw new Error(`Unknown output format: ${format}`); } - return formatter(stdout) + return formatter(stdout); } diff --git a/packages/cli/src/formatters/json.ts b/packages/cli/src/formatters/json.ts index 424096a..197c036 100644 --- a/packages/cli/src/formatters/json.ts +++ b/packages/cli/src/formatters/json.ts @@ -1,16 +1,16 @@ -import type { FormatterFactory } from './index.js' +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') + stdout.write(JSON.stringify(data, null, 2)); + stdout.write("\n"); } return { extremes: write, listStations: write, - toString: () => 'text' - } -} + toString: () => "text", + }; +}; -export default formatter +export default formatter; diff --git a/packages/cli/src/formatters/text.ts b/packages/cli/src/formatters/text.ts index 73fb537..7e358b0 100644 --- a/packages/cli/src/formatters/text.ts +++ b/packages/cli/src/formatters/text.ts @@ -1,31 +1,31 @@ -import type { FormatterFactory } from './index.js' -import { Console } from 'console' +import type { FormatterFactory } from "./index.js"; +import { Console } from "console"; const formatter: FormatterFactory = function (stdout) { - const console = new Console(stdout) + const console = new Console(stdout); return { extremes(prediction) { - console.log(`Station: ${prediction.station.name}`) - console.log(`Datum: ${prediction.datum}`) + 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'}` - ) - }) + `${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})`) - }) + console.log(`${s.id}\t${s.name} (${s.country})`); + }); }, - toString: () => 'text' - } -} + toString: () => "text", + }; +}; -export default formatter +export default formatter; diff --git a/packages/cli/test/commands/extremes.test.ts b/packages/cli/test/commands/extremes.test.ts index 899ca08..3c64ae5 100644 --- a/packages/cli/test/commands/extremes.test.ts +++ b/packages/cli/test/commands/extremes.test.ts @@ -1,120 +1,95 @@ -import { describe, test, expect } from 'vitest' -import { runCommand } from '@oclif/test' -import nock from 'nock' +import { describe, test, expect } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; -nock.disableNetConnect() +nock.disableNetConnect(); -describe('extremes', async () => { +describe("extremes", async () => { function run(args: string[]) { - return runCommand(['extremes', ...args]) + 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("--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("--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("--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, { + test("--ip", async () => { + nock("https://reallyfreegeoip.org").get("/json/").reply(200, { latitude: 25.0565, - longitude: -77.3524 - }) + longitude: -77.3524, + }); - const { stdout, error } = await run(['--ip']) - expect(error).toBeUndefined() - expect(stdout).toMatch(/Nassau, New Providence Island/) - }) + const { stdout, error } = await run(["--ip"]); + expect(error).toBeUndefined(); + expect(stdout).toMatch(/Nassau, New Providence Island/); + }); - describe('--start', () => { - test('defaults to today', async () => { - const today = new Date().toISOString().split('T')[0] + 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)) - }) + 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 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("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/) - }) - }) + 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/) - }) + 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 () => { + 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/) - }) - }) + "--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) - }) - }) -}) + 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); + }); + }); +}); diff --git a/packages/cli/test/setup.ts b/packages/cli/test/setup.ts index 64c7327..f778a63 100644 --- a/packages/cli/test/setup.ts +++ b/packages/cli/test/setup.ts @@ -1,2 +1,2 @@ // Force to use cli directory as root for oclif -process.env.OCLIF_TEST_ROOT = new URL('..', import.meta.url).pathname +process.env.OCLIF_TEST_ROOT = new URL("..", import.meta.url).pathname; diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 423c2e0..d6fdece 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,8 +1,8 @@ -import { defineProject } from 'vitest/config' +import { defineProject } from "vitest/config"; export default defineProject({ test: { disableConsoleIntercept: true, - setupFiles: ['./test/setup.ts'] - } -}) + setupFiles: ["./test/setup.ts"], + }, +}); From 1b882b0c2b75d419e519154b02afd367c508e0e4 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 19 Jan 2026 07:57:32 -0500 Subject: [PATCH 6/7] Bump vitest to latest --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 295fff1..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,7 +22,7 @@ "tsdown": "^0.19.0", "typescript": "^5.3.3", "typescript-eslint": "^8.49.0", - "vitest": "^4.0.15" + "vitest": "^4.0.17" }, "workspaces": [ "packages/tide-predictor", From f952cd5d48ff36221493a208f7dffc5143fee966 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 19 Jan 2026 08:09:21 -0500 Subject: [PATCH 7/7] Add more test coverage --- packages/cli/test/commands/extremes.test.ts | 19 +++++++++- packages/cli/test/commands/stations.test.ts | 42 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 packages/cli/test/commands/stations.test.ts diff --git a/packages/cli/test/commands/extremes.test.ts b/packages/cli/test/commands/extremes.test.ts index 3c64ae5..a19b326 100644 --- a/packages/cli/test/commands/extremes.test.ts +++ b/packages/cli/test/commands/extremes.test.ts @@ -34,8 +34,7 @@ describe("extremes", async () => { longitude: -77.3524, }); - const { stdout, error } = await run(["--ip"]); - expect(error).toBeUndefined(); + const { stdout } = await run(["--ip"]); expect(stdout).toMatch(/Nassau, New Providence Island/); }); @@ -92,4 +91,20 @@ describe("extremes", async () => { 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/); + }); +});