From edfba198004e369a05b2acc56dca273a121a2e82 Mon Sep 17 00:00:00 2001 From: hellodword <46193371+hellodword@users.noreply.github.com> Date: Fri, 29 Mar 2024 05:54:39 +0000 Subject: [PATCH] Fix and refactor the generate-docs subcommand --- package.json | 1 - .../collectionCommonUtils/generateDocs.ts | 38 +++++++ .../generateDocsCommandImpl.ts | 105 +++++++++++------- src/spec-node/devContainersSpecCLI.ts | 4 +- src/spec-node/featuresCLI/generateDocs.ts | 48 +++++--- src/spec-node/templatesCLI/generateDocs.ts | 44 +++++--- .../featuresCLICommands.test.ts | 57 +++++++++- .../templatesCLICommands.test.ts | 51 ++++++++- 8 files changed, 262 insertions(+), 86 deletions(-) create mode 100644 src/spec-node/collectionCommonUtils/generateDocs.ts diff --git a/package.json b/package.json index 27c38636..5f02f19e 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "test": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/*.test.ts", "test-matrix": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit", "test-container-features": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-features/*.test.ts", - "test-container-features-cli": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-features/featuresCLICommands.test.ts", "test-container-templates": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-templates/*.test.ts" }, "files": [ diff --git a/src/spec-node/collectionCommonUtils/generateDocs.ts b/src/spec-node/collectionCommonUtils/generateDocs.ts new file mode 100644 index 00000000..45de446c --- /dev/null +++ b/src/spec-node/collectionCommonUtils/generateDocs.ts @@ -0,0 +1,38 @@ +import { Argv } from 'yargs'; +import { CLIHost } from '../../spec-common/cliHost'; +import { Log } from '../../spec-utils/log'; + +const targetPositionalDescription = (collectionType: GenerateDocsCollectionType) => ` +Generate docs of ${collectionType}s at provided [target] (default is cwd), where [target] is either: + 1. A path to the src folder of the collection with [1..n] ${collectionType}s. + 2. A path to a single ${collectionType} that contains a devcontainer-${collectionType}.json. +`; + +export function GenerateDocsOptions(y: Argv, collectionType: GenerateDocsCollectionType) { + return y + .options({ + 'registry': { type: 'string', alias: 'r', default: 'ghcr.io', description: 'Name of the OCI registry.', hidden: collectionType !== 'feature' }, + 'namespace': { type: 'string', alias: 'n', require: collectionType === 'feature', description: `Unique indentifier for the collection of features. Example: /`, hidden: collectionType !== 'feature' }, + 'github-owner': { type: 'string', default: '', description: `GitHub owner for docs.` }, + 'github-repo': { type: 'string', default: '', description: `GitHub repo for docs.` }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' } + }) + .positional('target', { type: 'string', default: '.', description: targetPositionalDescription(collectionType) }) + .check(_argv => { + return true; + }); +} + +export type GenerateDocsCollectionType = 'feature' | 'template'; + +export interface GenerateDocsCommandInput { + cliHost: CLIHost; + targetFolder: string; + registry?: string; + namespace?: string; + gitHubOwner: string; + gitHubRepo: string; + output: Log; + disposables: (() => Promise | undefined)[]; + isSingle?: boolean; // Generating docs for a collection of many features/templates. Should autodetect. +} diff --git a/src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts b/src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts index 0a866f81..53066a8b 100644 --- a/src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts @@ -1,7 +1,9 @@ import * as fs from 'fs'; import * as path from 'path'; import * as jsonc from 'jsonc-parser'; -import { Log, LogLevel } from '../../spec-utils/log'; +import { LogLevel } from '../../spec-utils/log'; +import { GenerateDocsCollectionType, GenerateDocsCommandInput } from './generateDocs'; +import { isLocalFile, isLocalFolder, readLocalDir } from '../../spec-utils/pfs'; const FEATURES_README_TEMPLATE = ` # #{Name} @@ -39,49 +41,42 @@ const TEMPLATE_README_TEMPLATE = ` _Note: This file was auto-generated from the [devcontainer-template.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._ `; -export async function generateFeaturesDocumentation( - basePath: string, - ociRegistry: string, - namespace: string, - gitHubOwner: string, - gitHubRepo: string, - output: Log -) { - await _generateDocumentation(output, basePath, FEATURES_README_TEMPLATE, - 'devcontainer-feature.json', ociRegistry, namespace, gitHubOwner, gitHubRepo); -} +const README_TEMPLATES = { + 'feature': FEATURES_README_TEMPLATE, + 'template': TEMPLATE_README_TEMPLATE, +}; + +export async function generateDocumentation(args: GenerateDocsCommandInput, collectionType: GenerateDocsCollectionType) { + + const { + targetFolder: basePath, + registry, + namespace, + gitHubOwner, + gitHubRepo, + output, + isSingle, + } = args; + + const readmeTemplate = README_TEMPLATES[collectionType]; + + const directories = isSingle ? ['.'] : await readLocalDir(basePath); -export async function generateTemplatesDocumentation( - basePath: string, - gitHubOwner: string, - gitHubRepo: string, - output: Log -) { - await _generateDocumentation(output, basePath, TEMPLATE_README_TEMPLATE, - 'devcontainer-template.json', '', '', gitHubOwner, gitHubRepo); -} -async function _generateDocumentation( - output: Log, - basePath: string, - readmeTemplate: string, - metadataFile: string, - ociRegistry: string = '', - namespace: string = '', - gitHubOwner: string = '', - gitHubRepo: string = '' -) { - const directories = fs.readdirSync(basePath); + const metadataFile = `devcontainer-${collectionType}.json`; await Promise.all( directories.map(async (f: string) => { - if (!f.startsWith('.')) { - const readmePath = path.join(basePath, f, 'README.md'); - output.write(`Generating ${readmePath}...`, LogLevel.Info); + if (f.startsWith('..')) { + return; + } + + const readmePath = path.join(basePath, f, 'README.md'); + output.write(`Generating ${readmePath}...`, LogLevel.Info); - const jsonPath = path.join(basePath, f, metadataFile); + const jsonPath = path.join(basePath, f, metadataFile); - if (!fs.existsSync(jsonPath)) { + if (!(await isLocalFile(jsonPath))) { output.write(`(!) Warning: ${metadataFile} not found at path '${jsonPath}'. Skipping...`, LogLevel.Warning); return; } @@ -176,8 +171,8 @@ async function _generateDocumentation( .replace('#{Notes}', generateNotesMarkdown()) .replace('#{RepoUrl}', urlToConfig) // Features Only - .replace('#{Registry}', ociRegistry) - .replace('#{Namespace}', namespace) + .replace('#{Registry}', registry || '') + .replace('#{Namespace}', namespace || '') .replace('#{Version}', version) .replace('#{Customizations}', extensions); @@ -191,8 +186,36 @@ async function _generateDocumentation( } // Write new readme - fs.writeFileSync(readmePath, newReadme); - } + fs.writeFileSync(readmePath, newReadme); }) ); } + +export async function prepGenerateDocsCommand(args: GenerateDocsCommandInput, collectionType: GenerateDocsCollectionType): Promise { + const { cliHost, targetFolder, registry, namespace, gitHubOwner, gitHubRepo, output, disposables } = args; + + const targetFolderResolved = cliHost.path.resolve(targetFolder); + if (!(await isLocalFolder(targetFolderResolved))) { + throw new Error(`Target folder '${targetFolderResolved}' does not exist`); + } + + // Detect if we're dealing with a collection or a single feature/template + const isValidFolder = await isLocalFolder(cliHost.path.join(targetFolderResolved)); + const isSingle = await isLocalFile(cliHost.path.join(targetFolderResolved, `devcontainer-${collectionType}.json`)); + + if (!isValidFolder) { + throw new Error(`Target folder '${targetFolderResolved}' does not exist`); + } + + return { + cliHost, + targetFolder: targetFolderResolved, + registry, + namespace, + gitHubOwner, + gitHubRepo, + output, + disposables, + isSingle + }; +} diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 7af09050..e803472b 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -80,12 +80,12 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa y.command('publish ', 'Package and publish Features', featuresPublishOptions, featuresPublishHandler); y.command('info ', 'Fetch metadata for a published Feature', featuresInfoOptions, featuresInfoHandler); y.command('resolve-dependencies', 'Read and resolve dependency graph from a configuration', featuresResolveDependenciesOptions, featuresResolveDependenciesHandler); - y.command('generate-docs', 'Generate documentation', featuresGenerateDocsOptions, featuresGenerateDocsHandler); + y.command('generate-docs ', 'Generate documentation', featuresGenerateDocsOptions, featuresGenerateDocsHandler); }); y.command('templates', 'Templates commands', (y: Argv) => { y.command('apply', 'Apply a template to the project', templateApplyOptions, templateApplyHandler); y.command('publish ', 'Package and publish templates', templatesPublishOptions, templatesPublishHandler); - y.command('generate-docs', 'Generate documentation', templatesGenerateDocsOptions, templatesGenerateDocsHandler); + y.command('generate-docs ', 'Generate documentation', templatesGenerateDocsOptions, templatesGenerateDocsHandler); }); y.command(restArgs ? ['exec', '*'] : ['exec [args..]'], 'Execute a command on a running dev container', execOptions, execHandler); y.epilog(`devcontainer@${version} ${packageFolder}`); diff --git a/src/spec-node/featuresCLI/generateDocs.ts b/src/spec-node/featuresCLI/generateDocs.ts index 76a07707..3b2e668c 100644 --- a/src/spec-node/featuresCLI/generateDocs.ts +++ b/src/spec-node/featuresCLI/generateDocs.ts @@ -1,24 +1,18 @@ import { Argv } from 'yargs'; import { UnpackArgv } from '../devContainersSpecCLI'; -import { generateFeaturesDocumentation } from '../collectionCommonUtils/generateDocsCommandImpl'; import { createLog } from '../devContainers'; import { mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; +import { isLocalFolder } from '../../spec-utils/pfs'; +import { getCLIHost } from '../../spec-common/cliHost'; +import { loadNativeModule } from '../../spec-common/commonUtils'; +import { GenerateDocsCommandInput, GenerateDocsOptions } from '../collectionCommonUtils/generateDocs'; +import { generateDocumentation, prepGenerateDocsCommand } from '../collectionCommonUtils/generateDocsCommandImpl'; + +const collectionType = 'feature'; -// -- 'features generate-docs' command export function featuresGenerateDocsOptions(y: Argv) { - return y - .options({ - 'project-folder': { type: 'string', alias: 'p', default: '.', description: 'Path to folder containing \'src\' and \'test\' sub-folders. This is likely the git root of the project.' }, - 'registry': { type: 'string', alias: 'r', default: 'ghcr.io', description: 'Name of the OCI registry.' }, - 'namespace': { type: 'string', alias: 'n', require: true, description: `Unique indentifier for the collection of features. Example: /` }, - 'github-owner': { type: 'string', default: '', description: `GitHub owner for docs.` }, - 'github-repo': { type: 'string', default: '', description: `GitHub repo for docs.` }, - 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' } - }) - .check(_argv => { - return true; - }); + return GenerateDocsOptions(y, collectionType); } export type FeaturesGenerateDocsArgs = UnpackArgv>; @@ -28,7 +22,7 @@ export function featuresGenerateDocsHandler(args: FeaturesGenerateDocsArgs) { } export async function featuresGenerateDocs({ - 'project-folder': collectionFolder, + 'target': targetFolder, 'registry': registry, 'namespace': namespace, 'github-owner': gitHubOwner, @@ -42,6 +36,8 @@ export async function featuresGenerateDocs({ const pkg = getPackageConfig(); + const cwd = process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule, true); const output = createLog({ logLevel: mapLogLevel(inputLogLevel), logFormat: 'text', @@ -49,9 +45,27 @@ export async function featuresGenerateDocs({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - await generateFeaturesDocumentation(collectionFolder, registry, namespace, gitHubOwner, gitHubRepo, output); + const targetFolderResolved = cliHost.path.resolve(targetFolder); + if (!(await isLocalFolder(targetFolderResolved))) { + throw new Error(`Target folder '${targetFolderResolved}' does not exist`); + } + + + const args: GenerateDocsCommandInput = { + cliHost, + targetFolder, + registry, + namespace, + gitHubOwner, + gitHubRepo, + output, + disposables, + }; + + const preparedArgs = await prepGenerateDocsCommand(args, collectionType); + + await generateDocumentation(preparedArgs, collectionType); - // Cleanup await dispose(); process.exit(); } diff --git a/src/spec-node/templatesCLI/generateDocs.ts b/src/spec-node/templatesCLI/generateDocs.ts index 06ad3974..312a3e1a 100644 --- a/src/spec-node/templatesCLI/generateDocs.ts +++ b/src/spec-node/templatesCLI/generateDocs.ts @@ -1,22 +1,18 @@ import { Argv } from 'yargs'; import { UnpackArgv } from '../devContainersSpecCLI'; -import { generateTemplatesDocumentation } from '../collectionCommonUtils/generateDocsCommandImpl'; +import { generateDocumentation, prepGenerateDocsCommand } from '../collectionCommonUtils/generateDocsCommandImpl'; import { createLog } from '../devContainers'; import { mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; +import { GenerateDocsCommandInput, GenerateDocsOptions } from '../collectionCommonUtils/generateDocs'; +import { getCLIHost } from '../../spec-common/cliHost'; +import { loadNativeModule } from '../../spec-common/commonUtils'; +import { isLocalFolder } from '../../spec-utils/pfs'; + +const collectionType = 'template'; -// -- 'templates generate-docs' command export function templatesGenerateDocsOptions(y: Argv) { - return y - .options({ - 'project-folder': { type: 'string', alias: 'p', default: '.', description: 'Path to folder containing \'src\' and \'test\' sub-folders. This is likely the git root of the project.' }, - 'github-owner': { type: 'string', default: '', description: `GitHub owner for docs.` }, - 'github-repo': { type: 'string', default: '', description: `GitHub repo for docs.` }, - 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' } - }) - .check(_argv => { - return true; - }); + return GenerateDocsOptions(y, collectionType); } export type TemplatesGenerateDocsArgs = UnpackArgv>; @@ -26,7 +22,7 @@ export function templatesGenerateDocsHandler(args: TemplatesGenerateDocsArgs) { } export async function templatesGenerateDocs({ - 'project-folder': collectionFolder, + 'target': targetFolder, 'github-owner': gitHubOwner, 'github-repo': gitHubRepo, 'log-level': inputLogLevel, @@ -38,6 +34,8 @@ export async function templatesGenerateDocs({ const pkg = getPackageConfig(); + const cwd = process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule, true); const output = createLog({ logLevel: mapLogLevel(inputLogLevel), logFormat: 'text', @@ -45,9 +43,25 @@ export async function templatesGenerateDocs({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - await generateTemplatesDocumentation(collectionFolder, gitHubOwner, gitHubRepo, output); + const targetFolderResolved = cliHost.path.resolve(targetFolder); + if (!(await isLocalFolder(targetFolderResolved))) { + throw new Error(`Target folder '${targetFolderResolved}' does not exist`); + } + + + const args: GenerateDocsCommandInput = { + cliHost, + targetFolder, + gitHubOwner, + gitHubRepo, + output, + disposables, + }; + + const preparedArgs = await prepGenerateDocsCommand(args, collectionType); + + await generateDocumentation(preparedArgs, collectionType); - // Cleanup await dispose(); process.exit(); } diff --git a/src/test/container-features/featuresCLICommands.test.ts b/src/test/container-features/featuresCLICommands.test.ts index 1d20d5b1..d3cbc35e 100644 --- a/src/test/container-features/featuresCLICommands.test.ts +++ b/src/test/container-features/featuresCLICommands.test.ts @@ -5,7 +5,9 @@ import { isLocalFile, readLocalFile } from '../../spec-utils/pfs'; import { ExecResult, shellExec } from '../testUtils'; import { getSemanticTags } from '../../spec-node/collectionCommonUtils/publishCommandImpl'; import { getRef, getPublishedTags, getVersionsStrictSorted } from '../../spec-configuration/containerCollectionsOCI'; -import { generateFeaturesDocumentation } from '../../spec-node/collectionCommonUtils/generateDocsCommandImpl'; +import { generateDocumentation } from '../../spec-node/collectionCommonUtils/generateDocsCommandImpl'; +import { getCLIHost } from '../../spec-common/cliHost'; +import { loadNativeModule } from '../../spec-common/commonUtils'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); const pkg = require('../../../package.json'); @@ -679,17 +681,31 @@ describe('test functions getVersionsStrictSorted and getPublishedTags', async () }); -describe('tests generateFeaturesDocumentation()', async function () { +describe('tests generateFeaturesDocumentation(isSingle = false)', async function () { this.timeout('120s'); + const cwd = process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule, true); const projectFolder = `${__dirname}/example-v2-features-sets/simple/src`; after('clean', async () => { - await shellExec(`rm ${projectFolder}/**/README.md`); + await shellExec(`rm ${projectFolder}/**/README.md || true`); }); - it('tests generate-docs', async function () { - await generateFeaturesDocumentation(projectFolder, 'ghcr.io', 'devcontainers/cli', 'devcontainers', 'cli', output); + it('should generate docs for the features collection', async function () { + await shellExec(`rm ${projectFolder}/**/README.md || true`); + + await generateDocumentation({ + cliHost: cliHost, + targetFolder: projectFolder, + registry: 'ghcr.io', + namespace: 'devcontainers/cli', + gitHubOwner: 'devcontainers', + gitHubRepo: 'cli', + output: output, + isSingle: false, + disposables: [], + }, 'feature'); const colorDocsExists = await isLocalFile(`${projectFolder}/color/README.md`); assert.isTrue(colorDocsExists); @@ -701,3 +717,34 @@ describe('tests generateFeaturesDocumentation()', async function () { assert.isFalse(invalidDocsExists); }); }); + +describe('tests generateFeaturesDocumentation(isSingle = true)', async function () { + this.timeout('120s'); + + const cwd = process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule, true); + const projectFolder = `${__dirname}/example-v2-features-sets/simple/src/color`; + + after('clean', async () => { + await shellExec(`rm ${projectFolder}/README.md || true`); + }); + + it('should generate docs for the single feature', async function () { + await shellExec(`rm ${projectFolder}/README.md || true`); + + await generateDocumentation({ + cliHost: cliHost, + targetFolder: projectFolder, + registry: 'ghcr.io', + namespace: 'devcontainers/cli', + gitHubOwner: 'devcontainers', + gitHubRepo: 'cli', + output: output, + isSingle: true, + disposables: [], + }, 'feature'); + + const colorDocsExists = await isLocalFile(`${projectFolder}/README.md`); + assert.isTrue(colorDocsExists); + }); +}); diff --git a/src/test/container-templates/templatesCLICommands.test.ts b/src/test/container-templates/templatesCLICommands.test.ts index c4b563f1..132398f0 100644 --- a/src/test/container-templates/templatesCLICommands.test.ts +++ b/src/test/container-templates/templatesCLICommands.test.ts @@ -8,7 +8,7 @@ import { Template } from '../../spec-configuration/containerTemplatesConfigurati import { PackageCommandInput } from '../../spec-node/collectionCommonUtils/package'; import { getCLIHost } from '../../spec-common/cliHost'; import { loadNativeModule } from '../../spec-common/commonUtils'; -import { generateTemplatesDocumentation } from '../../spec-node/collectionCommonUtils/generateDocsCommandImpl'; +import { generateDocumentation } from '../../spec-node/collectionCommonUtils/generateDocsCommandImpl'; export const output = makeLog(createPlainLog(text => process.stderr.write(text), () => LogLevel.Trace)); @@ -172,17 +172,29 @@ describe('tests packageTemplates()', async function () { }); }); -describe('tests generateTemplateDocumentation()', async function () { +describe('tests generateTemplateDocumentation(isSingle = false)', async function () { this.timeout('120s'); + const cwd = process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule, true); const projectFolder = `${__dirname}/example-templates-sets/simple/src`; after('clean', async () => { - await shellExec(`rm ${projectFolder}/**/README.md`); + await shellExec(`rm ${projectFolder}/**/README.md || true`); }); - it('tests generate-docs', async function () { - await generateTemplatesDocumentation(projectFolder, 'devcontainers', 'cli', output); + it('should generate docs for the templates collection', async function () { + await shellExec(`rm ${projectFolder}/**/README.md || true`); + + await generateDocumentation({ + cliHost: cliHost, + targetFolder: projectFolder, + gitHubOwner: 'devcontainers', + gitHubRepo: 'cli', + output: output, + isSingle: false, + disposables: [], + }, 'template'); const alpineDocsExists = await isLocalFile(`${projectFolder}/alpine/README.md`); assert.isTrue(alpineDocsExists); @@ -197,3 +209,32 @@ describe('tests generateTemplateDocumentation()', async function () { assert.isFalse(invalidDocsExists); }); }); + +describe('tests generateTemplateDocumentation(isSingle = true)', async function () { + this.timeout('120s'); + + const cwd = process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule, true); + const projectFolder = `${__dirname}/example-templates-sets/simple/src/alpine`; + + after('clean', async () => { + await shellExec(`rm ${projectFolder}/README.md || true`); + }); + + it('should generate docs for the template', async function () { + await shellExec(`rm ${projectFolder}/README.md || true`); + + await generateDocumentation({ + cliHost: cliHost, + targetFolder: projectFolder, + gitHubOwner: 'devcontainers', + gitHubRepo: 'cli', + output: output, + isSingle: true, + disposables: [], + }, 'template'); + + const alpineDocsExists = await isLocalFile(`${projectFolder}/README.md`); + assert.isTrue(alpineDocsExists); + }); +});