
Gmaps
- 5 installs
- 10 repo stars
- Updated June 26, 2026
- deusyu/google-maps-skill
Call the Google Maps Platform API via scripts for geocoding, reverse geocoding, routing, place search, place details, elevation, and timezone.
About
Provides script-first access to Google Maps Platform APIs for geocoding, routing, and place lookups. A developer uses it to run map queries from the command line with a Google Maps API key.
- Covers geocoding, routing, place search, elevation, timezone
- Script-first with raw Google Maps JSON output
Gmaps by the numbers
- 5 all-time installs (skills.sh)
- Ranked #3,685 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/deusyu/google-maps-skill --skill gmapsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 10 |
| Last updated | June 26, 2026 |
| Repository | deusyu/google-maps-skill ↗ |
What it does
Call the Google Maps Platform API via scripts for geocoding, reverse geocoding, routing, place search, place details, elevation, and timezone.
Files
Google Maps Skill
Quick Start
1. Ensure GOOGLE_MAPS_API_KEY is set. 2. Run bun scripts/gmaps.ts --help in this skill directory. 3. Pick the matching command from references/command-map.md.
Workflow
1. Validate user intent and select one command. 2. Coordinates use lat,lng order (Google convention). 3. Keep output as raw Google Maps JSON without wrapping fields. 4. Treat any API business error as failure.
Commands
- Full command mapping:
references/command-map.md - Ready-to-run examples:
references/examples.md
Notes
- This skill is script-first and does not run an MCP server.
- Only
GOOGLE_MAPS_API_KEYis supported.
Command Map
| Command | Method | API | Auth | Required Flags | Optional Flags |
|---|---|---|---|---|---|
geocode | GET | Geocoding | query | --address | — |
reverse-geocode | GET | Geocoding | query | --latlng | — |
directions | POST | Routes v2 | header | --origin, --dest | --mode (DRIVE/WALK/BICYCLE/TRANSIT) |
places-search | POST | Places v1 | header | --query | --location, --radius |
places-nearby | POST | Places v1 | header | --location, --radius | --type |
place-detail | GET | Places v1 | header | --place-id | — |
elevation | GET | Elevation | query | --locations | — |
timezone | GET | Timezone | query | --location | --timestamp |
Coordinate Order
- Google Maps uses lat,lng order (e.g.
35.6585,139.7454)
Auth Modes
- query: API key appended as
?key=query parameter (legacy APIs) - header: API key sent via
X-Goog-Api-Keyheader (new APIs)
Exit Codes
0: success2: input or config error3: network / timeout / HTTP transport error4: Google Maps API business error5: unexpected internal error
API Key
- Required env var:
GOOGLE_MAPS_API_KEY
Examples
Geocode
GOOGLE_MAPS_API_KEY=your_key bun scripts/gmaps.ts geocode --address "Tokyo Tower"Reverse Geocode
GOOGLE_MAPS_API_KEY=your_key bun scripts/gmaps.ts reverse-geocode --latlng 35.6585805,139.7454329Directions (driving, default)
GOOGLE_MAPS_API_KEY=your_key bun scripts/gmaps.ts directions \
--origin "Shibuya Station" \
--dest "Tokyo Tower"Directions (transit)
GOOGLE_MAPS_API_KEY=your_key bun scripts/gmaps.ts directions \
--origin "Shibuya Station" \
--dest "Tokyo Tower" \
--mode TRANSITPlaces Text Search
GOOGLE_MAPS_API_KEY=your_key bun scripts/gmaps.ts places-search --query "ramen near Shinjuku"Places Text Search (with location bias)
GOOGLE_MAPS_API_KEY=your_key bun scripts/gmaps.ts places-search \
--query "coffee" \
--location 35.6585,139.7454 \
--radius 1000Places Nearby
GOOGLE_MAPS_API_KEY=your_key bun scripts/gmaps.ts places-nearby \
--location 35.6585,139.7454 \
--radius 500 \
--type restaurantPlace Detail
GOOGLE_MAPS_API_KEY=your_key bun scripts/gmaps.ts place-detail \
--place-id ChIJCewJkL2LGGAR2HQ6PeTfivUElevation
GOOGLE_MAPS_API_KEY=your_key bun scripts/gmaps.ts elevation \
--locations "35.6585,139.7454|34.0522,-118.2437"Timezone
GOOGLE_MAPS_API_KEY=your_key bun scripts/gmaps.ts timezone \
--location 35.6585,139.7454 \
--timestamp 1672531200import { isRecord } from "../lib/config.ts";
import type { CommandDef } from "../lib/types.ts";
import { getNewApiErrorMessage } from "../lib/types.ts";
const VALID_MODES = ["DRIVE", "WALK", "BICYCLE", "TRANSIT"] as const;
function validateMode(value: string): string | null {
if (!(VALID_MODES as readonly string[]).includes(value)) {
return `must be one of ${VALID_MODES.join(", ")}`;
}
return null;
}
const directions: CommandDef = {
name: "directions",
description: "Compute routes between an origin and destination using the Routes API.",
usage: "directions --origin <text> --dest <text> [--mode DRIVE|WALK|BICYCLE|TRANSIT]",
method: "POST",
url: "https://routes.googleapis.com/directions/v2:computeRoutes",
auth: "header",
fieldMask: "routes.duration,routes.distanceMeters,routes.polyline.encodedPolyline,routes.legs",
flags: {
origin: {
description: "Origin address or lat,lng",
required: true,
placeholder: "<text>",
},
dest: {
description: "Destination address or lat,lng",
required: true,
placeholder: "<text>",
},
mode: {
description: "Travel mode",
required: false,
placeholder: "DRIVE|WALK|BICYCLE|TRANSIT",
validate: validateMode,
},
},
buildRequest: (flags) => ({
body: {
origin: { address: flags.origin },
destination: { address: flags.dest },
travelMode: flags.mode ?? "DRIVE",
},
}),
checkSuccess: (httpStatus, payload) => {
if (httpStatus < 200 || httpStatus >= 300) return false;
if (!isRecord(payload)) return false;
const routes = payload.routes;
if (!Array.isArray(routes) || routes.length === 0) return false;
return true;
},
getErrorMessage: (payload) => {
if (isRecord(payload) && (!Array.isArray(payload.routes) || payload.routes.length === 0)) {
return "No routes found. TRANSIT mode has limited regional coverage.";
}
return getNewApiErrorMessage(payload);
},
};
export default directions;
import type { CommandDef } from "../lib/types.ts";
import { checkLegacySuccess, getLegacyErrorMessage } from "../lib/types.ts";
function validateLocations(value: string): string | null {
const points = value.split("|");
for (const point of points) {
const parts = point.trim().split(",");
if (parts.length !== 2) {
return "each point must be in <lat,lng> format, separated by |";
}
const lat = Number(parts[0]?.trim());
const lng = Number(parts[1]?.trim());
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return "each point must contain valid numbers";
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return "latitude must be -90..90, longitude must be -180..180";
}
}
return null;
}
const elevation: CommandDef = {
name: "elevation",
description: "Get elevation data for locations on the earth.",
usage: "elevation --locations <lat,lng|lat,lng|...>",
method: "GET",
url: "https://maps.googleapis.com/maps/api/elevation/json",
auth: "query",
flags: {
locations: {
description: "Pipe-separated lat,lng pairs",
required: true,
placeholder: "<lat,lng|lat,lng|...>",
validate: validateLocations,
},
},
buildRequest: (flags) => ({
params: { locations: flags.locations! },
}),
checkSuccess: checkLegacySuccess,
getErrorMessage: getLegacyErrorMessage,
};
export default elevation;
import type { CommandDef } from "../lib/types.ts";
import { checkLegacySuccess, getLegacyErrorMessage } from "../lib/types.ts";
const geocode: CommandDef = {
name: "geocode",
description: "Convert an address to geographic coordinates.",
usage: "geocode --address <text>",
method: "GET",
url: "https://maps.googleapis.com/maps/api/geocode/json",
auth: "query",
flags: {
address: {
description: "Address to geocode",
required: true,
placeholder: "<text>",
},
},
buildRequest: (flags) => ({
params: { address: flags.address! },
}),
checkSuccess: checkLegacySuccess,
getErrorMessage: getLegacyErrorMessage,
};
export default geocode;
import type { CommandDef } from "../lib/types.ts";
import geocode from "./geocode.ts";
import reverseGeocode from "./reverse-geocode.ts";
import directions from "./directions.ts";
import placesSearch from "./places-search.ts";
import placesNearby from "./places-nearby.ts";
import placeDetail from "./place-detail.ts";
import elevation from "./elevation.ts";
import timezone from "./timezone.ts";
export const COMMAND_ORDER: CommandDef[] = [
geocode,
reverseGeocode,
directions,
placesSearch,
placesNearby,
placeDetail,
elevation,
timezone,
];
export const COMMAND_REGISTRY = new Map<string, CommandDef>(
COMMAND_ORDER.map((cmd) => [cmd.name, cmd]),
);
import type { CommandDef } from "../lib/types.ts";
import { checkNewApiSuccess, getNewApiErrorMessage } from "../lib/types.ts";
const placeDetail: CommandDef = {
name: "place-detail",
description: "Get detailed information about a specific place by its place ID.",
usage: "place-detail --place-id <id>",
method: "GET",
url: "https://places.googleapis.com/v1/places",
auth: "header",
fieldMask: "id,displayName,formattedAddress,location,rating,websiteUri,nationalPhoneNumber,regularOpeningHours,types,editorialSummary",
flags: {
"place-id": {
description: "Google Maps place ID",
required: true,
placeholder: "<id>",
},
},
buildUrl: (flags) => `https://places.googleapis.com/v1/places/${flags["place-id"]}`,
buildRequest: () => ({}),
checkSuccess: checkNewApiSuccess,
getErrorMessage: getNewApiErrorMessage,
};
export default placeDetail;
import type { CommandDef } from "../lib/types.ts";
import { checkNewApiSuccess, getNewApiErrorMessage } from "../lib/types.ts";
function validateLocation(value: string): string | null {
const parts = value.split(",");
if (parts.length !== 2) {
return "must be in <lat,lng> format";
}
const lat = Number(parts[0]?.trim());
const lng = Number(parts[1]?.trim());
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return "must contain valid numbers";
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return "latitude must be -90..90, longitude must be -180..180";
}
return null;
}
function validateRadius(value: string): string | null {
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) {
return "must be a positive number (meters)";
}
return null;
}
const placesNearby: CommandDef = {
name: "places-nearby",
description: "Search for places near a location using the Places API (New).",
usage: "places-nearby --location <lat,lng> --radius <meters> [--type <place_type>]",
method: "POST",
url: "https://places.googleapis.com/v1/places:searchNearby",
auth: "header",
fieldMask: "places.displayName,places.formattedAddress,places.id,places.location,places.rating,places.types",
flags: {
location: {
description: "Center point for nearby search",
required: true,
placeholder: "<lat,lng>",
validate: validateLocation,
},
radius: {
description: "Search radius in meters",
required: true,
placeholder: "<meters>",
validate: validateRadius,
},
type: {
description: "Place type filter (e.g. restaurant, cafe, hotel)",
required: false,
placeholder: "<place_type>",
},
},
buildRequest: (flags) => {
const [lat, lng] = flags.location!.split(",").map(Number);
const body: Record<string, unknown> = {
locationRestriction: {
circle: {
center: { latitude: lat, longitude: lng },
radius: Number(flags.radius),
},
},
};
if (flags.type) {
body.includedTypes = [flags.type];
}
return { body };
},
checkSuccess: checkNewApiSuccess,
getErrorMessage: getNewApiErrorMessage,
};
export default placesNearby;
import type { CommandDef } from "../lib/types.ts";
import { checkNewApiSuccess, getNewApiErrorMessage } from "../lib/types.ts";
function validateRadius(value: string): string | null {
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) {
return "must be a positive number (meters)";
}
return null;
}
function validateLocation(value: string): string | null {
const parts = value.split(",");
if (parts.length !== 2) {
return "must be in <lat,lng> format";
}
const lat = Number(parts[0]?.trim());
const lng = Number(parts[1]?.trim());
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return "must contain valid numbers";
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return "latitude must be -90..90, longitude must be -180..180";
}
return null;
}
const placesSearch: CommandDef = {
name: "places-search",
description: "Search for places by text query using the Places API (New).",
usage: "places-search --query <text> [--location <lat,lng>] [--radius <meters>]",
method: "POST",
url: "https://places.googleapis.com/v1/places:searchText",
auth: "header",
fieldMask: "places.displayName,places.formattedAddress,places.id,places.location,places.rating,places.types",
flags: {
query: {
description: "Text query for place search",
required: true,
placeholder: "<text>",
},
location: {
description: "Center point for location bias",
required: false,
placeholder: "<lat,lng>",
validate: validateLocation,
},
radius: {
description: "Radius in meters for location bias",
required: false,
placeholder: "<meters>",
validate: validateRadius,
},
},
buildRequest: (flags) => {
const body: Record<string, unknown> = {
textQuery: flags.query,
};
if (flags.location) {
const [lat, lng] = flags.location.split(",").map(Number);
const locationBias: Record<string, unknown> = {
circle: {
center: { latitude: lat, longitude: lng },
radius: flags.radius ? Number(flags.radius) : 5000,
},
};
body.locationBias = locationBias;
}
return { body };
},
checkSuccess: checkNewApiSuccess,
getErrorMessage: getNewApiErrorMessage,
};
export default placesSearch;
import type { CommandDef } from "../lib/types.ts";
import { checkLegacySuccess, getLegacyErrorMessage } from "../lib/types.ts";
function validateLatLng(value: string): string | null {
const parts = value.split(",");
if (parts.length !== 2) {
return "must be in <lat,lng> format";
}
const lat = Number(parts[0]?.trim());
const lng = Number(parts[1]?.trim());
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return "must contain valid numbers";
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return "latitude must be -90..90, longitude must be -180..180";
}
return null;
}
const reverseGeocode: CommandDef = {
name: "reverse-geocode",
description: "Convert coordinates to a human-readable address.",
usage: "reverse-geocode --latlng <lat,lng>",
method: "GET",
url: "https://maps.googleapis.com/maps/api/geocode/json",
auth: "query",
flags: {
latlng: {
description: "Latitude,longitude pair",
required: true,
placeholder: "<lat,lng>",
validate: validateLatLng,
},
},
buildRequest: (flags) => ({
params: { latlng: flags.latlng! },
}),
checkSuccess: checkLegacySuccess,
getErrorMessage: getLegacyErrorMessage,
};
export default reverseGeocode;
import type { CommandDef } from "../lib/types.ts";
import { checkLegacySuccess, getLegacyErrorMessage } from "../lib/types.ts";
function validateLocation(value: string): string | null {
const parts = value.split(",");
if (parts.length !== 2) {
return "must be in <lat,lng> format";
}
const lat = Number(parts[0]?.trim());
const lng = Number(parts[1]?.trim());
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return "must contain valid numbers";
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return "latitude must be -90..90, longitude must be -180..180";
}
return null;
}
function validateTimestamp(value: string): string | null {
const num = Number(value);
if (!Number.isFinite(num) || num < 0) {
return "must be a non-negative Unix timestamp";
}
return null;
}
const timezone: CommandDef = {
name: "timezone",
description: "Get timezone information for a location and timestamp.",
usage: "timezone --location <lat,lng> [--timestamp <unix_seconds>]",
method: "GET",
url: "https://maps.googleapis.com/maps/api/timezone/json",
auth: "query",
flags: {
location: {
description: "Latitude,longitude pair",
required: true,
placeholder: "<lat,lng>",
validate: validateLocation,
},
timestamp: {
description: "Unix timestamp (defaults to current time)",
required: false,
placeholder: "<unix_seconds>",
validate: validateTimestamp,
},
},
buildRequest: (flags) => ({
params: {
location: flags.location!,
timestamp: flags.timestamp ?? String(Math.floor(Date.now() / 1000)),
},
}),
checkSuccess: checkLegacySuccess,
getErrorMessage: getLegacyErrorMessage,
};
export default timezone;
#!/usr/bin/env bun
import { CliError, ExitCode, toErrorMessage } from "./lib/config.ts";
import { validateFlags } from "./lib/validate.ts";
import { executeCommandDef } from "./lib/engine.ts";
import { COMMAND_ORDER, COMMAND_REGISTRY } from "./commands/index.ts";
function printJson(payload: unknown): void {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
function printGlobalHelp(): void {
const lines: string[] = [];
lines.push("Google Maps Script CLI");
lines.push("");
lines.push("Usage:");
lines.push(" bun scripts/gmaps.ts <command> [flags]");
lines.push("");
lines.push("Commands:");
for (const command of COMMAND_ORDER) {
lines.push(` ${command.usage}`);
}
lines.push("");
lines.push("Tips:");
lines.push(" - Set GOOGLE_MAPS_API_KEY before running commands");
lines.push(" - Use --help after a command for command-specific details");
process.stdout.write(`${lines.join("\n")}\n`);
}
function printCommandHelp(command: { usage: string; description: string; flags: Record<string, { description: string; required: boolean; placeholder: string }> }): void {
const lines: string[] = [];
lines.push(`Usage: ${command.usage}`);
lines.push(command.description);
lines.push("");
lines.push("Flags:");
for (const [key, def] of Object.entries(command.flags)) {
const req = def.required ? "(required)" : "(optional)";
lines.push(` --${key} ${def.placeholder} ${def.description} ${req}`);
}
process.stdout.write(`${lines.join("\n")}\n`);
}
function parseFlags(tokens: string[]): Record<string, string> {
const flags: Record<string, string> = {};
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) {
continue;
}
if (!token.startsWith("--")) {
throw new CliError(`Invalid token: ${token}. Use --key value format.`, ExitCode.PARAM_OR_CONFIG);
}
const trimmed = token.slice(2);
if (trimmed.length === 0) {
throw new CliError("Empty flag name is not allowed.", ExitCode.PARAM_OR_CONFIG);
}
let key = trimmed;
let value: string | undefined;
const equalIndex = trimmed.indexOf("=");
if (equalIndex >= 0) {
key = trimmed.slice(0, equalIndex);
value = trimmed.slice(equalIndex + 1);
} else {
const next = tokens[index + 1];
if (!next || next.startsWith("--")) {
throw new CliError(`Flag --${key} is missing a value.`, ExitCode.PARAM_OR_CONFIG);
}
value = next;
index += 1;
}
if (key.length === 0) {
throw new CliError("Flag name cannot be empty.", ExitCode.PARAM_OR_CONFIG);
}
if (Object.hasOwn(flags, key)) {
throw new CliError(`Duplicate flag: --${key}`, ExitCode.PARAM_OR_CONFIG);
}
flags[key] = value;
}
return flags;
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
printGlobalHelp();
process.exit(ExitCode.OK);
}
const commandToken = args[0]!;
const command = COMMAND_REGISTRY.get(commandToken);
if (!command) {
throw new CliError(`Unknown command: ${commandToken}`, ExitCode.PARAM_OR_CONFIG);
}
const restArgs = args.slice(1);
if (restArgs.length === 1 && (restArgs[0] === "--help" || restArgs[0] === "-h")) {
printCommandHelp(command);
process.exit(ExitCode.OK);
}
const rawFlags = parseFlags(restArgs);
const validatedFlags = validateFlags(command, rawFlags);
const response = await executeCommandDef(command, validatedFlags);
printJson(response);
process.exit(ExitCode.OK);
}
void main().catch((error: unknown) => {
if (error instanceof CliError) {
if (error.rawResponse !== undefined) {
printJson(error.rawResponse);
} else {
process.stderr.write(`${error.message}\n`);
}
process.exit(error.exitCode);
}
process.stderr.write(`Unexpected internal error: ${toErrorMessage(error)}\n`);
process.exit(ExitCode.INTERNAL);
});
export const GMAPS_API_KEY_ENV = "GOOGLE_MAPS_API_KEY";
export const DEFAULT_TIMEOUT_MS = 15_000;
export const DEFAULT_RETRY_COUNT = 2;
export const DEFAULT_BACKOFF_MS = 200;
export enum ExitCode {
OK = 0,
PARAM_OR_CONFIG = 2,
NETWORK = 3,
API_BUSINESS = 4,
INTERNAL = 5,
}
export interface CliErrorOptions {
cause?: unknown;
rawResponse?: unknown;
}
export class CliError extends Error {
public readonly exitCode: ExitCode;
public readonly rawResponse?: unknown;
constructor(message: string, exitCode: ExitCode, options: CliErrorOptions = {}) {
super(message, { cause: options.cause });
this.name = "CliError";
this.exitCode = exitCode;
this.rawResponse = options.rawResponse;
}
}
export function getGmapsApiKey(env: NodeJS.ProcessEnv = process.env): string {
const key = env[GMAPS_API_KEY_ENV];
if (!key || key.trim().length === 0) {
throw new CliError(
`${GMAPS_API_KEY_ENV} is required. Please export a valid Google Maps API key before running commands.`,
ExitCode.PARAM_OR_CONFIG,
);
}
return key.trim();
}
export function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
import { CliError, ExitCode, getGmapsApiKey } from "./config.ts";
import { requestWithRetry, type HttpRequest } from "./http.ts";
import type { CommandDef, HttpResult } from "./types.ts";
export interface EngineDependencies {
requestFn?: (request: HttpRequest) => Promise<HttpResult>;
apiKey?: string;
timeoutMs?: number;
retries?: number;
}
export async function executeCommandDef(
command: CommandDef,
flags: Record<string, string>,
dependencies: EngineDependencies = {},
): Promise<unknown> {
const apiKey = dependencies.apiKey ?? getGmapsApiKey();
const requestFn = dependencies.requestFn ?? requestWithRetry;
const { params, body } = command.buildRequest(flags);
const url = command.buildUrl ? command.buildUrl(flags) : command.url;
const headers: Record<string, string> = {};
let requestParams: Record<string, string> | undefined = params;
if (command.auth === "query") {
requestParams = { ...params, key: apiKey };
} else {
headers["X-Goog-Api-Key"] = apiKey;
}
if (command.fieldMask) {
headers["X-Goog-FieldMask"] = command.fieldMask;
}
const httpRequest: HttpRequest = {
method: command.method,
url,
params: requestParams,
headers,
body,
timeoutMs: dependencies.timeoutMs,
retries: dependencies.retries,
};
const { status, payload } = await requestFn(httpRequest);
if (!command.checkSuccess(status, payload)) {
throw new CliError(
`Google Maps API error for ${command.name}: ${command.getErrorMessage(payload)}`,
ExitCode.API_BUSINESS,
{ rawResponse: payload },
);
}
return payload;
}
import {
CliError,
DEFAULT_BACKOFF_MS,
DEFAULT_RETRY_COUNT,
DEFAULT_TIMEOUT_MS,
ExitCode,
toErrorMessage,
} from "./config.ts";
import type { HttpResult } from "./types.ts";
export interface HttpRequest {
method: "GET" | "POST";
url: string;
params?: Record<string, string>;
headers?: Record<string, string>;
body?: unknown;
timeoutMs?: number;
retries?: number;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildUrlWithParams(url: string, params?: Record<string, string>): string {
if (!params || Object.keys(params).length === 0) {
return url;
}
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
query.set(key, value);
}
return `${url}?${query.toString()}`;
}
export async function requestWithRetry(request: HttpRequest): Promise<HttpResult> {
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const retries = request.retries ?? DEFAULT_RETRY_COUNT;
const fullUrl = request.method === "GET"
? buildUrlWithParams(request.url, request.params)
: request.url;
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const isLastAttempt = attempt === retries;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const fetchOptions: RequestInit = {
method: request.method,
signal: controller.signal,
headers: {
Accept: "application/json",
...request.headers,
},
};
if (request.method === "POST" && request.body !== undefined) {
fetchOptions.body = JSON.stringify(request.body);
(fetchOptions.headers as Record<string, string>)["Content-Type"] = "application/json";
}
const response = await fetch(fullUrl, fetchOptions);
clearTimeout(timer);
let payload: unknown;
try {
payload = await response.json();
} catch (jsonError) {
throw new CliError(
`Failed to parse JSON response from ${fullUrl}: ${toErrorMessage(jsonError)}`,
ExitCode.NETWORK,
{ cause: jsonError },
);
}
if (!response.ok) {
if (!isLastAttempt && (response.status >= 500 || response.status === 429)) {
await sleep(DEFAULT_BACKOFF_MS * 2 ** attempt);
continue;
}
}
return { status: response.status, payload };
} catch (error) {
lastError = error;
if (error instanceof CliError) {
throw error;
}
if (!isLastAttempt) {
await sleep(DEFAULT_BACKOFF_MS * 2 ** attempt);
continue;
}
}
}
if (lastError instanceof CliError) {
throw lastError;
}
throw new CliError(
`Network request failed after retries: ${toErrorMessage(lastError)}`,
ExitCode.NETWORK,
{ cause: lastError },
);
}
import { isRecord } from "./config.ts";
export interface FlagDef {
description: string;
required: boolean;
placeholder: string;
validate?: (value: string) => string | null;
}
export interface HttpResult {
status: number;
payload: unknown;
}
export interface CommandDef {
name: string;
description: string;
usage: string;
method: "GET" | "POST";
url: string;
auth: "query" | "header";
fieldMask?: string;
flags: Record<string, FlagDef>;
buildUrl?: (flags: Record<string, string>) => string;
buildRequest: (flags: Record<string, string>) => { params?: Record<string, string>; body?: unknown };
checkSuccess: (httpStatus: number, payload: unknown) => boolean;
getErrorMessage: (payload: unknown) => string;
}
export function checkLegacySuccess(_httpStatus: number, payload: unknown): boolean {
if (!isRecord(payload)) {
return false;
}
return payload.status === "OK";
}
export function checkNewApiSuccess(httpStatus: number, _payload: unknown): boolean {
return httpStatus >= 200 && httpStatus < 300;
}
export function getLegacyErrorMessage(payload: unknown): string {
if (!isRecord(payload)) {
return "Unknown API response format";
}
if (typeof payload.error_message === "string" && payload.error_message.length > 0) {
return payload.error_message;
}
if (typeof payload.status === "string" && payload.status.length > 0) {
return payload.status;
}
return "Unknown API error";
}
export function getNewApiErrorMessage(payload: unknown): string {
if (!isRecord(payload)) {
return "Unknown API response format";
}
if (isRecord(payload.error) && typeof payload.error.message === "string") {
return payload.error.message;
}
if (typeof payload.message === "string" && payload.message.length > 0) {
return payload.message;
}
return "Unknown API error";
}
import { CliError, ExitCode } from "./config.ts";
import type { CommandDef } from "./types.ts";
export function validateFlags(
command: CommandDef,
rawFlags: Record<string, string>,
): Record<string, string> {
const allowedKeys = new Set(Object.keys(command.flags));
const unknownFlags: string[] = [];
for (const key of Object.keys(rawFlags)) {
if (!allowedKeys.has(key)) {
unknownFlags.push(`--${key}`);
}
}
if (unknownFlags.length > 0) {
throw new CliError(
`Invalid flags for ${command.name}: unknown flags: ${unknownFlags.join(", ")}`,
ExitCode.PARAM_OR_CONFIG,
);
}
const validated: Record<string, string> = {};
for (const [key, def] of Object.entries(command.flags)) {
const raw = rawFlags[key];
if (raw === undefined) {
if (def.required) {
throw new CliError(
`Invalid flags for ${command.name}: --${key} is required`,
ExitCode.PARAM_OR_CONFIG,
);
}
continue;
}
const trimmed = raw.trim();
if (trimmed.length === 0) {
throw new CliError(
`Invalid flags for ${command.name}: --${key} must be a non-empty string`,
ExitCode.PARAM_OR_CONFIG,
);
}
if (def.validate) {
const errorMsg = def.validate(trimmed);
if (errorMsg !== null) {
throw new CliError(
`Invalid flags for ${command.name}: --${key}: ${errorMsg}`,
ExitCode.PARAM_OR_CONFIG,
);
}
}
validated[key] = trimmed;
}
return validated;
}
import { describe, expect, it } from "bun:test";
const CLI_PATH = new URL("../scripts/gmaps.ts", import.meta.url).pathname;
interface CliResult {
exitCode: number;
stdout: string;
stderr: string;
}
async function runCli(args: string[], envOverrides: Record<string, string | null> = {}): Promise<CliResult> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (typeof value === "string") {
env[key] = value;
}
}
for (const [key, value] of Object.entries(envOverrides)) {
if (value === null) {
delete env[key];
} else {
env[key] = value;
}
}
const processHandle = Bun.spawn(["bun", CLI_PATH, ...args], {
env,
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(processHandle.stdout).text();
const stderr = await new Response(processHandle.stderr).text();
const exitCode = await processHandle.exited;
return { exitCode, stdout, stderr };
}
describe("gmaps CLI", () => {
it("shows global help", async () => {
const result = await runCli(["--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Google Maps Script CLI");
expect(result.stdout).toContain("geocode --address");
expect(result.stdout).toContain("directions --origin");
});
it("shows global help when no arguments", async () => {
const result = await runCli([]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Google Maps Script CLI");
});
it("shows command-specific help", async () => {
const result = await runCli(["geocode", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("geocode --address");
expect(result.stdout).toContain("Address to geocode");
});
it("shows directions command help with flags", async () => {
const result = await runCli(["directions", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--origin");
expect(result.stdout).toContain("--dest");
expect(result.stdout).toContain("--mode");
expect(result.stdout).toContain("(optional)");
});
it("returns exit code 2 for unknown command", async () => {
const result = await runCli(["unknown-command"]);
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain("Unknown command");
});
it("returns exit code 2 when API key is missing", async () => {
const result = await runCli(["geocode", "--address", "Tokyo Tower"], {
GOOGLE_MAPS_API_KEY: null,
});
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain("GOOGLE_MAPS_API_KEY is required");
});
it("returns exit code 2 for invalid latlng format", async () => {
const result = await runCli(["reverse-geocode", "--latlng", "invalid"]);
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain("Invalid flags");
});
it("returns exit code 2 for missing required flag", async () => {
const result = await runCli(["geocode"], {
GOOGLE_MAPS_API_KEY: "test-key",
});
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain("--address is required");
});
it("returns exit code 2 for unknown flags", async () => {
const result = await runCli(["geocode", "--address", "Tokyo", "--foo", "bar"], {
GOOGLE_MAPS_API_KEY: "test-key",
});
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain("unknown flags");
expect(result.stderr).toContain("--foo");
});
it("returns exit code 2 for invalid directions mode", async () => {
const result = await runCli(["directions", "--origin", "A", "--dest", "B", "--mode", "FLY"], {
GOOGLE_MAPS_API_KEY: "test-key",
});
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain("must be one of");
});
});
import { beforeAll, describe, expect, it } from "bun:test";
import { executeCommandDef } from "../scripts/lib/engine.ts";
import { CliError, ExitCode } from "../scripts/lib/config.ts";
import type { HttpRequest } from "../scripts/lib/http.ts";
import type { HttpResult } from "../scripts/lib/types.ts";
import { COMMAND_REGISTRY } from "../scripts/commands/index.ts";
const fixturePath = (name: string) => new URL(`./fixtures/${name}`, import.meta.url);
async function loadFixture(name: string): Promise<unknown> {
return JSON.parse(await Bun.file(fixturePath(name)).text());
}
function mockRequestFn(payload: unknown, status = 200): (request: HttpRequest) => Promise<HttpResult> {
return async () => ({ status, payload });
}
describe("engine: geocode", () => {
let geocodeSuccess: unknown;
let geocodeError: unknown;
beforeAll(async () => {
geocodeSuccess = await loadFixture("geocode-success.json");
geocodeError = await loadFixture("geocode-error.json");
});
it("returns raw geocode JSON on success", async () => {
const calls: HttpRequest[] = [];
const command = COMMAND_REGISTRY.get("geocode")!;
const result = await executeCommandDef(
command,
{ address: "Tokyo Tower" },
{
apiKey: "test-key",
requestFn: async (request) => {
calls.push(request);
return { status: 200, payload: geocodeSuccess };
},
},
);
expect(result).toEqual(geocodeSuccess);
expect(calls).toHaveLength(1);
expect(calls[0]?.url).toContain("geocode");
expect(calls[0]?.params?.key).toBe("test-key");
expect(calls[0]?.params?.address).toBe("Tokyo Tower");
});
it("uses query auth for legacy APIs", async () => {
const calls: HttpRequest[] = [];
const command = COMMAND_REGISTRY.get("geocode")!;
await executeCommandDef(
command,
{ address: "Tokyo" },
{
apiKey: "test-key",
requestFn: async (request) => {
calls.push(request);
return { status: 200, payload: geocodeSuccess };
},
},
);
expect(calls[0]?.params?.key).toBe("test-key");
expect(calls[0]?.headers?.["X-Goog-Api-Key"]).toBeUndefined();
});
it("fails with exit code 4 when geocode returns non-OK status", async () => {
const command = COMMAND_REGISTRY.get("geocode")!;
try {
await executeCommandDef(
command,
{ address: "invalid" },
{
apiKey: "test-key",
requestFn: mockRequestFn(geocodeError),
},
);
expect(true).toBe(false);
} catch (error) {
expect(error).toBeInstanceOf(CliError);
const cliError = error as CliError;
expect(cliError.exitCode).toBe(ExitCode.API_BUSINESS);
expect(cliError.rawResponse).toEqual(geocodeError);
}
});
});
describe("engine: directions (POST + header auth)", () => {
let directionsSuccess: unknown;
let newApiError: unknown;
beforeAll(async () => {
directionsSuccess = await loadFixture("directions-success.json");
newApiError = await loadFixture("new-api-error.json");
});
it("returns routes on success", async () => {
const calls: HttpRequest[] = [];
const command = COMMAND_REGISTRY.get("directions")!;
const result = await executeCommandDef(
command,
{ origin: "Shibuya Station", dest: "Tokyo Tower" },
{
apiKey: "test-key",
requestFn: async (request) => {
calls.push(request);
return { status: 200, payload: directionsSuccess };
},
},
);
expect(result).toEqual(directionsSuccess);
expect(calls).toHaveLength(1);
expect(calls[0]?.method).toBe("POST");
expect(calls[0]?.headers?.["X-Goog-Api-Key"]).toBe("test-key");
expect(calls[0]?.headers?.["X-Goog-FieldMask"]).toContain("routes");
expect(calls[0]?.body).toEqual({
origin: { address: "Shibuya Station" },
destination: { address: "Tokyo Tower" },
travelMode: "DRIVE",
});
});
it("sends correct travel mode", async () => {
const calls: HttpRequest[] = [];
const command = COMMAND_REGISTRY.get("directions")!;
await executeCommandDef(
command,
{ origin: "A", dest: "B", mode: "TRANSIT" },
{
apiKey: "test-key",
requestFn: async (request) => {
calls.push(request);
return { status: 200, payload: directionsSuccess };
},
},
);
const body = calls[0]?.body as Record<string, unknown>;
expect(body.travelMode).toBe("TRANSIT");
});
it("fails with exit code 4 when routes array is empty", async () => {
const command = COMMAND_REGISTRY.get("directions")!;
try {
await executeCommandDef(
command,
{ origin: "Shibuya Station", dest: "Tokyo Tower", mode: "TRANSIT" },
{
apiKey: "test-key",
requestFn: mockRequestFn({}, 200),
},
);
expect(true).toBe(false);
} catch (error) {
expect(error).toBeInstanceOf(CliError);
const cliError = error as CliError;
expect(cliError.exitCode).toBe(ExitCode.API_BUSINESS);
expect(cliError.message).toContain("No routes found");
}
});
it("fails with exit code 4 on new API error response", async () => {
const command = COMMAND_REGISTRY.get("directions")!;
try {
await executeCommandDef(
command,
{ origin: "A", dest: "B" },
{
apiKey: "test-key",
requestFn: mockRequestFn(newApiError, 400),
},
);
expect(true).toBe(false);
} catch (error) {
expect(error).toBeInstanceOf(CliError);
const cliError = error as CliError;
expect(cliError.exitCode).toBe(ExitCode.API_BUSINESS);
expect(cliError.rawResponse).toEqual(newApiError);
}
});
});
describe("engine: places-search", () => {
let placesSuccess: unknown;
beforeAll(async () => {
placesSuccess = await loadFixture("places-search-success.json");
});
it("sends POST with header auth and fieldMask", async () => {
const calls: HttpRequest[] = [];
const command = COMMAND_REGISTRY.get("places-search")!;
await executeCommandDef(
command,
{ query: "Tokyo Tower" },
{
apiKey: "test-key",
requestFn: async (request) => {
calls.push(request);
return { status: 200, payload: placesSuccess };
},
},
);
expect(calls[0]?.headers?.["X-Goog-Api-Key"]).toBe("test-key");
expect(calls[0]?.headers?.["X-Goog-FieldMask"]).toBeDefined();
expect(calls[0]?.body).toEqual({ textQuery: "Tokyo Tower" });
});
it("includes location bias when location flag provided", async () => {
const calls: HttpRequest[] = [];
const command = COMMAND_REGISTRY.get("places-search")!;
await executeCommandDef(
command,
{ query: "coffee", location: "35.6585,139.7454", radius: "1000" },
{
apiKey: "test-key",
requestFn: async (request) => {
calls.push(request);
return { status: 200, payload: placesSuccess };
},
},
);
const body = calls[0]?.body as Record<string, unknown>;
expect(body.textQuery).toBe("coffee");
expect(body.locationBias).toBeDefined();
});
});
describe("engine: place-detail (buildUrl)", () => {
it("uses dynamic URL with place ID", async () => {
const calls: HttpRequest[] = [];
const command = COMMAND_REGISTRY.get("place-detail")!;
await executeCommandDef(
command,
{ "place-id": "ChIJCewJkL2LGGAR2HQ6PeTfivU" },
{
apiKey: "test-key",
requestFn: async (request) => {
calls.push(request);
return { status: 200, payload: { id: "ChIJCewJkL2LGGAR2HQ6PeTfivU" } };
},
},
);
expect(calls[0]?.url).toContain("ChIJCewJkL2LGGAR2HQ6PeTfivU");
expect(calls[0]?.method).toBe("GET");
});
});
describe("engine: elevation", () => {
let elevationSuccess: unknown;
beforeAll(async () => {
elevationSuccess = await loadFixture("elevation-success.json");
});
it("sends locations as query parameter", async () => {
const calls: HttpRequest[] = [];
const command = COMMAND_REGISTRY.get("elevation")!;
const result = await executeCommandDef(
command,
{ locations: "35.6585,139.7454" },
{
apiKey: "test-key",
requestFn: async (request) => {
calls.push(request);
return { status: 200, payload: elevationSuccess };
},
},
);
expect(result).toEqual(elevationSuccess);
expect(calls[0]?.params?.locations).toBe("35.6585,139.7454");
expect(calls[0]?.params?.key).toBe("test-key");
});
});
describe("engine: timezone", () => {
let timezoneSuccess: unknown;
beforeAll(async () => {
timezoneSuccess = await loadFixture("timezone-success.json");
});
it("sends location and timestamp as query params", async () => {
const calls: HttpRequest[] = [];
const command = COMMAND_REGISTRY.get("timezone")!;
const result = await executeCommandDef(
command,
{ location: "35.6585,139.7454", timestamp: "1672531200" },
{
apiKey: "test-key",
requestFn: async (request) => {
calls.push(request);
return { status: 200, payload: timezoneSuccess };
},
},
);
expect(result).toEqual(timezoneSuccess);
expect(calls[0]?.params?.location).toBe("35.6585,139.7454");
expect(calls[0]?.params?.timestamp).toBe("1672531200");
});
it("defaults timestamp to current time when not provided", async () => {
const calls: HttpRequest[] = [];
const command = COMMAND_REGISTRY.get("timezone")!;
await executeCommandDef(
command,
{ location: "35.6585,139.7454" },
{
apiKey: "test-key",
requestFn: async (request) => {
calls.push(request);
return { status: 200, payload: timezoneSuccess };
},
},
);
const ts = Number(calls[0]?.params?.timestamp);
expect(ts).toBeGreaterThan(0);
});
});
describe("engine: network failure", () => {
it("propagates network failures with exit code 3", async () => {
const command = COMMAND_REGISTRY.get("geocode")!;
try {
await executeCommandDef(
command,
{ address: "Tokyo" },
{
apiKey: "test-key",
requestFn: async () => {
throw new CliError("network timeout", ExitCode.NETWORK);
},
},
);
expect(true).toBe(false);
} catch (error) {
expect(error).toBeInstanceOf(CliError);
const cliError = error as CliError;
expect(cliError.exitCode).toBe(ExitCode.NETWORK);
expect(cliError.message).toContain("network timeout");
}
});
});
{
"routes": [
{
"distanceMeters": 3520,
"duration": "842s",
"polyline": {
"encodedPolyline": "a~l~Fjk~uOwHJy@P"
},
"legs": [
{
"distanceMeters": 3520,
"duration": "842s",
"startLocation": {
"latLng": { "latitude": 35.6580339, "longitude": 139.7016358 }
},
"endLocation": {
"latLng": { "latitude": 35.6585805, "longitude": 139.7454329 }
}
}
]
}
]
}
{
"results": [
{
"elevation": 17.3245792,
"location": { "lat": 35.6585805, "lng": 139.7454329 },
"resolution": 610.8129272
}
],
"status": "OK"
}
{
"results": [],
"status": "ZERO_RESULTS",
"error_message": "No results found for the given address."
}
{
"results": [
{
"address_components": [
{ "long_name": "Tokyo Tower", "short_name": "Tokyo Tower", "types": ["premise"] },
{ "long_name": "4-chōme-2-8", "short_name": "4-chōme-2-8", "types": ["sublocality"] },
{ "long_name": "Shibakoen", "short_name": "Shibakoen", "types": ["sublocality"] },
{ "long_name": "Minato City", "short_name": "Minato City", "types": ["locality"] },
{ "long_name": "Tokyo", "short_name": "Tokyo", "types": ["administrative_area_level_1"] },
{ "long_name": "Japan", "short_name": "JP", "types": ["country"] }
],
"formatted_address": "4-chōme-2-8 Shibakoen, Minato City, Tokyo 105-0011, Japan",
"geometry": {
"location": { "lat": 35.6585805, "lng": 139.7454329 },
"location_type": "ROOFTOP"
},
"place_id": "ChIJCewJkL2LGGAR2HQ6PeTfivU"
}
],
"status": "OK"
}
{
"error": {
"code": 400,
"message": "Request contains an invalid argument.",
"status": "INVALID_ARGUMENT"
}
}
{
"places": [
{
"id": "ChIJCewJkL2LGGAR2HQ6PeTfivU",
"formattedAddress": "4-chōme-2-8 Shibakoen, Minato City, Tokyo 105-0011, Japan",
"location": { "latitude": 35.6585805, "longitude": 139.7454329 },
"rating": 4.3,
"displayName": { "text": "Tokyo Tower", "languageCode": "en" },
"types": ["tourist_attraction", "point_of_interest"]
}
]
}
{
"dstOffset": 0,
"rawOffset": 32400,
"status": "OK",
"timeZoneId": "Asia/Tokyo",
"timeZoneName": "Japan Standard Time"
}
import { describe, expect, it } from "bun:test";
import { validateFlags } from "../scripts/lib/validate.ts";
import { CliError, ExitCode } from "../scripts/lib/config.ts";
import { COMMAND_REGISTRY } from "../scripts/commands/index.ts";
describe("validateFlags", () => {
it("validates geocode with required address", () => {
const command = COMMAND_REGISTRY.get("geocode")!;
const result = validateFlags(command, { address: "Tokyo Tower" });
expect(result.address).toBe("Tokyo Tower");
});
it("trims whitespace from values", () => {
const command = COMMAND_REGISTRY.get("geocode")!;
const result = validateFlags(command, { address: " Tokyo Tower " });
expect(result.address).toBe("Tokyo Tower");
});
it("throws on missing required flag", () => {
const command = COMMAND_REGISTRY.get("geocode")!;
expect(() => validateFlags(command, {})).toThrow(CliError);
try {
validateFlags(command, {});
} catch (error) {
const cliError = error as CliError;
expect(cliError.exitCode).toBe(ExitCode.PARAM_OR_CONFIG);
expect(cliError.message).toContain("--address is required");
}
});
it("throws on unknown flags", () => {
const command = COMMAND_REGISTRY.get("geocode")!;
try {
validateFlags(command, { address: "Tokyo", foo: "bar" });
} catch (error) {
const cliError = error as CliError;
expect(cliError.exitCode).toBe(ExitCode.PARAM_OR_CONFIG);
expect(cliError.message).toContain("unknown flags");
expect(cliError.message).toContain("--foo");
}
});
it("throws on empty string value", () => {
const command = COMMAND_REGISTRY.get("geocode")!;
try {
validateFlags(command, { address: " " });
} catch (error) {
const cliError = error as CliError;
expect(cliError.exitCode).toBe(ExitCode.PARAM_OR_CONFIG);
expect(cliError.message).toContain("non-empty");
}
});
it("validates reverse-geocode latlng format", () => {
const command = COMMAND_REGISTRY.get("reverse-geocode")!;
const result = validateFlags(command, { latlng: "35.6585,139.7454" });
expect(result.latlng).toBe("35.6585,139.7454");
});
it("rejects invalid latlng", () => {
const command = COMMAND_REGISTRY.get("reverse-geocode")!;
expect(() => validateFlags(command, { latlng: "invalid" })).toThrow(CliError);
expect(() => validateFlags(command, { latlng: "999,999" })).toThrow(CliError);
});
it("validates directions with optional mode", () => {
const command = COMMAND_REGISTRY.get("directions")!;
const result = validateFlags(command, { origin: "A", dest: "B" });
expect(result.origin).toBe("A");
expect(result.dest).toBe("B");
expect(result.mode).toBeUndefined();
});
it("validates directions mode enum", () => {
const command = COMMAND_REGISTRY.get("directions")!;
const result = validateFlags(command, { origin: "A", dest: "B", mode: "TRANSIT" });
expect(result.mode).toBe("TRANSIT");
});
it("rejects invalid directions mode", () => {
const command = COMMAND_REGISTRY.get("directions")!;
expect(() => validateFlags(command, { origin: "A", dest: "B", mode: "FLY" })).toThrow(CliError);
});
it("validates elevation locations", () => {
const command = COMMAND_REGISTRY.get("elevation")!;
const result = validateFlags(command, { locations: "35.6585,139.7454|34.0522,-118.2437" });
expect(result.locations).toBe("35.6585,139.7454|34.0522,-118.2437");
});
it("rejects invalid elevation locations", () => {
const command = COMMAND_REGISTRY.get("elevation")!;
expect(() => validateFlags(command, { locations: "not-a-coord" })).toThrow(CliError);
});
it("validates timezone with optional timestamp", () => {
const command = COMMAND_REGISTRY.get("timezone")!;
const result = validateFlags(command, { location: "35.6585,139.7454" });
expect(result.location).toBe("35.6585,139.7454");
expect(result.timestamp).toBeUndefined();
});
it("validates timezone timestamp", () => {
const command = COMMAND_REGISTRY.get("timezone")!;
const result = validateFlags(command, { location: "35.6585,139.7454", timestamp: "1672531200" });
expect(result.timestamp).toBe("1672531200");
});
it("rejects invalid timezone timestamp", () => {
const command = COMMAND_REGISTRY.get("timezone")!;
expect(() => validateFlags(command, { location: "35.6585,139.7454", timestamp: "abc" })).toThrow(CliError);
});
it("validates places-nearby with required location and radius", () => {
const command = COMMAND_REGISTRY.get("places-nearby")!;
const result = validateFlags(command, { location: "35.6585,139.7454", radius: "1000" });
expect(result.location).toBe("35.6585,139.7454");
expect(result.radius).toBe("1000");
});
it("validates place-detail with place-id", () => {
const command = COMMAND_REGISTRY.get("place-detail")!;
const result = validateFlags(command, { "place-id": "ChIJCewJkL2LGGAR2HQ6PeTfivU" });
expect(result["place-id"]).toBe("ChIJCewJkL2LGGAR2HQ6PeTfivU");
});
});