Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"scripts": {
"prebuild": "rm -rf dist/*",
"build": "tsc",
"lint": "eslint src test",
"lint": "eslint '{src,test}/**/*.ts'",
"version": "npm verison --no-commit-hooks --no-git-tag-version",
"setup": "git config core.hooksPath ./githooks",
"docs": "npm run docs-readme && npm run docs-changelog",
Expand Down
114 changes: 114 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,120 @@ npm i --save-dev @acrontum/boats-cli
then you can just run `npx bc model test --dry-run`.


## Custom Generator Overrides

You can override any of the generators by adding any of these files to a templates folder, and passing `--templates <folder>` to the cli:
- boats-rc.js
- component-index.js
- create.js
- delete.js
- index.js
- list.js
- model.js
- models.js
- pagination-model.js
- param.js
- path-index.js
- replace.js
- show.js
- update.js

Or, alternatively, exporting any of the following methods from a module (local file or node module):
```js
exports.getBoatsRc = (opts, file) => { /* ... */ };
exports.getIndex = (opts, file) => { /* ... */ };
exports.getComponentIndex = (opts, file) => { /* ... */ };
exports.getModel = (opts, file) => { /* ... */ };
exports.getModels = (opts, file) => { /* ... */ };
exports.getParam = (opts, file) => { /* ... */ };
exports.getPaginationModel = (opts, file) => { /* ... */ };
exports.getPathIndex = (opts, file) => { /* ... */ };
exports.getList = (opts, file) => { /* ... */ };
exports.getCreate = (opts, file) => { /* ... */ };
exports.getShow = (opts, file) => { /* ... */ };
exports.getDelete = (opts, file) => { /* ... */ };
exports.getUpdate = (opts, file) => { /* ... */ };
exports.getReplace = (opts, file) => { /* ... */ };
```

for exmaple, `templates/index.js` or `exports.getList`:
```js
// @ts-check
const { toYaml } = require('@acrontum/boats-cli/dist/src/lib');

/** @type{import('@acrontum/boats-cli/').CustomTemplates['getList']} */
module.exports = (_globalOptions, file, pluralName, schemaRef, parameters) => {
return toYaml({
summary: `from ${file}`,
description: `pluralName ${pluralName}`,
...(parameters?.length ? { parameters } : {}),
responses: {
'"200"': {
description: 'Success',
content: {
'application/json': {
schema: { $ref: schemaRef },
},
},
},
},
});
};

````

or disabling the default generator and instead creating 2 different files for models `templates/model.yml` or `exports.getModel`:
```js
// @ts-check
const { toYaml } = require('@acrontum/boats-cli/dist/src/lib');
const { writeFile, mkdir } = require('node:fs/promises');
const { dirname, join } = require('node:path');

/** @type{import('@acrontum/boats-cli/').CustomTemplates['getModel']} */
module.exports = async (globalOptions, file) => {
const base = join(globalOptions.output || '.', file.replace('model.yml', 'base.yml'));
const extend = join(globalOptions.output || '.', file.replace('model.yml', 'extend.yml'));

await mkdir(dirname(base), { recursive: true });

await Promise.all([
writeFile(
base,
toYaml({
type: 'object',
required: ['name'],
properties: {
name: { type: 'string' },
},
}),
),
writeFile(
extend,
toYaml({
allOf: [
{ $ref: './base.yml' },
{
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
},
},
},
],
}),
),
]);

