9 Commits
v0.4.0 ... edge

Author SHA1 Message Date
f1bbd46cbd Merge pull request 'feat(rust): RunicNPC required in the Rust bundle, the banner, edge → main (runicnpc 9e step 3, merge 2 of 3)' (#40) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in -1m53s
Release installer / release (push) Successful in 13m8s
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-09 07:21:41 +00:00
bd73930584 Merge pull request 'feat(rust): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328)' (#39) from feat/9e-popup-banner into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m19s
Reviewed-on: #39
2026-10-09 06:29:49 +00:00
0db3a8ac73 feat(rust): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m5s
install and update write PopupNotifications.json into the framework's config
directory, as a banner across the top of the screen, only where the file is
missing or every setting is still PopupNotifications 0.2.1's own default (the
Version aside, numbers compared as numbers). A config with any setting
changed, or one that does not parse, is kept whole. The file carries the
plugin's Version, because PopupNotifications resets a config without one to
its defaults on load. The plan prints a "popup look" row naming the case.

Run against two scratch roots (a temporary local bypass of the RunicNPC check,
since no published bundle carries RunicNPC yet): Oxide with no config got the
banner; Carbon at the rig's defaults got the walk's banner file exactly; on
update a hand-changed config and an existing banner were both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-09 01:24:25 -05:00
6ab40a1e6a Merge pull request 'feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)' (#38) from feat/runicnpc-required into edge
Reviewed-on: #38
2026-10-07 02:42:56 +00:00
04bab67778 feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m13s
- install/update refuse a Rust bundle that carries no RunicNPC, before anything
  is written, and say how to get one that does.
- doctor fails when RunicNPC is missing or was never installed; a file edited
  by hand stays a warning, since it still loads.
- compose-bundles.sh composes no Rust bundle without a RunicNPC release that
  answers the bridge's runicnpc_api (the published one stays), and fails a
  bridge that declares none; every Rust bundle now has `npc`.
- A RunicNPC API-mismatch message lost its line continuation; restored.

The bundle type still parses a bundle without `npc`, so doctor and an old pin
can name it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 16:58:31 -05:00
2567cf5dd3 Merge pull request 'feat(rust): RunicNPC as the Rust bundle's third artefact (runicnpc stage 4, D224)' (#37) from feat/runicnpc-stage4 into main
All checks were successful
sync-project-tree / sync (push) Successful in 20s
Release installer / release (push) Successful in 5m8s
Compose bundle / compose (push) Successful in 22s
Reviewed-on: #37
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-30 19:51:58 +00:00
ab2efb62e4 feat(rust): RunicNPC as the Rust bundle's third artefact (runicnpc stage 4, D224)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m27s
The compose job carries the latest RunicNPC release that answers the API the
bridge's manifest declares (runicnpc_api), verified against its SHA256SUMS
like the other two; none when the bridge needs none, RunicNPC has not
released, or its API is too old, each said in the summary. Walked against a
mock Gitea in all three cases.

The installer reads the optional npc component (older bundles still parse),
fetches and checks RunicNPC's tarball against its manifest, places
RunicNPC.cs before the bridge in each instance's plugins directory, records
it, puts it back on update when edited or deleted, reports it in doctor, and
removes it on uninstall. Its data directory is never touched. Kits joins the
required plugins it reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:52:36 -05:00
c68a7d9d32 Merge pull request 'feat(rust): doctor says what a missing ZoneDomes helper costs' (#36) from feat/rust-domes-helper into main
All checks were successful
sync-project-tree / sync (push) Successful in 8s
Release installer / release (push) Successful in 6m0s
Compose bundle / compose (push) Successful in 22s
Reviewed-on: #36
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-29 16:28:32 +00:00
939dd0465d feat(rust): doctor says what a missing ZoneDomes helper costs
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m4s
RunicGatewayDomes.cs (PLAN_REDESIGNS §3.2, D194) ships beside the bridge
from the next plugin release; the installer already places every helper
the manifest lists. doctor now names its absence in the operator's words:
zones still open, but the site offers no dome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 05:25:44 -05:00
10 changed files with 844 additions and 10 deletions

View File

@@ -64,12 +64,14 @@ SERVUO_LINK_REPO="RunicGateway/link"
SERVUO_OVERLAY_REPO="RunicGateway/servuo-plugins"
RUST_LINK_REPO="RunicGateway/Rust-Link"
RUST_PLUGIN_REPO="RunicGateway/Rust-Plugins"
RUNICNPC_REPO="RunicGateway/runicnpc-rust"
# Fixed top-level directories inside each payload tarball. Deliberately NOT
# versioned — a versioned prefix would mean parsing the version out of a path in
# order to read the manifest that declares the version.
SERVUO_OVERLAY_PREFIX="runicgateway-overlay"
RUST_PLUGIN_PREFIX="runicgateway-rust-plugin"
RUNICNPC_PREFIX="runicnpc"
mkdir -p "$WORK" "$PUBLISHED"
: > "$WORK/summary.md"
@@ -375,6 +377,40 @@ compose_rust() {
[ "$link_protocol" = "$plugin_protocol" ] || fail \
"PROTOCOL MISMATCH — Rust-Link ${link_tag} speaks ${link_protocol}, Rust-Plugins ${plugin_tag} declares ${plugin_protocol}. The game link has no 409: a mismatched plugin would mis-parse. Fix: land the matching half and let its release cut, or bump Rust-Plugins/overlay.toml."
# RunicNPC (docs/runicnpc/PLAN.md D224): a third file, REQUIRED since its stage 9
# (D310) — the bridge refuses every NPC an event places without it, and the
# installer refuses a Rust bundle that does not carry it. The bridge's manifest
# says which RunicNPC API it needs (`runicnpc_api`, from overlay.toml); the
# latest RunicNPC release is carried only when it answers at least that. With
# none that does, the Rust bundle is not composed: the published one stays.
local npc need npc_tag npc_tarball npc_manifest npc_api
need="$(jq -r '.runicnpc_api // 0' "$manifest")"
[ "$need" -gt 0 ] || fail \
"Rust-Plugins ${plugin_tag} declares no runicnpc_api, and RunicNPC is required (D310) — a bridge older than RunicNPC's stage 4 cannot be bundled any more."
rc=0
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
[ "$rc" -ne 2 ] || fail "the bridge needs RunicNPC API ${need}, and RunicNPC has not released — RunicNPC is required (D310)."
npc_tag="$(release_tag runicnpc)"
verify_assets runicnpc
npc_tarball="$(single_tarball runicnpc)"
npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")"
[ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \
|| fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs"
npc_api="$(jq -r '.api' "$npc_manifest")"
echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}"
[ "$npc_api" -ge "$need" ] || fail \
"RunicNPC ${npc_tag} answers API ${npc_api} and Rust-Plugins ${plugin_tag} needs ${need} — RunicNPC is required (D310), so no Rust bundle is composed until it releases one that does."
npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \
--argjson asset "$(asset_json runicnpc "$npc_tarball")" \
'{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $asset }')"
jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string"))
and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \
|| fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins"
jq -n \
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
--argjson assets "$assets" --argjson launcher "$launcher" \
@@ -382,6 +418,7 @@ compose_rust() {
--slurpfile m "$manifest" \
--argjson p_asset "$(asset_json rust-plugin "$tarball")" \
--argjson protocol "$link_protocol" \
--argjson npc "$npc" \
'{ schema: 2, game: "rust", protocol: $protocol,
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
protocol: $protocol, assets: $assets, launcher: $launcher },
@@ -390,7 +427,8 @@ compose_rust() {
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $p_asset } }' > "$WORK/rust-s2.json"
asset: $p_asset },
npc: $npc }' > "$WORK/rust-s2.json"
# A manifest missing a key would compose a `null` the installer then trusts.
jq -e '([.payload.compat.frameworks[].min_version, .payload.commit] | all(type == "string"))
and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
@@ -403,7 +441,7 @@ compose_rust() {
local tag
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json"
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}."
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}, RunicNPC \`${npc_tag}\`."
echo "rust ${tag}" >> "$WORK/published-games.txt"
}

View File

@@ -18,7 +18,8 @@
#
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins,
# and RunicNPC, required since its stage 9 (D224, D310)
#
# Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload`
# that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still
@@ -56,8 +57,8 @@
# the push.
#
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and
# Rust-Plugins' release workflows as their final step,
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's,
# Rust-Plugins' and runicnpc-rust's release workflows as their final step,
# so a new release recomposes the bundle immediately.
# schedule (nightly) — recomputes from whatever the latest releases actually
# are, so a missed or failed dispatch self-heals instead

