Skip to content

Commit 20b52f3

Browse files
committed
Updated code.
1 parent bb954e0 commit 20b52f3

File tree

2 files changed

+173
-35
lines changed

2 files changed

+173
-35
lines changed

src/__tests__/gh-pages.test.ts

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,27 @@ jest.mock('execa', () => ({
1111
execa: jest.fn(),
1212
}));
1313

14+
jest.mock('gh-pages', () => ({
15+
__esModule: true,
16+
default: {
17+
publish: jest.fn(),
18+
},
19+
}));
20+
21+
jest.mock('../github.js', () => ({
22+
checkGhAuth: jest.fn().mockResolvedValue({ ok: false }),
23+
}));
24+
1425
import path from 'path';
1526
import fs from 'fs-extra';
1627
import { execa } from 'execa';
28+
import ghPages from 'gh-pages';
1729
import { runDeployToGitHubPages } from '../gh-pages.js';
1830

1931
const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists>;
2032
const mockReadJson = fs.readJson as jest.MockedFunction<typeof fs.readJson>;
2133
const mockExeca = execa as jest.MockedFunction<typeof execa>;
34+
const mockGhPagesPublish = ghPages.publish as jest.MockedFunction<typeof ghPages.publish>;
2235

2336
const cwd = '/tmp/my-app';
2437

