diff --git a/gitnexus-desktop/package-lock.json b/gitnexus-desktop/package-lock.json index b44096184..cd492c405 100644 --- a/gitnexus-desktop/package-lock.json +++ b/gitnexus-desktop/package-lock.json @@ -4934,23 +4934,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react": { - "version": "19.2.5", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.5", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.5" - } - }, "node_modules/react-refresh": { "version": "0.18.0", "dev": true, @@ -5155,10 +5138,6 @@ "node": ">=11.0.0" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "license": "MIT" - }, "node_modules/semver": { "version": "6.3.1", "dev": true, diff --git a/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs b/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs index 1d0a182ec..a6df9d098 100644 --- a/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs +++ b/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs @@ -1,5 +1,17 @@ import { spawnSync } from 'node:child_process'; -import { existsSync, readdirSync, statSync } from 'node:fs'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,11 +21,56 @@ const workspaceRoot = path.resolve(__dirname, '..', '..'); const sharedRoot = path.join(workspaceRoot, 'gitnexus-shared'); const gitnexusRoot = path.join(workspaceRoot, 'gitnexus'); const gitnexusWebRoot = path.join(workspaceRoot, 'gitnexus-web'); +const gitnexusLockfile = JSON.parse( + readFileSync(path.join(gitnexusRoot, 'package-lock.json'), 'utf8'), +); +const gitnexusPackageJson = JSON.parse( + readFileSync(path.join(gitnexusRoot, 'package.json'), 'utf8'), +); +const gitnexusModuleResolver = createRequire(path.join(gitnexusRoot, 'package.json')); const gitnexusServerEntry = path.join(gitnexusRoot, 'dist', 'server', 'api.js'); const gitnexusCliEntry = path.join(gitnexusRoot, 'dist', 'cli', 'index.js'); +const gitnexusServerPort = 4747; +const gitnexusWebDevPort = 5173; const desktopRendererPort = 5174; const shouldCleanupDevPort = process.argv.includes('--cleanup-dev-port'); +const getNodeModulePackagePath = (packageRoot, packageName) => { + return path.join(packageRoot, 'node_modules', ...packageName.split('/')); +}; + +const getNodeModulePackageJsonPath = (packageRoot, packageName) => { + return path.join(packageRoot, 'node_modules', ...packageName.split('/'), 'package.json'); +}; + +const getLockedPackageInstallSpecifier = (packageName) => { + const lockedPackage = gitnexusLockfile.packages?.[`node_modules/${packageName}`]; + const lockedVersion = lockedPackage?.version; + + if (!lockedVersion || String(lockedVersion).startsWith('file:')) { + return null; + } + + return lockedVersion; +}; + +const getDependencySubset = (dependencyMap, packageNames, overrides = {}) => { + return Object.fromEntries( + packageNames.map((packageName) => { + const versionSpecifier = + overrides[packageName] ?? + getLockedPackageInstallSpecifier(packageName) ?? + dependencyMap?.[packageName]; + + if (!versionSpecifier) { + throw new Error(`Missing package specifier for ${packageName}.`); + } + + return [packageName, versionSpecifier]; + }), + ); +}; + const getEntryMtimeMs = (targetPath) => { if (!existsSync(targetPath)) { return 0; @@ -90,11 +147,58 @@ const gitnexusInstallInputs = [ path.join(sharedRoot, 'package-lock.json'), ]; -const gitnexusInstallMarkerPaths = [ - path.join(gitnexusRoot, 'node_modules', 'tsx'), - path.join(gitnexusRoot, 'node_modules', 'typescript'), +const gitnexusDesktopRuntimeDependencyNames = [ + '@ladybugdb/core', + '@modelcontextprotocol/sdk', + 'cli-progress', + 'commander', + 'cors', + 'express', + 'glob', + 'graphology', + 'graphology-indices', + 'graphology-utils', + 'ignore', + 'js-yaml', + 'lru-cache', + 'mnemonist', + 'pandemonium', + 'uuid', ]; +const gitnexusDesktopBuildDependencyNames = ['@types/node', 'gitnexus-shared', 'typescript']; + +const gitnexusDesktopRuntimeDependencies = getDependencySubset( + gitnexusPackageJson.dependencies ?? {}, + gitnexusDesktopRuntimeDependencyNames, +); + +const gitnexusDesktopBuildDependencies = getDependencySubset( + gitnexusPackageJson.devDependencies ?? {}, + gitnexusDesktopBuildDependencyNames, + { 'gitnexus-shared': `file:${sharedRoot}` }, +); + +const gitnexusDesktopRuntimeInstallMarkerPaths = [ + ...gitnexusDesktopRuntimeDependencyNames.map((packageName) => + getNodeModulePackageJsonPath(gitnexusRoot, packageName), + ), + path.join(gitnexusRoot, 'node_modules', 'commander', 'index.js'), + path.join(gitnexusRoot, 'node_modules', '@ladybugdb', 'core', 'lbugjs.node'), +]; + +const gitnexusDesktopRuntimeResolutionChecks = [ + 'commander', + '@modelcontextprotocol/sdk/server/index.js', + '@modelcontextprotocol/sdk/server/streamableHttp.js', + 'ignore', + 'js-yaml', +]; + +const gitnexusDesktopBuildInstallMarkerPaths = gitnexusDesktopBuildDependencyNames.map( + (packageName) => getNodeModulePackageJsonPath(gitnexusRoot, packageName), +); + const gitnexusRuntimeInputs = [ path.join(gitnexusRoot, 'src'), path.join(gitnexusRoot, 'scripts'), @@ -116,14 +220,32 @@ const isInstallStale = (inputPaths, installMarkerPaths) => { return getNewestMtimeMs(inputPaths) > installMarkerMtimeMs; }; -const isGitNexusInstallStale = () => { - return isInstallStale(gitnexusInstallInputs, gitnexusInstallMarkerPaths); -}; - const isGitNexusSharedInstallStale = () => { return isInstallStale(gitnexusSharedInstallInputs, gitnexusSharedInstallMarkerPaths); }; +const canResolveGitNexusSpecifier = (specifier) => { + try { + gitnexusModuleResolver.resolve(specifier); + return true; + } catch { + return false; + } +}; + +const isGitNexusRuntimeInstallStale = () => { + return ( + isInstallStale(gitnexusInstallInputs, gitnexusDesktopRuntimeInstallMarkerPaths) || + gitnexusDesktopRuntimeResolutionChecks.some( + (specifier) => !canResolveGitNexusSpecifier(specifier), + ) + ); +}; + +const isGitNexusBuildInstallStale = () => { + return isInstallStale(gitnexusInstallInputs, gitnexusDesktopBuildInstallMarkerPaths); +}; + const isGitNexusBuildStale = () => { return getNewestMtimeMs(gitnexusRuntimeInputs) > getOldestOutputMtimeMs(gitnexusRuntimeOutputs); }; @@ -150,6 +272,56 @@ const runNpm = (args, cwd) => { } }; +const overlayDirectoryContents = (sourceDirectory, destinationDirectory) => { + mkdirSync(destinationDirectory, { recursive: true }); + + for (const entry of readdirSync(sourceDirectory, { withFileTypes: true })) { + cpSync(path.join(sourceDirectory, entry.name), path.join(destinationDirectory, entry.name), { + force: true, + recursive: true, + }); + } +}; + +const replaceDirectGitNexusPackages = (dependencyMap) => { + for (const packageName of Object.keys(dependencyMap)) { + rmSync(getNodeModulePackagePath(gitnexusRoot, packageName), { + force: true, + recursive: true, + }); + } +}; + +const repairGitNexusPackages = (dependencyMap, label) => { + const repairDirectory = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-desktop-gitnexus-repair-')); + + try { + writeFileSync( + path.join(repairDirectory, 'package.json'), + JSON.stringify( + { + name: 'gitnexus-desktop-gitnexus-repair', + private: true, + dependencies: dependencyMap, + }, + null, + 2, + ), + ); + + console.info(`[gitnexus-desktop] ${label}.`); + runNpm(['install', '--no-package-lock'], repairDirectory); + + replaceDirectGitNexusPackages(dependencyMap); + overlayDirectoryContents( + path.join(repairDirectory, 'node_modules'), + path.join(gitnexusRoot, 'node_modules'), + ); + } finally { + rmSync(repairDirectory, { force: true, recursive: true }); + } +}; + const runCommand = (command, args) => { return spawnSync(command, args, { encoding: 'utf8', @@ -250,8 +422,56 @@ const ensureDesktopRendererPortAvailable = () => { killProcessTree(owner.pid); }; +const ensureGitNexusWebDevPortAvailable = () => { + const owner = findPortOwner(gitnexusWebDevPort); + + if (!owner) { + return; + } + + const signature = `${owner.name ?? ''} ${owner.commandLine ?? ''}`.toLowerCase(); + const isGitNexusWebViteProcess = signature.includes('vite') && signature.includes('gitnexus-web'); + + if (!isGitNexusWebViteProcess) { + console.error( + `Port ${gitnexusWebDevPort} is already in use by ${owner.name ?? 'another process'} (PID ${owner.pid}). Stop that process and try again.`, + ); + process.exit(1); + } + + console.info( + `Stopping stale GitNexus web dev server on port ${gitnexusWebDevPort} (PID ${owner.pid}).`, + ); + killProcessTree(owner.pid); +}; + +const ensureGitNexusServerPortAvailable = () => { + const owner = findPortOwner(gitnexusServerPort); + + if (!owner) { + return; + } + + const signature = `${owner.name ?? ''} ${owner.commandLine ?? ''}`.toLowerCase(); + const isGitNexusServerProcess = + (signature.includes('dist\\cli\\index.js') || signature.includes('dist/cli/index.js')) && + signature.includes('serve'); + + if (!isGitNexusServerProcess) { + console.error( + `Port ${gitnexusServerPort} is already in use by ${owner.name ?? 'another process'} (PID ${owner.pid}). Stop that process and try again.`, + ); + process.exit(1); + } + + console.info(`Stopping stale GitNexus backend on port ${gitnexusServerPort} (PID ${owner.pid}).`); + killProcessTree(owner.pid); +}; + if (shouldCleanupDevPort) { + ensureGitNexusServerPortAvailable(); ensureDesktopRendererPortAvailable(); + ensureGitNexusWebDevPortAvailable(); } if ( @@ -262,9 +482,8 @@ if ( runNpm(['ci'], sharedRoot); } -if (!existsSync(path.join(gitnexusRoot, 'node_modules', 'tsx')) || isGitNexusInstallStale()) { - console.info('[gitnexus-desktop] Refreshing GitNexus dependencies.'); - runNpm(['ci'], gitnexusRoot); +if (isGitNexusRuntimeInstallStale()) { + repairGitNexusPackages(gitnexusDesktopRuntimeDependencies, 'Repairing GitNexus runtime packages'); } if (!existsSync(path.join(gitnexusWebRoot, 'node_modules', 'vite'))) { @@ -272,6 +491,16 @@ if (!existsSync(path.join(gitnexusWebRoot, 'node_modules', 'vite'))) { } if (getOldestOutputMtimeMs(gitnexusRuntimeOutputs) === 0 || isGitNexusBuildStale()) { + if (isGitNexusBuildInstallStale()) { + repairGitNexusPackages( + { + ...gitnexusDesktopRuntimeDependencies, + ...gitnexusDesktopBuildDependencies, + }, + 'Repairing GitNexus build tooling', + ); + } + console.info('[gitnexus-desktop] Rebuilding GitNexus runtime artifacts.'); runNpm(['run', 'build'], gitnexusRoot); } diff --git a/gitnexus-desktop/scripts/package.mjs b/gitnexus-desktop/scripts/package.mjs index da5910d84..5637f00d8 100644 --- a/gitnexus-desktop/scripts/package.mjs +++ b/gitnexus-desktop/scripts/package.mjs @@ -12,6 +12,12 @@ const workspaceRoot = path.resolve(packageRoot, '..'); const gitnexusRoot = path.join(workspaceRoot, 'gitnexus'); const gitnexusWebRoot = path.join(workspaceRoot, 'gitnexus-web'); const releaseRoot = path.join(packageRoot, 'release'); +const gitnexusPackageJson = JSON.parse( + fs.readFileSync(path.join(gitnexusRoot, 'package.json'), 'utf8'), +); +const gitnexusPackageLock = JSON.parse( + fs.readFileSync(path.join(gitnexusRoot, 'package-lock.json'), 'utf8'), +); const desktopPackageJson = JSON.parse( fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), ); @@ -52,20 +58,90 @@ const supportedTargetHosts = { '--win': 'win32', }; const artifactFileExtensions = new Set(['.AppImage', '.dmg', '.exe', '.msi', '.zip']); +const gitnexusRuntimeDependencyNames = Object.keys(gitnexusPackageJson.dependencies ?? {}); +const packagedResourceEntries = [ + { + from: path.join(gitnexusWebRoot, 'dist'), + to: 'gitnexus-web', + }, + { + from: path.join(gitnexusRoot, 'dist'), + to: path.join('gitnexus', 'dist'), + }, + { + from: path.join(gitnexusRoot, 'hooks'), + to: path.join('gitnexus', 'hooks'), + }, + { + from: path.join(gitnexusRoot, 'skills'), + to: path.join('gitnexus', 'skills'), + }, + { + from: path.join(gitnexusRoot, 'vendor'), + to: path.join('gitnexus', 'vendor'), + }, + { + from: path.join(gitnexusRoot, 'package.json'), + to: path.join('gitnexus', 'package.json'), + }, +]; + +const toBuilderRelativePath = (targetPath) => { + return path.relative(packageRoot, targetPath) || '.'; +}; + +const getNodeModulePath = (packageName) => { + return path.join(gitnexusRoot, 'node_modules', ...packageName.split('/')); +}; + +const getRuntimeDependencyClosure = () => { + const visited = new Set(); + const queue = [...gitnexusRuntimeDependencyNames]; + + while (queue.length > 0) { + const packageName = queue.shift(); + + if (!packageName || visited.has(packageName)) { + continue; + } + + visited.add(packageName); + + const lockEntry = gitnexusPackageLock.packages?.[`node_modules/${packageName}`]; + const dependencyNames = Object.keys({ + ...(lockEntry?.dependencies ?? {}), + ...(lockEntry?.optionalDependencies ?? {}), + }); + + for (const dependencyName of dependencyNames) { + if (!visited.has(dependencyName)) { + queue.push(dependencyName); + } + } + } + + return [...visited].filter((packageName) => fs.existsSync(getNodeModulePath(packageName))).sort(); +}; + +const packagedRuntimeNodeModules = getRuntimeDependencyClosure(); const builderArgs = process.argv.slice(2); const requestedTargets = builderArgs.filter((argument) => argument in supportedTargetHosts); const builderEnvironment = { - GITNEXUS_DESKTOP_GITNEXUS_DIST: path.join(gitnexusRoot, 'dist'), - GITNEXUS_DESKTOP_GITNEXUS_HOOKS: path.join(gitnexusRoot, 'hooks'), - GITNEXUS_DESKTOP_GITNEXUS_NODE_MODULES: path.join(gitnexusRoot, 'node_modules'), - GITNEXUS_DESKTOP_GITNEXUS_PACKAGE_JSON: path.join(gitnexusRoot, 'package.json'), - GITNEXUS_DESKTOP_GITNEXUS_SKILLS: path.join(gitnexusRoot, 'skills'), - GITNEXUS_DESKTOP_GITNEXUS_VENDOR: path.join(gitnexusRoot, 'vendor'), + GITNEXUS_DESKTOP_GITNEXUS_DIST: toBuilderRelativePath(path.join(gitnexusRoot, 'dist')), + GITNEXUS_DESKTOP_GITNEXUS_HOOKS: toBuilderRelativePath(path.join(gitnexusRoot, 'hooks')), + GITNEXUS_DESKTOP_GITNEXUS_NODE_MODULES: toBuilderRelativePath( + path.join(gitnexusRoot, 'node_modules'), + ), + GITNEXUS_DESKTOP_GITNEXUS_PACKAGE_JSON: toBuilderRelativePath( + path.join(gitnexusRoot, 'package.json'), + ), + GITNEXUS_DESKTOP_GITNEXUS_SKILLS: toBuilderRelativePath(path.join(gitnexusRoot, 'skills')), + GITNEXUS_DESKTOP_GITNEXUS_VENDOR: toBuilderRelativePath(path.join(gitnexusRoot, 'vendor')), GITNEXUS_DESKTOP_NODE_EXECUTABLE: process.execPath, GITNEXUS_DESKTOP_NODE_RESOURCE_PATH: `gitnexus-node/${bundledNodeExecutableName}`, - GITNEXUS_DESKTOP_WEB_DIST: path.join(gitnexusWebRoot, 'dist'), + GITNEXUS_DESKTOP_WEB_DIST: toBuilderRelativePath(path.join(gitnexusWebRoot, 'dist')), }; const builderCommand = [ @@ -97,6 +173,33 @@ const tryRunCommand = (command, cwd, extraEnv = {}) => { } }; +const mirrorDirectory = (sourceDirectory, destinationDirectory) => { + if (process.platform === 'win32') { + fs.mkdirSync(destinationDirectory, { recursive: true }); + + try { + execSync( + `robocopy "${sourceDirectory}" "${destinationDirectory}" /MIR /NFL /NDL /NJH /NJS /NP`, + { + cwd: packageRoot, + stdio: 'inherit', + }, + ); + } catch (error) { + const exitCode = error.status ?? 16; + + if (exitCode > 7) { + throw error; + } + } + + return; + } + + fs.rmSync(destinationDirectory, { force: true, recursive: true }); + fs.cpSync(sourceDirectory, destinationDirectory, { force: true, recursive: true }); +}; + const canResolveBuilderRuntimeModule = (moduleName) => { try { builderUtilRequire.resolve(moduleName); @@ -117,6 +220,63 @@ const overlayDirectoryContents = (sourceDirectory, destinationDirectory) => { } }; +const resolvePackagedResourceRoots = () => { + const candidateRoots = [ + path.join(outputDir, 'win-unpacked', 'resources'), + path.join(outputDir, 'linux-unpacked', 'resources'), + path.join( + outputDir, + `${desktopPackageJson.productName ?? 'GitNexus Desktop'}.app`, + 'Contents', + 'Resources', + ), + ]; + + return candidateRoots.filter((candidateRoot) => fs.existsSync(candidateRoot)); +}; + +const syncPackagedRuntimeResources = () => { + const resourceRoots = resolvePackagedResourceRoots(); + + if (resourceRoots.length === 0) { + return; + } + + for (const resourceRoot of resourceRoots) { + const packagedNodeModulesRoot = path.join(resourceRoot, 'gitnexus', 'node_modules'); + + fs.rmSync(packagedNodeModulesRoot, { force: true, recursive: true }); + fs.mkdirSync(packagedNodeModulesRoot, { recursive: true }); + + const sourceNodeModulesLock = path.join(gitnexusRoot, 'node_modules', '.package-lock.json'); + const destinationNodeModulesLock = path.join(packagedNodeModulesRoot, '.package-lock.json'); + + if (fs.existsSync(sourceNodeModulesLock)) { + fs.cpSync(sourceNodeModulesLock, destinationNodeModulesLock, { force: true }); + } + + for (const packageName of packagedRuntimeNodeModules) { + const sourcePackagePath = getNodeModulePath(packageName); + const destinationPackagePath = path.join(packagedNodeModulesRoot, ...packageName.split('/')); + + mirrorDirectory(sourcePackagePath, destinationPackagePath); + } + + for (const entry of packagedResourceEntries) { + const destinationPath = path.join(resourceRoot, entry.to); + + if (fs.statSync(entry.from).isDirectory()) { + mirrorDirectory(entry.from, destinationPath); + continue; + } + + fs.rmSync(destinationPath, { force: true, recursive: true }); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.cpSync(entry.from, destinationPath, { force: true }); + } + } +}; + const repairAppBuilderLibPackage = () => { const repairDirectory = fs.mkdtempSync( path.join(os.tmpdir(), 'gitnexus-desktop-app-builder-lib-'), @@ -262,6 +422,7 @@ runCommand('npm run bundle', packageRoot); runCommand('npm run build', gitnexusWebRoot); runCommand(builderCommand, packageRoot, builderEnvironment); +syncPackagedRuntimeResources(); const artifacts = listArtifacts(outputDir); diff --git a/gitnexus-desktop/src/main/main.ts b/gitnexus-desktop/src/main/main.ts index a226c0364..4103be9d4 100644 --- a/gitnexus-desktop/src/main/main.ts +++ b/gitnexus-desktop/src/main/main.ts @@ -33,7 +33,8 @@ const GITNEXUS_WEB_DEV_ROOT_FALLBACK = path.resolve(__dirname, '../../../gitnexu const GITNEXUS_PACKAGED_RUNTIME_DIR = path.join(process.resourcesPath, 'gitnexus'); const GITNEXUS_WEB_PACKAGED_DIR = path.join(process.resourcesPath, 'gitnexus-web'); const GITNEXUS_WEB_EXPECTED_MARKERS = ['