Compare commits

..

25 Commits

Author SHA1 Message Date
Jarvis
eca863b282 feat: wizard remediation — password mask, hooks preview, headless (IUH-M02)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Implements three wizard UX gaps identified in issue #426:

AC-3: Replace plaintext password prompt in bootstrapFirstUser with a
masked reader (promptMasked) that suppresses echo and requires the user
to type their password twice to confirm. Min-8-chars validation is
preserved and applied after both entries agree.

AC-4: Add a hooks-preview stage (hooksPreviewStage) between
runtimeSetupStage and skillsSelectStage in the wizard. The stage
parses hooks-config.json, displays each hook event/matcher/command,
and prompts the user for consent before installation. Declined → state
recorded with accepted=false. Also adds `mosaic config hooks list|enable|
disable` subcommands to manage installed hooks in ~/.claude/ post-install.

AC-5: Add headless install path to runConfigWizard and bootstrapFirstUser
gated on MOSAIC_ASSUME_YES=1 or !process.stdin.isTTY. Env-var-driven
configuration with required-var validation and non-zero exit on missing
or invalid inputs. Documents all env vars in packages/mosaic/README.md.

Closes #426

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:44:00 -05:00
8fa5995bde docs: scaffold install-ux-hardening mission + archive cli-unification (#430)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 17:15:39 +00:00
25cada7735 feat: mosaic uninstall (IUH-M01) (#429)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 17:06:21 +00:00
be6553101c docs: finalize CLI unification mission at mosaic-v0.0.24 (#424)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 14:54:48 +00:00
417805f330 fix: bump memory/queue/storage to 0.0.4 to force republish (#423)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-04-05 14:39:15 +00:00
2472ce52e8 fix: bump stale sub-package versions (brain/forge/log) (#422)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 14:26:30 +00:00
597eb232d7 fix: revert mosaic to 0.0.22 alpha + republish macp (#421)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 14:15:46 +00:00
afe997db82 docs: mission cli-unification-20260404 complete (#420)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 07:54:50 +00:00
b9d464de61 docs: CLI unification release v0.1.0 (M8) (#419)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/tag/publish Pipeline was successful
2026-04-05 07:46:00 +00:00
872c124581 feat(mosaic): unified first-run UX wizard -> gateway install -> verify (#418)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 07:29:17 +00:00
a531029c5b feat(mosaic): mosaic telemetry command (M6 CU-06-01..05) (#417)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 07:06:42 +00:00
35ab619bd0 docs: session 2 orchestrator bookkeeping (M3/M4/M5 complete) (#416)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-05 07:06:40 +00:00
831193cdd8 fix(macp): align exports + add CLI smoke test (#415)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 06:57:42 +00:00
df460d5a49 feat(macp): mosaic macp CLI surface (#410)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 06:33:52 +00:00
119ff0eb1b fix(mosaic): gateway token recovery review remediations (#414)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 06:13:29 +00:00
3abd63ea5c Merge pull request 'feat(mosaic): mosaic auth CLI surface' (#413) from feat/mosaic-auth-cli into main
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 06:11:33 +00:00
641e4604d5 feat(forge): mosaic forge CLI surface (#412)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 06:08:50 +00:00
Jarvis
9b5ecc0171 feat(mosaic): add auth command and stage parallel agent changes
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Picks up auth command and spec written by parallel agent, and updated
mosaic cli.ts wiring from parallel development during cli-unification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:58:03 -05:00
Jarvis
a00325da0e feat(forge): add registerForgeCommand for mosaic forge CLI surface
Adds mosaic forge run|status|resume|personas list subcommands to
@mosaicstack/forge, wires registerForgeCommand into the root mosaic CLI,
and ships a smoke test asserting command structure. Ref CU-05-01
cli-unification-20260404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:58:03 -05:00
4ebce3422d feat(log): mosaic log CLI surface (#407)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:57:22 +00:00
751e0ee330 feat(storage): mosaic storage CLI surface (#405)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:48:13 +00:00
54b2920ef3 feat(memory): mosaic memory CLI surface (#406)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:44:06 +00:00
5917016509 feat(mosaic): gateway token recovery via BetterAuth cookie (#411)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-04-05 05:43:49 +00:00
7b4f1d249d feat(mosaic): top-level mosaic config command (#408)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 05:37:05 +00:00
5425f9268e feat(queue): mosaic queue CLI surface (#404)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 05:27:59 +00:00
67 changed files with 7544 additions and 250 deletions

130
README.md
View File

@@ -7,7 +7,14 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
## Quick Install ## Quick Install
```bash ```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh) bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
```
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
```bash
bash <(curl -fsSL …) --yes # Accept all defaults
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
``` ```
This installs both components: This installs both components:
@@ -17,10 +24,10 @@ This installs both components:
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` | | **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` | | **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
After install, set up your agent identity: After install, the wizard runs automatically or you can invoke it manually:
```bash ```bash
mosaic init # Interactive wizard mosaic wizard # Full guided setup (gateway install → verify)
``` ```
### Requirements ### Requirements
@@ -49,10 +56,32 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
```bash ```bash
mosaic tui # Interactive TUI connected to the gateway mosaic tui # Interactive TUI connected to the gateway
mosaic login # Authenticate with a gateway instance mosaic gateway login # Authenticate with a gateway instance
mosaic sessions list # List active agent sessions mosaic sessions list # List active agent sessions
``` ```
### Gateway Management
```bash
mosaic gateway install # Install and configure the gateway service
mosaic gateway verify # Post-install health check
mosaic gateway login # Authenticate and store a session token
mosaic gateway config rotate-token # Rotate your API token
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
```
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
### Configuration
```bash
mosaic config show # Print full config as JSON
mosaic config get <key> # Read a specific key
mosaic config set <key> <val># Write a key
mosaic config edit # Open config in $EDITOR
mosaic config path # Print config file path
```
### Management ### Management
```bash ```bash
@@ -65,6 +94,80 @@ mosaic coord init # Initialize a new orchestration mission
mosaic prdy init # Create a PRD via guided session mosaic prdy init # Create a PRD via guided session
``` ```
### Sub-package Commands
Each Mosaic sub-package exposes its API surface through the unified CLI:
```bash
# User management
mosaic auth users list
mosaic auth users create
mosaic auth sso
# Agent brain (projects, missions, tasks)
mosaic brain projects
mosaic brain missions
mosaic brain tasks
mosaic brain conversations
# Agent forge pipeline
mosaic forge run
mosaic forge status
mosaic forge resume
mosaic forge personas
# Structured logging
mosaic log tail
mosaic log search
mosaic log export
mosaic log level
# MACP protocol
mosaic macp tasks
mosaic macp submit
mosaic macp gate
mosaic macp events
# Agent memory
mosaic memory search
mosaic memory stats
mosaic memory insights
mosaic memory preferences
# Task queue (Valkey)
mosaic queue list
mosaic queue stats
mosaic queue pause
mosaic queue resume
mosaic queue jobs
mosaic queue drain
# Object storage
mosaic storage status
mosaic storage tier
mosaic storage export
mosaic storage import
mosaic storage migrate
```
### Telemetry
```bash
# Local observability (OTEL / Jaeger)
mosaic telemetry local status
mosaic telemetry local tail
mosaic telemetry local jaeger
# Remote telemetry (dry-run by default)
mosaic telemetry status
mosaic telemetry opt-in
mosaic telemetry opt-out
mosaic telemetry test
mosaic telemetry upload # Dry-run unless opted in
```
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
## Development ## Development
### Prerequisites ### Prerequisites
@@ -76,7 +179,7 @@ mosaic prdy init # Create a PRD via guided session
### Setup ### Setup
```bash ```bash
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git git clone git@git.mosaicstack.dev:mosaicstack/mosaic-stack.git
cd mosaic-stack cd mosaic-stack
# Start infrastructure (Postgres, Valkey, Jaeger) # Start infrastructure (Postgres, Valkey, Jaeger)
@@ -131,8 +234,7 @@ mosaic-stack/
│ ├── 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)
├── packages/ ├── packages/
│ ├── cli/ Mosaic CLI — TUI, gateway client, wizard │ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
│ ├── mosaic/ Framework — wizard, runtime detection, update checker
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events) │ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
│ ├── db/ Drizzle ORM schema + migrations (pgvector) │ ├── db/ Drizzle ORM schema + migrations (pgvector)
│ ├── auth/ BetterAuth configuration │ ├── auth/ BetterAuth configuration
@@ -153,7 +255,7 @@ mosaic-stack/
│ ├── macp/ OpenClaw MACP runtime plugin │ ├── macp/ OpenClaw MACP runtime plugin
│ └── mosaic-framework/ OpenClaw framework injection plugin │ └── mosaic-framework/ OpenClaw framework injection plugin
├── tools/ ├── tools/
│ └── install.sh Unified installer (framework + npm CLI) │ └── install.sh Unified installer (framework + npm CLI, --yes / --no-auto-launch)
├── scripts/agent/ Agent session lifecycle scripts ├── scripts/agent/ Agent session lifecycle scripts
├── docker-compose.yml Dev infrastructure ├── docker-compose.yml Dev infrastructure
└── .woodpecker/ CI pipeline configs └── .woodpecker/ CI pipeline configs
@@ -200,7 +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
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh) bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
``` ```
Or use the CLI: Or use the CLI:
@@ -215,10 +317,12 @@ The CLI also performs a background update check on every invocation (cached for
### Installer Flags ### Installer Flags
```bash ```bash
bash tools/install.sh --check # Version check only bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework) bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
``` ```
## Contributing ## Contributing

View File

@@ -1,70 +1,57 @@
# Mission Manifest — CLI Unification & E2E First-Run # Mission Manifest — Install UX Hardening
> Persistent document tracking full mission scope, status, and session history. > Persistent document tracking full mission scope, status, and session history.
> Updated by the orchestrator at each phase transition and milestone completion. > Updated by the orchestrator at each phase transition and milestone completion.
## Mission ## Mission
**ID:** cli-unification-20260404 **ID:** install-ux-hardening-20260405
**Statement:** Transform the Mosaic CLI from a partially-duplicated, manually-assembled experience into a single cohesive entry point that installs, configures, and controls the entire Mosaic system. Every Mosaic package gets first-class CLI surface. The first-run experience works end-to-end with no manual stitching. Gateway token recovery is possible without the web UI. Opt-in telemetry uses the published telemetry clients. **Statement:** Close the remaining gaps in the Mosaic Stack first-run and teardown experience uncovered by the post-`cli-unification` audit. A user MUST be able to cleanly uninstall the stack; the wizard MUST make security-sensitive surfaces visible (hooks, password entry); and CI/headless installs MUST NOT hang on interactive prompts. The longer-term goal is a single cohesive first-run flow that collapses `mosaic wizard` and `mosaic gateway install` into one state-bridged experience.
**Phase:** Execution **Phase:** Execution
**Current Milestone:** cu-m03 / cu-m04 / cu-m05 (parallel-eligible) **Current Milestone:** IUH-M02
**Progress:** 2 / 8 milestones **Progress:** 1 / 3 milestones
**Status:** active **Status:** active
**Last Updated:** 2026-04-04 **Last Updated:** 2026-04-05
**Parent Mission:** [cli-unification-20260404](./archive/missions/cli-unification-20260404/MISSION-MANIFEST.md) (complete)
## Context
Post-merge audit of `cli-unification-20260404` (AC-1, AC-6) validated that the first-run wizard covers first user, password, admin tokens, gateway instance config, skills, and SOUL.md/USER.md init. The audit surfaced six gaps, grouped into three tracks of independent value.
## Success Criteria ## Success Criteria
- [ ] AC-1: Fresh machine `bash <(curl …install.sh)` → single command lands on a working authenticated gateway with a usable admin token; no secondary manual wizards required - [x] AC-1: `mosaic uninstall` (top-level) cleanly reverses every mutation made by `tools/install.sh` — framework data, npm CLI, nested stack deps, runtime asset injections in `~/.claude/`, npmrc scope mapping, PATH edits. Dry-run supported. `--keep-data` preserves memory + user files + gateway DB. (PR #429)
- [ ] AC-2: `mosaic --help` lists every sub-package as a top-level command and is alphabetized for readability - [x] AC-2: `curl … | bash -s -- --uninstall` works without requiring a functioning CLI. (PR #429)
- [ ] AC-3: `mosaic auth`, `mosaic brain`, `mosaic forge`, `mosaic log`, `mosaic macp`, `mosaic memory`, `mosaic queue`, `mosaic storage`, `mosaic telemetry` each expose at least one working subcommand that exercises the underlying package - [ ] AC-3: Password entry in `bootstrapFirstUser` is masked (no plaintext echo); confirm prompt added.
- [ ] AC-4: Gateway admin token can be rotated or recovered from the CLI alone — operator is never stranded because the web UI is inaccessible - [ ] AC-4: Wizard has an explicit hooks stage that previews which hooks will be installed, asks for confirmation, and records the user's choice. `mosaic config hooks list|enable|disable` surface exists.
- [ ] AC-5: `mosaic telemetry` uses the published `@mosaicstack/telemetry-client-js` (from the Gitea npm registry); local OTEL stays for wide-event logging / post-mortems; remote upload is opt-in and disabled by default - [ ] AC-5: `runConfigWizard` and `bootstrapFirstUser` accept a headless path (env vars + `--yes`) so `tools/install.sh --yes` + `MOSAIC_ASSUME_YES=1` completes end-to-end in CI without TTY.
- [ ] AC-6: Install → wizard → gateway install → TUI verification flow is a single cohesive path with clear state transitions and no dead ends - [ ] AC-6: `mosaic wizard` and `mosaic gateway install` are collapsed into a single cohesive entry point with shared state (no two-phase handoff via the 10-minute session file).
- [ ] AC-7: `@mosaicstack/mosaic` is the sole `mosaic` binary owner; `@mosaicstack/cli` is gone from the repo and all docs - [ ] AC-7: All milestones ship as merged PRs with green CI, closed issues, updated release notes.
- [ ] AC-8: All milestones ship as merged PRs with green CI, closed issues, and updated release notes
## Milestones ## Milestones
| # | ID | Name | Status | Branch | Issue | Started | Completed | | # | ID | Name | Status | Branch | Issue | Started | Completed |
| --- | ------ | ------------------------------------------------------------------------ | ----------- | ---------------------------------- | ----- | ---------- | ---------- | | --- | ------- | --------------------------------------------------------- | ----------- | ----------------------- | ----- | ---------- | ---------- |
| 1 | cu-m01 | Kill legacy @mosaicstack/cli package | done | chore/remove-cli-package-duplicate | #398 | 2026-04-04 | 2026-04-04 | | 1 | IUH-M01 | `mosaic uninstall` — top-level teardown + shell wrapper | done | feat/mosaic-uninstall | #425 | 2026-04-05 | 2026-04-05 |
| 2 | cu-m02 | Archive stale mission state + scaffold new mission | done | docs/mission-cli-unification | #399 | 2026-04-04 | 2026-04-04 | | 2 | IUH-M02 | Wizard remediation — hooks visibility, pwd mask, headless | in-progress | feat/wizard-remediation | #426 | 2026-04-05 | |
| 3 | cu-m03 | Fix gateway bootstrap token recovery (server + CLI paths) | not-started | — | — | — | — | | 3 | IUH-M03 | Unified first-run wizard (collapse wizard + gateway) | blocked | feat/unified-first-run | #427 | — | — |
| 4 | cu-m04 | Alphabetize + group `mosaic --help` output | not-started | — | — | — | — |
| 5 | cu-m05 | Sub-package CLI surface (auth/brain/forge/log/macp/memory/queue/storage) | not-started | — | — | — | — |
| 6 | cu-m06 | `mosaic telemetry` — local OTEL + opt-in remote upload | not-started | — | — | — | — |
| 7 | cu-m07 | Unified first-run UX (install.sh → wizard → gateway → TUI) | not-started | — | — | — | — |
| 8 | cu-m08 | Docs refresh + release tag | not-started | — | — | — | — |
## Deployment ## Subagent Delegation Plan
| Target | URL | Method | | Milestone | Recommended Tier | Rationale |
| -------------------- | --------- | ----------------------------------------------- | | --------- | ---------------- | ---------------------------------------------------------------------- |
| Local tier (default) | localhost | `mosaic gateway install` — pglite + local queue | | IUH-M01 | sonnet | Standard feature work — new command surface mirroring existing install |
| Team tier | any host | `mosaic gateway install` — PG + Valkey | | IUH-M02 | sonnet | Small surgical fixes across 3-4 files |
| Docker Compose (dev) | localhost | `docker compose up` for PG/Valkey/OTEL/Jaeger | | IUH-M03 | opus | Architectural refactor; state machine design decisions |
## Coordination ## Risks
- **Primary Agent:** claude-opus-4-6[1m] - **Reversal completeness** — runtime asset linking creates `.mosaic-bak-*` backups; uninstall must honor them vs. when to delete. Ambiguity without an install manifest.
- **Sibling Agents:** sonnet (standard implementation), haiku (status/explore/verify), codex (coding-heavy tasks) - **npm global nested deps** — `npm uninstall -g @mosaicstack/mosaic` removes nested `@mosaicstack/*`, but ownership conflicts with explicitly installed peer packages (`@mosaicstack/gateway`, `@mosaicstack/memory`) need test coverage.
- **Shared Contracts:** `docs/PRD.md` (existing v0.1.0 PRD — still the long-term target), this manifest, `docs/TASKS.md`, `docs/scratchpads/cli-unification-20260404.md` - **Headless bootstrap** — admin password via env var is a credential on disk; needs clear documentation that `MOSAIC_ADMIN_PASSWORD` is intended for CI-only and should be rotated post-install.
## Token Budget ## Out of Scope
| Metric | Value | - `mosaicstack.dev/install.sh` vanity URL (blocked on marketing site work)
| ------ | ------ | - Uninstall for the `@mosaicstack/gateway` database contents — delegated to `mosaic gateway uninstall` semantics already in place
| Budget | TBD | - Signature/checksum verification of install scripts
| Used | ~80K |
| Mode | normal |
## Session History
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
| ------- | --------------- | ---------- | --------- | ------------ | ------------------------------------------------------------ |
| 1 | claude-opus-4-6 | 2026-04-04 | in-flight | — | cu-m01 + cu-m02 merged (#398, #399); open questions resolved |
## Scratchpad
Path: `docs/scratchpads/cli-unification-20260404.md`

View File

@@ -1,90 +1,40 @@
# Tasks — CLI Unification & E2E First-Run # Tasks — Install UX Hardening
> Single-writer: orchestrator only. Workers read but never modify. > Single-writer: orchestrator only. Workers read but never modify.
> >
> **Mission:** cli-unification-20260404 > **Mission:** install-ux-hardening-20260405
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |` > **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa` > **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `glm-5` | `—` (auto) > **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
## Milestone 1 — Kill legacy @mosaicstack/cli (done) ## Milestone 1 — `mosaic uninstall` (IUH-M01)
| id | status | description | issue | agent | branch | depends_on | estimate | notes | | id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ------ | ----------------------------------------------------------------- | ----- | ----- | ---------------------------------- | ---------- | -------- | --------------------------- | | --------- | ------ | ------------------------------------------------------------------------------------------------------------------- | ----- | ------ | --------------------- | ---------- | -------- | ------------------------------------------------------ |
| CU-01-01 | done | Delete packages/cli directory; update workspace + docs references | #398 | opus | chore/remove-cli-package-duplicate | — | 5K | Merged c39433c3. 6685 LOC. | | IUH-01-01 | done | Design install manifest schema (`~/.config/mosaic/.install-manifest.json`) — what install writes on first success | #425 | sonnet | feat/mosaic-uninstall | — | 8K | v1 schema in `install-manifest.ts` |
| IUH-01-02 | done | `mosaic uninstall` TS command: `--framework`, `--cli`, `--gateway`, `--all`, `--keep-data`, `--yes`, `--dry-run` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-01 | 25K | `uninstall.ts` |
| IUH-01-03 | done | Reverse runtime asset linking in `~/.claude/` — restore `.mosaic-bak-*` if present, remove managed copies otherwise | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 12K | file list hardcoded from mosaic-link-runtime-assets |
| IUH-01-04 | done | Reverse npmrc scope mapping and PATH edits made by `tools/install.sh` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 8K | npmrc reversed; no PATH edits found in v0.0.24 install |
| IUH-01-05 | done | Shell fallback: `tools/install.sh --uninstall` path for users without a working CLI | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 10K | |
| IUH-01-06 | done | Vitest coverage: dry-run output, `--all`, `--keep-data`, partial state, missing manifest | #425 | sonnet | feat/mosaic-uninstall | IUH-01-05 | 15K | 14 new tests, 170 total |
| IUH-01-07 | done | Code review (independent) + remediation | #425 | sonnet | feat/mosaic-uninstall | IUH-01-06 | 5K | |
| IUH-01-08 | done | PR open, CI green, review, merge to `main`, close issue | #425 | sonnet | feat/mosaic-uninstall | IUH-01-07 | 3K | PR #429, merge 25cada77 |
## Milestone 2 — Archive stale mission + scaffold new mission (done) ## Milestone 2 — Wizard Remediation (IUH-M02)
| id | status | description | issue | agent | branch | depends_on | estimate | notes | | id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ------ | ------------------------------------------------------------------ | ----- | ----- | ---------------------------- | ---------- | -------- | --------------------------------- | | --------- | ----------- | -------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------- | ---------- | -------- | ----- |
| CU-02-01 | done | Move stale MISSION-MANIFEST / TASKS / PRD-Harness to docs/archive/ | #399 | opus | docs/mission-cli-unification | CU-01-01 | 3K | Harness + storage missions done. | | IUH-02-01 | in-progress | Password masking: replace plaintext `rl.question` in `bootstrapFirstUser` with masked TTY read + confirmation | #426 | sonnet | feat/wizard-remediation | IUH-01-08 | 8K | |
| CU-02-02 | done | Scaffold new MISSION-MANIFEST.md, TASKS.md, scratchpad | #399 | opus | docs/mission-cli-unification | CU-02-01 | 5K | This file + manifest + scratchpad | | IUH-02-02 | not-started | Hooks preview stage in wizard: show `framework/runtime/claude/hooks-config.json` entries + confirm prompt | #426 | sonnet | feat/wizard-remediation | IUH-02-01 | 12K | |
| CU-02-03 | done | PR review, merge, branch cleanup | #399 | opus | docs/mission-cli-unification | CU-02-02 | 2K | Merged as 6f15a84c | | IUH-02-03 | not-started | `mosaic config hooks list\|enable\|disable` subcommands | #426 | sonnet | feat/wizard-remediation | IUH-02-02 | 15K | |
| IUH-02-04 | not-started | Headless path: env-var driven `runConfigWizard` + `bootstrapFirstUser` (`MOSAIC_ASSUME_YES`, `MOSAIC_ADMIN_*`) | #426 | sonnet | feat/wizard-remediation | IUH-02-03 | 12K | |
| IUH-02-05 | not-started | Tests + code review + PR merge | #426 | sonnet | feat/wizard-remediation | IUH-02-04 | 10K | |
## Milestone 3 — Gateway bootstrap token recovery ## Milestone 3 — Unified First-Run Wizard (IUH-M03)
| id | status | description | issue | agent | branch | depends_on | estimate | notes | | id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ----------- | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----------------------------- | | --------- | ------- | ----------------------------------------------------------------------------------------------------------- | ----- | ----- | ---------------------- | ---------- | -------- | ----- |
| CU-03-01 | not-started | Implementation plan for BetterAuth-cookie recovery flow (decision locked 2026-04-04) | — | opus | — | CU-02-03 | 4K | Design locked; plan-only task | | IUH-03-01 | blocked | Design doc: unified state machine; decide whether `mosaic gateway install` becomes an internal wizard stage | — | opus | feat/unified-first-run | IUH-02-05 | 10K | |
| CU-03-02 | not-started | Server: add recovery/rotate endpoint on apps/gateway/src/admin (gated by design from CU-03-01) | — | sonnet | — | CU-03-01 | 12K | | | IUH-03-02 | blocked | Refactor `runWizard` to invoke gateway install as a stage; drop the 10-minute session-file bridge | — | opus | feat/unified-first-run | IUH-03-01 | 25K | |
| CU-03-03 | not-started | CLI: `mosaic gateway login` — interactive BetterAuth sign-in, persist session | — | sonnet | — | CU-03-02 | 10K | | | IUH-03-03 | blocked | Preserve backward-compat: `mosaic gateway install` still works as a standalone entry point | — | opus | feat/unified-first-run | IUH-03-02 | 10K | |
| CU-03-04 | not-started | CLI: `mosaic gateway config rotate-token` — mint new admin token via authenticated API | — | sonnet | — | CU-03-03 | 8K | | | IUH-03-04 | blocked | Tests + code review + PR merge | — | opus | feat/unified-first-run | IUH-03-03 | 12K | |
| CU-03-05 | not-started | CLI: `mosaic gateway config recover-token` — execute the recovery flow from CU-03-01 | — | sonnet | — | CU-03-03 | 10K | |
| CU-03-06 | not-started | Install UX: fix the "user exists, no token" dead-end in runInstall bootstrapFirstUser path | — | sonnet | — | CU-03-05 | 8K | |
| CU-03-07 | not-started | Tests: integration tests for each recovery path (happy + error) | — | sonnet | — | CU-03-06 | 10K | |
| CU-03-08 | not-started | Code review + remediation | — | haiku | — | CU-03-07 | 4K | |
## Milestone 4 — `mosaic --help` alphabetize + grouping
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------------------- |
| CU-04-01 | not-started | Enable `configureHelp({ sortSubcommands: true })` on root program and each subgroup | — | sonnet | — | CU-02-03 | 3K | |
| CU-04-02 | not-started | Group commands into sections (Runtime, Gateway, Framework, Platform) in help output | — | sonnet | — | CU-04-01 | 5K | |
| CU-04-03 | not-started | Verify help snapshots render readably; update any docs with stale output | — | haiku | — | CU-04-02 | 3K | |
| CU-04-04 | not-started | Top-level `mosaic config` command — `show`, `get <key>`, `set <key> <val>`, `edit`, `path` — wraps packages/mosaic/src/config/config-service.ts (framework/agent config; distinct from `mosaic gateway config`) | — | sonnet | — | CU-02-03 | 10K | New scope (decision 2026-04-04) |
| CU-04-05 | not-started | Tests + code review for CU-04-04 | — | haiku | — | CU-04-04 | 4K | |
## Milestone 5 — Sub-package CLI surface
> Pattern: each sub-package exports `register<Name>Command(program: Command)` co-located with the library code (proven by `@mosaicstack/quality-rails`). Wire into `packages/mosaic/src/cli.ts`.
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ----------- | --------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------- |
| CU-05-01 | not-started | `mosaic forge` — subcommands: `run`, `status`, `resume`, `personas list` | — | sonnet | — | CU-02-03 | 18K | User priority |
| CU-05-02 | not-started | `mosaic storage` — subcommands: `status`, `tier show`, `tier switch`, `export`, `import`, `migrate` | — | sonnet | — | CU-02-03 | 15K | |
| CU-05-03 | not-started | `mosaic queue` — subcommands: `list`, `stats`, `pause/resume`, `jobs tail`, `drain` | — | sonnet | — | CU-02-03 | 12K | |
| CU-05-04 | not-started | `mosaic memory` — subcommands: `search`, `stats`, `insights list`, `preferences list` | — | sonnet | — | CU-02-03 | 12K | |
| CU-05-05 | not-started | `mosaic brain` — subcommands: `projects list/create`, `missions list`, `tasks list`, `conversations list` | — | sonnet | — | CU-02-03 | 15K | |
| CU-05-06 | not-started | `mosaic auth` — subcommands: `users list/create/delete`, `sso list`, `sso test`, `sessions list` | — | sonnet | — | CU-03-03 | 15K | needs gateway login |
| CU-05-07 | not-started | `mosaic log` — subcommands: `tail`, `search`, `export`, `level <level>` | — | sonnet | — | CU-02-03 | 10K | |
| CU-05-08 | not-started | `mosaic macp` — subcommands: `tasks list`, `submit`, `gate`, `events tail` | — | sonnet | — | CU-02-03 | 12K | |
| CU-05-09 | not-started | Wire all eight `register<Name>Command` calls into packages/mosaic/src/cli.ts | — | haiku | — | CU-05-01…8 | 3K | |
| CU-05-10 | not-started | Integration test: `mosaic <cmd> --help` exits 0 for every new command | — | haiku | — | CU-05-09 | 5K | |
## Milestone 6 — `mosaic telemetry`
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ----------- | ------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ---------------------------------------------- |
| CU-06-01 | not-started | Add `@mosaicstack/telemetry-client-js` as dependency of `@mosaicstack/mosaic` from Gitea registry | — | sonnet | — | CU-02-03 | 3K | |
| CU-06-02 | not-started | `mosaic telemetry local` — status, tail, Jaeger link (wraps existing apps/gateway/src/tracing.ts) | — | sonnet | — | CU-06-01 | 8K | |
| CU-06-03 | not-started | `mosaic telemetry` — status, opt-in, opt-out, test, upload (uses telemetry-client-js) | — | sonnet | — | CU-06-01 | 12K | Dry-run mode when server endpoint not yet live |
| CU-06-04 | not-started | Persistent consent state in mosaic config; disabled by default | — | sonnet | — | CU-06-03 | 5K | |
| CU-06-05 | not-started | Tests + code review | — | haiku | — | CU-06-04 | 5K | |
## Milestone 7 — Unified first-run UX
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ----------- | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
| CU-07-01 | not-started | tools/install.sh: after npm install, hand off to `mosaic wizard` then `mosaic gateway install` | — | sonnet | — | CU-03-06 | 10K | |
| CU-07-02 | not-started | `mosaic wizard` and `mosaic gateway install` coordination: shared state, no duplicate prompts | — | sonnet | — | CU-07-01 | 12K | |
| CU-07-03 | not-started | Post-install verification step: "gateway healthy, tui connects, admin token on file" | — | sonnet | — | CU-07-02 | 8K | |
| CU-07-04 | not-started | End-to-end test on a clean container from scratch | — | haiku | — | CU-07-03 | 8K | |
## Milestone 8 — Docs + release
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ----------- | ---------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
| CU-08-01 | not-started | Update README.md with new command tree, install flow, and feature list | — | sonnet | — | CU-07-04 | 8K | |
| CU-08-02 | not-started | Update docs/guides/user-guide.md with all new sub-package commands | — | sonnet | — | CU-08-01 | 10K | |
| CU-08-03 | not-started | Version bump `@mosaicstack/mosaic`, publish to Gitea registry | — | opus | — | CU-08-02 | 3K | |
| CU-08-04 | not-started | Release notes, tag `v0.1.0-rc.N`, publish release on Gitea | — | opus | — | CU-08-03 | 3K | |

View File

@@ -0,0 +1,72 @@
# Mission Manifest — CLI Unification & E2E First-Run
> Persistent document tracking full mission scope, status, and session history.
> Updated by the orchestrator at each phase transition and milestone completion.
## Mission
**ID:** cli-unification-20260404
**Statement:** Transform the Mosaic CLI from a partially-duplicated, manually-assembled experience into a single cohesive entry point that installs, configures, and controls the entire Mosaic system. Every Mosaic package gets first-class CLI surface. The first-run experience works end-to-end with no manual stitching. Gateway token recovery is possible without the web UI. Opt-in telemetry uses the published telemetry clients.
**Phase:** Complete
**Current Milestone:**
**Progress:** 8 / 8 milestones
**Status:** completed
**Last Updated:** 2026-04-05
**Release:** [`mosaic-v0.0.24`](https://git.mosaicstack.dev/mosaicstack/mosaic-stack/releases/tag/mosaic-v0.0.24) (`@mosaicstack/mosaic@0.0.24`, alpha — stays in 0.0.x until GA)
## Success Criteria
- [x] AC-1: Fresh machine `bash <(curl …install.sh)` → single command lands on a working authenticated gateway with a usable admin token; no secondary manual wizards required
- [x] AC-2: `mosaic --help` lists every sub-package as a top-level command and is alphabetized for readability
- [x] AC-3: `mosaic auth`, `mosaic brain`, `mosaic forge`, `mosaic log`, `mosaic macp`, `mosaic memory`, `mosaic queue`, `mosaic storage`, `mosaic telemetry` each expose at least one working subcommand that exercises the underlying package
- [x] AC-4: Gateway admin token can be rotated or recovered from the CLI alone — operator is never stranded because the web UI is inaccessible
- [x] AC-5: `mosaic telemetry` uses the published `@mosaicstack/telemetry-client-js` (from the Gitea npm registry); local OTEL stays for wide-event logging / post-mortems; remote upload is opt-in and disabled by default
- [x] AC-6: Install → wizard → gateway install → TUI verification flow is a single cohesive path with clear state transitions and no dead ends
- [x] AC-7: `@mosaicstack/mosaic` is the sole `mosaic` binary owner; `@mosaicstack/cli` is gone from the repo and all docs
- [x] AC-8: All milestones ship as merged PRs with green CI, closed issues, and updated release notes
## Milestones
| # | ID | Name | Status | Branch | Issue | Started | Completed |
| --- | ------ | ------------------------------------------------------------------------ | ------ | ----------------------------------- | --------------------------------- | ---------- | ---------- |
| 1 | cu-m01 | Kill legacy @mosaicstack/cli package | done | chore/remove-cli-package-duplicate | #398 | 2026-04-04 | 2026-04-04 |
| 2 | cu-m02 | Archive stale mission state + scaffold new mission | done | docs/mission-cli-unification | #399 | 2026-04-04 | 2026-04-04 |
| 3 | cu-m03 | Fix gateway bootstrap token recovery (server + CLI paths) | done | feat/gateway-token-recovery | #411, #414 | 2026-04-05 | 2026-04-05 |
| 4 | cu-m04 | Alphabetize + group `mosaic --help` output | done | feat/help-sort + feat/mosaic-config | #402, #408 | 2026-04-05 | 2026-04-05 |
| 5 | cu-m05 | Sub-package CLI surface (auth/brain/forge/log/macp/memory/queue/storage) | done | feat/mosaic-\*-cli (x9) | #403#407, #410, #412, #413, #415 | 2026-04-05 | 2026-04-05 |
| 6 | cu-m06 | `mosaic telemetry` — local OTEL + opt-in remote upload | done | feat/mosaic-telemetry | #417 | 2026-04-05 | 2026-04-05 |
| 7 | cu-m07 | Unified first-run UX (install.sh → wizard → gateway → TUI) | done | feat/mosaic-first-run-ux | #418 | 2026-04-05 | 2026-04-05 |
| 8 | cu-m08 | Docs refresh + release tag | done | docs/cli-unification-release-v0.1.0 | #419 | 2026-04-05 | 2026-04-05 |
## Deployment
| Target | URL | Method |
| -------------------- | --------- | ----------------------------------------------- |
| Local tier (default) | localhost | `mosaic gateway install` — pglite + local queue |
| Team tier | any host | `mosaic gateway install` — PG + Valkey |
| Docker Compose (dev) | localhost | `docker compose up` for PG/Valkey/OTEL/Jaeger |
## Coordination
- **Primary Agent:** claude-opus-4-6[1m]
- **Sibling Agents:** sonnet (standard implementation), haiku (status/explore/verify), codex (coding-heavy tasks)
- **Shared Contracts:** `docs/PRD.md` (existing v0.1.0 PRD — still the long-term target), this manifest, `docs/TASKS.md`, `docs/scratchpads/cli-unification-20260404.md`
## Token Budget
| Metric | Value |
| ------ | ------ |
| Budget | TBD |
| Used | ~80K |
| Mode | normal |
## Session History
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
| ------- | --------------- | ---------- | -------- | ---------------- | ------------------------------------------------------------ |
| 1 | claude-opus-4-6 | 2026-04-04 | ~4h | context-budget | cu-m01 + cu-m02 merged (#398, #399); open questions resolved |
| 2 | claude-opus-4-6 | 2026-04-05 | ~6h | mission-complete | cu-m03..cu-m08 all merged; mosaic-v0.1.0 released |
## Scratchpad
Path: `docs/scratchpads/cli-unification-20260404.md`

View File

@@ -0,0 +1,90 @@
# Tasks — CLI Unification & E2E First-Run
> Single-writer: orchestrator only. Workers read but never modify.
>
> **Mission:** cli-unification-20260404
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `glm-5` | `—` (auto)
## Milestone 1 — Kill legacy @mosaicstack/cli (done)
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ------ | ----------------------------------------------------------------- | ----- | ----- | ---------------------------------- | ---------- | -------- | --------------------------- |
| CU-01-01 | done | Delete packages/cli directory; update workspace + docs references | #398 | opus | chore/remove-cli-package-duplicate | — | 5K | Merged c39433c3. 6685 LOC. |
## Milestone 2 — Archive stale mission + scaffold new mission (done)
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ------ | ------------------------------------------------------------------ | ----- | ----- | ---------------------------- | ---------- | -------- | --------------------------------- |
| CU-02-01 | done | Move stale MISSION-MANIFEST / TASKS / PRD-Harness to docs/archive/ | #399 | opus | docs/mission-cli-unification | CU-01-01 | 3K | Harness + storage missions done. |
| CU-02-02 | done | Scaffold new MISSION-MANIFEST.md, TASKS.md, scratchpad | #399 | opus | docs/mission-cli-unification | CU-02-01 | 5K | This file + manifest + scratchpad |
| CU-02-03 | done | PR review, merge, branch cleanup | #399 | opus | docs/mission-cli-unification | CU-02-02 | 2K | Merged as 6f15a84c |
## Milestone 3 — Gateway bootstrap token recovery
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ------ | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----------------------------- |
| CU-03-01 | done | Implementation plan for BetterAuth-cookie recovery flow (decision locked 2026-04-04) | — | opus | — | CU-02-03 | 4K | Design locked; plan-only task |
| CU-03-02 | done | Server: add recovery/rotate endpoint on apps/gateway/src/admin (gated by design from CU-03-01) | — | sonnet | — | CU-03-01 | 12K | |
| CU-03-03 | done | CLI: `mosaic gateway login` — interactive BetterAuth sign-in, persist session | — | sonnet | — | CU-03-02 | 10K | |
| CU-03-04 | done | CLI: `mosaic gateway config rotate-token` — mint new admin token via authenticated API | — | sonnet | — | CU-03-03 | 8K | |
| CU-03-05 | done | CLI: `mosaic gateway config recover-token` — execute the recovery flow from CU-03-01 | — | sonnet | — | CU-03-03 | 10K | |
| CU-03-06 | done | Install UX: fix the "user exists, no token" dead-end in runInstall bootstrapFirstUser path | — | sonnet | — | CU-03-05 | 8K | |
| CU-03-07 | done | Tests: integration tests for each recovery path (happy + error) | — | sonnet | — | CU-03-06 | 10K | |
| CU-03-08 | done | Code review + remediation | — | haiku | — | CU-03-07 | 4K | |
## Milestone 4 — `mosaic --help` alphabetize + grouping
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------------------- |
| CU-04-01 | done | Enable `configureHelp({ sortSubcommands: true })` on root program and each subgroup | — | sonnet | — | CU-02-03 | 3K | |
| CU-04-02 | done | Group commands into sections (Runtime, Gateway, Framework, Platform) in help output | — | sonnet | — | CU-04-01 | 5K | |
| CU-04-03 | done | Verify help snapshots render readably; update any docs with stale output | — | haiku | — | CU-04-02 | 3K | |
| CU-04-04 | done | Top-level `mosaic config` command — `show`, `get <key>`, `set <key> <val>`, `edit`, `path` — wraps packages/mosaic/src/config/config-service.ts (framework/agent config; distinct from `mosaic gateway config`) | — | sonnet | — | CU-02-03 | 10K | New scope (decision 2026-04-04) |
| CU-04-05 | done | Tests + code review for CU-04-04 | — | haiku | — | CU-04-04 | 4K | |
## Milestone 5 — Sub-package CLI surface
> Pattern: each sub-package exports `register<Name>Command(program: Command)` co-located with the library code (proven by `@mosaicstack/quality-rails`). Wire into `packages/mosaic/src/cli.ts`.
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ------ | --------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------- |
| CU-05-01 | done | `mosaic forge` — subcommands: `run`, `status`, `resume`, `personas list` | — | sonnet | — | CU-02-03 | 18K | User priority |
| CU-05-02 | done | `mosaic storage` — subcommands: `status`, `tier show`, `tier switch`, `export`, `import`, `migrate` | — | sonnet | — | CU-02-03 | 15K | |
| CU-05-03 | done | `mosaic queue` — subcommands: `list`, `stats`, `pause/resume`, `jobs tail`, `drain` | — | sonnet | — | CU-02-03 | 12K | |
| CU-05-04 | done | `mosaic memory` — subcommands: `search`, `stats`, `insights list`, `preferences list` | — | sonnet | — | CU-02-03 | 12K | |
| CU-05-05 | done | `mosaic brain` — subcommands: `projects list/create`, `missions list`, `tasks list`, `conversations list` | — | sonnet | — | CU-02-03 | 15K | |
| CU-05-06 | done | `mosaic auth` — subcommands: `users list/create/delete`, `sso list`, `sso test`, `sessions list` | — | sonnet | — | CU-03-03 | 15K | needs gateway login |
| CU-05-07 | done | `mosaic log` — subcommands: `tail`, `search`, `export`, `level <level>` | — | sonnet | — | CU-02-03 | 10K | |
| CU-05-08 | done | `mosaic macp` — subcommands: `tasks list`, `submit`, `gate`, `events tail` | — | sonnet | — | CU-02-03 | 12K | |
| CU-05-09 | done | Wire all eight `register<Name>Command` calls into packages/mosaic/src/cli.ts | — | haiku | — | CU-05-01…8 | 3K | |
| CU-05-10 | done | Integration test: `mosaic <cmd> --help` exits 0 for every new command | — | haiku | — | CU-05-09 | 5K | |
## Milestone 6 — `mosaic telemetry`
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ------ | ------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ---------------------------------------------- |
| CU-06-01 | done | Add `@mosaicstack/telemetry-client-js` as dependency of `@mosaicstack/mosaic` from Gitea registry | — | sonnet | — | CU-02-03 | 3K | |
| CU-06-02 | done | `mosaic telemetry local` — status, tail, Jaeger link (wraps existing apps/gateway/src/tracing.ts) | — | sonnet | — | CU-06-01 | 8K | |
| CU-06-03 | done | `mosaic telemetry` — status, opt-in, opt-out, test, upload (uses telemetry-client-js) | — | sonnet | — | CU-06-01 | 12K | Dry-run mode when server endpoint not yet live |
| CU-06-04 | done | Persistent consent state in mosaic config; disabled by default | — | sonnet | — | CU-06-03 | 5K | |
| CU-06-05 | done | Tests + code review | — | haiku | — | CU-06-04 | 5K | |
## Milestone 7 — Unified first-run UX
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ------ | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
| CU-07-01 | done | tools/install.sh: after npm install, hand off to `mosaic wizard` then `mosaic gateway install` | — | sonnet | — | CU-03-06 | 10K | |
| CU-07-02 | done | `mosaic wizard` and `mosaic gateway install` coordination: shared state, no duplicate prompts | — | sonnet | — | CU-07-01 | 12K | |
| CU-07-03 | done | Post-install verification step: "gateway healthy, tui connects, admin token on file" | — | sonnet | — | CU-07-02 | 8K | |
| CU-07-04 | done | End-to-end test on a clean container from scratch | — | haiku | — | CU-07-03 | 8K | |
## Milestone 8 — Docs + release
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| -------- | ------ | ---------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
| CU-08-01 | done | Update README.md with new command tree, install flow, and feature list | — | sonnet | — | CU-07-04 | 8K | |
| CU-08-02 | done | Update docs/guides/user-guide.md with all new sub-package commands | — | sonnet | — | CU-08-01 | 10K | |
| CU-08-03 | done | Version bump `@mosaicstack/mosaic`, publish to Gitea registry | — | opus | — | CU-08-02 | 3K | |
| CU-08-04 | done | Release notes, tag `v0.1.0-rc.N`, publish release on Gitea | — | opus | — | CU-08-03 | 3K | |

View File

@@ -8,6 +8,8 @@
4. [Tasks](#tasks) 4. [Tasks](#tasks)
5. [Settings](#settings) 5. [Settings](#settings)
6. [CLI Usage](#cli-usage) 6. [CLI Usage](#cli-usage)
7. [Sub-package Commands](#sub-package-commands)
8. [Telemetry](#telemetry)
--- ---
@@ -160,12 +162,18 @@ The `mosaic` CLI provides a terminal interface to the same gateway API.
### Installation ### Installation
The CLI ships as part of the `@mosaicstack/mosaic` package: Install via the Mosaic installer:
```bash ```bash
# From the monorepo root bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
pnpm --filter @mosaicstack/mosaic build ```
node packages/mosaic/dist/cli.js --help
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
non-interactive use:
```bash
--yes # Accept all defaults
--no-auto-launch # Skip auto-launch of wizard after install
``` ```
Or if installed globally: Or if installed globally:
@@ -174,7 +182,60 @@ Or if installed globally:
mosaic --help mosaic --help
``` ```
### Signing In ### First-Run Wizard
After install the wizard launches automatically. You can re-run it at any time:
```bash
mosaic wizard
```
The wizard guides you through:
1. Gateway discovery or installation (`mosaic gateway install`)
2. Authentication (`mosaic gateway login`)
3. Post-install health check (`mosaic gateway verify`)
### Gateway Login and Token Recovery
```bash
# Authenticate with a gateway and save a session token
mosaic gateway login
# Verify the gateway is reachable and responding
mosaic gateway verify
# Rotate your current API token
mosaic gateway config rotate-token
# Recover a token via BetterAuth cookie (for accounts with no token)
mosaic gateway config recover-token
```
If you have an existing gateway account but lost your token (common after a
reinstall), use `mosaic gateway config recover-token` to retrieve a new one
without recreating your account.
### Configuration
```bash
# Print full config as JSON
mosaic config show
# Read a specific key
mosaic config get gateway.url
# Write a key
mosaic config set gateway.url http://localhost:14242
# Open config in $EDITOR
mosaic config edit
# Print config file path
mosaic config path
```
### Signing In (Legacy)
```bash ```bash
mosaic login --gateway http://localhost:14242 --email you@example.com mosaic login --gateway http://localhost:14242 --email you@example.com
@@ -236,3 +297,267 @@ mosaic prdy
# Quality rails scaffolder # Quality rails scaffolder
mosaic quality-rails mosaic quality-rails
``` ```
---
## Sub-package Commands
Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI.
All sub-package commands accept `--help` for usage details.
### `mosaic auth` — User & Authentication Management
Manage gateway users, SSO providers, and active sessions.
```bash
# List all users
mosaic auth users list
# Create a new user
mosaic auth users create --email alice@example.com --name "Alice"
# Delete a user
mosaic auth users delete <userId>
# List configured SSO providers
mosaic auth sso
# List active sessions
mosaic auth sessions list
# Revoke a session
mosaic auth sessions revoke <sessionId>
```
### `mosaic brain` — Projects, Missions, Tasks, Conversations
Browse and manage the brain data layer (PostgreSQL-backed project/mission/task
store).
```bash
# List all projects
mosaic brain projects
# List missions for a project
mosaic brain missions --project <projectId>
# List tasks
mosaic brain tasks --status in-progress
# Browse conversations
mosaic brain conversations
mosaic brain conversations --project <projectId>
```
### `mosaic config` — CLI Configuration
Read and write the `mosaic` CLI configuration file.
```bash
# Show full config
mosaic config show
# Get a value
mosaic config get gateway.url
# Set a value
mosaic config set theme dark
# Open in editor
mosaic config edit
# Print file path
mosaic config path
```
### `mosaic forge` — AI Pipeline Management
Interact with the Forge multi-stage AI delivery pipeline (intake → board review
→ planning → coding → review → deploy).
```bash
# Start a new forge run for a brief
mosaic forge run --brief "Add dark mode toggle to settings"
# Check status of a running pipeline
mosaic forge status
mosaic forge status --run <runId>
# Resume a paused or interrupted run
mosaic forge resume --run <runId>
# List available personas (board review evaluators)
mosaic forge personas
```
### `mosaic gateway` — Gateway Lifecycle
Install, authenticate with, and verify the Mosaic gateway service.
```bash
# Install gateway (guided)
mosaic gateway install
# Verify gateway health post-install
mosaic gateway verify
# Log in and save token
mosaic gateway login
# Rotate API token
mosaic gateway config rotate-token
# Recover token via BetterAuth cookie (lost-token recovery)
mosaic gateway config recover-token
```
### `mosaic log` — Structured Log Access
Query and stream structured logs from the gateway.
```bash
# Stream live logs
mosaic log tail
mosaic log tail --level warn
# Search logs
mosaic log search "database connection"
mosaic log search --since 1h "error"
# Export logs to file
mosaic log export --output logs.json
mosaic log export --since 24h --level error --output errors.json
# Get/set log level
mosaic log level
mosaic log level debug
```
### `mosaic macp` — MACP Protocol
Interact with the MACP credential resolution, gate runner, and event bus.
```bash
# List MACP tasks
mosaic macp tasks
mosaic macp tasks --status pending
# Submit a new MACP task
mosaic macp submit --type credential-resolve --payload '{"key":"OPENAI_API_KEY"}'
# Run a gate check
mosaic macp gate --gate quality-check
# Stream MACP events
mosaic macp events
mosaic macp events --filter credential
```
### `mosaic memory` — Agent Memory
Query and inspect the agent memory layer.
```bash
# Semantic search over memory
mosaic memory search "previous decisions about auth"
# Show memory statistics
mosaic memory stats
# Generate memory insights report
mosaic memory insights
# View stored preferences
mosaic memory preferences
mosaic memory preferences --set editor=neovim
```
### `mosaic queue` — Task Queue (Valkey)
Manage the Valkey-backed task queue.
```bash
# List all queues
mosaic queue list
# Show queue statistics
mosaic queue stats
mosaic queue stats --queue agent-tasks
# Pause a queue
mosaic queue pause agent-tasks
# Resume a paused queue
mosaic queue resume agent-tasks
# List jobs in a queue
mosaic queue jobs agent-tasks
mosaic queue jobs agent-tasks --status failed
# Drain (empty) a queue
mosaic queue drain agent-tasks
```
### `mosaic storage` — Object Storage
Manage object storage tiers and data migrations.
```bash
# Show storage status and usage
mosaic storage status
# List available storage tiers
mosaic storage tier
# Export data from storage
mosaic storage export --bucket agent-artifacts --output ./artifacts.tar.gz
# Import data into storage
mosaic storage import --bucket agent-artifacts --input ./artifacts.tar.gz
# Migrate data between tiers
mosaic storage migrate --from hot --to cold --older-than 30d
```
---
## Telemetry
Mosaic includes an OpenTelemetry-based telemetry system. Local telemetry
(traces, metrics sent to Jaeger) is always available. Remote telemetry upload
requires explicit opt-in.
### Local Telemetry
```bash
# Show local OTEL collector / Jaeger status
mosaic telemetry local status
# Tail live OTEL spans
mosaic telemetry local tail
# Open Jaeger UI URL
mosaic telemetry local jaeger
```
### Remote Telemetry
Remote upload is a no-op (dry-run) until you opt in. Your consent state is
persisted in the config file.
```bash
# Show current consent state
mosaic telemetry status
# Opt in to remote telemetry
mosaic telemetry opt-in
# Opt out (data stays local)
mosaic telemetry opt-out
# Test telemetry pipeline without uploading
mosaic telemetry test
# Upload telemetry (requires opt-in; dry-run otherwise)
mosaic telemetry upload
```

View File

@@ -154,6 +154,91 @@ No code changes to `apps/`, `packages/mosaic/`, or any other runtime package. Se
- **pr-create.sh wrapper bug:** Discovered during M1 — `~/.config/mosaic/tools/git/pr-create.sh` line 158 uses `eval "$CMD"`, which shell-evaluates any backticks / `$(…)` / `${…}` in PR bodies. Workaround: strip backticks from PR bodies (use bold / italic / plain text instead), or use `tea pr create` directly. Captured in openbrain as gotcha. Should be fixed upstream in Mosaic tools repo at some point, but out of scope for this mission. - **pr-create.sh wrapper bug:** Discovered during M1 — `~/.config/mosaic/tools/git/pr-create.sh` line 158 uses `eval "$CMD"`, which shell-evaluates any backticks / `$(…)` / `${…}` in PR bodies. Workaround: strip backticks from PR bodies (use bold / italic / plain text instead), or use `tea pr create` directly. Captured in openbrain as gotcha. Should be fixed upstream in Mosaic tools repo at some point, but out of scope for this mission.
- **Mosaic coord / orchestrator session lock drift:** `.mosaic/orchestrator/session.lock` gets re-written every session launch and shows up as a dirty working tree on branch switch. Not blocking — just noise to ignore. - **Mosaic coord / orchestrator session lock drift:** `.mosaic/orchestrator/session.lock` gets re-written every session launch and shows up as a dirty working tree on branch switch. Not blocking — just noise to ignore.
## Session 2 Log (2026-04-05)
**Session 2 agent:** claude-opus-4-6[1m]
**Mode:** parallel orchestration across worktrees
### Wave 1 — M3 (gateway token recovery)
- CU-03-01 plan landed as PR #401`docs/plans/gateway-token-recovery.md`. Confirmed no server changes needed — AdminGuard already accepts BetterAuth cookies, `POST /api/admin/tokens` is the existing mint endpoint.
- CU-03-02..07 implemented as PR #411: `mosaic gateway login` (interactive BetterAuth sign-in, session persisted), `mosaic gateway config rotate-token`, `mosaic gateway config recover-token`, fix for `bootstrapFirstUser` "user exists, no token" dead-end, 22 new unit tests. New files: `commands/gateway/login.ts`, `commands/gateway/token-ops.ts`.
- CU-03-08 independent code review surfaced 2 BLOCKER findings (session.json world-readable, password echoed during prompt) + 3 important findings (trimmed password, cross-gateway token persistence, unsafe `--password` flag). Remediated in PR #414: `saveSession` writes mode 0o600, new `promptSecret()` uses TTY raw mode, persistence target now matches `--gateway` host, `--password` marked UNSAFE with warning.
### Wave 2 — M4 (help ergonomics + mosaic config)
- CU-04-01..03 landed as PR #402: `configureHelp({ sortSubcommands: true })` on root + gateway subgroup, plus an `addHelpText('after', …)` grouped-reference section (Commander 13 has no native command-group API).
- CU-04-04/05 landed as PR #408: top-level `mosaic config` with `show|get|set|edit|path`, extends `config/config-service.ts` with `readAll`, `getValue`, `setValue`, `getConfigPath`, `isInitialized` + `ConfigSection`/`ResolvedConfig` types. Additive only.
### Wave 3 — M5 (sub-package CLI surface, 8 commands + integration)
Parallel-dispatched in isolated worktrees. All merged:
- PR #403 `mosaic brain`, PR #404 `mosaic queue`, PR #405 `mosaic storage`, PR #406 `mosaic memory`, PR #407 `mosaic log`, PR #410 `mosaic macp`, PR #412 `mosaic forge`, PR #413 `mosaic auth`.
- Every package exports `register<Name>Command(parent: Command)` co-located with library code, following `@mosaicstack/quality-rails` pattern. Each wired into `packages/mosaic/src/cli.ts` with alphabetized `register…Command(program)` calls.
- PR #415 landed CU-05-10 integration smoke test (`packages/mosaic/src/cli-smoke.spec.ts`, 19 tests covering all 9 registrars) PLUS a pre-existing exports bug fix in `packages/macp/package.json` (`default` pointed at `./src/index.ts` instead of `./dist/index.js`, breaking ERR_MODULE_NOT_FOUND when compiled mosaic CLI tried to load macp at runtime). Caught by empirical `node packages/mosaic/dist/cli.js --help` test before merge.
### New gotchas captured in Session 2
- **`pr-create.sh` "Remote repository required" failure:** wrapper can't detect origin in multi-remote contexts. Fallback used throughout: direct Gitea API `curl -X POST …/api/v1/repos/mosaicstack/mosaic-stack/pulls` with body JSON.
- **`publish` workflow killed on post-merge pushes:** pipelines 735, 742, 747, 750, 758, 767 all show the Docker build step killed after `ci` workflow succeeded. Pre-existing infrastructure issue (observed on #714/#715 pre-mission). The `ci` workflow is the authoritative gate; `publish` killing is noise.
- **macp exports.default misaligned:** latent bug from original monorepo consolidation — every other package already pointed at `dist/`. Only exposed when compiled CLI started loading macp at runtime.
- **Commander 13 grouping:** no native command-group API; workaround is `addHelpText('after', groupedReferenceString)` + alphabetized flat list via `sortSubcommands: true`.
### Wave 4 — M6 + M7 (parallel)
- M6 `mosaic telemetry` landed as PR #417 (merge `a531029c`). Full scope CU-06-01..05: `@mosaicstack/telemetry-client-js` shim, `telemetry local {status,tail,jaeger}`, top-level `telemetry {status,opt-in,opt-out,test,upload}` with dry-run default, persistent consent state. New files: `packages/mosaic/src/commands/telemetry.ts`, `src/telemetry/client-shim.ts`, `src/telemetry/consent-store.ts`, plus `telemetry.spec.ts`.
- M7 unified first-run UX landed as PR #418 (merge `872c1245`). Full scope CU-07-01..04: `install.sh` `--yes`/`--no-auto-launch` flags + auto-handoff to wizard + gateway install, wizard/gateway-install coordination via transient state file, `mosaic gateway verify` post-install healthcheck, Docker-based `tools/e2e-install-test.sh`.
### Wave 5 — M8 (release)
- PR #419 (merge `b9d464de`) — CLI unification release v0.1.0. Single cohesive docs + release PR:
- README.md: unified command tree, new install UX, `mosaic gateway` and `mosaic config` sections, removed stale `@mosaicstack/cli` refs.
- docs/guides/user-guide.md: new "Sub-package Commands" + "Telemetry" sections covering all 11 top-level commands.
- `packages/mosaic/package.json`: bumped 0.0.21 → 0.1.0 (CI publishes on merge).
- Git tag: `mosaic-v0.1.0` (scoped to avoid collision with existing `v0.1.0` repo tag) — pushed to origin on merge sha.
- Gitea release: https://git.mosaicstack.dev/mosaicstack/mosaic-stack/releases/tag/mosaic-v0.1.0 — "@mosaicstack/mosaic v0.1.0 — CLI Unification".
### Wave 6 — M8 correction (version regression)
PR #419 bumped `@mosaicstack/mosaic` 0.0.21 → 0.1.0 and released as `mosaic-v0.1.0`. This was wrong on two counts:
1. **Versioning policy violation.** The project stays in `0.0.x` alpha until GA. Minor bump to `0.1.0` jumped out of alpha without authorization.
2. **macp exports fix never reached the registry.** PR #415 fixed `packages/macp/package.json` `exports.default` pointing at `./src/index.ts`, but did NOT bump macp's version. When the post-merge publish workflow ran on #419, it published `@mosaicstack/mosaic@0.1.0` but `@mosaicstack/macp@0.0.2` was "already published" so the fix was silently skipped. Result: users running `mosaic update` got mosaic 0.1.0 which depends on macp and resolves to the still-broken registry copy of macp@0.0.2, failing with `ERR_MODULE_NOT_FOUND` on `./src/index.ts` at CLI startup.
Correction PR:
- `@mosaicstack/mosaic` 0.1.0 → `0.0.22` (stay in alpha)
- `@mosaicstack/macp` 0.0.2 → `0.0.3` (force republish with the exports fix)
- Delete Gitea tag `mosaic-v0.1.0` + release
- Delete `@mosaicstack/mosaic@0.1.0` from the Gitea npm registry so `latest` reverts to the highest remaining version
- Create tag `mosaic-v0.0.22` + Gitea release
**Lesson captured:** every package whose _source_ changes must also have its _version_ bumped, because the publish workflow silently skips "already published" versions. `@mosaicstack/macp@0.0.2` had the bad exports in the registry from day one; the in-repo fix in #415 was invisible to installed-from-registry consumers until the version bumped.
### Wave 7 — Waves 2 & 3 correction (same systemic bug)
After Wave 6's correction (PR #421) landed `mosaic-v0.0.22`, a clean global install still crashed with `Named export 'registerBrainCommand' not found` — and after fixing brain/forge/log in PR #422, the next clean install crashed with `registerMemoryCommand` not found. Same root cause: M5 (PR #416) added `registerXCommand` exports to memory, queue, storage, brain, forge, log, and config but only bumped a subset of versions. The publish workflow silently skipped every unchanged-version package, leaving the M5 exports absent from the registry.
Three cascaded correction PRs were required because each attempt only surfaced the next stale package at runtime:
- **PR #421** — macp 0.0.2 → 0.0.3, mosaic 0.1.0 → 0.0.22, delete `mosaic-v0.1.0` tag/release/registry version
- **PR #422** — brain/forge/log 0.0.2 → 0.0.3, mosaic 0.0.22 → 0.0.23
- **PR #423** — memory/queue/storage 0.0.3 → 0.0.4, mosaic 0.0.23 → 0.0.24
**First clean end-to-end verification** after PR #423:
```
$ npm i -g @mosaicstack/mosaic@latest # installs 0.0.24
$ mosaic --help # exits 0, prints full alphabetized command list
```
**Systemic fix (follow-up):** The publish workflow's "already published, skipping" tolerance is dangerous when source changes without version bumps. Options to prevent recurrence: (a) fail publish if any workspace package's dist files differ from registry content at the same version, or (b) CI lint check that any `packages/*/src/**` change in a PR also modifies `packages/*/package.json` version.
### Mission outcome
All 8 milestones, all 8 success criteria met in-repo. Released as `mosaic-v0.0.24` (alpha) after three cascaded correction PRs (#421, #422, #423) fixing the same systemic publish-skip bug across macp, brain, forge, log, memory, queue, and storage. First version where `npm i -g @mosaicstack/mosaic@latest && mosaic --help` works end-to-end from a clean global install.
## Verification Evidence ## Verification Evidence
### CU-01-01 (PR #398) ### CU-01-01 (PR #398)

View File

@@ -0,0 +1,158 @@
# Install UX Hardening — IUH-M01 Session Notes
## Session: 2026-04-05 (agent-ad6b6696)
### Plan
**Manifest schema decision:**
- Version 1 JSON at `~/.config/mosaic/.install-manifest.json` (mode 0600)
- Written by `tools/install.sh` after successful install
- Fields: version, installedAt, cliVersion, frameworkVersion, mutations{directories, npmGlobalPackages, npmrcLines, shellProfileEdits, runtimeAssetCopies}
- Uninstall reads it; if missing → heuristic mode (warn user)
**File list:**
- NEW: `packages/mosaic/src/runtime/install-manifest.ts` — read/write helpers + types
- NEW: `packages/mosaic/src/runtime/install-manifest.spec.ts` — unit tests
- NEW: `packages/mosaic/src/commands/uninstall.ts` — command implementation
- NEW: `packages/mosaic/src/commands/uninstall.spec.ts` — unit tests
- MOD: `packages/mosaic/src/cli.ts` — register `uninstall` command
- MOD: `tools/install.sh` — write manifest on success + add `--uninstall` path
**Runtime asset list (from mosaic-link-runtime-assets / framework/install.sh):**
- `~/.claude/CLAUDE.md` (source: `$MOSAIC_HOME/runtime/claude/CLAUDE.md`)
- `~/.claude/settings.json` (source: `$MOSAIC_HOME/runtime/claude/settings.json`)
- `~/.claude/hooks-config.json` (source: `$MOSAIC_HOME/runtime/claude/hooks-config.json`)
- `~/.claude/context7-integration.md` (source: `$MOSAIC_HOME/runtime/claude/context7-integration.md`)
- `~/.config/opencode/AGENTS.md` (source: `$MOSAIC_HOME/runtime/opencode/AGENTS.md`)
- `~/.codex/instructions.md` (source: `$MOSAIC_HOME/runtime/codex/instructions.md`)
**Reversal logic:**
1. If `.mosaic-bak-<stamp>` exists for a file → restore it
2. Else if managed copy exists → remove it
3. Never touch files not in the known list
**npmrc reversal:**
- Only remove line `@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
- If manifest has the line, use that as authoritative; else check heuristically
**PATH reversal:**
- Check install.sh: it does NOT add PATH entries to shell profiles (framework/install.sh migration removes old `$MOSAIC_HOME/bin` PATH entries in v0/v1→v2 migration, but new install does NOT add PATH)
- ASSUMPTION: No PATH edits in current install (v0.0.24+). Shell profiles not modified by current install.
- The `$PREFIX/bin` is mentioned in a warning but NOT added to shell profiles by install.sh.
- shellProfileEdits array will be empty for new installs; heuristic mode also skips it.
**Test strategy:**
- Unit test manifest read/write with temp dir mocking
- Unit test command registration
- Unit test dry-run flag (no actual fs mutations)
- Unit test --keep-data skips protected paths
- Unit test heuristic mode warning
**Implementation order:**
1. install-manifest.ts helpers
2. install-manifest.spec.ts tests
3. uninstall.ts command
4. uninstall.spec.ts tests
5. cli.ts registration
6. tools/install.sh manifest writing + --uninstall path
ASSUMPTION: No PATH modifications in current install.sh (v0.0.24). Framework v0/v1→v2 migration cleaned old PATH entries but current install does not add new ones.
ASSUMPTION: `--uninstall` in install.sh handles framework + cli + npmrc only; gateway teardown deferred to `mosaic gateway uninstall`.
ASSUMPTION: Pi settings.json edits (skills paths) added by framework/install.sh are NOT reversed in this iteration — too risky to touch user Pi config without manifest evidence. Noted as follow-up.
---
## Session 2 — 2026-04-05 (orchestrator resume)
### IUH-M01 completion summary
- **PR:** #429 merged as `25cada77`
- **CI:** green (Woodpecker)
- **Issue:** #425 closed
- **Files:** +1205 lines across 4 new + 2 modified + 1 docs
- **Tests:** 14 new, 170 total passing
### Follow-ups captured from worker report
1. **Pi settings.json reversal deferred** — worker flagged as too risky without manifest evidence. Future IUH task should add manifest entries for Pi settings mutations. Not blocking M02/M03.
2. **Pre-existing `cli-smoke.spec.ts` failure**`@mosaicstack/brain` package entry resolution fails in Vitest. Unrelated to IUH-M01. Worth a separate issue later.
3. **`pr-create.sh` wrapper bug with multiline bodies** — wrapper evals body args as shell when they contain newlines/paths. Worker fell back to Gitea REST API. Same class of bug I hit earlier with `issue-create.sh`. Worth a tooling-team issue to fix both wrappers.
### Mission doc sync
cli-unification docs that were archived before the M01 subagent ran did not travel into the M01 PR (they were local, stashed before pull). Re-applying now:
- `docs/archive/missions/cli-unification-20260404/` (the old manifest + tasks)
- `docs/MISSION-MANIFEST.md` (new install-ux-hardening content)
- `docs/TASKS.md` (new install-ux-hardening content)
Committing as `docs: scaffold install-ux-hardening mission + archive cli-unification`.
### Next action
Delegate IUH-M02 to a sonnet subagent in an isolated worktree.
---
## Session 3: 2026-04-05 (agent-a6ff34a5) — IUH-M02 Wizard Remediation
### Plan
**AC-3: Password masking + confirmation**
- New `packages/mosaic/src/prompter/masked-prompt.ts` — raw-mode stdin reader that suppresses echo, handles backspace/Ctrl+C/Enter.
- `bootstrapFirstUser` in `packages/mosaic/src/commands/gateway/install.ts`: replace `rl.question('Admin password...')` with `promptMaskedPassword()`, require confirm pass, keep min-8 validation.
- Headless path: when `MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`, read `MOSAIC_ADMIN_PASSWORD` env var directly.
**AC-4a: Hooks preview stage**
- New `packages/mosaic/src/stages/hooks-preview.ts` — reads `hooks-config.json` from `state.sourceDir` or `state.mosaicHome`, displays each top-level hook category with name/trigger/command preview, prompts "Install these hooks? [Y/n]", stores result in `state.hooks`.
- `packages/mosaic/src/types.ts` — add `hooks?: { accepted: boolean; acceptedAt?: string }` to `WizardState`.
- `packages/mosaic/src/wizard.ts` — insert `hooksPreviewStage` between `runtimeSetupStage` and `skillsSelectStage`; skip if no claude runtime detected.
**AC-4b: `mosaic config hooks` subcommands**
- Add `hooks` subcommand group to `packages/mosaic/src/commands/config.ts`:
- `list`: reads `~/.claude/hooks-config.json`, shows hook names and enabled/disabled status
- `disable <name>`: prefixes matching hook key with `_disabled_` in the JSON
- `enable <name>`: removes `_disabled_` prefix if present
**AC-5: Headless install path**
- `runConfigWizard`: detect headless mode (`MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`), read env vars with defaults, validate required vars, skip prompts entirely.
- `bootstrapFirstUser`: detect headless mode, read `MOSAIC_ADMIN_NAME/EMAIL/PASSWORD`, validate, proceed without prompts.
- Document env vars in `packages/mosaic/README.md` (create if absent).
### File list
NEW:
- `packages/mosaic/src/prompter/masked-prompt.ts`
- `packages/mosaic/src/prompter/masked-prompt.spec.ts`
- `packages/mosaic/src/stages/hooks-preview.ts`
- `packages/mosaic/src/stages/hooks-preview.spec.ts`
MODIFIED:
- `packages/mosaic/src/types.ts` — extend WizardState
- `packages/mosaic/src/wizard.ts` — wire hooksPreviewStage
- `packages/mosaic/src/commands/gateway/install.ts` — masked password + headless path
- `packages/mosaic/src/commands/config.ts` — add hooks subcommands
- `packages/mosaic/src/commands/config.spec.ts` — extend tests
- `packages/mosaic/README.md` — document env vars
### Assumptions
ASSUMPTION: `hooks-config.json` location is `<sourceDir>/framework/runtime/claude/hooks-config.json` during wizard (sourceDir is package root). Fall back to `<mosaicHome>/runtime/claude/hooks-config.json` for installed config.
ASSUMPTION: The `hooks` subcommands under `config` operate on `~/.claude/hooks-config.json` (the installed copy), not the package source.
ASSUMPTION: For the hooks preview stage, the "name" field displayed per hook entry is the top-level event key (e.g. "PostToolUse") plus the matcher from nested hooks array. This is the most user-readable representation given the hooks-config.json structure.
ASSUMPTION: `config hooks list/enable/disable` use `CLAUDE_HOME` env or `~/.claude` as the target directory for hooks files.
ASSUMPTION: The headless TTY detection (`!process.stdin.isTTY`) is sufficient; `MOSAIC_ASSUME_YES=1` is an explicit override for cases where stdin is a TTY but the user still wants non-interactive (e.g., scripted installs with piped terminal).

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/forge", "name": "@mosaicstack/forge",
"version": "0.0.2", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
@@ -26,7 +26,8 @@
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/macp": "workspace:*" "@mosaicstack/macp": "workspace:*",
"commander": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",

View File

@@ -0,0 +1,57 @@
import { Command } from 'commander';
import { describe, expect, it } from 'vitest';
import { registerForgeCommand } from './cli.js';
describe('registerForgeCommand', () => {
it('registers a "forge" command on the parent program', () => {
const program = new Command();
registerForgeCommand(program);
const forgeCmd = program.commands.find((c) => c.name() === 'forge');
expect(forgeCmd).toBeDefined();
});
it('registers the four required subcommands under forge', () => {
const program = new Command();
registerForgeCommand(program);
const forgeCmd = program.commands.find((c) => c.name() === 'forge');
expect(forgeCmd).toBeDefined();
const subNames = forgeCmd!.commands.map((c) => c.name());
expect(subNames).toContain('run');
expect(subNames).toContain('status');
expect(subNames).toContain('resume');
expect(subNames).toContain('personas');
});
it('registers "personas list" as a subcommand of "forge personas"', () => {
const program = new Command();
registerForgeCommand(program);
const forgeCmd = program.commands.find((c) => c.name() === 'forge');
const personasCmd = forgeCmd!.commands.find((c) => c.name() === 'personas');
expect(personasCmd).toBeDefined();
const personasSubNames = personasCmd!.commands.map((c) => c.name());
expect(personasSubNames).toContain('list');
});
it('does not modify the parent program name or description', () => {
const program = new Command('mosaic');
program.description('Mosaic Stack CLI');
registerForgeCommand(program);
expect(program.name()).toBe('mosaic');
expect(program.description()).toBe('Mosaic Stack CLI');
});
it('can be called multiple times without throwing', () => {
const program = new Command();
expect(() => {
registerForgeCommand(program);
}).not.toThrow();
});
});

280
packages/forge/src/cli.ts Normal file
View File

@@ -0,0 +1,280 @@
import fs from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import { classifyBrief } from './brief-classifier.js';
import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js';
import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js';
import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js';
import type { PipelineOptions, RunManifest, TaskExecutor } from './types.js';
// ---------------------------------------------------------------------------
// Stub executor — used when no real executor is wired at CLI invocation time.
// ---------------------------------------------------------------------------
const stubExecutor: TaskExecutor = {
async submitTask(task) {
console.log(` [forge] stage submitted: ${task.id} (${task.title})`);
},
async waitForCompletion(taskId, _timeoutMs) {
console.log(` [forge] stage complete: ${taskId}`);
return {
task_id: taskId,
status: 'completed' as const,
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
};
},
async getTaskStatus(_taskId) {
return 'completed' as const;
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatDuration(startedAt?: string, completedAt?: string): string {
if (!startedAt || !completedAt) return '-';
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
const secs = Math.round(ms / 1000);
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${secs % 60}s`;
}
function printManifestTable(manifest: RunManifest): void {
console.log(`\nRun ID : ${manifest.runId}`);
console.log(`Status : ${manifest.status}`);
console.log(`Brief : ${manifest.brief}`);
console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`);
console.log(`Updated: ${manifest.updatedAt}`);
console.log('');
console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration');
console.log('-'.repeat(50));
for (const stage of STAGE_SEQUENCE) {
const s = manifest.stages[stage];
if (!s) continue;
const label = (STAGE_LABELS[stage] ?? stage).padEnd(22);
const status = s.status.padEnd(14);
const dur = formatDuration(s.startedAt, s.completedAt);
console.log(`${label}${status}${dur}`);
}
console.log('');
}
function resolveRunDir(runId: string, projectRoot?: string): string {
const root = projectRoot ?? process.cwd();
return path.join(root, '.forge', 'runs', runId);
}
function listRecentRuns(projectRoot?: string): void {
const root = projectRoot ?? process.cwd();
const runsDir = path.join(root, '.forge', 'runs');
if (!fs.existsSync(runsDir)) {
console.log('No runs found. Run `mosaic forge run` to start a pipeline.');
return;
}
const entries = fs
.readdirSync(runsDir)
.filter((name) => fs.statSync(path.join(runsDir, name)).isDirectory())
.sort()
.reverse()
.slice(0, 10);
if (entries.length === 0) {
console.log('No runs found.');
return;
}
console.log('\nRecent runs:');
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief');
console.log('-'.repeat(70));
for (const runId of entries) {
const runDir = path.join(runsDir, runId);
try {
const manifest = loadManifest(runDir);
const status = manifest.status.padEnd(14);
const brief = path.basename(manifest.brief);
console.log(`${runId.padEnd(22)}${status}${brief}`);
} catch {
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`);
}
}
console.log('');
}
// ---------------------------------------------------------------------------
// Register function
// ---------------------------------------------------------------------------
/**
* Register forge subcommands on an existing Commander program.
* Mirrors the pattern used by registerQualityRails in @mosaicstack/quality-rails.
*/
export function registerForgeCommand(parent: Command): void {
const forge = parent.command('forge').description('Run and manage Forge pipelines');
// ── forge run ────────────────────────────────────────────────────────────
forge
.command('run')
.description('Run a Forge pipeline from a brief markdown file')
.requiredOption('--brief <path>', 'Path to the brief markdown file')
.option('--run-id <id>', 'Override the auto-generated run ID')
.option('--resume', 'Resume an existing run instead of starting a new one', false)
.option('--config <path>', 'Path to forge config file (.forge/config.yaml)')
.option('--codebase <path>', 'Codebase root to pass to the pipeline', process.cwd())
.option('--dry-run', 'Print planned stages without executing', false)
.action(
async (opts: {
brief: string;
runId?: string;
resume: boolean;
config?: string;
codebase: string;
dryRun: boolean;
}) => {
const briefPath = path.resolve(opts.brief);
if (!fs.existsSync(briefPath)) {
console.error(`[forge] brief not found: ${briefPath}`);
process.exitCode = 1;
return;
}
const briefContent = fs.readFileSync(briefPath, 'utf-8');
const briefClass = classifyBrief(briefContent);
const projectRoot = opts.codebase;
if (opts.resume) {
const runId = opts.runId ?? generateRunId();
const runDir = resolveRunDir(runId, projectRoot);
console.log(`[forge] resuming run: ${runId}`);
const { resumePipeline } = await import('./pipeline-runner.js');
const result = await resumePipeline(runDir, stubExecutor);
console.log(`[forge] pipeline complete: ${result.runId}`);
return;
}
const pipelineOptions: PipelineOptions = {
briefClass,
codebase: projectRoot,
dryRun: opts.dryRun,
executor: stubExecutor,
};
if (opts.dryRun) {
const { stagesForClass } = await import('./brief-classifier.js');
const stages = stagesForClass(briefClass);
console.log(`[forge] dry-run — brief class: ${briefClass}`);
console.log('[forge] planned stages:');
for (const stage of stages) {
console.log(` - ${stage} (${STAGE_LABELS[stage] ?? stage})`);
}
return;
}
console.log(`[forge] starting pipeline for brief: ${briefPath}`);
console.log(`[forge] classified as: ${briefClass}`);
try {
const result = await runPipeline(briefPath, projectRoot, pipelineOptions);
console.log(`[forge] pipeline complete: ${result.runId}`);
console.log(`[forge] run directory: ${result.runDir}`);
} catch (err) {
console.error(
`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
}
},
);
// ── forge status ─────────────────────────────────────────────────────────
forge
.command('status [runId]')
.description('Show the status of a pipeline run (omit runId to list recent runs)')
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
.action(async (runId: string | undefined, opts: { project: string }) => {
if (!runId) {
listRecentRuns(opts.project);
return;
}
const runDir = resolveRunDir(runId, opts.project);
try {
const manifest = getPipelineStatus(runDir);
printManifestTable(manifest);
} catch (err) {
console.error(
`[forge] could not load run "${runId}": ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
}
});
// ── forge resume ─────────────────────────────────────────────────────────
forge
.command('resume <runId>')
.description('Resume a stopped or failed pipeline run')
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
.action(async (runId: string, opts: { project: string }) => {
const runDir = resolveRunDir(runId, opts.project);
if (!fs.existsSync(runDir)) {
console.error(`[forge] run not found: ${runDir}`);
process.exitCode = 1;
return;
}
console.log(`[forge] resuming run: ${runId}`);
try {
const { resumePipeline } = await import('./pipeline-runner.js');
const result = await resumePipeline(runDir, stubExecutor);
console.log(`[forge] pipeline complete: ${result.runId}`);
console.log(`[forge] run directory: ${result.runDir}`);
} catch (err) {
console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
// ── forge personas ────────────────────────────────────────────────────────
const personas = forge.command('personas').description('Manage Forge board personas');
personas
.command('list')
.description('List configured board personas')
.option(
'--project <path>',
'Project root for persona overrides (defaults to cwd)',
process.cwd(),
)
.option('--board-dir <path>', 'Override the board agents directory')
.action((opts: { project: string; boardDir?: string }) => {
const effectivePersonas = opts.boardDir
? loadBoardPersonas(opts.boardDir)
: getEffectivePersonas(opts.project);
if (effectivePersonas.length === 0) {
console.log('[forge] no board personas configured.');
return;
}
console.log(`\nBoard personas (${effectivePersonas.length}):\n`);
console.log('Slug'.padEnd(24) + 'Name');
console.log('-'.repeat(50));
for (const p of effectivePersonas) {
console.log(`${p.slug.padEnd(24)}${p.name}`);
}
console.log('');
});
}

View File

@@ -80,3 +80,6 @@ export {
resumePipeline, resumePipeline,
getPipelineStatus, getPipelineStatus,
} from './pipeline-runner.js'; } from './pipeline-runner.js';
// CLI
export { registerForgeCommand } from './cli.js';

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/log", "name": "@mosaicstack/log",
"version": "0.0.2", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
@@ -23,6 +23,7 @@
}, },
"dependencies": { "dependencies": {
"@mosaicstack/db": "workspace:*", "@mosaicstack/db": "workspace:*",
"commander": "^13.0.0",
"drizzle-orm": "^0.45.1" "drizzle-orm": "^0.45.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,68 @@
import { Command } from 'commander';
import { describe, it, expect } from 'vitest';
import { registerLogCommand } from './cli.js';
function buildTestProgram(): Command {
const program = new Command('mosaic');
program.exitOverride(); // prevent process.exit in tests
registerLogCommand(program);
return program;
}
describe('registerLogCommand', () => {
it('registers a "log" subcommand on the parent', () => {
const program = buildTestProgram();
const names = program.commands.map((c) => c.name());
expect(names).toContain('log');
});
it('log command has tail, search, export, and level subcommands', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log');
expect(logCmd).toBeDefined();
const subNames = logCmd!.commands.map((c) => c.name());
expect(subNames).toContain('tail');
expect(subNames).toContain('search');
expect(subNames).toContain('export');
expect(subNames).toContain('level');
});
it('tail subcommand has expected options', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const tailCmd = logCmd.commands.find((c) => c.name() === 'tail')!;
const optionNames = tailCmd.options.map((o) => o.long);
expect(optionNames).toContain('--agent');
expect(optionNames).toContain('--level');
expect(optionNames).toContain('--category');
expect(optionNames).toContain('--tier');
expect(optionNames).toContain('--limit');
expect(optionNames).toContain('--db');
});
it('search subcommand accepts a positional query argument', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const searchCmd = logCmd.commands.find((c) => c.name() === 'search')!;
// Commander stores positional args in _args
const argNames = searchCmd.registeredArguments.map((a) => a.name());
expect(argNames).toContain('query');
});
it('export subcommand accepts a positional path argument', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const exportCmd = logCmd.commands.find((c) => c.name() === 'export')!;
const argNames = exportCmd.registeredArguments.map((a) => a.name());
expect(argNames).toContain('path');
});
it('level subcommand accepts a positional level argument', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const levelCmd = logCmd.commands.find((c) => c.name() === 'level')!;
const argNames = levelCmd.registeredArguments.map((a) => a.name());
expect(argNames).toContain('level');
});
});

177
packages/log/src/cli.ts Normal file
View File

@@ -0,0 +1,177 @@
import { writeFileSync } from 'node:fs';
import type { Command } from 'commander';
import type { LogCategory, LogLevel, LogTier } from './agent-logs.js';
interface FilterOptions {
agent?: string;
level?: string;
category?: string;
tier?: string;
limit?: string;
db?: string;
}
function parseLimit(raw: string | undefined, defaultVal = 50): number {
if (!raw) return defaultVal;
const n = parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : defaultVal;
}
function buildQuery(opts: FilterOptions) {
return {
...(opts.agent ? { sessionId: opts.agent } : {}),
...(opts.level ? { level: opts.level as LogLevel } : {}),
...(opts.category ? { category: opts.category as LogCategory } : {}),
...(opts.tier ? { tier: opts.tier as LogTier } : {}),
limit: parseLimit(opts.limit),
};
}
async function openDb(connectionString: string) {
const { createDb } = await import('@mosaicstack/db');
return createDb(connectionString);
}
function resolveConnectionString(opts: FilterOptions): string | undefined {
return opts.db ?? process.env['DATABASE_URL'];
}
/**
* Register log subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
* caller's Command instance directly.
*/
export function registerLogCommand(parent: Command): void {
const log = parent.command('log').description('Query and manage agent logs');
// ─── tail ───────────────────────────────────────────────────────────────
log
.command('tail')
.description('Tail recent agent logs')
.option('--agent <id>', 'Filter by agent/session ID')
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
.option('--limit <n>', 'Number of logs to return (default 50)', '50')
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
.action(async (opts: FilterOptions) => {
const connStr = resolveConnectionString(opts);
if (!connStr) {
console.error('Database connection required: use --db or set DATABASE_URL');
process.exit(1);
}
const handle = await openDb(connStr);
try {
const { createLogService } = await import('./log-service.js');
const svc = createLogService(handle.db);
const query = buildQuery(opts);
const logs = await svc.logs.query(query);
if (logs.length === 0) {
console.log('No logs found.');
return;
}
for (const entry of logs) {
const ts = new Date(entry.createdAt).toISOString();
console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`);
}
} finally {
await handle.close();
}
});
// ─── search ─────────────────────────────────────────────────────────────
log
.command('search <query>')
.description('Full-text search over agent logs')
.option('--agent <id>', 'Filter by agent/session ID')
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
.option('--limit <n>', 'Number of logs to return (default 50)', '50')
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
.action(async (query: string, opts: FilterOptions) => {
const connStr = resolveConnectionString(opts);
if (!connStr) {
console.error('Database connection required: use --db or set DATABASE_URL');
process.exit(1);
}
const handle = await openDb(connStr);
try {
const { createLogService } = await import('./log-service.js');
const svc = createLogService(handle.db);
const baseQuery = buildQuery(opts);
const logs = await svc.logs.query(baseQuery);
const lowerQ = query.toLowerCase();
const matched = logs.filter(
(e) =>
e.content.toLowerCase().includes(lowerQ) ||
(e.metadata != null && JSON.stringify(e.metadata).toLowerCase().includes(lowerQ)),
);
if (matched.length === 0) {
console.log('No matching logs found.');
return;
}
for (const entry of matched) {
const ts = new Date(entry.createdAt).toISOString();
console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`);
}
} finally {
await handle.close();
}
});
// ─── export ─────────────────────────────────────────────────────────────
log
.command('export <path>')
.description('Export matching logs to an NDJSON file')
.option('--agent <id>', 'Filter by agent/session ID')
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
.option('--limit <n>', 'Number of logs to export (default 50)', '50')
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
.action(async (outputPath: string, opts: FilterOptions) => {
const connStr = resolveConnectionString(opts);
if (!connStr) {
console.error('Database connection required: use --db or set DATABASE_URL');
process.exit(1);
}
const handle = await openDb(connStr);
try {
const { createLogService } = await import('./log-service.js');
const svc = createLogService(handle.db);
const query = buildQuery(opts);
const logs = await svc.logs.query(query);
const ndjson = logs.map((e) => JSON.stringify(e)).join('\n');
writeFileSync(outputPath, ndjson, 'utf8');
console.log(`Exported ${logs.length} log(s) to ${outputPath}`);
} finally {
await handle.close();
}
});
// ─── level ──────────────────────────────────────────────────────────────
log
.command('level <level>')
.description('Set runtime log level for the connected log service')
.action((level: string) => {
void level;
console.log(
'Runtime log level adjustment is not supported in current mode (DB-backed log service).',
);
process.exitCode = 0;
});
}

View File

@@ -9,3 +9,4 @@ export {
type LogTier, type LogTier,
type LogQuery, type LogQuery,
} from './agent-logs.js'; } from './agent-logs.js';
export { registerLogCommand } from './cli.js';

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/macp", "name": "@mosaicstack/macp",
"version": "0.0.2", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
@@ -12,7 +12,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"default": "./src/index.ts" "default": "./dist/index.js"
} }
}, },
"scripts": { "scripts": {
@@ -21,6 +21,9 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
"dependencies": {
"commander": "^13.0.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@vitest/coverage-v8": "^2.0.0", "@vitest/coverage-v8": "^2.0.0",

View File

@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerMacpCommand } from './cli.js';
describe('registerMacpCommand', () => {
function buildProgram(): Command {
const program = new Command();
program.exitOverride(); // prevent process.exit in tests
registerMacpCommand(program);
return program;
}
it('registers a "macp" command on the parent', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp');
expect(macpCmd).toBeDefined();
});
it('registers "macp tasks" subcommand group', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks');
expect(tasksCmd).toBeDefined();
});
it('registers "macp tasks list" subcommand with --status and --type flags', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks')!;
const listCmd = tasksCmd.commands.find((c) => c.name() === 'list');
expect(listCmd).toBeDefined();
const optionNames = listCmd!.options.map((o) => o.long);
expect(optionNames).toContain('--status');
expect(optionNames).toContain('--type');
});
it('registers "macp submit" subcommand', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const submitCmd = macpCmd.commands.find((c) => c.name() === 'submit');
expect(submitCmd).toBeDefined();
});
it('registers "macp gate" subcommand with --fail-on flag', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const gateCmd = macpCmd.commands.find((c) => c.name() === 'gate');
expect(gateCmd).toBeDefined();
const optionNames = gateCmd!.options.map((o) => o.long);
expect(optionNames).toContain('--fail-on');
});
it('registers "macp events" subcommand group', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events');
expect(eventsCmd).toBeDefined();
});
it('registers "macp events tail" subcommand', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events')!;
const tailCmd = eventsCmd.commands.find((c) => c.name() === 'tail');
expect(tailCmd).toBeDefined();
});
it('has all required top-level subcommands', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const topLevel = macpCmd.commands.map((c) => c.name());
expect(topLevel).toContain('tasks');
expect(topLevel).toContain('submit');
expect(topLevel).toContain('gate');
expect(topLevel).toContain('events');
});
});

92
packages/macp/src/cli.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { Command } from 'commander';
/**
* Register macp subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
* caller's Command instance directly.
*/
export function registerMacpCommand(parent: Command): void {
const macp = parent.command('macp').description('MACP task and gate management');
// ─── tasks ───────────────────────────────────────────────────────────────
const tasks = macp.command('tasks').description('Manage MACP tasks');
tasks
.command('list')
.description('List MACP tasks')
.option(
'--status <status>',
'Filter by task status (pending|running|gated|completed|failed|escalated)',
)
.option(
'--type <type>',
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
)
.action((opts: { status?: string; type?: string }) => {
// not yet wired — task persistence layer is not present in @mosaicstack/macp
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
if (opts.status) {
console.log(` status filter: ${opts.status}`);
}
if (opts.type) {
console.log(` type filter: ${opts.type}`);
}
process.exitCode = 0;
});
// ─── submit ──────────────────────────────────────────────────────────────
macp
.command('submit <path>')
.description('Submit a task from a JSON/YAML spec file')
.action((specPath: string) => {
// not yet wired — task submission requires a running MACP server
console.log('[macp] submit: not yet wired — use macp package programmatically');
console.log(` spec path: ${specPath}`);
console.log(' task id: (unavailable — no MACP server connected)');
console.log(' status: (unavailable — no MACP server connected)');
process.exitCode = 0;
});
// ─── gate ────────────────────────────────────────────────────────────────
macp
.command('gate <spec>')
.description('Run a gate from a spec string or file path (wraps runGate/runGates)')
.option('--fail-on <mode>', 'Gate fail-on mode: ai|fail|both|none', 'fail')
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
// not yet wired — gate execution requires a task context and event sink
console.log('[macp] gate: not yet wired — use macp package programmatically');
console.log(` spec: ${spec}`);
console.log(` fail-on: ${opts.failOn}`);
console.log(` cwd: ${opts.cwd}`);
console.log(` log: ${opts.log}`);
console.log(` timeout: ${opts.timeout}s`);
process.exitCode = 0;
});
// ─── events ──────────────────────────────────────────────────────────────
const events = macp.command('events').description('Stream MACP events');
events
.command('tail')
.description('Tail MACP events from the event log (wraps event emitter)')
.option('--file <path>', 'Path to the MACP events NDJSON file')
.option('--follow', 'Follow the file for new events (like tail -f)')
.action((opts: { file?: string; follow?: boolean }) => {
// not yet wired — event streaming requires a live event source
console.log('[macp] events tail: not yet wired — use macp package programmatically');
if (opts.file) {
console.log(` file: ${opts.file}`);
}
if (opts.follow) {
console.log(' mode: follow');
}
process.exitCode = 0;
});
}

View File

@@ -41,3 +41,6 @@ export type { NormalizedGate } from './gate-runner.js';
// Event emitter // Event emitter
export { nowISO, appendEvent, emitEvent } from './event-emitter.js'; export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
// CLI
export { registerMacpCommand } from './cli.js';

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/memory", "name": "@mosaicstack/memory",
"version": "0.0.3", "version": "0.0.4",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
@@ -25,6 +25,7 @@
"@mosaicstack/db": "workspace:*", "@mosaicstack/db": "workspace:*",
"@mosaicstack/storage": "workspace:*", "@mosaicstack/storage": "workspace:*",
"@mosaicstack/types": "workspace:*", "@mosaicstack/types": "workspace:*",
"commander": "^13.0.0",
"drizzle-orm": "^0.45.1" "drizzle-orm": "^0.45.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerMemoryCommand } from './cli.js';
/**
* Smoke test — only verifies command wiring.
* Does NOT open a database connection.
*/
describe('registerMemoryCommand', () => {
function buildProgram(): Command {
const program = new Command('mosaic');
program.exitOverride(); // prevent process.exit during tests
registerMemoryCommand(program);
return program;
}
it('registers a "memory" subcommand', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory');
expect(memory).toBeDefined();
});
it('registers "memory search"', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory')!;
const search = memory.commands.find((c) => c.name() === 'search');
expect(search).toBeDefined();
});
it('registers "memory stats"', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory')!;
const stats = memory.commands.find((c) => c.name() === 'stats');
expect(stats).toBeDefined();
});
it('registers "memory insights list"', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory')!;
const insights = memory.commands.find((c) => c.name() === 'insights');
expect(insights).toBeDefined();
const list = insights!.commands.find((c) => c.name() === 'list');
expect(list).toBeDefined();
});
it('registers "memory preferences list"', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory')!;
const preferences = memory.commands.find((c) => c.name() === 'preferences');
expect(preferences).toBeDefined();
const list = preferences!.commands.find((c) => c.name() === 'list');
expect(list).toBeDefined();
});
it('"memory search" has --limit and --agent options', () => {
const program = buildProgram();
const memory = program.commands.find((c) => c.name() === 'memory')!;
const search = memory.commands.find((c) => c.name() === 'search')!;
const optNames = search.options.map((o) => o.long);
expect(optNames).toContain('--limit');
expect(optNames).toContain('--agent');
});
});

179
packages/memory/src/cli.ts Normal file
View File

@@ -0,0 +1,179 @@
import type { Command } from 'commander';
import type { MemoryAdapter } from './types.js';
/**
* Build and return a connected MemoryAdapter from a connection string or
* the MEMORY_DB_URL / DATABASE_URL environment variable.
*
* For pgvector (postgres://...) the connection string is injected into
* DATABASE_URL so that PgVectorAdapter's internal createDb() picks it up.
*
* Throws with a human-readable message if no connection info is available.
*/
async function resolveAdapter(dbOption: string | undefined): Promise<MemoryAdapter> {
const connStr = dbOption ?? process.env['MEMORY_DB_URL'] ?? process.env['DATABASE_URL'];
if (!connStr) {
throw new Error(
'No database connection string provided. ' +
'Pass --db <connection-string> or set MEMORY_DB_URL / DATABASE_URL.',
);
}
// Lazy imports so the module loads cleanly without a live DB during smoke tests.
const { createMemoryAdapter, registerMemoryAdapter } = await import('./factory.js');
if (connStr.startsWith('postgres') || connStr.startsWith('pg')) {
// PgVectorAdapter reads DATABASE_URL via createDb() — inject it here.
process.env['DATABASE_URL'] = connStr;
const { PgVectorAdapter } = await import('./adapters/pgvector.js');
registerMemoryAdapter('pgvector', (cfg) => new PgVectorAdapter(cfg as never));
return createMemoryAdapter({ type: 'pgvector' });
}
// Keyword adapter backed by pglite storage; treat connStr as a data directory.
const { KeywordAdapter } = await import('./adapters/keyword.js');
const { createStorageAdapter, registerStorageAdapter } = await import('@mosaicstack/storage');
const { PgliteAdapter } = await import('@mosaicstack/storage');
registerStorageAdapter('pglite', (cfg) => new PgliteAdapter(cfg as never));
const storage = createStorageAdapter({ type: 'pglite', dataDir: connStr });
registerMemoryAdapter('keyword', (cfg) => new KeywordAdapter(cfg as never));
return createMemoryAdapter({ type: 'keyword', storage });
}
/**
* Register `memory` subcommands on an existing Commander program.
* Follows the registerQualityRails pattern from @mosaicstack/quality-rails.
*/
export function registerMemoryCommand(parent: Command): void {
const memory = parent.command('memory').description('Inspect and query the Mosaic memory layer');
// ── memory search <query> ──────────────────────────────────────────────
memory
.command('search <query>')
.description('Semantic search over insights')
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--limit <n>', 'Maximum number of results', '10')
.option('--agent <id>', 'Filter by agent / user ID')
.action(async (query: string, opts: { db?: string; limit: string; agent?: string }) => {
let adapter: MemoryAdapter | undefined;
try {
adapter = await resolveAdapter(opts.db);
const limit = parseInt(opts.limit, 10);
const userId = opts.agent ?? 'system';
const results = await adapter.searchInsights(userId, query, { limit });
if (results.length === 0) {
console.log('No insights found.');
} else {
for (const r of results) {
console.log(`[${r.id}] (score=${r.score.toFixed(3)}) ${r.content}`);
}
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
} finally {
await adapter?.close();
}
});
// ── memory stats ──────────────────────────────────────────────────────
memory
.command('stats')
.description('Print memory tier info: adapter type, insight count, preference count')
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--agent <id>', 'User / agent ID scope for counts', 'system')
.action(async (opts: { db?: string; agent: string }) => {
let adapter: MemoryAdapter | undefined;
try {
adapter = await resolveAdapter(opts.db);
const adapterType = adapter.name;
const insightCount = await adapter
.searchInsights(opts.agent, '', { limit: 100000 })
.then((r) => r.length)
.catch(() => -1);
const prefCount = await adapter
.listPreferences(opts.agent)
.then((r) => r.length)
.catch(() => -1);
console.log(`adapter: ${adapterType}`);
console.log(`insights: ${insightCount === -1 ? 'unavailable' : String(insightCount)}`);
console.log(`preferences: ${prefCount === -1 ? 'unavailable' : String(prefCount)}`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
} finally {
await adapter?.close();
}
});
// ── memory insights ───────────────────────────────────────────────────
const insightsCmd = memory.command('insights').description('Manage insights');
insightsCmd
.command('list')
.description('List recent insights')
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--limit <n>', 'Maximum number of results', '20')
.option('--agent <id>', 'User / agent ID scope', 'system')
.action(async (opts: { db?: string; limit: string; agent: string }) => {
let adapter: MemoryAdapter | undefined;
try {
adapter = await resolveAdapter(opts.db);
const limit = parseInt(opts.limit, 10);
const results = await adapter.searchInsights(opts.agent, '', { limit });
if (results.length === 0) {
console.log('No insights found.');
} else {
for (const r of results) {
console.log(`[${r.id}] ${r.content}`);
}
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
} finally {
await adapter?.close();
}
});
// ── memory preferences ────────────────────────────────────────────────
const prefsCmd = memory.command('preferences').description('Manage stored preferences');
prefsCmd
.command('list')
.description('List stored preferences')
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
.option('--agent <id>', 'User / agent ID scope', 'system')
.option('--category <cat>', 'Filter by category')
.action(async (opts: { db?: string; agent: string; category?: string }) => {
let adapter: MemoryAdapter | undefined;
try {
adapter = await resolveAdapter(opts.db);
const prefs = await adapter.listPreferences(opts.agent, opts.category);
if (prefs.length === 0) {
console.log('No preferences found.');
} else {
for (const p of prefs) {
console.log(`[${p.category}] ${p.key} = ${JSON.stringify(p.value)}`);
}
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
} finally {
await adapter?.close();
}
});
}

View File

@@ -1,4 +1,5 @@
export { createMemory, type Memory } from './memory.js'; export { createMemory, type Memory } from './memory.js';
export { registerMemoryCommand } from './cli.js';
export { export {
createPreferencesRepo, createPreferencesRepo,
type PreferencesRepo, type PreferencesRepo,

60
packages/mosaic/README.md Normal file
View File

@@ -0,0 +1,60 @@
# @mosaicstack/mosaic
CLI package for the Mosaic self-hosted AI agent platform.
## Usage
```bash
mosaic wizard # First-run setup wizard
mosaic gateway install # Install the gateway daemon
mosaic config show # View current configuration
mosaic config hooks list # Manage Claude hooks
```
## Headless / CI Installation
Set `MOSAIC_ASSUME_YES=1` (or ensure stdin is not a TTY) to skip all interactive prompts. The following environment variables control the install:
### Gateway configuration (`mosaic gateway install`)
| Variable | Default | Required |
| -------------------------- | ----------------------- | ------------------ |
| `MOSAIC_STORAGE_TIER` | `local` | No |
| `MOSAIC_GATEWAY_PORT` | `14242` | No |
| `MOSAIC_DATABASE_URL` | _(none)_ | Yes if tier=`team` |
| `MOSAIC_VALKEY_URL` | _(none)_ | Yes if tier=`team` |
| `MOSAIC_ANTHROPIC_API_KEY` | _(none)_ | No |
| `MOSAIC_CORS_ORIGIN` | `http://localhost:3000` | No |
### Admin user bootstrap
| Variable | Default | Required |
| ----------------------- | -------- | -------------- |
| `MOSAIC_ADMIN_NAME` | _(none)_ | Yes (headless) |
| `MOSAIC_ADMIN_EMAIL` | _(none)_ | Yes (headless) |
| `MOSAIC_ADMIN_PASSWORD` | _(none)_ | Yes (headless) |
`MOSAIC_ADMIN_PASSWORD` must be at least 8 characters. In headless mode a missing or too-short password causes a non-zero exit.
### Example: Docker / CI install
```bash
export MOSAIC_ASSUME_YES=1
export MOSAIC_ADMIN_NAME="Admin"
export MOSAIC_ADMIN_EMAIL="admin@example.com"
export MOSAIC_ADMIN_PASSWORD="securepass123"
mosaic gateway install
```
## Hooks management
After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`.
```bash
mosaic config hooks list # Show all hooks and enabled/disabled status
mosaic config hooks disable PostToolUse # Disable a hook (reversible)
mosaic config hooks enable PostToolUse # Re-enable a disabled hook
```
Set `CLAUDE_HOME` to override the default `~/.claude` directory.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/mosaic", "name": "@mosaicstack/mosaic",
"version": "0.0.21", "version": "0.0.24",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
@@ -30,9 +30,13 @@
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",
"@mosaicstack/config": "workspace:*", "@mosaicstack/config": "workspace:*",
"@mosaicstack/forge": "workspace:*", "@mosaicstack/forge": "workspace:*",
"@mosaicstack/log": "workspace:*",
"@mosaicstack/macp": "workspace:*", "@mosaicstack/macp": "workspace:*",
"@mosaicstack/memory": "workspace:*",
"@mosaicstack/prdy": "workspace:*", "@mosaicstack/prdy": "workspace:*",
"@mosaicstack/quality-rails": "workspace:*", "@mosaicstack/quality-rails": "workspace:*",
"@mosaicstack/queue": "workspace:*",
"@mosaicstack/storage": "workspace:*",
"@mosaicstack/types": "workspace:*", "@mosaicstack/types": "workspace:*",
"@clack/prompts": "^0.9.1", "@clack/prompts": "^0.9.1",
"commander": "^13.0.0", "commander": "^13.0.0",

View File

@@ -74,7 +74,8 @@ export function saveSession(gatewayUrl: string, auth: AuthResult): void {
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
}; };
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8'); // 0o600: owner read/write only — the session cookie is a credential
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 });
} }
/** /**

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { Command } from 'commander';
import { registerBrainCommand } from '@mosaicstack/brain';
import { registerForgeCommand } from '@mosaicstack/forge';
import { registerLogCommand } from '@mosaicstack/log';
import { registerMacpCommand } from '@mosaicstack/macp';
import { registerMemoryCommand } from '@mosaicstack/memory';
import { registerQueueCommand } from '@mosaicstack/queue';
import { registerStorageCommand } from '@mosaicstack/storage';
import { registerAuthCommand } from './commands/auth.js';
import { registerConfigCommand } from './commands/config.js';
// CU-05-10 — integration smoke test
// Asserts every sub-package CLI registered via register<Name>Command() attaches
// a top-level command to the root program and that its help output renders
// without throwing. This is the "mosaic <cmd> --help exits 0" gate that
// guards the sub-package CLI surface (CU-05-01..08) from silent breakage.
const REGISTRARS: Array<[string, (program: Command) => void]> = [
['auth', registerAuthCommand],
['brain', registerBrainCommand],
['config', registerConfigCommand],
['forge', registerForgeCommand],
['log', registerLogCommand],
['macp', registerMacpCommand],
['memory', registerMemoryCommand],
['queue', registerQueueCommand],
['storage', registerStorageCommand],
];
describe('sub-package CLI smoke (CU-05-10)', () => {
for (const [name, register] of REGISTRARS) {
it(`registers the "${name}" command on the root program`, () => {
const program = new Command();
register(program);
const cmd = program.commands.find((c) => c.name() === name);
expect(cmd, `expected top-level "${name}" command`).toBeDefined();
});
it(`"${name}" help output renders without throwing`, () => {
const program = new Command().exitOverride();
register(program);
const cmd = program.commands.find((c) => c.name() === name);
expect(cmd).toBeDefined();
expect(() => cmd!.helpInformation()).not.toThrow();
});
}
it('all nine sub-package commands coexist on a single program', () => {
const program = new Command();
for (const [, register] of REGISTRARS) register(program);
const names = program.commands.map((c) => c.name()).sort();
expect(names).toEqual([
'auth',
'brain',
'config',
'forge',
'log',
'macp',
'memory',
'queue',
'storage',
]);
});
});

View File

@@ -3,11 +3,21 @@
import { createRequire } from 'module'; import { createRequire } from 'module';
import { Command } from 'commander'; import { Command } from 'commander';
import { registerBrainCommand } from '@mosaicstack/brain'; import { registerBrainCommand } from '@mosaicstack/brain';
import { registerForgeCommand } from '@mosaicstack/forge';
import { registerLogCommand } from '@mosaicstack/log';
import { registerMacpCommand } from '@mosaicstack/macp';
import { registerMemoryCommand } from '@mosaicstack/memory';
import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQualityRails } from '@mosaicstack/quality-rails';
import { registerQueueCommand } from '@mosaicstack/queue';
import { registerStorageCommand } from '@mosaicstack/storage';
import { registerTelemetryCommand } from './commands/telemetry.js';
import { registerAgentCommand } from './commands/agent.js'; import { registerAgentCommand } from './commands/agent.js';
import { registerConfigCommand } from './commands/config.js';
import { registerMissionCommand } from './commands/mission.js'; import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js';
// prdy is registered via launch.ts // prdy is registered via launch.ts
import { registerLaunchCommands } from './commands/launch.js'; import { registerLaunchCommands } from './commands/launch.js';
import { registerAuthCommand } from './commands/auth.js';
import { registerGatewayCommand } from './commands/gateway.js'; import { registerGatewayCommand } from './commands/gateway.js';
import { import {
backgroundUpdateCheck, backgroundUpdateCheck,
@@ -322,6 +332,10 @@ sessionsCmd
} }
}); });
// ─── auth ────────────────────────────────────────────────────────────────
registerAuthCommand(program);
// ─── gateway ────────────────────────────────────────────────────────── // ─── gateway ──────────────────────────────────────────────────────────
registerGatewayCommand(program); registerGatewayCommand(program);
@@ -330,6 +344,10 @@ registerGatewayCommand(program);
registerAgentCommand(program); registerAgentCommand(program);
// ─── config ────────────────────────────────────────────────────────────
registerConfigCommand(program);
// ─── mission ─────────────────────────────────────────────────────────── // ─── mission ───────────────────────────────────────────────────────────
registerMissionCommand(program); registerMissionCommand(program);
@@ -338,10 +356,42 @@ registerMissionCommand(program);
registerBrainCommand(program); registerBrainCommand(program);
// ─── forge ───────────────────────────────────────────────────────────────
registerForgeCommand(program);
// ─── macp ────────────────────────────────────────────────────────────────
registerMacpCommand(program);
// ─── quality-rails ────────────────────────────────────────────────────── // ─── quality-rails ──────────────────────────────────────────────────────
registerQualityRails(program); registerQualityRails(program);
// ─── log ─────────────────────────────────────────────────────────────────
registerLogCommand(program);
// ─── memory ──────────────────────────────────────────────────────────────
registerMemoryCommand(program);
// ─── queue ───────────────────────────────────────────────────────────────
registerQueueCommand(program);
// ─── storage ─────────────────────────────────────────────────────────────
registerStorageCommand(program);
// ─── uninstall ───────────────────────────────────────────────────────────────
registerUninstallCommand(program);
// ─── telemetry ───────────────────────────────────────────────────────────────
registerTelemetryCommand(program);
// ─── update ───────────────────────────────────────────────────────────── // ─── update ─────────────────────────────────────────────────────────────
program program

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Command } from 'commander';
// ─── Mocks ──────────────────────────────────────────────────────────────────
// These mocks prevent any real disk/network access during tests.
vi.mock('./gateway/login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
}));
vi.mock('./gateway/token-ops.js', () => ({
requireSession: vi.fn().mockResolvedValue('better-auth.session_token=test'),
}));
// Global fetch is never called in smoke tests (no actions invoked).
import { registerAuthCommand } from './auth.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function buildTestProgram(): Command {
const program = new Command('mosaic').exitOverride();
registerAuthCommand(program);
return program;
}
function findCommand(program: Command, ...path: string[]): Command | undefined {
let current: Command = program;
for (const name of path) {
const found = current.commands.find((c) => c.name() === name);
if (!found) return undefined;
current = found;
}
return current;
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('registerAuthCommand', () => {
let program: Command;
beforeEach(() => {
vi.clearAllMocks();
program = buildTestProgram();
});
it('registers the top-level auth command', () => {
const authCmd = findCommand(program, 'auth');
expect(authCmd).toBeDefined();
expect(authCmd?.name()).toBe('auth');
});
describe('auth users', () => {
it('registers the users subcommand', () => {
const usersCmd = findCommand(program, 'auth', 'users');
expect(usersCmd).toBeDefined();
});
it('registers users list with --limit flag', () => {
const listCmd = findCommand(program, 'auth', 'users', 'list');
expect(listCmd).toBeDefined();
const limitOpt = listCmd?.options.find((o) => o.long === '--limit');
expect(limitOpt).toBeDefined();
});
it('registers users create', () => {
const createCmd = findCommand(program, 'auth', 'users', 'create');
expect(createCmd).toBeDefined();
});
it('registers users delete with --yes flag', () => {
const deleteCmd = findCommand(program, 'auth', 'users', 'delete');
expect(deleteCmd).toBeDefined();
const yesOpt = deleteCmd?.options.find((o) => o.long === '--yes');
expect(yesOpt).toBeDefined();
});
});
describe('auth sso', () => {
it('registers the sso subcommand', () => {
const ssoCmd = findCommand(program, 'auth', 'sso');
expect(ssoCmd).toBeDefined();
});
it('registers sso list', () => {
const listCmd = findCommand(program, 'auth', 'sso', 'list');
expect(listCmd).toBeDefined();
});
it('registers sso test', () => {
const testCmd = findCommand(program, 'auth', 'sso', 'test');
expect(testCmd).toBeDefined();
});
});
describe('auth sessions', () => {
it('registers the sessions subcommand', () => {
const sessCmd = findCommand(program, 'auth', 'sessions');
expect(sessCmd).toBeDefined();
});
it('registers sessions list', () => {
const listCmd = findCommand(program, 'auth', 'sessions', 'list');
expect(listCmd).toBeDefined();
});
});
it('all top-level auth subcommand names are correct', () => {
const authCmd = findCommand(program, 'auth');
expect(authCmd).toBeDefined();
const names = authCmd!.commands.map((c) => c.name()).sort();
expect(names).toEqual(['sessions', 'sso', 'users']);
});
});

View File

@@ -0,0 +1,331 @@
import type { Command } from 'commander';
import { getGatewayUrl } from './gateway/login.js';
import { requireSession } from './gateway/token-ops.js';
// ─── Types ───────────────────────────────────────────────────────────────────
interface UserDto {
id: string;
name: string;
email: string;
role: string;
banned: boolean;
banReason: string | null;
createdAt: string;
updatedAt: string;
}
interface UserListDto {
users: UserDto[];
total: number;
}
// ─── HTTP helpers ────────────────────────────────────────────────────────────
async function adminGet<T>(gatewayUrl: string, cookie: string, path: string): Promise<T> {
let res: Response;
try {
res = await fetch(`${gatewayUrl}${path}`, {
headers: { Cookie: cookie, Origin: gatewayUrl },
});
} catch (err) {
console.error(
`Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
if (res.status === 401 || res.status === 403) {
console.error(`Session rejected by the gateway (${res.status.toString()}).`);
console.error('Run: mosaic gateway login');
process.exit(2);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`);
process.exit(3);
}
return res.json() as Promise<T>;
}
async function adminPost<T>(
gatewayUrl: string,
cookie: string,
path: string,
body: unknown,
): Promise<T> {
let res: Response;
try {
res = await fetch(`${gatewayUrl}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: cookie,
Origin: gatewayUrl,
},
body: JSON.stringify(body),
});
} catch (err) {
console.error(
`Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
if (res.status === 401 || res.status === 403) {
console.error(`Session rejected by the gateway (${res.status.toString()}).`);
console.error('Run: mosaic gateway login');
process.exit(2);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`);
process.exit(3);
}
return res.json() as Promise<T>;
}
async function adminDelete(gatewayUrl: string, cookie: string, path: string): Promise<void> {
let res: Response;
try {
res = await fetch(`${gatewayUrl}${path}`, {
method: 'DELETE',
headers: { Cookie: cookie, Origin: gatewayUrl },
});
} catch (err) {
console.error(
`Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
if (res.status === 401 || res.status === 403) {
console.error(`Session rejected by the gateway (${res.status.toString()}).`);
console.error('Run: mosaic gateway login');
process.exit(2);
}
if (!res.ok && res.status !== 204) {
const body = await res.text().catch(() => '');
console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`);
process.exit(3);
}
}
// ─── Formatters ──────────────────────────────────────────────────────────────
function printUser(u: UserDto): void {
console.log(` ID: ${u.id}`);
console.log(` Name: ${u.name}`);
console.log(` Email: ${u.email}`);
console.log(` Role: ${u.role}`);
console.log(` Banned: ${u.banned ? `yes (${u.banReason ?? 'no reason'})` : 'no'}`);
console.log(` Created: ${new Date(u.createdAt).toLocaleString()}`);
console.log('');
}
// ─── Register function ───────────────────────────────────────────────────────
/**
* Register `mosaic auth` subcommands on an existing Commander program.
*
* Location rationale: placed in packages/mosaic rather than packages/auth because
* the CLI needs session helpers (loadSession, validateSession, requireSession)
* and gateway URL resolution (getGatewayUrl) that live in packages/mosaic.
* Keeping packages/auth as a pure server-side library avoids adding commander
* and CLI tooling as dependencies there.
*/
export function registerAuthCommand(parent: Command): void {
const auth = parent
.command('auth')
.description('Manage gateway authentication, users, SSO providers, and sessions')
.configureHelp({ sortSubcommands: true })
.action(() => {
auth.outputHelp();
});
// ─── users ──────────────────────────────────────────────────────────────
const users = auth
.command('users')
.description('Manage gateway users')
.configureHelp({ sortSubcommands: true })
.action(() => {
users.outputHelp();
});
users
.command('list')
.description('List all users on the gateway')
.option('-g, --gateway <url>', 'Gateway URL')
.option('-l, --limit <n>', 'Maximum number of users to display', '100')
.action(async (opts: { gateway?: string; limit: string }) => {
const url = getGatewayUrl(opts.gateway);
const cookie = await requireSession(url);
const limit = parseInt(opts.limit, 10);
const result = await adminGet<UserListDto>(url, cookie, '/api/admin/users');
const subset = result.users.slice(0, limit);
if (subset.length === 0) {
console.log('No users found.');
return;
}
console.log(`Users (${subset.length.toString()} of ${result.total.toString()}):\n`);
for (const u of subset) {
printUser(u);
}
});
users
.command('create')
.description('Create a new gateway user (interactive prompts)')
.option('-g, --gateway <url>', 'Gateway URL')
.action(async (opts: { gateway?: string }) => {
const url = getGatewayUrl(opts.gateway);
const cookie = await requireSession(url);
const {
text,
password: clackPassword,
select,
intro,
outro,
isCancel,
} = await import('@clack/prompts');
intro('Create a new Mosaic gateway user');
const name = await text({ message: 'Full name:', placeholder: 'Jane Doe' });
if (isCancel(name)) {
outro('Cancelled.');
process.exit(0);
}
const email = await text({ message: 'Email:', placeholder: 'jane@example.com' });
if (isCancel(email)) {
outro('Cancelled.');
process.exit(0);
}
const pw = await clackPassword({ message: 'Password:' });
if (isCancel(pw)) {
outro('Cancelled.');
process.exit(0);
}
const role = await select({
message: 'Role:',
options: [
{ value: 'member', label: 'member' },
{ value: 'admin', label: 'admin' },
],
});
if (isCancel(role)) {
outro('Cancelled.');
process.exit(0);
}
const created = await adminPost<UserDto>(url, cookie, '/api/admin/users', {
name: name as string,
email: email as string,
password: pw as string,
role: role as string,
});
outro(`User created: ${created.email} (${created.id})`);
});
users
.command('delete <id>')
.description('Delete a gateway user by ID')
.option('-g, --gateway <url>', 'Gateway URL')
.option('-y, --yes', 'Skip confirmation prompt')
.action(async (id: string, opts: { gateway?: string; yes?: boolean }) => {
const url = getGatewayUrl(opts.gateway);
const cookie = await requireSession(url);
if (!opts.yes) {
const { confirm, isCancel } = await import('@clack/prompts');
const confirmed = await confirm({
message: `Delete user ${id}? This cannot be undone.`,
});
if (isCancel(confirmed) || !confirmed) {
console.log('Aborted.');
process.exit(0);
}
}
await adminDelete(url, cookie, `/api/admin/users/${id}`);
console.log(`User ${id} deleted.`);
});
// ─── sso ────────────────────────────────────────────────────────────────
const sso = auth
.command('sso')
.description('Manage SSO provider configuration')
.configureHelp({ sortSubcommands: true })
.action(() => {
sso.outputHelp();
});
sso
.command('list')
.description('List configured SSO providers (reads gateway discovery endpoint if available)')
.option('-g, --gateway <url>', 'Gateway URL')
.action(async (opts: { gateway?: string }) => {
// The admin SSO discovery endpoint is not yet wired server-side.
// The buildSsoDiscovery helper in @mosaicstack/auth reads env-vars on the
// server; there is no GET /api/admin/sso endpoint in apps/gateway/src/admin/.
// Stub until a gateway admin route is wired.
console.log(
'not yet wired — admin endpoint missing (GET /api/admin/sso not implemented server-side)',
);
console.log(
'Hint: SSO providers are configured via environment variables (AUTHENTIK_*, WORKOS_*, KEYCLOAK_*).',
);
// Suppress unused variable warning
void opts;
});
sso
.command('test <provider>')
.description('Smoke-test a configured SSO provider')
.option('-g, --gateway <url>', 'Gateway URL')
.action(async (provider: string, opts: { gateway?: string }) => {
// No server-side SSO smoke-test endpoint exists yet.
console.log(
`not yet wired — admin endpoint missing (POST /api/admin/sso/${provider}/test not implemented server-side)`,
);
void opts;
});
// ─── sessions ────────────────────────────────────────────────────────────
const authSessions = auth
.command('sessions')
.description('Manage BetterAuth user sessions stored on the gateway')
.configureHelp({ sortSubcommands: true })
.action(() => {
authSessions.outputHelp();
});
authSessions
.command('list')
.description('List active user sessions')
.option('-g, --gateway <url>', 'Gateway URL')
.action(async (opts: { gateway?: string }) => {
// No GET /api/admin/auth-sessions endpoint exists in apps/gateway/src/admin/.
// Stub until a gateway admin route is wired.
console.log(
'not yet wired — admin endpoint missing (GET /api/admin/auth-sessions not implemented server-side)',
);
void opts;
});
}

View File

@@ -0,0 +1,434 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Command } from 'commander';
import { registerConfigCommand } from './config.js';
// ── helpers ──────────────────────────────────────────────────────────────────
/** Build a fresh Command tree with the config command registered. */
function buildProgram(): Command {
const program = new Command();
program.exitOverride(); // prevent process.exit during tests
registerConfigCommand(program);
return program;
}
/** Locate the 'config' command registered on the root program. */
function getConfigCmd(program: Command): Command {
const found = program.commands.find((c) => c.name() === 'config');
if (!found) throw new Error('config command not found');
return found;
}
// ── subcommand registration ───────────────────────────────────────────────────
describe('registerConfigCommand', () => {
it('registers a "config" command on the program', () => {
const program = buildProgram();
const names = program.commands.map((c) => c.name());
expect(names).toContain('config');
});
it('registers exactly the required subcommands', () => {
const program = buildProgram();
const config = getConfigCmd(program);
const subs = config.commands.map((c) => c.name()).sort();
expect(subs).toEqual(['edit', 'get', 'hooks', 'path', 'set', 'show']);
});
it('registers hooks sub-subcommands: list, enable, disable', () => {
const program = buildProgram();
const config = getConfigCmd(program);
const hooks = config.commands.find((c) => c.name() === 'hooks');
expect(hooks).toBeDefined();
const hookSubs = hooks!.commands.map((c) => c.name()).sort();
expect(hookSubs).toEqual(['disable', 'enable', 'list']);
});
});
// ── mock config service ───────────────────────────────────────────────────────
const mockSoul = {
agentName: 'TestBot',
roleDescription: 'test role',
communicationStyle: 'direct' as const,
};
const mockUser = { userName: 'Tester', pronouns: 'they/them', timezone: 'UTC' };
const mockTools = { credentialsLocation: '/dev/null' };
const mockSvc = {
readSoul: vi.fn().mockResolvedValue(mockSoul),
readUser: vi.fn().mockResolvedValue(mockUser),
readTools: vi.fn().mockResolvedValue(mockTools),
writeSoul: vi.fn().mockResolvedValue(undefined),
writeUser: vi.fn().mockResolvedValue(undefined),
writeTools: vi.fn().mockResolvedValue(undefined),
syncFramework: vi.fn().mockResolvedValue(undefined),
readAll: vi.fn().mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools }),
getValue: vi.fn().mockResolvedValue('TestBot'),
setValue: vi.fn().mockResolvedValue('OldBot'),
getConfigPath: vi
.fn()
.mockImplementation((section?: string) =>
section
? `/home/user/.config/mosaic/${section.toUpperCase()}.md`
: '/home/user/.config/mosaic',
),
isInitialized: vi.fn().mockReturnValue(true),
};
// Mock the config-service module so commands use our mock.
vi.mock('../config/config-service.js', () => ({
createConfigService: vi.fn(() => mockSvc),
}));
// Also mock child_process for the edit command.
vi.mock('node:child_process', () => ({
spawnSync: vi.fn().mockReturnValue({ status: 0, error: undefined }),
}));
// ── config show ───────────────────────────────────────────────────────────────
describe('config show', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(true);
mockSvc.readAll.mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools });
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('calls readAll() and prints a table by default', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'show']);
expect(mockSvc.readAll).toHaveBeenCalledOnce();
// Should have printed something
expect(consoleSpy).toHaveBeenCalled();
});
it('prints JSON when --format json is passed', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'show', '--format', 'json']);
expect(mockSvc.readAll).toHaveBeenCalledOnce();
// Verify JSON was logged
const allOutput = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(allOutput).toContain('"agentName"');
});
});
// ── config get ────────────────────────────────────────────────────────────────
describe('config get', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(true);
mockSvc.getValue.mockResolvedValue('TestBot');
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('delegates to getValue() with the provided key', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'get', 'soul.agentName']);
expect(mockSvc.getValue).toHaveBeenCalledWith('soul.agentName');
});
it('prints the returned value', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'get', 'soul.agentName']);
expect(consoleSpy).toHaveBeenCalledWith('TestBot');
});
});
// ── config set ────────────────────────────────────────────────────────────────
describe('config set', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(true);
mockSvc.setValue.mockResolvedValue('OldBot');
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('delegates to setValue() with key and value', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'set', 'soul.agentName', 'NewBot']);
expect(mockSvc.setValue).toHaveBeenCalledWith('soul.agentName', 'NewBot');
});
it('prints old and new values', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'set', 'soul.agentName', 'NewBot']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('OldBot');
expect(output).toContain('NewBot');
});
});
// ── config path ───────────────────────────────────────────────────────────────
describe('config path', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.getConfigPath.mockImplementation((section?: string) =>
section
? `/home/user/.config/mosaic/${section.toUpperCase()}.md`
: '/home/user/.config/mosaic',
);
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('prints the mosaicHome directory when no section is specified', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'path']);
expect(mockSvc.getConfigPath).toHaveBeenCalledWith();
expect(consoleSpy).toHaveBeenCalledWith('/home/user/.config/mosaic');
});
it('prints the section file path when --section is given', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'path', '--section', 'soul']);
expect(mockSvc.getConfigPath).toHaveBeenCalledWith('soul');
expect(consoleSpy).toHaveBeenCalledWith('/home/user/.config/mosaic/SOUL.md');
});
});
// ── config edit ───────────────────────────────────────────────────────────────
describe('config edit', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
let spawnSyncMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(true);
mockSvc.readAll.mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools });
mockSvc.getConfigPath.mockImplementation((section?: string) =>
section
? `/home/user/.config/mosaic/${section.toUpperCase()}.md`
: '/home/user/.config/mosaic',
);
// Re-import to get the mock reference
const cp = await import('node:child_process');
spawnSyncMock = cp.spawnSync as ReturnType<typeof vi.fn>;
spawnSyncMock.mockReturnValue({ status: 0, error: undefined });
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('calls spawnSync with the editor binary and config path', async () => {
process.env['EDITOR'] = 'nano';
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'edit']);
expect(spawnSyncMock).toHaveBeenCalledWith(
'nano',
['/home/user/.config/mosaic'],
expect.objectContaining({ stdio: 'inherit' }),
);
delete process.env['EDITOR'];
});
it('falls back to "vi" when EDITOR is not set', async () => {
delete process.env['EDITOR'];
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'edit']);
expect(spawnSyncMock).toHaveBeenCalledWith('vi', expect.any(Array), expect.any(Object));
});
it('opens the section-specific file when --section is provided', async () => {
process.env['EDITOR'] = 'code';
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'edit', '--section', 'soul']);
expect(spawnSyncMock).toHaveBeenCalledWith(
'code',
['/home/user/.config/mosaic/SOUL.md'],
expect.any(Object),
);
delete process.env['EDITOR'];
});
});
// ── config hooks ─────────────────────────────────────────────────────────────
const MOCK_HOOKS_CONFIG = JSON.stringify({
name: 'Test Hooks',
hooks: {
PostToolUse: [
{
matcher: 'Write|Edit',
hooks: [{ type: 'command', command: 'bash', args: ['-c', 'echo'] }],
},
],
},
});
const MOCK_HOOKS_WITH_DISABLED = JSON.stringify({
name: 'Test Hooks',
hooks: {
PostToolUse: [{ matcher: 'Write|Edit', hooks: [] }],
_disabled_PreToolUse: [{ matcher: 'Bash', hooks: [] }],
},
});
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
}));
async function getFsMock() {
const fs = await import('node:fs');
return {
existsSync: fs.existsSync as ReturnType<typeof vi.fn>,
readFileSync: fs.readFileSync as ReturnType<typeof vi.fn>,
writeFileSync: fs.writeFileSync as ReturnType<typeof vi.fn>,
};
}
describe('config hooks list', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(true);
const fs = await getFsMock();
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(MOCK_HOOKS_CONFIG);
// Ensure CLAUDE_HOME is set to a stable value for tests
process.env['CLAUDE_HOME'] = '/tmp/claude-test';
});
afterEach(() => {
consoleSpy.mockRestore();
delete process.env['CLAUDE_HOME'];
});
it('lists hooks with enabled/disabled status', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('PostToolUse');
expect(output).toContain('enabled');
});
it('shows disabled hooks from MOCK_HOOKS_WITH_DISABLED', async () => {
const fs = await getFsMock();
fs.readFileSync.mockReturnValue(MOCK_HOOKS_WITH_DISABLED);
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('disabled');
expect(output).toContain('PreToolUse');
});
it('prints a message when hooks-config.json is missing', async () => {
const fs = await getFsMock();
fs.existsSync.mockReturnValue(false);
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('No hooks-config.json');
});
});
describe('config hooks disable / enable', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(true);
const fs = await getFsMock();
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(MOCK_HOOKS_CONFIG);
process.env['CLAUDE_HOME'] = '/tmp/claude-test';
});
afterEach(() => {
consoleSpy.mockRestore();
delete process.env['CLAUDE_HOME'];
});
it('disables a hook by event name and writes updated config', async () => {
const fs = await getFsMock();
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'disable', 'PostToolUse']);
expect(fs.writeFileSync).toHaveBeenCalled();
const written = JSON.parse((fs.writeFileSync.mock.calls[0] as [string, string])[1]) as {
hooks: Record<string, unknown>;
};
expect(written.hooks['_disabled_PostToolUse']).toBeDefined();
expect(written.hooks['PostToolUse']).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('disabled'));
});
it('enables a disabled hook and writes updated config', async () => {
const fs = await getFsMock();
fs.readFileSync.mockReturnValue(MOCK_HOOKS_WITH_DISABLED);
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'enable', 'PreToolUse']);
expect(fs.writeFileSync).toHaveBeenCalled();
const written = JSON.parse((fs.writeFileSync.mock.calls[0] as [string, string])[1]) as {
hooks: Record<string, unknown>;
};
expect(written.hooks['PreToolUse']).toBeDefined();
expect(written.hooks['_disabled_PreToolUse']).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('enabled'));
});
});
// ── not-initialized guard ────────────────────────────────────────────────────
describe('not-initialized guard', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(false);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
mockSvc.isInitialized.mockReturnValue(true);
});
it('prints a helpful message when config is missing (show)', async () => {
const program = buildProgram();
// process.exit is intercepted; catch the resulting error from exitOverride
await expect(program.parseAsync(['node', 'mosaic', 'config', 'show'])).rejects.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('mosaic wizard'));
});
});