@@ -57,7 +70,7 @@ describe('runDeployToGitHubPages', () => {
5770

5871
it('throws when git remote origin is not configured', async () => {
5972
setupPathExists({ 'package.json': true });
60-
mockExeca.mockResolvedValue(undefined as unknown as Awaited<ReturnType<typeof execa>>);
73+
mockExeca.mockRejectedValue(new Error('not a git repo'));
6174

6275
await expect(runDeployToGitHubPages(cwd)).rejects.toThrow(
6376
'Please save your changes first, before deploying to GitHub Pages.'
@@ -71,7 +84,11 @@ describe('runDeployToGitHubPages', () => {
7184
it('throws when no build script and skipBuild is false', async () => {
7285
setupPathExists({ 'package.json': true });
7386
mockReadJson.mockResolvedValueOnce({ scripts: {} });
74-
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
87+
mockExeca.mockResolvedValue({
88+
stdout: 'https://github.com/user/repo.git',
89+
stderr: '',
90+
exitCode: 0,
91+
} as Awaited<ReturnType<typeof execa>>);
7592

7693
await expect(runDeployToGitHubPages(cwd, { skipBuild: false })).rejects.toThrow(
7794
'No "build" script found in package.json'
@@ -85,19 +102,25 @@ describe('runDeployToGitHubPages', () => {
85102
mockReadJson.mockResolvedValueOnce({
86103
scripts: { build: 'webpack --config webpack.prod.js' },
87104
});
88-
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
105+
mockExeca.mockResolvedValue({
106+
stdout: 'https://github.com/user/repo.git',
107+
stderr: '',
108+
exitCode: 0,
109+
} as Awaited<ReturnType<typeof execa>>);
110+
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
89111

90112
await runDeployToGitHubPages(cwd, { skipBuild: false });
91113

92-
expect(mockExeca).toHaveBeenCalledTimes(3); // git, npm run build, gh-pages
114+
expect(mockExeca).toHaveBeenCalledTimes(2); // git, npm run build
93115
expect(mockExeca).toHaveBeenNthCalledWith(2, 'npm', ['run', 'build'], {
94116
cwd,
95117
stdio: 'inherit',
96118
});
97-
expect(mockExeca).toHaveBeenNthCalledWith(3, 'gh-pages', ['-d', 'dist', '-b', 'gh-pages'], {
98-
cwd,
99-
stdio: 'inherit',
100-
});
119+
expect(mockGhPagesPublish).toHaveBeenCalledWith(
120+
path.join(cwd, 'dist'),
121+
{ branch: 'gh-pages', repo: 'https://github.com/user/repo.git' },
122+
expect.any(Function)
123+
);
101124
expect(consoleLogSpy).toHaveBeenCalledWith(
102125
expect.stringContaining('Running build')
103126
);
@@ -111,7 +134,12 @@ describe('runDeployToGitHubPages', () => {
111134
mockReadJson.mockResolvedValueOnce({
112135
scripts: { build: 'webpack' },
113136
});
114-
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
137+
mockExeca.mockResolvedValue({
138+
stdout: 'https://github.com/user/repo.git',
139+
stderr: '',
140+
exitCode: 0,
141+
} as Awaited<ReturnType<typeof execa>>);
142+
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
115143

116144
await runDeployToGitHubPages(cwd, { skipBuild: false });
117145

@@ -126,7 +154,12 @@ describe('runDeployToGitHubPages', () => {
126154
mockReadJson.mockResolvedValueOnce({
127155
scripts: { build: 'vite build' },
128156
});
129-
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
157+
mockExeca.mockResolvedValue({
158+
stdout: 'https://github.com/user/repo.git',
159+
stderr: '',
160+
exitCode: 0,
161+
} as Awaited<ReturnType<typeof execa>>);
162+
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
130163

131164
await runDeployToGitHubPages(cwd, { skipBuild: false });
132165

@@ -138,24 +171,34 @@ describe('runDeployToGitHubPages', () => {
138171

139172
it('skips build and deploys when skipBuild is true', async () => {
140173
setupPathExists({ 'package.json': true, 'dist': true });
141-
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
174+
mockExeca.mockResolvedValue({
175+
stdout: 'https://github.com/user/repo.git',
176+
stderr: '',
177+
exitCode: 0,
178+
} as Awaited<ReturnType<typeof execa>>);
179+
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
142180

143181
await runDeployToGitHubPages(cwd, { skipBuild: true });
144182

145183
expect(mockReadJson).not.toHaveBeenCalled();
146-
expect(mockExeca).toHaveBeenCalledTimes(2); // git, gh-pages
147-
expect(mockExeca).toHaveBeenNthCalledWith(2, 'gh-pages', ['-d', 'dist', '-b', 'gh-pages'], {
148-
cwd,
149-
stdio: 'inherit',
150-
});
184+
expect(mockExeca).toHaveBeenCalledTimes(1); // git only
185+
expect(mockGhPagesPublish).toHaveBeenCalledWith(
186+
path.join(cwd, 'dist'),
187+
{ branch: 'gh-pages', repo: 'https://github.com/user/repo.git' },
188+
expect.any(Function)
189+
);
151190
});
152191

153192
it('throws when dist directory does not exist (after build)', async () => {
154193
setupPathExists({ 'package.json': true, 'dist': false });
155194
mockReadJson.mockResolvedValueOnce({
156195
scripts: { build: 'npm run build' },
157196
});
158-
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
197+
mockExeca.mockResolvedValue({
198+
stdout: 'https://github.com/user/repo.git',
199+
stderr: '',
200+
exitCode: 0,
201+
} as Awaited<ReturnType<typeof execa>>);
159202

160203
await expect(runDeployToGitHubPages(cwd, { skipBuild: false })).rejects.toThrow(
161204
'Build output directory "dist" does not exist'
@@ -165,7 +208,11 @@ describe('runDeployToGitHubPages', () => {
165208

166209
it('throws when dist directory does not exist with skipBuild true', async () => {
167210
setupPathExists({ 'package.json': true, 'dist': false });
168-
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
211+
mockExeca.mockResolvedValue({
212+
stdout: 'https://github.com/user/repo.git',
213+
stderr: '',
214+
exitCode: 0,
215+
} as Awaited<ReturnType<typeof execa>>);
169216

170217
await expect(runDeployToGitHubPages(cwd, { skipBuild: true })).rejects.toThrow(
171218
'Build output directory "dist" does not exist'
@@ -175,18 +222,24 @@ describe('runDeployToGitHubPages', () => {
175222

176223
it('uses custom distDir and branch options', async () => {
177224
setupPathExists({ 'package.json': true, 'build': true });
178-
mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>);
225+
mockExeca.mockResolvedValue({
226+
stdout: 'https://github.com/user/repo.git',
227+
stderr: '',
228+
exitCode: 0,
229+
} as Awaited<ReturnType<typeof execa>>);
230+
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) => cb(null));
179231

180232
await runDeployToGitHubPages(cwd, {
181233
skipBuild: true,
182234
distDir: 'build',
183235
branch: 'pages',
184236
});
185237

186-
expect(mockExeca).toHaveBeenNthCalledWith(2, 'gh-pages', ['-d', 'build', '-b', 'pages'], {
187-
cwd,
188-
stdio: 'inherit',
189-
});
238+
expect(mockGhPagesPublish).toHaveBeenCalledWith(
239+
path.join(cwd, 'build'),
240+
{ branch: 'pages', repo: 'https://github.com/user/repo.git' },
241+
expect.any(Function)
242+
);
190243
expect(consoleLogSpy).toHaveBeenCalledWith(
191244
expect.stringContaining('Deploying "build" to GitHub Pages (branch: pages)')
192245
);
@@ -198,7 +251,11 @@ describe('runDeployToGitHubPages', () => {
198251
scripts: { build: 'webpack' },
199252
});
200253
mockExeca
201-
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>)
254+
.mockResolvedValueOnce({
255+
stdout: 'https://github.com/user/repo.git',
256+
stderr: '',
257+
exitCode: 0,
258+
} as Awaited<ReturnType<typeof execa>>)
202259
.mockRejectedValueOnce(new Error('Build failed'));
203260

204261
await expect(runDeployToGitHubPages(cwd, { skipBuild: false })).rejects.toThrow(
@@ -209,13 +266,18 @@ describe('runDeployToGitHubPages', () => {
209266

210267
it('propagates gh-pages deploy failure', async () => {
211268
setupPathExists({ 'package.json': true, 'dist': true });
212-
mockExeca
213-
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as Awaited<ReturnType<typeof execa>>)
214-
.mockRejectedValueOnce(new Error('Deploy failed'));
269+
mockExeca.mockResolvedValue({
270+
stdout: 'https://github.com/user/repo.git',
271+
stderr: '',
272+
exitCode: 0,
273+
} as Awaited<ReturnType<typeof execa>>);
274+
mockGhPagesPublish.mockImplementation((_dir, _opts, cb) =>
275+
cb(new Error('Deploy failed'))
276+
);
215277

216278
await expect(runDeployToGitHubPages(cwd, { skipBuild: true })).rejects.toThrow(
217279
'Deploy failed'
218280
);
219-
expect(mockExeca).toHaveBeenCalledTimes(2); // git, gh-pages
281+
expect(mockExeca).toHaveBeenCalledTimes(1); // git only
220282
});
221283
});

src/gh-pages.ts

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import path from 'path';
22
import fs from 'fs-extra';
33
import { execa } from 'execa';
4+
import ghPages from 'gh-pages';
5+
import { checkGhAuth } from './github.js';
46

57
export type DeployOptions = {
68
/** Build output directory to deploy (e.g. dist, build) */
@@ -14,6 +16,66 @@ export type DeployOptions = {
1416
const DEFAULT_DIST_DIR = 'dist';
1517
const DEFAULT_BRANCH = 'gh-pages';
1618

19+
/**
20+
* Parse owner and repo name from a Git remote URL.
21+
* Supports https://github.com/owner/repo, https://github.com/owner/repo.git, git@github.com:owner/repo.git
22+
*/
23+
function parseRepoFromUrl(repoUrl: string): { owner: string; repo: string } | null {
24+
const trimmed = repoUrl.trim().replace(/\.git$/, '');
25+
// git@github.com:owner/repo or https://github.com/owner/repo
26+
const sshMatch = trimmed.match(/git@github\.com:([^/]+)\/([^/]+)/);
27+
if (sshMatch?.[1] && sshMatch[2]) {
28+
return { owner: sshMatch[1], repo: sshMatch[2] };
29+
}
30+
const httpsMatch = trimmed.match(/github\.com[/:]([^/]+)\/([^/#?]+)/);
31+
if (httpsMatch?.[1] && httpsMatch[2]) {
32+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
33+
}
34+
return null;
35+
}
36+
37+
/**
38+
* Ensure GitHub Pages is enabled for the repository, configured to use the given branch.
39+
* Uses the GitHub API via `gh` CLI. No-op if gh is not authenticated or on failure.
40+
*/
41+
async function ensurePagesEnabled(owner: string, repo: string, branch: string): Promise<void> {
42+
const auth = await checkGhAuth();
43+
if (!auth.ok) return;
44+
45+
const body = JSON.stringify({ source: { branch, path: '/' } });
46+
try {
47+
const getResult = await execa('gh', ['api', `repos/${owner}/${repo}/pages`, '--jq', '.source.branch'], {
48+
reject: false,
49+
encoding: 'utf8',
50+
});
51+
if (getResult.exitCode === 0) {
52+
const currentBranch = getResult.stdout?.trim();
53+
if (currentBranch === branch) return;
54+
await execa('gh', [
55+
'api',
56+
'-X',
57+
'PUT',
58+
`repos/${owner}/${repo}/pages`,
59+
'--input',
60+
'-',
61+
], { input: body });
62+
console.log(` GitHub Pages source updated to branch "${branch}".`);
63+
} else {
64+
await execa('gh', [
65+
'api',
66+
'-X',
67+
'POST',
68+
`repos/${owner}/${repo}/pages`,
69+
'--input',
70+
'-',
71+
], { input: body });
72+
console.log(` GitHub Pages enabled (source: branch "${branch}").`);
73+
}
74+
} catch {
75+
// Best-effort: continue without enabling; user can enable manually
76+
}
77+
}
78+
1779
/**
1880
* Detect package manager from lock files.
1981
*/
@@ -45,7 +107,7 @@ async function runBuild(cwd: string): Promise<void> {
45107
}
46108

47109
/**
48-
* Deploy the built app to GitHub Pages using gh-pages (npx).
110+
* Deploy the built app to GitHub Pages using the gh-pages package.
49111
* Builds the project first unless skipBuild is true, then publishes distDir to the gh-pages branch.
50112
*/
51113
export async function runDeployToGitHubPages(
@@ -58,15 +120,21 @@ export async function runDeployToGitHubPages(
58120

59121
const cwd = path.resolve(projectPath);
60122
const pkgPath = path.join(cwd, 'package.json');
61-
const gitDir = path.join(cwd, '.git');
62123

63124
if (!(await fs.pathExists(pkgPath))) {
64125
throw new Error(
65126
'No package.json found in this directory. Run this command from your project root (or pass the project path).'
66127
);
67128
}
68129

69-
if (!(await execa('git', ['remote', 'get-url', 'origin'], { cwd, reject: true }))) {
130+
let repoUrl: string;
131+
try {
132+
const { stdout } = await execa('git', ['remote', 'get-url', 'origin'], {
133+
cwd,
134+
reject: true,
135+
});
136+
repoUrl = stdout.trim();
137+
} catch {
70138
throw new Error(
71139
'Please save your changes first, before deploying to GitHub Pages.'
72140
);
@@ -76,17 +144,25 @@ export async function runDeployToGitHubPages(
76144
await runBuild(cwd);
77145
}
78146

79-
const absoluteDist = path.resolve(cwd, distDir);
147+
const absoluteDist = path.join(cwd, distDir);
80148
if (!(await fs.pathExists(absoluteDist))) {
81149
throw new Error(
82150
`Build output directory "${distDir}" does not exist. Run a build first or specify the correct directory with -d/--dist-dir.`
83151
);
84152
}
85153

154+
const parsed = parseRepoFromUrl(repoUrl);
155+
if (parsed) {
156+
await ensurePagesEnabled(parsed.owner, parsed.repo, branch);
157+
}
158+
86159
console.log(`🚀 Deploying "${distDir}" to GitHub Pages (branch: ${branch})...`);
87-
await execa('gh-pages', ['-d', distDir, '-b', branch], {
88-
cwd,
89-
stdio: 'inherit',
160+
await new Promise<void>((resolve, reject) => {
161+
ghPages.publish(
162+
absoluteDist,
163+
{ branch, repo: repoUrl },
164+
(err) => (err ? reject(err) : resolve())
165+
);
90166
});
91167
console.log('\n✅ Deployed to GitHub Pages.');
92168
console.log(' Enable GitHub Pages in your repo: Settings → Pages → Source: branch "' + branch + '".');

0 commit comments

Comments
 (0)