feat(mosaic): unified first-run UX wizard -> gateway install -> verify #418

Merged
jason.woltje merged 1 commits from feat/mosaic-first-run-ux into main 2026-04-05 07:29:18 +00:00
6 changed files with 471 additions and 20 deletions

View File

@@ -188,6 +188,16 @@ export function registerGatewayCommand(program: Command): void {
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
});
// ─── verify ─────────────────────────────────────────────────────────────
gw.command('verify')
.description('Verify the gateway installation (health, token, bootstrap endpoint)')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const { runVerify } = await import('./gateway/verify.js');
await runVerify(opts);
});
// ─── uninstall ──────────────────────────────────────────────────────────
gw.command('uninstall')

View File

@@ -1,6 +1,7 @@
import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { createInterface } from 'node:readline';
import type { GatewayMeta } from './daemon.js';
import {
@@ -21,6 +22,39 @@ import {
const MOSAIC_CONFIG_FILE = join(GATEWAY_HOME, 'mosaic.config.json');
// ─── Wizard session state (transient, CU-07-02) ──────────────────────────────
const INSTALL_STATE_FILE = join(
process.env['XDG_RUNTIME_DIR'] ?? process.env['TMPDIR'] ?? tmpdir(),
'mosaic-install-state.json',
);
interface InstallSessionState {
wizardCompletedAt: string;
mosaicHome: string;
}
function readInstallState(): InstallSessionState | null {
if (!existsSync(INSTALL_STATE_FILE)) return null;
try {
const raw = JSON.parse(readFileSync(INSTALL_STATE_FILE, 'utf-8')) as InstallSessionState;
// Only trust state that is < 10 minutes old
const age = Date.now() - new Date(raw.wizardCompletedAt).getTime();
if (age > 10 * 60 * 1000) return null;
return raw;
} catch {
return null;
}
}
function clearInstallState(): void {
try {
unlinkSync(INSTALL_STATE_FILE);
} catch {
// Ignore — file may already be gone
}
}
interface InstallOpts {
host: string;
port: number;
@@ -41,6 +75,30 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
}
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
// CU-07-02: Check for a fresh wizard session state and apply it.
const sessionState = readInstallState();
if (sessionState) {
const defaultHome = join(homedir(), '.config', 'mosaic');
const customHome = sessionState.mosaicHome !== defaultHome ? sessionState.mosaicHome : null;
if (customHome && !process.env['MOSAIC_GATEWAY_HOME']) {
// The wizard ran with a custom MOSAIC_HOME that differs from the default.
// GATEWAY_HOME is derived from MOSAIC_GATEWAY_HOME (or defaults to
// ~/.config/mosaic/gateway). Set the env var so the rest of this install
// inherits the correct location. This must be set before GATEWAY_HOME is
// evaluated by any imported helper — helpers that re-evaluate the path at
// call time will pick it up automatically.
process.env['MOSAIC_GATEWAY_HOME'] = join(customHome, 'gateway');
console.log(
`Resuming from wizard session — gateway home set to ${process.env['MOSAIC_GATEWAY_HOME']}\n`,
);
} else {
console.log(
`Resuming from wizard session — using ${sessionState.mosaicHome} from earlier.\n`,
);
}
}
const existing = readMeta();
const envExists = existsSync(ENV_FILE);
const mosaicConfigExists = existsSync(MOSAIC_CONFIG_FILE);
@@ -218,6 +276,13 @@ async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOp
console.log(` Config: ${GATEWAY_HOME}`);
console.log(` Logs: mosaic gateway logs`);
console.log(` Status: mosaic gateway status`);
// Step 7: Post-install verification (CU-07-03)
const { runPostInstallVerification } = await import('./verify.js');
await runPostInstallVerification(host, port);
// CU-07-02: Clear transient wizard session state on successful install.
clearInstallState();
}
async function runConfigWizard(

View File

@@ -0,0 +1,117 @@
import { readMeta } from './daemon.js';
// ANSI colour helpers (gracefully degrade when not a TTY)
const isTTY = Boolean(process.stdout.isTTY);
const G = isTTY ? '\x1b[0;32m' : '';
const R = isTTY ? '\x1b[0;31m' : '';
const BOLD = isTTY ? '\x1b[1m' : '';
const RESET = isTTY ? '\x1b[0m' : '';
function ok(label: string): void {
process.stdout.write(` ${G}${RESET} ${label.padEnd(36)}${G}[ok]${RESET}\n`);
}
function fail(label: string, hint: string): void {
process.stdout.write(` ${R}${RESET} ${label.padEnd(36)}${R}[FAIL]${RESET}\n`);
process.stdout.write(` ${R}${hint}${RESET}\n`);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithRetry(
url: string,
opts: RequestInit = {},
retries = 3,
delayMs = 1000,
): Promise<Response | null> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetch(url, opts);
// Retry on non-OK responses too — the gateway may still be starting up
// (e.g. 503 before the app bootstrap completes).
if (res.ok) return res;
} catch {
// Network-level error — not ready yet, will retry
}
if (attempt < retries - 1) await sleep(delayMs);
}
return null;
}
export interface VerifyResult {
gatewayHealthy: boolean;
adminTokenOnFile: boolean;
bootstrapReachable: boolean;
allPassed: boolean;
}
/**
* Run post-install verification checks.
*
* @param host - Gateway hostname (e.g. "localhost")
* @param port - Gateway port (e.g. 14242)
* @returns VerifyResult — callers can inspect individual flags
*/
export async function runPostInstallVerification(
host: string,
port: number,
): Promise<VerifyResult> {
const baseUrl = `http://${host}:${port.toString()}`;
console.log(`\n${BOLD}Mosaic installation verified:${RESET}`);
// ─── Check 1: Gateway /health ─────────────────────────────────────────────
const healthRes = await fetchWithRetry(`${baseUrl}/health`);
const gatewayHealthy = healthRes !== null && healthRes.ok;
if (gatewayHealthy) {
ok('gateway healthy');
} else {
fail('gateway healthy', 'Run: mosaic gateway status / mosaic gateway logs');
}
// ─── Check 2: Admin token on file ─────────────────────────────────────────
const meta = readMeta();
const adminTokenOnFile = Boolean(meta?.adminToken && meta.adminToken.length > 0);
if (adminTokenOnFile) {
ok('admin token on file');
} else {
fail('admin token on file', 'Run: mosaic gateway config recover-token');
}
// ─── Check 3: Bootstrap endpoint reachable ────────────────────────────────
const bootstrapRes = await fetchWithRetry(`${baseUrl}/api/bootstrap/status`);
const bootstrapReachable = bootstrapRes !== null && bootstrapRes.ok;
if (bootstrapReachable) {
ok('bootstrap endpoint reach');
} else {
fail('bootstrap endpoint reach', 'Run: mosaic gateway status / mosaic gateway logs');
}
const allPassed = gatewayHealthy && adminTokenOnFile && bootstrapReachable;
if (!allPassed) {
console.log(
`\n${R}One or more checks failed.${RESET} Recovery commands listed above.\n` +
`Use ${BOLD}mosaic gateway status${RESET} and ${BOLD}mosaic gateway config recover-token${RESET} to investigate.\n`,
);
}
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, allPassed };
}
/**
* Standalone entry point for `mosaic gateway verify`.
* Reads host/port from meta.json if not provided.
*/
export async function runVerify(opts: { host?: string; port?: number }): Promise<void> {
const meta = readMeta();
const host = opts.host ?? meta?.host ?? 'localhost';
const port = opts.port ?? meta?.port ?? 14242;
const result = await runPostInstallVerification(host, port);
if (!result.allPassed) {
process.exit(1);
}
}

