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;
}
}