Compare commits
11 Commits
feat/unifi
...
feat/insta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6deae95339 | ||
| 62b2ce2da1 | |||
| 172bacb30f | |||
| 43667d7349 | |||
| 783884376c | |||
| c08aa6fa46 | |||
| 0ae932ab34 | |||
| a8cd52e88c | |||
| a4c94d9a90 | |||
| cee838d22e | |||
| 732f8a49cf |
@@ -72,11 +72,17 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@swc/core": "^1.15.24",
|
||||
"@swc/helpers": "^0.5.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import type { Auth } from '@mosaicstack/auth';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||
import { BootstrapSetupDto } from './bootstrap.dto.js';
|
||||
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||
|
||||
@Controller('api/bootstrap')
|
||||
export class BootstrapController {
|
||||
|
||||
190
apps/gateway/src/admin/bootstrap.e2e.spec.ts
Normal file
190
apps/gateway/src/admin/bootstrap.e2e.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* E2E integration test — POST /api/bootstrap/setup
|
||||
*
|
||||
* Regression guard for the `import type { BootstrapSetupDto }` class-erasure
|
||||
* bug (IUV-M01, issue #436).
|
||||
*
|
||||
* When `BootstrapSetupDto` is imported with `import type`, TypeScript erases
|
||||
* the class at compile time. NestJS then sees `Object` as the `@Body()`
|
||||
* metatype, and ValidationPipe with `whitelist:true + forbidNonWhitelisted:true`
|
||||
* treats every property as non-whitelisted, returning:
|
||||
*
|
||||
* 400 { message: ["property email should not exist", "property password should not exist"] }
|
||||
*
|
||||
* The fix is a plain value import (`import { BootstrapSetupDto }`), which
|
||||
* preserves the class reference so Nest can read the class-validator decorators.
|
||||
*
|
||||
* This test MUST fail if `import type` is re-introduced on `BootstrapSetupDto`.
|
||||
* A controller unit test that constructs ValidationPipe manually won't catch
|
||||
* this — only the real DI binding path exercises the metatype lookup.
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { BootstrapController } from './bootstrap.controller.js';
|
||||
import type { BootstrapResultDto } from './bootstrap.dto.js';
|
||||
|
||||
// ─── Minimal mock dependencies ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* We use explicit `@Inject(AUTH)` / `@Inject(DB)` in the controller so we
|
||||
* can provide mock values by token without spinning up the real DB or Auth.
|
||||
*/
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
const MOCK_USER_ID = 'mock-user-id-001';
|
||||
|
||||
const mockAuth = {
|
||||
api: {
|
||||
createUser: () =>
|
||||
Promise.resolve({
|
||||
user: {
|
||||
id: MOCK_USER_ID,
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
// Override db.select() so the second query (verify user exists) returns a user.
|
||||
// The bootstrap controller calls select().from() twice:
|
||||
// 1. count() to check zero users → returns [{total: 0}]
|
||||
// 2. select().where().limit() → returns [the created user]
|
||||
let selectCallCount = 0;
|
||||
const mockDbWithUser = {
|
||||
select: () => {
|
||||
selectCallCount++;
|
||||
return {
|
||||
from: () => {
|
||||
if (selectCallCount === 1) {
|
||||
// First call: count — zero users
|
||||
return Promise.resolve([{ total: 0 }]);
|
||||
}
|
||||
// Subsequent calls: return a mock user row
|
||||
return {
|
||||
where: () => ({
|
||||
limit: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: MOCK_USER_ID,
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
update: () => ({
|
||||
set: () => ({
|
||||
where: () => Promise.resolve([]),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({
|
||||
returning: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'token-id-001',
|
||||
label: 'Initial setup token',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
selectCallCount = 0;
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [BootstrapController],
|
||||
providers: [
|
||||
{ provide: AUTH, useValue: mockAuth },
|
||||
{ provide: DB, useValue: mockDbWithUser },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
|
||||
// Mirror main.ts configuration exactly — this is what reproduced the 400.
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.init();
|
||||
// Fastify requires waiting for the adapter to be ready
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns 201 (not 400) when a valid {name, email, password} body is sent', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({ name: 'Admin', email: 'admin@example.com', password: 'password123' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
// Before the fix (import type), Nest ValidationPipe returned 400 with
|
||||
// "property email should not exist" / "property password should not exist"
|
||||
// because the DTO class was erased and every field looked non-whitelisted.
|
||||
expect(res.status).not.toBe(400);
|
||||
expect(res.status).toBe(201);
|
||||
const body = res.body as BootstrapResultDto;
|
||||
expect(body.user).toBeDefined();
|
||||
expect(body.user.email).toBe('admin@example.com');
|
||||
expect(body.token).toBeDefined();
|
||||
expect(body.token.plaintext).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 400 when extra forbidden properties are sent', async () => {
|
||||
// This proves ValidationPipe IS active and working (forbidNonWhitelisted).
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
password: 'password123',
|
||||
extraField: 'should-be-rejected',
|
||||
})
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when email is invalid', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({ name: 'Admin', email: 'not-an-email', password: 'password123' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when password is too short', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({ name: 'Admin', email: 'admin@example.com', password: 'short' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import swc from 'unplugin-swc';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -5,4 +6,22 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
plugins: [
|
||||
swc.vite({
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
decorators: true,
|
||||
},
|
||||
transform: {
|
||||
decoratorMetadata: true,
|
||||
legacyDecorator: true,
|
||||
},
|
||||
target: 'es2022',
|
||||
},
|
||||
module: {
|
||||
type: 'nodenext',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,57 +1,73 @@
|
||||
# Mission Manifest — Install UX Hardening
|
||||
# Mission Manifest — Install UX v2
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** install-ux-hardening-20260405
|
||||
**Statement:** Close the remaining gaps in the Mosaic Stack first-run and teardown experience uncovered by the post-`cli-unification` audit. A user MUST be able to cleanly uninstall the stack; the wizard MUST make security-sensitive surfaces visible (hooks, password entry); and CI/headless installs MUST NOT hang on interactive prompts. The longer-term goal is a single cohesive first-run flow that collapses `mosaic wizard` and `mosaic gateway install` into one state-bridged experience.
|
||||
**ID:** install-ux-v2-20260405
|
||||
**Statement:** The install-ux-hardening mission shipped the plumbing (uninstall, masked password, hooks consent, unified flow, headless path), but the first real end-to-end run surfaced a critical regression and a collection of UX failings that make the wizard feel neither quick nor intelligent. This mission closes the bootstrap regression as a hotfix, then rethinks the first-run experience around a provider-first, intent-driven flow with a drill-down main menu and a genuinely fast quick-start.
|
||||
**Phase:** Execution
|
||||
**Current Milestone:** IUH-M03
|
||||
**Current Milestone:** IUV-M03
|
||||
**Progress:** 2 / 3 milestones
|
||||
**Status:** active
|
||||
**Last Updated:** 2026-04-05
|
||||
**Parent Mission:** [cli-unification-20260404](./archive/missions/cli-unification-20260404/MISSION-MANIFEST.md) (complete)
|
||||
**Last Updated:** 2026-04-05 (IUV-M02 complete — CORS/FQDN + skill installer rework)
|
||||
**Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
|
||||
|
||||
## Context
|
||||
|
||||
Post-merge audit of `cli-unification-20260404` (AC-1, AC-6) validated that the first-run wizard covers first user, password, admin tokens, gateway instance config, skills, and SOUL.md/USER.md init. The audit surfaced six gaps, grouped into three tracks of independent value.
|
||||
Real-run testing of `@mosaicstack/mosaic@0.0.25` uncovered:
|
||||
|
||||
1. **Critical:** admin bootstrap fails with HTTP 400 `property email should not exist` — `bootstrap.controller.ts` uses `import type { BootstrapSetupDto }`, erasing the class at runtime. Nest's `@Body()` falls back to plain `Object` metatype, and ValidationPipe with `forbidNonWhitelisted` rejects every property. One-character fix (drop the `type` keyword), but it blocks the happy path of the release that just shipped.
|
||||
2. The wizard reports `✔ Wizard complete` and `✔ Done` _after_ the bootstrap 400 — failure only propagates in headless mode (`wizard.ts:147`).
|
||||
3. The gateway port prompt does not prefill `14242` in the input buffer.
|
||||
4. `"What is Mosaic?"` intro copy does not mention Pi SDK (the actual agent runtime behind Claude/Codex/OpenCode).
|
||||
5. CORS origin prompt is confusing — the user should be able to supply an FQDN/hostname and have the system derive the CORS value.
|
||||
6. Skill / additional feature install section is unusable in practice.
|
||||
7. Quick-start asks far too many questions to be meaningfully "quick".
|
||||
8. No drill-down main menu — everything is a linear interrogation.
|
||||
9. Provider setup happens late and without intelligence. An OpenClaw-style provider-first flow would let the user describe what they want in natural language, have the agent expound on it, and have the agent choose its own name based on that intent.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: `mosaic uninstall` (top-level) cleanly reverses every mutation made by `tools/install.sh` — framework data, npm CLI, nested stack deps, runtime asset injections in `~/.claude/`, npmrc scope mapping, PATH edits. Dry-run supported. `--keep-data` preserves memory + user files + gateway DB. (PR #429)
|
||||
- [x] AC-2: `curl … | bash -s -- --uninstall` works without requiring a functioning CLI. (PR #429)
|
||||
- [x] AC-3: Password entry in `bootstrapFirstUser` is masked (no plaintext echo); confirm prompt added. (PR #431)
|
||||
- [x] AC-4: Wizard has an explicit hooks stage that previews which hooks will be installed, asks for confirmation, and records the user's choice. `mosaic config hooks list|enable|disable` surface exists. (PR #431 — consent recorded in `state.hooks.accepted`; finalize-stage gating is a follow-up)
|
||||
- [x] AC-5: `runConfigWizard` and `bootstrapFirstUser` accept a headless path (env vars + `--yes`) so `tools/install.sh --yes` + `MOSAIC_ASSUME_YES=1` completes end-to-end in CI without TTY. (PR #431)
|
||||
- [ ] AC-6: `mosaic wizard` and `mosaic gateway install` are collapsed into a single cohesive entry point with shared state (no two-phase handoff via the 10-minute session file).
|
||||
- [ ] AC-7: All milestones ship as merged PRs with green CI, closed issues, updated release notes.
|
||||
- [x] AC-1: Admin bootstrap completes successfully end-to-end on a fresh install (DTO value import, no forbidNonWhitelisted regression); covered by an integration or e2e test that exercises the real DTO binding. _(PR #440)_
|
||||
- [x] AC-2: Wizard fails loudly (non-zero exit, clear error) when the bootstrap stage returns `completed: false`, in both interactive and headless modes. No more silent `✔ Wizard complete` after a 400. _(PR #440)_
|
||||
- [x] AC-3: Gateway port prompt prefills `14242` in the input field (user can press Enter to accept). _(PR #440)_
|
||||
- [x] AC-4: `"What is Mosaic?"` intro copy mentions Pi SDK as the underlying agent runtime. _(PR #440)_
|
||||
- [x] AC-5: Release `mosaic-v0.0.26` tagged and published to the Gitea npm registry, unblocking the 0.0.25 happy path. _(tag: mosaic-v0.0.26, registry: 0.0.26 live)_
|
||||
- [ ] AC-6: CORS origin prompt replaced with FQDN/hostname input; CORS string is derived from that.
|
||||
- [ ] AC-7: Skill / additional feature install section is reworked until it is actually usable end-to-end (worker defines the concrete failure modes during diagnosis).
|
||||
- [ ] AC-8: First-run flow has a drill-down main menu with at least `Plugins` (Recommended / Custom), `Providers`, and the other top-level configuration groups. Linear interrogation is gone.
|
||||
- [ ] AC-9: `Quick Start` path completes with a minimal, curated set of questions (target: under 90 seconds for a returning user; define the exact baseline during design).
|
||||
- [ ] AC-10: Provider setup happens first, driven by a natural-language intake prompt. The agent expounds on the user's intent and chooses its own name based on that intent (OpenClaw-style). Naming is confirmable / overridable.
|
||||
- [ ] AC-11: All milestones ship as merged PRs with green CI and closed issues.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | --------------------------------------------------------- | ----------- | ----------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | IUH-M01 | `mosaic uninstall` — top-level teardown + shell wrapper | done | feat/mosaic-uninstall | #425 | 2026-04-05 | 2026-04-05 |
|
||||
| 2 | IUH-M02 | Wizard remediation — hooks visibility, pwd mask, headless | done | feat/wizard-remediation | #426 | 2026-04-05 | 2026-04-05 |
|
||||
| 3 | IUH-M03 | Unified first-run wizard (collapse wizard + gateway) | in-progress | feat/unified-first-run | #427 | 2026-04-05 | — |
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | ------------------------------------------------------------ | ----------- | ---------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | IUV-M01 | Hotfix: bootstrap DTO + wizard failure + port prefill + copy | complete | fix/bootstrap-hotfix | #436 | 2026-04-05 | 2026-04-05 |
|
||||
| 2 | IUV-M02 | UX polish: CORS/FQDN, skill installer rework | complete | feat/install-ux-polish | #437 | 2026-04-05 | 2026-04-05 |
|
||||
| 3 | IUV-M03 | Provider-first intelligent flow + drill-down main menu | not-started | feat/install-ux-intent | #438 | — | — |
|
||||
|
||||
## Subagent Delegation Plan
|
||||
|
||||
| Milestone | Recommended Tier | Rationale |
|
||||
| --------- | ---------------- | ---------------------------------------------------------------------- |
|
||||
| IUH-M01 | sonnet | Standard feature work — new command surface mirroring existing install |
|
||||
| IUH-M02 | sonnet | Small surgical fixes across 3-4 files |
|
||||
| IUH-M03 | opus | Architectural refactor; state machine design decisions |
|
||||
| Milestone | Recommended Tier | Rationale |
|
||||
| --------- | ---------------- | --------------------------------------------------------------------- |
|
||||
| IUV-M01 | sonnet | Tight bug cluster with known fix sites + small release cycle |
|
||||
| IUV-M02 | sonnet | UX rework, moderate surface, diagnostic-heavy for the skill installer |
|
||||
| IUV-M03 | opus | Architectural redesign of first-run flow, state machine + LLM intake |
|
||||
|
||||
## Risks
|
||||
|
||||
- **Reversal completeness** — runtime asset linking creates `.mosaic-bak-*` backups; uninstall must honor them vs. when to delete. Ambiguity without an install manifest.
|
||||
- **npm global nested deps** — `npm uninstall -g @mosaicstack/mosaic` removes nested `@mosaicstack/*`, but ownership conflicts with explicitly installed peer packages (`@mosaicstack/gateway`, `@mosaicstack/memory`) need test coverage.
|
||||
- **Headless bootstrap** — admin password via env var is a credential on disk; needs clear documentation that `MOSAIC_ADMIN_PASSWORD` is intended for CI-only and should be rotated post-install.
|
||||
- **Hotfix regression surface** — the `import type` → `import` fix on the DTO class is one character but needs an integration test that binds the real DTO, not just a controller unit test, to prevent the same class-erasure regression from sneaking back in.
|
||||
- **LLM-driven intake latency / offline** — M03's provider-first intent flow assumes an available LLM call to expound on user input and choose a name. Offline installs need a deterministic fallback.
|
||||
- **Menu vs. linear back-compat** — M03 changes the top-level flow shape; existing `tools/install.sh --yes` + env-var headless path must continue to work.
|
||||
- **Scope creep in M03** — "redesign the wizard" can absorb arbitrary work. Keep it bounded with explicit non-goals.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `mosaicstack.dev/install.sh` vanity URL (blocked on marketing site work)
|
||||
- Uninstall for the `@mosaicstack/gateway` database contents — delegated to `mosaic gateway uninstall` semantics already in place
|
||||
- Signature/checksum verification of install scripts
|
||||
- Migrating the wizard to a GUI / web UI (still terminal-first)
|
||||
- Replacing the Gitea registry or the Woodpecker publish pipeline
|
||||
- Multi-tenant / multi-user onboarding (still single-admin bootstrap)
|
||||
- Reworking `mosaic uninstall` (M01 of the parent mission — stable)
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
# Tasks — Install UX Hardening
|
||||
# Tasks — Install UX v2
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** install-ux-hardening-20260405
|
||||
> **Mission:** install-ux-v2-20260405
|
||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
||||
|
||||
## Milestone 1 — `mosaic uninstall` (IUH-M01)
|
||||
## Milestone 1 — Hotfix: bootstrap DTO + wizard failure + port prefill + copy (IUV-M01)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------- | ----- | ------ | --------------------- | ---------- | -------- | ------------------------------------------------------ |
|
||||
| IUH-01-01 | done | Design install manifest schema (`~/.config/mosaic/.install-manifest.json`) — what install writes on first success | #425 | sonnet | feat/mosaic-uninstall | — | 8K | v1 schema in `install-manifest.ts` |
|
||||
| IUH-01-02 | done | `mosaic uninstall` TS command: `--framework`, `--cli`, `--gateway`, `--all`, `--keep-data`, `--yes`, `--dry-run` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-01 | 25K | `uninstall.ts` |
|
||||
| IUH-01-03 | done | Reverse runtime asset linking in `~/.claude/` — restore `.mosaic-bak-*` if present, remove managed copies otherwise | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 12K | file list hardcoded from mosaic-link-runtime-assets |
|
||||
| IUH-01-04 | done | Reverse npmrc scope mapping and PATH edits made by `tools/install.sh` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 8K | npmrc reversed; no PATH edits found in v0.0.24 install |
|
||||
| IUH-01-05 | done | Shell fallback: `tools/install.sh --uninstall` path for users without a working CLI | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 10K | |
|
||||
| IUH-01-06 | done | Vitest coverage: dry-run output, `--all`, `--keep-data`, partial state, missing manifest | #425 | sonnet | feat/mosaic-uninstall | IUH-01-05 | 15K | 14 new tests, 170 total |
|
||||
| IUH-01-07 | done | Code review (independent) + remediation | #425 | sonnet | feat/mosaic-uninstall | IUH-01-06 | 5K | |
|
||||
| IUH-01-08 | done | PR open, CI green, review, merge to `main`, close issue | #425 | sonnet | feat/mosaic-uninstall | IUH-01-07 | 3K | PR #429, merge 25cada77 |
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | -------------------- | ---------- | -------- | --------------------------------------------------------------------------------------- |
|
||||
| IUV-01-01 | done | Fix `apps/gateway/src/admin/bootstrap.controller.ts:16` — switch `import type { BootstrapSetupDto }` to a value import so Nest's `@Body()` binds the real class | #436 | sonnet | fix/bootstrap-hotfix | — | 3K | PR #440 merged `0ae932ab` |
|
||||
| IUV-01-02 | done | Add integration / e2e test that POSTs `/api/bootstrap/setup` with `{name,email,password}` against a real Nest app instance and asserts 201 — NOT a mocked controller unit test | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-01 | 10K | `apps/gateway/src/admin/bootstrap.e2e.spec.ts` — 4 tests; unplugin-swc added for vitest |
|
||||
| IUV-01-03 | done | `packages/mosaic/src/wizard.ts:147` — propagate `!bootstrapResult.completed` as a wizard failure in **interactive** mode too (not only headless); non-zero exit + no `✔ Wizard complete` line | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-02 | 5K | removed `&& headlessRun` guard |
|
||||
| IUV-01-04 | done | Gateway port prompt prefills `14242` in the input buffer — investigate why `promptPort`'s `defaultValue` isn't reaching the user-visible input | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-03 | 5K | added `initialValue` through prompter interface → clack |
|
||||
| IUV-01-05 | done | `"What is Mosaic?"` intro copy updated to mention Pi SDK as the underlying agent runtime (alongside Claude Code / Codex / OpenCode) | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-04 | 2K | `packages/mosaic/src/stages/welcome.ts` |
|
||||
| IUV-01-06 | done | Tests + code review + PR merge + tag `mosaic-v0.0.26` + Gitea release + npm registry republish | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-05 | 10K | PRs #440/#441/#442 merged; tag `mosaic-v0.0.26`; registry latest=0.0.26 ✓ |
|
||||
|
||||
## Milestone 2 — Wizard Remediation (IUH-M02)
|
||||
## Milestone 2 — UX polish: CORS/FQDN, skill installer rework (IUV-M02)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | -------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------- | ---------- | -------- | ----------------------------------------------- |
|
||||
| IUH-02-01 | done | Password masking: replace plaintext `rl.question` in `bootstrapFirstUser` with masked TTY read + confirmation | #426 | sonnet | feat/wizard-remediation | IUH-01-08 | 8K | `prompter/masked-prompt.ts` |
|
||||
| IUH-02-02 | done | Hooks preview stage in wizard: show `framework/runtime/claude/hooks-config.json` entries + confirm prompt | #426 | sonnet | feat/wizard-remediation | IUH-02-01 | 12K | `stages/hooks-preview.ts`; finalize gating TODO |
|
||||
| IUH-02-03 | done | `mosaic config hooks list\|enable\|disable` subcommands | #426 | sonnet | feat/wizard-remediation | IUH-02-02 | 15K | `commands/config.ts` |
|
||||
| IUH-02-04 | done | Headless path: env-var driven `runConfigWizard` + `bootstrapFirstUser` (`MOSAIC_ASSUME_YES`, `MOSAIC_ADMIN_*`) | #426 | sonnet | feat/wizard-remediation | IUH-02-03 | 12K | |
|
||||
| IUH-02-05 | done | Tests + code review + PR merge | #426 | sonnet | feat/wizard-remediation | IUH-02-04 | 10K | PR #431, merge cd8b1f66 |
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------- | ---------- | -------- | ---------------------------------------------------------------------- |
|
||||
| IUV-02-01 | done | Replace CORS origin prompt with FQDN / hostname input; derive the CORS value internally; default to `localhost` with clear help text | #437 | sonnet | feat/install-ux-polish | — | 10K | `deriveCorsOrigin()` pure fn; MOSAIC_HOSTNAME headless var; PR #444 |
|
||||
| IUV-02-02 | done | Diagnose and document the concrete failure modes of the current skill / additional feature install section end-to-end | #437 | sonnet | feat/install-ux-polish | IUV-02-01 | 8K | selection→install gap, silent catch{}, no whitelist concept |
|
||||
| IUV-02-03 | done | Rework the skill installer so it is usable end-to-end (selection, install, verify, failure reporting) | #437 | sonnet | feat/install-ux-polish | IUV-02-02 | 20K | MOSAIC_INSTALL_SKILLS env var whitelist; SyncSkillsResult typed return |
|
||||
| IUV-02-04 | done | Tests + code review + PR merge | #437 | sonnet | feat/install-ux-polish | IUV-02-03 | 10K | 18 new tests (13 CORS + 5 skills); PR #444 merged `172bacb3` |
|
||||
|
||||
## Milestone 3 — Unified First-Run Wizard (IUH-M03)
|
||||
## Milestone 3 — Provider-first intelligent flow + drill-down main menu (IUV-M03)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ----------- | ----------------------------------------------------------------------------------------------------------- | ----- | ----- | ---------------------- | ---------- | -------- | ----- |
|
||||
| IUH-03-01 | not-started | Design doc: unified state machine; decide whether `mosaic gateway install` becomes an internal wizard stage | #427 | opus | feat/unified-first-run | IUH-02-05 | 10K | |
|
||||
| IUH-03-02 | not-started | Refactor `runWizard` to invoke gateway install as a stage; drop the 10-minute session-file bridge | #427 | opus | feat/unified-first-run | IUH-03-01 | 25K | |
|
||||
| IUH-03-03 | not-started | Preserve backward-compat: `mosaic gateway install` still works as a standalone entry point | #427 | opus | feat/unified-first-run | IUH-03-02 | 10K | |
|
||||
| IUH-03-04 | not-started | Tests + code review + PR merge | #427 | opus | feat/unified-first-run | IUH-03-03 | 12K | |
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----- | ---------------------- | ---------- | -------- | ------------------------------------------------------------- |
|
||||
| IUV-03-01 | not-started | Design doc: new first-run state machine — main menu (Plugins / Providers / …), Quick Start vs Custom paths, provider-first flow, intent intake + naming loop | #438 | opus | feat/install-ux-intent | — | 15K | scratchpad + explicit non-goals |
|
||||
| IUV-03-02 | not-started | Implement drill-down main menu (Plugins: Recommended / Custom, Providers, …) as the top-level entry point of `mosaic wizard` | #438 | opus | feat/install-ux-intent | IUV-03-01 | 25K | |
|
||||
| IUV-03-03 | not-started | Quick Start path: curated minimum question set — define the exact baseline, delete everything else from the fast path | #438 | opus | feat/install-ux-intent | IUV-03-02 | 15K | |
|
||||
| IUV-03-04 | not-started | Provider-first natural-language intake: user describes intent → agent expounds → agent proposes a name (confirmable / overridable) — OpenClaw-style | #438 | opus | feat/install-ux-intent | IUV-03-03 | 25K | offline fallback required (deterministic default name + path) |
|
||||
| IUV-03-05 | not-started | Preserve backward-compat: headless path (`MOSAIC_ASSUME_YES=1` + env vars) still works end-to-end; `tools/install.sh --yes` unchanged | #438 | opus | feat/install-ux-intent | IUV-03-04 | 10K | |
|
||||
| IUV-03-06 | not-started | Tests + code review + PR merge + `mosaic-v0.0.27` release | #438 | opus | feat/install-ux-intent | IUV-03-05 | 15K | |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Mission Manifest — Install UX Hardening
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** install-ux-hardening-20260405
|
||||
**Statement:** Close the remaining gaps in the Mosaic Stack first-run and teardown experience uncovered by the post-`cli-unification` audit. A user MUST be able to cleanly uninstall the stack; the wizard MUST make security-sensitive surfaces visible (hooks, password entry); and CI/headless installs MUST NOT hang on interactive prompts. The longer-term goal is a single cohesive first-run flow that collapses `mosaic wizard` and `mosaic gateway install` into one state-bridged experience.
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** —
|
||||
**Progress:** 3 / 3 milestones
|
||||
**Status:** complete
|
||||
**Last Updated:** 2026-04-05 (mission complete)
|
||||
**Parent Mission:** [cli-unification-20260404](./archive/missions/cli-unification-20260404/MISSION-MANIFEST.md) (complete)
|
||||
|
||||
## Context
|
||||
|
||||
Post-merge audit of `cli-unification-20260404` (AC-1, AC-6) validated that the first-run wizard covers first user, password, admin tokens, gateway instance config, skills, and SOUL.md/USER.md init. The audit surfaced six gaps, grouped into three tracks of independent value.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: `mosaic uninstall` (top-level) cleanly reverses every mutation made by `tools/install.sh` — framework data, npm CLI, nested stack deps, runtime asset injections in `~/.claude/`, npmrc scope mapping, PATH edits. Dry-run supported. `--keep-data` preserves memory + user files + gateway DB. (PR #429)
|
||||
- [x] AC-2: `curl … | bash -s -- --uninstall` works without requiring a functioning CLI. (PR #429)
|
||||
- [x] AC-3: Password entry in `bootstrapFirstUser` is masked (no plaintext echo); confirm prompt added. (PR #431)
|
||||
- [x] AC-4: Wizard has an explicit hooks stage that previews which hooks will be installed, asks for confirmation, and records the user's choice. `mosaic config hooks list|enable|disable` surface exists. (PR #431 — consent; PR #433 — finalize-stage gating now honors `state.hooks.accepted === false` end-to-end)
|
||||
- [x] AC-5: `runConfigWizard` and `bootstrapFirstUser` accept a headless path (env vars + `--yes`) so `tools/install.sh --yes` + `MOSAIC_ASSUME_YES=1` completes end-to-end in CI without TTY. (PR #431)
|
||||
- [x] AC-6: `mosaic wizard` and `mosaic gateway install` are collapsed into a single cohesive entry point with shared state; gateway install is now terminal stages 11 & 12 of `runWizard`, session-file bridge removed, `mosaic gateway install` preserved as a thin standalone wrapper. (PR #433)
|
||||
- [x] AC-7: All milestones shipped as merged PRs with green CI and closed issues. (PRs #429, #431, #433)
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | --------------------------------------------------------- | ------ | ----------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | IUH-M01 | `mosaic uninstall` — top-level teardown + shell wrapper | done | feat/mosaic-uninstall | #425 | 2026-04-05 | 2026-04-05 |
|
||||
| 2 | IUH-M02 | Wizard remediation — hooks visibility, pwd mask, headless | done | feat/wizard-remediation | #426 | 2026-04-05 | 2026-04-05 |
|
||||
| 3 | IUH-M03 | Unified first-run wizard (collapse wizard + gateway) | done | feat/unified-first-run | #427 | 2026-04-05 | 2026-04-05 |
|
||||
|
||||
## Subagent Delegation Plan
|
||||
|
||||
| Milestone | Recommended Tier | Rationale |
|
||||
| --------- | ---------------- | ---------------------------------------------------------------------- |
|
||||
| IUH-M01 | sonnet | Standard feature work — new command surface mirroring existing install |
|
||||
| IUH-M02 | sonnet | Small surgical fixes across 3-4 files |
|
||||
| IUH-M03 | opus | Architectural refactor; state machine design decisions |
|
||||
|
||||
## Risks
|
||||
|
||||
- **Reversal completeness** — runtime asset linking creates `.mosaic-bak-*` backups; uninstall must honor them vs. when to delete. Ambiguity without an install manifest.
|
||||
- **npm global nested deps** — `npm uninstall -g @mosaicstack/mosaic` removes nested `@mosaicstack/*`, but ownership conflicts with explicitly installed peer packages (`@mosaicstack/gateway`, `@mosaicstack/memory`) need test coverage.
|
||||
- **Headless bootstrap** — admin password via env var is a credential on disk; needs clear documentation that `MOSAIC_ADMIN_PASSWORD` is intended for CI-only and should be rotated post-install.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `mosaicstack.dev/install.sh` vanity URL (blocked on marketing site work)
|
||||
- Uninstall for the `@mosaicstack/gateway` database contents — delegated to `mosaic gateway uninstall` semantics already in place
|
||||
- Signature/checksum verification of install scripts
|
||||
41
docs/archive/missions/install-ux-hardening-20260405/TASKS.md
Normal file
41
docs/archive/missions/install-ux-hardening-20260405/TASKS.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Tasks — Install UX Hardening
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** install-ux-hardening-20260405
|
||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
||||
|
||||
## Milestone 1 — `mosaic uninstall` (IUH-M01)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------- | ----- | ------ | --------------------- | ---------- | -------- | ------------------------------------------------------ |
|
||||
| IUH-01-01 | done | Design install manifest schema (`~/.config/mosaic/.install-manifest.json`) — what install writes on first success | #425 | sonnet | feat/mosaic-uninstall | — | 8K | v1 schema in `install-manifest.ts` |
|
||||
| IUH-01-02 | done | `mosaic uninstall` TS command: `--framework`, `--cli`, `--gateway`, `--all`, `--keep-data`, `--yes`, `--dry-run` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-01 | 25K | `uninstall.ts` |
|
||||
| IUH-01-03 | done | Reverse runtime asset linking in `~/.claude/` — restore `.mosaic-bak-*` if present, remove managed copies otherwise | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 12K | file list hardcoded from mosaic-link-runtime-assets |
|
||||
| IUH-01-04 | done | Reverse npmrc scope mapping and PATH edits made by `tools/install.sh` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 8K | npmrc reversed; no PATH edits found in v0.0.24 install |
|
||||
| IUH-01-05 | done | Shell fallback: `tools/install.sh --uninstall` path for users without a working CLI | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 10K | |
|
||||
| IUH-01-06 | done | Vitest coverage: dry-run output, `--all`, `--keep-data`, partial state, missing manifest | #425 | sonnet | feat/mosaic-uninstall | IUH-01-05 | 15K | 14 new tests, 170 total |
|
||||
| IUH-01-07 | done | Code review (independent) + remediation | #425 | sonnet | feat/mosaic-uninstall | IUH-01-06 | 5K | |
|
||||
| IUH-01-08 | done | PR open, CI green, review, merge to `main`, close issue | #425 | sonnet | feat/mosaic-uninstall | IUH-01-07 | 3K | PR #429, merge 25cada77 |
|
||||
|
||||
## Milestone 2 — Wizard Remediation (IUH-M02)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | -------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------- | ---------- | -------- | ----------------------------------------------- |
|
||||
| IUH-02-01 | done | Password masking: replace plaintext `rl.question` in `bootstrapFirstUser` with masked TTY read + confirmation | #426 | sonnet | feat/wizard-remediation | IUH-01-08 | 8K | `prompter/masked-prompt.ts` |
|
||||
| IUH-02-02 | done | Hooks preview stage in wizard: show `framework/runtime/claude/hooks-config.json` entries + confirm prompt | #426 | sonnet | feat/wizard-remediation | IUH-02-01 | 12K | `stages/hooks-preview.ts`; finalize gating TODO |
|
||||
| IUH-02-03 | done | `mosaic config hooks list\|enable\|disable` subcommands | #426 | sonnet | feat/wizard-remediation | IUH-02-02 | 15K | `commands/config.ts` |
|
||||
| IUH-02-04 | done | Headless path: env-var driven `runConfigWizard` + `bootstrapFirstUser` (`MOSAIC_ASSUME_YES`, `MOSAIC_ADMIN_*`) | #426 | sonnet | feat/wizard-remediation | IUH-02-03 | 12K | |
|
||||
| IUH-02-05 | done | Tests + code review + PR merge | #426 | sonnet | feat/wizard-remediation | IUH-02-04 | 10K | PR #431, merge cd8b1f66 |
|
||||
|
||||
## Milestone 3 — Unified First-Run Wizard (IUH-M03)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ----------------------------------------------------------------------------------------------------------- | ----- | ----- | ---------------------- | ---------- | -------- | ---------------------------------- |
|
||||
| IUH-03-01 | done | Design doc: unified state machine; decide whether `mosaic gateway install` becomes an internal wizard stage | #427 | opus | feat/unified-first-run | IUH-02-05 | 10K | scratchpad Session 5 |
|
||||
| IUH-03-02 | done | Refactor `runWizard` to invoke gateway install as a stage; drop the 10-minute session-file bridge | #427 | opus | feat/unified-first-run | IUH-03-01 | 25K | stages 11 & 12; bridge removed |
|
||||
| IUH-03-03 | done | Preserve backward-compat: `mosaic gateway install` still works as a standalone entry point | #427 | opus | feat/unified-first-run | IUH-03-02 | 10K | thin wrapper over stages |
|
||||
| IUH-03-04 | done | Tests + code review + PR merge | #427 | opus | feat/unified-first-run | IUH-03-03 | 12K | PR #433, merge 732f8a49; +15 tests |
|
||||
| IUH-03-05 | done | Bonus: honor `state.hooks.accepted` in finalize stage (closes M02 follow-up) | #427 | opus | feat/unified-first-run | IUH-03-04 | 5K | MOSAIC_SKIP_CLAUDE_HOOKS env flag |
|
||||
@@ -279,3 +279,52 @@ plan (this entry) → code → typecheck/lint/format → test → codex review (
|
||||
- **Round 3** — hooks removal too broad (would touch user-owned files); port override written to meta but not .env (drift); wizard swallowed errors. Fixed: `cmp -s` managed-file check, force regeneration when portOverride differs from saved port, re-throw unexpected errors.
|
||||
- **Round 4** — port-override regeneration tripped the corrupt-partial-state guard (blocker); headless already-bootstrapped-with-no-local-token path reported failure instead of no-op; hooks byte-equality fragile across template updates. Fixed: introduce `forcePortRegen` flag bypassing the guard (with a dedicated spec test), headless rerun of already-bootstrapped gateway now returns `{ completed: true }` (with spec coverage), hooks cleanup now checks for a stable `"mosaic-managed": true` marker embedded in the template (byte-equality remains as a fallback for legacy installs).
|
||||
- Round 5 codex review attempted but blocked by upstream usage limit (quota). Rerun after quota refresh if further findings appear; all round-4 findings are code-covered.
|
||||
|
||||
---
|
||||
|
||||
## Session 6 — 2026-04-05 (orchestrator close-out) — MISSION COMPLETE
|
||||
|
||||
### IUH-M03 completion summary (reported by opus delivery agent)
|
||||
|
||||
- **PR:** #433 merged as `732f8a49`
|
||||
- **CI:** Woodpecker green on final rebased commit `f3d5ef8d`
|
||||
- **Issue:** #427 closed with summary comment
|
||||
- **Tests:** 219 passing (+15 net new), 24 files
|
||||
- **Codex review:** 4 rounds applied and remediated; round 5 blocked by upstream quota — no known outstanding findings
|
||||
|
||||
### What shipped in M03
|
||||
|
||||
- NEW stages: `stages/gateway-config.ts`, `stages/gateway-bootstrap.ts` (extracted from the old monolithic `gateway/install.ts`)
|
||||
- NEW integration test: `__tests__/integration/unified-wizard.test.ts`
|
||||
- `runWizard` now has 12 stages — gateway config + bootstrap are terminal stages 11 & 12
|
||||
- 10-minute `$XDG_RUNTIME_DIR/mosaic-install-state.json` session-file bridge **deleted**
|
||||
- `mosaic gateway install` rewritten as a thin standalone wrapper invoking the same two stages — backward-compat preserved
|
||||
- `WizardState.gateway?` slice carries host/port/tier/admin/adminTokenIssued across stages
|
||||
- `tools/install.sh` single unified `mosaic wizard` call — no more two-phase launch
|
||||
- **Bonus scoped in:** finalize stage honors `state.hooks.accepted === false` via `MOSAIC_SKIP_CLAUDE_HOOKS=1`; `mosaic-link-runtime-assets` honors the flag; Mosaic-managed detection now uses a stable `"mosaic-managed": true` marker in `hooks-config.json` with byte-equality fallback for legacy installs. **Closes the M02 follow-up.**
|
||||
|
||||
### Mission status — ALL DONE
|
||||
|
||||
| AC | Status | PR |
|
||||
| ---- | ------ | ---------------------------------------------------- |
|
||||
| AC-1 | ✓ | #429 |
|
||||
| AC-2 | ✓ | #429 |
|
||||
| AC-3 | ✓ | #431 |
|
||||
| AC-4 | ✓ | #431 + #433 (gating) |
|
||||
| AC-5 | ✓ | #431 |
|
||||
| AC-6 | ✓ | #433 |
|
||||
| AC-7 | ✓ | #429, #431, #433 all merged, CI green, issues closed |
|
||||
|
||||
### Follow-ups for future work (not blocking mission close)
|
||||
|
||||
1. **`pr-ci-wait.sh` vs Woodpecker**: wrapper reports `state=unknown` because Woodpecker doesn't publish to Gitea's combined-status endpoint. Worker used `tea pr` CI glyphs as authoritative. Pre-existing tooling gap — worth a separate tooling-team issue.
|
||||
2. **`issue-create.sh` / `pr-create.sh` wrapper `eval` bug with multiline bodies** — hit by M01, M02, M03 workers. All fell back to Gitea REST API. Needs wrapper fix.
|
||||
3. **Codex review round 5** — attempted but blocked by upstream quota. Rerun after quota resets to confirm nothing else surfaces.
|
||||
4. **Pi settings.json reversal** — deferred from M01; install manifest schema should be extended to track Pi settings mutations for reversal.
|
||||
5. **`cli-smoke.spec.ts` pre-existing failure** — `@mosaicstack/brain` resolution in Vitest. Unrelated. Worth a separate issue.
|
||||
|
||||
### Next steps (orchestrator)
|
||||
|
||||
1. This scratchpad + MISSION-MANIFEST.md + TASKS.md updates → final docs PR
|
||||
2. After merge: create release tag per framework rule (milestone/mission completion = release tag + repository release)
|
||||
3. Archive mission docs under `docs/archive/missions/install-ux-hardening-20260405/` once the tag is published
|
||||
|
||||
173
docs/scratchpads/install-ux-v2-20260405.md
Normal file
173
docs/scratchpads/install-ux-v2-20260405.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Install UX v2 — Orchestrator Scratchpad
|
||||
|
||||
## Session 1 — 2026-04-05 (orchestrator scaffold)
|
||||
|
||||
### Trigger
|
||||
|
||||
Real-run testing of `@mosaicstack/mosaic@0.0.25` (fresh install of the release we just shipped from the parent mission `install-ux-hardening-20260405`) surfaced a critical regression and a cluster of UX failings. User feedback verbatim:
|
||||
|
||||
> The skill/additional feature installation section of install.sh is unsable
|
||||
> The "quick-start" is asking way too many questions. This process should be much faster to get a quick start.
|
||||
> The installater should have a main menu that allows for a drill-down install approach.
|
||||
> "Plugins" — Install Recommended Plugins / Custom
|
||||
> "Providers" — …
|
||||
> The gateway port is not prefilling with 14242 for default
|
||||
> What is the CORS origin for? Is that the webUI that isn't working yet? Maybe we should ask for the fqdn/hostname instead? There must be a better way to handle this.
|
||||
|
||||
Plus the critical bug, reproduced verbatim:
|
||||
|
||||
```
|
||||
◇ Admin email
|
||||
│ jason@woltje.com
|
||||
Admin password (min 8 chars): ****************
|
||||
Confirm password: ****************
|
||||
│
|
||||
▲ Bootstrap failed (400): {"message":["property email should not exist","property password should not exist"],"error":"Bad Request","statusCode":400}
|
||||
✔ Wizard complete.
|
||||
✔ Install manifest written: /home/jarvis/.config/mosaic/.install-manifest.json
|
||||
|
||||
✔ Done.
|
||||
```
|
||||
|
||||
Note the `✔ Wizard complete` and `✔ Done` lines **after** the 400. That's a second bug — failure didn't propagate in interactive mode.
|
||||
|
||||
### Diagnosis — orchestrator pre-scope
|
||||
|
||||
To avoid handing workers a vague prompt, pre-identified the concrete fix sites:
|
||||
|
||||
**Bug 1 (critical) — DTO class erasure.** `apps/gateway/src/admin/bootstrap.controller.ts:16`:
|
||||
|
||||
```ts
|
||||
import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||
```
|
||||
|
||||
`import type` erases the class at runtime. `@Body() dto: BootstrapSetupDto` then has no runtime metatype — `design:paramtypes` reflects `Object`. Nest's `ValidationPipe` with `whitelist: true` + `forbidNonWhitelisted: true` receives a plain Object metatype, treats every incoming property as non-whitelisted, and 400s with `"property email should not exist", "property password should not exist"`.
|
||||
|
||||
**One-character fix:** drop the `type` keyword on the `BootstrapSetupDto` import. `BootstrapStatusDto` and `BootstrapResultDto` are fine as type-only imports because they're used only in return type positions, not as `@Body()` metatypes.
|
||||
|
||||
Must be covered by an **integration test that binds through Nest**, not a controller unit test that imports the DTO directly — the unit test path would pass even with `import type` because it constructs the pipe manually. An e2e test with `@nestjs/testing` + `supertest` against the real `/api/bootstrap/setup` endpoint is the right guard.
|
||||
|
||||
**Bug 2 — interactive silent failure.** `packages/mosaic/src/wizard.ts:147-150`:
|
||||
|
||||
```ts
|
||||
if (!bootstrapResult.completed && headlessRun) {
|
||||
prompter.warn('Admin bootstrap failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
The guard is `&& headlessRun`. In interactive mode, `completed: false` is silently swallowed and the wizard continues to the success lines. Fix: propagate failure in both modes. Decision for the worker — either `throw` or `process.exit(1)` with a clear error.
|
||||
|
||||
**Bug 3 — port prefill.** `packages/mosaic/src/stages/gateway-config.ts:77-88`:
|
||||
|
||||
```ts
|
||||
const raw = await p.text({
|
||||
message: 'Gateway port',
|
||||
defaultValue: defaultPort.toString(),
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
The stage is passing `defaultValue`. Either the `WizardPrompter.text` adapter is dropping it, or the underlying `@clack/prompts` call expects `initialValue` (which actually prefills the buffer) vs `defaultValue` (which is used only if the user submits an empty string). Worker should verify the adapter and likely switch to `initialValue` semantics so the user sees `14242` in the field.
|
||||
|
||||
**Bug 4 — Pi SDK copy gap.** The `"What is Mosaic?"` intro text enumerates Claude Code, Codex, and OpenCode but never mentions Pi SDK, which is the actual agent runtime behind those frontends. Purely a copy edit — find the string, add Pi SDK.
|
||||
|
||||
### Mission shape
|
||||
|
||||
Three milestones, three tracks, different tiers:
|
||||
|
||||
1. **IUV-M01 Hotfix** (sonnet) — the four bugs above + release `mosaic-v0.0.26`. Small, fast, unblocks the 0.0.25 happy path.
|
||||
2. **IUV-M02 UX polish** (sonnet) — CORS origin → FQDN/hostname abstraction; diagnose and rework the skill installer section. Diagnostic-heavy.
|
||||
3. **IUV-M03 Provider-first intelligent flow** (opus) — the big one: drill-down main menu, Quick Start path that's actually quick, provider-first natural-language intake with agent self-naming (OpenClaw-style). Architectural.
|
||||
|
||||
Sequencing: strict. M01 ships first as a hotfix release (mosaic-v0.0.26). M02 is diagnostic-heavy and can share groundwork with M03 but ships separately for clean release notes. M03 is the architectural anchor and lands last as `mosaic-v0.0.27`.
|
||||
|
||||
### Open design questions (to be resolved by workers, not pre-decided)
|
||||
|
||||
- M01: does `process.exit(1)` vs `throw` matter for how `tools/install.sh` surfaces the error? Worker should check the install.sh call site and pick the behavior that surfaces cleanly.
|
||||
- M03: what LLM call powers the intent intake, and what's the offline fallback? Options: (a) reuse the provider the user is configuring (chicken-and-egg — provider setup hasn't happened yet), (b) a bundled deterministic "advisor" that hard-codes common intents, (c) require a provider key up-front before intake. Design doc (IUV-03-01) must resolve.
|
||||
- M03: is the "agent self-naming" persistent across all future `mosaic` invocations, or a per-session nickname? Probably persistent — lives in `~/.config/mosaic/agent.json` or similar. Worker to decide + document.
|
||||
|
||||
### Non-goals for this mission
|
||||
|
||||
- No GUI / web UI
|
||||
- No registry / pipeline migration
|
||||
- No multi-user / multi-tenant onboarding
|
||||
- No rework of `mosaic uninstall` (stable from parent mission)
|
||||
|
||||
### Known tooling caveats (carry forward from parent mission)
|
||||
|
||||
- `issue-create.sh` / `pr-create.sh` wrappers have an `eval` bug with multiline bodies — use Gitea REST API fallback with `load_credentials gitea-mosaicstack`
|
||||
- `pr-ci-wait.sh` reports `state=unknown` against Woodpecker (combined-status endpoint gap) — use `tea pr` glyphs or poll the commit status endpoint directly
|
||||
- Protected `main`, squash-merge only, PR-required
|
||||
- CI queue guard before push/merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge`
|
||||
|
||||
### Next action
|
||||
|
||||
1. Create Gitea issues for M01, M02, M03
|
||||
2. Open the mission-scaffold docs PR (same pattern as parent mission's PR #430)
|
||||
3. After merge, delegate IUV-M01 to a sonnet subagent in an isolated worktree with the concrete fix-site pointers above
|
||||
|
||||
## Session 2 — 2026-04-05 (IUV-M01 delivery + close-out)
|
||||
|
||||
### Outcome
|
||||
|
||||
IUV-M01 shipped. `mosaic-v0.0.26` released and registry latest confirmed `0.0.26`.
|
||||
|
||||
### PRs merged
|
||||
|
||||
| PR | Title | Merge |
|
||||
| ---- | ------------------------------------------------------------------------ | -------- |
|
||||
| #440 | fix: bootstrap hotfix — DTO erasure, wizard failure, port prefill, copy | 0ae932ab |
|
||||
| #441 | fix: add vitest.config.ts to eslint allowDefaultProject (#440 build fix) | c08aa6fa |
|
||||
| #442 | docs: mark IUV-M01 complete — mosaic-v0.0.26 released | 78388437 |
|
||||
|
||||
### Bugs fixed (all 4 in worker's PR #440)
|
||||
|
||||
1. **DTO class erasure** — `apps/gateway/src/admin/bootstrap.controller.ts:16` — dropped `type` from `import { BootstrapSetupDto }`. Guarded by new e2e test `bootstrap.e2e.spec.ts` (4 cases) that binds through a real Nest app with `ValidationPipe { whitelist, forbidNonWhitelisted }`. Test suite needed `unplugin-swc` in `apps/gateway/vitest.config.ts` to emit `decoratorMetadata` (tsx/esbuild can't).
|
||||
2. **Wizard silent failure** — `packages/mosaic/src/wizard.ts` — removed the `&& headlessRun` guard so `!bootstrapResult.completed` now aborts in both modes.
|
||||
3. **Port prefill** — root cause was clack's `defaultValue` vs `initialValue` semantics (`defaultValue` only fills on empty submit, `initialValue` prefills the buffer). Added an `initialValue` field to `WizardPrompter.text()` interface, threaded through clack and headless prompters, switched `gateway-config.ts` port/url prompts to use it.
|
||||
4. **Pi SDK copy** — `packages/mosaic/src/stages/welcome.ts` — intro copy now lists Pi SDK.
|
||||
|
||||
### Mid-delivery hiccup — tsconfig/eslint cross-contamination
|
||||
|
||||
Worker's initial approach added `vitest.config.ts` to `apps/gateway/tsconfig.json`'s `include` to appease the eslint parser. That broke `pnpm --filter @mosaicstack/gateway build` with TS6059 (`vitest.config.ts` outside `rootDir: "src"`). The publish pipeline on the `#440` merge commit failed.
|
||||
|
||||
**Correct fix** (worker's PR #441): leave `tsconfig.json` clean (`include: ["src/**/*"]`) and instead add the file to `allowDefaultProject` in the root `eslint.config.mjs`. This keeps the tsc program strict while letting eslint resolve a parser project for the standalone config file.
|
||||
|
||||
**Pattern to remember**: when adding root-level `.ts` config files (vitest, build scripts) to a package with `rootDir: "src"`, the eslint parser project conflict is solved with `allowDefaultProject`, NEVER by widening tsconfig include. I had independently arrived at the same fix on a branch before the worker shipped #441 — deleted the duplicate.
|
||||
|
||||
### Residual follow-ups carried forward
|
||||
|
||||
1. Headless prompter fallback order: worker set `initialValue > defaultValue` in the headless path. Correct semantic, but any future headless test that explicitly depends on `defaultValue` precedence will need review.
|
||||
2. Vitest + SWC decorator metadata pattern is now the blessed approach for NestJS e2e tests in this monorepo. Any other package that adds NestJS e2e tests should mirror `apps/gateway/vitest.config.ts`.
|
||||
|
||||
### Next action
|
||||
|
||||
- Close out orchestrator doc sync (this commit): mark M01 subtasks done in `TASKS.md`, update manifest phase to Execution, commit scratchpad session 2, PR to main.
|
||||
- After merge, delegate IUV-M02 (sonnet, isolated worktree). Dependencies: IUV-02-01 (CORS→FQDN) starts unblocked since M01 is released; first real task for the M02 worker is diagnosing the skill installer failure modes (IUV-02-02) against the fresh 0.0.26 install.
|
||||
|
||||
## Session 3 — 2026-04-05 (IUV-M02 delivery + close-out)
|
||||
|
||||
### Outcome
|
||||
|
||||
IUV-M02 shipped. PR #444 merged (`172bacb3`), issue #437 closed. 18 new tests (13 CORS derivation, 5 skill sync).
|
||||
|
||||
### Changes
|
||||
|
||||
**CORS → FQDN (IUV-02-01):**
|
||||
|
||||
- `packages/mosaic/src/stages/gateway-config.ts` — replaced raw "CORS origin" text prompt with "Web UI hostname" (default: `localhost`). Added HTTPS follow-up for remote hosts. Pure `deriveCorsOrigin(hostname, port, useHttps?)` function exported for testability.
|
||||
- Headless: `MOSAIC_HOSTNAME` env var as friendly alternative; `MOSAIC_CORS_ORIGIN` still works as full override.
|
||||
- `packages/mosaic/src/types.ts` — added `hostname?: string` to `GatewayState`.
|
||||
|
||||
**Skill installer rework (IUV-02-02 + IUV-02-03):**
|
||||
|
||||
- Root cause confirmed: `syncSkills()` in `finalize.ts` ignored `state.selectedSkills` entirely. The multiselect UI was a no-op.
|
||||
- `packages/mosaic/src/stages/finalize.ts` — `syncSkills()` rewritten to accept `selectedSkills[]`, returns typed `SyncSkillsResult`, passes `MOSAIC_INSTALL_SKILLS` (colon-separated) as env var to the bash script.
|
||||
- `packages/mosaic/framework/tools/_scripts/mosaic-sync-skills` — added bash associative array whitelist filter keyed on `MOSAIC_INSTALL_SKILLS`. When set, only whitelisted skills are linked. Empty/unset = all skills (legacy behavior preserved for `mosaic sync` outside wizard).
|
||||
- Failure surfaces: silent `catch {}` replaced with typed error reporting through `p.warn()`.
|
||||
|
||||
### Next action
|
||||
|
||||
- Delegate IUV-M03 (opus, isolated worktree) — the architectural milestone: provider-first intelligent flow, drill-down main menu, Quick Start fast path, agent self-naming. This is the biggest piece of the mission.
|
||||
227
docs/scratchpads/iuv-m03-design.md
Normal file
227
docs/scratchpads/iuv-m03-design.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# IUV-M03 Design: Provider-first intelligent flow + drill-down main menu
|
||||
|
||||
**Issue:** #438
|
||||
**Branch:** `feat/install-ux-intent`
|
||||
**Date:** 2026-04-05
|
||||
|
||||
## 1. New first-run state machine
|
||||
|
||||
The linear 12-stage interrogation is replaced with a menu-driven architecture.
|
||||
|
||||
### Flow overview
|
||||
|
||||
```
|
||||
Welcome banner
|
||||
|
|
||||
v
|
||||
Detect existing install (auto)
|
||||
|
|
||||
v
|
||||
Main Menu (loop)
|
||||
|-- Quick Start -> provider key + admin creds -> finalize
|
||||
|-- Providers -> LLM API key config
|
||||
|-- Agent Identity -> intent intake + naming (deterministic)
|
||||
|-- Skills -> recommended / custom selection
|
||||
|-- Gateway -> port, storage tier, hostname, CORS
|
||||
|-- Advanced -> SOUL.md, USER.md, TOOLS.md, runtimes, hooks
|
||||
|-- Finish & Apply -> finalize + gateway bootstrap
|
||||
v
|
||||
Done
|
||||
```
|
||||
|
||||
### Menu navigation
|
||||
|
||||
- Main menu is a `select` prompt. Each option drills into a sub-flow.
|
||||
- Completing a section returns to the main menu.
|
||||
- Menu items show completion state: `[done]` hint after configuration.
|
||||
- `Finish & Apply` is always last and requires at minimum a provider key (or explicit skip).
|
||||
- The menu tracks configured sections in `WizardState.completedSections`.
|
||||
|
||||
### Headless bypass
|
||||
|
||||
When `MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`, the entire menu is skipped.
|
||||
The wizard runs: defaults + env var overrides -> finalize -> gateway config -> bootstrap.
|
||||
This preserves full backward compatibility with `tools/install.sh --yes`.
|
||||
|
||||
## 2. Quick Start path
|
||||
|
||||
Target: 3-5 questions max. Under 90 seconds for a returning user.
|
||||
|
||||
### Questions asked
|
||||
|
||||
1. **Provider API key** (Anthropic/OpenAI) - `text` prompt with paste support
|
||||
2. **Admin email** - `text` prompt
|
||||
3. **Admin password** - masked + confirmed
|
||||
|
||||
### Questions skipped (with defaults)
|
||||
|
||||
| Setting | Default | Rationale |
|
||||
| ---------------------------- | ------------------------------- | ---------------------- |
|
||||
| Agent name | "Mosaic" | Generic but branded |
|
||||
| Port | 14242 | Standard default |
|
||||
| Storage tier | local | No external deps |
|
||||
| Hostname | localhost | Dev-first |
|
||||
| CORS origin | http://localhost:3000 | Standard web UI port |
|
||||
| Skills | recommended set | Curated by maintainers |
|
||||
| Runtimes | auto-detected | No user input needed |
|
||||
| Communication style | direct | Most popular choice |
|
||||
| SOUL.md / USER.md / TOOLS.md | template defaults | Can customize later |
|
||||
| Hooks | auto-install if Claude detected | Safe default |
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
Quick Start selected
|
||||
-> "Paste your LLM API key (Anthropic recommended):"
|
||||
-> [auto-detect provider from key prefix: sk-ant-* = Anthropic, sk-* = OpenAI]
|
||||
-> Apply all defaults
|
||||
-> Run finalize (sync framework, write configs, link assets, sync skills)
|
||||
-> Run gateway config (headless-style with defaults + provided key)
|
||||
-> "Admin email:"
|
||||
-> "Admin password:" (masked + confirm)
|
||||
-> Run gateway bootstrap
|
||||
-> Done
|
||||
```
|
||||
|
||||
## 3. Provider-first flow
|
||||
|
||||
Provider configuration (currently buried in gateway-config stage as "ANTHROPIC_API_KEY")
|
||||
moves to a dedicated top-level menu item and is the first question in Quick Start.
|
||||
|
||||
### Provider detection
|
||||
|
||||
The API key prefix determines the provider:
|
||||
|
||||
- `sk-ant-api03-*` -> Anthropic (Claude)
|
||||
- `sk-*` -> OpenAI
|
||||
- Empty/skipped -> no provider (gateway starts without LLM access)
|
||||
|
||||
### Storage
|
||||
|
||||
The provider key is stored in the gateway `.env` as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`.
|
||||
For Quick Start, this replaces the old interactive prompt in `collectAndWriteConfig`.
|
||||
|
||||
### Menu section: "Providers"
|
||||
|
||||
In the drill-down menu, "Providers" lets users:
|
||||
|
||||
1. Enter/change their API key
|
||||
2. See which provider was detected
|
||||
3. Optionally configure a second provider
|
||||
|
||||
For v0.0.27, we support Anthropic and OpenAI keys only. The key is stored
|
||||
in `WizardState` and written during finalize.
|
||||
|
||||
## 4. Intent intake + naming (deterministic fallback - Option B)
|
||||
|
||||
### Rationale
|
||||
|
||||
At install time, the LLM provider may not be configured yet (chicken-and-egg).
|
||||
We use **Option B: deterministic advisor** for the install wizard.
|
||||
|
||||
### Flow (Agent Identity menu section)
|
||||
|
||||
```
|
||||
1. "What will this agent primarily help you with?"
|
||||
-> Select from presets:
|
||||
- General purpose assistant
|
||||
- Software development
|
||||
- DevOps & infrastructure
|
||||
- Research & analysis
|
||||
- Content & writing
|
||||
- Custom (free text description)
|
||||
|
||||
2. System proposes a thematic name based on selection:
|
||||
- General purpose -> "Mosaic"
|
||||
- Software development -> "Forge"
|
||||
- DevOps & infrastructure -> "Sentinel"
|
||||
- Research & analysis -> "Atlas"
|
||||
- Content & writing -> "Muse"
|
||||
- Custom -> "Mosaic" (default)
|
||||
|
||||
3. "Your agent will be named 'Forge'. Press Enter to accept or type a new name:"
|
||||
-> User confirms or overrides
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
- Agent name -> `WizardState.soul.agentName` -> written to SOUL.md
|
||||
- Intent category -> `WizardState.agentIntent` (new field) -> written to `~/.config/mosaic/agent.json`
|
||||
|
||||
### Post-install LLM-powered intake (future)
|
||||
|
||||
A future `mosaic configure identity` command can use the configured LLM to:
|
||||
|
||||
- Accept free-text intent description
|
||||
- Generate an expounded persona
|
||||
- Propose a contextual name
|
||||
|
||||
This is explicitly out of scope for the install wizard.
|
||||
|
||||
## 5. Headless backward-compat
|
||||
|
||||
### Supported env vars (unchanged)
|
||||
|
||||
| Variable | Used by |
|
||||
| -------------------------- | ---------------------------------------------- |
|
||||
| `MOSAIC_ASSUME_YES=1` | Skip all prompts, use defaults + env overrides |
|
||||
| `MOSAIC_ADMIN_NAME` | Gateway bootstrap |
|
||||
| `MOSAIC_ADMIN_EMAIL` | Gateway bootstrap |
|
||||
| `MOSAIC_ADMIN_PASSWORD` | Gateway bootstrap |
|
||||
| `MOSAIC_GATEWAY_PORT` | Gateway config |
|
||||
| `MOSAIC_HOSTNAME` | Gateway config (CORS derivation) |
|
||||
| `MOSAIC_CORS_ORIGIN` | Gateway config (full override) |
|
||||
| `MOSAIC_STORAGE_TIER` | Gateway config (local/team) |
|
||||
| `MOSAIC_DATABASE_URL` | Gateway config (team tier) |
|
||||
| `MOSAIC_VALKEY_URL` | Gateway config (team tier) |
|
||||
| `MOSAIC_ANTHROPIC_API_KEY` | Provider config |
|
||||
|
||||
### New env vars
|
||||
|
||||
| Variable | Purpose |
|
||||
| --------------------- | ----------------------------------------- |
|
||||
| `MOSAIC_AGENT_NAME` | Override agent name in headless mode |
|
||||
| `MOSAIC_AGENT_INTENT` | Override intent category in headless mode |
|
||||
|
||||
### `tools/install.sh --yes`
|
||||
|
||||
The install script sets `MOSAIC_ASSUME_YES=1` and passes through env vars.
|
||||
No changes needed to the script itself. The new wizard detects headless mode
|
||||
at the top of `runWizard` and runs a linear path identical to the old flow.
|
||||
|
||||
## 6. Explicit non-goals
|
||||
|
||||
- **No GUI** — this is a terminal wizard only
|
||||
- **No multi-user install** — single-user, single-machine
|
||||
- **No registry changes** — npm publish flow is unchanged
|
||||
- **No LLM calls during install** — deterministic fallback only
|
||||
- **No new dependencies** — uses existing @clack/prompts and picocolors
|
||||
- **No changes to gateway API** — only the wizard orchestration changes
|
||||
- **No changes to tools/install.sh** — headless compat maintained via env vars
|
||||
|
||||
## 7. Implementation plan
|
||||
|
||||
### Files to modify
|
||||
|
||||
1. `packages/mosaic/src/types.ts` — add `MenuSection`, `AgentIntent`, `completedSections`, `agentIntent`, `providerKey`, `providerType` to WizardState
|
||||
2. `packages/mosaic/src/wizard.ts` — replace linear flow with menu loop
|
||||
3. `packages/mosaic/src/stages/mode-select.ts` — becomes the main menu
|
||||
4. `packages/mosaic/src/stages/provider-setup.ts` — new: provider key collection
|
||||
5. `packages/mosaic/src/stages/agent-intent.ts` — new: intent intake + naming
|
||||
6. `packages/mosaic/src/stages/menu-gateway.ts` — new: gateway sub-menu wrapper
|
||||
7. `packages/mosaic/src/stages/quick-start.ts` — new: quick start linear path
|
||||
8. `packages/mosaic/src/constants.ts` — add intent presets and name mappings
|
||||
9. `packages/mosaic/package.json` — version bump 0.0.26 -> 0.0.27
|
||||
|
||||
### Files to add (tests)
|
||||
|
||||
1. `packages/mosaic/src/stages/wizard-menu.spec.ts` — menu navigation tests
|
||||
2. `packages/mosaic/src/stages/quick-start.spec.ts` — quick start path tests
|
||||
3. `packages/mosaic/src/stages/agent-intent.spec.ts` — intent + naming tests
|
||||
4. `packages/mosaic/src/stages/provider-setup.spec.ts` — provider detection tests
|
||||
|
||||
### Migration strategy
|
||||
|
||||
The existing stage functions remain intact. The menu system wraps them —
|
||||
each menu item calls the appropriate stage function(s). The linear headless
|
||||
path calls them in the same order as before.
|
||||
@@ -27,6 +27,7 @@ export default tseslint.config(
|
||||
'apps/web/e2e/*.ts',
|
||||
'apps/web/e2e/helpers/*.ts',
|
||||
'apps/web/playwright.config.ts',
|
||||
'apps/gateway/vitest.config.ts',
|
||||
'packages/mosaic/__tests__/*.ts',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import { runWizard } from '../../src/wizard.js';
|
||||
describe('Full Wizard (headless)', () => {
|
||||
let tmpDir: string;
|
||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
||||
@@ -32,12 +33,16 @@ describe('Full Wizard (headless)', () => {
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('quick start produces valid SOUL.md', async () => {
|
||||
// The headless path reads agent name from MOSAIC_AGENT_NAME env var
|
||||
// (via agentIntentStage) rather than prompting interactively.
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
@@ -62,9 +67,10 @@ describe('Full Wizard (headless)', () => {
|
||||
});
|
||||
|
||||
it('quick start produces valid USER.md', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'He/Him',
|
||||
|
||||
@@ -7,6 +7,11 @@ SKILLS_REPO_DIR="${MOSAIC_SKILLS_REPO_DIR:-$MOSAIC_HOME/sources/agent-skills}"
|
||||
MOSAIC_SKILLS_DIR="$MOSAIC_HOME/skills"
|
||||
MOSAIC_LOCAL_SKILLS_DIR="$MOSAIC_HOME/skills-local"
|
||||
|
||||
# Colon-separated list of skill names to install. When set, only these skills
|
||||
# are linked into runtime skill directories. Empty/unset = link all skills
|
||||
# (the legacy "mosaic sync" full-catalog behavior).
|
||||
MOSAIC_INSTALL_SKILLS="${MOSAIC_INSTALL_SKILLS:-}"
|
||||
|
||||
fetch=1
|
||||
link_only=0
|
||||
|
||||
@@ -25,6 +30,7 @@ Env:
|
||||
MOSAIC_HOME Default: ~/.config/mosaic
|
||||
MOSAIC_SKILLS_REPO_URL Default: https://git.mosaicstack.dev/mosaic/agent-skills.git
|
||||
MOSAIC_SKILLS_REPO_DIR Default: ~/.config/mosaic/sources/agent-skills
|
||||
MOSAIC_INSTALL_SKILLS Colon-separated list of skills to link (default: all)
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -156,6 +162,27 @@ link_targets=(
|
||||
|
||||
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
|
||||
|
||||
# Build an associative array from the colon-separated whitelist for O(1) lookup.
|
||||
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
||||
declare -A _skill_whitelist=()
|
||||
_whitelist_active=0
|
||||
if [[ -n "$MOSAIC_INSTALL_SKILLS" ]]; then
|
||||
_whitelist_active=1
|
||||
IFS=':' read -ra _wl_items <<< "$MOSAIC_INSTALL_SKILLS"
|
||||
for _item in "${_wl_items[@]}"; do
|
||||
[[ -n "$_item" ]] && _skill_whitelist["$_item"]=1
|
||||
done
|
||||
fi
|
||||
|
||||
is_skill_selected() {
|
||||
local name="$1"
|
||||
if [[ $_whitelist_active -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ -n "${_skill_whitelist[$name]:-}" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
link_skill_into_target() {
|
||||
local skill_path="$1"
|
||||
local target_dir="$2"
|
||||
@@ -168,6 +195,11 @@ link_skill_into_target() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Respect the install whitelist (set during first-run wizard).
|
||||
if ! is_skill_selected "$name"; then
|
||||
return
|
||||
fi
|
||||
|
||||
link_path="$target_dir/$name"
|
||||
|
||||
if [[ -L "$link_path" ]]; then
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.24",
|
||||
"version": "0.0.27",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
|
||||
@@ -26,6 +26,53 @@ export const DEFAULTS = {
|
||||
| (add your git providers here) | | | |`,
|
||||
};
|
||||
|
||||
/** Preset intent categories with display labels and suggested agent names. */
|
||||
export const INTENT_PRESETS: Record<
|
||||
string,
|
||||
{ label: string; hint: string; suggestedName: string }
|
||||
> = {
|
||||
general: {
|
||||
label: 'General purpose assistant',
|
||||
hint: 'Versatile helper for any task',
|
||||
suggestedName: 'Mosaic',
|
||||
},
|
||||
'software-dev': {
|
||||
label: 'Software development',
|
||||
hint: 'Coding, debugging, architecture',
|
||||
suggestedName: 'Forge',
|
||||
},
|
||||
devops: {
|
||||
label: 'DevOps & infrastructure',
|
||||
hint: 'CI/CD, containers, monitoring',
|
||||
suggestedName: 'Sentinel',
|
||||
},
|
||||
research: {
|
||||
label: 'Research & analysis',
|
||||
hint: 'Data analysis, literature review',
|
||||
suggestedName: 'Atlas',
|
||||
},
|
||||
content: {
|
||||
label: 'Content & writing',
|
||||
hint: 'Documentation, copywriting, editing',
|
||||
suggestedName: 'Muse',
|
||||
},
|
||||
custom: {
|
||||
label: 'Custom',
|
||||
hint: 'Describe your own use case',
|
||||
suggestedName: 'Mosaic',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect LLM provider type from an API key prefix.
|
||||
*/
|
||||
export function detectProviderType(key: string): 'anthropic' | 'openai' | 'none' {
|
||||
if (!key) return 'none';
|
||||
if (key.startsWith('sk-ant-')) return 'anthropic';
|
||||
if (key.startsWith('sk-')) return 'openai';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export const RECOMMENDED_SKILLS = new Set([
|
||||
'brainstorming',
|
||||
'code-review-excellence',
|
||||
|
||||
@@ -39,6 +39,7 @@ export class ClackPrompter implements WizardPrompter {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
initialValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string> {
|
||||
const validate = opts.validate
|
||||
@@ -51,6 +52,7 @@ export class ClackPrompter implements WizardPrompter {
|
||||
message: opts.message,
|
||||
placeholder: opts.placeholder,
|
||||
defaultValue: opts.defaultValue,
|
||||
initialValue: opts.initialValue,
|
||||
validate,
|
||||
});
|
||||
return guardCancel(result);
|
||||
|
||||
@@ -35,15 +35,18 @@ export class HeadlessPrompter implements WizardPrompter {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
initialValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
const value =
|
||||
typeof answer === 'string'
|
||||
? answer
|
||||
: opts.defaultValue !== undefined
|
||||
? opts.defaultValue
|
||||
: undefined;
|
||||
: opts.initialValue !== undefined
|
||||
? opts.initialValue
|
||||
: opts.defaultValue !== undefined
|
||||
? opts.defaultValue
|
||||
: undefined;
|
||||
|
||||
if (value === undefined) {
|
||||
throw new Error(`HeadlessPrompter: no answer for "${opts.message}"`);
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface WizardPrompter {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
/** Prefills the input buffer so the user sees the value and can press Enter to accept. */
|
||||
initialValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string>;
|
||||
|
||||
|
||||
129
packages/mosaic/src/stages/agent-intent.spec.ts
Normal file
129
packages/mosaic/src/stages/agent-intent.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { agentIntentStage } from './agent-intent.js';
|
||||
|
||||
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn().mockResolvedValue('Mosaic'),
|
||||
confirm: vi.fn().mockResolvedValue(false),
|
||||
select: vi.fn().mockResolvedValue('general'),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||
separator: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/mosaic',
|
||||
sourceDir: '/tmp/mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('agentIntentStage', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('uses default intent and name in headless mode', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
delete process.env['MOSAIC_AGENT_INTENT'];
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('general');
|
||||
expect(state.soul.agentName).toBe('Mosaic');
|
||||
});
|
||||
|
||||
it('reads intent from MOSAIC_AGENT_INTENT env var', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_AGENT_INTENT'] = 'software-dev';
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('software-dev');
|
||||
expect(state.soul.agentName).toBe('Forge');
|
||||
});
|
||||
|
||||
it('honors MOSAIC_AGENT_NAME env var override', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_AGENT_INTENT'] = 'devops';
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'MyBot';
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('devops');
|
||||
expect(state.soul.agentName).toBe('MyBot');
|
||||
});
|
||||
|
||||
it('falls back to general for unknown intent values', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_AGENT_INTENT'] = 'nonexistent';
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('general');
|
||||
expect(state.soul.agentName).toBe('Mosaic');
|
||||
});
|
||||
|
||||
it('prompts for intent and name in interactive mode', async () => {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
const origIsTTY = process.stdin.isTTY;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
|
||||
const state = makeState();
|
||||
const p = buildPrompter({
|
||||
select: vi.fn().mockResolvedValue('research'),
|
||||
text: vi.fn().mockResolvedValue('Atlas'),
|
||||
});
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('research');
|
||||
expect(state.soul.agentName).toBe('Atlas');
|
||||
expect(p.select).toHaveBeenCalled();
|
||||
expect(p.text).toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
|
||||
});
|
||||
|
||||
it('maps content intent to Muse suggested name', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_AGENT_INTENT'] = 'content';
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await agentIntentStage(p, state);
|
||||
|
||||
expect(state.agentIntent).toBe('content');
|
||||
expect(state.soul.agentName).toBe('Muse');
|
||||
});
|
||||
});
|
||||
64
packages/mosaic/src/stages/agent-intent.ts
Normal file
64
packages/mosaic/src/stages/agent-intent.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { AgentIntent, WizardState } from '../types.js';
|
||||
import { INTENT_PRESETS } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Agent intent + naming stage — deterministic (no LLM required).
|
||||
*
|
||||
* The user picks an intent category from presets, the system proposes a
|
||||
* thematic name, and the user confirms or overrides it.
|
||||
*
|
||||
* In headless mode, reads from `MOSAIC_AGENT_INTENT` and `MOSAIC_AGENT_NAME`.
|
||||
*/
|
||||
export async function agentIntentStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
|
||||
if (isHeadless) {
|
||||
const intentEnv = process.env['MOSAIC_AGENT_INTENT'] ?? 'general';
|
||||
const nameEnv = process.env['MOSAIC_AGENT_NAME'];
|
||||
const preset = INTENT_PRESETS[intentEnv] ?? INTENT_PRESETS['general']!;
|
||||
state.agentIntent ??= (intentEnv in INTENT_PRESETS ? intentEnv : 'general') as AgentIntent;
|
||||
// Respect existing agentName (e.g. from CLI overrides) — only set from
|
||||
// env/preset if not already populated.
|
||||
state.soul.agentName ??= nameEnv ?? preset.suggestedName;
|
||||
return;
|
||||
}
|
||||
|
||||
p.separator();
|
||||
p.note(
|
||||
'Tell us what this agent will primarily help you with.\n' +
|
||||
"We'll suggest a name based on your choice — you can always change it.",
|
||||
'Agent Identity',
|
||||
);
|
||||
|
||||
const intentOptions = Object.entries(INTENT_PRESETS).map(([value, info]) => ({
|
||||
value: value as AgentIntent,
|
||||
label: info.label,
|
||||
hint: info.hint,
|
||||
}));
|
||||
|
||||
const intent = await p.select<AgentIntent>({
|
||||
message: 'What will this agent primarily help you with?',
|
||||
options: intentOptions,
|
||||
initialValue: 'general' as AgentIntent,
|
||||
});
|
||||
|
||||
state.agentIntent = intent;
|
||||
|
||||
const preset = INTENT_PRESETS[intent];
|
||||
const suggestedName = preset?.suggestedName ?? 'Mosaic';
|
||||
|
||||
const name = await p.text({
|
||||
message: `Your agent will be named "${suggestedName}". Press Enter to accept or type a new name`,
|
||||
initialValue: suggestedName,
|
||||
defaultValue: suggestedName,
|
||||
validate: (v) => {
|
||||
if (v.length === 0) return 'Name cannot be empty';
|
||||
if (v.length > 50) return 'Name must be under 50 characters';
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
state.soul.agentName = name;
|
||||
p.log(`Agent name set to: ${name}`);
|
||||
}
|
||||
186
packages/mosaic/src/stages/finalize-skills.spec.ts
Normal file
186
packages/mosaic/src/stages/finalize-skills.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Tests for the skill installer rework (IUV-02-03).
|
||||
*
|
||||
* We mock `node:child_process` to verify that:
|
||||
* 1. syncSkills passes MOSAIC_INSTALL_SKILLS with the exact selected subset
|
||||
* 2. When the script exits non-zero, the failure is surfaced to the user
|
||||
* 3. When the script is missing, a clear error is shown (not a silent no-op)
|
||||
* 4. An empty selection is a no-op (script never called)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { WizardState } from '../types.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
|
||||
// ── spawnSync mock ─────────────────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const spawnSyncMock = vi.fn<any>();
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
spawnSync: (...args: any[]) => spawnSyncMock(...args),
|
||||
}));
|
||||
|
||||
// ── platform stub ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../platform/detect.js', () => ({
|
||||
getShellProfilePath: () => null,
|
||||
}));
|
||||
|
||||
import { finalizeStage } from './finalize.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeState(mosaicHome: string, selectedSkills: string[] = []): WizardState {
|
||||
return {
|
||||
mosaicHome,
|
||||
sourceDir: mosaicHome,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: { agentName: 'TestBot', communicationStyle: 'direct' },
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrompter() {
|
||||
return {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
select: vi.fn(),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||
separator: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeConfigService(): ConfigService {
|
||||
return {
|
||||
readSoul: vi.fn().mockResolvedValue({}),
|
||||
readUser: vi.fn().mockResolvedValue({}),
|
||||
readTools: vi.fn().mockResolvedValue({}),
|
||||
writeSoul: vi.fn().mockResolvedValue(undefined),
|
||||
writeUser: vi.fn().mockResolvedValue(undefined),
|
||||
writeTools: vi.fn().mockResolvedValue(undefined),
|
||||
syncFramework: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
getSection: vi.fn(),
|
||||
} as unknown as ConfigService;
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('finalizeStage — skill installer', () => {
|
||||
let tmp: string;
|
||||
let binDir: string;
|
||||
let syncScript: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
|
||||
binDir = join(tmp, 'bin');
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
syncScript = join(binDir, 'mosaic-sync-skills');
|
||||
|
||||
// Default: script exists and succeeds
|
||||
writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
|
||||
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function findSkillsSyncCall() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (spawnSyncMock.mock.calls as any[][]).find(
|
||||
(args) =>
|
||||
Array.isArray(args[1]) &&
|
||||
(args[1] as string[]).some((a) => a.includes('mosaic-sync-skills')),
|
||||
);
|
||||
}
|
||||
|
||||
it('passes MOSAIC_INSTALL_SKILLS with the selected skill list', async () => {
|
||||
const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
const call = findSkillsSyncCall();
|
||||
expect(call).toBeDefined();
|
||||
const opts = call![2] as { env?: Record<string, string> };
|
||||
expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
|
||||
});
|
||||
|
||||
it('skips the sync script entirely when no skills are selected', async () => {
|
||||
const state = makeState(tmp, []);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
expect(findSkillsSyncCall()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('warns the user when the sync script exits non-zero', async () => {
|
||||
spawnSyncMock.mockReturnValue({
|
||||
status: 1,
|
||||
stdout: '',
|
||||
stderr: 'git clone failed: connection refused',
|
||||
});
|
||||
|
||||
const state = makeState(tmp, ['brainstorming']);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('git clone failed'));
|
||||
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('mosaic sync'));
|
||||
});
|
||||
|
||||
it('warns the user when the sync script is missing', async () => {
|
||||
// Remove the script to simulate a missing installation
|
||||
rmSync(syncScript);
|
||||
|
||||
const state = makeState(tmp, ['brainstorming']);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
// spawnSync should NOT have been called for the skills script
|
||||
expect(findSkillsSyncCall()).toBeUndefined();
|
||||
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('includes skills count in the summary when install succeeds', async () => {
|
||||
const state = makeState(tmp, ['brainstorming', 'lint']);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
const noteMock = p.note as ReturnType<typeof vi.fn>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const summaryCall = (noteMock.mock.calls as any[][]).find(
|
||||
([, title]) => title === 'Installation Summary',
|
||||
);
|
||||
expect(summaryCall).toBeDefined();
|
||||
expect(summaryCall![0] as string).toContain('2 installed');
|
||||
});
|
||||
});
|
||||
@@ -25,14 +25,68 @@ function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
function syncSkills(mosaicHome: string): void {
|
||||
interface SyncSkillsResult {
|
||||
success: boolean;
|
||||
installedCount: number;
|
||||
failureReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync skills from the catalog and link only the user-selected subset.
|
||||
*
|
||||
* When `selectedSkills` is non-empty the script receives the list via
|
||||
* `MOSAIC_INSTALL_SKILLS` (colon-separated) so it can skip unlisted skills
|
||||
* during the linking phase. An empty selection is a no-op.
|
||||
*
|
||||
* Failure modes surfaced here:
|
||||
* - Script not found → tells the user explicitly
|
||||
* - Script exits non-zero → stderr is captured and reported
|
||||
* - Catalog directory missing → detected before exec, reported clearly
|
||||
*/
|
||||
function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsResult {
|
||||
if (selectedSkills.length === 0) {
|
||||
return { success: true, installedCount: 0 };
|
||||
}
|
||||
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
|
||||
if (existsSync(script)) {
|
||||
try {
|
||||
spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' });
|
||||
} catch {
|
||||
// Non-fatal
|
||||
if (!existsSync(script)) {
|
||||
return {
|
||||
success: false,
|
||||
installedCount: 0,
|
||||
failureReason: `Skills sync script not found at ${script} — run 'mosaic sync' after installation.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync('bash', [script], {
|
||||
timeout: 60000,
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_HOME: mosaicHome,
|
||||
MOSAIC_INSTALL_SKILLS: selectedSkills.join(':'),
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr ?? '').trim();
|
||||
return {
|
||||
success: false,
|
||||
installedCount: 0,
|
||||
failureReason: stderr
|
||||
? `Skills sync failed: ${stderr}`
|
||||
: `Skills sync script exited with code ${(result.status ?? 'unknown').toString()}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, installedCount: selectedSkills.length };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
installedCount: 0,
|
||||
failureReason: `Skills sync threw: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,10 +178,11 @@ export async function finalizeStage(
|
||||
const skipClaudeHooks = state.hooks?.accepted === false;
|
||||
linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
|
||||
|
||||
// 4. Sync skills
|
||||
// 4. Sync skills (only installs the user-selected subset)
|
||||
let skillsResult: SyncSkillsResult = { success: true, installedCount: 0 };
|
||||
if (state.selectedSkills.length > 0) {
|
||||
spin.update('Syncing skills...');
|
||||
syncSkills(state.mosaicHome);
|
||||
spin.update(`Installing ${state.selectedSkills.length.toString()} selected skill(s)...`);
|
||||
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
|
||||
}
|
||||
|
||||
// 5. Run doctor
|
||||
@@ -136,15 +191,27 @@ export async function finalizeStage(
|
||||
|
||||
spin.stop('Installation complete');
|
||||
|
||||
// Report skill install failure clearly (non-fatal but user should know)
|
||||
if (!skillsResult.success && skillsResult.failureReason) {
|
||||
p.warn(skillsResult.failureReason);
|
||||
p.warn("Run 'mosaic sync' manually after installation to install skills.");
|
||||
}
|
||||
|
||||
// 6. PATH setup
|
||||
const pathAction = setupPath(state.mosaicHome, p);
|
||||
|
||||
// 7. Summary
|
||||
const skillsSummary = skillsResult.success
|
||||
? skillsResult.installedCount > 0
|
||||
? `${skillsResult.installedCount.toString()} installed`
|
||||
: 'none selected'
|
||||
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
|
||||
|
||||
const summary: string[] = [
|
||||
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
||||
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
||||
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
||||
`Skills: ${state.selectedSkills.length.toString()} selected`,
|
||||
`Skills: ${skillsSummary}`,
|
||||
`Config: ${state.mosaicHome}`,
|
||||
];
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ export async function gatewayBootstrapStage(
|
||||
host,
|
||||
port,
|
||||
tier: 'local',
|
||||
corsOrigin: 'http://localhost:3000',
|
||||
corsOrigin: `http://${host}:3000`,
|
||||
}),
|
||||
admin: { name, email, password },
|
||||
};
|
||||
|
||||
69
packages/mosaic/src/stages/gateway-config-cors.spec.ts
Normal file
69
packages/mosaic/src/stages/gateway-config-cors.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveCorsOrigin } from './gateway-config.js';
|
||||
|
||||
describe('deriveCorsOrigin', () => {
|
||||
describe('localhost / loopback — always http', () => {
|
||||
it('localhost port 3000 → http://localhost:3000', () => {
|
||||
expect(deriveCorsOrigin('localhost', 3000)).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('127.0.0.1 port 3000 → http://127.0.0.1:3000', () => {
|
||||
expect(deriveCorsOrigin('127.0.0.1', 3000)).toBe('http://127.0.0.1:3000');
|
||||
});
|
||||
|
||||
it('localhost port 80 omits port suffix', () => {
|
||||
expect(deriveCorsOrigin('localhost', 80)).toBe('http://localhost');
|
||||
});
|
||||
|
||||
it('localhost port 443 still uses http (loopback overrides), includes port', () => {
|
||||
// 443 is the https default port, but since localhost forces http, the port
|
||||
// is NOT the default for http (80), so it must be included.
|
||||
expect(deriveCorsOrigin('localhost', 443)).toBe('http://localhost:443');
|
||||
});
|
||||
|
||||
it('useHttps=false on localhost keeps http', () => {
|
||||
expect(deriveCorsOrigin('localhost', 3000, false)).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('useHttps=true on localhost still uses http (loopback wins)', () => {
|
||||
// Passing useHttps=true for localhost is unusual but the function honours
|
||||
// the explicit override — loopback detection only applies when useHttps is
|
||||
// undefined (auto-detect path).
|
||||
expect(deriveCorsOrigin('localhost', 3000, true)).toBe('https://localhost:3000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote hostname — defaults to https', () => {
|
||||
it('example.com port 3000 → https://example.com:3000', () => {
|
||||
expect(deriveCorsOrigin('example.com', 3000)).toBe('https://example.com:3000');
|
||||
});
|
||||
|
||||
it('example.com port 443 omits port suffix', () => {
|
||||
expect(deriveCorsOrigin('example.com', 443)).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('example.com port 80 → https://example.com:80 (non-default port for https)', () => {
|
||||
expect(deriveCorsOrigin('example.com', 80)).toBe('https://example.com:80');
|
||||
});
|
||||
|
||||
it('useHttps=false on remote host uses http', () => {
|
||||
expect(deriveCorsOrigin('example.com', 3000, false)).toBe('http://example.com:3000');
|
||||
});
|
||||
|
||||
it('useHttps=false on remote host, port 80 omits suffix', () => {
|
||||
expect(deriveCorsOrigin('example.com', 80, false)).toBe('http://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subdomain and non-standard hostnames', () => {
|
||||
it('sub.domain.example.com defaults to https', () => {
|
||||
expect(deriveCorsOrigin('sub.domain.example.com', 3000)).toBe(
|
||||
'https://sub.domain.example.com:3000',
|
||||
);
|
||||
});
|
||||
|
||||
it('myserver.local defaults to https (not loopback)', () => {
|
||||
expect(deriveCorsOrigin('myserver.local', 8080)).toBe('https://myserver.local:8080');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,25 @@ function isHeadless(): boolean {
|
||||
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
}
|
||||
|
||||
// ── CORS derivation ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive a full CORS origin URL from a user-provided hostname + web UI port.
|
||||
*
|
||||
* Rules:
|
||||
* - "localhost" and "127.0.0.1" always use http (never https)
|
||||
* - Everything else uses https by default; pass useHttps=false to override
|
||||
* - Standard ports (80 for http, 443 for https) are omitted from the origin
|
||||
*/
|
||||
export function deriveCorsOrigin(hostname: string, webUiPort: number, useHttps?: boolean): string {
|
||||
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
const proto =
|
||||
useHttps !== undefined ? (useHttps ? 'https' : 'http') : isLocalhost ? 'http' : 'https';
|
||||
const defaultPort = proto === 'https' ? 443 : 80;
|
||||
const portSuffix = webUiPort === defaultPort ? '' : `:${webUiPort.toString()}`;
|
||||
return `${proto}://${hostname}${portSuffix}`;
|
||||
}
|
||||
|
||||
// ── .env helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function readEnvVarFromFile(envFile: string, key: string): string | null {
|
||||
@@ -77,6 +96,10 @@ async function promptTier(p: WizardPrompter): Promise<GatewayStorageTier> {
|
||||
async function promptPort(p: WizardPrompter, defaultPort: number): Promise<number> {
|
||||
const raw = await p.text({
|
||||
message: 'Gateway port',
|
||||
// initialValue prefills the input buffer so the user sees 14242 and can
|
||||
// press Enter to accept it. defaultValue is only used when the user submits
|
||||
// an empty string, which never shows in the field.
|
||||
initialValue: defaultPort.toString(),
|
||||
defaultValue: defaultPort.toString(),
|
||||
validate: (v) => {
|
||||
const n = parseInt(v, 10);
|
||||
@@ -103,6 +126,14 @@ export interface GatewayConfigStageOptions {
|
||||
portOverride?: number;
|
||||
/** Skip the `npm install -g @mosaicstack/gateway` step (local build / tests). */
|
||||
skipInstall?: boolean;
|
||||
/**
|
||||
* Pre-collected provider API key (from the provider-setup stage or Quick
|
||||
* Start path). When set, the gateway-config stage will skip the interactive
|
||||
* API key prompt and use this value directly.
|
||||
*/
|
||||
providerKey?: string;
|
||||
/** Provider type detected from the key prefix. */
|
||||
providerType?: 'anthropic' | 'openai' | 'none';
|
||||
}
|
||||
|
||||
export interface GatewayConfigStageResult {
|
||||
@@ -224,7 +255,9 @@ export async function gatewayConfigStage(
|
||||
host: existing.host,
|
||||
port: existing.port,
|
||||
tier: 'local',
|
||||
corsOrigin: 'http://localhost:3000',
|
||||
corsOrigin:
|
||||
readEnvVarFromFile(ENV_FILE, 'GATEWAY_CORS_ORIGIN') ??
|
||||
deriveCorsOrigin('localhost', 3000),
|
||||
regeneratedConfig: false,
|
||||
};
|
||||
return { ready: true, host: existing.host, port: existing.port };
|
||||
@@ -277,7 +310,8 @@ export async function gatewayConfigStage(
|
||||
host,
|
||||
port,
|
||||
tier: 'local',
|
||||
corsOrigin: 'http://localhost:3000',
|
||||
corsOrigin:
|
||||
readEnvVarFromFile(ENV_FILE, 'GATEWAY_CORS_ORIGIN') ?? deriveCorsOrigin('localhost', 3000),
|
||||
regeneratedConfig: false,
|
||||
};
|
||||
} else {
|
||||
@@ -288,6 +322,8 @@ export async function gatewayConfigStage(
|
||||
envFile: ENV_FILE,
|
||||
mosaicConfigFile: MOSAIC_CONFIG_FILE,
|
||||
gatewayHome: GATEWAY_HOME,
|
||||
providerKey: opts.providerKey,
|
||||
providerType: opts.providerType,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayConfigValidationError) {
|
||||
@@ -363,6 +399,10 @@ interface CollectOptions {
|
||||
envFile: string;
|
||||
mosaicConfigFile: string;
|
||||
gatewayHome: string;
|
||||
/** Pre-collected API key — skips the interactive prompt when set. */
|
||||
providerKey?: string;
|
||||
/** Provider type — determines the env var name for the key. */
|
||||
providerType?: 'anthropic' | 'openai' | 'none';
|
||||
}
|
||||
|
||||
/** Raised by the config stage when headless env validation fails. */
|
||||
@@ -391,6 +431,7 @@ async function collectAndWriteConfig(
|
||||
let valkeyUrl: string | undefined;
|
||||
let anthropicKey: string;
|
||||
let corsOrigin: string;
|
||||
let hostname: string;
|
||||
|
||||
if (isHeadless()) {
|
||||
p.log('Headless mode detected — reading configuration from environment variables.');
|
||||
@@ -404,7 +445,13 @@ async function collectAndWriteConfig(
|
||||
databaseUrl = process.env['MOSAIC_DATABASE_URL'];
|
||||
valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
|
||||
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
||||
corsOrigin = process.env['MOSAIC_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||
|
||||
// MOSAIC_CORS_ORIGIN is the full override (e.g. from CI).
|
||||
// MOSAIC_HOSTNAME is the user-friendly alternative — derive from it.
|
||||
const corsOverride = process.env['MOSAIC_CORS_ORIGIN'];
|
||||
const hostnameEnv = process.env['MOSAIC_HOSTNAME'] ?? 'localhost';
|
||||
hostname = hostnameEnv;
|
||||
corsOrigin = corsOverride ?? deriveCorsOrigin(hostnameEnv, 3000);
|
||||
|
||||
if (tier === 'team') {
|
||||
const missing: string[] = [];
|
||||
@@ -423,23 +470,44 @@ async function collectAndWriteConfig(
|
||||
if (tier === 'team') {
|
||||
databaseUrl = await p.text({
|
||||
message: 'DATABASE_URL',
|
||||
initialValue: 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
|
||||
defaultValue: 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
|
||||
});
|
||||
valkeyUrl = await p.text({
|
||||
message: 'VALKEY_URL',
|
||||
initialValue: 'redis://localhost:6380',
|
||||
defaultValue: 'redis://localhost:6380',
|
||||
});
|
||||
}
|
||||
|
||||
anthropicKey = await p.text({
|
||||
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
|
||||
defaultValue: '',
|
||||
if (opts.providerKey) {
|
||||
anthropicKey = opts.providerKey;
|
||||
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
|
||||
} else {
|
||||
anthropicKey = await p.text({
|
||||
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
|
||||
defaultValue: '',
|
||||
});
|
||||
}
|
||||
|
||||
hostname = await p.text({
|
||||
message: 'Web UI hostname (for browser access)',
|
||||
initialValue: 'localhost',
|
||||
defaultValue: 'localhost',
|
||||
placeholder: 'e.g. localhost or myserver.example.com',
|
||||
});
|
||||
|
||||
corsOrigin = await p.text({
|
||||
message: 'CORS origin',
|
||||
defaultValue: 'http://localhost:3000',
|
||||
});
|
||||
// For non-localhost, ask if HTTPS is in use (defaults to yes for remote hosts)
|
||||
let useHttps: boolean | undefined;
|
||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
|
||||
useHttps = await p.confirm({
|
||||
message: 'Is HTTPS enabled for the web UI?',
|
||||
initialValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
corsOrigin = deriveCorsOrigin(hostname, 3000, useHttps);
|
||||
p.log(`CORS origin set to: ${corsOrigin}`);
|
||||
}
|
||||
|
||||
const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
|
||||
@@ -459,7 +527,11 @@ async function collectAndWriteConfig(
|
||||
}
|
||||
|
||||
if (anthropicKey) {
|
||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||
if (opts.providerType === 'openai') {
|
||||
envLines.push(`OPENAI_API_KEY=${anthropicKey}`);
|
||||
} else {
|
||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(opts.envFile, envLines.join('\n') + '\n', { mode: 0o600 });
|
||||
@@ -493,6 +565,7 @@ async function collectAndWriteConfig(
|
||||
valkeyUrl,
|
||||
anthropicKey: anthropicKey || undefined,
|
||||
corsOrigin,
|
||||
hostname,
|
||||
regeneratedConfig: true,
|
||||
};
|
||||
}
|
||||
|
||||
118
packages/mosaic/src/stages/provider-setup.spec.ts
Normal file
118
packages/mosaic/src/stages/provider-setup.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { providerSetupStage } from './provider-setup.js';
|
||||
|
||||
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
confirm: vi.fn().mockResolvedValue(false),
|
||||
select: vi.fn().mockResolvedValue('general'),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||
separator: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/mosaic',
|
||||
sourceDir: '/tmp/mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('providerSetupStage', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('detects Anthropic key from prefix in headless mode', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_ANTHROPIC_API_KEY'] = 'sk-ant-api03-test123';
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await providerSetupStage(p, state);
|
||||
|
||||
expect(state.providerKey).toBe('sk-ant-api03-test123');
|
||||
expect(state.providerType).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('detects OpenAI key from prefix in headless mode', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
process.env['MOSAIC_OPENAI_API_KEY'] = 'sk-proj-test123';
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await providerSetupStage(p, state);
|
||||
|
||||
expect(state.providerKey).toBe('sk-proj-test123');
|
||||
expect(state.providerType).toBe('openai');
|
||||
});
|
||||
|
||||
it('sets provider type to none when no key is provided in headless mode', async () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
delete process.env['MOSAIC_ANTHROPIC_API_KEY'];
|
||||
delete process.env['MOSAIC_OPENAI_API_KEY'];
|
||||
const state = makeState();
|
||||
const p = buildPrompter();
|
||||
|
||||
await providerSetupStage(p, state);
|
||||
|
||||
expect(state.providerKey).toBeUndefined();
|
||||
expect(state.providerType).toBe('none');
|
||||
});
|
||||
|
||||
it('prompts for key in interactive mode', async () => {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
// Simulate a TTY
|
||||
const origIsTTY = process.stdin.isTTY;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
|
||||
const state = makeState();
|
||||
const p = buildPrompter({
|
||||
text: vi.fn().mockResolvedValue('sk-ant-api03-interactive'),
|
||||
});
|
||||
|
||||
await providerSetupStage(p, state);
|
||||
|
||||
expect(p.text).toHaveBeenCalled();
|
||||
expect(state.providerKey).toBe('sk-ant-api03-interactive');
|
||||
expect(state.providerType).toBe('anthropic');
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
|
||||
});
|
||||
|
||||
it('handles empty key in interactive mode', async () => {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
const origIsTTY = process.stdin.isTTY;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
|
||||
const state = makeState();
|
||||
const p = buildPrompter({
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
});
|
||||
|
||||
await providerSetupStage(p, state);
|
||||
|
||||
expect(state.providerType).toBe('none');
|
||||
expect(state.providerKey).toBeUndefined();
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
|
||||
});
|
||||
});
|
||||
54
packages/mosaic/src/stages/provider-setup.ts
Normal file
54
packages/mosaic/src/stages/provider-setup.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { detectProviderType } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Provider setup stage — collects the user's LLM API key and detects the
|
||||
* provider type from the key prefix.
|
||||
*
|
||||
* In headless mode, reads from `MOSAIC_ANTHROPIC_API_KEY` or `MOSAIC_OPENAI_API_KEY`.
|
||||
*/
|
||||
export async function providerSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
|
||||
if (isHeadless) {
|
||||
const anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
||||
const openaiKey = process.env['MOSAIC_OPENAI_API_KEY'] ?? '';
|
||||
const key = anthropicKey || openaiKey;
|
||||
state.providerKey = key || undefined;
|
||||
state.providerType = detectProviderType(key);
|
||||
return;
|
||||
}
|
||||
|
||||
p.separator();
|
||||
p.note(
|
||||
'Configure your LLM provider so the agent has a brain.\n' +
|
||||
'Anthropic (Claude) and OpenAI are supported.\n' +
|
||||
'You can skip this and add a key later via `mosaic configure`.',
|
||||
'LLM Provider',
|
||||
);
|
||||
|
||||
const key = await p.text({
|
||||
message: 'API key (paste your Anthropic or OpenAI key, or press Enter to skip)',
|
||||
defaultValue: '',
|
||||
placeholder: 'sk-ant-api03-... or sk-...',
|
||||
});
|
||||
|
||||
if (key) {
|
||||
const provider = detectProviderType(key);
|
||||
state.providerKey = key;
|
||||
state.providerType = provider;
|
||||
|
||||
if (provider === 'anthropic') {
|
||||
p.log('Detected provider: Anthropic (Claude)');
|
||||
} else if (provider === 'openai') {
|
||||
p.log('Detected provider: OpenAI');
|
||||
} else {
|
||||
p.log('Provider auto-detection failed. Key will be stored as ANTHROPIC_API_KEY.');
|
||||
state.providerType = 'anthropic';
|
||||
}
|
||||
} else {
|
||||
state.providerType = 'none';
|
||||
p.log('No API key provided. You can add one later with `mosaic configure`.');
|
||||
}
|
||||
}
|
||||
98
packages/mosaic/src/stages/quick-start.ts
Normal file
98
packages/mosaic/src/stages/quick-start.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { DEFAULTS } from '../constants.js';
|
||||
import { providerSetupStage } from './provider-setup.js';
|
||||
import { runtimeSetupStage } from './runtime-setup.js';
|
||||
import { hooksPreviewStage } from './hooks-preview.js';
|
||||
import { skillsSelectStage } from './skills-select.js';
|
||||
import { finalizeStage } from './finalize.js';
|
||||
import { gatewayConfigStage } from './gateway-config.js';
|
||||
import { gatewayBootstrapStage } from './gateway-bootstrap.js';
|
||||
|
||||
export interface QuickStartOptions {
|
||||
skipGateway?: boolean;
|
||||
gatewayHost?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayPortOverride?: number;
|
||||
skipGatewayNpmInstall?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick Start path — minimal questions to get a working agent.
|
||||
*
|
||||
* 1. Provider API key
|
||||
* 2. Admin email + password (via gateway bootstrap)
|
||||
* 3. Everything else uses defaults.
|
||||
*
|
||||
* Target: under 90 seconds for a returning user.
|
||||
*/
|
||||
export async function quickStartPath(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: QuickStartOptions,
|
||||
): Promise<void> {
|
||||
state.mode = 'quick';
|
||||
|
||||
// 1. Provider setup (first question)
|
||||
await providerSetupStage(prompter, state);
|
||||
|
||||
// Apply sensible defaults for everything else
|
||||
state.soul.agentName ??= 'Mosaic';
|
||||
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
||||
state.soul.communicationStyle ??= 'direct';
|
||||
state.user.background = DEFAULTS.background;
|
||||
state.user.accessibilitySection = DEFAULTS.accessibilitySection;
|
||||
state.user.personalBoundaries = DEFAULTS.personalBoundaries;
|
||||
state.tools.gitProviders = [];
|
||||
state.tools.credentialsLocation = DEFAULTS.credentialsLocation;
|
||||
state.tools.customToolsSection = DEFAULTS.customToolsSection;
|
||||
|
||||
// Runtime detection (auto, no user input in quick mode)
|
||||
await runtimeSetupStage(prompter, state);
|
||||
|
||||
// Hooks (auto-accept in quick mode for Claude)
|
||||
await hooksPreviewStage(prompter, state);
|
||||
|
||||
// Skills (recommended set, no user input in quick mode)
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Finalize (writes configs, links runtime assets, syncs skills)
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway config + bootstrap
|
||||
if (!options.skipGateway) {
|
||||
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: options.gatewayHost ?? 'localhost',
|
||||
defaultPort: options.gatewayPort ?? 14242,
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType ?? 'none',
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
if (headlessRun) {
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export async function welcomeStage(p: WizardPrompter, _state: WizardState): Prom
|
||||
p.note(
|
||||
`Mosaic is an agent framework that gives AI coding assistants\n` +
|
||||
`a persistent identity, shared skills, and structured workflows.\n\n` +
|
||||
`It works with Claude Code, Codex, and OpenCode.\n\n` +
|
||||
`It works with Claude Code, Codex, OpenCode, and Pi SDK.\n\n` +
|
||||
`All config is stored locally in ~/.config/mosaic/.\n` +
|
||||
`No data is sent anywhere. No accounts required.`,
|
||||
'What is Mosaic?',
|
||||
|
||||
118
packages/mosaic/src/stages/wizard-menu.spec.ts
Normal file
118
packages/mosaic/src/stages/wizard-menu.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import type { MenuSection } from '../types.js';
|
||||
import { detectProviderType, INTENT_PRESETS } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Tests for the drill-down menu system and its supporting utilities.
|
||||
*
|
||||
* The menu loop itself is in wizard.ts and is hard to unit test in isolation
|
||||
* because it orchestrates many async stages. These tests verify the building
|
||||
* blocks: provider detection, intent presets, and the WizardState shape.
|
||||
*/
|
||||
|
||||
describe('detectProviderType', () => {
|
||||
it('detects Anthropic from sk-ant- prefix', () => {
|
||||
expect(detectProviderType('sk-ant-api03-abc123')).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('detects OpenAI from sk- prefix', () => {
|
||||
expect(detectProviderType('sk-proj-abc123')).toBe('openai');
|
||||
});
|
||||
|
||||
it('returns none for empty string', () => {
|
||||
expect(detectProviderType('')).toBe('none');
|
||||
});
|
||||
|
||||
it('returns none for unrecognized prefix', () => {
|
||||
expect(detectProviderType('gsk_abc123')).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('INTENT_PRESETS', () => {
|
||||
it('has all expected intent categories', () => {
|
||||
expect(Object.keys(INTENT_PRESETS)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'general',
|
||||
'software-dev',
|
||||
'devops',
|
||||
'research',
|
||||
'content',
|
||||
'custom',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('each preset has label, hint, and suggestedName', () => {
|
||||
for (const [key, preset] of Object.entries(INTENT_PRESETS)) {
|
||||
expect(preset.label, `${key}.label`).toBeTruthy();
|
||||
expect(preset.hint, `${key}.hint`).toBeTruthy();
|
||||
expect(preset.suggestedName, `${key}.suggestedName`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps software-dev to Forge', () => {
|
||||
expect(INTENT_PRESETS['software-dev']?.suggestedName).toBe('Forge');
|
||||
});
|
||||
|
||||
it('maps devops to Sentinel', () => {
|
||||
expect(INTENT_PRESETS['devops']?.suggestedName).toBe('Sentinel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WizardState completedSections', () => {
|
||||
it('tracks completed sections as a Set', () => {
|
||||
const completed = new Set<MenuSection>();
|
||||
completed.add('providers');
|
||||
completed.add('identity');
|
||||
|
||||
expect(completed.has('providers')).toBe(true);
|
||||
expect(completed.has('identity')).toBe(true);
|
||||
expect(completed.has('skills')).toBe(false);
|
||||
expect(completed.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headless backward compat', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('MOSAIC_ASSUME_YES=1 triggers headless path', () => {
|
||||
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
expect(isHeadless).toBe(true);
|
||||
});
|
||||
|
||||
it('non-TTY triggers headless path', () => {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
// In test environments, process.stdin.isTTY is typically undefined (falsy)
|
||||
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
expect(isHeadless).toBe(true);
|
||||
});
|
||||
|
||||
it('all headless env vars are recognized', () => {
|
||||
// This test documents the expected env vars for headless installs.
|
||||
const headlessVars = [
|
||||
'MOSAIC_ASSUME_YES',
|
||||
'MOSAIC_ADMIN_NAME',
|
||||
'MOSAIC_ADMIN_EMAIL',
|
||||
'MOSAIC_ADMIN_PASSWORD',
|
||||
'MOSAIC_GATEWAY_PORT',
|
||||
'MOSAIC_HOSTNAME',
|
||||
'MOSAIC_CORS_ORIGIN',
|
||||
'MOSAIC_STORAGE_TIER',
|
||||
'MOSAIC_DATABASE_URL',
|
||||
'MOSAIC_VALKEY_URL',
|
||||
'MOSAIC_ANTHROPIC_API_KEY',
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_AGENT_INTENT',
|
||||
];
|
||||
|
||||
// Just verify none of them throw when accessed
|
||||
for (const v of headlessVars) {
|
||||
expect(() => process.env[v]).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,19 @@ export type InstallAction = 'fresh' | 'keep' | 'reconfigure' | 'reset';
|
||||
export type CommunicationStyle = 'direct' | 'friendly' | 'formal';
|
||||
export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||
|
||||
export type MenuSection =
|
||||
| 'quick-start'
|
||||
| 'providers'
|
||||
| 'identity'
|
||||
| 'skills'
|
||||
| 'gateway'
|
||||
| 'advanced'
|
||||
| 'finish';
|
||||
|
||||
export type AgentIntent = 'general' | 'software-dev' | 'devops' | 'research' | 'content' | 'custom';
|
||||
|
||||
export type ProviderType = 'anthropic' | 'openai' | 'none';
|
||||
|
||||
export interface SoulConfig {
|
||||
agentName?: string;
|
||||
roleDescription?: string;
|
||||
@@ -62,6 +75,11 @@ export interface GatewayState {
|
||||
valkeyUrl?: string;
|
||||
anthropicKey?: string;
|
||||
corsOrigin: string;
|
||||
/**
|
||||
* Raw hostname the user entered (e.g. "localhost", "myserver.example.com").
|
||||
* The full CORS origin (`corsOrigin`) is derived from this + protocol + webUiPort.
|
||||
*/
|
||||
hostname?: string;
|
||||
/** True when .env + mosaic.config.json were (re)generated in this run. */
|
||||
regeneratedConfig?: boolean;
|
||||
admin?: GatewayAdminState;
|
||||
@@ -81,4 +99,12 @@ export interface WizardState {
|
||||
selectedSkills: string[];
|
||||
hooks?: HooksState;
|
||||
gateway?: GatewayState;
|
||||
/** Tracks which menu sections have been completed in drill-down mode. */
|
||||
completedSections?: Set<MenuSection>;
|
||||
/** The user's chosen agent intent category. */
|
||||
agentIntent?: AgentIntent;
|
||||
/** The LLM provider API key entered during setup. */
|
||||
providerKey?: string;
|
||||
/** Detected provider type based on API key prefix. */
|
||||
providerType?: ProviderType;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { WizardPrompter } from './prompter/interface.js';
|
||||
import type { ConfigService } from './config/config-service.js';
|
||||
import type { WizardState } from './types.js';
|
||||
import type { MenuSection, WizardState } from './types.js';
|
||||
import { welcomeStage } from './stages/welcome.js';
|
||||
import { detectInstallStage } from './stages/detect-install.js';
|
||||
import { modeSelectStage } from './stages/mode-select.js';
|
||||
import { soulSetupStage } from './stages/soul-setup.js';
|
||||
import { userSetupStage } from './stages/user-setup.js';
|
||||
import { toolsSetupStage } from './stages/tools-setup.js';
|
||||
@@ -13,6 +12,10 @@ import { skillsSelectStage } from './stages/skills-select.js';
|
||||
import { finalizeStage } from './stages/finalize.js';
|
||||
import { gatewayConfigStage } from './stages/gateway-config.js';
|
||||
import { gatewayBootstrapStage } from './stages/gateway-bootstrap.js';
|
||||
import { providerSetupStage } from './stages/provider-setup.js';
|
||||
import { agentIntentStage } from './stages/agent-intent.js';
|
||||
import { quickStartPath } from './stages/quick-start.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
export interface WizardOptions {
|
||||
mosaicHome: string;
|
||||
@@ -54,6 +57,7 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
completedSections: new Set<MenuSection>(),
|
||||
};
|
||||
|
||||
// Apply CLI overrides (strip undefined values)
|
||||
@@ -90,42 +94,343 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
// Stage 2: Existing Install Detection
|
||||
await detectInstallStage(prompter, state, configService);
|
||||
|
||||
// Stage 3: Quick Start vs Advanced (skip if keeping existing)
|
||||
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
||||
await modeSelectStage(prompter, state);
|
||||
} else if (state.installAction === 'reconfigure') {
|
||||
state.mode = 'advanced';
|
||||
// ── Headless bypass ────────────────────────────────────────────────────────
|
||||
// When MOSAIC_ASSUME_YES=1 or no TTY, run the linear headless path.
|
||||
// This preserves full backward compatibility with tools/install.sh --yes.
|
||||
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
if (headlessRun) {
|
||||
await runHeadlessPath(prompter, state, configService, options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 4: SOUL.md
|
||||
// ── Interactive: Main Menu ─────────────────────────────────────────────────
|
||||
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
||||
await runMenuLoop(prompter, state, configService, options);
|
||||
} else if (state.installAction === 'reconfigure') {
|
||||
state.mode = 'advanced';
|
||||
await runMenuLoop(prompter, state, configService, options);
|
||||
} else {
|
||||
// 'keep' — skip identity setup, go straight to finalize + gateway
|
||||
await runKeepPath(prompter, state, configService, options);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Menu-driven interactive flow ────────────────────────────────────────────
|
||||
|
||||
type MenuChoice =
|
||||
| 'quick-start'
|
||||
| 'providers'
|
||||
| 'identity'
|
||||
| 'skills'
|
||||
| 'gateway-config'
|
||||
| 'advanced'
|
||||
| 'finish';
|
||||
|
||||
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
|
||||
const labels: Record<MenuChoice, string> = {
|
||||
'quick-start': 'Quick Start',
|
||||
providers: 'Providers',
|
||||
identity: 'Agent Identity',
|
||||
skills: 'Skills',
|
||||
'gateway-config': 'Gateway',
|
||||
advanced: 'Advanced',
|
||||
finish: 'Finish & Apply',
|
||||
};
|
||||
const base = labels[section];
|
||||
const sectionKey: MenuSection =
|
||||
section === 'gateway-config' ? 'gateway' : (section as MenuSection);
|
||||
if (completed.has(sectionKey)) {
|
||||
return `${base} [done]`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async function runMenuLoop(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: WizardOptions,
|
||||
): Promise<void> {
|
||||
const completed = state.completedSections!;
|
||||
|
||||
for (;;) {
|
||||
const choice = await prompter.select<MenuChoice>({
|
||||
message: 'What would you like to configure?',
|
||||
options: [
|
||||
{
|
||||
value: 'quick-start',
|
||||
label: menuLabel('quick-start', completed),
|
||||
hint: 'Recommended defaults, minimal questions',
|
||||
},
|
||||
{
|
||||
value: 'providers',
|
||||
label: menuLabel('providers', completed),
|
||||
hint: 'LLM API keys (Anthropic, OpenAI)',
|
||||
},
|
||||
{
|
||||
value: 'identity',
|
||||
label: menuLabel('identity', completed),
|
||||
hint: 'Agent name, intent, persona',
|
||||
},
|
||||
{
|
||||
value: 'skills',
|
||||
label: menuLabel('skills', completed),
|
||||
hint: 'Install agent skills',
|
||||
},
|
||||
{
|
||||
value: 'gateway-config',
|
||||
label: menuLabel('gateway-config', completed),
|
||||
hint: 'Port, storage, database',
|
||||
},
|
||||
{
|
||||
value: 'advanced',
|
||||
label: menuLabel('advanced', completed),
|
||||
hint: 'SOUL.md, USER.md, TOOLS.md, runtimes, hooks',
|
||||
},
|
||||
{
|
||||
value: 'finish',
|
||||
label: menuLabel('finish', completed),
|
||||
hint: 'Write configs and start gateway',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
switch (choice) {
|
||||
case 'quick-start':
|
||||
await quickStartPath(prompter, state, configService, options);
|
||||
return; // Quick start is a complete flow — exit menu
|
||||
|
||||
case 'providers':
|
||||
await providerSetupStage(prompter, state);
|
||||
completed.add('providers');
|
||||
break;
|
||||
|
||||
case 'identity':
|
||||
await agentIntentStage(prompter, state);
|
||||
completed.add('identity');
|
||||
break;
|
||||
|
||||
case 'skills':
|
||||
await skillsSelectStage(prompter, state);
|
||||
completed.add('skills');
|
||||
break;
|
||||
|
||||
case 'gateway-config':
|
||||
// Gateway config is handled during Finish — mark as "configured"
|
||||
// after user reviews settings.
|
||||
await runGatewaySubMenu(prompter, state, options);
|
||||
completed.add('gateway');
|
||||
break;
|
||||
|
||||
case 'advanced':
|
||||
await runAdvancedSubMenu(prompter, state);
|
||||
completed.add('advanced');
|
||||
break;
|
||||
|
||||
case 'finish':
|
||||
await runFinishPath(prompter, state, configService, options);
|
||||
return; // Done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway sub-menu ─────────────────────────────────────────────────────────
|
||||
|
||||
async function runGatewaySubMenu(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
_options: WizardOptions,
|
||||
): Promise<void> {
|
||||
prompter.note(
|
||||
'Gateway settings will be applied when you select "Finish & Apply".\n' +
|
||||
'Configure the settings you want to customize here.',
|
||||
'Gateway Configuration',
|
||||
);
|
||||
|
||||
// For now, just let them know defaults will be used and they can
|
||||
// override during finish. The actual gateway config stage runs
|
||||
// during Finish & Apply. This menu item exists so users know
|
||||
// the gateway is part of the wizard.
|
||||
const port = await prompter.text({
|
||||
message: 'Gateway port',
|
||||
initialValue: (_options.gatewayPort ?? 14242).toString(),
|
||||
defaultValue: (_options.gatewayPort ?? 14242).toString(),
|
||||
validate: (v) => {
|
||||
const n = parseInt(v, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 65535) return 'Port must be 1-65535';
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
// Store for later use in the gateway config stage
|
||||
_options.gatewayPort = parseInt(port, 10);
|
||||
prompter.log(`Gateway port set to ${port}. Will be applied during Finish & Apply.`);
|
||||
}
|
||||
|
||||
// ── Advanced sub-menu ────────────────────────────────────────────────────────
|
||||
|
||||
async function runAdvancedSubMenu(prompter: WizardPrompter, state: WizardState): Promise<void> {
|
||||
state.mode = 'advanced';
|
||||
|
||||
// Run the detailed setup stages
|
||||
await soulSetupStage(prompter, state);
|
||||
|
||||
// Stage 5: USER.md
|
||||
await userSetupStage(prompter, state);
|
||||
|
||||
// Stage 6: TOOLS.md
|
||||
await toolsSetupStage(prompter, state);
|
||||
|
||||
// Stage 7: Runtime Detection & Installation
|
||||
await runtimeSetupStage(prompter, state);
|
||||
|
||||
// Stage 8: Hooks preview (Claude only — skipped if Claude not detected)
|
||||
await hooksPreviewStage(prompter, state);
|
||||
}
|
||||
|
||||
// Stage 9: Skills Selection
|
||||
await skillsSelectStage(prompter, state);
|
||||
// ── Finish & Apply ──────────────────────────────────────────────────────────
|
||||
|
||||
// Stage 10: Finalize (writes configs, links runtime assets, runs doctor)
|
||||
async function runFinishPath(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: WizardOptions,
|
||||
): Promise<void> {
|
||||
// Apply defaults for anything not explicitly configured
|
||||
state.soul.agentName ??= 'Mosaic';
|
||||
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
||||
state.soul.communicationStyle ??= 'direct';
|
||||
state.user.background ??= DEFAULTS.background;
|
||||
state.user.accessibilitySection ??= DEFAULTS.accessibilitySection;
|
||||
state.user.personalBoundaries ??= DEFAULTS.personalBoundaries;
|
||||
state.tools.gitProviders ??= [];
|
||||
state.tools.credentialsLocation ??= DEFAULTS.credentialsLocation;
|
||||
state.tools.customToolsSection ??= DEFAULTS.customToolsSection;
|
||||
|
||||
// Runtime detection if not already done
|
||||
if (state.runtimes.detected.length === 0 && !state.completedSections?.has('advanced')) {
|
||||
await runtimeSetupStage(prompter, state);
|
||||
await hooksPreviewStage(prompter, state);
|
||||
}
|
||||
|
||||
// Skills defaults if not already configured
|
||||
if (!state.completedSections?.has('skills')) {
|
||||
await skillsSelectStage(prompter, state);
|
||||
}
|
||||
|
||||
// Finalize (writes configs, links runtime assets, syncs skills)
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Stages 11 & 12: Gateway config + admin bootstrap.
|
||||
// The unified first-run flow runs these as terminal stages so the user
|
||||
// goes from "welcome" through "admin user created" in a single cohesive
|
||||
// experience. Callers that only want the framework portion pass
|
||||
// `skipGateway: true`.
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: options.gatewayHost ?? 'localhost',
|
||||
defaultPort: options.gatewayPort ?? 14242,
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType ?? 'none',
|
||||
});
|
||||
|
||||
if (configResult.ready && configResult.host && configResult.port) {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Headless linear path (backward compat) ──────────────────────────────────
|
||||
|
||||
async function runHeadlessPath(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: WizardOptions,
|
||||
): Promise<void> {
|
||||
// Provider setup from env vars
|
||||
await providerSetupStage(prompter, state);
|
||||
|
||||
// Agent intent from env vars
|
||||
await agentIntentStage(prompter, state);
|
||||
|
||||
// SOUL.md
|
||||
await soulSetupStage(prompter, state);
|
||||
|
||||
// USER.md
|
||||
await userSetupStage(prompter, state);
|
||||
|
||||
// TOOLS.md
|
||||
await toolsSetupStage(prompter, state);
|
||||
|
||||
// Runtime Detection
|
||||
await runtimeSetupStage(prompter, state);
|
||||
|
||||
// Hooks
|
||||
await hooksPreviewStage(prompter, state);
|
||||
|
||||
// Skills
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Finalize
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: options.gatewayHost ?? 'localhost',
|
||||
defaultPort: options.gatewayPort ?? 14242,
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType ?? 'none',
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keep path (preserve existing identity) ──────────────────────────────────
|
||||
|
||||
async function runKeepPath(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
configService: ConfigService,
|
||||
options: WizardOptions,
|
||||
): Promise<void> {
|
||||
// Runtime detection
|
||||
await runtimeSetupStage(prompter, state);
|
||||
|
||||
// Hooks
|
||||
await hooksPreviewStage(prompter, state);
|
||||
|
||||
// Skills
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Finalize
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: options.gatewayHost ?? 'localhost',
|
||||
@@ -134,28 +439,17 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
if (headlessRun) {
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
if (configResult.ready && configResult.host && configResult.port) {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed && headlessRun) {
|
||||
prompter.warn('Admin bootstrap failed in headless mode — aborting wizard.');
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Stages normally return structured `ready: false` results for
|
||||
// expected failures. Anything that reaches here is an unexpected
|
||||
// runtime error — render a concise warning for UX AND re-throw so
|
||||
// the CLI (and `tools/install.sh` auto-launch) sees a non-zero exit.
|
||||
// Swallowing here would let headless installs report success even
|
||||
// when the gateway stage crashed.
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
377
pnpm-lock.yaml
generated
377
pnpm-lock.yaml
generated
@@ -174,21 +174,39 @@ importers:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@nestjs/testing':
|
||||
specifier: ^11.1.18
|
||||
version: 11.1.18(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
'@swc/core':
|
||||
specifier: ^1.15.24
|
||||
version: 1.15.24(@swc/helpers@0.5.21)
|
||||
'@swc/helpers':
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
'@types/node-cron':
|
||||
specifier: ^3.0.11
|
||||
version: 3.0.11
|
||||
'@types/supertest':
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
'@types/uuid':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
supertest:
|
||||
specifier: ^7.2.2
|
||||
version: 7.2.2
|
||||
tsx:
|
||||
specifier: ^4.0.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
unplugin-swc:
|
||||
specifier: ^1.5.9
|
||||
version: 1.5.9(@swc/core@1.15.24(@swc/helpers@0.5.21))(rollup@4.59.0)
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||
@@ -2309,6 +2327,19 @@ packages:
|
||||
'@nestjs/websockets': ^11.0.0
|
||||
rxjs: ^7.1.0
|
||||
|
||||
'@nestjs/testing@11.1.18':
|
||||
resolution: {integrity: sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^11.0.0
|
||||
'@nestjs/core': ^11.0.0
|
||||
'@nestjs/microservices': ^11.0.0
|
||||
'@nestjs/platform-express': ^11.0.0
|
||||
peerDependenciesMeta:
|
||||
'@nestjs/microservices':
|
||||
optional: true
|
||||
'@nestjs/platform-express':
|
||||
optional: true
|
||||
|
||||
'@nestjs/throttler@6.5.0':
|
||||
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
|
||||
peerDependencies:
|
||||
@@ -2383,6 +2414,10 @@ packages:
|
||||
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/hashes@2.0.1':
|
||||
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
@@ -3007,6 +3042,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
@@ -3049,6 +3087,15 @@ packages:
|
||||
'@protobufjs/utf8@1.1.0':
|
||||
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
||||
|
||||
'@rollup/pluginutils@5.3.0':
|
||||
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
|
||||
cpu: [arm]
|
||||
@@ -3390,9 +3437,99 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.24':
|
||||
resolution: {integrity: sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@swc/core-darwin-x64@1.15.24':
|
||||
resolution: {integrity: sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@swc/core-linux-arm-gnueabihf@1.15.24':
|
||||
resolution: {integrity: sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-arm64-gnu@1.15.24':
|
||||
resolution: {integrity: sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.24':
|
||||
resolution: {integrity: sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.24':
|
||||
resolution: {integrity: sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.24':
|
||||
resolution: {integrity: sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.24':
|
||||
resolution: {integrity: sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.24':
|
||||
resolution: {integrity: sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.24':
|
||||
resolution: {integrity: sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core-win32-ia32-msvc@1.15.24':
|
||||
resolution: {integrity: sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core-win32-x64-msvc@1.15.24':
|
||||
resolution: {integrity: sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core@1.15.24':
|
||||
resolution: {integrity: sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@swc/helpers': '>=0.5.17'
|
||||
peerDependenciesMeta:
|
||||
'@swc/helpers':
|
||||
optional: true
|
||||
|
||||
'@swc/counter@0.1.3':
|
||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
||||
|
||||
'@swc/types@0.1.26':
|
||||
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
|
||||
|
||||
'@tailwindcss/node@4.2.1':
|
||||
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
|
||||
|
||||
@@ -3506,6 +3643,9 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/cookiejar@2.1.5':
|
||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
@@ -3536,6 +3676,9 @@ packages:
|
||||
'@types/memcached@2.2.10':
|
||||
resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==}
|
||||
|
||||
'@types/methods@1.1.4':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||
|
||||
'@types/mime-types@2.1.4':
|
||||
resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==}
|
||||
|
||||
@@ -3580,6 +3723,12 @@ packages:
|
||||
'@types/retry@0.12.0':
|
||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==}
|
||||
|
||||
'@types/supertest@7.2.0':
|
||||
resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==}
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
||||
|
||||
@@ -3788,6 +3937,9 @@ packages:
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
asap@2.0.6:
|
||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4129,6 +4281,9 @@ packages:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -4160,6 +4315,9 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cookiejar@2.1.4:
|
||||
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
@@ -4268,6 +4426,9 @@ packages:
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||
|
||||
diff@8.0.3:
|
||||
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
@@ -4573,6 +4734,9 @@ packages:
|
||||
estree-util-is-identifier-name@3.0.0:
|
||||
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
@@ -4751,6 +4915,10 @@ packages:
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
formidable@3.5.4:
|
||||
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
forwarded-parse@2.1.2:
|
||||
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
|
||||
|
||||
@@ -5324,6 +5492,10 @@ packages:
|
||||
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
||||
engines: {node: '>=13.2.0'}
|
||||
|
||||
load-tsconfig@0.2.5:
|
||||
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5462,6 +5634,10 @@ packages:
|
||||
merge-stream@2.0.0:
|
||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
||||
|
||||
methods@1.1.2:
|
||||
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||
|
||||
@@ -5545,6 +5721,11 @@ packages:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mime@2.6.0:
|
||||
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
hasBin: true
|
||||
|
||||
mimic-fn@2.1.0:
|
||||
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6502,6 +6683,14 @@ packages:
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
superagent@10.3.0:
|
||||
resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
supertest@7.2.2:
|
||||
resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6758,6 +6947,15 @@ packages:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
unplugin-swc@1.5.9:
|
||||
resolution: {integrity: sha512-RKwK3yf0M+MN17xZfF14bdKqfx0zMXYdtOdxLiE6jHAoidupKq3jGdJYANyIM1X/VmABhh1WpdO+/f4+Ol89+g==}
|
||||
peerDependencies:
|
||||
'@swc/core': ^1.2.108
|
||||
|
||||
unplugin@2.3.11:
|
||||
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
@@ -6870,6 +7068,9 @@ packages:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
whatwg-mimetype@5.0.0:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -8762,6 +8963,12 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@nestjs/testing@11.1.18(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -8808,6 +9015,8 @@ snapshots:
|
||||
|
||||
'@noble/ciphers@2.1.1': {}
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@nuxt/opencollective@0.4.1':
|
||||
@@ -9722,6 +9931,10 @@ snapshots:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
@@ -9754,6 +9967,14 @@ snapshots:
|
||||
|
||||
'@protobufjs/utf8@1.1.0': {}
|
||||
|
||||
'@rollup/pluginutils@5.3.0(rollup@4.59.0)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
estree-walker: 2.0.2
|
||||
picomatch: 4.0.3
|
||||
optionalDependencies:
|
||||
rollup: 4.59.0
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
optional: true
|
||||
|
||||
@@ -10151,10 +10372,75 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-darwin-x64@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm-gnueabihf@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm64-gnu@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-ia32-msvc@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-x64-msvc@1.15.24':
|
||||
optional: true
|
||||
|
||||
'@swc/core@1.15.24(@swc/helpers@0.5.21)':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/types': 0.1.26
|
||||
optionalDependencies:
|
||||
'@swc/core-darwin-arm64': 1.15.24
|
||||
'@swc/core-darwin-x64': 1.15.24
|
||||
'@swc/core-linux-arm-gnueabihf': 1.15.24
|
||||
'@swc/core-linux-arm64-gnu': 1.15.24
|
||||
'@swc/core-linux-arm64-musl': 1.15.24
|
||||
'@swc/core-linux-ppc64-gnu': 1.15.24
|
||||
'@swc/core-linux-s390x-gnu': 1.15.24
|
||||
'@swc/core-linux-x64-gnu': 1.15.24
|
||||
'@swc/core-linux-x64-musl': 1.15.24
|
||||
'@swc/core-win32-arm64-msvc': 1.15.24
|
||||
'@swc/core-win32-ia32-msvc': 1.15.24
|
||||
'@swc/core-win32-x64-msvc': 1.15.24
|
||||
'@swc/helpers': 0.5.21
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@swc/types@0.1.26':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
|
||||
'@tailwindcss/node@4.2.1':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -10252,6 +10538,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
@@ -10284,6 +10572,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
'@types/methods@1.1.4': {}
|
||||
|
||||
'@types/mime-types@2.1.4': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
@@ -10333,6 +10623,18 @@ snapshots:
|
||||
|
||||
'@types/retry@0.12.0': {}
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
dependencies:
|
||||
'@types/cookiejar': 2.1.5
|
||||
'@types/methods': 1.1.4
|
||||
'@types/node': 22.19.15
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/supertest@7.2.0':
|
||||
dependencies:
|
||||
'@types/methods': 1.1.4
|
||||
'@types/superagent': 8.1.9
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
@@ -10587,14 +10889,15 @@ snapshots:
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
asap@2.0.6: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-types@0.13.4:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
asynckit@0.4.0:
|
||||
optional: true
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
@@ -10891,7 +11194,6 @@ snapshots:
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
optional: true
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
@@ -10899,6 +11201,8 @@ snapshots:
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
consola@3.4.2: {}
|
||||
@@ -10915,6 +11219,8 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
cookiejar@2.1.4: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.6:
|
||||
@@ -10994,8 +11300,7 @@ snapshots:
|
||||
escodegen: 2.1.0
|
||||
esprima: 4.0.1
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
optional: true
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
@@ -11009,6 +11314,11 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
dezalgo@1.0.4:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
wrappy: 1.0.2
|
||||
|
||||
diff@8.0.3: {}
|
||||
|
||||
discord-api-types@0.38.42: {}
|
||||
@@ -11160,7 +11470,6 @@ snapshots:
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
optional: true
|
||||
|
||||
es-toolkit@1.45.1: {}
|
||||
|
||||
@@ -11368,6 +11677,8 @@ snapshots:
|
||||
|
||||
estree-util-is-identifier-name@3.0.0: {}
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -11618,12 +11929,17 @@ snapshots:
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
optional: true
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
dependencies:
|
||||
fetch-blob: 3.2.0
|
||||
|
||||
formidable@3.5.4:
|
||||
dependencies:
|
||||
'@paralleldrive/cuid2': 2.3.1
|
||||
dezalgo: 1.0.4
|
||||
once: 1.4.0
|
||||
|
||||
forwarded-parse@2.1.2: {}
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
@@ -11796,7 +12112,6 @@ snapshots:
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
optional: true
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
@@ -12268,6 +12583,8 @@ snapshots:
|
||||
|
||||
load-esm@1.0.3: {}
|
||||
|
||||
load-tsconfig@0.2.5: {}
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -12466,6 +12783,8 @@ snapshots:
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
|
||||
methods@1.1.2: {}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
dependencies:
|
||||
decode-named-character-reference: 1.3.0
|
||||
@@ -12616,6 +12935,8 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
mime@2.6.0: {}
|
||||
|
||||
mimic-fn@2.1.0: {}
|
||||
|
||||
mimic-fn@4.0.0: {}
|
||||
@@ -13696,6 +14017,28 @@ snapshots:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.4
|
||||
|
||||
superagent@10.3.0:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.4.3
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.5
|
||||
formidable: 3.5.4
|
||||
methods: 1.1.2
|
||||
mime: 2.6.0
|
||||
qs: 6.15.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
supertest@7.2.2:
|
||||
dependencies:
|
||||
cookie-signature: 1.2.2
|
||||
methods: 1.1.2
|
||||
superagent: 10.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -13951,6 +14294,22 @@ snapshots:
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
unplugin-swc@1.5.9(@swc/core@1.15.24(@swc/helpers@0.5.21))(rollup@4.59.0):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.59.0)
|
||||
'@swc/core': 1.15.24(@swc/helpers@0.5.21)
|
||||
load-tsconfig: 0.2.5
|
||||
unplugin: 2.3.11
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
|
||||
unplugin@2.3.11:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
acorn: 8.16.0
|
||||
picomatch: 4.0.3
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -14117,6 +14476,8 @@ snapshots:
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
whatwg-mimetype@5.0.0: {}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
|
||||
Reference in New Issue
Block a user