@cristiand391/oclif-carapace-spec-plugin
Version:
oclif plugin to generate a carapace spec
139 lines (133 loc) • 5.58 kB
JavaScript
import { Command, Flags } from '@oclif/core';
import * as ejs from 'ejs';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { styleText } from 'node:util';
import YAML from 'yaml';
import { buildCommandTree } from '../utils/buildCommandTree.js';
export default class CarapaceGen extends Command {
static description = `Generate a carapace spec file
Use \`carapace-gen\` to get shell completion:
https://github.com/carapace-sh/carapace-spec`;
static flags = {
'refresh-cache': Flags.boolean({ char: 'r', summary: 'Refresh cache (ignores displaying instructions)' }),
};
async run() {
const { flags } = await this.parse(CarapaceGen);
const { bin, cacheDir } = this.config;
const specPath = `${cacheDir}/oclif-carapace-spec/${bin}.yml`;
await mkdir(`${cacheDir}/oclif-carapace-spec`, {
recursive: true
});
const commandNodes = this.getCommandNodes();
const macrosFilepath = this.config.scopedEnvVar('CARAPACE_SPEC_MACROS_FILE');
const macros = macrosFilepath ? YAML.parse(await readFile(macrosFilepath, 'utf8')) : undefined;
await writeFile(specPath, YAML.stringify(buildCommandTree(commandNodes, macros, bin, this.config.pjson.description ?? `${bin} CLI`), {
// avoid using YAML anchors in completion file, carapace doesn't like them.
aliasDuplicateObjects: false
}));
if (!flags['refresh-cache']) {
this.log(`
1) Source the following spec file in your shell profile:
${styleText('cyan', specPath)}
${styleText('bold', 'Instructions for supported shells by carapace-gen:')}
https://carapace-sh.github.io/carapace-spec/carapace-spec/usage.html
2) Start using autocomplete
${styleText('cyan', `${bin} <TAB>`)} ## Command completion
${styleText('cyan', `${bin} command --<TAB>`)} ## Flag completion
`);
}
}
getCommandNodes() {
const nodes = [];
// Important:
// Topic nodes need to be added before command ones.
//
// When creating a command tree we pass the whole node array,
// having the topic nodes first ensures their help text matches what's defined
// in the plugin's pjson (descriptions in `oclif.topics`)
for (const t of this.getTopics()) {
nodes.push({
id: t.id,
summary: t.summary
});
}
for (const plugin of this.config.getPluginsList()) {
for (const cmd of plugin.commands) {
if (cmd.state === 'deprecated' || cmd.hidden)
continue;
const summary = this.sanitizeSummary(cmd.summary ?? cmd.description);
const { flags } = cmd;
nodes.push({
flags,
id: cmd.id,
summary,
});
// also include non-deprecated aliases
if (!cmd.deprecateAliases) {
for (const alias of cmd.aliases) {
nodes.push({
flags,
id: alias,
summary,
});
const split = alias.split(':');
let topic = split[0];
// Command nodes are generated from topics:
// `force` -> `force:org` -> `force:org:open|list`
//
// but aliases aren't guaranteed to follow the plugin command tree
// so we need to add any missing topic between the starting point and the alias.
for (let i = 0; i < split.length - 1; i++) {
if (!(this.getTopics()).some((t) => t.id === topic)) {
nodes.push({
flags: {},
id: topic,
summary: 'topic help'
});
}
topic += `:${split[i + 1]}`;
}
}
}
}
}
return nodes;
}
getTopics() {
const topics = this.config.topics
.filter((topic) => {
// it is assumed a topic has a child if it has children
const hasChild = this.config.topics.some((subTopic) => subTopic.name.includes(`${topic.name}:`));
return hasChild;
})
.sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
})
.map((t) => {
const summary = t.description
? this.sanitizeSummary(t.description)
: `${t.name.replaceAll(':', ' ')} commands`;
return {
id: t.name,
summary,
};
});
return topics;
}
sanitizeSummary(summary) {
if (summary === undefined) {
return '';
}
return ejs
.render(summary, { config: this.config })
.replaceAll(/(["`])/g, '\\\\\\$1') // backticks and double-quotes require triple-backslashes
.replaceAll(/([[\]])/g, '\\\\$1') // square brackets require double-backslashes
.split('\n')[0]; // only use the first line
}
}