@opengsd/gsd-pi 1.0.2-dev.50223bc → 1.0.2-dev.5961fbf
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/resource-loader.d.ts +5 -0
- package/dist/resource-loader.js +24 -8
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/loop.js +19 -0
- package/dist/resources/extensions/gsd/auto/phases.js +1 -1
- package/dist/resources/extensions/gsd/auto-worktree.js +2 -54
- package/dist/resources/extensions/gsd/worktree-post-create-hook.js +117 -0
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/session/events/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
- package/dist/web/standalone/.next/server/chunks/1834.js +1 -1
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/node_modules/node-pty/build/Makefile +1 -1
- package/dist/web/standalone/package.json +0 -1
- package/dist/worktree-cli.d.ts +0 -2
- package/dist/worktree-cli.js +21 -9
- package/package.json +9 -4
- package/packages/cloud-mcp-gateway/bin/gsd-cloud-mcp-gateway.js +14 -0
- package/packages/cloud-mcp-gateway/package.json +5 -4
- package/packages/contracts/package.json +2 -2
- package/packages/daemon/bin/gsd-daemon.js +14 -0
- package/packages/daemon/bin/gsd-mcp-runtime.js +14 -0
- package/packages/daemon/bin/gsd-mcp.js +14 -0
- package/packages/daemon/dist/channel-manager.d.ts +53 -0
- package/packages/daemon/dist/channel-manager.d.ts.map +1 -0
- package/packages/daemon/dist/channel-manager.js +167 -0
- package/packages/daemon/dist/channel-manager.js.map +1 -0
- package/packages/daemon/dist/cli.d.ts +3 -0
- package/packages/daemon/dist/cli.d.ts.map +1 -0
- package/packages/daemon/dist/cli.js +94 -0
- package/packages/daemon/dist/cli.js.map +1 -0
- package/packages/daemon/dist/cloud-cli.d.ts +7 -0
- package/packages/daemon/dist/cloud-cli.d.ts.map +1 -0
- package/packages/daemon/dist/cloud-cli.js +96 -0
- package/packages/daemon/dist/cloud-cli.js.map +1 -0
- package/packages/daemon/dist/cloud-config.d.ts +18 -0
- package/packages/daemon/dist/cloud-config.d.ts.map +1 -0
- package/packages/daemon/dist/cloud-config.js +209 -0
- package/packages/daemon/dist/cloud-config.js.map +1 -0
- package/packages/daemon/dist/cloud-config.test.d.ts +2 -0
- package/packages/daemon/dist/cloud-config.test.d.ts.map +1 -0
- package/packages/daemon/dist/cloud-config.test.js +132 -0
- package/packages/daemon/dist/cloud-config.test.js.map +1 -0
- package/packages/daemon/dist/cloud-runtime.d.ts +26 -0
- package/packages/daemon/dist/cloud-runtime.d.ts.map +1 -0
- package/packages/daemon/dist/cloud-runtime.js +180 -0
- package/packages/daemon/dist/cloud-runtime.js.map +1 -0
- package/packages/daemon/dist/cloud-runtime.test.d.ts +2 -0
- package/packages/daemon/dist/cloud-runtime.test.d.ts.map +1 -0
- package/packages/daemon/dist/cloud-runtime.test.js +28 -0
- package/packages/daemon/dist/cloud-runtime.test.js.map +1 -0
- package/packages/daemon/dist/cloud-token.d.ts +3 -0
- package/packages/daemon/dist/cloud-token.d.ts.map +1 -0
- package/packages/daemon/dist/cloud-token.js +37 -0
- package/packages/daemon/dist/cloud-token.js.map +1 -0
- package/packages/daemon/dist/commands.d.ts +25 -0
- package/packages/daemon/dist/commands.d.ts.map +1 -0
- package/packages/daemon/dist/commands.js +81 -0
- package/packages/daemon/dist/commands.js.map +1 -0
- package/packages/daemon/dist/config.d.ts +17 -0
- package/packages/daemon/dist/config.d.ts.map +1 -0
- package/packages/daemon/dist/config.js +146 -0
- package/packages/daemon/dist/config.js.map +1 -0
- package/packages/daemon/dist/daemon.d.ts +38 -0
- package/packages/daemon/dist/daemon.d.ts.map +1 -0
- package/packages/daemon/dist/daemon.js +194 -0
- package/packages/daemon/dist/daemon.js.map +1 -0
- package/packages/daemon/dist/daemon.test.d.ts +2 -0
- package/packages/daemon/dist/daemon.test.d.ts.map +1 -0
- package/packages/daemon/dist/daemon.test.js +692 -0
- package/packages/daemon/dist/daemon.test.js.map +1 -0
- package/packages/daemon/dist/discord-bot.d.ts +70 -0
- package/packages/daemon/dist/discord-bot.d.ts.map +1 -0
- package/packages/daemon/dist/discord-bot.js +433 -0
- package/packages/daemon/dist/discord-bot.js.map +1 -0
- package/packages/daemon/dist/discord-bot.test.d.ts +2 -0
- package/packages/daemon/dist/discord-bot.test.d.ts.map +1 -0
- package/packages/daemon/dist/discord-bot.test.js +667 -0
- package/packages/daemon/dist/discord-bot.test.js.map +1 -0
- package/packages/daemon/dist/event-bridge.d.ts +72 -0
- package/packages/daemon/dist/event-bridge.d.ts.map +1 -0
- package/packages/daemon/dist/event-bridge.js +366 -0
- package/packages/daemon/dist/event-bridge.js.map +1 -0
- package/packages/daemon/dist/event-bridge.test.d.ts +9 -0
- package/packages/daemon/dist/event-bridge.test.d.ts.map +1 -0
- package/packages/daemon/dist/event-bridge.test.js +528 -0
- package/packages/daemon/dist/event-bridge.test.js.map +1 -0
- package/packages/daemon/dist/event-formatter.d.ts +34 -0
- package/packages/daemon/dist/event-formatter.d.ts.map +1 -0
- package/packages/daemon/dist/event-formatter.js +355 -0
- package/packages/daemon/dist/event-formatter.js.map +1 -0
- package/packages/daemon/dist/event-formatter.test.d.ts +2 -0
- package/packages/daemon/dist/event-formatter.test.d.ts.map +1 -0
- package/packages/daemon/dist/event-formatter.test.js +333 -0
- package/packages/daemon/dist/event-formatter.test.js.map +1 -0
- package/packages/daemon/dist/index.d.ts +25 -0
- package/packages/daemon/dist/index.d.ts.map +1 -0
- package/packages/daemon/dist/index.js +17 -0
- package/packages/daemon/dist/index.js.map +1 -0
- package/packages/daemon/dist/launchd.d.ts +49 -0
- package/packages/daemon/dist/launchd.d.ts.map +1 -0
- package/packages/daemon/dist/launchd.js +188 -0
- package/packages/daemon/dist/launchd.js.map +1 -0
- package/packages/daemon/dist/launchd.test.d.ts +2 -0
- package/packages/daemon/dist/launchd.test.d.ts.map +1 -0
- package/packages/daemon/dist/launchd.test.js +296 -0
- package/packages/daemon/dist/launchd.test.js.map +1 -0
- package/packages/daemon/dist/local-tool-executor.d.ts +22 -0
- package/packages/daemon/dist/local-tool-executor.d.ts.map +1 -0
- package/packages/daemon/dist/local-tool-executor.js +307 -0
- package/packages/daemon/dist/local-tool-executor.js.map +1 -0
- package/packages/daemon/dist/local-tool-executor.test.d.ts +2 -0
- package/packages/daemon/dist/local-tool-executor.test.d.ts.map +1 -0
- package/packages/daemon/dist/local-tool-executor.test.js +111 -0
- package/packages/daemon/dist/local-tool-executor.test.js.map +1 -0
- package/packages/daemon/dist/logger.d.ts +25 -0
- package/packages/daemon/dist/logger.d.ts.map +1 -0
- package/packages/daemon/dist/logger.js +72 -0
- package/packages/daemon/dist/logger.js.map +1 -0
- package/packages/daemon/dist/mcp-cli.d.ts +3 -0
- package/packages/daemon/dist/mcp-cli.d.ts.map +1 -0
- package/packages/daemon/dist/mcp-cli.js +8 -0
- package/packages/daemon/dist/mcp-cli.js.map +1 -0
- package/packages/daemon/dist/mcp-cli.test.d.ts +2 -0
- package/packages/daemon/dist/mcp-cli.test.d.ts.map +1 -0
- package/packages/daemon/dist/mcp-cli.test.js +13 -0
- package/packages/daemon/dist/mcp-cli.test.js.map +1 -0
- package/packages/daemon/dist/mcp-runtime-cli.d.ts +3 -0
- package/packages/daemon/dist/mcp-runtime-cli.d.ts.map +1 -0
- package/packages/daemon/dist/mcp-runtime-cli.js +8 -0
- package/packages/daemon/dist/mcp-runtime-cli.js.map +1 -0
- package/packages/daemon/dist/message-batcher.d.ts +78 -0
- package/packages/daemon/dist/message-batcher.d.ts.map +1 -0
- package/packages/daemon/dist/message-batcher.js +173 -0
- package/packages/daemon/dist/message-batcher.js.map +1 -0
- package/packages/daemon/dist/message-batcher.test.d.ts +2 -0
- package/packages/daemon/dist/message-batcher.test.d.ts.map +1 -0
- package/packages/daemon/dist/message-batcher.test.js +242 -0
- package/packages/daemon/dist/message-batcher.test.js.map +1 -0
- package/packages/daemon/dist/orchestrator.d.ts +98 -0
- package/packages/daemon/dist/orchestrator.d.ts.map +1 -0
- package/packages/daemon/dist/orchestrator.js +359 -0
- package/packages/daemon/dist/orchestrator.js.map +1 -0
- package/packages/daemon/dist/orchestrator.test.d.ts +8 -0
- package/packages/daemon/dist/orchestrator.test.d.ts.map +1 -0
- package/packages/daemon/dist/orchestrator.test.js +425 -0
- package/packages/daemon/dist/orchestrator.test.js.map +1 -0
- package/packages/daemon/dist/project-scanner.d.ts +18 -0
- package/packages/daemon/dist/project-scanner.d.ts.map +1 -0
- package/packages/daemon/dist/project-scanner.js +90 -0
- package/packages/daemon/dist/project-scanner.js.map +1 -0
- package/packages/daemon/dist/project-scanner.test.d.ts +5 -0
- package/packages/daemon/dist/project-scanner.test.d.ts.map +1 -0
- package/packages/daemon/dist/project-scanner.test.js +183 -0
- package/packages/daemon/dist/project-scanner.test.js.map +1 -0
- package/packages/daemon/dist/session-manager.d.ts +70 -0
- package/packages/daemon/dist/session-manager.d.ts.map +1 -0
- package/packages/daemon/dist/session-manager.js +358 -0
- package/packages/daemon/dist/session-manager.js.map +1 -0
- package/packages/daemon/dist/session-manager.test.d.ts +9 -0
- package/packages/daemon/dist/session-manager.test.d.ts.map +1 -0
- package/packages/daemon/dist/session-manager.test.js +616 -0
- package/packages/daemon/dist/session-manager.test.js.map +1 -0
- package/packages/daemon/dist/types.d.ts +133 -0
- package/packages/daemon/dist/types.d.ts.map +1 -0
- package/packages/daemon/dist/types.js +8 -0
- package/packages/daemon/dist/types.js.map +1 -0
- package/packages/daemon/dist/verbosity.d.ts +27 -0
- package/packages/daemon/dist/verbosity.d.ts.map +1 -0
- package/packages/daemon/dist/verbosity.js +86 -0
- package/packages/daemon/dist/verbosity.js.map +1 -0
- package/packages/daemon/dist/verbosity.test.d.ts +2 -0
- package/packages/daemon/dist/verbosity.test.d.ts.map +1 -0
- package/packages/daemon/dist/verbosity.test.js +136 -0
- package/packages/daemon/dist/verbosity.test.js.map +1 -0
- package/packages/daemon/package.json +9 -8
- package/packages/gsd-agent-core/package.json +6 -6
- package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js +3 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js +0 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.d.ts +1 -0
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.js +1 -0
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.js.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.js +2 -1
- package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/packages/gsd-agent-modes/package.json +8 -8
- package/packages/mcp-server/bin/gsd-mcp-server.js +14 -0
- package/packages/mcp-server/package.json +6 -5
- package/packages/native/package.json +3 -3
- package/packages/pi-agent-core/package.json +4 -4
- package/packages/pi-ai/bin/pi-ai.js +14 -0
- package/packages/pi-ai/dist/models.generated.d.ts +0 -17
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +18 -35
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/package.json +5 -4
- package/packages/pi-coding-agent/dist/core/tools/read.d.ts +2 -2
- package/packages/pi-coding-agent/dist/core/tools/read.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/read.js +5 -3
- package/packages/pi-coding-agent/dist/core/tools/read.js.map +1 -1
- package/packages/pi-coding-agent/package.json +9 -9
- package/packages/pi-tui/package.json +2 -2
- package/packages/rpc-client/package.json +3 -3
- package/pkg/package.json +1 -1
- package/scripts/ensure-workspace-builds.cjs +4 -4
- package/scripts/install/deps.js +10 -0
- package/src/resources/extensions/gsd/auto/loop.ts +22 -0
- package/src/resources/extensions/gsd/auto/phases.ts +1 -1
- package/src/resources/extensions/gsd/auto-worktree.ts +2 -56
- package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +64 -0
- package/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +141 -1
- package/src/resources/extensions/gsd/worktree-post-create-hook.ts +127 -0
- package/dist/tsconfig.extensions.tsbuildinfo +0 -1
- /package/dist/web/standalone/.next/static/{JP7xjsa5zSaO76XhE-mFJ → spUYLkQXoHJyxYOMH9VQy}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{JP7xjsa5zSaO76XhE-mFJ → spUYLkQXoHJyxYOMH9VQy}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import { describe, it, afterEach, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir, homedir } from 'node:os';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { resolveConfigPath, loadConfig, validateConfig } from './config.js';
|
|
11
|
+
import { Logger } from './logger.js';
|
|
12
|
+
import { Daemon } from './daemon.js';
|
|
13
|
+
import { CloudRuntime } from './cloud-runtime.js';
|
|
14
|
+
import { SessionManager } from './session-manager.js';
|
|
15
|
+
// ---------- helpers ----------
|
|
16
|
+
function tmpDir() {
|
|
17
|
+
return mkdtempSync(join(tmpdir(), `daemon-test-${randomUUID().slice(0, 8)}-`));
|
|
18
|
+
}
|
|
19
|
+
const cleanupDirs = [];
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
while (cleanupDirs.length) {
|
|
22
|
+
const d = cleanupDirs.pop();
|
|
23
|
+
if (existsSync(d))
|
|
24
|
+
rmSync(d, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
it('cloud runtime handles gateway cancel for in-flight requests', async () => {
|
|
28
|
+
let finishTool;
|
|
29
|
+
const calls = [];
|
|
30
|
+
const executor = {
|
|
31
|
+
execute: (toolName, args, projectAlias) => {
|
|
32
|
+
calls.push({ toolName, args, projectAlias });
|
|
33
|
+
if (toolName === 'gsd_cancel')
|
|
34
|
+
return Promise.resolve({ content: [] });
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
finishTool = resolve;
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
advertisedProjects: async () => [],
|
|
40
|
+
};
|
|
41
|
+
const sent = [];
|
|
42
|
+
const runtime = new CloudRuntime({ gateway_url: 'ws://127.0.0.1:1', device_token: 'token', runtime_id: 'runtime' }, executor, { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined });
|
|
43
|
+
runtime.socket = {
|
|
44
|
+
readyState: 1,
|
|
45
|
+
send: (payload) => sent.push(JSON.parse(payload)),
|
|
46
|
+
};
|
|
47
|
+
const running = runtime.handleMessage(JSON.stringify({
|
|
48
|
+
type: 'tool_call',
|
|
49
|
+
requestId: 'request-1',
|
|
50
|
+
toolName: 'gsd_progress',
|
|
51
|
+
args: { projectDir: '/project' },
|
|
52
|
+
}));
|
|
53
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
54
|
+
await runtime.handleMessage(JSON.stringify({
|
|
55
|
+
type: 'cancel',
|
|
56
|
+
requestId: 'request-1',
|
|
57
|
+
}));
|
|
58
|
+
finishTool({ late: true });
|
|
59
|
+
await running;
|
|
60
|
+
assert.deepEqual(calls.map((call) => call.toolName), ['gsd_progress', 'gsd_cancel']);
|
|
61
|
+
assert.deepEqual(calls[1], { toolName: 'gsd_cancel', args: { projectDir: '/project' }, projectAlias: undefined });
|
|
62
|
+
assert.deepEqual(sent, []);
|
|
63
|
+
});
|
|
64
|
+
// ---------- config ----------
|
|
65
|
+
describe('resolveConfigPath', () => {
|
|
66
|
+
it('prefers explicit CLI path', () => {
|
|
67
|
+
const p = resolveConfigPath('/custom/config.yaml');
|
|
68
|
+
assert.equal(p, '/custom/config.yaml');
|
|
69
|
+
});
|
|
70
|
+
it('expands ~ in CLI path', () => {
|
|
71
|
+
const p = resolveConfigPath('~/my-daemon.yaml');
|
|
72
|
+
assert.ok(p.startsWith(homedir()));
|
|
73
|
+
assert.ok(p.endsWith('my-daemon.yaml'));
|
|
74
|
+
});
|
|
75
|
+
it('falls back to GSD_DAEMON_CONFIG env var', () => {
|
|
76
|
+
const prev = process.env['GSD_DAEMON_CONFIG'];
|
|
77
|
+
try {
|
|
78
|
+
process.env['GSD_DAEMON_CONFIG'] = '/env/path.yaml';
|
|
79
|
+
const p = resolveConfigPath();
|
|
80
|
+
assert.equal(p, '/env/path.yaml');
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (prev === undefined)
|
|
84
|
+
delete process.env['GSD_DAEMON_CONFIG'];
|
|
85
|
+
else
|
|
86
|
+
process.env['GSD_DAEMON_CONFIG'] = prev;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
it('defaults to ~/.gsd/daemon.yaml', () => {
|
|
90
|
+
const prev = process.env['GSD_DAEMON_CONFIG'];
|
|
91
|
+
try {
|
|
92
|
+
delete process.env['GSD_DAEMON_CONFIG'];
|
|
93
|
+
const p = resolveConfigPath();
|
|
94
|
+
assert.equal(p, join(homedir(), '.gsd', 'daemon.yaml'));
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if (prev !== undefined)
|
|
98
|
+
process.env['GSD_DAEMON_CONFIG'] = prev;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('loadConfig', () => {
|
|
103
|
+
// Save and clear DISCORD_BOT_TOKEN for this suite — env override interferes with file-token assertions
|
|
104
|
+
let savedToken;
|
|
105
|
+
before(() => {
|
|
106
|
+
savedToken = process.env['DISCORD_BOT_TOKEN'];
|
|
107
|
+
delete process.env['DISCORD_BOT_TOKEN'];
|
|
108
|
+
});
|
|
109
|
+
afterEach(() => { }); // cleanup dirs handled by top-level afterEach
|
|
110
|
+
// Restore after all tests in this suite
|
|
111
|
+
after(() => {
|
|
112
|
+
if (savedToken !== undefined)
|
|
113
|
+
process.env['DISCORD_BOT_TOKEN'] = savedToken;
|
|
114
|
+
});
|
|
115
|
+
it('parses valid YAML config', () => {
|
|
116
|
+
const dir = tmpDir();
|
|
117
|
+
cleanupDirs.push(dir);
|
|
118
|
+
const configPath = join(dir, 'daemon.yaml');
|
|
119
|
+
writeFileSync(configPath, `
|
|
120
|
+
discord:
|
|
121
|
+
token: "fixture"
|
|
122
|
+
guild_id: "g1"
|
|
123
|
+
owner_id: "o1"
|
|
124
|
+
projects:
|
|
125
|
+
scan_roots:
|
|
126
|
+
- ~/projects
|
|
127
|
+
- /absolute/path
|
|
128
|
+
log:
|
|
129
|
+
file: ~/logs/daemon.log
|
|
130
|
+
level: debug
|
|
131
|
+
max_size_mb: 100
|
|
132
|
+
`);
|
|
133
|
+
const cfg = loadConfig(configPath);
|
|
134
|
+
assert.equal(cfg.discord?.token, 'fixture');
|
|
135
|
+
assert.equal(cfg.discord?.guild_id, 'g1');
|
|
136
|
+
assert.equal(cfg.log.level, 'debug');
|
|
137
|
+
assert.equal(cfg.log.max_size_mb, 100);
|
|
138
|
+
assert.ok(cfg.log.file.startsWith(homedir()));
|
|
139
|
+
assert.ok(cfg.projects.scan_roots[0].startsWith(homedir()));
|
|
140
|
+
assert.equal(cfg.projects.scan_roots[1], '/absolute/path');
|
|
141
|
+
});
|
|
142
|
+
it('returns defaults when config file is missing', () => {
|
|
143
|
+
const cfg = loadConfig('/nonexistent/path/daemon.yaml');
|
|
144
|
+
assert.equal(cfg.log.level, 'info');
|
|
145
|
+
assert.equal(cfg.log.max_size_mb, 50);
|
|
146
|
+
assert.ok(cfg.log.file.endsWith('daemon.log'));
|
|
147
|
+
assert.deepEqual(cfg.projects.scan_roots, []);
|
|
148
|
+
assert.equal(cfg.discord, undefined);
|
|
149
|
+
});
|
|
150
|
+
it('throws on malformed YAML', () => {
|
|
151
|
+
const dir = tmpDir();
|
|
152
|
+
cleanupDirs.push(dir);
|
|
153
|
+
const configPath = join(dir, 'bad.yaml');
|
|
154
|
+
writeFileSync(configPath, ':\n :\n bad: [unclosed');
|
|
155
|
+
assert.throws(() => loadConfig(configPath), (err) => {
|
|
156
|
+
assert.ok(err instanceof Error);
|
|
157
|
+
assert.ok(err.message.includes('Failed to parse YAML'));
|
|
158
|
+
assert.ok(err.message.includes(configPath));
|
|
159
|
+
return true;
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
it('returns defaults for empty YAML file', () => {
|
|
163
|
+
const dir = tmpDir();
|
|
164
|
+
cleanupDirs.push(dir);
|
|
165
|
+
const configPath = join(dir, 'empty.yaml');
|
|
166
|
+
writeFileSync(configPath, '');
|
|
167
|
+
const cfg = loadConfig(configPath);
|
|
168
|
+
assert.equal(cfg.log.level, 'info');
|
|
169
|
+
assert.equal(cfg.log.max_size_mb, 50);
|
|
170
|
+
assert.deepEqual(cfg.projects.scan_roots, []);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
describe('validateConfig', () => {
|
|
174
|
+
// Save and clear DISCORD_BOT_TOKEN for tests that don't expect it
|
|
175
|
+
let savedToken;
|
|
176
|
+
before(() => {
|
|
177
|
+
savedToken = process.env['DISCORD_BOT_TOKEN'];
|
|
178
|
+
delete process.env['DISCORD_BOT_TOKEN'];
|
|
179
|
+
});
|
|
180
|
+
after(() => {
|
|
181
|
+
if (savedToken !== undefined)
|
|
182
|
+
process.env['DISCORD_BOT_TOKEN'] = savedToken;
|
|
183
|
+
});
|
|
184
|
+
it('fills remaining defaults for partial config', () => {
|
|
185
|
+
const cfg = validateConfig({ projects: { scan_roots: ['/a'] } });
|
|
186
|
+
assert.equal(cfg.log.level, 'info');
|
|
187
|
+
assert.equal(cfg.log.max_size_mb, 50);
|
|
188
|
+
assert.ok(cfg.log.file.endsWith('daemon.log'));
|
|
189
|
+
assert.deepEqual(cfg.projects.scan_roots, ['/a']);
|
|
190
|
+
assert.equal(cfg.discord, undefined);
|
|
191
|
+
});
|
|
192
|
+
it('falls back to info for invalid log level', () => {
|
|
193
|
+
const cfg = validateConfig({ log: { level: 'trace' } });
|
|
194
|
+
assert.equal(cfg.log.level, 'info');
|
|
195
|
+
});
|
|
196
|
+
it('returns full defaults for null input', () => {
|
|
197
|
+
const cfg = validateConfig(null);
|
|
198
|
+
assert.equal(cfg.log.level, 'info');
|
|
199
|
+
assert.equal(cfg.log.max_size_mb, 50);
|
|
200
|
+
});
|
|
201
|
+
it('returns full defaults for non-object input', () => {
|
|
202
|
+
const cfg = validateConfig('not-an-object');
|
|
203
|
+
assert.equal(cfg.log.level, 'info');
|
|
204
|
+
});
|
|
205
|
+
it('expands ~ in log file path', () => {
|
|
206
|
+
const cfg = validateConfig({ log: { file: '~/my.log' } });
|
|
207
|
+
assert.ok(cfg.log.file.startsWith(homedir()));
|
|
208
|
+
assert.ok(cfg.log.file.endsWith('my.log'));
|
|
209
|
+
});
|
|
210
|
+
it('overrides discord token from DISCORD_BOT_TOKEN env var', () => {
|
|
211
|
+
const prev = process.env['DISCORD_BOT_TOKEN'];
|
|
212
|
+
try {
|
|
213
|
+
process.env['DISCORD_BOT_TOKEN'] = 'env-override-token';
|
|
214
|
+
const cfg = validateConfig({
|
|
215
|
+
discord: { token: 'fixture', guild_id: 'g1', owner_id: 'o1' },
|
|
216
|
+
});
|
|
217
|
+
assert.equal(cfg.discord?.token, 'env-override-token');
|
|
218
|
+
assert.equal(cfg.discord?.guild_id, 'g1');
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
if (prev === undefined)
|
|
222
|
+
delete process.env['DISCORD_BOT_TOKEN'];
|
|
223
|
+
else
|
|
224
|
+
process.env['DISCORD_BOT_TOKEN'] = prev;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
it('creates discord block from env var even when absent in config', () => {
|
|
228
|
+
const prev = process.env['DISCORD_BOT_TOKEN'];
|
|
229
|
+
try {
|
|
230
|
+
process.env['DISCORD_BOT_TOKEN'] = 'env-only-token';
|
|
231
|
+
const cfg = validateConfig({});
|
|
232
|
+
assert.equal(cfg.discord?.token, 'env-only-token');
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
if (prev === undefined)
|
|
236
|
+
delete process.env['DISCORD_BOT_TOKEN'];
|
|
237
|
+
else
|
|
238
|
+
process.env['DISCORD_BOT_TOKEN'] = prev;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
// ---------- logger ----------
|
|
243
|
+
describe('Logger', () => {
|
|
244
|
+
it('writes JSON-lines entries to file', async () => {
|
|
245
|
+
const dir = tmpDir();
|
|
246
|
+
cleanupDirs.push(dir);
|
|
247
|
+
const logPath = join(dir, 'test.log');
|
|
248
|
+
const logger = new Logger({ filePath: logPath, level: 'debug' });
|
|
249
|
+
logger.info('hello world');
|
|
250
|
+
logger.debug('detail', { key: 'val' });
|
|
251
|
+
await logger.close();
|
|
252
|
+
const lines = readFileSync(logPath, 'utf-8').trim().split('\n');
|
|
253
|
+
assert.equal(lines.length, 2);
|
|
254
|
+
const entry0 = JSON.parse(lines[0]);
|
|
255
|
+
assert.equal(entry0.level, 'info');
|
|
256
|
+
assert.equal(entry0.msg, 'hello world');
|
|
257
|
+
assert.ok(entry0.ts); // ISO-8601
|
|
258
|
+
const entry1 = JSON.parse(lines[1]);
|
|
259
|
+
assert.equal(entry1.level, 'debug');
|
|
260
|
+
assert.equal(entry1.msg, 'detail');
|
|
261
|
+
assert.deepEqual(entry1.data, { key: 'val' });
|
|
262
|
+
});
|
|
263
|
+
it('filters entries below configured level', async () => {
|
|
264
|
+
const dir = tmpDir();
|
|
265
|
+
cleanupDirs.push(dir);
|
|
266
|
+
const logPath = join(dir, 'filter.log');
|
|
267
|
+
const logger = new Logger({ filePath: logPath, level: 'warn' });
|
|
268
|
+
logger.debug('should not appear');
|
|
269
|
+
logger.info('should not appear either');
|
|
270
|
+
logger.warn('visible warning');
|
|
271
|
+
logger.error('visible error');
|
|
272
|
+
await logger.close();
|
|
273
|
+
const lines = readFileSync(logPath, 'utf-8').trim().split('\n');
|
|
274
|
+
assert.equal(lines.length, 2);
|
|
275
|
+
assert.equal(JSON.parse(lines[0]).level, 'warn');
|
|
276
|
+
assert.equal(JSON.parse(lines[1]).level, 'error');
|
|
277
|
+
});
|
|
278
|
+
it('close() resolves after stream ends', async () => {
|
|
279
|
+
const dir = tmpDir();
|
|
280
|
+
cleanupDirs.push(dir);
|
|
281
|
+
const logPath = join(dir, 'close.log');
|
|
282
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
283
|
+
logger.info('before close');
|
|
284
|
+
await logger.close();
|
|
285
|
+
// File should be readable and contain the entry
|
|
286
|
+
const content = readFileSync(logPath, 'utf-8');
|
|
287
|
+
assert.ok(content.includes('before close'));
|
|
288
|
+
});
|
|
289
|
+
it('creates parent directories if they do not exist', async () => {
|
|
290
|
+
const dir = tmpDir();
|
|
291
|
+
cleanupDirs.push(dir);
|
|
292
|
+
const logPath = join(dir, 'nested', 'deep', 'test.log');
|
|
293
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
294
|
+
logger.info('nested dir test');
|
|
295
|
+
await logger.close();
|
|
296
|
+
assert.ok(existsSync(logPath));
|
|
297
|
+
const content = readFileSync(logPath, 'utf-8');
|
|
298
|
+
assert.ok(content.includes('nested dir test'));
|
|
299
|
+
});
|
|
300
|
+
it('does not include data field when not provided', async () => {
|
|
301
|
+
const dir = tmpDir();
|
|
302
|
+
cleanupDirs.push(dir);
|
|
303
|
+
const logPath = join(dir, 'nodata.log');
|
|
304
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
305
|
+
logger.info('no extra data');
|
|
306
|
+
await logger.close();
|
|
307
|
+
const entry = JSON.parse(readFileSync(logPath, 'utf-8').trim());
|
|
308
|
+
assert.equal(entry.data, undefined);
|
|
309
|
+
// Also verify the raw JSON doesn't contain "data" key
|
|
310
|
+
assert.ok(!readFileSync(logPath, 'utf-8').includes('"data"'));
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
// ---------- token safety ----------
|
|
314
|
+
describe('token safety', () => {
|
|
315
|
+
it('discord token never appears in log output', async () => {
|
|
316
|
+
const dir = tmpDir();
|
|
317
|
+
cleanupDirs.push(dir);
|
|
318
|
+
const logPath = join(dir, 'token-safety.log');
|
|
319
|
+
// Config with a token
|
|
320
|
+
const prev = process.env['DISCORD_BOT_TOKEN'];
|
|
321
|
+
try {
|
|
322
|
+
process.env['DISCORD_BOT_TOKEN'] = 'super-secret-token-value';
|
|
323
|
+
const cfg = validateConfig({});
|
|
324
|
+
const logger = new Logger({ filePath: logPath, level: 'debug' });
|
|
325
|
+
// Log the config object — token must not leak
|
|
326
|
+
logger.info('config loaded', { discord_configured: !!cfg.discord });
|
|
327
|
+
logger.debug('startup complete');
|
|
328
|
+
await logger.close();
|
|
329
|
+
const content = readFileSync(logPath, 'utf-8');
|
|
330
|
+
assert.ok(!content.includes('super-secret-token-value'));
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
if (prev === undefined)
|
|
334
|
+
delete process.env['DISCORD_BOT_TOKEN'];
|
|
335
|
+
else
|
|
336
|
+
process.env['DISCORD_BOT_TOKEN'] = prev;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
// ---------- daemon lifecycle ----------
|
|
341
|
+
// Resolve the dist/ directory for spawning CLI
|
|
342
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
343
|
+
const __dirname = dirname(__filename);
|
|
344
|
+
describe('Daemon', () => {
|
|
345
|
+
it('logs lifecycle events on start and shutdown', async () => {
|
|
346
|
+
const dir = tmpDir();
|
|
347
|
+
cleanupDirs.push(dir);
|
|
348
|
+
const logPath = join(dir, 'daemon-lifecycle.log');
|
|
349
|
+
const config = {
|
|
350
|
+
discord: undefined,
|
|
351
|
+
projects: { scan_roots: ['/a', '/b'] },
|
|
352
|
+
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
353
|
+
};
|
|
354
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
355
|
+
const daemon = new Daemon(config, logger);
|
|
356
|
+
await daemon.start();
|
|
357
|
+
// start() should have logged 'daemon started'
|
|
358
|
+
// shutdown() directly — we override process.exit to prevent test runner from dying
|
|
359
|
+
const origExit = process.exit;
|
|
360
|
+
let exitCode;
|
|
361
|
+
// @ts-expect-error — overriding process.exit for test
|
|
362
|
+
process.exit = (code) => { exitCode = code ?? 0; };
|
|
363
|
+
try {
|
|
364
|
+
await daemon.shutdown();
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
process.exit = origExit;
|
|
368
|
+
}
|
|
369
|
+
assert.equal(exitCode, 0);
|
|
370
|
+
const content = readFileSync(logPath, 'utf-8');
|
|
371
|
+
const lines = content.trim().split('\n');
|
|
372
|
+
// First line: daemon started
|
|
373
|
+
const startEntry = JSON.parse(lines[0]);
|
|
374
|
+
assert.equal(startEntry.msg, 'daemon started');
|
|
375
|
+
assert.equal(startEntry.data?.scan_roots, 2);
|
|
376
|
+
assert.equal(startEntry.data?.discord_configured, false);
|
|
377
|
+
// Second line: daemon shutting down
|
|
378
|
+
const stopEntry = JSON.parse(lines[1]);
|
|
379
|
+
assert.equal(stopEntry.msg, 'daemon shutting down');
|
|
380
|
+
});
|
|
381
|
+
it('shutdown is idempotent — second call is a no-op', async () => {
|
|
382
|
+
const dir = tmpDir();
|
|
383
|
+
cleanupDirs.push(dir);
|
|
384
|
+
const logPath = join(dir, 'idempotent.log');
|
|
385
|
+
const config = {
|
|
386
|
+
discord: undefined,
|
|
387
|
+
projects: { scan_roots: [] },
|
|
388
|
+
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
389
|
+
};
|
|
390
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
391
|
+
const daemon = new Daemon(config, logger);
|
|
392
|
+
await daemon.start();
|
|
393
|
+
const origExit = process.exit;
|
|
394
|
+
let exitCount = 0;
|
|
395
|
+
// @ts-expect-error — overriding process.exit for test
|
|
396
|
+
process.exit = () => { exitCount++; };
|
|
397
|
+
try {
|
|
398
|
+
await daemon.shutdown();
|
|
399
|
+
await daemon.shutdown(); // second call — should be no-op
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
process.exit = origExit;
|
|
403
|
+
}
|
|
404
|
+
assert.equal(exitCount, 1, 'process.exit should be called exactly once');
|
|
405
|
+
const lines = readFileSync(logPath, 'utf-8').trim().split('\n');
|
|
406
|
+
const shutdownLines = lines.filter(l => {
|
|
407
|
+
const e = JSON.parse(l);
|
|
408
|
+
return e.msg === 'daemon shutting down';
|
|
409
|
+
});
|
|
410
|
+
assert.equal(shutdownLines.length, 1, 'shutdown log should appear exactly once');
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
// ---------- Health heartbeat ----------
|
|
414
|
+
describe('Health heartbeat', () => {
|
|
415
|
+
it('logs health entry with expected fields after interval tick', async () => {
|
|
416
|
+
const dir = tmpDir();
|
|
417
|
+
cleanupDirs.push(dir);
|
|
418
|
+
const logPath = join(dir, 'health.log');
|
|
419
|
+
const config = {
|
|
420
|
+
discord: undefined,
|
|
421
|
+
projects: { scan_roots: [] },
|
|
422
|
+
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
423
|
+
};
|
|
424
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
425
|
+
// Use 50ms interval for fast test
|
|
426
|
+
const daemon = new Daemon(config, logger, 50);
|
|
427
|
+
await daemon.start();
|
|
428
|
+
// Wait for at least one health tick
|
|
429
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
430
|
+
const origExit = process.exit;
|
|
431
|
+
// @ts-expect-error — overriding process.exit for test
|
|
432
|
+
process.exit = () => { };
|
|
433
|
+
try {
|
|
434
|
+
await daemon.shutdown();
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
process.exit = origExit;
|
|
438
|
+
}
|
|
439
|
+
const content = readFileSync(logPath, 'utf-8');
|
|
440
|
+
const lines = content.trim().split('\n');
|
|
441
|
+
const healthLines = lines.filter((l) => {
|
|
442
|
+
const e = JSON.parse(l);
|
|
443
|
+
return e.msg === 'health';
|
|
444
|
+
});
|
|
445
|
+
assert.ok(healthLines.length >= 1, 'should have at least one health log entry');
|
|
446
|
+
const entry = JSON.parse(healthLines[0]);
|
|
447
|
+
assert.equal(entry.msg, 'health');
|
|
448
|
+
assert.equal(typeof entry.data?.uptime_s, 'number');
|
|
449
|
+
assert.equal(typeof entry.data?.active_sessions, 'number');
|
|
450
|
+
assert.equal(typeof entry.data?.discord_connected, 'boolean');
|
|
451
|
+
assert.equal(typeof entry.data?.memory_rss_mb, 'number');
|
|
452
|
+
assert.equal(entry.data?.discord_connected, false); // no discord configured
|
|
453
|
+
assert.equal(entry.data?.active_sessions, 0); // no sessions
|
|
454
|
+
});
|
|
455
|
+
it('health timer is cleared on shutdown — no lingering intervals', async () => {
|
|
456
|
+
const dir = tmpDir();
|
|
457
|
+
cleanupDirs.push(dir);
|
|
458
|
+
const logPath = join(dir, 'health-cleanup.log');
|
|
459
|
+
const config = {
|
|
460
|
+
discord: undefined,
|
|
461
|
+
projects: { scan_roots: [] },
|
|
462
|
+
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
463
|
+
};
|
|
464
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
465
|
+
// Use 50ms interval
|
|
466
|
+
const daemon = new Daemon(config, logger, 50);
|
|
467
|
+
await daemon.start();
|
|
468
|
+
// Wait for one tick
|
|
469
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
470
|
+
const origExit = process.exit;
|
|
471
|
+
// @ts-expect-error — overriding process.exit for test
|
|
472
|
+
process.exit = () => { };
|
|
473
|
+
try {
|
|
474
|
+
await daemon.shutdown();
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
process.exit = origExit;
|
|
478
|
+
}
|
|
479
|
+
// Count health entries at shutdown
|
|
480
|
+
const contentAtShutdown = readFileSync(logPath, 'utf-8');
|
|
481
|
+
const healthCountAtShutdown = contentAtShutdown
|
|
482
|
+
.trim()
|
|
483
|
+
.split('\n')
|
|
484
|
+
.filter((l) => JSON.parse(l).msg === 'health').length;
|
|
485
|
+
// Wait another interval — no new health entries should appear
|
|
486
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
487
|
+
// Re-read (logger is closed, so file shouldn't change)
|
|
488
|
+
const contentAfterWait = readFileSync(logPath, 'utf-8');
|
|
489
|
+
const healthCountAfterWait = contentAfterWait
|
|
490
|
+
.trim()
|
|
491
|
+
.split('\n')
|
|
492
|
+
.filter((l) => JSON.parse(l).msg === 'health').length;
|
|
493
|
+
assert.equal(healthCountAfterWait, healthCountAtShutdown, 'no new health entries should appear after shutdown');
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
describe('CLI integration', () => {
|
|
497
|
+
it('--help prints usage and exits 0', () => {
|
|
498
|
+
const result = execFileSync(process.execPath, [join(__dirname, 'cli.js'), '--help'], { encoding: 'utf-8', timeout: 5000 });
|
|
499
|
+
assert.ok(result.includes('Usage: gsd-daemon'));
|
|
500
|
+
assert.ok(result.includes('--config'));
|
|
501
|
+
assert.ok(result.includes('--verbose'));
|
|
502
|
+
});
|
|
503
|
+
it('starts, logs to file, and exits cleanly on SIGTERM', { timeout: 15000 }, async () => {
|
|
504
|
+
const dir = tmpDir();
|
|
505
|
+
cleanupDirs.push(dir);
|
|
506
|
+
const logPath = join(dir, 'integration.log');
|
|
507
|
+
const configPath = join(dir, 'daemon.yaml');
|
|
508
|
+
writeFileSync(configPath, `
|
|
509
|
+
projects:
|
|
510
|
+
scan_roots:
|
|
511
|
+
- /tmp/test-project
|
|
512
|
+
log:
|
|
513
|
+
file: "${logPath}"
|
|
514
|
+
level: info
|
|
515
|
+
max_size_mb: 10
|
|
516
|
+
`);
|
|
517
|
+
// Use execFile with a wrapper script approach: spawn, wait for start, SIGTERM, verify
|
|
518
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
519
|
+
const child = spawn(process.execPath, [join(__dirname, 'cli.js'), '--config', configPath], { stdio: 'ignore' });
|
|
520
|
+
let resolved = false;
|
|
521
|
+
child.on('error', (err) => { if (!resolved) {
|
|
522
|
+
resolved = true;
|
|
523
|
+
reject(err);
|
|
524
|
+
} });
|
|
525
|
+
child.on('exit', (code) => { if (!resolved) {
|
|
526
|
+
resolved = true;
|
|
527
|
+
resolve(code ?? 1);
|
|
528
|
+
} });
|
|
529
|
+
// Poll for startup, then send SIGTERM
|
|
530
|
+
const poll = setInterval(() => {
|
|
531
|
+
if (existsSync(logPath)) {
|
|
532
|
+
const content = readFileSync(logPath, 'utf-8');
|
|
533
|
+
if (content.includes('daemon started')) {
|
|
534
|
+
clearInterval(poll);
|
|
535
|
+
child.kill('SIGTERM');
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}, 100);
|
|
539
|
+
// Safety: kill child if it takes too long
|
|
540
|
+
setTimeout(() => {
|
|
541
|
+
clearInterval(poll);
|
|
542
|
+
if (!resolved) {
|
|
543
|
+
child.kill('SIGKILL');
|
|
544
|
+
resolved = true;
|
|
545
|
+
reject(new Error('timed out waiting for daemon'));
|
|
546
|
+
}
|
|
547
|
+
}, 10000);
|
|
548
|
+
});
|
|
549
|
+
assert.equal(exitCode, 0, 'daemon should exit with code 0 on SIGTERM');
|
|
550
|
+
// Small delay for filesystem flush
|
|
551
|
+
await new Promise(r => setTimeout(r, 100));
|
|
552
|
+
// Verify log file contents
|
|
553
|
+
const finalContent = readFileSync(logPath, 'utf-8');
|
|
554
|
+
assert.ok(finalContent.includes('daemon started'), 'log should contain startup entry');
|
|
555
|
+
assert.ok(finalContent.includes('daemon shutting down'), 'log should contain shutdown entry');
|
|
556
|
+
// Verify log entries are valid JSON-lines
|
|
557
|
+
const lines = finalContent.trim().split('\n');
|
|
558
|
+
for (const line of lines) {
|
|
559
|
+
const entry = JSON.parse(line);
|
|
560
|
+
assert.ok(entry.ts, 'each entry should have a timestamp');
|
|
561
|
+
assert.ok(entry.level, 'each entry should have a level');
|
|
562
|
+
assert.ok(entry.msg, 'each entry should have a message');
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
it('exits with code 1 on invalid config', () => {
|
|
566
|
+
const dir = tmpDir();
|
|
567
|
+
cleanupDirs.push(dir);
|
|
568
|
+
const configPath = join(dir, 'bad.yaml');
|
|
569
|
+
writeFileSync(configPath, ':\n :\n bad: [unclosed');
|
|
570
|
+
try {
|
|
571
|
+
execFileSync(process.execPath, [join(__dirname, 'cli.js'), '--config', configPath], { encoding: 'utf-8', timeout: 5000 });
|
|
572
|
+
assert.fail('should have thrown');
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
// execFileSync throws on non-zero exit
|
|
576
|
+
const execErr = err;
|
|
577
|
+
assert.equal(execErr.status, 1);
|
|
578
|
+
assert.ok(execErr.stderr.includes('fatal'));
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
// ---------- Daemon + SessionManager integration ----------
|
|
583
|
+
describe('Daemon integration', () => {
|
|
584
|
+
it('getSessionManager() returns SessionManager after start()', async () => {
|
|
585
|
+
const dir = tmpDir();
|
|
586
|
+
cleanupDirs.push(dir);
|
|
587
|
+
const logPath = join(dir, 'daemon-sm.log');
|
|
588
|
+
const config = {
|
|
589
|
+
discord: undefined,
|
|
590
|
+
projects: { scan_roots: [] },
|
|
591
|
+
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
592
|
+
};
|
|
593
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
594
|
+
const daemon = new Daemon(config, logger);
|
|
595
|
+
await daemon.start();
|
|
596
|
+
const sm = daemon.getSessionManager();
|
|
597
|
+
assert.ok(sm instanceof SessionManager);
|
|
598
|
+
// Clean shutdown
|
|
599
|
+
const origExit = process.exit;
|
|
600
|
+
// @ts-expect-error — overriding process.exit for test
|
|
601
|
+
process.exit = () => { };
|
|
602
|
+
try {
|
|
603
|
+
await daemon.shutdown();
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
process.exit = origExit;
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
it('getSessionManager() throws before start()', async () => {
|
|
610
|
+
const dir = tmpDir();
|
|
611
|
+
cleanupDirs.push(dir);
|
|
612
|
+
const logPath = join(dir, 'daemon-nostart.log');
|
|
613
|
+
const config = {
|
|
614
|
+
discord: undefined,
|
|
615
|
+
projects: { scan_roots: [] },
|
|
616
|
+
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
617
|
+
};
|
|
618
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
619
|
+
const daemon = new Daemon(config, logger);
|
|
620
|
+
assert.throws(() => daemon.getSessionManager(), (err) => {
|
|
621
|
+
assert.ok(err.message.includes('Daemon not started'));
|
|
622
|
+
return true;
|
|
623
|
+
});
|
|
624
|
+
// Close logger to prevent async write stream from hitting cleaned-up tmpdir
|
|
625
|
+
await logger.close();
|
|
626
|
+
});
|
|
627
|
+
it('scanProjects() delegates to scanForProjects with configured roots', async () => {
|
|
628
|
+
const dir = tmpDir();
|
|
629
|
+
cleanupDirs.push(dir);
|
|
630
|
+
const logPath = join(dir, 'daemon-scan.log');
|
|
631
|
+
// Create a fake project root with a project that has a .git marker
|
|
632
|
+
const scanRoot = join(dir, 'projects');
|
|
633
|
+
mkdirSync(scanRoot);
|
|
634
|
+
const projectDir = join(scanRoot, 'my-project');
|
|
635
|
+
mkdirSync(projectDir);
|
|
636
|
+
mkdirSync(join(projectDir, '.git'));
|
|
637
|
+
const config = {
|
|
638
|
+
discord: undefined,
|
|
639
|
+
projects: { scan_roots: [scanRoot] },
|
|
640
|
+
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
641
|
+
};
|
|
642
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
643
|
+
const daemon = new Daemon(config, logger);
|
|
644
|
+
await daemon.start();
|
|
645
|
+
const projects = await daemon.scanProjects();
|
|
646
|
+
assert.ok(projects.length >= 1);
|
|
647
|
+
const found = projects.find(p => p.name === 'my-project');
|
|
648
|
+
assert.ok(found);
|
|
649
|
+
assert.ok(found.markers.includes('git'));
|
|
650
|
+
// Clean shutdown
|
|
651
|
+
const origExit = process.exit;
|
|
652
|
+
// @ts-expect-error — overriding process.exit for test
|
|
653
|
+
process.exit = () => { };
|
|
654
|
+
try {
|
|
655
|
+
await daemon.shutdown();
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
process.exit = origExit;
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
it('shutdown cleans up sessionManager before closing logger', async () => {
|
|
662
|
+
const dir = tmpDir();
|
|
663
|
+
cleanupDirs.push(dir);
|
|
664
|
+
const logPath = join(dir, 'daemon-cleanup.log');
|
|
665
|
+
const config = {
|
|
666
|
+
discord: undefined,
|
|
667
|
+
projects: { scan_roots: [] },
|
|
668
|
+
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
669
|
+
};
|
|
670
|
+
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
671
|
+
const daemon = new Daemon(config, logger);
|
|
672
|
+
await daemon.start();
|
|
673
|
+
// Access sessionManager to verify it exists
|
|
674
|
+
const sm = daemon.getSessionManager();
|
|
675
|
+
assert.ok(sm);
|
|
676
|
+
// Shutdown — should not throw even though sessionManager has no active sessions
|
|
677
|
+
const origExit = process.exit;
|
|
678
|
+
// @ts-expect-error — overriding process.exit for test
|
|
679
|
+
process.exit = () => { };
|
|
680
|
+
try {
|
|
681
|
+
await daemon.shutdown();
|
|
682
|
+
}
|
|
683
|
+
finally {
|
|
684
|
+
process.exit = origExit;
|
|
685
|
+
}
|
|
686
|
+
// Verify log contains both started and shutting down
|
|
687
|
+
const content = readFileSync(logPath, 'utf-8');
|
|
688
|
+
assert.ok(content.includes('daemon started'));
|
|
689
|
+
assert.ok(content.includes('daemon shutting down'));
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
//# sourceMappingURL=daemon.test.js.map
|