create-foxglove-extension
Version: 
Create and package Foxglove extensions
372 lines (371 loc) • 14.8 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.packageCommand = packageCommand;
exports.installCommand = installCommand;
exports.publishCommand = publishCommand;
exports.removeExtensionsById = removeExtensionsById;
exports.listExtensions = listExtensions;
const child_process_1 = require("child_process");
const crypto_1 = require("crypto");
const fs_1 = require("fs");
const promises_1 = require("fs/promises");
const jszip_1 = __importDefault(require("jszip"));
const ncp_1 = __importDefault(require("ncp"));
const node_fetch_1 = __importDefault(require("node-fetch"));
const os_1 = require("os");
const path_1 = require("path");
const rimraf_1 = require("rimraf");
const util_1 = require("util");
const extensions_1 = require("./extensions");
const log_1 = require("./log");
const cpR = (0, util_1.promisify)(ncp_1.default);
// A fixed date is used for zip file modification timestamps to
// produce deterministic .foxe files. Foxglove birthday.
const MOD_DATE = new Date("2021-02-03");
var FileType;
(function (FileType) {
    FileType[FileType["File"] = 0] = "File";
    FileType[FileType["Directory"] = 1] = "Directory";
    FileType[FileType["FileOrDirectory"] = 2] = "FileOrDirectory";
})(FileType || (FileType = {}));
async function packageCommand(options = {}) {
    const extensionPath = options.cwd ?? process.cwd();
    const pkg = await readManifest(extensionPath);
    await prepublish(extensionPath, pkg);
    const files = await collect(extensionPath, pkg);
    const packagePath = (0, path_1.normalize)(options.packagePath ?? (0, path_1.join)(extensionPath, (0, extensions_1.getPackageDirname)(pkg) + ".foxe"));
    await writeFoxe(extensionPath, files, packagePath);
}
async function installCommand(options = {}) {
    const extensionPath = options.cwd ?? process.cwd();
    const pkg = await readManifest(extensionPath);
    await prepublish(extensionPath, pkg);
    const files = await collect(extensionPath, pkg);
    await install(files, extensionPath, pkg);
}
async function publishCommand(options) {
    const foxeUrl = options.foxe;
    if (foxeUrl == undefined) {
        throw new Error(`--foxe <foxe> Published .foxe file URL is required`);
    }
    // Open the package.json file
    const extensionPath = options.cwd ?? process.cwd();
    const pkgPath = (0, path_1.join)(extensionPath, "package.json");
    const pkg = await readManifest(extensionPath);
    const publisher = pkg.namespaceOrPublisher;
    if (publisher.length === 0 || publisher === "unknown") {
        throw new Error(`Invalid publisher "${publisher}" in ${pkgPath}`);
    }
    const homepage = pkg.homepage;
    if (homepage == undefined || homepage.length === 0) {
        throw new Error(`Missing required field "homepage" in ${pkgPath}`);
    }
    const license = pkg.license;
    if (license == undefined || license.length === 0) {
        throw new Error(`Missing required field "license" in ${pkgPath}`);
    }
    const version = options.version ?? pkg.version;
    if (version.length === 0) {
        throw new Error(`Missing required field "version" in ${pkgPath}`);
    }
    if (version === "0.0.0") {
        throw new Error(`Invalid version "${version}" in ${pkgPath}`);
    }
    const keywords = JSON.stringify(pkg.keywords ?? []);
    const readme = options.readme ?? (await githubRawFile(homepage, "README.md"));
    if (readme == undefined || readme.length === 0) {
        throw new Error(`Could not infer README.md URL. Use --readme <url>`);
    }
    const changelog = options.changelog ?? (await githubRawFile(homepage, "CHANGELOG.md"));
    if (changelog == undefined || changelog.length === 0) {
        throw new Error(`Could not infer CHANGELOG.md URL. Use --changelog <url>`);
    }
    // Fetch the .foxe file and compute the SHA256 hash
    const res = await (0, node_fetch_1.default)(foxeUrl);
    const foxeData = await res.arrayBuffer();
    const hash = (0, crypto_1.createHash)("sha256");
    const sha256sum = hash.update(new Uint8Array(foxeData)).digest("hex");
    // Print the extension.json entry
    (0, log_1.info)(`
  {
    "id": "${pkg.id}",
    "name": "${pkg.displayName ?? pkg.name}",
    "description": "${pkg.description}",
    "publisher": "${pkg.namespaceOrPublisher}",
    "homepage": "${homepage}",
    "readme": "${readme}",
    "changelog": "${changelog}",
    "license": "${license}",
    "version": "${version}",
    "sha256sum": "${sha256sum}",
    "foxe": "${foxeUrl}",
    "keywords": ${keywords}
  }
`);
}
async function readManifest(extensionPath) {
    const pkgPath = (0, path_1.join)(extensionPath, "package.json");
    let pkg;
    try {
        pkg = JSON.parse(await (0, promises_1.readFile)(pkgPath, { encoding: "utf8" }));
    }
    catch (err) {
        throw new Error(`Failed to load ${pkgPath}: ${String(err)}`);
    }
    const manifest = pkg;
    if (typeof manifest.name !== "string") {
        throw new Error(`Missing required field "name" in ${pkgPath}`);
    }
    if (typeof manifest.version !== "string") {
        throw new Error(`Missing required field "version" in ${pkgPath}`);
    }
    if (typeof manifest.main !== "string") {
        throw new Error(`Missing required field "main" in ${pkgPath}`);
    }
    if (manifest.files != undefined && !Array.isArray(manifest.files)) {
        throw new Error(`Invalid "files" entry in ${pkgPath}`);
    }
    const publisher = manifest.publisher ?? (0, extensions_1.parsePackageName)(manifest.name).namespace;
    if (publisher == undefined || publisher.length === 0) {
        throw new Error(`Unknown publisher, add a "publisher" field to package.json`);
    }
    manifest.namespaceOrPublisher = publisher;
    manifest.id = (0, extensions_1.getPackageId)(manifest);
    return manifest;
}
async function prepublish(extensionPath, pkg) {
    const script = pkg.scripts?.["foxglove:prepublish"];
    if (script == undefined) {
        return;
    }
    (0, log_1.info)(`Executing prepublish script 'npm run foxglove:prepublish'...`);
    await new Promise((resolve, reject) => {
        const tool = "npm";
        const cwd = extensionPath;
        const child = (0, child_process_1.spawn)(tool, ["run", "foxglove:prepublish"], {
            cwd,
            shell: true,
            stdio: "inherit",
        });
        child.on("exit", (code) => {
            if (code === 0) {
                resolve();
            }
            else {
                reject(new Error(`${tool} failed with exit code ${code ?? "<null>"}`));
            }
        });
        child.on("error", reject);
    });
}
async function collect(extensionPath, pkg) {
    const files = new Set();
    const baseFiles = [
        (0, path_1.join)(extensionPath, "package.json"),
        (0, path_1.join)(extensionPath, "README.md"),
        (0, path_1.join)(extensionPath, "CHANGELOG.md"),
        (0, path_1.join)(extensionPath, pkg.main),
    ];
    for (const file of baseFiles) {
        if (!(await pathExists(file, FileType.File))) {
            throw new Error(`Missing required file ${file}`);
        }
        files.add(file);
    }
    if (pkg.files != undefined) {
        for (const relFile of pkg.files) {
            const file = (0, path_1.join)(extensionPath, relFile);
            if (!inDirectory(extensionPath, file)) {
                throw new Error(`File ${file} is outside of the extension directory`);
            }
            if (!(await pathExists(file, FileType.FileOrDirectory))) {
                throw new Error(`Missing required path ${file}`);
            }
            files.add(file);
        }
    }
    else {
        const distDir = (0, path_1.join)(extensionPath, "dist");
        if (!(await pathExists(distDir, FileType.Directory))) {
            throw new Error(`Missing required directory ${distDir}`);
        }
        files.add(distDir);
    }
    return Array.from(files.values())
        .map((f) => (0, path_1.relative)(extensionPath, f))
        .sort();
}
async function writeFoxe(baseDir, files, outputFile) {
    const zip = new jszip_1.default();
    for (const file of files) {
        if (await isDirectory((0, path_1.join)(baseDir, file))) {
            await addDirToZip(zip, baseDir, file);
        }
        else {
            addFileToZip(zip, baseDir, file);
        }
    }
    (0, log_1.info)(`Writing archive to ${outputFile}`);
    await new Promise((resolve, reject) => {
        zip
            .generateNodeStream({ type: "nodebuffer", streamFiles: true, compression: "DEFLATE" })
            .pipe((0, fs_1.createWriteStream)(outputFile, { encoding: "binary" }))
            .on("error", reject)
            .on("finish", resolve);
    });
}
async function install(files, extensionPath, pkg) {
    process.chdir(extensionPath);
    const dirName = (0, extensions_1.getPackageDirname)(pkg);
    const id = (0, extensions_1.getPackageId)(pkg);
    // The snap package does not use the regular _home_ directory but instead uses a separate snap
    // application directory to limit filesystem access.
    //
    // We look for this app directory as a signal that the user installed the snap package rather than
    // the deb package. If we detect a snap installation directory, we install to the snap path and
    // exit.
    const snapAppDir = (0, path_1.join)((0, os_1.homedir)(), "snap", "foxglove-studio", "current");
    if (await isDirectory(snapAppDir)) {
        (0, log_1.info)(`Detected snap install at ${snapAppDir}`);
        await removeExtensionsById({
            id,
            rootFolder: (0, path_1.join)(snapAppDir, ".foxglove-studio", "extensions"),
        });
        const extensionDir = (0, path_1.join)(snapAppDir, ".foxglove-studio", "extensions", dirName);
        await copyFiles(files, extensionDir);
        return;
    }
    await removeExtensionsById({
        id,
        rootFolder: (0, path_1.join)((0, os_1.homedir)(), ".foxglove-studio", "extensions"),
    });
    // If there is no snap install present then we install to the home directory
    const defaultExtensionDir = (0, path_1.join)((0, os_1.homedir)(), ".foxglove-studio", "extensions", dirName);
    await copyFiles(files, defaultExtensionDir);
}
// Remove previous extensions by id. There could be multiple extensions with a matching ID on
// case-sensitive file systems since they are read by their directory name and may differ in case.
async function removeExtensionsById(opts) {
    const extensions = await listExtensions(opts.rootFolder);
    for (const ext of extensions) {
        if (ext.id === opts.id) {
            (0, log_1.info)(`Removing existing extension '${ext.id}' at '${ext.directory}'`);
            await (0, rimraf_1.rimraf)(ext.directory);
        }
    }
}
async function listExtensions(rootFolder) {
    const extensions = [];
    if (!(await pathExists(rootFolder, FileType.Directory))) {
        return extensions;
    }
    const rootFolderContents = await (0, promises_1.readdir)(rootFolder, { withFileTypes: true });
    for (const entry of rootFolderContents) {
        try {
            if (!entry.isDirectory()) {
                continue;
            }
            const extensionRootPath = (0, path_1.join)(rootFolder, entry.name);
            const packagePath = (0, path_1.join)(extensionRootPath, "package.json");
            const packageData = await (0, promises_1.readFile)(packagePath, { encoding: "utf8" });
            const packageJson = JSON.parse(packageData);
            const id = (0, extensions_1.getPackageId)(packageJson);
            (0, log_1.info)(`Found existing extension '${id}' at '${extensionRootPath}'`);
            extensions.push({
                id,
                packageJson,
                directory: extensionRootPath,
            });
        }
        catch (err) {
            (0, log_1.error)(err);
        }
    }
    return extensions;
}
async function copyFiles(files, destDir) {
    await (0, promises_1.mkdir)(destDir, { recursive: true });
    (0, log_1.info)(`Copying files to ${destDir}`);
    for (const file of files) {
        const target = (0, path_1.join)(destDir, file);
        (0, log_1.info)(`  - ${file} -> ${target}`);
        await cpR(file, target, { stopOnErr: true });
    }
}
async function pathExists(filename, fileType) {
    try {
        const finfo = await (0, promises_1.stat)(filename);
        switch (fileType) {
            case FileType.File:
                return finfo.isFile();
            case FileType.Directory:
                return finfo.isDirectory();
            case FileType.FileOrDirectory:
                return finfo.isFile() || finfo.isDirectory();
        }
    }
    catch {
        // ignore
    }
    return false;
}
async function isDirectory(pathname) {
    try {
        return (await (0, promises_1.stat)(pathname)).isDirectory();
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
    }
    catch (_) {
        // ignore any error from stat and assume not a directory
    }
    return false;
}
async function addDirToZip(zip, baseDir, dirname) {
    const fullPath = (0, path_1.join)(baseDir, dirname);
    const entries = await (0, promises_1.readdir)(fullPath, { withFileTypes: true });
    for (const entry of entries) {
        const entryPath = (0, path_1.join)(dirname, entry.name);
        if (entry.isFile()) {
            addFileToZip(zip, baseDir, entryPath);
        }
        else if (entry.isDirectory()) {
            await addDirToZip(zip, baseDir, entryPath);
        }
    }
}
function addFileToZip(zip, baseDir, filename) {
    const fullPath = (0, path_1.join)(baseDir, filename);
    (0, log_1.info)(`archiving ${fullPath}`);
    // zip file paths must use / as separator.
    const zipFilename = filename.replace(/\\/g, "/");
    zip.file(zipFilename, (0, fs_1.createReadStream)(fullPath), {
        createFolders: true,
        date: MOD_DATE,
    });
}
function inDirectory(directory, pathname) {
    const relPath = (0, path_1.relative)(directory, pathname);
    const parts = relPath.split(path_1.sep);
    return parts[0] !== "..";
}
async function githubRawFile(homepage, filename) {
    const match = /^https:\/\/github\.com\/([^/]+)\/([^/?]+)$/.exec(homepage);
    if (match == undefined) {
        return undefined;
    }
    const [_, org, project] = match;
    if (org == undefined || project == undefined) {
        return undefined;
    }
    const url = `https://raw.githubusercontent.com/${org}/${project}/main/${filename}`;
    try {
        const res = await (0, node_fetch_1.default)(url);
        const content = await res.text();
        return content.length > 0 ? url : undefined;
    }
    catch {
        return undefined;
    }
}