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