View File

@@ -361,6 +361,27 @@ pub struct RustBundle {
pub protocol: u32,
pub sidecar: RustSidecar,
pub payload: PluginPayload,
/// RunicNPC, Runic Gateway's NPC plugin (docs/runicnpc/PLAN.md D224): a third file placed in
/// the plugins directory beside the bridge. Required since RunicNPC's stage 9 (D310): a bundle
/// without it — every one before stage 4 — still PARSES, so `doctor` and an old pin can name
/// it, but `install` and `update` refuse it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<NpcComponent>,
}
/// One exact RunicNPC release, chosen by CI to answer at least the API the bridge's
/// `runicnpc_api` asks for.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NpcComponent {
pub repo: String,
pub tag: String,
pub version: String,
pub commit: String,
/// The RunicNPC API it answers (`RunicNpc_ApiVersion`).
pub api: u32,
pub compat: PluginCompat,
/// One tarball: `runicnpc/{RunicNPC.cs, manifest.json}`.
pub asset: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -485,6 +506,14 @@ pub fn parse_rust(body: &str) -> Result<RustBundle> {
bundle.bundle
);
}
if let Some(npc) = &bundle.npc {
if npc.asset.url.is_empty() || npc.asset.sha256.is_empty() {
bail!(
"Rust bundle {} names a RunicNPC asset with no URL or checksum",
bundle.bundle
);
}
}
Ok(bundle)
}
@@ -589,6 +618,37 @@ mod tests {
assert_eq!(v2, v1);
}
#[test]
fn a_rust_bundle_without_runicnpc_still_parses() {
let bundle = parse_rust(RUST).unwrap();
assert!(bundle.npc.is_none());
}
#[test]
fn a_rust_bundle_with_runicnpc_parses_and_is_checked() {
let mut value: serde_json::Value = serde_json::from_str(RUST).unwrap();
value["npc"] = serde_json::json!({
"repo": "RunicGateway/runicnpc-rust",
"tag": "v0.2.0",
"version": "0.2.0",
"commit": "abc",
"api": 3,
"compat": {
"frameworks": { "oxide": { "min_version": "2.0.7726" }, "carbon": { "min_version": "2.0.259" } },
"requires_plugins": ["Kits"]
},
"asset": { "name": "runicnpc-0.2.0.tar.gz", "url": "https://x/runicnpc-0.2.0.tar.gz", "sha256": "ab" }
});
let bundle = parse_rust(&value.to_string()).unwrap();
let npc = bundle.npc.as_ref().unwrap();
assert_eq!(
(npc.api, npc.compat.requires_plugins.clone()),
(3, vec!["Kits".to_string()])
);
value["npc"]["asset"]["sha256"] = serde_json::json!("");
assert!(parse_rust(&value.to_string()).is_err());
}
#[test]
fn a_rust_bundle_is_refused_by_the_servuo_reader() {
let err = parse(RUST).unwrap_err().to_string();

View File

@@ -178,6 +178,34 @@ fn instance_rows(
}
}
// ── RunicNPC: ours, and required (D224, D310) ─────────────────────────────
// A failure when it is missing or was never placed: since RunicNPC's stage 9 the bridge
// refuses every NPC an event places without it. A file edited by hand still loads, so that
// stays a warning, as the bridge's own helpers are.
if let Some(file) = &instance.npc {
let tag = record.npc.as_ref().map(|n| n.tag.as_str()).unwrap_or("?");
match std::fs::read(&file.path) {
Ok(bytes) if crate::util::sha256_bytes(&bytes) == file.sha256 => {
rows.push(Row::ok(&label("RunicNPC"), format!("{} (runicnpc-rust {tag})", file.path)))
}
Ok(_) => rows.push(
Row::warn(&label("RunicNPC"), format!("{} is not the file that was deployed", file.path))
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
),
Err(_) => rows.push(
Row::fail(&label("RunicNPC"), format!("{} is missing", file.path))
.note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included")
.note("`update --game rust` puts it back"),
),
}
} else {
rows.push(
Row::fail(&label("RunicNPC"), "not installed")
.note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included")
.note("`update --game rust` installs it from the current bundle"),
);
}
// ── The plugin's config: the website's ───────────────────────────────────
match plugin::read_config(Path::new(&instance.plugin_config)) {
Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok(
@@ -314,6 +342,11 @@ fn helper_absence(name: &str) -> String {
own flags miss anybody already standing in a zone when it is created or restored"
.to_string()
}
"RunicGatewayDomes.cs" => {
"the bridge still runs and its zones still open, but the site offers no dome over \
an event zone, because ZoneDomes cannot be called without it"
.to_string()
}
_ => format!("the bridge still runs without {name}; what it helped with falls back"),
}
}

