"use strict";
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 };
};
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.SnippetManager = void 0;
const shell_api_1 = require("@mongosh/shell-api");
const errors_1 = require("@mongosh/errors");
const escape_string_regexp_1 = __importDefault(require("escape-string-regexp"));
const path_1 = __importDefault(require("path"));
const util_1 = require("util");
const console_1 = require("console");
const fs_1 = require("fs");
const stream_1 = __importStar(require("stream"));
const events_1 = require("events");
const tar = __importStar(require("tar"));
const zlib_1 = __importDefault(require("zlib"));
const bson_1 = __importDefault(require("bson"));
const zod_1 = require("zod");
const devtools_proxy_support_1 = require("@mongodb-js/devtools-proxy-support");
const cross_spawn_1 = require("cross-spawn");
const pipeline = (0, util_1.promisify)(stream_1.default.pipeline);
const brotliCompress = (0, util_1.promisify)(zlib_1.default.brotliCompress);
const brotliDecompress = (0, util_1.promisify)(zlib_1.default.brotliDecompress);
const regExpTag = Object.prototype.toString.call(/foo/);
const errorMatcherSchema = zod_1.z.object({
    message: zod_1.z.string(),
    matches: zod_1.z.array(zod_1.z.custom((val) => Object.prototype.toString.call(val) === regExpTag)),
});
const indexDescriptionSchema = zod_1.z.object({
    name: zod_1.z.string(),
    snippetName: zod_1.z.string(),
    installSpec: zod_1.z.string().optional(),
    version: zod_1.z.string(),
    description: zod_1.z.string(),
    license: zod_1.z.string(),
    readme: zod_1.z.string(),
    errorMatchers: zod_1.z.array(errorMatcherSchema).optional(),
});
const indexFileSchema = zod_1.z.object({
    indexFileVersion: zod_1.z.number().int().max(1),
    metadata: zod_1.z
        .object({
        homepage: zod_1.z.string(),
    })
        .passthrough(),
    index: zod_1.z.array(indexDescriptionSchema),
});
async function unpackBSON(data) {
    return bson_1.default.deserialize(await brotliDecompress(data));
}
async function packBSON(data) {
    return await brotliCompress(bson_1.default.serialize(data));
}
function completeSnippetsCommand(args, snippets) {
    const plainCommands = [
        'update',
        'search',
        'ls',
        'outdated',
        'info',
        'refresh',
        'load-all',
    ];
    const pkgCommands = ['install', 'uninstall', 'help'];
    if (args.length >= 2 && pkgCommands.includes(args[1])) {
        const allSnippetNames = snippets.map(({ snippetName }) => snippetName);
        if (args.length === 2) {
            return allSnippetNames.map((str) => `${args[1]} ${str}`);
        }
        return allSnippetNames.filter((str) => str.startsWith(args[args.length - 1] ?? ''));
    }
    else if (args.length === 2) {
        return [...plainCommands, ...pkgCommands].filter((str) => str.startsWith(args[1] ?? ''));
    }
    return undefined;
}
class SnippetManager {
    constructor({ installdir, instanceState, skipInitialIndexLoad, proxyOptions = {}, }) {
        this.inflightFetchIndexPromise = null;
        const { load, config, print, require } = instanceState.context;
        this._instanceState = instanceState;
        this.load = load;
        this.config = config;
        this.print = print;
        this.require = require;
        this.installdir = installdir;
        this.repos = null;
        this.npmArgv = [];
        this.fetch = (0, devtools_proxy_support_1.createFetch)(proxyOptions);
        if (!skipInitialIndexLoad) {
            this.prepareIndex().catch(() => { });
        }
        const wrapperFn = (...args) => {
            return Object.assign(this.runSnippetCommand(args), {
                [Symbol.for('@@mongosh.syntheticPromise')]: true,
            });
        };
        wrapperFn.isDirectShellCommand = true;
        wrapperFn.returnsPromise = true;
        instanceState.shellApi.snippet = instanceState.context.snippet =
            wrapperFn;
        shell_api_1.signatures.ShellApi.attributes.snippet = _a.signature;
        _a.instance = new WeakRef(this);
        instanceState.registerPlugin(this);
        this.messageBus.emit('mongosh-snippets:loaded', { installdir });
    }
    static create(options) {
        return new _a(options);
    }
    get messageBus() {
        return this._instanceState.messageBus;
    }
    async prepareNpm() {
        const npmdir = path_1.default.join(this.installdir, 'node_modules', 'npm');
        const npmclipath = path_1.default.join(npmdir, 'bin', 'npm-cli.js');
        await fs_1.promises.mkdir(this.installdir, { recursive: true });
        try {
            await fs_1.promises.stat(npmclipath);
            this.messageBus.emit('mongosh-snippets:npm-lookup', {
                existingVersion: '<local>',
            });
            return [process.execPath, npmclipath];
        }
        catch {
        }
        try {
            const existingVersion = (await this.execFile(['npm', '--version'])).trim();
            this.messageBus.emit('mongosh-snippets:npm-lookup', { existingVersion });
            const major = +existingVersion.split('.')[0];
            if (major >= 6)
                return ['npm'];
        }
        catch {
        }
        const { evaluationListener, interrupted } = this._instanceState;
        interrupted.checkpoint();
        const result = await evaluationListener.onPrompt?.('This operation requires downloading a recent release of npm. Do you want to proceed? [Y/n]', 'yesno');
        if (result === 'no') {
            this.messageBus.emit('mongosh-snippets:npm-lookup-stopped');
            throw new errors_1.MongoshRuntimeError('Stopped by user request');
        }
        const npmMetadataURL = (await this.registryBaseUrl()) + '/npm/latest';
        interrupted.checkpoint();
        const npmMetadataResponse = await this.fetch(npmMetadataURL);
        if (!npmMetadataResponse.ok) {
            this.messageBus.emit('mongosh-snippets:npm-download-failed', {
                npmMetadataURL,
                status: npmMetadataResponse.status,
            });
            throw new errors_1.MongoshRuntimeError(`Failed to download npm: ${npmMetadataURL}: ${npmMetadataResponse.statusText}`);
        }
        interrupted.checkpoint();
        const npmTarballURL = (await npmMetadataResponse.json())?.dist?.tarball;
        if (!npmTarballURL) {
            this.messageBus.emit('mongosh-snippets:npm-download-failed', {
                npmMetadataURL,
                npmTarballURL,
            });
            throw new errors_1.MongoshRuntimeError(`Failed to download npm: ${npmMetadataURL}: Registry returned no download source`);
        }
        interrupted.checkpoint();
        await this.print(`Downloading npm from ${npmTarballURL}...`);
        const npmTarball = await this.fetch(npmTarballURL);
        if (!npmTarball.ok || !npmTarball.body) {
            this.messageBus.emit('mongosh-snippets:npm-download-failed', {
                npmMetadataURL,
                npmTarballURL,
                status: npmTarball.status,
            });
            throw new errors_1.MongoshRuntimeError(`Failed to download npm: ${npmTarballURL}: ${npmTarball.statusText}`);
        }
        this.messageBus.emit('mongosh-snippets:npm-download-active', {
            npmMetadataURL,
            npmTarballURL,
        });
        interrupted.checkpoint();
        await fs_1.promises.mkdir(npmdir, { recursive: true });
        await pipeline(npmTarball.body, tar.x({ strip: 1, C: npmdir }));
        await this.editPackageJSON((pjson) => {
            (pjson.dependencies ??= {}).npm = '*';
        });
        return [process.execPath, npmclipath];
    }
    async prepareIndex(refreshMode = 'allow-cached') {
        return (this.repos = await (this.inflightFetchIndexPromise ??=
            (async () => {
                this.messageBus.emit('mongosh-snippets:fetch-index', { refreshMode });
                const cachePath = path_1.default.join(this.installdir, 'index.bson.br');
                await fs_1.promises.mkdir(this.installdir, { recursive: true });
                const sourceURLs = (await this.config.get('snippetIndexSourceURLs'))
                    .split(';')
                    .filter(Boolean);
                let repoData = null;
                let fetchedFromNetwork = false;
                try {
                    repoData = (await unpackBSON(await fs_1.promises.readFile(cachePath))).repoData;
                }
                catch {
                }
                if (repoData !== null) {
                    const cachedSourceURLs = repoData.map((repo) => repo.sourceURL);
                    if (cachedSourceURLs.length !== sourceURLs.length ||
                        cachedSourceURLs.some((url) => !sourceURLs.includes(url))) {
                        this.messageBus.emit('mongosh-snippets:fetch-cache-invalid');
                        repoData = null;
                    }
                }
                if (!repoData || refreshMode === 'force-refresh') {
                    fetchedFromNetwork = true;
                    repoData = await Promise.all(sourceURLs.map(async (url) => {
                        const repoRes = await this.fetch(url);
                        if (!repoRes.ok) {
                            this.messageBus.emit('mongosh-snippets:fetch-index-error', {
                                action: 'fetch',
                                url,
                                status: repoRes.status,
                            });
                            throw new errors_1.MongoshRuntimeError(`The specified index file ${url} could not be read: ${repoRes.statusText}`);
                        }
                        const arrayBuffer = await repoRes.arrayBuffer();
                        const buffer = Buffer.from(arrayBuffer);
                        let data;
                        try {
                            data = await unpackBSON(buffer);
                        }
                        catch (err) {
                            this.messageBus.emit('mongosh-snippets:fetch-index-error', {
                                action: 'parse-fetched',
                                url,
                                error: err?.message,
                            });
                            throw new errors_1.MongoshRuntimeError(`The specified index file ${url} could not be parsed: ${err.message}`);
                        }
                        const { error, data: parsedData } = indexFileSchema.safeParse(data);
                        if (error) {
                            this.messageBus.emit('mongosh-snippets:fetch-index-error', {
                                action: 'validate-fetched',
                                url,
                                error: error.message,
                            });
                            throw new errors_1.MongoshRuntimeError(`The specified index file ${url} is not a valid index file: ${error.message}`);
                        }
                        return { ...parsedData, sourceURL: url };
                    }));
                    try {
                        await fs_1.promises.writeFile(cachePath, await packBSON({ repoData }));
                    }
                    catch (err) {
                        this.messageBus.emit('mongosh-snippets:fetch-index-error', {
                            action: 'save',
                            error: err?.message,
                        });
                    }
                }
                this.messageBus.emit('mongosh-snippets:fetch-index-done');
                this.inflightFetchIndexPromise = null;
                if (!fetchedFromNetwork &&
                    Date.now() - (await fs_1.promises.stat(cachePath)).mtime.getTime() > 3600_000) {
                    this.prepareIndex('force-refresh').catch(() => { });
                }
                return repoData;
            })()));
    }
    get snippets() {
        return (this.repos?.flatMap((repository) => repository.index) ??
            []);
    }
    async registryBaseUrl() {
        return await this.config.get('snippetRegistryURL');
    }
    async ensureSetup() {
        if (this.npmArgv.length > 0 && this.repos) {
            return this.npmArgv;
        }
        this._instanceState.interrupted.checkpoint();
        [this.npmArgv, this.repos] = await Promise.all([
            this.prepareNpm(),
            this.prepareIndex(),
        ]);
        return this.npmArgv;
    }
    async editPackageJSON(fn) {
        let pjson = {};
        try {
            pjson = JSON.parse(await fs_1.promises.readFile(path_1.default.join(this.installdir, 'package.json'), 'utf8'));
        }
        catch (err) {
            this.messageBus.emit('mongosh-snippets:package-json-edit-error', {
                error: err?.message,
            });
            if (err.code !== 'ENOENT') {
                throw err;
            }
        }
        const origContent = JSON.parse(JSON.stringify(pjson));
        const result = await fn(pjson);
        if (!(0, util_1.isDeepStrictEqual)(origContent, result)) {
            await fs_1.promises.writeFile(path_1.default.join(this.installdir, 'package.json'), JSON.stringify(pjson, null, '  '));
        }
        return result;
    }
    async runNpm(npmArgs, lenient = false) {
        await this.editPackageJSON(() => { });
        return await this.execFile([
            ...(await this.ensureSetup()),
            '--no-package-lock',
            '--ignore-scripts',
            '--loglevel=notice',
            `--registry=${await this.registryBaseUrl()}`,
            ...npmArgs,
        ], lenient);
    }
    async execFile([cmd, ...args], lenient = false) {
        const { interrupted } = this._instanceState;
        this.messageBus.emit('mongosh-snippets:spawn-child', {
            args: [cmd, ...args],
        });
        interrupted.checkpoint();
        const proc = (0, cross_spawn_1.spawn)(cmd, args, {
            cwd: this.installdir,
            env: { ...process.env, MONGOSH_RUN_NODE_SCRIPT: '1' },
            stdio: 'pipe',
        });
        let stdout = '';
        let stderr = '';
        proc.stdout
            .setEncoding('utf8')
            .on('data', (chunk) => {
            stdout += chunk;
        });
        proc.stderr
            .setEncoding('utf8')
            .on('data', (chunk) => {
            stderr += chunk;
        });
        const interruptPromise = interrupted.asPromise();
        interruptPromise.promise.catch(() => { });
        try {
            const [exitCode] = await Promise.race([
                interruptPromise.promise,
                (0, events_1.once)(proc, 'close'),
            ]);
            if (lenient ||
                exitCode === 0 ||
                (exitCode === 1 && stderr === '' && stdout)) {
                return stdout;
            }
            throw new Error(`Command failed: ${[cmd, ...args].join(' ')} with exit code ${exitCode}: ${stderr} ${stdout}`);
        }
        finally {
            interruptPromise.destroy();
            if (proc.exitCode === null && proc.signalCode === null) {
                proc.kill();
            }
        }
    }
    async search() {
        await this.ensureSetup();
        const list = this.snippets.map(({ snippetName, version, description }) => ({
            name: snippetName,
            version,
            description,
        }));
        const tableOutput = new stream_1.PassThrough({ encoding: 'utf8' });
        new console_1.Console(tableOutput).table(list);
        return tableOutput.read();
    }
    async loadAllSnippets(autoloadMode = 'only-autoload') {
        if (autoloadMode === 'only-autoload' &&
            !(await this.config.get('snippetAutoload'))) {
            return;
        }
        let installedPackages;
        try {
            installedPackages = await this.editPackageJSON((pjson) => Object.keys(pjson.dependencies ?? {}));
        }
        catch (err) {
            if (autoloadMode === 'only-autoload') {
                return;
            }
            throw err;
        }
        for (const name of installedPackages) {
            if (name === 'npm') {
                continue;
            }
            const packagePath = path_1.default.resolve(this.installdir, 'node_modules', name);
            this.messageBus.emit('mongosh-snippets:load-snippet', {
                source: 'load-all',
                name,
            });
            this._instanceState.interrupted.checkpoint();
            await this.load(this.require.resolve(packagePath));
        }
    }
    async runSnippetCommand(args) {
        this.messageBus.emit('mongosh-snippets:snippet-command', { args });
        switch (args[0]) {
            case 'help':
                return await this.helpText(args[1]);
            case 'refresh':
                await this.prepareIndex('force-refresh');
                return 'Done!';
            case 'install':
            case 'uninstall':
            case 'update':
                return await this.runInstallLikeCmd(args);
            case 'outdated':
            case 'ls': {
                let output;
                if (args[0] === 'ls') {
                    output = await this.runNpm(['ls', '--depth=0'], true);
                }
                else {
                    output = await this.runNpm([args[0]], true);
                }
                const firstLineEnd = output.indexOf('\n');
                let packages = output.substr(firstLineEnd + 1);
                for (const { name, snippetName } of this.snippets) {
                    packages = packages.replace(new RegExp((0, escape_string_regexp_1.default)(name), 'g'), `mongosh:${snippetName}`);
                }
                return ((firstLineEnd < 0 ? output : output.substr(0, firstLineEnd + 1)) +
                    packages);
            }
            case 'search':
                return await this.search();
            case 'info':
                return await this.showInfo();
            case 'load-all':
                await this.loadAllSnippets('always');
                return 'All installed snippets loaded';
            default:
                return `Unknown command "${args[0]}". Run 'snippet help' to list all available commands.`;
        }
    }
    async runInstallLikeCmd(args) {
        const { evaluationListener } = this._instanceState;
        await this.ensureSetup();
        const snippetDescs = [];
        for (let i = 1; i < args.length; i++) {
            const snippetDesc = this.snippets.find(({ snippetName }) => snippetName === args[i]);
            if (!snippetDesc) {
                throw new errors_1.MongoshInvalidInputError(`Unknown snippet "${args[i]}"`);
            }
            snippetDescs.push(snippetDesc);
        }
        await this.editPackageJSON((pjson) => {
            for (const { name } of snippetDescs) {
                (pjson.dependencies ??= {})[name] =
                    args[0] === 'uninstall' ? undefined : '*';
            }
        });
        const npmArguments = snippetDescs.map(({ name, installSpec }) => {
            return name + (args[0] === 'install' ? `@${installSpec ?? '*'}` : '');
        });
        await this.print(`Running ${args[0]}...`);
        await this.runNpm([args[0], '--save', ...npmArguments]);
        if (args[0] === 'install' && snippetDescs.length > 0) {
            this._instanceState.interrupted.checkpoint();
            const loadNow = await evaluationListener.onPrompt?.(`Installed new snippets ${args.slice(1)}. Do you want to load them now? [Y/n]`, 'yesno');
            if (loadNow !== 'no') {
                for (const { name } of snippetDescs) {
                    this.messageBus.emit('mongosh-snippets:load-snippet', {
                        source: 'install',
                        name,
                    });
                    this._instanceState.interrupted.checkpoint();
                    await this.load(this.require.resolve(path_1.default.join(this.installdir, 'node_modules', name)));
                }
            }
            return `Finished installing snippets: ${args.slice(1)}`;
        }
        return 'Done!';
    }
    async helpText(snippet = '') {
        if (snippet) {
            await this.prepareIndex();
            const info = this.snippets.find(({ snippetName }) => snippetName === snippet);
            if (!info) {
                throw new errors_1.MongoshInvalidInputError(`Unknown snippet "${snippet}"`);
            }
            if (!info.readme) {
                throw new errors_1.MongoshRuntimeError(`No help information available for "${snippet}"`);
            }
            return info.readme;
        }
        return `\
snippet <command> [args...]

  snippet install <name>     Install a new snippet
  snippet uninstall <name>   Remove an installed snippet
  snippet update             Get the latest versions of installed snippets
  snippet search             List available snippets
  snippet ls                 List installed snippets
  snippet outdated           List outdated snippets
  snippet help               Show this text
  snippet help <name>        Show information about a specific snippet
  snippet info               Show information about the snippet repository
  snippet refresh            Clear and refresh the snippet metadata cache
  snippet load-all           Load all available snippets using load()
`;
    }
    async showInfo() {
        await this.prepareIndex();
        return (this.repos
            ?.map((repository) => `\
Snippet repository URL:      ${repository.sourceURL}
  -->  Homepage:             ${repository.metadata.homepage}
`)
            .join('\n') ?? 'Not enabled');
    }
    transformError(err) {
        if (!this.repos)
            return err;
        for (const { errorMatchers, name } of this.snippets) {
            for (const { matches, message } of errorMatchers ?? []) {
                for (const regexp of matches) {
                    if (err.message.match(regexp)) {
                        this.messageBus.emit('mongosh-snippets:transform-error', {
                            error: err.message,
                            addition: message,
                            name,
                        });
                        err.message += ` (${message})`;
                        return err;
                    }
                }
            }
        }
        return err;
    }
}
exports.SnippetManager = SnippetManager;
_a = SnippetManager;
SnippetManager.signature = {
    type: 'function',
    returnsPromise: true,
    isDirectShellCommand: true,
    shellCommandCompleter: async (params, args) => {
        const instance = _a.instance.deref();
        return instance && completeSnippetsCommand(args, instance.snippets);
    },
    newShellCommandCompleter: async (context, args) => {
        const instance = _a.instance.deref();
        return instance && completeSnippetsCommand(args, instance.snippets);
    },
};
//# sourceMappingURL=snippet-manager.js.map