From e60c96823d45e89c2d8dea6e0ab7cb280dccd57a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:10:16 -0800 Subject: [PATCH] Add hook to setup repo when agent starts Fixes #5605 --- .github/hooks/setupRepo.json | 10 +++ bin/agent/setup-repo.mjs | 138 +++++++++++++++++++++++++++++++++++ package.json | 3 +- 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 .github/hooks/setupRepo.json create mode 100644 bin/agent/setup-repo.mjs diff --git a/.github/hooks/setupRepo.json b/.github/hooks/setupRepo.json new file mode 100644 index 00000000..5dd0c656 --- /dev/null +++ b/.github/hooks/setupRepo.json @@ -0,0 +1,10 @@ +{ + "hooks": { + "SessionStart": [ + { + "type": "command", + "command": "npm run agent:setup-repo" + } + ] + } +} \ No newline at end of file diff --git a/bin/agent/setup-repo.mjs b/bin/agent/setup-repo.mjs new file mode 100644 index 00000000..2db85d87 --- /dev/null +++ b/bin/agent/setup-repo.mjs @@ -0,0 +1,138 @@ +// @ts-check + +import { cpSync, existsSync, lstatSync, readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { basename, dirname, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const nodeModulesPath = resolve(repoRoot, 'node_modules'); +const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +/** @typedef {{ folder: string; reason: string }} Candidate */ + +/** @param {string} message */ +function log(message) { + console.info(`[setup-fast] ${message}`); +} + +/** @param {string[]} args */ +function runNpm(args) { + log(`Running: npm ${args.join(' ')}`); + const result = spawnSync(npmExecutable, args, { + cwd: repoRoot, + stdio: 'inherit' + }); + if (result.error) { + throw result.error; + } + if ((result.status ?? 1) !== 0) { + process.exit(result.status ?? 1); + } +} + +/** + * @param {Candidate[]} candidates + * @param {string | undefined} folder + * @param {string} reason + */ +function addCandidate(candidates, folder, reason) { + if (!folder) { + return; + } + if (candidates.some(candidate => candidate.folder === folder)) { + return; + } + candidates.push({ folder, reason }); + log(`Candidate found (${reason}): ${folder}`); +} + +function detectMainSiblingFolder() { + const currentFolderName = basename(repoRoot); + if (!currentFolderName.startsWith('xterm.js') || currentFolderName === 'xterm.js') { + log(`Current folder is "${currentFolderName}", skipping xterm.js sibling lookup.`); + return undefined; + } + const siblingFolder = resolve(dirname(repoRoot), 'xterm.js'); + log(`Current folder is "${currentFolderName}", sibling main folder candidate: ${siblingFolder}`); + return siblingFolder; +} + +function detectWorktreeMainFolder() { + const gitPath = resolve(repoRoot, '.git'); + if (!existsSync(gitPath)) { + log('No .git entry found at repo root.'); + return undefined; + } + const gitStat = lstatSync(gitPath); + if (!gitStat.isFile()) { + log('.git is not a file, this repo does not appear to be a worktree checkout.'); + return undefined; + } + const gitFileContent = readFileSync(gitPath, 'utf8').trim(); + const gitDirMatch = /^gitdir:\s*(.+)$/m.exec(gitFileContent); + if (!gitDirMatch) { + log('Could not parse gitdir from .git file.'); + return undefined; + } + + const gitDirPath = resolve(repoRoot, gitDirMatch[1].trim()); + log(`Parsed gitdir from .git file: ${gitDirPath}`); + + const normalizedGitDirPath = gitDirPath.replace(/\\/g, '/'); + const worktreeMarker = '/.git/worktrees/'; + const markerIndex = normalizedGitDirPath.indexOf(worktreeMarker); + if (markerIndex === -1) { + log('gitdir path does not contain /.git/worktrees/, skipping worktree main folder lookup.'); + return undefined; + } + + const normalizedMainFolder = normalizedGitDirPath.slice(0, markerIndex); + const mainFolder = sep === '/' ? normalizedMainFolder : normalizedMainFolder.split('/').join(sep); + log(`Worktree main folder candidate: ${mainFolder}`); + return mainFolder; +} + +function resolveSourceFolder() { + /** @type {Candidate[]} */ + const candidates = []; + addCandidate(candidates, detectMainSiblingFolder(), 'xterm.js sibling'); + addCandidate(candidates, detectWorktreeMainFolder(), 'worktree main repo'); + + for (const candidate of candidates) { + const candidateNodeModulesPath = resolve(candidate.folder, 'node_modules'); + if (existsSync(candidateNodeModulesPath)) { + log(`Using candidate (${candidate.reason}) with node_modules: ${candidate.folder}`); + return candidate.folder; + } + log(`Candidate skipped (${candidate.reason}), node_modules missing: ${candidateNodeModulesPath}`); + } + + log('No candidate folder with node_modules was found.'); + return undefined; +} + +if (!existsSync(nodeModulesPath)) { + log(`node_modules missing: ${nodeModulesPath}`); + const sourceFolder = resolveSourceFolder(); + if (sourceFolder) { + const sourceNodeModulesPath = resolve(sourceFolder, 'node_modules'); + log(`Copying node_modules from ${sourceNodeModulesPath} to ${nodeModulesPath}`); + try { + cpSync(sourceNodeModulesPath, nodeModulesPath, { recursive: true }); + log('node_modules copy completed.'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`node_modules copy failed: ${message}`); + log('Falling back to npm ci.'); + runNpm(['ci']); + } + } else { + log('No source folder available, running npm ci.'); + runNpm(['ci']); + } +} else { + log(`node_modules already exists: ${nodeModulesPath}`); +} + +runNpm(['run', 'setup']); diff --git a/package.json b/package.json index 8c0f4462..267477a1 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,8 @@ "prepackage-headless": "npm run esbuild-package-headless-only", "package-headless": "webpack --config ./webpack.config.headless.js", "postpackage-headless": "node ./bin/package_headless.js", - "prepublishOnly": "npm run package" + "prepublishOnly": "npm run package", + "agent:session-start": "node bin/agent/setup-repo.mjs" }, "devDependencies": { "@lunapaint/png-codec": "^0.2.0",