chore(api): tranche C parity burn-down and CLI integration scaffolding
This commit is contained in:
parent
69e89a97a1
commit
7b104952cc
|
|
@ -30,6 +30,9 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: API contract parity
|
||||||
|
run: pnpm api:parity
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Mission Control CLI for Agent-Complete Operations (v1 scaffold)
|
||||||
|
|
||||||
|
This repository now includes a first-party CLI scaffold at:
|
||||||
|
|
||||||
|
- scripts/mc-cli.cjs
|
||||||
|
|
||||||
|
It is designed for autonomous/headless usage first:
|
||||||
|
- API key auth support
|
||||||
|
- profile persistence (~/.mission-control/profiles/*.json)
|
||||||
|
- stable JSON mode (`--json`)
|
||||||
|
- deterministic exit code categories
|
||||||
|
- command groups mapped to Mission Control API resources
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1) Ensure Mission Control API is running.
|
||||||
|
2) Set environment variables or use profile flags:
|
||||||
|
|
||||||
|
- MC_URL=http://127.0.0.1:3000
|
||||||
|
- MC_API_KEY=your-key
|
||||||
|
|
||||||
|
3) Run commands:
|
||||||
|
|
||||||
|
node scripts/mc-cli.cjs agents list --json
|
||||||
|
node scripts/mc-cli.cjs tasks queue --agent Aegis --max-capacity 2 --json
|
||||||
|
node scripts/mc-cli.cjs sessions control --id <session-id> --action terminate
|
||||||
|
|
||||||
|
## Supported groups in scaffold
|
||||||
|
|
||||||
|
- auth: login, logout, whoami
|
||||||
|
- agents: list/get/create/update/delete/wake/diagnostics/heartbeat
|
||||||
|
- tasks: list/get/create/update/delete/queue
|
||||||
|
- sessions: list/control/continue
|
||||||
|
- connect: register/list/disconnect
|
||||||
|
- tokens: list/stats/by-agent
|
||||||
|
- skills: list/content/check/upsert/delete
|
||||||
|
- cron: list/create/update/pause/resume/remove/run
|
||||||
|
- events: watch (basic HTTP fallback)
|
||||||
|
- raw: generic request passthrough
|
||||||
|
|
||||||
|
## Exit code contract
|
||||||
|
|
||||||
|
- 0 success
|
||||||
|
- 2 usage error
|
||||||
|
- 3 auth error (401)
|
||||||
|
- 4 permission error (403)
|
||||||
|
- 5 network/timeout
|
||||||
|
- 6 server error (5xx)
|
||||||
|
|
||||||
|
## API contract parity gate
|
||||||
|
|
||||||
|
To detect drift between Next.js route handlers and openapi.json, use:
|
||||||
|
|
||||||
|
node scripts/check-api-contract-parity.mjs \
|
||||||
|
--root . \
|
||||||
|
--openapi openapi.json \
|
||||||
|
--ignore-file scripts/api-contract-parity.ignore
|
||||||
|
|
||||||
|
Machine output:
|
||||||
|
|
||||||
|
node scripts/check-api-contract-parity.mjs --json
|
||||||
|
|
||||||
|
The checker scans `src/app/api/**/route.ts(x)`, derives operations (METHOD + /api/path), compares against OpenAPI operations, and exits non-zero on mismatch.
|
||||||
|
|
||||||
|
Baseline policy in this repo:
|
||||||
|
- `scripts/api-contract-parity.ignore` currently stores a temporary baseline of known drift.
|
||||||
|
- CI enforces no regressions beyond baseline.
|
||||||
|
- When you fix a mismatch, remove its line from ignore file in the same PR.
|
||||||
|
- Goal is monotonic burn-down to an empty ignore file.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
- Promote scripts to package.json scripts (`mc`, `api:parity`).
|
||||||
|
- Add retry/backoff and SSE stream mode for `events watch`.
|
||||||
|
- Add richer pagination/filter UX and CSV export for reporting commands.
|
||||||
|
- Add integration tests that run the CLI against a test server fixture.
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
# Mission Control Platform Hardening + Full Agent CLI/TUI PRD
|
||||||
|
|
||||||
|
> For Hermes: execute this plan in iterative vertical slices (contract parity -> CLI core -> TUI -> hardening), with tests at each slice.
|
||||||
|
|
||||||
|
Goal
|
||||||
|
Build a production-grade Mission Control operator surface for autonomous agents via a first-party CLI (and optional lightweight TUI), while fixing platform inconsistencies discovered in audit: API contract drift, uneven reliability controls, and incomplete automation ergonomics.
|
||||||
|
|
||||||
|
Architecture
|
||||||
|
Mission Control remains the source of truth with REST + SSE endpoints. A first-party CLI consumes those APIs with profile-based auth and machine-friendly output. TUI is layered on top of CLI API client primitives for shared behavior. API contract reliability is enforced through route-to-spec parity checks in CI.
|
||||||
|
|
||||||
|
Tech Stack
|
||||||
|
- Existing: Next.js app-router API, SQLite, Node runtime, SSE
|
||||||
|
- New: Node CLI runtime (no heavy deps required for v1), optional TUI in terminal ANSI mode
|
||||||
|
- Testing: existing Playwright/Vitest patterns + CLI smoke tests + OpenAPI parity checks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Problem statement
|
||||||
|
|
||||||
|
Current Mission Control backend has strong capabilities for agent orchestration, but external automation quality is constrained by:
|
||||||
|
1. API surface drift between route handlers, openapi.json, and /api/index.
|
||||||
|
2. No first-party comprehensive CLI for operators/agents.
|
||||||
|
3. Uneven hardening around operational concerns (auth posture defaults, multi-instance rate limiting strategy, spawn history durability).
|
||||||
|
4. Incomplete UX for non-interactive agent workflows (idempotent commands, stable JSON output, strict exit codes).
|
||||||
|
|
||||||
|
Result: agents can use Mission Control partially, but not yet with high confidence as a full control plane.
|
||||||
|
|
||||||
|
## 2) Product objectives
|
||||||
|
|
||||||
|
Primary objectives
|
||||||
|
1. Deliver a first-party CLI with functional parity across core agent workflows.
|
||||||
|
2. Add optional TUI for rapid situational awareness and interactive operations.
|
||||||
|
3. Establish API contract parity as an enforceable quality gate.
|
||||||
|
4. Improve reliability and security defaults for autonomous operation.
|
||||||
|
|
||||||
|
Success criteria
|
||||||
|
- 95%+ of documented operator workflows executable via CLI without web UI.
|
||||||
|
- Contract parity CI gate blocks drift between route handlers and OpenAPI.
|
||||||
|
- CLI supports machine mode: stable JSON schemas and deterministic exit codes.
|
||||||
|
- TUI can monitor and trigger core actions (agents/tasks/sessions/events).
|
||||||
|
|
||||||
|
Non-goals (v1)
|
||||||
|
- Replacing the web UI.
|
||||||
|
- Building an advanced ncurses framework dependency stack if not needed.
|
||||||
|
- Supporting all historical/legacy endpoint aliases immediately.
|
||||||
|
|
||||||
|
## 3) Personas and workflows
|
||||||
|
|
||||||
|
Personas
|
||||||
|
1. Autonomous agent runtime (headless, non-interactive).
|
||||||
|
2. Human operator (terminal-first incident response).
|
||||||
|
3. Platform maintainer (release and contract governance).
|
||||||
|
|
||||||
|
Critical workflows
|
||||||
|
- Poll task queue and claim work.
|
||||||
|
- Manage agents (register/update/diagnose/wake).
|
||||||
|
- Manage sessions (list/control/continue/transcript).
|
||||||
|
- Observe events in real time.
|
||||||
|
- Track token usage and attribution.
|
||||||
|
- Manage skills, cron jobs, and direct CLI connections.
|
||||||
|
|
||||||
|
## 4) Functional requirements
|
||||||
|
|
||||||
|
### A. API contract governance
|
||||||
|
- FR-A1: A parity checker must compare discovered route handlers and OpenAPI paths/methods.
|
||||||
|
- FR-A2: CI fails on non-ignored mismatches.
|
||||||
|
- FR-A3: Ignore list must be explicit and reviewable.
|
||||||
|
- FR-A4: /api/index should be validated or generated from same contract source.
|
||||||
|
|
||||||
|
### B. CLI v1 requirements
|
||||||
|
- FR-B1: Profile-based configuration (URL + auth mode + key/cookie).
|
||||||
|
- FR-B2: Commands must support --json output and strict exit codes.
|
||||||
|
- FR-B3: Support key domains:
|
||||||
|
- auth
|
||||||
|
- agents
|
||||||
|
- tasks
|
||||||
|
- sessions
|
||||||
|
- connect
|
||||||
|
- tokens
|
||||||
|
- skills
|
||||||
|
- cron
|
||||||
|
- events watch
|
||||||
|
- raw request fallback
|
||||||
|
- FR-B4: Non-interactive defaults suitable for autonomous agents.
|
||||||
|
- FR-B5: Request timeout + retry controls for reliable automation.
|
||||||
|
|
||||||
|
### C. TUI v1 requirements (optional but included)
|
||||||
|
- FR-C1: Dashboard with agents/tasks/sessions summary panels.
|
||||||
|
- FR-C2: Keyboard-driven refresh/navigation.
|
||||||
|
- FR-C3: Trigger key operations (wake agent, queue poll, session controls).
|
||||||
|
- FR-C4: Clear degraded mode messaging if endpoints unavailable.
|
||||||
|
|
||||||
|
### D. Platform hardening requirements
|
||||||
|
- FR-D1: Document and enforce least-privilege auth guidance for agent keys.
|
||||||
|
- FR-D2: Expose explicit warning/controls for global admin API key usage.
|
||||||
|
- FR-D3: Add durable spawn history persistence (DB-backed) replacing log scraping fallback.
|
||||||
|
- FR-D4: Add scalable rate-limit strategy plan (in-memory now, pluggable backend next).
|
||||||
|
|
||||||
|
## 5) CLI command map (v1)
|
||||||
|
|
||||||
|
mc auth
|
||||||
|
- login --username --password
|
||||||
|
- logout
|
||||||
|
- whoami
|
||||||
|
|
||||||
|
mc agents
|
||||||
|
- list
|
||||||
|
- get --id
|
||||||
|
- create --name --role
|
||||||
|
- update --id ...fields
|
||||||
|
- delete --id
|
||||||
|
- wake --id
|
||||||
|
- diagnostics --id
|
||||||
|
- heartbeat --id
|
||||||
|
- memory get|set --id
|
||||||
|
- soul get|set --id
|
||||||
|
|
||||||
|
mc tasks
|
||||||
|
- list [filters]
|
||||||
|
- get --id
|
||||||
|
- create --title [--description --priority --assigned-to]
|
||||||
|
- update --id ...fields
|
||||||
|
- delete --id
|
||||||
|
- queue --agent [--max-capacity]
|
||||||
|
- comments list/add --id
|
||||||
|
- broadcast --id
|
||||||
|
|
||||||
|
mc sessions
|
||||||
|
- list
|
||||||
|
- control --id --action monitor|pause|terminate
|
||||||
|
- continue --kind claude-code|codex-cli --id --prompt
|
||||||
|
- transcript --id [--source]
|
||||||
|
|
||||||
|
mc connect
|
||||||
|
- register --tool-name --agent-name [...]
|
||||||
|
- list
|
||||||
|
- disconnect --connection-id
|
||||||
|
|
||||||
|
mc tokens
|
||||||
|
- list
|
||||||
|
- stats
|
||||||
|
- by-agent [--days]
|
||||||
|
- export --format json|csv
|
||||||
|
|
||||||
|
mc skills
|
||||||
|
- list
|
||||||
|
- content --source --name
|
||||||
|
- upsert --source --name --file
|
||||||
|
- delete --source --name
|
||||||
|
- check --source --name
|
||||||
|
|
||||||
|
mc cron
|
||||||
|
- list
|
||||||
|
- create/update/pause/resume/remove/run
|
||||||
|
|
||||||
|
mc events
|
||||||
|
- watch [--types]
|
||||||
|
|
||||||
|
mc raw
|
||||||
|
- raw --method GET --path /api/... [--body '{}']
|
||||||
|
|
||||||
|
## 6) UX and interface requirements
|
||||||
|
|
||||||
|
- Default output must be concise human-readable; --json returns machine-stable payload.
|
||||||
|
- All non-2xx responses include normalized error object and non-zero exit.
|
||||||
|
- Exit code taxonomy:
|
||||||
|
- 0 success
|
||||||
|
- 2 usage error
|
||||||
|
- 3 auth error
|
||||||
|
- 4 permission error
|
||||||
|
- 5 network/timeout
|
||||||
|
- 6 server error
|
||||||
|
- Pagination/filter flags normalized across list commands.
|
||||||
|
|
||||||
|
## 7) Security requirements
|
||||||
|
|
||||||
|
- Do not log raw API keys or cookies.
|
||||||
|
- Redact sensitive headers in verbose/debug output.
|
||||||
|
- Provide per-profile auth scope awareness (viewer/operator/admin implied risk labeling).
|
||||||
|
- Strong guidance: prefer agent-scoped keys over global admin key.
|
||||||
|
|
||||||
|
## 8) Reliability requirements
|
||||||
|
|
||||||
|
- Configurable timeout/retry/backoff.
|
||||||
|
- Safe JSON parsing and clear error surfaces.
|
||||||
|
- SSE reconnection strategy for watch mode.
|
||||||
|
- Graceful handling for partial endpoint availability.
|
||||||
|
|
||||||
|
## 9) Testing strategy
|
||||||
|
|
||||||
|
Unit
|
||||||
|
- CLI arg parsing and request mapping.
|
||||||
|
- Output modes and exit codes.
|
||||||
|
- API parity checker route extraction and mismatch detection.
|
||||||
|
|
||||||
|
Integration
|
||||||
|
- CLI against local Mission Control test server.
|
||||||
|
- Auth modes (API key, login session where enabled).
|
||||||
|
- Session control, queue polling, skills CRUD.
|
||||||
|
|
||||||
|
E2E
|
||||||
|
- Playwright/terminal-driven smoke for critical command paths.
|
||||||
|
- TUI render and keyboard navigation smoke tests.
|
||||||
|
|
||||||
|
Contract tests
|
||||||
|
- OpenAPI parity check in CI.
|
||||||
|
- Optional index parity check in CI.
|
||||||
|
|
||||||
|
## 10) Rollout plan
|
||||||
|
|
||||||
|
Phase 0: Contract stabilization
|
||||||
|
- Add parity checker and fail CI on drift.
|
||||||
|
- Resolve existing mismatches.
|
||||||
|
|
||||||
|
Phase 1: CLI core
|
||||||
|
- Ship profile/auth client + core command groups (auth/agents/tasks/sessions/connect).
|
||||||
|
|
||||||
|
Phase 2: CLI expansion
|
||||||
|
- tokens/skills/cron/events/raw + transcript ergonomics.
|
||||||
|
|
||||||
|
Phase 3: TUI
|
||||||
|
- Live dashboard + action shortcuts.
|
||||||
|
|
||||||
|
Phase 4: Hardening
|
||||||
|
- durable spawn history
|
||||||
|
- auth warnings and safeguards
|
||||||
|
- scalable rate-limit backend abstraction
|
||||||
|
|
||||||
|
## 11) Risks and mitigations
|
||||||
|
|
||||||
|
Risk: Large API surface causes long-tail parity gaps.
|
||||||
|
Mitigation: enforce parity checker + allowlist for temporary exceptions.
|
||||||
|
|
||||||
|
Risk: Auth complexity across cookie/key/proxy modes.
|
||||||
|
Mitigation: profile abstraction + explicit mode selection and diagnostics.
|
||||||
|
|
||||||
|
Risk: CLI churn if endpoint contracts continue changing.
|
||||||
|
Mitigation: typed response normalizers + compatibility layer + semver release notes.
|
||||||
|
|
||||||
|
## 12) Acceptance criteria
|
||||||
|
|
||||||
|
- PRD approved by maintainers.
|
||||||
|
- CLI provides end-to-end control for core workflows.
|
||||||
|
- Contract parity CI gate active and green.
|
||||||
|
- TUI displays operational state and triggers key actions.
|
||||||
|
- Security and reliability hardening changes documented and tested.
|
||||||
|
|
||||||
|
## 13) Immediate implementation tasks (next 1-2 PRs)
|
||||||
|
|
||||||
|
PR 1
|
||||||
|
1. Add API parity checker script and CI command.
|
||||||
|
2. Add first-party CLI scaffold with command routing and normalized request layer.
|
||||||
|
3. Add docs for CLI profiles/auth/output contract.
|
||||||
|
|
||||||
|
PR 2
|
||||||
|
1. Implement full command matrix.
|
||||||
|
2. Add TUI dashboard shell.
|
||||||
|
3. Add CLI integration tests.
|
||||||
|
4. Introduce durable spawn history model and endpoint alignment.
|
||||||
2335
openapi.json
2335
openapi.json
File diff suppressed because it is too large
Load Diff
|
|
@ -4,6 +4,8 @@
|
||||||
"description": "OpenClaw Mission Control — open-source agent orchestration dashboard",
|
"description": "OpenClaw Mission Control — open-source agent orchestration dashboard",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"verify:node": "node scripts/check-node-version.mjs",
|
"verify:node": "node scripts/check-node-version.mjs",
|
||||||
|
"api:parity": "node scripts/check-api-contract-parity.mjs --root . --openapi openapi.json --ignore-file scripts/api-contract-parity.ignore",
|
||||||
|
"api:parity:json": "node scripts/check-api-contract-parity.mjs --root . --openapi openapi.json --ignore-file scripts/api-contract-parity.ignore --json",
|
||||||
"dev": "pnpm run verify:node && next dev --hostname 127.0.0.1 --port ${PORT:-3000}",
|
"dev": "pnpm run verify:node && next dev --hostname 127.0.0.1 --port ${PORT:-3000}",
|
||||||
"build": "pnpm run verify:node && next build",
|
"build": "pnpm run verify:node && next build",
|
||||||
"start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}",
|
"start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
# API contract parity baseline ignore list
|
||||||
|
# One operation per line: METHOD /api/path
|
||||||
|
# Keep this list shrinking over time; remove entries when route/spec parity is fixed.
|
||||||
|
DELETE /api/agents/{id}/memory
|
||||||
|
DELETE /api/backup
|
||||||
|
DELETE /api/integrations
|
||||||
|
DELETE /api/memory
|
||||||
|
DELETE /api/notifications
|
||||||
|
DELETE /api/projects/{id}/agents
|
||||||
|
GET /api/agents/evals
|
||||||
|
GET /api/agents/optimize
|
||||||
|
GET /api/agents/sync
|
||||||
|
GET /api/backup
|
||||||
|
GET /api/channels
|
||||||
|
GET /api/claude-tasks
|
||||||
|
GET /api/cleanup
|
||||||
|
GET /api/gateways/discover
|
||||||
|
GET /api/gateways/health/history
|
||||||
|
GET /api/github/sync
|
||||||
|
GET /api/gnap
|
||||||
|
GET /api/hermes
|
||||||
|
GET /api/hermes/memory
|
||||||
|
GET /api/hermes/tasks
|
||||||
|
GET /api/index
|
||||||
|
GET /api/local/agents-doc
|
||||||
|
GET /api/local/flight-deck
|
||||||
|
GET /api/memory/context
|
||||||
|
GET /api/memory/graph
|
||||||
|
GET /api/memory/health
|
||||||
|
GET /api/memory/links
|
||||||
|
GET /api/nodes
|
||||||
|
GET /api/notifications/deliver
|
||||||
|
GET /api/pipelines/run
|
||||||
|
GET /api/projects/{id}/agents
|
||||||
|
GET /api/schedule-parse
|
||||||
|
GET /api/security-audit
|
||||||
|
GET /api/security-scan
|
||||||
|
GET /api/spawn
|
||||||
|
GET /api/super/os-users
|
||||||
|
GET /api/system-monitor
|
||||||
|
GET /api/tasks/outcomes
|
||||||
|
GET /api/tasks/regression
|
||||||
|
GET /api/tokens/by-agent
|
||||||
|
PATCH /api/auth/me
|
||||||
|
POST /api/agents/evals
|
||||||
|
POST /api/agents/register
|
||||||
|
POST /api/auth/google/disconnect
|
||||||
|
POST /api/channels
|
||||||
|
POST /api/github/sync
|
||||||
|
POST /api/gnap
|
||||||
|
POST /api/hermes
|
||||||
|
POST /api/local/flight-deck
|
||||||
|
POST /api/local/terminal
|
||||||
|
POST /api/logs
|
||||||
|
POST /api/memory/process
|
||||||
|
POST /api/nodes
|
||||||
|
POST /api/projects/{id}/agents
|
||||||
|
POST /api/releases/update
|
||||||
|
POST /api/security-scan/agent
|
||||||
|
POST /api/security-scan/fix
|
||||||
|
POST /api/standup
|
||||||
|
POST /api/super/os-users
|
||||||
|
POST /api/super/provision-jobs/{id}
|
||||||
|
POST /api/tokens/rotate
|
||||||
|
PUT /api/integrations
|
||||||
|
PUT /api/notifications
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
|
||||||
|
|
||||||
|
function toPosix(input) {
|
||||||
|
return input.split(path.sep).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSegment(segment) {
|
||||||
|
if (segment.startsWith('[[...') && segment.endsWith(']]')) return `{${segment.slice(5, -2)}}`
|
||||||
|
if (segment.startsWith('[...') && segment.endsWith(']')) return `{${segment.slice(4, -1)}}`
|
||||||
|
if (segment.startsWith('[') && segment.endsWith(']')) return `{${segment.slice(1, -1)}}`
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeFileToApiPath(projectRoot, fullPath) {
|
||||||
|
const rel = toPosix(path.relative(projectRoot, fullPath))
|
||||||
|
const withoutRoute = rel.replace(/\/route\.tsx?$/, '')
|
||||||
|
const trimmed = withoutRoute.startsWith('src/app/api') ? withoutRoute.slice('src/app/api'.length) : withoutRoute
|
||||||
|
const parts = trimmed.split('/').filter(Boolean).map(normalizeSegment)
|
||||||
|
return `/api${parts.length ? `/${parts.join('/')}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHttpMethods(source) {
|
||||||
|
const methods = []
|
||||||
|
for (const method of HTTP_METHODS) {
|
||||||
|
const constExport = new RegExp(`export\\s+const\\s+${method}\\s*=`, 'm')
|
||||||
|
const fnExport = new RegExp(`export\\s+(?:async\\s+)?function\\s+${method}\\s*\\(`, 'm')
|
||||||
|
if (constExport.test(source) || fnExport.test(source)) methods.push(method)
|
||||||
|
}
|
||||||
|
return methods
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkRouteFiles(dir, out = []) {
|
||||||
|
if (!fs.existsSync(dir)) return out
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) walkRouteFiles(full, out)
|
||||||
|
else if (entry.isFile() && /route\.tsx?$/.test(entry.name)) out.push(full)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOperation(operation) {
|
||||||
|
const [method = '', ...pathParts] = String(operation || '').trim().split(' ')
|
||||||
|
const normalizedMethod = method.toUpperCase()
|
||||||
|
const normalizedPath = pathParts.join(' ').trim()
|
||||||
|
return `${normalizedMethod} ${normalizedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIgnoreArg(ignoreArg) {
|
||||||
|
if (!ignoreArg) return []
|
||||||
|
return ignoreArg
|
||||||
|
.split(',')
|
||||||
|
.map((x) => normalizeOperation(x))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const flags = {}
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i]
|
||||||
|
if (!token.startsWith('--')) continue
|
||||||
|
const key = token.slice(2)
|
||||||
|
const next = argv[i + 1]
|
||||||
|
if (!next || next.startsWith('--')) {
|
||||||
|
flags[key] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flags[key] = next
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
const flags = parseArgs(process.argv.slice(2))
|
||||||
|
const projectRoot = path.resolve(String(flags.root || process.cwd()))
|
||||||
|
const openapiPath = path.resolve(projectRoot, String(flags.openapi || 'openapi.json'))
|
||||||
|
const ignoreFile = flags['ignore-file'] ? path.resolve(projectRoot, String(flags['ignore-file'])) : null
|
||||||
|
const ignoreInline = parseIgnoreArg(flags.ignore)
|
||||||
|
let ignore = new Set(ignoreInline)
|
||||||
|
|
||||||
|
if (ignoreFile && fs.existsSync(ignoreFile)) {
|
||||||
|
const lines = fs
|
||||||
|
.readFileSync(ignoreFile, 'utf8')
|
||||||
|
.split('\n')
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter((x) => x && !x.startsWith('#'))
|
||||||
|
.map((x) => normalizeOperation(x))
|
||||||
|
ignore = new Set([...ignore, ...lines])
|
||||||
|
}
|
||||||
|
|
||||||
|
const openapi = JSON.parse(fs.readFileSync(openapiPath, 'utf8'))
|
||||||
|
const openapiOps = new Set()
|
||||||
|
for (const [rawPath, pathItem] of Object.entries(openapi.paths || {})) {
|
||||||
|
for (const method of Object.keys(pathItem || {})) {
|
||||||
|
const upper = method.toUpperCase()
|
||||||
|
if (HTTP_METHODS.includes(upper)) {
|
||||||
|
openapiOps.add(`${upper} ${rawPath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeFiles = walkRouteFiles(path.join(projectRoot, 'src/app/api'))
|
||||||
|
const routeOps = new Set()
|
||||||
|
for (const file of routeFiles) {
|
||||||
|
const source = fs.readFileSync(file, 'utf8')
|
||||||
|
const methods = extractHttpMethods(source)
|
||||||
|
const apiPath = routeFileToApiPath(projectRoot, file)
|
||||||
|
for (const method of methods) routeOps.add(`${method} ${apiPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingInOpenApi = [...routeOps].filter((op) => !openapiOps.has(op) && !ignore.has(op)).sort()
|
||||||
|
const missingInRoutes = [...openapiOps].filter((op) => !routeOps.has(op) && !ignore.has(op)).sort()
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
ok: missingInOpenApi.length === 0 && missingInRoutes.length === 0,
|
||||||
|
totals: {
|
||||||
|
routeOperations: routeOps.size,
|
||||||
|
openapiOperations: openapiOps.size,
|
||||||
|
ignoredOperations: ignore.size,
|
||||||
|
},
|
||||||
|
missingInOpenApi,
|
||||||
|
missingInRoutes,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(summary, null, 2))
|
||||||
|
} else {
|
||||||
|
console.log('API contract parity check')
|
||||||
|
console.log(`- route operations: ${summary.totals.routeOperations}`)
|
||||||
|
console.log(`- openapi operations: ${summary.totals.openapiOperations}`)
|
||||||
|
console.log(`- ignored entries: ${summary.totals.ignoredOperations}`)
|
||||||
|
if (missingInOpenApi.length) {
|
||||||
|
console.log('\nMissing in OpenAPI:')
|
||||||
|
for (const op of missingInOpenApi) console.log(` - ${op}`)
|
||||||
|
}
|
||||||
|
if (missingInRoutes.length) {
|
||||||
|
console.log('\nMissing in routes:')
|
||||||
|
for (const op of missingInRoutes) console.log(` - ${op}`)
|
||||||
|
}
|
||||||
|
if (!missingInOpenApi.length && !missingInRoutes.length) {
|
||||||
|
console.log('\n✅ Contract parity OK')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(summary.ok ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
Mission Control CLI (v1 scaffold)
|
||||||
|
- Zero heavy dependencies
|
||||||
|
- API-key first for agent automation
|
||||||
|
- JSON mode + stable exit codes
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
|
||||||
|
const EXIT = {
|
||||||
|
OK: 0,
|
||||||
|
USAGE: 2,
|
||||||
|
AUTH: 3,
|
||||||
|
FORBIDDEN: 4,
|
||||||
|
NETWORK: 5,
|
||||||
|
SERVER: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = { _: [], flags: {} };
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
if (!token.startsWith('--')) {
|
||||||
|
out._.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = token.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (!next || next.startsWith('--')) {
|
||||||
|
out.flags[key] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.flags[key] = next;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log(`Mission Control CLI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
mc <group> <action> [--flags]
|
||||||
|
|
||||||
|
Groups:
|
||||||
|
auth login/logout/whoami
|
||||||
|
agents list/get/create/update/delete/wake/diagnostics/heartbeat
|
||||||
|
tasks list/get/create/update/delete/queue/comment
|
||||||
|
sessions list/control/continue
|
||||||
|
connect register/list/disconnect
|
||||||
|
tokens list/stats/by-agent
|
||||||
|
skills list/content/upsert/delete/check
|
||||||
|
cron list/create/update/pause/resume/remove/run
|
||||||
|
events watch
|
||||||
|
raw request fallback
|
||||||
|
|
||||||
|
Common flags:
|
||||||
|
--profile <name> profile name (default: default)
|
||||||
|
--url <base_url> override profile URL
|
||||||
|
--api-key <key> override profile API key
|
||||||
|
--json JSON output
|
||||||
|
--timeout-ms <n> request timeout (default 20000)
|
||||||
|
--help show help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
mc agents list --json
|
||||||
|
mc tasks queue --agent Aegis --max-capacity 2
|
||||||
|
mc sessions control --id abc123 --action terminate
|
||||||
|
mc raw --method GET --path /api/status --json
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function profilePath(name) {
|
||||||
|
return path.join(os.homedir(), '.mission-control', 'profiles', `${name}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureParentDir(filePath) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProfile(name) {
|
||||||
|
const p = profilePath(name);
|
||||||
|
if (!fs.existsSync(p)) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
url: process.env.MC_URL || 'http://127.0.0.1:3000',
|
||||||
|
apiKey: process.env.MC_API_KEY || '',
|
||||||
|
cookie: process.env.MC_COOKIE || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
url: parsed.url || process.env.MC_URL || 'http://127.0.0.1:3000',
|
||||||
|
apiKey: parsed.apiKey || process.env.MC_API_KEY || '',
|
||||||
|
cookie: parsed.cookie || process.env.MC_COOKIE || '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
url: process.env.MC_URL || 'http://127.0.0.1:3000',
|
||||||
|
apiKey: process.env.MC_API_KEY || '',
|
||||||
|
cookie: process.env.MC_COOKIE || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProfile(profile) {
|
||||||
|
const p = profilePath(profile.name);
|
||||||
|
ensureParentDir(p);
|
||||||
|
fs.writeFileSync(p, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url) {
|
||||||
|
return String(url || '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatusToExit(status) {
|
||||||
|
if (status === 401) return EXIT.AUTH;
|
||||||
|
if (status === 403) return EXIT.FORBIDDEN;
|
||||||
|
if (status >= 500) return EXIT.SERVER;
|
||||||
|
return EXIT.USAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function httpRequest({ baseUrl, apiKey, cookie, method, route, body, timeoutMs = 20000 }) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const headers = { Accept: 'application/json' };
|
||||||
|
if (apiKey) headers['x-api-key'] = apiKey;
|
||||||
|
if (cookie) headers['Cookie'] = cookie;
|
||||||
|
let payload;
|
||||||
|
if (body !== undefined) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
payload = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const url = `${normalizeBaseUrl(baseUrl)}${route.startsWith('/') ? route : `/${route}`}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: payload,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
const text = await res.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
data = { raw: text };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: res.ok,
|
||||||
|
status: res.status,
|
||||||
|
data,
|
||||||
|
setCookie: res.headers.get('set-cookie') || '',
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (String(err?.name || '') === 'AbortError') {
|
||||||
|
return { ok: false, status: 0, data: { error: `Request timeout after ${timeoutMs}ms` }, timeout: true, url, method };
|
||||||
|
}
|
||||||
|
return { ok: false, status: 0, data: { error: err?.message || 'Network error' }, network: true, url, method };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printResult(result, asJson) {
|
||||||
|
if (asJson) {
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.ok) {
|
||||||
|
console.log(`OK ${result.status} ${result.method} ${result.url}`);
|
||||||
|
if (result.data && Object.keys(result.data).length > 0) {
|
||||||
|
console.log(JSON.stringify(result.data, null, 2));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`ERROR ${result.status || 'NETWORK'} ${result.method} ${result.url}`);
|
||||||
|
console.error(JSON.stringify(result.data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function required(flags, key) {
|
||||||
|
const value = flags[key];
|
||||||
|
if (value === undefined || value === true || String(value).trim() === '') {
|
||||||
|
throw new Error(`Missing required flag --${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const parsed = parseArgs(process.argv.slice(2));
|
||||||
|
if (parsed.flags.help || parsed._.length === 0) {
|
||||||
|
usage();
|
||||||
|
process.exit(EXIT.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
const asJson = Boolean(parsed.flags.json);
|
||||||
|
const profileName = String(parsed.flags.profile || 'default');
|
||||||
|
const profile = loadProfile(profileName);
|
||||||
|
const baseUrl = parsed.flags.url ? String(parsed.flags.url) : profile.url;
|
||||||
|
const apiKey = parsed.flags['api-key'] ? String(parsed.flags['api-key']) : profile.apiKey;
|
||||||
|
const timeoutMs = Number(parsed.flags['timeout-ms'] || 20000);
|
||||||
|
|
||||||
|
const group = parsed._[0];
|
||||||
|
const action = parsed._[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (group === 'auth') {
|
||||||
|
if (action === 'login') {
|
||||||
|
const username = required(parsed.flags, 'username');
|
||||||
|
const password = required(parsed.flags, 'password');
|
||||||
|
const result = await httpRequest({
|
||||||
|
baseUrl,
|
||||||
|
method: 'POST',
|
||||||
|
route: '/api/auth/login',
|
||||||
|
body: { username, password },
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
if (result.ok && result.setCookie) {
|
||||||
|
profile.url = baseUrl;
|
||||||
|
profile.cookie = result.setCookie.split(';')[0];
|
||||||
|
if (apiKey) profile.apiKey = apiKey;
|
||||||
|
saveProfile(profile);
|
||||||
|
result.data = { ...result.data, profile: profile.name, saved_cookie: true };
|
||||||
|
}
|
||||||
|
printResult(result, asJson);
|
||||||
|
process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status));
|
||||||
|
}
|
||||||
|
if (action === 'logout') {
|
||||||
|
const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method: 'POST', route: '/api/auth/logout', timeoutMs });
|
||||||
|
if (result.ok) {
|
||||||
|
profile.cookie = '';
|
||||||
|
saveProfile(profile);
|
||||||
|
}
|
||||||
|
printResult(result, asJson);
|
||||||
|
process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status));
|
||||||
|
}
|
||||||
|
if (action === 'whoami') {
|
||||||
|
const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method: 'GET', route: '/api/auth/me', timeoutMs });
|
||||||
|
printResult(result, asJson);
|
||||||
|
process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group === 'raw') {
|
||||||
|
const method = String(required(parsed.flags, 'method')).toUpperCase();
|
||||||
|
const route = String(required(parsed.flags, 'path'));
|
||||||
|
const body = parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : undefined;
|
||||||
|
const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method, route, body, timeoutMs });
|
||||||
|
printResult(result, asJson);
|
||||||
|
process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
agents: {
|
||||||
|
list: { method: 'GET', route: '/api/agents' },
|
||||||
|
get: { method: 'GET', route: `/api/agents/${required(parsed.flags, 'id')}` },
|
||||||
|
create: { method: 'POST', route: '/api/agents', body: { name: required(parsed.flags, 'name'), role: required(parsed.flags, 'role') } },
|
||||||
|
update: { method: 'PUT', route: `/api/agents/${required(parsed.flags, 'id')}`, body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} },
|
||||||
|
delete: { method: 'DELETE', route: `/api/agents/${required(parsed.flags, 'id')}` },
|
||||||
|
wake: { method: 'POST', route: `/api/agents/${required(parsed.flags, 'id')}/wake` },
|
||||||
|
diagnostics: { method: 'GET', route: `/api/agents/${required(parsed.flags, 'id')}/diagnostics` },
|
||||||
|
heartbeat: { method: 'POST', route: `/api/agents/${required(parsed.flags, 'id')}/heartbeat` },
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
list: { method: 'GET', route: '/api/tasks' },
|
||||||
|
get: { method: 'GET', route: `/api/tasks/${required(parsed.flags, 'id')}` },
|
||||||
|
create: { method: 'POST', route: '/api/tasks', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : { title: required(parsed.flags, 'title') } },
|
||||||
|
update: { method: 'PUT', route: `/api/tasks/${required(parsed.flags, 'id')}`, body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} },
|
||||||
|
delete: { method: 'DELETE', route: `/api/tasks/${required(parsed.flags, 'id')}` },
|
||||||
|
queue: { method: 'GET', route: `/api/tasks/queue?agent=${encodeURIComponent(required(parsed.flags, 'agent'))}${parsed.flags['max-capacity'] ? `&max_capacity=${encodeURIComponent(String(parsed.flags['max-capacity']))}` : ''}` },
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
list: { method: 'GET', route: '/api/sessions' },
|
||||||
|
control: { method: 'POST', route: `/api/sessions/${required(parsed.flags, 'id')}/control`, body: { action: required(parsed.flags, 'action') } },
|
||||||
|
continue: { method: 'POST', route: '/api/sessions/continue', body: { kind: required(parsed.flags, 'kind'), id: required(parsed.flags, 'id'), prompt: required(parsed.flags, 'prompt') } },
|
||||||
|
},
|
||||||
|
connect: {
|
||||||
|
register: { method: 'POST', route: '/api/connect', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : { tool_name: required(parsed.flags, 'tool-name'), agent_name: required(parsed.flags, 'agent-name') } },
|
||||||
|
list: { method: 'GET', route: '/api/connect' },
|
||||||
|
disconnect: { method: 'DELETE', route: '/api/connect', body: { connection_id: required(parsed.flags, 'connection-id') } },
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
list: { method: 'GET', route: '/api/tokens?action=list' },
|
||||||
|
stats: { method: 'GET', route: '/api/tokens?action=stats' },
|
||||||
|
'by-agent': { method: 'GET', route: `/api/tokens/by-agent?days=${encodeURIComponent(String(parsed.flags.days || '30'))}` },
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
list: { method: 'GET', route: '/api/skills' },
|
||||||
|
content: { method: 'GET', route: `/api/skills?mode=content&source=${encodeURIComponent(required(parsed.flags, 'source'))}&name=${encodeURIComponent(required(parsed.flags, 'name'))}` },
|
||||||
|
check: { method: 'GET', route: `/api/skills?mode=check&source=${encodeURIComponent(required(parsed.flags, 'source'))}&name=${encodeURIComponent(required(parsed.flags, 'name'))}` },
|
||||||
|
upsert: { method: 'PUT', route: '/api/skills', body: { source: required(parsed.flags, 'source'), name: required(parsed.flags, 'name'), content: fs.readFileSync(required(parsed.flags, 'file'), 'utf8') } },
|
||||||
|
delete: { method: 'DELETE', route: `/api/skills?source=${encodeURIComponent(required(parsed.flags, 'source'))}&name=${encodeURIComponent(required(parsed.flags, 'name'))}` },
|
||||||
|
},
|
||||||
|
cron: {
|
||||||
|
list: { method: 'GET', route: '/api/cron' },
|
||||||
|
create: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} },
|
||||||
|
update: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} },
|
||||||
|
pause: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} },
|
||||||
|
resume: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} },
|
||||||
|
remove: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} },
|
||||||
|
run: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} },
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
watch: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (group === 'events' && action === 'watch') {
|
||||||
|
const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method: 'GET', route: '/api/events', timeoutMs: Number(parsed.flags['timeout-ms'] || 3600000) });
|
||||||
|
// Basic fallback: if server doesn't stream in this fetch mode, print response payload
|
||||||
|
printResult(result, asJson);
|
||||||
|
process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = map[group] && map[group][action];
|
||||||
|
if (!cfg) {
|
||||||
|
usage();
|
||||||
|
process.exit(EXIT.USAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await httpRequest({
|
||||||
|
baseUrl,
|
||||||
|
apiKey,
|
||||||
|
cookie: profile.cookie,
|
||||||
|
method: cfg.method,
|
||||||
|
route: cfg.route,
|
||||||
|
body: cfg.body,
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
printResult(result, asJson);
|
||||||
|
process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.message || String(err);
|
||||||
|
if (asJson) {
|
||||||
|
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
||||||
|
} else {
|
||||||
|
console.error(`USAGE ERROR: ${message}`);
|
||||||
|
}
|
||||||
|
process.exit(EXIT.USAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectOpenApiOperations,
|
||||||
|
compareApiContractParity,
|
||||||
|
extractHttpMethods,
|
||||||
|
routeFileToApiPath,
|
||||||
|
runApiContractParityCheck,
|
||||||
|
} from '@/lib/api-contract-parity'
|
||||||
|
|
||||||
|
const tempDirs: string[] = []
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('api-contract-parity helpers', () => {
|
||||||
|
it('maps Next.js route files to OpenAPI-style API paths', () => {
|
||||||
|
expect(routeFileToApiPath('src/app/api/agents/route.ts')).toBe('/api/agents')
|
||||||
|
expect(routeFileToApiPath('src/app/api/tasks/[id]/route.ts')).toBe('/api/tasks/{id}')
|
||||||
|
expect(routeFileToApiPath('src/app/api/files/[...slug]/route.ts')).toBe('/api/files/{slug}')
|
||||||
|
expect(routeFileToApiPath('src/app/api/optional/[[...tail]]/route.ts')).toBe('/api/optional/{tail}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts exported HTTP methods from route modules', () => {
|
||||||
|
const source = `
|
||||||
|
export const GET = async () => {}
|
||||||
|
export const POST = async () => {}
|
||||||
|
const internal = 'ignore me'
|
||||||
|
`
|
||||||
|
expect(extractHttpMethods(source).sort()).toEqual(['GET', 'POST'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes OpenAPI operations', () => {
|
||||||
|
const operations = collectOpenApiOperations({
|
||||||
|
paths: {
|
||||||
|
'/api/tasks': { get: {}, post: {} },
|
||||||
|
'/api/tasks/{id}': { delete: {}, patch: {} },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(operations).toEqual([
|
||||||
|
'DELETE /api/tasks/{id}',
|
||||||
|
'GET /api/tasks',
|
||||||
|
'PATCH /api/tasks/{id}',
|
||||||
|
'POST /api/tasks',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports mismatches with optional ignore list', () => {
|
||||||
|
const report = compareApiContractParity({
|
||||||
|
routeOperations: [
|
||||||
|
{ method: 'GET', path: '/api/tasks', sourceFile: 'a' },
|
||||||
|
{ method: 'POST', path: '/api/tasks', sourceFile: 'a' },
|
||||||
|
{ method: 'DELETE', path: '/api/tasks/{id}', sourceFile: 'b' },
|
||||||
|
],
|
||||||
|
openapiOperations: ['GET /api/tasks', 'PATCH /api/tasks/{id}', 'DELETE /api/tasks/{id}'],
|
||||||
|
ignore: ['PATCH /api/tasks/{id}'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(report.missingInOpenApi).toEqual(['POST /api/tasks'])
|
||||||
|
expect(report.missingInRoutes).toEqual([])
|
||||||
|
expect(report.ignoredOperations).toEqual(['PATCH /api/tasks/{id}'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scans a project root and compares route operations to openapi', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-contract-'))
|
||||||
|
tempDirs.push(root)
|
||||||
|
|
||||||
|
const routeDir = path.join(root, 'src/app/api/tasks/[id]')
|
||||||
|
fs.mkdirSync(routeDir, { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(root, 'src/app/api/tasks/route.ts'), 'export const GET = async () => {};\n', 'utf8')
|
||||||
|
fs.writeFileSync(path.join(routeDir, 'route.ts'), 'export const DELETE = async () => {};\n', 'utf8')
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'openapi.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: '3.0.0',
|
||||||
|
paths: {
|
||||||
|
'/api/tasks': { get: {} },
|
||||||
|
'/api/tasks/{id}': { delete: {}, patch: {} },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
const report = runApiContractParityCheck({
|
||||||
|
projectRoot: root,
|
||||||
|
ignore: ['PATCH /api/tasks/{id}'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(report.missingInOpenApi).toEqual([])
|
||||||
|
expect(report.missingInRoutes).toEqual([])
|
||||||
|
expect(report.ignoredOperations).toEqual(['PATCH /api/tasks/{id}'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import * as fs from 'node:fs'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
|
||||||
|
export type ContractOperation = string
|
||||||
|
|
||||||
|
export interface RouteOperation {
|
||||||
|
method: string
|
||||||
|
path: string
|
||||||
|
sourceFile: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParityReport {
|
||||||
|
routeOperations: ContractOperation[]
|
||||||
|
openapiOperations: ContractOperation[]
|
||||||
|
missingInOpenApi: ContractOperation[]
|
||||||
|
missingInRoutes: ContractOperation[]
|
||||||
|
ignoredOperations: ContractOperation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const
|
||||||
|
|
||||||
|
function toPosix(input: string): string {
|
||||||
|
return input.split(path.sep).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSegment(segment: string): string {
|
||||||
|
if (segment.startsWith('[[...') && segment.endsWith(']]')) {
|
||||||
|
return `{${segment.slice(5, -2)}}`
|
||||||
|
}
|
||||||
|
if (segment.startsWith('[...') && segment.endsWith(']')) {
|
||||||
|
return `{${segment.slice(4, -1)}}`
|
||||||
|
}
|
||||||
|
if (segment.startsWith('[') && segment.endsWith(']')) {
|
||||||
|
return `{${segment.slice(1, -1)}}`
|
||||||
|
}
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeFileToApiPath(routeFile: string, apiRoot = 'src/app/api'): string {
|
||||||
|
const normalizedFile = toPosix(routeFile)
|
||||||
|
const normalizedRoot = toPosix(apiRoot)
|
||||||
|
const routeWithoutExt = normalizedFile.replace(/\/route\.tsx?$/, '')
|
||||||
|
const relative = routeWithoutExt.startsWith(normalizedRoot)
|
||||||
|
? routeWithoutExt.slice(normalizedRoot.length)
|
||||||
|
: routeWithoutExt
|
||||||
|
|
||||||
|
const segments = relative
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(normalizeSegment)
|
||||||
|
|
||||||
|
return `/api${segments.length ? `/${segments.join('/')}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractHttpMethods(source: string): string[] {
|
||||||
|
const methods = new Set<string>()
|
||||||
|
for (const method of HTTP_METHODS) {
|
||||||
|
const constExport = new RegExp(`export\\s+const\\s+${method}\\s*=`, 'm')
|
||||||
|
const fnExport = new RegExp(`export\\s+(?:async\\s+)?function\\s+${method}\\s*\\(`, 'm')
|
||||||
|
if (constExport.test(source) || fnExport.test(source)) methods.add(method)
|
||||||
|
}
|
||||||
|
return Array.from(methods)
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkRouteFiles(dir: string, found: string[] = []): string[] {
|
||||||
|
if (!fs.existsSync(dir)) return found
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const fullPath = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walkRouteFiles(fullPath, found)
|
||||||
|
} else if (entry.isFile() && /route\.tsx?$/.test(entry.name)) {
|
||||||
|
found.push(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectRouteOperations(projectRoot: string): RouteOperation[] {
|
||||||
|
const apiRoot = path.join(projectRoot, 'src', 'app', 'api')
|
||||||
|
const routeFiles = walkRouteFiles(apiRoot)
|
||||||
|
|
||||||
|
const operations: RouteOperation[] = []
|
||||||
|
for (const file of routeFiles) {
|
||||||
|
const source = fs.readFileSync(file, 'utf8')
|
||||||
|
const methods = extractHttpMethods(source)
|
||||||
|
const apiPath = routeFileToApiPath(toPosix(path.relative(projectRoot, file)))
|
||||||
|
for (const method of methods) {
|
||||||
|
operations.push({ method, path: apiPath, sourceFile: file })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return operations
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectOpenApiOperations(openapi: any): ContractOperation[] {
|
||||||
|
const operations = new Set<ContractOperation>()
|
||||||
|
const paths = openapi?.paths ?? {}
|
||||||
|
for (const [rawPath, pathItem] of Object.entries(paths)) {
|
||||||
|
const normalizedPath = String(rawPath)
|
||||||
|
for (const method of Object.keys(pathItem as Record<string, unknown>)) {
|
||||||
|
const upper = method.toUpperCase()
|
||||||
|
if ((HTTP_METHODS as readonly string[]).includes(upper)) {
|
||||||
|
operations.add(`${upper} ${normalizedPath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(operations).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toContractOperation(method: string, apiPath: string): ContractOperation {
|
||||||
|
return `${method.toUpperCase()} ${apiPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOperation(operation: string): ContractOperation {
|
||||||
|
const [method = '', ...pathParts] = operation.trim().split(' ')
|
||||||
|
const normalizedMethod = method.toUpperCase()
|
||||||
|
const normalizedPath = pathParts.join(' ').trim()
|
||||||
|
return `${normalizedMethod} ${normalizedPath}` as ContractOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareApiContractParity(params: {
|
||||||
|
routeOperations: RouteOperation[]
|
||||||
|
openapiOperations: ContractOperation[]
|
||||||
|
ignore?: string[]
|
||||||
|
}): ParityReport {
|
||||||
|
const ignored = new Set((params.ignore ?? []).map((x) => normalizeOperation(x)))
|
||||||
|
const routeOperations = Array.from(new Set(params.routeOperations.map((op) => toContractOperation(op.method, op.path)))).sort()
|
||||||
|
const openapiOperations = Array.from(new Set(params.openapiOperations.map((op) => normalizeOperation(op)))).sort()
|
||||||
|
|
||||||
|
const routeSet = new Set(routeOperations)
|
||||||
|
const openapiSet = new Set(openapiOperations)
|
||||||
|
|
||||||
|
const ignoredOperations: ContractOperation[] = []
|
||||||
|
const missingInOpenApi: ContractOperation[] = []
|
||||||
|
for (const op of routeOperations) {
|
||||||
|
if (ignored.has(op)) {
|
||||||
|
ignoredOperations.push(op)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!openapiSet.has(op)) missingInOpenApi.push(op)
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingInRoutes: ContractOperation[] = []
|
||||||
|
for (const op of openapiOperations) {
|
||||||
|
if (ignored.has(op)) {
|
||||||
|
if (!ignoredOperations.includes(op as ContractOperation)) ignoredOperations.push(op as ContractOperation)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!routeSet.has(op)) missingInRoutes.push(op as ContractOperation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
routeOperations: routeOperations as ContractOperation[],
|
||||||
|
openapiOperations: openapiOperations as ContractOperation[],
|
||||||
|
missingInOpenApi,
|
||||||
|
missingInRoutes,
|
||||||
|
ignoredOperations: ignoredOperations.sort(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadOpenApiFile(projectRoot: string, openapiPath = 'openapi.json'): any {
|
||||||
|
const filePath = path.join(projectRoot, openapiPath)
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runApiContractParityCheck(params: {
|
||||||
|
projectRoot: string
|
||||||
|
openapiPath?: string
|
||||||
|
ignore?: string[]
|
||||||
|
}): ParityReport {
|
||||||
|
const projectRoot = path.resolve(params.projectRoot)
|
||||||
|
const openapi = loadOpenApiFile(projectRoot, params.openapiPath)
|
||||||
|
const routeOperations = collectRouteOperations(projectRoot)
|
||||||
|
const openapiOperations = collectOpenApiOperations(openapi)
|
||||||
|
return compareApiContractParity({ routeOperations, openapiOperations, ignore: params.ignore })
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue