Compare commits
4 Commits
mosaic-v0.
...
feat/wizar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eca863b282 | ||
| 8fa5995bde | |||
| 25cada7735 | |||
| be6553101c |
@@ -1,72 +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:** Complete
|
**Phase:** Execution
|
||||||
**Current Milestone:** —
|
**Current Milestone:** IUH-M02
|
||||||
**Progress:** 8 / 8 milestones
|
**Progress:** 1 / 3 milestones
|
||||||
**Status:** completed
|
**Status:** active
|
||||||
**Last Updated:** 2026-04-05
|
**Last Updated:** 2026-04-05
|
||||||
**Release:** [`mosaic-v0.0.22`](https://git.mosaicstack.dev/mosaicstack/mosaic-stack/releases/tag/mosaic-v0.0.22) (`@mosaicstack/mosaic@0.0.22`, alpha — stays in 0.0.x until GA)
|
**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
|
||||||
|
|
||||||
- [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-1: `mosaic uninstall` (top-level) cleanly reverses every mutation made by `tools/install.sh` — framework data, npm CLI, nested stack deps, runtime asset injections in `~/.claude/`, npmrc scope mapping, PATH edits. Dry-run supported. `--keep-data` preserves memory + user files + gateway DB. (PR #429)
|
||||||
- [x] AC-2: `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)
|
||||||
- [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
|
- [ ] AC-3: Password entry in `bootstrapFirstUser` is masked (no plaintext echo); confirm prompt added.
|
||||||
- [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
|
- [ ] 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.
|
||||||
- [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
|
- [ ] 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.
|
||||||
- [x] 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).
|
||||||
- [x] 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.
|
||||||
- [x] 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) | done | feat/gateway-token-recovery | #411, #414 | 2026-04-05 | 2026-04-05 |
|
| 3 | IUH-M03 | Unified first-run wizard (collapse wizard + gateway) | blocked | feat/unified-first-run | #427 | — | — |
|
||||||
| 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
|
## 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 | ~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`
|
|
||||||
|
|||||||
108
docs/TASKS.md
108
docs/TASKS.md
@@ -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 | done | 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 | done | 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 | done | 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 | done | 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 | 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 | |
|
|
||||||
|
|||||||
@@ -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`
|
||||||
90
docs/archive/missions/cli-unification-20260404/TASKS.md
Normal file
90
docs/archive/missions/cli-unification-20260404/TASKS.md
Normal 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 | |
|
||||||
@@ -216,9 +216,28 @@ Correction PR:
|
|||||||
|
|
||||||
**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.
|
**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
|
### Mission outcome
|
||||||
|
|
||||||
All 8 milestones, all 8 success criteria met in-repo. Released as `mosaic-v0.0.22` (alpha) after correcting an incorrect 0.1.0 version bump + missed macp republish. Two sessions total (~10h combined) plus a follow-up correction PR.
|
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
|
||||||
|
|
||||||
|
|||||||
158
docs/scratchpads/install-ux-hardening-20260405.md
Normal file
158
docs/scratchpads/install-ux-hardening-20260405.md
Normal 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).
|
||||||
60
packages/mosaic/README.md
Normal file
60
packages/mosaic/README.md
Normal 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.
|
||||||
@@ -14,6 +14,7 @@ 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 { 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 { registerAuthCommand } from './commands/auth.js';
|
||||||
@@ -383,6 +384,10 @@ registerQueueCommand(program);
|
|||||||
|
|
||||||
registerStorageCommand(program);
|
registerStorageCommand(program);
|
||||||
|
|
||||||
|
// ─── uninstall ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerUninstallCommand(program);
|
||||||
|
|
||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
|
|||||||
@@ -28,11 +28,20 @@ describe('registerConfigCommand', () => {
|
|||||||
expect(names).toContain('config');
|
expect(names).toContain('config');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers exactly the five required subcommands', () => {
|
it('registers exactly the required subcommands', () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
const config = getConfigCmd(program);
|
const config = getConfigCmd(program);
|
||||||
const subs = config.commands.map((c) => c.name()).sort();
|
const subs = config.commands.map((c) => c.name()).sort();
|
||||||
expect(subs).toEqual(['edit', 'get', 'path', 'set', 'show']);
|
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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -264,6 +273,142 @@ describe('config edit', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 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 ────────────────────────────────────────────────────
|
// ── not-initialized guard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('not-initialized guard', () => {
|
describe('not-initialized guard', () => {
|
||||||
|
|||||||
@@ -1,8 +1,74 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
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 type { Command } from 'commander';
|
||||||
import { createConfigService } from '../config/config-service.js';
|
import { createConfigService } from '../config/config-service.js';
|
||||||
import { DEFAULT_MOSAIC_HOME } from '../constants.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.
|
* Resolve mosaicHome from the MOSAIC_HOME env var or the default constant.
|
||||||
*/
|
*/
|
||||||
@@ -179,6 +245,138 @@ export function registerConfigCommand(program: Command): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 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 ─────────────────────────────────────────────────────────
|
// ── config path ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|||||||
import { homedir, tmpdir } from 'node:os';
|
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,
|
||||||
@@ -65,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 {
|
||||||
@@ -298,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 = [
|
||||||
@@ -488,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 {
|
||||||
|
|||||||
234
packages/mosaic/src/commands/uninstall.spec.ts
Normal file
234
packages/mosaic/src/commands/uninstall.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
379
packages/mosaic/src/commands/uninstall.ts
Normal file
379
packages/mosaic/src/commands/uninstall.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
57
packages/mosaic/src/prompter/masked-prompt.spec.ts
Normal file
57
packages/mosaic/src/prompter/masked-prompt.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
130
packages/mosaic/src/prompter/masked-prompt.ts
Normal file
130
packages/mosaic/src/prompter/masked-prompt.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
167
packages/mosaic/src/runtime/install-manifest.spec.ts
Normal file
167
packages/mosaic/src/runtime/install-manifest.spec.ts
Normal 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=');
|
||||||
|
});
|
||||||
|
});
|
||||||
163
packages/mosaic/src/runtime/install-manifest.ts
Normal file
163
packages/mosaic/src/runtime/install-manifest.ts
Normal 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/';
|
||||||
160
packages/mosaic/src/stages/hooks-preview.spec.ts
Normal file
160
packages/mosaic/src/stages/hooks-preview.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
150
packages/mosaic/src/stages/hooks-preview.ts
Normal file
150
packages/mosaic/src/stages/hooks-preview.ts
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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';
|
||||||
|
|
||||||
@@ -109,10 +110,13 @@ 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
|
// CU-07-02: Write transient session state so `mosaic gateway install` can
|
||||||
|
|||||||
188
tools/install.sh
188
tools/install.sh
@@ -18,6 +18,7 @@
|
|||||||
# --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
|
# --yes Accept all defaults; headless/non-interactive install
|
||||||
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first 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)
|
||||||
@@ -41,6 +42,7 @@ FLAG_FRAMEWORK=true
|
|||||||
FLAG_CLI=true
|
FLAG_CLI=true
|
||||||
FLAG_NO_AUTO_LAUNCH=false
|
FLAG_NO_AUTO_LAUNCH=false
|
||||||
FLAG_YES=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
|
# MOSAIC_ASSUME_YES env var acts the same as --yes
|
||||||
@@ -56,6 +58,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--ref) GIT_REF="${2:-main}"; shift 2 ;;
|
--ref) GIT_REF="${2:-main}"; shift 2 ;;
|
||||||
--yes|-y) FLAG_YES=true; shift ;;
|
--yes|-y) FLAG_YES=true; shift ;;
|
||||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
||||||
|
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -69,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=""
|
||||||
@@ -358,6 +464,88 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
fi
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user