diff --git a/README.md b/README.md index 4cb14d3..a572948 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,20 @@ npm test ## Usage +### CLI + +```sh +sidc-kit search "friendly infantry" --limit 3 +sidc-kit search "friendly infantry" --json +sidc-kit explain 130310001412110000000000000000 --json +sidc-kit build --affiliation friend --domain land --entity infantry --echelon platoon +sidc-kit render 130310001412110000000000000000 --size 40 > symbol.svg +``` + +The CLI wraps the public API without a separate data model. Human defaults use plain text, while `--json` returns JSON for commands and typed JSON errors on stderr. Render size must be between 1 and 4096 pixels. + +### TypeScript + ```ts import { buildSidc, explainSidc, renderSymbol, searchSymbols } from "sidc-kit"; diff --git a/package-lock.json b/package-lock.json index 55855cf..6a3c9b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,23 @@ "milsymbol": "^3.0.4" }, "devDependencies": { + "@types/node": "^20.19.43", "typescript": "^5.9.3" }, "engines": { "node": ">=20" } }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/milsymbol": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/milsymbol/-/milsymbol-3.0.4.tgz", @@ -37,6 +48,13 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index d5c52aa..2aff417 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "sidc-kit": "./dist/cli.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -14,7 +17,8 @@ "files": [ "dist", "README.md", - "CHANGELOG.md" + "CHANGELOG.md", + "LICENSE" ], "repository": { "type": "git", @@ -44,6 +48,7 @@ "milsymbol": "^3.0.4" }, "devDependencies": { + "@types/node": "^20.19.43", "typescript": "^5.9.3" }, "engines": { diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..522c0ba --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,411 @@ +#!/usr/bin/env node +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + SidcKitError, + buildSidc, + explainSidc, + renderSymbol, + searchSymbols, + type BuildSidcInput, + type RenderSymbolOptions +} from "./index.js"; + +type OptionSpec = { + kind: "flag" | "value"; +}; + +type ParsedArgs = { + positionals: string[]; + options: Map; +}; + +class UsageError extends Error { + readonly code = "USAGE_ERROR"; + + constructor(message: string) { + super(message); + this.name = "UsageError"; + } +} + +const commonOptions = { + help: { kind: "flag" }, + json: { kind: "flag" } +} satisfies Record; + +const maxRenderSize = 4096; + +const helpText = `Usage: + sidc-kit search [--limit ] [--json] + sidc-kit explain [--json] + sidc-kit render [--size ] [--fill|--no-fill] [--frame|--no-frame] [--json] + sidc-kit build --affiliation --domain --entity [--entity-type ] [--entity-subtype ] [--echelon ] [--json] + +Commands: + search Search curated symbols by plain-language terms. + explain Explain a curated SIDC into structured parts. + render Render any milsymbol-supported 30-digit SIDC to SVG. + build Build a curated SIDC from structured parts. +`; + +export function run(argv: readonly string[]): number { + const wantsJson = hasJsonFlagBeforeTerminator(argv); + + try { + if (argv.length === 0) { + throw new UsageError("Missing command. Run sidc-kit --help for usage."); + } + + const [command, ...rest] = argv; + if (command === "--help" || command === "-h") { + writeOutput(helpText); + return 0; + } + + switch (command) { + case "search": + return runSearch(rest); + case "explain": + return runExplain(rest); + case "render": + return runRender(rest); + case "build": + return runBuild(rest); + default: + throw new UsageError(`Unknown command: ${command}`); + } + } catch (error) { + reportError(error, wantsJson); + return error instanceof UsageError ? 2 : 1; + } +} + +function runSearch(args: readonly string[]): number { + const parsed = parseArgs(args, { + ...commonOptions, + limit: { kind: "value" } + }); + if (hasFlag(parsed, "help")) { + writeOutput("Usage: sidc-kit search [--limit ] [--json]\n"); + return 0; + } + if (parsed.positionals.length === 0) { + throw new UsageError("search requires one or more query terms."); + } + + const limitValue = getOptionalValue(parsed, "limit"); + const results = searchSymbols(parsed.positionals.join(" "), { + ...(limitValue === undefined ? {} : { limit: parseNonNegativeInteger(limitValue, "limit") }) + }); + + if (hasFlag(parsed, "json")) { + writeJson(results); + return 0; + } + + writeOutput(results.map((result) => `${result.sidc}\t${result.name}\tscore=${result.score}`).join("\n")); + if (results.length > 0) { + writeOutput("\n"); + } + return 0; +} + +function runExplain(args: readonly string[]): number { + const parsed = parseArgs(args, commonOptions); + if (hasFlag(parsed, "help")) { + writeOutput("Usage: sidc-kit explain [--json]\n"); + return 0; + } + if (parsed.positionals.length !== 1) { + throw new UsageError("explain requires exactly one SIDC."); + } + + const result = explainSidc(parsed.positionals[0]); + if (hasFlag(parsed, "json")) { + writeJson(result); + return 0; + } + + writeOutput(`${result.name}\n`); + writeOutput(`SIDC: ${result.sidc}\n`); + writeOutput(`Coverage: ${result.coverage}\n`); + writeOutput("Parts:\n"); + for (const [key, value] of Object.entries(result.parts)) { + writeOutput(` ${key}: ${value}\n`); + } + return 0; +} + +function runRender(args: readonly string[]): number { + const parsed = parseArgs(args, { + ...commonOptions, + fill: { kind: "flag" }, + frame: { kind: "flag" }, + "no-fill": { kind: "flag" }, + "no-frame": { kind: "flag" }, + size: { kind: "value" } + }); + if (hasFlag(parsed, "help")) { + writeOutput("Usage: sidc-kit render [--size ] [--fill|--no-fill] [--frame|--no-frame] [--json]\n"); + return 0; + } + if (parsed.positionals.length !== 1) { + throw new UsageError("render requires exactly one SIDC."); + } + if (hasFlag(parsed, "fill") && hasFlag(parsed, "no-fill")) { + throw new UsageError("render accepts only one of --fill or --no-fill."); + } + if (hasFlag(parsed, "frame") && hasFlag(parsed, "no-frame")) { + throw new UsageError("render accepts only one of --frame or --no-frame."); + } + + const options: RenderSymbolOptions = {}; + const size = getOptionalValue(parsed, "size"); + if (size !== undefined) { + options.size = parsePositiveInteger(size, "size", maxRenderSize); + } + if (hasFlag(parsed, "fill")) { + options.fill = true; + } + if (hasFlag(parsed, "no-fill")) { + options.fill = false; + } + if (hasFlag(parsed, "frame")) { + options.frame = true; + } + if (hasFlag(parsed, "no-frame")) { + options.frame = false; + } + + const result = renderSymbol(parsed.positionals[0], options); + if (hasFlag(parsed, "json")) { + writeJson(result); + return 0; + } + + writeOutput(`${result.svg}\n`); + return 0; +} + +function runBuild(args: readonly string[]): number { + const parsed = parseArgs(args, { + ...commonOptions, + affiliation: { kind: "value" }, + domain: { kind: "value" }, + echelon: { kind: "value" }, + entity: { kind: "value" }, + "entity-subtype": { kind: "value" }, + "entity-type": { kind: "value" } + }); + if (hasFlag(parsed, "help")) { + writeOutput( + "Usage: sidc-kit build --affiliation --domain --entity [--entity-type ] [--entity-subtype ] [--echelon ] [--json]\n" + ); + return 0; + } + if (parsed.positionals.length > 0) { + throw new UsageError("build accepts options only; pass parts with --affiliation, --domain, and --entity."); + } + + const input: BuildSidcInput = { + affiliation: getRequiredValue(parsed, "affiliation"), + domain: getRequiredValue(parsed, "domain"), + entity: getRequiredValue(parsed, "entity") + }; + const echelon = getOptionalValue(parsed, "echelon"); + const entityType = getOptionalValue(parsed, "entity-type"); + const entitySubtype = getOptionalValue(parsed, "entity-subtype"); + if (echelon !== undefined) { + input.echelon = echelon; + } + if (entityType !== undefined) { + input.entityType = entityType; + } + if (entitySubtype !== undefined) { + input.entitySubtype = entitySubtype; + } + + const sidc = buildSidc(input); + if (hasFlag(parsed, "json")) { + writeJson({ sidc }); + return 0; + } + + writeOutput(`${sidc}\n`); + return 0; +} + +function parseArgs(args: readonly string[], specs: Record): ParsedArgs { + const positionals: string[] = []; + const options = new Map(); + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--") { + positionals.push(...args.slice(index + 1)); + break; + } + + if (arg === "-h") { + setOption(options, "help", true); + continue; + } + + if (!arg.startsWith("--")) { + if (arg.startsWith("-")) { + throw new UsageError(`Unknown option: ${arg}`); + } + positionals.push(arg); + continue; + } + + const { name, inlineValue } = splitOption(arg); + const spec = specs[name]; + if (!spec) { + throw new UsageError(`Unknown option: --${name}`); + } + + if (spec.kind === "flag") { + if (inlineValue !== undefined) { + throw new UsageError(`--${name} does not take a value.`); + } + setOption(options, name, true); + continue; + } + + if (inlineValue !== undefined) { + setOption(options, name, inlineValue); + continue; + } + + const next = args[index + 1]; + if (next === undefined || next === "--" || next.startsWith("--")) { + throw new UsageError(`--${name} requires a value.`); + } + setOption(options, name, next); + index += 1; + } + + return { positionals, options }; +} + +function splitOption(arg: string): { name: string; inlineValue?: string } { + const withoutPrefix = arg.slice(2); + const separator = withoutPrefix.indexOf("="); + if (separator === -1) { + return { name: withoutPrefix }; + } + + return { + name: withoutPrefix.slice(0, separator), + inlineValue: withoutPrefix.slice(separator + 1) + }; +} + +function setOption(options: Map, name: string, value: string | true): void { + if (options.has(name)) { + throw new UsageError(`Option --${name} was provided more than once.`); + } + options.set(name, value); +} + +function hasFlag(parsed: ParsedArgs, name: string): boolean { + return parsed.options.get(name) === true; +} + +function getRequiredValue(parsed: ParsedArgs, name: string): string { + const value = getOptionalValue(parsed, name); + if (value === undefined) { + throw new UsageError(`--${name} is required.`); + } + return value; +} + +function getOptionalValue(parsed: ParsedArgs, name: string): string | undefined { + const value = parsed.options.get(name); + if (value === undefined) { + return undefined; + } + if (value === true) { + throw new UsageError(`--${name} requires a value.`); + } + if (value.trim() === "") { + throw new UsageError(`--${name} cannot be empty.`); + } + return value; +} + +function parseNonNegativeInteger(value: string, name: string): number { + if (!/^\d+$/.test(value)) { + throw new UsageError(`--${name} must be a non-negative integer.`); + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new UsageError(`--${name} must be a safe integer.`); + } + return parsed; +} + +function parsePositiveInteger(value: string, name: string, max?: number): number { + const parsed = parseNonNegativeInteger(value, name); + if (parsed === 0) { + throw new UsageError(`--${name} must be greater than zero.`); + } + if (max !== undefined && parsed > max) { + throw new UsageError(`--${name} must be no greater than ${max}.`); + } + return parsed; +} + +function hasJsonFlagBeforeTerminator(argv: readonly string[]): boolean { + for (const arg of argv) { + if (arg === "--") { + return false; + } + if (arg === "--json") { + return true; + } + } + return false; +} + +function writeJson(value: unknown): void { + writeOutput(`${JSON.stringify(value, null, 2)}\n`); +} + +function writeOutput(value: string): void { + process.stdout.write(value); +} + +function reportError(error: unknown, wantsJson: boolean): void { + const code = error instanceof SidcKitError || error instanceof UsageError ? error.code : "UNEXPECTED_ERROR"; + const message = error instanceof Error ? error.message : String(error); + + if (wantsJson) { + process.stderr.write(`${JSON.stringify({ error: { code, message } }, null, 2)}\n`); + return; + } + + process.stderr.write(`${code}: ${message}\n`); +} + +function isCliEntryPoint(): boolean { + const entryPoint = process.argv[1]; + if (!entryPoint) { + return false; + } + + try { + return realpathSync(resolve(entryPoint)) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isCliEntryPoint()) { + process.exitCode = run(process.argv.slice(2)); +} diff --git a/test/cli.test.mjs b/test/cli.test.mjs new file mode 100644 index 0000000..3392f52 --- /dev/null +++ b/test/cli.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const cliPath = path.join(rootDir, "dist", "cli.js"); +const infantryPlatoonSidc = "130310001412110000000000000000"; + +function runCli(args) { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd: rootDir, + encoding: "utf8" + }); +} + +test("CLI module import does not execute the command runner", async () => { + const originalArgv = process.argv; + const originalExitCode = process.exitCode; + const originalStderrWrite = process.stderr.write; + let stderr = ""; + + try { + process.argv = [process.execPath, "--test"]; + process.exitCode = undefined; + process.stderr.write = (chunk, ...args) => { + stderr += String(chunk); + const callback = args.find((arg) => typeof arg === "function"); + if (callback) { + callback(); + } + return true; + }; + + await import(`${new URL("../dist/cli.js", import.meta.url).href}?import-side-effect=${Date.now()}`); + + assert.equal(process.exitCode, undefined); + assert.equal(stderr, ""); + } finally { + process.argv = originalArgv; + process.exitCode = originalExitCode; + process.stderr.write = originalStderrWrite; + } +}); + +test("CLI searches curated symbols with plain text output", () => { + const result = runCli(["search", "friendly", "infantry", "platoon", "--limit", "1"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.match(result.stdout, new RegExp(`^${infantryPlatoonSidc}\\tFriendly Land Unit Infantry Platoon\\tscore=\\d+\\n$`)); +}); + +test("CLI explains a curated SIDC as JSON", () => { + const result = runCli(["explain", infantryPlatoonSidc, "--json"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.sidc, infantryPlatoonSidc); + assert.equal(parsed.name, "Friendly Land Unit Infantry Platoon"); + assert.equal(parsed.parts.echelon, "platoon"); +}); + +test("CLI builds a curated SIDC from structured options", () => { + const result = runCli([ + "build", + "--affiliation", + "friend", + "--domain", + "land", + "--entity", + "infantry", + "--echelon", + "platoon" + ]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.equal(result.stdout, `${infantryPlatoonSidc}\n`); +}); + +test("CLI renders SVG to stdout by default", () => { + const result = runCli(["render", infantryPlatoonSidc, "--size", "32"]); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /^\n$/); +}); + +test("CLI returns usage failures with exit code 2", () => { + const result = runCli(["build", "--affiliation", "friend", "--domain", "land"]); + + assert.equal(result.status, 2); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /^USAGE_ERROR: --entity is required\.\n$/); +}); + +test("CLI returns typed API failures as JSON when requested", () => { + const result = runCli(["explain", "not-a-sidc", "--json"]); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + + const parsed = JSON.parse(result.stderr); + assert.equal(parsed.error.code, "INVALID_SIDC"); + assert.match(parsed.error.message, /30 digits/); +}); + +test("CLI ignores --json after the positional terminator", () => { + const result = runCli(["explain", "--", "--json"]); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /^INVALID_SIDC: SIDC must be exactly 30 digits\.\n$/); + assert.throws(() => JSON.parse(result.stderr), SyntaxError); +}); + +test("CLI gives consistent diagnostics for separated negative numeric values", () => { + const separated = runCli(["search", "infantry", "--limit", "-1"]); + const inline = runCli(["search", "infantry", "--limit=-1"]); + + assert.equal(separated.status, 2); + assert.equal(inline.status, 2); + assert.equal(separated.stdout, ""); + assert.equal(inline.stdout, ""); + assert.equal(separated.stderr, inline.stderr); + assert.match(separated.stderr, /^USAGE_ERROR: --limit must be a non-negative integer\.\n$/); +}); + +test("CLI rejects unsafe and oversized integer values", () => { + const unsafe = runCli(["render", infantryPlatoonSidc, "--size", "999999999999999999999999999999"]); + const oversized = runCli(["render", infantryPlatoonSidc, "--size", "4097"]); + + assert.equal(unsafe.status, 2); + assert.equal(unsafe.stdout, ""); + assert.match(unsafe.stderr, /^USAGE_ERROR: --size must be a safe integer\.\n$/); + + assert.equal(oversized.status, 2); + assert.equal(oversized.stdout, ""); + assert.match(oversized.stderr, /^USAGE_ERROR: --size must be no greater than 4096\.\n$/); +});