chapterhouse 0.1.1 → 0.2.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/README.md +79 -12
- package/dist/api/errors.js +5 -3
- package/dist/api/errors.test.js +12 -21
- package/dist/api/server.js +67 -17
- package/dist/cli.js +111 -18
- package/dist/copilot/agents.js +9 -7
- package/dist/copilot/classifier.js +3 -1
- package/dist/copilot/orchestrator.js +64 -31
- package/dist/copilot/orchestrator.test.js +107 -1
- package/dist/copilot/router.js +4 -2
- package/dist/copilot/tools.js +7 -5
- package/dist/daemon-install.js +368 -0
- package/dist/daemon-install.test.js +98 -0
- package/dist/daemon.js +35 -33
- package/dist/squad/discovery.test.js +61 -0
- package/dist/store/db.js +42 -0
- package/dist/store/db.test.js +88 -0
- package/dist/update.js +162 -28
- package/dist/update.test.js +84 -5
- package/dist/util/logger.js +41 -0
- package/dist/util/logger.test.js +53 -0
- package/dist/wiki/migrate.js +4 -2
- package/dist/wiki/seed-team-wiki.js +4 -2
- package/package.json +3 -2
- package/web/dist/assets/{index-DAg9IrpO.js → index-Bgs6Mze7.js} +59 -59
- package/web/dist/assets/index-Bgs6Mze7.js.map +1 -0
- package/web/dist/assets/index-CxeGtVlE.css +10 -0
- package/web/dist/chapterhouse-icon.svg +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D-e7K-fT.css +0 -10
- package/web/dist/assets/index-DAg9IrpO.js.map +0 -1
|
@@ -90,4 +90,65 @@ test("invalidateProjectSquad marks agents stale — next loadProjectSquad re-res
|
|
|
90
90
|
// Both should have agents (content integrity survives invalidation + re-resolve)
|
|
91
91
|
assert.ok(after && typeof after === "object" && "agents" in after, "re-resolved context should include agents");
|
|
92
92
|
});
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// S5-02: agent count from filesystem is always authoritative
|
|
95
|
+
// These tests guard the fix: GET /api/projects must count agents from disk,
|
|
96
|
+
// not from the SQLite squad_agents cache (which is never populated on a fresh
|
|
97
|
+
// registration, causing the badge to show 0).
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
test("S5-02: resolveProjectSquad counts 0 agents for an empty agents dir", async () => {
|
|
100
|
+
const m = await loadDiscovery();
|
|
101
|
+
const dir = join(repoRoot, ".test-work", `s502-empty-${process.pid}`);
|
|
102
|
+
mkdirSync(join(dir, ".squad", "agents"), { recursive: true });
|
|
103
|
+
try {
|
|
104
|
+
const result = await m.resolveProjectSquad(dir);
|
|
105
|
+
assert.ok(result !== null, "should return context even with no agents");
|
|
106
|
+
assert.equal(result.agents.length, 0, "empty agents dir → 0 agents");
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
rmSync(dir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
test("S5-02: resolveProjectSquad counts N agents matching charter-bearing subdirs", async () => {
|
|
113
|
+
const m = await loadDiscovery();
|
|
114
|
+
const dir = join(repoRoot, ".test-work", `s502-n-agents-${process.pid}`);
|
|
115
|
+
// Create 3 agents (with charter.md) and 1 directory without charter.md
|
|
116
|
+
for (const slug of ["alpha", "beta", "gamma"]) {
|
|
117
|
+
mkdirSync(join(dir, ".squad", "agents", slug), { recursive: true });
|
|
118
|
+
const { writeFileSync } = await import("node:fs");
|
|
119
|
+
writeFileSync(join(dir, ".squad", "agents", slug, "charter.md"), `# ${slug}\n\n**Role:** Specialist\n`);
|
|
120
|
+
}
|
|
121
|
+
mkdirSync(join(dir, ".squad", "agents", "no-charter"), { recursive: true }); // no charter.md
|
|
122
|
+
try {
|
|
123
|
+
const result = await m.resolveProjectSquad(dir);
|
|
124
|
+
assert.ok(result !== null, "should return context");
|
|
125
|
+
assert.equal(result.agents.length, 3, "only dirs with charter.md count as agents");
|
|
126
|
+
const slugs = result.agents.map((a) => a.slug).sort();
|
|
127
|
+
assert.deepEqual(slugs, ["alpha", "beta", "gamma"]);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
rmSync(dir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
test("S5-02: resolveProjectSquad re-reads filesystem after agent added (no stale count)", async () => {
|
|
134
|
+
const m = await loadDiscovery();
|
|
135
|
+
const dir = join(repoRoot, ".test-work", `s502-refresh-${process.pid}`);
|
|
136
|
+
const { writeFileSync } = await import("node:fs");
|
|
137
|
+
// Start with 1 agent
|
|
138
|
+
mkdirSync(join(dir, ".squad", "agents", "first"), { recursive: true });
|
|
139
|
+
writeFileSync(join(dir, ".squad", "agents", "first", "charter.md"), "# first\n\n**Role:** Scout\n");
|
|
140
|
+
try {
|
|
141
|
+
const before = await m.resolveProjectSquad(dir);
|
|
142
|
+
assert.equal(before.agents.length, 1, "before: 1 agent on disk");
|
|
143
|
+
// Add a second agent to disk
|
|
144
|
+
mkdirSync(join(dir, ".squad", "agents", "second"), { recursive: true });
|
|
145
|
+
writeFileSync(join(dir, ".squad", "agents", "second", "charter.md"), "# second\n\n**Role:** Recon\n");
|
|
146
|
+
// resolveProjectSquad always reads from disk (no TTL — direct filesystem read)
|
|
147
|
+
const after = await m.resolveProjectSquad(dir);
|
|
148
|
+
assert.equal(after.agents.length, 2, "after adding agent to disk: 2 agents");
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
rmSync(dir, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
93
154
|
//# sourceMappingURL=discovery.test.js.map
|
package/dist/store/db.js
CHANGED
|
@@ -146,6 +146,17 @@ export function getDb() {
|
|
|
146
146
|
if (!taskCols.some((c) => c.name === 'session_key')) {
|
|
147
147
|
db.exec(`ALTER TABLE agent_tasks ADD COLUMN session_key TEXT NOT NULL DEFAULT 'default'`);
|
|
148
148
|
}
|
|
149
|
+
// Migrate: add source column to agent_tasks ('adhoc' | 'squad') if not present
|
|
150
|
+
if (!taskCols.some((c) => c.name === 'source')) {
|
|
151
|
+
db.exec(`ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'adhoc'`);
|
|
152
|
+
}
|
|
153
|
+
// Migrate: add last_used_at column to project_squads (epoch ms, nullable)
|
|
154
|
+
const projectCols = db.prepare(`PRAGMA table_info(project_squads)`).all();
|
|
155
|
+
if (!projectCols.some((c) => c.name === 'last_used_at')) {
|
|
156
|
+
db.exec(`ALTER TABLE project_squads ADD COLUMN last_used_at INTEGER`);
|
|
157
|
+
// Backfill from loaded_at so sidebar is not empty for existing projects
|
|
158
|
+
db.exec(`UPDATE project_squads SET last_used_at = CAST((julianday(loaded_at) - 2440587.5) * 86400000 AS INTEGER) WHERE last_used_at IS NULL`);
|
|
159
|
+
}
|
|
149
160
|
// Prune conversation log at startup — keep more history for better recovery
|
|
150
161
|
db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
|
|
151
162
|
// Set up FTS5 for memory search (graceful fallback if not available)
|
|
@@ -276,11 +287,42 @@ export function getRecentConversation(limit, sessionKey) {
|
|
|
276
287
|
return `${tag}: ${content}`;
|
|
277
288
|
}).join("\n\n");
|
|
278
289
|
}
|
|
290
|
+
const MAX_SESSION_MESSAGES_LIMIT = 500;
|
|
291
|
+
const DEFAULT_SESSION_MESSAGES_LIMIT = 100;
|
|
292
|
+
/**
|
|
293
|
+
* Return conversation_log rows for a specific session as structured JSON,
|
|
294
|
+
* suitable for seeding the frontend Zustand store on mount.
|
|
295
|
+
*
|
|
296
|
+
* Unlike `getRecentConversation()`, this returns structured objects (not a
|
|
297
|
+
* formatted string) and omits system messages (role = 'system') because the
|
|
298
|
+
* UI only renders user/assistant turns.
|
|
299
|
+
*/
|
|
300
|
+
export function getSessionMessages(sessionKey, limit) {
|
|
301
|
+
const db = getDb();
|
|
302
|
+
const effectiveLimit = Math.min(limit ?? DEFAULT_SESSION_MESSAGES_LIMIT, MAX_SESSION_MESSAGES_LIMIT);
|
|
303
|
+
const rows = db
|
|
304
|
+
.prepare(`SELECT role, content, ts FROM conversation_log
|
|
305
|
+
WHERE session_key = ? AND role IN ('user', 'assistant')
|
|
306
|
+
ORDER BY id DESC LIMIT ?`)
|
|
307
|
+
.all(sessionKey, effectiveLimit);
|
|
308
|
+
// Reverse so oldest is first (chronological order for the UI)
|
|
309
|
+
rows.reverse();
|
|
310
|
+
return rows.map((r) => ({
|
|
311
|
+
role: r.role,
|
|
312
|
+
content: r.content,
|
|
313
|
+
ts: r.ts,
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
279
316
|
// ---------------------------------------------------------------------------
|
|
280
317
|
// SQLite memory functions removed — wiki is the single source of truth.
|
|
281
318
|
// The memories table and FTS5 index are preserved in the schema for safety
|
|
282
319
|
// (existing data is not deleted), but no code reads or writes to them.
|
|
283
320
|
// ---------------------------------------------------------------------------
|
|
321
|
+
export function bumpProjectLastUsed(projectRoot) {
|
|
322
|
+
getDb()
|
|
323
|
+
.prepare(`UPDATE project_squads SET last_used_at = ? WHERE project_root = ?`)
|
|
324
|
+
.run(Date.now(), projectRoot);
|
|
325
|
+
}
|
|
284
326
|
export function closeDb() {
|
|
285
327
|
if (db) {
|
|
286
328
|
db.close();
|
package/dist/store/db.test.js
CHANGED
|
@@ -123,4 +123,92 @@ test("getDb prunes oversized conversation logs on startup and during inserts", a
|
|
|
123
123
|
dbModule.closeDb();
|
|
124
124
|
}
|
|
125
125
|
});
|
|
126
|
+
test("getSessionMessages returns empty array for unknown session", async () => {
|
|
127
|
+
const dbModule = await loadDbModule();
|
|
128
|
+
try {
|
|
129
|
+
dbModule.getDb();
|
|
130
|
+
const result = dbModule.getSessionMessages("nonexistent-session");
|
|
131
|
+
assert.deepEqual(result, []);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
dbModule.closeDb();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
test("getSessionMessages returns structured messages in chronological order, excludes system rows, respects limit", async () => {
|
|
138
|
+
const dbModule = await loadDbModule();
|
|
139
|
+
try {
|
|
140
|
+
dbModule.getDb();
|
|
141
|
+
dbModule.logConversation("user", "hello", "web", "test-session");
|
|
142
|
+
dbModule.logConversation("assistant", "hi there", "web", "test-session");
|
|
143
|
+
dbModule.logConversation("system", "system noise", "worker", "test-session");
|
|
144
|
+
dbModule.logConversation("user", "second message", "web", "test-session");
|
|
145
|
+
dbModule.logConversation("user", "from other session", "web", "other-session");
|
|
146
|
+
const all = dbModule.getSessionMessages("test-session");
|
|
147
|
+
assert.equal(all.length, 3, "3 user/assistant rows, system excluded");
|
|
148
|
+
assert.equal(all[0].role, "user");
|
|
149
|
+
assert.equal(all[0].content, "hello");
|
|
150
|
+
assert.equal(all[1].role, "assistant");
|
|
151
|
+
assert.equal(all[1].content, "hi there");
|
|
152
|
+
assert.equal(all[2].role, "user");
|
|
153
|
+
assert.equal(all[2].content, "second message");
|
|
154
|
+
// Limit clamping
|
|
155
|
+
const limited = dbModule.getSessionMessages("test-session", 2);
|
|
156
|
+
assert.equal(limited.length, 2, "limit=2 returns 2 most recent rows");
|
|
157
|
+
// After reversal, these should be the 2 most-recent (assistant + second user)
|
|
158
|
+
assert.equal(limited[0].content, "hi there");
|
|
159
|
+
assert.equal(limited[1].content, "second message");
|
|
160
|
+
// Other session not leaked
|
|
161
|
+
const other = dbModule.getSessionMessages("other-session");
|
|
162
|
+
assert.equal(other.length, 1);
|
|
163
|
+
assert.equal(other[0].content, "from other session");
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
dbModule.closeDb();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// #26 — bumpProjectLastUsed
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
test("migration adds last_used_at column to project_squads when absent", async () => {
|
|
173
|
+
const dbModule = await loadDbModule();
|
|
174
|
+
try {
|
|
175
|
+
const db = dbModule.getDb();
|
|
176
|
+
const cols = db.prepare(`PRAGMA table_info(project_squads)`).all();
|
|
177
|
+
assert.equal(cols.some((c) => c.name === "last_used_at"), true, "last_used_at column should exist after migration");
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
dbModule.closeDb();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
test("bumpProjectLastUsed updates last_used_at for the given project_root", async () => {
|
|
184
|
+
const dbModule = await loadDbModule();
|
|
185
|
+
try {
|
|
186
|
+
const db = dbModule.getDb();
|
|
187
|
+
db.prepare(`INSERT INTO project_squads (project_root, squad_dir, team_dir, mode, registered) VALUES (?, ?, ?, 'local', 1)`).run("/home/user/test-proj", "/home/user/test-proj/.squad", "/home/user/test-proj/.squad");
|
|
188
|
+
const before = db
|
|
189
|
+
.prepare(`SELECT last_used_at FROM project_squads WHERE project_root = ?`)
|
|
190
|
+
.get("/home/user/test-proj");
|
|
191
|
+
const beforeTs = before.last_used_at ?? 0;
|
|
192
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
193
|
+
dbModule.bumpProjectLastUsed("/home/user/test-proj");
|
|
194
|
+
const after = db
|
|
195
|
+
.prepare(`SELECT last_used_at FROM project_squads WHERE project_root = ?`)
|
|
196
|
+
.get("/home/user/test-proj");
|
|
197
|
+
assert.ok(after.last_used_at !== null, "last_used_at should not be null after bump");
|
|
198
|
+
assert.ok(after.last_used_at > beforeTs, "last_used_at should advance after bump");
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
dbModule.closeDb();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
test("bumpProjectLastUsed is a no-op for unknown project_root (no throw)", async () => {
|
|
205
|
+
const dbModule = await loadDbModule();
|
|
206
|
+
try {
|
|
207
|
+
dbModule.getDb();
|
|
208
|
+
dbModule.bumpProjectLastUsed("/does/not/exist");
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
dbModule.closeDb();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
126
214
|
//# sourceMappingURL=db.test.js.map
|
package/dist/update.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "fs";
|
|
2
|
-
import { join, dirname } from "path";
|
|
2
|
+
import { join, dirname, basename } from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
4
|
import { execSync } from "child_process";
|
|
5
5
|
import { homedir } from "os";
|
|
@@ -7,6 +7,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
7
7
|
const GITHUB_INSTALL_SOURCE = "bketelsen/chapterhouse";
|
|
8
8
|
const GITHUB_REPO_URL = "https://github.com/bketelsen/chapterhouse.git";
|
|
9
9
|
const GITHUB_TAGS_API_URL = "https://api.github.com/repos/bketelsen/chapterhouse/tags?per_page=100";
|
|
10
|
+
const NPM_REGISTRY_URL = "https://registry.npmjs.org/chapterhouse";
|
|
11
|
+
const NPM_PACKAGE_NAME = "chapterhouse";
|
|
10
12
|
const SOURCE_DIR = join(homedir(), ".chapterhouse", "src");
|
|
11
13
|
function getLocalVersion() {
|
|
12
14
|
try {
|
|
@@ -20,15 +22,113 @@ function getLocalVersion() {
|
|
|
20
22
|
function normalizeVersion(v) {
|
|
21
23
|
return v.startsWith("v") ? v.slice(1) : v;
|
|
22
24
|
}
|
|
25
|
+
function parseVersionTuple(v) {
|
|
26
|
+
const parts = normalizeVersion(v).split(".").map(Number);
|
|
27
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
28
|
+
}
|
|
29
|
+
function compareVersions(a, b) {
|
|
30
|
+
const [aMaj, aMin, aPat] = parseVersionTuple(a);
|
|
31
|
+
const [bMaj, bMin, bPat] = parseVersionTuple(b);
|
|
32
|
+
if (aMaj !== bMaj)
|
|
33
|
+
return aMaj - bMaj;
|
|
34
|
+
if (aMin !== bMin)
|
|
35
|
+
return aMin - bMin;
|
|
36
|
+
return aPat - bPat;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Determine how chapterhouse was installed.
|
|
40
|
+
*
|
|
41
|
+
* Detection order:
|
|
42
|
+
* 1. git/legacy — pkgDir is under SOURCE_DIR (~/.chapterhouse/src)
|
|
43
|
+
* 2. dev — pkgDir contains a .git directory (working tree, not SOURCE_DIR)
|
|
44
|
+
* 3. registry — parent of pkgDir is named "node_modules"
|
|
45
|
+
* 4. registry — npm ls -g confirms it
|
|
46
|
+
* 5. unknown — default
|
|
47
|
+
*/
|
|
48
|
+
export function detectInstallSource() {
|
|
49
|
+
const pkgDir = join(__dirname, "..");
|
|
50
|
+
if (pkgDir.startsWith(SOURCE_DIR)) {
|
|
51
|
+
return "git";
|
|
52
|
+
}
|
|
53
|
+
if (existsSync(join(pkgDir, ".git"))) {
|
|
54
|
+
return "dev";
|
|
55
|
+
}
|
|
56
|
+
if (basename(join(pkgDir, "..")) === "node_modules") {
|
|
57
|
+
return "registry";
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const out = execSync("npm ls -g chapterhouse --json 2>/dev/null", {
|
|
61
|
+
encoding: "utf-8",
|
|
62
|
+
timeout: 10_000,
|
|
63
|
+
});
|
|
64
|
+
const data = JSON.parse(out);
|
|
65
|
+
if (data?.dependencies?.chapterhouse) {
|
|
66
|
+
return "registry";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// ignore — npm ls failure is not fatal
|
|
71
|
+
}
|
|
72
|
+
return "unknown";
|
|
73
|
+
}
|
|
74
|
+
/** Check that Node ≥ 24 and npm ≥ 11.5.1 are available. */
|
|
75
|
+
export function checkPreconditions() {
|
|
76
|
+
const nodeVer = process.version.replace("v", "");
|
|
77
|
+
let npmVer = "0.0.0";
|
|
78
|
+
try {
|
|
79
|
+
npmVer = execSync("npm --version", { encoding: "utf-8", timeout: 5_000 }).trim();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// leave npmVer as 0.0.0 so the check fails gracefully
|
|
83
|
+
}
|
|
84
|
+
const [nodeMaj] = parseVersionTuple(nodeVer);
|
|
85
|
+
const npmOk = compareVersions(npmVer, "11.5.1") >= 0;
|
|
86
|
+
if (nodeMaj < 24 || !npmOk) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
nodeVersion: nodeVer,
|
|
90
|
+
npmVersion: npmVer,
|
|
91
|
+
message: `Node 24+ / npm 11.5.1+ recommended for chapterhouse updates (current Node: ${nodeVer}, npm: ${npmVer}). Update Node and try again.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, nodeVersion: nodeVer, npmVersion: npmVer };
|
|
95
|
+
}
|
|
96
|
+
// ─── Install command builders ──────────────────────────────────────────────
|
|
97
|
+
/** Returns the legacy GitHub install source (kept for deprecation messages). */
|
|
23
98
|
export function getInstallSource() {
|
|
24
99
|
return GITHUB_INSTALL_SOURCE;
|
|
25
100
|
}
|
|
101
|
+
/** Build the npm registry install command (default path post-S5-03). */
|
|
26
102
|
export function buildInstallCommand(ref) {
|
|
103
|
+
const suffix = ref?.trim() ? `@${ref.trim()}` : "@latest";
|
|
104
|
+
return `npm install -g ${NPM_PACKAGE_NAME}${suffix}`;
|
|
105
|
+
}
|
|
106
|
+
/** Build the legacy git-based install command. Used only in deprecation messages. */
|
|
107
|
+
export function buildLegacyGitInstallCommand(ref) {
|
|
27
108
|
const suffix = ref?.trim() ? `#${ref.trim()}` : "";
|
|
28
|
-
return `npm install -g ${
|
|
109
|
+
return `npm install -g ${GITHUB_INSTALL_SOURCE}${suffix}`;
|
|
29
110
|
}
|
|
30
|
-
/** Fetch the latest
|
|
31
|
-
|
|
111
|
+
/** Fetch the latest version from the npm registry (primary source). */
|
|
112
|
+
async function getLatestVersionFromNpm() {
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(NPM_REGISTRY_URL, {
|
|
115
|
+
headers: {
|
|
116
|
+
Accept: "application/json",
|
|
117
|
+
"User-Agent": "chapterhouse-updater",
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
if (!response.ok)
|
|
121
|
+
return { version: null, reachable: false };
|
|
122
|
+
const data = (await response.json());
|
|
123
|
+
const latest = (data?.["dist-tags"] ?? {})?.latest ?? null;
|
|
124
|
+
return { version: latest ? normalizeVersion(latest) : null, reachable: true };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return { version: null, reachable: false };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Fallback: fetch the latest tagged release from GitHub. */
|
|
131
|
+
async function getLatestVersionFromGitHub() {
|
|
32
132
|
try {
|
|
33
133
|
const response = await fetch(GITHUB_TAGS_API_URL, {
|
|
34
134
|
headers: {
|
|
@@ -38,40 +138,32 @@ export async function getLatestVersion() {
|
|
|
38
138
|
});
|
|
39
139
|
if (!response.ok)
|
|
40
140
|
return { version: null, reachable: false };
|
|
41
|
-
const tags = await response.json();
|
|
141
|
+
const tags = (await response.json());
|
|
42
142
|
const versions = tags
|
|
43
143
|
.map((tag) => tag.name?.trim() || "")
|
|
44
144
|
.filter((name) => /^v?\d+\.\d+\.\d+$/.test(name))
|
|
45
145
|
.map(normalizeVersion);
|
|
46
146
|
if (versions.length === 0)
|
|
47
147
|
return { version: null, reachable: true };
|
|
48
|
-
const latest = versions.sort((a, b) =>
|
|
49
|
-
const [aMaj, aMin, aPat] = a.split(".").map(Number);
|
|
50
|
-
const [bMaj, bMin, bPat] = b.split(".").map(Number);
|
|
51
|
-
if (aMaj !== bMaj)
|
|
52
|
-
return bMaj - aMaj;
|
|
53
|
-
if (aMin !== bMin)
|
|
54
|
-
return bMin - aMin;
|
|
55
|
-
return bPat - aPat;
|
|
56
|
-
})[0] || null;
|
|
148
|
+
const latest = versions.sort((a, b) => compareVersions(b, a))[0] ?? null;
|
|
57
149
|
return { version: latest, reachable: true };
|
|
58
150
|
}
|
|
59
151
|
catch {
|
|
60
152
|
return { version: null, reachable: false };
|
|
61
153
|
}
|
|
62
154
|
}
|
|
155
|
+
/** Get the latest version: npm registry first, GitHub tags as fallback. */
|
|
156
|
+
export async function getLatestVersion() {
|
|
157
|
+
const npmResult = await getLatestVersionFromNpm();
|
|
158
|
+
if (npmResult.reachable)
|
|
159
|
+
return npmResult;
|
|
160
|
+
return getLatestVersionFromGitHub();
|
|
161
|
+
}
|
|
63
162
|
/** Compare two semver strings. Returns true if remote is newer. */
|
|
64
163
|
function isNewer(local, remote) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (rMaj !== lMaj)
|
|
69
|
-
return rMaj > lMaj;
|
|
70
|
-
if (rMin !== lMin)
|
|
71
|
-
return rMin > lMin;
|
|
72
|
-
return rPat > lPat;
|
|
73
|
-
}
|
|
74
|
-
/** Check whether a newer tagged release is available on GitHub. */
|
|
164
|
+
return compareVersions(remote, local) > 0;
|
|
165
|
+
}
|
|
166
|
+
/** Check whether a newer version is available (npm registry first, GitHub fallback). */
|
|
75
167
|
export async function checkForUpdate() {
|
|
76
168
|
const current = getLocalVersion();
|
|
77
169
|
const result = await getLatestVersion();
|
|
@@ -82,12 +174,16 @@ export async function checkForUpdate() {
|
|
|
82
174
|
checkSucceeded: result.reachable,
|
|
83
175
|
};
|
|
84
176
|
}
|
|
85
|
-
|
|
86
|
-
|
|
177
|
+
// ─── Update execution ──────────────────────────────────────────────────────
|
|
178
|
+
/** Run a git pull + rebuild for legacy (~/.chapterhouse/src) installs. */
|
|
179
|
+
async function performLegacyGitUpdate(ref) {
|
|
87
180
|
try {
|
|
88
|
-
const opts = {
|
|
181
|
+
const opts = {
|
|
182
|
+
encoding: "utf-8",
|
|
183
|
+
timeout: 120_000,
|
|
184
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
+
};
|
|
89
186
|
if (!existsSync(join(SOURCE_DIR, ".git"))) {
|
|
90
|
-
// Fresh install — clone the repo
|
|
91
187
|
execSync(`git clone --depth 1 ${GITHUB_REPO_URL} "${SOURCE_DIR}"`, opts);
|
|
92
188
|
}
|
|
93
189
|
else {
|
|
@@ -109,4 +205,42 @@ export async function performUpdate(ref) {
|
|
|
109
205
|
return { ok: false, output: msg };
|
|
110
206
|
}
|
|
111
207
|
}
|
|
208
|
+
/** Run an npm registry update (post-S5-03 default path). */
|
|
209
|
+
async function performRegistryUpdate(ref) {
|
|
210
|
+
try {
|
|
211
|
+
const cmd = buildInstallCommand(ref);
|
|
212
|
+
const output = execSync(cmd, {
|
|
213
|
+
encoding: "utf-8",
|
|
214
|
+
timeout: 120_000,
|
|
215
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
216
|
+
});
|
|
217
|
+
return { ok: true, output: output.trim() };
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
const msg = err.stderr?.trim() || err.message || "Unknown error";
|
|
221
|
+
return { ok: false, output: msg };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Perform the update based on the detected install source.
|
|
226
|
+
*
|
|
227
|
+
* - registry / unknown → npm install -g chapterhouse[@<ref>]
|
|
228
|
+
* - git/legacy → git pull + build + link (with deprecation warning printed)
|
|
229
|
+
* - dev → no-op with friendly message
|
|
230
|
+
*/
|
|
231
|
+
export async function performUpdate(ref) {
|
|
232
|
+
const source = detectInstallSource();
|
|
233
|
+
if (source === "dev") {
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
output: "Dev mode — use git pull manually.",
|
|
237
|
+
source,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (source === "git") {
|
|
241
|
+
return { ...(await performLegacyGitUpdate(ref)), source };
|
|
242
|
+
}
|
|
243
|
+
// registry or unknown — default to npm registry path
|
|
244
|
+
return { ...(await performRegistryUpdate(ref)), source };
|
|
245
|
+
}
|
|
112
246
|
//# sourceMappingURL=update.js.map
|
package/dist/update.test.js
CHANGED
|
@@ -1,25 +1,104 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
|
|
3
|
+
// ─── Legacy compatibility ──────────────────────────────────────────────────
|
|
4
|
+
test("getInstallSource returns the GitHub repo for legacy/deprecation messages", async () => {
|
|
4
5
|
const updateModule = await import("./update.js");
|
|
5
6
|
assert.equal(typeof updateModule.getInstallSource, "function", "getInstallSource should be exported");
|
|
6
7
|
const source = updateModule.getInstallSource();
|
|
7
8
|
assert.equal(source, "bketelsen/chapterhouse");
|
|
8
9
|
});
|
|
9
|
-
|
|
10
|
+
// ─── buildInstallCommand (npm registry path) ───────────────────────────────
|
|
11
|
+
test("buildInstallCommand defaults to npm registry @latest", async () => {
|
|
10
12
|
const updateModule = await import("./update.js");
|
|
11
13
|
assert.equal(typeof updateModule.buildInstallCommand, "function", "buildInstallCommand should be exported");
|
|
14
|
+
const command = updateModule.buildInstallCommand(null);
|
|
15
|
+
assert.equal(command, "npm install -g chapterhouse@latest");
|
|
16
|
+
});
|
|
17
|
+
test("buildInstallCommand pins to a specific version when ref provided", async () => {
|
|
18
|
+
const updateModule = await import("./update.js");
|
|
19
|
+
const command = updateModule.buildInstallCommand("0.1.5");
|
|
20
|
+
assert.equal(command, "npm install -g chapterhouse@0.1.5");
|
|
21
|
+
});
|
|
22
|
+
test("buildInstallCommand handles v-prefixed ref", async () => {
|
|
23
|
+
const updateModule = await import("./update.js");
|
|
12
24
|
const command = updateModule.buildInstallCommand("v2.0.0");
|
|
25
|
+
assert.equal(command, "npm install -g chapterhouse@v2.0.0");
|
|
26
|
+
});
|
|
27
|
+
test("buildInstallCommand uses @latest when ref is empty string", async () => {
|
|
28
|
+
const updateModule = await import("./update.js");
|
|
29
|
+
const command = updateModule.buildInstallCommand("");
|
|
30
|
+
assert.equal(command, "npm install -g chapterhouse@latest");
|
|
31
|
+
});
|
|
32
|
+
// ─── buildLegacyGitInstallCommand ─────────────────────────────────────────
|
|
33
|
+
test("buildLegacyGitInstallCommand pins to a Git tag when provided", async () => {
|
|
34
|
+
const updateModule = await import("./update.js");
|
|
35
|
+
assert.equal(typeof updateModule.buildLegacyGitInstallCommand, "function");
|
|
36
|
+
const command = updateModule.buildLegacyGitInstallCommand("v2.0.0");
|
|
13
37
|
assert.equal(command, "npm install -g bketelsen/chapterhouse#v2.0.0");
|
|
14
38
|
});
|
|
15
|
-
test("
|
|
39
|
+
test("buildLegacyGitInstallCommand falls back to default branch when no tag provided", async () => {
|
|
16
40
|
const updateModule = await import("./update.js");
|
|
17
|
-
|
|
18
|
-
const command = updateModule.buildInstallCommand(null);
|
|
41
|
+
const command = updateModule.buildLegacyGitInstallCommand(null);
|
|
19
42
|
assert.equal(command, "npm install -g bketelsen/chapterhouse");
|
|
20
43
|
});
|
|
44
|
+
// ─── detectInstallSource ──────────────────────────────────────────────────
|
|
45
|
+
test("detectInstallSource returns a valid InstallSource value", async () => {
|
|
46
|
+
const updateModule = await import("./update.js");
|
|
47
|
+
assert.equal(typeof updateModule.detectInstallSource, "function", "detectInstallSource should be exported");
|
|
48
|
+
const source = updateModule.detectInstallSource();
|
|
49
|
+
const valid = ["registry", "git", "dev", "unknown"];
|
|
50
|
+
assert.ok(valid.includes(source), `Expected one of ${valid.join("|")}, got: ${source}`);
|
|
51
|
+
});
|
|
52
|
+
// During test runs the package lives in a git working tree → should be "dev"
|
|
53
|
+
test("detectInstallSource detects dev mode when running from source checkout", async () => {
|
|
54
|
+
const { detectInstallSource } = await import("./update.js");
|
|
55
|
+
const { existsSync } = await import("fs");
|
|
56
|
+
const { join, dirname } = await import("path");
|
|
57
|
+
const { fileURLToPath } = await import("url");
|
|
58
|
+
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
59
|
+
// Only assert dev if there really is a .git in pkgDir (guards CI environments)
|
|
60
|
+
if (existsSync(join(pkgDir, ".git"))) {
|
|
61
|
+
assert.equal(detectInstallSource(), "dev");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
// ─── checkPreconditions ───────────────────────────────────────────────────
|
|
65
|
+
test("checkPreconditions is exported as a function", async () => {
|
|
66
|
+
const updateModule = await import("./update.js");
|
|
67
|
+
assert.equal(typeof updateModule.checkPreconditions, "function", "checkPreconditions should be exported");
|
|
68
|
+
});
|
|
69
|
+
test("checkPreconditions returns ok:true for Node 24+", async () => {
|
|
70
|
+
const { checkPreconditions } = await import("./update.js");
|
|
71
|
+
// The test runner must be Node 24+ per the project requirement — so this asserts both the shape and the pass case.
|
|
72
|
+
const result = checkPreconditions();
|
|
73
|
+
assert.equal(typeof result.ok, "boolean");
|
|
74
|
+
assert.equal(typeof result.nodeVersion, "string");
|
|
75
|
+
assert.equal(typeof result.npmVersion, "string");
|
|
76
|
+
const [major] = result.nodeVersion.split(".").map(Number);
|
|
77
|
+
if (major >= 24) {
|
|
78
|
+
assert.equal(result.ok, true, "Should pass on Node 24+");
|
|
79
|
+
assert.equal(result.message, undefined);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
test("checkPreconditions message contains node and npm versions on failure", async () => {
|
|
83
|
+
// We construct the precondition result shape manually to test the message template
|
|
84
|
+
const fakeNode = "22.0.0";
|
|
85
|
+
const fakeNpm = "10.0.0";
|
|
86
|
+
const expected = `Node 24+ / npm 11.5.1+ recommended for chapterhouse updates (current Node: ${fakeNode}, npm: ${fakeNpm}). Update Node and try again.`;
|
|
87
|
+
// Verify the message format by reconstructing it (mirrors the implementation)
|
|
88
|
+
const msg = `Node 24+ / npm 11.5.1+ recommended for chapterhouse updates (current Node: ${fakeNode}, npm: ${fakeNpm}). Update Node and try again.`;
|
|
89
|
+
assert.equal(msg, expected);
|
|
90
|
+
});
|
|
91
|
+
// ─── performUpdate ────────────────────────────────────────────────────────
|
|
21
92
|
test("performUpdate is exported as async function", async () => {
|
|
22
93
|
const updateModule = await import("./update.js");
|
|
23
94
|
assert.equal(typeof updateModule.performUpdate, "function", "performUpdate should be exported");
|
|
24
95
|
});
|
|
96
|
+
test("performUpdate result includes source field", async () => {
|
|
97
|
+
// We cannot run the real network/exec path in unit tests.
|
|
98
|
+
// Verify only the shape contract by importing and checking the return type annotation exists.
|
|
99
|
+
const updateModule = await import("./update.js");
|
|
100
|
+
const fn = updateModule.performUpdate;
|
|
101
|
+
assert.equal(typeof fn, "function");
|
|
102
|
+
// Shape is validated via TypeScript at build time; this test confirms the export is present.
|
|
103
|
+
});
|
|
25
104
|
//# sourceMappingURL=update.test.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Structured logger — pino singleton
|
|
3
|
+
//
|
|
4
|
+
// Configuration:
|
|
5
|
+
// LOG_LEVEL env var controls verbosity. Default: "info".
|
|
6
|
+
// Valid levels: trace | debug | info | warn | error | fatal | silent
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// import { logger } from "../util/logger.js";
|
|
10
|
+
// logger.info("Server started");
|
|
11
|
+
// logger.debug({ direction: "in", source: "web", text: msg }, "chat");
|
|
12
|
+
// logger.error({ err }, "Something blew up");
|
|
13
|
+
//
|
|
14
|
+
// Chat message content should always be logged at debug or trace — never info.
|
|
15
|
+
// System events (startup, sessions, agent dispatch, DB) go at info.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
import pino from "pino";
|
|
18
|
+
const VALID_LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal", "silent"]);
|
|
19
|
+
export function resolveLevel(envOverride) {
|
|
20
|
+
const raw = (envOverride ?? process.env.LOG_LEVEL ?? "info").toLowerCase();
|
|
21
|
+
return VALID_LEVELS.has(raw) ? raw : "info";
|
|
22
|
+
}
|
|
23
|
+
/** Create a fresh pino logger for the given level. Useful in tests. */
|
|
24
|
+
export function createLogger(level) {
|
|
25
|
+
return pino({ level });
|
|
26
|
+
}
|
|
27
|
+
// In test environments (CHAPTERHOUSE_DISABLE_DOTENV=1), default to silent so
|
|
28
|
+
// test output stays clean. An explicit LOG_LEVEL env var overrides this.
|
|
29
|
+
const defaultLevel = process.env.CHAPTERHOUSE_DISABLE_DOTENV === "1" && !process.env.LOG_LEVEL
|
|
30
|
+
? "silent"
|
|
31
|
+
: resolveLevel();
|
|
32
|
+
export const logger = pino({
|
|
33
|
+
level: defaultLevel,
|
|
34
|
+
// Output is JSON (machine-readable). Pipe to `pino-pretty` for dev:
|
|
35
|
+
// node ... | npx pino-pretty
|
|
36
|
+
});
|
|
37
|
+
/** Convenience child logger pre-tagged with a module name. */
|
|
38
|
+
export function childLogger(module) {
|
|
39
|
+
return logger.child({ module });
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=logger.js.map
|