"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Application = exports.InvalidInstallTimeoutError = exports.InvalidInstallCommandError = exports.InvalidInstallPropertyError = exports.InvalidPackageIdentifierError = void 0;
exports.assertApplicationConfig = assertApplicationConfig;
exports.extractApplication = extractApplication;
exports.installApplication = installApplication;
exports.derivePackageIdentifier = derivePackageIdentifier;
exports.prepareApplication = prepareApplication;
exports.installApplications = installApplications;
exports.nonInteractiveSpawn = nonInteractiveSpawn;
exports.getEnvBuiltInComponents = getEnvBuiltInComponents;
const configUtils_js_1 = require("../config/configUtils.js");
const hdbTerms_js_1 = require("../utility/hdbTerms.js");
const harper_logger_js_1 = __importDefault(require("../utility/logging/harper_logger.js"));
const node_path_1 = require("node:path");
const promises_1 = require("node:fs/promises");
const node_child_process_1 = require("node:child_process");
const node_fs_1 = require("node:fs");
const node_stream_1 = require("node:stream");
const promises_2 = require("node:stream/promises");
const tar_fs_1 = require("tar-fs");
const gunzip_maybe_1 = __importDefault(require("gunzip-maybe"));
class InvalidPackageIdentifierError extends TypeError {
    constructor(applicationName, packageIdentifier) {
        super(`Invalid 'package' property for application ${applicationName}: expected string, got ${typeof packageIdentifier}`);
    }
}
exports.InvalidPackageIdentifierError = InvalidPackageIdentifierError;
class InvalidInstallPropertyError extends TypeError {
    constructor(applicationName, installProperty) {
        super(`Invalid 'install' property for application ${applicationName}: expected object, got ${typeof installProperty}`);
    }
}
exports.InvalidInstallPropertyError = InvalidInstallPropertyError;
class InvalidInstallCommandError extends TypeError {
    constructor(applicationName, command) {
        super(`Invalid 'install.command' property for application ${applicationName}: expected string, got ${typeof command}`);
    }
}
exports.InvalidInstallCommandError = InvalidInstallCommandError;
class InvalidInstallTimeoutError extends TypeError {
    constructor(applicationName, timeout) {
        super(`Invalid 'install.timeout' property for application ${applicationName}: expected non-negative number, got ${typeof timeout}`);
    }
}
exports.InvalidInstallTimeoutError = InvalidInstallTimeoutError;
function assertApplicationConfig(applicationName, applicationConfig) {
    if (typeof applicationConfig.package !== 'string') {
        throw new InvalidPackageIdentifierError(applicationName, applicationConfig.package);
    }
    if ('install' in applicationConfig) {
        if (typeof applicationConfig.install !== 'object' ||
            applicationConfig.install === null ||
            Array.isArray(applicationConfig.install)) {
            throw new InvalidInstallPropertyError(applicationName, applicationConfig.install);
        }
        if ('command' in applicationConfig.install && typeof applicationConfig.install.command !== 'string') {
            throw new InvalidInstallCommandError(applicationName, applicationConfig.install.command);
        }
        if ('timeout' in applicationConfig.install &&
            (typeof applicationConfig.install.timeout !== 'number' || applicationConfig.install.timeout < 0)) {
            throw new InvalidInstallTimeoutError(applicationName, applicationConfig.install.timeout);
        }
        if ('allowInstallScripts' in applicationConfig.install &&
            typeof applicationConfig.install.allowInstallScripts !== 'boolean') {
            throw new (class InvalidInstallAllowScriptsError extends TypeError {
            })(`Invalid 'install.allowInstallScripts' property for application ${applicationName}: expected boolean, got ${typeof applicationConfig.install.allowInstallScripts}`);
        }
    }
}
/**
 * Extract an application given payload (content of the application) or package (npm-compatible identifier to the application).
 *
 * Only one of `application.payload` or `application.package` should be specified; otherwise, an error is thrown.
 *
 * Writes the application to the configured components root directory using the `application.name` and overwrites any existing directory.
 *
 * This method should only be called from the main thread
 */
