Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
116b91d2ae fix(packages): republish @mosaic/config and bump dependents
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
The published @mosaic/config@0.0.1 on the Gitea registry is the
stale tooling-configs package (tsconfig/eslint/prettier) with only
subpath exports. When the package was repurposed in 04a80fb9 as the
runtime config loader, its version was never bumped, so consumers
that pull from the registry still get the old tarball.

This caused `mosaic gateway install` to fail with
ERR_PACKAGE_PATH_NOT_EXPORTED when gateway imported loadConfig from
@mosaic/config at runtime.

- Bump @mosaic/config to 0.0.2 so CI publishes the runtime variant
- Bump @mosaic/gateway to 0.0.5 to republish with the fixed dep
  (0.1.0 was an unintended semver jump; deleted from registry to
  restore 0.0.x lineage)
- Bump @mosaic/mosaic to 0.0.19 so the CLI ships with the fixed
  transitive dep resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:51:09 -05:00
18 changed files with 112 additions and 600 deletions

View File

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

View File

@@ -1,54 +0,0 @@
# 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

@@ -1,47 +0,0 @@
# 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,34 +0,0 @@
# Scratchpad — updater package target fix (#382)
- Objective: Fix `mosaic update` so modern installs query `@mosaic/mosaic` instead of stale `@mosaic/cli`.
- Scope: updater logic, user-facing update/install hints, tests, package version bump(s).
- Constraints: preserve backward compatibility for older `@mosaic/cli` installs if practical.
- Acceptance:
- fresh installs using `@mosaic/mosaic` report latest correctly
- older installs do not regress unnecessarily
- tests cover package lookup behavior
- release version bumped for changed package(s)
## Decisions
- Prefer `@mosaic/mosaic` when both modern and legacy packages are installed globally.
- For legacy `@mosaic/cli` installs, query `@mosaic/cli` first, then fall back to `@mosaic/mosaic` if the legacy package is not published.
- Share install-target selection from `packages/mosaic` so both the consolidated CLI and the legacy `packages/cli` entrypoint print/install the same package target.
- Extend the update cache to persist the resolved target package as well as the version so cached checks preserve the migration target.
## Validation
- `pnpm install`
- `pnpm --filter @mosaic/mosaic test -- __tests__/update-checker.test.ts`
- `pnpm exec eslint --no-warn-ignored packages/mosaic/src/runtime/update-checker.ts packages/mosaic/src/cli.ts packages/mosaic/src/index.ts packages/mosaic/__tests__/update-checker.test.ts packages/cli/src/cli.ts`
- `pnpm --filter @mosaic/mosaic lint`
- pre-push hooks: `typecheck`, `lint`, `format:check`
## Review
- Manual review of the updater diff caught and fixed a cache regression where fallback results would lose the resolved package target on subsequent cached checks.
## Risks / Notes
- Direct `pnpm --filter @mosaic/mosaic typecheck` and `pnpm --filter @mosaic/cli ...` checks were not representative in this worktree because `packages/cli` is excluded from `pnpm-workspace.yaml` and the standalone package check lacked the built workspace dependency graph.
- The repo's pre-push hooks provided the authoritative validation path here and passed: root `typecheck`, `lint`, and `format:check`.

View File

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

View File

