Compare commits
1 Commits
mosaic-v0.
...
feat/mosai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad0255cde6 |
130
README.md
130
README.md
@@ -7,14 +7,7 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
|||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
|
||||||
|
|
||||||
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL …) --yes # Accept all defaults
|
|
||||||
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs both components:
|
This installs both components:
|
||||||
@@ -24,10 +17,10 @@ This installs both components:
|
|||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
After install, set up your agent identity:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic wizard # Full guided setup (gateway install → verify)
|
mosaic init # Interactive wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
@@ -56,32 +49,10 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic tui # Interactive TUI connected to the gateway
|
mosaic tui # Interactive TUI connected to the gateway
|
||||||
mosaic gateway login # Authenticate with a gateway instance
|
mosaic login # Authenticate with a gateway instance
|
||||||
mosaic sessions list # List active agent sessions
|
mosaic sessions list # List active agent sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gateway Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic gateway install # Install and configure the gateway service
|
|
||||||
mosaic gateway verify # Post-install health check
|
|
||||||
mosaic gateway login # Authenticate and store a session token
|
|
||||||
mosaic gateway config rotate-token # Rotate your API token
|
|
||||||
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
|
|
||||||
```
|
|
||||||
|
|
||||||
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic config show # Print full config as JSON
|
|
||||||
mosaic config get <key> # Read a specific key
|
|
||||||
mosaic config set <key> <val># Write a key
|
|
||||||
mosaic config edit # Open config in $EDITOR
|
|
||||||
mosaic config path # Print config file path
|
|
||||||
```
|
|
||||||
|
|
||||||
### Management
|
### Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -94,80 +65,6 @@ mosaic coord init # Initialize a new orchestration mission
|
|||||||
mosaic prdy init # Create a PRD via guided session
|
mosaic prdy init # Create a PRD via guided session
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sub-package Commands
|
|
||||||
|
|
||||||
Each Mosaic sub-package exposes its API surface through the unified CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User management
|
|
||||||
mosaic auth users list
|
|
||||||
mosaic auth users create
|
|
||||||
mosaic auth sso
|
|
||||||
|
|
||||||
# Agent brain (projects, missions, tasks)
|
|
||||||
mosaic brain projects
|
|
||||||
mosaic brain missions
|
|
||||||
mosaic brain tasks
|
|
||||||
mosaic brain conversations
|
|
||||||
|
|
||||||
# Agent forge pipeline
|
|
||||||
mosaic forge run
|
|
||||||
mosaic forge status
|
|
||||||
mosaic forge resume
|
|
||||||
mosaic forge personas
|
|
||||||
|
|
||||||
# Structured logging
|
|
||||||
mosaic log tail
|
|
||||||
mosaic log search
|
|
||||||
mosaic log export
|
|
||||||
mosaic log level
|
|
||||||
|
|
||||||
# MACP protocol
|
|
||||||
mosaic macp tasks
|
|
||||||
mosaic macp submit
|
|
||||||
mosaic macp gate
|
|
||||||
mosaic macp events
|
|
||||||
|
|
||||||
# Agent memory
|
|
||||||
mosaic memory search
|
|
||||||
mosaic memory stats
|
|
||||||
mosaic memory insights
|
|
||||||
mosaic memory preferences
|
|
||||||
|
|
||||||
# Task queue (Valkey)
|
|
||||||
mosaic queue list
|
|
||||||
mosaic queue stats
|
|
||||||
mosaic queue pause
|
|
||||||
mosaic queue resume
|
|
||||||
mosaic queue jobs
|
|
||||||
mosaic queue drain
|
|
||||||
|
|
||||||
# Object storage
|
|
||||||
mosaic storage status
|
|
||||||
mosaic storage tier
|
|
||||||
mosaic storage export
|
|
||||||
mosaic storage import
|
|
||||||
mosaic storage migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Telemetry
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local observability (OTEL / Jaeger)
|
|
||||||
mosaic telemetry local status
|
|
||||||
mosaic telemetry local tail
|
|
||||||
mosaic telemetry local jaeger
|
|
||||||
|
|
||||||
# Remote telemetry (dry-run by default)
|
|
||||||
mosaic telemetry status
|
|
||||||
mosaic telemetry opt-in
|
|
||||||
mosaic telemetry opt-out
|
|
||||||
mosaic telemetry test
|
|
||||||
mosaic telemetry upload # Dry-run unless opted in
|
|
||||||
```
|
|
||||||
|
|
||||||
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -179,7 +76,7 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@git.mosaicstack.dev:mosaicstack/mosaic-stack.git
|
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git
|
||||||
cd mosaic-stack
|
cd mosaic-stack
|
||||||
|
|
||||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||||
@@ -234,7 +131,8 @@ mosaic-stack/
|
|||||||
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
||||||
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
|
│ ├── cli/ Mosaic CLI — TUI, gateway client, wizard
|
||||||
|
│ ├── mosaic/ Framework — wizard, runtime detection, update checker
|
||||||
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
|
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
|
||||||
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
|
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
|
||||||
│ ├── auth/ BetterAuth configuration
|
│ ├── auth/ BetterAuth configuration
|
||||||
@@ -255,7 +153,7 @@ mosaic-stack/
|
|||||||
│ ├── macp/ OpenClaw MACP runtime plugin
|
│ ├── macp/ OpenClaw MACP runtime plugin
|
||||||
│ └── mosaic-framework/ OpenClaw framework injection plugin
|
│ └── mosaic-framework/ OpenClaw framework injection plugin
|
||||||
├── tools/
|
├── tools/
|
||||||
│ └── install.sh Unified installer (framework + npm CLI, --yes / --no-auto-launch)
|
│ └── install.sh Unified installer (framework + npm CLI)
|
||||||
├── scripts/agent/ Agent session lifecycle scripts
|
├── scripts/agent/ Agent session lifecycle scripts
|
||||||
├── docker-compose.yml Dev infrastructure
|
├── docker-compose.yml Dev infrastructure
|
||||||
└── .woodpecker/ CI pipeline configs
|
└── .woodpecker/ CI pipeline configs
|
||||||
@@ -302,7 +200,7 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
|||||||
Run the installer again — it handles upgrades automatically:
|
Run the installer again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the CLI:
|
Or use the CLI:
|
||||||
@@ -317,12 +215,10 @@ The CLI also performs a background update check on every invocation (cached for
|
|||||||
### Installer Flags
|
### Installer Flags
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash tools/install.sh --check # Version check only
|
bash tools/install.sh --check # Version check only
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -7,36 +7,35 @@
|
|||||||
|
|
||||||
**ID:** cli-unification-20260404
|
**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.
|
**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
|
**Phase:** Execution
|
||||||
**Current Milestone:** —
|
**Current Milestone:** cu-m03 / cu-m04 / cu-m05 (parallel-eligible)
|
||||||
**Progress:** 8 / 8 milestones
|
**Progress:** 2 / 8 milestones
|
||||||
**Status:** completed
|
**Status:** active
|
||||||
**Last Updated:** 2026-04-05
|
**Last Updated:** 2026-04-04
|
||||||
**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)
|
|
||||||
|
|
||||||
## 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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 | 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 |
|
| 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 |
|
| 3 | cu-m03 | Fix gateway bootstrap token recovery (server + CLI paths) | not-started | — | — | — | — |
|
||||||
| 4 | cu-m04 | Alphabetize + group `mosaic --help` output | done | feat/help-sort + feat/mosaic-config | #402, #408 | 2026-04-05 | 2026-04-05 |
|
| 4 | cu-m04 | Alphabetize + group `mosaic --help` output | not-started | — | — | — | — |
|
||||||
| 5 | cu-m05 | Sub-package CLI surface (auth/brain/forge/log/macp/memory/queue/storage) | done | feat/mosaic-\*-cli (x9) | #403–#407, #410, #412, #413, #415 | 2026-04-05 | 2026-04-05 |
|
| 5 | cu-m05 | Sub-package CLI surface (auth/brain/forge/log/macp/memory/queue/storage) | not-started | — | — | — | — |
|
||||||
| 6 | cu-m06 | `mosaic telemetry` — local OTEL + opt-in remote upload | done | feat/mosaic-telemetry | #417 | 2026-04-05 | 2026-04-05 |
|
| 6 | cu-m06 | `mosaic telemetry` — local OTEL + opt-in remote upload | not-started | — | — | — | — |
|
||||||
| 7 | cu-m07 | Unified first-run UX (install.sh → wizard → gateway → TUI) | done | feat/mosaic-first-run-ux | #418 | 2026-04-05 | 2026-04-05 |
|
| 7 | cu-m07 | Unified first-run UX (install.sh → wizard → gateway → TUI) | not-started | — | — | — | — |
|
||||||
| 8 | cu-m08 | Docs refresh + release tag | done | docs/cli-unification-release-v0.1.0 | #419 | 2026-04-05 | 2026-04-05 |
|
| 8 | cu-m08 | Docs refresh + release tag | not-started | — | — | — | — |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -62,10 +61,9 @@
|
|||||||
|
|
||||||
## Session History
|
## Session History
|
||||||
|
|
||||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
| 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 |
|
| 1 | claude-opus-4-6 | 2026-04-04 | in-flight | — | 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
|
## Scratchpad
|
||||||
|
|
||||||
|
|||||||
@@ -23,68 +23,68 @@
|
|||||||
|
|
||||||
## Milestone 3 — Gateway bootstrap token recovery
|
## Milestone 3 — Gateway bootstrap token recovery
|
||||||
|
|
||||||
| 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 |
|
| CU-03-01 | not-started | Implementation plan for BetterAuth-cookie recovery flow (decision locked 2026-04-04) | — | opus | — | CU-02-03 | 4K | Design locked; plan-only task |
|
||||||
| 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-02 | not-started | Server: add recovery/rotate endpoint on apps/gateway/src/admin (gated by design from CU-03-01) | — | sonnet | — | CU-03-01 | 12K | |
|
||||||
| CU-03-03 | done | CLI: `mosaic gateway login` — interactive BetterAuth sign-in, persist session | — | sonnet | — | CU-03-02 | 10K | |
|
| CU-03-03 | not-started | 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-04 | not-started | 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-05 | not-started | CLI: `mosaic gateway config recover-token` — execute the recovery flow from CU-03-01 | — | sonnet | — | CU-03-03 | 10K | |
|
||||||
| CU-03-06 | done | Install UX: fix the "user exists, no token" dead-end in runInstall bootstrapFirstUser path | — | sonnet | — | CU-03-05 | 8K | |
|
| CU-03-06 | not-started | Install UX: fix the "user exists, no token" dead-end in runInstall bootstrapFirstUser path | — | sonnet | — | CU-03-05 | 8K | |
|
||||||
| CU-03-07 | done | Tests: integration tests for each recovery path (happy + error) | — | sonnet | — | CU-03-06 | 10K | |
|
| CU-03-07 | not-started | 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 | |
|
| CU-03-08 | not-started | Code review + remediation | — | haiku | — | CU-03-07 | 4K | |
|
||||||
|
|
||||||
## Milestone 4 — `mosaic --help` alphabetize + grouping
|
## Milestone 4 — `mosaic --help` alphabetize + grouping
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| 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-01 | not-started | 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-02 | not-started | 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-03 | not-started | 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-04 | not-started | Top-level `mosaic config` command — `show`, `get <key>`, `set <key> <val>`, `edit`, `path` — wraps packages/mosaic/src/config/config-service.ts (framework/agent config; distinct from `mosaic gateway config`) | — | sonnet | — | CU-02-03 | 10K | New scope (decision 2026-04-04) |
|
||||||
| CU-04-05 | done | Tests + code review for CU-04-04 | — | haiku | — | CU-04-04 | 4K | |
|
| CU-04-05 | not-started | Tests + code review for CU-04-04 | — | haiku | — | CU-04-04 | 4K | |
|
||||||
|
|
||||||
## Milestone 5 — Sub-package CLI surface
|
## 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`.
|
> 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 |
|
| 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-01 | not-started | `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-02 | not-started | `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-03 | not-started | `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-04 | not-started | `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-05 | not-started | `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-06 | not-started | `mosaic auth` — subcommands: `users list/create/delete`, `sso list`, `sso test`, `sessions list` | — | sonnet | — | CU-03-03 | 15K | needs gateway login |
|
||||||
| CU-05-07 | done | `mosaic log` — subcommands: `tail`, `search`, `export`, `level <level>` | — | sonnet | — | CU-02-03 | 10K | |
|
| CU-05-07 | not-started | `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-08 | not-started | `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-09 | not-started | Wire all eight `register<Name>Command` calls into packages/mosaic/src/cli.ts | — | haiku | — | CU-05-01…8 | 3K | |
|
||||||
| CU-05-10 | done | Integration test: `mosaic <cmd> --help` exits 0 for every new command | — | haiku | — | CU-05-09 | 5K | |
|
| CU-05-10 | not-started | Integration test: `mosaic <cmd> --help` exits 0 for every new command | — | haiku | — | CU-05-09 | 5K | |
|
||||||
|
|
||||||
## Milestone 6 — `mosaic telemetry`
|
## Milestone 6 — `mosaic telemetry`
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| 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-01 | not-started | 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-02 | not-started | `mosaic telemetry local` — status, tail, Jaeger link (wraps existing apps/gateway/src/tracing.ts) | — | sonnet | — | CU-06-01 | 8K | |
|
||||||
| CU-06-03 | 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-03 | not-started | `mosaic telemetry` — status, opt-in, opt-out, test, upload (uses telemetry-client-js) | — | sonnet | — | CU-06-01 | 12K | Dry-run mode when server endpoint not yet live |
|
||||||
| CU-06-04 | done | Persistent consent state in mosaic config; disabled by default | — | sonnet | — | CU-06-03 | 5K | |
|
| CU-06-04 | not-started | 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 | |
|
| CU-06-05 | not-started | Tests + code review | — | haiku | — | CU-06-04 | 5K | |
|
||||||
|
|
||||||
## Milestone 7 — Unified first-run UX
|
## Milestone 7 — Unified first-run UX
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| 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-01 | not-started | tools/install.sh: after npm install, hand off to `mosaic wizard` then `mosaic gateway install` | — | sonnet | — | CU-03-06 | 10K | |
|
||||||
| CU-07-02 | done | `mosaic wizard` and `mosaic gateway install` coordination: shared state, no duplicate prompts | — | sonnet | — | CU-07-01 | 12K | |
|
| CU-07-02 | not-started | `mosaic wizard` and `mosaic gateway install` coordination: shared state, no duplicate prompts | — | sonnet | — | CU-07-01 | 12K | |
|
||||||
| CU-07-03 | done | Post-install verification step: "gateway healthy, tui connects, admin token on file" | — | sonnet | — | CU-07-02 | 8K | |
|
| CU-07-03 | not-started | Post-install verification step: "gateway healthy, tui connects, admin token on file" | — | sonnet | — | CU-07-02 | 8K | |
|
||||||
| CU-07-04 | done | End-to-end test on a clean container from scratch | — | haiku | — | CU-07-03 | 8K | |
|
| CU-07-04 | not-started | End-to-end test on a clean container from scratch | — | haiku | — | CU-07-03 | 8K | |
|
||||||
|
|
||||||
## Milestone 8 — Docs + release
|
## Milestone 8 — Docs + release
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| 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-01 | not-started | 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-02 | not-started | 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-03 | not-started | 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 | |
|
| CU-08-04 | not-started | Release notes, tag `v0.1.0-rc.N`, publish release on Gitea | — | opus | — | CU-08-03 | 3K | |
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
4. [Tasks](#tasks)
|
4. [Tasks](#tasks)
|
||||||
5. [Settings](#settings)
|
5. [Settings](#settings)
|
||||||
6. [CLI Usage](#cli-usage)
|
6. [CLI Usage](#cli-usage)
|
||||||
7. [Sub-package Commands](#sub-package-commands)
|
|
||||||
8. [Telemetry](#telemetry)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -162,18 +160,12 @@ The `mosaic` CLI provides a terminal interface to the same gateway API.
|
|||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
Install via the Mosaic installer:
|
The CLI ships as part of the `@mosaicstack/mosaic` package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
# From the monorepo root
|
||||||
```
|
pnpm --filter @mosaicstack/mosaic build
|
||||||
|
node packages/mosaic/dist/cli.js --help
|
||||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
|
||||||
non-interactive use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
--yes # Accept all defaults
|
|
||||||
--no-auto-launch # Skip auto-launch of wizard after install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or if installed globally:
|
Or if installed globally:
|
||||||
@@ -182,60 +174,7 @@ Or if installed globally:
|
|||||||
mosaic --help
|
mosaic --help
|
||||||
```
|
```
|
||||||
|
|
||||||
### First-Run Wizard
|
### Signing In
|
||||||
|
|
||||||
After install the wizard launches automatically. You can re-run it at any time:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic wizard
|
|
||||||
```
|
|
||||||
|
|
||||||
The wizard guides you through:
|
|
||||||
|
|
||||||
1. Gateway discovery or installation (`mosaic gateway install`)
|
|
||||||
2. Authentication (`mosaic gateway login`)
|
|
||||||
3. Post-install health check (`mosaic gateway verify`)
|
|
||||||
|
|
||||||
### Gateway Login and Token Recovery
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Authenticate with a gateway and save a session token
|
|
||||||
mosaic gateway login
|
|
||||||
|
|
||||||
# Verify the gateway is reachable and responding
|
|
||||||
mosaic gateway verify
|
|
||||||
|
|
||||||
# Rotate your current API token
|
|
||||||
mosaic gateway config rotate-token
|
|
||||||
|
|
||||||
# Recover a token via BetterAuth cookie (for accounts with no token)
|
|
||||||
mosaic gateway config recover-token
|
|
||||||
```
|
|
||||||
|
|
||||||
If you have an existing gateway account but lost your token (common after a
|
|
||||||
reinstall), use `mosaic gateway config recover-token` to retrieve a new one
|
|
||||||
without recreating your account.
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Print full config as JSON
|
|
||||||
mosaic config show
|
|
||||||
|
|
||||||
# Read a specific key
|
|
||||||
mosaic config get gateway.url
|
|
||||||
|
|
||||||
# Write a key
|
|
||||||
mosaic config set gateway.url http://localhost:14242
|
|
||||||
|
|
||||||
# Open config in $EDITOR
|
|
||||||
mosaic config edit
|
|
||||||
|
|
||||||
# Print config file path
|
|
||||||
mosaic config path
|
|
||||||
```
|
|
||||||
|
|
||||||
### Signing In (Legacy)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic login --gateway http://localhost:14242 --email you@example.com
|
mosaic login --gateway http://localhost:14242 --email you@example.com
|
||||||
@@ -297,267 +236,3 @@ mosaic prdy
|
|||||||
# Quality rails scaffolder
|
# Quality rails scaffolder
|
||||||
mosaic quality-rails
|
mosaic quality-rails
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sub-package Commands
|
|
||||||
|
|
||||||
Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI.
|
|
||||||
All sub-package commands accept `--help` for usage details.
|
|
||||||
|
|
||||||
### `mosaic auth` — User & Authentication Management
|
|
||||||
|
|
||||||
Manage gateway users, SSO providers, and active sessions.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all users
|
|
||||||
mosaic auth users list
|
|
||||||
|
|
||||||
# Create a new user
|
|
||||||
mosaic auth users create --email alice@example.com --name "Alice"
|
|
||||||
|
|
||||||
# Delete a user
|
|
||||||
mosaic auth users delete <userId>
|
|
||||||
|
|
||||||
# List configured SSO providers
|
|
||||||
mosaic auth sso
|
|
||||||
|
|
||||||
# List active sessions
|
|
||||||
mosaic auth sessions list
|
|
||||||
|
|
||||||
# Revoke a session
|
|
||||||
mosaic auth sessions revoke <sessionId>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mosaic brain` — Projects, Missions, Tasks, Conversations
|
|
||||||
|
|
||||||
Browse and manage the brain data layer (PostgreSQL-backed project/mission/task
|
|
||||||
store).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all projects
|
|
||||||
mosaic brain projects
|
|
||||||
|
|
||||||
# List missions for a project
|
|
||||||
mosaic brain missions --project <projectId>
|
|
||||||
|
|
||||||
# List tasks
|
|
||||||
mosaic brain tasks --status in-progress
|
|
||||||
|
|
||||||
# Browse conversations
|
|
||||||
mosaic brain conversations
|
|
||||||
mosaic brain conversations --project <projectId>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mosaic config` — CLI Configuration
|
|
||||||
|
|
||||||
Read and write the `mosaic` CLI configuration file.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Show full config
|
|
||||||
mosaic config show
|
|
||||||
|
|
||||||
# Get a value
|
|
||||||
mosaic config get gateway.url
|
|
||||||
|
|
||||||
# Set a value
|
|
||||||
mosaic config set theme dark
|
|
||||||
|
|
||||||
# Open in editor
|
|
||||||
mosaic config edit
|
|
||||||
|
|
||||||
# Print file path
|
|
||||||
mosaic config path
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mosaic forge` — AI Pipeline Management
|
|
||||||
|
|
||||||
Interact with the Forge multi-stage AI delivery pipeline (intake → board review
|
|
||||||
→ planning → coding → review → deploy).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start a new forge run for a brief
|
|
||||||
mosaic forge run --brief "Add dark mode toggle to settings"
|
|
||||||
|
|
||||||
# Check status of a running pipeline
|
|
||||||
mosaic forge status
|
|
||||||
mosaic forge status --run <runId>
|
|
||||||
|
|
||||||
# Resume a paused or interrupted run
|
|
||||||
mosaic forge resume --run <runId>
|
|
||||||
|
|
||||||
# List available personas (board review evaluators)
|
|
||||||
mosaic forge personas
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mosaic gateway` — Gateway Lifecycle
|
|
||||||
|
|
||||||
Install, authenticate with, and verify the Mosaic gateway service.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install gateway (guided)
|
|
||||||
mosaic gateway install
|
|
||||||
|
|
||||||
# Verify gateway health post-install
|
|
||||||
mosaic gateway verify
|
|
||||||
|
|
||||||
# Log in and save token
|
|
||||||
mosaic gateway login
|
|
||||||
|
|
||||||
# Rotate API token
|
|
||||||
mosaic gateway config rotate-token
|
|
||||||
|
|
||||||
# Recover token via BetterAuth cookie (lost-token recovery)
|
|
||||||
mosaic gateway config recover-token
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mosaic log` — Structured Log Access
|
|
||||||
|
|
||||||
Query and stream structured logs from the gateway.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stream live logs
|
|
||||||
mosaic log tail
|
|
||||||
mosaic log tail --level warn
|
|
||||||
|
|
||||||
# Search logs
|
|
||||||
mosaic log search "database connection"
|
|
||||||
mosaic log search --since 1h "error"
|
|
||||||
|
|
||||||
# Export logs to file
|
|
||||||
mosaic log export --output logs.json
|
|
||||||
mosaic log export --since 24h --level error --output errors.json
|
|
||||||
|
|
||||||
# Get/set log level
|
|
||||||
mosaic log level
|
|
||||||
mosaic log level debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mosaic macp` — MACP Protocol
|
|
||||||
|
|
||||||
Interact with the MACP credential resolution, gate runner, and event bus.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List MACP tasks
|
|
||||||
mosaic macp tasks
|
|
||||||
mosaic macp tasks --status pending
|
|
||||||
|
|
||||||
# Submit a new MACP task
|
|
||||||
mosaic macp submit --type credential-resolve --payload '{"key":"OPENAI_API_KEY"}'
|
|
||||||
|
|
||||||
# Run a gate check
|
|
||||||
mosaic macp gate --gate quality-check
|
|
||||||
|
|
||||||
# Stream MACP events
|
|
||||||
mosaic macp events
|
|
||||||
mosaic macp events --filter credential
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mosaic memory` — Agent Memory
|
|
||||||
|
|
||||||
Query and inspect the agent memory layer.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Semantic search over memory
|
|
||||||
mosaic memory search "previous decisions about auth"
|
|
||||||
|
|
||||||
# Show memory statistics
|
|
||||||
mosaic memory stats
|
|
||||||
|
|
||||||
# Generate memory insights report
|
|
||||||
mosaic memory insights
|
|
||||||
|
|
||||||
# View stored preferences
|
|
||||||
mosaic memory preferences
|
|
||||||
mosaic memory preferences --set editor=neovim
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mosaic queue` — Task Queue (Valkey)
|
|
||||||
|
|
||||||
Manage the Valkey-backed task queue.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all queues
|
|
||||||
mosaic queue list
|
|
||||||
|
|
||||||
# Show queue statistics
|
|
||||||
mosaic queue stats
|
|
||||||
mosaic queue stats --queue agent-tasks
|
|
||||||
|
|
||||||
# Pause a queue
|
|
||||||
mosaic queue pause agent-tasks
|
|
||||||
|
|
||||||
# Resume a paused queue
|
|
||||||
mosaic queue resume agent-tasks
|
|
||||||
|
|
||||||
# List jobs in a queue
|
|
||||||
mosaic queue jobs agent-tasks
|
|
||||||
mosaic queue jobs agent-tasks --status failed
|
|
||||||
|
|
||||||
# Drain (empty) a queue
|
|
||||||
mosaic queue drain agent-tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mosaic storage` — Object Storage
|
|
||||||
|
|
||||||
Manage object storage tiers and data migrations.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Show storage status and usage
|
|
||||||
mosaic storage status
|
|
||||||
|
|
||||||
# List available storage tiers
|
|
||||||
mosaic storage tier
|
|
||||||
|
|
||||||
# Export data from storage
|
|
||||||
mosaic storage export --bucket agent-artifacts --output ./artifacts.tar.gz
|
|
||||||
|
|
||||||
# Import data into storage
|
|
||||||
mosaic storage import --bucket agent-artifacts --input ./artifacts.tar.gz
|
|
||||||
|
|
||||||
# Migrate data between tiers
|
|
||||||
mosaic storage migrate --from hot --to cold --older-than 30d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Telemetry
|
|
||||||
|
|
||||||
Mosaic includes an OpenTelemetry-based telemetry system. Local telemetry
|
|
||||||
(traces, metrics sent to Jaeger) is always available. Remote telemetry upload
|
|
||||||
requires explicit opt-in.
|
|
||||||
|
|
||||||
### Local Telemetry
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Show local OTEL collector / Jaeger status
|
|
||||||
mosaic telemetry local status
|
|
||||||
|
|
||||||
# Tail live OTEL spans
|
|
||||||
mosaic telemetry local tail
|
|
||||||
|
|
||||||
# Open Jaeger UI URL
|
|
||||||
mosaic telemetry local jaeger
|
|
||||||
```
|
|
||||||
|
|
||||||
### Remote Telemetry
|
|
||||||
|
|
||||||
Remote upload is a no-op (dry-run) until you opt in. Your consent state is
|
|
||||||
persisted in the config file.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Show current consent state
|
|
||||||
mosaic telemetry status
|
|
||||||
|
|
||||||
# Opt in to remote telemetry
|
|
||||||
mosaic telemetry opt-in
|
|
||||||
|
|
||||||
# Opt out (data stays local)
|
|
||||||
mosaic telemetry opt-out
|
|
||||||
|
|
||||||
# Test telemetry pipeline without uploading
|
|
||||||
mosaic telemetry test
|
|
||||||
|
|
||||||
# Upload telemetry (requires opt-in; dry-run otherwise)
|
|
||||||
mosaic telemetry upload
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -154,72 +154,6 @@ No code changes to `apps/`, `packages/mosaic/`, or any other runtime package. Se
|
|||||||
- **pr-create.sh wrapper bug:** Discovered during M1 — `~/.config/mosaic/tools/git/pr-create.sh` line 158 uses `eval "$CMD"`, which shell-evaluates any backticks / `$(…)` / `${…}` in PR bodies. Workaround: strip backticks from PR bodies (use bold / italic / plain text instead), or use `tea pr create` directly. Captured in openbrain as gotcha. Should be fixed upstream in Mosaic tools repo at some point, but out of scope for this mission.
|
- **pr-create.sh wrapper bug:** Discovered during M1 — `~/.config/mosaic/tools/git/pr-create.sh` line 158 uses `eval "$CMD"`, which shell-evaluates any backticks / `$(…)` / `${…}` in PR bodies. Workaround: strip backticks from PR bodies (use bold / italic / plain text instead), or use `tea pr create` directly. Captured in openbrain as gotcha. Should be fixed upstream in Mosaic tools repo at some point, but out of scope for this mission.
|
||||||
- **Mosaic coord / orchestrator session lock drift:** `.mosaic/orchestrator/session.lock` gets re-written every session launch and shows up as a dirty working tree on branch switch. Not blocking — just noise to ignore.
|
- **Mosaic coord / orchestrator session lock drift:** `.mosaic/orchestrator/session.lock` gets re-written every session launch and shows up as a dirty working tree on branch switch. Not blocking — just noise to ignore.
|
||||||
|
|
||||||
## Session 2 Log (2026-04-05)
|
|
||||||
|
|
||||||
**Session 2 agent:** claude-opus-4-6[1m]
|
|
||||||
**Mode:** parallel orchestration across worktrees
|
|
||||||
|
|
||||||
### Wave 1 — M3 (gateway token recovery)
|
|
||||||
|
|
||||||
- CU-03-01 plan landed as PR #401 → `docs/plans/gateway-token-recovery.md`. Confirmed no server changes needed — AdminGuard already accepts BetterAuth cookies, `POST /api/admin/tokens` is the existing mint endpoint.
|
|
||||||
- CU-03-02..07 implemented as PR #411: `mosaic gateway login` (interactive BetterAuth sign-in, session persisted), `mosaic gateway config rotate-token`, `mosaic gateway config recover-token`, fix for `bootstrapFirstUser` "user exists, no token" dead-end, 22 new unit tests. New files: `commands/gateway/login.ts`, `commands/gateway/token-ops.ts`.
|
|
||||||
- CU-03-08 independent code review surfaced 2 BLOCKER findings (session.json world-readable, password echoed during prompt) + 3 important findings (trimmed password, cross-gateway token persistence, unsafe `--password` flag). Remediated in PR #414: `saveSession` writes mode 0o600, new `promptSecret()` uses TTY raw mode, persistence target now matches `--gateway` host, `--password` marked UNSAFE with warning.
|
|
||||||
|
|
||||||
### Wave 2 — M4 (help ergonomics + mosaic config)
|
|
||||||
|
|
||||||
- CU-04-01..03 landed as PR #402: `configureHelp({ sortSubcommands: true })` on root + gateway subgroup, plus an `addHelpText('after', …)` grouped-reference section (Commander 13 has no native command-group API).
|
|
||||||
- CU-04-04/05 landed as PR #408: top-level `mosaic config` with `show|get|set|edit|path`, extends `config/config-service.ts` with `readAll`, `getValue`, `setValue`, `getConfigPath`, `isInitialized` + `ConfigSection`/`ResolvedConfig` types. Additive only.
|
|
||||||
|
|
||||||
### Wave 3 — M5 (sub-package CLI surface, 8 commands + integration)
|
|
||||||
|
|
||||||
Parallel-dispatched in isolated worktrees. All merged:
|
|
||||||
|
|
||||||
- PR #403 `mosaic brain`, PR #404 `mosaic queue`, PR #405 `mosaic storage`, PR #406 `mosaic memory`, PR #407 `mosaic log`, PR #410 `mosaic macp`, PR #412 `mosaic forge`, PR #413 `mosaic auth`.
|
|
||||||
- Every package exports `register<Name>Command(parent: Command)` co-located with library code, following `@mosaicstack/quality-rails` pattern. Each wired into `packages/mosaic/src/cli.ts` with alphabetized `register…Command(program)` calls.
|
|
||||||
- PR #415 landed CU-05-10 integration smoke test (`packages/mosaic/src/cli-smoke.spec.ts`, 19 tests covering all 9 registrars) PLUS a pre-existing exports bug fix in `packages/macp/package.json` (`default` pointed at `./src/index.ts` instead of `./dist/index.js`, breaking ERR_MODULE_NOT_FOUND when compiled mosaic CLI tried to load macp at runtime). Caught by empirical `node packages/mosaic/dist/cli.js --help` test before merge.
|
|
||||||
|
|
||||||
### New gotchas captured in Session 2
|
|
||||||
|
|
||||||
- **`pr-create.sh` "Remote repository required" failure:** wrapper can't detect origin in multi-remote contexts. Fallback used throughout: direct Gitea API `curl -X POST …/api/v1/repos/mosaicstack/mosaic-stack/pulls` with body JSON.
|
|
||||||
- **`publish` workflow killed on post-merge pushes:** pipelines 735, 742, 747, 750, 758, 767 all show the Docker build step killed after `ci` workflow succeeded. Pre-existing infrastructure issue (observed on #714/#715 pre-mission). The `ci` workflow is the authoritative gate; `publish` killing is noise.
|
|
||||||
- **macp exports.default misaligned:** latent bug from original monorepo consolidation — every other package already pointed at `dist/`. Only exposed when compiled CLI started loading macp at runtime.
|
|
||||||
- **Commander 13 grouping:** no native command-group API; workaround is `addHelpText('after', groupedReferenceString)` + alphabetized flat list via `sortSubcommands: true`.
|
|
||||||
|
|
||||||
### Wave 4 — M6 + M7 (parallel)
|
|
||||||
|
|
||||||
- M6 `mosaic telemetry` landed as PR #417 (merge `a531029c`). Full scope CU-06-01..05: `@mosaicstack/telemetry-client-js` shim, `telemetry local {status,tail,jaeger}`, top-level `telemetry {status,opt-in,opt-out,test,upload}` with dry-run default, persistent consent state. New files: `packages/mosaic/src/commands/telemetry.ts`, `src/telemetry/client-shim.ts`, `src/telemetry/consent-store.ts`, plus `telemetry.spec.ts`.
|
|
||||||
- M7 unified first-run UX landed as PR #418 (merge `872c1245`). Full scope CU-07-01..04: `install.sh` `--yes`/`--no-auto-launch` flags + auto-handoff to wizard + gateway install, wizard/gateway-install coordination via transient state file, `mosaic gateway verify` post-install healthcheck, Docker-based `tools/e2e-install-test.sh`.
|
|
||||||
|
|
||||||
### Wave 5 — M8 (release)
|
|
||||||
|
|
||||||
- PR #419 (merge `b9d464de`) — CLI unification release v0.1.0. Single cohesive docs + release PR:
|
|
||||||
- README.md: unified command tree, new install UX, `mosaic gateway` and `mosaic config` sections, removed stale `@mosaicstack/cli` refs.
|
|
||||||
- docs/guides/user-guide.md: new "Sub-package Commands" + "Telemetry" sections covering all 11 top-level commands.
|
|
||||||
- `packages/mosaic/package.json`: bumped 0.0.21 → 0.1.0 (CI publishes on merge).
|
|
||||||
- Git tag: `mosaic-v0.1.0` (scoped to avoid collision with existing `v0.1.0` repo tag) — pushed to origin on merge sha.
|
|
||||||
- Gitea release: https://git.mosaicstack.dev/mosaicstack/mosaic-stack/releases/tag/mosaic-v0.1.0 — "@mosaicstack/mosaic v0.1.0 — CLI Unification".
|
|
||||||
|
|
||||||
### Wave 6 — M8 correction (version regression)
|
|
||||||
|
|
||||||
PR #419 bumped `@mosaicstack/mosaic` 0.0.21 → 0.1.0 and released as `mosaic-v0.1.0`. This was wrong on two counts:
|
|
||||||
|
|
||||||
1. **Versioning policy violation.** The project stays in `0.0.x` alpha until GA. Minor bump to `0.1.0` jumped out of alpha without authorization.
|
|
||||||
2. **macp exports fix never reached the registry.** PR #415 fixed `packages/macp/package.json` `exports.default` pointing at `./src/index.ts`, but did NOT bump macp's version. When the post-merge publish workflow ran on #419, it published `@mosaicstack/mosaic@0.1.0` but `@mosaicstack/macp@0.0.2` was "already published" so the fix was silently skipped. Result: users running `mosaic update` got mosaic 0.1.0 which depends on macp and resolves to the still-broken registry copy of macp@0.0.2, failing with `ERR_MODULE_NOT_FOUND` on `./src/index.ts` at CLI startup.
|
|
||||||
|
|
||||||
Correction PR:
|
|
||||||
|
|
||||||
- `@mosaicstack/mosaic` 0.1.0 → `0.0.22` (stay in alpha)
|
|
||||||
- `@mosaicstack/macp` 0.0.2 → `0.0.3` (force republish with the exports fix)
|
|
||||||
- Delete Gitea tag `mosaic-v0.1.0` + release
|
|
||||||
- Delete `@mosaicstack/mosaic@0.1.0` from the Gitea npm registry so `latest` reverts to the highest remaining version
|
|
||||||
- Create tag `mosaic-v0.0.22` + Gitea release
|
|
||||||
|
|
||||||
**Lesson captured:** every package whose _source_ changes must also have its _version_ bumped, because the publish workflow silently skips "already published" versions. `@mosaicstack/macp@0.0.2` had the bad exports in the registry from day one; the in-repo fix in #415 was invisible to installed-from-registry consumers until the version bumped.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
## Verification Evidence
|
## Verification Evidence
|
||||||
|
|
||||||
### CU-01-01 (PR #398)
|
### CU-01-01 (PR #398)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/brain",
|
"name": "@mosaicstack/brain",
|
||||||
"version": "0.0.3",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/forge",
|
"name": "@mosaicstack/forge",
|
||||||
"version": "0.0.3",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
@@ -26,8 +26,7 @@
|
|||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/macp": "workspace:*",
|
"@mosaicstack/macp": "workspace:*"
|
||||||
"commander": "^13.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Command } from 'commander';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { registerForgeCommand } from './cli.js';
|
|
||||||
|
|
||||||
describe('registerForgeCommand', () => {
|
|
||||||
it('registers a "forge" command on the parent program', () => {
|
|
||||||
const program = new Command();
|
|
||||||
registerForgeCommand(program);
|
|
||||||
|
|
||||||
const forgeCmd = program.commands.find((c) => c.name() === 'forge');
|
|
||||||
expect(forgeCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers the four required subcommands under forge', () => {
|
|
||||||
const program = new Command();
|
|
||||||
registerForgeCommand(program);
|
|
||||||
|
|
||||||
const forgeCmd = program.commands.find((c) => c.name() === 'forge');
|
|
||||||
expect(forgeCmd).toBeDefined();
|
|
||||||
|
|
||||||
const subNames = forgeCmd!.commands.map((c) => c.name());
|
|
||||||
|
|
||||||
expect(subNames).toContain('run');
|
|
||||||
expect(subNames).toContain('status');
|
|
||||||
expect(subNames).toContain('resume');
|
|
||||||
expect(subNames).toContain('personas');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "personas list" as a subcommand of "forge personas"', () => {
|
|
||||||
const program = new Command();
|
|
||||||
registerForgeCommand(program);
|
|
||||||
|
|
||||||
const forgeCmd = program.commands.find((c) => c.name() === 'forge');
|
|
||||||
const personasCmd = forgeCmd!.commands.find((c) => c.name() === 'personas');
|
|
||||||
expect(personasCmd).toBeDefined();
|
|
||||||
|
|
||||||
const personasSubNames = personasCmd!.commands.map((c) => c.name());
|
|
||||||
expect(personasSubNames).toContain('list');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not modify the parent program name or description', () => {
|
|
||||||
const program = new Command('mosaic');
|
|
||||||
program.description('Mosaic Stack CLI');
|
|
||||||
registerForgeCommand(program);
|
|
||||||
|
|
||||||
expect(program.name()).toBe('mosaic');
|
|
||||||
expect(program.description()).toBe('Mosaic Stack CLI');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can be called multiple times without throwing', () => {
|
|
||||||
const program = new Command();
|
|
||||||
expect(() => {
|
|
||||||
registerForgeCommand(program);
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import type { Command } from 'commander';
|
|
||||||
|
|
||||||
import { classifyBrief } from './brief-classifier.js';
|
|
||||||
import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js';
|
|
||||||
import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js';
|
|
||||||
import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js';
|
|
||||||
import type { PipelineOptions, RunManifest, TaskExecutor } from './types.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Stub executor — used when no real executor is wired at CLI invocation time.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const stubExecutor: TaskExecutor = {
|
|
||||||
async submitTask(task) {
|
|
||||||
console.log(` [forge] stage submitted: ${task.id} (${task.title})`);
|
|
||||||
},
|
|
||||||
async waitForCompletion(taskId, _timeoutMs) {
|
|
||||||
console.log(` [forge] stage complete: ${taskId}`);
|
|
||||||
return {
|
|
||||||
task_id: taskId,
|
|
||||||
status: 'completed' as const,
|
|
||||||
completed_at: new Date().toISOString(),
|
|
||||||
exit_code: 0,
|
|
||||||
gate_results: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async getTaskStatus(_taskId) {
|
|
||||||
return 'completed' as const;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function formatDuration(startedAt?: string, completedAt?: string): string {
|
|
||||||
if (!startedAt || !completedAt) return '-';
|
|
||||||
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
|
||||||
const secs = Math.round(ms / 1000);
|
|
||||||
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${secs % 60}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function printManifestTable(manifest: RunManifest): void {
|
|
||||||
console.log(`\nRun ID : ${manifest.runId}`);
|
|
||||||
console.log(`Status : ${manifest.status}`);
|
|
||||||
console.log(`Brief : ${manifest.brief}`);
|
|
||||||
console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`);
|
|
||||||
console.log(`Updated: ${manifest.updatedAt}`);
|
|
||||||
console.log('');
|
|
||||||
console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration');
|
|
||||||
console.log('-'.repeat(50));
|
|
||||||
for (const stage of STAGE_SEQUENCE) {
|
|
||||||
const s = manifest.stages[stage];
|
|
||||||
if (!s) continue;
|
|
||||||
const label = (STAGE_LABELS[stage] ?? stage).padEnd(22);
|
|
||||||
const status = s.status.padEnd(14);
|
|
||||||
const dur = formatDuration(s.startedAt, s.completedAt);
|
|
||||||
console.log(`${label}${status}${dur}`);
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRunDir(runId: string, projectRoot?: string): string {
|
|
||||||
const root = projectRoot ?? process.cwd();
|
|
||||||
return path.join(root, '.forge', 'runs', runId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function listRecentRuns(projectRoot?: string): void {
|
|
||||||
const root = projectRoot ?? process.cwd();
|
|
||||||
const runsDir = path.join(root, '.forge', 'runs');
|
|
||||||
|
|
||||||
if (!fs.existsSync(runsDir)) {
|
|
||||||
console.log('No runs found. Run `mosaic forge run` to start a pipeline.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = fs
|
|
||||||
.readdirSync(runsDir)
|
|
||||||
.filter((name) => fs.statSync(path.join(runsDir, name)).isDirectory())
|
|
||||||
.sort()
|
|
||||||
.reverse()
|
|
||||||
.slice(0, 10);
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
console.log('No runs found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nRecent runs:');
|
|
||||||
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief');
|
|
||||||
console.log('-'.repeat(70));
|
|
||||||
|
|
||||||
for (const runId of entries) {
|
|
||||||
const runDir = path.join(runsDir, runId);
|
|
||||||
try {
|
|
||||||
const manifest = loadManifest(runDir);
|
|
||||||
const status = manifest.status.padEnd(14);
|
|
||||||
const brief = path.basename(manifest.brief);
|
|
||||||
console.log(`${runId.padEnd(22)}${status}${brief}`);
|
|
||||||
} catch {
|
|
||||||
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Register function
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register forge subcommands on an existing Commander program.
|
|
||||||
* Mirrors the pattern used by registerQualityRails in @mosaicstack/quality-rails.
|
|
||||||
*/
|
|
||||||
export function registerForgeCommand(parent: Command): void {
|
|
||||||
const forge = parent.command('forge').description('Run and manage Forge pipelines');
|
|
||||||
|
|
||||||
// ── forge run ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
forge
|
|
||||||
.command('run')
|
|
||||||
.description('Run a Forge pipeline from a brief markdown file')
|
|
||||||
.requiredOption('--brief <path>', 'Path to the brief markdown file')
|
|
||||||
.option('--run-id <id>', 'Override the auto-generated run ID')
|
|
||||||
.option('--resume', 'Resume an existing run instead of starting a new one', false)
|
|
||||||
.option('--config <path>', 'Path to forge config file (.forge/config.yaml)')
|
|
||||||
.option('--codebase <path>', 'Codebase root to pass to the pipeline', process.cwd())
|
|
||||||
.option('--dry-run', 'Print planned stages without executing', false)
|
|
||||||
.action(
|
|
||||||
async (opts: {
|
|
||||||
brief: string;
|
|
||||||
runId?: string;
|
|
||||||
resume: boolean;
|
|
||||||
config?: string;
|
|
||||||
codebase: string;
|
|
||||||
dryRun: boolean;
|
|
||||||
}) => {
|
|
||||||
const briefPath = path.resolve(opts.brief);
|
|
||||||
|
|
||||||
if (!fs.existsSync(briefPath)) {
|
|
||||||
console.error(`[forge] brief not found: ${briefPath}`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const briefContent = fs.readFileSync(briefPath, 'utf-8');
|
|
||||||
const briefClass = classifyBrief(briefContent);
|
|
||||||
const projectRoot = opts.codebase;
|
|
||||||
|
|
||||||
if (opts.resume) {
|
|
||||||
const runId = opts.runId ?? generateRunId();
|
|
||||||
const runDir = resolveRunDir(runId, projectRoot);
|
|
||||||
console.log(`[forge] resuming run: ${runId}`);
|
|
||||||
const { resumePipeline } = await import('./pipeline-runner.js');
|
|
||||||
const result = await resumePipeline(runDir, stubExecutor);
|
|
||||||
console.log(`[forge] pipeline complete: ${result.runId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pipelineOptions: PipelineOptions = {
|
|
||||||
briefClass,
|
|
||||||
codebase: projectRoot,
|
|
||||||
dryRun: opts.dryRun,
|
|
||||||
executor: stubExecutor,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (opts.dryRun) {
|
|
||||||
const { stagesForClass } = await import('./brief-classifier.js');
|
|
||||||
const stages = stagesForClass(briefClass);
|
|
||||||
console.log(`[forge] dry-run — brief class: ${briefClass}`);
|
|
||||||
console.log('[forge] planned stages:');
|
|
||||||
for (const stage of stages) {
|
|
||||||
console.log(` - ${stage} (${STAGE_LABELS[stage] ?? stage})`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[forge] starting pipeline for brief: ${briefPath}`);
|
|
||||||
console.log(`[forge] classified as: ${briefClass}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await runPipeline(briefPath, projectRoot, pipelineOptions);
|
|
||||||
console.log(`[forge] pipeline complete: ${result.runId}`);
|
|
||||||
console.log(`[forge] run directory: ${result.runDir}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── forge status ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
forge
|
|
||||||
.command('status [runId]')
|
|
||||||
.description('Show the status of a pipeline run (omit runId to list recent runs)')
|
|
||||||
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
|
|
||||||
.action(async (runId: string | undefined, opts: { project: string }) => {
|
|
||||||
if (!runId) {
|
|
||||||
listRecentRuns(opts.project);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runDir = resolveRunDir(runId, opts.project);
|
|
||||||
try {
|
|
||||||
const manifest = getPipelineStatus(runDir);
|
|
||||||
printManifestTable(manifest);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`[forge] could not load run "${runId}": ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── forge resume ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
forge
|
|
||||||
.command('resume <runId>')
|
|
||||||
.description('Resume a stopped or failed pipeline run')
|
|
||||||
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
|
|
||||||
.action(async (runId: string, opts: { project: string }) => {
|
|
||||||
const runDir = resolveRunDir(runId, opts.project);
|
|
||||||
|
|
||||||
if (!fs.existsSync(runDir)) {
|
|
||||||
console.error(`[forge] run not found: ${runDir}`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[forge] resuming run: ${runId}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { resumePipeline } = await import('./pipeline-runner.js');
|
|
||||||
const result = await resumePipeline(runDir, stubExecutor);
|
|
||||||
console.log(`[forge] pipeline complete: ${result.runId}`);
|
|
||||||
console.log(`[forge] run directory: ${result.runDir}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── forge personas ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const personas = forge.command('personas').description('Manage Forge board personas');
|
|
||||||
|
|
||||||
personas
|
|
||||||
.command('list')
|
|
||||||
.description('List configured board personas')
|
|
||||||
.option(
|
|
||||||
'--project <path>',
|
|
||||||
'Project root for persona overrides (defaults to cwd)',
|
|
||||||
process.cwd(),
|
|
||||||
)
|
|
||||||
.option('--board-dir <path>', 'Override the board agents directory')
|
|
||||||
.action((opts: { project: string; boardDir?: string }) => {
|
|
||||||
const effectivePersonas = opts.boardDir
|
|
||||||
? loadBoardPersonas(opts.boardDir)
|
|
||||||
: getEffectivePersonas(opts.project);
|
|
||||||
|
|
||||||
if (effectivePersonas.length === 0) {
|
|
||||||
console.log('[forge] no board personas configured.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nBoard personas (${effectivePersonas.length}):\n`);
|
|
||||||
console.log('Slug'.padEnd(24) + 'Name');
|
|
||||||
console.log('-'.repeat(50));
|
|
||||||
for (const p of effectivePersonas) {
|
|
||||||
console.log(`${p.slug.padEnd(24)}${p.name}`);
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -80,6 +80,3 @@ export {
|
|||||||
resumePipeline,
|
resumePipeline,
|
||||||
getPipelineStatus,
|
getPipelineStatus,
|
||||||
} from './pipeline-runner.js';
|
} from './pipeline-runner.js';
|
||||||
|
|
||||||
// CLI
|
|
||||||
export { registerForgeCommand } from './cli.js';
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/log",
|
"name": "@mosaicstack/log",
|
||||||
"version": "0.0.3",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/db": "workspace:*",
|
"@mosaicstack/db": "workspace:*",
|
||||||
"commander": "^13.0.0",
|
|
||||||
"drizzle-orm": "^0.45.1"
|
"drizzle-orm": "^0.45.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Command } from 'commander';
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
|
|
||||||
import { registerLogCommand } from './cli.js';
|
|
||||||
|
|
||||||
function buildTestProgram(): Command {
|
|
||||||
const program = new Command('mosaic');
|
|
||||||
program.exitOverride(); // prevent process.exit in tests
|
|
||||||
registerLogCommand(program);
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('registerLogCommand', () => {
|
|
||||||
it('registers a "log" subcommand on the parent', () => {
|
|
||||||
const program = buildTestProgram();
|
|
||||||
const names = program.commands.map((c) => c.name());
|
|
||||||
expect(names).toContain('log');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('log command has tail, search, export, and level subcommands', () => {
|
|
||||||
const program = buildTestProgram();
|
|
||||||
const logCmd = program.commands.find((c) => c.name() === 'log');
|
|
||||||
expect(logCmd).toBeDefined();
|
|
||||||
const subNames = logCmd!.commands.map((c) => c.name());
|
|
||||||
expect(subNames).toContain('tail');
|
|
||||||
expect(subNames).toContain('search');
|
|
||||||
expect(subNames).toContain('export');
|
|
||||||
expect(subNames).toContain('level');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tail subcommand has expected options', () => {
|
|
||||||
const program = buildTestProgram();
|
|
||||||
const logCmd = program.commands.find((c) => c.name() === 'log')!;
|
|
||||||
const tailCmd = logCmd.commands.find((c) => c.name() === 'tail')!;
|
|
||||||
const optionNames = tailCmd.options.map((o) => o.long);
|
|
||||||
expect(optionNames).toContain('--agent');
|
|
||||||
expect(optionNames).toContain('--level');
|
|
||||||
expect(optionNames).toContain('--category');
|
|
||||||
expect(optionNames).toContain('--tier');
|
|
||||||
expect(optionNames).toContain('--limit');
|
|
||||||
expect(optionNames).toContain('--db');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('search subcommand accepts a positional query argument', () => {
|
|
||||||
const program = buildTestProgram();
|
|
||||||
const logCmd = program.commands.find((c) => c.name() === 'log')!;
|
|
||||||
const searchCmd = logCmd.commands.find((c) => c.name() === 'search')!;
|
|
||||||
// Commander stores positional args in _args
|
|
||||||
const argNames = searchCmd.registeredArguments.map((a) => a.name());
|
|
||||||
expect(argNames).toContain('query');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('export subcommand accepts a positional path argument', () => {
|
|
||||||
const program = buildTestProgram();
|
|
||||||
const logCmd = program.commands.find((c) => c.name() === 'log')!;
|
|
||||||
const exportCmd = logCmd.commands.find((c) => c.name() === 'export')!;
|
|
||||||
const argNames = exportCmd.registeredArguments.map((a) => a.name());
|
|
||||||
expect(argNames).toContain('path');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('level subcommand accepts a positional level argument', () => {
|
|
||||||
const program = buildTestProgram();
|
|
||||||
const logCmd = program.commands.find((c) => c.name() === 'log')!;
|
|
||||||
const levelCmd = logCmd.commands.find((c) => c.name() === 'level')!;
|
|
||||||
const argNames = levelCmd.registeredArguments.map((a) => a.name());
|
|
||||||
expect(argNames).toContain('level');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { writeFileSync } from 'node:fs';
|
|
||||||
|
|
||||||
import type { Command } from 'commander';
|
|
||||||
|
|
||||||
import type { LogCategory, LogLevel, LogTier } from './agent-logs.js';
|
|
||||||
|
|
||||||
interface FilterOptions {
|
|
||||||
agent?: string;
|
|
||||||
level?: string;
|
|
||||||
category?: string;
|
|
||||||
tier?: string;
|
|
||||||
limit?: string;
|
|
||||||
db?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLimit(raw: string | undefined, defaultVal = 50): number {
|
|
||||||
if (!raw) return defaultVal;
|
|
||||||
const n = parseInt(raw, 10);
|
|
||||||
return Number.isFinite(n) && n > 0 ? n : defaultVal;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildQuery(opts: FilterOptions) {
|
|
||||||
return {
|
|
||||||
...(opts.agent ? { sessionId: opts.agent } : {}),
|
|
||||||
...(opts.level ? { level: opts.level as LogLevel } : {}),
|
|
||||||
...(opts.category ? { category: opts.category as LogCategory } : {}),
|
|
||||||
...(opts.tier ? { tier: opts.tier as LogTier } : {}),
|
|
||||||
limit: parseLimit(opts.limit),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openDb(connectionString: string) {
|
|
||||||
const { createDb } = await import('@mosaicstack/db');
|
|
||||||
return createDb(connectionString);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveConnectionString(opts: FilterOptions): string | undefined {
|
|
||||||
return opts.db ?? process.env['DATABASE_URL'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register log subcommands on an existing Commander program.
|
|
||||||
* This avoids cross-package Commander version mismatches by using the
|
|
||||||
* caller's Command instance directly.
|
|
||||||
*/
|
|
||||||
export function registerLogCommand(parent: Command): void {
|
|
||||||
const log = parent.command('log').description('Query and manage agent logs');
|
|
||||||
|
|
||||||
// ─── tail ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
log
|
|
||||||
.command('tail')
|
|
||||||
.description('Tail recent agent logs')
|
|
||||||
.option('--agent <id>', 'Filter by agent/session ID')
|
|
||||||
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
|
|
||||||
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
|
|
||||||
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
|
|
||||||
.option('--limit <n>', 'Number of logs to return (default 50)', '50')
|
|
||||||
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
|
|
||||||
.action(async (opts: FilterOptions) => {
|
|
||||||
const connStr = resolveConnectionString(opts);
|
|
||||||
if (!connStr) {
|
|
||||||
console.error('Database connection required: use --db or set DATABASE_URL');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handle = await openDb(connStr);
|
|
||||||
try {
|
|
||||||
const { createLogService } = await import('./log-service.js');
|
|
||||||
const svc = createLogService(handle.db);
|
|
||||||
const query = buildQuery(opts);
|
|
||||||
|
|
||||||
const logs = await svc.logs.query(query);
|
|
||||||
if (logs.length === 0) {
|
|
||||||
console.log('No logs found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const entry of logs) {
|
|
||||||
const ts = new Date(entry.createdAt).toISOString();
|
|
||||||
console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── search ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
log
|
|
||||||
.command('search <query>')
|
|
||||||
.description('Full-text search over agent logs')
|
|
||||||
.option('--agent <id>', 'Filter by agent/session ID')
|
|
||||||
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
|
|
||||||
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
|
|
||||||
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
|
|
||||||
.option('--limit <n>', 'Number of logs to return (default 50)', '50')
|
|
||||||
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
|
|
||||||
.action(async (query: string, opts: FilterOptions) => {
|
|
||||||
const connStr = resolveConnectionString(opts);
|
|
||||||
if (!connStr) {
|
|
||||||
console.error('Database connection required: use --db or set DATABASE_URL');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handle = await openDb(connStr);
|
|
||||||
try {
|
|
||||||
const { createLogService } = await import('./log-service.js');
|
|
||||||
const svc = createLogService(handle.db);
|
|
||||||
const baseQuery = buildQuery(opts);
|
|
||||||
|
|
||||||
const logs = await svc.logs.query(baseQuery);
|
|
||||||
const lowerQ = query.toLowerCase();
|
|
||||||
const matched = logs.filter(
|
|
||||||
(e) =>
|
|
||||||
e.content.toLowerCase().includes(lowerQ) ||
|
|
||||||
(e.metadata != null && JSON.stringify(e.metadata).toLowerCase().includes(lowerQ)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matched.length === 0) {
|
|
||||||
console.log('No matching logs found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const entry of matched) {
|
|
||||||
const ts = new Date(entry.createdAt).toISOString();
|
|
||||||
console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── export ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
log
|
|
||||||
.command('export <path>')
|
|
||||||
.description('Export matching logs to an NDJSON file')
|
|
||||||
.option('--agent <id>', 'Filter by agent/session ID')
|
|
||||||
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
|
|
||||||
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
|
|
||||||
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
|
|
||||||
.option('--limit <n>', 'Number of logs to export (default 50)', '50')
|
|
||||||
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
|
|
||||||
.action(async (outputPath: string, opts: FilterOptions) => {
|
|
||||||
const connStr = resolveConnectionString(opts);
|
|
||||||
if (!connStr) {
|
|
||||||
console.error('Database connection required: use --db or set DATABASE_URL');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handle = await openDb(connStr);
|
|
||||||
try {
|
|
||||||
const { createLogService } = await import('./log-service.js');
|
|
||||||
const svc = createLogService(handle.db);
|
|
||||||
const query = buildQuery(opts);
|
|
||||||
|
|
||||||
const logs = await svc.logs.query(query);
|
|
||||||
const ndjson = logs.map((e) => JSON.stringify(e)).join('\n');
|
|
||||||
writeFileSync(outputPath, ndjson, 'utf8');
|
|
||||||
console.log(`Exported ${logs.length} log(s) to ${outputPath}`);
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── level ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
log
|
|
||||||
.command('level <level>')
|
|
||||||
.description('Set runtime log level for the connected log service')
|
|
||||||
.action((level: string) => {
|
|
||||||
void level;
|
|
||||||
console.log(
|
|
||||||
'Runtime log level adjustment is not supported in current mode (DB-backed log service).',
|
|
||||||
);
|
|
||||||
process.exitCode = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -9,4 +9,3 @@ export {
|
|||||||
type LogTier,
|
type LogTier,
|
||||||
type LogQuery,
|
type LogQuery,
|
||||||
} from './agent-logs.js';
|
} from './agent-logs.js';
|
||||||
export { registerLogCommand } from './cli.js';
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/macp",
|
"name": "@mosaicstack/macp",
|
||||||
"version": "0.0.3",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"default": "./dist/index.js"
|
"default": "./src/index.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -21,9 +21,6 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
|
||||||
"commander": "^13.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@vitest/coverage-v8": "^2.0.0",
|
"@vitest/coverage-v8": "^2.0.0",
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { registerMacpCommand } from './cli.js';
|
|
||||||
|
|
||||||
describe('registerMacpCommand', () => {
|
|
||||||
function buildProgram(): Command {
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride(); // prevent process.exit in tests
|
|
||||||
registerMacpCommand(program);
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('registers a "macp" command on the parent', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const macpCmd = program.commands.find((c) => c.name() === 'macp');
|
|
||||||
expect(macpCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "macp tasks" subcommand group', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
|
||||||
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks');
|
|
||||||
expect(tasksCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "macp tasks list" subcommand with --status and --type flags', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
|
||||||
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks')!;
|
|
||||||
const listCmd = tasksCmd.commands.find((c) => c.name() === 'list');
|
|
||||||
expect(listCmd).toBeDefined();
|
|
||||||
const optionNames = listCmd!.options.map((o) => o.long);
|
|
||||||
expect(optionNames).toContain('--status');
|
|
||||||
expect(optionNames).toContain('--type');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "macp submit" subcommand', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
|
||||||
const submitCmd = macpCmd.commands.find((c) => c.name() === 'submit');
|
|
||||||
expect(submitCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "macp gate" subcommand with --fail-on flag', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
|
||||||
const gateCmd = macpCmd.commands.find((c) => c.name() === 'gate');
|
|
||||||
expect(gateCmd).toBeDefined();
|
|
||||||
const optionNames = gateCmd!.options.map((o) => o.long);
|
|
||||||
expect(optionNames).toContain('--fail-on');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "macp events" subcommand group', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
|
||||||
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events');
|
|
||||||
expect(eventsCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "macp events tail" subcommand', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
|
||||||
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events')!;
|
|
||||||
const tailCmd = eventsCmd.commands.find((c) => c.name() === 'tail');
|
|
||||||
expect(tailCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has all required top-level subcommands', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
|
||||||
const topLevel = macpCmd.commands.map((c) => c.name());
|
|
||||||
expect(topLevel).toContain('tasks');
|
|
||||||
expect(topLevel).toContain('submit');
|
|
||||||
expect(topLevel).toContain('gate');
|
|
||||||
expect(topLevel).toContain('events');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import type { Command } from 'commander';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register macp subcommands on an existing Commander program.
|
|
||||||
* This avoids cross-package Commander version mismatches by using the
|
|
||||||
* caller's Command instance directly.
|
|
||||||
*/
|
|
||||||
export function registerMacpCommand(parent: Command): void {
|
|
||||||
const macp = parent.command('macp').description('MACP task and gate management');
|
|
||||||
|
|
||||||
// ─── tasks ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const tasks = macp.command('tasks').description('Manage MACP tasks');
|
|
||||||
|
|
||||||
tasks
|
|
||||||
.command('list')
|
|
||||||
.description('List MACP tasks')
|
|
||||||
.option(
|
|
||||||
'--status <status>',
|
|
||||||
'Filter by task status (pending|running|gated|completed|failed|escalated)',
|
|
||||||
)
|
|
||||||
.option(
|
|
||||||
'--type <type>',
|
|
||||||
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
|
|
||||||
)
|
|
||||||
.action((opts: { status?: string; type?: string }) => {
|
|
||||||
// not yet wired — task persistence layer is not present in @mosaicstack/macp
|
|
||||||
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
|
|
||||||
if (opts.status) {
|
|
||||||
console.log(` status filter: ${opts.status}`);
|
|
||||||
}
|
|
||||||
if (opts.type) {
|
|
||||||
console.log(` type filter: ${opts.type}`);
|
|
||||||
}
|
|
||||||
process.exitCode = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── submit ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
macp
|
|
||||||
.command('submit <path>')
|
|
||||||
.description('Submit a task from a JSON/YAML spec file')
|
|
||||||
.action((specPath: string) => {
|
|
||||||
// not yet wired — task submission requires a running MACP server
|
|
||||||
console.log('[macp] submit: not yet wired — use macp package programmatically');
|
|
||||||
console.log(` spec path: ${specPath}`);
|
|
||||||
console.log(' task id: (unavailable — no MACP server connected)');
|
|
||||||
console.log(' status: (unavailable — no MACP server connected)');
|
|
||||||
process.exitCode = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── gate ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
macp
|
|
||||||
.command('gate <spec>')
|
|
||||||
.description('Run a gate from a spec string or file path (wraps runGate/runGates)')
|
|
||||||
.option('--fail-on <mode>', 'Gate fail-on mode: ai|fail|both|none', 'fail')
|
|
||||||
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
|
|
||||||
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
|
|
||||||
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
|
|
||||||
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
|
|
||||||
// not yet wired — gate execution requires a task context and event sink
|
|
||||||
console.log('[macp] gate: not yet wired — use macp package programmatically');
|
|
||||||
console.log(` spec: ${spec}`);
|
|
||||||
console.log(` fail-on: ${opts.failOn}`);
|
|
||||||
console.log(` cwd: ${opts.cwd}`);
|
|
||||||
console.log(` log: ${opts.log}`);
|
|
||||||
console.log(` timeout: ${opts.timeout}s`);
|
|
||||||
process.exitCode = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── events ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const events = macp.command('events').description('Stream MACP events');
|
|
||||||
|
|
||||||
events
|
|
||||||
.command('tail')
|
|
||||||
.description('Tail MACP events from the event log (wraps event emitter)')
|
|
||||||
.option('--file <path>', 'Path to the MACP events NDJSON file')
|
|
||||||
.option('--follow', 'Follow the file for new events (like tail -f)')
|
|
||||||
.action((opts: { file?: string; follow?: boolean }) => {
|
|
||||||
// not yet wired — event streaming requires a live event source
|
|
||||||
console.log('[macp] events tail: not yet wired — use macp package programmatically');
|
|
||||||
if (opts.file) {
|
|
||||||
console.log(` file: ${opts.file}`);
|
|
||||||
}
|
|
||||||
if (opts.follow) {
|
|
||||||
console.log(' mode: follow');
|
|
||||||
}
|
|
||||||
process.exitCode = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -41,6 +41,3 @@ export type { NormalizedGate } from './gate-runner.js';
|
|||||||
|
|
||||||
// Event emitter
|
// Event emitter
|
||||||
export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
|
export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
|
||||||
|
|
||||||
// CLI
|
|
||||||
export { registerMacpCommand } from './cli.js';
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/memory",
|
"name": "@mosaicstack/memory",
|
||||||
"version": "0.0.4",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/mosaic",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.24",
|
"version": "0.0.21",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
@@ -30,13 +30,11 @@
|
|||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
"@mosaicstack/config": "workspace:*",
|
"@mosaicstack/config": "workspace:*",
|
||||||
"@mosaicstack/forge": "workspace:*",
|
"@mosaicstack/forge": "workspace:*",
|
||||||
"@mosaicstack/log": "workspace:*",
|
|
||||||
"@mosaicstack/macp": "workspace:*",
|
"@mosaicstack/macp": "workspace:*",
|
||||||
"@mosaicstack/memory": "workspace:*",
|
"@mosaicstack/memory": "workspace:*",
|
||||||
"@mosaicstack/prdy": "workspace:*",
|
"@mosaicstack/prdy": "workspace:*",
|
||||||
"@mosaicstack/quality-rails": "workspace:*",
|
"@mosaicstack/quality-rails": "workspace:*",
|
||||||
"@mosaicstack/queue": "workspace:*",
|
"@mosaicstack/queue": "workspace:*",
|
||||||
"@mosaicstack/storage": "workspace:*",
|
|
||||||
"@mosaicstack/types": "workspace:*",
|
"@mosaicstack/types": "workspace:*",
|
||||||
"@clack/prompts": "^0.9.1",
|
"@clack/prompts": "^0.9.1",
|
||||||
"commander": "^13.0.0",
|
"commander": "^13.0.0",
|
||||||
|
|||||||
@@ -74,8 +74,7 @@ export function saveSession(gatewayUrl: string, auth: AuthResult): void {
|
|||||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
|
||||||
};
|
};
|
||||||
|
|
||||||
// 0o600: owner read/write only — the session cookie is a credential
|
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8');
|
||||||
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { registerBrainCommand } from '@mosaicstack/brain';
|
|
||||||
import { registerForgeCommand } from '@mosaicstack/forge';
|
|
||||||
import { registerLogCommand } from '@mosaicstack/log';
|
|
||||||
import { registerMacpCommand } from '@mosaicstack/macp';
|
|
||||||
import { registerMemoryCommand } from '@mosaicstack/memory';
|
|
||||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
|
||||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
|
||||||
|
|
||||||
// CU-05-10 — integration smoke test
|
|
||||||
// Asserts every sub-package CLI registered via register<Name>Command() attaches
|
|
||||||
// a top-level command to the root program and that its help output renders
|
|
||||||
// without throwing. This is the "mosaic <cmd> --help exits 0" gate that
|
|
||||||
// guards the sub-package CLI surface (CU-05-01..08) from silent breakage.
|
|
||||||
|
|
||||||
const REGISTRARS: Array<[string, (program: Command) => void]> = [
|
|
||||||
['auth', registerAuthCommand],
|
|
||||||
['brain', registerBrainCommand],
|
|
||||||
['config', registerConfigCommand],
|
|
||||||
['forge', registerForgeCommand],
|
|
||||||
['log', registerLogCommand],
|
|
||||||
['macp', registerMacpCommand],
|
|
||||||
['memory', registerMemoryCommand],
|
|
||||||
['queue', registerQueueCommand],
|
|
||||||
['storage', registerStorageCommand],
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('sub-package CLI smoke (CU-05-10)', () => {
|
|
||||||
for (const [name, register] of REGISTRARS) {
|
|
||||||
it(`registers the "${name}" command on the root program`, () => {
|
|
||||||
const program = new Command();
|
|
||||||
register(program);
|
|
||||||
const cmd = program.commands.find((c) => c.name() === name);
|
|
||||||
expect(cmd, `expected top-level "${name}" command`).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`"${name}" help output renders without throwing`, () => {
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
register(program);
|
|
||||||
const cmd = program.commands.find((c) => c.name() === name);
|
|
||||||
expect(cmd).toBeDefined();
|
|
||||||
expect(() => cmd!.helpInformation()).not.toThrow();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it('all nine sub-package commands coexist on a single program', () => {
|
|
||||||
const program = new Command();
|
|
||||||
for (const [, register] of REGISTRARS) register(program);
|
|
||||||
const names = program.commands.map((c) => c.name()).sort();
|
|
||||||
expect(names).toEqual([
|
|
||||||
'auth',
|
|
||||||
'brain',
|
|
||||||
'config',
|
|
||||||
'forge',
|
|
||||||
'log',
|
|
||||||
'macp',
|
|
||||||
'memory',
|
|
||||||
'queue',
|
|
||||||
'storage',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,20 +3,13 @@
|
|||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { registerBrainCommand } from '@mosaicstack/brain';
|
import { registerBrainCommand } from '@mosaicstack/brain';
|
||||||
import { registerForgeCommand } from '@mosaicstack/forge';
|
|
||||||
import { registerLogCommand } from '@mosaicstack/log';
|
|
||||||
import { registerMacpCommand } from '@mosaicstack/macp';
|
|
||||||
import { registerMemoryCommand } from '@mosaicstack/memory';
|
import { registerMemoryCommand } from '@mosaicstack/memory';
|
||||||
import { registerQualityRails } from '@mosaicstack/quality-rails';
|
import { registerQualityRails } from '@mosaicstack/quality-rails';
|
||||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
import { registerQueueCommand } from '@mosaicstack/queue';
|
||||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
|
||||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
import {
|
import {
|
||||||
backgroundUpdateCheck,
|
backgroundUpdateCheck,
|
||||||
@@ -331,10 +324,6 @@ sessionsCmd
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── auth ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerAuthCommand(program);
|
|
||||||
|
|
||||||
// ─── gateway ──────────────────────────────────────────────────────────
|
// ─── gateway ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerGatewayCommand(program);
|
registerGatewayCommand(program);
|
||||||
@@ -343,10 +332,6 @@ registerGatewayCommand(program);
|
|||||||
|
|
||||||
registerAgentCommand(program);
|
registerAgentCommand(program);
|
||||||
|
|
||||||
// ─── config ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerConfigCommand(program);
|
|
||||||
|
|
||||||
// ─── mission ───────────────────────────────────────────────────────────
|
// ─── mission ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerMissionCommand(program);
|
registerMissionCommand(program);
|
||||||
@@ -355,22 +340,10 @@ registerMissionCommand(program);
|
|||||||
|
|
||||||
registerBrainCommand(program);
|
registerBrainCommand(program);
|
||||||
|
|
||||||
// ─── forge ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerForgeCommand(program);
|
|
||||||
|
|
||||||
// ─── macp ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerMacpCommand(program);
|
|
||||||
|
|
||||||
// ─── quality-rails ──────────────────────────────────────────────────────
|
// ─── quality-rails ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerQualityRails(program);
|
registerQualityRails(program);
|
||||||
|
|
||||||
// ─── log ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerLogCommand(program);
|
|
||||||
|
|
||||||
// ─── memory ──────────────────────────────────────────────────────────────
|
// ─── memory ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerMemoryCommand(program);
|
registerMemoryCommand(program);
|
||||||
@@ -379,14 +352,6 @@ registerMemoryCommand(program);
|
|||||||
|
|
||||||
registerQueueCommand(program);
|
registerQueueCommand(program);
|
||||||
|
|
||||||
// ─── storage ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerStorageCommand(program);
|
|
||||||
|
|
||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
|
||||||
|
|
||||||
// ─── update ─────────────────────────────────────────────────────────────
|
// ─── update ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
|
|
||||||
// ─── Mocks ──────────────────────────────────────────────────────────────────
|
|
||||||
// These mocks prevent any real disk/network access during tests.
|
|
||||||
|
|
||||||
vi.mock('./gateway/login.js', () => ({
|
|
||||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./gateway/token-ops.js', () => ({
|
|
||||||
requireSession: vi.fn().mockResolvedValue('better-auth.session_token=test'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Global fetch is never called in smoke tests (no actions invoked).
|
|
||||||
|
|
||||||
import { registerAuthCommand } from './auth.js';
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildTestProgram(): Command {
|
|
||||||
const program = new Command('mosaic').exitOverride();
|
|
||||||
registerAuthCommand(program);
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findCommand(program: Command, ...path: string[]): Command | undefined {
|
|
||||||
let current: Command = program;
|
|
||||||
for (const name of path) {
|
|
||||||
const found = current.commands.find((c) => c.name() === name);
|
|
||||||
if (!found) return undefined;
|
|
||||||
current = found;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('registerAuthCommand', () => {
|
|
||||||
let program: Command;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
program = buildTestProgram();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers the top-level auth command', () => {
|
|
||||||
const authCmd = findCommand(program, 'auth');
|
|
||||||
expect(authCmd).toBeDefined();
|
|
||||||
expect(authCmd?.name()).toBe('auth');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('auth users', () => {
|
|
||||||
it('registers the users subcommand', () => {
|
|
||||||
const usersCmd = findCommand(program, 'auth', 'users');
|
|
||||||
expect(usersCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers users list with --limit flag', () => {
|
|
||||||
const listCmd = findCommand(program, 'auth', 'users', 'list');
|
|
||||||
expect(listCmd).toBeDefined();
|
|
||||||
const limitOpt = listCmd?.options.find((o) => o.long === '--limit');
|
|
||||||
expect(limitOpt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers users create', () => {
|
|
||||||
const createCmd = findCommand(program, 'auth', 'users', 'create');
|
|
||||||
expect(createCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers users delete with --yes flag', () => {
|
|
||||||
const deleteCmd = findCommand(program, 'auth', 'users', 'delete');
|
|
||||||
expect(deleteCmd).toBeDefined();
|
|
||||||
const yesOpt = deleteCmd?.options.find((o) => o.long === '--yes');
|
|
||||||
expect(yesOpt).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('auth sso', () => {
|
|
||||||
it('registers the sso subcommand', () => {
|
|
||||||
const ssoCmd = findCommand(program, 'auth', 'sso');
|
|
||||||
expect(ssoCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers sso list', () => {
|
|
||||||
const listCmd = findCommand(program, 'auth', 'sso', 'list');
|
|
||||||
expect(listCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers sso test', () => {
|
|
||||||
const testCmd = findCommand(program, 'auth', 'sso', 'test');
|
|
||||||
expect(testCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('auth sessions', () => {
|
|
||||||
it('registers the sessions subcommand', () => {
|
|
||||||
const sessCmd = findCommand(program, 'auth', 'sessions');
|
|
||||||
expect(sessCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers sessions list', () => {
|
|
||||||
const listCmd = findCommand(program, 'auth', 'sessions', 'list');
|
|
||||||
expect(listCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('all top-level auth subcommand names are correct', () => {
|
|
||||||
const authCmd = findCommand(program, 'auth');
|
|
||||||
expect(authCmd).toBeDefined();
|
|
||||||
const names = authCmd!.commands.map((c) => c.name()).sort();
|
|
||||||
expect(names).toEqual(['sessions', 'sso', 'users']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import type { Command } from 'commander';
|
|
||||||
import { getGatewayUrl } from './gateway/login.js';
|
|
||||||
import { requireSession } from './gateway/token-ops.js';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface UserDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
banned: boolean;
|
|
||||||
banReason: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserListDto {
|
|
||||||
users: UserDto[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── HTTP helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function adminGet<T>(gatewayUrl: string, cookie: string, path: string): Promise<T> {
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(`${gatewayUrl}${path}`, {
|
|
||||||
headers: { Cookie: cookie, Origin: gatewayUrl },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
console.error(`Session rejected by the gateway (${res.status.toString()}).`);
|
|
||||||
console.error('Run: mosaic gateway login');
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`);
|
|
||||||
process.exit(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function adminPost<T>(
|
|
||||||
gatewayUrl: string,
|
|
||||||
cookie: string,
|
|
||||||
path: string,
|
|
||||||
body: unknown,
|
|
||||||
): Promise<T> {
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(`${gatewayUrl}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Cookie: cookie,
|
|
||||||
Origin: gatewayUrl,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
console.error(`Session rejected by the gateway (${res.status.toString()}).`);
|
|
||||||
console.error('Run: mosaic gateway login');
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`);
|
|
||||||
process.exit(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function adminDelete(gatewayUrl: string, cookie: string, path: string): Promise<void> {
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(`${gatewayUrl}${path}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { Cookie: cookie, Origin: gatewayUrl },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
console.error(`Session rejected by the gateway (${res.status.toString()}).`);
|
|
||||||
console.error('Run: mosaic gateway login');
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok && res.status !== 204) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
console.error(`Gateway returned error (${res.status.toString()}): ${body.slice(0, 200)}`);
|
|
||||||
process.exit(3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Formatters ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function printUser(u: UserDto): void {
|
|
||||||
console.log(` ID: ${u.id}`);
|
|
||||||
console.log(` Name: ${u.name}`);
|
|
||||||
console.log(` Email: ${u.email}`);
|
|
||||||
console.log(` Role: ${u.role}`);
|
|
||||||
console.log(` Banned: ${u.banned ? `yes (${u.banReason ?? 'no reason'})` : 'no'}`);
|
|
||||||
console.log(` Created: ${new Date(u.createdAt).toLocaleString()}`);
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Register function ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register `mosaic auth` subcommands on an existing Commander program.
|
|
||||||
*
|
|
||||||
* Location rationale: placed in packages/mosaic rather than packages/auth because
|
|
||||||
* the CLI needs session helpers (loadSession, validateSession, requireSession)
|
|
||||||
* and gateway URL resolution (getGatewayUrl) that live in packages/mosaic.
|
|
||||||
* Keeping packages/auth as a pure server-side library avoids adding commander
|
|
||||||
* and CLI tooling as dependencies there.
|
|
||||||
*/
|
|
||||||
export function registerAuthCommand(parent: Command): void {
|
|
||||||
const auth = parent
|
|
||||||
.command('auth')
|
|
||||||
.description('Manage gateway authentication, users, SSO providers, and sessions')
|
|
||||||
.configureHelp({ sortSubcommands: true })
|
|
||||||
.action(() => {
|
|
||||||
auth.outputHelp();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── users ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const users = auth
|
|
||||||
.command('users')
|
|
||||||
.description('Manage gateway users')
|
|
||||||
.configureHelp({ sortSubcommands: true })
|
|
||||||
.action(() => {
|
|
||||||
users.outputHelp();
|
|
||||||
});
|
|
||||||
|
|
||||||
users
|
|
||||||
.command('list')
|
|
||||||
.description('List all users on the gateway')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL')
|
|
||||||
.option('-l, --limit <n>', 'Maximum number of users to display', '100')
|
|
||||||
.action(async (opts: { gateway?: string; limit: string }) => {
|
|
||||||
const url = getGatewayUrl(opts.gateway);
|
|
||||||
const cookie = await requireSession(url);
|
|
||||||
const limit = parseInt(opts.limit, 10);
|
|
||||||
|
|
||||||
const result = await adminGet<UserListDto>(url, cookie, '/api/admin/users');
|
|
||||||
|
|
||||||
const subset = result.users.slice(0, limit);
|
|
||||||
if (subset.length === 0) {
|
|
||||||
console.log('No users found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Users (${subset.length.toString()} of ${result.total.toString()}):\n`);
|
|
||||||
for (const u of subset) {
|
|
||||||
printUser(u);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
users
|
|
||||||
.command('create')
|
|
||||||
.description('Create a new gateway user (interactive prompts)')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL')
|
|
||||||
.action(async (opts: { gateway?: string }) => {
|
|
||||||
const url = getGatewayUrl(opts.gateway);
|
|
||||||
const cookie = await requireSession(url);
|
|
||||||
|
|
||||||
const {
|
|
||||||
text,
|
|
||||||
password: clackPassword,
|
|
||||||
select,
|
|
||||||
intro,
|
|
||||||
outro,
|
|
||||||
isCancel,
|
|
||||||
} = await import('@clack/prompts');
|
|
||||||
|
|
||||||
intro('Create a new Mosaic gateway user');
|
|
||||||
|
|
||||||
const name = await text({ message: 'Full name:', placeholder: 'Jane Doe' });
|
|
||||||
if (isCancel(name)) {
|
|
||||||
outro('Cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = await text({ message: 'Email:', placeholder: 'jane@example.com' });
|
|
||||||
if (isCancel(email)) {
|
|
||||||
outro('Cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pw = await clackPassword({ message: 'Password:' });
|
|
||||||
if (isCancel(pw)) {
|
|
||||||
outro('Cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = await select({
|
|
||||||
message: 'Role:',
|
|
||||||
options: [
|
|
||||||
{ value: 'member', label: 'member' },
|
|
||||||
{ value: 'admin', label: 'admin' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (isCancel(role)) {
|
|
||||||
outro('Cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await adminPost<UserDto>(url, cookie, '/api/admin/users', {
|
|
||||||
name: name as string,
|
|
||||||
email: email as string,
|
|
||||||
password: pw as string,
|
|
||||||
role: role as string,
|
|
||||||
});
|
|
||||||
|
|
||||||
outro(`User created: ${created.email} (${created.id})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
users
|
|
||||||
.command('delete <id>')
|
|
||||||
.description('Delete a gateway user by ID')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL')
|
|
||||||
.option('-y, --yes', 'Skip confirmation prompt')
|
|
||||||
.action(async (id: string, opts: { gateway?: string; yes?: boolean }) => {
|
|
||||||
const url = getGatewayUrl(opts.gateway);
|
|
||||||
const cookie = await requireSession(url);
|
|
||||||
|
|
||||||
if (!opts.yes) {
|
|
||||||
const { confirm, isCancel } = await import('@clack/prompts');
|
|
||||||
const confirmed = await confirm({
|
|
||||||
message: `Delete user ${id}? This cannot be undone.`,
|
|
||||||
});
|
|
||||||
if (isCancel(confirmed) || !confirmed) {
|
|
||||||
console.log('Aborted.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await adminDelete(url, cookie, `/api/admin/users/${id}`);
|
|
||||||
console.log(`User ${id} deleted.`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── sso ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const sso = auth
|
|
||||||
.command('sso')
|
|
||||||
.description('Manage SSO provider configuration')
|
|
||||||
.configureHelp({ sortSubcommands: true })
|
|
||||||
.action(() => {
|
|
||||||
sso.outputHelp();
|
|
||||||
});
|
|
||||||
|
|
||||||
sso
|
|
||||||
.command('list')
|
|
||||||
.description('List configured SSO providers (reads gateway discovery endpoint if available)')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL')
|
|
||||||
.action(async (opts: { gateway?: string }) => {
|
|
||||||
// The admin SSO discovery endpoint is not yet wired server-side.
|
|
||||||
// The buildSsoDiscovery helper in @mosaicstack/auth reads env-vars on the
|
|
||||||
// server; there is no GET /api/admin/sso endpoint in apps/gateway/src/admin/.
|
|
||||||
// Stub until a gateway admin route is wired.
|
|
||||||
console.log(
|
|
||||||
'not yet wired — admin endpoint missing (GET /api/admin/sso not implemented server-side)',
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
'Hint: SSO providers are configured via environment variables (AUTHENTIK_*, WORKOS_*, KEYCLOAK_*).',
|
|
||||||
);
|
|
||||||
// Suppress unused variable warning
|
|
||||||
void opts;
|
|
||||||
});
|
|
||||||
|
|
||||||
sso
|
|
||||||
.command('test <provider>')
|
|
||||||
.description('Smoke-test a configured SSO provider')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL')
|
|
||||||
.action(async (provider: string, opts: { gateway?: string }) => {
|
|
||||||
// No server-side SSO smoke-test endpoint exists yet.
|
|
||||||
console.log(
|
|
||||||
`not yet wired — admin endpoint missing (POST /api/admin/sso/${provider}/test not implemented server-side)`,
|
|
||||||
);
|
|
||||||
void opts;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── sessions ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const authSessions = auth
|
|
||||||
.command('sessions')
|
|
||||||
.description('Manage BetterAuth user sessions stored on the gateway')
|
|
||||||
.configureHelp({ sortSubcommands: true })
|
|
||||||
.action(() => {
|
|
||||||
authSessions.outputHelp();
|
|
||||||
});
|
|
||||||
|
|
||||||
authSessions
|
|
||||||
.command('list')
|
|
||||||
.description('List active user sessions')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL')
|
|
||||||
.action(async (opts: { gateway?: string }) => {
|
|
||||||
// No GET /api/admin/auth-sessions endpoint exists in apps/gateway/src/admin/.
|
|
||||||
// Stub until a gateway admin route is wired.
|
|
||||||
console.log(
|
|
||||||
'not yet wired — admin endpoint missing (GET /api/admin/auth-sessions not implemented server-side)',
|
|
||||||
);
|
|
||||||
void opts;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { registerConfigCommand } from './config.js';
|
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Build a fresh Command tree with the config command registered. */
|
|
||||||
function buildProgram(): Command {
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride(); // prevent process.exit during tests
|
|
||||||
registerConfigCommand(program);
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Locate the 'config' command registered on the root program. */
|
|
||||||
function getConfigCmd(program: Command): Command {
|
|
||||||
const found = program.commands.find((c) => c.name() === 'config');
|
|
||||||
if (!found) throw new Error('config command not found');
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── subcommand registration ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('registerConfigCommand', () => {
|
|
||||||
it('registers a "config" command on the program', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const names = program.commands.map((c) => c.name());
|
|
||||||
expect(names).toContain('config');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers exactly the five required subcommands', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const config = getConfigCmd(program);
|
|
||||||
const subs = config.commands.map((c) => c.name()).sort();
|
|
||||||
expect(subs).toEqual(['edit', 'get', 'path', 'set', 'show']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── mock config service ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const mockSoul = {
|
|
||||||
agentName: 'TestBot',
|
|
||||||
roleDescription: 'test role',
|
|
||||||
communicationStyle: 'direct' as const,
|
|
||||||
};
|
|
||||||
const mockUser = { userName: 'Tester', pronouns: 'they/them', timezone: 'UTC' };
|
|
||||||
const mockTools = { credentialsLocation: '/dev/null' };
|
|
||||||
|
|
||||||
const mockSvc = {
|
|
||||||
readSoul: vi.fn().mockResolvedValue(mockSoul),
|
|
||||||
readUser: vi.fn().mockResolvedValue(mockUser),
|
|
||||||
readTools: vi.fn().mockResolvedValue(mockTools),
|
|
||||||
writeSoul: vi.fn().mockResolvedValue(undefined),
|
|
||||||
writeUser: vi.fn().mockResolvedValue(undefined),
|
|
||||||
writeTools: vi.fn().mockResolvedValue(undefined),
|
|
||||||
syncFramework: vi.fn().mockResolvedValue(undefined),
|
|
||||||
readAll: vi.fn().mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools }),
|
|
||||||
getValue: vi.fn().mockResolvedValue('TestBot'),
|
|
||||||
setValue: vi.fn().mockResolvedValue('OldBot'),
|
|
||||||
getConfigPath: vi
|
|
||||||
.fn()
|
|
||||||
.mockImplementation((section?: string) =>
|
|
||||||
section
|
|
||||||
? `/home/user/.config/mosaic/${section.toUpperCase()}.md`
|
|
||||||
: '/home/user/.config/mosaic',
|
|
||||||
),
|
|
||||||
isInitialized: vi.fn().mockReturnValue(true),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock the config-service module so commands use our mock.
|
|
||||||
vi.mock('../config/config-service.js', () => ({
|
|
||||||
createConfigService: vi.fn(() => mockSvc),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Also mock child_process for the edit command.
|
|
||||||
vi.mock('node:child_process', () => ({
|
|
||||||
spawnSync: vi.fn().mockReturnValue({ status: 0, error: undefined }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ── config show ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('config show', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockSvc.isInitialized.mockReturnValue(true);
|
|
||||||
mockSvc.readAll.mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls readAll() and prints a table by default', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'show']);
|
|
||||||
expect(mockSvc.readAll).toHaveBeenCalledOnce();
|
|
||||||
// Should have printed something
|
|
||||||
expect(consoleSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints JSON when --format json is passed', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'show', '--format', 'json']);
|
|
||||||
expect(mockSvc.readAll).toHaveBeenCalledOnce();
|
|
||||||
// Verify JSON was logged
|
|
||||||
const allOutput = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
||||||
expect(allOutput).toContain('"agentName"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── config get ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('config get', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockSvc.isInitialized.mockReturnValue(true);
|
|
||||||
mockSvc.getValue.mockResolvedValue('TestBot');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delegates to getValue() with the provided key', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'get', 'soul.agentName']);
|
|
||||||
expect(mockSvc.getValue).toHaveBeenCalledWith('soul.agentName');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints the returned value', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'get', 'soul.agentName']);
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('TestBot');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── config set ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('config set', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockSvc.isInitialized.mockReturnValue(true);
|
|
||||||
mockSvc.setValue.mockResolvedValue('OldBot');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delegates to setValue() with key and value', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'set', 'soul.agentName', 'NewBot']);
|
|
||||||
expect(mockSvc.setValue).toHaveBeenCalledWith('soul.agentName', 'NewBot');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints old and new values', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'set', 'soul.agentName', 'NewBot']);
|
|
||||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
||||||
expect(output).toContain('OldBot');
|
|
||||||
expect(output).toContain('NewBot');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── config path ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('config path', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockSvc.getConfigPath.mockImplementation((section?: string) =>
|
|
||||||
section
|
|
||||||
? `/home/user/.config/mosaic/${section.toUpperCase()}.md`
|
|
||||||
: '/home/user/.config/mosaic',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints the mosaicHome directory when no section is specified', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'path']);
|
|
||||||
expect(mockSvc.getConfigPath).toHaveBeenCalledWith();
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('/home/user/.config/mosaic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints the section file path when --section is given', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'path', '--section', 'soul']);
|
|
||||||
expect(mockSvc.getConfigPath).toHaveBeenCalledWith('soul');
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('/home/user/.config/mosaic/SOUL.md');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── config edit ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('config edit', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let spawnSyncMock: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockSvc.isInitialized.mockReturnValue(true);
|
|
||||||
mockSvc.readAll.mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools });
|
|
||||||
mockSvc.getConfigPath.mockImplementation((section?: string) =>
|
|
||||||
section
|
|
||||||
? `/home/user/.config/mosaic/${section.toUpperCase()}.md`
|
|
||||||
: '/home/user/.config/mosaic',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-import to get the mock reference
|
|
||||||
const cp = await import('node:child_process');
|
|
||||||
spawnSyncMock = cp.spawnSync as ReturnType<typeof vi.fn>;
|
|
||||||
spawnSyncMock.mockReturnValue({ status: 0, error: undefined });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls spawnSync with the editor binary and config path', async () => {
|
|
||||||
process.env['EDITOR'] = 'nano';
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'edit']);
|
|
||||||
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
||||||
'nano',
|
|
||||||
['/home/user/.config/mosaic'],
|
|
||||||
expect.objectContaining({ stdio: 'inherit' }),
|
|
||||||
);
|
|
||||||
delete process.env['EDITOR'];
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to "vi" when EDITOR is not set', async () => {
|
|
||||||
delete process.env['EDITOR'];
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'edit']);
|
|
||||||
expect(spawnSyncMock).toHaveBeenCalledWith('vi', expect.any(Array), expect.any(Object));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens the section-specific file when --section is provided', async () => {
|
|
||||||
process.env['EDITOR'] = 'code';
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'config', 'edit', '--section', 'soul']);
|
|
||||||
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
||||||
'code',
|
|
||||||
['/home/user/.config/mosaic/SOUL.md'],
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
delete process.env['EDITOR'];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── not-initialized guard ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('not-initialized guard', () => {
|
|
||||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockSvc.isInitialized.mockReturnValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
mockSvc.isInitialized.mockReturnValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints a helpful message when config is missing (show)', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
// process.exit is intercepted; catch the resulting error from exitOverride
|
|
||||||
await expect(program.parseAsync(['node', 'mosaic', 'config', 'show'])).rejects.toThrow();
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('mosaic wizard'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import type { Command } from 'commander';
|
|
||||||
import { createConfigService } from '../config/config-service.js';
|
|
||||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve mosaicHome from the MOSAIC_HOME env var or the default constant.
|
|
||||||
*/
|
|
||||||
function getMosaicHome(): string {
|
|
||||||
return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guard: print an error and exit(1) if config has not been initialised.
|
|
||||||
*/
|
|
||||||
function assertInitialized(svc: ReturnType<typeof createConfigService>): void {
|
|
||||||
if (!svc.isInitialized()) {
|
|
||||||
console.error('No config found — run `mosaic wizard` first.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flatten a nested object into dotted-key rows for table display.
|
|
||||||
*/
|
|
||||||
function flattenConfig(obj: Record<string, unknown>, prefix = ''): Array<[string, string]> {
|
|
||||||
const rows: Array<[string, string]> = [];
|
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
|
||||||
const key = prefix ? `${prefix}.${k}` : k;
|
|
||||||
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
||||||
rows.push(...flattenConfig(v as Record<string, unknown>, key));
|
|
||||||
} else {
|
|
||||||
rows.push([key, v === undefined || v === null ? '' : String(v)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print rows as a padded ASCII table.
|
|
||||||
*/
|
|
||||||
function printTable(rows: Array<[string, string]>): void {
|
|
||||||
if (rows.length === 0) {
|
|
||||||
console.log('(no config values)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const maxKey = Math.max(...rows.map(([k]) => k.length));
|
|
||||||
const header = `${'Key'.padEnd(maxKey)} Value`;
|
|
||||||
const divider = '-'.repeat(header.length);
|
|
||||||
console.log(header);
|
|
||||||
console.log(divider);
|
|
||||||
for (const [k, v] of rows) {
|
|
||||||
console.log(`${k.padEnd(maxKey)} ${v}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerConfigCommand(program: Command): void {
|
|
||||||
const cmd = program
|
|
||||||
.command('config')
|
|
||||||
.description('Manage Mosaic framework configuration')
|
|
||||||
.configureHelp({ sortSubcommands: true });
|
|
||||||
|
|
||||||
// ── config show ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command('show')
|
|
||||||
.description('Print the current resolved config')
|
|
||||||
.option('-f, --format <format>', 'Output format: table or json', 'table')
|
|
||||||
.action(async (opts: { format: string }) => {
|
|
||||||
const mosaicHome = getMosaicHome();
|
|
||||||
const svc = createConfigService(mosaicHome, mosaicHome);
|
|
||||||
assertInitialized(svc);
|
|
||||||
|
|
||||||
const config = await svc.readAll();
|
|
||||||
|
|
||||||
if (opts.format === 'json') {
|
|
||||||
console.log(JSON.stringify(config, null, 2));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: table
|
|
||||||
const rows = flattenConfig(config as unknown as Record<string, unknown>);
|
|
||||||
printTable(rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── config get <key> ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command('get <key>')
|
|
||||||
.description('Print a single config value (supports dotted keys, e.g. soul.agentName)')
|
|
||||||
.action(async (key: string) => {
|
|
||||||
const mosaicHome = getMosaicHome();
|
|
||||||
const svc = createConfigService(mosaicHome, mosaicHome);
|
|
||||||
assertInitialized(svc);
|
|
||||||
|
|
||||||
const value = await svc.getValue(key);
|
|
||||||
if (value === undefined) {
|
|
||||||
console.error(`Key "${key}" not found.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
console.log(JSON.stringify(value, null, 2));
|
|
||||||
} else {
|
|
||||||
console.log(String(value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── config set <key> <value> ────────────────────────────────────────────
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command('set <key> <value>')
|
|
||||||
.description(
|
|
||||||
'Set a config value and persist (supports dotted keys, e.g. soul.agentName "Jarvis")',
|
|
||||||
)
|
|
||||||
.action(async (key: string, value: string) => {
|
|
||||||
const mosaicHome = getMosaicHome();
|
|
||||||
const svc = createConfigService(mosaicHome, mosaicHome);
|
|
||||||
assertInitialized(svc);
|
|
||||||
|
|
||||||
let previous: unknown;
|
|
||||||
try {
|
|
||||||
previous = await svc.setValue(key, value);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevStr = previous === undefined ? '(unset)' : String(previous);
|
|
||||||
console.log(`${key}`);
|
|
||||||
console.log(` old: ${prevStr}`);
|
|
||||||
console.log(` new: ${value}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── config edit ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command('edit')
|
|
||||||
.description('Open the config directory in $EDITOR (or vi)')
|
|
||||||
.option('-s, --section <section>', 'Open a specific section file: soul | user | tools')
|
|
||||||
.action(async (opts: { section?: string }) => {
|
|
||||||
const mosaicHome = getMosaicHome();
|
|
||||||
const svc = createConfigService(mosaicHome, mosaicHome);
|
|
||||||
assertInitialized(svc);
|
|
||||||
|
|
||||||
const editor = process.env['EDITOR'] ?? 'vi';
|
|
||||||
|
|
||||||
let targetPath: string;
|
|
||||||
if (opts.section) {
|
|
||||||
const validSections = ['soul', 'user', 'tools'] as const;
|
|
||||||
if (!validSections.includes(opts.section as (typeof validSections)[number])) {
|
|
||||||
console.error(`Invalid section "${opts.section}". Choose: soul, user, tools`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
targetPath = svc.getConfigPath(opts.section as 'soul' | 'user' | 'tools');
|
|
||||||
} else {
|
|
||||||
targetPath = svc.getConfigPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = spawnSync(editor, [targetPath], { stdio: 'inherit' });
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
console.error(`Failed to open editor: ${result.error.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
console.error(`Editor exited with code ${String(result.status ?? 1)}`);
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-read after edit and report any issues
|
|
||||||
try {
|
|
||||||
await svc.readAll();
|
|
||||||
console.log('Config looks valid.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Warning: config may have validation issues:');
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── config path ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.command('path')
|
|
||||||
.description('Print the active config directory path (for scripting)')
|
|
||||||
.option(
|
|
||||||
'-s, --section <section>',
|
|
||||||
'Print path for a specific section file: soul | user | tools',
|
|
||||||
)
|
|
||||||
.action(async (opts: { section?: string }) => {
|
|
||||||
const mosaicHome = getMosaicHome();
|
|
||||||
const svc = createConfigService(mosaicHome, mosaicHome);
|
|
||||||
|
|
||||||
if (opts.section) {
|
|
||||||
const validSections = ['soul', 'user', 'tools'] as const;
|
|
||||||
if (!validSections.includes(opts.section as (typeof validSections)[number])) {
|
|
||||||
console.error(`Invalid section "${opts.section}". Choose: soul, user, tools`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(svc.getConfigPath(opts.section as 'soul' | 'user' | 'tools'));
|
|
||||||
} else {
|
|
||||||
console.log(svc.getConfigPath());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
stopDaemon,
|
stopDaemon,
|
||||||
waitForHealth,
|
waitForHealth,
|
||||||
} from './gateway/daemon.js';
|
} from './gateway/daemon.js';
|
||||||
import { getGatewayUrl } from './gateway/login.js';
|
|
||||||
|
|
||||||
interface GatewayParentOpts {
|
interface GatewayParentOpts {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -120,36 +119,9 @@ export function registerGatewayCommand(program: Command): void {
|
|||||||
await runStatus(opts);
|
await runStatus(opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── login ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
gw.command('login')
|
|
||||||
.description('Sign in to the gateway (defaults to URL from meta.json)')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
|
|
||||||
.option('-e, --email <email>', 'Email address')
|
|
||||||
.option(
|
|
||||||
'-p, --password <password>',
|
|
||||||
'[UNSAFE] Avoid — exposes credentials in shell history and process listings',
|
|
||||||
)
|
|
||||||
.action(async (cmdOpts: { gateway?: string; email?: string; password?: string }) => {
|
|
||||||
const { runLogin } = await import('./gateway/login.js');
|
|
||||||
const url = getGatewayUrl(cmdOpts.gateway);
|
|
||||||
if (cmdOpts.password) {
|
|
||||||
console.warn(
|
|
||||||
'Warning: --password flag exposes credentials in shell history and process listings.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await runLogin({ gatewayUrl: url, email: cmdOpts.email, password: cmdOpts.password });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── config ─────────────────────────────────────────────────────────────
|
// ─── config ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const configCmd = gw
|
gw.command('config')
|
||||||
.command('config')
|
|
||||||
.description('View or modify gateway configuration')
|
.description('View or modify gateway configuration')
|
||||||
.option('--set <KEY=VALUE>', 'Set a configuration value')
|
.option('--set <KEY=VALUE>', 'Set a configuration value')
|
||||||
.option('--unset <KEY>', 'Remove a configuration key')
|
.option('--unset <KEY>', 'Remove a configuration key')
|
||||||
@@ -159,24 +131,6 @@ export function registerGatewayCommand(program: Command): void {
|
|||||||
await runConfig(cmdOpts);
|
await runConfig(cmdOpts);
|
||||||
});
|
});
|
||||||
|
|
||||||
configCmd
|
|
||||||
.command('rotate-token')
|
|
||||||
.description('Mint a new admin token using the stored BetterAuth session')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
|
|
||||||
.action(async (cmdOpts: { gateway?: string }) => {
|
|
||||||
const { runRotateToken } = await import('./gateway/token-ops.js');
|
|
||||||
await runRotateToken(cmdOpts.gateway);
|
|
||||||
});
|
|
||||||
|
|
||||||
configCmd
|
|
||||||
.command('recover-token')
|
|
||||||
.description('Recover an admin token — prompts for login if no valid session exists')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
|
|
||||||
.action(async (cmdOpts: { gateway?: string }) => {
|
|
||||||
const { runRecoverToken } = await import('./gateway/token-ops.js');
|
|
||||||
await runRecoverToken(cmdOpts.gateway);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── logs ───────────────────────────────────────────────────────────────
|
// ─── logs ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
gw.command('logs')
|
gw.command('logs')
|
||||||
@@ -188,16 +142,6 @@ export function registerGatewayCommand(program: Command): void {
|
|||||||
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
|
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── verify ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
gw.command('verify')
|
|
||||||
.description('Verify the gateway installation (health, token, bootstrap endpoint)')
|
|
||||||
.action(async () => {
|
|
||||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
|
||||||
const { runVerify } = await import('./gateway/verify.js');
|
|
||||||
await runVerify(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── uninstall ──────────────────────────────────────────────────────────
|
// ─── uninstall ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
gw.command('uninstall')
|
gw.command('uninstall')
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir, tmpdir } from 'node:os';
|
|
||||||
import { createInterface } from 'node:readline';
|
import { createInterface } from 'node:readline';
|
||||||
import type { GatewayMeta } from './daemon.js';
|
import type { GatewayMeta } from './daemon.js';
|
||||||
import {
|
import {
|
||||||
@@ -22,39 +21,6 @@ import {
|
|||||||
|
|
||||||
const MOSAIC_CONFIG_FILE = join(GATEWAY_HOME, 'mosaic.config.json');
|
const MOSAIC_CONFIG_FILE = join(GATEWAY_HOME, 'mosaic.config.json');
|
||||||
|
|
||||||
// ─── Wizard session state (transient, CU-07-02) ──────────────────────────────
|
|
||||||
|
|
||||||
const INSTALL_STATE_FILE = join(
|
|
||||||
process.env['XDG_RUNTIME_DIR'] ?? process.env['TMPDIR'] ?? tmpdir(),
|
|
||||||
'mosaic-install-state.json',
|
|
||||||
);
|
|
||||||
|
|
||||||
interface InstallSessionState {
|
|
||||||
wizardCompletedAt: string;
|
|
||||||
mosaicHome: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readInstallState(): InstallSessionState | null {
|
|
||||||
if (!existsSync(INSTALL_STATE_FILE)) return null;
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(readFileSync(INSTALL_STATE_FILE, 'utf-8')) as InstallSessionState;
|
|
||||||
// Only trust state that is < 10 minutes old
|
|
||||||
const age = Date.now() - new Date(raw.wizardCompletedAt).getTime();
|
|
||||||
if (age > 10 * 60 * 1000) return null;
|
|
||||||
return raw;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearInstallState(): void {
|
|
||||||
try {
|
|
||||||
unlinkSync(INSTALL_STATE_FILE);
|
|
||||||
} catch {
|
|
||||||
// Ignore — file may already be gone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InstallOpts {
|
interface InstallOpts {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -75,30 +41,6 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
|
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
|
||||||
// CU-07-02: Check for a fresh wizard session state and apply it.
|
|
||||||
const sessionState = readInstallState();
|
|
||||||
if (sessionState) {
|
|
||||||
const defaultHome = join(homedir(), '.config', 'mosaic');
|
|
||||||
const customHome = sessionState.mosaicHome !== defaultHome ? sessionState.mosaicHome : null;
|
|
||||||
|
|
||||||
if (customHome && !process.env['MOSAIC_GATEWAY_HOME']) {
|
|
||||||
// The wizard ran with a custom MOSAIC_HOME that differs from the default.
|
|
||||||
// GATEWAY_HOME is derived from MOSAIC_GATEWAY_HOME (or defaults to
|
|
||||||
// ~/.config/mosaic/gateway). Set the env var so the rest of this install
|
|
||||||
// inherits the correct location. This must be set before GATEWAY_HOME is
|
|
||||||
// evaluated by any imported helper — helpers that re-evaluate the path at
|
|
||||||
// call time will pick it up automatically.
|
|
||||||
process.env['MOSAIC_GATEWAY_HOME'] = join(customHome, 'gateway');
|
|
||||||
console.log(
|
|
||||||
`Resuming from wizard session — gateway home set to ${process.env['MOSAIC_GATEWAY_HOME']}\n`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`Resuming from wizard session — using ${sessionState.mosaicHome} from earlier.\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = readMeta();
|
const existing = readMeta();
|
||||||
const envExists = existsSync(ENV_FILE);
|
const envExists = existsSync(ENV_FILE);
|
||||||
const mosaicConfigExists = existsSync(MOSAIC_CONFIG_FILE);
|
const mosaicConfigExists = existsSync(MOSAIC_CONFIG_FILE);
|
||||||
@@ -276,13 +218,6 @@ async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOp
|
|||||||
console.log(` Config: ${GATEWAY_HOME}`);
|
console.log(` Config: ${GATEWAY_HOME}`);
|
||||||
console.log(` Logs: mosaic gateway logs`);
|
console.log(` Logs: mosaic gateway logs`);
|
||||||
console.log(` Status: mosaic gateway status`);
|
console.log(` Status: mosaic gateway status`);
|
||||||
|
|
||||||
// Step 7: Post-install verification (CU-07-03)
|
|
||||||
const { runPostInstallVerification } = await import('./verify.js');
|
|
||||||
await runPostInstallVerification(host, port);
|
|
||||||
|
|
||||||
// CU-07-02: Clear transient wizard session state on successful install.
|
|
||||||
clearInstallState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runConfigWizard(
|
async function runConfigWizard(
|
||||||
@@ -453,32 +388,10 @@ async function bootstrapFirstUser(
|
|||||||
if (!status.needsSetup) {
|
if (!status.needsSetup) {
|
||||||
if (meta.adminToken) {
|
if (meta.adminToken) {
|
||||||
console.log('Admin user already exists (token on file).');
|
console.log('Admin user already exists (token on file).');
|
||||||
return;
|
} else {
|
||||||
|
console.log('Admin user already exists — skipping setup.');
|
||||||
|
console.log('(No admin token on file — sign in via the web UI to manage tokens.)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin user exists but no token — offer inline recovery when interactive.
|
|
||||||
console.log('Admin user already exists but no admin token is on file.');
|
|
||||||
|
|
||||||
if (process.stdin.isTTY) {
|
|
||||||
const answer = (await prompt(rl, 'Run token recovery now? [Y/n] ')).trim().toLowerCase();
|
|
||||||
if (answer === '' || answer === 'y' || answer === 'yes') {
|
|
||||||
console.log();
|
|
||||||
try {
|
|
||||||
const { ensureSession, mintAdminToken, persistToken } = await import('./token-ops.js');
|
|
||||||
const cookie = await ensureSession(baseUrl);
|
|
||||||
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
|
|
||||||
const minted = await mintAdminToken(baseUrl, cookie, label);
|
|
||||||
persistToken(baseUrl, minted);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`Token recovery failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('No admin token on file. Run: mosaic gateway config recover-token');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
// Mock auth module
|
|
||||||
vi.mock('../../auth.js', () => ({
|
|
||||||
signIn: vi.fn(),
|
|
||||||
saveSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock daemon to avoid file-system reads
|
|
||||||
vi.mock('./daemon.js', () => ({
|
|
||||||
readMeta: vi.fn().mockReturnValue({
|
|
||||||
host: 'localhost',
|
|
||||||
port: 14242,
|
|
||||||
version: '1.0.0',
|
|
||||||
installedAt: '',
|
|
||||||
entryPoint: '',
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { runLogin, getGatewayUrl } from './login.js';
|
|
||||||
import { signIn, saveSession } from '../../auth.js';
|
|
||||||
import { readMeta } from './daemon.js';
|
|
||||||
|
|
||||||
const mockSignIn = vi.mocked(signIn);
|
|
||||||
const mockSaveSession = vi.mocked(saveSession);
|
|
||||||
const mockReadMeta = vi.mocked(readMeta);
|
|
||||||
|
|
||||||
describe('getGatewayUrl', () => {
|
|
||||||
it('returns override URL when provided', () => {
|
|
||||||
expect(getGatewayUrl('http://my-gateway:9999')).toBe('http://my-gateway:9999');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds URL from meta.json when no override given', () => {
|
|
||||||
mockReadMeta.mockReturnValueOnce({
|
|
||||||
host: 'myhost',
|
|
||||||
port: 8080,
|
|
||||||
version: '1.0.0',
|
|
||||||
installedAt: '',
|
|
||||||
entryPoint: '',
|
|
||||||
});
|
|
||||||
expect(getGatewayUrl()).toBe('http://myhost:8080');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to default when meta is null', () => {
|
|
||||||
mockReadMeta.mockReturnValueOnce(null);
|
|
||||||
expect(getGatewayUrl()).toBe('http://localhost:14242');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('runLogin', () => {
|
|
||||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls signIn and saveSession on success', async () => {
|
|
||||||
const fakeAuth = {
|
|
||||||
cookie: 'better-auth.session_token=abc',
|
|
||||||
userId: 'u1',
|
|
||||||
email: 'admin@test.com',
|
|
||||||
};
|
|
||||||
mockSignIn.mockResolvedValueOnce(fakeAuth);
|
|
||||||
|
|
||||||
await runLogin({
|
|
||||||
gatewayUrl: 'http://localhost:14242',
|
|
||||||
email: 'admin@test.com',
|
|
||||||
password: 'password123',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSignIn).toHaveBeenCalledWith(
|
|
||||||
'http://localhost:14242',
|
|
||||||
'admin@test.com',
|
|
||||||
'password123',
|
|
||||||
);
|
|
||||||
expect(mockSaveSession).toHaveBeenCalledWith('http://localhost:14242', fakeAuth);
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('admin@test.com'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('propagates signIn errors', async () => {
|
|
||||||
mockSignIn.mockRejectedValueOnce(new Error('Sign-in failed (401): invalid credentials'));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runLogin({ gatewayUrl: 'http://localhost:14242', email: 'bad@test.com', password: 'wrong' }),
|
|
||||||
).rejects.toThrow('Sign-in failed (401)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { createInterface } from 'node:readline';
|
|
||||||
import { signIn, saveSession } from '../../auth.js';
|
|
||||||
import { readMeta } from './daemon.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompt for a single line of input (with echo).
|
|
||||||
*/
|
|
||||||
export function promptLine(question: string): Promise<string> {
|
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
rl.question(question, (answer) => {
|
|
||||||
rl.close();
|
|
||||||
resolve(answer.trim());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompt for a secret value without echoing the typed characters to the terminal.
|
|
||||||
* Uses TTY raw mode when available so that passwords do not appear in terminal
|
|
||||||
* recordings, scrollback, or shared screen sessions.
|
|
||||||
*/
|
|
||||||
export function promptSecret(question: string): Promise<string> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
process.stdout.write(question);
|
|
||||||
if (process.stdin.isTTY) {
|
|
||||||
process.stdin.setRawMode(true);
|
|
||||||
}
|
|
||||||
process.stdin.resume();
|
|
||||||
process.stdin.setEncoding('utf-8');
|
|
||||||
|
|
||||||
let secret = '';
|
|
||||||
const onData = (char: string): void => {
|
|
||||||
if (char === '\n' || char === '\r' || char === '\u0004') {
|
|
||||||
process.stdout.write('\n');
|
|
||||||
if (process.stdin.isTTY) {
|
|
||||||
process.stdin.setRawMode(false);
|
|
||||||
}
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeListener('data', onData);
|
|
||||||
resolve(secret);
|
|
||||||
} else if (char === '\u0003') {
|
|
||||||
// ^C
|
|
||||||
process.stdout.write('\n');
|
|
||||||
if (process.stdin.isTTY) {
|
|
||||||
process.stdin.setRawMode(false);
|
|
||||||
}
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeListener('data', onData);
|
|
||||||
process.exit(130);
|
|
||||||
} else if (char === '\u007f' || char === '\b') {
|
|
||||||
secret = secret.slice(0, -1);
|
|
||||||
} else {
|
|
||||||
secret += char;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
process.stdin.on('data', onData);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared login helper used by both `mosaic login` and `mosaic gateway login`.
|
|
||||||
* Prompts for email/password if not supplied, signs in, and persists the session.
|
|
||||||
*/
|
|
||||||
export async function runLogin(opts: {
|
|
||||||
gatewayUrl: string;
|
|
||||||
email?: string;
|
|
||||||
password?: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const email = opts.email ?? (await promptLine('Email: '));
|
|
||||||
// Do not trim password — it may intentionally contain leading/trailing whitespace
|
|
||||||
const password = opts.password ?? (await promptSecret('Password: '));
|
|
||||||
|
|
||||||
const auth = await signIn(opts.gatewayUrl, email, password);
|
|
||||||
saveSession(opts.gatewayUrl, auth);
|
|
||||||
console.log(`Signed in as ${auth.email} (${opts.gatewayUrl})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive the gateway base URL from meta.json with a fallback.
|
|
||||||
*/
|
|
||||||
export function getGatewayUrl(overrideUrl?: string): string {
|
|
||||||
if (overrideUrl) return overrideUrl;
|
|
||||||
const meta = readMeta();
|
|
||||||
if (meta) return `http://${meta.host}:${meta.port.toString()}`;
|
|
||||||
return 'http://localhost:14242';
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
// ─── Mocks ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
vi.mock('../../auth.js', () => ({
|
|
||||||
loadSession: vi.fn(),
|
|
||||||
validateSession: vi.fn(),
|
|
||||||
signIn: vi.fn(),
|
|
||||||
saveSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./daemon.js', () => ({
|
|
||||||
readMeta: vi.fn(),
|
|
||||||
writeMeta: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./login.js', () => ({
|
|
||||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
|
||||||
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin
|
|
||||||
promptLine: vi.fn().mockResolvedValue('test@example.com'),
|
|
||||||
promptSecret: vi.fn().mockResolvedValue('test-password'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
|
||||||
|
|
||||||
import { runRecoverToken, ensureSession } from './token-ops.js';
|
|
||||||
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
|
||||||
import { readMeta, writeMeta } from './daemon.js';
|
|
||||||
|
|
||||||
const mockLoadSession = vi.mocked(loadSession);
|
|
||||||
const mockValidateSession = vi.mocked(validateSession);
|
|
||||||
const mockSignIn = vi.mocked(signIn);
|
|
||||||
const mockSaveSession = vi.mocked(saveSession);
|
|
||||||
const mockReadMeta = vi.mocked(readMeta);
|
|
||||||
const mockWriteMeta = vi.mocked(writeMeta);
|
|
||||||
|
|
||||||
const baseUrl = 'http://localhost:14242';
|
|
||||||
const fakeCookie = 'better-auth.session_token=sess123';
|
|
||||||
const fakeToken = {
|
|
||||||
id: 'tok-1',
|
|
||||||
label: 'CLI recovery token (2026-04-04 12:00)',
|
|
||||||
plaintext: 'abcdef1234567890',
|
|
||||||
};
|
|
||||||
const fakeMeta = {
|
|
||||||
version: '1.0.0',
|
|
||||||
installedAt: '',
|
|
||||||
entryPoint: '',
|
|
||||||
host: 'localhost',
|
|
||||||
port: 14242,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('ensureSession', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns cookie from stored session when valid', async () => {
|
|
||||||
mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: 'a@b.com' });
|
|
||||||
mockValidateSession.mockResolvedValueOnce(true);
|
|
||||||
|
|
||||||
const cookie = await ensureSession(baseUrl);
|
|
||||||
expect(cookie).toBe(fakeCookie);
|
|
||||||
expect(mockSignIn).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prompts for credentials and signs in when stored session is invalid', async () => {
|
|
||||||
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: 'a@b.com' });
|
|
||||||
mockValidateSession.mockResolvedValueOnce(false);
|
|
||||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'a@b.com' };
|
|
||||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
|
||||||
|
|
||||||
const cookie = await ensureSession(baseUrl);
|
|
||||||
expect(cookie).toBe(fakeCookie);
|
|
||||||
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prompts for credentials when no session exists', async () => {
|
|
||||||
mockLoadSession.mockReturnValueOnce(null);
|
|
||||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'a@b.com' };
|
|
||||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
|
||||||
|
|
||||||
const cookie = await ensureSession(baseUrl);
|
|
||||||
expect(cookie).toBe(fakeCookie);
|
|
||||||
expect(mockSignIn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exits non-zero when signIn fails', async () => {
|
|
||||||
mockLoadSession.mockReturnValueOnce(null);
|
|
||||||
mockSignIn.mockRejectedValueOnce(new Error('Sign-in failed (401): bad creds'));
|
|
||||||
const processExitSpy = vi
|
|
||||||
.spyOn(process, 'exit')
|
|
||||||
.mockImplementation((_code?: number | string | null | undefined) => {
|
|
||||||
throw new Error(`process.exit(${String(_code)})`);
|
|
||||||
});
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await expect(ensureSession(baseUrl)).rejects.toThrow('process.exit(2)');
|
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(2);
|
|
||||||
|
|
||||||
processExitSpy.mockRestore();
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('runRecoverToken', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prompts for login, mints a token, and persists it when no session exists', async () => {
|
|
||||||
mockLoadSession.mockReturnValueOnce(null);
|
|
||||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'admin@test.com' };
|
|
||||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
|
||||||
mockReadMeta.mockReturnValue(fakeMeta);
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => fakeToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runRecoverToken();
|
|
||||||
|
|
||||||
expect(mockSignIn).toHaveBeenCalled();
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
|
||||||
`${baseUrl}/api/admin/tokens`,
|
|
||||||
expect.objectContaining({ method: 'POST' }),
|
|
||||||
);
|
|
||||||
expect(mockWriteMeta).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ adminToken: fakeToken.plaintext }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips login when a valid session exists and mints a recovery token', async () => {
|
|
||||||
mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: 'a@b.com' });
|
|
||||||
mockValidateSession.mockResolvedValueOnce(true);
|
|
||||||
mockReadMeta.mockReturnValue(fakeMeta);
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => fakeToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runRecoverToken();
|
|
||||||
|
|
||||||
expect(mockSignIn).not.toHaveBeenCalled();
|
|
||||||
expect(mockWriteMeta).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ adminToken: fakeToken.plaintext }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses label containing "recovery token"', async () => {
|
|
||||||
mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: 'a@b.com' });
|
|
||||||
mockValidateSession.mockResolvedValueOnce(true);
|
|
||||||
mockReadMeta.mockReturnValue(fakeMeta);
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => fakeToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runRecoverToken();
|
|
||||||
|
|
||||||
const call = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
||||||
const body = JSON.parse(call[1].body as string) as { label: string };
|
|
||||||
expect(body.label).toMatch(/CLI recovery token/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
// ─── Mocks ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
vi.mock('../../auth.js', () => ({
|
|
||||||
loadSession: vi.fn(),
|
|
||||||
validateSession: vi.fn(),
|
|
||||||
signIn: vi.fn(),
|
|
||||||
saveSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./daemon.js', () => ({
|
|
||||||
readMeta: vi.fn(),
|
|
||||||
writeMeta: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./login.js', () => ({
|
|
||||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock global fetch
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
|
||||||
|
|
||||||
import { runRotateToken, mintAdminToken, persistToken } from './token-ops.js';
|
|
||||||
import { loadSession, validateSession } from '../../auth.js';
|
|
||||||
import { readMeta, writeMeta } from './daemon.js';
|
|
||||||
|
|
||||||
const mockLoadSession = vi.mocked(loadSession);
|
|
||||||
const mockValidateSession = vi.mocked(validateSession);
|
|
||||||
const mockReadMeta = vi.mocked(readMeta);
|
|
||||||
const mockWriteMeta = vi.mocked(writeMeta);
|
|
||||||
|
|
||||||
const baseUrl = 'http://localhost:14242';
|
|
||||||
const fakeCookie = 'better-auth.session_token=sess123';
|
|
||||||
const fakeToken = {
|
|
||||||
id: 'tok-1',
|
|
||||||
label: 'CLI rotated token (2026-04-04)',
|
|
||||||
plaintext: 'abcdef1234567890',
|
|
||||||
};
|
|
||||||
const fakeMeta = {
|
|
||||||
version: '1.0.0',
|
|
||||||
installedAt: '',
|
|
||||||
entryPoint: '',
|
|
||||||
host: 'localhost',
|
|
||||||
port: 14242,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('mintAdminToken', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls the admin tokens endpoint with the session cookie and returns the token', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => fakeToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await mintAdminToken(baseUrl, fakeCookie, fakeToken.label);
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
|
||||||
`${baseUrl}/api/admin/tokens`,
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
headers: expect.objectContaining({ Cookie: fakeCookie }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toEqual(fakeToken);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exits 2 on 401 from the server', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 401, text: async () => 'Unauthorized' });
|
|
||||||
const processExitSpy = vi
|
|
||||||
.spyOn(process, 'exit')
|
|
||||||
.mockImplementation((_code?: number | string | null | undefined) => {
|
|
||||||
throw new Error(`process.exit(${String(_code)})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(2)');
|
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(2);
|
|
||||||
processExitSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exits 2 on 403 from the server', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 403, text: async () => 'Forbidden' });
|
|
||||||
const processExitSpy = vi
|
|
||||||
.spyOn(process, 'exit')
|
|
||||||
.mockImplementation((_code?: number | string | null | undefined) => {
|
|
||||||
throw new Error(`process.exit(${String(_code)})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(2)');
|
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(2);
|
|
||||||
processExitSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exits 3 on other non-ok status', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'Internal Error' });
|
|
||||||
const processExitSpy = vi
|
|
||||||
.spyOn(process, 'exit')
|
|
||||||
.mockImplementation((_code?: number | string | null | undefined) => {
|
|
||||||
throw new Error(`process.exit(${String(_code)})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(3)');
|
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(3);
|
|
||||||
processExitSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exits 1 on network error', async () => {
|
|
||||||
mockFetch.mockRejectedValueOnce(new Error('connection refused'));
|
|
||||||
const processExitSpy = vi
|
|
||||||
.spyOn(process, 'exit')
|
|
||||||
.mockImplementation((_code?: number | string | null | undefined) => {
|
|
||||||
throw new Error(`process.exit(${String(_code)})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(mintAdminToken(baseUrl, fakeCookie, 'label')).rejects.toThrow('process.exit(1)');
|
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
||||||
processExitSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('persistToken', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes the new token to meta.json', () => {
|
|
||||||
mockReadMeta.mockReturnValueOnce(fakeMeta);
|
|
||||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
persistToken(baseUrl, fakeToken);
|
|
||||||
|
|
||||||
expect(mockWriteMeta).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ adminToken: fakeToken.plaintext }),
|
|
||||||
);
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints a masked preview of the token', () => {
|
|
||||||
mockReadMeta.mockReturnValueOnce(fakeMeta);
|
|
||||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
persistToken(baseUrl, fakeToken);
|
|
||||||
|
|
||||||
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
||||||
expect(allOutput).toContain('abcdef12...');
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('runRotateToken', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exits 2 when there is no stored session', async () => {
|
|
||||||
mockLoadSession.mockReturnValueOnce(null);
|
|
||||||
const processExitSpy = vi
|
|
||||||
.spyOn(process, 'exit')
|
|
||||||
.mockImplementation((_code?: number | string | null | undefined) => {
|
|
||||||
throw new Error(`process.exit(${String(_code)})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(runRotateToken()).rejects.toThrow('process.exit(2)');
|
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(2);
|
|
||||||
processExitSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exits 2 when session is invalid', async () => {
|
|
||||||
mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: 'a@b.com' });
|
|
||||||
mockValidateSession.mockResolvedValueOnce(false);
|
|
||||||
const processExitSpy = vi
|
|
||||||
.spyOn(process, 'exit')
|
|
||||||
.mockImplementation((_code?: number | string | null | undefined) => {
|
|
||||||
throw new Error(`process.exit(${String(_code)})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(runRotateToken()).rejects.toThrow('process.exit(2)');
|
|
||||||
expect(processExitSpy).toHaveBeenCalledWith(2);
|
|
||||||
processExitSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('mints and persists a new token when session is valid', async () => {
|
|
||||||
mockLoadSession.mockReturnValueOnce({ cookie: fakeCookie, userId: 'u1', email: 'a@b.com' });
|
|
||||||
mockValidateSession.mockResolvedValueOnce(true);
|
|
||||||
mockReadMeta.mockReturnValue(fakeMeta);
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => fakeToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runRotateToken();
|
|
||||||
|
|
||||||
expect(mockWriteMeta).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ adminToken: fakeToken.plaintext }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
|
||||||
import { readMeta, writeMeta } from './daemon.js';
|
|
||||||
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
|
|
||||||
|
|
||||||
interface MintedToken {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
plaintext: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call POST /api/admin/tokens with the session cookie and return the minted token.
|
|
||||||
* Exits the process on network or auth errors.
|
|
||||||
*/
|
|
||||||
export async function mintAdminToken(
|
|
||||||
gatewayUrl: string,
|
|
||||||
cookie: string,
|
|
||||||
label: string,
|
|
||||||
): Promise<MintedToken> {
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(`${gatewayUrl}/api/admin/tokens`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Cookie: cookie,
|
|
||||||
Origin: gatewayUrl,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ label, scope: 'admin' }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
console.error(
|
|
||||||
`Session rejected by the gateway (${res.status.toString()}) — your session may be expired.`,
|
|
||||||
);
|
|
||||||
console.error('Run: mosaic gateway login');
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
console.error(
|
|
||||||
`Gateway rejected token creation (${res.status.toString()}): ${body.slice(0, 200)}`,
|
|
||||||
);
|
|
||||||
process.exit(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { id: string; label: string; plaintext: string };
|
|
||||||
return { id: data.id, label: data.label, plaintext: data.plaintext };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist the new token into meta.json and print the confirmation banner.
|
|
||||||
*
|
|
||||||
* Emits a warning when the target gateway differs from the locally installed one,
|
|
||||||
* so operators are aware that meta.json may not reflect the intended gateway.
|
|
||||||
*/
|
|
||||||
export function persistToken(gatewayUrl: string, minted: MintedToken): void {
|
|
||||||
const meta = readMeta() ?? {
|
|
||||||
version: 'unknown',
|
|
||||||
installedAt: new Date().toISOString(),
|
|
||||||
entryPoint: '',
|
|
||||||
host: new URL(gatewayUrl).hostname,
|
|
||||||
port: parseInt(new URL(gatewayUrl).port || '14242', 10),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Warn when the target gateway does not match the locally installed one
|
|
||||||
const targetHost = new URL(gatewayUrl).hostname;
|
|
||||||
if (targetHost !== meta.host) {
|
|
||||||
console.warn(
|
|
||||||
`Warning: token was minted against ${gatewayUrl} but is being saved to the local` +
|
|
||||||
` meta.json (host: ${meta.host}). Copy the token manually if targeting a remote gateway.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeMeta({ ...meta, adminToken: minted.plaintext });
|
|
||||||
|
|
||||||
const preview = `${minted.plaintext.slice(0, 8)}...`;
|
|
||||||
console.log();
|
|
||||||
console.log(`Token minted: ${minted.label}`);
|
|
||||||
console.log(`Preview: ${preview}`);
|
|
||||||
console.log('Token saved to meta.json. Use it with admin endpoints.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require a valid session for the given gateway URL.
|
|
||||||
* Returns the session cookie or exits if not authenticated.
|
|
||||||
*/
|
|
||||||
export async function requireSession(gatewayUrl: string): Promise<string> {
|
|
||||||
const session = loadSession(gatewayUrl);
|
|
||||||
if (session) {
|
|
||||||
const valid = await validateSession(gatewayUrl, session.cookie);
|
|
||||||
if (valid) return session.cookie;
|
|
||||||
}
|
|
||||||
console.error('Not signed in or session expired.');
|
|
||||||
console.error('Run: mosaic gateway login');
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a valid session for the gateway, prompting for credentials if needed.
|
|
||||||
* On sign-in failure, prints the error and exits non-zero.
|
|
||||||
* Returns the session cookie.
|
|
||||||
*/
|
|
||||||
export async function ensureSession(gatewayUrl: string): Promise<string> {
|
|
||||||
// Try the stored session first
|
|
||||||
const session = loadSession(gatewayUrl);
|
|
||||||
if (session) {
|
|
||||||
const valid = await validateSession(gatewayUrl, session.cookie);
|
|
||||||
if (valid) return session.cookie;
|
|
||||||
console.log('Stored session is invalid or expired. Please sign in again.');
|
|
||||||
} else {
|
|
||||||
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prompt for credentials — password must not be echoed to the terminal
|
|
||||||
const email = await promptLine('Email: ');
|
|
||||||
// Do not trim password — it may contain intentional leading/trailing whitespace
|
|
||||||
const password = await promptSecret('Password: ');
|
|
||||||
|
|
||||||
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
saveSession(gatewayUrl, auth);
|
|
||||||
console.log(`Signed in as ${auth.email}`);
|
|
||||||
return auth.cookie;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `mosaic gateway config rotate-token` — requires an existing valid session.
|
|
||||||
*/
|
|
||||||
export async function runRotateToken(gatewayUrl?: string): Promise<void> {
|
|
||||||
const url = getGatewayUrl(gatewayUrl);
|
|
||||||
const cookie = await requireSession(url);
|
|
||||||
const label = `CLI rotated token (${new Date().toISOString().slice(0, 10)})`;
|
|
||||||
const minted = await mintAdminToken(url, cookie, label);
|
|
||||||
persistToken(url, minted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `mosaic gateway config recover-token` — prompts for login if no session exists.
|
|
||||||
*/
|
|
||||||
export async function runRecoverToken(gatewayUrl?: string): Promise<void> {
|
|
||||||
const url = getGatewayUrl(gatewayUrl);
|
|
||||||
const cookie = await ensureSession(url);
|
|
||||||
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
|
|
||||||
const minted = await mintAdminToken(url, cookie, label);
|
|
||||||
persistToken(url, minted);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { readMeta } from './daemon.js';
|
|
||||||
|
|
||||||
// ANSI colour helpers (gracefully degrade when not a TTY)
|
|
||||||
const isTTY = Boolean(process.stdout.isTTY);
|
|
||||||
const G = isTTY ? '\x1b[0;32m' : '';
|
|
||||||
const R = isTTY ? '\x1b[0;31m' : '';
|
|
||||||
const BOLD = isTTY ? '\x1b[1m' : '';
|
|
||||||
const RESET = isTTY ? '\x1b[0m' : '';
|
|
||||||
|
|
||||||
function ok(label: string): void {
|
|
||||||
process.stdout.write(` ${G}✔${RESET} ${label.padEnd(36)}${G}[ok]${RESET}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail(label: string, hint: string): void {
|
|
||||||
process.stdout.write(` ${R}✖${RESET} ${label.padEnd(36)}${R}[FAIL]${RESET}\n`);
|
|
||||||
process.stdout.write(` ${R}↳ ${hint}${RESET}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithRetry(
|
|
||||||
url: string,
|
|
||||||
opts: RequestInit = {},
|
|
||||||
retries = 3,
|
|
||||||
delayMs = 1000,
|
|
||||||
): Promise<Response | null> {
|
|
||||||
for (let attempt = 0; attempt < retries; attempt++) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, opts);
|
|
||||||
// Retry on non-OK responses too — the gateway may still be starting up
|
|
||||||
// (e.g. 503 before the app bootstrap completes).
|
|
||||||
if (res.ok) return res;
|
|
||||||
} catch {
|
|
||||||
// Network-level error — not ready yet, will retry
|
|
||||||
}
|
|
||||||
if (attempt < retries - 1) await sleep(delayMs);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyResult {
|
|
||||||
gatewayHealthy: boolean;
|
|
||||||
adminTokenOnFile: boolean;
|
|
||||||
bootstrapReachable: boolean;
|
|
||||||
allPassed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run post-install verification checks.
|
|
||||||
*
|
|
||||||
* @param host - Gateway hostname (e.g. "localhost")
|
|
||||||
* @param port - Gateway port (e.g. 14242)
|
|
||||||
* @returns VerifyResult — callers can inspect individual flags
|
|
||||||
*/
|
|
||||||
export async function runPostInstallVerification(
|
|
||||||
host: string,
|
|
||||||
port: number,
|
|
||||||
): Promise<VerifyResult> {
|
|
||||||
const baseUrl = `http://${host}:${port.toString()}`;
|
|
||||||
|
|
||||||
console.log(`\n${BOLD}Mosaic installation verified:${RESET}`);
|
|
||||||
|
|
||||||
// ─── Check 1: Gateway /health ─────────────────────────────────────────────
|
|
||||||
const healthRes = await fetchWithRetry(`${baseUrl}/health`);
|
|
||||||
const gatewayHealthy = healthRes !== null && healthRes.ok;
|
|
||||||
if (gatewayHealthy) {
|
|
||||||
ok('gateway healthy');
|
|
||||||
} else {
|
|
||||||
fail('gateway healthy', 'Run: mosaic gateway status / mosaic gateway logs');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Check 2: Admin token on file ─────────────────────────────────────────
|
|
||||||
const meta = readMeta();
|
|
||||||
const adminTokenOnFile = Boolean(meta?.adminToken && meta.adminToken.length > 0);
|
|
||||||
if (adminTokenOnFile) {
|
|
||||||
ok('admin token on file');
|
|
||||||
} else {
|
|
||||||
fail('admin token on file', 'Run: mosaic gateway config recover-token');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Check 3: Bootstrap endpoint reachable ────────────────────────────────
|
|
||||||
const bootstrapRes = await fetchWithRetry(`${baseUrl}/api/bootstrap/status`);
|
|
||||||
const bootstrapReachable = bootstrapRes !== null && bootstrapRes.ok;
|
|
||||||
if (bootstrapReachable) {
|
|
||||||
ok('bootstrap endpoint reach');
|
|
||||||
} else {
|
|
||||||
fail('bootstrap endpoint reach', 'Run: mosaic gateway status / mosaic gateway logs');
|
|
||||||
}
|
|
||||||
|
|
||||||
const allPassed = gatewayHealthy && adminTokenOnFile && bootstrapReachable;
|
|
||||||
|
|
||||||
if (!allPassed) {
|
|
||||||
console.log(
|
|
||||||
`\n${R}One or more checks failed.${RESET} Recovery commands listed above.\n` +
|
|
||||||
`Use ${BOLD}mosaic gateway status${RESET} and ${BOLD}mosaic gateway config recover-token${RESET} to investigate.\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, allPassed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standalone entry point for `mosaic gateway verify`.
|
|
||||||
* Reads host/port from meta.json if not provided.
|
|
||||||
*/
|
|
||||||
export async function runVerify(opts: { host?: string; port?: number }): Promise<void> {
|
|
||||||
const meta = readMeta();
|
|
||||||
const host = opts.host ?? meta?.host ?? 'localhost';
|
|
||||||
const port = opts.port ?? meta?.port ?? 14242;
|
|
||||||
|
|
||||||
const result = await runPostInstallVerification(host, port);
|
|
||||||
if (!result.allPassed) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
/**
|
|
||||||
* CU-06-05 — Vitest tests for mosaic telemetry command
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { registerTelemetryCommand } from './telemetry.js';
|
|
||||||
import type { TelemetryConsent } from '../telemetry/consent-store.js';
|
|
||||||
|
|
||||||
// ─── module mocks ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Mock consent-store so tests don't touch the filesystem.
|
|
||||||
const mockConsent: TelemetryConsent = {
|
|
||||||
remoteEnabled: false,
|
|
||||||
optedInAt: null,
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock('../telemetry/consent-store.js', () => ({
|
|
||||||
readConsent: vi.fn(() => ({ ...mockConsent })),
|
|
||||||
writeConsent: vi.fn(),
|
|
||||||
optIn: vi.fn(() => ({
|
|
||||||
...mockConsent,
|
|
||||||
remoteEnabled: true,
|
|
||||||
optedInAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
})),
|
|
||||||
optOut: vi.fn(() => ({
|
|
||||||
...mockConsent,
|
|
||||||
remoteEnabled: false,
|
|
||||||
optedOutAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
})),
|
|
||||||
recordUpload: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the telemetry client shim.
|
|
||||||
const mockClientInstance = {
|
|
||||||
init: vi.fn(),
|
|
||||||
captureEvent: vi.fn(),
|
|
||||||
upload: vi.fn().mockResolvedValue(undefined),
|
|
||||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock('../telemetry/client-shim.js', () => ({
|
|
||||||
getTelemetryClient: vi.fn(() => mockClientInstance),
|
|
||||||
setTelemetryClient: vi.fn(),
|
|
||||||
resetTelemetryClient: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock @clack/prompts so tests don't require stdin.
|
|
||||||
vi.mock('@clack/prompts', () => ({
|
|
||||||
confirm: vi.fn().mockResolvedValue(true),
|
|
||||||
intro: vi.fn(),
|
|
||||||
outro: vi.fn(),
|
|
||||||
isCancel: vi.fn().mockReturnValue(false),
|
|
||||||
cancel: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildProgram(): Command {
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerTelemetryCommand(program);
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTelemetryCmd(program: Command): Command {
|
|
||||||
const found = program.commands.find((c) => c.name() === 'telemetry');
|
|
||||||
if (!found) throw new Error('telemetry command not found');
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalCmd(telemetryCmd: Command): Command {
|
|
||||||
const found = telemetryCmd.commands.find((c) => c.name() === 'local');
|
|
||||||
if (!found) throw new Error('local subcommand not found');
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── CU-06-05 a: command structure smoke test ─────────────────────────────────
|
|
||||||
|
|
||||||
describe('registerTelemetryCommand — structure', () => {
|
|
||||||
it('registers a "telemetry" command on the program', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const names = program.commands.map((c) => c.name());
|
|
||||||
expect(names).toContain('telemetry');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers the expected top-level subcommands', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const tel = getTelemetryCmd(program);
|
|
||||||
const subs = tel.commands.map((c) => c.name()).sort();
|
|
||||||
expect(subs).toEqual(['local', 'opt-in', 'opt-out', 'status', 'test', 'upload']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers all three local subcommands', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const local = getLocalCmd(getTelemetryCmd(program));
|
|
||||||
const subs = local.commands.map((c) => c.name()).sort();
|
|
||||||
expect(subs).toEqual(['jaeger', 'status', 'tail']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── CU-06-05 b: opt-in / opt-out ────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('telemetry opt-in', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
// Provide disabled consent so opt-in path is taken.
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
vi.mocked(store.readConsent).mockReturnValue({
|
|
||||||
remoteEnabled: false,
|
|
||||||
optedInAt: null,
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: null,
|
|
||||||
});
|
|
||||||
vi.mocked(store.optIn).mockReturnValue({
|
|
||||||
remoteEnabled: true,
|
|
||||||
optedInAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const clack = await import('@clack/prompts');
|
|
||||||
vi.mocked(clack.confirm).mockResolvedValue(true);
|
|
||||||
vi.mocked(clack.isCancel).mockReturnValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls optIn() when user confirms', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-in']);
|
|
||||||
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
expect(vi.mocked(store.optIn)).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not call optIn() when user cancels', async () => {
|
|
||||||
const clack = await import('@clack/prompts');
|
|
||||||
vi.mocked(clack.confirm).mockResolvedValue(false);
|
|
||||||
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-in']);
|
|
||||||
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
expect(vi.mocked(store.optIn)).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('telemetry opt-out', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
vi.mocked(store.readConsent).mockReturnValue({
|
|
||||||
remoteEnabled: true,
|
|
||||||
optedInAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: null,
|
|
||||||
});
|
|
||||||
vi.mocked(store.optOut).mockReturnValue({
|
|
||||||
remoteEnabled: false,
|
|
||||||
optedInAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
optedOutAt: '2026-02-01T00:00:00.000Z',
|
|
||||||
lastUploadAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const clack = await import('@clack/prompts');
|
|
||||||
vi.mocked(clack.confirm).mockResolvedValue(true);
|
|
||||||
vi.mocked(clack.isCancel).mockReturnValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls optOut() when user confirms', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-out']);
|
|
||||||
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
expect(vi.mocked(store.optOut)).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not call optOut() when already disabled', async () => {
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
vi.mocked(store.readConsent).mockReturnValue({
|
|
||||||
remoteEnabled: false,
|
|
||||||
optedInAt: null,
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-out']);
|
|
||||||
expect(vi.mocked(store.optOut)).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── CU-06-05 c: status ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('telemetry status', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows disabled state when remote upload is off', async () => {
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
vi.mocked(store.readConsent).mockReturnValue({
|
|
||||||
remoteEnabled: false,
|
|
||||||
optedInAt: null,
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']);
|
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
||||||
expect(output).toContain('false');
|
|
||||||
expect(output).toContain('(never)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows enabled state and timestamps when opted in', async () => {
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
vi.mocked(store.readConsent).mockReturnValue({
|
|
||||||
remoteEnabled: true,
|
|
||||||
optedInAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: '2026-03-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']);
|
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
||||||
expect(output).toContain('true');
|
|
||||||
expect(output).toContain('2026-01-01');
|
|
||||||
expect(output).toContain('2026-03-01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows dry-run banner when MOSAIC_TELEMETRY_DRY_RUN=1', async () => {
|
|
||||||
process.env['MOSAIC_TELEMETRY_DRY_RUN'] = '1';
|
|
||||||
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
vi.mocked(store.readConsent).mockReturnValue({
|
|
||||||
remoteEnabled: false,
|
|
||||||
optedInAt: null,
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']);
|
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
||||||
expect(output).toContain('[dry-run]');
|
|
||||||
|
|
||||||
delete process.env['MOSAIC_TELEMETRY_DRY_RUN'];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── CU-06-05 d: test / upload — dry-run assertions ──────────────────────────
|
|
||||||
|
|
||||||
describe('telemetry test (dry-run)', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints dry-run banner and does not call upload()', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']);
|
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
||||||
expect(output).toContain('[dry-run]');
|
|
||||||
expect(mockClientInstance.upload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls captureEvent() with a mosaic.cli.test event', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']);
|
|
||||||
|
|
||||||
expect(mockClientInstance.captureEvent).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ name: 'mosaic.cli.test' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not make network calls in dry-run mode', async () => {
|
|
||||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response());
|
|
||||||
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']);
|
|
||||||
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
fetchSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('telemetry upload (dry-run default)', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
// Remote disabled by default.
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
vi.mocked(store.readConsent).mockReturnValue({
|
|
||||||
remoteEnabled: false,
|
|
||||||
optedInAt: null,
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
delete process.env['MOSAIC_TELEMETRY_DRY_RUN'];
|
|
||||||
delete process.env['MOSAIC_TELEMETRY_ENDPOINT'];
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints dry-run banner when remote upload is disabled', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'upload']);
|
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
||||||
expect(output).toContain('[dry-run]');
|
|
||||||
expect(mockClientInstance.upload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints dry-run banner when MOSAIC_TELEMETRY_DRY_RUN=1 even if opted in', async () => {
|
|
||||||
process.env['MOSAIC_TELEMETRY_DRY_RUN'] = '1';
|
|
||||||
process.env['MOSAIC_TELEMETRY_ENDPOINT'] = 'http://example.com/telemetry';
|
|
||||||
|
|
||||||
const store = await import('../telemetry/consent-store.js');
|
|
||||||
vi.mocked(store.readConsent).mockReturnValue({
|
|
||||||
remoteEnabled: true,
|
|
||||||
optedInAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'upload']);
|
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
||||||
expect(output).toContain('[dry-run]');
|
|
||||||
expect(mockClientInstance.upload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── local subcommand smoke tests ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('telemetry local tail', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints Jaeger UI URL and docker compose hint', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'tail']);
|
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
|
||||||
expect(output).toContain('Jaeger');
|
|
||||||
expect(output).toContain('docker compose');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('telemetry local jaeger', () => {
|
|
||||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
||||||
delete process.env['JAEGER_UI_URL'];
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints the default Jaeger URL', async () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'jaeger']);
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('http://localhost:16686');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('respects JAEGER_UI_URL env var', async () => {
|
|
||||||
process.env['JAEGER_UI_URL'] = 'http://jaeger.example.com:16686';
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'jaeger']);
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('http://jaeger.example.com:16686');
|
|
||||||
delete process.env['JAEGER_UI_URL'];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
/**
|
|
||||||
* mosaic telemetry — CU-06-02 (local) + CU-06-03 (remote)
|
|
||||||
*
|
|
||||||
* Local half: mosaic telemetry local {status, tail, jaeger}
|
|
||||||
* Remote half: mosaic telemetry {status, opt-in, opt-out, test, upload}
|
|
||||||
*
|
|
||||||
* Remote upload is DISABLED by default (dry-run mode).
|
|
||||||
* Per session-1 decision: ship upload/test in dry-run-only mode until
|
|
||||||
* the mosaicstack.dev server endpoint is live.
|
|
||||||
*
|
|
||||||
* Telemetry client: uses a forward-compat shim (see telemetry/client-shim.ts)
|
|
||||||
* because @mosaicstack/telemetry-client-js is not yet published.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Command } from 'commander';
|
|
||||||
import { confirm, intro, outro, isCancel, cancel } from '@clack/prompts';
|
|
||||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
|
||||||
import { getTelemetryClient } from '../telemetry/client-shim.js';
|
|
||||||
import { readConsent, optIn, optOut, recordUpload } from '../telemetry/consent-store.js';
|
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getMosaicHome(): string {
|
|
||||||
return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDryRun(): boolean {
|
|
||||||
return process.env['MOSAIC_TELEMETRY_DRY_RUN'] === '1';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Try to open a URL — best-effort, does not fail if unsupported. */
|
|
||||||
async function tryOpenUrl(url: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { spawn } = await import('node:child_process');
|
|
||||||
// `start` is a Windows shell builtin — must be invoked via cmd /c.
|
|
||||||
const [bin, args] =
|
|
||||||
process.platform === 'darwin'
|
|
||||||
? (['open', [url]] as [string, string[]])
|
|
||||||
: process.platform === 'win32'
|
|
||||||
? (['cmd', ['/c', 'start', '', url]] as [string, string[]])
|
|
||||||
: (['xdg-open', [url]] as [string, string[]]);
|
|
||||||
spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
|
|
||||||
} catch {
|
|
||||||
// Best-effort — silently skip if unavailable.
|
|
||||||
console.log(`Open this URL in your browser: ${url}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── local subcommands ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function registerLocalCommand(parent: Command): void {
|
|
||||||
const local = parent
|
|
||||||
.command('local')
|
|
||||||
.description('Inspect the local OpenTelemetry stack')
|
|
||||||
.configureHelp({ sortSubcommands: true });
|
|
||||||
|
|
||||||
// ── telemetry local status ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
local
|
|
||||||
.command('status')
|
|
||||||
.description('Report reachability of the local OTEL collector endpoint')
|
|
||||||
.action(async () => {
|
|
||||||
const endpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? 'http://localhost:4318';
|
|
||||||
const serviceName = process.env['OTEL_SERVICE_NAME'] ?? 'mosaic-gateway';
|
|
||||||
const exportInterval = '15000ms'; // matches tracing.ts PeriodicExportingMetricReader
|
|
||||||
|
|
||||||
console.log(`OTEL endpoint: ${endpoint}`);
|
|
||||||
console.log(`Service name: ${serviceName}`);
|
|
||||||
console.log(`Export interval: ${exportInterval}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(3000),
|
|
||||||
});
|
|
||||||
// OTLP collector typically returns 404 for GET on the root path —
|
|
||||||
// but a response means it's listening.
|
|
||||||
console.log(`Status: reachable (HTTP ${String(response.status)})`);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
console.log(`Status: unreachable — ${msg}`);
|
|
||||||
console.log('');
|
|
||||||
console.log('Hint: start the local stack with `docker compose up -d`');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── telemetry local tail ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local
|
|
||||||
.command('tail')
|
|
||||||
.description('Explain how to view live traces from the local OTEL stack')
|
|
||||||
.action(() => {
|
|
||||||
const jaegerUrl = process.env['JAEGER_UI_URL'] ?? 'http://localhost:16686';
|
|
||||||
|
|
||||||
console.log('OTLP is a push protocol — there is no log tail.');
|
|
||||||
console.log('');
|
|
||||||
console.log('Traces flow: your service → OTEL Collector → Jaeger');
|
|
||||||
console.log('');
|
|
||||||
console.log(`Jaeger UI: ${jaegerUrl}`);
|
|
||||||
console.log('Run `mosaic telemetry local jaeger` to print the URL (or open it).');
|
|
||||||
console.log('');
|
|
||||||
console.log('For raw collector output:');
|
|
||||||
console.log(' docker compose logs -f otel-collector');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── telemetry local jaeger ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
local
|
|
||||||
.command('jaeger')
|
|
||||||
.description('Print the Jaeger UI URL (use --open to launch in browser)')
|
|
||||||
.option('--open', 'Open the Jaeger UI in the default browser')
|
|
||||||
.action(async (opts: { open?: boolean }) => {
|
|
||||||
const jaegerUrl = process.env['JAEGER_UI_URL'] ?? 'http://localhost:16686';
|
|
||||||
console.log(jaegerUrl);
|
|
||||||
|
|
||||||
if (opts.open) {
|
|
||||||
await tryOpenUrl(jaegerUrl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── remote subcommands ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function registerRemoteStatusCommand(cmd: Command): void {
|
|
||||||
cmd
|
|
||||||
.command('status')
|
|
||||||
.description('Print the remote telemetry upload status and consent state')
|
|
||||||
.action(() => {
|
|
||||||
const mosaicHome = getMosaicHome();
|
|
||||||
const consent = readConsent(mosaicHome);
|
|
||||||
const remoteEndpoint = process.env['MOSAIC_TELEMETRY_ENDPOINT'] ?? '(not configured)';
|
|
||||||
const dryRunActive = isDryRun();
|
|
||||||
|
|
||||||
console.log('Remote telemetry status');
|
|
||||||
console.log('─────────────────────────────────────────────');
|
|
||||||
console.log(` Remote upload enabled: ${String(consent.remoteEnabled)}`);
|
|
||||||
console.log(` Remote endpoint: ${remoteEndpoint}`);
|
|
||||||
if (consent.optedInAt) {
|
|
||||||
console.log(` Opted in: ${consent.optedInAt}`);
|
|
||||||
}
|
|
||||||
if (consent.optedOutAt) {
|
|
||||||
console.log(` Opted out: ${consent.optedOutAt}`);
|
|
||||||
}
|
|
||||||
if (consent.lastUploadAt) {
|
|
||||||
console.log(` Last upload: ${consent.lastUploadAt}`);
|
|
||||||
} else {
|
|
||||||
console.log(' Last upload: (never)');
|
|
||||||
}
|
|
||||||
if (dryRunActive) {
|
|
||||||
console.log('');
|
|
||||||
console.log(' [dry-run] MOSAIC_TELEMETRY_DRY_RUN=1 is set — uploads are suppressed');
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
console.log('Local OTEL stack always active (see `mosaic telemetry local status`).');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerOptInCommand(cmd: Command): void {
|
|
||||||
cmd
|
|
||||||
.command('opt-in')
|
|
||||||
.description('Enable remote telemetry upload (requires explicit consent)')
|
|
||||||
.action(async () => {
|
|
||||||
const mosaicHome = getMosaicHome();
|
|
||||||
const current = readConsent(mosaicHome);
|
|
||||||
|
|
||||||
if (current.remoteEnabled) {
|
|
||||||
console.log('Remote telemetry upload is already enabled.');
|
|
||||||
console.log(`Opted in: ${current.optedInAt ?? '(unknown)'}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
intro('Mosaic remote telemetry opt-in');
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log('What gets uploaded:');
|
|
||||||
console.log(' - CLI command names and completion status (no arguments / values)');
|
|
||||||
console.log(' - Error types (no stack traces or user data)');
|
|
||||||
console.log(' - Mosaic version and platform metadata');
|
|
||||||
console.log('');
|
|
||||||
console.log('What is NEVER uploaded:');
|
|
||||||
console.log(' - File contents, code, or credentials');
|
|
||||||
console.log(' - Personal information or agent conversation data');
|
|
||||||
console.log('');
|
|
||||||
console.log('Note: remote upload is currently in dry-run mode until');
|
|
||||||
console.log(' the mosaicstack.dev telemetry endpoint is live.');
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
const confirmed = await confirm({
|
|
||||||
message: 'Enable remote telemetry upload?',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isCancel(confirmed) || !confirmed) {
|
|
||||||
cancel('Opt-in cancelled — no changes made.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const consent = optIn(mosaicHome);
|
|
||||||
outro(`Remote telemetry enabled. Opted in at ${consent.optedInAt ?? ''}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerOptOutCommand(cmd: Command): void {
|
|
||||||
cmd
|
|
||||||
.command('opt-out')
|
|
||||||
.description('Disable remote telemetry upload')
|
|
||||||
.action(async () => {
|
|
||||||
const mosaicHome = getMosaicHome();
|
|
||||||
const current = readConsent(mosaicHome);
|
|
||||||
|
|
||||||
if (!current.remoteEnabled) {
|
|
||||||
console.log('Remote telemetry upload is already disabled.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
intro('Mosaic remote telemetry opt-out');
|
|
||||||
console.log('');
|
|
||||||
console.log('This will disable remote upload of anonymised usage data.');
|
|
||||||
console.log('Local OTEL tracing (to Jaeger) will remain active — it is');
|
|
||||||
console.log('independent of this consent state.');
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
const confirmed = await confirm({
|
|
||||||
message: 'Disable remote telemetry upload?',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isCancel(confirmed) || !confirmed) {
|
|
||||||
cancel('Opt-out cancelled — no changes made.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const consent = optOut(mosaicHome);
|
|
||||||
outro(`Remote telemetry disabled. Opted out at ${consent.optedOutAt ?? ''}`);
|
|
||||||
console.log('Local OTEL stack (Jaeger) remains active.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerTestCommand(cmd: Command): void {
|
|
||||||
cmd
|
|
||||||
.command('test')
|
|
||||||
.description('Synthesise a fake event and print the payload that would be sent (dry-run)')
|
|
||||||
.option('--upload', 'Actually upload (requires consent + MOSAIC_TELEMETRY_ENDPOINT)')
|
|
||||||
.action(async (opts: { upload?: boolean }) => {
|
|
||||||
const mosaicHome = getMosaicHome();
|
|
||||||
const consent = readConsent(mosaicHome);
|
|
||||||
const dryRunActive = isDryRun() || !opts.upload;
|
|
||||||
|
|
||||||
if (!dryRunActive && !consent.remoteEnabled) {
|
|
||||||
console.error('Remote upload is not enabled. Run `mosaic telemetry opt-in` first.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fakeEvent = {
|
|
||||||
name: 'mosaic.cli.test',
|
|
||||||
properties: {
|
|
||||||
command: 'telemetry test',
|
|
||||||
version: process.env['npm_package_version'] ?? 'unknown',
|
|
||||||
platform: process.platform,
|
|
||||||
},
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const endpoint = process.env['MOSAIC_TELEMETRY_ENDPOINT'];
|
|
||||||
const client = getTelemetryClient();
|
|
||||||
|
|
||||||
client.init({
|
|
||||||
endpoint,
|
|
||||||
dryRun: dryRunActive,
|
|
||||||
labels: { source: 'mosaic-cli' },
|
|
||||||
});
|
|
||||||
|
|
||||||
client.captureEvent(fakeEvent);
|
|
||||||
|
|
||||||
if (dryRunActive) {
|
|
||||||
console.log('[dry-run] telemetry test — payload that would be sent:');
|
|
||||||
console.log(JSON.stringify(fakeEvent, null, 2));
|
|
||||||
console.log('');
|
|
||||||
console.log('No network call made. Pass --upload to attempt real delivery.');
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await client.upload();
|
|
||||||
recordUpload(mosaicHome);
|
|
||||||
console.log('Event delivered.');
|
|
||||||
} catch (err) {
|
|
||||||
// The shim throws when a real POST is attempted — make it clear nothing was sent.
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerUploadCommand(cmd: Command): void {
|
|
||||||
cmd
|
|
||||||
.command('upload')
|
|
||||||
.description('Send pending telemetry events to the remote endpoint')
|
|
||||||
.action(async () => {
|
|
||||||
const mosaicHome = getMosaicHome();
|
|
||||||
const consent = readConsent(mosaicHome);
|
|
||||||
const dryRunActive = isDryRun();
|
|
||||||
|
|
||||||
if (!consent.remoteEnabled) {
|
|
||||||
console.log('[dry-run] telemetry upload — no network call made');
|
|
||||||
console.log('Remote upload is disabled. Run `mosaic telemetry opt-in` to enable.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = process.env['MOSAIC_TELEMETRY_ENDPOINT'];
|
|
||||||
|
|
||||||
if (dryRunActive || !endpoint) {
|
|
||||||
console.log('[dry-run] telemetry upload — no network call made');
|
|
||||||
if (!endpoint) {
|
|
||||||
console.log('MOSAIC_TELEMETRY_ENDPOINT is not set — running in dry-run mode.');
|
|
||||||
}
|
|
||||||
if (dryRunActive) {
|
|
||||||
console.log('MOSAIC_TELEMETRY_DRY_RUN=1 — uploads suppressed.');
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
console.log('Dry-run is the default until the mosaicstack.dev telemetry endpoint is live.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = getTelemetryClient();
|
|
||||||
client.init({ endpoint, dryRun: false, labels: { source: 'mosaic-cli' } });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.upload();
|
|
||||||
recordUpload(mosaicHome);
|
|
||||||
console.log('Upload complete.');
|
|
||||||
} catch (err) {
|
|
||||||
// The shim throws when a real POST is attempted — make it clear nothing was sent.
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── public registration ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function registerTelemetryCommand(program: Command): void {
|
|
||||||
const cmd = program
|
|
||||||
.command('telemetry')
|
|
||||||
.description('Inspect and manage telemetry (local OTEL stack + remote upload)')
|
|
||||||
.configureHelp({ sortSubcommands: true });
|
|
||||||
|
|
||||||
// ── local subgroup ──────────────────────────────────────────────────────
|
|
||||||
registerLocalCommand(cmd);
|
|
||||||
|
|
||||||
// ── remote subcommands ──────────────────────────────────────────────────
|
|
||||||
registerRemoteStatusCommand(cmd);
|
|
||||||
registerOptInCommand(cmd);
|
|
||||||
registerOptOutCommand(cmd);
|
|
||||||
registerTestCommand(cmd);
|
|
||||||
registerUploadCommand(cmd);
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,6 @@
|
|||||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
||||||
import { FileConfigAdapter } from './file-adapter.js';
|
import { FileConfigAdapter } from './file-adapter.js';
|
||||||
|
|
||||||
/** Supported top-level config sections for dotted-key access. */
|
|
||||||
export type ConfigSection = 'soul' | 'user' | 'tools';
|
|
||||||
|
|
||||||
/** A resolved view of all config sections, keyed by section name. */
|
|
||||||
export interface ResolvedConfig {
|
|
||||||
soul: SoulConfig;
|
|
||||||
user: UserConfig;
|
|
||||||
tools: ToolsConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ConfigService interface — abstracts config read/write operations.
|
* ConfigService interface — abstracts config read/write operations.
|
||||||
* Currently backed by FileConfigAdapter (writes .md files from templates).
|
* Currently backed by FileConfigAdapter (writes .md files from templates).
|
||||||
@@ -26,35 +16,6 @@ export interface ConfigService {
|
|||||||
writeTools(config: ToolsConfig): Promise<void>;
|
writeTools(config: ToolsConfig): Promise<void>;
|
||||||
|
|
||||||
syncFramework(action: InstallAction): Promise<void>;
|
syncFramework(action: InstallAction): Promise<void>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the resolved (merged) config across all sections.
|
|
||||||
*/
|
|
||||||
readAll(): Promise<ResolvedConfig>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a single value by dotted key (e.g. "soul.agentName").
|
|
||||||
* Returns undefined if the key doesn't exist.
|
|
||||||
*/
|
|
||||||
getValue(dottedKey: string): Promise<unknown>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a single value by dotted key (e.g. "soul.agentName") and persist.
|
|
||||||
* Returns the previous value (or undefined).
|
|
||||||
*/
|
|
||||||
setValue(dottedKey: string, value: string): Promise<unknown>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the filesystem path for a given config section file.
|
|
||||||
* When no section is provided, returns the mosaicHome directory.
|
|
||||||
*/
|
|
||||||
getConfigPath(section?: ConfigSection): string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the mosaicHome directory exists and at least one
|
|
||||||
* config file (SOUL.md, USER.md, TOOLS.md) is present.
|
|
||||||
*/
|
|
||||||
isInitialized(): boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createConfigService(mosaicHome: string, sourceDir: string): ConfigService {
|
export function createConfigService(mosaicHome: string, sourceDir: string): ConfigService {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } from 'node:fs';
|
import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { ConfigService, ConfigSection, ResolvedConfig } from './config-service.js';
|
import type { ConfigService } from './config-service.js';
|
||||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
||||||
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
||||||
import { renderTemplate } from '../template/engine.js';
|
import { renderTemplate } from '../template/engine.js';
|
||||||
@@ -159,73 +159,6 @@ export class FileConfigAdapter implements ConfigService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async readAll(): Promise<ResolvedConfig> {
|
|
||||||
const [soul, user, tools] = await Promise.all([
|
|
||||||
this.readSoul(),
|
|
||||||
this.readUser(),
|
|
||||||
this.readTools(),
|
|
||||||
]);
|
|
||||||
return { soul, user, tools };
|
|
||||||
}
|
|
||||||
|
|
||||||
async getValue(dottedKey: string): Promise<unknown> {
|
|
||||||
const parts = dottedKey.split('.');
|
|
||||||
const section = parts[0] ?? '';
|
|
||||||
const field = parts.slice(1).join('.');
|
|
||||||
const config = await this.readAll();
|
|
||||||
if (!this.isValidSection(section)) return undefined;
|
|
||||||
const sectionData = config[section as ConfigSection] as Record<string, unknown>;
|
|
||||||
return field ? sectionData[field] : sectionData;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setValue(dottedKey: string, value: string): Promise<unknown> {
|
|
||||||
const parts = dottedKey.split('.');
|
|
||||||
const section = parts[0] ?? '';
|
|
||||||
const field = parts.slice(1).join('.');
|
|
||||||
if (!this.isValidSection(section) || !field) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid key "${dottedKey}". Use format <section>.<field> (e.g. soul.agentName).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = await this.getValue(dottedKey);
|
|
||||||
|
|
||||||
if (section === 'soul') {
|
|
||||||
const current = await this.readSoul();
|
|
||||||
await this.writeSoul({ ...current, [field]: value });
|
|
||||||
} else if (section === 'user') {
|
|
||||||
const current = await this.readUser();
|
|
||||||
await this.writeUser({ ...current, [field]: value });
|
|
||||||
} else {
|
|
||||||
const current = await this.readTools();
|
|
||||||
await this.writeTools({ ...current, [field]: value });
|
|
||||||
}
|
|
||||||
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
getConfigPath(section?: ConfigSection): string {
|
|
||||||
if (!section) return this.mosaicHome;
|
|
||||||
const fileMap: Record<ConfigSection, string> = {
|
|
||||||
soul: join(this.mosaicHome, 'SOUL.md'),
|
|
||||||
user: join(this.mosaicHome, 'USER.md'),
|
|
||||||
tools: join(this.mosaicHome, 'TOOLS.md'),
|
|
||||||
};
|
|
||||||
return fileMap[section];
|
|
||||||
}
|
|
||||||
|
|
||||||
isInitialized(): boolean {
|
|
||||||
return (
|
|
||||||
existsSync(join(this.mosaicHome, 'SOUL.md')) ||
|
|
||||||
existsSync(join(this.mosaicHome, 'USER.md')) ||
|
|
||||||
existsSync(join(this.mosaicHome, 'TOOLS.md'))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isValidSection(s: string): s is ConfigSection {
|
|
||||||
return s === 'soul' || s === 'user' || s === 'tools';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look for template in source dir first, then mosaic home.
|
* Look for template in source dir first, then mosaic home.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* Forward-compat shim for @mosaicstack/telemetry-client-js.
|
|
||||||
*
|
|
||||||
* @mosaicstack/telemetry-client-js is not yet published to the Gitea npm
|
|
||||||
* registry (returns 404 as of 2026-04-04). This shim mirrors the minimal
|
|
||||||
* interface that the real client will expose so that all telemetry wiring
|
|
||||||
* can be implemented now and swapped for the real package when it lands.
|
|
||||||
*
|
|
||||||
* TODO: replace this shim with `import { ... } from '@mosaicstack/telemetry-client-js'`
|
|
||||||
* once the package is published.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TelemetryEvent {
|
|
||||||
/** Event name / category */
|
|
||||||
name: string;
|
|
||||||
/** Arbitrary key-value payload */
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
/** ISO timestamp — defaults to now if omitted */
|
|
||||||
timestamp?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal interface mirroring what @mosaicstack/telemetry-client-js exposes.
|
|
||||||
*/
|
|
||||||
export interface TelemetryClient {
|
|
||||||
/** Initialise the client (must be called before captureEvent / upload). */
|
|
||||||
init(options: TelemetryClientOptions): void;
|
|
||||||
/** Queue a telemetry event for eventual upload. */
|
|
||||||
captureEvent(event: TelemetryEvent): void;
|
|
||||||
/**
|
|
||||||
* Flush all queued events to the remote endpoint.
|
|
||||||
* In dry-run mode the client must print instead of POST.
|
|
||||||
*/
|
|
||||||
upload(): Promise<void>;
|
|
||||||
/** Flush and release resources. */
|
|
||||||
shutdown(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TelemetryClientOptions {
|
|
||||||
/** Remote OTLP / telemetry endpoint URL */
|
|
||||||
endpoint?: string;
|
|
||||||
/** Dry-run: print payloads instead of posting */
|
|
||||||
dryRun?: boolean;
|
|
||||||
/** Extra labels attached to every event */
|
|
||||||
labels?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Shim implementation ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A no-network shim that buffers events and pretty-prints them in dry-run mode.
|
|
||||||
* This is the ONLY implementation used until the real package is published.
|
|
||||||
*/
|
|
||||||
class TelemetryClientShim implements TelemetryClient {
|
|
||||||
private options: TelemetryClientOptions = {};
|
|
||||||
private queue: TelemetryEvent[] = [];
|
|
||||||
|
|
||||||
init(options: TelemetryClientOptions): void {
|
|
||||||
// Merge options without clearing the queue — buffered events must survive
|
|
||||||
// re-initialisation so that `telemetry upload` can flush them.
|
|
||||||
this.options = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
captureEvent(event: TelemetryEvent): void {
|
|
||||||
this.queue.push({
|
|
||||||
...event,
|
|
||||||
timestamp: event.timestamp ?? new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async upload(): Promise<void> {
|
|
||||||
const isDryRun = this.options.dryRun !== false; // dry-run is default
|
|
||||||
|
|
||||||
if (isDryRun) {
|
|
||||||
console.log('[dry-run] telemetry upload — no network call made');
|
|
||||||
for (const evt of this.queue) {
|
|
||||||
console.log(JSON.stringify({ ...evt, labels: this.options.labels }, null, 2));
|
|
||||||
}
|
|
||||||
this.queue = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Real upload path — placeholder until real client replaces this shim.
|
|
||||||
const endpoint = this.options.endpoint;
|
|
||||||
if (!endpoint) {
|
|
||||||
console.log('[dry-run] telemetry upload — no endpoint configured, no network call made');
|
|
||||||
for (const evt of this.queue) {
|
|
||||||
console.log(JSON.stringify(evt, null, 2));
|
|
||||||
}
|
|
||||||
this.queue = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The real client is not yet published — throw so callers know no data
|
|
||||||
// was actually sent. This prevents the CLI from marking an upload as
|
|
||||||
// successful when only the shim is present.
|
|
||||||
// TODO: remove once @mosaicstack/telemetry-client-js replaces this shim.
|
|
||||||
throw new Error(
|
|
||||||
`[shim] telemetry-client-js is not yet available — cannot POST to ${endpoint}. ` +
|
|
||||||
'Remote upload is supported only after the mosaicstack.dev endpoint is live.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async shutdown(): Promise<void> {
|
|
||||||
await this.upload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Singleton client instance. */
|
|
||||||
let _client: TelemetryClient | null = null;
|
|
||||||
|
|
||||||
/** Return (or lazily create) the singleton telemetry client. */
|
|
||||||
export function getTelemetryClient(): TelemetryClient {
|
|
||||||
if (!_client) {
|
|
||||||
_client = new TelemetryClientShim();
|
|
||||||
}
|
|
||||||
return _client;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the singleton — used in tests to inject a mock.
|
|
||||||
*/
|
|
||||||
export function setTelemetryClient(client: TelemetryClient): void {
|
|
||||||
_client = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the singleton to null (useful in tests).
|
|
||||||
*/
|
|
||||||
export function resetTelemetryClient(): void {
|
|
||||||
_client = null;
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* Persistent consent store for remote telemetry upload.
|
|
||||||
*
|
|
||||||
* State is stored in $MOSAIC_HOME/telemetry.json (not inside the markdown
|
|
||||||
* config files — those are template-rendered and would lose structured data).
|
|
||||||
*
|
|
||||||
* Schema:
|
|
||||||
* {
|
|
||||||
* remoteEnabled: boolean,
|
|
||||||
* optedInAt: string | null, // ISO timestamp
|
|
||||||
* optedOutAt: string | null, // ISO timestamp
|
|
||||||
* lastUploadAt: string | null // ISO timestamp
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { atomicWrite } from '../platform/file-ops.js';
|
|
||||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
|
||||||
|
|
||||||
export interface TelemetryConsent {
|
|
||||||
remoteEnabled: boolean;
|
|
||||||
optedInAt: string | null;
|
|
||||||
optedOutAt: string | null;
|
|
||||||
lastUploadAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TELEMETRY_FILE = 'telemetry.json';
|
|
||||||
|
|
||||||
const DEFAULT_CONSENT: TelemetryConsent = {
|
|
||||||
remoteEnabled: false,
|
|
||||||
optedInAt: null,
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function consentFilePath(mosaicHome?: string): string {
|
|
||||||
return join(mosaicHome ?? getMosaicHome(), TELEMETRY_FILE);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMosaicHome(): string {
|
|
||||||
return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the current consent state. Returns defaults if file doesn't exist.
|
|
||||||
*/
|
|
||||||
export function readConsent(mosaicHome?: string): TelemetryConsent {
|
|
||||||
const filePath = consentFilePath(mosaicHome);
|
|
||||||
if (!existsSync(filePath)) {
|
|
||||||
return { ...DEFAULT_CONSENT };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const raw = readFileSync(filePath, 'utf-8');
|
|
||||||
const parsed = JSON.parse(raw) as Partial<TelemetryConsent>;
|
|
||||||
return {
|
|
||||||
remoteEnabled: parsed.remoteEnabled ?? false,
|
|
||||||
optedInAt: parsed.optedInAt ?? null,
|
|
||||||
optedOutAt: parsed.optedOutAt ?? null,
|
|
||||||
lastUploadAt: parsed.lastUploadAt ?? null,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULT_CONSENT };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist a full or partial consent update.
|
|
||||||
*/
|
|
||||||
export function writeConsent(update: Partial<TelemetryConsent>, mosaicHome?: string): void {
|
|
||||||
const current = readConsent(mosaicHome);
|
|
||||||
const next: TelemetryConsent = { ...current, ...update };
|
|
||||||
atomicWrite(consentFilePath(mosaicHome), JSON.stringify(next, null, 2) + '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark opt-in: enable remote upload and record timestamp.
|
|
||||||
*/
|
|
||||||
export function optIn(mosaicHome?: string): TelemetryConsent {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const next: TelemetryConsent = {
|
|
||||||
remoteEnabled: true,
|
|
||||||
optedInAt: now,
|
|
||||||
optedOutAt: null,
|
|
||||||
lastUploadAt: readConsent(mosaicHome).lastUploadAt,
|
|
||||||
};
|
|
||||||
writeConsent(next, mosaicHome);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark opt-out: disable remote upload and record timestamp.
|
|
||||||
*/
|
|
||||||
export function optOut(mosaicHome?: string): TelemetryConsent {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const current = readConsent(mosaicHome);
|
|
||||||
const next: TelemetryConsent = {
|
|
||||||
remoteEnabled: false,
|
|
||||||
optedInAt: current.optedInAt,
|
|
||||||
optedOutAt: now,
|
|
||||||
lastUploadAt: current.lastUploadAt,
|
|
||||||
};
|
|
||||||
writeConsent(next, mosaicHome);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a successful upload timestamp.
|
|
||||||
*/
|
|
||||||
export function recordUpload(mosaicHome?: string): void {
|
|
||||||
writeConsent({ lastUploadAt: new Date().toISOString() }, mosaicHome);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
import { writeFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import type { WizardPrompter } from './prompter/interface.js';
|
import type { WizardPrompter } from './prompter/interface.js';
|
||||||
import type { ConfigService } from './config/config-service.js';
|
import type { ConfigService } from './config/config-service.js';
|
||||||
import type { WizardState } from './types.js';
|
import type { WizardState } from './types.js';
|
||||||
@@ -14,25 +11,6 @@ import { runtimeSetupStage } from './stages/runtime-setup.js';
|
|||||||
import { skillsSelectStage } from './stages/skills-select.js';
|
import { skillsSelectStage } from './stages/skills-select.js';
|
||||||
import { finalizeStage } from './stages/finalize.js';
|
import { finalizeStage } from './stages/finalize.js';
|
||||||
|
|
||||||
// ─── Transient install session state (CU-07-02) ───────────────────────────────
|
|
||||||
|
|
||||||
const INSTALL_STATE_FILE = join(
|
|
||||||
process.env['XDG_RUNTIME_DIR'] ?? process.env['TMPDIR'] ?? tmpdir(),
|
|
||||||
'mosaic-install-state.json',
|
|
||||||
);
|
|
||||||
|
|
||||||
function writeInstallState(mosaicHome: string): void {
|
|
||||||
try {
|
|
||||||
const state = {
|
|
||||||
wizardCompletedAt: new Date().toISOString(),
|
|
||||||
mosaicHome,
|
|
||||||
};
|
|
||||||
writeFileSync(INSTALL_STATE_FILE, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
|
||||||
} catch {
|
|
||||||
// Non-fatal — gateway install will just ask for home again
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WizardOptions {
|
export interface WizardOptions {
|
||||||
mosaicHome: string;
|
mosaicHome: string;
|
||||||
sourceDir: string;
|
sourceDir: string;
|
||||||
@@ -114,8 +92,4 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
|||||||
|
|
||||||
// Stage 9: Finalize
|
// Stage 9: Finalize
|
||||||
await finalizeStage(prompter, state, configService);
|
await finalizeStage(prompter, state, configService);
|
||||||
|
|
||||||
// CU-07-02: Write transient session state so `mosaic gateway install` can
|
|
||||||
// pick up mosaicHome without re-prompting.
|
|
||||||
writeInstallState(state.mosaicHome);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/queue",
|
"name": "@mosaicstack/queue",
|
||||||
"version": "0.0.4",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/storage",
|
"name": "@mosaicstack/storage",
|
||||||
"version": "0.0.4",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||||
@@ -23,8 +23,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electric-sql/pglite": "^0.2.17",
|
"@electric-sql/pglite": "^0.2.17",
|
||||||
"@mosaicstack/db": "workspace:^",
|
"@mosaicstack/db": "workspace:^",
|
||||||
"@mosaicstack/types": "workspace:*",
|
"@mosaicstack/types": "workspace:*"
|
||||||
"commander": "^13.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { registerStorageCommand } from './cli.js';
|
|
||||||
|
|
||||||
describe('registerStorageCommand', () => {
|
|
||||||
function buildProgram(): Command {
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride(); // prevent process.exit in tests
|
|
||||||
registerStorageCommand(program);
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('registers a "storage" command on the parent', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const storageCmd = program.commands.find((c) => c.name() === 'storage');
|
|
||||||
expect(storageCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "storage status" subcommand', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
|
|
||||||
const statusCmd = storageCmd.commands.find((c) => c.name() === 'status');
|
|
||||||
expect(statusCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "storage tier" subcommand group', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
|
|
||||||
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier');
|
|
||||||
expect(tierCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "storage tier show" subcommand', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
|
|
||||||
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
|
|
||||||
const showCmd = tierCmd.commands.find((c) => c.name() === 'show');
|
|
||||||
expect(showCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "storage tier switch" subcommand', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
|
|
||||||
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
|
|
||||||
const switchCmd = tierCmd.commands.find((c) => c.name() === 'switch');
|
|
||||||
expect(switchCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "storage export" subcommand', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
|
|
||||||
const exportCmd = storageCmd.commands.find((c) => c.name() === 'export');
|
|
||||||
expect(exportCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "storage import" subcommand', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
|
|
||||||
const importCmd = storageCmd.commands.find((c) => c.name() === 'import');
|
|
||||||
expect(importCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers "storage migrate" subcommand', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
|
|
||||||
const migrateCmd = storageCmd.commands.find((c) => c.name() === 'migrate');
|
|
||||||
expect(migrateCmd).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has all required subcommands in a single assertion', () => {
|
|
||||||
const program = buildProgram();
|
|
||||||
const storageCmd = program.commands.find((c) => c.name() === 'storage')!;
|
|
||||||
const topLevel = storageCmd.commands.map((c) => c.name());
|
|
||||||
expect(topLevel).toContain('status');
|
|
||||||
expect(topLevel).toContain('tier');
|
|
||||||
expect(topLevel).toContain('export');
|
|
||||||
expect(topLevel).toContain('import');
|
|
||||||
expect(topLevel).toContain('migrate');
|
|
||||||
|
|
||||||
const tierCmd = storageCmd.commands.find((c) => c.name() === 'tier')!;
|
|
||||||
const tierSubcmds = tierCmd.commands.map((c) => c.name());
|
|
||||||
expect(tierSubcmds).toContain('show');
|
|
||||||
expect(tierSubcmds).toContain('switch');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
import type { Command } from 'commander';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the DATABASE_URL environment variable and redacts the password portion.
|
|
||||||
*/
|
|
||||||
function redactedConnectionString(): string | null {
|
|
||||||
const url = process.env['DATABASE_URL'];
|
|
||||||
if (!url) return null;
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
if (parsed.password) {
|
|
||||||
parsed.password = '***';
|
|
||||||
}
|
|
||||||
return parsed.toString();
|
|
||||||
} catch {
|
|
||||||
// Not a valid URL — redact anything that looks like :password@
|
|
||||||
return url.replace(/:([^@/]+)@/, ':***@');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the active storage tier from the environment.
|
|
||||||
* Looks at DATABASE_URL; if absent or set to a pglite path, treats tier as pglite.
|
|
||||||
*/
|
|
||||||
function activeTier(): 'postgres' | 'pglite' {
|
|
||||||
const url = process.env['DATABASE_URL'];
|
|
||||||
if (url && url.startsWith('postgres')) return 'postgres';
|
|
||||||
return 'pglite';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a human-readable config source description.
|
|
||||||
*/
|
|
||||||
function configSource(): string {
|
|
||||||
if (process.env['DATABASE_URL']) return 'env:DATABASE_URL';
|
|
||||||
const pgliteDir = process.env['PGLITE_DATA_DIR'];
|
|
||||||
if (pgliteDir) return `env:PGLITE_DATA_DIR (${pgliteDir})`;
|
|
||||||
return 'default (no DATABASE_URL set)';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register storage subcommands on an existing Commander program.
|
|
||||||
* Follows the registerQualityRails pattern — uses the caller's Command
|
|
||||||
* instance to avoid cross-package Commander version mismatches.
|
|
||||||
*/
|
|
||||||
export function registerStorageCommand(parent: Command): void {
|
|
||||||
const storage = parent
|
|
||||||
.command('storage')
|
|
||||||
.description('Inspect and manage Mosaic storage configuration');
|
|
||||||
|
|
||||||
// ── storage status ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
storage
|
|
||||||
.command('status')
|
|
||||||
.description('Show the configured storage tier and whether the adapter is reachable')
|
|
||||||
.action(async () => {
|
|
||||||
const tier = activeTier();
|
|
||||||
const source = configSource();
|
|
||||||
const connStr = tier === 'postgres' ? redactedConnectionString() : null;
|
|
||||||
|
|
||||||
console.log(`[storage] tier: ${tier}`);
|
|
||||||
console.log(`[storage] config source: ${source}`);
|
|
||||||
|
|
||||||
if (tier === 'postgres' && connStr) {
|
|
||||||
console.log(`[storage] connection: ${connStr}`);
|
|
||||||
try {
|
|
||||||
const { createDb, sql } = await import('@mosaicstack/db');
|
|
||||||
const url = process.env['DATABASE_URL'] ?? '';
|
|
||||||
const handle = createDb(url);
|
|
||||||
await handle.db.execute(sql`SELECT 1`);
|
|
||||||
await handle.close();
|
|
||||||
console.log('[storage] reachable: yes');
|
|
||||||
} catch (err) {
|
|
||||||
console.log(
|
|
||||||
`[storage] reachable: no (${err instanceof Error ? err.message : String(err)})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const dataDir = process.env['PGLITE_DATA_DIR'] ?? ':memory:';
|
|
||||||
console.log(`[storage] data dir: ${dataDir}`);
|
|
||||||
console.log('[storage] reachable: pglite is always local — no network check needed');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── storage tier ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const tier = storage.command('tier').description('Inspect or switch the storage tier');
|
|
||||||
|
|
||||||
tier
|
|
||||||
.command('show')
|
|
||||||
.description('Print the active storage tier and its config source')
|
|
||||||
.action(() => {
|
|
||||||
const activeTierValue = activeTier();
|
|
||||||
const source = configSource();
|
|
||||||
console.log(`[storage] active tier: ${activeTierValue}`);
|
|
||||||
console.log(`[storage] config source: ${source}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
tier
|
|
||||||
.command('switch <tier>')
|
|
||||||
.description('Switch storage tier between pglite and postgres')
|
|
||||||
.action((newTier: string) => {
|
|
||||||
const validTiers = ['pglite', 'postgres'];
|
|
||||||
if (!validTiers.includes(newTier)) {
|
|
||||||
console.error(
|
|
||||||
`[storage] unknown tier: ${newTier}. Valid options: ${validTiers.join(', ')}`,
|
|
||||||
);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[storage] tier switch requested: ${newTier}`);
|
|
||||||
console.log('');
|
|
||||||
console.log('Mosaic storage tier is controlled by environment variables.');
|
|
||||||
console.log('Automatic config-file mutation is not supported — set the variable manually.');
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
if (newTier === 'postgres') {
|
|
||||||
console.log('To switch to postgres:');
|
|
||||||
console.log(' 1. Set DATABASE_URL in your environment or .env file:');
|
|
||||||
console.log(' export DATABASE_URL="postgresql://user:pass@localhost:5432/mosaic"');
|
|
||||||
console.log(' 2. Run migrations:');
|
|
||||||
console.log(' pnpm --filter @mosaicstack/db db:migrate');
|
|
||||||
console.log(' 3. Restart the gateway.');
|
|
||||||
} else {
|
|
||||||
console.log('To switch to pglite:');
|
|
||||||
console.log(' 1. Unset DATABASE_URL (or set it to a pglite path):');
|
|
||||||
console.log(' unset DATABASE_URL');
|
|
||||||
console.log(' # optionally: export PGLITE_DATA_DIR=/path/to/pglite/data');
|
|
||||||
console.log(' 2. Restart the gateway.');
|
|
||||||
console.log(' Note: pglite uses an in-process database — no migrations needed.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── storage export ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
storage
|
|
||||||
.command('export <path>')
|
|
||||||
.description('Dump the active storage contents to a file')
|
|
||||||
.action((outputPath: string) => {
|
|
||||||
const currentTier = activeTier();
|
|
||||||
|
|
||||||
if (currentTier === 'postgres') {
|
|
||||||
const redacted = redactedConnectionString() ?? '<DATABASE_URL>';
|
|
||||||
console.log('[storage] export for postgres tier');
|
|
||||||
console.log('');
|
|
||||||
console.log('postgres export is not yet wired in the CLI — use pg_dump directly:');
|
|
||||||
console.log('');
|
|
||||||
console.log(` pg_dump "${redacted}" > ${outputPath}`);
|
|
||||||
console.log('');
|
|
||||||
console.log('Or with Docker:');
|
|
||||||
console.log(
|
|
||||||
` docker exec <postgres-container> pg_dump -U <user> <dbname> > ${outputPath}`,
|
|
||||||
);
|
|
||||||
process.exitCode = 0;
|
|
||||||
} else {
|
|
||||||
const dataDir = process.env['PGLITE_DATA_DIR'];
|
|
||||||
console.log('[storage] export for pglite tier');
|
|
||||||
console.log('');
|
|
||||||
console.log(
|
|
||||||
'pglite export is not yet wired in the CLI — copy the data directory directly:',
|
|
||||||
);
|
|
||||||
console.log('');
|
|
||||||
if (dataDir) {
|
|
||||||
console.log(` cp -r ${dataDir} ${outputPath}`);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
' PGLITE_DATA_DIR is not set; the database is in-memory and cannot be exported.',
|
|
||||||
);
|
|
||||||
console.log(' Set PGLITE_DATA_DIR to a persistent path before running export.');
|
|
||||||
}
|
|
||||||
process.exitCode = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── storage import ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
storage
|
|
||||||
.command('import <path>')
|
|
||||||
.description('Restore storage contents from a previously exported file')
|
|
||||||
.action((inputPath: string) => {
|
|
||||||
const currentTier = activeTier();
|
|
||||||
|
|
||||||
if (currentTier === 'postgres') {
|
|
||||||
const redacted = redactedConnectionString() ?? '<DATABASE_URL>';
|
|
||||||
console.log('[storage] import for postgres tier');
|
|
||||||
console.log('');
|
|
||||||
console.log('postgres import is not yet wired in the CLI — use psql directly:');
|
|
||||||
console.log('');
|
|
||||||
console.log(` psql "${redacted}" < ${inputPath}`);
|
|
||||||
process.exitCode = 0;
|
|
||||||
} else {
|
|
||||||
const dataDir = process.env['PGLITE_DATA_DIR'];
|
|
||||||
console.log('[storage] import for pglite tier');
|
|
||||||
console.log('');
|
|
||||||
console.log(
|
|
||||||
'pglite import is not yet wired in the CLI — restore the data directory directly:',
|
|
||||||
);
|
|
||||||
console.log('');
|
|
||||||
if (dataDir) {
|
|
||||||
console.log(` rm -rf ${dataDir} && cp -r ${inputPath} ${dataDir}`);
|
|
||||||
console.log(' Then restart the gateway.');
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
' PGLITE_DATA_DIR is not set; set it to a persistent path before running import.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
process.exitCode = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── storage migrate ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
storage
|
|
||||||
.command('migrate')
|
|
||||||
.description(
|
|
||||||
'Run database migrations (thin wrapper — delegates to pnpm db:migrate or prints the command)',
|
|
||||||
)
|
|
||||||
.option('--run', 'Actually execute the migration command via shell')
|
|
||||||
.action(async (opts: { run?: boolean }) => {
|
|
||||||
const currentTier = activeTier();
|
|
||||||
|
|
||||||
if (currentTier === 'pglite') {
|
|
||||||
console.log('[storage] pglite tier detected');
|
|
||||||
console.log(
|
|
||||||
'pglite runs schema setup automatically on first connection via adapter.migrate().',
|
|
||||||
);
|
|
||||||
console.log('No separate migration step is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const migrateCmd = 'pnpm --filter @mosaicstack/db db:migrate';
|
|
||||||
console.log('[storage] postgres tier detected');
|
|
||||||
console.log(`Migration command: ${migrateCmd}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
if (opts.run) {
|
|
||||||
console.log('Running migrations...');
|
|
||||||
const { execSync } = await import('node:child_process');
|
|
||||||
try {
|
|
||||||
execSync(migrateCmd, { stdio: 'inherit' });
|
|
||||||
console.log('[storage] migrations complete.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`[storage] migration failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('To run migrations, execute:');
|
|
||||||
console.log(` ${migrateCmd}`);
|
|
||||||
console.log('');
|
|
||||||
console.log('Or pass --run to have this command execute it for you.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ export type { StorageAdapter, StorageConfig } from './types.js';
|
|||||||
export { createStorageAdapter, registerStorageAdapter } from './factory.js';
|
export { createStorageAdapter, registerStorageAdapter } from './factory.js';
|
||||||
export { PostgresAdapter } from './adapters/postgres.js';
|
export { PostgresAdapter } from './adapters/postgres.js';
|
||||||
export { PgliteAdapter } from './adapters/pglite.js';
|
export { PgliteAdapter } from './adapters/pglite.js';
|
||||||
export { registerStorageCommand } from './cli.js';
|
|
||||||
|
|
||||||
import { registerStorageAdapter } from './factory.js';
|
import { registerStorageAdapter } from './factory.js';
|
||||||
import { PostgresAdapter } from './adapters/postgres.js';
|
import { PostgresAdapter } from './adapters/postgres.js';
|
||||||
|
|||||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -385,9 +385,6 @@ importers:
|
|||||||
'@mosaicstack/macp':
|
'@mosaicstack/macp':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../macp
|
version: link:../macp
|
||||||
commander:
|
|
||||||
specifier: ^13.0.0
|
|
||||||
version: 13.1.0
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
@@ -407,9 +404,6 @@ importers:
|
|||||||
'@mosaicstack/db':
|
'@mosaicstack/db':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../db
|
version: link:../db
|
||||||
commander:
|
|
||||||
specifier: ^13.0.0
|
|
||||||
version: 13.1.0
|
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.45.1
|
specifier: ^0.45.1
|
||||||
version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
|
version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
|
||||||
@@ -422,10 +416,6 @@ importers:
|
|||||||
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||||
|
|
||||||
packages/macp:
|
packages/macp:
|
||||||
dependencies:
|
|
||||||
commander:
|
|
||||||
specifier: ^13.0.0
|
|
||||||
version: 13.1.0
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
@@ -479,9 +469,6 @@ importers:
|
|||||||
'@mosaicstack/forge':
|
'@mosaicstack/forge':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../forge
|
version: link:../forge
|
||||||
'@mosaicstack/log':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../log
|
|
||||||
'@mosaicstack/macp':
|
'@mosaicstack/macp':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../macp
|
version: link:../macp
|
||||||
@@ -497,9 +484,6 @@ importers:
|
|||||||
'@mosaicstack/queue':
|
'@mosaicstack/queue':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../queue
|
version: link:../queue
|
||||||
'@mosaicstack/storage':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../storage
|
|
||||||
'@mosaicstack/types':
|
'@mosaicstack/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../types
|
version: link:../types
|
||||||
@@ -621,9 +605,6 @@ importers:
|
|||||||
'@mosaicstack/types':
|
'@mosaicstack/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../types
|
version: link:../types
|
||||||
commander:
|
|
||||||
specifier: ^13.0.0
|
|
||||||
version: 13.1.0
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ─── Mosaic Stack — End-to-End Install Test ────────────────────────────────────
|
|
||||||
#
|
|
||||||
# Runs a clean-container install test to verify the full first-run flow:
|
|
||||||
# tools/install.sh -> mosaic wizard (non-interactive)
|
|
||||||
# -> mosaic gateway install
|
|
||||||
# -> mosaic gateway verify
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash tools/e2e-install-test.sh
|
|
||||||
#
|
|
||||||
# Requirements:
|
|
||||||
# - Docker (skips gracefully if not available)
|
|
||||||
# - Run from the repository root
|
|
||||||
#
|
|
||||||
# How it works:
|
|
||||||
# 1. Mounts the repository into a node:22-alpine container.
|
|
||||||
# 2. Installs prerequisites (bash, curl, jq, git) inside the container.
|
|
||||||
# 3. Runs `bash tools/install.sh --yes --no-auto-launch` to install the
|
|
||||||
# framework and CLI from the Gitea registry.
|
|
||||||
# 4. Runs `mosaic wizard --non-interactive` to set up SOUL/USER.
|
|
||||||
# 5. Runs `mosaic gateway install` with piped defaults (non-interactive).
|
|
||||||
# 6. Runs `mosaic gateway verify` and checks its exit code.
|
|
||||||
# NOTE: `mosaic gateway verify` is a new command added in the
|
|
||||||
# feat/mosaic-first-run-ux branch. If the installed CLI version
|
|
||||||
# pre-dates this branch (does not have `gateway verify`), the test
|
|
||||||
# marks this step as EXPECTED-SKIP and reports the installed version.
|
|
||||||
# 7. Reports PASS or FAIL with a summary.
|
|
||||||
#
|
|
||||||
# To run manually:
|
|
||||||
# cd /path/to/mosaic-stack
|
|
||||||
# bash tools/e2e-install-test.sh
|
|
||||||
#
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
IMAGE="node:22-alpine"
|
|
||||||
CONTAINER_NAME="mosaic-e2e-install-$$"
|
|
||||||
|
|
||||||
# ─── Colour helpers ───────────────────────────────────────────────────────────
|
|
||||||
if [[ -t 1 ]]; then
|
|
||||||
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' BOLD=$'\033[1m' RESET=$'\033[0m'
|
|
||||||
else
|
|
||||||
R="" G="" Y="" BOLD="" RESET=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
info() { echo "${BOLD}[e2e]${RESET} $*"; }
|
|
||||||
ok() { echo "${G}[PASS]${RESET} $*"; }
|
|
||||||
fail() { echo "${R}[FAIL]${RESET} $*" >&2; }
|
|
||||||
warn() { echo "${Y}[WARN]${RESET} $*"; }
|
|
||||||
|
|
||||||
# ─── Docker availability check ────────────────────────────────────────────────
|
|
||||||
if ! command -v docker &>/dev/null; then
|
|
||||||
warn "Docker not found — skipping e2e install test."
|
|
||||||
warn "Install Docker and re-run this script to exercise the full install flow."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! docker info &>/dev/null 2>&1; then
|
|
||||||
warn "Docker daemon is not running or not accessible — skipping e2e install test."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Docker available — proceeding with e2e install test."
|
|
||||||
info "Repo root: ${REPO_ROOT}"
|
|
||||||
info "Container image: ${IMAGE}"
|
|
||||||
|
|
||||||
# ─── Inline script that runs INSIDE the container ────────────────────────────
|
|
||||||
INNER_SCRIPT="$(mktemp /tmp/mosaic-e2e-inner-XXXXXX.sh)"
|
|
||||||
trap 'rm -f "$INNER_SCRIPT"' EXIT
|
|
||||||
|
|
||||||
cat > "$INNER_SCRIPT" <<'INNER_SCRIPT_EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
# Bootstrap: /bin/sh until bash is installed, then re-exec.
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== [inner] Installing system prerequisites ==="
|
|
||||||
apk add --no-cache bash curl jq git 2>/dev/null || \
|
|
||||||
apt-get install -y -q bash curl jq git 2>/dev/null || true
|
|
||||||
|
|
||||||
# Re-exec under bash.
|
|
||||||
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1; then
|
|
||||||
exec bash "$0" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── bash from here ────────────────────────────────────────────────────────────
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "=== [inner] Node.js / npm versions ==="
|
|
||||||
node --version
|
|
||||||
npm --version
|
|
||||||
|
|
||||||
echo "=== [inner] Setting up npm global prefix ==="
|
|
||||||
export NPM_PREFIX="/root/.npm-global"
|
|
||||||
mkdir -p "$NPM_PREFIX/bin"
|
|
||||||
npm config set prefix "$NPM_PREFIX" 2>/dev/null || true
|
|
||||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
|
||||||
|
|
||||||
echo "=== [inner] Running install.sh --yes --no-auto-launch ==="
|
|
||||||
# Install both framework and CLI from the Gitea registry.
|
|
||||||
MOSAIC_SKIP_SKILLS_SYNC=1 \
|
|
||||||
MOSAIC_ASSUME_YES=1 \
|
|
||||||
bash /repo/tools/install.sh --yes --no-auto-launch
|
|
||||||
|
|
||||||
INSTALLED_VERSION="$(mosaic --version 2>/dev/null || echo 'unknown')"
|
|
||||||
echo "[inner] mosaic CLI installed: ${INSTALLED_VERSION}"
|
|
||||||
|
|
||||||
echo "=== [inner] Running mosaic wizard (non-interactive) ==="
|
|
||||||
mosaic wizard \
|
|
||||||
--non-interactive \
|
|
||||||
--name "test-agent" \
|
|
||||||
--user-name "tester" \
|
|
||||||
--pronouns "they/them" \
|
|
||||||
--timezone "UTC" || {
|
|
||||||
echo "[WARN] mosaic wizard exited non-zero — continuing"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "=== [inner] Running mosaic gateway install ==="
|
|
||||||
# Feed non-interactive answers:
|
|
||||||
# "1" → storage tier: local
|
|
||||||
# "" → port: accept default (14242)
|
|
||||||
# "" → ANTHROPIC_API_KEY: skip
|
|
||||||
# "" → CORS origin: accept default
|
|
||||||
# Then admin bootstrap: name, email, password
|
|
||||||
printf '1\n\n\n\nTest Admin\ntest@example.com\ntestpassword123\n' \
|
|
||||||
| mosaic gateway install
|
|
||||||
INSTALL_EXIT="$?"
|
|
||||||
if [ "${INSTALL_EXIT}" -ne 0 ]; then
|
|
||||||
echo "[ERR] mosaic gateway install exited ${INSTALL_EXIT}"
|
|
||||||
mosaic gateway status 2>/dev/null || true
|
|
||||||
exit "${INSTALL_EXIT}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== [inner] Running mosaic gateway verify ==="
|
|
||||||
# `gateway verify` was added in feat/mosaic-first-run-ux.
|
|
||||||
# If the installed version pre-dates this, skip gracefully.
|
|
||||||
if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then
|
|
||||||
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
|
|
||||||
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
|
|
||||||
echo "[SKIP] Re-run after the new version is published to validate this step."
|
|
||||||
# Treat as pass — the install flow itself worked.
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
mosaic gateway verify
|
|
||||||
VERIFY_EXIT="$?"
|
|
||||||
echo "=== [inner] verify exit code: ${VERIFY_EXIT} ==="
|
|
||||||
exit "${VERIFY_EXIT}"
|
|
||||||
INNER_SCRIPT_EOF
|
|
||||||
|
|
||||||
chmod +x "$INNER_SCRIPT"
|
|
||||||
|
|
||||||
# ─── Pull image ───────────────────────────────────────────────────────────────
|
|
||||||
info "Pulling ${IMAGE}…"
|
|
||||||
docker pull "${IMAGE}" --quiet
|
|
||||||
|
|
||||||
# ─── Run container ────────────────────────────────────────────────────────────
|
|
||||||
info "Starting container ${CONTAINER_NAME}…"
|
|
||||||
|
|
||||||
EXIT_CODE=0
|
|
||||||
docker run --rm \
|
|
||||||
--name "${CONTAINER_NAME}" \
|
|
||||||
--volume "${REPO_ROOT}:/repo:ro" \
|
|
||||||
--volume "${INNER_SCRIPT}:/e2e-inner.sh:ro" \
|
|
||||||
--network host \
|
|
||||||
"${IMAGE}" \
|
|
||||||
/bin/sh /e2e-inner.sh \
|
|
||||||
|| EXIT_CODE=$?
|
|
||||||
|
|
||||||
# ─── Report ───────────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
if [[ "$EXIT_CODE" -eq 0 ]]; then
|
|
||||||
ok "End-to-end install test PASSED (exit ${EXIT_CODE})"
|
|
||||||
else
|
|
||||||
fail "End-to-end install test FAILED (exit ${EXIT_CODE})"
|
|
||||||
echo ""
|
|
||||||
echo " Troubleshooting:"
|
|
||||||
echo " - Review the output above for the failing step."
|
|
||||||
echo " - Re-run with bash -x tools/e2e-install-test.sh for verbose trace."
|
|
||||||
echo " - Run mosaic gateway logs inside a manual container for daemon output."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -12,21 +12,18 @@
|
|||||||
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh | bash -s --
|
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh | bash -s --
|
||||||
#
|
#
|
||||||
# Flags:
|
# Flags:
|
||||||
# --check Version check only, no install
|
# --check Version check only, no install
|
||||||
# --framework Install/upgrade framework only (skip npm CLI)
|
# --framework Install/upgrade framework only (skip npm CLI)
|
||||||
# --cli Install/upgrade npm CLI only (skip framework)
|
# --cli Install/upgrade npm CLI only (skip framework)
|
||||||
# --ref <branch> Git ref for framework archive (default: main)
|
# --ref <branch> Git ref for framework archive (default: main)
|
||||||
# --yes Accept all defaults; headless/non-interactive install
|
|
||||||
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
|
|
||||||
#
|
#
|
||||||
# Environment:
|
# Environment:
|
||||||
# MOSAIC_HOME — framework install dir (default: ~/.config/mosaic)
|
# MOSAIC_HOME — framework install dir (default: ~/.config/mosaic)
|
||||||
# MOSAIC_REGISTRY — npm registry URL (default: Gitea instance)
|
# MOSAIC_REGISTRY — npm registry URL (default: Gitea instance)
|
||||||
# MOSAIC_SCOPE — npm scope (default: @mosaicstack)
|
# MOSAIC_SCOPE — npm scope (default: @mosaicstack)
|
||||||
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
|
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
|
||||||
# MOSAIC_NO_COLOR — disable colour (set to 1)
|
# MOSAIC_NO_COLOR — disable colour (set to 1)
|
||||||
# MOSAIC_REF — git ref for framework (default: main)
|
# MOSAIC_REF — git ref for framework (default: main)
|
||||||
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Wrapped in main() for safe curl-pipe usage.
|
# Wrapped in main() for safe curl-pipe usage.
|
||||||
@@ -39,24 +36,15 @@ main() {
|
|||||||
FLAG_CHECK=false
|
FLAG_CHECK=false
|
||||||
FLAG_FRAMEWORK=true
|
FLAG_FRAMEWORK=true
|
||||||
FLAG_CLI=true
|
FLAG_CLI=true
|
||||||
FLAG_NO_AUTO_LAUNCH=false
|
|
||||||
FLAG_YES=false
|
|
||||||
GIT_REF="${MOSAIC_REF:-main}"
|
GIT_REF="${MOSAIC_REF:-main}"
|
||||||
|
|
||||||
# MOSAIC_ASSUME_YES env var acts the same as --yes
|
|
||||||
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
|
|
||||||
FLAG_YES=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--check) FLAG_CHECK=true; shift ;;
|
--check) FLAG_CHECK=true; shift ;;
|
||||||
--framework) FLAG_CLI=false; shift ;;
|
--framework) FLAG_CLI=false; shift ;;
|
||||||
--cli) FLAG_FRAMEWORK=false; shift ;;
|
--cli) FLAG_FRAMEWORK=false; shift ;;
|
||||||
--ref) GIT_REF="${2:-main}"; shift 2 ;;
|
--ref) GIT_REF="${2:-main}"; shift 2 ;;
|
||||||
--yes|-y) FLAG_YES=true; shift ;;
|
*) shift ;;
|
||||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
|
||||||
*) shift ;;
|
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -313,49 +301,12 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
dim " Framework data: $MOSAIC_HOME/"
|
dim " Framework data: $MOSAIC_HOME/"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# First install guidance / auto-launch
|
# First install guidance
|
||||||
if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
|
if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
if [[ "$FLAG_NO_AUTO_LAUNCH" == "false" ]] && [[ -t 0 ]] && [[ -t 1 ]]; then
|
info "First install detected. Set up your agent identity:"
|
||||||
# Interactive TTY and auto-launch not suppressed: run wizard + gateway install
|
echo " ${C}mosaic init${RESET} (interactive SOUL.md / USER.md setup)"
|
||||||
info "First install detected — launching setup wizard…"
|
echo " ${C}mosaic wizard${RESET} (full guided wizard via Node.js)"
|
||||||
echo ""
|
|
||||||
|
|
||||||
MOSAIC_BIN="$PREFIX/bin/mosaic"
|
|
||||||
|
|
||||||
if ! command -v "$MOSAIC_BIN" &>/dev/null && ! command -v mosaic &>/dev/null; then
|
|
||||||
warn "mosaic binary not found on PATH — skipping auto-launch."
|
|
||||||
warn "Add $PREFIX/bin to PATH and run: mosaic wizard && mosaic gateway install"
|
|
||||||
else
|
|
||||||
# Prefer the absolute path from the prefix we just installed to
|
|
||||||
MOSAIC_CMD="mosaic"
|
|
||||||
if [[ -x "$MOSAIC_BIN" ]]; then
|
|
||||||
MOSAIC_CMD="$MOSAIC_BIN"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run wizard; if it fails we still try gateway install (best effort)
|
|
||||||
if "$MOSAIC_CMD" wizard; then
|
|
||||||
ok "Wizard complete."
|
|
||||||
else
|
|
||||||
warn "Wizard exited non-zero — continuing to gateway install."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
info "Launching gateway install…"
|
|
||||||
if "$MOSAIC_CMD" gateway install; then
|
|
||||||
ok "Gateway install complete."
|
|
||||||
else
|
|
||||||
warn "Gateway install exited non-zero."
|
|
||||||
echo " You can retry with: ${C}mosaic gateway install${RESET}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Non-interactive or --no-auto-launch: print guidance only
|
|
||||||
info "First install detected. Set up your agent identity:"
|
|
||||||
echo " ${C}mosaic init${RESET} (interactive SOUL.md / USER.md setup)"
|
|
||||||
echo " ${C}mosaic wizard${RESET} (full guided wizard via Node.js)"
|
|
||||||
echo " ${C}mosaic gateway install${RESET} (install and start the gateway)"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
Reference in New Issue
Block a user