View File

@@ -18,8 +18,10 @@ use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use super::npc;
use super::plugin::{self, ConfigView};
use super::record::{ComponentRecord, DeployedFile, Instance, RustRecord, SCHEMA};
use super::popup;
use super::record::{ComponentRecord, DeployedFile, Instance, NpcRecord, RustRecord, SCHEMA};
use super::server::{self, RustServer};
use super::sidecar;
use crate::bundle::{self, RustBundle};
@@ -46,6 +48,10 @@ struct Planned {
helper_actions: Vec<BinaryAction>,
/// Helpers this instance's record holds that the release no longer ships: removed.
retired_helpers: Vec<String>,
/// RunicNPC, when the bundle carries it (D224): absent, identical or replaced.
npc_action: Option<BinaryAction>,
/// PopupNotifications' config: the banner where it is missing or the plugin's defaults (D327).
popup: popup::State,
game_port: u16,
web_port: u16,
config_path: PathBuf,
@@ -141,6 +147,16 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
bundle.payload.protocol
),
);
if let Some(npc) = &bundle.npc {
ui::row(
"RunicNPC",
&format!(
"{:<24} API {}",
format!("runicnpc-rust {}", npc.tag),
npc.api
),
);
}
// The plugin is fetched before anything is planned: its manifest is the last statement of the
// protocol to check, and a pair that disagrees must stop the run before any file moves.
@@ -170,6 +186,39 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
);
}
// RunicNPC (D224), fetched and checked beside the plugin, before anything is planned. It is
// required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places
// without it, so a bundle that cannot place it is refused before anything is written.
let npc_released = match &bundle.npc {
Some(component) => {
let path = scratch.path().join(&component.asset.name);
crate::net::download_verified(&component.asset.url, &path, &component.asset.sha256)?;
let released = npc::read_tarball(&path)?;
if released.manifest.api != component.api {
bail!(
"RunicNPC in bundle {} answers API {}, but the bundle says {} — refusing a \
component the bundle does not describe.",
bundle.bundle,
released.manifest.api,
component.api
);
}
if released.manifest.version != component.version {
ui::warn(&format!(
"the RunicNPC tarball says version {} but bundle {} names {}. The checksum matched, \n so this is a labelling mismatch in the release rather than a wrong download.",
released.manifest.version, bundle.bundle, component.version
));
}
Some(released)
}
None => bail!(
"bundle {} carries no RunicNPC, and Runic Gateway's Rust events need it: without it \
the bridge refuses every NPC an event places. Install the current bundle (leave \
--bundle off), or pin one published with RunicNPC v1.0.0 or later.",
bundle.bundle
),
};
// ── Plan every instance ──────────────────────────────────────────────────
let mut planned = Vec::new();
for (id, root) in &targets {
@@ -180,6 +229,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
id,
root,
&released,
npc_released.as_ref(),
)?);
}
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
@@ -229,6 +279,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
&prepared,
&bundle,
&released,
npc_released.as_ref(),
plan,
binary_action.writes(),
prior
@@ -288,6 +339,16 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
&bundle.payload.commit,
bundle.payload.protocol,
);
// A bundle without RunicNPC leaves a recorded one where it is: it may be an operator's.
if let Some(component) = &bundle.npc {
record.npc = Some(NpcRecord {
repo: component.repo.clone(),
tag: component.tag.clone(),
version: component.version.clone(),
commit: component.commit.clone(),
api: component.api,
});
}
record.service_user_created |= prepared.user_created;
match prior.as_ref() {
@@ -334,8 +395,10 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
.iter()
.filter(|p| {
p.plugin_action.writes()
|| p.npc_action.is_some_and(|a| a.writes())
|| p.helper_actions.iter().any(|a| a.writes())
|| !p.retired_helpers.is_empty()
|| p.popup.writes()
})
.count();
if restored == 0 && !binary_action.writes() {
@@ -377,6 +440,7 @@ fn plan_instance(
id: &str,
root: &Path,
released: &plugin::Released,
npc_released: Option<&npc::Released>,
) -> Result<Planned> {
let server = server::open(root)
.with_context(|| format!("cannot use {} as a Rust server root", root.display()))?;
@@ -466,10 +530,23 @@ fn plan_instance(
.collect()
})
.unwrap_or_default();
// RunicNPC as the plugin is: put back when it was deleted or edited (D224).
let npc_action =
npc_released.map(
|r| match std::fs::read(server.plugins_dir().join(npc::FILE)) {
Err(_) => BinaryAction::Install,
Ok(bytes) if crate::util::sha256_bytes(&bytes) == r.sha256 => {
BinaryAction::Unchanged
}
Ok(_) => BinaryAction::Replace,
},
);
let popup = popup::inspect(&server.framework.config_dir(&server.path).join(popup::FILE));
let config_path = layout.rust_config(id);
Ok(Planned {
id: id.to_string(),
running: server::is_running(&server.path),
popup,
config_exists: config_path.exists(),
config_path,
db_path: layout.rust_db(id),
@@ -478,6 +555,7 @@ fn plan_instance(
plugin_action,
helper_actions,
retired_helpers,
npc_action,
game_port,
web_port,
})
@@ -536,6 +614,16 @@ fn print_plan(
),
);
}
if let Some(action) = p.npc_action {
ui::row(
"RunicNPC",
&format!(
"{} {}",
p.server.plugins_dir().join(npc::FILE).display(),
action.label()
),
);
}
ui::row(
"plugin config",
&match &p.plugin_config {
@@ -559,6 +647,18 @@ fn print_plan(
if p.config_exists { "kept" } else { "written" }
),
);
ui::row(
"popup look",
&format!(
"{} {}",
p.server
.framework
.config_dir(&p.server.path)
.join(popup::FILE)
.display(),
p.popup.label()
),
);
ui::row(
"ports",
&format!(
@@ -566,10 +666,16 @@ fn print_plan(
p.game_port, p.web_port
),
);
let missing = plugin::missing_plugins(
&p.server.plugins_dir(),
&bundle.payload.compat.requires_plugins,
);
// RunicNPC's own requirements join the bridge's: it will not load without Kits (D217).
let mut required = bundle.payload.compat.requires_plugins.clone();
if let Some(component) = &bundle.npc {
for name in &component.compat.requires_plugins {
if !required.contains(name) {
required.push(name.clone());
}
}
}
let missing = plugin::missing_plugins(&p.server.plugins_dir(), &required);
if !missing.is_empty() {
ui::warn(&format!(
"{} not in {} — the features that use {} stay off until you install {} from uMod. \
@@ -584,11 +690,13 @@ fn print_plan(
}
/// Writes one instance: plugin config, sidecar config, service, plugin — in that order.
#[allow(clippy::too_many_arguments)]
fn deploy_instance(
layout: &paths::Layout,
prepared: &service::Prepared,
bundle: &RustBundle,
released: &plugin::Released,
npc_released: Option<&npc::Released>,
plan: &Planned,
binary_changed: bool,
plugin_config_written_before: bool,
@@ -613,6 +721,26 @@ fn deploy_instance(
ui::ok(&format!("wrote {}", plugin_config_path.display()));
}
// PopupNotifications' look (D327): the banner, only over nothing or over the plugin's own
// defaults. Before the plugins, so a PopupNotifications that loads after them reads it; one
// already loaded keeps its old look until it is reloaded.
if let Some(banner) = popup::banner_for(plan.popup) {
let dir = plan.server.framework.config_dir(&plan.server.path);
std::fs::create_dir_all(&dir)
.with_context(|| format!("cannot create {}", dir.display()))?;
let path = dir.join(popup::FILE);
write_atomic(&path, banner.as_bytes())?;
ui::ok(&format!(
"wrote the PopupNotifications banner {}{}",
path.display(),
if plan.running {
" (takes effect when PopupNotifications next loads)"
} else {
""
}
));
}
// The sidecar's config, once; the token is the sidecar's to generate.
if !plan.config_exists {
if let Some(parent) = plan.config_path.parent() {
@@ -758,6 +886,35 @@ fn deploy_instance(
}
}
// RunicNPC before the bridge (D224): the bridge reports it at hello, and one already loaded is
// one its first hello names. Written in place, for the plugin's reason below.
let npc_file = match (npc_released, plan.npc_action) {
(Some(r), Some(action)) => {
let path = plan.server.plugins_dir().join(npc::FILE);
if action.writes() {
std::fs::create_dir_all(plan.server.plugins_dir()).with_context(|| {
format!("cannot create {}", plan.server.plugins_dir().display())
})?;
std::fs::write(&path, &r.source)
.with_context(|| format!("cannot write {}", path.display()))?;
ui::ok(&format!(
"RunicNPC {} {}",
if action == BinaryAction::Replace {
"replaced"
} else {
"installed"
},
path.display()
));
}
Some(DeployedFile {
path: path.display().to_string(),
sha256: r.sha256.clone(),
})
}
_ => None,
};
// The plugin last: it loads the moment it lands, and its sidecar is now there to dial.
let plugin_path = plan.server.plugin_path();
if plan.plugin_action.writes() {
@@ -791,6 +948,7 @@ fn deploy_instance(
plugin_path: plugin_path.display().to_string(),
plugin_sha256: released.sha256.clone(),
helpers,
npc: npc_file,
plugin_config: plugin_config_path.display().to_string(),
plugin_config_written: wrote_plugin_config || plugin_config_written_before,
game_port: plan.game_port,
@@ -832,6 +990,7 @@ fn empty_record(bundle: &RustBundle, url: &str) -> RustRecord {
sha256: String::new(),
},
plugin: component("", "", "", "", 0),
npc: None,
service_user_created: false,
instances: Default::default(),
extra: Default::default(),

View File

@@ -13,7 +13,9 @@
mod doctor;
mod install;
mod npc;
mod plugin;
mod popup;
mod record;
mod server;
mod sidecar;

172
src/rustgame/npc.rs Normal file
View File

@@ -0,0 +1,172 @@
//! RunicNPC: Runic Gateway's NPC plugin, a third file beside the bridge (docs/runicnpc/PLAN.md
//! D224, stage 4).
//!
//! **`RunicNPC.cs` is the installer's**, like the bridge and its helpers: it comes from the bundle,
//! is replaced when the bundle moves, is put back by `update` when it was edited or deleted, and is
//! removed by `uninstall`. Its DATA is not: `data/RunicNPC/` holds an admin's placements and
//! routes, which RunicNPC writes and the site edits through the bridge, and nothing here touches
//! it. RunicNPC makes that directory itself on first load, because one made from outside the game
//! is not writable by it (runicnpc PLAN.md §1.5).
//!
//! It is required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places
//! without it, Rust's own scientists included, so `install` and `update` refuse a bundle that does
//! not carry it and `doctor` fails a host that lacks it.
use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use crate::util::sha256_bytes;
/// The fixed top directory inside RunicNPC's release tarball (runicnpc-rust's release.yml).
const PREFIX: &str = "runicnpc";
/// The one file placed, in the same plugins directory as the bridge.
pub const FILE: &str = "RunicNPC.cs";
/// `runicnpc/manifest.json`, which RunicNPC's release folds from `plugin.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub version: String,
pub api: u32,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
/// RunicNPC's released file, read out of its tarball.
#[derive(Debug, Clone)]
pub struct Released {
pub manifest: Manifest,
pub source: Vec<u8>,
pub sha256: String,
}
/// Reads RunicNPC and its manifest out of a downloaded tarball, and checks the two agree.
pub fn read_tarball(path: &Path) -> Result<Released> {
let file =
std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
let mut manifest: Option<Vec<u8>> = None;
let mut source: Option<Vec<u8>> = None;
for entry in archive
.entries()
.context("the RunicNPC tarball is not a tar.gz")?
{
let mut entry = entry.context("the RunicNPC tarball is truncated")?;
let name = entry.path()?.to_string_lossy().replace('\\', "/");
if name == format!("{PREFIX}/manifest.json") {
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
manifest = Some(bytes);
} else if name == format!("{PREFIX}/{FILE}") {
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
source = Some(bytes);
}
}
let manifest = manifest
.ok_or_else(|| anyhow::anyhow!("the RunicNPC tarball has no {PREFIX}/manifest.json"))?;
let source =
source.ok_or_else(|| anyhow::anyhow!("the RunicNPC tarball has no {PREFIX}/{FILE}"))?;
let manifest: Manifest =
serde_json::from_slice(&manifest).context("RunicNPC's manifest.json is unreadable")?;
let sha256 = sha256_bytes(&source);
match manifest.files.get(FILE) {
Some(declared) if declared.eq_ignore_ascii_case(&sha256) => {}
Some(declared) => bail!(
"RunicNPC in the tarball does not match its own manifest (sha256 {sha256}, manifest \
says {declared}). The tarball matched the bundle's checksum, so the release itself is \
inconsistent — refusing it."
),
None => bail!("RunicNPC's manifest does not list {FILE} — refusing a release that does not say what it ships"),
}
Ok(Released {
manifest,
source,
sha256,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn tarball(dir: &Path, entries: &[(&str, &[u8])]) -> std::path::PathBuf {
let path = dir.join("runicnpc.tar.gz");
let file = std::fs::File::create(&path).unwrap();
let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
file,
flate2::Compression::default(),
));
for (name, bytes) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, format!("{PREFIX}/{name}"), *bytes)
.unwrap();
}
builder.into_inner().unwrap().finish().unwrap();
path
}
fn manifest(sha: &str) -> Vec<u8> {
serde_json::json!({
"component": "runicnpc", "version": "0.2.0", "commit": "abc", "api": 3,
"requires_plugins": ["Kits"], "files": { FILE: sha }
})
.to_string()
.into_bytes()
}
#[test]
fn a_release_is_read_and_its_file_checked_against_its_manifest() {
let dir = TempDir::new("rg-npc").unwrap();
let source = b"// Requires: Kits\nclass RunicNPC {}";
let good = tarball(
dir.path(),
&[
("manifest.json", &manifest(&sha256_bytes(source))),
(FILE, source),
],
);
let released = read_tarball(&good).unwrap();
assert_eq!(
(released.manifest.api, released.manifest.version.as_str()),
(3, "0.2.0")
);
assert_eq!(released.source, source);
let bad = tarball(
dir.path(),
&[
("manifest.json", &manifest(&"00".repeat(32))),
(FILE, source),
],
);
assert!(read_tarball(&bad)
.unwrap_err()
.to_string()
.contains("does not match its own manifest"));
}
#[test]
fn a_release_without_the_file_or_the_manifest_is_refused() {
let dir = TempDir::new("rg-npc-missing").unwrap();
let only_manifest = tarball(dir.path(), &[("manifest.json", &manifest("ab"))]);
assert!(read_tarball(&only_manifest)
.unwrap_err()
.to_string()
.contains("has no runicnpc/RunicNPC.cs"));
let only_file = tarball(dir.path(), &[(FILE, b"x")]);
assert!(read_tarball(&only_file)
.unwrap_err()
.to_string()
.contains("manifest.json"));
}
}

312
src/rustgame/popup.rs Normal file
View File

@@ -0,0 +1,312 @@
//! The recommended PopupNotifications look: a banner across the top of the screen (docs/runicnpc/
//! PLAN.md stage 9e, D327).
//!
//! PopupNotifications is optional and the operator's to install (D141); its config is the
//! operator's too. The installer writes the banner into it only where it is **missing** or **still
//! exactly the plugin's own defaults** — every setting equal to 0.2.1's, the `Version` aside (D328).
//! A config with any one setting changed is somebody's choice and is kept whole, never merged into.
//! A config that does not parse is kept too: it is not ours to repair.
//!
//! The file carries a `Version`: PopupNotifications resets a config whose version is missing or
//! below 0.2.0 to its defaults on load, which would undo the banner at the first boot.
use std::path::Path;
use serde_json::Value;
/// The config's file name, in the framework's config directory beside the bridge's.
pub const FILE: &str = "PopupNotifications.json";
/// What is there now, and so what happens to it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
/// No config: the banner is written, and PopupNotifications reads it when it is installed.
Missing,
/// The plugin's own defaults, untouched: the banner replaces them. Holds the file's version.
Defaults(Version),
/// Changed by someone, or unreadable: kept.
Theirs,
}
/// PopupNotifications' `VersionNumber`, as its config writes it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Version {
pub major: u64,
pub minor: u64,
pub patch: u64,
}
/// The version written into a config made from nothing: the release the defaults are read from.
const WRITTEN_VERSION: Version = Version {
major: 0,
minor: 2,
patch: 1,
};
impl State {
pub fn writes(self) -> bool {
!matches!(self, Self::Theirs)
}
pub fn label(self) -> &'static str {
match self {
Self::Missing => "banner written (no config yet)",
Self::Defaults(_) => "banner written (was the plugin's defaults)",
Self::Theirs => "kept (not the plugin's defaults)",
}
}
}
/// Reads the config at `path` and says what it is.
pub fn inspect(path: &Path) -> State {
match std::fs::read(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => State::Missing,
Err(_) => State::Theirs,
Ok(bytes) => classify(&bytes),
}
}
fn classify(bytes: &[u8]) -> State {
let Ok(Value::Object(mut found)) = serde_json::from_slice::<Value>(bytes) else {
return State::Theirs;
};
let version = found.remove("Version");
let Ok(Value::Object(mut defaults)) = serde_json::from_str::<Value>(&render(DEFAULTS, None))
else {
unreachable!("the defaults are valid JSON")
};
defaults.remove("Version");
if !same(&Value::Object(found), &Value::Object(defaults)) {
return State::Theirs;
}
State::Defaults(
version
.as_ref()
.and_then(read_version)
.unwrap_or(WRITTEN_VERSION),
)
}
fn read_version(v: &Value) -> Option<Version> {
Some(Version {
major: v.get("Major")?.as_u64()?,
minor: v.get("Minor")?.as_u64()?,
patch: v.get("Patch")?.as_u64()?,
})
}
/// JSON equality with numbers compared as numbers: the plugin writes its floats through
/// Newtonsoft, and a value written `1.0` by one framework and `1` by another is the same setting.
fn same(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
(Some(x), Some(y)) => (x - y).abs() < 1e-6,
_ => false,
},
(Value::Object(x), Value::Object(y)) => {
x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same(v, w)))
}
(Value::Array(x), Value::Array(y)) => {
x.len() == y.len() && x.iter().zip(y).all(|(v, w)| same(v, w))
}
_ => a == b,
}
}
/// The banner, as the file to write for `state`. `None` for a config that is kept.
pub fn banner_for(state: State) -> Option<String> {
match state {
State::Missing => Some(render(BANNER, Some(WRITTEN_VERSION))),
State::Defaults(version) => Some(render(BANNER, Some(version))),
State::Theirs => None,
}
}
/// One look: the eleven settings PopupNotifications has.
struct Look {
left: &'static str,
bottom: &'static str,
width: &'static str,
height: &'static str,
spacing: &'static str,
close: bool,
alpha: &'static str,
font: &'static str,
font_size: u32,
}
/// PopupNotifications 0.2.1's `GetBaseConfig()`: a small grey box at the right of the screen.
const DEFAULTS: Look = Look {
left: "0.8",
bottom: "0.78",
width: "0.19",
height: "0.1",
spacing: "0.01",
close: true,
alpha: "0.5",
font: "droidsansmono.ttf",
font_size: 12,
};
/// The recommended look (9d player session, 2026-10-09): a banner across the top of the screen.
const BANNER: Look = Look {
left: "0.15",
bottom: "0.87",
width: "0.7",
height: "0.07",
spacing: "0.005",
close: false,
alpha: "0.8",
font: "robotocondensed-bold.ttf",
font_size: 18,
};
/// The config file, laid out as the plugin writes it. Duration, the most shown, both colours and
/// the close button's transparency are the plugin's own in both looks.
fn render(look: Look, version: Option<Version>) -> String {
let version = version.unwrap_or(WRITTEN_VERSION);
format!(
r##"{{
"Notification duration (in seconds)": 8,
"Maximum notifications shown at any time": 6,
"UI Positioning": {{
"Position of the left side of notification (0.0 - 1.0)": {left},
"Position of the bottom of noticiation (0.0 - 1.0)": {bottom},
"Width (0.0 - 1.0)": {width},
"Height (0.0 - 1.0)": {height},
"Space between notification (0.0 - 1.0)": {spacing}
}},
"UI Options": {{
"Show close button": {close},
"Panel color (hex)": "#2b2b2b",
"Panel transparency (0.0 - 1.0)": {alpha},
"Close button color (hex)": "#d85540",
"Close button transparency (0.0 - 1.0)": 0.5,
"Font": "{font}",
"Font size": {font_size}
}},
"Version": {{
"Major": {major},
"Minor": {minor},
"Patch": {patch}
}}
}}"##,
left = look.left,
bottom = look.bottom,
width = look.width,
height = look.height,
spacing = look.spacing,
close = look.close,
alpha = look.alpha,
font = look.font,
font_size = look.font_size,
major = version.major,
minor = version.minor,
patch = version.patch,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
/// The file PopupNotifications 0.2.1 wrote on both rigs at its first load (2026-10-09).
const RIG_DEFAULTS: &str = r##"{
"Notification duration (in seconds)": 8,
"Maximum notifications shown at any time": 6,
"UI Positioning": {
"Position of the left side of notification (0.0 - 1.0)": 0.8,
"Position of the bottom of noticiation (0.0 - 1.0)": 0.78,
"Width (0.0 - 1.0)": 0.19,
"Height (0.0 - 1.0)": 0.1,
"Space between notification (0.0 - 1.0)": 0.01
},
"UI Options": {
"Show close button": true,
"Panel color (hex)": "#2b2b2b",
"Panel transparency (0.0 - 1.0)": 0.5,
"Close button color (hex)": "#d85540",
"Close button transparency (0.0 - 1.0)": 0.5,
"Font": "droidsansmono.ttf",
"Font size": 12
},
"Version": {
"Major": 0,
"Minor": 2,
"Patch": 1
}
}"##;
#[test]
fn a_missing_config_gets_the_banner_with_a_version() {
let dir = TempDir::new("rg-popup-missing").unwrap();
let state = inspect(&dir.path().join(FILE));
assert_eq!(state, State::Missing);
let written: Value = serde_json::from_str(&banner_for(state).unwrap()).unwrap();
assert_eq!(written["UI Positioning"]["Width (0.0 - 1.0)"], 0.7);
assert_eq!(written["UI Options"]["Show close button"], false);
assert_eq!(written["UI Options"]["Font"], "robotocondensed-bold.ttf");
assert_eq!(written["UI Options"]["Font size"], 18);
// Without a version the plugin resets the file to its defaults at load.
assert_eq!(written["Version"]["Minor"], 2);
}
#[test]
fn the_plugins_own_defaults_are_replaced_and_their_version_kept() {
assert_eq!(
classify(RIG_DEFAULTS.as_bytes()),
State::Defaults(WRITTEN_VERSION)
);
let later = RIG_DEFAULTS.replace("\"Patch\": 1", "\"Patch\": 4");
let state = classify(later.as_bytes());
assert_eq!(
state,
State::Defaults(Version {
major: 0,
minor: 2,
patch: 4
})
);
assert!(banner_for(state).unwrap().contains("\"Patch\": 4"));
// A whole number written as a float is still the default.
let floats = RIG_DEFAULTS.replace("\"Font size\": 12", "\"Font size\": 12.0");
assert!(matches!(classify(floats.as_bytes()), State::Defaults(_)));
}
#[test]
fn the_rendered_defaults_are_the_file_the_plugin_writes() {
let ours: Value = serde_json::from_str(&render(DEFAULTS, None)).unwrap();
let rig: Value = serde_json::from_str(RIG_DEFAULTS).unwrap();
assert_eq!(ours, rig);
}
#[test]
fn any_change_keeps_the_whole_config() {
for changed in [
RIG_DEFAULTS.replace("\"Font size\": 12", "\"Font size\": 14"),
RIG_DEFAULTS.replace(
"\"Notification duration (in seconds)\": 8",
"\"Notification duration (in seconds)\": 9",
),
RIG_DEFAULTS.replace("#2b2b2b", "#000000"),
render(BANNER, None),
"{ not json".to_string(),
"[]".to_string(),
] {
let state = classify(changed.as_bytes());
assert_eq!(state, State::Theirs, "{changed}");
assert_eq!(banner_for(state), None);
}
}
#[test]
fn a_config_with_a_setting_added_or_missing_is_theirs() {
let mut v: Value = serde_json::from_str(RIG_DEFAULTS).unwrap();
v["Extra"] = Value::Bool(true);
assert_eq!(classify(v.to_string().as_bytes()), State::Theirs);
let mut v: Value = serde_json::from_str(RIG_DEFAULTS).unwrap();
v.as_object_mut().unwrap().remove("UI Options");
assert_eq!(classify(v.to_string().as_bytes()), State::Theirs);
}
}

