Minimal example
ndx-hello adds an ndx hello command, plus a sidecar web app that the hub home and each project's dashboard link to.
Files
ndx-hello/
package.json
bin/cli.js
bin/serve.js
schema/config.jsonpackage.json:
{
"name": "ndx-hello",
"version": "1.0.0",
"type": "module",
"ndx": {
"extension": {
"apiVersion": 1,
"name": "hello",
"displayName": "Hello",
"ndxVersion": ">=0.10.0",
"commands": [
{ "name": "hello", "bin": "bin/cli.js", "summary": "Greet, then show the project's PRD status" }
],
"sidecar": { "bin": "bin/serve.js", "health": "/healthz" },
"hub": { "label": "Hello", "description": "A page the hub serves for the hello extension" },
"projectLinks": [{ "label": "Hello", "path": "/project/{projectId}" }],
"configSchema": "schema/config.json"
}
}
}bin/cli.js reads its config, then calls back into the same n-dx install:
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
const config = JSON.parse(readFileSync(process.env.NDX_EXTENSION_CONFIG, "utf-8"));
console.log(`${config.greeting ?? "Hello"} from n-dx ${process.env.NDX_VERSION}`);
// NDX_BIN is n-dx's cli.js; process.execPath is the Node n-dx runs on.
execFileSync(process.execPath, [process.env.NDX_BIN, "status", "."], { stdio: "inherit" });bin/serve.js listens on the loopback PORT the hub picks:
import { createServer } from "node:http";
const secret = process.env.NDX_SIDECAR_SECRET;
if (!secret) throw new Error("Expected a hub launch secret");
process.stdin.resume();
process.stdin.on("end", () => process.exit(0));
createServer((req, res) => {
if (req.headers["x-ndx-sidecar-secret"] !== secret) {
res.writeHead(403).end("Forbidden");
return;
}
if (req.url === "/healthz") {
res.writeHead(200).end("ok");
return;
}
// The hub strips /x/hello from the path and says where it mounted the app.
const base = req.headers["x-ndx-base-path"];
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(`Mounted at ${base}; asked for ${req.url}\n`);
}).listen(Number(process.env.PORT), "127.0.0.1");schema/config.json:
{
"type": "object",
"properties": { "greeting": { "type": "string" } },
"additionalProperties": false
}Develop it
Point discovery at the checkout. Add the directory to
extensions.pathsin<ndx home>/config.json(the home is~/.ndxunlessNDX_HOMEis set):json{ "extensions": { "paths": ["/path/to/ndx-hello"] } }Check it with
ndx extensions doctor. The command prints one line per problem with its fix, and exits 1 if there is any problem.Approve it with
ndx extensions enable hello. Pass--yesto skip the prompt.Run
ndx hello. Set the greeting withndx config extensions.hello.greeting Hi(or--userfor every project).Open the app:
ndx start ., thenhttp://localhost:3117/x/hello/. The hub home shows a Hello card, and the project's dashboard header shows a Hello link.
Editing package.json's ndx.extension block or bumping version asks for approval again. Editing the scripts does not.
Ship it
Publish the package to npm. Users install and approve it with:
ndx extensions add ndx-hello
ndx extensions enable helloadd installs into <ndx home>/extensions with --ignore-scripts, so no install script runs before the user approves the extension. A package that is not on a registry installs from a path, resolved against the directory add runs in: ndx extensions add ./ndx-hello-1.0.0.tgz or ndx extensions add ../ndx-hello. add takes no npm flags; set the registry and similar through npm's own config (npm_config_registry, or <ndx home>/extensions/.npmrc). A project can also list the package in its own package.json. n-dx then runs it only for that project, after an approval tied to that project's path.
