Compare commits
1 Commits
docs/feder
...
fix/build-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb39cfc0a |
@@ -103,12 +103,12 @@ steps:
|
|||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- 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
|
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
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
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
|
fi
|
||||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -128,12 +128,12 @@ steps:
|
|||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- 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
|
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
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
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
|
fi
|
||||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
16
AGENTS.md
16
AGENTS.md
@@ -58,14 +58,14 @@ 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.**
|
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 |
|
| Value | When to use | Budget |
|
||||||
| --------- | ----------------------------------------------------------- | -------------------------- |
|
| -------- | ----------------------------------------------------------- | -------------------------- |
|
||||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
| `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 |
|
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
||||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||||
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
||||||
|
|
||||||
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
||||||
|
|
||||||
|
|||||||
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
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
|
||||||
|
|
||||||
Or use the direct URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/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:
|
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
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
|
git clone git@git.mosaicstack.dev:mosaicstack/mosaic-stack.git
|
||||||
cd stack
|
cd mosaic-stack
|
||||||
|
|
||||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -235,7 +229,7 @@ npm packages are published to the Gitea package registry on main merges.
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
stack/
|
mosaic-stack/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
||||||
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
│ └── 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:
|
Run the installer again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
|
||||||
|
|
||||||
Or use the direct URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the CLI:
|
Or use the CLI:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "apps/gateway"
|
"directory": "apps/gateway"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
**ID:** install-ux-v2-20260405
|
**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.
|
**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.
|
||||||
**Phase:** Execution
|
**Phase:** Planning
|
||||||
**Current Milestone:** IUV-M03
|
**Current Milestone:** IUV-M01
|
||||||
**Progress:** 2 / 3 milestones
|
**Progress:** 0 / 3 milestones
|
||||||
**Status:** active
|
**Status:** active
|
||||||
**Last Updated:** 2026-04-05 (IUV-M02 complete — CORS/FQDN + skill installer rework)
|
**Last Updated:** 2026-04-05
|
||||||
**Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
|
**Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
@@ -30,11 +30,11 @@ Real-run testing of `@mosaicstack/mosaic@0.0.25` uncovered:
|
|||||||
|
|
||||||
## Success Criteria
|
## 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)_
|
- [ ] 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.
|
||||||
- [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)_
|
- [ ] 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.
|
||||||
- [x] AC-3: Gateway port prompt prefills `14242` in the input field (user can press Enter to accept). _(PR #440)_
|
- [ ] AC-3: Gateway port prompt prefills `14242` in the input field (user can press Enter to accept).
|
||||||
- [x] AC-4: `"What is Mosaic?"` intro copy mentions Pi SDK as the underlying agent runtime. _(PR #440)_
|
- [ ] AC-4: `"What is Mosaic?"` intro copy mentions Pi SDK as the underlying agent runtime.
|
||||||
- [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-5: Release `mosaic-v0.0.26` tagged and published to the Gitea npm registry, unblocking the 0.0.25 happy path.
|
||||||
- [ ] AC-6: CORS origin prompt replaced with FQDN/hostname input; CORS string is derived from that.
|
- [ ] 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-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-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.
|
||||||
@@ -44,11 +44,11 @@ Real-run testing of `@mosaicstack/mosaic@0.0.25` uncovered:
|
|||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
| # | 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 |
|
| 1 | IUV-M01 | Hotfix: bootstrap DTO + wizard failure + port prefill + copy | in-progress | fix/bootstrap-hotfix | #436 | 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 |
|
| 2 | IUV-M02 | UX polish: CORS/FQDN, skill installer rework | not-started | feat/install-ux-polish | #437 | — | — |
|
||||||
| 3 | IUV-M03 | Provider-first intelligent flow + drill-down main menu | not-started | feat/install-ux-intent | #438 | — | — |
|
| 3 | IUV-M03 | Provider-first intelligent flow + drill-down main menu | not-started | feat/install-ux-intent | #438 | — | — |
|
||||||
|
|
||||||
## Subagent Delegation Plan
|
## Subagent Delegation Plan
|
||||||
|
|
||||||
|
|||||||
@@ -9,29 +9,29 @@
|
|||||||
|
|
||||||
## Milestone 1 — Hotfix: bootstrap DTO + wizard failure + port prefill + copy (IUV-M01)
|
## Milestone 1 — Hotfix: bootstrap DTO + wizard failure + port prefill + copy (IUV-M01)
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| 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-01 | not-started | 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 | one-character fix; repro is real-run `mosaic wizard` against `0.0.25` |
|
||||||
| 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-02 | not-started | 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 | must fail before the fix and pass after; guards against the class-erasure regression recurring |
|
||||||
| 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-03 | not-started | `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 | |
|
||||||
| 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-04 | not-started | 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 | likely WizardPrompter adapter or @clack/prompts `initialValue` vs `defaultValue` mismatch |
|
||||||
| 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-05 | not-started | `"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 | |
|
||||||
| 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 ✓ |
|
| IUV-01-06 | not-started | Tests + code review + PR merge + tag `mosaic-v0.0.26` + Gitea release + npm registry republish | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-05 | 10K | bump `packages/mosaic/package.json` to 0.0.25 → 0.0.26 |
|
||||||
|
|
||||||
## Milestone 2 — UX polish: CORS/FQDN, skill installer rework (IUV-M02)
|
## Milestone 2 — UX polish: CORS/FQDN, skill installer rework (IUV-M02)
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| 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-01 | not-started | 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 | IUV-01-06 | 10K | |
|
||||||
| 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-02 | not-started | 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 | needs real-run reproduction |
|
||||||
| 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-03 | not-started | 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 | |
|
||||||
| 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` |
|
| IUV-02-04 | not-started | Tests + code review + PR merge | #437 | sonnet | feat/install-ux-polish | IUV-02-03 | 10K | |
|
||||||
|
|
||||||
## Milestone 3 — Provider-first intelligent flow + drill-down main menu (IUV-M03)
|
## Milestone 3 — Provider-first intelligent flow + drill-down main menu (IUV-M03)
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| 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-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 | IUV-02-04 | 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-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-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-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) |
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
# Mosaic Stack — Federation Implementation Milestones
|
|
||||||
|
|
||||||
**Companion to:** `PRD.md`
|
|
||||||
**Approach:** Each milestone is a verifiable slice. A milestone is "done" only when its acceptance tests pass in CI against a real (not mocked) dependency stack.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone Dependency Graph
|
|
||||||
|
|
||||||
```
|
|
||||||
M1 (federated tier infra)
|
|
||||||
└── M2 (Step-CA + grant schema + CLI)
|
|
||||||
└── M3 (mTLS handshake + list/get + scope enforcement)
|
|
||||||
├── M4 (search + audit + rate limit)
|
|
||||||
│ └── M5 (cache + offline degradation + OTEL)
|
|
||||||
├── M6 (revocation + auto-renewal) ◄── can start after M3
|
|
||||||
└── M7 (multi-user hardening + e2e suite) ◄── depends on M4+M5+M6
|
|
||||||
```
|
|
||||||
|
|
||||||
M5 and M6 can run in parallel once M4 is merged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Strategy (applies to all milestones)
|
|
||||||
|
|
||||||
Three layers, all required before a milestone ships:
|
|
||||||
|
|
||||||
| Layer | Scope | Runtime |
|
|
||||||
| ------------------ | --------------------------------------------- | ------------------------------------------------------------------------ |
|
|
||||||
| **Unit** | Per-module logic, pure functions, adapters | Vitest, no I/O |
|
|
||||||
| **Integration** | Single gateway against real PG/Valkey/Step-CA | Vitest + Docker Compose test profile |
|
|
||||||
| **Federation E2E** | Two gateways on a Docker network, real mTLS | Playwright/custom harness (`tools/federation-harness/`) introduced in M3 |
|
|
||||||
|
|
||||||
Every milestone adds tests to these layers. A milestone cannot be claimed complete if the federation E2E harness fails (applies from M3 onward).
|
|
||||||
|
|
||||||
**Quality gates per milestone** (same as stack-wide):
|
|
||||||
|
|
||||||
- `pnpm typecheck` green
|
|
||||||
- `pnpm lint` green
|
|
||||||
- `pnpm test` green (unit + integration)
|
|
||||||
- `pnpm test:federation` green (M3+)
|
|
||||||
- Independent code review passed
|
|
||||||
- Docs updated (`docs/federation/`)
|
|
||||||
- Merged PR on `main`, CI terminal green, linked issue closed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M1 — Federated Tier Infrastructure
|
|
||||||
|
|
||||||
**Goal:** A gateway can run in `federated` tier with containerized Postgres + Valkey + pgvector, with no federation logic active yet.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
|
|
||||||
- Add `"tier": "federated"` to `mosaic.config.json` schema and validators
|
|
||||||
- Docker Compose `federated` profile (`docker-compose.federated.yml`) adds: Postgres+pgvector (5433), Valkey (6380), dedicated volumes
|
|
||||||
- Tier detector in gateway bootstrap: reads config, asserts required services reachable, refuses to start otherwise
|
|
||||||
- `pgvector` extension installed + verified on startup
|
|
||||||
- Migration logic: safe upgrade path from `local`/`standalone` → `federated` (data export/import script, one-way)
|
|
||||||
- `mosaic doctor` reports tier + service health
|
|
||||||
- Gateway continues to serve as a normal standalone instance (no federation yet)
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
|
|
||||||
- `mosaic.config.json` schema v2 (tier enum includes `federated`)
|
|
||||||
- `apps/gateway/src/bootstrap/tier-detector.ts`
|
|
||||||
- `docker-compose.federated.yml`
|
|
||||||
- `scripts/migrate-to-federated.ts`
|
|
||||||
- Updated `mosaic doctor` output
|
|
||||||
- Updated `packages/storage/src/adapters/postgres.ts` with pgvector support
|
|
||||||
|
|
||||||
**Acceptance tests:**
|
|
||||||
| # | Test | Layer |
|
|
||||||
| - | ---------------------------------------------------------------------------------------- | ----------- |
|
|
||||||
| 1 | Gateway boots in `federated` tier with all services present | Integration |
|
|
||||||
| 2 | Gateway refuses to boot in `federated` tier when Postgres unreachable (fail-fast, clear) | Integration |
|
|
||||||
| 3 | `pgvector` extension available in target DB (`SELECT * FROM pg_extension WHERE extname='vector'`) | Integration |
|
|
||||||
| 4 | Migration script moves a populated `local` (PGlite) instance to `federated` (Postgres) with no data loss | Integration |
|
|
||||||
| 5 | `mosaic doctor` reports correct tier and all services green | Unit |
|
|
||||||
| 6 | Existing standalone behavior regression: agent session works end-to-end, no federation references | E2E (single-gateway) |
|
|
||||||
|
|
||||||
**Estimated budget:** ~20K tokens (infra + config + migration script)
|
|
||||||
**Risk notes:** Pgvector install on existing PG installs is occasionally finicky; test the migration path on a realistic DB snapshot.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M2 — Step-CA + Grant Schema + Admin CLI
|
|
||||||
|
|
||||||
**Goal:** An admin can create a federation grant and its counterparty can enroll. No runtime traffic flows yet.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
|
|
||||||
- Embed Step-CA as a Docker Compose sidecar with a persistent CA volume
|
|
||||||
- Gateway exposes a short-lived enrollment endpoint (single-use token from the grant)
|
|
||||||
- DB schema: `federation_grants`, `federation_peers`, `federation_audit_log` (table only, not yet written to)
|
|
||||||
- Sealed storage for `client_key_pem` using the existing credential sealing key
|
|
||||||
- Admin CLI:
|
|
||||||
- `mosaic federation grant create --user <id> --peer <host> --scope <file>`
|
|
||||||
- `mosaic federation grant list`
|
|
||||||
- `mosaic federation grant show <id>`
|
|
||||||
- `mosaic federation peer add <enrollment-url>`
|
|
||||||
- `mosaic federation peer list`
|
|
||||||
- Step-CA signs the cert with SAN OIDs for `grantId` + `subjectUserId`
|
|
||||||
- Grant status transitions: `pending` → `active` on successful enrollment
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
|
|
||||||
- `packages/db` migration: three federation tables + enum types
|
|
||||||
- `apps/gateway/src/federation/ca.service.ts` (Step-CA client)
|
|
||||||
- `apps/gateway/src/federation/grants.service.ts`
|
|
||||||
- `apps/gateway/src/federation/enrollment.controller.ts`
|
|
||||||
- `packages/mosaic/src/commands/federation/` (grant + peer subcommands)
|
|
||||||
- `docker-compose.federated.yml` adds Step-CA service
|
|
||||||
- Scope JSON schema + validator
|
|
||||||
|
|
||||||
**Acceptance tests:**
|
|
||||||
| # | Test | Layer |
|
|
||||||
| - | ---------------------------------------------------------------------------------------- | ----------- |
|
|
||||||
| 1 | `grant create` writes a `pending` row with a scoped bundle | Integration |
|
|
||||||
| 2 | Enrollment endpoint signs a CSR and returns a cert with expected SAN OIDs | Integration |
|
|
||||||
| 3 | Enrollment token is single-use; second attempt returns 410 | Integration |
|
|
||||||
| 4 | Cert `subjectUserId` OID matches the grant's `subject_user_id` | Unit |
|
|
||||||
| 5 | `client_key_pem` is at-rest encrypted; raw DB read shows ciphertext, not PEM | Integration |
|
|
||||||
| 6 | `peer add <url>` on Server A yields an `active` peer record with a valid cert + key | E2E (two gateways, no traffic) |
|
|
||||||
| 7 | Scope JSON with unknown resource type rejected at `grant create` | Unit |
|
|
||||||
| 8 | `grant list` and `peer list` render active / pending / revoked accurately | Unit |
|
|
||||||
|
|
||||||
**Estimated budget:** ~30K tokens (schema + CA integration + CLI + sealing)
|
|
||||||
**Risk notes:** Step-CA's API surface is well-documented but the sealing integration with existing provider-credential encryption is a cross-module concern — walk that seam deliberately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M3 — mTLS Handshake + `list` + `get` with Scope Enforcement
|
|
||||||
|
|
||||||
**Goal:** Two federated gateways exchange real data over mTLS with scope intersecting native RBAC.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
|
|
||||||
- `FederationClient` (outbound): picks cert from `federation_peers`, does mTLS call
|
|
||||||
- `FederationServer` (inbound): NestJS guard validates client cert, extracts `grantId` + `subjectUserId`, loads grant
|
|
||||||
- Scope enforcement pipeline:
|
|
||||||
1. Resource allowlist / excluded-list check
|
|
||||||
2. Native RBAC evaluation as the `subjectUserId`
|
|
||||||
3. Scope filter intersection (`include_teams`, `include_personal`)
|
|
||||||
4. `max_rows_per_query` cap
|
|
||||||
- Verbs: `list`, `get`, `capabilities`
|
|
||||||
- Gateway query layer accepts `source: "local" | "federated:<host>" | "all"`; fan-out for `"all"`
|
|
||||||
- **Federation E2E harness** (`tools/federation-harness/`): docker-compose.two-gateways.yml, seed script, assertion helpers — this is its own deliverable
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
|
|
||||||
- `apps/gateway/src/federation/client/federation-client.service.ts`
|
|
||||||
- `apps/gateway/src/federation/server/federation-auth.guard.ts`
|
|
||||||
- `apps/gateway/src/federation/server/scope.service.ts`
|
|
||||||
- `apps/gateway/src/federation/server/verbs/{list,get,capabilities}.controller.ts`
|
|
||||||
- `apps/gateway/src/federation/client/query-source.service.ts` (fan-out/merge)
|
|
||||||
- `tools/federation-harness/` (compose + seed + test helpers)
|
|
||||||
- `packages/types` — federation request/response DTOs in `federation.dto.ts`
|
|
||||||
|
|
||||||
**Acceptance tests:**
|
|
||||||
| # | Test | Layer |
|
|
||||||
| -- | -------------------------------------------------------------------------------------------------------- | ----- |
|
|
||||||
| 1 | A→B `list tasks` returns subjectUser's tasks intersected with scope | E2E |
|
|
||||||
| 2 | A→B `list tasks` with `include_teams: [T1]` excludes T2 tasks the user owns | E2E |
|
|
||||||
| 3 | A→B `get credential <id>` returns 403 when `credentials` is in `excluded_resources` | E2E |
|
|
||||||
| 4 | Client presenting cert for grant X cannot query subjectUser of grant Y (cross-user isolation) | E2E |
|
|
||||||
| 5 | Cert signed by untrusted CA rejected at TLS layer (no NestJS handler reached) | E2E |
|
|
||||||
| 6 | Malformed SAN OIDs → 401; cert valid but grant revoked in DB → 403 | Integration |
|
|
||||||
| 7 | `max_rows_per_query` caps response; request for more paginated | Integration |
|
|
||||||
| 8 | `source: "all"` fan-out merges local + federated results, each tagged with `_source` | Integration |
|
|
||||||
| 9 | Federation responses never persist: verify DB row count unchanged after `list` round-trip | E2E |
|
|
||||||
| 10 | Scope cannot grant more than native RBAC: user without access to team T still gets [] even if scope allows T | E2E |
|
|
||||||
|
|
||||||
**Estimated budget:** ~40K tokens (largest milestone — core federation logic + harness)
|
|
||||||
**Risk notes:** This is the critical trust boundary. Code review should focus on scope enforcement bypass and cert-SAN-spoofing paths. Every 403/401 path needs a test.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M4 — `search` Verb + Audit Log + Rate Limit
|
|
||||||
|
|
||||||
**Goal:** Keyword search over allowed resources with full audit and per-grant rate limiting.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
|
|
||||||
- `search` verb across `resources` allowlist (intersection of scope + native RBAC)
|
|
||||||
- Keyword search (reuse existing `packages/memory/src/adapters/keyword.ts`); pgvector search stays out of v1 search verb
|
|
||||||
- Every federated request (all verbs) writes to `federation_audit_log`: `grant_id`, `verb`, `resource`, `query_hash`, `outcome`, `bytes_out`, `latency_ms`
|
|
||||||
- No request body captured; `query_hash` is SHA-256 of normalized query params
|
|
||||||
- Token-bucket rate limit per grant (default 60/min, override per grant)
|
|
||||||
- 429 response with `Retry-After` header and structured body
|
|
||||||
- 90-day hot retention for audit log; cold-tier rollover deferred to M7
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
|
|
||||||
- `apps/gateway/src/federation/server/verbs/search.controller.ts`
|
|
||||||
- `apps/gateway/src/federation/server/audit.service.ts` (async write, no blocking)
|
|
||||||
- `apps/gateway/src/federation/server/rate-limit.guard.ts`
|
|
||||||
- Tests in harness
|
|
||||||
|
|
||||||
**Acceptance tests:**
|
|
||||||
| # | Test | Layer |
|
|
||||||
| - | ------------------------------------------------------------------------------------------------- | ----------- |
|
|
||||||
| 1 | `search` returns ranked hits only from allowed resources | E2E |
|
|
||||||
| 2 | `search` excluding `credentials` does not return a match even when keyword matches a credential name | E2E |
|
|
||||||
| 3 | Every successful request appears in `federation_audit_log` within 1s | Integration |
|
|
||||||
| 4 | Denied request (403) is also audited with `outcome='denied'` | Integration |
|
|
||||||
| 5 | Audit row stores query hash but NOT query body | Unit |
|
|
||||||
| 6 | 61st request in 60s window returns 429 with `Retry-After` | E2E |
|
|
||||||
| 7 | Per-grant override (e.g., 600/min) takes effect without restart | Integration |
|
|
||||||
| 8 | Audit writes are async: request latency unchanged when audit write slow (simulated) | Integration |
|
|
||||||
|
|
||||||
**Estimated budget:** ~20K tokens
|
|
||||||
**Risk notes:** Ensure audit writes can't block or error-out the request path; use a bounded queue and drop-with-counter pattern rather than in-line writes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M5 — Cache + Offline Degradation + Observability
|
|
||||||
|
|
||||||
**Goal:** Sessions feel fast and stay useful when the peer is slow or down.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
|
|
||||||
- In-memory response cache keyed by `(grant_id, verb, resource, query_hash)`, TTL 30s default
|
|
||||||
- Cache NOT used for `search`; only `list` and `get`
|
|
||||||
- Cache flushed on cert rotation and grant revocation
|
|
||||||
- Circuit breaker per peer: after N failures, fast-fail for cooldown window
|
|
||||||
- `_source` tagging extended with `_cached: true` when served from cache
|
|
||||||
- Agent-visible "federation offline for `<peer>`" signal emitted once per session per peer
|
|
||||||
- OTEL spans: `federation.request` with attrs `grant_id`, `peer`, `verb`, `resource`, `outcome`, `latency_ms`, `cached`
|
|
||||||
- W3C `traceparent` propagated across the mTLS boundary (both directions)
|
|
||||||
- `mosaic federation status` CLI subcommand
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
|
|
||||||
- `apps/gateway/src/federation/client/response-cache.service.ts`
|
|
||||||
- `apps/gateway/src/federation/client/circuit-breaker.service.ts`
|
|
||||||
- `apps/gateway/src/federation/observability/` (span helpers)
|
|
||||||
- `packages/mosaic/src/commands/federation/status.ts`
|
|
||||||
|
|
||||||
**Acceptance tests:**
|
|
||||||
| # | Test | Layer |
|
|
||||||
| - | --------------------------------------------------------------------------------------------- | ----- |
|
|
||||||
| 1 | Two identical `list` calls within 30s: second served from cache, flagged `_cached` | Integration |
|
|
||||||
| 2 | `search` is never cached: two identical searches both hit the peer | Integration |
|
|
||||||
| 3 | After grant revocation, peer's cache is flushed immediately | Integration |
|
|
||||||
| 4 | After N consecutive failures, circuit opens; subsequent requests fail-fast without network call | E2E |
|
|
||||||
| 5 | Circuit closes after cooldown and next success | E2E |
|
|
||||||
| 6 | With peer offline, session completes using local data, one "federation offline" signal surfaced | E2E |
|
|
||||||
| 7 | OTEL traces show spans on both gateways correlated by `traceparent` | E2E |
|
|
||||||
| 8 | `mosaic federation status` prints peer state, cert expiry, last success/failure, circuit state | Unit |
|
|
||||||
|
|
||||||
**Estimated budget:** ~20K tokens
|
|
||||||
**Risk notes:** Caching correctness under revocation must be provable — write tests that intentionally race revocation against cached hits.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M6 — Revocation, Auto-Renewal, CRL
|
|
||||||
|
|
||||||
**Goal:** Grant lifecycle works end-to-end: admin revoke, revoke-on-delete, automatic cert renewal, CRL distribution.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
|
|
||||||
- `mosaic federation grant revoke <id>` → status `revoked`, CRL updated, audit entry
|
|
||||||
- DB hook: deleting a user cascades `revoke-on-delete` on all grants where that user is subject
|
|
||||||
- Step-CA CRL endpoint exposed; serving gateway enforces CRL check on every handshake (cached CRL, refresh interval 60s)
|
|
||||||
- Client-side cert renewal job: at T-7 days, submit renewal CSR; rotate cert atomically; flush cache
|
|
||||||
- On renewal failure, peer marked `degraded` and admin-visible alert emitted
|
|
||||||
- Server A detects revocation on next request (TLS handshake fails with specific error) → peer marked `revoked`, user notified
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
|
|
||||||
- `apps/gateway/src/federation/server/crl.service.ts` + endpoint
|
|
||||||
- `apps/gateway/src/federation/server/revocation.service.ts`
|
|
||||||
- DB cascade trigger or ORM hook for user deletion → grant revocation
|
|
||||||
- `apps/gateway/src/federation/client/renewal.job.ts` (scheduled)
|
|
||||||
- `packages/mosaic/src/commands/federation/grant.ts` gains `revoke` subcommand
|
|
||||||
|
|
||||||
**Acceptance tests:**
|
|
||||||
| # | Test | Layer |
|
|
||||||
| - | ----------------------------------------------------------------------------------------- | ----- |
|
|
||||||
| 1 | Admin `grant revoke` → A's next request fails with TLS-level error | E2E |
|
|
||||||
| 2 | Deleting subject user on B auto-revokes all grants where that user was the subject | Integration |
|
|
||||||
| 3 | CRL endpoint serves correct list; revoked cert present | Integration |
|
|
||||||
| 4 | Server rejects cert listed in CRL even if cert itself is still time-valid | E2E |
|
|
||||||
| 5 | Cert at T-7 days triggers renewal job; new cert issued and installed without dropped requests | E2E |
|
|
||||||
| 6 | Renewal failure marks peer `degraded` and surfaces alert | Integration |
|
|
||||||
| 7 | A marks peer `revoked` after a revocation-caused handshake failure (not on transient network errors) | E2E |
|
|
||||||
|
|
||||||
**Estimated budget:** ~20K tokens
|
|
||||||
**Risk notes:** The atomic cert swap during renewal is the sharpest edge here — any in-flight request mid-swap must either complete on old or retry on new, never fail mid-call.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M7 — Multi-User RBAC Hardening + Team-Scoped Grants + Acceptance Suite
|
|
||||||
|
|
||||||
**Goal:** The full multi-tenant scenario from §4 user stories works end-to-end, with no cross-user leakage under any circumstance.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
|
|
||||||
- Three-user scenario on Server B (E1, E2, E3) each with their own Server A
|
|
||||||
- Team-scoped grants exercised: each employee's team-data visible on their own A, but E1's personal data never visible on E2's A
|
|
||||||
- User-facing UI surfaces on both gateways for: peer list, grant list, audit log viewer, scope editor
|
|
||||||
- Negative-path test matrix (every denial path from PRD §8)
|
|
||||||
- All PRD §15 acceptance criteria mapped to automated tests in the harness
|
|
||||||
- Security review: cert-spoofing, scope-bypass, audit-bypass paths explicitly tested
|
|
||||||
- Cold-storage rollover for audit log >90 days
|
|
||||||
- Docs: operator runbook, onboarding guide, troubleshooting guide
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
|
|
||||||
- Full federation acceptance suite in `tools/federation-harness/acceptance/`
|
|
||||||
- `apps/web` surfaces for peer/grant/audit management
|
|
||||||
- `docs/federation/RUNBOOK.md`, `docs/federation/ONBOARDING.md`, `docs/federation/TROUBLESHOOTING.md`
|
|
||||||
- Audit cold-tier job (daily cron, moves rows >90d to separate table or object storage)
|
|
||||||
|
|
||||||
**Acceptance tests:**
|
|
||||||
Every PRD §15 criterion must be automated and green. Additionally:
|
|
||||||
|
|
||||||
| # | Test | Layer |
|
|
||||||
| --- | ----------------------------------------------------------------------------------------------------- | ---------------- |
|
|
||||||
| 1 | 3-employee scenario: each A sees only its user's data from B | E2E |
|
|
||||||
| 2 | Grant with team scope returns team data; same grant denied access to another employee's personal data | E2E |
|
|
||||||
| 3 | Concurrent sessions from E1's and E2's Server A to B interleave without any leakage | E2E |
|
|
||||||
| 4 | Audit log across 3-user test shows per-grant trails with no mis-attributed rows | E2E |
|
|
||||||
| 5 | Scope editor UI round-trip: edit → save → next request uses new scope | E2E |
|
|
||||||
| 6 | Attempt to use a revoked grant's cert against a different grant's endpoint: rejected | E2E |
|
|
||||||
| 7 | 90-day-old audit rows moved to cold tier; queryable via explicit historical query | Integration |
|
|
||||||
| 8 | Runbook steps validated: an operator following the runbook can onboard, rotate, and revoke | Manual checklist |
|
|
||||||
|
|
||||||
**Estimated budget:** ~25K tokens
|
|
||||||
**Risk notes:** This is the security-critical milestone. Budget review time here is non-negotiable — plan for two independent code reviews (internal + security-focused) before merge.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Total Budget & Timeline Sketch
|
|
||||||
|
|
||||||
| Milestone | Tokens (est.) | Can parallelize? |
|
|
||||||
| --------- | ------------- | ---------------------- |
|
|
||||||
| M1 | 20K | No (foundation) |
|
|
||||||
| M2 | 30K | No (needs M1) |
|
|
||||||
| M3 | 40K | No (needs M2) |
|
|
||||||
| M4 | 20K | No (needs M3) |
|
|
||||||
| M5 | 20K | Yes (with M6 after M4) |
|
|
||||||
| M6 | 20K | Yes (with M5 after M3) |
|
|
||||||
| M7 | 25K | No (needs all) |
|
|
||||||
| **Total** | **~175K** | |
|
|
||||||
|
|
||||||
Parallelization of M5 and M6 after M4 saves one milestone's worth of serial time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Exit Criteria (federation feature complete)
|
|
||||||
|
|
||||||
All of the following must be green on `main`:
|
|
||||||
|
|
||||||
- Every PRD §15 acceptance criterion automated and passing
|
|
||||||
- Every milestone's acceptance table green
|
|
||||||
- Security review sign-off on M7
|
|
||||||
- Runbook walk-through completed by operator (not author)
|
|
||||||
- `mosaic doctor` recognizes federated tier and reports peer health accurately
|
|
||||||
- Two-gateway production deployment (woltje.com ↔ uscllc.com) operational for ≥7 days without incident
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Step After This Doc Is Approved
|
|
||||||
|
|
||||||
1. File tracking issues on `git.mosaicstack.dev/mosaicstack/stack` — one per milestone, labeled `epic:federation`
|
|
||||||
2. Populate `docs/TASKS.md` with M1's task breakdown (per-task agent assignment, budget, dependencies)
|
|
||||||
3. Begin M1 implementation
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# Mission Manifest — Federation v1
|
|
||||||
|
|
||||||
> Persistent document tracking full mission scope, status, and session history.
|
|
||||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
**ID:** federation-v1-20260419
|
|
||||||
**Statement:** Jarvis operates across 3–4 workstations in two physical locations (home, USC). The user currently reaches back to a single jarvis-brain checkout from every session; a prior OpenBrain attempt caused cache, latency, and opacity pain. This mission builds asymmetric federation between Mosaic Stack gateways so that a session on a user's home gateway can query their work gateway in real time without data ever persisting across the boundary, with full multi-tenant isolation and standard-PKI (X.509 / Step-CA) trust management.
|
|
||||||
**Phase:** Planning complete — M1 implementation not started
|
|
||||||
**Current Milestone:** FED-M1
|
|
||||||
**Progress:** 0 / 7 milestones
|
|
||||||
**Status:** active
|
|
||||||
**Last Updated:** 2026-04-19 (PRD + MILESTONES + tracking issues filed)
|
|
||||||
**Parent Mission:** None — new mission
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Federation is the solution to what originally drove OpenBrain. The prior attempt coupled every agent session to a remote service, introduced cache/latency/opacity pain, and created a hard dependency that punished offline use. This redesign:
|
|
||||||
|
|
||||||
1. Makes federation **gateway-to-gateway**, not agent-to-service
|
|
||||||
2. Keeps each user's home instance as source of truth for their data
|
|
||||||
3. Exposes scoped, read-only data on demand without persisting across the boundary
|
|
||||||
4. Uses X.509 mTLS via Step-CA so rotation/revocation/CRL/OCSP are standard
|
|
||||||
5. Supports multi-tenant serving sides (employees on uscllc.com each federating back to their own home gateway) with no cross-user leakage
|
|
||||||
6. Requires federation-tier instances on both sides (PG + pgvector + Valkey) — local/standalone tiers cannot federate
|
|
||||||
7. Works over public HTTPS (no VPN required); Tailscale is an optional overlay
|
|
||||||
|
|
||||||
Key design references:
|
|
||||||
|
|
||||||
- `docs/federation/PRD.md` — 16-section product requirements
|
|
||||||
- `docs/federation/MILESTONES.md` — 7-milestone decomposition with per-milestone acceptance tests
|
|
||||||
- `docs/federation/TASKS.md` — per-task breakdown (M1 populated; M2-M7 deferred to mission planning)
|
|
||||||
- `docs/research/mempalace-evaluation/` (in jarvis-brain) — why we didn't adopt MemPalace
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- [ ] AC-1: Two Mosaic Stack gateways on different hosts can establish a federation grant via CLI-driven onboarding
|
|
||||||
- [ ] AC-2: Server A can query Server B for `tasks`, `notes`, `memory` respecting scope filters
|
|
||||||
- [ ] AC-3: User on B with no grant cannot be queried by A, even if A has a valid grant for another user (cross-user isolation)
|
|
||||||
- [ ] AC-4: Revoking a grant on B causes A's next request to fail with a clear error within one request cycle
|
|
||||||
- [ ] AC-5: Cert rotation happens automatically at T-7 days; in-progress session survives rotation without user action
|
|
||||||
- [ ] AC-6: Rate-limit enforcement returns 429 with `Retry-After`; client backs off
|
|
||||||
- [ ] AC-7: With B unreachable, a session on A completes using local data and surfaces "federation offline for `<peer>`" once per session
|
|
||||||
- [ ] AC-8: Every federated request appears in B's `federation_audit_log` within 1 second
|
|
||||||
- [ ] AC-9: Scope excluding `credentials` means credentials are never returned — even via `search` with matching keywords
|
|
||||||
- [ ] AC-10: `mosaic federation status` shows cert expiry, grant status, last success/failure per peer
|
|
||||||
- [ ] AC-11: Full 3-employee multi-tenant scenario passes with no cross-user leakage
|
|
||||||
- [ ] AC-12: Two-gateway production deployment (woltje.com ↔ uscllc.com) operational ≥7 days without incident
|
|
||||||
- [ ] AC-13: All 7 milestones ship as merged PRs with green CI and closed issues
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
|
||||||
| --- | ------ | --------------------------------------------- | ----------- | ------ | ----- | ------- | --------- |
|
|
||||||
| 1 | FED-M1 | Federated tier infrastructure | not-started | — | #460 | — | — |
|
|
||||||
| 2 | FED-M2 | Step-CA + grant schema + admin CLI | not-started | — | #461 | — | — |
|
|
||||||
| 3 | FED-M3 | mTLS handshake + list/get + scope enforcement | not-started | — | #462 | — | — |
|
|
||||||
| 4 | FED-M4 | search verb + audit log + rate limit | not-started | — | #463 | — | — |
|
|
||||||
| 5 | FED-M5 | Cache + offline degradation + OTEL | not-started | — | #464 | — | — |
|
|
||||||
| 6 | FED-M6 | Revocation + auto-renewal + CRL | not-started | — | #465 | — | — |
|
|
||||||
| 7 | FED-M7 | Multi-user RBAC hardening + acceptance suite | not-started | — | #466 | — | — |
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
| Milestone | Est. tokens | Parallelizable? |
|
|
||||||
| --------- | ----------- | ---------------------- |
|
|
||||||
| FED-M1 | 20K | No (foundation) |
|
|
||||||
| FED-M2 | 30K | No (needs M1) |
|
|
||||||
| FED-M3 | 40K | No (needs M2) |
|
|
||||||
| FED-M4 | 20K | No (needs M3) |
|
|
||||||
| FED-M5 | 20K | Yes (with M6 after M4) |
|
|
||||||
| FED-M6 | 20K | Yes (with M5 after M3) |
|
|
||||||
| FED-M7 | 25K | No (needs all) |
|
|
||||||
| **Total** | **~175K** | |
|
|
||||||
|
|
||||||
## Session History
|
|
||||||
|
|
||||||
| Session | Date | Runtime | Outcome |
|
|
||||||
| ------- | ---------- | ------- | --------------------------------------------------- |
|
|
||||||
| S1 | 2026-04-19 | claude | PRD authored, MILESTONES decomposed, 7 issues filed |
|
|
||||||
|
|
||||||
## Next Step
|
|
||||||
|
|
||||||
Begin FED-M1 implementation: federated tier infrastructure. Breakdown in `docs/federation/TASKS.md`.
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
# Mosaic Stack — Federation PRD
|
|
||||||
|
|
||||||
**Status:** Draft v1 (locked for implementation)
|
|
||||||
**Owner:** Jason
|
|
||||||
**Date:** 2026-04-19
|
|
||||||
**Scope:** Enables cross-instance data federation between Mosaic Stack gateways with asymmetric trust, multi-tenant scoping, and no cross-boundary data persistence.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Problem Statement
|
|
||||||
|
|
||||||
Jarvis operates across 3–4 workstations in two physical locations (home, USC). The user currently reaches back to a single jarvis-brain checkout from every session, and has tried OpenBrain to solve cross-session state — with poor results (cache invalidation, latency, opacity, hard dependency on a remote service).
|
|
||||||
|
|
||||||
The goal is a federation model where each user's **home instance** remains the source of truth for their personal data, and **work/shared instances** expose scoped data to that user's home instance on demand — without persisting anything across the boundary.
|
|
||||||
|
|
||||||
## 2. Goals
|
|
||||||
|
|
||||||
1. A user logged into their **home gateway** (Server A) can query their **work gateway** (Server B) in real time during a session.
|
|
||||||
2. Data returned from Server B is used in-session only; never written to Server A storage.
|
|
||||||
3. Server B has multiple users, each with their own Server A. No user's data leaks to another user.
|
|
||||||
4. Federation works over public HTTPS (no VPN required). Tailscale is a supported optional overlay.
|
|
||||||
5. Sync latency target: seconds, or at the next data need of the agent.
|
|
||||||
6. Graceful degradation: if the remote instance is unreachable, the local session continues with local data and a clear "federation offline" signal.
|
|
||||||
7. Teams exist on both sides. A federation grant can share **team-owned** data without exposing other team members' personal data.
|
|
||||||
8. Auth and revocation use standard PKI (X.509) so that certificate tooling (Step-CA, rotation, OCSP, CRL) is available out of the box.
|
|
||||||
|
|
||||||
## 3. Non-Goals (v1)
|
|
||||||
|
|
||||||
- Mesh federation (N-to-N). v1 is strictly A↔B pairs.
|
|
||||||
- Cross-instance writes. All federation is **read-only** on the remote side.
|
|
||||||
- Shared agent sessions across instances. Sessions live on one instance; federation is data-plane only.
|
|
||||||
- Cross-instance SSO. Each instance owns its own BetterAuth identity store; federation is service-to-service, not user-to-user.
|
|
||||||
- Realtime push from B→A. v1 is pull-only (A pulls from B during a session).
|
|
||||||
- Global search index. Federation is query-by-query, not index replication.
|
|
||||||
|
|
||||||
## 4. User Stories
|
|
||||||
|
|
||||||
- **US-1 (Solo user at home):** As the sole user on Server A, I want my agent session on workstation-1 to see the same data it saw on workstation-2, without running OpenBrain.
|
|
||||||
- **US-2 (Cross-location):** As a user with a home server and a work server, I want a session on my home laptop to transparently pull my USC-owned tasks/notes when I ask for them.
|
|
||||||
- **US-3 (Work admin):** As the admin of mosaic.uscllc.com, I want to grant each employee's home gateway scoped read access to only their own data plus explicitly-shared team data.
|
|
||||||
- **US-4 (Privacy boundary):** As employee A on mosaic.uscllc.com, my data must never appear in a session on employee B's home gateway — even if both are federated with uscllc.com.
|
|
||||||
- **US-5 (Revocation):** As a work admin, when I delete an employee, their home gateway loses access within one request cycle.
|
|
||||||
- **US-6 (Offline):** As a user in a hotel with flaky wifi, my local session keeps working; federation calls fail fast and are reported as "offline," not hung.
|
|
||||||
|
|
||||||
## 5. Architecture Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐ mTLS / X.509 ┌─────────────────────────────────────┐
|
|
||||||
│ Server A — mosaic.woltje.com │ ───────────────────────► │ Server B — mosaic.uscllc.com │
|
|
||||||
│ (home, master for Jason) │ ◄── JSON over HTTPS │ (work, multi-tenant) │
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ │
|
|
||||||
│ │ Gateway │ │ Postgres │ │ │ │ Gateway │ │ Postgres │ │
|
|
||||||
│ │ (NestJS) │──│ (local SSOT)│ │ │ │ (NestJS) │──│ (tenant SSOT)│ │
|
|
||||||
│ └──────┬───────┘ └──────────────┘ │ │ └──────┬───────┘ └──────────────┘ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ FederationClient │ │ │ FederationServer │
|
|
||||||
│ │ (outbound, scoped query) │ │ │ (inbound, RBAC-gated) │
|
|
||||||
│ └───────────────────────────┼──────────────────────────┼────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ Step-CA (issues A's client cert) │ │ Step-CA (issues B's server cert, │
|
|
||||||
│ │ │ trusts A's CA root on grant)│
|
|
||||||
└─────────────────────────────────────┘ └──────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- Federation is a **transport-layer** concern between two gateways, implemented as a new internal module on each gateway.
|
|
||||||
- Both sides run the same code. Direction (client vs. server role) is per-request.
|
|
||||||
- Nothing in the agent runtime changes — agents query the gateway; the gateway decides local vs. remote.
|
|
||||||
|
|
||||||
## 6. Transport & Authentication
|
|
||||||
|
|
||||||
**Transport:** HTTPS with mutual TLS (mTLS).
|
|
||||||
|
|
||||||
**Identity:** X.509 client certificates issued by Step-CA. Each federation grant materializes as a client cert on the requesting side and a trust-anchor entry (CA root or explicit cert) on the serving side.
|
|
||||||
|
|
||||||
**Why mTLS over HMAC bearer tokens:**
|
|
||||||
|
|
||||||
- Standard rotation/revocation semantics (renew, CRL, OCSP).
|
|
||||||
- The cert subject carries identity claims (user, grant_id) that don't need a separate DB lookup to verify authenticity.
|
|
||||||
- Client certs never transit request bodies, so they can't be logged by accident.
|
|
||||||
- Transport is pinned at the TLS layer, not re-validated per-handler.
|
|
||||||
|
|
||||||
**Cert contents (SAN + subject):**
|
|
||||||
|
|
||||||
- `CN=grant-<uuid>`
|
|
||||||
- `O=<requesting-server-hostname>` (e.g., `mosaic.woltje.com`)
|
|
||||||
- Custom OIDs embedded in SAN otherName:
|
|
||||||
- `mosaic.federation.grantId` (UUID)
|
|
||||||
- `mosaic.federation.subjectUserId` (user on the **serving** side that this grant acts-as)
|
|
||||||
- Default lifetime: **30 days**, with auto-renewal at T-7 days if the grant is still active.
|
|
||||||
|
|
||||||
**Step-CA topology (v1):** Each server runs its own Step-CA instance. During onboarding, the serving side imports the requesting side's CA root. A central/shared Step-CA is out of scope for v1.
|
|
||||||
|
|
||||||
**Handshake:**
|
|
||||||
|
|
||||||
1. Client (A) opens HTTPS to B with its grant cert.
|
|
||||||
2. B validates cert chain against trusted CA roots for that grant.
|
|
||||||
3. B extracts `grantId` and `subjectUserId` from the cert.
|
|
||||||
4. B loads the grant record, checks it is `active`, not revoked, and not expired.
|
|
||||||
5. B enforces scope and rate-limit for this grant.
|
|
||||||
6. Request proceeds; response returned.
|
|
||||||
|
|
||||||
## 7. Data Model
|
|
||||||
|
|
||||||
All tables live on **each instance's own Postgres**. Federation grants are bilateral — each side has a record of the grant.
|
|
||||||
|
|
||||||
### 7.1 `federation_grants` (on serving side, Server B)
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
| --------------------------- | ----------- | ------------------------------------------------- |
|
|
||||||
| `id` | uuid PK | |
|
|
||||||
| `subject_user_id` | uuid FK | Which local user this grant acts-as |
|
|
||||||
| `requesting_server` | text | Hostname of requesting gateway (e.g., woltje.com) |
|
|
||||||
| `requesting_ca_fingerprint` | text | SHA-256 of trusted CA root |
|
|
||||||
| `active_cert_fingerprint` | text | SHA-256 of currently valid client cert |
|
|
||||||
| `scope` | jsonb | See §8 |
|
|
||||||
| `rate_limit_rpm` | int | Default 60 |
|
|
||||||
| `status` | enum | `pending`, `active`, `suspended`, `revoked` |
|
|
||||||
| `created_at` | timestamptz | |
|
|
||||||
| `activated_at` | timestamptz | |
|
|
||||||
| `revoked_at` | timestamptz | |
|
|
||||||
| `last_used_at` | timestamptz | |
|
|
||||||
| `notes` | text | Admin-visible description |
|
|
||||||
|
|
||||||
### 7.2 `federation_peers` (on requesting side, Server A)
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
| --------------------- | ----------- | ------------------------------------------------ |
|
|
||||||
| `id` | uuid PK | |
|
|
||||||
| `peer_hostname` | text | e.g., `mosaic.uscllc.com` |
|
|
||||||
| `peer_ca_fingerprint` | text | SHA-256 of peer's CA root |
|
|
||||||
| `grant_id` | uuid | The grant ID assigned by the peer |
|
|
||||||
| `local_user_id` | uuid FK | Who on Server A this federation belongs to |
|
|
||||||
| `client_cert_pem` | text (enc) | Current client cert (PEM); rotated automatically |
|
|
||||||
| `client_key_pem` | text (enc) | Private key (encrypted at rest) |
|
|
||||||
| `cert_expires_at` | timestamptz | |
|
|
||||||
| `status` | enum | `pending`, `active`, `degraded`, `revoked` |
|
|
||||||
| `last_success_at` | timestamptz | |
|
|
||||||
| `last_failure_at` | timestamptz | |
|
|
||||||
| `notes` | text | |
|
|
||||||
|
|
||||||
### 7.3 `federation_audit_log` (on serving side, Server B)
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
| ------------- | ----------- | ------------------------------------------------ |
|
|
||||||
| `id` | uuid PK | |
|
|
||||||
| `grant_id` | uuid FK | |
|
|
||||||
| `occurred_at` | timestamptz | indexed |
|
|
||||||
| `verb` | text | `query`, `handshake`, `rejected`, `rate_limited` |
|
|
||||||
| `resource` | text | e.g., `tasks`, `notes`, `credentials` |
|
|
||||||
| `query_hash` | text | SHA-256 of normalized query (no payload stored) |
|
|
||||||
| `outcome` | text | `ok`, `denied`, `error` |
|
|
||||||
| `bytes_out` | int | |
|
|
||||||
| `latency_ms` | int | |
|
|
||||||
|
|
||||||
**Audit policy:** Every federation request is logged on the serving side. Read-only requests only — no body capture. Retention: 90 days hot, then roll to cold storage.
|
|
||||||
|
|
||||||
## 8. RBAC & Scope
|
|
||||||
|
|
||||||
Every federation grant has a scope object that answers three questions for every inbound request:
|
|
||||||
|
|
||||||
1. **Who is acting?** — `subject_user_id` from the cert.
|
|
||||||
2. **What resources?** — an allowlist of resource types (`tasks`, `notes`, `credentials`, `memory`, `teams/:id/tasks`, …).
|
|
||||||
3. **Filter expression** — predicates applied on top of the subject's normal RBAC (see below).
|
|
||||||
|
|
||||||
### 8.1 Scope schema
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"resources": ["tasks", "notes", "memory"],
|
|
||||||
"filters": {
|
|
||||||
"tasks": { "include_teams": ["team_uuid_1", "team_uuid_2"], "include_personal": true },
|
|
||||||
"notes": { "include_personal": true, "include_teams": [] },
|
|
||||||
"memory": { "include_personal": true }
|
|
||||||
},
|
|
||||||
"excluded_resources": ["credentials", "api_keys"],
|
|
||||||
"max_rows_per_query": 500
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 Access rule (enforced on serving side)
|
|
||||||
|
|
||||||
For every inbound federated query on resource R:
|
|
||||||
|
|
||||||
1. Resolve effective identity → `subject_user_id`.
|
|
||||||
2. Check R is in `scope.resources` and NOT in `scope.excluded_resources`. Otherwise 403.
|
|
||||||
3. Evaluate the user's **normal RBAC** (what would they see if they logged into Server B directly)?
|
|
||||||
4. Intersect with the scope filter (e.g., only team X, only personal).
|
|
||||||
5. Apply `max_rows_per_query`.
|
|
||||||
6. Return; log to audit.
|
|
||||||
|
|
||||||
### 8.3 Team boundary guarantees
|
|
||||||
|
|
||||||
- Scope filters are additive, never subtractive of the native RBAC. A grant cannot grant access the user would not have had themselves.
|
|
||||||
- `include_teams` means "only these teams," not "these teams in addition to all teams."
|
|
||||||
- `include_personal: false` hides the user's personal data entirely from federation, even if they own it — useful for work-only accounts.
|
|
||||||
|
|
||||||
### 8.4 No cross-user leakage
|
|
||||||
|
|
||||||
When Server B has multiple users (employees) all federating back to their own Server A:
|
|
||||||
|
|
||||||
- Each employee has their own grant with their own `subject_user_id`.
|
|
||||||
- The cert is bound to a specific grant; there is no mechanism by which one grant's cert can be used to impersonate another.
|
|
||||||
- Audit log is per-grant.
|
|
||||||
|
|
||||||
## 9. Query Model
|
|
||||||
|
|
||||||
Federation exposes a **narrow read API**, not arbitrary SQL.
|
|
||||||
|
|
||||||
### 9.1 Supported verbs (v1)
|
|
||||||
|
|
||||||
| Verb | Purpose | Returns |
|
|
||||||
| -------------- | ------------------------------------------ | ------------------------------- |
|
|
||||||
| `list` | Paginated list of a resource type | Array of resources |
|
|
||||||
| `get` | Fetch a single resource by id | One resource or 404 |
|
|
||||||
| `search` | Keyword search within allowed resources | Ranked list of hits |
|
|
||||||
| `capabilities` | What this grant is allowed to do right now | Scope object + rate-limit state |
|
|
||||||
|
|
||||||
### 9.2 Not in v1
|
|
||||||
|
|
||||||
- Write verbs.
|
|
||||||
- Aggregations / analytics.
|
|
||||||
- Streaming / subscriptions (future: see §13).
|
|
||||||
|
|
||||||
### 9.3 Agent-facing integration
|
|
||||||
|
|
||||||
Agents never call federation directly. Instead:
|
|
||||||
|
|
||||||
- The gateway query layer accepts `source: "local" | "federated:<peer_hostname>" | "all"`.
|
|
||||||
- `"all"` fans out in parallel, merges results, tags each with `_source`.
|
|
||||||
- Federation results are in-memory only; the gateway does not persist them.
|
|
||||||
|
|
||||||
## 10. Caching
|
|
||||||
|
|
||||||
- **In-memory response cache** with short TTL (default 30s) for `list` and `get`. `search` is not cached.
|
|
||||||
- Cache is keyed by `(grant_id, verb, resource, query_hash)`.
|
|
||||||
- Cache is flushed on cert rotation and on grant revocation.
|
|
||||||
- No disk cache. No cross-session cache.
|
|
||||||
|
|
||||||
## 11. Bootstrap & Onboarding
|
|
||||||
|
|
||||||
### 11.1 Instance capability tiers
|
|
||||||
|
|
||||||
| Tier | Storage | Queue | Memory | Can federate? |
|
|
||||||
| ------------ | -------- | ------- | -------- | --------------------- |
|
|
||||||
| `local` | PGlite | in-proc | keyword | No |
|
|
||||||
| `standalone` | Postgres | Valkey | keyword | No (can be client) |
|
|
||||||
| `federated` | Postgres | Valkey | pgvector | Yes (server + client) |
|
|
||||||
|
|
||||||
Federation requires `federated` tier on **both** sides.
|
|
||||||
|
|
||||||
### 11.2 Onboarding flow (admin-driven)
|
|
||||||
|
|
||||||
1. Admin on Server B runs `mosaic federation grant create --user <user-id> --peer <peer-hostname> --scope-file scope.json`.
|
|
||||||
2. Server B generates a `grant_id`, prints a one-time enrollment URL containing the grant ID + B's CA root fingerprint.
|
|
||||||
3. Admin on Server A (or the user themselves, if allowed) runs `mosaic federation peer add <enrollment-url>`.
|
|
||||||
4. Server A's Step-CA generates a CSR for the new grant. A submits the CSR to B over a short-lived enrollment endpoint (single-use token in the enrollment URL).
|
|
||||||
5. B's Step-CA signs the cert (with grant ID embedded in SAN OIDs), returns it.
|
|
||||||
6. A stores the signed cert + private key (encrypted) in `federation_peers`.
|
|
||||||
7. Grant status flips from `pending` to `active` on both sides.
|
|
||||||
8. Cert auto-renews at T-7 days using the standard Step-CA renewal flow as long as the grant remains active.
|
|
||||||
|
|
||||||
### 11.3 Revocation
|
|
||||||
|
|
||||||
- **Admin-initiated:** `mosaic federation grant revoke <grant-id>` on B flips status to `revoked`, adds the cert to B's CRL, and writes an audit entry.
|
|
||||||
- **Revoke-on-delete:** Deleting a user on B automatically revokes all grants where that user is the subject.
|
|
||||||
- Server A learns of revocation on the next request (TLS handshake fails) and flips the peer to `revoked`.
|
|
||||||
|
|
||||||
### 11.4 Rate limit
|
|
||||||
|
|
||||||
Default `60 req/min` per grant. Configurable per grant. Enforced at the serving side. A rate-limited request returns `429` with `Retry-After`.
|
|
||||||
|
|
||||||
## 12. Operational Concerns
|
|
||||||
|
|
||||||
- **Observability:** Each federation request emits an OTEL span with `grant_id`, `peer`, `verb`, `resource`, `outcome`, `latency_ms`. Traces correlate across both servers via W3C traceparent.
|
|
||||||
- **Health check:** `mosaic federation status` on each side shows active grants, last-success times, cert expirations, and any CRL mismatches.
|
|
||||||
- **Backpressure:** If the serving side is overloaded, it returns `503` with a structured body; the client marks the peer `degraded` and falls back to local-only until the next successful handshake.
|
|
||||||
- **Secrets:** `client_key_pem` in `federation_peers` is encrypted with the gateway's key (sealed with the instance's master key — same mechanism as `provider_credentials`).
|
|
||||||
- **Credentials never cross:** The `credentials` resource type is in the default excluded list. It must be explicitly added to scope (admin action, logged) and even then is per-grant and per-user.
|
|
||||||
|
|
||||||
## 13. Future (post-v1)
|
|
||||||
|
|
||||||
- B→A push (e.g., "notify A when a task assigned to subject changes") via Socket.IO over mTLS.
|
|
||||||
- Mesh (N-to-N) federation.
|
|
||||||
- Write verbs with conflict resolution.
|
|
||||||
- Shared Step-CA (a "root of roots") so that onboarding doesn't require exchanging CA roots.
|
|
||||||
- Federated memory search over vector indexes with homomorphic filtering.
|
|
||||||
|
|
||||||
## 14. Locked Decisions (was "Open Questions")
|
|
||||||
|
|
||||||
| # | Question | Decision |
|
|
||||||
| --- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 1 | What happens to a grant when its subject user is deleted? | **Revoke-on-delete.** All grants where the user is subject are auto-revoked and CRL'd. |
|
|
||||||
| 2 | Do we audit read-only requests? | **Yes.** All federated reads are audited on the serving side. Bodies are not captured; query hash + metadata only. |
|
|
||||||
| 3 | Default rate limit? | **60 requests per minute per grant,** override-able per grant. |
|
|
||||||
| 4 | How do we verify the requesting-server's identity beyond the grant token? | **X.509 client cert tied to the user,** issued by Step-CA (per-server) or locally generated. Cert subject carries `grantId` + `subjectUserId`. |
|
|
||||||
|
|
||||||
### M1 decisions
|
|
||||||
|
|
||||||
- **Postgres deployment:** **Containerized** alongside the gateway in M1 (Docker Compose profile). Moving to a dedicated host is a M5+ operational concern, not a v1 feature.
|
|
||||||
- **Instance signing key:** **Separate** from the Step-CA key. Step-CA signs federation certs; the instance master key seals at-rest secrets (client keys, provider credentials). Different blast-radius, different rotation cadences.
|
|
||||||
|
|
||||||
## 15. Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] Two Mosaic Stack gateways on different hosts can establish a federation grant via the CLI-driven onboarding flow.
|
|
||||||
- [ ] Server A can query Server B for `tasks`, `notes`, `memory` respecting scope filters.
|
|
||||||
- [ ] A user on B with no grant cannot be queried by A, even if A has a valid grant for another user.
|
|
||||||
- [ ] Revoking a grant on B causes A's next request to fail with a clear error within one request cycle.
|
|
||||||
- [ ] Cert rotation happens automatically at T-7 days; an in-progress session survives rotation without user action.
|
|
||||||
- [ ] Rate-limit enforcement returns 429 with `Retry-After`; client backs off.
|
|
||||||
- [ ] With B unreachable, a session on A completes using local data and surfaces a "federation offline for `<peer>`" signal once.
|
|
||||||
- [ ] Every federated request appears in B's `federation_audit_log` within 1 second.
|
|
||||||
- [ ] A scope excluding `credentials` means credentials are not returnable even via `search` with matching keywords.
|
|
||||||
- [ ] `mosaic federation status` shows cert expiry, grant status, and last success/failure per peer.
|
|
||||||
|
|
||||||
## 16. Implementation Milestones (reference)
|
|
||||||
|
|
||||||
Milestones live in `docs/federation/MILESTONES.md` (to be authored next). High-level:
|
|
||||||
|
|
||||||
- **M1:** Server A runs `federated` tier standalone (Postgres + Valkey + pgvector, containerized). No peer yet.
|
|
||||||
- **M2:** Step-CA embedded; `federation_grants` / `federation_peers` schema + admin CLI.
|
|
||||||
- **M3:** Handshake + `list`/`get` verbs with scope enforcement.
|
|
||||||
- **M4:** `search` verb, audit log, rate limits.
|
|
||||||
- **M5:** Cache layer, offline-degradation UX, observability surfaces.
|
|
||||||
- **M6:** Revocation flows (admin + revoke-on-delete), cert auto-renewal.
|
|
||||||
- **M7:** Multi-user RBAC hardening on B, team-scoped grants end-to-end, acceptance suite green.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Next step after PRD sign-off:** author `docs/federation/MILESTONES.md` with per-milestone acceptance tests and estimated token budget, then file tracking issues on `git.mosaicstack.dev/mosaicstack/stack`.
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Tasks — Federation v1
|
|
||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
|
||||||
>
|
|
||||||
> **Mission:** federation-v1-20260419
|
|
||||||
> **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` | `glm-5.1` | `haiku` | `sonnet` | `opus` | `—` (auto)
|
|
||||||
>
|
|
||||||
> **Scope of this file:** M1 is fully decomposed below. M2–M7 are placeholders pending each milestone's entry into active planning — the orchestrator expands them one milestone at a time to avoid speculative decomposition of work whose shape will depend on what M1 surfaces.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone 1 — Federated tier infrastructure (FED-M1)
|
|
||||||
|
|
||||||
Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress.
|
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
|
||||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------- | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| FED-M1-01 | not-started | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | codex | feat/federation-m1-tier-config | — | 4K | Schema lives in `packages/types`; validator in gateway bootstrap. No behavior change yet — enum only. |
|
|
||||||
| FED-M1-02 | not-started | Author `docker-compose.federated.yml` as an overlay profile: Postgres 16 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | codex | feat/federation-m1-compose | FED-M1-01 | 5K | Overlay on existing `docker-compose.yml`; no changes to base file. Add `profile: federated` gating. |
|
|
||||||
| FED-M1-03 | not-started | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | codex | feat/federation-m1-pgvector | FED-M1-02 | 8K | Extension create is idempotent `CREATE EXTENSION IF NOT EXISTS vector`. Gate on tier = federated. |
|
|
||||||
| FED-M1-04 | not-started | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | codex | feat/federation-m1-detector | FED-M1-03 | 8K | Structured error type with remediation hints. Logs which service failed, with host:port attempted. |
|
|
||||||
| FED-M1-05 | not-started | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | codex | feat/federation-m1-migrate | FED-M1-04 | 10K | Do NOT run automatically. CLI subcommand `mosaic migrate tier --to federated --dry-run`. Safety rails. |
|
|
||||||
| FED-M1-06 | not-started | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Existing doctor output evolves; add `--json` flag. Green/yellow/red + remediation suggestions per issue. |
|
|
||||||
| FED-M1-07 | not-started | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Vitest + docker-compose test profile. One test file per assertion; real services, no mocks. |
|
|
||||||
| FED-M1-08 | not-started | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Runs against docker-compose federated profile; uses temp PGlite file; deterministic seed. |
|
|
||||||
| FED-M1-09 | not-started | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | haiku | feat/federation-m1-regression | FED-M1-07 | 4K | Reuse existing e2e harness; just re-point at the federation branch build. Canary that we didn't break it. |
|
|
||||||
| FED-M1-10 | not-started | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | — | FED-M1-09 | 8K | Use `feature-dev:code-reviewer` agent. Specifically: no secrets in error messages; no partial-migration footguns. |
|
|
||||||
| FED-M1-11 | not-started | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Short, actionable. Link from MISSION-MANIFEST. No decisions captured here — those belong in PRD. |
|
|
||||||
| FED-M1-12 | not-started | PR, CI green, merge to main, close #460. | #460 | — | (aggregate) | FED-M1-11 | 3K | Queue-guard before push; wait for green; merge squashed; tea `issue-close` #460. |
|
|
||||||
|
|
||||||
**M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below)
|
|
||||||
|
|
||||||
**Why over-budget:** PRD's 20K estimate reflected implementation complexity only. The per-task breakdown includes tests, review, and docs as separate tasks per the delivery cycle, which catches the real cost. The final per-milestone budgets in MISSION-MANIFEST will be updated after M1 completes with actuals.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone 2 — Step-CA + grant schema + admin CLI (FED-M2)
|
|
||||||
|
|
||||||
_Deferred to mission planning when M1 is complete. Issue #461 tracks scope._
|
|
||||||
|
|
||||||
## Milestone 3 — mTLS handshake + list/get + scope enforcement (FED-M3)
|
|
||||||
|
|
||||||
_Deferred. Issue #462._
|
|
||||||
|
|
||||||
## Milestone 4 — search + audit + rate limit (FED-M4)
|
|
||||||
|
|
||||||
_Deferred. Issue #463._
|
|
||||||
|
|
||||||
## Milestone 5 — cache + offline + OTEL (FED-M5)
|
|
||||||
|
|
||||||
_Deferred. Issue #464._
|
|
||||||
|
|
||||||
## Milestone 6 — revocation + auto-renewal + CRL (FED-M6)
|
|
||||||
|
|
||||||
_Deferred. Issue #465._
|
|
||||||
|
|
||||||
## Milestone 7 — multi-user hardening + acceptance suite (FED-M7)
|
|
||||||
|
|
||||||
_Deferred. Issue #466._
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution Notes
|
|
||||||
|
|
||||||
**Agent assignment rationale:**
|
|
||||||
|
|
||||||
- `codex` for most implementation tasks (OpenAI credit pool preferred for feature code)
|
|
||||||
- `sonnet` for tests (pattern-based, moderate complexity), `doctor` work (cross-cutting), and independent code review
|
|
||||||
- `haiku` for docs and the standalone regression canary (cheapest tier for mechanical/verification work)
|
|
||||||
- No `opus` in M1 — save for cross-cutting architecture decisions if they surface later
|
|
||||||
|
|
||||||
**Branch strategy:** Each task gets its own feature branch off `main`. Tasks within a milestone merge in dependency order. Final aggregate PR (FED-M1-12) isn't a branch of its own — it's the merge of the last upstream task that closes the issue.
|
|
||||||
|
|
||||||
**Queue guard:** Every push and every merge in this mission must run `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge` per Mosaic hard gate #6.
|
|
||||||
@@ -165,13 +165,7 @@ The `mosaic` CLI provides a terminal interface to the same gateway API.
|
|||||||
Install via the Mosaic installer:
|
Install via the Mosaic installer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
|
||||||
|
|
||||||
Or use the direct URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
||||||
|
|||||||
@@ -107,67 +107,3 @@ Sequencing: strict. M01 ships first as a hotfix release (mosaic-v0.0.26). M02 is
|
|||||||
1. Create Gitea issues for M01, M02, M03
|
1. Create Gitea issues for M01, M02, M03
|
||||||
2. Open the mission-scaffold docs PR (same pattern as parent mission's PR #430)
|
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
|
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.
|
|
||||||
@@ -73,27 +73,6 @@ Spawn a worker instead. No exceptions. No "quick fixes."
|
|||||||
- Wait for at least one worker to complete before spawning more
|
- Wait for at least one worker to complete before spawning more
|
||||||
- This optimizes token usage and reduces context pressure
|
- 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
|
## Delegation Mode Selection
|
||||||
|
|
||||||
Choose one delegation mode at session start:
|
Choose one delegation mode at session start:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/agent"
|
"directory": "packages/agent"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/auth"
|
"directory": "packages/auth"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/brain"
|
"directory": "packages/brain"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/config"
|
"directory": "packages/config"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/coord"
|
"directory": "packages/coord"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/db"
|
"directory": "packages/db"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/design-tokens"
|
"directory": "packages/design-tokens"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/forge"
|
"directory": "packages/forge"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/log"
|
"directory": "packages/log"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/macp"
|
"directory": "packages/macp"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/memory"
|
"directory": "packages/memory"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { runWizard } from '../../src/wizard.js';
|
|||||||
describe('Full Wizard (headless)', () => {
|
describe('Full Wizard (headless)', () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
||||||
@@ -33,16 +32,12 @@ describe('Full Wizard (headless)', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
process.env = { ...originalEnv };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('quick start produces valid SOUL.md', async () => {
|
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({
|
const prompter = new HeadlessPrompter({
|
||||||
'Installation mode': 'quick',
|
'Installation mode': 'quick',
|
||||||
|
'What name should agents use?': 'TestBot',
|
||||||
'Communication style': 'direct',
|
'Communication style': 'direct',
|
||||||
'Your name': 'Tester',
|
'Your name': 'Tester',
|
||||||
'Your pronouns': 'They/Them',
|
'Your pronouns': 'They/Them',
|
||||||
@@ -67,10 +62,9 @@ describe('Full Wizard (headless)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('quick start produces valid USER.md', async () => {
|
it('quick start produces valid USER.md', async () => {
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
|
||||||
|
|
||||||
const prompter = new HeadlessPrompter({
|
const prompter = new HeadlessPrompter({
|
||||||
'Installation mode': 'quick',
|
'Installation mode': 'quick',
|
||||||
|
'What name should agents use?': 'TestBot',
|
||||||
'Communication style': 'direct',
|
'Communication style': 'direct',
|
||||||
'Your name': 'Tester',
|
'Your name': 'Tester',
|
||||||
'Your pronouns': 'He/Him',
|
'Your pronouns': 'He/Him',
|
||||||
|
|||||||
@@ -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).
|
**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
|
## 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.
|
- Do not load unrelated skills.
|
||||||
- Follow skill trigger rules from the active runtime instruction layer.
|
- 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
|
## Session Closure Requirement
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,14 @@ Universal agent standards layer for Claude Code, Codex, OpenCode, and Pi.
|
|||||||
|
|
||||||
One config, every runtime, same standards.
|
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
|
## Quick Install
|
||||||
|
|
||||||
### Mac / Linux
|
### Mac / Linux
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
|
||||||
|
|
||||||
Or use the direct URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows (PowerShell)
|
### Windows (PowerShell)
|
||||||
@@ -29,8 +23,8 @@ bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/
|
|||||||
### From Source (any platform)
|
### From Source (any platform)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@git.mosaicstack.dev:mosaicstack/stack.git ~/src/stack
|
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git ~/src/mosaic-stack
|
||||||
cd ~/src/stack && bash tools/install.sh
|
cd ~/src/mosaic-stack && bash tools/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer:
|
The installer:
|
||||||
@@ -151,19 +145,13 @@ mosaic upgrade check # Check upgrade status (no changes)
|
|||||||
Run the installer again — it handles upgrades automatically:
|
Run the installer again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
|
||||||
|
|
||||||
Or use the direct URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or from a local checkout:
|
Or from a local checkout:
|
||||||
|
|
||||||
```bash
|
```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.
|
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}"
|
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||||
|
|
||||||
# Files/dirs preserved across upgrades (never overwritten).
|
# Files preserved across upgrades (never overwritten)
|
||||||
# User-created content in these paths survives rsync --delete.
|
PRESERVE_PATHS=("SOUL.md" "USER.md" "TOOLS.md" "memory" "sources")
|
||||||
PRESERVE_PATHS=("AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
|
|
||||||
|
|
||||||
# Current framework schema version — bump this when the layout changes.
|
# Current framework schema version — bump this when the layout changes.
|
||||||
# The migration system uses this to run upgrade steps.
|
# The migration system uses this to run upgrade steps.
|
||||||
@@ -218,22 +217,8 @@ fi
|
|||||||
|
|
||||||
sync_framework
|
sync_framework
|
||||||
|
|
||||||
# Ensure persistent directories exist
|
# Ensure memory directory exists
|
||||||
mkdir -p "$TARGET_DIR/memory"
|
mkdir -p "$TARGET_DIR/memory"
|
||||||
mkdir -p "$TARGET_DIR/credentials"
|
|
||||||
|
|
||||||
# Seed defaults — copy from defaults/ to framework root if not already present.
|
|
||||||
# These are user-editable files that ship with sensible defaults but should
|
|
||||||
# never be overwritten once the user has customized them.
|
|
||||||
DEFAULTS_DIR="$TARGET_DIR/defaults"
|
|
||||||
if [[ -d "$DEFAULTS_DIR" ]]; then
|
|
||||||
for default_file in AGENTS.md STANDARDS.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
|
# Ensure tool scripts are executable
|
||||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
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).
|
`--scope local` = default, local-only (not committed).
|
||||||
|
|
||||||
Do NOT add `mcpServers` to `~/.claude/settings.json` — that key is ignored for MCP loading.
|
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.
|
|
||||||
|
|||||||
@@ -23,16 +23,6 @@
|
|||||||
"timeout": 60
|
"timeout": 60
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": "Edit|MultiEdit|Write",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "~/.config/mosaic/tools/qa/typecheck-hook.sh",
|
|
||||||
"timeout": 30
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ SKILLS_REPO_DIR="${MOSAIC_SKILLS_REPO_DIR:-$MOSAIC_HOME/sources/agent-skills}"
|
|||||||
MOSAIC_SKILLS_DIR="$MOSAIC_HOME/skills"
|
MOSAIC_SKILLS_DIR="$MOSAIC_HOME/skills"
|
||||||
MOSAIC_LOCAL_SKILLS_DIR="$MOSAIC_HOME/skills-local"
|
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
|
fetch=1
|
||||||
link_only=0
|
link_only=0
|
||||||
|
|
||||||
@@ -30,7 +25,6 @@ Env:
|
|||||||
MOSAIC_HOME Default: ~/.config/mosaic
|
MOSAIC_HOME Default: ~/.config/mosaic
|
||||||
MOSAIC_SKILLS_REPO_URL Default: https://git.mosaicstack.dev/mosaic/agent-skills.git
|
MOSAIC_SKILLS_REPO_URL Default: https://git.mosaicstack.dev/mosaic/agent-skills.git
|
||||||
MOSAIC_SKILLS_REPO_DIR Default: ~/.config/mosaic/sources/agent-skills
|
MOSAIC_SKILLS_REPO_DIR Default: ~/.config/mosaic/sources/agent-skills
|
||||||
MOSAIC_INSTALL_SKILLS Colon-separated list of skills to link (default: all)
|
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,27 +156,6 @@ link_targets=(
|
|||||||
|
|
||||||
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
|
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() {
|
link_skill_into_target() {
|
||||||
local skill_path="$1"
|
local skill_path="$1"
|
||||||
local target_dir="$2"
|
local target_dir="$2"
|
||||||
@@ -195,11 +168,6 @@ link_skill_into_target() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Respect the install whitelist (set during first-run wizard).
|
|
||||||
if ! is_skill_selected "$name"; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
link_path="$target_dir/$name"
|
link_path="$target_dir/$name"
|
||||||
|
|
||||||
if [[ -L "$link_path" ]]; then
|
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",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.29",
|
"version": "0.0.26",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/mosaic"
|
"directory": "packages/mosaic"
|
||||||
},
|
},
|
||||||
"description": "Mosaic agent framework — installation wizard and meta package",
|
"description": "Mosaic agent framework — installation wizard and meta package",
|
||||||
|
|||||||
@@ -135,11 +135,15 @@ program
|
|||||||
|
|
||||||
// No valid session — prompt for credentials
|
// No valid session — prompt for credentials
|
||||||
if (!session) {
|
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}`);
|
console.log(`Sign in to ${opts.gateway}`);
|
||||||
const email = await promptLine('Email: ');
|
const email = await ask('Email: ');
|
||||||
const password = await promptSecret('Password: ');
|
const password = await ask('Password: ');
|
||||||
|
rl.close();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await signIn(opts.gateway, email, password);
|
const auth = await signIn(opts.gateway, email, password);
|
||||||
|
|||||||
@@ -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 {
|
function checkSequentialThinking(runtime: string): void {
|
||||||
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||||
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
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) {
|
switch (runtime) {
|
||||||
case 'claude': {
|
case 'claude': {
|
||||||
// Audit Claude Code settings and warn about missing hooks/plugins
|
|
||||||
const settingsAudit = auditClaudeSettings();
|
|
||||||
printSettingsWarnings(settingsAudit);
|
|
||||||
|
|
||||||
const prompt = buildRuntimePrompt('claude');
|
const prompt = buildRuntimePrompt('claude');
|
||||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||||
cliArgs.push('--append-system-prompt', prompt);
|
cliArgs.push('--append-system-prompt', prompt);
|
||||||
|
|||||||
@@ -26,53 +26,6 @@ export const DEFAULTS = {
|
|||||||
| (add your git providers here) | | | |`,
|
| (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([
|
export const RECOMMENDED_SKILLS = new Set([
|
||||||
'brainstorming',
|
'brainstorming',
|
||||||
'code-review-excellence',
|
'code-review-excellence',
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -25,68 +25,14 @@ function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncSkillsResult {
|
function syncSkills(mosaicHome: string): void {
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
|
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
|
||||||
if (!existsSync(script)) {
|
if (existsSync(script)) {
|
||||||
return {
|
try {
|
||||||
success: false,
|
spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' });
|
||||||
installedCount: 0,
|
} catch {
|
||||||
failureReason: `Skills sync script not found at ${script} — run 'mosaic sync' after installation.`,
|
// Non-fatal
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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()}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, installedCount: selectedSkills.length };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
installedCount: 0,
|
|
||||||
failureReason: `Skills sync threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,11 +124,10 @@ export async function finalizeStage(
|
|||||||
const skipClaudeHooks = state.hooks?.accepted === false;
|
const skipClaudeHooks = state.hooks?.accepted === false;
|
||||||
linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
|
linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
|
||||||
|
|
||||||
// 4. Sync skills (only installs the user-selected subset)
|
// 4. Sync skills
|
||||||
let skillsResult: SyncSkillsResult = { success: true, installedCount: 0 };
|
|
||||||
if (state.selectedSkills.length > 0) {
|
if (state.selectedSkills.length > 0) {
|
||||||
spin.update(`Installing ${state.selectedSkills.length.toString()} selected skill(s)...`);
|
spin.update('Syncing skills...');
|
||||||
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
|
syncSkills(state.mosaicHome);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Run doctor
|
// 5. Run doctor
|
||||||
@@ -191,27 +136,15 @@ export async function finalizeStage(
|
|||||||
|
|
||||||
spin.stop('Installation complete');
|
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
|
// 6. PATH setup
|
||||||
const pathAction = setupPath(state.mosaicHome, p);
|
const pathAction = setupPath(state.mosaicHome, p);
|
||||||
|
|
||||||
// 7. Summary
|
// 7. Summary
|
||||||
const skillsSummary = skillsResult.success
|
|
||||||
? skillsResult.installedCount > 0
|
|
||||||
? `${skillsResult.installedCount.toString()} installed`
|
|
||||||
: 'none selected'
|
|
||||||
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
|
|
||||||
|
|
||||||
const summary: string[] = [
|
const summary: string[] = [
|
||||||
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
||||||
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
||||||
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
||||||
`Skills: ${skillsSummary}`,
|
`Skills: ${state.selectedSkills.length.toString()} selected`,
|
||||||
`Config: ${state.mosaicHome}`,
|
`Config: ${state.mosaicHome}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export async function gatewayBootstrapStage(
|
|||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
tier: 'local',
|
tier: 'local',
|
||||||
corsOrigin: `http://${host}:3000`,
|
corsOrigin: 'http://localhost:3000',
|
||||||
}),
|
}),
|
||||||
admin: { name, email, password },
|
admin: { name, email, password },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -26,25 +26,6 @@ function isHeadless(): boolean {
|
|||||||
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
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 ──────────────────────────────────────────────────────────────
|
// ── .env helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function readEnvVarFromFile(envFile: string, key: string): string | null {
|
function readEnvVarFromFile(envFile: string, key: string): string | null {
|
||||||
@@ -126,14 +107,6 @@ export interface GatewayConfigStageOptions {
|
|||||||
portOverride?: number;
|
portOverride?: number;
|
||||||
/** Skip the `npm install -g @mosaicstack/gateway` step (local build / tests). */
|
/** Skip the `npm install -g @mosaicstack/gateway` step (local build / tests). */
|
||||||
skipInstall?: boolean;
|
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 {
|
export interface GatewayConfigStageResult {
|
||||||
@@ -255,9 +228,7 @@ export async function gatewayConfigStage(
|
|||||||
host: existing.host,
|
host: existing.host,
|
||||||
port: existing.port,
|
port: existing.port,
|
||||||
tier: 'local',
|
tier: 'local',
|
||||||
corsOrigin:
|
corsOrigin: 'http://localhost:3000',
|
||||||
readEnvVarFromFile(ENV_FILE, 'GATEWAY_CORS_ORIGIN') ??
|
|
||||||
deriveCorsOrigin('localhost', 3000),
|
|
||||||
regeneratedConfig: false,
|
regeneratedConfig: false,
|
||||||
};
|
};
|
||||||
return { ready: true, host: existing.host, port: existing.port };
|
return { ready: true, host: existing.host, port: existing.port };
|
||||||
@@ -310,8 +281,7 @@ export async function gatewayConfigStage(
|
|||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
tier: 'local',
|
tier: 'local',
|
||||||
corsOrigin:
|
corsOrigin: 'http://localhost:3000',
|
||||||
readEnvVarFromFile(ENV_FILE, 'GATEWAY_CORS_ORIGIN') ?? deriveCorsOrigin('localhost', 3000),
|
|
||||||
regeneratedConfig: false,
|
regeneratedConfig: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -322,8 +292,6 @@ export async function gatewayConfigStage(
|
|||||||
envFile: ENV_FILE,
|
envFile: ENV_FILE,
|
||||||
mosaicConfigFile: MOSAIC_CONFIG_FILE,
|
mosaicConfigFile: MOSAIC_CONFIG_FILE,
|
||||||
gatewayHome: GATEWAY_HOME,
|
gatewayHome: GATEWAY_HOME,
|
||||||
providerKey: opts.providerKey,
|
|
||||||
providerType: opts.providerType,
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GatewayConfigValidationError) {
|
if (err instanceof GatewayConfigValidationError) {
|
||||||
@@ -399,10 +367,6 @@ interface CollectOptions {
|
|||||||
envFile: string;
|
envFile: string;
|
||||||
mosaicConfigFile: string;
|
mosaicConfigFile: string;
|
||||||
gatewayHome: 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. */
|
/** Raised by the config stage when headless env validation fails. */
|
||||||
@@ -431,7 +395,6 @@ async function collectAndWriteConfig(
|
|||||||
let valkeyUrl: string | undefined;
|
let valkeyUrl: string | undefined;
|
||||||
let anthropicKey: string;
|
let anthropicKey: string;
|
||||||
let corsOrigin: string;
|
let corsOrigin: string;
|
||||||
let hostname: string;
|
|
||||||
|
|
||||||
if (isHeadless()) {
|
if (isHeadless()) {
|
||||||
p.log('Headless mode detected — reading configuration from environment variables.');
|
p.log('Headless mode detected — reading configuration from environment variables.');
|
||||||
@@ -445,13 +408,7 @@ async function collectAndWriteConfig(
|
|||||||
databaseUrl = process.env['MOSAIC_DATABASE_URL'];
|
databaseUrl = process.env['MOSAIC_DATABASE_URL'];
|
||||||
valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
|
valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
|
||||||
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
||||||
|
corsOrigin = process.env['MOSAIC_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||||
// 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') {
|
if (tier === 'team') {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
@@ -480,34 +437,16 @@ async function collectAndWriteConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.providerKey) {
|
anthropicKey = await p.text({
|
||||||
anthropicKey = opts.providerKey;
|
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
|
||||||
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
|
defaultValue: '',
|
||||||
} 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)
|
corsOrigin = await p.text({
|
||||||
let useHttps: boolean | undefined;
|
message: 'CORS origin',
|
||||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
|
initialValue: 'http://localhost:3000',
|
||||||
useHttps = await p.confirm({
|
defaultValue: 'http://localhost:3000',
|
||||||
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 authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
|
||||||
@@ -527,11 +466,7 @@ async function collectAndWriteConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (anthropicKey) {
|
if (anthropicKey) {
|
||||||
if (opts.providerType === 'openai') {
|
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||||
envLines.push(`OPENAI_API_KEY=${anthropicKey}`);
|
|
||||||
} else {
|
|
||||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(opts.envFile, envLines.join('\n') + '\n', { mode: 0o600 });
|
writeFileSync(opts.envFile, envLines.join('\n') + '\n', { mode: 0o600 });
|
||||||
@@ -565,7 +500,6 @@ async function collectAndWriteConfig(
|
|||||||
valkeyUrl,
|
valkeyUrl,
|
||||||
anthropicKey: anthropicKey || undefined,
|
anthropicKey: anthropicKey || undefined,
|
||||||
corsOrigin,
|
corsOrigin,
|
||||||
hostname,
|
|
||||||
regeneratedConfig: true,
|
regeneratedConfig: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 CommunicationStyle = 'direct' | 'friendly' | 'formal';
|
||||||
export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
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 {
|
export interface SoulConfig {
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
roleDescription?: string;
|
roleDescription?: string;
|
||||||
@@ -75,11 +62,6 @@ export interface GatewayState {
|
|||||||
valkeyUrl?: string;
|
valkeyUrl?: string;
|
||||||
anthropicKey?: string;
|
anthropicKey?: string;
|
||||||
corsOrigin: 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. */
|
/** True when .env + mosaic.config.json were (re)generated in this run. */
|
||||||
regeneratedConfig?: boolean;
|
regeneratedConfig?: boolean;
|
||||||
admin?: GatewayAdminState;
|
admin?: GatewayAdminState;
|
||||||
@@ -99,12 +81,4 @@ export interface WizardState {
|
|||||||
selectedSkills: string[];
|
selectedSkills: string[];
|
||||||
hooks?: HooksState;
|
hooks?: HooksState;
|
||||||
gateway?: GatewayState;
|
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,9 @@
|
|||||||
import type { WizardPrompter } from './prompter/interface.js';
|
import type { WizardPrompter } from './prompter/interface.js';
|
||||||
import type { ConfigService } from './config/config-service.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 { welcomeStage } from './stages/welcome.js';
|
||||||
import { detectInstallStage } from './stages/detect-install.js';
|
import { detectInstallStage } from './stages/detect-install.js';
|
||||||
|
import { modeSelectStage } from './stages/mode-select.js';
|
||||||
import { soulSetupStage } from './stages/soul-setup.js';
|
import { soulSetupStage } from './stages/soul-setup.js';
|
||||||
import { userSetupStage } from './stages/user-setup.js';
|
import { userSetupStage } from './stages/user-setup.js';
|
||||||
import { toolsSetupStage } from './stages/tools-setup.js';
|
import { toolsSetupStage } from './stages/tools-setup.js';
|
||||||
@@ -12,10 +13,6 @@ import { skillsSelectStage } from './stages/skills-select.js';
|
|||||||
import { finalizeStage } from './stages/finalize.js';
|
import { finalizeStage } from './stages/finalize.js';
|
||||||
import { gatewayConfigStage } from './stages/gateway-config.js';
|
import { gatewayConfigStage } from './stages/gateway-config.js';
|
||||||
import { gatewayBootstrapStage } from './stages/gateway-bootstrap.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';
|
|
||||||
|
|
||||||
export interface WizardOptions {
|
export interface WizardOptions {
|
||||||
mosaicHome: string;
|
mosaicHome: string;
|
||||||
@@ -57,7 +54,6 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
|||||||
tools: {},
|
tools: {},
|
||||||
runtimes: { detected: [], mcpConfigured: false },
|
runtimes: { detected: [], mcpConfigured: false },
|
||||||
selectedSkills: [],
|
selectedSkills: [],
|
||||||
completedSections: new Set<MenuSection>(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply CLI overrides (strip undefined values)
|
// Apply CLI overrides (strip undefined values)
|
||||||
@@ -94,304 +90,55 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
|||||||
// Stage 2: Existing Install Detection
|
// Stage 2: Existing Install Detection
|
||||||
await detectInstallStage(prompter, state, configService);
|
await detectInstallStage(prompter, state, configService);
|
||||||
|
|
||||||
// ── Headless bypass ────────────────────────────────────────────────────────
|
// Stage 3: Quick Start vs Advanced (skip if keeping existing)
|
||||||
// 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 ─────────────────────────────────────────────────
|
|
||||||
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
||||||
await runMenuLoop(prompter, state, configService, options);
|
await modeSelectStage(prompter, state);
|
||||||
} else if (state.installAction === 'reconfigure') {
|
} else if (state.installAction === 'reconfigure') {
|
||||||
state.mode = 'advanced';
|
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
|
// Stage 4: SOUL.md
|
||||||
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
|
|
||||||
await soulSetupStage(prompter, state);
|
await soulSetupStage(prompter, state);
|
||||||
|
|
||||||
// USER.md
|
// Stage 5: USER.md
|
||||||
await userSetupStage(prompter, state);
|
await userSetupStage(prompter, state);
|
||||||
|
|
||||||
// TOOLS.md
|
// Stage 6: TOOLS.md
|
||||||
await toolsSetupStage(prompter, state);
|
await toolsSetupStage(prompter, state);
|
||||||
|
|
||||||
// Runtime Detection
|
// Stage 7: Runtime Detection & Installation
|
||||||
await runtimeSetupStage(prompter, state);
|
await runtimeSetupStage(prompter, state);
|
||||||
|
|
||||||
// Hooks
|
// Stage 8: Hooks preview (Claude only — skipped if Claude not detected)
|
||||||
await hooksPreviewStage(prompter, state);
|
await hooksPreviewStage(prompter, state);
|
||||||
|
|
||||||
// Skills
|
// Stage 9: Skills Selection
|
||||||
await skillsSelectStage(prompter, state);
|
await skillsSelectStage(prompter, state);
|
||||||
|
|
||||||
// Finalize
|
// Stage 10: Finalize (writes configs, links runtime assets, runs doctor)
|
||||||
await finalizeStage(prompter, state, configService);
|
await finalizeStage(prompter, state, configService);
|
||||||
|
|
||||||
// Gateway stages
|
// Stages 11 & 12: Gateway config + admin bootstrap.
|
||||||
|
// The unified first-run flow runs these as terminal stages so the user
|
||||||
|
// goes from "welcome" through "admin user created" in a single cohesive
|
||||||
|
// experience. Callers that only want the framework portion pass
|
||||||
|
// `skipGateway: true`.
|
||||||
if (!options.skipGateway) {
|
if (!options.skipGateway) {
|
||||||
|
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configResult = await gatewayConfigStage(prompter, state, {
|
const configResult = await gatewayConfigStage(prompter, state, {
|
||||||
host: options.gatewayHost ?? 'localhost',
|
host: options.gatewayHost ?? 'localhost',
|
||||||
defaultPort: options.gatewayPort ?? 14242,
|
defaultPort: options.gatewayPort ?? 14242,
|
||||||
portOverride: options.gatewayPortOverride,
|
portOverride: options.gatewayPortOverride,
|
||||||
skipInstall: options.skipGatewayNpmInstall,
|
skipInstall: options.skipGatewayNpmInstall,
|
||||||
providerKey: state.providerKey,
|
|
||||||
providerType: state.providerType ?? 'none',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
if (headlessRun) {
|
||||||
process.exit(1);
|
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
host: configResult.host,
|
host: configResult.host,
|
||||||
@@ -403,53 +150,12 @@ async function runHeadlessPath(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
// Stages normally return structured `ready: false` results for
|
||||||
throw err;
|
// expected failures. Anything that reaches here is an unexpected
|
||||||
}
|
// runtime error — render a concise warning for UX AND re-throw so
|
||||||
}
|
// the CLI (and `tools/install.sh` auto-launch) sees a non-zero exit.
|
||||||
}
|
// Swallowing here would let headless installs report success even
|
||||||
|
// when the gateway stage crashed.
|
||||||
// ── 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)}`);
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/prdy"
|
"directory": "packages/prdy"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/quality-rails"
|
"directory": "packages/quality-rails"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/queue"
|
"directory": "packages/queue"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/storage"
|
"directory": "packages/storage"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "packages/types"
|
"directory": "packages/types"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "plugins/discord"
|
"directory": "plugins/discord"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "plugins/macp"
|
"directory": "plugins/macp"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "plugins/mosaic-framework"
|
"directory": "plugins/mosaic-framework"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
"directory": "plugins/telegram"
|
"directory": "plugins/telegram"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
|
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
|
||||||
# 2. @mosaicstack/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
|
# 2. @mosaicstack/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
|
||||||
#
|
#
|
||||||
# Quick: curl -fsSL https://mosaicstack.dev/install.sh | bash
|
# Remote install (recommended):
|
||||||
# Direct: 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)
|
||||||
#
|
#
|
||||||
# Remote install (alternative — use -s -- to pass flags):
|
# 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:
|
# Flags:
|
||||||
# --check Version check only, no install
|
# --check Version check only, no install
|
||||||
@@ -69,7 +69,7 @@ REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstac
|
|||||||
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
|
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
|
||||||
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
||||||
CLI_PKG="${SCOPE}/mosaic"
|
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"
|
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
||||||
|
|
||||||
# ─── uninstall path ───────────────────────────────────────────────────────────
|
# ─── uninstall path ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user