mirror of
https://github.com/Klipper4FlashForge/firmware.git
synced 2026-10-22 13:22:16 -05:00
Both bugs were found by auditinga40bc86, which the first pass never saw. 1. The plain Creator 5 could not print at all. ff-filament.cfg subscripted printer["heater_generic chamber_heater"] unguarded, and that object exists only on the Pro. Klipper renders a macro's whole template before executing any of it, so _FF_FILAMENT_PREP/_FF_FILAMENT_FINISH raised on a non-Pro -- taking LOAD_FILAMENT, UNLOAD_FILAMENT, PURGE, _FF_NOZZLE_CLEAN and therefore START_PRINT and every Moonraker print with them. Guarded with the `in printer` idiom ff-chamber.cfg already used. test_chamber.py gains a static gate over every ff-*.cfg macro. Proven to catch it: reverted the guards and the test fails naming both macros. 2. The shipped moonraker.conf could never install. /usr/data/config/ moonraker.conf is ON the factory image -- verified by probing the replica -- so the compare-and-.mod-new rule saw an existing file with no snapshot to compare it against and parked ours as .mod-new, on the first install and on every one after. The [webcam] entry therefore never reached the printer and Mainsail still showed no camera, which is the entire reasona40bc86exists. The API lockdown never applied either. /usr/prog/klipper/runConfig/moonraker.conf is FlashForge's pristine template and is byte-identical to the live copy on an untouched machine (both 371 bytes, md5 a49bd5ea...), so that is now the test for "nobody has edited this": matching it means the file is ours to write. case-install.sh asserts the LIVE file has a [webcam] block, not that a .mod-new appeared. It reproduced the bug before the fix. 3. moonraker.conf is mod-owned and overwritten every update, so it needed the seam printer.cfg gives the Klipper configs. New moonraker-custom.conf: created once, never written again, and included at the END of moonraker.conf -- Moonraker applies options in read order, so the user's win. Moonraker treats an include matching no file as fatal, so the file is guaranteed to exist and both it and its header say not to delete it. case-install.sh writes a trusted_clients block into it before boot 2 and asserts it is byte-identical afterwards, so "survives an update" is tested rather than asserted. Verified on the printer replica through all three boots: the config lands, the seam is created and included, a user edit survives the re-install, and Moonraker still starts -- which is what proves the include parses. 53 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
346 lines
17 KiB
Bash
Executable File
346 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# 2/3 -- apply the mods to work/software/.
|
|
# Idempotent: safe to re-run after editing config.env or assets.
|
|
set -euo pipefail
|
|
. "$(dirname "$0")/common.sh"
|
|
|
|
SW=work/software
|
|
[ -d "$SW" ] || { echo "run bin/unpack.sh first" >&2; exit 1; }
|
|
|
|
say() { printf '>> %s\n' "$*"; }
|
|
skip() { printf ' (skip) %s\n' "$*"; }
|
|
|
|
# Everything we add to the printer lives under this one directory on the DATA
|
|
# partition, so a FlashForge OTA cannot delete it.
|
|
MODDIR=/usr/data/anvil
|
|
# The mod payload is built OUTSIDE the software component on purpose. The
|
|
# software component is extracted to /usr/prog/PROGRAM/software/<ver>/ -- the
|
|
# firmware partition, of which the installer keeps only one version. Mainsail and
|
|
# HelixScreen are ~100MB and would overflow it. They ride in the outer package
|
|
# instead, land in /usr/data/update/ (data partition), and are moved to
|
|
# /usr/data/anvil from there.
|
|
MP=work/modpayload
|
|
rm -rf "$MP" "$SW/mod" # $SW/mod: leftover from an older layout
|
|
mkdir -p "$MP/bin" "$MP/nginx" "$MP/www" "$MP/config"
|
|
|
|
# ---------------------------------------------------------------- 1. Klipper
|
|
if [ "${BUILD_KLIPPER:-fork}" = "fork" ] && [ -d "${KLIPPER_FORK:-}/klippy" ]; then
|
|
say "Klipper: installing fork tree from $KLIPPER_FORK"
|
|
# Stock ships only a handful of klippy files as an overlay; the fork is a
|
|
# different Klipper generation (v0.13 vs v0.12), so ship the WHOLE tree.
|
|
rm -rf "$SW/klipper/klippy"
|
|
mkdir -p "$SW/klipper/klippy"
|
|
( cd "$KLIPPER_FORK/klippy" && tar -cf - \
|
|
--exclude='__pycache__' --exclude='*.pyc' --exclude='chelper/*.o' . ) \
|
|
| tar -xf - -C "$SW/klipper/klippy"
|
|
|
|
# c_helper.so must be MIPS32r2 / nan2008 / o32 or klippy dies on import.
|
|
CH="$KLIPPER_FORK/klippy/chelper/c_helper.so"
|
|
if [ -f "$CH" ]; then
|
|
# Only the ABI is checked here. A symbol-level check against the
|
|
# klippy tree beside it used to run too, because cffi resolves symbols
|
|
# lazily and a .so older than its sources installs, boots and then
|
|
# dies at connect. That check is gone; if klippy ever fails at connect
|
|
# with a cffi traceback, a stale c_helper.so is the first thing to
|
|
# suspect and rebuilding it is the fix.
|
|
if readelf -h "$CH" 2>/dev/null | grep -q nan2008; then
|
|
say "Klipper: c_helper.so is nan2008 MIPS32r2 -- good"
|
|
mkdir -p work/.chelper/chelper
|
|
cp -f "$CH" work/.chelper/chelper/c_helper.so
|
|
tar -cf "$SW/klipper/chelper.tar" -C work/.chelper chelper
|
|
rm -rf work/.chelper
|
|
else
|
|
echo " !! c_helper.so is NOT nan2008 -- the kernel will refuse it" >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
echo " !! no c_helper.so in the fork; see docs/building.md 'Rebuilding chelper'" >&2
|
|
exit 1
|
|
fi
|
|
# klippy/ now contains the fork's own extras+kinematics, so the stock
|
|
# overlay dirs would only re-inject 0.12-era files on top. Drop them.
|
|
rm -rf "$SW/klipper/extras" "$SW/klipper/kinematics"
|
|
mkdir -p "$SW/klipper/extras"
|
|
else
|
|
skip "Klipper: keeping stock tree"
|
|
fi
|
|
|
|
# ----------------------------------------------------------- 2. Toolchanger
|
|
# Lives in this repo under payload/klipper/ -- it used to be the separate
|
|
# creator5-toolchange checkout, pointed at by TOOLCHANGE= in config.env.
|
|
if [ "${BUILD_TOOLCHANGE:-1}" = "1" ]; then
|
|
say "Toolchange: ff_*.py + configs"
|
|
mkdir -p "$SW/klipper/extras"
|
|
cp -f payload/klipper/extras/ff_*.py "$SW/klipper/extras/"
|
|
# .cfg files belong on the data partition. These are mod-owned: run.sh
|
|
# overwrites them on every update (test_config_ownership.py enforces it).
|
|
# User changes go in printer.cfg, which no flash ever writes.
|
|
cp -f payload/klipper/config/ff-*.cfg "$MP/config/"
|
|
# Our printer.base.cfg is FlashForge's with the chamber block replaced by
|
|
# [include printer.chamber.cfg] -- Klipper can override an option but
|
|
# cannot un-declare a section, and the plain Creator 5 has no chamber
|
|
# heating element, so its heater has to be absent rather than neutralised.
|
|
# NOTE: this cp is why the stock-drift check lives in bin/unpack.sh and
|
|
# not in a test -- it overwrites the pristine copy, and the test that used
|
|
# to read it afterwards was comparing our file against itself.
|
|
cp -f payload/klipper/config/printer.base.cfg "$SW/klipper/config/printer.base.cfg"
|
|
|
|
# Anything that differs between models exists once per model, named
|
|
# <file>.creator5 / <file>.creator5pro, and the matching one is installed
|
|
# under its real name. Nothing is edited: the suffixed file IS the
|
|
# difference. printer.*.cfg belongs beside printer.base.cfg on the program
|
|
# partition; ff-*.cfg belongs on the data partition with the rest.
|
|
SUFFIX=$(printf '%s' "$TARGET_MACHINE" | tr 'A-Z' 'a-z')
|
|
for variant in payload/klipper/config/*."$SUFFIX"; do
|
|
[ -e "$variant" ] || continue
|
|
base=$(basename "$variant" ".$SUFFIX")
|
|
case "$base" in
|
|
printer.*) dest="$SW/klipper/config/$base" ;;
|
|
*) dest="$MP/config/$base" ;;
|
|
esac
|
|
cp -f "$variant" "$dest"
|
|
say "Model: $base for $TARGET_MACHINE"
|
|
done
|
|
# printer.base.cfg includes it unconditionally, so without it klippy will
|
|
# not start at all. A broken build, not a silent default.
|
|
[ -f "$SW/klipper/config/printer.chamber.cfg" ] \
|
|
|| { echo "no printer.chamber.cfg.$SUFFIX for TARGET_MACHINE=$TARGET_MACHINE" >&2; exit 1; }
|
|
else
|
|
skip "Toolchange"
|
|
fi
|
|
|
|
# -------------------------------------------------------------- 3. Mainsail
|
|
if [ "${BUILD_MAINSAIL:-1}" = "1" ]; then
|
|
# BUILD_MAINSAIL=1 asked for Mainsail, so a missing file is a broken build,
|
|
# not a reason to ship a package with an empty web root. bin/fetch-assets.sh
|
|
# should have put it here.
|
|
[ -f "${MAINSAIL_ZIP:-}" ] || { echo "BUILD_MAINSAIL=1 but no Mainsail zip at '${MAINSAIL_ZIP:-}' -- run ./bin/fetch-assets.sh" >&2; exit 1; }
|
|
say "Mainsail: unpacking $(basename "$MAINSAIL_ZIP")"
|
|
mkdir -p "$MP/www/mainsail"
|
|
unzip -q -o "$MAINSAIL_ZIP" -d "$MP/www/mainsail"
|
|
cp -f assets/nginx.conf "$MP/nginx/nginx.conf"
|
|
du -sh "$MP/www/mainsail" | awk '{print " "$1}'
|
|
else
|
|
skip "Mainsail"
|
|
fi
|
|
[ -f assets/moonraker.conf ] && cp -f assets/moonraker.conf "$MP/config/moonraker.conf"
|
|
# The user seam for Moonraker. moonraker.conf includes it by name, and
|
|
# run-append.sh creates it only when it is missing -- never overwrites it.
|
|
[ -f assets/moonraker-custom.conf ] \
|
|
&& cp -f assets/moonraker-custom.conf "$MP/config/moonraker-custom.conf"
|
|
|
|
# ------------------------------------------------------------- 4. Moonraker
|
|
# WHY THIS EXISTS -- the stock Moonraker is a 2022 build (it reports API
|
|
# 1.0.5) and it does NOT come from the update package at all: it ships on the
|
|
# factory image only, at /usr/prog/moonraker/moonraker/. Old enough that the
|
|
# current Mainsail quietly drops features it cannot see. The camera is the one
|
|
# you notice: Moonraker only grew the webcam "enabled" flag in April 2023, and
|
|
# Mainsail filters its webcam list on exactly that field, so every [webcam]
|
|
# entry is discarded and the panel disappears -- with the stream itself
|
|
# perfectly healthy behind nginx. No amount of config fixes that; the server
|
|
# has to be newer.
|
|
#
|
|
# WHAT IS AND IS NOT REPLACED. Only the python package tree
|
|
# (moonraker/moonraker/moonraker/) is swapped. The interpreter, the
|
|
# moonraker-env virtualenv beside it and moonrakerDaemon are all left alone,
|
|
# because the pinned build runs on what the printer already has:
|
|
#
|
|
# tornado 6.1, jinja2 3.1.2, distro 1.5.0, libnacl 1.7.2,
|
|
# streaming-form-data 1.8.1, inotify-simple 1.3.5, importlib_metadata 5.1.0,
|
|
# dbus-next 0.2.3, lmdb 1.3.0
|
|
#
|
|
# -- verified by booting it against exactly those versions on python 3.8,
|
|
# started the way moonrakerDaemon starts it (moonraker/moonraker.py -d), with
|
|
# _sqlite3 removed from the interpreter to match the printer. Nothing needs a
|
|
# MIPS wheel built: the only native module it imports is
|
|
# streaming_form_data._parser, and the installed 1.8.1 already exports every
|
|
# name it asks of it (StreamingFormDataParser, ParseFailedException,
|
|
# FileTarget, ValueTarget, SHA256Target).
|
|
#
|
|
# WHY A COMMIT AND NOT A RELEASE. FlashForge built python 3.8.2 without the
|
|
# _sqlite3 module -- there is no _sqlite3*.so in lib-dynload and no libsqlite3
|
|
# anywhere on the image, only the pure-python sqlite3/ wrapper that cannot
|
|
# work without it. Moonraker moved its database from lmdb to sqlite in v0.9.0,
|
|
# so every release from there on gets as far as loading the database component
|
|
# and dies:
|
|
#
|
|
# ModuleNotFoundError: No module named '_sqlite3'
|
|
#
|
|
# The last release still on lmdb is v0.8.0 (Feb 2023), which predates the
|
|
# webcam flag (Apr 2023). No release has both, so versions.env pins the newest
|
|
# commit that does. This was not caught by reasoning about it -- v0.9.3 was
|
|
# built, shipped and tried on the printer first, and this is what it said.
|
|
#
|
|
# The database is NOT converted: the pinned build uses the same lmdb store the
|
|
# stock server uses, so Mainsail's settings carry over untouched and going
|
|
# back to stock is a clean round trip.
|
|
#
|
|
# WHY IT RIDES IN THE MOD PAYLOAD AND NOT THE SOFTWARE COMPONENT. The stock
|
|
# run.sh does not extract the software component over /usr/prog -- it copies a
|
|
# hand-written list of paths out of it (app_startup.sh, klipper/klippy/*,
|
|
# firmwareExe, ...). A moonraker/ directory dropped in beside them would be
|
|
# unpacked to /usr/prog/PROGRAM/software/<ver>/ and then simply sat there.
|
|
# So it travels with the rest of the payload and run-append.sh puts it in
|
|
# place, which also lets that step swap the tree atomically and roll back.
|
|
if [ "${BUILD_MOONRAKER:-1}" = "1" ]; then
|
|
[ -f "${MOONRAKER_TGZ:-}" ] || { echo "BUILD_MOONRAKER=1 but no Moonraker tarball at '${MOONRAKER_TGZ:-}' -- run ./bin/fetch-assets.sh" >&2; exit 1; }
|
|
say "Moonraker: staging $MOONRAKER_VERSION package tree"
|
|
rm -rf work/.moonraker
|
|
mkdir -p work/.moonraker
|
|
tar -xzf "$MOONRAKER_TGZ" -C work/.moonraker --strip-components=1
|
|
# Guard against a tarball whose shape changed under us -- silently
|
|
# shipping nothing here would look like a clean build and a dead UI.
|
|
[ -f work/.moonraker/moonraker/moonraker.py ] || {
|
|
echo " !! no moonraker/moonraker.py in $(basename "$MOONRAKER_TGZ")" >&2; exit 1; }
|
|
rm -rf "$MP/moonraker"
|
|
cp -a work/.moonraker/moonraker "$MP/moonraker"
|
|
# The gate run-append.sh puts in front of the swap. It reads Moonraker's
|
|
# own component list out of the tree beside it, so it does not need
|
|
# updating when the pin moves.
|
|
cp -f payload/moonraker-preflight.py "$MP/moonraker-preflight.py"
|
|
# Tests never run on the printer and are a sizeable chunk of the tree.
|
|
rm -rf "$MP/moonraker/tests"
|
|
find "$MP/moonraker" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null
|
|
rm -rf work/.moonraker
|
|
du -sh "$MP/moonraker" | awk '{print " "$1}'
|
|
else
|
|
skip "Moonraker: keeping the stock 2022 build"
|
|
fi
|
|
|
|
# ----------------------------------------------------------- 5. HelixScreen
|
|
if [ "${BUILD_HELIX:-1}" = "1" ]; then
|
|
[ -f "${HELIX_TGZ:-}" ] || { echo "BUILD_HELIX=1 but no HelixScreen tarball at '${HELIX_TGZ:-}' -- run ./bin/fetch-assets.sh" >&2; exit 1; }
|
|
say "HelixScreen: unpacking $(basename "$HELIX_TGZ")"
|
|
mkdir -p "$MP/helixscreen"
|
|
tar -xzf "$HELIX_TGZ" -C "$MP" # yields $MP/helixscreen/
|
|
# Printer-database entry so it detects the Creator 5 Pro as a tool changer
|
|
mkdir -p "$MP/helixscreen/config/printer_database.d"
|
|
cp -f payload/helixscreen/printer_database.d/*.json \
|
|
"$MP/helixscreen/config/printer_database.d/"
|
|
# Optional platform hook. No such file is in the repo, so this never fires
|
|
# on a stock checkout -- drop one in assets/ to have it shipped.
|
|
[ -f assets/hooks-creator5.sh ] && \
|
|
cp -f assets/hooks-creator5.sh "$MP/helixscreen/assets/config/platform/"
|
|
du -sh "$MP/helixscreen" | awk '{print " "$1}'
|
|
else
|
|
skip "HelixScreen"
|
|
fi
|
|
|
|
# ------------------------------------------------------------------- 6. SSH
|
|
# Nothing to install. The stock rootfs (kernel-*.tar.xz -> rootfs.squashfs)
|
|
# already ships /usr/sbin/dropbear, /usr/bin/dropbearkey AND an enabled
|
|
# /etc/init.d/S50dropbear that busybox init runs at every boot. SSH is
|
|
# therefore ALREADY LISTENING on port 22 of a stock printer.
|
|
#
|
|
# The only thing missing is a root password anyone knows: stock /etc/shadow
|
|
# carries an unpublished hash. Setting ROOT_PW_HASH below is the entire
|
|
# "enable ssh" feature -- no cross-compiled binaries, no init script.
|
|
if [ "${MOD_SSH:-1}" = "1" ]; then
|
|
if [ -n "${ROOT_PW_HASH:-}" ]; then
|
|
say "SSH: stock dropbear is already running; setting a known root password"
|
|
else
|
|
say "SSH: no ROOT_PW_HASH -- the installer will pick a random root password"
|
|
say " and write it to anvil-password.txt on the USB stick."
|
|
say " Set ROOT_PW_HASH to choose your own instead."
|
|
fi
|
|
else
|
|
skip "SSH"
|
|
fi
|
|
if [ -n "${BUSYBOX_BIN:-}" ] && [ -f "$BUSYBOX_BIN" ]; then
|
|
cp -f "$BUSYBOX_BIN" "$MP/bin/busybox"; chmod +x "$MP/bin/busybox"
|
|
fi
|
|
|
|
# --------------------------------------------------------- 7. root password
|
|
if [ -n "${ROOT_PW_HASH:-}" ]; then
|
|
say "Accounts: setting root password hash"
|
|
# /etc is a bind mount of /usr/prog/etc (app_startup.sh), and this file is
|
|
# what dropbear reads at authentication time -- so this is the live shadow
|
|
# even though dropbear started earlier from the read-only squashfs.
|
|
awk -v h="$ROOT_PW_HASH" 'BEGIN{FS=OFS=":"} $1=="root"{$2=h} {print}' \
|
|
"$SW/shadow" > "$SW/shadow.new" && mv -f "$SW/shadow.new" "$SW/shadow"
|
|
else
|
|
skip "root password (set ROOT_PW_HASH)"
|
|
fi
|
|
|
|
# ------------------------------------------------ 8. start.sh (web stack on)
|
|
say "start.sh: enabling nginx + moonraker"
|
|
cp -f payload/start.sh "$SW/start.sh"
|
|
chmod +x "$SW/start.sh"
|
|
|
|
# ------------------------------------- 9. firmwareExe -> our wrapper script
|
|
# The stock chain is rcS -> S99factory_test_shell -> app_startup.sh ->
|
|
# firmwareExe, and firmwareExe is also what starts Klipper. Replacing this
|
|
# one file is therefore enough to own the whole userspace boot, which means
|
|
# app_startup.sh, rcS and the init chain are left COMPLETELY STOCK.
|
|
#
|
|
# The genuine binary is replaced, not kept aside: HelixScreen is the only UI,
|
|
# and the installer wipes the software dir before run.sh anyway, so nothing
|
|
# here could ever be a reliable backup. Flashing the stock FlashForge package
|
|
# -- which still ships the binary -- is the uninstall.
|
|
say "firmwareExe: installing wrapper (replaces the stock binary)"
|
|
cp -f payload/firmwareExe "$SW/firmwareExe"
|
|
chmod +x "$SW/firmwareExe"
|
|
|
|
# ----------------------------------------------------- 10. mod service dir
|
|
mkdir -p "$MP/init.d"
|
|
[ -d payload/bin ] && cp -f payload/bin/* "$MP/bin/" && chmod +x "$MP/bin"/*
|
|
cp -f payload/init.d/S* "$MP/init.d/"
|
|
chmod +x "$MP/init.d"/S*
|
|
sed -e "s/^MOD_WEB=.*/MOD_WEB=${MOD_WEB:-1}/" \
|
|
-e "s/^MOD_CAM=.*/MOD_CAM=${MOD_CAM:-1}/" \
|
|
-e "s/^MOD_UI=.*/MOD_UI=${MOD_UI:-1}/" \
|
|
-e "s/^MOD_SSH=.*/MOD_SSH=${MOD_SSH:-1}/" \
|
|
-e "s/^MOD_WIFI=.*/MOD_WIFI=${MOD_WIFI:-1}/" \
|
|
payload/anvil.conf > "$MP/anvil.conf"
|
|
|
|
# --------------------------------------------------- 11. run.sh install step
|
|
say "run.sh: injecting mod install blocks (pre + post)"
|
|
POST=work/.run-post.sh
|
|
# 1 only when ssh is on and nothing was baked in: a package is one file that
|
|
# many people flash, so a baked-in default would be the same password on every
|
|
# printer. The installer picks a random per-machine one instead and writes it
|
|
# onto the USB stick it was flashed from.
|
|
if [ "${MOD_SSH:-1}" = "1" ] && [ -z "${ROOT_PW_HASH:-}" ]; then
|
|
PW_AUTO=1
|
|
else
|
|
PW_AUTO=0
|
|
fi
|
|
sed -e "s/^MOD_PW_AUTO=.*/MOD_PW_AUTO=$PW_AUTO/" \
|
|
payload/run-append.sh > "$POST"
|
|
python3 - "$SW/run.sh" payload/run-pre.sh "$POST" <<'PY'
|
|
import sys, re
|
|
run, pre_f, post_f = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
B1, E1 = "# >>> anvil pre >>>", "# <<< anvil pre <<<"
|
|
B2, E2 = "# >>> anvil begin >>>", "# <<< anvil end <<<"
|
|
src = open(run, encoding='utf-8', errors='surrogateescape').read()
|
|
# idempotent: strip any previous injection
|
|
for b, e in ((B1, E1), (B2, E2)):
|
|
src = re.sub(re.escape(b) + r".*?" + re.escape(e) + r"\n?", "", src, flags=re.S)
|
|
|
|
pre = B1 + "\n" + open(pre_f, encoding='utf-8').read() + E1 + "\n"
|
|
post = B2 + "\n" + open(post_f, encoding='utf-8').read() + E2 + "\n\n"
|
|
|
|
# The pre-block must land AFTER WORK_DIR is defined (it uses $WORK_DIR's
|
|
# siblings) but BEFORE the first cp into /usr/prog.
|
|
m = re.search(r"^WORK_DIR=.*$", src, flags=re.M)
|
|
if not m:
|
|
raise SystemExit("run.sh has no WORK_DIR assignment -- cannot place the pre-block")
|
|
i = m.end()
|
|
src = src[:i] + "\n\n" + pre + src[i:]
|
|
print(" pre-block inserted after WORK_DIR")
|
|
|
|
m = list(re.finditer(r"^exit 0\s*$", src, flags=re.M))
|
|
j = m[-1].start() if m else len(src)
|
|
src = src[:j] + post + src[j:]
|
|
print(" post-block inserted before exit")
|
|
open(run, 'w', encoding='utf-8', errors='surrogateescape').write(src)
|
|
PY
|
|
chmod +x "$SW/run.sh"
|
|
rm -f "$POST"
|
|
|
|
echo
|
|
echo "Patched."
|
|
echo " software component: $(du -sh "$SW" | cut -f1) (-> /usr/prog, firmware partition)"
|
|
echo " mod payload: $(du -sh "$MP" | cut -f1) (-> /usr/data/anvil, data partition)"
|
|
echo "Now run ./bin/pack.sh"
|