diff --git a/.gitignore b/.gitignore index 7b270dea1..bc02994ef 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ output *.testMarker src/test/container-features/configs/temp_lifecycle-hooks-alternative-order test-secrets-temp.json +src/test/container-*/**/src/**/README.md diff --git a/package.json b/package.json index e3e3aab65..c457362ce 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "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/generateDocsCommandImpl.ts b/src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts new file mode 100644 index 000000000..0a866f818 --- /dev/null +++ b/src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts @@ -0,0 +1,198 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as jsonc from 'jsonc-parser'; +import { Log, LogLevel } from '../../spec-utils/log'; + +const FEATURES_README_TEMPLATE = ` +# #{Name} + +#{Description} + +## Example Usage + +\`\`\`json +"features": { + "#{Registry}/#{Namespace}/#{Id}:#{Version}": {} +} +\`\`\` + +#{OptionsTable} +#{Customizations} +#{Notes} + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._ +`; + +const TEMPLATE_README_TEMPLATE = ` +# #{Name} + +#{Description} + +#{OptionsTable} + +#{Notes} + +--- + +_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); +} + +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); + + 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); + + const jsonPath = path.join(basePath, f, metadataFile); + + if (!fs.existsSync(jsonPath)) { + output.write(`(!) Warning: ${metadataFile} not found at path '${jsonPath}'. Skipping...`, LogLevel.Warning); + return; + } + + let parsedJson: any | undefined = undefined; + try { + parsedJson = jsonc.parse(fs.readFileSync(jsonPath, 'utf8')); + } catch (err) { + output.write(`Failed to parse ${jsonPath}: ${err}`, LogLevel.Error); + return; + } + + if (!parsedJson || !parsedJson?.id) { + output.write(`${metadataFile} for '${f}' does not contain an 'id'`, LogLevel.Error); + return; + } + + // Add version + let version = 'latest'; + const parsedVersion: string = parsedJson?.version; + if (parsedVersion) { + // example - 1.0.0 + const splitVersion = parsedVersion.split('.'); + version = splitVersion[0]; + } + + const generateOptionsMarkdown = () => { + const options = parsedJson?.options; + if (!options) { + return ''; + } + + const keys = Object.keys(options); + const contents = keys + .map(k => { + const val = options[k]; + + const desc = val.description || '-'; + const type = val.type || '-'; + const def = val.default !== '' ? val.default : '-'; + + return `| ${k} | ${desc} | ${type} | ${def} |`; + }) + .join('\n'); + + return '## Options\n\n' + '| Options Id | Description | Type | Default Value |\n' + '|-----|-----|-----|-----|\n' + contents; + }; + + const generateNotesMarkdown = () => { + const notesPath = path.join(basePath, f, 'NOTES.md'); + return fs.existsSync(notesPath) ? fs.readFileSync(path.join(notesPath), 'utf8') : ''; + }; + + let urlToConfig = `${metadataFile}`; + const basePathTrimmed = basePath.startsWith('./') ? basePath.substring(2) : basePath; + if (gitHubOwner !== '' && gitHubRepo !== '') { + urlToConfig = `https://github.com/${gitHubOwner}/${gitHubRepo}/blob/main/${basePathTrimmed}/${f}/${metadataFile}`; + } + + let header; + const isDeprecated = parsedJson?.deprecated; + const hasLegacyIds = parsedJson?.legacyIds && parsedJson?.legacyIds.length > 0; + + if (isDeprecated || hasLegacyIds) { + header = '### **IMPORTANT NOTE**\n'; + + if (isDeprecated) { + header += `- **This Feature is deprecated, and will no longer receive any further updates/support.**\n`; + } + + if (hasLegacyIds) { + const formattedLegacyIds = parsedJson.legacyIds.map((legacyId: string) => `'${legacyId}'`); + header += `- **Ids used to publish this Feature in the past - ${formattedLegacyIds.join(', ')}**\n`; + } + } + + let extensions = ''; + if (parsedJson?.customizations?.vscode?.extensions) { + const extensionsList = parsedJson.customizations.vscode.extensions; + if (extensionsList && extensionsList.length > 0) { + extensions = + '\n## Customizations\n\n### VS Code Extensions\n\n' + extensionsList.map((ext: string) => `- \`${ext}\``).join('\n') + '\n'; + } + } + + let newReadme = readmeTemplate + // Templates & Features + .replace('#{Id}', parsedJson.id) + .replace('#{Name}', parsedJson.name ? `${parsedJson.name} (${parsedJson.id})` : `${parsedJson.id}`) + .replace('#{Description}', parsedJson.description ?? '') + .replace('#{OptionsTable}', generateOptionsMarkdown()) + .replace('#{Notes}', generateNotesMarkdown()) + .replace('#{RepoUrl}', urlToConfig) + // Features Only + .replace('#{Registry}', ociRegistry) + .replace('#{Namespace}', namespace) + .replace('#{Version}', version) + .replace('#{Customizations}', extensions); + + if (header) { + newReadme = header + newReadme; + } + + // Remove previous readme + if (fs.existsSync(readmePath)) { + fs.unlinkSync(readmePath); + } + + // Write new readme + fs.writeFileSync(readmePath, newReadme); + } + }) + ); +} diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 1c5bb7dff..7af090500 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -41,6 +41,8 @@ import { featuresResolveDependenciesHandler, featuresResolveDependenciesOptions import { getFeatureIdWithoutVersion } from '../spec-configuration/containerFeaturesOCI'; import { featuresUpgradeHandler, featuresUpgradeOptions } from './upgradeCommand'; import { readFeaturesConfig } from './featureUtils'; +import { featuresGenerateDocsHandler, featuresGenerateDocsOptions } from './featuresCLI/generateDocs'; +import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './templatesCLI/generateDocs'; import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; @@ -78,10 +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('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(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 new file mode 100644 index 000000000..76a07707d --- /dev/null +++ b/src/spec-node/featuresCLI/generateDocs.ts @@ -0,0 +1,57 @@ +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'; + +// -- '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; + }); +} + +export type FeaturesGenerateDocsArgs = UnpackArgv>; + +export function featuresGenerateDocsHandler(args: FeaturesGenerateDocsArgs) { + (async () => await featuresGenerateDocs(args))().catch(console.error); +} + +export async function featuresGenerateDocs({ + 'project-folder': collectionFolder, + 'registry': registry, + 'namespace': namespace, + 'github-owner': gitHubOwner, + 'github-repo': gitHubRepo, + 'log-level': inputLogLevel, +}: FeaturesGenerateDocsArgs) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + + const pkg = getPackageConfig(); + + const output = createLog({ + logLevel: mapLogLevel(inputLogLevel), + logFormat: 'text', + log: (str) => process.stderr.write(str), + terminalDimensions: undefined, + }, pkg, new Date(), disposables); + + await generateFeaturesDocumentation(collectionFolder, registry, namespace, gitHubOwner, gitHubRepo, output); + + // Cleanup + await dispose(); + process.exit(); +} diff --git a/src/spec-node/templatesCLI/generateDocs.ts b/src/spec-node/templatesCLI/generateDocs.ts new file mode 100644 index 000000000..06ad39749 --- /dev/null +++ b/src/spec-node/templatesCLI/generateDocs.ts @@ -0,0 +1,53 @@ +import { Argv } from 'yargs'; +import { UnpackArgv } from '../devContainersSpecCLI'; +import { generateTemplatesDocumentation } from '../collectionCommonUtils/generateDocsCommandImpl'; +import { createLog } from '../devContainers'; +import { mapLogLevel } from '../../spec-utils/log'; +import { getPackageConfig } from '../../spec-utils/product'; + +// -- '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; + }); +} + +export type TemplatesGenerateDocsArgs = UnpackArgv>; + +export function templatesGenerateDocsHandler(args: TemplatesGenerateDocsArgs) { + (async () => await templatesGenerateDocs(args))().catch(console.error); +} + +export async function templatesGenerateDocs({ + 'project-folder': collectionFolder, + 'github-owner': gitHubOwner, + 'github-repo': gitHubRepo, + 'log-level': inputLogLevel, +}: TemplatesGenerateDocsArgs) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + + const pkg = getPackageConfig(); + + const output = createLog({ + logLevel: mapLogLevel(inputLogLevel), + logFormat: 'text', + log: (str) => process.stderr.write(str), + terminalDimensions: undefined, + }, pkg, new Date(), disposables); + + await generateTemplatesDocumentation(collectionFolder, gitHubOwner, gitHubRepo, output); + + // Cleanup + await dispose(); + process.exit(); +} diff --git a/src/test/container-features/featuresCLICommands.test.ts b/src/test/container-features/featuresCLICommands.test.ts index a9dc6a3a1..1d20d5b1a 100644 --- a/src/test/container-features/featuresCLICommands.test.ts +++ b/src/test/container-features/featuresCLICommands.test.ts @@ -5,6 +5,7 @@ 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'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); const pkg = require('../../../package.json'); @@ -482,6 +483,7 @@ describe('CLI features subcommands', async function () { }); describe('test function getSermanticVersions', () => { + it('should generate correct semantic versions for first publishing', async () => { let version = '1.0.0'; let publishedTags: string[] = []; @@ -675,4 +677,27 @@ describe('test functions getVersionsStrictSorted and getPublishedTags', async () }); -}); \ No newline at end of file +}); + +describe('tests generateFeaturesDocumentation()', async function () { + this.timeout('120s'); + + const projectFolder = `${__dirname}/example-v2-features-sets/simple/src`; + + after('clean', async () => { + await shellExec(`rm ${projectFolder}/**/README.md`); + }); + + it('tests generate-docs', async function () { + await generateFeaturesDocumentation(projectFolder, 'ghcr.io', 'devcontainers/cli', 'devcontainers', 'cli', output); + + const colorDocsExists = await isLocalFile(`${projectFolder}/color/README.md`); + assert.isTrue(colorDocsExists); + + const helloDocsExists = await isLocalFile(`${projectFolder}/hello/README.md`); + assert.isTrue(helloDocsExists); + + const invalidDocsExists = await isLocalFile(`${projectFolder}/not-a-feature/README.md`); + assert.isFalse(invalidDocsExists); + }); +}); diff --git a/src/test/container-templates/templatesCLICommands.test.ts b/src/test/container-templates/templatesCLICommands.test.ts index 6a515eb09..c4b563f19 100644 --- a/src/test/container-templates/templatesCLICommands.test.ts +++ b/src/test/container-templates/templatesCLICommands.test.ts @@ -8,6 +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'; export const output = makeLog(createPlainLog(text => process.stderr.write(text), () => LogLevel.Trace)); @@ -170,3 +171,29 @@ describe('tests packageTemplates()', async function () { assert.equal(alpineProperties?.fileCount, 2); }); }); + +describe('tests generateTemplateDocumentation()', async function () { + this.timeout('120s'); + + const projectFolder = `${__dirname}/example-templates-sets/simple/src`; + + after('clean', async () => { + await shellExec(`rm ${projectFolder}/**/README.md`); + }); + + it('tests generate-docs', async function () { + await generateTemplatesDocumentation(projectFolder, 'devcontainers', 'cli', output); + + const alpineDocsExists = await isLocalFile(`${projectFolder}/alpine/README.md`); + assert.isTrue(alpineDocsExists); + + const cppDocsExists = await isLocalFile(`${projectFolder}/cpp/README.md`); + assert.isTrue(cppDocsExists); + + const nodeMongoDocsExists = await isLocalFile(`${projectFolder}/node-mongo/README.md`); + assert.isTrue(nodeMongoDocsExists); + + const invalidDocsExists = await isLocalFile(`${projectFolder}/not-a-template/README.md`); + assert.isFalse(invalidDocsExists); + }); +});