switchman-dev 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +98 -0
- package/README.md +243 -0
- package/examples/README.md +117 -0
- package/examples/setup.sh +102 -0
- package/examples/taskapi/.switchman/switchman.db +0 -0
- package/examples/taskapi/package-lock.json +4736 -0
- package/examples/taskapi/package.json +18 -0
- package/examples/taskapi/src/db.js +179 -0
- package/examples/taskapi/src/middleware/auth.js +96 -0
- package/examples/taskapi/src/middleware/validate.js +133 -0
- package/examples/taskapi/src/routes/tasks.js +65 -0
- package/examples/taskapi/src/routes/users.js +38 -0
- package/examples/taskapi/src/server.js +7 -0
- package/examples/taskapi/tests/api.test.js +112 -0
- package/examples/teardown.sh +37 -0
- package/examples/walkthrough.sh +172 -0
- package/examples/worktrees/agent-rate-limiting/package-lock.json +4736 -0
- package/examples/worktrees/agent-rate-limiting/package.json +18 -0
- package/examples/worktrees/agent-rate-limiting/src/db.js +179 -0
- package/examples/worktrees/agent-rate-limiting/src/middleware/auth.js +96 -0
- package/examples/worktrees/agent-rate-limiting/src/middleware/validate.js +133 -0
- package/examples/worktrees/agent-rate-limiting/src/routes/tasks.js +65 -0
- package/examples/worktrees/agent-rate-limiting/src/routes/users.js +38 -0
- package/examples/worktrees/agent-rate-limiting/src/server.js +7 -0
- package/examples/worktrees/agent-rate-limiting/tests/api.test.js +112 -0
- package/examples/worktrees/agent-tests/package-lock.json +4736 -0
- package/examples/worktrees/agent-tests/package.json +18 -0
- package/examples/worktrees/agent-tests/src/db.js +179 -0
- package/examples/worktrees/agent-tests/src/middleware/auth.js +96 -0
- package/examples/worktrees/agent-tests/src/middleware/validate.js +133 -0
- package/examples/worktrees/agent-tests/src/routes/tasks.js +65 -0
- package/examples/worktrees/agent-tests/src/routes/users.js +38 -0
- package/examples/worktrees/agent-tests/src/server.js +7 -0
- package/examples/worktrees/agent-tests/tests/api.test.js +112 -0
- package/examples/worktrees/agent-validation/package-lock.json +4736 -0
- package/examples/worktrees/agent-validation/package.json +18 -0
- package/examples/worktrees/agent-validation/src/db.js +179 -0
- package/examples/worktrees/agent-validation/src/middleware/auth.js +96 -0
- package/examples/worktrees/agent-validation/src/middleware/validate.js +133 -0
- package/examples/worktrees/agent-validation/src/routes/tasks.js +65 -0
- package/examples/worktrees/agent-validation/src/routes/users.js +38 -0
- package/examples/worktrees/agent-validation/src/server.js +7 -0
- package/examples/worktrees/agent-validation/tests/api.test.js +112 -0
- package/package.json +29 -0
- package/src/cli/index.js +602 -0
- package/src/core/db.js +240 -0
- package/src/core/detector.js +172 -0
- package/src/core/git.js +265 -0
- package/src/mcp/server.js +555 -0
- package/tests/test.js +259 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "taskapi",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Example project for practising Switchman — a simple task management REST API",
|
|
5
|
+
"main": "src/server.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node src/server.js",
|
|
8
|
+
"dev": "node --watch src/server.js",
|
|
9
|
+
"test": "jest --runInBand"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"express": "^4.18.2"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"jest": "^29.7.0",
|
|
16
|
+
"supertest": "^6.3.4"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// db.js — SQLite-backed store for taskapi.
|
|
2
|
+
// Drop-in alternative to dbstore/store.js.
|
|
3
|
+
// Requires: npm install better-sqlite3
|
|
4
|
+
//
|
|
5
|
+
// Usage: swap require('./dbstore/store') → require('./db') in routes/app.js
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const Database = require('better-sqlite3');
|
|
9
|
+
|
|
10
|
+
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'taskapi.db');
|
|
11
|
+
|
|
12
|
+
let _db;
|
|
13
|
+
|
|
14
|
+
function getDb() {
|
|
15
|
+
if (!_db) {
|
|
16
|
+
_db = new Database(DB_PATH);
|
|
17
|
+
_db.pragma('journal_mode = WAL');
|
|
18
|
+
_db.pragma('busy_timeout = 5000');
|
|
19
|
+
_db.pragma('foreign_keys = ON');
|
|
20
|
+
migrate(_db);
|
|
21
|
+
seed(_db);
|
|
22
|
+
}
|
|
23
|
+
return _db;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function migrate(db) {
|
|
27
|
+
db.exec(`
|
|
28
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
29
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
name TEXT NOT NULL,
|
|
31
|
+
email TEXT NOT NULL UNIQUE,
|
|
32
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
33
|
+
createdAt TEXT NOT NULL DEFAULT (datetime('now'))
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
name TEXT NOT NULL,
|
|
39
|
+
ownerId INTEGER REFERENCES users(id),
|
|
40
|
+
createdAt TEXT NOT NULL DEFAULT (datetime('now'))
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
title TEXT NOT NULL,
|
|
46
|
+
status TEXT NOT NULL DEFAULT 'todo',
|
|
47
|
+
priority TEXT NOT NULL DEFAULT 'medium',
|
|
48
|
+
projectId INTEGER NOT NULL REFERENCES projects(id),
|
|
49
|
+
assigneeId INTEGER REFERENCES users(id),
|
|
50
|
+
createdAt TEXT NOT NULL DEFAULT (datetime('now'))
|
|
51
|
+
);
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function seed(db) {
|
|
56
|
+
const count = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
|
57
|
+
if (count > 0) return; // already seeded
|
|
58
|
+
|
|
59
|
+
db.prepare(`INSERT INTO users (name, email, role) VALUES (?, ?, ?)`).run('Alice', 'alice@example.com', 'admin');
|
|
60
|
+
db.prepare(`INSERT INTO users (name, email, role) VALUES (?, ?, ?)`).run('Bob', 'bob@example.com', 'member');
|
|
61
|
+
|
|
62
|
+
db.prepare(`INSERT INTO projects (name, ownerId) VALUES (?, ?)`).run('Backend Rewrite', 1);
|
|
63
|
+
db.prepare(`INSERT INTO projects (name, ownerId) VALUES (?, ?)`).run('Mobile App', 2);
|
|
64
|
+
|
|
65
|
+
db.prepare(`INSERT INTO tasks (title, status, priority, projectId, assigneeId) VALUES (?, ?, ?, ?, ?)`).run('Set up CI pipeline', 'todo', 'high', 1, null);
|
|
66
|
+
db.prepare(`INSERT INTO tasks (title, status, priority, projectId, assigneeId) VALUES (?, ?, ?, ?, ?)`).run('Write API docs', 'in_progress', 'medium', 1, 2);
|
|
67
|
+
db.prepare(`INSERT INTO tasks (title, status, priority, projectId, assigneeId) VALUES (?, ?, ?, ?, ?)`).run('Fix login bug', 'todo', 'high', 2, null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Public API (same shape as dbstore/store.js) ──────────────────────────────
|
|
71
|
+
|
|
72
|
+
const db = {
|
|
73
|
+
tasks: {
|
|
74
|
+
findAll() {
|
|
75
|
+
return getDb().prepare('SELECT * FROM tasks ORDER BY createdAt DESC').all();
|
|
76
|
+
},
|
|
77
|
+
findById(id) {
|
|
78
|
+
return getDb().prepare('SELECT * FROM tasks WHERE id = ?').get(id) || null;
|
|
79
|
+
},
|
|
80
|
+
findByProject(projectId) {
|
|
81
|
+
return getDb().prepare('SELECT * FROM tasks WHERE projectId = ?').all(projectId);
|
|
82
|
+
},
|
|
83
|
+
create(data) {
|
|
84
|
+
const { title, projectId, priority = 'medium', status = 'todo', assigneeId = null } = data;
|
|
85
|
+
const result = getDb()
|
|
86
|
+
.prepare('INSERT INTO tasks (title, projectId, priority, status, assigneeId) VALUES (?, ?, ?, ?, ?)')
|
|
87
|
+
.run(title, projectId, priority, status, assigneeId);
|
|
88
|
+
return this.findById(result.lastInsertRowid);
|
|
89
|
+
},
|
|
90
|
+
update(id, data) {
|
|
91
|
+
const allowed = ['title', 'status', 'priority', 'assigneeId'];
|
|
92
|
+
const fields = Object.keys(data).filter(k => allowed.includes(k));
|
|
93
|
+
if (fields.length === 0) return this.findById(id);
|
|
94
|
+
const set = fields.map(f => `${f} = ?`).join(', ');
|
|
95
|
+
const values = fields.map(f => data[f]);
|
|
96
|
+
getDb().prepare(`UPDATE tasks SET ${set} WHERE id = ?`).run(...values, id);
|
|
97
|
+
return this.findById(id);
|
|
98
|
+
},
|
|
99
|
+
delete(id) {
|
|
100
|
+
const result = getDb().prepare('DELETE FROM tasks WHERE id = ?').run(id);
|
|
101
|
+
return result.changes > 0;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
projects: {
|
|
106
|
+
findAll() {
|
|
107
|
+
return getDb().prepare('SELECT * FROM projects ORDER BY createdAt DESC').all();
|
|
108
|
+
},
|
|
109
|
+
findById(id) {
|
|
110
|
+
return getDb().prepare('SELECT * FROM projects WHERE id = ?').get(id) || null;
|
|
111
|
+
},
|
|
112
|
+
create(data) {
|
|
113
|
+
const { name, ownerId = null } = data;
|
|
114
|
+
const result = getDb()
|
|
115
|
+
.prepare('INSERT INTO projects (name, ownerId) VALUES (?, ?)')
|
|
116
|
+
.run(name, ownerId);
|
|
117
|
+
return this.findById(result.lastInsertRowid);
|
|
118
|
+
},
|
|
119
|
+
update(id, data) {
|
|
120
|
+
const allowed = ['name', 'ownerId'];
|
|
121
|
+
const fields = Object.keys(data).filter(k => allowed.includes(k));
|
|
122
|
+
if (fields.length === 0) return this.findById(id);
|
|
123
|
+
const set = fields.map(f => `${f} = ?`).join(', ');
|
|
124
|
+
const values = fields.map(f => data[f]);
|
|
125
|
+
getDb().prepare(`UPDATE projects SET ${set} WHERE id = ?`).run(...values, id);
|
|
126
|
+
return this.findById(id);
|
|
127
|
+
},
|
|
128
|
+
delete(id) {
|
|
129
|
+
const result = getDb().prepare('DELETE FROM projects WHERE id = ?').run(id);
|
|
130
|
+
return result.changes > 0;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
users: {
|
|
135
|
+
findAll() {
|
|
136
|
+
return getDb().prepare('SELECT * FROM users ORDER BY id').all();
|
|
137
|
+
},
|
|
138
|
+
findById(id) {
|
|
139
|
+
return getDb().prepare('SELECT * FROM users WHERE id = ?').get(id) || null;
|
|
140
|
+
},
|
|
141
|
+
findByEmail(email) {
|
|
142
|
+
return getDb().prepare('SELECT * FROM users WHERE email = ?').get(email) || null;
|
|
143
|
+
},
|
|
144
|
+
create(data) {
|
|
145
|
+
const { name, email, role = 'member' } = data;
|
|
146
|
+
const result = getDb()
|
|
147
|
+
.prepare('INSERT INTO users (name, email, role) VALUES (?, ?, ?)')
|
|
148
|
+
.run(name, email, role);
|
|
149
|
+
return this.findById(result.lastInsertRowid);
|
|
150
|
+
},
|
|
151
|
+
update(id, data) {
|
|
152
|
+
const allowed = ['name', 'email', 'role'];
|
|
153
|
+
const fields = Object.keys(data).filter(k => allowed.includes(k));
|
|
154
|
+
if (fields.length === 0) return this.findById(id);
|
|
155
|
+
const set = fields.map(f => `${f} = ?`).join(', ');
|
|
156
|
+
const values = fields.map(f => data[f]);
|
|
157
|
+
getDb().prepare(`UPDATE users SET ${set} WHERE id = ?`).run(...values, id);
|
|
158
|
+
return this.findById(id);
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
// For tests: wipe and re-seed
|
|
163
|
+
_reset() {
|
|
164
|
+
const d = getDb();
|
|
165
|
+
d.exec('DELETE FROM tasks; DELETE FROM projects; DELETE FROM users;');
|
|
166
|
+
// Reset autoincrement counters
|
|
167
|
+
d.exec(`
|
|
168
|
+
DELETE FROM sqlite_sequence WHERE name IN ('tasks','projects','users');
|
|
169
|
+
`);
|
|
170
|
+
seed(d);
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
// Close the connection (useful in tests)
|
|
174
|
+
close() {
|
|
175
|
+
if (_db) { _db.close(); _db = null; }
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
module.exports = db;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// middleware/auth.js — API key authentication middleware.
|
|
2
|
+
//
|
|
3
|
+
// Every protected route requires an `x-api-key` header.
|
|
4
|
+
// Keys are looked up against the users table; the resolved user
|
|
5
|
+
// is attached to req.user for downstream handlers.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// const { requireAuth, requireRole } = require('./middleware/auth');
|
|
9
|
+
// router.post('/admin-only', requireAuth, requireRole('admin'), handler);
|
|
10
|
+
|
|
11
|
+
const db = require('../dbstore/store');
|
|
12
|
+
|
|
13
|
+
// Static API key → user mapping (matches session summary keys).
|
|
14
|
+
// In production, store hashed keys in the DB and look them up there.
|
|
15
|
+
const API_KEYS = {
|
|
16
|
+
'dev-key-alice-123': { email: 'alice@example.com' },
|
|
17
|
+
'dev-key-bob-456': { email: 'bob@example.com' },
|
|
18
|
+
'test-key-789': { email: 'bob@example.com' }, // test user maps to Bob
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* requireAuth
|
|
23
|
+
* Reads the `x-api-key` header, resolves the key to a user record,
|
|
24
|
+
* and attaches it to req.user. Returns 401 if the key is missing or
|
|
25
|
+
* unknown, 403 if the user record can no longer be found in the store.
|
|
26
|
+
*/
|
|
27
|
+
function requireAuth(req, res, next) {
|
|
28
|
+
const key = req.headers['x-api-key'];
|
|
29
|
+
|
|
30
|
+
if (!key) {
|
|
31
|
+
return res.status(401).json({
|
|
32
|
+
error: 'Missing API key. Provide an x-api-key header.',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mapping = API_KEYS[key];
|
|
37
|
+
if (!mapping) {
|
|
38
|
+
return res.status(401).json({ error: 'Invalid API key.' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const user = db.users.findByEmail(mapping.email);
|
|
42
|
+
if (!user) {
|
|
43
|
+
return res.status(403).json({ error: 'API key refers to a deleted user.' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
req.user = user;
|
|
47
|
+
next();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* requireRole(role)
|
|
52
|
+
* Factory that returns a middleware enforcing a minimum role.
|
|
53
|
+
* Roles (weakest → strongest): member → admin
|
|
54
|
+
* Must be used AFTER requireAuth.
|
|
55
|
+
*
|
|
56
|
+
* @param {'member'|'admin'} role
|
|
57
|
+
*/
|
|
58
|
+
const ROLE_RANK = { member: 1, admin: 2 };
|
|
59
|
+
|
|
60
|
+
function requireRole(role) {
|
|
61
|
+
return function roleGuard(req, res, next) {
|
|
62
|
+
if (!req.user) {
|
|
63
|
+
// Programmer error: requireRole used without requireAuth
|
|
64
|
+
return res.status(500).json({ error: 'Auth middleware misconfigured.' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const userRank = ROLE_RANK[req.user.role] ?? 0;
|
|
68
|
+
const required = ROLE_RANK[role] ?? 99;
|
|
69
|
+
|
|
70
|
+
if (userRank < required) {
|
|
71
|
+
return res.status(403).json({
|
|
72
|
+
error: `Requires '${role}' role. Your role: '${req.user.role}'.`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
next();
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* optionalAuth
|
|
82
|
+
* Like requireAuth but doesn't block unauthenticated requests.
|
|
83
|
+
* Attaches req.user if a valid key is provided, otherwise leaves it null.
|
|
84
|
+
*/
|
|
85
|
+
function optionalAuth(req, res, next) {
|
|
86
|
+
const key = req.headers['x-api-key'];
|
|
87
|
+
if (!key) { req.user = null; return next(); }
|
|
88
|
+
|
|
89
|
+
const mapping = API_KEYS[key];
|
|
90
|
+
if (!mapping) { req.user = null; return next(); }
|
|
91
|
+
|
|
92
|
+
req.user = db.users.findByEmail(mapping.email) || null;
|
|
93
|
+
next();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { requireAuth, requireRole, optionalAuth };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// middleware/validate.js — lightweight request validation helpers.
|
|
2
|
+
//
|
|
3
|
+
// Provides schema-based body validation without a heavy dependency.
|
|
4
|
+
// Works alongside auth.js as the other primary shared middleware —
|
|
5
|
+
// making both files natural conflict hotspots for parallel agents.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// const { validate, schemas } = require('./middleware/validate');
|
|
9
|
+
// router.post('/', validate(schemas.createTask), handler);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* validate(schema)
|
|
13
|
+
* Returns an Express middleware that checks req.body against `schema`.
|
|
14
|
+
* A schema is a plain object whose values are validator functions:
|
|
15
|
+
* { field: (value, body) => errorString | null }
|
|
16
|
+
*
|
|
17
|
+
* All errors are collected and returned together as a 400 response.
|
|
18
|
+
*/
|
|
19
|
+
function validate(schema) {
|
|
20
|
+
return function validationMiddleware(req, res, next) {
|
|
21
|
+
const errors = [];
|
|
22
|
+
|
|
23
|
+
for (const [field, validator] of Object.entries(schema)) {
|
|
24
|
+
const value = req.body?.[field];
|
|
25
|
+
const error = validator(value, req.body);
|
|
26
|
+
if (error) errors.push({ field, message: error });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (errors.length > 0) {
|
|
30
|
+
return res.status(400).json({ errors });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
next();
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Reusable validator primitives ────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const required = (field) => (value) =>
|
|
40
|
+
value === undefined || value === null || value === ''
|
|
41
|
+
? `${field} is required`
|
|
42
|
+
: null;
|
|
43
|
+
|
|
44
|
+
const isString = (field) => (value) =>
|
|
45
|
+
value !== undefined && typeof value !== 'string'
|
|
46
|
+
? `${field} must be a string`
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
const isNumber = (field) => (value) =>
|
|
50
|
+
value !== undefined && (typeof value !== 'number' || !Number.isFinite(value))
|
|
51
|
+
? `${field} must be a number`
|
|
52
|
+
: null;
|
|
53
|
+
|
|
54
|
+
const isEmail = (field) => (value) => {
|
|
55
|
+
if (!value) return null; // let `required` handle missing values
|
|
56
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
57
|
+
? null
|
|
58
|
+
: `${field} must be a valid email address`;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const oneOf = (field, options) => (value) =>
|
|
62
|
+
value !== undefined && !options.includes(value)
|
|
63
|
+
? `${field} must be one of: ${options.join(', ')}`
|
|
64
|
+
: null;
|
|
65
|
+
|
|
66
|
+
const maxLength = (field, max) => (value) =>
|
|
67
|
+
value && typeof value === 'string' && value.length > max
|
|
68
|
+
? `${field} must be ${max} characters or fewer`
|
|
69
|
+
: null;
|
|
70
|
+
|
|
71
|
+
const minLength = (field, min) => (value) =>
|
|
72
|
+
value && typeof value === 'string' && value.length < min
|
|
73
|
+
? `${field} must be at least ${min} characters`
|
|
74
|
+
: null;
|
|
75
|
+
|
|
76
|
+
/** Compose multiple validators for one field (returns first error). */
|
|
77
|
+
const compose = (...validators) =>
|
|
78
|
+
(value, body) => {
|
|
79
|
+
for (const v of validators) {
|
|
80
|
+
const err = v(value, body);
|
|
81
|
+
if (err) return err;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// ── Pre-built schemas ────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const VALID_STATUSES = ['todo', 'in_progress', 'done', 'cancelled'];
|
|
89
|
+
const VALID_PRIORITIES = ['low', 'medium', 'high'];
|
|
90
|
+
const VALID_ROLES = ['member', 'admin'];
|
|
91
|
+
|
|
92
|
+
const schemas = {
|
|
93
|
+
createTask: {
|
|
94
|
+
title: compose(required('title'), isString('title'), maxLength('title', 200)),
|
|
95
|
+
projectId: compose(required('projectId'), isNumber('projectId')),
|
|
96
|
+
priority: oneOf('priority', VALID_PRIORITIES),
|
|
97
|
+
status: oneOf('status', VALID_STATUSES),
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
updateTask: {
|
|
101
|
+
title: compose(isString('title'), maxLength('title', 200)),
|
|
102
|
+
status: oneOf('status', VALID_STATUSES),
|
|
103
|
+
priority: oneOf('priority', VALID_PRIORITIES),
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
createProject: {
|
|
107
|
+
name: compose(required('name'), isString('name'), maxLength('name', 100)),
|
|
108
|
+
ownerId: isNumber('ownerId'),
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
updateProject: {
|
|
112
|
+
name: compose(isString('name'), maxLength('name', 100)),
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
createUser: {
|
|
116
|
+
name: compose(required('name'), isString('name'), maxLength('name', 100)),
|
|
117
|
+
email: compose(required('email'), isEmail('email'), maxLength('email', 255)),
|
|
118
|
+
role: oneOf('role', VALID_ROLES),
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
updateUser: {
|
|
122
|
+
name: compose(isString('name'), maxLength('name', 100)),
|
|
123
|
+
email: compose(isEmail('email'), maxLength('email', 255)),
|
|
124
|
+
role: oneOf('role', VALID_ROLES),
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
validate,
|
|
130
|
+
schemas,
|
|
131
|
+
// Export primitives so routes can build ad-hoc schemas
|
|
132
|
+
validators: { required, isString, isNumber, isEmail, oneOf, maxLength, minLength, compose },
|
|
133
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const db = require('../dbstore/store');
|
|
3
|
+
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
|
|
6
|
+
// GET /api/tasks — list all tasks (optionally filter by projectId or status)
|
|
7
|
+
router.get('/', (req, res) => {
|
|
8
|
+
let tasks = db.tasks.findAll();
|
|
9
|
+
if (req.query.projectId) {
|
|
10
|
+
tasks = tasks.filter(t => t.projectId === Number(req.query.projectId));
|
|
11
|
+
}
|
|
12
|
+
if (req.query.status) {
|
|
13
|
+
tasks = tasks.filter(t => t.status === req.query.status);
|
|
14
|
+
}
|
|
15
|
+
res.json(tasks);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// GET /api/tasks/:id
|
|
19
|
+
router.get('/:id', (req, res) => {
|
|
20
|
+
const task = db.tasks.findById(Number(req.params.id));
|
|
21
|
+
if (!task) return res.status(404).json({ error: 'Task not found' });
|
|
22
|
+
res.json(task);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// POST /api/tasks
|
|
26
|
+
router.post('/', (req, res) => {
|
|
27
|
+
const { title, projectId, priority = 'medium', assigneeId } = req.body;
|
|
28
|
+
if (!title) return res.status(400).json({ error: 'title is required' });
|
|
29
|
+
if (!projectId) return res.status(400).json({ error: 'projectId is required' });
|
|
30
|
+
|
|
31
|
+
const project = db.projects.findById(Number(projectId));
|
|
32
|
+
if (!project) return res.status(400).json({ error: 'Project not found' });
|
|
33
|
+
|
|
34
|
+
const task = db.tasks.create({
|
|
35
|
+
title,
|
|
36
|
+
projectId: Number(projectId),
|
|
37
|
+
priority,
|
|
38
|
+
status: 'todo',
|
|
39
|
+
assigneeId: assigneeId ? Number(assigneeId) : null,
|
|
40
|
+
});
|
|
41
|
+
res.status(201).json(task);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// PATCH /api/tasks/:id
|
|
45
|
+
router.patch('/:id', (req, res) => {
|
|
46
|
+
const task = db.tasks.findById(Number(req.params.id));
|
|
47
|
+
if (!task) return res.status(404).json({ error: 'Task not found' });
|
|
48
|
+
|
|
49
|
+
const VALID_STATUSES = ['todo', 'in_progress', 'done', 'cancelled'];
|
|
50
|
+
if (req.body.status && !VALID_STATUSES.includes(req.body.status)) {
|
|
51
|
+
return res.status(400).json({ error: `status must be one of: ${VALID_STATUSES.join(', ')}` });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const updated = db.tasks.update(Number(req.params.id), req.body);
|
|
55
|
+
res.json(updated);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// DELETE /api/tasks/:id
|
|
59
|
+
router.delete('/:id', (req, res) => {
|
|
60
|
+
const deleted = db.tasks.delete(Number(req.params.id));
|
|
61
|
+
if (!deleted) return res.status(404).json({ error: 'Task not found' });
|
|
62
|
+
res.status(204).send();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
module.exports = router;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const db = require('../dbstore/store');
|
|
3
|
+
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
|
|
6
|
+
// GET /api/users
|
|
7
|
+
router.get('/', (req, res) => {
|
|
8
|
+
res.json(db.users.findAll());
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// GET /api/users/:id
|
|
12
|
+
router.get('/:id', (req, res) => {
|
|
13
|
+
const user = db.users.findById(Number(req.params.id));
|
|
14
|
+
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
15
|
+
res.json(user);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// POST /api/users
|
|
19
|
+
router.post('/', (req, res) => {
|
|
20
|
+
const { name, email, role = 'member' } = req.body;
|
|
21
|
+
if (!name) return res.status(400).json({ error: 'name is required' });
|
|
22
|
+
if (!email) return res.status(400).json({ error: 'email is required' });
|
|
23
|
+
|
|
24
|
+
const existing = db.users.findByEmail(email);
|
|
25
|
+
if (existing) return res.status(409).json({ error: 'Email already in use' });
|
|
26
|
+
|
|
27
|
+
const user = db.users.create({ name, email, role });
|
|
28
|
+
res.status(201).json(user);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// PATCH /api/users/:id
|
|
32
|
+
router.patch('/:id', (req, res) => {
|
|
33
|
+
const user = db.users.findById(Number(req.params.id));
|
|
34
|
+
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
35
|
+
res.json(db.users.update(Number(req.params.id), req.body));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
module.exports = router;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const app = require('../src/app');
|
|
3
|
+
const db = require('../src/dbstore/store');
|
|
4
|
+
|
|
5
|
+
beforeEach(() => db._reset());
|
|
6
|
+
|
|
7
|
+
describe('Tasks', () => {
|
|
8
|
+
test('GET /api/tasks returns all tasks', async () => {
|
|
9
|
+
const res = await request(app).get('/api/tasks');
|
|
10
|
+
expect(res.status).toBe(200);
|
|
11
|
+
expect(Array.isArray(res.body)).toBe(true);
|
|
12
|
+
expect(res.body.length).toBeGreaterThan(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('GET /api/tasks?status=todo filters correctly', async () => {
|
|
16
|
+
const res = await request(app).get('/api/tasks?status=todo');
|
|
17
|
+
expect(res.status).toBe(200);
|
|
18
|
+
expect(res.body.every(t => t.status === 'todo')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('GET /api/tasks/:id returns single task', async () => {
|
|
22
|
+
const res = await request(app).get('/api/tasks/1');
|
|
23
|
+
expect(res.status).toBe(200);
|
|
24
|
+
expect(res.body.id).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('GET /api/tasks/:id returns 404 for unknown id', async () => {
|
|
28
|
+
const res = await request(app).get('/api/tasks/999');
|
|
29
|
+
expect(res.status).toBe(404);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('POST /api/tasks creates a task', async () => {
|
|
33
|
+
const res = await request(app)
|
|
34
|
+
.post('/api/tasks')
|
|
35
|
+
.send({ title: 'New task', projectId: 1 });
|
|
36
|
+
expect(res.status).toBe(201);
|
|
37
|
+
expect(res.body.title).toBe('New task');
|
|
38
|
+
expect(res.body.status).toBe('todo');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('POST /api/tasks returns 400 without title', async () => {
|
|
42
|
+
const res = await request(app).post('/api/tasks').send({ projectId: 1 });
|
|
43
|
+
expect(res.status).toBe(400);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('PATCH /api/tasks/:id updates status', async () => {
|
|
47
|
+
const res = await request(app)
|
|
48
|
+
.patch('/api/tasks/1')
|
|
49
|
+
.send({ status: 'in_progress' });
|
|
50
|
+
expect(res.status).toBe(200);
|
|
51
|
+
expect(res.body.status).toBe('in_progress');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('PATCH /api/tasks/:id rejects invalid status', async () => {
|
|
55
|
+
const res = await request(app)
|
|
56
|
+
.patch('/api/tasks/1')
|
|
57
|
+
.send({ status: 'flying' });
|
|
58
|
+
expect(res.status).toBe(400);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('DELETE /api/tasks/:id deletes task', async () => {
|
|
62
|
+
const res = await request(app).delete('/api/tasks/1');
|
|
63
|
+
expect(res.status).toBe(204);
|
|
64
|
+
const get = await request(app).get('/api/tasks/1');
|
|
65
|
+
expect(get.status).toBe(404);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('Projects', () => {
|
|
70
|
+
test('GET /api/projects returns all projects', async () => {
|
|
71
|
+
const res = await request(app).get('/api/projects');
|
|
72
|
+
expect(res.status).toBe(200);
|
|
73
|
+
expect(Array.isArray(res.body)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('GET /api/projects/:id/tasks returns tasks for project', async () => {
|
|
77
|
+
const res = await request(app).get('/api/projects/1/tasks');
|
|
78
|
+
expect(res.status).toBe(200);
|
|
79
|
+
expect(res.body.every(t => t.projectId === 1)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('POST /api/projects creates project', async () => {
|
|
83
|
+
const res = await request(app)
|
|
84
|
+
.post('/api/projects')
|
|
85
|
+
.send({ name: 'New Project', ownerId: 1 });
|
|
86
|
+
expect(res.status).toBe(201);
|
|
87
|
+
expect(res.body.name).toBe('New Project');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('Users', () => {
|
|
92
|
+
test('GET /api/users returns all users', async () => {
|
|
93
|
+
const res = await request(app).get('/api/users');
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
expect(res.body.length).toBeGreaterThan(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('POST /api/users creates user', async () => {
|
|
99
|
+
const res = await request(app)
|
|
100
|
+
.post('/api/users')
|
|
101
|
+
.send({ name: 'Charlie', email: 'charlie@example.com' });
|
|
102
|
+
expect(res.status).toBe(201);
|
|
103
|
+
expect(res.body.name).toBe('Charlie');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('POST /api/users rejects duplicate email', async () => {
|
|
107
|
+
const res = await request(app)
|
|
108
|
+
.post('/api/users')
|
|
109
|
+
.send({ name: 'Dup', email: 'alice@example.com' });
|
|
110
|
+
expect(res.status).toBe(409);
|
|
111
|
+
});
|
|
112
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "switchman-dev",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Route your AI agents so they don't collide",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"switchman": "./src/cli/index.js",
|
|
9
|
+
"switchman-mcp": "./src/mcp/server.js"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/switchman-dev/switchman"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node src/cli/index.js",
|
|
17
|
+
"mcp": "node src/mcp/server.js",
|
|
18
|
+
"test": "node tests/test.js"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
22
|
+
"chalk": "^5.3.0",
|
|
23
|
+
"commander": "^12.0.0",
|
|
24
|
+
"node-sqlite3-wasm": "^0.8.35",
|
|
25
|
+
"ora": "^8.0.1",
|
|
26
|
+
"zod": "^4.3.6"
|
|
27
|
+
},
|
|
28
|
+
"license": "Apache-2.0"
|
|
29
|
+
}
|