Skip to content

Commit b14a839

Browse files
committed
First pass at CLI
1 parent afb4c3d commit b14a839

14 files changed

Lines changed: 363 additions & 0 deletions

File tree

packages/cli/bin/dev.cmd

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@echo off
2+
3+
node --no-warnings=ExperimentalWarning "%~dp0\dev" %*

packages/cli/bin/dev.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env node
2+
3+
import { execute } from '@oclif/core'
4+
5+
await execute({ development: true, dir: import.meta.url })

packages/cli/bin/run.cmd

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@echo off
2+
3+
node "%~dp0\run" %*

packages/cli/bin/run.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env node
2+
3+
import { execute } from '@oclif/core'
4+
5+
await execute({ dir: import.meta.url })

packages/cli/package.json

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"name": "@neaps/cli",
3+
"version": "0.1.0",
4+
"description": "Command line interface for Neaps tide prediction",
5+
"keywords": [
6+
"tides",
7+
"harmonics"
8+
],
9+
"homepage": "https://github.com/neaps/neaps#readme",
10+
"bugs": {
11+
"url": "https://github.com/neaps/neaps/issues"
12+
},
13+
"repository": {
14+
"type": "git",
15+
"url": "git+https://github.com/neaps/neaps.git",
16+
"directory": "packages/cli"
17+
},
18+
"license": "MIT",
19+
"author": "Brandon Keepers <brandon@openwaters.io>",
20+
"type": "module",
21+
"main": "dist/index.cjs",
22+
"exports": {
23+
".": {
24+
"types": "./dist/index.d.ts",
25+
"import": "./dist/index.js",
26+
"require": "./dist/index.cjs"
27+
}
28+
},
29+
"files": [
30+
"dist"
31+
],
32+
"bin": {
33+
"neaps": "./bin/run.js"
34+
},
35+
"scripts": {
36+
"build": "tsc -b",
37+
"prepack": "npm run build",
38+
"test": "vitest"
39+
},
40+
"dependencies": {
41+
"@oclif/core": "^4.8.0",
42+
"neaps": "^0.1.0"
43+
},
44+
"oclif": {
45+
"bin": "neaps",
46+
"commands": "./dist/commands",
47+
"dirname": "neaps",
48+
"topicSeparator": " "
49+
},
50+
"devDependencies": {
51+
"@oclif/test": "^4.1.15",
52+
"@types/node": "^18.19.130",
53+
"nock": "^14.0.10"
54+
}
55+
}