async function extractApplication(application) {
    // Can't specify neither
    if (!application.payload && !application.packageIdentifier) {
        throw new Error('Either payload or package must be provided');
    }
    // Can't specify both
    if (application.payload && application.packageIdentifier) {
        throw new Error('Both payload and package cannot be provided');
    }
    // Resolve the tarball from the input
    let tarballPath;
    let tarball;
    if (application.payload) {
        // Given a payload, create a Readable from the Buffer or string
        tarball = node_stream_1.Readable.from(application.payload instanceof Buffer ? application.payload : Buffer.from(application.payload, 'base64'));
    }
    else {
        // Given a package, there are a a couple options
        const parentDirPath = (0, node_path_1.dirname)(application.dirPath);
        // If the package identifier is a file path we need to check if its a tarball or a directory
        if (application.packageIdentifier.startsWith('file:')) {
            const packagePath = application.packageIdentifier.slice(5);
            try {
                // Have to remove the 'file:' prefix in order to use fs methods
                const stats = await (0, promises_1.stat)(packagePath);
                if (stats.isDirectory()) {
                    // If its a directory, symlink
                    await (0, promises_1.symlink)(packagePath, application.dirPath, 'dir');
                    // And return early since we're done; no extraction needed
                    return;
                }
                if (!stats.isFile()) {
                    throw new Error(`File path specified in package identifier is not a file or directory: ${packagePath}`);
                }
                // If its a file, we assume it can be unzipped and extracted.
                // We are using maybe-gunzip to handle both gzipped and non-gzipped tarballs
                // And then we are happy to let the `tar-fs` library handle the extraction.
                // Maybe worth adding some detection or at least some error handling if that step below fails.
                tarballPath = packagePath;
                tarball = (0, node_fs_1.createReadStream)(tarballPath);
            }
            catch (err) {
                if (err.code === 'ENOENT') {
                    throw new Error(`File path specified in package identifier does not exist: ${packagePath}`);
                }
                else {
                    throw err;
                }
            }
        }
        else {
            // `npm pack --json` writes a JSON array describing the packed tarball(s).
            const { stdout, code, stderr } = await nonInteractiveSpawn(application.name, 'npm', ['pack', '--json', application.packageIdentifier], parentDirPath);
            if (code !== 0) {
                throw new Error(`Failed to download package ${application.packageIdentifier}: ${stderr}`);
            }
            let packResult;
            try {
                packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
            }
            catch (err) {
                throw new Error(`Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`);
            }
            if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
                throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
            }
            tarballPath = (0, node_path_1.join)(parentDirPath, packResult[0].filename);
            tarball = (0, node_fs_1.createReadStream)(tarballPath);
        }
    }
    // Create the application directory
    try {
        await (0, promises_1.access)(application.dirPath, promises_1.constants.F_OK);
        // directory already exists; clear it
        await (0, promises_1.rm)(application.dirPath, { recursive: true, force: true });
    }
    catch (err) {
        // Ignore does not exist error
        if (err.code !== 'ENOENT') {
            throw err;
        }
    }
    // Finally, create the application directory fresh
    await (0, promises_1.mkdir)(application.dirPath, { recursive: true });
    // Now pipeline the tarball into maybe-gunzip then tar-fs to reliably decompress and extract the contents
    await (0, promises_2.pipeline)(tarball, (0, gunzip_maybe_1.default)(), (0, tar_fs_1.extract)(application.dirPath));
    // If the extracted directory contains a single folder, move the contents up one level
    // The `npm pack` command does this (the top-level folder is called "package")
    // Other packing tools may have similar behavior, but the directory name is not guaranteed.
    const extracted = await (0, promises_1.readdir)(application.dirPath, { withFileTypes: true });
    if (extracted.length === 1 && extracted[0].isDirectory()) {
        const topLevelDirPath = (0, node_path_1.join)(application.dirPath, extracted[0].name);
        const tempDirPath = await (0, promises_1.mkdtemp)(application.dirPath);
        // Copy contents of top-level directory to temp directory (in order to avoid collisions of top-level directory name and one of the contents)
        await (0, promises_1.cp)(topLevelDirPath, tempDirPath, { recursive: true });
        // Remove top-level directory
        await (0, promises_1.rm)(topLevelDirPath, { recursive: true, force: true });
        // Copy contents of temp directory to application directory
        await (0, promises_1.cp)(tempDirPath, application.dirPath, { recursive: true });
        // Finally, remove the temp dir
        await (0, promises_1.rm)(tempDirPath, { recursive: true, force: true });
    }
    // Clean up the original tarball
    if (tarballPath) {
        await (0, promises_1.rm)(tarballPath, { force: true });
    }
}
/**
 * Install an application to its relative `application.dirPath` using either a
 * configured `application.install` command, a derived package manager from the
 * application's `package.json#devEngines`, or falling back to the default
 * package manager, `npm`.
 *
 * Will return early if `node_modules` already exists within the `application.dirPath`
 *
 * This method should only be called from the main thread
 */