View File

@@ -30,6 +30,9 @@ pub struct RustRecord {
pub sidecar: ComponentRecord,
pub binary: BinaryRef,
pub plugin: ComponentRecord,
/// RunicNPC's release, when the bundle carried one (docs/runicnpc/PLAN.md D224).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<NpcRecord>,
/// This installer created the shared `runicgateway` service user. Kept here rather than on each
/// instance: the account outlives any one instance and may also run ServUO's sidecar, so only
/// the last Rust instance's removal — on a host with no ServUO record — may delete it.
@@ -51,6 +54,17 @@ pub struct ComponentRecord {
pub protocol: u32,
}
/// Which RunicNPC release is deployed, and the API it answers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NpcRecord {
pub repo: String,
pub tag: String,
pub version: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub commit: String,
pub api: u32,
}
/// One Rust server and its sidecar.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Instance {
@@ -64,6 +78,9 @@ pub struct Instance {
/// plugin. Absent from a record written before helpers existed, which reads back as none.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub helpers: BTreeMap<String, DeployedFile>,
/// `RunicNPC.cs`, when the bundle carried RunicNPC. Its data directory is not the installer's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<DeployedFile>,
/// The plugin's config — the website's file, recorded so `doctor` knows where to look.
pub plugin_config: String,
/// The installer wrote that config (it did not exist). Informational; it is kept either way.
@@ -154,6 +171,7 @@ mod tests {
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
plugin_sha256: "ab".repeat(32),
helpers: BTreeMap::new(),
npc: None,
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
plugin_config_written: true,
game_port: game,
@@ -191,6 +209,7 @@ mod tests {
commit: "abc".into(),
protocol: 12,
},
npc: None,
service_user_created: true,
instances: BTreeMap::from([
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
@@ -211,6 +230,34 @@ mod tests {
assert!(!text.contains("auth_token") && !text.contains("token\""));
}
#[test]
fn a_record_with_runicnpc_round_trips_and_one_without_loads_as_none() {
let dir = TempDir::new("rg-rust-record-npc").unwrap();
let path = dir.path().join("install.json");
let mut record = sample();
record.npc = Some(NpcRecord {
repo: "RunicGateway/runicnpc-rust".into(),
tag: "v0.2.0".into(),
version: "0.2.0".into(),
commit: "abc".into(),
api: 3,
});
record.instances.get_mut("alpha").unwrap().npc = Some(DeployedFile {
path: "/srv/a/oxide/plugins/RunicNPC.cs".into(),
sha256: "ef".repeat(32),
});
record.save(&path).unwrap();
assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record);
// A record from before stage 4 has neither key, and reads back as none.
let old = sample();
old.save(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("\"npc\""));
let back = RustRecord::load(&path).unwrap().unwrap();
assert!(back.npc.is_none() && back.instances.values().all(|i| i.npc.is_none()));
}
#[test]
fn a_record_written_before_helpers_loads_with_none() {
// Every host installed before D182 has one of these: no `helpers` key at all.

View File

@@ -62,6 +62,12 @@ pub fn run(cli: &Cli) -> Result<i32> {
for helper in i.helpers.values() {
println!(" · remove {}", helper.path);
}
if let Some(npc) = &i.npc {
println!(
" · remove {} (RunicNPC; its placements in data/RunicNPC/ are kept)",
npc.path
);
}
println!(
" · keep {} (the website's; it names this server)",
i.plugin_config
@@ -114,6 +120,10 @@ pub fn run(cli: &Cli) -> Result<i32> {
for helper in instance.helpers.values() {
remove_file(Path::new(&helper.path), &mut done, &mut problems);
}
// RunicNPC is the installer's file (D224); its data directory is the admins', and stays.
if let Some(npc) = &instance.npc {
remove_file(Path::new(&npc.path), &mut done, &mut problems);
}
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
if cli.purge {
remove_file(Path::new(&instance.config_path), &mut done, &mut problems);