Compare commits

..

7 Commits

Author SHA1 Message Date
495f73bfdb fix(wizard): avoid rerunning completed setup steps (#692)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 18:44:35 +00:00
b96cc7982a fix(wizard): report gateway failures before success summary (#691)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 18:14:40 +00:00
0883fb91ec fix(wizard): resolve skills sync script path (#690)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 17:35:19 +00:00
56787fabf1 fix(gateway): disable Redis consumers on local tier (#689)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 17:17:24 +00:00
940ae3cc41 feat(installer): prefer npm next lane (#688)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
--next now prefers a fast npm @next install (CLI + gateway from the Gitea registry) and falls back to source build at next if the dist-tag is unavailable. Registry lane gated to non-dev, non-explicit-ref next installs; CLI/gateway prerelease versions must share a pipeline suffix. Adds tools/install-next-lane.test.sh (wired into CI). PR-event CI 1635 fully green + review-of-record APPROVE (functional install test, head 2fd7cfc3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:14:24 +00:00
c25a551c28 ci(#462): add durable next publish pipeline (#687)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Durable @next integration-line publish: on next pushes, compute <patch+1>-next.<pipeline#> prerelease versions (in-CI, uncommitted) and publish @mosaicstack/* under the next dist-tag; gateway image sha-only on next. Strict guardrails: next-only, never writes latest, never tags from next; main path unchanged. PR-event CI 1631 fully green + review-of-record APPROVE (head b1a887a2). Guardrails independently verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 05:45:09 +00:00
94d6538061 feat(installer): add next integration lane (#686)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Add --next installer flag (build-from-source at the next integration branch; MOSAIC_NEXT=1 env equiv; explicit --ref wins). Three-lane install docs (stable @latest / --next prerelease / --dev source) + @next dist-tag pipeline design doc. Green PR-event CI 1626 + review-of-record APPROVE (head 3a5c12a5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 05:14:32 +00:00
29 changed files with 1311 additions and 154 deletions

View File

@@ -30,6 +30,16 @@ This installs both components:
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
After install, the wizard runs automatically or you can invoke it manually:
```bash
@@ -336,7 +346,9 @@ The CLI also performs a background update check on every invocation (cached for
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --next # Prerelease lane: source build from next
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
```

View File

@@ -1,9 +1,11 @@
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@@ -14,6 +16,9 @@ export class AdminHealthController {
@Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {}
@Get()
@@ -55,6 +60,14 @@ export class AdminHealthController {
}
private async checkCache(): Promise<ServiceStatusDto> {
// On Local tier there is no Redis. The cache is intentionally absent, which
// is a healthy state for this tier — report 'ok' rather than opening a new
// ioredis connection on every admin health check (which would spam
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
// signals "no cache backend to measure" for this tier.
if (this.mosaicConfig?.queue?.type === 'local') {
return { status: 'ok', latencyMs: 0 };
}
const start = Date.now();
const handle = createQueue();
try {

View File

@@ -21,7 +21,10 @@ export class CommandExecutorService {
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
@Optional()
@Inject(COMMANDS_REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(BRAIN) private readonly brain: Brain,
@Optional()
@Inject(forwardRef(() => ReloadService))
@@ -403,14 +406,16 @@ export class CommandExecutorService {
};
}
const pollToken = crypto.randomUUID();
const key = `mosaic:auth:poll:${pollToken}`;
// Store pending state in Valkey (TTL 5 minutes)
await this.redis.set(
key,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
const pollKey = `mosaic:auth:poll:${pollToken}`;
if (this.redis) {
// Store pending state in Valkey (TTL 5 minutes)
await this.redis.set(
pollKey,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
}
// In production this would construct an OAuth URL
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
return {

View File

@@ -1,5 +1,7 @@
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js';
@@ -14,13 +16,17 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
providers: [
{
provide: COMMANDS_QUEUE_HANDLE,
useFactory: (): QueueHandle => {
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
// On Local tier there is no Redis — skip the ioredis connection.
// CommandExecutorService falls back to no-cache for /provider login on local.
if (config?.queue?.type === 'local') return null;
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: COMMANDS_REDIS,
useFactory: (handle: QueueHandle) => handle.redis,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
inject: [COMMANDS_QUEUE_HANDLE],
},
CommandRegistryService,
@@ -29,9 +35,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
exports: [CommandRegistryService, CommandExecutorService],
})
export class CommandsModule implements OnApplicationShutdown {
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
constructor(
@Optional()
@Inject(COMMANDS_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> {
await this.handle.close().catch(() => {});
await this.handle?.close().catch(() => {});
}
}

View File

@@ -1,5 +1,7 @@
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js';
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
providers: [
{
provide: GC_QUEUE_HANDLE,
useFactory: (): QueueHandle => {
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
// On Local tier there is no Redis — skip the ioredis connection entirely.
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
if (config?.queue?.type === 'local') return null;
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: REDIS,
useFactory: (handle: QueueHandle) => handle.redis,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
inject: [GC_QUEUE_HANDLE],
},
SessionGCService,
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
exports: [SessionGCService],
})
export class GCModule implements OnApplicationShutdown {
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
constructor(
@Optional()
@Inject(GC_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> {
await this.handle.close().catch(() => {});
await this.handle?.close().catch(() => {});
}
}

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { Inject, Injectable, Logger, Optional, type OnModuleInit } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -32,11 +32,21 @@ export class SessionGCService implements OnModuleInit {
private readonly logger = new Logger(SessionGCService.name);
constructor(
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
// On Local tier there is no Redis — the GC module provides null for this token.
// NOTE: if a future feature stores Redis-backed state on Local tier, this guard
// would silently skip GC for those keys. Revisit when that happens.
@Optional()
@Inject(REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(LOG_SERVICE) private readonly logService: LogService,
) {}
onModuleInit(): void {
if (!this.redis) {
// Local tier: no Valkey — skip cold-start GC entirely (correct no-op).
this.logger.log('SessionGCService: Valkey GC skipped on local tier (no Redis configured)');
return;
}
// Fire-and-forget: run full GC asynchronously so it does not block the
// NestJS bootstrap chain. Cold-start GC typically takes 100500 ms
// depending on Valkey key count; deferring it removes that latency from
@@ -60,8 +70,10 @@ export class SessionGCService implements OnModuleInit {
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
* KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes.
* Returns empty array when Redis is not available (Local tier).
*/
private async scanKeys(pattern: string): Promise<string[]> {
if (!this.redis) return [];
const collected: string[] = [];
let cursor = '0';
do {
@@ -78,12 +90,14 @@ export class SessionGCService implements OnModuleInit {
async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys
const pattern = `mosaic:session:${sessionId}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
// 1. Valkey: delete all session-scoped keys (skipped on Local tier)
if (this.redis) {
const pattern = `mosaic:session:${sessionId}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
}
// 2. PG: demote hot-tier agent_logs for this session to warm
@@ -106,6 +120,7 @@ export class SessionGCService implements OnModuleInit {
const cleaned: GCResult[] = [];
// 1. Find all session-scoped Valkey keys (non-blocking SCAN)
// Returns empty on Local tier — no Valkey session keys exist there.
const allSessionKeys = await this.scanKeys('mosaic:session:*');
// Extract unique session IDs from keys
@@ -136,11 +151,15 @@ export class SessionGCService implements OnModuleInit {
*/
async fullCollect(): Promise<FullGCResult> {
const start = Date.now();
let valkeyKeysCount = 0;
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
const sessionKeys = await this.scanKeys('mosaic:session:*');
if (sessionKeys.length > 0) {
await this.redis.del(...sessionKeys);
if (this.redis) {
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
const sessionKeys = await this.scanKeys('mosaic:session:*');
if (sessionKeys.length > 0) {
await this.redis.del(...sessionKeys);
}
valkeyKeysCount = sessionKeys.length;
}
// 2. NOTE: channel keys are NOT collected on cold start
@@ -154,7 +173,7 @@ export class SessionGCService implements OnModuleInit {
const jobsPurged = 0;
return {
valkeyKeys: sessionKeys.length,
valkeyKeys: valkeyKeysCount,
logsDemoted,
jobsPurged,
tempFilesRemoved: 0,

View File

@@ -19,7 +19,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
@Injectable()
export class CronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CronService.name);
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService,
@@ -28,6 +28,16 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
) {}
async onModuleInit(): Promise<void> {
// On Local tier BullMQ is disabled — skip all job scheduling.
// NOTE: this means summarization, tier management, and Valkey GC jobs do not
// run on Local installs. For a single-user local install this is acceptable.
// If periodic background work is needed on Local in the future, add a
// setInterval-based scheduler here.
if (!this.queueService.isEnabled()) {
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
return;
}
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
@@ -42,7 +52,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
await this.summarization.runSummarization();
});
this.registeredWorkers.push(summarizationWorker);
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
// M6-005: Tier management repeatable job
await this.queueService.addRepeatableJob(
@@ -54,14 +64,14 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
await this.summarization.runTierManagement();
});
this.registeredWorkers.push(tierWorker);
if (tierWorker) this.registeredWorkers.push(tierWorker);
// M6-004: GC repeatable job
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
await this.sessionGC.sweepOrphans();
});
this.registeredWorkers.push(gcWorker);
if (gcWorker) this.registeredWorkers.push(gcWorker);
this.logger.log(
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,

View File

@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
@@ -11,16 +13,54 @@ interface OverrideFragment {
addedAt: number;
}
@Injectable()
export class SystemOverrideService {
private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle;
interface LocalOverrideEntry {
condensed: string;
fragments: OverrideFragment[];
}
constructor() {
this.handle = createQueue();
@Injectable()
export class SystemOverrideService implements OnApplicationShutdown {
private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle | null;
/**
* In-memory fallback used on Local tier (no Redis).
* NOTE: state is ephemeral — lost on restart. For Local single-user installs
* this is acceptable; system overrides are re-applied at the next session.
* This is a deliberate behavior change from the Redis-backed 7-day TTL.
*/
private readonly localStore = new Map<string, LocalOverrideEntry>();
constructor(
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
if (this.mosaicConfig?.queue?.type === 'local') {
this.handle = null;
} else {
this.handle = createQueue();
}
}
async onApplicationShutdown(): Promise<void> {
// On non-local tiers the constructor opens an ioredis connection; close it
// on graceful shutdown to avoid leaking the handle (local tier is null).
await this.handle?.close().catch(() => {});
}
async set(sessionId: string, override: string): Promise<void> {
if (!this.handle) {
// Local tier: in-memory path
const entry = this.localStore.get(sessionId) ?? { condensed: '', fragments: [] };
entry.fragments.push({ text: override, addedAt: Date.now() });
entry.condensed = await this.condenseOverrides(entry.fragments.map((f) => f.text));
this.localStore.set(sessionId, entry);
this.logger.debug(
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
);
return;
}
// Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
const fragments: OverrideFragment[] = existing
@@ -50,10 +90,17 @@ export class SystemOverrideService {
}
async get(sessionId: string): Promise<string | null> {
if (!this.handle) {
return this.localStore.get(sessionId)?.condensed ?? null;
}
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
}
async renew(sessionId: string): Promise<void> {
if (!this.handle) {
// Local tier: no TTL to renew; entry persists until restart
return;
}
const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
@@ -61,6 +108,11 @@ export class SystemOverrideService {
}
async clear(sessionId: string): Promise<void> {
if (!this.handle) {
this.localStore.delete(sessionId);
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
return;
}
await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),

View File

@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
import { QueueService } from './queue.service.js';
const localConfig = {
queue: { type: 'local' },
} as MosaicConfig;
describe('QueueService local tier', () => {
it('disables BullMQ and treats queue operations as local no-ops', async () => {
const service = new QueueService(null, localConfig);
expect(service.isEnabled()).toBe(false);
expect(service.getQueue('mosaic-test')).toBeNull();
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
await expect(
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
).resolves.toBeUndefined();
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
await expect(service.listJobs()).resolves.toEqual([]);
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
});
});

View File

@@ -8,7 +8,9 @@ import {
} from '@nestjs/common';
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
import type { LogService } from '@mosaicstack/log';
import type { MosaicConfig } from '@mosaicstack/config';
import { LOG_SERVICE } from '../log/log.tokens.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import type { JobDto, JobStatus } from './queue-admin.dto.js';
// ---------------------------------------------------------------------------
@@ -108,21 +110,42 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly connection: ConnectionOptions;
private readonly queues = new Map<string, Queue<MosaicJobData>>();
private readonly workers = new Map<string, Worker<MosaicJobData>>();
/** False on Local tier — BullMQ/Redis operations become no-ops. */
private readonly enabled: boolean;
constructor(
@Optional()
@Inject(LOG_SERVICE)
private readonly logService: LogService | null,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
this.connection = getConnection();
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
this.connection = this.enabled
? getConnection()
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
}
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
isEnabled(): boolean {
return this.enabled;
}
onModuleInit(): void {
this.logger.log('QueueService initialised (BullMQ)');
if (this.enabled) {
this.logger.log('QueueService initialised (BullMQ)');
} else {
this.logger.log(
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
);
}
}
async onModuleDestroy(): Promise<void> {
await this.closeAll();
if (this.enabled) {
await this.closeAll();
}
}
// -------------------------------------------------------------------------
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Get or create a BullMQ Queue for the given queue name.
* Returns null on Local tier where BullMQ is disabled.
*/
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
if (!this.enabled) return null;
let queue = this.queues.get(name) as Queue<T> | undefined;
if (!queue) {
queue = new Queue<T>(name, { connection: this.connection });
@@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Add a BullMQ repeatable job (cron-style).
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
* No-op on Local tier.
*/
async addRepeatableJob<T extends MosaicJobData>(
queueName: string,
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
data: T,
cronExpression: string,
): Promise<void> {
const queue = this.getQueue<T>(queueName);
if (!this.enabled) {
this.logger.debug(
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return;
}
const queue = this.getQueue<T>(queueName)!;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (queue as Queue<any>).add(jobName, data, {
repeat: { pattern: cronExpression },
@@ -165,8 +197,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Register a Worker for the given queue name with error handling and
* exponential backoff.
* Returns null on Local tier where BullMQ is disabled.
*/
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
registerWorker<T extends MosaicJobData>(
queueName: string,
handler: JobHandler<T>,
): Worker<T> | null {
if (!this.enabled) {
this.logger.debug(
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
);
return null;
}
const worker = new Worker<T>(
queueName,
async (job) => {
@@ -223,8 +265,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Return queue health statistics for all managed queues.
* Returns an empty healthy result on Local tier.
*/
async getHealthStatus(): Promise<QueueHealthStatus> {
if (!this.enabled) {
return { queues: {}, healthy: true };
}
const queues: QueueHealthStatus['queues'] = {};
let healthy = true;
@@ -255,8 +301,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* List jobs across all managed queues, optionally filtered by status.
* BullMQ jobs are fetched by state type from each queue.
* Returns empty array on Local tier.
*/
async listJobs(status?: JobStatus): Promise<JobDto[]> {
if (!this.enabled) return [];
const jobs: JobDto[] = [];
const states: JobStatus[] = status
? [status]
@@ -283,8 +331,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
* job IDs are not globally unique — they are scoped to their queue.
* Returns an error on Local tier.
*/
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const sep = compositeId.lastIndexOf('__');
if (sep === -1) {
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
@@ -316,6 +366,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Pause a queue by name.
*/
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.pause();
@@ -327,6 +378,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Resume a paused queue by name.
*/
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.resume();

View File

@@ -0,0 +1,63 @@
# npm `@next` prerelease lane
Status: **IMPLEMENTED**
## Current behavior
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
The lane is fast-by-default:
1. Install framework files from the `next` source archive.
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
```bash
npm view @mosaicstack/gateway@next version
npm view @mosaicstack/mosaic@next version
```
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
## Published packages
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
```text
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
```
Observed `next` dist-tags after enabling the pipeline:
```text
@mosaicstack/mosaic@next -> 0.0.49-next.1633
@mosaicstack/gateway@next -> 0.0.7-next.1633
```
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
## Explicit source lanes
Source builds remain available and are still the authority for explicit ref validation:
- `--dev` always builds from source.
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
## Pipeline shape
1. Trigger on `next` merges.
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
3. Build and publish non-private packages in CI.
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
5. Keep `latest` untouched; only main/release promotion can update `latest`.
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
## Guardrails
- `@next` is mutable prerelease convenience, not a deployment pin.
- Stable installs continue to use `@latest`.
- Contributor validation remains available through `--dev --ref <branch>`.
- Pipeline output traces every prerelease package back to the source commit on `next`.
- The installer falls back to source rather than hard-failing on prerelease registry issues.

View File

@@ -175,8 +175,18 @@ Or use the direct URL:
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
non-interactive use:
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
Install lanes:
| Lane | Command | Source |
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
Flags for non-interactive use:
```bash
--yes # Accept all defaults

View File

@@ -0,0 +1,34 @@
# B2 — Fresh-install skills sync path
## Problem
Greenfield wizard on `next` reported:
```text
Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills
Skills: install failed
```
## Diagnosis
The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under:
```text
~/.config/mosaic/tools/_scripts/
```
`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout.
## Fix
- Resolve framework helper scripts through `tools/_scripts/<name>` first.
- Keep a legacy `bin/<name>` fallback for pre-migration installs.
- Point missing-script warnings at the current `tools/_scripts` layout.
- Update the finalize skills test fixture to model the fresh framework layout.
- Update framework README examples from legacy `bin/` helper paths to `tools/_scripts/`.
## Verification
- Unit: `pnpm --filter @mosaicstack/mosaic test -- finalize-skills`
- Gates: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build`
- Fresh path: ran `packages/mosaic/framework/install.sh` with a temp `MOSAIC_HOME` and `MOSAIC_SYNC_ONLY=1`; verified `tools/_scripts/mosaic-sync-skills` exists, legacy `bin/mosaic-sync-skills` does not, and the script installs a selected fake `lint` skill into Mosaic + Pi runtime skill directories.

View File

@@ -0,0 +1,36 @@
# B3 — Wizard completion ordering
## Problem
The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure.
## Diagnosis
`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward:
1. finalize writes config, links runtime assets, syncs skills, runs doctor;
2. finalize prints `Installation Summary` + `Mosaic is ready.`;
3. gateway config starts/waits for daemon health;
4. gateway bootstrap runs.
The summary needed to be deferred until after the gateway readiness gates.
## Fix
- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`.
- Wizard/quick-start paths call finalize with `deferSummary: true`.
- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup.
- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary.
- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`.
## Verification
- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change.

View File

@@ -0,0 +1,36 @@
# B4 — Wizard step deduplication
## Problem
Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run.
There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty.
## Diagnosis
- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row.
- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`.
- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish:
- provider step completed and user intentionally skipped the key, vs.
- provider step was never run.
## Fix
- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`.
- Completed menu steps now log a skip message instead of re-running their stage.
- Quick Start marks Providers and Skills complete after running them.
- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`.
- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt.
## Verification
- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once.
- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: no findings.

View File

@@ -0,0 +1,40 @@
# Installer `--next` fast npm lane — 2026-06-25
## Scope
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
## Registry reality check
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
Verified before implementation:
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
## Behavior
- `--next` with no explicit ref:
1. framework archive from `next`;
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
3. require both resolved versions to share the same `next.<pipeline>` suffix;
4. install the exact resolved package versions;
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
- `--dev` remains pure source build.
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
## Install detail
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
## Verification notes
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
- Real temp-prefix fast install succeeded with `@mosaicstack/gateway@0.0.7-next.1633` and `@mosaicstack/mosaic@0.0.49-next.1633`.

View File

@@ -0,0 +1,35 @@
# Scratchpad — installer `--next` lane
## Objective
Add a prerelease installer lane for the permanent `next` integration branch.
## Scope
- `tools/install.sh`
- README/install documentation
- Follow-up design note for future npm `@next` prerelease publishing
## Plan
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
3. Update installer source display/help text.
4. Document three lanes:
- stable npm `@latest`
- prerelease `--next`
- contributor `--dev --ref X`
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
## Verification
- `bash -n tools/install.sh` — pass.
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
- `pnpm typecheck` — pass (41 successful tasks).
- `pnpm lint` — pass (23 successful tasks).
- `pnpm format:check` — pass.
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.

View File

@@ -7,7 +7,8 @@
"dev": "turbo run dev",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"test": "turbo run test && pnpm run test:installer",
"test:installer": "bash tools/install-next-lane.test.sh",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "husky"

View File

@@ -11,9 +11,37 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { createConfigService } from '../../src/config/config-service.js';
import type { SelectOption } from '../../src/prompter/interface.js';
import type { MenuSection, WizardState } from '../../src/types.js';
const gatewayConfigMock = vi.fn();
const gatewayBootstrapMock = vi.fn();
const providerSetupMock = vi.fn();
const skillsSelectMock = vi.fn();
class SequencedMenuPrompter extends HeadlessPrompter {
constructor(
answers: Record<string, string | boolean | string[]>,
private readonly menuChoices: string[],
) {
super(answers);
}
override async select<T>(opts: {
message: string;
options: SelectOption<T>[];
initialValue?: T;
}): Promise<T> {
if (opts.message === 'What would you like to configure?') {
const next = this.menuChoices.shift();
if (!next) throw new Error('No queued menu choice left');
const match = opts.options.find((o) => String(o.value) === next);
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
return match.value;
}
return super.select(opts);
}
}
vi.mock('../../src/stages/gateway-config.js', () => ({
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
@@ -23,6 +51,14 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
}));
vi.mock('../../src/stages/provider-setup.js', () => ({
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
}));
vi.mock('../../src/stages/skills-select.js', () => ({
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
}));
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
import { runWizard } from '../../src/wizard.js';
@@ -44,6 +80,16 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
}
gatewayConfigMock.mockReset();
gatewayBootstrapMock.mockReset();
providerSetupMock.mockReset();
skillsSelectMock.mockReset();
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.providerType = 'none';
state.completedSections?.add('providers' satisfies MenuSection);
});
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.selectedSkills = [];
state.completedSections?.add('skills' satisfies MenuSection);
});
// Pretend we're on an interactive TTY so the wizard's headless-abort
// branch does not call `process.exit(1)` during these tests.
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
@@ -98,8 +144,12 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
});
it('does not invoke bootstrap when config stage reports not ready', async () => {
gatewayConfigMock.mockResolvedValue({ ready: false });
it('prints the success summary only after gateway health succeeds', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
p.log('Gateway is healthy.');
return { ready: true, host: 'localhost', port: 14242 };
});
gatewayBootstrapMock.mockResolvedValue({ completed: true });
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
@@ -118,6 +168,43 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
skipGatewayNpmInstall: true,
});
const logs = prompter.getLogs();
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
expect(healthIndex).toBeGreaterThanOrEqual(0);
expect(summaryIndex).toBeGreaterThan(healthIndex);
expect(readyIndex).toBeGreaterThan(summaryIndex);
});
it('does not claim success when gateway health reports not ready', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
p.warn('Gateway did not become healthy within 30 seconds.');
return { ready: false };
});
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGatewayNpmInstall: true,
});
const logs = prompter.getLogs();
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
});
@@ -143,4 +230,34 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(gatewayConfigMock).not.toHaveBeenCalled();
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
});
it('does not re-run completed provider or skills menu steps', async () => {
const prompter = new SequencedMenuPrompter(
{
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
},
['providers', 'providers', 'skills', 'skills', 'finish'],
);
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGateway: true,
});
expect(providerSetupMock).toHaveBeenCalledTimes(1);
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
expect(prompter.getLogs()).toEqual(
expect.arrayContaining([
expect.stringContaining('Providers [done] is already complete; skipping.'),
expect.stringContaining('Skills [done] is already complete; skipping.'),
]),
);
});
});

View File

@@ -43,6 +43,16 @@ The installer:
- Runs a health audit
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` wins and uses the source path.
## First Run
After install, open a new terminal (or `source ~/.bashrc`) and run:
@@ -108,8 +118,8 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
├── STANDARDS.md ← Machine-wide standards
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
├── runtime/ ← Runtime adapters + runtime-specific references
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
│ ├── codex/ ← instructions.md, RUNTIME.md
@@ -174,7 +184,9 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
```
## Universal Skills
@@ -182,15 +194,15 @@ bash tools/install.sh --ref v1.0 # Install from a specific git ref
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.
```bash
mosaic sync # Full sync (clone + link)
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
mosaic sync # Full sync (clone + link)
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
```
## Health Audit
```bash
mosaic doctor # Standard audit
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
mosaic doctor # Standard audit
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
```
## MCP Registration
@@ -201,8 +213,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
To verify or re-register manually:
```bash
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
```
### Claude Code MCP Registration

View File

@@ -37,7 +37,7 @@ const RUNTIME_DEFS: Record<
label: 'Pi',
command: 'pi',
versionFlag: '--version',
installHint: 'npm install -g @mariozechner/pi-coding-agent',
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
},
};

View File

@@ -85,16 +85,16 @@ function makeConfigService(): ConfigService {
describe('finalizeStage — skill installer', () => {
let tmp: string;
let binDir: string;
let scriptsDir: string;
let syncScript: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
binDir = join(tmp, 'bin');
mkdirSync(binDir, { recursive: true });
syncScript = join(binDir, 'mosaic-sync-skills');
scriptsDir = join(tmp, 'tools', '_scripts');
mkdirSync(scriptsDir, { recursive: true });
syncScript = join(scriptsDir, 'mosaic-sync-skills');
// Default: script exists and succeeds
// Default: current framework layout has tools/_scripts and succeeds.
writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
});
@@ -122,10 +122,29 @@ describe('finalizeStage — skill installer', () => {
const call = findSkillsSyncCall();
expect(call).toBeDefined();
expect(call![1]).toEqual([join(tmp, 'tools', '_scripts', 'mosaic-sync-skills')]);
const opts = call![2] as { env?: Record<string, string> };
expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
});
it('falls back to legacy bin path for pre-migration installs', async () => {
rmSync(syncScript);
const legacyBinDir = join(tmp, 'bin');
mkdirSync(legacyBinDir, { recursive: true });
const legacySyncScript = join(legacyBinDir, 'mosaic-sync-skills');
writeFileSync(legacySyncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
const state = makeState(tmp, ['brainstorming']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
const call = findSkillsSyncCall();
expect(call).toBeDefined();
expect(call![1]).toEqual([legacySyncScript]);
});
it('skips the sync script entirely when no skills are selected', async () => {
const state = makeState(tmp, []);
const p = buildPrompter();
@@ -165,7 +184,9 @@ describe('finalizeStage — skill installer', () => {
// spawnSync should NOT have been called for the skills script
expect(findSkillsSyncCall()).toBeUndefined();
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(p.warn).toHaveBeenCalledWith(
expect.stringContaining('tools/_scripts/mosaic-sync-skills'),
);
});
it('includes skills count in the summary when install succeeds', async () => {

View File

@@ -7,8 +7,21 @@ import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js';
import { getShellProfilePath } from '../platform/detect.js';
function frameworkScriptPath(mosaicHome: string, name: string): string {
const currentPath = join(mosaicHome, 'tools', '_scripts', name);
if (existsSync(currentPath)) return currentPath;
// Backward-compatible fallback for pre-migration installs that still have bin/.
const legacyPath = join(mosaicHome, 'bin', name);
if (existsSync(legacyPath)) return legacyPath;
// Return the current expected path so user-facing errors point at the layout
// installed by packages/mosaic/framework/install.sh.
return currentPath;
}
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
const script = frameworkScriptPath(mosaicHome, 'mosaic-link-runtime-assets');
if (existsSync(script)) {
try {
spawnSync('bash', [script], {
@@ -48,7 +61,7 @@ function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsRes
return { success: true, installedCount: 0 };
}
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
const script = frameworkScriptPath(mosaicHome, 'mosaic-sync-skills');
if (!existsSync(script)) {
return {
success: false,
@@ -96,7 +109,7 @@ interface DoctorResult {
}
function runDoctor(mosaicHome: string): DoctorResult {
const script = join(mosaicHome, 'bin', 'mosaic-doctor');
const script = frameworkScriptPath(mosaicHome, 'mosaic-doctor');
if (!existsSync(script)) {
return { warnings: 0, output: 'mosaic-doctor not found' };
}
@@ -149,11 +162,24 @@ function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
}
}
export interface FinalizeStageOptions {
/**
* Defer the success summary/outro so callers can run downstream readiness
* gates (gateway health/bootstrap) before claiming Mosaic is ready.
*/
deferSummary?: boolean;
}
export interface FinalizeStageResult {
showSummary: () => void;
}
export async function finalizeStage(
p: WizardPrompter,
state: WizardState,
config: ConfigService,
): Promise<void> {
options: FinalizeStageOptions = {},
): Promise<FinalizeStageResult> {
p.separator();
const spin = p.spinner();
@@ -200,44 +226,56 @@ export async function finalizeStage(
// 6. PATH setup
const pathAction = setupPath(state.mosaicHome, p);
// 7. Summary
const skillsSummary = skillsResult.success
? skillsResult.installedCount > 0
? `${skillsResult.installedCount.toString()} installed`
: 'none selected'
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
let summaryShown = false;
const showSummary = () => {
if (summaryShown) return;
summaryShown = true;
const summary: string[] = [
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
`Skills: ${skillsSummary}`,
`Config: ${state.mosaicHome}`,
];
// 7. Summary
const skillsSummary = skillsResult.success
? skillsResult.installedCount > 0
? `${skillsResult.installedCount.toString()} installed`
: 'none selected'
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
if (doctorResult.warnings > 0) {
summary.push(
`Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`,
);
} else {
summary.push('Health: all checks passed');
const summary: string[] = [
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
`Skills: ${skillsSummary}`,
`Config: ${state.mosaicHome}`,
];
if (doctorResult.warnings > 0) {
summary.push(
`Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`,
);
} else {
summary.push('Health: all checks passed');
}
p.note(summary.join('\n'), 'Installation Summary');
// 8. Next steps
const nextSteps: string[] = [];
if (pathAction === 'added') {
const profilePath = getShellProfilePath();
nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`);
}
if (state.runtimes.detected.length === 0) {
nextSteps.push('Install at least one runtime (claude, codex, or opencode)');
}
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning');
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
p.outro('Mosaic is ready.');
};
if (!options.deferSummary) {
showSummary();
}
p.note(summary.join('\n'), 'Installation Summary');
// 8. Next steps
const nextSteps: string[] = [];
if (pathAction === 'added') {
const profilePath = getShellProfilePath();
nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`);
}
if (state.runtimes.detected.length === 0) {
nextSteps.push('Install at least one runtime (claude, codex, or opencode)');
}
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning');
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
p.outro('Mosaic is ready.');
return { showSummary };
}

View File

@@ -167,6 +167,45 @@ describe('gatewayConfigStage', () => {
expect(state.gateway?.regeneratedConfig).toBe(true);
});
it('does not ask for a gateway API key when provider setup was completed with no key', async () => {
delete process.env['MOSAIC_ASSUME_YES'];
const originalIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
try {
const textFn = vi.fn(async (opts: { message: string; initialValue?: string }) => {
if (opts.message === 'Gateway port') return opts.initialValue ?? '14242';
if (opts.message === 'Web UI hostname (for browser access)') return 'localhost';
if (opts.message.includes('API_KEY')) {
throw new Error('gateway API key prompt should be skipped');
}
return '';
});
const p = buildPrompter({ text: textFn, select: vi.fn().mockResolvedValue('local') });
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: true,
providerType: 'none',
});
expect(result.ready).toBe(true);
expect(textFn).not.toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('API_KEY') }),
);
const envContents = readFileSync(daemonState.envFile, 'utf-8');
expect(envContents).not.toContain('ANTHROPIC_API_KEY=');
expect(envContents).not.toContain('OPENAI_API_KEY=');
} finally {
Object.defineProperty(process.stdin, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
}
});
it('short-circuits when gateway is already fully installed and user declines rerun', async () => {
// Pre-populate both files + running daemon + meta with token
const fs = require('node:fs');

View File

@@ -506,6 +506,9 @@ async function collectAndWriteConfig(
if (opts.providerKey) {
anthropicKey = opts.providerKey;
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
} else if (opts.providerType === 'none') {
anthropicKey = '';
p.log('No API key provided during provider setup; skipping gateway API key prompt.');
} else {
anthropicKey = await p.text({
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',

View File

@@ -37,6 +37,7 @@ export async function quickStartPath(
// 1. Provider setup (first question)
await providerSetupStage(prompter, state);
state.completedSections?.add('providers');
// Apply sensible defaults for everything else
state.soul.agentName ??= 'Mosaic';
@@ -57,9 +58,13 @@ export async function quickStartPath(
// Skills (recommended set, no user input in quick mode)
await skillsSelectStage(prompter, state);
state.completedSections?.add('skills');
// Finalize (writes configs, links runtime assets, syncs skills)
await finalizeStage(prompter, state, configService);
// Finalize writes configs/assets/skills, but defer the success summary until
// after the gateway health/bootstrap gates complete.
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway config + bootstrap
if (!options.skipGateway) {
@@ -72,7 +77,7 @@ export async function quickStartPath(
portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey,
providerType: state.providerType ?? 'none',
providerType: state.providerType,
});
if (!configResult.ready || !configResult.host || !configResult.port) {
@@ -80,19 +85,24 @@ export async function quickStartPath(
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1);
}
} else {
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
port: configResult.port,
});
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
}
return;
}
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
port: configResult.port,
});
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
return;
}
finalizeResult.showSummary();
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
} else {
finalizeResult.showSummary();
}
}

View File

@@ -126,6 +126,11 @@ type MenuChoice =
| 'advanced'
| 'finish';
function menuSectionKey(section: MenuChoice): MenuSection | null {
if (section === 'quick-start' || section === 'finish') return null;
return section === 'gateway-config' ? 'gateway' : section;
}
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
const labels: Record<MenuChoice, string> = {
'quick-start': 'Quick Start',
@@ -137,14 +142,24 @@ function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
finish: 'Finish & Apply',
};
const base = labels[section];
const sectionKey: MenuSection =
section === 'gateway-config' ? 'gateway' : (section as MenuSection);
if (completed.has(sectionKey)) {
const sectionKey = menuSectionKey(section);
if (sectionKey && completed.has(sectionKey)) {
return `${base} [done]`;
}
return base;
}
function skipCompletedMenuChoice(
prompter: WizardPrompter,
completed: Set<MenuSection>,
choice: MenuChoice,
): boolean {
const sectionKey = menuSectionKey(choice);
if (!sectionKey || !completed.has(sectionKey)) return false;
prompter.log(`${menuLabel(choice, completed)} is already complete; skipping.`);
return true;
}
async function runMenuLoop(
prompter: WizardPrompter,
state: WizardState,
@@ -201,21 +216,25 @@ async function runMenuLoop(
return; // Quick start is a complete flow — exit menu
case 'providers':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await providerSetupStage(prompter, state);
completed.add('providers');
break;
case 'identity':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await agentIntentStage(prompter, state);
completed.add('identity');
break;
case 'skills':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await skillsSelectStage(prompter, state);
completed.add('skills');
break;
case 'gateway-config':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
// Gateway config is handled during Finish — mark as "configured"
// after user reviews settings.
await runGatewaySubMenu(prompter, state, options);
@@ -223,6 +242,7 @@ async function runMenuLoop(
break;
case 'advanced':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await runAdvancedSubMenu(prompter, state);
completed.add('advanced');
break;
@@ -310,8 +330,11 @@ async function runFinishPath(
await skillsSelectStage(prompter, state);
}
// Finalize (writes configs, links runtime assets, syncs skills)
await finalizeStage(prompter, state, configService);
// Finalize writes configs/assets/skills, but defer the success summary until
// after the gateway health/bootstrap gates complete.
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages
if (!options.skipGateway) {
@@ -322,7 +345,7 @@ async function runFinishPath(
portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey,
providerType: state.providerType ?? 'none',
providerType: state.providerType,
});
if (configResult.ready && configResult.host && configResult.port) {
@@ -333,12 +356,16 @@ async function runFinishPath(
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
return;
}
finalizeResult.showSummary();
}
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
} else {
finalizeResult.showSummary();
}
}
@@ -374,8 +401,11 @@ async function runHeadlessPath(
// Skills
await skillsSelectStage(prompter, state);
// Finalize
await finalizeStage(prompter, state, configService);
// Finalize writes configs/assets/skills, but defer the success summary until
// after the gateway health/bootstrap gates complete.
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages
if (!options.skipGateway) {
@@ -386,26 +416,31 @@ async function runHeadlessPath(
portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey,
providerType: state.providerType ?? 'none',
providerType: state.providerType,
});
if (!configResult.ready || !configResult.host || !configResult.port) {
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1);
} else {
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
port: configResult.port,
});
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
}
return;
}
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
port: configResult.port,
});
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
return;
}
finalizeResult.showSummary();
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
} else {
finalizeResult.showSummary();
}
}
@@ -426,8 +461,11 @@ async function runKeepPath(
// Skills
await skillsSelectStage(prompter, state);
// Finalize
await finalizeStage(prompter, state, configService);
// Finalize writes configs/assets/skills, but defer the success summary until
// after the gateway health/bootstrap gates complete.
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages
if (!options.skipGateway) {
@@ -447,11 +485,15 @@ async function runKeepPath(
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
return;
}
finalizeResult.showSummary();
}
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
} else {
finalizeResult.showSummary();
}
}

222
tools/install-next-lane.test.sh Executable file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
FAKE_BIN="$TMP/bin"
HOME_DIR="$TMP/home"
PREFIX="$TMP/prefix"
MOSAIC_HOME="$TMP/mosaic"
STATE="$TMP/state"
LOG="$TMP/npm.log"
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
STATE="${MOSAIC_TEST_STATE:?}"
echo "$*" >> "$LOG"
if [[ "$1" == "view" ]]; then
case "$2 $3" in
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
"@mosaicstack/mosaic version") echo "0.0.48" ;;
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "install" ]]; then
case "$*" in
*"@mosaicstack/mosaic@0.0.49-next.999"*)
echo "0.0.49-next.999" > "$STATE/mosaic"
;;
*"@mosaicstack/gateway@0.0.7-next.999"*)
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
echo "forced gateway install failure" >&2
exit 1
fi
echo "0.0.7-next.999" > "$STATE/gateway"
;;
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/mosaic"
;;
*"mosaicstack-gateway-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/gateway"
;;
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "ls" ]]; then
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
node -e '
const cli = process.argv[1];
const gateway = process.argv[2];
const dependencies = {};
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
process.stdout.write(JSON.stringify({ dependencies }));
' "$cli" "$gateway"
exit 0
fi
echo "unexpected npm command: $*" >&2
exit 1
FAKE_NPM
chmod +x "$FAKE_BIN/npm"
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
#!/usr/bin/env bash
set -euo pipefail
# The fake tar creates the source tree; curl only needs to keep the pipe alive.
exit 0
FAKE_CURL
chmod +x "$FAKE_BIN/curl"
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
#!/usr/bin/env bash
set -euo pipefail
dest=""
while [[ $# -gt 0 ]]; do
case "$1" in
-C) dest="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$dest" ]]; then
echo "fake tar missing -C destination" >&2
exit 1
fi
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
FAKE_TAR
chmod +x "$FAKE_BIN/tar"
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
echo "pnpm $*" >> "$LOG"
if [[ "$1" == "pack" ]]; then
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--pack-destination) out="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$out" ]]; then
echo "fake pnpm pack missing destination" >&2
exit 1
fi
mkdir -p "$out"
case "$PWD" in
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
esac
exit 0
fi
# install/build commands are no-ops in this harness.
exit 0
FAKE_PNPM
chmod +x "$FAKE_BIN/pnpm"
reset_state() {
: > "$LOG"
rm -f "$STATE"/*
}
reset_state
echo "[test] --next fast path pins resolved package versions"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/gateway@0.0.7-next.999' "$LOG"
grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG"
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
echo "expected exact-version installs, found mutable @next install" >&2
exit 1
fi
if grep -qF 'Downloading source from next' <<<"$OUTPUT"; then
echo "fast path unexpectedly fell back to source" >&2
exit 1
fi
reset_state
echo "[test] fast path failure falls back to source build"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
grep -qF 'Downloading source from next' <<<"$OUTPUT"
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG"
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
reset_state
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
)"
grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT"
if grep -qF '@next version' "$LOG"; then
echo "explicit ref should not query @next dist-tags" >&2
exit 1
fi
reset_state
echo "[test] --check --next warns on mismatched prerelease pipeline suffixes"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next
)"
grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT"
echo "[test] installer next lane tests passed"

View File

@@ -16,6 +16,10 @@
# --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main)
# --next Prerelease lane: try fast npm @next install for CLI +
# gateway from the Gitea registry, then fall back to a
# source build at next if unavailable. Explicit
# --ref/MOSAIC_REF wins and uses the source path.
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the
# registry @latest. Zero registry writes — packs local
# tarballs and installs them globally. Use to test a branch
@@ -31,6 +35,7 @@
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_NEXT — equivalent to --next (set to 1)
# MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ──────────────────────────────────────────────────────────────────────────────
@@ -49,7 +54,12 @@ FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false
FLAG_UNINSTALL=false
FLAG_DEV=false
FLAG_NEXT=false
GIT_REF="${MOSAIC_REF:-main}"
GIT_REF_EXPLICIT=false
if [[ -n "${MOSAIC_REF:-}" ]]; then
GIT_REF_EXPLICIT=true
fi
# MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
@@ -61,13 +71,24 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true
fi
# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
# source fallback from the permanent next integration branch unless
# MOSAIC_REF/--ref explicitly wins.
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
FLAG_NEXT=true
if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
GIT_REF="next"
fi
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; shift 2 ;;
--ref) GIT_REF="${2:-main}"; GIT_REF_EXPLICIT=true; shift 2 ;;
--dev) FLAG_DEV=true; shift ;;
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
@@ -75,12 +96,24 @@ while [[ $# -gt 0 ]]; do
esac
done
# Explicit refs represent a request for that exact source tree. Keep --next as
# a lane selector, but do not install the registry @next package for a different
# ref than the permanent next branch.
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
FLAG_DEV=true
fi
if [[ "$FLAG_YES" == "true" ]]; then
export MOSAIC_ASSUME_YES=1
fi
# ─── constants ────────────────────────────────────────────────────────────────
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/mosaic"
GATEWAY_PKG="${SCOPE}/gateway"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
@@ -95,6 +128,20 @@ fi
WORK_DIR=""
EXTRACTED_DIR=""
newest_matching_file() {
local dir="$1"
local pattern="$2"
local matches=()
[[ -d "$dir" ]] || return 0
shopt -s nullglob
# shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern.
matches=("$dir"/$pattern)
shopt -u nullglob
[[ "${#matches[@]}" -gt 0 ]] || return 0
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
ls -1t "${matches[@]}" 2>/dev/null | head -1
}
# ─── uninstall path ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line.
@@ -158,7 +205,7 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
# Find most recent backup
backup=""
if [[ -d "$dir" ]]; then
backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
fi
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
cp "$backup" "$dest"
@@ -214,6 +261,22 @@ fail() { echo "${R}✖${RESET} $*" >&2; }
dim() { echo "${DIM}$*${RESET}"; }
step() { echo ""; echo "${BOLD}$*${RESET}"; }
is_next_registry_lane() {
[[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
}
source_ref_details() {
if is_next_registry_lane; then
echo "ref: next, --next prerelease lane"
elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
echo "ref: next, --next prerelease lane (build-from-source)"
elif [[ "$FLAG_NEXT" == "true" ]]; then
echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
else
echo "ref: ${GIT_REF}"
fi
}
# ─── helpers ──────────────────────────────────────────────────────────────────
require_cmd() {
@@ -236,10 +299,43 @@ installed_cli_version() {
fi
}
installed_gateway_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" 2>/dev/null || true
fi
}
latest_cli_version() {
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
}
next_cli_version() {
npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_gateway_version() {
npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_pipeline_suffix() {
printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
}
next_versions_share_pipeline() {
local cli_next="$1"
local gateway_next="$2"
local cli_pipeline gateway_pipeline
cli_pipeline="$(next_pipeline_suffix "$cli_next")"
gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
[[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
}
version_lt() {
node -e "
const a=process.argv[1], b=process.argv[2];
@@ -332,8 +428,8 @@ install_cli_from_source() {
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_tgz
cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack."
@@ -355,6 +451,49 @@ install_cli_from_source() {
ok "Installed from source: CLI $(installed_cli_version)"
}
install_next_cli_from_registry() {
local cli_next gateway_next
cli_next="$(next_cli_version)"
gateway_next="$(next_gateway_version)"
if [[ -z "$cli_next" ]]; then
warn "${CLI_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if [[ -z "$gateway_next" ]]; then
warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
return 1
fi
info "Installing ${CLI_PKG}@${cli_next} from registry…"
if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast CLI @next install failed."
return 1
fi
info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast gateway @next install failed."
return 1
fi
local installed_cli installed_gateway
installed_cli="$(installed_cli_version)"
installed_gateway="$(installed_gateway_version)"
if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
return 1
fi
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
}
# ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node
@@ -388,7 +527,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
else
dim " Installed: (none)"
fi
dim " Source: ${REPO_BASE} (ref: ${GIT_REF})"
dim " Source: ${REPO_BASE} ($(source_ref_details))"
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
@@ -455,8 +594,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi
CURRENT="$(installed_cli_version)"
NEXT_GATEWAY=""
if [[ "$FLAG_DEV" == "true" ]]; then
LATEST=""
elif is_next_registry_lane; then
LATEST="$(next_cli_version)"
NEXT_GATEWAY="$(next_gateway_version)"
else
LATEST="$(latest_cli_version)"
fi
@@ -468,7 +611,19 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi
if [[ "$FLAG_DEV" == "true" ]]; then
dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)"
elif is_next_registry_lane; then
if [[ -n "$LATEST" ]]; then
dim " Next CLI: ${CLI_PKG}@${LATEST}"
else
dim " Next CLI: (registry @next unreachable)"
fi
if [[ -n "$NEXT_GATEWAY" ]]; then
dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}"
else
dim " Next GW: (registry @next unreachable)"
fi
dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)"
elif [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}"
else
@@ -479,6 +634,12 @@ if [[ "$FLAG_CLI" == "true" ]]; then
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif is_next_registry_lane; then
if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
else
warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
fi
elif [[ -z "$LATEST" ]]; then
warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then
@@ -495,6 +656,23 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ensure_monorepo
install_cli_from_source
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}"
if install_next_cli_from_registry; then
:
else
warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
ensure_monorepo
install_cli_from_source
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
@@ -603,7 +781,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
local base dir backup_path backup_val
base="$(basename "$dest")"
dir="$(dirname "$dest")"
backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
if [[ -n "$backup_path" ]]; then
backup_val="\"$backup_path\""
else
@@ -628,7 +806,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
fi
node -e "
if node -e "
const fs = require('fs');
const path = require('path');
const p = process.argv[1];
@@ -653,9 +831,11 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
"$MANIFEST_CLI_VERSION" \
"$MANIFEST_FW_VERSION" \
"$NPMRC_LINES_JSON" \
"$RUNTIME_COPIES" 2>/dev/null \
&& ok "Install manifest written: $MANIFEST_PATH" \
|| warn "Could not write install manifest (non-fatal)"
"$RUNTIME_COPIES" 2>/dev/null; then
ok "Install manifest written: $MANIFEST_PATH"
else
warn "Could not write install manifest (non-fatal)"
fi
echo ""
ok "Done."