Compare commits
1 Commits
main
...
feat/wizar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eca863b282 |
@@ -103,12 +103,12 @@ steps:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:latest"
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
@@ -128,12 +128,12 @@ steps:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:latest"
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
|
||||
@@ -59,9 +59,9 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
||||
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
||||
|
||||
| Value | When to use | Budget |
|
||||
| --------- | ----------------------------------------------------------- | -------------------------- |
|
||||
| -------- | ----------------------------------------------------------- | -------------------------- |
|
||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
||||
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
||||
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
||||
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||
|
||||
22
README.md
22
README.md
@@ -7,13 +7,7 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
||||
@@ -185,8 +179,8 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
|
||||
cd stack
|
||||
git clone git@git.mosaicstack.dev:mosaicstack/mosaic-stack.git
|
||||
cd mosaic-stack
|
||||
|
||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||
docker compose up -d
|
||||
@@ -235,7 +229,7 @@ npm packages are published to the Gitea package registry on main merges.
|
||||
## Architecture
|
||||
|
||||
```
|
||||
stack/
|
||||
mosaic-stack/
|
||||
├── apps/
|
||||
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
||||
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
||||
@@ -308,13 +302,7 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
||||
Run the installer again — it handles upgrades automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
Or use the CLI:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.6",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "apps/gateway"
|
||||
},
|
||||
"type": "module",
|
||||
@@ -72,17 +72,11 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@swc/core": "^1.15.24",
|
||||
"@swc/helpers": "^0.5.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ import type { Auth } from '@mosaicstack/auth';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { BootstrapSetupDto } from './bootstrap.dto.js';
|
||||
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||
import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||
|
||||
@Controller('api/bootstrap')
|
||||
export class BootstrapController {
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* E2E integration test — POST /api/bootstrap/setup
|
||||
*
|
||||
* Regression guard for the `import type { BootstrapSetupDto }` class-erasure
|
||||
* bug (IUV-M01, issue #436).
|
||||
*
|
||||
* When `BootstrapSetupDto` is imported with `import type`, TypeScript erases
|
||||
* the class at compile time. NestJS then sees `Object` as the `@Body()`
|
||||
* metatype, and ValidationPipe with `whitelist:true + forbidNonWhitelisted:true`
|
||||
* treats every property as non-whitelisted, returning:
|
||||
*
|
||||
* 400 { message: ["property email should not exist", "property password should not exist"] }
|
||||
*
|
||||
* The fix is a plain value import (`import { BootstrapSetupDto }`), which
|
||||
* preserves the class reference so Nest can read the class-validator decorators.
|
||||
*
|
||||
* This test MUST fail if `import type` is re-introduced on `BootstrapSetupDto`.
|
||||
* A controller unit test that constructs ValidationPipe manually won't catch
|
||||
* this — only the real DI binding path exercises the metatype lookup.
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { BootstrapController } from './bootstrap.controller.js';
|
||||
import type { BootstrapResultDto } from './bootstrap.dto.js';
|
||||
|
||||
// ─── Minimal mock dependencies ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* We use explicit `@Inject(AUTH)` / `@Inject(DB)` in the controller so we
|
||||
* can provide mock values by token without spinning up the real DB or Auth.
|
||||
*/
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
const MOCK_USER_ID = 'mock-user-id-001';
|
||||
|
||||
const mockAuth = {
|
||||
api: {
|
||||
createUser: () =>
|
||||
Promise.resolve({
|
||||
user: {
|
||||
id: MOCK_USER_ID,
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
// Override db.select() so the second query (verify user exists) returns a user.
|
||||
// The bootstrap controller calls select().from() twice:
|
||||
// 1. count() to check zero users → returns [{total: 0}]
|
||||
// 2. select().where().limit() → returns [the created user]
|
||||
let selectCallCount = 0;
|
||||
const mockDbWithUser = {
|
||||
select: () => {
|
||||
selectCallCount++;
|
||||
return {
|
||||
from: () => {
|
||||
if (selectCallCount === 1) {
|
||||
// First call: count — zero users
|
||||
return Promise.resolve([{ total: 0 }]);
|
||||
}
|
||||
// Subsequent calls: return a mock user row
|
||||
return {
|
||||
where: () => ({
|
||||
limit: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: MOCK_USER_ID,
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
update: () => ({
|
||||
set: () => ({
|
||||
where: () => Promise.resolve([]),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({
|
||||
returning: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'token-id-001',
|
||||
label: 'Initial setup token',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
selectCallCount = 0;
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [BootstrapController],
|
||||
providers: [
|
||||
{ provide: AUTH, useValue: mockAuth },
|
||||
{ provide: DB, useValue: mockDbWithUser },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
|
||||
// Mirror main.ts configuration exactly — this is what reproduced the 400.
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.init();
|
||||
// Fastify requires waiting for the adapter to be ready
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns 201 (not 400) when a valid {name, email, password} body is sent', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({ name: 'Admin', email: 'admin@example.com', password: 'password123' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
// Before the fix (import type), Nest ValidationPipe returned 400 with
|
||||
// "property email should not exist" / "property password should not exist"
|
||||
// because the DTO class was erased and every field looked non-whitelisted.
|
||||
expect(res.status).not.toBe(400);
|
||||
expect(res.status).toBe(201);
|
||||
const body = res.body as BootstrapResultDto;
|
||||
expect(body.user).toBeDefined();
|
||||
expect(body.user.email).toBe('admin@example.com');
|
||||
expect(body.token).toBeDefined();
|
||||
expect(body.token.plaintext).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 400 when extra forbidden properties are sent', async () => {
|
||||
// This proves ValidationPipe IS active and working (forbidNonWhitelisted).
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
password: 'password123',
|
||||
extraField: 'should-be-rejected',
|
||||
})
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when email is invalid', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({ name: 'Admin', email: 'not-an-email', password: 'password123' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when password is too short', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({ name: 'Admin', email: 'admin@example.com', password: 'short' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import swc from 'unplugin-swc';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -6,22 +5,4 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
plugins: [
|
||||
swc.vite({
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
decorators: true,
|
||||
},
|
||||
transform: {
|
||||
decoratorMetadata: true,
|
||||
legacyDecorator: true,
|
||||
},
|
||||
target: 'es2022',
|
||||
},
|
||||
module: {
|
||||
type: 'nodenext',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,73 +1,57 @@
|
||||
# Mission Manifest — Install UX v2
|
||||
# Mission Manifest — Install UX Hardening
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** install-ux-v2-20260405
|
||||
**Statement:** The install-ux-hardening mission shipped the plumbing (uninstall, masked password, hooks consent, unified flow, headless path), but the first real end-to-end run surfaced a critical regression and a collection of UX failings that make the wizard feel neither quick nor intelligent. This mission closes the bootstrap regression as a hotfix, then rethinks the first-run experience around a provider-first, intent-driven flow with a drill-down main menu and a genuinely fast quick-start.
|
||||
**ID:** install-ux-hardening-20260405
|
||||
**Statement:** Close the remaining gaps in the Mosaic Stack first-run and teardown experience uncovered by the post-`cli-unification` audit. A user MUST be able to cleanly uninstall the stack; the wizard MUST make security-sensitive surfaces visible (hooks, password entry); and CI/headless installs MUST NOT hang on interactive prompts. The longer-term goal is a single cohesive first-run flow that collapses `mosaic wizard` and `mosaic gateway install` into one state-bridged experience.
|
||||
**Phase:** Execution
|
||||
**Current Milestone:** IUV-M03
|
||||
**Progress:** 2 / 3 milestones
|
||||
**Current Milestone:** IUH-M02
|
||||
**Progress:** 1 / 3 milestones
|
||||
**Status:** active
|
||||
**Last Updated:** 2026-04-05 (IUV-M02 complete — CORS/FQDN + skill installer rework)
|
||||
**Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
|
||||
**Last Updated:** 2026-04-05
|
||||
**Parent Mission:** [cli-unification-20260404](./archive/missions/cli-unification-20260404/MISSION-MANIFEST.md) (complete)
|
||||
|
||||
## Context
|
||||
|
||||
Real-run testing of `@mosaicstack/mosaic@0.0.25` uncovered:
|
||||
|
||||
1. **Critical:** admin bootstrap fails with HTTP 400 `property email should not exist` — `bootstrap.controller.ts` uses `import type { BootstrapSetupDto }`, erasing the class at runtime. Nest's `@Body()` falls back to plain `Object` metatype, and ValidationPipe with `forbidNonWhitelisted` rejects every property. One-character fix (drop the `type` keyword), but it blocks the happy path of the release that just shipped.
|
||||
2. The wizard reports `✔ Wizard complete` and `✔ Done` _after_ the bootstrap 400 — failure only propagates in headless mode (`wizard.ts:147`).
|
||||
3. The gateway port prompt does not prefill `14242` in the input buffer.
|
||||
4. `"What is Mosaic?"` intro copy does not mention Pi SDK (the actual agent runtime behind Claude/Codex/OpenCode).
|
||||
5. CORS origin prompt is confusing — the user should be able to supply an FQDN/hostname and have the system derive the CORS value.
|
||||
6. Skill / additional feature install section is unusable in practice.
|
||||
7. Quick-start asks far too many questions to be meaningfully "quick".
|
||||
8. No drill-down main menu — everything is a linear interrogation.
|
||||
9. Provider setup happens late and without intelligence. An OpenClaw-style provider-first flow would let the user describe what they want in natural language, have the agent expound on it, and have the agent choose its own name based on that intent.
|
||||
Post-merge audit of `cli-unification-20260404` (AC-1, AC-6) validated that the first-run wizard covers first user, password, admin tokens, gateway instance config, skills, and SOUL.md/USER.md init. The audit surfaced six gaps, grouped into three tracks of independent value.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: Admin bootstrap completes successfully end-to-end on a fresh install (DTO value import, no forbidNonWhitelisted regression); covered by an integration or e2e test that exercises the real DTO binding. _(PR #440)_
|
||||
- [x] AC-2: Wizard fails loudly (non-zero exit, clear error) when the bootstrap stage returns `completed: false`, in both interactive and headless modes. No more silent `✔ Wizard complete` after a 400. _(PR #440)_
|
||||
- [x] AC-3: Gateway port prompt prefills `14242` in the input field (user can press Enter to accept). _(PR #440)_
|
||||
- [x] AC-4: `"What is Mosaic?"` intro copy mentions Pi SDK as the underlying agent runtime. _(PR #440)_
|
||||
- [x] AC-5: Release `mosaic-v0.0.26` tagged and published to the Gitea npm registry, unblocking the 0.0.25 happy path. _(tag: mosaic-v0.0.26, registry: 0.0.26 live)_
|
||||
- [ ] AC-6: CORS origin prompt replaced with FQDN/hostname input; CORS string is derived from that.
|
||||
- [ ] AC-7: Skill / additional feature install section is reworked until it is actually usable end-to-end (worker defines the concrete failure modes during diagnosis).
|
||||
- [ ] AC-8: First-run flow has a drill-down main menu with at least `Plugins` (Recommended / Custom), `Providers`, and the other top-level configuration groups. Linear interrogation is gone.
|
||||
- [ ] AC-9: `Quick Start` path completes with a minimal, curated set of questions (target: under 90 seconds for a returning user; define the exact baseline during design).
|
||||
- [ ] AC-10: Provider setup happens first, driven by a natural-language intake prompt. The agent expounds on the user's intent and chooses its own name based on that intent (OpenClaw-style). Naming is confirmable / overridable.
|
||||
- [ ] AC-11: All milestones ship as merged PRs with green CI and closed issues.
|
||||
- [x] AC-1: `mosaic uninstall` (top-level) cleanly reverses every mutation made by `tools/install.sh` — framework data, npm CLI, nested stack deps, runtime asset injections in `~/.claude/`, npmrc scope mapping, PATH edits. Dry-run supported. `--keep-data` preserves memory + user files + gateway DB. (PR #429)
|
||||
- [x] AC-2: `curl … | bash -s -- --uninstall` works without requiring a functioning CLI. (PR #429)
|
||||
- [ ] AC-3: Password entry in `bootstrapFirstUser` is masked (no plaintext echo); confirm prompt added.
|
||||
- [ ] AC-4: Wizard has an explicit hooks stage that previews which hooks will be installed, asks for confirmation, and records the user's choice. `mosaic config hooks list|enable|disable` surface exists.
|
||||
- [ ] AC-5: `runConfigWizard` and `bootstrapFirstUser` accept a headless path (env vars + `--yes`) so `tools/install.sh --yes` + `MOSAIC_ASSUME_YES=1` completes end-to-end in CI without TTY.
|
||||
- [ ] AC-6: `mosaic wizard` and `mosaic gateway install` are collapsed into a single cohesive entry point with shared state (no two-phase handoff via the 10-minute session file).
|
||||
- [ ] AC-7: All milestones ship as merged PRs with green CI, closed issues, updated release notes.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | ------------------------------------------------------------ | ----------- | ---------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | IUV-M01 | Hotfix: bootstrap DTO + wizard failure + port prefill + copy | complete | fix/bootstrap-hotfix | #436 | 2026-04-05 | 2026-04-05 |
|
||||
| 2 | IUV-M02 | UX polish: CORS/FQDN, skill installer rework | complete | feat/install-ux-polish | #437 | 2026-04-05 | 2026-04-05 |
|
||||
| 3 | IUV-M03 | Provider-first intelligent flow + drill-down main menu | not-started | feat/install-ux-intent | #438 | — | — |
|
||||
| --- | ------- | --------------------------------------------------------- | ----------- | ----------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | IUH-M01 | `mosaic uninstall` — top-level teardown + shell wrapper | done | feat/mosaic-uninstall | #425 | 2026-04-05 | 2026-04-05 |
|
||||
| 2 | IUH-M02 | Wizard remediation — hooks visibility, pwd mask, headless | in-progress | feat/wizard-remediation | #426 | 2026-04-05 | — |
|
||||
| 3 | IUH-M03 | Unified first-run wizard (collapse wizard + gateway) | blocked | feat/unified-first-run | #427 | — | — |
|
||||
|
||||
## Subagent Delegation Plan
|
||||
|
||||
| Milestone | Recommended Tier | Rationale |
|
||||
| --------- | ---------------- | --------------------------------------------------------------------- |
|
||||
| IUV-M01 | sonnet | Tight bug cluster with known fix sites + small release cycle |
|
||||
| IUV-M02 | sonnet | UX rework, moderate surface, diagnostic-heavy for the skill installer |
|
||||
| IUV-M03 | opus | Architectural redesign of first-run flow, state machine + LLM intake |
|
||||
| --------- | ---------------- | ---------------------------------------------------------------------- |
|
||||
| IUH-M01 | sonnet | Standard feature work — new command surface mirroring existing install |
|
||||
| IUH-M02 | sonnet | Small surgical fixes across 3-4 files |
|
||||
| IUH-M03 | opus | Architectural refactor; state machine design decisions |
|
||||
|
||||
## Risks
|
||||
|
||||
- **Hotfix regression surface** — the `import type` → `import` fix on the DTO class is one character but needs an integration test that binds the real DTO, not just a controller unit test, to prevent the same class-erasure regression from sneaking back in.
|
||||
- **LLM-driven intake latency / offline** — M03's provider-first intent flow assumes an available LLM call to expound on user input and choose a name. Offline installs need a deterministic fallback.
|
||||
- **Menu vs. linear back-compat** — M03 changes the top-level flow shape; existing `tools/install.sh --yes` + env-var headless path must continue to work.
|
||||
- **Scope creep in M03** — "redesign the wizard" can absorb arbitrary work. Keep it bounded with explicit non-goals.
|
||||
- **Reversal completeness** — runtime asset linking creates `.mosaic-bak-*` backups; uninstall must honor them vs. when to delete. Ambiguity without an install manifest.
|
||||
- **npm global nested deps** — `npm uninstall -g @mosaicstack/mosaic` removes nested `@mosaicstack/*`, but ownership conflicts with explicitly installed peer packages (`@mosaicstack/gateway`, `@mosaicstack/memory`) need test coverage.
|
||||
- **Headless bootstrap** — admin password via env var is a credential on disk; needs clear documentation that `MOSAIC_ADMIN_PASSWORD` is intended for CI-only and should be rotated post-install.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Migrating the wizard to a GUI / web UI (still terminal-first)
|
||||
- Replacing the Gitea registry or the Woodpecker publish pipeline
|
||||
- Multi-tenant / multi-user onboarding (still single-admin bootstrap)
|
||||
- Reworking `mosaic uninstall` (M01 of the parent mission — stable)
|
||||
- `mosaicstack.dev/install.sh` vanity URL (blocked on marketing site work)
|
||||
- Uninstall for the `@mosaicstack/gateway` database contents — delegated to `mosaic gateway uninstall` semantics already in place
|
||||
- Signature/checksum verification of install scripts
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
# Tasks — Install UX v2
|
||||
# Tasks — Install UX Hardening
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** install-ux-v2-20260405
|
||||
> **Mission:** install-ux-hardening-20260405
|
||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
||||
|
||||
## Milestone 1 — Hotfix: bootstrap DTO + wizard failure + port prefill + copy (IUV-M01)
|
||||
## Milestone 1 — `mosaic uninstall` (IUH-M01)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | -------------------- | ---------- | -------- | --------------------------------------------------------------------------------------- |
|
||||
| IUV-01-01 | done | Fix `apps/gateway/src/admin/bootstrap.controller.ts:16` — switch `import type { BootstrapSetupDto }` to a value import so Nest's `@Body()` binds the real class | #436 | sonnet | fix/bootstrap-hotfix | — | 3K | PR #440 merged `0ae932ab` |
|
||||
| IUV-01-02 | done | Add integration / e2e test that POSTs `/api/bootstrap/setup` with `{name,email,password}` against a real Nest app instance and asserts 201 — NOT a mocked controller unit test | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-01 | 10K | `apps/gateway/src/admin/bootstrap.e2e.spec.ts` — 4 tests; unplugin-swc added for vitest |
|
||||
| IUV-01-03 | done | `packages/mosaic/src/wizard.ts:147` — propagate `!bootstrapResult.completed` as a wizard failure in **interactive** mode too (not only headless); non-zero exit + no `✔ Wizard complete` line | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-02 | 5K | removed `&& headlessRun` guard |
|
||||
| IUV-01-04 | done | Gateway port prompt prefills `14242` in the input buffer — investigate why `promptPort`'s `defaultValue` isn't reaching the user-visible input | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-03 | 5K | added `initialValue` through prompter interface → clack |
|
||||
| IUV-01-05 | done | `"What is Mosaic?"` intro copy updated to mention Pi SDK as the underlying agent runtime (alongside Claude Code / Codex / OpenCode) | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-04 | 2K | `packages/mosaic/src/stages/welcome.ts` |
|
||||
| IUV-01-06 | done | Tests + code review + PR merge + tag `mosaic-v0.0.26` + Gitea release + npm registry republish | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-05 | 10K | PRs #440/#441/#442 merged; tag `mosaic-v0.0.26`; registry latest=0.0.26 ✓ |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------- | ----- | ------ | --------------------- | ---------- | -------- | ------------------------------------------------------ |
|
||||
| IUH-01-01 | done | Design install manifest schema (`~/.config/mosaic/.install-manifest.json`) — what install writes on first success | #425 | sonnet | feat/mosaic-uninstall | — | 8K | v1 schema in `install-manifest.ts` |
|
||||
| IUH-01-02 | done | `mosaic uninstall` TS command: `--framework`, `--cli`, `--gateway`, `--all`, `--keep-data`, `--yes`, `--dry-run` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-01 | 25K | `uninstall.ts` |
|
||||
| IUH-01-03 | done | Reverse runtime asset linking in `~/.claude/` — restore `.mosaic-bak-*` if present, remove managed copies otherwise | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 12K | file list hardcoded from mosaic-link-runtime-assets |
|
||||
| IUH-01-04 | done | Reverse npmrc scope mapping and PATH edits made by `tools/install.sh` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 8K | npmrc reversed; no PATH edits found in v0.0.24 install |
|
||||
| IUH-01-05 | done | Shell fallback: `tools/install.sh --uninstall` path for users without a working CLI | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 10K | |
|
||||
| IUH-01-06 | done | Vitest coverage: dry-run output, `--all`, `--keep-data`, partial state, missing manifest | #425 | sonnet | feat/mosaic-uninstall | IUH-01-05 | 15K | 14 new tests, 170 total |
|
||||
| IUH-01-07 | done | Code review (independent) + remediation | #425 | sonnet | feat/mosaic-uninstall | IUH-01-06 | 5K | |
|
||||
| IUH-01-08 | done | PR open, CI green, review, merge to `main`, close issue | #425 | sonnet | feat/mosaic-uninstall | IUH-01-07 | 3K | PR #429, merge 25cada77 |
|
||||
|
||||
## Milestone 2 — UX polish: CORS/FQDN, skill installer rework (IUV-M02)
|
||||
## Milestone 2 — Wizard Remediation (IUH-M02)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------- | ---------- | -------- | ---------------------------------------------------------------------- |
|
||||
| IUV-02-01 | done | Replace CORS origin prompt with FQDN / hostname input; derive the CORS value internally; default to `localhost` with clear help text | #437 | sonnet | feat/install-ux-polish | — | 10K | `deriveCorsOrigin()` pure fn; MOSAIC_HOSTNAME headless var; PR #444 |
|
||||
| IUV-02-02 | done | Diagnose and document the concrete failure modes of the current skill / additional feature install section end-to-end | #437 | sonnet | feat/install-ux-polish | IUV-02-01 | 8K | selection→install gap, silent catch{}, no whitelist concept |
|
||||
| IUV-02-03 | done | Rework the skill installer so it is usable end-to-end (selection, install, verify, failure reporting) | #437 | sonnet | feat/install-ux-polish | IUV-02-02 | 20K | MOSAIC_INSTALL_SKILLS env var whitelist; SyncSkillsResult typed return |
|
||||
| IUV-02-04 | done | Tests + code review + PR merge | #437 | sonnet | feat/install-ux-polish | IUV-02-03 | 10K | 18 new tests (13 CORS + 5 skills); PR #444 merged `172bacb3` |
|
||||
| --------- | ----------- | -------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------- | ---------- | -------- | ----- |
|
||||
| IUH-02-01 | in-progress | Password masking: replace plaintext `rl.question` in `bootstrapFirstUser` with masked TTY read + confirmation | #426 | sonnet | feat/wizard-remediation | IUH-01-08 | 8K | |
|
||||
| IUH-02-02 | not-started | Hooks preview stage in wizard: show `framework/runtime/claude/hooks-config.json` entries + confirm prompt | #426 | sonnet | feat/wizard-remediation | IUH-02-01 | 12K | |
|
||||
| IUH-02-03 | not-started | `mosaic config hooks list\|enable\|disable` subcommands | #426 | sonnet | feat/wizard-remediation | IUH-02-02 | 15K | |
|
||||
| IUH-02-04 | not-started | Headless path: env-var driven `runConfigWizard` + `bootstrapFirstUser` (`MOSAIC_ASSUME_YES`, `MOSAIC_ADMIN_*`) | #426 | sonnet | feat/wizard-remediation | IUH-02-03 | 12K | |
|
||||
| IUH-02-05 | not-started | Tests + code review + PR merge | #426 | sonnet | feat/wizard-remediation | IUH-02-04 | 10K | |
|
||||
|
||||
## Milestone 3 — Provider-first intelligent flow + drill-down main menu (IUV-M03)
|
||||
## Milestone 3 — Unified First-Run Wizard (IUH-M03)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----- | ---------------------- | ---------- | -------- | ------------------------------------------------------------- |
|
||||
| IUV-03-01 | not-started | Design doc: new first-run state machine — main menu (Plugins / Providers / …), Quick Start vs Custom paths, provider-first flow, intent intake + naming loop | #438 | opus | feat/install-ux-intent | — | 15K | scratchpad + explicit non-goals |
|
||||
| IUV-03-02 | not-started | Implement drill-down main menu (Plugins: Recommended / Custom, Providers, …) as the top-level entry point of `mosaic wizard` | #438 | opus | feat/install-ux-intent | IUV-03-01 | 25K | |
|
||||
| IUV-03-03 | not-started | Quick Start path: curated minimum question set — define the exact baseline, delete everything else from the fast path | #438 | opus | feat/install-ux-intent | IUV-03-02 | 15K | |
|
||||
| IUV-03-04 | not-started | Provider-first natural-language intake: user describes intent → agent expounds → agent proposes a name (confirmable / overridable) — OpenClaw-style | #438 | opus | feat/install-ux-intent | IUV-03-03 | 25K | offline fallback required (deterministic default name + path) |
|
||||
| IUV-03-05 | not-started | Preserve backward-compat: headless path (`MOSAIC_ASSUME_YES=1` + env vars) still works end-to-end; `tools/install.sh --yes` unchanged | #438 | opus | feat/install-ux-intent | IUV-03-04 | 10K | |
|
||||
| IUV-03-06 | not-started | Tests + code review + PR merge + `mosaic-v0.0.27` release | #438 | opus | feat/install-ux-intent | IUV-03-05 | 15K | |
|
||||
| --------- | ------- | ----------------------------------------------------------------------------------------------------------- | ----- | ----- | ---------------------- | ---------- | -------- | ----- |
|
||||
| IUH-03-01 | blocked | Design doc: unified state machine; decide whether `mosaic gateway install` becomes an internal wizard stage | — | opus | feat/unified-first-run | IUH-02-05 | 10K | |
|
||||
| IUH-03-02 | blocked | Refactor `runWizard` to invoke gateway install as a stage; drop the 10-minute session-file bridge | — | opus | feat/unified-first-run | IUH-03-01 | 25K | |
|
||||
| IUH-03-03 | blocked | Preserve backward-compat: `mosaic gateway install` still works as a standalone entry point | — | opus | feat/unified-first-run | IUH-03-02 | 10K | |
|
||||
| IUH-03-04 | blocked | Tests + code review + PR merge | — | opus | feat/unified-first-run | IUH-03-03 | 12K | |
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Mission Manifest — Install UX Hardening
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** install-ux-hardening-20260405
|
||||
**Statement:** Close the remaining gaps in the Mosaic Stack first-run and teardown experience uncovered by the post-`cli-unification` audit. A user MUST be able to cleanly uninstall the stack; the wizard MUST make security-sensitive surfaces visible (hooks, password entry); and CI/headless installs MUST NOT hang on interactive prompts. The longer-term goal is a single cohesive first-run flow that collapses `mosaic wizard` and `mosaic gateway install` into one state-bridged experience.
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** —
|
||||
**Progress:** 3 / 3 milestones
|
||||
**Status:** complete
|
||||
**Last Updated:** 2026-04-05 (mission complete)
|
||||
**Parent Mission:** [cli-unification-20260404](./archive/missions/cli-unification-20260404/MISSION-MANIFEST.md) (complete)
|
||||
|
||||
## Context
|
||||
|
||||
Post-merge audit of `cli-unification-20260404` (AC-1, AC-6) validated that the first-run wizard covers first user, password, admin tokens, gateway instance config, skills, and SOUL.md/USER.md init. The audit surfaced six gaps, grouped into three tracks of independent value.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: `mosaic uninstall` (top-level) cleanly reverses every mutation made by `tools/install.sh` — framework data, npm CLI, nested stack deps, runtime asset injections in `~/.claude/`, npmrc scope mapping, PATH edits. Dry-run supported. `--keep-data` preserves memory + user files + gateway DB. (PR #429)
|
||||
- [x] AC-2: `curl … | bash -s -- --uninstall` works without requiring a functioning CLI. (PR #429)
|
||||
- [x] AC-3: Password entry in `bootstrapFirstUser` is masked (no plaintext echo); confirm prompt added. (PR #431)
|
||||
- [x] AC-4: Wizard has an explicit hooks stage that previews which hooks will be installed, asks for confirmation, and records the user's choice. `mosaic config hooks list|enable|disable` surface exists. (PR #431 — consent; PR #433 — finalize-stage gating now honors `state.hooks.accepted === false` end-to-end)
|
||||
- [x] AC-5: `runConfigWizard` and `bootstrapFirstUser` accept a headless path (env vars + `--yes`) so `tools/install.sh --yes` + `MOSAIC_ASSUME_YES=1` completes end-to-end in CI without TTY. (PR #431)
|
||||
- [x] AC-6: `mosaic wizard` and `mosaic gateway install` are collapsed into a single cohesive entry point with shared state; gateway install is now terminal stages 11 & 12 of `runWizard`, session-file bridge removed, `mosaic gateway install` preserved as a thin standalone wrapper. (PR #433)
|
||||
- [x] AC-7: All milestones shipped as merged PRs with green CI and closed issues. (PRs #429, #431, #433)
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | --------------------------------------------------------- | ------ | ----------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | IUH-M01 | `mosaic uninstall` — top-level teardown + shell wrapper | done | feat/mosaic-uninstall | #425 | 2026-04-05 | 2026-04-05 |
|
||||
| 2 | IUH-M02 | Wizard remediation — hooks visibility, pwd mask, headless | done | feat/wizard-remediation | #426 | 2026-04-05 | 2026-04-05 |
|
||||
| 3 | IUH-M03 | Unified first-run wizard (collapse wizard + gateway) | done | feat/unified-first-run | #427 | 2026-04-05 | 2026-04-05 |
|
||||
|
||||
## Subagent Delegation Plan
|
||||
|
||||
| Milestone | Recommended Tier | Rationale |
|
||||
| --------- | ---------------- | ---------------------------------------------------------------------- |
|
||||
| IUH-M01 | sonnet | Standard feature work — new command surface mirroring existing install |
|
||||
| IUH-M02 | sonnet | Small surgical fixes across 3-4 files |
|
||||
| IUH-M03 | opus | Architectural refactor; state machine design decisions |
|
||||
|
||||
## Risks
|
||||
|
||||
- **Reversal completeness** — runtime asset linking creates `.mosaic-bak-*` backups; uninstall must honor them vs. when to delete. Ambiguity without an install manifest.
|
||||
- **npm global nested deps** — `npm uninstall -g @mosaicstack/mosaic` removes nested `@mosaicstack/*`, but ownership conflicts with explicitly installed peer packages (`@mosaicstack/gateway`, `@mosaicstack/memory`) need test coverage.
|
||||
- **Headless bootstrap** — admin password via env var is a credential on disk; needs clear documentation that `MOSAIC_ADMIN_PASSWORD` is intended for CI-only and should be rotated post-install.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `mosaicstack.dev/install.sh` vanity URL (blocked on marketing site work)
|
||||
- Uninstall for the `@mosaicstack/gateway` database contents — delegated to `mosaic gateway uninstall` semantics already in place
|
||||
- Signature/checksum verification of install scripts
|
||||
@@ -1,41 +0,0 @@
|
||||
# Tasks — Install UX Hardening
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** install-ux-hardening-20260405
|
||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
||||
|
||||
## Milestone 1 — `mosaic uninstall` (IUH-M01)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------- | ----- | ------ | --------------------- | ---------- | -------- | ------------------------------------------------------ |
|
||||
| IUH-01-01 | done | Design install manifest schema (`~/.config/mosaic/.install-manifest.json`) — what install writes on first success | #425 | sonnet | feat/mosaic-uninstall | — | 8K | v1 schema in `install-manifest.ts` |
|
||||
| IUH-01-02 | done | `mosaic uninstall` TS command: `--framework`, `--cli`, `--gateway`, `--all`, `--keep-data`, `--yes`, `--dry-run` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-01 | 25K | `uninstall.ts` |
|
||||
| IUH-01-03 | done | Reverse runtime asset linking in `~/.claude/` — restore `.mosaic-bak-*` if present, remove managed copies otherwise | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 12K | file list hardcoded from mosaic-link-runtime-assets |
|
||||
| IUH-01-04 | done | Reverse npmrc scope mapping and PATH edits made by `tools/install.sh` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 8K | npmrc reversed; no PATH edits found in v0.0.24 install |
|
||||
| IUH-01-05 | done | Shell fallback: `tools/install.sh --uninstall` path for users without a working CLI | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 10K | |
|
||||
| IUH-01-06 | done | Vitest coverage: dry-run output, `--all`, `--keep-data`, partial state, missing manifest | #425 | sonnet | feat/mosaic-uninstall | IUH-01-05 | 15K | 14 new tests, 170 total |
|
||||
| IUH-01-07 | done | Code review (independent) + remediation | #425 | sonnet | feat/mosaic-uninstall | IUH-01-06 | 5K | |
|
||||
| IUH-01-08 | done | PR open, CI green, review, merge to `main`, close issue | #425 | sonnet | feat/mosaic-uninstall | IUH-01-07 | 3K | PR #429, merge 25cada77 |
|
||||
|
||||
## Milestone 2 — Wizard Remediation (IUH-M02)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | -------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------- | ---------- | -------- | ----------------------------------------------- |
|
||||
| IUH-02-01 | done | Password masking: replace plaintext `rl.question` in `bootstrapFirstUser` with masked TTY read + confirmation | #426 | sonnet | feat/wizard-remediation | IUH-01-08 | 8K | `prompter/masked-prompt.ts` |
|
||||
| IUH-02-02 | done | Hooks preview stage in wizard: show `framework/runtime/claude/hooks-config.json` entries + confirm prompt | #426 | sonnet | feat/wizard-remediation | IUH-02-01 | 12K | `stages/hooks-preview.ts`; finalize gating TODO |
|
||||
| IUH-02-03 | done | `mosaic config hooks list\|enable\|disable` subcommands | #426 | sonnet | feat/wizard-remediation | IUH-02-02 | 15K | `commands/config.ts` |
|
||||
| IUH-02-04 | done | Headless path: env-var driven `runConfigWizard` + `bootstrapFirstUser` (`MOSAIC_ASSUME_YES`, `MOSAIC_ADMIN_*`) | #426 | sonnet | feat/wizard-remediation | IUH-02-03 | 12K | |
|
||||
| IUH-02-05 | done | Tests + code review + PR merge | #426 | sonnet | feat/wizard-remediation | IUH-02-04 | 10K | PR #431, merge cd8b1f66 |
|
||||
|
||||
## Milestone 3 — Unified First-Run Wizard (IUH-M03)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ----------------------------------------------------------------------------------------------------------- | ----- | ----- | ---------------------- | ---------- | -------- | ---------------------------------- |
|
||||
| IUH-03-01 | done | Design doc: unified state machine; decide whether `mosaic gateway install` becomes an internal wizard stage | #427 | opus | feat/unified-first-run | IUH-02-05 | 10K | scratchpad Session 5 |
|
||||
| IUH-03-02 | done | Refactor `runWizard` to invoke gateway install as a stage; drop the 10-minute session-file bridge | #427 | opus | feat/unified-first-run | IUH-03-01 | 25K | stages 11 & 12; bridge removed |
|
||||
| IUH-03-03 | done | Preserve backward-compat: `mosaic gateway install` still works as a standalone entry point | #427 | opus | feat/unified-first-run | IUH-03-02 | 10K | thin wrapper over stages |
|
||||
| IUH-03-04 | done | Tests + code review + PR merge | #427 | opus | feat/unified-first-run | IUH-03-03 | 12K | PR #433, merge 732f8a49; +15 tests |
|
||||
| IUH-03-05 | done | Bonus: honor `state.hooks.accepted` in finalize stage (closes M02 follow-up) | #427 | opus | feat/unified-first-run | IUH-03-04 | 5K | MOSAIC_SKIP_CLAUDE_HOOKS env flag |
|
||||
@@ -165,13 +165,7 @@ The `mosaic` CLI provides a terminal interface to the same gateway API.
|
||||
Install via the Mosaic installer:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
||||
|
||||
@@ -156,175 +156,3 @@ ASSUMPTION: The `hooks` subcommands under `config` operate on `~/.claude/hooks-c
|
||||
ASSUMPTION: For the hooks preview stage, the "name" field displayed per hook entry is the top-level event key (e.g. "PostToolUse") plus the matcher from nested hooks array. This is the most user-readable representation given the hooks-config.json structure.
|
||||
ASSUMPTION: `config hooks list/enable/disable` use `CLAUDE_HOME` env or `~/.claude` as the target directory for hooks files.
|
||||
ASSUMPTION: The headless TTY detection (`!process.stdin.isTTY`) is sufficient; `MOSAIC_ASSUME_YES=1` is an explicit override for cases where stdin is a TTY but the user still wants non-interactive (e.g., scripted installs with piped terminal).
|
||||
|
||||
---
|
||||
|
||||
## Session 4 — 2026-04-05 (orchestrator resume) — IUH-M02 closed, delegating IUH-M03
|
||||
|
||||
### IUH-M02 completion summary
|
||||
|
||||
- **PR:** #431 merged as `cd8b1f66`
|
||||
- **CI:** green (Woodpecker)
|
||||
- **Issue:** #426 closed
|
||||
- **Acceptance criteria:** AC-3 (password mask), AC-4 (hooks visibility — consent recorded), AC-5 (headless path) all satisfied
|
||||
- **New files:** `prompter/masked-prompt.ts`, `stages/hooks-preview.ts` (+ specs)
|
||||
- **Modified:** `wizard.ts`, `types.ts` (`state.hooks`), `commands/gateway/install.ts`, `commands/config.ts`
|
||||
|
||||
### Follow-up captured from M02 agent
|
||||
|
||||
**Hooks consent is recorded but not enforced.** The `hooks-preview` stage sets `state.hooks.accepted` when the user confirms, but the finalize stage still unconditionally runs `mosaic-link-runtime-assets`, which copies `hooks-config.json` into `~/.claude/` regardless of consent. This is a soft gap — the user sees the prompt and can decline, but declining currently has no effect downstream.
|
||||
|
||||
Options for addressing:
|
||||
|
||||
- Fold into IUH-M03 (since M03 touches the finalize/install convergence path anyway)
|
||||
- Spin a separate small follow-up issue after M03 lands
|
||||
|
||||
Leaning toward folding into M03 — the unified first-run flow naturally reworks the finalize→gateway handoff where this gating belongs.
|
||||
|
||||
### IUH-M03 delegation
|
||||
|
||||
Now delegating to an **opus** subagent in an isolated worktree. Scope from `/tmp/iuh-m03-body.md`:
|
||||
|
||||
- Extract `runConfigWizard` → `stages/gateway-config.ts`
|
||||
- Extract `bootstrapFirstUser` → `stages/gateway-bootstrap.ts`
|
||||
- `runWizard` invokes gateway stages as final stages
|
||||
- Drop the 10-minute `$XDG_RUNTIME_DIR/mosaic-install-state.json` session bridge
|
||||
- `mosaic gateway install` becomes a thin standalone wrapper for backward-compat
|
||||
- `tools/install.sh` single auto-launch entry point
|
||||
- **Bonus if scoped:** honor `state.hooks.accepted` in finalize stage so declining hooks actually skips hook install
|
||||
|
||||
Known tooling caveats to pass to worker:
|
||||
|
||||
- `issue-create.sh` / `pr-create.sh` wrappers eval multiline bodies as shell — use Gitea REST API fallback with `load_credentials gitea-mosaicstack`
|
||||
- Protected `main`: PR-only, squash merge
|
||||
- Must run `ci-queue-wait.sh --purpose push|merge` before push/merge
|
||||
|
||||
---
|
||||
|
||||
## Session 5: 2026-04-05 (agent-a7875fbd) — IUH-M03 Unified First-Run
|
||||
|
||||
### Problem recap
|
||||
|
||||
`mosaic wizard` and `mosaic gateway install` currently run as two separate phases bridged by a fragile 10-minute session file at `$XDG_RUNTIME_DIR/mosaic-install-state.json`. `tools/install.sh` auto-launches both sequentially so the user perceives two wizards stitched together; state is not shared, prompts are duplicated, and if the user walks away the bridge expires.
|
||||
|
||||
### Design decision — Option A: gateway install becomes terminal stages of `runWizard`
|
||||
|
||||
Two options on the table:
|
||||
|
||||
- (A) Extract `runConfigWizard` and `bootstrapFirstUser` into `stages/gateway-config.ts` and `stages/gateway-bootstrap.ts`, append them to `runWizard` as final stages, and make `mosaic gateway install` a thin wrapper that runs the same stages with an ephemeral state seeded from existing config.
|
||||
- (B) Introduce a new top-level orchestrator that composes the wizard and gateway install as siblings.
|
||||
|
||||
**Chosen: Option A.** Rationale:
|
||||
|
||||
1. The wizard already owns a `WizardState` that threads state across stages — gateway config/bootstrap fit naturally as additional stages without a new orchestration layer.
|
||||
2. `mosaic gateway install` as standalone entry point stays idempotent by seeding a minimal `WizardState` and running only the gateway stages, reusing the same functions.
|
||||
3. Avoids a parallel state object and keeps the call graph linear; easier to test and to reason about the "one cohesive flow" UX goal.
|
||||
4. Option B would leave `runWizard` and the gateway install as siblings that still need to share a state object — equivalent complexity without the narrative simplification.
|
||||
|
||||
### Scope
|
||||
|
||||
1. Extend `WizardState` with optional `gateway` slice: `{ tier, port, databaseUrl?, valkeyUrl?, anthropicKey?, corsOrigin, admin?: { name, email, password } }`. The admin password is held in memory only — never persisted to disk as part of the state object.
|
||||
2. New `packages/mosaic/src/stages/gateway-config.ts` — pure stage that:
|
||||
- Reads existing `.env`/`mosaic.config.json` if present (resume path) and sets state.
|
||||
- Otherwise prompts via `WizardPrompter` (interactive) or reads env vars (headless).
|
||||
- Writes `.env` and `mosaic.config.json`, starts the daemon, waits for health.
|
||||
3. New `packages/mosaic/src/stages/gateway-bootstrap.ts` — pure stage that:
|
||||
- Checks `/api/bootstrap/status`.
|
||||
- If needsSetup, prompts for admin name/email/password (uses `promptMaskedConfirmed`) or reads env vars (headless); calls `/api/bootstrap/setup`; persists token in meta.
|
||||
- If already setup, handles inline token recovery exactly as today.
|
||||
4. `packages/mosaic/src/wizard.ts` — append gateway-config and gateway-bootstrap as stages 11 and 12. Remove `writeInstallState` and the `INSTALL_STATE_FILE` constant entirely.
|
||||
5. `packages/mosaic/src/commands/gateway/install.ts` — becomes a thin wrapper that builds a minimal `WizardState` with a `ClackPrompter`, then calls `runGatewayConfigStage(...)` and `runGatewayBootstrapStage(...)` directly. Remove the session-file readers/writers. Headless detection is delegated to the stage itself. The wrapper still exposes the `runInstall({host, port, skipInstall})` API so `gateway.ts` command registration is unchanged.
|
||||
6. `tools/install.sh` — drop the second `mosaic gateway install` call; `mosaic wizard` now covers end-to-end. Leave `gateway install` guidance for non-auto-launch path so users still know the standalone entry point exists.
|
||||
7. **Hooks gating (bonus — folded in):** `finalize.ts` already runs `mosaic-link-runtime-assets`. When `state.hooks?.accepted === false`, set `MOSAIC_SKIP_CLAUDE_HOOKS=1` in the env for the subprocess; teach the script to skip copying `hooks-config.json` when that env var is set. Other runtime assets (CLAUDE.md, settings.json, context7) still get linked.
|
||||
|
||||
### Files
|
||||
|
||||
NEW:
|
||||
|
||||
- `packages/mosaic/src/stages/gateway-config.ts` (+ `.spec.ts`)
|
||||
- `packages/mosaic/src/stages/gateway-bootstrap.ts` (+ `.spec.ts`)
|
||||
|
||||
MODIFIED:
|
||||
|
||||
- `packages/mosaic/src/types.ts` — extend WizardState with `gateway?:` slice
|
||||
- `packages/mosaic/src/wizard.ts` — append gateway stages, remove session-file bridge
|
||||
- `packages/mosaic/src/commands/gateway/install.ts` — thin wrapper over stages, remove 10-min bridge
|
||||
- `packages/mosaic/src/stages/finalize.ts` — honor `state.hooks.accepted === false` by setting `MOSAIC_SKIP_CLAUDE_HOOKS=1`
|
||||
- `packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets` — honor `MOSAIC_SKIP_CLAUDE_HOOKS=1`
|
||||
- `tools/install.sh` — single unified auto-launch
|
||||
|
||||
### Assumptions
|
||||
|
||||
ASSUMPTION: Gateway stages must run **after** `finalizeStage` because finalize writes identity files and links runtime assets that the gateway admin UX may later display — reversed ordering would leave Claude runtime linkage incomplete when the admin token banner prints.
|
||||
ASSUMPTION: Standalone `mosaic gateway install` uses a `ClackPrompter` (interactive) by default; the headless path is still triggered by `MOSAIC_ASSUME_YES=1` or non-TTY stdin, and the stage functions detect this internally.
|
||||
ASSUMPTION: When `runWizard` reaches the gateway stages, `state.mosaicHome` is authoritative for GATEWAY_HOME resolution if it differs from the default — we set `process.env.MOSAIC_GATEWAY_HOME` before importing gateway modules so the constants resolve correctly.
|
||||
ASSUMPTION: Keeping backwards compatibility for `runInstall({host, port, skipInstall})` is enough — no other internal caller exists.
|
||||
ASSUMPTION: Removing the session file is safe because the old bridge is at most a 10-minute window; there is no on-disk migration to do.
|
||||
|
||||
### Test plan
|
||||
|
||||
- `gateway-config.spec.ts`: fresh install writes .env + mosaic.config.json (mock fs + prompter); resume path reuses existing BETTER_AUTH_SECRET; headless path respects MOSAIC_STORAGE_TIER/MOSAIC_GATEWAY_PORT/etc.
|
||||
- `gateway-bootstrap.spec.ts`: calls `/api/bootstrap/setup` with collected creds (mock fetch); handles "already setup" branch; honors headless env vars; persists token via `writeMeta`.
|
||||
- Extend existing passing tests — no regressions in `login.spec`, `recover-token.spec`, `rotate-token.spec`.
|
||||
- Unified flow integration is covered at the stage-level; no new e2e test infra required.
|
||||
|
||||
### Delivery cycle
|
||||
|
||||
plan (this entry) → code → typecheck/lint/format → test → codex review (`~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`) → remediate → commit → ci-queue-wait push → push → PR → CI green → merge → close #427.
|
||||
|
||||
### Remediation log (codex review rounds)
|
||||
|
||||
- **Round 1** — hooks opt-out did not remove an existing managed file; port override ignored on resume; headless errors swallowed. Fixed: hooks cleanup, `portOverride` honored, errors re-thrown.
|
||||
- **Round 2** — headless stage failures exited 0; port override on decline-rerun mismatched; no default-path integration test. Fixed: `process.exit(1)` in headless, revert portOverride on decline, add `unified-wizard.test.ts`.
|
||||
- **Round 3** — hooks removal too broad (would touch user-owned files); port override written to meta but not .env (drift); wizard swallowed errors. Fixed: `cmp -s` managed-file check, force regeneration when portOverride differs from saved port, re-throw unexpected errors.
|
||||
- **Round 4** — port-override regeneration tripped the corrupt-partial-state guard (blocker); headless already-bootstrapped-with-no-local-token path reported failure instead of no-op; hooks byte-equality fragile across template updates. Fixed: introduce `forcePortRegen` flag bypassing the guard (with a dedicated spec test), headless rerun of already-bootstrapped gateway now returns `{ completed: true }` (with spec coverage), hooks cleanup now checks for a stable `"mosaic-managed": true` marker embedded in the template (byte-equality remains as a fallback for legacy installs).
|
||||
- Round 5 codex review attempted but blocked by upstream usage limit (quota). Rerun after quota refresh if further findings appear; all round-4 findings are code-covered.
|
||||
|
||||
---
|
||||
|
||||
## Session 6 — 2026-04-05 (orchestrator close-out) — MISSION COMPLETE
|
||||
|
||||
### IUH-M03 completion summary (reported by opus delivery agent)
|
||||
|
||||
- **PR:** #433 merged as `732f8a49`
|
||||
- **CI:** Woodpecker green on final rebased commit `f3d5ef8d`
|
||||
- **Issue:** #427 closed with summary comment
|
||||
- **Tests:** 219 passing (+15 net new), 24 files
|
||||
- **Codex review:** 4 rounds applied and remediated; round 5 blocked by upstream quota — no known outstanding findings
|
||||
|
||||
### What shipped in M03
|
||||
|
||||
- NEW stages: `stages/gateway-config.ts`, `stages/gateway-bootstrap.ts` (extracted from the old monolithic `gateway/install.ts`)
|
||||
- NEW integration test: `__tests__/integration/unified-wizard.test.ts`
|
||||
- `runWizard` now has 12 stages — gateway config + bootstrap are terminal stages 11 & 12
|
||||
- 10-minute `$XDG_RUNTIME_DIR/mosaic-install-state.json` session-file bridge **deleted**
|
||||
- `mosaic gateway install` rewritten as a thin standalone wrapper invoking the same two stages — backward-compat preserved
|
||||
- `WizardState.gateway?` slice carries host/port/tier/admin/adminTokenIssued across stages
|
||||
- `tools/install.sh` single unified `mosaic wizard` call — no more two-phase launch
|
||||
- **Bonus scoped in:** finalize stage honors `state.hooks.accepted === false` via `MOSAIC_SKIP_CLAUDE_HOOKS=1`; `mosaic-link-runtime-assets` honors the flag; Mosaic-managed detection now uses a stable `"mosaic-managed": true` marker in `hooks-config.json` with byte-equality fallback for legacy installs. **Closes the M02 follow-up.**
|
||||
|
||||
### Mission status — ALL DONE
|
||||
|
||||
| AC | Status | PR |
|
||||
| ---- | ------ | ---------------------------------------------------- |
|
||||
| AC-1 | ✓ | #429 |
|
||||
| AC-2 | ✓ | #429 |
|
||||
| AC-3 | ✓ | #431 |
|
||||
| AC-4 | ✓ | #431 + #433 (gating) |
|
||||
| AC-5 | ✓ | #431 |
|
||||
| AC-6 | ✓ | #433 |
|
||||
| AC-7 | ✓ | #429, #431, #433 all merged, CI green, issues closed |
|
||||
|
||||
### Follow-ups for future work (not blocking mission close)
|
||||
|
||||
1. **`pr-ci-wait.sh` vs Woodpecker**: wrapper reports `state=unknown` because Woodpecker doesn't publish to Gitea's combined-status endpoint. Worker used `tea pr` CI glyphs as authoritative. Pre-existing tooling gap — worth a separate tooling-team issue.
|
||||
2. **`issue-create.sh` / `pr-create.sh` wrapper `eval` bug with multiline bodies** — hit by M01, M02, M03 workers. All fell back to Gitea REST API. Needs wrapper fix.
|
||||
3. **Codex review round 5** — attempted but blocked by upstream quota. Rerun after quota resets to confirm nothing else surfaces.
|
||||
4. **Pi settings.json reversal** — deferred from M01; install manifest schema should be extended to track Pi settings mutations for reversal.
|
||||
5. **`cli-smoke.spec.ts` pre-existing failure** — `@mosaicstack/brain` resolution in Vitest. Unrelated. Worth a separate issue.
|
||||
|
||||
### Next steps (orchestrator)
|
||||
|
||||
1. This scratchpad + MISSION-MANIFEST.md + TASKS.md updates → final docs PR
|
||||
2. After merge: create release tag per framework rule (milestone/mission completion = release tag + repository release)
|
||||
3. Archive mission docs under `docs/archive/missions/install-ux-hardening-20260405/` once the tag is published
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
# Install UX v2 — Orchestrator Scratchpad
|
||||
|
||||
## Session 1 — 2026-04-05 (orchestrator scaffold)
|
||||
|
||||
### Trigger
|
||||
|
||||
Real-run testing of `@mosaicstack/mosaic@0.0.25` (fresh install of the release we just shipped from the parent mission `install-ux-hardening-20260405`) surfaced a critical regression and a cluster of UX failings. User feedback verbatim:
|
||||
|
||||
> The skill/additional feature installation section of install.sh is unsable
|
||||
> The "quick-start" is asking way too many questions. This process should be much faster to get a quick start.
|
||||
> The installater should have a main menu that allows for a drill-down install approach.
|
||||
> "Plugins" — Install Recommended Plugins / Custom
|
||||
> "Providers" — …
|
||||
> The gateway port is not prefilling with 14242 for default
|
||||
> What is the CORS origin for? Is that the webUI that isn't working yet? Maybe we should ask for the fqdn/hostname instead? There must be a better way to handle this.
|
||||
|
||||
Plus the critical bug, reproduced verbatim:
|
||||
|
||||
```
|
||||
◇ Admin email
|
||||
│ jason@woltje.com
|
||||
Admin password (min 8 chars): ****************
|
||||
Confirm password: ****************
|
||||
│
|
||||
▲ Bootstrap failed (400): {"message":["property email should not exist","property password should not exist"],"error":"Bad Request","statusCode":400}
|
||||
✔ Wizard complete.
|
||||
✔ Install manifest written: /home/jarvis/.config/mosaic/.install-manifest.json
|
||||
|
||||
✔ Done.
|
||||
```
|
||||
|
||||
Note the `✔ Wizard complete` and `✔ Done` lines **after** the 400. That's a second bug — failure didn't propagate in interactive mode.
|
||||
|
||||
### Diagnosis — orchestrator pre-scope
|
||||
|
||||
To avoid handing workers a vague prompt, pre-identified the concrete fix sites:
|
||||
|
||||
**Bug 1 (critical) — DTO class erasure.** `apps/gateway/src/admin/bootstrap.controller.ts:16`:
|
||||
|
||||
```ts
|
||||
import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||
```
|
||||
|
||||
`import type` erases the class at runtime. `@Body() dto: BootstrapSetupDto` then has no runtime metatype — `design:paramtypes` reflects `Object`. Nest's `ValidationPipe` with `whitelist: true` + `forbidNonWhitelisted: true` receives a plain Object metatype, treats every incoming property as non-whitelisted, and 400s with `"property email should not exist", "property password should not exist"`.
|
||||
|
||||
**One-character fix:** drop the `type` keyword on the `BootstrapSetupDto` import. `BootstrapStatusDto` and `BootstrapResultDto` are fine as type-only imports because they're used only in return type positions, not as `@Body()` metatypes.
|
||||
|
||||
Must be covered by an **integration test that binds through Nest**, not a controller unit test that imports the DTO directly — the unit test path would pass even with `import type` because it constructs the pipe manually. An e2e test with `@nestjs/testing` + `supertest` against the real `/api/bootstrap/setup` endpoint is the right guard.
|
||||
|
||||
**Bug 2 — interactive silent failure.** `packages/mosaic/src/wizard.ts:147-150`:
|
||||
|
||||
```ts
|
||||
if (!bootstrapResult.completed && headlessRun) {
|
||||
prompter.warn('Admin bootstrap failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
The guard is `&& headlessRun`. In interactive mode, `completed: false` is silently swallowed and the wizard continues to the success lines. Fix: propagate failure in both modes. Decision for the worker — either `throw` or `process.exit(1)` with a clear error.
|
||||
|
||||
**Bug 3 — port prefill.** `packages/mosaic/src/stages/gateway-config.ts:77-88`:
|
||||
|
||||
```ts
|
||||
const raw = await p.text({
|
||||
message: 'Gateway port',
|
||||
defaultValue: defaultPort.toString(),
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
The stage is passing `defaultValue`. Either the `WizardPrompter.text` adapter is dropping it, or the underlying `@clack/prompts` call expects `initialValue` (which actually prefills the buffer) vs `defaultValue` (which is used only if the user submits an empty string). Worker should verify the adapter and likely switch to `initialValue` semantics so the user sees `14242` in the field.
|
||||
|
||||
**Bug 4 — Pi SDK copy gap.** The `"What is Mosaic?"` intro text enumerates Claude Code, Codex, and OpenCode but never mentions Pi SDK, which is the actual agent runtime behind those frontends. Purely a copy edit — find the string, add Pi SDK.
|
||||
|
||||
### Mission shape
|
||||
|
||||
Three milestones, three tracks, different tiers:
|
||||
|
||||
1. **IUV-M01 Hotfix** (sonnet) — the four bugs above + release `mosaic-v0.0.26`. Small, fast, unblocks the 0.0.25 happy path.
|
||||
2. **IUV-M02 UX polish** (sonnet) — CORS origin → FQDN/hostname abstraction; diagnose and rework the skill installer section. Diagnostic-heavy.
|
||||
3. **IUV-M03 Provider-first intelligent flow** (opus) — the big one: drill-down main menu, Quick Start path that's actually quick, provider-first natural-language intake with agent self-naming (OpenClaw-style). Architectural.
|
||||
|
||||
Sequencing: strict. M01 ships first as a hotfix release (mosaic-v0.0.26). M02 is diagnostic-heavy and can share groundwork with M03 but ships separately for clean release notes. M03 is the architectural anchor and lands last as `mosaic-v0.0.27`.
|
||||
|
||||
### Open design questions (to be resolved by workers, not pre-decided)
|
||||
|
||||
- M01: does `process.exit(1)` vs `throw` matter for how `tools/install.sh` surfaces the error? Worker should check the install.sh call site and pick the behavior that surfaces cleanly.
|
||||
- M03: what LLM call powers the intent intake, and what's the offline fallback? Options: (a) reuse the provider the user is configuring (chicken-and-egg — provider setup hasn't happened yet), (b) a bundled deterministic "advisor" that hard-codes common intents, (c) require a provider key up-front before intake. Design doc (IUV-03-01) must resolve.
|
||||
- M03: is the "agent self-naming" persistent across all future `mosaic` invocations, or a per-session nickname? Probably persistent — lives in `~/.config/mosaic/agent.json` or similar. Worker to decide + document.
|
||||
|
||||
### Non-goals for this mission
|
||||
|
||||
- No GUI / web UI
|
||||
- No registry / pipeline migration
|
||||
- No multi-user / multi-tenant onboarding
|
||||
- No rework of `mosaic uninstall` (stable from parent mission)
|
||||
|
||||
### Known tooling caveats (carry forward from parent mission)
|
||||
|
||||
- `issue-create.sh` / `pr-create.sh` wrappers have an `eval` bug with multiline bodies — use Gitea REST API fallback with `load_credentials gitea-mosaicstack`
|
||||
- `pr-ci-wait.sh` reports `state=unknown` against Woodpecker (combined-status endpoint gap) — use `tea pr` glyphs or poll the commit status endpoint directly
|
||||
- Protected `main`, squash-merge only, PR-required
|
||||
- CI queue guard before push/merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge`
|
||||
|
||||
### Next action
|
||||
|
||||
1. Create Gitea issues for M01, M02, M03
|
||||
2. Open the mission-scaffold docs PR (same pattern as parent mission's PR #430)
|
||||
3. After merge, delegate IUV-M01 to a sonnet subagent in an isolated worktree with the concrete fix-site pointers above
|
||||
|
||||
## Session 2 — 2026-04-05 (IUV-M01 delivery + close-out)
|
||||
|
||||
### Outcome
|
||||
|
||||
IUV-M01 shipped. `mosaic-v0.0.26` released and registry latest confirmed `0.0.26`.
|
||||
|
||||
### PRs merged
|
||||
|
||||
| PR | Title | Merge |
|
||||
| ---- | ------------------------------------------------------------------------ | -------- |
|
||||
| #440 | fix: bootstrap hotfix — DTO erasure, wizard failure, port prefill, copy | 0ae932ab |
|
||||
| #441 | fix: add vitest.config.ts to eslint allowDefaultProject (#440 build fix) | c08aa6fa |
|
||||
| #442 | docs: mark IUV-M01 complete — mosaic-v0.0.26 released | 78388437 |
|
||||
|
||||
### Bugs fixed (all 4 in worker's PR #440)
|
||||
|
||||
1. **DTO class erasure** — `apps/gateway/src/admin/bootstrap.controller.ts:16` — dropped `type` from `import { BootstrapSetupDto }`. Guarded by new e2e test `bootstrap.e2e.spec.ts` (4 cases) that binds through a real Nest app with `ValidationPipe { whitelist, forbidNonWhitelisted }`. Test suite needed `unplugin-swc` in `apps/gateway/vitest.config.ts` to emit `decoratorMetadata` (tsx/esbuild can't).
|
||||
2. **Wizard silent failure** — `packages/mosaic/src/wizard.ts` — removed the `&& headlessRun` guard so `!bootstrapResult.completed` now aborts in both modes.
|
||||
3. **Port prefill** — root cause was clack's `defaultValue` vs `initialValue` semantics (`defaultValue` only fills on empty submit, `initialValue` prefills the buffer). Added an `initialValue` field to `WizardPrompter.text()` interface, threaded through clack and headless prompters, switched `gateway-config.ts` port/url prompts to use it.
|
||||
4. **Pi SDK copy** — `packages/mosaic/src/stages/welcome.ts` — intro copy now lists Pi SDK.
|
||||
|
||||
### Mid-delivery hiccup — tsconfig/eslint cross-contamination
|
||||
|
||||
Worker's initial approach added `vitest.config.ts` to `apps/gateway/tsconfig.json`'s `include` to appease the eslint parser. That broke `pnpm --filter @mosaicstack/gateway build` with TS6059 (`vitest.config.ts` outside `rootDir: "src"`). The publish pipeline on the `#440` merge commit failed.
|
||||
|
||||
**Correct fix** (worker's PR #441): leave `tsconfig.json` clean (`include: ["src/**/*"]`) and instead add the file to `allowDefaultProject` in the root `eslint.config.mjs`. This keeps the tsc program strict while letting eslint resolve a parser project for the standalone config file.
|
||||
|
||||
**Pattern to remember**: when adding root-level `.ts` config files (vitest, build scripts) to a package with `rootDir: "src"`, the eslint parser project conflict is solved with `allowDefaultProject`, NEVER by widening tsconfig include. I had independently arrived at the same fix on a branch before the worker shipped #441 — deleted the duplicate.
|
||||
|
||||
### Residual follow-ups carried forward
|
||||
|
||||
1. Headless prompter fallback order: worker set `initialValue > defaultValue` in the headless path. Correct semantic, but any future headless test that explicitly depends on `defaultValue` precedence will need review.
|
||||
2. Vitest + SWC decorator metadata pattern is now the blessed approach for NestJS e2e tests in this monorepo. Any other package that adds NestJS e2e tests should mirror `apps/gateway/vitest.config.ts`.
|
||||
|
||||
### Next action
|
||||
|
||||
- Close out orchestrator doc sync (this commit): mark M01 subtasks done in `TASKS.md`, update manifest phase to Execution, commit scratchpad session 2, PR to main.
|
||||
- After merge, delegate IUV-M02 (sonnet, isolated worktree). Dependencies: IUV-02-01 (CORS→FQDN) starts unblocked since M01 is released; first real task for the M02 worker is diagnosing the skill installer failure modes (IUV-02-02) against the fresh 0.0.26 install.
|
||||
|
||||
## Session 3 — 2026-04-05 (IUV-M02 delivery + close-out)
|
||||
|
||||
### Outcome
|
||||
|
||||
IUV-M02 shipped. PR #444 merged (`172bacb3`), issue #437 closed. 18 new tests (13 CORS derivation, 5 skill sync).
|
||||
|
||||
### Changes
|
||||
|
||||
**CORS → FQDN (IUV-02-01):**
|
||||
|
||||
- `packages/mosaic/src/stages/gateway-config.ts` — replaced raw "CORS origin" text prompt with "Web UI hostname" (default: `localhost`). Added HTTPS follow-up for remote hosts. Pure `deriveCorsOrigin(hostname, port, useHttps?)` function exported for testability.
|
||||
- Headless: `MOSAIC_HOSTNAME` env var as friendly alternative; `MOSAIC_CORS_ORIGIN` still works as full override.
|
||||
- `packages/mosaic/src/types.ts` — added `hostname?: string` to `GatewayState`.
|
||||
|
||||
**Skill installer rework (IUV-02-02 + IUV-02-03):**
|
||||
|
||||
- Root cause confirmed: `syncSkills()` in `finalize.ts` ignored `state.selectedSkills` entirely. The multiselect UI was a no-op.
|
||||
- `packages/mosaic/src/stages/finalize.ts` — `syncSkills()` rewritten to accept `selectedSkills[]`, returns typed `SyncSkillsResult`, passes `MOSAIC_INSTALL_SKILLS` (colon-separated) as env var to the bash script.
|
||||
- `packages/mosaic/framework/tools/_scripts/mosaic-sync-skills` — added bash associative array whitelist filter keyed on `MOSAIC_INSTALL_SKILLS`. When set, only whitelisted skills are linked. Empty/unset = all skills (legacy behavior preserved for `mosaic sync` outside wizard).
|
||||
- Failure surfaces: silent `catch {}` replaced with typed error reporting through `p.warn()`.
|
||||
|
||||
### Next action
|
||||
|
||||
- Delegate IUV-M03 (opus, isolated worktree) — the architectural milestone: provider-first intelligent flow, drill-down main menu, Quick Start fast path, agent self-naming. This is the biggest piece of the mission.
|
||||
@@ -1,227 +0,0 @@
|
||||
# IUV-M03 Design: Provider-first intelligent flow + drill-down main menu
|
||||
|
||||
**Issue:** #438
|
||||
**Branch:** `feat/install-ux-intent`
|
||||
**Date:** 2026-04-05
|
||||
|
||||
## 1. New first-run state machine
|
||||
|
||||
The linear 12-stage interrogation is replaced with a menu-driven architecture.
|
||||
|
||||
### Flow overview
|
||||
|
||||
```
|
||||
Welcome banner
|
||||
|
|
||||
v
|
||||
Detect existing install (auto)
|
||||
|
|
||||
v
|
||||
Main Menu (loop)
|
||||
|-- Quick Start -> provider key + admin creds -> finalize
|
||||
|-- Providers -> LLM API key config
|
||||
|-- Agent Identity -> intent intake + naming (deterministic)
|
||||
|-- Skills -> recommended / custom selection
|
||||
|-- Gateway -> port, storage tier, hostname, CORS
|
||||
|-- Advanced -> SOUL.md, USER.md, TOOLS.md, runtimes, hooks
|
||||
|-- Finish & Apply -> finalize + gateway bootstrap
|
||||
v
|
||||
Done
|
||||
```
|
||||
|
||||
### Menu navigation
|
||||
|
||||
- Main menu is a `select` prompt. Each option drills into a sub-flow.
|
||||
- Completing a section returns to the main menu.
|
||||
- Menu items show completion state: `[done]` hint after configuration.
|
||||
- `Finish & Apply` is always last and requires at minimum a provider key (or explicit skip).
|
||||
- The menu tracks configured sections in `WizardState.completedSections`.
|
||||
|
||||
### Headless bypass
|
||||
|
||||
When `MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`, the entire menu is skipped.
|
||||
The wizard runs: defaults + env var overrides -> finalize -> gateway config -> bootstrap.
|
||||
This preserves full backward compatibility with `tools/install.sh --yes`.
|
||||
|
||||
## 2. Quick Start path
|
||||
|
||||
Target: 3-5 questions max. Under 90 seconds for a returning user.
|
||||
|
||||
### Questions asked
|
||||
|
||||
1. **Provider API key** (Anthropic/OpenAI) - `text` prompt with paste support
|
||||
2. **Admin email** - `text` prompt
|
||||
3. **Admin password** - masked + confirmed
|
||||
|
||||
### Questions skipped (with defaults)
|
||||
|
||||
| Setting | Default | Rationale |
|
||||
| ---------------------------- | ------------------------------- | ---------------------- |
|
||||
| Agent name | "Mosaic" | Generic but branded |
|
||||
| Port | 14242 | Standard default |
|
||||
| Storage tier | local | No external deps |
|
||||
| Hostname | localhost | Dev-first |
|
||||
| CORS origin | http://localhost:3000 | Standard web UI port |
|
||||
| Skills | recommended set | Curated by maintainers |
|
||||
| Runtimes | auto-detected | No user input needed |
|
||||
| Communication style | direct | Most popular choice |
|
||||
| SOUL.md / USER.md / TOOLS.md | template defaults | Can customize later |
|
||||
| Hooks | auto-install if Claude detected | Safe default |
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
Quick Start selected
|
||||
-> "Paste your LLM API key (Anthropic recommended):"
|
||||
-> [auto-detect provider from key prefix: sk-ant-* = Anthropic, sk-* = OpenAI]
|
||||
-> Apply all defaults
|
||||
-> Run finalize (sync framework, write configs, link assets, sync skills)
|
||||
-> Run gateway config (headless-style with defaults + provided key)
|
||||
-> "Admin email:"
|
||||
-> "Admin password:" (masked + confirm)
|
||||
-> Run gateway bootstrap
|
||||
-> Done
|
||||
```
|
||||
|
||||
## 3. Provider-first flow
|
||||
|
||||
Provider configuration (currently buried in gateway-config stage as "ANTHROPIC_API_KEY")
|
||||
moves to a dedicated top-level menu item and is the first question in Quick Start.
|
||||
|
||||
### Provider detection
|
||||
|
||||
The API key prefix determines the provider:
|
||||
|
||||
- `sk-ant-api03-*` -> Anthropic (Claude)
|
||||
- `sk-*` -> OpenAI
|
||||
- Empty/skipped -> no provider (gateway starts without LLM access)
|
||||
|
||||
### Storage
|
||||
|
||||
The provider key is stored in the gateway `.env` as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`.
|
||||
For Quick Start, this replaces the old interactive prompt in `collectAndWriteConfig`.
|
||||
|
||||
### Menu section: "Providers"
|
||||
|
||||
In the drill-down menu, "Providers" lets users:
|
||||
|
||||
1. Enter/change their API key
|
||||
2. See which provider was detected
|
||||
3. Optionally configure a second provider
|
||||
|
||||
For v0.0.27, we support Anthropic and OpenAI keys only. The key is stored
|
||||
in `WizardState` and written during finalize.
|
||||
|
||||
## 4. Intent intake + naming (deterministic fallback - Option B)
|
||||
|
||||
### Rationale
|
||||
|
||||
At install time, the LLM provider may not be configured yet (chicken-and-egg).
|
||||
We use **Option B: deterministic advisor** for the install wizard.
|
||||
|
||||
### Flow (Agent Identity menu section)
|
||||
|
||||
```
|
||||
1. "What will this agent primarily help you with?"
|
||||
-> Select from presets:
|
||||
- General purpose assistant
|
||||
- Software development
|
||||
- DevOps & infrastructure
|
||||
- Research & analysis
|
||||
- Content & writing
|
||||
- Custom (free text description)
|
||||
|
||||
2. System proposes a thematic name based on selection:
|
||||
- General purpose -> "Mosaic"
|
||||
- Software development -> "Forge"
|
||||
- DevOps & infrastructure -> "Sentinel"
|
||||
- Research & analysis -> "Atlas"
|
||||
- Content & writing -> "Muse"
|
||||
- Custom -> "Mosaic" (default)
|
||||
|
||||
3. "Your agent will be named 'Forge'. Press Enter to accept or type a new name:"
|
||||
-> User confirms or overrides
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
- Agent name -> `WizardState.soul.agentName` -> written to SOUL.md
|
||||
- Intent category -> `WizardState.agentIntent` (new field) -> written to `~/.config/mosaic/agent.json`
|
||||
|
||||
### Post-install LLM-powered intake (future)
|
||||
|
||||
A future `mosaic configure identity` command can use the configured LLM to:
|
||||
|
||||
- Accept free-text intent description
|
||||
- Generate an expounded persona
|
||||
- Propose a contextual name
|
||||
|
||||
This is explicitly out of scope for the install wizard.
|
||||
|
||||
## 5. Headless backward-compat
|
||||
|
||||
### Supported env vars (unchanged)
|
||||
|
||||
| Variable | Used by |
|
||||
| -------------------------- | ---------------------------------------------- |
|
||||
| `MOSAIC_ASSUME_YES=1` | Skip all prompts, use defaults + env overrides |
|
||||
| `MOSAIC_ADMIN_NAME` | Gateway bootstrap |
|
||||
| `MOSAIC_ADMIN_EMAIL` | Gateway bootstrap |
|
||||
| `MOSAIC_ADMIN_PASSWORD` | Gateway bootstrap |
|
||||
| `MOSAIC_GATEWAY_PORT` | Gateway config |
|
||||
| `MOSAIC_HOSTNAME` | Gateway config (CORS derivation) |
|
||||
| `MOSAIC_CORS_ORIGIN` | Gateway config (full override) |
|
||||
| `MOSAIC_STORAGE_TIER` | Gateway config (local/team) |
|
||||
| `MOSAIC_DATABASE_URL` | Gateway config (team tier) |
|
||||
| `MOSAIC_VALKEY_URL` | Gateway config (team tier) |
|
||||
| `MOSAIC_ANTHROPIC_API_KEY` | Provider config |
|
||||
|
||||
### New env vars
|
||||
|
||||
| Variable | Purpose |
|
||||
| --------------------- | ----------------------------------------- |
|
||||
| `MOSAIC_AGENT_NAME` | Override agent name in headless mode |
|
||||
| `MOSAIC_AGENT_INTENT` | Override intent category in headless mode |
|
||||
|
||||
### `tools/install.sh --yes`
|
||||
|
||||
The install script sets `MOSAIC_ASSUME_YES=1` and passes through env vars.
|
||||
No changes needed to the script itself. The new wizard detects headless mode
|
||||
at the top of `runWizard` and runs a linear path identical to the old flow.
|
||||
|
||||
## 6. Explicit non-goals
|
||||
|
||||
- **No GUI** — this is a terminal wizard only
|
||||
- **No multi-user install** — single-user, single-machine
|
||||
- **No registry changes** — npm publish flow is unchanged
|
||||
- **No LLM calls during install** — deterministic fallback only
|
||||
- **No new dependencies** — uses existing @clack/prompts and picocolors
|
||||
- **No changes to gateway API** — only the wizard orchestration changes
|
||||
- **No changes to tools/install.sh** — headless compat maintained via env vars
|
||||
|
||||
## 7. Implementation plan
|
||||
|
||||
### Files to modify
|
||||
|
||||
1. `packages/mosaic/src/types.ts` — add `MenuSection`, `AgentIntent`, `completedSections`, `agentIntent`, `providerKey`, `providerType` to WizardState
|
||||
2. `packages/mosaic/src/wizard.ts` — replace linear flow with menu loop
|
||||
3. `packages/mosaic/src/stages/mode-select.ts` — becomes the main menu
|
||||
4. `packages/mosaic/src/stages/provider-setup.ts` — new: provider key collection
|
||||
5. `packages/mosaic/src/stages/agent-intent.ts` — new: intent intake + naming
|
||||
6. `packages/mosaic/src/stages/menu-gateway.ts` — new: gateway sub-menu wrapper
|
||||
7. `packages/mosaic/src/stages/quick-start.ts` — new: quick start linear path
|
||||
8. `packages/mosaic/src/constants.ts` — add intent presets and name mappings
|
||||
9. `packages/mosaic/package.json` — version bump 0.0.26 -> 0.0.27
|
||||
|
||||
### Files to add (tests)
|
||||
|
||||
1. `packages/mosaic/src/stages/wizard-menu.spec.ts` — menu navigation tests
|
||||
2. `packages/mosaic/src/stages/quick-start.spec.ts` — quick start path tests
|
||||
3. `packages/mosaic/src/stages/agent-intent.spec.ts` — intent + naming tests
|
||||
4. `packages/mosaic/src/stages/provider-setup.spec.ts` — provider detection tests
|
||||
|
||||
### Migration strategy
|
||||
|
||||
The existing stage functions remain intact. The menu system wraps them —
|
||||
each menu item calls the appropriate stage function(s). The linear headless
|
||||
path calls them in the same order as before.
|
||||
@@ -1,110 +0,0 @@
|
||||
# Hotfix Scratchpad — `install.sh` does not seed `TOOLS.md`
|
||||
|
||||
- **Issue:** mosaicstack/stack#457
|
||||
- **Branch:** `fix/tools-md-seeding`
|
||||
- **Type:** Out-of-mission hotfix (not part of Install UX v2 mission)
|
||||
- **Started:** 2026-04-11
|
||||
- **Ships in:** `@mosaicstack/mosaic` 0.0.30
|
||||
|
||||
## Objective
|
||||
|
||||
Ensure `~/.config/mosaic/TOOLS.md` is created on every supported install path so the mandatory AGENTS.md load order actually resolves. The load order lists `TOOLS.md` at position 5 but the bash installer never seeds it.
|
||||
|
||||
## Root cause
|
||||
|
||||
`packages/mosaic/framework/install.sh:228-236` — the post-sync "Seed defaults" loop explicitly lists `AGENTS.md STANDARDS.md`:
|
||||
|
||||
```bash
|
||||
DEFAULTS_DIR="$TARGET_DIR/defaults"
|
||||
if [[ -d "$DEFAULTS_DIR" ]]; then
|
||||
for default_file in AGENTS.md STANDARDS.md; do # ← missing TOOLS.md
|
||||
if [[ -f "$DEFAULTS_DIR/$default_file" ]] && [[ ! -f "$TARGET_DIR/$default_file" ]]; then
|
||||
cp "$DEFAULTS_DIR/$default_file" "$TARGET_DIR/$default_file"
|
||||
ok "Seeded $default_file from defaults"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
```
|
||||
|
||||
`TOOLS.md` is listed in `PRESERVE_PATHS` (line 24) but never created in the first place. A fresh bootstrap install via `tools/install.sh → framework/install.sh` leaves `~/.config/mosaic/TOOLS.md` absent, and the agent load order then points at a missing file.
|
||||
|
||||
### Secondary: TypeScript `syncFramework` is too greedy
|
||||
|
||||
`packages/mosaic/src/config/file-adapter.ts:133-160` — `FileConfigAdapter.syncFramework` correctly seeds TOOLS.md, but it does so by iterating _every_ file in `framework/defaults/`:
|
||||
|
||||
```ts
|
||||
for (const entry of readdirSync(defaultsDir)) {
|
||||
const dest = join(this.mosaicHome, entry);
|
||||
if (!existsSync(dest)) {
|
||||
copyFileSync(join(defaultsDir, entry), dest);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`framework/defaults/` contains:
|
||||
|
||||
```
|
||||
AGENTS.md
|
||||
AUDIT-2026-02-17-framework-consistency.md
|
||||
README.md
|
||||
SOUL.md ← hardcoded "Jarvis"
|
||||
STANDARDS.md
|
||||
TOOLS.md
|
||||
USER.md
|
||||
```
|
||||
|
||||
So on a fresh install the TS wizard would silently copy the `Jarvis`-flavored `SOUL.md` + placeholder `USER.md` + internal `AUDIT-*.md` and `README.md` into the user's mosaic home before `mosaic init` ever prompts them. That's a latent identity bug as well as a root-clutter bug — the wizard's own stages are responsible for generating `SOUL.md`/`USER.md` via templates.
|
||||
|
||||
### Tertiary: stale `TOOLS.md.template`
|
||||
|
||||
`packages/mosaic/framework/templates/TOOLS.md.template` still references `~/.config/mosaic/rails/git/…` and `~/.config/mosaic/rails/codex/…`. The `rails/` tree was renamed to `tools/` in the v1→v2 migration (see `run_migrations` in `install.sh`, which removes the old `rails/` symlink). Any user who does run `mosaic init` ends up with a `TOOLS.md` that points to paths that no longer exist.
|
||||
|
||||
## Scope of this fix
|
||||
|
||||
1. **`packages/mosaic/framework/install.sh`** — extend the explicit seed list to include `TOOLS.md`.
|
||||
2. **`packages/mosaic/src/config/file-adapter.ts`** — restrict `syncFramework` defaults-seeding to an explicit whitelist (`AGENTS.md`, `STANDARDS.md`, `TOOLS.md`) so the TS wizard never accidentally seeds `SOUL.md`/`USER.md`/`README.md`/`AUDIT-*.md` into the mosaic home.
|
||||
3. **`packages/mosaic/framework/templates/TOOLS.md.template`** — replace `rails/` with `tools/` in the wrapper-path examples (minimal surgical fix; full template modernization is out of scope for a 0.0.30 hotfix).
|
||||
4. **Regression test** — unit test around `FileConfigAdapter.syncFramework` that runs against a tmpdir fixture asserting:
|
||||
- `TOOLS.md` is seeded when absent
|
||||
- `AGENTS.md` / `STANDARDS.md` are still seeded when absent
|
||||
- `SOUL.md` / `USER.md` are **not** seeded from `defaults/` (the wizard stages own those)
|
||||
- Existing root files are not clobbered.
|
||||
|
||||
Out of scope (tracked separately / future work):
|
||||
|
||||
- Regenerating `defaults/SOUL.md` and `defaults/USER.md` so they no longer contain Jarvis-specific content.
|
||||
- Fully modernizing `TOOLS.md.template` to match the rich canonical `defaults/TOOLS.md` reference.
|
||||
- `issue-create.sh` / `pr-create.sh` `eval` bugs (already captured to OpenBrain from the prior hotfix).
|
||||
|
||||
## Plan / checklist
|
||||
|
||||
- [ ] Branch `fix/tools-md-seeding` from `main` (at `b2cbf89`)
|
||||
- [ ] File Gitea issue (direct API; wrappers broken for bodies with backticks)
|
||||
- [ ] Scratchpad created (this file)
|
||||
- [ ] `install.sh` seed loop extended to `AGENTS.md STANDARDS.md TOOLS.md`
|
||||
- [ ] `file-adapter.ts` seeding restricted to explicit whitelist
|
||||
- [ ] `TOOLS.md.template` `rails/` → `tools/`
|
||||
- [ ] Regression test added (`file-adapter.test.ts`) — failing first, then green
|
||||
- [ ] `pnpm --filter @mosaicstack/mosaic run typecheck` green
|
||||
- [ ] `pnpm --filter @mosaicstack/mosaic run lint` green
|
||||
- [ ] `pnpm --filter @mosaicstack/mosaic exec vitest run` — new test green, no new failures beyond the known pre-existing `uninstall.spec.ts:138`
|
||||
- [ ] Repo baselines: `pnpm typecheck` / `pnpm lint` / `pnpm format:check`
|
||||
- [ ] Independent code review (`feature-dev:code-reviewer`, sonnet tier)
|
||||
- [ ] Commit + push
|
||||
- [ ] PR opened via Gitea API
|
||||
- [ ] CI queue guard cleared (bypass local `ci-queue-wait.sh` if stale origin URL breaks it; query Gitea API directly)
|
||||
- [ ] CI green on PR
|
||||
- [ ] PR merged (squash)
|
||||
- [ ] CI green on main
|
||||
- [ ] Issue closed with link to merge commit
|
||||
- [ ] `chore/release-mosaic-0.0.30` branch bumps `packages/mosaic/package.json` 0.0.29 → 0.0.30
|
||||
- [ ] Release PR opened + merged
|
||||
- [ ] `.woodpecker/publish.yml` auto-publishes to Gitea npm registry
|
||||
- [ ] Publish verified (`npm view @mosaicstack/mosaic version` or registry check)
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- `ci-queue-wait.sh` wrapper may still crash on stale `origin` URL (captured in OpenBrain from prior hotfix). Workaround: query Gitea API directly for running/queued pipelines.
|
||||
- `issue-create.sh` / `pr-create.sh` `eval` bugs. Workaround: Gitea API direct call.
|
||||
- `uninstall.spec.ts:138` is a pre-existing failure on main; not this change's problem.
|
||||
- Publish flow is fire-and-forget on main push — if `publish.yml` fails, rollback means republishing a follow-up patch, not reverting the version bump.
|
||||
@@ -1,114 +0,0 @@
|
||||
# Hotfix Scratchpad — `mosaic yolo <runtime>` passes runtime name as initial user message
|
||||
|
||||
- **Issue:** mosaicstack/stack#454
|
||||
- **Branch:** `fix/yolo-runtime-initial-arg`
|
||||
- **Type:** Out-of-mission hotfix (not part of Install UX v2 mission)
|
||||
- **Started:** 2026-04-11
|
||||
|
||||
## Objective
|
||||
|
||||
Stop `mosaic yolo <runtime>` from passing the runtime name (`claude`, `codex`, etc.) as the initial user message to the underlying CLI. Restore the mission-auto-prompt path for yolo launches.
|
||||
|
||||
## Root cause (confirmed)
|
||||
|
||||
`packages/mosaic/src/commands/launch.ts:779` — the `yolo <runtime>` action handler:
|
||||
|
||||
```ts
|
||||
.action((runtime: string, _opts: unknown, cmd: Command) => {
|
||||
// ... validate runtime ...
|
||||
launchRuntime(runtime as RuntimeName, cmd.args, true);
|
||||
});
|
||||
```
|
||||
|
||||
Commander.js includes declared positional arguments in `cmd.args`. For `mosaic yolo claude`:
|
||||
|
||||
- `runtime` (destructured) = `"claude"`
|
||||
- `cmd.args` = `["claude"]` — the same value
|
||||
|
||||
`launchRuntime` treats `["claude"]` as excess positional args, and for the `claude` case that becomes the initial user message. As a secondary consequence, `hasMissionNoArgs` evaluates false, so the mission-auto-prompt path is bypassed too.
|
||||
|
||||
## Live reproduction (intercepted claude binary)
|
||||
|
||||
```
|
||||
$ PATH=/tmp/fake-claude-bin:$PATH mosaic yolo claude
|
||||
[mosaic] Launching Claude Code in YOLO mode...
|
||||
argv[1]: --dangerously-skip-permissions
|
||||
argv[2]: --append-system-prompt
|
||||
argv[3] (len=25601): # ACTIVE MISSION — HARD GATE ...
|
||||
argv[4]: claude ← the bug
|
||||
```
|
||||
|
||||
Non-yolo variant `mosaic claude` is clean:
|
||||
|
||||
```
|
||||
argv[1]: --append-system-prompt
|
||||
argv[2]: <prompt>
|
||||
argv[3]: Active mission detected: MVP. Read the mission state files and report status.
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
1. Refactor `launch.ts`: extract `registerRuntimeLaunchers(program, handler)` with an injectable handler so commander wiring is testable without spawning subprocesses. `registerLaunchCommands` delegates to it with `launchRuntime` as the handler.
|
||||
2. Fix: in the `yolo <runtime>` action, pass `cmd.args.slice(1)` instead of `cmd.args`.
|
||||
3. Add `packages/mosaic/src/commands/launch.spec.ts`:
|
||||
- Failing-first reproducer: parse `['node','x','yolo','claude']` and assert handler receives `extraArgs=[]` and `yolo=true`.
|
||||
- Regression test: parse `['node','x','claude']` asserts handler receives `extraArgs=[]` and `yolo=false`.
|
||||
- Excess args: parse `['node','x','yolo','claude','--print','hi']` asserts handler receives `extraArgs=['--print','hi']` (with `--print` kept because `allowUnknownOption` is true).
|
||||
- Excess args non-yolo: parse `['node','x','claude','--print','hi']` asserts `extraArgs=['--print','hi']`.
|
||||
- Reject unknown runtime under yolo.
|
||||
4. Run typecheck, lint, format:check, vitest for `@mosaicstack/mosaic`.
|
||||
5. Independent code review (feature-dev:code-reviewer subagent, sonnet tier).
|
||||
6. Commit → push → PR via wrappers → merge → CI green → close issue #454.
|
||||
7. Release decision (`mosaic-v0.0.30`) deferred to Jason after merge.
|
||||
|
||||
## Framework compliance sub-findings (out-of-scope; to capture in OpenBrain after)
|
||||
|
||||
- `~/.config/mosaic/tools/git/issue-create.sh` uses `eval` on `$BODY`; arbitrary bodies with backticks, `$`, or parens break catastrophically.
|
||||
- `gitea_issue_create_api` fallback uses `curl -fsS` without `-L`; after the `mosaicstack/mosaic-stack → mosaicstack/stack` rename, the API redirect is not followed and the fallback silently fails.
|
||||
- Local repo `origin` remote still points at old `mosaic/mosaic-stack.git` slug. Not touched here per git-config safety rule.
|
||||
- `~/.config/mosaic/TOOLS.md` referenced by the global load order but does not exist on disk.
|
||||
|
||||
These will be captured to OpenBrain after the hotfix merges so they don't get lost, and filed as separate tracking items.
|
||||
|
||||
## Progress checkpoints
|
||||
|
||||
- [x] Branch created (`fix/yolo-runtime-initial-arg`)
|
||||
- [x] Issue #454 opened
|
||||
- [x] Scratchpad scaffolded
|
||||
- [x] Failing test added (red)
|
||||
- [x] Refactor + fix applied
|
||||
- [x] Tests green (launch.spec.ts 11/11)
|
||||
- [x] Baselines green (typecheck, lint, format:check, vitest — pre-existing `uninstall.spec.ts:138` failure on branch main acknowledged, not caused by this change)
|
||||
- [x] Code review pass (feature-dev:code-reviewer, sonnet — no blockers)
|
||||
- [x] Commit + push (commit 1dd4f59)
|
||||
- [x] PR opened (mosaicstack/stack#455)
|
||||
- [x] CI queue guard cleared (no pending pipelines pre-push or pre-merge)
|
||||
- [x] PR merged (squash merge commit b2cec8c6bac29336a6cdcdb4f19806f7b5fa0054)
|
||||
- [x] CI green on main (`ci/woodpecker/push/ci` + `ci/woodpecker/push/publish` both success on merge commit)
|
||||
- [x] Issue #454 closed
|
||||
- [x] Scratchpad final evidence entry
|
||||
|
||||
## Tests run
|
||||
|
||||
- `pnpm --filter @mosaicstack/mosaic run typecheck` → green
|
||||
- `pnpm --filter @mosaicstack/mosaic run lint` → green
|
||||
- `pnpm --filter @mosaicstack/mosaic exec prettier --check "src/**/*.ts"` → green
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/launch.spec.ts` → 11/11 pass
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run` → 270/271 pass (1 pre-existing `uninstall.spec.ts:138` EACCES failure, confirmed on the branch before this change)
|
||||
- `pnpm typecheck` (repo) → green
|
||||
- `pnpm lint` (repo) → green
|
||||
- `pnpm format:check` (repo) → green (after prettier-writing the scratchpad)
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
None expected. Refactor is small and the Commander API is stable. Test needs `exitOverride()` to prevent `process.exit` on invalid runtime.
|
||||
|
||||
## Final verification evidence
|
||||
|
||||
- PR: mosaicstack/stack#455 — state `closed`, merged.
|
||||
- Merge commit: `b2cec8c6bac29336a6cdcdb4f19806f7b5fa0054` (squash to `main`).
|
||||
- Post-merge CI (main @ b2cec8c6): `ci/woodpecker/push/ci` = success, `ci/woodpecker/push/publish` = success. (`ci/woodpecker/tag/publish` was last observed as a pre-existing failure on the prior release tag and is unrelated to this change.)
|
||||
- Issue mosaicstack/stack#454 closed with a comment linking the merge commit.
|
||||
- Launch regression suite: `launch.spec.ts` 11/11 pass on main.
|
||||
- Baselines on main after merge are inherited from the PR CI run.
|
||||
- Release decision (`mosaicstack/mosaic` 0.0.30) intentionally deferred to the user — the fix is now sitting on main awaiting a release cut.
|
||||
@@ -27,7 +27,6 @@ export default tseslint.config(
|
||||
'apps/web/e2e/*.ts',
|
||||
'apps/web/e2e/helpers/*.ts',
|
||||
'apps/web/playwright.config.ts',
|
||||
'apps/gateway/vitest.config.ts',
|
||||
'packages/mosaic/__tests__/*.ts',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -73,27 +73,6 @@ Spawn a worker instead. No exceptions. No "quick fixes."
|
||||
- Wait for at least one worker to complete before spawning more
|
||||
- This optimizes token usage and reduces context pressure
|
||||
|
||||
## File Ownership & Partitioning (Hard Rule for Parallel Workers)
|
||||
|
||||
When dispatching parallel workers, the orchestrator MUST assign **non-overlapping file scopes** to each worker. File collisions between parallel workers cause merge conflicts, lost edits, and wasted tokens.
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. **Exclusive file ownership.** Each file may be assigned to at most one active worker. The orchestrator records ownership in the worker dispatch (prompt or task definition).
|
||||
2. **Partition by directory or module.** Prefer assigning entire directories/modules to one worker rather than splitting files within a directory across workers.
|
||||
3. **Shared files are serialized.** If two tasks must modify the same file (e.g., a shared types file, a barrel export), they MUST run sequentially — never in parallel. Mark the second task with `depends_on` pointing to the first.
|
||||
4. **Test files follow source ownership.** If Worker A owns `src/auth/login.ts`, Worker A also owns `src/auth/__tests__/login.test.ts`. Do not split source and test across workers.
|
||||
5. **Config files are orchestrator-reserved.** Files like `package.json`, `tsconfig.json`, and CI config are owned by the orchestrator and modified only between worker cycles, never during parallel execution.
|
||||
6. **Document ownership in dispatch.** When spawning a worker, include an explicit `Files:` section listing owned paths/globs. Example:
|
||||
|
||||
```
|
||||
Files (exclusive — do not touch files outside this scope):
|
||||
- apps/web/src/components/auth/**
|
||||
- apps/web/src/lib/auth.ts
|
||||
```
|
||||
|
||||
7. **Conflict recovery.** If a worker edits a file outside its scope, the orchestrator MUST flag the violation, assess the diff, and either revert the out-of-scope change or re-run the affected worker with the corrected file.
|
||||
|
||||
## Delegation Mode Selection
|
||||
|
||||
Choose one delegation mode at session start:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/agent"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/auth"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/brain"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/config"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/coord"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/db"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/design-tokens"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/forge"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/log"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/macp"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.4",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/memory"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -17,7 +17,6 @@ import { runWizard } from '../../src/wizard.js';
|
||||
describe('Full Wizard (headless)', () => {
|
||||
let tmpDir: string;
|
||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
||||
@@ -33,16 +32,12 @@ describe('Full Wizard (headless)', () => {
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('quick start produces valid SOUL.md', async () => {
|
||||
// The headless path reads agent name from MOSAIC_AGENT_NAME env var
|
||||
// (via agentIntentStage) rather than prompting interactively.
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
@@ -54,7 +49,6 @@ describe('Full Wizard (headless)', () => {
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
const soulPath = join(tmpDir, 'SOUL.md');
|
||||
@@ -67,10 +61,9 @@ describe('Full Wizard (headless)', () => {
|
||||
});
|
||||
|
||||
it('quick start produces valid USER.md', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'He/Him',
|
||||
@@ -82,7 +75,6 @@ describe('Full Wizard (headless)', () => {
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
const userPath = join(tmpDir, 'USER.md');
|
||||
@@ -105,7 +97,6 @@ describe('Full Wizard (headless)', () => {
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
cliOverrides: {
|
||||
soul: {
|
||||
agentName: 'FromCLI',
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Unified wizard integration test — exercises the `skipGateway: false` code
|
||||
* path so that wiring between `runWizard` and the two gateway stages is
|
||||
* covered. The gateway stages themselves are mocked (they require a real
|
||||
* daemon + network) but the dynamic imports and option plumbing are real.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, cpSync, existsSync } from 'node:fs';
|
||||
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';
|
||||
|
||||
const gatewayConfigMock = vi.fn();
|
||||
const gatewayBootstrapMock = vi.fn();
|
||||
|
||||
vi.mock('../../src/stages/gateway-config.js', () => ({
|
||||
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
||||
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
||||
}));
|
||||
|
||||
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
||||
import { runWizard } from '../../src/wizard.js';
|
||||
|
||||
describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
let tmpDir: string;
|
||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
const originalAssumeYes = process.env['MOSAIC_ASSUME_YES'];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-unified-wizard-'));
|
||||
const candidates = [join(repoRoot, 'framework', 'templates'), join(repoRoot, 'templates')];
|
||||
for (const templatesDir of candidates) {
|
||||
if (existsSync(templatesDir)) {
|
||||
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
gatewayConfigMock.mockReset();
|
||||
gatewayBootstrapMock.mockReset();
|
||||
// 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 });
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
if (originalAssumeYes === undefined) {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
} else {
|
||||
process.env['MOSAIC_ASSUME_YES'] = originalAssumeYes;
|
||||
}
|
||||
});
|
||||
|
||||
it('invokes the gateway config + bootstrap stages by default', async () => {
|
||||
gatewayConfigMock.mockResolvedValue({ ready: true, host: 'localhost', port: 14242 });
|
||||
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
||||
|
||||
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),
|
||||
gatewayHost: 'localhost',
|
||||
gatewayPort: 14242,
|
||||
skipGatewayNpmInstall: true,
|
||||
});
|
||||
|
||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayBootstrapMock).toHaveBeenCalledTimes(1);
|
||||
const configCall = gatewayConfigMock.mock.calls[0];
|
||||
expect(configCall[2]).toMatchObject({
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
const bootstrapCall = gatewayBootstrapMock.mock.calls[0];
|
||||
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||
});
|
||||
|
||||
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
||||
gatewayConfigMock.mockResolvedValue({ 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,
|
||||
});
|
||||
|
||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects skipGateway: true', async () => {
|
||||
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),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -151,68 +151,11 @@ When delegating work to subagents, you MUST select the cheapest model capable of
|
||||
|
||||
**Runtime-specific syntax**: See the runtime reference for how to specify model tier when spawning subagents (e.g., Claude Code Task tool `model` parameter).
|
||||
|
||||
## Superpowers Enforcement (Hard Rule)
|
||||
|
||||
Mosaic provides capabilities beyond basic code editing: **skills**, **hooks**, **MCP tools**, and **plugins**. These are not optional extras — they are force multipliers that agents MUST actively use when applicable. Under-utilization of superpowers is a framework violation.
|
||||
|
||||
### Skills
|
||||
|
||||
Skills are domain-specific instruction sets in `~/.config/mosaic/skills/` that encode best practices, patterns, and guardrails. They are loaded into agents via the runtime's skill mechanism (e.g., Claude Code slash commands, Pi `--skill` flag).
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. Before starting implementation, scan available skills (`ls ~/.config/mosaic/skills/`) and load any that match the task domain.
|
||||
2. When a skill exists for the technology being used (e.g., `nestjs-best-practices` for NestJS work), you MUST load it.
|
||||
3. When spawning workers, include skill loading in the kickstart prompt.
|
||||
4. If you complete a task without loading a relevant available skill, that is a quality gap.
|
||||
|
||||
### Hooks
|
||||
|
||||
Hooks provide automated quality gates (lint, format, typecheck) that fire on file edits. They are configured in the runtime settings and run automatically.
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. Do NOT bypass or suppress hook output. If a hook reports errors, fix them before proceeding.
|
||||
2. Hook failures are immediate feedback — treat them like failing tests.
|
||||
3. If a hook is consistently failing on valid code, report it as a framework issue rather than working around it.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
MCP servers extend agent capabilities with external integrations (sequential-thinking, web search, memory, browser automation, etc.). Available MCP tools are listed at session start.
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. **sequential-thinking** is REQUIRED for planning, architecture, and multi-step reasoning. Use it — do not skip structured thinking for complex decisions.
|
||||
2. **OpenBrain** (`capture`, `search`, `recent`) is the cross-agent memory layer. Capture discoveries and search for prior context at session start.
|
||||
3. When a task involves web research, browser testing, or external data, use the available MCP tools (web-search, chrome-devtools, web-reader) rather than asking the user to look things up.
|
||||
4. Check available MCP tools at session start and use them proactively throughout the session.
|
||||
|
||||
### Plugins (Runtime-Specific)
|
||||
|
||||
Runtime plugins (e.g., Claude Code's `feature-dev`, `pr-review-toolkit`, `code-review`) provide specialized agent capabilities like code review, architecture analysis, and test coverage analysis.
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. After completing a significant code change, use code review plugins proactively — do not wait for the user to ask.
|
||||
2. Before creating a PR, use PR review plugins to catch issues early.
|
||||
3. When designing architecture, use planning/architecture plugins for structured analysis.
|
||||
|
||||
### Self-Evolution
|
||||
|
||||
The Mosaic framework should improve over time based on usage patterns:
|
||||
|
||||
1. When you discover a recurring pattern that should be codified, capture it to OpenBrain with `type: "framework-improvement"`.
|
||||
2. When a hook, skill, or tool is missing for a common task, capture the gap to OpenBrain with `type: "tooling-gap"`.
|
||||
3. When a framework rule causes friction without adding value, capture the observation to OpenBrain with `type: "framework-friction"`.
|
||||
|
||||
These captures feed the framework's continuous improvement cycle.
|
||||
|
||||
## Skills Policy
|
||||
|
||||
- Load skills that match the active task domain before starting implementation.
|
||||
- Use only the minimum required skills for the active task.
|
||||
- Do not load unrelated skills.
|
||||
- Follow skill trigger rules from the active runtime instruction layer.
|
||||
- Actively check `~/.config/mosaic/skills/` for applicable skills rather than passively waiting for them to be mentioned.
|
||||
|
||||
## Session Closure Requirement
|
||||
|
||||
|
||||
@@ -4,20 +4,14 @@ Universal agent standards layer for Claude Code, Codex, OpenCode, and Pi.
|
||||
|
||||
One config, every runtime, same standards.
|
||||
|
||||
> **This is the framework component of [mosaic-stack](https://git.mosaicstack.dev/mosaicstack/stack).** No personal data, credentials, user-specific preferences, or machine-specific paths should be committed. All personalization happens at install time via `mosaic init` or by editing files in `~/.config/mosaic/` after installation.
|
||||
> **This is the framework component of [mosaic-stack](https://git.mosaicstack.dev/mosaic/mosaic-stack).** No personal data, credentials, user-specific preferences, or machine-specific paths should be committed. All personalization happens at install time via `mosaic init` or by editing files in `~/.config/mosaic/` after installation.
|
||||
|
||||
## Quick Install
|
||||
|
||||
### Mac / Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
@@ -29,8 +23,8 @@ bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/
|
||||
### From Source (any platform)
|
||||
|
||||
```bash
|
||||
git clone git@git.mosaicstack.dev:mosaicstack/stack.git ~/src/stack
|
||||
cd ~/src/stack && bash tools/install.sh
|
||||
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git ~/src/mosaic-stack
|
||||
cd ~/src/mosaic-stack && bash tools/install.sh
|
||||
```
|
||||
|
||||
The installer:
|
||||
@@ -151,19 +145,13 @@ mosaic upgrade check # Check upgrade status (no changes)
|
||||
Run the installer again — it handles upgrades automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
Or from a local checkout:
|
||||
|
||||
```bash
|
||||
cd ~/src/stack && git pull && bash tools/install.sh
|
||||
cd ~/src/mosaic-stack && git pull && bash tools/install.sh
|
||||
```
|
||||
|
||||
The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by default.
|
||||
|
||||
@@ -19,9 +19,8 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
|
||||
# Files/dirs preserved across upgrades (never overwritten).
|
||||
# User-created content in these paths survives rsync --delete.
|
||||
PRESERVE_PATHS=("AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
|
||||
# Files preserved across upgrades (never overwritten)
|
||||
PRESERVE_PATHS=("SOUL.md" "USER.md" "TOOLS.md" "memory" "sources")
|
||||
|
||||
# Current framework schema version — bump this when the layout changes.
|
||||
# The migration system uses this to run upgrade steps.
|
||||
@@ -218,27 +217,8 @@ fi
|
||||
|
||||
sync_framework
|
||||
|
||||
# Ensure persistent directories exist
|
||||
# Ensure memory directory exists
|
||||
mkdir -p "$TARGET_DIR/memory"
|
||||
mkdir -p "$TARGET_DIR/credentials"
|
||||
|
||||
# Seed defaults — copy framework contract files from defaults/ to framework
|
||||
# root if not already present. These ship with sensible defaults but must
|
||||
# never be overwritten once the user has customized them.
|
||||
#
|
||||
# This list must match the framework-contract whitelist in
|
||||
# packages/mosaic/src/config/file-adapter.ts (FileConfigAdapter.syncFramework).
|
||||
# SOUL.md and USER.md are intentionally NOT seeded here — they are generated
|
||||
# by `mosaic init` from templates with user-supplied values.
|
||||
DEFAULTS_DIR="$TARGET_DIR/defaults"
|
||||
if [[ -d "$DEFAULTS_DIR" ]]; then
|
||||
for default_file in AGENTS.md STANDARDS.md TOOLS.md; do
|
||||
if [[ -f "$DEFAULTS_DIR/$default_file" ]] && [[ ! -f "$TARGET_DIR/$default_file" ]]; then
|
||||
cp "$DEFAULTS_DIR/$default_file" "$TARGET_DIR/$default_file"
|
||||
ok "Seeded $default_file from defaults"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Ensure tool scripts are executable
|
||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||
|
||||
@@ -102,30 +102,3 @@ claude mcp add --scope user <name> -- npx -y <package>
|
||||
`--scope local` = default, local-only (not committed).
|
||||
|
||||
Do NOT add `mcpServers` to `~/.claude/settings.json` — that key is ignored for MCP loading.
|
||||
|
||||
## Required Claude Code Settings (Enforced by Launcher)
|
||||
|
||||
The `mosaic claude` launcher validates that `~/.claude/settings.json` contains the required Mosaic configuration. Missing or outdated settings trigger a warning at launch.
|
||||
|
||||
**Required hooks:**
|
||||
|
||||
| Event | Matcher | Script | Purpose |
|
||||
| ----------- | ------------------------ | ------------------------- | ---------------------------------------------- |
|
||||
| PreToolUse | `Write\|Edit\|MultiEdit` | `prevent-memory-write.sh` | Block writes to `~/.claude/projects/*/memory/` |
|
||||
| PostToolUse | `Edit\|MultiEdit\|Write` | `qa-hook-stdin.sh` | QA report generation after code edits |
|
||||
| PostToolUse | `Edit\|MultiEdit\|Write` | `typecheck-hook.sh` | Inline TypeScript type checking |
|
||||
|
||||
**Required plugins:**
|
||||
|
||||
| Plugin | Purpose |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `feature-dev` | Subagent architecture: code-reviewer, code-architect, code-explorer |
|
||||
| `pr-review-toolkit` | PR review: code-simplifier, comment-analyzer, test-analyzer, silent-failure-hunter, type-design-analyzer |
|
||||
| `code-review` | Standalone code review capabilities |
|
||||
|
||||
**Required settings:**
|
||||
|
||||
- `enableAllMcpTools: true` — Allow all configured MCP tools without per-tool approval
|
||||
- `model: "opus"` — Default to opus for orchestrator-level sessions (workers use tiered models via Task tool)
|
||||
|
||||
If `mosaic claude` detects missing hooks or plugins, it will print a warning with the exact settings to add. The session will still launch — enforcement is advisory, not blocking — but agents operating without these settings are running degraded.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "Universal Atomic Code Implementer Hooks",
|
||||
"description": "Comprehensive hooks configuration for quality enforcement and automatic remediation",
|
||||
"mosaic-managed": true,
|
||||
"version": "1.0.0",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
|
||||
@@ -23,16 +23,6 @@
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|MultiEdit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/typecheck-hook.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,32 +5,32 @@ Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
||||
|
||||
## Mosaic Git Wrappers (Use First)
|
||||
|
||||
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||
Mosaic wrappers at `~/.config/mosaic/rails/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||
|
||||
```bash
|
||||
# Issues
|
||||
~/.config/mosaic/tools/git/issue-create.sh
|
||||
~/.config/mosaic/tools/git/issue-close.sh
|
||||
~/.config/mosaic/rails/git/issue-create.sh
|
||||
~/.config/mosaic/rails/git/issue-close.sh
|
||||
|
||||
# PRs
|
||||
~/.config/mosaic/tools/git/pr-create.sh
|
||||
~/.config/mosaic/tools/git/pr-merge.sh
|
||||
~/.config/mosaic/rails/git/pr-create.sh
|
||||
~/.config/mosaic/rails/git/pr-merge.sh
|
||||
|
||||
# Milestones
|
||||
~/.config/mosaic/tools/git/milestone-create.sh
|
||||
~/.config/mosaic/rails/git/milestone-create.sh
|
||||
|
||||
# CI queue guard (required before push/merge)
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||
~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge
|
||||
```
|
||||
|
||||
## Code Review (Codex)
|
||||
|
||||
```bash
|
||||
# Code quality review
|
||||
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
|
||||
|
||||
# Security review
|
||||
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
|
||||
```
|
||||
|
||||
## Git Providers
|
||||
|
||||
@@ -70,45 +70,11 @@ for p in "${legacy_paths[@]}"; do
|
||||
done
|
||||
|
||||
# Claude-specific runtime files (settings, hooks — NOT CLAUDE.md which is now a thin pointer)
|
||||
# When MOSAIC_SKIP_CLAUDE_HOOKS=1 is set (user declined hooks in the wizard
|
||||
# preview stage), skip hooks-config.json but still copy the other runtime
|
||||
# files so Claude still gets CLAUDE.md/settings.json/context7 guidance.
|
||||
for runtime_file in \
|
||||
CLAUDE.md \
|
||||
settings.json \
|
||||
hooks-config.json \
|
||||
context7-integration.md; do
|
||||
if [[ "$runtime_file" == "hooks-config.json" ]] && [[ "${MOSAIC_SKIP_CLAUDE_HOOKS:-0}" == "1" ]]; then
|
||||
echo "[mosaic-link] Skipping hooks-config.json (user declined in wizard)"
|
||||
# An existing ~/.claude/hooks-config.json that we previously installed
|
||||
# is identified by one of:
|
||||
# 1. It's a symlink (legacy symlink-mode install)
|
||||
# 2. It contains the `mosaic-managed` marker string we embed in the
|
||||
# template (survives template updates unlike byte-equality)
|
||||
# 3. It is byte-identical to the current Mosaic template (fallback
|
||||
# for templates that pre-date the marker)
|
||||
# Anything else is user-owned and we must leave it alone.
|
||||
existing_hooks="$HOME/.claude/hooks-config.json"
|
||||
mosaic_hooks_src="$MOSAIC_HOME/runtime/claude/hooks-config.json"
|
||||
if [[ -L "$existing_hooks" ]]; then
|
||||
rm -f "$existing_hooks"
|
||||
echo "[mosaic-link] Removed previously-linked Mosaic hooks-config.json (was symlink)"
|
||||
elif [[ -f "$existing_hooks" ]]; then
|
||||
is_mosaic_managed=0
|
||||
if grep -q 'mosaic-managed' "$existing_hooks" 2>/dev/null; then
|
||||
is_mosaic_managed=1
|
||||
elif [[ -f "$mosaic_hooks_src" ]] && cmp -s "$existing_hooks" "$mosaic_hooks_src"; then
|
||||
is_mosaic_managed=1
|
||||
fi
|
||||
if [[ "$is_mosaic_managed" == "1" ]]; then
|
||||
mv "$existing_hooks" "${existing_hooks}.mosaic-bak-${backup_stamp}"
|
||||
echo "[mosaic-link] Removed previously-linked Mosaic hooks-config.json (backup at ${existing_hooks}.mosaic-bak-${backup_stamp})"
|
||||
else
|
||||
echo "[mosaic-link] Leaving existing non-Mosaic hooks-config.json in place"
|
||||
fi
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
src="$MOSAIC_HOME/runtime/claude/$runtime_file"
|
||||
[[ -f "$src" ]] || continue
|
||||
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
||||
|
||||
@@ -7,11 +7,6 @@ SKILLS_REPO_DIR="${MOSAIC_SKILLS_REPO_DIR:-$MOSAIC_HOME/sources/agent-skills}"
|
||||
MOSAIC_SKILLS_DIR="$MOSAIC_HOME/skills"
|
||||
MOSAIC_LOCAL_SKILLS_DIR="$MOSAIC_HOME/skills-local"
|
||||
|
||||
# Colon-separated list of skill names to install. When set, only these skills
|
||||
# are linked into runtime skill directories. Empty/unset = link all skills
|
||||
# (the legacy "mosaic sync" full-catalog behavior).
|
||||
MOSAIC_INSTALL_SKILLS="${MOSAIC_INSTALL_SKILLS:-}"
|
||||
|
||||
fetch=1
|
||||
link_only=0
|
||||
|
||||
@@ -30,7 +25,6 @@ Env:
|
||||
MOSAIC_HOME Default: ~/.config/mosaic
|
||||
MOSAIC_SKILLS_REPO_URL Default: https://git.mosaicstack.dev/mosaic/agent-skills.git
|
||||
MOSAIC_SKILLS_REPO_DIR Default: ~/.config/mosaic/sources/agent-skills
|
||||
MOSAIC_INSTALL_SKILLS Colon-separated list of skills to link (default: all)
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -162,27 +156,6 @@ link_targets=(
|
||||
|
||||
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
|
||||
|
||||
# Build an associative array from the colon-separated whitelist for O(1) lookup.
|
||||
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
||||
declare -A _skill_whitelist=()
|
||||
_whitelist_active=0
|
||||
if [[ -n "$MOSAIC_INSTALL_SKILLS" ]]; then
|
||||
_whitelist_active=1
|
||||
IFS=':' read -ra _wl_items <<< "$MOSAIC_INSTALL_SKILLS"
|
||||
for _item in "${_wl_items[@]}"; do
|
||||
[[ -n "$_item" ]] && _skill_whitelist["$_item"]=1
|
||||
done
|
||||
fi
|
||||
|
||||
is_skill_selected() {
|
||||
local name="$1"
|
||||
if [[ $_whitelist_active -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ -n "${_skill_whitelist[$name]:-}" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
link_skill_into_target() {
|
||||
local skill_path="$1"
|
||||
local target_dir="$2"
|
||||
@@ -195,11 +168,6 @@ link_skill_into_target() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Respect the install whitelist (set during first-run wizard).
|
||||
if ! is_skill_selected "$name"; then
|
||||
return
|
||||
fi
|
||||
|
||||
link_path="$target_dir/$name"
|
||||
|
||||
if [[ -L "$link_path" ]]; then
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Lightweight PostToolUse typecheck hook for TypeScript files.
|
||||
# Runs tsc --noEmit on the nearest tsconfig after TS/TSX edits.
|
||||
# Returns non-zero with diagnostic output so the agent sees type errors immediately.
|
||||
# Location: ~/.config/mosaic/tools/qa/typecheck-hook.sh
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
# Read JSON from stdin (Claude Code PostToolUse payload)
|
||||
JSON_INPUT=$(cat)
|
||||
|
||||
# Extract file path
|
||||
if command -v jq &>/dev/null; then
|
||||
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // .tool_response.filePath // .file_path // empty' 2>/dev/null || echo "")
|
||||
else
|
||||
FILE_PATH=$(echo "$JSON_INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -1)
|
||||
fi
|
||||
|
||||
# Only check TypeScript files
|
||||
if ! [[ "$FILE_PATH" =~ \.(ts|tsx)$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Must be a real file
|
||||
if [ ! -f "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find nearest tsconfig.json by walking up from the file
|
||||
DIR=$(dirname "$FILE_PATH")
|
||||
TSCONFIG=""
|
||||
while [ "$DIR" != "/" ] && [ "$DIR" != "." ]; do
|
||||
if [ -f "$DIR/tsconfig.json" ]; then
|
||||
TSCONFIG="$DIR/tsconfig.json"
|
||||
break
|
||||
fi
|
||||
DIR=$(dirname "$DIR")
|
||||
done
|
||||
|
||||
if [ -z "$TSCONFIG" ]; then
|
||||
# No tsconfig found — skip silently
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run tsc --noEmit from the tsconfig directory
|
||||
# Use --pretty for readable output, limit to 10 errors to keep output short
|
||||
TSCONFIG_DIR=$(dirname "$TSCONFIG")
|
||||
cd "$TSCONFIG_DIR"
|
||||
|
||||
# Run typecheck — capture output and exit code
|
||||
OUTPUT=$(npx tsc --noEmit --pretty --maxNodeModuleJsDepth 0 2>&1) || STATUS=$?
|
||||
|
||||
if [ "${STATUS:-0}" -ne 0 ]; then
|
||||
# Filter output to only show errors related to the edited file (if possible)
|
||||
BASENAME=$(basename "$FILE_PATH")
|
||||
RELEVANT=$(echo "$OUTPUT" | grep -A2 "$BASENAME" 2>/dev/null || echo "$OUTPUT" | head -20)
|
||||
|
||||
echo "TypeScript type errors detected after editing $FILE_PATH:"
|
||||
echo "$RELEVANT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.30",
|
||||
"version": "0.0.24",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/mosaic"
|
||||
},
|
||||
"description": "Mosaic agent framework — installation wizard and meta package",
|
||||
|
||||
@@ -135,11 +135,15 @@ program
|
||||
|
||||
// No valid session — prompt for credentials
|
||||
if (!session) {
|
||||
const { promptLine, promptSecret } = await import('./commands/gateway/login.js');
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> =>
|
||||
new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
console.log(`Sign in to ${opts.gateway}`);
|
||||
const email = await promptLine('Email: ');
|
||||
const password = await promptSecret('Password: ');
|
||||
const email = await ask('Email: ');
|
||||
const password = await ask('Password: ');
|
||||
rl.close();
|
||||
|
||||
try {
|
||||
const auth = await signIn(opts.gateway, email, password);
|
||||
|
||||
@@ -1,21 +1,60 @@
|
||||
/**
|
||||
* Thin wrapper over the unified first-run stages.
|
||||
*
|
||||
* `mosaic gateway install` is kept as a standalone entry point for users who
|
||||
* already went through `mosaic wizard` and only need to (re)configure the
|
||||
* gateway daemon. It builds a minimal `WizardState`, invokes
|
||||
* `gatewayConfigStage` and `gatewayBootstrapStage` directly, and returns.
|
||||
*
|
||||
* The heavy lifting — prompts, env writes, daemon lifecycle, bootstrap POST —
|
||||
* lives in `packages/mosaic/src/stages/gateway-config.ts` and
|
||||
* `packages/mosaic/src/stages/gateway-bootstrap.ts` so that the same code
|
||||
* path runs under both the unified wizard and this standalone command.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ClackPrompter } from '../../prompter/clack-prompter.js';
|
||||
import type { WizardState } from '../../types.js';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { GatewayMeta } from './daemon.js';
|
||||
import { promptMaskedConfirmed } from '../../prompter/masked-prompt.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');
|
||||
|
||||
// ─── Wizard session state (transient, CU-07-02) ──────────────────────────────
|
||||
|
||||
const INSTALL_STATE_FILE = join(
|
||||
process.env['XDG_RUNTIME_DIR'] ?? process.env['TMPDIR'] ?? tmpdir(),
|
||||
'mosaic-install-state.json',
|
||||
);
|
||||
|
||||
interface InstallSessionState {
|
||||
wizardCompletedAt: string;
|
||||
mosaicHome: string;
|
||||
}
|
||||
|
||||
function readInstallState(): InstallSessionState | null {
|
||||
if (!existsSync(INSTALL_STATE_FILE)) return null;
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(INSTALL_STATE_FILE, 'utf-8')) as InstallSessionState;
|
||||
// Only trust state that is < 10 minutes old
|
||||
const age = Date.now() - new Date(raw.wizardCompletedAt).getTime();
|
||||
if (age > 10 * 60 * 1000) return null;
|
||||
return raw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearInstallState(): void {
|
||||
try {
|
||||
unlinkSync(INSTALL_STATE_FILE);
|
||||
} catch {
|
||||
// Ignore — file may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
interface InstallOpts {
|
||||
host: string;
|
||||
@@ -23,85 +62,563 @@ interface InstallOpts {
|
||||
skipInstall?: boolean;
|
||||
}
|
||||
|
||||
function isHeadlessRun(): boolean {
|
||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the process should skip interactive prompts.
|
||||
* Headless mode is activated by `MOSAIC_ASSUME_YES=1` or when stdin is not a
|
||||
* TTY (piped/redirected — typical in CI and Docker).
|
||||
*/
|
||||
function isHeadless(): boolean {
|
||||
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
}
|
||||
|
||||
export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||
const mosaicHome = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
|
||||
const prompter = new ClackPrompter();
|
||||
|
||||
const state: WizardState = {
|
||||
mosaicHome,
|
||||
sourceDir: mosaicHome,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
|
||||
const { gatewayConfigStage } = await import('../../stages/gateway-config.js');
|
||||
const { gatewayBootstrapStage } = await import('../../stages/gateway-bootstrap.js');
|
||||
|
||||
// Preserve the legacy "explicit --port wins over saved config" semantic:
|
||||
// commander defaults the port to 14242, so any other value is treated as
|
||||
// an explicit user override that the config stage should honor even on
|
||||
// resume.
|
||||
const portOverride = opts.port !== 14242 ? opts.port : undefined;
|
||||
|
||||
const headless = isHeadlessRun();
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: opts.host,
|
||||
defaultPort: opts.port,
|
||||
portOverride,
|
||||
skipInstall: opts.skipInstall,
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || configResult.port === undefined) {
|
||||
// In headless/scripted installs, a non-ready config stage is a fatal
|
||||
// error — we must not report "complete" when the gateway was never
|
||||
// configured. Exit non-zero so CI notices.
|
||||
if (headless) {
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting.');
|
||||
process.exit(1);
|
||||
await doInstall(rl, opts);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
|
||||
// CU-07-02: Check for a fresh wizard session state and apply it.
|
||||
const sessionState = readInstallState();
|
||||
if (sessionState) {
|
||||
const defaultHome = join(homedir(), '.config', 'mosaic');
|
||||
const customHome = sessionState.mosaicHome !== defaultHome ? sessionState.mosaicHome : null;
|
||||
|
||||
if (customHome && !process.env['MOSAIC_GATEWAY_HOME']) {
|
||||
// The wizard ran with a custom MOSAIC_HOME that differs from the default.
|
||||
// GATEWAY_HOME is derived from MOSAIC_GATEWAY_HOME (or defaults to
|
||||
// ~/.config/mosaic/gateway). Set the env var so the rest of this install
|
||||
// inherits the correct location. This must be set before GATEWAY_HOME is
|
||||
// evaluated by any imported helper — helpers that re-evaluate the path at
|
||||
// call time will pick it up automatically.
|
||||
process.env['MOSAIC_GATEWAY_HOME'] = join(customHome, 'gateway');
|
||||
console.log(
|
||||
`Resuming from wizard session — gateway home set to ${process.env['MOSAIC_GATEWAY_HOME']}\n`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Resuming from wizard session — using ${sessionState.mosaicHome} from earlier.\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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})`,
|
||||
);
|
||||
console.error('\nRun `mosaic gateway uninstall` to clean up, then re-run install.');
|
||||
return;
|
||||
}
|
||||
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
// Fully set up already — offer to re-run the config wizard and restart.
|
||||
// The wizard allows changing storage tier / DB URLs, so this can move
|
||||
// the install onto a different data store. We do NOT wipe persisted
|
||||
// local data here — for a true scratch wipe run `mosaic gateway
|
||||
// uninstall` first.
|
||||
let explicitReinstall = false;
|
||||
if (existing && hasConfig && daemonRunning && hasAdminToken) {
|
||||
console.log(`Gateway is already installed and running (v${existing.version}).`);
|
||||
console.log(` Endpoint: http://${existing.host}:${existing.port.toString()}`);
|
||||
console.log(` Status: mosaic gateway status`);
|
||||
console.log();
|
||||
console.log('Re-running the config wizard will:');
|
||||
console.log(' - regenerate .env and mosaic.config.json');
|
||||
console.log(' - restart the daemon');
|
||||
console.log(' - preserve BETTER_AUTH_SECRET (sessions stay valid)');
|
||||
console.log(' - clear the stored admin token (you will re-bootstrap an admin user)');
|
||||
console.log(' - allow changing storage tier / DB URLs (may point at a different data store)');
|
||||
console.log('To wipe persisted data, run `mosaic gateway uninstall` first.');
|
||||
const answer = await prompt(rl, 'Re-run config wizard? [y/N] ');
|
||||
if (answer.trim().toLowerCase() !== 'y') {
|
||||
console.log('Nothing to do.');
|
||||
return;
|
||||
}
|
||||
// Fall through. The daemon stop below triggers because hasConfig=false
|
||||
// forces the wizard to re-run.
|
||||
hasConfig = false;
|
||||
explicitReinstall = true;
|
||||
} else if (existing && (hasConfig || daemonRunning)) {
|
||||
// Partial install detected — resume instead of re-prompting the user.
|
||||
console.log('Detected a partial gateway installation — resuming setup.\n');
|
||||
}
|
||||
|
||||
if (!bootstrapResult.completed && headless) {
|
||||
prompter.warn('Admin bootstrap failed in headless mode — aborting.');
|
||||
// If we are going to (re)write config, the running daemon would end up
|
||||
// serving the old config while health checks and meta point at the new
|
||||
// one. Always stop the daemon before writing config.
|
||||
if (!hasConfig && daemonRunning) {
|
||||
console.log('Stopping gateway daemon before writing new config...');
|
||||
try {
|
||||
await stopDaemon();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/not running/i.test(msg)) {
|
||||
// Raced with daemon exit — fine, proceed.
|
||||
} else {
|
||||
console.error(`Failed to stop running daemon: ${msg}`);
|
||||
console.error('Refusing to rewrite config while an unknown-state daemon is running.');
|
||||
console.error('Stop it manually (mosaic gateway stop) and re-run install.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Re-check — stop may have succeeded but we want to be sure before
|
||||
// writing new config files and starting a fresh process.
|
||||
if (getDaemonPid() !== null) {
|
||||
console.error('Gateway daemon is still running after stop attempt. Aborting.');
|
||||
return;
|
||||
}
|
||||
daemonRunning = false;
|
||||
}
|
||||
|
||||
// Step 1: Install npm package. Always run on first install and on any
|
||||
// resume where the daemon is NOT already running — a prior failure may
|
||||
// have been caused by a broken package version, and the retry should
|
||||
// pick up the latest release. Skip only when resuming while the daemon
|
||||
// is already alive (package must be working to have started).
|
||||
if (!opts.skipInstall && !daemonRunning) {
|
||||
installGatewayPackage();
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
|
||||
// Step 2: Collect configuration (skip if both files already exist).
|
||||
// On resume, treat the .env file as authoritative for port — but let a
|
||||
// user-supplied non-default `--port` override it so they can recover
|
||||
// from a conflicting saved port the same way `--host` lets them
|
||||
// recover from a bad saved host. `opts.port === 14242` is commander's
|
||||
// default (not explicit user input), so we prefer .env in that case.
|
||||
let port: number;
|
||||
const regeneratedConfig = !hasConfig;
|
||||
if (hasConfig) {
|
||||
const envPort = readPortFromEnv();
|
||||
port = opts.port !== 14242 ? opts.port : (envPort ?? existing?.port ?? opts.port);
|
||||
console.log(`Using existing config at ${ENV_FILE} (port ${port.toString()})`);
|
||||
} else {
|
||||
port = await runConfigWizard(rl, opts);
|
||||
}
|
||||
|
||||
// Step 3: Write meta.json. Prefer host from existing meta when resuming.
|
||||
let entryPoint: string;
|
||||
try {
|
||||
entryPoint = resolveGatewayEntry();
|
||||
} catch {
|
||||
console.error('Error: Gateway package not found after install.');
|
||||
console.error('Check that @mosaicstack/gateway installed correctly.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = getInstalledGatewayVersion() ?? 'unknown';
|
||||
// Preserve the admin token only on a pure resume (no config regeneration).
|
||||
// Any time we regenerated config, the wizard may have pointed at a
|
||||
// different storage tier / DB URL, so the old token is unverifiable —
|
||||
// drop it and require re-bootstrap.
|
||||
const preserveToken = !regeneratedConfig && Boolean(existing?.adminToken);
|
||||
const meta: GatewayMeta = {
|
||||
version,
|
||||
installedAt: explicitReinstall
|
||||
? new Date().toISOString()
|
||||
: (existing?.installedAt ?? new Date().toISOString()),
|
||||
entryPoint,
|
||||
host,
|
||||
port,
|
||||
...(preserveToken && existing?.adminToken ? { adminToken: existing.adminToken } : {}),
|
||||
};
|
||||
writeMeta(meta);
|
||||
|
||||
// Step 4: Start the daemon (idempotent — skip if already running).
|
||||
if (!daemonRunning) {
|
||||
console.log('\nStarting gateway daemon...');
|
||||
try {
|
||||
const pid = startDaemon();
|
||||
console.log(`Gateway started (PID ${pid.toString()})`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
||||
printLogTail();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log('\nGateway daemon is already running.');
|
||||
}
|
||||
|
||||
// Step 5: Wait for health
|
||||
console.log('Waiting for gateway to become healthy...');
|
||||
const healthy = await waitForHealth(host, port, 30_000);
|
||||
if (!healthy) {
|
||||
console.error('\nGateway did not become healthy within 30 seconds.');
|
||||
printLogTail();
|
||||
console.error('\nFix the underlying error above, then re-run `mosaic gateway install`.');
|
||||
return;
|
||||
}
|
||||
console.log('Gateway is healthy.\n');
|
||||
|
||||
// Step 6: Bootstrap — first admin user.
|
||||
await bootstrapFirstUser(rl, host, port, meta);
|
||||
|
||||
console.log('\n─── Installation Complete ───');
|
||||
console.log(` Endpoint: http://${host}:${port.toString()}`);
|
||||
console.log(` Config: ${GATEWAY_HOME}`);
|
||||
console.log(` Logs: mosaic gateway logs`);
|
||||
console.log(` Status: mosaic gateway status`);
|
||||
|
||||
// Step 7: Post-install verification (CU-07-03)
|
||||
const { runPostInstallVerification } = await import('./verify.js');
|
||||
await runPostInstallVerification(host, port);
|
||||
|
||||
// CU-07-02: Clear transient wizard session state on successful install.
|
||||
clearInstallState();
|
||||
}
|
||||
|
||||
async function runConfigWizard(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
opts: InstallOpts,
|
||||
): Promise<number> {
|
||||
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');
|
||||
}
|
||||
|
||||
let tier: 'local' | 'team';
|
||||
let port: number;
|
||||
let databaseUrl: string | undefined;
|
||||
let valkeyUrl: string | undefined;
|
||||
let anthropicKey: string;
|
||||
let corsOrigin: string;
|
||||
|
||||
if (isHeadless()) {
|
||||
// ── Headless / non-interactive path ────────────────────────────────────
|
||||
console.log('Headless mode detected — reading configuration from environment variables.\n');
|
||||
|
||||
const storageTierEnv = process.env['MOSAIC_STORAGE_TIER'] ?? 'local';
|
||||
tier = storageTierEnv === 'team' ? 'team' : 'local';
|
||||
|
||||
const portEnv = process.env['MOSAIC_GATEWAY_PORT'];
|
||||
port = portEnv ? parseInt(portEnv, 10) : opts.port;
|
||||
|
||||
databaseUrl = process.env['MOSAIC_DATABASE_URL'];
|
||||
valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
|
||||
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
||||
corsOrigin = process.env['MOSAIC_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||
|
||||
// Validate required vars for team tier
|
||||
if (tier === 'team') {
|
||||
const missing: string[] = [];
|
||||
if (!databaseUrl) missing.push('MOSAIC_DATABASE_URL');
|
||||
if (!valkeyUrl) missing.push('MOSAIC_VALKEY_URL');
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`Error: headless install with tier=team requires the following env vars:\n` +
|
||||
missing.map((v) => ` ${v}`).join('\n'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Storage tier: ${tier}`);
|
||||
console.log(` Gateway port: ${port.toString()}`);
|
||||
if (tier === 'team') {
|
||||
console.log(` DATABASE_URL: ${databaseUrl ?? ''}`);
|
||||
console.log(` VALKEY_URL: ${valkeyUrl ?? ''}`);
|
||||
}
|
||||
console.log(` CORS origin: ${corsOrigin}`);
|
||||
console.log();
|
||||
} else {
|
||||
// ── Interactive path ────────────────────────────────────────────────────
|
||||
console.log('Storage tier:');
|
||||
console.log(' 1. Local (embedded database, no dependencies)');
|
||||
console.log(' 2. Team (PostgreSQL + Valkey required)');
|
||||
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
|
||||
tier = tierAnswer === '2' ? 'team' : 'local';
|
||||
|
||||
port =
|
||||
opts.port !== 14242
|
||||
? opts.port
|
||||
: parseInt(
|
||||
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
|
||||
10,
|
||||
);
|
||||
|
||||
if (tier === 'team') {
|
||||
databaseUrl =
|
||||
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
|
||||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
valkeyUrl =
|
||||
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
|
||||
}
|
||||
|
||||
anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
|
||||
|
||||
corsOrigin =
|
||||
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
|
||||
}
|
||||
|
||||
const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
|
||||
|
||||
const envLines = [
|
||||
`GATEWAY_PORT=${port.toString()}`,
|
||||
`BETTER_AUTH_SECRET=${authSecret}`,
|
||||
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
|
||||
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
|
||||
`OTEL_SERVICE_NAME=mosaic-gateway`,
|
||||
];
|
||||
|
||||
if (tier === 'team' && databaseUrl && valkeyUrl) {
|
||||
envLines.push(`DATABASE_URL=${databaseUrl}`);
|
||||
envLines.push(`VALKEY_URL=${valkeyUrl}`);
|
||||
}
|
||||
|
||||
if (anthropicKey) {
|
||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||
}
|
||||
|
||||
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
|
||||
console.log(`\nConfig written to ${ENV_FILE}`);
|
||||
|
||||
const mosaicConfig =
|
||||
tier === 'local'
|
||||
? {
|
||||
tier: 'local',
|
||||
storage: { type: 'pglite', dataDir: join(GATEWAY_HOME, 'storage-pglite') },
|
||||
queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') },
|
||||
memory: { type: 'keyword' },
|
||||
}
|
||||
: {
|
||||
tier: 'team',
|
||||
storage: { type: 'postgres', url: databaseUrl },
|
||||
queue: { type: 'bullmq', url: valkeyUrl },
|
||||
memory: { type: 'pgvector' },
|
||||
};
|
||||
|
||||
writeFileSync(MOSAIC_CONFIG_FILE, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
|
||||
console.log(`Config written to ${MOSAIC_CONFIG_FILE}`);
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
function readEnvVarFromFile(key: string): string | null {
|
||||
if (!existsSync(ENV_FILE)) return null;
|
||||
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);
|
||||
}
|
||||
} 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})`);
|
||||
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);
|
||||
}
|
||||
|
||||
async function bootstrapFirstUser(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
host: string,
|
||||
port: number,
|
||||
meta: GatewayMeta,
|
||||
): Promise<void> {
|
||||
const baseUrl = `http://${host}:${port.toString()}`;
|
||||
|
||||
try {
|
||||
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
|
||||
if (!statusRes.ok) return;
|
||||
|
||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||
if (!status.needsSetup) {
|
||||
if (meta.adminToken) {
|
||||
console.log('Admin user already exists (token on file).');
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin user exists but no token — offer inline recovery when interactive.
|
||||
console.log('Admin user already exists but no admin token is on file.');
|
||||
|
||||
if (process.stdin.isTTY) {
|
||||
const answer = (await prompt(rl, 'Run token recovery now? [Y/n] ')).trim().toLowerCase();
|
||||
if (answer === '' || answer === 'y' || answer === 'yes') {
|
||||
console.log();
|
||||
try {
|
||||
const { ensureSession, mintAdminToken, persistToken } = await import('./token-ops.js');
|
||||
const cookie = await ensureSession(baseUrl);
|
||||
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
|
||||
const minted = await mintAdminToken(baseUrl, cookie, label);
|
||||
persistToken(baseUrl, minted);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Token recovery failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('No admin token on file. Run: mosaic gateway config recover-token');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.warn('Could not check bootstrap status — skipping first user setup.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('─── Admin User Setup ───\n');
|
||||
|
||||
let name: string;
|
||||
let email: string;
|
||||
let password: string;
|
||||
|
||||
if (isHeadless()) {
|
||||
// ── Headless path ──────────────────────────────────────────────────────
|
||||
const nameEnv = process.env['MOSAIC_ADMIN_NAME']?.trim() ?? '';
|
||||
const emailEnv = process.env['MOSAIC_ADMIN_EMAIL']?.trim() ?? '';
|
||||
const passwordEnv = process.env['MOSAIC_ADMIN_PASSWORD'] ?? '';
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!nameEnv) missing.push('MOSAIC_ADMIN_NAME');
|
||||
if (!emailEnv) missing.push('MOSAIC_ADMIN_EMAIL');
|
||||
if (!passwordEnv) missing.push('MOSAIC_ADMIN_PASSWORD');
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`Error: headless admin bootstrap requires the following env vars:\n` +
|
||||
missing.map((v) => ` ${v}`).join('\n'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
prompter.log('─── Installation Complete ───');
|
||||
prompter.log(` Endpoint: http://${configResult.host}:${configResult.port.toString()}`);
|
||||
prompter.log(` Logs: mosaic gateway logs`);
|
||||
prompter.log(` Status: mosaic gateway status`);
|
||||
if (passwordEnv.length < 8) {
|
||||
console.error('Error: MOSAIC_ADMIN_PASSWORD must be at least 8 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
name = nameEnv;
|
||||
email = emailEnv;
|
||||
password = passwordEnv;
|
||||
} else {
|
||||
// ── Interactive path ────────────────────────────────────────────────────
|
||||
name = (await prompt(rl, 'Admin name: ')).trim();
|
||||
if (!name) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
email = (await prompt(rl, 'Admin email: ')).trim();
|
||||
if (!email) {
|
||||
console.error('Email is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
password = await promptMaskedConfirmed(
|
||||
'Admin password (min 8 chars): ',
|
||||
'Confirm password: ',
|
||||
(v) => (v.length < 8 ? 'Password must be at least 8 characters' : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
// Post-install verification (CU-07-03) — non-fatal.
|
||||
try {
|
||||
const { runPostInstallVerification } = await import('./verify.js');
|
||||
await runPostInstallVerification(configResult.host, configResult.port);
|
||||
} catch {
|
||||
// Non-fatal — verification is a courtesy
|
||||
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
console.error(`Bootstrap failed (${res.status.toString()}): ${body}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
user: { id: string; email: string };
|
||||
token: { plaintext: string };
|
||||
};
|
||||
|
||||
// Persist the token so future CLI calls can authenticate automatically.
|
||||
meta.adminToken = result.token.plaintext;
|
||||
writeMeta(meta);
|
||||
|
||||
console.log(`\nAdmin user created: ${result.user.email}`);
|
||||
printAdminTokenBanner(result.token.plaintext);
|
||||
} catch (err) {
|
||||
// Stages normally return structured results for expected failures.
|
||||
// Anything that reaches here is an unexpected runtime error — render a
|
||||
// concise warning AND re-throw so the command exits non-zero. Silent
|
||||
// swallowing would let scripted installs report success on failure.
|
||||
prompter.warn(`Gateway install failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { registerRuntimeLaunchers, type RuntimeLaunchHandler } from './launch.js';
|
||||
|
||||
/**
|
||||
* Tests for the commander wiring between `mosaic <runtime>` / `mosaic yolo <runtime>`
|
||||
* subcommands and the internal `launchRuntime` dispatcher.
|
||||
*
|
||||
* Regression target: see mosaicstack/stack#454 — before the fix, `mosaic yolo claude`
|
||||
* passed the literal string "claude" as an excess positional argument to the
|
||||
* underlying CLI, which Claude Code then interpreted as the first user message.
|
||||
*
|
||||
* The bug existed because Commander.js includes declared positional arguments
|
||||
* (here `<runtime>`) in `cmd.args` alongside any true excess args. The action
|
||||
* handler must slice them off before forwarding.
|
||||
*/
|
||||
|
||||
function buildProgram(handler: RuntimeLaunchHandler): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride(); // prevent process.exit on parse errors
|
||||
registerRuntimeLaunchers(program, handler);
|
||||
return program;
|
||||
}
|
||||
|
||||
// `process.exit` returns `never`, so vi.spyOn demands a replacement with the
|
||||
// same signature. We throw from the mock to short-circuit into test-land.
|
||||
const exitThrows = (): never => {
|
||||
throw new Error('process.exit called');
|
||||
};
|
||||
|
||||
describe('registerRuntimeLaunchers — non-yolo subcommands', () => {
|
||||
let mockExit: MockInstance<typeof process.exit>;
|
||||
|
||||
beforeEach(() => {
|
||||
// process.exit is called when the yolo action rejects an invalid runtime.
|
||||
// Stub it so the assertion catches the rejection instead of terminating
|
||||
// the test runner.
|
||||
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockExit.mockRestore();
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'forwards %s with empty extraArgs and yolo=false',
|
||||
(runtime) => {
|
||||
const handler = vi.fn();
|
||||
const program = buildProgram(handler);
|
||||
program.parse(['node', 'mosaic', runtime]);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(runtime, [], false);
|
||||
},
|
||||
);
|
||||
|
||||
it('forwards excess args after a non-yolo runtime subcommand', () => {
|
||||
const handler = vi.fn();
|
||||
const program = buildProgram(handler);
|
||||
program.parse(['node', 'mosaic', 'claude', '--print', 'hello']);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('claude', ['--print', 'hello'], false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerRuntimeLaunchers — yolo <runtime>', () => {
|
||||
let mockExit: MockInstance<typeof process.exit>;
|
||||
let mockError: MockInstance<typeof console.error>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
||||
mockError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockExit.mockRestore();
|
||||
mockError.mockRestore();
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'does NOT pass the runtime name as an extra arg (regression #454) for yolo %s',
|
||||
(runtime) => {
|
||||
const handler = vi.fn();
|
||||
const program = buildProgram(handler);
|
||||
program.parse(['node', 'mosaic', 'yolo', runtime]);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
// The critical assertion: extraArgs must be empty, not [runtime].
|
||||
// Before the fix, cmd.args was [runtime] and the runtime name leaked
|
||||
// through to the underlying CLI as an initial positional argument.
|
||||
expect(handler).toHaveBeenCalledWith(runtime, [], true);
|
||||
},
|
||||
);
|
||||
|
||||
it('forwards true excess args after a yolo runtime', () => {
|
||||
const handler = vi.fn();
|
||||
const program = buildProgram(handler);
|
||||
program.parse(['node', 'mosaic', 'yolo', 'claude', '--print', 'hi']);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('claude', ['--print', 'hi'], true);
|
||||
});
|
||||
|
||||
it('rejects an unknown runtime under yolo without invoking the handler', () => {
|
||||
const handler = vi.fn();
|
||||
const program = buildProgram(handler);
|
||||
|
||||
expect(() => program.parse(['node', 'mosaic', 'yolo', 'bogus'])).toThrow('process.exit called');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -78,82 +78,6 @@ function checkSoul(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Claude settings validation ─────────────────────────────────────────────
|
||||
|
||||
interface SettingsAudit {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function auditClaudeSettings(): SettingsAudit {
|
||||
const warnings: string[] = [];
|
||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||
const settings = readJson(settingsPath);
|
||||
|
||||
if (!settings) {
|
||||
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
|
||||
return { warnings };
|
||||
}
|
||||
|
||||
// Check required hooks
|
||||
const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;
|
||||
|
||||
const requiredPreToolUse = ['prevent-memory-write.sh'];
|
||||
const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];
|
||||
|
||||
const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
|
||||
const postHooks = (hooks?.['PostToolUse'] ?? []) as Array<Record<string, unknown>>;
|
||||
|
||||
const preCommands = preHooks.flatMap((h) => {
|
||||
const inner = (h['hooks'] ?? []) as Array<Record<string, unknown>>;
|
||||
return inner.map((ih) => String(ih['command'] ?? ''));
|
||||
});
|
||||
const postCommands = postHooks.flatMap((h) => {
|
||||
const inner = (h['hooks'] ?? []) as Array<Record<string, unknown>>;
|
||||
return inner.map((ih) => String(ih['command'] ?? ''));
|
||||
});
|
||||
|
||||
for (const script of requiredPreToolUse) {
|
||||
if (!preCommands.some((c) => c.includes(script))) {
|
||||
warnings.push(`Missing PreToolUse hook: ${script}`);
|
||||
}
|
||||
}
|
||||
for (const script of requiredPostToolUse) {
|
||||
if (!postCommands.some((c) => c.includes(script))) {
|
||||
warnings.push(`Missing PostToolUse hook: ${script}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check required plugins
|
||||
const plugins = (settings['enabledPlugins'] ?? {}) as Record<string, boolean>;
|
||||
const requiredPlugins = ['feature-dev', 'pr-review-toolkit', 'code-review'];
|
||||
|
||||
for (const plugin of requiredPlugins) {
|
||||
const found = Object.keys(plugins).some((k) => k.startsWith(plugin) && plugins[k]);
|
||||
if (!found) {
|
||||
warnings.push(`Missing plugin: ${plugin}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check enableAllMcpTools
|
||||
if (!settings['enableAllMcpTools']) {
|
||||
warnings.push('enableAllMcpTools is not true — MCP tools may require per-tool approval');
|
||||
}
|
||||
|
||||
return { warnings };
|
||||
}
|
||||
|
||||
function printSettingsWarnings(audit: SettingsAudit): void {
|
||||
if (audit.warnings.length === 0) return;
|
||||
|
||||
console.log('\n[mosaic] Claude Code settings audit:');
|
||||
for (const w of audit.warnings) {
|
||||
console.log(` ⚠ ${w}`);
|
||||
}
|
||||
console.log(
|
||||
'[mosaic] Run: mosaic doctor — or see ~/.config/mosaic/runtime/claude/RUNTIME.md for required settings.\n',
|
||||
);
|
||||
}
|
||||
|
||||
function checkSequentialThinking(runtime: string): void {
|
||||
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
||||
@@ -483,10 +407,6 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
|
||||
switch (runtime) {
|
||||
case 'claude': {
|
||||
// Audit Claude Code settings and warn about missing hooks/plugins
|
||||
const settingsAudit = auditClaudeSettings();
|
||||
printSettingsWarnings(settingsAudit);
|
||||
|
||||
const prompt = buildRuntimePrompt('claude');
|
||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||
cliArgs.push('--append-system-prompt', prompt);
|
||||
@@ -757,23 +677,8 @@ function runUpgrade(args: string[]): never {
|
||||
|
||||
// ─── Commander registration ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handler invoked when a runtime subcommand (`<runtime>` or `yolo <runtime>`)
|
||||
* is parsed. Exposed so tests can exercise the commander wiring without
|
||||
* spawning subprocesses.
|
||||
*/
|
||||
export type RuntimeLaunchHandler = (
|
||||
runtime: RuntimeName,
|
||||
extraArgs: string[],
|
||||
yolo: boolean,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Wire `<runtime>` and `yolo <runtime>` subcommands onto `program` using a
|
||||
* pluggable launch handler. Separated from `registerLaunchCommands` so tests
|
||||
* can inject a spy and verify argument forwarding.
|
||||
*/
|
||||
export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunchHandler): void {
|
||||
export function registerLaunchCommands(program: Command): void {
|
||||
// Runtime launchers
|
||||
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
||||
program
|
||||
.command(runtime)
|
||||
@@ -781,10 +686,11 @@ export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunc
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
handler(runtime, cmd.args, false);
|
||||
launchRuntime(runtime, cmd.args, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Yolo mode
|
||||
program
|
||||
.command('yolo <runtime>')
|
||||
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
|
||||
@@ -798,20 +704,7 @@ export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunc
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// Commander includes declared positional arguments (`<runtime>`) in
|
||||
// `cmd.args` alongside any trailing excess args. Slice off the first
|
||||
// element so we forward only true excess args — otherwise the runtime
|
||||
// name leaks into the underlying CLI as an initial positional arg,
|
||||
// which Claude Code interprets as the first user message.
|
||||
// Regression test: launch.spec.ts, issue mosaicstack/stack#454.
|
||||
handler(runtime as RuntimeName, cmd.args.slice(1), true);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLaunchCommands(program: Command): void {
|
||||
// Runtime launchers + yolo mode wired to the real process-replacing launcher.
|
||||
registerRuntimeLaunchers(program, (runtime, extraArgs, yolo) => {
|
||||
launchRuntime(runtime, extraArgs, yolo);
|
||||
launchRuntime(runtime as RuntimeName, cmd.args, true);
|
||||
});
|
||||
|
||||
// Coord (mission orchestrator)
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { FileConfigAdapter, DEFAULT_SEED_FILES } from './file-adapter.js';
|
||||
|
||||
/**
|
||||
* Regression tests for the `FileConfigAdapter.syncFramework` seed behavior.
|
||||
*
|
||||
* Background: the bash installer (`framework/install.sh`) and this TS wizard
|
||||
* path both seed framework-contract files from `framework/defaults/` into the
|
||||
* user's mosaic home on first install. Before this fix:
|
||||
*
|
||||
* - The bash installer only seeded `AGENTS.md` and `STANDARDS.md`, leaving
|
||||
* `TOOLS.md` missing despite it being listed as mandatory in the
|
||||
* AGENTS.md load order (position 5).
|
||||
* - The TS wizard iterated every file in `defaults/` and copied it to the
|
||||
* mosaic home root — including `defaults/SOUL.md` (hardcoded "Jarvis"),
|
||||
* `defaults/USER.md` (placeholder), and internal framework files like
|
||||
* `README.md` and `AUDIT-*.md`. That clobbered the identity flow on
|
||||
* fresh installs and leaked framework-internal clutter into the user's
|
||||
* home directory.
|
||||
*
|
||||
* This suite pins the whitelist and the preservation semantics so both
|
||||
* regressions stay fixed.
|
||||
*/
|
||||
|
||||
function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-file-adapter-'));
|
||||
const sourceDir = join(root, 'source');
|
||||
const mosaicHome = join(root, 'mosaic-home');
|
||||
const defaultsDir = join(sourceDir, 'defaults');
|
||||
|
||||
mkdirSync(defaultsDir, { recursive: true });
|
||||
mkdirSync(mosaicHome, { recursive: true });
|
||||
|
||||
// Framework-contract defaults we expect the wizard to seed.
|
||||
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
||||
writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n');
|
||||
writeFileSync(join(defaultsDir, 'TOOLS.md'), '# TOOLS default\n');
|
||||
|
||||
// Non-contract files we must NOT seed on first install.
|
||||
writeFileSync(join(defaultsDir, 'SOUL.md'), '# SOUL default (should not be seeded)\n');
|
||||
writeFileSync(join(defaultsDir, 'USER.md'), '# USER default (should not be seeded)\n');
|
||||
writeFileSync(join(defaultsDir, 'README.md'), '# README (framework-internal)\n');
|
||||
writeFileSync(
|
||||
join(defaultsDir, 'AUDIT-2026-02-17-framework-consistency.md'),
|
||||
'# Audit snapshot\n',
|
||||
);
|
||||
|
||||
return { sourceDir, mosaicHome, defaultsDir };
|
||||
}
|
||||
|
||||
describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
let fixture: ReturnType<typeof makeFixture>;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = makeFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(join(fixture.sourceDir, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('seeds the three framework-contract files on a fresh mosaic home', async () => {
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
|
||||
await adapter.syncFramework('fresh');
|
||||
|
||||
for (const name of DEFAULT_SEED_FILES) {
|
||||
expect(existsSync(join(fixture.mosaicHome, name))).toBe(true);
|
||||
}
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toContain(
|
||||
'# TOOLS default',
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT seed SOUL.md or USER.md from defaults/ (wizard stages own those)', async () => {
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
|
||||
await adapter.syncFramework('fresh');
|
||||
|
||||
// SOUL.md and USER.md live in defaults/ for historical reasons, but they
|
||||
// are template-rendered per-user by the wizard stages. Seeding them here
|
||||
// would clobber the identity flow and leak placeholder content.
|
||||
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT seed README.md or AUDIT-*.md from defaults/', async () => {
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
|
||||
await adapter.syncFramework('fresh');
|
||||
|
||||
expect(existsSync(join(fixture.mosaicHome, 'README.md'))).toBe(false);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'AUDIT-2026-02-17-framework-consistency.md'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing contract files — never overwrites user customization', async () => {
|
||||
// Also plant a root-level AGENTS.md in sourceDir so that `syncDirectory`
|
||||
// itself (not just the seed loop) has something to try to overwrite.
|
||||
// Without this, the test would silently pass even if preserve semantics
|
||||
// were broken in syncDirectory.
|
||||
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
|
||||
|
||||
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
|
||||
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
await adapter.syncFramework('keep');
|
||||
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(
|
||||
'# user-customized TOOLS\n',
|
||||
);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe(
|
||||
'# user-customized AGENTS\n',
|
||||
);
|
||||
// And the missing contract file still gets seeded.
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'STANDARDS.md'), 'utf-8')).toContain(
|
||||
'# STANDARDS default',
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||
rmSync(fixture.defaultsDir, { recursive: true });
|
||||
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
await expect(adapter.syncFramework('fresh')).resolves.toBeUndefined();
|
||||
|
||||
expect(existsSync(join(fixture.mosaicHome, 'TOOLS.md'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,5 @@
|
||||
import { readFileSync, existsSync, statSync, copyFileSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Framework-contract files that `syncFramework` seeds from `framework/defaults/`
|
||||
* into the mosaic home root on first install. These are the only files the
|
||||
* wizard is allowed to touch as a one-time seed — SOUL.md and USER.md are
|
||||
* generated from templates by their respective wizard stages with
|
||||
* user-supplied values, and anything else under `defaults/` (README.md,
|
||||
* audit snapshots, etc.) is framework-internal and must not leak into the
|
||||
* user's mosaic home.
|
||||
*
|
||||
* This list must match the explicit seed loop in
|
||||
* packages/mosaic/framework/install.sh.
|
||||
*/
|
||||
export const DEFAULT_SEED_FILES = ['AGENTS.md', 'STANDARDS.md', 'TOOLS.md'] as const;
|
||||
import type { ConfigService, ConfigSection, ResolvedConfig } from './config-service.js';
|
||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
||||
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
||||
@@ -145,24 +131,9 @@ export class FileConfigAdapter implements ConfigService {
|
||||
}
|
||||
|
||||
async syncFramework(action: InstallAction): Promise<void> {
|
||||
// Must match PRESERVE_PATHS in packages/mosaic/framework/install.sh so
|
||||
// the bash and TS install paths have the same upgrade-preservation
|
||||
// semantics. Contract files (AGENTS.md, STANDARDS.md, TOOLS.md) are
|
||||
// seeded from defaults/ on first install and preserved thereafter;
|
||||
// identity files (SOUL.md, USER.md) are generated by wizard stages and
|
||||
// must never be touched by the framework sync.
|
||||
const preservePaths =
|
||||
action === 'keep' || action === 'reconfigure'
|
||||
? [
|
||||
'AGENTS.md',
|
||||
'SOUL.md',
|
||||
'USER.md',
|
||||
'TOOLS.md',
|
||||
'STANDARDS.md',
|
||||
'memory',
|
||||
'sources',
|
||||
'credentials',
|
||||
]
|
||||
? ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory']
|
||||
: [];
|
||||
|
||||
syncDirectory(this.sourceDir, this.mosaicHome, {
|
||||
@@ -170,26 +141,23 @@ export class FileConfigAdapter implements ConfigService {
|
||||
excludeGit: true,
|
||||
});
|
||||
|
||||
// Copy framework-contract files (AGENTS.md, STANDARDS.md, TOOLS.md)
|
||||
// from framework/defaults/ into the mosaic home root if they don't
|
||||
// exist yet. These are written on first install only and are never
|
||||
// overwritten afterwards — the user may have customized them.
|
||||
//
|
||||
// SOUL.md and USER.md are deliberately NOT seeded here. They are
|
||||
// generated from templates by the soul/user wizard stages with
|
||||
// user-supplied values; seeding them from defaults would clobber the
|
||||
// identity flow and leak placeholder content into the mosaic home.
|
||||
// Copy default root-level .md files (AGENTS.md, STANDARDS.md, etc.)
|
||||
// from framework/defaults/ into mosaicHome root if they don't exist yet.
|
||||
// These are framework contracts — only written on first install, never
|
||||
// overwritten (user may have customized them).
|
||||
const defaultsDir = join(this.sourceDir, 'defaults');
|
||||
if (existsSync(defaultsDir)) {
|
||||
for (const entry of DEFAULT_SEED_FILES) {
|
||||
const src = join(defaultsDir, entry);
|
||||
for (const entry of readdirSync(defaultsDir)) {
|
||||
const dest = join(this.mosaicHome, entry);
|
||||
if (existsSync(dest)) continue;
|
||||
if (!existsSync(src) || !statSync(src).isFile()) continue;
|
||||
if (!existsSync(dest)) {
|
||||
const src = join(defaultsDir, entry);
|
||||
if (statSync(src).isFile()) {
|
||||
copyFileSync(src, dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async readAll(): Promise<ResolvedConfig> {
|
||||
const [soul, user, tools] = await Promise.all([
|
||||
|
||||
@@ -26,53 +26,6 @@ export const DEFAULTS = {
|
||||
| (add your git providers here) | | | |`,
|
||||
};
|
||||
|
||||
/** Preset intent categories with display labels and suggested agent names. */
|
||||
export const INTENT_PRESETS: Record<
|
||||
string,
|
||||
{ label: string; hint: string; suggestedName: string }
|
||||
> = {
|
||||
general: {
|
||||
label: 'General purpose assistant',
|
||||
hint: 'Versatile helper for any task',
|
||||
suggestedName: 'Mosaic',
|
||||
},
|
||||
'software-dev': {
|
||||
label: 'Software development',
|
||||
hint: 'Coding, debugging, architecture',
|
||||
suggestedName: 'Forge',
|
||||
},
|
||||
devops: {
|
||||
label: 'DevOps & infrastructure',
|
||||
hint: 'CI/CD, containers, monitoring',
|
||||
suggestedName: 'Sentinel',
|
||||
},
|
||||
research: {
|
||||
label: 'Research & analysis',
|
||||
hint: 'Data analysis, literature review',
|
||||
suggestedName: 'Atlas',
|
||||
},
|
||||
content: {
|
||||
label: 'Content & writing',
|
||||
hint: 'Documentation, copywriting, editing',
|
||||
suggestedName: 'Muse',
|
||||
},
|
||||
custom: {
|
||||
label: 'Custom',
|
||||
hint: 'Describe your own use case',
|
||||
suggestedName: 'Mosaic',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect LLM provider type from an API key prefix.
|
||||
*/
|
||||
export function detectProviderType(key: string): 'anthropic' | 'openai' | 'none' {
|
||||
if (!key) return 'none';
|
||||
if (key.startsWith('sk-ant-')) return 'anthropic';
|
||||
if (key.startsWith('sk-')) return 'openai';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export const RECOMMENDED_SKILLS = new Set([
|
||||
'brainstorming',
|
||||
'code-review-excellence',
|
||||
|
||||
@@ -39,7 +39,6 @@ export class ClackPrompter implements WizardPrompter {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
initialValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string> {
|
||||
const validate = opts.validate
|
||||
@@ -52,7 +51,6 @@ export class ClackPrompter implements WizardPrompter {
|
||||
message: opts.message,
|
||||
placeholder: opts.placeholder,
|
||||
defaultValue: opts.defaultValue,
|
||||
initialValue: opts.initialValue,
|
||||
validate,
|
||||
});
|
||||
return guardCancel(result);
|
||||
|
||||
@@ -35,15 +35,12 @@ export class HeadlessPrompter implements WizardPrompter {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
initialValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
const value =
|
||||
typeof answer === 'string'
|
||||
? answer
|
||||
: opts.initialValue !== undefined
|
||||
? opts.initialValue
|
||||
: opts.defaultValue !== undefined
|
||||
? opts.defaultValue
|
||||
: undefined;
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface WizardPrompter {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
/** Prefills the input buffer so the user sees the value and can press Enter to accept. */
|
||||
initialValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string>;
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { agentIntentStage } from './agent-intent.js';
|
||||
|
||||
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn().mockResolvedValue('Mosaic'),
|
||||
confirm: vi.fn().mockResolvedValue(false),
|
||||
select: vi.fn().mockResolvedValue('general'),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||
separator: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/mosaic',
|
||||
sourceDir: '/tmp/mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('agentIntentStage', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('uses default intent and name in headless mode', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
delete process.env['MOSAIC_AGENT_INTENT'];
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('general');
|
||||
expect(state.soul.agentName).toBe('Mosaic');
|
||||
});
|
||||
|
||||
it('reads intent from MOSAIC_AGENT_INTENT env var', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_AGENT_INTENT'] = 'software-dev';
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('software-dev');
|
||||
expect(state.soul.agentName).toBe('Forge');
|
||||
});
|
||||
|
||||
it('honors MOSAIC_AGENT_NAME env var override', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_AGENT_INTENT'] = 'devops';
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'MyBot';
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('devops');
|
||||
expect(state.soul.agentName).toBe('MyBot');
|
||||
});
|
||||
|
||||
it('falls back to general for unknown intent values', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_AGENT_INTENT'] = 'nonexistent';
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('general');
|
||||
expect(state.soul.agentName).toBe('Mosaic');
|
||||
});
|
||||
|
||||
it('prompts for intent and name in interactive mode', async () => {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
const origIsTTY = process.stdin.isTTY;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
|
||||
const state = makeState();
|
||||
const p = buildPrompter({
|
||||
select: vi.fn().mockResolvedValue('research'),
|
||||
text: vi.fn().mockResolvedValue('Atlas'),
|
||||
});
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('research');
|
||||
expect(state.soul.agentName).toBe('Atlas');
|
||||
expect(p.select).toHaveBeenCalled();
|
||||
expect(p.text).toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
|
||||
});
|
||||
|
||||
it('maps content intent to Muse suggested name', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_AGENT_INTENT'] = 'content';
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('content');
|
||||
expect(state.soul.agentName).toBe('Muse');
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { AgentIntent, WizardState } from '../types.js';
|
||||
import { INTENT_PRESETS } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Agent intent + naming stage — deterministic (no LLM required).
|
||||
*
|
||||
* The user picks an intent category from presets, the system proposes a
|
||||
* thematic name, and the user confirms or overrides it.
|
||||
*
|
||||
* In headless mode, reads from `MOSAIC_AGENT_INTENT` and `MOSAIC_AGENT_NAME`.
|
||||
*/
|
||||
export async function agentIntentStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
|
||||
if (isHeadless) {
|
||||
const intentEnv = process.env['MOSAIC_AGENT_INTENT'] ?? 'general';
|
||||
const nameEnv = process.env['MOSAIC_AGENT_NAME'];
|
||||
const preset = INTENT_PRESETS[intentEnv] ?? INTENT_PRESETS['general']!;
|
||||
state.agentIntent ??= (intentEnv in INTENT_PRESETS ? intentEnv : 'general') as AgentIntent;
|
||||
// Respect existing agentName (e.g. from CLI overrides) — only set from
|
||||
// env/preset if not already populated.
|
||||
state.soul.agentName ??= nameEnv ?? preset.suggestedName;
|
||||
return;
|
||||
}
|
||||
|
||||
p.separator();
|
||||
p.note(
|
||||
'Tell us what this agent will primarily help you with.\n' +
|
||||
"We'll suggest a name based on your choice — you can always change it.",
|
||||
'Agent Identity',
|
||||
);
|
||||
|
||||
const intentOptions = Object.entries(INTENT_PRESETS).map(([value, info]) => ({
|
||||
value: value as AgentIntent,
|
||||
label: info.label,
|
||||
hint: info.hint,
|
||||
}));
|
||||
|
||||
const intent = await p.select<AgentIntent>({
|
||||
message: 'What will this agent primarily help you with?',
|
||||
options: intentOptions,
|
||||
initialValue: 'general' as AgentIntent,
|
||||
});
|
||||
|
||||
state.agentIntent = intent;
|
||||
|
||||
const preset = INTENT_PRESETS[intent];
|
||||
const suggestedName = preset?.suggestedName ?? 'Mosaic';
|
||||
|
||||
const name = await p.text({
|
||||
message: `Your agent will be named "${suggestedName}". Press Enter to accept or type a new name`,
|
||||
initialValue: suggestedName,
|
||||
defaultValue: suggestedName,
|
||||
validate: (v) => {
|
||||
if (v.length === 0) return 'Name cannot be empty';
|
||||
if (v.length > 50) return 'Name must be under 50 characters';
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
state.soul.agentName = name;
|
||||
p.log(`Agent name set to: ${name}`);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Tests for the skill installer rework (IUV-02-03).
|
||||
*
|
||||
* We mock `node:child_process` to verify that:
|
||||
* 1. syncSkills passes MOSAIC_INSTALL_SKILLS with the exact selected subset
|
||||
* 2. When the script exits non-zero, the failure is surfaced to the user
|
||||
* 3. When the script is missing, a clear error is shown (not a silent no-op)
|
||||
* 4. An empty selection is a no-op (script never called)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { WizardState } from '../types.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
|
||||
// ── spawnSync mock ─────────────────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const spawnSyncMock = vi.fn<any>();
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
spawnSync: (...args: any[]) => spawnSyncMock(...args),
|
||||
}));
|
||||
|
||||
// ── platform stub ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../platform/detect.js', () => ({
|
||||
getShellProfilePath: () => null,
|
||||
}));
|
||||
|
||||
import { finalizeStage } from './finalize.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeState(mosaicHome: string, selectedSkills: string[] = []): WizardState {
|
||||
return {
|
||||
mosaicHome,
|
||||
sourceDir: mosaicHome,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: { agentName: 'TestBot', communicationStyle: 'direct' },
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrompter() {
|
||||
return {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
select: vi.fn(),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||
separator: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeConfigService(): ConfigService {
|
||||
return {
|
||||
readSoul: vi.fn().mockResolvedValue({}),
|
||||
readUser: vi.fn().mockResolvedValue({}),
|
||||
readTools: vi.fn().mockResolvedValue({}),
|
||||
writeSoul: vi.fn().mockResolvedValue(undefined),
|
||||
writeUser: vi.fn().mockResolvedValue(undefined),
|
||||
writeTools: vi.fn().mockResolvedValue(undefined),
|
||||
syncFramework: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
getSection: vi.fn(),
|
||||
} as unknown as ConfigService;
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('finalizeStage — skill installer', () => {
|
||||
let tmp: string;
|
||||
let binDir: 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');
|
||||
|
||||
// Default: script exists and succeeds
|
||||
writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
|
||||
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function findSkillsSyncCall() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (spawnSyncMock.mock.calls as any[][]).find(
|
||||
(args) =>
|
||||
Array.isArray(args[1]) &&
|
||||
(args[1] as string[]).some((a) => a.includes('mosaic-sync-skills')),
|
||||
);
|
||||
}
|
||||
|
||||
it('passes MOSAIC_INSTALL_SKILLS with the selected skill list', async () => {
|
||||
const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
const call = findSkillsSyncCall();
|
||||
expect(call).toBeDefined();
|
||||
const opts = call![2] as { env?: Record<string, string> };
|
||||
expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
|
||||
});
|
||||
|
||||
it('skips the sync script entirely when no skills are selected', async () => {
|
||||
const state = makeState(tmp, []);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
expect(findSkillsSyncCall()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('warns the user when the sync script exits non-zero', async () => {
|
||||
spawnSyncMock.mockReturnValue({
|
||||
status: 1,
|
||||
stdout: '',
|
||||
stderr: 'git clone failed: connection refused',
|
||||
});
|
||||
|
||||
const state = makeState(tmp, ['brainstorming']);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('git clone failed'));
|
||||
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('mosaic sync'));
|
||||
});
|
||||
|
||||
it('warns the user when the sync script is missing', async () => {
|
||||
// Remove the script to simulate a missing installation
|
||||
rmSync(syncScript);
|
||||
|
||||
const state = makeState(tmp, ['brainstorming']);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
// spawnSync should NOT have been called for the skills script
|
||||
expect(findSkillsSyncCall()).toBeUndefined();
|
||||
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('includes skills count in the summary when install succeeds', async () => {
|
||||
const state = makeState(tmp, ['brainstorming', 'lint']);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
const noteMock = p.note as ReturnType<typeof vi.fn>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const summaryCall = (noteMock.mock.calls as any[][]).find(
|
||||
([, title]) => title === 'Installation Summary',
|
||||
);
|
||||
expect(summaryCall).toBeDefined();
|
||||
expect(summaryCall![0] as string).toContain('2 installed');
|
||||
});
|
||||
});
|
||||
@@ -7,86 +7,25 @@ import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { getShellProfilePath } from '../platform/detect.js';
|
||||
|
||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
||||
function linkRuntimeAssets(mosaicHome: string): void {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||
if (existsSync(script)) {
|
||||
try {
|
||||
spawnSync('bash', [script], {
|
||||
timeout: 30000,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
...(skipClaudeHooks ? { MOSAIC_SKIP_CLAUDE_HOOKS: '1' } : {}),
|
||||
},
|
||||
});
|
||||
spawnSync('bash', [script], { timeout: 30000, stdio: 'pipe' });
|
||||
} catch {
|
||||
// Non-fatal: wizard continues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SyncSkillsResult {
|
||||
success: boolean;
|
||||
installedCount: number;
|
||||
failureReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync skills from the catalog and link only the user-selected subset.
|
||||
*
|
||||
* When `selectedSkills` is non-empty the script receives the list via
|
||||
* `MOSAIC_INSTALL_SKILLS` (colon-separated) so it can skip unlisted skills
|
||||
* during the linking phase. An empty selection is a no-op.
|
||||
*
|
||||
* Failure modes surfaced here:
|
||||
* - Script not found → tells the user explicitly
|
||||
* - Script exits non-zero → stderr is captured and reported
|
||||
* - Catalog directory missing → detected before exec, reported clearly
|
||||
*/
|
||||
function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsResult {
|
||||
if (selectedSkills.length === 0) {
|
||||
return { success: true, installedCount: 0 };
|
||||
}
|
||||
|
||||
function syncSkills(mosaicHome: string): void {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
|
||||
if (!existsSync(script)) {
|
||||
return {
|
||||
success: false,
|
||||
installedCount: 0,
|
||||
failureReason: `Skills sync script not found at ${script} — run 'mosaic sync' after installation.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (existsSync(script)) {
|
||||
try {
|
||||
const result = spawnSync('bash', [script], {
|
||||
timeout: 60000,
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_HOME: mosaicHome,
|
||||
MOSAIC_INSTALL_SKILLS: selectedSkills.join(':'),
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr ?? '').trim();
|
||||
return {
|
||||
success: false,
|
||||
installedCount: 0,
|
||||
failureReason: stderr
|
||||
? `Skills sync failed: ${stderr}`
|
||||
: `Skills sync script exited with code ${(result.status ?? 'unknown').toString()}`,
|
||||
};
|
||||
spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' });
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
return { success: true, installedCount: selectedSkills.length };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
installedCount: 0,
|
||||
failureReason: `Skills sync threw: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,18 +110,13 @@ export async function finalizeStage(
|
||||
}
|
||||
|
||||
// 3. Link runtime assets
|
||||
// Honor the hooks-preview decision: when the user declined hooks, pass
|
||||
// MOSAIC_SKIP_CLAUDE_HOOKS=1 to the linker so hooks-config.json is not
|
||||
// copied into ~/.claude/ while still linking the other runtime files.
|
||||
spin.update('Linking runtime assets...');
|
||||
const skipClaudeHooks = state.hooks?.accepted === false;
|
||||
linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
|
||||
linkRuntimeAssets(state.mosaicHome);
|
||||
|
||||
// 4. Sync skills (only installs the user-selected subset)
|
||||
let skillsResult: SyncSkillsResult = { success: true, installedCount: 0 };
|
||||
// 4. Sync skills
|
||||
if (state.selectedSkills.length > 0) {
|
||||
spin.update(`Installing ${state.selectedSkills.length.toString()} selected skill(s)...`);
|
||||
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
|
||||
spin.update('Syncing skills...');
|
||||
syncSkills(state.mosaicHome);
|
||||
}
|
||||
|
||||
// 5. Run doctor
|
||||
@@ -191,27 +125,15 @@ export async function finalizeStage(
|
||||
|
||||
spin.stop('Installation complete');
|
||||
|
||||
// Report skill install failure clearly (non-fatal but user should know)
|
||||
if (!skillsResult.success && skillsResult.failureReason) {
|
||||
p.warn(skillsResult.failureReason);
|
||||
p.warn("Run 'mosaic sync' manually after installation to install skills.");
|
||||
}
|
||||
|
||||
// 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'}`;
|
||||
|
||||
const summary: string[] = [
|
||||
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
||||
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
||||
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
||||
`Skills: ${skillsSummary}`,
|
||||
`Skills: ${state.selectedSkills.length.toString()} selected`,
|
||||
`Config: ${state.mosaicHome}`,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { WizardState } from '../types.js';
|
||||
|
||||
// ── Mock daemon module ────────────────────────────────────────────────────
|
||||
|
||||
const daemonState = {
|
||||
meta: null as null | {
|
||||
version: string;
|
||||
installedAt: string;
|
||||
entryPoint: string;
|
||||
host: string;
|
||||
port: number;
|
||||
adminToken?: string;
|
||||
},
|
||||
writeMetaCalls: [] as unknown[],
|
||||
};
|
||||
|
||||
vi.mock('../commands/gateway/daemon.js', () => ({
|
||||
GATEWAY_HOME: '/tmp/fake-gw',
|
||||
readMeta: () => daemonState.meta,
|
||||
writeMeta: (m: unknown) => {
|
||||
daemonState.writeMetaCalls.push(m);
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Mock masked-prompt so we never touch real stdin raw mode ──────────────
|
||||
|
||||
vi.mock('../prompter/masked-prompt.js', () => ({
|
||||
promptMaskedConfirmed: vi.fn().mockResolvedValue('supersecret'),
|
||||
}));
|
||||
|
||||
import { gatewayBootstrapStage } from './gateway-bootstrap.js';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn().mockImplementation(async (opts: { message: string }) => {
|
||||
if (/name/i.test(opts.message)) return 'Tester';
|
||||
if (/email/i.test(opts.message)) return 'test@example.com';
|
||||
return '';
|
||||
}),
|
||||
confirm: vi.fn().mockResolvedValue(true),
|
||||
select: vi.fn(),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||
separator: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/fake-mosaic',
|
||||
sourceDir: '/tmp/fake-mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('gatewayBootstrapStage', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
daemonState.meta = {
|
||||
version: '0.0.99',
|
||||
installedAt: new Date().toISOString(),
|
||||
entryPoint: '/fake/entry.js',
|
||||
host: 'localhost',
|
||||
port: 14242,
|
||||
};
|
||||
daemonState.writeMetaCalls = [];
|
||||
// Keep headless so we exercise the env-var path
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_ADMIN_NAME'] = 'Tester';
|
||||
process.env['MOSAIC_ADMIN_EMAIL'] = 'test@example.com';
|
||||
process.env['MOSAIC_ADMIN_PASSWORD'] = 'supersecret';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates the first admin user and persists the token', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: true }),
|
||||
}))
|
||||
.mockImplementationOnce(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user: { id: 'u1', email: 'test@example.com' },
|
||||
token: { plaintext: 'plain-token-xyz' },
|
||||
}),
|
||||
}));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(1);
|
||||
const persistedMeta = daemonState.writeMetaCalls[0] as { adminToken?: string };
|
||||
expect(persistedMeta.adminToken).toBe('plain-token-xyz');
|
||||
expect(state.gateway?.adminTokenIssued).toBe(true);
|
||||
});
|
||||
|
||||
it('short-circuits when admin already exists and token is on file', async () => {
|
||||
daemonState.meta!.adminToken = 'already-have-token';
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: false }),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('treats headless rerun of already-bootstrapped gateway as a successful no-op', async () => {
|
||||
// Admin already exists server-side, but local meta has no token cache.
|
||||
// Headless mode should NOT fail the install — leave admin in place.
|
||||
daemonState.meta!.adminToken = undefined;
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: false }),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns non-completed in headless mode when required env vars are missing', async () => {
|
||||
delete process.env['MOSAIC_ADMIN_NAME'];
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: true }),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('MOSAIC_ADMIN_NAME'));
|
||||
});
|
||||
|
||||
it('returns non-completed when bootstrap status call fails', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValueOnce(new Error('network down'));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
expect(p.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns non-completed when bootstrap/setup responds with error', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => 'bad password',
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
globalThis.fetch = fetchMock as any;
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState();
|
||||
|
||||
const result = await gatewayBootstrapStage(p, state, { host: 'localhost', port: 14242 });
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Gateway bootstrap stage — creates the first admin user and persists the
|
||||
* admin API token.
|
||||
*
|
||||
* Runs as the terminal stage of the unified first-run wizard and is also
|
||||
* invoked by the `mosaic gateway install` standalone entry point after the
|
||||
* config stage. Idempotent: if an admin already exists, this stage offers
|
||||
* inline token recovery instead of re-prompting for credentials.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { promptMaskedConfirmed } from '../prompter/masked-prompt.js';
|
||||
|
||||
// ── Headless detection ────────────────────────────────────────────────────────
|
||||
|
||||
function isHeadless(): boolean {
|
||||
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
}
|
||||
|
||||
// ── Options ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GatewayBootstrapStageOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface GatewayBootstrapStageResult {
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
// ── Stage ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function gatewayBootstrapStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
opts: GatewayBootstrapStageOptions,
|
||||
): Promise<GatewayBootstrapStageResult> {
|
||||
const { host, port } = opts;
|
||||
const baseUrl = `http://${host}:${port.toString()}`;
|
||||
|
||||
const { readMeta, writeMeta, GATEWAY_HOME } = await import('../commands/gateway/daemon.js');
|
||||
const existingMeta = readMeta();
|
||||
if (!existingMeta) {
|
||||
p.warn('Gateway meta.json missing — cannot bootstrap admin user.');
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
// Check whether an admin already exists.
|
||||
let needsSetup: boolean;
|
||||
try {
|
||||
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
|
||||
if (!statusRes.ok) {
|
||||
p.warn('Could not check bootstrap status — skipping first user setup.');
|
||||
return { completed: false };
|
||||
}
|
||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||
needsSetup = status.needsSetup;
|
||||
} catch {
|
||||
p.warn('Could not reach gateway bootstrap endpoint — skipping first user setup.');
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
if (!needsSetup) {
|
||||
if (existingMeta.adminToken) {
|
||||
p.log('Admin user already exists (token on file).');
|
||||
return { completed: true };
|
||||
}
|
||||
|
||||
// Admin exists but no token on file — offer inline recovery if interactive.
|
||||
p.warn('Admin user already exists but no admin token is on file.');
|
||||
|
||||
// Headless re-install: treat this as a successful no-op. The gateway has
|
||||
// already been bootstrapped; a scripted re-run should not fail simply
|
||||
// because the local admin-token cache has been cleared. Operators can
|
||||
// run `mosaic gateway config recover-token` interactively later.
|
||||
if (isHeadless()) {
|
||||
p.log(
|
||||
'Headless mode — leaving existing admin in place. Run `mosaic gateway config recover-token` to restore local token access.',
|
||||
);
|
||||
return { completed: true };
|
||||
}
|
||||
|
||||
const runRecovery = await p.confirm({
|
||||
message: 'Run token recovery now?',
|
||||
initialValue: true,
|
||||
});
|
||||
if (runRecovery) {
|
||||
try {
|
||||
const { ensureSession, mintAdminToken, persistToken } =
|
||||
await import('../commands/gateway/token-ops.js');
|
||||
const cookie = await ensureSession(baseUrl);
|
||||
const label = `CLI recovery token (${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace('T', ' ')})`;
|
||||
const minted = await mintAdminToken(baseUrl, cookie, label);
|
||||
persistToken(baseUrl, minted);
|
||||
return { completed: true };
|
||||
} catch (err) {
|
||||
p.warn(`Token recovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return { completed: false };
|
||||
}
|
||||
}
|
||||
|
||||
p.log('No admin token on file. Run: mosaic gateway config recover-token');
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
// Fresh bootstrap — collect admin credentials.
|
||||
p.note('Admin User Setup', 'Create your first admin user');
|
||||
|
||||
let name: string;
|
||||
let email: string;
|
||||
let password: string;
|
||||
|
||||
if (isHeadless()) {
|
||||
const nameEnv = process.env['MOSAIC_ADMIN_NAME']?.trim() ?? '';
|
||||
const emailEnv = process.env['MOSAIC_ADMIN_EMAIL']?.trim() ?? '';
|
||||
const passwordEnv = process.env['MOSAIC_ADMIN_PASSWORD'] ?? '';
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!nameEnv) missing.push('MOSAIC_ADMIN_NAME');
|
||||
if (!emailEnv) missing.push('MOSAIC_ADMIN_EMAIL');
|
||||
if (!passwordEnv) missing.push('MOSAIC_ADMIN_PASSWORD');
|
||||
|
||||
if (missing.length > 0) {
|
||||
p.warn('Headless admin bootstrap requires env vars: ' + missing.join(', '));
|
||||
return { completed: false };
|
||||
}
|
||||
if (passwordEnv.length < 8) {
|
||||
p.warn('MOSAIC_ADMIN_PASSWORD must be at least 8 characters.');
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
name = nameEnv;
|
||||
email = emailEnv;
|
||||
password = passwordEnv;
|
||||
} else {
|
||||
name = await p.text({
|
||||
message: 'Admin name',
|
||||
validate: (v) => (v.trim().length === 0 ? 'Name is required' : undefined),
|
||||
});
|
||||
email = await p.text({
|
||||
message: 'Admin email',
|
||||
validate: (v) => (v.trim().length === 0 ? 'Email is required' : undefined),
|
||||
});
|
||||
password = await promptMaskedConfirmed(
|
||||
'Admin password (min 8 chars): ',
|
||||
'Confirm password: ',
|
||||
(v) => (v.length < 8 ? 'Password must be at least 8 characters' : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
state.gateway = {
|
||||
...(state.gateway ?? {
|
||||
host,
|
||||
port,
|
||||
tier: 'local',
|
||||
corsOrigin: `http://${host}:3000`,
|
||||
}),
|
||||
admin: { name, email, password },
|
||||
};
|
||||
|
||||
// Call bootstrap setup.
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
p.warn(`Bootstrap failed (${res.status.toString()}): ${body}`);
|
||||
return { completed: false };
|
||||
}
|
||||
|
||||
const result = (await res.json()) as {
|
||||
user: { id: string; email: string };
|
||||
token: { plaintext: string };
|
||||
};
|
||||
|
||||
// Persist the token so future CLI calls can authenticate automatically.
|
||||
const meta = { ...existingMeta, adminToken: result.token.plaintext };
|
||||
writeMeta(meta);
|
||||
|
||||
if (state.gateway) {
|
||||
state.gateway.adminTokenIssued = true;
|
||||
}
|
||||
|
||||
p.log(`Admin user created: ${result.user.email}`);
|
||||
printAdminTokenBanner(p, result.token.plaintext, join(GATEWAY_HOME, 'meta.json'));
|
||||
return { completed: true };
|
||||
} catch (err) {
|
||||
p.warn(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return { completed: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Banner ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function printAdminTokenBanner(p: WizardPrompter, token: string, metaPath: string): void {
|
||||
const body = [
|
||||
' Save this token now — it will not be shown again in full.',
|
||||
` ${token}`,
|
||||
'',
|
||||
` Stored (read-only) at: ${metaPath}`,
|
||||
'',
|
||||
' Use it with admin endpoints, e.g.:',
|
||||
' mosaic gateway --token <token> status',
|
||||
].join('\n');
|
||||
p.note(body, 'Admin API Token');
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveCorsOrigin } from './gateway-config.js';
|
||||
|
||||
describe('deriveCorsOrigin', () => {
|
||||
describe('localhost / loopback — always http', () => {
|
||||
it('localhost port 3000 → http://localhost:3000', () => {
|
||||
expect(deriveCorsOrigin('localhost', 3000)).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('127.0.0.1 port 3000 → http://127.0.0.1:3000', () => {
|
||||
expect(deriveCorsOrigin('127.0.0.1', 3000)).toBe('http://127.0.0.1:3000');
|
||||
});
|
||||
|
||||
it('localhost port 80 omits port suffix', () => {
|
||||
expect(deriveCorsOrigin('localhost', 80)).toBe('http://localhost');
|
||||
});
|
||||
|
||||
it('localhost port 443 still uses http (loopback overrides), includes port', () => {
|
||||
// 443 is the https default port, but since localhost forces http, the port
|
||||
// is NOT the default for http (80), so it must be included.
|
||||
expect(deriveCorsOrigin('localhost', 443)).toBe('http://localhost:443');
|
||||
});
|
||||
|
||||
it('useHttps=false on localhost keeps http', () => {
|
||||
expect(deriveCorsOrigin('localhost', 3000, false)).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('useHttps=true on localhost still uses http (loopback wins)', () => {
|
||||
// Passing useHttps=true for localhost is unusual but the function honours
|
||||
// the explicit override — loopback detection only applies when useHttps is
|
||||
// undefined (auto-detect path).
|
||||
expect(deriveCorsOrigin('localhost', 3000, true)).toBe('https://localhost:3000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote hostname — defaults to https', () => {
|
||||
it('example.com port 3000 → https://example.com:3000', () => {
|
||||
expect(deriveCorsOrigin('example.com', 3000)).toBe('https://example.com:3000');
|
||||
});
|
||||
|
||||
it('example.com port 443 omits port suffix', () => {
|
||||
expect(deriveCorsOrigin('example.com', 443)).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('example.com port 80 → https://example.com:80 (non-default port for https)', () => {
|
||||
expect(deriveCorsOrigin('example.com', 80)).toBe('https://example.com:80');
|
||||
});
|
||||
|
||||
it('useHttps=false on remote host uses http', () => {
|
||||
expect(deriveCorsOrigin('example.com', 3000, false)).toBe('http://example.com:3000');
|
||||
});
|
||||
|
||||
it('useHttps=false on remote host, port 80 omits suffix', () => {
|
||||
expect(deriveCorsOrigin('example.com', 80, false)).toBe('http://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subdomain and non-standard hostnames', () => {
|
||||
it('sub.domain.example.com defaults to https', () => {
|
||||
expect(deriveCorsOrigin('sub.domain.example.com', 3000)).toBe(
|
||||
'https://sub.domain.example.com:3000',
|
||||
);
|
||||
});
|
||||
|
||||
it('myserver.local defaults to https (not loopback)', () => {
|
||||
expect(deriveCorsOrigin('myserver.local', 8080)).toBe('https://myserver.local:8080');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,314 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { WizardState } from '../types.js';
|
||||
|
||||
// ── Mock the gateway daemon module (dynamic-imported inside the stage) ──
|
||||
//
|
||||
// The stage dynamic-imports `../commands/gateway/daemon.js`, so vi.mock
|
||||
// before importing the stage itself. We pin GATEWAY_HOME/ENV_FILE to a
|
||||
// per-test temp directory via a mutable holder so each test can swap the
|
||||
// values without reloading the module.
|
||||
|
||||
const daemonState = {
|
||||
gatewayHome: '',
|
||||
envFile: '',
|
||||
metaFile: '',
|
||||
mosaicConfigFile: '',
|
||||
logFile: '',
|
||||
daemonPid: null as number | null,
|
||||
meta: null as null | {
|
||||
version: string;
|
||||
installedAt: string;
|
||||
entryPoint: string;
|
||||
host: string;
|
||||
port: number;
|
||||
adminToken?: string;
|
||||
},
|
||||
startCalled: 0,
|
||||
stopCalled: 0,
|
||||
waitHealthOk: true,
|
||||
ensureDirsCalled: 0,
|
||||
installPkgCalled: 0,
|
||||
writeMetaCalls: [] as unknown[],
|
||||
};
|
||||
|
||||
vi.mock('../commands/gateway/daemon.js', () => ({
|
||||
get GATEWAY_HOME() {
|
||||
return daemonState.gatewayHome;
|
||||
},
|
||||
get ENV_FILE() {
|
||||
return daemonState.envFile;
|
||||
},
|
||||
get META_FILE() {
|
||||
return daemonState.metaFile;
|
||||
},
|
||||
get LOG_FILE() {
|
||||
return daemonState.logFile;
|
||||
},
|
||||
ensureDirs: () => {
|
||||
daemonState.ensureDirsCalled += 1;
|
||||
},
|
||||
getDaemonPid: () => daemonState.daemonPid,
|
||||
installGatewayPackage: () => {
|
||||
daemonState.installPkgCalled += 1;
|
||||
},
|
||||
readMeta: () => daemonState.meta,
|
||||
resolveGatewayEntry: () => '/fake/entry.js',
|
||||
startDaemon: () => {
|
||||
daemonState.startCalled += 1;
|
||||
daemonState.daemonPid = 42424;
|
||||
return 42424;
|
||||
},
|
||||
stopDaemon: async () => {
|
||||
daemonState.stopCalled += 1;
|
||||
daemonState.daemonPid = null;
|
||||
},
|
||||
waitForHealth: async () => daemonState.waitHealthOk,
|
||||
writeMeta: (m: unknown) => {
|
||||
daemonState.writeMetaCalls.push(m);
|
||||
},
|
||||
getInstalledGatewayVersion: () => '0.0.99',
|
||||
}));
|
||||
|
||||
import { gatewayConfigStage } from './gateway-config.js';
|
||||
|
||||
// ── Prompter stub ─────────────────────────────────────────────────────────
|
||||
|
||||
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn().mockResolvedValue('14242'),
|
||||
confirm: vi.fn().mockResolvedValue(false),
|
||||
select: vi.fn().mockResolvedValue('local'),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||
separator: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(mosaicHome: string): WizardState {
|
||||
return {
|
||||
mosaicHome,
|
||||
sourceDir: mosaicHome,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('gatewayConfigStage', () => {
|
||||
let tmp: string;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'mosaic-gw-config-'));
|
||||
daemonState.gatewayHome = join(tmp, 'gateway');
|
||||
daemonState.envFile = join(daemonState.gatewayHome, '.env');
|
||||
daemonState.metaFile = join(daemonState.gatewayHome, 'meta.json');
|
||||
daemonState.mosaicConfigFile = join(daemonState.gatewayHome, 'mosaic.config.json');
|
||||
daemonState.logFile = join(daemonState.gatewayHome, 'logs', 'gateway.log');
|
||||
daemonState.daemonPid = null;
|
||||
daemonState.meta = null;
|
||||
daemonState.startCalled = 0;
|
||||
daemonState.stopCalled = 0;
|
||||
daemonState.waitHealthOk = true;
|
||||
daemonState.ensureDirsCalled = 0;
|
||||
daemonState.installPkgCalled = 0;
|
||||
daemonState.writeMetaCalls = [];
|
||||
// Ensure the dir exists for config writes
|
||||
require('node:fs').mkdirSync(daemonState.gatewayHome, { recursive: true });
|
||||
// Force headless path via env for predictable tests
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['MOSAIC_DATABASE_URL'];
|
||||
delete process.env['MOSAIC_VALKEY_URL'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('writes .env + mosaic.config.json and starts the daemon on a fresh install', async () => {
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.host).toBe('localhost');
|
||||
expect(result.port).toBe(14242);
|
||||
expect(existsSync(daemonState.envFile)).toBe(true);
|
||||
expect(existsSync(daemonState.mosaicConfigFile)).toBe(true);
|
||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||
expect(envContents).toContain('GATEWAY_PORT=14242');
|
||||
expect(envContents).toContain('BETTER_AUTH_SECRET=');
|
||||
expect(daemonState.startCalled).toBe(1);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(1);
|
||||
expect(state.gateway?.tier).toBe('local');
|
||||
expect(state.gateway?.regeneratedConfig).toBe(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');
|
||||
fs.writeFileSync(daemonState.envFile, 'GATEWAY_PORT=14242\n');
|
||||
fs.writeFileSync(daemonState.mosaicConfigFile, '{}');
|
||||
daemonState.daemonPid = 1234;
|
||||
daemonState.meta = {
|
||||
version: '0.0.99',
|
||||
installedAt: new Date().toISOString(),
|
||||
entryPoint: '/fake/entry.js',
|
||||
host: 'localhost',
|
||||
port: 14242,
|
||||
adminToken: 'existing-token',
|
||||
};
|
||||
|
||||
const p = buildPrompter({ confirm: vi.fn().mockResolvedValue(false) });
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.port).toBe(14242);
|
||||
expect(daemonState.startCalled).toBe(0);
|
||||
expect(daemonState.writeMetaCalls).toHaveLength(0);
|
||||
expect(state.gateway?.regeneratedConfig).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses corrupt partial state (one config file present)', async () => {
|
||||
const fs = require('node:fs');
|
||||
fs.writeFileSync(daemonState.envFile, 'GATEWAY_PORT=14242\n');
|
||||
// mosaicConfigFile intentionally missing
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(daemonState.startCalled).toBe(0);
|
||||
});
|
||||
|
||||
it('honors MOSAIC_STORAGE_TIER=team in headless path', async () => {
|
||||
process.env['MOSAIC_STORAGE_TIER'] = 'team';
|
||||
process.env['MOSAIC_DATABASE_URL'] = 'postgresql://test/db';
|
||||
process.env['MOSAIC_VALKEY_URL'] = 'redis://test:6379';
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(state.gateway?.tier).toBe('team');
|
||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||
expect(envContents).toContain('DATABASE_URL=postgresql://test/db');
|
||||
expect(envContents).toContain('VALKEY_URL=redis://test:6379');
|
||||
const mosaicConfig = JSON.parse(readFileSync(daemonState.mosaicConfigFile, 'utf-8'));
|
||||
expect(mosaicConfig.tier).toBe('team');
|
||||
});
|
||||
|
||||
it('regenerates config when portOverride differs from saved GATEWAY_PORT', async () => {
|
||||
// Both config files present with a saved port of 14242. Caller passes
|
||||
// a portOverride of 15000, which should force regeneration (not trip
|
||||
// the corrupt-partial-state guard) and write the new port to .env.
|
||||
const fs = require('node:fs');
|
||||
fs.writeFileSync(daemonState.envFile, 'GATEWAY_PORT=14242\nBETTER_AUTH_SECRET=seeded\n');
|
||||
fs.writeFileSync(daemonState.mosaicConfigFile, '{}');
|
||||
daemonState.daemonPid = null;
|
||||
daemonState.meta = {
|
||||
version: '0.0.99',
|
||||
installedAt: new Date().toISOString(),
|
||||
entryPoint: '/fake/entry.js',
|
||||
host: 'localhost',
|
||||
port: 14242,
|
||||
};
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
portOverride: 15000,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.port).toBe(15000);
|
||||
expect(state.gateway?.regeneratedConfig).toBe(true);
|
||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||
expect(envContents).toContain('GATEWAY_PORT=15000');
|
||||
expect(envContents).not.toContain('GATEWAY_PORT=14242');
|
||||
// Secret should still be preserved across the regeneration.
|
||||
expect(envContents).toContain('BETTER_AUTH_SECRET=seeded');
|
||||
// writeMeta should have been called with the new port.
|
||||
const lastMeta = daemonState.writeMetaCalls.at(-1) as { port: number } | undefined;
|
||||
expect(lastMeta?.port).toBe(15000);
|
||||
});
|
||||
|
||||
it('preserves BETTER_AUTH_SECRET from existing .env on reconfigure', async () => {
|
||||
// Seed an .env with a known secret, leave mosaic.config.json missing so
|
||||
// hasConfig=false (triggers config regeneration without needing the
|
||||
// "already installed" branch).
|
||||
const fs = require('node:fs');
|
||||
const preservedSecret = 'b'.repeat(64);
|
||||
fs.writeFileSync(
|
||||
daemonState.envFile,
|
||||
`GATEWAY_PORT=14242\nBETTER_AUTH_SECRET=${preservedSecret}\n`,
|
||||
);
|
||||
// Corrupt partial state normally refuses — remove envFile after capturing
|
||||
// its contents... actually use a different approach: pre-create both files
|
||||
// but clear the meta/daemon state so the "fully installed" branch is skipped.
|
||||
fs.writeFileSync(daemonState.mosaicConfigFile, '{}');
|
||||
daemonState.daemonPid = null;
|
||||
daemonState.meta = null; // no meta → partial install "resume" path
|
||||
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
|
||||
// hasConfig=true (both files present) so we enter the "use existing
|
||||
// config" branch and DON'T regenerate — secret is implicitly preserved.
|
||||
expect(result.ready).toBe(true);
|
||||
expect(state.gateway?.regeneratedConfig).toBe(false);
|
||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||
expect(envContents).toContain(`BETTER_AUTH_SECRET=${preservedSecret}`);
|
||||
});
|
||||
});
|
||||
@@ -1,593 +0,0 @@
|
||||
/**
|
||||
* Gateway configuration stage — writes .env + mosaic.config.json, starts the
|
||||
* daemon, and waits for it to become healthy.
|
||||
*
|
||||
* Runs as the penultimate stage of the unified first-run wizard, and is also
|
||||
* invoked directly by the `mosaic gateway install` standalone entry point
|
||||
* (see `commands/gateway/install.ts`).
|
||||
*
|
||||
* Idempotency contract:
|
||||
* - If both .env and mosaic.config.json already exist AND the daemon is
|
||||
* running AND meta has an adminToken, we short-circuit with a confirmation
|
||||
* prompt asking whether to re-run the config wizard.
|
||||
* - Partial state (one file present, the other missing) is refused and the
|
||||
* user is told to run `mosaic gateway uninstall` first.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { GatewayState, GatewayStorageTier, WizardState } from '../types.js';
|
||||
|
||||
// ── Headless detection ────────────────────────────────────────────────────────
|
||||
|
||||
function isHeadless(): boolean {
|
||||
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
}
|
||||
|
||||
// ── CORS derivation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive a full CORS origin URL from a user-provided hostname + web UI port.
|
||||
*
|
||||
* Rules:
|
||||
* - "localhost" and "127.0.0.1" always use http (never https)
|
||||
* - Everything else uses https by default; pass useHttps=false to override
|
||||
* - Standard ports (80 for http, 443 for https) are omitted from the origin
|
||||
*/
|
||||
export function deriveCorsOrigin(hostname: string, webUiPort: number, useHttps?: boolean): string {
|
||||
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
const proto =
|
||||
useHttps !== undefined ? (useHttps ? 'https' : 'http') : isLocalhost ? 'http' : 'https';
|
||||
const defaultPort = proto === 'https' ? 443 : 80;
|
||||
const portSuffix = webUiPort === defaultPort ? '' : `:${webUiPort.toString()}`;
|
||||
return `${proto}://${hostname}${portSuffix}`;
|
||||
}
|
||||
|
||||
// ── .env helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function readEnvVarFromFile(envFile: string, key: string): string | null {
|
||||
if (!existsSync(envFile)) return null;
|
||||
try {
|
||||
for (const line of readFileSync(envFile, 'utf-8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx <= 0) continue;
|
||||
if (trimmed.slice(0, eqIdx) !== key) continue;
|
||||
return trimmed.slice(eqIdx + 1);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPortFromEnv(envFile: string): number | null {
|
||||
const raw = readEnvVarFromFile(envFile, 'GATEWAY_PORT');
|
||||
if (raw === null) return null;
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
// ── Prompt helpers (unified prompter) ────────────────────────────────────────
|
||||
|
||||
async function promptTier(p: WizardPrompter): Promise<GatewayStorageTier> {
|
||||
const tier = await p.select<GatewayStorageTier>({
|
||||
message: 'Storage tier',
|
||||
initialValue: 'local',
|
||||
options: [
|
||||
{
|
||||
value: 'local',
|
||||
label: 'Local',
|
||||
hint: 'embedded database, no dependencies',
|
||||
},
|
||||
{
|
||||
value: 'team',
|
||||
label: 'Team',
|
||||
hint: 'PostgreSQL + Valkey required',
|
||||
},
|
||||
],
|
||||
});
|
||||
return tier;
|
||||
}
|
||||
|
||||
async function promptPort(p: WizardPrompter, defaultPort: number): Promise<number> {
|
||||
const raw = await p.text({
|
||||
message: 'Gateway port',
|
||||
// initialValue prefills the input buffer so the user sees 14242 and can
|
||||
// press Enter to accept it. defaultValue is only used when the user submits
|
||||
// an empty string, which never shows in the field.
|
||||
initialValue: defaultPort.toString(),
|
||||
defaultValue: defaultPort.toString(),
|
||||
validate: (v) => {
|
||||
const n = parseInt(v, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 65535) return 'Port must be a number between 1 and 65535';
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
return parseInt(raw, 10);
|
||||
}
|
||||
|
||||
// ── Options ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GatewayConfigStageOptions {
|
||||
/** Gateway host (from CLI flag or meta fallback). Defaults to localhost. */
|
||||
host: string;
|
||||
/** Default port when nothing else is set. */
|
||||
defaultPort?: number;
|
||||
/**
|
||||
* Explicit port override from the caller (e.g. `mosaic gateway install
|
||||
* --port 9999`). When set, this value wins over the port stored in an
|
||||
* existing `.env` / meta.json so users can recover from a conflicting
|
||||
* saved port without deleting config files first.
|
||||
*/
|
||||
portOverride?: number;
|
||||
/** Skip the `npm install -g @mosaicstack/gateway` step (local build / tests). */
|
||||
skipInstall?: boolean;
|
||||
/**
|
||||
* Pre-collected provider API key (from the provider-setup stage or Quick
|
||||
* Start path). When set, the gateway-config stage will skip the interactive
|
||||
* API key prompt and use this value directly.
|
||||
*/
|
||||
providerKey?: string;
|
||||
/** Provider type detected from the key prefix. */
|
||||
providerType?: 'anthropic' | 'openai' | 'none';
|
||||
}
|
||||
|
||||
export interface GatewayConfigStageResult {
|
||||
/** `true` when the daemon is running, healthy, and `meta.json` is current. */
|
||||
ready: boolean;
|
||||
/** Populated when ready — caller uses this for the bootstrap stage. */
|
||||
host?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
// ── Stage ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function gatewayConfigStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
opts: GatewayConfigStageOptions,
|
||||
): Promise<GatewayConfigStageResult> {
|
||||
// Ensure gateway modules resolve against the correct MOSAIC_GATEWAY_HOME
|
||||
// before any dynamic import — the daemon module captures paths at import
|
||||
// time from process.env.
|
||||
const defaultMosaicHome = join(process.env['HOME'] ?? '', '.config', 'mosaic');
|
||||
if (state.mosaicHome !== defaultMosaicHome && !process.env['MOSAIC_GATEWAY_HOME']) {
|
||||
process.env['MOSAIC_GATEWAY_HOME'] = join(state.mosaicHome, 'gateway');
|
||||
}
|
||||
|
||||
const {
|
||||
ENV_FILE,
|
||||
GATEWAY_HOME,
|
||||
LOG_FILE,
|
||||
ensureDirs,
|
||||
getDaemonPid,
|
||||
installGatewayPackage,
|
||||
readMeta,
|
||||
resolveGatewayEntry,
|
||||
startDaemon,
|
||||
stopDaemon,
|
||||
waitForHealth,
|
||||
writeMeta,
|
||||
getInstalledGatewayVersion,
|
||||
} = await import('../commands/gateway/daemon.js');
|
||||
|
||||
const MOSAIC_CONFIG_FILE = join(GATEWAY_HOME, 'mosaic.config.json');
|
||||
|
||||
p.separator();
|
||||
|
||||
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);
|
||||
|
||||
const defaultPort = opts.defaultPort ?? 14242;
|
||||
const host = opts.host;
|
||||
|
||||
// If the caller explicitly asked for a port that differs from the saved
|
||||
// .env port, force config regeneration. Otherwise meta.json and .env would
|
||||
// drift: the daemon still binds to the saved GATEWAY_PORT while meta +
|
||||
// health checks believe the daemon is on the override port.
|
||||
//
|
||||
// We track this as a separate `forcePortRegen` flag so the corrupt-
|
||||
// partial-state guard below does not mistake an intentional override
|
||||
// regeneration for half-written config from a crashed install.
|
||||
let forcePortRegen = false;
|
||||
if (hasConfig && opts.portOverride !== undefined) {
|
||||
const savedPort = readPortFromEnv(ENV_FILE);
|
||||
if (savedPort !== null && savedPort !== opts.portOverride) {
|
||||
p.log(
|
||||
`Port override (${opts.portOverride.toString()}) differs from saved GATEWAY_PORT=${savedPort.toString()} — regenerating config.`,
|
||||
);
|
||||
hasConfig = false;
|
||||
forcePortRegen = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Corrupt partial state — refuse. (Skip when we intentionally forced
|
||||
// regeneration due to a port-override mismatch; in that case both files
|
||||
// are present and `hasConfig` was deliberately cleared.)
|
||||
if ((envExists || mosaicConfigExists) && !hasConfig && !forcePortRegen) {
|
||||
p.warn('Gateway install is in a corrupt partial state:');
|
||||
p.log(` .env file: ${envExists ? 'present' : 'MISSING'} (${ENV_FILE})`);
|
||||
p.log(
|
||||
` mosaic.config.json: ${mosaicConfigExists ? 'present' : 'MISSING'} (${MOSAIC_CONFIG_FILE})`,
|
||||
);
|
||||
p.log('\nRun `mosaic gateway uninstall` to clean up, then re-run install.');
|
||||
return { ready: false };
|
||||
}
|
||||
|
||||
// Already fully installed path — ask whether to re-run config.
|
||||
let explicitReinstall = false;
|
||||
if (existing && hasConfig && daemonRunning && hasAdminToken) {
|
||||
p.note(
|
||||
[
|
||||
`Gateway is already installed and running (v${existing.version}).`,
|
||||
` Endpoint: http://${existing.host}:${existing.port.toString()}`,
|
||||
` Status: mosaic gateway status`,
|
||||
'',
|
||||
'Re-running the config wizard will:',
|
||||
' - regenerate .env and mosaic.config.json',
|
||||
' - restart the daemon',
|
||||
' - preserve BETTER_AUTH_SECRET (sessions stay valid)',
|
||||
' - clear the stored admin token (you will re-bootstrap an admin user)',
|
||||
' - allow changing storage tier / DB URLs (may point at a different data store)',
|
||||
'To wipe persisted data, run `mosaic gateway uninstall` first.',
|
||||
].join('\n'),
|
||||
'Gateway already installed',
|
||||
);
|
||||
|
||||
const rerun = await p.confirm({
|
||||
message: 'Re-run config wizard?',
|
||||
initialValue: false,
|
||||
});
|
||||
if (!rerun) {
|
||||
// Not rewriting config — the daemon is still listening on
|
||||
// `existing.port`, so downstream callers must use that even if the
|
||||
// user passed a --port override. An override only applies when the
|
||||
// user agrees to a rerun (handled in the regeneration branch below).
|
||||
state.gateway = {
|
||||
host: existing.host,
|
||||
port: existing.port,
|
||||
tier: 'local',
|
||||
corsOrigin:
|
||||
readEnvVarFromFile(ENV_FILE, 'GATEWAY_CORS_ORIGIN') ??
|
||||
deriveCorsOrigin('localhost', 3000),
|
||||
regeneratedConfig: false,
|
||||
};
|
||||
return { ready: true, host: existing.host, port: existing.port };
|
||||
}
|
||||
hasConfig = false;
|
||||
explicitReinstall = true;
|
||||
} else if (existing && (hasConfig || daemonRunning)) {
|
||||
p.log('Detected a partial gateway installation — resuming setup.\n');
|
||||
}
|
||||
|
||||
// Stop daemon before rewriting config.
|
||||
if (!hasConfig && daemonRunning) {
|
||||
p.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)) {
|
||||
p.warn(`Failed to stop running daemon: ${msg}`);
|
||||
p.warn('Refusing to rewrite config while an unknown-state daemon is running.');
|
||||
return { ready: false };
|
||||
}
|
||||
}
|
||||
if (getDaemonPid() !== null) {
|
||||
p.warn('Gateway daemon is still running after stop attempt. Aborting.');
|
||||
return { ready: false };
|
||||
}
|
||||
daemonRunning = false;
|
||||
}
|
||||
|
||||
// Install the gateway npm package on first install or after failure.
|
||||
if (!opts.skipInstall && !daemonRunning) {
|
||||
installGatewayPackage();
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
|
||||
// Collect configuration.
|
||||
const regeneratedConfig = !hasConfig;
|
||||
let port: number;
|
||||
let gatewayState: GatewayState;
|
||||
|
||||
if (hasConfig) {
|
||||
const envPort = readPortFromEnv(ENV_FILE);
|
||||
// Explicit --port override wins even on resume so users can recover from
|
||||
// a conflicting saved port without wiping config first.
|
||||
port = opts.portOverride ?? envPort ?? existing?.port ?? defaultPort;
|
||||
p.log(`Using existing config at ${ENV_FILE} (port ${port.toString()})`);
|
||||
gatewayState = {
|
||||
host,
|
||||
port,
|
||||
tier: 'local',
|
||||
corsOrigin:
|
||||
readEnvVarFromFile(ENV_FILE, 'GATEWAY_CORS_ORIGIN') ?? deriveCorsOrigin('localhost', 3000),
|
||||
regeneratedConfig: false,
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
gatewayState = await collectAndWriteConfig(p, {
|
||||
host,
|
||||
defaultPort: opts.portOverride ?? defaultPort,
|
||||
envFile: ENV_FILE,
|
||||
mosaicConfigFile: MOSAIC_CONFIG_FILE,
|
||||
gatewayHome: GATEWAY_HOME,
|
||||
providerKey: opts.providerKey,
|
||||
providerType: opts.providerType,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayConfigValidationError) {
|
||||
p.warn(err.message);
|
||||
return { ready: false };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
port = gatewayState.port;
|
||||
}
|
||||
|
||||
state.gateway = gatewayState;
|
||||
|
||||
// Write meta.json.
|
||||
let entryPoint: string;
|
||||
try {
|
||||
entryPoint = resolveGatewayEntry();
|
||||
} catch {
|
||||
p.warn(
|
||||
'Gateway package not found after install. Check that @mosaicstack/gateway installed correctly.',
|
||||
);
|
||||
return { ready: false };
|
||||
}
|
||||
|
||||
const version = getInstalledGatewayVersion() ?? 'unknown';
|
||||
const preserveToken = !regeneratedConfig && Boolean(existing?.adminToken);
|
||||
const meta = {
|
||||
version,
|
||||
installedAt: explicitReinstall
|
||||
? new Date().toISOString()
|
||||
: (existing?.installedAt ?? new Date().toISOString()),
|
||||
entryPoint,
|
||||
host,
|
||||
port,
|
||||
...(preserveToken && existing?.adminToken ? { adminToken: existing.adminToken } : {}),
|
||||
};
|
||||
writeMeta(meta);
|
||||
|
||||
// Start the daemon.
|
||||
if (!daemonRunning) {
|
||||
p.log('Starting gateway daemon...');
|
||||
try {
|
||||
const pid = startDaemon();
|
||||
p.log(`Gateway started (PID ${pid.toString()})`);
|
||||
} catch (err) {
|
||||
p.warn(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
||||
printLogTailViaPrompter(p, LOG_FILE);
|
||||
return { ready: false };
|
||||
}
|
||||
} else {
|
||||
p.log('Gateway daemon is already running.');
|
||||
}
|
||||
|
||||
// Wait for health.
|
||||
p.log('Waiting for gateway to become healthy...');
|
||||
const healthy = await waitForHealth(host, port, 30_000);
|
||||
if (!healthy) {
|
||||
p.warn('Gateway did not become healthy within 30 seconds.');
|
||||
printLogTailViaPrompter(p, LOG_FILE);
|
||||
p.warn('Fix the underlying error above, then re-run `mosaic gateway install`.');
|
||||
return { ready: false };
|
||||
}
|
||||
p.log('Gateway is healthy.');
|
||||
|
||||
return { ready: true, host, port };
|
||||
}
|
||||
|
||||
// ── Config collection ─────────────────────────────────────────────────────────
|
||||
|
||||
interface CollectOptions {
|
||||
host: string;
|
||||
defaultPort: number;
|
||||
envFile: string;
|
||||
mosaicConfigFile: string;
|
||||
gatewayHome: string;
|
||||
/** Pre-collected API key — skips the interactive prompt when set. */
|
||||
providerKey?: string;
|
||||
/** Provider type — determines the env var name for the key. */
|
||||
providerType?: 'anthropic' | 'openai' | 'none';
|
||||
}
|
||||
|
||||
/** Raised by the config stage when headless env validation fails. */
|
||||
export class GatewayConfigValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'GatewayConfigValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAndWriteConfig(
|
||||
p: WizardPrompter,
|
||||
opts: CollectOptions,
|
||||
): Promise<GatewayState> {
|
||||
p.note('Collecting gateway configuration', 'Gateway Configuration');
|
||||
|
||||
// Preserve existing BETTER_AUTH_SECRET if an .env survives on disk.
|
||||
const preservedAuthSecret = readEnvVarFromFile(opts.envFile, 'BETTER_AUTH_SECRET');
|
||||
if (preservedAuthSecret) {
|
||||
p.log('(Preserving existing BETTER_AUTH_SECRET — auth sessions will remain valid.)');
|
||||
}
|
||||
|
||||
let tier: GatewayStorageTier;
|
||||
let port: number;
|
||||
let databaseUrl: string | undefined;
|
||||
let valkeyUrl: string | undefined;
|
||||
let anthropicKey: string;
|
||||
let corsOrigin: string;
|
||||
let hostname: string;
|
||||
|
||||
if (isHeadless()) {
|
||||
p.log('Headless mode detected — reading configuration from environment variables.');
|
||||
|
||||
const storageTierEnv = process.env['MOSAIC_STORAGE_TIER'] ?? 'local';
|
||||
tier = storageTierEnv === 'team' ? 'team' : 'local';
|
||||
|
||||
const portEnv = process.env['MOSAIC_GATEWAY_PORT'];
|
||||
port = portEnv ? parseInt(portEnv, 10) : opts.defaultPort;
|
||||
|
||||
databaseUrl = process.env['MOSAIC_DATABASE_URL'];
|
||||
valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
|
||||
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
||||
|
||||
// MOSAIC_CORS_ORIGIN is the full override (e.g. from CI).
|
||||
// MOSAIC_HOSTNAME is the user-friendly alternative — derive from it.
|
||||
const corsOverride = process.env['MOSAIC_CORS_ORIGIN'];
|
||||
const hostnameEnv = process.env['MOSAIC_HOSTNAME'] ?? 'localhost';
|
||||
hostname = hostnameEnv;
|
||||
corsOrigin = corsOverride ?? deriveCorsOrigin(hostnameEnv, 3000);
|
||||
|
||||
if (tier === 'team') {
|
||||
const missing: string[] = [];
|
||||
if (!databaseUrl) missing.push('MOSAIC_DATABASE_URL');
|
||||
if (!valkeyUrl) missing.push('MOSAIC_VALKEY_URL');
|
||||
if (missing.length > 0) {
|
||||
throw new GatewayConfigValidationError(
|
||||
'Headless install with tier=team requires env vars: ' + missing.join(', '),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tier = await promptTier(p);
|
||||
port = await promptPort(p, opts.defaultPort);
|
||||
|
||||
if (tier === 'team') {
|
||||
databaseUrl = await p.text({
|
||||
message: 'DATABASE_URL',
|
||||
initialValue: 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
|
||||
defaultValue: 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
|
||||
});
|
||||
valkeyUrl = await p.text({
|
||||
message: 'VALKEY_URL',
|
||||
initialValue: 'redis://localhost:6380',
|
||||
defaultValue: 'redis://localhost:6380',
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.providerKey) {
|
||||
anthropicKey = opts.providerKey;
|
||||
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
|
||||
} else {
|
||||
anthropicKey = await p.text({
|
||||
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
|
||||
defaultValue: '',
|
||||
});
|
||||
}
|
||||
|
||||
hostname = await p.text({
|
||||
message: 'Web UI hostname (for browser access)',
|
||||
initialValue: 'localhost',
|
||||
defaultValue: 'localhost',
|
||||
placeholder: 'e.g. localhost or myserver.example.com',
|
||||
});
|
||||
|
||||
// For non-localhost, ask if HTTPS is in use (defaults to yes for remote hosts)
|
||||
let useHttps: boolean | undefined;
|
||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
|
||||
useHttps = await p.confirm({
|
||||
message: 'Is HTTPS enabled for the web UI?',
|
||||
initialValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
corsOrigin = deriveCorsOrigin(hostname, 3000, useHttps);
|
||||
p.log(`CORS origin set to: ${corsOrigin}`);
|
||||
}
|
||||
|
||||
const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
|
||||
|
||||
const envLines = [
|
||||
`GATEWAY_PORT=${port.toString()}`,
|
||||
`BETTER_AUTH_SECRET=${authSecret}`,
|
||||
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
|
||||
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
|
||||
`OTEL_SERVICE_NAME=mosaic-gateway`,
|
||||
];
|
||||
|
||||
if (tier === 'team' && databaseUrl && valkeyUrl) {
|
||||
envLines.push(`DATABASE_URL=${databaseUrl}`);
|
||||
envLines.push(`VALKEY_URL=${valkeyUrl}`);
|
||||
}
|
||||
|
||||
if (anthropicKey) {
|
||||
if (opts.providerType === 'openai') {
|
||||
envLines.push(`OPENAI_API_KEY=${anthropicKey}`);
|
||||
} else {
|
||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(opts.envFile, envLines.join('\n') + '\n', { mode: 0o600 });
|
||||
p.log(`Config written to ${opts.envFile}`);
|
||||
|
||||
const mosaicConfig =
|
||||
tier === 'local'
|
||||
? {
|
||||
tier: 'local',
|
||||
storage: { type: 'pglite', dataDir: join(opts.gatewayHome, 'storage-pglite') },
|
||||
queue: { type: 'local', dataDir: join(opts.gatewayHome, 'queue') },
|
||||
memory: { type: 'keyword' },
|
||||
}
|
||||
: {
|
||||
tier: 'team',
|
||||
storage: { type: 'postgres', url: databaseUrl },
|
||||
queue: { type: 'bullmq', url: valkeyUrl },
|
||||
memory: { type: 'pgvector' },
|
||||
};
|
||||
|
||||
writeFileSync(opts.mosaicConfigFile, JSON.stringify(mosaicConfig, null, 2) + '\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
p.log(`Config written to ${opts.mosaicConfigFile}`);
|
||||
|
||||
return {
|
||||
host: opts.host,
|
||||
port,
|
||||
tier,
|
||||
databaseUrl,
|
||||
valkeyUrl,
|
||||
anthropicKey: anthropicKey || undefined,
|
||||
corsOrigin,
|
||||
hostname,
|
||||
regeneratedConfig: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Log tail ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function printLogTailViaPrompter(p: WizardPrompter, logFile: string, maxLines = 30): void {
|
||||
if (!existsSync(logFile)) {
|
||||
p.warn(`(no log file at ${logFile})`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const lines = readFileSync(logFile, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0);
|
||||
const tail = lines.slice(-maxLines);
|
||||
if (tail.length === 0) {
|
||||
p.warn('(log file is empty)');
|
||||
return;
|
||||
}
|
||||
p.note(tail.join('\n'), `Last ${tail.length.toString()} log lines`);
|
||||
} catch (err) {
|
||||
p.warn(`Could not read log file: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { providerSetupStage } from './provider-setup.js';
|
||||
|
||||
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
confirm: vi.fn().mockResolvedValue(false),
|
||||
select: vi.fn().mockResolvedValue('general'),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||
separator: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/mosaic',
|
||||
sourceDir: '/tmp/mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('providerSetupStage', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('detects Anthropic key from prefix in headless mode', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_ANTHROPIC_API_KEY'] = 'sk-ant-api03-test123';
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await providerSetupStage(p, state);
|
||||
|
||||
expect(state.providerKey).toBe('sk-ant-api03-test123');
|
||||
expect(state.providerType).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('detects OpenAI key from prefix in headless mode', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_OPENAI_API_KEY'] = 'sk-proj-test123';
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await providerSetupStage(p, state);
|
||||
|
||||
expect(state.providerKey).toBe('sk-proj-test123');
|
||||
expect(state.providerType).toBe('openai');
|
||||
});
|
||||
|
||||
it('sets provider type to none when no key is provided in headless mode', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
delete process.env['MOSAIC_ANTHROPIC_API_KEY'];
|
||||
delete process.env['MOSAIC_OPENAI_API_KEY'];
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await providerSetupStage(p, state);
|
||||
|
||||
expect(state.providerKey).toBeUndefined();
|
||||
expect(state.providerType).toBe('none');
|
||||
});
|
||||
|
||||
it('prompts for key in interactive mode', async () => {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
// Simulate a TTY
|
||||
const origIsTTY = process.stdin.isTTY;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
|
||||
const state = makeState();
|
||||
const p = buildPrompter({
|
||||
text: vi.fn().mockResolvedValue('sk-ant-api03-interactive'),
|
||||
});
|
||||
|
||||
await providerSetupStage(p, state);
|
||||
|
||||
expect(p.text).toHaveBeenCalled();
|
||||
expect(state.providerKey).toBe('sk-ant-api03-interactive');
|
||||
expect(state.providerType).toBe('anthropic');
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
|
||||
});
|
||||
|
||||
it('handles empty key in interactive mode', async () => {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
const origIsTTY = process.stdin.isTTY;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
|
||||
const state = makeState();
|
||||
const p = buildPrompter({
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
});
|
||||
|
||||
await providerSetupStage(p, state);
|
||||
|
||||
expect(state.providerType).toBe('none');
|
||||
expect(state.providerKey).toBeUndefined();
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { detectProviderType } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Provider setup stage — collects the user's LLM API key and detects the
|
||||
* provider type from the key prefix.
|
||||
*
|
||||
* In headless mode, reads from `MOSAIC_ANTHROPIC_API_KEY` or `MOSAIC_OPENAI_API_KEY`.
|
||||
*/
|
||||
export async function providerSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
|
||||
if (isHeadless) {
|
||||
const anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
||||
const openaiKey = process.env['MOSAIC_OPENAI_API_KEY'] ?? '';
|
||||
const key = anthropicKey || openaiKey;
|
||||
state.providerKey = key || undefined;
|
||||
state.providerType = detectProviderType(key);
|
||||
return;
|
||||
}
|
||||
|
||||
p.separator();
|
||||
p.note(
|
||||
'Configure your LLM provider so the agent has a brain.\n' +
|
||||
'Anthropic (Claude) and OpenAI are supported.\n' +
|
||||
'You can skip this and add a key later via `mosaic configure`.',
|
||||
'LLM Provider',
|
||||
);
|
||||
|
||||
const key = await p.text({
|
||||
message: 'API key (paste your Anthropic or OpenAI key, or press Enter to skip)',
|
||||
defaultValue: '',
|
||||
placeholder: 'sk-ant-api03-... or sk-...',
|
||||
});
|
||||
|
||||
if (key) {
|
||||
const provider = detectProviderType(key);
|
||||
state.providerKey = key;
|
||||
state.providerType = provider;
|
||||
|
||||
if (provider === 'anthropic') {
|
||||
p.log('Detected provider: Anthropic (Claude)');
|
||||
} else if (provider === 'openai') {
|
||||
p.log('Detected provider: OpenAI');
|
||||
} else {
|
||||
p.log('Provider auto-detection failed. Key will be stored as ANTHROPIC_API_KEY.');
|
||||
state.providerType = 'anthropic';
|
||||
}
|
||||
} else {
|
||||
state.providerType = 'none';
|
||||
p.log('No API key provided. You can add one later with `mosaic configure`.');
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { DEFAULTS } from '../constants.js';
|
||||
import { providerSetupStage } from './provider-setup.js';
|
||||
import { runtimeSetupStage } from './runtime-setup.js';
|
||||
import { hooksPreviewStage } from './hooks-preview.js';
|
||||
import { skillsSelectStage } from './skills-select.js';
|
||||
import { finalizeStage } from './finalize.js';
|
||||
import { gatewayConfigStage } from './gateway-config.js';
|
||||
import { gatewayBootstrapStage } from './gateway-bootstrap.js';
|
||||
|
||||
export interface QuickStartOptions {
|
||||
skipGateway?: boolean;
|
||||
gatewayHost?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayPortOverride?: number;
|
||||
skipGatewayNpmInstall?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick Start path — minimal questions to get a working agent.
|
||||
*
|
||||
* 1. Provider API key
|
||||
* 2. Admin email + password (via gateway bootstrap)
|
||||
* 3. Everything else uses defaults.
|
||||
*
|
||||
* Target: under 90 seconds for a returning user.
|
||||
*/
|
||||
export async function quickStartPath(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: QuickStartOptions,
|
||||
): Promise<void> {
|
||||
state.mode = 'quick';
|
||||
|
||||
// 1. Provider setup (first question)
|
||||
await providerSetupStage(prompter, state);
|
||||
|
||||
// Apply sensible defaults for everything else
|
||||
state.soul.agentName ??= 'Mosaic';
|
||||
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
||||
state.soul.communicationStyle ??= 'direct';
|
||||
state.user.background = DEFAULTS.background;
|
||||
state.user.accessibilitySection = DEFAULTS.accessibilitySection;
|
||||
state.user.personalBoundaries = DEFAULTS.personalBoundaries;
|
||||
state.tools.gitProviders = [];
|
||||
state.tools.credentialsLocation = DEFAULTS.credentialsLocation;
|
||||
state.tools.customToolsSection = DEFAULTS.customToolsSection;
|
||||
|
||||
// Runtime detection (auto, no user input in quick mode)
|
||||
await runtimeSetupStage(prompter, state);
|
||||
|
||||
// Hooks (auto-accept in quick mode for Claude)
|
||||
await hooksPreviewStage(prompter, state);
|
||||
|
||||
// Skills (recommended set, no user input in quick mode)
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Finalize (writes configs, links runtime assets, syncs skills)
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway config + bootstrap
|
||||
if (!options.skipGateway) {
|
||||
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: options.gatewayHost ?? 'localhost',
|
||||
defaultPort: options.gatewayPort ?? 14242,
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType ?? 'none',
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
if (headlessRun) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export async function welcomeStage(p: WizardPrompter, _state: WizardState): Prom
|
||||
p.note(
|
||||
`Mosaic is an agent framework that gives AI coding assistants\n` +
|
||||
`a persistent identity, shared skills, and structured workflows.\n\n` +
|
||||
`It works with Claude Code, Codex, OpenCode, and Pi SDK.\n\n` +
|
||||
`It works with Claude Code, Codex, and OpenCode.\n\n` +
|
||||
`All config is stored locally in ~/.config/mosaic/.\n` +
|
||||
`No data is sent anywhere. No accounts required.`,
|
||||
'What is Mosaic?',
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import type { MenuSection } from '../types.js';
|
||||
import { detectProviderType, INTENT_PRESETS } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Tests for the drill-down menu system and its supporting utilities.
|
||||
*
|
||||
* The menu loop itself is in wizard.ts and is hard to unit test in isolation
|
||||
* because it orchestrates many async stages. These tests verify the building
|
||||
* blocks: provider detection, intent presets, and the WizardState shape.
|
||||
*/
|
||||
|
||||
describe('detectProviderType', () => {
|
||||
it('detects Anthropic from sk-ant- prefix', () => {
|
||||
expect(detectProviderType('sk-ant-api03-abc123')).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('detects OpenAI from sk- prefix', () => {
|
||||
expect(detectProviderType('sk-proj-abc123')).toBe('openai');
|
||||
});
|
||||
|
||||
it('returns none for empty string', () => {
|
||||
expect(detectProviderType('')).toBe('none');
|
||||
});
|
||||
|
||||
it('returns none for unrecognized prefix', () => {
|
||||
expect(detectProviderType('gsk_abc123')).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('INTENT_PRESETS', () => {
|
||||
it('has all expected intent categories', () => {
|
||||
expect(Object.keys(INTENT_PRESETS)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'general',
|
||||
'software-dev',
|
||||
'devops',
|
||||
'research',
|
||||
'content',
|
||||
'custom',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('each preset has label, hint, and suggestedName', () => {
|
||||
for (const [key, preset] of Object.entries(INTENT_PRESETS)) {
|
||||
expect(preset.label, `${key}.label`).toBeTruthy();
|
||||
expect(preset.hint, `${key}.hint`).toBeTruthy();
|
||||
expect(preset.suggestedName, `${key}.suggestedName`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps software-dev to Forge', () => {
|
||||
expect(INTENT_PRESETS['software-dev']?.suggestedName).toBe('Forge');
|
||||
});
|
||||
|
||||
it('maps devops to Sentinel', () => {
|
||||
expect(INTENT_PRESETS['devops']?.suggestedName).toBe('Sentinel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WizardState completedSections', () => {
|
||||
it('tracks completed sections as a Set', () => {
|
||||
const completed = new Set<MenuSection>();
|
||||
completed.add('providers');
|
||||
completed.add('identity');
|
||||
|
||||
expect(completed.has('providers')).toBe(true);
|
||||
expect(completed.has('identity')).toBe(true);
|
||||
expect(completed.has('skills')).toBe(false);
|
||||
expect(completed.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headless backward compat', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('MOSAIC_ASSUME_YES=1 triggers headless path', () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
expect(isHeadless).toBe(true);
|
||||
});
|
||||
|
||||
it('non-TTY triggers headless path', () => {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
// In test environments, process.stdin.isTTY is typically undefined (falsy)
|
||||
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
expect(isHeadless).toBe(true);
|
||||
});
|
||||
|
||||
it('all headless env vars are recognized', () => {
|
||||
// This test documents the expected env vars for headless installs.
|
||||
const headlessVars = [
|
||||
'MOSAIC_ASSUME_YES',
|
||||
'MOSAIC_ADMIN_NAME',
|
||||
'MOSAIC_ADMIN_EMAIL',
|
||||
'MOSAIC_ADMIN_PASSWORD',
|
||||
'MOSAIC_GATEWAY_PORT',
|
||||
'MOSAIC_HOSTNAME',
|
||||
'MOSAIC_CORS_ORIGIN',
|
||||
'MOSAIC_STORAGE_TIER',
|
||||
'MOSAIC_DATABASE_URL',
|
||||
'MOSAIC_VALKEY_URL',
|
||||
'MOSAIC_ANTHROPIC_API_KEY',
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_AGENT_INTENT',
|
||||
];
|
||||
|
||||
// Just verify none of them throw when accessed
|
||||
for (const v of headlessVars) {
|
||||
expect(() => process.env[v]).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,19 +3,6 @@ export type InstallAction = 'fresh' | 'keep' | 'reconfigure' | 'reset';
|
||||
export type CommunicationStyle = 'direct' | 'friendly' | 'formal';
|
||||
export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||
|
||||
export type MenuSection =
|
||||
| 'quick-start'
|
||||
| 'providers'
|
||||
| 'identity'
|
||||
| 'skills'
|
||||
| 'gateway'
|
||||
| 'advanced'
|
||||
| 'finish';
|
||||
|
||||
export type AgentIntent = 'general' | 'software-dev' | 'devops' | 'research' | 'content' | 'custom';
|
||||
|
||||
export type ProviderType = 'anthropic' | 'openai' | 'none';
|
||||
|
||||
export interface SoulConfig {
|
||||
agentName?: string;
|
||||
roleDescription?: string;
|
||||
@@ -58,35 +45,6 @@ export interface HooksState {
|
||||
acceptedAt?: string;
|
||||
}
|
||||
|
||||
export type GatewayStorageTier = 'local' | 'team';
|
||||
|
||||
export interface GatewayAdminState {
|
||||
name: string;
|
||||
email: string;
|
||||
/** Plaintext password held in memory only for the duration of the wizard run. */
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface GatewayState {
|
||||
host: string;
|
||||
port: number;
|
||||
tier: GatewayStorageTier;
|
||||
databaseUrl?: string;
|
||||
valkeyUrl?: string;
|
||||
anthropicKey?: string;
|
||||
corsOrigin: string;
|
||||
/**
|
||||
* Raw hostname the user entered (e.g. "localhost", "myserver.example.com").
|
||||
* The full CORS origin (`corsOrigin`) is derived from this + protocol + webUiPort.
|
||||
*/
|
||||
hostname?: string;
|
||||
/** True when .env + mosaic.config.json were (re)generated in this run. */
|
||||
regeneratedConfig?: boolean;
|
||||
admin?: GatewayAdminState;
|
||||
/** Populated after bootstrap/setup succeeds. */
|
||||
adminTokenIssued?: boolean;
|
||||
}
|
||||
|
||||
export interface WizardState {
|
||||
mosaicHome: string;
|
||||
sourceDir: string;
|
||||
@@ -98,13 +56,4 @@ export interface WizardState {
|
||||
runtimes: RuntimeState;
|
||||
selectedSkills: string[];
|
||||
hooks?: HooksState;
|
||||
gateway?: GatewayState;
|
||||
/** Tracks which menu sections have been completed in drill-down mode. */
|
||||
completedSections?: Set<MenuSection>;
|
||||
/** The user's chosen agent intent category. */
|
||||
agentIntent?: AgentIntent;
|
||||
/** The LLM provider API key entered during setup. */
|
||||
providerKey?: string;
|
||||
/** Detected provider type based on API key prefix. */
|
||||
providerType?: ProviderType;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { WizardPrompter } from './prompter/interface.js';
|
||||
import type { ConfigService } from './config/config-service.js';
|
||||
import type { MenuSection, WizardState } from './types.js';
|
||||
import type { WizardState } from './types.js';
|
||||
import { welcomeStage } from './stages/welcome.js';
|
||||
import { detectInstallStage } from './stages/detect-install.js';
|
||||
import { modeSelectStage } from './stages/mode-select.js';
|
||||
import { soulSetupStage } from './stages/soul-setup.js';
|
||||
import { userSetupStage } from './stages/user-setup.js';
|
||||
import { toolsSetupStage } from './stages/tools-setup.js';
|
||||
@@ -10,12 +14,25 @@ import { runtimeSetupStage } from './stages/runtime-setup.js';
|
||||
import { hooksPreviewStage } from './stages/hooks-preview.js';
|
||||
import { skillsSelectStage } from './stages/skills-select.js';
|
||||
import { finalizeStage } from './stages/finalize.js';
|
||||
import { gatewayConfigStage } from './stages/gateway-config.js';
|
||||
import { gatewayBootstrapStage } from './stages/gateway-bootstrap.js';
|
||||
import { providerSetupStage } from './stages/provider-setup.js';
|
||||
import { agentIntentStage } from './stages/agent-intent.js';
|
||||
import { quickStartPath } from './stages/quick-start.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
// ─── Transient install session state (CU-07-02) ───────────────────────────────
|
||||
|
||||
const INSTALL_STATE_FILE = join(
|
||||
process.env['XDG_RUNTIME_DIR'] ?? process.env['TMPDIR'] ?? tmpdir(),
|
||||
'mosaic-install-state.json',
|
||||
);
|
||||
|
||||
function writeInstallState(mosaicHome: string): void {
|
||||
try {
|
||||
const state = {
|
||||
wizardCompletedAt: new Date().toISOString(),
|
||||
mosaicHome,
|
||||
};
|
||||
writeFileSync(INSTALL_STATE_FILE, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
||||
} catch {
|
||||
// Non-fatal — gateway install will just ask for home again
|
||||
}
|
||||
}
|
||||
|
||||
export interface WizardOptions {
|
||||
mosaicHome: string;
|
||||
@@ -23,25 +40,6 @@ export interface WizardOptions {
|
||||
prompter: WizardPrompter;
|
||||
configService: ConfigService;
|
||||
cliOverrides?: Partial<WizardState>;
|
||||
/**
|
||||
* Skip the terminal gateway stages. Used by callers that only want to
|
||||
* configure the framework (SOUL.md/USER.md/skills/hooks) without touching
|
||||
* the gateway daemon. Defaults to `false` — the unified first-run flow
|
||||
* runs everything end-to-end.
|
||||
*/
|
||||
skipGateway?: boolean;
|
||||
/** Host passed through to the gateway config stage. Defaults to localhost. */
|
||||
gatewayHost?: string;
|
||||
/** Default gateway port (14242) — overridable by CLI flag. */
|
||||
gatewayPort?: number;
|
||||
/**
|
||||
* Explicit port override from the caller. Honored even when resuming
|
||||
* from an existing `.env` (useful when the saved port conflicts with
|
||||
* another service).
|
||||
*/
|
||||
gatewayPortOverride?: number;
|
||||
/** Skip `npm install -g @mosaicstack/gateway` during the config stage. */
|
||||
skipGatewayNpmInstall?: boolean;
|
||||
}
|
||||
|
||||
export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
@@ -57,7 +55,6 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
completedSections: new Set<MenuSection>(),
|
||||
};
|
||||
|
||||
// Apply CLI overrides (strip undefined values)
|
||||
@@ -94,364 +91,35 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
// Stage 2: Existing Install Detection
|
||||
await detectInstallStage(prompter, state, configService);
|
||||
|
||||
// ── Headless bypass ────────────────────────────────────────────────────────
|
||||
// When MOSAIC_ASSUME_YES=1 or no TTY, run the linear headless path.
|
||||
// This preserves full backward compatibility with tools/install.sh --yes.
|
||||
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
if (headlessRun) {
|
||||
await runHeadlessPath(prompter, state, configService, options);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Interactive: Main Menu ─────────────────────────────────────────────────
|
||||
// Stage 3: Quick Start vs Advanced (skip if keeping existing)
|
||||
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
||||
await runMenuLoop(prompter, state, configService, options);
|
||||
await modeSelectStage(prompter, state);
|
||||
} else if (state.installAction === 'reconfigure') {
|
||||
state.mode = 'advanced';
|
||||
await runMenuLoop(prompter, state, configService, options);
|
||||
} else {
|
||||
// 'keep' — skip identity setup, go straight to finalize + gateway
|
||||
await runKeepPath(prompter, state, configService, options);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Menu-driven interactive flow ────────────────────────────────────────────
|
||||
|
||||
type MenuChoice =
|
||||
| 'quick-start'
|
||||
| 'providers'
|
||||
| 'identity'
|
||||
| 'skills'
|
||||
| 'gateway-config'
|
||||
| 'advanced'
|
||||
| 'finish';
|
||||
|
||||
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
|
||||
const labels: Record<MenuChoice, string> = {
|
||||
'quick-start': 'Quick Start',
|
||||
providers: 'Providers',
|
||||
identity: 'Agent Identity',
|
||||
skills: 'Skills',
|
||||
'gateway-config': 'Gateway',
|
||||
advanced: 'Advanced',
|
||||
finish: 'Finish & Apply',
|
||||
};
|
||||
const base = labels[section];
|
||||
const sectionKey: MenuSection =
|
||||
section === 'gateway-config' ? 'gateway' : (section as MenuSection);
|
||||
if (completed.has(sectionKey)) {
|
||||
return `${base} [done]`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async function runMenuLoop(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: WizardOptions,
|
||||
): Promise<void> {
|
||||
const completed = state.completedSections!;
|
||||
|
||||
for (;;) {
|
||||
const choice = await prompter.select<MenuChoice>({
|
||||
message: 'What would you like to configure?',
|
||||
options: [
|
||||
{
|
||||
value: 'quick-start',
|
||||
label: menuLabel('quick-start', completed),
|
||||
hint: 'Recommended defaults, minimal questions',
|
||||
},
|
||||
{
|
||||
value: 'providers',
|
||||
label: menuLabel('providers', completed),
|
||||
hint: 'LLM API keys (Anthropic, OpenAI)',
|
||||
},
|
||||
{
|
||||
value: 'identity',
|
||||
label: menuLabel('identity', completed),
|
||||
hint: 'Agent name, intent, persona',
|
||||
},
|
||||
{
|
||||
value: 'skills',
|
||||
label: menuLabel('skills', completed),
|
||||
hint: 'Install agent skills',
|
||||
},
|
||||
{
|
||||
value: 'gateway-config',
|
||||
label: menuLabel('gateway-config', completed),
|
||||
hint: 'Port, storage, database',
|
||||
},
|
||||
{
|
||||
value: 'advanced',
|
||||
label: menuLabel('advanced', completed),
|
||||
hint: 'SOUL.md, USER.md, TOOLS.md, runtimes, hooks',
|
||||
},
|
||||
{
|
||||
value: 'finish',
|
||||
label: menuLabel('finish', completed),
|
||||
hint: 'Write configs and start gateway',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
switch (choice) {
|
||||
case 'quick-start':
|
||||
await quickStartPath(prompter, state, configService, options);
|
||||
return; // Quick start is a complete flow — exit menu
|
||||
|
||||
case 'providers':
|
||||
await providerSetupStage(prompter, state);
|
||||
completed.add('providers');
|
||||
break;
|
||||
|
||||
case 'identity':
|
||||
await agentIntentStage(prompter, state);
|
||||
completed.add('identity');
|
||||
break;
|
||||
|
||||
case 'skills':
|
||||
await skillsSelectStage(prompter, state);
|
||||
completed.add('skills');
|
||||
break;
|
||||
|
||||
case 'gateway-config':
|
||||
// Gateway config is handled during Finish — mark as "configured"
|
||||
// after user reviews settings.
|
||||
await runGatewaySubMenu(prompter, state, options);
|
||||
completed.add('gateway');
|
||||
break;
|
||||
|
||||
case 'advanced':
|
||||
await runAdvancedSubMenu(prompter, state);
|
||||
completed.add('advanced');
|
||||
break;
|
||||
|
||||
case 'finish':
|
||||
await runFinishPath(prompter, state, configService, options);
|
||||
return; // Done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway sub-menu ─────────────────────────────────────────────────────────
|
||||
|
||||
async function runGatewaySubMenu(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
_options: WizardOptions,
|
||||
): Promise<void> {
|
||||
prompter.note(
|
||||
'Gateway settings will be applied when you select "Finish & Apply".\n' +
|
||||
'Configure the settings you want to customize here.',
|
||||
'Gateway Configuration',
|
||||
);
|
||||
|
||||
// For now, just let them know defaults will be used and they can
|
||||
// override during finish. The actual gateway config stage runs
|
||||
// during Finish & Apply. This menu item exists so users know
|
||||
// the gateway is part of the wizard.
|
||||
const port = await prompter.text({
|
||||
message: 'Gateway port',
|
||||
initialValue: (_options.gatewayPort ?? 14242).toString(),
|
||||
defaultValue: (_options.gatewayPort ?? 14242).toString(),
|
||||
validate: (v) => {
|
||||
const n = parseInt(v, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 65535) return 'Port must be 1-65535';
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
// Store for later use in the gateway config stage
|
||||
_options.gatewayPort = parseInt(port, 10);
|
||||
prompter.log(`Gateway port set to ${port}. Will be applied during Finish & Apply.`);
|
||||
}
|
||||
|
||||
// ── Advanced sub-menu ────────────────────────────────────────────────────────
|
||||
|
||||
async function runAdvancedSubMenu(prompter: WizardPrompter, state: WizardState): Promise<void> {
|
||||
state.mode = 'advanced';
|
||||
|
||||
// Run the detailed setup stages
|
||||
await soulSetupStage(prompter, state);
|
||||
await userSetupStage(prompter, state);
|
||||
await toolsSetupStage(prompter, state);
|
||||
await runtimeSetupStage(prompter, state);
|
||||
await hooksPreviewStage(prompter, state);
|
||||
}
|
||||
|
||||
// ── Finish & Apply ──────────────────────────────────────────────────────────
|
||||
|
||||
async function runFinishPath(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: WizardOptions,
|
||||
): Promise<void> {
|
||||
// Apply defaults for anything not explicitly configured
|
||||
state.soul.agentName ??= 'Mosaic';
|
||||
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
||||
state.soul.communicationStyle ??= 'direct';
|
||||
state.user.background ??= DEFAULTS.background;
|
||||
state.user.accessibilitySection ??= DEFAULTS.accessibilitySection;
|
||||
state.user.personalBoundaries ??= DEFAULTS.personalBoundaries;
|
||||
state.tools.gitProviders ??= [];
|
||||
state.tools.credentialsLocation ??= DEFAULTS.credentialsLocation;
|
||||
state.tools.customToolsSection ??= DEFAULTS.customToolsSection;
|
||||
|
||||
// Runtime detection if not already done
|
||||
if (state.runtimes.detected.length === 0 && !state.completedSections?.has('advanced')) {
|
||||
await runtimeSetupStage(prompter, state);
|
||||
await hooksPreviewStage(prompter, state);
|
||||
}
|
||||
|
||||
// Skills defaults if not already configured
|
||||
if (!state.completedSections?.has('skills')) {
|
||||
await skillsSelectStage(prompter, state);
|
||||
}
|
||||
|
||||
// Finalize (writes configs, links runtime assets, syncs skills)
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: options.gatewayHost ?? 'localhost',
|
||||
defaultPort: options.gatewayPort ?? 14242,
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType ?? 'none',
|
||||
});
|
||||
|
||||
if (configResult.ready && configResult.host && configResult.port) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Headless linear path (backward compat) ──────────────────────────────────
|
||||
|
||||
async function runHeadlessPath(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: WizardOptions,
|
||||
): Promise<void> {
|
||||
// Provider setup from env vars
|
||||
await providerSetupStage(prompter, state);
|
||||
|
||||
// Agent intent from env vars
|
||||
await agentIntentStage(prompter, state);
|
||||
|
||||
// SOUL.md
|
||||
// Stage 4: SOUL.md
|
||||
await soulSetupStage(prompter, state);
|
||||
|
||||
// USER.md
|
||||
// Stage 5: USER.md
|
||||
await userSetupStage(prompter, state);
|
||||
|
||||
// TOOLS.md
|
||||
// Stage 6: TOOLS.md
|
||||
await toolsSetupStage(prompter, state);
|
||||
|
||||
// Runtime Detection
|
||||
// Stage 7: Runtime Detection & Installation
|
||||
await runtimeSetupStage(prompter, state);
|
||||
|
||||
// Hooks
|
||||
// Stage 8: Hooks preview (Claude only — skipped if Claude not detected)
|
||||
await hooksPreviewStage(prompter, state);
|
||||
|
||||
// Skills
|
||||
// Stage 9: Skills Selection
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Finalize
|
||||
// Stage 10: Finalize
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: options.gatewayHost ?? 'localhost',
|
||||
defaultPort: options.gatewayPort ?? 14242,
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType ?? 'none',
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keep path (preserve existing identity) ──────────────────────────────────
|
||||
|
||||
async function runKeepPath(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: WizardOptions,
|
||||
): Promise<void> {
|
||||
// Runtime detection
|
||||
await runtimeSetupStage(prompter, state);
|
||||
|
||||
// Hooks
|
||||
await hooksPreviewStage(prompter, state);
|
||||
|
||||
// Skills
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Finalize
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: options.gatewayHost ?? 'localhost',
|
||||
defaultPort: options.gatewayPort ?? 14242,
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
});
|
||||
|
||||
if (configResult.ready && configResult.host && configResult.port) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// CU-07-02: Write transient session state so `mosaic gateway install` can
|
||||
// pick up mosaicHome without re-prompting.
|
||||
writeInstallState(state.mosaicHome);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/prdy"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/quality-rails"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.4",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/queue"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.4",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/storage"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "packages/types"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "plugins/discord"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "plugins/macp"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "plugins/mosaic-framework"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"directory": "plugins/telegram"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
|
||||
377
pnpm-lock.yaml
generated
377
pnpm-lock.yaml
generated
@@ -174,39 +174,21 @@ importers:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@nestjs/testing':
|
||||
specifier: ^11.1.18
|
||||
version: 11.1.18(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
'@swc/core':
|
||||
specifier: ^1.15.24
|
||||
version: 1.15.24(@swc/helpers@0.5.21)
|
||||
'@swc/helpers':
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
'@types/node-cron':
|
||||
specifier: ^3.0.11
|
||||
version: 3.0.11
|
||||
'@types/supertest':
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
'@types/uuid':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
supertest:
|
||||
specifier: ^7.2.2
|
||||
version: 7.2.2
|
||||
tsx:
|
||||
specifier: ^4.0.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
unplugin-swc:
|
||||
specifier: ^1.5.9
|
||||
version: 1.5.9(@swc/core@1.15.24(@swc/helpers@0.5.21))(rollup@4.59.0)
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
@@ -2327,19 +2309,6 @@ packages:
|
||||
'@nestjs/websockets': ^11.0.0
|
||||
rxjs: ^7.1.0
|
||||
|
||||
'@nestjs/testing@11.1.18':
|
||||
resolution: {integrity: sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^11.0.0
|
||||
'@nestjs/core': ^11.0.0
|
||||
'@nestjs/microservices': ^11.0.0
|
||||
'@nestjs/platform-express': ^11.0.0
|
||||
peerDependenciesMeta:
|
||||
'@nestjs/microservices':
|
||||
optional: true
|
||||
'@nestjs/platform-express':
|
||||
optional: true
|
||||
|
||||
'@nestjs/throttler@6.5.0':
|
||||
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
|
||||
peerDependencies:
|
||||
@@ -2414,10 +2383,6 @@ packages:
|
||||
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/hashes@2.0.1':
|
||||
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
@@ -3042,9 +3007,6 @@ packages:
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
@@ -3087,15 +3049,6 @@ packages:
|
||||
'@protobufjs/utf8@1.1.0':
|
||||
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
||||
|
||||
'@rollup/pluginutils@5.3.0':
|
||||
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
|
||||
cpu: [arm]
|
||||
@@ -3437,99 +3390,9 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.24':
|
||||
resolution: {integrity: sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@swc/core-darwin-x64@1.15.24':
|
||||
resolution: {integrity: sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@swc/core-linux-arm-gnueabihf@1.15.24':
|
||||
resolution: {integrity: sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-arm64-gnu@1.15.24':
|
||||
resolution: {integrity: sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.24':
|
||||
resolution: {integrity: sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.24':
|
||||
resolution: {integrity: sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.24':
|
||||
resolution: {integrity: sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.24':
|
||||
resolution: {integrity: sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.24':
|
||||
resolution: {integrity: sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.24':
|
||||
resolution: {integrity: sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core-win32-ia32-msvc@1.15.24':
|
||||
resolution: {integrity: sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core-win32-x64-msvc@1.15.24':
|
||||
resolution: {integrity: sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core@1.15.24':
|
||||
resolution: {integrity: sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@swc/helpers': '>=0.5.17'
|
||||
peerDependenciesMeta:
|
||||
'@swc/helpers':
|
||||
optional: true
|
||||
|
||||
'@swc/counter@0.1.3':
|
||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
||||
|
||||
'@swc/types@0.1.26':
|
||||
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
|
||||
|
||||
'@tailwindcss/node@4.2.1':
|
||||
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
|
||||
|
||||
@@ -3643,9 +3506,6 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/cookiejar@2.1.5':
|
||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
@@ -3676,9 +3536,6 @@ packages:
|
||||
'@types/memcached@2.2.10':
|
||||
resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==}
|
||||
|
||||
'@types/methods@1.1.4':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||
|
||||
'@types/mime-types@2.1.4':
|
||||
resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==}
|
||||
|
||||
@@ -3723,12 +3580,6 @@ packages:
|
||||
'@types/retry@0.12.0':
|
||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==}
|
||||
|
||||
'@types/supertest@7.2.0':
|
||||
resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==}
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
||||
|
||||
@@ -3937,9 +3788,6 @@ packages:
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
asap@2.0.6:
|
||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4281,9 +4129,6 @@ packages:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -4315,9 +4160,6 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cookiejar@2.1.4:
|
||||
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
@@ -4426,9 +4268,6 @@ packages:
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||
|
||||
diff@8.0.3:
|
||||
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
@@ -4734,9 +4573,6 @@ packages:
|
||||
estree-util-is-identifier-name@3.0.0:
|
||||
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
@@ -4915,10 +4751,6 @@ packages:
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
formidable@3.5.4:
|
||||
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
forwarded-parse@2.1.2:
|
||||
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
|
||||
|
||||
@@ -5492,10 +5324,6 @@ packages:
|
||||
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
||||
engines: {node: '>=13.2.0'}
|
||||
|
||||
load-tsconfig@0.2.5:
|
||||
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5634,10 +5462,6 @@ packages:
|
||||
merge-stream@2.0.0:
|
||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
||||
|
||||
methods@1.1.2:
|
||||
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||
|
||||
@@ -5721,11 +5545,6 @@ packages:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mime@2.6.0:
|
||||
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
hasBin: true
|
||||
|
||||
mimic-fn@2.1.0:
|
||||
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6683,14 +6502,6 @@ packages:
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
superagent@10.3.0:
|
||||
resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
supertest@7.2.2:
|
||||
resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6947,15 +6758,6 @@ packages:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
unplugin-swc@1.5.9:
|
||||
resolution: {integrity: sha512-RKwK3yf0M+MN17xZfF14bdKqfx0zMXYdtOdxLiE6jHAoidupKq3jGdJYANyIM1X/VmABhh1WpdO+/f4+Ol89+g==}
|
||||
peerDependencies:
|
||||
'@swc/core': ^1.2.108
|
||||
|
||||
unplugin@2.3.11:
|
||||
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
@@ -7068,9 +6870,6 @@ packages:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
whatwg-mimetype@5.0.0:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -8963,12 +8762,6 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@nestjs/testing@11.1.18(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -9015,8 +8808,6 @@ snapshots:
|
||||
|
||||
'@noble/ciphers@2.1.1': {}
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@nuxt/opencollective@0.4.1':
|
||||
@@ -9931,10 +9722,6 @@ snapshots:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
@@ -9967,14 +9754,6 @@ snapshots:
|
||||
|
||||
'@protobufjs/utf8@1.1.0': {}
|
||||
|
||||
'@rollup/pluginutils@5.3.0(rollup@4.59.0)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
estree-walker: 2.0.2
|
||||
picomatch: 4.0.3
|
||||
optionalDependencies:
|
||||
rollup: 4.59.0
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
optional: true
|
||||
|
||||
@@ -10372,75 +10151,10 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-darwin-x64@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm-gnueabihf@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm64-gnu@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-ia32-msvc@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-x64-msvc@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core@1.15.24(@swc/helpers@0.5.21)':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/types': 0.1.26
|
||||
optionalDependencies:
|
||||
'@swc/core-darwin-arm64': 1.15.24
|
||||
'@swc/core-darwin-x64': 1.15.24
|
||||
'@swc/core-linux-arm-gnueabihf': 1.15.24
|
||||
'@swc/core-linux-arm64-gnu': 1.15.24
|
||||
'@swc/core-linux-arm64-musl': 1.15.24
|
||||
'@swc/core-linux-ppc64-gnu': 1.15.24
|
||||
'@swc/core-linux-s390x-gnu': 1.15.24
|
||||
'@swc/core-linux-x64-gnu': 1.15.24
|
||||
'@swc/core-linux-x64-musl': 1.15.24
|
||||
'@swc/core-win32-arm64-msvc': 1.15.24
|
||||
'@swc/core-win32-ia32-msvc': 1.15.24
|
||||
'@swc/core-win32-x64-msvc': 1.15.24
|
||||
'@swc/helpers': 0.5.21
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@swc/types@0.1.26':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
|
||||
'@tailwindcss/node@4.2.1':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -10538,8 +10252,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
@@ -10572,8 +10284,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
'@types/methods@1.1.4': {}
|
||||
|
||||
'@types/mime-types@2.1.4': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
@@ -10623,18 +10333,6 @@ snapshots:
|
||||
|
||||
'@types/retry@0.12.0': {}
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
dependencies:
|
||||
'@types/cookiejar': 2.1.5
|
||||
'@types/methods': 1.1.4
|
||||
'@types/node': 22.19.15
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/supertest@7.2.0':
|
||||
dependencies:
|
||||
'@types/methods': 1.1.4
|
||||
'@types/superagent': 8.1.9
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
@@ -10889,15 +10587,14 @@ snapshots:
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
asap@2.0.6: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-types@0.13.4:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
asynckit@0.4.0:
|
||||
optional: true
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
@@ -11194,6 +10891,7 @@ snapshots:
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
optional: true
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
@@ -11201,8 +10899,6 @@ snapshots:
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
consola@3.4.2: {}
|
||||
@@ -11219,8 +10915,6 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
cookiejar@2.1.4: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.6:
|
||||
@@ -11300,7 +10994,8 @@ snapshots:
|
||||
escodegen: 2.1.0
|
||||
esprima: 4.0.1
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
delayed-stream@1.0.0:
|
||||
optional: true
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
@@ -11314,11 +11009,6 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
dezalgo@1.0.4:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
wrappy: 1.0.2
|
||||
|
||||
diff@8.0.3: {}
|
||||
|
||||
discord-api-types@0.38.42: {}
|
||||
@@ -11470,6 +11160,7 @@ snapshots:
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
optional: true
|
||||
|
||||
es-toolkit@1.45.1: {}
|
||||
|
||||
@@ -11677,8 +11368,6 @@ snapshots:
|
||||
|
||||
estree-util-is-identifier-name@3.0.0: {}
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -11929,17 +11618,12 @@ snapshots:
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
optional: true
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
dependencies:
|
||||
fetch-blob: 3.2.0
|
||||
|
||||
formidable@3.5.4:
|
||||
dependencies:
|
||||
'@paralleldrive/cuid2': 2.3.1
|
||||
dezalgo: 1.0.4
|
||||
once: 1.4.0
|
||||
|
||||
forwarded-parse@2.1.2: {}
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
@@ -12112,6 +11796,7 @@ snapshots:
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
optional: true
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
@@ -12583,8 +12268,6 @@ snapshots:
|
||||
|
||||
load-esm@1.0.3: {}
|
||||
|
||||
load-tsconfig@0.2.5: {}
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -12783,8 +12466,6 @@ snapshots:
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
|
||||
methods@1.1.2: {}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
dependencies:
|
||||
decode-named-character-reference: 1.3.0
|
||||
@@ -12935,8 +12616,6 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
mime@2.6.0: {}
|
||||
|
||||
mimic-fn@2.1.0: {}
|
||||
|
||||
mimic-fn@4.0.0: {}
|
||||
@@ -14017,28 +13696,6 @@ snapshots:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.4
|
||||
|
||||
superagent@10.3.0:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.4.3
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.5
|
||||
formidable: 3.5.4
|
||||
methods: 1.1.2
|
||||
mime: 2.6.0
|
||||
qs: 6.15.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
supertest@7.2.2:
|
||||
dependencies:
|
||||
cookie-signature: 1.2.2
|
||||
methods: 1.1.2
|
||||
superagent: 10.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -14294,22 +13951,6 @@ snapshots:
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
unplugin-swc@1.5.9(@swc/core@1.15.24(@swc/helpers@0.5.21))(rollup@4.59.0):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.59.0)
|
||||
'@swc/core': 1.15.24(@swc/helpers@0.5.21)
|
||||
load-tsconfig: 0.2.5
|
||||
unplugin: 2.3.11
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
|
||||
unplugin@2.3.11:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
acorn: 8.16.0
|
||||
picomatch: 4.0.3
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -14476,8 +14117,6 @@ snapshots:
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
whatwg-mimetype@5.0.0: {}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
|
||||
# 2. @mosaicstack/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
|
||||
#
|
||||
# Quick: curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
# Direct: bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
# Remote install (recommended):
|
||||
# bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||
#
|
||||
# Remote install (alternative — use -s -- to pass flags):
|
||||
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh | bash -s --
|
||||
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh | bash -s --
|
||||
#
|
||||
# Flags:
|
||||
# --check Version check only, no install
|
||||
@@ -69,7 +69,7 @@ REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstac
|
||||
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
|
||||
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
||||
CLI_PKG="${SCOPE}/mosaic"
|
||||
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
|
||||
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/mosaic-stack"
|
||||
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
||||
|
||||
# ─── uninstall path ───────────────────────────────────────────────────────────
|
||||
@@ -423,18 +423,15 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
|
||||
echo ""
|
||||
if [[ "$FLAG_NO_AUTO_LAUNCH" == "false" ]] && [[ -t 0 ]] && [[ -t 1 ]]; then
|
||||
# Interactive TTY and auto-launch not suppressed: run the unified wizard.
|
||||
# `mosaic wizard` now runs the full first-run flow end-to-end: identity
|
||||
# setup → runtimes → hooks preview → skills → finalize → gateway
|
||||
# config → admin bootstrap. No second call needed.
|
||||
info "First install detected — launching unified setup wizard…"
|
||||
# Interactive TTY and auto-launch not suppressed: run wizard + gateway install
|
||||
info "First install detected — launching setup wizard…"
|
||||
echo ""
|
||||
|
||||
MOSAIC_BIN="$PREFIX/bin/mosaic"
|
||||
|
||||
if ! command -v "$MOSAIC_BIN" &>/dev/null && ! command -v mosaic &>/dev/null; then
|
||||
warn "mosaic binary not found on PATH — skipping auto-launch."
|
||||
warn "Add $PREFIX/bin to PATH and run: mosaic wizard"
|
||||
warn "Add $PREFIX/bin to PATH and run: mosaic wizard && mosaic gateway install"
|
||||
else
|
||||
# Prefer the absolute path from the prefix we just installed to
|
||||
MOSAIC_CMD="mosaic"
|
||||
@@ -442,19 +439,28 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
MOSAIC_CMD="$MOSAIC_BIN"
|
||||
fi
|
||||
|
||||
# Run wizard; if it fails we still try gateway install (best effort)
|
||||
if "$MOSAIC_CMD" wizard; then
|
||||
ok "Wizard complete."
|
||||
else
|
||||
warn "Wizard exited non-zero."
|
||||
echo " You can retry with: ${C}mosaic wizard${RESET}"
|
||||
echo " Or run gateway install alone: ${C}mosaic gateway install${RESET}"
|
||||
warn "Wizard exited non-zero — continuing to gateway install."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Launching gateway install…"
|
||||
if "$MOSAIC_CMD" gateway install; then
|
||||
ok "Gateway install complete."
|
||||
else
|
||||
warn "Gateway install exited non-zero."
|
||||
echo " You can retry with: ${C}mosaic gateway install${RESET}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Non-interactive or --no-auto-launch: print guidance only
|
||||
info "First install detected. Set up your agent identity:"
|
||||
echo " ${C}mosaic wizard${RESET} (unified first-run wizard — identity + gateway + admin)"
|
||||
echo " ${C}mosaic gateway install${RESET} (standalone gateway (re)configure)"
|
||||
echo " ${C}mosaic init${RESET} (interactive SOUL.md / USER.md setup)"
|
||||
echo " ${C}mosaic wizard${RESET} (full guided wizard via Node.js)"
|
||||
echo " ${C}mosaic gateway install${RESET} (install and start the gateway)"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user