Skip to content

Commit

Permalink
feat: rewrite CLI
Browse files Browse the repository at this point in the history
  • Loading branch information
jonahsnider committed Oct 15, 2021
1 parent b7f6c60 commit 6aef24e
Show file tree
Hide file tree
Showing 16 changed files with 375 additions and 174 deletions.
4 changes: 4 additions & 0 deletions docs/how.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@
- Get typical usages of a command:

`how {{command}}`

- Refresh knowledge base:

`how refresh`
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,24 @@
"prettier": "@jonahsnider/prettier-config",
"dependencies": {
"@jonahsnider/util": "8.0.1",
"convert": "4.2.2",
"clipanion": "3.2.0-rc.3",
"convert": "4.2.4",
"decompress": "4.2.1",
"execa": "5.1.1",
"got": "11.8.2",
"path-exists": "5.0.0",
"update-notifier": "5.1.0"
},
"devDependencies": {
"@jonahsnider/prettier-config": "1.0.0",
"@jonahsnider/xo-config": "4.0.4",
"@jonahsnider/prettier-config": "1.1.0",
"@jonahsnider/xo-config": "4.1.0",
"@types/decompress": "4.2.4",
"@types/node": "16.10.3",
"@types/update-notifier": "5.1.0",
"prettier": "2.4.1",
"semantic-release": "18.0.0",
"ts-node": "10.2.1",
"type-fest": "2.3.4",
"typescript": "4.4.3",
"xo": "0.45.0"
},
Expand Down
66 changes: 66 additions & 0 deletions src/commands/how-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {URL} from 'node:url';
import {Command, Option} from 'clipanion';
import {CommandError} from '../utils/errors.js';
import * as Glow from '../utils/glow/index.js';
import * as Tldr from '../utils/tldr.js';

class MissingCommandError extends CommandError {
constructor(message: string) {
super(message, false);
this.name = 'MissingCommandError';
}
}

export class HowCommand extends Command {
static paths = [Command.Default, ['view']];

// eslint-disable-next-line new-cap
static usage = Command.Usage({
description: 'Learn how to use a CLI app.',
examples: [
['Basic command', '$0 tar'],
['Space-delimited command', '$0 git status'],
['Slugified command', '$0 git-status'],
],
});

readonly command = Option.String();
// eslint-disable-next-line new-cap
readonly subCommand = Option.Rest();

async execute() {
const app = [this.command, ...this.subCommand].join('-') || 'how';

let rawMarkdown: Tldr.RawMarkdown;

const preparations: Array<Promise<void>> = [Glow.prepare()];

if (app === 'how') {
rawMarkdown = (await fs.readFile(new URL(path.join('..', '..', 'docs', 'how.md'), import.meta.url), 'utf-8')) as Tldr.RawMarkdown;
} else {
const tldrPrepare = Tldr.prepare();
preparations.push(tldrPrepare);
await tldrPrepare;

const rawTldr = await Tldr.read(app);

if (rawTldr === null) {
throw new MissingCommandError("That command doesn't exist in the knowledge base (try running `how refresh`)");
}

rawMarkdown = rawTldr;
}

await Promise.all(preparations);

const formattedTldr: Tldr.FormattedMarkdown = Tldr.format(rawMarkdown);

const rawGlow = await Glow.render(formattedTldr);

const formattedGlow = Glow.format(rawGlow);

this.context.stdout.write(formattedGlow);
}
}
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './how-command.js';
export * from './refresh-command.js';
16 changes: 16 additions & 0 deletions src/commands/refresh-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {Command} from 'clipanion';
import * as Tldr from '../utils/tldr.js';

export class RefreshCommand extends Command {
static paths = [['refresh'], ['-r']];

// eslint-disable-next-line new-cap
static usage = Command.Usage({
description: 'Refresh the downloaded knowledge base.',
category: 'Meta',
});

async execute() {
await Tldr.refresh();
}
}
20 changes: 0 additions & 20 deletions src/format.ts

