forked from olcxjas-softworks/LarpixClient
419 lines
No EOL
20 KiB
JavaScript
419 lines
No EOL
20 KiB
JavaScript
"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
|