// prevent default generation
return '';
};
```

see [custom-models.spec.ts](./test/custom-models.spec.ts) and the [overrides folder]('./test/fixtures/overrides/') for more examples.


## Development

Configure githooks:
Expand Down
169 changes: 165 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
#!/usr/bin/env node

import { mkdir } from 'node:fs/promises';
import { relative } from 'node:path';
import { mkdir, access, stat, readdir } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import { parseArgs, ParseArgsConfig } from 'node:util';
import { generate, GenerationTask, logger } from './generate';
import { argname, description } from './lib';
import { parseInitCommand } from './subcommands/init';
import { modelCliArguments, parseModelCommand } from './subcommands/model';
import { parsePathCommand, pathCliArguments } from './subcommands/path';
import { getBoatsRc, getIndex } from './templates/init';
import { getComponentIndex, getModel, getModels, getPaginationModel, getParam } from './templates/model';
import { getCreate, getDelete, getList, getPathIndex, getReplace, getShow, getUpdate } from './templates/path';

export type ParseArgsOptionConfig = NonNullable<ParseArgsConfig['options']>[string];
export type CliArg = ParseArgsOptionConfig & { [description]: string; [argname]?: string };
Expand All @@ -22,8 +24,27 @@ export type GlobalOptions = {
quiet?: boolean;
verbose?: boolean;
'root-ref'?: string;
customTemplates?: {
getBoatsRc?: typeof getBoatsRc;
getIndex?: typeof getIndex;

getComponentIndex?: typeof getComponentIndex;
getModel?: typeof getModel;
getModels?: typeof getModels;
getParam?: typeof getParam;
getPaginationModel?: typeof getPaginationModel;

getPathIndex?: typeof getPathIndex;
getList?: typeof getList;
getCreate?: typeof getCreate;
getShow?: typeof getShow;
getDelete?: typeof getDelete;
getUpdate?: typeof getUpdate;
getReplace?: typeof getReplace;
};
};
export type SubcommandGenerator = (args: string[], options: GlobalOptions) => GenerationTask[] | null;
export type CustomTemplates = Exclude<GlobalOptions['customTemplates'], undefined>;

/**
* Custom error to immediately exit on errors.
Expand All @@ -45,8 +66,35 @@ const subcommands: Record<string, SubcommandGenerator> = {
init: parseInitCommand,
};

const templateFileMapping = {
'boats-rc.js': 'getBoatsRc',
'component-index.js': 'getComponentIndex',
'create.js': 'getCreate',
'delete.js': 'getDelete',
'index.js': 'getIndex',
'list.js': 'getList',
'model.js': 'getModel',
'models.js': 'getModels',
'pagination-model.js': 'getPaginationModel',
'param.js': 'getParam',
'path-index.js': 'getPathIndex',
'replace.js': 'getReplace',
'show.js': 'getShow',
'update.js': 'getUpdate',
} as const satisfies Record<string, keyof Exclude<GlobalOptions['customTemplates'], undefined>>;

export const cliArguments: Record<string, CliArg> = {
/*
-t --templates
create.js delete.js list.js model.js models.js pagination.js param.js replace.js show.js update.js
*/
'dry-run': { type: 'boolean', short: 'D', [description]: 'Print the changes to be made' },
templates: {
type: 'string',
short: 'T',
[argname]: 'TEMPLATES',
[description]: 'Folder or module containing template overrides',
},
force: { type: 'boolean', short: 'f', [description]: 'Overwrite existing files' },
'no-index': { type: 'boolean', short: 'I', [description]: 'Skip auto-creating index files, only models' },
'no-init': { type: 'boolean', short: 'N', [description]: 'Skip auto-creating init files' },
Expand Down Expand Up @@ -114,6 +162,11 @@ Subcommands:
model SINGULAR_NAME [...OPTIONS]
init [-a]

If templates are used (-T, --templates), the following filenames / exports are used (files when path is a folder, export function name if is a module):
- ${Object.entries(templateFileMapping)
.map(([file, fn]) => `${file.padEnd(19, ' ')} - ${fn}`)
.join('\n - ')}

Examples:
npx bc path users/:id --list --get --delete --patch --put

Expand Down Expand Up @@ -153,6 +206,90 @@ export const parseCliArgs = (
}
};

const tryRequire = (path: string): GlobalOptions['customTemplates'] | null => {
const overrides: Exclude<GlobalOptions['customTemplates'], undefined> = {};
let lib: Exclude<GlobalOptions['customTemplates'], undefined> | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
lib = require(path) as Exclude<GlobalOptions['customTemplates'], undefined>;
} catch (_) {}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
lib = require(resolve(path)) as Exclude<GlobalOptions['customTemplates'], undefined>;
} catch (_) {}

if (lib) {
overrides.getBoatsRc = lib.getBoatsRc;
overrides.getIndex = lib.getIndex;
overrides.getComponentIndex = lib.getComponentIndex;
overrides.getModel = lib.getModel;
overrides.getModels = lib.getModels;
overrides.getParam = lib.getParam;
overrides.getPaginationModel = lib.getPaginationModel;
overrides.getPathIndex = lib.getPathIndex;
overrides.getList = lib.getList;
overrides.getCreate = lib.getCreate;
overrides.getShow = lib.getShow;
overrides.getDelete = lib.getDelete;
overrides.getUpdate = lib.getUpdate;
overrides.getReplace = lib.getReplace;

if (!Object.values(overrides).find((v) => typeof v !== 'undefined')) {
logger.console.error(`cannot load templates "${path}": module has no override exports\n`);

return help(1);
}

return overrides;
}

logger.console.error(`cannot load templates "${path}": not a module\n`);

return help(1);
};

const getTemplates = async (path: string): Promise<GlobalOptions['customTemplates'] | null> => {
const overrides: Exclude<GlobalOptions['customTemplates'], undefined> = {};
const fullPath = resolve(path);

const accessible = await access(fullPath)
.then(() => true)
.catch(() => false);
if (!accessible) {
return tryRequire(path);
}

const folder = await stat(fullPath).catch(() => null);
if (folder === null) {
return tryRequire(path);
}

if (!folder.isDirectory()) {
return tryRequire(path);
}

const files = await readdir(fullPath).catch(() => null);
if (files === null) {
logger.console.error(`cannot load templates "${path}": could not read template folder contents\n`);

return help(1);
}

const matchingFiles = files.filter((file) => file in templateFileMapping) as (keyof typeof templateFileMapping)[];
if (matchingFiles.length === 0) {
logger.console.error(`cannot load templates "${path}": template folder has no override files\n`);

return help(1);
}

for (const file of matchingFiles) {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
overrides[templateFileMapping[file]] = require(join(fullPath, file));
}

return overrides;
};

export const cli = async (args: string[]): Promise<Record<string, GenerationTask>> => {
const processed = parseCliArgs(
{
Expand All @@ -176,6 +313,7 @@ export const cli = async (args: string[]): Promise<Record<string, GenerationTask
const globalOptions: GlobalOptions = {};
const todo: string[][] = [];
const subcommand: string[] = [];
let processTemplates: string | null = null;

let done = false;
let hasSubCommand = false;
Expand All @@ -194,13 +332,24 @@ export const cli = async (args: string[]): Promise<Record<string, GenerationTask
}

if (arg.name in cliArguments) {
if (arg.name === 'output' || arg.name === 'root-ref') {
if (arg.name === 'templates') {
if (!arg.value) {
help(1, `Parameter '--${arg.name}' requires a value`);

return {};
}
processTemplates = arg.inlineValue ? arg.value.slice(1) : arg.value;
} else if (arg.name === 'output' || arg.name === 'root-ref') {
if (!arg.value) {
help(1, `Parameter '--${arg.name}' requires a value`);

return {};
}
globalOptions[arg.name] = arg.value;
} else if (arg.value) {
help(1, `Parameter '--${arg.name}' is not expecting a value`);

return {};
} else {
globalOptions[arg.name as Exclude<keyof typeof globalOptions, 'output' | 'root-ref'>] = true;
}
Expand Down Expand Up @@ -266,8 +415,18 @@ export const cli = async (args: string[]): Promise<Record<string, GenerationTask
globalOptions.output = relative('.', globalOptions.output);
}

if (processTemplates) {
const templates = await getTemplates(processTemplates);
if (templates !== null) {
globalOptions.customTemplates = templates;
}
}

if (!globalOptions['no-init']) {
tasks.push({ contents: () => getIndex(globalOptions), filename: 'src/index.yml' }, { contents: getBoatsRc, filename: '.boatsrc' });
tasks.push(
{ contents: () => getIndex(globalOptions, 'src/index.yml'), filename: 'src/index.yml' },
{ contents: () => getBoatsRc(globalOptions, '.boatsrc'), filename: '.boatsrc' },
);
}

if (!tasks.length) {
Expand All @@ -291,6 +450,8 @@ export const cli = async (args: string[]): Promise<Record<string, GenerationTask
}
}

// if custom templates - find all and import

return await generate(tasks, globalOptions);
};

Expand Down
1 change: 1 addition & 0 deletions src/components/parameters/index.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{ autoComponentIndexer() }}
1 change: 1 addition & 0 deletions src/components/schemas/index.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{ autoComponentIndexer('Model') }}
Loading