View File

@@ -1,3 +1,6 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { WizardPrompter } from './prompter/interface.js';
import type { ConfigService } from './config/config-service.js';
import type { WizardState } from './types.js';
@@ -11,6 +14,25 @@ import { runtimeSetupStage } from './stages/runtime-setup.js';
import { skillsSelectStage } from './stages/skills-select.js';
import { finalizeStage } from './stages/finalize.js';
// ─── Transient install session state (CU-07-02) ───────────────────────────────
const INSTALL_STATE_FILE = join(
process.env['XDG_RUNTIME_DIR'] ?? process.env['TMPDIR'] ?? tmpdir(),
'mosaic-install-state.json',
);
function writeInstallState(mosaicHome: string): void {
try {
const state = {
wizardCompletedAt: new Date().toISOString(),
mosaicHome,
};
writeFileSync(INSTALL_STATE_FILE, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
} catch {
// Non-fatal — gateway install will just ask for home again
}
}
export interface WizardOptions {
mosaicHome: string;
sourceDir: string;
@@ -92,4 +114,8 @@ export async function runWizard(options: WizardOptions): Promise<void> {
// Stage 9: Finalize
await finalizeStage(prompter, state, configService);
// CU-07-02: Write transient session state so `mosaic gateway install` can
// pick up mosaicHome without re-prompting.
writeInstallState(state.mosaicHome);
}

184
tools/e2e-install-test.sh Executable file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env bash
# ─── Mosaic Stack — End-to-End Install Test ────────────────────────────────────
#
# Runs a clean-container install test to verify the full first-run flow:
# tools/install.sh -> mosaic wizard (non-interactive)
# -> mosaic gateway install
# -> mosaic gateway verify
#
# Usage:
# bash tools/e2e-install-test.sh
#
# Requirements:
# - Docker (skips gracefully if not available)
# - Run from the repository root
#
# How it works:
# 1. Mounts the repository into a node:22-alpine container.
# 2. Installs prerequisites (bash, curl, jq, git) inside the container.
# 3. Runs `bash tools/install.sh --yes --no-auto-launch` to install the
# framework and CLI from the Gitea registry.
# 4. Runs `mosaic wizard --non-interactive` to set up SOUL/USER.
# 5. Runs `mosaic gateway install` with piped defaults (non-interactive).
# 6. Runs `mosaic gateway verify` and checks its exit code.
# NOTE: `mosaic gateway verify` is a new command added in the
# feat/mosaic-first-run-ux branch. If the installed CLI version
# pre-dates this branch (does not have `gateway verify`), the test
# marks this step as EXPECTED-SKIP and reports the installed version.
# 7. Reports PASS or FAIL with a summary.
#
# To run manually:
# cd /path/to/mosaic-stack
# bash tools/e2e-install-test.sh
#
# ──────────────────────────────────────────────────────────────────────────────
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="node:22-alpine"
CONTAINER_NAME="mosaic-e2e-install-$$"
# ─── Colour helpers ───────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' BOLD=$'\033[1m' RESET=$'\033[0m'
else
R="" G="" Y="" BOLD="" RESET=""
fi
info() { echo "${BOLD}[e2e]${RESET} $*"; }
ok() { echo "${G}[PASS]${RESET} $*"; }
fail() { echo "${R}[FAIL]${RESET} $*" >&2; }
warn() { echo "${Y}[WARN]${RESET} $*"; }
# ─── Docker availability check ────────────────────────────────────────────────
if ! command -v docker &>/dev/null; then
warn "Docker not found — skipping e2e install test."
warn "Install Docker and re-run this script to exercise the full install flow."
exit 0
fi
if ! docker info &>/dev/null 2>&1; then
warn "Docker daemon is not running or not accessible — skipping e2e install test."
exit 0
fi
info "Docker available — proceeding with e2e install test."
info "Repo root: ${REPO_ROOT}"
info "Container image: ${IMAGE}"
# ─── Inline script that runs INSIDE the container ────────────────────────────
INNER_SCRIPT="$(mktemp /tmp/mosaic-e2e-inner-XXXXXX.sh)"
trap 'rm -f "$INNER_SCRIPT"' EXIT
cat > "$INNER_SCRIPT" <<'INNER_SCRIPT_EOF'
#!/bin/sh
# Bootstrap: /bin/sh until bash is installed, then re-exec.
set -e
echo "=== [inner] Installing system prerequisites ==="
apk add --no-cache bash curl jq git 2>/dev/null || \
apt-get install -y -q bash curl jq git 2>/dev/null || true
# Re-exec under bash.
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1; then
exec bash "$0" "$@"
fi
# ── bash from here ────────────────────────────────────────────────────────────
set -euo pipefail
echo "=== [inner] Node.js / npm versions ==="
node --version
npm --version
echo "=== [inner] Setting up npm global prefix ==="
export NPM_PREFIX="/root/.npm-global"
mkdir -p "$NPM_PREFIX/bin"
npm config set prefix "$NPM_PREFIX" 2>/dev/null || true
export PATH="$NPM_PREFIX/bin:$PATH"
echo "=== [inner] Running install.sh --yes --no-auto-launch ==="
# Install both framework and CLI from the Gitea registry.
MOSAIC_SKIP_SKILLS_SYNC=1 \
MOSAIC_ASSUME_YES=1 \
bash /repo/tools/install.sh --yes --no-auto-launch
INSTALLED_VERSION="$(mosaic --version 2>/dev/null || echo 'unknown')"
echo "[inner] mosaic CLI installed: ${INSTALLED_VERSION}"
echo "=== [inner] Running mosaic wizard (non-interactive) ==="
mosaic wizard \
--non-interactive \
--name "test-agent" \
--user-name "tester" \
--pronouns "they/them" \
--timezone "UTC" || {
echo "[WARN] mosaic wizard exited non-zero — continuing"
}
echo "=== [inner] Running mosaic gateway install ==="
# Feed non-interactive answers:
# "1" → storage tier: local
# "" → port: accept default (14242)
# "" → ANTHROPIC_API_KEY: skip
# "" → CORS origin: accept default
# Then admin bootstrap: name, email, password
printf '1\n\n\n\nTest Admin\ntest@example.com\ntestpassword123\n' \
| mosaic gateway install
INSTALL_EXIT="$?"
if [ "${INSTALL_EXIT}" -ne 0 ]; then
echo "[ERR] mosaic gateway install exited ${INSTALL_EXIT}"
mosaic gateway status 2>/dev/null || true
exit "${INSTALL_EXIT}"
fi
echo "=== [inner] Running mosaic gateway verify ==="
# `gateway verify` was added in feat/mosaic-first-run-ux.
# If the installed version pre-dates this, skip gracefully.
if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
echo "[SKIP] Re-run after the new version is published to validate this step."
# Treat as pass — the install flow itself worked.
exit 0
fi
mosaic gateway verify
VERIFY_EXIT="$?"
echo "=== [inner] verify exit code: ${VERIFY_EXIT} ==="
exit "${VERIFY_EXIT}"
INNER_SCRIPT_EOF
chmod +x "$INNER_SCRIPT"
# ─── Pull image ───────────────────────────────────────────────────────────────
info "Pulling ${IMAGE}"
docker pull "${IMAGE}" --quiet
# ─── Run container ────────────────────────────────────────────────────────────
info "Starting container ${CONTAINER_NAME}"
EXIT_CODE=0
docker run --rm \
--name "${CONTAINER_NAME}" \
--volume "${REPO_ROOT}:/repo:ro" \
--volume "${INNER_SCRIPT}:/e2e-inner.sh:ro" \
--network host \
"${IMAGE}" \
/bin/sh /e2e-inner.sh \
|| EXIT_CODE=$?
# ─── Report ───────────────────────────────────────────────────────────────────
echo ""
if [[ "$EXIT_CODE" -eq 0 ]]; then
ok "End-to-end install test PASSED (exit ${EXIT_CODE})"
else
fail "End-to-end install test FAILED (exit ${EXIT_CODE})"
echo ""
echo " Troubleshooting:"
echo " - Review the output above for the failing step."
echo " - Re-run with bash -x tools/e2e-install-test.sh for verbose trace."
echo " - Run mosaic gateway logs inside a manual container for daemon output."
exit 1
fi

View File

@@ -12,18 +12,21 @@
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh | bash -s --
#
# Flags:
# --check Version check only, no install
# --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)
# --check Version check only, no install
# --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)
# --yes Accept all defaults; headless/non-interactive install
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
#
# Environment:
# MOSAIC_HOME — framework install dir (default: ~/.config/mosaic)
# MOSAIC_REGISTRY — npm registry URL (default: Gitea instance)
# MOSAIC_SCOPE — npm scope (default: @mosaicstack)
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_HOME — framework install dir (default: ~/.config/mosaic)
# MOSAIC_REGISTRY — npm registry URL (default: Gitea instance)
# MOSAIC_SCOPE — npm scope (default: @mosaicstack)
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ──────────────────────────────────────────────────────────────────────────────
#
# Wrapped in main() for safe curl-pipe usage.
@@ -36,15 +39,24 @@ main() {
FLAG_CHECK=false
FLAG_FRAMEWORK=true
FLAG_CLI=true
FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false
GIT_REF="${MOSAIC_REF:-main}"
# MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
FLAG_YES=true
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; shift 2 ;;
*) shift ;;
--check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; shift 2 ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
*) shift ;;
esac
done
@@ -301,12 +313,49 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
dim " Framework data: $MOSAIC_HOME/"
echo ""
# First install guidance
# First install guidance / auto-launch
if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
echo ""
info "First install detected. Set up your agent identity:"
echo " ${C}mosaic init${RESET} (interactive SOUL.md / USER.md setup)"
echo " ${C}mosaic wizard${RESET} (full guided wizard via Node.js)"
if [[ "$FLAG_NO_AUTO_LAUNCH" == "false" ]] && [[ -t 0 ]] && [[ -t 1 ]]; then
# Interactive TTY and auto-launch not suppressed: run wizard + gateway install
info "First install detected — launching setup wizard…"
echo ""
MOSAIC_BIN="$PREFIX/bin/mosaic"
if ! command -v "$MOSAIC_BIN" &>/dev/null && ! command -v mosaic &>/dev/null; then
warn "mosaic binary not found on PATH — skipping auto-launch."
warn "Add $PREFIX/bin to PATH and run: mosaic wizard && mosaic gateway install"
else
# Prefer the absolute path from the prefix we just installed to
MOSAIC_CMD="mosaic"
if [[ -x "$MOSAIC_BIN" ]]; then
MOSAIC_CMD="$MOSAIC_BIN"
fi
# Run wizard; if it fails we still try gateway install (best effort)
if "$MOSAIC_CMD" wizard; then
ok "Wizard complete."
else
warn "Wizard exited non-zero — continuing to gateway install."
fi
echo ""
info "Launching gateway install…"
if "$MOSAIC_CMD" gateway install; then
ok "Gateway install complete."
else
warn "Gateway install exited non-zero."
echo " You can retry with: ${C}mosaic gateway install${RESET}"
fi
fi
else
# Non-interactive or --no-auto-launch: print guidance only
info "First install detected. Set up your agent identity:"
echo " ${C}mosaic init${RESET} (interactive SOUL.md / USER.md setup)"
echo " ${C}mosaic wizard${RESET} (full guided wizard via Node.js)"
echo " ${C}mosaic gateway install${RESET} (install and start the gateway)"
fi
fi
echo ""