@fcannizzaro/exocommand 1.0.10 → 1.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/dist/index.js CHANGED
@@ -2,20 +2,122 @@
2
2
  import { watch } from "node:fs";
3
3
  import { createServer as createHttpServer } from "node:http";
4
4
  import { createRequire } from "node:module";
5
+ import { resolve, join, dirname, basename } from "node:path";
6
+ import { stat } from "node:fs/promises";
5
7
  import { getRequestListener } from "@hono/node-server";
6
8
  import {} from "@modelcontextprotocol/sdk/server/mcp.js";
7
9
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
8
10
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
9
- import { createServer } from "./server.js";
11
+ import { parseArgs } from "./cli.js";
10
12
  import { loadConfig, configExists, createSampleConfig } from "./config.js";
11
- import { logger } from "./logger.js";
13
+ import { logger, createTuiLogger } from "./logger.js";
14
+ import { addProject, removeProject, removeProjectByPath, listProjects, resolveProject, getRegistryPath, } from "./registry.js";
15
+ import { createServer } from "./server.js";
16
+ import { createTui } from "./tui.js";
12
17
  const _require = createRequire(import.meta.url);
13
18
  const { version } = _require("../package.json");
14
- const CONFIG_PATH = process.env.EXO_COMMAND_FILE || "./.exocommand";
19
+ // ANSI styling for CLI output
20
+ const RESET = "\x1b[0m";
21
+ const BOLD = "\x1b[1m";
22
+ const DIM = "\x1b[2m";
23
+ const CYAN = "\x1b[36m";
24
+ const GREEN = "\x1b[32m";
25
+ const RED = "\x1b[31m";
26
+ const MAGENTA = "\x1b[35m";
15
27
  const transports = new Map();
16
28
  const servers = new Set();
29
+ // Session -> accessKey mapping
30
+ const sessionProject = new Map();
31
+ // AccessKey -> Set<McpServer> (for targeted config-change notifications)
32
+ const projectServers = new Map();
33
+ // Per-project .exocommand file watchers
34
+ const configWatchers = new Map();
35
+ const configTimers = new Map();
17
36
  let nextAgentId = 0;
18
37
  let taskMode = false;
