@cursorpool-dev/cli 0.5.6

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.
Files changed (105) hide show
  1. package/bin/cursor-pool.mjs +9 -0
  2. package/bin/cursor-pool.ts +169 -0
  3. package/node_modules/@cursor-pool/extension/dist/extension.js +2910 -0
  4. package/node_modules/@cursor-pool/extension/package.json +64 -0
  5. package/node_modules/@cursor-pool/extension/resources/cursor-pool.svg +6 -0
  6. package/node_modules/@cursor-pool/extension/src/api.ts +545 -0
  7. package/node_modules/@cursor-pool/extension/src/extension.ts +104 -0
  8. package/node_modules/@cursor-pool/extension/src/index.ts +1 -0
  9. package/node_modules/@cursor-pool/extension/src/panel.ts +569 -0
  10. package/node_modules/@cursor-pool/extension/src/runtime.ts +22 -0
  11. package/node_modules/@cursor-pool/extension/test/panel.test.ts +1785 -0
  12. package/node_modules/@cursor-pool/patcher/package.json +17 -0
  13. package/node_modules/@cursor-pool/patcher/src/alwaysLocalMarker.ts +86 -0
  14. package/node_modules/@cursor-pool/patcher/src/hash.ts +7 -0
  15. package/node_modules/@cursor-pool/patcher/src/index.ts +55 -0
  16. package/node_modules/@cursor-pool/patcher/src/marker.ts +159 -0
  17. package/node_modules/@cursor-pool/patcher/src/patchCursorAgentExec.ts +154 -0
  18. package/node_modules/@cursor-pool/patcher/src/patchCursorAlwaysLocal.ts +142 -0
  19. package/node_modules/@cursor-pool/patcher/src/patchCursorWorkbenchAuthGate.ts +140 -0
  20. package/node_modules/@cursor-pool/patcher/src/restoreCursorAgentExec.ts +52 -0
  21. package/node_modules/@cursor-pool/patcher/src/restoreCursorAlwaysLocal.ts +52 -0
  22. package/node_modules/@cursor-pool/patcher/src/restoreCursorWorkbenchAuthGate.ts +70 -0
  23. package/node_modules/@cursor-pool/patcher/src/workbenchAuthGateMarker.ts +243 -0
  24. package/node_modules/@cursor-pool/patcher/test/patchCursorAgentExec.test.ts +630 -0
  25. package/node_modules/@cursor-pool/patcher/test/patchCursorAlwaysLocal.test.ts +144 -0
  26. package/node_modules/@cursor-pool/patcher/test/patchCursorWorkbench.test.ts +770 -0
  27. package/node_modules/@cursor-pool/patcher/test/restoreCursorAgentExec.test.ts +139 -0
  28. package/node_modules/@cursor-pool/service/package.json +17 -0
  29. package/node_modules/@cursor-pool/service/src/canary.ts +61 -0
  30. package/node_modules/@cursor-pool/service/src/diagnostics.ts +385 -0
  31. package/node_modules/@cursor-pool/service/src/entry.ts +161 -0
  32. package/node_modules/@cursor-pool/service/src/health.ts +10 -0
  33. package/node_modules/@cursor-pool/service/src/index.ts +29 -0
  34. package/node_modules/@cursor-pool/service/src/metadata.ts +22 -0
  35. package/node_modules/@cursor-pool/service/src/platformSession.ts +1178 -0
  36. package/node_modules/@cursor-pool/service/src/requestCheck.ts +81 -0
  37. package/node_modules/@cursor-pool/service/src/requestGate.ts +100 -0
  38. package/node_modules/@cursor-pool/service/src/requestGateway.ts +441 -0
  39. package/node_modules/@cursor-pool/service/src/runtime.ts +48 -0
  40. package/node_modules/@cursor-pool/service/src/server.ts +939 -0
  41. package/node_modules/@cursor-pool/service/src/takeover.ts +111 -0
  42. package/node_modules/@cursor-pool/service/test/canary.test.ts +140 -0
  43. package/node_modules/@cursor-pool/service/test/diagnostics.test.ts +506 -0
  44. package/node_modules/@cursor-pool/service/test/metadata.test.ts +63 -0
  45. package/node_modules/@cursor-pool/service/test/platformSession.test.ts +2428 -0
  46. package/node_modules/@cursor-pool/service/test/requestCheck.test.ts +152 -0
  47. package/node_modules/@cursor-pool/service/test/requestGate.test.ts +207 -0
  48. package/node_modules/@cursor-pool/service/test/requestGateway.test.ts +466 -0
  49. package/node_modules/@cursor-pool/service/test/runtime.test.ts +47 -0
  50. package/node_modules/@cursor-pool/service/test/server.test.ts +2570 -0
  51. package/node_modules/@cursor-pool/shared/package.json +17 -0
  52. package/node_modules/@cursor-pool/shared/src/clientConfig.ts +49 -0
  53. package/node_modules/@cursor-pool/shared/src/index.ts +14 -0
  54. package/node_modules/@cursor-pool/shared/src/manifest.ts +36 -0
  55. package/node_modules/@cursor-pool/shared/src/metadata.ts +19 -0
  56. package/node_modules/@cursor-pool/shared/src/paths.ts +5 -0
  57. package/node_modules/@cursor-pool/shared/src/runtime.ts +3 -0
  58. package/node_modules/@cursor-pool/shared/test/index.test.ts +56 -0
  59. package/node_modules/@cursor-pool/shared/test/manifest.test.ts +65 -0
  60. package/node_modules/@cursor-pool/shared/test/metadata.test.ts +25 -0
  61. package/node_modules/@cursor-pool/shared/test/runtime.test.ts +8 -0
  62. package/package.json +28 -0
  63. package/src/adHocResign.ts +65 -0
  64. package/src/autostart.ts +240 -0
  65. package/src/compat.ts +282 -0
  66. package/src/confirm.ts +76 -0
  67. package/src/cursor.ts +94 -0
  68. package/src/diagnostics.ts +558 -0
  69. package/src/environment.ts +18 -0
  70. package/src/extensionBundle.ts +111 -0
  71. package/src/extensionLink.ts +168 -0
  72. package/src/index.ts +23 -0
  73. package/src/install.ts +614 -0
  74. package/src/installRecord.ts +105 -0
  75. package/src/launch.ts +182 -0
  76. package/src/patchSet.ts +182 -0
  77. package/src/platform.ts +132 -0
  78. package/src/repair.ts +383 -0
  79. package/src/restore.ts +153 -0
  80. package/src/serviceCommands.ts +79 -0
  81. package/src/serviceProcess.ts +188 -0
  82. package/src/status.ts +241 -0
  83. package/src/target.ts +37 -0
  84. package/src/trial.ts +133 -0
  85. package/src/uninstall.ts +213 -0
  86. package/test/autostart.test.ts +151 -0
  87. package/test/compat.test.ts +192 -0
  88. package/test/confirm.test.ts +114 -0
  89. package/test/cursor-pool-bin.test.ts +658 -0
  90. package/test/cursor.test.ts +20 -0
  91. package/test/diagnostics.test.ts +709 -0
  92. package/test/e2e-install.test.ts +773 -0
  93. package/test/extensionBundle.test.ts +161 -0
  94. package/test/extensionLink.test.ts +209 -0
  95. package/test/install.test.ts +862 -0
  96. package/test/installRecord.test.ts +107 -0
  97. package/test/launch.test.ts +138 -0
  98. package/test/platform.test.ts +226 -0
  99. package/test/repair.test.ts +575 -0
  100. package/test/restore.test.ts +211 -0
  101. package/test/serviceCommands.test.ts +135 -0
  102. package/test/serviceProcess.test.ts +280 -0
  103. package/test/status.test.ts +615 -0
  104. package/test/target.test.ts +49 -0
  105. package/test/trial.test.ts +146 -0
