Add tool to create foxglove extensions
This tool lets users create new foxglove extensions by running the
equivalent of `npm init foxglove-extension@latest <patch>`.
A future patch will actually create a new extension using this tool.
Change-Id: Ifb3ca868fa72c30477a4943c3a6a805b06d0642d
Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
diff --git a/tools/bazel b/tools/bazel
index d4312b2..4090764 100755
--- a/tools/bazel
+++ b/tools/bazel
@@ -99,7 +99,7 @@
ENVIRONMENT_VARIABLES+=(HOSTNAME="${HOSTNAME}")
ENVIRONMENT_VARIABLES+=(SHELL="${SHELL}")
ENVIRONMENT_VARIABLES+=(USER="${USER}")
-ENVIRONMENT_VARIABLES+=(PATH="/usr/bin:/bin")
+ENVIRONMENT_VARIABLES+=(PATH="${PATH}")
ENVIRONMENT_VARIABLES+=(HOME="${HOME}")
ENVIRONMENT_VARIABLES+=(TERM="${TERM}")
ENVIRONMENT_VARIABLES+=(LANG="${LANG:-C}")
diff --git a/tools/build_rules/foxglove.bzl b/tools/build_rules/foxglove.bzl
new file mode 100644
index 0000000..cfcab92
--- /dev/null
+++ b/tools/build_rules/foxglove.bzl
@@ -0,0 +1,46 @@
+load("@aspect_rules_js//js:defs.bzl", "js_run_binary")
+
+def foxglove_extension(name, **kwargs):
+ """Compiles a foxglove extension into a .foxe file.
+
+ Drag the generated .foxe file onto the foxglove UI in your browser. The
+ extension should then install automatically. If you want to update the
+ extension, drag a new version to the UI.
+
+ Use `tools/foxglove/create-foxglove-extension`. Don't use this rule
+ directly. See `tools/foxglove/README.md` for more details.
+
+ Args:
+ name: The name of the target.
+ **kwargs: The arguments to pass to js_run_binary.
+ """
+
+ # We need to glob all the non-Bazel files because we're going to invoke the
+ # `foxglove-extension` binary directly. That expects to have access to
+ # `package.json` and the like.
+ all_files = native.glob(
+ ["**"],
+ exclude = [
+ "BUILD",
+ "BUILD.bazel",
+ ],
+ )
+
+ # Run the `foxglove-extension` wrapper to create the .foxe file.
+ js_run_binary(
+ name = name,
+ srcs = all_files + [
+ ":node_modules",
+ ],
+ tool = "//tools/foxglove:foxglove_extension_wrapper",
+ outs = ["%s.foxe" % name],
+ args = [
+ "package",
+ "--out",
+ "%s.foxe" % name,
+ ],
+ target_compatible_with = [
+ "@platforms//cpu:x86_64",
+ ],
+ **kwargs
+ )
diff --git a/tools/foxglove/BUILD b/tools/foxglove/BUILD
new file mode 100644
index 0000000..6bbd263
--- /dev/null
+++ b/tools/foxglove/BUILD
@@ -0,0 +1,55 @@
+load("@aspect_rules_js//js:defs.bzl", "js_binary")
+load("@npm//:create-foxglove-extension/package_json.bzl", "bin")
+
+bin.create_foxglove_extension_binary(
+ name = "create_foxglove_extension",
+)
+
+bin.foxglove_extension_binary(
+ name = "foxglove_extension",
+ data = [
+ # This upstream binary needs the dummy npm binary in its runfiles since
+ # it will invoke this dummy npm binary.
+ ":foxglove_extension_wrapper_npm",
+ ],
+)
+
+js_binary(
+ name = "foxglove_extension_wrapper",
+ data = [
+ ":foxglove_extension",
+ # This binary needs the dummy npm binary in its runfiles since it needs
+ # to point the `foxglove_extension` binary above to it.
+ ":foxglove_extension_wrapper_npm",
+ "//:node_modules/create-foxglove-extension",
+ ],
+ entry_point = "foxglove_extension_wrapper.js",
+ visibility = ["//visibility:public"],
+)
+
+js_binary(
+ name = "foxglove_extension_wrapper_npm",
+ entry_point = "foxglove_extension_wrapper_npm.js",
+)
+
+py_binary(
+ name = "creation_wrapper",
+ srcs = ["creation_wrapper.py"],
+ data = [
+ "BUILD.bazel.tmpl",
+ ":creation_wrapper_npm",
+ "@com_github_bazelbuild_buildtools//buildozer",
+ ],
+ target_compatible_with = [
+ "@platforms//cpu:x86_64",
+ ],
+ deps = [
+ "@pip//pyyaml",
+ "@rules_python//python/runfiles",
+ ],
+)
+
+py_binary(
+ name = "creation_wrapper_npm",
+ srcs = ["creation_wrapper_npm.py"],
+)
diff --git a/tools/foxglove/BUILD.bazel.tmpl b/tools/foxglove/BUILD.bazel.tmpl
new file mode 100644
index 0000000..60f03dc
--- /dev/null
+++ b/tools/foxglove/BUILD.bazel.tmpl
@@ -0,0 +1,8 @@
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:foxglove.bzl", "foxglove_extension")
+
+npm_link_all_packages(name = "node_modules")
+
+foxglove_extension(
+ name = "extension",
+)
diff --git a/tools/foxglove/README.md b/tools/foxglove/README.md
new file mode 100644
index 0000000..7c3a34a
--- /dev/null
+++ b/tools/foxglove/README.md
@@ -0,0 +1,14 @@
+# Creating a new extension
+
+Change directories into the directory of interest.
+
+ $ cd path/to/package
+
+The run the creation script and pass the new directory name as an argument.
+
+ $ ../.../tools/foxglove/create-foxglove-extension <path>
+
+The script will automatically set up all the Bazel hooks so you can compile
+the extension.
+
+ $ bazel build //path/to/package/<path>:extension
diff --git a/tools/foxglove/create-foxglove-extension b/tools/foxglove/create-foxglove-extension
new file mode 100755
index 0000000..ffc3bad
--- /dev/null
+++ b/tools/foxglove/create-foxglove-extension
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+exec bazel run \
+ --run_under="//tools/foxglove:creation_wrapper" \
+ //tools/foxglove:create_foxglove_extension \
+ -- \
+ "$@"
diff --git a/tools/foxglove/creation_wrapper.py b/tools/foxglove/creation_wrapper.py
new file mode 100644
index 0000000..dd36c5f
--- /dev/null
+++ b/tools/foxglove/creation_wrapper.py
@@ -0,0 +1,126 @@
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+import yaml
+from python.runfiles import runfiles
+
+RUNFILES = runfiles.Create()
+
+FAKE_NPM_BIN = RUNFILES.Rlocation(
+ "org_frc971/tools/foxglove/creation_wrapper_npm")
+BUILDOZER_BIN = RUNFILES.Rlocation(
+ "com_github_bazelbuild_buildtools/buildozer/buildozer_/buildozer")
+
+WORKSPACE_DIR = Path(os.environ["BUILD_WORKSPACE_DIRECTORY"])
+WORKING_DIR = Path(os.environ["BUILD_WORKING_DIRECTORY"])
+
+
+def create_npm_link(temp_dir: Path, env: dict[str, str]):
+ """Set up the creation_wrapper_npm.py script as the "npm" binary."""
+ bin_dir = temp_dir / "bin"
+ bin_dir.mkdir()
+ npm = bin_dir / "npm"
+ npm.symlink_to(FAKE_NPM_BIN)
+ env["PATH"] = f"{temp_dir / 'bin'}:{env['PATH']}"
+
+
+def run_create_foxglove_extension(argv: list[str], name: str):
+ """Runs the create-foxglove-extension binary.
+
+ Args:
+ argv: The list of command line arguments passed to this wrapper.
+ name: The (directory) name of the new extension to be created.
+ """
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_dir = Path(temp_dir)
+ env = os.environ.copy()
+ create_npm_link(temp_dir, env)
+
+ env["BAZEL_BINDIR"] = WORKING_DIR
+ env.pop("RUNFILES_DIR", None)
+ env.pop("RUNFILES_MANIFEST_FILE", None)
+
+ subprocess.run(argv[1:], check=True, env=env, cwd=WORKING_DIR)
+ # For some reason, the `foxglove-extension` binary doesn't set up the
+ # ts-loader dependency. Do it manually here.
+ subprocess.run(["npm", "install", "ts-loader@^9"],
+ check=True,
+ env=env,
+ cwd=WORKING_DIR / name)
+
+
+def add_new_js_project(name: str):
+ """Tell Bazel about the new project."""
+ # The name of the Bazel package for the new extension.
+ package_name = WORKING_DIR.relative_to(WORKSPACE_DIR) / name
+
+ # Add the new "node_modules" directory to the ignore list.
+ bazelignore_file = WORKSPACE_DIR / ".bazelignore"
+ bazelignore = bazelignore_file.read_text()
+ bazelignore_entry = str(package_name / "node_modules")
+ if bazelignore_entry not in bazelignore.splitlines():
+ bazelignore = bazelignore.rstrip("\n") + "\n"
+ bazelignore_file.write_text(bazelignore + bazelignore_entry + "\n")
+
+ # Add the new project to the workspace list. This ensures the lock file
+ # gets updated properly.
+ pnpm_workspace_file = WORKSPACE_DIR / "pnpm-workspace.yaml"
+ pnpm_workspace = yaml.load(pnpm_workspace_file.read_text(),
+ Loader=yaml.CLoader)
+ if str(package_name) not in pnpm_workspace["packages"]:
+ pnpm_workspace["packages"].append(str(package_name))
+ pnpm_workspace_file.write_text(yaml.dump(pnpm_workspace))
+
+ # Add the new project to the workspace. This ensures that all of its
+ # dependencies get downloaded by Bazel.
+ subprocess.check_call([
+ BUILDOZER_BIN,
+ f"add data @//{package_name}:package.json",
+ "WORKSPACE:npm",
+ ],
+ cwd=WORKSPACE_DIR)
+
+ # Regenerate the lock file with the new project's dependencies included.
+ subprocess.check_call([
+ "bazel",
+ "run",
+ "--",
+ "@pnpm//:pnpm",
+ "--dir",
+ WORKSPACE_DIR,
+ "install",
+ "--lockfile-only",
+ ],
+ cwd=WORKSPACE_DIR)
+
+
+def main(argv):
+ """Runs the main logic."""
+
+ # Assume that the only argument the user passed in is the name of the
+ # extension. We can probably do better here, but oh well.
+ create_foxglove_extension_args = argv[2:]
+ name = create_foxglove_extension_args[0]
+
+ run_create_foxglove_extension(argv, name)
+ add_new_js_project(name)
+
+ # Generate a BUILD file.
+ build_file_template = WORKSPACE_DIR / "tools/foxglove/BUILD.bazel.tmpl"
+ build_file = WORKING_DIR / name / "BUILD.bazel"
+ build_file.write_text(build_file_template.read_text())
+
+ # Fix up the tsconfig.json. For some reason the inheritance for the `lib`
+ # field doesn't work out of the box. We're using string manipulation since
+ # we don't have a readily-available "JSON with comments" parser.
+ tsconfig_file = WORKING_DIR / name / "tsconfig.json"
+ tsconfig = tsconfig_file.read_text()
+ tsconfig = tsconfig.replace('"lib": ["dom"]', '"lib": ["dom", "es2022"]')
+ tsconfig_file.write_text(tsconfig)
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/tools/foxglove/creation_wrapper_npm.py b/tools/foxglove/creation_wrapper_npm.py
new file mode 100644
index 0000000..6483536
--- /dev/null
+++ b/tools/foxglove/creation_wrapper_npm.py
@@ -0,0 +1,45 @@
+"""Acts as a dummy `npm` binary for the `create-foxglove-extension` binary.
+
+The `create-foxglove-extension` binary uses `npm` to manipulate the
+`package.json` file instead of doing so directly. Since we don't have access to
+the real `npm` binary here we just emulate the limited functionality we need.
+"""
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+
+def main(argv: list[str]):
+ """Runs the main logic."""
+ parser = argparse.ArgumentParser()
+ parser.add_argument("command")
+ parser.add_argument("--save-exact", action="store_true")
+ parser.add_argument("--save-dev", action="store_true")
+ args, packages = parser.parse_known_args(argv[1:])
+
+ # Validate the input arguments.
+ if args.command != "install":
+ raise ValueError("Don't know how to simulate anything other "
+ f"than 'install'. Got '{args.command}'.")
+
+ for package in packages:
+ if "@^" not in package:
+ raise ValueError(f"Got unexpected package: {package}")
+
+ # Append the specified packages to the dependencies list.
+ package_version_pairs = list(
+ package.rsplit("@", maxsplit=1) for package in packages)
+ package_json_file = Path.cwd() / "package.json"
+ package_json = json.loads(package_json_file.read_text())
+ package_json.setdefault("dependencies", {}).update(
+ {package: version
+ for package, version in package_version_pairs})
+
+ package_json_file.write_text(
+ json.dumps(package_json, sort_keys=True, indent=4))
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/tools/foxglove/extension.tmpl.BUILD b/tools/foxglove/extension.tmpl.BUILD
new file mode 100644
index 0000000..bfe99aa
--- /dev/null
+++ b/tools/foxglove/extension.tmpl.BUILD
@@ -0,0 +1,5 @@
+load("//tools/build_rules:foxglove.bzl", "foxglove_extension")
+
+foxglove_extension(
+ name = "{NAME}",
+)
diff --git a/tools/foxglove/foxglove_extension_wrapper.js b/tools/foxglove/foxglove_extension_wrapper.js
new file mode 100644
index 0000000..71560f2
--- /dev/null
+++ b/tools/foxglove/foxglove_extension_wrapper.js
@@ -0,0 +1,56 @@
+// This script acts as a wrapper for the `foxglove-extension` binary. We need a
+// wrapper here because `foxglove-extension` wants to invoke `npm` directly.
+// Since we don't support the real npm binary, we force `foxglove-extension` to
+// use our fake npm binary (`foxglove_extension_wrapper_npm.js`) by
+// manipulating the PATH.
+
+const { spawnSync } = require('child_process');
+const path = require('path');
+const process = require('process');
+const fs = require('fs');
+const { tmpdir } = require('os');
+
+// Add a directory to the PATH environment variable.
+function addToPath(directory) {
+ const currentPath = process.env.PATH || '';
+ const newPath = `${directory}${path.delimiter}${currentPath}`;
+ process.env.PATH = newPath;
+}
+
+const fakeNpm = path.join(__dirname, 'foxglove_extension_wrapper_npm.sh');
+
+const tempBinDir = fs.mkdtempSync(path.join(tmpdir(), "foxglove_extension_wrapper-tmp-"));
+fs.symlinkSync(fakeNpm, path.join(tempBinDir, 'npm'));
+
+addToPath(tempBinDir);
+
+// Create a relative path for a specific root-relative directory.
+function getRelativePath(filePath) {
+ // Count the number of directories and construct the relative path.
+ const numDirectories = filePath.split('/').length;
+ return '../'.repeat(numDirectories);
+}
+
+// We need to know the path to the `foxglove-extension` binary from the
+// sub-directory where we're generating code into.
+const relativePath = getRelativePath(process.env.BAZEL_PACKAGE);
+const foxgloveExtensionPath = path.join(relativePath, `tools/foxglove/foxglove_extension.sh`)
+
+// Extract arguments intended for the `foxglove-extension` binary.
+const args = process.argv.slice(2);
+
+// Execute the `foxglove-extension` binary.
+try {
+ const result = spawnSync(foxgloveExtensionPath, args, { stdio: 'inherit', cwd: process.env.BAZEL_PACKAGE });
+ if (result.error) {
+ console.error('Error executing foxglove_extension:', result.error);
+ process.exit(1);
+ }
+ if (result.status !== 0) {
+ console.error(`foxglove_extension exited with status ${result.status}`);
+ process.exit(result.status);
+ }
+} catch (error) {
+ console.error('Error executing foxglove_extension:', error);
+ process.exit(1);
+}
diff --git a/tools/foxglove/foxglove_extension_wrapper_npm.js b/tools/foxglove/foxglove_extension_wrapper_npm.js
new file mode 100644
index 0000000..88dfca5
--- /dev/null
+++ b/tools/foxglove/foxglove_extension_wrapper_npm.js
@@ -0,0 +1,69 @@
+// This script acts as an "npm" binary for the foxglove-extension binary. We
+// don't actually care to do any npm things here. For some reason
+// foxglove-extension defers to npm to execute the various build stages. So
+// all this script does is execute those various build stages. The stages are
+// defined in the package.json file.
+
+const fs = require('fs');
+const { execSync } = require('child_process');
+const path = require('path');
+
+// Read the package.json file.
+function readPackageJson() {
+ try {
+ const packageJson = fs.readFileSync('package.json', 'utf8');
+ return JSON.parse(packageJson);
+ } catch (error) {
+ console.error('Error reading package.json:', error);
+ process.exit(1);
+ }
+}
+
+// Execute the named script specified in package.json.
+function executeScript(scriptName) {
+ const packageJson = readPackageJson();
+ const scripts = packageJson.scripts || {};
+
+ if (!scripts[scriptName]) {
+ console.error(`Script '${scriptName}' not found in package.json`);
+ process.exit(1);
+ }
+
+ // We cannot execute the `foxglove-extension` binary as-is (at least not
+ // without setting up a custom PATH). So we instead point at the
+ // Bazel-generated wrapper script for that binary.
+ const scriptParts = scripts[scriptName].split(' ');
+ const bin = scriptParts[0];
+ if (bin !== 'foxglove-extension') {
+ console.error(`Cannot support commands other than 'foxglove-extension'. Got: ${bin}`);
+ process.exit(1);
+ }
+ scriptParts[0] = path.join(__dirname, 'foxglove_extension.sh');
+
+ // Execute the `foxglove-extension` command specified in the script.
+ try {
+ console.log(`Executing script '${scriptName}'...`);
+ execSync(scriptParts.join(' '), { stdio: 'inherit' });
+ } catch (error) {
+ console.error(`Error executing script '${scriptName}':`, error);
+ process.exit(1);
+ }
+}
+
+function main() {
+ // Validate the input arguments.
+ if (process.argv.length !== 4) {
+ console.error('Usage: node foxglove_extension_wrapper_npm.js <scriptName>');
+ process.exit(1);
+ }
+ if (process.argv[2] !== "run") {
+ console.error(`Cannot support commands other than 'run'. Got: ${process.argv[2]}`);
+ process.exit(1);
+ }
+
+ // Run the script specified in the package.json file.
+ const scriptName = process.argv[3];
+ executeScript(scriptName);
+}
+
+main();
diff --git a/tools/python/requirements.txt b/tools/python/requirements.txt
index ddbaef3..81d306d 100644
--- a/tools/python/requirements.txt
+++ b/tools/python/requirements.txt
@@ -15,6 +15,7 @@
validators
yapf
sympy
+pyyaml
# TODO(phil): Migrate to absl-py. These are abandoned as far as I can tell.
python-gflags