UNPKG

create-foxglove-extension

Version:
372 lines (371 loc) 14.8 kB
"use strict"; 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; } }