@@ -0,0 +1,211 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, normalize } from 'node:path';
5
+ import test from 'node:test';
6
+ import { CURSOR_POOL_AGENT_EXEC_PROVIDER_REGISTER_ANCHOR } from '../../patcher/src/marker';
7
+ import { patchCursorAgentExec } from '../../patcher/src/patchCursorAgentExec';
8
+ import {
9
+ installIdForAppPath,
10
+ readInstallRecord,
11
+ writeInstallRecord,
12
+ type InstallRecord,
13
+ } from '../src/installRecord';
14
+ import { restore } from '../src/restore';
15
+ import { trialIdForAppPath, writeTrialRecord } from '../src/trial';
16
+
17
+ const targetRelativePath =
18
+ 'Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js';
19
+
20
+ async function createFixtureApp() {
21
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-cli-restore-'));
22
+ const appPath = join(tempDir, 'Cursor.app');
23
+ const targetPath = join(appPath, targetRelativePath);
24
+ const targetContent = `function main() { return "agent"; }\n${CURSOR_POOL_AGENT_EXEC_PROVIDER_REGISTER_ANCHOR}\nmain();\n`;
25
+ await mkdir(join(appPath, 'Contents/Resources/app/extensions/cursor-agent-exec/dist'), {
26
+ recursive: true,
27
+ });
28
+ await writeFile(
29
+ join(appPath, 'Contents/Resources/app/product.json'),
30
+ JSON.stringify({
31
+ version: '3.5.38',
32
+ commit: '009bb5a3600dd98fe1c1f25798f767f686e14750',
33
+ }),
34
+ 'utf8',
35
+ );
36
+ await writeFile(targetPath, targetContent, 'utf8');
37
+
38
+ return {
39
+ appPath,
40
+ tempDir,
41
+ targetPath,
42
+ targetContent,
43
+ backupDir: join(tempDir, 'backups'),
44
+ trialRecordDir: join(tempDir, 'trials'),
45
+ };
46
+ }
47
+
48
+ test('restore reports ok and replaces patched fixture bytes with backup bytes', async () => {
49
+ const fixture = await createFixtureApp();
50
+
51
+ try {
52
+ await patchCursorAgentExec(fixture.appPath, { backupDir: fixture.backupDir });
53
+
54
+ const output = await restore({
55
+ appPath: fixture.appPath,
56
+ backupDir: fixture.backupDir,
57
+ });
58
+
59
+ assert.match(output, /mode: disposable/);
60
+ assert.match(output, /restore: ok/);
61
+ assert.equal(await readFile(fixture.targetPath, 'utf8'), fixture.targetContent);
62
+ } finally {
63
+ await rm(fixture.tempDir, { recursive: true, force: true });
64
+ }
65
+ });
66
+
67
+ test('restore discovers backup directory from the trial record', async () => {
68
+ const fixture = await createFixtureApp();
69
+
70
+ try {
71
+ await patchCursorAgentExec(fixture.appPath, { backupDir: fixture.backupDir });
72
+ await writeTrialRecord(
73
+ {
74
+ trialId: trialIdForAppPath(fixture.appPath),
75
+ appPath: fixture.appPath,
76
+ cursorVersion: '3.5.38',
77
+ cursorCommit: '009bb5a3600dd98fe1c1f25798f767f686e14750',
78
+ targetRelativePath,
79
+ originalSha256: 'fixture-original-sha',
80
+ compatSupportStatus: 'supported',
81
+ runtimeFile: join(fixture.tempDir, 'runtime.json'),
82
+ backupDir: fixture.backupDir,
83
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
84
+ extensionState: 'bundled',
85
+ createdAt: '2026-05-30T00:00:00.000Z',
86
+ updatedAt: '2026-05-30T00:00:01.000Z',
87
+ },
88
+ { trialRecordDir: fixture.trialRecordDir },
89
+ );
90
+
91
+ const output = await restore({
92
+ appPath: fixture.appPath,
93
+ trialRecordDir: fixture.trialRecordDir,
94
+ });
95
+
96
+ assert.match(output, /mode: disposable/);
97
+ assert.match(output, /restore: ok/);
98
+ assert.equal(await readFile(fixture.targetPath, 'utf8'), fixture.targetContent);
99
+ } finally {
100
+ await rm(fixture.tempDir, { recursive: true, force: true });
101
+ }
102
+ });
103
+
104
+ function createInstallRecordFixture(
105
+ fixture: Awaited<ReturnType<typeof createFixtureApp>>,
106
+ overrides: Partial<InstallRecord> = {},
107
+ ): InstallRecord {
108
+ const now = '2026-05-30T00:00:00.000Z';
109
+ return {
110
+ installId: installIdForAppPath(fixture.appPath),
111
+ mode: 'real',
112
+ appPath: fixture.appPath,
113
+ cursorVersion: '3.5.38',
114
+ cursorCommit: '009bb5a3600dd98fe1c1f25798f767f686e14750',
115
+ targetRelativePath,
116
+ originalSha256: 'fixture-original-sha',
117
+ compatSupportStatus: 'supported',
118
+ runtimeFile: join(fixture.tempDir, 'runtime.json'),
119
+ backupDir: fixture.backupDir,
120
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
121
+ extensionState: 'bundled',
122
+ cliVersion: '0.0.0',
123
+ extensionVersion: '0.0.0',
124
+ serviceVersion: '0.0.0',
125
+ lastOperation: 'install',
126
+ lastOperationStatus: 'ok',
127
+ createdAt: now,
128
+ updatedAt: now,
129
+ ...overrides,
130
+ };
131
+ }
132
+
133
+ test('real-mode restore uses install record backup and keeps install record', async () => {
134
+ const fixture = await createFixtureApp();
135
+ const installRecordFile = join(fixture.tempDir, 'install.json');
136
+ const runtimeFile = join(fixture.tempDir, 'runtime.json');
137
+
138
+ try {
139
+ await patchCursorAgentExec(fixture.appPath, { backupDir: fixture.backupDir });
140
+ const record = createInstallRecordFixture(fixture, { runtimeFile });
141
+ await writeInstallRecord(record, { installRecordFile });
142
+
143
+ const output = await restore({
144
+ realAppPath: fixture.appPath,
145
+ installRecordFile,
146
+ yes: true,
147
+ });
148
+
149
+ assert.match(output, /mode: real/);
150
+ assert.match(output, /install-record: recorded/);
151
+ assert.match(output, /restore: ok/);
152
+ assert.equal(await readFile(fixture.targetPath, 'utf8'), fixture.targetContent);
153
+ assert.deepEqual(await readInstallRecord({ installRecordFile }), record);
154
+ } finally {
155
+ await rm(fixture.tempDir, { recursive: true, force: true });
156
+ }
157
+ });
158
+
159
+ test('real-mode restore rejects mismatched install record app path before mutation', async () => {
160
+ const fixture = await createFixtureApp();
161
+ const otherFixture = await createFixtureApp();
162
+ const installRecordFile = join(fixture.tempDir, 'install.json');
163
+
164
+ try {
165
+ await patchCursorAgentExec(fixture.appPath, { backupDir: fixture.backupDir });
166
+ const patchedBytes = await readFile(fixture.targetPath, 'utf8');
167
+ const wrongRecord = createInstallRecordFixture(otherFixture);
168
+ await writeInstallRecord(wrongRecord, { installRecordFile });
169
+
170
+ await assert.rejects(
171
+ restore({
172
+ realAppPath: fixture.appPath,
173
+ installRecordFile,
174
+ yes: true,
175
+ }),
176
+ new RegExp(`Install record does not match Cursor app: ${normalize(fixture.appPath)}`),
177
+ );
178
+
179
+ assert.equal(await readFile(fixture.targetPath, 'utf8'), patchedBytes);
180
+ assert.deepEqual(await readInstallRecord({ installRecordFile }), wrongRecord);
181
+ } finally {
182
+ await rm(fixture.tempDir, { recursive: true, force: true });
183
+ await rm(otherFixture.tempDir, { recursive: true, force: true });
184
+ }
185
+ });
186
+
187
+ test('real-mode restore rejects missing confirmation before mutation', async () => {
188
+ const fixture = await createFixtureApp();
189
+ const installRecordFile = join(fixture.tempDir, 'install.json');
190
+
191
+ try {
192
+ await patchCursorAgentExec(fixture.appPath, { backupDir: fixture.backupDir });
193
+ const patchedBytes = await readFile(fixture.targetPath, 'utf8');
194
+ const record = createInstallRecordFixture(fixture);
195
+ await writeInstallRecord(record, { installRecordFile });
196
+
197
+ await assert.rejects(
198
+ restore({
199
+ realAppPath: fixture.appPath,
200
+ installRecordFile,
201
+ isInteractive: false,
202
+ }),
203
+ /Pass --yes to confirm restore for real Cursor/,
204
+ );
205
+
206
+ assert.equal(await readFile(fixture.targetPath, 'utf8'), patchedBytes);
207
+ assert.deepEqual(await readInstallRecord({ installRecordFile }), record);
208
+ } finally {
209
+ await rm(fixture.tempDir, { recursive: true, force: true });
210
+ }
211
+ });
@@ -0,0 +1,135 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ import { readRuntimeInfo } from '../../service/src/runtime';
7
+ import { startServer } from '../../service/src/server';
8
+ import { startLocalServiceCommand, stopLocalServiceCommand } from '../src/serviceCommands';
9
+ import { writeClientConfig } from '../../shared/src/clientConfig';
10
+
11
+ test('startLocalServiceCommand starts the local service and reports runtime paths', async () => {
12
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-service-command-'));
13
+ const runtimeFile = join(tempDir, 'runtime.json');
14
+ const logFile = join(tempDir, 'service.log');
15
+
16
+ try {
17
+ const output = await startLocalServiceCommand({
18
+ runtimeFile,
19
+ serviceLogFile: logFile,
20
+ });
21
+ const runtime = await readRuntimeInfo({ runtimeFile });
22
+
23
+ assert.ok(runtime);
24
+ assert.match(output, /service: started 127\.0\.0\.1:\d+/);
25
+ assert.match(output, new RegExp(`runtime: ${runtimeFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
26
+ assert.match(output, new RegExp(`log: ${logFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
27
+ } finally {
28
+ await stopLocalServiceCommand({ runtimeFile });
29
+ await rm(tempDir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test('startLocalServiceCommand passes API base URL to detached service', async () => {
34
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-service-command-'));
35
+ const runtimeFile = join(tempDir, 'runtime.json');
36
+ const logFile = join(tempDir, 'service.log');
37
+ const calls: Record<string, unknown>[] = [];
38
+
39
+ try {
40
+ const output = await startLocalServiceCommand({
41
+ runtimeFile,
42
+ serviceLogFile: logFile,
43
+ apiBaseUrl: 'http://127.0.0.1:8765',
44
+ startDetachedService: async (options) => {
45
+ calls.push(options);
46
+ return { host: '127.0.0.1', port: 8766, runtimeId: 'runtime-1' };
47
+ },
48
+ });
49
+
50
+ assert.match(output, /service: started 127\.0\.0\.1:8766/);
51
+ assert.equal(calls[0]?.apiBaseUrl, 'http://127.0.0.1:8765');
52
+ } finally {
53
+ await rm(tempDir, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test('startLocalServiceCommand falls back to persisted API base URL', async () => {
58
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-service-command-'));
59
+ const runtimeFile = join(tempDir, 'runtime.json');
60
+ const logFile = join(tempDir, 'service.log');
61
+ const configFile = join(tempDir, 'client-config.json');
62
+ const calls: Record<string, unknown>[] = [];
63
+
64
+ try {
65
+ await writeClientConfig({ apiBaseUrl: 'https://platform.example.test' }, { configFile });
66
+ const output = await startLocalServiceCommand({
67
+ runtimeFile,
68
+ serviceLogFile: logFile,
69
+ configFile,
70
+ startDetachedService: async (options) => {
71
+ calls.push(options);
72
+ return { host: '127.0.0.1', port: 8766, runtimeId: 'runtime-1' };
73
+ },
74
+ });
75
+
76
+ assert.match(output, /service: started 127\.0\.0\.1:8766/);
77
+ assert.equal(calls[0]?.apiBaseUrl, 'https://platform.example.test');
78
+ } finally {
79
+ await rm(tempDir, { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ test('startLocalServiceCommand reports already-running without restarting a healthy service', async () => {
84
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-service-command-'));
85
+ const runtimeFile = join(tempDir, 'runtime.json');
86
+ const logFile = join(tempDir, 'service.log');
87
+ const service = await startServer({ runtimeFile });
88
+
89
+ try {
90
+ const output = await startLocalServiceCommand({
91
+ runtimeFile,
92
+ serviceLogFile: logFile,
93
+ startDetachedService: async () => {
94
+ throw new Error('must not restart a healthy service');
95
+ },
96
+ });
97
+
98
+ assert.match(output, new RegExp(`service: already-running ${service.host}:${service.port}`));
99
+ assert.match(output, new RegExp(`runtime: ${runtimeFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
100
+ assert.match(output, new RegExp(`log: ${logFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
101
+ } finally {
102
+ await service.stop();
103
+ await rm(tempDir, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ test('stopLocalServiceCommand stops a healthy local service', async () => {
108
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-service-command-'));
109
+ const runtimeFile = join(tempDir, 'runtime.json');
110
+ await startServer({ runtimeFile });
111
+
112
+ try {
113
+ const output = await stopLocalServiceCommand({ runtimeFile });
114
+
115
+ assert.match(output, /service: stopped/);
116
+ assert.match(output, new RegExp(`runtime: ${runtimeFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
117
+ } finally {
118
+ await stopLocalServiceCommand({ runtimeFile });
119
+ await rm(tempDir, { recursive: true, force: true });
120
+ }
121
+ });
122
+
123
+ test('stopLocalServiceCommand is idempotent when no service is running', async () => {
124
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-service-command-'));
125
+ const runtimeFile = join(tempDir, 'runtime.json');
126
+
127
+ try {
128
+ const output = await stopLocalServiceCommand({ runtimeFile });
129
+
130
+ assert.match(output, /service: not-running/);
131
+ assert.match(output, new RegExp(`runtime: ${runtimeFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
132
+ } finally {
133
+ await rm(tempDir, { recursive: true, force: true });
134
+ }
135
+ });
@@ -0,0 +1,280 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import test from 'node:test';
7
+ import { readRuntimeInfo } from '../../service/src/runtime';
8
+ import { startServer } from '../../service/src/server';
9
+ import { startDetachedService, stopRuntimeService, waitForRuntimeHealth } from '../src/serviceProcess';
10
+
11
+ const cliRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
12
+
13
+ test('startDetachedService starts a service discoverable by runtime file', async () => {
14
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-detached-service-'));
15
+ const runtimeFile = join(tempDir, 'runtime.json');
16
+ const logFile = join(tempDir, 'service.log');
17
+
18
+ try {
19
+ const started = await startDetachedService({ runtimeFile, logFile });
20
+ assert.equal(started.host, '127.0.0.1');
21
+ assert.equal(typeof started.port, 'number');
22
+
23
+ const runtime = await readRuntimeInfo({ runtimeFile });
24
+ assert.deepEqual(runtime, started);
25
+ assert.equal(await waitForRuntimeHealth(runtimeFile), true);
26
+
27
+ assert.equal(await stopRuntimeService(runtimeFile), true);
28
+ assert.equal(await waitForRuntimeHealth(runtimeFile), false);
29
+ await stat(logFile);
30
+ } finally {
31
+ await stopRuntimeService(runtimeFile);
32
+ await rm(tempDir, { recursive: true, force: true });
33
+ }
34
+ });
35
+
36
+ test('startDetachedService reuses an existing healthy runtime instead of starting another service', async () => {
37
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-detached-service-reuse-'));
38
+ const runtimeFile = join(tempDir, 'runtime.json');
39
+ const logFile = join(tempDir, 'service.log');
40
+ const existingService = await startServer({ runtimeFile });
41
+
42
+ try {
43
+ const started = await startDetachedService({
44
+ runtimeFile,
45
+ logFile,
46
+ serviceEntry: join(tempDir, 'missing-entry.ts'),
47
+ startupTimeoutMs: 250,
48
+ });
49
+
50
+ assert.deepEqual(started, {
51
+ host: existingService.host,
52
+ port: existingService.port,
53
+ runtimeId: existingService.runtimeId,
54
+ });
55
+ assert.deepEqual(await readRuntimeInfo({ runtimeFile }), started);
56
+ } finally {
57
+ await existingService.stop();
58
+ await stopRuntimeService(runtimeFile);
59
+ await rm(tempDir, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test('startDetachedService coalesces concurrent starts for the same runtime file', async () => {
64
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-detached-service-concurrent-'));
65
+ const runtimeFile = join(tempDir, 'runtime.json');
66
+ const logFile = join(tempDir, 'service.log');
67
+
68
+ try {
69
+ const [first, second] = await Promise.all([
70
+ startDetachedService({ runtimeFile, logFile }),
71
+ startDetachedService({ runtimeFile, logFile }),
72
+ ]);
73
+
74
+ assert.deepEqual(first, second);
75
+ assert.deepEqual(await readRuntimeInfo({ runtimeFile }), first);
76
+
77
+ const log = await readFile(logFile, 'utf8');
78
+ const runtimeIds = new Set(
79
+ log
80
+ .split('\n')
81
+ .filter((line) => line.trim().startsWith('{'))
82
+ .map((line) => JSON.parse(line) as { runtimeId?: unknown })
83
+ .map((runtime) => runtime.runtimeId)
84
+ .filter((runtimeId): runtimeId is string => typeof runtimeId === 'string'),
85
+ );
86
+ assert.deepEqual([...runtimeIds], [first.runtimeId]);
87
+ } finally {
88
+ await stopRuntimeService(runtimeFile);
89
+ await rm(tempDir, { recursive: true, force: true });
90
+ }
91
+ });
92
+
93
+ test('startDetachedService default entry works from package-local cwd', async () => {
94
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-detached-service-cwd-'));
95
+ const runtimeFile = join(tempDir, 'runtime.json');
96
+ const logFile = join(tempDir, 'service.log');
97
+ const previousCwd = process.cwd();
98
+ process.chdir(cliRoot);
99
+
100
+ try {
101
+ const started = await startDetachedService({ runtimeFile, logFile });
102
+ assert.equal(started.host, '127.0.0.1');
103
+ assert.equal(await waitForRuntimeHealth(runtimeFile), true);
104
+ assert.equal(await stopRuntimeService(runtimeFile), true);
105
+ } finally {
106
+ process.chdir(previousCwd);
107
+ await stopRuntimeService(runtimeFile);
108
+ await rm(tempDir, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ test('startDetachedService reuses an existing healthy runtime even when the requested entry is missing', async () => {
113
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-detached-service-'));
114
+ const runtimeFile = join(tempDir, 'runtime.json');
115
+ const logFile = join(tempDir, 'service.log');
116
+ const staleService = await startServer({ runtimeFile });
117
+
118
+ try {
119
+ const started = await startDetachedService({
120
+ runtimeFile,
121
+ logFile,
122
+ serviceEntry: join(tempDir, 'missing-entry.ts'),
123
+ startupTimeoutMs: 250,
124
+ });
125
+
126
+ const runtime = await readRuntimeInfo({ runtimeFile });
127
+ assert.deepEqual(runtime, started);
128
+ assert.deepEqual(started, {
129
+ host: staleService.host,
130
+ port: staleService.port,
131
+ runtimeId: staleService.runtimeId,
132
+ });
133
+ } finally {
134
+ await staleService.stop();
135
+ await stopRuntimeService(runtimeFile);
136
+ await rm(tempDir, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test('startDetachedService reuses an existing healthy tilde runtime', async () => {
141
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-detached-service-'));
142
+ const originalHome = process.env.HOME;
143
+ process.env.HOME = tempDir;
144
+ const runtimeFile = '~/.cursor-pool/runtime.json';
145
+ const logFile = join(tempDir, 'service.log');
146
+ const staleService = await startServer({ runtimeFile });
147
+
148
+ try {
149
+ const started = await startDetachedService({
150
+ runtimeFile,
151
+ logFile,
152
+ serviceEntry: join(tempDir, 'missing-entry.ts'),
153
+ startupTimeoutMs: 250,
154
+ });
155
+
156
+ const runtime = await readRuntimeInfo({ runtimeFile });
157
+ assert.deepEqual(runtime, started);
158
+ assert.deepEqual(started, {
159
+ host: staleService.host,
160
+ port: staleService.port,
161
+ runtimeId: staleService.runtimeId,
162
+ });
163
+ } finally {
164
+ await staleService.stop();
165
+ await stopRuntimeService(runtimeFile);
166
+ if (originalHome === undefined) {
167
+ delete process.env.HOME;
168
+ } else {
169
+ process.env.HOME = originalHome;
170
+ }
171
+ await rm(tempDir, { recursive: true, force: true });
172
+ }
173
+ });
174
+
175
+ test('stopRuntimeService does not stop service when runtimeId differs', async () => {
176
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-detached-service-'));
177
+ const runtimeFile = join(tempDir, 'runtime.json');
178
+ const service = await startServer({ runtimeFile });
179
+
180
+ try {
181
+ await writeFile(
182
+ runtimeFile,
183
+ `${JSON.stringify({
184
+ host: service.host,
185
+ port: service.port,
186
+ runtimeId: 'wrong-runtime-id',
187
+ })}\n`,
188
+ 'utf8',
189
+ );
190
+
191
+ assert.equal(await stopRuntimeService(runtimeFile), false);
192
+
193
+ const health = await fetch(`http://${service.host}:${service.port}/health`);
194
+ assert.equal(health.ok, true);
195
+ const body = (await health.json()) as { ok?: unknown; runtimeId?: unknown };
196
+ assert.equal(body.ok, true);
197
+ assert.equal(body.runtimeId, service.runtimeId);
198
+ } finally {
199
+ await service.stop();
200
+ await rm(tempDir, { recursive: true, force: true });
201
+ }
202
+ });
203
+
204
+ test('startDetachedService rejects spawn errors with context', async () => {
205
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-detached-service-'));
206
+
207
+ try {
208
+ await assert.rejects(
209
+ startDetachedService({
210
+ runtimeFile: join(tempDir, 'runtime.json'),
211
+ logFile: join(tempDir, 'service.log'),
212
+ command: join(tempDir, 'missing-command'),
213
+ startupTimeoutMs: 250,
214
+ }),
215
+ /Failed to start detached service process/,
216
+ );
217
+ } finally {
218
+ await rm(tempDir, { recursive: true, force: true });
219
+ }
220
+ });
221
+
222
+ test('startDetachedService terminates a non-starting process on timeout', async () => {
223
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-detached-service-'));
224
+ const runtimeFile = join(tempDir, 'runtime.json');
225
+ const entryFile = join(tempDir, 'hanging-entry.ts');
226
+ const pidFile = join(tempDir, 'service.pid');
227
+
228
+ await writeFile(
229
+ entryFile,
230
+ `import { writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(pidFile)}, String(process.pid));\nsetInterval(() => {}, 1000);\n`,
231
+ 'utf8',
232
+ );
233
+
234
+ try {
235
+ await assert.rejects(
236
+ startDetachedService({
237
+ runtimeFile,
238
+ logFile: join(tempDir, 'service.log'),
239
+ serviceEntry: entryFile,
240
+ startupTimeoutMs: 500,
241
+ }),
242
+ /Detached service failed to become healthy/,
243
+ );
244
+
245
+ const pid = Number(await import('node:fs/promises').then(({ readFile }) => readFile(pidFile, 'utf8')));
246
+ assert.ok(Number.isInteger(pid));
247
+ await assert.rejects(
248
+ import('node:process').then(({ kill }) => kill(pid, 0)),
249
+ /ESRCH/,
250
+ );
251
+ } finally {
252
+ await stopRuntimeService(runtimeFile);
253
+ await rm(tempDir, { recursive: true, force: true });
254
+ }
255
+ });
256
+
257
+ test('startDetachedService expands tilde in logFile', async () => {
258
+ const tempDir = await mkdtemp(join(tmpdir(), 'cursor-pool-detached-service-'));
259
+ const originalHome = process.env.HOME;
260
+ process.env.HOME = tempDir;
261
+ const runtimeFile = join(tempDir, 'runtime.json');
262
+
263
+ try {
264
+ await startDetachedService({
265
+ runtimeFile,
266
+ logFile: '~/cursor-pool/service.log',
267
+ });
268
+
269
+ await stat(join(tempDir, 'cursor-pool/service.log'));
270
+ assert.equal(await stopRuntimeService(runtimeFile), true);
271
+ } finally {
272
+ await stopRuntimeService(runtimeFile);
273
+ if (originalHome === undefined) {
274
+ delete process.env.HOME;
275
+ } else {
276
+ process.env.HOME = originalHome;
277
+ }
278
+ await rm(tempDir, { recursive: true, force: true });
279
+ }
280
+ });