packages/cli/src/command.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Command, Flags } from '@oclif/core'
2+
import getFormat, {
3+
availableFormats,
4+
type Formats,
5+
type Formatter
6+
} from './formatters/index.js'
7+
8+
export abstract class BaseCommand extends Command {
9+
static baseFlags = {
10+
format: Flags.custom<Formatter>({
11+
parse: async (input: string) => getFormat(input as Formats),
12+
default: async () => getFormat('text'),
13+
helpValue: `<${availableFormats.join('|')}>`,
14+
description: 'Output format'
15+
})()
16+
}
17+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { BaseCommand } from '../command.js'
2+
import { Flags } from '@oclif/core'
3+
import { findStation, nearestStation } from 'neaps'
4+
5+
export default class Extremes extends BaseCommand {
6+
static override description = 'Get tide extremes for a station'
7+
8+
static override flags = {
9+
station: Flags.string({
10+
description: 'Use the specified station ID',
11+
helpValue: '<station-id>',
12+
exclusive: ['near', 'ip']
13+
}),
14+
ip: Flags.boolean({
15+
default: false,
16+
description: 'Use IP geolocation to find nearest station',
17+
exclusive: ['station', 'near']
18+
}),
19+
near: Flags.string({
20+
description: 'Use specified lat,lon to find nearest station',
21+
helpValue: 'lat,lon',
22+
exclusive: ['station', 'ip']
23+
}),
24+
date: Flags.string({
25+
description: 'ISO date',
26+
default: new Date().toISOString().slice(0, 10),
27+
helpValue: 'YYYY-MM-DD'
28+
}),
29+
units: Flags.string({
30+
description: 'Units for output (meters or feet)',
31+
default: 'meters',
32+
helpValue: '<meters|feet>'
33+
}),
34+
hours: Flags.string({
35+
description: 'Number of hours to predict',
36+
default: '24'
37+
})
38+
}
39+
40+
public async run(): Promise<void> {
41+
const { flags } = await this.parse(Extremes)
42+
43+
const { date, hours } = flags
44+
const durationHours = Number(hours)
45+
46+
const station = await getStation(flags)
47+
const prediction = station.getExtremesPrediction({
48+
start: new Date(date),
49+
end: new Date(new Date(date).getTime() + durationHours * 60 * 60 * 1000),
50+
units: flags.units as 'meters' | 'feet'
51+
})
52+
53+
flags.format.extremes(prediction)
54+
}
55+
}
56+
57+
async function getStation({
58+
station,
59+
near,
60+
ip
61+
}: {
62+
station?: string
63+
near?: string
64+
ip?: boolean
65+
}) {
66+
if (station) {
67+
return findStation(station)
68+
}
69+
70+
if (near) {
71+
const [lat, lon] = near.split(',').map(Number)
72+
return nearestStation({ latitude: lat, longitude: lon })
73+
}
74+
75+
if (ip) {
76+
const res = await fetch('https://reallyfreegeoip.org/json/')
77+
if (!res.ok)
78+
throw new Error(`Failed to fetch IP geolocation: ${res.statusText}`)
79+
return nearestStation(await res.json())
80+
} else {
81+
throw new Error('No station specified. Use --station or --ip flag.')
82+
}
83+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { BaseCommand } from '../command.js'
2+
import { Args, Flags } from '@oclif/core'
3+
import { stations } from '@neaps/tide-database'
4+
import { stationsNear } from 'neaps'
5+
6+
export default class Stations extends BaseCommand {
7+
static override description = 'List available tide stations'
8+
9+
static override args = {
10+
query: Args.string({ description: 'Station name or id' })
11+
}
12+
13+
static override flags = {
14+
all: Flags.boolean({
15+
description: 'List all stations. Same as setting --limit=0',
16+
exclusive: ['limit']
17+
}),
18+
limit: Flags.integer({
19+
description: 'Limit number of stations displayed',
20+
default: 10,
21+
exclusive: ['all']
22+
}),
23+
near: Flags.string({
24+
description: 'Use specified lat,lon to find nearest stations',
25+
helpValue: '<lat,lon>'
26+
})
27+
}
28+
29+
public async run(): Promise<void> {
30+
const { args, flags } = await this.parse(Stations)
31+
32+
if (flags.all) flags.limit = 0
33+
34+
let list = stations
35+
36+
if (flags.near) {
37+
const [lat, lon] = flags.near.split(',').map(Number)
38+
list = stationsNear({ lat, lon }, Number(flags.limit))
39+
}
40+
41+
if (args.query) {
42+
const search = args.query.toLowerCase()
43+
list = list.filter(
44+
(s) => s.id.includes(search) || s.name.toLowerCase().includes(search)
45+
)
46+
}
47+
48+
if (flags.limit > 0) {
49+
list = list.slice(0, flags.limit)
50+
}
51+
52+
if (!list.length) throw new Error('No stations found')
53+
54+
flags.format.listStations(list)
55+
}
56+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import text from './text.js'
2+
import json from './json.js'
3+
import type { Station } from '@neaps/tide-database'
4+
import { getExtremesPrediction } from 'neaps'
5+
6+
export const formatters = {
7+
text,
8+
json
9+
}
10+
11+
export type Formats = keyof typeof formatters
12+
export type FormatterFactory = (stdout: NodeJS.WriteStream) => Formatter
13+
// TODO: export a proper type from neaps
14+
export type ExtremesPrediction = ReturnType<typeof getExtremesPrediction>
15+
16+
export interface Formatter {
17+
extremes(prediction: ExtremesPrediction): void
18+
listStations(stations: Station[]): void
19+
}
20+
21+
export const availableFormats = Object.keys(formatters) as Formats[]
22+
23+
export default function getFormat(
24+
format: Formats,
25+
stdout: NodeJS.WriteStream = process.stdout
26+
): Formatter {
27+
const formatter = formatters[format]
28+
if (!formatter) {
29+
throw new Error(`Unknown output format: ${format}`)
30+
}
31+
return formatter(stdout)
32+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { FormatterFactory } from './index.js'
2+
3+
const formatter: FormatterFactory = function (stdout) {
4+
function write(data: object) {
5+
stdout.write(JSON.stringify(data, null, 2))
6+
stdout.write('\n')
7+
}
8+
9+
return {
10+
extremes: write,
11+
listStations: write,
12+
toString: () => 'text'
13+
}
14+
}
15+
16+
export default formatter

0 commit comments

Comments
 (0)