diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..444539d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,154 @@ +name: CI and release + +on: + pull_request: + push: + branches: + - '**' + tags: + - '*' + +permissions: + contents: read + +jobs: + validate: + name: Validate (${{ matrix.label }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + label: Windows + - os: macos-13 + label: macOS + - os: ubuntu-latest + label: Linux + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.6 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Install Linux native dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run web tests + run: bun run test + + - name: Run sidecar tests + run: bun run sidecar:test + + - name: Build the application + run: bun run build + + create-release: + name: Create GitHub release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + needs: validate + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Create release if needed + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.ref_name }} + run: | + if gh release view "$TAG_NAME" >/dev/null 2>&1; then + echo "Release $TAG_NAME already exists." + else + gh release create "$TAG_NAME" --title "$TAG_NAME" --generate-notes + fi + + publish-release-assets: + name: Publish release asset (${{ matrix.label }}) + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + needs: create-release + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + label: Windows + asset_path: release/Eryx-windows-x64.zip + - os: macos-13 + label: macOS + asset_path: release/Eryx-macos-x64.zip + - os: ubuntu-latest + label: Linux + asset_path: release/Eryx-linux-x64.tar.gz + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.6 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Install Linux native dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Package current platform + run: bun run package + + - name: Ad-hoc sign macOS app bundle + if: runner.os == 'macOS' + run: codesign --force --deep --sign - release/Eryx-macos-x64/Eryx.app + + - name: Create Windows release archive + if: runner.os == 'Windows' + shell: pwsh + run: Compress-Archive -Path 'release\Eryx-windows-x64\*' -DestinationPath 'release\Eryx-windows-x64.zip' -Force + + - name: Create macOS release archive + if: runner.os == 'macOS' + run: ditto -c -k --sequesterRsrc --keepParent release/Eryx-macos-x64/Eryx.app release/Eryx-macos-x64.zip + + - name: Create Linux release archive + if: runner.os == 'Linux' + run: tar -czf release/Eryx-linux-x64.tar.gz -C release Eryx-linux-x64 + + - name: Upload asset to GitHub release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.ref_name }} + ASSET_PATH: ${{ matrix.asset_path }} + run: gh release upload "$TAG_NAME" "$ASSET_PATH" --clobber diff --git a/README.md b/README.md index 46dc188..d3b30e1 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,20 @@ Eryx shines when you want to: - reuse patterns for recurring workflows - maintain a history of meaningful sessions instead of disposable chats +## Build and release automation + +For local validation, run: + +- `bun run test` +- `bun run sidecar:test` +- `bun run build` + +To package the current platform into `release/`, run: + +- `bun run package` + +GitHub Actions now runs validation on pushes and pull requests, and pushing a git tag creates a GitHub release with Windows, macOS, and Linux assets uploaded directly to the release. + ## Current focus Eryx is focused on local, project-based work with your GitHub Copilot account. It already covers the essentials for working with projects, sessions, and reusable orchestration patterns, and it is growing toward a fuller AI workstation experience over time. diff --git a/package.json b/package.json index cc16b2d..d783517 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,15 @@ "dev": "electron-vite dev", "build:electron": "electron-vite build", "build": "bun run build:electron && bun run sidecar:build", - "package": "bun run build:electron && bun run sidecar:publish && node \".\\scripts\\package-electron.mjs\"", + "package": "bun run build:electron && bun run sidecar:publish && bun run scripts/package-electron.ts", "preview": "electron-vite preview", "lsp:typescript": "typescript-language-server --stdio", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "bun run typecheck && bun test", - "sidecar:restore": "powershell -NoProfile -Command \"dotnet restore 'sidecar\\\\Eryx.AgentHost.slnx'\"", - "sidecar:build": "powershell -NoProfile -Command \"dotnet build 'sidecar\\\\Eryx.AgentHost.slnx'\"", - "sidecar:publish": "powershell -NoProfile -Command \"if (Test-Path 'dist-sidecar\\\\win-x64') { Remove-Item 'dist-sidecar\\\\win-x64' -Recurse -Force }; dotnet publish 'sidecar\\\\src\\\\Eryx.AgentHost\\\\Eryx.AgentHost.csproj' -c Release -r win-x64 --self-contained true -p:DebugType=None -p:DebugSymbols=false -o 'dist-sidecar\\\\win-x64'\"", - "sidecar:test": "powershell -NoProfile -Command \"dotnet test 'sidecar\\\\Eryx.AgentHost.slnx'\"" + "sidecar:restore": "dotnet restore sidecar/Eryx.AgentHost.slnx", + "sidecar:build": "dotnet build sidecar/Eryx.AgentHost.slnx", + "sidecar:publish": "bun run scripts/publish-sidecar.ts", + "sidecar:test": "dotnet test sidecar/Eryx.AgentHost.slnx" }, "repository": { "type": "git", diff --git a/scripts/package-electron.mjs b/scripts/package-electron.mjs deleted file mode 100644 index 2b438eb..0000000 --- a/scripts/package-electron.mjs +++ /dev/null @@ -1,127 +0,0 @@ -import { access, cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; -import { constants } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { rcedit } from 'rcedit'; - -const productName = 'Eryx'; -const sidecarRuntime = 'win-x64'; -const outputDirectoryName = 'win-unpacked'; - -const scriptDirectory = dirname(fileURLToPath(import.meta.url)); -const repositoryRoot = resolve(scriptDirectory, '..'); -const assetDirectory = join(repositoryRoot, 'assets'); -const sourceIconPath = join(assetDirectory, 'icons', 'icon.png'); -const windowsIconPath = join(assetDirectory, 'icons', 'windows', 'icon.ico'); -const electronDistributionDirectory = join(repositoryRoot, 'node_modules', 'electron', 'dist'); -const rendererBuildDirectory = join(repositoryRoot, 'dist'); -const electronBuildDirectory = join(repositoryRoot, 'dist-electron'); -const publishedSidecarDirectory = join(repositoryRoot, 'dist-sidecar', sidecarRuntime); -const outputDirectory = join(repositoryRoot, 'release', outputDirectoryName); -const outputResourcesDirectory = join(outputDirectory, 'resources'); -const packagedAppDirectory = join(outputResourcesDirectory, 'app'); -const packagedExecutablePath = join(outputDirectory, `${productName}.exe`); - -async function ensurePathExists(path, label) { - try { - await access(path, constants.F_OK); - } catch { - throw new Error(`${label} was not found at ${path}.`); - } -} - -async function readJson(path) { - return JSON.parse(await readFile(path, 'utf8')); -} - -async function collectRuntimeDependencies() { - const rootPackageJson = await readJson(join(repositoryRoot, 'package.json')); - const dependencies = new Set(Object.keys(rootPackageJson.dependencies ?? {})); - const queue = [...dependencies]; - - while (queue.length > 0) { - const dependencyName = queue.shift(); - const dependencyPackageJson = await readJson( - join(repositoryRoot, 'node_modules', ...dependencyName.split('/'), 'package.json'), - ); - - for (const transitiveDependency of Object.keys({ - ...(dependencyPackageJson.dependencies ?? {}), - ...(dependencyPackageJson.optionalDependencies ?? {}), - })) { - if (!dependencies.has(transitiveDependency)) { - dependencies.add(transitiveDependency); - queue.push(transitiveDependency); - } - } - } - - return [...dependencies].sort(); -} - -async function copyRuntimeDependencies(dependencyNames) { - const packagedNodeModulesDirectory = join(packagedAppDirectory, 'node_modules'); - await mkdir(packagedNodeModulesDirectory, { recursive: true }); - - for (const dependencyName of dependencyNames) { - const dependencyPathParts = dependencyName.split('/'); - const sourceDirectory = join(repositoryRoot, 'node_modules', ...dependencyPathParts); - const targetDirectory = join(packagedNodeModulesDirectory, ...dependencyPathParts); - await mkdir(dirname(targetDirectory), { recursive: true }); - await cp(sourceDirectory, targetDirectory, { recursive: true }); - } -} - -async function writePackagedManifest() { - const sourcePackageJson = await readJson(join(repositoryRoot, 'package.json')); - const packagedManifest = { - name: sourcePackageJson.name, - productName, - version: sourcePackageJson.version, - description: sourcePackageJson.description, - main: sourcePackageJson.main, - author: sourcePackageJson.author, - license: sourcePackageJson.license, - }; - - await writeFile(join(packagedAppDirectory, 'package.json'), `${JSON.stringify(packagedManifest, null, 2)}\n`); -} - -async function main() { - if (process.platform !== 'win32') { - throw new Error('This packaging script currently targets Windows only.'); - } - - await Promise.all([ - ensurePathExists(assetDirectory, 'Application assets'), - ensurePathExists(sourceIconPath, 'Source application icon'), - ensurePathExists(windowsIconPath, 'Windows application icon'), - ensurePathExists(electronDistributionDirectory, 'Electron runtime'), - ensurePathExists(rendererBuildDirectory, 'Renderer build output'), - ensurePathExists(electronBuildDirectory, 'Electron build output'), - ensurePathExists(publishedSidecarDirectory, 'Published sidecar output'), - ]); - - const runtimeDependencies = await collectRuntimeDependencies(); - - await rm(outputDirectory, { recursive: true, force: true }); - await mkdir(outputDirectory, { recursive: true }); - await cp(electronDistributionDirectory, outputDirectory, { recursive: true }); - await rename(join(outputDirectory, 'electron.exe'), packagedExecutablePath); - - await mkdir(packagedAppDirectory, { recursive: true }); - await Promise.all([ - writePackagedManifest(), - cp(assetDirectory, join(packagedAppDirectory, 'assets'), { recursive: true }), - cp(rendererBuildDirectory, join(packagedAppDirectory, 'dist'), { recursive: true }), - cp(electronBuildDirectory, join(packagedAppDirectory, 'dist-electron'), { recursive: true }), - cp(publishedSidecarDirectory, join(outputResourcesDirectory, 'sidecar'), { recursive: true }), - ]); - await copyRuntimeDependencies(runtimeDependencies); - await rcedit(packagedExecutablePath, { icon: windowsIconPath }); - - console.log(`Packaged ${productName} to ${outputDirectory}`); - console.log(`Bundled ${runtimeDependencies.length} runtime dependencies and the self-contained .NET sidecar.`); -} - -await main(); diff --git a/scripts/package-electron.ts b/scripts/package-electron.ts new file mode 100644 index 0000000..eaedb37 --- /dev/null +++ b/scripts/package-electron.ts @@ -0,0 +1,287 @@ +import { constants } from 'node:fs'; +import { access, chmod, cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + macBundleIdentifier, + productName, + resolveReleaseTarget, + type ReleaseTarget, +} from './releaseTarget'; + +interface PackageManifest { + readonly name: string; + readonly productName: string; + readonly version: string; + readonly description?: string; + readonly main: string; + readonly author?: string; + readonly license?: string; +} + +interface RootPackageJson { + readonly name: string; + readonly version: string; + readonly description?: string; + readonly main: string; + readonly author?: string; + readonly license?: string; + readonly dependencies?: Record; +} + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDirectory, '..'); +const assetDirectory = join(repositoryRoot, 'assets'); +const genericIconPath = join(assetDirectory, 'icons', 'icon.png'); +const windowsIconPath = join(assetDirectory, 'icons', 'windows', 'icon.ico'); +const macosIconPath = join(assetDirectory, 'icons', 'macos', 'icon.icns'); +const rendererBuildDirectory = join(repositoryRoot, 'dist'); +const electronBuildDirectory = join(repositoryRoot, 'dist-electron'); +const releaseTarget = resolveReleaseTarget(process.platform, process.arch); +const releaseRootDirectory = join(repositoryRoot, 'release'); +const outputDirectory = join(releaseRootDirectory, releaseTarget.outputDirectoryName); +const electronDistributionDirectory = releaseTarget.platform === 'darwin' + ? join(repositoryRoot, 'node_modules', 'electron', 'dist', 'Electron.app') + : join(repositoryRoot, 'node_modules', 'electron', 'dist'); +const publishedSidecarDirectory = join(repositoryRoot, 'dist-sidecar', releaseTarget.dotnetRuntime); + +async function ensurePathExists(path: string, label: string): Promise { + try { + await access(path, constants.F_OK); + } catch { + throw new Error(`${label} was not found at ${path}.`); + } +} + +async function pathExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as T; +} + +async function collectRuntimeDependencies(): Promise { + const rootPackageJson = await readJson(join(repositoryRoot, 'package.json')); + const dependencies = new Set(Object.keys(rootPackageJson.dependencies ?? {})); + const queue = [...dependencies]; + + while (queue.length > 0) { + const dependencyName = queue.shift(); + if (!dependencyName) { + continue; + } + + const dependencyPackageJsonPath = join( + repositoryRoot, + 'node_modules', + ...dependencyName.split('/'), + 'package.json', + ); + if (!(await pathExists(dependencyPackageJsonPath))) { + dependencies.delete(dependencyName); + continue; + } + + const dependencyPackageJson = await readJson<{ + readonly dependencies?: Record; + readonly optionalDependencies?: Record; + }>(dependencyPackageJsonPath); + + for (const transitiveDependency of Object.keys({ + ...(dependencyPackageJson.dependencies ?? {}), + ...(dependencyPackageJson.optionalDependencies ?? {}), + })) { + if (!dependencies.has(transitiveDependency)) { + dependencies.add(transitiveDependency); + queue.push(transitiveDependency); + } + } + } + + return [...dependencies].sort(); +} + +async function copyRuntimeDependencies( + packagedAppDirectory: string, + dependencyNames: string[], +): Promise { + const packagedNodeModulesDirectory = join(packagedAppDirectory, 'node_modules'); + await mkdir(packagedNodeModulesDirectory, { recursive: true }); + + for (const dependencyName of dependencyNames) { + const dependencyPathParts = dependencyName.split('/'); + const sourceDirectory = join(repositoryRoot, 'node_modules', ...dependencyPathParts); + const targetDirectory = join(packagedNodeModulesDirectory, ...dependencyPathParts); + await mkdir(dirname(targetDirectory), { recursive: true }); + await cp(sourceDirectory, targetDirectory, { recursive: true }); + } +} + +async function writePackagedManifest(packagedAppDirectory: string): Promise { + const sourcePackageJson = await readJson(join(repositoryRoot, 'package.json')); + const packagedManifest: PackageManifest = { + name: sourcePackageJson.name, + productName, + version: sourcePackageJson.version, + description: sourcePackageJson.description, + main: sourcePackageJson.main, + author: sourcePackageJson.author, + license: sourcePackageJson.license, + }; + + await writeFile( + join(packagedAppDirectory, 'package.json'), + `${JSON.stringify(packagedManifest, null, 2)}\n`, + ); + + return packagedManifest; +} + +async function copyApplicationPayload( + packagedAppDirectory: string, + outputResourcesDirectory: string, + dependencyNames: string[], +): Promise { + await mkdir(packagedAppDirectory, { recursive: true }); + + const manifest = await writePackagedManifest(packagedAppDirectory); + await Promise.all([ + cp(assetDirectory, join(packagedAppDirectory, 'assets'), { recursive: true }), + cp(rendererBuildDirectory, join(packagedAppDirectory, 'dist'), { recursive: true }), + cp(electronBuildDirectory, join(packagedAppDirectory, 'dist-electron'), { recursive: true }), + cp(publishedSidecarDirectory, join(outputResourcesDirectory, 'sidecar'), { recursive: true }), + ]); + + await copyRuntimeDependencies(packagedAppDirectory, dependencyNames); + return manifest; +} + +async function ensureExecutable(path: string, mode = 0o755): Promise { + await chmod(path, mode); +} + +function replacePlistValue(plistContents: string, key: string, value: string): string { + const pattern = new RegExp(`(${key}\\s*)([^<]*)()`); + if (!pattern.test(plistContents)) { + throw new Error(`Could not find ${key} in macOS Info.plist.`); + } + + return plistContents.replace(pattern, `$1${value}$3`); +} + +async function applyMacMetadata(appBundleDirectory: string, version: string): Promise { + const infoPlistPath = join(appBundleDirectory, 'Contents', 'Info.plist'); + let infoPlistContents = await readFile(infoPlistPath, 'utf8'); + infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleDisplayName', productName); + infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleExecutable', productName); + infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleIconFile', 'icon.icns'); + infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleIdentifier', macBundleIdentifier); + infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleName', productName); + infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleShortVersionString', version); + infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleVersion', version); + await writeFile(infoPlistPath, infoPlistContents); + + const sourceExecutablePath = join(appBundleDirectory, 'Contents', 'MacOS', 'Electron'); + const targetExecutablePath = join(appBundleDirectory, 'Contents', 'MacOS', productName); + await rename(sourceExecutablePath, targetExecutablePath); + await ensureExecutable(targetExecutablePath); + await cp(macosIconPath, join(appBundleDirectory, 'Contents', 'Resources', 'icon.icns')); +} + +async function packageWindows(dependencyNames: string[]): Promise { + const packagedExecutablePath = join(outputDirectory, `${productName}.exe`); + const packagedAppDirectory = join(outputDirectory, 'resources', 'app'); + const outputResourcesDirectory = join(outputDirectory, 'resources'); + + await cp(electronDistributionDirectory, outputDirectory, { recursive: true }); + await rename(join(outputDirectory, 'electron.exe'), packagedExecutablePath); + await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames); + + const { rcedit } = await import('rcedit'); + await rcedit(packagedExecutablePath, { icon: windowsIconPath }); +} + +async function packageMac(dependencyNames: string[]): Promise { + const appBundleName = releaseTarget.appBundleName; + if (!appBundleName) { + throw new Error('macOS packaging requires an app bundle name.'); + } + + const appBundleDirectory = join(outputDirectory, appBundleName); + const packagedAppDirectory = join(appBundleDirectory, 'Contents', 'Resources', 'app'); + const outputResourcesDirectory = join(appBundleDirectory, 'Contents', 'Resources'); + + await cp(electronDistributionDirectory, appBundleDirectory, { recursive: true }); + const manifest = await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames); + await applyMacMetadata(appBundleDirectory, manifest.version); + await ensureExecutable(join(outputResourcesDirectory, 'sidecar', releaseTarget.sidecarExecutableName)); +} + +async function packageLinux(dependencyNames: string[]): Promise { + const packagedExecutableName = releaseTarget.packagedExecutableName; + if (!packagedExecutableName) { + throw new Error('Linux packaging requires a packaged executable name.'); + } + + const packagedExecutablePath = join(outputDirectory, packagedExecutableName); + const packagedAppDirectory = join(outputDirectory, 'resources', 'app'); + const outputResourcesDirectory = join(outputDirectory, 'resources'); + const chromeSandboxPath = join(outputDirectory, 'chrome-sandbox'); + + await cp(electronDistributionDirectory, outputDirectory, { recursive: true }); + await rename(join(outputDirectory, 'electron'), packagedExecutablePath); + await ensureExecutable(packagedExecutablePath); + await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames); + await ensureExecutable(join(outputResourcesDirectory, 'sidecar', releaseTarget.sidecarExecutableName)); + + if (await pathExists(chromeSandboxPath)) { + await chmod(chromeSandboxPath, 0o4755); + } +} + +async function packageCurrentPlatform(target: ReleaseTarget, dependencyNames: string[]): Promise { + switch (target.platform) { + case 'win32': + await packageWindows(dependencyNames); + return; + case 'darwin': + await packageMac(dependencyNames); + return; + case 'linux': + await packageLinux(dependencyNames); + return; + } +} + +await Promise.all([ + ensurePathExists(assetDirectory, 'Application assets'), + ensurePathExists(genericIconPath, 'Source application icon'), + ensurePathExists(electronDistributionDirectory, 'Electron runtime'), + ensurePathExists(rendererBuildDirectory, 'Renderer build output'), + ensurePathExists(electronBuildDirectory, 'Electron build output'), + ensurePathExists(publishedSidecarDirectory, 'Published sidecar output'), +]); + +if (releaseTarget.platform === 'win32') { + await ensurePathExists(windowsIconPath, 'Windows application icon'); +} + +if (releaseTarget.platform === 'darwin') { + await ensurePathExists(macosIconPath, 'macOS application icon'); +} + +const runtimeDependencies = await collectRuntimeDependencies(); + +await rm(outputDirectory, { recursive: true, force: true }); +await mkdir(releaseRootDirectory, { recursive: true }); +await packageCurrentPlatform(releaseTarget, runtimeDependencies); + +console.log(`Packaged ${productName} for ${releaseTarget.platformLabel} to ${outputDirectory}`); +console.log(`Bundled ${runtimeDependencies.length} runtime dependencies and the self-contained .NET sidecar.`); diff --git a/scripts/publish-sidecar.ts b/scripts/publish-sidecar.ts new file mode 100644 index 0000000..da0af63 --- /dev/null +++ b/scripts/publish-sidecar.ts @@ -0,0 +1,65 @@ +import { spawn } from 'node:child_process'; +import { rm } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { resolveReleaseTarget } from './releaseTarget'; + +function runCommand(command: string, args: string[], cwd: string): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd, + stdio: 'inherit', + }); + + child.on('error', rejectPromise); + child.on('exit', (code, signal) => { + if (code === 0) { + resolvePromise(); + return; + } + + if (signal) { + rejectPromise(new Error(`${command} exited because of signal ${signal}.`)); + return; + } + + rejectPromise(new Error(`${command} exited with code ${code ?? 'unknown'}.`)); + }); + }); +} + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDirectory, '..'); +const releaseTarget = resolveReleaseTarget(process.platform, process.arch); +const sidecarProjectPath = join( + repositoryRoot, + 'sidecar', + 'src', + 'Eryx.AgentHost', + 'Eryx.AgentHost.csproj', +); +const outputDirectory = join(repositoryRoot, 'dist-sidecar', releaseTarget.dotnetRuntime); + +await rm(outputDirectory, { recursive: true, force: true }); + +await runCommand( + 'dotnet', + [ + 'publish', + sidecarProjectPath, + '-c', + 'Release', + '-r', + releaseTarget.dotnetRuntime, + '--self-contained', + 'true', + '-p:DebugType=None', + '-p:DebugSymbols=false', + '-o', + outputDirectory, + ], + repositoryRoot, +); + +console.log(`Published sidecar for ${releaseTarget.platformLabel} (${releaseTarget.dotnetRuntime}) to ${outputDirectory}`); diff --git a/scripts/releaseTarget.ts b/scripts/releaseTarget.ts new file mode 100644 index 0000000..8252c74 --- /dev/null +++ b/scripts/releaseTarget.ts @@ -0,0 +1,83 @@ +export const productName = 'Eryx'; +export const macBundleIdentifier = 'com.davidkaya.eryx'; + +type SupportedPlatform = 'win32' | 'darwin' | 'linux'; +type SupportedArch = 'x64' | 'arm64'; + +export interface ReleaseTarget { + readonly platform: SupportedPlatform; + readonly arch: SupportedArch; + readonly platformLabel: 'windows' | 'macos' | 'linux'; + readonly dotnetRuntime: `${string}-${SupportedArch}`; + readonly outputDirectoryName: string; + readonly archiveBaseName: string; + readonly sidecarExecutableName: string; + readonly packagedExecutableName?: string; + readonly appBundleName?: string; +} + +function resolveSupportedArch( + platform: SupportedPlatform, + arch: NodeJS.Architecture, +): SupportedArch { + if (arch === 'x64' || arch === 'arm64') { + return arch; + } + + throw new Error(`Unsupported architecture for ${platform}: ${arch}`); +} + +export function resolveReleaseTarget( + platform: NodeJS.Platform, + arch: NodeJS.Architecture, +): ReleaseTarget { + switch (platform) { + case 'win32': { + const supportedArch = resolveSupportedArch(platform, arch); + const archiveBaseName = `${productName}-windows-${supportedArch}`; + + return { + platform, + arch: supportedArch, + platformLabel: 'windows', + dotnetRuntime: `win-${supportedArch}`, + outputDirectoryName: archiveBaseName, + archiveBaseName, + sidecarExecutableName: 'Eryx.AgentHost.exe', + packagedExecutableName: `${productName}.exe`, + }; + } + case 'darwin': { + const supportedArch = resolveSupportedArch(platform, arch); + const archiveBaseName = `${productName}-macos-${supportedArch}`; + + return { + platform, + arch: supportedArch, + platformLabel: 'macos', + dotnetRuntime: `osx-${supportedArch}`, + outputDirectoryName: archiveBaseName, + archiveBaseName, + sidecarExecutableName: 'Eryx.AgentHost', + appBundleName: `${productName}.app`, + }; + } + case 'linux': { + const supportedArch = resolveSupportedArch(platform, arch); + const archiveBaseName = `${productName}-linux-${supportedArch}`; + + return { + platform, + arch: supportedArch, + platformLabel: 'linux', + dotnetRuntime: `linux-${supportedArch}`, + outputDirectoryName: archiveBaseName, + archiveBaseName, + sidecarExecutableName: 'Eryx.AgentHost', + packagedExecutableName: productName, + }; + } + default: + throw new Error(`Unsupported release platform: ${platform}`); + } +} diff --git a/tests/scripts/releaseTarget.test.ts b/tests/scripts/releaseTarget.test.ts new file mode 100644 index 0000000..ba53d28 --- /dev/null +++ b/tests/scripts/releaseTarget.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test'; + +import { + macBundleIdentifier, + productName, + resolveReleaseTarget, +} from '../../scripts/releaseTarget'; + +describe('resolveReleaseTarget', () => { + test('maps Windows x64 to the expected runtime and output names', () => { + expect(resolveReleaseTarget('win32', 'x64')).toEqual({ + platform: 'win32', + arch: 'x64', + platformLabel: 'windows', + dotnetRuntime: 'win-x64', + outputDirectoryName: 'Eryx-windows-x64', + archiveBaseName: 'Eryx-windows-x64', + sidecarExecutableName: 'Eryx.AgentHost.exe', + packagedExecutableName: 'Eryx.exe', + }); + }); + + test('maps macOS arm64 to the expected bundle metadata', () => { + expect(resolveReleaseTarget('darwin', 'arm64')).toEqual({ + platform: 'darwin', + arch: 'arm64', + platformLabel: 'macos', + dotnetRuntime: 'osx-arm64', + outputDirectoryName: 'Eryx-macos-arm64', + archiveBaseName: 'Eryx-macos-arm64', + sidecarExecutableName: 'Eryx.AgentHost', + appBundleName: 'Eryx.app', + }); + }); + + test('maps Linux x64 to the expected runtime and output names', () => { + expect(resolveReleaseTarget('linux', 'x64')).toEqual({ + platform: 'linux', + arch: 'x64', + platformLabel: 'linux', + dotnetRuntime: 'linux-x64', + outputDirectoryName: 'Eryx-linux-x64', + archiveBaseName: 'Eryx-linux-x64', + sidecarExecutableName: 'Eryx.AgentHost', + packagedExecutableName: 'Eryx', + }); + }); + + test('exports the expected product metadata constants', () => { + expect(productName).toBe('Eryx'); + expect(macBundleIdentifier).toBe('com.davidkaya.eryx'); + }); + + test('rejects unsupported platforms', () => { + expect(() => resolveReleaseTarget('freebsd', 'x64')).toThrow( + 'Unsupported release platform: freebsd', + ); + }); + + test('rejects unsupported architectures', () => { + expect(() => resolveReleaseTarget('linux', 'ia32')).toThrow( + 'Unsupported architecture for linux: ia32', + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 2f4a92b..8b59e6b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ }, "include": [ "electron.vite.config.ts", + "scripts/**/*.ts", "src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"