@hellcoder/companion 0.96.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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,1419 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdtempSync,
|
|
3
|
+
rmSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
|
|
12
|
+
let tempDir: string;
|
|
13
|
+
let service: typeof import("./service.js");
|
|
14
|
+
|
|
15
|
+
// ─── Mocks ─────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const mockHomedir = vi.hoisted(() => {
|
|
18
|
+
let dir = "";
|
|
19
|
+
return {
|
|
20
|
+
get: () => dir,
|
|
21
|
+
set: (d: string) => { dir = d; },
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const mockExecSync = vi.hoisted(() => {
|
|
26
|
+
return vi.fn<(cmd: string, opts?: object) => string>();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const mockPlatform = vi.hoisted(() => {
|
|
30
|
+
let platform = "darwin";
|
|
31
|
+
return {
|
|
32
|
+
get: () => platform,
|
|
33
|
+
set: (p: string) => { platform = p; },
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
vi.mock("node:os", async (importOriginal) => {
|
|
38
|
+
const actual = await importOriginal<typeof import("node:os")>();
|
|
39
|
+
return {
|
|
40
|
+
...actual,
|
|
41
|
+
homedir: () => mockHomedir.get(),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
46
|
+
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
47
|
+
return {
|
|
48
|
+
...actual,
|
|
49
|
+
execSync: mockExecSync,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Mock path-resolver to return a deterministic enriched PATH
|
|
54
|
+
const MOCK_SERVICE_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/mock/.bun/bin:/mock/.local/bin";
|
|
55
|
+
vi.mock("./path-resolver.js", () => ({
|
|
56
|
+
getServicePath: () => MOCK_SERVICE_PATH,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
// Mock process.platform
|
|
60
|
+
const originalPlatform = process.platform;
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
Object.defineProperty(process, "platform", {
|
|
64
|
+
value: originalPlatform,
|
|
65
|
+
writable: true,
|
|
66
|
+
configurable: true,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ─── Setup / teardown ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
tempDir = mkdtempSync(join(tmpdir(), "service-test-"));
|
|
74
|
+
mockHomedir.set(tempDir);
|
|
75
|
+
mockPlatform.set("darwin");
|
|
76
|
+
mockExecSync.mockReset();
|
|
77
|
+
|
|
78
|
+
// Set process.platform AFTER resetting mockPlatform to "darwin"
|
|
79
|
+
Object.defineProperty(process, "platform", {
|
|
80
|
+
value: mockPlatform.get(),
|
|
81
|
+
writable: true,
|
|
82
|
+
configurable: true,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Mock process.exit to throw instead of exiting
|
|
86
|
+
vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
|
|
87
|
+
throw new Error(`process.exit(${code})`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
vi.resetModules();
|
|
91
|
+
service = await import("./service.js");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
96
|
+
vi.restoreAllMocks();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function plistPath(): string {
|
|
102
|
+
return join(tempDir, "Library", "LaunchAgents", "sh.thecompanion.app.plist");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function oldPlistPath(): string {
|
|
106
|
+
return join(tempDir, "Library", "LaunchAgents", "co.thevibecompany.companion.plist");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unitPath(): string {
|
|
110
|
+
return join(tempDir, ".config", "systemd", "user", "the-companion.service");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function logDir(): string {
|
|
114
|
+
return join(tempDir, ".companion", "logs");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ===========================================================================
|
|
118
|
+
// generatePlist
|
|
119
|
+
// ===========================================================================
|
|
120
|
+
describe("generatePlist", () => {
|
|
121
|
+
it("generates valid XML with the correct label", () => {
|
|
122
|
+
const plist = service.generatePlist({ binPath: "/usr/local/bin/the-companion" });
|
|
123
|
+
expect(plist).toContain('<?xml version="1.0"');
|
|
124
|
+
expect(plist).toContain("<string>sh.thecompanion.app</string>");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("includes RunAtLoad true", () => {
|
|
128
|
+
const plist = service.generatePlist({ binPath: "/usr/local/bin/the-companion" });
|
|
129
|
+
expect(plist).toContain("<key>RunAtLoad</key>");
|
|
130
|
+
expect(plist).toContain("<true/>");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("includes KeepAlive with SuccessfulExit false", () => {
|
|
134
|
+
const plist = service.generatePlist({ binPath: "/usr/local/bin/the-companion" });
|
|
135
|
+
expect(plist).toContain("<key>KeepAlive</key>");
|
|
136
|
+
expect(plist).toContain("<key>SuccessfulExit</key>");
|
|
137
|
+
expect(plist).toContain("<false/>");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("uses the provided binary path in ProgramArguments", () => {
|
|
141
|
+
const plist = service.generatePlist({ binPath: "/opt/homebrew/bin/the-companion" });
|
|
142
|
+
expect(plist).toContain("<string>/opt/homebrew/bin/the-companion</string>");
|
|
143
|
+
expect(plist).toContain("<string>start</string>");
|
|
144
|
+
expect(plist).toContain("<string>--foreground</string>");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("uses the default production port when none specified", () => {
|
|
148
|
+
const plist = service.generatePlist({ binPath: "/usr/local/bin/the-companion" });
|
|
149
|
+
expect(plist).toContain("<key>PORT</key>");
|
|
150
|
+
expect(plist).toContain("<string>3456</string>");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("uses a custom port when specified", () => {
|
|
154
|
+
const plist = service.generatePlist({ binPath: "/usr/local/bin/the-companion", port: 8080 });
|
|
155
|
+
expect(plist).toContain("<string>8080</string>");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("includes NODE_ENV production", () => {
|
|
159
|
+
const plist = service.generatePlist({ binPath: "/usr/local/bin/the-companion" });
|
|
160
|
+
expect(plist).toContain("<key>NODE_ENV</key>");
|
|
161
|
+
expect(plist).toContain("<string>production</string>");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("uses enriched PATH from path-resolver when no path option given", () => {
|
|
165
|
+
const plist = service.generatePlist({ binPath: "/usr/local/bin/the-companion" });
|
|
166
|
+
expect(plist).toContain(MOCK_SERVICE_PATH);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("uses custom path option when provided", () => {
|
|
170
|
+
const customPath = "/custom/bin:/other/bin";
|
|
171
|
+
const plist = service.generatePlist({ binPath: "/usr/local/bin/the-companion", path: customPath });
|
|
172
|
+
expect(plist).toContain(customPath);
|
|
173
|
+
expect(plist).not.toContain(MOCK_SERVICE_PATH);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("includes ThrottleInterval", () => {
|
|
177
|
+
const plist = service.generatePlist({ binPath: "/usr/local/bin/the-companion" });
|
|
178
|
+
expect(plist).toContain("<key>ThrottleInterval</key>");
|
|
179
|
+
expect(plist).toContain("<integer>5</integer>");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ===========================================================================
|
|
184
|
+
// generateSystemdUnit
|
|
185
|
+
// ===========================================================================
|
|
186
|
+
describe("generateSystemdUnit", () => {
|
|
187
|
+
it("generates a valid systemd unit with correct sections", () => {
|
|
188
|
+
const unit = service.generateSystemdUnit({ binPath: "/usr/local/bin/the-companion" });
|
|
189
|
+
expect(unit).toContain("[Unit]");
|
|
190
|
+
expect(unit).toContain("[Service]");
|
|
191
|
+
expect(unit).toContain("[Install]");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("includes the description", () => {
|
|
195
|
+
const unit = service.generateSystemdUnit({ binPath: "/usr/local/bin/the-companion" });
|
|
196
|
+
expect(unit).toContain("Description=The Companion");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("uses the provided binary path in ExecStart", () => {
|
|
200
|
+
const unit = service.generateSystemdUnit({ binPath: "/home/user/.bun/bin/the-companion" });
|
|
201
|
+
expect(unit).toContain("ExecStart=/home/user/.bun/bin/the-companion start --foreground");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("uses the default production port when none specified", () => {
|
|
205
|
+
const unit = service.generateSystemdUnit({ binPath: "/usr/local/bin/the-companion" });
|
|
206
|
+
expect(unit).toContain("Environment=PORT=3456");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("uses a custom port when specified", () => {
|
|
210
|
+
const unit = service.generateSystemdUnit({ binPath: "/usr/local/bin/the-companion", port: 8080 });
|
|
211
|
+
expect(unit).toContain("Environment=PORT=8080");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("includes NODE_ENV production", () => {
|
|
215
|
+
const unit = service.generateSystemdUnit({ binPath: "/usr/local/bin/the-companion" });
|
|
216
|
+
expect(unit).toContain("Environment=NODE_ENV=production");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("includes restart always with graceful update exit code", () => {
|
|
220
|
+
const unit = service.generateSystemdUnit({ binPath: "/usr/local/bin/the-companion" });
|
|
221
|
+
expect(unit).toContain("Restart=always");
|
|
222
|
+
expect(unit).toContain("RestartSec=5");
|
|
223
|
+
expect(unit).toContain("SuccessExitStatus=42");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("uses enriched PATH from path-resolver when no path option given", () => {
|
|
227
|
+
const unit = service.generateSystemdUnit({ binPath: "/usr/local/bin/the-companion" });
|
|
228
|
+
expect(unit).toContain(`Environment=PATH=${MOCK_SERVICE_PATH}`);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("uses custom path option when provided", () => {
|
|
232
|
+
const customPath = "/custom/bin:/other/bin";
|
|
233
|
+
const unit = service.generateSystemdUnit({ binPath: "/usr/local/bin/the-companion", path: customPath });
|
|
234
|
+
expect(unit).toContain(`Environment=PATH=${customPath}`);
|
|
235
|
+
expect(unit).not.toContain(MOCK_SERVICE_PATH);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("targets default.target for user service", () => {
|
|
239
|
+
const unit = service.generateSystemdUnit({ binPath: "/usr/local/bin/the-companion" });
|
|
240
|
+
expect(unit).toContain("WantedBy=default.target");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ===========================================================================
|
|
245
|
+
// install (macOS)
|
|
246
|
+
// ===========================================================================
|
|
247
|
+
describe("install", () => {
|
|
248
|
+
it("creates log directory and writes plist file", async () => {
|
|
249
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
250
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
251
|
+
if (cmd.startsWith("launchctl load")) return "";
|
|
252
|
+
return "";
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await service.install();
|
|
256
|
+
|
|
257
|
+
expect(existsSync(logDir())).toBe(true);
|
|
258
|
+
expect(existsSync(plistPath())).toBe(true);
|
|
259
|
+
|
|
260
|
+
const content = readFileSync(plistPath(), "utf-8");
|
|
261
|
+
expect(content).toContain("sh.thecompanion.app");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("calls launchctl load with the plist path", async () => {
|
|
265
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
266
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
267
|
+
if (cmd.startsWith("launchctl load")) return "";
|
|
268
|
+
return "";
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await service.install();
|
|
272
|
+
|
|
273
|
+
const launchctlCall = mockExecSync.mock.calls.find(
|
|
274
|
+
([cmd]) => typeof cmd === "string" && cmd.startsWith("launchctl load"),
|
|
275
|
+
);
|
|
276
|
+
expect(launchctlCall).toBeDefined();
|
|
277
|
+
expect(launchctlCall![0]).toContain(plistPath());
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("exits with error if already installed", async () => {
|
|
281
|
+
// First install
|
|
282
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
283
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
284
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
285
|
+
return "";
|
|
286
|
+
});
|
|
287
|
+
await service.install();
|
|
288
|
+
|
|
289
|
+
// Second install should fail
|
|
290
|
+
vi.resetModules();
|
|
291
|
+
service = await import("./service.js");
|
|
292
|
+
await expect(service.install()).rejects.toThrow("process.exit(1)");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("exits with error if binary not found globally", async () => {
|
|
296
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
297
|
+
if (cmd.startsWith("which")) throw new Error("not found");
|
|
298
|
+
return "";
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await expect(service.install()).rejects.toThrow("process.exit(1)");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("uses custom port when provided", async () => {
|
|
305
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
306
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
307
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
308
|
+
return "";
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await service.install({ port: 9000 });
|
|
312
|
+
|
|
313
|
+
const content = readFileSync(plistPath(), "utf-8");
|
|
314
|
+
expect(content).toContain("<string>9000</string>");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("cleans up plist if launchctl load fails", async () => {
|
|
318
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
319
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
320
|
+
if (cmd.startsWith("launchctl load")) throw new Error("launchctl failed");
|
|
321
|
+
return "";
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await expect(service.install()).rejects.toThrow("process.exit(1)");
|
|
325
|
+
expect(existsSync(plistPath())).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("migrates old launchd label before installing", async () => {
|
|
329
|
+
const oldPath = oldPlistPath();
|
|
330
|
+
const launchAgentsDir = join(tempDir, "Library", "LaunchAgents");
|
|
331
|
+
rmSync(launchAgentsDir, { recursive: true, force: true });
|
|
332
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
333
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
334
|
+
if (cmd.startsWith("launchctl unload")) return "";
|
|
335
|
+
if (cmd.startsWith("launchctl load")) return "";
|
|
336
|
+
return "";
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Create a legacy plist to simulate pre-rename installs
|
|
340
|
+
const plist = service.generatePlist({ binPath: "/usr/local/bin/the-companion" })
|
|
341
|
+
.replaceAll("sh.thecompanion.app", "co.thevibecompany.companion");
|
|
342
|
+
mkdirSync(launchAgentsDir, { recursive: true });
|
|
343
|
+
writeFileSync(oldPath, plist, "utf-8");
|
|
344
|
+
|
|
345
|
+
await service.install();
|
|
346
|
+
|
|
347
|
+
expect(existsSync(oldPath)).toBe(false);
|
|
348
|
+
expect(existsSync(plistPath())).toBe(true);
|
|
349
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
350
|
+
expect.stringContaining(`launchctl unload -w "${oldPath}"`),
|
|
351
|
+
expect.any(Object),
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ===========================================================================
|
|
357
|
+
// install (Linux)
|
|
358
|
+
// ===========================================================================
|
|
359
|
+
describe("install (linux)", () => {
|
|
360
|
+
beforeEach(async () => {
|
|
361
|
+
mockPlatform.set("linux");
|
|
362
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
363
|
+
vi.resetModules();
|
|
364
|
+
service = await import("./service.js");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("creates log directory and writes systemd unit file", async () => {
|
|
368
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
369
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
370
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
371
|
+
return "";
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
await service.install();
|
|
375
|
+
|
|
376
|
+
expect(existsSync(logDir())).toBe(true);
|
|
377
|
+
expect(existsSync(unitPath())).toBe(true);
|
|
378
|
+
|
|
379
|
+
const content = readFileSync(unitPath(), "utf-8");
|
|
380
|
+
expect(content).toContain("ExecStart=/usr/local/bin/the-companion start --foreground");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("calls systemctl daemon-reload and enable --now", async () => {
|
|
384
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
385
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
386
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
387
|
+
return "";
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
await service.install();
|
|
391
|
+
|
|
392
|
+
const daemonReload = mockExecSync.mock.calls.find(
|
|
393
|
+
([cmd]) => typeof cmd === "string" && cmd.includes("daemon-reload"),
|
|
394
|
+
);
|
|
395
|
+
expect(daemonReload).toBeDefined();
|
|
396
|
+
|
|
397
|
+
const enableCall = mockExecSync.mock.calls.find(
|
|
398
|
+
([cmd]) => typeof cmd === "string" && cmd.includes("enable --now"),
|
|
399
|
+
);
|
|
400
|
+
expect(enableCall).toBeDefined();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("exits with error if already installed", async () => {
|
|
404
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
405
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
406
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
407
|
+
return "";
|
|
408
|
+
});
|
|
409
|
+
await service.install();
|
|
410
|
+
|
|
411
|
+
vi.resetModules();
|
|
412
|
+
service = await import("./service.js");
|
|
413
|
+
await expect(service.install()).rejects.toThrow("process.exit(1)");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("exits with error if binary not found globally", async () => {
|
|
417
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
418
|
+
if (cmd.startsWith("which")) throw new Error("not found");
|
|
419
|
+
return "";
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
await expect(service.install()).rejects.toThrow("process.exit(1)");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("uses custom port when provided", async () => {
|
|
426
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
427
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
428
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
429
|
+
return "";
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
await service.install({ port: 9000 });
|
|
433
|
+
|
|
434
|
+
const content = readFileSync(unitPath(), "utf-8");
|
|
435
|
+
expect(content).toContain("Environment=PORT=9000");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("cleans up unit file if systemctl enable fails", async () => {
|
|
439
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
440
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
441
|
+
if (cmd.includes("daemon-reload")) return "";
|
|
442
|
+
if (cmd.includes("enable --now")) throw new Error("systemctl failed");
|
|
443
|
+
return "";
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await expect(service.install()).rejects.toThrow("process.exit(1)");
|
|
447
|
+
expect(existsSync(unitPath())).toBe(false);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ===========================================================================
|
|
452
|
+
// uninstall (macOS)
|
|
453
|
+
// ===========================================================================
|
|
454
|
+
describe("uninstall", () => {
|
|
455
|
+
it("calls launchctl unload and removes plist", async () => {
|
|
456
|
+
// Install first
|
|
457
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
458
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
459
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
460
|
+
return "";
|
|
461
|
+
});
|
|
462
|
+
await service.install();
|
|
463
|
+
|
|
464
|
+
vi.resetModules();
|
|
465
|
+
service = await import("./service.js");
|
|
466
|
+
mockExecSync.mockReset();
|
|
467
|
+
mockExecSync.mockImplementation(() => "");
|
|
468
|
+
|
|
469
|
+
await service.uninstall();
|
|
470
|
+
|
|
471
|
+
const unloadCall = mockExecSync.mock.calls.find(
|
|
472
|
+
([cmd]) => typeof cmd === "string" && cmd.startsWith("launchctl unload"),
|
|
473
|
+
);
|
|
474
|
+
expect(unloadCall).toBeDefined();
|
|
475
|
+
expect(existsSync(plistPath())).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("handles not-installed gracefully", async () => {
|
|
479
|
+
// Should not throw
|
|
480
|
+
await service.uninstall();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("uninstalls old launchd label when only legacy plist exists", async () => {
|
|
484
|
+
const oldPath = oldPlistPath();
|
|
485
|
+
const launchAgentsDir = join(tempDir, "Library", "LaunchAgents");
|
|
486
|
+
mkdirSync(launchAgentsDir, { recursive: true });
|
|
487
|
+
writeFileSync(oldPath, "<plist/>", "utf-8");
|
|
488
|
+
mockExecSync.mockReset();
|
|
489
|
+
mockExecSync.mockImplementation(() => "");
|
|
490
|
+
|
|
491
|
+
await service.uninstall();
|
|
492
|
+
|
|
493
|
+
expect(existsSync(oldPath)).toBe(false);
|
|
494
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
495
|
+
expect.stringContaining(`launchctl unload -w "${oldPath}"`),
|
|
496
|
+
expect.any(Object),
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ===========================================================================
|
|
502
|
+
// uninstall (Linux)
|
|
503
|
+
// ===========================================================================
|
|
504
|
+
describe("uninstall (linux)", () => {
|
|
505
|
+
beforeEach(async () => {
|
|
506
|
+
mockPlatform.set("linux");
|
|
507
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
508
|
+
vi.resetModules();
|
|
509
|
+
service = await import("./service.js");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("calls systemctl disable --now and removes unit file", async () => {
|
|
513
|
+
// Install first
|
|
514
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
515
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
516
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
517
|
+
return "";
|
|
518
|
+
});
|
|
519
|
+
await service.install();
|
|
520
|
+
|
|
521
|
+
vi.resetModules();
|
|
522
|
+
service = await import("./service.js");
|
|
523
|
+
mockExecSync.mockReset();
|
|
524
|
+
mockExecSync.mockImplementation(() => "");
|
|
525
|
+
|
|
526
|
+
await service.uninstall();
|
|
527
|
+
|
|
528
|
+
const disableCall = mockExecSync.mock.calls.find(
|
|
529
|
+
([cmd]) => typeof cmd === "string" && cmd.includes("disable --now"),
|
|
530
|
+
);
|
|
531
|
+
expect(disableCall).toBeDefined();
|
|
532
|
+
expect(existsSync(unitPath())).toBe(false);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("calls daemon-reload after removing unit file", async () => {
|
|
536
|
+
// Install first
|
|
537
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
538
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
539
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
540
|
+
return "";
|
|
541
|
+
});
|
|
542
|
+
await service.install();
|
|
543
|
+
|
|
544
|
+
vi.resetModules();
|
|
545
|
+
service = await import("./service.js");
|
|
546
|
+
mockExecSync.mockReset();
|
|
547
|
+
mockExecSync.mockImplementation(() => "");
|
|
548
|
+
|
|
549
|
+
await service.uninstall();
|
|
550
|
+
|
|
551
|
+
const reloadCall = mockExecSync.mock.calls.find(
|
|
552
|
+
([cmd]) => typeof cmd === "string" && cmd.includes("daemon-reload"),
|
|
553
|
+
);
|
|
554
|
+
expect(reloadCall).toBeDefined();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("handles not-installed gracefully", async () => {
|
|
558
|
+
// Should not throw
|
|
559
|
+
await service.uninstall();
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ===========================================================================
|
|
564
|
+
// start (macOS)
|
|
565
|
+
// ===========================================================================
|
|
566
|
+
describe("start", () => {
|
|
567
|
+
it("calls launchctl kickstart when installed", async () => {
|
|
568
|
+
// Install first
|
|
569
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
570
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
571
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
572
|
+
return "";
|
|
573
|
+
});
|
|
574
|
+
await service.install();
|
|
575
|
+
|
|
576
|
+
vi.resetModules();
|
|
577
|
+
service = await import("./service.js");
|
|
578
|
+
mockExecSync.mockReset();
|
|
579
|
+
mockExecSync.mockImplementation(() => "");
|
|
580
|
+
|
|
581
|
+
await service.start();
|
|
582
|
+
|
|
583
|
+
const startCall = mockExecSync.mock.calls.find(
|
|
584
|
+
([cmd]) => typeof cmd === "string" && cmd.startsWith("launchctl kickstart -k"),
|
|
585
|
+
);
|
|
586
|
+
expect(startCall).toBeDefined();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("falls back to launchctl bootstrap when kickstart fails", async () => {
|
|
590
|
+
// Install first
|
|
591
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
592
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
593
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
594
|
+
return "";
|
|
595
|
+
});
|
|
596
|
+
await service.install();
|
|
597
|
+
|
|
598
|
+
vi.resetModules();
|
|
599
|
+
service = await import("./service.js");
|
|
600
|
+
mockExecSync.mockReset();
|
|
601
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
602
|
+
if (cmd.startsWith("launchctl kickstart -k")) throw new Error("kickstart failed");
|
|
603
|
+
if (cmd.startsWith("launchctl bootstrap")) return "";
|
|
604
|
+
return "";
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
await service.start();
|
|
608
|
+
|
|
609
|
+
const bootstrapCall = mockExecSync.mock.calls.find(
|
|
610
|
+
([cmd]) => typeof cmd === "string" && cmd.startsWith("launchctl bootstrap"),
|
|
611
|
+
);
|
|
612
|
+
expect(bootstrapCall).toBeDefined();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("handles not-installed gracefully", async () => {
|
|
616
|
+
// Should not throw
|
|
617
|
+
await service.start();
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// ===========================================================================
|
|
622
|
+
// start (Linux)
|
|
623
|
+
// ===========================================================================
|
|
624
|
+
describe("start (linux)", () => {
|
|
625
|
+
beforeEach(async () => {
|
|
626
|
+
mockPlatform.set("linux");
|
|
627
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
628
|
+
vi.resetModules();
|
|
629
|
+
service = await import("./service.js");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("calls systemctl start when installed", async () => {
|
|
633
|
+
// Install first
|
|
634
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
635
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
636
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
637
|
+
return "";
|
|
638
|
+
});
|
|
639
|
+
await service.install();
|
|
640
|
+
|
|
641
|
+
vi.resetModules();
|
|
642
|
+
service = await import("./service.js");
|
|
643
|
+
mockExecSync.mockReset();
|
|
644
|
+
// start() now calls refreshServiceDefinition() which needs `which`
|
|
645
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
646
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
647
|
+
return "";
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
await service.start();
|
|
651
|
+
|
|
652
|
+
const startCall = mockExecSync.mock.calls.find(
|
|
653
|
+
([cmd]) => typeof cmd === "string" && cmd.includes("start the-companion.service"),
|
|
654
|
+
);
|
|
655
|
+
expect(startCall).toBeDefined();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("auto-installs and starts when not installed", async () => {
|
|
659
|
+
// When the service is not installed, start() should auto-install it.
|
|
660
|
+
// Mock `which` to return a valid binary path so install can proceed.
|
|
661
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
662
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
663
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
664
|
+
if (cmd.startsWith("loginctl")) return "";
|
|
665
|
+
return "";
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
await service.start();
|
|
669
|
+
|
|
670
|
+
// Verify unit file was written (install happened)
|
|
671
|
+
expect(existsSync(unitPath())).toBe(true);
|
|
672
|
+
|
|
673
|
+
// Verify systemctl enable --now was called (service started)
|
|
674
|
+
const enableCall = mockExecSync.mock.calls.find(
|
|
675
|
+
([cmd]) => typeof cmd === "string" && cmd.includes("enable --now"),
|
|
676
|
+
);
|
|
677
|
+
expect(enableCall).toBeDefined();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("refreshes the service definition before starting an already-installed service", async () => {
|
|
681
|
+
// Install first with an older-style unit file (missing SuccessExitStatus).
|
|
682
|
+
// start() should rewrite the unit via refreshServiceDefinition() so that
|
|
683
|
+
// stale definitions from older versions don't cause restart loops.
|
|
684
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
685
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
686
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
687
|
+
return "";
|
|
688
|
+
});
|
|
689
|
+
await service.install();
|
|
690
|
+
|
|
691
|
+
// Manually overwrite the unit with a stale version (no SuccessExitStatus)
|
|
692
|
+
const staleUnit = readFileSync(unitPath(), "utf-8")
|
|
693
|
+
.replace("SuccessExitStatus=42\n", "")
|
|
694
|
+
.replace("Restart=always", "Restart=on-failure");
|
|
695
|
+
writeFileSync(unitPath(), staleUnit, "utf-8");
|
|
696
|
+
expect(readFileSync(unitPath(), "utf-8")).not.toContain("SuccessExitStatus=42");
|
|
697
|
+
|
|
698
|
+
vi.resetModules();
|
|
699
|
+
service = await import("./service.js");
|
|
700
|
+
mockExecSync.mockReset();
|
|
701
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
702
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
703
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
704
|
+
return "";
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
await service.start();
|
|
708
|
+
|
|
709
|
+
// Verify the unit file was refreshed with current template values
|
|
710
|
+
const updatedContent = readFileSync(unitPath(), "utf-8");
|
|
711
|
+
expect(updatedContent).toContain("SuccessExitStatus=42");
|
|
712
|
+
expect(updatedContent).toContain("Restart=always");
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// ===========================================================================
|
|
717
|
+
// stop (macOS)
|
|
718
|
+
// ===========================================================================
|
|
719
|
+
describe("stop", () => {
|
|
720
|
+
it("calls launchctl bootout when installed", async () => {
|
|
721
|
+
// Install first
|
|
722
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
723
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
724
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
725
|
+
return "";
|
|
726
|
+
});
|
|
727
|
+
await service.install();
|
|
728
|
+
|
|
729
|
+
vi.resetModules();
|
|
730
|
+
service = await import("./service.js");
|
|
731
|
+
mockExecSync.mockReset();
|
|
732
|
+
mockExecSync.mockImplementation(() => "");
|
|
733
|
+
|
|
734
|
+
await service.stop();
|
|
735
|
+
|
|
736
|
+
const stopCall = mockExecSync.mock.calls.find(
|
|
737
|
+
([cmd]) => typeof cmd === "string" && cmd.startsWith("launchctl bootout"),
|
|
738
|
+
);
|
|
739
|
+
expect(stopCall).toBeDefined();
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("falls back to launchctl unload when bootout fails", async () => {
|
|
743
|
+
// Install first
|
|
744
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
745
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
746
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
747
|
+
return "";
|
|
748
|
+
});
|
|
749
|
+
await service.install();
|
|
750
|
+
|
|
751
|
+
vi.resetModules();
|
|
752
|
+
service = await import("./service.js");
|
|
753
|
+
mockExecSync.mockReset();
|
|
754
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
755
|
+
if (cmd.startsWith("launchctl bootout")) throw new Error("bootout failed");
|
|
756
|
+
if (cmd.startsWith("launchctl unload")) return "";
|
|
757
|
+
return "";
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
await service.stop();
|
|
761
|
+
|
|
762
|
+
const unloadCall = mockExecSync.mock.calls.find(
|
|
763
|
+
([cmd]) => typeof cmd === "string" && cmd.startsWith("launchctl unload"),
|
|
764
|
+
);
|
|
765
|
+
expect(unloadCall).toBeDefined();
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("handles not-installed gracefully", async () => {
|
|
769
|
+
// Should not throw
|
|
770
|
+
await service.stop();
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// ===========================================================================
|
|
775
|
+
// stop (Linux)
|
|
776
|
+
// ===========================================================================
|
|
777
|
+
describe("stop (linux)", () => {
|
|
778
|
+
beforeEach(async () => {
|
|
779
|
+
mockPlatform.set("linux");
|
|
780
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
781
|
+
vi.resetModules();
|
|
782
|
+
service = await import("./service.js");
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("calls systemctl stop when installed", async () => {
|
|
786
|
+
// Install first
|
|
787
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
788
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
789
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
790
|
+
return "";
|
|
791
|
+
});
|
|
792
|
+
await service.install();
|
|
793
|
+
|
|
794
|
+
vi.resetModules();
|
|
795
|
+
service = await import("./service.js");
|
|
796
|
+
mockExecSync.mockReset();
|
|
797
|
+
mockExecSync.mockImplementation(() => "");
|
|
798
|
+
|
|
799
|
+
await service.stop();
|
|
800
|
+
|
|
801
|
+
const stopCall = mockExecSync.mock.calls.find(
|
|
802
|
+
([cmd]) => typeof cmd === "string" && cmd.includes("stop the-companion.service"),
|
|
803
|
+
);
|
|
804
|
+
expect(stopCall).toBeDefined();
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it("handles not-installed gracefully", async () => {
|
|
808
|
+
// Should not throw
|
|
809
|
+
await service.stop();
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
// ===========================================================================
|
|
814
|
+
// restart (macOS)
|
|
815
|
+
// ===========================================================================
|
|
816
|
+
describe("restart", () => {
|
|
817
|
+
it("calls launchctl kickstart when installed", async () => {
|
|
818
|
+
// Install first
|
|
819
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
820
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
821
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
822
|
+
return "";
|
|
823
|
+
});
|
|
824
|
+
await service.install();
|
|
825
|
+
|
|
826
|
+
vi.resetModules();
|
|
827
|
+
service = await import("./service.js");
|
|
828
|
+
mockExecSync.mockReset();
|
|
829
|
+
mockExecSync.mockImplementation(() => "");
|
|
830
|
+
|
|
831
|
+
await service.restart();
|
|
832
|
+
|
|
833
|
+
const restartCall = mockExecSync.mock.calls.find(
|
|
834
|
+
([cmd]) => typeof cmd === "string" && cmd.startsWith("launchctl kickstart -k"),
|
|
835
|
+
);
|
|
836
|
+
expect(restartCall).toBeDefined();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("handles not-installed gracefully", async () => {
|
|
840
|
+
// Should not throw
|
|
841
|
+
await service.restart();
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
// ===========================================================================
|
|
846
|
+
// restart (Linux)
|
|
847
|
+
// ===========================================================================
|
|
848
|
+
describe("restart (linux)", () => {
|
|
849
|
+
beforeEach(async () => {
|
|
850
|
+
mockPlatform.set("linux");
|
|
851
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
852
|
+
vi.resetModules();
|
|
853
|
+
service = await import("./service.js");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("calls systemctl restart when installed", async () => {
|
|
857
|
+
// Install first
|
|
858
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
859
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
860
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
861
|
+
return "";
|
|
862
|
+
});
|
|
863
|
+
await service.install();
|
|
864
|
+
|
|
865
|
+
vi.resetModules();
|
|
866
|
+
service = await import("./service.js");
|
|
867
|
+
mockExecSync.mockReset();
|
|
868
|
+
// restart() now calls refreshServiceDefinition() which needs `which`
|
|
869
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
870
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
871
|
+
return "";
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
await service.restart();
|
|
875
|
+
|
|
876
|
+
const restartCall = mockExecSync.mock.calls.find(
|
|
877
|
+
([cmd]) => typeof cmd === "string" && cmd.includes("restart the-companion.service"),
|
|
878
|
+
);
|
|
879
|
+
expect(restartCall).toBeDefined();
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("handles not-installed gracefully", async () => {
|
|
883
|
+
// Should not throw
|
|
884
|
+
await service.restart();
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("refreshes the service definition before restarting", async () => {
|
|
888
|
+
// Install first
|
|
889
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
890
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
891
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
892
|
+
return "";
|
|
893
|
+
});
|
|
894
|
+
await service.install();
|
|
895
|
+
|
|
896
|
+
// Manually write a stale unit (no SuccessExitStatus)
|
|
897
|
+
const staleUnit = readFileSync(unitPath(), "utf-8")
|
|
898
|
+
.replace("SuccessExitStatus=42\n", "");
|
|
899
|
+
writeFileSync(unitPath(), staleUnit, "utf-8");
|
|
900
|
+
expect(readFileSync(unitPath(), "utf-8")).not.toContain("SuccessExitStatus=42");
|
|
901
|
+
|
|
902
|
+
vi.resetModules();
|
|
903
|
+
service = await import("./service.js");
|
|
904
|
+
mockExecSync.mockReset();
|
|
905
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
906
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
907
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
908
|
+
return "";
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
await service.restart();
|
|
912
|
+
|
|
913
|
+
// Verify the unit file was refreshed with current template
|
|
914
|
+
const updatedContent = readFileSync(unitPath(), "utf-8");
|
|
915
|
+
expect(updatedContent).toContain("SuccessExitStatus=42");
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// ===========================================================================
|
|
920
|
+
// status (macOS)
|
|
921
|
+
// ===========================================================================
|
|
922
|
+
describe("status", () => {
|
|
923
|
+
it("returns installed: false when no plist exists", async () => {
|
|
924
|
+
const result = await service.status();
|
|
925
|
+
expect(result).toEqual({ installed: false, running: false });
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("returns installed: true, running: true with PID", async () => {
|
|
929
|
+
// Install first
|
|
930
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
931
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
932
|
+
if (cmd.startsWith("launchctl load")) return "";
|
|
933
|
+
return "";
|
|
934
|
+
});
|
|
935
|
+
await service.install();
|
|
936
|
+
|
|
937
|
+
vi.resetModules();
|
|
938
|
+
service = await import("./service.js");
|
|
939
|
+
mockExecSync.mockReset();
|
|
940
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
941
|
+
if (typeof cmd === "string" && cmd.includes("launchctl list")) {
|
|
942
|
+
return `{\n\t"PID" = 12345;\n\t"Label" = "sh.thecompanion.app";\n}`;
|
|
943
|
+
}
|
|
944
|
+
return "";
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
const result = await service.status();
|
|
948
|
+
expect(result.installed).toBe(true);
|
|
949
|
+
expect(result.running).toBe(true);
|
|
950
|
+
expect(result.pid).toBe(12345);
|
|
951
|
+
expect(result.port).toBe(3456);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it("returns installed: true, running: false when service is loaded but not running", async () => {
|
|
955
|
+
// Install first
|
|
956
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
957
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
958
|
+
if (cmd.startsWith("launchctl load")) return "";
|
|
959
|
+
return "";
|
|
960
|
+
});
|
|
961
|
+
await service.install();
|
|
962
|
+
|
|
963
|
+
vi.resetModules();
|
|
964
|
+
service = await import("./service.js");
|
|
965
|
+
mockExecSync.mockReset();
|
|
966
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
967
|
+
if (typeof cmd === "string" && cmd.includes("launchctl list")) {
|
|
968
|
+
return `{\n\t"Label" = "sh.thecompanion.app";\n}`;
|
|
969
|
+
}
|
|
970
|
+
return "";
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
const result = await service.status();
|
|
974
|
+
expect(result.installed).toBe(true);
|
|
975
|
+
expect(result.running).toBe(false);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it("reports legacy launchd label as installed and running", async () => {
|
|
979
|
+
const oldPath = oldPlistPath();
|
|
980
|
+
const launchAgentsDir = join(tempDir, "Library", "LaunchAgents");
|
|
981
|
+
mkdirSync(launchAgentsDir, { recursive: true });
|
|
982
|
+
writeFileSync(
|
|
983
|
+
oldPath,
|
|
984
|
+
`
|
|
985
|
+
<plist>
|
|
986
|
+
<dict>
|
|
987
|
+
<key>EnvironmentVariables</key>
|
|
988
|
+
<dict>
|
|
989
|
+
<key>PORT</key>
|
|
990
|
+
<string>4567</string>
|
|
991
|
+
</dict>
|
|
992
|
+
</dict>
|
|
993
|
+
</plist>
|
|
994
|
+
`,
|
|
995
|
+
"utf-8",
|
|
996
|
+
);
|
|
997
|
+
mockExecSync.mockReset();
|
|
998
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
999
|
+
if (typeof cmd === "string" && cmd.includes("launchctl list")) {
|
|
1000
|
+
return `{\n\t"PID" = 12345;\n\t"Label" = "co.thevibecompany.companion";\n}`;
|
|
1001
|
+
}
|
|
1002
|
+
return "";
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
const result = await service.status();
|
|
1006
|
+
expect(result).toEqual({
|
|
1007
|
+
installed: true,
|
|
1008
|
+
running: true,
|
|
1009
|
+
pid: 12345,
|
|
1010
|
+
port: 4567,
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// ===========================================================================
|
|
1016
|
+
// status (Linux)
|
|
1017
|
+
// ===========================================================================
|
|
1018
|
+
describe("status (linux)", () => {
|
|
1019
|
+
beforeEach(async () => {
|
|
1020
|
+
mockPlatform.set("linux");
|
|
1021
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
1022
|
+
vi.resetModules();
|
|
1023
|
+
service = await import("./service.js");
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it("returns installed: false when no unit file exists", async () => {
|
|
1027
|
+
const result = await service.status();
|
|
1028
|
+
expect(result).toEqual({ installed: false, running: false });
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it("returns installed: true, running: true with PID", async () => {
|
|
1032
|
+
// Install first
|
|
1033
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1034
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1035
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
1036
|
+
return "";
|
|
1037
|
+
});
|
|
1038
|
+
await service.install();
|
|
1039
|
+
|
|
1040
|
+
vi.resetModules();
|
|
1041
|
+
service = await import("./service.js");
|
|
1042
|
+
mockExecSync.mockReset();
|
|
1043
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1044
|
+
if (typeof cmd === "string" && cmd.includes("show the-companion.service")) {
|
|
1045
|
+
return "ActiveState=active\nMainPID=54321\n";
|
|
1046
|
+
}
|
|
1047
|
+
return "";
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
const result = await service.status();
|
|
1051
|
+
expect(result.installed).toBe(true);
|
|
1052
|
+
expect(result.running).toBe(true);
|
|
1053
|
+
expect(result.pid).toBe(54321);
|
|
1054
|
+
expect(result.port).toBe(3456);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it("returns installed: true, running: false when service is inactive", async () => {
|
|
1058
|
+
// Install first
|
|
1059
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1060
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1061
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
1062
|
+
return "";
|
|
1063
|
+
});
|
|
1064
|
+
await service.install();
|
|
1065
|
+
|
|
1066
|
+
vi.resetModules();
|
|
1067
|
+
service = await import("./service.js");
|
|
1068
|
+
mockExecSync.mockReset();
|
|
1069
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1070
|
+
if (typeof cmd === "string" && cmd.includes("show the-companion.service")) {
|
|
1071
|
+
return "ActiveState=inactive\nMainPID=0\n";
|
|
1072
|
+
}
|
|
1073
|
+
return "";
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
const result = await service.status();
|
|
1077
|
+
expect(result.installed).toBe(true);
|
|
1078
|
+
expect(result.running).toBe(false);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it("reads custom port from unit file", async () => {
|
|
1082
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1083
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1084
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
1085
|
+
return "";
|
|
1086
|
+
});
|
|
1087
|
+
await service.install({ port: 7777 });
|
|
1088
|
+
|
|
1089
|
+
vi.resetModules();
|
|
1090
|
+
service = await import("./service.js");
|
|
1091
|
+
mockExecSync.mockReset();
|
|
1092
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1093
|
+
if (typeof cmd === "string" && cmd.includes("show the-companion.service")) {
|
|
1094
|
+
return "ActiveState=active\nMainPID=1234\n";
|
|
1095
|
+
}
|
|
1096
|
+
return "";
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
const result = await service.status();
|
|
1100
|
+
expect(result.port).toBe(7777);
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// ===========================================================================
|
|
1105
|
+
// isRunningAsService (macOS)
|
|
1106
|
+
// ===========================================================================
|
|
1107
|
+
describe("isRunningAsService", () => {
|
|
1108
|
+
it("returns false on unsupported platforms", async () => {
|
|
1109
|
+
mockPlatform.set("win32");
|
|
1110
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
1111
|
+
|
|
1112
|
+
vi.resetModules();
|
|
1113
|
+
service = await import("./service.js");
|
|
1114
|
+
|
|
1115
|
+
expect(service.isRunningAsService()).toBe(false);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
it("returns false when no plist exists (macOS)", () => {
|
|
1119
|
+
expect(service.isRunningAsService()).toBe(false);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("returns true when plist exists and service has a PID", async () => {
|
|
1123
|
+
// Install first
|
|
1124
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1125
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1126
|
+
if (cmd.startsWith("launchctl load")) return "";
|
|
1127
|
+
return "";
|
|
1128
|
+
});
|
|
1129
|
+
await service.install();
|
|
1130
|
+
|
|
1131
|
+
vi.resetModules();
|
|
1132
|
+
service = await import("./service.js");
|
|
1133
|
+
mockExecSync.mockReset();
|
|
1134
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1135
|
+
if (typeof cmd === "string" && cmd.includes("launchctl list")) {
|
|
1136
|
+
return `{\n\t"PID" = 12345;\n\t"Label" = "sh.thecompanion.app";\n}`;
|
|
1137
|
+
}
|
|
1138
|
+
return "";
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
expect(service.isRunningAsService()).toBe(true);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it("returns false when plist exists but no PID (not running)", async () => {
|
|
1145
|
+
// Install first
|
|
1146
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1147
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1148
|
+
if (cmd.startsWith("launchctl load")) return "";
|
|
1149
|
+
return "";
|
|
1150
|
+
});
|
|
1151
|
+
await service.install();
|
|
1152
|
+
|
|
1153
|
+
vi.resetModules();
|
|
1154
|
+
service = await import("./service.js");
|
|
1155
|
+
mockExecSync.mockReset();
|
|
1156
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1157
|
+
if (typeof cmd === "string" && cmd.includes("launchctl list")) {
|
|
1158
|
+
return `{\n\t"Label" = "sh.thecompanion.app";\n}`;
|
|
1159
|
+
}
|
|
1160
|
+
return "";
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
expect(service.isRunningAsService()).toBe(false);
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// ===========================================================================
|
|
1168
|
+
// isRunningAsService (Linux)
|
|
1169
|
+
// ===========================================================================
|
|
1170
|
+
describe("isRunningAsService (linux)", () => {
|
|
1171
|
+
beforeEach(async () => {
|
|
1172
|
+
mockPlatform.set("linux");
|
|
1173
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
1174
|
+
vi.resetModules();
|
|
1175
|
+
service = await import("./service.js");
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it("returns false when no unit file exists", () => {
|
|
1179
|
+
expect(service.isRunningAsService()).toBe(false);
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it("returns true when unit file exists and service is active", async () => {
|
|
1183
|
+
// Install first
|
|
1184
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1185
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1186
|
+
if (cmd.includes("daemon-reload")) return "";
|
|
1187
|
+
if (cmd.includes("enable --now")) return "";
|
|
1188
|
+
return "";
|
|
1189
|
+
});
|
|
1190
|
+
await service.install();
|
|
1191
|
+
|
|
1192
|
+
vi.resetModules();
|
|
1193
|
+
service = await import("./service.js");
|
|
1194
|
+
mockExecSync.mockReset();
|
|
1195
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1196
|
+
if (typeof cmd === "string" && cmd.includes("is-active")) {
|
|
1197
|
+
return "active\n";
|
|
1198
|
+
}
|
|
1199
|
+
return "";
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
expect(service.isRunningAsService()).toBe(true);
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
it("returns false when unit file exists but service is inactive", async () => {
|
|
1206
|
+
// Install first
|
|
1207
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1208
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1209
|
+
if (cmd.includes("daemon-reload")) return "";
|
|
1210
|
+
if (cmd.includes("enable --now")) return "";
|
|
1211
|
+
return "";
|
|
1212
|
+
});
|
|
1213
|
+
await service.install();
|
|
1214
|
+
|
|
1215
|
+
vi.resetModules();
|
|
1216
|
+
service = await import("./service.js");
|
|
1217
|
+
mockExecSync.mockReset();
|
|
1218
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1219
|
+
if (typeof cmd === "string" && cmd.includes("is-active")) {
|
|
1220
|
+
throw new Error("inactive");
|
|
1221
|
+
}
|
|
1222
|
+
return "";
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
expect(service.isRunningAsService()).toBe(false);
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
// ===========================================================================
|
|
1230
|
+
// refreshServiceDefinition (macOS)
|
|
1231
|
+
// ===========================================================================
|
|
1232
|
+
describe("refreshServiceDefinition (macOS)", () => {
|
|
1233
|
+
it("rewrites plist with current binary path", async () => {
|
|
1234
|
+
// Install first
|
|
1235
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1236
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1237
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
1238
|
+
return "";
|
|
1239
|
+
});
|
|
1240
|
+
await service.install();
|
|
1241
|
+
|
|
1242
|
+
// Verify plist exists with original binary
|
|
1243
|
+
const originalContent = readFileSync(plistPath(), "utf-8");
|
|
1244
|
+
expect(originalContent).toContain("/usr/local/bin/the-companion");
|
|
1245
|
+
|
|
1246
|
+
// Now refresh with a different binary path
|
|
1247
|
+
vi.resetModules();
|
|
1248
|
+
service = await import("./service.js");
|
|
1249
|
+
mockExecSync.mockReset();
|
|
1250
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1251
|
+
if (cmd.startsWith("which")) return "/new/path/the-companion\n";
|
|
1252
|
+
return "";
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
service.refreshServiceDefinition();
|
|
1256
|
+
|
|
1257
|
+
const updatedContent = readFileSync(plistPath(), "utf-8");
|
|
1258
|
+
expect(updatedContent).toContain("/new/path/the-companion");
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("preserves custom port from existing plist", async () => {
|
|
1262
|
+
// Install with custom port
|
|
1263
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1264
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1265
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
1266
|
+
return "";
|
|
1267
|
+
});
|
|
1268
|
+
await service.install({ port: 9999 });
|
|
1269
|
+
|
|
1270
|
+
const originalContent = readFileSync(plistPath(), "utf-8");
|
|
1271
|
+
expect(originalContent).toContain("9999");
|
|
1272
|
+
|
|
1273
|
+
// Refresh
|
|
1274
|
+
vi.resetModules();
|
|
1275
|
+
service = await import("./service.js");
|
|
1276
|
+
mockExecSync.mockReset();
|
|
1277
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1278
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1279
|
+
return "";
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
service.refreshServiceDefinition();
|
|
1283
|
+
|
|
1284
|
+
const updatedContent = readFileSync(plistPath(), "utf-8");
|
|
1285
|
+
expect(updatedContent).toContain("9999");
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
it("is a no-op when service is not installed", () => {
|
|
1289
|
+
// Should not throw
|
|
1290
|
+
service.refreshServiceDefinition();
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
// ===========================================================================
|
|
1295
|
+
// refreshServiceDefinition (Linux)
|
|
1296
|
+
// ===========================================================================
|
|
1297
|
+
describe("refreshServiceDefinition (linux)", () => {
|
|
1298
|
+
beforeEach(async () => {
|
|
1299
|
+
mockPlatform.set("linux");
|
|
1300
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
1301
|
+
vi.resetModules();
|
|
1302
|
+
service = await import("./service.js");
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it("rewrites unit file and calls daemon-reload", async () => {
|
|
1306
|
+
// Install first
|
|
1307
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1308
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1309
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
1310
|
+
return "";
|
|
1311
|
+
});
|
|
1312
|
+
await service.install();
|
|
1313
|
+
|
|
1314
|
+
// Refresh with a different binary path
|
|
1315
|
+
vi.resetModules();
|
|
1316
|
+
service = await import("./service.js");
|
|
1317
|
+
mockExecSync.mockReset();
|
|
1318
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1319
|
+
if (cmd.startsWith("which")) return "/new/path/the-companion\n";
|
|
1320
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
1321
|
+
return "";
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
service.refreshServiceDefinition();
|
|
1325
|
+
|
|
1326
|
+
const updatedContent = readFileSync(unitPath(), "utf-8");
|
|
1327
|
+
expect(updatedContent).toContain("/new/path/the-companion");
|
|
1328
|
+
|
|
1329
|
+
// Verify daemon-reload was called
|
|
1330
|
+
const daemonReloadCall = mockExecSync.mock.calls.find(
|
|
1331
|
+
([cmd]) => typeof cmd === "string" && cmd.includes("daemon-reload"),
|
|
1332
|
+
);
|
|
1333
|
+
expect(daemonReloadCall).toBeDefined();
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
it("preserves custom port from existing unit", async () => {
|
|
1337
|
+
// Install with custom port
|
|
1338
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1339
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1340
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
1341
|
+
return "";
|
|
1342
|
+
});
|
|
1343
|
+
await service.install({ port: 9999 });
|
|
1344
|
+
|
|
1345
|
+
const originalContent = readFileSync(unitPath(), "utf-8");
|
|
1346
|
+
expect(originalContent).toContain("PORT=9999");
|
|
1347
|
+
|
|
1348
|
+
// Refresh
|
|
1349
|
+
vi.resetModules();
|
|
1350
|
+
service = await import("./service.js");
|
|
1351
|
+
mockExecSync.mockReset();
|
|
1352
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1353
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1354
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
1355
|
+
return "";
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
service.refreshServiceDefinition();
|
|
1359
|
+
|
|
1360
|
+
const updatedContent = readFileSync(unitPath(), "utf-8");
|
|
1361
|
+
expect(updatedContent).toContain("PORT=9999");
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it("is a no-op when service is not installed", () => {
|
|
1365
|
+
// Should not throw
|
|
1366
|
+
service.refreshServiceDefinition();
|
|
1367
|
+
});
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
// ===========================================================================
|
|
1371
|
+
// Platform check
|
|
1372
|
+
// ===========================================================================
|
|
1373
|
+
describe("platform check", () => {
|
|
1374
|
+
it("exits on unsupported platforms", async () => {
|
|
1375
|
+
mockPlatform.set("win32");
|
|
1376
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
1377
|
+
|
|
1378
|
+
vi.resetModules();
|
|
1379
|
+
service = await import("./service.js");
|
|
1380
|
+
|
|
1381
|
+
await expect(service.install()).rejects.toThrow("process.exit(1)");
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
it("allows macOS (darwin)", async () => {
|
|
1385
|
+
mockPlatform.set("darwin");
|
|
1386
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
1387
|
+
|
|
1388
|
+
vi.resetModules();
|
|
1389
|
+
service = await import("./service.js");
|
|
1390
|
+
|
|
1391
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1392
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1393
|
+
if (cmd.startsWith("launchctl")) return "";
|
|
1394
|
+
return "";
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// Should not throw platform error
|
|
1398
|
+
await service.install();
|
|
1399
|
+
expect(existsSync(plistPath())).toBe(true);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
it("allows Linux", async () => {
|
|
1403
|
+
mockPlatform.set("linux");
|
|
1404
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
1405
|
+
|
|
1406
|
+
vi.resetModules();
|
|
1407
|
+
service = await import("./service.js");
|
|
1408
|
+
|
|
1409
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
1410
|
+
if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
|
|
1411
|
+
if (cmd.startsWith("systemctl")) return "";
|
|
1412
|
+
return "";
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
// Should not throw platform error
|
|
1416
|
+
await service.install();
|
|
1417
|
+
expect(existsSync(unitPath())).toBe(true);
|
|
1418
|
+
});
|
|
1419
|
+
});
|