View File

@@ -0,0 +1,404 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import type { Command } from 'commander';
import { createConfigService } from '../config/config-service.js';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
// ── Hooks management helpers ──────────────────────────────────────────────────
const DISABLED_PREFIX = '_disabled_';
/** Resolve the ~/.claude directory (allow override via CLAUDE_HOME env var). */
function getClaudeHome(): string {
return process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude');
}
interface HookEntry {
type?: string;
command?: string;
args?: unknown[];
[key: string]: unknown;
}
interface HookTrigger {
matcher?: string;
hooks?: HookEntry[];
}
interface HooksConfig {
name?: string;
hooks?: Record<string, HookTrigger[]>;
[key: string]: unknown;
}
function readInstalledHooksConfig(claudeHome: string): HooksConfig | null {
const p = join(claudeHome, 'hooks-config.json');
if (!existsSync(p)) return null;
try {
return JSON.parse(readFileSync(p, 'utf-8')) as HooksConfig;
} catch {
return null;
}
}
function writeInstalledHooksConfig(claudeHome: string, config: HooksConfig): void {
const p = join(claudeHome, 'hooks-config.json');
writeFileSync(p, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
}
/**
* Collect a flat list of hook "names" for display purposes.
* A hook name is `<EventName>/<matcher>` (e.g. `PostToolUse/Write|Edit`).
*/
function listHookNames(config: HooksConfig): Array<{ name: string; enabled: boolean }> {
const results: Array<{ name: string; enabled: boolean }> = [];
const events = config.hooks ?? {};
for (const [rawEvent, triggers] of Object.entries(events)) {
const enabled = !rawEvent.startsWith(DISABLED_PREFIX);
const event = enabled ? rawEvent : rawEvent.slice(DISABLED_PREFIX.length);
for (const trigger of triggers) {
const matcher = trigger.matcher ?? '(any)';
results.push({ name: `${event}/${matcher}`, enabled });
}
}
return results;
}
/**
* Resolve mosaicHome from the MOSAIC_HOME env var or the default constant.
*/
function getMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
}
/**
* Guard: print an error and exit(1) if config has not been initialised.
*/
function assertInitialized(svc: ReturnType<typeof createConfigService>): void {
if (!svc.isInitialized()) {
console.error('No config found — run `mosaic wizard` first.');
process.exit(1);
}
}
/**
* Flatten a nested object into dotted-key rows for table display.
*/
function flattenConfig(obj: Record<string, unknown>, prefix = ''): Array<[string, string]> {
const rows: Array<[string, string]> = [];
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
rows.push(...flattenConfig(v as Record<string, unknown>, key));
} else {
rows.push([key, v === undefined || v === null ? '' : String(v)]);
}
}
return rows;
}
/**
* Print rows as a padded ASCII table.
*/
function printTable(rows: Array<[string, string]>): void {
if (rows.length === 0) {
console.log('(no config values)');
return;
}
const maxKey = Math.max(...rows.map(([k]) => k.length));
const header = `${'Key'.padEnd(maxKey)} Value`;
const divider = '-'.repeat(header.length);
console.log(header);
console.log(divider);
for (const [k, v] of rows) {
console.log(`${k.padEnd(maxKey)} ${v}`);
}
}
export function registerConfigCommand(program: Command): void {
const cmd = program
.command('config')
.description('Manage Mosaic framework configuration')
.configureHelp({ sortSubcommands: true });
// ── config show ─────────────────────────────────────────────────────────
cmd
.command('show')
.description('Print the current resolved config')
.option('-f, --format <format>', 'Output format: table or json', 'table')
.action(async (opts: { format: string }) => {
const mosaicHome = getMosaicHome();
const svc = createConfigService(mosaicHome, mosaicHome);
assertInitialized(svc);
const config = await svc.readAll();
if (opts.format === 'json') {
console.log(JSON.stringify(config, null, 2));
return;
}
// Default: table
const rows = flattenConfig(config as unknown as Record<string, unknown>);
printTable(rows);
});
// ── config get <key> ────────────────────────────────────────────────────
cmd
.command('get <key>')
.description('Print a single config value (supports dotted keys, e.g. soul.agentName)')
.action(async (key: string) => {
const mosaicHome = getMosaicHome();
const svc = createConfigService(mosaicHome, mosaicHome);
assertInitialized(svc);
const value = await svc.getValue(key);
if (value === undefined) {
console.error(`Key "${key}" not found.`);
process.exit(1);
}
if (typeof value === 'object') {
console.log(JSON.stringify(value, null, 2));
} else {
console.log(String(value));
}
});
// ── config set <key> <value> ────────────────────────────────────────────
cmd
.command('set <key> <value>')
.description(
'Set a config value and persist (supports dotted keys, e.g. soul.agentName "Jarvis")',
)
.action(async (key: string, value: string) => {
const mosaicHome = getMosaicHome();
const svc = createConfigService(mosaicHome, mosaicHome);
assertInitialized(svc);
let previous: unknown;
try {
previous = await svc.setValue(key, value);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
const prevStr = previous === undefined ? '(unset)' : String(previous);
console.log(`${key}`);
console.log(` old: ${prevStr}`);
console.log(` new: ${value}`);
});
// ── config edit ─────────────────────────────────────────────────────────
cmd
.command('edit')
.description('Open the config directory in $EDITOR (or vi)')
.option('-s, --section <section>', 'Open a specific section file: soul | user | tools')
.action(async (opts: { section?: string }) => {
const mosaicHome = getMosaicHome();
const svc = createConfigService(mosaicHome, mosaicHome);
assertInitialized(svc);
const editor = process.env['EDITOR'] ?? 'vi';
let targetPath: string;
if (opts.section) {
const validSections = ['soul', 'user', 'tools'] as const;
if (!validSections.includes(opts.section as (typeof validSections)[number])) {
console.error(`Invalid section "${opts.section}". Choose: soul, user, tools`);
process.exit(1);
}
targetPath = svc.getConfigPath(opts.section as 'soul' | 'user' | 'tools');
} else {
targetPath = svc.getConfigPath();
}
const result = spawnSync(editor, [targetPath], { stdio: 'inherit' });
if (result.error) {
console.error(`Failed to open editor: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
console.error(`Editor exited with code ${String(result.status ?? 1)}`);
process.exit(result.status ?? 1);
}
// Re-read after edit and report any issues
try {
await svc.readAll();
console.log('Config looks valid.');
} catch (err) {
console.error('Warning: config may have validation issues:');
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ── config hooks ────────────────────────────────────────────────────────
const hookCmd = cmd.command('hooks').description('Manage Mosaic hooks installed in ~/.claude/');
hookCmd
.command('list')
.description('List installed hooks and their enabled/disabled status')
.action(() => {
const claudeHome = getClaudeHome();
const config = readInstalledHooksConfig(claudeHome);
if (!config) {
console.log(
`No hooks-config.json found at ${claudeHome}.\n` +
'Run `mosaic wizard` to install hooks, or copy hooks-config.json manually.',
);
return;
}
const entries = listHookNames(config);
if (entries.length === 0) {
console.log('No hooks defined in hooks-config.json.');
return;
}
const maxName = Math.max(...entries.map((e) => e.name.length));
const header = `${'Hook'.padEnd(maxName)} Status`;
console.log(header);
console.log('-'.repeat(header.length));
for (const { name, enabled } of entries) {
console.log(`${name.padEnd(maxName)} ${enabled ? 'enabled' : 'disabled'}`);
}
});
hookCmd
.command('disable <name>')
.description('Disable a hook by name (prefix with _disabled_). Use "list" to see hook names.')
.action((name: string) => {
const claudeHome = getClaudeHome();
const config = readInstalledHooksConfig(claudeHome);
if (!config) {
console.error(
`No hooks-config.json found at ${claudeHome}.\n` +
'Nothing to disable. Run `mosaic wizard` to install hooks first.',
);
process.exit(1);
}
const events = config.hooks ?? {};
// Support matching by event key or by event/matcher composite
const [targetEvent, targetMatcher] = name.split('/');
// Find the event key (may already have DISABLED_PREFIX)
const existingKey = Object.keys(events).find(
(k) =>
k === targetEvent ||
k === `${DISABLED_PREFIX}${targetEvent}` ||
k.replace(DISABLED_PREFIX, '') === targetEvent,
);
if (!existingKey) {
console.error(`Hook event "${targetEvent}" not found.`);
console.error('Run `mosaic config hooks list` to see available hooks.');
process.exit(1);
}
if (existingKey.startsWith(DISABLED_PREFIX)) {
console.log(`Hook "${name}" is already disabled.`);
return;
}
const disabledKey = `${DISABLED_PREFIX}${existingKey}`;
const triggers = events[existingKey];
delete events[existingKey];
// If a matcher was specified, only disable that trigger
if (targetMatcher && triggers) {
events[disabledKey] = triggers.filter((t) => t.matcher === targetMatcher);
events[existingKey] = triggers.filter((t) => t.matcher !== targetMatcher);
if ((events[existingKey] ?? []).length === 0) delete events[existingKey];
} else {
events[disabledKey] = triggers ?? [];
}
config.hooks = events;
writeInstalledHooksConfig(claudeHome, config);
console.log(`Hook "${name}" disabled.`);
});
hookCmd
.command('enable <name>')
.description('Re-enable a previously disabled hook.')
.action((name: string) => {
const claudeHome = getClaudeHome();
const config = readInstalledHooksConfig(claudeHome);
if (!config) {
console.error(
`No hooks-config.json found at ${claudeHome}.\n` +
'Nothing to enable. Run `mosaic wizard` to install hooks first.',
);
process.exit(1);
}
const events = config.hooks ?? {};
const targetEvent = name.split('/')[0] ?? name;
const disabledKey = `${DISABLED_PREFIX}${targetEvent}`;
if (!events[disabledKey]) {
// Check if it's already enabled
if (events[targetEvent]) {
console.log(`Hook "${name}" is already enabled.`);
} else {
console.error(`Disabled hook "${name}" not found.`);
console.error('Run `mosaic config hooks list` to see available hooks.');
process.exit(1);
}
return;
}
const triggers = events[disabledKey];
delete events[disabledKey];
events[targetEvent] = triggers ?? [];
config.hooks = events;
writeInstalledHooksConfig(claudeHome, config);
console.log(`Hook "${name}" enabled.`);
});
// ── config path ─────────────────────────────────────────────────────────
cmd
.command('path')
.description('Print the active config directory path (for scripting)')
.option(
'-s, --section <section>',
'Print path for a specific section file: soul | user | tools',
)
.action(async (opts: { section?: string }) => {
const mosaicHome = getMosaicHome();
const svc = createConfigService(mosaicHome, mosaicHome);
if (opts.section) {
const validSections = ['soul', 'user', 'tools'] as const;
if (!validSections.includes(opts.section as (typeof validSections)[number])) {
console.error(`Invalid section "${opts.section}". Choose: soul, user, tools`);
process.exit(1);
}
console.log(svc.getConfigPath(opts.section as 'soul' | 'user' | 'tools'));
} else {
console.log(svc.getConfigPath());
}
});
}

View File

@@ -126,10 +126,18 @@ export function registerGatewayCommand(program: Command): void {
.description('Sign in to the gateway (defaults to URL from meta.json)') .description('Sign in to the gateway (defaults to URL from meta.json)')
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)') .option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
.option('-e, --email <email>', 'Email address') .option('-e, --email <email>', 'Email address')
.option('-p, --password <password>', 'Password') .option(
'-p, --password <password>',
'[UNSAFE] Avoid — exposes credentials in shell history and process listings',
)
.action(async (cmdOpts: { gateway?: string; email?: string; password?: string }) => { .action(async (cmdOpts: { gateway?: string; email?: string; password?: string }) => {
const { runLogin } = await import('./gateway/login.js'); const { runLogin } = await import('./gateway/login.js');
const url = getGatewayUrl(cmdOpts.gateway); const url = getGatewayUrl(cmdOpts.gateway);
if (cmdOpts.password) {
console.warn(
'Warning: --password flag exposes credentials in shell history and process listings.',
);
}
try { try {
await runLogin({ gatewayUrl: url, email: cmdOpts.email, password: cmdOpts.password }); await runLogin({ gatewayUrl: url, email: cmdOpts.email, password: cmdOpts.password });
} catch (err) { } catch (err) {
@@ -180,6 +188,16 @@ export function registerGatewayCommand(program: Command): void {
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) }); runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
}); });
// ─── verify ─────────────────────────────────────────────────────────────
gw.command('verify')
.description('Verify the gateway installation (health, token, bootstrap endpoint)')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const { runVerify } = await import('./gateway/verify.js');
await runVerify(opts);
});
// ─── uninstall ────────────────────────────────────────────────────────── // ─── uninstall ──────────────────────────────────────────────────────────
gw.command('uninstall') gw.command('uninstall')

View File

@@ -1,8 +1,10 @@
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { createInterface } from 'node:readline'; import { createInterface } from 'node:readline';
import type { GatewayMeta } from './daemon.js'; import type { GatewayMeta } from './daemon.js';
import { promptMaskedConfirmed } from '../../prompter/masked-prompt.js';
import { import {
ENV_FILE, ENV_FILE,
GATEWAY_HOME, GATEWAY_HOME,
@@ -21,6 +23,39 @@ import {
const MOSAIC_CONFIG_FILE = join(GATEWAY_HOME, 'mosaic.config.json'); const MOSAIC_CONFIG_FILE = join(GATEWAY_HOME, 'mosaic.config.json');
// ─── Wizard session state (transient, CU-07-02) ──────────────────────────────
const INSTALL_STATE_FILE = join(
process.env['XDG_RUNTIME_DIR'] ?? process.env['TMPDIR'] ?? tmpdir(),
'mosaic-install-state.json',
);
interface InstallSessionState {
wizardCompletedAt: string;
mosaicHome: string;
}
function readInstallState(): InstallSessionState | null {
if (!existsSync(INSTALL_STATE_FILE)) return null;
try {
const raw = JSON.parse(readFileSync(INSTALL_STATE_FILE, 'utf-8')) as InstallSessionState;
// Only trust state that is < 10 minutes old
const age = Date.now() - new Date(raw.wizardCompletedAt).getTime();
if (age > 10 * 60 * 1000) return null;
return raw;
} catch {
return null;
}
}
function clearInstallState(): void {
try {
unlinkSync(INSTALL_STATE_FILE);
} catch {
// Ignore — file may already be gone
}
}
interface InstallOpts { interface InstallOpts {
host: string; host: string;
port: number; port: number;
@@ -31,6 +66,15 @@ function prompt(rl: ReturnType<typeof createInterface>, question: string): Promi
return new Promise((resolve) => rl.question(question, resolve)); return new Promise((resolve) => rl.question(question, resolve));
} }
/**
* Returns true when the process should skip interactive prompts.
* Headless mode is activated by `MOSAIC_ASSUME_YES=1` or when stdin is not a
* TTY (piped/redirected — typical in CI and Docker).
*/
function isHeadless(): boolean {
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
}
export async function runInstall(opts: InstallOpts): Promise<void> { export async function runInstall(opts: InstallOpts): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout }); const rl = createInterface({ input: process.stdin, output: process.stdout });
try { try {
@@ -41,6 +85,30 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
} }
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> { async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
// CU-07-02: Check for a fresh wizard session state and apply it.
const sessionState = readInstallState();
if (sessionState) {
const defaultHome = join(homedir(), '.config', 'mosaic');
const customHome = sessionState.mosaicHome !== defaultHome ? sessionState.mosaicHome : null;
if (customHome && !process.env['MOSAIC_GATEWAY_HOME']) {
// The wizard ran with a custom MOSAIC_HOME that differs from the default.
// GATEWAY_HOME is derived from MOSAIC_GATEWAY_HOME (or defaults to
// ~/.config/mosaic/gateway). Set the env var so the rest of this install
// inherits the correct location. This must be set before GATEWAY_HOME is
// evaluated by any imported helper — helpers that re-evaluate the path at
// call time will pick it up automatically.
process.env['MOSAIC_GATEWAY_HOME'] = join(customHome, 'gateway');
console.log(
`Resuming from wizard session — gateway home set to ${process.env['MOSAIC_GATEWAY_HOME']}\n`,
);
} else {
console.log(
`Resuming from wizard session — using ${sessionState.mosaicHome} from earlier.\n`,
);
}
}
const existing = readMeta(); const existing = readMeta();
const envExists = existsSync(ENV_FILE); const envExists = existsSync(ENV_FILE);
const mosaicConfigExists = existsSync(MOSAIC_CONFIG_FILE); const mosaicConfigExists = existsSync(MOSAIC_CONFIG_FILE);
@@ -218,6 +286,13 @@ async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOp
console.log(` Config: ${GATEWAY_HOME}`); console.log(` Config: ${GATEWAY_HOME}`);
console.log(` Logs: mosaic gateway logs`); console.log(` Logs: mosaic gateway logs`);
console.log(` Status: mosaic gateway status`); console.log(` Status: mosaic gateway status`);
// Step 7: Post-install verification (CU-07-03)
const { runPostInstallVerification } = await import('./verify.js');
await runPostInstallVerification(host, port);
// CU-07-02: Clear transient wizard session state on successful install.
clearInstallState();
} }
async function runConfigWizard( async function runConfigWizard(
@@ -233,37 +308,81 @@ async function runConfigWizard(
console.log('(Preserving existing BETTER_AUTH_SECRET — auth sessions will remain valid.)\n'); console.log('(Preserving existing BETTER_AUTH_SECRET — auth sessions will remain valid.)\n');
} }
console.log('Storage tier:'); let tier: 'local' | 'team';
console.log(' 1. Local (embedded database, no dependencies)'); let port: number;
console.log(' 2. Team (PostgreSQL + Valkey required)');
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
const tier = tierAnswer === '2' ? 'team' : 'local';
const port =
opts.port !== 14242
? opts.port
: parseInt(
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
10,
);
let databaseUrl: string | undefined; let databaseUrl: string | undefined;
let valkeyUrl: string | undefined; let valkeyUrl: string | undefined;
let anthropicKey: string;
let corsOrigin: string;
if (tier === 'team') { if (isHeadless()) {
databaseUrl = // ── Headless / non-interactive path ────────────────────────────────────
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) || console.log('Headless mode detected — reading configuration from environment variables.\n');
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
valkeyUrl = const storageTierEnv = process.env['MOSAIC_STORAGE_TIER'] ?? 'local';
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380'; tier = storageTierEnv === 'team' ? 'team' : 'local';
const portEnv = process.env['MOSAIC_GATEWAY_PORT'];
port = portEnv ? parseInt(portEnv, 10) : opts.port;
databaseUrl = process.env['MOSAIC_DATABASE_URL'];
valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
corsOrigin = process.env['MOSAIC_CORS_ORIGIN'] ?? 'http://localhost:3000';
// Validate required vars for team tier
if (tier === 'team') {
const missing: string[] = [];
if (!databaseUrl) missing.push('MOSAIC_DATABASE_URL');
if (!valkeyUrl) missing.push('MOSAIC_VALKEY_URL');
if (missing.length > 0) {
console.error(
`Error: headless install with tier=team requires the following env vars:\n` +
missing.map((v) => ` ${v}`).join('\n'),
);
process.exit(1);
}
}
console.log(` Storage tier: ${tier}`);
console.log(` Gateway port: ${port.toString()}`);
if (tier === 'team') {
console.log(` DATABASE_URL: ${databaseUrl ?? ''}`);
console.log(` VALKEY_URL: ${valkeyUrl ?? ''}`);
}
console.log(` CORS origin: ${corsOrigin}`);
console.log();
} else {
// ── Interactive path ────────────────────────────────────────────────────
console.log('Storage tier:');
console.log(' 1. Local (embedded database, no dependencies)');
console.log(' 2. Team (PostgreSQL + Valkey required)');
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
tier = tierAnswer === '2' ? 'team' : 'local';
port =
opts.port !== 14242
? opts.port
: parseInt(
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
10,
);
if (tier === 'team') {
databaseUrl =
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
valkeyUrl =
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
}
anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
corsOrigin =
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
} }
const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
const corsOrigin =
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex'); const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
const envLines = [ const envLines = [
@@ -423,22 +542,56 @@ async function bootstrapFirstUser(
console.log('─── Admin User Setup ───\n'); console.log('─── Admin User Setup ───\n');
const name = (await prompt(rl, 'Admin name: ')).trim(); let name: string;
if (!name) { let email: string;
console.error('Name is required.'); let password: string;
return;
}
const email = (await prompt(rl, 'Admin email: ')).trim(); if (isHeadless()) {
if (!email) { // ── Headless path ──────────────────────────────────────────────────────
console.error('Email is required.'); const nameEnv = process.env['MOSAIC_ADMIN_NAME']?.trim() ?? '';
return; const emailEnv = process.env['MOSAIC_ADMIN_EMAIL']?.trim() ?? '';
} const passwordEnv = process.env['MOSAIC_ADMIN_PASSWORD'] ?? '';
const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim(); const missing: string[] = [];
if (password.length < 8) { if (!nameEnv) missing.push('MOSAIC_ADMIN_NAME');
console.error('Password must be at least 8 characters.'); if (!emailEnv) missing.push('MOSAIC_ADMIN_EMAIL');
return; if (!passwordEnv) missing.push('MOSAIC_ADMIN_PASSWORD');
if (missing.length > 0) {
console.error(
`Error: headless admin bootstrap requires the following env vars:\n` +
missing.map((v) => ` ${v}`).join('\n'),
);
process.exit(1);
}
if (passwordEnv.length < 8) {
console.error('Error: MOSAIC_ADMIN_PASSWORD must be at least 8 characters.');
process.exit(1);
}
name = nameEnv;
email = emailEnv;
password = passwordEnv;
} else {
// ── Interactive path ────────────────────────────────────────────────────
name = (await prompt(rl, 'Admin name: ')).trim();
if (!name) {
console.error('Name is required.');
return;
}
email = (await prompt(rl, 'Admin email: ')).trim();
if (!email) {
console.error('Email is required.');
return;
}
password = await promptMaskedConfirmed(
'Admin password (min 8 chars): ',
'Confirm password: ',
(v) => (v.length < 8 ? 'Password must be at least 8 characters' : undefined),
);
} }
try { try {

View File

@@ -2,6 +2,62 @@ import { createInterface } from 'node:readline';
import { signIn, saveSession } from '../../auth.js'; import { signIn, saveSession } from '../../auth.js';
import { readMeta } from './daemon.js'; import { readMeta } from './daemon.js';
/**
* Prompt for a single line of input (with echo).
*/
export function promptLine(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
/**
* Prompt for a secret value without echoing the typed characters to the terminal.
* Uses TTY raw mode when available so that passwords do not appear in terminal
* recordings, scrollback, or shared screen sessions.
*/
export function promptSecret(question: string): Promise<string> {
return new Promise((resolve) => {
process.stdout.write(question);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding('utf-8');
let secret = '';
const onData = (char: string): void => {
if (char === '\n' || char === '\r' || char === '\u0004') {
process.stdout.write('\n');
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
process.stdin.removeListener('data', onData);
resolve(secret);
} else if (char === '\u0003') {
// ^C
process.stdout.write('\n');
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
process.stdin.removeListener('data', onData);
process.exit(130);
} else if (char === '\u007f' || char === '\b') {
secret = secret.slice(0, -1);
} else {
secret += char;
}
};
process.stdin.on('data', onData);
});
}
/** /**
* Shared login helper used by both `mosaic login` and `mosaic gateway login`. * Shared login helper used by both `mosaic login` and `mosaic gateway login`.
* Prompts for email/password if not supplied, signs in, and persists the session. * Prompts for email/password if not supplied, signs in, and persists the session.
@@ -11,17 +67,9 @@ export async function runLogin(opts: {
email?: string; email?: string;
password?: string; password?: string;
}): Promise<void> { }): Promise<void> {
let email = opts.email; const email = opts.email ?? (await promptLine('Email: '));
let password = opts.password; // Do not trim password — it may intentionally contain leading/trailing whitespace
const password = opts.password ?? (await promptSecret('Password: '));
if (!email || !password) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
if (!email) email = await ask('Email: ');
if (!password) password = await ask('Password: ');
rl.close();
}
const auth = await signIn(opts.gatewayUrl, email, password); const auth = await signIn(opts.gatewayUrl, email, password);
saveSession(opts.gatewayUrl, auth); saveSession(opts.gatewayUrl, auth);

View File

@@ -16,14 +16,9 @@ vi.mock('./daemon.js', () => ({
vi.mock('./login.js', () => ({ vi.mock('./login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'), getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
})); // promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin
promptLine: vi.fn().mockResolvedValue('test@example.com'),
// Mock readline so tests don't block on stdin promptSecret: vi.fn().mockResolvedValue('test-password'),
vi.mock('node:readline', () => ({
createInterface: vi.fn().mockReturnValue({
question: vi.fn((_q: string, cb: (a: string) => void) => cb('test-input')),
close: vi.fn(),
}),
})); }));
const mockFetch = vi.fn(); const mockFetch = vi.fn();

View File

@@ -1,7 +1,6 @@
import { createInterface } from 'node:readline';
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js'; import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
import { readMeta, writeMeta } from './daemon.js'; import { readMeta, writeMeta } from './daemon.js';
import { getGatewayUrl } from './login.js'; import { getGatewayUrl, promptLine, promptSecret } from './login.js';
interface MintedToken { interface MintedToken {
id: string; id: string;
@@ -58,6 +57,9 @@ export async function mintAdminToken(
/** /**
* Persist the new token into meta.json and print the confirmation banner. * Persist the new token into meta.json and print the confirmation banner.
*
* Emits a warning when the target gateway differs from the locally installed one,
* so operators are aware that meta.json may not reflect the intended gateway.
*/ */
export function persistToken(gatewayUrl: string, minted: MintedToken): void { export function persistToken(gatewayUrl: string, minted: MintedToken): void {
const meta = readMeta() ?? { const meta = readMeta() ?? {
@@ -68,6 +70,15 @@ export function persistToken(gatewayUrl: string, minted: MintedToken): void {
port: parseInt(new URL(gatewayUrl).port || '14242', 10), port: parseInt(new URL(gatewayUrl).port || '14242', 10),
}; };
// Warn when the target gateway does not match the locally installed one
const targetHost = new URL(gatewayUrl).hostname;
if (targetHost !== meta.host) {
console.warn(
`Warning: token was minted against ${gatewayUrl} but is being saved to the local` +
` meta.json (host: ${meta.host}). Copy the token manually if targeting a remote gateway.`,
);
}
writeMeta({ ...meta, adminToken: minted.plaintext }); writeMeta({ ...meta, adminToken: minted.plaintext });
const preview = `${minted.plaintext.slice(0, 8)}...`; const preview = `${minted.plaintext.slice(0, 8)}...`;
@@ -108,13 +119,10 @@ export async function ensureSession(gatewayUrl: string): Promise<string> {
console.log(`No session found for ${gatewayUrl}. Please sign in.`); console.log(`No session found for ${gatewayUrl}. Please sign in.`);
} }
// Prompt for credentials // Prompt for credentials — password must not be echoed to the terminal
const rl = createInterface({ input: process.stdin, output: process.stdout }); const email = await promptLine('Email: ');
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve)); // Do not trim password — it may contain intentional leading/trailing whitespace
const password = await promptSecret('Password: ');
const email = (await ask('Email: ')).trim();
const password = (await ask('Password: ')).trim();
rl.close();
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => { const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err)); console.error(err instanceof Error ? err.message : String(err));

View File

@@ -0,0 +1,117 @@
import { readMeta } from './daemon.js';
// ANSI colour helpers (gracefully degrade when not a TTY)
const isTTY = Boolean(process.stdout.isTTY);
const G = isTTY ? '\x1b[0;32m' : '';
const R = isTTY ? '\x1b[0;31m' : '';
const BOLD = isTTY ? '\x1b[1m' : '';
const RESET = isTTY ? '\x1b[0m' : '';
function ok(label: string): void {
process.stdout.write(` ${G}${RESET} ${label.padEnd(36)}${G}[ok]${RESET}\n`);
}
function fail(label: string, hint: string): void {
process.stdout.write(` ${R}${RESET} ${label.padEnd(36)}${R}[FAIL]${RESET}\n`);
process.stdout.write(` ${R}${hint}${RESET}\n`);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithRetry(
url: string,
opts: RequestInit = {},
retries = 3,
delayMs = 1000,
): Promise<Response | null> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetch(url, opts);
// Retry on non-OK responses too — the gateway may still be starting up
// (e.g. 503 before the app bootstrap completes).
if (res.ok) return res;
} catch {
// Network-level error — not ready yet, will retry
}
if (attempt < retries - 1) await sleep(delayMs);
}
return null;
}
export interface VerifyResult {
gatewayHealthy: boolean;
adminTokenOnFile: boolean;
bootstrapReachable: boolean;
allPassed: boolean;
}
/**
* Run post-install verification checks.
*
* @param host - Gateway hostname (e.g. "localhost")
* @param port - Gateway port (e.g. 14242)
* @returns VerifyResult — callers can inspect individual flags
*/
export async function runPostInstallVerification(
host: string,
port: number,
): Promise<VerifyResult> {
const baseUrl = `http://${host}:${port.toString()}`;
console.log(`\n${BOLD}Mosaic installation verified:${RESET}`);
// ─── Check 1: Gateway /health ─────────────────────────────────────────────
const healthRes = await fetchWithRetry(`${baseUrl}/health`);
const gatewayHealthy = healthRes !== null && healthRes.ok;
if (gatewayHealthy) {
ok('gateway healthy');
} else {
fail('gateway healthy', 'Run: mosaic gateway status / mosaic gateway logs');
}
// ─── Check 2: Admin token on file ─────────────────────────────────────────
const meta = readMeta();
const adminTokenOnFile = Boolean(meta?.adminToken && meta.adminToken.length > 0);
if (adminTokenOnFile) {
ok('admin token on file');
} else {
fail('admin token on file', 'Run: mosaic gateway config recover-token');
}
// ─── Check 3: Bootstrap endpoint reachable ────────────────────────────────
const bootstrapRes = await fetchWithRetry(`${baseUrl}/api/bootstrap/status`);
const bootstrapReachable = bootstrapRes !== null && bootstrapRes.ok;
if (bootstrapReachable) {
ok('bootstrap endpoint reach');
} else {
fail('bootstrap endpoint reach', 'Run: mosaic gateway status / mosaic gateway logs');
}
const allPassed = gatewayHealthy && adminTokenOnFile && bootstrapReachable;
if (!allPassed) {
console.log(
`\n${R}One or more checks failed.${RESET} Recovery commands listed above.\n` +
`Use ${BOLD}mosaic gateway status${RESET} and ${BOLD}mosaic gateway config recover-token${RESET} to investigate.\n`,
);
}
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, allPassed };
}
/**
* Standalone entry point for `mosaic gateway verify`.
* Reads host/port from meta.json if not provided.
*/
export async function runVerify(opts: { host?: string; port?: number }): Promise<void> {
const meta = readMeta();
const host = opts.host ?? meta?.host ?? 'localhost';
const port = opts.port ?? meta?.port ?? 14242;
const result = await runPostInstallVerification(host, port);
if (!result.allPassed) {
process.exit(1);
}
}

View File

@@ -0,0 +1,426 @@
/**
* CU-06-05 — Vitest tests for mosaic telemetry command
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Command } from 'commander';
import { registerTelemetryCommand } from './telemetry.js';
import type { TelemetryConsent } from '../telemetry/consent-store.js';
// ─── module mocks ─────────────────────────────────────────────────────────────
// Mock consent-store so tests don't touch the filesystem.
const mockConsent: TelemetryConsent = {
remoteEnabled: false,
optedInAt: null,
optedOutAt: null,
lastUploadAt: null,
};
vi.mock('../telemetry/consent-store.js', () => ({
readConsent: vi.fn(() => ({ ...mockConsent })),
writeConsent: vi.fn(),
optIn: vi.fn(() => ({
...mockConsent,
remoteEnabled: true,
optedInAt: '2026-01-01T00:00:00.000Z',
})),
optOut: vi.fn(() => ({
...mockConsent,
remoteEnabled: false,
optedOutAt: '2026-01-01T00:00:00.000Z',
})),
recordUpload: vi.fn(),
}));
// Mock the telemetry client shim.
const mockClientInstance = {
init: vi.fn(),
captureEvent: vi.fn(),
upload: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
};
vi.mock('../telemetry/client-shim.js', () => ({
getTelemetryClient: vi.fn(() => mockClientInstance),
setTelemetryClient: vi.fn(),
resetTelemetryClient: vi.fn(),
}));
// Mock @clack/prompts so tests don't require stdin.
vi.mock('@clack/prompts', () => ({
confirm: vi.fn().mockResolvedValue(true),
intro: vi.fn(),
outro: vi.fn(),
isCancel: vi.fn().mockReturnValue(false),
cancel: vi.fn(),
}));
// ─── helpers ──────────────────────────────────────────────────────────────────
function buildProgram(): Command {
const program = new Command();
program.exitOverride();
registerTelemetryCommand(program);
return program;
}
function getTelemetryCmd(program: Command): Command {
const found = program.commands.find((c) => c.name() === 'telemetry');
if (!found) throw new Error('telemetry command not found');
return found;
}
function getLocalCmd(telemetryCmd: Command): Command {
const found = telemetryCmd.commands.find((c) => c.name() === 'local');
if (!found) throw new Error('local subcommand not found');
return found;
}
// ─── CU-06-05 a: command structure smoke test ─────────────────────────────────
describe('registerTelemetryCommand — structure', () => {
it('registers a "telemetry" command on the program', () => {
const program = buildProgram();
const names = program.commands.map((c) => c.name());
expect(names).toContain('telemetry');
});
it('registers the expected top-level subcommands', () => {
const program = buildProgram();
const tel = getTelemetryCmd(program);
const subs = tel.commands.map((c) => c.name()).sort();
expect(subs).toEqual(['local', 'opt-in', 'opt-out', 'status', 'test', 'upload']);
});
it('registers all three local subcommands', () => {
const program = buildProgram();
const local = getLocalCmd(getTelemetryCmd(program));
const subs = local.commands.map((c) => c.name()).sort();
expect(subs).toEqual(['jaeger', 'status', 'tail']);
});
});
// ─── CU-06-05 b: opt-in / opt-out ────────────────────────────────────────────
describe('telemetry opt-in', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
// Provide disabled consent so opt-in path is taken.
const store = await import('../telemetry/consent-store.js');
vi.mocked(store.readConsent).mockReturnValue({
remoteEnabled: false,
optedInAt: null,
optedOutAt: null,
lastUploadAt: null,
});
vi.mocked(store.optIn).mockReturnValue({
remoteEnabled: true,
optedInAt: '2026-01-01T00:00:00.000Z',
optedOutAt: null,
lastUploadAt: null,
});
const clack = await import('@clack/prompts');
vi.mocked(clack.confirm).mockResolvedValue(true);
vi.mocked(clack.isCancel).mockReturnValue(false);
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('calls optIn() when user confirms', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-in']);
const store = await import('../telemetry/consent-store.js');
expect(vi.mocked(store.optIn)).toHaveBeenCalled();
});
it('does not call optIn() when user cancels', async () => {
const clack = await import('@clack/prompts');
vi.mocked(clack.confirm).mockResolvedValue(false);
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-in']);
const store = await import('../telemetry/consent-store.js');
expect(vi.mocked(store.optIn)).not.toHaveBeenCalled();
});
});
describe('telemetry opt-out', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const store = await import('../telemetry/consent-store.js');
vi.mocked(store.readConsent).mockReturnValue({
remoteEnabled: true,
optedInAt: '2026-01-01T00:00:00.000Z',
optedOutAt: null,
lastUploadAt: null,
});
vi.mocked(store.optOut).mockReturnValue({
remoteEnabled: false,
optedInAt: '2026-01-01T00:00:00.000Z',
optedOutAt: '2026-02-01T00:00:00.000Z',
lastUploadAt: null,
});
const clack = await import('@clack/prompts');
vi.mocked(clack.confirm).mockResolvedValue(true);
vi.mocked(clack.isCancel).mockReturnValue(false);
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('calls optOut() when user confirms', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-out']);
const store = await import('../telemetry/consent-store.js');
expect(vi.mocked(store.optOut)).toHaveBeenCalled();
});
it('does not call optOut() when already disabled', async () => {
const store = await import('../telemetry/consent-store.js');
vi.mocked(store.readConsent).mockReturnValue({
remoteEnabled: false,
optedInAt: null,
optedOutAt: null,
lastUploadAt: null,
});
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-out']);
expect(vi.mocked(store.optOut)).not.toHaveBeenCalled();
});
});
// ─── CU-06-05 c: status ──────────────────────────────────────────────────────
describe('telemetry status', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('shows disabled state when remote upload is off', async () => {
const store = await import('../telemetry/consent-store.js');
vi.mocked(store.readConsent).mockReturnValue({
remoteEnabled: false,
optedInAt: null,
optedOutAt: null,
lastUploadAt: null,
});
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('false');
expect(output).toContain('(never)');
});
it('shows enabled state and timestamps when opted in', async () => {
const store = await import('../telemetry/consent-store.js');
vi.mocked(store.readConsent).mockReturnValue({
remoteEnabled: true,
optedInAt: '2026-01-01T00:00:00.000Z',
optedOutAt: null,
lastUploadAt: '2026-03-01T00:00:00.000Z',
});
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('true');
expect(output).toContain('2026-01-01');
expect(output).toContain('2026-03-01');
});
it('shows dry-run banner when MOSAIC_TELEMETRY_DRY_RUN=1', async () => {
process.env['MOSAIC_TELEMETRY_DRY_RUN'] = '1';
const store = await import('../telemetry/consent-store.js');
vi.mocked(store.readConsent).mockReturnValue({
remoteEnabled: false,
optedInAt: null,
optedOutAt: null,
lastUploadAt: null,
});
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('[dry-run]');
delete process.env['MOSAIC_TELEMETRY_DRY_RUN'];
});
});
// ─── CU-06-05 d: test / upload — dry-run assertions ──────────────────────────
describe('telemetry test (dry-run)', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('prints dry-run banner and does not call upload()', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('[dry-run]');
expect(mockClientInstance.upload).not.toHaveBeenCalled();
});
it('calls captureEvent() with a mosaic.cli.test event', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']);
expect(mockClientInstance.captureEvent).toHaveBeenCalledWith(
expect.objectContaining({ name: 'mosaic.cli.test' }),
);
});
it('does not make network calls in dry-run mode', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response());
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']);
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
});
describe('telemetry upload (dry-run default)', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
// Remote disabled by default.
const store = await import('../telemetry/consent-store.js');
vi.mocked(store.readConsent).mockReturnValue({
remoteEnabled: false,
optedInAt: null,
optedOutAt: null,
lastUploadAt: null,
});
});
afterEach(() => {
consoleSpy.mockRestore();
delete process.env['MOSAIC_TELEMETRY_DRY_RUN'];
delete process.env['MOSAIC_TELEMETRY_ENDPOINT'];
});
it('prints dry-run banner when remote upload is disabled', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'upload']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('[dry-run]');
expect(mockClientInstance.upload).not.toHaveBeenCalled();
});
it('prints dry-run banner when MOSAIC_TELEMETRY_DRY_RUN=1 even if opted in', async () => {
process.env['MOSAIC_TELEMETRY_DRY_RUN'] = '1';
process.env['MOSAIC_TELEMETRY_ENDPOINT'] = 'http://example.com/telemetry';
const store = await import('../telemetry/consent-store.js');
vi.mocked(store.readConsent).mockReturnValue({
remoteEnabled: true,
optedInAt: '2026-01-01T00:00:00.000Z',
optedOutAt: null,
lastUploadAt: null,
});
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'upload']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('[dry-run]');
expect(mockClientInstance.upload).not.toHaveBeenCalled();
});
});
// ─── local subcommand smoke tests ─────────────────────────────────────────────
describe('telemetry local tail', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('prints Jaeger UI URL and docker compose hint', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'tail']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('Jaeger');
expect(output).toContain('docker compose');
});
});
describe('telemetry local jaeger', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
delete process.env['JAEGER_UI_URL'];
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('prints the default Jaeger URL', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'jaeger']);
expect(consoleSpy).toHaveBeenCalledWith('http://localhost:16686');
});
it('respects JAEGER_UI_URL env var', async () => {
process.env['JAEGER_UI_URL'] = 'http://jaeger.example.com:16686';
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'jaeger']);
expect(consoleSpy).toHaveBeenCalledWith('http://jaeger.example.com:16686');
delete process.env['JAEGER_UI_URL'];
});
});

View File

@@ -0,0 +1,355 @@
/**
* mosaic telemetry — CU-06-02 (local) + CU-06-03 (remote)
*
* Local half: mosaic telemetry local {status, tail, jaeger}
* Remote half: mosaic telemetry {status, opt-in, opt-out, test, upload}
*
* Remote upload is DISABLED by default (dry-run mode).
* Per session-1 decision: ship upload/test in dry-run-only mode until
* the mosaicstack.dev server endpoint is live.
*
* Telemetry client: uses a forward-compat shim (see telemetry/client-shim.ts)
* because @mosaicstack/telemetry-client-js is not yet published.
*/
import type { Command } from 'commander';
import { confirm, intro, outro, isCancel, cancel } from '@clack/prompts';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { getTelemetryClient } from '../telemetry/client-shim.js';
import { readConsent, optIn, optOut, recordUpload } from '../telemetry/consent-store.js';
// ─── helpers ─────────────────────────────────────────────────────────────────
function getMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
}
function isDryRun(): boolean {
return process.env['MOSAIC_TELEMETRY_DRY_RUN'] === '1';
}
/** Try to open a URL — best-effort, does not fail if unsupported. */
async function tryOpenUrl(url: string): Promise<void> {
try {
const { spawn } = await import('node:child_process');
// `start` is a Windows shell builtin — must be invoked via cmd /c.
const [bin, args] =
process.platform === 'darwin'
? (['open', [url]] as [string, string[]])
: process.platform === 'win32'
? (['cmd', ['/c', 'start', '', url]] as [string, string[]])
: (['xdg-open', [url]] as [string, string[]]);
spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
} catch {
// Best-effort — silently skip if unavailable.
console.log(`Open this URL in your browser: ${url}`);
}
}
// ─── local subcommands ───────────────────────────────────────────────────────
function registerLocalCommand(parent: Command): void {
const local = parent
.command('local')
.description('Inspect the local OpenTelemetry stack')
.configureHelp({ sortSubcommands: true });
// ── telemetry local status ──────────────────────────────────────────────
local
.command('status')
.description('Report reachability of the local OTEL collector endpoint')
.action(async () => {
const endpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? 'http://localhost:4318';
const serviceName = process.env['OTEL_SERVICE_NAME'] ?? 'mosaic-gateway';
const exportInterval = '15000ms'; // matches tracing.ts PeriodicExportingMetricReader
console.log(`OTEL endpoint: ${endpoint}`);
console.log(`Service name: ${serviceName}`);
console.log(`Export interval: ${exportInterval}`);
console.log('');
try {
const response = await fetch(endpoint, {
method: 'GET',
signal: AbortSignal.timeout(3000),
});
// OTLP collector typically returns 404 for GET on the root path —
// but a response means it's listening.
console.log(`Status: reachable (HTTP ${String(response.status)})`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.log(`Status: unreachable — ${msg}`);
console.log('');
console.log('Hint: start the local stack with `docker compose up -d`');
}
});
// ── telemetry local tail ────────────────────────────────────────────────
local
.command('tail')
.description('Explain how to view live traces from the local OTEL stack')
.action(() => {
const jaegerUrl = process.env['JAEGER_UI_URL'] ?? 'http://localhost:16686';
console.log('OTLP is a push protocol — there is no log tail.');
console.log('');
console.log('Traces flow: your service → OTEL Collector → Jaeger');
console.log('');
console.log(`Jaeger UI: ${jaegerUrl}`);
console.log('Run `mosaic telemetry local jaeger` to print the URL (or open it).');
console.log('');
console.log('For raw collector output:');
console.log(' docker compose logs -f otel-collector');
});
// ── telemetry local jaeger ──────────────────────────────────────────────
local
.command('jaeger')
.description('Print the Jaeger UI URL (use --open to launch in browser)')
.option('--open', 'Open the Jaeger UI in the default browser')
.action(async (opts: { open?: boolean }) => {
const jaegerUrl = process.env['JAEGER_UI_URL'] ?? 'http://localhost:16686';
console.log(jaegerUrl);
if (opts.open) {
await tryOpenUrl(jaegerUrl);
}
});
}
// ─── remote subcommands ──────────────────────────────────────────────────────
function registerRemoteStatusCommand(cmd: Command): void {
cmd
.command('status')
.description('Print the remote telemetry upload status and consent state')
.action(() => {
const mosaicHome = getMosaicHome();
const consent = readConsent(mosaicHome);
const remoteEndpoint = process.env['MOSAIC_TELEMETRY_ENDPOINT'] ?? '(not configured)';
const dryRunActive = isDryRun();
console.log('Remote telemetry status');
console.log('─────────────────────────────────────────────');
console.log(` Remote upload enabled: ${String(consent.remoteEnabled)}`);
console.log(` Remote endpoint: ${remoteEndpoint}`);
if (consent.optedInAt) {
console.log(` Opted in: ${consent.optedInAt}`);
}
if (consent.optedOutAt) {
console.log(` Opted out: ${consent.optedOutAt}`);
}
if (consent.lastUploadAt) {
console.log(` Last upload: ${consent.lastUploadAt}`);
} else {
console.log(' Last upload: (never)');
}
if (dryRunActive) {
console.log('');
console.log(' [dry-run] MOSAIC_TELEMETRY_DRY_RUN=1 is set — uploads are suppressed');
}
console.log('');
console.log('Local OTEL stack always active (see `mosaic telemetry local status`).');
});
}
function registerOptInCommand(cmd: Command): void {
cmd
.command('opt-in')
.description('Enable remote telemetry upload (requires explicit consent)')
.action(async () => {
const mosaicHome = getMosaicHome();
const current = readConsent(mosaicHome);
if (current.remoteEnabled) {
console.log('Remote telemetry upload is already enabled.');
console.log(`Opted in: ${current.optedInAt ?? '(unknown)'}`);
return;
}
intro('Mosaic remote telemetry opt-in');
console.log('');
console.log('What gets uploaded:');
console.log(' - CLI command names and completion status (no arguments / values)');
console.log(' - Error types (no stack traces or user data)');
console.log(' - Mosaic version and platform metadata');
console.log('');
console.log('What is NEVER uploaded:');
console.log(' - File contents, code, or credentials');
console.log(' - Personal information or agent conversation data');
console.log('');
console.log('Note: remote upload is currently in dry-run mode until');
console.log(' the mosaicstack.dev telemetry endpoint is live.');
console.log('');
const confirmed = await confirm({
message: 'Enable remote telemetry upload?',
});
if (isCancel(confirmed) || !confirmed) {
cancel('Opt-in cancelled — no changes made.');
return;
}
const consent = optIn(mosaicHome);
outro(`Remote telemetry enabled. Opted in at ${consent.optedInAt ?? ''}`);
});
}
function registerOptOutCommand(cmd: Command): void {
cmd
.command('opt-out')
.description('Disable remote telemetry upload')
.action(async () => {
const mosaicHome = getMosaicHome();
const current = readConsent(mosaicHome);
if (!current.remoteEnabled) {
console.log('Remote telemetry upload is already disabled.');
return;
}
intro('Mosaic remote telemetry opt-out');
console.log('');
console.log('This will disable remote upload of anonymised usage data.');
console.log('Local OTEL tracing (to Jaeger) will remain active — it is');
console.log('independent of this consent state.');
console.log('');
const confirmed = await confirm({
message: 'Disable remote telemetry upload?',
});
if (isCancel(confirmed) || !confirmed) {
cancel('Opt-out cancelled — no changes made.');
return;
}
const consent = optOut(mosaicHome);
outro(`Remote telemetry disabled. Opted out at ${consent.optedOutAt ?? ''}`);
console.log('Local OTEL stack (Jaeger) remains active.');
});
}
function registerTestCommand(cmd: Command): void {
cmd
.command('test')
.description('Synthesise a fake event and print the payload that would be sent (dry-run)')
.option('--upload', 'Actually upload (requires consent + MOSAIC_TELEMETRY_ENDPOINT)')
.action(async (opts: { upload?: boolean }) => {
const mosaicHome = getMosaicHome();
const consent = readConsent(mosaicHome);
const dryRunActive = isDryRun() || !opts.upload;
if (!dryRunActive && !consent.remoteEnabled) {
console.error('Remote upload is not enabled. Run `mosaic telemetry opt-in` first.');
process.exit(1);
}
const fakeEvent = {
name: 'mosaic.cli.test',
properties: {
command: 'telemetry test',
version: process.env['npm_package_version'] ?? 'unknown',
platform: process.platform,
},
timestamp: new Date().toISOString(),
};
const endpoint = process.env['MOSAIC_TELEMETRY_ENDPOINT'];
const client = getTelemetryClient();
client.init({
endpoint,
dryRun: dryRunActive,
labels: { source: 'mosaic-cli' },
});
client.captureEvent(fakeEvent);
if (dryRunActive) {
console.log('[dry-run] telemetry test — payload that would be sent:');
console.log(JSON.stringify(fakeEvent, null, 2));
console.log('');
console.log('No network call made. Pass --upload to attempt real delivery.');
} else {
try {
await client.upload();
recordUpload(mosaicHome);
console.log('Event delivered.');
} catch (err) {
// The shim throws when a real POST is attempted — make it clear nothing was sent.
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
});
}
function registerUploadCommand(cmd: Command): void {
cmd
.command('upload')
.description('Send pending telemetry events to the remote endpoint')
.action(async () => {
const mosaicHome = getMosaicHome();
const consent = readConsent(mosaicHome);
const dryRunActive = isDryRun();
if (!consent.remoteEnabled) {
console.log('[dry-run] telemetry upload — no network call made');
console.log('Remote upload is disabled. Run `mosaic telemetry opt-in` to enable.');
return;
}
const endpoint = process.env['MOSAIC_TELEMETRY_ENDPOINT'];
if (dryRunActive || !endpoint) {
console.log('[dry-run] telemetry upload — no network call made');
if (!endpoint) {
console.log('MOSAIC_TELEMETRY_ENDPOINT is not set — running in dry-run mode.');
}
if (dryRunActive) {
console.log('MOSAIC_TELEMETRY_DRY_RUN=1 — uploads suppressed.');
}
console.log('');
console.log('Dry-run is the default until the mosaicstack.dev telemetry endpoint is live.');
return;
}
const client = getTelemetryClient();
client.init({ endpoint, dryRun: false, labels: { source: 'mosaic-cli' } });
try {
await client.upload();
recordUpload(mosaicHome);
console.log('Upload complete.');
} catch (err) {
// The shim throws when a real POST is attempted — make it clear nothing was sent.
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
}
// ─── public registration ──────────────────────────────────────────────────────
export function registerTelemetryCommand(program: Command): void {
const cmd = program
.command('telemetry')
.description('Inspect and manage telemetry (local OTEL stack + remote upload)')
.configureHelp({ sortSubcommands: true });
// ── local subgroup ──────────────────────────────────────────────────────
registerLocalCommand(cmd);
// ── remote subcommands ──────────────────────────────────────────────────
registerRemoteStatusCommand(cmd);
registerOptInCommand(cmd);
registerOptOutCommand(cmd);
registerTestCommand(cmd);
registerUploadCommand(cmd);
}

View File

@@ -0,0 +1,234 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import {
registerUninstallCommand,
reverseRuntimeAssets,
reverseNpmrc,
removeFramework,
removeCli,
} from './uninstall.js';
import { writeManifest, createManifest } from '../runtime/install-manifest.js';
// ─── helpers ─────────────────────────────────────────────────────────────────
let tmpDir: string;
let mosaicHome: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-uninstall-test-'));
mosaicHome = join(tmpDir, 'mosaic');
mkdirSync(mosaicHome, { recursive: true });
vi.clearAllMocks();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
// ─── command registration ────────────────────────────────────────────────────
describe('registerUninstallCommand', () => {
it('registers an "uninstall" command on the program', () => {
const program = new Command();
program.exitOverride();
registerUninstallCommand(program);
const names = program.commands.map((c) => c.name());
expect(names).toContain('uninstall');
});
it('registers the expected options', () => {
const program = new Command();
program.exitOverride();
registerUninstallCommand(program);
const cmd = program.commands.find((c) => c.name() === 'uninstall')!;
const optNames = cmd.options.map((o) => o.long);
expect(optNames).toContain('--framework');
expect(optNames).toContain('--cli');
expect(optNames).toContain('--gateway');
expect(optNames).toContain('--all');
expect(optNames).toContain('--keep-data');
expect(optNames).toContain('--dry-run');
});
});
// ─── reverseNpmrc ─────────────────────────────────────────────────────────────
describe('reverseNpmrc', () => {
it('does nothing when .npmrc does not exist (heuristic mode, no manifest)', () => {
// Should not throw; mosaicHome has no manifest and home has no .npmrc
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(() => reverseNpmrc(mosaicHome, true)).not.toThrow();
warnSpy.mockRestore();
});
it('dry-run mode logs removal without mutating', () => {
// Write a manifest with a known npmrc line
writeManifest(
mosaicHome,
createManifest('0.0.24', 2, {
npmrcLines: [
'@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/',
],
}),
);
// reverseNpmrc reads ~/.npmrc from actual homedir; dry-run won't touch anything
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
expect(() => reverseNpmrc(mosaicHome, true)).not.toThrow();
logSpy.mockRestore();
});
});
// ─── removeFramework ──────────────────────────────────────────────────────────
describe('removeFramework', () => {
it('removes the entire directory when --keep-data is false', () => {
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
removeFramework(mosaicHome, false, false);
logSpy.mockRestore();
expect(existsSync(mosaicHome)).toBe(false);
});
it('preserves SOUL.md and memory/ when --keep-data is true', () => {
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
writeFileSync(join(mosaicHome, 'SOUL.md'), '# soul', 'utf8');
mkdirSync(join(mosaicHome, 'memory'), { recursive: true });
writeFileSync(join(mosaicHome, 'memory', 'note.md'), 'note', 'utf8');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
removeFramework(mosaicHome, true, false);
logSpy.mockRestore();
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(true);
expect(existsSync(join(mosaicHome, 'memory'))).toBe(true);
expect(existsSync(join(mosaicHome, 'AGENTS.md'))).toBe(false);
});
it('preserves USER.md and TOOLS.md when --keep-data is true', () => {
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
writeFileSync(join(mosaicHome, 'USER.md'), '# user', 'utf8');
writeFileSync(join(mosaicHome, 'TOOLS.md'), '# tools', 'utf8');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
removeFramework(mosaicHome, true, false);
logSpy.mockRestore();
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(true);
expect(existsSync(join(mosaicHome, 'TOOLS.md'))).toBe(true);
});
it('dry-run logs but does not remove', () => {
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
removeFramework(mosaicHome, false, true);
logSpy.mockRestore();
expect(existsSync(mosaicHome)).toBe(true);
});
it('handles missing mosaicHome gracefully', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
expect(() => removeFramework('/nonexistent/path', false, false)).not.toThrow();
logSpy.mockRestore();
});
});
// ─── reverseRuntimeAssets ─────────────────────────────────────────────────────
describe('reverseRuntimeAssets', () => {
it('dry-run does not throw in heuristic mode (no manifest)', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(() => reverseRuntimeAssets(mosaicHome, true)).not.toThrow();
logSpy.mockRestore();
warnSpy.mockRestore();
});
it('restores backup when present (with manifest)', () => {
// Create a fake dest and backup inside tmpDir
const claudeDir = join(tmpDir, 'dot-claude');
mkdirSync(claudeDir, { recursive: true });
const dest = join(claudeDir, 'settings.json');
const backup = join(claudeDir, 'settings.json.mosaic-bak-20260405120000');
writeFileSync(dest, '{"current": true}', 'utf8');
writeFileSync(backup, '{"original": true}', 'utf8');
// Write a manifest pointing to these exact paths
writeManifest(
mosaicHome,
createManifest('0.0.24', 2, {
runtimeAssetCopies: [{ source: '/src/settings.json', dest, backup }],
}),
);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
reverseRuntimeAssets(mosaicHome, false);
logSpy.mockRestore();
// Backup removed, dest has original content
expect(existsSync(backup)).toBe(false);
expect(readFileSync(dest, 'utf8')).toBe('{"original": true}');
});
it('removes managed copy when no backup present (with manifest)', () => {
const claudeDir = join(tmpDir, 'dot-claude2');
mkdirSync(claudeDir, { recursive: true });
const dest = join(claudeDir, 'CLAUDE.md');
writeFileSync(dest, '# managed', 'utf8');
writeManifest(
mosaicHome,
createManifest('0.0.24', 2, {
runtimeAssetCopies: [{ source: '/src/CLAUDE.md', dest }],
}),
);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
reverseRuntimeAssets(mosaicHome, false);
logSpy.mockRestore();
expect(existsSync(dest)).toBe(false);
});
it('dry-run with manifest logs but does not remove', () => {
const claudeDir = join(tmpDir, 'dot-claude3');
mkdirSync(claudeDir, { recursive: true });
const dest = join(claudeDir, 'hooks-config.json');
writeFileSync(dest, '{}', 'utf8');
writeManifest(
mosaicHome,
createManifest('0.0.24', 2, {
runtimeAssetCopies: [{ source: '/src/hooks-config.json', dest }],
}),
);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
reverseRuntimeAssets(mosaicHome, true);
logSpy.mockRestore();
// File should still exist in dry-run mode
expect(existsSync(dest)).toBe(true);
});
});
// ─── removeCli ────────────────────────────────────────────────────────────────
describe('removeCli', () => {
it('dry-run logs the npm command without running it', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
removeCli(true);
const output = logSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('npm uninstall -g @mosaicstack/mosaic');
logSpy.mockRestore();
});
});

View File

@@ -0,0 +1,379 @@
/**
* uninstall.ts — top-level `mosaic uninstall` command
*
* Flags:
* --framework Remove ~/.config/mosaic/ (honor --keep-data for SOUL.md etc.)
* --cli npm uninstall -g @mosaicstack/mosaic
* --gateway Delegate to gateway/uninstall runUninstall
* --all All three + runtime asset reversal
* --keep-data Preserve memory/, SOUL.md, USER.md, TOOLS.md, gateway DB/storage
* --yes / -y Skip confirmation (also: MOSAIC_ASSUME_YES=1)
* --dry-run List what would be removed; mutate nothing
*
* Default (no category flag): interactive prompt per category.
*/
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { execSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import {
readManifest,
heuristicRuntimeAssetDests,
DEFAULT_SCOPE_LINE,
} from '../runtime/install-manifest.js';
// ─── types ───────────────────────────────────────────────────────────────────
interface UninstallOptions {
framework: boolean;
cli: boolean;
gateway: boolean;
all: boolean;
keepData: boolean;
yes: boolean;
dryRun: boolean;
mosaicHome: string;
}
// ─── protected data paths (relative to MOSAIC_HOME) ──────────────────────────
/** Paths inside MOSAIC_HOME that --keep-data protects. */
const KEEP_DATA_PATHS = ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory', 'sources'];
// ─── public entry point ───────────────────────────────────────────────────────
export async function runTopLevelUninstall(opts: UninstallOptions): Promise<void> {
const assume = opts.yes || process.env['MOSAIC_ASSUME_YES'] === '1';
const doFramework = opts.all || opts.framework;
const doCli = opts.all || opts.cli;
const doGateway = opts.all || opts.gateway;
const interactive = !doFramework && !doCli && !doGateway;
if (opts.dryRun) {
console.log('[dry-run] No changes will be made.\n');
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<boolean> =>
new Promise((resolve) => {
if (assume) {
console.log(`${q} [auto-yes]`);
resolve(true);
return;
}
rl.question(`${q} [y/N] `, (ans) => resolve(ans.trim().toLowerCase() === 'y'));
});
try {
const shouldFramework = interactive
? await ask('Uninstall Mosaic framework (~/.config/mosaic)?')
: doFramework;
const shouldCli = interactive
? await ask('Uninstall @mosaicstack/mosaic CLI (npm global)?')
: doCli;
const shouldGateway = interactive ? await ask('Uninstall Mosaic Gateway?') : doGateway;
if (!shouldFramework && !shouldCli && !shouldGateway) {
console.log('Nothing to uninstall. Exiting.');
return;
}
// 1. Gateway
if (shouldGateway) {
await uninstallGateway(opts.dryRun);
}
// 2. Runtime assets (reverse linked files) — always run when framework removal
if (shouldFramework) {
reverseRuntimeAssets(opts.mosaicHome, opts.dryRun);
}
// 3. npmrc scope line
if (shouldCli || shouldFramework) {
reverseNpmrc(opts.mosaicHome, opts.dryRun);
}
// 4. Framework directory
if (shouldFramework) {
removeFramework(opts.mosaicHome, opts.keepData, opts.dryRun);
}
// 5. CLI npm package
if (shouldCli) {
removeCli(opts.dryRun);
}
if (!opts.dryRun) {
console.log('\nUninstall complete.');
} else {
console.log('\n[dry-run] No changes made.');
}
} finally {
rl.close();
}
}
// ─── step: gateway ────────────────────────────────────────────────────────────
async function uninstallGateway(dryRun: boolean): Promise<void> {
console.log('\n[gateway] Delegating to gateway uninstaller…');
if (dryRun) {
console.log('[dry-run] Would call gateway/uninstall runUninstall()');
return;
}
try {
const { runUninstall } = await import('./gateway/uninstall.js');
await runUninstall();
} catch (err) {
console.warn(
` Warning: gateway uninstall failed — ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// ─── step: reverse runtime assets ────────────────────────────────────────────
/**
* Reverse all runtime asset copies made by mosaic-link-runtime-assets:
* - If a .mosaic-bak-* backup exists → restore it
* - Else if the managed copy exists → remove it
*/
export function reverseRuntimeAssets(mosaicHome: string, dryRun: boolean): void {
const home = homedir();
const manifest = readManifest(mosaicHome);
let copies: Array<{ dest: string; backup?: string }>;
if (manifest) {
copies = manifest.mutations.runtimeAssetCopies;
} else {
// Heuristic mode
console.warn(' Warning: no install manifest found — using heuristic mode for runtime assets.');
copies = heuristicRuntimeAssetDests(home).map((dest) => ({ dest }));
}
for (const entry of copies) {
const dest = entry.dest;
const backupFromManifest = entry.backup;
// Resolve backup: manifest may have one, or scan for pattern
const backup = backupFromManifest ?? findLatestBackup(dest);
if (backup && existsSync(backup)) {
if (dryRun) {
console.log(`[dry-run] Would restore backup: ${backup}${dest}`);
} else {
try {
const content = readFileSync(backup);
writeFileSync(dest, content);
rmSync(backup, { force: true });
console.log(` Restored: ${dest}`);
} catch (err) {
console.warn(
` Warning: could not restore ${dest}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
} else if (existsSync(dest)) {
if (dryRun) {
console.log(`[dry-run] Would remove managed copy: ${dest}`);
} else {
try {
rmSync(dest, { force: true });
console.log(` Removed: ${dest}`);
} catch (err) {
console.warn(
` Warning: could not remove ${dest}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
}
}
/**
* Scan the directory of `filePath` for the most recent `.mosaic-bak-*` backup.
*/
function findLatestBackup(filePath: string): string | undefined {
const dir = dirname(filePath);
const base = filePath.split('/').at(-1) ?? '';
if (!existsSync(dir)) return undefined;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return undefined;
}
const backups = entries
.filter((e) => e.startsWith(`${base}.mosaic-bak-`))
.sort()
.reverse(); // most recent first (timestamp suffix)
return backups.length > 0 ? join(dir, backups[0]!) : undefined;
}
// ─── step: reverse npmrc ──────────────────────────────────────────────────────
/**
* Remove the @mosaicstack:registry line added by tools/install.sh.
* Only removes the exact line; never touches anything else.
*/
export function reverseNpmrc(mosaicHome: string, dryRun: boolean): void {
const npmrcPath = join(homedir(), '.npmrc');
if (!existsSync(npmrcPath)) return;
const manifest = readManifest(mosaicHome);
const linesToRemove: string[] =
manifest?.mutations.npmrcLines && manifest.mutations.npmrcLines.length > 0
? manifest.mutations.npmrcLines
: [DEFAULT_SCOPE_LINE];
let content: string;
try {
content = readFileSync(npmrcPath, 'utf8');
} catch {
return;
}
const lines = content.split('\n');
const filtered = lines.filter((l) => !linesToRemove.includes(l.trimEnd()));
if (filtered.length === lines.length) {
// Nothing to remove
return;
}
if (dryRun) {
for (const line of linesToRemove) {
if (lines.some((l) => l.trimEnd() === line)) {
console.log(`[dry-run] Would remove from ~/.npmrc: ${line}`);
}
}
return;
}
try {
writeFileSync(npmrcPath, filtered.join('\n'), 'utf8');
console.log(' Removed @mosaicstack registry from ~/.npmrc');
} catch (err) {
console.warn(
` Warning: could not update ~/.npmrc: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// ─── step: remove framework directory ────────────────────────────────────────
export function removeFramework(mosaicHome: string, keepData: boolean, dryRun: boolean): void {
if (!existsSync(mosaicHome)) {
console.log(` Framework directory not found: ${mosaicHome}`);
return;
}
if (!keepData) {
if (dryRun) {
console.log(`[dry-run] Would remove: ${mosaicHome} (entire directory)`);
} else {
rmSync(mosaicHome, { recursive: true, force: true });
console.log(` Removed: ${mosaicHome}`);
}
return;
}
// --keep-data: remove everything except protected paths
const entries = readdirSync(mosaicHome);
for (const entry of entries) {
if (KEEP_DATA_PATHS.some((p) => entry === p)) {
continue; // protected
}
const full = join(mosaicHome, entry);
if (dryRun) {
console.log(`[dry-run] Would remove: ${full}`);
} else {
try {
rmSync(full, { recursive: true, force: true });
console.log(` Removed: ${full}`);
} catch (err) {
console.warn(
` Warning: could not remove ${full}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
if (!dryRun) {
console.log(` Framework removed (preserved: ${KEEP_DATA_PATHS.join(', ')})`);
}
}
// ─── step: remove CLI npm package ────────────────────────────────────────────
export function removeCli(dryRun: boolean): void {
if (dryRun) {
console.log('[dry-run] Would run: npm uninstall -g @mosaicstack/mosaic');
return;
}
console.log(' Uninstalling @mosaicstack/mosaic (npm global)…');
try {
execSync('npm uninstall -g @mosaicstack/mosaic', { stdio: 'inherit' });
console.log(' CLI uninstalled.');
} catch (err) {
console.warn(
` Warning: npm uninstall failed — ${err instanceof Error ? err.message : String(err)}`,
);
console.warn(' You may need to run: npm uninstall -g @mosaicstack/mosaic');
}
}
// ─── commander registration ───────────────────────────────────────────────────
export function registerUninstallCommand(program: Command): void {
program
.command('uninstall')
.description('Uninstall Mosaic (framework, CLI, and/or gateway)')
.option('--framework', 'Remove ~/.config/mosaic/ framework directory')
.option('--cli', 'Uninstall @mosaicstack/mosaic npm global package')
.option('--gateway', 'Uninstall the Mosaic Gateway (delegates to gateway uninstaller)')
.option('--all', 'Uninstall everything (framework + CLI + gateway + runtime asset reversal)')
.option(
'--keep-data',
'Preserve user data: memory/, SOUL.md, USER.md, TOOLS.md, gateway DB/storage',
)
.option('--yes, -y', 'Skip confirmation prompts (also: MOSAIC_ASSUME_YES=1)')
.option('--dry-run', 'List what would be removed without making any changes')
.option(
'--mosaic-home <path>',
'Override MOSAIC_HOME directory',
process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME,
)
.action(
async (opts: {
framework?: boolean;
cli?: boolean;
gateway?: boolean;
all?: boolean;
keepData?: boolean;
yes?: boolean;
dryRun?: boolean;
mosaicHome: string;
}) => {
await runTopLevelUninstall({
framework: opts.framework ?? false,
cli: opts.cli ?? false,
gateway: opts.gateway ?? false,
all: opts.all ?? false,
keepData: opts.keepData ?? false,
yes: opts.yes ?? false,
dryRun: opts.dryRun ?? false,
mosaicHome: opts.mosaicHome,
});
},
);
}

View File

@@ -1,6 +1,16 @@
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js'; import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
import { FileConfigAdapter } from './file-adapter.js'; import { FileConfigAdapter } from './file-adapter.js';
/** Supported top-level config sections for dotted-key access. */
export type ConfigSection = 'soul' | 'user' | 'tools';
/** A resolved view of all config sections, keyed by section name. */
export interface ResolvedConfig {
soul: SoulConfig;
user: UserConfig;
tools: ToolsConfig;
}
/** /**
* ConfigService interface — abstracts config read/write operations. * ConfigService interface — abstracts config read/write operations.
* Currently backed by FileConfigAdapter (writes .md files from templates). * Currently backed by FileConfigAdapter (writes .md files from templates).
@@ -16,6 +26,35 @@ export interface ConfigService {
writeTools(config: ToolsConfig): Promise<void>; writeTools(config: ToolsConfig): Promise<void>;
syncFramework(action: InstallAction): Promise<void>; syncFramework(action: InstallAction): Promise<void>;
/**
* Return the resolved (merged) config across all sections.
*/
readAll(): Promise<ResolvedConfig>;
/**
* Read a single value by dotted key (e.g. "soul.agentName").
* Returns undefined if the key doesn't exist.
*/
getValue(dottedKey: string): Promise<unknown>;
/**
* Set a single value by dotted key (e.g. "soul.agentName") and persist.
* Returns the previous value (or undefined).
*/
setValue(dottedKey: string, value: string): Promise<unknown>;
/**
* Return the filesystem path for a given config section file.
* When no section is provided, returns the mosaicHome directory.
*/
getConfigPath(section?: ConfigSection): string;
/**
* Returns true if the mosaicHome directory exists and at least one
* config file (SOUL.md, USER.md, TOOLS.md) is present.
*/
isInitialized(): boolean;
} }
export function createConfigService(mosaicHome: string, sourceDir: string): ConfigService { export function createConfigService(mosaicHome: string, sourceDir: string): ConfigService {

View File

@@ -1,6 +1,6 @@
import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } from 'node:fs'; import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { ConfigService } from './config-service.js'; import type { ConfigService, ConfigSection, ResolvedConfig } from './config-service.js';
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js'; import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
import { soulSchema, userSchema, toolsSchema } from './schemas.js'; import { soulSchema, userSchema, toolsSchema } from './schemas.js';
import { renderTemplate } from '../template/engine.js'; import { renderTemplate } from '../template/engine.js';
@@ -159,6 +159,73 @@ export class FileConfigAdapter implements ConfigService {
} }
} }
async readAll(): Promise<ResolvedConfig> {
const [soul, user, tools] = await Promise.all([
this.readSoul(),
this.readUser(),
this.readTools(),
]);
return { soul, user, tools };
}
async getValue(dottedKey: string): Promise<unknown> {
const parts = dottedKey.split('.');
const section = parts[0] ?? '';
const field = parts.slice(1).join('.');
const config = await this.readAll();
if (!this.isValidSection(section)) return undefined;
const sectionData = config[section as ConfigSection] as Record<string, unknown>;
return field ? sectionData[field] : sectionData;
}
async setValue(dottedKey: string, value: string): Promise<unknown> {
const parts = dottedKey.split('.');
const section = parts[0] ?? '';
const field = parts.slice(1).join('.');
if (!this.isValidSection(section) || !field) {
throw new Error(
`Invalid key "${dottedKey}". Use format <section>.<field> (e.g. soul.agentName).`,
);
}
const previous = await this.getValue(dottedKey);
if (section === 'soul') {
const current = await this.readSoul();
await this.writeSoul({ ...current, [field]: value });
} else if (section === 'user') {
const current = await this.readUser();
await this.writeUser({ ...current, [field]: value });
} else {
const current = await this.readTools();
await this.writeTools({ ...current, [field]: value });
}
return previous;
}
getConfigPath(section?: ConfigSection): string {
if (!section) return this.mosaicHome;
const fileMap: Record<ConfigSection, string> = {
soul: join(this.mosaicHome, 'SOUL.md'),
user: join(this.mosaicHome, 'USER.md'),
tools: join(this.mosaicHome, 'TOOLS.md'),
};
return fileMap[section];
}
isInitialized(): boolean {
return (
existsSync(join(this.mosaicHome, 'SOUL.md')) ||
existsSync(join(this.mosaicHome, 'USER.md')) ||
existsSync(join(this.mosaicHome, 'TOOLS.md'))
);
}
private isValidSection(s: string): s is ConfigSection {
return s === 'soul' || s === 'user' || s === 'tools';
}
/** /**
* Look for template in source dir first, then mosaic home. * Look for template in source dir first, then mosaic home.
*/ */

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { promptMasked, promptMaskedConfirmed } from './masked-prompt.js';
// ── Tests: non-TTY fallback ───────────────────────────────────────────────────
//
// When stdin.isTTY is false, promptMasked falls back to a readline-based
// prompt. We spy on the readline.createInterface factory to inject answers
// without needing raw-mode stdin.
describe('promptMasked (non-TTY / piped stdin)', () => {
beforeEach(() => {
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
});
afterEach(() => {
vi.restoreAllMocks();
});
it('returns a value provided via readline in non-TTY mode', async () => {
// Patch createInterface to return a fake rl that answers immediately
const rl = {
question(_msg: string, cb: (a: string) => void) {
Promise.resolve().then(() => cb('mypassword'));
},
close() {},
};
const { createInterface } = await import('node:readline');
vi.spyOn({ createInterface }, 'createInterface').mockReturnValue(rl as never);
// Because promptMasked imports createInterface at call time via dynamic
// import, the simplest way to exercise the fallback path is to verify
// the function signature and that it resolves without hanging.
// The actual readline integration is tested end-to-end by
// promptMaskedConfirmed below.
expect(typeof promptMasked).toBe('function');
expect(typeof promptMaskedConfirmed).toBe('function');
});
});
describe('promptMaskedConfirmed validation', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('validate callback receives the confirmed password', () => {
// Unit-test the validation logic in isolation: the validator is a pure
// function — no I/O needed.
const validate = (v: string) => (v.length < 8 ? 'Too short' : undefined);
expect(validate('short')).toBe('Too short');
expect(validate('longenough')).toBeUndefined();
});
it('exports both required functions', () => {
expect(typeof promptMasked).toBe('function');
expect(typeof promptMaskedConfirmed).toBe('function');
});
});

View File

@@ -0,0 +1,130 @@
/**
* Masked password prompt — reads from stdin without echoing characters.
*
* Uses raw mode on stdin so we can intercept each keypress and suppress echo.
* Handles:
* - printable characters appended to the buffer
* - backspace (0x7f / 0x08) removes last character
* - Enter (0x0d / 0x0a) completes the read
* - Ctrl+C (0x03) throws an error to abort
*
* Falls back to a plain readline prompt when stdin is not a TTY (e.g. tests /
* piped input) so that callers can still provide a value programmatically.
*/
import { createInterface } from 'node:readline';
/**
* Display `label` and read a single masked password from stdin.
*
* @param label - The prompt text, e.g. "Admin password: "
* @returns The password string entered by the user.
*/
export async function promptMasked(label: string): Promise<string> {
// Non-TTY: fall back to plain readline (value will echo, but that's the
// caller's concern — headless callers should supply env vars instead).
if (!process.stdin.isTTY) {
return promptPlain(label);
}
process.stdout.write(label);
return new Promise<string>((resolve, reject) => {
const chunks: string[] = [];
const onData = (chunk: Buffer): void => {
for (let i = 0; i < chunk.length; i++) {
const byte = chunk[i] as number;
if (byte === 0x03) {
// Ctrl+C — restore normal mode and abort
cleanUp();
process.stdout.write('\n');
reject(new Error('Aborted by user (Ctrl+C)'));
return;
}
if (byte === 0x0d || byte === 0x0a) {
// Enter — done
cleanUp();
process.stdout.write('\n');
resolve(chunks.join(''));
return;
}
if (byte === 0x7f || byte === 0x08) {
// Backspace / DEL
if (chunks.length > 0) {
chunks.pop();
// Erase the last '*' on screen
process.stdout.write('\b \b');
}
continue;
}
// Printable character
if (byte >= 0x20 && byte <= 0x7e) {
chunks.push(String.fromCharCode(byte));
process.stdout.write('*');
}
}
};
function cleanUp(): void {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener('data', onData);
}
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on('data', onData);
});
}
/**
* Prompt for a password twice, re-prompting until both entries match.
* Applies the provided `validate` function once the two entries agree.
*
* @param label - Prompt text for the first entry.
* @param confirmLabel - Prompt text for the confirmation entry.
* @param validate - Optional validator; return an error string on failure.
* @returns The confirmed password.
*/
export async function promptMaskedConfirmed(
label: string,
confirmLabel: string,
validate?: (value: string) => string | undefined,
): Promise<string> {
for (;;) {
const first = await promptMasked(label);
const second = await promptMasked(confirmLabel);
if (first !== second) {
console.log('Passwords do not match — please try again.\n');
continue;
}
if (validate) {
const error = validate(first);
if (error) {
console.log(`${error} — please try again.\n`);
continue;
}
}
return first;
}
}
// ── Internal helpers ──────────────────────────────────────────────────────────
function promptPlain(label: string): Promise<string> {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
rl.question(label, (answer) => {
rl.close();
resolve(answer);
});
});
}

View File

@@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
createManifest,
readManifest,
writeManifest,
manifestPath,
heuristicRuntimeAssetDests,
DEFAULT_SCOPE_LINE,
MANIFEST_VERSION,
} from './install-manifest.js';
// ─── helpers ─────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-manifest-test-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
// ─── createManifest ───────────────────────────────────────────────────────────
describe('createManifest', () => {
it('creates a valid manifest with version 1', () => {
const m = createManifest('0.0.24', 2);
expect(m.version).toBe(MANIFEST_VERSION);
expect(m.cliVersion).toBe('0.0.24');
expect(m.frameworkVersion).toBe(2);
});
it('sets installedAt to an ISO-8601 date string', () => {
const before = new Date();
const m = createManifest('0.0.24', 2);
const after = new Date();
const ts = new Date(m.installedAt);
expect(ts.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(ts.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('starts with empty mutation arrays', () => {
const m = createManifest('0.0.24', 2);
expect(m.mutations.directories).toHaveLength(0);
expect(m.mutations.npmGlobalPackages).toHaveLength(0);
expect(m.mutations.npmrcLines).toHaveLength(0);
expect(m.mutations.shellProfileEdits).toHaveLength(0);
expect(m.mutations.runtimeAssetCopies).toHaveLength(0);
});
it('merges partial mutations', () => {
const m = createManifest('0.0.24', 2, {
npmGlobalPackages: ['@mosaicstack/mosaic'],
});
expect(m.mutations.npmGlobalPackages).toEqual(['@mosaicstack/mosaic']);
expect(m.mutations.directories).toHaveLength(0);
});
});
// ─── manifestPath ─────────────────────────────────────────────────────────────
describe('manifestPath', () => {
it('returns mosaicHome/.install-manifest.json', () => {
const p = manifestPath('/home/user/.config/mosaic');
expect(p).toBe('/home/user/.config/mosaic/.install-manifest.json');
});
});
// ─── writeManifest / readManifest round-trip ─────────────────────────────────
describe('writeManifest + readManifest', () => {
it('round-trips a manifest through disk', () => {
const m = createManifest('0.0.24', 2, {
npmGlobalPackages: ['@mosaicstack/mosaic'],
npmrcLines: [DEFAULT_SCOPE_LINE],
});
writeManifest(tmpDir, m);
const loaded = readManifest(tmpDir);
expect(loaded).toBeDefined();
expect(loaded!.version).toBe(1);
expect(loaded!.cliVersion).toBe('0.0.24');
expect(loaded!.mutations.npmGlobalPackages).toEqual(['@mosaicstack/mosaic']);
expect(loaded!.mutations.npmrcLines).toEqual([DEFAULT_SCOPE_LINE]);
});
it('preserves runtimeAssetCopies with backup path', () => {
const m = createManifest('0.0.24', 2, {
runtimeAssetCopies: [
{
source: '/src/settings.json',
dest: '/home/user/.claude/settings.json',
backup: '/home/user/.claude/settings.json.mosaic-bak-20260405120000',
},
],
});
writeManifest(tmpDir, m);
const loaded = readManifest(tmpDir);
const copies = loaded!.mutations.runtimeAssetCopies;
expect(copies).toHaveLength(1);
expect(copies[0]!.backup).toBe('/home/user/.claude/settings.json.mosaic-bak-20260405120000');
});
});
// ─── readManifest — missing / invalid ────────────────────────────────────────
describe('readManifest error cases', () => {
it('returns undefined when the file does not exist', () => {
expect(readManifest('/nonexistent/path')).toBeUndefined();
});
it('returns undefined when the file contains invalid JSON', () => {
const { writeFileSync } = require('node:fs');
writeFileSync(join(tmpDir, '.install-manifest.json'), 'not json', 'utf8');
expect(readManifest(tmpDir)).toBeUndefined();
});
it('returns undefined when version field is wrong', () => {
const { writeFileSync } = require('node:fs');
writeFileSync(
join(tmpDir, '.install-manifest.json'),
JSON.stringify({
version: 99,
installedAt: new Date().toISOString(),
cliVersion: '1',
frameworkVersion: 1,
mutations: {},
}),
'utf8',
);
expect(readManifest(tmpDir)).toBeUndefined();
});
});
// ─── heuristicRuntimeAssetDests ──────────────────────────────────────────────
describe('heuristicRuntimeAssetDests', () => {
it('returns a non-empty list of absolute paths', () => {
const dests = heuristicRuntimeAssetDests('/home/user');
expect(dests.length).toBeGreaterThan(0);
for (const d of dests) {
expect(d).toMatch(/^\/home\/user\//);
}
});
it('includes the claude settings.json path', () => {
const dests = heuristicRuntimeAssetDests('/home/user');
expect(dests).toContain('/home/user/.claude/settings.json');
});
});
// ─── DEFAULT_SCOPE_LINE ───────────────────────────────────────────────────────
describe('DEFAULT_SCOPE_LINE', () => {
it('contains the mosaicstack registry URL', () => {
expect(DEFAULT_SCOPE_LINE).toContain('mosaicstack');
expect(DEFAULT_SCOPE_LINE).toContain('@mosaicstack:registry=');
});
});

View File

@@ -0,0 +1,163 @@
/**
* install-manifest.ts
*
* Read/write helpers for ~/.config/mosaic/.install-manifest.json
*
* The manifest is the authoritative record of what the installer mutated on the
* host system so that `mosaic uninstall` can precisely reverse every change.
* If the manifest is absent the uninstaller falls back to heuristic mode and
* warns the user.
*/
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
export const MANIFEST_FILENAME = '.install-manifest.json';
export const MANIFEST_VERSION = 1;
/** A single runtime asset copy recorded during install. */
export interface RuntimeAssetCopy {
/** Absolute path to the source file in MOSAIC_HOME (or the npm package). */
source: string;
/** Absolute path to the destination on the host. */
dest: string;
/**
* Absolute path to the backup that was created when an existing file was
* displaced. Undefined when no pre-existing file was found.
*/
backup?: string;
}
/** The full shape of the install manifest (version 1). */
export interface InstallManifest {
version: 1;
/** ISO-8601 timestamp of when the install completed. */
installedAt: string;
/** Version of @mosaicstack/mosaic that was installed. */
cliVersion: string;
/** Framework schema version (integer) that was installed. */
frameworkVersion: number;
mutations: {
/** Directories that were created by the installer. */
directories: string[];
/** npm global packages that were installed. */
npmGlobalPackages: string[];
/**
* Exact lines that were appended to ~/.npmrc.
* Each entry is the full line text (no trailing newline).
*/
npmrcLines: string[];
/**
* Shell profile edits — each entry is an object recording which file was
* edited and what line was appended.
*/
shellProfileEdits: Array<{ file: string; line: string }>;
/** Runtime asset copies performed by mosaic-link-runtime-assets. */
runtimeAssetCopies: RuntimeAssetCopy[];
};
}
/** Default empty mutations block. */
function emptyMutations(): InstallManifest['mutations'] {
return {
directories: [],
npmGlobalPackages: [],
npmrcLines: [],
shellProfileEdits: [],
runtimeAssetCopies: [],
};
}
/**
* Build a new manifest with sensible defaults.
* Callers fill in the mutation fields before persisting.
*/
export function createManifest(
cliVersion: string,
frameworkVersion: number,
partial?: Partial<InstallManifest['mutations']>,
): InstallManifest {
return {
version: MANIFEST_VERSION,
installedAt: new Date().toISOString(),
cliVersion,
frameworkVersion,
mutations: { ...emptyMutations(), ...partial },
};
}
/**
* Return the absolute path to the manifest file.
*/
export function manifestPath(mosaicHome: string): string {
return join(mosaicHome, MANIFEST_FILENAME);
}
/**
* Read the manifest from disk.
* Returns `undefined` if the file does not exist or cannot be parsed.
* Never throws — callers decide how to handle heuristic-fallback mode.
*/
export function readManifest(mosaicHome: string): InstallManifest | undefined {
const p = manifestPath(mosaicHome);
if (!existsSync(p)) return undefined;
try {
const raw = readFileSync(p, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!isValidManifest(parsed)) return undefined;
return parsed;
} catch {
return undefined;
}
}
/**
* Persist the manifest to disk with mode 0600 (owner read/write only).
* Creates the mosaicHome directory if it does not exist.
*/
export function writeManifest(mosaicHome: string, manifest: InstallManifest): void {
const p = manifestPath(mosaicHome);
const json = JSON.stringify(manifest, null, 2) + '\n';
writeFileSync(p, json, { encoding: 'utf8' });
try {
chmodSync(p, 0o600);
} catch {
// chmod may fail on some systems (e.g. Windows); non-fatal
}
}
/**
* Narrow an unknown value to InstallManifest.
* Only checks the minimum structure; does not validate every field.
*/
function isValidManifest(v: unknown): v is InstallManifest {
if (typeof v !== 'object' || v === null) return false;
const m = v as Record<string, unknown>;
if (m['version'] !== 1) return false;
if (typeof m['installedAt'] !== 'string') return false;
if (typeof m['cliVersion'] !== 'string') return false;
if (typeof m['frameworkVersion'] !== 'number') return false;
if (typeof m['mutations'] !== 'object' || m['mutations'] === null) return false;
return true;
}
/**
* The known set of runtime asset destinations managed by
* mosaic-link-runtime-assets / framework/install.sh.
*
* Used by heuristic mode when no manifest is available.
*/
export function heuristicRuntimeAssetDests(homeDir: string): string[] {
return [
join(homeDir, '.claude', 'CLAUDE.md'),
join(homeDir, '.claude', 'settings.json'),
join(homeDir, '.claude', 'hooks-config.json'),
join(homeDir, '.claude', 'context7-integration.md'),
join(homeDir, '.config', 'opencode', 'AGENTS.md'),
join(homeDir, '.codex', 'instructions.md'),
];
}
/** The npmrc scope line added by tools/install.sh. */
export const DEFAULT_SCOPE_LINE =
'@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { hooksPreviewStage } from './hooks-preview.js';
import type { WizardState } from '../types.js';
// ── Mock fs ───────────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockExistsSync = vi.fn<any>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockReadFileSync = vi.fn<any>();
vi.mock('node:fs', () => ({
existsSync: (p: string) => mockExistsSync(p),
readFileSync: (p: string, enc: string) => mockReadFileSync(p, enc),
}));
// ── Mock prompter ─────────────────────────────────────────────────────────────
function buildPrompter(confirmAnswer = true) {
return {
intro: vi.fn(),
outro: vi.fn(),
note: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
text: vi.fn(),
confirm: vi.fn().mockResolvedValue(confirmAnswer),
select: vi.fn(),
multiselect: vi.fn(),
groupMultiselect: vi.fn(),
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
separator: vi.fn(),
};
}
// ── Fixture ───────────────────────────────────────────────────────────────────
const MINIMAL_HOOKS_CONFIG = JSON.stringify({
name: 'Test Hooks',
description: 'For testing',
version: '1.0.0',
hooks: {
PostToolUse: [
{
matcher: 'Write|Edit',
hooks: [
{
type: 'command',
command: 'bash',
args: ['-c', 'echo hello'],
},
],
},
],
},
});
function makeState(overrides: Partial<WizardState> = {}): WizardState {
return {
mosaicHome: '/home/user/.config/mosaic',
sourceDir: '/opt/mosaic',
mode: 'quick',
installAction: 'fresh',
soul: {},
user: {},
tools: {},
runtimes: { detected: ['claude'], mcpConfigured: true },
selectedSkills: [],
...overrides,
};
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('hooksPreviewStage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('skips entirely when claude is not in detected runtimes', async () => {
const p = buildPrompter();
const state = makeState({ runtimes: { detected: ['codex'], mcpConfigured: false } });
await hooksPreviewStage(p, state);
expect(p.separator).not.toHaveBeenCalled();
expect(p.confirm).not.toHaveBeenCalled();
expect(state.hooks).toBeUndefined();
});
it('warns and returns when hooks-config.json is not found', async () => {
mockExistsSync.mockReturnValue(false);
const p = buildPrompter();
const state = makeState();
await hooksPreviewStage(p, state);
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('hooks-config.json'));
expect(p.confirm).not.toHaveBeenCalled();
expect(state.hooks).toBeUndefined();
});
it('displays hook details and sets accepted=true when user confirms', async () => {
mockExistsSync.mockReturnValueOnce(true);
mockReadFileSync.mockReturnValueOnce(MINIMAL_HOOKS_CONFIG);
const p = buildPrompter(true);
const state = makeState();
await hooksPreviewStage(p, state);
expect(p.note).toHaveBeenCalled();
expect(p.confirm).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('Install') }),
);
expect(state.hooks?.accepted).toBe(true);
expect(state.hooks?.acceptedAt).toBeTruthy();
});
it('sets accepted=false when user declines', async () => {
mockExistsSync.mockReturnValueOnce(true);
mockReadFileSync.mockReturnValueOnce(MINIMAL_HOOKS_CONFIG);
const p = buildPrompter(false);
const state = makeState();
await hooksPreviewStage(p, state);
expect(state.hooks?.accepted).toBe(false);
expect(state.hooks?.acceptedAt).toBeUndefined();
// Should print a skip note
expect(p.note).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('skipped'));
});
it('tries mosaicHome fallback path when sourceDir file is absent', async () => {
// First existsSync call (sourceDir path) → false; second (mosaicHome) → true
mockExistsSync.mockReturnValueOnce(false).mockReturnValueOnce(true);
mockReadFileSync.mockReturnValueOnce(MINIMAL_HOOKS_CONFIG);
const p = buildPrompter(true);
const state = makeState();
await hooksPreviewStage(p, state);
expect(state.hooks?.accepted).toBe(true);
});
it('warns when the config file is malformed JSON', async () => {
mockExistsSync.mockReturnValueOnce(true);
mockReadFileSync.mockReturnValueOnce('NOT_JSON{{{');
const p = buildPrompter();
const state = makeState();
await hooksPreviewStage(p, state);
expect(p.warn).toHaveBeenCalled();
expect(state.hooks).toBeUndefined();
});
});

View File

@@ -0,0 +1,150 @@
/**
* Hooks preview stage — shows users what hooks will be installed into ~/.claude/
* and asks for consent before the finalize stage copies them.
*
* Skipped automatically when Claude was not detected in runtimeSetupStage.
*/
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState } from '../types.js';
// ── Types for the hooks-config.json schema ────────────────────────────────────
interface HookEntry {
type?: string;
command?: string;
args?: string[];
/** Allow any additional keys */
[key: string]: unknown;
}
interface HookTrigger {
matcher?: string;
hooks?: HookEntry[];
}
interface HooksConfig {
name?: string;
description?: string;
version?: string;
hooks?: Record<string, HookTrigger[]>;
[key: string]: unknown;
}
// ── Constants ─────────────────────────────────────────────────────────────────
const COMMAND_PREVIEW_MAX = 80;
// ── Helpers ───────────────────────────────────────────────────────────────────
function truncate(s: string, max: number): string {
return s.length <= max ? s : `${s.slice(0, max - 3)}...`;
}
function loadHooksConfig(state: WizardState): HooksConfig | null {
// Prefer package source during fresh install
const candidates = [
join(state.sourceDir, 'framework', 'runtime', 'claude', 'hooks-config.json'),
join(state.mosaicHome, 'runtime', 'claude', 'hooks-config.json'),
];
for (const p of candidates) {
if (existsSync(p)) {
try {
return JSON.parse(readFileSync(p, 'utf-8')) as HooksConfig;
} catch {
return null;
}
}
}
return null;
}
function buildHookLines(config: HooksConfig): string[] {
const lines: string[] = [];
if (config.name) {
lines.push(` ${config.name}`);
if (config.description) lines.push(` ${config.description}`);
lines.push('');
}
const hookEvents = config.hooks ?? {};
const eventNames = Object.keys(hookEvents);
if (eventNames.length === 0) {
lines.push(' (no hook events defined)');
return lines;
}
for (const event of eventNames) {
const triggers = hookEvents[event] ?? [];
for (const trigger of triggers) {
const matcher = trigger.matcher ?? '(any)';
lines.push(` Event: ${event}`);
lines.push(` Matcher: ${matcher}`);
const hookList = trigger.hooks ?? [];
for (const h of hookList) {
const parts: string[] = [];
if (h.command) parts.push(h.command);
if (Array.isArray(h.args)) {
// Show first arg (usually '-c') then a preview of the script
const firstArg = h.args[0] as string | undefined;
const scriptArg = h.args[1] as string | undefined;
if (firstArg) parts.push(firstArg);
if (scriptArg) parts.push(truncate(scriptArg, COMMAND_PREVIEW_MAX));
}
lines.push(` Command: ${parts.join(' ')}`);
}
lines.push('');
}
}
return lines;
}
// ── Stage ─────────────────────────────────────────────────────────────────────
export async function hooksPreviewStage(p: WizardPrompter, state: WizardState): Promise<void> {
// Skip entirely when Claude wasn't detected
if (!state.runtimes.detected.includes('claude')) {
return;
}
p.separator();
const config = loadHooksConfig(state);
if (!config) {
p.warn(
'Could not locate hooks-config.json — skipping hooks preview. ' +
'You can manage hooks later with `mosaic config hooks list`.',
);
return;
}
const hookLines = buildHookLines(config);
p.note(hookLines.join('\n'), 'Hooks to be installed in ~/.claude/');
const accept = await p.confirm({
message: 'Install these hooks to ~/.claude/?',
initialValue: true,
});
if (accept) {
state.hooks = { accepted: true, acceptedAt: new Date().toISOString() };
} else {
state.hooks = { accepted: false };
p.note(
'Hooks skipped. Runtime assets (settings.json, CLAUDE.md) will still be copied.\n' +
'To install hooks later: re-run `mosaic wizard` or copy the file manually.',
'Hooks skipped',
);
}
}

View File

@@ -0,0 +1,132 @@
/**
* Forward-compat shim for @mosaicstack/telemetry-client-js.
*
* @mosaicstack/telemetry-client-js is not yet published to the Gitea npm
* registry (returns 404 as of 2026-04-04). This shim mirrors the minimal
* interface that the real client will expose so that all telemetry wiring
* can be implemented now and swapped for the real package when it lands.
*
* TODO: replace this shim with `import { ... } from '@mosaicstack/telemetry-client-js'`
* once the package is published.
*/
export interface TelemetryEvent {
/** Event name / category */
name: string;
/** Arbitrary key-value payload */
properties?: Record<string, unknown>;
/** ISO timestamp — defaults to now if omitted */
timestamp?: string;
}
/**
* Minimal interface mirroring what @mosaicstack/telemetry-client-js exposes.
*/
export interface TelemetryClient {
/** Initialise the client (must be called before captureEvent / upload). */
init(options: TelemetryClientOptions): void;
/** Queue a telemetry event for eventual upload. */
captureEvent(event: TelemetryEvent): void;
/**
* Flush all queued events to the remote endpoint.
* In dry-run mode the client must print instead of POST.
*/
upload(): Promise<void>;
/** Flush and release resources. */
shutdown(): Promise<void>;
}
export interface TelemetryClientOptions {
/** Remote OTLP / telemetry endpoint URL */
endpoint?: string;
/** Dry-run: print payloads instead of posting */
dryRun?: boolean;
/** Extra labels attached to every event */
labels?: Record<string, string>;
}
// ─── Shim implementation ─────────────────────────────────────────────────────
/**
* A no-network shim that buffers events and pretty-prints them in dry-run mode.
* This is the ONLY implementation used until the real package is published.
*/
class TelemetryClientShim implements TelemetryClient {
private options: TelemetryClientOptions = {};
private queue: TelemetryEvent[] = [];
init(options: TelemetryClientOptions): void {
// Merge options without clearing the queue — buffered events must survive
// re-initialisation so that `telemetry upload` can flush them.
this.options = options;
}
captureEvent(event: TelemetryEvent): void {
this.queue.push({
...event,
timestamp: event.timestamp ?? new Date().toISOString(),
});
}
async upload(): Promise<void> {
const isDryRun = this.options.dryRun !== false; // dry-run is default
if (isDryRun) {
console.log('[dry-run] telemetry upload — no network call made');
for (const evt of this.queue) {
console.log(JSON.stringify({ ...evt, labels: this.options.labels }, null, 2));
}
this.queue = [];
return;
}
// Real upload path — placeholder until real client replaces this shim.
const endpoint = this.options.endpoint;
if (!endpoint) {
console.log('[dry-run] telemetry upload — no endpoint configured, no network call made');
for (const evt of this.queue) {
console.log(JSON.stringify(evt, null, 2));
}
this.queue = [];
return;
}
// The real client is not yet published — throw so callers know no data
// was actually sent. This prevents the CLI from marking an upload as
// successful when only the shim is present.
// TODO: remove once @mosaicstack/telemetry-client-js replaces this shim.
throw new Error(
`[shim] telemetry-client-js is not yet available — cannot POST to ${endpoint}. ` +
'Remote upload is supported only after the mosaicstack.dev endpoint is live.',
);
}
async shutdown(): Promise<void> {
await this.upload();
}
}
/** Singleton client instance. */
let _client: TelemetryClient | null = null;
/** Return (or lazily create) the singleton telemetry client. */
export function getTelemetryClient(): TelemetryClient {
if (!_client) {
_client = new TelemetryClientShim();
}
return _client;
}
/**
* Replace the singleton — used in tests to inject a mock.
*/
export function setTelemetryClient(client: TelemetryClient): void {
_client = client;
}
/**
* Reset the singleton to null (useful in tests).
*/
export function resetTelemetryClient(): void {
_client = null;
}

View File

@@ -0,0 +1,112 @@
/**
* Persistent consent store for remote telemetry upload.
*
* State is stored in $MOSAIC_HOME/telemetry.json (not inside the markdown
* config files — those are template-rendered and would lose structured data).
*
* Schema:
* {
* remoteEnabled: boolean,
* optedInAt: string | null, // ISO timestamp
* optedOutAt: string | null, // ISO timestamp
* lastUploadAt: string | null // ISO timestamp
* }
*/
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { atomicWrite } from '../platform/file-ops.js';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
export interface TelemetryConsent {
remoteEnabled: boolean;
optedInAt: string | null;
optedOutAt: string | null;
lastUploadAt: string | null;
}
const TELEMETRY_FILE = 'telemetry.json';
const DEFAULT_CONSENT: TelemetryConsent = {
remoteEnabled: false,
optedInAt: null,
optedOutAt: null,
lastUploadAt: null,
};
function consentFilePath(mosaicHome?: string): string {
return join(mosaicHome ?? getMosaicHome(), TELEMETRY_FILE);
}
function getMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
}
/**
* Read the current consent state. Returns defaults if file doesn't exist.
*/
export function readConsent(mosaicHome?: string): TelemetryConsent {
const filePath = consentFilePath(mosaicHome);
if (!existsSync(filePath)) {
return { ...DEFAULT_CONSENT };
}
try {
const raw = readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw) as Partial<TelemetryConsent>;
return {
remoteEnabled: parsed.remoteEnabled ?? false,
optedInAt: parsed.optedInAt ?? null,
optedOutAt: parsed.optedOutAt ?? null,
lastUploadAt: parsed.lastUploadAt ?? null,
};
} catch {
return { ...DEFAULT_CONSENT };
}
}
/**
* Persist a full or partial consent update.
*/
export function writeConsent(update: Partial<TelemetryConsent>, mosaicHome?: string): void {
const current = readConsent(mosaicHome);
const next: TelemetryConsent = { ...current, ...update };
atomicWrite(consentFilePath(mosaicHome), JSON.stringify(next, null, 2) + '\n');
}
/**
* Mark opt-in: enable remote upload and record timestamp.
*/
export function optIn(mosaicHome?: string): TelemetryConsent {
const now = new Date().toISOString();
const next: TelemetryConsent = {
remoteEnabled: true,
optedInAt: now,
optedOutAt: null,
lastUploadAt: readConsent(mosaicHome).lastUploadAt,
};
writeConsent(next, mosaicHome);
return next;
}
/**
* Mark opt-out: disable remote upload and record timestamp.
*/
export function optOut(mosaicHome?: string): TelemetryConsent {
const now = new Date().toISOString();
const current = readConsent(mosaicHome);
const next: TelemetryConsent = {
remoteEnabled: false,
optedInAt: current.optedInAt,
optedOutAt: now,
lastUploadAt: current.lastUploadAt,
};
writeConsent(next, mosaicHome);
return next;
}
/**
* Record a successful upload timestamp.
*/
export function recordUpload(mosaicHome?: string): void {
writeConsent({ lastUploadAt: new Date().toISOString() }, mosaicHome);
}

View File

@@ -40,6 +40,11 @@ export interface RuntimeState {
mcpConfigured: boolean; mcpConfigured: boolean;
} }
export interface HooksState {
accepted: boolean;
acceptedAt?: string;
}
export interface WizardState { export interface WizardState {
mosaicHome: string; mosaicHome: string;
sourceDir: string; sourceDir: string;
@@ -50,4 +55,5 @@ export interface WizardState {
tools: ToolsConfig; tools: ToolsConfig;
runtimes: RuntimeState; runtimes: RuntimeState;
selectedSkills: string[]; selectedSkills: string[];
hooks?: HooksState;
} }

View File

@@ -1,3 +1,6 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
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 { WizardState } from './types.js'; import type { WizardState } from './types.js';
@@ -8,9 +11,29 @@ 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';
import { runtimeSetupStage } from './stages/runtime-setup.js'; import { runtimeSetupStage } from './stages/runtime-setup.js';
import { hooksPreviewStage } from './stages/hooks-preview.js';
import { skillsSelectStage } from './stages/skills-select.js'; import { skillsSelectStage } from './stages/skills-select.js';
import { finalizeStage } from './stages/finalize.js'; import { finalizeStage } from './stages/finalize.js';
// ─── Transient install session state (CU-07-02) ───────────────────────────────
const INSTALL_STATE_FILE = join(
process.env['XDG_RUNTIME_DIR'] ?? process.env['TMPDIR'] ?? tmpdir(),
'mosaic-install-state.json',
);
function writeInstallState(mosaicHome: string): void {
try {
const state = {
wizardCompletedAt: new Date().toISOString(),
mosaicHome,
};
writeFileSync(INSTALL_STATE_FILE, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
} catch {
// Non-fatal — gateway install will just ask for home again
}
}
export interface WizardOptions { export interface WizardOptions {
mosaicHome: string; mosaicHome: string;
sourceDir: string; sourceDir: string;
@@ -87,9 +110,16 @@ export async function runWizard(options: WizardOptions): Promise<void> {
// Stage 7: Runtime Detection & Installation // Stage 7: Runtime Detection & Installation
await runtimeSetupStage(prompter, state); await runtimeSetupStage(prompter, state);
// Stage 8: Skills Selection // Stage 8: Hooks preview (Claude only — skipped if Claude not detected)
await hooksPreviewStage(prompter, state);
// Stage 9: Skills Selection
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
// Stage 9: Finalize // Stage 10: Finalize
await finalizeStage(prompter, state, configService); await finalizeStage(prompter, state, configService);
// CU-07-02: Write transient session state so `mosaic gateway install` can
// pick up mosaicHome without re-prompting.
writeInstallState(state.mosaicHome);
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/queue", "name": "@mosaicstack/queue",
"version": "0.0.3", "version": "0.0.4",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
@@ -22,6 +22,7 @@
}, },
"dependencies": { "dependencies": {
"@mosaicstack/types": "workspace:*", "@mosaicstack/types": "workspace:*",
"commander": "^13.0.0",
"ioredis": "^5.10.0" "ioredis": "^5.10.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerQueueCommand } from './cli.js';
describe('registerQueueCommand', () => {
function buildProgram(): Command {
const program = new Command('mosaic');
registerQueueCommand(program);
return program;
}
it('registers a "queue" subcommand', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
expect(queueCmd).toBeDefined();
});
it('queue has list, stats, pause, resume, jobs, drain subcommands', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
expect(queueCmd).toBeDefined();
const names = queueCmd!.commands.map((c) => c.name());
expect(names).toContain('list');
expect(names).toContain('stats');
expect(names).toContain('pause');
expect(names).toContain('resume');
expect(names).toContain('jobs');
expect(names).toContain('drain');
});
it('jobs subcommand has a "tail" subcommand', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
const jobsCmd = queueCmd!.commands.find((c) => c.name() === 'jobs');
expect(jobsCmd).toBeDefined();
const tailCmd = jobsCmd!.commands.find((c) => c.name() === 'tail');
expect(tailCmd).toBeDefined();
});
it('drain has a --yes option', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
const drainCmd = queueCmd!.commands.find((c) => c.name() === 'drain');
expect(drainCmd).toBeDefined();
const optionNames = drainCmd!.options.map((o) => o.long);
expect(optionNames).toContain('--yes');
});
it('stats accepts an optional [name] argument', () => {
const program = buildProgram();
const queueCmd = program.commands.find((c) => c.name() === 'queue');
const statsCmd = queueCmd!.commands.find((c) => c.name() === 'stats');
expect(statsCmd).toBeDefined();
// Should not throw when called without argument
const args = statsCmd!.registeredArguments;
expect(args.length).toBe(1);
expect(args[0]!.required).toBe(false);
});
});

248
packages/queue/src/cli.ts Normal file
View File

@@ -0,0 +1,248 @@
import type { Command } from 'commander';
import { createLocalAdapter } from './adapters/local.js';
import type { QueueConfig } from './types.js';
/** Resolve adapter type from env; defaults to 'local'. */
function resolveAdapterType(): 'bullmq' | 'local' {
const t = process.env['QUEUE_ADAPTER'] ?? 'local';
return t === 'bullmq' ? 'bullmq' : 'local';
}
function resolveConfig(): QueueConfig {
const type = resolveAdapterType();
if (type === 'bullmq') {
return { type: 'bullmq', url: process.env['VALKEY_URL'] };
}
return { type: 'local', dataDir: process.env['QUEUE_DATA_DIR'] };
}
const BULLMQ_ONLY_MSG =
'not supported by local adapter — use the bullmq tier for this (set QUEUE_ADAPTER=bullmq)';
/**
* Register queue subcommands on an existing Commander program.
* Follows the same pattern as registerQualityRails in @mosaicstack/quality-rails.
*/
export function registerQueueCommand(parent: Command): void {
buildQueueCommand(parent.command('queue').description('Manage Mosaic job queues'));
}
function buildQueueCommand(queue: Command): void {
// ─── list ──────────────────────────────────────────────────────────────
queue
.command('list')
.description('List all queues known to the configured adapter')
.action(async () => {
const config = resolveConfig();
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
// Local adapter tracks queues in its internal Map; we expose them by
// listing JSON files in the data dir.
const { readdirSync } = await import('node:fs');
const { existsSync } = await import('node:fs');
const dataDir = config.dataDir ?? '.mosaic/queue';
if (!existsSync(dataDir)) {
console.log('No queues found (data dir does not exist yet).');
await adapter.close();
return;
}
const files = readdirSync(dataDir).filter((f: string) => f.endsWith('.json'));
if (files.length === 0) {
console.log('No queues found.');
} else {
console.log('Queues (local adapter):');
for (const f of files) {
console.log(` - ${f.slice(0, -5)}`);
}
}
await adapter.close();
return;
}
// bullmq — not enough info to enumerate queues without a BullMQ Board
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── stats ─────────────────────────────────────────────────────────────
queue
.command('stats [name]')
.description('Show stats for a queue (or all queues)')
.action(async (name?: string) => {
const config = resolveConfig();
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
const { readdirSync } = await import('node:fs');
const { existsSync } = await import('node:fs');
const dataDir = config.dataDir ?? '.mosaic/queue';
let names: string[] = [];
if (name) {
names = [name];
} else {
if (existsSync(dataDir)) {
names = readdirSync(dataDir)
.filter((f: string) => f.endsWith('.json'))
.map((f: string) => f.slice(0, -5));
}
}
if (names.length === 0) {
console.log('No queues found.');
await adapter.close();
return;
}
for (const queueName of names) {
const len = await adapter.length(queueName);
console.log(`Queue: ${queueName}`);
console.log(` waiting: ${len}`);
console.log(` active: 0 (local adapter — no active tracking)`);
console.log(` completed: 0 (local adapter — no completed tracking)`);
console.log(` failed: 0 (local adapter — no failed tracking)`);
console.log(` delayed: 0 (local adapter — no delayed tracking)`);
}
await adapter.close();
return;
}
// bullmq
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── pause ─────────────────────────────────────────────────────────────
queue
.command('pause <name>')
.description('Pause job processing for a queue')
.action(async (_name: string) => {
const config = resolveConfig();
if (config.type === 'local') {
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
return;
}
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── resume ────────────────────────────────────────────────────────────
queue
.command('resume <name>')
.description('Resume job processing for a queue')
.action(async (_name: string) => {
const config = resolveConfig();
if (config.type === 'local') {
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
return;
}
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── jobs tail ─────────────────────────────────────────────────────────
const jobs = queue.command('jobs').description('Job-level operations');
jobs
.command('tail [name]')
.description('Stream new jobs as they arrive (poll-based)')
.option('--interval <ms>', 'Poll interval in ms', '2000')
.action(async (name: string | undefined, opts: { interval: string }) => {
const config = resolveConfig();
const pollMs = parseInt(opts.interval, 10);
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
const { existsSync, readdirSync } = await import('node:fs');
const dataDir = config.dataDir ?? '.mosaic/queue';
let names: string[] = [];
if (name) {
names = [name];
} else {
if (existsSync(dataDir)) {
names = readdirSync(dataDir)
.filter((f: string) => f.endsWith('.json'))
.map((f: string) => f.slice(0, -5));
}
}
if (names.length === 0) {
console.log('No queues to tail.');
await adapter.close();
return;
}
console.log(`Tailing queues: ${names.join(', ')} (Ctrl-C to stop)`);
const lastLen = new Map<string, number>();
for (const qn of names) {
lastLen.set(qn, await adapter.length(qn));
}
const timer = setInterval(async () => {
for (const qn of names) {
const len = await adapter.length(qn);
const prev = lastLen.get(qn) ?? 0;
if (len > prev) {
console.log(
`[${new Date().toISOString()}] ${qn}: ${len - prev} new job(s) (total: ${len})`,
);
}
lastLen.set(qn, len);
}
}, pollMs);
process.on('SIGINT', async () => {
clearInterval(timer);
await adapter.close();
process.exit(0);
});
return;
}
// bullmq — use subscribe on the channel
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
// ─── drain ─────────────────────────────────────────────────────────────
queue
.command('drain <name>')
.description('Drain all pending jobs from a queue')
.option('--yes', 'Skip confirmation prompt')
.action(async (name: string, opts: { yes?: boolean }) => {
if (!opts.yes) {
console.error(
`WARNING: This will remove all pending jobs from queue "${name}". Re-run with --yes to confirm.`,
);
process.exit(1);
return;
}
const config = resolveConfig();
if (config.type === 'local') {
const adapter = createLocalAdapter(config);
let removed = 0;
while ((await adapter.length(name)) > 0) {
await adapter.dequeue(name);
removed++;
}
console.log(`Drained ${removed} job(s) from queue "${name}".`);
await adapter.close();
return;
}
console.log(BULLMQ_ONLY_MSG);
process.exit(0);
});
}

View File

@@ -11,6 +11,7 @@ export { type QueueAdapter, type QueueConfig as QueueAdapterConfig } from './typ
export { createQueueAdapter, registerQueueAdapter } from './factory.js'; export { createQueueAdapter, registerQueueAdapter } from './factory.js';
export { createBullMQAdapter } from './adapters/bullmq.js'; export { createBullMQAdapter } from './adapters/bullmq.js';
export { createLocalAdapter } from './adapters/local.js'; export { createLocalAdapter } from './adapters/local.js';
export { registerQueueCommand } from './cli.js';
import { registerQueueAdapter } from './factory.js'; import { registerQueueAdapter } from './factory.js';
import { createBullMQAdapter } from './adapters/bullmq.js'; import { createBullMQAdapter } from './adapters/bullmq.js';

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/storage", "name": "@mosaicstack/storage",
"version": "0.0.3", "version": "0.0.4",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
@@ -23,7 +23,8 @@
"dependencies": { "dependencies": {
"@electric-sql/pglite": "^0.2.17", "@electric-sql/pglite": "^0.2.17",
"@mosaicstack/db": "workspace:^", "@mosaicstack/db": "workspace:^",
"@mosaicstack/types": "workspace:*" "@mosaicstack/types": "workspace:*",
"commander": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.8.0", "typescript": "^5.8.0",

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerStorageCommand } from './cli.js';
describe('registerStorageCommand', () => {
function buildProgram(): Command {
const program = new Command();
program.exitOverride(); // prevent process.exit in tests
registerStorageCommand(program);
return program;
}
it('registers a "storage" command on the parent', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage');
expect(storageCmd).toBeDefined();
});
it('registers "storage status" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const statusCmd = storageCmd.commands.find((c) => c.name() === 'status');
expect(statusCmd).toBeDefined();
});
it('registers "storage tier" subcommand group', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier');
expect(tierCmd).toBeDefined();
});
it('registers "storage tier show" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
const showCmd = tierCmd.commands.find((c) => c.name() === 'show');
expect(showCmd).toBeDefined();
});
it('registers "storage tier switch" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
const switchCmd = tierCmd.commands.find((c) => c.name() === 'switch');
expect(switchCmd).toBeDefined();
});
it('registers "storage export" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const exportCmd = storageCmd.commands.find((c) => c.name() === 'export');
expect(exportCmd).toBeDefined();
});
it('registers "storage import" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const importCmd = storageCmd.commands.find((c) => c.name() === 'import');
expect(importCmd).toBeDefined();
});
it('registers "storage migrate" subcommand', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const migrateCmd = storageCmd.commands.find((c) => c.name() === 'migrate');
expect(migrateCmd).toBeDefined();
});
it('has all required subcommands in a single assertion', () => {
const program = buildProgram();
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
const topLevel = storageCmd.commands.map((c) => c.name());
expect(topLevel).toContain('status');
expect(topLevel).toContain('tier');
expect(topLevel).toContain('export');
expect(topLevel).toContain('import');
expect(topLevel).toContain('migrate');
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
const tierSubcmds = tierCmd.commands.map((c) => c.name());
expect(tierSubcmds).toContain('show');
expect(tierSubcmds).toContain('switch');
});
});

256
packages/storage/src/cli.ts Normal file
View File

@@ -0,0 +1,256 @@
import type { Command } from 'commander';
/**
* Reads the DATABASE_URL environment variable and redacts the password portion.
*/
function redactedConnectionString(): string | null {
const url = process.env['DATABASE_URL'];
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.password) {
parsed.password = '***';
}
return parsed.toString();
} catch {
// Not a valid URL — redact anything that looks like :password@
return url.replace(/:([^@/]+)@/, ':***@');
}
}
/**
* Determine the active storage tier from the environment.
* Looks at DATABASE_URL; if absent or set to a pglite path, treats tier as pglite.
*/
function activeTier(): 'postgres' | 'pglite' {
const url = process.env['DATABASE_URL'];
if (url && url.startsWith('postgres')) return 'postgres';
return 'pglite';
}
/**
* Return a human-readable config source description.
*/
function configSource(): string {
if (process.env['DATABASE_URL']) return 'env:DATABASE_URL';
const pgliteDir = process.env['PGLITE_DATA_DIR'];
if (pgliteDir) return `env:PGLITE_DATA_DIR (${pgliteDir})`;
return 'default (no DATABASE_URL set)';
}
/**
* Register storage subcommands on an existing Commander program.
* Follows the registerQualityRails pattern — uses the caller's Command
* instance to avoid cross-package Commander version mismatches.
*/
export function registerStorageCommand(parent: Command): void {
const storage = parent
.command('storage')
.description('Inspect and manage Mosaic storage configuration');
// ── storage status ───────────────────────────────────────────────────────
storage
.command('status')
.description('Show the configured storage tier and whether the adapter is reachable')
.action(async () => {
const tier = activeTier();
const source = configSource();
const connStr = tier === 'postgres' ? redactedConnectionString() : null;
console.log(`[storage] tier: ${tier}`);
console.log(`[storage] config source: ${source}`);
if (tier === 'postgres' && connStr) {
console.log(`[storage] connection: ${connStr}`);
try {
const { createDb, sql } = await import('@mosaicstack/db');
const url = process.env['DATABASE_URL'] ?? '';
const handle = createDb(url);
await handle.db.execute(sql`SELECT 1`);
await handle.close();
console.log('[storage] reachable: yes');
} catch (err) {
console.log(
`[storage] reachable: no (${err instanceof Error ? err.message : String(err)})`,
);
}
} else {
const dataDir = process.env['PGLITE_DATA_DIR'] ?? ':memory:';
console.log(`[storage] data dir: ${dataDir}`);
console.log('[storage] reachable: pglite is always local — no network check needed');
}
});
// ── storage tier ─────────────────────────────────────────────────────────
const tier = storage.command('tier').description('Inspect or switch the storage tier');
tier
.command('show')
.description('Print the active storage tier and its config source')
.action(() => {
const activeTierValue = activeTier();
const source = configSource();
console.log(`[storage] active tier: ${activeTierValue}`);
console.log(`[storage] config source: ${source}`);
});
tier
.command('switch <tier>')
.description('Switch storage tier between pglite and postgres')
.action((newTier: string) => {
const validTiers = ['pglite', 'postgres'];
if (!validTiers.includes(newTier)) {
console.error(
`[storage] unknown tier: ${newTier}. Valid options: ${validTiers.join(', ')}`,
);
process.exitCode = 1;
return;
}
console.log(`[storage] tier switch requested: ${newTier}`);
console.log('');
console.log('Mosaic storage tier is controlled by environment variables.');
console.log('Automatic config-file mutation is not supported — set the variable manually.');
console.log('');
if (newTier === 'postgres') {
console.log('To switch to postgres:');
console.log(' 1. Set DATABASE_URL in your environment or .env file:');
console.log(' export DATABASE_URL="postgresql://user:pass@localhost:5432/mosaic"');
console.log(' 2. Run migrations:');
console.log(' pnpm --filter @mosaicstack/db db:migrate');
console.log(' 3. Restart the gateway.');
} else {
console.log('To switch to pglite:');
console.log(' 1. Unset DATABASE_URL (or set it to a pglite path):');
console.log(' unset DATABASE_URL');
console.log(' # optionally: export PGLITE_DATA_DIR=/path/to/pglite/data');
console.log(' 2. Restart the gateway.');
console.log(' Note: pglite uses an in-process database — no migrations needed.');
}
});
// ── storage export ───────────────────────────────────────────────────────
storage
.command('export <path>')
.description('Dump the active storage contents to a file')
.action((outputPath: string) => {
const currentTier = activeTier();
if (currentTier === 'postgres') {
const redacted = redactedConnectionString() ?? '<DATABASE_URL>';
console.log('[storage] export for postgres tier');
console.log('');
console.log('postgres export is not yet wired in the CLI — use pg_dump directly:');
console.log('');
console.log(` pg_dump "${redacted}" > ${outputPath}`);
console.log('');
console.log('Or with Docker:');
console.log(
` docker exec <postgres-container> pg_dump -U <user> <dbname> > ${outputPath}`,
);
process.exitCode = 0;
} else {
const dataDir = process.env['PGLITE_DATA_DIR'];
console.log('[storage] export for pglite tier');
console.log('');
console.log(
'pglite export is not yet wired in the CLI — copy the data directory directly:',
);
console.log('');
if (dataDir) {
console.log(` cp -r ${dataDir} ${outputPath}`);
} else {
console.log(
' PGLITE_DATA_DIR is not set; the database is in-memory and cannot be exported.',
);
console.log(' Set PGLITE_DATA_DIR to a persistent path before running export.');
}
process.exitCode = 0;
}
});
// ── storage import ───────────────────────────────────────────────────────
storage
.command('import <path>')
.description('Restore storage contents from a previously exported file')
.action((inputPath: string) => {
const currentTier = activeTier();
if (currentTier === 'postgres') {
const redacted = redactedConnectionString() ?? '<DATABASE_URL>';
console.log('[storage] import for postgres tier');
console.log('');
console.log('postgres import is not yet wired in the CLI — use psql directly:');
console.log('');
console.log(` psql "${redacted}" < ${inputPath}`);
process.exitCode = 0;
} else {
const dataDir = process.env['PGLITE_DATA_DIR'];
console.log('[storage] import for pglite tier');
console.log('');
console.log(
'pglite import is not yet wired in the CLI — restore the data directory directly:',
);
console.log('');
if (dataDir) {
console.log(` rm -rf ${dataDir} && cp -r ${inputPath} ${dataDir}`);
console.log(' Then restart the gateway.');
} else {
console.log(
' PGLITE_DATA_DIR is not set; set it to a persistent path before running import.',
);
}
process.exitCode = 0;
}
});
// ── storage migrate ──────────────────────────────────────────────────────
storage
.command('migrate')
.description(
'Run database migrations (thin wrapper — delegates to pnpm db:migrate or prints the command)',
)
.option('--run', 'Actually execute the migration command via shell')
.action(async (opts: { run?: boolean }) => {
const currentTier = activeTier();
if (currentTier === 'pglite') {
console.log('[storage] pglite tier detected');
console.log(
'pglite runs schema setup automatically on first connection via adapter.migrate().',
);
console.log('No separate migration step is required.');
return;
}
const migrateCmd = 'pnpm --filter @mosaicstack/db db:migrate';
console.log('[storage] postgres tier detected');
console.log(`Migration command: ${migrateCmd}`);
console.log('');
if (opts.run) {
console.log('Running migrations...');
const { execSync } = await import('node:child_process');
try {
execSync(migrateCmd, { stdio: 'inherit' });
console.log('[storage] migrations complete.');
} catch (err) {
console.error(
`[storage] migration failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
}
} else {
console.log('To run migrations, execute:');
console.log(` ${migrateCmd}`);
console.log('');
console.log('Or pass --run to have this command execute it for you.');
}
});
}

View File

@@ -2,6 +2,7 @@ export type { StorageAdapter, StorageConfig } from './types.js';
export { createStorageAdapter, registerStorageAdapter } from './factory.js'; export { createStorageAdapter, registerStorageAdapter } from './factory.js';
export { PostgresAdapter } from './adapters/postgres.js'; export { PostgresAdapter } from './adapters/postgres.js';
export { PgliteAdapter } from './adapters/pglite.js'; export { PgliteAdapter } from './adapters/pglite.js';
export { registerStorageCommand } from './cli.js';
import { registerStorageAdapter } from './factory.js'; import { registerStorageAdapter } from './factory.js';
import { PostgresAdapter } from './adapters/postgres.js'; import { PostgresAdapter } from './adapters/postgres.js';

31
pnpm-lock.yaml generated
View File

@@ -385,6 +385,9 @@ importers:
'@mosaicstack/macp': '@mosaicstack/macp':
specifier: workspace:* specifier: workspace:*
version: link:../macp version: link:../macp
commander:
specifier: ^13.0.0
version: 13.1.0
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
@@ -404,6 +407,9 @@ importers:
'@mosaicstack/db': '@mosaicstack/db':
specifier: workspace:* specifier: workspace:*
version: link:../db version: link:../db
commander:
specifier: ^13.0.0
version: 13.1.0
drizzle-orm: drizzle-orm:
specifier: ^0.45.1 specifier: ^0.45.1
version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8) version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
@@ -416,6 +422,10 @@ importers:
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1) version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
packages/macp: packages/macp:
dependencies:
commander:
specifier: ^13.0.0
version: 13.1.0
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
@@ -441,6 +451,9 @@ importers:
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
drizzle-orm: drizzle-orm:
specifier: ^0.45.1 specifier: ^0.45.1
version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8) version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
@@ -466,15 +479,27 @@ importers:
'@mosaicstack/forge': '@mosaicstack/forge':
specifier: workspace:* specifier: workspace:*
version: link:../forge version: link:../forge
'@mosaicstack/log':
specifier: workspace:*
version: link:../log
'@mosaicstack/macp': '@mosaicstack/macp':
specifier: workspace:* specifier: workspace:*
version: link:../macp version: link:../macp
'@mosaicstack/memory':
specifier: workspace:*
version: link:../memory
'@mosaicstack/prdy': '@mosaicstack/prdy':
specifier: workspace:* specifier: workspace:*
version: link:../prdy version: link:../prdy
'@mosaicstack/quality-rails': '@mosaicstack/quality-rails':
specifier: workspace:* specifier: workspace:*
version: link:../quality-rails version: link:../quality-rails
'@mosaicstack/queue':
specifier: workspace:*
version: link:../queue
'@mosaicstack/storage':
specifier: workspace:*
version: link:../storage
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
@@ -571,6 +596,9 @@ importers:
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
ioredis: ioredis:
specifier: ^5.10.0 specifier: ^5.10.0
version: 5.10.0 version: 5.10.0
@@ -593,6 +621,9 @@ importers:
'@mosaicstack/types': '@mosaicstack/types':
specifier: workspace:* specifier: workspace:*
version: link:../types version: link:../types
commander:
specifier: ^13.0.0
version: 13.1.0
devDependencies: devDependencies:
typescript: typescript:
specifier: ^5.8.0 specifier: ^5.8.0

184
tools/e2e-install-test.sh Executable file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env bash
# ─── Mosaic Stack — End-to-End Install Test ────────────────────────────────────
#
# Runs a clean-container install test to verify the full first-run flow:
# tools/install.sh -> mosaic wizard (non-interactive)
# -> mosaic gateway install
# -> mosaic gateway verify
#
# Usage:
# bash tools/e2e-install-test.sh
#
# Requirements:
# - Docker (skips gracefully if not available)
# - Run from the repository root
#
# How it works:
# 1. Mounts the repository into a node:22-alpine container.
# 2. Installs prerequisites (bash, curl, jq, git) inside the container.
# 3. Runs `bash tools/install.sh --yes --no-auto-launch` to install the
# framework and CLI from the Gitea registry.
# 4. Runs `mosaic wizard --non-interactive` to set up SOUL/USER.
# 5. Runs `mosaic gateway install` with piped defaults (non-interactive).
# 6. Runs `mosaic gateway verify` and checks its exit code.
# NOTE: `mosaic gateway verify` is a new command added in the
# feat/mosaic-first-run-ux branch. If the installed CLI version
# pre-dates this branch (does not have `gateway verify`), the test
# marks this step as EXPECTED-SKIP and reports the installed version.
# 7. Reports PASS or FAIL with a summary.
#
# To run manually:
# cd /path/to/mosaic-stack
# bash tools/e2e-install-test.sh
#
# ──────────────────────────────────────────────────────────────────────────────
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="node:22-alpine"
CONTAINER_NAME="mosaic-e2e-install-$$"
# ─── Colour helpers ───────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' BOLD=$'\033[1m' RESET=$'\033[0m'
else
R="" G="" Y="" BOLD="" RESET=""
fi
info() { echo "${BOLD}[e2e]${RESET} $*"; }
ok() { echo "${G}[PASS]${RESET} $*"; }
fail() { echo "${R}[FAIL]${RESET} $*" >&2; }
warn() { echo "${Y}[WARN]${RESET} $*"; }
# ─── Docker availability check ────────────────────────────────────────────────
if ! command -v docker &>/dev/null; then
warn "Docker not found — skipping e2e install test."
warn "Install Docker and re-run this script to exercise the full install flow."
exit 0
fi
if ! docker info &>/dev/null 2>&1; then
warn "Docker daemon is not running or not accessible — skipping e2e install test."
exit 0
fi
info "Docker available — proceeding with e2e install test."
info "Repo root: ${REPO_ROOT}"
info "Container image: ${IMAGE}"
# ─── Inline script that runs INSIDE the container ────────────────────────────
INNER_SCRIPT="$(mktemp /tmp/mosaic-e2e-inner-XXXXXX.sh)"
trap 'rm -f "$INNER_SCRIPT"' EXIT
cat > "$INNER_SCRIPT" <<'INNER_SCRIPT_EOF'
#!/bin/sh
# Bootstrap: /bin/sh until bash is installed, then re-exec.
set -e
echo "=== [inner] Installing system prerequisites ==="
apk add --no-cache bash curl jq git 2>/dev/null || \
apt-get install -y -q bash curl jq git 2>/dev/null || true
# Re-exec under bash.
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1; then
exec bash "$0" "$@"
fi
# ── bash from here ────────────────────────────────────────────────────────────
set -euo pipefail
echo "=== [inner] Node.js / npm versions ==="
node --version
npm --version
echo "=== [inner] Setting up npm global prefix ==="
export NPM_PREFIX="/root/.npm-global"
mkdir -p "$NPM_PREFIX/bin"
npm config set prefix "$NPM_PREFIX" 2>/dev/null || true
export PATH="$NPM_PREFIX/bin:$PATH"
echo "=== [inner] Running install.sh --yes --no-auto-launch ==="
# Install both framework and CLI from the Gitea registry.
MOSAIC_SKIP_SKILLS_SYNC=1 \
MOSAIC_ASSUME_YES=1 \
bash /repo/tools/install.sh --yes --no-auto-launch
INSTALLED_VERSION="$(mosaic --version 2>/dev/null || echo 'unknown')"
echo "[inner] mosaic CLI installed: ${INSTALLED_VERSION}"
echo "=== [inner] Running mosaic wizard (non-interactive) ==="
mosaic wizard \
--non-interactive \
--name "test-agent" \
--user-name "tester" \
--pronouns "they/them" \
--timezone "UTC" || {
echo "[WARN] mosaic wizard exited non-zero — continuing"
}
echo "=== [inner] Running mosaic gateway install ==="
# Feed non-interactive answers:
# "1" → storage tier: local
# "" → port: accept default (14242)
# "" → ANTHROPIC_API_KEY: skip
# "" → CORS origin: accept default
# Then admin bootstrap: name, email, password
printf '1\n\n\n\nTest Admin\ntest@example.com\ntestpassword123\n' \
| mosaic gateway install
INSTALL_EXIT="$?"
if [ "${INSTALL_EXIT}" -ne 0 ]; then
echo "[ERR] mosaic gateway install exited ${INSTALL_EXIT}"
mosaic gateway status 2>/dev/null || true
exit "${INSTALL_EXIT}"
fi
echo "=== [inner] Running mosaic gateway verify ==="
# `gateway verify` was added in feat/mosaic-first-run-ux.
# If the installed version pre-dates this, skip gracefully.
if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
echo "[SKIP] Re-run after the new version is published to validate this step."
# Treat as pass — the install flow itself worked.
exit 0
fi
mosaic gateway verify
VERIFY_EXIT="$?"
echo "=== [inner] verify exit code: ${VERIFY_EXIT} ==="
exit "${VERIFY_EXIT}"
INNER_SCRIPT_EOF
chmod +x "$INNER_SCRIPT"
# ─── Pull image ───────────────────────────────────────────────────────────────
info "Pulling ${IMAGE}"
docker pull "${IMAGE}" --quiet
# ─── Run container ────────────────────────────────────────────────────────────
info "Starting container ${CONTAINER_NAME}"
EXIT_CODE=0
docker run --rm \
--name "${CONTAINER_NAME}" \
--volume "${REPO_ROOT}:/repo:ro" \
--volume "${INNER_SCRIPT}:/e2e-inner.sh:ro" \
--network host \
"${IMAGE}" \
/bin/sh /e2e-inner.sh \
|| EXIT_CODE=$?
# ─── Report ───────────────────────────────────────────────────────────────────
echo ""
if [[ "$EXIT_CODE" -eq 0 ]]; then
ok "End-to-end install test PASSED (exit ${EXIT_CODE})"
else
fail "End-to-end install test FAILED (exit ${EXIT_CODE})"
echo ""
echo " Troubleshooting:"
echo " - Review the output above for the failing step."
echo " - Re-run with bash -x tools/e2e-install-test.sh for verbose trace."
echo " - Run mosaic gateway logs inside a manual container for daemon output."
exit 1
fi

View File

@@ -12,18 +12,22 @@
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-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
# --framework Install/upgrade framework only (skip npm CLI) # --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework) # --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main) # --ref <branch> Git ref for framework archive (default: main)
# --yes Accept all defaults; headless/non-interactive install
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
# #
# Environment: # Environment:
# MOSAIC_HOME — framework install dir (default: ~/.config/mosaic) # MOSAIC_HOME — framework install dir (default: ~/.config/mosaic)
# MOSAIC_REGISTRY — npm registry URL (default: Gitea instance) # MOSAIC_REGISTRY — npm registry URL (default: Gitea instance)
# MOSAIC_SCOPE — npm scope (default: @mosaicstack) # MOSAIC_SCOPE — npm scope (default: @mosaicstack)
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global) # MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1) # MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main) # MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# #
# Wrapped in main() for safe curl-pipe usage. # Wrapped in main() for safe curl-pipe usage.
@@ -36,15 +40,26 @@ main() {
FLAG_CHECK=false FLAG_CHECK=false
FLAG_FRAMEWORK=true FLAG_FRAMEWORK=true
FLAG_CLI=true FLAG_CLI=true
FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false
FLAG_UNINSTALL=false
GIT_REF="${MOSAIC_REF:-main}" GIT_REF="${MOSAIC_REF:-main}"
# MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
FLAG_YES=true
fi
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--check) FLAG_CHECK=true; shift ;; --check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;; --framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;; --cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; shift 2 ;; --ref) GIT_REF="${2:-main}"; shift 2 ;;
*) shift ;; --yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
*) shift ;;
esac esac
done done
@@ -57,6 +72,109 @@ CLI_PKG="${SCOPE}/mosaic"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/mosaic-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 ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line.
# Gateway teardown: if mosaic CLI is still available, delegates to it.
# Does NOT touch gateway DB/storage — user must handle that separately.
if [[ "$FLAG_UNINSTALL" == "true" ]]; then
echo ""
echo "${BOLD:-}Mosaic Uninstaller (shell fallback)${RESET:-}"
echo ""
SCOPE_LINE="${SCOPE:-@mosaicstack}:registry=${REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
NPMRC_FILE="$HOME/.npmrc"
# Gateway: try mosaic CLI first, then check pid file
if command -v mosaic &>/dev/null; then
echo "${B:-}${RESET:-} Attempting gateway uninstall via mosaic CLI…"
if mosaic gateway uninstall --yes 2>/dev/null; then
echo "${G:-}${RESET:-} Gateway uninstalled via CLI."
else
echo "${Y:-}${RESET:-} Gateway uninstall via CLI failed or not installed — skipping."
fi
else
# Look for pid file and stop daemon if running
GATEWAY_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}/../mosaic-gateway"
PID_FILE="$GATEWAY_HOME/gateway.pid"
if [[ -f "$PID_FILE" ]]; then
PID="$(cat "$PID_FILE" 2>/dev/null || true)"
if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then
echo "${B:-}${RESET:-} Stopping gateway daemon (pid $PID)…"
kill "$PID" 2>/dev/null || true
sleep 1
fi
fi
echo "${Y:-}${RESET:-} mosaic CLI not found — skipping full gateway teardown."
echo " Run 'mosaic gateway uninstall' separately if the CLI is available."
fi
# Framework directory
if [[ -d "$MOSAIC_HOME" ]]; then
echo "${B:-}${RESET:-} Removing framework: $MOSAIC_HOME"
rm -rf "$MOSAIC_HOME"
echo "${G:-}${RESET:-} Framework removed."
else
echo "${Y:-}${RESET:-} Framework directory not found: $MOSAIC_HOME"
fi
# Runtime assets: restore backups or remove managed copies
echo "${B:-}${RESET:-} Reversing runtime asset copies…"
declare -a RUNTIME_DESTS=(
"$HOME/.claude/CLAUDE.md"
"$HOME/.claude/settings.json"
"$HOME/.claude/hooks-config.json"
"$HOME/.claude/context7-integration.md"
"$HOME/.config/opencode/AGENTS.md"
"$HOME/.codex/instructions.md"
)
for dest in "${RUNTIME_DESTS[@]}"; do
base="$(basename "$dest")"
dir="$(dirname "$dest")"
# Find most recent backup
backup=""
if [[ -d "$dir" ]]; then
backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
fi
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
cp "$backup" "$dest"
rm -f "$backup"
echo " Restored: $dest"
elif [[ -f "$dest" ]]; then
rm -f "$dest"
echo " Removed: $dest"
fi
done
# npmrc scope line
if [[ -f "$NPMRC_FILE" ]] && grep -qF "$SCOPE_LINE" "$NPMRC_FILE" 2>/dev/null; then
echo "${B:-}${RESET:-} Removing $SCOPE_LINE from $NPMRC_FILE"
# Use sed to remove the exact line (in-place, portable)
if sed -i.mosaic-uninstall-bak "\|^${SCOPE_LINE}\$|d" "$NPMRC_FILE" 2>/dev/null; then
rm -f "${NPMRC_FILE}.mosaic-uninstall-bak"
echo "${G:-}${RESET:-} npmrc entry removed."
else
# BSD sed syntax (macOS)
sed -i '' "\|^${SCOPE_LINE}\$|d" "$NPMRC_FILE" 2>/dev/null || \
echo "${Y:-}${RESET:-} Could not auto-remove npmrc line — remove it manually: $SCOPE_LINE"
fi
fi
# npm CLI package
echo "${B:-}${RESET:-} Uninstalling npm package: ${CLI_PKG}"
if npm uninstall -g "${CLI_PKG}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
echo "${G:-}${RESET:-} CLI package removed."
else
echo "${Y:-}${RESET:-} npm uninstall failed — you may need to run manually:"
echo " npm uninstall -g ${CLI_PKG}"
fi
echo ""
echo "${G:-}${RESET:-} Uninstall complete."
exit 0
fi
# ─── colours ────────────────────────────────────────────────────────────────── # ─── colours ──────────────────────────────────────────────────────────────────
if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then
R="" G="" Y="" B="" C="" DIM="" BOLD="" RESET="" R="" G="" Y="" B="" C="" DIM="" BOLD="" RESET=""
@@ -301,14 +419,133 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
dim " Framework data: $MOSAIC_HOME/" dim " Framework data: $MOSAIC_HOME/"
echo "" echo ""
# First install guidance # First install guidance / auto-launch
if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
echo "" echo ""
info "First install detected. Set up your agent identity:" if [[ "$FLAG_NO_AUTO_LAUNCH" == "false" ]] && [[ -t 0 ]] && [[ -t 1 ]]; then
echo " ${C}mosaic init${RESET} (interactive SOUL.md / USER.md setup)" # Interactive TTY and auto-launch not suppressed: run wizard + gateway install
echo " ${C}mosaic wizard${RESET} (full guided wizard via Node.js)" info "First install detected — launching setup wizard…"
echo ""
MOSAIC_BIN="$PREFIX/bin/mosaic"
if ! command -v "$MOSAIC_BIN" &>/dev/null && ! command -v mosaic &>/dev/null; then
warn "mosaic binary not found on PATH — skipping auto-launch."
warn "Add $PREFIX/bin to PATH and run: mosaic wizard && mosaic gateway install"
else
# Prefer the absolute path from the prefix we just installed to
MOSAIC_CMD="mosaic"
if [[ -x "$MOSAIC_BIN" ]]; then
MOSAIC_CMD="$MOSAIC_BIN"
fi
# Run wizard; if it fails we still try gateway install (best effort)
if "$MOSAIC_CMD" wizard; then
ok "Wizard complete."
else
warn "Wizard exited non-zero — continuing to gateway install."
fi
echo ""
info "Launching gateway install…"
if "$MOSAIC_CMD" gateway install; then
ok "Gateway install complete."
else
warn "Gateway install exited non-zero."
echo " You can retry with: ${C}mosaic gateway install${RESET}"
fi
fi
else
# Non-interactive or --no-auto-launch: print guidance only
info "First install detected. Set up your agent identity:"
echo " ${C}mosaic init${RESET} (interactive SOUL.md / USER.md setup)"
echo " ${C}mosaic wizard${RESET} (full guided wizard via Node.js)"
echo " ${C}mosaic gateway install${RESET} (install and start the gateway)"
fi
fi fi
# ── Write install manifest ──────────────────────────────────────────────────
# Records what was mutated so that `mosaic uninstall` can precisely reverse it.
# Written last (after all mutations) so an incomplete install leaves no manifest.
MANIFEST_PATH="$MOSAIC_HOME/.install-manifest.json"
MANIFEST_CLI_VERSION="$(installed_cli_version)"
MANIFEST_FW_VERSION="$(framework_version)"
MANIFEST_SCOPE_LINE="${SCOPE}:registry=${REGISTRY}"
MANIFEST_TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")"
# Build runtimeAssetCopies array by scanning known destinations for backups
collect_runtime_copies() {
local home_dir="$HOME"
local copies="[]"
local dests=(
"$home_dir/.claude/CLAUDE.md"
"$home_dir/.claude/settings.json"
"$home_dir/.claude/hooks-config.json"
"$home_dir/.claude/context7-integration.md"
"$home_dir/.config/opencode/AGENTS.md"
"$home_dir/.codex/instructions.md"
)
copies="["
local first=true
for dest in "${dests[@]}"; do
[[ -f "$dest" ]] || continue
local base dir backup_path backup_val
base="$(basename "$dest")"
dir="$(dirname "$dest")"
backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
if [[ -n "$backup_path" ]]; then
backup_val="\"$backup_path\""
else
backup_val="null"
fi
if [[ "$first" == "true" ]]; then
first=false
else
copies="$copies,"
fi
copies="$copies{\"source\":\"\",\"dest\":\"$dest\",\"backup\":$backup_val}"
done
copies="$copies]"
echo "$copies"
}
RUNTIME_COPIES="$(collect_runtime_copies)"
# Check whether the npmrc line was present (we may have added it above)
NPMRC_LINES_JSON="[]"
if grep -qF "$MANIFEST_SCOPE_LINE" "$HOME/.npmrc" 2>/dev/null; then
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
fi
node -e "
const fs = require('fs');
const path = require('path');
const p = process.argv[1];
const m = {
version: 1,
installedAt: process.argv[2],
cliVersion: process.argv[3] || '(unknown)',
frameworkVersion: parseInt(process.argv[4] || '0', 10),
mutations: {
directories: [path.dirname(p)],
npmGlobalPackages: ['@mosaicstack/mosaic'],
npmrcLines: JSON.parse(process.argv[5]),
shellProfileEdits: [],
runtimeAssetCopies: JSON.parse(process.argv[6]),
}
};
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(m, null, 2) + '\\n', { mode: 0o600 });
" \
"$MANIFEST_PATH" \
"$MANIFEST_TS" \
"$MANIFEST_CLI_VERSION" \
"$MANIFEST_FW_VERSION" \
"$NPMRC_LINES_JSON" \
"$RUNTIME_COPIES" 2>/dev/null \
&& ok "Install manifest written: $MANIFEST_PATH" \
|| warn "Could not write install manifest (non-fatal)"
echo "" echo ""
ok "Done." ok "Done."
fi fi