Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it acts, and never overwrites itself. 1. "Install box?" — prompted before anything is downloaded. 2. If box is already installed: say so and stop. A re-run changes nothing, so curl|bash can no longer clobber a working tree or rebuild the host stack under live boxes. Upgrading is explicit — uninstall, then install fresh. 3. On a fresh host: download, install, link onto PATH. 4. "Set up this machine as a box host now?" — a separate decision, because the CLI and the host are not the same choice (you may host boxes elsewhere). This replaces the version-diff refusal from the previous round with the simpler rule Dan asked for: installed at all => no-op. It dissolves the same class of "the upgrade ate my boxes" errors without the installer having to reason about versions or enumerate boxes at all — you cannot lose boxes to an install that refuses to touch an existing one. Prompts read /dev/tty, because under curl|bash the script itself is stdin and a plain read would eat the installer's own remaining lines. With no terminal (CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended; without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines the second prompt. The drill uninstalls before installing (the no-op rule would otherwise refuse to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts; BOX_FORCE_UPGRADE is gone with the refusal it drove. Verified on a real host: cancel, fresh install, no-op re-run, and both prompts driven through a pty (y/n and y-then-n), plus the no-tty refusal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
043e556027
commit
d0a14a18a5
4 changed files with 111 additions and 117 deletions
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -55,19 +55,22 @@ which records not just what changed but what each drill run proved.
|
||||||
unsets `dns.mode` and so has to be converged back. `DRILL_OWNS_SETUP=1`
|
unsets `dns.mode` and so has to be converged back. `DRILL_OWNS_SETUP=1`
|
||||||
hands sequencing back to the drill. Pre-setup tripwires now read *before*
|
hands sequencing back to the drill. Pre-setup tripwires now read *before*
|
||||||
`install.sh`, since that is what triggers setup now.
|
`install.sh`, since that is what triggers setup now.
|
||||||
- **`install.sh` runs the host setup itself** (#64) — it printed a warning and
|
- **`install.sh` asks, sets up the host, and no-ops on re-run** (#64) — it now
|
||||||
left you a command to run, so the install reported success and `box new`
|
prompts *"Install box?"*, then on a fresh host installs the tree and asks a
|
||||||
failed on a host with no Incus. `BOX_SKIP_SETUP_HOST=1` opts out; if setup
|
second question, *"Set up this machine as a box host now?"*, running the whole
|
||||||
fails, the install still stands and says what to re-run.
|
isolation stack if you say yes (previously it only printed a warning and left
|
||||||
- **`install.sh` refuses to change versions under existing boxes** — building
|
you a command, so the install reported success and `box new` died on a host
|
||||||
the host stack from the installer means an upgrade reaches under every box
|
with no Incus). Prompts read `/dev/tty`, since under `curl | bash` the script
|
||||||
attached to that stack, so it is no longer only a tree swap. Same version and
|
itself is stdin; `BOX_YES=1` answers yes unattended (required where there is
|
||||||
ref: says so, changes nothing. Version or ref change with boxes on the host
|
no terminal), `BOX_SKIP_SETUP_HOST=1` declines the host-setup step.
|
||||||
(either tag generation): refuses, loudly, listing them — and refuses *before*
|
- **`install.sh` never overwrites an existing install** — if box is already
|
||||||
`$DEST` is touched, so the working install survives the refusal. No boxes:
|
installed it says so and changes nothing, so a stray re-run can no longer
|
||||||
proceeds. `BOX_FORCE_UPGRADE=1` overrides; the drill sets it, since wiping
|
clobber a working tree or rebuild the host stack under live boxes. Upgrading
|
||||||
boxes is its job. The version-aware upgrade that migrates instead of refusing
|
is explicit: uninstall (`rm -rf ~/.local/share/box ~/.local/bin/box`, boxes
|
||||||
is #67.
|
preserved first) and install fresh. This replaces the earlier version-diff
|
||||||
|
refusal with a simpler rule that dissolves the same class of errors. The
|
||||||
|
version-aware upgrade that migrates boxes instead is #67; a portable
|
||||||
|
`box export` so a box survives its own deletion is #70.
|
||||||
|
|
||||||
## 0.5.0 — 2026-07-15
|
## 0.5.0 — 2026-07-15
|
||||||
|
|
||||||
|
|
|
||||||
35
README.md
35
README.md
|
|
@ -40,20 +40,29 @@ design rationale.
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Installs the tree to `~/.local/share/box` and links `box` onto your
|
It asks first — **"Install box?"** — then, if box is not already installed,
|
||||||
`PATH`, then runs the host setup below for you (it may ask for `sudo`; set
|
downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and
|
||||||
`BOX_SKIP_SETUP_HOST=1` to opt out and run it yourself). Upgrading from a
|
asks a second question: **"Set up this machine as a box host now?"** Say yes and
|
||||||
pre-0.4.0 install also retires the old `claudebox` symlink. (No `git clone`
|
it builds the whole isolation stack for you (it may ask for `sudo`); say no and
|
||||||
needed.)
|
you can run `box setup-host` later. (No `git clone` needed.)
|
||||||
|
|
||||||
**Upgrading, and boxes.** Because the installer builds the host stack, an
|
**Re-running is a safe no-op.** If box is already installed, the installer tells
|
||||||
upgrade reaches under the boxes attached to it — so it is deliberate about it.
|
you so and changes nothing — a stray re-run can never clobber your install or
|
||||||
Re-running the same version changes nothing and says so. Changing version on a
|
rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall
|
||||||
host that has boxes **refuses**, lists them, and leaves your install exactly as
|
what you have and install fresh. Preserve any boxes first — `box down <box>`,
|
||||||
it was; deal with the boxes and re-run, or say `BOX_FORCE_UPGRADE=1` to upgrade
|
copy out anything you need (a portable `box export` is
|
||||||
over them on purpose (boxes are not deleted, but the stack is rebuilt beneath
|
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
|
||||||
them). With no boxes on the host, it just upgrades. Migrating boxes across an
|
(which deletes the box *and* its snapshots) — then:
|
||||||
upgrade instead of refusing is [#67](https://github.com/heavy-duty/box/issues/67).
|
|
||||||
|
```sh
|
||||||
|
rm -rf ~/.local/share/box ~/.local/bin/box # uninstall
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
A version-aware upgrade that migrates boxes instead of asking you to is
|
||||||
|
[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs
|
||||||
|
(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1`
|
||||||
|
declines the host-setup step.
|
||||||
|
|
||||||
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -202,11 +202,16 @@ EOF
|
||||||
export BOX_SKIP_SETUP_HOST=1
|
export BOX_SKIP_SETUP_HOST=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The installer refuses to change versions on a host that still has boxes.
|
# The installer is a no-op when box is already installed — upgrading is
|
||||||
# That hatch is for humans with work to lose; the drill's whole job is to
|
# uninstall-then-install, by design. The drill re-proves a tree from scratch
|
||||||
# arrive on a dirty host, wipe every box it recognises (below) and re-prove
|
# every run, so it does the uninstall itself: clear any prior tree and symlink
|
||||||
# the stack from there — so it opts out, deliberately and in one place.
|
# before installing, or install.sh would correctly refuse to touch them.
|
||||||
export BOX_FORCE_UPGRADE=1
|
rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box"
|
||||||
|
|
||||||
|
# The installer prompts (install? set up host?) and reads /dev/tty. The drill
|
||||||
|
# runs unattended with no tty, so it answers yes to everything via BOX_YES.
|
||||||
|
# OWNS still suppresses the setup prompt via BOX_SKIP_SETUP_HOST above.
|
||||||
|
export BOX_YES=1
|
||||||
|
|
||||||
BOX_REPO="$REPO" BOX_REF="$REF" \
|
BOX_REPO="$REPO" BOX_REF="$REF" \
|
||||||
bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \
|
bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \
|
||||||
|
|
|
||||||
149
install.sh
149
install.sh
|
|
@ -17,10 +17,50 @@ log() { printf 'box-install: %s\n' "$*"; }
|
||||||
warn() { printf 'box-install: WARNING: %s\n' "$*" >&2; }
|
warn() { printf 'box-install: WARNING: %s\n' "$*" >&2; }
|
||||||
die() { printf 'box-install: ERROR: %s\n' "$*" >&2; exit 1; }
|
die() { printf 'box-install: ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Ask a yes/no question and echo the answer. The wrinkle: under the intended
|
||||||
|
# 'curl … | bash', THIS SCRIPT is stdin — so a plain 'read' would consume the
|
||||||
|
# installer's own remaining lines, not the user's keystroke. Prompts therefore
|
||||||
|
# read the terminal directly via /dev/tty. When there is no terminal at all (CI,
|
||||||
|
# a pipe with no tty), there is nobody to ask: BOX_YES=1 means "assume yes to
|
||||||
|
# every prompt" and is how automation and the drill drive this unattended;
|
||||||
|
# without it we refuse rather than silently assume consent.
|
||||||
|
confirm() { # $1 = question
|
||||||
|
[ -n "${BOX_YES:-}" ] && return 0
|
||||||
|
if ! { true >/dev/tty; } 2>/dev/null; then
|
||||||
|
die "no terminal to confirm on. Re-run with BOX_YES=1 to proceed non-interactively (assumes yes to all prompts)."
|
||||||
|
fi
|
||||||
|
local reply
|
||||||
|
printf 'box-install: %s [y/N] ' "$1" >/dev/tty
|
||||||
|
read -r reply </dev/tty || reply=""
|
||||||
|
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
|
||||||
|
}
|
||||||
|
|
||||||
# --- prerequisites ---------------------------------------------------------
|
# --- prerequisites ---------------------------------------------------------
|
||||||
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
|
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
|
||||||
command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run."
|
command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run."
|
||||||
|
|
||||||
|
# --- confirm, then no-op if already installed ------------------------------
|
||||||
|
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is
|
||||||
|
# ask whether you meant to. Then, if box is already installed, this run changes
|
||||||
|
# nothing and says so — a re-run is a safe no-op, which dissolves the whole
|
||||||
|
# "curl clobbered my working install / rebuilt the stack under my boxes" class
|
||||||
|
# of failures. Upgrading is deliberately NOT an in-place overwrite: you uninstall
|
||||||
|
# what you have (dealing with your boxes as you do) and install fresh.
|
||||||
|
confirm "Install box from $REPO@$REF?" || die "cancelled — nothing was changed."
|
||||||
|
|
||||||
|
if [ -e "$BINDIR/box" ] || [ -x "$DEST/bin/box" ]; then
|
||||||
|
cur="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')"
|
||||||
|
cur_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo '?')"
|
||||||
|
log "box is already installed ($cur, version $cur_ver) — nothing to do."
|
||||||
|
log "To install a different version, remove the current one first:"
|
||||||
|
log " · preserve any boxes you care about — 'box down <box>', then keep them"
|
||||||
|
log " (a portable 'box export' is #70; for now copy what you need OUT via"
|
||||||
|
log " 'box shell'/'box exec'), and 'box rm <box>' when you are done"
|
||||||
|
log " · uninstall: rm -rf \"$DEST\" \"$BINDIR/box\""
|
||||||
|
log " · then re-run this installer"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# --- temp workspace --------------------------------------------------------
|
# --- temp workspace --------------------------------------------------------
|
||||||
TMPDIR="$(mktemp -d)"
|
TMPDIR="$(mktemp -d)"
|
||||||
cleanup() { rm -rf "$TMPDIR"; }
|
cleanup() { rm -rf "$TMPDIR"; }
|
||||||
|
|
@ -47,80 +87,10 @@ EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
|
||||||
[ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive"
|
[ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive"
|
||||||
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
|
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
|
||||||
|
|
||||||
# --- upgrade hatch ---------------------------------------------------------
|
# --- install into $DEST ----------------------------------------------------
|
||||||
# This installer builds the host stack itself now, and every box on the host is
|
# Reached only on a host with no existing install (the no-op check above
|
||||||
# attached to that stack — so a version change here is not just a tree swap, it
|
# exits otherwise), so this is always a fresh tree, never an overwrite.
|
||||||
# reaches under running boxes. Until the version-aware migration exists (#67),
|
|
||||||
# refuse rather than guess: if this would change what is installed AND there are
|
|
||||||
# boxes on the host, stop and let a human decide. Checked BEFORE $DEST is
|
|
||||||
# touched, so a refusal leaves the working install exactly as it was.
|
|
||||||
# Same version + same ref = nothing to change: say so and carry on.
|
|
||||||
new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || echo unknown)"
|
|
||||||
old_ver="$(cat "$DEST/VERSION" 2>/dev/null || true)"
|
|
||||||
old_from="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || true)"
|
|
||||||
|
|
||||||
if [ -n "$old_ver" ] && [ "$old_ver" = "$new_ver" ] && [ "$old_from" = "$REPO@$REF" ]; then
|
|
||||||
CHANGING=0
|
|
||||||
else
|
|
||||||
CHANGING=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# How we ask incus about boxes. No incus => no boxes, and nothing to protect.
|
|
||||||
if [ "$(id -u)" -eq 0 ]; then PRIV=""
|
|
||||||
elif command -v sudo >/dev/null 2>&1; then PRIV="sudo"
|
|
||||||
else PRIV=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Unprivileged FIRST: anyone who owns boxes is already in incus-admin, so the
|
|
||||||
# plain query answers it without making the installer demand a sudo password
|
|
||||||
# just to look. Escalate only if the socket refuses us.
|
|
||||||
incus_names() { # $1 = tag filter
|
|
||||||
incus list "$1" --format csv --columns n 2>/dev/null && return 0
|
|
||||||
[ -n "$PRIV" ] && $PRIV incus list "$1" --format csv --columns n 2>/dev/null
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
boxes_on_host() {
|
|
||||||
command -v incus >/dev/null 2>&1 || return 0
|
|
||||||
# BOTH tags: a pre-rename box carries user.claudebox=1 and is just as much
|
|
||||||
# someone's work as a current one.
|
|
||||||
{ incus_names "user.box=1"; incus_names "user.claudebox=1"; } | sed '/^$/d' | sort -u
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "$CHANGING" = 0 ]; then
|
|
||||||
log "already at $new_ver ($REPO@$REF) — reinstalling the same tree, nothing to migrate"
|
|
||||||
elif [ -z "${BOX_FORCE_UPGRADE:-}" ]; then
|
|
||||||
found="$(boxes_on_host)"
|
|
||||||
if [ -n "$found" ]; then
|
|
||||||
printf 'box-install: ERROR: this host has boxes, and this install would change what runs them.\n' >&2
|
|
||||||
printf '\n installed: %s\n incoming: %s\n\n boxes on this host:\n' \
|
|
||||||
"${old_from:-<none>} ${old_ver:-<no version file>}" "$REPO@$REF $new_ver" >&2
|
|
||||||
printf '%s\n' "$found" | sed 's/^/ · /' >&2
|
|
||||||
cat >&2 <<EOF
|
|
||||||
|
|
||||||
Refusing, and nothing has been changed — your current install is intact.
|
|
||||||
The installer now builds the host stack (network, ACL, profile, firewall)
|
|
||||||
itself, so upgrading reaches under boxes that are attached to it.
|
|
||||||
|
|
||||||
Your options:
|
|
||||||
· Stay where you are. The boxes keep working. Nothing to do.
|
|
||||||
· Deal with the boxes, then re-run this installer.
|
|
||||||
NOTE: 'box rm' deletes a box AND every snapshot it has — it cannot be
|
|
||||||
undone, and a snapshot does NOT survive its box. Copy anything you need
|
|
||||||
OUT of a box first ('box shell <box>' / 'box exec <box> -- ...').
|
|
||||||
· Upgrade anyway, on purpose:
|
|
||||||
BOX_FORCE_UPGRADE=1 curl -fsSL <this url> | bash
|
|
||||||
Boxes are not deleted, but the stack is rebuilt underneath them.
|
|
||||||
|
|
||||||
A version-aware upgrade that migrates boxes instead of refusing is #67.
|
|
||||||
EOF
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- atomically replace $DEST ---------------------------------------------
|
|
||||||
log "installing into $DEST"
|
log "installing into $DEST"
|
||||||
rm -rf "$DEST"
|
|
||||||
mkdir -p "$(dirname "$DEST")"
|
mkdir -p "$(dirname "$DEST")"
|
||||||
mv "$EXTRACTED" "$DEST"
|
mv "$EXTRACTED" "$DEST"
|
||||||
|
|
||||||
|
|
@ -163,36 +133,43 @@ esac
|
||||||
# and it must not hinge on whether the host stack came up.
|
# and it must not hinge on whether the host stack came up.
|
||||||
printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM"
|
printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM"
|
||||||
|
|
||||||
# --- host setup ------------------------------------------------------------
|
# --- host setup (second prompt) --------------------------------------------
|
||||||
# The installer finishes the job (#64). Telling the user to go run setup-host
|
# The tool is installed; the machine is not yet a box host. Offer to finish the
|
||||||
# was a step that read as optional and failed later as mysterious: the install
|
# job — build Incus and the isolation stack — rather than leave 'box new' to die
|
||||||
# reports success, 'box' is on PATH, and 'box new' dies on a host with no
|
# later on a host with no boxnet and no profile (#64). This is its own decision:
|
||||||
# Incus, no boxnet, no profile. setup-host is idempotent by design, so doing
|
# you might be installing the CLI on a workstation and hosting boxes elsewhere.
|
||||||
# this on EVERY install is also how an upgraded host picks up stack changes —
|
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
|
||||||
# the isolation fixes that ship as new firewall rules land when the tool that
|
# up by hand); BOX_YES answers "yes".
|
||||||
# claims them lands, instead of waiting on someone to re-run a command.
|
|
||||||
# BOX_SKIP_SETUP_HOST=1 opts out: CI, image builds, a host set up by hand.
|
|
||||||
setup_ok=""
|
setup_ok=""
|
||||||
|
setup_declined=""
|
||||||
if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
|
if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
|
||||||
log "skipping host setup (BOX_SKIP_SETUP_HOST is set) — run it yourself: box setup-host"
|
log "skipping host setup (BOX_SKIP_SETUP_HOST is set)."
|
||||||
|
setup_declined=1
|
||||||
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
|
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
|
||||||
warn "host setup needs root and sudo was not found."
|
warn "cannot set up the host: it needs root and sudo was not found."
|
||||||
warn " run this as root to finish: $DEST/host/setup-host.sh"
|
warn " run this as root to finish: $DEST/host/setup-host.sh"
|
||||||
else
|
setup_declined=1
|
||||||
log "running one-time host setup (installs Incus + the isolation stack; may ask for sudo)"
|
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
|
||||||
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
|
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
|
||||||
# reads stdin eats the installer's own remaining lines. sudo is unaffected —
|
# reads stdin eats the installer's own remaining lines. sudo is unaffected —
|
||||||
# it prompts on /dev/tty, so an interactive host can still authenticate.
|
# it prompts on /dev/tty, so an interactive host can still authenticate.
|
||||||
|
# setup-host re-execs itself under sg incus-admin if it must add you to the
|
||||||
|
# group; that re-exec is a child here and completes the whole setup in one go.
|
||||||
if bash "$DEST/host/setup-host.sh" </dev/null; then
|
if bash "$DEST/host/setup-host.sh" </dev/null; then
|
||||||
setup_ok=1
|
setup_ok=1
|
||||||
else
|
else
|
||||||
warn "host setup did not complete — box is installed, the host is not ready."
|
warn "host setup did not complete — box is installed, the host is not ready."
|
||||||
warn " fix the error above and re-run: box setup-host"
|
warn " fix the error above and re-run: box setup-host"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
log "skipped host setup."
|
||||||
|
setup_declined=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$setup_ok" ]; then
|
if [ -n "$setup_ok" ]; then
|
||||||
log "done ($REPO@$REF) — try: box new --name test"
|
log "done ($REPO@$REF) — try: box new --name test"
|
||||||
|
elif [ -n "$setup_declined" ]; then
|
||||||
|
log "done ($REPO@$REF) — when you want this machine to host boxes: box setup-host"
|
||||||
else
|
else
|
||||||
log "done ($REPO@$REF) — finish with 'box setup-host', then: box new --name test"
|
log "done ($REPO@$REF) — finish with 'box setup-host', then: box new --name test"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue