Support a persistent, private plugins directory alongside the built-in ones
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 13s

plugins/ ships baked into the image and gets wiped/replaced by the
checkout sync on every deploy (docs/deployment.adoc), so a private
plugin dropped there — the exact use case the plugin contract was
designed for — didn't survive a redeploy the way uploaded GPX/kiwix
data under data/ does.

Plugins.list/run now accept either one directory or an array, and
app.rb checks the built-in plugins/ plus data/plugins/ (already
covered by the existing ./data bind mount, no new volume needed).
This commit is contained in:
randy committed 2026-09-09 17:24:52 +02:00
1 parent 1733fc7c1b
commit 6e0b67f2a0
5 files changed
+82 -19

No files matched your search

+25 -13
View File
@@ -4,7 +4,7 @@ require "open3"
module Maptool
# Runs an external "importer" script that turns a bbox into a GeoJSON
# FeatureCollection, and lists what's available. Each plugin is a
# directory under PLUGINS_DIR with a plugin.json manifest ({"label":
# directory under one of `dirs` with a plugin.json manifest ({"label":
# "...", "command": "./run.rb"}) plus whatever script it names — the
# entire interface a plugin needs to know about maptool is "read
# --south/--west/--north/--east, print GeoJSON to stdout". Kept this thin
@@ -12,6 +12,13 @@ module Maptool
# forbids that live in its own private repo, dropped into plugins/
# locally, never touching this (public) one — see plugins/overpass-pois
# for a real, in-repo example of the contract itself.
#
# `dirs` takes either one directory or an array of them (wrapped with
# Array() below) — app.rb passes both the built-in plugins/ (baked into
# the image, wiped and replaced on every deploy along with the rest of
# the checkout) and an optional second directory under the persistent
# data/ bind mount, so a private plugin dropped there survives a
# redeploy the same way an uploaded GPX file does.
module Plugins
class RunError < StandardError; end
@@ -21,27 +28,32 @@ module Maptool
# filesystem, same spirit as GpxStore's own path-escape guard.
NAME_PATTERN = /\A[A-Za-z0-9_-]+\z/
def self.list(dir)
Dir.glob(File.join(dir, "*", "plugin.json")).filter_map do |manifest_path|
name = File.basename(File.dirname(manifest_path))
manifest = JSON.parse(File.read(manifest_path))
{ "name" => name, "label" => manifest["label"] || name }
rescue JSON::ParserError
nil
end.sort_by { |p| p["label"] }
def self.list(dirs)
Array(dirs).flat_map do |dir|
Dir.glob(File.join(dir, "*", "plugin.json")).filter_map do |manifest_path|
name = File.basename(File.dirname(manifest_path))
manifest = JSON.parse(File.read(manifest_path))
{ "name" => name, "label" => manifest["label"] || name }
rescue JSON::ParserError
nil
end
end.uniq { |plugin| plugin["name"] }.sort_by { |p| p["label"] }
end
# bbox: [south, west, north, east]. Raises RunError with a short
# message on anything that isn't a clean run — an unknown plugin, a
# non-zero exit, or output that isn't a GeoJSON FeatureCollection — so
# the route can turn it into a single error response without knowing
# any of these details itself.
def self.run(dir, name, bbox)
# any of these details itself. When the same name exists under more
# than one of `dirs`, the first one that has it wins — same precedence
# as list's `uniq` above.
def self.run(dirs, name, bbox)
raise RunError, "unknown plugin" unless name =~ NAME_PATTERN
plugin_dir = File.join(dir, name)
plugin_dir = Array(dirs).map { |dir| File.join(dir, name) }.find { |dir| File.file?(File.join(dir, "plugin.json")) }
raise RunError, "unknown plugin" unless plugin_dir
manifest_path = File.join(plugin_dir, "plugin.json")
raise RunError, "unknown plugin" unless File.file?(manifest_path)
command = JSON.parse(File.read(manifest_path))["command"]
raise RunError, "plugin has no command" unless command