Compare commits
11 Commits
9a6ff66d72
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7264e91663 | |||
| 047e342842 | |||
| 47a8b19748 | |||
| 9142518cc3 | |||
| a4b08d6a15 | |||
| 281b0c13aa | |||
| f5877d2ccd | |||
| 4dac95e081 | |||
| fe1b8f0e1f | |||
| d74dafbe01 | |||
| 751a49f268 |
@@ -11,7 +11,7 @@ This package hosts non-domain-specific utilities that can be reused by CLI and l
|
|||||||
Current exported areas:
|
Current exported areas:
|
||||||
|
|
||||||
- Markdown table rendering (`toMarkdownTable`)
|
- Markdown table rendering (`toMarkdownTable`)
|
||||||
- Output helpers for CLI tools (`renderOutput`, `normalizeOutputFormat`, `parseHeaderSpec`)
|
- Output helpers for CLI tools (`renderOutput`, `normalizeOutputFormat`, `parseColumnSpec`)
|
||||||
- Input readers for stdin (`readJsonFromStdin`, `readCsvFromStdin`)
|
- Input readers for stdin (`readJsonFromStdin`, `readCsvFromStdin`)
|
||||||
- JMESPath output filtering (`outputFiltered`)
|
- JMESPath output filtering (`outputFiltered`)
|
||||||
|
|
||||||
|
|||||||
@@ -2,25 +2,41 @@
|
|||||||
|
|
||||||
The `sk-tools` package provides generic CLI utilities not tied to a specific cloud provider.
|
The `sk-tools` package provides generic CLI utilities not tied to a specific cloud provider.
|
||||||
|
|
||||||
|
## Global Options
|
||||||
|
|
||||||
|
These options apply to all commands unless stated otherwise:
|
||||||
|
|
||||||
|
- `--query`, `-q` <jmespath> - Apply JMESPath filter before output rendering.
|
||||||
|
- `--output`, `-o` <format> - Output format: `table|t|alignedtable|at|prettytable|pt|tsv`.
|
||||||
|
- `--columns`, `-C` <definition> - Column selection for table outputs:
|
||||||
|
- `col1` - Select column (case-insensitive match), keep raw header label.
|
||||||
|
- `col1:` - Select column (case-insensitive match), use auto-generated header label.
|
||||||
|
- `col1: Label 1` - Select column (case-insensitive match), use custom header label.
|
||||||
|
- Prefix token with `=` for exact column-name match: `=col1`, `=col1:`, `=col1:Label`.
|
||||||
|
- Tokens are comma-separated and rendered in the specified order.
|
||||||
|
- `--help`, `-h` - Show help.
|
||||||
|
|
||||||
## Table
|
## Table
|
||||||
|
|
||||||
**Command name:** `table`
|
**Command name:** `table` (alias: `t`)
|
||||||
|
|
||||||
**Usage:** `sk-tools table [--from|-F <json|csv|tsv>] [--header|-H <definition|auto|a|original|o>] [global options]`
|
**Usage:** `sk-tools table [--from|-F <json|csv|tsv>] [--columns|-C <definition>] [global options]`
|
||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
|
|
||||||
- `--from`, `-F` <json|csv|tsv> - Input format read from stdin. Default: `json`.
|
- `--from`, `-F` <json|csv|tsv> - Input format read from stdin. Default: `json`.
|
||||||
- `--header`, `-H` <definition|auto|a|original|o> - Header definition. Possible values:
|
- `--columns`, `-C` <definition> - Column definition (see global options).
|
||||||
- `col1, col2, ...` - Column names separated by comma. The number of columns should match table columns.
|
|
||||||
- `key1: Col1, key2: Col2, ...` - Property-to-column mapping.
|
|
||||||
- `auto`, `a` - Header generated automatically from result keys.
|
|
||||||
- `original`, `o` - Header based on original table shape.
|
|
||||||
|
|
||||||
**Global options:**
|
|
||||||
|
|
||||||
- `--query`, `-q` <jmespath> - JMESPath filter applied before rendering.
|
|
||||||
- `--output`, `-o` <format> - Output format: `table|t|alignedtable|at|prettytable|pt|tsv`.
|
|
||||||
- `--help`, `-h` - Show help.
|
|
||||||
|
|
||||||
**Description:** The `table` command transforms stdin data into tabular output. It accepts JSON, CSV, or TSV input and renders either Markdown table variants or TSV output.
|
**Description:** The `table` command transforms stdin data into tabular output. It accepts JSON, CSV, or TSV input and renders either Markdown table variants or TSV output.
|
||||||
|
|
||||||
|
## Table Info
|
||||||
|
|
||||||
|
**Command name:** `table-info` (alias: `ti`)
|
||||||
|
|
||||||
|
**Usage:** `sk-tools table-info [--from|-F <json|csv|tsv>]`
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
- `--from`, `-F` <json|csv|tsv> - Input format read from stdin. Default: `json`.
|
||||||
|
|
||||||
|
**Description:** The `table-info` command reads stdin data and prints basic diagnostics: number of rows, number of columns, and inferred type for each column.
|
||||||
|
|||||||
54
package-lock.json
generated
54
package-lock.json
generated
@@ -1,16 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "@slawek/sk-tools",
|
"name": "@slawek/sk-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@slawek/sk-tools",
|
"name": "@slawek/sk-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.4.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"commander": "^14.0.3",
|
||||||
"d3-dsv": "^3.0.1",
|
"d3-dsv": "^3.0.1",
|
||||||
"jmespath": "^0.16.0"
|
"jmespath": "^0.16.0",
|
||||||
|
"semver": "^7.7.4",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"sk-tools": "dist/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/d3-dsv": "^3.0.7",
|
"@types/d3-dsv": "^3.0.7",
|
||||||
@@ -47,12 +53,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "7.2.0",
|
"version": "14.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-dsv": {
|
"node_modules/d3-dsv": {
|
||||||
@@ -80,6 +86,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-dsv/node_modules/commander": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -113,6 +128,18 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -133,6 +160,19 @@
|
|||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "11.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@slawek/sk-tools",
|
"name": "@slawek/sk-tools",
|
||||||
"version": "0.1.1",
|
"version": "0.4.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -10,16 +10,18 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"build": "npm run clean && tsc && chmod +x dist/cli.js",
|
"build": "npm run clean && tsc && chmod +x dist/cli.js",
|
||||||
"build:watch": "tsc --watch",
|
"build:watch": "tsc --watch"
|
||||||
"prepublishOnly": "npm run build"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
"description": "A set of generic NodeJS utilities shared by Slawek tools.",
|
"description": "A set of generic NodeJS utilities shared by Slawek tools.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"commander": "^14.0.3",
|
||||||
"d3-dsv": "^3.0.1",
|
"d3-dsv": "^3.0.1",
|
||||||
"jmespath": "^0.16.0"
|
"jmespath": "^0.16.0",
|
||||||
|
"semver": "^7.7.4",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/d3-dsv": "^3.0.7",
|
"@types/d3-dsv": "^3.0.7",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js",
|
".": "./dist/index.js",
|
||||||
"./markdown": "./dist/markdown.js",
|
"./markdown": "./dist/markdown.js",
|
||||||
"./cli/utils": "./dist/cli/utils.js"
|
"./cli/utils": "./dist/cli/utils.js",
|
||||||
|
"./config": "./dist/config/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
scripts/bump-patch.mjs
Executable file
45
scripts/bump-patch.mjs
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import semver from 'semver';
|
||||||
|
import { Command, Option } from 'commander';
|
||||||
|
|
||||||
|
function bump(fileName, version, releaseType = 'patch') {
|
||||||
|
const filePath = path.resolve(process.cwd(), fileName);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`File not found: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
const currentVersion = json.version;
|
||||||
|
|
||||||
|
if (typeof currentVersion !== 'string') {
|
||||||
|
throw new Error(`${fileName} does not contain a string "version" field.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVersion = version ?? semver.inc(currentVersion, releaseType);
|
||||||
|
|
||||||
|
if (!nextVersion) {
|
||||||
|
throw new Error(`Unsupported semver format: "${currentVersion}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
json.version = nextVersion;
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(json, null, 4)}\n`, 'utf8');
|
||||||
|
console.log(`Bumped version in ${fileName} to ${nextVersion}`);
|
||||||
|
return nextVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
program
|
||||||
|
.name('bump-patch')
|
||||||
|
.description('Bump the version in package.json')
|
||||||
|
.addOption(new Option('-r, --release-type <type>', 'Release type (major, minor, patch)')
|
||||||
|
.choices(['major', 'minor', 'patch'])
|
||||||
|
.default('patch')
|
||||||
|
)
|
||||||
|
.parse(process.argv);
|
||||||
|
|
||||||
|
bump('package.json', undefined, program.opts().releaseType);
|
||||||
143
src/cli.ts
143
src/cli.ts
@@ -1,124 +1,53 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import { parseArgs } from "node:util";
|
import { Command, Option } from "commander";
|
||||||
|
|
||||||
import {
|
import { runTableInfoCommand } from "./cli/commands/table-info.ts";
|
||||||
normalizeOutputFormat,
|
import { runTableCommand } from "./cli/commands/table.ts";
|
||||||
outputFiltered,
|
import { renderCliOutput } from "./cli/utils.ts";
|
||||||
parseHeaderSpec,
|
|
||||||
readCsvFromStdin,
|
|
||||||
readJsonFromStdin,
|
|
||||||
renderOutput,
|
|
||||||
} from "./cli/utils.ts";
|
|
||||||
|
|
||||||
type CliValues = {
|
|
||||||
help?: boolean;
|
|
||||||
from?: string;
|
|
||||||
query?: string;
|
|
||||||
header?: string;
|
|
||||||
output?: string;
|
|
||||||
[key: string]: string | boolean | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
function usage(): string {
|
|
||||||
return `Usage: sk-tools <command> [options]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
table Render stdin data as Markdown table
|
|
||||||
|
|
||||||
Global options (all commands):
|
|
||||||
--query, -q <jmespath>
|
|
||||||
--output, -o <format> table|t|alignedtable|at|prettytable|pt|tsv
|
|
||||||
--help, -h
|
|
||||||
|
|
||||||
Use: sk-tools --help <command>
|
|
||||||
or: sk-tools <command> --help`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usageTable(): string {
|
|
||||||
return `Usage: sk-tools table [--from|-F <json|csv|tsv>] [--header|-H <definition|auto|a|original|o>] [global options]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--from, -F <json|csv|tsv> Input format on stdin (default: json)
|
|
||||||
--header, -H <value> Header mode: auto|a (default), original|o, or "col1, col2" or "key1: Label 1, key2: Label 2"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usageCommand(command: string): string {
|
|
||||||
switch (command) {
|
|
||||||
case "table":
|
|
||||||
return usageTable();
|
|
||||||
default:
|
|
||||||
return `Unknown command: ${command}\n\n${usage()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTableCommand(values: CliValues): Promise<unknown> {
|
|
||||||
const from = (values.from ?? "json").toString().trim().toLowerCase();
|
|
||||||
|
|
||||||
if (from === "json") {
|
|
||||||
return readJsonFromStdin();
|
|
||||||
}
|
|
||||||
if (from === "csv") {
|
|
||||||
return readCsvFromStdin(",");
|
|
||||||
}
|
|
||||||
if (from === "tsv") {
|
|
||||||
return readCsvFromStdin("\t");
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Invalid --from '${values.from}'. Allowed: json, csv, tsv`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const argv = process.argv.slice(2);
|
const skTools = new Command();
|
||||||
const command = argv[0];
|
|
||||||
|
|
||||||
if (!command) {
|
skTools
|
||||||
console.log(usage());
|
.name("sk-tools")
|
||||||
process.exit(0);
|
.description("A set of generic NodeJS utilities shared by Slawek tools.")
|
||||||
}
|
.option("-q, --query <jmespath>", "JMESPath query to filter output")
|
||||||
|
.option("-C, --columns <definition>", "Column tokens: col (raw), col: (auto), col:Label (custom), exact via = prefix")
|
||||||
|
.addOption(new Option("-o, --output <format>", "Output format: table|t|alignedtable|at|prettytable|pt|tsv")
|
||||||
|
.choices(["table", "t", "alignedtable", "at", "prettytable", "pt", "tsv"]));
|
||||||
|
|
||||||
if (command === "-h" || command === "--help") {
|
skTools
|
||||||
const helpCommand = argv[1];
|
.command("table")
|
||||||
console.log(helpCommand ? usageCommand(helpCommand) : usage());
|
.alias("t")
|
||||||
process.exit(0);
|
.description("Render stdin data as Markdown table")
|
||||||
}
|
.addOption(new Option("-F, --from <json|csv|tsv>", "Input format on stdin")
|
||||||
|
.choices(["json", "csv", "tsv"])
|
||||||
|
.default("json"))
|
||||||
|
.action(async (options, command) => {
|
||||||
|
const allOptions = command.optsWithGlobals();
|
||||||
|
const output = await runTableCommand(options);
|
||||||
|
renderCliOutput(output, allOptions.output ?? "alignedtable", allOptions.query, allOptions.columns);
|
||||||
|
});
|
||||||
|
|
||||||
const { values } = parseArgs({
|
skTools
|
||||||
args: argv.slice(1),
|
.command("table-info")
|
||||||
options: {
|
.alias("ti")
|
||||||
help: { type: "boolean", short: "h" },
|
.description("Print row/column stats and inferred column types")
|
||||||
from: { type: "string", short: "F" },
|
.addOption(new Option("-F, --from <json|csv|tsv>", "Input format on stdin")
|
||||||
query: { type: "string", short: "q" },
|
.choices(["json", "csv", "tsv"])
|
||||||
header: { type: "string", short: "H" },
|
.default("json"))
|
||||||
output: { type: "string", short: "o" },
|
.action(async (options) => {
|
||||||
},
|
const output = await runTableInfoCommand(options);
|
||||||
strict: true,
|
console.log(output);
|
||||||
allowPositionals: false,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const typedValues = values as CliValues;
|
await skTools.parseAsync(process.argv);
|
||||||
|
|
||||||
if (typedValues.help) {
|
|
||||||
console.log(usageCommand(command));
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command !== "table") {
|
|
||||||
throw new Error(`Unknown command: ${command}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputFormat = normalizeOutputFormat(typedValues.output);
|
|
||||||
const result = await runTableCommand(typedValues);
|
|
||||||
const filtered = outputFiltered(result, typedValues.query);
|
|
||||||
const headerSpec = parseHeaderSpec(typedValues.header);
|
|
||||||
|
|
||||||
renderOutput(outputFormat, headerSpec, filtered);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err: unknown) => {
|
main().catch((err: unknown) => {
|
||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
console.error(`Error: ${error.message}`);
|
console.error(`Error: ${error.message}`);
|
||||||
console.error(usage());
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
118
src/cli/commands/table-info.ts
Normal file
118
src/cli/commands/table-info.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import {
|
||||||
|
readCsvFromStdin,
|
||||||
|
readJsonFromStdin,
|
||||||
|
} from "../utils.ts";
|
||||||
|
|
||||||
|
type TableInfoCommandValues = {
|
||||||
|
from?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RowObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
type ValueType = "string" | "number" | "boolean" | "object" | "array" | "null" | "unknown";
|
||||||
|
|
||||||
|
function getType(value: unknown): ValueType {
|
||||||
|
if (value === null) return "null";
|
||||||
|
if (Array.isArray(value)) return "array";
|
||||||
|
if (value === undefined) return "unknown";
|
||||||
|
if (typeof value === "string") return "string";
|
||||||
|
if (typeof value === "number") return "number";
|
||||||
|
if (typeof value === "boolean") return "boolean";
|
||||||
|
if (typeof value === "object") return "object";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRows(input: unknown): { rowCount: number; rows: RowObject[] } {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return {
|
||||||
|
rowCount: input.length,
|
||||||
|
rows: input.map((item) =>
|
||||||
|
item && typeof item === "object" && !Array.isArray(item)
|
||||||
|
? item as RowObject
|
||||||
|
: { value: item }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input && typeof input === "object") {
|
||||||
|
return {
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [input as RowObject],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ value: input }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferColumnType(rows: RowObject[], column: string): string {
|
||||||
|
const kinds = new Set<ValueType>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!(column in row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = getType(row[column]);
|
||||||
|
if (kind !== "unknown") {
|
||||||
|
kinds.add(kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kinds.size === 0) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kinds.size === 1) {
|
||||||
|
return [...kinds][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kinds.size === 2 && kinds.has("null")) {
|
||||||
|
return [...kinds].find((kind) => kind !== "null") ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `mixed(${[...kinds].join("|")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInfo(input: unknown): string {
|
||||||
|
const { rowCount, rows } = normalizeRows(input);
|
||||||
|
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`Number of columns: ${columns.length}`,
|
||||||
|
`Number of rows: ${rowCount}`,
|
||||||
|
"",
|
||||||
|
"Columns:",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (columns.length === 0) {
|
||||||
|
lines.push("- (none)");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const column of columns) {
|
||||||
|
lines.push(`- ${column}: ${inferColumnType(rows, column)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTableInfoCommand(values: TableInfoCommandValues): Promise<string> {
|
||||||
|
const from = (values.from ?? "json").toString().trim().toLowerCase();
|
||||||
|
|
||||||
|
if (from === "json") {
|
||||||
|
return buildInfo(await readJsonFromStdin());
|
||||||
|
}
|
||||||
|
if (from === "csv") {
|
||||||
|
return buildInfo(await readCsvFromStdin(","));
|
||||||
|
}
|
||||||
|
if (from === "tsv") {
|
||||||
|
return buildInfo(await readCsvFromStdin("\t"));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid --from '${values.from}'. Allowed: json, csv, tsv`);
|
||||||
|
}
|
||||||
26
src/cli/commands/table.ts
Normal file
26
src/cli/commands/table.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import {
|
||||||
|
readCsvFromStdin,
|
||||||
|
readJsonFromStdin,
|
||||||
|
} from "../utils.ts";
|
||||||
|
|
||||||
|
type TableCommandValues = {
|
||||||
|
from?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runTableCommand(values: TableCommandValues): Promise<unknown> {
|
||||||
|
const from = (values.from ?? "json").toString().trim().toLowerCase();
|
||||||
|
|
||||||
|
if (from === "json") {
|
||||||
|
return readJsonFromStdin();
|
||||||
|
}
|
||||||
|
if (from === "csv") {
|
||||||
|
return readCsvFromStdin(",");
|
||||||
|
}
|
||||||
|
if (from === "tsv") {
|
||||||
|
return readCsvFromStdin("\t");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid --from '${values.from}'. Allowed: json, csv, tsv`);
|
||||||
|
}
|
||||||
119
src/cli/utils.ts
119
src/cli/utils.ts
@@ -5,11 +5,16 @@ import { dsvFormat } from "d3-dsv";
|
|||||||
|
|
||||||
import { toMarkdownTable } from "../markdown.ts";
|
import { toMarkdownTable } from "../markdown.ts";
|
||||||
|
|
||||||
type HeaderSpec =
|
type ColumnToken = {
|
||||||
| { mode: "auto" }
|
sourceKey: string;
|
||||||
| { mode: "original" }
|
exactMatch: boolean;
|
||||||
| { mode: "list"; labels: string[] }
|
labelMode: "raw" | "auto" | "custom";
|
||||||
| { mode: "map"; map: Record<string, string> };
|
customLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ColumnSpec =
|
||||||
|
| { mode: "default" }
|
||||||
|
| { mode: "tokens"; tokens: ColumnToken[] };
|
||||||
|
|
||||||
type OutputFormat = "json" | "table" | "alignedtable" | "prettytable" | "tsv";
|
type OutputFormat = "json" | "table" | "alignedtable" | "prettytable" | "tsv";
|
||||||
|
|
||||||
@@ -22,41 +27,69 @@ export function outputFiltered(object: unknown, query?: string): unknown {
|
|||||||
: object;
|
: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseHeaderSpec(headerValue?: string): HeaderSpec {
|
function parseColumnToken(rawToken: string): ColumnToken {
|
||||||
if (!headerValue) {
|
const token = rawToken.trim();
|
||||||
return { mode: "auto" };
|
if (!token) {
|
||||||
|
throw new Error("Invalid --columns token: empty token");
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = headerValue.trim();
|
const exactMatch = token.startsWith("=");
|
||||||
if (raw === "" || raw.toLowerCase() === "auto" || raw.toLowerCase() === "a") {
|
const core = exactMatch ? token.slice(1).trim() : token;
|
||||||
return { mode: "auto" };
|
if (!core) {
|
||||||
|
throw new Error(`Invalid --columns token: '${rawToken}'`);
|
||||||
}
|
}
|
||||||
if (raw.toLowerCase() === "original" || raw.toLowerCase() === "o") {
|
|
||||||
return { mode: "original" };
|
const separatorIndex = core.indexOf(":");
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
return {
|
||||||
|
sourceKey: core,
|
||||||
|
exactMatch,
|
||||||
|
labelMode: "raw",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceKey = core.slice(0, separatorIndex).trim();
|
||||||
|
if (!sourceKey) {
|
||||||
|
throw new Error(`Invalid --columns token: '${rawToken}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLabel = core.slice(separatorIndex + 1);
|
||||||
|
const label = rawLabel.trim();
|
||||||
|
if (!label) {
|
||||||
|
return {
|
||||||
|
sourceKey,
|
||||||
|
exactMatch,
|
||||||
|
labelMode: "auto",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceKey,
|
||||||
|
exactMatch,
|
||||||
|
labelMode: "custom",
|
||||||
|
customLabel: label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseColumnSpec(columnsValue?: string): ColumnSpec {
|
||||||
|
if (!columnsValue) {
|
||||||
|
return { mode: "default" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = columnsValue.trim();
|
||||||
|
if (raw === "") {
|
||||||
|
return { mode: "default" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = raw.split(",").map((p) => p.trim()).filter(Boolean);
|
const parts = raw.split(",").map((p) => p.trim()).filter(Boolean);
|
||||||
const isMap = parts.some((p) => p.includes(":"));
|
if (parts.length === 0) {
|
||||||
|
return { mode: "default" };
|
||||||
if (!isMap) {
|
|
||||||
return { mode: "list", labels: parts };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const map: Record<string, string> = {};
|
return {
|
||||||
for (const part of parts) {
|
mode: "tokens",
|
||||||
const idx = part.indexOf(":");
|
tokens: parts.map((part) => parseColumnToken(part)),
|
||||||
if (idx < 0) {
|
};
|
||||||
throw new Error(`Invalid --header mapping segment: '${part}'`);
|
|
||||||
}
|
|
||||||
const key = part.slice(0, idx).trim();
|
|
||||||
const label = part.slice(idx + 1).trim();
|
|
||||||
if (!key || !label) {
|
|
||||||
throw new Error(`Invalid --header mapping segment: '${part}'`);
|
|
||||||
}
|
|
||||||
map[key] = label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { mode: "map", map };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeOutputFormat(outputValue?: string): OutputFormat {
|
export function normalizeOutputFormat(outputValue?: string): OutputFormat {
|
||||||
@@ -82,7 +115,7 @@ function isScalar(value: unknown): value is Scalar {
|
|||||||
return value == null || typeof value !== "object";
|
return value == null || typeof value !== "object";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: ScalarRow[] } {
|
export function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: ScalarRow[] } {
|
||||||
let rows: Array<Record<string, unknown>>;
|
let rows: Array<Record<string, unknown>>;
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
rows = value.map((item) =>
|
rows = value.map((item) =>
|
||||||
@@ -202,7 +235,7 @@ export async function readCsvFromStdin(separator: string): Promise<unknown> {
|
|||||||
|
|
||||||
export function renderOutput(
|
export function renderOutput(
|
||||||
outputFormat: OutputFormat,
|
outputFormat: OutputFormat,
|
||||||
headerSpec: HeaderSpec,
|
columnSpec: ColumnSpec,
|
||||||
output: unknown,
|
output: unknown,
|
||||||
): void {
|
): void {
|
||||||
if (outputFormat === "tsv") {
|
if (outputFormat === "tsv") {
|
||||||
@@ -215,12 +248,24 @@ export function renderOutput(
|
|||||||
// - alignedtable: aligned columns, no quoting
|
// - alignedtable: aligned columns, no quoting
|
||||||
// - prettytable: aligned columns + quoting selected values
|
// - prettytable: aligned columns + quoting selected values
|
||||||
if (outputFormat === "prettytable") {
|
if (outputFormat === "prettytable") {
|
||||||
console.log(toMarkdownTable(output, "prettytable", headerSpec));
|
console.log(toMarkdownTable(output, "prettytable", columnSpec));
|
||||||
} else if (outputFormat === "alignedtable") {
|
} else if (outputFormat === "alignedtable") {
|
||||||
console.log(toMarkdownTable(output, "alignedtable", headerSpec));
|
console.log(toMarkdownTable(output, "alignedtable", columnSpec));
|
||||||
} else if (outputFormat === "table") {
|
} else if (outputFormat === "table") {
|
||||||
console.log(toMarkdownTable(output, "table", headerSpec));
|
console.log(toMarkdownTable(output, "table", columnSpec));
|
||||||
} else {
|
} else {
|
||||||
console.log(JSON.stringify(output, null, 2));
|
console.log(JSON.stringify(output, null, 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderCliOutput(
|
||||||
|
output: unknown,
|
||||||
|
outputValue?: string,
|
||||||
|
query?: string,
|
||||||
|
columnsValue?: string,
|
||||||
|
): void {
|
||||||
|
const outputFormat = normalizeOutputFormat(outputValue);
|
||||||
|
const filtered = outputFiltered(output, query);
|
||||||
|
const columnSpec = parseColumnSpec(columnsValue);
|
||||||
|
renderOutput(outputFormat, columnSpec, filtered);
|
||||||
|
}
|
||||||
|
|||||||
27
src/config/index.ts
Normal file
27
src/config/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function getConfigDir(moduleName: string): string {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return path.join(process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"), moduleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), moduleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfig(moduleName: string, configName = "config"): Promise<unknown> {
|
||||||
|
const configPath = path.join(getConfigDir(moduleName), `${configName}.json`);
|
||||||
|
|
||||||
|
return readFile(configPath, "utf8")
|
||||||
|
.then((configJson) => JSON.parse(configJson) as unknown)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if ((err as { code?: string } | null)?.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
export { toMarkdownTable } from "./markdown.ts";
|
export { toMarkdownTable } from "./markdown.ts";
|
||||||
export {
|
export {
|
||||||
outputFiltered,
|
outputFiltered,
|
||||||
parseHeaderSpec,
|
parseColumnSpec,
|
||||||
normalizeOutputFormat,
|
normalizeOutputFormat,
|
||||||
readJsonFromStdin,
|
readJsonFromStdin,
|
||||||
readCsvFromStdin,
|
readCsvFromStdin,
|
||||||
renderOutput,
|
renderOutput,
|
||||||
|
renderCliOutput,
|
||||||
} from "./cli/utils.ts";
|
} from "./cli/utils.ts";
|
||||||
|
export { getConfigDir, getConfig } from "./config/index.ts";
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import { validate as validateUuid } from "uuid";
|
||||||
|
|
||||||
type Scalar = string | number | boolean | null | undefined;
|
type Scalar = string | number | boolean | null | undefined;
|
||||||
type ScalarRow = Record<string, Scalar>;
|
type ScalarRow = Record<string, Scalar>;
|
||||||
|
|
||||||
type HeaderSpec =
|
type ColumnToken = {
|
||||||
|
sourceKey: string;
|
||||||
|
exactMatch: boolean;
|
||||||
|
labelMode: "raw" | "auto" | "custom";
|
||||||
|
customLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ColumnSpec =
|
||||||
| { mode: "default" }
|
| { mode: "default" }
|
||||||
| { mode: "auto" }
|
| { mode: "tokens"; tokens: ColumnToken[] };
|
||||||
| { mode: "original" }
|
|
||||||
| { mode: "list"; labels: string[] }
|
|
||||||
| { mode: "map"; map: Record<string, string> };
|
|
||||||
|
|
||||||
type TableMode = "table" | "alignedtable" | "prettytable";
|
type TableMode = "table" | "alignedtable" | "prettytable";
|
||||||
|
|
||||||
@@ -20,7 +26,15 @@ function formatCell(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inlineCodePredicates = [
|
const inlineCodePredicates = [
|
||||||
(value: Scalar): boolean => isGuid(value),
|
(value: Scalar): boolean => typeof value === "string" && validateUuid(value),
|
||||||
|
(value: Scalar): boolean => {
|
||||||
|
if (typeof value !== "string" || !value.toLowerCase().startsWith("/subscriptions/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const rest = value.slice("/subscriptions/".length);
|
||||||
|
const subscriptionId = rest.split("/", 1)[0];
|
||||||
|
return validateUuid(subscriptionId);
|
||||||
|
},
|
||||||
(value: Scalar): boolean =>
|
(value: Scalar): boolean =>
|
||||||
typeof value === "string"
|
typeof value === "string"
|
||||||
&& /^(?:\d{1,3}\.){3}\d{1,3}(?:\/(?:\d{1,2}|(?:\d{1,3}\.){3}\d{1,3}))?$/.test(value),
|
&& /^(?:\d{1,3}\.){3}\d{1,3}(?:\/(?:\d{1,2}|(?:\d{1,3}\.){3}\d{1,3}))?$/.test(value),
|
||||||
@@ -38,11 +52,6 @@ function renderCell(raw: Scalar, shouldQuote: boolean): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGuid(value: unknown): value is string {
|
|
||||||
return typeof value === "string"
|
|
||||||
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAutoHeaderLabel(key: string): string {
|
function toAutoHeaderLabel(key: string): string {
|
||||||
const withSpaces = String(key)
|
const withSpaces = String(key)
|
||||||
.replace(/[_-]+/g, " ")
|
.replace(/[_-]+/g, " ")
|
||||||
@@ -106,24 +115,46 @@ function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: Sca
|
|||||||
return { headers, rows: scalarRows };
|
return { headers, rows: scalarRows };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveColumnKey(headers: string[], token: ColumnToken): string {
|
||||||
|
if (token.exactMatch) {
|
||||||
|
return headers.find((header) => header === token.sourceKey) ?? token.sourceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = headers.filter(
|
||||||
|
(header) => header.toLowerCase() === token.sourceKey.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matches.length > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Ambiguous --columns token '${token.sourceKey}' matches multiple columns: ${matches.join(", ")}. Use exact match with '=...'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches[0] ?? token.sourceKey;
|
||||||
|
}
|
||||||
|
|
||||||
export function toMarkdownTable(
|
export function toMarkdownTable(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
mode: TableMode = "table",
|
mode: TableMode = "table",
|
||||||
headerSpec: HeaderSpec = { mode: "default" },
|
columnSpec: ColumnSpec = { mode: "default" },
|
||||||
): string {
|
): string {
|
||||||
const prettyMode = mode === "prettytable";
|
const prettyMode = mode === "prettytable";
|
||||||
const { headers, rows } = getScalarRowsAndHeaders(value);
|
const { headers, rows } = getScalarRowsAndHeaders(value);
|
||||||
const headerDefinitions = headers.map((key, idx) => {
|
const headerDefinitions = columnSpec.mode === "default"
|
||||||
let label = key;
|
? headers.map((key) => ({
|
||||||
if (headerSpec.mode === "auto") {
|
key,
|
||||||
label = toAutoHeaderLabel(key);
|
label: toAutoHeaderLabel(key),
|
||||||
} else if (headerSpec.mode === "list" && headerSpec.labels[idx]) {
|
}))
|
||||||
label = headerSpec.labels[idx];
|
: columnSpec.tokens.map((token) => {
|
||||||
} else if (headerSpec.mode === "map" && headerSpec.map[key]) {
|
const key = resolveColumnKey(headers, token);
|
||||||
label = headerSpec.map[key];
|
let label = key;
|
||||||
}
|
if (token.labelMode === "auto") {
|
||||||
return { key, label };
|
label = toAutoHeaderLabel(key);
|
||||||
});
|
} else if (token.labelMode === "custom") {
|
||||||
|
label = token.customLabel ?? "";
|
||||||
|
}
|
||||||
|
return { key, label };
|
||||||
|
});
|
||||||
|
|
||||||
if (mode !== "alignedtable" && mode !== "prettytable") {
|
if (mode !== "alignedtable" && mode !== "prettytable") {
|
||||||
const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`;
|
const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`;
|
||||||
|
|||||||
Reference in New Issue
Block a user