Skip to content
Open

CLI #182

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
]
}
3 changes: 3 additions & 0 deletions packages/cli/bin/dev.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@echo off

node --no-warnings=ExperimentalWarning "%~dp0\dev" %*
5 changes: 5 additions & 0 deletions packages/cli/bin/dev.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env node

import { execute } from "@oclif/core";

await execute({ development: true, dir: import.meta.url });
3 changes: 3 additions & 0 deletions packages/cli/bin/run.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@echo off

node "%~dp0\run" %*
5 changes: 5 additions & 0 deletions packages/cli/bin/run.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env node

import { execute } from "@oclif/core";

await execute({ dir: import.meta.url });
55 changes: 55 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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 <brandon@openwaters.io>",
"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"
}
}
13 changes: 13 additions & 0 deletions packages/cli/src/command.ts
Original file line number Diff line number Diff line change
@@ -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<Formatter>({
parse: async (input: string) => getFormat(input as Formats),
default: async () => getFormat("text"),
helpValue: `<${availableFormats.join("|")}>`,
description: "Output format",
})(),
};
}
85 changes: 85 additions & 0 deletions packages/cli/src/commands/extremes.ts
Original file line number Diff line number Diff line change
@@ -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: "<station-id>",
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: "<meters|feet>",
}),
};

public async run(): Promise<void> {
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.");
}
}
54 changes: 54 additions & 0 deletions packages/cli/src/commands/stations.ts
Original file line number Diff line number Diff line change
@@ -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: "<lat,lon>",
}),
};

public async run(): Promise<void> {
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);
}
}
32 changes: 32 additions & 0 deletions packages/cli/src/formatters/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getExtremesPrediction>;

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);
}
16 changes: 16 additions & 0 deletions packages/cli/src/formatters/json.ts
Original file line number Diff line number Diff line change
@@ -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;
31 changes: 31 additions & 0 deletions packages/cli/src/formatters/text.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading