diff --git a/services/app-builder/src/_integration_tests/git-test-helpers.ts b/services/app-builder/src/_integration_tests/git-test-helpers.ts index f5c80b5e13..e86722c03d 100644 --- a/services/app-builder/src/_integration_tests/git-test-helpers.ts +++ b/services/app-builder/src/_integration_tests/git-test-helpers.ts @@ -10,13 +10,9 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, readdirSyn import { tmpdir } from 'os'; import { join } from 'path'; -// --- Configuration --- - export const APP_BUILDER_URL = process.env.APP_BUILDER_URL || 'http://localhost:8790'; export const AUTH_TOKEN = process.env.AUTH_TOKEN || 'dev-token-change-this-in-production'; -// --- Types --- - export type TokenPermission = 'full' | 'ro'; export type InitSuccessResponse = { @@ -32,8 +28,6 @@ export type TokenResponse = { permission: TokenPermission; }; -// --- Logging --- - export function log(message: string, data?: unknown) { console.log(`[TEST] ${message}`, data ? JSON.stringify(data, null, 2) : ''); } @@ -50,8 +44,6 @@ export function logFailure(message: string) { console.error(`[✗] ${message}`); } -// --- API helpers --- - export async function initProject(projectId: string): Promise { const endpoint = `${APP_BUILDER_URL}/apps/${encodeURIComponent(projectId)}/init`; @@ -94,8 +86,6 @@ export async function generateGitToken( return response.json(); } -// --- Git CLI helpers --- - export function buildGitUrlWithToken(gitUrl: string, token: string): string { const url = new URL(gitUrl); url.username = 'x-access-token'; @@ -172,8 +162,6 @@ export function runGitCommandSafe( } } -// --- High-level helpers --- - /** * Clone a repository into a subdirectory of `parentDir`. * Returns the absolute path to the cloned directory. @@ -212,8 +200,6 @@ export function push(dir: string, branch = 'main') { runGitCommand(dir, `git push origin ${branch}`); } -// --- Temp dir management --- - export function createTempDir(prefix = 'app-builder-test-'): string { return mkdtempSync(join(tmpdir(), prefix)); } @@ -226,8 +212,6 @@ export function removeTempDir(dir: string) { } } -// --- Assertions --- - export class AssertionError extends Error { constructor(message: string) { super(message); @@ -274,20 +258,16 @@ export function assertFileExists(dir: string, relativePath: string, label: strin * Runs multiple independent checks. */ export function assertNotDetachedHead(dir: string, expectedBranch = 'main') { - // Check 1: git branch --show-current (empty in detached HEAD) const branchShowCurrent = runGitCommandSafe(dir, 'git branch --show-current'); assertEqual(branchShowCurrent.stdout, expectedBranch, 'git branch --show-current'); - // Check 2: git symbolic-ref HEAD (errors in detached HEAD) const symbolicRef = runGitCommandSafe(dir, 'git symbolic-ref HEAD'); assertEqual(symbolicRef.exitCode, 0, 'git symbolic-ref HEAD exit code'); assertEqual(symbolicRef.stdout, `refs/heads/${expectedBranch}`, 'git symbolic-ref HEAD'); - // Check 3: git rev-parse --abbrev-ref HEAD (returns "HEAD" if detached) const abbrevRef = runGitCommandSafe(dir, 'git rev-parse --abbrev-ref HEAD'); assertEqual(abbrevRef.stdout, expectedBranch, 'git rev-parse --abbrev-ref HEAD'); - // Check 4: git status should say "On branch main", not "HEAD detached" const status = runGitCommandSafe(dir, 'git status'); assertIncludes(status.stdout, `On branch ${expectedBranch}`, 'git status branch line'); } @@ -311,8 +291,6 @@ export function getCommitMessages(dir: string): string[] { .filter(line => line.length > 0); } -// --- Test runner --- - export type TestFn = () => Promise; /** @@ -342,7 +320,6 @@ export async function runTestSuite( } } - // Summary console.log(`\n${'='.repeat(60)}`); console.log(` Results: ${suiteName}`); console.log('='.repeat(60)); diff --git a/services/app-builder/src/_integration_tests/test-git-head-and-history.ts b/services/app-builder/src/_integration_tests/test-git-head-and-history.ts index 99995bea53..8d3cfa6198 100644 --- a/services/app-builder/src/_integration_tests/test-git-head-and-history.ts +++ b/services/app-builder/src/_integration_tests/test-git-head-and-history.ts @@ -45,9 +45,6 @@ function uniqueId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -// ========================================================================= -// Test 1: Multiple local commits then push, clone verifies all history -// ========================================================================= async function testMultipleCommitsThenPush() { const testId = uniqueId('multi-commit'); const tempDir = createTempDir(); @@ -56,29 +53,23 @@ async function testMultipleCommitsThenPush() { log(`Project: ${testId}`); const { git_url: gitUrl } = await initProject(testId); - // Clone const dir1 = await cloneRepo(testId, gitUrl, tempDir, 'work'); configureGitUser(dir1); - // First local commit: create file A const fileAContent = 'File A — first commit'; writeFileSync(join(dir1, 'fileA.txt'), fileAContent); commitAll(dir1, 'Add file A'); - // Second local commit: modify A + create B const fileAUpdated = 'File A — updated in second commit'; const fileBContent = 'File B — second commit'; writeFileSync(join(dir1, 'fileA.txt'), fileAUpdated); writeFileSync(join(dir1, 'fileB.txt'), fileBContent); commitAll(dir1, 'Update A, add B'); - // Push both commits at once push(dir1); - // Clone into a fresh folder const dir2 = await cloneRepo(testId, gitUrl, tempDir, 'verify'); - // Assertions assertNotDetachedHead(dir2, 'main'); // 3 commits: initial template + "Add file A" + "Update A, add B" @@ -93,7 +84,6 @@ async function testMultipleCommitsThenPush() { assertFileContent(dir2, 'fileA.txt', fileAUpdated, 'fileA.txt content'); assertFileContent(dir2, 'fileB.txt', fileBContent, 'fileB.txt content'); - // git status should be clean const status = runGitCommand(dir2, 'git status --porcelain'); assertEqual(status.trim(), '', 'git status clean'); @@ -103,9 +93,6 @@ async function testMultipleCommitsThenPush() { } } -// ========================================================================= -// Test 2: Incremental pushes — push, then push again, then clone -// ========================================================================= async function testIncrementalPushes() { const testId = uniqueId('incr-push'); const tempDir = createTempDir(); @@ -114,24 +101,19 @@ async function testIncrementalPushes() { log(`Project: ${testId}`); const { git_url: gitUrl } = await initProject(testId); - // Clone const dir1 = await cloneRepo(testId, gitUrl, tempDir, 'work'); configureGitUser(dir1); - // First commit + push writeFileSync(join(dir1, 'first.txt'), 'first push content'); commitAll(dir1, 'First push'); push(dir1); - // Second commit + push (incremental) writeFileSync(join(dir1, 'second.txt'), 'second push content'); commitAll(dir1, 'Second push'); push(dir1); - // Clone into fresh folder const dir2 = await cloneRepo(testId, gitUrl, tempDir, 'verify'); - // Assertions assertNotDetachedHead(dir2, 'main'); const commitCount = countCommits(dir2); @@ -150,9 +132,6 @@ async function testIncrementalPushes() { } } -// ========================================================================= -// Test 3: Bidirectional — clone in dir1 push, clone in dir2 push, verify -// ========================================================================= async function testBidirectionalPushes() { const testId = uniqueId('bidir'); const tempDir = createTempDir(); @@ -161,27 +140,22 @@ async function testBidirectionalPushes() { log(`Project: ${testId}`); const { git_url: gitUrl } = await initProject(testId); - // Clone into dir1, make changes, push const dir1 = await cloneRepo(testId, gitUrl, tempDir, 'dir1'); configureGitUser(dir1); writeFileSync(join(dir1, 'from-dir1.txt'), 'created in dir1'); commitAll(dir1, 'Commit from dir1'); push(dir1); - // Clone into dir2 (simulates cloud-agent picking up the repo) const dir2 = await cloneRepo(testId, gitUrl, tempDir, 'dir2'); assertNotDetachedHead(dir2, 'main'); configureGitUser(dir2); - // Verify dir2 has dir1's file assertFileContent(dir2, 'from-dir1.txt', 'created in dir1', 'dir1 file in dir2'); - // Make changes in dir2, push writeFileSync(join(dir2, 'from-dir2.txt'), 'created in dir2'); commitAll(dir2, 'Commit from dir2'); push(dir2); - // Clone into dir3 (final verification) const dir3 = await cloneRepo(testId, gitUrl, tempDir, 'dir3'); assertNotDetachedHead(dir3, 'main'); @@ -202,9 +176,6 @@ async function testBidirectionalPushes() { } } -// ========================================================================= -// Test 4: Detached HEAD regression — thorough checks after every clone -// ========================================================================= async function testDetachedHeadRegression() { const testId = uniqueId('detached'); const tempDir = createTempDir(); @@ -213,37 +184,31 @@ async function testDetachedHeadRegression() { log(`Project: ${testId}`); const { git_url: gitUrl } = await initProject(testId); - // Clone immediately after init (only initial commit exists) log('Cloning immediately after init...'); const dir1 = await cloneRepo(testId, gitUrl, tempDir, 'clone-after-init'); assertNotDetachedHead(dir1, 'main'); logSuccess('Clone after init: NOT detached HEAD'); - // Push a commit configureGitUser(dir1); writeFileSync(join(dir1, 'change.txt'), 'some change'); commitAll(dir1, 'Add change'); push(dir1); - // Clone after first push log('Cloning after first push...'); const dir2 = await cloneRepo(testId, gitUrl, tempDir, 'clone-after-push1'); assertNotDetachedHead(dir2, 'main'); logSuccess('Clone after 1st push: NOT detached HEAD'); - // Push another commit configureGitUser(dir2); writeFileSync(join(dir2, 'change2.txt'), 'another change'); commitAll(dir2, 'Add change2'); push(dir2); - // Clone after second push log('Cloning after second push...'); const dir3 = await cloneRepo(testId, gitUrl, tempDir, 'clone-after-push2'); assertNotDetachedHead(dir3, 'main'); logSuccess('Clone after 2nd push: NOT detached HEAD'); - // Verify the full chain is intact assertEqual(countCommits(dir3), 3, 'final commit count'); logSuccess('No detached HEAD detected at any stage'); @@ -252,9 +217,6 @@ async function testDetachedHeadRegression() { } } -// ========================================================================= -// Test 5: Clone of fresh repo (only template initial commit) -// ========================================================================= async function testCloneFreshRepo() { const testId = uniqueId('fresh'); const tempDir = createTempDir(); @@ -263,26 +225,20 @@ async function testCloneFreshRepo() { log(`Project: ${testId}`); const { git_url: gitUrl } = await initProject(testId); - // Clone immediately — no pushes have occurred yet const dir1 = await cloneRepo(testId, gitUrl, tempDir, 'fresh-clone'); - // Should be on main, not detached assertNotDetachedHead(dir1, 'main'); - // Should have exactly 1 commit (the initial template commit) assertEqual(countCommits(dir1), 1, 'commit count'); assertEqual(getCommitMessages(dir1)[0], 'Initial commit', 'initial commit message'); - // Template files should exist (at minimum package.json for nextjs-starter) assertFileExists(dir1, 'package.json', 'template package.json'); - // Should be able to make changes and push configureGitUser(dir1); writeFileSync(join(dir1, 'new-file.txt'), 'new content'); commitAll(dir1, 'First user commit'); push(dir1); - // Verify the push took const dir2 = await cloneRepo(testId, gitUrl, tempDir, 'verify'); assertNotDetachedHead(dir2, 'main'); assertEqual(countCommits(dir2), 2, 'commit count after push'); @@ -294,10 +250,6 @@ async function testCloneFreshRepo() { } } -// ========================================================================= -// Main -// ========================================================================= - async function main() { log('Git HEAD & History Integration Tests', { appBuilderUrl: APP_BUILDER_URL }); diff --git a/services/app-builder/src/_integration_tests/test-git-integration.ts b/services/app-builder/src/_integration_tests/test-git-integration.ts index 380733a520..79e2f32ec1 100644 --- a/services/app-builder/src/_integration_tests/test-git-integration.ts +++ b/services/app-builder/src/_integration_tests/test-git-integration.ts @@ -52,9 +52,6 @@ async function runTests() { }); try { - // =========================================== - // Step 1: Initialize Project - // =========================================== log('\n=== Step 1: Initialize Project ==='); const initResult = await initProject(testId); log('Project initialized', initResult); @@ -62,9 +59,6 @@ async function runTests() { const gitUrl = initResult.git_url; - // =========================================== - // Step 2: Generate Tokens - // =========================================== log('\n=== Step 2: Generate Tokens ==='); const fullTokenResult = await generateGitToken(testId, 'full'); @@ -83,9 +77,6 @@ async function runTests() { }); logSuccess('Read-only token generated'); - // =========================================== - // Step 3: Clone with Full Token - // =========================================== log('\n=== Step 3: Clone with Full Token ==='); const cloneDir1 = join(tempDir, 'clone1'); @@ -102,9 +93,6 @@ async function runTests() { } logSuccess(`Clone with full token succeeded, ${files1.length} files`); - // =========================================== - // Step 4: Make Changes and Push with Full Token - // =========================================== log('\n=== Step 4: Push Changes with Full Token ==='); const testFileName = 'test-file.txt'; @@ -119,12 +107,8 @@ async function runTests() { logSuccess('Push with full token succeeded'); - // =========================================== - // Step 5: Clone Again to Verify Push - // =========================================== log('\n=== Step 5: Clone Again to Verify Push ==='); - // Generate a fresh token for the second clone const fullTokenResult2 = await generateGitToken(testId, 'full'); const fullTokenUrl2 = buildGitUrlWithToken(gitUrl, fullTokenResult2.token); @@ -147,12 +131,8 @@ async function runTests() { logSuccess('Second clone verified - push was persisted'); - // =========================================== - // Step 6: Clone with Read-Only Token - // =========================================== log('\n=== Step 6: Clone with Read-Only Token ==='); - // Generate fresh read-only token const roTokenResult2 = await generateGitToken(testId, 'ro'); const roTokenUrl = buildGitUrlWithToken(gitUrl, roTokenResult2.token); @@ -165,9 +145,6 @@ async function runTests() { log('Read-only clone contents', { files: files3 }); logSuccess('Clone with read-only token succeeded'); - // =========================================== - // Step 7: Attempt Push with Read-Only Token (Should Fail) - // =========================================== log('\n=== Step 7: Attempt Push with Read-Only Token (Should Fail) ==='); const testFileName2 = 'should-not-exist.txt'; @@ -178,14 +155,12 @@ async function runTests() { runGitCommand(cloneDir3, `git add "${testFileName2}"`); runGitCommand(cloneDir3, `git commit -m "Should fail push"`); - // This should fail - read-only token cannot push const pushError = runGitCommand( cloneDir3, 'git push origin main 2>&1 || true', false // We handle the error ourselves with || true ); - // Check if push actually failed if ( pushError.includes('Forbidden') || pushError.includes('403') || @@ -194,7 +169,6 @@ async function runTests() { ) { logSuccess('Push with read-only token correctly rejected'); } else { - // Try another verification: clone again and check if file exists const fullTokenResult3 = await generateGitToken(testId, 'full'); const fullTokenUrl3 = buildGitUrlWithToken(gitUrl, fullTokenResult3.token); @@ -212,9 +186,6 @@ async function runTests() { } } - // =========================================== - // All Tests Passed - // =========================================== console.log('\n' + '='.repeat(50)); console.log('ALL TESTS PASSED'); console.log('='.repeat(50)); @@ -239,7 +210,6 @@ async function runTests() { } } -// Run tests runTests().catch(error => { logError('Unhandled error', error); process.exit(1); diff --git a/services/app-builder/src/api-schemas.ts b/services/app-builder/src/api-schemas.ts index 399718296e..51d6859346 100644 --- a/services/app-builder/src/api-schemas.ts +++ b/services/app-builder/src/api-schemas.ts @@ -1,10 +1,5 @@ import { z } from 'zod'; -// ============================================ -// Init Endpoint Schemas -// POST /apps/{app_id}/init -// ============================================ - // Template names must be alphanumeric with dashes/underscores only (no path traversal) const templateNameRegex = /^[a-zA-Z0-9_-]+$/; @@ -52,11 +47,6 @@ export type InitSuccessResponse = z.infer; export type InitErrorResponse = z.infer; export type InitResponse = z.infer; -// ============================================ -// Preview Status Endpoint Schemas -// GET /apps/{app_id}/preview -// ============================================ - export const PreviewStateSchema = z.enum(['uninitialized', 'idle', 'building', 'running', 'error']); export type PreviewState = z.infer; @@ -68,11 +58,6 @@ export const GetPreviewResponseSchema = z.object({ export type GetPreviewResponse = z.infer; -// ============================================ -// Build Trigger Endpoint Schemas -// POST /apps/{app_id}/build -// ============================================ - // Returns 202 Accepted with empty body on success // Returns error response on failure export const BuildTriggerErrorResponseSchema = z.object({ @@ -82,11 +67,6 @@ export const BuildTriggerErrorResponseSchema = z.object({ export type BuildTriggerErrorResponse = z.infer; -// ============================================ -// Build Logs Streaming Endpoint Schemas -// GET /apps/{app_id}/build/logs -// ============================================ - // Returns Server-Sent Events stream on success // Returns error response on failure export const BuildLogsErrorResponseSchema = z.object({ @@ -96,11 +76,6 @@ export const BuildLogsErrorResponseSchema = z.object({ export type BuildLogsErrorResponse = z.infer; -// ============================================ -// Token Generation Endpoint Schemas -// POST /apps/{app_id}/token -// ============================================ - export const TokenRequestSchema = z.object({ permission: z.enum(['full', 'ro']), }); @@ -116,11 +91,6 @@ export const TokenSuccessResponseSchema = z.object({ export type TokenSuccessResponse = z.infer; -// ============================================ -// Delete Endpoint Schemas -// DELETE /apps/{app_id} -// ============================================ - export const DeleteSuccessResponseSchema = z.object({ success: z.literal(true), }); @@ -133,12 +103,6 @@ export const DeleteErrorResponseSchema = z.object({ export type DeleteSuccessResponse = z.infer; export type DeleteErrorResponse = z.infer; -// ============================================ -// Migrate to GitHub Endpoint Schemas -// POST /apps/{app_id}/migrate-to-github -// Sets GitHub source and schedules internal git repo deletion -// ============================================ - export const MigrateToGithubRequestSchema = z.object({ githubRepo: z.string().regex(/^[^/]+\/[^/]+$/, 'Must be in "owner/repo" format'), userId: z.string().min(1), @@ -166,10 +130,6 @@ export type MigrateToGithubSuccessResponse = z.infer; export type MigrateToGithubResponse = z.infer; -// ============================================ -// Common Error Response Schema -// ============================================ - export const ApiErrorResponseSchema = z.object({ error: z.string(), message: z.string(), diff --git a/services/app-builder/src/git-repository-do.ts b/services/app-builder/src/git-repository-do.ts index e342b70101..29a3c7824f 100644 --- a/services/app-builder/src/git-repository-do.ts +++ b/services/app-builder/src/git-repository-do.ts @@ -44,13 +44,11 @@ export class GitRepositoryDO extends DurableObject { throw error; } - // Check if .git directory exists try { await this.fs.stat('.git'); this._initialized = true; logger.debug('Repository already initialized'); } catch (_err) { - // .git doesn't exist, repo not initialized yet this._initialized = false; logger.debug('Repository not yet initialized'); } @@ -108,7 +106,6 @@ export class GitRepositoryDO extends DurableObject { logger.debug('Creating initial commit', { fileCount: Object.keys(files).length }); - // Write files (decode base64 to binary) for (const [path, base64Content] of Object.entries(files)) { const bytes = Buffer.from(base64Content, 'base64'); await this.fs.writeFile(path, bytes); @@ -332,7 +329,6 @@ export class GitRepositoryDO extends DurableObject { return { success: false, error: 'No git objects to push' }; } - // Build in-memory FS for isomorphic-git push operation const memFs = new MemFS(); await git.init({ fs: memFs, dir: '/', defaultBranch: 'main' }); diff --git a/services/app-builder/src/git/fs-adapter.ts b/services/app-builder/src/git/fs-adapter.ts index 00594da0f6..fa734a25c7 100644 --- a/services/app-builder/src/git/fs-adapter.ts +++ b/services/app-builder/src/git/fs-adapter.ts @@ -134,10 +134,8 @@ export class SqliteFS { throw new Error('Cannot write to root'); } - // Convert to Uint8Array if string const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; - // Check size limit if (bytes.length > MAX_OBJECT_SIZE) { const sizeKB = (bytes.length / 1024).toFixed(2); const maxKB = (MAX_OBJECT_SIZE / 1024).toFixed(2); @@ -152,7 +150,6 @@ export class SqliteFS { throw new Error(`File too large: ${path} (${bytes.length} bytes, max ${MAX_OBJECT_SIZE})`); } - // Check if path exists as directory const existing = this.db .select({ is_dir: gitObjects.is_dir }) .from(gitObjects) @@ -192,7 +189,6 @@ export class SqliteFS { } } - // Encode to base64 for safe storage let base64Content = ''; if (bytes.length > 0) { let binaryString = ''; @@ -283,7 +279,6 @@ export class SqliteFS { if (rows.length === 0) return []; - // Extract just the basename from each path return rows.map(row => { const parts = row.path.split('/'); return parts[parts.length - 1]; @@ -422,13 +417,10 @@ export class SqliteFS { const row = result[0]; const isDir = row.is_dir === 1; - // Calculate actual size for files (base64 is ~1.33x larger than binary) let size = 0; if (!isDir && row.data) { - // Approximate binary size from base64 length size = Math.floor(row.data.length * 0.75); } - const type: 'file' | 'dir' = isDir ? 'dir' : 'file'; const statResult = { type, @@ -491,7 +483,6 @@ export class SqliteFS { const exported: Array<{ path: string; data: Uint8Array }> = []; for (const obj of objects) { - // Decode base64 to binary const binaryString = atob(obj.data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { diff --git a/services/app-builder/src/git/git-clone-service.test.ts b/services/app-builder/src/git/git-clone-service.test.ts index b34add0711..3e7242a61f 100644 --- a/services/app-builder/src/git/git-clone-service.test.ts +++ b/services/app-builder/src/git/git-clone-service.test.ts @@ -66,10 +66,6 @@ function buildValidPackfile(blobContent: string): { packBytes: Uint8Array; blobO return { packBytes, blobOid }; } -// --------------------------------------------------------------------------- -// Push helper: uses GitReceivePackService to populate a MemFS with real objects -// --------------------------------------------------------------------------- - async function pushToRepo( fs: MemFS, refs: Array<{ oldOid: string; newOid: string; refName: string }>, @@ -82,10 +78,6 @@ async function pushToRepo( } } -// --------------------------------------------------------------------------- -// Parsing helpers for smart HTTP info/refs responses -// --------------------------------------------------------------------------- - type ParsedInfoRefs = { service: string; headOid: string; @@ -97,13 +89,11 @@ type ParsedInfoRefs = { function parseInfoRefsResponse(response: string): ParsedInfoRefs { let offset = 0; - // Read service announcement pkt-line const serviceHex = response.substring(offset, offset + 4); const serviceLen = parseInt(serviceHex, 16); const serviceLine = response.substring(offset + 4, offset + serviceLen); offset += serviceLen; - // Skip flush packet if (response.substring(offset, offset + 4) === '0000') { offset += 4; } @@ -161,7 +151,6 @@ function verifyPktLineLengths(response: string): void { throw new Error(`Invalid pkt-line hex at offset ${offset}: "${hex}"`); } - // The content after the hex prefix up to declaredLen bytes total const lineContent = response.substring(offset + 4, offset + declaredLen); const byteLen = encoder.encode(lineContent).length + 4; @@ -171,10 +160,6 @@ function verifyPktLineLengths(response: string): void { } } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('GitCloneService', () => { describe('handleInfoRefs', () => { it('advertises symref=HEAD:refs/heads/main when HEAD is symbolic', async () => { @@ -203,7 +188,6 @@ describe('GitCloneService', () => { packBytes ); - // Read the OID that refs/heads/main points to const mainOid = String( await fs.readFile('.git/refs/heads/main', { encoding: 'utf8' }) ).trim(); @@ -227,7 +211,6 @@ describe('GitCloneService', () => { packBytes ); - // Overwrite HEAD with an OID that doesn't match any branch await fs.writeFile('.git/HEAD', 'b'.repeat(40)); const response = await GitCloneService.handleInfoRefs(fs); @@ -270,7 +253,6 @@ describe('GitCloneService', () => { const response = await GitCloneService.handleInfoRefs(fs); const parsed = parseInfoRefsResponse(response); - // Read the OID from refs/heads/main const mainOid = String( await fs.readFile('.git/refs/heads/main', { encoding: 'utf8' }) ).trim(); @@ -292,7 +274,6 @@ describe('GitCloneService', () => { const response = await GitCloneService.handleInfoRefs(fs); - // Verify every pkt-line in the response has correct hex lengths verifyPktLineLengths(response); }); }); @@ -310,7 +291,6 @@ describe('GitCloneService', () => { const result = await GitCloneService.handleUploadPack(fs); - // First 8 bytes should be "0008NAK\n" const first8 = new TextDecoder().decode(result.slice(0, 8)); expect(first8).toBe('0008NAK\n'); }); diff --git a/services/app-builder/src/git/git-clone-service.ts b/services/app-builder/src/git/git-clone-service.ts index 862907d364..17ee899449 100644 --- a/services/app-builder/src/git/git-clone-service.ts +++ b/services/app-builder/src/git/git-clone-service.ts @@ -18,7 +18,6 @@ export class GitCloneService { const fs = new MemFS(); try { - // If no commits yet, create empty repo if (gitObjects.length === 0) { await git.init({ fs, dir: '/', defaultBranch: 'main' }); return fs; @@ -26,7 +25,6 @@ export class GitCloneService { await git.init({ fs, dir: '/', defaultBranch: 'main' }); - // Import all git objects for (const obj of gitObjects) { await fs.writeFile(obj.path, obj.data); } @@ -59,7 +57,6 @@ export class GitCloneService { branches = []; } - // Determine symref target for HEAD const symrefTarget = await resolveHeadSymref(fs, branches); // Git HTTP protocol: info/refs response format @@ -76,7 +73,6 @@ export class GitCloneService { const headLine = `${head} HEAD\0${capabilities}\n`; response += formatPacketLine(headLine); - // Branch refs for (const branch of branches) { try { const oid = await git.resolveRef({ @@ -90,7 +86,6 @@ export class GitCloneService { } } - // Flush packet response += '0000'; return response; @@ -108,7 +103,6 @@ export class GitCloneService { */ static async handleUploadPack(fs: MemFS): Promise { try { - // Collect objects from ALL branches const reachableObjects = new Set(); // Get all branches (same approach as handleInfoRefs to avoid hanging) @@ -121,13 +115,11 @@ export class GitCloneService { branches = []; } - // Collect objects from each branch for (const branch of branches) { try { const commits = await git.log({ fs, dir: '/', ref: branch }); for (const commit of commits) { - // Add commit OID reachableObjects.add(commit.oid); // Walk tree to get all blobs recursively (this also adds the tree OID) @@ -168,10 +160,8 @@ export class GitCloneService { // NAK packet: "0008NAK\n" const nakPacket = new Uint8Array([0x30, 0x30, 0x30, 0x38, 0x4e, 0x41, 0x4b, 0x0a]); - // Wrap packfile in sideband format const sideband = this.wrapInSideband(packfile); - // Concatenate NAK + sideband packfile const result = new Uint8Array(nakPacket.length + sideband.length); result.set(nakPacket, 0); result.set(sideband, nakPacket.length); @@ -205,7 +195,6 @@ export class GitCloneService { if (entry.type === 'tree') { await this.collectTreeObjects(fs, entry.oid, objects); } else { - // For blobs, add directly objects.add(entry.oid); } } diff --git a/services/app-builder/src/git/git-receive-pack-service.test.ts b/services/app-builder/src/git/git-receive-pack-service.test.ts index 7a5e3b50c4..73e77d7d4e 100644 --- a/services/app-builder/src/git/git-receive-pack-service.test.ts +++ b/services/app-builder/src/git/git-receive-pack-service.test.ts @@ -25,13 +25,10 @@ function buildPushRequest( chunks.push(encoder.encode(lengthHex + line)); } - // Flush packet chunks.push(encoder.encode('0000')); - // Append packfile bytes chunks.push(packfileBytes); - // Concatenate const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); const result = new Uint8Array(totalLength); let offset = 0; @@ -75,10 +72,8 @@ function buildValidPackfile(blobContent: string): { packBytes: Uint8Array; blobO throw new Error('buildValidPackfile: content too long for simple header'); const objHeader = Buffer.from([(3 << 4) | content.length]); - // Zlib-deflate the content const deflated = deflateSync(content); - // Assemble pack body (everything before the checksum) const packBody = Buffer.concat([header, objHeader, deflated]); // 20-byte SHA-1 checksum of the body @@ -244,7 +239,6 @@ describe('GitReceivePackService', () => { const fs = new MemFS(); const { packBytes, blobOid: _blobOid } = buildValidPackfile('hello'); - // Push with a ref pointing to an OID that does NOT exist in the pack const bogusOid = 'dead'.repeat(10); const requestData = buildPushRequest( [{ oldOid: zeroOid, newOid: bogusOid, refName: 'refs/heads/main' }], @@ -269,7 +263,6 @@ describe('GitReceivePackService', () => { await GitReceivePackService.handleReceivePack(fs, requestData); - // The ref should NOT exist — writing it would corrupt the repo await expect(fs.readFile('.git/refs/heads/main')).rejects.toThrow('ENOENT'); }); @@ -303,21 +296,17 @@ describe('GitReceivePackService', () => { const { result } = await GitReceivePackService.handleReceivePack(fs, requestData); - // The good ref should have been written const goodRef = await fs.readFile('.git/refs/heads/good', { encoding: 'utf8' }); expect(String(goodRef)).toContain(blobOid); - // The bad ref should NOT exist await expect(fs.readFile('.git/refs/heads/bad')).rejects.toThrow('ENOENT'); - // Should report an error for the bad ref expect(result.errors.some(e => e.includes('refs/heads/bad'))).toBe(true); }); it('allows ref update pointing to object from a previous push', async () => { const fs = new MemFS(); - // First push: index a valid packfile so its blob OID exists in the repo const { packBytes: firstPack, blobOid: existingOid } = buildValidPackfile('first'); const firstRequest = buildPushRequest( [{ oldOid: zeroOid, newOid: existingOid, refName: 'refs/heads/main' }], @@ -329,7 +318,6 @@ describe('GitReceivePackService', () => { ); expect(firstResult.success).toBe(true); - // Second push: a NEW packfile with a different blob, but one ref targets the old OID const { packBytes: secondPack, blobOid: newOid } = buildValidPackfile('second'); const secondRequest = buildPushRequest( [ @@ -354,7 +342,6 @@ describe('GitReceivePackService', () => { it('still rejects ref pointing to truly nonexistent object across pushes', async () => { const fs = new MemFS(); - // First push: seed the repo with one valid object const { packBytes, blobOid } = buildValidPackfile('seed'); const firstRequest = buildPushRequest( [{ oldOid: zeroOid, newOid: blobOid, refName: 'refs/heads/main' }], @@ -362,7 +349,6 @@ describe('GitReceivePackService', () => { ); await GitReceivePackService.handleReceivePack(fs, firstRequest); - // Second push: ref targets an OID that has never existed anywhere const { packBytes: secondPack } = buildValidPackfile('other'); const bogusOid = 'dead'.repeat(10); const secondRequest = buildPushRequest( @@ -466,11 +452,9 @@ describe('GitReceivePackService', () => { const { result } = await GitReceivePackService.handleReceivePack(fs, requestData); expect(result.success).toBe(true); - // HEAD should be a symbolic ref, not a detached OID const headContent = String(await fs.readFile('.git/HEAD', { encoding: 'utf8' })).trim(); expect(headContent).toBe('ref: refs/heads/main'); - // resolveRef should follow the symref to the branch OID const resolved = await git.resolveRef({ fs, dir: '/', ref: 'HEAD' }); expect(resolved).toBe(blobOid); }); @@ -479,7 +463,6 @@ describe('GitReceivePackService', () => { const fs = new MemFS(); const { packBytes: firstPack, blobOid: firstOid } = buildValidPackfile('first'); - // First push — sets HEAD const firstRequest = buildPushRequest( [{ oldOid: zeroOid, newOid: firstOid, refName: 'refs/heads/main' }], firstPack @@ -489,7 +472,6 @@ describe('GitReceivePackService', () => { const headAfterFirst = String(await fs.readFile('.git/HEAD', { encoding: 'utf8' })).trim(); expect(headAfterFirst).toBe('ref: refs/heads/main'); - // Second push — HEAD should remain symbolic, not be rewritten const { packBytes: secondPack, blobOid: secondOid } = buildValidPackfile('second'); const secondRequest = buildPushRequest( [{ oldOid: firstOid, newOid: secondOid, refName: 'refs/heads/main' }], @@ -500,7 +482,6 @@ describe('GitReceivePackService', () => { const headAfterSecond = String(await fs.readFile('.git/HEAD', { encoding: 'utf8' })).trim(); expect(headAfterSecond).toBe('ref: refs/heads/main'); - // HEAD should resolve to the new OID via the symref const resolved = await git.resolveRef({ fs, dir: '/', ref: 'HEAD' }); expect(resolved).toBe(secondOid); }); @@ -531,7 +512,6 @@ describe('GitReceivePackService', () => { await GitReceivePackService.handleReceivePack(fs, requestData); - // HEAD should not exist — no main/master was pushed await expect(fs.readFile('.git/HEAD')).rejects.toThrow('ENOENT'); }); }); @@ -600,7 +580,6 @@ describe('GitReceivePackService', () => { const status = parseReportStatus(response); expect(status.unpack).toBe('error'); - // Global error should apply to all refs expect(status.refs).toEqual([ { status: 'ng', refName: 'refs/heads/main', message: 'index failed' }, ]); @@ -636,13 +615,11 @@ describe('GitReceivePackService', () => { const response = GitReceivePackService.generateReportStatus(commands, errors); const status = parseReportStatus(response); - // Newlines should be collapsed to spaces expect(status.refs[0].status).toBe('ng'); expect(status.refs[0]).toHaveProperty( 'message', 'Packfile too large: 100KB exceeds 50KB limit. Try: 1. Push fewer files' ); - // The message must not contain newlines expect(status.refs[0]).toHaveProperty('message', expect.not.stringContaining('\n')); }); @@ -672,26 +649,21 @@ function parseReportStatus(data: Uint8Array): { unpack: string; refs: RefStatus[ let offset = 0; const lines: string[] = []; - // Read sideband packets until we hit the final flush (0000) while (offset < data.length) { const hexLen = decoder.decode(data.subarray(offset, offset + 4)); if (hexLen === '0000') { offset += 4; - // Could be the inner flush (inside sideband) or the outer flush. - // If we've already collected lines and the next 4 bytes are also 0000, that's the outer flush. continue; } const pktLen = parseInt(hexLen, 16); if (pktLen === 0 || isNaN(pktLen)) break; - // byte at offset+4 is the sideband band number const band = data[offset + 4]; const payload = data.subarray(offset + 5, offset + pktLen); if (band === 1) { // The payload is itself a pkt-line (or flush) const payloadStr = decoder.decode(payload); - // Could be a pkt-line "XXXX" or "0000" (inner flush) if (payloadStr === '0000') { offset += pktLen; continue; @@ -706,7 +678,6 @@ function parseReportStatus(data: Uint8Array): { unpack: string; refs: RefStatus[ offset += pktLen; } - // First line should be "unpack ok" or "unpack error" let unpack = 'unknown'; const refs: RefStatus[] = []; diff --git a/services/app-builder/src/git/git-receive-pack-service.ts b/services/app-builder/src/git/git-receive-pack-service.ts index a730a4ce08..7e6209099e 100644 --- a/services/app-builder/src/git/git-receive-pack-service.ts +++ b/services/app-builder/src/git/git-receive-pack-service.ts @@ -27,10 +27,8 @@ export class GitReceivePackService { */ static async handleInfoRefs(fs: MemFS): Promise { try { - // Build response with receive-pack service header let response = '001f# service=git-receive-pack\n0000'; - // Try to get HEAD ref let head: string | null = null; try { head = await git.resolveRef({ fs, dir: '/', ref: 'HEAD' }); @@ -38,7 +36,6 @@ export class GitReceivePackService { logger.warn('Failed to resolve HEAD (empty repo?)', formatError(err)); } - // Get branches from .git/refs/heads/ let branches: string[] = []; try { const headsDir = await fs.readdir('.git/refs/heads'); @@ -47,7 +44,6 @@ export class GitReceivePackService { logger.warn('Failed to list branches', formatError(err)); } - // Determine symref target for HEAD const symrefTarget = await resolveHeadSymref(fs, branches); // Capabilities for receive-pack (symref first per convention) @@ -64,11 +60,9 @@ export class GitReceivePackService { ].join(' '); if (head && branches.length > 0) { - // Existing repo with refs const headLine = `${head} HEAD\0${capabilities}\n`; response += formatPacketLine(headLine); - // Add branch refs for (const branch of branches) { try { const oid = await git.resolveRef({ @@ -88,7 +82,6 @@ export class GitReceivePackService { response += formatPacketLine(emptyLine); } - // Flush packet response += '0000'; return response; @@ -128,20 +121,16 @@ export class GitReceivePackService { break; } - // Read packet content const packetData = data.slice(offset + 4, offset + length); const packetText = textDecoder.decode(packetData).trim(); - // Skip capabilities line (contains NUL byte) if (packetText.includes('\0')) { - // Parse command before capabilities const commandPart = packetText.split('\0')[0]; const command = this.parseRefUpdateCommand(commandPart); if (command) { commands.push(command); } } else { - // Regular ref update command const command = this.parseRefUpdateCommand(packetText); if (command) { commands.push(command); @@ -168,7 +157,6 @@ export class GitReceivePackService { if (!oldOid || !newOid || !refName) return null; - // Validate OID format (40 hex chars) if (oldOid.length !== 40 || newOid.length !== 40) return null; return { oldOid, newOid, refName }; @@ -191,12 +179,10 @@ export class GitReceivePackService { const reportErrors: ReceivePackError[] = []; try { - // Parse pkt-line commands and find packfile const { commands, packfileStart } = this.parsePktLines(requestData); result.refUpdates = commands; let indexedOids: Set | undefined; - // Extract packfile data (skip PACK header check, pass all remaining data) if (packfileStart < requestData.length) { const packfileData = requestData.slice(packfileStart); @@ -204,12 +190,11 @@ export class GitReceivePackService { let packStart = 0; for (let i = 0; i < Math.min(packfileData.length - 4, 100); i++) { if ( - packfileData[i] === 0x50 && // P - packfileData[i + 1] === 0x41 && // A - packfileData[i + 2] === 0x43 && // C + packfileData[i] === 0x50 && + packfileData[i + 1] === 0x41 && + packfileData[i + 2] === 0x43 && packfileData[i + 3] === 0x4b ) { - // K packStart = i; break; } @@ -238,20 +223,15 @@ export class GitReceivePackService { result.errors.push(errorMsg); result.success = false; - // Return error response immediately - DO NOT index or update refs const response = this.generateReportStatus(commands, [ { kind: 'global', message: errorMsg }, ]); return { response, result }; } - // IMPORTANT: Write the packfile to the filesystem BEFORE calling indexPack - // indexPack reads from this path, so it must exist first! - // Use a unique name for each pack file to avoid overwriting previous packs const packId = `pack-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; const packPath = `.git/objects/pack/${packId}.pack`; - // Ensure the pack directory exists try { await fs.mkdir('.git/objects/pack', { recursive: true }); } catch (_err) { @@ -260,7 +240,6 @@ export class GitReceivePackService { await fs.writeFile(packPath, actualPackfile); - // Use isomorphic-git to index the packfile try { const indexResult = await git.indexPack({ fs, @@ -298,12 +277,9 @@ export class GitReceivePackService { } } - // Don't silently continue - this is a critical error - // Mark as failed and DON'T proceed with ref updates const indexErrorMsg = `Failed to index packfile: ${errorMessage}`; result.errors.push(indexErrorMsg); - // Return error response immediately - DO NOT apply refs with corrupt objects result.success = false; const response = this.generateReportStatus(commands, [ { kind: 'global', message: indexErrorMsg }, @@ -319,12 +295,10 @@ export class GitReceivePackService { for (const cmd of commands) { try { if (cmd.newOid === zeroOid) { - // Delete ref await git.deleteRef({ fs, dir: '/', ref: cmd.refName }); continue; } - // Validate the target object exists somewhere in the repo if (indexedOids && !indexedOids.has(cmd.newOid)) { let objectExists = false; try { @@ -346,7 +320,6 @@ export class GitReceivePackService { } } - // Create or update ref await git.writeRef({ fs, dir: '/', @@ -424,11 +397,9 @@ export class GitReceivePackService { const unpackStatus = globalError ? 'unpack error\n' : 'unpack ok\n'; chunks.push(this.createSidebandPacket(1, encoder.encode(formatPacketLine(unpackStatus)))); - // Ref statuses for (const cmd of commands) { let status: string; if (globalError) { - // Global failure applies to all refs status = `ng ${cmd.refName} ${sanitizeStatusMessage(globalError.message)}\n`; } else { const refError = errors.find(e => e.kind === 'ref' && e.refName === cmd.refName); @@ -442,10 +413,8 @@ export class GitReceivePackService { // Flush packet for sideband chunks.push(this.createSidebandPacket(1, encoder.encode('0000'))); - // Final flush packet chunks.push(encoder.encode('0000')); - // Concatenate all chunks const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const result = new Uint8Array(totalLength); let offset = 0; @@ -461,19 +430,16 @@ export class GitReceivePackService { * Create a sideband packet */ private static createSidebandPacket(band: number, data: Uint8Array): Uint8Array { - const length = 4 + 1 + data.length; // length header + band byte + data + const length = 4 + 1 + data.length; const lengthHex = length.toString(16).padStart(4, '0'); const packet = new Uint8Array(length); - // Write length for (let i = 0; i < 4; i++) { packet[i] = lengthHex.charCodeAt(i); } - // Write band number packet[4] = band; - // Write data packet.set(data, 5); return packet; diff --git a/services/app-builder/src/git/memfs.ts b/services/app-builder/src/git/memfs.ts index 36fb8c2a25..a92012a6ab 100644 --- a/services/app-builder/src/git/memfs.ts +++ b/services/app-builder/src/git/memfs.ts @@ -78,7 +78,6 @@ export class MemFS { }> { const normalized = path.startsWith('/') ? path.slice(1) : path; - // Check if it's a file const data = this.files.get(normalized); if (data) { return { @@ -99,7 +98,6 @@ export class MemFS { }; } - // Check if it's a directory (has children) const prefix = normalized ? normalized + '/' : ''; for (const filePath of this.files.keys()) { if (filePath.startsWith(prefix)) { diff --git a/services/app-builder/src/handlers/delete.ts b/services/app-builder/src/handlers/delete.ts index 576b876d4c..d00f6657bf 100644 --- a/services/app-builder/src/handlers/delete.ts +++ b/services/app-builder/src/handlers/delete.ts @@ -3,7 +3,6 @@ import { verifyBearerToken } from '../utils/auth'; import { logger, formatError } from '../utils/logger'; export async function handleDelete(request: Request, env: Env, appId: string): Promise { - // Verify server-to-server auth token const authResult = verifyBearerToken(request, env); if (!authResult.isAuthenticated) { if (!authResult.errorResponse) { @@ -15,12 +14,10 @@ export async function handleDelete(request: Request, env: Env, appId: string): P logger.info('Deleting app'); try { - // Delete git repository data const gitId = env.GIT_REPOSITORY.idFromName(appId); const gitStub = env.GIT_REPOSITORY.get(gitId); await gitStub.deleteAll(); - // Delete preview data and sandbox const previewId = env.PREVIEW.idFromName(appId); const previewStub = env.PREVIEW.get(previewId); await previewStub.deleteAll(); diff --git a/services/app-builder/src/handlers/git-protocol.ts b/services/app-builder/src/handlers/git-protocol.ts index 282b721de6..9caee5f56a 100644 --- a/services/app-builder/src/handlers/git-protocol.ts +++ b/services/app-builder/src/handlers/git-protocol.ts @@ -68,22 +68,16 @@ async function handleInfoRefs( repoStub: DurableObjectStub ): Promise { try { - // Determine which service is requested const url = new URL(request.url); const service = url.searchParams.get('service'); const isReceivePack = service === 'git-receive-pack'; - // Check if repository is initialized (RPC call) const isInitialized = await repoStub.isInitialized(); - // For receive-pack on empty repo, we need to advertise capabilities - // For upload-pack on empty repo, return empty advertisement if (!isInitialized) { if (isReceivePack) { - // For receive-pack, initialize the repo first and return empty refs with capabilities (RPC call) await repoStub.initialize(); - // Return empty repo advertisement for receive-pack const capabilities = 'report-status report-status-v2 delete-refs side-band-64k quiet atomic ofs-delta agent=git/isomorphic-git'; const zeroOid = '0000000000000000000000000000000000000000'; @@ -103,17 +97,14 @@ async function handleInfoRefs( } } - // Get git objects from DO (RPC call) const gitObjects = await repoStub.exportGitObjects(); - // Convert base64 data back to Uint8Array const processedObjects = gitObjects.map(obj => ({ path: obj.path, data: Uint8Array.from(atob(obj.data), c => c.charCodeAt(0)), })); if (processedObjects.length === 0 && !isReceivePack) { - // Return empty advertisement for repos with no commits (upload-pack only) return new Response('001e# service=git-upload-pack\n0000', { status: 200, headers: { @@ -123,13 +114,11 @@ async function handleInfoRefs( }); } - // Build repository in worker const repoFS = await GitCloneService.buildRepository({ gitObjects: processedObjects, }); if (isReceivePack) { - // Generate receive-pack info/refs response const response = await GitReceivePackService.handleInfoRefs(repoFS); return new Response(response, { status: 200, @@ -139,7 +128,6 @@ async function handleInfoRefs( }, }); } else { - // Generate upload-pack info/refs response const response = await GitCloneService.handleInfoRefs(repoFS); return new Response(response, { status: 200, @@ -161,17 +149,14 @@ async function handleInfoRefs( */ async function handleUploadPack(repoStub: DurableObjectStub): Promise { try { - // Check if repository is initialized (RPC call) const isInitialized = await repoStub.isInitialized(); if (!isInitialized) { return new Response('Repository not found', { status: 404 }); } - // Get git objects from DO (RPC call) const gitObjects = await repoStub.exportGitObjects(); - // Convert base64 data back to Uint8Array const processedObjects = gitObjects.map(obj => ({ path: obj.path, data: Uint8Array.from(atob(obj.data), c => c.charCodeAt(0)), @@ -181,12 +166,10 @@ async function handleUploadPack(repoStub: DurableObjectStub): P return new Response('No commits to pack', { status: 404 }); } - // Build repository in worker const repoFS = await GitCloneService.buildRepository({ gitObjects: processedObjects, }); - // Generate packfile with full commit history const packfile = await GitCloneService.handleUploadPack(repoFS); return new Response(packfile, { status: 200, @@ -213,34 +196,27 @@ async function handleReceivePack( repoId: string ): Promise { try { - // Check if repository is initialized (RPC call) const isInitialized = await repoStub.isInitialized(); - // For push to new repo, initialize first (RPC call) if (!isInitialized) { await repoStub.initialize(); } - // Get existing git objects from DO (may be empty for new repo) (RPC call) const existingObjects = await repoStub.exportGitObjects(); - // Convert base64 data back to Uint8Array const processedObjects = existingObjects.map(obj => ({ path: obj.path, data: Uint8Array.from(atob(obj.data), c => c.charCodeAt(0)), })); - // Build repository in worker with existing objects const repoFS = await GitCloneService.buildRepository({ gitObjects: processedObjects, }); - // Read request body (packfile data from client) const requestData = new Uint8Array(await request.arrayBuffer()); logger.debug('Received push data', { dataLength: requestData.length }); - // Process the receive-pack request const { response, result } = await GitReceivePackService.handleReceivePack(repoFS, requestData); logger.info('Push processed', { @@ -249,12 +225,10 @@ async function handleReceivePack( hasErrors: result.errors.length > 0, }); - // If successful, persist the updated git objects back to DO // CRITICAL: We must persist objects BEFORE returning success to avoid corrupt state if (result.success && result.refUpdates.length > 0) { const updatedObjects = GitReceivePackService.exportGitObjects(repoFS); - // PRE-VALIDATE: Check all object sizes before attempting persistence let oversizedObject: { path: string; size: number } | null = null as { path: string; size: number; @@ -263,7 +237,6 @@ async function handleReceivePack( const objectsToSave = updatedObjects.map(obj => { const binarySize = obj.data.length; - // Check if this object exceeds the safe size if (binarySize > MAX_OBJECT_SIZE && !oversizedObject) { oversizedObject = { path: obj.path, size: binarySize }; } @@ -300,7 +273,6 @@ async function handleReceivePack( logger.debug('Persisting updated git objects', { objectCount: updatedObjects.length }); - // Send updated objects to DO for persistence (RPC call) try { await repoStub.importGitObjects(objectsToSave); } catch (persistError) { @@ -318,8 +290,6 @@ async function handleReceivePack( }); } - // Get the new HEAD commit from the ref updates - // Look for main or master branch updates const mainBranchUpdate = result.refUpdates.find( u => u.refName === 'refs/heads/main' || u.refName === 'refs/heads/master' ); @@ -327,7 +297,6 @@ async function handleReceivePack( if (mainBranchUpdate && mainBranchUpdate.newOid) { const commitHash = mainBranchUpdate.newOid; - // Trigger preview build if PREVIEW DO is available if (env.PREVIEW) { logger.setTags({ commitHash }); logger.debug('Triggering preview build', { refName: mainBranchUpdate.refName }); @@ -337,13 +306,11 @@ async function handleReceivePack( ctx.waitUntil( previewStub.triggerBuild().catch(error => { - // Log error but don't fail the push logger.error('Failed to trigger preview build', formatError(error)); }) ); } - // Notify backend of push (fire-and-forget) ctx.waitUntil( notifyBackendOfPush(env, { repoId, @@ -378,7 +345,6 @@ export async function handleGitProtocolRequest( const url = new URL(request.url); const pathname = url.pathname; - // Extract repository ID const repoId = extractRepoId(pathname); if (!repoId) { return new Response('Invalid Git URL', { status: 400 }); @@ -387,7 +353,6 @@ export async function handleGitProtocolRequest( // Get repository stub (needed for legacy auth fallback) const repoStub = getRepositoryStub(env, repoId); - // Verify auth with hybrid JWT + legacy token support const authResult = await verifyGitAuth(request, repoId, env.GIT_JWT_SECRET, token => repoStub.verifyAuthToken(token) ); @@ -395,12 +360,10 @@ export async function handleGitProtocolRequest( return authResult.errorResponse; } - // Check permissions for receive-pack (push) - requires 'full' permission if (GIT_RECEIVE_PACK_PATTERN.test(pathname) && authResult.permission !== 'full') { return new Response('Forbidden: Write access required', { status: 403 }); } - // Route to appropriate handler (auth already verified) if (GIT_INFO_REFS_PATTERN.test(pathname)) { return handleInfoRefs(request, repoStub); } else if (GIT_UPLOAD_PACK_PATTERN.test(pathname)) { diff --git a/services/app-builder/src/handlers/init.ts b/services/app-builder/src/handlers/init.ts index 35728aff69..3204653200 100644 --- a/services/app-builder/src/handlers/init.ts +++ b/services/app-builder/src/handlers/init.ts @@ -21,7 +21,6 @@ async function extractTar(tarStream: ReadableStream): Promise = {}; const reader = tarStream.getReader(); - // Collect all data from the stream const chunks: Uint8Array[] = []; while (true) { const { done, value } = await reader.read(); @@ -29,7 +28,6 @@ async function extractTar(tarStream: ReadableStream): Promise sum + chunk.length, 0); const buffer = new Uint8Array(totalLength); let offset = 0; @@ -38,12 +36,10 @@ async function extractTar(tarStream: ReadableStream): Promise): Promise): Promise tags) if (directiveMap.has('script-src-elem')) { const current = directiveMap.get('script-src-elem') ?? ''; directiveMap.set('script-src-elem', addNonceToDirective(current, nonceValue)); } - // Reconstruct CSP string const result: string[] = []; for (const [name, value] of directiveMap) { result.push(value ? `${name} ${value}` : name); @@ -134,7 +130,6 @@ export async function handleGetPreviewStatus( appId: string ): Promise { try { - // 1. Verify Bearer token authentication const authResult = verifyBearerToken(request, env); if (!authResult.isAuthenticated) { if (!authResult.errorResponse) { @@ -186,7 +181,6 @@ export async function handleTriggerBuild( appId: string ): Promise { try { - // 1. Verify Bearer token authentication const authResult = verifyBearerToken(request, env); if (!authResult.isAuthenticated) { if (!authResult.errorResponse) { @@ -222,7 +216,6 @@ export async function handleStreamBuildLogs( appId: string ): Promise { try { - // 1. Verify Bearer token authentication const authResult = verifyBearerToken(request, env); if (!authResult.isAuthenticated) { if (!authResult.errorResponse) { @@ -247,7 +240,6 @@ export async function handleStreamBuildLogs( ); } - // Return the stream as Server-Sent Events return new Response(logStream, { headers: { 'Content-Type': 'text/event-stream', @@ -278,7 +270,6 @@ export async function handlePreviewProxy( try { const sandbox = getSandbox(env.SANDBOX, appId); - // Detect WebSocket upgrade request const upgradeHeader = request.headers.get('Upgrade'); const isWebSocket = upgradeHeader?.toLowerCase() === 'websocket'; @@ -291,7 +282,6 @@ export async function handlePreviewProxy( proxyUrl.protocol = 'http:'; if (isWebSocket) { - // WebSocket: Use sandbox.fetch() with switchPort const wsRequest = new Request(proxyUrl, request); try { const response = await sandbox.fetch(switchPort(wsRequest, port)); @@ -311,7 +301,6 @@ export async function handlePreviewProxy( } } - // Regular HTTP: Use containerFetch const clonedRequest = request.clone(); const proxyRequest = new Request(proxyUrl, { method: clonedRequest.method, @@ -321,7 +310,6 @@ export async function handlePreviewProxy( duplex: 'half', }); - // Add forwarding headers proxyRequest.headers.set('X-Original-URL', request.url); proxyRequest.headers.set('X-Forwarded-Host', url.hostname); proxyRequest.headers.set('X-Forwarded-Proto', url.protocol.replace(':', '')); @@ -334,7 +322,6 @@ export async function handlePreviewProxy( // Uses HTMLRewriter for streaming transformation (avoids buffering entire response) const contentType = response.headers.get('content-type'); if (contentType?.includes('text/html')) { - // Generate a base64 nonce for CSP-safe script injection const nonce = generateCSPNonce(); const bridgeScript = getPreviewBridgeScript(nonce); @@ -342,7 +329,6 @@ export async function handlePreviewProxy( newHeaders.delete('content-length'); newHeaders.delete('content-encoding'); - // Modify CSP headers to allow our nonced script const csp = response.headers.get('content-security-policy'); if (csp) { newHeaders.set('content-security-policy', addNonceToCSP(csp, nonce)); @@ -355,7 +341,6 @@ export async function handlePreviewProxy( ); } - // Track whether we've injected the script (only inject once) let injected = false; const rewriter = new HTMLRewriter() diff --git a/services/app-builder/src/index.ts b/services/app-builder/src/index.ts index f8e14957c0..65ed39c1ef 100644 --- a/services/app-builder/src/index.ts +++ b/services/app-builder/src/index.ts @@ -16,10 +16,8 @@ import { } from './handlers/preview'; import { logger, withLogTags } from './utils/logger'; -// Export Durable Objects export { GitRepositoryDO, PreviewDO, Sandbox }; -// Route patterns const APP_ID_PATTERN_STR = '[a-z0-9_-]{20,}'; const APP_ID_PATTERN = new RegExp(`^${APP_ID_PATTERN_STR}$`); const INIT_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/init$`); @@ -31,14 +29,12 @@ const BUILD_LOGS_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/build/logs const MIGRATE_TO_GITHUB_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/migrate-to-github$`); const DELETE_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})$`); -// Dev Mode let previewAppId: string | null = null; /** * Extract app ID from subdomain if hostname matches app-id.BUILDER_HOSTNAME pattern */ function extractAppIdFromSubdomain(hostname: string, builderHostname: string): string | null { - // Match pattern: app-id.BUILDER_HOSTNAME if (!builderHostname) return null; const suffix = `.${builderHostname}`; @@ -46,7 +42,6 @@ function extractAppIdFromSubdomain(hostname: string, builderHostname: string): s const appId = hostname.slice(0, -suffix.length); - // Validate appId matches the expected pattern if (!appId || !APP_ID_PATTERN.test(appId)) { return null; } @@ -93,7 +88,6 @@ export default { const url = new URL(request.url); const pathname = url.pathname; - // Extract appId from subdomain or path for logging context const subdomainAppId = extractAppIdFromSubdomain(url.hostname, env.BUILDER_HOSTNAME); const pathAppId = extractAppIdFromPath(pathname); const appId = subdomainAppId ?? pathAppId; @@ -122,25 +116,21 @@ export default { return allowedOrigin ? withCorsHeaders(response, allowedOrigin) : response; } - // Handle init requests const initMatch = pathname.match(INIT_PATTERN); if (initMatch && request.method === 'POST') { return handleInit(request, env, initMatch[1]); } - // Handle token generation requests (POST /apps/{app_id}/token) const tokenMatch = pathname.match(TOKEN_PATTERN); if (tokenMatch && request.method === 'POST') { return handleGenerateToken(request, env, tokenMatch[1]); } - // Handle commit hash requests (GET /apps/{app_id}/commit) const commitMatch = pathname.match(COMMIT_PATTERN); if (commitMatch && request.method === 'GET') { return handleGetCommit(request, env, commitMatch[1]); } - // Handle preview status requests (GET /apps/{app_id}/preview) const previewStatusMatch = pathname.match(PREVIEW_STATUS_PATTERN); if (previewStatusMatch && request.method === 'GET') { const matchedAppId = previewStatusMatch[1]; @@ -150,31 +140,26 @@ export default { return handleGetPreviewStatus(request, env, matchedAppId); } - // Handle build trigger requests (POST /apps/{app_id}/build) const buildTriggerMatch = pathname.match(BUILD_TRIGGER_PATTERN); if (buildTriggerMatch && request.method === 'POST') { return handleTriggerBuild(request, env, buildTriggerMatch[1]); } - // Handle build logs streaming requests (GET /apps/{app_id}/build/logs) const buildLogsMatch = pathname.match(BUILD_LOGS_PATTERN); if (buildLogsMatch && request.method === 'GET') { return handleStreamBuildLogs(request, env, buildLogsMatch[1]); } - // Handle migrate to GitHub requests (POST /apps/{app_id}/migrate-to-github) const migrateToGithubMatch = pathname.match(MIGRATE_TO_GITHUB_PATTERN); if (migrateToGithubMatch && request.method === 'POST') { return handleMigrateToGithub(request, env, migrateToGithubMatch[1]); } - // Handle delete requests (DELETE /apps/{app_id}) const deleteMatch = pathname.match(DELETE_PATTERN); if (deleteMatch && request.method === 'DELETE') { return handleDelete(request, env, deleteMatch[1]); } - // Handle git protocol requests if (isGitProtocolRequest(pathname)) { return handleGitProtocolRequest(request, env, ctx); } diff --git a/services/app-builder/src/preview-do.ts b/services/app-builder/src/preview-do.ts index 6c0f9d67ab..e8212a0cb9 100644 --- a/services/app-builder/src/preview-do.ts +++ b/services/app-builder/src/preview-do.ts @@ -15,7 +15,6 @@ import { logger, withLogTags, formatError } from './utils/logger'; import { signGitToken } from './utils/jwt'; import { createDBProvisioner, DBProvisionResult } from './db-provisioner'; -/** Process ID prefix for dev server - combined with appId for unique identification */ const DEV_SERVER_PROCESS_PREFIX = 'bun-dev-'; export class PreviewDO extends DurableObject { @@ -26,7 +25,6 @@ export class PreviewDO extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); - // Initialize default persisted state - explicitly uninitialized this.persistedState = { appId: null, lastError: null, @@ -34,7 +32,6 @@ export class PreviewDO extends DurableObject { githubSource: null, }; - // Restore persisted state from storage if available void this.ctx.blockConcurrencyWhile(async () => { const stored = await this.ctx.storage.get('state'); if (stored) { @@ -121,14 +118,12 @@ export class PreviewDO extends DurableObject { * Finds active process by prefix using listProcesses */ private async getSandboxState(): Promise { - // No appId = uninitialized if (!this.persistedState.appId) { return 'uninitialized'; } const sandbox = getSandbox(this.env.SANDBOX, this.persistedState.appId); - // Try to find the dev server process const process = await this.getDevProcess(sandbox); if (process) { @@ -160,7 +155,6 @@ export class PreviewDO extends DurableObject { const portOpen = await this.isPortOpen(sandbox); return portOpen ? 'running' : 'building'; } catch { - // If port check fails, assume building return 'building'; } @@ -171,16 +165,13 @@ export class PreviewDO extends DurableObject { } } - // No process found - check for orphan process (something running on port) try { const portOpen = await this.isPortOpen(sandbox); if (portOpen) { - // There's something running but we can't access by processId logger.warn('Orphan process detected on port'); return 'running'; } } catch (error) { - // Port check failed - sandbox likely doesn't exist, return idle logger.debug('Port check failed, sandbox may not exist', { error: error instanceof Error ? error.message : 'Unknown error', }); @@ -199,11 +190,9 @@ export class PreviewDO extends DurableObject { * access for the internal App Builder git server. */ private async getRepoUrl(repoId: string): Promise { - // Check if project is migrated to GitHub if (this.persistedState.githubSource) { const { githubRepo, userId, orgId } = this.persistedState.githubSource; - // Get fresh token from git-token-service const result: GetTokenForRepoResult = await this.env.GIT_TOKEN_SERVICE.getTokenForRepo({ githubRepo, userId, @@ -220,7 +209,6 @@ export class PreviewDO extends DurableObject { return url.toString(); } - // Internal App Builder repository (not migrated) const authToken = signGitToken(repoId, 'ro', this.env.GIT_JWT_SECRET); const hostname = this.env.BUILDER_HOSTNAME; const baseUrl = `https://x-access-token:${authToken}@${hostname}`; @@ -236,7 +224,6 @@ export class PreviewDO extends DurableObject { for await (const event of parseSSEStream(stream)) { const data = stripVTControlCharacters(event.data || '').trim(); - // Log output for debugging (verbose, use debug level) if (data) { logger.debug('Sandbox output', { data }); } @@ -255,19 +242,13 @@ export class PreviewDO extends DurableObject { * Check if the default port is listening using ss (socket statistics) */ private async isPortOpen(sandbox: ReturnType): Promise { - // Use ss to check if something is listening on the port const portCheck = await sandbox.exec(`ss -tln | grep -q ':${DEFAULT_SANDBOX_PORT} '`); - // If grep found a match (exit code 0), the port is listening const isOpen = portCheck.exitCode === 0; return isOpen; } - // ============================================ - // RPC Methods (Public API) - // ============================================ - async initWithAppId(appId: string): Promise { return withLogTags({ source: 'PreviewDO', tags: { appId } }, async () => { logger.info('Initializing PreviewDO'); @@ -374,12 +355,10 @@ export class PreviewDO extends DurableObject { throw new Error('App ID not set'); } - // Clear any previous error state await this.clearErrorState(); const sandbox = getSandbox(this.env.SANDBOX, appId); - // Check if repository already exists const checkResult = await sandbox.exec('test -d /workspace/.git'); const repoExists = checkResult.exitCode === 0; @@ -430,7 +409,6 @@ export class PreviewDO extends DurableObject { }); const dbResult = await dbProvisioner.provisionIfNeeded(sandbox, appId); - // Check if there's already a process running const activeProcess = await this.getDevProcess(sandbox); logger.debug('Checking if dev server is already running', { hasActiveProcess: !!activeProcess, @@ -454,19 +432,16 @@ export class PreviewDO extends DurableObject { stderr: pkillResult.stderr, }); } - // Continue to start a new dev server process below } else { return; } } - // Generate a new process ID with random suffix const processId = this.generateProcessId(); if (!processId) { throw new Error('Failed to generate dev server process ID'); } - // Build environment variables for dev server const env: Record = { PORT: String(DEFAULT_SANDBOX_PORT) }; if (this.persistedState.dbCredentials) { env.DB_URL = this.persistedState.dbCredentials.url; @@ -509,7 +484,6 @@ export class PreviewDO extends DurableObject { try { const sandbox = getSandbox(this.env.SANDBOX, this.persistedState.appId); - // Find the active dev server process const process = await this.getDevProcess(sandbox); if (!process) { return null; @@ -540,7 +514,6 @@ export class PreviewDO extends DurableObject { const sandbox = getSandbox(this.env.SANDBOX, this.persistedState.appId); await sandbox.destroy(); } catch (error) { - // Log but don't fail if sandbox cleanup fails logger.error('Failed to destroy sandbox', formatError(error)); } } @@ -557,13 +530,10 @@ export class PreviewDO extends DurableObject { async () => { logger.info('Deleting all preview data'); - // First destroy the sandbox container if it exists await this.destroy(); - // Clear all persisted state await this.ctx.storage.deleteAll(); - // Reset in-memory state this.persistedState = { appId: null, lastError: null, diff --git a/services/app-builder/src/types.ts b/services/app-builder/src/types.ts index 8c4774bfcc..2ea9001ab8 100644 --- a/services/app-builder/src/types.ts +++ b/services/app-builder/src/types.ts @@ -1,8 +1,3 @@ -// src/types.ts - -// ============================================ -// Environment Types -// ============================================ import type { Sandbox } from '@cloudflare/sandbox'; export const DEFAULT_SANDBOX_PORT = 8080; @@ -65,9 +60,6 @@ export interface Env extends Omit { GIT_TOKEN_SERVICE: GitTokenService; } -// ============================================ -// Git Repository Types -// ============================================ export interface GitObject { path: string; data: string; @@ -80,9 +72,6 @@ export interface RepositoryStats { initialized: boolean; } -// ============================================ -// Git Service Types -// ============================================ export type RepositoryBuildOptions = { gitObjects: Array<{ path: string; data: Uint8Array }>; }; @@ -99,9 +88,6 @@ export interface ReceivePackResult { errors: string[]; } -// ============================================ -// Filesystem Error Types (Node.js-compatible for isomorphic-git) -// ============================================ /** * Error interface compatible with Node.js ErrnoException * Used by isomorphic-git for filesystem error handling @@ -113,10 +99,6 @@ export interface ErrnoException extends Error { syscall?: string; } -// ============================================ -// Preview Types -// ============================================ - /** * Possible states for a preview sandbox * - uninitialized: DO not configured with appId yet diff --git a/services/app-builder/src/utils/auth.ts b/services/app-builder/src/utils/auth.ts index 13082abf2e..7c31cf21e6 100644 --- a/services/app-builder/src/utils/auth.ts +++ b/services/app-builder/src/utils/auth.ts @@ -32,7 +32,6 @@ export function verifyBearerToken(request: Request, env: Env): AuthResult { }; } - // Extract token by removing "Bearer " prefix const token = authHeader.slice(7); if (!env.AUTH_TOKEN || token !== env.AUTH_TOKEN) { @@ -52,7 +51,6 @@ export function verifyBearerToken(request: Request, env: Env): AuthResult { }; } - // Authentication successful return { isAuthenticated: true, errorResponse: null, @@ -80,7 +78,6 @@ export async function verifyGitAuthJWT( }; } - // Decode Base64 credentials const base64Credentials = authHeader.slice(6); let credentials: string; try { @@ -97,7 +94,6 @@ export async function verifyGitAuthJWT( const [username, password] = credentials.split(':'); - // Verify username is x-access-token if (username !== 'x-access-token') { return { isAuthenticated: false, @@ -108,7 +104,6 @@ export async function verifyGitAuthJWT( }; } - // Verify JWT token const jwtResult = verifyGitToken(password, repoId, jwtSecret); if (jwtResult.valid === false) { return { @@ -149,7 +144,6 @@ export async function verifyGitAuth( }; } - // Decode Base64 credentials const base64Credentials = authHeader.slice(6); let credentials: string; try { @@ -166,7 +160,6 @@ export async function verifyGitAuth( const [username, password] = credentials.split(':'); - // Verify username is x-access-token if (username !== 'x-access-token') { return { isAuthenticated: false, @@ -177,7 +170,6 @@ export async function verifyGitAuth( }; } - // Try JWT verification first (new method) const jwtResult = verifyGitToken(password, repoId, jwtSecret); if (jwtResult.valid === true) { return { @@ -186,7 +178,6 @@ export async function verifyGitAuth( }; } - // Fall back to legacy token verification const isValidLegacy = await verifyLegacyToken(password); if (isValidLegacy) { // Legacy tokens grant full access @@ -196,7 +187,6 @@ export async function verifyGitAuth( }; } - // Both methods failed return { isAuthenticated: false, errorResponse: new Response('Unauthorized: Invalid token', { diff --git a/services/app-builder/src/utils/push-notification.ts b/services/app-builder/src/utils/push-notification.ts index 86ad862128..b6b063a1b9 100644 --- a/services/app-builder/src/utils/push-notification.ts +++ b/services/app-builder/src/utils/push-notification.ts @@ -14,20 +14,17 @@ export type PushNotificationParams = { export async function notifyBackendOfPush(env: Env, params: PushNotificationParams): Promise { const { repoId, commitHash, branch } = params; - // Check if push notification URL is configured if (!env.BACKEND_PUSH_NOTIFICATION_URL) { logger.debug('Push notification skipped - BACKEND_PUSH_NOTIFICATION_URL not configured'); return; } - // Construct gitUrl from repoId using BUILDER_HOSTNAME const gitUrl = `https://${env.BUILDER_HOSTNAME}/apps/${repoId}.git`; const headers: Record = { 'Content-Type': 'application/json', }; - // Add auth token if configured if (env.AUTH_TOKEN) { headers['Authorization'] = `Bearer ${env.AUTH_TOKEN}`; }