43cd6e22f2
Publish Docker Image / resolve-versions (push) Successful in 9s
Lint / actionlint (push) Successful in 15s
Lint / hadolint (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / build-base (push) Successful in 41m22s
Publish Docker Image / smoke-studio (push) Successful in 5m16s
Publish Docker Image / smoke (push) Successful in 7m31s
Publish Docker Image / build-variant-studio (push) Successful in 18m31s
Publish Docker Image / build-variant (push) Successful in 27m18s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Publish Docker Image / update-description (push) Successful in 12s
Two changes that belong together, because the first is what makes the second dangerous to get wrong. pi-atelier (TUI sidebar + status rail) is now vendored to /opt/pi-atelier at PI_ATELIER_REF=v0.8.0 and registered by entrypoint-user.sh — the pi-fork / pi-observational-memory / pi-studio pattern, deliberately NOT `pi install npm:pi-atelier`, which writes into ~/.pi/npm-global on the config volume where it shadows the image and pins nothing. Unlike its siblings it gets no `npm install`: atelier declares zero runtime deps (peerDeps only, satisfied by the baked pi) and has no build step, so pi loads its TypeScript straight from the checkout via package.json `pi.extensions`. pi is no longer resolved to npm `latest` at build time. The pin lives in Dockerfile.variant and CI reads it from there, so a local `docker build` and a CI release ship the same versions by construction. The pin is a CHECKPOINT, NOT A FREEZE: bumping stays a one-line change; what stops is *unreviewed* adoption of whatever shipped that morning, in the same build that then gets tagged and published. CI fails when a pin is not concrete or not actually published on npm, and warns — never adopts — when npm latest moves ahead, naming what to re-check. Why this pairing needed care: pi-atelier 0.6.0/0.7.0 wrap pi's PRIVATE TUI renderer, and under pi 0.84 that wrapper recurses — pi hangs at startup burning CPU with no error. Upstream fixed the recursion in 0.7.1 and restored the non-overlapping split in 0.7.2; 0.8.0 is additive on top. atelier's own peerDependencies still say >=0.80.7, which does not express that floor, so nothing in npm metadata could have warned us. The floor is therefore encoded as an executable rule — pi >= 0.84 => pi-atelier >= 0.7.1 — asserted in both smoke-test.sh (build time) and recreate-sanity-check.sh (after a real recreate), verified against a 4x4 version matrix. Existing volumes needed migration, not just vendoring: a hand-installed `npm:pi-atelier` entry is counted as already-registered by the entrypoint guard, so every existing volume would have kept its unpinned npm copy — and a 0.6.x copy next to pi 0.84 is exactly the startup hang. The entrypoint now drops that one exact string (settings.json.bak.atelier.<ts> backup, distinct prefix so it cannot clobber the template merge's backup in the same second) and lets the pinned /opt copy register. Tested against a real settings.json: only that entry removed, other packages and all keys intact, idempotent, and unparseable JSON leaves the file untouched. DEVBOX_ATELIER=0 opts out entirely — in the entrypoint rather than via `pi uninstall`, because this component's failure mode is "pi will not start", which cannot be repaired from inside pi. 0.84.1 was audited for this release, not merely adopted: theme/TUI changes are additive, the session format is unchanged (CURRENT_SESSION_VERSION = 3 in both 0.83.0 and 0.84.1 with an identical migrateV1ToV2/migrateV2ToV3 ladder, so existing transcripts are neither migrated nor at risk and pi-session-repair stays valid), and the Node engine floor is unmoved at >=22.19.0. CI resolves the atelier tag to its PEELED commit SHA — atelier uses annotated tags, so the unpeeled ref is a tag object, not a commit; pi-studio's lightweight tags never exposed that distinction. Also: docs for overriding the read-only ~/.ssh/config from the container — container-only keys in ~/.ssh-local, hardened authorized_keys, the fact that `from=` must allow the HOST's addresses because container egress is NAT'd through it, and the macOS-only-keyword trap (`UseKeychain` is fatal to Linux OpenSSH and takes out dssh/pi --ssh while the host keeps working). Corrects two claims in "Naming LAN peers": ssh-lan.conf is not ProxyJump-only, and first-time creation does need one restart because the Include is emitted only when the file already exists at start.
316 lines
17 KiB
Bash
Executable File
316 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# ── Startup banner: which pi-devbox build is this? ─────────────────
|
|
# Printed FIRST, before the setup noise below, so it's the first thing
|
|
# visible when the container starts (CMD is `bash -l`, tty:true in compose,
|
|
# so this reaches the same stream as the interactive shell the user lands
|
|
# in). Reads the ground-truth manifest baked in Dockerfile.variant; a no-op
|
|
# with a short stderr notice on images built before it existed.
|
|
command -v pi-devbox-version >/dev/null 2>&1 && pi-devbox-version || true
|
|
|
|
# ── SSH ControlMaster socket dir ────────────────────────────────
|
|
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
|
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
|
|
# creates the directory with the right permissions on every container
|
|
# start. /tmp is per-container so the dir doesn't survive recreation;
|
|
# baking it into a Dockerfile layer would be wrong.
|
|
# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that
|
|
# others can write to.
|
|
mkdir -p /tmp/sshcm
|
|
chmod 700 /tmp/sshcm
|
|
|
|
# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
|
|
# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
|
|
# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
|
|
# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
|
|
# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
|
|
# SSH-jump-via-host block so the container can reach the host's
|
|
# directly-attached LAN peers; on native Linux (LAN reachable directly) the
|
|
# jump block is omitted but the sidecar is still rendered. Controlled by
|
|
# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the
|
|
# script header.
|
|
if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
|
|
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
|
|
fi
|
|
|
|
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
|
|
# Respects host bind-mounts and user customizations — existing files
|
|
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
|
|
# .inputrc) and recreate the container, or cp from /etc/skel-devbox/
|
|
# directly.
|
|
SKEL_DIR="/etc/skel-devbox"
|
|
if [ -d "$SKEL_DIR" ]; then
|
|
for f in .bash_aliases .inputrc .gitignore_global; do
|
|
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
|
|
cp "$SKEL_DIR/$f" "$HOME/$f"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── Image-baked skills: link into ~/.agents/skills ───────────────────
|
|
# Skills shipped IN the image (under /usr/local/share/pi-devbox/skills/) are
|
|
# made available regardless of whether a skillset repo is mounted. Done EARLY
|
|
# — before the pi-toolkit/extensions deploy below — so the symlinks exist by
|
|
# the time anything gates on "container ready": the smoke-test readiness probe
|
|
# waits on pi-deploy markers (keybindings.json, mempalace.ts) that only land
|
|
# AFTER this point, so linking here closes a sample-too-early race that failed
|
|
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
|
|
# keeps the skill fresh from the image and surviving volume recreate (unlike
|
|
# anything baked under a home dir, which a named volume would shadow). Created
|
|
# only when absent, so a same-named skillset skill (deployed later, at the end
|
|
# of this script) or a user override is never clobbered; the skillset deploy
|
|
# classifies these as foreign-links and its --prune-stale pass leaves them
|
|
# alone (only dangling symlinks are pruned).
|
|
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
|
|
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
|
mkdir -p "$HOME/.agents/skills"
|
|
for _sk in "$DEVBOX_SKILLS_SRC"/*/; do
|
|
[ -d "$_sk" ] || continue
|
|
_skname=$(basename "$_sk")
|
|
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
|
|
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── MemPalace: initialize palace for the workspace if mempalace is installed
|
|
# Creates the palace directory structure on first run. Idempotent — skips
|
|
# if palace already exists, so upgrades from older versions preserve
|
|
# existing data. `--yes` auto-accepts detected entities so the init is
|
|
# non-interactive.
|
|
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
|
PALACE_DIR="${HOME}/.mempalace"
|
|
if [ ! -d "$PALACE_DIR/palace" ]; then
|
|
echo "Initializing MemPalace for workspace (non-interactive)..."
|
|
# </dev/null: mempalace init has an interactive "Mine this directory
|
|
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
|
# Without redirected stdin, the process blocks here forever when run
|
|
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
|
|
# makes the prompt fall through to its default (skip).
|
|
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
|
|
fi
|
|
fi
|
|
|
|
# ── Git config defaults ──────────────────────────────────────────────
|
|
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
|
git config --global user.name "$GIT_USER_NAME"
|
|
fi
|
|
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
|
|
git config --global user.email "$GIT_USER_EMAIL"
|
|
fi
|
|
# Global gitignore for personal/tooling artifacts (*.bak, *~, *.orig, ...).
|
|
# Seeded above into $HOME/.gitignore_global from /etc/skel-devbox. Point git at
|
|
# it only if the user has not already set their own core.excludesFile.
|
|
if [ -f "$HOME/.gitignore_global" ] && ! git config --global core.excludesFile &>/dev/null; then
|
|
git config --global core.excludesFile "$HOME/.gitignore_global"
|
|
fi
|
|
|
|
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
|
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
|
|
# Each install.sh is idempotent and backs up real files before linking,
|
|
# so re-running across container restarts is safe.
|
|
#
|
|
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
|
|
# and writes the AWS env loader), then pi-extensions (symlinks our
|
|
# extensions), then settings.json bootstrap from the toolkit template,
|
|
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
|
|
# install_skill is intentionally skipped to avoid racing with skillset
|
|
# auto-deploy below).
|
|
if command -v pi &>/dev/null; then
|
|
if [ -d /opt/pi-toolkit ]; then
|
|
(cd /opt/pi-toolkit && ./install.sh --yes) || \
|
|
echo "WARN: pi-toolkit install.sh failed (continuing)"
|
|
fi
|
|
|
|
if [ -d /opt/pi-extensions ]; then
|
|
(cd /opt/pi-extensions && ./install.sh --yes) || \
|
|
echo "WARN: pi-extensions install.sh failed (continuing)"
|
|
fi
|
|
|
|
# Bootstrap settings.json from template if absent (pi rewrites this
|
|
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
|
_pi_settings="$HOME/.pi/agent/settings.json"
|
|
_pi_template=/opt/pi-toolkit/settings.example.json
|
|
if [ ! -f "$_pi_settings" ] && [ -f "$_pi_template" ]; then
|
|
cp "$_pi_template" "$_pi_settings"
|
|
echo "pi settings.json bootstrapped from template"
|
|
elif [ -f "$_pi_settings" ] && [ -f "$_pi_template" ] && \
|
|
[ "${PI_SETTINGS_MERGE:-1}" != "0" ] && command -v jq >/dev/null 2>&1; then
|
|
# Non-destructive merge: a settings.json on a PRESERVED volume never
|
|
# otherwise sees new template keys (the bootstrap above only fires when
|
|
# the file is absent), so config added in an image upgrade — e.g. the
|
|
# observational-memory / pi-fork blocks or a newly-enabled model — never
|
|
# reaches existing users. Deep-merge with the template FIRST and the
|
|
# live file SECOND ('.[0] * .[1]') so the user's values always win and
|
|
# only keys MISSING from the live file are filled in from the template.
|
|
# Arrays are treated as leaves (the user's array is kept verbatim, so a
|
|
# model they deliberately removed is not re-added). Only rewrite when the
|
|
# merge actually changes something, and back up the original first.
|
|
# Set PI_SETTINGS_MERGE=0 to disable. Invalid JSON on either side → skip,
|
|
# never clobber.
|
|
if _pi_merged=$(jq -s '.[0] * .[1]' "$_pi_template" "$_pi_settings" 2>/dev/null); then
|
|
if [ -n "$_pi_merged" ] && \
|
|
! printf '%s' "$_pi_merged" | jq -e --slurpfile cur "$_pi_settings" '. == $cur[0]' >/dev/null 2>&1; then
|
|
cp "$_pi_settings" "${_pi_settings}.bak.$(date +%Y%m%d-%H%M%S)"
|
|
printf '%s\n' "$_pi_merged" > "$_pi_settings"
|
|
echo "pi settings.json: merged new template keys from settings.example.json (backup saved)"
|
|
fi
|
|
else
|
|
echo "WARN: pi settings.json merge skipped (jq could not parse template or live file; left untouched)"
|
|
fi
|
|
fi
|
|
|
|
# pi↔mempalace MCP bridge — single extension symlink.
|
|
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
|
|
command -v mempalace &>/dev/null && \
|
|
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
|
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
|
"$HOME/.pi/agent/extensions/mempalace.ts"
|
|
fi
|
|
|
|
# pi-fork (fork tool) + pi-observational-memory (recall tool) + pi-atelier
|
|
# (TUI sidebar panels/split-pane) + (in the :latest-studio variant only)
|
|
# pi-studio (/studio command + studio_* tools + theme). These are pi packages (not symlink-style extensions):
|
|
# they're cloned to /opt with node_modules baked at BUILD time, then
|
|
# registered here via `pi install <local-path>`. A local-path install is
|
|
# instant + in-place (pi loads the extension directly from /opt) +
|
|
# idempotent (no duplicate package entry on re-run), and stores a relative
|
|
# path that resolves into the image-layer /opt so it survives volume
|
|
# recreate. The tools/command register on the NEXT pi start (extensions
|
|
# bind at startup) or on `/reload`. Guard on settings.json so we only
|
|
# install once per volume. /opt/pi-studio is present only in the studio
|
|
# variant; the `[ -d ]` test makes this a no-op everywhere else.
|
|
#
|
|
# The guard MUST inspect the `packages` ARRAY, not merely grep the whole
|
|
# file for the package name. settings.example.json ships a top-level
|
|
# "pi-fork" CONFIG block (the fork effort profiles, pi-toolkit adb6907,
|
|
# 2026-06-17), so a whole-file substring grep matches on any settings.json
|
|
# that was bootstrapped from — or template-merged with — that template.
|
|
# Worse, the merge above runs FIRST, so it plants the matching string in the
|
|
# same startup that the loop then reads: `pi install /opt/pi-fork` was
|
|
# skipped forever and the `fork` tool never registered (v1.0.0 → v1.6.3).
|
|
# Its siblings escaped only by luck — the template key is
|
|
# "observational-memory" (no pi- prefix) and there is no studio block.
|
|
# jq reads the array; the grep fallback matches the stored relative-path
|
|
# form ("…/opt/<name>\""), which a config KEY can never produce.
|
|
_pi_pkg_registered() {
|
|
_pi_reg_settings="$HOME/.pi/agent/settings.json"
|
|
[ -f "$_pi_reg_settings" ] || return 1
|
|
if command -v jq >/dev/null 2>&1; then
|
|
jq -e --arg n "$1" \
|
|
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
|
"$_pi_reg_settings" >/dev/null 2>&1
|
|
else
|
|
grep -q "opt/$1\"" "$_pi_reg_settings"
|
|
fi
|
|
}
|
|
|
|
# ── pi-atelier: retire a stale `npm:pi-atelier`, plus an opt-out ──────
|
|
# The image now vendors pi-atelier at a pinned, audited tag (PI_ATELIER_REF
|
|
# in Dockerfile.variant). A leftover `npm:pi-atelier` entry from a
|
|
# hand-install resolves through ~/.pi/npm-global, which lives on the
|
|
# devbox-pi-config VOLUME — so it survives image upgrades and keeps whatever
|
|
# version was installed by hand, unpinned and unaudited. That is not
|
|
# academic: pi-atelier < 0.7.1 makes pi >= 0.84 hang at startup with
|
|
# sustained CPU, so leaving it in place turns a pi bump into a TUI that will
|
|
# not start. And `_pi_pkg_registered` deliberately counts `npm:<name>` as
|
|
# registered (it respects a user's own npm install), so the loop below would
|
|
# never replace it.
|
|
#
|
|
# We only DELETE the exact `npm:pi-atelier` string; the loop then registers
|
|
# /opt/pi-atelier in pi's own canonical serialization, so this code never has
|
|
# to guess the stored relative-path form. Idempotent — after the rewrite
|
|
# there is no npm entry left to match.
|
|
#
|
|
# DEVBOX_ATELIER=0 goes further and removes pi-atelier from `packages`
|
|
# altogether. That escape hatch lives HERE, in the entrypoint, precisely
|
|
# because this component's known failure mode is "pi will not start" — which
|
|
# you cannot repair with `pi uninstall`.
|
|
_pi_atelier_drop() {
|
|
# $1 = jq predicate over one `packages` entry, selecting what to REMOVE.
|
|
# Returns 0 only when the file was actually rewritten (caller logs), 1 for
|
|
# "nothing to do" — including missing jq or unparseable JSON, which must
|
|
# never clobber user settings. Backs up first, same convention as the
|
|
# template merge above.
|
|
_ad_settings="$HOME/.pi/agent/settings.json"
|
|
[ -f "$_ad_settings" ] || return 1
|
|
command -v jq >/dev/null 2>&1 || return 1
|
|
_ad_new=$(jq "(.packages // []) |= map(select(($1) | not))" "$_ad_settings" 2>/dev/null) || return 1
|
|
[ -n "$_ad_new" ] || return 1
|
|
if printf '%s' "$_ad_new" | jq -e --slurpfile cur "$_ad_settings" '. == $cur[0]' >/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
# `.bak.atelier.` rather than the merge's plain `.bak.` prefix: both can
|
|
# fire in the same startup, and a bare seconds-resolution timestamp would
|
|
# make the second cp overwrite the first one's backup.
|
|
cp "$_ad_settings" "${_ad_settings}.bak.atelier.$(date +%Y%m%d-%H%M%S)"
|
|
printf '%s\n' "$_ad_new" > "$_ad_settings"
|
|
return 0
|
|
}
|
|
if [ "${DEVBOX_ATELIER:-1}" = "0" ]; then
|
|
if _pi_atelier_drop '(. == "npm:pi-atelier") or ((type == "string") and endswith("/pi-atelier"))'; then
|
|
echo "pi-atelier: unregistered per DEVBOX_ATELIER=0 (settings backup saved)"
|
|
fi
|
|
elif [ -d /opt/pi-atelier ]; then
|
|
if _pi_atelier_drop '. == "npm:pi-atelier"'; then
|
|
echo "pi-atelier: dropped stale npm: registration — the pinned /opt copy takes over (settings backup saved)"
|
|
fi
|
|
fi
|
|
|
|
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio /opt/pi-atelier; do
|
|
[ -d "$_pkg" ] || continue
|
|
_name=$(basename "$_pkg")
|
|
# DEVBOX_ATELIER=0 → leave pi-atelier unregistered (handled just above).
|
|
if [ "$_name" = "pi-atelier" ] && [ "${DEVBOX_ATELIER:-1}" = "0" ]; then continue; fi
|
|
if ! _pi_pkg_registered "$_name"; then
|
|
pi install "$_pkg" >/dev/null 2>&1 || \
|
|
echo "WARN: pi install $_name failed (continuing)"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── pi-studio: optional loopback bridge (opt-in) ──────────────────────
|
|
# pi-studio binds its server to 127.0.0.1 inside the container, which a
|
|
# published Docker port cannot reach. When STUDIO_EXPOSE is truthy (set in
|
|
# compose), start the `studio-expose` socat bridge in the background so a
|
|
# published port + `ssh -L` tunnel can reach Studio once the user runs
|
|
# `/studio --port "$STUDIO_PORT"`. Default OFF — Studio stays loopback-only
|
|
# (its secure default) unless explicitly opted in. Guarded on the studio
|
|
# variant (/opt/pi-studio) so it is a no-op in the plain image.
|
|
case "${STUDIO_EXPOSE:-}" in
|
|
1|true|TRUE|yes|on)
|
|
if [ -d /opt/pi-studio ] && command -v studio-expose &>/dev/null && command -v socat &>/dev/null; then
|
|
echo "STUDIO_EXPOSE set — starting studio-expose bridge on port ${STUDIO_PORT:-8765} (background)"
|
|
nohup studio-expose "${STUDIO_PORT:-8765}" >/tmp/studio-expose.log 2>&1 &
|
|
else
|
|
echo "STUDIO_EXPOSE set but studio-expose/socat/pi-studio unavailable — skipping bridge"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
|
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
|
|
# run the deploy script to create relative symlinks for skills and instructions.
|
|
# This ensures skills resolve correctly inside the container regardless of
|
|
# where the repo lives on the host. Idempotent — second run is a no-op.
|
|
#
|
|
# Detection order:
|
|
# 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts)
|
|
# 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose)
|
|
# 3. /workspace/skillset (skillset is directly inside workspace root)
|
|
SKILLSET_DEPLOY=""
|
|
if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then
|
|
SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh"
|
|
elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then
|
|
SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh"
|
|
elif [ -x /workspace/skillset/deploy-skills.sh ]; then
|
|
SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh"
|
|
fi
|
|
if [ -n "$SKILLSET_DEPLOY" ]; then
|
|
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
# ── Execute command ──────────────────────────────────────────────────
|
|
exec "$@"
|