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
121 changes: 121 additions & 0 deletions .github/workflows/cli-docs.yml
Original file line number Diff line number Diff line change
@@ -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


2 changes: 2 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ on:
push:
branches:
- main
paths-ignore:
- 'docs/**'

jobs:
release:
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
246 changes: 246 additions & 0 deletions scripts/generate-cli-docs.mjs
Original file line number Diff line number Diff line change
@@ -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, '&lt;').replace(/>/g, '&gt;');
}

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);
});