38
+ let tui = null;
39
+ let activeLogger = logger;
40
+ // -- Per-project config file watchers -------------------------------------
41
+ function ensureConfigWatcher(accessKey, configPath) {
42
+ if (configWatchers.has(accessKey))
43
+ return;
44
+ const watcher = watch(configPath, (eventType) => {
45
+ if (eventType !== "change")
46
+ return;
47
+ const existing = configTimers.get(accessKey);
48
+ if (existing)
49
+ clearTimeout(existing);
50
+ configTimers.set(accessKey, setTimeout(() => {
51
+ configTimers.delete(accessKey);
52
+ activeLogger.info("watcher", `${configPath} changed, notifying project ${accessKey}`);
53
+ const projectSet = projectServers.get(accessKey);
54
+ if (projectSet) {
55
+ for (const server of projectSet) {
56
+ try {
57
+ server.sendToolListChanged();
58
+ }
59
+ catch {
60
+ // client may have disconnected
61
+ }
62
+ }
63
+ }
64
+ }, 200));
65
+ });
66
+ configWatchers.set(accessKey, watcher);
67
+ activeLogger.info("watcher", `watching ${configPath} (project: ${accessKey})`);
68
+ }
69
+ function stopConfigWatcher(accessKey) {
70
+ const watcher = configWatchers.get(accessKey);
71
+ if (watcher) {
72
+ watcher.close();
73
+ configWatchers.delete(accessKey);
74
+ }
75
+ const timer = configTimers.get(accessKey);
76
+ if (timer) {
77
+ clearTimeout(timer);
78
+ configTimers.delete(accessKey);
79
+ }
80
+ activeLogger.info("watcher", `stopped watching project ${accessKey}`);
81
+ }
82
+ // Sync config watchers with the current registry state
83
+ async function syncConfigWatchers() {
84
+ const registry = await listProjects();
85
+ const registeredKeys = new Set(Object.keys(registry));
86
+ // Start watchers for newly registered projects
87
+ for (const [accessKey, configPath] of Object.entries(registry)) {
88
+ ensureConfigWatcher(accessKey, configPath);
89
+ }
90
+ // Stop watchers for projects no longer in the registry
91
+ for (const accessKey of configWatchers.keys()) {
92
+ if (!registeredKeys.has(accessKey)) {
93
+ stopConfigWatcher(accessKey);
94
+ }
95
+ }
96
+ }
97
+ // -- Registry file watcher (detect add/rm from other processes) -----------
98
+ function watchRegistry() {
99
+ const registryPath = getRegistryPath();
100
+ let debounceTimer = null;
101
+ try {
102
+ watch(registryPath, (eventType) => {
103
+ if (eventType !== "change")
104
+ return;
105
+ if (debounceTimer)
106
+ clearTimeout(debounceTimer);
107
+ debounceTimer = setTimeout(async () => {
108
+ debounceTimer = null;
109
+ activeLogger.info("registry", "registry changed, reloading project list");
110
+ await syncConfigWatchers();
111
+ }, 200);
112
+ });
113
+ activeLogger.info("registry", "watching exocommand.db.json for changes");
114
+ }
115
+ catch {
116
+ // registry file may not exist yet; it will be created on first add
117
+ activeLogger.warn("registry", "registry file not found, skipping watch");
118
+ }
119
+ }
120
+ // -- MCP request handler --------------------------------------------------
19
121
  async function handleMcp(req) {
20
122
  const url = new URL(req.url);
21
123
  if (url.pathname !== "/mcp") {
@@ -35,33 +137,103 @@ async function handleMcp(req) {
35
137
  return transports.get(sessionId).handleRequest(req);
36
138
  }
37
139
  if (req.method === "POST") {
38
- // reuse existing session
140
+ // Reuse existing session
39
141
  if (sessionId && transports.has(sessionId)) {
40
142
  return transports.get(sessionId).handleRequest(req);
41
143
  }
42
- // parse body to check if it's an initialize request
144
+ // Parse body to check if it's an initialize request
43
145
  const body = await req.json();
44
146
  if (!sessionId && isInitializeRequest(body)) {
147
+ // Extract project access key from header
148
+ const accessKey = req.headers.get("exocommand-project");
149
+ if (!accessKey) {
150
+ return Response.json({
151
+ jsonrpc: "2.0",
152
+ error: {
153
+ code: -32000,
154
+ message: "Missing exocommand-project header",
155
+ },
156
+ id: null,
157
+ }, { status: 400 });
158
+ }
159
+ // Resolve config path from registry
160
+ let configPath;
161
+ try {
162
+ configPath = await resolveProject(accessKey);
163
+ }
164
+ catch {
165
+ return Response.json({
166
+ jsonrpc: "2.0",
167
+ error: {
168
+ code: -32000,
169
+ message: `Unknown project key: ${accessKey}`,
170
+ },
171
+ id: null,
172
+ }, { status: 400 });
173
+ }
174
+ // Verify the .exocommand file still exists
175
+ if (!(await configExists(configPath))) {
176
+ return Response.json({
177
+ jsonrpc: "2.0",
178
+ error: {
179
+ code: -32000,
180
+ message: `Config file not found: ${configPath} (project may have been moved or deleted)`,
181
+ },
182
+ id: null,
183
+ }, { status: 400 });
184
+ }
45
185
  const agentId = ++nextAgentId;
46
- const agentLogger = logger.withAgent(agentId);
186
+ const agentLogger = activeLogger.withAgent(agentId);
187
+ const projectLabel = basename(dirname(configPath));
47
188
  const transport = new WebStandardStreamableHTTPServerTransport({
48
189
  sessionIdGenerator: () => crypto.randomUUID(),
49
190
  onsessioninitialized: (sid) => {
50
191
  transports.set(sid, transport);
51
- agentLogger.info("session", `created ${sid}`);
192
+ sessionProject.set(sid, accessKey);
193
+ // Track this server under the project
194
+ if (!projectServers.has(accessKey)) {
195
+ projectServers.set(accessKey, new Set());
196
+ }
197
+ const isFirstSession = projectServers.get(accessKey).size === 0;
198
+ projectServers.get(accessKey).add(server);
199
+ // Notify TUI about new project on first session
200
+ if (isFirstSession) {
201
+ tui?.addProject(accessKey, projectLabel);
202
+ }
203
+ // Start watching this project's config if not already watched
204
+ ensureConfigWatcher(accessKey, configPath);
205
+ agentLogger.info("session", `created ${sid} (project: ${accessKey})`);
52
206
  },
53
207
  onsessionclosed: (sid) => {
54
208
  transports.delete(sid);
209
+ sessionProject.delete(sid);
55
210
  agentLogger.warn("session", `closed ${sid}`);
56
211
  },
57
212
  });
58
- const server = createServer(agentLogger, { taskMode });
213
+ const server = createServer(agentLogger, {
214
+ configPath,
215
+ taskMode,
216
+ tui,
217
+ agentId,
218
+ projectKey: accessKey,
219
+ });
59
220
  servers.add(server);
60
221
  transport.onclose = () => {
61
222
  if (transport.sessionId) {
62
223
  transports.delete(transport.sessionId);
224
+ sessionProject.delete(transport.sessionId);
63
225
  }
64
226
  servers.delete(server);
227
+ // Remove from project tracking
228
+ const projectSet = projectServers.get(accessKey);
229
+ if (projectSet) {
230
+ projectSet.delete(server);
231
+ if (projectSet.size === 0) {
232
+ projectServers.delete(accessKey);
233
+ stopConfigWatcher(accessKey);
234
+ tui?.removeProject(accessKey);
235
+ }
236
+ }
65
237
  };
66
238
  await server.connect(transport);
67
239
  return transport.handleRequest(req, { parsedBody: body });
@@ -77,29 +249,96 @@ async function handleMcp(req) {
77
249
  }
78
250
  return new Response("Method Not Allowed", { status: 405 });
79
251
  }
80
- async function main() {
81
- logger.banner(version);
82
- if (!(await configExists(CONFIG_PATH))) {
83
- await createSampleConfig(CONFIG_PATH);
84
- logger.info("setup", `created sample config at ${CONFIG_PATH}`);
85
- logger.info("setup", "edit the file with your commands, then restart the server");
86
- process.exit(0);
87
- }
88
- let config;
252
+ // -- CLI command handlers -------------------------------------------------
253
+ async function handleAdd(rawPath) {
254
+ let resolved = resolve(rawPath);
255
+ const fileStat = await stat(resolved).catch(() => null);
256
+ if (fileStat?.isDirectory()) {
257
+ resolved = join(resolved, ".exocommand");
258
+ }
259
+ if (!(await configExists(resolved))) {
260
+ console.error(`\n ${RED}${BOLD}✗${RESET} Config file not found: ${DIM}${resolved}${RESET}\n`);
261
+ process.exit(1);
262
+ }
89
263
  try {
90
- config = await loadConfig(CONFIG_PATH);
264
+ await loadConfig(resolved);
91
265
  }
92
266
  catch (err) {
93
- logger.error("startup", `Failed to load config from ${CONFIG_PATH}: ${err.message}`);
267
+ console.error(`\n ${RED}${BOLD}✗${RESET} Invalid config: ${err.message}\n`);
94
268
  process.exit(1);
95
269
  }
96
- const PORT = parseInt(process.env.EXO_PORT || String(config.port ?? 5555), 10);
97
- // Resolve task mode: env var overrides config
270
+ const accessKey = await addProject(resolved);
271
+ console.log(`
272
+ ${GREEN}${BOLD}✓${RESET} Project registered
273
+
274
+ ${DIM}Key${RESET} ${CYAN}${BOLD}${accessKey}${RESET}
275
+ ${DIM}Header${RESET} ${DIM}exocommand-project: ${accessKey}${RESET}
276
+ ${DIM}Config${RESET} ${DIM}${resolved}${RESET}
277
+ `);
278
+ }
279
+ async function handleLs() {
280
+ const registry = await listProjects();
281
+ const entries = Object.entries(registry);
282
+ if (entries.length === 0) {
283
+ console.log(`\n ${DIM}No projects registered.${RESET}\n`);
284
+ return;
285
+ }
286
+ const keyWidth = 14;
287
+ console.log(`\n ${BOLD}${MAGENTA}KEY${RESET}${" ".repeat(keyWidth - 3)}${BOLD}${MAGENTA}PATH${RESET}`);
288
+ console.log(` ${DIM}${"─".repeat(keyWidth)}${"─".repeat(40)}${RESET}`);
289
+ for (const [key, filePath] of entries) {
290
+ const exists = await configExists(filePath);
291
+ const pathDisplay = exists
292
+ ? `${DIM}${filePath}${RESET}`
293
+ : `${DIM}${filePath} ${RED}(missing)${RESET}`;
294
+ console.log(` ${CYAN}${BOLD}${key}${RESET}${" ".repeat(keyWidth - key.length)}${pathDisplay}`);
295
+ }
296
+ console.log();
297
+ }
298
+ async function handleRm(target) {
299
+ // Detect if target is a path (contains / or . or \) vs an access key
300
+ const isPath = target.includes("/") || target.includes("\\") || target === ".";
301
+ try {
302
+ if (isPath) {
303
+ let resolved = resolve(target);
304
+ const fileStat = await stat(resolved).catch(() => null);
305
+ if (fileStat?.isDirectory()) {
306
+ resolved = join(resolved, ".exocommand");
307
+ }
308
+ await removeProjectByPath(resolved);
309
+ console.log(`\n ${GREEN}${BOLD}✓${RESET} Project removed ${DIM}(${resolved})${RESET}\n`);
310
+ }
311
+ else {
312
+ await removeProject(target);
313
+ console.log(`\n ${GREEN}${BOLD}✓${RESET} Project ${CYAN}${BOLD}${target}${RESET} removed\n`);
314
+ }
315
+ }
316
+ catch (err) {
317
+ console.error(`\n ${RED}${BOLD}✗${RESET} ${err.message}\n`);
318
+ process.exit(1);
319
+ }
320
+ }
321
+ async function handleInit() {
322
+ const configPath = resolve(".exocommand");
323
+ if (await configExists(configPath)) {
324
+ console.error(`\n ${RED}${BOLD}✗${RESET} Config file already exists: ${DIM}${configPath}${RESET}\n`);
325
+ process.exit(1);
326
+ }
327
+ await createSampleConfig(configPath);
328
+ console.log(`\n ${GREEN}${BOLD}✓${RESET} Created ${DIM}${configPath}${RESET}\n`);
329
+ }
330
+ // -- Server startup -------------------------------------------------------
331
+ async function startServer() {
332
+ if (process.stdout.isTTY) {
333
+ tui = await createTui(version);
334
+ activeLogger = createTuiLogger(tui);
335
+ }
336
+ const PORT = parseInt(process.env.EXO_PORT || "5555", 10);
337
+ tui?.setPort(PORT);
338
+ // Resolve task mode from env
98
339
  const envTaskMode = process.env.EXO_TASK_MODE;
99
- taskMode = envTaskMode !== undefined
100
- ? envTaskMode === "true" || envTaskMode === "1"
101
- : config.taskMode ?? false;
102
- logger.info("startup", `execution mode: ${taskMode ? "task" : "streaming"}`);
340
+ taskMode = envTaskMode === "true" || envTaskMode === "1";
341
+ activeLogger.info("startup", `execution mode: ${taskMode ? "task" : "streaming"}`);
103
342
  try {
104
343
  const listener = getRequestListener(handleMcp, {
105
344
  overrideGlobalObjects: false,
@@ -110,33 +349,39 @@ async function main() {
110
349
  httpServer.listen(PORT, "127.0.0.1");
111
350
  }
112
351
  catch (err) {
113
- logger.error("startup", `Failed to start server on port ${PORT}: ${err.message}`);
352
+ activeLogger.error("startup", `Failed to start server on port ${PORT}: ${err.message}`);
353
+ tui?.destroy();
114
354
  process.exit(1);
115
355
  }
116
- logger.info("server", `listening on http://127.0.0.1:${PORT}/mcp`);
117
- // Watch .exocommand for changes and notify connected clients
118
- let debounceTimer = null;
119
- watch(CONFIG_PATH, (eventType) => {
120
- if (eventType !== "change")
356
+ activeLogger.info("server", `listening on http://127.0.0.1:${PORT}/mcp`);
357
+ // Watch the registry file for changes from other CLI processes
358
+ watchRegistry();
359
+ // Start watching all currently registered project configs
360
+ await syncConfigWatchers();
361
+ }
362
+ // -- Main entry point -----------------------------------------------------
363
+ async function main() {
364
+ const action = parseArgs(process.argv);
365
+ switch (action.kind) {
366
+ case "add":
367
+ await handleAdd(action.path);
121
368
  return;
122
- if (debounceTimer)
123
- clearTimeout(debounceTimer);
124
- debounceTimer = setTimeout(() => {
125
- debounceTimer = null;
126
- logger.info("watcher", ".exocommand changed, notifying clients");
127
- for (const server of servers) {
128
- try {
129
- server.sendToolListChanged();
130
- }
131
- catch {
132
- // client may have disconnected
133
- }
134
- }
135
- }, 200);
136
- });
137
- logger.info("watcher", `watching ${CONFIG_PATH} for changes`);
369
+ case "init":
370
+ await handleInit();
371
+ return;
372
+ case "ls":
373
+ await handleLs();
374
+ return;
375
+ case "rm":
376
+ await handleRm(action.target);
377
+ return;
378
+ case "serve":
379
+ await startServer();
380
+ return;
381
+ }
138
382
  }
139
383
  main().catch((err) => {
384
+ tui?.destroy();
140
385
  logger.error("fatal", `Unexpected error: ${err.message}`);
141
386
  process.exit(1);
142
387
  });
package/dist/logger.js CHANGED
@@ -36,23 +36,36 @@ function createLogger(agentId) {
36
36
  }
37
37
  export const logger = {
38
38
  ...createLogger(),
39
- banner(version) {
40
- const v = `v${version}`;
41
- // 32-char wide interior: " ● ○ ○" (7 chars) + padding + version + " " (2 trailing)
42
- const dotsLeft = " ● ○ ○";
43
- const padLen = 32 - dotsLeft.length - v.length - 2;
44
- const pad = " ".repeat(Math.max(padLen, 1));
45
- const lines = [
46
- `${DIM} ╭────────────────────────────────╮${RESET}`,
47
- `${DIM} │${RESET} ${RED}●${RESET} ${DIM}○ ○${pad}${v}${RESET} ${DIM}│${RESET}`,
48
- `${DIM} │${RESET} ${GREEN}>${RESET} ${BOLD}${CYAN}EXOCOMMAND${RESET}${GREEN}_${RESET} ${DIM}├══════╗${RESET}`,
49
- `${DIM} ╰────────────────────────────────╯ ┌════╝${RESET}`,
50
- `${DIM} └══╗${RESET}`,
51
- `${DIM} ╹${RESET}`,
52
- ];
53
- console.log(lines.join("\n"));
54
- },
55
39
  withAgent(id) {
56
40
  return createLogger(id);
57
41
  },
58
42
  };
43
+ // TUI-aware logger that delegates to TuiManager instead of console
44
+ export function createTuiLogger(tui) {
45
+ function makeLogger(agentId) {
46
+ return {
47
+ info(event, message) {
48
+ const msg = agentId !== undefined ? `[agent ${agentId}] ${message}` : message;
49
+ tui.logMessage("info", event, msg);
50
+ },
51
+ success(event, message) {
52
+ const msg = agentId !== undefined ? `[agent ${agentId}] ${message}` : message;
53
+ tui.logMessage("success", event, msg);
54
+ },
55
+ warn(event, message) {
56
+ const msg = agentId !== undefined ? `[agent ${agentId}] ${message}` : message;
57
+ tui.logMessage("warn", event, msg);
58
+ },
59
+ error(event, message) {
60
+ const msg = agentId !== undefined ? `[agent ${agentId}] ${message}` : message;
61
+ tui.logMessage("error", event, msg);
62
+ },
63
+ };
64
+ }
65
+ return {
66
+ ...makeLogger(),
67
+ withAgent(id) {
68
+ return makeLogger(id);
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,80 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ const REGISTRY_DIR = join(homedir(), ".exocommand");
6
+ const REGISTRY_PATH = join(REGISTRY_DIR, "exocommand.db.json");
7
+ export function getRegistryPath() {
8
+ return REGISTRY_PATH;
9
+ }
10
+ export async function loadRegistry() {
11
+ try {
12
+ await access(REGISTRY_PATH);
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ const content = await readFile(REGISTRY_PATH, "utf-8");
18
+ const parsed = JSON.parse(content);
19
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
20
+ throw new Error("Invalid registry format: expected a JSON object");
21
+ }
22
+ return parsed;
23
+ }
24
+ export async function saveRegistry(registry) {
25
+ await mkdir(REGISTRY_DIR, { recursive: true });
26
+ await writeFile(REGISTRY_PATH, JSON.stringify(registry, null, 2) + "\n", "utf-8");
27
+ }
28
+ export function generateAccessKey() {
29
+ return createHash("sha256").update(randomUUID()).digest("hex").slice(0, 12);
30
+ }
31
+ export async function addProject(filePath) {
32
+ const absolute = resolve(filePath);
33
+ await access(absolute);
34
+ const registry = await loadRegistry();
35
+ // Check for duplicate path — return existing key
36
+ for (const [key, existingPath] of Object.entries(registry)) {
37
+ if (existingPath === absolute) {
38
+ return key;
39
+ }
40
+ }
41
+ // Generate a unique key (handle unlikely collisions)
42
+ let accessKey = generateAccessKey();
43
+ while (registry[accessKey] !== undefined) {
44
+ accessKey = generateAccessKey();
45
+ }
46
+ registry[accessKey] = absolute;
47
+ await saveRegistry(registry);
48
+ return accessKey;
49
+ }
50
+ export async function removeProject(accessKey) {
51
+ const registry = await loadRegistry();
52
+ if (registry[accessKey] === undefined) {
53
+ throw new Error(`Project "${accessKey}" not found in registry`);
54
+ }
55
+ delete registry[accessKey];
56
+ await saveRegistry(registry);
57
+ }
58
+ export async function removeProjectByPath(filePath) {
59
+ const absolute = resolve(filePath);
60
+ const registry = await loadRegistry();
61
+ for (const [key, existingPath] of Object.entries(registry)) {
62
+ if (existingPath === absolute) {
63
+ delete registry[key];
64
+ await saveRegistry(registry);
65
+ return;
66
+ }
67
+ }
68
+ throw new Error(`No project registered for path: ${absolute}`);
69
+ }
70
+ export async function listProjects() {
71
+ return loadRegistry();
72
+ }
73
+ export async function resolveProject(accessKey) {
74
+ const registry = await loadRegistry();
75
+ const path = registry[accessKey];
76
+ if (path === undefined) {
77
+ throw new Error(`Project "${accessKey}" not found in registry`);
78
+ }
79
+ return path;
80
+ }
@@ -0,0 +1,107 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { loadRegistry, saveRegistry, generateAccessKey, addProject, removeProject, removeProjectByPath, listProjects, resolveProject, } from "./registry";
6
+ // Override the registry path for testing by monkey-patching the module internals.
7
+ // Instead, we test using temp .exocommand files and the real registry functions
8
+ // operating on the actual registry path. To isolate tests, we save/restore.
9
+ let tmpDir;
10
+ let sampleConfigPath;
11
+ let sampleConfigPath2;
12
+ beforeAll(async () => {
13
+ tmpDir = await mkdtemp(join(tmpdir(), "exocommand-registry-test-"));
14
+ sampleConfigPath = join(tmpDir, ".exocommand");
15
+ sampleConfigPath2 = join(tmpDir, ".exocommand2");
16
+ await writeFile(sampleConfigPath, 'hello:\n description: "hi"\n command: "echo hi"\n');
17
+ await writeFile(sampleConfigPath2, 'build:\n description: "build"\n command: "bun run build"\n');
18
+ });
19
+ afterAll(async () => {
20
+ await rm(tmpDir, { recursive: true, force: true });
21
+ });
22
+ describe("generateAccessKey", () => {
23
+ test("returns a 12-char hex string", () => {
24
+ const key = generateAccessKey();
25
+ expect(key).toHaveLength(12);
26
+ expect(key).toMatch(/^[0-9a-f]{12}$/);
27
+ });
28
+ test("returns unique keys on successive calls", () => {
29
+ const keys = new Set(Array.from({ length: 100 }, () => generateAccessKey()));
30
+ expect(keys.size).toBe(100);
31
+ });
32
+ });
33
+ describe("registry CRUD", () => {
34
+ test("loadRegistry returns empty object when file does not exist", async () => {
35
+ // The real registry file may or may not exist, so we test the function's behavior
36
+ // by checking it returns a valid object
37
+ const registry = await loadRegistry();
38
+ expect(typeof registry).toBe("object");
39
+ expect(registry).not.toBeNull();
40
+ });
41
+ test("saveRegistry creates directory and writes file", async () => {
42
+ const registry = await loadRegistry();
43
+ // Saving the same registry should not throw
44
+ await saveRegistry(registry);
45
+ const reloaded = await loadRegistry();
46
+ expect(reloaded).toEqual(registry);
47
+ });
48
+ test("addProject registers a new project and returns 12-char key", async () => {
49
+ const key = await addProject(sampleConfigPath);
50
+ expect(key).toHaveLength(12);
51
+ expect(key).toMatch(/^[0-9a-f]{12}$/);
52
+ // Verify it's in the registry
53
+ const registry = await loadRegistry();
54
+ expect(registry[key]).toBe(sampleConfigPath);
55
+ });
56
+ test("addProject returns existing key for duplicate path", async () => {
57
+ const key1 = await addProject(sampleConfigPath);
58
+ const key2 = await addProject(sampleConfigPath);
59
+ expect(key1).toBe(key2);
60
+ });
61
+ test("addProject throws for non-existent file", async () => {
62
+ await expect(addProject(join(tmpDir, "nonexistent"))).rejects.toThrow();
63
+ });
64
+ test("resolveProject returns path for valid key", async () => {
65
+ const key = await addProject(sampleConfigPath);
66
+ const resolved = await resolveProject(key);
67
+ expect(resolved).toBe(sampleConfigPath);
68
+ });
69
+ test("resolveProject throws for unknown key", async () => {
70
+ await expect(resolveProject("zzzzzzzz")).rejects.toThrow('Project "zzzzzzzz" not found in registry');
71
+ });
72
+ test("listProjects returns all registered projects", async () => {
73
+ const key1 = await addProject(sampleConfigPath);
74
+ const key2 = await addProject(sampleConfigPath2);
75
+ const projects = await listProjects();
76
+ expect(projects[key1]).toBe(sampleConfigPath);
77
+ expect(projects[key2]).toBe(sampleConfigPath2);
78
+ });
79
+ test("removeProject deletes the key", async () => {
80
+ const key = await addProject(sampleConfigPath2);
81
+ await removeProject(key);
82
+ const registry = await loadRegistry();
83
+ expect(registry[key]).toBeUndefined();
84
+ });
85
+ test("removeProject throws for unknown key", async () => {
86
+ await expect(removeProject("zzzzzzzzzzzz")).rejects.toThrow('Project "zzzzzzzzzzzz" not found in registry');
87
+ });
88
+ test("removeProjectByPath removes by config file path", async () => {
89
+ const key = await addProject(sampleConfigPath2);
90
+ await removeProjectByPath(sampleConfigPath2);
91
+ const registry = await loadRegistry();
92
+ expect(registry[key]).toBeUndefined();
93
+ });
94
+ test("removeProjectByPath throws for unknown path", async () => {
95
+ await expect(removeProjectByPath("/nonexistent/.exocommand")).rejects.toThrow("No project registered for path: /nonexistent/.exocommand");
96
+ });
97
+ test("full lifecycle: add, list, resolve, remove", async () => {
98
+ const key = await addProject(sampleConfigPath);
99
+ expect(key).toHaveLength(12);
100
+ const projects = await listProjects();
101
+ expect(projects[key]).toBe(sampleConfigPath);
102
+ const resolved = await resolveProject(key);
103
+ expect(resolved).toBe(sampleConfigPath);
104
+ await removeProject(key);
105
+ await expect(resolveProject(key)).rejects.toThrow();
106
+ });
107
+ });