feat(installer): prefer npm next lane (#688)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

--next now prefers a fast npm @next install (CLI + gateway from the Gitea registry) and falls back to source build at next if the dist-tag is unavailable. Registry lane gated to non-dev, non-explicit-ref next installs; CLI/gateway prerelease versions must share a pipeline suffix. Adds tools/install-next-lane.test.sh (wired into CI). PR-event CI 1635 fully green + review-of-record APPROVE (functional install test, head 2fd7cfc3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #688.
This commit is contained in:
2026-06-25 07:14:24 +00:00
parent c25a551c28
commit 940ae3cc41
7 changed files with 455 additions and 39 deletions

222
tools/install-next-lane.test.sh Executable file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
FAKE_BIN="$TMP/bin"
HOME_DIR="$TMP/home"
PREFIX="$TMP/prefix"
MOSAIC_HOME="$TMP/mosaic"
STATE="$TMP/state"
LOG="$TMP/npm.log"
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
STATE="${MOSAIC_TEST_STATE:?}"
echo "$*" >> "$LOG"
if [[ "$1" == "view" ]]; then
case "$2 $3" in
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
"@mosaicstack/mosaic version") echo "0.0.48" ;;
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "install" ]]; then
case "$*" in
*"@mosaicstack/mosaic@0.0.49-next.999"*)
echo "0.0.49-next.999" > "$STATE/mosaic"
;;
*"@mosaicstack/gateway@0.0.7-next.999"*)
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
echo "forced gateway install failure" >&2
exit 1
fi
echo "0.0.7-next.999" > "$STATE/gateway"
;;
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/mosaic"
;;
*"mosaicstack-gateway-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/gateway"
;;
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "ls" ]]; then
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
node -e '
const cli = process.argv[1];
const gateway = process.argv[2];
const dependencies = {};
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
process.stdout.write(JSON.stringify({ dependencies }));
' "$cli" "$gateway"
exit 0
fi
echo "unexpected npm command: $*" >&2
exit 1
FAKE_NPM
chmod +x "$FAKE_BIN/npm"
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
#!/usr/bin/env bash
set -euo pipefail
# The fake tar creates the source tree; curl only needs to keep the pipe alive.
exit 0
FAKE_CURL
chmod +x "$FAKE_BIN/curl"
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
#!/usr/bin/env bash
set -euo pipefail
dest=""
while [[ $# -gt 0 ]]; do
case "$1" in
-C) dest="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$dest" ]]; then
echo "fake tar missing -C destination" >&2
exit 1
fi
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
FAKE_TAR
chmod +x "$FAKE_BIN/tar"
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
echo "pnpm $*" >> "$LOG"
if [[ "$1" == "pack" ]]; then
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--pack-destination) out="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$out" ]]; then
echo "fake pnpm pack missing destination" >&2
exit 1
fi
mkdir -p "$out"
case "$PWD" in
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
esac
exit 0
fi
# install/build commands are no-ops in this harness.
exit 0
FAKE_PNPM
chmod +x "$FAKE_BIN/pnpm"
reset_state() {
: > "$LOG"
rm -f "$STATE"/*
}
reset_state
echo "[test] --next fast path pins resolved package versions"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/gateway@0.0.7-next.999' "$LOG"
grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG"
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
echo "expected exact-version installs, found mutable @next install" >&2
exit 1
fi
if grep -qF 'Downloading source from next' <<<"$OUTPUT"; then
echo "fast path unexpectedly fell back to source" >&2
exit 1
fi
reset_state
echo "[test] fast path failure falls back to source build"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
grep -qF 'Downloading source from next' <<<"$OUTPUT"
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG"
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
reset_state
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
)"
grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT"
if grep -qF '@next version' "$LOG"; then
echo "explicit ref should not query @next dist-tags" >&2
exit 1
fi
reset_state
echo "[test] --check --next warns on mismatched prerelease pipeline suffixes"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next
)"
grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT"
echo "[test] installer next lane tests passed"

View File

@@ -16,9 +16,10 @@
# --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main)
# --next Prerelease lane: build CLI + gateway FROM SOURCE at the
# permanent next integration branch. Shorthand for --dev
# with ref=next; explicit --ref/MOSAIC_REF wins.
# --next Prerelease lane: try fast npm @next install for CLI +
# gateway from the Gitea registry, then fall back to a
# source build at next if unavailable. Explicit
# --ref/MOSAIC_REF wins and uses the source path.
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the
# registry @latest. Zero registry writes — packs local
# tarballs and installs them globally. Use to test a branch
@@ -70,10 +71,10 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true
fi
# MOSAIC_NEXT env var acts the same as --next: source build from the
# permanent next integration branch unless MOSAIC_REF/--ref explicitly wins.
# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
# source fallback from the permanent next integration branch unless
# MOSAIC_REF/--ref explicitly wins.
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
FLAG_DEV=true
FLAG_NEXT=true
if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
GIT_REF="next"
@@ -87,7 +88,7 @@ while [[ $# -gt 0 ]]; do
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; GIT_REF_EXPLICIT=true; shift 2 ;;
--dev) FLAG_DEV=true; shift ;;
--next) FLAG_DEV=true; FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
@@ -95,6 +96,13 @@ while [[ $# -gt 0 ]]; do
esac
done
# Explicit refs represent a request for that exact source tree. Keep --next as
# a lane selector, but do not install the registry @next package for a different
# ref than the permanent next branch.
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
FLAG_DEV=true
fi
if [[ "$FLAG_YES" == "true" ]]; then
export MOSAIC_ASSUME_YES=1
fi
@@ -105,6 +113,7 @@ REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstac
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/mosaic"
GATEWAY_PKG="${SCOPE}/gateway"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
@@ -252,9 +261,15 @@ fail() { echo "${R}✖${RESET} $*" >&2; }
dim() { echo "${DIM}$*${RESET}"; }
step() { echo ""; echo "${BOLD}$*${RESET}"; }
is_next_registry_lane() {
[[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
}
source_ref_details() {
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
if is_next_registry_lane; then
echo "ref: next, --next prerelease lane"
elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
echo "ref: next, --next prerelease lane (build-from-source)"
elif [[ "$FLAG_NEXT" == "true" ]]; then
echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
else
@@ -284,10 +299,43 @@ installed_cli_version() {
fi
}
installed_gateway_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" 2>/dev/null || true
fi
}
latest_cli_version() {
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
}
next_cli_version() {
npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_gateway_version() {
npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_pipeline_suffix() {
printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
}
next_versions_share_pipeline() {
local cli_next="$1"
local gateway_next="$2"
local cli_pipeline gateway_pipeline
cli_pipeline="$(next_pipeline_suffix "$cli_next")"
gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
[[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
}
version_lt() {
node -e "
const a=process.argv[1], b=process.argv[2];
@@ -403,6 +451,49 @@ install_cli_from_source() {
ok "Installed from source: CLI $(installed_cli_version)"
}
install_next_cli_from_registry() {
local cli_next gateway_next
cli_next="$(next_cli_version)"
gateway_next="$(next_gateway_version)"
if [[ -z "$cli_next" ]]; then
warn "${CLI_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if [[ -z "$gateway_next" ]]; then
warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
return 1
fi
info "Installing ${CLI_PKG}@${cli_next} from registry…"
if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast CLI @next install failed."
return 1
fi
info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast gateway @next install failed."
return 1
fi
local installed_cli installed_gateway
installed_cli="$(installed_cli_version)"
installed_gateway="$(installed_gateway_version)"
if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
return 1
fi
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
}
# ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node
@@ -503,8 +594,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi
CURRENT="$(installed_cli_version)"
NEXT_GATEWAY=""
if [[ "$FLAG_DEV" == "true" ]]; then
LATEST=""
elif is_next_registry_lane; then
LATEST="$(next_cli_version)"
NEXT_GATEWAY="$(next_gateway_version)"
else
LATEST="$(latest_cli_version)"
fi
@@ -517,6 +612,18 @@ if [[ "$FLAG_CLI" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then
dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)"
elif is_next_registry_lane; then
if [[ -n "$LATEST" ]]; then
dim " Next CLI: ${CLI_PKG}@${LATEST}"
else
dim " Next CLI: (registry @next unreachable)"
fi
if [[ -n "$NEXT_GATEWAY" ]]; then
dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}"
else
dim " Next GW: (registry @next unreachable)"
fi
dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)"
elif [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}"
else
@@ -527,6 +634,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif is_next_registry_lane; then
if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
else
warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
fi
elif [[ -z "$LATEST" ]]; then
warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then
@@ -543,6 +656,23 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ensure_monorepo
install_cli_from_source
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}"
if install_next_cli_from_registry; then
:
else
warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
ensure_monorepo
install_cli_from_source
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"