async function installApplication(application) {
    let packageJSON;
    try {
        packageJSON = JSON.parse(await (0, promises_1.readFile)((0, node_path_1.join)(application.dirPath, 'package.json'), 'utf8'));
    }
    catch (err) {
        if (err.code !== 'ENOENT')
            throw err;
        // If no package.json, nothing to install
        application.logger.debug(`Application ${application.name} has no package.json; skipping install`);
        return;
    }
    try {
        // Does node_modules exist?
        await (0, promises_1.access)((0, node_path_1.join)(application.dirPath, 'node_modules'), promises_1.constants.F_OK);
        application.logger.debug(`Application ${application.name} already has node_modules; skipping install`);
        return;
    }
    catch (err) {
        if (err.code !== 'ENOENT')
            throw err;
        // If node_modules doesn't exist, we need to install dependencies
    }
    // If custom install command is specified, run it
    if (application.install?.command) {
        const [command, ...args] = application.install.command.split(' ');
        const { stdout, stderr, code } = await nonInteractiveSpawn(application.name, command, args, application.dirPath, application.install?.timeout);
        // if it succeeds, return
        if (code === 0) {
            return;
        }
        if (stdout) {
            printStd(application.name, command, stdout, 'stdout', 'warn');
        }
        if (stderr) {
            printStd(application.name, command, stderr, 'stderr', 'warn');
        }
        // and throw a descriptive error
        throw new Error(`Failed to install dependencies for ${application.name} using custom install command: ${application.install.command}. Exit code: ${code}`);
    }
    // Next, try package.json devEngines field
    const { packageManager } = packageJSON.devEngines || {};
    // Custom package manager specified
    if (packageManager) {
        // On any given system we want to leverage the `name` to match the package manager executable
        let onFail = packageManager.onFail;
        const validOnFailValues = ['ignore', 'warn', 'error'];
        if (onFail === 'download') {
            application.logger.warn('Harper currently does not support `devEngines.packageManager.onFail = "download"`. Defaulting to "error"');
            onFail = 'error';
        }
        else if (onFail && !validOnFailValues.includes(onFail)) {
            application.logger.error(`Invalid \`devEngines.packageManager.onFail\` value: "${onFail}". Expected one of ${validOnFailValues.map((v) => `"${v}"`).join(', ')}. Defaulting to "error"`);
            onFail = 'error';
        }
        onFail = onFail || 'error';
        // TODO: Implement a version check / resolution system
        // For example, say they specify a specific major version for their package manager
        // Maybe on our system, we have all of the supported majors (for a given Node.js major) of any supported package manager.
        // Then we can do something like <name>@<version> for the corresponding executable.
        // `devEngines: { packageManager: { name: 'pnpm', version: '>=7' } }`
        // Would result in `pnpm@7` being used as the executable.
        // Important note: an `npm` version should not be specifiable; the only valid npm version is the one installed alongside Node.js
        const { stdout, stderr, code } = await nonInteractiveSpawn(application.name, (application.packageManagerPrefix ? application.packageManagerPrefix + ' ' : '') + packageManager.name, application.install?.allowInstallScripts ? ['install'] : ['install', '--ignore-scripts'], // All of `npm`, `yarn`, and `pnpm` support the `install` command. If we need to configure options here we may have to use some other defaults though
        application.dirPath, application.install?.timeout);
        // if it succeeds, return
        if (code === 0) {
            return;
        }
        // Otherwise handle failure case based on `onFail` value
        if (onFail === 'error') {
            // Log the std outputs using the error log level (in case the user doesn't have debug level set)
            if (stdout) {
                printStd(application.name, packageManager.name, stdout, 'stdout', 'error');
            }
            if (stderr) {
                printStd(application.name, packageManager.name, stderr, 'stderr', 'error');
            }
            // And throw an error instead of continuing
            throw new Error(`Failed to install dependencies for ${application.name} using ${packageManager.name}. Exit code: ${code}`);
        }
        // If onFail is 'warn', print outputs using the warn level, plus an additional message
        if (onFail === 'warn') {
            if (stdout) {
                printStd(application.name, packageManager.name, stdout, 'stdout', 'warn');
            }
            if (stderr) {
                printStd(application.name, packageManager.name, stderr, 'stderr', 'warn');
            }
            application.logger.warn(`Failed to install dependencies for ${application.name} using ${packageManager.name}. Exit code: ${code}`);
        }
        // But then fall through to installing with npm
    }
    // Finally, default to running `npm install`
    const npmInstallArgs = application.install?.allowInstallScripts
        ? ['install', '--force']
        : ['install', '--force', '--ignore-scripts'];
    const { stdout, stderr, code } = await nonInteractiveSpawn(application.name, (application.packageManagerPrefix ? application.packageManagerPrefix + ' ' : '') + 'npm', npmInstallArgs, application.dirPath, application.install?.timeout);
    // if it succeeds, return
    if (code === 0) {
        return;
    }
    // Otherwise, print the stdout and stderr outputs
    if (stdout) {
        printStd(application.name, 'npm', stdout, 'stdout', 'warn');
    }
    if (stderr) {
        printStd(application.name, 'npm', stderr, 'stderr', 'error');
    }
    // and throw a descriptive error
    throw new Error(`Failed to install dependencies for ${application.name} using npm default. Exit code: ${code}`);
}
class Application {
    name;
    payload;
    packageIdentifier;
    install;
    dirPath;
    logger;
    packageManagerPrefix; // can be used to configure a package manager prefix, specifically "sfw".
    constructor({ name, payload, packageIdentifier, install }) {
        this.name = name;
        this.payload = payload;
        this.packageIdentifier = packageIdentifier && derivePackageIdentifier(packageIdentifier);
        this.install = install;
        const componentsRoot = (0, configUtils_js_1.getConfigPath)(hdbTerms_js_1.CONFIG_PARAMS.COMPONENTSROOT);
        if (!componentsRoot)
            throw new Error('componentsRoot is not configured');
        this.dirPath = (0, node_path_1.join)(componentsRoot, name);
        this.logger = harper_logger_js_1.default.loggerWithTag(name);
        this.packageManagerPrefix = (0, configUtils_js_1.getConfigValue)(hdbTerms_js_1.CONFIG_PARAMS.APPLICATIONS_PACKAGEMANAGERPREFIX);
    }
}
exports.Application = Application;
/**
 * Based on an old implementation for a method called `getPkgPrefix()` that was used
 * during the installation process in order to actually resolve what the user specifies for a
 * component matching some of npm's package resolution rules.
 */