This file was deleted.

117 changes: 17 additions & 100 deletions src/index.ts
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,113 +1,30 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process, {stdout} from 'node:process';
import process from 'node:process';
import {URL} from 'node:url';
import type {PathLike} from 'node:fs';
import execa from 'execa';
import {convert} from 'convert';
import {Stopwatch} from '@jonahsnider/util';
import {Builtins, Cli} from 'clipanion';
import updateNotifier from 'update-notifier';
import {getOsKind, resolveVersion, updateGlow} from './fetch-glow/index.js';
import {formatGlow, formatTldr} from './format.js';
import {desiredGlowVersion, options} from './options.js';
import {glowPath, tldrPath} from './paths.js';
import * as Commands from './commands/index.js';

updateNotifier({pkg: JSON.parse(await fs.readFile(new URL(path.join('..', 'package.json'), import.meta.url), 'utf-8')) as updateNotifier.Package}).notify();
const args = process.argv.slice(2);

try {
await fs.access(tldrPath);
} catch {
await fs.mkdir(tldrPath, {recursive: true});
await execa('git', ['clone', 'https://github.com/tldr-pages/tldr.git', tldrPath]);
console.log('downloaded the database of Markdown examples');
}

if (options.glowVersion !== desiredGlowVersion) {
// Currently installed version is not the same as the desired version

const osKind = getOsKind();
const version = resolveVersion(osKind);

try {
const stopwatch = Stopwatch.start();
await updateGlow(version);

const duration = convert(stopwatch.end(), 'ms').to('s').toFixed(2);
const pkg = JSON.parse(await fs.readFile(new URL(path.join('..', 'package.json'), import.meta.url), 'utf-8')) as updateNotifier.Package;

console.log(`downloaded a new binary for Glow (makes Markdown pretty, took ${duration}s)`);
} catch (error) {
console.error('failed to download a new binary for Glow (makes Markdown pretty)', error);
process.exit(1);
}
}

if (Math.random() > 0.95) {
try {
const stopwatch = Stopwatch.start();
await execa('git', ['pull'], {cwd: tldrPath});
const duration = convert(stopwatch.end(), 'ms').to('s').toFixed(2);

console.log(`you got unlucky and the cache for the Markdown database was refreshed (took ${duration}s)`);
} catch (error) {
console.error('failed to refresh the cache for the Markdown database', error);
}
}
updateNotifier({pkg}).notify();

const command = process.argv[process.argv.length - 1];
const categories = ['common', 'linux'];
const cli = new Cli({
binaryLabel: `how`,
binaryName: `how`,
binaryVersion: pkg.version,
});

