@symbiosis-lab/moss-plugin-github 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +18 -0
- package/assets/icon.svg +3 -0
- package/assets/manifest.json +19 -0
- package/e2e/deploy-api.test.ts +1129 -0
- package/e2e/moss-cli.test.ts +478 -0
- package/features/auth/device-flow.feature +41 -0
- package/features/deploy/validation.feature +50 -0
- package/features/steps/auth.steps.ts +285 -0
- package/features/steps/deploy.steps.ts +354 -0
- package/package.json +51 -0
- package/src/__tests__/auth-flow.integration.test.ts +738 -0
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/configure-domain.test.ts +263 -0
- package/src/__tests__/deploy.integration.test.ts +798 -0
- package/src/__tests__/git.test.ts +190 -0
- package/src/__tests__/github-api.test.ts +761 -0
- package/src/__tests__/github-deploy.test.ts +2411 -0
- package/src/__tests__/progress-timeout.test.ts +209 -0
- package/src/__tests__/repo-setup-progress.test.ts +367 -0
- package/src/__tests__/repo-setup.test.ts +370 -0
- package/src/__tests__/token.test.ts +152 -0
- package/src/__tests__/utils.test.ts +129 -0
- package/src/__tests__/workflow.test.ts +146 -0
- package/src/auth.ts +588 -0
- package/src/constants.ts +7 -0
- package/src/git.ts +60 -0
- package/src/github-api.ts +601 -0
- package/src/github-deploy.ts +593 -0
- package/src/main.ts +646 -0
- package/src/repo-setup.ts +685 -0
- package/src/token.ts +202 -0
- package/src/types.ts +91 -0
- package/src/utils.ts +108 -0
- package/src/workflow.ts +79 -0
- package/test-helpers/mock-github-api.ts +217 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +50 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Deployment Module
|
|
3
|
+
*
|
|
4
|
+
* Deploys site content to GitHub Pages via git push and backs up
|
|
5
|
+
* source files to the main branch.
|
|
6
|
+
*
|
|
7
|
+
* @module github-deploy
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { GITHUB_API_BASE, GITHUB_API_HEADERS } from "./github-api";
|
|
11
|
+
import { executeBinary, listSiteFilesWithSizes, type ExecuteResult } from "@symbiosis-lab/moss-api";
|
|
12
|
+
import { showToast } from "./utils";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Types
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Progress callback: (percent 0-100, message).
|
|
20
|
+
* Percent reflects weighted phase position, not linear file count.
|
|
21
|
+
*/
|
|
22
|
+
export type OnProgress = (percent: number, message: string) => void;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Result of a deployViaGitPush call.
|
|
26
|
+
*/
|
|
27
|
+
export interface DeployResult {
|
|
28
|
+
/** Short SHA of the main branch commit (empty string if no changes) */
|
|
29
|
+
commitSha: string;
|
|
30
|
+
/** Full SHA of the orphan commit pushed to gh-pages */
|
|
31
|
+
orphanSha: string;
|
|
32
|
+
/** Whether the gh-pages tree actually changed from the previous deployment */
|
|
33
|
+
treeChanged: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Internal Helpers
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse an error response body for a human-readable message.
|
|
42
|
+
*/
|
|
43
|
+
async function parseErrorMessage(response: Response): Promise<string> {
|
|
44
|
+
try {
|
|
45
|
+
const body = await response.json();
|
|
46
|
+
return body.message || `GitHub API error: ${response.status}`;
|
|
47
|
+
} catch {
|
|
48
|
+
return `GitHub API error: ${response.status}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// API Functions
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Verify that a repository exists on GitHub.
|
|
59
|
+
*
|
|
60
|
+
* Call this early in the deploy flow to fail fast with a clear error message
|
|
61
|
+
* instead of getting a cryptic "Not Found" from blob/tree/commit endpoints.
|
|
62
|
+
*
|
|
63
|
+
* @param owner - Repository owner
|
|
64
|
+
* @param repo - Repository name
|
|
65
|
+
* @param token - GitHub access token
|
|
66
|
+
* @throws {RepoNotFoundError} if the repository is definitively not found (404)
|
|
67
|
+
* @throws {Error} if the token is invalid (401), access is denied (403), or another API error occurs
|
|
68
|
+
*/
|
|
69
|
+
export async function verifyRepoExists(
|
|
70
|
+
owner: string,
|
|
71
|
+
repo: string,
|
|
72
|
+
token: string
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const headers = {
|
|
75
|
+
...GITHUB_API_HEADERS,
|
|
76
|
+
Authorization: `Bearer ${token}`,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const response = await fetch(
|
|
80
|
+
`${GITHUB_API_BASE}/repos/${owner}/${repo}`,
|
|
81
|
+
{ headers }
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (response.status === 404) {
|
|
85
|
+
// Disambiguate: check if the owner exists (unauthenticated, works for public profiles)
|
|
86
|
+
const ownerResp = await fetch(`${GITHUB_API_BASE}/users/${owner}`, {
|
|
87
|
+
headers: { ...GITHUB_API_HEADERS },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (ownerResp.status === 404) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`GitHub user or organization "${owner}" not found. ` +
|
|
93
|
+
`Check for typos in the repository owner name.`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Repository "${owner}/${repo}" not found on GitHub. ` +
|
|
99
|
+
`The repository may not exist, or your token may not have access to it.`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (response.status === 401) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`GitHub token is invalid or expired. Please re-authenticate.`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (response.status === 403) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Access denied to "${owner}/${repo}". ` +
|
|
112
|
+
`Your token may lack the required "repo" scope.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
const msg = await parseErrorMessage(response);
|
|
118
|
+
throw new Error(msg);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// Git Origin Helpers
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read the deploy target from the project's .git origin remote.
|
|
128
|
+
* Returns null if no .git, no origin, or origin is not a GitHub URL.
|
|
129
|
+
*/
|
|
130
|
+
export async function getOriginOwnerRepo(gitPath: string = "git"): Promise<{ owner: string; repo: string } | null> {
|
|
131
|
+
const result = await executeBinary({
|
|
132
|
+
binaryPath: gitPath,
|
|
133
|
+
args: ["remote", "get-url", "origin"],
|
|
134
|
+
workingDir: ".",
|
|
135
|
+
timeoutMs: 5_000,
|
|
136
|
+
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!result.success) return null;
|
|
140
|
+
|
|
141
|
+
const url = result.stdout.trim();
|
|
142
|
+
|
|
143
|
+
// Parse HTTPS: https://github.com/{owner}/{repo}.git
|
|
144
|
+
const httpsMatch = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
145
|
+
if (httpsMatch) return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
146
|
+
|
|
147
|
+
// Parse SSH: git@github.com:{owner}/{repo}.git
|
|
148
|
+
const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
149
|
+
if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ============================================================================
|
|
155
|
+
// Constants
|
|
156
|
+
// ============================================================================
|
|
157
|
+
|
|
158
|
+
/** GitHub's per-file size limit: 100 MB */
|
|
159
|
+
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Patterns in git error output that indicate corrupt local objects.
|
|
163
|
+
* These errors cannot be fixed by retrying — the .git directory must
|
|
164
|
+
* be wiped and reinitialized. Common cause: iCloud sync corrupting
|
|
165
|
+
* .git/objects/ files.
|
|
166
|
+
*/
|
|
167
|
+
const CORRUPT_GIT_PATTERNS = [
|
|
168
|
+
"Could not read",
|
|
169
|
+
"Failed to traverse parents",
|
|
170
|
+
"bad object",
|
|
171
|
+
"corrupt",
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if a git error message indicates corrupt local git state.
|
|
176
|
+
*/
|
|
177
|
+
export function looksLikeCorruptGit(errorMsg: string): boolean {
|
|
178
|
+
return CORRUPT_GIT_PATTERNS.some(p => errorMsg.includes(p));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ============================================================================
|
|
182
|
+
// Git Push Deploy
|
|
183
|
+
// ============================================================================
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Options for deploying via git push.
|
|
187
|
+
*/
|
|
188
|
+
export interface DeployViaGitPushOptions {
|
|
189
|
+
owner: string;
|
|
190
|
+
repo: string;
|
|
191
|
+
token: string;
|
|
192
|
+
onProgress: OnProgress;
|
|
193
|
+
gitPath: string;
|
|
194
|
+
/** Custom domain to include as CNAME file in gh-pages branch */
|
|
195
|
+
domain?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Replace all occurrences of the token in text with "***".
|
|
200
|
+
* Prevents leaking credentials in error messages.
|
|
201
|
+
*/
|
|
202
|
+
function sanitize(text: string, token: string): string {
|
|
203
|
+
return text.replaceAll(token, "***");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Format a byte count as a human-readable string (e.g., "105.3 MB").
|
|
208
|
+
*/
|
|
209
|
+
function formatSize(bytes: number): string {
|
|
210
|
+
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
211
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
212
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
213
|
+
return `${bytes} B`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parse git push progress from stderr and map to a progress range.
|
|
218
|
+
*
|
|
219
|
+
* Git push outputs lines like:
|
|
220
|
+
* "Writing objects: 79% (234/295), 1.84 MiB | 1.20 MiB/s"
|
|
221
|
+
*
|
|
222
|
+
* @param line - stderr line from git push
|
|
223
|
+
* @param rangeStart - Start of the progress range (e.g., 20)
|
|
224
|
+
* @param rangeEnd - End of the progress range (e.g., 40)
|
|
225
|
+
* @param onProgress - Callback to report progress
|
|
226
|
+
* @param token - Token to sanitize from output
|
|
227
|
+
*/
|
|
228
|
+
function parsePushProgress(
|
|
229
|
+
line: string,
|
|
230
|
+
rangeStart: number,
|
|
231
|
+
rangeEnd: number,
|
|
232
|
+
onProgress: OnProgress,
|
|
233
|
+
token: string,
|
|
234
|
+
): void {
|
|
235
|
+
const match = line.match(/Writing objects:\s+(\d+)%/);
|
|
236
|
+
if (match) {
|
|
237
|
+
const gitPercent = parseInt(match[1], 10);
|
|
238
|
+
const mapped = Math.round(rangeStart + (gitPercent / 100) * (rangeEnd - rangeStart));
|
|
239
|
+
onProgress(mapped, sanitize(line.trim(), token));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolve the current generation directory path (relative to project root).
|
|
245
|
+
*
|
|
246
|
+
* The build pipeline writes output to `.moss/build/generations/<hash>/` and
|
|
247
|
+
* updates `.moss/build/current` (a symlink) to point to that directory.
|
|
248
|
+
* `.moss/build/site/` is empty in the generations model — never stage from there.
|
|
249
|
+
*
|
|
250
|
+
* Returns a path like `.moss/build/generations/abc123def456` — the real directory
|
|
251
|
+
* git can stage and extract a tree from (git does not follow symlinks for
|
|
252
|
+
* `write-tree --prefix`).
|
|
253
|
+
*
|
|
254
|
+
* @throws {Error} if the current symlink is absent or unresolvable
|
|
255
|
+
*/
|
|
256
|
+
export async function resolveCurrentGenDir(): Promise<string> {
|
|
257
|
+
// `readlink` on macOS does NOT support -f, but the symlink target is already
|
|
258
|
+
// absolute (set_current_ptr uses an absolute path), so plain `readlink` suffices.
|
|
259
|
+
const result = await executeBinary({
|
|
260
|
+
binaryPath: "readlink",
|
|
261
|
+
args: [".moss/build/current"],
|
|
262
|
+
workingDir: ".",
|
|
263
|
+
timeoutMs: 5_000,
|
|
264
|
+
env: {},
|
|
265
|
+
});
|
|
266
|
+
if (!result.success || !result.stdout.trim()) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
"Cannot locate current generation: .moss/build/current symlink is missing or unresolvable. " +
|
|
269
|
+
"Run a build first before deploying."
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
// The symlink target is absolute: /abs/path/to/.moss/build/generations/<id>
|
|
273
|
+
// Extract just the generation ID and reconstruct the relative path.
|
|
274
|
+
const absTarget = result.stdout.trim();
|
|
275
|
+
const genId = absTarget.split("/").filter(Boolean).pop();
|
|
276
|
+
if (!genId) {
|
|
277
|
+
throw new Error(`Unexpected generation symlink target: ${absTarget}`);
|
|
278
|
+
}
|
|
279
|
+
return `.moss/build/generations/${genId}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Deploy site to GitHub Pages via a single git repo.
|
|
284
|
+
*
|
|
285
|
+
* Uses one git repo at the project root. Stages site files from the current
|
|
286
|
+
* generation directory (.moss/build/current → .moss/build/generations/<id>/),
|
|
287
|
+
* extracts the generation tree as an orphan commit and pushes it to gh-pages,
|
|
288
|
+
* then backs up source files to main. This gives GitHub Pages the site content
|
|
289
|
+
* at the branch root while keeping source on main.
|
|
290
|
+
*
|
|
291
|
+
* Enforces GitHub's 100MB per-file limit:
|
|
292
|
+
* - Site files (current generation dir): ABORT if any exceed 100MB
|
|
293
|
+
* - Source files (project root): SKIP >100MB files with a warning toast
|
|
294
|
+
*
|
|
295
|
+
* @param options - Deploy options
|
|
296
|
+
* @returns DeployResult with commitSha (short SHA on main) and orphanSha (full SHA on gh-pages)
|
|
297
|
+
*/
|
|
298
|
+
export async function deployViaGitPush(options: DeployViaGitPushOptions): Promise<DeployResult> {
|
|
299
|
+
const { owner, repo, token, onProgress } = options;
|
|
300
|
+
const repoMarker = `https://github.com/${owner}/${repo}.git`;
|
|
301
|
+
const pushUrl = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
|
|
302
|
+
|
|
303
|
+
async function git(
|
|
304
|
+
args: string[],
|
|
305
|
+
onStderr?: (line: string) => void,
|
|
306
|
+
): Promise<ExecuteResult> {
|
|
307
|
+
return executeBinary({
|
|
308
|
+
binaryPath: options.gitPath,
|
|
309
|
+
args,
|
|
310
|
+
workingDir: ".",
|
|
311
|
+
timeoutMs: 600_000, // 10 min — first push of large repos can be slow
|
|
312
|
+
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
313
|
+
onStderr,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Pre-flight: Check site files for 100MB limit ──────────────────────
|
|
318
|
+
onProgress(0, "Preparing deploy...");
|
|
319
|
+
|
|
320
|
+
const siteFiles = await listSiteFilesWithSizes();
|
|
321
|
+
const oversizedSiteFiles = siteFiles.filter((f) => f.size > MAX_FILE_SIZE);
|
|
322
|
+
if (oversizedSiteFiles.length > 0) {
|
|
323
|
+
const fileList = oversizedSiteFiles
|
|
324
|
+
.map((f) => ` ${f.path} (${formatSize(f.size)})`)
|
|
325
|
+
.join("\n");
|
|
326
|
+
throw new Error(
|
|
327
|
+
`Site files exceed GitHub's 100 MB per-file limit:\n${fileList}\n\n` +
|
|
328
|
+
`Remove or reduce these files before deploying.`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Inner function containing the full init → add → commit → push sequence.
|
|
333
|
+
// Extracted so we can retry once on corrupt git state.
|
|
334
|
+
async function attemptDeploy(): Promise<DeployResult> {
|
|
335
|
+
// ── 1. Init git repo if needed, reinit if target repo changed ────────
|
|
336
|
+
// IDEMPOTENT: detect repo change and reinitialize .git so switching
|
|
337
|
+
// deploy targets doesn't push to the wrong remote.
|
|
338
|
+
const check = await git(["rev-parse", "--git-dir"]);
|
|
339
|
+
let needsInit = !check.success;
|
|
340
|
+
|
|
341
|
+
if (check.success) {
|
|
342
|
+
const originUrl = await git(["remote", "get-url", "origin"]);
|
|
343
|
+
if (!originUrl.success || originUrl.stdout.trim() !== repoMarker) {
|
|
344
|
+
// Origin missing or pointing at a different repo — wipe and reinit
|
|
345
|
+
const rm = await executeBinary({
|
|
346
|
+
binaryPath: "rm", args: ["-rf", ".git"],
|
|
347
|
+
workingDir: ".", timeoutMs: 10_000, env: {},
|
|
348
|
+
});
|
|
349
|
+
if (!rm.success) throw new Error(`Failed to remove stale .git: ${rm.stderr}`);
|
|
350
|
+
needsInit = true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (needsInit) {
|
|
355
|
+
await git(["init"]);
|
|
356
|
+
await git(["config", "user.email", "moss@symbiosis-lab.com"]);
|
|
357
|
+
await git(["config", "user.name", "moss"]);
|
|
358
|
+
await git(["remote", "add", "origin", repoMarker]);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Fetch remote refs for delta compression during push ──────────────
|
|
362
|
+
// Without this, git has no common objects and must upload everything.
|
|
363
|
+
// Non-fatal: first deploy has no remote refs to fetch.
|
|
364
|
+
const fetchResult = await git(["fetch", "--depth=1", "origin"]);
|
|
365
|
+
if (!fetchResult.success) {
|
|
366
|
+
console.log(" No remote history to fetch (first deploy)");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Migration: strip stale moss-managed .moss/* lines from root .gitignore ──
|
|
370
|
+
// Older moss versions overwrote the root .gitignore with .moss/* exclusions.
|
|
371
|
+
// Those rules now live in .moss/.gitignore (written by the Rust pipeline).
|
|
372
|
+
await executeBinary({
|
|
373
|
+
binaryPath: "sh",
|
|
374
|
+
args: ["-c", "[ -f .gitignore ] && sed -i '' '/^\\.moss/d;/^!\\.moss/d' .gitignore || true"],
|
|
375
|
+
workingDir: ".",
|
|
376
|
+
timeoutMs: 5_000,
|
|
377
|
+
env: {},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// IDEMPOTENT: remove stale locks from crashed git operations (common with iCloud)
|
|
381
|
+
await executeBinary({
|
|
382
|
+
binaryPath: "rm", args: ["-f", ".git/index.lock"],
|
|
383
|
+
workingDir: ".", timeoutMs: 5_000, env: {},
|
|
384
|
+
});
|
|
385
|
+
await executeBinary({
|
|
386
|
+
binaryPath: "rm", args: ["-f", ".git/shallow.lock"],
|
|
387
|
+
workingDir: ".", timeoutMs: 5_000, env: {},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ── 2. Resolve current generation dir and stage its files ────────────
|
|
391
|
+
// .moss/build/site/ is empty in the generations model (#816). The real
|
|
392
|
+
// output lives at .moss/build/current → .moss/build/generations/<id>/.
|
|
393
|
+
// git does not follow symlinks for write-tree --prefix, so we resolve
|
|
394
|
+
// the symlink to the actual (relative) generation directory first.
|
|
395
|
+
onProgress(5, "Staging site files...");
|
|
396
|
+
const genDir = await resolveCurrentGenDir();
|
|
397
|
+
await git(["add", `${genDir}/`]);
|
|
398
|
+
|
|
399
|
+
// ── 3. Get site tree SHA directly from index ─────────────────────────
|
|
400
|
+
// Remove index.lock again: iCloud can re-lock the index between git add
|
|
401
|
+
// (which releases it) and write-tree (which needs to read it). The lock
|
|
402
|
+
// file is zero-bytes with iCloud extended attributes — not a real git lock.
|
|
403
|
+
await executeBinary({
|
|
404
|
+
binaryPath: "rm", args: ["-f", ".git/index.lock"],
|
|
405
|
+
workingDir: ".", timeoutMs: 5_000, env: {},
|
|
406
|
+
});
|
|
407
|
+
onProgress(10, "Preparing gh-pages...");
|
|
408
|
+
const writeTree = await git(["write-tree", `--prefix=${genDir}/`]);
|
|
409
|
+
if (!writeTree.success) throw new Error(`Failed to write site tree: ${sanitize(writeTree.stderr, token)}`);
|
|
410
|
+
|
|
411
|
+
// Inject .nojekyll (always) and CNAME (when domain is set) into the
|
|
412
|
+
// gh-pages tree. Without .nojekyll, GitHub runs Jekyll which may skip
|
|
413
|
+
// files starting with underscores or fail to trigger the Pages pipeline.
|
|
414
|
+
// Without CNAME, each force-push removes the custom domain setting.
|
|
415
|
+
// Both are injected in a single ls-tree → modify → mktree pass.
|
|
416
|
+
let treeSha = writeTree.stdout.trim();
|
|
417
|
+
{
|
|
418
|
+
// Create empty .nojekyll blob
|
|
419
|
+
const nojekyllHash = await executeBinary({
|
|
420
|
+
binaryPath: options.gitPath,
|
|
421
|
+
args: ["hash-object", "-w", "--stdin"],
|
|
422
|
+
workingDir: ".",
|
|
423
|
+
timeoutMs: 30_000, // iCloud can slow .git/objects writes
|
|
424
|
+
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
425
|
+
stdin: "",
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
if (nojekyllHash.success) {
|
|
429
|
+
const lsTree = await git(["ls-tree", treeSha]);
|
|
430
|
+
if (lsTree.success) {
|
|
431
|
+
// Filter out existing .nojekyll and CNAME entries to avoid duplicates
|
|
432
|
+
// (user's site may already contain these files)
|
|
433
|
+
const filteredEntries = lsTree.stdout.trimEnd().split("\n")
|
|
434
|
+
.filter(line => !line.endsWith("\t.nojekyll") && !line.endsWith("\tCNAME"))
|
|
435
|
+
.join("\n");
|
|
436
|
+
let treeEntries = filteredEntries
|
|
437
|
+
+ "\n100644 blob " + nojekyllHash.stdout.trim() + "\t.nojekyll\n";
|
|
438
|
+
|
|
439
|
+
// Additionally inject CNAME when domain is configured
|
|
440
|
+
if (options.domain) {
|
|
441
|
+
const cnameHash = await executeBinary({
|
|
442
|
+
binaryPath: options.gitPath,
|
|
443
|
+
args: ["hash-object", "-w", "--stdin"],
|
|
444
|
+
workingDir: ".",
|
|
445
|
+
timeoutMs: 30_000,
|
|
446
|
+
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
447
|
+
stdin: options.domain + "\n",
|
|
448
|
+
});
|
|
449
|
+
if (cnameHash.success) {
|
|
450
|
+
treeEntries += "100644 blob " + cnameHash.stdout.trim() + "\tCNAME\n";
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const mktree = await executeBinary({
|
|
455
|
+
binaryPath: options.gitPath,
|
|
456
|
+
args: ["mktree"],
|
|
457
|
+
workingDir: ".",
|
|
458
|
+
timeoutMs: 30_000,
|
|
459
|
+
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
460
|
+
stdin: treeEntries,
|
|
461
|
+
});
|
|
462
|
+
if (mktree.success) treeSha = mktree.stdout.trim();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── 4. Parent to previous gh-pages tip for delta compression ─────────
|
|
468
|
+
// When the remote gh-pages ref exists, create a child commit instead of
|
|
469
|
+
// an orphan. This lets git delta-compress against the previous tree,
|
|
470
|
+
// drastically reducing upload size on repeat deploys.
|
|
471
|
+
// Also detect whether the tree actually changed (for accurate UI messaging).
|
|
472
|
+
const ghPagesTip = await git(["rev-parse", "refs/remotes/origin/gh-pages"]);
|
|
473
|
+
let treeChanged = true; // Assume changed unless we prove otherwise
|
|
474
|
+
if (ghPagesTip.success) {
|
|
475
|
+
const prevTree = await git(["rev-parse", `${ghPagesTip.stdout.trim()}^{tree}`]);
|
|
476
|
+
if (prevTree.success && prevTree.stdout.trim() === treeSha) {
|
|
477
|
+
treeChanged = false;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const commitTreeArgs = ghPagesTip.success
|
|
481
|
+
? ["commit-tree", treeSha, "-p", ghPagesTip.stdout.trim(), "-m", "Deploy site\n\nGenerated by moss"]
|
|
482
|
+
: ["commit-tree", treeSha, "-m", "Deploy site\n\nGenerated by moss"];
|
|
483
|
+
const orphan = await git(commitTreeArgs);
|
|
484
|
+
if (!orphan.success) throw new Error(`Failed to create gh-pages commit: ${sanitize(orphan.stderr, token)}`);
|
|
485
|
+
const orphanSha = orphan.stdout.trim();
|
|
486
|
+
|
|
487
|
+
// ── 5. Push gh-pages first (fast — user sees "Deployed!" quickly) ────
|
|
488
|
+
onProgress(25, "Pushing to GitHub...");
|
|
489
|
+
const push = await git(
|
|
490
|
+
[
|
|
491
|
+
"push", "--force", "--progress", pushUrl,
|
|
492
|
+
`${orphanSha}:refs/heads/gh-pages`,
|
|
493
|
+
],
|
|
494
|
+
(line) => parsePushProgress(line, 25, 95, onProgress, token),
|
|
495
|
+
);
|
|
496
|
+
if (!push.success) throw new Error(`git push failed: ${sanitize(push.stderr, token)}`);
|
|
497
|
+
|
|
498
|
+
onProgress(100, "Deployed!");
|
|
499
|
+
|
|
500
|
+
// ── 6. Deferred source backup to main branch (non-fatal) ─────────────
|
|
501
|
+
// Stage the entire vault (may be slow on iCloud) and push source to main.
|
|
502
|
+
// This happens AFTER "Deployed!" so the user isn't waiting.
|
|
503
|
+
//
|
|
504
|
+
// Emit a phase change so the parent's heartbeat shows the right message
|
|
505
|
+
// ("Backing up source...") instead of repeating "Deployed!" for minutes
|
|
506
|
+
// while iCloud-synced `git add --all` of the whole vault grinds.
|
|
507
|
+
onProgress(100, "Backing up source...");
|
|
508
|
+
let sha = "";
|
|
509
|
+
try {
|
|
510
|
+
// Check for source files >100MB and append to .gitignore
|
|
511
|
+
const findResult = await executeBinary({
|
|
512
|
+
binaryPath: "find",
|
|
513
|
+
args: [
|
|
514
|
+
".", "-not", "-path", "./.moss/*", "-not", "-path", "./.git/*",
|
|
515
|
+
"-type", "f", "-size", "+100M",
|
|
516
|
+
],
|
|
517
|
+
workingDir: ".",
|
|
518
|
+
timeoutMs: 30_000,
|
|
519
|
+
env: {},
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const largeSourceFiles = findResult.stdout
|
|
523
|
+
.split("\n")
|
|
524
|
+
.map((l) => l.trim())
|
|
525
|
+
.filter((l) => l.length > 0)
|
|
526
|
+
.map((l) => l.startsWith("./") ? l.slice(2) : l);
|
|
527
|
+
|
|
528
|
+
if (largeSourceFiles.length > 0) {
|
|
529
|
+
const escapedFiles = largeSourceFiles.map((f) => f.replace(/'/g, "'\\''")).join("\\n");
|
|
530
|
+
await executeBinary({
|
|
531
|
+
binaryPath: "sh",
|
|
532
|
+
args: ["-c", `printf "\\n${escapedFiles}\\n" >> .gitignore`],
|
|
533
|
+
workingDir: ".",
|
|
534
|
+
timeoutMs: 5_000,
|
|
535
|
+
env: {},
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const fileList = largeSourceFiles
|
|
539
|
+
.map((f) => ` ${f}`)
|
|
540
|
+
.join("<br>");
|
|
541
|
+
await showToast({
|
|
542
|
+
variant: "warning",
|
|
543
|
+
message: `Skipped ${largeSourceFiles.length} file(s) exceeding 100 MB:<br>${fileList}`,
|
|
544
|
+
duration: 10_000,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
await git(["add", "--all"]);
|
|
549
|
+
const diff = await git(["diff", "--cached", "--quiet"]);
|
|
550
|
+
if (!diff.success) {
|
|
551
|
+
// Has changes to commit
|
|
552
|
+
const commit = await git(["commit", "-m", "Deploy site\n\nGenerated by moss"]);
|
|
553
|
+
if (commit.success) {
|
|
554
|
+
const revParse = await git(["rev-parse", "--short", "HEAD"]);
|
|
555
|
+
sha = revParse.success ? revParse.stdout.trim() : "";
|
|
556
|
+
await git(["push", pushUrl, "HEAD:refs/heads/main"]);
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
// No new changes to commit, but push any existing unpushed commits.
|
|
560
|
+
// This handles the case where previous deploys committed but failed
|
|
561
|
+
// to push (e.g., network error), leaving local main ahead of remote.
|
|
562
|
+
const behind = await git(["rev-list", "--count", "origin/main..HEAD"]);
|
|
563
|
+
if (behind.success && parseInt(behind.stdout.trim(), 10) > 0) {
|
|
564
|
+
await git(["push", pushUrl, "HEAD:refs/heads/main"]);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} catch {
|
|
568
|
+
// Source backup is non-fatal — site is already deployed to gh-pages
|
|
569
|
+
console.warn("Source backup to main branch failed (non-fatal)");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return { commitSha: sha, orphanSha, treeChanged };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ── Execute with corrupt-git recovery ──────────────────────────────────
|
|
576
|
+
// If the deploy fails due to corrupt local git objects (common with iCloud
|
|
577
|
+
// sync), wipe .git and retry once. Since we force-push, no history is needed.
|
|
578
|
+
try {
|
|
579
|
+
return await attemptDeploy();
|
|
580
|
+
} catch (err) {
|
|
581
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
582
|
+
if (!looksLikeCorruptGit(msg)) throw err;
|
|
583
|
+
|
|
584
|
+
// Corrupt git state detected — wipe and retry once
|
|
585
|
+
onProgress(0, "Recovering from corrupt git state...");
|
|
586
|
+
console.warn("Corrupt git detected, reinitializing .git");
|
|
587
|
+
await executeBinary({
|
|
588
|
+
binaryPath: "rm", args: ["-rf", ".git"],
|
|
589
|
+
workingDir: ".", timeoutMs: 10_000, env: {},
|
|
590
|
+
});
|
|
591
|
+
return await attemptDeploy();
|
|
592
|
+
}
|
|
593
|
+
}
|