GitNexus/gitnexus-desktop/scripts/package.mjs

436 lines
14 KiB
JavaScript

import { execSync } from 'node:child_process';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageRoot = path.resolve(__dirname, '..');
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'),
);
const desktopPackageLock = JSON.parse(
fs.readFileSync(path.join(packageRoot, 'package-lock.json'), 'utf8'),
);
const electronVersion =
desktopPackageJson.devDependencies?.electron?.replace(/^[^\d]*/, '') ?? '41.2.1';
const electronBuilderVersion =
desktopPackageJson.devDependencies?.['electron-builder']?.replace(/^[^\d]*/, '') ?? '26.8.1';
const bundledNodeExecutableName = process.platform === 'win32' ? 'node.exe' : 'node';
const electronBuilderCliPath = path.join(packageRoot, 'node_modules', 'electron-builder', 'cli.js');
const builderUtilRequire = createRequire(
path.join(packageRoot, 'node_modules', 'builder-util', 'out', 'util.js'),
);
const requiredBuilderRuntimeModules = ['app-builder-bin'];
const appBuilderLibVersion =
desktopPackageLock.packages?.['node_modules/app-builder-lib']?.version ??
electronBuilderVersion.replace(/^[^\d]*/, '');
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const outputDir = path.join(releaseRoot, stamp);
const latestReleasePointerPath = path.join(releaseRoot, '.latest-unpacked-release');
const electronBuilderConfigPath = path.join(packageRoot, 'electron-builder.yml');
const requiredNsisTemplateFiles = [
path.join(packageRoot, 'node_modules', 'app-builder-lib', 'templates', 'nsis', 'messages.yml'),
path.join(
packageRoot,
'node_modules',
'app-builder-lib',
'templates',
'nsis',
'assistedMessages.yml',
),
];
const supportedTargetHosts = {
'--linux': 'linux',
'--mac': 'darwin',
'--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: 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: toBuilderRelativePath(path.join(gitnexusWebRoot, 'dist')),
};
const builderCommand = [
`node "${electronBuilderCliPath}"`,
`--config "${electronBuilderConfigPath}"`,
`-c.electronVersion=${electronVersion}`,
`-c.directories.output="${outputDir}"`,
'--publish never',
...builderArgs,
].join(' ');
const runCommand = (command, cwd, extraEnv = {}) => {
execSync(command, {
cwd,
env: {
...process.env,
...extraEnv,
},
stdio: 'inherit',
});
};
const tryRunCommand = (command, cwd, extraEnv = {}) => {
try {
runCommand(command, cwd, extraEnv);
return true;
} catch {
return false;
}
};
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);
return true;
} catch {
return false;
}
};
const overlayDirectoryContents = (sourceDirectory, destinationDirectory) => {
fs.mkdirSync(destinationDirectory, { recursive: true });
for (const entry of fs.readdirSync(sourceDirectory, { withFileTypes: true })) {
fs.cpSync(path.join(sourceDirectory, entry.name), path.join(destinationDirectory, entry.name), {
force: true,
recursive: true,
});
}
};
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-'),
);
try {
const tarballName = `app-builder-lib-${appBuilderLibVersion}.tgz`;
const tarballPath = path.join(repairDirectory, tarballName);
const extractedPackageRoot = path.join(repairDirectory, 'package');
const installedPackageRoot = path.join(packageRoot, 'node_modules', 'app-builder-lib');
runCommand(
`npm pack app-builder-lib@${appBuilderLibVersion} --pack-destination "${repairDirectory}"`,
packageRoot,
);
runCommand(`tar -xzf "${tarballPath}" -C "${repairDirectory}"`, packageRoot);
overlayDirectoryContents(extractedPackageRoot, installedPackageRoot);
} finally {
fs.rmSync(repairDirectory, { force: true, recursive: true });
}
};
const assertSupportedHostForRequestedTargets = () => {
const unsupportedTargets = requestedTargets.filter(
(target) => supportedTargetHosts[target] !== process.platform,
);
if (unsupportedTargets.length === 0) {
return;
}
const messages = unsupportedTargets.map((target) => {
if (target === '--mac') {
return 'macOS DMG builds must run on a macOS host. Use the Desktop Packaging GitHub Actions workflow for .dmg artifacts.';
}
if (target === '--linux') {
return 'Linux AppImage builds should run on a Linux host. Use the Desktop Packaging GitHub Actions workflow for Linux installers.';
}
return 'Windows NSIS builds must run on a Windows host.';
});
throw new Error(messages.join('\n'));
};
const ensureDesktopToolchainHealthy = () => {
const missingBuilderRuntimeModules = requiredBuilderRuntimeModules.filter(
(moduleName) => !canResolveBuilderRuntimeModule(moduleName),
);
const missingNsisTemplates = requestedTargets.includes('--win')
? requiredNsisTemplateFiles.filter((filePath) => !fs.existsSync(filePath))
: [];
if (missingBuilderRuntimeModules.length === 0 && missingNsisTemplates.length === 0) {
return;
}
if (missingNsisTemplates.length > 0) {
console.warn(
'[build] electron-builder NSIS templates are missing. Restoring app-builder-lib package contents first...',
);
try {
repairAppBuilderLibPackage();
} catch {
// Fall through to install-based repair paths below.
}
}
const remainingMissingBuilderRuntimeModules = requiredBuilderRuntimeModules.filter(
(moduleName) => !canResolveBuilderRuntimeModule(moduleName),
);
const remainingMissingNsisTemplates = requestedTargets.includes('--win')
? requiredNsisTemplateFiles.filter((filePath) => !fs.existsSync(filePath))
: [];
if (
remainingMissingBuilderRuntimeModules.length === 0 &&
remainingMissingNsisTemplates.length === 0
) {
return;
}
console.warn(
'[build] electron-builder installation is incomplete. Restoring desktop dependencies with npm ci...',
);
runCommand('npm ci', packageRoot);
const unresolvedBuilderRuntimeModules = requiredBuilderRuntimeModules.filter(
(moduleName) => !canResolveBuilderRuntimeModule(moduleName),
);
const unresolvedTemplates = requiredNsisTemplateFiles.filter(
(filePath) => !fs.existsSync(filePath),
);
if (unresolvedBuilderRuntimeModules.length > 0 || unresolvedTemplates.length > 0) {
throw new Error(
[
'electron-builder is missing required runtime modules after reinstall:',
...unresolvedBuilderRuntimeModules,
...unresolvedTemplates,
].join('\n'),
);
}
};
const listArtifacts = (directoryPath) => {
const results = [];
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'win-unpacked') {
results.push(fullPath);
continue;
}
results.push(...listArtifacts(fullPath));
continue;
}
if (artifactFileExtensions.has(path.extname(entry.name))) {
results.push(fullPath);
}
}
return results;
};
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(latestReleasePointerPath, `${outputDir}\n`);
assertSupportedHostForRequestedTargets();
ensureDesktopToolchainHealthy();
runCommand('node scripts/ensure-gitnexus-runtime.mjs', packageRoot);
runCommand('npm run bundle', packageRoot);
runCommand('npm run build', gitnexusWebRoot);
runCommand(builderCommand, packageRoot, builderEnvironment);
syncPackagedRuntimeResources();
const artifacts = listArtifacts(outputDir);
console.log(`[build] artifacts written to release/${stamp}`);
if (artifacts.length > 0) {
console.log('[build] artifacts:');
for (const artifact of artifacts) {
console.log(` - ${artifact}`);
}
}