switch (os.platform()) {
case 'darwin':
categories.unshift('osx');
break;
case 'cygwin':
case 'win32':
categories.unshift('windows');
break;
case 'sunos':
console.log('how are you even running node on sunos???');
categories.unshift('sunos');
break;
case 'android':
categories.unshift('android');
break;
default:
break;
// eslint-disable-next-line @typescript-eslint/naming-convention
for (const Command of Object.values(Commands)) {
cli.register(Command);
}

const potentialPaths: PathLike[] = categories.map(dir => path.join(tldrPath, 'pages', dir, `${command}.md`));

if (
// Command was probably a path (ex. '/home/jonah/programming/how/tsc_output')
command.includes(path.sep) ||
// Command is this program
command === 'how'
) {
potentialPaths.unshift(new URL(path.join('..', 'docs', 'how.md'), import.meta.url));
}

for (const potentialPath of potentialPaths) {
/* eslint-disable no-await-in-loop */
let contents: string;

try {
contents = await fs.readFile(potentialPath, 'utf-8');
} catch {
continue;
}

const formatted = formatTldr(contents);

const tldr = await execa(glowPath, ['-s', 'dark', '-'], {
input: formatted,
});
/* eslint-enable no-await-in-loop */

stdout.write(formatGlow(tldr.stdout));
process.exit(0);
}
cli.register(Builtins.HelpCommand);
cli.register(Builtins.VersionCommand);

console.error('no');
process.exit(1);
await cli.runExit(args);
68 changes: 54 additions & 14 deletions src/options.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,78 @@
import * as fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {cacheDir, optionsPath} from './paths.js';
import {pathExists} from 'path-exists';
import {CACHE_DIR, OPTIONS_PATH} from './paths.js';

export interface Options {
interface OptionsV1 {
versionNumber: 1;
/** Currently installed version for glow. */
glowVersion: string | null;
versionNumber: 1;
}

interface OptionsV2 {
versionNumber: 2;
/** Currently installed version for glow. */
glowVersion: string | null;
/** Timestamp of last refresh. */
lastRefresh: number;
}

type OptionsLatest = OptionsV2;
type Options = OptionsV1 | OptionsV2;

export {OptionsLatest as Optinons};

/**
* The desired version of Glow to download locally.
* @see https://github.com/charmbracelet/glow/releases
*/
export const desiredGlowVersion = '1.4.1';
export const DESIRED_GLOW_VERSION = '1.4.1';

export const defaultOptions: Options = {
export const DEFAULT_OPTIONS: Readonly<OptionsLatest> = {
versionNumber: 2,
glowVersion: null,
versionNumber: 1,
lastRefresh: 0,
};

async function exists() {
return pathExists(OPTIONS_PATH);
}

/**
* Flush options object to filesystem.
* @param data - Options to flush
*/
export async function flushOptions(data: Options): Promise<void> {
await fs.writeFile(optionsPath, `${JSON.stringify(data, undefined, 2)}${os.EOL}`, 'utf-8');
export async function flushOptions(data: OptionsLatest): Promise<void> {
await fs.writeFile(OPTIONS_PATH, `${JSON.stringify(data, undefined, 2)}${os.EOL}`, 'utf-8');
}

try {
await fs.access(optionsPath);
} catch {
await fs.mkdir(cacheDir, {recursive: true});
await flushOptions(defaultOptions);
async function readOptions(): Promise<OptionsLatest> {
if (!(await exists())) {
await fs.mkdir(CACHE_DIR, {recursive: true});
await flushOptions(DEFAULT_OPTIONS);
}

const contents = await fs.readFile(path.join(OPTIONS_PATH), 'utf-8');

const rawOptions = JSON.parse(contents) as Options;

switch (rawOptions.versionNumber) {
case 1: {
const options: OptionsLatest = {...DEFAULT_OPTIONS, ...rawOptions, versionNumber: 2};
await flushOptions(options);

return readOptions();
}

case 2: {
return rawOptions;
}

default: {
throw new RangeError(`Unknown options version`);
}
}
}

export const options = JSON.parse(await fs.readFile(path.join(optionsPath), 'utf-8')) as Options;
export const options = await readOptions();
8 changes: 4 additions & 4 deletions src/paths.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {homedir} from 'node:os';
import path from 'node:path';

export const cacheDir = path.join(homedir(), '.cache', 'how');
export const optionsPath = path.join(cacheDir, 'options.json');
export const tldrPath = path.join(cacheDir, 'tldr');
export const glowPath = path.join(cacheDir, 'glow');
export const CACHE_DIR = path.join(homedir(), '.cache', 'how');
export const OPTIONS_PATH = path.join(CACHE_DIR, 'options.json');
export const TLDR_PATH = path.join(CACHE_DIR, 'tldr');
export const GLOW_PATH = path.join(CACHE_DIR, 'glow');
10 changes: 10 additions & 0 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export class CommandError extends Error {
constructor(message: string, stack?: boolean) {
super(message);
this.name = 'CommandError';

if (stack === false) {
this.stack = undefined;
}
}
}
Loading

0 comments on commit 6aef24e

Please sign in to comment.