diff --git a/.github/workflows/cli-docs.yml b/.github/workflows/cli-docs.yml new file mode 100644 index 00000000..85796e55 --- /dev/null +++ b/.github/workflows/cli-docs.yml @@ -0,0 +1,121 @@ +name: Generate CLI Docs and PR to genlayer-docs + +on: + workflow_dispatch: + release: + types: [published] + +jobs: + generate-and-sync: + # Skip for pre-releases (tags containing '-') unless manually dispatched + if: github.event_name != 'release' || !contains(github.event.release.tag_name, '-') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout CLI repo + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Determine version for docs + id: version + run: | + if [ "${{ github.event_name }}" = "release" ]; then + echo "value=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + else + # Prefer package.json version when not a release event + echo "value=$(node -p \"require('./package.json').version\")" >> $GITHUB_OUTPUT + fi + + - name: Generate CLI docs (MDX) + env: + DOCS_CLEAN: 'true' + DOCS_VERSION: ${{ steps.version.outputs.value }} + run: node scripts/generate-cli-docs.mjs | cat + + - name: Set up Git (for committing to CLI repo) + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Commit and push docs back to CLI repo (non-beta releases) + if: github.event_name == 'release' && !contains(github.event.release.tag_name, '-') + run: | + set -euo pipefail + if [ -n "$(git status --porcelain docs/api-references || true)" ]; then + git add docs/api-references + VERSION=${{ steps.version.outputs.value }} + git commit -m "docs(cli): update API reference for ${VERSION}" + git push + else + echo "No docs changes to commit" + fi + + - name: Checkout docs repo + uses: actions/checkout@v4 + with: + repository: genlayerlabs/genlayer-docs + token: ${{ secrets.DOCS_REPO_TOKEN || secrets.GITHUB_TOKEN }} + path: docs-repo + fetch-depth: 0 + + - name: Prepare branch + working-directory: docs-repo + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + BRANCH="docs/cli/${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }}" + git switch -c "$BRANCH" || git switch "$BRANCH" + echo "BRANCH=$BRANCH" >> $GITHUB_ENV + + - name: Sync CLI docs into docs repo + run: | + set -euo pipefail + mkdir -p docs-repo/pages/api-references/genlayer-cli + rsync -a --delete "${{ github.workspace }}/docs/api-references/" docs-repo/pages/api-references/genlayer-cli/ + echo "Synced files:" && ls -la docs-repo/pages/api-references/genlayer-cli | cat + + - name: Commit changes + working-directory: docs-repo + run: | + set -euo pipefail + if [ -n "$(git status --porcelain)" ]; then + git add pages/api-references/genlayer-cli + git commit -m "docs(cli): sync API reference ${VERSION:-${{ env.VERSION }}}" + git push --set-upstream origin "$BRANCH" + echo "HAS_CHANGES=true" >> $GITHUB_ENV + else + echo "No changes to commit" + echo "HAS_CHANGES=false" >> $GITHUB_ENV + fi + + - name: Create PR in docs repo + if: env.HAS_CHANGES == 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.DOCS_REPO_TOKEN || secrets.GITHUB_TOKEN }} + path: docs-repo + commit-message: "docs(cli): sync API reference ${{ steps.version.outputs.value }}" + branch: ${{ env.BRANCH }} + title: "docs(cli): sync CLI API reference ${{ steps.version.outputs.value }}" + body: | + This PR updates the GenlayerCLI API Reference generated automatically from `${{ github.repository }}`. + + - Version: `${{ steps.version.outputs.value }}` + - Source commit: `${{ github.sha }}` + - Trigger: `${{ github.event_name }}` + labels: documentation, cli + + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5a4b70f0..e0f08222 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,8 @@ on: push: branches: - main + paths-ignore: + - 'docs/**' jobs: release: diff --git a/package.json b/package.json index 7f926c43..f06c1b3f 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "build": "cross-env NODE_ENV=production node esbuild.config.js", "release": "release-it --ci", "release-beta": "release-it --ci --preRelease=beta", - "postinstall": "node ./scripts/postinstall.js" + "postinstall": "node ./scripts/postinstall.js", + "docs:cli": "node scripts/generate-cli-docs.mjs" }, "repository": { "type": "git", diff --git a/scripts/generate-cli-docs.mjs b/scripts/generate-cli-docs.mjs new file mode 100644 index 00000000..0e6987fd --- /dev/null +++ b/scripts/generate-cli-docs.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +import { promises as fs } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { spawnSync } from 'node:child_process'; + +function escapeMdx(text) { + if (!text) return ''; + return String(text).replace(//g, '>'); +} + +function formatArg(arg) { + const base = arg.variadic ? `${arg.name}...` : arg.name; + return arg.required ? `<${base}>` : `[${base}]`; +} +function toSlug(text) { + return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); +} + +function makeCommandFilepath(commandPath) { + const parts = commandPath.split(' '); + if (parts.length === 1) return { relDir: '', filename: `${toSlug(parts[0])}.mdx` }; + return { relDir: parts.slice(0, -1).map(toSlug).join('/'), filename: `${toSlug(parts[parts.length - 1])}.mdx` }; +} + +function renderOptionsTable(options) { + if (!options.length) return 'No options.\n'; + const rows = options + .map((o) => `| ${escapeMdx(o.short ?? '')} | ${escapeMdx(o.long ?? '')} | ${escapeMdx(o.description ?? '')} | ${o.required ? 'Yes' : 'No'} | ${o.defaultValue === undefined ? '' : `\`${escapeMdx(String(o.defaultValue))}\``} |`) + .join('\n'); + return ['| Short | Long | Description | Required | Default |', '| --- | --- | --- | :---: | --- |', rows].join('\n'); +} + +function renderArgsList(args) { + if (!args.length) return 'No positional arguments.\n'; + return args.map((a) => `- \`${formatArg(a)}\``).join('\n'); +} + +function generatePageForCommand(help, programName, commandPath) { + const { description, usage, args, options, subcommands } = help; + const title = commandPath || programName; + const header = `---\ntitle: ${escapeMdx(title)}\n---`; + const parts = [header]; + if (description) parts.push('', description); + if (usage) parts.push('', '### Usage', '', `\`${usage}\``); + if (args && args.length) parts.push('', '### Arguments', '', renderArgsList(args)); + parts.push('', '### Options', '', renderOptionsTable(options || [])); + if (subcommands && subcommands.length) { + parts.push('', '### Subcommands', ''); + for (const sc of subcommands) { + parts.push(`- \`${programName} ${sc.name}\` — ${escapeMdx(sc.description ?? '')}`); + } + } + const body = parts.join('\n').trim() + '\n'; + const { relDir, filename } = makeCommandFilepath(commandPath || programName); + return { relDir, filename, content: body }; +} + +function generateIndexPage(rootHelp, programName, pkgVersion, pkgDescription) { + const title = `${programName} Commands`; + const header = `---\ntitle: ${escapeMdx(title)}\n---`; + const intro = rootHelp.description || pkgDescription || ''; + const lines = [header, '', intro, `Version: \`${pkgVersion}\``, '', '### Command List', '']; + for (const sc of rootHelp.subcommands || []) { + lines.push(`- \`${programName} ${sc.name}\` — ${escapeMdx(sc.description ?? '')}`); + } + lines.push('', '---', '', 'This reference is auto-generated. Do not edit manually.'); + return { relDir: '', filename: 'index.mdx', content: lines.join('\n') + '\n' }; +} + +function runHelp(args) { + const res = spawnSync(process.execPath, ['dist/index.js', ...args, '--help'], { + encoding: 'utf8', + timeout: 30000, + }); + if ((res.status !== 0 || res.error) && res.stdout.trim() === '') { + const reason = res.error?.message || res.stderr || `exitCode=${res.status}`; + throw new Error(`Failed to run help for: ${args.join(' ')} (${reason})`); + } + return res.stdout; +} + +function parseHelp(text, programName, commandPath) { + const lines = text.split(/\r?\n/); + let description = ''; + let usage = ''; + const options = []; + const subcommands = []; + const args = []; + + // Accumulate description between the first blank after Usage and before Options/Commands + let inOptions = false; + let inCommands = false; + + // Usage + const usageIdx = lines.findIndex((l) => l.startsWith('Usage:')); + if (usageIdx !== -1) { + const usageLine = lines[usageIdx]; + // Replace program binary name (index) by programName + const afterColon = usageLine.replace(/^Usage:\s*/, ''); + const replaced = afterColon.replace(/^\S+/, programName); + usage = `$ ${replaced}`; + } + + // Description + for (let i = usageIdx + 1; i < lines.length; i += 1) { + const l = lines[i]; + if (l.trim() === '') continue; + if (l.startsWith('Options:') || l.startsWith('Commands:')) break; + description += (description ? '\n' : '') + l.trim(); + } + + // Parse sections + for (let i = 0; i < lines.length; i += 1) { + const l = lines[i]; + if (l.startsWith('Options:')) { inOptions = true; inCommands = false; continue; } + if (l.startsWith('Commands:')) { inCommands = true; inOptions = false; continue; } + if (/^\s*$/.test(l)) continue; + + if (inOptions) { + // e.g., " -V, --version output the version number" + const m = l.match(/^\s*(-\w)?,?\s*(--[\w-]+)?\s{2,}(.+)$/); + if (m) { + const short = m[1] || ''; + const long = m[2] || ''; + const desc = m[3] || ''; + options.push({ short, long, description: desc, required: false }); + } + } else if (inCommands) { + // e.g., " deploy [options] Deploy intelligent contracts" + const m = l.match(/^\s*([\w-]+)(?:\s<[^>]+>|\s\[[^\]]+\])*\s{2,}(.+)$/); + if (m) { + const cmdToken = m[1]; + const desc = m[2] || ''; + const name = cmdToken.trim(); + if (name !== 'help') { + subcommands.push({ name, description: desc }); + } + } + } + } + + // Derive args from usage (after commandPath) + if (usage) { + const usageCmd = usage.replace(/^`?\$\s+/, '').replace(/`?$/, ''); + const tokens = usageCmd.split(/\s+/); + // find starting index of commandPath tokens + const cmdTokens = (commandPath ? `${programName} ${commandPath}` : programName).split(' '); + const start = tokens.findIndex((t, idx) => tokens.slice(idx, idx + cmdTokens.length).join(' ') === cmdTokens.join(' ')); + const after = start >= 0 ? tokens.slice(start + cmdTokens.length) : []; + for (const t of after) { + if (t === '[options]') continue; + const m = t.match(/^<(.*)>$/) || t.match(/^\[(.*)\]$/); + if (m) { + const variadic = m[1].endsWith('...'); + const name = variadic ? m[1].slice(0, -3) : m[1]; + const required = t.startsWith('<'); + args.push({ name, variadic, required }); + } + } + } + + return { description, usage, options, subcommands, args }; +} + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +async function writePages(root, pages) { + for (const page of pages) { + const dir = path.join(root, page.relDir); + await ensureDir(dir); + const fullpath = path.join(dir, page.filename); + await fs.writeFile(fullpath, page.content, 'utf8'); + } +} + +async function rmrf(dir) { + try { + await fs.rm(dir, { recursive: true, force: true }); + } catch {} +} + +async function readPackageInfo() { + const here = path.dirname(fileURLToPath(import.meta.url)); + const pkgPath = path.join(here, '..', 'package.json'); + const raw = await fs.readFile(pkgPath, 'utf8'); + const json = JSON.parse(raw); + return { version: json.version, description: json.description }; +} + +async function main() { + const { version: pkgVersion, description: pkgDescription } = await readPackageInfo(); + const programName = 'genlayer'; + + // Ensure CLI is built before attempting to read help output + const here = path.dirname(fileURLToPath(import.meta.url)); + const cliPath = path.join(here, '..', 'dist', 'index.js'); + try { + await fs.access(cliPath); + } catch { + throw new Error('CLI not built. Please run "npm run build" first.'); + } + + const rootHelpText = runHelp([]); + const rootHelp = parseHelp(rootHelpText, programName, ''); + + const outputDirFromEnv = process.env.DOCS_OUTPUT_DIR; + const clean = (process.env.DOCS_CLEAN || '').toLowerCase() === 'true'; + + const outputs = []; + // filter out auto 'help' just in case + rootHelp.subcommands = (rootHelp.subcommands || []).filter((c) => c.name !== 'help'); + outputs.push(generateIndexPage(rootHelp, programName, pkgVersion, pkgDescription)); + + // BFS through subcommands by invoking help for each + const queue = [...(rootHelp.subcommands || [])].map((c) => ({ path: c.name })); + while (queue.length) { + const { path: cmdPath } = queue.shift(); + const helpText = runHelp(cmdPath.split(' ')); + const help = parseHelp(helpText, programName, cmdPath); + outputs.push(generatePageForCommand(help, programName, cmdPath)); + for (const sc of help.subcommands || []) { + queue.push({ path: `${cmdPath} ${sc.name}` }); + } + } + + const defaultOut = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'api-references'); + const rootOut = outputDirFromEnv ? outputDirFromEnv : defaultOut; + if (clean) await rmrf(rootOut); + await writePages(rootOut, outputs); + + const meta = {}; + for (const c of (rootHelp.subcommands || []).map((c) => toSlug(c.name))) meta[c] = c; + await fs.writeFile(path.join(rootOut, '_meta.json'), JSON.stringify(meta, null, 2), 'utf8'); + + console.log(`Generated ${outputs.length} pages at ${rootOut}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); + +