"use strict";
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
 * Copyright 2017 Teppo Kurki <teppo.kurki@iki.fi>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0

 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.modulesWithKeyword = modulesWithKeyword;
exports.restoreModules = restoreModules;
exports.runNpm = runNpm;
exports.checkForNewServerVersion = checkForNewServerVersion;
exports.getAuthor = getAuthor;
exports.getKeywords = getKeywords;
exports.importOrRequire = importOrRequire;
const child_process_1 = require("child_process");
const fs_1 = __importDefault(require("fs"));
const lodash_1 = __importDefault(require("lodash"));
const path_1 = __importDefault(require("path"));
const semver_1 = __importStar(require("semver"));
const atomicWrite_1 = require("./atomicWrite");
const debug_1 = require("./debug");
const plugin_paths_1 = require("./plugin-paths");
const debug = (0, debug_1.createDebug)('signalk:modules');
const npmDebug = (0, debug_1.createDebug)('signalk:modules:npm');
function findModulesInDir(dir, keyword) {
    // If no directory by name return empty array.
    if (!fs_1.default.existsSync(dir)) {
        return [];
    }
    debug('findModulesInDir: ' + dir);
    return fs_1.default
        .readdirSync(dir)
        .filter((name) => !name.startsWith('.'))
        .reduce((result, filename) => {
        if (filename.indexOf('@') === 0) {
            return result.concat(findModulesInDir(dir + filename + '/', keyword).map((entry) => {
                return {
                    module: entry.module,
                    metadata: entry.metadata,
                    location: dir
                };
            }));
        }
        else {
            let metadata;
            try {
                // eslint-disable-next-line @typescript-eslint/no-require-imports
                metadata = require(path_1.default.join(dir, filename, 'package.json'));
            }
            catch (err) {
                debug(err);
            }
            if (metadata &&
                metadata.keywords &&
                metadata.keywords.includes(keyword)) {
                result.push({
                    module: metadata.name,
                    metadata,
                    location: dir
                });
            }
        }
        return result;
    }, []);
}
// Extract unique directory paths from app object.
function getModulePaths(config) {
    // appPath is the app working directory.
    const { appPath, configPath } = config;
    return (appPath === configPath ? [appPath] : [configPath, appPath]).map((pathOption) => path_1.default.join(pathOption, 'node_modules/'));
}
const getModuleSortName = (x) => (x.module || '').replace('@signalk', ' ');
// Sort handler that puts strings with '@signalk' first.
const priorityPrefix = (a, b) => getModuleSortName(a).localeCompare(getModuleSortName(b));
// Searches for installed modules that contain `keyword`.
function modulesWithKeyword(config, keyword) {
    return lodash_1.default.uniqBy(
    // _.flatten since values are inside an array. [[modules...], [modules...]]
    lodash_1.default.flatten(getModulePaths(config).map((pathOption) => findModulesInDir(pathOption, keyword))), (moduleData) => moduleData.module).sort(priorityPrefix);
}
function installModule(config, name, version, onData, onErr, onClose) {
    runNpm(config, name, version, 'install', onData, onErr, onClose);
}
function removeModule(config, name, version, onData, onErr, onClose, pluginId, deleteData = false) {
    runNpm(config, name, null, 'remove', onData, onErr, (code) => {
        cleanupAfterRemove(config.configPath, name, pluginId, deleteData);
        onClose(code);
    });
}
function cleanupAfterRemove(configPath, packageName, pluginId, deleteData = false) {
    const moduleDir = path_1.default.join(configPath, 'node_modules', packageName);
    if (fs_1.default.existsSync(moduleDir)) {
        console.warn(`${packageName}: directory still exists after npm remove, cleaning up`);
        try {
            fs_1.default.rmSync(moduleDir, { recursive: true, force: true });
        }
        catch (e) {
            console.error(`Failed to remove ${moduleDir}: ${e.message}`);
        }
    }
    const resolvedDir = path_1.default.resolve(moduleDir);
    for (const key of Object.keys(require.cache)) {
        if (key.startsWith(resolvedDir)) {
            delete require.cache[key];
        }
    }
    const packageJsonPath = path_1.default.join(configPath, 'package.json');
    if (fs_1.default.existsSync(packageJsonPath)) {
        try {
            const packageJson = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf-8'));
            if (packageJson.dependencies && packageJson.dependencies[packageName]) {
                delete packageJson.dependencies[packageName];
                (0, atomicWrite_1.atomicWriteFileSync)(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
                console.warn(`${packageName}: removed from settings package.json`);
            }
        }
        catch (e) {
            console.error(`Failed to update settings package.json: ${e.message}`);
        }
    }
    if (pluginId && deleteData) {
        const configFile = (0, plugin_paths_1.pluginConfigPath)(configPath, pluginId);
        if (fs_1.default.existsSync(configFile)) {
            try {
                fs_1.default.unlinkSync(configFile);
            }
            catch (e) {
                console.error(`Failed to remove ${configFile}: ${e.message}`);
            }
        }
        const dataDir = (0, plugin_paths_1.pluginDataDir)(configPath, pluginId);
        if (fs_1.default.existsSync(dataDir)) {
            try {
                fs_1.default.rmSync(dataDir, { recursive: true, force: true });
            }
            catch (e) {
                console.error(`Failed to remove ${dataDir}: ${e.message}`);
            }
        }
    }
}
async function getPluginDataSize(configPath, pluginId) {
    let totalBytes = 0;
    let fileCount = 0;
    const configFile = (0, plugin_paths_1.pluginConfigPath)(configPath, pluginId);
    try {
        const stats = await fs_1.default.promises.lstat(configFile);
        if (stats.isFile()) {
            totalBytes += stats.size;
            fileCount++;
        }
    }
    catch {
        // file does not exist or inaccessible
    }
    const dataDir = (0, plugin_paths_1.pluginDataDir)(configPath, pluginId);
    try {
        const dirStats = await fs_1.default.promises.lstat(dataDir);
        if (dirStats.isDirectory()) {
            const { default: getFolderSize } = await import('get-folder-size');
            totalBytes += await getFolderSize.loose(dataDir);
            async function countFiles(d) {
                let entries;
                try {
                    entries = await fs_1.default.promises.readdir(d);
                }
                catch {
                    return 0;
                }
                let count = 0;
                for (const entry of entries) {
                    try {
                        const stats = await fs_1.default.promises.lstat(path_1.default.join(d, entry));
                        if (stats.isFile()) {
                            count++;
                        }
                        else if (stats.isDirectory()) {
                            count += await countFiles(path_1.default.join(d, entry));
                        }
                    }
                    catch {
                        // entry removed or inaccessible between readdir and lstat
                    }
                }
                return count;
            }
            fileCount += await countFiles(dataDir);
        }
    }
    catch {
        // directory does not exist or inaccessible
    }
    return { totalBytes, fileCount, hasData: totalBytes > 0 };
}
function restoreModules(config, onData, onErr, onClose) {
    runNpm(config, null, null, 'remove', onData, onErr, onClose);
}
function runNpm(config, name, version, command, onData, onErr, onClose) {
    if (version && version !== '' && !semver_1.default.valid(version)) {
        onErr(new Error('Invalid version: ' + version));
        onClose(-1);
        return;
    }
    let npm;
    const opts = {};
    let packageString;
    if (process.platform === 'win32') {
        opts['shell'] = true;
    }
    if (name) {
        packageString = version ? `${name}@${version}` : name;
    }
    else {
        packageString = '';
    }
    debug(`${command}: ${packageString}`);
    const npmArgs = isTheServerModule(name, config)
        ? [command, '-g']
        : ['--save', '--ignore-scripts', command];
    if (packageString) {
        npmArgs.push(packageString);
    }
    if (isTheServerModule(name, config)) {
        if (process.platform === 'win32') {
            npm = (0, child_process_1.spawn)('npm.cmd', npmArgs, opts);
        }
        else {
            npm = (0, child_process_1.spawn)('sudo', ['npm', ...npmArgs], opts);
        }
    }
    else {
        opts.cwd = config.configPath;
        if (process.platform === 'win32') {
            npm = (0, child_process_1.spawn)('npm.cmd', npmArgs, opts);
        }
        else {
            npm = (0, child_process_1.spawn)('npm', npmArgs, opts);
        }
    }
    npm.stdout.on('data', onData);
    npm.stderr.on('data', onErr);
    npm.on('close', onClose);
    npm.on('error', (err) => {
        onErr(err);
        onClose(-1);
    });
}
function isTheServerModule(moduleName, config) {
    return moduleName === config.name;
}
const modulesByKeyword = {};
async function findModulesWithKeyword(keyword) {
    if (modulesByKeyword[keyword] &&
        Date.now() - modulesByKeyword[keyword].time < 60 * 1000) {
        return modulesByKeyword[keyword].packages;
    }
    const moduleData = await searchByKeyword(keyword);
    npmDebug(`npm search returned ${moduleData.length} modules with keyword ${keyword}`);
    const result = moduleData.reduce((acc, module) => {
        const name = module.package.name;
        if (!acc[name] ||
            semver_1.default.gt(module.package.version, acc[name].package.version)) {
            acc[name] = module;
        }
        return acc;
    }, {});
    const packages = Object.values(result);
    modulesByKeyword[keyword] = { time: Date.now(), packages };
    return packages;
}
const NPM_SEARCH_TIMEOUT_MS = 60_000;
const NPM_DIST_TAGS_TIMEOUT_MS = 20_000;
async function searchByKeyword(keyword) {
    let fetchedCount = 0;
    let toFetchCount = 1;
    let moduleData = [];
    while (fetchedCount < toFetchCount) {
        npmDebug(`searching ${keyword} from ${fetchedCount + 1} of ${toFetchCount}`);
        const res = await fetch(`https://registry.npmjs.org/-/v1/search?size=250&from=${fetchedCount > 0 ? fetchedCount : 0}&text=keywords:${keyword}`, { signal: AbortSignal.timeout(NPM_SEARCH_TIMEOUT_MS) });
        if (!res.ok) {
            npmDebug(`npm search failed with status ${res.status}: ${res.statusText}`);
            break;
        }
        const parsed = (await res.json());
        moduleData = moduleData.concat(parsed.objects);
        fetchedCount += parsed.objects.length;
        toFetchCount = parsed.total;
    }
    return moduleData;
}
let distTagsCache = {
    time: 0,
    data: {}
};
async function fetchDistTagsForPackages(packageNames) {
    if (Date.now() - distTagsCache.time < 60 * 1000) {
        return distTagsCache.data;
    }
    const result = {};
    const CONCURRENCY = 10;
    let i = 0;
    while (i < packageNames.length) {
        const batch = packageNames.slice(i, i + CONCURRENCY);
        const settled = await Promise.allSettled(batch.map(async (name) => {
            const res = await fetch(`https://registry.npmjs.org/-/package/${name}/dist-tags`, { signal: AbortSignal.timeout(NPM_DIST_TAGS_TIMEOUT_MS) });
            if (!res.ok)
                return null;
            const tags = (await res.json());
            return { name, tags };
        }));
        for (const entry of settled) {
            if (entry.status === 'fulfilled' && entry.value) {
                result[entry.value.name] = entry.value.tags;
            }
        }
        i += CONCURRENCY;
    }
    distTagsCache = { time: Date.now(), data: result };
    return result;
}
function doFetchDistTags() {
    return fetch('https://registry.npmjs.org/-/package/signalk-server/dist-tags', {
        signal: AbortSignal.timeout(NPM_DIST_TAGS_TIMEOUT_MS)
    });
}
async function getLatestServerVersion(currentVersion, distTags = doFetchDistTags) {
    const res = await distTags();
    if (!res.ok) {
        throw new Error(`Failed to fetch dist-tags: ${res.status} ${res.statusText}`);
    }
    const versions = (await res.json());
    const prereleaseData = semver_1.default.prerelease(currentVersion);
    if (prereleaseData) {
        if (semver_1.default.satisfies(versions.latest, `>${currentVersion}`)) {
            return versions.latest;
        }
        else {
            return versions[prereleaseData[0]];
        }
    }
    else {
        return versions.latest;
    }
}
function checkForNewServerVersion(currentVersion, serverUpgradeIsAvailable, getLatestServerVersionP = getLatestServerVersion) {
    getLatestServerVersionP(currentVersion)
        .then((version) => {
        if (semver_1.default.satisfies(new semver_1.SemVer(version), `>${currentVersion}`)) {
            serverUpgradeIsAvailable(undefined, version);
        }
    })
        .catch((err) => {
        serverUpgradeIsAvailable(`unable to check for new server version: ${err}`);
    });
}
function getAuthor(thePackage) {
    return `${thePackage.publisher?.username}${thePackage.name.startsWith('@signalk/') ? ' (Signal K team)' : ''}`;
}
function getKeywords(thePackage) {
    const keywords = thePackage.keywords;
    debug('%s keywords: %j', thePackage.name, keywords);
    return keywords;
}
async function importOrRequire(moduleDir) {
    try {
        // eslint-disable-next-line @typescript-eslint/no-require-imports
        const mod = require(moduleDir);
        // Starting with version 20.19.0 and 22 Node will load ESM modules with require
        // https://nodejs.org/en/blog/release/v20.19.0
        return mod.default ?? mod;
    }
    catch (err) {
        debug(`Failed to require("${moduleDir}") module, trying import()`);
        // `import()` only works with file paths or npm module names. It can't
        // directly load a path to a directory. One solution would be to refactor
        // module loading to update `NODE_PATH` with plugin directories, and
        // then import/require them here using just their module name (e.g.
        // `import("@signalk/plugin-name")`), which would allow NodeJS to resolve
        // and load the module. This would be a little more extensive refactoring
        // that may be worth while once the whole project is entirely using ESM.
        // For now, this `esm-resolve` package work
        const { buildResolver } = await import('esm-resolve');
        const resolver = buildResolver(moduleDir, {
            isDir: true,
            resolveToAbsolute: true
        });
        const modulePath = resolver('.');
        if (modulePath) {
            const module = await import(modulePath);
            return module.default;
        }
        else {
            // Could not resolve, throw the original error.
            throw err;
        }
    }
}
module.exports = {
    modulesWithKeyword,
    installModule,
    removeModule,
    isTheServerModule,
    findModulesWithKeyword,
    fetchDistTagsForPackages,
    getLatestServerVersion,
    checkForNewServerVersion,
    getAuthor,
    getKeywords,
    restoreModules,
    importOrRequire,
    runNpm,
    getPluginDataSize
};
//# sourceMappingURL=modules.js.map