function derivePackageIdentifier(packageIdentifier) {
    if (packageIdentifier.includes(':')) {
        return packageIdentifier;
    }
    if (packageIdentifier.startsWith('@') || (!packageIdentifier.startsWith('@') && !packageIdentifier.includes('/'))) {
        return `npm:${packageIdentifier}`;
    }
    if ((0, node_path_1.extname)(packageIdentifier) || (0, node_fs_1.existsSync)(packageIdentifier)) {
        return `file:${packageIdentifier}`;
    }
    return `github:${packageIdentifier}`;
}
/**
 * Extract and install the specified application.
 *
 * This method should only be called from the main thread
 *
 * @param application The application to prepare.
 * @returns A promise that resolves when all preparation steps complete.
 */
function prepareApplication(application) {
    return extractApplication(application).then(() => installApplication(application));
}
/**
 * Install all applications specified in the root config.
 *
 * This method should only be called from the main thread otherwise certain
 * operations may conflict with each other (such as writing to the same directory).
 */
async function installApplications() {
    const applicationInstallationPromises = [];
    // first install any built-in components specified from env vars
    for (const { name, packageIdentifier } of getEnvBuiltInComponents()) {
        if (packageIdentifier.startsWith('@/')) {
            // this is a package relative module id, so later we will resolve it, but we don't need to install anything
            continue;
        }
        const application = new Application({
            name,
            packageIdentifier,
        });
        applicationInstallationPromises.push(prepareApplication(application));
    }
    const config = (0, configUtils_js_1.getConfigObj)();
    const componentsRootDirPath = (0, configUtils_js_1.getConfigPath)(hdbTerms_js_1.CONFIG_PARAMS.COMPONENTSROOT);
    if (!componentsRootDirPath)
        throw new Error('componentsRoot is not configured');
    // Ensure component directory exists
    await (0, promises_1.mkdir)(componentsRootDirPath, { recursive: true });
    const harperApplicationLockPath = (0, node_path_1.join)((0, configUtils_js_1.getConfigValue)(hdbTerms_js_1.CONFIG_PARAMS.ROOTPATH), 'harper-application-lock.json');
    let harperApplicationLock = { applications: {} };
    try {
        harperApplicationLock = JSON.parse(await (0, promises_1.readFile)(harperApplicationLockPath, 'utf8'));
    }
    catch (error) {
        // Ignore file not found error; will create new lock file after installations
        if (error.code !== 'ENOENT') {
            throw error;
        }
    }
    for (const [name, applicationConfig] of Object.entries(config)) {
        // Pre-validation check if the configuration is actually for an application
        // Don't want to throw an error here as the config may contain non-application entries
        if (typeof applicationConfig !== 'object' || applicationConfig === null || !('package' in applicationConfig)) {
            continue;
        }
        try {
            // Then do proper error-based validation with TypeScript `asserts` to provide type safety
            // This will throw if the config is invalid
            assertApplicationConfig(name, applicationConfig);
            const application = new Application({
                name,
                packageIdentifier: applicationConfig.package,
                install: applicationConfig.install,
            });
            // Lock check: only install if not already installed with matching configuration
            if ((0, node_fs_1.existsSync)(application.dirPath) &&
                harperApplicationLock.applications[name] &&
                JSON.stringify(harperApplicationLock.applications[name]) === JSON.stringify(applicationConfig)) {
                harper_logger_js_1.default.info?.(`Application ${name} is already installed with matching configuration; skipping installation`);
                continue;
            }
            applicationInstallationPromises.push(prepareApplication(application));
            harperApplicationLock.applications[name] = applicationConfig;
        }
        catch (error) {
            harper_logger_js_1.default.error?.(`Skipping installation of application ${name} due to invalid configuration: ${error.message}`);
        }
    }
    const applicationInstallationStatuses = await Promise.allSettled(applicationInstallationPromises);
    harper_logger_js_1.default.debug?.(applicationInstallationStatuses);
    harper_logger_js_1.default.info?.('All root applications loaded');
    // Finally, write the lock file
    await (0, promises_1.writeFile)(harperApplicationLockPath, JSON.stringify(harperApplicationLock, null, 2), 'utf8');
}
function getGitSSHCommand() {
    const rootDir = (0, configUtils_js_1.getConfigValue)(hdbTerms_js_1.CONFIG_PARAMS.ROOTPATH);
    const sshDir = (0, node_path_1.join)(rootDir, 'ssh');
    if ((0, node_fs_1.existsSync)(sshDir)) {
        for (const file of (0, node_fs_1.readdirSync)(sshDir)) {
            if (file.includes('.key')) {
                return `ssh -F ${(0, node_path_1.join)(sshDir, 'config')} -o UserKnownHostsFile=${(0, node_path_1.join)(sshDir, 'known_hosts')}`;
            }
        }
    }
}
/**
 * Execute a command (using `spawn`) with stdin ignored.
 *
 * Stdout is logged chunk-by-chunk. Stderr is buffered and then logged line-by-line.
 *
 * Rejects with an error if the command fails or times out.
 *
 * @param command The command to run.
 * @param args The arguments to pass to the command.
 * @param cwd The working directory for the command.
 * @param timeoutMs The timeout for the command in milliseconds. Defaults to 5 minutes.
 * @returns A promise that resolves when the command completes.
 */