@@ -314,8 +314,7 @@ program
.description('Check for and install Mosaic CLI updates')
.option('--check', 'Check only, do not install')
.action(async (opts: { check?: boolean }) => {
const { checkForUpdate, formatUpdateNotice, getInstallCommand } =
await import('@mosaic/mosaic');
const { checkForUpdate, formatUpdateNotice } = await import('@mosaic/mosaic');
const { execSync } = await import('node:child_process');
console.log('Checking for updates…');
@@ -345,7 +344,7 @@ program
try {
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
// globally or non-@mosaic deps will 404 against the Gitea registry.
execSync(getInstallCommand(result), {
execSync('npm install -g @mosaic/cli@latest', {
stdio: 'inherit',
timeout: 60_000,
});

View File

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

View File

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

View File

@@ -1,40 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { semverLt, formatUpdateNotice } from '../src/runtime/update-checker.js';
import type { UpdateCheckResult } from '../src/runtime/update-checker.js';
const { execSyncMock, cacheFiles } = vi.hoisted(() => ({
execSyncMock: vi.fn(),
cacheFiles: new Map<string, string>(),
}));
vi.mock('node:child_process', () => ({
execSync: execSyncMock,
}));
vi.mock('node:fs', () => ({
existsSync: vi.fn((path: string) => cacheFiles.has(path)),
mkdirSync: vi.fn(),
readFileSync: vi.fn((path: string) => {
const value = cacheFiles.get(path);
if (value === undefined) {
throw new Error(`ENOENT: ${path}`);
}
return value;
}),
writeFileSync: vi.fn((path: string, content: string) => {
cacheFiles.set(path, content);
}),
}));
vi.mock('node:os', () => ({
homedir: vi.fn(() => '/mock-home'),
}));
async function importUpdateChecker() {
vi.resetModules();
return import('../src/runtime/update-checker.js');
}
describe('semverLt', () => {
it('returns true when a < b', () => {
expect(semverLt('0.0.1', '0.0.2')).toBe(true);
@@ -58,11 +25,6 @@ describe('semverLt', () => {
});
describe('formatUpdateNotice', () => {
beforeEach(() => {
execSyncMock.mockReset();
cacheFiles.clear();
});
it('returns empty string when up to date', () => {
const result: UpdateCheckResult = {
current: '1.0.0',
@@ -87,83 +49,4 @@ describe('formatUpdateNotice', () => {
expect(notice).toContain('0.1.0');
expect(notice).toContain('Update available');
});
it('uses @mosaic/mosaic for installs', async () => {
execSyncMock.mockImplementation((command: string) => {
if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({
dependencies: {
'@mosaic/mosaic': { version: '0.0.19' },
},
});
}
if (command.includes('view @mosaic/mosaic version')) {
return '0.0.20';
}
throw new Error(`Unexpected command: ${command}`);
});
const { checkForUpdate } = await importUpdateChecker();
const result = checkForUpdate({ skipCache: true });
const notice = formatUpdateNotice(result);
expect(result.current).toBe('0.0.19');
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');
});
it('does not query legacy @mosaic/cli package', async () => {
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')) {
return JSON.stringify({
dependencies: {
'@mosaic/mosaic': { version: '0.0.19' },
},
});
}
if (command.includes('view @mosaic/mosaic version')) {
return '0.0.20';
}
throw new Error(`Unexpected command: ${command}`);
});
const { checkForUpdate } = await importUpdateChecker();
const result = checkForUpdate({ skipCache: true });
expect(result.targetPackage).toBe('@mosaic/mosaic');
expect(result.latest).toBe('0.0.20');
// 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('returns empty result when package is not installed', async () => {
execSyncMock.mockImplementation((command: string) => {
if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({ dependencies: {} });
}
if (command.includes('view @mosaic/mosaic version')) {
return '';
}
throw new Error(`Unexpected command: ${command}`);
});
const { checkForUpdate } = await importUpdateChecker();
const result = checkForUpdate({ skipCache: true });
expect(result.current).toBe('');
expect(result.updateAvailable).toBe(false);
});
});

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaic/mosaic",
"version": "0.0.20",
"version": "0.0.19",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
@@ -18,7 +18,9 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./package.json": "./package.json",
"./framework/*": "./framework/*"
},
"scripts": {
"build": "tsc",

View File

@@ -12,7 +12,6 @@ import {
backgroundUpdateCheck,
checkForUpdate,
formatUpdateNotice,
getInstallCommand,
} from './runtime/update-checker.js';
import { runWizard } from './wizard.js';
import { ClackPrompter } from './prompter/clack-prompter.js';
@@ -355,7 +354,7 @@ program
try {
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
// globally or non-@mosaic deps will 404 against the Gitea registry.
execSync(getInstallCommand(result), {
execSync('npm install -g @mosaic/cli@latest', {
stdio: 'inherit',
timeout: 60_000,
});

View File

@@ -1,26 +1,21 @@
import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { 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;
@@ -41,198 +36,30 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
}
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
// Check existing installation
const existing = readMeta();
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 (existing) {
const answer = await prompt(
rl,
`Gateway already installed (v${existing.version}). Reinstall? [y/N] `,
);
console.error('\nRun `mosaic gateway uninstall` to clean up, then re-run install.');
return;
}
// 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.');
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
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) {
// Step 1: Install npm package
if (!opts.skipInstall) {
installGatewayPackage();
}
ensureDirs();
// 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> {
// Step 2: Collect configuration
console.log('\n─── Gateway Configuration ───\n');
// 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');
}
// Tier selection
console.log('Storage tier:');
console.log(' 1. Local (embedded database, no dependencies)');
console.log(' 2. Team (PostgreSQL + Valkey required)');
@@ -264,8 +91,10 @@ async function runConfigWizard(
const corsOrigin =
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
// Generate auth secret
const authSecret = randomBytes(32).toString('hex');
// Step 3: Write .env
const envLines = [
`GATEWAY_PORT=${port.toString()}`,
`BETTER_AUTH_SECRET=${authSecret}`,
@@ -287,6 +116,7 @@ async function runConfigWizard(
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'
? {
@@ -302,81 +132,66 @@ async function runConfigWizard(
memory: { type: 'pgvector' },
};
writeFileSync(MOSAIC_CONFIG_FILE, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
console.log(`Config written to ${MOSAIC_CONFIG_FILE}`);
const configFile = join(GATEWAY_HOME, 'mosaic.config.json');
writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
console.log(`Config written to ${configFile}`);
return port;
}
function readEnvVarFromFile(key: string): string | null {
if (!existsSync(ENV_FILE)) return null;
// Step 4: Write meta.json
let entryPoint: string;
try {
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);
}
entryPoint = resolveGatewayEntry();
} catch {
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})`);
console.error('Error: Gateway package not found after install.');
console.error('Check that @mosaic/gateway installed correctly.');
return;
}
try {
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(`Could not read log file: ${err instanceof Error ? err.message : String(err)}`);
}
}
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 <token> status`);
console.log(border);
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()})`);
} catch (err) {
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
return;
}
// 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`);
}
async function bootstrapFirstUser(
rl: ReturnType<typeof createInterface>,
host: string,
port: number,
meta: GatewayMeta,
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string },
): Promise<void> {
const baseUrl = `http://${host}:${port.toString()}`;
@@ -386,12 +201,7 @@ async function bootstrapFirstUser(
const status = (await statusRes.json()) as { needsSetup: boolean };
if (!status.needsSetup) {
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.)');
}
console.log('Admin user already exists — skipping setup.');
return;
}
} catch {
@@ -437,12 +247,12 @@ async function bootstrapFirstUser(
token: { plaintext: string };
};
// Persist the token so future CLI calls can authenticate automatically.
// Save admin token to meta
meta.adminToken = result.token.plaintext;
writeMeta(meta);
writeMeta(meta as GatewayMeta);
console.log(`\nAdmin user created: ${result.user.email}`);
printAdminTokenBanner(result.token.plaintext);
console.log('Admin API token saved to gateway config.');
} catch (err) {
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
}

View File

@@ -7,9 +7,9 @@
import { execFileSync, execSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Command } from 'commander';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -498,14 +498,10 @@ function delegateToScript(scriptPath: string, args: string[], env?: Record<strin
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
*/
function resolveTool(...segments: string[]): string {
try {
const req = createRequire(import.meta.url);
const mosaicPkg = dirname(req.resolve('@mosaic/mosaic/package.json'));
const bundled = join(mosaicPkg, 'framework', 'tools', ...segments);
if (existsSync(bundled)) return bundled;
} catch {
// Fall through to deployed copy
}
// Resolve relative to the built file: dist/commands/launch.js → ../../framework/tools/...
const thisFile = fileURLToPath(import.meta.url);
const bundled = join(dirname(thisFile), '..', '..', 'framework', 'tools', ...segments);
if (existsSync(bundled)) return bundled;
return join(MOSAIC_HOME, 'tools', ...segments);
}

View File

@@ -1,11 +1 @@
export const VERSION = '0.0.0';
export {
backgroundUpdateCheck,
checkForUpdate,
formatUpdateNotice,
getInstallCommand,
getInstalledVersion,
getLatestVersion,
semverLt,
} from './runtime/update-checker.js';

View File

@@ -1,6 +1,6 @@
/**
* Mosaic update checker — compares the installed @mosaic/mosaic package
* against the Gitea npm registry and reports when an upgrade is available.
* 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)
@@ -23,12 +23,8 @@ import { join } from 'node:path';
export interface UpdateCheckResult {
/** Currently installed version (empty if not found) */
current: string;
/** Currently installed package name */
currentPackage?: string;
/** Latest published version (empty if check failed) */
latest: string;
/** Package that should be installed for the latest version */
targetPackage?: string;
/** True when a newer version is available */
updateAvailable: boolean;
/** ISO timestamp of this check */
@@ -40,7 +36,7 @@ export interface UpdateCheckResult {
// ─── Constants ──────────────────────────────────────────────────────────────
const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
const PKG = '@mosaic/mosaic';
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
@@ -158,40 +154,31 @@ function writeCache(entry: RegistryCache): void {
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Get the currently installed @mosaic/mosaic version.
* Get the currently installed version of @mosaic/cli.
* Returns empty string if not installed.
*/
export function getInstalledVersion(): { name: string; version: string } {
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<string, { version?: string }>;
};
const version = data?.dependencies?.[PKG]?.version;
if (version) {
return { name: PKG, version };
}
return data?.dependencies?.[CLI_PKG]?.version ?? '';
}
} catch {
// fall through
}
return { name: '', version: '' };
return '';
}
/**
* Fetch the latest published @mosaic/mosaic version from the Gitea npm registry.
* Fetch the latest published version from the Gitea npm registry.
* Returns empty string on failure.
*/
export function getLatestVersion(): { name: string; version: string } {
const version = npmExec(`view ${PKG} version --registry=${REGISTRY}`);
if (version) {
return { name: PKG, version };
}
return { name: '', version: '' };
}
export function getInstallCommand(result: Pick<UpdateCheckResult, 'targetPackage'>): string {
return `npm i -g ${result.targetPackage || PKG}@latest`;
export function getLatestVersion(): string {
return npmExec(`view ${CLI_PKG} version --registry=${REGISTRY}`);
}
/**
@@ -201,45 +188,31 @@ export function getInstallCommand(result: Pick<UpdateCheckResult, 'targetPackage
* Never throws.
*/
export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult {
const currentInfo = getInstalledVersion();
const current = currentInfo.version;
const current = getInstalledVersion();
let latestInfo: { name: string; version: string };
let latest: string;
let checkedAt: string;
if (!options?.skipCache) {
const cached = readCache();
if (cached) {
latestInfo = {
name: PKG,
version: cached.latest,
};
latest = cached.latest;
checkedAt = cached.checkedAt;
} else {
latestInfo = getLatestVersion();
latest = getLatestVersion();
checkedAt = new Date().toISOString();
writeCache({
latest: latestInfo.version,
checkedAt,
registry: REGISTRY,
});
writeCache({ latest, checkedAt, registry: REGISTRY });
}
} else {
latestInfo = getLatestVersion();
latest = getLatestVersion();
checkedAt = new Date().toISOString();
writeCache({
latest: latestInfo.version,
checkedAt,
registry: REGISTRY,
});
writeCache({ latest, checkedAt, registry: REGISTRY });
}
return {
current,
currentPackage: currentInfo.name || PKG,
latest: latestInfo.version,
targetPackage: latestInfo.name || PKG,
updateAvailable: !!(current && latestInfo.version && semverLt(current, latestInfo.version)),
latest,
updateAvailable: !!(current && latest && semverLt(current, latest)),
checkedAt,
registry: REGISTRY,
};
@@ -251,8 +224,6 @@ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckRe
export function formatUpdateNotice(result: UpdateCheckResult): string {
if (!result.updateAvailable) return '';
const installCommand = getInstallCommand(result);
const lines = [
'',
'╭─────────────────────────────────────────────────╮',
@@ -260,7 +231,7 @@ export function formatUpdateNotice(result: UpdateCheckResult): string {
`│ Update available: ${result.current}${result.latest}`.padEnd(50) + '│',
'│ │',
'│ Run: bash tools/install.sh │',
`│ Or: ${installCommand}`.padEnd(50) + '│',
'│ Or: npm i -g @mosaic/cli@latest │',
'│ │',
'╰─────────────────────────────────────────────────╯',
'',

View File

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