feat(cli): add arg parsing; chore(config): organize package.json/tsconfig

This commit is contained in:
2025-10-16 16:46:28 +01:00
parent 1151dd4bef
commit c111fd6689
6 changed files with 198 additions and 35 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/node_modules /node_modules
/dist

View File

@@ -1,14 +1,23 @@
{ {
"name": "issue-scheduler", "name": "issue-scheduler",
"description": "Schedule issues in Gitea or Github from issue templates",
"version": "0.0.0", "version": "0.0.0",
"description": "Issue scheduler", "main": "dist/index.js",
"main": "src/index.ts",
"scripts": { "author": "Luís Figueiredo (TxFig)",
"test": "test" "homepage": "https://gitea.alluna.pt/TxFig/issue-scheduler",
"repository": {
"type": "git",
"url": "https://gitea.alluna.pt/TxFig/issue-scheduler.git"
}, },
"keywords": [],
"author": "",
"license": "ISC", "license": "ISC",
"keywords": [],
"scripts": {
"test": "vitest --run",
"build": "tsc",
"start": "node dist/main.js"
},
"devDependencies": { "devDependencies": {
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^3.2.4" "vitest": "^3.2.4"

41
src/cli.test.ts Normal file
View File

@@ -0,0 +1,41 @@
import { test, expect, describe } from "vitest";
import * as cli from "./cli.js"
test("get flag value", () => {
})
test("optional string flag", () => {
})
test("optional string literal flag", () => {
})
test("optional boolean flag", () => {
// no flags at all
expect(cli.optionalBooleanFlag([], [])).toBe(false)
// target flag not present in args
expect(cli.optionalBooleanFlag([], ["--verbose"])).toBe(false)
// flags list empty (nothing to match)
expect(cli.optionalBooleanFlag(["--verbose"], [])).toBe(false)
// flag present with no value
expect(cli.optionalBooleanFlag(["--verbose"], ["--verbose"])).toBe(true)
// flag followed by another flag
expect(cli.optionalBooleanFlag(["--verbose", "--other"], ["--verbose"])).toBe(true)
// duplicate flag; second ignored even if invalid
expect(cli.optionalBooleanFlag(["--verbose", "--verbose", "true"], ["--verbose"])).toBe(true)
// inline assignment not recognized as the flag token
expect(cli.optionalBooleanFlag(["--verbose=true"], ["--verbose"])).toBe(false)
// flag followed by a value
expect(() => cli.optionalBooleanFlag(["--verbose", "true"], ["--verbose"])).toThrowError(cli.NoValueExpected)
// flag followed by empty string (still a value)
expect(() => cli.optionalBooleanFlag(["--verbose", ""], ["--verbose"])).toThrowError(cli.NoValueExpected)
})
test("parse args", () => {
})

121
src/cli.ts Normal file
View File

@@ -0,0 +1,121 @@
export interface CLIConfig {
// path to config file
config_filepath: string,
git_client: "gitea" | "github",
log_file: string | undefined,
// verbose logging
verbose: boolean,
// doesn't log to stdout/stderr (still logs to file if --log-file)
quiet: boolean
}
export const defaults: CLIConfig = {
config_filepath: "config.toml",
git_client: "gitea",
log_file: undefined,
verbose: false,
quiet: false
}
export class EmptyArgumentError extends Error {}
export class NoValueExpected extends Error {}
export class InvalidValue extends Error {}
/**
* Searches for first argument from flags options
* Valid values don't start with dashes
* @example Flag + Valid value
* getFlagValue(["--config", "config.toml"], ["--config", "-c"])
* // result: "config.toml"
* @example Flag + No value
* getFlagValue(["-c", "--verbose"], ["--config", "-c"])
* // result: null
* @example No Flag
* getFlagValue(["config.toml"], ["--config", "-c"])
* // result: undefined
*
* @param args An array of all command-line arguments
* @param flags An array of flag strings to search for
* @return {string}: When a flag is found and has a valid value
* @return {null}: When a flag is found but has no valid value
* @return {undefined}: When no flag is found in the arguments
*
*/
export function getFlagValue(args: string[], flags: string[]): string | null | undefined {
const index = args.findIndex(arg => flags.includes(arg))
if (index == -1) return undefined
if (index == args.length - 1) return null
const next = args[index + 1]!
if (next.startsWith("-")) {
return null
}
return next
}
/**
* Used for when the flag is optional, but a value is required if the flag appears.
* @throws EmptyArgumentError when the flag is present but has no value.
* @returns string | undefined — string when present with value, undefined when flag not present
*/
export function optionalStringFlag(args: string[], flags: string[]): string | undefined {
const value = getFlagValue(args, flags)
if (value === null) {
const errorMessage = `${flags.join(", ")} flag requires a value`
throw new EmptyArgumentError(errorMessage)
}
return value
}
export function optionalStringLiteralFlag<Option extends string>(
args: string[], flags: string[], options: Option[]
): Option | undefined {
const value = getFlagValue(args, flags)
if (value === null) {
const errorMessage = `${flags.join(", ")} flag requires a value`
throw new EmptyArgumentError(errorMessage)
}
if (value && !(options as string[]).includes(value)) {
const errorMessage = `${flags.join(", ")} flag requires one of these options ${options.join(", ")}`
throw new InvalidValue(errorMessage)
}
return value as (Option | undefined)
}
/**
* ...
*/
export function optionalBooleanFlag(args: string[], flags: string[]): boolean {
const value = getFlagValue(args, flags)
if (typeof value === "string") {
throw new NoValueExpected()
}
return value === null;
}
/**
* Parse command line arguments from a string array (process.argv)
* @param args Command line arguments
* @return {CLIConfig} Parsed flags
*/
export function parseArgs(args: string[]): CLIConfig {
const config = defaults
const configValue = optionalStringFlag(args, ["--config", "-c"])
if (configValue) config.config_filepath = configValue
const gitClientValue = optionalStringLiteralFlag(args, ["--git-client", "-gc"], ["gitea", "github"])
if (gitClientValue) config.git_client = gitClientValue
const logFileValue = optionalStringFlag(args, ["--log-file", "-lf"])
if (logFileValue) config.log_file = logFileValue
const verboseValue = optionalBooleanFlag(args, ["--verbose"])
if (verboseValue) config.verbose = verboseValue
const quietValue = optionalBooleanFlag(args, ["--quiet"])
if (quietValue) config.quiet = quietValue
return config
}

View File

@@ -1 +1,5 @@
console.log("Hello World") import * as cli from "./cli.js"
const config = cli.parseArgs(process.argv)
console.log(config)

View File

@@ -1,41 +1,28 @@
{ {
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": { "compilerOptions": {
// File Layout "rootDir": "./src",
// "rootDir": "./src", "outDir": "./dist",
// "outDir": "./dist",
// Environment Settings "module": "ESNext",
// See also https://aka.ms/tsconfig/module "target": "ESNext",
"module": "nodenext", "moduleResolution": "node",
"target": "esnext", "moduleDetection": "force",
"types": [], "esModuleInterop": true,
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true, "sourceMap": true,
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true, "strict": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"isolatedModules": true, "isolatedModules": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
"moduleDetection": "force", "skipLibCheck": true,
"skipLibCheck": true
} "forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true
},
} }