function nonInteractiveSpawn(applicationName, command, args, cwd, timeoutMs = 60 * 60 * 1000) {
    return new Promise((resolve, reject) => {
        harper_logger_js_1.default
            .loggerWithTag(`${applicationName}:spawn:${command}`)
            .debug?.(`Executing \`${command} ${args.join(' ')}\` in ${cwd}`);
        const env = { ...process.env };
        const gitSSHCommand = getGitSSHCommand();
        if (gitSSHCommand) {
            env.GIT_SSH_COMMAND = gitSSHCommand;
        }
        const childProcess = (0, node_child_process_1.spawn)(command, args, {
            shell: true,
            cwd,
            env,
            stdio: ['ignore', 'pipe', 'pipe'],
        });
        const timeout = setTimeout(() => {
            childProcess.kill();
            reject(new Error(`Command\`${command} ${args.join(' ')}\` timed out after ${timeoutMs}ms`));
        }, timeoutMs);
        let stdout = '';
        childProcess.stdout.on('data', (chunk) => {
            // buffer stdout for later resolve
            stdout += chunk.toString();
            // log stdout lines immediately
            // TODO: Technically nothing guarantees that a chunk will be a complete line so need to implement
            // something here to buffer until a newline character, then log the complete line
            harper_logger_js_1.default.loggerWithTag(`${applicationName}:spawn:${command}:stdout`).debug?.(chunk.toString());
        });
        // buffer stderr
        let stderr = '';
        childProcess.stderr.on('data', (chunk) => {
            stderr += chunk.toString();
        });
        childProcess.on('error', (error) => {
            clearTimeout(timeout);
            // Print out stderr before rejecting
            if (stderr) {
                printStd(applicationName, command, stderr, 'stderr');
            }
            reject(error);
        });
        childProcess.on('close', (code) => {
            clearTimeout(timeout);
            if (stderr) {
                printStd(applicationName, command, stderr, 'stderr');
            }
            harper_logger_js_1.default.loggerWithTag(`${applicationName}:spawn:${command}`).debug?.(`Process exited with code ${code}`);
            resolve({
                stdout,
                stderr,
                code,
            });
        });
    });
}
function getEnvBuiltInComponents() {
    const builtInComponents = [];
    if (process.env.HARPER_BUILTIN_COMPONENTS) {
        for (const componentDefinition of process.env.HARPER_BUILTIN_COMPONENTS.split(',')) {
            const [name, packageIdentifier] = componentDefinition.trim().split('=');
            if (!componentDefinition)
                continue;
            builtInComponents.push({ name, packageIdentifier });
        }
    }
    return builtInComponents;
}
function printStd(applicationName, command, stdString, stdStreamLabel, level = 'debug') {
    const stdLogger = harper_logger_js_1.default.loggerWithTag(`${applicationName}:spawn:${command}:${stdStreamLabel}`);
    for (const line of stdString.split('\n')) {
        stdLogger[level]?.(line);
    }
}
//# sourceMappingURL=Application.js.map