From 0eb217398eceafc4d9b503ba1d0d982b30eb66f1 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Wed, 25 Feb 2026 22:22:25 -0500 Subject: [PATCH] Added the ability to deploy to github pages. --- package-lock.json | 5 + src/__tests__/gh-pages.test.ts | 241 +++++++++++++++++++++++++++++++++ src/cli.ts | 27 ++++ src/gh-pages.ts | 94 +++++++++++++ 4 files changed, 367 insertions(+) create mode 100644 src/__tests__/gh-pages.test.ts create mode 100644 src/gh-pages.ts diff --git a/package-lock.json b/package-lock.json index 6837e81..83adbfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1667,6 +1668,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1987,6 +1989,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3076,6 +3079,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5767,6 +5771,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/__tests__/gh-pages.test.ts b/src/__tests__/gh-pages.test.ts new file mode 100644 index 0000000..b955d5a --- /dev/null +++ b/src/__tests__/gh-pages.test.ts @@ -0,0 +1,241 @@ +jest.mock('fs-extra', () => ({ + __esModule: true, + default: { + pathExists: jest.fn(), + readJson: jest.fn(), + }, +})); + +jest.mock('execa', () => ({ + __esModule: true, + execa: jest.fn(), +})); + +import path from 'path'; +import fs from 'fs-extra'; +import { execa } from 'execa'; +import { runDeployToGitHubPages } from '../gh-pages.js'; + +const mockPathExists = fs.pathExists as jest.MockedFunction; +const mockReadJson = fs.readJson as jest.MockedFunction; +const mockExeca = execa as jest.MockedFunction; + +const cwd = '/tmp/my-app'; + +describe('runDeployToGitHubPages', () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + consoleLogSpy.mockRestore(); + }); + + it('throws when package.json does not exist', async () => { + mockPathExists.mockImplementation((p: string) => + Promise.resolve(path.join(cwd, 'package.json') !== p) + ); + + await expect(runDeployToGitHubPages(cwd)).rejects.toThrow( + 'No package.json found in this directory' + ); + expect(mockPathExists).toHaveBeenCalledWith(path.join(cwd, 'package.json')); + expect(mockExeca).not.toHaveBeenCalled(); + }); + + it('throws when .git directory does not exist', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(false); // .git + + await expect(runDeployToGitHubPages(cwd)).rejects.toThrow( + 'This directory is not a git repository' + ); + expect(mockPathExists).toHaveBeenCalledWith(path.join(cwd, '.git')); + expect(mockExeca).not.toHaveBeenCalled(); + }); + + it('throws when no build script and skipBuild is false', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(true); // .git + mockReadJson.mockResolvedValueOnce({ scripts: {} }); + + await expect(runDeployToGitHubPages(cwd, { skipBuild: false })).rejects.toThrow( + 'No "build" script found in package.json' + ); + expect(mockReadJson).toHaveBeenCalledWith(path.join(cwd, 'package.json')); + expect(mockExeca).not.toHaveBeenCalled(); + }); + + it('runs build then deploys when skipBuild is false (npm)', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(true) // .git + .mockResolvedValueOnce(false) // yarn.lock + .mockResolvedValueOnce(false) // pnpm-lock.yaml + .mockResolvedValueOnce(true); // dist + mockReadJson.mockResolvedValueOnce({ + scripts: { build: 'webpack --config webpack.prod.js' }, + }); + mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited>); + + await runDeployToGitHubPages(cwd, { skipBuild: false }); + + expect(mockExeca).toHaveBeenCalledTimes(2); + expect(mockExeca).toHaveBeenNthCalledWith(1, 'npm', ['run', 'build'], { + cwd, + stdio: 'inherit', + }); + expect(mockExeca).toHaveBeenNthCalledWith(2, 'npx', ['gh-pages', '-d', 'dist', '-b', 'gh-pages'], { + cwd, + stdio: 'inherit', + }); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Running build') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Deployed to GitHub Pages') + ); + }); + + it('uses yarn when yarn.lock exists', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(true) // .git + .mockResolvedValueOnce(true) // yarn.lock + .mockResolvedValueOnce(true); // dist + mockReadJson.mockResolvedValueOnce({ + scripts: { build: 'webpack' }, + }); + mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited>); + + await runDeployToGitHubPages(cwd, { skipBuild: false }); + + expect(mockExeca).toHaveBeenNthCalledWith(1, 'yarn', ['build'], { + cwd, + stdio: 'inherit', + }); + }); + + it('uses pnpm when pnpm-lock.yaml exists (and no yarn.lock)', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(true) // .git + .mockResolvedValueOnce(false) // yarn.lock + .mockResolvedValueOnce(true) // pnpm-lock.yaml + .mockResolvedValueOnce(true); // dist + mockReadJson.mockResolvedValueOnce({ + scripts: { build: 'vite build' }, + }); + mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited>); + + await runDeployToGitHubPages(cwd, { skipBuild: false }); + + expect(mockExeca).toHaveBeenNthCalledWith(1, 'pnpm', ['build'], { + cwd, + stdio: 'inherit', + }); + }); + + it('skips build and deploys when skipBuild is true', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(true) // .git + .mockResolvedValueOnce(true); // dist + mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited>); + + await runDeployToGitHubPages(cwd, { skipBuild: true }); + + expect(mockReadJson).not.toHaveBeenCalled(); + expect(mockExeca).toHaveBeenCalledTimes(1); + expect(mockExeca).toHaveBeenCalledWith('npx', ['gh-pages', '-d', 'dist', '-b', 'gh-pages'], { + cwd, + stdio: 'inherit', + }); + }); + + it('throws when dist directory does not exist (after build)', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(true) // .git + .mockResolvedValueOnce(false) // yarn.lock + .mockResolvedValueOnce(false) // pnpm-lock.yaml + .mockResolvedValueOnce(false); // dist (missing) + mockReadJson.mockResolvedValueOnce({ + scripts: { build: 'npm run build' }, + }); + mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited>); + + await expect(runDeployToGitHubPages(cwd, { skipBuild: false })).rejects.toThrow( + 'Build output directory "dist" does not exist' + ); + expect(mockExeca).toHaveBeenCalledTimes(1); // only build + }); + + it('throws when dist directory does not exist with skipBuild true', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(true) // .git + .mockResolvedValueOnce(false); // dist + + await expect(runDeployToGitHubPages(cwd, { skipBuild: true })).rejects.toThrow( + 'Build output directory "dist" does not exist' + ); + expect(mockExeca).not.toHaveBeenCalled(); + }); + + it('uses custom distDir and branch options', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(true) // .git + .mockResolvedValueOnce(true); // build dir + mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited>); + + await runDeployToGitHubPages(cwd, { + skipBuild: true, + distDir: 'build', + branch: 'pages', + }); + + expect(mockExeca).toHaveBeenCalledWith('npx', ['gh-pages', '-d', 'build', '-b', 'pages'], { + cwd, + stdio: 'inherit', + }); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Deploying "build" to GitHub Pages (branch: pages)') + ); + }); + + it('propagates build failure', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(true) // .git + .mockResolvedValueOnce(false) // yarn.lock + .mockResolvedValueOnce(false); // pnpm-lock.yaml + mockReadJson.mockResolvedValueOnce({ + scripts: { build: 'webpack' }, + }); + mockExeca.mockRejectedValueOnce(new Error('Build failed')); + + await expect(runDeployToGitHubPages(cwd, { skipBuild: false })).rejects.toThrow( + 'Build failed' + ); + expect(mockExeca).toHaveBeenCalledTimes(1); + }); + + it('propagates gh-pages deploy failure', async () => { + mockPathExists + .mockResolvedValueOnce(true) // package.json + .mockResolvedValueOnce(true) // .git + .mockResolvedValueOnce(true); // dist + mockExeca.mockRejectedValueOnce(new Error('Deploy failed')); + + await expect(runDeployToGitHubPages(cwd, { skipBuild: true })).rejects.toThrow( + 'Deploy failed' + ); + expect(mockExeca).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index b8a7116..ef501cf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { mergeTemplates } from './template-loader.js'; import { offerAndCreateGitHubRepo } from './github.js'; import { runSave } from './save.js'; import { runLoad } from './load.js'; +import { runDeployToGitHubPages } from './gh-pages.js'; /** Project data provided by the user */ type ProjectData = { @@ -304,4 +305,30 @@ program } }); +/** Command to deploy the React app to GitHub Pages */ +program + .command('deploy') + .description('Build the app and deploy it to GitHub Pages (uses gh-pages branch)') + .argument('[path]', 'Path to the project (defaults to current directory)') + .option('-d, --dist-dir ', 'Build output directory to deploy', 'dist') + .option('--no-build', 'Skip running the build step (deploy existing output only)') + .option('-b, --branch ', 'Git branch to deploy to', 'gh-pages') + .action(async (projectPath, options) => { + const cwd = projectPath ? path.resolve(projectPath) : process.cwd(); + try { + await runDeployToGitHubPages(cwd, { + distDir: options.distDir, + skipBuild: options.build === false, + branch: options.branch, + }); + } catch (error) { + if (error instanceof Error) { + console.error(`\nāŒ ${error.message}\n`); + } else { + console.error(error); + } + process.exit(1); + } + }); + program.parse(process.argv); \ No newline at end of file diff --git a/src/gh-pages.ts b/src/gh-pages.ts new file mode 100644 index 0000000..a80251f --- /dev/null +++ b/src/gh-pages.ts @@ -0,0 +1,94 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { execa } from 'execa'; + +export type DeployOptions = { + /** Build output directory to deploy (e.g. dist, build) */ + distDir: string; + /** Skip running the build step */ + skipBuild: boolean; + /** Branch to push to (default gh-pages) */ + branch: string; +}; + +const DEFAULT_DIST_DIR = 'dist'; +const DEFAULT_BRANCH = 'gh-pages'; + +/** + * Detect package manager from lock files. + */ +async function getPackageManager(cwd: string): Promise<'yarn' | 'pnpm' | 'npm'> { + if (await fs.pathExists(path.join(cwd, 'yarn.lock'))) return 'yarn'; + if (await fs.pathExists(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'; + return 'npm'; +} + +/** + * Run build script in the project (npm run build / yarn build / pnpm build). + */ +async function runBuild(cwd: string): Promise { + const pkgPath = path.join(cwd, 'package.json'); + const pkg = await fs.readJson(pkgPath); + const scripts = (pkg.scripts as Record) || {}; + if (!scripts['build']) { + throw new Error( + 'No "build" script found in package.json. Add a build script or use --no-build and deploy an existing folder with -d/--dist-dir.' + ); + } + + const pm = await getPackageManager(cwd); + const runCmd = pm === 'npm' ? 'npm' : pm === 'yarn' ? 'yarn' : 'pnpm'; + const args = pm === 'npm' ? ['run', 'build'] : ['build']; + console.log(`šŸ“¦ Running build (${runCmd} ${args.join(' ')})...`); + await execa(runCmd, args, { cwd, stdio: 'inherit' }); + console.log('āœ… Build completed.\n'); +} + +/** + * Deploy the built app to GitHub Pages using gh-pages (npx). + * Builds the project first unless skipBuild is true, then publishes distDir to the gh-pages branch. + */ +export async function runDeployToGitHubPages( + projectPath: string, + options: Partial = {} +): Promise { + const distDir = options.distDir ?? DEFAULT_DIST_DIR; + const skipBuild = options.skipBuild ?? false; + const branch = options.branch ?? DEFAULT_BRANCH; + + const cwd = path.resolve(projectPath); + const pkgPath = path.join(cwd, 'package.json'); + const gitDir = path.join(cwd, '.git'); + + if (!(await fs.pathExists(pkgPath))) { + throw new Error( + 'No package.json found in this directory. Run this command from your project root (or pass the project path).' + ); + } + + if (!(await fs.pathExists(gitDir))) { + throw new Error( + 'This directory is not a git repository. Initialize with "git init" or use "patternfly-cli init", and ensure the repo has a remote (e.g. GitHub) before deploying.' + ); + } + + if (!skipBuild) { + await runBuild(cwd); + } + + const absoluteDist = path.join(cwd, distDir); + if (!(await fs.pathExists(absoluteDist))) { + throw new Error( + `Build output directory "${distDir}" does not exist. Run a build first or specify the correct directory with -d/--dist-dir.` + ); + } + + console.log(`šŸš€ Deploying "${distDir}" to GitHub Pages (branch: ${branch})...`); + await execa('npx', ['gh-pages', '-d', distDir, '-b', branch], { + cwd, + stdio: 'inherit', + }); + console.log('\nāœ… Deployed to GitHub Pages.'); + console.log(' Enable GitHub Pages in your repo: Settings → Pages → Source: branch "' + branch + '".'); + console.log(' If the site is at username.github.io/, set your app\'s base path (e.g. base: \'//\' in Vite).\n'); +}