From 621ab260c0405f6d44470e2eb63abaf144a24124 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 5 Apr 2026 03:19:07 +0000 Subject: [PATCH] fix(mosaic): resumable gateway install + prominent admin token (#393) --- .../gateway-install-ux-20260404.md | 47 +++ packages/mosaic/package.json | 2 +- .../mosaic/src/commands/gateway/install.ts | 324 ++++++++++++++---- 3 files changed, 305 insertions(+), 68 deletions(-) create mode 100644 docs/scratchpads/gateway-install-ux-20260404.md diff --git a/docs/scratchpads/gateway-install-ux-20260404.md b/docs/scratchpads/gateway-install-ux-20260404.md new file mode 100644 index 0000000..1802ca4 --- /dev/null +++ b/docs/scratchpads/gateway-install-ux-20260404.md @@ -0,0 +1,47 @@ +# Gateway Install UX Fixes — 2026-04-04 + +## Context + +User hit two publish-drift bugs on `mosaic gateway install` (fixed in #386 and +#389). On top of those, the install UX itself has three concrete problems: + +1. Admin API token is generated by the bootstrap step and saved to + `~/.config/mosaic/gateway/meta.json`, but never shown to the user. There is + no way to retrieve it without reading the file or running `mosaic gateway +config` afterward. +2. When install crashes mid-way (e.g. daemon fails to become healthy), the + next run of `mosaic gateway install` prompts "Reinstall? [y/N]". Saying N + aborts, leaving the user with a half-configured install and no clear path + forward. There is no resume path. +3. Health-check failure only prints "Check logs: mosaic gateway logs" — forcing + the user to run another command to see the actual error. + +## Goal + +Make `mosaic gateway install` a single end-to-end, resumable command that shows +the admin token on creation and surfaces errors inline. + +## Plan + +- `install.ts`: detect partial state (meta exists, no admin token, daemon not + running) and skip already-completed phases instead of aborting. +- `install.ts`: add a prominent "Admin API Token" banner printed right after + bootstrap succeeds. Include copy-now warning. +- `install.ts`: on health-check timeout, read the tail of `LOG_FILE` and print + the last ~30 non-empty lines before returning. +- Keep `mosaic gateway config` as-is (view/edit env vars); this is not the + setup wizard. +- No new commands. No new flags yet. + +## Out of scope + +- Readline/stdin piping fragility (pre-existing, not related to user complaint). +- Refactor to a phased state machine (overkill for three targeted fixes). + +## Verification + +- Typecheck, lint, format (mandatory gates). +- Manual end-to-end: fresh install → confirm token displayed. +- Manual resume: delete daemon.pid mid-install → re-run → confirm it resumes. +- Manual failure: point ENV_FILE port to a used port → re-run → confirm log + tail is printed. diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index df57f94..2c9fdcb 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -1,6 +1,6 @@ { "name": "@mosaicstack/mosaic", - "version": "0.0.19", + "version": "0.0.20", "repository": { "type": "git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", diff --git a/packages/mosaic/src/commands/gateway/install.ts b/packages/mosaic/src/commands/gateway/install.ts index 5c813a0..97c59e7 100644 --- a/packages/mosaic/src/commands/gateway/install.ts +++ b/packages/mosaic/src/commands/gateway/install.ts @@ -1,21 +1,26 @@ import { randomBytes } from 'node:crypto'; -import { writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; import type { GatewayMeta } from './daemon.js'; import { ENV_FILE, GATEWAY_HOME, + LOG_FILE, ensureDirs, + getDaemonPid, installGatewayPackage, readMeta, resolveGatewayEntry, startDaemon, + stopDaemon, waitForHealth, writeMeta, getInstalledGatewayVersion, } from './daemon.js'; +const MOSAIC_CONFIG_FILE = join(GATEWAY_HOME, 'mosaic.config.json'); + interface InstallOpts { host: string; port: number; @@ -36,30 +41,198 @@ export async function runInstall(opts: InstallOpts): Promise { } async function doInstall(rl: ReturnType, opts: InstallOpts): Promise { - // Check existing installation const existing = readMeta(); - if (existing) { - const answer = await prompt( - rl, - `Gateway already installed (v${existing.version}). Reinstall? [y/N] `, + const envExists = existsSync(ENV_FILE); + const mosaicConfigExists = existsSync(MOSAIC_CONFIG_FILE); + let hasConfig = envExists && mosaicConfigExists; + let daemonRunning = getDaemonPid() !== null; + const hasAdminToken = Boolean(existing?.adminToken); + // `opts.host` already incorporates meta fallback via the parent command + // in gateway.ts (resolveOpts). Using it directly also lets a user pass + // `--host X` to recover from a previous install that stored a broken + // host. We intentionally do not prefer `existing.host` over `opts.host`. + const host = opts.host; + + // Corrupt partial state: exactly one of the two config files survived. + // This happens when an earlier install was interrupted between writing + // .env and mosaic.config.json. Rewriting the missing one would silently + // rotate BETTER_AUTH_SECRET or clobber saved DB/Valkey URLs. Refuse to + // guess — tell the user how to recover. Check file presence only; do + // NOT gate on `existing`, because the installer writes config before + // meta, so an interrupted first install has no meta yet. + if ((envExists || mosaicConfigExists) && !hasConfig) { + console.error('Gateway install is in a corrupt partial state:'); + console.error(` .env file: ${envExists ? 'present' : 'MISSING'} (${ENV_FILE})`); + console.error( + ` mosaic.config.json: ${mosaicConfigExists ? 'present' : 'MISSING'} (${MOSAIC_CONFIG_FILE})`, ); - if (answer.toLowerCase() !== 'y') { - console.log('Aborted.'); - return; - } + console.error('\nRun `mosaic gateway uninstall` to clean up, then re-run install.'); + return; } - // Step 1: Install npm package - if (!opts.skipInstall) { + // Fully set up already — offer to re-run the config wizard and restart. + // The wizard allows changing storage tier / DB URLs, so this can move + // the install onto a different data store. We do NOT wipe persisted + // local data here — for a true scratch wipe run `mosaic gateway + // uninstall` first. + let explicitReinstall = false; + if (existing && hasConfig && daemonRunning && hasAdminToken) { + console.log(`Gateway is already installed and running (v${existing.version}).`); + console.log(` Endpoint: http://${existing.host}:${existing.port.toString()}`); + console.log(` Status: mosaic gateway status`); + console.log(); + console.log('Re-running the config wizard will:'); + console.log(' - regenerate .env and mosaic.config.json'); + console.log(' - restart the daemon'); + console.log(' - preserve BETTER_AUTH_SECRET (sessions stay valid)'); + console.log(' - clear the stored admin token (you will re-bootstrap an admin user)'); + console.log(' - allow changing storage tier / DB URLs (may point at a different data store)'); + console.log('To wipe persisted data, run `mosaic gateway uninstall` first.'); + const answer = await prompt(rl, 'Re-run config wizard? [y/N] '); + if (answer.trim().toLowerCase() !== 'y') { + console.log('Nothing to do.'); + return; + } + // Fall through. The daemon stop below triggers because hasConfig=false + // forces the wizard to re-run. + hasConfig = false; + explicitReinstall = true; + } else if (existing && (hasConfig || daemonRunning)) { + // Partial install detected — resume instead of re-prompting the user. + console.log('Detected a partial gateway installation — resuming setup.\n'); + } + + // If we are going to (re)write config, the running daemon would end up + // serving the old config while health checks and meta point at the new + // one. Always stop the daemon before writing config. + if (!hasConfig && daemonRunning) { + console.log('Stopping gateway daemon before writing new config...'); + try { + await stopDaemon(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/not running/i.test(msg)) { + // Raced with daemon exit — fine, proceed. + } else { + console.error(`Failed to stop running daemon: ${msg}`); + console.error('Refusing to rewrite config while an unknown-state daemon is running.'); + console.error('Stop it manually (mosaic gateway stop) and re-run install.'); + return; + } + } + // Re-check — stop may have succeeded but we want to be sure before + // writing new config files and starting a fresh process. + if (getDaemonPid() !== null) { + console.error('Gateway daemon is still running after stop attempt. Aborting.'); + return; + } + daemonRunning = false; + } + + // Step 1: Install npm package. Always run on first install and on any + // resume where the daemon is NOT already running — a prior failure may + // have been caused by a broken package version, and the retry should + // pick up the latest release. Skip only when resuming while the daemon + // is already alive (package must be working to have started). + if (!opts.skipInstall && !daemonRunning) { installGatewayPackage(); } ensureDirs(); - // Step 2: Collect configuration + // Step 2: Collect configuration (skip if both files already exist). + // On resume, treat the .env file as authoritative for port — but let a + // user-supplied non-default `--port` override it so they can recover + // from a conflicting saved port the same way `--host` lets them + // recover from a bad saved host. `opts.port === 14242` is commander's + // default (not explicit user input), so we prefer .env in that case. + let port: number; + const regeneratedConfig = !hasConfig; + if (hasConfig) { + const envPort = readPortFromEnv(); + port = opts.port !== 14242 ? opts.port : (envPort ?? existing?.port ?? opts.port); + console.log(`Using existing config at ${ENV_FILE} (port ${port.toString()})`); + } else { + port = await runConfigWizard(rl, opts); + } + + // Step 3: Write meta.json. Prefer host from existing meta when resuming. + let entryPoint: string; + try { + entryPoint = resolveGatewayEntry(); + } catch { + console.error('Error: Gateway package not found after install.'); + console.error('Check that @mosaicstack/gateway installed correctly.'); + return; + } + + const version = getInstalledGatewayVersion() ?? 'unknown'; + // Preserve the admin token only on a pure resume (no config regeneration). + // Any time we regenerated config, the wizard may have pointed at a + // different storage tier / DB URL, so the old token is unverifiable — + // drop it and require re-bootstrap. + const preserveToken = !regeneratedConfig && Boolean(existing?.adminToken); + const meta: GatewayMeta = { + version, + installedAt: explicitReinstall + ? new Date().toISOString() + : (existing?.installedAt ?? new Date().toISOString()), + entryPoint, + host, + port, + ...(preserveToken && existing?.adminToken ? { adminToken: existing.adminToken } : {}), + }; + writeMeta(meta); + + // Step 4: Start the daemon (idempotent — skip if already running). + if (!daemonRunning) { + console.log('\nStarting gateway daemon...'); + try { + const pid = startDaemon(); + console.log(`Gateway started (PID ${pid.toString()})`); + } catch (err) { + console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`); + printLogTail(); + return; + } + } else { + console.log('\nGateway daemon is already running.'); + } + + // Step 5: Wait for health + console.log('Waiting for gateway to become healthy...'); + const healthy = await waitForHealth(host, port, 30_000); + if (!healthy) { + console.error('\nGateway did not become healthy within 30 seconds.'); + printLogTail(); + console.error('\nFix the underlying error above, then re-run `mosaic gateway install`.'); + return; + } + console.log('Gateway is healthy.\n'); + + // Step 6: Bootstrap — first admin user. + await bootstrapFirstUser(rl, host, port, meta); + + console.log('\n─── Installation Complete ───'); + console.log(` Endpoint: http://${host}:${port.toString()}`); + console.log(` Config: ${GATEWAY_HOME}`); + console.log(` Logs: mosaic gateway logs`); + console.log(` Status: mosaic gateway status`); +} + +async function runConfigWizard( + rl: ReturnType, + opts: InstallOpts, +): Promise { console.log('\n─── Gateway Configuration ───\n'); - // Tier selection + // If a previous .env exists on disk, reuse its BETTER_AUTH_SECRET so + // regenerating config does not silently log out existing users. + const preservedAuthSecret = readEnvVarFromFile('BETTER_AUTH_SECRET'); + if (preservedAuthSecret) { + console.log('(Preserving existing BETTER_AUTH_SECRET — auth sessions will remain valid.)\n'); + } + console.log('Storage tier:'); console.log(' 1. Local (embedded database, no dependencies)'); console.log(' 2. Team (PostgreSQL + Valkey required)'); @@ -91,10 +264,8 @@ async function doInstall(rl: ReturnType, opts: InstallOp const corsOrigin = (await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000'; - // Generate auth secret - const authSecret = randomBytes(32).toString('hex'); + const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex'); - // Step 3: Write .env const envLines = [ `GATEWAY_PORT=${port.toString()}`, `BETTER_AUTH_SECRET=${authSecret}`, @@ -116,7 +287,6 @@ async function doInstall(rl: ReturnType, opts: InstallOp writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 }); console.log(`\nConfig written to ${ENV_FILE}`); - // Step 3b: Write mosaic.config.json const mosaicConfig = tier === 'local' ? { @@ -132,66 +302,81 @@ async function doInstall(rl: ReturnType, opts: InstallOp memory: { type: 'pgvector' }, }; - const configFile = join(GATEWAY_HOME, 'mosaic.config.json'); - writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 }); - console.log(`Config written to ${configFile}`); + writeFileSync(MOSAIC_CONFIG_FILE, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 }); + console.log(`Config written to ${MOSAIC_CONFIG_FILE}`); - // Step 4: Write meta.json - let entryPoint: string; + return port; +} + +function readEnvVarFromFile(key: string): string | null { + if (!existsSync(ENV_FILE)) return null; try { - entryPoint = resolveGatewayEntry(); + for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx <= 0) continue; + if (trimmed.slice(0, eqIdx) !== key) continue; + return trimmed.slice(eqIdx + 1); + } } catch { - console.error('Error: Gateway package not found after install.'); - console.error('Check that @mosaicstack/gateway installed correctly.'); + return null; + } + return null; +} + +function readPortFromEnv(): number | null { + const raw = readEnvVarFromFile('GATEWAY_PORT'); + if (raw === null) return null; + const parsed = parseInt(raw, 10); + return Number.isNaN(parsed) ? null : parsed; +} + +function printLogTail(maxLines = 30): void { + if (!existsSync(LOG_FILE)) { + console.error(`(no log file at ${LOG_FILE})`); return; } - - const version = getInstalledGatewayVersion() ?? 'unknown'; - - const meta = { - version, - installedAt: new Date().toISOString(), - entryPoint, - host: opts.host, - port, - }; - writeMeta(meta); - - // Step 5: Start the daemon - console.log('\nStarting gateway daemon...'); try { - const pid = startDaemon(); - console.log(`Gateway started (PID ${pid.toString()})`); + const lines = readFileSync(LOG_FILE, 'utf-8') + .split('\n') + .filter((l) => l.trim().length > 0); + const tail = lines.slice(-maxLines); + if (tail.length === 0) { + console.error('(log file is empty)'); + return; + } + console.error(`\n─── Last ${tail.length.toString()} log lines (${LOG_FILE}) ───`); + for (const line of tail) console.error(line); + console.error('─────────────────────────────────────────────'); } catch (err) { - console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`); - return; + console.error(`Could not read log file: ${err instanceof Error ? err.message : String(err)}`); } +} - // Step 6: Wait for health - console.log('Waiting for gateway to become healthy...'); - const healthy = await waitForHealth(opts.host, port, 30_000); - if (!healthy) { - console.error('Gateway did not become healthy within 30 seconds.'); - console.error(`Check logs: mosaic gateway logs`); - return; - } - console.log('Gateway is healthy.\n'); - - // Step 7: Bootstrap — first user setup - await bootstrapFirstUser(rl, opts.host, port, meta); - - console.log('\n─── Installation Complete ───'); - console.log(` Endpoint: http://${opts.host}:${port.toString()}`); - console.log(` Config: ${GATEWAY_HOME}`); - console.log(` Logs: mosaic gateway logs`); - console.log(` Status: mosaic gateway status`); +function printAdminTokenBanner(token: string): void { + const border = '═'.repeat(68); + console.log(); + console.log(border); + console.log(' Admin API Token'); + console.log(border); + console.log(); + console.log(` ${token}`); + console.log(); + console.log(' Save this token now — it will not be shown again in full.'); + console.log(' It is stored (read-only) at:'); + console.log(` ${join(GATEWAY_HOME, 'meta.json')}`); + console.log(); + console.log(' Use it with admin endpoints, e.g.:'); + console.log(` mosaic gateway --token status`); + console.log(border); } async function bootstrapFirstUser( rl: ReturnType, host: string, port: number, - meta: Omit & { adminToken?: string }, + meta: GatewayMeta, ): Promise { const baseUrl = `http://${host}:${port.toString()}`; @@ -201,7 +386,12 @@ async function bootstrapFirstUser( const status = (await statusRes.json()) as { needsSetup: boolean }; if (!status.needsSetup) { - console.log('Admin user already exists — skipping setup.'); + if (meta.adminToken) { + console.log('Admin user already exists (token on file).'); + } else { + console.log('Admin user already exists — skipping setup.'); + console.log('(No admin token on file — sign in via the web UI to manage tokens.)'); + } return; } } catch { @@ -247,12 +437,12 @@ async function bootstrapFirstUser( token: { plaintext: string }; }; - // Save admin token to meta + // Persist the token so future CLI calls can authenticate automatically. meta.adminToken = result.token.plaintext; - writeMeta(meta as GatewayMeta); + writeMeta(meta); console.log(`\nAdmin user created: ${result.user.email}`); - console.log('Admin API token saved to gateway config.'); + printAdminTokenBanner(result.token.plaintext); } catch (err) { console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`); }