From 45f5b9062e8dba91216359e1d14a6479566981f5 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 1 Apr 2026 21:03:23 -0500 Subject: [PATCH] feat: install.sh + auto-update checker for CLI - tools/install.sh: standalone installer/upgrader, curl-pipe safe (main() wrapper, process.argv instead of stdin, mkdir -p prefix) - packages/mosaic/src/runtime/update-checker.ts: version check module with 1h cache at ~/.cache/mosaic/update-check.json - CLI startup: non-blocking background update check on every invocation - 'mosaic update' command: explicit check + install (--check for CI) - session-start.sh: warns agents when CLI is outdated - Proper semver comparison including pre-release precedence - eslint: allow __tests__ in packages/mosaic for projectService --- eslint.config.mjs | 1 + packages/cli/src/cli.ts | 54 ++++ .../mosaic/__tests__/update-checker.test.ts | 52 ++++ packages/mosaic/src/index.ts | 9 + packages/mosaic/src/runtime/update-checker.ts | 238 ++++++++++++++++++ scripts/agent/session-start.sh | 12 + tools/install.sh | 218 ++++++++++++++++ 7 files changed, 584 insertions(+) create mode 100644 packages/mosaic/__tests__/update-checker.test.ts create mode 100644 packages/mosaic/src/runtime/update-checker.ts create mode 100755 tools/install.sh diff --git a/eslint.config.mjs b/eslint.config.mjs index 1a2c789..f105948 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -27,6 +27,7 @@ export default tseslint.config( 'apps/web/e2e/*.ts', 'apps/web/e2e/helpers/*.ts', 'apps/web/playwright.config.ts', + 'packages/mosaic/__tests__/*.ts', ], }, }, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f54c846..21f6b94 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -10,6 +10,14 @@ import { registerPrdyCommand } from './commands/prdy.js'; const _require = createRequire(import.meta.url); const CLI_VERSION: string = (_require('../package.json') as { version: string }).version; +// Fire-and-forget update check at startup (non-blocking, cached 1h) +try { + const { backgroundUpdateCheck } = await import('@mosaic/mosaic'); + backgroundUpdateCheck(); +} catch { + // Silently ignore — update check is best-effort +} + const program = new Command(); program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION); @@ -297,6 +305,52 @@ if (qrCmd !== undefined) { program.addCommand(qrCmd as unknown as Command); } +// ─── update ───────────────────────────────────────────────────────────── + +program + .command('update') + .description('Check for and install Mosaic CLI updates') + .option('--check', 'Check only, do not install') + .action(async (opts: { check?: boolean }) => { + const { checkForUpdate, formatUpdateNotice } = await import('@mosaic/mosaic'); + const { execSync } = await import('node:child_process'); + + console.log('Checking for updates…'); + const result = checkForUpdate({ skipCache: true }); + + if (!result.latest) { + console.error('Could not reach the Mosaic registry.'); + process.exit(1); + } + + console.log(` Installed: ${result.current || '(none)'}`); + console.log(` Latest: ${result.latest}`); + + if (!result.updateAvailable) { + console.log('\n✔ Up to date.'); + return; + } + + const notice = formatUpdateNotice(result); + if (notice) console.log(notice); + + if (opts.check) { + process.exit(2); // Signal to callers that an update exists + } + + console.log('Installing update…'); + try { + execSync( + 'npm install -g @mosaic/cli@latest --registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/', + { stdio: 'inherit', timeout: 60_000 }, + ); + console.log('\n✔ Updated successfully.'); + } catch { + console.error('\nUpdate failed. Try manually: bash tools/install.sh'); + process.exit(1); + } + }); + // ─── wizard ───────────────────────────────────────────────────────────── program diff --git a/packages/mosaic/__tests__/update-checker.test.ts b/packages/mosaic/__tests__/update-checker.test.ts new file mode 100644 index 0000000..1bf25e2 --- /dev/null +++ b/packages/mosaic/__tests__/update-checker.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { semverLt, formatUpdateNotice } from '../src/runtime/update-checker.js'; +import type { UpdateCheckResult } from '../src/runtime/update-checker.js'; + +describe('semverLt', () => { + it('returns true when a < b', () => { + expect(semverLt('0.0.1', '0.0.2')).toBe(true); + expect(semverLt('0.1.0', '0.2.0')).toBe(true); + expect(semverLt('1.0.0', '2.0.0')).toBe(true); + expect(semverLt('0.0.1-alpha.1', '0.0.1-alpha.2')).toBe(true); + expect(semverLt('0.0.1-alpha.1', '0.0.1')).toBe(true); + }); + + it('returns false when a >= b', () => { + expect(semverLt('0.0.2', '0.0.1')).toBe(false); + expect(semverLt('1.0.0', '1.0.0')).toBe(false); + expect(semverLt('2.0.0', '1.0.0')).toBe(false); + }); + + it('returns false for empty strings', () => { + expect(semverLt('', '1.0.0')).toBe(false); + expect(semverLt('1.0.0', '')).toBe(false); + expect(semverLt('', '')).toBe(false); + }); +}); + +describe('formatUpdateNotice', () => { + it('returns empty string when up to date', () => { + const result: UpdateCheckResult = { + current: '1.0.0', + latest: '1.0.0', + updateAvailable: false, + checkedAt: new Date().toISOString(), + registry: 'https://example.com', + }; + expect(formatUpdateNotice(result)).toBe(''); + }); + + it('returns a notice when update is available', () => { + const result: UpdateCheckResult = { + current: '0.0.1', + latest: '0.1.0', + updateAvailable: true, + checkedAt: new Date().toISOString(), + registry: 'https://example.com', + }; + const notice = formatUpdateNotice(result); + expect(notice).toContain('0.0.1'); + expect(notice).toContain('0.1.0'); + expect(notice).toContain('Update available'); + }); +}); diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index 2a82f62..f228583 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -13,6 +13,15 @@ import type { CommunicationStyle } from './types.js'; export { VERSION, DEFAULT_MOSAIC_HOME }; export { runWizard } from './wizard.js'; +export { + checkForUpdate, + backgroundUpdateCheck, + formatUpdateNotice, + getInstalledVersion, + getLatestVersion, + semverLt, +} from './runtime/update-checker.js'; +export type { UpdateCheckResult } from './runtime/update-checker.js'; export { ClackPrompter } from './prompter/clack-prompter.js'; export { HeadlessPrompter } from './prompter/headless-prompter.js'; export { createConfigService } from './config/config-service.js'; diff --git a/packages/mosaic/src/runtime/update-checker.ts b/packages/mosaic/src/runtime/update-checker.ts new file mode 100644 index 0000000..0d9878e --- /dev/null +++ b/packages/mosaic/src/runtime/update-checker.ts @@ -0,0 +1,238 @@ +/** + * Mosaic update checker — compares installed @mosaic/cli version against the + * Gitea npm registry and reports when an upgrade is available. + * + * Used by: + * - CLI startup (non-blocking background check) + * - session-start.sh (via `mosaic update --check`) + * - install.sh (direct invocation) + * + * Design: + * - Result is cached to ~/.cache/mosaic/update-check.json for 1 hour + * - Network call is fire-and-forget with a 5 s timeout + * - Never throws on failure — returns stale/unknown result + */ + +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface UpdateCheckResult { + /** Currently installed version (empty if not found) */ + current: string; + /** Latest published version (empty if check failed) */ + latest: string; + /** True when a newer version is available */ + updateAvailable: boolean; + /** ISO timestamp of this check */ + checkedAt: string; + /** Where the latest version was resolved from */ + registry: string; +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/'; +const CLI_PKG = '@mosaic/cli'; +const CACHE_DIR = join(homedir(), '.cache', 'mosaic'); +const CACHE_FILE = join(CACHE_DIR, 'update-check.json'); +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour +const NETWORK_TIMEOUT_MS = 5_000; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string { + try { + return execSync(`npm ${args}`, { + encoding: 'utf-8', + timeout: timeoutMs, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +/** + * Rudimentary semver "a < b" — handles pre-release tags. + * Per semver spec: 1.0.0-alpha.1 < 1.0.0 (pre-release has lower precedence). + */ +export function semverLt(a: string, b: string): boolean { + if (!a || !b) return false; + + const stripped = (v: string) => v.replace(/^v/, ''); + const splitPre = (v: string): [string, string | null] => { + const idx = v.indexOf('-'); + return idx === -1 ? [v, null] : [v.slice(0, idx), v.slice(idx + 1)]; + }; + + const sa = stripped(a); + const sb = stripped(b); + const [coreA, preA] = splitPre(sa); + const [coreB, preB] = splitPre(sb); + + // Compare core version (major.minor.patch) + const partsA = coreA.split('.').map(Number); + const partsB = coreB.split('.').map(Number); + const len = Math.max(partsA.length, partsB.length); + + for (let i = 0; i < len; i++) { + const va = partsA[i] ?? 0; + const vb = partsB[i] ?? 0; + if (va < vb) return true; + if (va > vb) return false; + } + + // Core versions are equal — compare pre-release + // No pre-release > any pre-release (1.0.0 > 1.0.0-alpha) + if (preA !== null && preB === null) return true; + if (preA === null && preB !== null) return false; + if (preA === null && preB === null) return false; + + // Both have pre-release — compare dot-separated identifiers + const idsA = preA!.split('.'); + const idsB = preB!.split('.'); + const preLen = Math.max(idsA.length, idsB.length); + + for (let i = 0; i < preLen; i++) { + const ia = idsA[i]; + const ib = idsB[i]; + // Fewer fields = lower precedence + if (ia === undefined && ib !== undefined) return true; + if (ia !== undefined && ib === undefined) return false; + const na = /^\d+$/.test(ia!) ? parseInt(ia!, 10) : null; + const nb = /^\d+$/.test(ib!) ? parseInt(ib!, 10) : null; + // Numeric vs string: numeric < string + if (na !== null && nb !== null) { + if (na < nb) return true; + if (na > nb) return false; + continue; + } + if (na !== null && nb === null) return true; + if (na === null && nb !== null) return false; + // Both strings + if (ia! < ib!) return true; + if (ia! > ib!) return false; + } + return false; +} + +// ─── Cache ────────────────────────────────────────────────────────────────── + +function readCache(): UpdateCheckResult | null { + try { + if (!existsSync(CACHE_FILE)) return null; + const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as UpdateCheckResult; + const age = Date.now() - new Date(raw.checkedAt).getTime(); + if (age > CACHE_TTL_MS) return null; + return raw; + } catch { + return null; + } +} + +function writeCache(result: UpdateCheckResult): void { + try { + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync(CACHE_FILE, JSON.stringify(result, null, 2) + '\n', 'utf-8'); + } catch { + // Best-effort — cache is not critical + } +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Get the currently installed version of @mosaic/cli. + * Returns empty string if not installed. + */ +export function getInstalledVersion(): string { + // Fast path: check via package.json require chain + try { + const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000); + if (raw) { + const data = JSON.parse(raw) as { + dependencies?: Record; + }; + return data?.dependencies?.[CLI_PKG]?.version ?? ''; + } + } catch { + // fall through + } + return ''; +} + +/** + * Fetch the latest published version from the Gitea npm registry. + * Returns empty string on failure. + */ +export function getLatestVersion(): string { + return npmExec(`view ${CLI_PKG} version --registry=${REGISTRY}`); +} + +/** + * Perform an update check — uses cache when fresh, otherwise hits registry. + * Never throws. + */ +export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult { + if (!options?.skipCache) { + const cached = readCache(); + if (cached) return cached; + } + + const current = getInstalledVersion(); + const latest = getLatestVersion(); + const updateAvailable = !!(current && latest && semverLt(current, latest)); + + const result: UpdateCheckResult = { + current, + latest, + updateAvailable, + checkedAt: new Date().toISOString(), + registry: REGISTRY, + }; + + writeCache(result); + return result; +} + +/** + * Format a human-readable update notice. Returns empty string if up-to-date. + */ +export function formatUpdateNotice(result: UpdateCheckResult): string { + if (!result.updateAvailable) return ''; + + const lines = [ + '', + '╭─────────────────────────────────────────────────╮', + '│ │', + `│ Update available: ${result.current} → ${result.latest}`.padEnd(50) + '│', + '│ │', + '│ Run: bash tools/install.sh │', + '│ Or: npm i -g @mosaic/cli@latest │', + '│ │', + '╰─────────────────────────────────────────────────╯', + '', + ]; + return lines.join('\n'); +} + +/** + * Non-blocking update check that prints a notice to stderr if an update + * is available. Designed to be called at CLI startup without delaying + * the user. + */ +export function backgroundUpdateCheck(): void { + try { + const result = checkForUpdate(); + const notice = formatUpdateNotice(result); + if (notice) { + process.stderr.write(notice); + } + } catch { + // Silently ignore — never block the user + } +} diff --git a/scripts/agent/session-start.sh b/scripts/agent/session-start.sh index 613eaad..4753558 100755 --- a/scripts/agent/session-start.sh +++ b/scripts/agent/session-start.sh @@ -8,6 +8,18 @@ source "$SCRIPT_DIR/common.sh" ensure_repo_root load_repo_hooks +# ─── Update check (non-blocking) ──────────────────────────────────────────── +if command -v mosaic &>/dev/null; then + if mosaic update --check 2>/dev/null; then + : # up to date + elif [[ $? -eq 2 ]]; then + echo "" + echo "[agent-framework] ⚠ A newer version of Mosaic CLI is available." + echo "[agent-framework] Run: mosaic update or bash tools/install.sh" + echo "" + fi +fi + if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && has_remote; then if git diff --quiet && git diff --cached --quiet; then run_step "Pull latest changes" git pull --rebase diff --git a/tools/install.sh b/tools/install.sh new file mode 100755 index 0000000..2fd8d2e --- /dev/null +++ b/tools/install.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# ─── Mosaic Stack Installer / Upgrader ──────────────────────────────────────── +# +# Remote install (recommended): +# bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh) +# +# Remote install (alternative — use -s -- to pass flags): +# curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh | bash -s -- +# curl -fsSL ... | bash -s -- --check +# +# Local (from repo checkout): +# bash tools/install.sh +# bash tools/install.sh --check # version check only +# +# Environment: +# MOSAIC_REGISTRY — npm registry URL (default: Gitea instance) +# MOSAIC_SCOPE — npm scope (default: @mosaic) +# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global) +# MOSAIC_NO_COLOR — disable colour (set to 1) +# ────────────────────────────────────────────────────────────────────────────── +# +# The entire script is wrapped in main() so that bash reads it fully into +# memory before execution — required for safe curl-pipe usage. + +set -euo pipefail + +main() { + +# ─── constants ──────────────────────────────────────────────────────────────── +REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaic/npm/}" +SCOPE="${MOSAIC_SCOPE:-@mosaic}" +PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}" +CLI_PKG="${SCOPE}/cli" + +# ─── colours ────────────────────────────────────────────────────────────────── +# Detect colour support: works in terminals and bash <(curl …), but correctly +# disables when piped through a non-tty (curl … | bash). +if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then + R="" G="" Y="" B="" C="" DIM="" RESET="" +else + R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' + B=$'\033[0;34m' C=$'\033[0;36m' DIM=$'\033[2m' RESET=$'\033[0m' +fi + +info() { echo "${B}ℹ${RESET} $*"; } +ok() { echo "${G}✔${RESET} $*"; } +warn() { echo "${Y}⚠${RESET} $*"; } +fail() { echo "${R}✖${RESET} $*" >&2; } +dim() { echo "${DIM}$*${RESET}"; } + +# ─── helpers ────────────────────────────────────────────────────────────────── + +require_cmd() { + if ! command -v "$1" &>/dev/null; then + fail "Required command not found: $1" + echo " Install it and re-run this script." + exit 1 + fi +} + +# Get the installed version of @mosaic/cli (empty string if not installed) +installed_version() { + local json + json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true + if [[ -n "$json" ]]; then + # Feed json via heredoc, not stdin pipe — safe in curl-pipe context + node -e " + const d = JSON.parse(process.argv[1]); + const v = d?.dependencies?.['${CLI_PKG}']?.version ?? ''; + process.stdout.write(v); + " "$json" 2>/dev/null || true + fi +} + +# Get the latest published version from the registry +latest_version() { + npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true +} + +# Compare two semver strings: returns 0 (true) if $1 < $2 +version_lt() { + # Semver-aware comparison via node (handles pre-release correctly) + node -e " + const a=process.argv[1], b=process.argv[2]; + const sp = v => { const i=v.indexOf('-'); return i===-1 ? [v,null] : [v.slice(0,i),v.slice(i+1)]; }; + const [cA,pA]=sp(a.replace(/^v/,'')), [cB,pB]=sp(b.replace(/^v/,'')); + const nA=cA.split('.').map(Number), nB=cB.split('.').map(Number); + for(let i=0;i<3;i++){if((nA[i]||0)<(nB[i]||0))process.exit(0);if((nA[i]||0)>(nB[i]||0))process.exit(1);} + if(pA!==null&&pB===null)process.exit(0); + if(pA===null)process.exit(1); + if(pA/dev/null +} + +# ─── preflight ──────────────────────────────────────────────────────────────── + +require_cmd node +require_cmd npm + +NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')" +if [[ "$NODE_MAJOR" -lt 20 ]]; then + fail "Node.js >= 20 required (found v$(node --version))" + exit 1 +fi + +# ─── ensure prefix directory exists ────────────────────────────────────────── + +if [[ ! -d "$PREFIX" ]]; then + info "Creating global prefix directory: $PREFIX" + mkdir -p "$PREFIX"/{bin,lib} +fi + +# ─── ensure npmrc scope mapping ────────────────────────────────────────────── + +NPMRC="$HOME/.npmrc" +SCOPE_LINE="${SCOPE}:registry=${REGISTRY}" + +if ! grep -qF "$SCOPE_LINE" "$NPMRC" 2>/dev/null; then + info "Adding ${SCOPE} registry to $NPMRC" + echo "$SCOPE_LINE" >> "$NPMRC" + ok "Registry configured" +fi + +# Ensure prefix is set (only if no prefix= line exists yet) +if ! grep -qF "prefix=$PREFIX" "$NPMRC" 2>/dev/null; then + if ! grep -q '^prefix=' "$NPMRC" 2>/dev/null; then + echo "prefix=$PREFIX" >> "$NPMRC" + info "Set npm global prefix to $PREFIX" + fi +fi + +# Ensure PREFIX/bin is on PATH +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 + +# ─── version check ─────────────────────────────────────────────────────────── + +info "Checking versions…" + +CURRENT="$(installed_version)" +LATEST="$(latest_version)" + +if [[ -z "$LATEST" ]]; then + fail "Could not reach registry at $REGISTRY" + fail "Check network connectivity and registry URL." + exit 1 +fi + +echo "" +if [[ -n "$CURRENT" ]]; then + dim " Installed: ${CLI_PKG}@${CURRENT}" +else + dim " Installed: (none)" +fi +dim " Latest: ${CLI_PKG}@${LATEST}" +echo "" + +# --check flag: just report, don't install +if [[ "${1:-}" == "--check" ]]; then + if [[ -z "$CURRENT" ]]; then + warn "Not installed. Run without --check to install." + exit 1 + elif [[ "$CURRENT" == "$LATEST" ]]; then + ok "Up to date." + exit 0 + elif version_lt "$CURRENT" "$LATEST"; then + warn "Update available: $CURRENT → $LATEST" + exit 2 + else + ok "Up to date (or ahead of registry)." + exit 0 + fi +fi + +# ─── install / upgrade ────────────────────────────────────────────────────── + +if [[ -z "$CURRENT" ]]; then + info "Installing ${CLI_PKG}@${LATEST}…" +elif [[ "$CURRENT" == "$LATEST" ]]; then + ok "Already at latest version ($LATEST). Nothing to do." + exit 0 +elif version_lt "$CURRENT" "$LATEST"; then + info "Upgrading ${CLI_PKG}: $CURRENT → $LATEST…" +else + ok "Installed version ($CURRENT) is at or ahead of registry ($LATEST)." + exit 0 +fi + +npm install -g "${CLI_PKG}@${LATEST}" \ + --registry="$REGISTRY" \ + --prefix="$PREFIX" \ + 2>&1 | sed 's/^/ /' + +echo "" +ok "Mosaic CLI installed: $(installed_version)" +dim " Binary: $PREFIX/bin/mosaic" + +# ─── post-install: run wizard if first install ─────────────────────────────── + +if [[ -z "$CURRENT" ]]; then + echo "" + info "First install detected." + if [[ -t 0 ]] && [[ -t 1 ]]; then + echo " Run ${C}mosaic wizard${RESET} to set up your configuration." + else + dim " Run 'mosaic wizard' to set up your configuration." + fi +fi + +echo "" +ok "Done." + +} # end main + +main "$@"