Compare commits

...

3 Commits

Author SHA1 Message Date
Jarvis
ebf9517dd0 fix(mosaic): resumable gateway install + prominent admin token
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
The `mosaic gateway install` command had three UX problems:

1. Admin API token was silently saved to meta.json and never shown to
   the user — no way to retrieve it without reading the file.
2. If install crashed mid-way (e.g. daemon health-check failure), the
   next run prompted "Reinstall? [y/N]" and aborted on N, leaving a
   half-configured install with no resume path.
3. Health-check failures only pointed at `mosaic gateway logs` — user
   had to run a separate command to see the actual error.

Changes:

- **Prominent admin token banner** printed immediately after bootstrap
  creates the first admin user. Clear "save now, won't be shown again"
  warning.
- **Resumable install state machine.** Detects partial installs from
  meta.json + .env + mosaic.config.json + daemon state, and picks up
  where a prior attempt stopped instead of prompting. Fully set up
  installs now offer "re-run config wizard" with explicit warnings
  about what it does (regenerates .env, clears admin token, may change
  backend storage).
- **Inline log tail on health failure.** Last 30 non-empty lines of
  gateway.log printed automatically when the daemon fails to become
  healthy, so the user sees the underlying error without running a
  second command.
- **Corrupt-state detection.** If exactly one of .env / mosaic.config.json
  exists (from an interrupted prior install), refuses to guess and
  directs the user to `mosaic gateway uninstall`.
- **BETTER_AUTH_SECRET preservation** across config regeneration so
  existing Better Auth sessions aren't silently invalidated.
- **Admin token dropped on any config regeneration** (the wizard may
  point at a different backend; the old token is unverifiable).
- **Daemon stopped before config rewrite** so the live process never
  serves stale config.
- Bump `@mosaic/mosaic` 0.0.19 → 0.0.20.

Known follow-ups (noted in review):
- `--port 14242` as an explicit override cannot be distinguished from
  commander's default value; requires plumbing an `explicit` flag.
- No automated test coverage for the new state branches; requires
  mocking fs/readline/fetch/spawn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:08:39 -05:00
