diff --git a/packages/mosaic/src/commands/gateway.ts b/packages/mosaic/src/commands/gateway.ts index 92fc9ef..870292b 100644 --- a/packages/mosaic/src/commands/gateway.ts +++ b/packages/mosaic/src/commands/gateway.ts @@ -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') diff --git a/packages/mosaic/src/commands/gateway/install.ts b/packages/mosaic/src/commands/gateway/install.ts index c63307d..60050ab 100644 --- a/packages/mosaic/src/commands/gateway/install.ts +++ b/packages/mosaic/src/commands/gateway/install.ts @@ -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 { } async function doInstall(rl: ReturnType, opts: InstallOpts): Promise { + // 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, 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( diff --git a/packages/mosaic/src/commands/gateway/verify.ts b/packages/mosaic/src/commands/gateway/verify.ts new file mode 100644 index 0000000..e001bb2 --- /dev/null +++ b/packages/mosaic/src/commands/gateway/verify.ts @@ -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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchWithRetry( + url: string, + opts: RequestInit = {}, + retries = 3, + delayMs = 1000, +): Promise { + 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 { + 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 { + 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); + } +} diff --git a/packages/mosaic/src/wizard.ts b/packages/mosaic/src/wizard.ts index b5af0e4..3dc69c8 100644 --- a/packages/mosaic/src/wizard.ts +++ b/packages/mosaic/src/wizard.ts @@ -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 { // 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); } diff --git a/tools/e2e-install-test.sh b/tools/e2e-install-test.sh new file mode 100755 index 0000000..672103b --- /dev/null +++ b/tools/e2e-install-test.sh @@ -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 diff --git a/tools/install.sh b/tools/install.sh index 2aaaacf..4687d8c 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -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 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 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 ""