Write your first tool¶
This tutorial takes you from scripts/new-tool.mjs to your own signed tool, running in a local APRScaching app and
covered by a test. It is for developers who know some JavaScript. At the end your tool passes every check of this
repository and is ready to host, or to propose for the project registry.
Before you start¶
- Node 22 or newer, with corepack (it ships with Node) for pnpm.
- Your fork of apachler/aprscaching-tools, cloned.
- A checkout of apachler/aprscaching with
pnpm installrun once, for the local app (Run from source in the APRScaching manual). - A Chromium-based browser, Firefox or Safari.
- Python 3, for the documentation build.
- The sandbox API open beside you: it lists every call this page uses.
Steps¶
Set up the repository¶
In your clone of the tools repository, install the build and test tools and fetch the shared libraries:
fetch-libs writes the APRScaching MIT libraries at the lib.lock commit into vendor/aprscaching/. The build
bundles them into a tool's script.
Start the tool¶
Let new-tool.mjs write the tool, its test and its catalogue page:
node scripts/new-tool.mjs my-station-log --title "My station log" --author <your callsign> \
--description "Lists the stations the Station DB tool hears."
It prints the files it wrote and builds the tool:
| File | What it is |
|---|---|
tools/my-station-log/tool.json |
The manifest: version 1.0.0, tool API 1.0, the command permission |
tools/my-station-log/src/index.js |
The source, a working /my-station-log command |
tools/my-station-log/tool.js |
The built script the sandbox runs; never edit it |
tools/my-station-log/README.md |
The tool's README, with its Permissions table |
test/my-station-log.test.mjs |
A test of the command |
docs/catalogue/my-station-log.md |
The tool's catalogue page |
It also adds a row to the Utilities table of docs/catalogue/index.md and an entry to the nav in mkdocs.yml. As
it is, the tool passes pnpm test, node scripts/verify.mjs and mkdocs build --strict.
The name is the tool's identity: lower case, 2 to 40 characters of a-z, 0-9 and -. The manifest
lists every field of tool.json.
Write the script¶
The source is the body of a function the sandbox calls with three arguments: register, ipc and tool. Replace
tools/my-station-log/src/index.js with a station log:
// SPDX-License-Identifier: MIT
/* global register, ipc */
// My station log: lists the stations the Station DB tool announces on the bus, and answers /seen and /whois.
const MAX = 10;
const seen = []; // newest first: { call, type, source }
function panel(extra = []) {
const rows = seen.map((s) => [s.call, s.type || "-", s.source || "-"]);
return {
title: "My station log",
nodes: [
...extra,
rows.length
? { kind: "table", head: ["Call", "Type", "Via"], rows }
: { kind: "text", text: "No stations yet. Enable Station DB and turn on live stations.", tone: "muted" },
],
};
}
if (ipc) {
ipc.subscribe("station.seen", (data) => {
const call = data && typeof data.call === "string" ? data.call : "";
if (!call) return;
const i = seen.findIndex((s) => s.call === call);
if (i >= 0) seen.splice(i, 1);
seen.unshift({ call, type: String(data.type || ""), source: String(data.source || "") });
if (seen.length > MAX) seen.length = MAX;
ipc.setPanel(panel());
});
}
register({
commands: {
seen: () =>
seen.length ? seen.map((s) => `${s.call} ${s.type || "-"} ${s.source || "-"}`) : ["No stations heard yet."],
whois: (args) => {
const call = args.trim().toUpperCase();
if (!call) return ["Usage: /whois <callsign>"];
if (!ipc) return ["My station log needs the ipc permission to ask Station DB."];
ipc.call("station.type", call).then((type) => {
const known = typeof type === "string" && type !== "";
ipc.setPanel(panel([{ kind: "kv", key: call, value: known ? type : "not heard", tone: known ? "ok" : "muted" }]));
});
return [`Asked Station DB about ${call}; the answer shows in the panel.`];
},
},
panel: panel(),
});
ipc.subscribe("station.seen", …)hears every station the Station DB (NAMES.GP) tool publishes, and redraws the panel.ipcisundefinedunless the player grantedipc, so the script checks it first.register()runs once, with two commands and a first panel. A command answers at once with its lines./whoisasks another tool throughipc.call(), which answers later, so its result goes to the panel.- A panel is data, not HTML: a title and a list of nodes such as
kv,tableandtext. The app draws it with its own elements, in the player's theme.
Build it:
Declare what it asks for¶
The script now uses a panel and the tool bus besides commands. Every permission it uses goes in four places, and the checks compare them:
- In
tools/my-station-log/tool.json, set"permissions": ["command", "panel", "ipc"]. -
In
tools/my-station-log/README.mdand indocs/catalogue/my-station-log.md, make the Permissions table say why the tool needs each one: -
In
docs/catalogue/index.md, end the tool's row with`command` `panel` `ipc`.
Then describe the commands: replace the scaffold's /my-station-log lines under Use it in the README, and under
What it does and Use it on the catalogue page, with /seen and /whois <callsign>. When the tool fits
another group of the catalogue better than Utilities, move its row in docs/catalogue/index.md and its nav entry in
mkdocs.yml there.
Test it¶
Replace test/my-station-log.test.mjs. The harness runs the built script the way the sandbox does:
// SPDX-License-Identifier: MIT
import { describe, expect, it } from "vitest";
import { createBus, loadTool } from "./harness.mjs";
describe("my-station-log", () => {
it("lists a station Station DB announces", async () => {
const bus = createBus();
const t = loadTool("my-station-log", { bus });
expect(await t.run("seen")).toEqual(["No stations heard yet."]);
bus.emit("station.seen", { call: "OE8APR-9", type: "user", source: "RF" }, "station-db");
expect(await t.run("seen")).toEqual(["OE8APR-9 user RF"]);
});
});
Test a tool covers the harness in full.
Sign it¶
The app installs only signed tools. Make an author key once, on a computer you trust, and sign the manifest:
node scripts/genkey.mjs --raw > ~/my-tool-author.key && chmod 600 ~/my-tool-author.key
TOOL_PRIVATE_KEY="$(cat ~/my-tool-author.key)" node scripts/sign.mjs manifest tools/my-station-log/tool.json
sign.mjs writes the script's SHA-256 into entrySha256, your public key into pubkey, and the signature. Sign
again after every build. Keep the key file out of the repository: .gitignore excludes *.key.
Sign a tool explains the keys.
Run it in your local app¶
-
Copy the Station log example's small web server next to your tool, and start it:
It prints
serving … at http://127.0.0.1:8790/tool.jsonand sends the CORS header the app needs to read the files. Deleteserve.mjsagain before you propose the tool. -
In the APRScaching checkout, start the web app:
-
Open
http://localhost:5173/?demo=app&net=1&view=tools&tools=station-db.demo=appruns the whole app on sample data with no gateway,net=1lets it reach your server,view=toolsopens Shack → Tools, andtools=station-dbinstalls Station DB from the bundled project registry. -
Under Install by address, enter
http://127.0.0.1:8790/tool.jsonand select Install….The prompt shows My station log by
<your callsign>requests: command, panel, ipc and the label Signed · unknown author key (trust-on-first-use). -
Select Approve and install. A toast says the tool was installed, and the My station log panel appears.
- Under Run a tool command, enter
/whois OE6XRR-9and select Run.
To load a change to the script, build it, sign the manifest again and reload the page: the app starts your installed tools again and runs the new script once its hash matches the signed manifest.
Run the checks¶
Run what CI runs on a pull request:
Then build the documentation site, in a Python virtual environment of its own:
python3 -m venv .venv && .venv/bin/pip install -r docs/requirements.txt
node scripts/fetch-mermaid.mjs
.venv/bin/mkdocs build --strict
Check that it worked¶
pnpm testpasses, with a warning thattools/my-station-loghas no registry entry yet.verify.mjsliststools/my-station-log/tool.jsonas signed by your key, not listed in the registry.mkdocs build --strictbuilds the site, with My station log in the catalogue.- In the local app, the panel My station log shows under the list of tools,
/seenanswersNo stations heard yet.or a list of stations, and after/whois OE6XRR-9the panel's first row showsOE6XRR-9with its type, ornot heard.
The demo data never hears a new station, so the station table stays empty there. Against a gateway that hears
stations (pnpm dev --ingest in the APRScaching checkout), with Live stations switched on, the table fills.
Next¶
- Contribute a tool: propose your tool for the project registry, from a branch cut
from
dev. - Host a registry on GitHub: publish it in a registry of your own.