80994bdc8e fix(packages): bump db/memory/queue for PGlite + adapter factories (#389)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 02:20:23 +00:00
2e31626f87 fix: simplify updater to @mosaic/mosaic only, add explicit tea repo/login flags (#388)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 02:09:23 +00:00
12 changed files with 415 additions and 173 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/gateway", "name": "@mosaic/gateway",
"version": "0.0.5", "version": "0.0.6",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",

View File

@@ -0,0 +1,54 @@
# Scratchpad — issue #387 updater simplification + Gitea wrapper repo context
- Objective: simplify updater behavior to `@mosaic/mosaic` only, and fix Gitea wrapper scripts so merge/CI/issue operations work reliably when tea needs explicit repo/login context.
- Scope:
- `packages/mosaic/src/runtime/update-checker.ts`
- `packages/mosaic/__tests__/update-checker.test.ts`
- any package metadata/version bumps needed
- repo-source git wrappers under `packages/mosaic/framework/tools/git/`
- Constraints:
- Jason approved breaking changes; legacy `@mosaic/cli` support is out of scope.
- Keep changes focused and mergeable.
- Acceptance:
- updater only targets `@mosaic/mosaic`
- wrapper path works on Gitea in this environment without manual repo guessing
- PR merges squash-only after green CI
## Progress
### 2026-04-04
#### Update Checker Simplification (DONE)
- Removed `LEGACY_PKG`, `INSTALLED_PACKAGE_ORDER` constants — only `PKG = '@mosaic/mosaic'` remains
- `getInstalledVersion()` — removed loop over multiple packages, only checks `@mosaic/mosaic`
- `getLatestVersion()` — removed `installedPackage` parameter and candidate iteration; single query for `@mosaic/mosaic`
- `checkForUpdate()` — removed `currentPackage`-based cache key comparison; cache is now package-agnostic
- `RegistryCache` — removed `currentPackage` and `targetPackage` fields
- `formatUpdateNotice()` — removed `targetChanged` branch (package migration notice no longer relevant)
- All legacy fallback/compatibility logic removed
#### Test Updates (DONE)
- Removed legacy `@mosaic/cli` fallback test
- Removed cache cross-contamination test (was testing legacy→modern package transition)
- Added `does not query legacy @mosaic/cli package` test — asserts no `@mosaic/cli` npm commands are issued
- Added `returns empty result when package is not installed` test
- All 8 tests pass ✅
#### Gitea Wrapper Fixes (DONE)
- `pr-merge.sh`:
- Added `OWNER=$(get_repo_owner)` / `REPO=$(get_repo_name)` before case block
- tea merge command now includes `--repo $OWNER/$REPO --login ${GITEA_LOGIN:-mosaicstack}`
- `issue-close.sh`:
- Added `OWNER=$(get_repo_owner)` / `REPO=$(get_repo_name)` after detect_platform
- Both `tea issue comment` and `tea issue close` now include `--repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"`
- `pr-ci-wait.sh`: No changes needed — uses curl API calls (not tea), already passes owner/repo correctly
- `detect-platform.sh`: No changes needed — provides the `get_repo_owner`/`get_repo_name` functions used by wrappers
#### Verification
- `vitest run` — 8/8 tests pass
- `tsc --noEmit` — no errors in update-checker.ts (pre-existing workspace dep errors unrelated)
- `eslint` — clean, no warnings

View File

@@ -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.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/db", "name": "@mosaic/db",
"version": "0.0.2", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/memory", "name": "@mosaic/memory",
"version": "0.0.2", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",

View File

@@ -88,19 +88,18 @@ describe('formatUpdateNotice', () => {
expect(notice).toContain('Update available'); expect(notice).toContain('Update available');
}); });
it('uses @mosaic/mosaic hints for modern installs', async () => { it('uses @mosaic/mosaic for installs', async () => {
execSyncMock.mockImplementation((command: string) => { execSyncMock.mockImplementation((command: string) => {
if (command.includes('ls -g --depth=0 --json')) { if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({ return JSON.stringify({
dependencies: { dependencies: {
'@mosaic/mosaic': { version: '0.0.17' }, '@mosaic/mosaic': { version: '0.0.19' },
'@mosaic/cli': { version: '0.0.16' },
}, },
}); });
} }
if (command.includes('view @mosaic/mosaic version')) { if (command.includes('view @mosaic/mosaic version')) {
return '0.0.18'; return '0.0.20';
} }
throw new Error(`Unexpected command: ${command}`); throw new Error(`Unexpected command: ${command}`);
@@ -110,28 +109,29 @@ describe('formatUpdateNotice', () => {
const result = checkForUpdate({ skipCache: true }); const result = checkForUpdate({ skipCache: true });
const notice = formatUpdateNotice(result); const notice = formatUpdateNotice(result);
expect(result.current).toBe('0.0.17'); expect(result.current).toBe('0.0.19');
expect(result.latest).toBe('0.0.18'); expect(result.latest).toBe('0.0.20');
expect(result.currentPackage).toBe('@mosaic/mosaic');
expect(result.targetPackage).toBe('@mosaic/mosaic');
expect(notice).toContain('@mosaic/mosaic@latest'); expect(notice).toContain('@mosaic/mosaic@latest');
expect(notice).not.toContain('@mosaic/cli@latest');
}); });
it('falls back to @mosaic/mosaic for legacy @mosaic/cli installs when cli is unavailable', async () => { it('does not query legacy @mosaic/cli package', async () => {
execSyncMock.mockImplementation((command: string) => { execSyncMock.mockImplementation((command: string) => {
if (command.includes('view @mosaic/cli')) {
throw new Error('Should not query @mosaic/cli');
}
if (command.includes('ls -g --depth=0 --json')) { if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({ return JSON.stringify({
dependencies: { dependencies: {
'@mosaic/cli': { version: '0.0.16' }, '@mosaic/mosaic': { version: '0.0.19' },
}, },
}); });
} }
if (command.includes('view @mosaic/cli version')) {
throw new Error('not found');
}
if (command.includes('view @mosaic/mosaic version')) { if (command.includes('view @mosaic/mosaic version')) {
return '0.0.17'; return '0.0.20';
} }
throw new Error(`Unexpected command: ${command}`); throw new Error(`Unexpected command: ${command}`);
@@ -139,57 +139,31 @@ describe('formatUpdateNotice', () => {
const { checkForUpdate } = await importUpdateChecker(); const { checkForUpdate } = await importUpdateChecker();
const result = checkForUpdate({ skipCache: true }); const result = checkForUpdate({ skipCache: true });
const notice = formatUpdateNotice(result);
expect(result.current).toBe('0.0.16'); expect(result.targetPackage).toBe('@mosaic/mosaic');
expect(result.latest).toBe('0.0.17'); expect(result.latest).toBe('0.0.20');
expect(notice).toContain('@mosaic/mosaic@latest'); // Verify no @mosaic/cli queries were made
const calls = execSyncMock.mock.calls.map((c: any[]) => c[0] as string);
expect(calls.some((c) => c.includes('@mosaic/cli'))).toBe(false);
}); });
it('does not reuse a cached modern-package result for a legacy install', async () => { it('returns empty result when package is not installed', async () => {
let installedPackage = '@mosaic/mosaic';
execSyncMock.mockImplementation((command: string) => { execSyncMock.mockImplementation((command: string) => {
if (command.includes('ls -g --depth=0 --json')) { if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({ return JSON.stringify({ dependencies: {} });
dependencies:
installedPackage === '@mosaic/mosaic'
? { '@mosaic/mosaic': { version: '0.0.17' } }
: { '@mosaic/cli': { version: '0.0.16' } },
});
} }
if (command.includes('view @mosaic/mosaic version')) { if (command.includes('view @mosaic/mosaic version')) {
return installedPackage === '@mosaic/mosaic' ? '0.0.18' : '0.0.17'; return '';
}
if (command.includes('view @mosaic/cli version')) {
throw new Error('not found');
} }
throw new Error(`Unexpected command: ${command}`); throw new Error(`Unexpected command: ${command}`);
}); });
const { checkForUpdate } = await importUpdateChecker(); const { checkForUpdate } = await importUpdateChecker();
const result = checkForUpdate({ skipCache: true });
const modernResult = checkForUpdate(); expect(result.current).toBe('');
installedPackage = '@mosaic/cli'; expect(result.updateAvailable).toBe(false);
const legacyResult = checkForUpdate();
expect(modernResult.currentPackage).toBe('@mosaic/mosaic');
expect(modernResult.targetPackage).toBe('@mosaic/mosaic');
expect(modernResult.latest).toBe('0.0.18');
expect(legacyResult.currentPackage).toBe('@mosaic/cli');
expect(legacyResult.targetPackage).toBe('@mosaic/mosaic');
expect(legacyResult.latest).toBe('0.0.17');
expect(execSyncMock).toHaveBeenCalledWith(
expect.stringContaining('view @mosaic/cli version'),
expect.any(Object),
);
expect(execSyncMock).toHaveBeenCalledWith(
expect.stringContaining('view @mosaic/mosaic version'),
expect.any(Object),
);
}); });
}); });

View File

@@ -45,6 +45,8 @@ fi
# Detect platform and close issue # Detect platform and close issue
detect_platform detect_platform
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
if [[ -n "$COMMENT" ]]; then if [[ -n "$COMMENT" ]]; then
@@ -54,9 +56,9 @@ if [[ "$PLATFORM" == "github" ]]; then
echo "Closed GitHub issue #$ISSUE_NUMBER" echo "Closed GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
if [[ -n "$COMMENT" ]]; then if [[ -n "$COMMENT" ]]; then
tea issue comment "$ISSUE_NUMBER" "$COMMENT" tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
fi fi
tea issue close "$ISSUE_NUMBER" tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
echo "Closed Gitea issue #$ISSUE_NUMBER" echo "Closed Gitea issue #$ISSUE_NUMBER"
else else
echo "Error: Unknown platform" echo "Error: Unknown platform"

View File

@@ -89,6 +89,8 @@ if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
fi fi
PLATFORM=$(detect_platform) PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
case "$PLATFORM" in case "$PLATFORM" in
github) github)
@@ -97,8 +99,7 @@ case "$PLATFORM" in
eval "$CMD" eval "$CMD"
;; ;;
gitea) gitea)
# tea pr merge syntax CMD="tea pr merge $PR_NUMBER --style squash --repo $OWNER/$REPO --login ${GITEA_LOGIN:-mosaicstack}"
CMD="tea pr merge $PR_NUMBER --style squash"
# Delete branch after merge if requested # Delete branch after merge if requested
if [[ "$DELETE_BRANCH" == true ]]; then if [[ "$DELETE_BRANCH" == true ]]; then

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/mosaic", "name": "@mosaic/mosaic",
"version": "0.0.19", "version": "0.0.20",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",

View File

@@ -1,21 +1,26 @@
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { writeFileSync } from 'node:fs'; import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { createInterface } from 'node:readline'; import { createInterface } from 'node:readline';
import type { GatewayMeta } from './daemon.js'; import type { GatewayMeta } from './daemon.js';
import { import {
ENV_FILE, ENV_FILE,
GATEWAY_HOME, GATEWAY_HOME,
LOG_FILE,
ensureDirs, ensureDirs,
getDaemonPid,
installGatewayPackage, installGatewayPackage,
readMeta, readMeta,
resolveGatewayEntry, resolveGatewayEntry,
startDaemon, startDaemon,
stopDaemon,
waitForHealth, waitForHealth,
writeMeta, writeMeta,
getInstalledGatewayVersion, getInstalledGatewayVersion,
} from './daemon.js'; } from './daemon.js';
const MOSAIC_CONFIG_FILE = join(GATEWAY_HOME, 'mosaic.config.json');
interface InstallOpts { interface InstallOpts {
host: string; host: string;
port: number; port: number;
@@ -36,30 +41,198 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
} }
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> { async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
// Check existing installation
const existing = readMeta(); const existing = readMeta();
if (existing) { const envExists = existsSync(ENV_FILE);
const answer = await prompt( const mosaicConfigExists = existsSync(MOSAIC_CONFIG_FILE);
rl, let hasConfig = envExists && mosaicConfigExists;
`Gateway already installed (v${existing.version}). Reinstall? [y/N] `, 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.error('\nRun `mosaic gateway uninstall` to clean up, then re-run install.');
console.log('Aborted.'); return;
return;
}
} }
// Step 1: Install npm package // Fully set up already — offer to re-run the config wizard and restart.
if (!opts.skipInstall) { // 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(); installGatewayPackage();
} }
ensureDirs(); 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 @mosaic/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<typeof createInterface>,
opts: InstallOpts,
): Promise<number> {
console.log('\n─── Gateway Configuration ───\n'); 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('Storage tier:');
console.log(' 1. Local (embedded database, no dependencies)'); console.log(' 1. Local (embedded database, no dependencies)');
console.log(' 2. Team (PostgreSQL + Valkey required)'); console.log(' 2. Team (PostgreSQL + Valkey required)');
@@ -91,10 +264,8 @@ async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOp
const corsOrigin = const corsOrigin =
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000'; (await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
// Generate auth secret const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
const authSecret = randomBytes(32).toString('hex');
// Step 3: Write .env
const envLines = [ const envLines = [
`GATEWAY_PORT=${port.toString()}`, `GATEWAY_PORT=${port.toString()}`,
`BETTER_AUTH_SECRET=${authSecret}`, `BETTER_AUTH_SECRET=${authSecret}`,
@@ -116,7 +287,6 @@ async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOp
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 }); writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
console.log(`\nConfig written to ${ENV_FILE}`); console.log(`\nConfig written to ${ENV_FILE}`);
// Step 3b: Write mosaic.config.json
const mosaicConfig = const mosaicConfig =
tier === 'local' tier === 'local'
? { ? {
@@ -132,66 +302,81 @@ async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOp
memory: { type: 'pgvector' }, memory: { type: 'pgvector' },
}; };
const configFile = join(GATEWAY_HOME, 'mosaic.config.json'); writeFileSync(MOSAIC_CONFIG_FILE, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 }); console.log(`Config written to ${MOSAIC_CONFIG_FILE}`);
console.log(`Config written to ${configFile}`);
// Step 4: Write meta.json return port;
let entryPoint: string; }
function readEnvVarFromFile(key: string): string | null {
if (!existsSync(ENV_FILE)) return null;
try { 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 { } catch {
console.error('Error: Gateway package not found after install.'); return null;
console.error('Check that @mosaic/gateway installed correctly.'); }
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; 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 { try {
const pid = startDaemon(); const lines = readFileSync(LOG_FILE, 'utf-8')
console.log(`Gateway started (PID ${pid.toString()})`); .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) { } catch (err) {
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`); console.error(`Could not read log file: ${err instanceof Error ? err.message : String(err)}`);
return;
} }
}
// Step 6: Wait for health function printAdminTokenBanner(token: string): void {
console.log('Waiting for gateway to become healthy...'); const border = '═'.repeat(68);
const healthy = await waitForHealth(opts.host, port, 30_000); console.log();
if (!healthy) { console.log(border);
console.error('Gateway did not become healthy within 30 seconds.'); console.log(' Admin API Token');
console.error(`Check logs: mosaic gateway logs`); console.log(border);
return; console.log();
} console.log(` ${token}`);
console.log('Gateway is healthy.\n'); console.log();
console.log(' Save this token now — it will not be shown again in full.');
// Step 7: Bootstrap — first user setup console.log(' It is stored (read-only) at:');
await bootstrapFirstUser(rl, opts.host, port, meta); console.log(` ${join(GATEWAY_HOME, 'meta.json')}`);
console.log();
console.log('\n─── Installation Complete ───'); console.log(' Use it with admin endpoints, e.g.:');
console.log(` Endpoint: http://${opts.host}:${port.toString()}`); console.log(` mosaic gateway --token <token> status`);
console.log(` Config: ${GATEWAY_HOME}`); console.log(border);
console.log(` Logs: mosaic gateway logs`);
console.log(` Status: mosaic gateway status`);
} }
async function bootstrapFirstUser( async function bootstrapFirstUser(
rl: ReturnType<typeof createInterface>, rl: ReturnType<typeof createInterface>,
host: string, host: string,
port: number, port: number,
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string }, meta: GatewayMeta,
): Promise<void> { ): Promise<void> {
const baseUrl = `http://${host}:${port.toString()}`; const baseUrl = `http://${host}:${port.toString()}`;
@@ -201,7 +386,12 @@ async function bootstrapFirstUser(
const status = (await statusRes.json()) as { needsSetup: boolean }; const status = (await statusRes.json()) as { needsSetup: boolean };
if (!status.needsSetup) { 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; return;
} }
} catch { } catch {
@@ -247,12 +437,12 @@ async function bootstrapFirstUser(
token: { plaintext: string }; token: { plaintext: string };
}; };
// Save admin token to meta // Persist the token so future CLI calls can authenticate automatically.
meta.adminToken = result.token.plaintext; meta.adminToken = result.token.plaintext;
writeMeta(meta as GatewayMeta); writeMeta(meta);
console.log(`\nAdmin user created: ${result.user.email}`); console.log(`\nAdmin user created: ${result.user.email}`);
console.log('Admin API token saved to gateway config.'); printAdminTokenBanner(result.token.plaintext);
} catch (err) { } catch (err) {
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`); console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
} }

View File

@@ -1,6 +1,6 @@
/** /**
* Mosaic update checker — compares the installed Mosaic package against the * Mosaic update checker — compares the installed @mosaic/mosaic package
* Gitea npm registry and reports when an upgrade is available. * against the Gitea npm registry and reports when an upgrade is available.
* *
* Used by: * Used by:
* - CLI startup (non-blocking background check) * - CLI startup (non-blocking background check)
@@ -40,9 +40,7 @@ export interface UpdateCheckResult {
// ─── Constants ────────────────────────────────────────────────────────────── // ─── Constants ──────────────────────────────────────────────────────────────
const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/'; const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
const MODERN_PKG = '@mosaic/mosaic'; const PKG = '@mosaic/mosaic';
const LEGACY_PKG = '@mosaic/cli';
const INSTALLED_PACKAGE_ORDER = [MODERN_PKG, LEGACY_PKG] as const;
const CACHE_DIR = join(homedir(), '.cache', 'mosaic'); const CACHE_DIR = join(homedir(), '.cache', 'mosaic');
const CACHE_FILE = join(CACHE_DIR, 'update-check.json'); const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -131,20 +129,17 @@ export function semverLt(a: string, b: string): boolean {
/** Cache stores only the latest registry version (the expensive network call). /** Cache stores only the latest registry version (the expensive network call).
* The installed version is always checked fresh — it's a local `npm ls`. */ * The installed version is always checked fresh — it's a local `npm ls`. */
interface RegistryCache { interface RegistryCache {
currentPackage?: string;
latest: string; latest: string;
targetPackage?: string;
checkedAt: string; checkedAt: string;
registry: string; registry: string;
} }
function readCache(currentPackage: string): RegistryCache | null { function readCache(): RegistryCache | null {
try { try {
if (!existsSync(CACHE_FILE)) return null; if (!existsSync(CACHE_FILE)) return null;
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as RegistryCache; const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as RegistryCache;
const age = Date.now() - new Date(raw.checkedAt).getTime(); const age = Date.now() - new Date(raw.checkedAt).getTime();
if (age > CACHE_TTL_MS) return null; if (age > CACHE_TTL_MS) return null;
if ((raw.currentPackage || '') !== currentPackage) return null;
return raw; return raw;
} catch { } catch {
return null; return null;
@@ -163,22 +158,18 @@ function writeCache(entry: RegistryCache): void {
// ─── Public API ───────────────────────────────────────────────────────────── // ─── Public API ─────────────────────────────────────────────────────────────
/** /**
* Get the currently installed Mosaic package version. * Get the currently installed @mosaic/mosaic version.
* Prefers the consolidated @mosaic/mosaic package over legacy @mosaic/cli.
*/ */
export function getInstalledVersion(): { name: string; version: string } { export function getInstalledVersion(): { name: string; version: string } {
// Fast path: check via package.json require chain
try { try {
const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000); const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000);
if (raw) { if (raw) {
const data = JSON.parse(raw) as { const data = JSON.parse(raw) as {
dependencies?: Record<string, { version?: string }>; dependencies?: Record<string, { version?: string }>;
}; };
for (const pkg of INSTALLED_PACKAGE_ORDER) { const version = data?.dependencies?.[PKG]?.version;
const version = data?.dependencies?.[pkg]?.version; if (version) {
if (version) { return { name: PKG, version };
return { name: pkg, version };
}
} }
} }
} catch { } catch {
@@ -188,27 +179,19 @@ export function getInstalledVersion(): { name: string; version: string } {
} }
/** /**
* Fetch the latest published version from the Gitea npm registry. * Fetch the latest published @mosaic/mosaic version from the Gitea npm registry.
* For legacy @mosaic/cli installs, try the legacy package first and fall back
* to @mosaic/mosaic to support the CLI -> mosaic package consolidation.
* Returns empty string on failure. * Returns empty string on failure.
*/ */
export function getLatestVersion(installedPackage = ''): { name: string; version: string } { export function getLatestVersion(): { name: string; version: string } {
const candidates = const version = npmExec(`view ${PKG} version --registry=${REGISTRY}`);
installedPackage === LEGACY_PKG ? [LEGACY_PKG, MODERN_PKG] : [MODERN_PKG, LEGACY_PKG]; if (version) {
return { name: PKG, version };
for (const pkg of candidates) {
const version = npmExec(`view ${pkg} version --registry=${REGISTRY}`);
if (version) {
return { name: pkg, version };
}
} }
return { name: '', version: '' }; return { name: '', version: '' };
} }
export function getInstallCommand(result: Pick<UpdateCheckResult, 'targetPackage'>): string { export function getInstallCommand(result: Pick<UpdateCheckResult, 'targetPackage'>): string {
return `npm i -g ${result.targetPackage || MODERN_PKG}@latest`; return `npm i -g ${result.targetPackage || PKG}@latest`;
} }
/** /**
@@ -225,31 +208,27 @@ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckRe
let checkedAt: string; let checkedAt: string;
if (!options?.skipCache) { if (!options?.skipCache) {
const cached = readCache(currentInfo.name); const cached = readCache();
if (cached) { if (cached) {
latestInfo = { latestInfo = {
name: cached.targetPackage || MODERN_PKG, name: PKG,
version: cached.latest, version: cached.latest,
}; };
checkedAt = cached.checkedAt; checkedAt = cached.checkedAt;
} else { } else {
latestInfo = getLatestVersion(currentInfo.name); latestInfo = getLatestVersion();
checkedAt = new Date().toISOString(); checkedAt = new Date().toISOString();
writeCache({ writeCache({
currentPackage: currentInfo.name,
latest: latestInfo.version, latest: latestInfo.version,
targetPackage: latestInfo.name,
checkedAt, checkedAt,
registry: REGISTRY, registry: REGISTRY,
}); });
} }
} else { } else {
latestInfo = getLatestVersion(currentInfo.name); latestInfo = getLatestVersion();
checkedAt = new Date().toISOString(); checkedAt = new Date().toISOString();
writeCache({ writeCache({
currentPackage: currentInfo.name,
latest: latestInfo.version, latest: latestInfo.version,
targetPackage: latestInfo.name,
checkedAt, checkedAt,
registry: REGISTRY, registry: REGISTRY,
}); });
@@ -257,9 +236,9 @@ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckRe
return { return {
current, current,
currentPackage: currentInfo.name, currentPackage: currentInfo.name || PKG,
latest: latestInfo.version, latest: latestInfo.version,
targetPackage: latestInfo.name || MODERN_PKG, targetPackage: latestInfo.name || PKG,
updateAvailable: !!(current && latestInfo.version && semverLt(current, latestInfo.version)), updateAvailable: !!(current && latestInfo.version && semverLt(current, latestInfo.version)),
checkedAt, checkedAt,
registry: REGISTRY, registry: REGISTRY,
@@ -273,17 +252,12 @@ export function formatUpdateNotice(result: UpdateCheckResult): string {
if (!result.updateAvailable) return ''; if (!result.updateAvailable) return '';
const installCommand = getInstallCommand(result); const installCommand = getInstallCommand(result);
const targetChanged =
result.currentPackage && result.targetPackage && result.currentPackage !== result.targetPackage;
const lines = [ const lines = [
'', '',
'╭─────────────────────────────────────────────────╮', '╭─────────────────────────────────────────────────╮',
'│ │', '│ │',
`│ Update available: ${result.current}${result.latest}`.padEnd(50) + '│', `│ Update available: ${result.current}${result.latest}`.padEnd(50) + '│',
...(targetChanged
? [`│ Package target: ${result.currentPackage}${result.targetPackage}`.padEnd(50) + '│']
: []),
'│ │', '│ │',
'│ Run: bash tools/install.sh │', '│ Run: bash tools/install.sh │',
`│ Or: ${installCommand}`.padEnd(50) + '│', `│ Or: ${installCommand}`.padEnd(50) + '│',

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/queue", "name": "@mosaic/queue",
"version": "0.0.2", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",