This commit is contained in:
596
README.md
596
README.md
@ -1,396 +1,288 @@
|
|||||||
# opencode Spec-Driven Development Boilerplate
|
# OpenGroupware
|
||||||
|
|
||||||
A spec-driven development boilerplate for [opencode](https://opencode.ai), converted from the
|
A simple, self-contained, project-based groupware built with **Next.js 15**, **TypeScript**, and
|
||||||
[Claude Code boilerplate](https://git.yasue.org/ken/claudecode-boilerplate) that accompanies the book
|
**SQLite**. It runs entirely on Node.js (no external database or storage) and provides boards,
|
||||||
*"Practical Claude Code Introduction."*
|
Markdown notes, realtime chat (SSE), a Kanban ToDo board, file sharing, a calendar with milestones,
|
||||||
|
meetings with schedule-conflict detection, search, dashboards, and admin backups.
|
||||||
|
|
||||||
It gives opencode a structured workflow for turning ideas into production code through persistent design
|
The codebase follows a strict **layered architecture**:
|
||||||
documents, per-task steering files, AI skills, subagents, and slash commands.
|
|
||||||
|
```
|
||||||
|
UI (app/) → Service (services/) → Repository (repositories/) → Data (lib/db/)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies flow one direction only; SQL is always parameter-bound and accessed through the
|
||||||
|
`SqliteDatabase` wrapper (never `better-sqlite3` directly in repositories).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [What Is Spec-Driven Development?](#what-is-spec-driven-development)
|
|
||||||
- [Conversion Summary (Claude Code → opencode)](#conversion-summary-claude-code--opencode)
|
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [Quick Start](#quick-start)
|
- [How to Start](#how-to-start)
|
||||||
- [Directory Structure](#directory-structure)
|
- [How to Test](#how-to-test)
|
||||||
- [Configuration Overview](#configuration-overview)
|
- [How to Develop](#how-to-develop)
|
||||||
- [Usage Workflow](#usage-workflow)
|
- [Environment Variables](#environment-variables)
|
||||||
- [Commands Reference](#commands-reference)
|
- [Project Structure](#project-structure)
|
||||||
- [Skills Reference](#skills-reference)
|
- [npm Scripts Reference](#npm-scripts-reference)
|
||||||
- [Agents Reference](#agents-reference)
|
|
||||||
- [Customizing the Boilerplate](#customizing-the-boilerplate)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [Troubleshooting](#troubleshooting)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What Is Spec-Driven Development?
|
|
||||||
|
|
||||||
Spec-driven development separates **"what to build"** from **"how to build it"**:
|
|
||||||
|
|
||||||
1. **Persistent documents** (`docs/`) — the north-star design (PRD, functional design, architecture, etc.)
|
|
||||||
2. **Steering files** (`.steering/`) — per-task plans created fresh for each piece of work
|
|
||||||
3. **Implementation** — the agent follows `tasklist.md`, updating progress as it goes
|
|
||||||
4. **Verification** — tests, lint, typecheck, and a retrospective
|
|
||||||
|
|
||||||
opencode loads the right skill automatically based on what you're doing, so you don't have to think about
|
|
||||||
the process — just describe what you want.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conversion Summary (Claude Code → opencode)
|
|
||||||
|
|
||||||
| Claude Code | opencode | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `CLAUDE.md` | `AGENTS.md` | Referenced via `instructions` in `opencode.json` |
|
|
||||||
| `.claude/settings.json` | `opencode.json` | Permissions converted to opencode's `permission` schema |
|
|
||||||
| `.claude/agents/*.md` | `.opencode/agent/*.md` | Added `mode: subagent`; `model: sonnet` removed (inherits default) |
|
|
||||||
| `.claude/commands/*.md` | `.opencode/command/*.md` | Tool-call syntax (`Bash()`, `Grep()`, `Skill()`) converted to natural instructions; `$ARGUMENTS` added |
|
|
||||||
| `.claude/skills/*/SKILL.md` | `.opencode/skills/*/SKILL.md` | `allowed-tools` frontmatter removed (not an opencode field) |
|
|
||||||
| `Skill('steering')` calls | Auto-loaded by description | opencode surfaces skills via `description` matching |
|
|
||||||
| `TodoWrite` / `Edit` tool refs | `todowrite` / `edit` | Lowercased to match opencode tool names |
|
|
||||||
| Claude Code devcontainer feature | `curl … opencode.ai/install` | Replaced Anthropic feature with opencode install script |
|
|
||||||
|
|
||||||
All skill templates and guides were preserved verbatim except for `.claude/` path references and
|
|
||||||
"Claude Code" mentions, which were updated to `.opencode/` and "opencode" respectively.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js** v18+ (v24.11.0 recommended)
|
- **Node.js** v24.11.0 (LTS; v18+ works) — the dev container pins this version
|
||||||
- **npm**
|
- **npm** 11.x (bundled with Node.js)
|
||||||
- **opencode** — install with:
|
- **Playwright browsers** for E2E tests (see [How to Test](#how-to-test))
|
||||||
```bash
|
|
||||||
curl -fsSL https://opencode.ai/install | bash
|
A Dev Container configuration (`.devcontainer/`) is included for VS Code.
|
||||||
```
|
|
||||||
See <https://opencode.ai/docs> for alternatives (npm, Homebrew, etc.)
|
|
||||||
- **Docker** (only if using the Dev Container)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## How to Start
|
||||||
|
|
||||||
### Option A — Dev Container (recommended)
|
|
||||||
|
|
||||||
1. Open this folder in **VS Code**.
|
|
||||||
2. When prompted, click **"Reopen in Container"** (or run the command
|
|
||||||
*Dev Containers: Reopen in Container*).
|
|
||||||
3. The container automatically:
|
|
||||||
- Installs Node.js LTS
|
|
||||||
- Runs `npm install`
|
|
||||||
- Installs opencode
|
|
||||||
4. Open a terminal and start opencode:
|
|
||||||
```bash
|
|
||||||
opencode
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option B — Manual setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Install dependencies
|
# 1. Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 2. Set up Git hooks
|
# 2. Create your local environment file
|
||||||
npm run prepare
|
cp .env.example .env
|
||||||
|
# Edit .env if you want to change SQLITE_PATH / SESSION_SECRET / UPLOADS_PATH.
|
||||||
|
# SESSION_SECRET is required — the app will not start without it.
|
||||||
|
|
||||||
# 3. Start opencode
|
# 3. Initialize the database (runs all SQL migrations)
|
||||||
opencode
|
npm run migrate
|
||||||
|
|
||||||
|
# 4. Start the development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is then available at **http://localhost:3000**.
|
||||||
|
|
||||||
|
- The first user you register becomes a regular `member`. To use admin features
|
||||||
|
(backups, migration status), promote a user to `system_admin` in the DB, or seed one:
|
||||||
|
```bash
|
||||||
|
npx tsx -e "import('./lib/db/sqlite.ts').then(async m => { \
|
||||||
|
const { UserRepository } = await import('./repositories/UserRepository.ts'); \
|
||||||
|
const bcrypt = (await import('bcrypt')).default; \
|
||||||
|
const db = new m.SqliteDatabase(process.env.SQLITE_PATH ?? './data/app.db'); \
|
||||||
|
new UserRepository(db).create({ name:'Admin', email:'admin@example.com', \
|
||||||
|
passwordHash: bcrypt.hashSync('admin123', 10), role:'system_admin' }); db.close(); })"
|
||||||
|
```
|
||||||
|
- The SQLite database lives at `./data/app.db` (and uploads under `./data/uploads/`). Both are
|
||||||
|
git-ignored. Deleting `./data/` and re-running `npm run migrate` gives a clean slate.
|
||||||
|
|
||||||
|
### Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start # serves the optimized build on http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Directory Structure
|
## How to Test
|
||||||
|
|
||||||
|
There are three test layers: **Unit/Integration** (Vitest) and **E2E** (Playwright).
|
||||||
|
|
||||||
|
### Unit & integration tests (Vitest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # run all unit/integration tests once
|
||||||
|
npm run test:watch # watch mode
|
||||||
|
npm run test:coverage # run with coverage (threshold: 80% for repositories/** + services/**)
|
||||||
|
```
|
||||||
|
|
||||||
|
These use a real temporary SQLite database per test (see `tests/helpers/db.ts`) and cover every
|
||||||
|
Repository, Service, and the key algorithms (schedule-conflict, milestone progress, session tokens,
|
||||||
|
notification targeting).
|
||||||
|
|
||||||
|
### End-to-end tests (Playwright)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e # run the full E2E suite (headless)
|
||||||
|
npm run test:e2e:ui # interactive UI mode — test tree + per-step screenshots/traces
|
||||||
|
```
|
||||||
|
|
||||||
|
Before running E2E:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the Chromium browser once (per machine)
|
||||||
|
npx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
How E2E works:
|
||||||
|
- Playwright's `webServer` runs `npm run migrate && npm run dev` automatically, so the DB is
|
||||||
|
initialized and the dev server is started for you.
|
||||||
|
- A `globalSetup` (`tests/e2e/globalSetup.ts`) seeds an `admin@example.com` / `admin123`
|
||||||
|
(`system_admin`) account used by the admin/backup tests.
|
||||||
|
- The suite runs with `workers: 1` for deterministic behavior (the realtime SSE test needs low
|
||||||
|
contention against the single dev server).
|
||||||
|
- E2E specs live in `tests/e2e/*.spec.ts` (auth, project-management, board, notes, chat-sse,
|
||||||
|
todo-kanban, file-sharing, calendar, meetings, search/dashboard, notifications, activity-log,
|
||||||
|
backup).
|
||||||
|
|
||||||
|
### Lint, type-check, build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run all three green before committing. A pre-commit hook (Husky + lint-staged) auto-fixes and
|
||||||
|
formats staged files.
|
||||||
|
|
||||||
|
### Recommended full check before opening a PR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint && npm run typecheck && npm test && npm run build && npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Develop
|
||||||
|
|
||||||
|
### Typical workflow
|
||||||
|
|
||||||
|
1. Branch from `main`: `git checkout -b feature/<name>` (or `fix/`, `refactor/`).
|
||||||
|
2. Implement following the layered architecture and coding conventions (see
|
||||||
|
`docs/development-guidelines.md`):
|
||||||
|
- Route handlers in `app/api/.../route.ts` start with `export const runtime = 'nodejs';` (Edge
|
||||||
|
runtime is forbidden because of better-sqlite3).
|
||||||
|
- Services do permission checks and call repositories; repositories hold SQL and use parameter
|
||||||
|
binding (`@param`) and `deleted_at IS NULL` for soft-deleted tables.
|
||||||
|
- Map DB `snake_case` columns to camelCase entity fields in repository mappers.
|
||||||
|
- All list endpoints paginate (`LIMIT`/`OFFSET`).
|
||||||
|
3. Add Unit tests for any new Repository/Service, and an E2E spec for user-facing flows.
|
||||||
|
4. Ensure `npm run lint`, `npm run typecheck`, `npm test`, and `npm run build` are green.
|
||||||
|
5. Merge to `main` (merge commit). Milestones in this project were delivered one per branch, each
|
||||||
|
merged to `main` before the next branched off.
|
||||||
|
|
||||||
|
### Database migrations
|
||||||
|
|
||||||
|
Migrations are plain SQL files in `lib/db/migrations/`, named `NNN_description.sql` and run in
|
||||||
|
filename order. Each file runs in its own transaction and is recorded in `schema_migrations`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate # apply pending migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
To add a schema change, create e.g. `lib/db/migrations/002_add_column.sql` and re-run
|
||||||
|
`npm run migrate`. Never edit an already-applied migration — add a new one.
|
||||||
|
|
||||||
|
### Realtime (SSE)
|
||||||
|
|
||||||
|
`lib/sse/hub.ts` (`SseHub`) keeps an in-memory set of clients per project and broadcasts events only
|
||||||
|
to that project's clients. Services inject the hub and call `broadcast(projectId, event)`; the
|
||||||
|
stream endpoint `GET /api/projects/:projectId/chat/stream` registers a client and cleans up on
|
||||||
|
abort. The chat UI uses `EventSource` (auto-reconnect).
|
||||||
|
|
||||||
|
### Sessions & auth
|
||||||
|
|
||||||
|
Stateless signed-cookie sessions (`lib/auth/session.ts`): the cookie is
|
||||||
|
`base64url({uid,iat}).base64url(hmacSha256(secret, payload))`, verified server-side with
|
||||||
|
`crypto.timingSafeEqual` and an `iat` expiry check. `middleware.ts` does a coarse cookie-presence
|
||||||
|
redirect for pages; the real HMAC + DB verification happens in `getCurrentUser` (Node runtime).
|
||||||
|
|
||||||
|
### Where things live
|
||||||
|
|
||||||
|
| Concern | Location |
|
||||||
|
|---|---|
|
||||||
|
| Pages & API routes | `app/` |
|
||||||
|
| Business logic, permissions | `services/` |
|
||||||
|
| SQL & data access | `repositories/` |
|
||||||
|
| DB wrapper, migrations, SSE, auth, types, validators | `lib/` |
|
||||||
|
| React components | `components/` |
|
||||||
|
| Tests | `tests/unit`, `tests/integration`, `tests/e2e` |
|
||||||
|
|
||||||
|
### Spec-driven workflow (optional)
|
||||||
|
|
||||||
|
This project also ships an opencode spec-driven workflow (see `AGENTS.md`, `docs/`, `.steering/`,
|
||||||
|
`.opencode/`). Persistent design docs live in `docs/`; per-task plans live in
|
||||||
|
`.steering/[YYYYMMDD]-[name]/`. You can drive new work with `/add-feature <name>` in opencode, but
|
||||||
|
normal Git + `npm` development works too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configured via `.env` (copy from `.env.example`):
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `SQLITE_PATH` | `./data/app.db` | SQLite database file path |
|
||||||
|
| `SESSION_SECRET` | _(must set)_ | HMAC secret for signing session cookies |
|
||||||
|
| `UPLOADS_PATH` | `./data/uploads` | Directory for uploaded files |
|
||||||
|
|
||||||
|
`SESSION_SECRET` must be set — the app throws on startup if it is missing. Never commit `.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── opencode.json # opencode config (permissions, instructions)
|
├── app/ # Next.js App Router: pages + Route Handlers (all runtime='nodejs')
|
||||||
├── AGENTS.md # Project instructions (loaded into every session)
|
│ ├── api/ # REST/SSE endpoints
|
||||||
├── package.json # Node.js project + scripts
|
│ ├── projects/[projectId]/ # project screens (board, notes, chat, todos, files, calendar, ...)
|
||||||
├── tsconfig.json # TypeScript config
|
│ ├── admin/backups/ # admin-only backup screen
|
||||||
├── eslint.config.js # ESLint flat config
|
│ └── login / profile / dashboard / notifications
|
||||||
├── vitest.config.ts # Vitest config (80% coverage threshold)
|
├── lib/
|
||||||
├── .prettierrc / .prettierignore
|
│ ├── db/ # SqliteDatabase, Migrator, migrations/*.sql
|
||||||
├── .gitignore
|
│ ├── sse/ # SseHub
|
||||||
├── .husky/pre-commit # Runs lint-staged + typecheck before commit
|
│ ├── auth/ # session, getCurrentUser
|
||||||
├── .devcontainer/devcontainer.json
|
│ ├── types/ # entity & event types
|
||||||
├── prompt.md # Example MVP prompt
|
│ ├── validators/ # input validators
|
||||||
│
|
│ ├── api/ # service factories + error handling for route handlers
|
||||||
├── src/ # Source code
|
│ └── errors.ts # AppError hierarchy (ValidationError, ForbiddenError, ...)
|
||||||
│ ├── example.ts
|
├── repositories/ # one class per table (SQL + mapping)
|
||||||
│ └── example.test.ts
|
├── services/ # business logic + permission checks + side effects
|
||||||
│
|
├── components/ # React components by area (layout, board, chat, todo, ...)
|
||||||
├── docs/ # Persistent design documents (created by /setup-project)
|
├── tests/
|
||||||
│ └── ideas/
|
│ ├── unit/ # Vitest — mirrors source structure
|
||||||
│ └── initial-requirements.md # Brainstorming notes (pre-PRD)
|
│ ├── integration/ # Vitest — real temp SQLite DB
|
||||||
│
|
│ ├── e2e/ # Playwright specs + globalSetup.ts
|
||||||
├── .steering/ # Per-task steering files (created by /add-feature)
|
│ └── helpers/db.ts # createTestDb() / createMigratedTestDb()
|
||||||
│ └── .gitkeep
|
├── data/ # git-ignored: app.db, uploads/
|
||||||
│
|
├── backups/ # git-ignored: backup-<timestamp>.zip
|
||||||
└── .opencode/ # opencode configuration
|
├── docs/ # persistent design docs (PRD, architecture, milestones, ...)
|
||||||
├── agent/ # Subagent definitions
|
└── .steering/ # per-task plans (git-ignored working artifacts)
|
||||||
│ ├── doc-reviewer.md
|
|
||||||
│ └── implementation-validator.md
|
|
||||||
├── command/ # Slash commands
|
|
||||||
│ ├── setup-project.md
|
|
||||||
│ ├── add-feature.md
|
|
||||||
│ └── review-docs.md
|
|
||||||
└── skills/ # Task-specific skills
|
|
||||||
├── prd-writing/ (SKILL.md + template.md)
|
|
||||||
├── functional-design/ (SKILL.md + template.md + guide.md)
|
|
||||||
├── architecture-design/ (SKILL.md + template.md + guide.md)
|
|
||||||
├── repository-structure/ (SKILL.md + template.md + guide.md)
|
|
||||||
├── development-guidelines/(SKILL.md + template.md + guides/)
|
|
||||||
├── glossary-creation/ (SKILL.md + template.md + guide.md)
|
|
||||||
└── steering/ (SKILL.md + templates/)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration Overview
|
## npm Scripts Reference
|
||||||
|
|
||||||
### `opencode.json`
|
| Script | Description |
|
||||||
|
|---|---|
|
||||||
The main configuration file. Key sections:
|
| `npm run dev` | Start the Next.js dev server (http://localhost:3000) |
|
||||||
|
| `npm run build` | Production build |
|
||||||
```jsonc
|
| `npm run start` | Serve the production build |
|
||||||
{
|
| `npm run lint` | ESLint |
|
||||||
"$schema": "https://opencode.ai/config.json",
|
| `npm run format` | Prettier (write) |
|
||||||
"instructions": ["AGENTS.md"], // Loaded into every session
|
| `npm run typecheck` | `tsc --noEmit` |
|
||||||
"permission": {
|
| `npm test` | Run unit/integration tests (Vitest, once) |
|
||||||
"skill": "allow", // Skills are auto-available
|
| `npm run test:watch` | Vitest watch mode |
|
||||||
"bash": { // Common dev commands pre-approved
|
| `npm run test:coverage` | Vitest with coverage (80% threshold on repos+services) |
|
||||||
"*": "ask",
|
| `npm run test:e2e` | Playwright E2E (headless, full suite) |
|
||||||
"npm *": "allow",
|
| `npm run test:e2e:ui` | Playwright interactive UI mode (screenshots + traces) |
|
||||||
"npx *": "allow",
|
| `npm run migrate` | Apply SQL migrations |
|
||||||
"git *": "allow",
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **After editing `opencode.json`, quit and restart opencode** — config is loaded once at startup.
|
|
||||||
|
|
||||||
### `AGENTS.md`
|
|
||||||
|
|
||||||
The project memory file. opencode reads it on every session. It defines:
|
|
||||||
- The technology stack
|
|
||||||
- The spec-driven development principles
|
|
||||||
- The directory structure
|
|
||||||
- The development process and workflow
|
|
||||||
|
|
||||||
### Agents (`.opencode/agent/`)
|
|
||||||
|
|
||||||
Subagents run in isolated contexts via the `task` tool. They don't consume the main session's context.
|
|
||||||
|
|
||||||
### Commands (`.opencode/command/`)
|
|
||||||
|
|
||||||
Slash commands invoked in the opencode TUI with `/command-name`. Each command is a prompt template
|
|
||||||
that opencode executes.
|
|
||||||
|
|
||||||
### Skills (`.opencode/skills/`)
|
|
||||||
|
|
||||||
Skills are loaded automatically based on their `description` field. You don't invoke them by name —
|
|
||||||
opencode surfaces the right skill when the task matches.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage Workflow
|
|
||||||
|
|
||||||
### 1. Initial project setup
|
|
||||||
|
|
||||||
Run the setup command to create all six persistent design documents interactively:
|
|
||||||
|
|
||||||
```
|
|
||||||
> /setup-project
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates (one at a time, with your approval):
|
|
||||||
1. `docs/product-requirements.md` (PRD)
|
|
||||||
2. `docs/functional-design.md`
|
|
||||||
3. `docs/architecture.md`
|
|
||||||
4. `docs/repository-structure.md`
|
|
||||||
5. `docs/development-guidelines.md`
|
|
||||||
6. `docs/glossary.md`
|
|
||||||
|
|
||||||
The PRD is based on the brainstorming notes in `docs/ideas/initial-requirements.md`. Edit that file
|
|
||||||
first if you have your own idea, or replace it.
|
|
||||||
|
|
||||||
### 2. Add a feature
|
|
||||||
|
|
||||||
```
|
|
||||||
> /add-feature User authentication
|
|
||||||
```
|
|
||||||
|
|
||||||
This fully autonomous workflow:
|
|
||||||
1. Creates `.steering/[YYYYMMDD]-[feature-name]/` with `requirements.md`, `design.md`, `tasklist.md`
|
|
||||||
2. Generates the steering files (planning mode of the steering skill)
|
|
||||||
3. Implements every task in `tasklist.md`, marking each `[ ]` → `[x]` as it goes
|
|
||||||
4. Launches the `implementation-validator` subagent for quality review
|
|
||||||
5. Runs `npm test`, `npm run lint`, `npm run typecheck`
|
|
||||||
6. Records a retrospective in `tasklist.md`
|
|
||||||
|
|
||||||
### 3. Review a document
|
|
||||||
|
|
||||||
```
|
|
||||||
> /review-docs docs/product-requirements.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Launches the `doc-reviewer` subagent to evaluate the document from five perspectives:
|
|
||||||
completeness, clarity, consistency, implementability, and measurability.
|
|
||||||
|
|
||||||
### 4. Day-to-day editing
|
|
||||||
|
|
||||||
You don't always need a command — just ask in normal conversation:
|
|
||||||
|
|
||||||
```
|
|
||||||
> Add a new feature to the PRD
|
|
||||||
> Review the performance requirements in architecture.md
|
|
||||||
> Add a new domain term to glossary.md
|
|
||||||
```
|
|
||||||
|
|
||||||
opencode loads the appropriate skill automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commands Reference
|
|
||||||
|
|
||||||
| Command | Description | Example |
|
|
||||||
|---|---|---|
|
|
||||||
| `/setup-project` | Create the six persistent documents interactively | `/setup-project` |
|
|
||||||
| `/add-feature` | Implement a feature end-to-end (autonomous) | `/add-feature User profile editing` |
|
|
||||||
| `/review-docs` | Detailed document review via subagent | `/review-docs docs/architecture.md` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Skills Reference
|
|
||||||
|
|
||||||
| Skill | When it loads | Output |
|
|
||||||
|---|---|---|
|
|
||||||
| `prd-writing` | Creating a Product Requirements Document | `docs/product-requirements.md` |
|
|
||||||
| `functional-design` | Creating a functional design document | `docs/functional-design.md` |
|
|
||||||
| `architecture-design` | Designing the system architecture | `docs/architecture.md` |
|
|
||||||
| `repository-structure` | Defining the directory layout | `docs/repository-structure.md` |
|
|
||||||
| `development-guidelines` | Creating coding conventions / implementing code | `docs/development-guidelines.md` |
|
|
||||||
| `glossary-creation` | Creating a project glossary | `docs/glossary.md` |
|
|
||||||
| `steering` | Planning, implementing, and retrospecting on a task | `.steering/[date]-[name]/` files |
|
|
||||||
|
|
||||||
Each skill folder contains a `SKILL.md` (instructions) plus `template.md` and/or `guide.md` (reference
|
|
||||||
material the skill points to).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agents Reference
|
|
||||||
|
|
||||||
| Agent | Mode | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `doc-reviewer` | subagent | Reviews document quality across 5 dimensions; outputs a scored report with prioritized improvements |
|
|
||||||
| `implementation-validator` | subagent | Validates code against the spec; checks quality, test coverage, security, performance; runs lint/test/typecheck |
|
|
||||||
|
|
||||||
Agents are launched via the `task` tool (e.g., by the `/add-feature` and `/review-docs` commands).
|
|
||||||
They run in an isolated context window.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Customizing the Boilerplate
|
|
||||||
|
|
||||||
### Change the model
|
|
||||||
|
|
||||||
Agents inherit the default model from `opencode.json`. To pin a specific model for a subagent, add
|
|
||||||
`model` to its frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
description: ...
|
|
||||||
mode: subagent
|
|
||||||
model: anthropic/claude-sonnet-4-6
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Or set a global default in `opencode.json`:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"model": "anthropic/claude-sonnet-4-6"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Model IDs use the `provider/model-id` format. See <https://opencode.ai/config.json> for the full schema.
|
|
||||||
|
|
||||||
### Adjust permissions
|
|
||||||
|
|
||||||
Edit the `permission` block in `opencode.json`. For example, to allow all bash commands:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"permission": { "bash": "allow" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add a new skill
|
|
||||||
|
|
||||||
1. Create `.opencode/skills/my-skill/SKILL.md`
|
|
||||||
2. Add frontmatter with `name` and `description` (the description controls when opencode loads it)
|
|
||||||
3. Add any companion `template.md` or `guide.md` files
|
|
||||||
4. Restart opencode
|
|
||||||
|
|
||||||
### Add a new command
|
|
||||||
|
|
||||||
1. Create `.opencode/command/my-command.md`
|
|
||||||
2. Add `description` frontmatter + a prompt body (use `$ARGUMENTS` for user input)
|
|
||||||
3. Restart opencode
|
|
||||||
|
|
||||||
### Add a new agent
|
|
||||||
|
|
||||||
1. Create `.opencode/agent/my-agent.md`
|
|
||||||
2. Add `description` and `mode: subagent` frontmatter + a prompt body
|
|
||||||
3. Restart opencode
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### opencode won't start after editing config
|
- **App won't start: `SESSION_SECRET is not configured`** — create `.env` from `.env.example` and
|
||||||
|
set `SESSION_SECRET`.
|
||||||
opencode validates `opencode.json` strictly and refuses to start on invalid fields. To recover:
|
- **`npm run migrate` fails with "more than one statement"** — ensure the migrator uses
|
||||||
|
`SqliteDatabase.exec()` (it does); this is a historical footgun with `better-sqlite3`'s
|
||||||
```bash
|
`prepare()`.
|
||||||
# Skip the project config and start from globals only
|
- **E2E: `Executable doesn't exist`** — run `npx playwright install chromium`.
|
||||||
OPENCODE_DISABLE_PROJECT_CONFIG=1 opencode
|
- **E2E: tests flaky/failing** — the suite is pinned to `workers: 1`; ensure no other `npm run dev`
|
||||||
```
|
is occupying port 3000, and remove `./data/` before a clean run.
|
||||||
|
- **Port 3000 busy** — stop the other process (`lsof -ti:3000 | xargs kill`) before running dev/E2E.
|
||||||
Then fix the broken file and restart normally.
|
|
||||||
|
|
||||||
### Skills not loading
|
|
||||||
|
|
||||||
- Ensure each `SKILL.md` has a `description` in its frontmatter — skills without one are filtered out.
|
|
||||||
- Ensure the folder name matches the `name` field.
|
|
||||||
- Restart opencode after adding or changing skills.
|
|
||||||
|
|
||||||
### Commands not appearing
|
|
||||||
|
|
||||||
- Command files must be directly inside `.opencode/command/` (not in subdirectories).
|
|
||||||
- Restart opencode after adding commands.
|
|
||||||
|
|
||||||
### Dev Container: `opencode` command not found
|
|
||||||
|
|
||||||
The install script adds opencode to `~/.opencode/bin/` and updates your shell profile. If the command
|
|
||||||
isn't found in a new terminal:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PATH="$HOME/.opencode/bin:$PATH"
|
|
||||||
opencode
|
|
||||||
```
|
|
||||||
|
|
||||||
Or create a permanent symlink:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ln -sf "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -14,8 +14,10 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"migrate": "tsx lib/db/run-migrations.ts"
|
"migrate": "tsx lib/db/run-migrations.ts",
|
||||||
|
"seed": "tsx scripts/seed-demo.ts"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"groupware",
|
"groupware",
|
||||||
|
|||||||
729
scripts/seed-demo.ts
Normal file
729
scripts/seed-demo.ts
Normal file
@ -0,0 +1,729 @@
|
|||||||
|
/**
|
||||||
|
* デモデータを一括投入するシードスクリプト。
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* npm run seed # ./data/app.db をリセットしてデモデータを投入
|
||||||
|
* SQLITE_PATH=./other.db npm run seed
|
||||||
|
*
|
||||||
|
* 注意: 既存のDBファイルとuploadsディレクトリを削除してから再作成します
|
||||||
|
* (デモ用途のため、冪等に再実行できるようにリセット方式を採用)。
|
||||||
|
*
|
||||||
|
* 投入内容: 管理者1名 + 一般ユーザー5名、プロジェクト3件、各プロジェクトに
|
||||||
|
* 掲示板/チャット/ToDo/メモ/ファイル/カレンダー/マイルストーン/ミーティング/
|
||||||
|
* 通知/アクティビティログを網羅。
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { SqliteDatabase } from '../lib/db/sqlite';
|
||||||
|
import { Migrator } from '../lib/db/migrator';
|
||||||
|
|
||||||
|
const BCRYPT_ROUNDS = 10;
|
||||||
|
const now = () => new Date().toISOString();
|
||||||
|
const daysFromNow = (d: number) => {
|
||||||
|
const dt = new Date();
|
||||||
|
dt.setDate(dt.getDate() + d);
|
||||||
|
return dt.toISOString();
|
||||||
|
};
|
||||||
|
const dayStr = (d: number) => daysFromNow(d).slice(0, 10);
|
||||||
|
|
||||||
|
interface UserSeed {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: 'system_admin' | 'member';
|
||||||
|
status: 'active' | 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
const USERS: UserSeed[] = [
|
||||||
|
{
|
||||||
|
name: 'Admin User',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
password: 'admin123',
|
||||||
|
role: 'system_admin',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Alice Tanaka',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
password: 'password',
|
||||||
|
role: 'member',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Bob Sato',
|
||||||
|
email: 'bob@example.com',
|
||||||
|
password: 'password',
|
||||||
|
role: 'member',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Carol Yamada',
|
||||||
|
email: 'carol@example.com',
|
||||||
|
password: 'password',
|
||||||
|
role: 'member',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Dave Suzuki',
|
||||||
|
email: 'dave@example.com',
|
||||||
|
password: 'password',
|
||||||
|
role: 'member',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Eve Mori (inactive)',
|
||||||
|
email: 'eve@example.com',
|
||||||
|
password: 'password',
|
||||||
|
role: 'member',
|
||||||
|
status: 'inactive',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 1x1 透明PNG(ファイル共有デモ用)
|
||||||
|
const PNG_BYTES = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||||
|
'base64'
|
||||||
|
);
|
||||||
|
|
||||||
|
function insert(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
sql: string,
|
||||||
|
params: Record<string, unknown> = {}
|
||||||
|
): number {
|
||||||
|
return Number(db.execute(sql, params).lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStorage(dbPath: string, uploadsDir: string): void {
|
||||||
|
for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
||||||
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||||
|
}
|
||||||
|
if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedUsers(db: SqliteDatabase): Map<string, number> {
|
||||||
|
const ids = new Map<string, number>();
|
||||||
|
for (const u of USERS) {
|
||||||
|
const id = insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO users (name, email, password_hash, avatar_url, role, status, created_at, updated_at)
|
||||||
|
VALUES (@name, @email, @passwordHash, NULL, @role, @status, @createdAt, @updatedAt)`,
|
||||||
|
{
|
||||||
|
name: u.name,
|
||||||
|
email: u.email,
|
||||||
|
passwordHash: bcrypt.hashSync(u.password, BCRYPT_ROUNDS),
|
||||||
|
role: u.role,
|
||||||
|
status: u.status,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ids.set(u.email, id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectSeed {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: 'active' | 'on_hold' | 'completed' | 'archived';
|
||||||
|
owner: string; // email
|
||||||
|
members: { email: string; role: 'admin' | 'member' | 'guest' }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECTS: ProjectSeed[] = [
|
||||||
|
{
|
||||||
|
name: 'Website Redesign',
|
||||||
|
description: 'コーポレートサイトの全面リニューアルプロジェクト',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'alice@example.com',
|
||||||
|
members: [
|
||||||
|
{ email: 'bob@example.com', role: 'member' },
|
||||||
|
{ email: 'carol@example.com', role: 'member' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mobile App v2',
|
||||||
|
description: 'iOS/Androidアプリの次期メジャーバージョン開発',
|
||||||
|
status: 'active',
|
||||||
|
owner: 'bob@example.com',
|
||||||
|
members: [
|
||||||
|
{ email: 'alice@example.com', role: 'member' },
|
||||||
|
{ email: 'dave@example.com', role: 'member' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Marketing Campaign Q4',
|
||||||
|
description: '第4四半期マーケティングキャンペーンの企画・実行',
|
||||||
|
status: 'on_hold',
|
||||||
|
owner: 'carol@example.com',
|
||||||
|
members: [
|
||||||
|
{ email: 'alice@example.com', role: 'member' },
|
||||||
|
{ email: 'bob@example.com', role: 'member' },
|
||||||
|
{ email: 'dave@example.com', role: 'guest' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function addMember(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
projectId: number,
|
||||||
|
userId: number,
|
||||||
|
role: string
|
||||||
|
): void {
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO project_members (project_id, user_id, role, joined_at)
|
||||||
|
VALUES (@projectId, @userId, @role, @joinedAt)`,
|
||||||
|
{ projectId, userId, role, joinedAt: now() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedBoard(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
projectId: number,
|
||||||
|
authorIds: number[]
|
||||||
|
): void {
|
||||||
|
const threads = [
|
||||||
|
{
|
||||||
|
title: 'デザイン方針について',
|
||||||
|
body: '# デザイン方針\n\n- シンプルさを重視\n- モバイルファースト\n\nご意見ください。',
|
||||||
|
category: 'decision',
|
||||||
|
pinned: 1,
|
||||||
|
important: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '週次進捗報告',
|
||||||
|
body: '今週の進捗を共有します。\n\n| タスク | 状態 |\n|---|---|\n| トップ画面 | 完了 |\n| About画面 | 進行中 |',
|
||||||
|
category: 'minutes',
|
||||||
|
pinned: 0,
|
||||||
|
important: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'FAQ: ログインできない',
|
||||||
|
body: 'ログインできない場合のトラブルシューティングです。',
|
||||||
|
category: 'trouble',
|
||||||
|
pinned: 0,
|
||||||
|
important: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const t of threads) {
|
||||||
|
const author = authorIds[0];
|
||||||
|
const threadId = insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO board_threads (project_id, title, body_md, author_id, category, is_pinned, is_important, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@projectId, @title, @bodyMd, @authorId, @category, @isPinned, @isImportant, @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
title: t.title,
|
||||||
|
bodyMd: t.body,
|
||||||
|
authorId: author,
|
||||||
|
category: t.category,
|
||||||
|
isPinned: t.pinned,
|
||||||
|
isImportant: t.important,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// コメント
|
||||||
|
const commenter = authorIds[1] ?? author;
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO board_comments (thread_id, author_id, body_md, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@threadId, @authorId, @bodyMd, @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
threadId,
|
||||||
|
authorId: commenter,
|
||||||
|
bodyMd: '確認しました。ありがとうございます!',
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedChat(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
projectId: number,
|
||||||
|
authorIds: number[]
|
||||||
|
): void {
|
||||||
|
const messages = [
|
||||||
|
{ author: 0, body: 'おはようございます!今日もよろしくお願いします。' },
|
||||||
|
{
|
||||||
|
author: 1,
|
||||||
|
body: 'おつですー。 @alice@example.com ちょっと確認したいことあります',
|
||||||
|
},
|
||||||
|
{ author: 0, body: '何でしょう?' },
|
||||||
|
{ author: 1, body: 'デザインのカラーパレット、これで確定で大丈夫ですか?' },
|
||||||
|
{ author: 0, body: '問題ないです!進めましょう :)' },
|
||||||
|
];
|
||||||
|
for (const m of messages) {
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO chat_messages (project_id, author_id, body, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@projectId, @authorId, @body, @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
authorId: authorIds[m.author],
|
||||||
|
body: m.body,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedTodos(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
projectId: number,
|
||||||
|
memberIds: number[],
|
||||||
|
milestoneIds: number[]
|
||||||
|
): void {
|
||||||
|
const columns = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done'];
|
||||||
|
const colIds: number[] = [];
|
||||||
|
columns.forEach((name, idx) => {
|
||||||
|
colIds.push(
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO todo_columns (project_id, name, order_index, created_at, updated_at)
|
||||||
|
VALUES (@projectId, @name, @orderIndex, @createdAt, @updatedAt)`,
|
||||||
|
{ projectId, name, orderIndex: idx, createdAt: now(), updatedAt: now() }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
col: 0,
|
||||||
|
title: 'アイデア募集: ヘッダー案',
|
||||||
|
priority: 'low',
|
||||||
|
assignee: null,
|
||||||
|
due: null,
|
||||||
|
milestone: null,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 1,
|
||||||
|
title: 'トップ画面の実装',
|
||||||
|
priority: 'high',
|
||||||
|
assignee: 0,
|
||||||
|
due: dayStr(2),
|
||||||
|
milestone: 0,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 1,
|
||||||
|
title: 'About画面の実装',
|
||||||
|
priority: 'normal',
|
||||||
|
assignee: 1,
|
||||||
|
due: dayStr(5),
|
||||||
|
milestone: 0,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 2,
|
||||||
|
title: '問い合わせフォーム',
|
||||||
|
priority: 'normal',
|
||||||
|
assignee: 0,
|
||||||
|
due: dayStr(7),
|
||||||
|
milestone: 1,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 3,
|
||||||
|
title: 'デザインレビュー',
|
||||||
|
priority: 'high',
|
||||||
|
assignee: 1,
|
||||||
|
due: dayStr(1),
|
||||||
|
milestone: 1,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 4,
|
||||||
|
title: '要件定義',
|
||||||
|
priority: 'normal',
|
||||||
|
assignee: 0,
|
||||||
|
due: dayStr(-3),
|
||||||
|
milestone: 0,
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 4,
|
||||||
|
title: 'ワイヤフレーム',
|
||||||
|
priority: 'normal',
|
||||||
|
assignee: 1,
|
||||||
|
due: dayStr(-1),
|
||||||
|
milestone: 0,
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
items.forEach((it, idx) => {
|
||||||
|
const assigneeId = it.assignee === null ? null : memberIds[it.assignee];
|
||||||
|
const milestoneId =
|
||||||
|
it.milestone === null ? null : milestoneIds[it.milestone];
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, NULL, @dueDate, @completedAt, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
columnId: colIds[it.col],
|
||||||
|
title: it.title,
|
||||||
|
description: 'デモ用タスクです。',
|
||||||
|
assigneeId,
|
||||||
|
creatorId: memberIds[0],
|
||||||
|
priority: it.priority,
|
||||||
|
dueDate: it.due,
|
||||||
|
completedAt: it.completed ? now() : null,
|
||||||
|
orderIndex: idx,
|
||||||
|
milestoneId,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedNotes(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
projectId: number,
|
||||||
|
authorIds: number[]
|
||||||
|
): void {
|
||||||
|
const notes = [
|
||||||
|
{
|
||||||
|
title: 'ミーティングメモ',
|
||||||
|
body: '# ミーティングメモ\n\n## 決定事項\n- カラーテーマ: 青\n- リリース: 来月\n\n## 宿題\n- [ ] デザイン作成\n- [ ] レビュー',
|
||||||
|
tags: 'meeting,notes',
|
||||||
|
pinned: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '技術メモ',
|
||||||
|
body: '## 技術スタック\n\n- Next.js 15\n- SQLite\n- Tailwind CSS\n\n```ts\nconst x = 1;\n```',
|
||||||
|
tags: 'tech',
|
||||||
|
pinned: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'アイデア',
|
||||||
|
body: '面白い機能のアイデアをメモしておく場所。',
|
||||||
|
tags: null,
|
||||||
|
pinned: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const n of notes) {
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO project_notes (project_id, title, body_md, tags, is_pinned, created_by_id, updated_by_id, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@projectId, @title, @bodyMd, @tags, @isPinned, @createdById, @updatedById, @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
title: n.title,
|
||||||
|
bodyMd: n.body,
|
||||||
|
tags: n.tags,
|
||||||
|
isPinned: n.pinned,
|
||||||
|
createdById: authorIds[0],
|
||||||
|
updatedById: authorIds[0],
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedFiles(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
projectId: number,
|
||||||
|
uploaderId: number,
|
||||||
|
uploadsDir: string
|
||||||
|
): void {
|
||||||
|
const dir = path.join(uploadsDir, String(projectId));
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
// 画像ファイル
|
||||||
|
const imgName = `${crypto.randomUUID()}.png`;
|
||||||
|
const imgPath = path.join(dir, imgName);
|
||||||
|
fs.writeFileSync(imgPath, PNG_BYTES);
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at)
|
||||||
|
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
uploaderId,
|
||||||
|
filename: imgName,
|
||||||
|
originalName: 'sample-diagram.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
size: PNG_BYTES.length,
|
||||||
|
path: imgPath,
|
||||||
|
createdAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// テキストファイル
|
||||||
|
const txtName = `${crypto.randomUUID()}.txt`;
|
||||||
|
const txtPath = path.join(dir, txtName);
|
||||||
|
const txtContent =
|
||||||
|
'これはデモ用テキストファイルです。\nシードスクリプトによって生成されました。';
|
||||||
|
fs.writeFileSync(txtPath, txtContent, 'utf-8');
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at)
|
||||||
|
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
uploaderId,
|
||||||
|
filename: txtName,
|
||||||
|
originalName: 'spec-notes.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
size: Buffer.byteLength(txtContent),
|
||||||
|
path: txtPath,
|
||||||
|
createdAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedMilestones(db: SqliteDatabase, projectId: number): number[] {
|
||||||
|
const ms = [
|
||||||
|
{ title: 'M1: デザイン完了', due: dayStr(7) },
|
||||||
|
{ title: 'M2: 実装完了', due: dayStr(21) },
|
||||||
|
];
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (const m of ms) {
|
||||||
|
ids.push(
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO milestones (project_id, title, description, due_date, status, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@projectId, @title, @description, @dueDate, 'open', @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
title: m.title,
|
||||||
|
description: 'マイルストーンです。',
|
||||||
|
dueDate: m.due,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedCalendar(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
projectId: number,
|
||||||
|
creatorId: number
|
||||||
|
): void {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
title: '定例ミーティング',
|
||||||
|
type: 'meeting',
|
||||||
|
start: daysFromNow(1),
|
||||||
|
end: daysFromNow(1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'デザインレビュー',
|
||||||
|
type: 'reminder',
|
||||||
|
start: daysFromNow(3),
|
||||||
|
end: null,
|
||||||
|
},
|
||||||
|
{ title: 'リリース締切', type: 'deadline', start: dayStr(14), end: null },
|
||||||
|
];
|
||||||
|
for (const e of events) {
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO calendar_events (project_id, title, description, type, start_at, end_at, created_by_id, related_todo_id, related_milestone_id, related_meeting_id, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@projectId, @title, @description, @type, @startAt, @endAt, @createdById, NULL, NULL, NULL, @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
title: e.title,
|
||||||
|
description: 'カレンダーイベントです。',
|
||||||
|
type: e.type,
|
||||||
|
startAt: e.start,
|
||||||
|
endAt: e.end,
|
||||||
|
createdById: creatorId,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedMeetings(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
projectId: number,
|
||||||
|
creatorId: number,
|
||||||
|
memberIds: number[]
|
||||||
|
): void {
|
||||||
|
const meetingId = insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO meetings (project_id, title, description, location, meeting_url, start_at, end_at, agenda_md, minutes_md, created_by_id, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@projectId, @title, @description, @location, @meetingUrl, @startAt, @endAt, @agendaMd, @minutesMd, @createdById, @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
title: '週次定例ミーティング',
|
||||||
|
description: '進捗確認と課題共有',
|
||||||
|
location: '会議室A / オンライン',
|
||||||
|
meetingUrl: 'https://meet.example.com/abc-defg-hij',
|
||||||
|
startAt: daysFromNow(2),
|
||||||
|
endAt: daysFromNow(2),
|
||||||
|
agendaMd: '# アジェンダ\n\n1. 進捗共有\n2. ブロッカー確認\n3. 次週の計画',
|
||||||
|
minutesMd: '# 議事録\n\n- トップ画面は完了\n- About画面は来週完了予定',
|
||||||
|
createdById: creatorId,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
for (const uid of memberIds) {
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO meeting_members (meeting_id, user_id, status) VALUES (@meetingId, @userId, 'invited')`,
|
||||||
|
{ meetingId, userId: uid }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedNotifications(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
projectId: number,
|
||||||
|
memberIds: number[]
|
||||||
|
): void {
|
||||||
|
const notifs = [
|
||||||
|
{
|
||||||
|
user: 0,
|
||||||
|
type: 'mention',
|
||||||
|
title: 'メンションされました',
|
||||||
|
body: 'チャットでメンションされました',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user: 1,
|
||||||
|
type: 'todo_assigned',
|
||||||
|
title: 'ToDoが割り当てられました',
|
||||||
|
body: 'トップ画面の実装',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user: 0,
|
||||||
|
type: 'meeting_invited',
|
||||||
|
title: 'ミーティングに招待されました',
|
||||||
|
body: '週次定例ミーティング',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const n of notifs) {
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO notifications (user_id, project_id, type, title, body, read_at, created_at)
|
||||||
|
VALUES (@userId, @projectId, @type, @title, @body, NULL, @createdAt)`,
|
||||||
|
{
|
||||||
|
userId: memberIds[n.user],
|
||||||
|
projectId,
|
||||||
|
type: n.type,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
createdAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedActivityLogs(
|
||||||
|
db: SqliteDatabase,
|
||||||
|
projectId: number,
|
||||||
|
actorIds: number[]
|
||||||
|
): void {
|
||||||
|
const logs = [
|
||||||
|
{ actor: 0, action: 'board_posted', targetType: 'thread', targetId: 1 },
|
||||||
|
{ actor: 1, action: 'comment_added', targetType: 'comment', targetId: 1 },
|
||||||
|
{ actor: 0, action: 'todo_created', targetType: 'todo', targetId: 1 },
|
||||||
|
{ actor: 1, action: 'todo_completed', targetType: 'todo', targetId: 6 },
|
||||||
|
{ actor: 0, action: 'file_uploaded', targetType: 'file', targetId: 1 },
|
||||||
|
{ actor: 0, action: 'meeting_created', targetType: 'meeting', targetId: 1 },
|
||||||
|
{ actor: 1, action: 'note_updated', targetType: 'note', targetId: 1 },
|
||||||
|
{
|
||||||
|
actor: 0,
|
||||||
|
action: 'milestone_updated',
|
||||||
|
targetType: 'milestone',
|
||||||
|
targetId: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const l of logs) {
|
||||||
|
insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO activity_logs (project_id, actor_id, action, target_type, target_id, metadata_json, created_at)
|
||||||
|
VALUES (@projectId, @actorId, @action, @targetType, @targetId, NULL, @createdAt)`,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
actorId: actorIds[l.actor],
|
||||||
|
action: l.action,
|
||||||
|
targetType: l.targetType,
|
||||||
|
targetId: l.targetId,
|
||||||
|
createdAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const dbPath = process.env.SQLITE_PATH ?? './data/app.db';
|
||||||
|
const uploadsDir = process.env.UPLOADS_PATH ?? './data/uploads';
|
||||||
|
|
||||||
|
console.log(`Resetting storage (db: ${dbPath}, uploads: ${uploadsDir})...`);
|
||||||
|
resetStorage(dbPath, uploadsDir);
|
||||||
|
|
||||||
|
const db = new SqliteDatabase(dbPath);
|
||||||
|
new Migrator(
|
||||||
|
db,
|
||||||
|
path.join(process.cwd(), 'lib', 'db', 'migrations')
|
||||||
|
).migrate();
|
||||||
|
|
||||||
|
console.log('Seeding users...');
|
||||||
|
const userIds = seedUsers(db);
|
||||||
|
|
||||||
|
let projectCount = 0;
|
||||||
|
for (const p of PROJECTS) {
|
||||||
|
const ownerId = userIds.get(p.owner)!;
|
||||||
|
const projectId = insert(
|
||||||
|
db,
|
||||||
|
`INSERT INTO projects (name, description, status, owner_id, created_at, updated_at)
|
||||||
|
VALUES (@name, @description, @status, @ownerId, @createdAt, @updatedAt)`,
|
||||||
|
{
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
status: p.status,
|
||||||
|
ownerId,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
addMember(db, projectId, ownerId, 'admin');
|
||||||
|
const memberIds = [ownerId];
|
||||||
|
for (const m of p.members) {
|
||||||
|
const uid = userIds.get(m.email)!;
|
||||||
|
addMember(db, projectId, uid, m.role);
|
||||||
|
if (!memberIds.includes(uid)) memberIds.push(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestoneIds = seedMilestones(db, projectId);
|
||||||
|
seedBoard(db, projectId, memberIds);
|
||||||
|
seedChat(db, projectId, memberIds);
|
||||||
|
seedTodos(db, projectId, memberIds, milestoneIds);
|
||||||
|
seedNotes(db, projectId, memberIds);
|
||||||
|
seedFiles(db, projectId, ownerId, uploadsDir);
|
||||||
|
seedCalendar(db, projectId, ownerId);
|
||||||
|
seedMeetings(db, projectId, ownerId, memberIds);
|
||||||
|
seedNotifications(db, projectId, memberIds);
|
||||||
|
seedActivityLogs(db, projectId, memberIds);
|
||||||
|
projectCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
console.log('\n=== Seed complete ===');
|
||||||
|
console.log(`Users: ${USERS.length} | Projects: ${projectCount}`);
|
||||||
|
console.log('\nLogin credentials:');
|
||||||
|
console.log(' Admin: admin@example.com / admin123 (system_admin)');
|
||||||
|
console.log(' Users: alice / bob / carol / dave @example.com / password');
|
||||||
|
console.log(' Inactive: eve@example.com / password (ログイン不可)');
|
||||||
|
console.log('\nStart the app with: npm run dev -> http://localhost:3000');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user