UNPKG

app-builder-lib

Version:
419 lines 20.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DEFAULT_STAGE_PACKAGES = exports.SNAPCRAFT_YAML_OPTIONS = void 0; exports.buildSnap = buildSnap; const builder_util_1 = require("builder-util"); const childProcess = require("child_process"); const crypto_1 = require("crypto"); const electron_publish_1 = require("electron-publish"); const fs_extra_1 = require("fs-extra"); const path = require("path"); const util = require("util"); const builder_util_runtime_1 = require("builder-util-runtime"); const execAsync = util.promisify(childProcess.exec); exports.SNAPCRAFT_YAML_OPTIONS = { indent: 2, lineWidth: -1, noRefs: true }; exports.DEFAULT_STAGE_PACKAGES = ["libnspr4", "libnss3", "libxss1", "libappindicator3-1", "libsecret-1-0"]; /** * Validates snapcraft.yaml using snapcraft's built-in `expand-extensions` command. * Throws on failure. The caller in `buildSnap` catches this and treats it as a non-fatal warning. */ async function validateSnapcraftYamlWithCLI(workDir) { try { const { stdout } = await execAsync("snapcraft expand-extensions", { cwd: workDir, timeout: 30000, }); builder_util_1.log.debug({ expandedYaml: stdout }, "validated extended snapcraft.yaml"); } catch (error) { builder_util_1.log.error({ error: error.message, stderr: error.stderr }, "snapcraft.yaml validation failed"); throw new Error(`Invalid snapcraft.yaml: ${error.message}\n` + `Snapcraft output: ${error.stderr || error.stdout || "No output"}\n` + `Run 'snapcraft expand-extensions' in ${workDir} for more details`); } } /** * Validates snapcraft.yaml configuration with basic client-side checks * This is a fast pre-check before running the full CLI validation */ function validateSnapcraftConfig(config) { const errors = []; const warnings = []; // Required fields if (!config.name) { errors.push("name is required"); } if (!config.base) { errors.push("base is required"); } if (!config.confinement) { errors.push("confinement is required"); } if (!config.parts || Object.keys(config.parts).length === 0) { errors.push("at least one part is required"); } // Name validation if (config.name) { if (!/^[a-z0-9-]*$/.test(config.name)) { errors.push("name must only contain lowercase letters, numbers, and hyphens"); } if (config.name.length > 40) { errors.push("name must be 40 characters or less"); } if (config.name.startsWith("-") || config.name.endsWith("-")) { errors.push("name cannot start or end with a hyphen"); } } // Parts validation Object.entries(config.parts).forEach(([partName, part]) => { if (!part.plugin) { errors.push(`part '${partName}' missing required 'plugin' field`); } }); // Apps validation if (config.apps) { Object.entries(config.apps).forEach(([appName, app]) => { if (!app.command) { errors.push(`app '${appName}' missing required 'command' field`); } }); } // Summary validation if (config.summary && config.summary.length > 78) { warnings.push(`summary is ${config.summary.length} characters (recommended: 78 or less)`); } // Log results if (errors.length > 0) { builder_util_1.log.error({ errors }, "snapcraft.yaml validation failed"); throw new builder_util_1.InvalidConfigurationError(`Invalid snapcraft.yaml: ${errors.join(", ")}`); } if (warnings.length > 0) { builder_util_1.log.warn({ warnings }, "snapcraft.yaml validation warnings"); } } /** * Retry wrapper for operations that may fail transiently */ async function executeWithRetry(fn, options = {}) { var _a; const { maxRetries = 3, retryDelay = 5000, retryableErrors = ["network timeout", "connection refused", "temporary failure", "snap store error"] } = options; let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; const errorMessage = ((_a = error.message) === null || _a === void 0 ? void 0 : _a.toLowerCase()) || ""; const isRetryable = retryableErrors.some(pattern => errorMessage.includes(pattern)); if (attempt < maxRetries && isRetryable) { builder_util_1.log.warn({ attempt, maxRetries, error: error.message, retryIn: retryDelay }, "build failed with retryable error, retrying..."); await new Promise(resolve => setTimeout(resolve, retryDelay)); } else { break; } } } throw lastError; } /** * Cleans up build artifacts */ async function cleanupBuildArtifacts(workDir) { const artifactsToClean = ["parts", "stage", "prime"]; for (const artifact of artifactsToClean) { const artifactPath = path.join(workDir, artifact); try { await (0, fs_extra_1.remove)(artifactPath); builder_util_1.log.debug({ artifact }, "cleaned build artifact"); } catch (e) { builder_util_1.log.debug({ artifact, error: e.message }, "no build artifact to clean"); } } try { const files = await (0, fs_extra_1.readdir)(workDir); for (const file of files) { if (file.endsWith(".snap")) { await (0, fs_extra_1.remove)(path.join(workDir, file)); builder_util_1.log.debug({ file }, "cleaned snap file"); } } } catch (e) { builder_util_1.log.debug({ error: e.message }, "no snap files to clean"); } } async function copySnapToArtifactPath(workDir, outputBasename, outputFileName) { const snapInWorkDir = path.join(workDir, outputBasename); if (snapInWorkDir !== outputFileName) { await (0, fs_extra_1.ensureDir)(path.dirname(outputFileName)); await (0, fs_extra_1.copyFile)(snapInWorkDir, outputFileName); builder_util_1.log.debug({ from: snapInWorkDir, to: outputFileName }, "copied snap from build dir to artifact path"); } return outputFileName; } /** * Builds a snap package from SnapcraftYAML configuration. * * `SNAPCRAFT_NO_NETWORK` is intentionally **not** forced to `"1"` here. * All build modes (destructive-mode, LXD, Multipass, remote) require network * access to download stage-packages, the base image, and extensions. * To opt into an offline build, set `SNAPCRAFT_NO_NETWORK=1` in your environment. */ async function buildSnap(options) { const { SNAPCRAFT_NO_NETWORK } = process.env; const { snapcraftConfig, artifactPath, remoteBuild, stageDir, useLXD = false, useMultipass = false, useDestructiveMode = false, cscLink } = options; const isolatedEnv = { ...(SNAPCRAFT_NO_NETWORK != null ? { SNAPCRAFT_NO_NETWORK } : {}), }; if (useDestructiveMode) { isolatedEnv.SNAPCRAFT_BUILD_ENVIRONMENT = "host"; } if (useLXD && process.platform !== "linux") { throw new builder_util_1.InvalidConfigurationError(`useLXD is only supported on Linux. On ${process.platform}, use useMultipass or remoteBuild instead.`); } if (useDestructiveMode && process.platform !== "linux") { throw new builder_util_1.InvalidConfigurationError(`useDestructiveMode is only supported on Linux (requires Ubuntu 24.04 host for core24). On ${process.platform}, use useMultipass or remoteBuild instead.`); } // Config validation — throws InvalidConfigurationError, no build artifacts exist yet. validateSnapcraftConfig(snapcraftConfig); try { await validateSnapcraftYamlWithCLI(stageDir); } catch (validationError) { builder_util_1.log.warn({ error: validationError.message }, "snapcraft CLI pre-validation failed (non-fatal), continuing build"); } await ensureSnapcraftInstalled(); // Inject credentials for all build modes from snapcraft.cscLink / SNAP_CSC_LINK. const credEnv = await (0, electron_publish_1.resolveSnapCredentials)(cscLink, options.packager.buildResourcesDir); (0, builder_util_runtime_1.deepAssign)(isolatedEnv, credEnv); if (remoteBuild === null || remoteBuild === void 0 ? void 0 : remoteBuild.enabled) { // Remote-build auth does additional checks (interactive session, throws on missing creds) // and overrides any credential already set above. const authEnv = await ensureRemoteBuildAuthentication(cscLink, options.packager.buildResourcesDir); (0, builder_util_runtime_1.deepAssign)(isolatedEnv, authEnv); } const projectAppDir = path.join(stageDir, "app"); if (!(await (0, fs_extra_1.pathExists)(projectAppDir))) { throw new builder_util_1.InvalidConfigurationError(`snap build failed: expected app directory not found at ${projectAppDir}`); } builder_util_1.log.debug({ appFiles: (await (0, fs_extra_1.readdir)(projectAppDir)).slice(0, 20) }, "app directory contents (truncated)"); if (!(remoteBuild === null || remoteBuild === void 0 ? void 0 : remoteBuild.enabled) && !useLXD && !useMultipass && !useDestructiveMode && process.platform !== "linux") { throw new builder_util_1.InvalidConfigurationError(`No snap build environment specified for ${process.platform}. Set one of: useMultipass, useLXD (Linux only), useDestructiveMode (Linux only), or remoteBuild.enabled`); } // Actual build — only this step can leave partial artifacts that need cleanup. try { return await executeWithRetry(() => executeSnapcraftBuild({ workDir: stageDir, remoteBuild, outputSnap: artifactPath, useLXD, useMultipass, useDestructiveMode, isolatedEnv: isolatedEnv, }), { maxRetries: (remoteBuild === null || remoteBuild === void 0 ? void 0 : remoteBuild.enabled) ? 3 : 1, retryDelay: 10000 }); } catch (error) { builder_util_1.log.error({ error: error.message }, "snap build failed"); await cleanupBuildArtifacts(stageDir).catch((cleanupError) => { builder_util_1.log.warn({ error: cleanupError.message }, "failed to cleanup build artifacts"); }); throw error; } } /** * Ensures snapcraft is installed on the system */ async function ensureSnapcraftInstalled() { try { const { stdout } = await execAsync("snapcraft --version"); builder_util_1.log.info({ version: stdout.trim() }, "snapcraft found"); } catch (error) { builder_util_1.log.error({ error: error.message }, "snapcraft is not installed"); const platform = process.platform; if (platform === "linux") { builder_util_1.log.error(null, "Install with: sudo snap install snapcraft --classic"); } else if (platform === "darwin") { builder_util_1.log.error(null, "Install snapcraft with: pip3 install snapcraft"); builder_util_1.log.error(null, "On macOS, useMultipass or remoteBuild are the only supported build modes for core24"); } else if (platform === "win32") { builder_util_1.log.error(null, "Install snapcraft via WSL2 or use remote-build"); builder_util_1.log.error(null, "See: https://snapcraft.io/docs/snapcraft-overview"); } throw new builder_util_1.InvalidConfigurationError("snapcraft not found - please install snapcraft to continue"); } } /** * Resolves Snapcraft Store authentication for remote builds and returns the credential * env entries to inject. Returns an empty map when snapcraft can authenticate itself * (interactive session). Throws when no credential source is found. */ async function ensureRemoteBuildAuthentication(cscLink, resourcesDir) { var _a; builder_util_1.log.debug(null, "resolving remote build authentication..."); // 1. snapcraft.cscLink / SNAP_CSC_LINK — config-level or env credential (base64 or file path). // resolveSnapCredentials already ran for all build modes; re-run here so remote-build // gets the same result and the interactive-session fallback is only reached when neither is set. const credEnv = await (0, electron_publish_1.resolveSnapCredentials)(cscLink, resourcesDir); if (Object.keys(credEnv).length > 0) { return credEnv; } // 2. SNAPCRAFT_STORE_CREDENTIALS env var — directly provide the credentials string (not base64-encoded). const SNAPCRAFT_STORE_CREDENTIALS = (_a = process.env.SNAPCRAFT_STORE_CREDENTIALS) === null || _a === void 0 ? void 0 : _a.trim(); if (!(0, builder_util_1.isEmptyOrSpaces)(SNAPCRAFT_STORE_CREDENTIALS)) { builder_util_1.log.debug(null, "using SNAPCRAFT_STORE_CREDENTIALS from environment, verbatim"); return { SNAPCRAFT_STORE_CREDENTIALS }; } // 3. Interactive snapcraft session. try { const { stdout } = await execAsync("snapcraft whoami"); if (stdout.includes("email:")) { builder_util_1.log.debug({ account: stdout.trim() }, "already authenticated with snapcraft"); return {}; } } catch { // Not logged in, fall through to error. } throw new builder_util_1.InvalidConfigurationError("Snapcraft authentication required for remote build.\n" + "Authenticate with one of any:\n" + " 1. Set SNAP_CSC_LINK\n" + " 2. Set snapcraft.cscLink in your build config\n" + " 3. Run: snapcraft login\n" + " 4. Set SNAPCRAFT_STORE_CREDENTIALS environment variable directly"); } /** * Executes the snapcraft build command */ async function executeSnapcraftBuild(options) { const { workDir, outputSnap: outputFileName, remoteBuild, useLXD, useMultipass, useDestructiveMode, isolatedEnv } = options; let processedEnv = { ...(0, builder_util_1.stripSensitiveEnvVars)(process.env), ...isolatedEnv }; // Use a UUID-based temp name as the --output target so the copy below doesn't // depend on snapcraft's naming convention (which always uses underscores). const tmpSnap = `eb-snap-${(0, crypto_1.randomUUID)().replace(/-/g, "")}.snap`; if (useDestructiveMode && !(remoteBuild === null || remoteBuild === void 0 ? void 0 : remoteBuild.enabled)) { return await runDestructiveBuild(workDir, processedEnv, tmpSnap, outputFileName); } const command = "snapcraft"; const args = []; if (remoteBuild === null || remoteBuild === void 0 ? void 0 : remoteBuild.enabled) { const remoteBuildArgs = generateRemoteBuildArgs(remoteBuild, workDir); // Remote build on Launchpad (works from any platform) args.push(...remoteBuildArgs.args); processedEnv = { ...processedEnv, ...remoteBuildArgs.isolatedEnv }; } else { // `snapcraft pack` runs the full lifecycle (pull → build → stage → prime → pack). // snapcraft 8.x removed --use-multipass entirely; Multipass is now configured // via the SNAPCRAFT_BUILD_ENVIRONMENT env var (or auto-selected on macOS). // --use-lxd remains a supported CLI flag on `pack`. args.push("pack"); if (useLXD) { args.push("--use-lxd"); builder_util_1.log.debug(null, "using LXD for build"); } else if (useMultipass) { processedEnv.SNAPCRAFT_BUILD_ENVIRONMENT = "multipass"; builder_util_1.log.debug(null, "using Multipass for build (via SNAPCRAFT_BUILD_ENVIRONMENT)"); } else { args.push("--output", tmpSnap); } } if (builder_util_1.log.isDebugEnabled) { args.push("--verbose"); } builder_util_1.log.info({ workDir: builder_util_1.log.filePath(workDir) }, "executing snapcraft"); await (0, builder_util_1.spawn)(command, args, { cwd: workDir, env: processedEnv, }); if ((remoteBuild === null || remoteBuild === void 0 ? void 0 : remoteBuild.enabled) || useLXD || useMultipass) { // snapcraft names the output snap itself (e.g. <name>_<version>_<arch>.snap). // Each electron-builder build invocation targets exactly one arch, so exactly one snap is expected. const files = await (0, fs_extra_1.readdir)(workDir); const builtSnap = files.find(f => f.endsWith(".snap")); if (!builtSnap) { throw new Error(`Build succeeded but no .snap file found in ${workDir}`); } return copySnapToArtifactPath(workDir, builtSnap, outputFileName); } return copySnapToArtifactPath(workDir, tmpSnap, outputFileName); } function generateRemoteBuildArgs(remoteBuild, workDir) { const isolatedEnv = {}; const args = ["remote-build"]; builder_util_1.log.debug(null, "using remote-build (Launchpad)"); // Add remote build specific options if (remoteBuild.launchpadUsername) { args.push("--user", remoteBuild.launchpadUsername); } if (remoteBuild.acceptPublicUpload) { args.push("--launchpad-accept-public-upload"); } else { builder_util_1.log.warn(null, "your project will be publicly uploaded to Launchpad. Use `acceptPublicUpload: true` to suppress this warning"); } if (remoteBuild.privateProject) { args.push("--project", remoteBuild.privateProject); builder_util_1.log.debug({ project: remoteBuild.privateProject }, "using private Launchpad project"); } if (remoteBuild.buildFor) { args.push("--build-for", remoteBuild.buildFor); builder_util_1.log.debug({ arch: remoteBuild.buildFor }, "building for architecture"); } if (remoteBuild.recover) { args.push("--recover"); builder_util_1.log.debug(null, "recovering previous build"); } if (remoteBuild.strategy) { isolatedEnv.SNAPCRAFT_REMOTE_BUILD_STRATEGY = remoteBuild.strategy; } if (remoteBuild.timeout) { args.push("--timeout", String(remoteBuild.timeout)); builder_util_1.log.debug({ timeout: `${remoteBuild.timeout}s` }, "build timeout configured"); } // Remote-build downloads the finished snap into workDir. // --output-dir (not --output <file>) lets snapcraft name the file itself. args.push("--output-dir", workDir); return { args, isolatedEnv }; } // Snapcraft 8 (craft-application) hangs after a successful destructive-mode // build in containerised environments (Docker/CI without snapd running). // craft-application's PackageService teardown tries to reach // /run/snapd-snap.socket (snapctl IPC) which doesn't exist without a live // snapd daemon — causing an indefinite block. // // Work-around: split into two steps: // 1. `snapcraft prime --destructive-mode` — runs pull/build/stage/prime, // exits cleanly (no post-pack teardown executed). // 2. `snapcraft pack <primeDir>` — packs the pre-primed directory without // running the full build lifecycle, avoiding the problematic teardown. async function runDestructiveBuild(workDir, processedEnv, tmpSnap, outputFileName) { const primeArgs = ["prime", "--destructive-mode"]; if (builder_util_1.log.isDebugEnabled) { primeArgs.push("--verbose"); } builder_util_1.log.info({ command: `snapcraft ${primeArgs.join(" ")}`, workDir: builder_util_1.log.filePath(workDir) }, "snapcraft prime (1/2)"); await (0, builder_util_1.spawn)("snapcraft", primeArgs, { cwd: workDir, env: processedEnv, }); const primeDir = path.join(workDir, "prime"); const snapcraftPackArgs = ["pack", "--output", tmpSnap, primeDir]; if (builder_util_1.log.isDebugEnabled) { snapcraftPackArgs.push("--verbose"); } builder_util_1.log.info({ command: `snapcraft ${snapcraftPackArgs.join(" ")}`, workDir: builder_util_1.log.filePath(workDir) }, "snapcraft pack prime dir (2/2)"); await (0, builder_util_1.spawn)("snapcraft", snapcraftPackArgs, { cwd: workDir, env: processedEnv, }); return copySnapToArtifactPath(workDir, tmpSnap, outputFileName); } //# sourceMappingURL=snapcraftBuilder.js.map