feat(cli): add arg parsing; chore(config): organize package.json/tsconfig
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
|
/dist
|
||||||
|
|||||||
21
package.json
21
package.json
@@ -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
41
src/cli.test.ts
Normal 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
121
src/cli.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1 +1,5 @@
|
|||||||
console.log("Hello World")
|
import * as cli from "./cli.js"
|
||||||
|
|
||||||
|
|
||||||
|
const config = cli.parseArgs(process.argv)
|
||||||
|
console.log(config)
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user